Skip to content
Binary file not shown.
8 changes: 8 additions & 0 deletions packages/ai-bot/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
} from '@cardstack/runtime-common/matrix-constants';

import { handleDebugCommands } from './lib/debug.ts';
import { DelegatedRealmSessionManager } from './lib/user-delegated-realm-server-session.ts';
import { Responder } from './lib/responder.ts';
import {
shouldSetRoomTitle,
Expand Down Expand Up @@ -84,6 +85,10 @@ class Assistant {
pgAdapter: PgAdapter;
id: string;
aiBotInstanceId: string;
// Mints user-scoped, read-only realm tokens on demand. Inert unless
// AI_BOT_DELEGATION_SECRET is configured; the pull-model skill loader is its
// consumer.
delegatedRealmSessions: DelegatedRealmSessionManager;

constructor(client: MatrixClient, id: string, aiBotInstanceId: string) {
this.openai = new OpenAI({
Expand All @@ -94,6 +99,9 @@ class Assistant {
this.client = client;
this.pgAdapter = new PgAdapter();
this.aiBotInstanceId = aiBotInstanceId;
this.delegatedRealmSessions = new DelegatedRealmSessionManager(
process.env.AI_BOT_DELEGATION_SECRET,
);
Comment on lines +102 to +104
}

getResponse(prompt: PromptParts, senderMatrixUserId?: string) {
Expand Down
1 change: 1 addition & 0 deletions packages/ai-bot/tests/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ import './modality-test.ts';
import './locking-test.ts';
import './interrupt-test.ts';
import './credit-tracking-test.ts';
import './user-delegated-realm-server-session-test.ts';

QUnit.start();
274 changes: 274 additions & 0 deletions packages/ai-bot/tests/user-delegated-realm-server-session-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
import QUnit from 'qunit';
const { module, test, assert } = QUnit;

import {
requestDelegatedRealmSession,
verifyDelegatedRealmSessionRequest,
DelegatedRealmSessionError,
DELEGATED_REALM_SESSION_TIMESTAMP_HEADER,
DELEGATED_REALM_SESSION_SIGNATURE_HEADER,
} from '@cardstack/runtime-common/user-delegated-realm-server-session';
import { DelegatedRealmSessionManager } from '../lib/user-delegated-realm-server-session.ts';

const SECRET = 'shared-secret-under-test';
const ON_BEHALF_OF = '@example-user:boxel.ai';
const REALM = 'https://realm.example.com/u/example-user/';

// 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`;
}
Comment on lines +17 to +26

// A fake fetch that records each call and returns a scripted Response.
function recordingFetch(
handler: (url: string, init: RequestInit) => Response,
): {
fetch: typeof globalThis.fetch;
calls: { url: string; init: RequestInit }[];
} {
let calls: { url: string; init: RequestInit }[] = [];
let fetch = (async (input: any, init: any) => {
let url = typeof input === 'string' ? input : input.url;
calls.push({ url, init });
return handler(url, init);
}) as unknown as typeof globalThis.fetch;
return { fetch, calls };
}

function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}

module('delegated realm session client', () => {
test('signs the request so the server verifier accepts it', async () => {
let now = 1_700_000_000_000;
let captured: RequestInit | undefined;
let { fetch } = recordingFetch((_url, init) => {
captured = init;
return jsonResponse({
token: makeToken(1),
realm: REALM,
permissions: ['read'],
});
});

await requestDelegatedRealmSession({
realmServerURL: 'https://realm.example.com',
secret: SECRET,
onBehalfOf: ON_BEHALF_OF,
realm: REALM,
fetch,
now,
});

let headers = captured!.headers as Record<string, string>;
let result = verifyDelegatedRealmSessionRequest({
secret: SECRET,
timestamp: headers[DELEGATED_REALM_SESSION_TIMESTAMP_HEADER],
signature: headers[DELEGATED_REALM_SESSION_SIGNATURE_HEADER],
rawBody: captured!.body as string,
now,
});
assert.true(result.ok, 'server verifier accepts the client signature');
assert.strictEqual(
headers[DELEGATED_REALM_SESSION_TIMESTAMP_HEADER],
String(now),
'timestamp header carries the signing time',
);
});

test('POSTs to /_delegate-session at the given realm-server origin', async () => {
let { fetch, calls } = recordingFetch(() =>
jsonResponse({
token: makeToken(1),
realm: REALM,
permissions: ['read'],
}),
);
await requestDelegatedRealmSession({
realmServerURL: 'https://realm.example.com',
secret: SECRET,
onBehalfOf: ON_BEHALF_OF,
realm: REALM,
fetch,
now: 1,
});
assert.strictEqual(
calls[0].url,
'https://realm.example.com/_delegate-session',
);
assert.strictEqual(calls[0].init.method, 'POST');
});

test('maps status codes to typed DelegatedRealmSessionError kinds', async () => {
let cases: { status: number; kind: string }[] = [
{ status: 503, kind: 'disabled' },
{ status: 403, kind: 'forbidden' },
{ status: 401, kind: 'unauthorized' },
{ status: 400, kind: 'bad-request' },
{ status: 500, kind: 'unexpected' },
];
for (let { status, kind } of cases) {
let { fetch } = recordingFetch(() => new Response('nope', { status }));
try {
await requestDelegatedRealmSession({
realmServerURL: 'https://realm.example.com',
secret: SECRET,
onBehalfOf: ON_BEHALF_OF,
realm: REALM,
fetch,
now: 1,
});
assert.true(false, `expected ${status} to throw`);
} catch (e) {
assert.true(
e instanceof DelegatedRealmSessionError,
`${status} → DelegatedRealmSessionError`,
);
assert.strictEqual(
(e as DelegatedRealmSessionError).kind,
kind,
`${status} → ${kind}`,
);
}
}
});
});

module('DelegatedRealmSessionManager', () => {
test('is disabled and throws when no secret is configured', async () => {
let manager = new DelegatedRealmSessionManager(undefined);
assert.false(manager.enabled, 'manager reports disabled');
try {
await manager.getToken({ onBehalfOf: ON_BEHALF_OF, realm: REALM });
assert.true(false, 'expected getToken to throw');
} catch (e) {
assert.true(e instanceof DelegatedRealmSessionError);
assert.strictEqual((e as DelegatedRealmSessionError).kind, 'disabled');
}
});

test('caches a valid token and does not re-mint', async () => {
let now = 1_700_000_000_000;
let nowSeconds = Math.floor(now / 1000);
let { fetch, calls } = recordingFetch(() =>
jsonResponse({
token: makeToken(nowSeconds + 1800), // 30m out, well clear of the lead time
realm: REALM,
permissions: ['read'],
}),
);
let manager = new DelegatedRealmSessionManager(SECRET, {
fetch,
now: () => now,
});

let a = await manager.getToken({ onBehalfOf: ON_BEHALF_OF, realm: REALM });
let b = await manager.getToken({ onBehalfOf: ON_BEHALF_OF, realm: REALM });
assert.strictEqual(a, b, 'same cached token returned');
assert.strictEqual(calls.length, 1, 'minted exactly once');
});

test('proactively re-mints when within the 2-minute lead time', async () => {
let now = 1_700_000_000_000;
let nowSeconds = Math.floor(now / 1000);
// exp is 90s out — inside the 120s lead window, so the cached copy is
// considered too close to expiry to reuse.
let { fetch, calls } = recordingFetch(() =>
jsonResponse({
token: makeToken(nowSeconds + 90),
realm: REALM,
permissions: ['read'],
}),
);
let manager = new DelegatedRealmSessionManager(SECRET, {
fetch,
now: () => now,
});

await manager.getToken({ onBehalfOf: ON_BEHALF_OF, realm: REALM });
await manager.getToken({ onBehalfOf: ON_BEHALF_OF, realm: REALM });
assert.strictEqual(calls.length, 2, 're-minted because token was expiring');
});

test('caches per (user, realm) pair independently', async () => {
let now = 1_700_000_000_000;
let nowSeconds = Math.floor(now / 1000);
let { fetch, calls } = recordingFetch(() =>
jsonResponse({
token: makeToken(nowSeconds + 1800),
realm: REALM,
permissions: ['read'],
}),
);
let manager = new DelegatedRealmSessionManager(SECRET, {
fetch,
now: () => now,
});

await manager.getToken({ onBehalfOf: ON_BEHALF_OF, realm: REALM });
await manager.getToken({
onBehalfOf: '@another-user:boxel.ai',
realm: REALM,
});
await manager.getToken({
onBehalfOf: ON_BEHALF_OF,
realm: 'https://realm.example.com/u/another-realm/',
});
assert.strictEqual(calls.length, 3, 'each distinct key minted separately');
});

test('derives the realm-server origin from the realm URL', async () => {
let now = 1_700_000_000_000;
let nowSeconds = Math.floor(now / 1000);
let { fetch, calls } = recordingFetch(() =>
jsonResponse({
token: makeToken(nowSeconds + 1800),
realm: REALM,
permissions: ['read'],
}),
);
let manager = new DelegatedRealmSessionManager(SECRET, {
fetch,
now: () => now,
});
await manager.getToken({
onBehalfOf: ON_BEHALF_OF,
realm: 'https://realm.example.com/u/example-user/',
});
assert.strictEqual(
calls[0].url,
'https://realm.example.com/_delegate-session',
'POSTs to the realm origin, not the realm path',
);
});

test('invalidate() forces the next getToken to re-mint', async () => {
let now = 1_700_000_000_000;
let nowSeconds = Math.floor(now / 1000);
let { fetch, calls } = recordingFetch(() =>
jsonResponse({
token: makeToken(nowSeconds + 1800),
realm: REALM,
permissions: ['read'],
}),
);
let manager = new DelegatedRealmSessionManager(SECRET, {
fetch,
now: () => now,
});
await manager.getToken({ onBehalfOf: ON_BEHALF_OF, realm: REALM });
manager.invalidate({ onBehalfOf: ON_BEHALF_OF, realm: REALM });
await manager.getToken({ onBehalfOf: ON_BEHALF_OF, realm: REALM });
assert.strictEqual(calls.length, 2, 're-minted after invalidate');
});
});
17 changes: 9 additions & 8 deletions packages/realm-server/handlers/handle-delegate-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ import {
setContextResponse,
} from '../middleware/index.ts';
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';
Comment on lines 19 to +23

// Token lifetime per the v1 security design (CS-11551): 30 minutes. Long
// enough to span a tool call, short enough to bound how stale a revoked
Expand All @@ -31,7 +31,8 @@ const log = logger('realm:delegate-session');

// Mints a realm session JWT scoped to a named user's read access on a single
// realm (CS-11552). Shared-secret authenticated (HMAC over the request body +
// timestamp, see utils/delegation.ts). The minted token carries only ['read']
// timestamp, see @cardstack/runtime-common/user-delegated-realm-server-session).
// The minted token carries only ['read']
// and is flagged `delegated` so the realm accepts it read-only regardless of
// the user's broader permissions; it can never read anything the user
// couldn't, and can never write.
Expand All @@ -57,10 +58,10 @@ export default function handleDelegateSession({
let request = await fetchRequestFromContext(ctxt);
let rawBody = await request.text();

let auth = verifyDelegationRequest({
let auth = verifyDelegatedRealmSessionRequest({
secret: aiBotDelegationSecret,
timestamp: ctxt.get(DELEGATION_TIMESTAMP_HEADER),
signature: ctxt.get(DELEGATION_SIGNATURE_HEADER),
timestamp: ctxt.get(DELEGATED_REALM_SESSION_TIMESTAMP_HEADER),
signature: ctxt.get(DELEGATED_REALM_SESSION_SIGNATURE_HEADER),
rawBody,
now: Date.now(),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ import {
testRealmURL,
} from '../helpers/index.ts';
import {
DELEGATION_SIGNATURE_HEADER,
DELEGATION_TIMESTAMP_HEADER,
delegationSignature,
} from '../../utils/delegation.ts';
DELEGATED_REALM_SESSION_SIGNATURE_HEADER,
DELEGATED_REALM_SESSION_TIMESTAMP_HEADER,
delegatedRealmSessionSignature,
} from '@cardstack/runtime-common/user-delegated-realm-server-session';

const onBehalfOf = '@jane:localhost';
// A user with no permission rows on the test realm.
Expand All @@ -37,7 +37,7 @@ function signedPost(
let timestamp = String(opts.timestamp ?? Date.now());
let signature =
opts.signature ??
delegationSignature(
delegatedRealmSessionSignature(
opts.secret ?? aiBotDelegationSecret,
timestamp,
rawBody,
Expand All @@ -46,10 +46,10 @@ function signedPost(
.post('/_delegate-session')
.set('Content-Type', 'application/json');
if (!opts.omitTimestamp) {
req = req.set(DELEGATION_TIMESTAMP_HEADER, timestamp);
req = req.set(DELEGATED_REALM_SESSION_TIMESTAMP_HEADER, timestamp);
}
if (!opts.omitSignature) {
req = req.set(DELEGATION_SIGNATURE_HEADER, signature);
req = req.set(DELEGATED_REALM_SESSION_SIGNATURE_HEADER, signature);
}
return req.send(rawBody);
}
Expand Down
Loading
Loading