Repository navigation
fix(plugin-approvals)!: sys_approval_token, the action-link tokens, is no longer exposed through the automatic API (#22616) - #22633
Conversation
…generic doors Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
…rough the automatic API Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
Contract reviewServed-tier: Inputs: card #22616 (body and all three comments, the Check-runs on the head, read at 2026-10-10T07:44Z: 34 distinct names, all completed, none red. The seven required contexts are ① Derived judgmentsThe diff is three files: the object declaration, one integration test, one changeset. Every accept-set and public-surface change it implies, named and judged:
Pins against the card's Ask 3: a granted member is served no token row by list, by id or by count. The pin sits at the decision every door delegates to, over every operation in ② Semver level
③ Boundary flagsOpen question 1 — amend the internal QA checklist item here. Answered: B. The item Open question 2 — an HTTP-level pin. Answered: A, no further pin is a condition of this PR. The decision every door turns into its refusal is pinned over every operation; the real-engine search pin covers the one door that reads the switch and not the whitelist; the REST and MCP envelopes are pinned in their own suites; and HTTP reach was measured before and after on a real stack with the readings in the PR body. Out-of-scope finding 1 — the analytics door. Verified as #22634 ( Out-of-scope finding 2 — the QA checklist. Carried by #22631, as above. Answered. Out-of-scope finding 3 — Dispatch constraints: no Implemented-by: VERDICT: PASS |
Fixes #22616
Clause-②: no (narrowing)
What this does
sys_approval_token, the single-use action-link tokens of ADR-0043, is no longer exposed through the automatic API. Itsenableblock moves fromapiMethods: ['get', 'list']toapiEnabled: falsewithapiMethods: []. The generic doors now serve token rows to no caller, as the approvals door (/api/v1/approvals/*) serves them to nobody.The object's own declaration already said its tokens are "minted and consumed by the approval engine (SYSTEM_CTX), never via the data API". What is declared is now what is enforced. The engine's mint, lookup-by-digest and consume path runs as the system and is unchanged.
This is the third request-keyed table of the family, after #22559 (
sys_approval_request, PR #22587) and #22589 (the child tables, PR #22613).Step 1a: reach, measured before any code
Measured through the real HTTP doors on an in-process stack:
@objectstack/verify'sbootStackwith the realSecurityPlugin, automation, the record-change trigger andApprovalsServicePlugin.The scene:
The probe was a local scratch file and is not part of this PR.
83b8b80728)9ecf41b2c7)200, both rows (total 2): request, bound identity, action, expiry, consumption state404 OBJECT_API_DISABLED200, the row404 OBJECT_API_DISABLED200, both rows (total 2)404 OBJECT_API_DISABLED403(no export grant)404 OBJECT_API_DISABLED200, every row404 OBJECT_API_DISABLED403 PERMISSION_DENIED404 OBJECT_API_DISABLED403 PERMISSION_DENIED404 OBJECT_API_DISABLEDinternal: true, [security] The compliance ledger stores a JWT signing-key row's key material in its create snapshot, and an admin is served it through the ledger's by-id door while the key object itself declares no API door #21197).approved, and exactly one token row carriesconsumed_at.403 PERMISSION_DENIEDto404 OBJECT_API_DISABLED, because the REST data routes judge the exposure gate (enforceApiAccess) before the data call's permission check. Every caller now gets the same answer.Step 1b: readers census
packages/,apps/,examples/, the dogfood suites (git grep sys_approval_token).objectui-sha20c6d351ad(full tree)sys_approval_token,approval_token,approvals/act: 0 hits. Control:sys_approval_requesthas hitsdocs/qa/platform-checklist/areas/approvals.json, itemapprovals.email-action-token-doorNo product reader exists, so no visibility rule for tokens is invented to keep one.
Why this spelling (read from the source)
apiMethodshas three declared states. Seepackages/spec/src/data/api-derivation.ts, its module doc andresolveEffectiveApiMethods:undefinedresolves tounrestricted(every operation);[]resolves todeny-all(fully closed);An absent whitelist is fully open, so deleting the key would retire nothing.
apiEnabledis the declared off switch:ObjectCapabilities.apiEnabled, "Expose object via automatic APIs", defaulttrue.apiExposureDenialReasonjudges it first, for every operation. REST (apiAccessDenialFromEnable, answering404 OBJECT_API_DISABLED), the dispatcher (checkApiExposure) and the MCP bridge (enforceApiExposure) do the same.The cross-object search reads only the switch.
ObjectStackProtocolImplementation.searchAllskips an object onsearchable === falseorapiEnabled === false, and never readsapiMethods. SoapiMethods: []alone would leave the search door serving the rows. Ablation C below measures exactly that.Precedent. Every credential and token store already declares both:
sys_flow_credential, thesys_oauth_*stores andsys_jwks.setup-nav.contributions.tscallsapiMethods: []fail-closed by design.Both halves are spelled.
apiEnabled: falsecloses the doors, andapiMethods: []makes the whitelist agree with it instead of advertisinggetandlistbehind the switch.Pins
The pins are in
packages/plugins/plugin-approvals/src/sys-approval-token-generic-door.integration.test.ts.The declaration:
apiEnabled: false,apiMethods: [], effective modedeny-all.Every operation is refused, for every caller.
apiExposureDenialReasonrefuses every operation inAPI_OPERATION_ORDER(14, floored) with theapi-disableddiscriminant, andbulkwith each child verb. This function is the decision every door turns into its refusal.canServeApiOperationserves none, andOBJECT_API_DISABLEDis registered vocabulary. The function takes no caller, so this is the granted member's answer and the administrator's alike.The cross-object search, on a real engine. The rig: ObjectQL over better-sqlite3, the real
ApprovalsServicePlugin.start()and the real protocol, with two token rows minted byremind(). No token row comes back for a member holding read or for an administrator:Control: the search still finds the business record.
Positive control. The Approve link that
remind()delivered peeks, redeems once as the bound approver, and is refused asconsumedon replay. The engine's system read still sees both rows, with the redeemed one consumed, and the request isapproved.This package does not depend on
@objectstack/restor@objectstack/verify, the same choice as the webhook object's exposure test. So the HTTP readings above come from the scratch probe. The404 OBJECT_API_DISABLEDenvelope for this discriminant is pinned in@objectstack/rest's and@objectstack/mcp's own suites.Red first, then ablation
The subject is imported from source, so no build sits between a mutation and a run.
scripts/ablation-replace.mjs: the anchor must hit exactly once, and the blob must change.abe8c71c89), withgit diff HEADempty.c8584b9c23, before the fix)9ecf41b2c7)apiEnabled: false,apiMethods: []apiMethods: ['get', 'list']getandlistrestored, switch keptapiEnabled: false,apiMethods: ['get', 'list']apiMethods: []method-not-allowed, notapi-disabled)getandlistalone reopens nothing.apiMethods: []alone leaves the search open.Tests and gates
All readings are at
dd037479aaunless stated.pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2: 68 files and 994 tests passed, at27c94553c5. The one later commit only types a query-options bag in the new test file. That file was re-run atdd037479aatogether with the internal-hash test: 7 of 7.pnpm --filter @objectstack/plugin-approvals run typecheck(tsc --noEmit, the scripts project andcheck:test-typecheck): exit 0. The test layer compiles, with its identity-pinned debt unchanged (8 files, 324 errors, 27 signatures).pnpm --filter @objectstack/spec run test:repo, which scans every object'sapiMethods: 54 files and 915 tests passed, at27c94553c5. Nothing it reads changed afterwards.node scripts/pm/dispatch-gates.mjs --commandsderives 65 commands for this diff. All 65 exited 0, each exit captured before any pipe. The--ranreconciliation reads 65 derived, 65 run, 0 unrun. Among them:check-adr-0087-registration: one declared-breaking changeset, carrying its disposition;check-changeset-no-major: nomajor;check:i18n: 9 packages in sync;check:query-options-erasure: the test surface is back at its 236 ceiling after typing one bag;check:test-source-alias,check:cross-package-test-inputsandcheck:nul-bytes.eslint --no-inline-configover the two changed.tsfiles.--print-configresolves 6 and 5 rules.--format jsonreports 2 files, 0 errors and 0 warnings.parserOptions.projectorprojectService), so this diff cannot move any untouched file's verdict.pnpm lintis CI's.Acceptance notes
inferCubeFromQuery). It reads neither the object-levelapiEnabled: falsenor the field-levelinternal: true.apiEnabled: falsecredential store'sinternalcolumn, served to an administrator.service-analytics, and it reaches every object with such a declaration.approvals.email-action-token-doorreads token rows as the administrator through the data API, as an oracle, and writesexpires_atthrough it for its expiry leg.update.404.approval-token-internal-hash.integration.test.ts.apiMethods. An object whose whitelist omitslistis still swept unless it also declaressearchable: falseorapiEnabled: false. This is dormant today: the three whitelists withoutlist(sys_verification,sys_device_code,sys_two_factor) all declaresearchable: false. Ablation C shows the shape on a counterfactual. Noted, not filed.plugin-approvals, which cannot depend on@objectstack/restor@objectstack/verify. An end-to-end pin would live inpackages/verifyor the dogfood suite./me/permissionsannotates this object with an empty operation set wherever the subject's map carries it (annotateEffectiveApiOperationsreadscanServeApiOperation). This was read from the source, not measured.Generated by Claude Code