Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/messaging-inbox-read-authenticated-caller.md
Original file line numberDiff line numberDiff line change
@@ -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.
31 changes: 24 additions & 7 deletions packages/services/service-messaging/src/inbox-caller.ts
Original file line numberDiff line numberDiff line change
@@ -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
*
Expand All@@ -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
Expand DownExpand Up@@ -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';
Expand All@@ -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`,
Expand All@@ -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` /
Expand All@@ -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;

Expand All@@ -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}.`,
);
}
126 changes: 126 additions & 0 deletions packages/services/service-messaging/src/messaging-service.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<unknown>): Promise<InboxCallerError> {
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({}));
});
});
});
48 changes: 48 additions & 0 deletions packages/services/service-messaging/src/messaging-service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
Expand DownExpand Up@@ -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).
Expand Down
Loading