Skip to content

Commit 1d79556

Browse files
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)
Fixes #23367 Clause-②: yes Executes ruling `6159880505` (letter A, maintainer 「同意」) on the re-scoped claim `6173499602`, as amended by `6178205488` (the three relays below): the key store stays `sys_api_key`, and the API-key lifecycle is auth configuration. The changeset declares the arm, `Clause-②: yes (narrowing)`: the published contract widens (four `auth` settings keys, `IAuthService.resolveApiKeyLifecyclePolicy`, four registered codes, new `@objectstack/core` exports), and the key doors narrow. ## What changed **1. The policy is auth configuration (`@objectstack/service-settings`, `@objectstack/plugin-auth`, `@objectstack/spec`).** - `auth.manifest.ts` gains the group **API keys**: `api_key_default_expiry_days` (90), `api_key_max_expiry_days` (365), `api_key_inactivity_days` (90, `0` = off), and `api_key_policy_started_at`. - `bindAuthSettings` binds the three numbers into `AuthManager` (`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 as `IAuthService.resolveApiKeyLifecyclePolicy` (type `ApiKeyLifecyclePolicy`). - **The landing is recorded once, in one place.** On `kernel:bootstrapped`, after the settings engine binds, plugin-auth writes `auth.api_key_policy_started_at` when 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 why `kernel:bootstrapped`: a write inside the `kernel:ready` window is refused `SETTINGS_ENGINE_NOT_BOUND`. With no settings service the anchor cannot be kept: said once at `warn`, and the two clocks it drives stay off. A failed write is said at `error`. **2. The signing door (`POST /api/v1/keys`, `@objectstack/runtime`).** It reads the same source as the seam, through the `auth` service. - No `expires_at` gives now + `defaultExpiryDays`, echoed in the response. - An `expires_at` past now + `maxExpiryDays` is refused `400 API_KEY_EXPIRY_TOO_LONG`, naming the latest date allowed. Exactly the ceiling is accepted, with 60 s of forgiven client clock skew. - A kernel with no `auth` service signs as before. An `auth` service that is registered and broken is the store outage (`503`), never a policy-less mint. **3. The key-admission seam (`resolveApiKeyAdmission`, `@objectstack/core`).** - The lookup is by hash alone, so a revoked row is found and refused with its own code. A new outcome, `lifecycle`, carries one of three distinct codes: `API_KEY_REVOKED`, `API_KEY_EXPIRED` (past `expires_at`, or past the grace of a key with none) and `API_KEY_INACTIVE`. The located message names the key's prefix, the date and the remedy. An unknown key is still `none`. - **Inactivity reads the PRE-stamp value.** The verdict reads the row as found, and the stamp runs only for a key that verdict admits. A key's own call can never revive it, and a refusal writes nothing (not a revocation). - **Keys with no recorded use** count from the landing. They count from their own `created_at` only when that is later, i.e. a key signed after the landing. An earlier `created_at` is 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 "never `created_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. - **Keys with no expiry** get `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. - `resolveAuthzContext` takes the policy as a **REQUIRED** input, `apiKeyLifecycle`, beside `userAuthGates` (the #23508 shape). It raises a `lifecycle` verdict as the branded `ApiKeyLifecycleRefusalError` (`403`, code, located message), never falls through to the session, and logs one operator line. **4. `last_used_at` is 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`), through `ql.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. - **Measured against the durability log-level rule.** A failed stamp never fails the request, because the key was judged on the row as found. It is a durability degradation all the same: the field claims each use, and an unrecorded use lets a key in daily use read as idle. So it is said at `error`, with the consequence and the fix, **once per data engine** (a `WeakSet` latch), never once per request. - The DB-held throttle needs no in-memory state, survives restarts and is multi-node safe; a race costs one duplicate write. The description now reads "Recorded when the key is used, at most once an hour…". The `en` bundle was regenerated; `zh-CN`, `ja-JP` and `es-ES` were translated by hand. **5. Every call site that builds `ResolveAuthzInput` passes the source** (`apiKeyLifecyclePolicySourceOf(authService)`). They are the 8 faces plus the two test seams; the test seams pass an explicit `undefined` (no auth service) or the real source (dogfood `armed.ts`). The storage gate reader's direct `resolveApiKeyAdmission` call passes it too. The refusal's answer on each face: | Face | Answer to a revoked / expired / inactive key | |:--|:--| | REST data API (`rest-server.ts`) | `403` + code + located message. `computeExecCtx` resolves no context, keeps the refusal beside the request (a `WeakMap`), and `enforceAuth` answers it. Every other door keeps its fail-closed answer. | | MCP stdio (`mcp/src/plugin.ts`) | per call: the thrown `403` envelope; at start: refuses to start, naming the code | | datasource admin (`admin-routes.ts`) | `403` + code (re-raised ahead of the routes' `try`; the host's thrown-envelope render answers it) | | share links (`sharing-plugin.ts`) | `403` + code (the family door relays a throw that declares its status) | | marketplace install-local (`marketplace-install-local-plugin.ts`) | `403` + code (raw-app routes; the host's `onError` envelope render) | | runtime dispatcher (`http-dispatcher.ts` identity step), incl. MCP HTTP | `403` + code (re-raised by brand beside the store outage; the transport's `errorResponseBase` renders the declared status and registered code) | | settings (`settings-service-plugin.ts` → `settings-routes.ts`) | `403` + code (the resolver re-raises the brand; the family door relays it beside `relayAuthzStoreOutage`) | | storage download (`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_LEDGER` and `content/docs/api/error-catalog.mdx`. `API_KEY_EXPIRED`, `API_KEY_INACTIVE` and `API_KEY_REVOKED` sit under `@objectstack/core`; `API_KEY_EXPIRY_TOO_LONG` sits under `@objectstack/runtime`. `EXPIRED_TOKEN` was judged and not reused. Its catalog meaning is a JWT or session token whose remedy is a refresh, and its retry is `retry_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 }`), so `check:error-status-conformance` derives a producer for it. **7. Docs.** `content/docs/ai/connect-mcp.mdx` gains **Key lifetime, inactivity and rotation**: the policy, the codes, rotation without a gap, and which faces answer the codes. `content/docs/api/index.mdx` points 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_KEY` Read 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 by `resolveApiKeyAdmission`, and `cloud-connection-plugin.ts` records that the `sys_api_key`-backed data API rejects it with 401. It is **not a `sys_api_key` row: untouched**, and no exemption question arises. Its second use, the CLI publish `--token` env fallback, carries whatever key the user sets; a personal `osk_` 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. - **runtime dispatcher**: `packages/runtime/src/http-dispatcher.ts`, the identity step's `catch` only: `if (isApiKeyLifecycleRefusalError(err)) throw err;` ahead of `rethrowAuthzStoreUnavailable(err)`, plus the name in the file's `@objectstack/core` import. `dispatch()` leaves with the refusal, and `dispatcher-plugin`'s `errorResponseBase` answers its `403` and code. **This includes the MCP HTTP endpoint** (`/api/v1/mcp`) and the declarative-endpoint fallback, which renders through the same `errorResponse`. - **settings**: `settings-routes.ts` gains `relayApiKeyLifecycleRefusal` beside `relayAuthzStoreOutage`, called from the same family-door `catch`; `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**: `storage-routes.ts`, the download gate's `catch` (`authorizeDownload`, shared by the URL and redirect routes): the brand is relayed beside the outage. `storage-service-plugin.ts`'s `buildFileReadAuthorizer` re-raises it instead of returning `'unauthenticated'`. - **The storage gate catch (`refuseHeldSession`) is not edited**, because no brand can reach it. Its reader (`buildAuthGateSessionReader`) calls `resolveApiKeyAdmission`, which returns a `lifecycle` outcome and never throws one. The reader answers that outcome with no posture, as it answers `refused`, 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/spec` `test:repo`) is fixed in `469acd6a41`. 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 to `deriveWireFace()`. 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()` raising `403 API_KEY_REVOKED`, and `403 API_KEY_EXPIRED` with a valid cookie beside the key, nothing minted; an unbranded throw at the identity step spelling the same code and status, still answered the generic `401 UNAUTHENTICATED`; an unknown key, `401`; a live control, `201`; and, on the wire through the mounted `POST /api/v1/mcp` route, `403 API_KEY_REVOKED` in a schema-valid envelope and `403 API_KEY_INACTIVE`. Settings: `settings-routes.api-key-lifecycle.pin.test.ts` (5): revoked on every mounted route, expired, inactive, unknown key `401 UNAUTHENTICATED`, and a live control. Storage: `storage-routes.api-key-lifecycle.pin.test.ts` (4): revoked and expired give `403` with the code and nothing minted, an unknown key gives `401 AUTH_REQUIRED`, and a live key gets its signed URL. REST: `rest-api-key-lifecycle.pin.test.ts` (4). plugin-auth: four policy cases in `auth-plugin.test.ts`. Dogfood: `api-key-lifecycle.dogfood.test.ts` (3, the showcase stack end to end). It proves the landing is recorded in the `auth` settings, the 90-day default, the `400 API_KEY_EXPIRY_TOO_LONG`, and that **one admitted call really writes `last_used_at` through 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 as `401`, or as `403` with code `API_KEY_REVOKED` and only that code. Pins against the dispatch's list: no `expires_at` gives +90 d; +365 d accepted, +366 d refused with its code; idle > 90 d refused `API_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 on `expires_at` from 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 writes `last_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 HEAD `204fb33318`; the core suite imports `./api-key.js` from source, so no build stands between the mutation and the run): - **Inactivity check.** `nowMs - idle.ms > policy.inactivityDays * DAY_MS` was replaced by `false`. Anchor x1 → x0, blob `2c7db74f5e18` → `e3f45c2db200`: **5 failed / 15 passed** (three codes, pre-stamp, landing anchor, signing anchor, source number). Restored: blob == HEAD `2c7db74f5e18`, `git diff HEAD` empty. - **The stamp.** The `ql.update('sys_api_key', …)` line was replaced by `void 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 HEAD` empty. - **The dispatcher relay** (at committed HEAD `e50fb94b57`; the pin imports `./http-dispatcher.js` and `./dispatcher-plugin.js` from source, so no build stands between). `if (isApiKeyLifecycleRefusalError(err)) throw err;` was replaced by `void 0;`. Anchor x1 → x0, blob `22d1fe62b338` → `e0459ffb1954`: **4 failed / 10 passed** (the two `dispatch()` codes and the two MCP HTTP wire codes). The unbranded-throw, unknown-key and live controls stayed green. Restored: blob == HEAD `22d1fe62b338`, `git diff HEAD` empty. ## 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 `d18cfe5488` after merging `origin/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/`enforceAuth` files (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). - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 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-loads` and `check:i18n` exited 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-prereqs` and `check-nul-bytes` exit 0. - **Typecheck** (exit 0): runtime (`tsc` plus `check: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`). - **Tests:** - full suites: service-settings (47 files / 831) and service-storage (57 / 991); - runtime: the 76 test files that name the key, the resolver or the identity step (2324 passed, 18 skipped). That set comes from a `git grep`, which is blind to a test that reaches the identity step without naming it; CI runs the full suite; - spec, the `repo` project in full (`pnpm --filter @objectstack/spec run test:repo`): **61 files / 1033 tests passed**, including `error-catalog-docs.test.ts`, the file red at `d18cfe5488`; - dogfood, on the showcase stack: the four key tests, the lifecycle test and `showcase-mcp-http-identity` (5 files / 29 passed). ## Acceptance notes - **Change size: 81 files, +2741 / −178 = 2919 changed lines** (against merge-base `989aa483fd`), under the 3000-line class. - `ApiKeyLifecycleRefusalError` is a throw rather than an envelope field, for the reason `authz-store-unavailable.ts` records. 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. - The amendment's three files each gained one name in their `@objectstack/core` import (`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.mdx` and `connect-mcp.mdx` now list every face instead of naming three as answering the generic 401. - `packages/core/src/security/index.ts` gained the new exports (the barrel of the claimed `api-key.ts`). `scripts/engine-double-contract.pinned.json` records the new pinned double (`--write`, as the gate prescribes). - `packages/objectql/src/internal-fields.test.ts` still 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](https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8226755 commit 1d79556

81 files changed

Lines changed: 2741 additions & 178 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/core': minor
4+
'@objectstack/runtime': minor
5+
'@objectstack/rest': minor
6+
'@objectstack/mcp': minor
7+
'@objectstack/plugin-auth': minor
8+
'@objectstack/plugin-sharing': minor
9+
'@objectstack/service-settings': minor
10+
'@objectstack/service-storage': minor
11+
'@objectstack/service-datasource': minor
12+
'@objectstack/cloud-connection': minor
13+
'@objectstack/platform-objects': minor
14+
---
15+
16+
feat(spec,core,runtime,plugin-auth)!: API keys get a lifecycle: a 90-day default expiry, a one-year ceiling, a 90-day inactivity expiry, a one-year grace for keys created with none, and distinct refusal codes
17+
18+
Clause-②: yes (narrowing) — the published contract widens (four `auth` settings keys, the `IAuthService.resolveApiKeyLifecyclePolicy` member and its `ApiKeyLifecyclePolicy` type, four registered error codes, new `@objectstack/core` exports) and the API-key doors narrow (a signing past the ceiling is refused; an inactive key, a key past its grace, and a revoked or expired key sent beside a session are refused where they were served).
19+
20+
<!-- adr-0087: not-required (no-migration-prescription) An authentication-policy change at the API-key signing door and the shared key-admission seam, not a metadata change. No authorable spec key, stored shape or metadata surface is removed, renamed or re-shaped, so `objectstack migrate meta` has nothing to rewrite: the four new `auth` settings keys are additive and defaulted, `sys_api_key` gains no column (its existing `expires_at` and `last_used_at` are written by the platform), and `ResolveAuthzInput.apiKeyLifecycle` / the sixth parameter of `resolveApiKeyAdmission` and `resolveApiKeyPrincipal` are server-side wiring no author writes. What narrows is which API keys are served and which signings are accepted; the remedy is a new key or a nearer date, never a rewrite of a package source or a stored row. The packages publish (not unpublished), no ADR-0087 id covers this and none is added (not registered or already-registered). -->
21+
22+
**BREAKING** (an accept-set narrowing at the API-key doors), shipped as `minor` under the launch-window convention for breaking changes.
23+
24+
Ruling A on #23367: the key store stays `sys_api_key`, and the policy is auth configuration.
25+
26+
- **The policy (`@objectstack/plugin-auth`, `IAuthService.resolveApiKeyLifecyclePolicy`).** Three `auth` settings keys, bound by `bindAuthSettings`: `api_key_default_expiry_days` (90), `api_key_max_expiry_days` (365) and `api_key_inactivity_days` (90; `0` turns it off). A fourth, `api_key_policy_started_at`, is recorded once — on the first boot that runs the policy, at `kernel:bootstrapped` — and anchors the two clocks below; it survives restarts and is never overwritten by the platform.
27+
- **The signing door (`POST /api/v1/keys`).** A signing that names no `expires_at` gets the default life and the response echoes it. An `expires_at` further away than the ceiling is refused `400 API_KEY_EXPIRY_TOO_LONG`, naming the latest date allowed; exactly the ceiling is accepted. A kernel with no auth service signs as before.
28+
- **The key-admission seam (`resolveApiKeyAdmission`, `@objectstack/core`).** A real key its own state refuses is a new outcome, `lifecycle`, with one of three distinct codes: `API_KEY_REVOKED`, `API_KEY_EXPIRED` (past `expires_at`, or past the grace of a key created with none) or `API_KEY_INACTIVE` (no recorded use for longer than the window). `resolveAuthzContext` raises it as the branded `ApiKeyLifecycleRefusalError` (`403`, a located message naming the key's prefix, the date and the remedy). An unknown key is still `none`.
29+
- **`last_used_at` is written.** Its description promised "Automatically updated on each API call", and nothing wrote it. The seam now stamps it on an admitted (or held) call, at most once an hour per key, and the inactivity judgment reads the value as it was BEFORE that call's stamp, so a key's own use cannot revive it. A stamp that fails never fails the request; it is said once, at `error`.
30+
- **Keys created before the policy.** A key with no expiry gets one year from `api_key_policy_started_at`; the date is stamped onto its `expires_at` the first time it is admitted, so the keys page shows it, and the refusal after it names it. A key with no recorded use counts its inactivity from the same anchor (or from its own creation, when later) — never from an earlier `created_at`, since no use was ever recorded before the policy.
31+
- **Every face that admits keys** passes the policy source (`apiKeyLifecyclePolicySourceOf(authService)`, a required input beside `userAuthGates`). Every one of them answers the refusal's `403` and code: the runtime dispatcher (the MCP HTTP endpoint among its routes), the REST data API, the settings routes, the storage download routes, the datasource admin routes, the share-link routes, the marketplace install-local routes and the MCP stdio transport. Each relays the refusal by its brand, so every other identity fault at those doors keeps its existing answer.
32+
- **`OS_CLOUD_API_KEY` is untouched.** It is the runtime's outbound credential to the control plane, never admitted by this seam.
33+
34+
What changes for you:
35+
36+
- **A key created without an expiry now expires.** It used to live until revoked; it now lives 90 days (or the configured default). Pass `expires_at` to choose, and create a new key before it ends.
37+
- **A signing asking for more than a year is refused** `400 API_KEY_EXPIRY_TOO_LONG`, where it used to be accepted. Ask for the date the message names, or an earlier one.
38+
- **A key unused for 90 days is refused** `403 API_KEY_INACTIVE`, where it used to be served. It is not revoked; the remedy is a new key.
39+
- **A key that already existed with no expiry keeps working for one year** from the date the policy took effect on your deployment (`auth.api_key_policy_started_at`), then answers `403 API_KEY_EXPIRED` naming that date. Replace such keys within the year.
40+
- **A revoked or expired key answers its own code** (`403 API_KEY_REVOKED` / `API_KEY_EXPIRED`) on every face that accepts a key, where it used to answer the generic `401`; and a session cookie sent beside such a key no longer answers for it.
41+
- **Hosts calling `resolveAuthzContext`, `resolveApiKeyAdmission` or `resolveApiKeyPrincipal` directly** pass `apiKeyLifecycle: apiKeyLifecyclePolicySourceOf(authService)` (or the sixth argument), or an explicit `undefined` where no auth service exists. A caller of `resolveAuthzContext` that wants the located answer re-raises `ApiKeyLifecycleRefusalError` (`isApiKeyLifecycleRefusalError`) the way it re-raises the store outage. Code switching exhaustively over `ApiKeyAdmission['outcome']` handles the new `lifecycle` outcome.

‎content/docs/ai/connect-mcp.mdx‎

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,59 @@ Or mint one over REST:
121121

122122
```bash
123123
curl -b cookies.txt -X POST https://your-deployment.example.com/api/v1/keys
124-
# → { "success": true, "data": { "key": "osk_...", "prefix": "osk_...", "name": "API Key" } }
124+
# → { "success": true, "data": { "key": "osk_...", "prefix": "osk_...", "name": "API Key",
125+
# "expires_at": "2027-01-12T09:30:00.000Z" } }
125126
# the raw key is shown once — store it in your secret manager
126127
```
127128

129+
### Key lifetime, inactivity and rotation
130+
131+
Personal API keys do not live forever. The policy is part of the
132+
**Authentication** settings (the `auth` namespace, group **API keys**), with
133+
these defaults:
134+
135+
| Rule | Default | Setting |
136+
|:---|:---|:---|
137+
| A key created without `expires_at` expires after | 90 days | `api_key_default_expiry_days` |
138+
| The furthest `expires_at` a new key may ask for | 1 year from creation | `api_key_max_expiry_days` |
139+
| A key with no recorded use is refused after | 90 days | `api_key_inactivity_days` (`0` turns it off) |
140+
141+
- **Creating a key.** Pass `{ "expires_at": "<ISO date>" }` to choose the
142+
expiry. A date further away than the maximum is refused with
143+
`400 API_KEY_EXPIRY_TOO_LONG`, whose message names the latest date allowed;
144+
exactly the maximum is accepted. Without `expires_at` the key gets the
145+
default lifetime, and the response echoes the date it got.
146+
- **Use is recorded.** Each key's `last_used_at` is stamped when the key is
147+
used (at most once an hour) and shows on the API Keys page.
148+
- **Keys created before the policy.** A key that was created with no expiry
149+
date gets one year of grace from the moment the deployment started enforcing
150+
the policy (recorded once, as `api_key_policy_started_at`). The date is
151+
written onto the key's `expires_at` the first time it is used, so the API
152+
Keys page shows it, and the refusal after it names it. A key with no recorded
153+
use counts its inactivity from that same moment (or from its own creation,
154+
when that is later).
155+
156+
A key that stops working is refused with `403` and a code that says why — the
157+
message names the key's prefix, the date, and the remedy:
158+
159+
| Code | Meaning |
160+
|:---|:---|
161+
| `API_KEY_EXPIRED` | Past its `expires_at` (or past the grace of a key created with none). |
162+
| `API_KEY_INACTIVE` | No recorded use for longer than the inactivity window. The key is **not** revoked. |
163+
| `API_KEY_REVOKED` | Revoked by its owner or an administrator. |
164+
165+
The remedy is the same for all three: **create a new key and replace the old
166+
one wherever it is configured** — keys do not renew. To rotate without a gap,
167+
create the new key first, switch the integration over, then revoke the old one.
168+
An unknown key (a typo, a key that never existed) still gets the generic
169+
`401 UNAUTHENTICATED`.
170+
171+
These codes reach the caller on every endpoint that accepts a key: the MCP
172+
HTTP endpoint (`/api/v1/mcp`) and the other endpoints the runtime serves, the
173+
REST data API (`/api/v1/data/…`), the settings, file-download, datasource
174+
admin and share-link routes, the marketplace install routes, and the MCP
175+
**stdio** transport.
176+
128177
Send it on every request in any of three equivalent forms:
129178

130179
| Header | Example |
@@ -159,8 +208,8 @@ security, field-level security, and tenant isolation apply to the stdio session
159208
exactly as they would to that identity in the Console — and revoking the key
160209
stops the session on its next call.
161210

162-
The transport is **fail-closed**: a missing, unknown, revoked, expired, or
163-
owner-less key makes stdio **refuse to start** rather than fall back to an
211+
The transport is **fail-closed**: a missing, unknown, revoked, expired,
212+
inactive, or owner-less key makes stdio **refuse to start** rather than fall back to an
164213
unscoped or `system` session — there is deliberately no bypass. For a
165214
full-authority local agent, mint the key on a platform-admin or dedicated
166215
**service** identity; for a scoped one, mint it on a user with exactly the

‎content/docs/api/error-catalog.mdx‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ navTitle: Error Code Catalog
44
description: Complete reference for all ObjectStack error codes with causes, fixes, and retry strategies
55
---
66

7-
ObjectStack uses a structured error system with **9 error categories** and **52 error codes reachable on the wire**. Every error includes a machine-readable code, HTTP status mapping, and retry guidance.
7+
ObjectStack uses a structured error system with **9 error categories** and **56 error codes reachable on the wire**. Every error includes a machine-readable code, HTTP status mapping, and retry guidance.
88

99
This catalog documents the **wire face** — the codes a client can actually receive. That is not quite the
1010
`StandardErrorCode` enum: the enum also carries in-process spellings the REST door translates at the
@@ -81,6 +81,11 @@ settings' `max_upload_mb`, and an import over its route's row ceiling, answer `4
8181
}
8282
```
8383

84+
### `API_KEY_EXPIRY_TOO_LONG`
85+
**Cause:** `POST /api/v1/keys` asked for an `expires_at` further away than the deployment's maximum key lifetime (`api_key_max_expiry_days`, one year by default). The message names the latest date allowed; exactly the maximum is accepted.
86+
**Fix:** Ask for that date or an earlier one, and rotate the key before it expires.
87+
**Retry:** `no_retry`
88+
8489
### `VALIDATION_FAILED`
8590
**Cause:** Record-level validation refused the write. This is the code a
8691
*producer* names — `ValidationError` from the record and rule validators, and
@@ -429,6 +434,23 @@ result set — a response indistinguishable from a successful query.
429434
**Fix:** Retry during the allowed time window.
430435
**Retry:** `retry_after`
431436

437+
### `API_KEY_EXPIRED`
438+
**Cause:** The API key is past its `expires_at`, or it was created with no expiry and the one-year grace the API-key lifecycle policy gave it has ended. The message names the key's prefix and the date.
439+
**Fix:** Keys do not renew: create a new key and replace this one wherever it is configured. See [key lifetime, inactivity and rotation](/docs/ai/connect-mcp#key-lifetime-inactivity-and-rotation).
440+
**Retry:** `no_retry`
441+
442+
### `API_KEY_INACTIVE`
443+
**Cause:** The API key has no recorded use for longer than the deployment's inactivity window (`api_key_inactivity_days`, 90 by default). The key is not revoked; its own call does not revive it. The message names the date the idle period started.
444+
**Fix:** Create a new key and replace this one wherever it is configured.
445+
**Retry:** `no_retry`
446+
447+
### `API_KEY_REVOKED`
448+
**Cause:** The API key was revoked by its owner or an administrator.
449+
**Fix:** Create a new key and replace this one wherever it is configured. A revoked key never works again.
450+
**Retry:** `no_retry`
451+
452+
These three are answered on every endpoint that accepts an API key: the endpoints the runtime serves (the MCP HTTP endpoint among them), the REST data API, the settings, file-download, datasource admin and share-link routes, the marketplace install routes and the MCP stdio transport. An unknown key is always the generic 401.
453+
432454
---
433455

434456
## Not Found Errors (404)
@@ -942,7 +964,7 @@ async function handleApiCall() {
942964
|:---:|:---|:---|
943965
| 400 | `validation` | `VALIDATION_FAILED`, `VALIDATION_ERROR`, `INVALID_FIELD`, `MISSING_REQUIRED_FIELD`, `INVALID_QUERY` |
944966
| 401 | `authentication` | `UNAUTHENTICATED`, `EXPIRED_TOKEN`, `INVALID_CREDENTIALS` |
945-
| 403 | `authorization` | `PERMISSION_DENIED`, `FIELD_NOT_ACCESSIBLE`, `LICENSE_REQUIRED` |
967+
| 403 | `authorization` | `PERMISSION_DENIED`, `FIELD_NOT_ACCESSIBLE`, `LICENSE_REQUIRED`, `API_KEY_EXPIRED`, `API_KEY_INACTIVE`, `API_KEY_REVOKED` |
946968
| 404 | `not_found` | `RECORD_NOT_FOUND`, `OBJECT_NOT_FOUND`, `ENDPOINT_NOT_FOUND` |
947969
| 409 | `conflict` | `CONCURRENT_MODIFICATION`, `UNIQUE_VIOLATION`, `DELETE_RESTRICTED` |
948970
| 413 | `validation` | `PAYLOAD_TOO_LARGE` — an upload over the storage settings' `max_upload_mb`, or an import over its row ceiling (see [above](#payload_too_large)) |

‎content/docs/api/index.mdx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,11 @@ permission model grants anonymous users. Two ways to authenticate:
6969
(the key is shown once), or from the **Connect an Agent** page in the Console
7070
— **Account → Developer** for any signed-in user, **Setup → Connect an Agent**
7171
for platform admins. Send it as `x-api-key: osk_…` or
72-
`Authorization: Bearer osk_…`.
72+
`Authorization: Bearer osk_…`. Keys expire (90 days by default, at most a
73+
year) and stop working after 90 days without use; a refused key answers
74+
`403` with `API_KEY_EXPIRED`, `API_KEY_INACTIVE` or `API_KEY_REVOKED`, and the
75+
remedy is a new key — see
76+
[key lifetime, inactivity and rotation](/docs/ai/connect-mcp#key-lifetime-inactivity-and-rotation).
7377

7478
See [Authentication](/docs/permissions/authentication) for the full identity
7579
surface (OAuth flows, sessions, providers) and

‎content/docs/references/api/contract.mdx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);
2828

2929
| Property | Type | Required | Description |
3030
| :--- | :--- | :--- | :--- |
31-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +327 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
31+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +331 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
3232
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
3333
| **message** | `string` | ✅ | Readable error message |
3434
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
@@ -96,6 +96,10 @@ const result = ApiErrorSchema.parse(data);
9696
* `AMBIGUOUS_MATCH`
9797
* `ANALYTICS_DATE_RANGE_UNRECOGNIZED`
9898
* `ANALYTICS_QUERY_FAILED`
99+
* `API_KEY_EXPIRED`
100+
* `API_KEY_EXPIRY_TOO_LONG`
101+
* `API_KEY_INACTIVE`
102+
* `API_KEY_REVOKED`
99103
* `APPROVAL_ACTIONS_FAILED`
100104
* `APPROVAL_APPROVE_FAILED`
101105
* `APPROVAL_COMMENT_FAILED`

‎content/docs/references/api/error-code-ledger.mdx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,10 @@ const result = ErrorCode.parse(data);
263263
* `AMBIGUOUS_MATCH`
264264
* `ANALYTICS_DATE_RANGE_UNRECOGNIZED`
265265
* `ANALYTICS_QUERY_FAILED`
266+
* `API_KEY_EXPIRED`
267+
* `API_KEY_EXPIRY_TOO_LONG`
268+
* `API_KEY_INACTIVE`
269+
* `API_KEY_REVOKED`
266270
* `APPROVAL_ACTIONS_FAILED`
267271
* `APPROVAL_APPROVE_FAILED`
268272
* `APPROVAL_COMMENT_FAILED`

‎packages/cli/test/security-catalog-echo-env-kernel.integration.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ async function boot(kind: Kind, dbFile: string, rootDir: string, app: any): Prom
9999
async function readAs(ql: any, userId: string, org: string): Promise<'granted' | 'refused'> {
100100
const authz = await resolveAuthzContext({
101101
userAuthGates: undefined,
102+
apiKeyLifecycle: undefined,
102103
ql,
103104
headers: {},
104105
getSession: () => ({ user: { id: userId }, session: { userId, activeOrganizationId: org } }),

‎packages/cloud-connection/src/marketplace-install-local-plugin.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,9 @@ import {
120120
// [#23455] The auth service's per-user posture computation, bound for the
121121
// shared key-admission seam.
122122
userAuthGateSourceOf,
123+
// [#23367] The API-key lifecycle policy source, and its branded refusal.
124+
apiKeyLifecyclePolicySourceOf,
125+
isApiKeyLifecycleRefusalError,
123126
// [#16013] The ADMISSION seam's posture derivation, in ONE call — the same
124127
// symbol `packages/rest`'s seam now imports, for the same reason. It reads
125128
// the posture IN FORCE off the kernel's `tenancy` service and carries
@@ -3366,6 +3369,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
33663369
getSession,
33673370
tenancyPosture,
33683371
userAuthGates: userAuthGateSourceOf(authService),
3372+
// [#23367] The API-key lifecycle policy, from the same service.
3373+
apiKeyLifecycle: apiKeyLifecyclePolicySourceOf(authService),
33693374
});
33703375
if (!authz.userId) return null;
33713376
// ADR-0069: the ONE consult of the authentication-policy gate for
@@ -3391,6 +3396,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
33913396
// [commit 6a180e42d] `null` here means "nobody is authenticated", which is
33923397
// not what a permission-store outage established. Re-raised.
33933398
if (isAuthzStoreUnavailableError(err)) throw err;
3399+
// [#23367, ruling A] A key its own lifecycle refused (revoked,
3400+
// expired, inactive) is re-raised too: the five doors mount on the
3401+
// host's raw app, whose thrown-envelope render answers its declared
3402+
// `403` and registered code with the located message.
3403+
if (isApiKeyLifecycleRefusalError(err)) throw err;
33943404
return null;
33953405
}
33963406
};

0 commit comments

Comments
 (0)