Repository navigation
feat(spec,core,runtime,plugin-auth)!: API keys get a lifecycle: 90-day default expiry, one-year ceiling, 90-day inactivity expiry, one-year grace, distinct refusal codes - #23541
Conversation
Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
… and landing record (#23367) Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
…answers the refusal (#23367) Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
…ail-closed pin (#23367) Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
…ebt flat (#23367) Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
… hash-only lookup (#23367) Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
…log, last_used_at description (#23367) Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
…s for the lifecycle contract (#23367) Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
…es hold limit and the update contract (#23367) Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
…23367) Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
…#23367) Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
…ire pairs (#23367) Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 12 package(s): 45 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 11 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 160 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 63ba9b300dd308d33bda76ce324082cf1058ac62 && git checkout 63ba9b300dd308d33bda76ce324082cf1058ac62
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0fb55fc2fca5590a76c1827d95bf7d1bacf22d02 3c2b4152f9693935ac039d2daeb3be8c83be74de && git checkout -B drift-repro 0fb55fc2fca5590a76c1827d95bf7d1bacf22d02 && git merge --no-ff 3c2b4152f9693935ac039d2daeb3be8c83be74de
node scripts/docs-audit/affected-docs.mjs --json 0fb55fc2fca5590a76c1827d95bf7d1bacf22d02
|
…cycle refusal at the dispatcher, settings and storage doors The runtime dispatcher's identity step (the MCP HTTP endpoint among its routes), the settings family door and the storage download gate re-raise or relay the branded ApiKeyLifecycleRefusalError beside the store outage, so a revoked, expired or inactive key answers its own 403 and ledger code there too. Matched by brand: every other throw keeps its existing answer, and an unknown key keeps the generic 401. One pin per face. Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
…our API-key lifecycle codes The catalog page's own count claim is pinned to the wire face (error-catalog-docs.test.ts, "the advertised code count matches the wire face"). The four codes registered for #23367 moved the face from 52 to 56; the claim stayed at 52 and turned the spec repo project red in CI. Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
…ts a key The runtime dispatcher (MCP HTTP among its routes), the settings routes and the storage download routes now relay the refusal, so the changeset, the error catalog and the MCP connection guide list every face instead of naming three as answering the generic 401. Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs read: card #23367 body; ruling ① Derived judgments
② Semver level
③ VerdictPASS Acceptance notes (non-blocking):
|
席位核对
Verdict: ACCEPT.
|
…ude/issue-23429-metadata-family No textual conflict on this merge. The generated reference docs are regenerated over the merged ledger in the next commit, never picked from a side. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
) `gen:schema && gen:docs` on the merged tree: `… +333 more` (main's four API_KEY_* codes from #23541, plus ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED and LEGACY_ORGANIZATION_ROWS_UNREADABLE). check:docs green; the i18n bundles are in sync (check-i18n-bundles OK, nothing to regenerate). Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
Fixes #23367
Clause-②: yes
Executes ruling
6159880505(letter A, maintainer 「同意」) on the re-scoped claim6173499602, as amended by6178205488(the three relays below): the key store stayssys_api_key, and the API-key lifecycle is auth configuration. The changeset declares the arm,Clause-②: yes (narrowing): the published contract widens (fourauthsettings keys,IAuthService.resolveApiKeyLifecyclePolicy, four registered codes, new@objectstack/coreexports), and the key doors narrow.What changed
1. The policy is auth configuration (
@objectstack/service-settings,@objectstack/plugin-auth,@objectstack/spec).auth.manifest.tsgains the group API keys:api_key_default_expiry_days(90),api_key_max_expiry_days(365),api_key_inactivity_days(90,0= off), andapi_key_policy_started_at.bindAuthSettingsbinds the three numbers intoAuthManager(apiKeyDefaultExpiryDays/apiKeyMaxExpiryDays/apiKeyInactivityDays). Only explicit values apply, as for every key there.AuthManager.resolveApiKeyLifecyclePolicy()answers the policy with every number defaulted to the ruling's. The default never outlives the ceiling. It is declared on the contract asIAuthService.resolveApiKeyLifecyclePolicy(typeApiKeyLifecyclePolicy).kernel:bootstrapped, after the settings engine binds, plugin-auth writesauth.api_key_policy_started_atwhen no value is stored, and never overwrites a stored one. The value survives restarts and every node reads the same instant; it is never the process start. Measured whykernel:bootstrapped: a write inside thekernel:readywindow is refusedSETTINGS_ENGINE_NOT_BOUND. With no settings service the anchor cannot be kept: said once atwarn, and the two clocks it drives stay off. A failed write is said aterror.2. The signing door (
POST /api/v1/keys,@objectstack/runtime). It reads the same source as the seam, through theauthservice.expires_atgives now +defaultExpiryDays, echoed in the response.expires_atpast now +maxExpiryDaysis refused400 API_KEY_EXPIRY_TOO_LONG, naming the latest date allowed. Exactly the ceiling is accepted, with 60 s of forgiven client clock skew.authservice signs as before. Anauthservice that is registered and broken is the store outage (503), never a policy-less mint.3. The key-admission seam (
resolveApiKeyAdmission,@objectstack/core).lifecycle, carries one of three distinct codes:API_KEY_REVOKED,API_KEY_EXPIRED(pastexpires_at, or past the grace of a key with none) andAPI_KEY_INACTIVE. The located message names the key's prefix, the date and the remedy. An unknown key is stillnone.created_atonly when that is later, i.e. a key signed after the landing. An earliercreated_atis never the anchor: no use was recorded before the policy, so an old signing date says nothing about use. Measured deviation from the seat's wording "nevercreated_at": counting a key signed after the landing from the landing would refuse it on its very first call once the landing is more than 90 days old. The later-of rule keeps the seat's intent for every pre-policy key.expires_at= landing + 365 days, stamped on the row the first time the key is admitted (the MFA-grace precedent,mfa_required_at). The keys page then names the date, and the 403 after it names it too.resolveAuthzContexttakes the policy as a REQUIRED input,apiKeyLifecycle, besideuserAuthGates(the fix(core,cloud-connection,mcp)!: every API-key admission names its owner-posture source, so a held owner's key is refused at install-local and on every MCP stdio call (#23455, #23456) #23508 shape). It raises alifecycleverdict as the brandedApiKeyLifecycleRefusalError(403, code, located message), never falls through to the session, and logs one operator line.4.
last_used_atis written, and its description is now true. The field read "Automatically updated on each API call" (sys-api-key.object.ts), and nothing in this repository wrote it. The seat carried this as the dev's finding on the claim. The seam now stamps it on an admitted or held call, at most once an hour per key (API_KEY_LAST_USED_STAMP_INTERVAL_MS), throughql.update('sys_api_key', { id, ...fields })in the system context. A held key is stamped too: ruling C′ item 4 serves it again when its owner remediates, which an idle clock run out during the hold would defeat.error, with the consequence and the fix, once per data engine (aWeakSetlatch), never once per request.enbundle was regenerated;zh-CN,ja-JPandes-ESwere translated by hand.5. Every call site that builds
ResolveAuthzInputpasses the source (apiKeyLifecyclePolicySourceOf(authService)). They are the 8 faces plus the two test seams; the test seams pass an explicitundefined(no auth service) or the real source (dogfoodarmed.ts). The storage gate reader's directresolveApiKeyAdmissioncall passes it too. The refusal's answer on each face:rest-server.ts)403+ code + located message.computeExecCtxresolves no context, keeps the refusal beside the request (aWeakMap), andenforceAuthanswers it. Every other door keeps its fail-closed answer.mcp/src/plugin.ts)403envelope; at start: refuses to start, naming the codeadmin-routes.ts)403+ code (re-raised ahead of the routes'try; the host's thrown-envelope render answers it)sharing-plugin.ts)403+ code (the family door relays a throw that declares its status)marketplace-install-local-plugin.ts)403+ code (raw-app routes; the host'sonErrorenvelope render)http-dispatcher.tsidentity step), incl. MCP HTTP403+ code (re-raised by brand beside the store outage; the transport'serrorResponseBaserenders the declared status and registered code)settings-service-plugin.ts→settings-routes.ts)403+ code (the resolver re-raises the brand; the family door relays it besiderelayAuthzStoreOutage)storage-service-plugin.ts→storage-routes.ts)403+ code (the authorizer re-raises the brand; the download gate relays it beside the outage relay)6. Codes. All four are registered in
ERROR_CODE_LEDGERandcontent/docs/api/error-catalog.mdx.API_KEY_EXPIRED,API_KEY_INACTIVEandAPI_KEY_REVOKEDsit under@objectstack/core;API_KEY_EXPIRY_TOO_LONGsits under@objectstack/runtime.EXPIRED_TOKENwas judged and not reused. Its catalog meaning is a JWT or session token whose remedy is a refresh, and its retry isretry_immediate(after refreshing); a key has no refresh, and its remedy is a new key. Each code is declared as its wire pair ({ code, status }), socheck:error-status-conformancederives a producer for it.7. Docs.
content/docs/ai/connect-mcp.mdxgains Key lifetime, inactivity and rotation: the policy, the codes, rotation without a gap, and which faces answer the codes.content/docs/api/index.mdxpoints to it, and the stdio paragraph names inactive keys. Cloud's agent-connection page is cloud's (the seat's pointer on cloud#2868).OS_CLOUD_API_KEYRead first, as ruling item 4 orders. In this repository it is the runtime's outbound bearer to the control plane (
cloud-connection-plugin.ts:166,marketplace-install-local-plugin.ts, the marketplace fetch). It is never admitted byresolveApiKeyAdmission, andcloud-connection-plugin.tsrecords that thesys_api_key-backed data API rejects it with 401. It is not asys_api_keyrow: untouched, and no exemption question arises. Its second use, the CLI publish--tokenenv fallback, carries whatever key the user sets; a personalosk_key there is an ordinary key on the cloud runtime. Cloud's own verification of the variable: NOT MEASURED (no cloud checkout in this container).The three relays (claim amendment
6178205488)The seat widened the claim by three files so ruling item 3's codes reach every face. Each relay is matched by the brand (
isApiKeyLifecycleRefusalError); every other throw keeps today's answer, and an unknown key keeps the generic 401.packages/runtime/src/http-dispatcher.ts, the identity step'scatchonly:if (isApiKeyLifecycleRefusalError(err)) throw err;ahead ofrethrowAuthzStoreUnavailable(err), plus the name in the file's@objectstack/coreimport.dispatch()leaves with the refusal, anddispatcher-plugin'serrorResponseBaseanswers its403and code. This includes the MCP HTTP endpoint (/api/v1/mcp) and the declarative-endpoint fallback, which renders through the sameerrorResponse.settings-routes.tsgainsrelayApiKeyLifecycleRefusalbesiderelayAuthzStoreOutage, called from the same family-doorcatch;settings-service-plugin.ts's resolver re-raises the brand instead of returning the fail-closed{ enforced: true }. Two doc comments in the door now name the relay.storage-routes.ts, the download gate'scatch(authorizeDownload, shared by the URL and redirect routes): the brand is relayed beside the outage.storage-service-plugin.ts'sbuildFileReadAuthorizerre-raises it instead of returning'unauthenticated'.refuseHeldSession) is not edited, because no brand can reach it. Its reader (buildAuthGateSessionReader) callsresolveApiKeyAdmission, which returns alifecycleoutcome and never throws one. The reader answers that outcome with no posture, as it answersrefused, and leaves the key to the route's own identity resolution. On a download, that is the authorizer above, which relays the code. The upload routes read the session cookie alone (buildAuthSessionResolver) and never admit a key, so no key face is left behind. A relay there would be unreachable code.The CI red at
d18cfe5488(Test Core 1/6,packages/spectest:repo) is fixed in469acd6a41. Reproduced locally:src/api/error-catalog-docs.test.ts› "the advertised code count matches the wire face" (expected 52 to be 56). The catalog page states how many codes reach the wire, and that claim is pinned toderiveWireFace(). The four codes registered here moved the face from 52 to 56, and the page still said 52. The page now says 56. No test was touched.Tests
Core:
api-key-lifecycle.test.ts(20). Runtime:api-key-lifecycle.pin.test.ts(14). It covers the signing door; on the dispatcher,dispatch()raising403 API_KEY_REVOKED, and403 API_KEY_EXPIREDwith a valid cookie beside the key, nothing minted; an unbranded throw at the identity step spelling the same code and status, still answered the generic401 UNAUTHENTICATED; an unknown key,401; a live control,201; and, on the wire through the mountedPOST /api/v1/mcproute,403 API_KEY_REVOKEDin a schema-valid envelope and403 API_KEY_INACTIVE. Settings:settings-routes.api-key-lifecycle.pin.test.ts(5): revoked on every mounted route, expired, inactive, unknown key401 UNAUTHENTICATED, and a live control. Storage:storage-routes.api-key-lifecycle.pin.test.ts(4): revoked and expired give403with the code and nothing minted, an unknown key gives401 AUTH_REQUIRED, and a live key gets its signed URL. REST:rest-api-key-lifecycle.pin.test.ts(4). plugin-auth: four policy cases inauth-plugin.test.ts. Dogfood:api-key-lifecycle.dogfood.test.ts(3, the showcase stack end to end). It proves the landing is recorded in theauthsettings, the 90-day default, the400 API_KEY_EXPIRY_TOO_LONG, and that one admitted call really writeslast_used_atthrough the real engine. It also proves a pre-policy key gets its grace stamped. The three existing dogfood key probes now read a refused key as401, or as403with codeAPI_KEY_REVOKEDand only that code.Pins against the dispatch's list: no
expires_atgives +90 d; +365 d accepted, +366 d refused with its code; idle > 90 d refusedAPI_KEY_INACTIVE, the row not written, and re-issue works. A no-expiry key is served until the grace end and refused after it, naming the date; the date is onexpires_atfrom first use. Revoked, expired and inactive give three codes. Each number comes from the source, so a changed setting changes the verdict. The stamp pin: one admitted call writeslast_used_at, and the throttle holds. The pre-stamp pin and the NULL-anchor pin are listed above. The missed-face pin is now the dispatcher's unbranded-throw case: a face that misses the brand fails closed to its generic 401.Ablations (
scripts/ablation-replace.mjs, run against committed HEAD204fb33318; the core suite imports./api-key.jsfrom source, so no build stands between the mutation and the run):nowMs - idle.ms > policy.inactivityDays * DAY_MSwas replaced byfalse. Anchor x1 → x0, blob2c7db74f5e18→e3f45c2db200: 5 failed / 15 passed (three codes, pre-stamp, landing anchor, signing anchor, source number). Restored: blob == HEAD2c7db74f5e18,git diff HEADempty.ql.update('sys_api_key', …)line was replaced byvoid patch;. Anchor x1 → x0, blob →48112ae9cdca: 5 failed / 15 passed (grace stamp, one call writes, throttle, held key stamped, said-once). Restored: blob == HEAD,git diff HEADempty.e50fb94b57; the pin imports./http-dispatcher.jsand./dispatcher-plugin.jsfrom source, so no build stands between).if (isApiKeyLifecycleRefusalError(err)) throw err;was replaced byvoid 0;. Anchor x1 → x0, blob22d1fe62b338→e0459ffb1954: 4 failed / 10 passed (the twodispatch()codes and the two MCP HTTP wire codes). The unbranded-throw, unknown-key and live controls stayed green. Restored: blob == HEAD22d1fe62b338,git diff HEADempty.Local verification (head
3c2b4152f9)This round touched runtime, service-settings, service-storage, the changeset and two docs pages. The readings for the packages it did not touch are the previous round's, at
d18cfe5488after mergingorigin/main(989aa483fd). Those were: full suites green for core (105 files / 2544), plugin-auth (140 / 2819), mcp (37 / 420), service-datasource (45 / 838), plugin-sharing (46 / 1061) and cloud-connection (46 / 555); rest, the 30 key/resolver/enforceAuthfiles (474); typecheck exit 0 for spec, core, rest, mcp, plugin-auth, plugin-sharing, service-datasource, cloud-connection, plugin-security, cli and dogfood; and the CLI integration file (9).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 128 families on this head. All 128 ran with their exit codes captured before any pipe. 125 exited 0 on the first pass.check:skill-examples,check:dual-build-cjs-loadsandcheck:i18nexited 3 (PREREQUISITE NOT MET: packages not yet built, so nothing was measured), then exited 0 when re-run on the built workspace.--ran: 128 derived, 128 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero: every family recorded an exit code). Local-only:check:dev-prereqsandcheck-nul-bytesexit 0.tscpluscheck:test-typecheck), service-settings (tsc; its file list includes the new pin, counted with--listFiles) and service-storage (tsc,tsconfig.scripts.json,check:test-typecheck).git grep, which is blind to a test that reaches the identity step without naming it; CI runs the full suite;repoproject in full (pnpm --filter @objectstack/spec run test:repo): 61 files / 1033 tests passed, includingerror-catalog-docs.test.ts, the file red atd18cfe5488;showcase-mcp-http-identity(5 files / 29 passed).Acceptance notes
989aa483fd), under the 3000-line class.ApiKeyLifecycleRefusalErroris a throw rather than an envelope field, for the reasonauthz-store-unavailable.tsrecords. A face that misses a throw fails closed by default; a face that misses a field answers the old quiet value.API_KEY_LEGACY_GRACE_DAYS(365) is a constant, not a fifth setting. The grace is a one-time transition stamped onto each key, so a setting could not move a date already stamped. Moving the landing (api_key_policy_started_at) moves it for keys not yet stamped.@objectstack/coreimport (isApiKeyLifecycleRefusalError), which the relay needs.settings-routes.ts's two doc comments that list what the door relays now name the new relay. The changeset,error-catalog.mdxandconnect-mcp.mdxnow list every face instead of naming three as answering the generic 401.packages/core/src/security/index.tsgained the new exports (the barrel of the claimedapi-key.ts).scripts/engine-double-contract.pinned.jsonrecords the new pinned double (--write, as the gate prescribes).packages/objectql/src/internal-fields.test.tsstill describes the verifier's lookup as{ key, revoked: false }. The test is true (that query still works), but it is no longer the seam's query. Noted, not edited: it is outside the surface. Carrier: none.Generated by Claude Code