Repository navigation
feat(server): platform bootstrap key creates the dedicated search mint key - #120
khaliqgant wants to merge 2 commits into
Conversation
…t key Hosted RelayAuth has no boot step, and after #114/#119 only a plain key already holding relayauth:assertion:create:session-directory-search can delegate it. Add a platform-only scope, relayauth:platform:create:dedicated-api-key, that no API route can create or delegate and that wildcard/manage grants do not satisfy. An operator creates one such key per environment with ensurePlatformBootstrapApiKey (hash only, audited, idempotent). POST /v1/platform/dedicated-api-keys {type} accepts only a plain API key whose one scope is the platform scope, and creates exactly the allowlisted dedicated key type (today only session-directory-search) in the caller's org. Re-runs return the existing key without plaintext and write nothing; refused authenticated callers are audited. The platform key cannot mint search tokens and issuance stays behind RELAYAUTH_SESSION_DIRECTORY_ISSUANCE. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars.
|
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
| }); | ||
| try { | ||
| await storage.audit.write(dedicatedKeyAuditEntry({ | ||
| action: "token.issued", |
There was a problem hiding this comment.
🟡 API keys inflate issued-token counts
Creating a platform or search mint key records token.issued without issuing a token. The dashboard count treats each key as an issued token.
Learn more
The shared key-creation helper writes token.issued for both the operator-created platform key and the route-created search mint key. The audit count increments tokensIssued for every row with that action, without checking the resource. Thus provisioning these two keys increases the token count although no JWT was minted.
Example: Provision a platform key and then create its dedicated search key in an otherwise empty org. The dashboard reports two issued tokens before either key mints a token.
Recommended fix: Give key provisioning a distinct audit action and update the audit action type, query allowlists, and dashboard aggregation as needed so these entries remain queryable without counting as token issuance.
Was this helpful? React with 👍 or 👎 to provide feedback.
| } catch (error) { | ||
| if (isStorageError(error)) { | ||
| return c.json({ error: error.message, code: error.code }, error.status as 400 | 404 | 409); | ||
| } | ||
| return c.json({ error: "internal_error", code: "internal_error" }, 500); |
There was a problem hiding this comment.
🟡 Storage exhaustion loses retryable response
When storage fills during dedicated key creation, isStorageError misses capacity errors and the route returns a generic 500. The global capacity handler never sees the error, so callers lose its retryable 503 response.
Learn more
A storage-capacity failure can occur while listing existing keys, inserting a key, or writing its audit entry. This route catches errors from those operations and returns a generic 500 unless they are StorageError. The global error handler otherwise recognizes capacity exhaustion and supplies a retryable 503 with Retry-After.
Example: SQLite throws SQLITE_FULL during the dedicated key insert. The route answers 500 internal_error instead of the standard 503 storage_capacity_exhausted response.
Recommended fix: Let non-StorageError failures propagate to the global handler, or explicitly classify and return capacity and overload errors with their standard response helpers.
Was this helpful? React with 👍 or 👎 to provide feedback.
| const existing = await findActiveExactScopeApiKey(storage, orgId, options.scope); | ||
| if (existing) return { status: "exists", apiKey: existing }; |
There was a problem hiding this comment.
🟥 Existing mint key blocks operator provisioning
When any active mint key already exists in the platform org, ensureExactScopeApiKey returns exists without issuing a value. Its holder can retain exclusive mint access while the platform operator cannot obtain a usable key through this route.
Was this helpful? React with 👍 or 👎 to provide feedback.
[Medium impact] The PR adds a bounded path for creating search-mint credentials. The PR appears safe to merge, with a non-blocking gap in audit entries for rejected request bodies.
|
| const body = await c.req.raw.json().catch(() => null) as Record<string, unknown> | null; | ||
| if (!body || typeof body !== "object" || Array.isArray(body)) { | ||
| return c.json({ error: "Invalid JSON body", code: "invalid_body" }, 400); |
There was a problem hiding this comment.
Rejected bodies leave no audit
Requests from a valid platform key return 400 for malformed JSON, extra fields, or an unknown type without writing scope.denied. Only the earlier caller check writes that entry. Operators therefore cannot see rejected attempts to supply extra scopes or another org, despite the promise to audit every authenticated refusal. Write a denial entry before each body-related rejection and test those audit rows.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/server/src/routes/platform.ts
Line: 64-66
Comment:
**Rejected bodies leave no audit**
Requests from a valid platform key return `400` for malformed JSON, extra fields, or an unknown `type` without writing `scope.denied`. Only the earlier caller check writes that entry. Operators therefore cannot see rejected attempts to supply extra scopes or another org, despite the promise to audit every authenticated refusal. Write a denial entry before each body-related rejection and test those audit rows.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
Why
Hosted RelayAuth (the Cloudflare Worker in AgentWorkforce/cloud) has no boot step, so it cannot use
RELAYAUTH_SESSION_DIRECTORY_BOOTSTRAP_API_KEY[_FILE]. After #114/#119, only a plain API key that already holdsrelayauth:assertion:create:session-directory-searchcan delegate that scope, so admin keys cannot create the search mint key either.Khaliq chose the model on 2026-10-31: a platform key creates the narrow key.
What
relayauth:platform:create:dedicated-api-key. Everyrelayauth:platform:*scope is platform-only:*andrelayauth:*:manage:*do not./v1/api-keysand/v1/tokens/workspacereturnscope_escalation, even for a key that holds the literal plusmanage. This is stricter than the broker rule.ensurePlatformBootstrapApiKey(storage, { orgId, operator }), exported from@relayauth/server:token.issuedis written with resourceplatform-bootstrap-api-keyand actoroperator:<name>, and the plaintext is returned once.existsand writes nothing.POST /v1/platform/dedicated-api-keyswith body{ "type": "session-directory-search" }:type, looked up in a frozen allowlistDEDICATED_API_KEY_TYPES. It never accepts scopes, org or name. More key types can be added later as reviewed entries.201with the plaintext once.200 {status:"exists"}with no plaintext and writes nothing.scope.denied.RELAYAUTH_SESSION_DIRECTORY_ISSUANCE, and the test asserts the 503.docs/self-hosting.mdhas a new section on the platform bootstrap key.Tests
New file
src/__tests__/platform-dedicated-api-keys.test.ts, 9 tests. They cover:*,relayauth:*:manage:*,api-key:manage, the mint scope alone, platform plus another scope, a workspace token, an identity JWT, a JWT forgingmeta.apiKeyKind, and a bearer*.__proto__andtoStringare rejected.Revert checks. Each change below makes exactly 1 test fail:
isPlatformScopefromfirstUndelegableBrokerScope.*satisfy the route.apiKeyVia === "api_key"check.Commands run locally:
npx turbo build --filter=@relayauth/server^... --forcecd packages/server && npx tsc --noEmit: cleannpm testinpackages/server: 722/722 passRelease
A release is needed, presumably 0.2.36.
v0.2.35was cut from a branch, so the next release frommainalso brings in #93's migration0011_identity_retention. Cloud's D1 source-migration preflight refuses that migration until it is reviewed (see AgentWorkforce/cloud#5893).findActiveExactScopeApiKeyis also exported so Cloud's operator script can do a read-only dry run. Cloud consumes this from the published@relayauth/serverthrough a draft PR that pins the next version. Not published by this PR.🤖 Generated with Claude Code
Agent Relay sessions
claudesessiona97e3617· opened viagh pr create· last active 2026-10-31