From 7194ca951d48fc56febdfb8b7670d04b4edf23c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 06:41:16 +0000 Subject: [PATCH 1/6] test(plugin-approvals): pins for the action-link token object on the generic doors Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude --- ...val-token-generic-door.integration.test.ts | 237 ++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 packages/plugins/plugin-approvals/src/sys-approval-token-generic-door.integration.test.ts diff --git a/packages/plugins/plugin-approvals/src/sys-approval-token-generic-door.integration.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-token-generic-door.integration.test.ts new file mode 100644 index 00000000000..90a5a7efbe8 --- /dev/null +++ b/packages/plugins/plugin-approvals/src/sys-approval-token-generic-door.integration.test.ts @@ -0,0 +1,237 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22616] `sys_approval_token` — the action-link tokens of ADR-0043 — is + * served by the GENERIC doors to no caller, as the approvals door serves it to + * nobody. The third request-keyed table of the approval engine, after the + * request itself (#22559) and its two child tables (#22589). + * + * ## Why no caller, and not "the participants" + * + * One operation, two implementations: the approvals door never reads a token + * row back to anyone — the engine mints, looks up by digest and consumes them, + * all as the system — while the generic doors served a row to any caller an + * application granted read on the object. The governed implementation wins, + * and the object's own declaration already said "never via the data API". So + * the declaration now says it in the form every door enforces: + * + * - `apiEnabled: false` — the declared off switch of the automatic API + * (`ObjectCapabilities.apiEnabled`, "Expose object via automatic APIs"). + * `apiExposureDenialReason` judges it FIRST and for every operation, so the + * REST door, the dispatcher and MCP all refuse every verb, for every caller, + * with `404 OBJECT_API_DISABLED`; and it is the one flag the cross-object + * search reads (`searchAll` skips `apiEnabled === false`), which never + * consults `apiMethods` at all. + * - `apiMethods: []` — `deny-all` in the three-state table of + * `api-derivation.ts` (`undefined` = fully open, `[]` = fully closed). The + * read methods are retired rather than the key dropped: an ABSENT whitelist + * resolves to `unrestricted`, so dropping it would leave the whitelist + * advertising every operation behind the switch. + * + * ⛔ No visibility rule for tokens is invented here (no participant rule, no + * approver-sees-own-tokens rule): nothing reads this object through a generic + * door, so there is no reader to keep. + * + * ## What each block pins + * + * - the declaration, and the decision every door delegates to: every + * operation refused with the `api-disabled` discriminant. The function takes + * no caller, so the answer is the same for a member the application granted + * read on the object and for an administrator; + * - the cross-object search, on a real engine with token rows present: no + * token row for any caller, while the search itself still answers; + * - positive control: a link minted by `remind()` still peeks, redeems once and + * is refused as consumed on replay — the engine's mint, lookup-by-digest and + * consume path runs as the system and is untouched by the door. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { + API_OPERATION_ORDER, + apiExposureDenialReason, + canServeApiOperation, + resolveEffectiveApiMethods, + type EnableLike, + type EngineQueryOptions, +} from '@objectstack/spec/data'; +import { REGISTERED_ERROR_CODES } from '@objectstack/spec/api'; +import type { ApprovalService } from './approval-service.js'; +import { ApprovalsServicePlugin } from './approvals-plugin.js'; +import { SysApprovalRequest } from './sys-approval-request.object.js'; +import { SysApprovalAction } from './sys-approval-action.object.js'; +import { SysApprovalApprover } from './sys-approval-approver.object.js'; +import { SysApprovalDelegation } from './sys-approval-delegation.object.js'; +import { SysApprovalToken } from './sys-approval-token.object.js'; + +const TOKEN = 'sys_approval_token'; +const OBJECT = 'tokdoor_sheet'; +const ENABLE = SysApprovalToken.enable as EnableLike; + +const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any; +const asUser = (userId: string) => ({ userId, positions: [], permissions: [] }) as any; +const ADMIN = { userId: 'u_admin', positions: [], permissions: [], posture: 'PLATFORM_ADMIN' } as any; + +const SUBMITTER = 'u_submitter'; +const APPROVER = 'u_approver'; +/** Takes part in no request; holds read on every object (no security plugin is mounted). */ +const MEMBER = 'u_member'; + +describe('the declaration: the generic doors serve token rows to no caller', () => { + it('is not exposed through the automatic API, and its whitelist grants nothing', () => { + expect(ENABLE?.apiEnabled).toBe(false); + expect(ENABLE?.apiMethods).toEqual([]); + expect(resolveEffectiveApiMethods(ENABLE).mode).toBe('deny-all'); + }); + + it('every operation — list, count, by id, query, search, export, and every write — is refused as api-disabled, for every caller', () => { + // `apiExposureDenialReason` is the decision the REST door + // (`apiAccessDenialFromEnable`), the dispatcher and the MCP bridge all turn + // into their refusal; it takes no user, so this is the granted member's + // answer and the administrator's alike. + expect(API_OPERATION_ORDER.length).toBeGreaterThanOrEqual(14); + const served = API_OPERATION_ORDER.filter((op) => apiExposureDenialReason(ENABLE, op) !== 'api-disabled'); + expect(served).toEqual([]); + for (const child of ['create', 'update', 'delete', 'upsert']) { + expect(apiExposureDenialReason(ENABLE, 'bulk', { bulkChild: child }), child).toBe('api-disabled'); + } + expect(API_OPERATION_ORDER.filter((op) => canServeApiOperation(ENABLE, op))).toEqual([]); + // The REST door's envelope for this discriminant is `404 OBJECT_API_DISABLED` + // (`@objectstack/rest`, which this package does not depend on); the code is + // registered vocabulary, so a rename cannot pass silently on the spec side. + expect(REGISTERED_ERROR_CODES).toContain('OBJECT_API_DISABLED'); + }); +}); + +interface Rig { + engine: ObjectQL; + svc: ApprovalService; + protocol: any; + links: Array<{ label: string; url: string }>; + requestId: string; +} + +let current: ObjectQL | undefined; + +afterEach(async () => { + try { await current?.destroy(); } catch { /* noop */ } + current = undefined; +}); + +/** A real engine, the real plugin, one pending request and the links one reminder minted. */ +async function boot(): Promise { + const engine = new ObjectQL(); + current = engine; + engine.registerDriver(new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }), true); + await engine.init(); + const sheet = { + name: OBJECT, + label: 'Token Door Sheet', + fields: { + id: { name: 'id', label: 'Id', type: 'text' as const, primaryKey: true }, + title: { name: 'title', label: 'Title', type: 'text' as const }, + }, + }; + for (const def of [sheet, SysApprovalRequest, SysApprovalAction, SysApprovalApprover, SysApprovalDelegation, SysApprovalToken]) { + engine.registry.registerObject(def as any, 'approvals-token-door-test', 'approvals-token-door-test'); + } + await engine.syncSchemas(); + await engine.insert(OBJECT, { id: 'sheet_1', title: 'tokdoor ledger sheet' }, { context: SYSTEM } as any); + + // The delivered message is where a raw link exists at all: storage holds its digest only. + const links: Rig['links'] = []; + const services: Record = { + objectql: engine, + messaging: { + emit: async (event: any) => { + for (const action of event?.payload?.actions ?? []) { + if (typeof action?.url === 'string') links.push({ label: String(action.label), url: action.url }); + } + }, + }, + }; + const ctx: any = { + getService: (name: string) => { + if (!(name in services)) throw new Error(`[Kernel] Service '${name}' not found`); + return services[name]; + }, + registerService: (name: string, svc: unknown) => { services[name] = svc; }, + hook: () => {}, + logger: { info: () => {}, debug: () => {}, warn: () => {}, error: () => {} }, + }; + await new ApprovalsServicePlugin().start(ctx); + const svc = services.approvals as ApprovalService; + + const opened: any = await svc.openNodeRequest({ + object: OBJECT, recordId: 'sheet_1', runId: 'run_1', nodeId: 'review', flowName: 'tokdoor_review', + config: { approvers: [{ type: 'user', value: APPROVER }], behavior: 'first_response' } as any, + submitterId: SUBMITTER, record: { id: 'sheet_1', title: 'tokdoor ledger sheet' }, + }, asUser(SUBMITTER)); + const requestId = String(opened.id); + await svc.remind(requestId, { actorId: SUBMITTER }, asUser(SUBMITTER)); + + return { engine, svc, protocol: new ObjectStackProtocolImplementation(engine as any), links, requestId }; +} + +/** The raw token a delivered action link carries. */ +const tokenOf = (url: string) => String(new URL(url).searchParams.get('token') ?? ''); + +/** The engine's own read of the token rows, as the system. */ +async function tokenRows(rig: Rig): Promise { + return rig.engine.find(TOKEN, { context: SYSTEM } satisfies EngineQueryOptions) as Promise; +} + +describe('the cross-object search, on a real engine with token rows present', () => { + it('the scene: one reminder minted one approve and one reject link for the concrete approver', async () => { + const rig = await boot(); + const rows = await tokenRows(rig); + expect(rows.map((r) => r.action).sort()).toEqual(['approve', 'reject']); + expect(rows.every((r) => r.approver_id === APPROVER && r.request_id === rig.requestId)).toBe(true); + expect(rig.links).toHaveLength(2); + }); + + it('serves no token row to any caller — by the bound slot, by the request, scoped to the object or not', async () => { + const rig = await boot(); + for (const ctx of [asUser(MEMBER), ADMIN]) { + for (const q of [APPROVER, rig.requestId]) { + const scoped = await rig.protocol.searchAll({ q, objects: [TOKEN], context: ctx }); + expect(scoped.hits, `${ctx.userId} ${q} scoped`).toEqual([]); + const all = await rig.protocol.searchAll({ q, context: ctx }); + expect(all.hits.filter((h: any) => h.object === TOKEN), `${ctx.userId} ${q}`).toEqual([]); + } + } + }); + + it('control — the search itself still answers: the business record is found', async () => { + const rig = await boot(); + const res = await rig.protocol.searchAll({ q: 'tokdoor ledger', objects: [OBJECT], context: asUser(MEMBER) }); + expect(res.hits.map((h: any) => h.id)).toEqual(['sheet_1']); + }); +}); + +describe('positive control: the engine\'s own token path is unchanged', () => { + it('a link minted by remind() peeks, redeems exactly once, and is refused as consumed on replay', async () => { + const rig = await boot(); + const approve = rig.links.find((l) => l.label === 'Approve'); + expect(approve, 'the reminder carried an Approve link').toBeTruthy(); + const raw = tokenOf(approve!.url); + expect(raw.length).toBeGreaterThan(0); + + expect(await rig.svc.peekActionToken(raw)).toMatchObject({ ok: true, action: 'approve', approverId: APPROVER }); + expect(await rig.svc.redeemActionToken(raw)).toMatchObject({ ok: true, action: 'approve', approverId: APPROVER }); + expect(await rig.svc.redeemActionToken(raw)).toMatchObject({ ok: false, reason: 'consumed' }); + + // The engine reads its rows as the system, unnarrowed: the redeemed one is consumed. + const rows = await tokenRows(rig); + expect(rows).toHaveLength(2); + expect(rows.filter((r) => r.consumed_at).map((r) => r.action)).toEqual(['approve']); + const request: any = await rig.engine.findOne('sys_approval_request', { where: { id: rig.requestId }, context: SYSTEM } as any); + expect(request?.status).toBe('approved'); + }); +}); From c8584b9c2373a09428381ed43c11d940e5aa766c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 06:41:51 +0000 Subject: [PATCH 2/6] test(plugin-approvals): parse a relative action link Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude --- .../src/sys-approval-token-generic-door.integration.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-approvals/src/sys-approval-token-generic-door.integration.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-token-generic-door.integration.test.ts index 90a5a7efbe8..7908c7298a4 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-token-generic-door.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-token-generic-door.integration.test.ts @@ -179,8 +179,8 @@ async function boot(): Promise { return { engine, svc, protocol: new ObjectStackProtocolImplementation(engine as any), links, requestId }; } -/** The raw token a delivered action link carries. */ -const tokenOf = (url: string) => String(new URL(url).searchParams.get('token') ?? ''); +/** The raw token a delivered action link carries (relative when no public base URL is configured). */ +const tokenOf = (url: string) => String(new URL(url, 'http://localhost').searchParams.get('token') ?? ''); /** The engine's own read of the token rows, as the system. */ async function tokenRows(rig: Rig): Promise { From 7a21137a6f3cd5fbb3a8bfd2252c030ecad39042 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 06:42:47 +0000 Subject: [PATCH 3/6] fix(plugin-approvals): the action-link token object is not exposed through the automatic API Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude --- .../src/sys-approval-token.object.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-approvals/src/sys-approval-token.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-token.object.ts index ada432666e6..abe8c71c891 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-token.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-token.object.ts @@ -102,6 +102,17 @@ export const SysApprovalToken = ObjectSchema.create({ enable: { // [ADR-0103] Engine-owned: one-time email-approval tokens are minted and // consumed by the approval engine (SYSTEM_CTX), never via the data API. - apiMethods: ['get', 'list'], + // + // [#22616] Declared in the form every door enforces, so the generic doors + // serve token rows to no caller — as the approvals door serves them to + // nobody. `apiEnabled: false` is the automatic API's off switch: judged + // first for every operation (`apiExposureDenialReason` → 404 + // OBJECT_API_DISABLED on REST, the dispatcher and MCP), and the one flag the + // cross-object search reads. `apiMethods: []` is `deny-all` + // (`api-derivation.ts`); the read methods are retired, not the key dropped, + // because an ABSENT whitelist resolves to fully open. The engine's mint, + // lookup-by-digest and consume path runs as the system and is untouched. + apiEnabled: false, + apiMethods: [], }, }); From 9ecf41b2c729c3328b5e28cf4b180ef6f3cc3612 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 06:43:20 +0000 Subject: [PATCH 4/6] test(plugin-approvals): sweep the token object beside others, scoped Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude --- .../sys-approval-token-generic-door.integration.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/plugins/plugin-approvals/src/sys-approval-token-generic-door.integration.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-token-generic-door.integration.test.ts index 7908c7298a4..00d80aa9bd5 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-token-generic-door.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-token-generic-door.integration.test.ts @@ -196,14 +196,16 @@ describe('the cross-object search, on a real engine with token rows present', () expect(rig.links).toHaveLength(2); }); - it('serves no token row to any caller — by the bound slot, by the request, scoped to the object or not', async () => { + it('serves no token row to any caller — by the bound slot, by the request, alone or swept with other objects', async () => { const rig = await boot(); for (const ctx of [asUser(MEMBER), ADMIN]) { for (const q of [APPROVER, rig.requestId]) { const scoped = await rig.protocol.searchAll({ q, objects: [TOKEN], context: ctx }); expect(scoped.hits, `${ctx.userId} ${q} scoped`).toEqual([]); - const all = await rig.protocol.searchAll({ q, context: ctx }); - expect(all.hits.filter((h: any) => h.object === TOKEN), `${ctx.userId} ${q}`).toEqual([]); + // Swept beside other objects. (A sweep naming no object also searches + // the page store, which this engine-only rig does not provision.) + const swept = await rig.protocol.searchAll({ q, objects: [OBJECT, 'sys_approval_request', TOKEN], context: ctx }); + expect(swept.hits.filter((h: any) => h.object === TOKEN), `${ctx.userId} ${q} swept`).toEqual([]); } } }); From 27c94553c542207963243cb3cfc2dc8a23af4f76 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 06:48:45 +0000 Subject: [PATCH 5/6] changeset: the action-link token object leaves the automatic API Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude --- .../22616-approval-token-generic-door.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .changeset/22616-approval-token-generic-door.md diff --git a/.changeset/22616-approval-token-generic-door.md b/.changeset/22616-approval-token-generic-door.md new file mode 100644 index 00000000000..697cc883456 --- /dev/null +++ b/.changeset/22616-approval-token-generic-door.md @@ -0,0 +1,24 @@ +--- +'@objectstack/plugin-approvals': minor +--- + +fix(plugin-approvals)!: `sys_approval_token`, the action-link tokens, is no longer exposed through the automatic API + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing on a runtime door), shipped as `minor` under the launch-window convention for breaking changes. + +The object's own declaration already said its tokens are minted and consumed by the approval engine, never through the data API. It now says so in the form every door enforces, so the generic doors serve token rows to no caller, as the approvals door (`/api/v1/approvals/*`) serves them to nobody. + +**What narrows, from → to.** + +- The declaration: `enable.apiMethods: ['get', 'list']` becomes `enable.apiEnabled: false` with `enable.apiMethods: []`. `apiEnabled: false` is the automatic API's off switch, judged first for every operation; `apiMethods: []` is the declared fully-closed whitelist, so the whitelist no longer advertises the read methods either (an absent whitelist would read as fully open). +- The REST data routes (list, query, by id, export, and every write): a caller granted read on the object was served its rows, which name the request, the identity a link is bound to, the action, the expiry and the consumption state, and so was an administrator. Every caller, an administrator included, now receives `404 OBJECT_API_DISABLED`. The dispatcher and the MCP data tools judge the same switch first. +- Without a grant, the answer moves from `403 PERMISSION_DENIED` to `404 OBJECT_API_DISABLED`: the exposure gate is judged before the permission check. +- The cross-object search (`GET /api/v1/search`) no longer sweeps the object, for any caller. + +**Unchanged.** The approval engine's own token path, which runs as the system: a reminder still mints one approve and one reject link per concrete pending approver, the link's confirm page still validates without consuming, one POST still decides as the bound approver, and a replay is still refused as already used. The digest is still stored, indexed and withheld from every generic exit (`internal: true`). + +**What changes for you.** Nothing in the platform, the console or Setup reads this object through a generic door, so no shipped surface changes. A script or integration that read token rows through the data API now receives `404 OBJECT_API_DISABLED`. There is no replacement read, by design: a link's state is what its confirm page answers, and a decision taken through a link is recorded in the request's action history on the approvals door. From dd037479aa1af849a2af54907082b17062ddb24c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 07:06:55 +0000 Subject: [PATCH 6/6] test(plugin-approvals): type the request read's query options Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude --- .../src/sys-approval-token-generic-door.integration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugins/plugin-approvals/src/sys-approval-token-generic-door.integration.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-token-generic-door.integration.test.ts index 00d80aa9bd5..8f7fb248228 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-token-generic-door.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-token-generic-door.integration.test.ts @@ -233,7 +233,7 @@ describe('positive control: the engine\'s own token path is unchanged', () => { const rows = await tokenRows(rig); expect(rows).toHaveLength(2); expect(rows.filter((r) => r.consumed_at).map((r) => r.action)).toEqual(['approve']); - const request: any = await rig.engine.findOne('sys_approval_request', { where: { id: rig.requestId }, context: SYSTEM } as any); + const request: any = await rig.engine.findOne('sys_approval_request', { where: { id: rig.requestId }, context: SYSTEM } satisfies EngineQueryOptions); expect(request?.status).toBe('approved'); }); });