Skip to content

feat(server): platform bootstrap key creates the dedicated search mint key - #120

Open
khaliqgant wants to merge 2 commits into
mainfrom
feat/platform-bootstrap-key
Open

khaliqgant wants to merge 2 commits into
mainfrom
feat/platform-bootstrap-key

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Oct 31, 2026 •

Copy link
Copy Markdown
Member

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 holds relayauth:assertion:create:session-directory-search can 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

  • Platform scope relayauth:platform:create:dedicated-api-key. Every relayauth:platform:* scope is platform-only:
    • Only the exact literal satisfies it. * and relayauth:*:manage:* do not.
    • No route can delegate it. /v1/api-keys and /v1/tokens/workspace return scope_escalation, even for a key that holds the literal plus manage. This is stricter than the broker rule.
  • Operator helper ensurePlatformBootstrapApiKey(storage, { orgId, operator }), exported from @relayauth/server:
    • It creates the environment's one platform key. Only the hash and prefix are stored, an audit entry token.issued is written with resource platform-bootstrap-api-key and actor operator:<name>, and the plaintext is returned once.
    • A re-run returns exists and writes nothing.
    • If the audit write fails, the helper revokes the new key, so no unaudited key is left behind.
  • Route POST /v1/platform/dedicated-api-keys with body { "type": "session-directory-search" }:
    • The caller must be a plain API key whose only scope is the platform scope.
    • The body accepts only type, looked up in a frozen allowlist DEDICATED_API_KEY_TYPES. It never accepts scopes, org or name. More key types can be added later as reviewed entries.
    • The key is created in the caller's org with exactly that type's one scope. The response is 201 with the plaintext once.
    • A re-run returns 200 {status:"exists"} with no plaintext and writes nothing.
    • Every refusal from an authenticated caller is audited as scope.denied.
  • The platform key cannot mint search tokens: the exchange endpoint requires exactly the mint scope.
  • The issuer stays off by default. Creating the key does not bypass RELAYAUTH_SESSION_DIRECTORY_ISSUANCE, and the test asserts the 503.
  • docs/self-hosting.md has a new section on the platform bootstrap key.

Tests

New file src/__tests__/platform-dedicated-api-keys.test.ts, 9 tests. They cover:

  • The key is created once with exactly one scope.
  • A re-run is a no-op: no new key, no new audit row, no plaintext.
  • The plaintext is absent from the stored key rows and the audit rows.
  • A failed audit write revokes the new key.
  • The new mint key can mint, and the platform key cannot.
  • The issuer stays off by default.
  • These callers are refused and each refusal is audited: *, relayauth:*:manage:*, api-key:manage, the mint scope alone, platform plus another scope, a workspace token, an identity JWT, a JWT forging meta.apiKeyKind, and a bearer *.
  • Nobody can delegate the platform scope.
  • The body accepts only an allowlisted type, and __proto__ and toString are rejected.

Revert checks. Each change below makes exactly 1 test fail:

  • Removing isPlatformScope from firstUndelegableBrokerScope.
  • Letting * satisfy the route.
  • Dropping the single-scope check.
  • Dropping the apiKeyVia === "api_key" check.

Commands run locally:

  • npx turbo build --filter=@relayauth/server^... --force
  • cd packages/server && npx tsc --noEmit: clean
  • npm test in packages/server: 722/722 pass

Release

A release is needed, presumably 0.2.36. v0.2.35 was cut from a branch, so the next release from main also brings in #93's migration 0011_identity_retention. Cloud's D1 source-migration preflight refuses that migration until it is reviewed (see AgentWorkforce/cloud#5893). findActiveExactScopeApiKey is also exported so Cloud's operator script can do a read-only dry run. Cloud consumes this from the published @relayauth/server through a draft PR that pins the next version. Not published by this PR.

🤖 Generated with Claude Code


Agent Relay sessions

  • claude session a97e3617 · opened via gh pr create · last active 2026-10-31

…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>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-31T07:23:20.861843Z 065b7c8 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@coderabbitai

coderabbitai Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Important

  • Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 78c29189-2007-4d43-966a-e573e06c1306
Finishing Touches
Generate docstrings
  • Commit to this branch
  • Create a new PR
Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

Devin Review

});
try {
await storage.audit.write(dedicatedKeyAuditEntry({
action: "token.issued",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +109 to +113
} 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +104 to +105
const existing = await findActiveExactScopeApiKey(storage, orgId, options.scope);
if (existing) return { status: "exists", apiKey: existing };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

@greptile-apps

greptile-apps Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5

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

Fix All in CodexFindings

  1. P2 Rejected bodies leave no audit ▶
Fix with agent prompt
### Issue 1
packages/server/src/routes/platform.ts:64-66
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.

Summary

Adds an operator-created platform key that can create the dedicated search mint key through POST /v1/platform/dedicated-api-keys.

  • Operators can create a dedicated search mint key with a platform key.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Operator calls ensurePlatformBootstrapApiKey] --> B[Platform key]
  B --> C[POST /v1/platform/dedicated-api-keys]
  C --> D{Exact scope, plain key, one scope?}
  D -->|No| E[403 and denial audit]
  D -->|Yes| F{Body names an allowed type?}
  F -->|No| G[400 without denial audit]
  F -->|Yes| H{Active mint key exists?}
  H -->|Yes| I[200 without plaintext]
  H -->|No| J[Create key and write audit]
  J --> K[201 with plaintext once]
Loading

Reviews (1) · Last reviewed commit: "feat(server): export findActiveExactScop..." · Reviewed by Greptile

Comment on lines +64 to +66
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Fix in Codex Fix in Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant