Context
ADR-0036 Phase 2b. Phase 1a shipped API-key verification (#1624) + the generateApiKey() primitive (@objectstack/mcp/security); Phase 2 shipped the MCP HTTP transport (#1626); package renamed to @objectstack/mcp (#1627); generic Skill generator (#1628). Missing: a way to MINT a key. Without it, a user can't self-serve a sys_api_key to connect an agent — so the whole "connect your app to Claude/Cursor" flow is blocked.
This is security-critical and per the auth red-line must be its own focused PR with a full test matrix (do not fold into unrelated work).
Goal
An authenticated endpoint that generates a sys_api_key, stores only the hash, and returns the raw secret exactly once.
Design
- Route:
POST /api/v1/keys (dispatcher handleKeys), body { name, scopes?, expires_at? }. (List/revoke already exist via the sys_api_key data API + the revoke_api_key action — this issue is generation only.)
- Auth: require
context.executionContext.userId. Anonymous → 401.
- Generate: use
generateApiKey() from @objectstack/mcp (security) → { raw, hash, prefix }.
- Insert:
sys_api_key is protection-locked (isSystem), so insert with an elevated { context: { isSystem: true } } — BUT pin user_id to the caller's executionContext.userId, never from the request body.
- Response:
{ id, name, prefix, key: <raw> } — the raw key once. Never re-displayable.
Security constraints (zero-tolerance)
user_id is taken from the resolved principal, never from the request body (no impersonation).
- The raw key and its hash never enter logs, error messages, or any response other than the single creation response.
- Whitelist inputs; reject unknown/elevated fields in the body (e.g. caller cannot set
revoked:false tricks, key, user_id, id).
- Fail-closed on anything ambiguous.
Test matrix
- authed → returns
{ id, prefix, key }; key is the raw secret.
hashApiKey(returned.key) === the stored sys_api_key.key (hash, not plaintext).
- round-trip: the returned raw key authenticates via
resolveExecutionContext (x-api-key / Authorization: ApiKey) → resolves to the caller's principal.
- anonymous → 401, no row created.
user_id is the caller's even if the body tries to set a different user_id (impersonation blocked).
- body cannot inject
key/id/revoked to forge a known-secret key.
- (optional)
expires_at / scopes from body are honored and round-trip through the verify path.
Acceptance criteria
- New endpoint + tests green locally; raw key shown once; nothing secret logged; isolated PR.
- ADR-0036 Status updated (key-gen shipped).
Blocks: objectstack-ai/objectui connect-surface issue (Phase 2b UI).
Context
ADR-0036 Phase 2b. Phase 1a shipped API-key verification (#1624) + the
generateApiKey()primitive (@objectstack/mcp/security); Phase 2 shipped the MCP HTTP transport (#1626); package renamed to@objectstack/mcp(#1627); generic Skill generator (#1628). Missing: a way to MINT a key. Without it, a user can't self-serve asys_api_keyto connect an agent — so the whole "connect your app to Claude/Cursor" flow is blocked.This is security-critical and per the auth red-line must be its own focused PR with a full test matrix (do not fold into unrelated work).
Goal
An authenticated endpoint that generates a
sys_api_key, stores only the hash, and returns the raw secret exactly once.Design
POST /api/v1/keys(dispatcherhandleKeys), body{ name, scopes?, expires_at? }. (List/revoke already exist via thesys_api_keydata API + therevoke_api_keyaction — this issue is generation only.)context.executionContext.userId. Anonymous → 401.generateApiKey()from@objectstack/mcp(security) →{ raw, hash, prefix }.sys_api_keyis protection-locked (isSystem), so insert with an elevated{ context: { isSystem: true } }— BUT pinuser_idto the caller'sexecutionContext.userId, never from the request body.{ id, name, prefix, key: <raw> }— the raw key once. Never re-displayable.Security constraints (zero-tolerance)
user_idis taken from the resolved principal, never from the request body (no impersonation).revoked:falsetricks,key,user_id,id).Test matrix
{ id, prefix, key };keyis the raw secret.hashApiKey(returned.key)=== the storedsys_api_key.key(hash, not plaintext).resolveExecutionContext(x-api-key /Authorization: ApiKey) → resolves to the caller's principal.user_idis the caller's even if the body tries to set a differentuser_id(impersonation blocked).key/id/revokedto forge a known-secret key.expires_at/scopesfrom body are honored and round-trip through the verify path.Acceptance criteria
Blocks: objectstack-ai/objectui connect-surface issue (Phase 2b UI).