Repository navigation
fix(platform-objects,plugin-auth): give API-key revoke/restore a working product route - #7769
Conversation
…ing product route (#7727) `sys_api_key` declared two row actions — `revoke_api_key` / `restore_api_key` — as `PATCH /api/v1/data/sys_api_key/{id}` with `bodyExtra: {revoked}`, while the same object set `enable.apiMethods = ['get','list']`. The declared PATCH died at the ADR-0049 method gate with 405 before any authorization ran, so no product route revoked an API key: the Setup UI's Revoke button errored, the row kept `revoked = false`, and the key kept authenticating. The write path had two gates, not one: - the method gate — `enable.apiMethods` now carries `update` (`create` / `delete` stay off: minting is `POST /api/v1/keys`, and keys are retired by revoking rather than deleting); - ADR-0103's `reconcileManagedApiMethods`, which strips any write verb a `managedBy` object's affordances do not grant and only warns. `apiMethods` alone would still have served 405 while the source read correctly, so `userActions: { edit: true }` declares the affordance — the ADR-0092 D4 pattern `sys_user` already uses. Opening the method does not open the columns. The object stays `managedBy: 'better-auth'`, so ADR-0092 D2's identity write guard still fail-closed rejects user-context writes and its per-object update whitelist stays the only opening; `revoked` is registered there and nothing else is. The guard itself is untouched — no general weakening, and every other identity table keeps its default-deny. Per D4's form-rendering constraint the columns outside the whitelist are now `readonly`, so the edit form this affordance turns on cannot offer a write the server refuses. Nothing pinned any of this: the existing tests exercise key resolution against a pre-revoked row and never call the route the actions declare. The new dogfood suite drives the real PATCH, asserts 200, then asserts the consequence — the key stops authenticating — and pins the refusals with code AND status. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqARTSYRvgutYHaLXbx6f7
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
…h decision (#7802) The `apiMethods` conformance scan in `@objectstack/spec` went red on `main` after #7769 gave `sys_api_key` `update` without `bulk`. Resolved as the rule's second documented outcome — a `SINGLE_RECORD_WRITE_ONLY` exemption carrying the evidence — rather than by widening the object's API surface. Evidence: the console renders no checkbox column for this object (multi-select is auto-enabled only when a bulk action exists; the only implicit one is bulk-delete, and the object grants no delete affordance), and a promoted multi-select revoke would fan out per row through the action runner rather than reaching `/batch`. #7769's `update` grant is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0158ZQo7LiHSxGWpYKuPq1wu
…h decision (objectstack-ai#7802) (objectstack-ai#7818) The `apiMethods` conformance scan in `@objectstack/spec` went red on `main` after objectstack-ai#7769 gave `sys_api_key` `update` without `bulk`. Resolved as the rule's second documented outcome — a `SINGLE_RECORD_WRITE_ONLY` exemption carrying the evidence — rather than by widening the object's API surface. Evidence: the console renders no checkbox column for this object (multi-select is auto-enabled only when a bulk action exists; the only implicit one is bulk-delete, and the object grants no delete affordance), and a promoted multi-select revoke would fan out per row through the action runner rather than reaching `/batch`. objectstack-ai#7769's `update` grant is untouched. Claude-Session: https://claude.ai/code/session_0158ZQo7LiHSxGWpYKuPq1wu Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…objectstack-ai#7802) (objectstack-ai#7928) A test that resolves the repo root and walks it has an input set far wider than the package it lives in, and both of CI's scoping layers judged such a test by its home package: - `turbo ls --affected` (ci.yml `test`, the PR path) reaches packages through the dependency GRAPH. `packages/spec` declares no dependency on `packages/platform-objects` — and should not; the api-methods batch conformance scan reads source text precisely to avoid inverting the spec -> * direction. Measured on turbo 2.10.7: a platform-objects-only diff makes 51 packages affected, `@objectstack/spec` not among them. - turbo's task cache hashes `$TURBO_DEFAULT$`, which is package-local, so `@objectstack/spec#test` hashed identically before and after the change. Even the merge-queue and push builds — which deliberately partition the FULL package list — replayed a cached green. Measured: `>>> FULL TURBO` in 42ms replaying the prior log, while `--force` on the same tree failed the scan. This layer, not the paths-filter, is why objectstack-ai#7769 reached `main`. Packages whose tests read outside themselves now declare that radius once, and both layers are driven from it: ci.yml unions a declaring package back into the shard's package set when the diff touches its globs, and turbo.json carries matching `$TURBO_ROOT$` inputs so the cache hash moves with them. The declaration list cannot rot, which is the point: the gate finds escaping tests itself, statically, and fails on a package that has one and no declaration, on a stale declaration, on a glob turbo.json does not hash, and on a radius too narrow to cover the paths its own tests name. A new cross-package scan that nobody registers produces a red gate naming it and the globs to write — not a silent skip. Census: 20 such tests in 8 packages. Radii are kept narrow so the affected-subset optimisation the 3-way shard exists for survives; the literal-coverage check is what makes narrowing safe. Co-authored-by: os-dev <support@objectstack.ai>
Fixes #7727
What was wrong
sys_api_keycontradicted itself. It declared two row actions —revoke_api_key/restore_api_key— asmethod: 'PATCH'against/api/v1/data/sys_api_key/{id}withbodyExtra: { revoked: true|false }, while the same object setenable.apiMethods = ['get', 'list']. The declared PATCH was refused at the ADR-0049 method gate with405 OBJECT_API_METHOD_NOT_ALLOWEDbefore any authorization ran, so no product route revoked an API key: the Setup → API Keys → Revoke button produced an error toast, the row still readrevoked = false, and the key kept authenticating.Enforcement of the flag was never the problem. The verifier filters
revoked: falseand re-checks the row, so a flipped bit takes effect on the very nextx-api-keycall. The missing piece was purely the write path.The premise, verified before implementing
The card ruled the in-object, column-scoped fix, hung on this premise: ADR-0092 D2's identity write guard admits a narrowly-scoped write without being weakened. Confirmed, at the ADR and at the implementation.
ADR-0092 D2 states it directly — "Per-object update whitelist is the only opening: plugin-auth exposes a small registry —
registerManagedUpdateWhitelist(object, fields)— consulted by thebeforeUpdateguard."identity-write-guard.tsimplements exactly that: non-whitelisted keys are stripped, and a payload that strips to nothing throws. Registering one column on one object needs no guard change at all, andauth-plugin.tsalready loopsMANAGED_EXTENSION_EDITABLE_FIELDSinto that registry. So the scoped exception is the mechanism's declared purpose, not a hole widened to fit.revokedqualifies as an ObjectStack extension field under ADR-0105 D7 without ambiguity: this table is hand-rolled (packages/core/src/security/api-key.tsmints and verifies it) and better-auth'sapiKeyplugin is not loaded, so better-auth owns none of these columns.The gate the card did not know about
The write path had two gates. Opening
enable.apiMethodsalone still served 405, because ADR-0103'sreconcileManagedApiMethodsstrips any write verb amanagedByobject's resolved affordances do not grant — and it only warns. An object can therefore declareupdate, serve 405, and look perfectly correct in its own source: the same declared-≠-enforced shape as the original defect, one layer deeper.userActions: { edit: true }declares the affordance, exactly assys_userdoes under ADR-0092 D4. D4's sequencing rule is satisfied — the guard it fronts already shipped.Scope of the opening
Opening the METHOD does not open the COLUMNS. The object stays
managedBy: 'better-auth', the guard still fail-closed rejects user-context writes, and its per-object whitelist stays the only opening — carryingrevokedand nothing else:keystays unwritable — a rotated hash would mint a credential nobody holds.user_idstays unwritable — re-owning a key is privilege transfer.expires_atstays on the mint path.create/deletestay off — minting isPOST /api/v1/keys, and keys are retired by revoking, not deleting.The guard itself is unchanged. No general weakening; every other identity table keeps its default-deny. Enforcement was not touched either — the card measured it as already correct.
Per D4's form-rendering constraint, the five columns outside the whitelist (
name,prefix,user_id,scopes,expires_at) are nowreadonly, so the edit form this affordance turns on cannot advertise a write the server refuses.The pin
Nothing pinned this before — the existing tests exercise key resolution against a pre-revoked row and never call the route the actions declare, which is exactly how a declared action and a method gate cancelled out unnoticed.
api-key-revoke-lifecycle.dogfood.test.tsdrives the real product route and asserts the consequence, not just the status:PATCH {revoked:true}→200, then the key stops authenticating (a 200 that leaves the key working is the defect wearing a success code). Baseline-asserted first, so the post-revoke 401 is falsifiable.PATCH {revoked:false}→200, and the key authenticates again.{name}PATCH is refused403+PERMISSION_DENIED, the stored value is unchanged, and the key is untouched. If the opening let a general update through, that would be worse than the bug being closed.{revoked, key, user_id}appliesrevokedonly;user_idis unchanged and the ORIGINAL secret still authenticates after restore, which is what proveskeywas stripped rather than merely unread.405+OBJECT_API_METHOD_NOT_ALLOWED.bodyExtraand the REGISTERED (post-reconciler)apiMethodsanduserActions.edit, read from the live schema. This is what stops the reconciler from silently strippingupdateagain.Refusal cases assert
codeandstatusper ADR-0112 — a status-only or bare-throw assertion cannot separate "refused with the wrong envelope" from "did not refuse at all".Reverse verification
Direction predicted before running: reverting each half turns the new pins red, and in distinguishable ways. Both confirmed.
expected 405 to be 200,expected ['get','list'] to include 'update'expected 403 to be 200The 403 refusal case stays green in both reverted states, correctly: it is a refusal test, and the refusal merely arrives for a broader reason. That is why the positive cases carry the load.
Verification
@objectstack/dogfood@objectstack/plugin-auth@objectstack/platform-objects@objectstack/objectql@objectstack/rest@objectstack/plugin-security@objectstack/runtimetypecheck(the 3 changed packages)check:type-check-debtcheck:query-options-erasurecheck:adr-anchorscheck:i18ncheck:nul-byteseslint(changed files)Consumer-sweep direction: not applicable — no exported type or signature changed. The build closure was rebuilt first (
--filter '@objectstack/dogfood^...' build) before any judgement, and the full workspace was built before the debt ratchet.Also in this PR
sys-api-key.object.ts(ADR-0092, ADR-0103), so the next reader is told which decisionsuserActions.editand thereadonlystamps are standing on rather than reading them as arbitrary./api/v1/sys_api_key/{id}, omitting/data/— the exact path this card is about.patchon both packages).Generated by Claude Code