diff --git a/.changeset/identity-api-key-schema-retired.md b/.changeset/identity-api-key-schema-retired.md new file mode 100644 index 0000000000..5c7b80b839 --- /dev/null +++ b/.changeset/identity-api-key-schema-retired.md @@ -0,0 +1,69 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): retire `ApiKeySchema` — the identity module no longer publishes a second, fictional declaration of `sys_api_key` (#8715, ADR-0049) + + + +**BREAKING** public-surface removal, landing after the v17.0.0 cut (the +lockstep launch-window convention ships it as `minor`; the migration +prescription is registered under protocol major 18, where `os migrate meta` +users will look — the #8586 precedent). + +`ApiKeySchema` (and its `ApiKey` / `ApiKeyParsed` types) documented +better-auth's `apiKey` **plugin** schema — a plugin this platform does not +load: `start` and `lastRefetchAt` name columns that do not exist; `enabled` +inverts the real `revoked` column's polarity; `rateLimitEnabled` / +`rateLimitTimeWindow` / `rateLimitMax` / `remaining` advertise a per-key +rate-limit capability nothing implements; `permissions` and `metadata` have no +columns; `organizationId` is camelCase fiction next to the real snake_case +`active_organization_id`. Zero consumers anywhere in the monorepo outside its +own unit test — one table had two declarations, and the published one was +fiction (maintainer-ruled DELETE, 2026-08-15). + +**What breaks:** `import { ApiKeySchema, ApiKey, ApiKeyParsed }` from +`@objectstack/spec` or `@objectstack/spec/identity` is TS2305 after upgrade. +The generated reference page's `ApiKey` section and the 19 +`identity/ApiKey:*` authorable-surface keys disappear with the schema. + +**What stays:** everything real. The single declaration of `sys_api_key` is +the ObjectSchema in `@objectstack/platform-objects` +(`identity/sys-api-key.object.ts`) — columns `name, prefix, user_id, +active_organization_id, scopes, expires_at, last_used_at, revoked, key, id, +created_at, updated_at`; rows are minted by `POST /api/v1/keys` and verified +by `core/src/security/api-key.ts`, keyed by the `osk_` prefix. Neither ever +read the deleted schema, so runtime behaviour is byte-identical. +`UserSchema` / `AccountSchema` / `VerificationTokenSchema` and the +organization module survive unchanged. + +The retirement kit: + +- schema deleted in place, with the in-module explanatory block naming the + live declaration (`packages/spec/src/identity/identity.zod.ts`) +- ADR-0087 registration: retired-def entry `identity/ApiKey` + D3 semantic + entry `identity-api-key-schema-retired`, both under protocol 18 (route 3 — + no carrier key and no authored document, so no tombstone and no D2 + conversion; the registry entries ARE the declaration) +- pin tests: `identity/api-key-retirement.test.ts` (zero holders on every + public entry, survivors stand) and platform-objects' + `sys-api-key-single-declaration.test.ts` (the real column set, spec's + runtime namespace lost the name) +- generated baselines regenerated: authorable surface (−19 keys), JSON-schema + manifest (−1 def), api-surface / export-origins (−3 names), reference docs +- `cloud/developer-portal.zod.ts` prose corrected: marketplace API keys point + at the `sys_api_key` object and `POST /api/v1/keys`, not at + `Identity.ApiKeySchema` (the marketplace-key plan is ruled not live) + +## FROM → TO + +```ts +// before — type-checked green against a schema no runtime ever read +import { ApiKeySchema, type ApiKey } from '@objectstack/spec/identity'; +const key: ApiKey = { id, name, userId, enabled: true, rateLimitMax: 100, /* … */ }; + +// after — read the real table: the sys_api_key ObjectSchema in +// @objectstack/platform-objects (snake_case, `revoked` not `enabled`); +// mint via POST /api/v1/keys, verify via core/src/security/api-key.ts. +import { SysApiKey } from '@objectstack/platform-objects'; +``` diff --git a/content/docs/references/cloud/developer-portal.mdx b/content/docs/references/cloud/developer-portal.mdx index 4d83d77698..a62c6f99dc 100644 --- a/content/docs/references/cloud/developer-portal.mdx +++ b/content/docs/references/cloud/developer-portal.mdx @@ -25,9 +25,15 @@ Identity module (`@objectstack/spec` Identity namespace), which follows the better-auth specification. This module only defines marketplace-specific extensions on top of the shared identity layer: -- **User & Session** → `Identity.UserSchema`, `Identity.SessionSchema` +- **User** → `Identity.UserSchema`; the session wire shape is `Session` from + `@objectstack/spec/api` (the identity module deliberately declares no bare + `SessionSchema` — #4641) - **Organization & Members** → `Identity.OrganizationSchema`, `Identity.MemberSchema` -- **API Keys** → `Identity.ApiKeySchema` (with marketplace scopes) +- **API Keys** → the `sys_api_key` ObjectSchema in + `@objectstack/platform-objects` (`identity/sys-api-key.object.ts`), minted via + `POST /api/v1/keys`; its `scopes` column is the marketplace-scope carrier. + There is no `Identity.ApiKeySchema` — #8715 retired it (it documented + better-auth's apiKey plugin, which this platform does not load) ## Key Concepts - **Publisher Profile**: Links an Identity Organization to a marketplace publisher diff --git a/content/docs/references/identity/identity.mdx b/content/docs/references/identity/identity.mdx index cbd77aead3..a56a8702ef 100644 --- a/content/docs/references/identity/identity.mdx +++ b/content/docs/references/identity/identity.mdx @@ -20,8 +20,8 @@ defines "how to login". ## TypeScript Usage ```typescript -import { AccountSchema, ApiKeySchema, UserSchema, VerificationTokenSchema } from '@objectstack/spec/identity'; -import type { Account, ApiKey, User, VerificationToken } from '@objectstack/spec/identity'; +import { AccountSchema, UserSchema, VerificationTokenSchema } from '@objectstack/spec/identity'; +import type { Account, User, VerificationToken } from '@objectstack/spec/identity'; // Validate data const result = AccountSchema.parse(data); @@ -51,35 +51,6 @@ const result = AccountSchema.parse(data); | **updatedAt** | `string` | ✅ | Last update timestamp | ---- - -## ApiKey - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | API key identifier | -| **name** | `string` | ✅ | API key display name | -| **start** | `string` | optional | Key prefix for identification | -| **prefix** | `string` | optional | Custom key prefix | -| **userId** | `string` | ✅ | Owner user ID | -| **organizationId** | `string` | optional | Scoped organization ID | -| **expiresAt** | `string` | optional | Expiration timestamp | -| **createdAt** | `string` | ✅ | Creation timestamp | -| **updatedAt** | `string` | ✅ | Last update timestamp | -| **lastUsedAt** | `string` | optional | Last used timestamp | -| **lastRefetchAt** | `string` | optional | Last refetch timestamp | -| **enabled** | `boolean` | ✅ | Whether the key is active | -| **rateLimitEnabled** | `boolean` | optional | Whether rate limiting is enabled | -| **rateLimitTimeWindow** | `integer` | optional | Rate limit window (ms) | -| **rateLimitMax** | `integer` | optional | Max requests per window | -| **remaining** | `integer` | optional | Remaining requests | -| **permissions** | `Record` | optional | Granular permission flags | -| **scopes** | `string[]` | optional | High-level access scopes | -| **metadata** | `Record` | optional | Custom metadata | - - --- ## User diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 6390f159ec..7c95d10374 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1583 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1582 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -24,7 +24,7 @@ counts are sums of the rows they head. Regenerate with | [Automation Protocol](/docs/references/automation) | 13 | 68 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Cloud Protocol](/docs/references/cloud) | 11 | 94 | Environments, packages and versions, marketplace, developer portal, tenancy. | | [Data Protocol](/docs/references/data) | 29 | 163 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | -| [Identity Protocol](/docs/references/identity) | 5 | 28 | Users and accounts, organizations, positions, API keys, SCIM provisioning. | +| [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | | [Integration Protocol](/docs/references/integration) | 1 | 27 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | | [Kernel Protocol](/docs/references/kernel) | 31 | 176 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | | [QA Protocol](/docs/references/qa) | 1 | 8 | Declarative test suites — scenarios, steps, actions and assertions. | @@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 36 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 160 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **198** | **1583** | 14 protocol modules | +| **Total** | **198** | **1582** | 14 protocol modules | --- @@ -186,14 +186,14 @@ Objects, fields, queries, filters, datasources and drivers — the ObjectQL laye ## Identity Protocol -**Source:** `packages/spec/src/identity/` · **Import:** `@objectstack/spec/identity` · **5 pages, 28 schemas** +**Source:** `packages/spec/src/identity/` · **Import:** `@objectstack/spec/identity` · **5 pages, 27 schemas** -Users and accounts, organizations, positions, API keys, SCIM provisioning. +Users and accounts, organizations, positions, SCIM provisioning. | File | Schemas | | :--- | :--- | | [`eval-user.zod.ts`](/docs/references/identity/eval-user) | `EvalUser` | -| [`identity.zod.ts`](/docs/references/identity/identity) | `Account`, `ApiKey`, `User`, `VerificationToken` | +| [`identity.zod.ts`](/docs/references/identity/identity) | `Account`, `User`, `VerificationToken` | | [`organization.zod.ts`](/docs/references/identity/organization) | `Invitation`, `InvitationStatus`, `Member`, `Organization` | | [`position.zod.ts`](/docs/references/identity/position) | `Position` | | [`scim.zod.ts`](/docs/references/identity/scim) | `SCIMAddress`, `SCIMBulkOperation`, `SCIMBulkRequest`, `SCIMBulkResponse`, `SCIMBulkResponseOperation`, `SCIMEmail`, `SCIMEnterpriseUser`, `SCIMError`, `SCIMGroup`, `SCIMGroupReference`, `SCIMListResponse`, `SCIMMemberReference`, `SCIMMeta`, `SCIMName`, `SCIMPatchOperation`, `SCIMPatchRequest`, `SCIMPhoneNumber`, `SCIMUser` | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 766e2133ca..525352802a 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -261,7 +261,7 @@ directory rather than per file. | `ai/` | 77 | | `api/` | 399 | | `cloud/` | 83 | -| `identity/` | 33 | +| `identity/` | 32 | | `integration/` | 10 | | `kernel/` | 296 | | `qa/` | 6 | diff --git a/packages/platform-objects/src/identity/sys-api-key-single-declaration.test.ts b/packages/platform-objects/src/identity/sys-api-key-single-declaration.test.ts new file mode 100644 index 0000000000..f8e5b851c2 --- /dev/null +++ b/packages/platform-objects/src/identity/sys-api-key-single-declaration.test.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { SysApiKey } from './sys-api-key.object.js'; +import * as specIdentity from '@objectstack/spec/identity'; + +// ─── [#8715] `sys_api_key` has exactly ONE declaration — this object ──────── +// +// `@objectstack/spec/identity` used to publish an `ApiKeySchema` that +// documented better-auth's `apiKey` PLUGIN shape — a plugin this platform +// does not load — so the one table had two declarations and the published one +// was fiction (`enabled` vs the real `revoked`, four rate-limit keys with no +// implementation, `start`/`lastRefetchAt`/`permissions`/`metadata` columns +// that do not exist). ADR-0049 enforce-or-remove; maintainer ruling +// 2026-08-15, disposition B: the schema is DELETED and this ObjectSchema is +// the single declaration. +// +// The spec half of the pin (zero holders on every public entry) lives in +// `packages/spec/src/identity/api-key-retirement.test.ts`; this half pins the +// consumer side — spec's runtime namespace really lost the name — and the +// real column set, so a drifted re-declaration cannot come back quietly on +// either side. +describe('[#8715] sys_api_key single-declaration pin', () => { + it('this object declares exactly the real column set', () => { + const declared = Object.keys((SysApiKey as { fields: Record }).fields).sort(); + expect(declared).toEqual([ + 'active_organization_id', + 'created_at', + 'expires_at', + 'id', + 'key', + 'last_used_at', + 'name', + 'prefix', + 'revoked', + 'scopes', + 'updated_at', + 'user_id', + ]); + // The polarity the deleted schema inverted: the kill switch is `revoked`, + // and there is no `enabled` column (asserted by the exact set above). + expect(declared).toContain('revoked'); + }); + + it('@objectstack/spec/identity no longer exports the fictional schema', () => { + // Value export only, deliberately: `ApiKey` / `ApiKeyParsed` were + // type-only and have no runtime footprint a namespace check could see — + // asserting them here would be vacuous (green before the retirement too). + // The types are covered by the spec-side pin, which reads the built + // export-origins artifact and enumerates types as well as consts. + expect('ApiKeySchema' in specIdentity, 'spec/identity must not export ApiKeySchema (#8715)').toBe(false); + // Anti-vacuity: the namespace import is real and the survivors stand. + expect('UserSchema' in specIdentity).toBe(true); + }); +}); diff --git a/packages/spec/api-surface/identity.json b/packages/spec/api-surface/identity.json index 815c08958b..980f105e78 100644 --- a/packages/spec/api-surface/identity.json +++ b/packages/spec/api-surface/identity.json @@ -8,9 +8,6 @@ "AUTH_ERROR_CODES (const)", "Account (type)", "AccountSchema (const)", - "ApiKey (type)", - "ApiKeyParsed (type)", - "ApiKeySchema (const)", "AuthError (interface)", "AuthHeaders (interface)", "AuthResponse (interface)", diff --git a/packages/spec/authorable-defaults/identity.json b/packages/spec/authorable-defaults/identity.json index 6d1ec8045a..b20c3cbdf5 100644 --- a/packages/spec/authorable-defaults/identity.json +++ b/packages/spec/authorable-defaults/identity.json @@ -2,7 +2,6 @@ "description": "Ratchet of the DEFAULT VALUE of every authorable key in one category that has one (#4666) — what a metadata author gets when they omit the key, which for AI-authored metadata is most of the time. Sharded by category like authorable-surface/; the gate reads the whole authorable-defaults/ directory as ONE set. Each line is \": = \". Additions (a NEW key that ships with a default) are auto-recorded — commit the change. CHANGING, ADDING or REMOVING the default of a key that already existed is NOT auto-recorded: it silently alters the behaviour of already-deployed metadata, so it fails check:authorable-surface until it is declared in DEFAULT_CHANGES_BY_MAJOR (scripts/lib/default-changes.ts). Constraints are deliberately NOT recorded here — a tightened bound REJECTS a document loudly, which is a different and self-announcing class (maintainer ruling on #4666, direction B). See #4666, #4661.", "category": "identity", "defaults": [ - "identity/ApiKey:enabled = true", "identity/EvalUser:positions = []", "identity/Invitation:status = \"pending\"", "identity/Position:delegatable = false", diff --git a/packages/spec/authorable-surface/identity.json b/packages/spec/authorable-surface/identity.json index 20fa64fb69..21cf207f7f 100644 --- a/packages/spec/authorable-surface/identity.json +++ b/packages/spec/authorable-surface/identity.json @@ -16,25 +16,6 @@ "identity/Account:type", "identity/Account:updatedAt", "identity/Account:userId", - "identity/ApiKey:createdAt", - "identity/ApiKey:enabled", - "identity/ApiKey:expiresAt", - "identity/ApiKey:id", - "identity/ApiKey:lastRefetchAt", - "identity/ApiKey:lastUsedAt", - "identity/ApiKey:metadata", - "identity/ApiKey:name", - "identity/ApiKey:organizationId", - "identity/ApiKey:permissions", - "identity/ApiKey:prefix", - "identity/ApiKey:rateLimitEnabled", - "identity/ApiKey:rateLimitMax", - "identity/ApiKey:rateLimitTimeWindow", - "identity/ApiKey:remaining", - "identity/ApiKey:scopes", - "identity/ApiKey:start", - "identity/ApiKey:updatedAt", - "identity/ApiKey:userId", "identity/EvalUser:email", "identity/EvalUser:id", "identity/EvalUser:isPlatformAdmin", diff --git a/packages/spec/export-origins/identity.json b/packages/spec/export-origins/identity.json index d49cad3121..f4aad8d04f 100644 --- a/packages/spec/export-origins/identity.json +++ b/packages/spec/export-origins/identity.json @@ -8,9 +8,6 @@ "AUTH_ERROR_CODES": "src/identity/protocol.ts#AUTH_ERROR_CODES (const)", "Account": "src/identity/identity.zod.ts#Account (type)", "AccountSchema": "src/identity/identity.zod.ts#AccountSchema (const)", - "ApiKey": "src/identity/identity.zod.ts#ApiKey (type)", - "ApiKeyParsed": "src/identity/identity.zod.ts#ApiKeyParsed (type)", - "ApiKeySchema": "src/identity/identity.zod.ts#ApiKeySchema (const)", "AuthError": "src/identity/protocol.ts#AuthError (interface)", "AuthHeaders": "src/identity/protocol.ts#AuthHeaders (interface)", "AuthResponse": "src/identity/protocol.ts#AuthResponse (interface)", diff --git a/packages/spec/json-schema.manifest/identity.json b/packages/spec/json-schema.manifest/identity.json index 837b227b34..a4fd1edca9 100644 --- a/packages/spec/json-schema.manifest/identity.json +++ b/packages/spec/json-schema.manifest/identity.json @@ -3,7 +3,6 @@ "category": "identity", "schemas": [ "identity/Account", - "identity/ApiKey", "identity/EvalUser", "identity/Invitation", "identity/InvitationStatus", diff --git a/packages/spec/scripts/build-docs.ts b/packages/spec/scripts/build-docs.ts index 05e1a3ecac..37e8950aff 100644 --- a/packages/spec/scripts/build-docs.ts +++ b/packages/spec/scripts/build-docs.ts @@ -620,7 +620,9 @@ const CATEGORY_BLURBS: Record = { automation: 'Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records.', cloud: 'Environments, packages and versions, marketplace, developer portal, tenancy.', data: 'Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer.', - identity: 'Users and accounts, organizations, positions, API keys, SCIM provisioning.', + // "API keys" deliberately absent since #8715: the sys_api_key table is + // declared by @objectstack/platform-objects, not by a spec identity schema. + identity: 'Users and accounts, organizations, positions, SCIM provisioning.', integration: 'The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances.', kernel: 'Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry.', qa: 'Declarative test suites — scenarios, steps, actions and assertions.', diff --git a/packages/spec/src/cloud/developer-portal.zod.ts b/packages/spec/src/cloud/developer-portal.zod.ts index 83ef118a5e..50976509b7 100644 --- a/packages/spec/src/cloud/developer-portal.zod.ts +++ b/packages/spec/src/cloud/developer-portal.zod.ts @@ -24,9 +24,15 @@ import { PublisherVerificationSchema } from './marketplace.zod'; * better-auth specification. This module only defines marketplace-specific * extensions on top of the shared identity layer: * - * - **User & Session** → `Identity.UserSchema`, `Identity.SessionSchema` + * - **User** → `Identity.UserSchema`; the session wire shape is `Session` from + * `@objectstack/spec/api` (the identity module deliberately declares no bare + * `SessionSchema` — #4641) * - **Organization & Members** → `Identity.OrganizationSchema`, `Identity.MemberSchema` - * - **API Keys** → `Identity.ApiKeySchema` (with marketplace scopes) + * - **API Keys** → the `sys_api_key` ObjectSchema in + * `@objectstack/platform-objects` (`identity/sys-api-key.object.ts`), minted via + * `POST /api/v1/keys`; its `scopes` column is the marketplace-scope carrier. + * There is no `Identity.ApiKeySchema` — #8715 retired it (it documented + * better-auth's apiKey plugin, which this platform does not load) * * ## Key Concepts * - **Publisher Profile**: Links an Identity Organization to a marketplace publisher diff --git a/packages/spec/src/identity/api-key-retirement.test.ts b/packages/spec/src/identity/api-key-retirement.test.ts new file mode 100644 index 0000000000..550d31b592 --- /dev/null +++ b/packages/spec/src/identity/api-key-retirement.test.ts @@ -0,0 +1,120 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; + +import { + EXPORT_ENTRY_POINTS, + exportNamesOf, + holdersOf, +} from '../../scripts/lib/export-origins-testkit'; + +// ─── [#8715] `ApiKeySchema` is RETIRED ────────────────────────────────────── +// +// ADR-0049 enforce-or-remove; maintainer ruling 2026-08-15 (disposition B: +// delete). `identity/identity.zod.ts` no longer declares `ApiKeySchema` / +// `ApiKey` / `ApiKeyParsed` — 1 emitted def (`identity/ApiKey`), 3 exported +// names, 19 authorable-surface keys, the reference page's `ApiKey` section +// with them. +// +// The measurement that decided it (issue #8715, dev report 2026-08-14; +// re-verified at this retirement's base commit, 7901b2d): +// +// 1. STATIC — zero imports of any of the three names outside +// `packages/spec` repo-wide; in-package, only its own unit test. The +// export snapshots, the generated reference page and one prose mention +// in `cloud/developer-portal.zod.ts` (corrected in the same PR) were the +// only other occurrences. +// 2. DOORS — no metadata-type binding, no stack collection, no object/field +// embedding: no authored document could ever carry the shape. +// 3. The fiction face: the schema documented better-auth's `apiKey` PLUGIN +// shape, a plugin this platform does not load — `start`/`lastRefetchAt` +// (no such columns), `enabled` (the real column is `revoked`, opposite +// polarity), four per-key rate-limit keys (no such surface exists +// anywhere), `permissions`/`metadata` (no columns), camelCase +// `organizationId` next to the real snake_case `active_organization_id`. +// One table, two declarations, and the published one was fiction — +// AGENTS.md PD #10. +// +// ## Why route 3, and why there is nothing to tombstone +// +// With no carrier key there is no shape on which a `retiredKey()` tombstone +// could sit, and no authored document for an ADR-0087 D2 conversion to +// rewrite — a prescription nobody can receive is noise. The declared record +// is the D3 `SemanticMigration` `identity-api-key-schema-retired` plus the +// `RETIRED_DEFS_BY_MAJOR[18]` entry `identity/ApiKey` the manifest-deletion +// gate reads. +// +// The single declaration of the `sys_api_key` table is the ObjectSchema in +// `@objectstack/platform-objects` (`identity/sys-api-key.object.ts`) — its +// column pin lives next to that object +// (`sys-api-key-single-declaration.test.ts`), because spec cannot import its +// own consumer. +// +// Form follows #4988 / #5055 / #8075: resolved symbol identity over every +// public entry via the build-time `export-origins/` artifact. +describe('[#8715] identity/ ApiKeySchema retirement', () => { + /** The 3 names the retired def exported (1 schema const + 2 types). */ + const RETIRED_NAMES = ['ApiKeySchema', 'ApiKey', 'ApiKeyParsed'] as const; + + /** + * Names that must SURVIVE on `./identity`. The ruling accepts the sibling + * asymmetry deliberately (User/Account/VerificationToken and the + * organization module stay) — exactly what a too-wide "tidy the identity + * module" sweep would take. + */ + const MUST_SURVIVE_IDENTITY = [ + 'UserSchema', + 'AccountSchema', + 'VerificationTokenSchema', + 'OrganizationSchema', + 'MemberSchema', + ] as const; + + it('every retired name has ZERO holders on any public entry; the survivors still stand', () => { + // Anti-vacuity: the baseline must cover the real surface. + for (const needed of ['.', './identity', './api']) { + expect(EXPORT_ENTRY_POINTS, `exports map must include ${needed}`).toContain(needed); + } + expect(exportNamesOf('./identity').length, './identity must export a non-trivial surface').toBeGreaterThan(20); + + // ── ABSENCE (every entry, not just ./identity) ──────────────────────── + for (const name of RETIRED_NAMES) { + expect(holdersOf(name), `${name} must have zero holders after #8715`).toEqual([]); + } + + // ── SURVIVAL ────────────────────────────────────────────────────────── + const identityNames = exportNamesOf('./identity'); + for (const name of MUST_SURVIVE_IDENTITY) { + expect(identityNames, `${name} must SURVIVE this retirement`).toContain(name); + } + }); + + it('runtime namespace agrees with the compiler view', async () => { + const identity = await import('./index'); + expect('ApiKeySchema' in identity, 'identity must not export ApiKeySchema').toBe(false); + for (const name of ['UserSchema', 'AccountSchema', 'VerificationTokenSchema']) { + expect(name in identity, `${name} must SURVIVE at runtime`).toBe(true); + } + }); + + it('the module file no longer spells any of the fictional keys', async () => { + // The schema was deleted in place (the module survives — unlike #8075 this + // is not a whole-file retirement), so the pin is textual: none of the + // plugin-shaped keys the card called fictional may reappear in + // identity.zod.ts as declarations. `lastRefetchAt` / `rateLimit*` / + // `remaining` never had another legitimate use in this module. + const fs = await import('node:fs'); + const path = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + const src = fs.readFileSync( + path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'identity.zod.ts'), + 'utf-8', + ); + for (const key of ['lastRefetchAt:', 'rateLimitEnabled:', 'rateLimitTimeWindow:', 'rateLimitMax:', 'remaining:']) { + expect(src.includes(key), `identity.zod.ts must not re-declare \`${key.slice(0, -1)}\``).toBe(false); + } + // Anti-vacuity: the explanatory block this retirement left behind is + // present, so "false" above cannot mean "wrong file". + expect(src).toContain('are NOT declared here (#8715'); + }); +}); diff --git a/packages/spec/src/identity/identity.test.ts b/packages/spec/src/identity/identity.test.ts index c66da9d706..2df8180b0e 100644 --- a/packages/spec/src/identity/identity.test.ts +++ b/packages/spec/src/identity/identity.test.ts @@ -3,11 +3,9 @@ import { UserSchema, AccountSchema, VerificationTokenSchema, - ApiKeySchema, type User, type Account, type VerificationToken, - type ApiKey, } from "./identity.zod"; describe('UserSchema', () => { @@ -210,78 +208,6 @@ describe('VerificationTokenSchema', () => { }); }); -describe('ApiKeySchema', () => { - it('should accept minimal API key', () => { - const key = { - id: 'key_123', - name: 'CI/CD Pipeline', - userId: 'user_123', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - const result = ApiKeySchema.parse(key); - expect(result.enabled).toBe(true); - }); - - it('should accept full API key with rate limiting and permissions', () => { - const key: ApiKey = { - id: 'key_123', - name: 'Production API Key', - start: 'os_pk_ab', - prefix: 'os_pk_', - userId: 'user_123', - organizationId: 'org_456', - expiresAt: new Date(Date.now() + 86400000).toISOString(), - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - lastUsedAt: new Date().toISOString(), - lastRefetchAt: new Date().toISOString(), - enabled: true, - rateLimitEnabled: true, - rateLimitTimeWindow: 60000, - rateLimitMax: 100, - remaining: 95, - permissions: { 'publish': true, 'read': true, 'manage': false }, - scopes: ['marketplace:publish', 'marketplace:read'], - metadata: { environment: 'production' }, - }; - - const result = ApiKeySchema.parse(key); - expect(result.organizationId).toBe('org_456'); - expect(result.scopes).toHaveLength(2); - expect(result.permissions?.publish).toBe(true); - }); - - it('should accept API key without optional fields', () => { - const key = { - id: 'key_123', - name: 'Minimal Key', - userId: 'user_123', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - const result = ApiKeySchema.parse(key); - expect(result.organizationId).toBeUndefined(); - expect(result.expiresAt).toBeUndefined(); - expect(result.scopes).toBeUndefined(); - }); - - it('should correctly infer ApiKey type', () => { - const key: ApiKey = { - id: 'key_123', - name: 'Test Key', - userId: 'user_123', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - expect(key.id).toBe('key_123'); - expect(key.name).toBe('Test Key'); - }); -}); - describe('Type inference', () => { it('should correctly infer User type', () => { const user: User = { diff --git a/packages/spec/src/identity/identity.zod.ts b/packages/spec/src/identity/identity.zod.ts index 20eba5fb2c..0f7f1b9752 100644 --- a/packages/spec/src/identity/identity.zod.ts +++ b/packages/spec/src/identity/identity.zod.ts @@ -194,113 +194,30 @@ export const VerificationTokenSchema = lazySchema(() => z.object({ export type VerificationToken = z.input; -/** - * API Key Schema +/* + * `ApiKey` / `ApiKeySchema` / `ApiKeyParsed` are NOT declared here (#8715, + * maintainer-ruled DELETE 2026-08-15; ADR-0049 enforce-or-remove). * - * Aligns with better-auth's API key plugin capabilities. - * Provides programmatic access to ObjectStack APIs (CI/CD, service-to-service, CLI). + * The schema that stood here documented better-auth's `apiKey` PLUGIN shape — + * a plugin this platform does not load: `start`, `lastRefetchAt`, `enabled` + * (the real column is `revoked`, opposite polarity), `rateLimitEnabled` / + * `rateLimitTimeWindow` / `rateLimitMax` / `remaining` (no per-key rate-limit + * surface exists anywhere), `permissions`, `metadata`, camelCase + * `organizationId`. It had ZERO consumers in framework, cloud or objectui + * outside its own unit test, and one table ended up with two declarations of + * which the published one was fiction — the AGENTS.md PD #10 shape. * - * @see https://www.better-auth.com/docs/plugins/api-key + * The single declaration of the `sys_api_key` table is the ObjectSchema in + * `@objectstack/platform-objects` (`identity/sys-api-key.object.ts`): columns + * `name, prefix, user_id, active_organization_id, scopes, expires_at, + * last_used_at, revoked, key, id, created_at, updated_at` (snake_case — this + * is persisted-record vocabulary, not a wire DTO). Rows are minted by + * `POST /api/v1/keys` (`runtime/src/domains/keys.ts`) and verified by + * `core/src/security/api-key.ts`; neither ever read the deleted schema. + * + * Need the persisted record? Read the `sys_api_key` object. Need the mint/ + * verify behaviour? It lives behind the endpoints above, keyed by the `osk_` + * prefix. Per-key rate limiting returns only via the ENFORCE route of + * ADR-0049 — the executor first, the vocabulary second. */ -export const ApiKeySchema = lazySchema(() => z.object({ - /** - * Unique API key identifier - */ - id: z.string().describe('API key identifier'), - - /** - * Human-readable name for the key - */ - name: z.string().describe('API key display name'), - - /** - * Key prefix (visible portion for identification, e.g., "os_pk_ab") - */ - start: z.string().optional().describe('Key prefix for identification'), - - /** - * Custom prefix for the key (e.g., "os_pk_") - */ - prefix: z.string().optional().describe('Custom key prefix'), - - /** - * User ID of the key owner - */ - userId: z.string().describe('Owner user ID'), - - /** - * Organization ID the key is scoped to (optional) - */ - organizationId: z.string().optional().describe('Scoped organization ID'), - - /** - * Key expiration timestamp (null = never expires) - */ - expiresAt: z.string().datetime().optional().describe('Expiration timestamp'), - - /** - * Creation timestamp - */ - createdAt: z.string().datetime().describe('Creation timestamp'), - - /** - * Last update timestamp - */ - updatedAt: z.string().datetime().describe('Last update timestamp'), - - /** - * Last used timestamp - */ - lastUsedAt: z.string().datetime().optional().describe('Last used timestamp'), - - /** - * Last refetch timestamp (for cached permission checks) - */ - lastRefetchAt: z.string().datetime().optional().describe('Last refetch timestamp'), - - /** - * Whether this key is enabled - */ - enabled: z.boolean().default(true).describe('Whether the key is active'), - - /** - * Rate limiting: enabled flag - */ - rateLimitEnabled: z.boolean().optional().describe('Whether rate limiting is enabled'), - - /** - * Rate limiting: time window in milliseconds - */ - rateLimitTimeWindow: z.number().int().min(0).optional().describe('Rate limit window (ms)'), - - /** - * Rate limiting: max requests per window - */ - rateLimitMax: z.number().int().min(0).optional().describe('Max requests per window'), - - /** - * Rate limiting: remaining requests in current window - */ - remaining: z.number().int().min(0).optional().describe('Remaining requests'), - - /** - * Permissions assigned to this key (granular access control) - */ - permissions: z.record(z.string(), z.boolean()).optional() - .describe('Granular permission flags'), - - /** - * Scopes assigned to this key (high-level access categories) - */ - scopes: z.array(z.string()).optional() - .describe('High-level access scopes'), - - /** - * Custom metadata - */ - metadata: z.record(z.string(), z.unknown()).optional().describe('Custom metadata'), -})); -export type ApiKey = z.input; -/** Post-parse shape of {@link ApiKey} — defaults applied, transforms run (ADR-0122). */ -export type ApiKeyParsed = z.infer; diff --git a/packages/spec/src/migrations/entries/retired-defs/18.identity__ApiKey.ts b/packages/spec/src/migrations/entries/retired-defs/18.identity__ApiKey.ts new file mode 100644 index 0000000000..802ad59ee8 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.identity__ApiKey.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #8715 — identity/identity.zod.ts `ApiKeySchema`, retired whole (ADR-0049 +// enforce-or-remove; maintainer ruling 2026-08-15, disposition B: delete). +// The schema documented better-auth's `apiKey` PLUGIN shape — a plugin this +// platform does not load: `start`, `lastRefetchAt`, `enabled` (the real +// column is `revoked`, opposite polarity), the four per-key rate-limit keys +// (no such surface exists anywhere), `permissions`, `metadata`, camelCase +// `organizationId`. Zero consumers in framework, cloud or objectui outside +// its own unit test; the live `sys_api_key` table is declared by +// `@objectstack/platform-objects` (`identity/sys-api-key.object.ts`) and +// read by `core/src/security/api-key.ts` / `runtime/src/domains/keys.ts` in +// snake_case, never through this schema — one table had two declarations and +// the published one was fiction. Route 3: no carrier key, no authored +// document for a D2 conversion to rewrite, so no tombstone and no +// conversion — this table plus the D3 semantic entry +// `identity-api-key-schema-retired` ARE the declaration. +// +// Registered under 18, not 17: v17.0.0 was cut before this landed, so the +// removal ships on the 17.x line (launch-window convention: accept-set +// narrowings ride minor releases) and the prescription lives at the major +// boundary where `migrate meta` users look (the #8586 / PR #8702 precedent). +export const entry = 'identity/ApiKey'; diff --git a/packages/spec/src/migrations/entries/semantic/18.identity-api-key-schema-retired.ts b/packages/spec/src/migrations/entries/semantic/18.identity-api-key-schema-retired.ts new file mode 100644 index 0000000000..c67f59068a --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.identity-api-key-schema-retired.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'identity-api-key-schema-retired', + surface: + 'identity.apiKey (the whole of `ApiKeySchema` in identity/identity.zod.ts — ' + + '1 def, 3 exported names: `ApiKeySchema`, `ApiKey`, `ApiKeyParsed`)', + replacement: + '(removed — there is no replacement schema, because the deleted one never ' + + 'described the real table. The single declaration of `sys_api_key` is the ' + + 'ObjectSchema in `@objectstack/platform-objects` ' + + '(`identity/sys-api-key.object.ts`): columns `name, prefix, user_id, ' + + 'active_organization_id, scopes, expires_at, last_used_at, revoked, key, ' + + 'id, created_at, updated_at`, snake_case, `revoked` as the kill switch — ' + + 'not `enabled`. Rows are minted by `POST /api/v1/keys` ' + + '(`runtime/src/domains/keys.ts`) and verified by ' + + '`core/src/security/api-key.ts`, keyed by the `osk_` prefix. Per-key rate ' + + 'limiting returns only via the ENFORCE route of ADR-0049 through a new ' + + 'ADR — the executor first, the vocabulary second)', + reason: + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-08-15 on #8715 ' + + '(disposition B: delete). `ApiKeySchema` documented better-auth\'s `apiKey` ' + + 'PLUGIN schema — a plugin this platform does not load ' + + '(`plugin-auth/src/managed-extension-fields.ts` states the table is ' + + 'hand-rolled ObjectStack): `start` and `lastRefetchAt` name columns that do ' + + 'not exist; `enabled` inverts the real `revoked` column\'s polarity; ' + + '`rateLimitEnabled` / `rateLimitTimeWindow` / `rateLimitMax` / `remaining` ' + + 'advertise a per-key rate-limit capability nothing implements (the sharpest ' + + 'PD #10 instance — a reader can reasonably conclude API keys support rate ' + + 'limiting); `permissions` and `metadata` have no columns; `organizationId` ' + + 'is camelCase fiction next to the real snake_case ' + + '`active_organization_id`. Zero consumers measured (08-14, re-verified at ' + + 'the retirement\'s base commit): only its own unit test, the export ' + + 'snapshots, the generated reference page and a prose mention in ' + + '`cloud/developer-portal.zod.ts` (corrected in the same PR — the ' + + 'marketplace-key plan it gestured at is ruled NOT live). One table had two ' + + 'declarations and the published one was fiction; the generated reference ' + + 'page rendered it faithfully, which is how the defect surfaced as a docs ' + + 'card. With no carrier key and no authored document there is nothing to ' + + 'tombstone and no seam for a D2 conversion: route 3, the #4834 / #4988 / ' + + '#5055 / #6486 / #8075 shape — RETIRED_DEFS_BY_MAJOR plus this entry ARE ' + + 'the declaration.', + acceptanceCriteria: + 'No code imports `ApiKeySchema`, `ApiKey` or `ApiKeyParsed` from ' + + '`@objectstack/spec` or `@objectstack/spec/identity` — every one is TS2305 ' + + 'after upgrade, on every public entry (pinned by resolved symbol identity ' + + 'in `identity/api-key-retirement.test.ts`). No metadata document needs ' + + 'editing: the schema was reachable from no metadata-type binding, stack ' + + 'collection or /meta door, so no document could ever carry it. ' + + '`UserSchema` / `AccountSchema` / `VerificationTokenSchema` and the ' + + 'organization module survive unchanged (the ruling accepts the sibling ' + + 'asymmetry deliberately), and the `sys_api_key` ObjectSchema in ' + + '`@objectstack/platform-objects` still declares the real column set ' + + '(pinned in `sys-api-key-single-declaration.test.ts`). ' + + '⚠️ Runtime behaviour is deliberately UNCHANGED: nothing ever ' + + 'read the schema, so removing it removes no behaviour — mint and verify ' + + 'work byte-identically before and after.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 09225452e7..9a424e4aea 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5026,6 +5026,62 @@ const step18: MigrationStep = { + 'malformed value keep loading (the rehydration seam replays the conversion, which drops ' + 'the meaningless key).', }, + { + id: 'identity-api-key-schema-retired', + surface: + 'identity.apiKey (the whole of `ApiKeySchema` in identity/identity.zod.ts — ' + + '1 def, 3 exported names: `ApiKeySchema`, `ApiKey`, `ApiKeyParsed`)', + replacement: + '(removed — there is no replacement schema, because the deleted one never ' + + 'described the real table. The single declaration of `sys_api_key` is the ' + + 'ObjectSchema in `@objectstack/platform-objects` ' + + '(`identity/sys-api-key.object.ts`): columns `name, prefix, user_id, ' + + 'active_organization_id, scopes, expires_at, last_used_at, revoked, key, ' + + 'id, created_at, updated_at`, snake_case, `revoked` as the kill switch — ' + + 'not `enabled`. Rows are minted by `POST /api/v1/keys` ' + + '(`runtime/src/domains/keys.ts`) and verified by ' + + '`core/src/security/api-key.ts`, keyed by the `osk_` prefix. Per-key rate ' + + 'limiting returns only via the ENFORCE route of ADR-0049 through a new ' + + 'ADR — the executor first, the vocabulary second)', + reason: + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-08-15 on #8715 ' + + '(disposition B: delete). `ApiKeySchema` documented better-auth\'s `apiKey` ' + + 'PLUGIN schema — a plugin this platform does not load ' + + '(`plugin-auth/src/managed-extension-fields.ts` states the table is ' + + 'hand-rolled ObjectStack): `start` and `lastRefetchAt` name columns that do ' + + 'not exist; `enabled` inverts the real `revoked` column\'s polarity; ' + + '`rateLimitEnabled` / `rateLimitTimeWindow` / `rateLimitMax` / `remaining` ' + + 'advertise a per-key rate-limit capability nothing implements (the sharpest ' + + 'PD #10 instance — a reader can reasonably conclude API keys support rate ' + + 'limiting); `permissions` and `metadata` have no columns; `organizationId` ' + + 'is camelCase fiction next to the real snake_case ' + + '`active_organization_id`. Zero consumers measured (08-14, re-verified at ' + + 'the retirement\'s base commit): only its own unit test, the export ' + + 'snapshots, the generated reference page and a prose mention in ' + + '`cloud/developer-portal.zod.ts` (corrected in the same PR — the ' + + 'marketplace-key plan it gestured at is ruled NOT live). One table had two ' + + 'declarations and the published one was fiction; the generated reference ' + + 'page rendered it faithfully, which is how the defect surfaced as a docs ' + + 'card. With no carrier key and no authored document there is nothing to ' + + 'tombstone and no seam for a D2 conversion: route 3, the #4834 / #4988 / ' + + '#5055 / #6486 / #8075 shape — RETIRED_DEFS_BY_MAJOR plus this entry ARE ' + + 'the declaration.', + acceptanceCriteria: + 'No code imports `ApiKeySchema`, `ApiKey` or `ApiKeyParsed` from ' + + '`@objectstack/spec` or `@objectstack/spec/identity` — every one is TS2305 ' + + 'after upgrade, on every public entry (pinned by resolved symbol identity ' + + 'in `identity/api-key-retirement.test.ts`). No metadata document needs ' + + 'editing: the schema was reachable from no metadata-type binding, stack ' + + 'collection or /meta door, so no document could ever carry it. ' + + '`UserSchema` / `AccountSchema` / `VerificationTokenSchema` and the ' + + 'organization module survive unchanged (the ruling accepts the sibling ' + + 'asymmetry deliberately), and the `sys_api_key` ObjectSchema in ' + + '`@objectstack/platform-objects` still declares the real column set ' + + '(pinned in `sys-api-key-single-declaration.test.ts`). ' + + '⚠️ Runtime behaviour is deliberately UNCHANGED: nothing ever ' + + 'read the schema, so removing it removes no behaviour — mint and verify ' + + 'work byte-identically before and after.', + }, { id: 'memory-persistence-placeholder-refused', surface: 'memory driver config `persistence.path` (file persistence and the `auto` ' + @@ -5839,4 +5895,37 @@ export const RETIRED_DEFS_BY_MAJOR: Readonly> 'ui/WidgetSource', // ], + // Post-cut retirements: v17.0.0 was cut before these landed, so the + // removal ships on the 17.x line (launch-window convention) and the + // prescription registers at the major boundary where `migrate meta` users + // look — the step18 discipline the retired-key table above already applies, + // extended to whole defs. + 18: [ + // One file per entry under `entries/retired-defs/`, concatenated here sorted by + // entry id by `gen:migration-registry` (#7297). Add an entry by adding a + // FILE — never by editing between the markers, which is generated. + // + // #8715 — identity/identity.zod.ts `ApiKeySchema`, retired whole (ADR-0049 + // enforce-or-remove; maintainer ruling 2026-08-15, disposition B: delete). + // The schema documented better-auth's `apiKey` PLUGIN shape — a plugin this + // platform does not load: `start`, `lastRefetchAt`, `enabled` (the real + // column is `revoked`, opposite polarity), the four per-key rate-limit keys + // (no such surface exists anywhere), `permissions`, `metadata`, camelCase + // `organizationId`. Zero consumers in framework, cloud or objectui outside + // its own unit test; the live `sys_api_key` table is declared by + // `@objectstack/platform-objects` (`identity/sys-api-key.object.ts`) and + // read by `core/src/security/api-key.ts` / `runtime/src/domains/keys.ts` in + // snake_case, never through this schema — one table had two declarations and + // the published one was fiction. Route 3: no carrier key, no authored + // document for a D2 conversion to rewrite, so no tombstone and no + // conversion — this table plus the D3 semantic entry + // `identity-api-key-schema-retired` ARE the declaration. + // + // Registered under 18, not 17: v17.0.0 was cut before this landed, so the + // removal ships on the 17.x line (launch-window convention: accept-set + // narrowings ride minor releases) and the prescription lives at the major + // boundary where `migrate meta` users look (the #8586 / PR #8702 precedent). + 'identity/ApiKey', + // + ], };