diff --git a/.changeset/messaging-inbox-read-authenticated-caller.md b/.changeset/messaging-inbox-read-authenticated-caller.md new file mode 100644 index 0000000000..752bec6948 --- /dev/null +++ b/.changeset/messaging-inbox-read-authenticated-caller.md @@ -0,0 +1,13 @@ +--- +"@objectstack/service-messaging": minor +--- + +**Feature:** `MessagingService` gains the plugin-facing inbox **read** door scoped to the authenticated caller — `listInboxAsCaller(caller, opts)` (#11452), completing the axis the write door (`markReadAsCaller` / `markAllReadAsCaller`, #10753) established. + +`listInbox(userId, opts)` is the REST door's contract method (`INotificationService.listInbox?`), and on that path its `userId` is trustworthy because `runtime/src/domains/notifications.ts` binds it to an already-authenticated session and answers 401 when there is none. But the service is also a kernel service, and the kernel hands every plugin one shared `PluginContext` whose `getService` carries no caller identity — so for an in-process caller that same parameter is a free string, and the read lands context-lessly on an `engine-owned` object (ADR-0103), so no engine permission check sees it either. Any plugin could read any user's inbox titles, bodies and read-state — the exact shape the write door closed, on the arguably more sensitive half: inbox bodies carry rendered business content. + +`listInboxAsCaller` takes **no target user at all**. The recipient is derived from the caller's `ExecutionContext.userId` through the same `resolveInboxRecipient` the write door uses — one refusal vocabulary, not two. `attributedUserId` (attribution only, ADR-0118 D2), `actor` (a service-principal label) and `isSystem` are refused rather than promoted, with `InboxCallerError` carrying the ADR-0112 envelope pair a boundary reads — `status: 401`, registered `code: 'UNAUTHENTICATED'`. The refusal is evaluated **before** `listInbox`'s no-data-engine / no-user short-circuit, which answers a well-formed empty `{ notifications: [], unreadCount: 0 }` inbox — the read-side analog of the silent success the write door replaced. The options window (`read` / `type` / `limit`) is forwarded unchanged. + +Honest about what it is, same as the write door: a **discipline** boundary, not a security boundary. An in-process plugin already holds the data engine and can read `sys_inbox_message` rows directly; nothing at this layer stops trusted code that means to. What changes is that the correct pattern is the only one the plugin-facing surface expresses, and the incorrect one now fails loudly at the call site. + +Nothing existing changes behaviour: `listInbox` / `markRead` / `markAllRead` keep their signatures (they are the published `INotificationService` contract the REST door needs), and no schema, column or object declaration moves. `resolveInboxRecipient` gains an optional third parameter naming the target-user door its refusal prescribes; it defaults to the write door's existing text, so existing call sites keep their refusal bytes unchanged. diff --git a/packages/services/service-messaging/src/inbox-caller.ts b/packages/services/service-messaging/src/inbox-caller.ts index 5988335501..eae5b0b8f3 100644 --- a/packages/services/service-messaging/src/inbox-caller.ts +++ b/packages/services/service-messaging/src/inbox-caller.ts @@ -1,8 +1,8 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * Authenticated-caller scoping for the plugin-facing inbox write surface - * (ADR-0030 Layer 5). + * Authenticated-caller scoping for the plugin-facing inbox surface + * (ADR-0030 Layer 5) — the write door (#10753) and the read door (#11452). * * ## The shape this closes * @@ -22,6 +22,11 @@ * permission check sees it either. Unconstrained and undeclared, in both * directions. * + * The READ side has the same shape (#11452): `listInbox(userId, opts)` keys + * its whole read — inbox rows joined with read-state — on the same free + * parameter, so an in-process caller could read ANY user's inbox titles, + * bodies and read-state. Both doors resolve their recipient here. + * * The verbs here are the plugin-facing door, and the whole design is that they * take NO target user. The recipient is DERIVED from the caller's * {@link ExecutionContext}, so "mark someone else's inbox read" has no @@ -66,7 +71,9 @@ * What this does is make the CORRECT pattern the only one the plugin-facing * surface expresses, and make the incorrect one fail loudly at the call site * instead of silently succeeding — which is the failure mode measured on the - * existing path, where an absent caller returns `{ success: true, readCount: 0 }`. + * existing path, where an absent caller returns `{ success: true, readCount: 0 }` + * (the read path's analog: a well-formed empty + * `{ notifications: [], unreadCount: 0 }` inbox). */ import type { ExecutionContext } from '@objectstack/spec/kernel'; @@ -83,7 +90,8 @@ import type { ExecutionContext } from '@objectstack/spec/kernel'; export type InboxCaller = ExecutionContext; /** - * The plugin-facing inbox write refusal. Carries the ADR-0112 envelope pair a + * The plugin-facing inbox refusal — write and read doors alike. Carries the + * ADR-0112 envelope pair a * boundary reads — `status` + a registered `code` — so a caller that surfaces * it over HTTP answers `401 UNAUTHENTICATED` rather than the `500 * INTERNAL_ERROR` a bare `Error` demotes to (`resolveThrownHttpError`, @@ -107,7 +115,7 @@ export class InboxCallerError extends Error { } /** - * The recipient a plugin-facing inbox write acts on: the caller's + * The recipient a plugin-facing inbox call acts on: the caller's * authenticated `userId`, or a refusal. * * Never returns a guess. Never falls back to `attributedUserId` / `actor` / @@ -116,9 +124,18 @@ export class InboxCallerError extends Error { * * @param caller The caller's execution context (`undefined` is a refusal). * @param verb The plugin-facing method name, so the refusal names the call. + * @param targetUserDoor The contract method a caller that legitimately must + * NAME a target user still has, quoted in the refusal so the + * prescription matches the verb — `listInbox(userId, opts)` for + * the read door. Defaults to the write door's existing text, so + * the #10753 call sites keep their refusal bytes unchanged. * @throws {InboxCallerError} when no authenticated user can be resolved. */ -export function resolveInboxRecipient(caller: InboxCaller | undefined, verb: string): string { +export function resolveInboxRecipient( + caller: InboxCaller | undefined, + verb: string, + targetUserDoor = 'markRead(userId, ids)', +): string { const userId = typeof caller?.userId === 'string' ? caller.userId.trim() : ''; if (userId) return userId; @@ -136,6 +153,6 @@ export function resolveInboxRecipient(caller: InboxCaller | undefined, verb: str throw new InboxCallerError( `messaging: ${verb} requires an authenticated caller — no 'userId' on the execution context.${detail}. ` + `This surface acts only on the CALLER'S OWN inbox and takes no target user; ` - + `a system/background sweep that must name one uses markRead(userId, ids).`, + + `a system/background sweep that must name one uses ${targetUserDoor}.`, ); } diff --git a/packages/services/service-messaging/src/messaging-service.test.ts b/packages/services/service-messaging/src/messaging-service.test.ts index 10622c5efd..32c2288004 100644 --- a/packages/services/service-messaging/src/messaging-service.test.ts +++ b/packages/services/service-messaging/src/messaging-service.test.ts @@ -1195,3 +1195,129 @@ describe('MessagingService — plugin-facing inbox writes scoped to the authenti }); }); }); + +/** + * [#11452] The plugin-facing inbox READ door. + * + * The measured BEFORE, the read-side sibling of #10753: the messaging service + * is registered as a kernel service and the kernel hands every plugin ONE + * shared `PluginContext` whose `getService` carries no caller identity, so + * `listInbox(userId, opts)`'s first parameter is a free string for an + * in-process caller — any plugin could read ANY user's inbox titles, bodies + * and read-state, with the read landing context-lessly on an `engine-owned` + * object so no engine permission check saw it either. + * + * `listInboxAsCaller` takes no target user at all. These pin that the + * recipient comes from the caller's authenticated `userId` and from NOTHING + * that merely resembles one, and that the options window is forwarded + * unchanged. + */ +describe('MessagingService — plugin-facing inbox read scoped to the authenticated caller (#11452)', () => { + const logger = silentLogger(); + + /** u1 and u2 each hold one unread message, so cross-user reach is visible. */ + function twoUserInbox() { + return inboxEngine({ + inbox: [ + { id: 'm1', user_id: 'u1', notification_id: 'n1', topic: 'approval.request', title: 'Approve me', body_md: 'u1 body', created_at: '2026-01-01T00:00:01Z' }, + { id: 'm2', user_id: 'u2', notification_id: 'n2', topic: 'approval.request', title: 'Approve me too', body_md: 'u2 body', created_at: '2026-01-01T00:00:02Z' }, + ], + receipts: [ + { id: 'r1', notification_id: 'n1', user_id: 'u1', channel: 'inbox', state: 'delivered' }, + { id: 'r2', notification_id: 'n2', user_id: 'u2', channel: 'inbox', state: 'delivered' }, + ], + }); + } + + it("lists the CALLER'S own inbox and nothing else", async () => { + const svc = new MessagingService({ logger, getData: () => twoUserInbox() }); + + const res = await svc.listInboxAsCaller({ userId: 'u1' }); + + expect(res.notifications.map((n) => n.id)).toEqual(['n1']); + expect(res.unreadCount).toBe(1); + // u2's rendered content appears nowhere in the answer, in any field. + expect(JSON.stringify(res)).not.toContain('Approve me too'); + expect(JSON.stringify(res)).not.toContain('u2 body'); + }); + + it('forwards the window/filter options unchanged — the same read listInbox performs', async () => { + const engine = inboxEngine({ + inbox: [ + { id: 'm1', user_id: 'u1', notification_id: 'n1', topic: 'a', title: 'A', created_at: '2026-01-01T00:00:01Z' }, + { id: 'm2', user_id: 'u1', notification_id: 'n2', topic: 'b', title: 'B', created_at: '2026-01-01T00:00:02Z' }, + { id: 'm3', user_id: 'u1', notification_id: 'n3', topic: 'b', title: 'C', created_at: '2026-01-01T00:00:03Z' }, + ], + receipts: [ + { id: 'r1', notification_id: 'n1', user_id: 'u1', channel: 'inbox', state: 'read' }, + ], + }); + const svc = new MessagingService({ logger, getData: () => engine }); + + // Same engine, same opts, both doors: the answers must be identical. + const viaCaller = await svc.listInboxAsCaller({ userId: 'u1' }, { type: 'b', limit: 1 }); + const viaContract = await svc.listInbox('u1', { type: 'b', limit: 1 }); + expect(viaCaller).toEqual(viaContract); + expect(viaCaller.notifications).toHaveLength(1); + + const unreadOnly = await svc.listInboxAsCaller({ userId: 'u1' }, { read: false }); + expect(unreadOnly.notifications.map((n) => n.id).sort()).toEqual(['n2', 'n3']); + }); + + describe('refusals — the ADR-0112 envelope, not a silent empty inbox', () => { + const svc = () => new MessagingService({ logger, getData: () => twoUserInbox() }); + + /** Assert the declared envelope pair a boundary reads: `status` + `code`. */ + async function expectRefusal(run: () => Promise): Promise { + const err = await run().then( + () => { throw new Error('expected InboxCallerError, but the call resolved'); }, + (e: unknown) => e as InboxCallerError, + ); + expect(err).toBeInstanceOf(InboxCallerError); + expect(err.code).toBe('UNAUTHENTICATED'); + expect(err.status).toBe(401); + return err; + } + + it('refuses an absent context, an empty one, and a blank userId', async () => { + await expectRefusal(() => svc().listInboxAsCaller(undefined)); + await expectRefusal(() => svc().listInboxAsCaller({})); + await expectRefusal(() => svc().listInboxAsCaller({ userId: ' ' })); + }); + + it('refuses a context carrying only attributedUserId — attribution never becomes authorization', async () => { + // Same invariant the write door pinned (ADR-0118 D2): a + // `userId ?? attributedUserId` fallback would read as working and + // hand a plugin the wrong person's inbox. + const err = await expectRefusal(() => svc().listInboxAsCaller({ attributedUserId: 'u1' })); + expect(err.message).toContain('attributedUserId'); + }); + + it('refuses a service-principal label and a system context — neither owns an inbox', async () => { + await expectRefusal(() => svc().listInboxAsCaller({ actor: 'svc:flow:nightly' })); + await expectRefusal(() => svc().listInboxAsCaller({ isSystem: true })); + }); + + it('prescribes the READ contract door, not the write one', async () => { + // The refusal names the target-user door a legitimate named-target + // caller still has. For this verb that is `listInbox(userId, opts)` + // — sending a refused reader to `markRead` would be a wrong + // prescription wearing the right envelope. + const err = await expectRefusal(() => svc().listInboxAsCaller(undefined)); + expect(err.message).toContain('listInboxAsCaller'); + expect(err.message).toContain('listInbox(userId, opts)'); + expect(err.message).not.toContain('markRead(userId, ids)'); + }); + + it('refuses BEFORE the no-data-engine / no-user short-circuit', async () => { + // `listInbox` answers `{ notifications: [], unreadCount: 0 }` for a + // missing engine or blank user — a well-formed "empty inbox" that + // reads as an answer. An unauthenticated in-process caller must get + // the refusal, never that envelope: the read-side analog of the + // silent success the write door replaced, and the reason the order + // is pinned rather than left to reading. + await expectRefusal(() => new MessagingService({ logger }).listInboxAsCaller(undefined)); + await expectRefusal(() => new MessagingService({ logger }).listInboxAsCaller({})); + }); + }); +}); diff --git a/packages/services/service-messaging/src/messaging-service.ts b/packages/services/service-messaging/src/messaging-service.ts index 2d41b23898..ace6fc2b6a 100644 --- a/packages/services/service-messaging/src/messaging-service.ts +++ b/packages/services/service-messaging/src/messaging-service.ts @@ -366,6 +366,21 @@ export class MessagingService { * the inbox does not mean the badge is zero. A `type` filter does — the * count answers the query that was asked, as it always has. * + * **This is the REST door's method** — `INotificationService.listInbox?`, + * called by `runtime/src/domains/notifications.ts`, which binds `userId` + * to `context.executionContext.userId` after answering 401 for a request + * that has none. The target user is a declared parameter here *because* + * that door has already authenticated it. + * + * An IN-PROCESS caller has no such door in front of it, and for that + * caller the parameter is simply a free string — the "any plugin can read + * any user's inbox" shape (#11452), the read-side sibling of the one + * {@link markReadAsCaller} closed for writes. Plugins use + * {@link listInboxAsCaller}, which derives the recipient from the + * caller's execution context and has no target-user parameter to get + * wrong. This signature stays as it is: it is the published + * `INotificationService` contract, and the REST door needs exactly it. + * * Returns the REST contract shape: `{ notifications, unreadCount }`. */ async listInbox( @@ -415,6 +430,39 @@ export class MessagingService { return { notifications, unreadCount }; } + /** + * List **the calling user's own inbox** — the plugin-facing counterpart to + * {@link listInbox}, on the same authenticated-caller axis as + * {@link markReadAsCaller} / {@link markAllReadAsCaller} (#11452; the + * write door is #10753). + * + * Takes no target user at all: the recipient is derived from the caller's + * execution context, so "read someone else's inbox" has no spelling on + * this surface — it is unrepresentable rather than merely discouraged. + * See `inbox-caller.ts` for the full rationale, including why + * `attributedUserId` / `actor` / `isSystem` are refused rather than + * accepted as fallbacks. + * + * Refuses (`InboxCallerError`, 401 `UNAUTHENTICATED`) when the context + * names no authenticated user. **The refusal is evaluated FIRST**, before + * {@link listInbox}'s no-data-engine / no-user short-circuit: that answers + * a well-formed empty `{ notifications: [], unreadCount: 0 }` envelope, + * and letting an unauthenticated call reach it would hand back an "empty + * inbox" that reads as an answer for a read that was never authorized — + * the read-side analog of the silent success the write door replaced. + * + * @param caller The caller's execution context. The recipient is read + * from its `userId` and from nothing else. + * @param opts Same window/filter options as {@link listInbox}, forwarded + * unchanged. + */ + async listInboxAsCaller( + caller: InboxCaller | undefined, + opts: { read?: boolean; type?: string; limit?: number } = {}, + ): Promise<{ notifications: InboxNotificationView[]; unreadCount: number }> { + return this.listInbox(resolveInboxRecipient(caller, 'listInboxAsCaller', 'listInbox(userId, opts)'), opts); + } + /** * Total unread across the user's whole matching inbox — the reverse join * `unreadCount` is declared to answer (#6363).