ai-bot: read a user's realm with a short-lived, read-only token#5317
ai-bot: read a user's realm with a short-lived, read-only token#5317jurgenwerk wants to merge 6 commits into
Conversation
ai-bot exchanges a shared secret for a user-scoped, read-only realm JWT
via the realm server's /_delegate-session endpoint, so it can read a
realm on a user's behalf without a blanket read grant.
- runtime-common/delegation.ts: canonical HMAC sign/verify over the
${timestamp}.${rawBody} payload plus the requestDelegatedToken client;
single source of truth shared by ai-bot and the realm-server verifier.
- ai-bot DelegatedTokenManager: per-(user, realm) token cache, 30m TTL,
proactive refresh under 2 minutes remaining; realm-server origin
derived from the realm URL.
- main.ts wires AI_BOT_DELEGATION_SECRET; the path is inert when unset.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Host Test Results 1 files 1 suites 2h 2m 39s ⏱️ Results for commit 4615eea. Realm Server Test Results 1 files ±0 1 suites ±0 10m 32s ⏱️ +14s Results for commit 4615eea. ± Comparison against earlier commit 98e2762. |
…d-secret-infra-delegated-jwt-retrieval
The merged realm-server endpoint (#5287) shipped its own copy of the shared-secret signing/verification logic in realm-server/utils/delegation.ts, duplicating what this PR adds. Point the realm-server handler and its test at the runtime-common module and delete the duplicate, so the canonical ${timestamp}.${rawBody} HMAC format has exactly one definition that both ai-bot and the realm server import. Rename the module from the vague "delegation" (reads like the OOP delegation pattern) to user-delegated-realm-server-session, matching the /_delegate-session endpoint and the DelegatedSession type: - runtime-common/delegation.ts -> user-delegated-realm-server-session.ts - ai-bot/lib/delegation.ts -> user-delegated-realm-server-session.ts - ai-bot/tests/delegation-test.ts -> user-delegated-realm-server-session-test.ts Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow the module rename through to the exported symbols, which still led with the vague "delegation"/"delegated token" stem. The concise DelegatedRealmSession prefix names what is actually delegated — a read-only session on a single realm — without reading like the OOP delegation pattern: - DelegatedSession -> DelegatedRealmSession - DelegatedTokenManager -> DelegatedRealmSessionManager - requestDelegatedToken -> requestDelegatedRealmSession - DelegationError(Kind) -> DelegatedRealmSessionError(Kind) - delegationSignature -> delegatedRealmSessionSignature - verifyDelegationRequest -> verifyDelegatedRealmSessionRequest - DELEGATION_* headers -> DELEGATED_REALM_SESSION_* (and x-boxel-delegated-realm-session-* values) - Assistant.delegatedTokens -> delegatedRealmSessions The AI_BOT_DELEGATION_SECRET env var is left unchanged (external contract). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The module had a private copy of ensureTrailingSlash identical to the one already exported from runtime-common/paths.ts. Import the shared helper instead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the safeText helper with an inline await response.text().catch(() => '') at its single call site. Same behavior — the realm server's error message is still folded into the thrown error, and a body that can't be read can't throw — without the extra function or the unused length cap. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds shared client/server signing + request helpers for realm-server POST /_delegate-session, wires ai-bot to mint short-lived, read-only, per-(user, realm) delegated tokens (behind AI_BOT_DELEGATION_SECRET), and updates realm-server to use the shared implementation.
Changes:
- Introduce
@cardstack/runtime-common/user-delegated-realm-server-sessionas the single source of truth for delegation request signing/verification and the client request helper. - Update realm-server’s delegate-session handler + tests to use the shared module (and remove the realm-server-local copy).
- Add ai-bot tests for the delegation client and session manager behavior; wire the manager into
packages/ai-bot/main.ts.
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/runtime-common/user-delegated-realm-server-session.ts | New shared sign/verify + request helper and typed error surface. |
| packages/realm-server/utils/delegation.ts | Removes realm-server-local sign/verify implementation in favor of shared module. |
| packages/realm-server/tests/server-endpoints/delegate-session-test.ts | Updates tests to use shared headers + signature helper. |
| packages/realm-server/handlers/handle-delegate-session.ts | Switches delegate-session auth verification to shared module + new header constants. |
| packages/ai-bot/tests/user-delegated-realm-server-session-test.ts | New tests covering request signing/error mapping and manager caching/refresh behavior. |
| packages/ai-bot/tests/index.ts | Registers the new test module. |
| packages/ai-bot/main.ts | Instantiates and stores the delegation session manager (feature-gated by env secret). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| this.delegatedRealmSessions = new DelegatedRealmSessionManager( | ||
| process.env.AI_BOT_DELEGATION_SECRET, | ||
| ); |
| import { | ||
| DELEGATION_SIGNATURE_HEADER, | ||
| DELEGATION_TIMESTAMP_HEADER, | ||
| verifyDelegationRequest, | ||
| } from '../utils/delegation.ts'; | ||
| DELEGATED_REALM_SESSION_SIGNATURE_HEADER, | ||
| DELEGATED_REALM_SESSION_TIMESTAMP_HEADER, | ||
| verifyDelegatedRealmSessionRequest, | ||
| } from '@cardstack/runtime-common/user-delegated-realm-server-session'; |
| // Builds a minimally-valid JWT carrying the given `exp` (epoch seconds) in its | ||
| // payload — the manager only ever reads `exp` off the token, so the header and | ||
| // signature segments are placeholders. Payload is standard base64 to match the | ||
| // manager's `atob` decode. | ||
| function makeToken(expSeconds: number): string { | ||
| let payload = Buffer.from(JSON.stringify({ exp: expSeconds })).toString( | ||
| 'base64', | ||
| ); | ||
| return `header.${payload}.signature`; | ||
| } |
(Written by Claude on Matic's behalf.)
Why we need this
ai-bot needs to read realm content on a user's behalf for the pull-model skill loader (CS-11554): the bot reads skills out of the realms a user can access (their own realm, and any other realm they have read access to).
Today the only ways to do that are bad:
@aibota standing read grant on every realm — far more access than it needs, all the time, with a big blast radius if the bot's credentials leak; orThis adds a third, least-privilege option: the bot asks for just-in-time access that is read-only, scoped to a single realm, expires in 30 minutes, is tied to the specific user, and can never read more than that user could already read themselves. So the bot reads exactly what it needs, when it needs it, without holding broad standing access.
What this does
The bot asks the realm server for a short-lived token — 30 minutes, one realm, read-only, tied to that user — and uses it to read that realm on the user's behalf. It works for any realm the user can already read, not just their own.
The realm server already shipped the endpoint that hands out these tokens (#5287). This PR is the ai-bot side, plus the small shared module both sides use to sign and check the request so their formats can't drift apart.
What's in the PR
main.tsbehindAI_BOT_DELEGATION_SECRET. If that secret isn't set, the feature is simply off.Nothing calls this at runtime yet — the consumer is the pull-model skill loader (CS-11554). The manager is wired up but dormant until then.
How I tested it
There's no chat flow that triggers this yet, so I drove it directly against a local stack with the realm server started with
AI_BOT_DELEGATION_SECRETset:@user:localhoston thecatalogrealm.Plus the automated tests below; lint + type-check clean.
Tests
packages/ai-bot/tests/user-delegated-realm-server-session-test.ts(9 tests): the client signs in a way the server accepts; correct endpoint/origin; error-code mapping; and the manager's disabled path, cache hit, proactive refresh, per-key isolation, origin derivation, and invalidate. The realm-serverdelegate-sessiontests cover the server side.🤖 Generated with Claude Code