Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
661a4a2
wip(core,spec): API-key lifecycle policy at the admission seam (#23367)
claude Oct 14, 2026
12df811
wip(core): lifecycle pins; tests name the required policy input (#23367)
claude Oct 14, 2026
a37d332
wip(plugin-auth,service-settings): API-key lifecycle settings, policy…
claude Oct 14, 2026
8380648
wip(faces): every key-admitting face passes the lifecycle source and …
claude Oct 14, 2026
b06b22b
wip(runtime): signing door applies the lifecycle policy; dispatcher f…
claude Oct 14, 2026
ec45efb
wip: storage auth type declares the policy member; plugin-auth test d…
claude Oct 14, 2026
1b98781
wip: keys policy read after the data-service check; fixtures read the…
claude Oct 14, 2026
31109a8
wip(rest): lifecycle relay pin (#23367)
claude Oct 14, 2026
bd004f4
wip(dogfood): key probes read the distinct revoked code (#23367)
claude Oct 14, 2026
00fa5f5
wip(docs,platform-objects): API-key lifecycle docs, codes in the cata…
claude Oct 14, 2026
a1bc46c
wip: changeset for the API-key lifecycle (#23367)
claude Oct 14, 2026
f5ffbf9
chore(spec): regenerate api-surface, export-origins and reference doc…
claude Oct 14, 2026
bdd9f14
Merge remote-tracking branch 'origin/main' into claude/issue-23367-ap…
claude Oct 14, 2026
4c656aa
fix(tests,core,runtime): refusal pairs declare code and status; doubl…
claude Oct 14, 2026
204fb33
fix(tests): lifecycle doubles refuse operators they do not implement …
claude Oct 14, 2026
b89af7a
test(dogfood): the API-key lifecycle end to end on the showcase stack…
claude Oct 14, 2026
d18cfe5
fix(core): lifecycle refusals stamp their code through the declared w…
claude Oct 14, 2026
e50fb94
fix(runtime,service-settings,service-storage): relay the API-key life…
claude Oct 14, 2026
469acd6
docs(api): the error catalog advertises 56 wire codes, counting the f…
claude Oct 14, 2026
3c2b415
docs(api,ai): the API-key lifecycle codes reach every face that accep…
claude Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/23367-api-key-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
'@objectstack/spec': minor
'@objectstack/core': minor
'@objectstack/runtime': minor
'@objectstack/rest': minor
'@objectstack/mcp': minor
'@objectstack/plugin-auth': minor
'@objectstack/plugin-sharing': minor
'@objectstack/service-settings': minor
'@objectstack/service-storage': minor
'@objectstack/service-datasource': minor
'@objectstack/cloud-connection': minor
'@objectstack/platform-objects': minor
---

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

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).

<!-- 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). -->

**BREAKING** (an accept-set narrowing at the API-key doors), shipped as `minor` under the launch-window convention for breaking changes.

Ruling A on #23367: the key store stays `sys_api_key`, and the policy is auth configuration.

- **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.
- **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.
- **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`.
- **`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`.
- **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.
- **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.
- **`OS_CLOUD_API_KEY` is untouched.** It is the runtime's outbound credential to the control plane, never admitted by this seam.

What changes for you:

- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
55 changes: 52 additions & 3 deletions content/docs/ai/connect-mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,59 @@ Or mint one over REST:

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

### Key lifetime, inactivity and rotation

Personal API keys do not live forever. The policy is part of the
**Authentication** settings (the `auth` namespace, group **API keys**), with
these defaults:

| Rule | Default | Setting |
|:---|:---|:---|
| A key created without `expires_at` expires after | 90 days | `api_key_default_expiry_days` |
| The furthest `expires_at` a new key may ask for | 1 year from creation | `api_key_max_expiry_days` |
| A key with no recorded use is refused after | 90 days | `api_key_inactivity_days` (`0` turns it off) |

- **Creating a key.** Pass `{ "expires_at": "<ISO date>" }` to choose the
expiry. A date further away than the maximum is refused with
`400 API_KEY_EXPIRY_TOO_LONG`, whose message names the latest date allowed;
exactly the maximum is accepted. Without `expires_at` the key gets the
default lifetime, and the response echoes the date it got.
- **Use is recorded.** Each key's `last_used_at` is stamped when the key is
used (at most once an hour) and shows on the API Keys page.
- **Keys created before the policy.** A key that was created with no expiry
date gets one year of grace from the moment the deployment started enforcing
the policy (recorded once, as `api_key_policy_started_at`). The date is
written onto the key's `expires_at` the first time it is used, so the API
Keys page shows it, and the refusal after it names it. A key with no recorded
use counts its inactivity from that same moment (or from its own creation,
when that is later).

A key that stops working is refused with `403` and a code that says why — the
message names the key's prefix, the date, and the remedy:

| Code | Meaning |
|:---|:---|
| `API_KEY_EXPIRED` | Past its `expires_at` (or past the grace of a key created with none). |
| `API_KEY_INACTIVE` | No recorded use for longer than the inactivity window. The key is **not** revoked. |
| `API_KEY_REVOKED` | Revoked by its owner or an administrator. |

The remedy is the same for all three: **create a new key and replace the old
one wherever it is configured** — keys do not renew. To rotate without a gap,
create the new key first, switch the integration over, then revoke the old one.
An unknown key (a typo, a key that never existed) still gets the generic
`401 UNAUTHENTICATED`.

These codes reach the caller on every endpoint that accepts a key: the MCP
HTTP endpoint (`/api/v1/mcp`) and the other endpoints the runtime serves, the
REST data API (`/api/v1/data/…`), the settings, file-download, datasource
admin and share-link routes, the marketplace install routes, and the MCP
**stdio** transport.

Send it on every request in any of three equivalent forms:

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

The transport is **fail-closed**: a missing, unknown, revoked, expired, or
owner-less key makes stdio **refuse to start** rather than fall back to an
The transport is **fail-closed**: a missing, unknown, revoked, expired,
inactive, or owner-less key makes stdio **refuse to start** rather than fall back to an
unscoped or `system` session — there is deliberately no bypass. For a
full-authority local agent, mint the key on a platform-admin or dedicated
**service** identity; for a scoped one, mint it on a user with exactly the
Expand Down
26 changes: 24 additions & 2 deletions content/docs/api/error-catalog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ navTitle: Error Code Catalog
description: Complete reference for all ObjectStack error codes with causes, fixes, and retry strategies
---

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.
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.

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

### `API_KEY_EXPIRY_TOO_LONG`
**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.
**Fix:** Ask for that date or an earlier one, and rotate the key before it expires.
**Retry:** `no_retry`

### `VALIDATION_FAILED`
**Cause:** Record-level validation refused the write. This is the code a
*producer* names — `ValidationError` from the record and rule validators, and
Expand Down Expand Up @@ -429,6 +434,23 @@ result set — a response indistinguishable from a successful query.
**Fix:** Retry during the allowed time window.
**Retry:** `retry_after`

### `API_KEY_EXPIRED`
**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.
**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).
**Retry:** `no_retry`

### `API_KEY_INACTIVE`
**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.
**Fix:** Create a new key and replace this one wherever it is configured.
**Retry:** `no_retry`

### `API_KEY_REVOKED`
**Cause:** The API key was revoked by its owner or an administrator.
**Fix:** Create a new key and replace this one wherever it is configured. A revoked key never works again.
**Retry:** `no_retry`

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.

---

## Not Found Errors (404)
Expand Down Expand Up @@ -942,7 +964,7 @@ async function handleApiCall() {
|:---:|:---|:---|
| 400 | `validation` | `VALIDATION_FAILED`, `VALIDATION_ERROR`, `INVALID_FIELD`, `MISSING_REQUIRED_FIELD`, `INVALID_QUERY` |
| 401 | `authentication` | `UNAUTHENTICATED`, `EXPIRED_TOKEN`, `INVALID_CREDENTIALS` |
| 403 | `authorization` | `PERMISSION_DENIED`, `FIELD_NOT_ACCESSIBLE`, `LICENSE_REQUIRED` |
| 403 | `authorization` | `PERMISSION_DENIED`, `FIELD_NOT_ACCESSIBLE`, `LICENSE_REQUIRED`, `API_KEY_EXPIRED`, `API_KEY_INACTIVE`, `API_KEY_REVOKED` |
| 404 | `not_found` | `RECORD_NOT_FOUND`, `OBJECT_NOT_FOUND`, `ENDPOINT_NOT_FOUND` |
| 409 | `conflict` | `CONCURRENT_MODIFICATION`, `UNIQUE_VIOLATION`, `DELETE_RESTRICTED` |
| 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)) |
Expand Down
6 changes: 5 additions & 1 deletion content/docs/api/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,11 @@ permission model grants anonymous users. Two ways to authenticate:
(the key is shown once), or from the **Connect an Agent** page in the Console
— **Account → Developer** for any signed-in user, **Setup → Connect an Agent**
for platform admins. Send it as `x-api-key: osk_…` or
`Authorization: Bearer osk_…`.
`Authorization: Bearer osk_…`. Keys expire (90 days by default, at most a
year) and stop working after 90 days without use; a refused key answers
`403` with `API_KEY_EXPIRED`, `API_KEY_INACTIVE` or `API_KEY_REVOKED`, and the
remedy is a new key — see
[key lifetime, inactivity and rotation](/docs/ai/connect-mcp#key-lifetime-inactivity-and-rotation).

See [Authentication](/docs/permissions/authentication) for the full identity
surface (OAuth flows, sessions, providers) and
Expand Down
6 changes: 5 additions & 1 deletion content/docs/references/api/contract.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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) |
| **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) |
| **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) |
| **message** | `string` | ✅ | Readable error message |
| **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`. |
Expand Down Expand Up @@ -96,6 +96,10 @@ const result = ApiErrorSchema.parse(data);
* `AMBIGUOUS_MATCH`
* `ANALYTICS_DATE_RANGE_UNRECOGNIZED`
* `ANALYTICS_QUERY_FAILED`
* `API_KEY_EXPIRED`
* `API_KEY_EXPIRY_TOO_LONG`
* `API_KEY_INACTIVE`
* `API_KEY_REVOKED`
* `APPROVAL_ACTIONS_FAILED`
* `APPROVAL_APPROVE_FAILED`
* `APPROVAL_COMMENT_FAILED`
Expand Down
4 changes: 4 additions & 0 deletions content/docs/references/api/error-code-ledger.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,10 @@ const result = ErrorCode.parse(data);
* `AMBIGUOUS_MATCH`
* `ANALYTICS_DATE_RANGE_UNRECOGNIZED`
* `ANALYTICS_QUERY_FAILED`
* `API_KEY_EXPIRED`
* `API_KEY_EXPIRY_TOO_LONG`
* `API_KEY_INACTIVE`
* `API_KEY_REVOKED`
* `APPROVAL_ACTIONS_FAILED`
* `APPROVAL_APPROVE_FAILED`
* `APPROVAL_COMMENT_FAILED`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ async function boot(kind: Kind, dbFile: string, rootDir: string, app: any): Prom
async function readAs(ql: any, userId: string, org: string): Promise<'granted' | 'refused'> {
const authz = await resolveAuthzContext({
userAuthGates: undefined,
apiKeyLifecycle: undefined,
ql,
headers: {},
getSession: () => ({ user: { id: userId }, session: { userId, activeOrganizationId: org } }),
Expand Down
10 changes: 10 additions & 0 deletions packages/cloud-connection/src/marketplace-install-local-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ import {
// [#23455] The auth service's per-user posture computation, bound for the
// shared key-admission seam.
userAuthGateSourceOf,
// [#23367] The API-key lifecycle policy source, and its branded refusal.
apiKeyLifecyclePolicySourceOf,
isApiKeyLifecycleRefusalError,
// [#16013] The ADMISSION seam's posture derivation, in ONE call — the same
// symbol `packages/rest`'s seam now imports, for the same reason. It reads
// the posture IN FORCE off the kernel's `tenancy` service and carries
Expand Down Expand Up @@ -3366,6 +3369,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
getSession,
tenancyPosture,
userAuthGates: userAuthGateSourceOf(authService),
// [#23367] The API-key lifecycle policy, from the same service.
apiKeyLifecycle: apiKeyLifecyclePolicySourceOf(authService),
});
if (!authz.userId) return null;
// ADR-0069: the ONE consult of the authentication-policy gate for
Expand All @@ -3391,6 +3396,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
// [commit 6a180e42d] `null` here means "nobody is authenticated", which is
// not what a permission-store outage established. Re-raised.
if (isAuthzStoreUnavailableError(err)) throw err;
// [#23367, ruling A] A key its own lifecycle refused (revoked,
// expired, inactive) is re-raised too: the five doors mount on the
// host's raw app, whose thrown-envelope render answers its declared
// `403` and registered code with the located message.
if (isApiKeyLifecycleRefusalError(err)) throw err;
return null;
}
};
Expand Down
Loading
Loading