From 7e748076ceae15b8cf4e33f80979404df542aa56 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Sat, 25 Jul 2026 19:34:47 +0200 Subject: [PATCH 01/20] refactor(server): route token persistence through storage contract --- .../src/__tests__/sqlite-storage.test.ts | 56 ++++++ .../__tests__/token-storage-contract.test.ts | 19 +++ packages/server/src/routes/tokens.ts | 150 +++++----------- packages/server/src/storage/interface.ts | 27 +++ packages/server/src/storage/sqlite.ts | 161 +++++++++++++++++- 5 files changed, 300 insertions(+), 113 deletions(-) create mode 100644 packages/server/src/__tests__/token-storage-contract.test.ts diff --git a/packages/server/src/__tests__/sqlite-storage.test.ts b/packages/server/src/__tests__/sqlite-storage.test.ts index 6a4f5c1..cb72ca0 100644 --- a/packages/server/src/__tests__/sqlite-storage.test.ts +++ b/packages/server/src/__tests__/sqlite-storage.test.ts @@ -98,6 +98,62 @@ function createAuditEntry(overrides: Partial = {}): Omit { + const { storage, cleanup } = createHarness(); + + try { + await storage.tokens.persistIssued({ + id: "tok_row_1", + tokenId: "tok_external_1", + jti: "jti_1", + identityId: "agent_token_1", + sessionId: "sess_shared", + issuedAt: 1_774_608_000, + expiresAt: 1_774_611_600, + createdAt: "2026-03-27T12:00:00.000Z", + }); + await storage.tokens.persistIssued({ + id: "tok_row_2", + tokenId: "tok_external_2", + jti: "jti_2", + identityId: "agent_token_2", + sessionId: "sess_shared", + issuedAt: 1_774_608_000, + expiresAt: 1_774_611_600, + createdAt: "2026-03-27T12:00:00.000Z", + }); + + assert.deepEqual(await storage.tokens.getById("tok_external_1"), { + id: "tok_row_1", + tokenId: "tok_external_1", + jti: "jti_1", + identityId: "agent_token_1", + status: "active", + sessionId: "sess_shared", + expiresAt: 1_774_611_600, + }); + assert.deepEqual( + await storage.tokens.listActiveByIdentityId("agent_token_1"), + [await storage.tokens.getById("jti_1")], + ); + assert.deepEqual( + (await storage.tokens.listActiveBySessionId("sess_shared")).map((token) => token.id), + ["tok_row_1", "tok_row_2"], + ); + assert.deepEqual(await storage.tokens.listActiveIds("agent_token_1"), ["tok_row_1"]); + + await storage.revocations.revokeIdentityTokens( + "agent_token_1", + ["tok_row_1"], + "2026-03-27T12:01:00.000Z", + ); + assert.deepEqual(await storage.tokens.listActiveByIdentityId("agent_token_1"), []); + assert.equal((await storage.tokens.getById("tok_row_1"))?.status, "revoked"); + } finally { + cleanup(); + } +}); + test("sqlite identity storage supports CRUD, hierarchy, and budget auto-suspend", async () => { const { storage, cleanup } = createHarness(); diff --git a/packages/server/src/__tests__/token-storage-contract.test.ts b/packages/server/src/__tests__/token-storage-contract.test.ts new file mode 100644 index 0000000..449b813 --- /dev/null +++ b/packages/server/src/__tests__/token-storage-contract.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const tokenRoutesSource = readFileSync(new URL("../routes/tokens.ts", import.meta.url), "utf8"); +const storageInterfaceSource = readFileSync(new URL("../storage/interface.ts", import.meta.url), "utf8"); + +test("token routes use the public storage contract instead of raw SQL storage", () => { + assert.doesNotMatch(tokenRoutesSource, /\bstorage\.DB\b/); + assert.doesNotMatch(tokenRoutesSource, /\bSqlBackedStorage\b/); + assert.doesNotMatch(tokenRoutesSource, /\bgetSqlStorage\b/); +}); + +test("TokenStorage owns the complete issued-token hot-path contract", () => { + assert.match(storageInterfaceSource, /\bpersistIssued\(token: IssuedTokenRecord\): Promise/); + assert.match(storageInterfaceSource, /\bgetById\(tokenId: string\): Promise/); + assert.match(storageInterfaceSource, /\blistActiveByIdentityId\(identityId: string\): Promise/); + assert.match(storageInterfaceSource, /\blistActiveBySessionId\(sessionId: string\): Promise/); +}); diff --git a/packages/server/src/routes/tokens.ts b/packages/server/src/routes/tokens.ts index 25924b9..0763e9e 100644 --- a/packages/server/src/routes/tokens.ts +++ b/packages/server/src/routes/tokens.ts @@ -12,7 +12,7 @@ import { signToken } from "../lib/sign.js"; import { verifyRs256Token } from "../lib/token-verifier.js"; import type { StoredIdentity } from "../storage/identity-types.js"; import type { StoredApiKey } from "../storage/api-key-types.js"; -import type { AuditLogWriteEntry, AuthStorage, RevocationStorage } from "../storage/index.js"; +import type { AuditLogWriteEntry, AuthStorage, StoredTokenRecord } from "../storage/index.js"; type IssueTokenRequest = { identityId?: string; @@ -113,34 +113,6 @@ type RelayhistoryAssertionResponse = { tokenType: "Bearer"; }; -type TokenRow = { - id?: string | null; - token_id?: string | null; - jti?: string | null; - identity_id?: string | null; - status?: string | null; - session_id?: string | null; - expires_at?: number | string | null; -}; - -type SqlPrepared = { - bind(...params: unknown[]): { - all(): Promise<{ results: T[] }>; - first(): Promise; - run(): Promise<{ success: boolean; meta?: { changes?: number } }>; - }; -}; - -type SqlBackedStorage = AuthStorage & { - DB: { - prepare(sql: string): SqlPrepared; - }; - revocations: RevocationStorage & { - isRevoked?(jti: string): Promise; - revoke?(jti: string, expiresAt: number): Promise; - }; -}; - const tokens = new Hono(); const DEFAULT_ACCESS_TOKEN_TTL_SECONDS = 3600; @@ -158,40 +130,6 @@ const RELAYHISTORY_ASSERTION_AUDIENCE = "relayhistory"; const MAX_RELAYHISTORY_ASSERTION_TTL_SECONDS = 60; const RELAYHISTORY_ASSERTION_SCOPES = ["rth:read", "rth:sync"] as const; -const SELECT_TOKEN_BY_ID_SQL = ` - SELECT id, token_id, jti, identity_id, status, session_id, expires_at - FROM tokens - WHERE id = ? OR token_id = ? OR jti = ? - LIMIT 1 -`; - -const SELECT_TOKENS_BY_IDENTITY_SQL = ` - SELECT id, token_id, jti, identity_id, status, session_id, expires_at - FROM tokens - WHERE identity_id = ? AND status = 'active' -`; - -const SELECT_TOKENS_BY_SESSION_SQL = ` - SELECT id, token_id, jti, identity_id, status, session_id, expires_at - FROM tokens - WHERE session_id = ? AND status = 'active' -`; - -const INSERT_TOKEN_SQL = ` - INSERT INTO tokens ( - id, - token_id, - jti, - identity_id, - session_id, - issued_at, - expires_at, - status, - created_at - ) - VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?) -`; - tokens.post("/", async (c) => { const auth = await authenticateAndAuthorizeFromContext( c, @@ -212,7 +150,7 @@ tokens.post("/", async (c) => { return c.json({ error: "identityId is required" }, 400); } - const storage = getSqlStorage(c.get("storage")); + const storage = c.get("storage"); const identity = await storage.identities.get(identityId); if (!identity || identity.orgId !== auth.claims.org) { return c.json({ error: "identity_not_found" }, 404); @@ -272,7 +210,7 @@ tokens.post("/workspace", async (c) => { return c.json({ error: "insufficient_scope" }, 403); } - const storage = getSqlStorage(c.get("storage")); + const storage = c.get("storage"); const key = generateApiKey(WORKSPACE_TOKEN_PREFIX); const createdAt = new Date().toISOString(); const name = normalizeOptionalString(body.name) ?? `workspace:${workspaceId}`; @@ -304,7 +242,7 @@ tokens.post("/agent", async (c) => { return c.json({ error: auth.error, code: auth.code }, auth.status); } - const storage = getSqlStorage(c.get("storage")); + const storage = c.get("storage"); const workspaceToken = await resolveWorkspaceToken(storage, auth.claims); if (!workspaceToken) { return c.json({ error: "workspace_token_required", code: "workspace_token_required" }, 401); @@ -381,7 +319,7 @@ tokens.post("/path", async (c) => { return c.json({ error: auth.error, code: auth.code }, auth.status); } - const storage = getSqlStorage(c.get("storage")); + const storage = c.get("storage"); const workspaceToken = await resolveWorkspaceToken(storage, auth.claims); if (!workspaceToken) { return c.json({ error: "workspace_token_required", code: "workspace_token_required" }, 401); @@ -485,7 +423,7 @@ tokens.post("/workspace-path", async (c) => { return c.json({ error: "workspaceId is required", code: "workspaceId_required" }, 400); } - const storage = getSqlStorage(c.get("storage")); + const storage = c.get("storage"); // Direct workspace-path minting is intentionally equivalent to: // POST /v1/tokens/workspace (org API key + caller-supplied workspaceId) // followed by /v1/tokens/path. @@ -587,7 +525,7 @@ tokens.post("/relayhistory-assertion", async (c) => { return c.json({ error: request.error, code: request.code }, request.status); } - const storage = getSqlStorage(c.get("storage")); + const storage = c.get("storage"); const assertion = await issueRelayhistoryAssertion(storage, c.env, { deferTask: c.get("deferTask"), orgId: request.orgId, @@ -613,7 +551,7 @@ tokens.post("/refresh", async (c) => { return c.json({ error: "refreshToken is required" }, 400); } - const storage = getSqlStorage(c.get("storage")); + const storage = c.get("storage"); const verification = await verifyToken(refreshToken, c.env, { audience: [REFRESH_AUDIENCE] }); if (!verification.ok) { return c.json({ error: verification.error }, 401); @@ -728,7 +666,7 @@ tokens.post("/revoke", async (c) => { return c.json({ error: "tokenId, identityId, or sessionId is required" }, 400); } - const storage = getSqlStorage(c.get("storage")); + const storage = c.get("storage"); const targetTokens = tokenId ? await findTargetTokensByTokenId(storage, tokenId) : identityId @@ -739,7 +677,7 @@ tokens.post("/revoke", async (c) => { return c.json({ error: "token_not_found" }, 404); } - const firstIdentityId = normalizeOptionalString(targetTokens[0]?.identity_id); + const firstIdentityId = normalizeOptionalString(targetTokens[0]?.identityId); const identity = firstIdentityId ? await storage.identities.get(firstIdentityId) : null; if (!identity || identity.orgId !== auth.claims.org) { return c.json({ error: "token_not_found" }, 404); @@ -776,7 +714,7 @@ tokens.get("/introspect", async (c) => { return c.json({ error: "token query parameter is required" }, 400); } - const storage = getSqlStorage(c.get("storage")); + const storage = c.get("storage"); const verification = await verifyToken(token, c.env); if (!verification.ok) { return c.json(null, 200); @@ -805,7 +743,7 @@ tokens.get("/introspect", async (c) => { export default tokens; async function issueTokenPair( - storage: SqlBackedStorage, + storage: AuthStorage, env: AppEnv["Bindings"], identity: StoredIdentity, options: { @@ -901,7 +839,7 @@ async function issueTokenPair( } async function issueRelayhistoryAssertion( - storage: SqlBackedStorage, + storage: AuthStorage, env: AppEnv["Bindings"], options: { deferTask: DeferredTaskScheduler; @@ -964,27 +902,25 @@ async function issueRelayhistoryAssertion( } async function persistIssuedToken( - storage: SqlBackedStorage, + storage: AuthStorage, identityId: string, claims: RelayAuthTokenClaims, ): Promise { const createdAt = new Date(claims.iat * 1000).toISOString(); - await storage.DB.prepare(INSERT_TOKEN_SQL) - .bind( - claims.jti, - claims.jti, - claims.jti, - identityId, - claims.sid ?? null, - claims.iat, - claims.exp, - createdAt, - ) - .run(); + await storage.tokens.persistIssued({ + id: claims.jti, + tokenId: claims.jti, + jti: claims.jti, + identityId, + sessionId: claims.sid ?? null, + issuedAt: claims.iat, + expiresAt: claims.exp, + createdAt, + }); } async function writeTokenAudit( - storage: SqlBackedStorage, + storage: AuthStorage, options: { action: "token.issued" | "token.refreshed" | "token.revoked"; identity: StoredIdentity; @@ -996,7 +932,7 @@ async function writeTokenAudit( } async function writeTokenAuditBatch( - storage: SqlBackedStorage, + storage: AuthStorage, options: { action: "token.issued" | "token.refreshed" | "token.revoked"; identity: StoredIdentity; @@ -1033,7 +969,7 @@ function createTokenAuditEntry( } async function writeAssertionAuditBatch( - storage: SqlBackedStorage, + storage: AuthStorage, options: { actorId: string; actorOrgId: string; @@ -1063,43 +999,39 @@ async function writeAssertionAuditBatch( }]); } -async function findTargetTokensByTokenId(storage: SqlBackedStorage, tokenId: string): Promise { +async function findTargetTokensByTokenId(storage: AuthStorage, tokenId: string): Promise { const row = await findStoredTokenById(storage, tokenId); return row ? [row] : []; } -async function findTargetTokensByIdentityId(storage: SqlBackedStorage, identityId: string): Promise { +async function findTargetTokensByIdentityId(storage: AuthStorage, identityId: string): Promise { const normalizedIdentityId = normalizeOptionalString(identityId); if (!normalizedIdentityId) { return []; } - const result = await storage.DB.prepare(SELECT_TOKENS_BY_IDENTITY_SQL).bind(normalizedIdentityId).all(); - return result.results; + return storage.tokens.listActiveByIdentityId(normalizedIdentityId); } -async function findTargetTokensBySessionId(storage: SqlBackedStorage, sessionId: string): Promise { +async function findTargetTokensBySessionId(storage: AuthStorage, sessionId: string): Promise { const normalizedSessionId = normalizeOptionalString(sessionId); if (!normalizedSessionId) { return []; } - const result = await storage.DB.prepare(SELECT_TOKENS_BY_SESSION_SQL).bind(normalizedSessionId).all(); - return result.results; + return storage.tokens.listActiveBySessionId(normalizedSessionId); } -async function findStoredTokenById(storage: SqlBackedStorage, tokenId: string): Promise { +async function findStoredTokenById(storage: AuthStorage, tokenId: string): Promise { const normalizedTokenId = normalizeOptionalString(tokenId); if (!normalizedTokenId) { return null; } - return storage.DB.prepare(SELECT_TOKEN_BY_ID_SQL) - .bind(normalizedTokenId, normalizedTokenId, normalizedTokenId) - .first(); + return storage.tokens.getById(normalizedTokenId); } -async function isTokenRevoked(storage: SqlBackedStorage, jti: string): Promise { +async function isTokenRevoked(storage: AuthStorage, jti: string): Promise { if (typeof storage.revocations.isRevoked === "function") { return storage.revocations.isRevoked(jti); } @@ -1108,7 +1040,7 @@ async function isTokenRevoked(storage: SqlBackedStorage, jti: string): Promise; get(id: string): Promise; @@ -148,11 +169,17 @@ export interface IdentityStorage { } export interface TokenStorage { + persistIssued(token: IssuedTokenRecord): Promise; + getById(tokenId: string): Promise; + listActiveByIdentityId(identityId: string): Promise; + listActiveBySessionId(sessionId: string): Promise; listActiveIds(identityId: string): Promise; } export interface RevocationStorage { revokeIdentityTokens(identityId: string, tokenIds: string[], revokedAt: string): Promise; + isRevoked?(tokenId: string): Promise; + revoke?(tokenId: string, expiresAt: number): Promise; } export interface RoleStorage { diff --git a/packages/server/src/storage/sqlite.ts b/packages/server/src/storage/sqlite.ts index f2288ec..2e6c30f 100644 --- a/packages/server/src/storage/sqlite.ts +++ b/packages/server/src/storage/sqlite.ts @@ -43,12 +43,14 @@ import type { IdentityChildSummary, IdentityStorage, IdentityStatusCounts, + IssuedTokenRecord, ListIdentitiesOptions, OrganizationContextRecord, PolicyStorage, PolicyUpdate, RevocationStorage, RoleStorage, + StoredTokenRecord, TokenStorage, RoleUpdate, WorkspaceContextRecord, @@ -412,6 +414,40 @@ const LIST_ACTIVE_TOKENS_SQL = ` WHERE identity_id = ? AND status = 'active' `; +const SELECT_TOKEN_BY_ID_SQL = ` + SELECT id, token_id, jti, identity_id, status, session_id, expires_at + FROM tokens + WHERE id = ? OR token_id = ? OR jti = ? + LIMIT 1 +`; + +const SELECT_TOKENS_BY_IDENTITY_SQL = ` + SELECT id, token_id, jti, identity_id, status, session_id, expires_at + FROM tokens + WHERE identity_id = ? AND status = 'active' +`; + +const SELECT_TOKENS_BY_SESSION_SQL = ` + SELECT id, token_id, jti, identity_id, status, session_id, expires_at + FROM tokens + WHERE session_id = ? AND status = 'active' +`; + +const INSERT_TOKEN_SQL = ` + INSERT INTO tokens ( + id, + token_id, + jti, + identity_id, + session_id, + issued_at, + expires_at, + status, + created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?) +`; + const UPSERT_REVOKED_TOKEN_SQL = ` INSERT OR REPLACE INTO revoked_tokens (jti, expires_at) VALUES (?, ?) @@ -604,6 +640,15 @@ type ChildIdentityRow = { }; type StatusCountRow = { status?: string | null; count?: number | string | null }; type ActiveTokenRow = { id?: string; jti?: string; token_id?: string }; +type TokenRow = { + id?: string | null; + token_id?: string | null; + jti?: string | null; + identity_id?: string | null; + status?: string | null; + session_id?: string | null; + expires_at?: number | string | null; +}; type ExistsRow = { found?: number | string | bigint | null }; type RevokedTokenRow = { expires_at?: number | string | null }; type TableInfoRow = { name?: string | null }; @@ -705,10 +750,13 @@ type BudgetPolicyResult = { type MemoryTokenRecord = { id: string; - tokenId?: string; - jti?: string; + tokenId: string; + jti: string; identityId: string; status: string; + sessionId?: string | null; + issuedAt: number; + expiresAt: number; createdAt: string; }; @@ -1240,6 +1288,91 @@ class SqliteIdentityStorage implements IdentityStorage { class SqliteTokenStorage implements TokenStorage { constructor(private readonly provider: BackendProvider) {} + async persistIssued(token: IssuedTokenRecord): Promise { + const backend = await this.provider.getBackend(); + + if (backend.kind === "memory") { + backend.state.tokens.set(token.id, { + ...token, + status: "active", + }); + return; + } + + backend.db.prepare(INSERT_TOKEN_SQL).run( + token.id, + token.tokenId, + token.jti, + token.identityId, + token.sessionId ?? null, + token.issuedAt, + token.expiresAt, + token.createdAt, + ); + } + + async getById(tokenId: string): Promise { + const normalizedTokenId = normalizeOptionalString(tokenId); + if (!normalizedTokenId) { + return null; + } + + const backend = await this.provider.getBackend(); + if (backend.kind === "memory") { + const token = [...backend.state.tokens.values()].find((candidate) => + candidate.id === normalizedTokenId + || candidate.tokenId === normalizedTokenId + || candidate.jti === normalizedTokenId + ); + return token ? toStoredTokenRecord(token) : null; + } + + const row = backend.db.prepare(SELECT_TOKEN_BY_ID_SQL).get( + normalizedTokenId, + normalizedTokenId, + normalizedTokenId, + ); + return row ? toStoredTokenRecord(row) : null; + } + + async listActiveByIdentityId(identityId: string): Promise { + const normalizedIdentityId = normalizeOptionalString(identityId); + if (!normalizedIdentityId) { + return []; + } + + const backend = await this.provider.getBackend(); + if (backend.kind === "memory") { + return [...backend.state.tokens.values()] + .filter((token) => token.identityId === normalizedIdentityId && token.status === "active") + .map(toStoredTokenRecord); + } + + return backend.db + .prepare(SELECT_TOKENS_BY_IDENTITY_SQL) + .all(normalizedIdentityId) + .map(toStoredTokenRecord); + } + + async listActiveBySessionId(sessionId: string): Promise { + const normalizedSessionId = normalizeOptionalString(sessionId); + if (!normalizedSessionId) { + return []; + } + + const backend = await this.provider.getBackend(); + if (backend.kind === "memory") { + return [...backend.state.tokens.values()] + .filter((token) => token.sessionId === normalizedSessionId && token.status === "active") + .map(toStoredTokenRecord); + } + + return backend.db + .prepare(SELECT_TOKENS_BY_SESSION_SQL) + .all(normalizedSessionId) + .map(toStoredTokenRecord); + } + async listActiveIds(identityId: string): Promise { const normalizedIdentityId = requireString(identityId, "identityId is required"); const backend = await this.provider.getBackend(); @@ -1259,6 +1392,30 @@ class SqliteTokenStorage implements TokenStorage { } } +function toStoredTokenRecord(token: TokenRow | MemoryTokenRecord): StoredTokenRecord { + if ("identityId" in token) { + return { + id: token.id, + tokenId: token.tokenId, + jti: token.jti, + identityId: token.identityId, + status: token.status, + sessionId: token.sessionId ?? null, + expiresAt: token.expiresAt, + }; + } + + return { + id: token.id, + tokenId: token.token_id, + jti: token.jti, + identityId: token.identity_id, + status: token.status, + sessionId: token.session_id, + expiresAt: token.expires_at, + }; +} + class SqliteRevocationStorage implements RevocationStorage { constructor(private readonly provider: BackendProvider) {} From ae639fc8bdd1797d12a64518318c6f922db01739 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Thu, 30 Jul 2026 20:56:39 +0200 Subject: [PATCH 02/20] fix(server): make token pair audit persistence atomic --- .../src/__tests__/sqlite-storage.test.ts | 102 ++++++++++++++++++ .../__tests__/token-storage-contract.test.ts | 4 + .../server/src/__tests__/tokens-route.test.ts | 38 +++---- .../db/migrations/0005_audit_hot_outbox.sql | 47 ++++++++ packages/server/src/routes/tokens.ts | 38 +++---- packages/server/src/storage/interface.ts | 14 +++ packages/server/src/storage/sqlite.ts | 55 ++++++++++ 7 files changed, 251 insertions(+), 47 deletions(-) create mode 100644 packages/server/src/db/migrations/0005_audit_hot_outbox.sql diff --git a/packages/server/src/__tests__/sqlite-storage.test.ts b/packages/server/src/__tests__/sqlite-storage.test.ts index cb72ca0..8deecdf 100644 --- a/packages/server/src/__tests__/sqlite-storage.test.ts +++ b/packages/server/src/__tests__/sqlite-storage.test.ts @@ -154,6 +154,108 @@ test("sqlite token storage owns issued-token persistence and hot-path lookups", } }); +test("sqlite token pair and audit entry commit atomically and retries do not duplicate", async () => { + const { storage, cleanup } = createHarness(); + const pair = { + accessToken: { + id: "tok_pair_access", + tokenId: "tok_pair_access", + jti: "tok_pair_access", + identityId: "agent_pair", + sessionId: "sess_pair", + issuedAt: 1_774_608_000, + expiresAt: 1_774_611_600, + createdAt: "2026-03-27T12:00:00.000Z", + }, + refreshToken: { + id: "tok_pair_refresh", + tokenId: "tok_pair_refresh", + jti: "tok_pair_refresh", + identityId: "agent_pair", + sessionId: "sess_pair", + issuedAt: 1_774_608_000, + expiresAt: 1_774_694_400, + createdAt: "2026-03-27T12:00:00.000Z", + }, + auditEntry: createAuditEntry({ + id: "aud_pair", + action: "token.issued", + identityId: "agent_pair", + metadata: { tokenId: "tok_pair_access" }, + }), + }; + + try { + await storage.tokens.persistIssuedPairWithAudit(pair); + assert.equal((await storage.tokens.listActiveBySessionId("sess_pair")).length, 2); + assert.deepEqual( + (await storage.audit.query({ + orgId: "org_test", + action: "token.issued", + limit: 10, + }, { includeOverflowRow: false })).map((entry) => entry.id), + ["aud_pair"], + ); + + await assert.rejects( + () => storage.tokens.persistIssuedPairWithAudit(pair), + ); + assert.equal((await storage.tokens.listActiveBySessionId("sess_pair")).length, 2); + assert.equal((await storage.audit.query({ + orgId: "org_test", + action: "token.issued", + limit: 10, + }, { includeOverflowRow: false })).length, 1); + } finally { + cleanup(); + } +}); + +test("sqlite token pair rolls back both token rows when the audit insert fails", async () => { + const { storage, cleanup } = createHarness(); + + try { + await storage.audit.write(createAuditEntry({ + id: "aud_conflict", + action: "token.issued", + })); + + await assert.rejects(() => storage.tokens.persistIssuedPairWithAudit({ + accessToken: { + id: "tok_rollback_access", + tokenId: "tok_rollback_access", + jti: "tok_rollback_access", + identityId: "agent_rollback", + sessionId: "sess_rollback", + issuedAt: 1_774_608_000, + expiresAt: 1_774_611_600, + createdAt: "2026-03-27T12:00:00.000Z", + }, + refreshToken: { + id: "tok_rollback_refresh", + tokenId: "tok_rollback_refresh", + jti: "tok_rollback_refresh", + identityId: "agent_rollback", + sessionId: "sess_rollback", + issuedAt: 1_774_608_000, + expiresAt: 1_774_694_400, + createdAt: "2026-03-27T12:00:00.000Z", + }, + auditEntry: createAuditEntry({ + id: "aud_conflict", + action: "token.issued", + identityId: "agent_rollback", + }), + })); + + assert.deepEqual(await storage.tokens.listActiveBySessionId("sess_rollback"), []); + assert.equal(await storage.tokens.getById("tok_rollback_access"), null); + assert.equal(await storage.tokens.getById("tok_rollback_refresh"), null); + } finally { + cleanup(); + } +}); + test("sqlite identity storage supports CRUD, hierarchy, and budget auto-suspend", async () => { const { storage, cleanup } = createHarness(); diff --git a/packages/server/src/__tests__/token-storage-contract.test.ts b/packages/server/src/__tests__/token-storage-contract.test.ts index 449b813..9cac7ca 100644 --- a/packages/server/src/__tests__/token-storage-contract.test.ts +++ b/packages/server/src/__tests__/token-storage-contract.test.ts @@ -13,6 +13,10 @@ test("token routes use the public storage contract instead of raw SQL storage", test("TokenStorage owns the complete issued-token hot-path contract", () => { assert.match(storageInterfaceSource, /\bpersistIssued\(token: IssuedTokenRecord\): Promise/); + assert.match( + storageInterfaceSource, + /\bpersistIssuedPairWithAudit\(input: IssuedTokenPairAudit\): Promise/, + ); assert.match(storageInterfaceSource, /\bgetById\(tokenId: string\): Promise/); assert.match(storageInterfaceSource, /\blistActiveByIdentityId\(identityId: string\): Promise/); assert.match(storageInterfaceSource, /\blistActiveBySessionId\(sessionId: string\): Promise/); diff --git a/packages/server/src/__tests__/tokens-route.test.ts b/packages/server/src/__tests__/tokens-route.test.ts index 49c2a82..66e8f2c 100644 --- a/packages/server/src/__tests__/tokens-route.test.ts +++ b/packages/server/src/__tests__/tokens-route.test.ts @@ -478,38 +478,28 @@ test("POST /v1/tokens", async (t) => { await assertRs256Algorithm(body.refreshToken, ["relayauth"]); }); - await t.test("returns the minted pair when a deferred batched audit insert fails", async () => { + await t.test("fails closed without token rows when the atomic audit commit fails", async () => { const deferred: DeferredTask[] = []; const { app, identity, authHeaders } = await createHarness({ deferTask: (task) => deferred.push(task), }); - let auditBatchAttempts = 0; - app.storage.audit.writeBatch = async () => { - auditBatchAttempts += 1; + let atomicCommitAttempts = 0; + app.storage.tokens.persistIssuedPairWithAudit = async () => { + atomicCommitAttempts += 1; throw new Error("audit insert failed"); }; - const logged: unknown[][] = []; - const originalConsoleError = console.error; - console.error = (...args: unknown[]) => { - logged.push(args); - }; - try { - const response = await requestRoute(app, "POST", "/v1/tokens", { + const response = await withSilencedConsoleError(() => requestRoute(app, "POST", "/v1/tokens", { body: { identityId: identity.id }, headers: authHeaders, - }); + })); - const body = await assertJsonResponse(response, 201); - assert.equal(typeof body.accessToken, "string"); - assert.equal(deferred.length, 1, "audit work should be registered with the request lifecycle"); - assert.equal(await countStoredTokens(app), 2, "essential token records must be committed before responding"); - await Promise.all(deferred.map((task) => task())); - assert.equal(auditBatchAttempts, 1); - assert.ok(logged.some((args) => String(args[0]).includes("Deferred RelayAuth task failed"))); - } finally { - console.error = originalConsoleError; - } + await assertJsonResponse(response, 500, (body) => { + assert.equal(body.code, "internal_error"); + }); + assert.equal(deferred.length, 0, "token mint audit must not be deferred"); + assert.equal(atomicCommitAttempts, 1); + assert.equal(await countStoredTokens(app), 0); }); await t.test("returns 401 when Authorization is missing", async () => { @@ -634,7 +624,7 @@ test("POST /v1/tokens when the database cannot allocate", async (t) => { await t.test("carries a hosted adapter's translated capacity error", async () => { const { app, identity, authHeaders } = await createHarness(); - app.storage.DB.prepare = () => { + app.storage.tokens.persistIssuedPairWithAudit = async () => { throw new StorageCapacityExhaustedError("tokens.persist"); }; @@ -650,7 +640,7 @@ test("POST /v1/tokens when the database cannot allocate", async (t) => { await t.test("leaves unrelated internal failures on the generic error envelope", async () => { const { app, identity, authHeaders } = await createHarness(); - app.storage.DB.prepare = () => { + app.storage.tokens.persistIssuedPairWithAudit = async () => { throw new Error("unexpected mint failure"); }; diff --git a/packages/server/src/db/migrations/0005_audit_hot_outbox.sql b/packages/server/src/db/migrations/0005_audit_hot_outbox.sql new file mode 100644 index 0000000..910cc02 --- /dev/null +++ b/packages/server/src/db/migrations/0005_audit_hot_outbox.sql @@ -0,0 +1,47 @@ +-- Bounded operational audit store plus durable archive outbox. +-- +-- Platform adapters that archive audit entries atomically enqueue the +-- canonical JSON envelope here. The hot table remains fully indexed for +-- recent operational reads and is trimmed only after archive custody is +-- acknowledged. The historical audit_logs table is intentionally untouched. + +CREATE TABLE IF NOT EXISTS audit_hot_logs ( + id TEXT PRIMARY KEY, + action TEXT NOT NULL, + identity_id TEXT, + org_id TEXT NOT NULL, + workspace_id TEXT, + plane TEXT, + resource TEXT, + result TEXT NOT NULL, + metadata_json TEXT, + ip TEXT, + user_agent TEXT, + timestamp TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_audit_hot_org_timestamp + ON audit_hot_logs (org_id, timestamp DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_audit_hot_identity_timestamp + ON audit_hot_logs (identity_id, timestamp DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_audit_hot_workspace_timestamp + ON audit_hot_logs (workspace_id, timestamp DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_audit_hot_org_action_timestamp + ON audit_hot_logs (org_id, action, timestamp DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_audit_hot_created_at + ON audit_hot_logs (created_at, id); + +CREATE TABLE IF NOT EXISTS audit_outbox ( + id TEXT PRIMARY KEY, + payload_json TEXT NOT NULL, + payload_sha256 TEXT NOT NULL, + created_at TEXT NOT NULL, + available_at TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + archived_at TEXT, + last_error TEXT +); + +CREATE INDEX IF NOT EXISTS idx_audit_outbox_pending + ON audit_outbox (archived_at, available_at, created_at, id); diff --git a/packages/server/src/routes/tokens.ts b/packages/server/src/routes/tokens.ts index 0763e9e..e43a704 100644 --- a/packages/server/src/routes/tokens.ts +++ b/packages/server/src/routes/tokens.ts @@ -817,17 +817,15 @@ async function issueTokenPair( ? wrapRelayToken(signedRefreshToken, relayTokenPrefix(options.tokenIdPrefix)) : signedRefreshToken; - await persistIssuedToken(storage, identity.id, accessClaims); - await persistIssuedToken(storage, identity.id, refreshClaims); - scheduleDeferredTask( - options.deferTask, - "audit.token_mint", - () => writeTokenAuditBatch(storage, { + await storage.tokens.persistIssuedPairWithAudit({ + accessToken: toIssuedTokenRecord(identity.id, accessClaims), + refreshToken: toIssuedTokenRecord(identity.id, refreshClaims), + auditEntry: createTokenAuditEntry({ action: options.action, identity, tokenId: accessClaims.jti, }), - ); + }); return { accessToken, @@ -906,8 +904,14 @@ async function persistIssuedToken( identityId: string, claims: RelayAuthTokenClaims, ): Promise { - const createdAt = new Date(claims.iat * 1000).toISOString(); - await storage.tokens.persistIssued({ + await storage.tokens.persistIssued(toIssuedTokenRecord(identityId, claims)); +} + +function toIssuedTokenRecord( + identityId: string, + claims: RelayAuthTokenClaims, +) { + return { id: claims.jti, tokenId: claims.jti, jti: claims.jti, @@ -915,8 +919,8 @@ async function persistIssuedToken( sessionId: claims.sid ?? null, issuedAt: claims.iat, expiresAt: claims.exp, - createdAt, - }); + createdAt: new Date(claims.iat * 1000).toISOString(), + }; } async function writeTokenAudit( @@ -931,18 +935,6 @@ async function writeTokenAudit( await storage.audit.write(createTokenAuditEntry(options)); } -async function writeTokenAuditBatch( - storage: AuthStorage, - options: { - action: "token.issued" | "token.refreshed" | "token.revoked"; - identity: StoredIdentity; - tokenId: string; - actorId?: string; - }, -): Promise { - await storage.audit.writeBatch([createTokenAuditEntry(options)]); -} - function createTokenAuditEntry( options: { action: "token.issued" | "token.refreshed" | "token.revoked"; diff --git a/packages/server/src/storage/interface.ts b/packages/server/src/storage/interface.ts index 359d452..e84816c 100644 --- a/packages/server/src/storage/interface.ts +++ b/packages/server/src/storage/interface.ts @@ -152,6 +152,19 @@ export type StoredTokenRecord = { expiresAt?: number | string | null; }; +/** + * Durable mint boundary. Implementations must commit both token rows and the + * audit entry atomically before callers return the signed token pair. + * + * A platform adapter may additionally materialize the audit entry in an + * outbox, but it must never acknowledge a partial commit. + */ +export type IssuedTokenPairAudit = { + accessToken: IssuedTokenRecord; + refreshToken: IssuedTokenRecord; + auditEntry: AuditLogWriteEntry; +}; + export interface IdentityStorage { list(orgId: string, options?: ListIdentitiesOptions): Promise; get(id: string): Promise; @@ -170,6 +183,7 @@ export interface IdentityStorage { export interface TokenStorage { persistIssued(token: IssuedTokenRecord): Promise; + persistIssuedPairWithAudit(input: IssuedTokenPairAudit): Promise; getById(tokenId: string): Promise; listActiveByIdentityId(identityId: string): Promise; listActiveBySessionId(sessionId: string): Promise; diff --git a/packages/server/src/storage/sqlite.ts b/packages/server/src/storage/sqlite.ts index 2e6c30f..41a37f6 100644 --- a/packages/server/src/storage/sqlite.ts +++ b/packages/server/src/storage/sqlite.ts @@ -43,6 +43,7 @@ import type { IdentityChildSummary, IdentityStorage, IdentityStatusCounts, + IssuedTokenPairAudit, IssuedTokenRecord, ListIdentitiesOptions, OrganizationContextRecord, @@ -1311,6 +1312,60 @@ class SqliteTokenStorage implements TokenStorage { ); } + async persistIssuedPairWithAudit(input: IssuedTokenPairAudit): Promise { + const backend = await this.provider.getBackend(); + const auditEntry = normalizeAuditWriteEntry(input.auditEntry); + + if (backend.kind === "memory") { + if ( + backend.state.tokens.has(input.accessToken.id) + || backend.state.tokens.has(input.refreshToken.id) + || backend.state.auditLogs.some((entry) => entry.id === auditEntry.id) + ) { + throw new StorageError("token_pair_already_exists", 409, "token_pair_already_exists"); + } + + backend.state.tokens.set(input.accessToken.id, { + ...input.accessToken, + status: "active", + }); + backend.state.tokens.set(input.refreshToken.id, { + ...input.refreshToken, + status: "active", + }); + backend.state.auditLogs.push(cloneAuditEntryRecord(auditEntry)); + backend.state.auditLogs.sort(compareAuditRecordDesc); + return; + } + + backend.db.exec("BEGIN IMMEDIATE"); + try { + const insertToken = backend.db.prepare(INSERT_TOKEN_SQL); + for (const token of [input.accessToken, input.refreshToken]) { + insertToken.run( + token.id, + token.tokenId, + token.jti, + token.identityId, + token.sessionId ?? null, + token.issuedAt, + token.expiresAt, + token.createdAt, + ); + } + backend.db.prepare(INSERT_AUDIT_LOG_SQL).run(...toAuditParams(auditEntry)); + backend.db.exec("COMMIT"); + } catch (error) { + try { + backend.db.exec("ROLLBACK"); + } catch { + // Preserve the originating storage error (for example SQLITE_FULL); + // rollback errors must not hide the retry/capacity classification. + } + throw error; + } + } + async getById(tokenId: string): Promise { const normalizedTokenId = normalizeOptionalString(tokenId); if (!normalizedTokenId) { From 4cf70cb4f49161b745086175ec49a705b7e17434 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Thu, 30 Jul 2026 21:27:24 +0200 Subject: [PATCH 03/20] fix(server): retain compact audit archive receipts --- packages/server/src/db/migrations/0005_audit_hot_outbox.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/server/src/db/migrations/0005_audit_hot_outbox.sql b/packages/server/src/db/migrations/0005_audit_hot_outbox.sql index 910cc02..e686163 100644 --- a/packages/server/src/db/migrations/0005_audit_hot_outbox.sql +++ b/packages/server/src/db/migrations/0005_audit_hot_outbox.sql @@ -36,6 +36,7 @@ CREATE TABLE IF NOT EXISTS audit_outbox ( id TEXT PRIMARY KEY, payload_json TEXT NOT NULL, payload_sha256 TEXT NOT NULL, + archive_key TEXT NOT NULL, created_at TEXT NOT NULL, available_at TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, From 198f7aa09bbba02a711c5fa6ca1ce54b2fa8fa89 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Thu, 30 Jul 2026 21:59:39 +0200 Subject: [PATCH 04/20] fix(server): make audit lifecycle durable --- .../src/__tests__/sqlite-storage.test.ts | 123 +++++++++++++++ .../__tests__/token-storage-contract.test.ts | 8 + .../server/src/__tests__/tokens-route.test.ts | 58 ++++++- .../db/migrations/0005_audit_hot_outbox.sql | 40 ++++- packages/server/src/routes/tokens.ts | 117 +++++++------- packages/server/src/storage/interface.ts | 24 +++ packages/server/src/storage/sqlite.ts | 146 ++++++++++++++++++ 7 files changed, 449 insertions(+), 67 deletions(-) diff --git a/packages/server/src/__tests__/sqlite-storage.test.ts b/packages/server/src/__tests__/sqlite-storage.test.ts index 8deecdf..59163bd 100644 --- a/packages/server/src/__tests__/sqlite-storage.test.ts +++ b/packages/server/src/__tests__/sqlite-storage.test.ts @@ -256,6 +256,129 @@ test("sqlite token pair rolls back both token rows when the audit insert fails", } }); +test("sqlite refresh rotation atomically mints, revokes, and audits", async () => { + const { storage, cleanup } = createHarness(); + const previous = { + id: "tok_previous_refresh", + tokenId: "tok_previous_refresh", + jti: "tok_previous_refresh", + identityId: "agent_rotation", + sessionId: "sess_rotation", + issuedAt: 1_774_608_000, + expiresAt: 1_800_000_000, + createdAt: "2026-03-27T12:00:00.000Z", + }; + const accessToken = { + ...previous, + id: "tok_rotation_access", + tokenId: "tok_rotation_access", + jti: "tok_rotation_access", + expiresAt: 1_774_611_600, + }; + const refreshToken = { + ...previous, + id: "tok_rotation_refresh", + tokenId: "tok_rotation_refresh", + jti: "tok_rotation_refresh", + }; + + try { + await storage.tokens.persistIssued(previous); + await storage.tokens.rotateIssuedPairWithAudit({ + accessToken, + refreshToken, + previousRefreshToken: { + id: previous.id, + identityId: previous.identityId, + expiresAt: previous.expiresAt, + }, + refreshedAuditEntry: createAuditEntry({ + id: "aud_rotation_refreshed", + action: "token.refreshed", + identityId: previous.identityId, + }), + revokedAuditEntry: createAuditEntry({ + id: "aud_rotation_revoked", + action: "token.revoked", + identityId: previous.identityId, + }), + }); + + assert.equal((await storage.tokens.getById(previous.id))?.status, "revoked"); + assert.equal((await storage.tokens.getById(accessToken.id))?.status, "active"); + assert.equal((await storage.tokens.getById(refreshToken.id))?.status, "active"); + assert.equal(await storage.revocations.isRevoked?.(previous.id), true); + assert.deepEqual( + (await storage.audit.query({ + orgId: "org_test", + limit: 10, + }, { includeOverflowRow: false })).map((entry) => entry.id).sort(), + ["aud_rotation_refreshed", "aud_rotation_revoked"], + ); + } finally { + cleanup(); + } +}); + +test("sqlite refresh rotation audit conflict rolls back new tokens and old-JTI revocation", async () => { + const { storage, cleanup } = createHarness(); + const previous = { + id: "tok_previous_rollback", + tokenId: "tok_previous_rollback", + jti: "tok_previous_rollback", + identityId: "agent_rotation_rollback", + sessionId: "sess_rotation_rollback", + issuedAt: 1_774_608_000, + expiresAt: 1_774_694_400, + createdAt: "2026-03-27T12:00:00.000Z", + }; + + try { + await storage.tokens.persistIssued(previous); + await storage.audit.write(createAuditEntry({ + id: "aud_rotation_conflict", + action: "token.revoked", + identityId: previous.identityId, + })); + await assert.rejects(() => storage.tokens.rotateIssuedPairWithAudit({ + accessToken: { + ...previous, + id: "tok_rollback_rotation_access", + tokenId: "tok_rollback_rotation_access", + jti: "tok_rollback_rotation_access", + }, + refreshToken: { + ...previous, + id: "tok_rollback_rotation_refresh", + tokenId: "tok_rollback_rotation_refresh", + jti: "tok_rollback_rotation_refresh", + }, + previousRefreshToken: { + id: previous.id, + identityId: previous.identityId, + expiresAt: previous.expiresAt, + }, + refreshedAuditEntry: createAuditEntry({ + id: "aud_rotation_first", + action: "token.refreshed", + identityId: previous.identityId, + }), + revokedAuditEntry: createAuditEntry({ + id: "aud_rotation_conflict", + action: "token.revoked", + identityId: previous.identityId, + }), + })); + + assert.equal((await storage.tokens.getById(previous.id))?.status, "active"); + assert.equal(await storage.tokens.getById("tok_rollback_rotation_access"), null); + assert.equal(await storage.tokens.getById("tok_rollback_rotation_refresh"), null); + assert.equal(await storage.revocations.isRevoked?.(previous.id), false); + } finally { + cleanup(); + } +}); + test("sqlite identity storage supports CRUD, hierarchy, and budget auto-suspend", async () => { const { storage, cleanup } = createHarness(); diff --git a/packages/server/src/__tests__/token-storage-contract.test.ts b/packages/server/src/__tests__/token-storage-contract.test.ts index 9cac7ca..f73611c 100644 --- a/packages/server/src/__tests__/token-storage-contract.test.ts +++ b/packages/server/src/__tests__/token-storage-contract.test.ts @@ -17,6 +17,14 @@ test("TokenStorage owns the complete issued-token hot-path contract", () => { storageInterfaceSource, /\bpersistIssuedPairWithAudit\(input: IssuedTokenPairAudit\): Promise/, ); + assert.match( + storageInterfaceSource, + /\bpersistIssuedWithAudit\(input: IssuedTokenAudit\): Promise/, + ); + assert.match( + storageInterfaceSource, + /\brotateIssuedPairWithAudit\(input: IssuedTokenRotationAudit\): Promise/, + ); assert.match(storageInterfaceSource, /\bgetById\(tokenId: string\): Promise/); assert.match(storageInterfaceSource, /\blistActiveByIdentityId\(identityId: string\): Promise/); assert.match(storageInterfaceSource, /\blistActiveBySessionId\(sessionId: string\): Promise/); diff --git a/packages/server/src/__tests__/tokens-route.test.ts b/packages/server/src/__tests__/tokens-route.test.ts index 66e8f2c..518fd6a 100644 --- a/packages/server/src/__tests__/tokens-route.test.ts +++ b/packages/server/src/__tests__/tokens-route.test.ts @@ -1219,7 +1219,6 @@ test("POST /v1/tokens/relayhistory-assertion", async (t) => { grantedScopes: JSON.stringify(["rth:read", "rth:sync"]), }); assert.equal(await countStoredTokens(app), 1, "only the access assertion should be persisted"); - await Promise.all(deferred.map((task) => task())); const auditRow = await app.storage.DB.prepare(` SELECT action, identity_id, org_id, workspace_id, resource, metadata_json @@ -1246,6 +1245,37 @@ test("POST /v1/tokens/relayhistory-assertion", async (t) => { }); }); + await t.test("fails closed without an assertion token when its audit cannot commit", async () => { + const { app, authHeaders } = await createHarness({ + authClaims: { + scopes: assertionKeyIssuerScopes, + }, + }); + const assertionKey = await issueAssertionKey(app, authHeaders); + app.storage.tokens.persistIssuedWithAudit = async () => { + throw new Error("fault: assertion audit"); + }; + + const response = await withSilencedConsoleError(() => requestRoute( + app, + "POST", + "/v1/tokens/relayhistory-assertion", + { + body: { + orgId: "org_relayhistory_target", + workspaceId: "ws_relayhistory_target", + sponsorId: "user_cloud_login", + scopes: ["rth:read"], + }, + headers: { "x-api-key": assertionKey.key }, + }, + )); + await assertJsonResponse(response, 500, (body) => { + assert.equal(body.code, "internal_error"); + }); + assert.equal(await countStoredTokens(app), 0); + }); + await t.test("requires the dedicated api-key path even when a bearer has the assertion scope", async () => { const { app, authHeaders } = await createHarness({ authClaims: { @@ -1358,6 +1388,32 @@ test("POST /v1/tokens/refresh", async (t) => { await assertRs256Algorithm(body.refreshToken, ["relayauth"]); }); + await t.test("rolls back the new pair and leaves the old JTI active when rotation audit fails", async () => { + const { app, identity } = await createHarness(); + const { pair, accessClaims, refreshClaims } = createRs256TokenPair(identity); + await seedActiveTokens(app, identity.id, [accessClaims.jti, refreshClaims.jti]); + const beforeCount = await countStoredTokens(app); + app.storage.tokens.rotateIssuedPairWithAudit = async () => { + throw new Error("fault: rotation audit"); + }; + + const response = await withSilencedConsoleError(() => requestRoute( + app, + "POST", + "/v1/tokens/refresh", + { body: { refreshToken: pair.refreshToken } }, + )); + await assertJsonResponse(response, 500, (body) => { + assert.equal(body.code, "internal_error"); + }); + assert.equal(await countStoredTokens(app), beforeCount); + assert.equal( + (await app.storage.tokens.getById(refreshClaims.jti))?.status, + "active", + ); + assert.deepEqual(await listRevokedTokenIds(app), []); + }); + await t.test("returns 400 when refreshToken is missing", async () => { const { app } = await createHarness(); diff --git a/packages/server/src/db/migrations/0005_audit_hot_outbox.sql b/packages/server/src/db/migrations/0005_audit_hot_outbox.sql index e686163..6fb23fd 100644 --- a/packages/server/src/db/migrations/0005_audit_hot_outbox.sql +++ b/packages/server/src/db/migrations/0005_audit_hot_outbox.sql @@ -40,9 +40,47 @@ CREATE TABLE IF NOT EXISTS audit_outbox ( created_at TEXT NOT NULL, available_at TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, + lease_token TEXT, + lease_until TEXT, + poisoned_at TEXT, archived_at TEXT, last_error TEXT ); CREATE INDEX IF NOT EXISTS idx_audit_outbox_pending - ON audit_outbox (archived_at, available_at, created_at, id); + ON audit_outbox ( + archived_at, + poisoned_at, + available_at, + lease_until, + created_at, + id + ); + +-- Compact, bounded historical read index. The immutable event bodies remain +-- in R2 under v1/. Each row points at a minute partition manifest under +-- indexes/v1/ and retains only aggregate/filter metadata in D1. +CREATE TABLE IF NOT EXISTS audit_archive_partitions ( + org_id TEXT NOT NULL, + partition_minute TEXT NOT NULL, + index_key TEXT NOT NULL, + index_sha256 TEXT NOT NULL, + entry_count INTEGER NOT NULL, + min_timestamp TEXT NOT NULL, + max_timestamp TEXT NOT NULL, + actions_json TEXT NOT NULL, + identity_ids_json TEXT NOT NULL, + workspace_ids_json TEXT NOT NULL, + planes_json TEXT NOT NULL, + results_json TEXT NOT NULL, + tokens_issued INTEGER NOT NULL DEFAULT 0, + tokens_revoked INTEGER NOT NULL DEFAULT 0, + tokens_refreshed INTEGER NOT NULL DEFAULT 0, + scope_checks INTEGER NOT NULL DEFAULT 0, + scope_denials INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL, + PRIMARY KEY (org_id, partition_minute) +); + +CREATE INDEX IF NOT EXISTS idx_audit_archive_partitions_org_minute + ON audit_archive_partitions (org_id, partition_minute DESC); diff --git a/packages/server/src/routes/tokens.ts b/packages/server/src/routes/tokens.ts index e43a704..14d2e65 100644 --- a/packages/server/src/routes/tokens.ts +++ b/packages/server/src/routes/tokens.ts @@ -527,7 +527,6 @@ tokens.post("/relayhistory-assertion", async (c) => { const storage = c.get("storage"); const assertion = await issueRelayhistoryAssertion(storage, c.env, { - deferTask: c.get("deferTask"), orgId: request.orgId, workspaceId: request.workspaceId, sponsorId: request.sponsorId, @@ -628,17 +627,22 @@ tokens.post("/refresh", async (c) => { wrapAccessToken: isDerivedClaims(verification.claims), wrapRefreshToken: isDerivedClaims(verification.claims), tokenIdPrefix: tokenPrefixForClaims(verification.claims), + previousRefreshToken: { + id: presentedJti, + identityId: identity.id, + expiresAt: verification.claims.exp, + }, }); - // Single-use enforcement: revoke the previous refresh JTI after we have - // successfully issued and persisted the new pair. If this revoke fails the - // new pair is already persisted — we log the audit and still surface the - // error so callers don't see a successful rotation that left the old JTI - // active. - try { - await revokePreviousRefreshJti(storage, identity, presentedJti, verification.claims.exp); - } catch (error) { - return c.json({ error: "refresh_rotation_failed" }, 500); + // The durable storage transaction above is the source of truth. Hosted + // adapters may also maintain a low-latency revocation cache; a cache write + // cannot roll back or split the already-atomic token rotation. + if (typeof storage.revocations.revoke === "function") { + try { + await storage.revocations.revoke(presentedJti, verification.claims.exp); + } catch (error) { + console.error("Failed to refresh revocation cache after atomic rotation", error); + } } return c.json(tokenPair, 200); @@ -760,6 +764,11 @@ async function issueTokenPair( wrapAccessToken?: boolean; wrapRefreshToken?: boolean; tokenIdPrefix?: string; + previousRefreshToken?: { + id: string; + identityId: string; + expiresAt: number; + }; }, ): Promise { const issuedAtSeconds = Math.floor(Date.now() / 1000); @@ -817,15 +826,32 @@ async function issueTokenPair( ? wrapRelayToken(signedRefreshToken, relayTokenPrefix(options.tokenIdPrefix)) : signedRefreshToken; - await storage.tokens.persistIssuedPairWithAudit({ - accessToken: toIssuedTokenRecord(identity.id, accessClaims), - refreshToken: toIssuedTokenRecord(identity.id, refreshClaims), - auditEntry: createTokenAuditEntry({ - action: options.action, - identity, - tokenId: accessClaims.jti, - }), + const accessTokenRecord = toIssuedTokenRecord(identity.id, accessClaims); + const refreshTokenRecord = toIssuedTokenRecord(identity.id, refreshClaims); + const issueAuditEntry = createTokenAuditEntry({ + action: options.action, + identity, + tokenId: accessClaims.jti, }); + if (options.previousRefreshToken) { + await storage.tokens.rotateIssuedPairWithAudit({ + accessToken: accessTokenRecord, + refreshToken: refreshTokenRecord, + previousRefreshToken: options.previousRefreshToken, + refreshedAuditEntry: issueAuditEntry, + revokedAuditEntry: createTokenAuditEntry({ + action: "token.revoked", + identity, + tokenId: options.previousRefreshToken.id, + }), + }); + } else { + await storage.tokens.persistIssuedPairWithAudit({ + accessToken: accessTokenRecord, + refreshToken: refreshTokenRecord, + auditEntry: issueAuditEntry, + }); + } return { accessToken, @@ -840,7 +866,6 @@ async function issueRelayhistoryAssertion( storage: AuthStorage, env: AppEnv["Bindings"], options: { - deferTask: DeferredTaskScheduler; orgId: string; workspaceId: string; sponsorId: string; @@ -877,11 +902,9 @@ async function issueRelayhistoryAssertion( }; const accessToken = await signToken(claims, env); - await persistIssuedToken(storage, claims.sub, claims); - scheduleDeferredTask( - options.deferTask, - "audit.relayhistory_assertion_mint", - () => writeAssertionAuditBatch(storage, { + await storage.tokens.persistIssuedWithAudit({ + token: toIssuedTokenRecord(claims.sub, claims), + auditEntry: createAssertionAuditEntry({ actorId: options.actorId, actorOrgId: options.actorOrgId, orgId: options.orgId, @@ -890,7 +913,7 @@ async function issueRelayhistoryAssertion( tokenId: claims.jti, scopes: options.scopes, }), - ); + }); return { accessToken, @@ -899,14 +922,6 @@ async function issueRelayhistoryAssertion( }; } -async function persistIssuedToken( - storage: AuthStorage, - identityId: string, - claims: RelayAuthTokenClaims, -): Promise { - await storage.tokens.persistIssued(toIssuedTokenRecord(identityId, claims)); -} - function toIssuedTokenRecord( identityId: string, claims: RelayAuthTokenClaims, @@ -960,8 +975,7 @@ function createTokenAuditEntry( }; } -async function writeAssertionAuditBatch( - storage: AuthStorage, +function createAssertionAuditEntry( options: { actorId: string; actorOrgId: string; @@ -971,8 +985,8 @@ async function writeAssertionAuditBatch( tokenId: string; scopes: string[]; }, -): Promise { - await storage.audit.writeBatch([{ +): AuditLogWriteEntry { + return { id: crypto.randomUUID(), action: "token.issued", identityId: options.actorId, @@ -988,7 +1002,7 @@ async function writeAssertionAuditBatch( grantedScopes: JSON.stringify(options.scopes), }, timestamp: new Date().toISOString(), - }]); + }; } async function findTargetTokensByTokenId(storage: AuthStorage, tokenId: string): Promise { @@ -1031,33 +1045,6 @@ async function isTokenRevoked(storage: AuthStorage, jti: string): Promise { - const jti = normalizeOptionalString(previousJti); - if (!jti) { - return; - } - - const revokedAt = new Date().toISOString(); - await storage.revocations.revokeIdentityTokens(identity.id, [jti], revokedAt); - if (typeof storage.revocations.revoke === "function") { - const expiresAt = Number.isFinite(previousExp) && previousExp > 0 - ? previousExp - : Math.floor(Date.now() / 1000) + DEFAULT_REFRESH_TOKEN_TTL_SECONDS; - await storage.revocations.revoke(jti, expiresAt); - } - - await writeTokenAudit(storage, { - action: "token.revoked", - identity, - tokenId: jti, - }); -} - async function cascadeRevokeSession( storage: AuthStorage, identity: StoredIdentity, diff --git a/packages/server/src/storage/interface.ts b/packages/server/src/storage/interface.ts index e84816c..067d612 100644 --- a/packages/server/src/storage/interface.ts +++ b/packages/server/src/storage/interface.ts @@ -165,6 +165,28 @@ export type IssuedTokenPairAudit = { auditEntry: AuditLogWriteEntry; }; +export type IssuedTokenAudit = { + token: IssuedTokenRecord; + auditEntry: AuditLogWriteEntry; +}; + +/** + * Atomic single-use refresh boundary. A successful commit creates the new + * pair, revokes the presented refresh JTI, and persists both audit entries. + * A failed commit must leave the old refresh token active and create nothing. + */ +export type IssuedTokenRotationAudit = { + accessToken: IssuedTokenRecord; + refreshToken: IssuedTokenRecord; + previousRefreshToken: { + id: string; + identityId: string; + expiresAt: number; + }; + refreshedAuditEntry: AuditLogWriteEntry; + revokedAuditEntry: AuditLogWriteEntry; +}; + export interface IdentityStorage { list(orgId: string, options?: ListIdentitiesOptions): Promise; get(id: string): Promise; @@ -183,7 +205,9 @@ export interface IdentityStorage { export interface TokenStorage { persistIssued(token: IssuedTokenRecord): Promise; + persistIssuedWithAudit(input: IssuedTokenAudit): Promise; persistIssuedPairWithAudit(input: IssuedTokenPairAudit): Promise; + rotateIssuedPairWithAudit(input: IssuedTokenRotationAudit): Promise; getById(tokenId: string): Promise; listActiveByIdentityId(identityId: string): Promise; listActiveBySessionId(sessionId: string): Promise; diff --git a/packages/server/src/storage/sqlite.ts b/packages/server/src/storage/sqlite.ts index 41a37f6..df8ddb7 100644 --- a/packages/server/src/storage/sqlite.ts +++ b/packages/server/src/storage/sqlite.ts @@ -43,7 +43,9 @@ import type { IdentityChildSummary, IdentityStorage, IdentityStatusCounts, + IssuedTokenAudit, IssuedTokenPairAudit, + IssuedTokenRotationAudit, IssuedTokenRecord, ListIdentitiesOptions, OrganizationContextRecord, @@ -1312,6 +1314,50 @@ class SqliteTokenStorage implements TokenStorage { ); } + async persistIssuedWithAudit(input: IssuedTokenAudit): Promise { + const backend = await this.provider.getBackend(); + const auditEntry = normalizeAuditWriteEntry(input.auditEntry); + + if (backend.kind === "memory") { + if ( + backend.state.tokens.has(input.token.id) + || backend.state.auditLogs.some((entry) => entry.id === auditEntry.id) + ) { + throw new StorageError("token_already_exists", 409, "token_already_exists"); + } + backend.state.tokens.set(input.token.id, { + ...input.token, + status: "active", + }); + backend.state.auditLogs.push(cloneAuditEntryRecord(auditEntry)); + backend.state.auditLogs.sort(compareAuditRecordDesc); + return; + } + + backend.db.exec("BEGIN IMMEDIATE"); + try { + backend.db.prepare(INSERT_TOKEN_SQL).run( + input.token.id, + input.token.tokenId, + input.token.jti, + input.token.identityId, + input.token.sessionId ?? null, + input.token.issuedAt, + input.token.expiresAt, + input.token.createdAt, + ); + backend.db.prepare(INSERT_AUDIT_LOG_SQL).run(...toAuditParams(auditEntry)); + backend.db.exec("COMMIT"); + } catch (error) { + try { + backend.db.exec("ROLLBACK"); + } catch { + // Preserve the originating storage error. + } + throw error; + } + } + async persistIssuedPairWithAudit(input: IssuedTokenPairAudit): Promise { const backend = await this.provider.getBackend(); const auditEntry = normalizeAuditWriteEntry(input.auditEntry); @@ -1366,6 +1412,106 @@ class SqliteTokenStorage implements TokenStorage { } } + async rotateIssuedPairWithAudit(input: IssuedTokenRotationAudit): Promise { + const backend = await this.provider.getBackend(); + const refreshedAudit = normalizeAuditWriteEntry(input.refreshedAuditEntry); + const revokedAudit = normalizeAuditWriteEntry(input.revokedAuditEntry); + const previous = input.previousRefreshToken; + + if (backend.kind === "memory") { + const previousToken = backend.state.tokens.get(previous.id); + if ( + !previousToken + || previousToken.identityId !== previous.identityId + || previousToken.status !== "active" + ) { + throw new StorageError( + "refresh_token_not_active", + 409, + "refresh_token_not_active", + ); + } + if ( + backend.state.tokens.has(input.accessToken.id) + || backend.state.tokens.has(input.refreshToken.id) + || backend.state.auditLogs.some((entry) => + entry.id === refreshedAudit.id || entry.id === revokedAudit.id) + ) { + throw new StorageError("token_rotation_conflict", 409, "token_rotation_conflict"); + } + backend.state.tokens.set(input.accessToken.id, { + ...input.accessToken, + status: "active", + }); + backend.state.tokens.set(input.refreshToken.id, { + ...input.refreshToken, + status: "active", + }); + previousToken.status = "revoked"; + backend.state.revokedTokens.set(previous.id, { + expiresAt: previous.expiresAt, + identityId: previous.identityId, + revokedAt: revokedAudit.timestamp, + }); + backend.state.auditLogs.push( + cloneAuditEntryRecord(refreshedAudit), + cloneAuditEntryRecord(revokedAudit), + ); + backend.state.auditLogs.sort(compareAuditRecordDesc); + return; + } + + backend.db.exec("BEGIN IMMEDIATE"); + try { + const insertToken = backend.db.prepare(INSERT_TOKEN_SQL); + for (const token of [input.accessToken, input.refreshToken]) { + insertToken.run( + token.id, + token.tokenId, + token.jti, + token.identityId, + token.sessionId ?? null, + token.issuedAt, + token.expiresAt, + token.createdAt, + ); + } + const revoked = backend.db.prepare(` + UPDATE tokens + SET status = 'revoked' + WHERE identity_id = ? + AND status = 'active' + AND (id = ? OR token_id = ? OR jti = ?) + `).run( + previous.identityId, + previous.id, + previous.id, + previous.id, + ); + if (Number(revoked.changes ?? 0) !== 1) { + throw new StorageError( + "refresh_token_not_active", + 409, + "refresh_token_not_active", + ); + } + backend.db.prepare(UPSERT_REVOKED_TOKEN_SQL).run( + previous.id, + previous.expiresAt, + ); + backend.db.prepare(INSERT_AUDIT_LOG_SQL).run(...toAuditParams(refreshedAudit)); + backend.db.prepare(INSERT_AUDIT_LOG_SQL).run(...toAuditParams(revokedAudit)); + backend.db.exec("COMMIT"); + } catch (error) { + try { + backend.db.exec("ROLLBACK"); + } catch { + // Preserve the originating storage error. + } + throw error; + } + } + async getById(tokenId: string): Promise { const normalizedTokenId = normalizeOptionalString(tokenId); if (!normalizedTokenId) { From 00364df16738b8b41f781471514b34427f000823 Mon Sep 17 00:00:00 2001 From: Miya Date: Fri, 31 Jul 2026 00:47:09 +0200 Subject: [PATCH 05/20] feat(audit): expose bounded archive continuations (cherry picked from commit 47a2141b1342868e2c17482f6e9944d84e102596) --- .../src/__tests__/client-audit.test.ts | 22 +++++ packages/sdk/typescript/src/client.ts | 40 ++++++--- packages/sdk/typescript/src/index.ts | 6 +- .../server/src/__tests__/audit-logger.test.ts | 2 +- .../src/__tests__/audit-query-api.test.ts | 85 +++++++++++++++++++ .../src/__tests__/dashboard-stats-api.test.ts | 64 ++++++++++++++ .../server/src/__tests__/e2e/audit.test.ts | 4 +- .../server/src/__tests__/e2e/rbac.test.ts | 4 +- .../src/__tests__/sqlite-storage.test.ts | 13 +-- .../src/__tests__/storage-sqlite.test.ts | 10 +-- packages/server/src/routes/audit-export.ts | 16 +++- packages/server/src/routes/audit-query.ts | 66 +++++++++++--- packages/server/src/routes/dashboard-stats.ts | 39 ++++++++- .../server/src/routes/identity-activity.ts | 19 ++++- packages/server/src/storage/compat.ts | 4 +- packages/server/src/storage/interface.ts | 60 +++++++++++-- packages/server/src/storage/sqlite.ts | 69 +++++++++------ 17 files changed, 446 insertions(+), 77 deletions(-) diff --git a/packages/sdk/typescript/src/__tests__/client-audit.test.ts b/packages/sdk/typescript/src/__tests__/client-audit.test.ts index 96b74a5..2e4aedf 100644 --- a/packages/sdk/typescript/src/__tests__/client-audit.test.ts +++ b/packages/sdk/typescript/src/__tests__/client-audit.test.ts @@ -173,6 +173,28 @@ test("queryAudit sends audit filters as query params and maps nextCursor to curs assert.equal(request.url.searchParams.get("limit"), "50"); }); +test("queryAudit preserves a typed archive work-budget continuation", async (t) => { + const client = createClient(); + const workBudget = { d1Pages: 4, d1Rows: 129, partitions: 128, r2Reads: 128 }; + const fetchMock = mockFetch(() => + jsonResponse({ + entries: auditEntries.slice(0, 1), + nextCursor: "archive_partition_cursor", + hasMore: true, + partial: true, + workBudget, + }), + ); + t.after(() => fetchMock.restore()); + + assert.deepEqual(await client.queryAudit({ orgId: "org_123" }), { + entries: auditEntries.slice(0, 1), + cursor: "archive_partition_cursor", + partial: true, + workBudget, + }); +}); + test("queryAudit returns an empty page when no audit entries match", async (t) => { const client = createClient(); const fetchMock = mockFetch(() => diff --git a/packages/sdk/typescript/src/client.ts b/packages/sdk/typescript/src/client.ts index 9cd053e..0cfaf3a 100644 --- a/packages/sdk/typescript/src/client.ts +++ b/packages/sdk/typescript/src/client.ts @@ -34,6 +34,20 @@ export interface RelayAuthClientOptions { token?: string; } +export type AuditQueryWorkBudget = { + d1Pages: number; + d1Rows: number; + partitions: number; + r2Reads: number; +}; + +export type AuditQueryPage = { + entries: AuditEntry[]; + cursor?: string; + partial?: true; + workBudget?: AuditQueryWorkBudget; +}; + type ListIdentitiesOptions = { limit?: number; cursor?: string; @@ -213,10 +227,12 @@ export class RelayAuthClient { }; } - async queryAudit(query: AuditQuery): Promise<{ entries: AuditEntry[]; cursor?: string }> { + async queryAudit(query: AuditQuery): Promise { const response = await this._request<{ entries?: AuditEntry[]; nextCursor?: string | null; + partial?: boolean; + workBudget?: AuditQueryWorkBudget; }>("/v1/audit", { query: serializeAuditQuery(query), }); @@ -227,10 +243,12 @@ export class RelayAuthClient { async getIdentityActivity( identityId: string, options?: Omit, - ): Promise<{ entries: AuditEntry[]; cursor?: string }> { + ): Promise { const response = await this._request<{ entries?: AuditEntry[]; nextCursor?: string | null; + partial?: boolean; + workBudget?: AuditQueryWorkBudget; }>(`/v1/identities/${encodeURIComponent(identityId)}/activity`, { query: serializeAuditQuery(options), }); @@ -441,15 +459,15 @@ function serializeAuditQuery(query?: Partial): Record= 1, "expected a token validation audit write"); const tokenValidated = entries.find((e: any) => e.action === "token.validated"); diff --git a/packages/server/src/__tests__/audit-query-api.test.ts b/packages/server/src/__tests__/audit-query-api.test.ts index 14c65b4..f1fa2c4 100644 --- a/packages/server/src/__tests__/audit-query-api.test.ts +++ b/packages/server/src/__tests__/audit-query-api.test.ts @@ -5,6 +5,7 @@ import { assertJsonResponse, createTestApp, createTestRequest, + createTestStorage, generateTestToken, seedAuditEntries, } from "./test-helpers.js"; @@ -12,6 +13,14 @@ import { type AuditQueryResponse = { entries: Array; nextCursor: string | null; + hasMore?: boolean; + partial?: boolean; + workBudget?: { + d1Pages: number; + d1Rows: number; + partitions: number; + r2Reads: number; + }; }; function createAuditEntry( @@ -369,6 +378,82 @@ test("GET /v1/audit supports cursor-based pagination with limit", async () => { assert.equal(secondPage.nextCursor, null); }); +test("GET /v1/audit returns a typed archive budget continuation that resumes without gaps or duplicates", async () => { + const storage = createTestStorage(); + const entries = [ + createAuditEntry(3, { id: "aud_archive_003", orgId: "org_archive", timestamp: "2026-03-24T12:00:03.000Z" }), + createAuditEntry(2, { id: "aud_archive_002", orgId: "org_archive", timestamp: "2026-03-24T12:00:02.000Z" }), + createAuditEntry(1, { id: "aud_archive_001", orgId: "org_archive", timestamp: "2026-03-24T12:00:01.000Z" }), + ]; + storage.audit.query = async (query) => { + assert.equal(query.orgId, "org_archive"); + if (query.cursor?.kind === "archive_partition") { + assert.equal(query.cursor.timestamp, "2026-03-24T12:00:02.000Z"); + return { kind: "complete", entries: [entries[2]!] }; + } + return { + kind: "budget_exhausted", + entries: entries.slice(0, 2), + continuation: { + kind: "archive_partition", + orgId: "org_archive", + timestamp: "2026-03-24T12:00:02.000Z", + }, + workBudget: { d1Pages: 4, d1Rows: 129, partitions: 128, r2Reads: 128 }, + }; + }; + const app = createTestApp({ storage }); + const token = `Bearer ${generateTestToken({ + org: "org_archive", + scopes: ["relayauth:audit:read"], + })}`; + + const first = await app.request( + createTestRequest("GET", "/v1/audit?orgId=org_archive", undefined, { Authorization: token }), + undefined, + app.bindings, + ); + const firstPage = await assertJsonResponse(first, 200); + assert.equal(firstPage.partial, true); + assert.equal(firstPage.hasMore, true); + assert.equal(typeof firstPage.nextCursor, "string"); + assert.deepEqual(firstPage.workBudget, { d1Pages: 4, d1Rows: 129, partitions: 128, r2Reads: 128 }); + + const crossOrg = await app.request( + createTestRequest( + "GET", + `/v1/audit?orgId=org_other&cursor=${encodeURIComponent(firstPage.nextCursor!)}`, + undefined, + { + Authorization: `Bearer ${generateTestToken({ + org: "org_other", + scopes: ["relayauth:audit:read"], + })}`, + }, + ), + undefined, + app.bindings, + ); + await assertJsonResponse<{ error: string }>(crossOrg, 400, (body) => { + assert.equal(body.error, "invalid cursor"); + }); + + const second = await app.request( + createTestRequest( + "GET", + `/v1/audit?orgId=org_archive&cursor=${encodeURIComponent(firstPage.nextCursor!)}`, + undefined, + { Authorization: token }, + ), + undefined, + app.bindings, + ); + const secondPage = await assertJsonResponse(second, 200); + const received = [...firstPage.entries, ...secondPage.entries].map((entry) => entry.id); + assert.deepEqual(received, entries.map((entry) => entry.id)); + assert.equal(new Set(received).size, received.length); +}); + test("GET /v1/audit returns 400 when orgId is missing", async () => { const response = await queryAudit("", { claims: { org: "org_test", scopes: ["relayauth:audit:read"] }, diff --git a/packages/server/src/__tests__/dashboard-stats-api.test.ts b/packages/server/src/__tests__/dashboard-stats-api.test.ts index 0edba77..b837707 100644 --- a/packages/server/src/__tests__/dashboard-stats-api.test.ts +++ b/packages/server/src/__tests__/dashboard-stats-api.test.ts @@ -5,6 +5,7 @@ import { assertJsonResponse, createTestApp, createTestRequest, + createTestStorage, generateTestIdentity, generateTestToken, seedAuditEntries, @@ -19,6 +20,15 @@ type DashboardStatsResponse = { scopeDenials: number; activeIdentities: number; suspendedIdentities: number; + partial?: boolean; + nextCursor?: string; + hasMore?: boolean; + workBudget?: { + d1Pages: number; + d1Rows: number; + partitions: number; + r2Reads: number; + }; }; type AuditLogRow = { @@ -578,6 +588,60 @@ test("GET /v1/stats is scoped to the caller's org", async () => { assert.equal(body.suspendedIdentities, 0); }); +test("GET /v1/stats exposes a typed, org-scoped bounded count continuation", async () => { + const storage = createTestStorage(); + storage.audit.getActionCounts = async (orgId, query) => { + assert.equal(orgId, "org_stats_continuation"); + if (query.cursor?.kind === "archive_partition") { + return { + kind: "complete", + counts: { tokensIssued: 2, tokensRevoked: 0, tokensRefreshed: 0, scopeChecks: 0, scopeDenials: 0 }, + }; + } + return { + kind: "budget_exhausted", + counts: { tokensIssued: 128, tokensRevoked: 0, tokensRefreshed: 0, scopeChecks: 0, scopeDenials: 0 }, + continuation: { + kind: "archive_partition", + orgId: "org_stats_continuation", + timestamp: "2026-03-24T12:00:00.000Z", + }, + workBudget: { d1Pages: 1, d1Rows: 129, partitions: 128, r2Reads: 0 }, + }; + }; + const app = createTestApp({ storage }); + const token = `Bearer ${generateTestToken({ + org: "org_stats_continuation", + scopes: ["relayauth:stats:read"], + })}`; + + const first = await app.request( + createTestRequest("GET", "/v1/stats", undefined, { Authorization: token }), + undefined, + app.bindings, + ); + const firstBody = await assertJsonResponse(first, 200); + assert.equal(firstBody.tokensIssued, 128); + assert.equal(firstBody.partial, true); + assert.equal(firstBody.hasMore, true); + assert.equal(typeof firstBody.nextCursor, "string"); + assert.deepEqual(firstBody.workBudget, { d1Pages: 1, d1Rows: 129, partitions: 128, r2Reads: 0 }); + + const second = await app.request( + createTestRequest( + "GET", + `/v1/stats?cursor=${encodeURIComponent(firstBody.nextCursor!)}`, + undefined, + { Authorization: token }, + ), + undefined, + app.bindings, + ); + const secondBody = await assertJsonResponse(second, 200); + assert.equal(secondBody.tokensIssued, 2); + assert.equal(secondBody.partial, undefined); +}); + test("GET /v1/stats returns 401 without valid auth token", async () => { const app = createTestApp(); const request = createTestRequest("GET", "/v1/stats"); diff --git a/packages/server/src/__tests__/e2e/audit.test.ts b/packages/server/src/__tests__/e2e/audit.test.ts index c4e6366..5d2d913 100644 --- a/packages/server/src/__tests__/e2e/audit.test.ts +++ b/packages/server/src/__tests__/e2e/audit.test.ts @@ -287,7 +287,7 @@ test("Audit & Observability E2E", async (t) => { action: "budget.alert" as AuditAction, limit: 10, }); - const alertEntry = alertEntries.find((entry) => entry.action === "budget.alert"); + const alertEntry = alertEntries.entries.find((entry) => entry.action === "budget.alert"); assert.ok(alertEntry, "expected a budget.alert audit row"); const requests: Array<{ request: Request; body: string }> = []; @@ -399,7 +399,7 @@ test("Audit & Observability E2E", async (t) => { const afterPurge = await countExpiredEntries(scenario.harness.db, 90); assert.deepEqual(afterPurge, { expiredCount: 0 }); assert.equal( - (await scenario.harness.db.audit.query({ orgId: ORG_ID, limit: 100 })).some( + (await scenario.harness.db.audit.query({ orgId: ORG_ID, limit: 100 })).entries.some( (row) => row.id === "aud_retention_old", ), false, diff --git a/packages/server/src/__tests__/e2e/rbac.test.ts b/packages/server/src/__tests__/e2e/rbac.test.ts index 2e709f3..973f6fa 100644 --- a/packages/server/src/__tests__/e2e/rbac.test.ts +++ b/packages/server/src/__tests__/e2e/rbac.test.ts @@ -399,7 +399,7 @@ test("Scopes & RBAC E2E", async (t) => { action: "budget.exceeded" as AuditAction, limit: 10, }); - const audit = auditEntries.find( + const audit = auditEntries.entries.find( (entry) => entry.action === "budget.exceeded" && entry.resource === READ_GENERAL_SCOPE, @@ -447,7 +447,7 @@ test("Scopes & RBAC E2E", async (t) => { action: "scope.escalation_denied" as AuditAction, limit: 10, }); - const audit = auditEntries.find( + const audit = auditEntries.entries.find( (entry) => entry.action === "scope.escalation_denied" && entry.resource === FILE_WRITE_SCOPE, diff --git a/packages/server/src/__tests__/sqlite-storage.test.ts b/packages/server/src/__tests__/sqlite-storage.test.ts index 59163bd..e1647eb 100644 --- a/packages/server/src/__tests__/sqlite-storage.test.ts +++ b/packages/server/src/__tests__/sqlite-storage.test.ts @@ -193,7 +193,7 @@ test("sqlite token pair and audit entry commit atomically and retries do not dup orgId: "org_test", action: "token.issued", limit: 10, - }, { includeOverflowRow: false })).map((entry) => entry.id), + }, { includeOverflowRow: false })).entries.map((entry) => entry.id), ["aud_pair"], ); @@ -205,7 +205,7 @@ test("sqlite token pair and audit entry commit atomically and retries do not dup orgId: "org_test", action: "token.issued", limit: 10, - }, { includeOverflowRow: false })).length, 1); + }, { includeOverflowRow: false })).entries.length, 1); } finally { cleanup(); } @@ -312,7 +312,7 @@ test("sqlite refresh rotation atomically mints, revokes, and audits", async () = (await storage.audit.query({ orgId: "org_test", limit: 10, - }, { includeOverflowRow: false })).map((entry) => entry.id).sort(), + }, { includeOverflowRow: false })).entries.map((entry) => entry.id).sort(), ["aud_rotation_refreshed", "aud_rotation_revoked"], ); } finally { @@ -574,16 +574,19 @@ test("sqlite storage supports roles, policies, audit, webhooks, contexts, and re orgId: "org_test", limit: 1, }); - assert.equal(queriedAudit.length, 2); - assert.equal(queriedAudit[0]?.id, "aud_newer"); + assert.equal(queriedAudit.entries.length, 2); + assert.equal(queriedAudit.entries[0]?.id, "aud_newer"); const actionCounts = await storage.audit.getActionCounts("org_test", {}); assert.deepEqual(actionCounts, { + kind: "complete", + counts: { tokensIssued: 0, tokensRevoked: 1, tokensRefreshed: 0, scopeChecks: 1, scopeDenials: 0, + }, }); const suspendedIdentity = createIdentity({ diff --git a/packages/server/src/__tests__/storage-sqlite.test.ts b/packages/server/src/__tests__/storage-sqlite.test.ts index a6bd15c..e03e43e 100644 --- a/packages/server/src/__tests__/storage-sqlite.test.ts +++ b/packages/server/src/__tests__/storage-sqlite.test.ts @@ -219,9 +219,9 @@ test("TestSqliteAuditLog", async (t) => { } const byOrg = await storage.audit.query({ orgId: "org_audit", limit: 10 }); - assert.equal(byOrg.length, 2); - assert.equal(byOrg[0]?.action, "identity.updated"); - assert.equal(byOrg[1]?.action, "identity.created"); + assert.equal(byOrg.entries.length, 2); + assert.equal(byOrg.entries[0]?.action, "identity.updated"); + assert.equal(byOrg.entries[1]?.action, "identity.created"); const byTimeRange = await storage.audit.query({ orgId: "org_audit", @@ -229,8 +229,8 @@ test("TestSqliteAuditLog", async (t) => { to: "2026-03-27T12:45:00.000Z", limit: 10, }); - assert.equal(byTimeRange.length, 1); - assert.equal(byTimeRange[0]?.action, "identity.updated"); + assert.equal(byTimeRange.entries.length, 1); + assert.equal(byTimeRange.entries[0]?.action, "identity.updated"); }); test("TestSqliteAutoCreateTables", async (t) => { diff --git a/packages/server/src/routes/audit-export.ts b/packages/server/src/routes/audit-export.ts index e67e19f..d4392d6 100644 --- a/packages/server/src/routes/audit-export.ts +++ b/packages/server/src/routes/audit-export.ts @@ -4,6 +4,7 @@ import type { AppEnv } from "../env.js"; import { requireScope } from "../middleware/scope.js"; import { buildAuditQuery, + encodeAuditCursor, parseAuditQuery, toAuditEntry, type AuditLogRow, @@ -64,7 +65,20 @@ auditExport.post("/export", requireScope("relayauth:audit:read"), async (c) => { return c.json({ error: parsed.error }, 400); } - const entries = await c.get("storage").audit.query(parsed.value, { includeOverflowRow: false }); + const result = await c.get("storage").audit.query(parsed.value, { includeOverflowRow: false }); + if (result.kind === "budget_exhausted") { + return c.json( + { + error: "audit_archive_query_budget_exceeded", + entries: result.entries, + nextCursor: encodeAuditCursor(result.continuation), + partial: true, + workBudget: result.workBudget, + }, + 409, + ); + } + const entries = result.entries; if (body.format === "json") { return c.json(entries, 200); diff --git a/packages/server/src/routes/audit-query.ts b/packages/server/src/routes/audit-query.ts index fb3bfcd..7be2930 100644 --- a/packages/server/src/routes/audit-query.ts +++ b/packages/server/src/routes/audit-query.ts @@ -2,6 +2,7 @@ import type { AuditAction, AuditEntry } from "@relayauth/types"; import { Hono } from "hono"; import type { AppEnv } from "../env.js"; +import type { AuditQueryCursor } from "../storage/interface.js"; import { requireScope } from "../middleware/scope.js"; export type ScopeContextVars = { @@ -39,10 +40,7 @@ export type AuditQueryParams = { result?: "allowed" | "denied"; from?: string; to?: string; - cursor?: { - timestamp: string; - id: string; - }; + cursor?: AuditQueryCursor; limit: number; }; @@ -87,10 +85,29 @@ auditQuery.get("/", requireScope("relayauth:audit:read"), async (c) => { return c.json({ error: parsed.error }, 400); } - const entries = await c.get("storage").audit.query(parsed.value); + const result = await c.get("storage").audit.query(parsed.value); + if (result.kind === "budget_exhausted") { + return c.json( + { + entries: result.entries, + nextCursor: encodeAuditCursor(result.continuation), + hasMore: true, + partial: true, + workBudget: result.workBudget, + }, + 200, + ); + } + + const entries = result.entries; const hasMore = entries.length > parsed.value.limit; const page = hasMore ? entries.slice(0, parsed.value.limit) : entries; - const nextCursor = hasMore ? encodeCursor(page[page.length - 1]?.timestamp, page[page.length - 1]?.id) : null; + const nextCursor = hasMore + ? encodeAuditCursor({ + timestamp: page[page.length - 1]?.timestamp ?? "", + id: page[page.length - 1]?.id ?? "", + }) + : null; return c.json( { @@ -137,10 +154,13 @@ export function parseAuditQuery( } const cursor = normalizeQueryValue(query.cursor); - const decodedCursor = cursor ? decodeCursor(cursor) : null; + const decodedCursor = cursor ? decodeAuditCursor(cursor) : null; if (cursor && !decodedCursor) { return { ok: false, error: "invalid cursor" }; } + if (decodedCursor?.kind === "archive_partition" && decodedCursor.orgId !== orgId) { + return { ok: false, error: "invalid cursor" }; + } const limit = parseLimit(query.limit, options.defaultLimit ?? 50, options.maxLimit ?? 200); if (limit === null) { @@ -206,7 +226,10 @@ export function buildAuditQuery( values.push(params.to); } - if (params.cursor) { + if (params.cursor?.kind === "archive_partition") { + clauses.push("timestamp < ?"); + values.push(params.cursor.timestamp); + } else if (params.cursor) { clauses.push("(timestamp < ? OR (timestamp = ? AND id < ?))"); values.push(params.cursor.timestamp, params.cursor.timestamp, params.cursor.id); } @@ -299,17 +322,34 @@ function isIsoTimestamp(value: string): boolean { return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value); } -function encodeCursor(timestamp: string | undefined, id: string | undefined): string | null { - if (!timestamp || !id) { +export function encodeAuditCursor(cursor: AuditQueryCursor | undefined): string | null { + if (!cursor?.timestamp) { return null; } - return toBase64Url(`${timestamp}|${id}`); + if (cursor.kind === "archive_partition") { + return toBase64Url(`archive_partition|${cursor.orgId}|${cursor.timestamp}`); + } + + if (!cursor.id) { + return null; + } + + return toBase64Url(`${cursor.timestamp}|${cursor.id}`); } -function decodeCursor(value: string): { timestamp: string; id: string } | null { +export function decodeAuditCursor(value: string): AuditQueryCursor | null { try { const decoded = fromBase64Url(value); + if (decoded.startsWith("archive_partition|")) { + const payload = decoded.slice("archive_partition|".length); + const separator = payload.lastIndexOf("|"); + const orgId = payload.slice(0, separator); + const timestamp = payload.slice(separator + 1); + return orgId && timestamp && isIsoTimestamp(timestamp) + ? { kind: "archive_partition", orgId, timestamp } + : null; + } const separator = decoded.lastIndexOf("|"); if (separator <= 0 || separator === decoded.length - 1) { return null; @@ -321,7 +361,7 @@ function decodeCursor(value: string): { timestamp: string; id: string } | null { return null; } - return { timestamp, id }; + return { kind: "entry", timestamp, id }; } catch { return null; } diff --git a/packages/server/src/routes/dashboard-stats.ts b/packages/server/src/routes/dashboard-stats.ts index 13b58b8..fce3ca3 100644 --- a/packages/server/src/routes/dashboard-stats.ts +++ b/packages/server/src/routes/dashboard-stats.ts @@ -2,6 +2,7 @@ import { Hono } from "hono"; import type { AppEnv } from "../env.js"; import { requireScope } from "../middleware/scope.js"; +import { decodeAuditCursor, encodeAuditCursor } from "./audit-query.js"; type ScopeContextVars = { identity?: { @@ -21,6 +22,15 @@ type DashboardStatsResponse = { from: string; to: string; }; + partial?: true; + nextCursor?: string; + hasMore?: true; + workBudget?: { + d1Pages: number; + d1Rows: number; + partitions: number; + r2Reads: number; + }; }; type DashboardAuditCountRow = { @@ -46,6 +56,11 @@ type DashboardIdentityCounts = Required(); @@ -54,7 +69,7 @@ dashboardStats.use("*", requireScope("relayauth:stats:read")); dashboardStats.get("/", async (c) => { const claims = (c as typeof c & { var: ScopeContextVars }).var.identity; - const parsedQuery = parseDashboardStatsQuery(c.req.query()); + const parsedQuery = parseDashboardStatsQuery(c.req.query(), claims?.org); if (!parsedQuery.ok) { return c.json({ error: parsedQuery.error }, 400); @@ -65,11 +80,12 @@ dashboardStats.get("/", async (c) => { } const storage = c.get("storage"); - const [auditCounts, identityCounts] = await Promise.all([ + const [auditResult, identityCounts] = await Promise.all([ storage.audit.getActionCounts(claims.org, parsedQuery.value), storage.identities.getStatusCounts(claims.org), ]); + const auditCounts = auditResult.counts; const response: DashboardStatsResponse = { tokensIssued: auditCounts.tokensIssued, tokensRevoked: auditCounts.tokensRevoked, @@ -86,6 +102,14 @@ dashboardStats.get("/", async (c) => { }, } : {}), + ...(auditResult.kind === "budget_exhausted" + ? { + partial: true as const, + nextCursor: encodeAuditCursor(auditResult.continuation) ?? "", + hasMore: true as const, + workBudget: auditResult.workBudget, + } + : {}), }; return c.json(response, 200); @@ -93,6 +117,7 @@ dashboardStats.get("/", async (c) => { function parseDashboardStatsQuery( query: Record, + authenticatedOrgId: string | undefined, ): { ok: true; value: DashboardStatsQuery } | { ok: false; error: string } { const from = normalizeQueryValue(query.from); if (from && !isIsoTimestamp(from)) { @@ -104,11 +129,21 @@ function parseDashboardStatsQuery( return { ok: false, error: "to must be an ISO 8601 timestamp" }; } + const cursorValue = normalizeQueryValue(query.cursor); + const decodedCursor = cursorValue ? decodeAuditCursor(cursorValue) : undefined; + if (cursorValue && (!decodedCursor || decodedCursor.kind !== "archive_partition")) { + return { ok: false, error: "invalid cursor" }; + } + if (decodedCursor?.kind === "archive_partition" && decodedCursor.orgId !== authenticatedOrgId) { + return { ok: false, error: "invalid cursor" }; + } + return { ok: true, value: { from, to, + cursor: decodedCursor, }, }; } diff --git a/packages/server/src/routes/identity-activity.ts b/packages/server/src/routes/identity-activity.ts index 0b5f1c0..b1633d0 100644 --- a/packages/server/src/routes/identity-activity.ts +++ b/packages/server/src/routes/identity-activity.ts @@ -6,6 +6,7 @@ import type { StoredIdentity } from "../storage/identity-types.js"; import type { AuthStorage } from "../storage/index.js"; import { buildAuditQuery, + encodeAuditCursor, parseAuditQuery, toAuditEntry, type AuditLogRow, @@ -79,7 +80,23 @@ identityActivity.get("/:id/activity", requireScope("relayauth:audit:read"), asyn return c.json({ error: parsed.error }, 400); } - const entries = await storage.audit.query(parsed.value); + const result = await storage.audit.query(parsed.value); + if (result.kind === "budget_exhausted") { + return c.json( + { + entries: result.entries, + nextCursor: encodeAuditCursor(result.continuation), + hasMore: true, + partial: true, + workBudget: result.workBudget, + sponsorChain: storedIdentity.identity.sponsorChain, + budgetUsage: summarizeBudgetUsage(storedIdentity.identity), + subAgents: await listSubAgentTree(storage, storedIdentity.identity.orgId, storedIdentity.identity.id), + }, + 200, + ); + } + const entries = result.entries; const hasMore = entries.length > parsed.value.limit; const page = hasMore ? entries.slice(0, parsed.value.limit) : entries; diff --git a/packages/server/src/storage/compat.ts b/packages/server/src/storage/compat.ts index a9e5c5a..da6ea10 100644 --- a/packages/server/src/storage/compat.ts +++ b/packages/server/src/storage/compat.ts @@ -127,11 +127,11 @@ function createD1AuditStorage(db: D1DatabaseLike): AuditStorage { } }, - async query(_query: AuditQueryInput, _options?: AuditQueryOptions): Promise { + async query(_query: AuditQueryInput, _options?: AuditQueryOptions) { throw new Error("D1 audit storage adapter does not support query()"); }, - async getActionCounts(_orgId: string, _query: DashboardAuditQuery): Promise { + async getActionCounts(_orgId: string, _query: DashboardAuditQuery) { throw new Error("D1 audit storage adapter does not support getActionCounts()"); }, diff --git a/packages/server/src/storage/interface.ts b/packages/server/src/storage/interface.ts index 067d612..d23a529 100644 --- a/packages/server/src/storage/interface.ts +++ b/packages/server/src/storage/interface.ts @@ -60,6 +60,32 @@ export type PolicyUpdate = Partial< Pick >; +export type AuditEntryCursor = { + /** Existing entry-position cursor. `kind` is omitted by legacy callers. */ + kind?: "entry"; + timestamp: string; + id: string; +}; + +/** + * A durable scan boundary between immutable archive partitions. It is not an + * audit entry id: resuming starts strictly before this minute. + */ +export type AuditArchivePartitionCursor = { + kind: "archive_partition"; + orgId: string; + timestamp: string; +}; + +export type AuditQueryCursor = AuditEntryCursor | AuditArchivePartitionCursor; + +export type AuditQueryWorkBudget = { + d1Pages: number; + d1Rows: number; + partitions: number; + r2Reads: number; +}; + export type AuditQueryInput = { orgId: string; identityId?: string; @@ -69,10 +95,7 @@ export type AuditQueryInput = { result?: "allowed" | "denied"; from?: string; to?: string; - cursor?: { - timestamp: string; - id: string; - }; + cursor?: AuditQueryCursor; limit: number; }; @@ -80,9 +103,22 @@ export type AuditQueryOptions = { includeOverflowRow?: boolean; }; +export type AuditQueryResult = + | { + kind: "complete"; + entries: AuditEntryRecord[]; + } + | { + kind: "budget_exhausted"; + entries: AuditEntryRecord[]; + continuation: AuditArchivePartitionCursor; + workBudget: AuditQueryWorkBudget; + }; + export type DashboardAuditQuery = { from?: string; to?: string; + cursor?: AuditArchivePartitionCursor; }; export type DashboardAuditCounts = { @@ -93,6 +129,18 @@ export type DashboardAuditCounts = { scopeDenials: number; }; +export type DashboardAuditCountsResult = + | { + kind: "complete"; + counts: DashboardAuditCounts; + } + | { + kind: "budget_exhausted"; + counts: DashboardAuditCounts; + continuation: AuditArchivePartitionCursor; + workBudget: AuditQueryWorkBudget; + }; + export type CreateAuditWebhookInput = { orgId: string; url: string; @@ -240,8 +288,8 @@ export interface PolicyStorage { export interface AuditStorage { write(entry: AuditLogWriteEntry): Promise; writeBatch(entries: AuditLogWriteEntry[]): Promise; - query(query: AuditQueryInput, options?: AuditQueryOptions): Promise; - getActionCounts(orgId: string, query: DashboardAuditQuery): Promise; + query(query: AuditQueryInput, options?: AuditQueryOptions): Promise; + getActionCounts(orgId: string, query: DashboardAuditQuery): Promise; writeIdentitySuspendedEvent(identity: StoredIdentity, reason: string, actorId: string): Promise; } diff --git a/packages/server/src/storage/sqlite.ts b/packages/server/src/storage/sqlite.ts index df8ddb7..4abf802 100644 --- a/packages/server/src/storage/sqlite.ts +++ b/packages/server/src/storage/sqlite.ts @@ -2078,51 +2078,63 @@ class SqliteAuditStorage implements AuditStorage { } } - async query(query: AuditQueryInput, options: AuditQueryOptions = {}): Promise { + async query(query: AuditQueryInput, options: AuditQueryOptions = {}) { const normalized = normalizeAuditQuery(query); const backend = await this.provider.getBackend(); const limitWithOverflow = normalized.limit + (options.includeOverflowRow ?? true ? 1 : 0); if (backend.kind === "memory") { - return backend.state.auditLogs - .filter((entry) => matchesAuditQuery(entry, normalized)) - .sort(compareAuditRecordDesc) - .slice(0, limitWithOverflow) - .map((entry) => cloneAuditEntryRecord(entry)); + return { + kind: "complete" as const, + entries: backend.state.auditLogs + .filter((entry) => matchesAuditQuery(entry, normalized)) + .sort(compareAuditRecordDesc) + .slice(0, limitWithOverflow) + .map((entry) => cloneAuditEntryRecord(entry)), + }; } const statement = buildAuditQuerySql(normalized, limitWithOverflow); - return backend.db - .prepare(statement.sql) - .all(...statement.params) - .map((row) => hydrateAuditEntryRecord(row)) - .filter((entry): entry is AuditEntryRecord => entry !== null); + return { + kind: "complete" as const, + entries: backend.db + .prepare(statement.sql) + .all(...statement.params) + .map((row) => hydrateAuditEntryRecord(row)) + .filter((entry): entry is AuditEntryRecord => entry !== null), + }; } - async getActionCounts(orgId: string, query: DashboardAuditQuery): Promise { + async getActionCounts(orgId: string, query: DashboardAuditQuery) { const normalizedOrgId = requireString(orgId, "orgId is required"); const from = normalizeOptionalString(query.from); const to = normalizeOptionalString(query.to); const backend = await this.provider.getBackend(); if (backend.kind === "memory") { - return summarizeAuditCounts( - backend.state.auditLogs.filter((entry) => - entry.orgId === normalizedOrgId - && (!from || entry.timestamp >= from) - && (!to || entry.timestamp < to), + return { + kind: "complete" as const, + counts: summarizeAuditCounts( + backend.state.auditLogs.filter((entry) => + entry.orgId === normalizedOrgId + && (!from || entry.timestamp >= from) + && (!to || entry.timestamp < to), + ), ), - ); + }; } const statement = buildAuditCountsSql(normalizedOrgId, { from, to }); const row = backend.db.prepare(statement.sql).get(...statement.params); return { - tokensIssued: normalizeNumber(row?.tokensIssued), - tokensRevoked: normalizeNumber(row?.tokensRevoked), - tokensRefreshed: normalizeNumber(row?.tokensRefreshed), - scopeChecks: normalizeNumber(row?.scopeChecks), - scopeDenials: normalizeNumber(row?.scopeDenials), + kind: "complete" as const, + counts: { + tokensIssued: normalizeNumber(row?.tokensIssued), + tokensRevoked: normalizeNumber(row?.tokensRevoked), + tokensRefreshed: normalizeNumber(row?.tokensRefreshed), + scopeChecks: normalizeNumber(row?.scopeChecks), + scopeDenials: normalizeNumber(row?.scopeDenials), + }, }; } @@ -3091,7 +3103,10 @@ function buildAuditQuerySql(query: AuditQueryInput, limit: number): { sql: strin clauses.push("(timestamp < ?)"); params.push(query.to); } - if (query.cursor) { + if (query.cursor?.kind === "archive_partition") { + clauses.push("timestamp < ?"); + params.push(query.cursor.timestamp); + } else if (query.cursor) { clauses.push("(timestamp < ? OR (timestamp = ? AND id < ?))"); params.push(query.cursor.timestamp, query.cursor.timestamp, query.cursor.id); } @@ -3206,7 +3221,11 @@ function matchesAuditQuery(entry: AuditEntryRecord, query: AuditQueryInput): boo if (query.to && entry.timestamp >= query.to) { return false; } - if (query.cursor) { + if (query.cursor?.kind === "archive_partition") { + if (entry.timestamp >= query.cursor.timestamp) { + return false; + } + } else if (query.cursor) { if (entry.timestamp > query.cursor.timestamp) { return false; } From 7a653415539a6a37c6757b0fb339bf97cc1c6043 Mon Sep 17 00:00:00 2001 From: Miya Date: Fri, 31 Jul 2026 01:05:10 +0200 Subject: [PATCH 06/20] fix: bind audit continuations to query scope (cherry picked from commit 3c0ef2bcda9df5afac32d17a60397112bf02aedd) --- .../src/__tests__/audit-query-api.test.ts | 35 ++++- .../src/__tests__/dashboard-stats-api.test.ts | 25 +++- packages/server/src/routes/audit-query.ts | 135 ++++++++++++++---- packages/server/src/routes/dashboard-stats.ts | 27 +++- packages/server/src/storage/interface.ts | 78 ++++++++++ packages/server/src/storage/sqlite.ts | 27 +++- 6 files changed, 291 insertions(+), 36 deletions(-) diff --git a/packages/server/src/__tests__/audit-query-api.test.ts b/packages/server/src/__tests__/audit-query-api.test.ts index f1fa2c4..53f67b5 100644 --- a/packages/server/src/__tests__/audit-query-api.test.ts +++ b/packages/server/src/__tests__/audit-query-api.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import type { AuditAction, AuditEntry } from "@relayauth/types"; +import { createAuditQueryContinuationFilterKey } from "../storage/interface.js"; import { assertJsonResponse, createTestApp, @@ -389,6 +390,11 @@ test("GET /v1/audit returns a typed archive budget continuation that resumes wit assert.equal(query.orgId, "org_archive"); if (query.cursor?.kind === "archive_partition") { assert.equal(query.cursor.timestamp, "2026-03-24T12:00:02.000Z"); + assert.equal(query.cursor.inclusive, true); + assert.deepEqual(query.cursor.chunk, { + key: "indexes/v1/org=org_archive/next.json", + sha256: "a".repeat(64), + }); return { kind: "complete", entries: [entries[2]!] }; } return { @@ -398,6 +404,12 @@ test("GET /v1/audit returns a typed archive budget continuation that resumes wit kind: "archive_partition", orgId: "org_archive", timestamp: "2026-03-24T12:00:02.000Z", + inclusive: true, + chunk: { + key: "indexes/v1/org=org_archive/next.json", + sha256: "a".repeat(64), + }, + filterKey: createAuditQueryContinuationFilterKey(query), }, workBudget: { d1Pages: 4, d1Rows: 129, partitions: 128, r2Reads: 128 }, }; @@ -409,7 +421,12 @@ test("GET /v1/audit returns a typed archive budget continuation that resumes wit })}`; const first = await app.request( - createTestRequest("GET", "/v1/audit?orgId=org_archive", undefined, { Authorization: token }), + createTestRequest( + "GET", + "/v1/audit?orgId=org_archive&identityId=agent_archive&action=token.validated&workspaceId=workspace_archive&plane=relayauth&result=allowed&from=2026-03-24T12%3A00%3A00.000Z&to=2026-03-24T13%3A00%3A00.000Z&limit=3", + undefined, + { Authorization: token }, + ), undefined, app.bindings, ); @@ -438,10 +455,24 @@ test("GET /v1/audit returns a typed archive budget continuation that resumes wit assert.equal(body.error, "invalid cursor"); }); + const mismatchedFilter = await app.request( + createTestRequest( + "GET", + `/v1/audit?orgId=org_archive&identityId=agent_other&action=token.validated&workspaceId=workspace_archive&plane=relayauth&result=allowed&from=2026-03-24T12%3A00%3A00.000Z&to=2026-03-24T13%3A00%3A00.000Z&limit=3&cursor=${encodeURIComponent(firstPage.nextCursor!)}`, + undefined, + { Authorization: token }, + ), + undefined, + app.bindings, + ); + await assertJsonResponse<{ error: string }>(mismatchedFilter, 400, (body) => { + assert.equal(body.error, "invalid cursor"); + }); + const second = await app.request( createTestRequest( "GET", - `/v1/audit?orgId=org_archive&cursor=${encodeURIComponent(firstPage.nextCursor!)}`, + `/v1/audit?orgId=org_archive&identityId=agent_archive&action=token.validated&workspaceId=workspace_archive&plane=relayauth&result=allowed&from=2026-03-24T12%3A00%3A00.000Z&to=2026-03-24T13%3A00%3A00.000Z&limit=3&cursor=${encodeURIComponent(firstPage.nextCursor!)}`, undefined, { Authorization: token }, ), diff --git a/packages/server/src/__tests__/dashboard-stats-api.test.ts b/packages/server/src/__tests__/dashboard-stats-api.test.ts index b837707..eb1b485 100644 --- a/packages/server/src/__tests__/dashboard-stats-api.test.ts +++ b/packages/server/src/__tests__/dashboard-stats-api.test.ts @@ -12,6 +12,7 @@ import { seedStoredIdentities, } from "./test-helpers.js"; import type { StoredIdentity } from "../storage/identity-types.js"; +import { createDashboardAuditContinuationFilterKey } from "../storage/interface.js"; type DashboardStatsResponse = { tokensIssued: number; @@ -605,6 +606,7 @@ test("GET /v1/stats exposes a typed, org-scoped bounded count continuation", asy kind: "archive_partition", orgId: "org_stats_continuation", timestamp: "2026-03-24T12:00:00.000Z", + filterKey: createDashboardAuditContinuationFilterKey(query), }, workBudget: { d1Pages: 1, d1Rows: 129, partitions: 128, r2Reads: 0 }, }; @@ -616,7 +618,12 @@ test("GET /v1/stats exposes a typed, org-scoped bounded count continuation", asy })}`; const first = await app.request( - createTestRequest("GET", "/v1/stats", undefined, { Authorization: token }), + createTestRequest( + "GET", + "/v1/stats?from=2026-03-24T00%3A00%3A00.000Z&to=2026-03-25T00%3A00%3A00.000Z", + undefined, + { Authorization: token }, + ), undefined, app.bindings, ); @@ -627,10 +634,24 @@ test("GET /v1/stats exposes a typed, org-scoped bounded count continuation", asy assert.equal(typeof firstBody.nextCursor, "string"); assert.deepEqual(firstBody.workBudget, { d1Pages: 1, d1Rows: 129, partitions: 128, r2Reads: 0 }); + const mismatchedRange = await app.request( + createTestRequest( + "GET", + `/v1/stats?from=2026-03-24T00%3A00%3A00.000Z&to=2026-03-26T00%3A00%3A00.000Z&cursor=${encodeURIComponent(firstBody.nextCursor!)}`, + undefined, + { Authorization: token }, + ), + undefined, + app.bindings, + ); + await assertJsonResponse<{ error: string }>(mismatchedRange, 400, (body) => { + assert.equal(body.error, "invalid cursor"); + }); + const second = await app.request( createTestRequest( "GET", - `/v1/stats?cursor=${encodeURIComponent(firstBody.nextCursor!)}`, + `/v1/stats?from=2026-03-24T00%3A00%3A00.000Z&to=2026-03-25T00%3A00%3A00.000Z&cursor=${encodeURIComponent(firstBody.nextCursor!)}`, undefined, { Authorization: token }, ), diff --git a/packages/server/src/routes/audit-query.ts b/packages/server/src/routes/audit-query.ts index 7be2930..6f01243 100644 --- a/packages/server/src/routes/audit-query.ts +++ b/packages/server/src/routes/audit-query.ts @@ -2,7 +2,10 @@ import type { AuditAction, AuditEntry } from "@relayauth/types"; import { Hono } from "hono"; import type { AppEnv } from "../env.js"; -import type { AuditQueryCursor } from "../storage/interface.js"; +import { + createAuditQueryContinuationFilterKey, + type AuditQueryCursor, +} from "../storage/interface.js"; import { requireScope } from "../middleware/scope.js"; export type ScopeContextVars = { @@ -158,29 +161,35 @@ export function parseAuditQuery( if (cursor && !decodedCursor) { return { ok: false, error: "invalid cursor" }; } - if (decodedCursor?.kind === "archive_partition" && decodedCursor.orgId !== orgId) { - return { ok: false, error: "invalid cursor" }; - } - const limit = parseLimit(query.limit, options.defaultLimit ?? 50, options.maxLimit ?? 200); if (limit === null) { return { ok: false, error: "limit must be a positive integer" }; } + const value: AuditQueryParams = { + orgId, + identityId: normalizeQueryValue(query.identityId), + action: action as AuditAction | undefined, + workspaceId: normalizeQueryValue(query.workspaceId), + plane: normalizeQueryValue(query.plane), + result: result as "allowed" | "denied" | undefined, + from, + to, + cursor: decodedCursor ?? undefined, + limit, + }; + + if ( + decodedCursor?.kind === "archive_partition" && + (decodedCursor.orgId !== orgId || + decodedCursor.filterKey !== createAuditQueryContinuationFilterKey(value)) + ) { + return { ok: false, error: "invalid cursor" }; + } + return { ok: true, - value: { - orgId, - identityId: normalizeQueryValue(query.identityId), - action: action as AuditAction | undefined, - workspaceId: normalizeQueryValue(query.workspaceId), - plane: normalizeQueryValue(query.plane), - result: result as "allowed" | "denied" | undefined, - from, - to, - cursor: decodedCursor ?? undefined, - limit, - }, + value, }; } @@ -228,7 +237,19 @@ export function buildAuditQuery( if (params.cursor?.kind === "archive_partition") { clauses.push("timestamp < ?"); - values.push(params.cursor.timestamp); + values.push( + params.cursor.inclusive && !params.cursor.chunk + ? new Date(new Date(params.cursor.timestamp).getTime() + 60_000).toISOString() + : params.cursor.timestamp, + ); + if (params.cursor.entryCursor) { + clauses.push("(timestamp < ? OR (timestamp = ? AND id < ?))"); + values.push( + params.cursor.entryCursor.timestamp, + params.cursor.entryCursor.timestamp, + params.cursor.entryCursor.id, + ); + } } else if (params.cursor) { clauses.push("(timestamp < ? OR (timestamp = ? AND id < ?))"); values.push(params.cursor.timestamp, params.cursor.timestamp, params.cursor.id); @@ -328,7 +349,18 @@ export function encodeAuditCursor(cursor: AuditQueryCursor | undefined): string } if (cursor.kind === "archive_partition") { - return toBase64Url(`archive_partition|${cursor.orgId}|${cursor.timestamp}`); + return toBase64Url( + JSON.stringify({ + version: 1, + kind: cursor.kind, + orgId: cursor.orgId, + timestamp: cursor.timestamp, + inclusive: cursor.inclusive === true, + ...(cursor.chunk ? { chunk: cursor.chunk } : {}), + ...(cursor.entryCursor ? { entryCursor: cursor.entryCursor } : {}), + filterKey: cursor.filterKey, + }), + ); } if (!cursor.id) { @@ -341,14 +373,9 @@ export function encodeAuditCursor(cursor: AuditQueryCursor | undefined): string export function decodeAuditCursor(value: string): AuditQueryCursor | null { try { const decoded = fromBase64Url(value); - if (decoded.startsWith("archive_partition|")) { - const payload = decoded.slice("archive_partition|".length); - const separator = payload.lastIndexOf("|"); - const orgId = payload.slice(0, separator); - const timestamp = payload.slice(separator + 1); - return orgId && timestamp && isIsoTimestamp(timestamp) - ? { kind: "archive_partition", orgId, timestamp } - : null; + const archiveCursor = parseArchiveCursor(decoded); + if (archiveCursor) { + return archiveCursor; } const separator = decoded.lastIndexOf("|"); if (separator <= 0 || separator === decoded.length - 1) { @@ -367,6 +394,60 @@ export function decodeAuditCursor(value: string): AuditQueryCursor | null { } } +function parseArchiveCursor(value: string): Extract | null { + try { + const parsed: unknown = JSON.parse(value); + if (!isRecord(parsed)) { + return null; + } + const { version, kind, orgId, timestamp, inclusive, chunk, entryCursor, filterKey } = parsed; + return version === 1 && + kind === "archive_partition" && + typeof orgId === "string" && + orgId.length > 0 && + typeof timestamp === "string" && + isIsoTimestamp(timestamp) && + (inclusive === undefined || typeof inclusive === "boolean") && + (chunk === undefined || + (isRecord(chunk) && + typeof chunk.key === "string" && + chunk.key.length > 0 && + typeof chunk.sha256 === "string" && + chunk.sha256.length > 0)) && + (entryCursor === undefined || + (isRecord(entryCursor) && + typeof entryCursor.timestamp === "string" && + isIsoTimestamp(entryCursor.timestamp) && + typeof entryCursor.id === "string" && + entryCursor.id.length > 0)) && + typeof filterKey === "string" && + filterKey.length > 0 + ? { + kind, + orgId, + timestamp, + ...(inclusive === true ? { inclusive } : {}), + ...(chunk ? { chunk: { key: chunk.key as string, sha256: chunk.sha256 as string } } : {}), + ...(entryCursor + ? { + entryCursor: { + timestamp: entryCursor.timestamp as string, + id: entryCursor.id as string, + }, + } + : {}), + filterKey, + } + : null; + } catch { + return null; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + function toBase64Url(value: string): string { return btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); } diff --git a/packages/server/src/routes/dashboard-stats.ts b/packages/server/src/routes/dashboard-stats.ts index fce3ca3..0b670db 100644 --- a/packages/server/src/routes/dashboard-stats.ts +++ b/packages/server/src/routes/dashboard-stats.ts @@ -3,6 +3,7 @@ import { Hono } from "hono"; import type { AppEnv } from "../env.js"; import { requireScope } from "../middleware/scope.js"; import { decodeAuditCursor, encodeAuditCursor } from "./audit-query.js"; +import { createDashboardAuditContinuationFilterKey } from "../storage/interface.js"; type ScopeContextVars = { identity?: { @@ -60,6 +61,16 @@ type DashboardStatsQuery = { kind: "archive_partition"; orgId: string; timestamp: string; + inclusive?: boolean; + chunk?: { + key: string; + sha256: string; + }; + entryCursor?: { + timestamp: string; + id: string; + }; + filterKey: string; }; }; @@ -102,12 +113,12 @@ dashboardStats.get("/", async (c) => { }, } : {}), + ...(auditResult.workBudget ? { workBudget: auditResult.workBudget } : {}), ...(auditResult.kind === "budget_exhausted" ? { partial: true as const, nextCursor: encodeAuditCursor(auditResult.continuation) ?? "", hasMore: true as const, - workBudget: auditResult.workBudget, } : {}), }; @@ -131,10 +142,20 @@ function parseDashboardStatsQuery( const cursorValue = normalizeQueryValue(query.cursor); const decodedCursor = cursorValue ? decodeAuditCursor(cursorValue) : undefined; - if (cursorValue && (!decodedCursor || decodedCursor.kind !== "archive_partition")) { + if ( + cursorValue && + (!decodedCursor || + decodedCursor.kind !== "archive_partition" || + decodedCursor.entryCursor) + ) { return { ok: false, error: "invalid cursor" }; } - if (decodedCursor?.kind === "archive_partition" && decodedCursor.orgId !== authenticatedOrgId) { + if ( + decodedCursor?.kind === "archive_partition" && + (decodedCursor.orgId !== authenticatedOrgId || + decodedCursor.filterKey !== + createDashboardAuditContinuationFilterKey({ from, to })) + ) { return { ok: false, error: "invalid cursor" }; } diff --git a/packages/server/src/storage/interface.ts b/packages/server/src/storage/interface.ts index d23a529..a871863 100644 --- a/packages/server/src/storage/interface.ts +++ b/packages/server/src/storage/interface.ts @@ -75,6 +75,29 @@ export type AuditArchivePartitionCursor = { kind: "archive_partition"; orgId: string; timestamp: string; + /** + * Resume at (rather than strictly before) this partition. This is used only + * when a fixed budget is exhausted before any chunk of the partition is + * read, so the next request cannot skip that unread partition. + */ + inclusive?: boolean; + /** + * The next unread immutable index chunk for a partition that is larger than + * one request's fixed R2 budget. It is a resumable position, never an + * emitted chunk, so a continuation cannot duplicate prior entries. + */ + chunk?: { + key: string; + sha256: string; + }; + /** Original entry page boundary, retained when budget continuation starts mid-page. */ + entryCursor?: AuditEntryCursor; + /** + * Opaque canonical query scope. Archive cursors may only be replayed by the + * exact org-scoped query that produced them; this prevents a continuation + * from being reused with broader/narrower filters or a different ordering. + */ + filterKey: string; }; export type AuditQueryCursor = AuditEntryCursor | AuditArchivePartitionCursor; @@ -121,6 +144,59 @@ export type DashboardAuditQuery = { cursor?: AuditArchivePartitionCursor; }; +/** + * The archive is ordered timestamp DESC, id DESC. Keep this canonical key in + * the storage contract so every backend produces continuations accepted by + * the HTTP boundary without copying filter semantics. + */ +export function createAuditQueryContinuationFilterKey( + query: Pick< + AuditQueryInput, + | "identityId" + | "action" + | "workspaceId" + | "plane" + | "result" + | "from" + | "to" + | "limit" + | "cursor" + >, +): string { + const entryCursor = query.cursor?.kind === "archive_partition" + ? query.cursor.entryCursor + : query.cursor; + return JSON.stringify({ + version: 1, + resource: "audit", + order: "timestamp_desc_id_desc", + identityId: query.identityId ?? null, + action: query.action ?? null, + workspaceId: query.workspaceId ?? null, + plane: query.plane ?? null, + result: query.result ?? null, + from: query.from ?? null, + to: query.to ?? null, + limit: query.limit, + entryCursor: entryCursor + ? { timestamp: entryCursor.timestamp, id: entryCursor.id } + : null, + }); +} + +/** Dashboard supports only the documented exact from/to range tuple. */ +export function createDashboardAuditContinuationFilterKey( + query: Pick, +): string { + return JSON.stringify({ + version: 1, + resource: "dashboard-audit-counts", + order: "partition_minute_desc", + from: query.from ?? null, + to: query.to ?? null, + }); +} + export type DashboardAuditCounts = { tokensIssued: number; tokensRevoked: number; @@ -133,6 +209,8 @@ export type DashboardAuditCountsResult = | { kind: "complete"; counts: DashboardAuditCounts; + /** Archive scan counters are returned when a backend performed one. */ + workBudget?: AuditQueryWorkBudget; } | { kind: "budget_exhausted"; diff --git a/packages/server/src/storage/sqlite.ts b/packages/server/src/storage/sqlite.ts index 4abf802..b28f809 100644 --- a/packages/server/src/storage/sqlite.ts +++ b/packages/server/src/storage/sqlite.ts @@ -3105,7 +3105,19 @@ function buildAuditQuerySql(query: AuditQueryInput, limit: number): { sql: strin } if (query.cursor?.kind === "archive_partition") { clauses.push("timestamp < ?"); - params.push(query.cursor.timestamp); + params.push( + query.cursor.inclusive && !query.cursor.chunk + ? new Date(new Date(query.cursor.timestamp).getTime() + 60_000).toISOString() + : query.cursor.timestamp, + ); + if (query.cursor.entryCursor) { + clauses.push("(timestamp < ? OR (timestamp = ? AND id < ?))"); + params.push( + query.cursor.entryCursor.timestamp, + query.cursor.entryCursor.timestamp, + query.cursor.entryCursor.id, + ); + } } else if (query.cursor) { clauses.push("(timestamp < ? OR (timestamp = ? AND id < ?))"); params.push(query.cursor.timestamp, query.cursor.timestamp, query.cursor.id); @@ -3222,7 +3234,18 @@ function matchesAuditQuery(entry: AuditEntryRecord, query: AuditQueryInput): boo return false; } if (query.cursor?.kind === "archive_partition") { - if (entry.timestamp >= query.cursor.timestamp) { + const upper = query.cursor.inclusive && !query.cursor.chunk + ? new Date(new Date(query.cursor.timestamp).getTime() + 60_000).toISOString() + : query.cursor.timestamp; + if (entry.timestamp >= upper) { + return false; + } + if ( + query.cursor.entryCursor && + (entry.timestamp > query.cursor.entryCursor.timestamp || + (entry.timestamp === query.cursor.entryCursor.timestamp && + entry.id >= query.cursor.entryCursor.id)) + ) { return false; } } else if (query.cursor) { From 7d3774c60def011a87dc1e2e7288c4ff2532edc0 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Fri, 31 Jul 2026 01:15:14 +0200 Subject: [PATCH 07/20] test(server): cover repeated refresh reuse cascade (cherry picked from commit 503e858a59612a42f940a4ad79aa3e89c2679d20) --- .../server/src/__tests__/tokens-route.test.ts | 68 ++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/packages/server/src/__tests__/tokens-route.test.ts b/packages/server/src/__tests__/tokens-route.test.ts index 518fd6a..aeac08b 100644 --- a/packages/server/src/__tests__/tokens-route.test.ts +++ b/packages/server/src/__tests__/tokens-route.test.ts @@ -1505,12 +1505,31 @@ test("POST /v1/tokens/refresh", async (t) => { const { app, identity } = await createHarness(); const { pair, accessClaims, refreshClaims } = createRs256TokenPair(identity); await seedActiveTokens(app, identity.id, [accessClaims.jti, refreshClaims.jti]); + const atomicRevocations = + app.storage.revocations.revokeIdentityTokensWithAudit.bind( + app.storage.revocations, + ); + const atomicCascadeCalls: Parameters[0][] = []; + app.storage.revocations.revokeIdentityTokensWithAudit = async (input) => { + atomicCascadeCalls.push(input); + await atomicRevocations(input); + }; + app.storage.revocations.revokeIdentityTokens = async () => { + throw new Error("refresh-reuse cascade must use revokeIdentityTokensWithAudit"); + }; const firstResponse = await requestRoute(app, "POST", "/v1/tokens/refresh", { body: { refreshToken: pair.refreshToken }, }); const firstBody = await assertJsonResponse(firstResponse, 200); - const secondRefreshClaims = decodeJwtJsonSegment(firstBody.refreshToken, 1); + const secondAccessClaims = decodeJwtJsonSegment( + firstBody.accessToken, + 1, + ); + const secondRefreshClaims = decodeJwtJsonSegment( + firstBody.refreshToken, + 1, + ); // Replay the original refresh token (single-use violation). const replayResponse = await requestRoute(app, "POST", "/v1/tokens/refresh", { @@ -1519,6 +1538,32 @@ test("POST /v1/tokens/refresh", async (t) => { await assertJsonResponse(replayResponse, 401, (body) => { assert.match(JSON.stringify(body), /revoked/i); }); + assert.equal(atomicCascadeCalls.length, 1); + assert.deepEqual( + [...atomicCascadeCalls[0]!.tokenIds].sort(), + [ + refreshClaims.jti, + secondAccessClaims.jti, + secondRefreshClaims.jti, + ].sort(), + ); + assert.equal(atomicCascadeCalls[0]!.identityId, identity.id); + assert.match(atomicCascadeCalls[0]!.revokedAt, /^\d{4}-\d{2}-\d{2}T/); + assert.deepEqual(atomicCascadeCalls[0]!.auditEntry, { + id: atomicCascadeCalls[0]!.auditEntry.id, + action: "token.revoked", + identityId: identity.id, + orgId: identity.orgId, + workspaceId: identity.workspaceId, + plane: "relayauth", + resource: "tokens", + result: "allowed", + metadata: { + tokenId: refreshClaims.jti, + actorId: "refresh_reuse_detected", + }, + timestamp: atomicCascadeCalls[0]!.auditEntry.timestamp, + }); // The newly issued refresh token should ALSO be unusable now because the // session was cascade-revoked. @@ -1526,6 +1571,27 @@ test("POST /v1/tokens/refresh", async (t) => { body: { refreshToken: firstBody.refreshToken }, }); await assertJsonResponse(followupResponse, 401); + assert.equal(atomicCascadeCalls.length, 2); + assert.deepEqual(atomicCascadeCalls[1]!.tokenIds, [ + secondRefreshClaims.jti, + ]); + assert.equal(atomicCascadeCalls[1]!.identityId, identity.id); + assert.match(atomicCascadeCalls[1]!.revokedAt, /^\d{4}-\d{2}-\d{2}T/); + assert.deepEqual(atomicCascadeCalls[1]!.auditEntry, { + id: atomicCascadeCalls[1]!.auditEntry.id, + action: "token.revoked", + identityId: identity.id, + orgId: identity.orgId, + workspaceId: identity.workspaceId, + plane: "relayauth", + resource: "tokens", + result: "allowed", + metadata: { + tokenId: secondRefreshClaims.jti, + actorId: "refresh_reuse_detected", + }, + timestamp: atomicCascadeCalls[1]!.auditEntry.timestamp, + }); const revoked = await listRevokedTokenIds(app); assert.ok(revoked.includes(refreshClaims.jti), "original refresh JTI must be revoked"); From 977ebd76eecf7d30d1998dad59e9a46e0c965601 Mon Sep 17 00:00:00 2001 From: Miya Date: Fri, 31 Jul 2026 00:16:17 +0200 Subject: [PATCH 08/20] fix(server): make revoke audit atomic (cherry picked from commit 46710e4c82dba67a4da59cfa920129ca8fbc72e1) --- .../src/__tests__/sqlite-storage.test.ts | 45 +++++++++++ .../server/src/__tests__/tokens-route.test.ts | 11 +++ packages/server/src/routes/tokens.ts | 77 +++++++++++-------- packages/server/src/storage/interface.ts | 23 ++++++ packages/server/src/storage/sqlite.ts | 60 +++++++++++++++ 5 files changed, 182 insertions(+), 34 deletions(-) diff --git a/packages/server/src/__tests__/sqlite-storage.test.ts b/packages/server/src/__tests__/sqlite-storage.test.ts index e1647eb..6bf03cb 100644 --- a/packages/server/src/__tests__/sqlite-storage.test.ts +++ b/packages/server/src/__tests__/sqlite-storage.test.ts @@ -154,6 +154,51 @@ test("sqlite token storage owns issued-token persistence and hot-path lookups", } }); +test("sqlite revoke with audit rolls all durable state back when the audit insert fails", async () => { + const { storage, cleanup } = createHarness(); + + try { + await storage.tokens.persistIssued({ + id: "tok_revoke_atomic", + tokenId: "tok_revoke_atomic", + jti: "tok_revoke_atomic", + identityId: "agent_revoke_atomic", + sessionId: "sess_revoke_atomic", + issuedAt: 1_774_608_000, + expiresAt: 1_800_000_000, + createdAt: "2026-03-27T12:00:00.000Z", + }); + const conflictingAudit = createAuditEntry({ + id: "aud_revoke_conflict", + action: "token.revoked", + identityId: "agent_revoke_atomic", + }); + await storage.audit.write(conflictingAudit); + + await assert.rejects( + () => storage.revocations.revokeIdentityTokensWithAudit({ + identityId: "agent_revoke_atomic", + tokenIds: ["tok_revoke_atomic"], + revokedAt: "2026-03-27T12:01:00.000Z", + auditEntry: conflictingAudit, + }), + ); + + assert.equal((await storage.tokens.getById("tok_revoke_atomic"))?.status, "active"); + assert.equal(await storage.revocations.isRevoked("tok_revoke_atomic"), false); + assert.deepEqual( + (await storage.audit.query({ + orgId: "org_test", + action: "token.revoked", + limit: 10, + }, { includeOverflowRow: false })).map((entry) => entry.id), + ["aud_revoke_conflict"], + ); + } finally { + cleanup(); + } +}); + test("sqlite token pair and audit entry commit atomically and retries do not duplicate", async () => { const { storage, cleanup } = createHarness(); const pair = { diff --git a/packages/server/src/__tests__/tokens-route.test.ts b/packages/server/src/__tests__/tokens-route.test.ts index aeac08b..c685724 100644 --- a/packages/server/src/__tests__/tokens-route.test.ts +++ b/packages/server/src/__tests__/tokens-route.test.ts @@ -1599,6 +1599,7 @@ test("POST /v1/tokens/refresh", async (t) => { revoked.includes(secondRefreshClaims.jti), `second refresh JTI ${secondRefreshClaims.jti} must be revoked after re-use detection (got ${JSON.stringify(revoked)})`, ); + assert.equal(atomicCascadeCalls, 1); }); await t.test("rejects a refresh token signed with the wrong issuer", async () => { @@ -1931,6 +1932,15 @@ test("POST /v1/tokens/revoke", async (t) => { const { app, identity, authHeaders } = await createHarness(); const { accessClaims } = createRs256TokenPair(identity); await seedActiveTokens(app, identity.id, [accessClaims.jti]); + const atomicRevocations = app.storage.revocations.revokeIdentityTokensWithAudit.bind(app.storage.revocations); + let atomicRevokeCalls = 0; + app.storage.revocations.revokeIdentityTokensWithAudit = async (input) => { + atomicRevokeCalls += 1; + await atomicRevocations(input); + }; + app.storage.revocations.revokeIdentityTokens = async () => { + throw new Error("public revoke must use revokeIdentityTokensWithAudit"); + }; const response = await requestRoute(app, "POST", "/v1/tokens/revoke", { body: { @@ -1941,6 +1951,7 @@ test("POST /v1/tokens/revoke", async (t) => { assert.equal(response.status, 204); assert.deepEqual(await listRevokedTokenIds(app), [accessClaims.jti]); + assert.equal(atomicRevokeCalls, 1); }); await t.test("returns 401 when Authorization is missing", async () => { diff --git a/packages/server/src/routes/tokens.ts b/packages/server/src/routes/tokens.ts index 14d2e65..fda0b07 100644 --- a/packages/server/src/routes/tokens.ts +++ b/packages/server/src/routes/tokens.ts @@ -634,16 +634,13 @@ tokens.post("/refresh", async (c) => { }, }); - // The durable storage transaction above is the source of truth. Hosted - // adapters may also maintain a low-latency revocation cache; a cache write - // cannot roll back or split the already-atomic token rotation. - if (typeof storage.revocations.revoke === "function") { - try { - await storage.revocations.revoke(presentedJti, verification.claims.exp); - } catch (error) { - console.error("Failed to refresh revocation cache after atomic rotation", error); - } - } + await populateRevocationCache( + storage, + identity.id, + [presentedJti], + new Date().toISOString(), + verification.claims.exp, + ); return c.json(tokenPair, 200); }); @@ -691,14 +688,19 @@ tokens.post("/revoke", async (c) => { const revocableIds = targetTokens .map((row) => getTokenIdentifier(row)) .filter((value): value is string => Boolean(value)); - await storage.revocations.revokeIdentityTokens(identity.id, revocableIds, revokedAt); - - await writeTokenAudit(storage, { + const auditEntry = createTokenAuditEntry({ action: "token.revoked", identity, tokenId: revocableIds[0] ?? tokenId ?? sessionId ?? identityId ?? identity.id, actorId: auth.claims.sub, }); + await storage.revocations.revokeIdentityTokensWithAudit({ + identityId: identity.id, + tokenIds: revocableIds, + revokedAt, + auditEntry, + }); + await populateRevocationCache(storage, identity.id, revocableIds, revokedAt); return c.body(null, 204); }); @@ -938,18 +940,6 @@ function toIssuedTokenRecord( }; } -async function writeTokenAudit( - storage: AuthStorage, - options: { - action: "token.issued" | "token.refreshed" | "token.revoked"; - identity: StoredIdentity; - tokenId: string; - actorId?: string; - }, -): Promise { - await storage.audit.write(createTokenAuditEntry(options)); -} - function createTokenAuditEntry( options: { action: "token.issued" | "token.refreshed" | "token.revoked"; @@ -1074,20 +1064,39 @@ async function cascadeRevokeSession( } const tokenIds = [...jtis]; - await storage.revocations.revokeIdentityTokens(identity.id, tokenIds, revokedAt); - if (typeof storage.revocations.revoke === "function") { - const farFuture = Math.floor(Date.now() / 1000) + (365 * 24 * 3600); - for (const tokenId of tokenIds) { - await storage.revocations.revoke(tokenId, farFuture); - } - } - - await writeTokenAudit(storage, { + const auditEntry = createTokenAuditEntry({ action: "token.revoked", identity, tokenId: normalizedPresentedJti ?? tokenIds[0] ?? identity.id, actorId: "refresh_reuse_detected", }); + await storage.revocations.revokeIdentityTokensWithAudit({ + identityId: identity.id, + tokenIds, + revokedAt, + auditEntry, + }); + await populateRevocationCache(storage, identity.id, tokenIds, revokedAt); +} + +async function populateRevocationCache( + storage: AuthStorage, + identityId: string, + tokenIds: string[], + revokedAt: string, + expiresAt?: number, +): Promise { + if (typeof storage.revocations.cacheRevokedTokens !== "function") { + return; + } + + try { + await storage.revocations.cacheRevokedTokens(identityId, tokenIds, revokedAt, expiresAt); + } catch (error) { + // Revocation and audit are already durable. A cache outage must never + // turn a successful revoke into a partial-failure response. + console.error("Failed to populate revocation cache after durable revoke", error); + } } async function verifyToken( diff --git a/packages/server/src/storage/interface.ts b/packages/server/src/storage/interface.ts index a871863..7cc3578 100644 --- a/packages/server/src/storage/interface.ts +++ b/packages/server/src/storage/interface.ts @@ -313,6 +313,18 @@ export type IssuedTokenRotationAudit = { revokedAuditEntry: AuditLogWriteEntry; }; +/** + * Durable revoke boundary. Implementations must update the affected token + * rows, record the revoked JTIs, and persist the audit entry as one + * transaction. A failed audit write must leave every token active. + */ +export type RevokedTokenAudit = { + identityId: string; + tokenIds: string[]; + revokedAt: string; + auditEntry: AuditLogWriteEntry; +}; + export interface IdentityStorage { list(orgId: string, options?: ListIdentitiesOptions): Promise; get(id: string): Promise; @@ -342,6 +354,17 @@ export interface TokenStorage { export interface RevocationStorage { revokeIdentityTokens(identityId: string, tokenIds: string[], revokedAt: string): Promise; + revokeIdentityTokensWithAudit(input: RevokedTokenAudit): Promise; + /** + * Optionally populate a low-latency cache after the durable transaction + * commits. Cache failure must not change the durable revoke result. + */ + cacheRevokedTokens?( + identityId: string, + tokenIds: string[], + revokedAt: string, + expiresAt?: number, + ): Promise; isRevoked?(tokenId: string): Promise; revoke?(tokenId: string, expiresAt: number): Promise; } diff --git a/packages/server/src/storage/sqlite.ts b/packages/server/src/storage/sqlite.ts index b28f809..53e7658 100644 --- a/packages/server/src/storage/sqlite.ts +++ b/packages/server/src/storage/sqlite.ts @@ -51,6 +51,7 @@ import type { OrganizationContextRecord, PolicyStorage, PolicyUpdate, + RevokedTokenAudit, RevocationStorage, RoleStorage, StoredTokenRecord, @@ -1656,6 +1657,65 @@ class SqliteRevocationStorage implements RevocationStorage { } } + async revokeIdentityTokensWithAudit(input: RevokedTokenAudit): Promise { + const normalizedIdentityId = requireString(input.identityId, "identityId is required"); + const normalizedTokenIds = normalizeStringArray(input.tokenIds); + if (normalizedTokenIds.length === 0) { + return; + } + + const timestamp = normalizeTimestamp(input.revokedAt); + const auditEntry = normalizeAuditWriteEntry(input.auditEntry); + const backend = await this.provider.getBackend(); + pruneExpiredRevocations(backend); + + if (backend.kind === "memory") { + // Check every failure condition before changing memory state, preserving + // the same all-or-nothing contract as the SQLite transaction below. + if (backend.state.auditLogs.some((entry) => entry.id === auditEntry.id)) { + throw new StorageError("audit_entry_already_exists", 409, "audit_entry_already_exists"); + } + + for (const tokenId of normalizedTokenIds) { + backend.state.revokedTokens.set(tokenId, { + expiresAt: MAX_REVOCATION_EXPIRY, + identityId: normalizedIdentityId, + revokedAt: timestamp, + }); + for (const token of backend.state.tokens.values()) { + if ( + token.identityId === normalizedIdentityId + && (token.id === tokenId || token.jti === tokenId || token.tokenId === tokenId) + ) { + token.status = "revoked"; + } + } + } + backend.state.auditLogs.push(cloneAuditEntryRecord(auditEntry)); + backend.state.auditLogs.sort(compareAuditRecordDesc); + return; + } + + backend.db.exec("BEGIN IMMEDIATE"); + try { + const upsertRevokedToken = backend.db.prepare(UPSERT_REVOKED_TOKEN_SQL); + const updateTokenStatus = backend.db.prepare(UPDATE_TOKEN_STATUS_SQL); + for (const tokenId of normalizedTokenIds) { + upsertRevokedToken.run(tokenId, MAX_REVOCATION_EXPIRY); + updateTokenStatus.run(normalizedIdentityId, tokenId, tokenId, tokenId); + } + backend.db.prepare(INSERT_AUDIT_LOG_SQL).run(...toAuditParams(auditEntry)); + backend.db.exec("COMMIT"); + } catch (error) { + try { + backend.db.exec("ROLLBACK"); + } catch { + // Preserve the originating audit or storage failure. + } + throw error; + } + } + async isRevoked(tokenId: string): Promise { const normalizedTokenId = normalizeOptionalString(tokenId); if (!normalizedTokenId) { From 5d70d2bfb4472b889cd85f6b4c94837fc35dabc7 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Fri, 31 Jul 2026 01:23:23 +0200 Subject: [PATCH 09/20] fix(server): repair composed audit test contracts --- .../src/__tests__/client-audit.test.ts | 43 +- packages/sdk/typescript/src/client.ts | 154 +- packages/sdk/typescript/src/index.ts | 11 +- .../server/src/__tests__/audit-logger.test.ts | 255 +- .../src/__tests__/audit-query-api.test.ts | 181 +- .../src/__tests__/dashboard-stats-api.test.ts | 247 +- .../server/src/__tests__/e2e/audit.test.ts | 675 +-- .../server/src/__tests__/e2e/rbac.test.ts | 972 +++-- .../src/__tests__/sqlite-storage.test.ts | 374 +- .../src/__tests__/storage-sqlite.test.ts | 51 +- .../server/src/__tests__/tokens-route.test.ts | 3747 ++++++++++------- packages/server/src/routes/audit-export.ts | 6 +- packages/server/src/routes/audit-query.ts | 70 +- packages/server/src/routes/dashboard-stats.ts | 38 +- .../server/src/routes/identity-activity.ts | 150 +- packages/server/src/routes/tokens.ts | 668 ++- packages/server/src/storage/compat.ts | 48 +- packages/server/src/storage/interface.ts | 44 +- packages/server/src/storage/sqlite.ts | 1140 +++-- 19 files changed, 5762 insertions(+), 3112 deletions(-) diff --git a/packages/sdk/typescript/src/__tests__/client-audit.test.ts b/packages/sdk/typescript/src/__tests__/client-audit.test.ts index 2e4aedf..03d6743 100644 --- a/packages/sdk/typescript/src/__tests__/client-audit.test.ts +++ b/packages/sdk/typescript/src/__tests__/client-audit.test.ts @@ -6,7 +6,9 @@ import { RelayAuthClient } from "../client.js"; type IdentityActivityOptions = Omit; type AuditClient = RelayAuthClient & { - queryAudit(query: AuditQuery): Promise<{ entries: AuditEntry[]; cursor?: string }>; + queryAudit( + query: AuditQuery, + ): Promise<{ entries: AuditEntry[]; cursor?: string }>; getIdentityActivity( identityId: string, options?: IdentityActivityOptions, @@ -66,7 +68,11 @@ function jsonResponse(body: unknown, status = 200): Response { }); } -function textResponse(body: string, status = 200, contentType = "text/plain"): Response { +function textResponse( + body: string, + status = 200, + contentType = "text/plain", +): Response { return new Response(body, { status, headers: { @@ -75,7 +81,12 @@ function textResponse(body: string, status = 200, contentType = "text/plain"): R }); } -function mockFetch(responder: (input: RequestInfo | URL, init?: RequestInit) => Response | Promise) { +function mockFetch( + responder: ( + input: RequestInfo | URL, + init?: RequestInit, + ) => Response | Promise, +) { const calls: FetchCall[] = []; const originalFetch = globalThis.fetch; @@ -105,8 +116,12 @@ async function inspectCall(call: FetchCall): Promise<{ ? new URL(call.input.toString()) : new URL(call.input.url); - const method = call.init?.method ?? (call.input instanceof Request ? call.input.method : "GET"); - const headers = new Headers(call.input instanceof Request ? call.input.headers : undefined); + const method = + call.init?.method ?? + (call.input instanceof Request ? call.input.method : "GET"); + const headers = new Headers( + call.input instanceof Request ? call.input.headers : undefined, + ); if (call.init?.headers) { const overrideHeaders = new Headers(call.init.headers); for (const [name, value] of overrideHeaders.entries()) { @@ -167,7 +182,10 @@ test("queryAudit sends audit filters as query params and maps nextCursor to curs assert.equal(request.url.searchParams.get("identityId"), "agent_123"); assert.equal(request.url.searchParams.get("action"), "scope.denied"); assert.equal(request.url.searchParams.get("result"), "denied"); - assert.equal(request.url.searchParams.get("from"), "2026-03-25T09:00:00.000Z"); + assert.equal( + request.url.searchParams.get("from"), + "2026-03-25T09:00:00.000Z", + ); assert.equal(request.url.searchParams.get("to"), "2026-03-25T11:00:00.000Z"); assert.equal(request.url.searchParams.get("cursor"), "cursor_start"); assert.equal(request.url.searchParams.get("limit"), "50"); @@ -269,7 +287,10 @@ test("getIdentityActivity fetches a paginated activity feed with action and date assert.equal(request.body, ""); assert.equal(request.url.searchParams.get("action"), "scope.denied"); assert.equal(request.url.searchParams.get("result"), "denied"); - assert.equal(request.url.searchParams.get("from"), "2026-03-25T09:00:00.000Z"); + assert.equal( + request.url.searchParams.get("from"), + "2026-03-25T09:00:00.000Z", + ); assert.equal(request.url.searchParams.get("to"), "2026-03-25T11:00:00.000Z"); assert.equal(request.url.searchParams.get("cursor"), "cursor_activity_start"); assert.equal(request.url.searchParams.get("limit"), "25"); @@ -289,7 +310,9 @@ test("exportAudit posts json export filters and returns the raw json payload", a limit: 100, }; const exportPayload = JSON.stringify(auditEntries); - const fetchMock = mockFetch(() => textResponse(exportPayload, 200, "application/json")); + const fetchMock = mockFetch(() => + textResponse(exportPayload, 200, "application/json"), + ); t.after(() => fetchMock.restore()); const result = await client.exportAudit(query, "json"); @@ -313,7 +336,9 @@ test("exportAudit returns raw csv data for csv exports", async (t) => { const csvExport = "id,action,identityId,orgId,result,timestamp\n" + "aud_002,scope.denied,agent_123,org_123,denied,2026-03-25T10:05:00.000Z\n"; - const fetchMock = mockFetch(() => textResponse(csvExport, 200, "text/csv; charset=utf-8")); + const fetchMock = mockFetch(() => + textResponse(csvExport, 200, "text/csv; charset=utf-8"), + ); t.after(() => fetchMock.restore()); const result = await client.exportAudit( diff --git a/packages/sdk/typescript/src/client.ts b/packages/sdk/typescript/src/client.ts index 0cfaf3a..927bd1f 100644 --- a/packages/sdk/typescript/src/client.ts +++ b/packages/sdk/typescript/src/client.ts @@ -67,7 +67,9 @@ type CreateRoleInput = { workspaceId?: string; }; -type UpdateRoleInput = Partial>; +type UpdateRoleInput = Partial< + Pick +>; type RequestOptions = Omit & { body?: unknown; @@ -100,7 +102,10 @@ export class RelayAuthClient { this.options = options; } - async createIdentity(orgId: string, input: CreateIdentityInput): Promise { + async createIdentity( + orgId: string, + input: CreateIdentityInput, + ): Promise { return this._request("/v1/identities", { method: "POST", body: { @@ -111,10 +116,15 @@ export class RelayAuthClient { } async getIdentity(identityId: string): Promise { - return this._request(`/v1/identities/${encodeURIComponent(identityId)}`); + return this._request( + `/v1/identities/${encodeURIComponent(identityId)}`, + ); } - async issueToken(identityId: string, options?: IssueTokenOptions): Promise { + async issueToken( + identityId: string, + options?: IssueTokenOptions, + ): Promise { return this._request("/v1/tokens", { method: "POST", body: { @@ -136,21 +146,25 @@ export class RelayAuthClient { }); } - async issueWorkspaceToken(options: WorkspaceTokenIssueRequest): Promise { + async issueWorkspaceToken( + options: WorkspaceTokenIssueRequest, + ): Promise { return this._request("/v1/tokens/workspace", { method: "POST", body: options, }); } - async issueAgentToken(options: AgentTokenIssueRequest): Promise { + async issueAgentToken( + options: AgentTokenIssueRequest, + ): Promise { return this._request("/v1/tokens/agent", { method: "POST", body: options, headers: this.options.apiKey ? { - "x-api-key": this.options.apiKey, - } + "x-api-key": this.options.apiKey, + } : undefined, errorContext: { identityId: options.agentId, @@ -164,8 +178,8 @@ export class RelayAuthClient { body: options, headers: this.options.apiKey ? { - "x-api-key": this.options.apiKey, - } + "x-api-key": this.options.apiKey, + } : undefined, errorContext: { identityId: options.agentId, @@ -173,14 +187,16 @@ export class RelayAuthClient { }); } - async issueWorkspacePathToken(options: WorkspacePathTokenIssueRequest): Promise { + async issueWorkspacePathToken( + options: WorkspacePathTokenIssueRequest, + ): Promise { return this._request("/v1/tokens/workspace-path", { method: "POST", body: options, headers: this.options.apiKey ? { - "x-api-key": this.options.apiKey, - } + "x-api-key": this.options.apiKey, + } : undefined, errorContext: { identityId: options.agentId, @@ -209,17 +225,17 @@ export class RelayAuthClient { orgId: string, options?: ListIdentitiesOptions, ): Promise<{ identities: AgentIdentity[]; cursor?: string }> { - const response = await this._request<{ data: AgentIdentity[]; cursor?: string }>( - "/v1/identities", - { - query: { - orgId, - limit: options?.limit, - cursor: options?.cursor, - status: options?.status, - }, + const response = await this._request<{ + data: AgentIdentity[]; + cursor?: string; + }>("/v1/identities", { + query: { + orgId, + limit: options?.limit, + cursor: options?.cursor, + status: options?.status, }, - ); + }); return { identities: response.data, @@ -256,7 +272,10 @@ export class RelayAuthClient { return mapAuditPage(response); } - async exportAudit(query: AuditQuery, format: "json" | "csv"): Promise { + async exportAudit( + query: AuditQuery, + format: "json" | "csv", + ): Promise { return this._request("/v1/audit/export", { method: "POST", body: { @@ -321,15 +340,18 @@ export class RelayAuthClient { } async assignRole(identityId: string, roleId: string): Promise { - await this._request(`/v1/identities/${encodeURIComponent(identityId)}/roles`, { - method: "POST", - body: { - roleId, - }, - errorContext: { - disableIdentityErrorMapping: true, + await this._request( + `/v1/identities/${encodeURIComponent(identityId)}/roles`, + { + method: "POST", + body: { + roleId, + }, + errorContext: { + disableIdentityErrorMapping: true, + }, }, - }); + ); } async removeRole(identityId: string, roleId: string): Promise { @@ -348,13 +370,19 @@ export class RelayAuthClient { identityId: string, updates: Partial, ): Promise { - return this._request(`/v1/identities/${encodeURIComponent(identityId)}`, { - method: "PATCH", - body: updates, - }); + return this._request( + `/v1/identities/${encodeURIComponent(identityId)}`, + { + method: "PATCH", + body: updates, + }, + ); } - async suspendIdentity(identityId: string, reason: string): Promise { + async suspendIdentity( + identityId: string, + reason: string, + ): Promise { return this._request( `/v1/identities/${encodeURIComponent(identityId)}/suspend`, { @@ -374,22 +402,38 @@ export class RelayAuthClient { } async retireIdentity(identityId: string): Promise { - return this._request(`/v1/identities/${encodeURIComponent(identityId)}/retire`, { - method: "POST", - }); + return this._request( + `/v1/identities/${encodeURIComponent(identityId)}/retire`, + { + method: "POST", + }, + ); } async deleteIdentity(identityId: string): Promise { - await this._request(`/v1/identities/${encodeURIComponent(identityId)}`, { - method: "DELETE", - headers: { - "X-Confirm-Delete": "true", + await this._request( + `/v1/identities/${encodeURIComponent(identityId)}`, + { + method: "DELETE", + headers: { + "X-Confirm-Delete": "true", + }, }, - }); + ); } - private async _request(path: string, options: RequestOptions = {}): Promise { - const { body, errorContext, headers, query, responseType = "json", ...init } = options; + private async _request( + path: string, + options: RequestOptions = {}, + ): Promise { + const { + body, + errorContext, + headers, + query, + responseType = "json", + ...init + } = options; const url = new URL(path, normalizeBaseUrl(this.options.baseUrl)); if (query) { @@ -441,7 +485,9 @@ export class RelayAuthClient { } } -function serializeAuditQuery(query?: Partial): Record { +function serializeAuditQuery( + query?: Partial, +): Record { return { identityId: query?.identityId, action: query?.action, @@ -492,7 +538,8 @@ function createRequestError( ? undefined : (context?.identityId ?? extractIdentityId(path)); const errorCode = getString(payload, "code") ?? getString(payload, "error"); - const message = getString(payload, "message") ?? `Request failed with status ${status}`; + const message = + getString(payload, "message") ?? `Request failed with status ${status}`; if (status === 404 && identityId) { return new IdentityNotFoundError(identityId); @@ -526,7 +573,10 @@ function createRequestError( } if (status === 400 && errorCode === "invalid_scope") { - return new InvalidScopeError(getString(payload, "scope") ?? "unknown", getString(payload, "reason")); + return new InvalidScopeError( + getString(payload, "scope") ?? "unknown", + getString(payload, "reason"), + ); } return new RelayAuthError(message, errorCode ?? "request_failed", status); @@ -552,5 +602,7 @@ function getStringArray(value: unknown, key: string): string[] { } const entry = (value as Record)[key]; - return Array.isArray(entry) && entry.every((item) => typeof item === "string") ? entry : []; + return Array.isArray(entry) && entry.every((item) => typeof item === "string") + ? entry + : []; } diff --git a/packages/sdk/typescript/src/index.ts b/packages/sdk/typescript/src/index.ts index dc38e86..6df6198 100644 --- a/packages/sdk/typescript/src/index.ts +++ b/packages/sdk/typescript/src/index.ts @@ -4,7 +4,10 @@ export type { AuditQueryWorkBudget, RelayAuthClientOptions, } from "./client.js"; -export { AgentTokenSession, createAgentTokenSession } from "./agent-token-session.js"; +export { + AgentTokenSession, + createAgentTokenSession, +} from "./agent-token-session.js"; export type { AgentTokenSessionOptions } from "./agent-token-session.js"; export type { AgentTokenIssueRequest, @@ -46,11 +49,7 @@ export { configurationToAgentCard, } from "./a2a-bridge.js"; export type { A2aAgentCard, A2aAgentSkill } from "./a2a-bridge.js"; -export { - parseScope, - parseScopes, - validateScope, -} from "./scope-parser.js"; +export { parseScope, parseScopes, validateScope } from "./scope-parser.js"; export { isSubsetOf, matchScope, diff --git a/packages/server/src/__tests__/audit-logger.test.ts b/packages/server/src/__tests__/audit-logger.test.ts index 87c97dc..883d798 100644 --- a/packages/server/src/__tests__/audit-logger.test.ts +++ b/packages/server/src/__tests__/audit-logger.test.ts @@ -1,6 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import type { AuditAction, AuditEntry, RelayAuthTokenClaims } from "@relayauth/types"; +import type { + AuditAction, + AuditEntry, + RelayAuthTokenClaims, +} from "@relayauth/types"; import { Hono, type MiddlewareHandler } from "hono"; import type { AppEnv } from "../env.js"; @@ -24,8 +28,14 @@ type AuditEntryInput = Omit & { }; type AuditLoggerModule = { - writeAuditEntry: (db: D1Database, entry: Partial) => Promise | void; - flushAuditBatch: (db: D1Database, entries: Partial[]) => Promise | void; + writeAuditEntry: ( + db: D1Database, + entry: Partial, + ) => Promise | void; + flushAuditBatch: ( + db: D1Database, + entries: Partial[], + ) => Promise | void; createAuditMiddleware: () => MiddlewareHandler; }; @@ -51,7 +61,9 @@ function normalizeSql(query: string): string { return query.replace(/\s+/g, " ").trim().toLowerCase(); } -function createBindings(overrides: Partial = {}): AppEnv["Bindings"] { +function createBindings( + overrides: Partial = {}, +): AppEnv["Bindings"] { return { INTERNAL_SECRET: "internal-test-secret", RELAYAUTH_SIGNING_KEY_PEM: TEST_RS256_PRIVATE_KEY_PEM, @@ -150,7 +162,9 @@ function createRecordingD1(options: RecordingD1Options = {}): RecordingD1 { throw new Error("simulated D1 batch failure"); } - return Promise.all(statements.map((statement) => statement.run())) as Awaited; + return Promise.all( + statements.map((statement) => statement.run()), + ) as Awaited; }, exec: async () => ({ count: 0, @@ -162,7 +176,12 @@ function createRecordingD1(options: RecordingD1Options = {}): RecordingD1 { } type RecordingAuditStorage = { - storage: { audit: { write: (entry: unknown) => Promise; writeBatch: (entries: unknown[]) => Promise } }; + storage: { + audit: { + write: (entry: unknown) => Promise; + writeBatch: (entries: unknown[]) => Promise; + }; + }; writes: unknown[]; }; @@ -171,8 +190,12 @@ function createRecordingAuditStorage(): RecordingAuditStorage { return { storage: { audit: { - write: async (entry: unknown) => { writes.push(entry); }, - writeBatch: async (entries: unknown[]) => { entries.forEach(e => writes.push(e)); }, + write: async (entry: unknown) => { + writes.push(entry); + }, + writeBatch: async (entries: unknown[]) => { + entries.forEach((e) => writes.push(e)); + }, }, }, writes, @@ -183,14 +206,30 @@ async function loadAuditLogger(): Promise { let moduleRecord: Record; try { - moduleRecord = (await import("../engine/audit-logger.js")) as Record; + moduleRecord = (await import("../engine/audit-logger.js")) as Record< + string, + unknown + >; } catch (error) { - const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error); - assert.fail(`Expected audit logger module at ../engine/audit-logger.js: ${message}`); + const message = + error instanceof Error + ? `${error.name}: ${error.message}` + : String(error); + assert.fail( + `Expected audit logger module at ../engine/audit-logger.js: ${message}`, + ); } - assert.equal(typeof moduleRecord.writeAuditEntry, "function", "audit logger should export writeAuditEntry()"); - assert.equal(typeof moduleRecord.flushAuditBatch, "function", "audit logger should export flushAuditBatch()"); + assert.equal( + typeof moduleRecord.writeAuditEntry, + "function", + "audit logger should export writeAuditEntry()", + ); + assert.equal( + typeof moduleRecord.flushAuditBatch, + "function", + "audit logger should export flushAuditBatch()", + ); assert.equal( typeof moduleRecord.createAuditMiddleware, "function", @@ -213,7 +252,11 @@ function createBudgetExceededEntry( result: "denied", metadata: { sponsorId: "user_sponsor_1", - sponsorChain: JSON.stringify(["user_sponsor_1", "agent_root_1", "agent_budget_1"]), + sponsorChain: JSON.stringify([ + "user_sponsor_1", + "agent_root_1", + "agent_budget_1", + ]), budgetConfig: JSON.stringify({ maxActionsPerHour: 10, maxCostPerDay: 50, @@ -241,10 +284,16 @@ function createAuditAlertEntry( workspaceId: overrides.workspaceId ?? "ws_alert_1", plane: overrides.plane ?? "relaycast", resource: overrides.resource ?? "channel:#ops", - result: overrides.result ?? (action === "scope.escalation_denied" ? "denied" : "allowed"), + result: + overrides.result ?? + (action === "scope.escalation_denied" ? "denied" : "allowed"), metadata: { sponsorId: "user_sponsor_2", - sponsorChain: JSON.stringify(["user_sponsor_2", "agent_parent_2", overrides.identityId ?? "agent_alert_1"]), + sponsorChain: JSON.stringify([ + "user_sponsor_2", + "agent_parent_2", + overrides.identityId ?? "agent_alert_1", + ]), budgetConfig: JSON.stringify({ maxActionsPerHour: 100, maxCostPerDay: 250, @@ -281,14 +330,17 @@ async function expectFailure( } function findAuditWrite(statements: RecordedStatement[]): RecordedStatement { - const statement = statements.find(({ query }) => /insert into audit_log(s)?/.test(query)); + const statement = statements.find(({ query }) => + /insert into audit_log(s)?/.test(query), + ); assert.ok(statement, "expected an INSERT into audit_log or audit_logs"); return statement; } function findAuditId(params: unknown[]): string { const auditId = params.find( - (param): param is string => typeof param === "string" && /^aud_[A-Za-z0-9_-]+$/.test(param), + (param): param is string => + typeof param === "string" && /^aud_[A-Za-z0-9_-]+$/.test(param), ); assert.ok(auditId, "expected a generated audit id with aud_ prefix"); @@ -328,10 +380,14 @@ function findMetadata(params: unknown[]): MetadataRecord { } } - assert.fail("expected serialized metadata containing sponsorId and sponsorChain"); + assert.fail( + "expected serialized metadata containing sponsorId and sponsorChain", + ); } -function createAuthorizationHeader(claims: Partial = {}): HeadersInit { +function createAuthorizationHeader( + claims: Partial = {}, +): HeadersInit { return { Authorization: `Bearer ${generateTestToken(claims)}`, }; @@ -346,8 +402,16 @@ test("writeAuditEntry() writes budget breach entries with sponsor and budget met assert.equal(writes.length, 1, "expected one audit write"); const write = writes[0] as Record; - assert.equal(write.action, "budget.exceeded", "expected the budget.exceeded action"); - assert.equal(write.identityId, "agent_budget_1", "expected the entry identity id"); + assert.equal( + write.action, + "budget.exceeded", + "expected the budget.exceeded action", + ); + assert.equal( + write.identityId, + "agent_budget_1", + "expected the entry identity id", + ); assert.equal(write.orgId, "org_budget_1", "expected the entry org id"); assert.equal(write.result, "denied", "expected the entry result"); @@ -374,19 +438,39 @@ test("writeAuditEntry() generates a unique audit id and timestamp when they are const { writeAuditEntry } = await loadAuditLogger(); const { storage, writes } = createRecordingAuditStorage(); - await writeAuditEntry(storage, createBudgetExceededEntry({ identityId: "agent_budget_1a" })); - await writeAuditEntry(storage, createBudgetExceededEntry({ identityId: "agent_budget_1b" })); + await writeAuditEntry( + storage, + createBudgetExceededEntry({ identityId: "agent_budget_1a" }), + ); + await writeAuditEntry( + storage, + createBudgetExceededEntry({ identityId: "agent_budget_1b" }), + ); assert.equal(writes.length, 2, "expected two writes"); const first = writes[0] as Record; const second = writes[1] as Record; - assert.ok(typeof first.id === "string" && first.id.length > 0, "expected a generated id"); - assert.ok(typeof second.id === "string" && second.id.length > 0, "expected a generated id"); + assert.ok( + typeof first.id === "string" && first.id.length > 0, + "expected a generated id", + ); + assert.ok( + typeof second.id === "string" && second.id.length > 0, + "expected a generated id", + ); assert.notEqual(first.id, second.id, "expected unique generated ids"); - assert.equal(Number.isNaN(Date.parse(first.timestamp as string)), false, "expected a valid timestamp"); - assert.equal(Number.isNaN(Date.parse(second.timestamp as string)), false, "expected a valid timestamp"); + assert.equal( + Number.isNaN(Date.parse(first.timestamp as string)), + false, + "expected a valid timestamp", + ); + assert.equal( + Number.isNaN(Date.parse(second.timestamp as string)), + false, + "expected a valid timestamp", + ); }); test("writeAuditEntry() validates required fields and sponsor trace metadata", async (t) => { @@ -395,7 +479,11 @@ test("writeAuditEntry() validates required fields and sponsor trace metadata", a await t.test("requires action", async () => { const { db, runs } = createRecordingD1(); await expectFailure( - () => writeAuditEntry(db, { ...createBudgetExceededEntry(), action: undefined }), + () => + writeAuditEntry(db, { + ...createBudgetExceededEntry(), + action: undefined, + }), /action/i, ); assert.equal(runs.length, 0); @@ -404,7 +492,11 @@ test("writeAuditEntry() validates required fields and sponsor trace metadata", a await t.test("requires identityId", async () => { const { db, runs } = createRecordingD1(); await expectFailure( - () => writeAuditEntry(db, { ...createBudgetExceededEntry(), identityId: undefined }), + () => + writeAuditEntry(db, { + ...createBudgetExceededEntry(), + identityId: undefined, + }), /identity/i, ); assert.equal(runs.length, 0); @@ -413,7 +505,11 @@ test("writeAuditEntry() validates required fields and sponsor trace metadata", a await t.test("requires orgId", async () => { const { db, runs } = createRecordingD1(); await expectFailure( - () => writeAuditEntry(db, { ...createBudgetExceededEntry(), orgId: undefined }), + () => + writeAuditEntry(db, { + ...createBudgetExceededEntry(), + orgId: undefined, + }), /org/i, ); assert.equal(runs.length, 0); @@ -422,7 +518,11 @@ test("writeAuditEntry() validates required fields and sponsor trace metadata", a await t.test("requires result", async () => { const { db, runs } = createRecordingD1(); await expectFailure( - () => writeAuditEntry(db, { ...createBudgetExceededEntry(), result: undefined }), + () => + writeAuditEntry(db, { + ...createBudgetExceededEntry(), + result: undefined, + }), /result/i, ); assert.equal(runs.length, 0); @@ -460,24 +560,23 @@ test("createAuditMiddleware() logs token validation events automatically", async app.get("/session", (c) => c.json({ ok: true })); const response = await app.request( - createTestRequest( - "GET", - "/session", - undefined, - { - ...createAuthorizationHeader({ - sub: "agent_middleware_1", - org: "org_middleware_1", - wks: "ws_middleware_1", - scopes: ["relayauth:*"], - sponsorId: "user_middleware_1", - sponsorChain: ["user_middleware_1", "agent_root_1", "agent_middleware_1"], - jti: "tok_middleware_1", - }), - "User-Agent": "audit-tests/1.0", - "CF-Connecting-IP": "203.0.113.10", - }, - ), + createTestRequest("GET", "/session", undefined, { + ...createAuthorizationHeader({ + sub: "agent_middleware_1", + org: "org_middleware_1", + wks: "ws_middleware_1", + scopes: ["relayauth:*"], + sponsorId: "user_middleware_1", + sponsorChain: [ + "user_middleware_1", + "agent_root_1", + "agent_middleware_1", + ], + jti: "tok_middleware_1", + }), + "User-Agent": "audit-tests/1.0", + "CF-Connecting-IP": "203.0.113.10", + }), undefined, createBindings(), ); @@ -485,16 +584,31 @@ test("createAuditMiddleware() logs token validation events automatically", async assert.equal(response.status, 200); // Query audit log from SQLite storage instead of D1 recording - const auditEntries = await sqliteStorage.audit.query({ orgId: "org_middleware_1" }); + const auditEntries = await sqliteStorage.audit.query({ + orgId: "org_middleware_1", + }); const entries = auditEntries.entries; assert.ok(entries.length >= 1, "expected a token validation audit write"); - const tokenValidated = entries.find((e: any) => e.action === "token.validated"); + const tokenValidated = entries.find( + (e: any) => e.action === "token.validated", + ); assert.ok(tokenValidated, "expected token.validated audit action"); - assert.equal(tokenValidated.identityId, "agent_middleware_1", "expected request identity id"); - assert.equal(tokenValidated.orgId, "org_middleware_1", "expected request org id"); + assert.equal( + tokenValidated.identityId, + "agent_middleware_1", + "expected request identity id", + ); + assert.equal( + tokenValidated.orgId, + "org_middleware_1", + "expected request org id", + ); - const metadata = typeof tokenValidated.metadata === "string" ? JSON.parse(tokenValidated.metadata) : (tokenValidated.metadata ?? {}); + const metadata = + typeof tokenValidated.metadata === "string" + ? JSON.parse(tokenValidated.metadata) + : (tokenValidated.metadata ?? {}); assert.equal(metadata.sponsorId, "user_middleware_1"); assert.deepEqual(JSON.parse(metadata.sponsorChain ?? "[]"), [ "user_middleware_1", @@ -516,7 +630,11 @@ test("flushAuditBatch() writes multiple audit entries", async () => { result: "denied", metadata: { sponsorId: "user_scope_1", - sponsorChain: JSON.stringify(["user_scope_1", "agent_parent_2", "agent_scope_1"]), + sponsorChain: JSON.stringify([ + "user_scope_1", + "agent_parent_2", + "agent_scope_1", + ]), actionAttempted: "relaycast:workspace:admin:*", budgetConfig: JSON.stringify({ maxActionsPerHour: 100 }), actualUsage: JSON.stringify({ actionsThisHour: 101 }), @@ -528,11 +646,23 @@ test("flushAuditBatch() writes multiple audit entries", async () => { assert.equal(writes.length, 2, "expected both entries to be written"); const actions = writes.map((w) => (w as Record).action); - assert.equal(actions.includes("budget.alert"), true, "expected support for the new budget.alert action"); - assert.equal(actions.includes("scope.escalation_denied"), true, "expected support for scope.escalation_denied"); + assert.equal( + actions.includes("budget.alert"), + true, + "expected support for the new budget.alert action", + ); + assert.equal( + actions.includes("scope.escalation_denied"), + true, + "expected support for scope.escalation_denied", + ); - const alertEntry = writes.find((w) => (w as Record).action === "budget.alert") as Record; - const escalationEntry = writes.find((w) => (w as Record).action === "scope.escalation_denied") as Record; + const alertEntry = writes.find( + (w) => (w as Record).action === "budget.alert", + ) as Record; + const escalationEntry = writes.find( + (w) => (w as Record).action === "scope.escalation_denied", + ) as Record; const alertMetadata = (alertEntry.metadata ?? {}) as MetadataRecord; const escalationMetadata = (escalationEntry.metadata ?? {}) as MetadataRecord; @@ -549,7 +679,10 @@ test("flushAuditBatch() writes multiple audit entries", async () => { "agent_parent_2", "agent_scope_1", ]); - assert.equal(escalationMetadata.actionAttempted, "relaycast:workspace:admin:*"); + assert.equal( + escalationMetadata.actionAttempted, + "relaycast:workspace:admin:*", + ); }); test("writeAuditEntry() handles D1 write failures gracefully by logging the error and not throwing", async (t) => { diff --git a/packages/server/src/__tests__/audit-query-api.test.ts b/packages/server/src/__tests__/audit-query-api.test.ts index 53f67b5..24cd81f 100644 --- a/packages/server/src/__tests__/audit-query-api.test.ts +++ b/packages/server/src/__tests__/audit-query-api.test.ts @@ -31,26 +31,37 @@ function createAuditEntry( return { id: overrides.id ?? `aud_${String(index).padStart(3, "0")}`, action: overrides.action ?? "token.validated", - identityId: overrides.identityId ?? `agent_${String(index).padStart(3, "0")}`, + identityId: + overrides.identityId ?? `agent_${String(index).padStart(3, "0")}`, orgId: overrides.orgId ?? "org_test", - ...(overrides.workspaceId !== undefined ? { workspaceId: overrides.workspaceId } : {}), + ...(overrides.workspaceId !== undefined + ? { workspaceId: overrides.workspaceId } + : {}), plane: overrides.plane ?? "relayauth", resource: overrides.resource ?? `/resources/${index}`, result: overrides.result ?? "allowed", metadata: overrides.metadata ?? { sponsorId: "user_test", - sponsorChain: JSON.stringify(["user_test", overrides.identityId ?? `agent_${String(index).padStart(3, "0")}`]), + sponsorChain: JSON.stringify([ + "user_test", + overrides.identityId ?? `agent_${String(index).padStart(3, "0")}`, + ]), requestId: `req_${String(index).padStart(3, "0")}`, }, ip: overrides.ip ?? "203.0.113.10", userAgent: overrides.userAgent ?? "audit-query-tests/1.0", - timestamp: overrides.timestamp ?? new Date(Date.UTC(2026, 2, 24, 12, 0, index)).toISOString(), + timestamp: + overrides.timestamp ?? + new Date(Date.UTC(2026, 2, 24, 12, 0, index)).toISOString(), createdAt: - overrides.createdAt ?? new Date(Date.UTC(2026, 2, 24, 12, 5, index)).toISOString(), + overrides.createdAt ?? + new Date(Date.UTC(2026, 2, 24, 12, 5, index)).toISOString(), }; } -function createAuditSearch(params: Record): string { +function createAuditSearch( + params: Record, +): string { const search = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { @@ -103,13 +114,16 @@ test("GET /v1/audit returns paginated audit entries", async () => { }), ]; - const response = await queryAudit(createAuditSearch({ orgId: "org_audit_feed" }), { - claims: { - org: "org_audit_feed", - scopes: ["relayauth:audit:read"], + const response = await queryAudit( + createAuditSearch({ orgId: "org_audit_feed" }), + { + claims: { + org: "org_audit_feed", + scopes: ["relayauth:audit:read"], + }, + entries, }, - entries, - }); + ); const body = await assertJsonResponse(response, 200); assert.deepEqual(body.entries, entries); @@ -225,16 +239,22 @@ test("GET /v1/audit filters by orgId query param", async () => { }), ]; - const response = await queryAudit(createAuditSearch({ orgId: "org_target" }), { - claims: { - org: "org_target", - scopes: ["relayauth:audit:read"], + const response = await queryAudit( + createAuditSearch({ orgId: "org_target" }), + { + claims: { + org: "org_target", + scopes: ["relayauth:audit:read"], + }, + entries, }, - entries, - }); + ); const body = await assertJsonResponse(response, 200); - assert.deepEqual(body.entries.map((entry) => entry.id), ["aud_org_003", "aud_org_001"]); + assert.deepEqual( + body.entries.map((entry) => entry.id), + ["aud_org_003", "aud_org_001"], + ); assert.ok(body.entries.every((entry) => entry.orgId === "org_target")); }); @@ -278,7 +298,10 @@ test("GET /v1/audit filters by date range using inclusive from and exclusive to" ); const body = await assertJsonResponse(response, 200); - assert.deepEqual(body.entries.map((entry) => entry.id), ["aud_range_003", "aud_range_002"]); + assert.deepEqual( + body.entries.map((entry) => entry.id), + ["aud_range_003", "aud_range_002"], + ); }); test("GET /v1/audit filters by result query param", async () => { @@ -318,7 +341,10 @@ test("GET /v1/audit filters by result query param", async () => { ); const body = await assertJsonResponse(response, 200); - assert.deepEqual(body.entries.map((entry) => entry.id), ["aud_result_003", "aud_result_001"]); + assert.deepEqual( + body.entries.map((entry) => entry.id), + ["aud_result_003", "aud_result_001"], + ); assert.ok(body.entries.every((entry) => entry.result === "denied")); }); @@ -354,9 +380,15 @@ test("GET /v1/audit supports cursor-based pagination with limit", async () => { entries, }, ); - const firstPage = await assertJsonResponse(firstPageResponse, 200); + const firstPage = await assertJsonResponse( + firstPageResponse, + 200, + ); - assert.deepEqual(firstPage.entries.map((entry) => entry.id), ["aud_page_003", "aud_page_002"]); + assert.deepEqual( + firstPage.entries.map((entry) => entry.id), + ["aud_page_003", "aud_page_002"], + ); assert.equal(typeof firstPage.nextCursor, "string"); const secondPageResponse = await queryAudit( @@ -373,18 +405,36 @@ test("GET /v1/audit supports cursor-based pagination with limit", async () => { entries, }, ); - const secondPage = await assertJsonResponse(secondPageResponse, 200); + const secondPage = await assertJsonResponse( + secondPageResponse, + 200, + ); - assert.deepEqual(secondPage.entries.map((entry) => entry.id), ["aud_page_001"]); + assert.deepEqual( + secondPage.entries.map((entry) => entry.id), + ["aud_page_001"], + ); assert.equal(secondPage.nextCursor, null); }); test("GET /v1/audit returns a typed archive budget continuation that resumes without gaps or duplicates", async () => { const storage = createTestStorage(); const entries = [ - createAuditEntry(3, { id: "aud_archive_003", orgId: "org_archive", timestamp: "2026-03-24T12:00:03.000Z" }), - createAuditEntry(2, { id: "aud_archive_002", orgId: "org_archive", timestamp: "2026-03-24T12:00:02.000Z" }), - createAuditEntry(1, { id: "aud_archive_001", orgId: "org_archive", timestamp: "2026-03-24T12:00:01.000Z" }), + createAuditEntry(3, { + id: "aud_archive_003", + orgId: "org_archive", + timestamp: "2026-03-24T12:00:03.000Z", + }), + createAuditEntry(2, { + id: "aud_archive_002", + orgId: "org_archive", + timestamp: "2026-03-24T12:00:02.000Z", + }), + createAuditEntry(1, { + id: "aud_archive_001", + orgId: "org_archive", + timestamp: "2026-03-24T12:00:01.000Z", + }), ]; storage.audit.query = async (query) => { assert.equal(query.orgId, "org_archive"); @@ -414,7 +464,7 @@ test("GET /v1/audit returns a typed archive budget continuation that resumes wit workBudget: { d1Pages: 4, d1Rows: 129, partitions: 128, r2Reads: 128 }, }; }; - const app = createTestApp({ storage }); + const app = createTestApp({}, { storage }); const token = `Bearer ${generateTestToken({ org: "org_archive", scopes: ["relayauth:audit:read"], @@ -434,7 +484,12 @@ test("GET /v1/audit returns a typed archive budget continuation that resumes wit assert.equal(firstPage.partial, true); assert.equal(firstPage.hasMore, true); assert.equal(typeof firstPage.nextCursor, "string"); - assert.deepEqual(firstPage.workBudget, { d1Pages: 4, d1Rows: 129, partitions: 128, r2Reads: 128 }); + assert.deepEqual(firstPage.workBudget, { + d1Pages: 4, + d1Rows: 129, + partitions: 128, + r2Reads: 128, + }); const crossOrg = await app.request( createTestRequest( @@ -480,8 +535,13 @@ test("GET /v1/audit returns a typed archive budget continuation that resumes wit app.bindings, ); const secondPage = await assertJsonResponse(second, 200); - const received = [...firstPage.entries, ...secondPage.entries].map((entry) => entry.id); - assert.deepEqual(received, entries.map((entry) => entry.id)); + const received = [...firstPage.entries, ...secondPage.entries].map( + (entry) => entry.id, + ); + assert.deepEqual( + received, + entries.map((entry) => entry.id), + ); assert.equal(new Set(received).size, received.length); }); @@ -489,57 +549,72 @@ test("GET /v1/audit returns 400 when orgId is missing", async () => { const response = await queryAudit("", { claims: { org: "org_test", scopes: ["relayauth:audit:read"] }, }); - const body = await response.json() as { error: string }; + const body = (await response.json()) as { error: string }; assert.equal(response.status, 400); assert.equal(body.error, "orgId query param is required"); }); test("GET /v1/audit returns 400 for invalid action", async () => { - const response = await queryAudit(createAuditSearch({ orgId: "org_test", action: "bogus.action" }), { - claims: { org: "org_test", scopes: ["relayauth:audit:read"] }, - }); - const body = await response.json() as { error: string }; + const response = await queryAudit( + createAuditSearch({ orgId: "org_test", action: "bogus.action" }), + { + claims: { org: "org_test", scopes: ["relayauth:audit:read"] }, + }, + ); + const body = (await response.json()) as { error: string }; assert.equal(response.status, 400); assert.match(body.error, /invalid action/); }); test("GET /v1/audit returns 400 for invalid cursor", async () => { - const response = await queryAudit(createAuditSearch({ orgId: "org_test", cursor: "not-valid-base64-cursor" }), { - claims: { org: "org_test", scopes: ["relayauth:audit:read"] }, - }); - const body = await response.json() as { error: string }; + const response = await queryAudit( + createAuditSearch({ orgId: "org_test", cursor: "not-valid-base64-cursor" }), + { + claims: { org: "org_test", scopes: ["relayauth:audit:read"] }, + }, + ); + const body = (await response.json()) as { error: string }; assert.equal(response.status, 400); assert.equal(body.error, "invalid cursor"); }); test("GET /v1/audit returns 400 for invalid limit", async () => { - const response = await queryAudit(createAuditSearch({ orgId: "org_test", limit: "abc" }), { - claims: { org: "org_test", scopes: ["relayauth:audit:read"] }, - }); - const body = await response.json() as { error: string }; + const response = await queryAudit( + createAuditSearch({ orgId: "org_test", limit: "abc" }), + { + claims: { org: "org_test", scopes: ["relayauth:audit:read"] }, + }, + ); + const body = (await response.json()) as { error: string }; assert.equal(response.status, 400); assert.equal(body.error, "limit must be a positive integer"); }); test("GET /v1/audit returns 401 without valid auth token", async () => { - const response = await queryAudit(createAuditSearch({ orgId: "org_auth_failure" }), { - authorization: "Bearer definitely-not-a-valid-token", - }); + const response = await queryAudit( + createAuditSearch({ orgId: "org_auth_failure" }), + { + authorization: "Bearer definitely-not-a-valid-token", + }, + ); assert.equal(response.status, 401); }); test("GET /v1/audit returns 403 without relayauth:audit:read scope", async () => { - const response = await queryAudit(createAuditSearch({ orgId: "org_scope_failure" }), { - claims: { - org: "org_scope_failure", - scopes: ["relayauth:identity:read:*"], + const response = await queryAudit( + createAuditSearch({ orgId: "org_scope_failure" }), + { + claims: { + org: "org_scope_failure", + scopes: ["relayauth:identity:read:*"], + }, }, - }); + ); assert.equal(response.status, 403); }); diff --git a/packages/server/src/__tests__/dashboard-stats-api.test.ts b/packages/server/src/__tests__/dashboard-stats-api.test.ts index eb1b485..e93bdcd 100644 --- a/packages/server/src/__tests__/dashboard-stats-api.test.ts +++ b/packages/server/src/__tests__/dashboard-stats-api.test.ts @@ -1,6 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import type { AgentIdentity, AuditEntry, RelayAuthTokenClaims } from "@relayauth/types"; +import type { + AgentIdentity, + AuditEntry, + RelayAuthTokenClaims, +} from "@relayauth/types"; import { assertJsonResponse, createTestApp, @@ -72,9 +76,13 @@ function createAuditEntry( action: overrides.action ?? "token.issued", identityId, orgId: overrides.orgId ?? "org_stats", - ...(overrides.workspaceId !== undefined ? { workspaceId: overrides.workspaceId } : {}), + ...(overrides.workspaceId !== undefined + ? { workspaceId: overrides.workspaceId } + : {}), ...(overrides.plane !== undefined ? { plane: overrides.plane } : {}), - ...(overrides.resource !== undefined ? { resource: overrides.resource } : {}), + ...(overrides.resource !== undefined + ? { resource: overrides.resource } + : {}), result: overrides.result ?? "allowed", metadata: overrides.metadata ?? { sponsorId: "user_stats_owner", @@ -82,11 +90,15 @@ function createAuditEntry( requestId: `req_stats_${padded}`, }, ...(overrides.ip !== undefined ? { ip: overrides.ip } : {}), - ...(overrides.userAgent !== undefined ? { userAgent: overrides.userAgent } : {}), + ...(overrides.userAgent !== undefined + ? { userAgent: overrides.userAgent } + : {}), timestamp: - overrides.timestamp ?? new Date(Date.UTC(2026, 2, 24, 12, 0, index)).toISOString(), + overrides.timestamp ?? + new Date(Date.UTC(2026, 2, 24, 12, 0, index)).toISOString(), createdAt: - overrides.createdAt ?? new Date(Date.UTC(2026, 2, 24, 12, 5, index)).toISOString(), + overrides.createdAt ?? + new Date(Date.UTC(2026, 2, 24, 12, 5, index)).toISOString(), }; } @@ -100,12 +112,20 @@ function createIdentity( orgId: overrides.orgId ?? "org_stats", status: overrides.status ?? "active", createdAt: - overrides.createdAt ?? new Date(Date.UTC(2026, 2, 24, 9, 0, index)).toISOString(), + overrides.createdAt ?? + new Date(Date.UTC(2026, 2, 24, 9, 0, index)).toISOString(), updatedAt: - overrides.updatedAt ?? new Date(Date.UTC(2026, 2, 24, 10, 0, index)).toISOString(), - ...(overrides.lastActiveAt !== undefined ? { lastActiveAt: overrides.lastActiveAt } : {}), - ...(overrides.suspendedAt !== undefined ? { suspendedAt: overrides.suspendedAt } : {}), - ...(overrides.suspendReason !== undefined ? { suspendReason: overrides.suspendReason } : {}), + overrides.updatedAt ?? + new Date(Date.UTC(2026, 2, 24, 10, 0, index)).toISOString(), + ...(overrides.lastActiveAt !== undefined + ? { lastActiveAt: overrides.lastActiveAt } + : {}), + ...(overrides.suspendedAt !== undefined + ? { suspendedAt: overrides.suspendedAt } + : {}), + ...(overrides.suspendReason !== undefined + ? { suspendReason: overrides.suspendReason } + : {}), }); } @@ -142,9 +162,15 @@ function toIdentityRow(identity: AgentIdentity): IdentityRow { org_id: identity.orgId, created_at: identity.createdAt, updated_at: identity.updatedAt, - ...(identity.lastActiveAt !== undefined ? { last_active_at: identity.lastActiveAt } : {}), - ...(identity.suspendedAt !== undefined ? { suspended_at: identity.suspendedAt } : {}), - ...(identity.suspendReason !== undefined ? { suspend_reason: identity.suspendReason } : {}), + ...(identity.lastActiveAt !== undefined + ? { last_active_at: identity.lastActiveAt } + : {}), + ...(identity.suspendedAt !== undefined + ? { suspended_at: identity.suspendedAt } + : {}), + ...(identity.suspendReason !== undefined + ? { suspend_reason: identity.suspendReason } + : {}), scopes_json: JSON.stringify(identity.scopes), roles_json: JSON.stringify(identity.roles), metadata_json: JSON.stringify(identity.metadata), @@ -171,7 +197,9 @@ function extractAuditFilters( to?: string; } { const normalized = normalizeSql(query); - const stringParams = params.filter((param): param is string => typeof param === "string"); + const stringParams = params.filter( + (param): param is string => typeof param === "string", + ); let offset = 0; const filters: { orgId?: string; from?: string; to?: string } = {}; @@ -260,25 +288,38 @@ function createDashboardStatsD1({ return true; }); - const filteredIdentityRows = identityRows.filter((row) => !orgId || row.org_id === orgId); + const filteredIdentityRows = identityRows.filter( + (row) => !orgId || row.org_id === orgId, + ); return { - tokensIssued: filteredAuditRows.filter((row) => row.action === "token.issued").length, - tokensRevoked: filteredAuditRows.filter((row) => row.action === "token.revoked").length, + tokensIssued: filteredAuditRows.filter( + (row) => row.action === "token.issued", + ).length, + tokensRevoked: filteredAuditRows.filter( + (row) => row.action === "token.revoked", + ).length, scopeChecks: filteredAuditRows.filter( (row) => row.action === "scope.checked" && (row.result === "allowed" || row.result === "denied"), ).length, - scopeDenials: filteredAuditRows.filter((row) => row.action === "scope.denied").length, - activeIdentities: filteredIdentityRows.filter((row) => row.status === "active").length, - suspendedIdentities: filteredIdentityRows.filter((row) => row.status === "suspended").length, + scopeDenials: filteredAuditRows.filter( + (row) => row.action === "scope.denied", + ).length, + activeIdentities: filteredIdentityRows.filter( + (row) => row.status === "active", + ).length, + suspendedIdentities: filteredIdentityRows.filter( + (row) => row.status === "suspended", + ).length, }; }; const createPreparedStatement = (query: string) => ({ bind: (...params: unknown[]) => ({ - first: async () => (resolveAggregateRow(query, params) as T | null) ?? null, + first: async () => + (resolveAggregateRow(query, params) as T | null) ?? null, run: async () => ({ success: true, meta }), raw: async () => { const row = resolveAggregateRow(query, params); @@ -364,7 +405,12 @@ async function getDashboardStats( ); } - const request = createTestRequest("GET", `/v1/stats${search}`, undefined, headers); + const request = createTestRequest( + "GET", + `/v1/stats${search}`, + undefined, + headers, + ); return app.request(request, undefined, app.bindings); } @@ -375,10 +421,24 @@ test("GET /v1/stats returns aggregate stats object", async () => { scopes: ["relayauth:stats:read"], }, entries: [ - createAuditEntry(1, { orgId: "org_stats_contract", action: "token.issued" }), - createAuditEntry(2, { orgId: "org_stats_contract", action: "token.revoked" }), - createAuditEntry(3, { orgId: "org_stats_contract", action: "scope.checked", result: "allowed" }), - createAuditEntry(4, { orgId: "org_stats_contract", action: "scope.denied", result: "denied" }), + createAuditEntry(1, { + orgId: "org_stats_contract", + action: "token.issued", + }), + createAuditEntry(2, { + orgId: "org_stats_contract", + action: "token.revoked", + }), + createAuditEntry(3, { + orgId: "org_stats_contract", + action: "scope.checked", + result: "allowed", + }), + createAuditEntry(4, { + orgId: "org_stats_contract", + action: "scope.denied", + result: "denied", + }), ], identities: [ createIdentity(1, { orgId: "org_stats_contract", status: "active" }), @@ -410,9 +470,18 @@ test("GET /v1/stats includes tokensIssued count", async () => { scopes: ["relayauth:stats:read"], }, entries: [ - createAuditEntry(1, { orgId: "org_tokens_issued", action: "token.issued" }), - createAuditEntry(2, { orgId: "org_tokens_issued", action: "token.issued" }), - createAuditEntry(3, { orgId: "org_tokens_issued", action: "token.revoked" }), + createAuditEntry(1, { + orgId: "org_tokens_issued", + action: "token.issued", + }), + createAuditEntry(2, { + orgId: "org_tokens_issued", + action: "token.issued", + }), + createAuditEntry(3, { + orgId: "org_tokens_issued", + action: "token.revoked", + }), ], }); const body = await assertJsonResponse(response, 200); @@ -427,9 +496,18 @@ test("GET /v1/stats includes tokensRevoked count", async () => { scopes: ["relayauth:stats:read"], }, entries: [ - createAuditEntry(1, { orgId: "org_tokens_revoked", action: "token.revoked" }), - createAuditEntry(2, { orgId: "org_tokens_revoked", action: "token.revoked" }), - createAuditEntry(3, { orgId: "org_tokens_revoked", action: "token.issued" }), + createAuditEntry(1, { + orgId: "org_tokens_revoked", + action: "token.revoked", + }), + createAuditEntry(2, { + orgId: "org_tokens_revoked", + action: "token.revoked", + }), + createAuditEntry(3, { + orgId: "org_tokens_revoked", + action: "token.issued", + }), ], }); const body = await assertJsonResponse(response, 200); @@ -444,10 +522,26 @@ test("GET /v1/stats includes scopeChecks count for allowed and denied evaluation scopes: ["relayauth:stats:read"], }, entries: [ - createAuditEntry(1, { orgId: "org_scope_checks", action: "scope.checked", result: "allowed" }), - createAuditEntry(2, { orgId: "org_scope_checks", action: "scope.checked", result: "denied" }), - createAuditEntry(3, { orgId: "org_scope_checks", action: "scope.checked", result: "error" }), - createAuditEntry(4, { orgId: "org_scope_checks", action: "scope.denied", result: "denied" }), + createAuditEntry(1, { + orgId: "org_scope_checks", + action: "scope.checked", + result: "allowed", + }), + createAuditEntry(2, { + orgId: "org_scope_checks", + action: "scope.checked", + result: "denied", + }), + createAuditEntry(3, { + orgId: "org_scope_checks", + action: "scope.checked", + result: "error", + }), + createAuditEntry(4, { + orgId: "org_scope_checks", + action: "scope.denied", + result: "denied", + }), ], }); const body = await assertJsonResponse(response, 200); @@ -462,9 +556,21 @@ test("GET /v1/stats includes scopeDenials count", async () => { scopes: ["relayauth:stats:read"], }, entries: [ - createAuditEntry(1, { orgId: "org_scope_denials", action: "scope.denied", result: "denied" }), - createAuditEntry(2, { orgId: "org_scope_denials", action: "scope.denied", result: "denied" }), - createAuditEntry(3, { orgId: "org_scope_denials", action: "scope.checked", result: "denied" }), + createAuditEntry(1, { + orgId: "org_scope_denials", + action: "scope.denied", + result: "denied", + }), + createAuditEntry(2, { + orgId: "org_scope_denials", + action: "scope.denied", + result: "denied", + }), + createAuditEntry(3, { + orgId: "org_scope_denials", + action: "scope.checked", + result: "denied", + }), ], }); const body = await assertJsonResponse(response, 200); @@ -481,7 +587,10 @@ test("GET /v1/stats includes activeIdentities count", async () => { identities: [ createIdentity(1, { orgId: "org_active_identities", status: "active" }), createIdentity(2, { orgId: "org_active_identities", status: "active" }), - createIdentity(3, { orgId: "org_active_identities", status: "suspended" }), + createIdentity(3, { + orgId: "org_active_identities", + status: "suspended", + }), createIdentity(4, { orgId: "org_active_identities", status: "retired" }), ], }); @@ -497,9 +606,18 @@ test("GET /v1/stats includes suspendedIdentities count", async () => { scopes: ["relayauth:stats:read"], }, identities: [ - createIdentity(1, { orgId: "org_suspended_identities", status: "suspended" }), - createIdentity(2, { orgId: "org_suspended_identities", status: "suspended" }), - createIdentity(3, { orgId: "org_suspended_identities", status: "active" }), + createIdentity(1, { + orgId: "org_suspended_identities", + status: "suspended", + }), + createIdentity(2, { + orgId: "org_suspended_identities", + status: "suspended", + }), + createIdentity(3, { + orgId: "org_suspended_identities", + status: "active", + }), ], }); const body = await assertJsonResponse(response, 200); @@ -571,7 +689,11 @@ test("GET /v1/stats is scoped to the caller's org", async () => { createAuditEntry(1, { orgId: "org_scoped", action: "token.issued" }), createAuditEntry(2, { orgId: "org_scoped", action: "token.revoked" }), createAuditEntry(3, { orgId: "org_other", action: "token.issued" }), - createAuditEntry(4, { orgId: "org_other", action: "scope.denied", result: "denied" }), + createAuditEntry(4, { + orgId: "org_other", + action: "scope.denied", + result: "denied", + }), ], identities: [ createIdentity(1, { orgId: "org_scoped", status: "active" }), @@ -596,12 +718,24 @@ test("GET /v1/stats exposes a typed, org-scoped bounded count continuation", asy if (query.cursor?.kind === "archive_partition") { return { kind: "complete", - counts: { tokensIssued: 2, tokensRevoked: 0, tokensRefreshed: 0, scopeChecks: 0, scopeDenials: 0 }, + counts: { + tokensIssued: 2, + tokensRevoked: 0, + tokensRefreshed: 0, + scopeChecks: 0, + scopeDenials: 0, + }, }; } return { kind: "budget_exhausted", - counts: { tokensIssued: 128, tokensRevoked: 0, tokensRefreshed: 0, scopeChecks: 0, scopeDenials: 0 }, + counts: { + tokensIssued: 128, + tokensRevoked: 0, + tokensRefreshed: 0, + scopeChecks: 0, + scopeDenials: 0, + }, continuation: { kind: "archive_partition", orgId: "org_stats_continuation", @@ -611,7 +745,7 @@ test("GET /v1/stats exposes a typed, org-scoped bounded count continuation", asy workBudget: { d1Pages: 1, d1Rows: 129, partitions: 128, r2Reads: 0 }, }; }; - const app = createTestApp({ storage }); + const app = createTestApp({}, { storage }); const token = `Bearer ${generateTestToken({ org: "org_stats_continuation", scopes: ["relayauth:stats:read"], @@ -627,12 +761,20 @@ test("GET /v1/stats exposes a typed, org-scoped bounded count continuation", asy undefined, app.bindings, ); - const firstBody = await assertJsonResponse(first, 200); + const firstBody = await assertJsonResponse( + first, + 200, + ); assert.equal(firstBody.tokensIssued, 128); assert.equal(firstBody.partial, true); assert.equal(firstBody.hasMore, true); assert.equal(typeof firstBody.nextCursor, "string"); - assert.deepEqual(firstBody.workBudget, { d1Pages: 1, d1Rows: 129, partitions: 128, r2Reads: 0 }); + assert.deepEqual(firstBody.workBudget, { + d1Pages: 1, + d1Rows: 129, + partitions: 128, + r2Reads: 0, + }); const mismatchedRange = await app.request( createTestRequest( @@ -658,7 +800,10 @@ test("GET /v1/stats exposes a typed, org-scoped bounded count continuation", asy undefined, app.bindings, ); - const secondBody = await assertJsonResponse(second, 200); + const secondBody = await assertJsonResponse( + second, + 200, + ); assert.equal(secondBody.tokensIssued, 2); assert.equal(secondBody.partial, undefined); }); diff --git a/packages/server/src/__tests__/e2e/audit.test.ts b/packages/server/src/__tests__/e2e/audit.test.ts index 5d2d913..0d7e130 100644 --- a/packages/server/src/__tests__/e2e/audit.test.ts +++ b/packages/server/src/__tests__/e2e/audit.test.ts @@ -1,9 +1,16 @@ import assert from "node:assert/strict"; import test from "node:test"; -import type { AuditAction, AuditEntry, RelayAuthTokenClaims } from "@relayauth/types"; +import type { + AuditAction, + AuditEntry, + RelayAuthTokenClaims, +} from "@relayauth/types"; import type { StoredIdentity } from "../../storage/identity-types.js"; -import { countExpiredEntries, purgeExpiredEntries } from "../../engine/audit-retention.js"; +import { + countExpiredEntries, + purgeExpiredEntries, +} from "../../engine/audit-retention.js"; import { writeAuditEntry } from "../../engine/audit-logger.js"; import { dispatchWebhook } from "../../engine/audit-webhook-dispatcher.js"; import { checkAccess } from "../../engine/policy-evaluation.js"; @@ -152,99 +159,140 @@ const CSV_HEADER = [ test("Audit & Observability E2E", async (t) => { const scenario = await seedScenario(); - await t.test("records base and extended audit actions with the full sponsorChain", async () => { - const response = await scenario.harness.request("GET", `/v1/audit?orgId=${ORG_ID}`); - const body = await assertJsonResponse(response, 200); - - const actions = new Set(body.entries.map((entry) => entry.action)); - assert.deepEqual( - actions, - new Set([ - "token.issued", - "identity.created", - "scope.checked", - "scope.denied", - "budget.exceeded", - "budget.alert", - "scope.escalation_denied", - ]), - ); - - const issued = body.entries.find((entry) => entry.action === "token.issued"); - assert.ok(issued?.metadata?.sponsorChain); - assert.deepEqual( - JSON.parse(issued.metadata.sponsorChain), - scenario.targetIdentity.sponsorChain, - ); - - const budgetExceeded = body.entries.find((entry) => entry.action === "budget.exceeded"); - assert.ok(budgetExceeded?.metadata?.sponsorChain); - assert.deepEqual( - JSON.parse(budgetExceeded.metadata.sponsorChain), - scenario.budgetExceededIdentity.sponsorChain, - ); - - const scopeEscalation = body.entries.find( - (entry) => entry.action === "scope.escalation_denied", - ); - assert.equal( - scopeEscalation?.metadata?.actionAttempted, - "relaycast:workspace:admin:billing", - ); - }); - - await t.test("filters audit entries by identity, action, and date range", async () => { - const byIdentityResponse = await scenario.harness.request( - "GET", - `/v1/audit?orgId=${ORG_ID}&identityId=${scenario.targetIdentity.id}`, - ); - const byIdentity = await assertJsonResponse(byIdentityResponse, 200); - - assert.equal(byIdentity.entries.length, 5); - assert.ok( - byIdentity.entries.every((entry) => entry.identityId === scenario.targetIdentity.id), - ); - - const byActionResponse = await scenario.harness.request( - "GET", - `/v1/audit?orgId=${ORG_ID}&action=scope.checked`, - ); - const byAction = await assertJsonResponse(byActionResponse, 200); - - assert.equal(byAction.entries.length, 1); - assert.equal(byAction.entries[0]?.action, "scope.checked"); - - const byRangeResponse = await scenario.harness.request( - "GET", - `/v1/audit?orgId=${ORG_ID}&from=2026-03-24T09:30:00.000Z&to=2026-03-24T12:15:00.000Z`, - ); - const byRange = await assertJsonResponse(byRangeResponse, 200); + await t.test( + "records base and extended audit actions with the full sponsorChain", + async () => { + const response = await scenario.harness.request( + "GET", + `/v1/audit?orgId=${ORG_ID}`, + ); + const body = await assertJsonResponse(response, 200); + + const actions = new Set(body.entries.map((entry) => entry.action)); + assert.deepEqual( + actions, + new Set([ + "token.issued", + "identity.created", + "scope.checked", + "scope.denied", + "budget.exceeded", + "budget.alert", + "scope.escalation_denied", + ]), + ); + + const issued = body.entries.find( + (entry) => entry.action === "token.issued", + ); + assert.ok(issued?.metadata?.sponsorChain); + assert.deepEqual( + JSON.parse(issued.metadata.sponsorChain), + scenario.targetIdentity.sponsorChain, + ); + + const budgetExceeded = body.entries.find( + (entry) => entry.action === "budget.exceeded", + ); + assert.ok(budgetExceeded?.metadata?.sponsorChain); + assert.deepEqual( + JSON.parse(budgetExceeded.metadata.sponsorChain), + scenario.budgetExceededIdentity.sponsorChain, + ); + + const scopeEscalation = body.entries.find( + (entry) => entry.action === "scope.escalation_denied", + ); + assert.equal( + scopeEscalation?.metadata?.actionAttempted, + "relaycast:workspace:admin:billing", + ); + }, + ); - assert.deepEqual( - new Set(byRange.entries.map((entry) => entry.action)), - new Set(["identity.created", "scope.checked", "scope.denied"]), - ); - }); + await t.test( + "filters audit entries by identity, action, and date range", + async () => { + const byIdentityResponse = await scenario.harness.request( + "GET", + `/v1/audit?orgId=${ORG_ID}&identityId=${scenario.targetIdentity.id}`, + ); + const byIdentity = await assertJsonResponse( + byIdentityResponse, + 200, + ); + + assert.equal(byIdentity.entries.length, 5); + assert.ok( + byIdentity.entries.every( + (entry) => entry.identityId === scenario.targetIdentity.id, + ), + ); + + const byActionResponse = await scenario.harness.request( + "GET", + `/v1/audit?orgId=${ORG_ID}&action=scope.checked`, + ); + const byAction = await assertJsonResponse( + byActionResponse, + 200, + ); + + assert.equal(byAction.entries.length, 1); + assert.equal(byAction.entries[0]?.action, "scope.checked"); + + const byRangeResponse = await scenario.harness.request( + "GET", + `/v1/audit?orgId=${ORG_ID}&from=2026-03-24T09:30:00.000Z&to=2026-03-24T12:15:00.000Z`, + ); + const byRange = await assertJsonResponse( + byRangeResponse, + 200, + ); + + assert.deepEqual( + new Set(byRange.entries.map((entry) => entry.action)), + new Set([ + "identity.created", + "scope.checked", + "scope.denied", + ]), + ); + }, + ); await t.test("exports audit entries as JSON and CSV", async () => { - const jsonResponse = await scenario.harness.request("POST", "/v1/audit/export", { - body: { - format: "json", - orgId: ORG_ID, + const jsonResponse = await scenario.harness.request( + "POST", + "/v1/audit/export", + { + body: { + format: "json", + orgId: ORG_ID, + }, }, - }); - const jsonBody = await assertJsonResponse(jsonResponse, 200); + ); + const jsonBody = await assertJsonResponse( + jsonResponse, + 200, + ); assert.equal(jsonBody.length, 7); assert.ok(jsonBody.some((entry) => entry.action === "budget.exceeded")); - assert.ok(jsonBody.some((entry) => entry.action === "scope.escalation_denied")); + assert.ok( + jsonBody.some((entry) => entry.action === "scope.escalation_denied"), + ); - const csvResponse = await scenario.harness.request("POST", "/v1/audit/export", { - body: { - format: "csv", - orgId: ORG_ID, + const csvResponse = await scenario.harness.request( + "POST", + "/v1/audit/export", + { + body: { + format: "csv", + orgId: ORG_ID, + }, }, - }); + ); assert.equal(csvResponse.status, 200); assert.match(csvResponse.headers.get("content-type") ?? "", /text\/csv/i); @@ -259,90 +307,117 @@ test("Audit & Observability E2E", async (t) => { assert.ok(csvRows.some((row) => row[1] === "scope.escalation_denied")); }); - await t.test("creates, lists, dispatches, and deletes audit webhooks", async (subtest) => { - const createResponse = await scenario.harness.request("POST", "/v1/audit/webhooks", { - body: { + await t.test( + "creates, lists, dispatches, and deletes audit webhooks", + async (subtest) => { + const createResponse = await scenario.harness.request( + "POST", + "/v1/audit/webhooks", + { + body: { + orgId: ORG_ID, + url: "https://audit.example.com/hooks/budget-alert", + events: ["budget.alert"], + secret: "whsec_audit_budget_alert", + }, + }, + ); + const createdWebhook = await assertJsonResponse( + createResponse, + 201, + ); + + assert.deepEqual(createdWebhook.events, ["budget.alert"]); + + const listResponse = await scenario.harness.request( + "GET", + `/v1/audit/webhooks?orgId=${ORG_ID}`, + ); + const listedWebhooks = await assertJsonResponse( + listResponse, + 200, + ); + + assert.equal(listedWebhooks.length, 1); + assert.equal(listedWebhooks[0]?.id, createdWebhook.id); + assert.equal(listedWebhooks[0]?.secret, "****lert"); + + const alertEntries = await scenario.harness.db.audit.query({ orgId: ORG_ID, - url: "https://audit.example.com/hooks/budget-alert", - events: ["budget.alert"], - secret: "whsec_audit_budget_alert", - }, - }); - const createdWebhook = await assertJsonResponse(createResponse, 201); - - assert.deepEqual(createdWebhook.events, ["budget.alert"]); - - const listResponse = await scenario.harness.request( - "GET", - `/v1/audit/webhooks?orgId=${ORG_ID}`, - ); - const listedWebhooks = await assertJsonResponse(listResponse, 200); - - assert.equal(listedWebhooks.length, 1); - assert.equal(listedWebhooks[0]?.id, createdWebhook.id); - assert.equal(listedWebhooks[0]?.secret, "****lert"); - - const alertEntries = await scenario.harness.db.audit.query({ - orgId: ORG_ID, - action: "budget.alert" as AuditAction, - limit: 10, - }); - const alertEntry = alertEntries.entries.find((entry) => entry.action === "budget.alert"); - assert.ok(alertEntry, "expected a budget.alert audit row"); - - const requests: Array<{ request: Request; body: string }> = []; - const originalFetch = globalThis.fetch; - - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const url = input instanceof Request ? input.url : input instanceof URL ? input.toString() : String(input); - if (url.startsWith("data:")) { - return originalFetch(input, init); - } - const request = input instanceof Request ? input : new Request(String(input), init); - const body = await request.text(); - requests.push({ request, body }); - return new Response(null, { status: 202 }); - }) as typeof globalThis.fetch; - - subtest.after(() => { - globalThis.fetch = originalFetch; - }); - - await dispatchWebhook(createdWebhook, alertEntry); - - assert.equal(requests.length, 1); - assert.equal(requests[0]?.request.url, createdWebhook.url); - assert.match(requests[0]?.request.headers.get("x-relayauth-signature") ?? "", /^sha256=/); - - const payload = JSON.parse(requests[0]?.body ?? "{}") as { - type: string; - entry: ObservedAuditEntry; - }; - - assert.equal(payload.type, "audit.event"); - assert.equal(payload.entry.action, "budget.alert"); - assert.deepEqual( - JSON.parse(payload.entry.metadata?.sponsorChain ?? "[]"), - scenario.budgetAlertIdentity.sponsorChain, - ); - - const deleteResponse = await scenario.harness.request( - "DELETE", - `/v1/audit/webhooks/${createdWebhook.id}?orgId=${ORG_ID}`, - ); - assert.equal(deleteResponse.status, 204); - - const listAfterDeleteResponse = await scenario.harness.request( - "GET", - `/v1/audit/webhooks?orgId=${ORG_ID}`, - ); - const listAfterDelete = await assertJsonResponse( - listAfterDeleteResponse, - 200, - ); + action: "budget.alert" as AuditAction, + limit: 10, + }); + const alertEntry = alertEntries.entries.find( + (entry) => entry.action === "budget.alert", + ); + assert.ok(alertEntry, "expected a budget.alert audit row"); + + const requests: Array<{ request: Request; body: string }> = []; + const originalFetch = globalThis.fetch; + + globalThis.fetch = (async ( + input: RequestInfo | URL, + init?: RequestInit, + ) => { + const url = + input instanceof Request + ? input.url + : input instanceof URL + ? input.toString() + : String(input); + if (url.startsWith("data:")) { + return originalFetch(input, init); + } + const request = + input instanceof Request ? input : new Request(String(input), init); + const body = await request.text(); + requests.push({ request, body }); + return new Response(null, { status: 202 }); + }) as typeof globalThis.fetch; + + subtest.after(() => { + globalThis.fetch = originalFetch; + }); + + await dispatchWebhook(createdWebhook, alertEntry); + + assert.equal(requests.length, 1); + assert.equal(requests[0]?.request.url, createdWebhook.url); + assert.match( + requests[0]?.request.headers.get("x-relayauth-signature") ?? "", + /^sha256=/, + ); + + const payload = JSON.parse(requests[0]?.body ?? "{}") as { + type: string; + entry: ObservedAuditEntry; + }; - assert.deepEqual(listAfterDelete, []); - }); + assert.equal(payload.type, "audit.event"); + assert.equal(payload.entry.action, "budget.alert"); + assert.deepEqual( + JSON.parse(payload.entry.metadata?.sponsorChain ?? "[]"), + scenario.budgetAlertIdentity.sponsorChain, + ); + + const deleteResponse = await scenario.harness.request( + "DELETE", + `/v1/audit/webhooks/${createdWebhook.id}?orgId=${ORG_ID}`, + ); + assert.equal(deleteResponse.status, 204); + + const listAfterDeleteResponse = await scenario.harness.request( + "GET", + `/v1/audit/webhooks?orgId=${ORG_ID}`, + ); + const listAfterDelete = await assertJsonResponse( + listAfterDeleteResponse, + 200, + ); + + assert.deepEqual(listAfterDelete, []); + }, + ); await t.test("returns identity activity scoped to one identity", async () => { const response = await scenario.harness.request( @@ -352,22 +427,35 @@ test("Audit & Observability E2E", async (t) => { const body = await assertJsonResponse(response, 200); assert.equal(body.entries.length, 5); - assert.ok(body.entries.every((entry) => entry.identityId === scenario.targetIdentity.id)); + assert.ok( + body.entries.every( + (entry) => entry.identityId === scenario.targetIdentity.id, + ), + ); assert.deepEqual(body.sponsorChain, scenario.targetIdentity.sponsorChain); assert.equal(body.subAgents[0]?.id, scenario.childIdentity.id); - assert.equal(body.subAgents[0]?.children[0]?.id, scenario.grandchildIdentity.id); + assert.equal( + body.subAgents[0]?.children[0]?.id, + scenario.grandchildIdentity.id, + ); }); - await t.test("reports dashboard stats from the actions performed", async () => { - const response = await scenario.harness.request("GET", "/v1/stats"); - const body = await assertJsonResponse(response, 200); - - assert.equal(body.tokensIssued, 1); - assert.equal(body.scopeChecks, 1); - assert.equal(body.scopeDenials, 1); - assert.equal(body.activeIdentities, 4); - assert.equal(body.suspendedIdentities, 1); - }); + await t.test( + "reports dashboard stats from the actions performed", + async () => { + const response = await scenario.harness.request("GET", "/v1/stats"); + const body = await assertJsonResponse( + response, + 200, + ); + + assert.equal(body.tokensIssued, 1); + assert.equal(body.scopeChecks, 1); + assert.equal(body.scopeDenials, 1); + assert.equal(body.activeIdentities, 4); + assert.equal(body.suspendedIdentities, 1); + }, + ); await t.test("purges retained audit rows older than the cutoff", async () => { const oldTimestamp = daysAgo(120); @@ -386,7 +474,9 @@ test("Audit & Observability E2E", async (t) => { }, timestamp: oldTimestamp, }); - await scenario.harness.db.DB.prepare("UPDATE audit_logs SET created_at = ? WHERE id = ?") + await scenario.harness.db.DB.prepare( + "UPDATE audit_logs SET created_at = ? WHERE id = ?", + ) .bind(oldTimestamp, "aud_retention_old") .run(); @@ -399,9 +489,9 @@ test("Audit & Observability E2E", async (t) => { const afterPurge = await countExpiredEntries(scenario.harness.db, 90); assert.deepEqual(afterPurge, { expiredCount: 0 }); assert.equal( - (await scenario.harness.db.audit.query({ orgId: ORG_ID, limit: 100 })).entries.some( - (row) => row.id === "aud_retention_old", - ), + ( + await scenario.harness.db.audit.query({ orgId: ORG_ID, limit: 100 }) + ).entries.some((row) => row.id === "aud_retention_old"), false, ); }); @@ -412,7 +502,11 @@ async function seedScenario(): Promise { id: "agent_observed_target", name: "Observed Target", sponsorId: "agent_observed_parent", - sponsorChain: ["user_observed_owner", "agent_observed_parent", "agent_observed_target"], + sponsorChain: [ + "user_observed_owner", + "agent_observed_parent", + "agent_observed_target", + ], budget: { maxActionsPerHour: 20, maxCostPerDay: 50, @@ -444,7 +538,11 @@ async function seedScenario(): Promise { id: "agent_budget_exceeded", name: "Budget Exceeded Agent", sponsorId: "agent_observed_parent", - sponsorChain: ["user_observed_owner", "agent_observed_parent", "agent_budget_exceeded"], + sponsorChain: [ + "user_observed_owner", + "agent_observed_parent", + "agent_budget_exceeded", + ], scopes: ["relaycast:workspace:write:*"], budget: { maxActionsPerHour: 10, @@ -462,7 +560,11 @@ async function seedScenario(): Promise { id: "agent_budget_alert", name: "Budget Alert Agent", sponsorId: "agent_observed_parent", - sponsorChain: ["user_observed_owner", "agent_observed_parent", "agent_budget_alert"], + sponsorChain: [ + "user_observed_owner", + "agent_observed_parent", + "agent_budget_alert", + ], scopes: ["relaycast:workspace:write:*"], budget: { maxActionsPerHour: 10, @@ -569,11 +671,15 @@ async function seedScenario(): Promise { }; } -async function createAuditHarness(identities: StoredIdentity[]): Promise { +async function createAuditHarness( + identities: StoredIdentity[], +): Promise { const state: HarnessState = { auditLogs: [], auditWebhooks: new Map(), - identities: new Map(identities.map((identity) => [identity.id, clone(identity)])), + identities: new Map( + identities.map((identity) => [identity.id, clone(identity)]), + ), executed: [], }; @@ -629,7 +735,9 @@ async function createAuditHarness(identities: StoredIdentity[]): Promise(statements: D1PreparedStatement[]) { - return Promise.all(statements.map((statement) => statement.run())) as Awaited; + return Promise.all( + statements.map((statement) => statement.run()), + ) as Awaited; }, async exec() { return { count: 0, duration: 0 }; @@ -714,15 +822,30 @@ async function createAuditHarness(identities: StoredIdentity[]): Promise typeof orgId !== "string" || record.orgId === orgId) .sort( (left, right) => - right.createdAt?.localeCompare(left.createdAt ?? "") ?? right.id.localeCompare(left.id), + right.createdAt?.localeCompare(left.createdAt ?? "") ?? + right.id.localeCompare(left.id), ) .map((record) => ({ id: record.id, @@ -754,7 +878,10 @@ function resolveAll(state: HarnessState, query: string, params: unknown[]): unkn })); } - if (/\bfrom identities\b/.test(normalized) && /\bgroup by status\b/.test(normalized)) { + if ( + /\bfrom identities\b/.test(normalized) && + /\bgroup by status\b/.test(normalized) + ) { const [orgId] = params; const counts = new Map(); @@ -768,16 +895,22 @@ function resolveAll(state: HarnessState, query: string, params: unknown[]): unkn } } - return Array.from(counts.entries()).map(([status, count]) => ({ status, count })); + return Array.from(counts.entries()).map(([status, count]) => ({ + status, + count, + })); } if ( - /\bselect roles, roles_json\b/.test(normalized) - && /\bfrom identities\b/.test(normalized) - && /\bwhere id = \?/.test(normalized) + /\bselect roles, roles_json\b/.test(normalized) && + /\bfrom identities\b/.test(normalized) && + /\bwhere id = \?/.test(normalized) ) { const [identityId] = params; - const identity = typeof identityId === "string" ? state.identities.get(identityId) : undefined; + const identity = + typeof identityId === "string" + ? state.identities.get(identityId) + : undefined; if (!identity) { return []; } @@ -790,17 +923,21 @@ function resolveAll(state: HarnessState, query: string, params: unknown[]): unkn ]; } - if (/\bfrom identities\b/.test(normalized) && /\bsponsor_id = \?/.test(normalized)) { + if ( + /\bfrom identities\b/.test(normalized) && + /\bsponsor_id = \?/.test(normalized) + ) { const [orgId, sponsorId] = params; return Array.from(state.identities.values()) .filter( (identity) => - (typeof orgId !== "string" || identity.orgId === orgId) - && (typeof sponsorId !== "string" || identity.sponsorId === sponsorId), + (typeof orgId !== "string" || identity.orgId === orgId) && + (typeof sponsorId !== "string" || identity.sponsorId === sponsorId), ) .sort( (left, right) => - right.createdAt.localeCompare(left.createdAt) || right.id.localeCompare(left.id), + right.createdAt.localeCompare(left.createdAt) || + right.id.localeCompare(left.id), ) .map((identity) => ({ id: identity.id, @@ -814,12 +951,14 @@ function resolveAll(state: HarnessState, query: string, params: unknown[]): unkn } if ( - /\bfrom identities\b/.test(normalized) - && /\bwhere org_id = \? and id = \?/.test(normalized) + /\bfrom identities\b/.test(normalized) && + /\bwhere org_id = \? and id = \?/.test(normalized) ) { const [orgId, identityId] = params; const identity = - typeof identityId === "string" ? state.identities.get(identityId) : undefined; + typeof identityId === "string" + ? state.identities.get(identityId) + : undefined; if (!identity || (typeof orgId === "string" && identity.orgId !== orgId)) { return []; @@ -880,7 +1019,8 @@ async function executeRun( if (/\bdelete from audit_webhooks\b/.test(normalized)) { const [orgId, id] = params; - const existing = typeof id === "string" ? state.auditWebhooks.get(id) : undefined; + const existing = + typeof id === "string" ? state.auditWebhooks.get(id) : undefined; const deleted = existing && typeof orgId === "string" && existing.orgId === orgId ? state.auditWebhooks.delete(id) @@ -896,7 +1036,10 @@ async function executeRun( }; } - if (/\bdelete from audit_logs\b/.test(normalized) && /\bcreated_at < \?/.test(normalized)) { + if ( + /\bdelete from audit_logs\b/.test(normalized) && + /\bcreated_at < \?/.test(normalized) + ) { const before = state.auditLogs.length; const cutoff = typeof params[0] === "string" ? params[0] : ""; state.auditLogs = state.auditLogs.filter((row) => row.created_at >= cutoff); @@ -935,13 +1078,37 @@ function selectAuditLogs( const clausePositions = [ { type: "orgId", index: normalized.search(/\borg_id\s*=\s*\?/i), arity: 1 }, - { type: "identityId", index: normalized.search(/\bidentity_id\s*=\s*\?/i), arity: 1 }, - { type: "action", index: normalized.search(/\baction\s*=\s*\?/i), arity: 1 }, - { type: "workspaceId", index: normalized.search(/\bworkspace_id\s*=\s*\?/i), arity: 1 }, + { + type: "identityId", + index: normalized.search(/\bidentity_id\s*=\s*\?/i), + arity: 1, + }, + { + type: "action", + index: normalized.search(/\baction\s*=\s*\?/i), + arity: 1, + }, + { + type: "workspaceId", + index: normalized.search(/\bworkspace_id\s*=\s*\?/i), + arity: 1, + }, { type: "plane", index: normalized.search(/\bplane\s*=\s*\?/i), arity: 1 }, - { type: "result", index: normalized.search(/\bresult\s*=\s*\?/i), arity: 1 }, - { type: "from", index: normalized.search(/\btimestamp\s*>=\s*\?/i), arity: 1 }, - { type: "to", index: normalized.search(/\btimestamp\s*<\s*\?(?!\s*or)/i), arity: 1 }, + { + type: "result", + index: normalized.search(/\bresult\s*=\s*\?/i), + arity: 1, + }, + { + type: "from", + index: normalized.search(/\btimestamp\s*>=\s*\?/i), + arity: 1, + }, + { + type: "to", + index: normalized.search(/\btimestamp\s*<\s*\?(?!\s*or)/i), + arity: 1, + }, { type: "cursor", index: normalized.search( @@ -1001,12 +1168,16 @@ function selectAuditLogs( } const cursor = values.get("cursor"); - if (cursor && typeof cursor[0] === "string" && typeof cursor[2] === "string") { + if ( + cursor && + typeof cursor[0] === "string" && + typeof cursor[2] === "string" + ) { const [cursorTimestamp, , cursorId] = cursor; filtered = filtered.filter( (row) => - row.timestamp < cursorTimestamp - || (row.timestamp === cursorTimestamp && row.id < cursorId), + row.timestamp < cursorTimestamp || + (row.timestamp === cursorTimestamp && row.id < cursorId), ); } @@ -1029,12 +1200,18 @@ function summarizeAuditCounts( let from: string | undefined; let to: string | undefined; - if (/\btimestamp >= \?\b/.test(normalized) && typeof rest[offset] === "string") { + if ( + /\btimestamp >= \?\b/.test(normalized) && + typeof rest[offset] === "string" + ) { from = rest[offset] as string; offset += 1; } - if (/\btimestamp < \?\b/.test(normalized) && typeof rest[offset] === "string") { + if ( + /\btimestamp < \?\b/.test(normalized) && + typeof rest[offset] === "string" + ) { to = rest[offset] as string; } @@ -1054,12 +1231,12 @@ function summarizeAuditCounts( } const shouldInclude = - row.action === "token.issued" - || row.action === "token.revoked" - || row.action === "token.refreshed" - || row.action === "scope.denied" - || (row.action === "scope.checked" - && (row.result === "allowed" || row.result === "denied")); + row.action === "token.issued" || + row.action === "token.revoked" || + row.action === "token.refreshed" || + row.action === "scope.denied" || + (row.action === "scope.checked" && + (row.result === "allowed" || row.result === "denied")); if (!shouldInclude) { continue; @@ -1068,7 +1245,10 @@ function summarizeAuditCounts( counts.set(row.action, (counts.get(row.action) ?? 0) + 1); } - return Array.from(counts.entries()).map(([action, count]) => ({ action, count })); + return Array.from(counts.entries()).map(([action, count]) => ({ + action, + count, + })); } function countExpiredRows(rows: AuditLogRow[], params: unknown[]): number { @@ -1092,7 +1272,8 @@ function toAuditLogRow(params: unknown[]): AuditLogRow { timestamp, ] = params; - const ts = typeof timestamp === "string" ? timestamp : new Date().toISOString(); + const ts = + typeof timestamp === "string" ? timestamp : new Date().toISOString(); return { id: String(id), @@ -1121,7 +1302,9 @@ function toObservedAuditEntry(row: AuditLogRow): ObservedAuditEntry { ...(row.plane ? { plane: row.plane } : {}), ...(row.resource ? { resource: row.resource } : {}), result: row.result, - ...(row.metadata_json ? { metadata: JSON.parse(row.metadata_json) as Record } : {}), + ...(row.metadata_json + ? { metadata: JSON.parse(row.metadata_json) as Record } + : {}), ...(row.ip ? { ip: row.ip } : {}), ...(row.user_agent ? { userAgent: row.user_agent } : {}), timestamp: row.timestamp, @@ -1170,7 +1353,12 @@ function toIdentityRow(identity: StoredIdentity) { sponsor_chain_json: JSON.stringify(identity.sponsorChain), workspaceId: identity.workspaceId, workspace_id: identity.workspaceId, - ...(identity.budget ? { budget: identity.budget, budget_json: JSON.stringify(identity.budget) } : {}), + ...(identity.budget + ? { + budget: identity.budget, + budget_json: JSON.stringify(identity.budget), + } + : {}), ...(identity.budgetUsage ? { budgetUsage: identity.budgetUsage, @@ -1181,7 +1369,9 @@ function toIdentityRow(identity: StoredIdentity) { }; } -function createStoredIdentity(overrides: Partial = {}): StoredIdentity { +function createStoredIdentity( + overrides: Partial = {}, +): StoredIdentity { const base = generateTestIdentity({ id: overrides.id ?? `agent_${Math.random().toString(16).slice(2)}`, name: overrides.name ?? "Audit Identity", @@ -1202,7 +1392,9 @@ function createStoredIdentity(overrides: Partial = {}): StoredId sponsorChain: overrides.sponsorChain ?? [sponsorId, base.id], workspaceId: overrides.workspaceId ?? WORKSPACE_ID, ...(overrides.budget !== undefined ? { budget: overrides.budget } : {}), - ...(overrides.budgetUsage !== undefined ? { budgetUsage: overrides.budgetUsage } : {}), + ...(overrides.budgetUsage !== undefined + ? { budgetUsage: overrides.budgetUsage } + : {}), }; } @@ -1212,7 +1404,9 @@ function createManualAuditWrite( overrides: Partial = {}, ): AuditWriteInput { const defaultResult: AuditEntry["result"] = - action === "scope.denied" || action === "scope.escalation_denied" ? "denied" : "allowed"; + action === "scope.denied" || action === "scope.escalation_denied" + ? "denied" + : "allowed"; return { id: overrides.id, @@ -1238,7 +1432,10 @@ function createManualAuditWrite( } function compareAuditRowsDesc(left: AuditLogRow, right: AuditLogRow): number { - return right.timestamp.localeCompare(left.timestamp) || right.id.localeCompare(left.id); + return ( + right.timestamp.localeCompare(left.timestamp) || + right.id.localeCompare(left.id) + ); } function normalizeSql(query: string): string { @@ -1254,9 +1451,9 @@ function parseCsvLine(line: string): string[] { const character = line[index]; if (inQuotes) { - if (character === "\"") { - if (line[index + 1] === "\"") { - current += "\""; + if (character === '"') { + if (line[index + 1] === '"') { + current += '"'; index += 1; } else { inQuotes = false; @@ -1267,7 +1464,7 @@ function parseCsvLine(line: string): string[] { continue; } - if (character === "\"") { + if (character === '"') { inQuotes = true; continue; } diff --git a/packages/server/src/__tests__/e2e/rbac.test.ts b/packages/server/src/__tests__/e2e/rbac.test.ts index 973f6fa..d2e7c1e 100644 --- a/packages/server/src/__tests__/e2e/rbac.test.ts +++ b/packages/server/src/__tests__/e2e/rbac.test.ts @@ -5,7 +5,10 @@ import { Hono } from "hono"; import type { StoredIdentity } from "../../storage/identity-types.js"; import { writeAuditEntry } from "../../engine/audit-logger.js"; -import { checkAccess, evaluatePermissions } from "../../engine/policy-evaluation.js"; +import { + checkAccess, + evaluatePermissions, +} from "../../engine/policy-evaluation.js"; import { getInheritanceChain } from "../../engine/scope-inheritance.js"; import type { AppEnv } from "../../env.js"; import { requireScope } from "../../middleware/scope.js"; @@ -18,8 +21,14 @@ import { generateTestToken, } from "../test-helpers.js"; import { RelayAuthError } from "../../../../sdk/typescript/src/errors.js"; -import { matchesAny, validateSubset } from "../../../../sdk/typescript/src/scope-matcher.js"; -import { parseScope, validateScope } from "../../../../sdk/typescript/src/scope-parser.js"; +import { + matchesAny, + validateSubset, +} from "../../../../sdk/typescript/src/scope-matcher.js"; +import { + parseScope, + validateScope, +} from "../../../../sdk/typescript/src/scope-parser.js"; import { authenticate } from "../../lib/auth.js"; type StoredPolicy = Policy & { deletedAt?: string }; @@ -142,321 +151,393 @@ test("Scopes & RBAC E2E", async (t) => { ); }); - await t.test("2. creates a role with relaycast read and write scopes", async () => { - const response = await harness.request("POST", "/v1/roles", { - body: { - name: "channel-operator", - description: "Can read and write relaycast channels", - scopes: [READ_SCOPE, WRITE_SCOPE], - }, - }); + await t.test( + "2. creates a role with relaycast read and write scopes", + async () => { + const response = await harness.request("POST", "/v1/roles", { + body: { + name: "channel-operator", + description: "Can read and write relaycast channels", + scopes: [READ_SCOPE, WRITE_SCOPE], + }, + }); - createdRole = await assertJsonResponse(response, 201); - assert.deepEqual(sortStrings(createdRole.scopes), sortStrings([READ_SCOPE, WRITE_SCOPE])); - }); + createdRole = await assertJsonResponse(response, 201); + assert.deepEqual( + sortStrings(createdRole.scopes), + sortStrings([READ_SCOPE, WRITE_SCOPE]), + ); + }, + ); await t.test("3. creates an identity and assigns the role", async () => { assert.ok(createdRole, "expected role to exist before assignment"); - const response = await harness.request("POST", `/v1/identities/${primaryIdentity.id}/roles`, { - body: { roleId: createdRole.id }, - }); + const response = await harness.request( + "POST", + `/v1/identities/${primaryIdentity.id}/roles`, + { + body: { roleId: createdRole.id }, + }, + ); const assigned = await assertJsonResponse(response, 201); assert.deepEqual(assigned.roles, [createdRole.id]); }); - await t.test("4. token with the role scopes can access relaycast:channel:read:general", async () => { - const token = await harness.issueEffectiveToken(primaryIdentity.id); - const response = await requestProtectedScope( - harness.app.bindings, - READ_SCOPE, - token, - "/channels/general", - ); + await t.test( + "4. token with the role scopes can access relaycast:channel:read:general", + async () => { + const token = await harness.issueEffectiveToken(primaryIdentity.id); + const response = await requestProtectedScope( + harness.app.bindings, + READ_SCOPE, + token, + "/channels/general", + ); - await assertJsonResponse<{ ok: boolean }>(response, 200, (body) => { - assert.equal(body.ok, true); - }); - }); + await assertJsonResponse<{ ok: boolean }>(response, 200, (body) => { + assert.equal(body.ok, true); + }); + }, + ); - await t.test("5. token with the role scopes cannot access relayfile:fs:write:*", async () => { - const token = await harness.issueEffectiveToken(primaryIdentity.id); - const response = await requestProtectedScope( - harness.app.bindings, - FILE_WRITE_SCOPE, - token, - "/files/write", - ); + await t.test( + "5. token with the role scopes cannot access relayfile:fs:write:*", + async () => { + const token = await harness.issueEffectiveToken(primaryIdentity.id); + const response = await requestProtectedScope( + harness.app.bindings, + FILE_WRITE_SCOPE, + token, + "/files/write", + ); - await assertJsonResponse<{ error: string; code?: string }>(response, 403, (body) => { - assert.equal(body.code, "insufficient_scope"); - assert.match(body.error, /insufficient scope/i); - }); - }); + await assertJsonResponse<{ error: string; code?: string }>( + response, + 403, + (body) => { + assert.equal(body.code, "insufficient_scope"); + assert.match(body.error, /insufficient scope/i); + }, + ); + }, + ); - await t.test("6. creates a deny policy that blocks relaycast:channel:write:* for the identity", async () => { - const response = await harness.request("POST", "/v1/policies", { - body: { - name: "deny-primary-write", - effect: "deny", - scopes: [WRITE_SCOPE], - conditions: [ - { - type: "identity", - operator: "eq", - value: primaryIdentity.id, - }, - ], - priority: 800, - }, - }); + await t.test( + "6. creates a deny policy that blocks relaycast:channel:write:* for the identity", + async () => { + const response = await harness.request("POST", "/v1/policies", { + body: { + name: "deny-primary-write", + effect: "deny", + scopes: [WRITE_SCOPE], + conditions: [ + { + type: "identity", + operator: "eq", + value: primaryIdentity.id, + }, + ], + priority: 800, + }, + }); - denyPolicy = await assertJsonResponse(response, 201); - assert.equal(denyPolicy.effect, "deny"); - }); + denyPolicy = await assertJsonResponse(response, 201); + assert.equal(denyPolicy.effect, "deny"); + }, + ); - await t.test("7. identity can still read but cannot write after the deny policy", async () => { - const readDecision = await checkAccess( - harness.db, - primaryIdentity.id, - ORG_ID, - READ_GENERAL_SCOPE, - ); - const writeDecision = await checkAccess( - harness.db, - primaryIdentity.id, - ORG_ID, - WRITE_GENERAL_SCOPE, - ); + await t.test( + "7. identity can still read but cannot write after the deny policy", + async () => { + const readDecision = await checkAccess( + harness.db, + primaryIdentity.id, + ORG_ID, + READ_GENERAL_SCOPE, + ); + const writeDecision = await checkAccess( + harness.db, + primaryIdentity.id, + ORG_ID, + WRITE_GENERAL_SCOPE, + ); - assert.deepEqual(readDecision, { - allowed: true, - reason: "scope_allowed", - }); - assert.equal(writeDecision.allowed, false); - assert.equal(writeDecision.reason, "policy_denied"); - assert.equal(writeDecision.matchedPolicy, denyPolicy?.id); - - const effective = await evaluatePermissions(harness.db, primaryIdentity.id, ORG_ID); - assert.deepEqual( - sortStrings(effective.effectiveScopes), - sortStrings([READ_SCOPE]), - ); - }); + assert.deepEqual(readDecision, { + allowed: true, + reason: "scope_allowed", + }); + assert.equal(writeDecision.allowed, false); + assert.equal(writeDecision.reason, "policy_denied"); + assert.equal(writeDecision.matchedPolicy, denyPolicy?.id); - await t.test("8. scope inheritance narrows a child request to the parent boundary", async () => { - const narrowed = matchesAny( - [READ_SCOPE, WRITE_SCOPE, FILE_WRITE_SCOPE], - [READ_SCOPE, WRITE_SCOPE], - ); - assert.deepEqual(sortStrings(narrowed.matched), sortStrings([READ_SCOPE, WRITE_SCOPE])); - assert.deepEqual(narrowed.denied, [FILE_WRITE_SCOPE]); + const effective = await evaluatePermissions( + harness.db, + primaryIdentity.id, + ORG_ID, + ); + assert.deepEqual( + sortStrings(effective.effectiveScopes), + sortStrings([READ_SCOPE]), + ); + }, + ); - const chain = await getInheritanceChain(harness.db, childIdentity.id); + await t.test( + "8. scope inheritance narrows a child request to the parent boundary", + async () => { + const narrowed = matchesAny( + [READ_SCOPE, WRITE_SCOPE, FILE_WRITE_SCOPE], + [READ_SCOPE, WRITE_SCOPE], + ); + assert.deepEqual( + sortStrings(narrowed.matched), + sortStrings([READ_SCOPE, WRITE_SCOPE]), + ); + assert.deepEqual(narrowed.denied, [FILE_WRITE_SCOPE]); - assert.deepEqual(chain.org.scopes, ["relaycast:*:*:*"]); - assert.deepEqual(chain.workspace.scopes, ["relaycast:channel:*:*"]); - assert.deepEqual( - sortStrings(chain.agent.scopes), - sortStrings([READ_SCOPE, WRITE_SCOPE]), - ); - assert.equal(chain.agent.scopes.includes(FILE_WRITE_SCOPE), false); - }); + const chain = await getInheritanceChain(harness.db, childIdentity.id); - await t.test("9. higher-priority allow overrides a lower-priority deny", async () => { - const lowerDeny = await harness.request("POST", "/v1/policies", { - body: { - name: "priority-lower-deny", - effect: "deny", - scopes: [PRIORITY_SCOPE], - conditions: [ - { - type: "identity", - operator: "eq", - value: priorityIdentity.id, - }, - ], - priority: 100, - }, - }); - const lowerDenyPolicy = await assertJsonResponse(lowerDeny, 201); - - const higherAllow = await harness.request("POST", "/v1/policies", { - body: { - name: "priority-higher-allow", - effect: "allow", - scopes: [PRIORITY_SCOPE], - conditions: [ - { - type: "identity", - operator: "eq", - value: priorityIdentity.id, - }, - ], - priority: 900, - }, - }); - const higherAllowPolicy = await assertJsonResponse(higherAllow, 201); + assert.deepEqual(chain.org.scopes, ["relaycast:*:*:*"]); + assert.deepEqual(chain.workspace.scopes, ["relaycast:channel:*:*"]); + assert.deepEqual( + sortStrings(chain.agent.scopes), + sortStrings([READ_SCOPE, WRITE_SCOPE]), + ); + assert.equal(chain.agent.scopes.includes(FILE_WRITE_SCOPE), false); + }, + ); - const decision = await checkAccess( - harness.db, - priorityIdentity.id, - ORG_ID, - PRIORITY_SCOPE, - ); - const evaluation = await evaluatePermissions(harness.db, priorityIdentity.id, ORG_ID); + await t.test( + "9. higher-priority allow overrides a lower-priority deny", + async () => { + const lowerDeny = await harness.request("POST", "/v1/policies", { + body: { + name: "priority-lower-deny", + effect: "deny", + scopes: [PRIORITY_SCOPE], + conditions: [ + { + type: "identity", + operator: "eq", + value: priorityIdentity.id, + }, + ], + priority: 100, + }, + }); + const lowerDenyPolicy = await assertJsonResponse(lowerDeny, 201); + + const higherAllow = await harness.request("POST", "/v1/policies", { + body: { + name: "priority-higher-allow", + effect: "allow", + scopes: [PRIORITY_SCOPE], + conditions: [ + { + type: "identity", + operator: "eq", + value: priorityIdentity.id, + }, + ], + priority: 900, + }, + }); + const higherAllowPolicy = await assertJsonResponse( + higherAllow, + 201, + ); - assert.deepEqual(decision, { - allowed: true, - reason: "scope_allowed", - }); - assert.equal(evaluation.effectiveScopes.includes(PRIORITY_SCOPE), true); - assert.deepEqual( - evaluation.appliedPolicies.map((policy) => policy.id), - [higherAllowPolicy.id], - ); - }); + const decision = await checkAccess( + harness.db, + priorityIdentity.id, + ORG_ID, + PRIORITY_SCOPE, + ); + const evaluation = await evaluatePermissions( + harness.db, + priorityIdentity.id, + ORG_ID, + ); - await t.test("10. scope middleware returns 403 for insufficient scope", async () => { - const token = generateTestToken({ - sub: primaryIdentity.id, - org: ORG_ID, - wks: WORKSPACE_ID, - scopes: [READ_SCOPE], - sponsorId: primaryIdentity.sponsorId, - sponsorChain: primaryIdentity.sponsorChain, - }); + assert.deepEqual(decision, { + allowed: true, + reason: "scope_allowed", + }); + assert.equal(evaluation.effectiveScopes.includes(PRIORITY_SCOPE), true); + assert.deepEqual( + evaluation.appliedPolicies.map((policy) => policy.id), + [higherAllowPolicy.id], + ); + }, + ); - const response = await requestProtectedScope( - harness.app.bindings, - WRITE_SCOPE, - token, - "/channels/write", - ); + await t.test( + "10. scope middleware returns 403 for insufficient scope", + async () => { + const token = generateTestToken({ + sub: primaryIdentity.id, + org: ORG_ID, + wks: WORKSPACE_ID, + scopes: [READ_SCOPE], + sponsorId: primaryIdentity.sponsorId, + sponsorChain: primaryIdentity.sponsorChain, + }); - await assertJsonResponse<{ error: string; code?: string }>(response, 403, (body) => { - assert.equal(body.code, "insufficient_scope"); - assert.match(body.error, /requires all of/i); - }); - }); + const response = await requestProtectedScope( + harness.app.bindings, + WRITE_SCOPE, + token, + "/channels/write", + ); - await t.test("11. deleting the deny policy restores write access, then removing the role revokes inherited access", async () => { - assert.ok(denyPolicy, "expected deny policy to exist before cleanup"); - assert.ok(createdRole, "expected role to exist before cleanup"); + await assertJsonResponse<{ error: string; code?: string }>( + response, + 403, + (body) => { + assert.equal(body.code, "insufficient_scope"); + assert.match(body.error, /requires all of/i); + }, + ); + }, + ); - const deletePolicyResponse = await harness.request("DELETE", `/v1/policies/${denyPolicy.id}`); - assert.equal(deletePolicyResponse.status, 204); + await t.test( + "11. deleting the deny policy restores write access, then removing the role revokes inherited access", + async () => { + assert.ok(denyPolicy, "expected deny policy to exist before cleanup"); + assert.ok(createdRole, "expected role to exist before cleanup"); - const restoredWrite = await checkAccess( - harness.db, - primaryIdentity.id, - ORG_ID, - WRITE_GENERAL_SCOPE, - ); - assert.deepEqual(restoredWrite, { - allowed: true, - reason: "scope_allowed", - }); + const deletePolicyResponse = await harness.request( + "DELETE", + `/v1/policies/${denyPolicy.id}`, + ); + assert.equal(deletePolicyResponse.status, 204); - const removeRoleResponse = await harness.request( - "DELETE", - `/v1/identities/${primaryIdentity.id}/roles/${createdRole.id}`, - ); - assert.equal(removeRoleResponse.status, 204); + const restoredWrite = await checkAccess( + harness.db, + primaryIdentity.id, + ORG_ID, + WRITE_GENERAL_SCOPE, + ); + assert.deepEqual(restoredWrite, { + allowed: true, + reason: "scope_allowed", + }); - const afterRoleRemoval = await checkAccess( - harness.db, - primaryIdentity.id, - ORG_ID, - READ_GENERAL_SCOPE, - ); - assert.deepEqual(afterRoleRemoval, { - allowed: false, - reason: "implicit_deny", - }); + const removeRoleResponse = await harness.request( + "DELETE", + `/v1/identities/${primaryIdentity.id}/roles/${createdRole.id}`, + ); + assert.equal(removeRoleResponse.status, 204); - const deleteRoleResponse = await harness.request("DELETE", `/v1/roles/${createdRole.id}`); - assert.equal(deleteRoleResponse.status, 204); - }); + const afterRoleRemoval = await checkAccess( + harness.db, + primaryIdentity.id, + ORG_ID, + READ_GENERAL_SCOPE, + ); + assert.deepEqual(afterRoleRemoval, { + allowed: false, + reason: "implicit_deny", + }); - await t.test("budget exceeded denies access with a clear reason and records an audit event", async () => { - const decision = await checkAccess( - harness.db, - budgetIdentity.id, - ORG_ID, - READ_GENERAL_SCOPE, - ); + const deleteRoleResponse = await harness.request( + "DELETE", + `/v1/roles/${createdRole.id}`, + ); + assert.equal(deleteRoleResponse.status, 204); + }, + ); - assert.deepEqual(decision, { - allowed: false, - reason: "budget_exceeded", - }); + await t.test( + "budget exceeded denies access with a clear reason and records an audit event", + async () => { + const decision = await checkAccess( + harness.db, + budgetIdentity.id, + ORG_ID, + READ_GENERAL_SCOPE, + ); - const auditEntries = await harness.db.audit.query({ - orgId: ORG_ID, - identityId: budgetIdentity.id, - action: "budget.exceeded" as AuditAction, - limit: 10, - }); - const audit = auditEntries.entries.find( - (entry) => - entry.action === "budget.exceeded" - && entry.resource === READ_GENERAL_SCOPE, - ); + assert.deepEqual(decision, { + allowed: false, + reason: "budget_exceeded", + }); - assert.ok(audit, "expected a budget.exceeded audit log"); - assert.equal(audit?.result, "denied"); - assert.equal(audit?.metadata.actionAttempted, READ_GENERAL_SCOPE); - }); + const auditEntries = await harness.db.audit.query({ + orgId: ORG_ID, + identityId: budgetIdentity.id, + action: "budget.exceeded" as AuditAction, + limit: 10, + }); + const audit = auditEntries.entries.find( + (entry) => + entry.action === "budget.exceeded" && + entry.resource === READ_GENERAL_SCOPE, + ); - await t.test("scope escalation attempt returns 403 and writes a scope.escalation_denied audit event", async () => { - const escalationApp = createScopeIssuanceApp(harness.app.storage); - const parentToken = generateTestToken({ - sub: primaryIdentity.id, - org: ORG_ID, - wks: WORKSPACE_ID, - scopes: [READ_SCOPE, WRITE_SCOPE], - sponsorId: primaryIdentity.sponsorId, - sponsorChain: primaryIdentity.sponsorChain, - }); + assert.ok(audit, "expected a budget.exceeded audit log"); + assert.equal(audit?.result, "denied"); + assert.equal(audit?.metadata.actionAttempted, READ_GENERAL_SCOPE); + }, + ); - const response = await escalationApp.request( - createTestRequest( - "POST", - "/subagents", - { - scopes: [READ_SCOPE, WRITE_SCOPE, FILE_WRITE_SCOPE], - }, - { - Authorization: `Bearer ${parentToken}`, - }, - ), - undefined, - harness.app.bindings, - ); + await t.test( + "scope escalation attempt returns 403 and writes a scope.escalation_denied audit event", + async () => { + const escalationApp = createScopeIssuanceApp(harness.app.storage); + const parentToken = generateTestToken({ + sub: primaryIdentity.id, + org: ORG_ID, + wks: WORKSPACE_ID, + scopes: [READ_SCOPE, WRITE_SCOPE], + sponsorId: primaryIdentity.sponsorId, + sponsorChain: primaryIdentity.sponsorChain, + }); - await assertJsonResponse<{ error: string; code?: string }>(response, 403, (body) => { - assert.equal(body.code, "scope_escalation"); - assert.match(body.error, /broader than the parent scope set/i); - }); + const response = await escalationApp.request( + createTestRequest( + "POST", + "/subagents", + { + scopes: [READ_SCOPE, WRITE_SCOPE, FILE_WRITE_SCOPE], + }, + { + Authorization: `Bearer ${parentToken}`, + }, + ), + undefined, + harness.app.bindings, + ); - const auditEntries = await harness.db.audit.query({ - orgId: ORG_ID, - identityId: primaryIdentity.id, - action: "scope.escalation_denied" as AuditAction, - limit: 10, - }); - const audit = auditEntries.entries.find( - (entry) => - entry.action === "scope.escalation_denied" - && entry.resource === FILE_WRITE_SCOPE, - ); + await assertJsonResponse<{ error: string; code?: string }>( + response, + 403, + (body) => { + assert.equal(body.code, "scope_escalation"); + assert.match(body.error, /broader than the parent scope set/i); + }, + ); - assert.ok(audit, "expected a scope.escalation_denied audit log"); - assert.equal(audit?.result, "denied"); - assert.equal(audit?.metadata.actionAttempted, FILE_WRITE_SCOPE); - }); + const auditEntries = await harness.db.audit.query({ + orgId: ORG_ID, + identityId: primaryIdentity.id, + action: "scope.escalation_denied" as AuditAction, + limit: 10, + }); + const audit = auditEntries.entries.find( + (entry) => + entry.action === "scope.escalation_denied" && + entry.resource === FILE_WRITE_SCOPE, + ); + + assert.ok(audit, "expected a scope.escalation_denied audit log"); + assert.equal(audit?.result, "denied"); + assert.equal(audit?.metadata.actionAttempted, FILE_WRITE_SCOPE); + }, + ); }); async function createRbacHarness() { @@ -501,7 +582,8 @@ async function createRbacHarness() { prepare(query: string) { return { bind: (...params: unknown[]) => ({ - first: async () => (resolveAll(state, query, params)[0] as T | null) ?? null, + first: async () => + (resolveAll(state, query, params)[0] as T | null) ?? null, all: async () => ({ results: resolveAll(state, query, params) as T[], success: true, @@ -510,7 +592,8 @@ async function createRbacHarness() { raw: async () => resolveAll(state, query, params) as T[], run: async () => runMutation(state, query, params), }), - first: async () => (resolveAll(state, query, [])[0] as T | null) ?? null, + first: async () => + (resolveAll(state, query, [])[0] as T | null) ?? null, all: async () => ({ results: resolveAll(state, query, []) as T[], success: true, @@ -532,7 +615,8 @@ async function createRbacHarness() { }, get(identityId: string) { return { - fetch: async (request: Request) => handleIdentityDoRequest(state, identityId, request), + fetch: async (request: Request) => + handleIdentityDoRequest(state, identityId, request), }; }, } as unknown as DurableObjectNamespace; @@ -544,12 +628,23 @@ async function createRbacHarness() { await app.storage.DB.prepare( "INSERT INTO organizations (id, org_id, scopes_json, roles_json) VALUES (?, ?, ?, ?)", ) - .bind(ORG_ID, ORG_ID, JSON.stringify(["relaycast:*:*:*"]), JSON.stringify([])) + .bind( + ORG_ID, + ORG_ID, + JSON.stringify(["relaycast:*:*:*"]), + JSON.stringify([]), + ) .run(); await app.storage.DB.prepare( "INSERT INTO workspaces (id, workspace_id, org_id, scopes_json, roles_json) VALUES (?, ?, ?, ?, ?)", ) - .bind(WORKSPACE_ID, WORKSPACE_ID, ORG_ID, JSON.stringify(["relaycast:channel:*:*"]), JSON.stringify([])) + .bind( + WORKSPACE_ID, + WORKSPACE_ID, + ORG_ID, + JSON.stringify(["relaycast:channel:*:*"]), + JSON.stringify([]), + ) .run(); const storageDb = Object.assign(app.storage, { @@ -560,7 +655,11 @@ async function createRbacHarness() { dump: async () => new ArrayBuffer(0), }) as AuthStorage & D1Database; - async function request(method: string, path: string, options: RequestOptions = {}): Promise { + async function request( + method: string, + path: string, + options: RequestOptions = {}, + ): Promise { const headers = new Headers(options.headers); if (!headers.has("Authorization")) { headers.set( @@ -581,7 +680,9 @@ async function createRbacHarness() { return app.request(request, undefined, app.bindings); } - async function seedIdentity(identity: StoredIdentity): Promise { + async function seedIdentity( + identity: StoredIdentity, + ): Promise { const cloned = clone(identity); state.identities.set(cloned.id, cloned); await app.storage.identities.create(cloned); @@ -592,10 +693,15 @@ async function createRbacHarness() { const identity = state.identities.get(identityId); assert.ok(identity, `expected seeded identity '${identityId}'`); - const evaluation = await evaluatePermissions(storageDb, identity.id, identity.orgId, { - workspaceId: identity.workspaceId, - identityId: identity.id, - }); + const evaluation = await evaluatePermissions( + storageDb, + identity.id, + identity.orgId, + { + workspaceId: identity.workspaceId, + identityId: identity.id, + }, + ); return generateTestToken({ sub: identity.id, @@ -617,7 +723,9 @@ async function createRbacHarness() { }; } -function createStoredIdentity(overrides: Partial = {}): StoredIdentity { +function createStoredIdentity( + overrides: Partial = {}, +): StoredIdentity { const base = generateTestIdentity(overrides); const sponsorId = overrides.sponsorId ?? "user_rbac_owner"; @@ -630,7 +738,9 @@ function createStoredIdentity(overrides: Partial = {}): StoredId sponsorChain: overrides.sponsorChain ?? [sponsorId, base.id], workspaceId: overrides.workspaceId ?? WORKSPACE_ID, ...(overrides.budget !== undefined ? { budget: overrides.budget } : {}), - ...(overrides.budgetUsage !== undefined ? { budgetUsage: overrides.budgetUsage } : {}), + ...(overrides.budgetUsage !== undefined + ? { budgetUsage: overrides.budgetUsage } + : {}), }; } @@ -645,7 +755,10 @@ function createScopeIssuanceApp(storage: AuthStorage): Hono { app.post("/subagents", async (c) => { const auth = await authenticate(c.req.header("Authorization"), c.env); if (!auth.ok) { - return c.json({ error: auth.error, code: "invalid_authorization" }, auth.status); + return c.json( + { error: auth.error, code: "invalid_authorization" }, + auth.status, + ); } const claims = auth.claims; @@ -657,7 +770,10 @@ function createScopeIssuanceApp(storage: AuthStorage): Hono { const narrowed = matchesAny(requestedScopes, claims.scopes).matched; return c.json({ scopes: narrowed }, 201); } catch (error) { - const deniedScope = matchesAny(requestedScopes, claims.scopes).denied[0] ?? requestedScopes[0] ?? "*"; + const deniedScope = + matchesAny(requestedScopes, claims.scopes).denied[0] ?? + requestedScopes[0] ?? + "*"; await writeAuditEntry(c.get("storage"), { action: "scope.escalation_denied", identityId: claims.sub, @@ -673,11 +789,10 @@ function createScopeIssuanceApp(storage: AuthStorage): Hono { }, }); - const relayError = error instanceof RelayAuthError ? error : new RelayAuthError( - String(error), - "scope_escalation", - 403, - ); + const relayError = + error instanceof RelayAuthError + ? error + : new RelayAuthError(String(error), "scope_escalation", 403); return c.json( { @@ -703,12 +818,9 @@ async function requestProtectedScope( app.get(path, (c) => c.json({ ok: true })); return app.request( - createTestRequest( - "GET", - path, - undefined, - { Authorization: `Bearer ${token}` }, - ), + createTestRequest("GET", path, undefined, { + Authorization: `Bearer ${token}`, + }), undefined, bindings, ); @@ -744,7 +856,9 @@ async function handleIdentityDoRequest( return jsonResponse({ error: "identity_not_found" }, 404); } - const patch = await request.json>().catch(() => null); + const patch = await request + .json>() + .catch(() => null); if (!patch) { return jsonResponse({ error: "invalid_identity_patch" }, 400); } @@ -752,7 +866,10 @@ async function handleIdentityDoRequest( return jsonResponse(mergeIdentity(state, current, patch), 200); } - return jsonResponse({ error: `unexpected_do_request:${request.method}:${pathname}` }, 500); + return jsonResponse( + { error: `unexpected_do_request:${request.method}:${pathname}` }, + 500, + ); } function mergeIdentity( @@ -774,7 +891,11 @@ function mergeIdentity( return next; } -function resolveAll(state: HarnessState, query: string, params: unknown[]): unknown[] { +function resolveAll( + state: HarnessState, + query: string, + params: unknown[], +): unknown[] { const sql = normalizeSql(query); state.executed.push({ query: sql, params: [...params] }); @@ -801,11 +922,17 @@ function resolveAll(state: HarnessState, query: string, params: unknown[]): unkn return []; } -function selectRoles(state: HarnessState, sql: string, params: unknown[]): unknown[] { +function selectRoles( + state: HarnessState, + sql: string, + params: unknown[], +): unknown[] { let roles = [...state.roles.values()]; if (/\bwhere id in \(/.test(sql)) { - const ids = new Set(params.filter((value): value is string => typeof value === "string")); + const ids = new Set( + params.filter((value): value is string => typeof value === "string"), + ); roles = roles.filter((role) => ids.has(role.id)); } else if (/\bwhere id = \?/.test(sql)) { const [id] = params; @@ -813,10 +940,16 @@ function selectRoles(state: HarnessState, sql: string, params: unknown[]): unkno } else if (/\bwhere org_id = \? and name = \?/.test(sql)) { const [orgId, name] = params; roles = roles.filter((role) => role.orgId === orgId && role.name === name); - } else if (/\bwhere org_id = \? and \(workspace_id = \? or workspace_id is null\)/.test(sql)) { + } else if ( + /\bwhere org_id = \? and \(workspace_id = \? or workspace_id is null\)/.test( + sql, + ) + ) { const [orgId, workspaceId] = params; roles = roles.filter( - (role) => role.orgId === orgId && (role.workspaceId === workspaceId || role.workspaceId === undefined), + (role) => + role.orgId === orgId && + (role.workspaceId === workspaceId || role.workspaceId === undefined), ); } else if (/\bwhere org_id = \?/.test(sql)) { const [orgId] = params; @@ -824,7 +957,10 @@ function selectRoles(state: HarnessState, sql: string, params: unknown[]): unkno } if (/\border by name asc, id asc\b/.test(sql)) { - roles.sort((left, right) => left.name.localeCompare(right.name) || left.id.localeCompare(right.id)); + roles.sort( + (left, right) => + left.name.localeCompare(right.name) || left.id.localeCompare(right.id), + ); } else { roles.sort((left, right) => left.id.localeCompare(right.id)); } @@ -832,19 +968,36 @@ function selectRoles(state: HarnessState, sql: string, params: unknown[]): unkno return roles.map(toRoleRow); } -function selectPolicies(state: HarnessState, sql: string, params: unknown[]): unknown[] { - let policies = [...state.policies.values()].filter((policy) => policy.deletedAt === undefined); +function selectPolicies( + state: HarnessState, + sql: string, + params: unknown[], +): unknown[] { + let policies = [...state.policies.values()].filter( + (policy) => policy.deletedAt === undefined, + ); if (/\bwhere id = \? and deleted_at is null\b/.test(sql)) { const [id] = params; policies = policies.filter((policy) => policy.id === id); - } else if (/\bwhere org_id = \? and name = \? and deleted_at is null\b/.test(sql)) { + } else if ( + /\bwhere org_id = \? and name = \? and deleted_at is null\b/.test(sql) + ) { const [orgId, name] = params; - policies = policies.filter((policy) => policy.orgId === orgId && policy.name === name); - } else if (/\bwhere org_id = \? and deleted_at is null and \(workspace_id = \? or workspace_id is null\)/.test(sql)) { + policies = policies.filter( + (policy) => policy.orgId === orgId && policy.name === name, + ); + } else if ( + /\bwhere org_id = \? and deleted_at is null and \(workspace_id = \? or workspace_id is null\)/.test( + sql, + ) + ) { const [orgId, workspaceId] = params; policies = policies.filter( - (policy) => policy.orgId === orgId && (policy.workspaceId === workspaceId || policy.workspaceId === undefined), + (policy) => + policy.orgId === orgId && + (policy.workspaceId === workspaceId || + policy.workspaceId === undefined), ); } else if (/\bwhere org_id = \? and deleted_at is null\b/.test(sql)) { const [orgId] = params; @@ -852,7 +1005,10 @@ function selectPolicies(state: HarnessState, sql: string, params: unknown[]): un } if (/\border by priority desc, id asc\b/.test(sql)) { - policies.sort((left, right) => right.priority - left.priority || left.id.localeCompare(right.id)); + policies.sort( + (left, right) => + right.priority - left.priority || left.id.localeCompare(right.id), + ); } else { policies.sort((left, right) => left.id.localeCompare(right.id)); } @@ -860,34 +1016,45 @@ function selectPolicies(state: HarnessState, sql: string, params: unknown[]): un return policies.map(toPolicyRow); } -function selectIdentities(state: HarnessState, sql: string, params: unknown[]): unknown[] { +function selectIdentities( + state: HarnessState, + sql: string, + params: unknown[], +): unknown[] { if (/\bselect roles, roles_json\b/.test(sql)) { const [id] = params; - const identity = typeof id === "string" ? state.identities.get(id) : undefined; + const identity = + typeof id === "string" ? state.identities.get(id) : undefined; if (!identity) { return []; } - return [{ - roles: [...identity.roles], - roles_json: JSON.stringify(identity.roles), - }]; + return [ + { + roles: [...identity.roles], + roles_json: JSON.stringify(identity.roles), + }, + ]; } if (/\bselect org_id as orgid\b/.test(sql) && /\bwhere id = \?/.test(sql)) { const [id] = params; - const identity = typeof id === "string" ? state.identities.get(id) : undefined; + const identity = + typeof id === "string" ? state.identities.get(id) : undefined; if (!identity) { return []; } - return [{ - orgId: identity.orgId, - org_id: identity.orgId, - }]; + return [ + { + orgId: identity.orgId, + org_id: identity.orgId, + }, + ]; } if (/\bwhere org_id = \? and id = \?/.test(sql)) { const [orgId, id] = params; - const identity = typeof id === "string" ? state.identities.get(id) : undefined; + const identity = + typeof id === "string" ? state.identities.get(id) : undefined; if (!identity || identity.orgId !== orgId) { return []; } @@ -896,49 +1063,59 @@ function selectIdentities(state: HarnessState, sql: string, params: unknown[]): if (/\bwhere id = \? limit 1\b/.test(sql)) { const [id] = params; - const identity = typeof id === "string" ? state.identities.get(id) : undefined; + const identity = + typeof id === "string" ? state.identities.get(id) : undefined; return identity ? [toIdentityRow(identity)] : []; } return []; } -function selectOrganizations(state: HarnessState, params: unknown[]): unknown[] { +function selectOrganizations( + state: HarnessState, + params: unknown[], +): unknown[] { const [id] = params; - const organization = typeof id === "string" ? state.organizations.get(id) : undefined; + const organization = + typeof id === "string" ? state.organizations.get(id) : undefined; if (!organization) { return []; } - return [{ - id: organization.id, - orgId: organization.id, - org_id: organization.id, - scopes: [...organization.scopes], - scopes_json: JSON.stringify(organization.scopes), - roles: [...organization.roles], - roles_json: JSON.stringify(organization.roles), - }]; + return [ + { + id: organization.id, + orgId: organization.id, + org_id: organization.id, + scopes: [...organization.scopes], + scopes_json: JSON.stringify(organization.scopes), + roles: [...organization.roles], + roles_json: JSON.stringify(organization.roles), + }, + ]; } function selectWorkspaces(state: HarnessState, params: unknown[]): unknown[] { const [id] = params; - const workspace = typeof id === "string" ? state.workspaces.get(id) : undefined; + const workspace = + typeof id === "string" ? state.workspaces.get(id) : undefined; if (!workspace) { return []; } - return [{ - id: workspace.id, - workspaceId: workspace.id, - workspace_id: workspace.id, - orgId: workspace.orgId, - org_id: workspace.orgId, - scopes: [...workspace.scopes], - scopes_json: JSON.stringify(workspace.scopes), - roles: [...workspace.roles], - roles_json: JSON.stringify(workspace.roles), - }]; + return [ + { + id: workspace.id, + workspaceId: workspace.id, + workspace_id: workspace.id, + orgId: workspace.orgId, + org_id: workspace.orgId, + scopes: [...workspace.scopes], + scopes_json: JSON.stringify(workspace.scopes), + roles: [...workspace.roles], + roles_json: JSON.stringify(workspace.roles), + }, + ]; } function runMutation(state: HarnessState, query: string, params: unknown[]) { @@ -946,14 +1123,26 @@ function runMutation(state: HarnessState, query: string, params: unknown[]) { state.executed.push({ query: sql, params: [...params] }); if (/^insert into roles\b/.test(sql)) { - const [id, name, description, scopes, _scopesJson, orgId, workspaceId, builtIn, createdAt] = params; + const [ + id, + name, + description, + scopes, + _scopesJson, + orgId, + workspaceId, + builtIn, + createdAt, + ] = params; state.roles.set(String(id), { id: String(id), name: String(name), description: String(description), scopes: parseStringArray(scopes), orgId: String(orgId), - ...(typeof workspaceId === "string" && workspaceId.length > 0 ? { workspaceId } : {}), + ...(typeof workspaceId === "string" && workspaceId.length > 0 + ? { workspaceId } + : {}), builtIn: builtIn === 1 || builtIn === true, createdAt: String(createdAt), }); @@ -1007,17 +1196,38 @@ function runMutation(state: HarnessState, query: string, params: unknown[]) { conditions: parseConditions(conditions), priority: Number(priority), orgId: String(orgId), - ...(typeof workspaceId === "string" && workspaceId.length > 0 ? { workspaceId } : {}), + ...(typeof workspaceId === "string" && workspaceId.length > 0 + ? { workspaceId } + : {}), createdAt: String(createdAt), - ...(typeof deletedAt === "string" && deletedAt.length > 0 ? { deletedAt } : {}), + ...(typeof deletedAt === "string" && deletedAt.length > 0 + ? { deletedAt } + : {}), }); return successResult(); } - if (/^update policies\b/.test(sql) && /\bset name = \?, effect = \?/.test(sql)) { - const [name, effect, scopes, _scopesJson, conditions, _conditionsJson, priority, id, orgId] = params; + if ( + /^update policies\b/.test(sql) && + /\bset name = \?, effect = \?/.test(sql) + ) { + const [ + name, + effect, + scopes, + _scopesJson, + conditions, + _conditionsJson, + priority, + id, + orgId, + ] = params; const existing = state.policies.get(String(id)); - if (existing && existing.orgId === orgId && existing.deletedAt === undefined) { + if ( + existing && + existing.orgId === orgId && + existing.deletedAt === undefined + ) { state.policies.set(existing.id, { ...existing, name: String(name), @@ -1033,7 +1243,11 @@ function runMutation(state: HarnessState, query: string, params: unknown[]) { if (/^update policies\b/.test(sql) && /\bset deleted_at = \?/.test(sql)) { const [deletedAt, id, orgId] = params; const existing = state.policies.get(String(id)); - if (existing && existing.orgId === orgId && existing.deletedAt === undefined) { + if ( + existing && + existing.orgId === orgId && + existing.deletedAt === undefined + ) { state.policies.set(existing.id, { ...existing, deletedAt: String(deletedAt), @@ -1085,7 +1299,9 @@ function toRoleRow(role: Role) { scopes_json: JSON.stringify(role.scopes), orgId: role.orgId, org_id: role.orgId, - ...(role.workspaceId ? { workspaceId: role.workspaceId, workspace_id: role.workspaceId } : {}), + ...(role.workspaceId + ? { workspaceId: role.workspaceId, workspace_id: role.workspaceId } + : {}), builtIn: role.builtIn, built_in: role.builtIn ? 1 : 0, createdAt: role.createdAt, @@ -1105,7 +1321,9 @@ function toPolicyRow(policy: StoredPolicy) { priority: policy.priority, orgId: policy.orgId, org_id: policy.orgId, - ...(policy.workspaceId ? { workspaceId: policy.workspaceId, workspace_id: policy.workspaceId } : {}), + ...(policy.workspaceId + ? { workspaceId: policy.workspaceId, workspace_id: policy.workspaceId } + : {}), createdAt: policy.createdAt, created_at: policy.createdAt, deletedAt: policy.deletedAt ?? null, @@ -1141,8 +1359,12 @@ function toIdentityRow(identity: StoredIdentity) { budget: identity.budget ?? null, budget_json: identity.budget ? JSON.stringify(identity.budget) : null, budgetUsage: identity.budgetUsage ?? null, - budget_usage: identity.budgetUsage ? JSON.stringify(identity.budgetUsage) : null, - budget_usage_json: identity.budgetUsage ? JSON.stringify(identity.budgetUsage) : null, + budget_usage: identity.budgetUsage + ? JSON.stringify(identity.budgetUsage) + : null, + budget_usage_json: identity.budgetUsage + ? JSON.stringify(identity.budgetUsage) + : null, }; } @@ -1168,7 +1390,10 @@ function parseStringArray(value: unknown): string[] { function parseConditions(value: unknown): StoredPolicy["conditions"] { if (Array.isArray(value)) { return value - .filter((entry): entry is StoredPolicy["conditions"][number] => typeof entry === "object" && entry !== null) + .filter( + (entry): entry is StoredPolicy["conditions"][number] => + typeof entry === "object" && entry !== null, + ) .map((entry) => ({ ...entry })); } @@ -1180,7 +1405,10 @@ function parseConditions(value: unknown): StoredPolicy["conditions"] { const parsed = JSON.parse(value) as unknown; return Array.isArray(parsed) ? parsed - .filter((entry): entry is StoredPolicy["conditions"][number] => typeof entry === "object" && entry !== null) + .filter( + (entry): entry is StoredPolicy["conditions"][number] => + typeof entry === "object" && entry !== null, + ) .map((entry) => ({ ...entry })) : []; } catch { @@ -1191,7 +1419,9 @@ function parseConditions(value: unknown): StoredPolicy["conditions"] { function parseRecord(value: unknown): Record { if (value && typeof value === "object" && !Array.isArray(value)) { return Object.fromEntries( - Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"), + Object.entries(value).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), ); } @@ -1206,7 +1436,9 @@ function parseRecord(value: unknown): Record { } return Object.fromEntries( - Object.entries(parsed).filter((entry): entry is [string, string] => typeof entry[1] === "string"), + Object.entries(parsed).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), ); } catch { return {}; diff --git a/packages/server/src/__tests__/sqlite-storage.test.ts b/packages/server/src/__tests__/sqlite-storage.test.ts index 6bf03cb..ac85546 100644 --- a/packages/server/src/__tests__/sqlite-storage.test.ts +++ b/packages/server/src/__tests__/sqlite-storage.test.ts @@ -25,7 +25,9 @@ function createHarness(): { storage: AuthStorage; cleanup: () => void } { }; } -function createIdentity(overrides: Partial = {}): StoredIdentity { +function createIdentity( + overrides: Partial = {}, +): StoredIdentity { const id = overrides.id ?? `agent_${Math.random().toString(36).slice(2)}`; const createdAt = overrides.createdAt ?? "2026-03-27T10:00:00.000Z"; const updatedAt = overrides.updatedAt ?? createdAt; @@ -46,7 +48,9 @@ function createIdentity(overrides: Partial = {}): StoredIdentity updatedAt, ...(overrides.lastActiveAt ? { lastActiveAt: overrides.lastActiveAt } : {}), ...(overrides.suspendedAt ? { suspendedAt: overrides.suspendedAt } : {}), - ...(overrides.suspendReason ? { suspendReason: overrides.suspendReason } : {}), + ...(overrides.suspendReason + ? { suspendReason: overrides.suspendReason } + : {}), ...(overrides.budget ? { budget: overrides.budget } : {}), ...(overrides.budgetUsage ? { budgetUsage: overrides.budgetUsage } : {}), }; @@ -81,7 +85,9 @@ function createPolicy(overrides: Partial = {}): Policy { }; } -function createAuditEntry(overrides: Partial = {}): Omit & { id: string } { +function createAuditEntry( + overrides: Partial = {}, +): Omit & { id: string } { return { id: overrides.id ?? `aud_${Math.random().toString(36).slice(2)}`, action: overrides.action ?? "scope.checked", @@ -137,18 +143,28 @@ test("sqlite token storage owns issued-token persistence and hot-path lookups", [await storage.tokens.getById("jti_1")], ); assert.deepEqual( - (await storage.tokens.listActiveBySessionId("sess_shared")).map((token) => token.id), + (await storage.tokens.listActiveBySessionId("sess_shared")).map( + (token) => token.id, + ), ["tok_row_1", "tok_row_2"], ); - assert.deepEqual(await storage.tokens.listActiveIds("agent_token_1"), ["tok_row_1"]); + assert.deepEqual(await storage.tokens.listActiveIds("agent_token_1"), [ + "tok_row_1", + ]); await storage.revocations.revokeIdentityTokens( "agent_token_1", ["tok_row_1"], "2026-03-27T12:01:00.000Z", ); - assert.deepEqual(await storage.tokens.listActiveByIdentityId("agent_token_1"), []); - assert.equal((await storage.tokens.getById("tok_row_1"))?.status, "revoked"); + assert.deepEqual( + await storage.tokens.listActiveByIdentityId("agent_token_1"), + [], + ); + assert.equal( + (await storage.tokens.getById("tok_row_1"))?.status, + "revoked", + ); } finally { cleanup(); } @@ -175,8 +191,8 @@ test("sqlite revoke with audit rolls all durable state back when the audit inser }); await storage.audit.write(conflictingAudit); - await assert.rejects( - () => storage.revocations.revokeIdentityTokensWithAudit({ + await assert.rejects(() => + storage.revocations.revokeIdentityTokensWithAudit({ identityId: "agent_revoke_atomic", tokenIds: ["tok_revoke_atomic"], revokedAt: "2026-03-27T12:01:00.000Z", @@ -184,14 +200,25 @@ test("sqlite revoke with audit rolls all durable state back when the audit inser }), ); - assert.equal((await storage.tokens.getById("tok_revoke_atomic"))?.status, "active"); - assert.equal(await storage.revocations.isRevoked("tok_revoke_atomic"), false); - assert.deepEqual( - (await storage.audit.query({ + assert.equal( + (await storage.tokens.getById("tok_revoke_atomic"))?.status, + "active", + ); + assert.equal( + await storage.revocations.isRevoked("tok_revoke_atomic"), + false, + ); + const auditResult = await storage.audit.query( + { orgId: "org_test", action: "token.revoked", limit: 10, - }, { includeOverflowRow: false })).map((entry) => entry.id), + }, + { includeOverflowRow: false }, + ); + assert.equal(auditResult.kind, "complete"); + assert.deepEqual( + auditResult.entries.map((entry) => entry.id), ["aud_revoke_conflict"], ); } finally { @@ -232,25 +259,42 @@ test("sqlite token pair and audit entry commit atomically and retries do not dup try { await storage.tokens.persistIssuedPairWithAudit(pair); - assert.equal((await storage.tokens.listActiveBySessionId("sess_pair")).length, 2); + assert.equal( + (await storage.tokens.listActiveBySessionId("sess_pair")).length, + 2, + ); assert.deepEqual( - (await storage.audit.query({ - orgId: "org_test", - action: "token.issued", - limit: 10, - }, { includeOverflowRow: false })).entries.map((entry) => entry.id), + ( + await storage.audit.query( + { + orgId: "org_test", + action: "token.issued", + limit: 10, + }, + { includeOverflowRow: false }, + ) + ).entries.map((entry) => entry.id), ["aud_pair"], ); - await assert.rejects( - () => storage.tokens.persistIssuedPairWithAudit(pair), + await assert.rejects(() => storage.tokens.persistIssuedPairWithAudit(pair)); + assert.equal( + (await storage.tokens.listActiveBySessionId("sess_pair")).length, + 2, + ); + assert.equal( + ( + await storage.audit.query( + { + orgId: "org_test", + action: "token.issued", + limit: 10, + }, + { includeOverflowRow: false }, + ) + ).entries.length, + 1, ); - assert.equal((await storage.tokens.listActiveBySessionId("sess_pair")).length, 2); - assert.equal((await storage.audit.query({ - orgId: "org_test", - action: "token.issued", - limit: 10, - }, { includeOverflowRow: false })).entries.length, 1); } finally { cleanup(); } @@ -260,40 +304,47 @@ test("sqlite token pair rolls back both token rows when the audit insert fails", const { storage, cleanup } = createHarness(); try { - await storage.audit.write(createAuditEntry({ - id: "aud_conflict", - action: "token.issued", - })); - - await assert.rejects(() => storage.tokens.persistIssuedPairWithAudit({ - accessToken: { - id: "tok_rollback_access", - tokenId: "tok_rollback_access", - jti: "tok_rollback_access", - identityId: "agent_rollback", - sessionId: "sess_rollback", - issuedAt: 1_774_608_000, - expiresAt: 1_774_611_600, - createdAt: "2026-03-27T12:00:00.000Z", - }, - refreshToken: { - id: "tok_rollback_refresh", - tokenId: "tok_rollback_refresh", - jti: "tok_rollback_refresh", - identityId: "agent_rollback", - sessionId: "sess_rollback", - issuedAt: 1_774_608_000, - expiresAt: 1_774_694_400, - createdAt: "2026-03-27T12:00:00.000Z", - }, - auditEntry: createAuditEntry({ + await storage.audit.write( + createAuditEntry({ id: "aud_conflict", action: "token.issued", - identityId: "agent_rollback", }), - })); + ); + + await assert.rejects(() => + storage.tokens.persistIssuedPairWithAudit({ + accessToken: { + id: "tok_rollback_access", + tokenId: "tok_rollback_access", + jti: "tok_rollback_access", + identityId: "agent_rollback", + sessionId: "sess_rollback", + issuedAt: 1_774_608_000, + expiresAt: 1_774_611_600, + createdAt: "2026-03-27T12:00:00.000Z", + }, + refreshToken: { + id: "tok_rollback_refresh", + tokenId: "tok_rollback_refresh", + jti: "tok_rollback_refresh", + identityId: "agent_rollback", + sessionId: "sess_rollback", + issuedAt: 1_774_608_000, + expiresAt: 1_774_694_400, + createdAt: "2026-03-27T12:00:00.000Z", + }, + auditEntry: createAuditEntry({ + id: "aud_conflict", + action: "token.issued", + identityId: "agent_rollback", + }), + }), + ); - assert.deepEqual(await storage.tokens.listActiveBySessionId("sess_rollback"), []); + assert.deepEqual( + await storage.tokens.listActiveBySessionId("sess_rollback"), + [], + ); assert.equal(await storage.tokens.getById("tok_rollback_access"), null); assert.equal(await storage.tokens.getById("tok_rollback_refresh"), null); } finally { @@ -349,15 +400,31 @@ test("sqlite refresh rotation atomically mints, revokes, and audits", async () = }), }); - assert.equal((await storage.tokens.getById(previous.id))?.status, "revoked"); - assert.equal((await storage.tokens.getById(accessToken.id))?.status, "active"); - assert.equal((await storage.tokens.getById(refreshToken.id))?.status, "active"); + assert.equal( + (await storage.tokens.getById(previous.id))?.status, + "revoked", + ); + assert.equal( + (await storage.tokens.getById(accessToken.id))?.status, + "active", + ); + assert.equal( + (await storage.tokens.getById(refreshToken.id))?.status, + "active", + ); assert.equal(await storage.revocations.isRevoked?.(previous.id), true); assert.deepEqual( - (await storage.audit.query({ - orgId: "org_test", - limit: 10, - }, { includeOverflowRow: false })).entries.map((entry) => entry.id).sort(), + ( + await storage.audit.query( + { + orgId: "org_test", + limit: 10, + }, + { includeOverflowRow: false }, + ) + ).entries + .map((entry) => entry.id) + .sort(), ["aud_rotation_refreshed", "aud_rotation_revoked"], ); } finally { @@ -380,44 +447,54 @@ test("sqlite refresh rotation audit conflict rolls back new tokens and old-JTI r try { await storage.tokens.persistIssued(previous); - await storage.audit.write(createAuditEntry({ - id: "aud_rotation_conflict", - action: "token.revoked", - identityId: previous.identityId, - })); - await assert.rejects(() => storage.tokens.rotateIssuedPairWithAudit({ - accessToken: { - ...previous, - id: "tok_rollback_rotation_access", - tokenId: "tok_rollback_rotation_access", - jti: "tok_rollback_rotation_access", - }, - refreshToken: { - ...previous, - id: "tok_rollback_rotation_refresh", - tokenId: "tok_rollback_rotation_refresh", - jti: "tok_rollback_rotation_refresh", - }, - previousRefreshToken: { - id: previous.id, - identityId: previous.identityId, - expiresAt: previous.expiresAt, - }, - refreshedAuditEntry: createAuditEntry({ - id: "aud_rotation_first", - action: "token.refreshed", - identityId: previous.identityId, - }), - revokedAuditEntry: createAuditEntry({ + await storage.audit.write( + createAuditEntry({ id: "aud_rotation_conflict", action: "token.revoked", identityId: previous.identityId, }), - })); + ); + await assert.rejects(() => + storage.tokens.rotateIssuedPairWithAudit({ + accessToken: { + ...previous, + id: "tok_rollback_rotation_access", + tokenId: "tok_rollback_rotation_access", + jti: "tok_rollback_rotation_access", + }, + refreshToken: { + ...previous, + id: "tok_rollback_rotation_refresh", + tokenId: "tok_rollback_rotation_refresh", + jti: "tok_rollback_rotation_refresh", + }, + previousRefreshToken: { + id: previous.id, + identityId: previous.identityId, + expiresAt: previous.expiresAt, + }, + refreshedAuditEntry: createAuditEntry({ + id: "aud_rotation_first", + action: "token.refreshed", + identityId: previous.identityId, + }), + revokedAuditEntry: createAuditEntry({ + id: "aud_rotation_conflict", + action: "token.revoked", + identityId: previous.identityId, + }), + }), + ); assert.equal((await storage.tokens.getById(previous.id))?.status, "active"); - assert.equal(await storage.tokens.getById("tok_rollback_rotation_access"), null); - assert.equal(await storage.tokens.getById("tok_rollback_rotation_refresh"), null); + assert.equal( + await storage.tokens.getById("tok_rollback_rotation_access"), + null, + ); + assert.equal( + await storage.tokens.getById("tok_rollback_rotation_refresh"), + null, + ); assert.equal(await storage.revocations.isRevoked?.(previous.id), false); } finally { cleanup(); @@ -488,17 +565,26 @@ test("sqlite identity storage supports CRUD, hierarchy, and budget auto-suspend" assert.equal(afterCursor.length, 1); assert.equal(afterCursor[0]?.id, createdParent.id); - const duplicate = await storage.identities.findDuplicate("org_test", "Parent Agent"); + const duplicate = await storage.identities.findDuplicate( + "org_test", + "Parent Agent", + ); assert.deepEqual(duplicate, { id: createdParent.id, name: "Parent Agent", orgId: "org_test", }); - const childIds = await storage.identities.listChildIds("org_test", parent.id); + const childIds = await storage.identities.listChildIds( + "org_test", + parent.id, + ); assert.deepEqual(childIds, [createdChild.id]); - const children = await storage.identities.listChildren("org_test", parent.id); + const children = await storage.identities.listChildren( + "org_test", + parent.id, + ); assert.equal(children.length, 1); assert.equal(children[0]?.id, createdChild.id); assert.equal(children[0]?.status, "active"); @@ -509,7 +595,10 @@ test("sqlite identity storage supports CRUD, hierarchy, and budget auto-suspend" suspendedIdentities: 1, }); - const suspended = await storage.identities.suspend(parent.id, "manual_review"); + const suspended = await storage.identities.suspend( + parent.id, + "manual_review", + ); assert.equal(suspended.status, "suspended"); assert.equal(suspended.suspendReason, "manual_review"); @@ -518,7 +607,10 @@ test("sqlite identity storage supports CRUD, hierarchy, and budget auto-suspend" assert.equal(reactivated.suspendedAt, undefined); assert.equal(reactivated.suspendReason, undefined); - const retired = await storage.identities.retire(parent.id, "decommissioned"); + const retired = await storage.identities.retire( + parent.id, + "decommissioned", + ); assert.equal(retired.status, "retired"); await assert.rejects( () => storage.identities.reactivate(parent.id), @@ -557,13 +649,19 @@ test("sqlite storage supports roles, policies, audit, webhooks, contexts, and re ["role_global", "role_workspace"], ); - const listedByIds = await storage.roles.listByIds([workspaceRole.id, globalRole.id]); + const listedByIds = await storage.roles.listByIds([ + workspaceRole.id, + globalRole.id, + ]); assert.equal(listedByIds.length, 2); const updatedRole = await storage.roles.update(globalRole.id, { scopes: ["relayauth:identity:read", "relayauth:audit:read"], }); - assert.deepEqual(updatedRole.scopes, ["relayauth:identity:read", "relayauth:audit:read"]); + assert.deepEqual(updatedRole.scopes, [ + "relayauth:identity:read", + "relayauth:audit:read", + ]); const lowPriorityPolicy = await storage.policies.create( createPolicy({ @@ -596,24 +694,34 @@ test("sqlite storage supports roles, policies, audit, webhooks, contexts, and re const deletedPolicy = await storage.policies.get(highPriorityPolicy.id); assert.equal(deletedPolicy, null); - await storage.audit.write(createAuditEntry({ - id: "aud_newer", - action: "scope.checked", - identityId: "agent_parent", - orgId: "org_test", - result: "allowed", - timestamp: "2026-03-27T15:00:00.000Z", - metadata: { sponsorId: "sponsor_root", sponsorChain: "[\"sponsor_root\"]" }, - })); - await storage.audit.write(createAuditEntry({ - id: "aud_older", - action: "token.revoked", - identityId: "agent_parent", - orgId: "org_test", - result: "allowed", - timestamp: "2026-03-27T14:00:00.000Z", - metadata: { sponsorId: "sponsor_root", sponsorChain: "[\"sponsor_root\"]" }, - })); + await storage.audit.write( + createAuditEntry({ + id: "aud_newer", + action: "scope.checked", + identityId: "agent_parent", + orgId: "org_test", + result: "allowed", + timestamp: "2026-03-27T15:00:00.000Z", + metadata: { + sponsorId: "sponsor_root", + sponsorChain: '["sponsor_root"]', + }, + }), + ); + await storage.audit.write( + createAuditEntry({ + id: "aud_older", + action: "token.revoked", + identityId: "agent_parent", + orgId: "org_test", + result: "allowed", + timestamp: "2026-03-27T14:00:00.000Z", + metadata: { + sponsorId: "sponsor_root", + sponsorChain: '["sponsor_root"]', + }, + }), + ); const queriedAudit = await storage.audit.query({ orgId: "org_test", @@ -626,11 +734,11 @@ test("sqlite storage supports roles, policies, audit, webhooks, contexts, and re assert.deepEqual(actionCounts, { kind: "complete", counts: { - tokensIssued: 0, - tokensRevoked: 1, - tokensRefreshed: 0, - scopeChecks: 1, - scopeDenials: 0, + tokensIssued: 0, + tokensRevoked: 1, + tokensRefreshed: 0, + scopeChecks: 1, + scopeDenials: 0, }, }); @@ -639,7 +747,11 @@ test("sqlite storage supports roles, policies, audit, webhooks, contexts, and re sponsorChain: ["sponsor_root", "agent_for_audit_event"], }); await storage.identities.create(suspendedIdentity); - await storage.audit.writeIdentitySuspendedEvent(suspendedIdentity, "manual_review", "actor_1"); + await storage.audit.writeIdentitySuspendedEvent( + suspendedIdentity, + "manual_review", + "actor_1", + ); const createdWebhook = await storage.auditWebhooks.create({ orgId: "org_test", @@ -664,7 +776,11 @@ test("sqlite storage supports roles, policies, audit, webhooks, contexts, and re const revocations = storage.revocations as AuthStorage["revocations"] & { isRevoked?: (tokenId: string) => Promise; }; - await revocations.revokeIdentityTokens("agent_parent", ["tok_1"], "2026-03-27T16:00:00.000Z"); + await revocations.revokeIdentityTokens( + "agent_parent", + ["tok_1"], + "2026-03-27T16:00:00.000Z", + ); if (typeof revocations.isRevoked === "function") { assert.equal(await revocations.isRevoked("tok_1"), true); } diff --git a/packages/server/src/__tests__/storage-sqlite.test.ts b/packages/server/src/__tests__/storage-sqlite.test.ts index e03e43e..60227e2 100644 --- a/packages/server/src/__tests__/storage-sqlite.test.ts +++ b/packages/server/src/__tests__/storage-sqlite.test.ts @@ -84,7 +84,10 @@ test("TestSqliteIdentitySuspendRetire", async (t) => { metadata: {}, }); - const suspended = await storage.identities.suspend(created.id, "manual_review"); + const suspended = await storage.identities.suspend( + created.id, + "manual_review", + ); assert.equal(suspended.status, "suspended"); assert.equal(suspended.suspendReason, "manual_review"); assert.equal(typeof suspended.suspendedAt, "string"); @@ -105,7 +108,10 @@ test("TestSqliteRevocation", async (t) => { assert.equal(await storage.revocations.isRevoked("jti_missing"), false); - await storage.revocations.revoke("jti_revoked", Math.floor(Date.now() / 1000) + 3600); + await storage.revocations.revoke( + "jti_revoked", + Math.floor(Date.now() / 1000) + 3600, + ); assert.equal(await storage.revocations.isRevoked("jti_revoked"), true); assert.equal(await storage.revocations.isRevoked("jti_other"), false); @@ -137,7 +143,10 @@ test("TestSqliteRoleCRUD", async (t) => { scopes: ["relayauth:role:manage:*", "relayauth:role:read:*"], }); assert.equal(updated.description, "Updated SQLite admin role"); - assert.deepEqual(updated.scopes, ["relayauth:role:manage:*", "relayauth:role:read:*"]); + assert.deepEqual(updated.scopes, [ + "relayauth:role:manage:*", + "relayauth:role:read:*", + ]); await storage.roles.delete(created.id); @@ -174,12 +183,17 @@ test("TestSqlitePolicyCRUD", async (t) => { }); assert.equal(updated.effect, "deny"); assert.equal(updated.priority, 75); - assert.deepEqual(updated.conditions, [{ type: "ip", operator: "eq", value: "203.0.113.10" }]); + assert.deepEqual(updated.conditions, [ + { type: "ip", operator: "eq", value: "203.0.113.10" }, + ]); await storage.policies.delete(created.id); assert.equal(await storage.policies.get(created.id), null); - assert.deepEqual(await storage.policies.list("org_policies", "ws_policies"), []); + assert.deepEqual( + await storage.policies.list("org_policies", "ws_policies"), + [], + ); }); test("TestSqliteAuditLog", async (t) => { @@ -194,7 +208,10 @@ test("TestSqliteAuditLog", async (t) => { plane: "relayauth", resource: "/v1/identities", result: "allowed", - metadata: { sponsorId: "user_audit", sponsorChain: "[\"user_audit\",\"agent_audit_1\"]" }, + metadata: { + sponsorId: "user_audit", + sponsorChain: '["user_audit","agent_audit_1"]', + }, ip: "203.0.113.10", userAgent: "node:test", timestamp: "2026-03-27T12:00:00.000Z", @@ -207,7 +224,10 @@ test("TestSqliteAuditLog", async (t) => { plane: "relayauth", resource: "/v1/identities/agent_audit_1", result: "allowed", - metadata: { sponsorId: "user_audit", sponsorChain: "[\"user_audit\",\"agent_audit_1\"]" }, + metadata: { + sponsorId: "user_audit", + sponsorChain: '["user_audit","agent_audit_1"]', + }, ip: "203.0.113.10", userAgent: "node:test", timestamp: "2026-03-27T12:30:00.000Z", @@ -236,19 +256,24 @@ test("TestSqliteAuditLog", async (t) => { test("TestSqliteAutoCreateTables", async (t) => { const { dbPath, storage } = createTempStorage(t); - const result = await storage.DB.prepare(` + const result = await storage.DB.prepare( + ` SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name ASC - `).all<{ name?: string }>(); + `, + ).all<{ name?: string }>(); const tables = result.results ?? []; assert.equal(existsSync(dbPath), true); const tableNames = new Set( tables - .filter((row): row is { name: string } => typeof row.name === "string" && row.name.length > 0) + .filter( + (row): row is { name: string } => + typeof row.name === "string" && row.name.length > 0, + ) .map((row) => row.name), ); @@ -266,6 +291,10 @@ test("TestSqliteAutoCreateTables", async (t) => { "audit_webhooks", "revoked_tokens", ]) { - assert.equal(tableNames.has(tableName), true, `expected ${tableName} to be auto-created`); + assert.equal( + tableNames.has(tableName), + true, + `expected ${tableName} to be auto-created`, + ); } }); diff --git a/packages/server/src/__tests__/tokens-route.test.ts b/packages/server/src/__tests__/tokens-route.test.ts index c685724..57017ed 100644 --- a/packages/server/src/__tests__/tokens-route.test.ts +++ b/packages/server/src/__tests__/tokens-route.test.ts @@ -86,11 +86,19 @@ function decodeJwtJsonSegment(token: string, index: 0 | 1): T { ? token.slice("relay_pa_".length) : token; const segments = normalized.split("."); - assert.equal(segments.length, 3, "expected a compact JWT with exactly three segments"); - return JSON.parse(Buffer.from(segments[index], "base64url").toString("utf8")) as T; + assert.equal( + segments.length, + 3, + "expected a compact JWT with exactly three segments", + ); + return JSON.parse( + Buffer.from(segments[index], "base64url").toString("utf8"), + ) as T; } -function createStoredIdentity(overrides: Partial = {}): StoredIdentity { +function createStoredIdentity( + overrides: Partial = {}, +): StoredIdentity { const base = generateTestIdentity(overrides); const sponsorId = overrides.sponsorId ?? "user_worker_owner"; @@ -100,11 +108,15 @@ function createStoredIdentity(overrides: Partial = {}): StoredId sponsorChain: overrides.sponsorChain ?? [sponsorId, base.id], workspaceId: overrides.workspaceId ?? "ws_worker", ...(overrides.budget !== undefined ? { budget: overrides.budget } : {}), - ...(overrides.budgetUsage !== undefined ? { budgetUsage: overrides.budgetUsage } : {}), + ...(overrides.budgetUsage !== undefined + ? { budgetUsage: overrides.budgetUsage } + : {}), }; } -function createAuthToken(overrides: Partial = {}): string { +function createAuthToken( + overrides: Partial = {}, +): string { const now = Math.floor(Date.now() / 1000); const sponsorId = overrides.sponsorId ?? "user_admin_worker"; const sub = overrides.sub ?? "agent_admin_worker"; @@ -113,7 +125,11 @@ function createAuthToken(overrides: Partial = {}): string sub, org: overrides.org ?? "org_tokens_route", wks: overrides.wks ?? "ws_tokens_route", - scopes: overrides.scopes ?? ["relayauth:token:create:*", "relayauth:token:manage:*", "relayauth:token:read:*"], + scopes: overrides.scopes ?? [ + "relayauth:token:create:*", + "relayauth:token:manage:*", + "relayauth:token:read:*", + ], sponsorId, sponsorChain: overrides.sponsorChain ?? [sponsorId, sub], token_type: overrides.token_type ?? "access", @@ -125,7 +141,9 @@ function createAuthToken(overrides: Partial = {}): string ...(overrides.nbf !== undefined ? { nbf: overrides.nbf } : {}), ...(overrides.sid !== undefined ? { sid: overrides.sid } : {}), ...(overrides.meta !== undefined ? { meta: overrides.meta } : {}), - ...(overrides.parentTokenId !== undefined ? { parentTokenId: overrides.parentTokenId } : {}), + ...(overrides.parentTokenId !== undefined + ? { parentTokenId: overrides.parentTokenId } + : {}), ...(overrides.budget !== undefined ? { budget: overrides.budget } : {}), }); } @@ -256,7 +274,10 @@ function assertTokenClaimsMatchSpec( assert.equal(claims.sponsorId, expectedIdentity.sponsorId); assert.match(claims.sponsorId, /^user_[A-Za-z0-9_-]+$/); assert.deepEqual(claims.sponsorChain, expectedIdentity.sponsorChain); - assert.ok(claims.sponsorChain.length >= 2, "sponsorChain should include sponsor and agent"); + assert.ok( + claims.sponsorChain.length >= 2, + "sponsorChain should include sponsor and agent", + ); assert.equal(claims.sponsorChain[0], claims.sponsorId); assert.equal(claims.sponsorChain.at(-1), claims.sub); assert.equal(claims.iss, "https://relayauth.dev"); @@ -267,9 +288,21 @@ function assertTokenClaimsMatchSpec( assert.equal(typeof claims.iat, "number"); assert.equal(typeof claims.exp, "number"); assert.ok(claims.exp > claims.iat, "exp must be after iat"); - assert.equal("workspace_id" in claims, false, "RS256 workspace_id alias should not be present"); - assert.equal("agent_name" in claims, false, "RS256 agent_name alias should not be present"); - assert.equal("sponsor" in claims, false, "RS256 sponsor claim should not be present"); + assert.equal( + "workspace_id" in claims, + false, + "RS256 workspace_id alias should not be present", + ); + assert.equal( + "agent_name" in claims, + false, + "RS256 agent_name alias should not be present", + ); + assert.equal( + "sponsor" in claims, + false, + "RS256 sponsor claim should not be present", + ); if (tokenType === "refresh") { assert.deepEqual(claims.aud, ["relayauth"]); @@ -292,15 +325,17 @@ async function createHarness({ deferTask?: (task: DeferredTask) => void; } = {}) { const app = createTestApp({}, { deferTask }); - const storedIdentity = identity ?? createStoredIdentity({ - id: "agent_tokens_subject", - name: "Tokens Subject", - orgId: authClaims?.org ?? "org_tokens_route", - workspaceId: authClaims?.wks ?? "ws_tokens_route", - sponsorId: "user_tokens_owner", - sponsorChain: ["user_tokens_owner", "agent_tokens_subject"], - scopes: ["specialist:invoke"], - }); + const storedIdentity = + identity ?? + createStoredIdentity({ + id: "agent_tokens_subject", + name: "Tokens Subject", + orgId: authClaims?.org ?? "org_tokens_route", + workspaceId: authClaims?.wks ?? "ws_tokens_route", + sponsorId: "user_tokens_owner", + sponsorChain: ["user_tokens_owner", "agent_tokens_subject"], + scopes: ["specialist:invoke"], + }); await seedStoredIdentity(app, storedIdentity); return { @@ -400,24 +435,41 @@ async function fillDatabaseToCeiling( `INSERT INTO tokens (id, token_id, jti, identity_id, session_id, issued_at, expires_at, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?)`, ) - .bind(id, id, id, identityId, `sess_${id}`, expiresAt - 3_600, expiresAt, new Date().toISOString()) + .bind( + id, + id, + id, + identityId, + `sess_${id}`, + expiresAt - 3_600, + expiresAt, + new Date().toISOString(), + ) .run(); }; const expiredAt = Math.floor(Date.now() / 1_000) - 86_400; for (let index = 0; index < 400; index += 1) { - await insertToken(`tok_expired_${index}_${crypto.randomUUID().replace(/-/g, "")}`, expiredAt); + await insertToken( + `tok_expired_${index}_${crypto.randomUUID().replace(/-/g, "")}`, + expiredAt, + ); } - const pageCountRow = await app.storage.DB - .prepare("PRAGMA page_count") - .first<{ page_count: number }>(); - await app.storage.DB.exec(`PRAGMA max_page_count = ${Number(pageCountRow?.page_count ?? 0)}`); + const pageCountRow = await app.storage.DB.prepare("PRAGMA page_count").first<{ + page_count: number; + }>(); + await app.storage.DB.exec( + `PRAGMA max_page_count = ${Number(pageCountRow?.page_count ?? 0)}`, + ); let full = false; for (let index = 0; index < 5_000 && !full; index += 1) { try { - await insertToken(`tok_filler_${index}_${crypto.randomUUID().replace(/-/g, "")}`, expiredAt); + await insertToken( + `tok_filler_${index}_${crypto.randomUUID().replace(/-/g, "")}`, + expiredAt, + ); } catch (error) { if (!isStorageCapacityExhausted(error)) { throw error; @@ -429,78 +481,103 @@ async function fillDatabaseToCeiling( assert.ok(full, "database must reach its page ceiling for this scenario"); } -async function countStoredTokens(app: ReturnType): Promise { - const row = await app.storage.DB.prepare("SELECT COUNT(*) AS count FROM tokens").first<{ count: number }>(); +async function countStoredTokens( + app: ReturnType, +): Promise { + const row = await app.storage.DB.prepare( + "SELECT COUNT(*) AS count FROM tokens", + ).first<{ count: number }>(); return Number(row?.count ?? 0); } test("POST /v1/tokens", async (t) => { - await t.test("issues a Phase 0 RS256 token pair with token-format claim shape", async () => { - const { app, identity, authHeaders } = await createHarness(); - - const response = await requestRoute(app, "POST", "/v1/tokens", { - body: { - identityId: identity.id, - scopes: ["specialist:invoke"], - audience: ["specialist"], - expiresIn: 3600, - }, - headers: authHeaders, - }); + await t.test( + "issues a Phase 0 RS256 token pair with token-format claim shape", + async () => { + const { app, identity, authHeaders } = await createHarness(); - const body = await assertJsonResponse(response, 201); - assert.equal(body.tokenType, "Bearer"); - assert.match(body.accessToken, /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/); - assert.match(body.refreshToken, /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/); - assert.equal(Number.isNaN(Date.parse(body.accessTokenExpiresAt)), false); - assert.equal(Number.isNaN(Date.parse(body.refreshTokenExpiresAt)), false); - assert.ok( - Date.parse(body.refreshTokenExpiresAt) > Date.parse(body.accessTokenExpiresAt), - "refresh expiry should be after access expiry", - ); - - const accessClaims = decodeJwtJsonSegment(body.accessToken, 1); - const refreshClaims = decodeJwtJsonSegment(body.refreshToken, 1); - assertTokenClaimsMatchSpec(accessClaims, { - tokenType: "access", - expectedIdentity: identity, - expectedAudience: ["specialist"], - expectedScopes: ["specialist:invoke"], - }); - assertTokenClaimsMatchSpec(refreshClaims, { - tokenType: "refresh", - expectedIdentity: identity, - expectedAudience: ["relayauth"], - expectedScopes: ["relayauth:token:refresh"], - }); + const response = await requestRoute(app, "POST", "/v1/tokens", { + body: { + identityId: identity.id, + scopes: ["specialist:invoke"], + audience: ["specialist"], + expiresIn: 3600, + }, + headers: authHeaders, + }); - await assertRs256Algorithm(body.accessToken, ["specialist"]); - await assertRs256Algorithm(body.refreshToken, ["relayauth"]); - }); + const body = await assertJsonResponse(response, 201); + assert.equal(body.tokenType, "Bearer"); + assert.match( + body.accessToken, + /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/, + ); + assert.match( + body.refreshToken, + /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/, + ); + assert.equal(Number.isNaN(Date.parse(body.accessTokenExpiresAt)), false); + assert.equal(Number.isNaN(Date.parse(body.refreshTokenExpiresAt)), false); + assert.ok( + Date.parse(body.refreshTokenExpiresAt) > + Date.parse(body.accessTokenExpiresAt), + "refresh expiry should be after access expiry", + ); - await t.test("fails closed without token rows when the atomic audit commit fails", async () => { - const deferred: DeferredTask[] = []; - const { app, identity, authHeaders } = await createHarness({ - deferTask: (task) => deferred.push(task), - }); - let atomicCommitAttempts = 0; - app.storage.tokens.persistIssuedPairWithAudit = async () => { - atomicCommitAttempts += 1; - throw new Error("audit insert failed"); - }; + const accessClaims = decodeJwtJsonSegment( + body.accessToken, + 1, + ); + const refreshClaims = decodeJwtJsonSegment( + body.refreshToken, + 1, + ); + assertTokenClaimsMatchSpec(accessClaims, { + tokenType: "access", + expectedIdentity: identity, + expectedAudience: ["specialist"], + expectedScopes: ["specialist:invoke"], + }); + assertTokenClaimsMatchSpec(refreshClaims, { + tokenType: "refresh", + expectedIdentity: identity, + expectedAudience: ["relayauth"], + expectedScopes: ["relayauth:token:refresh"], + }); - const response = await withSilencedConsoleError(() => requestRoute(app, "POST", "/v1/tokens", { - body: { identityId: identity.id }, - headers: authHeaders, - })); + await assertRs256Algorithm(body.accessToken, ["specialist"]); + await assertRs256Algorithm(body.refreshToken, ["relayauth"]); + }, + ); + + await t.test( + "fails closed without token rows when the atomic audit commit fails", + async () => { + const deferred: DeferredTask[] = []; + const { app, identity, authHeaders } = await createHarness({ + deferTask: (task) => deferred.push(task), + }); + let atomicCommitAttempts = 0; + app.storage.tokens.persistIssuedPairWithAudit = async () => { + atomicCommitAttempts += 1; + throw new Error("audit insert failed"); + }; + + const response = await withSilencedConsoleError(() => + requestRoute(app, "POST", "/v1/tokens", { + body: { identityId: identity.id }, + headers: authHeaders, + }), + ); - await assertJsonResponse(response, 500, (body) => { - assert.equal(body.code, "internal_error"); - }); - assert.equal(deferred.length, 0, "token mint audit must not be deferred"); - assert.equal(atomicCommitAttempts, 1); - assert.equal(await countStoredTokens(app), 0); - }); + await assertJsonResponse(response, 500, (body) => { + assert.equal(body.code, "internal_error"); + }); + assert.equal(deferred.length, 0, "token mint audit must not be deferred"); + assert.equal(atomicCommitAttempts, 1); + assert.equal(await countStoredTokens(app), 0); + }, + ); await t.test("returns 401 when Authorization is missing", async () => { const { app, identity } = await createHarness(); @@ -529,88 +606,117 @@ test("POST /v1/tokens", async (t) => { }); }); - await t.test("returns 400 when identityId is missing from the request body", async () => { - const { app, authHeaders } = await createHarness(); + await t.test( + "returns 400 when identityId is missing from the request body", + async () => { + const { app, authHeaders } = await createHarness(); - const response = await requestRoute(app, "POST", "/v1/tokens", { - body: { - scopes: ["specialist:invoke"], - audience: ["specialist"], - }, - headers: authHeaders, - }); + const response = await requestRoute(app, "POST", "/v1/tokens", { + body: { + scopes: ["specialist:invoke"], + audience: ["specialist"], + }, + headers: authHeaders, + }); - await assertJsonResponse(response, 400, (body) => { - assert.match(JSON.stringify(body), /identityId/i); - }); - }); + await assertJsonResponse(response, 400, (body) => { + assert.match(JSON.stringify(body), /identityId/i); + }); + }, + ); - await t.test("returns 404 when the requested identity does not exist", async () => { - const { app, authHeaders } = await createHarness(); + await t.test( + "returns 404 when the requested identity does not exist", + async () => { + const { app, authHeaders } = await createHarness(); - const response = await requestRoute(app, "POST", "/v1/tokens", { - body: { - identityId: "agent_missing_for_tokens", - scopes: ["specialist:invoke"], - audience: ["specialist"], - }, - headers: authHeaders, - }); + const response = await requestRoute(app, "POST", "/v1/tokens", { + body: { + identityId: "agent_missing_for_tokens", + scopes: ["specialist:invoke"], + audience: ["specialist"], + }, + headers: authHeaders, + }); - await assertJsonResponse(response, 404, (body) => { - assert.match(JSON.stringify(body), /identity|not[_ -]?found/i); - }); - }); + await assertJsonResponse(response, 404, (body) => { + assert.match(JSON.stringify(body), /identity|not[_ -]?found/i); + }); + }, + ); - await t.test("returns 403 when requested scopes exceed the target identity grant", async () => { - const { app, identity, authHeaders } = await createHarness(); + await t.test( + "returns 403 when requested scopes exceed the target identity grant", + async () => { + const { app, identity, authHeaders } = await createHarness(); - const response = await requestRoute(app, "POST", "/v1/tokens", { - body: { - identityId: identity.id, - scopes: ["relayauth:identity:manage:*"], - audience: ["relayauth"], - }, - headers: authHeaders, - }); + const response = await requestRoute(app, "POST", "/v1/tokens", { + body: { + identityId: identity.id, + scopes: ["relayauth:identity:manage:*"], + audience: ["relayauth"], + }, + headers: authHeaders, + }); - await assertJsonResponse(response, 403, (body) => { - assert.equal(body.error, "insufficient_scope"); - }); - }); + await assertJsonResponse(response, 403, (body) => { + assert.equal(body.error, "insufficient_scope"); + }); + }, + ); }); test("POST /v1/tokens when the database cannot allocate", async (t) => { - await t.test("returns a typed capacity envelope instead of an unhandled 500", async () => { - const { app, identity, authHeaders } = await createHarness(); - await fillDatabaseToCeiling(app, identity.id); - - const response = await withSilencedConsoleError(() => requestRoute(app, "POST", "/v1/tokens", { - body: { identityId: identity.id }, - headers: authHeaders, - })); + await t.test( + "returns a typed capacity envelope instead of an unhandled 500", + async () => { + const { app, identity, authHeaders } = await createHarness(); + await fillDatabaseToCeiling(app, identity.id); + + const response = await withSilencedConsoleError(() => + requestRoute(app, "POST", "/v1/tokens", { + body: { identityId: identity.id }, + headers: authHeaders, + }), + ); - assert.equal(response.headers.get("Retry-After"), "30"); - await assertJsonResponse(response, 503, (body) => { - assert.equal(body.code, "storage_capacity_exhausted"); - assert.equal(body.error, "Storage is at capacity"); - assert.equal(body.retryable, true); - }); - }); + assert.equal(response.headers.get("Retry-After"), "30"); + await assertJsonResponse( + response, + 503, + (body) => { + assert.equal(body.code, "storage_capacity_exhausted"); + assert.equal(body.error, "Storage is at capacity"); + assert.equal(body.retryable, true); + }, + ); + }, + ); await t.test("mints again once retention reclaims space", async () => { const { app, identity, authHeaders } = await createHarness(); await fillDatabaseToCeiling(app, identity.id); - const blocked = await withSilencedConsoleError(() => requestRoute(app, "POST", "/v1/tokens", { - body: { identityId: identity.id }, - headers: authHeaders, - })); + const blocked = await withSilencedConsoleError(() => + requestRoute(app, "POST", "/v1/tokens", { + body: { identityId: identity.id }, + headers: authHeaders, + }), + ); assert.equal(blocked.status, 503); - const window = await scanExpiredTokensWindow(app.storage.DB as never, { cursor: 0, limit: 1_000 }); - const pruned = await pruneExpiredTokensWindow(app.storage.DB as never, window); - assert.ok(pruned.deletedCount > 0, "retention must reclaim the expired rows"); + const window = await scanExpiredTokensWindow(app.storage.DB as never, { + cursor: 0, + limit: 1_000, + }); + const pruned = await pruneExpiredTokensWindow( + app.storage.DB as never, + window, + ); + assert.ok( + pruned.deletedCount > 0, + "retention must reclaim the expired rows", + ); const recovered = await requestRoute(app, "POST", "/v1/tokens", { body: { identityId: identity.id }, @@ -622,58 +728,71 @@ test("POST /v1/tokens when the database cannot allocate", async (t) => { }); }); - await t.test("carries a hosted adapter's translated capacity error", async () => { - const { app, identity, authHeaders } = await createHarness(); - app.storage.tokens.persistIssuedPairWithAudit = async () => { - throw new StorageCapacityExhaustedError("tokens.persist"); - }; - - const response = await withSilencedConsoleError(() => requestRoute(app, "POST", "/v1/tokens", { - body: { identityId: identity.id }, - headers: authHeaders, - })); - - await assertJsonResponse(response, 503, (body) => { - assert.equal(body.code, "storage_capacity_exhausted"); - }); - }); + await t.test( + "carries a hosted adapter's translated capacity error", + async () => { + const { app, identity, authHeaders } = await createHarness(); + app.storage.tokens.persistIssuedPairWithAudit = async () => { + throw new StorageCapacityExhaustedError("tokens.persist"); + }; - await t.test("leaves unrelated internal failures on the generic error envelope", async () => { - const { app, identity, authHeaders } = await createHarness(); - app.storage.tokens.persistIssuedPairWithAudit = async () => { - throw new Error("unexpected mint failure"); - }; + const response = await withSilencedConsoleError(() => + requestRoute(app, "POST", "/v1/tokens", { + body: { identityId: identity.id }, + headers: authHeaders, + }), + ); - const response = await withSilencedConsoleError(() => requestRoute(app, "POST", "/v1/tokens", { - body: { identityId: identity.id }, - headers: authHeaders, - })); + await assertJsonResponse(response, 503, (body) => { + assert.equal(body.code, "storage_capacity_exhausted"); + }); + }, + ); + + await t.test( + "leaves unrelated internal failures on the generic error envelope", + async () => { + const { app, identity, authHeaders } = await createHarness(); + app.storage.tokens.persistIssuedPairWithAudit = async () => { + throw new Error("unexpected mint failure"); + }; + + const response = await withSilencedConsoleError(() => + requestRoute(app, "POST", "/v1/tokens", { + body: { identityId: identity.id }, + headers: authHeaders, + }), + ); - await assertJsonResponse(response, 500, (body) => { - assert.equal(body.code, "internal_error"); - }); - }); + await assertJsonResponse(response, 500, (body) => { + assert.equal(body.code, "internal_error"); + }); + }, + ); }); test("POST /v1/tokens/workspace", async (t) => { - await t.test("issues a long-lived workspace token with a relay_ws_ prefix", async () => { - const { app, authHeaders } = await createHarness({ - authClaims: { - scopes: [ - "relayauth:api-key:manage:*", - "relayauth:token:create:*", - "relayauth:token:read:*", - "relayauth:role:read:*", - ], - }, - }); + await t.test( + "issues a long-lived workspace token with a relay_ws_ prefix", + async () => { + const { app, authHeaders } = await createHarness({ + authClaims: { + scopes: [ + "relayauth:api-key:manage:*", + "relayauth:token:create:*", + "relayauth:token:read:*", + "relayauth:role:read:*", + ], + }, + }); - const body = await issueWorkspaceToken(app, authHeaders); - assert.equal(body.workspaceToken.kind, "workspace_token"); - assert.equal(body.workspaceToken.workspaceId, "ws_tokens_route"); - assert.match(body.key, /^relay_ws_[A-Za-z0-9_-]+$/); - assert.ok(body.key.startsWith(body.workspaceToken.prefix)); - }); + const body = await issueWorkspaceToken(app, authHeaders); + assert.equal(body.workspaceToken.kind, "workspace_token"); + assert.equal(body.workspaceToken.workspaceId, "ws_tokens_route"); + assert.match(body.key, /^relay_ws_[A-Za-z0-9_-]+$/); + assert.ok(body.key.startsWith(body.workspaceToken.prefix)); + }, + ); await t.test("rejects scope escalation beyond the caller grant", async () => { const { app, authHeaders } = await createHarness({ @@ -698,437 +817,654 @@ test("POST /v1/tokens/workspace", async (t) => { }); test("POST /v1/tokens/agent", async (t) => { - await t.test("exchanges a workspace token for a prefixed agent token pair", async () => { - const { app, identity, authHeaders } = await createHarness({ - authClaims: { + await t.test( + "exchanges a workspace token for a prefixed agent token pair", + async () => { + const { app, identity, authHeaders } = await createHarness({ + authClaims: { + scopes: [ + "relayauth:api-key:manage:*", + "relayauth:token:create:*", + "relayauth:token:read:*", + "relayauth:role:read:*", + ], + }, + identity: createStoredIdentity({ + id: "agent_runtime_roles", + orgId: "org_tokens_route", + workspaceId: "ws_tokens_route", + scopes: ["relayauth:role:read:*"], + }), + }); + + const workspaceToken = await issueWorkspaceToken(app, authHeaders); + const response = await requestRoute(app, "POST", "/v1/tokens/agent", { + body: { + agentId: identity.id, + scopes: ["relayauth:role:read:*"], + audience: ["relayauth"], + expiresIn: 7200, + }, + headers: { + "x-api-key": workspaceToken.key, + }, + }); + + const body = await assertJsonResponse(response, 201); + assert.equal(body.agentId, identity.id); + assert.equal(body.workspaceId, identity.workspaceId); + assert.equal(body.tokenClass, "relay_ag"); + assert.match( + body.accessToken, + /^relay_ag_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/, + ); + assert.match( + body.refreshToken, + /^relay_ag_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/, + ); + + const accessClaims = decodeJwtJsonSegment( + body.accessToken, + 1, + ); + const refreshClaims = decodeJwtJsonSegment( + body.refreshToken, + 1, + ); + assert.equal(accessClaims.meta?.tokenClass, "agent"); + assert.equal( + accessClaims.meta?.workspaceTokenId, + workspaceToken.workspaceToken.id, + ); + assert.deepEqual(JSON.parse(accessClaims.meta?.accessScopes ?? "[]"), [ + "relayauth:role:read:*", + ]); + assert.equal( + accessClaims.parentTokenId, + workspaceToken.workspaceToken.id, + ); + assert.equal(refreshClaims.meta?.tokenClass, "agent"); + assert.equal( + refreshClaims.parentTokenId, + workspaceToken.workspaceToken.id, + ); + assert.ok( + accessClaims.exp - accessClaims.iat <= 3600, + "agent access TTL should cap at 1h", + ); + }, + ); + + await t.test( + "rejects bearer auth when a workspace token is required", + async () => { + const { app, identity, authHeaders } = await createHarness(); + const response = await requestRoute(app, "POST", "/v1/tokens/agent", { + body: { + agentId: identity.id, + }, + headers: authHeaders, + }); + + await assertJsonResponse(response, 401, (body) => { + assert.equal(body.code, "workspace_token_required"); + }); + }, + ); +}); + +test("POST /v1/tokens/path", async (t) => { + await t.test( + "mints a relay_pa token pair from a workspace token", + async () => { + const { app, authHeaders } = await createHarness({ + authClaims: { + scopes: [ + "relayauth:api-key:manage:*", + "relayauth:token:create:*", + "relayfile:fs:read:*", + "relayfile:fs:write:*", + ], + }, + }); + const workspaceToken = await issueWorkspaceToken(app, authHeaders, { scopes: [ - "relayauth:api-key:manage:*", "relayauth:token:create:*", - "relayauth:token:read:*", - "relayauth:role:read:*", + "relayfile:fs:read:*", + "relayfile:fs:write:*", ], - }, - identity: createStoredIdentity({ - id: "agent_runtime_roles", - orgId: "org_tokens_route", - workspaceId: "ws_tokens_route", - scopes: ["relayauth:role:read:*"], - }), - }); + }); + const delegationNotAfter = new Date( + (Math.floor(Date.now() / 1000) + 30 * 60) * 1000, + ).toISOString(); - const workspaceToken = await issueWorkspaceToken(app, authHeaders); - const response = await requestRoute(app, "POST", "/v1/tokens/agent", { - body: { - agentId: identity.id, - scopes: ["relayauth:role:read:*"], - audience: ["relayauth"], - expiresIn: 7200, - }, - headers: { - "x-api-key": workspaceToken.key, - }, - }); + const response = await requestRoute(app, "POST", "/v1/tokens/path", { + body: { + workspaceId: "ws_tokens_route", + agentName: "cloud-orchestrator", + paths: ["/linear/issues/**"], + ttlSeconds: 7200, + delegationNotAfter, + }, + headers: { + Authorization: `Bearer ${workspaceToken.key}`, + }, + }); - const body = await assertJsonResponse(response, 201); - assert.equal(body.agentId, identity.id); - assert.equal(body.workspaceId, identity.workspaceId); - assert.equal(body.tokenClass, "relay_ag"); - assert.match(body.accessToken, /^relay_ag_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/); - assert.match(body.refreshToken, /^relay_ag_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/); - - const accessClaims = decodeJwtJsonSegment(body.accessToken, 1); - const refreshClaims = decodeJwtJsonSegment(body.refreshToken, 1); - assert.equal(accessClaims.meta?.tokenClass, "agent"); - assert.equal(accessClaims.meta?.workspaceTokenId, workspaceToken.workspaceToken.id); - assert.deepEqual(JSON.parse(accessClaims.meta?.accessScopes ?? "[]"), ["relayauth:role:read:*"]); - assert.equal(accessClaims.parentTokenId, workspaceToken.workspaceToken.id); - assert.equal(refreshClaims.meta?.tokenClass, "agent"); - assert.equal(refreshClaims.parentTokenId, workspaceToken.workspaceToken.id); - assert.ok(accessClaims.exp - accessClaims.iat <= 3600, "agent access TTL should cap at 1h"); - }); + const body = await assertJsonResponse(response, 201); + assert.equal(body.agentId, "agent_cloud-orchestrator"); + assert.equal(body.agentName, "cloud-orchestrator"); + assert.equal(body.workspaceId, "ws_tokens_route"); + assert.equal(body.tokenClass, "relay_pa"); + assert.deepEqual(body.paths, ["/linear/issues/*"]); + assert.equal(body.delegationNotAfter, delegationNotAfter); + assert.match( + body.accessToken, + /^relay_pa_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/, + ); + assert.match( + body.refreshToken, + /^relay_pa_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/, + ); + assert.ok( + Date.parse(body.accessTokenExpiresAt) <= Date.parse(delegationNotAfter), + ); + assert.ok( + Date.parse(body.refreshTokenExpiresAt) <= + Date.parse(delegationNotAfter), + ); - await t.test("rejects bearer auth when a workspace token is required", async () => { - const { app, identity, authHeaders } = await createHarness(); - const response = await requestRoute(app, "POST", "/v1/tokens/agent", { - body: { - agentId: identity.id, - }, - headers: authHeaders, - }); + const accessClaims = decodeJwtJsonSegment( + body.accessToken, + 1, + ); + const refreshClaims = decodeJwtJsonSegment( + body.refreshToken, + 1, + ); + assert.equal(accessClaims.sub, "agent_cloud-orchestrator"); + assert.equal(accessClaims.meta?.tokenClass, "path"); + assert.equal( + accessClaims.meta?.workspaceTokenId, + workspaceToken.workspaceToken.id, + ); + assert.equal(accessClaims.meta?.agentName, "cloud-orchestrator"); + assert.equal(accessClaims.meta?.delegationNotAfter, delegationNotAfter); + assert.equal(refreshClaims.meta?.delegationNotAfter, delegationNotAfter); + assert.deepEqual(JSON.parse(accessClaims.meta?.paths ?? "[]"), [ + "/linear/issues/*", + ]); + assert.deepEqual(accessClaims.scopes, [ + "relayfile:fs:read:/linear/issues/*", + "relayfile:fs:write:/linear/issues/*", + ]); + assert.deepEqual(accessClaims.aud, ["relayfile"]); + assert.ok( + accessClaims.exp - accessClaims.iat <= 3600, + "path access TTL should cap at 1h", + ); - await assertJsonResponse(response, 401, (body) => { - assert.equal(body.code, "workspace_token_required"); - }); - }); + const refreshResponse = await requestRoute( + app, + "POST", + "/v1/tokens/refresh", + { + body: { + refreshToken: body.refreshToken, + }, + }, + ); + const refreshed = await assertJsonResponse( + refreshResponse, + 200, + ); + assert.match( + refreshed.accessToken, + /^relay_pa_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/, + ); + assert.match( + refreshed.refreshToken, + /^relay_pa_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/, + ); + assert.ok( + Date.parse(refreshed.accessTokenExpiresAt) <= + Date.parse(delegationNotAfter), + ); + assert.ok( + Date.parse(refreshed.refreshTokenExpiresAt) <= + Date.parse(delegationNotAfter), + ); + const refreshedClaims = decodeJwtJsonSegment( + refreshed.accessToken, + 1, + ); + assert.equal(refreshedClaims.meta?.tokenClass, "path"); + assert.equal( + refreshedClaims.meta?.delegationNotAfter, + delegationNotAfter, + ); + assert.deepEqual(refreshedClaims.scopes, accessClaims.scopes); + }, + ); + + await t.test( + "rejects path scopes outside the workspace token grant", + async () => { + const { app, authHeaders } = await createHarness({ + authClaims: { + scopes: [ + "relayauth:api-key:manage:*", + "relayauth:token:create:*", + "relayfile:fs:read:*", + ], + }, + }); + const workspaceToken = await issueWorkspaceToken(app, authHeaders, { + scopes: ["relayauth:token:create:*", "relayfile:fs:read:*"], + }); + + const response = await requestRoute(app, "POST", "/v1/tokens/path", { + body: { + agentId: "agent_path_subject", + paths: ["/linear/issues/**"], + }, + headers: { + "x-api-key": workspaceToken.key, + }, + }); + + await assertJsonResponse(response, 403, (body) => { + assert.equal(body.code, "insufficient_scope"); + }); + }, + ); }); -test("POST /v1/tokens/path", async (t) => { - await t.test("mints a relay_pa token pair from a workspace token", async () => { +test("POST /v1/tokens/workspace-path", async (t) => { + await t.test("requires workspaceId", async () => { const { app, authHeaders } = await createHarness({ authClaims: { scopes: [ "relayauth:api-key:manage:*", - "relayauth:token:create:*", "relayfile:fs:read:*", "relayfile:fs:write:*", ], }, }); - const workspaceToken = await issueWorkspaceToken(app, authHeaders, { - scopes: ["relayauth:token:create:*", "relayfile:fs:read:*", "relayfile:fs:write:*"], - }); - const delegationNotAfter = new Date((Math.floor(Date.now() / 1000) + 30 * 60) * 1000).toISOString(); + const orgApiKey = await issueApiKey(app, authHeaders, [ + "relayauth:api-key:manage:*", + "relayfile:fs:read:*", + "relayfile:fs:write:*", + ]); - const response = await requestRoute(app, "POST", "/v1/tokens/path", { - body: { - workspaceId: "ws_tokens_route", - agentName: "cloud-orchestrator", - paths: ["/linear/issues/**"], - ttlSeconds: 7200, - delegationNotAfter, - }, - headers: { - Authorization: `Bearer ${workspaceToken.key}`, - }, - }); - - const body = await assertJsonResponse(response, 201); - assert.equal(body.agentId, "agent_cloud-orchestrator"); - assert.equal(body.agentName, "cloud-orchestrator"); - assert.equal(body.workspaceId, "ws_tokens_route"); - assert.equal(body.tokenClass, "relay_pa"); - assert.deepEqual(body.paths, ["/linear/issues/*"]); - assert.equal(body.delegationNotAfter, delegationNotAfter); - assert.match(body.accessToken, /^relay_pa_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/); - assert.match(body.refreshToken, /^relay_pa_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/); - assert.ok(Date.parse(body.accessTokenExpiresAt) <= Date.parse(delegationNotAfter)); - assert.ok(Date.parse(body.refreshTokenExpiresAt) <= Date.parse(delegationNotAfter)); - - const accessClaims = decodeJwtJsonSegment(body.accessToken, 1); - const refreshClaims = decodeJwtJsonSegment(body.refreshToken, 1); - assert.equal(accessClaims.sub, "agent_cloud-orchestrator"); - assert.equal(accessClaims.meta?.tokenClass, "path"); - assert.equal(accessClaims.meta?.workspaceTokenId, workspaceToken.workspaceToken.id); - assert.equal(accessClaims.meta?.agentName, "cloud-orchestrator"); - assert.equal(accessClaims.meta?.delegationNotAfter, delegationNotAfter); - assert.equal(refreshClaims.meta?.delegationNotAfter, delegationNotAfter); - assert.deepEqual(JSON.parse(accessClaims.meta?.paths ?? "[]"), ["/linear/issues/*"]); - assert.deepEqual(accessClaims.scopes, [ - "relayfile:fs:read:/linear/issues/*", - "relayfile:fs:write:/linear/issues/*", - ]); - assert.deepEqual(accessClaims.aud, ["relayfile"]); - assert.ok(accessClaims.exp - accessClaims.iat <= 3600, "path access TTL should cap at 1h"); - - const refreshResponse = await requestRoute(app, "POST", "/v1/tokens/refresh", { - body: { - refreshToken: body.refreshToken, - }, - }); - const refreshed = await assertJsonResponse(refreshResponse, 200); - assert.match(refreshed.accessToken, /^relay_pa_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/); - assert.match(refreshed.refreshToken, /^relay_pa_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/); - assert.ok(Date.parse(refreshed.accessTokenExpiresAt) <= Date.parse(delegationNotAfter)); - assert.ok(Date.parse(refreshed.refreshTokenExpiresAt) <= Date.parse(delegationNotAfter)); - const refreshedClaims = decodeJwtJsonSegment(refreshed.accessToken, 1); - assert.equal(refreshedClaims.meta?.tokenClass, "path"); - assert.equal(refreshedClaims.meta?.delegationNotAfter, delegationNotAfter); - assert.deepEqual(refreshedClaims.scopes, accessClaims.scopes); - }); - - await t.test("rejects path scopes outside the workspace token grant", async () => { - const { app, authHeaders } = await createHarness({ - authClaims: { - scopes: ["relayauth:api-key:manage:*", "relayauth:token:create:*", "relayfile:fs:read:*"], - }, - }); - const workspaceToken = await issueWorkspaceToken(app, authHeaders, { - scopes: ["relayauth:token:create:*", "relayfile:fs:read:*"], - }); - - const response = await requestRoute(app, "POST", "/v1/tokens/path", { - body: { - agentId: "agent_path_subject", - paths: ["/linear/issues/**"], - }, - headers: { - "x-api-key": workspaceToken.key, - }, - }); - - await assertJsonResponse(response, 403, (body) => { - assert.equal(body.code, "insufficient_scope"); - }); - }); -}); - -test("POST /v1/tokens/workspace-path", async (t) => { - await t.test("requires workspaceId", async () => { - const { app, authHeaders } = await createHarness({ - authClaims: { - scopes: [ - "relayauth:api-key:manage:*", - "relayfile:fs:read:*", - "relayfile:fs:write:*", - ], - }, - }); - const orgApiKey = await issueApiKey(app, authHeaders, [ - "relayauth:api-key:manage:*", - "relayfile:fs:read:*", - "relayfile:fs:write:*", - ]); - - const response = await requestRoute(app, "POST", "/v1/tokens/workspace-path", { - body: { - paths: ["/github/repos/AgentWorkforce/cloud/issues/123/**"], - scopes: ["relayfile:fs:write:/github/repos/AgentWorkforce/cloud/issues/123/**"], - }, - headers: { - "x-api-key": orgApiKey.key, + const response = await requestRoute( + app, + "POST", + "/v1/tokens/workspace-path", + { + body: { + paths: ["/github/repos/AgentWorkforce/cloud/issues/123/**"], + scopes: [ + "relayfile:fs:write:/github/repos/AgentWorkforce/cloud/issues/123/**", + ], + }, + headers: { + "x-api-key": orgApiKey.key, + }, }, - }); + ); await assertJsonResponse(response, 400, (body) => { assert.equal(body.code, "workspaceId_required"); }); }); - await t.test("mints a short-lived relay_pa directly from an org api key without a seeded workspace row", async () => { - const { app, authHeaders } = await createHarness({ - authClaims: { - scopes: [ - "relayauth:api-key:manage:*", - "relayfile:fs:read:*", - "relayfile:fs:write:*", - ], - }, - }); - const orgApiKey = await issueApiKey(app, authHeaders, [ - "relayauth:api-key:manage:*", - "relayfile:fs:read:*", - "relayfile:fs:write:*", - ]); - - const response = await requestRoute(app, "POST", "/v1/tokens/workspace-path", { - body: { - workspaceId: " ws_tokens_route ", - agentName: "cloud-team-member", - paths: ["/github/repos/AgentWorkforce/cloud/issues/123/**"], - scopes: ["relayfile:fs:write:/github/repos/AgentWorkforce/cloud/issues/123/**"], - ttlSeconds: 120, - }, - headers: { - "x-api-key": orgApiKey.key, - }, - }); - - const body = await assertJsonResponse(response, 201); - assert.equal(body.agentId, "agent_cloud-team-member"); - assert.equal(body.agentName, "cloud-team-member"); - assert.equal(body.workspaceId, "ws_tokens_route"); - assert.equal(body.tokenClass, "relay_pa"); - assert.deepEqual(body.paths, ["/github/repos/AgentWorkforce/cloud/issues/123/*"]); - assert.match(body.accessToken, /^relay_pa_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/); - assert.match(body.refreshToken, /^relay_pa_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/); - assert.equal("issuedViaWorkspaceTokenId" in body, false); - assert.equal("workspaceToken" in body, false); - assert.equal("key" in body, false); - - const accessClaims = decodeJwtJsonSegment(body.accessToken, 1); - assert.equal(accessClaims.sub, "agent_cloud-team-member"); - assert.equal(accessClaims.wks, "ws_tokens_route"); - assert.equal(accessClaims.org, "org_tokens_route"); - assert.equal(accessClaims.meta?.tokenClass, "path"); - assert.equal(accessClaims.meta?.workspaceTokenId, undefined); - assert.equal(accessClaims.parentTokenId, undefined); - assert.deepEqual(JSON.parse(accessClaims.meta?.paths ?? "[]"), ["/github/repos/AgentWorkforce/cloud/issues/123/*"]); - assert.deepEqual(accessClaims.scopes, [ - "relayfile:fs:write:/github/repos/AgentWorkforce/cloud/issues/123/*", - ]); - assert.deepEqual(accessClaims.aud, ["relayfile"]); - assert.ok(accessClaims.exp - accessClaims.iat <= 120, "direct path access TTL should honor short ttlSeconds"); - }); - - await t.test("mints a provider-subtree scope for a narrower writeback path", async () => { - const { app, authHeaders } = await createHarness({ - authClaims: { - scopes: [ - "relayauth:api-key:manage:*", - "relayfile:fs:read:*", - "relayfile:fs:write:*", - ], - }, - }); - const orgApiKey = await issueApiKey(app, authHeaders, [ - "relayauth:api-key:manage:*", - "relayfile:fs:read:*", - "relayfile:fs:write:*", - ]); - - const response = await requestRoute(app, "POST", "/v1/tokens/workspace-path", { - body: { - workspaceId: "ws_tokens_route", - agentName: "relayfile-writeback", - paths: ["/linear/issues/issue-55.json"], - scopes: ["relayfile:fs:write:/linear/**"], - }, - headers: { - "x-api-key": orgApiKey.key, - }, - }); + await t.test( + "mints a short-lived relay_pa directly from an org api key without a seeded workspace row", + async () => { + const { app, authHeaders } = await createHarness({ + authClaims: { + scopes: [ + "relayauth:api-key:manage:*", + "relayfile:fs:read:*", + "relayfile:fs:write:*", + ], + }, + }); + const orgApiKey = await issueApiKey(app, authHeaders, [ + "relayauth:api-key:manage:*", + "relayfile:fs:read:*", + "relayfile:fs:write:*", + ]); + + const response = await requestRoute( + app, + "POST", + "/v1/tokens/workspace-path", + { + body: { + workspaceId: " ws_tokens_route ", + agentName: "cloud-team-member", + paths: ["/github/repos/AgentWorkforce/cloud/issues/123/**"], + scopes: [ + "relayfile:fs:write:/github/repos/AgentWorkforce/cloud/issues/123/**", + ], + ttlSeconds: 120, + }, + headers: { + "x-api-key": orgApiKey.key, + }, + }, + ); - const body = await assertJsonResponse(response, 201); - assert.deepEqual(body.paths, ["/linear/issues/issue-55.json"]); + const body = await assertJsonResponse( + response, + 201, + ); + assert.equal(body.agentId, "agent_cloud-team-member"); + assert.equal(body.agentName, "cloud-team-member"); + assert.equal(body.workspaceId, "ws_tokens_route"); + assert.equal(body.tokenClass, "relay_pa"); + assert.deepEqual(body.paths, [ + "/github/repos/AgentWorkforce/cloud/issues/123/*", + ]); + assert.match( + body.accessToken, + /^relay_pa_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/, + ); + assert.match( + body.refreshToken, + /^relay_pa_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/, + ); + assert.equal("issuedViaWorkspaceTokenId" in body, false); + assert.equal("workspaceToken" in body, false); + assert.equal("key" in body, false); - const accessClaims = decodeJwtJsonSegment(body.accessToken, 1); - assert.deepEqual(JSON.parse(accessClaims.meta?.paths ?? "[]"), ["/linear/issues/issue-55.json"]); - assert.deepEqual(accessClaims.scopes, ["relayfile:fs:write:/linear/*"]); - }); + const accessClaims = decodeJwtJsonSegment( + body.accessToken, + 1, + ); + assert.equal(accessClaims.sub, "agent_cloud-team-member"); + assert.equal(accessClaims.wks, "ws_tokens_route"); + assert.equal(accessClaims.org, "org_tokens_route"); + assert.equal(accessClaims.meta?.tokenClass, "path"); + assert.equal(accessClaims.meta?.workspaceTokenId, undefined); + assert.equal(accessClaims.parentTokenId, undefined); + assert.deepEqual(JSON.parse(accessClaims.meta?.paths ?? "[]"), [ + "/github/repos/AgentWorkforce/cloud/issues/123/*", + ]); + assert.deepEqual(accessClaims.scopes, [ + "relayfile:fs:write:/github/repos/AgentWorkforce/cloud/issues/123/*", + ]); + assert.deepEqual(accessClaims.aud, ["relayfile"]); + assert.ok( + accessClaims.exp - accessClaims.iat <= 120, + "direct path access TTL should honor short ttlSeconds", + ); + }, + ); + + await t.test( + "mints a provider-subtree scope for a narrower writeback path", + async () => { + const { app, authHeaders } = await createHarness({ + authClaims: { + scopes: [ + "relayauth:api-key:manage:*", + "relayfile:fs:read:*", + "relayfile:fs:write:*", + ], + }, + }); + const orgApiKey = await issueApiKey(app, authHeaders, [ + "relayauth:api-key:manage:*", + "relayfile:fs:read:*", + "relayfile:fs:write:*", + ]); + + const response = await requestRoute( + app, + "POST", + "/v1/tokens/workspace-path", + { + body: { + workspaceId: "ws_tokens_route", + agentName: "relayfile-writeback", + paths: ["/linear/issues/issue-55.json"], + scopes: ["relayfile:fs:write:/linear/**"], + }, + headers: { + "x-api-key": orgApiKey.key, + }, + }, + ); - await t.test("stamps the authenticated org even when the workspaceId is associated with another org", async () => { - const { app, authHeaders } = await createHarness({ - authClaims: { - org: "org_a", - scopes: [ - "relayauth:api-key:manage:*", - "relayfile:fs:read:*", - "relayfile:fs:write:*", - ], - }, - }); - await seedWorkspaceContext(app, { - id: "ws_owned_by_org_b", - workspaceId: "ws_owned_by_org_b", - orgId: "org_b", - scopes: [], - roles: [], - }); - const orgApiKey = await issueApiKey(app, authHeaders, [ - "relayauth:api-key:manage:*", - "relayfile:fs:read:*", - "relayfile:fs:write:*", - ]); + const body = await assertJsonResponse( + response, + 201, + ); + assert.deepEqual(body.paths, ["/linear/issues/issue-55.json"]); - const response = await requestRoute(app, "POST", "/v1/tokens/workspace-path", { - body: { + const accessClaims = decodeJwtJsonSegment( + body.accessToken, + 1, + ); + assert.deepEqual(JSON.parse(accessClaims.meta?.paths ?? "[]"), [ + "/linear/issues/issue-55.json", + ]); + assert.deepEqual(accessClaims.scopes, ["relayfile:fs:write:/linear/*"]); + }, + ); + + await t.test( + "stamps the authenticated org even when the workspaceId is associated with another org", + async () => { + const { app, authHeaders } = await createHarness({ + authClaims: { + org: "org_a", + scopes: [ + "relayauth:api-key:manage:*", + "relayfile:fs:read:*", + "relayfile:fs:write:*", + ], + }, + }); + await seedWorkspaceContext(app, { + id: "ws_owned_by_org_b", workspaceId: "ws_owned_by_org_b", - paths: ["/github/repos/AgentWorkforce/cloud/issues/123/*"], - scopes: ["relayfile:fs:write:/github/repos/AgentWorkforce/cloud/issues/123/*"], - }, - headers: { - "x-api-key": orgApiKey.key, - }, - }); - - const body = await assertJsonResponse(response, 201); - const accessClaims = decodeJwtJsonSegment(body.accessToken, 1); - assert.equal(accessClaims.org, "org_a"); - assert.equal(accessClaims.wks, "ws_owned_by_org_b"); - assert.equal(body.workspaceId, "ws_owned_by_org_b"); - }); - - await t.test("caps direct path token TTL at the agent-token maximum", async () => { - const { app, authHeaders } = await createHarness({ - authClaims: { - scopes: [ - "relayauth:api-key:manage:*", - "relayfile:fs:read:*", - "relayfile:fs:write:*", - ], - }, - }); - - const response = await requestRoute(app, "POST", "/v1/tokens/workspace-path", { - body: { - workspaceId: "ws_tokens_route", - paths: ["/github/repos/AgentWorkforce/cloud/issues/123/*"], - ttlSeconds: 7200, - }, - headers: authHeaders, - }); - - const body = await assertJsonResponse(response, 201); - const accessClaims = decodeJwtJsonSegment(body.accessToken, 1); - assert.ok(accessClaims.exp - accessClaims.iat <= 3600, "direct path access TTL should cap at 1h"); - }); + orgId: "org_b", + scopes: [], + roles: [], + }); + const orgApiKey = await issueApiKey(app, authHeaders, [ + "relayauth:api-key:manage:*", + "relayfile:fs:read:*", + "relayfile:fs:write:*", + ]); + + const response = await requestRoute( + app, + "POST", + "/v1/tokens/workspace-path", + { + body: { + workspaceId: "ws_owned_by_org_b", + paths: ["/github/repos/AgentWorkforce/cloud/issues/123/*"], + scopes: [ + "relayfile:fs:write:/github/repos/AgentWorkforce/cloud/issues/123/*", + ], + }, + headers: { + "x-api-key": orgApiKey.key, + }, + }, + ); - await t.test("rejects requested scopes outside the org api-key grant", async () => { - const { app, authHeaders } = await createHarness({ - authClaims: { - scopes: ["relayauth:api-key:manage:*", "relayfile:fs:read:*"], - }, - }); + const body = await assertJsonResponse( + response, + 201, + ); + const accessClaims = decodeJwtJsonSegment( + body.accessToken, + 1, + ); + assert.equal(accessClaims.org, "org_a"); + assert.equal(accessClaims.wks, "ws_owned_by_org_b"); + assert.equal(body.workspaceId, "ws_owned_by_org_b"); + }, + ); + + await t.test( + "caps direct path token TTL at the agent-token maximum", + async () => { + const { app, authHeaders } = await createHarness({ + authClaims: { + scopes: [ + "relayauth:api-key:manage:*", + "relayfile:fs:read:*", + "relayfile:fs:write:*", + ], + }, + }); - const response = await requestRoute(app, "POST", "/v1/tokens/workspace-path", { - body: { - workspaceId: "ws_tokens_route", - paths: ["/github/repos/AgentWorkforce/cloud/issues/123/*"], - scopes: ["relayfile:fs:write:/github/repos/AgentWorkforce/cloud/issues/123/*"], - }, - headers: authHeaders, - }); + const response = await requestRoute( + app, + "POST", + "/v1/tokens/workspace-path", + { + body: { + workspaceId: "ws_tokens_route", + paths: ["/github/repos/AgentWorkforce/cloud/issues/123/*"], + ttlSeconds: 7200, + }, + headers: authHeaders, + }, + ); - await assertJsonResponse(response, 403, (body) => { - assert.equal(body.code, "insufficient_scope"); - }); - }); + const body = await assertJsonResponse( + response, + 201, + ); + const accessClaims = decodeJwtJsonSegment( + body.accessToken, + 1, + ); + assert.ok( + accessClaims.exp - accessClaims.iat <= 3600, + "direct path access TTL should cap at 1h", + ); + }, + ); + + await t.test( + "rejects requested scopes outside the org api-key grant", + async () => { + const { app, authHeaders } = await createHarness({ + authClaims: { + scopes: ["relayauth:api-key:manage:*", "relayfile:fs:read:*"], + }, + }); - await t.test("rejects whole-tree scopes even with a narrow requested path", async () => { - const { app, authHeaders } = await createHarness({ - authClaims: { - scopes: ["relayauth:api-key:manage:*", "relayfile:fs:write:*"], - }, - }); + const response = await requestRoute( + app, + "POST", + "/v1/tokens/workspace-path", + { + body: { + workspaceId: "ws_tokens_route", + paths: ["/github/repos/AgentWorkforce/cloud/issues/123/*"], + scopes: [ + "relayfile:fs:write:/github/repos/AgentWorkforce/cloud/issues/123/*", + ], + }, + headers: authHeaders, + }, + ); - const wholeTreeScopes = [ - "relayfile:fs:write:*", - "relayfile:fs:write:/", - "relayfile:fs:write:/*", - "relayfile:fs:write:/**", - "relayfile:fs:write://", - "relayfile:fs:write://**", - "relayfile:fs:write:////", - "relayfile:fs:write:/./", - "relayfile:fs:write:/./**", - ]; - - for (const scope of wholeTreeScopes) { - const response = await requestRoute(app, "POST", "/v1/tokens/workspace-path", { - body: { - workspaceId: "ws_tokens_route", - paths: ["/linear/issues/issue-55.json"], - scopes: [scope], + await assertJsonResponse(response, 403, (body) => { + assert.equal(body.code, "insufficient_scope"); + }); + }, + ); + + await t.test( + "rejects whole-tree scopes even with a narrow requested path", + async () => { + const { app, authHeaders } = await createHarness({ + authClaims: { + scopes: ["relayauth:api-key:manage:*", "relayfile:fs:write:*"], }, - headers: authHeaders, }); - await assertJsonResponse(response, 400, (body) => { - assert.equal(body.code, "invalid_scope", `expected ${scope} to be rejected`); + const wholeTreeScopes = [ + "relayfile:fs:write:*", + "relayfile:fs:write:/", + "relayfile:fs:write:/*", + "relayfile:fs:write:/**", + "relayfile:fs:write://", + "relayfile:fs:write://**", + "relayfile:fs:write:////", + "relayfile:fs:write:/./", + "relayfile:fs:write:/./**", + ]; + + for (const scope of wholeTreeScopes) { + const response = await requestRoute( + app, + "POST", + "/v1/tokens/workspace-path", + { + body: { + workspaceId: "ws_tokens_route", + paths: ["/linear/issues/issue-55.json"], + scopes: [scope], + }, + headers: authHeaders, + }, + ); + + await assertJsonResponse(response, 400, (body) => { + assert.equal( + body.code, + "invalid_scope", + `expected ${scope} to be rejected`, + ); + }); + } + }, + ); + + await t.test( + "rejects provider-subtree scopes that do not cover every requested path", + async () => { + const { app, authHeaders } = await createHarness({ + authClaims: { + scopes: ["relayauth:api-key:manage:*", "relayfile:fs:write:*"], + }, }); - } - }); - - await t.test("rejects provider-subtree scopes that do not cover every requested path", async () => { - const { app, authHeaders } = await createHarness({ - authClaims: { - scopes: ["relayauth:api-key:manage:*", "relayfile:fs:write:*"], - }, - }); - const response = await requestRoute(app, "POST", "/v1/tokens/workspace-path", { - body: { - workspaceId: "ws_tokens_route", - paths: ["/linear/issues/issue-55.json", "/github/issues/issue-55.json"], - scopes: ["relayfile:fs:write:/linear/**"], - }, - headers: authHeaders, - }); + const response = await requestRoute( + app, + "POST", + "/v1/tokens/workspace-path", + { + body: { + workspaceId: "ws_tokens_route", + paths: [ + "/linear/issues/issue-55.json", + "/github/issues/issue-55.json", + ], + scopes: ["relayfile:fs:write:/linear/**"], + }, + headers: authHeaders, + }, + ); - await assertJsonResponse(response, 400, (body) => { - assert.equal(body.code, "invalid_scope"); - }); - }); + await assertJsonResponse(response, 400, (body) => { + assert.equal(body.code, "invalid_scope"); + }); + }, + ); await t.test("rejects degenerate or traversal paths", async () => { const { app, authHeaders } = await createHarness({ @@ -1137,24 +1473,34 @@ test("POST /v1/tokens/workspace-path", async (t) => { }, }); - const degenerate = await requestRoute(app, "POST", "/v1/tokens/workspace-path", { - body: { - workspaceId: "ws_tokens_route", - paths: ["/"], + const degenerate = await requestRoute( + app, + "POST", + "/v1/tokens/workspace-path", + { + body: { + workspaceId: "ws_tokens_route", + paths: ["/"], + }, + headers: authHeaders, }, - headers: authHeaders, - }); + ); await assertJsonResponse(degenerate, 400, (body) => { assert.equal(body.code, "invalid_paths"); }); - const traversal = await requestRoute(app, "POST", "/v1/tokens/workspace-path", { - body: { - workspaceId: "ws_tokens_route", - paths: ["/github/repos/AgentWorkforce/cloud/issues/../secrets/*"], + const traversal = await requestRoute( + app, + "POST", + "/v1/tokens/workspace-path", + { + body: { + workspaceId: "ws_tokens_route", + paths: ["/github/repos/AgentWorkforce/cloud/issues/../secrets/*"], + }, + headers: authHeaders, }, - headers: authHeaders, - }); + ); await assertJsonResponse(traversal, 400, (body) => { assert.equal(body.code, "invalid_paths"); }); @@ -1168,251 +1514,324 @@ test("POST /v1/tokens/relayhistory-assertion", async (t) => { assertionScope, ]; - async function issueAssertionKey(app: ReturnType, headers: HeadersInit) { + async function issueAssertionKey( + app: ReturnType, + headers: HeadersInit, + ) { return issueApiKey(app, headers, [assertionScope]); } - await t.test("mints a short-lived access-only relayhistory assertion from a dedicated api key", async () => { - const deferred: DeferredTask[] = []; - const { app, authHeaders } = await createHarness({ - authClaims: { - scopes: assertionKeyIssuerScopes, - }, - deferTask: (task) => deferred.push(task), - }); - const assertionKey = await issueAssertionKey(app, authHeaders); + await t.test( + "mints a short-lived access-only relayhistory assertion from a dedicated api key", + async () => { + const deferred: DeferredTask[] = []; + const { app, authHeaders } = await createHarness({ + authClaims: { + scopes: assertionKeyIssuerScopes, + }, + deferTask: (task) => deferred.push(task), + }); + const assertionKey = await issueAssertionKey(app, authHeaders); + + const response = await requestRoute( + app, + "POST", + "/v1/tokens/relayhistory-assertion", + { + body: { + orgId: "org_relayhistory_target", + workspaceId: "ws_relayhistory_target", + sponsorId: "user_cloud_login", + scopes: ["rth:sync", "rth:read"], + expiresIn: 45, + }, + headers: { + "x-api-key": assertionKey.key, + }, + }, + ); - const response = await requestRoute(app, "POST", "/v1/tokens/relayhistory-assertion", { - body: { - orgId: "org_relayhistory_target", - workspaceId: "ws_relayhistory_target", - sponsorId: "user_cloud_login", - scopes: ["rth:sync", "rth:read"], - expiresIn: 45, - }, - headers: { - "x-api-key": assertionKey.key, - }, - }); + const body = await assertJsonResponse( + response, + 201, + ); + assert.equal(body.tokenType, "Bearer"); + assert.equal( + "refreshToken" in body, + false, + "relayhistory assertions must not issue refresh tokens", + ); - const body = await assertJsonResponse(response, 201); - assert.equal(body.tokenType, "Bearer"); - assert.equal("refreshToken" in body, false, "relayhistory assertions must not issue refresh tokens"); - - const claims = decodeJwtJsonSegment(body.accessToken, 1); - assert.equal(claims.sub, "agent_relayhistory_assertion"); - assert.equal(claims.org, "org_relayhistory_target"); - assert.equal(claims.wks, "ws_relayhistory_target"); - assert.equal(claims.sponsorId, "user_cloud_login"); - assert.deepEqual(claims.sponsorChain, ["user_cloud_login", "agent_relayhistory_assertion"]); - assert.deepEqual(claims.aud, ["relayhistory"]); - assert.deepEqual(claims.scopes, ["rth:read", "rth:sync"]); - assert.equal(claims.token_type, "access"); - assert.equal(claims.exp - claims.iat, 45); - assert.match(claims.jti, /^tok_[A-Za-z0-9_-]+$/); - assert.deepEqual(claims.meta, { - tokenClass: "relayhistory_assertion", - actorId: "api_key:" + assertionKey.apiKey.id, - actorOrgId: "org_tokens_route", - requestedOrgId: "org_relayhistory_target", - workspaceId: "ws_relayhistory_target", - grantedScopes: JSON.stringify(["rth:read", "rth:sync"]), - }); - assert.equal(await countStoredTokens(app), 1, "only the access assertion should be persisted"); + const claims = decodeJwtJsonSegment( + body.accessToken, + 1, + ); + assert.equal(claims.sub, "agent_relayhistory_assertion"); + assert.equal(claims.org, "org_relayhistory_target"); + assert.equal(claims.wks, "ws_relayhistory_target"); + assert.equal(claims.sponsorId, "user_cloud_login"); + assert.deepEqual(claims.sponsorChain, [ + "user_cloud_login", + "agent_relayhistory_assertion", + ]); + assert.deepEqual(claims.aud, ["relayhistory"]); + assert.deepEqual(claims.scopes, ["rth:read", "rth:sync"]); + assert.equal(claims.token_type, "access"); + assert.equal(claims.exp - claims.iat, 45); + assert.match(claims.jti, /^tok_[A-Za-z0-9_-]+$/); + assert.deepEqual(claims.meta, { + tokenClass: "relayhistory_assertion", + actorId: "api_key:" + assertionKey.apiKey.id, + actorOrgId: "org_tokens_route", + requestedOrgId: "org_relayhistory_target", + workspaceId: "ws_relayhistory_target", + grantedScopes: JSON.stringify(["rth:read", "rth:sync"]), + }); + assert.equal( + await countStoredTokens(app), + 1, + "only the access assertion should be persisted", + ); - const auditRow = await app.storage.DB.prepare(` + const auditRow = await app.storage.DB.prepare( + ` SELECT action, identity_id, org_id, workspace_id, resource, metadata_json FROM audit_logs WHERE resource = 'relayhistory-assertion' LIMIT 1 - `).first<{ - action: string; - identity_id: string; - org_id: string; - workspace_id: string; - resource: string; - metadata_json: string; - }>(); - assert.equal(auditRow?.action, "token.issued"); - assert.equal(auditRow?.identity_id, "api_key:" + assertionKey.apiKey.id); - assert.equal(auditRow?.org_id, "org_relayhistory_target"); - assert.equal(auditRow?.workspace_id, "ws_relayhistory_target"); - assert.deepEqual(JSON.parse(auditRow?.metadata_json ?? "{}"), { - tokenId: claims.jti, - actorOrgId: "org_tokens_route", - sponsorId: "user_cloud_login", - grantedScopes: JSON.stringify(["rth:read", "rth:sync"]), - }); - }); - - await t.test("fails closed without an assertion token when its audit cannot commit", async () => { - const { app, authHeaders } = await createHarness({ - authClaims: { - scopes: assertionKeyIssuerScopes, - }, - }); - const assertionKey = await issueAssertionKey(app, authHeaders); - app.storage.tokens.persistIssuedWithAudit = async () => { - throw new Error("fault: assertion audit"); - }; - - const response = await withSilencedConsoleError(() => requestRoute( - app, - "POST", - "/v1/tokens/relayhistory-assertion", - { - body: { - orgId: "org_relayhistory_target", - workspaceId: "ws_relayhistory_target", - sponsorId: "user_cloud_login", - scopes: ["rth:read"], - }, - headers: { "x-api-key": assertionKey.key }, - }, - )); - await assertJsonResponse(response, 500, (body) => { - assert.equal(body.code, "internal_error"); - }); - assert.equal(await countStoredTokens(app), 0); - }); - - await t.test("requires the dedicated api-key path even when a bearer has the assertion scope", async () => { - const { app, authHeaders } = await createHarness({ - authClaims: { - scopes: [assertionScope], - }, - }); - - const response = await requestRoute(app, "POST", "/v1/tokens/relayhistory-assertion", { - body: { - orgId: "org_relayhistory_target", - workspaceId: "ws_relayhistory_target", + `, + ).first<{ + action: string; + identity_id: string; + org_id: string; + workspace_id: string; + resource: string; + metadata_json: string; + }>(); + assert.equal(auditRow?.action, "token.issued"); + assert.equal(auditRow?.identity_id, "api_key:" + assertionKey.apiKey.id); + assert.equal(auditRow?.org_id, "org_relayhistory_target"); + assert.equal(auditRow?.workspace_id, "ws_relayhistory_target"); + assert.deepEqual(JSON.parse(auditRow?.metadata_json ?? "{}"), { + tokenId: claims.jti, + actorOrgId: "org_tokens_route", sponsorId: "user_cloud_login", - scopes: ["rth:read"], - }, - headers: authHeaders, - }); - - await assertJsonResponse(response, 403, (body) => { - assert.equal(body.code, "assertion_key_required"); - }); - }); - - await t.test("strict-rejects non-relayhistory scopes instead of silently dropping them", async () => { - const { app, authHeaders } = await createHarness({ - authClaims: { - scopes: assertionKeyIssuerScopes, - }, - }); - const assertionKey = await issueAssertionKey(app, authHeaders); - - const response = await requestRoute(app, "POST", "/v1/tokens/relayhistory-assertion", { - body: { - orgId: "org_relayhistory_target", - workspaceId: "ws_relayhistory_target", - sponsorId: "user_cloud_login", - scopes: ["rth:read", "rth:admin"], - }, - headers: { - "x-api-key": assertionKey.key, - }, - }); + grantedScopes: JSON.stringify(["rth:read", "rth:sync"]), + }); + }, + ); + + await t.test( + "fails closed without an assertion token when its audit cannot commit", + async () => { + const { app, authHeaders } = await createHarness({ + authClaims: { + scopes: assertionKeyIssuerScopes, + }, + }); + const assertionKey = await issueAssertionKey(app, authHeaders); + app.storage.tokens.persistIssuedWithAudit = async () => { + throw new Error("fault: assertion audit"); + }; + + const response = await withSilencedConsoleError(() => + requestRoute(app, "POST", "/v1/tokens/relayhistory-assertion", { + body: { + orgId: "org_relayhistory_target", + workspaceId: "ws_relayhistory_target", + sponsorId: "user_cloud_login", + scopes: ["rth:read"], + }, + headers: { "x-api-key": assertionKey.key }, + }), + ); + await assertJsonResponse(response, 500, (body) => { + assert.equal(body.code, "internal_error"); + }); + assert.equal(await countStoredTokens(app), 0); + }, + ); + + await t.test( + "requires the dedicated api-key path even when a bearer has the assertion scope", + async () => { + const { app, authHeaders } = await createHarness({ + authClaims: { + scopes: [assertionScope], + }, + }); - await assertJsonResponse(response, 400, (body) => { - assert.equal(body.code, "invalid_scope"); - }); - }); + const response = await requestRoute( + app, + "POST", + "/v1/tokens/relayhistory-assertion", + { + body: { + orgId: "org_relayhistory_target", + workspaceId: "ws_relayhistory_target", + sponsorId: "user_cloud_login", + scopes: ["rth:read"], + }, + headers: authHeaders, + }, + ); - await t.test("strict-rejects ttl requests above the 60 second ceiling", async () => { - const { app, authHeaders } = await createHarness({ - authClaims: { - scopes: assertionKeyIssuerScopes, - }, - }); - const assertionKey = await issueAssertionKey(app, authHeaders); + await assertJsonResponse(response, 403, (body) => { + assert.equal(body.code, "assertion_key_required"); + }); + }, + ); + + await t.test( + "strict-rejects non-relayhistory scopes instead of silently dropping them", + async () => { + const { app, authHeaders } = await createHarness({ + authClaims: { + scopes: assertionKeyIssuerScopes, + }, + }); + const assertionKey = await issueAssertionKey(app, authHeaders); + + const response = await requestRoute( + app, + "POST", + "/v1/tokens/relayhistory-assertion", + { + body: { + orgId: "org_relayhistory_target", + workspaceId: "ws_relayhistory_target", + sponsorId: "user_cloud_login", + scopes: ["rth:read", "rth:admin"], + }, + headers: { + "x-api-key": assertionKey.key, + }, + }, + ); - const response = await requestRoute(app, "POST", "/v1/tokens/relayhistory-assertion", { - body: { - orgId: "org_relayhistory_target", - workspaceId: "ws_relayhistory_target", - sponsorId: "user_cloud_login", - scopes: ["rth:read"], - expiresIn: 61, - }, - headers: { - "x-api-key": assertionKey.key, - }, - }); + await assertJsonResponse(response, 400, (body) => { + assert.equal(body.code, "invalid_scope"); + }); + }, + ); + + await t.test( + "strict-rejects ttl requests above the 60 second ceiling", + async () => { + const { app, authHeaders } = await createHarness({ + authClaims: { + scopes: assertionKeyIssuerScopes, + }, + }); + const assertionKey = await issueAssertionKey(app, authHeaders); + + const response = await requestRoute( + app, + "POST", + "/v1/tokens/relayhistory-assertion", + { + body: { + orgId: "org_relayhistory_target", + workspaceId: "ws_relayhistory_target", + sponsorId: "user_cloud_login", + scopes: ["rth:read"], + expiresIn: 61, + }, + headers: { + "x-api-key": assertionKey.key, + }, + }, + ); - await assertJsonResponse(response, 400, (body) => { - assert.equal(body.code, "invalid_expires_in"); - }); - }); + await assertJsonResponse(response, 400, (body) => { + assert.equal(body.code, "invalid_expires_in"); + }); + }, + ); }); test("POST /v1/tokens/refresh", async (t) => { - await t.test("refreshes a RS256 token pair without requiring a bearer token", async () => { - const { app, identity } = await createHarness(); - const { pair, accessClaims, refreshClaims } = createRs256TokenPair(identity); - await seedActiveTokens(app, identity.id, [accessClaims.jti, refreshClaims.jti]); - - const response = await requestRoute(app, "POST", "/v1/tokens/refresh", { - body: { - refreshToken: pair.refreshToken, - }, - }); - - const body = await assertJsonResponse(response, 200); - assert.equal(body.tokenType, "Bearer"); - assert.notEqual(body.accessToken, pair.accessToken); - assert.notEqual(body.refreshToken, pair.refreshToken); + await t.test( + "refreshes a RS256 token pair without requiring a bearer token", + async () => { + const { app, identity } = await createHarness(); + const { pair, accessClaims, refreshClaims } = + createRs256TokenPair(identity); + await seedActiveTokens(app, identity.id, [ + accessClaims.jti, + refreshClaims.jti, + ]); - const nextAccessClaims = decodeJwtJsonSegment(body.accessToken, 1); - const nextRefreshClaims = decodeJwtJsonSegment(body.refreshToken, 1); - assertTokenClaimsMatchSpec(nextAccessClaims, { - tokenType: "access", - expectedIdentity: identity, - expectedAudience: ["specialist"], - expectedScopes: ["specialist:invoke"], - }); - assertTokenClaimsMatchSpec(nextRefreshClaims, { - tokenType: "refresh", - expectedIdentity: identity, - expectedAudience: ["relayauth"], - expectedScopes: ["relayauth:token:refresh"], - }); - assert.notEqual(nextAccessClaims.jti, accessClaims.jti); - assert.notEqual(nextRefreshClaims.jti, refreshClaims.jti); + const response = await requestRoute(app, "POST", "/v1/tokens/refresh", { + body: { + refreshToken: pair.refreshToken, + }, + }); - await assertRs256Algorithm(body.accessToken, ["specialist"]); - await assertRs256Algorithm(body.refreshToken, ["relayauth"]); - }); + const body = await assertJsonResponse(response, 200); + assert.equal(body.tokenType, "Bearer"); + assert.notEqual(body.accessToken, pair.accessToken); + assert.notEqual(body.refreshToken, pair.refreshToken); - await t.test("rolls back the new pair and leaves the old JTI active when rotation audit fails", async () => { - const { app, identity } = await createHarness(); - const { pair, accessClaims, refreshClaims } = createRs256TokenPair(identity); - await seedActiveTokens(app, identity.id, [accessClaims.jti, refreshClaims.jti]); - const beforeCount = await countStoredTokens(app); - app.storage.tokens.rotateIssuedPairWithAudit = async () => { - throw new Error("fault: rotation audit"); - }; + const nextAccessClaims = decodeJwtJsonSegment( + body.accessToken, + 1, + ); + const nextRefreshClaims = decodeJwtJsonSegment( + body.refreshToken, + 1, + ); + assertTokenClaimsMatchSpec(nextAccessClaims, { + tokenType: "access", + expectedIdentity: identity, + expectedAudience: ["specialist"], + expectedScopes: ["specialist:invoke"], + }); + assertTokenClaimsMatchSpec(nextRefreshClaims, { + tokenType: "refresh", + expectedIdentity: identity, + expectedAudience: ["relayauth"], + expectedScopes: ["relayauth:token:refresh"], + }); + assert.notEqual(nextAccessClaims.jti, accessClaims.jti); + assert.notEqual(nextRefreshClaims.jti, refreshClaims.jti); - const response = await withSilencedConsoleError(() => requestRoute( - app, - "POST", - "/v1/tokens/refresh", - { body: { refreshToken: pair.refreshToken } }, - )); - await assertJsonResponse(response, 500, (body) => { - assert.equal(body.code, "internal_error"); - }); - assert.equal(await countStoredTokens(app), beforeCount); - assert.equal( - (await app.storage.tokens.getById(refreshClaims.jti))?.status, - "active", - ); - assert.deepEqual(await listRevokedTokenIds(app), []); - }); + await assertRs256Algorithm(body.accessToken, ["specialist"]); + await assertRs256Algorithm(body.refreshToken, ["relayauth"]); + }, + ); + + await t.test( + "rolls back the new pair and leaves the old JTI active when rotation audit fails", + async () => { + const { app, identity } = await createHarness(); + const { pair, accessClaims, refreshClaims } = + createRs256TokenPair(identity); + await seedActiveTokens(app, identity.id, [ + accessClaims.jti, + refreshClaims.jti, + ]); + const beforeCount = await countStoredTokens(app); + app.storage.tokens.rotateIssuedPairWithAudit = async () => { + throw new Error("fault: rotation audit"); + }; + + const response = await withSilencedConsoleError(() => + requestRoute(app, "POST", "/v1/tokens/refresh", { + body: { refreshToken: pair.refreshToken }, + }), + ); + await assertJsonResponse(response, 500, (body) => { + assert.equal(body.code, "internal_error"); + }); + assert.equal(await countStoredTokens(app), beforeCount); + assert.equal( + (await app.storage.tokens.getById(refreshClaims.jti))?.status, + "active", + ); + assert.deepEqual(await listRevokedTokenIds(app), []); + }, + ); await t.test("returns 400 when refreshToken is missing", async () => { const { app } = await createHarness(); @@ -1442,7 +1861,7 @@ test("POST /v1/tokens/refresh", async (t) => { await t.test("returns 401 when the refresh token is expired", async () => { const { app, identity } = await createHarness(); - const now = Math.floor(Date.now() / 1000) - (2 * 3600); + const now = Math.floor(Date.now() / 1000) - 2 * 3600; const { pair, refreshClaims } = createRs256TokenPair(identity, { issuedAt: now, accessExpiresInSeconds: 60, @@ -1461,470 +1880,589 @@ test("POST /v1/tokens/refresh", async (t) => { }); }); - await t.test("returns 401 when the refresh token has been revoked", async () => { - const { app, identity } = await createHarness(); - const { pair, refreshClaims } = createRs256TokenPair(identity); - await seedActiveTokens(app, identity.id, [refreshClaims.jti]); - - const revocations = app.storage.revocations as typeof app.storage.revocations & { - revoke(jti: string, expiresAt: number): Promise; - }; - await revocations.revoke(refreshClaims.jti, refreshClaims.exp); - - const response = await requestRoute(app, "POST", "/v1/tokens/refresh", { - body: { - refreshToken: pair.refreshToken, - }, - }); + await t.test( + "returns 401 when the refresh token has been revoked", + async () => { + const { app, identity } = await createHarness(); + const { pair, refreshClaims } = createRs256TokenPair(identity); + await seedActiveTokens(app, identity.id, [refreshClaims.jti]); - await assertJsonResponse(response, 401, (body) => { - assert.match(JSON.stringify(body), /revoked/i); - }); - }); + const revocations = app.storage + .revocations as typeof app.storage.revocations & { + revoke(jti: string, expiresAt: number): Promise; + }; + await revocations.revoke(refreshClaims.jti, refreshClaims.exp); - await t.test("revokes the old refresh JTI after a successful refresh", async () => { - const { app, identity } = await createHarness(); - const { pair, accessClaims, refreshClaims } = createRs256TokenPair(identity); - await seedActiveTokens(app, identity.id, [accessClaims.jti, refreshClaims.jti]); + const response = await requestRoute(app, "POST", "/v1/tokens/refresh", { + body: { + refreshToken: pair.refreshToken, + }, + }); - assert.deepEqual(await listRevokedTokenIds(app), []); + await assertJsonResponse(response, 401, (body) => { + assert.match(JSON.stringify(body), /revoked/i); + }); + }, + ); + + await t.test( + "revokes the old refresh JTI after a successful refresh", + async () => { + const { app, identity } = await createHarness(); + const { pair, accessClaims, refreshClaims } = + createRs256TokenPair(identity); + await seedActiveTokens(app, identity.id, [ + accessClaims.jti, + refreshClaims.jti, + ]); - const response = await requestRoute(app, "POST", "/v1/tokens/refresh", { - body: { refreshToken: pair.refreshToken }, - }); - await assertJsonResponse(response, 200); + assert.deepEqual(await listRevokedTokenIds(app), []); - const revoked = await listRevokedTokenIds(app); - assert.ok( - revoked.includes(refreshClaims.jti), - `old refresh JTI ${refreshClaims.jti} should be in the revocation list but got ${JSON.stringify(revoked)}`, - ); - }); + const response = await requestRoute(app, "POST", "/v1/tokens/refresh", { + body: { refreshToken: pair.refreshToken }, + }); + await assertJsonResponse(response, 200); - await t.test("detects refresh-token re-use and cascade-revokes the session", async () => { - const { app, identity } = await createHarness(); - const { pair, accessClaims, refreshClaims } = createRs256TokenPair(identity); - await seedActiveTokens(app, identity.id, [accessClaims.jti, refreshClaims.jti]); - const atomicRevocations = - app.storage.revocations.revokeIdentityTokensWithAudit.bind( - app.storage.revocations, + const revoked = await listRevokedTokenIds(app); + assert.ok( + revoked.includes(refreshClaims.jti), + `old refresh JTI ${refreshClaims.jti} should be in the revocation list but got ${JSON.stringify(revoked)}`, ); - const atomicCascadeCalls: Parameters[0][] = []; - app.storage.revocations.revokeIdentityTokensWithAudit = async (input) => { - atomicCascadeCalls.push(input); - await atomicRevocations(input); - }; - app.storage.revocations.revokeIdentityTokens = async () => { - throw new Error("refresh-reuse cascade must use revokeIdentityTokensWithAudit"); - }; - - const firstResponse = await requestRoute(app, "POST", "/v1/tokens/refresh", { - body: { refreshToken: pair.refreshToken }, - }); - const firstBody = await assertJsonResponse(firstResponse, 200); - const secondAccessClaims = decodeJwtJsonSegment( - firstBody.accessToken, - 1, - ); - const secondRefreshClaims = decodeJwtJsonSegment( - firstBody.refreshToken, - 1, - ); - - // Replay the original refresh token (single-use violation). - const replayResponse = await requestRoute(app, "POST", "/v1/tokens/refresh", { - body: { refreshToken: pair.refreshToken }, - }); - await assertJsonResponse(replayResponse, 401, (body) => { - assert.match(JSON.stringify(body), /revoked/i); - }); - assert.equal(atomicCascadeCalls.length, 1); - assert.deepEqual( - [...atomicCascadeCalls[0]!.tokenIds].sort(), - [ + }, + ); + + await t.test( + "detects refresh-token re-use and cascade-revokes the session", + async () => { + const { app, identity } = await createHarness(); + const { pair, accessClaims, refreshClaims } = + createRs256TokenPair(identity); + await seedActiveTokens(app, identity.id, [ + accessClaims.jti, refreshClaims.jti, - secondAccessClaims.jti, - secondRefreshClaims.jti, - ].sort(), - ); - assert.equal(atomicCascadeCalls[0]!.identityId, identity.id); - assert.match(atomicCascadeCalls[0]!.revokedAt, /^\d{4}-\d{2}-\d{2}T/); - assert.deepEqual(atomicCascadeCalls[0]!.auditEntry, { - id: atomicCascadeCalls[0]!.auditEntry.id, - action: "token.revoked", - identityId: identity.id, - orgId: identity.orgId, - workspaceId: identity.workspaceId, - plane: "relayauth", - resource: "tokens", - result: "allowed", - metadata: { - tokenId: refreshClaims.jti, - actorId: "refresh_reuse_detected", - }, - timestamp: atomicCascadeCalls[0]!.auditEntry.timestamp, - }); - - // The newly issued refresh token should ALSO be unusable now because the - // session was cascade-revoked. - const followupResponse = await requestRoute(app, "POST", "/v1/tokens/refresh", { - body: { refreshToken: firstBody.refreshToken }, - }); - await assertJsonResponse(followupResponse, 401); - assert.equal(atomicCascadeCalls.length, 2); - assert.deepEqual(atomicCascadeCalls[1]!.tokenIds, [ - secondRefreshClaims.jti, - ]); - assert.equal(atomicCascadeCalls[1]!.identityId, identity.id); - assert.match(atomicCascadeCalls[1]!.revokedAt, /^\d{4}-\d{2}-\d{2}T/); - assert.deepEqual(atomicCascadeCalls[1]!.auditEntry, { - id: atomicCascadeCalls[1]!.auditEntry.id, - action: "token.revoked", - identityId: identity.id, - orgId: identity.orgId, - workspaceId: identity.workspaceId, - plane: "relayauth", - resource: "tokens", - result: "allowed", - metadata: { - tokenId: secondRefreshClaims.jti, - actorId: "refresh_reuse_detected", - }, - timestamp: atomicCascadeCalls[1]!.auditEntry.timestamp, - }); - - const revoked = await listRevokedTokenIds(app); - assert.ok(revoked.includes(refreshClaims.jti), "original refresh JTI must be revoked"); - assert.ok( - revoked.includes(secondRefreshClaims.jti), - `second refresh JTI ${secondRefreshClaims.jti} must be revoked after re-use detection (got ${JSON.stringify(revoked)})`, - ); - assert.equal(atomicCascadeCalls, 1); - }); + ]); + const atomicRevocations = + app.storage.revocations.revokeIdentityTokensWithAudit.bind( + app.storage.revocations, + ); + const atomicCascadeCalls: Parameters[0][] = []; + app.storage.revocations.revokeIdentityTokensWithAudit = async (input) => { + atomicCascadeCalls.push(input); + await atomicRevocations(input); + }; + app.storage.revocations.revokeIdentityTokens = async () => { + throw new Error( + "refresh-reuse cascade must use revokeIdentityTokensWithAudit", + ); + }; + + const firstResponse = await requestRoute( + app, + "POST", + "/v1/tokens/refresh", + { + body: { refreshToken: pair.refreshToken }, + }, + ); + const firstBody = await assertJsonResponse(firstResponse, 200); + const secondAccessClaims = decodeJwtJsonSegment( + firstBody.accessToken, + 1, + ); + const secondRefreshClaims = decodeJwtJsonSegment( + firstBody.refreshToken, + 1, + ); - await t.test("rejects a refresh token signed with the wrong issuer", async () => { - const { app, identity } = await createHarness(); - const now = Math.floor(Date.now() / 1000); - const sid = `sess_${crypto.randomUUID().replace(/-/g, "")}`; - const jti = `tok_${crypto.randomUUID().replace(/-/g, "")}`; - await seedActiveTokens(app, identity.id, [jti]); - - const evilRefresh = signRs256Jwt({ - sub: identity.id, - org: identity.orgId, - wks: identity.workspaceId, - scopes: ["relayauth:token:refresh"], - sponsorId: identity.sponsorId, - sponsorChain: [...identity.sponsorChain], - token_type: "refresh", - iss: "https://evil.example", - aud: ["relayauth"], - exp: now + 3600, - iat: now, - jti, - sid, - }); + // Replay the original refresh token (single-use violation). + const replayResponse = await requestRoute( + app, + "POST", + "/v1/tokens/refresh", + { + body: { refreshToken: pair.refreshToken }, + }, + ); + await assertJsonResponse(replayResponse, 401, (body) => { + assert.match(JSON.stringify(body), /revoked/i); + }); + assert.equal(atomicCascadeCalls.length, 1); + assert.deepEqual( + [...atomicCascadeCalls[0]!.tokenIds].sort(), + [ + refreshClaims.jti, + secondAccessClaims.jti, + secondRefreshClaims.jti, + ].sort(), + ); + assert.equal(atomicCascadeCalls[0]!.identityId, identity.id); + assert.match(atomicCascadeCalls[0]!.revokedAt, /^\d{4}-\d{2}-\d{2}T/); + assert.deepEqual(atomicCascadeCalls[0]!.auditEntry, { + id: atomicCascadeCalls[0]!.auditEntry.id, + action: "token.revoked", + identityId: identity.id, + orgId: identity.orgId, + workspaceId: identity.workspaceId, + plane: "relayauth", + resource: "tokens", + result: "allowed", + metadata: { + tokenId: refreshClaims.jti, + actorId: "refresh_reuse_detected", + }, + timestamp: atomicCascadeCalls[0]!.auditEntry.timestamp, + }); - const response = await requestRoute(app, "POST", "/v1/tokens/refresh", { - body: { refreshToken: evilRefresh }, - }); - await assertJsonResponse(response, 401); - }); + // The newly issued refresh token should ALSO be unusable now because the + // session was cascade-revoked. + const followupResponse = await requestRoute( + app, + "POST", + "/v1/tokens/refresh", + { + body: { refreshToken: firstBody.refreshToken }, + }, + ); + await assertJsonResponse(followupResponse, 401); + assert.equal(atomicCascadeCalls.length, 2); + assert.deepEqual(atomicCascadeCalls[1]!.tokenIds, [ + secondRefreshClaims.jti, + ]); + assert.equal(atomicCascadeCalls[1]!.identityId, identity.id); + assert.match(atomicCascadeCalls[1]!.revokedAt, /^\d{4}-\d{2}-\d{2}T/); + assert.deepEqual(atomicCascadeCalls[1]!.auditEntry, { + id: atomicCascadeCalls[1]!.auditEntry.id, + action: "token.revoked", + identityId: identity.id, + orgId: identity.orgId, + workspaceId: identity.workspaceId, + plane: "relayauth", + resource: "tokens", + result: "allowed", + metadata: { + tokenId: secondRefreshClaims.jti, + actorId: "refresh_reuse_detected", + }, + timestamp: atomicCascadeCalls[1]!.auditEntry.timestamp, + }); - await t.test("rejects a refresh token with a non-relayauth audience", async () => { - const { app, identity } = await createHarness(); - const now = Math.floor(Date.now() / 1000); - const sid = `sess_${crypto.randomUUID().replace(/-/g, "")}`; - const jti = `tok_${crypto.randomUUID().replace(/-/g, "")}`; - await seedActiveTokens(app, identity.id, [jti]); - - const wrongAudRefresh = signRs256Jwt({ - sub: identity.id, - org: identity.orgId, - wks: identity.workspaceId, - scopes: ["relayauth:token:refresh"], - sponsorId: identity.sponsorId, - sponsorChain: [...identity.sponsorChain], - token_type: "refresh", - iss: "https://relayauth.dev", - aud: ["not-relayauth"], - exp: now + 3600, - iat: now, - jti, - sid, - }); + const revoked = await listRevokedTokenIds(app); + assert.ok( + revoked.includes(refreshClaims.jti), + "original refresh JTI must be revoked", + ); + assert.ok( + revoked.includes(secondRefreshClaims.jti), + `second refresh JTI ${secondRefreshClaims.jti} must be revoked after re-use detection (got ${JSON.stringify(revoked)})`, + ); + }, + ); + + await t.test( + "rejects a refresh token signed with the wrong issuer", + async () => { + const { app, identity } = await createHarness(); + const now = Math.floor(Date.now() / 1000); + const sid = `sess_${crypto.randomUUID().replace(/-/g, "")}`; + const jti = `tok_${crypto.randomUUID().replace(/-/g, "")}`; + await seedActiveTokens(app, identity.id, [jti]); + + const evilRefresh = signRs256Jwt({ + sub: identity.id, + org: identity.orgId, + wks: identity.workspaceId, + scopes: ["relayauth:token:refresh"], + sponsorId: identity.sponsorId, + sponsorChain: [...identity.sponsorChain], + token_type: "refresh", + iss: "https://evil.example", + aud: ["relayauth"], + exp: now + 3600, + iat: now, + jti, + sid, + }); - const response = await requestRoute(app, "POST", "/v1/tokens/refresh", { - body: { refreshToken: wrongAudRefresh }, - }); - await assertJsonResponse(response, 401); - }); + const response = await requestRoute(app, "POST", "/v1/tokens/refresh", { + body: { refreshToken: evilRefresh }, + }); + await assertJsonResponse(response, 401); + }, + ); + + await t.test( + "rejects a refresh token with a non-relayauth audience", + async () => { + const { app, identity } = await createHarness(); + const now = Math.floor(Date.now() / 1000); + const sid = `sess_${crypto.randomUUID().replace(/-/g, "")}`; + const jti = `tok_${crypto.randomUUID().replace(/-/g, "")}`; + await seedActiveTokens(app, identity.id, [jti]); + + const wrongAudRefresh = signRs256Jwt({ + sub: identity.id, + org: identity.orgId, + wks: identity.workspaceId, + scopes: ["relayauth:token:refresh"], + sponsorId: identity.sponsorId, + sponsorChain: [...identity.sponsorChain], + token_type: "refresh", + iss: "https://relayauth.dev", + aud: ["not-relayauth"], + exp: now + 3600, + iat: now, + jti, + sid, + }); - await t.test("rejects a refresh token whose exp is beyond clock-skew in the past", async () => { - const { app, identity } = await createHarness(); - const past = Math.floor(Date.now() / 1000) - 1000; - const expiredRefresh = signRs256Jwt({ - sub: identity.id, - org: identity.orgId, - wks: identity.workspaceId, - scopes: ["relayauth:token:refresh"], - sponsorId: identity.sponsorId, - sponsorChain: [...identity.sponsorChain], - token_type: "refresh", - iss: "https://relayauth.dev", - aud: ["relayauth"], - exp: past + 60, // exp 120s before "now" - iat: past, - jti: `tok_${crypto.randomUUID().replace(/-/g, "")}`, - }); + const response = await requestRoute(app, "POST", "/v1/tokens/refresh", { + body: { refreshToken: wrongAudRefresh }, + }); + await assertJsonResponse(response, 401); + }, + ); + + await t.test( + "rejects a refresh token whose exp is beyond clock-skew in the past", + async () => { + const { app, identity } = await createHarness(); + const past = Math.floor(Date.now() / 1000) - 1000; + const expiredRefresh = signRs256Jwt({ + sub: identity.id, + org: identity.orgId, + wks: identity.workspaceId, + scopes: ["relayauth:token:refresh"], + sponsorId: identity.sponsorId, + sponsorChain: [...identity.sponsorChain], + token_type: "refresh", + iss: "https://relayauth.dev", + aud: ["relayauth"], + exp: past + 60, // exp 120s before "now" + iat: past, + jti: `tok_${crypto.randomUUID().replace(/-/g, "")}`, + }); - const response = await requestRoute(app, "POST", "/v1/tokens/refresh", { - body: { refreshToken: expiredRefresh }, - }); - await assertJsonResponse(response, 401, (body) => { - assert.match(JSON.stringify(body), /expired|invalid/i); - }); - }); + const response = await requestRoute(app, "POST", "/v1/tokens/refresh", { + body: { refreshToken: expiredRefresh }, + }); + await assertJsonResponse(response, 401, (body) => { + assert.match(JSON.stringify(body), /expired|invalid/i); + }); + }, + ); + + await t.test( + "accepts a refresh token whose exp is within the 60s clock-skew allowance", + async () => { + const { app, identity } = await createHarness(); + const now = Math.floor(Date.now() / 1000); + const jti = `tok_${crypto.randomUUID().replace(/-/g, "")}`; + const sid = `sess_${crypto.randomUUID().replace(/-/g, "")}`; + await seedActiveTokens(app, identity.id, [jti]); + + const skewedRefresh = signRs256Jwt({ + sub: identity.id, + org: identity.orgId, + wks: identity.workspaceId, + scopes: ["relayauth:token:refresh"], + sponsorId: identity.sponsorId, + sponsorChain: [...identity.sponsorChain], + token_type: "refresh", + iss: "https://relayauth.dev", + aud: ["relayauth"], + exp: now - 20, // 20s past exp, should be accepted within verifier and route skew + iat: now - 120, + jti, + sid, + }); - await t.test("accepts a refresh token whose exp is within the 60s clock-skew allowance", async () => { - const { app, identity } = await createHarness(); - const now = Math.floor(Date.now() / 1000); - const jti = `tok_${crypto.randomUUID().replace(/-/g, "")}`; - const sid = `sess_${crypto.randomUUID().replace(/-/g, "")}`; - await seedActiveTokens(app, identity.id, [jti]); - - const skewedRefresh = signRs256Jwt({ - sub: identity.id, - org: identity.orgId, - wks: identity.workspaceId, - scopes: ["relayauth:token:refresh"], - sponsorId: identity.sponsorId, - sponsorChain: [...identity.sponsorChain], - token_type: "refresh", - iss: "https://relayauth.dev", - aud: ["relayauth"], - exp: now - 20, // 20s past exp, should be accepted within verifier and route skew - iat: now - 120, - jti, - sid, - }); + const response = await requestRoute(app, "POST", "/v1/tokens/refresh", { + body: { refreshToken: skewedRefresh }, + }); + await assertJsonResponse(response, 200); + }, + ); + + await t.test( + "rejects path token refresh at the delegation horizon without persisting a new pair", + async () => { + const { app } = await createHarness(); + const now = Math.floor(Date.now() / 1000); + const jti = `relay_pa_${crypto.randomUUID().replace(/-/g, "")}`; + const sid = `sess_${crypto.randomUUID().replace(/-/g, "")}`; + const refreshToken = signRs256Jwt({ + sub: "agent_path_horizon", + org: "org_tokens_route", + wks: "ws_tokens_route", + scopes: ["relayauth:token:refresh"], + sponsorId: "user_tokens_owner", + sponsorChain: ["user_tokens_owner", "agent_path_horizon"], + token_type: "refresh", + iss: "https://relayauth.dev", + aud: ["relayauth"], + exp: now + 3600, + iat: now - 60, + jti, + sid, + meta: { + tokenClass: "path", + agentName: "path-horizon", + paths: JSON.stringify(["/linear/issues/*"]), + accessScopes: JSON.stringify(["relayfile:fs:read:/linear/issues/*"]), + accessAudience: JSON.stringify(["relayfile"]), + delegationNotAfter: new Date((now - 10) * 1000).toISOString(), + }, + }); + await seedActiveTokens(app, "agent_path_horizon", [jti]); + const beforeCount = await countStoredTokens(app); - const response = await requestRoute(app, "POST", "/v1/tokens/refresh", { - body: { refreshToken: skewedRefresh }, - }); - await assertJsonResponse(response, 200); - }); + const response = await requestRoute(app, "POST", "/v1/tokens/refresh", { + body: { refreshToken }, + }); - await t.test("rejects path token refresh at the delegation horizon without persisting a new pair", async () => { - const { app } = await createHarness(); - const now = Math.floor(Date.now() / 1000); - const jti = `relay_pa_${crypto.randomUUID().replace(/-/g, "")}`; - const sid = `sess_${crypto.randomUUID().replace(/-/g, "")}`; - const refreshToken = signRs256Jwt({ - sub: "agent_path_horizon", - org: "org_tokens_route", - wks: "ws_tokens_route", - scopes: ["relayauth:token:refresh"], - sponsorId: "user_tokens_owner", - sponsorChain: ["user_tokens_owner", "agent_path_horizon"], - token_type: "refresh", - iss: "https://relayauth.dev", - aud: ["relayauth"], - exp: now + 3600, - iat: now - 60, - jti, - sid, - meta: { - tokenClass: "path", - agentName: "path-horizon", - paths: JSON.stringify(["/linear/issues/*"]), - accessScopes: JSON.stringify(["relayfile:fs:read:/linear/issues/*"]), - accessAudience: JSON.stringify(["relayfile"]), - delegationNotAfter: new Date((now - 10) * 1000).toISOString(), - }, - }); - await seedActiveTokens(app, "agent_path_horizon", [jti]); - const beforeCount = await countStoredTokens(app); + await assertJsonResponse(response, 401, (body) => { + assert.equal(body.code, "delegation_expired"); + }); + assert.equal(await countStoredTokens(app), beforeCount); + }, + ); + + await t.test( + "workspace token revocation wins over an expired delegation horizon", + async () => { + const { app, authHeaders } = await createHarness({ + authClaims: { + scopes: [ + "relayauth:api-key:manage:*", + "relayauth:token:create:*", + "relayfile:fs:read:*", + ], + }, + }); + const workspaceToken = await issueWorkspaceToken(app, authHeaders, { + scopes: ["relayauth:token:create:*", "relayfile:fs:read:*"], + }); + const revokeResponse = await requestRoute( + app, + "POST", + `/v1/api-keys/${workspaceToken.workspaceToken.id}/revoke`, + { body: {}, headers: authHeaders }, + ); + assert.equal(revokeResponse.status, 200); + + const now = Math.floor(Date.now() / 1000); + const jti = `relay_pa_${crypto.randomUUID().replace(/-/g, "")}`; + const refreshToken = signRs256Jwt({ + sub: "agent_path_revoked_horizon", + org: "org_tokens_route", + wks: "ws_tokens_route", + scopes: ["relayauth:token:refresh"], + sponsorId: "user_tokens_owner", + sponsorChain: ["user_tokens_owner", "agent_path_revoked_horizon"], + token_type: "refresh", + iss: "https://relayauth.dev", + aud: ["relayauth"], + exp: now + 3600, + iat: now - 60, + jti, + sid: `sess_${crypto.randomUUID().replace(/-/g, "")}`, + parentTokenId: workspaceToken.workspaceToken.id, + meta: { + tokenClass: "path", + workspaceTokenId: workspaceToken.workspaceToken.id, + agentName: "path-revoked-horizon", + paths: JSON.stringify(["/linear/issues/*"]), + accessScopes: JSON.stringify(["relayfile:fs:read:/linear/issues/*"]), + accessAudience: JSON.stringify(["relayfile"]), + delegationNotAfter: new Date((now - 10) * 1000).toISOString(), + }, + }); + await seedActiveTokens(app, "agent_path_revoked_horizon", [jti]); - const response = await requestRoute(app, "POST", "/v1/tokens/refresh", { - body: { refreshToken }, - }); + const response = await requestRoute(app, "POST", "/v1/tokens/refresh", { + body: { refreshToken }, + }); - await assertJsonResponse(response, 401, (body) => { - assert.equal(body.code, "delegation_expired"); - }); - assert.equal(await countStoredTokens(app), beforeCount); - }); + await assertJsonResponse(response, 401, (body) => { + assert.equal(body.code, "workspace_token_revoked"); + }); + }, + ); + + await t.test( + "preserves prefixed agent token shape and narrowed scopes on refresh", + async () => { + const { app, identity, authHeaders } = await createHarness({ + authClaims: { + scopes: [ + "relayauth:api-key:manage:*", + "relayauth:token:create:*", + "relayauth:role:read:*", + ], + }, + identity: createStoredIdentity({ + id: "agent_runtime_refresh", + orgId: "org_tokens_route", + workspaceId: "ws_tokens_route", + scopes: ["relayauth:role:read:*"], + }), + }); + const workspaceToken = await issueWorkspaceToken(app, authHeaders); + const issueResponse = await requestRoute( + app, + "POST", + "/v1/tokens/agent", + { + body: { + agentId: identity.id, + scopes: ["relayauth:role:read:*"], + }, + headers: { + "x-api-key": workspaceToken.key, + }, + }, + ); + const issued = await assertJsonResponse( + issueResponse, + 201, + ); - await t.test("workspace token revocation wins over an expired delegation horizon", async () => { - const { app, authHeaders } = await createHarness({ - authClaims: { - scopes: ["relayauth:api-key:manage:*", "relayauth:token:create:*", "relayfile:fs:read:*"], - }, - }); - const workspaceToken = await issueWorkspaceToken(app, authHeaders, { - scopes: ["relayauth:token:create:*", "relayfile:fs:read:*"], - }); - const revokeResponse = await requestRoute( - app, - "POST", - `/v1/api-keys/${workspaceToken.workspaceToken.id}/revoke`, - { body: {}, headers: authHeaders }, - ); - assert.equal(revokeResponse.status, 200); - - const now = Math.floor(Date.now() / 1000); - const jti = `relay_pa_${crypto.randomUUID().replace(/-/g, "")}`; - const refreshToken = signRs256Jwt({ - sub: "agent_path_revoked_horizon", - org: "org_tokens_route", - wks: "ws_tokens_route", - scopes: ["relayauth:token:refresh"], - sponsorId: "user_tokens_owner", - sponsorChain: ["user_tokens_owner", "agent_path_revoked_horizon"], - token_type: "refresh", - iss: "https://relayauth.dev", - aud: ["relayauth"], - exp: now + 3600, - iat: now - 60, - jti, - sid: `sess_${crypto.randomUUID().replace(/-/g, "")}`, - parentTokenId: workspaceToken.workspaceToken.id, - meta: { - tokenClass: "path", - workspaceTokenId: workspaceToken.workspaceToken.id, - agentName: "path-revoked-horizon", - paths: JSON.stringify(["/linear/issues/*"]), - accessScopes: JSON.stringify(["relayfile:fs:read:/linear/issues/*"]), - accessAudience: JSON.stringify(["relayfile"]), - delegationNotAfter: new Date((now - 10) * 1000).toISOString(), - }, - }); - await seedActiveTokens(app, "agent_path_revoked_horizon", [jti]); + const refreshResponse = await requestRoute( + app, + "POST", + "/v1/tokens/refresh", + { + body: { + refreshToken: issued.refreshToken, + }, + }, + ); + const refreshed = await assertJsonResponse( + refreshResponse, + 200, + ); - const response = await requestRoute(app, "POST", "/v1/tokens/refresh", { - body: { refreshToken }, - }); + assert.match( + refreshed.accessToken, + /^relay_ag_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/, + ); + assert.match( + refreshed.refreshToken, + /^relay_ag_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/, + ); + const refreshedAccessClaims = decodeJwtJsonSegment( + refreshed.accessToken, + 1, + ); + assert.equal(refreshedAccessClaims.meta?.tokenClass, "agent"); + assert.deepEqual( + JSON.parse(refreshedAccessClaims.meta?.accessScopes ?? "[]"), + ["relayauth:role:read:*"], + ); + }, + ); + + await t.test( + "rejects refreshing an agent token after its workspace token is revoked", + async () => { + const { app, identity, authHeaders } = await createHarness({ + authClaims: { + scopes: [ + "relayauth:api-key:manage:*", + "relayauth:token:create:*", + "relayauth:role:read:*", + ], + }, + identity: createStoredIdentity({ + id: "agent_runtime_revoked_refresh", + orgId: "org_tokens_route", + workspaceId: "ws_tokens_route", + scopes: ["relayauth:role:read:*"], + }), + }); + const workspaceToken = await issueWorkspaceToken(app, authHeaders); + const issueResponse = await requestRoute( + app, + "POST", + "/v1/tokens/agent", + { + body: { + agentId: identity.id, + scopes: ["relayauth:role:read:*"], + }, + headers: { + "x-api-key": workspaceToken.key, + }, + }, + ); + const issued = await assertJsonResponse( + issueResponse, + 201, + ); - await assertJsonResponse(response, 401, (body) => { - assert.equal(body.code, "workspace_token_revoked"); - }); - }); + const revokeResponse = await requestRoute( + app, + "POST", + `/v1/api-keys/${workspaceToken.workspaceToken.id}/revoke`, + { + body: {}, + headers: authHeaders, + }, + ); + assert.equal(revokeResponse.status, 200); + + const refreshResponse = await requestRoute( + app, + "POST", + "/v1/tokens/refresh", + { + body: { + refreshToken: issued.refreshToken, + }, + }, + ); + await assertJsonResponse(refreshResponse, 401, (body) => { + assert.equal(body.code, "workspace_token_revoked"); + }); + }, + ); +}); - await t.test("preserves prefixed agent token shape and narrowed scopes on refresh", async () => { - const { app, identity, authHeaders } = await createHarness({ - authClaims: { - scopes: ["relayauth:api-key:manage:*", "relayauth:token:create:*", "relayauth:role:read:*"], - }, - identity: createStoredIdentity({ - id: "agent_runtime_refresh", +test("POST /v1/tokens enforces max sponsor-chain depth", async (t) => { + await t.test( + "rejects issuance when identity.sponsorChain exceeds 10", + async () => { + const deepChain = Array.from({ length: 11 }, (_, index) => + index === 10 ? "agent_deep_subject" : `user_ancestor_${index}`, + ); + const deepIdentity = createStoredIdentity({ + id: "agent_deep_subject", + name: "Deep Subject", orgId: "org_tokens_route", workspaceId: "ws_tokens_route", - scopes: ["relayauth:role:read:*"], - }), - }); - const workspaceToken = await issueWorkspaceToken(app, authHeaders); - const issueResponse = await requestRoute(app, "POST", "/v1/tokens/agent", { - body: { - agentId: identity.id, - scopes: ["relayauth:role:read:*"], - }, - headers: { - "x-api-key": workspaceToken.key, - }, - }); - const issued = await assertJsonResponse(issueResponse, 201); - - const refreshResponse = await requestRoute(app, "POST", "/v1/tokens/refresh", { - body: { - refreshToken: issued.refreshToken, - }, - }); - const refreshed = await assertJsonResponse(refreshResponse, 200); - - assert.match(refreshed.accessToken, /^relay_ag_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/); - assert.match(refreshed.refreshToken, /^relay_ag_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/); - const refreshedAccessClaims = decodeJwtJsonSegment(refreshed.accessToken, 1); - assert.equal(refreshedAccessClaims.meta?.tokenClass, "agent"); - assert.deepEqual(JSON.parse(refreshedAccessClaims.meta?.accessScopes ?? "[]"), ["relayauth:role:read:*"]); - }); + sponsorId: "user_ancestor_0", + sponsorChain: deepChain, + scopes: ["specialist:invoke"], + }); - await t.test("rejects refreshing an agent token after its workspace token is revoked", async () => { - const { app, identity, authHeaders } = await createHarness({ - authClaims: { - scopes: ["relayauth:api-key:manage:*", "relayauth:token:create:*", "relayauth:role:read:*"], - }, - identity: createStoredIdentity({ - id: "agent_runtime_revoked_refresh", - orgId: "org_tokens_route", - workspaceId: "ws_tokens_route", - scopes: ["relayauth:role:read:*"], - }), - }); - const workspaceToken = await issueWorkspaceToken(app, authHeaders); - const issueResponse = await requestRoute(app, "POST", "/v1/tokens/agent", { - body: { - agentId: identity.id, - scopes: ["relayauth:role:read:*"], - }, - headers: { - "x-api-key": workspaceToken.key, - }, - }); - const issued = await assertJsonResponse(issueResponse, 201); + const { app, authHeaders } = await createHarness({ + identity: deepIdentity, + }); - const revokeResponse = await requestRoute( - app, - "POST", - `/v1/api-keys/${workspaceToken.workspaceToken.id}/revoke`, - { - body: {}, + const response = await requestRoute(app, "POST", "/v1/tokens", { + body: { + identityId: deepIdentity.id, + scopes: ["specialist:invoke"], + audience: ["specialist"], + }, headers: authHeaders, - }, - ); - assert.equal(revokeResponse.status, 200); - - const refreshResponse = await requestRoute(app, "POST", "/v1/tokens/refresh", { - body: { - refreshToken: issued.refreshToken, - }, - }); - await assertJsonResponse(refreshResponse, 401, (body) => { - assert.equal(body.code, "workspace_token_revoked"); - }); - }); -}); - -test("POST /v1/tokens enforces max sponsor-chain depth", async (t) => { - await t.test("rejects issuance when identity.sponsorChain exceeds 10", async () => { - const deepChain = Array.from({ length: 11 }, (_, index) => - index === 10 ? "agent_deep_subject" : `user_ancestor_${index}`, - ); - const deepIdentity = createStoredIdentity({ - id: "agent_deep_subject", - name: "Deep Subject", - orgId: "org_tokens_route", - workspaceId: "ws_tokens_route", - sponsorId: "user_ancestor_0", - sponsorChain: deepChain, - scopes: ["specialist:invoke"], - }); - - const { app, authHeaders } = await createHarness({ identity: deepIdentity }); - - const response = await requestRoute(app, "POST", "/v1/tokens", { - body: { - identityId: deepIdentity.id, - scopes: ["specialist:invoke"], - audience: ["specialist"], - }, - headers: authHeaders, - }); + }); - await assertJsonResponse(response, 400, (body) => { - assert.match(JSON.stringify(body), /delegation|depth|chain/i); - }); - }); + await assertJsonResponse(response, 400, (body) => { + assert.match(JSON.stringify(body), /delegation|depth|chain/i); + }); + }, + ); }); test("POST /v1/tokens/revoke", async (t) => { @@ -1932,7 +2470,10 @@ test("POST /v1/tokens/revoke", async (t) => { const { app, identity, authHeaders } = await createHarness(); const { accessClaims } = createRs256TokenPair(identity); await seedActiveTokens(app, identity.id, [accessClaims.jti]); - const atomicRevocations = app.storage.revocations.revokeIdentityTokensWithAudit.bind(app.storage.revocations); + const atomicRevocations = + app.storage.revocations.revokeIdentityTokensWithAudit.bind( + app.storage.revocations, + ); let atomicRevokeCalls = 0; app.storage.revocations.revokeIdentityTokensWithAudit = async (input) => { atomicRevokeCalls += 1; @@ -1968,37 +2509,43 @@ test("POST /v1/tokens/revoke", async (t) => { }); }); - await t.test("returns 403 when the caller lacks relayauth:token:manage scope", async () => { - const { app, authHeaders } = await createHarness({ - authClaims: { - scopes: ["relayauth:identity:read:*"], - }, - }); + await t.test( + "returns 403 when the caller lacks relayauth:token:manage scope", + async () => { + const { app, authHeaders } = await createHarness({ + authClaims: { + scopes: ["relayauth:identity:read:*"], + }, + }); - const response = await requestRoute(app, "POST", "/v1/tokens/revoke", { - body: { - tokenId: "tok_forbidden_revoke", - }, - headers: authHeaders, - }); + const response = await requestRoute(app, "POST", "/v1/tokens/revoke", { + body: { + tokenId: "tok_forbidden_revoke", + }, + headers: authHeaders, + }); - await assertJsonResponse(response, 403, (body) => { - assert.match(JSON.stringify(body), /scope/i); - }); - }); + await assertJsonResponse(response, 403, (body) => { + assert.match(JSON.stringify(body), /scope/i); + }); + }, + ); - await t.test("returns 400 when tokenId, identityId, and sessionId are all missing", async () => { - const { app, authHeaders } = await createHarness(); + await t.test( + "returns 400 when tokenId, identityId, and sessionId are all missing", + async () => { + const { app, authHeaders } = await createHarness(); - const response = await requestRoute(app, "POST", "/v1/tokens/revoke", { - body: {}, - headers: authHeaders, - }); + const response = await requestRoute(app, "POST", "/v1/tokens/revoke", { + body: {}, + headers: authHeaders, + }); - await assertJsonResponse(response, 400, (body) => { - assert.match(JSON.stringify(body), /tokenId|identityId|sessionId/i); - }); - }); + await assertJsonResponse(response, 400, (body) => { + assert.match(JSON.stringify(body), /tokenId|identityId|sessionId/i); + }); + }, + ); await t.test("returns 404 when the target token does not exist", async () => { const { app, authHeaders } = await createHarness(); @@ -2044,28 +2591,35 @@ test("GET /v1/tokens/introspect", async (t) => { await t.test("returns 401 when Authorization is missing", async () => { const { app } = await createHarness(); - const response = await requestRoute(app, "GET", "/v1/tokens/introspect?token=abc"); + const response = await requestRoute( + app, + "GET", + "/v1/tokens/introspect?token=abc", + ); await assertJsonResponse(response, 401, (body) => { assert.equal(body.code, "missing_authorization"); }); }); - await t.test("returns 400 when the token query parameter is missing", async () => { - const { app, authHeaders } = await createHarness(); + await t.test( + "returns 400 when the token query parameter is missing", + async () => { + const { app, authHeaders } = await createHarness(); - const response = await requestRoute(app, "GET", "/v1/tokens/introspect", { - headers: authHeaders, - }); + const response = await requestRoute(app, "GET", "/v1/tokens/introspect", { + headers: authHeaders, + }); - await assertJsonResponse(response, 400, (body) => { - assert.match(JSON.stringify(body), /token/i); - }); - }); + await assertJsonResponse(response, 400, (body) => { + assert.match(JSON.stringify(body), /token/i); + }); + }, + ); await t.test("returns null for an expired access token", async () => { const { app, identity, authHeaders } = await createHarness(); - const now = Math.floor(Date.now() / 1000) - (2 * 3600); + const now = Math.floor(Date.now() / 1000) - 2 * 3600; const { pair, accessClaims } = createRs256TokenPair(identity, { issuedAt: now, accessExpiresInSeconds: 60, @@ -2081,7 +2635,10 @@ test("GET /v1/tokens/introspect", async (t) => { }, ); - const body = await assertJsonResponse(response, 200); + const body = await assertJsonResponse( + response, + 200, + ); assert.equal(body, null); }); @@ -2090,7 +2647,8 @@ test("GET /v1/tokens/introspect", async (t) => { const { pair, accessClaims } = createRs256TokenPair(identity); await seedActiveTokens(app, identity.id, [accessClaims.jti]); - const revocations = app.storage.revocations as typeof app.storage.revocations & { + const revocations = app.storage + .revocations as typeof app.storage.revocations & { revoke(jti: string, expiresAt: number): Promise; }; await revocations.revoke(accessClaims.jti, accessClaims.exp); @@ -2104,265 +2662,396 @@ test("GET /v1/tokens/introspect", async (t) => { }, ); - const body = await assertJsonResponse(response, 200); + const body = await assertJsonResponse( + response, + 200, + ); assert.equal(body, null); }); - await t.test("returns null for an agent token after its workspace token is revoked", async () => { - const { app, identity, authHeaders } = await createHarness({ - authClaims: { - scopes: [ - "relayauth:api-key:manage:*", - "relayauth:token:create:*", - "relayauth:token:read:*", - "relayauth:role:read:*", - ], - }, - identity: createStoredIdentity({ - id: "agent_runtime_introspect_after_revoke", - orgId: "org_tokens_route", - workspaceId: "ws_tokens_route", - scopes: ["relayauth:role:read:*"], - }), - }); - const workspaceToken = await issueWorkspaceToken(app, authHeaders); - const issueResponse = await requestRoute(app, "POST", "/v1/tokens/agent", { - body: { - agentId: identity.id, - scopes: ["relayauth:role:read:*"], - }, - headers: { - "x-api-key": workspaceToken.key, - }, - }); - const issued = await assertJsonResponse(issueResponse, 201); - - const revokeResponse = await requestRoute( - app, - "POST", - `/v1/api-keys/${workspaceToken.workspaceToken.id}/revoke`, - { - body: {}, - headers: authHeaders, - }, - ); - assert.equal(revokeResponse.status, 200); + await t.test( + "returns null for an agent token after its workspace token is revoked", + async () => { + const { app, identity, authHeaders } = await createHarness({ + authClaims: { + scopes: [ + "relayauth:api-key:manage:*", + "relayauth:token:create:*", + "relayauth:token:read:*", + "relayauth:role:read:*", + ], + }, + identity: createStoredIdentity({ + id: "agent_runtime_introspect_after_revoke", + orgId: "org_tokens_route", + workspaceId: "ws_tokens_route", + scopes: ["relayauth:role:read:*"], + }), + }); + const workspaceToken = await issueWorkspaceToken(app, authHeaders); + const issueResponse = await requestRoute( + app, + "POST", + "/v1/tokens/agent", + { + body: { + agentId: identity.id, + scopes: ["relayauth:role:read:*"], + }, + headers: { + "x-api-key": workspaceToken.key, + }, + }, + ); + const issued = await assertJsonResponse( + issueResponse, + 201, + ); - const response = await requestRoute( - app, - "GET", - `/v1/tokens/introspect?token=${encodeURIComponent(issued.accessToken)}`, - { - headers: authHeaders, - }, - ); + const revokeResponse = await requestRoute( + app, + "POST", + `/v1/api-keys/${workspaceToken.workspaceToken.id}/revoke`, + { + body: {}, + headers: authHeaders, + }, + ); + assert.equal(revokeResponse.status, 200); + + const response = await requestRoute( + app, + "GET", + `/v1/tokens/introspect?token=${encodeURIComponent(issued.accessToken)}`, + { + headers: authHeaders, + }, + ); - const body = await assertJsonResponse(response, 200); - assert.equal(body, null); - }); + const body = await assertJsonResponse( + response, + 200, + ); + assert.equal(body, null); + }, + ); }); test("derived agent bearer auth", async (t) => { - await t.test("closes derived agent tokens after workspace-token revocation", async () => { - const { app, identity, authHeaders } = await createHarness({ - authClaims: { - scopes: ["relayauth:api-key:manage:*", "relayauth:token:create:*", "relayauth:role:read:*"], - }, - identity: createStoredIdentity({ - id: "agent_runtime_roles_after_revoke", - orgId: "org_tokens_route", - workspaceId: "ws_tokens_route", - scopes: ["relayauth:role:read:*"], - }), - }); - const workspaceToken = await issueWorkspaceToken(app, authHeaders); - const issueResponse = await requestRoute(app, "POST", "/v1/tokens/agent", { - body: { - agentId: identity.id, - scopes: ["relayauth:role:read:*"], - }, - headers: { - "x-api-key": workspaceToken.key, - }, - }); - const issued = await assertJsonResponse(issueResponse, 201); - - const beforeRevoke = await requestRoute(app, "GET", "/v1/roles", { - headers: { - Authorization: `Bearer ${issued.accessToken}`, - }, - }); - assert.equal(beforeRevoke.status, 200); + await t.test( + "closes derived agent tokens after workspace-token revocation", + async () => { + const { app, identity, authHeaders } = await createHarness({ + authClaims: { + scopes: [ + "relayauth:api-key:manage:*", + "relayauth:token:create:*", + "relayauth:role:read:*", + ], + }, + identity: createStoredIdentity({ + id: "agent_runtime_roles_after_revoke", + orgId: "org_tokens_route", + workspaceId: "ws_tokens_route", + scopes: ["relayauth:role:read:*"], + }), + }); + const workspaceToken = await issueWorkspaceToken(app, authHeaders); + const issueResponse = await requestRoute( + app, + "POST", + "/v1/tokens/agent", + { + body: { + agentId: identity.id, + scopes: ["relayauth:role:read:*"], + }, + headers: { + "x-api-key": workspaceToken.key, + }, + }, + ); + const issued = await assertJsonResponse( + issueResponse, + 201, + ); - const revokeResponse = await requestRoute( - app, - "POST", - `/v1/api-keys/${workspaceToken.workspaceToken.id}/revoke`, - { - body: {}, - headers: authHeaders, - }, - ); - assert.equal(revokeResponse.status, 200); + const beforeRevoke = await requestRoute(app, "GET", "/v1/roles", { + headers: { + Authorization: `Bearer ${issued.accessToken}`, + }, + }); + assert.equal(beforeRevoke.status, 200); + + const revokeResponse = await requestRoute( + app, + "POST", + `/v1/api-keys/${workspaceToken.workspaceToken.id}/revoke`, + { + body: {}, + headers: authHeaders, + }, + ); + assert.equal(revokeResponse.status, 200); - const afterRevoke = await requestRoute(app, "GET", "/v1/roles", { - headers: { - Authorization: `Bearer ${issued.accessToken}`, - }, - }); - await assertJsonResponse(afterRevoke, 401, (body) => { - assert.equal(body.code, "workspace_token_revoked"); - }); - }); + const afterRevoke = await requestRoute(app, "GET", "/v1/roles", { + headers: { + Authorization: `Bearer ${issued.accessToken}`, + }, + }); + await assertJsonResponse(afterRevoke, 401, (body) => { + assert.equal(body.code, "workspace_token_revoked"); + }); + }, + ); }); test("POST /v1/tokens refreshTokenTtlSeconds", async (t) => { - await t.test("issues a refresh token with the requested TTL when refreshTokenTtlSeconds is provided", async () => { - const { app, identity, authHeaders } = await createHarness(); - const THIRTY_DAYS = 30 * 24 * 3600; - - const response = await requestRoute(app, "POST", "/v1/tokens", { - body: { - identityId: identity.id, - scopes: ["specialist:invoke"], - audience: ["specialist"], - refreshTokenTtlSeconds: THIRTY_DAYS, - }, - headers: authHeaders, - }); + await t.test( + "issues a refresh token with the requested TTL when refreshTokenTtlSeconds is provided", + async () => { + const { app, identity, authHeaders } = await createHarness(); + const THIRTY_DAYS = 30 * 24 * 3600; - const body = await assertJsonResponse(response, 201); - const refreshClaims = decodeJwtJsonSegment(body.refreshToken, 1); - const accessClaims = decodeJwtJsonSegment(body.accessToken, 1); - - const refreshTtl = refreshClaims.exp - refreshClaims.iat; - assert.ok(refreshTtl >= THIRTY_DAYS - 5, `expected refresh TTL ~${THIRTY_DAYS}s, got ${refreshTtl}`); - assert.ok(refreshTtl <= THIRTY_DAYS + 5, `expected refresh TTL ~${THIRTY_DAYS}s, got ${refreshTtl}`); + const response = await requestRoute(app, "POST", "/v1/tokens", { + body: { + identityId: identity.id, + scopes: ["specialist:invoke"], + audience: ["specialist"], + refreshTokenTtlSeconds: THIRTY_DAYS, + }, + headers: authHeaders, + }); - assert.equal(refreshClaims.meta?.refreshTokenTtl, String(THIRTY_DAYS)); - assert.equal(accessClaims.meta?.refreshTokenTtl, String(THIRTY_DAYS)); - }); + const body = await assertJsonResponse(response, 201); + const refreshClaims = decodeJwtJsonSegment( + body.refreshToken, + 1, + ); + const accessClaims = decodeJwtJsonSegment( + body.accessToken, + 1, + ); - await t.test("caps refreshTokenTtlSeconds at 90 days (MAX_OPERATOR_REFRESH_TOKEN_TTL_SECONDS)", async () => { - const { app, identity, authHeaders } = await createHarness(); - const NINETY_DAYS = 90 * 24 * 3600; - const TOO_LARGE = 365 * 24 * 3600; + const refreshTtl = refreshClaims.exp - refreshClaims.iat; + assert.ok( + refreshTtl >= THIRTY_DAYS - 5, + `expected refresh TTL ~${THIRTY_DAYS}s, got ${refreshTtl}`, + ); + assert.ok( + refreshTtl <= THIRTY_DAYS + 5, + `expected refresh TTL ~${THIRTY_DAYS}s, got ${refreshTtl}`, + ); - const response = await requestRoute(app, "POST", "/v1/tokens", { - body: { - identityId: identity.id, - scopes: ["specialist:invoke"], - refreshTokenTtlSeconds: TOO_LARGE, - }, - headers: authHeaders, - }); + assert.equal(refreshClaims.meta?.refreshTokenTtl, String(THIRTY_DAYS)); + assert.equal(accessClaims.meta?.refreshTokenTtl, String(THIRTY_DAYS)); + }, + ); - const body = await assertJsonResponse(response, 201); - const refreshClaims = decodeJwtJsonSegment(body.refreshToken, 1); - const refreshTtl = refreshClaims.exp - refreshClaims.iat; - assert.ok(refreshTtl <= NINETY_DAYS + 5, `refresh TTL must be capped at 90d, got ${refreshTtl}`); - assert.equal(refreshClaims.meta?.refreshTokenTtl, String(NINETY_DAYS)); - }); + await t.test( + "caps refreshTokenTtlSeconds at 90 days (MAX_OPERATOR_REFRESH_TOKEN_TTL_SECONDS)", + async () => { + const { app, identity, authHeaders } = await createHarness(); + const NINETY_DAYS = 90 * 24 * 3600; + const TOO_LARGE = 365 * 24 * 3600; - await t.test("defaults to 24h refresh TTL when refreshTokenTtlSeconds is absent", async () => { - const { app, identity, authHeaders } = await createHarness(); - const DEFAULT_24H = 24 * 3600; + const response = await requestRoute(app, "POST", "/v1/tokens", { + body: { + identityId: identity.id, + scopes: ["specialist:invoke"], + refreshTokenTtlSeconds: TOO_LARGE, + }, + headers: authHeaders, + }); - const response = await requestRoute(app, "POST", "/v1/tokens", { - body: { - identityId: identity.id, - scopes: ["specialist:invoke"], - }, - headers: authHeaders, - }); + const body = await assertJsonResponse(response, 201); + const refreshClaims = decodeJwtJsonSegment( + body.refreshToken, + 1, + ); + const refreshTtl = refreshClaims.exp - refreshClaims.iat; + assert.ok( + refreshTtl <= NINETY_DAYS + 5, + `refresh TTL must be capped at 90d, got ${refreshTtl}`, + ); + assert.equal(refreshClaims.meta?.refreshTokenTtl, String(NINETY_DAYS)); + }, + ); - const body = await assertJsonResponse(response, 201); - const refreshClaims = decodeJwtJsonSegment(body.refreshToken, 1); - const refreshTtl = refreshClaims.exp - refreshClaims.iat; - assert.ok(refreshTtl >= DEFAULT_24H - 5, `expected default 24h refresh TTL, got ${refreshTtl}`); - assert.ok(refreshTtl <= DEFAULT_24H + 5, `expected default 24h refresh TTL, got ${refreshTtl}`); - assert.equal(refreshClaims.meta?.refreshTokenTtl, undefined); - }); + await t.test( + "defaults to 24h refresh TTL when refreshTokenTtlSeconds is absent", + async () => { + const { app, identity, authHeaders } = await createHarness(); + const DEFAULT_24H = 24 * 3600; - await t.test("propagates refreshTokenTtl through rotation so each new refresh token gets the same TTL", async () => { - const { app, identity, authHeaders } = await createHarness(); - const THIRTY_DAYS = 30 * 24 * 3600; + const response = await requestRoute(app, "POST", "/v1/tokens", { + body: { + identityId: identity.id, + scopes: ["specialist:invoke"], + }, + headers: authHeaders, + }); - const issueResponse = await requestRoute(app, "POST", "/v1/tokens", { - body: { - identityId: identity.id, - scopes: ["specialist:invoke"], - refreshTokenTtlSeconds: THIRTY_DAYS, - }, - headers: authHeaders, - }); - const issued = await assertJsonResponse(issueResponse, 201); + const body = await assertJsonResponse(response, 201); + const refreshClaims = decodeJwtJsonSegment( + body.refreshToken, + 1, + ); + const refreshTtl = refreshClaims.exp - refreshClaims.iat; + assert.ok( + refreshTtl >= DEFAULT_24H - 5, + `expected default 24h refresh TTL, got ${refreshTtl}`, + ); + assert.ok( + refreshTtl <= DEFAULT_24H + 5, + `expected default 24h refresh TTL, got ${refreshTtl}`, + ); + assert.equal(refreshClaims.meta?.refreshTokenTtl, undefined); + }, + ); - const refreshResponse = await requestRoute(app, "POST", "/v1/tokens/refresh", { - body: { refreshToken: issued.refreshToken }, - }); - const rotated = await assertJsonResponse(refreshResponse, 200); + await t.test( + "propagates refreshTokenTtl through rotation so each new refresh token gets the same TTL", + async () => { + const { app, identity, authHeaders } = await createHarness(); + const THIRTY_DAYS = 30 * 24 * 3600; - const rotatedRefreshClaims = decodeJwtJsonSegment(rotated.refreshToken, 1); - const rotatedRefreshTtl = rotatedRefreshClaims.exp - rotatedRefreshClaims.iat; - assert.ok( - rotatedRefreshTtl >= THIRTY_DAYS - 5, - `expected rotated refresh TTL ~${THIRTY_DAYS}s, got ${rotatedRefreshTtl}`, - ); - assert.ok( - rotatedRefreshTtl <= THIRTY_DAYS + 5, - `expected rotated refresh TTL ~${THIRTY_DAYS}s, got ${rotatedRefreshTtl}`, - ); - assert.equal(rotatedRefreshClaims.meta?.refreshTokenTtl, String(THIRTY_DAYS)); - }); + const issueResponse = await requestRoute(app, "POST", "/v1/tokens", { + body: { + identityId: identity.id, + scopes: ["specialist:invoke"], + refreshTokenTtlSeconds: THIRTY_DAYS, + }, + headers: authHeaders, + }); + const issued = await assertJsonResponse(issueResponse, 201); + + const refreshResponse = await requestRoute( + app, + "POST", + "/v1/tokens/refresh", + { + body: { refreshToken: issued.refreshToken }, + }, + ); + const rotated = await assertJsonResponse(refreshResponse, 200); - await t.test("workspace-path mint: 90d TTL lands in token meta and survives /v1/tokens/refresh", async () => { - const { app, authHeaders } = await createHarness({ - authClaims: { - scopes: [ - "relayauth:api-key:manage:*", - "relayfile:fs:read:*", - "relayfile:fs:write:*", - ], - }, - }); - const orgApiKey = await issueApiKey(app, authHeaders, [ - "relayauth:api-key:manage:*", - "relayfile:fs:read:*", - "relayfile:fs:write:*", - ]); - const NINETY_DAYS = 90 * 24 * 3600; + const rotatedRefreshClaims = decodeJwtJsonSegment( + rotated.refreshToken, + 1, + ); + const rotatedRefreshTtl = + rotatedRefreshClaims.exp - rotatedRefreshClaims.iat; + assert.ok( + rotatedRefreshTtl >= THIRTY_DAYS - 5, + `expected rotated refresh TTL ~${THIRTY_DAYS}s, got ${rotatedRefreshTtl}`, + ); + assert.ok( + rotatedRefreshTtl <= THIRTY_DAYS + 5, + `expected rotated refresh TTL ~${THIRTY_DAYS}s, got ${rotatedRefreshTtl}`, + ); + assert.equal( + rotatedRefreshClaims.meta?.refreshTokenTtl, + String(THIRTY_DAYS), + ); + }, + ); + + await t.test( + "workspace-path mint: 90d TTL lands in token meta and survives /v1/tokens/refresh", + async () => { + const { app, authHeaders } = await createHarness({ + authClaims: { + scopes: [ + "relayauth:api-key:manage:*", + "relayfile:fs:read:*", + "relayfile:fs:write:*", + ], + }, + }); + const orgApiKey = await issueApiKey(app, authHeaders, [ + "relayauth:api-key:manage:*", + "relayfile:fs:read:*", + "relayfile:fs:write:*", + ]); + const NINETY_DAYS = 90 * 24 * 3600; + + const mintResponse = await requestRoute( + app, + "POST", + "/v1/tokens/workspace-path", + { + body: { + workspaceId: "ws_tokens_route", + agentName: "cloud-orchestrator", + paths: ["/github/repos/*"], + scopes: ["relayfile:fs:read:/github/repos/*"], + refreshTokenTtlSeconds: NINETY_DAYS, + }, + headers: { "x-api-key": orgApiKey.key }, + }, + ); - const mintResponse = await requestRoute(app, "POST", "/v1/tokens/workspace-path", { - body: { - workspaceId: "ws_tokens_route", - agentName: "cloud-orchestrator", - paths: ["/github/repos/*"], - scopes: ["relayfile:fs:read:/github/repos/*"], - refreshTokenTtlSeconds: NINETY_DAYS, - }, - headers: { "x-api-key": orgApiKey.key }, - }); + const minted = await assertJsonResponse( + mintResponse, + 201, + ); + const mintedRefreshClaims = decodeJwtJsonSegment( + minted.refreshToken, + 1, + ); - const minted = await assertJsonResponse(mintResponse, 201); - const mintedRefreshClaims = decodeJwtJsonSegment(minted.refreshToken, 1); + const mintedRefreshTtl = + mintedRefreshClaims.exp - mintedRefreshClaims.iat; + assert.ok( + mintedRefreshTtl >= NINETY_DAYS - 5, + `minted refresh TTL should be ~90d, got ${mintedRefreshTtl}`, + ); + assert.ok( + mintedRefreshTtl <= NINETY_DAYS + 5, + `minted refresh TTL should be ~90d, got ${mintedRefreshTtl}`, + ); + assert.equal( + mintedRefreshClaims.meta?.refreshTokenTtl, + String(NINETY_DAYS), + ); - const mintedRefreshTtl = mintedRefreshClaims.exp - mintedRefreshClaims.iat; - assert.ok(mintedRefreshTtl >= NINETY_DAYS - 5, `minted refresh TTL should be ~90d, got ${mintedRefreshTtl}`); - assert.ok(mintedRefreshTtl <= NINETY_DAYS + 5, `minted refresh TTL should be ~90d, got ${mintedRefreshTtl}`); - assert.equal(mintedRefreshClaims.meta?.refreshTokenTtl, String(NINETY_DAYS)); + const refreshResponse = await requestRoute( + app, + "POST", + "/v1/tokens/refresh", + { + body: { refreshToken: minted.refreshToken }, + }, + ); + const rotated = await assertJsonResponse(refreshResponse, 200); - const refreshResponse = await requestRoute(app, "POST", "/v1/tokens/refresh", { - body: { refreshToken: minted.refreshToken }, - }); - const rotated = await assertJsonResponse(refreshResponse, 200); - - const rotatedRefreshClaims = decodeJwtJsonSegment(rotated.refreshToken, 1); - const rotatedRefreshTtl = rotatedRefreshClaims.exp - rotatedRefreshClaims.iat; - assert.ok(rotatedRefreshTtl >= NINETY_DAYS - 5, `rotated refresh TTL should be ~90d, got ${rotatedRefreshTtl}`); - assert.ok(rotatedRefreshTtl <= NINETY_DAYS + 5, `rotated refresh TTL should be ~90d, got ${rotatedRefreshTtl}`); - assert.equal(rotatedRefreshClaims.meta?.refreshTokenTtl, String(NINETY_DAYS)); - assert.match(rotated.accessToken, /^relay_pa_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/); - assert.match(rotated.refreshToken, /^relay_pa_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/); - }); + const rotatedRefreshClaims = decodeJwtJsonSegment( + rotated.refreshToken, + 1, + ); + const rotatedRefreshTtl = + rotatedRefreshClaims.exp - rotatedRefreshClaims.iat; + assert.ok( + rotatedRefreshTtl >= NINETY_DAYS - 5, + `rotated refresh TTL should be ~90d, got ${rotatedRefreshTtl}`, + ); + assert.ok( + rotatedRefreshTtl <= NINETY_DAYS + 5, + `rotated refresh TTL should be ~90d, got ${rotatedRefreshTtl}`, + ); + assert.equal( + rotatedRefreshClaims.meta?.refreshTokenTtl, + String(NINETY_DAYS), + ); + assert.match( + rotated.accessToken, + /^relay_pa_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/, + ); + assert.match( + rotated.refreshToken, + /^relay_pa_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/, + ); + }, + ); }); diff --git a/packages/server/src/routes/audit-export.ts b/packages/server/src/routes/audit-export.ts index d4392d6..051957c 100644 --- a/packages/server/src/routes/audit-export.ts +++ b/packages/server/src/routes/audit-export.ts @@ -65,7 +65,9 @@ auditExport.post("/export", requireScope("relayauth:audit:read"), async (c) => { return c.json({ error: parsed.error }, 400); } - const result = await c.get("storage").audit.query(parsed.value, { includeOverflowRow: false }); + const result = await c + .get("storage") + .audit.query(parsed.value, { includeOverflowRow: false }); if (result.kind === "budget_exhausted") { return c.json( { @@ -128,7 +130,7 @@ function escapeCsvValue(value: string): string { return sanitized; } - return `"${sanitized.replace(/"/g, "\"\"")}"`; + return `"${sanitized.replace(/"/g, '""')}"`; } export default auditExport; diff --git a/packages/server/src/routes/audit-query.ts b/packages/server/src/routes/audit-query.ts index 6f01243..287e2b2 100644 --- a/packages/server/src/routes/audit-query.ts +++ b/packages/server/src/routes/audit-query.ts @@ -161,7 +161,11 @@ export function parseAuditQuery( if (cursor && !decodedCursor) { return { ok: false, error: "invalid cursor" }; } - const limit = parseLimit(query.limit, options.defaultLimit ?? 50, options.maxLimit ?? 200); + const limit = parseLimit( + query.limit, + options.defaultLimit ?? 50, + options.maxLimit ?? 200, + ); if (limit === null) { return { ok: false, error: "limit must be a positive integer" }; } @@ -239,7 +243,9 @@ export function buildAuditQuery( clauses.push("timestamp < ?"); values.push( params.cursor.inclusive && !params.cursor.chunk - ? new Date(new Date(params.cursor.timestamp).getTime() + 60_000).toISOString() + ? new Date( + new Date(params.cursor.timestamp).getTime() + 60_000, + ).toISOString() : params.cursor.timestamp, ); if (params.cursor.entryCursor) { @@ -252,10 +258,14 @@ export function buildAuditQuery( } } else if (params.cursor) { clauses.push("(timestamp < ? OR (timestamp = ? AND id < ?))"); - values.push(params.cursor.timestamp, params.cursor.timestamp, params.cursor.id); + values.push( + params.cursor.timestamp, + params.cursor.timestamp, + params.cursor.id, + ); } - values.push(params.limit + (options.includeOverflowRow ?? true ? 1 : 0)); + values.push(params.limit + ((options.includeOverflowRow ?? true) ? 1 : 0)); return { sql: ` @@ -280,7 +290,9 @@ export function toAuditEntry(row: AuditLogRow): AuditEntryResponse { ...(row.plane ? { plane: row.plane } : {}), ...(row.resource ? { resource: row.resource } : {}), result: row.result, - ...(row.metadata_json ? { metadata: parseMetadata(row.metadata_json) } : {}), + ...(row.metadata_json + ? { metadata: parseMetadata(row.metadata_json) } + : {}), ...(row.ip ? { ip: row.ip } : {}), ...(row.user_agent ? { userAgent: row.user_agent } : {}), timestamp: row.timestamp, @@ -314,7 +326,11 @@ function normalizeQueryValue(value: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } -function parseLimit(value: unknown, defaultLimit: number, maxLimit: number): number | null { +function parseLimit( + value: unknown, + defaultLimit: number, + maxLimit: number, +): number | null { if (value === undefined) { return defaultLimit; } @@ -340,10 +356,14 @@ function parseLimit(value: unknown, defaultLimit: number, maxLimit: number): num } function isIsoTimestamp(value: string): boolean { - return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value); + return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test( + value, + ); } -export function encodeAuditCursor(cursor: AuditQueryCursor | undefined): string | null { +export function encodeAuditCursor( + cursor: AuditQueryCursor | undefined, +): string | null { if (!cursor?.timestamp) { return null; } @@ -394,13 +414,24 @@ export function decodeAuditCursor(value: string): AuditQueryCursor | null { } } -function parseArchiveCursor(value: string): Extract | null { +function parseArchiveCursor( + value: string, +): Extract | null { try { const parsed: unknown = JSON.parse(value); if (!isRecord(parsed)) { return null; } - const { version, kind, orgId, timestamp, inclusive, chunk, entryCursor, filterKey } = parsed; + const { + version, + kind, + orgId, + timestamp, + inclusive, + chunk, + entryCursor, + filterKey, + } = parsed; return version === 1 && kind === "archive_partition" && typeof orgId === "string" && @@ -427,7 +458,14 @@ function parseArchiveCursor(value: string): Extract { } function toBase64Url(value: string): string { - return btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); + return btoa(value) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); } function fromBase64Url(value: string): string { const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); - const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), "="); + const padded = normalized.padEnd( + normalized.length + ((4 - (normalized.length % 4)) % 4), + "=", + ); return atob(padded); } diff --git a/packages/server/src/routes/dashboard-stats.ts b/packages/server/src/routes/dashboard-stats.ts index 0b670db..f944472 100644 --- a/packages/server/src/routes/dashboard-stats.ts +++ b/packages/server/src/routes/dashboard-stats.ts @@ -51,8 +51,19 @@ type DashboardIdentityCountRow = { suspendedIdentities?: number | string | null; }; -type DashboardAuditCounts = Required>; -type DashboardIdentityCounts = Required>; +type DashboardAuditCounts = Required< + Pick< + DashboardStatsResponse, + | "tokensIssued" + | "tokensRevoked" + | "tokensRefreshed" + | "scopeChecks" + | "scopeDenials" + > +>; +type DashboardIdentityCounts = Required< + Pick +>; type DashboardStatsQuery = { from?: string; @@ -104,7 +115,9 @@ dashboardStats.get("/", async (c) => { scopeDenials: auditCounts.scopeDenials, activeIdentities: identityCounts.activeIdentities, suspendedIdentities: identityCounts.suspendedIdentities, - ...(auditCounts.tokensRefreshed > 0 ? { tokensRefreshed: auditCounts.tokensRefreshed } : {}), + ...(auditCounts.tokensRefreshed > 0 + ? { tokensRefreshed: auditCounts.tokensRefreshed } + : {}), ...(parsedQuery.value.from || parsedQuery.value.to ? { period: { @@ -141,7 +154,9 @@ function parseDashboardStatsQuery( } const cursorValue = normalizeQueryValue(query.cursor); - const decodedCursor = cursorValue ? decodeAuditCursor(cursorValue) : undefined; + const decodedCursor = cursorValue + ? decodeAuditCursor(cursorValue) + : undefined; if ( cursorValue && (!decodedCursor || @@ -164,12 +179,15 @@ function parseDashboardStatsQuery( value: { from, to, - cursor: decodedCursor, + cursor: + decodedCursor?.kind === "archive_partition" ? decodedCursor : undefined, }, }; } -function summarizeAuditCounts(rows: DashboardAuditCountRow[]): DashboardAuditCounts { +function summarizeAuditCounts( + rows: DashboardAuditCountRow[], +): DashboardAuditCounts { const counts: DashboardAuditCounts = { tokensIssued: 0, tokensRevoked: 0, @@ -210,7 +228,9 @@ function summarizeAuditCounts(rows: DashboardAuditCountRow[]): DashboardAuditCou return counts; } -function summarizeIdentityCounts(rows: DashboardIdentityCountRow[]): DashboardIdentityCounts { +function summarizeIdentityCounts( + rows: DashboardIdentityCountRow[], +): DashboardIdentityCounts { const counts: DashboardIdentityCounts = { activeIdentities: 0, suspendedIdentities: 0, @@ -276,7 +296,9 @@ function normalizeQueryValue(value: string | undefined): string | undefined { } function isIsoTimestamp(value: string): boolean { - return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value); + return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test( + value, + ); } export default dashboardStats; diff --git a/packages/server/src/routes/identity-activity.ts b/packages/server/src/routes/identity-activity.ts index b1633d0..d1110df 100644 --- a/packages/server/src/routes/identity-activity.ts +++ b/packages/server/src/routes/identity-activity.ts @@ -53,65 +53,77 @@ const SUB_AGENT_QUERY_SQL = ` ORDER BY created_at DESC, id DESC `; -identityActivity.get("/:id/activity", requireScope("relayauth:audit:read"), async (c) => { - const claims = (c as typeof c & { var: ScopeContextVars }).var.identity; - const identityId = c.req.param("id").trim(); - const storage = c.get("storage"); - - const storedIdentity = await getStoredIdentity(storage, identityId); - if (!storedIdentity.ok) { - return c.json({ error: storedIdentity.error }, storedIdentity.status); - } +identityActivity.get( + "/:id/activity", + requireScope("relayauth:audit:read"), + async (c) => { + const claims = (c as typeof c & { var: ScopeContextVars }).var.identity; + const identityId = c.req.param("id").trim(); + const storage = c.get("storage"); + + const storedIdentity = await getStoredIdentity(storage, identityId); + if (!storedIdentity.ok) { + return c.json({ error: storedIdentity.error }, storedIdentity.status); + } - if (storedIdentity.identity.orgId !== claims?.org) { - return c.json({ error: "identity_not_found" }, 404); - } + if (storedIdentity.identity.orgId !== claims?.org) { + return c.json({ error: "identity_not_found" }, 404); + } - const parsed = parseAuditQuery( - { - ...c.req.query(), - orgId: claims.org, - identityId, - }, - claims.org, - ); + const parsed = parseAuditQuery( + { + ...c.req.query(), + orgId: claims.org, + identityId, + }, + claims.org, + ); - if (!parsed.ok) { - return c.json({ error: parsed.error }, 400); - } + if (!parsed.ok) { + return c.json({ error: parsed.error }, 400); + } + + const result = await storage.audit.query(parsed.value); + if (result.kind === "budget_exhausted") { + return c.json( + { + entries: result.entries, + nextCursor: encodeAuditCursor(result.continuation), + hasMore: true, + partial: true, + workBudget: result.workBudget, + sponsorChain: storedIdentity.identity.sponsorChain, + budgetUsage: summarizeBudgetUsage(storedIdentity.identity), + subAgents: await listSubAgentTree( + storage, + storedIdentity.identity.orgId, + storedIdentity.identity.id, + ), + }, + 200, + ); + } + const entries = result.entries; + const hasMore = entries.length > parsed.value.limit; + const page = hasMore ? entries.slice(0, parsed.value.limit) : entries; - const result = await storage.audit.query(parsed.value); - if (result.kind === "budget_exhausted") { return c.json( { - entries: result.entries, - nextCursor: encodeAuditCursor(result.continuation), - hasMore: true, - partial: true, - workBudget: result.workBudget, + entries: page, + nextCursor: hasMore ? encodeCursor(page[page.length - 1]) : null, + hasMore, sponsorChain: storedIdentity.identity.sponsorChain, budgetUsage: summarizeBudgetUsage(storedIdentity.identity), - subAgents: await listSubAgentTree(storage, storedIdentity.identity.orgId, storedIdentity.identity.id), + subAgents: await listSubAgentTree( + storage, + storedIdentity.identity.orgId, + storedIdentity.identity.id, + ), }, 200, ); - } - const entries = result.entries; - const hasMore = entries.length > parsed.value.limit; - const page = hasMore ? entries.slice(0, parsed.value.limit) : entries; - - return c.json( - { - entries: page, - nextCursor: hasMore ? encodeCursor(page[page.length - 1]) : null, - hasMore, - sponsorChain: storedIdentity.identity.sponsorChain, - budgetUsage: summarizeBudgetUsage(storedIdentity.identity), - subAgents: await listSubAgentTree(storage, storedIdentity.identity.orgId, storedIdentity.identity.id), - }, - 200, - ); -}); + }, +); async function getStoredIdentity( storage: AuthStorage, @@ -135,7 +147,8 @@ async function getStoredIdentity( identity, }; } catch (error) { - const message = error instanceof Error ? error.message : "Failed to fetch identity"; + const message = + error instanceof Error ? error.message : "Failed to fetch identity"; return { ok: false, error: message, @@ -202,38 +215,59 @@ function hydrateSubAgent(row: IdentityTreeRow): HydratedSubAgent | null { }; } -function compareSubAgents(left: HydratedSubAgent, right: HydratedSubAgent): number { +function compareSubAgents( + left: HydratedSubAgent, + right: HydratedSubAgent, +): number { const leftCreatedAt = left.createdAt ?? ""; const rightCreatedAt = right.createdAt ?? ""; - return rightCreatedAt.localeCompare(leftCreatedAt) || right.id.localeCompare(left.id); + return ( + rightCreatedAt.localeCompare(leftCreatedAt) || + right.id.localeCompare(left.id) + ); } -function normalizeIdentityStatus(status: string | undefined): StoredIdentity["status"] { +function normalizeIdentityStatus( + status: string | undefined, +): StoredIdentity["status"] { return status === "suspended" || status === "retired" ? status : "active"; } -function summarizeBudgetUsage(identity: StoredIdentity): IdentityActivityBudgetUsage { +function summarizeBudgetUsage( + identity: StoredIdentity, +): IdentityActivityBudgetUsage { const actionsThisHour = identity.budgetUsage?.actionsThisHour ?? 0; const costToday = identity.budgetUsage?.costToday ?? 0; const percentages: number[] = []; - if (typeof identity.budget?.maxActionsPerHour === "number" && identity.budget.maxActionsPerHour > 0) { - percentages.push((actionsThisHour / identity.budget.maxActionsPerHour) * 100); + if ( + typeof identity.budget?.maxActionsPerHour === "number" && + identity.budget.maxActionsPerHour > 0 + ) { + percentages.push( + (actionsThisHour / identity.budget.maxActionsPerHour) * 100, + ); } - if (typeof identity.budget?.maxCostPerDay === "number" && identity.budget.maxCostPerDay > 0) { + if ( + typeof identity.budget?.maxCostPerDay === "number" && + identity.budget.maxCostPerDay > 0 + ) { percentages.push((costToday / identity.budget.maxCostPerDay) * 100); } return { actionsThisHour, costToday, - percentOfBudget: percentages.length > 0 ? Number(Math.max(...percentages).toFixed(2)) : 0, + percentOfBudget: + percentages.length > 0 ? Number(Math.max(...percentages).toFixed(2)) : 0, }; } -function encodeCursor(row: { timestamp?: string; id?: string } | undefined): string | null { +function encodeCursor( + row: { timestamp?: string; id?: string } | undefined, +): string | null { if (!row?.timestamp || !row.id) { return null; } diff --git a/packages/server/src/routes/tokens.ts b/packages/server/src/routes/tokens.ts index fda0b07..15a1f10 100644 --- a/packages/server/src/routes/tokens.ts +++ b/packages/server/src/routes/tokens.ts @@ -1,18 +1,37 @@ import crypto from "node:crypto"; import type { RelayAuthTokenClaims, TokenPair } from "@relayauth/types"; -import { matchScope, TokenExpiredError, type VerifyOptions } from "@relayauth/sdk"; +import { + matchScope, + TokenExpiredError, + type VerifyOptions, +} from "@relayauth/sdk"; import { Hono } from "hono"; import type { AppEnv } from "../env.js"; -import { extractPrefix, generateApiKey, WORKSPACE_TOKEN_PREFIX } from "../lib/api-keys.js"; +import { + extractPrefix, + generateApiKey, + WORKSPACE_TOKEN_PREFIX, +} from "../lib/api-keys.js"; import { authenticateAndAuthorizeFromContext } from "../lib/auth.js"; -import { scheduleDeferredTask, type DeferredTaskScheduler } from "../lib/deferred.js"; -import { RELAY_AGENT_TOKEN_PREFIX, RELAY_PATH_TOKEN_PREFIX, wrapRelayToken } from "../lib/jwt.js"; +import { + scheduleDeferredTask, + type DeferredTaskScheduler, +} from "../lib/deferred.js"; +import { + RELAY_AGENT_TOKEN_PREFIX, + RELAY_PATH_TOKEN_PREFIX, + wrapRelayToken, +} from "../lib/jwt.js"; import { signToken } from "../lib/sign.js"; import { verifyRs256Token } from "../lib/token-verifier.js"; import type { StoredIdentity } from "../storage/identity-types.js"; import type { StoredApiKey } from "../storage/api-key-types.js"; -import type { AuditLogWriteEntry, AuthStorage, StoredTokenRecord } from "../storage/index.js"; +import type { + AuditLogWriteEntry, + AuthStorage, + StoredTokenRecord, +} from "../storage/index.js"; type IssueTokenRequest = { identityId?: string; @@ -171,7 +190,9 @@ tokens.post("/", async (c) => { } const accessAudience = normalizeAudience(body.audience, accessScopes); const accessExpiresIn = normalizeExpiresIn(body.expiresIn); - const refreshTokenTtlSeconds = normalizeRefreshTokenTtl(body.refreshTokenTtlSeconds); + const refreshTokenTtlSeconds = normalizeRefreshTokenTtl( + body.refreshTokenTtlSeconds, + ); const tokenPair = await issueTokenPair(storage, c.env, identity, { deferTask: c.get("deferTask"), @@ -226,10 +247,13 @@ tokens.post("/workspace", async (c) => { updatedAt: createdAt, }); - return c.json({ - workspaceToken: serializeWorkspaceToken(apiKey), - key, - }, 201); + return c.json( + { + workspaceToken: serializeWorkspaceToken(apiKey), + key, + }, + 201, + ); }); tokens.post("/agent", async (c) => { @@ -245,7 +269,10 @@ tokens.post("/agent", async (c) => { const storage = c.get("storage"); const workspaceToken = await resolveWorkspaceToken(storage, auth.claims); if (!workspaceToken) { - return c.json({ error: "workspace_token_required", code: "workspace_token_required" }, 401); + return c.json( + { error: "workspace_token_required", code: "workspace_token_required" }, + 401, + ); } const body = await parseJsonObjectBody(c.req.raw); @@ -259,7 +286,11 @@ tokens.post("/agent", async (c) => { } const identity = await storage.identities.get(agentId); - if (!identity || identity.orgId !== workspaceToken.orgId || identity.workspaceId !== workspaceToken.workspaceId) { + if ( + !identity || + identity.orgId !== workspaceToken.orgId || + identity.workspaceId !== workspaceToken.workspaceId + ) { return c.json({ error: "identity_not_found" }, 404); } @@ -274,13 +305,18 @@ tokens.post("/agent", async (c) => { } const accessScopes = normalizeScopes(body.scopes, identity.scopes); - if (!scopesWithinGrant(accessScopes, identity.scopes) || !scopesWithinGrant(accessScopes, workspaceToken.scopes)) { + if ( + !scopesWithinGrant(accessScopes, identity.scopes) || + !scopesWithinGrant(accessScopes, workspaceToken.scopes) + ) { return c.json({ error: "insufficient_scope" }, 403); } const accessAudience = normalizeAudience(body.audience, accessScopes); const accessExpiresIn = normalizeAgentExpiresIn(body.expiresIn); - const refreshTokenTtlSeconds = normalizeRefreshTokenTtl(body.refreshTokenTtlSeconds); + const refreshTokenTtlSeconds = normalizeRefreshTokenTtl( + body.refreshTokenTtlSeconds, + ); const tokenPair = await issueTokenPair(storage, c.env, identity, { deferTask: c.get("deferTask"), accessScopes, @@ -300,13 +336,16 @@ tokens.post("/agent", async (c) => { tokenIdPrefix: "relay_ag_", }); - return c.json({ - ...tokenPair, - agentId: identity.id, - workspaceId: identity.workspaceId, - tokenClass: "relay_ag", - issuedViaWorkspaceTokenId: workspaceToken.id, - }, 201); + return c.json( + { + ...tokenPair, + agentId: identity.id, + workspaceId: identity.workspaceId, + tokenClass: "relay_ag", + issuedViaWorkspaceTokenId: workspaceToken.id, + }, + 201, + ); }); tokens.post("/path", async (c) => { @@ -322,7 +361,10 @@ tokens.post("/path", async (c) => { const storage = c.get("storage"); const workspaceToken = await resolveWorkspaceToken(storage, auth.claims); if (!workspaceToken) { - return c.json({ error: "workspace_token_required", code: "workspace_token_required" }, 401); + return c.json( + { error: "workspace_token_required", code: "workspace_token_required" }, + 401, + ); } const body = await parseJsonObjectBody(c.req.raw); @@ -331,8 +373,14 @@ tokens.post("/path", async (c) => { } const requestedWorkspaceId = normalizeOptionalString(body.workspaceId); - if (requestedWorkspaceId && requestedWorkspaceId !== workspaceToken.workspaceId) { - return c.json({ error: "workspace_not_found", code: "workspace_not_found" }, 404); + if ( + requestedWorkspaceId && + requestedWorkspaceId !== workspaceToken.workspaceId + ) { + return c.json( + { error: "workspace_not_found", code: "workspace_not_found" }, + 404, + ); } const paths = normalizePathTokenPaths(body.paths); @@ -342,22 +390,42 @@ tokens.post("/path", async (c) => { const accessScopes = normalizePathTokenScopes(body.scopes, paths.paths); if (!accessScopes.ok) { - return c.json({ error: accessScopes.error, code: accessScopes.code }, accessScopes.status); + return c.json( + { error: accessScopes.error, code: accessScopes.code }, + accessScopes.status, + ); } if (!scopesWithinGrant(accessScopes.scopes, workspaceToken.scopes)) { - return c.json({ error: "insufficient_scope", code: "insufficient_scope" }, 403); + return c.json( + { error: "insufficient_scope", code: "insufficient_scope" }, + 403, + ); } const accessAudience = normalizeAudience(body.audience, accessScopes.scopes); - const accessExpiresIn = normalizeAgentExpiresIn(body.expiresIn ?? body.ttlSeconds); - const refreshTokenTtlSeconds = normalizeRefreshTokenTtl(body.refreshTokenTtlSeconds); - const delegationNotAfter = normalizeDelegationNotAfter(body.delegationNotAfter); + const accessExpiresIn = normalizeAgentExpiresIn( + body.expiresIn ?? body.ttlSeconds, + ); + const refreshTokenTtlSeconds = normalizeRefreshTokenTtl( + body.refreshTokenTtlSeconds, + ); + const delegationNotAfter = normalizeDelegationNotAfter( + body.delegationNotAfter, + ); if (!delegationNotAfter.ok) { - return c.json({ error: delegationNotAfter.error, code: delegationNotAfter.code }, delegationNotAfter.status); + return c.json( + { error: delegationNotAfter.error, code: delegationNotAfter.code }, + delegationNotAfter.status, + ); } - const agentName = normalizeOptionalString(body.agentName) ?? normalizeOptionalString(body.agentId) ?? "cloud-orchestrator"; - const agentId = normalizeAgentIdentifier(normalizeOptionalString(body.agentId) ?? agentName); + const agentName = + normalizeOptionalString(body.agentName) ?? + normalizeOptionalString(body.agentId) ?? + "cloud-orchestrator"; + const agentId = normalizeAgentIdentifier( + normalizeOptionalString(body.agentId) ?? agentName, + ); const identity = createPathTokenIdentity({ agentId, agentName, @@ -383,7 +451,9 @@ tokens.post("/path", async (c) => { paths: JSON.stringify(paths.paths), accessScopes: JSON.stringify(accessScopes.scopes), accessAudience: JSON.stringify(accessAudience), - ...(delegationNotAfter.iso ? { [DELEGATION_NOT_AFTER_META_KEY]: delegationNotAfter.iso } : {}), + ...(delegationNotAfter.iso + ? { [DELEGATION_NOT_AFTER_META_KEY]: delegationNotAfter.iso } + : {}), }, expiresNotAfter: delegationNotAfter.epochSeconds, wrapAccessToken: true, @@ -391,16 +461,21 @@ tokens.post("/path", async (c) => { tokenIdPrefix: RELAY_PATH_TOKEN_PREFIX, }); - return c.json({ - ...tokenPair, - agentId, - agentName, - workspaceId: identity.workspaceId, - tokenClass: "relay_pa", - paths: paths.paths, - ...(delegationNotAfter.iso ? { delegationNotAfter: delegationNotAfter.iso } : {}), - issuedViaWorkspaceTokenId: workspaceToken.id, - }, 201); + return c.json( + { + ...tokenPair, + agentId, + agentName, + workspaceId: identity.workspaceId, + tokenClass: "relay_pa", + paths: paths.paths, + ...(delegationNotAfter.iso + ? { delegationNotAfter: delegationNotAfter.iso } + : {}), + issuedViaWorkspaceTokenId: workspaceToken.id, + }, + 201, + ); }); tokens.post("/workspace-path", async (c) => { @@ -420,7 +495,10 @@ tokens.post("/workspace-path", async (c) => { const workspaceId = normalizeOptionalString(body.workspaceId); if (!workspaceId) { - return c.json({ error: "workspaceId is required", code: "workspaceId_required" }, 400); + return c.json( + { error: "workspaceId is required", code: "workspaceId_required" }, + 400, + ); } const storage = c.get("storage"); @@ -437,22 +515,42 @@ tokens.post("/workspace-path", async (c) => { const accessScopes = normalizePathTokenScopes(body.scopes, paths.paths); if (!accessScopes.ok) { - return c.json({ error: accessScopes.error, code: accessScopes.code }, accessScopes.status); + return c.json( + { error: accessScopes.error, code: accessScopes.code }, + accessScopes.status, + ); } if (!scopesWithinGrant(accessScopes.scopes, auth.claims.scopes)) { - return c.json({ error: "insufficient_scope", code: "insufficient_scope" }, 403); + return c.json( + { error: "insufficient_scope", code: "insufficient_scope" }, + 403, + ); } const accessAudience = normalizeAudience(body.audience, accessScopes.scopes); - const accessExpiresIn = normalizeAgentExpiresIn(body.expiresIn ?? body.ttlSeconds); - const refreshTokenTtlSeconds = normalizeRefreshTokenTtl(body.refreshTokenTtlSeconds); - const delegationNotAfter = normalizeDelegationNotAfter(body.delegationNotAfter); + const accessExpiresIn = normalizeAgentExpiresIn( + body.expiresIn ?? body.ttlSeconds, + ); + const refreshTokenTtlSeconds = normalizeRefreshTokenTtl( + body.refreshTokenTtlSeconds, + ); + const delegationNotAfter = normalizeDelegationNotAfter( + body.delegationNotAfter, + ); if (!delegationNotAfter.ok) { - return c.json({ error: delegationNotAfter.error, code: delegationNotAfter.code }, delegationNotAfter.status); + return c.json( + { error: delegationNotAfter.error, code: delegationNotAfter.code }, + delegationNotAfter.status, + ); } - const agentName = normalizeOptionalString(body.agentName) ?? normalizeOptionalString(body.agentId) ?? "cloud-orchestrator"; - const agentId = normalizeAgentIdentifier(normalizeOptionalString(body.agentId) ?? agentName); + const agentName = + normalizeOptionalString(body.agentName) ?? + normalizeOptionalString(body.agentId) ?? + "cloud-orchestrator"; + const agentId = normalizeAgentIdentifier( + normalizeOptionalString(body.agentId) ?? agentName, + ); const identity = createPathTokenIdentity({ agentId, agentName, @@ -476,7 +574,9 @@ tokens.post("/workspace-path", async (c) => { paths: JSON.stringify(paths.paths), accessScopes: JSON.stringify(accessScopes.scopes), accessAudience: JSON.stringify(accessAudience), - ...(delegationNotAfter.iso ? { [DELEGATION_NOT_AFTER_META_KEY]: delegationNotAfter.iso } : {}), + ...(delegationNotAfter.iso + ? { [DELEGATION_NOT_AFTER_META_KEY]: delegationNotAfter.iso } + : {}), }, expiresNotAfter: delegationNotAfter.epochSeconds, wrapAccessToken: true, @@ -484,15 +584,20 @@ tokens.post("/workspace-path", async (c) => { tokenIdPrefix: RELAY_PATH_TOKEN_PREFIX, }); - return c.json({ - ...tokenPair, - agentId, - agentName, - workspaceId: identity.workspaceId, - tokenClass: "relay_pa", - paths: paths.paths, - ...(delegationNotAfter.iso ? { delegationNotAfter: delegationNotAfter.iso } : {}), - }, 201); + return c.json( + { + ...tokenPair, + agentId, + agentName, + workspaceId: identity.workspaceId, + tokenClass: "relay_pa", + paths: paths.paths, + ...(delegationNotAfter.iso + ? { delegationNotAfter: delegationNotAfter.iso } + : {}), + }, + 201, + ); }); tokens.post("/relayhistory-assertion", async (c) => { @@ -515,7 +620,9 @@ tokens.post("/relayhistory-assertion", async (c) => { ); } - const body = await parseJsonObjectBody(c.req.raw); + const body = await parseJsonObjectBody( + c.req.raw, + ); if (!body) { return c.json({ error: "Invalid JSON body" }, 400); } @@ -551,7 +658,9 @@ tokens.post("/refresh", async (c) => { } const storage = c.get("storage"); - const verification = await verifyToken(refreshToken, c.env, { audience: [REFRESH_AUDIENCE] }); + const verification = await verifyToken(refreshToken, c.env, { + audience: [REFRESH_AUDIENCE], + }); if (!verification.ok) { return c.json({ error: verification.error }, 401); } @@ -570,7 +679,12 @@ tokens.post("/refresh", async (c) => { if (await isTokenRevoked(storage, presentedJti)) { const reuseIdentity = await storage.identities.get(verification.claims.sub); if (reuseIdentity) { - await cascadeRevokeSession(storage, reuseIdentity, presentedSid, presentedJti); + await cascadeRevokeSession( + storage, + reuseIdentity, + presentedSid, + presentedJti, + ); } return c.json({ error: "Refresh token has been revoked" }, 401); } @@ -586,12 +700,21 @@ tokens.post("/refresh", async (c) => { } if (await isWorkspaceTokenRevoked(storage, verification.claims)) { - return c.json({ error: "Workspace token has been revoked", code: "workspace_token_revoked" }, 401); + return c.json( + { + error: "Workspace token has been revoked", + code: "workspace_token_revoked", + }, + 401, + ); } const delegationHorizon = refreshDelegationHorizon(verification.claims); if (!delegationHorizon.ok) { - return c.json({ error: delegationHorizon.error, code: delegationHorizon.code }, delegationHorizon.status); + return c.json( + { error: delegationHorizon.error, code: delegationHorizon.code }, + delegationHorizon.status, + ); } if (identity.sponsorChain.length > MAX_SPONSOR_CHAIN_DEPTH) { @@ -607,13 +730,16 @@ tokens.post("/refresh", async (c) => { const tokenPair = await issueTokenPair(storage, c.env, identity, { deferTask: c.get("deferTask"), accessScopes: isDerivedClaims(verification.claims) - ? parseMetaStringArray(verification.claims.meta?.accessScopes, identity.scopes) + ? parseMetaStringArray( + verification.claims.meta?.accessScopes, + identity.scopes, + ) : normalizeScopes(undefined, identity.scopes), accessAudience: isDerivedClaims(verification.claims) ? parseMetaStringArray( - verification.claims.meta?.accessAudience, - normalizeAudience(undefined, identity.scopes), - ) + verification.claims.meta?.accessAudience, + normalizeAudience(undefined, identity.scopes), + ) : normalizeAudience(undefined, identity.scopes), accessExpiresIn: isDerivedClaims(verification.claims) ? MAX_AGENT_ACCESS_TOKEN_TTL_SECONDS @@ -664,7 +790,10 @@ tokens.post("/revoke", async (c) => { const identityId = normalizeOptionalString(body.identityId); const sessionId = normalizeOptionalString(body.sessionId); if (!tokenId && !identityId && !sessionId) { - return c.json({ error: "tokenId, identityId, or sessionId is required" }, 400); + return c.json( + { error: "tokenId, identityId, or sessionId is required" }, + 400, + ); } const storage = c.get("storage"); @@ -679,7 +808,9 @@ tokens.post("/revoke", async (c) => { } const firstIdentityId = normalizeOptionalString(targetTokens[0]?.identityId); - const identity = firstIdentityId ? await storage.identities.get(firstIdentityId) : null; + const identity = firstIdentityId + ? await storage.identities.get(firstIdentityId) + : null; if (!identity || identity.orgId !== auth.claims.org) { return c.json({ error: "token_not_found" }, 404); } @@ -691,7 +822,8 @@ tokens.post("/revoke", async (c) => { const auditEntry = createTokenAuditEntry({ action: "token.revoked", identity, - tokenId: revocableIds[0] ?? tokenId ?? sessionId ?? identityId ?? identity.id, + tokenId: + revocableIds[0] ?? tokenId ?? sessionId ?? identityId ?? identity.id, actorId: auth.claims.sub, }); await storage.revocations.revokeIdentityTokensWithAudit({ @@ -734,7 +866,10 @@ tokens.get("/introspect", async (c) => { return c.json(null, 200); } - const storedToken = await findStoredTokenById(storage, verification.claims.jti); + const storedToken = await findStoredTokenById( + storage, + verification.claims.jti, + ); if (!storedToken || storedToken.status !== "active") { return c.json(null, 200); } @@ -775,14 +910,25 @@ async function issueTokenPair( ): Promise { const issuedAtSeconds = Math.floor(Date.now() / 1000); const sessionId = options.sessionId ?? createSessionId(); - const refreshTtl = options.refreshTokenTtlSeconds ?? DEFAULT_REFRESH_TOKEN_TTL_SECONDS; - const accessExpiresAt = capExpiry(issuedAtSeconds + options.accessExpiresIn, options.expiresNotAfter); - const refreshExpiresAt = capExpiry(issuedAtSeconds + refreshTtl, options.expiresNotAfter); + const refreshTtl = + options.refreshTokenTtlSeconds ?? DEFAULT_REFRESH_TOKEN_TTL_SECONDS; + const accessExpiresAt = capExpiry( + issuedAtSeconds + options.accessExpiresIn, + options.expiresNotAfter, + ); + const refreshExpiresAt = capExpiry( + issuedAtSeconds + refreshTtl, + options.expiresNotAfter, + ); // Embed refreshTokenTtl in meta so rotation (/tokens/refresh) can reissue // the same TTL without the caller needing to re-supply it each time. - const mergedMeta: Record | undefined = options.refreshTokenTtlSeconds !== undefined - ? { ...(options.meta ?? {}), [REFRESH_TOKEN_TTL_META_KEY]: String(options.refreshTokenTtlSeconds) } - : options.meta; + const mergedMeta: Record | undefined = + options.refreshTokenTtlSeconds !== undefined + ? { + ...(options.meta ?? {}), + [REFRESH_TOKEN_TTL_META_KEY]: String(options.refreshTokenTtlSeconds), + } + : options.meta; const accessClaims: RelayAuthTokenClaims = { sub: identity.id, org: identity.orgId, @@ -825,7 +971,10 @@ async function issueTokenPair( ? wrapRelayToken(signedAccessToken, relayTokenPrefix(options.tokenIdPrefix)) : signedAccessToken; const refreshToken = options.wrapRefreshToken - ? wrapRelayToken(signedRefreshToken, relayTokenPrefix(options.tokenIdPrefix)) + ? wrapRelayToken( + signedRefreshToken, + relayTokenPrefix(options.tokenIdPrefix), + ) : signedRefreshToken; const accessTokenRecord = toIssuedTokenRecord(identity.id, accessClaims); @@ -924,10 +1073,7 @@ async function issueRelayhistoryAssertion( }; } -function toIssuedTokenRecord( - identityId: string, - claims: RelayAuthTokenClaims, -) { +function toIssuedTokenRecord(identityId: string, claims: RelayAuthTokenClaims) { return { id: claims.jti, tokenId: claims.jti, @@ -940,14 +1086,12 @@ function toIssuedTokenRecord( }; } -function createTokenAuditEntry( - options: { - action: "token.issued" | "token.refreshed" | "token.revoked"; - identity: StoredIdentity; - tokenId: string; - actorId?: string; - }, -): AuditLogWriteEntry { +function createTokenAuditEntry(options: { + action: "token.issued" | "token.refreshed" | "token.revoked"; + identity: StoredIdentity; + tokenId: string; + actorId?: string; +}): AuditLogWriteEntry { return { id: crypto.randomUUID(), action: options.action, @@ -965,17 +1109,15 @@ function createTokenAuditEntry( }; } -function createAssertionAuditEntry( - options: { - actorId: string; - actorOrgId: string; - orgId: string; - workspaceId: string; - sponsorId: string; - tokenId: string; - scopes: string[]; - }, -): AuditLogWriteEntry { +function createAssertionAuditEntry(options: { + actorId: string; + actorOrgId: string; + orgId: string; + workspaceId: string; + sponsorId: string; + tokenId: string; + scopes: string[]; +}): AuditLogWriteEntry { return { id: crypto.randomUUID(), action: "token.issued", @@ -995,12 +1137,18 @@ function createAssertionAuditEntry( }; } -async function findTargetTokensByTokenId(storage: AuthStorage, tokenId: string): Promise { +async function findTargetTokensByTokenId( + storage: AuthStorage, + tokenId: string, +): Promise { const row = await findStoredTokenById(storage, tokenId); return row ? [row] : []; } -async function findTargetTokensByIdentityId(storage: AuthStorage, identityId: string): Promise { +async function findTargetTokensByIdentityId( + storage: AuthStorage, + identityId: string, +): Promise { const normalizedIdentityId = normalizeOptionalString(identityId); if (!normalizedIdentityId) { return []; @@ -1009,7 +1157,10 @@ async function findTargetTokensByIdentityId(storage: AuthStorage, identityId: st return storage.tokens.listActiveByIdentityId(normalizedIdentityId); } -async function findTargetTokensBySessionId(storage: AuthStorage, sessionId: string): Promise { +async function findTargetTokensBySessionId( + storage: AuthStorage, + sessionId: string, +): Promise { const normalizedSessionId = normalizeOptionalString(sessionId); if (!normalizedSessionId) { return []; @@ -1018,7 +1169,10 @@ async function findTargetTokensBySessionId(storage: AuthStorage, sessionId: stri return storage.tokens.listActiveBySessionId(normalizedSessionId); } -async function findStoredTokenById(storage: AuthStorage, tokenId: string): Promise { +async function findStoredTokenById( + storage: AuthStorage, + tokenId: string, +): Promise { const normalizedTokenId = normalizeOptionalString(tokenId); if (!normalizedTokenId) { return null; @@ -1027,7 +1181,10 @@ async function findStoredTokenById(storage: AuthStorage, tokenId: string): Promi return storage.tokens.getById(normalizedTokenId); } -async function isTokenRevoked(storage: AuthStorage, jti: string): Promise { +async function isTokenRevoked( + storage: AuthStorage, + jti: string, +): Promise { if (typeof storage.revocations.isRevoked === "function") { return storage.revocations.isRevoked(jti); } @@ -1050,7 +1207,10 @@ async function cascadeRevokeSession( const normalizedSessionId = normalizeOptionalString(sessionId); if (normalizedSessionId) { - const sessionTokens = await findTargetTokensBySessionId(storage, normalizedSessionId); + const sessionTokens = await findTargetTokensBySessionId( + storage, + normalizedSessionId, + ); for (const row of sessionTokens) { const identifier = getTokenIdentifier(row); if (identifier) { @@ -1091,11 +1251,19 @@ async function populateRevocationCache( } try { - await storage.revocations.cacheRevokedTokens(identityId, tokenIds, revokedAt, expiresAt); + await storage.revocations.cacheRevokedTokens( + identityId, + tokenIds, + revokedAt, + expiresAt, + ); } catch (error) { // Revocation and audit are already durable. A cache outage must never // turn a successful revoke into a partial-failure response. - console.error("Failed to populate revocation cache after durable revoke", error); + console.error( + "Failed to populate revocation cache after durable revoke", + error, + ); } } @@ -1104,8 +1272,7 @@ async function verifyToken( env: AppEnv["Bindings"], options: Omit = {}, ): Promise< - | { ok: true; claims: RelayAuthTokenClaims } - | { ok: false; error: string } + { ok: true; claims: RelayAuthTokenClaims } | { ok: false; error: string } > { let claims: RelayAuthTokenClaims; try { @@ -1116,7 +1283,8 @@ async function verifyToken( } catch (error) { return { ok: false, - error: error instanceof TokenExpiredError ? "Token expired" : "Invalid token", + error: + error instanceof TokenExpiredError ? "Token expired" : "Invalid token", }; } @@ -1148,10 +1316,16 @@ async function verifyToken( // Refresh tokens are spec'd single-audience `["relayauth"]`; access tokens // must list `relayauth` in their audience list for this issuer endpoint. - if (!claims.aud.includes(REFRESH_AUDIENCE) && claims.token_type === "refresh") { + if ( + !claims.aud.includes(REFRESH_AUDIENCE) && + claims.token_type === "refresh" + ) { return { ok: false, error: "Invalid token" }; } - if (claims.token_type === "refresh" && (claims.aud.length !== 1 || claims.aud[0] !== REFRESH_AUDIENCE)) { + if ( + claims.token_type === "refresh" && + (claims.aud.length !== 1 || claims.aud[0] !== REFRESH_AUDIENCE) + ) { return { ok: false, error: "Invalid token" }; } @@ -1159,20 +1333,22 @@ async function verifyToken( } function isValidClaims(value: RelayAuthTokenClaims): boolean { - return typeof value.sub === "string" - && typeof value.org === "string" - && typeof value.wks === "string" - && typeof value.sponsorId === "string" - && Array.isArray(value.sponsorChain) - && Array.isArray(value.scopes) - && Array.isArray(value.aud) - && value.aud.length > 0 - && value.aud.every((entry) => typeof entry === "string" && entry.length > 0) - && typeof value.iss === "string" - && typeof value.jti === "string" - && typeof value.iat === "number" - && typeof value.exp === "number" - && (value.token_type === "access" || value.token_type === "refresh"); + return ( + typeof value.sub === "string" && + typeof value.org === "string" && + typeof value.wks === "string" && + typeof value.sponsorId === "string" && + Array.isArray(value.sponsorChain) && + Array.isArray(value.scopes) && + Array.isArray(value.aud) && + value.aud.length > 0 && + value.aud.every((entry) => typeof entry === "string" && entry.length > 0) && + typeof value.iss === "string" && + typeof value.jti === "string" && + typeof value.iat === "number" && + typeof value.exp === "number" && + (value.token_type === "access" || value.token_type === "refresh") + ); } function normalizeScopes(value: unknown, fallback: string[]): string[] { @@ -1196,13 +1372,20 @@ function normalizeAudience(value: unknown, scopes: string[]): string[] { } } - const derived = [...new Set(scopes - .map((scope) => scope.split(":", 1)[0]?.trim()) - .filter((segment): segment is string => Boolean(segment)))]; + const derived = [ + ...new Set( + scopes + .map((scope) => scope.split(":", 1)[0]?.trim()) + .filter((segment): segment is string => Boolean(segment)), + ), + ]; return derived.length > 0 ? derived : ["relayauth"]; } -function scopesWithinGrant(requestedScopes: string[], grantedScopes: string[]): boolean { +function scopesWithinGrant( + requestedScopes: string[], + grantedScopes: string[], +): boolean { return requestedScopes.every((requestedScope) => { if (grantedScopes.includes(requestedScope)) { return true; @@ -1231,15 +1414,17 @@ function normalizeExpiresIn(value: unknown): number { return DEFAULT_ACCESS_TOKEN_TTL_SECONDS; } -function normalizeRelayhistoryAssertionRequest(value: RelayhistoryAssertionRequest): +function normalizeRelayhistoryAssertionRequest( + value: RelayhistoryAssertionRequest, +): | { - ok: true; - orgId: string; - workspaceId: string; - sponsorId: string; - scopes: string[]; - expiresIn: number; - } + ok: true; + orgId: string; + workspaceId: string; + sponsorId: string; + scopes: string[]; + expiresIn: number; + } | { ok: false; error: string; code: string; status: 400 } { const orgId = normalizeOptionalString(value.orgId); if (!orgId) { @@ -1301,7 +1486,9 @@ function normalizeRelayhistoryAssertionRequest(value: RelayhistoryAssertionReque }; } -function normalizeRelayhistoryAssertionScopes(value: unknown): +function normalizeRelayhistoryAssertionScopes( + value: unknown, +): | { ok: true; scopes: string[] } | { ok: false; error: string; code: string; status: 400 } { if (!Array.isArray(value)) { @@ -1326,7 +1513,13 @@ function normalizeRelayhistoryAssertionScopes(value: unknown): } const unique = [...new Set(normalized)]; - if (!unique.every((scope) => RELAYHISTORY_ASSERTION_SCOPES.includes(scope as typeof RELAYHISTORY_ASSERTION_SCOPES[number]))) { + if ( + !unique.every((scope) => + RELAYHISTORY_ASSERTION_SCOPES.includes( + scope as (typeof RELAYHISTORY_ASSERTION_SCOPES)[number], + ), + ) + ) { return { ok: false, error: "scopes may only include rth:read or rth:sync", @@ -1335,10 +1528,17 @@ function normalizeRelayhistoryAssertionScopes(value: unknown): }; } - return { ok: true, scopes: RELAYHISTORY_ASSERTION_SCOPES.filter((scope) => unique.includes(scope)) }; + return { + ok: true, + scopes: RELAYHISTORY_ASSERTION_SCOPES.filter((scope) => + unique.includes(scope), + ), + }; } -function normalizeRelayhistoryAssertionExpiresIn(value: unknown): +function normalizeRelayhistoryAssertionExpiresIn( + value: unknown, +): | { ok: true; expiresIn: number } | { ok: false; error: string; code: string; status: 400 } { if (value === undefined || value === null) { @@ -1352,7 +1552,11 @@ function normalizeRelayhistoryAssertionExpiresIn(value: unknown): ? Number.parseInt(value, 10) : Number.NaN; - if (!Number.isFinite(parsed) || parsed <= 0 || parsed > MAX_RELAYHISTORY_ASSERTION_TTL_SECONDS) { + if ( + !Number.isFinite(parsed) || + parsed <= 0 || + parsed > MAX_RELAYHISTORY_ASSERTION_TTL_SECONDS + ) { return { ok: false, error: `expiresIn must be between 1 and ${MAX_RELAYHISTORY_ASSERTION_TTL_SECONDS} seconds`, @@ -1364,7 +1568,9 @@ function normalizeRelayhistoryAssertionExpiresIn(value: unknown): return { ok: true, expiresIn: parsed }; } -function normalizeDelegationNotAfter(value: unknown): +function normalizeDelegationNotAfter( + value: unknown, +): | { ok: true; iso?: string; epochSeconds?: number } | { ok: false; error: string; code: string; status: 400 } { if (value === undefined || value === null) { @@ -1398,7 +1604,9 @@ function normalizeDelegationNotAfter(value: unknown): }; } -function refreshDelegationHorizon(claims: RelayAuthTokenClaims): +function refreshDelegationHorizon( + claims: RelayAuthTokenClaims, +): | { ok: true; epochSeconds?: number } | { ok: false; error: string; code: string; status: 401 } { const raw = claims.meta?.[DELEGATION_NOT_AFTER_META_KEY]; @@ -1431,7 +1639,8 @@ function refreshDelegationHorizon(claims: RelayAuthTokenClaims): function parseEpochSeconds(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) { - const normalized = value > 9_999_999_999 ? Math.floor(value / 1000) : Math.floor(value); + const normalized = + value > 9_999_999_999 ? Math.floor(value / 1000) : Math.floor(value); return normalized > 0 ? normalized : null; } @@ -1465,7 +1674,10 @@ function capExpiry(expiresAt: number, notAfter: number | undefined): number { } function normalizeAgentExpiresIn(value: unknown): number { - return Math.min(normalizeExpiresIn(value), MAX_AGENT_ACCESS_TOKEN_TTL_SECONDS); + return Math.min( + normalizeExpiresIn(value), + MAX_AGENT_ACCESS_TOKEN_TTL_SECONDS, + ); } function normalizeRefreshTokenTtl(value: unknown): number | undefined { @@ -1473,11 +1685,12 @@ function normalizeRefreshTokenTtl(value: unknown): number | undefined { return undefined; } - const parsed = typeof value === "number" - ? value - : typeof value === "string" - ? Number.parseInt(value, 10) - : NaN; + const parsed = + typeof value === "number" + ? value + : typeof value === "string" + ? Number.parseInt(value, 10) + : NaN; if (!Number.isFinite(parsed) || parsed <= 0) { return undefined; @@ -1486,7 +1699,9 @@ function normalizeRefreshTokenTtl(value: unknown): number | undefined { return Math.min(Math.floor(parsed), MAX_OPERATOR_REFRESH_TOKEN_TTL_SECONDS); } -function parseMetaRefreshTokenTtl(meta: Record | undefined): number | undefined { +function parseMetaRefreshTokenTtl( + meta: Record | undefined, +): number | undefined { const raw = meta?.[REFRESH_TOKEN_TTL_META_KEY]; if (!raw) { return undefined; @@ -1498,7 +1713,10 @@ function parseMetaRefreshTokenTtl(meta: Record | undefined): num : undefined; } -function parseMetaStringArray(value: string | undefined, fallback: string[]): string[] { +function parseMetaStringArray( + value: string | undefined, + fallback: string[], +): string[] { if (!value) { return [...fallback]; } @@ -1518,8 +1736,13 @@ function parseMetaStringArray(value: string | undefined, fallback: string[]): st } } -async function parseJsonObjectBody(request: Request): Promise { - const raw = await request.clone().text().catch(() => ""); +async function parseJsonObjectBody( + request: Request, +): Promise { + const raw = await request + .clone() + .text() + .catch(() => ""); if (!raw.trim()) { return {} as T; } @@ -1546,9 +1769,11 @@ function normalizeOptionalString(value: unknown): string | undefined { } function getTokenIdentifier(row: StoredTokenRecord): string | undefined { - return normalizeOptionalString(row.id) - ?? normalizeOptionalString(row.jti) - ?? normalizeOptionalString(row.tokenId); + return ( + normalizeOptionalString(row.id) ?? + normalizeOptionalString(row.jti) ?? + normalizeOptionalString(row.tokenId) + ); } function createTokenId(prefix = "tok_"): string { @@ -1559,7 +1784,9 @@ function createSessionId(): string { return `sess_${crypto.randomUUID().replace(/-/g, "")}`; } -function serializeWorkspaceToken(apiKey: StoredApiKey): WorkspaceTokenResponse["workspaceToken"] { +function serializeWorkspaceToken( + apiKey: StoredApiKey, +): WorkspaceTokenResponse["workspaceToken"] { return { id: apiKey.id, kind: "workspace_token", @@ -1595,9 +1822,10 @@ function createPathTokenIdentity(options: { scopes: string[]; }): StoredIdentity { const now = new Date().toISOString(); - const sponsorChain = options.sponsorChain.length > 0 - ? [...options.sponsorChain, options.agentId] - : [options.sponsorId, options.agentId]; + const sponsorChain = + options.sponsorChain.length > 0 + ? [...options.sponsorChain, options.agentId] + : [options.sponsorId, options.agentId]; return { id: options.agentId, @@ -1636,25 +1864,42 @@ async function resolveRefreshIdentity( }); } -function normalizePathTokenPaths(value: unknown): +function normalizePathTokenPaths( + value: unknown, +): | { ok: true; paths: string[] } | { ok: false; error: string; code: string; status: 400 } { if (!Array.isArray(value)) { - return { ok: false, error: "paths is required", code: "invalid_paths", status: 400 }; + return { + ok: false, + error: "paths is required", + code: "invalid_paths", + status: 400, + }; } const paths: string[] = []; for (const entry of value) { const normalized = normalizePathTokenPath(entry); if (!normalized) { - return { ok: false, error: "paths must contain valid relayfile paths", code: "invalid_paths", status: 400 }; + return { + ok: false, + error: "paths must contain valid relayfile paths", + code: "invalid_paths", + status: 400, + }; } paths.push(normalized); } const unique = [...new Set(paths)]; if (unique.length === 0) { - return { ok: false, error: "paths is required", code: "invalid_paths", status: 400 }; + return { + ok: false, + error: "paths is required", + code: "invalid_paths", + status: 400, + }; } return { ok: true, paths: unique }; @@ -1666,7 +1911,13 @@ function normalizePathTokenPath(value: unknown): string | null { } const trimmed = value.trim(); - if (!trimmed || trimmed === "*" || trimmed === "/" || trimmed === "/*" || trimmed === "/**") { + if ( + !trimmed || + trimmed === "*" || + trimmed === "/" || + trimmed === "/*" || + trimmed === "/**" + ) { return null; } @@ -1690,7 +1941,10 @@ function normalizePathTokenPath(value: unknown): string | null { } const starIndex = normalized.indexOf("*"); - if (starIndex !== -1 && (starIndex !== normalized.length - 1 || !normalized.endsWith("/*"))) { + if ( + starIndex !== -1 && + (starIndex !== normalized.length - 1 || !normalized.endsWith("/*")) + ) { return null; } @@ -1700,23 +1954,39 @@ function normalizePathTokenPath(value: unknown): string | null { function normalizePathTokenScopes( value: unknown, paths: string[], -): { ok: true; scopes: string[] } | { ok: false; error: string; code: string; status: 400 } { +): + | { ok: true; scopes: string[] } + | { ok: false; error: string; code: string; status: 400 } { const rawScopes = Array.isArray(value) ? value - : paths.flatMap((path) => [`relayfile:fs:read:${path}`, `relayfile:fs:write:${path}`]); + : paths.flatMap((path) => [ + `relayfile:fs:read:${path}`, + `relayfile:fs:write:${path}`, + ]); const scopes: string[] = []; for (const entry of rawScopes) { const scope = normalizePathTokenScope(entry); if (!scope || !scopeWithinPaths(scope, paths)) { - return { ok: false, error: "scopes must be relayfile path scopes within the requested paths", code: "invalid_scope", status: 400 }; + return { + ok: false, + error: + "scopes must be relayfile path scopes within the requested paths", + code: "invalid_scope", + status: 400, + }; } scopes.push(scope); } const unique = [...new Set(scopes)]; if (unique.length === 0) { - return { ok: false, error: "scopes is required", code: "invalid_scope", status: 400 }; + return { + ok: false, + error: "scopes is required", + code: "invalid_scope", + status: 400, + }; } return { ok: true, scopes: unique }; @@ -1752,17 +2022,25 @@ function scopeWithinPaths(scope: string, paths: string[]): boolean { return true; } - return paths.every((path) => matchScope(`relayfile:fs:${action}:${path}`, [scope])); + return paths.every((path) => + matchScope(`relayfile:fs:${action}:${path}`, [scope]), + ); } catch { return false; } } -function relayTokenPrefix(prefix: string | undefined): typeof RELAY_AGENT_TOKEN_PREFIX | typeof RELAY_PATH_TOKEN_PREFIX { - return prefix === RELAY_PATH_TOKEN_PREFIX ? RELAY_PATH_TOKEN_PREFIX : RELAY_AGENT_TOKEN_PREFIX; +function relayTokenPrefix( + prefix: string | undefined, +): typeof RELAY_AGENT_TOKEN_PREFIX | typeof RELAY_PATH_TOKEN_PREFIX { + return prefix === RELAY_PATH_TOKEN_PREFIX + ? RELAY_PATH_TOKEN_PREFIX + : RELAY_AGENT_TOKEN_PREFIX; } -function tokenPrefixForClaims(claims: RelayAuthTokenClaims): string | undefined { +function tokenPrefixForClaims( + claims: RelayAuthTokenClaims, +): string | undefined { if (isPathClaims(claims)) { return RELAY_PATH_TOKEN_PREFIX; } @@ -1780,7 +2058,11 @@ async function resolveWorkspaceToken( } const apiKey = await storage.apiKeys.get(apiKeyId); - if (!apiKey || apiKey.kind !== "workspace_token" || normalizeOptionalString(apiKey.revokedAt ?? undefined)) { + if ( + !apiKey || + apiKey.kind !== "workspace_token" || + normalizeOptionalString(apiKey.revokedAt ?? undefined) + ) { return null; } @@ -1791,17 +2073,23 @@ async function isWorkspaceTokenRevoked( storage: AuthStorage, claims: RelayAuthTokenClaims, ): Promise { - const workspaceTokenId = normalizeOptionalString(claims.meta?.workspaceTokenId); + const workspaceTokenId = normalizeOptionalString( + claims.meta?.workspaceTokenId, + ); if (!workspaceTokenId) { return false; } const workspaceToken = await storage.apiKeys.get(workspaceTokenId); - const expectedWorkspaceId = normalizeOptionalString(workspaceToken?.workspaceId); - return !workspaceToken - || workspaceToken.kind !== "workspace_token" - || Boolean(normalizeOptionalString(workspaceToken.revokedAt ?? undefined)) - || Boolean(expectedWorkspaceId && expectedWorkspaceId !== claims.wks); + const expectedWorkspaceId = normalizeOptionalString( + workspaceToken?.workspaceId, + ); + return ( + !workspaceToken || + workspaceToken.kind !== "workspace_token" || + Boolean(normalizeOptionalString(workspaceToken.revokedAt ?? undefined)) || + Boolean(expectedWorkspaceId && expectedWorkspaceId !== claims.wks) + ); } function isAgentClaims(claims: RelayAuthTokenClaims): boolean { diff --git a/packages/server/src/storage/compat.ts b/packages/server/src/storage/compat.ts index da6ea10..6a1b8ea 100644 --- a/packages/server/src/storage/compat.ts +++ b/packages/server/src/storage/compat.ts @@ -15,7 +15,10 @@ import type { AuditEntryRecord } from "./interface.js"; import type { StoredIdentity } from "./identity-types.js"; type RoleStorageSource = RoleStorage | Pick | unknown; -type PolicyStorageSource = PolicyStorage | Pick | unknown; +type PolicyStorageSource = + | PolicyStorage + | Pick + | unknown; type AuditStorageSource = AuditStorage | Pick | unknown; type AuthStorageSource = AuthStorage | unknown; @@ -27,7 +30,9 @@ type D1PreparedStatementLike = { type D1DatabaseLike = { prepare(query: string): D1PreparedStatementLike; - batch?(statements: Array>): Promise; + batch?( + statements: Array>, + ): Promise; }; const INSERT_AUDIT_LOG_SQL = ` @@ -59,7 +64,9 @@ export function resolveRoleStorage(source: RoleStorageSource): RoleStorage { return source as RoleStorage; } -export function resolvePolicyStorage(source: PolicyStorageSource): PolicyStorage { +export function resolvePolicyStorage( + source: PolicyStorageSource, +): PolicyStorage { if (typeof source === "object" && source !== null && "policies" in source) { return (source as Pick).policies; } @@ -82,31 +89,36 @@ export function resolveAuditStorage(source: AuditStorageSource): AuditStorage { export function resolveContextStorage(c: Context): AuthStorage { const storage = c.get("storage"); if (!storage) { - throw new Error("storage not set in context — ensure createApp() receives a storage adapter"); + throw new Error( + "storage not set in context — ensure createApp() receives a storage adapter", + ); } return storage; } function isAuditStorage(source: unknown): source is AuditStorage { return ( - typeof source === "object" - && source !== null - && typeof (source as Partial).write === "function" + typeof source === "object" && + source !== null && + typeof (source as Partial).write === "function" ); } function isD1DatabaseLike(source: unknown): source is D1DatabaseLike { return ( - typeof source === "object" - && source !== null - && typeof (source as Partial).prepare === "function" + typeof source === "object" && + source !== null && + typeof (source as Partial).prepare === "function" ); } function createD1AuditStorage(db: D1DatabaseLike): AuditStorage { return { async write(entry: AuditLogWriteEntry): Promise { - await db.prepare(INSERT_AUDIT_LOG_SQL).bind(...toAuditParams(entry)).run(); + await db + .prepare(INSERT_AUDIT_LOG_SQL) + .bind(...toAuditParams(entry)) + .run(); }, async writeBatch(entries: AuditLogWriteEntry[]): Promise { @@ -132,11 +144,19 @@ function createD1AuditStorage(db: D1DatabaseLike): AuditStorage { }, async getActionCounts(_orgId: string, _query: DashboardAuditQuery) { - throw new Error("D1 audit storage adapter does not support getActionCounts()"); + throw new Error( + "D1 audit storage adapter does not support getActionCounts()", + ); }, - async writeIdentitySuspendedEvent(_identity: StoredIdentity, _reason: string, _actorId: string): Promise { - throw new Error("D1 audit storage adapter does not support writeIdentitySuspendedEvent()"); + async writeIdentitySuspendedEvent( + _identity: StoredIdentity, + _reason: string, + _actorId: string, + ): Promise { + throw new Error( + "D1 audit storage adapter does not support writeIdentitySuspendedEvent()", + ); }, }; } diff --git a/packages/server/src/storage/interface.ts b/packages/server/src/storage/interface.ts index 7cc3578..4da4237 100644 --- a/packages/server/src/storage/interface.ts +++ b/packages/server/src/storage/interface.ts @@ -163,9 +163,10 @@ export function createAuditQueryContinuationFilterKey( | "cursor" >, ): string { - const entryCursor = query.cursor?.kind === "archive_partition" - ? query.cursor.entryCursor - : query.cursor; + const entryCursor = + query.cursor?.kind === "archive_partition" + ? query.cursor.entryCursor + : query.cursor; return JSON.stringify({ version: 1, resource: "audit", @@ -326,7 +327,10 @@ export type RevokedTokenAudit = { }; export interface IdentityStorage { - list(orgId: string, options?: ListIdentitiesOptions): Promise; + list( + orgId: string, + options?: ListIdentitiesOptions, + ): Promise; get(id: string): Promise; create(identity: StoredIdentity): Promise; update(id: string, patch: Partial): Promise; @@ -334,10 +338,16 @@ export interface IdentityStorage { suspend(id: string, reason: string): Promise; retire(id: string, reason?: string): Promise; reactivate(id: string): Promise; - findDuplicate(orgId: string, name: string): Promise; + findDuplicate( + orgId: string, + name: string, + ): Promise; loadOrgBudget(orgId: string): Promise; listChildIds(orgId: string, sponsorId: string): Promise; - listChildren(orgId: string, sponsorId: string): Promise; + listChildren( + orgId: string, + sponsorId: string, + ): Promise; getStatusCounts(orgId: string): Promise; } @@ -353,7 +363,11 @@ export interface TokenStorage { } export interface RevocationStorage { - revokeIdentityTokens(identityId: string, tokenIds: string[], revokedAt: string): Promise; + revokeIdentityTokens( + identityId: string, + tokenIds: string[], + revokedAt: string, + ): Promise; revokeIdentityTokensWithAudit(input: RevokedTokenAudit): Promise; /** * Optionally populate a low-latency cache after the durable transaction @@ -389,9 +403,19 @@ export interface PolicyStorage { export interface AuditStorage { write(entry: AuditLogWriteEntry): Promise; writeBatch(entries: AuditLogWriteEntry[]): Promise; - query(query: AuditQueryInput, options?: AuditQueryOptions): Promise; - getActionCounts(orgId: string, query: DashboardAuditQuery): Promise; - writeIdentitySuspendedEvent(identity: StoredIdentity, reason: string, actorId: string): Promise; + query( + query: AuditQueryInput, + options?: AuditQueryOptions, + ): Promise; + getActionCounts( + orgId: string, + query: DashboardAuditQuery, + ): Promise; + writeIdentitySuspendedEvent( + identity: StoredIdentity, + reason: string, + actorId: string, + ): Promise; } export interface AuditWebhookStorage { diff --git a/packages/server/src/storage/sqlite.ts b/packages/server/src/storage/sqlite.ts index 53e7658..81f512f 100644 --- a/packages/server/src/storage/sqlite.ts +++ b/packages/server/src/storage/sqlite.ts @@ -111,7 +111,9 @@ const MIGRATIONS_DIR = pathResolve( async function runBootstrapMigrations(db: { exec(sql: string): unknown; - prepare = Record>(sql: string): { + prepare = Record>( + sql: string, + ): { run(...params: unknown[]): unknown; get(...params: unknown[]): Row | undefined; all(...params: unknown[]): Row[]; @@ -642,7 +644,10 @@ type ChildIdentityRow = { sponsor_id?: string | null; created_at?: string | null; }; -type StatusCountRow = { status?: string | null; count?: number | string | null }; +type StatusCountRow = { + status?: string | null; + count?: number | string | null; +}; type ActiveTokenRow = { id?: string; jti?: string; token_id?: string }; type TokenRow = { id?: string | null; @@ -788,7 +793,10 @@ type BackendContext = | { kind: "sqlite"; db: SqliteDatabase } | { kind: "memory"; state: MemoryState }; -const dynamicImport = Function("specifier", "return import(specifier)") as DynamicImportFunction; +const dynamicImport = Function( + "specifier", + "return import(specifier)", +) as DynamicImportFunction; const MAX_REVOCATION_EXPIRY = 253402300799; export function createSqliteStorage(dbPath?: string): SqliteStorage { @@ -815,11 +823,16 @@ export function createSqliteStorage(dbPath?: string): SqliteStorage { const backend = await provider.getBackend(); if (backend.kind !== "sqlite") return { results: [] as T[] }; const stmt = backend.db.prepare(sql); - return { results: (params.length ? stmt.all(...params) : stmt.all()) as T[] }; + return { + results: (params.length + ? stmt.all(...params) + : stmt.all()) as T[], + }; }, async run() { const backend = await provider.getBackend(); - if (backend.kind !== "sqlite") return { success: true, meta: { changes: 0 } }; + if (backend.kind !== "sqlite") + return { success: true, meta: { changes: 0 } }; const stmt = backend.db.prepare(sql); const result = params.length ? stmt.run(...params) : stmt.run(); return { success: true, meta: { changes: result.changes ?? 0 } }; @@ -828,7 +841,9 @@ export function createSqliteStorage(dbPath?: string): SqliteStorage { const backend = await provider.getBackend(); if (backend.kind !== "sqlite") return null as T | null; const stmt = backend.db.prepare(sql); - return (params.length ? stmt.get(...params) : stmt.get()) as T | null; + return ( + params.length ? stmt.get(...params) : stmt.get() + ) as T | null; }, }; } @@ -851,7 +866,10 @@ export function createSqliteStorage(dbPath?: string): SqliteStorage { revocations: Object.assign(revocations, { revoke: async (jti: string, expiresAt: number) => { const normalizedJti = requireString(jti, "jti is required"); - const normalizedExpiresAt = requireUnixTimestamp(expiresAt, "expiresAt is required"); + const normalizedExpiresAt = requireUnixTimestamp( + expiresAt, + "expiresAt is required", + ); const backend = await provider.getBackend(); pruneExpiredRevocations(backend); @@ -866,17 +884,27 @@ export function createSqliteStorage(dbPath?: string): SqliteStorage { } if (backend.kind === "memory") { - backend.state.revokedTokens.set(normalizedJti, { expiresAt: normalizedExpiresAt }); + backend.state.revokedTokens.set(normalizedJti, { + expiresAt: normalizedExpiresAt, + }); for (const token of backend.state.tokens.values()) { - if (token.id === normalizedJti || token.jti === normalizedJti || token.tokenId === normalizedJti) { + if ( + token.id === normalizedJti || + token.jti === normalizedJti || + token.tokenId === normalizedJti + ) { token.status = "revoked"; } } return; } - backend.db.prepare(UPSERT_REVOKED_TOKEN_SQL).run(normalizedJti, normalizedExpiresAt); - backend.db.prepare(UPDATE_TOKEN_STATUS_BY_TOKEN_SQL).run(normalizedJti, normalizedJti, normalizedJti); + backend.db + .prepare(UPSERT_REVOKED_TOKEN_SQL) + .run(normalizedJti, normalizedExpiresAt); + backend.db + .prepare(UPDATE_TOKEN_STATUS_BY_TOKEN_SQL) + .run(normalizedJti, normalizedJti, normalizedJti); }, }), DB, @@ -939,7 +967,10 @@ class BackendProvider { class SqliteIdentityStorage implements IdentityStorage { constructor(private readonly provider: BackendProvider) {} - async list(orgId: string, options: ListIdentitiesOptions = {}): Promise { + async list( + orgId: string, + options: ListIdentitiesOptions = {}, + ): Promise { const normalizedOrgId = requireString(orgId, "orgId is required"); const limit = normalizeLimit(options.limit); const cursorId = normalizeOptionalString(options.cursorId); @@ -948,7 +979,9 @@ class SqliteIdentityStorage implements IdentityStorage { if (backend.kind === "memory") { return [...backend.state.identities.values()] .filter((identity) => identity.orgId === normalizedOrgId) - .filter((identity) => !options.status || identity.status === options.status) + .filter( + (identity) => !options.status || identity.status === options.status, + ) .filter((identity) => !options.type || identity.type === options.type) .sort(compareIdentityDesc) .filter((identity) => { @@ -956,19 +989,28 @@ class SqliteIdentityStorage implements IdentityStorage { return true; } - return compareIdentityCursor(identity, backend.state.identities.get(cursorId) ?? null) < 0; + return ( + compareIdentityCursor( + identity, + backend.state.identities.get(cursorId) ?? null, + ) < 0 + ); }) .slice(0, limit) .map((identity) => toAgentIdentity(identity)); } - const rows = backend.db.prepare(LIST_IDENTITIES_SQL).all(normalizedOrgId); + const rows = backend.db + .prepare(LIST_IDENTITIES_SQL) + .all(normalizedOrgId); const cursorIdentity = cursorId ? await this.get(cursorId) : null; return rows .map((row) => parseStoredIdentity(row.data)) .filter((identity) => identity.orgId === normalizedOrgId) - .filter((identity) => !options.status || identity.status === options.status) + .filter( + (identity) => !options.status || identity.status === options.status, + ) .filter((identity) => !options.type || identity.type === options.type) .sort(compareIdentityDesc) .filter((identity) => compareIdentityCursor(identity, cursorIdentity) < 0) @@ -988,22 +1030,35 @@ class SqliteIdentityStorage implements IdentityStorage { return identity ? cloneStoredIdentity(identity) : null; } - const row = backend.db.prepare(SELECT_STORED_IDENTITY_SQL).get(identityId); + const row = backend.db + .prepare(SELECT_STORED_IDENTITY_SQL) + .get(identityId); return row ? parseStoredIdentity(row.data) : null; } async create(identity: StoredIdentity): Promise { const normalized = normalizeStoredIdentity(identity, { generateId: true }); - const budgetResult = applyBudgetPolicy(normalized, normalized, normalizeTimestamp(normalized.updatedAt)); + const budgetResult = applyBudgetPolicy( + normalized, + normalized, + normalizeTimestamp(normalized.updatedAt), + ); const finalIdentity = budgetResult.identity; const backend = await this.provider.getBackend(); if (backend.kind === "memory") { if (backend.state.identities.has(finalIdentity.id)) { - throw new StorageError("identity_already_exists", 409, "identity_already_exists"); + throw new StorageError( + "identity_already_exists", + 409, + "identity_already_exists", + ); } - backend.state.identities.set(finalIdentity.id, cloneStoredIdentity(finalIdentity)); + backend.state.identities.set( + finalIdentity.id, + cloneStoredIdentity(finalIdentity), + ); if (budgetResult.shouldWriteAuditEvent) { emitBudgetAlert(finalIdentity); } @@ -1011,10 +1066,16 @@ class SqliteIdentityStorage implements IdentityStorage { } if (await this.get(finalIdentity.id)) { - throw new StorageError("identity_already_exists", 409, "identity_already_exists"); + throw new StorageError( + "identity_already_exists", + 409, + "identity_already_exists", + ); } - backend.db.prepare(INSERT_IDENTITY_SQL).run(...toIdentityParams(finalIdentity)); + backend.db + .prepare(INSERT_IDENTITY_SQL) + .run(...toIdentityParams(finalIdentity)); if (budgetResult.shouldWriteAuditEvent) { await this.writeBudgetAuditEvent(backend, finalIdentity); emitBudgetAlert(finalIdentity); @@ -1022,7 +1083,10 @@ class SqliteIdentityStorage implements IdentityStorage { return this.getRequired(finalIdentity.id); } - async update(id: string, patch: Partial): Promise { + async update( + id: string, + patch: Partial, + ): Promise { const current = await this.getRequired(id); const timestamp = nowIso(); const merged = mergeStoredIdentity(current, patch, timestamp); @@ -1031,14 +1095,19 @@ class SqliteIdentityStorage implements IdentityStorage { const backend = await this.provider.getBackend(); if (backend.kind === "memory") { - backend.state.identities.set(finalIdentity.id, cloneStoredIdentity(finalIdentity)); + backend.state.identities.set( + finalIdentity.id, + cloneStoredIdentity(finalIdentity), + ); if (budgetResult.shouldWriteAuditEvent) { emitBudgetAlert(finalIdentity); } return cloneStoredIdentity(finalIdentity); } - backend.db.prepare(UPDATE_IDENTITY_SQL).run(...toIdentityUpdateParams(finalIdentity), finalIdentity.id); + backend.db + .prepare(UPDATE_IDENTITY_SQL) + .run(...toIdentityUpdateParams(finalIdentity), finalIdentity.id); if (budgetResult.shouldWriteAuditEvent) { await this.writeBudgetAuditEvent(backend, finalIdentity); emitBudgetAlert(finalIdentity); @@ -1061,10 +1130,18 @@ class SqliteIdentityStorage implements IdentityStorage { async suspend(id: string, reason: string): Promise { const current = await this.getRequired(id); if (current.status === "suspended") { - throw new StorageError("Identity is already suspended", 409, "identity_conflict"); + throw new StorageError( + "Identity is already suspended", + 409, + "identity_conflict", + ); } if (current.status === "retired") { - throw new StorageError("Retired identities cannot be suspended", 409, "identity_conflict"); + throw new StorageError( + "Retired identities cannot be suspended", + 409, + "identity_conflict", + ); } const timestamp = nowIso(); @@ -1078,18 +1155,27 @@ class SqliteIdentityStorage implements IdentityStorage { const backend = await this.provider.getBackend(); if (backend.kind === "memory") { - backend.state.identities.set(suspended.id, cloneStoredIdentity(suspended)); + backend.state.identities.set( + suspended.id, + cloneStoredIdentity(suspended), + ); return cloneStoredIdentity(suspended); } - backend.db.prepare(UPDATE_IDENTITY_SQL).run(...toIdentityUpdateParams(suspended), suspended.id); + backend.db + .prepare(UPDATE_IDENTITY_SQL) + .run(...toIdentityUpdateParams(suspended), suspended.id); return this.getRequired(suspended.id); } async retire(id: string, _reason?: string): Promise { const current = await this.getRequired(id); if (current.status === "retired") { - throw new StorageError("Identity is already retired", 409, "identity_conflict"); + throw new StorageError( + "Identity is already retired", + 409, + "identity_conflict", + ); } const timestamp = nowIso(); const retired = normalizeStoredIdentity({ @@ -1106,17 +1192,27 @@ class SqliteIdentityStorage implements IdentityStorage { return cloneStoredIdentity(retired); } - backend.db.prepare(UPDATE_IDENTITY_SQL).run(...toIdentityUpdateParams(retired), retired.id); + backend.db + .prepare(UPDATE_IDENTITY_SQL) + .run(...toIdentityUpdateParams(retired), retired.id); return this.getRequired(retired.id); } async reactivate(id: string): Promise { const current = await this.getRequired(id); if (current.status === "active") { - throw new StorageError("Identity is already active", 409, "identity_conflict"); + throw new StorageError( + "Identity is already active", + 409, + "identity_conflict", + ); } if (current.status === "retired") { - throw new StorageError("Retired identities cannot be reactivated", 409, "identity_conflict"); + throw new StorageError( + "Retired identities cannot be reactivated", + 409, + "identity_conflict", + ); } const timestamp = nowIso(); @@ -1130,29 +1226,46 @@ class SqliteIdentityStorage implements IdentityStorage { const backend = await this.provider.getBackend(); if (backend.kind === "memory") { - backend.state.identities.set(reactivated.id, cloneStoredIdentity(reactivated)); + backend.state.identities.set( + reactivated.id, + cloneStoredIdentity(reactivated), + ); return cloneStoredIdentity(reactivated); } - backend.db.prepare(UPDATE_IDENTITY_SQL).run(...toIdentityUpdateParams(reactivated), reactivated.id); + backend.db + .prepare(UPDATE_IDENTITY_SQL) + .run(...toIdentityUpdateParams(reactivated), reactivated.id); return this.getRequired(reactivated.id); } - async findDuplicate(orgId: string, name: string): Promise { + async findDuplicate( + orgId: string, + name: string, + ): Promise { const normalizedOrgId = requireString(orgId, "orgId is required"); const normalizedName = requireString(name, "name is required"); const backend = await this.provider.getBackend(); if (backend.kind === "memory") { for (const identity of backend.state.identities.values()) { - if (identity.orgId === normalizedOrgId && identity.name === normalizedName) { - return { id: identity.id, name: identity.name, orgId: identity.orgId }; + if ( + identity.orgId === normalizedOrgId && + identity.name === normalizedName + ) { + return { + id: identity.id, + name: identity.name, + orgId: identity.orgId, + }; } } return null; } - const row = backend.db.prepare(FIND_DUPLICATE_IDENTITY_SQL).get(normalizedOrgId, normalizedName); + const row = backend.db + .prepare(FIND_DUPLICATE_IDENTITY_SQL) + .get(normalizedOrgId, normalizedName); const id = normalizeOptionalString(row?.id); const duplicateOrgId = normalizeOptionalString(row?.org_id); return id && duplicateOrgId @@ -1168,7 +1281,9 @@ class SqliteIdentityStorage implements IdentityStorage { return cloneOptionalJson(backend.state.orgBudgets.get(normalizedOrgId)); } - const row = backend.db.prepare(SELECT_ORG_BUDGET_SQL).get(normalizedOrgId); + const row = backend.db + .prepare(SELECT_ORG_BUDGET_SQL) + .get(normalizedOrgId); if (!row) { return undefined; } @@ -1184,12 +1299,19 @@ class SqliteIdentityStorage implements IdentityStorage { async listChildIds(orgId: string, sponsorId: string): Promise { const normalizedOrgId = requireString(orgId, "orgId is required"); - const normalizedSponsorId = requireString(sponsorId, "sponsorId is required"); + const normalizedSponsorId = requireString( + sponsorId, + "sponsorId is required", + ); const backend = await this.provider.getBackend(); if (backend.kind === "memory") { return [...backend.state.identities.values()] - .filter((identity) => identity.orgId === normalizedOrgId && identity.sponsorId === normalizedSponsorId) + .filter( + (identity) => + identity.orgId === normalizedOrgId && + identity.sponsorId === normalizedSponsorId, + ) .sort(compareIdentityDesc) .map((identity) => identity.id); } @@ -1201,14 +1323,24 @@ class SqliteIdentityStorage implements IdentityStorage { .filter((id): id is string => Boolean(id)); } - async listChildren(orgId: string, sponsorId: string): Promise { + async listChildren( + orgId: string, + sponsorId: string, + ): Promise { const normalizedOrgId = requireString(orgId, "orgId is required"); - const normalizedSponsorId = requireString(sponsorId, "sponsorId is required"); + const normalizedSponsorId = requireString( + sponsorId, + "sponsorId is required", + ); const backend = await this.provider.getBackend(); if (backend.kind === "memory") { return [...backend.state.identities.values()] - .filter((identity) => identity.orgId === normalizedOrgId && identity.sponsorId === normalizedSponsorId) + .filter( + (identity) => + identity.orgId === normalizedOrgId && + identity.sponsorId === normalizedSponsorId, + ) .sort(compareIdentityDesc) .map((identity) => ({ id: identity.id, @@ -1249,7 +1381,9 @@ class SqliteIdentityStorage implements IdentityStorage { return { activeIdentities, suspendedIdentities }; } - const rows = backend.db.prepare(STATUS_COUNTS_SQL).all(normalizedOrgId); + const rows = backend.db + .prepare(STATUS_COUNTS_SQL) + .all(normalizedOrgId); return summarizeIdentityCounts(rows); } @@ -1262,7 +1396,10 @@ class SqliteIdentityStorage implements IdentityStorage { return identity; } - private async writeBudgetAuditEvent(backend: Extract, identity: StoredIdentity): Promise { + private async writeBudgetAuditEvent( + backend: Extract, + identity: StoredIdentity, + ): Promise { const payload = JSON.stringify({ eventType: "budget.exceeded", status: identity.status, @@ -1273,16 +1410,18 @@ class SqliteIdentityStorage implements IdentityStorage { }); try { - backend.db.prepare(INSERT_AUDIT_EVENT_SQL).run( - crypto.randomUUID(), - identity.orgId, - identity.workspaceId, - identity.id, - "identity.suspended", - "budget_exceeded", - payload, - identity.updatedAt, - ); + backend.db + .prepare(INSERT_AUDIT_EVENT_SQL) + .run( + crypto.randomUUID(), + identity.orgId, + identity.workspaceId, + identity.id, + "identity.suspended", + "budget_exceeded", + payload, + identity.updatedAt, + ); } catch (error) { console.error("Failed to write budget audit event", error); } @@ -1303,16 +1442,18 @@ class SqliteTokenStorage implements TokenStorage { return; } - backend.db.prepare(INSERT_TOKEN_SQL).run( - token.id, - token.tokenId, - token.jti, - token.identityId, - token.sessionId ?? null, - token.issuedAt, - token.expiresAt, - token.createdAt, - ); + backend.db + .prepare(INSERT_TOKEN_SQL) + .run( + token.id, + token.tokenId, + token.jti, + token.identityId, + token.sessionId ?? null, + token.issuedAt, + token.expiresAt, + token.createdAt, + ); } async persistIssuedWithAudit(input: IssuedTokenAudit): Promise { @@ -1321,10 +1462,14 @@ class SqliteTokenStorage implements TokenStorage { if (backend.kind === "memory") { if ( - backend.state.tokens.has(input.token.id) - || backend.state.auditLogs.some((entry) => entry.id === auditEntry.id) + backend.state.tokens.has(input.token.id) || + backend.state.auditLogs.some((entry) => entry.id === auditEntry.id) ) { - throw new StorageError("token_already_exists", 409, "token_already_exists"); + throw new StorageError( + "token_already_exists", + 409, + "token_already_exists", + ); } backend.state.tokens.set(input.token.id, { ...input.token, @@ -1337,17 +1482,21 @@ class SqliteTokenStorage implements TokenStorage { backend.db.exec("BEGIN IMMEDIATE"); try { - backend.db.prepare(INSERT_TOKEN_SQL).run( - input.token.id, - input.token.tokenId, - input.token.jti, - input.token.identityId, - input.token.sessionId ?? null, - input.token.issuedAt, - input.token.expiresAt, - input.token.createdAt, - ); - backend.db.prepare(INSERT_AUDIT_LOG_SQL).run(...toAuditParams(auditEntry)); + backend.db + .prepare(INSERT_TOKEN_SQL) + .run( + input.token.id, + input.token.tokenId, + input.token.jti, + input.token.identityId, + input.token.sessionId ?? null, + input.token.issuedAt, + input.token.expiresAt, + input.token.createdAt, + ); + backend.db + .prepare(INSERT_AUDIT_LOG_SQL) + .run(...toAuditParams(auditEntry)); backend.db.exec("COMMIT"); } catch (error) { try { @@ -1365,11 +1514,15 @@ class SqliteTokenStorage implements TokenStorage { if (backend.kind === "memory") { if ( - backend.state.tokens.has(input.accessToken.id) - || backend.state.tokens.has(input.refreshToken.id) - || backend.state.auditLogs.some((entry) => entry.id === auditEntry.id) + backend.state.tokens.has(input.accessToken.id) || + backend.state.tokens.has(input.refreshToken.id) || + backend.state.auditLogs.some((entry) => entry.id === auditEntry.id) ) { - throw new StorageError("token_pair_already_exists", 409, "token_pair_already_exists"); + throw new StorageError( + "token_pair_already_exists", + 409, + "token_pair_already_exists", + ); } backend.state.tokens.set(input.accessToken.id, { @@ -1400,7 +1553,9 @@ class SqliteTokenStorage implements TokenStorage { token.createdAt, ); } - backend.db.prepare(INSERT_AUDIT_LOG_SQL).run(...toAuditParams(auditEntry)); + backend.db + .prepare(INSERT_AUDIT_LOG_SQL) + .run(...toAuditParams(auditEntry)); backend.db.exec("COMMIT"); } catch (error) { try { @@ -1413,7 +1568,9 @@ class SqliteTokenStorage implements TokenStorage { } } - async rotateIssuedPairWithAudit(input: IssuedTokenRotationAudit): Promise { + async rotateIssuedPairWithAudit( + input: IssuedTokenRotationAudit, + ): Promise { const backend = await this.provider.getBackend(); const refreshedAudit = normalizeAuditWriteEntry(input.refreshedAuditEntry); const revokedAudit = normalizeAuditWriteEntry(input.revokedAuditEntry); @@ -1422,9 +1579,9 @@ class SqliteTokenStorage implements TokenStorage { if (backend.kind === "memory") { const previousToken = backend.state.tokens.get(previous.id); if ( - !previousToken - || previousToken.identityId !== previous.identityId - || previousToken.status !== "active" + !previousToken || + previousToken.identityId !== previous.identityId || + previousToken.status !== "active" ) { throw new StorageError( "refresh_token_not_active", @@ -1433,12 +1590,18 @@ class SqliteTokenStorage implements TokenStorage { ); } if ( - backend.state.tokens.has(input.accessToken.id) - || backend.state.tokens.has(input.refreshToken.id) - || backend.state.auditLogs.some((entry) => - entry.id === refreshedAudit.id || entry.id === revokedAudit.id) + backend.state.tokens.has(input.accessToken.id) || + backend.state.tokens.has(input.refreshToken.id) || + backend.state.auditLogs.some( + (entry) => + entry.id === refreshedAudit.id || entry.id === revokedAudit.id, + ) ) { - throw new StorageError("token_rotation_conflict", 409, "token_rotation_conflict"); + throw new StorageError( + "token_rotation_conflict", + 409, + "token_rotation_conflict", + ); } backend.state.tokens.set(input.accessToken.id, { ...input.accessToken, @@ -1477,18 +1640,17 @@ class SqliteTokenStorage implements TokenStorage { token.createdAt, ); } - const revoked = backend.db.prepare(` + const revoked = backend.db + .prepare( + ` UPDATE tokens SET status = 'revoked' WHERE identity_id = ? AND status = 'active' AND (id = ? OR token_id = ? OR jti = ?) - `).run( - previous.identityId, - previous.id, - previous.id, - previous.id, - ); + `, + ) + .run(previous.identityId, previous.id, previous.id, previous.id); if (Number(revoked.changes ?? 0) !== 1) { throw new StorageError( "refresh_token_not_active", @@ -1496,12 +1658,15 @@ class SqliteTokenStorage implements TokenStorage { "refresh_token_not_active", ); } - backend.db.prepare(UPSERT_REVOKED_TOKEN_SQL).run( - previous.id, - previous.expiresAt, - ); - backend.db.prepare(INSERT_AUDIT_LOG_SQL).run(...toAuditParams(refreshedAudit)); - backend.db.prepare(INSERT_AUDIT_LOG_SQL).run(...toAuditParams(revokedAudit)); + backend.db + .prepare(UPSERT_REVOKED_TOKEN_SQL) + .run(previous.id, previous.expiresAt); + backend.db + .prepare(INSERT_AUDIT_LOG_SQL) + .run(...toAuditParams(refreshedAudit)); + backend.db + .prepare(INSERT_AUDIT_LOG_SQL) + .run(...toAuditParams(revokedAudit)); backend.db.exec("COMMIT"); } catch (error) { try { @@ -1521,23 +1686,24 @@ class SqliteTokenStorage implements TokenStorage { const backend = await this.provider.getBackend(); if (backend.kind === "memory") { - const token = [...backend.state.tokens.values()].find((candidate) => - candidate.id === normalizedTokenId - || candidate.tokenId === normalizedTokenId - || candidate.jti === normalizedTokenId + const token = [...backend.state.tokens.values()].find( + (candidate) => + candidate.id === normalizedTokenId || + candidate.tokenId === normalizedTokenId || + candidate.jti === normalizedTokenId, ); return token ? toStoredTokenRecord(token) : null; } - const row = backend.db.prepare(SELECT_TOKEN_BY_ID_SQL).get( - normalizedTokenId, - normalizedTokenId, - normalizedTokenId, - ); + const row = backend.db + .prepare(SELECT_TOKEN_BY_ID_SQL) + .get(normalizedTokenId, normalizedTokenId, normalizedTokenId); return row ? toStoredTokenRecord(row) : null; } - async listActiveByIdentityId(identityId: string): Promise { + async listActiveByIdentityId( + identityId: string, + ): Promise { const normalizedIdentityId = normalizeOptionalString(identityId); if (!normalizedIdentityId) { return []; @@ -1546,7 +1712,11 @@ class SqliteTokenStorage implements TokenStorage { const backend = await this.provider.getBackend(); if (backend.kind === "memory") { return [...backend.state.tokens.values()] - .filter((token) => token.identityId === normalizedIdentityId && token.status === "active") + .filter( + (token) => + token.identityId === normalizedIdentityId && + token.status === "active", + ) .map(toStoredTokenRecord); } @@ -1565,7 +1735,11 @@ class SqliteTokenStorage implements TokenStorage { const backend = await this.provider.getBackend(); if (backend.kind === "memory") { return [...backend.state.tokens.values()] - .filter((token) => token.sessionId === normalizedSessionId && token.status === "active") + .filter( + (token) => + token.sessionId === normalizedSessionId && + token.status === "active", + ) .map(toStoredTokenRecord); } @@ -1576,12 +1750,19 @@ class SqliteTokenStorage implements TokenStorage { } async listActiveIds(identityId: string): Promise { - const normalizedIdentityId = requireString(identityId, "identityId is required"); + const normalizedIdentityId = requireString( + identityId, + "identityId is required", + ); const backend = await this.provider.getBackend(); if (backend.kind === "memory") { return [...backend.state.tokens.values()] - .filter((token) => token.identityId === normalizedIdentityId && token.status === "active") + .filter( + (token) => + token.identityId === normalizedIdentityId && + token.status === "active", + ) .map((token) => token.id || token.jti || token.tokenId || "") .filter(Boolean); } @@ -1589,12 +1770,19 @@ class SqliteTokenStorage implements TokenStorage { return backend.db .prepare(LIST_ACTIVE_TOKENS_SQL) .all(normalizedIdentityId) - .map((row) => normalizeOptionalString(row.id) ?? normalizeOptionalString(row.jti) ?? normalizeOptionalString(row.token_id)) + .map( + (row) => + normalizeOptionalString(row.id) ?? + normalizeOptionalString(row.jti) ?? + normalizeOptionalString(row.token_id), + ) .filter((tokenId): tokenId is string => Boolean(tokenId)); } } -function toStoredTokenRecord(token: TokenRow | MemoryTokenRecord): StoredTokenRecord { +function toStoredTokenRecord( + token: TokenRow | MemoryTokenRecord, +): StoredTokenRecord { if ("identityId" in token) { return { id: token.id, @@ -1621,8 +1809,15 @@ function toStoredTokenRecord(token: TokenRow | MemoryTokenRecord): StoredTokenRe class SqliteRevocationStorage implements RevocationStorage { constructor(private readonly provider: BackendProvider) {} - async revokeIdentityTokens(identityId: string, tokenIds: string[], revokedAt: string): Promise { - const normalizedIdentityId = requireString(identityId, "identityId is required"); + async revokeIdentityTokens( + identityId: string, + tokenIds: string[], + revokedAt: string, + ): Promise { + const normalizedIdentityId = requireString( + identityId, + "identityId is required", + ); const normalizedTokenIds = normalizeStringArray(tokenIds); if (normalizedTokenIds.length === 0) { return; @@ -1641,8 +1836,10 @@ class SqliteRevocationStorage implements RevocationStorage { }); for (const token of backend.state.tokens.values()) { if ( - token.identityId === normalizedIdentityId - && (token.id === tokenId || token.jti === tokenId || token.tokenId === tokenId) + token.identityId === normalizedIdentityId && + (token.id === tokenId || + token.jti === tokenId || + token.tokenId === tokenId) ) { token.status = "revoked"; } @@ -1652,13 +1849,20 @@ class SqliteRevocationStorage implements RevocationStorage { } for (const tokenId of normalizedTokenIds) { - backend.db.prepare(UPSERT_REVOKED_TOKEN_SQL).run(tokenId, MAX_REVOCATION_EXPIRY); - backend.db.prepare(UPDATE_TOKEN_STATUS_SQL).run(normalizedIdentityId, tokenId, tokenId, tokenId); + backend.db + .prepare(UPSERT_REVOKED_TOKEN_SQL) + .run(tokenId, MAX_REVOCATION_EXPIRY); + backend.db + .prepare(UPDATE_TOKEN_STATUS_SQL) + .run(normalizedIdentityId, tokenId, tokenId, tokenId); } } async revokeIdentityTokensWithAudit(input: RevokedTokenAudit): Promise { - const normalizedIdentityId = requireString(input.identityId, "identityId is required"); + const normalizedIdentityId = requireString( + input.identityId, + "identityId is required", + ); const normalizedTokenIds = normalizeStringArray(input.tokenIds); if (normalizedTokenIds.length === 0) { return; @@ -1673,7 +1877,11 @@ class SqliteRevocationStorage implements RevocationStorage { // Check every failure condition before changing memory state, preserving // the same all-or-nothing contract as the SQLite transaction below. if (backend.state.auditLogs.some((entry) => entry.id === auditEntry.id)) { - throw new StorageError("audit_entry_already_exists", 409, "audit_entry_already_exists"); + throw new StorageError( + "audit_entry_already_exists", + 409, + "audit_entry_already_exists", + ); } for (const tokenId of normalizedTokenIds) { @@ -1684,8 +1892,10 @@ class SqliteRevocationStorage implements RevocationStorage { }); for (const token of backend.state.tokens.values()) { if ( - token.identityId === normalizedIdentityId - && (token.id === tokenId || token.jti === tokenId || token.tokenId === tokenId) + token.identityId === normalizedIdentityId && + (token.id === tokenId || + token.jti === tokenId || + token.tokenId === tokenId) ) { token.status = "revoked"; } @@ -1704,7 +1914,9 @@ class SqliteRevocationStorage implements RevocationStorage { upsertRevokedToken.run(tokenId, MAX_REVOCATION_EXPIRY); updateTokenStatus.run(normalizedIdentityId, tokenId, tokenId, tokenId); } - backend.db.prepare(INSERT_AUDIT_LOG_SQL).run(...toAuditParams(auditEntry)); + backend.db + .prepare(INSERT_AUDIT_LOG_SQL) + .run(...toAuditParams(auditEntry)); backend.db.exec("COMMIT"); } catch (error) { try { @@ -1729,7 +1941,9 @@ class SqliteRevocationStorage implements RevocationStorage { } pruneExpiredRevocations(backend); - const row = backend.db.prepare(SELECT_REVOKED_TOKEN_SQL).get(normalizedTokenId, nowUnixSeconds()); + const row = backend.db + .prepare(SELECT_REVOKED_TOKEN_SQL) + .get(normalizedTokenId, nowUnixSeconds()); return normalizeNumber(row?.found) > 0; } } @@ -1769,7 +1983,9 @@ class SqliteApiKeyStorage implements ApiKeyStorage { return apiKey ? cloneApiKey(apiKey) : null; } - return hydrateApiKey(backend.db.prepare(SELECT_API_KEY_SQL).get(apiKeyId)); + return hydrateApiKey( + backend.db.prepare(SELECT_API_KEY_SQL).get(apiKeyId), + ); } async getByHash(keyHash: string): Promise { @@ -1791,7 +2007,9 @@ class SqliteApiKeyStorage implements ApiKeyStorage { } const apiKey = hydrateApiKey( - backend.db.prepare(SELECT_API_KEY_BY_HASH_SQL).get(normalizedKeyHash), + backend.db + .prepare(SELECT_API_KEY_BY_HASH_SQL) + .get(normalizedKeyHash), ); if (!apiKey || !constantTimeEquals(normalizedKeyHash, apiKey.keyHash)) { return null; @@ -1800,7 +2018,10 @@ class SqliteApiKeyStorage implements ApiKeyStorage { return apiKey; } - async list(orgId: string, options: ListApiKeysOptions = {}): Promise { + async list( + orgId: string, + options: ListApiKeysOptions = {}, + ): Promise { const normalizedOrgId = requireString(orgId, "orgId is required"); const limit = normalizeLimit(options.limit); const cursorId = normalizeOptionalString(options.cursorId); @@ -1817,13 +2038,20 @@ class SqliteApiKeyStorage implements ApiKeyStorage { return true; } - return compareApiKeyCursor(apiKey, backend.state.apiKeys.get(cursorId) ?? null) < 0; + return ( + compareApiKeyCursor( + apiKey, + backend.state.apiKeys.get(cursorId) ?? null, + ) < 0 + ); }) .slice(0, limit) .map((apiKey) => cloneApiKey(apiKey)); } - const rows = backend.db.prepare(LIST_API_KEYS_SQL).all(normalizedOrgId); + const rows = backend.db + .prepare(LIST_API_KEYS_SQL) + .all(normalizedOrgId); const cursorApiKey = cursorId ? await this.get(cursorId) : null; return rows @@ -1855,7 +2083,9 @@ class SqliteApiKeyStorage implements ApiKeyStorage { return cloneApiKey(next); } - backend.db.prepare(UPDATE_API_KEY_REVOKED_SQL).run(timestamp, timestamp, next.id); + backend.db + .prepare(UPDATE_API_KEY_REVOKED_SQL) + .run(timestamp, timestamp, next.id); return this.getRequired(next.id); } @@ -1877,15 +2107,22 @@ class SqliteApiKeyStorage implements ApiKeyStorage { return; } - backend.state.apiKeys.set(apiKeyId, cloneApiKey(normalizeStoredApiKey({ - ...current, - updatedAt: timestamp, - lastUsedAt: timestamp, - }))); + backend.state.apiKeys.set( + apiKeyId, + cloneApiKey( + normalizeStoredApiKey({ + ...current, + updatedAt: timestamp, + lastUsedAt: timestamp, + }), + ), + ); return; } - backend.db.prepare(UPDATE_API_KEY_LAST_USED_SQL).run(timestamp, timestamp, apiKeyId, threshold); + backend.db + .prepare(UPDATE_API_KEY_LAST_USED_SQL) + .run(timestamp, timestamp, apiKeyId, threshold); } private async getRequired(id: string): Promise { @@ -1942,13 +2179,20 @@ class SqliteRoleStorage implements RoleStorage { if (backend.kind === "memory") { return [...backend.state.roles.values()] .filter((role) => role.orgId === normalizedOrgId) - .filter((role) => !normalizedWorkspaceId || !role.workspaceId || role.workspaceId === normalizedWorkspaceId) + .filter( + (role) => + !normalizedWorkspaceId || + !role.workspaceId || + role.workspaceId === normalizedWorkspaceId, + ) .sort(compareRoleAsc) .map((role) => cloneRole(role)); } const rows = normalizedWorkspaceId - ? backend.db.prepare(LIST_ROLES_FOR_WORKSPACE_SQL).all(normalizedOrgId, normalizedWorkspaceId) + ? backend.db + .prepare(LIST_ROLES_FOR_WORKSPACE_SQL) + .all(normalizedOrgId, normalizedWorkspaceId) : backend.db.prepare(LIST_ROLES_SQL).all(normalizedOrgId); return rows @@ -1961,7 +2205,9 @@ class SqliteRoleStorage implements RoleStorage { const next: Role = normalizeRole({ ...current, ...(patch.name !== undefined ? { name: patch.name } : {}), - ...(patch.description !== undefined ? { description: patch.description } : {}), + ...(patch.description !== undefined + ? { description: patch.description } + : {}), ...(patch.scopes !== undefined ? { scopes: patch.scopes } : {}), }); const backend = await this.provider.getBackend(); @@ -2001,7 +2247,9 @@ class SqliteRoleStorage implements RoleStorage { .map((role) => cloneRole(role)); } - const roles = await Promise.all(normalizedIds.map((roleId) => this.get(roleId))); + const roles = await Promise.all( + normalizedIds.map((roleId) => this.get(roleId)), + ); return roles.filter((role): role is Role => role !== null); } @@ -2024,7 +2272,11 @@ class SqlitePolicyStorage implements PolicyStorage { if (backend.kind === "memory") { if (backend.state.policies.has(normalized.id)) { - throw new StorageError("policy_name_conflict", 409, "policy_name_conflict"); + throw new StorageError( + "policy_name_conflict", + 409, + "policy_name_conflict", + ); } backend.state.policies.set(normalized.id, clonePolicy(normalized)); @@ -2059,13 +2311,20 @@ class SqlitePolicyStorage implements PolicyStorage { if (backend.kind === "memory") { return [...backend.state.policies.values()] .filter((policy) => policy.orgId === normalizedOrgId) - .filter((policy) => !normalizedWorkspaceId || !policy.workspaceId || policy.workspaceId === normalizedWorkspaceId) + .filter( + (policy) => + !normalizedWorkspaceId || + !policy.workspaceId || + policy.workspaceId === normalizedWorkspaceId, + ) .sort(comparePolicyDesc) .map((policy) => clonePolicy(policy)); } const rows = normalizedWorkspaceId - ? backend.db.prepare(LIST_POLICIES_FOR_WORKSPACE_SQL).all(normalizedOrgId, normalizedWorkspaceId) + ? backend.db + .prepare(LIST_POLICIES_FOR_WORKSPACE_SQL) + .all(normalizedOrgId, normalizedWorkspaceId) : backend.db.prepare(LIST_POLICIES_SQL).all(normalizedOrgId); return rows @@ -2080,7 +2339,9 @@ class SqlitePolicyStorage implements PolicyStorage { ...(patch.name !== undefined ? { name: patch.name } : {}), ...(patch.effect !== undefined ? { effect: patch.effect } : {}), ...(patch.scopes !== undefined ? { scopes: patch.scopes } : {}), - ...(patch.conditions !== undefined ? { conditions: patch.conditions } : {}), + ...(patch.conditions !== undefined + ? { conditions: patch.conditions } + : {}), ...(patch.priority !== undefined ? { priority: patch.priority } : {}), }); const backend = await this.provider.getBackend(); @@ -2103,7 +2364,9 @@ class SqlitePolicyStorage implements PolicyStorage { return; } - backend.db.prepare(DELETE_POLICY_SQL).run(new Date().toISOString(), policy.id, policy.orgId); + backend.db + .prepare(DELETE_POLICY_SQL) + .run(new Date().toISOString(), policy.id, policy.orgId); } private async getRequired(id: string): Promise { @@ -2141,7 +2404,8 @@ class SqliteAuditStorage implements AuditStorage { async query(query: AuditQueryInput, options: AuditQueryOptions = {}) { const normalized = normalizeAuditQuery(query); const backend = await this.provider.getBackend(); - const limitWithOverflow = normalized.limit + (options.includeOverflowRow ?? true ? 1 : 0); + const limitWithOverflow = + normalized.limit + ((options.includeOverflowRow ?? true) ? 1 : 0); if (backend.kind === "memory") { return { @@ -2175,17 +2439,20 @@ class SqliteAuditStorage implements AuditStorage { return { kind: "complete" as const, counts: summarizeAuditCounts( - backend.state.auditLogs.filter((entry) => - entry.orgId === normalizedOrgId - && (!from || entry.timestamp >= from) - && (!to || entry.timestamp < to), + backend.state.auditLogs.filter( + (entry) => + entry.orgId === normalizedOrgId && + (!from || entry.timestamp >= from) && + (!to || entry.timestamp < to), ), ), }; } const statement = buildAuditCountsSql(normalizedOrgId, { from, to }); - const row = backend.db.prepare(statement.sql).get(...statement.params); + const row = backend.db + .prepare(statement.sql) + .get(...statement.params); return { kind: "complete" as const, counts: { @@ -2198,7 +2465,11 @@ class SqliteAuditStorage implements AuditStorage { }; } - async writeIdentitySuspendedEvent(identity: StoredIdentity, reason: string, actorId: string): Promise { + async writeIdentitySuspendedEvent( + identity: StoredIdentity, + reason: string, + actorId: string, + ): Promise { const normalizedReason = requireString(reason, "reason is required"); const backend = await this.provider.getBackend(); const payload = JSON.stringify({ @@ -2215,16 +2486,18 @@ class SqliteAuditStorage implements AuditStorage { } try { - backend.db.prepare(INSERT_AUDIT_EVENT_SQL).run( - crypto.randomUUID(), - identity.orgId, - identity.workspaceId, - identity.id, - "identity.suspended", - normalizedReason, - payload, - identity.updatedAt, - ); + backend.db + .prepare(INSERT_AUDIT_EVENT_SQL) + .run( + crypto.randomUUID(), + identity.orgId, + identity.workspaceId, + identity.id, + "identity.suspended", + normalizedReason, + payload, + identity.updatedAt, + ); } catch (error) { console.error("Failed to write identity suspended audit event", error); } @@ -2239,19 +2512,24 @@ class SqliteAuditWebhookStorage implements AuditWebhookStorage { const backend = await this.provider.getBackend(); if (backend.kind === "memory") { - backend.state.auditWebhooks.set(normalized.id, cloneAuditWebhook(normalized)); + backend.state.auditWebhooks.set( + normalized.id, + cloneAuditWebhook(normalized), + ); return cloneAuditWebhook(normalized); } - backend.db.prepare(INSERT_AUDIT_WEBHOOK_SQL).run( - normalized.id, - normalized.orgId, - normalized.url, - normalized.secret, - normalized.events ? JSON.stringify(normalized.events) : null, - normalized.createdAt, - normalized.updatedAt, - ); + backend.db + .prepare(INSERT_AUDIT_WEBHOOK_SQL) + .run( + normalized.id, + normalized.orgId, + normalized.url, + normalized.secret, + normalized.events ? JSON.stringify(normalized.events) : null, + normalized.createdAt, + normalized.updatedAt, + ); return cloneAuditWebhook(normalized); } @@ -2287,14 +2565,18 @@ class SqliteAuditWebhookStorage implements AuditWebhookStorage { return; } - backend.db.prepare(DELETE_AUDIT_WEBHOOK_SQL).run(normalizedOrgId, normalizedId); + backend.db + .prepare(DELETE_AUDIT_WEBHOOK_SQL) + .run(normalizedOrgId, normalizedId); } } class SqliteContextStorage implements ContextStorage { constructor(private readonly provider: BackendProvider) {} - async getOrganization(orgId: string): Promise { + async getOrganization( + orgId: string, + ): Promise { const normalizedOrgId = normalizeOptionalString(orgId); if (!normalizedOrgId) { return null; @@ -2306,11 +2588,15 @@ class SqliteContextStorage implements ContextStorage { return organization ? cloneOrganization(organization) : null; } - const row = backend.db.prepare(SELECT_ORGANIZATION_SQL).get(normalizedOrgId); + const row = backend.db + .prepare(SELECT_ORGANIZATION_SQL) + .get(normalizedOrgId); return hydrateOrganization(row); } - async getWorkspace(workspaceId: string): Promise { + async getWorkspace( + workspaceId: string, + ): Promise { const normalizedWorkspaceId = normalizeOptionalString(workspaceId); if (!normalizedWorkspaceId) { return null; @@ -2322,16 +2608,20 @@ class SqliteContextStorage implements ContextStorage { return workspace ? cloneWorkspace(workspace) : null; } - const row = backend.db.prepare(SELECT_WORKSPACE_SQL).get(normalizedWorkspaceId); + const row = backend.db + .prepare(SELECT_WORKSPACE_SQL) + .get(normalizedWorkspaceId); return hydrateWorkspace(row); } } async function loadSqliteConstructors(): Promise { const constructors: SqliteDatabaseConstructor[] = []; - const moduleRecord = await importOptional>("better-sqlite3"); + const moduleRecord = + await importOptional>("better-sqlite3"); if (moduleRecord) { - const candidate = "default" in moduleRecord ? moduleRecord.default : moduleRecord; + const candidate = + "default" in moduleRecord ? moduleRecord.default : moduleRecord; if (typeof candidate === "function") { constructors.push(candidate as SqliteDatabaseConstructor); } @@ -2351,29 +2641,33 @@ async function loadSqliteConstructors(): Promise { if (sqliteModule?.DatabaseSync) { const NodeSqlite = sqliteModule.DatabaseSync; - constructors.push(class NodeSqliteAdapter { - private readonly db: InstanceType; + constructors.push( + class NodeSqliteAdapter { + private readonly db: InstanceType; - constructor(filename: string) { - this.db = new NodeSqlite(filename); - } + constructor(filename: string) { + this.db = new NodeSqlite(filename); + } - pragma(statement: string): void { - this.db.exec(`PRAGMA ${statement}`); - } + pragma(statement: string): void { + this.db.exec(`PRAGMA ${statement}`); + } - exec(sql: string): void { - this.db.exec(sql); - } + exec(sql: string): void { + this.db.exec(sql); + } - prepare(sql: string): SqliteStatement { - return this.db.prepare(sql) as unknown as SqliteStatement; - } + prepare( + sql: string, + ): SqliteStatement { + return this.db.prepare(sql) as unknown as SqliteStatement; + } - close(): void { - this.db.close(); - } - } as unknown as SqliteDatabaseConstructor); + close(): void { + this.db.close(); + } + } as unknown as SqliteDatabaseConstructor, + ); } return constructors; @@ -2385,7 +2679,9 @@ async function ensureDbDirectory(dbPath: string): Promise { } const [fsModule, pathModule] = await Promise.all([ - importOptional<{ mkdirSync?: (path: string, options?: { recursive?: boolean }) => void }>("node:fs"), + importOptional<{ + mkdirSync?: (path: string, options?: { recursive?: boolean }) => void; + }>("node:fs"), importOptional<{ dirname?: (path: string) => string }>("node:path"), ]); @@ -2431,7 +2727,10 @@ function createMemoryBackend(): BackendContext { async function getRevocationRecord( provider: BackendProvider, jti: string, -): Promise | null> { +): Promise | null> { const normalizedJti = normalizeOptionalString(jti); if (!normalizedJti) { return null; @@ -2453,7 +2752,9 @@ async function getRevocationRecord( }; } - const row = backend.db.prepare(SELECT_REVOKED_TOKEN_RECORD_SQL).get(normalizedJti); + const row = backend.db + .prepare(SELECT_REVOKED_TOKEN_RECORD_SQL) + .get(normalizedJti); const expiresAt = normalizeRevocationExpiry(row?.expires_at, undefined); if (expiresAt === undefined || expiresAt <= nowUnixSeconds()) { if (row) { @@ -2477,12 +2778,16 @@ async function getRevocationRecord( * throw a clear error rather than silently reading/writing the wrong columns. */ function ensureRevokedTokensSchema(db: SqliteDatabase): void { - const revokedTokensTable = db.prepare(` + const revokedTokensTable = db + .prepare( + ` SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'revoked_tokens' LIMIT 1 - `).get(); + `, + ) + .get(); // Post-migration, the table must exist. If it doesn't, the migration run // silently failed upstream; surface that rather than masking it. @@ -2493,7 +2798,8 @@ function ensureRevokedTokensSchema(db: SqliteDatabase): void { } const columns = new Set( - db.prepare("PRAGMA table_info(revoked_tokens)") + db + .prepare("PRAGMA table_info(revoked_tokens)") .all() .map((row) => normalizeOptionalString(row.name)) .filter((name): name is string => Boolean(name)), @@ -2512,7 +2818,8 @@ function ensureRevokedTokensSchema(db: SqliteDatabase): void { function ensureTokensSchema(db: SqliteDatabase): void { const tokenColumns = new Set( - db.prepare("PRAGMA table_info(tokens)") + db + .prepare("PRAGMA table_info(tokens)") .all() .map((row) => normalizeOptionalString(row.name)) .filter((name): name is string => Boolean(name)), @@ -2551,7 +2858,9 @@ function normalizeStoredIdentity( options: { generateId?: boolean } = {}, ): StoredIdentity { const providedId = normalizeOptionalString(identity.id); - const id = providedId ?? (options.generateId ? createGeneratedIdentityId() : undefined); + const id = + providedId ?? + (options.generateId ? createGeneratedIdentityId() : undefined); if (!id) { throw new StorageError("id is required", 400, "invalid_input"); } @@ -2561,9 +2870,10 @@ function normalizeStoredIdentity( throw new StorageError("sponsorChain is required", 400, "invalid_identity"); } - const normalizedSponsorChain = !providedId && options.generateId && sponsorChain.at(-1) !== id - ? [...sponsorChain, id] - : sponsorChain; + const normalizedSponsorChain = + !providedId && options.generateId && sponsorChain.at(-1) !== id + ? [...sponsorChain, id] + : sponsorChain; return { ...identity, @@ -2580,11 +2890,19 @@ function normalizeStoredIdentity( metadata: normalizeRecord(identity.metadata), createdAt: normalizeTimestamp(identity.createdAt), updatedAt: normalizeTimestamp(identity.updatedAt), - ...(normalizeOptionalString(identity.lastActiveAt) ? { lastActiveAt: normalizeOptionalString(identity.lastActiveAt) } : {}), - ...(normalizeOptionalString(identity.suspendedAt) ? { suspendedAt: normalizeOptionalString(identity.suspendedAt) } : {}), - ...(normalizeOptionalString(identity.suspendReason) ? { suspendReason: normalizeOptionalString(identity.suspendReason) } : {}), + ...(normalizeOptionalString(identity.lastActiveAt) + ? { lastActiveAt: normalizeOptionalString(identity.lastActiveAt) } + : {}), + ...(normalizeOptionalString(identity.suspendedAt) + ? { suspendedAt: normalizeOptionalString(identity.suspendedAt) } + : {}), + ...(normalizeOptionalString(identity.suspendReason) + ? { suspendReason: normalizeOptionalString(identity.suspendReason) } + : {}), ...(identity.budget ? { budget: cloneOptionalJson(identity.budget)! } : {}), - ...(identity.budgetUsage ? { budgetUsage: cloneOptionalJson(identity.budgetUsage)! } : {}), + ...(identity.budgetUsage + ? { budgetUsage: cloneOptionalJson(identity.budgetUsage)! } + : {}), }; } @@ -2592,7 +2910,9 @@ function parseStoredIdentity(data: string): StoredIdentity { return normalizeStoredIdentity(JSON.parse(data) as StoredIdentity); } -function hydrateStoredIdentityRow(row: StoredIdentityRow | undefined): StoredIdentity | null { +function hydrateStoredIdentityRow( + row: StoredIdentityRow | undefined, +): StoredIdentity | null { if (!row) { return null; } @@ -2609,27 +2929,48 @@ function hydrateStoredIdentityRow(row: StoredIdentityRow | undefined): StoredIde const id = normalizeOptionalString(row.id); const name = normalizeOptionalString(row.name); const orgId = normalizeOptionalString(row.org_id ?? row.orgId); - const workspaceId = normalizeOptionalString(row.workspace_id ?? row.workspaceId); + const workspaceId = normalizeOptionalString( + row.workspace_id ?? row.workspaceId, + ); const sponsorId = normalizeOptionalString(row.sponsor_id ?? row.sponsorId); const createdAt = normalizeOptionalString(row.created_at ?? row.createdAt); const updatedAt = normalizeOptionalString(row.updated_at ?? row.updatedAt); const sponsorChain = parseStringArrayField( row.sponsorChain ?? row.sponsor_chain, - typeof row.sponsor_chain === "string" ? row.sponsor_chain : row.sponsor_chain_json, + typeof row.sponsor_chain === "string" + ? row.sponsor_chain + : row.sponsor_chain_json, ); - if (!id || !name || !orgId || !workspaceId || !sponsorId || !createdAt || !updatedAt || sponsorChain.length === 0) { + if ( + !id || + !name || + !orgId || + !workspaceId || + !sponsorId || + !createdAt || + !updatedAt || + sponsorChain.length === 0 + ) { return null; } - const metadata = normalizeRecord(parseJsonObjectField(row.metadata, row.metadata_json) ?? {}); + const metadata = normalizeRecord( + parseJsonObjectField(row.metadata, row.metadata_json) ?? {}, + ); const budget = row.budget ?? parseIdentityBudget(row.budget_json); const budgetUsage = parseJsonObjectField( row.budgetUsage ?? row.budget_usage, row.budget_usage_json, ); - const lastActiveAt = normalizeOptionalString(row.last_active_at ?? row.lastActiveAt); - const suspendedAt = normalizeOptionalString(row.suspended_at ?? row.suspendedAt); - const suspendReason = normalizeOptionalString(row.suspend_reason ?? row.suspendReason); + const lastActiveAt = normalizeOptionalString( + row.last_active_at ?? row.lastActiveAt, + ); + const suspendedAt = normalizeOptionalString( + row.suspended_at ?? row.suspendedAt, + ); + const suspendReason = normalizeOptionalString( + row.suspend_reason ?? row.suspendReason, + ); return normalizeStoredIdentity({ id, @@ -2666,18 +3007,31 @@ function mergeStoredIdentity( id: current.id, orgId: current.orgId, createdAt: current.createdAt, - metadata: patch.metadata ? { ...current.metadata, ...normalizeRecord(patch.metadata) } : current.metadata, + metadata: patch.metadata + ? { ...current.metadata, ...normalizeRecord(patch.metadata) } + : current.metadata, scopes: patch.scopes ?? current.scopes, roles: patch.roles ?? current.roles, - sponsorChain: "sponsorChain" in patch ? normalizeStringArray(patch.sponsorChain) : current.sponsorChain, + sponsorChain: + "sponsorChain" in patch + ? normalizeStringArray(patch.sponsorChain) + : current.sponsorChain, budget: patch.budget ?? current.budget, budgetUsage: patch.budgetUsage ?? current.budgetUsage, updatedAt, }); } -function applyBudgetPolicy(previous: StoredIdentity, identity: StoredIdentity, timestamp: string): BudgetPolicyResult { - if (identity.status === "retired" || !identity.budget?.autoSuspend || !isBudgetExceeded(identity)) { +function applyBudgetPolicy( + previous: StoredIdentity, + identity: StoredIdentity, + timestamp: string, +): BudgetPolicyResult { + if ( + identity.status === "retired" || + !identity.budget?.autoSuspend || + !isBudgetExceeded(identity) + ) { return { identity, shouldWriteAuditEvent: false, @@ -2692,7 +3046,9 @@ function applyBudgetPolicy(previous: StoredIdentity, identity: StoredIdentity, t suspendedAt: identity.suspendedAt ?? timestamp, updatedAt: timestamp, }, - shouldWriteAuditEvent: previous.status !== "suspended" || previous.suspendReason !== "budget_exceeded", + shouldWriteAuditEvent: + previous.status !== "suspended" || + previous.suspendReason !== "budget_exceeded", }; } @@ -2704,11 +3060,11 @@ function isBudgetExceeded(identity: StoredIdentity): boolean { } const actionsExceeded = - typeof budget.maxActionsPerHour === "number" - && usage.actionsThisHour > budget.maxActionsPerHour; + typeof budget.maxActionsPerHour === "number" && + usage.actionsThisHour > budget.maxActionsPerHour; const costExceeded = - typeof budget.maxCostPerDay === "number" - && usage.costToday > budget.maxCostPerDay; + typeof budget.maxCostPerDay === "number" && + usage.costToday > budget.maxCostPerDay; return actionsExceeded || costExceeded; } @@ -2731,7 +3087,9 @@ function emitBudgetAlert(identity: StoredIdentity): void { }); } -function getBudgetMetric(identity: StoredIdentity): { usage: number; limit: number; ratio: number } | undefined { +function getBudgetMetric( + identity: StoredIdentity, +): { usage: number; limit: number; ratio: number } | undefined { const budget = identity.budget; const usage = identity.budgetUsage; if (!budget || !usage) { @@ -2739,7 +3097,10 @@ function getBudgetMetric(identity: StoredIdentity): { usage: number; limit: numb } const metrics: { usage: number; limit: number; ratio: number }[] = []; - if (typeof budget.maxActionsPerHour === "number" && budget.maxActionsPerHour > 0) { + if ( + typeof budget.maxActionsPerHour === "number" && + budget.maxActionsPerHour > 0 + ) { metrics.push({ usage: usage.actionsThisHour, limit: budget.maxActionsPerHour, @@ -2800,15 +3161,26 @@ function toAgentIdentity(identity: StoredIdentity): AgentIdentity { updatedAt: identity.updatedAt, ...(identity.lastActiveAt ? { lastActiveAt: identity.lastActiveAt } : {}), ...(identity.suspendedAt ? { suspendedAt: identity.suspendedAt } : {}), - ...(identity.suspendReason ? { suspendReason: identity.suspendReason } : {}), + ...(identity.suspendReason + ? { suspendReason: identity.suspendReason } + : {}), }; } -function compareIdentityDesc(left: StoredIdentity, right: StoredIdentity): number { - return right.createdAt.localeCompare(left.createdAt) || right.id.localeCompare(left.id); +function compareIdentityDesc( + left: StoredIdentity, + right: StoredIdentity, +): number { + return ( + right.createdAt.localeCompare(left.createdAt) || + right.id.localeCompare(left.id) + ); } -function compareIdentityCursor(identity: StoredIdentity, cursor: StoredIdentity | null): number { +function compareIdentityCursor( + identity: StoredIdentity, + cursor: StoredIdentity | null, +): number { if (!cursor) { return -1; } @@ -2821,7 +3193,9 @@ function compareIdentityCursor(identity: StoredIdentity, cursor: StoredIdentity return identity.id.localeCompare(cursor.id); } -function hydrateChildIdentity(row: ChildIdentityRow): IdentityChildSummary | null { +function hydrateChildIdentity( + row: ChildIdentityRow, +): IdentityChildSummary | null { const id = normalizeOptionalString(row.id); const name = normalizeOptionalString(row.name); if (!id || !name) { @@ -2832,8 +3206,12 @@ function hydrateChildIdentity(row: ChildIdentityRow): IdentityChildSummary | nul id, name, status: normalizeIdentityStatus(row.status) ?? "active", - ...(normalizeOptionalString(row.sponsor_id) ? { sponsorId: row.sponsor_id ?? undefined } : {}), - ...(normalizeOptionalString(row.created_at) ? { createdAt: row.created_at ?? undefined } : {}), + ...(normalizeOptionalString(row.sponsor_id) + ? { sponsorId: row.sponsor_id ?? undefined } + : {}), + ...(normalizeOptionalString(row.created_at) + ? { createdAt: row.created_at ?? undefined } + : {}), }; } @@ -2859,12 +3237,17 @@ function normalizeRole(role: Role): Role { ...role, id: requireString(role.id, "role id is required"), name: requireString(role.name, "role name is required"), - description: requireString(role.description, "role description is required"), + description: requireString( + role.description, + "role description is required", + ), orgId: requireString(role.orgId, "role orgId is required"), scopes: normalizeStringArray(role.scopes), builtIn: role.builtIn === true, createdAt: normalizeTimestamp(role.createdAt), - ...(normalizeOptionalString(role.workspaceId) ? { workspaceId: role.workspaceId } : {}), + ...(normalizeOptionalString(role.workspaceId) + ? { workspaceId: role.workspaceId } + : {}), }; } @@ -2936,7 +3319,9 @@ function normalizePolicy(policy: Policy): Policy { conditions: normalizePolicyConditions(policy.conditions), priority: Number.isInteger(policy.priority) ? policy.priority : 0, createdAt: normalizeTimestamp(policy.createdAt), - ...(normalizeOptionalString(policy.workspaceId) ? { workspaceId: policy.workspaceId } : {}), + ...(normalizeOptionalString(policy.workspaceId) + ? { workspaceId: policy.workspaceId } + : {}), }; } @@ -2956,7 +3341,10 @@ function hydratePolicy(row: PolicyRow | undefined): Policy | null { name, effect, scopes: parseStringArrayField(row?.scopes, row?.scopes_json), - conditions: parsePolicyConditionsField(row?.conditions, row?.conditions_json), + conditions: parsePolicyConditionsField( + row?.conditions, + row?.conditions_json, + ), priority: normalizeNumber(row?.priority), orgId, createdAt, @@ -3013,9 +3401,13 @@ function normalizeStoredApiKey(apiKey: StoredApiKey): StoredApiKey { ...(workspaceId ? { workspaceId } : {}), createdAt: normalizeTimestamp(apiKey.createdAt), updatedAt: normalizeTimestamp(apiKey.updatedAt), - ...(normalizeOptionalString(apiKey.lastUsedAt) ? { lastUsedAt: apiKey.lastUsedAt } : {}), + ...(normalizeOptionalString(apiKey.lastUsedAt) + ? { lastUsedAt: apiKey.lastUsedAt } + : {}), ...(apiKey.revokedAt === null ? { revokedAt: null } : {}), - ...(normalizeOptionalString(apiKey.revokedAt ?? undefined) ? { revokedAt: apiKey.revokedAt ?? undefined } : {}), + ...(normalizeOptionalString(apiKey.revokedAt ?? undefined) + ? { revokedAt: apiKey.revokedAt ?? undefined } + : {}), }; } @@ -3027,7 +3419,15 @@ function hydrateApiKey(row: ApiKeyRow | undefined): StoredApiKey | null { const keyHash = normalizeOptionalString(row?.key_hash); const createdAt = normalizeOptionalString(row?.created_at); const updatedAt = normalizeOptionalString(row?.updated_at); - if (!id || !orgId || !name || !prefix || !keyHash || !createdAt || !updatedAt) { + if ( + !id || + !orgId || + !name || + !prefix || + !keyHash || + !createdAt || + !updatedAt + ) { return null; } @@ -3037,14 +3437,22 @@ function hydrateApiKey(row: ApiKeyRow | undefined): StoredApiKey | null { name, prefix, keyHash, - scopes: normalizeStringArray(parseJson(row?.scopes_json ?? "[]", [])), + scopes: normalizeStringArray( + parseJson(row?.scopes_json ?? "[]", []), + ), kind: normalizeApiKeyKind(row?.kind) ?? "api_key", createdAt, updatedAt, - ...(normalizeOptionalString(row?.workspace_id) ? { workspaceId: row?.workspace_id ?? undefined } : {}), - ...(normalizeOptionalString(row?.last_used_at) ? { lastUsedAt: row?.last_used_at ?? undefined } : {}), + ...(normalizeOptionalString(row?.workspace_id) + ? { workspaceId: row?.workspace_id ?? undefined } + : {}), + ...(normalizeOptionalString(row?.last_used_at) + ? { lastUsedAt: row?.last_used_at ?? undefined } + : {}), ...(row?.revoked_at === null ? { revokedAt: null } : {}), - ...(normalizeOptionalString(row?.revoked_at) ? { revokedAt: row?.revoked_at ?? undefined } : {}), + ...(normalizeOptionalString(row?.revoked_at) + ? { revokedAt: row?.revoked_at ?? undefined } + : {}), }); } @@ -3065,15 +3473,27 @@ function toApiKeyParams(apiKey: StoredApiKey): unknown[] { ]; } -function normalizeApiKeyKind(value: unknown): "api_key" | "workspace_token" | undefined { - return value === "workspace_token" ? "workspace_token" : value === "api_key" ? "api_key" : undefined; +function normalizeApiKeyKind( + value: unknown, +): "api_key" | "workspace_token" | undefined { + return value === "workspace_token" + ? "workspace_token" + : value === "api_key" + ? "api_key" + : undefined; } function compareApiKeyDesc(left: StoredApiKey, right: StoredApiKey): number { - return right.createdAt.localeCompare(left.createdAt) || right.id.localeCompare(left.id); + return ( + right.createdAt.localeCompare(left.createdAt) || + right.id.localeCompare(left.id) + ); } -function compareApiKeyCursor(apiKey: StoredApiKey, cursor: StoredApiKey | null): number { +function compareApiKeyCursor( + apiKey: StoredApiKey, + cursor: StoredApiKey | null, +): number { if (!cursor) { return -1; } @@ -3097,12 +3517,18 @@ function normalizeAuditWriteEntry(entry: AuditLogWriteEntry): AuditEntryRecord { result: entry.result, timestamp: normalizeTimestamp(entry.timestamp), createdAt, - ...(normalizeOptionalString(entry.workspaceId) ? { workspaceId: entry.workspaceId } : {}), + ...(normalizeOptionalString(entry.workspaceId) + ? { workspaceId: entry.workspaceId } + : {}), ...(normalizeOptionalString(entry.plane) ? { plane: entry.plane } : {}), - ...(normalizeOptionalString(entry.resource) ? { resource: entry.resource } : {}), + ...(normalizeOptionalString(entry.resource) + ? { resource: entry.resource } + : {}), ...(entry.metadata ? { metadata: normalizeRecord(entry.metadata) } : {}), ...(normalizeOptionalString(entry.ip) ? { ip: entry.ip } : {}), - ...(normalizeOptionalString(entry.userAgent) ? { userAgent: entry.userAgent } : {}), + ...(normalizeOptionalString(entry.userAgent) + ? { userAgent: entry.userAgent } + : {}), }; } @@ -3131,7 +3557,10 @@ function toAuditParams(entry: AuditEntryRecord): unknown[] { ]; } -function buildAuditQuerySql(query: AuditQueryInput, limit: number): { sql: string; params: unknown[] } { +function buildAuditQuerySql( + query: AuditQueryInput, + limit: number, +): { sql: string; params: unknown[] } { const clauses = ["org_id = ?"]; const params: unknown[] = [query.orgId]; @@ -3167,7 +3596,9 @@ function buildAuditQuerySql(query: AuditQueryInput, limit: number): { sql: strin clauses.push("timestamp < ?"); params.push( query.cursor.inclusive && !query.cursor.chunk - ? new Date(new Date(query.cursor.timestamp).getTime() + 60_000).toISOString() + ? new Date( + new Date(query.cursor.timestamp).getTime() + 60_000, + ).toISOString() : query.cursor.timestamp, ); if (query.cursor.entryCursor) { @@ -3180,7 +3611,11 @@ function buildAuditQuerySql(query: AuditQueryInput, limit: number): { sql: strin } } else if (query.cursor) { clauses.push("(timestamp < ? OR (timestamp = ? AND id < ?))"); - params.push(query.cursor.timestamp, query.cursor.timestamp, query.cursor.id); + params.push( + query.cursor.timestamp, + query.cursor.timestamp, + query.cursor.id, + ); } params.push(limit); @@ -3258,17 +3693,36 @@ function hydrateAuditEntryRecord(row: AuditRow): AuditEntryRecord | null { orgId, result: (row.result ?? "allowed") as AuditEntry["result"], timestamp, - ...(normalizeOptionalString(row.workspace_id) ? { workspaceId: row.workspace_id ?? undefined } : {}), - ...(normalizeOptionalString(row.plane) ? { plane: row.plane ?? undefined } : {}), - ...(normalizeOptionalString(row.resource) ? { resource: row.resource ?? undefined } : {}), - ...(row.metadata_json ? { metadata: normalizeRecord(parseJson>(row.metadata_json, {})) } : {}), + ...(normalizeOptionalString(row.workspace_id) + ? { workspaceId: row.workspace_id ?? undefined } + : {}), + ...(normalizeOptionalString(row.plane) + ? { plane: row.plane ?? undefined } + : {}), + ...(normalizeOptionalString(row.resource) + ? { resource: row.resource ?? undefined } + : {}), + ...(row.metadata_json + ? { + metadata: normalizeRecord( + parseJson>(row.metadata_json, {}), + ), + } + : {}), ...(normalizeOptionalString(row.ip) ? { ip: row.ip ?? undefined } : {}), - ...(normalizeOptionalString(row.user_agent) ? { userAgent: row.user_agent ?? undefined } : {}), - ...(normalizeOptionalString(row.created_at) ? { createdAt: row.created_at ?? undefined } : {}), + ...(normalizeOptionalString(row.user_agent) + ? { userAgent: row.user_agent ?? undefined } + : {}), + ...(normalizeOptionalString(row.created_at) + ? { createdAt: row.created_at ?? undefined } + : {}), }; } -function matchesAuditQuery(entry: AuditEntryRecord, query: AuditQueryInput): boolean { +function matchesAuditQuery( + entry: AuditEntryRecord, + query: AuditQueryInput, +): boolean { if (entry.orgId !== query.orgId) { return false; } @@ -3294,9 +3748,12 @@ function matchesAuditQuery(entry: AuditEntryRecord, query: AuditQueryInput): boo return false; } if (query.cursor?.kind === "archive_partition") { - const upper = query.cursor.inclusive && !query.cursor.chunk - ? new Date(new Date(query.cursor.timestamp).getTime() + 60_000).toISOString() - : query.cursor.timestamp; + const upper = + query.cursor.inclusive && !query.cursor.chunk + ? new Date( + new Date(query.cursor.timestamp).getTime() + 60_000, + ).toISOString() + : query.cursor.timestamp; if (entry.timestamp >= upper) { return false; } @@ -3312,7 +3769,10 @@ function matchesAuditQuery(entry: AuditEntryRecord, query: AuditQueryInput): boo if (entry.timestamp > query.cursor.timestamp) { return false; } - if (entry.timestamp === query.cursor.timestamp && entry.id >= query.cursor.id) { + if ( + entry.timestamp === query.cursor.timestamp && + entry.id >= query.cursor.id + ) { return false; } } @@ -3320,7 +3780,9 @@ function matchesAuditQuery(entry: AuditEntryRecord, query: AuditQueryInput): boo return true; } -function summarizeAuditCounts(entries: AuditEntryRecord[]): DashboardAuditCounts { +function summarizeAuditCounts( + entries: AuditEntryRecord[], +): DashboardAuditCounts { let tokensIssued = 0; let tokensRevoked = 0; let tokensRefreshed = 0; @@ -3351,14 +3813,28 @@ function summarizeAuditCounts(entries: AuditEntryRecord[]): DashboardAuditCounts } } - return { tokensIssued, tokensRevoked, tokensRefreshed, scopeChecks, scopeDenials }; + return { + tokensIssued, + tokensRevoked, + tokensRefreshed, + scopeChecks, + scopeDenials, + }; } -function compareAuditRecordDesc(left: AuditEntryRecord, right: AuditEntryRecord): number { - return right.timestamp.localeCompare(left.timestamp) || right.id.localeCompare(left.id); +function compareAuditRecordDesc( + left: AuditEntryRecord, + right: AuditEntryRecord, +): number { + return ( + right.timestamp.localeCompare(left.timestamp) || + right.id.localeCompare(left.id) + ); } -function normalizeAuditWebhook(input: CreateAuditWebhookInput): AuditWebhookRecord { +function normalizeAuditWebhook( + input: CreateAuditWebhookInput, +): AuditWebhookRecord { const timestamp = new Date().toISOString(); return { @@ -3383,7 +3859,9 @@ function hydrateAuditWebhook(row: AuditWebhookRow): AuditWebhookRecord | null { return null; } - const events = row.events_json ? normalizeStringArray(parseJson(row.events_json, [])) : undefined; + const events = row.events_json + ? normalizeStringArray(parseJson(row.events_json, [])) + : undefined; return { id, @@ -3396,11 +3874,19 @@ function hydrateAuditWebhook(row: AuditWebhookRow): AuditWebhookRecord | null { }; } -function compareWebhookDesc(left: AuditWebhookRecord, right: AuditWebhookRecord): number { - return (right.createdAt ?? "").localeCompare(left.createdAt ?? "") || right.id.localeCompare(left.id); +function compareWebhookDesc( + left: AuditWebhookRecord, + right: AuditWebhookRecord, +): number { + return ( + (right.createdAt ?? "").localeCompare(left.createdAt ?? "") || + right.id.localeCompare(left.id) + ); } -function hydrateOrganization(row: OrganizationRow | undefined): OrganizationContextRecord | null { +function hydrateOrganization( + row: OrganizationRow | undefined, +): OrganizationContextRecord | null { const id = normalizeOptionalString(row?.id); if (!id) { return null; @@ -3409,12 +3895,18 @@ function hydrateOrganization(row: OrganizationRow | undefined): OrganizationCont return { id, orgId: normalizeOptionalString(row?.org_id) ?? id, - scopes: normalizeStringArray(parseJson(row?.scopes_json ?? "[]", [])), - roles: normalizeStringArray(parseJson(row?.roles_json ?? "[]", [])), + scopes: normalizeStringArray( + parseJson(row?.scopes_json ?? "[]", []), + ), + roles: normalizeStringArray( + parseJson(row?.roles_json ?? "[]", []), + ), }; } -function hydrateWorkspace(row: WorkspaceRow | undefined): WorkspaceContextRecord | null { +function hydrateWorkspace( + row: WorkspaceRow | undefined, +): WorkspaceContextRecord | null { const id = normalizeOptionalString(row?.id); const orgId = normalizeOptionalString(row?.org_id); if (!id || !orgId) { @@ -3425,8 +3917,12 @@ function hydrateWorkspace(row: WorkspaceRow | undefined): WorkspaceContextRecord id, workspaceId: normalizeOptionalString(row?.workspace_id) ?? id, orgId, - scopes: normalizeStringArray(parseJson(row?.scopes_json ?? "[]", [])), - roles: normalizeStringArray(parseJson(row?.roles_json ?? "[]", [])), + scopes: normalizeStringArray( + parseJson(row?.scopes_json ?? "[]", []), + ), + roles: normalizeStringArray( + parseJson(row?.roles_json ?? "[]", []), + ), }; } @@ -3462,7 +3958,9 @@ function normalizeIdentityType(value: unknown): IdentityType { } function normalizeIdentityStatus(value: unknown): IdentityStatus | undefined { - return value === "active" || value === "suspended" || value === "retired" ? value : undefined; + return value === "active" || value === "suspended" || value === "retired" + ? value + : undefined; } function normalizePolicyEffect(value: unknown): Policy["effect"] | undefined { @@ -3477,7 +3975,10 @@ function normalizeStringArray(value: unknown): string[] { return value.filter((item): item is string => typeof item === "string"); } -function parseStringArrayField(value: unknown, jsonValue?: string | null): string[] { +function parseStringArrayField( + value: unknown, + jsonValue?: string | null, +): string[] { if (Array.isArray(value)) { return normalizeStringArray(value); } @@ -3486,18 +3987,28 @@ function parseStringArrayField(value: unknown, jsonValue?: string | null): strin } function normalizePolicyConditions(value: unknown): Policy["conditions"] { - return Array.isArray(value) ? cloneJsonArray(value as Policy["conditions"]) : []; + return Array.isArray(value) + ? cloneJsonArray(value as Policy["conditions"]) + : []; } -function parsePolicyConditionsField(value: unknown, jsonValue?: string | null): Policy["conditions"] { +function parsePolicyConditionsField( + value: unknown, + jsonValue?: string | null, +): Policy["conditions"] { if (Array.isArray(value)) { return normalizePolicyConditions(value); } - return normalizePolicyConditions(parseJson(jsonValue ?? undefined, [])); + return normalizePolicyConditions( + parseJson(jsonValue ?? undefined, []), + ); } -function parseJsonObjectField(value: unknown, jsonValue?: string | null): T | undefined { +function parseJsonObjectField( + value: unknown, + jsonValue?: string | null, +): T | undefined { if (value && typeof value === "object" && !Array.isArray(value)) { return cloneOptionalJson(value as T); } @@ -3516,7 +4027,9 @@ function normalizeRecord(value: unknown): Record { } return Object.fromEntries( - Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"), + Object.entries(value).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), ); } @@ -3606,7 +4119,10 @@ function mergeRecordsById( return [...merged.values()].sort(compare); } -function normalizeRevocationExpiry(value: unknown, fallback: number | undefined): number | undefined { +function normalizeRevocationExpiry( + value: unknown, + fallback: number | undefined, +): number | undefined { if (typeof value === "number" && Number.isFinite(value)) { return Math.max(0, Math.floor(value)); } @@ -3664,16 +4180,22 @@ function cloneAuditWebhook(webhook: AuditWebhookRecord): AuditWebhookRecord { return JSON.parse(JSON.stringify(webhook)) as AuditWebhookRecord; } -function cloneOrganization(record: OrganizationContextRecord): OrganizationContextRecord { +function cloneOrganization( + record: OrganizationContextRecord, +): OrganizationContextRecord { return JSON.parse(JSON.stringify(record)) as OrganizationContextRecord; } -function cloneWorkspace(record: WorkspaceContextRecord): WorkspaceContextRecord { +function cloneWorkspace( + record: WorkspaceContextRecord, +): WorkspaceContextRecord { return JSON.parse(JSON.stringify(record)) as WorkspaceContextRecord; } function cloneOptionalJson(value: T | undefined): T | undefined { - return value === undefined ? undefined : (JSON.parse(JSON.stringify(value)) as T); + return value === undefined + ? undefined + : (JSON.parse(JSON.stringify(value)) as T); } function cloneJsonArray(value: T[]): T[] { @@ -3703,7 +4225,9 @@ function nowUnixSeconds(): number { return Math.floor(Date.now() / 1000); } -function parseTimestampMs(value: string | undefined | null): number | undefined { +function parseTimestampMs( + value: string | undefined | null, +): number | undefined { if (!value) { return undefined; } From e4aa5ed4f85ae266f13d6345293b1fe86319cddc Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Fri, 31 Jul 2026 07:14:44 +0200 Subject: [PATCH 10/20] fix(server): resolve audit and token review findings --- .../src/__tests__/client-audit.test.ts | 7 +- packages/sdk/typescript/src/client.ts | 8 +- .../src/__tests__/audit-query-api.test.ts | 60 +++++++--- .../src/__tests__/dashboard-stats-api.test.ts | 65 +++++++++-- .../__tests__/identity-activity-api.test.ts | 110 ++++++++++++++++++ .../server/src/__tests__/tokens-route.test.ts | 80 +++++++++++++ packages/server/src/routes/audit-export.ts | 6 +- packages/server/src/routes/audit-query.ts | 78 +++++++++++-- packages/server/src/routes/dashboard-stats.ts | 17 ++- .../server/src/routes/identity-activity.ts | 8 +- packages/server/src/routes/tokens.ts | 84 +++++++------ packages/server/src/storage/interface.ts | 8 +- 12 files changed, 449 insertions(+), 82 deletions(-) diff --git a/packages/sdk/typescript/src/__tests__/client-audit.test.ts b/packages/sdk/typescript/src/__tests__/client-audit.test.ts index 03d6743..d07e8f9 100644 --- a/packages/sdk/typescript/src/__tests__/client-audit.test.ts +++ b/packages/sdk/typescript/src/__tests__/client-audit.test.ts @@ -193,7 +193,12 @@ test("queryAudit sends audit filters as query params and maps nextCursor to curs test("queryAudit preserves a typed archive work-budget continuation", async (t) => { const client = createClient(); - const workBudget = { d1Pages: 4, d1Rows: 129, partitions: 128, r2Reads: 128 }; + const workBudget = { + hotStorePages: 4, + hotStoreRows: 129, + archivePartitions: 128, + archiveReads: 128, + }; const fetchMock = mockFetch(() => jsonResponse({ entries: auditEntries.slice(0, 1), diff --git a/packages/sdk/typescript/src/client.ts b/packages/sdk/typescript/src/client.ts index 927bd1f..ed328a3 100644 --- a/packages/sdk/typescript/src/client.ts +++ b/packages/sdk/typescript/src/client.ts @@ -35,10 +35,10 @@ export interface RelayAuthClientOptions { } export type AuditQueryWorkBudget = { - d1Pages: number; - d1Rows: number; - partitions: number; - r2Reads: number; + hotStorePages: number; + hotStoreRows: number; + archivePartitions: number; + archiveReads: number; }; export type AuditQueryPage = { diff --git a/packages/server/src/__tests__/audit-query-api.test.ts b/packages/server/src/__tests__/audit-query-api.test.ts index 24cd81f..655d6d6 100644 --- a/packages/server/src/__tests__/audit-query-api.test.ts +++ b/packages/server/src/__tests__/audit-query-api.test.ts @@ -17,10 +17,10 @@ type AuditQueryResponse = { hasMore?: boolean; partial?: boolean; workBudget?: { - d1Pages: number; - d1Rows: number; - partitions: number; - r2Reads: number; + hotStorePages: number; + hotStoreRows: number; + archivePartitions: number; + archiveReads: number; }; }; @@ -438,6 +438,7 @@ test("GET /v1/audit returns a typed archive budget continuation that resumes wit ]; storage.audit.query = async (query) => { assert.equal(query.orgId, "org_archive"); + assert.equal(query.identityId, "agent_archive_雪"); if (query.cursor?.kind === "archive_partition") { assert.equal(query.cursor.timestamp, "2026-03-24T12:00:02.000Z"); assert.equal(query.cursor.inclusive, true); @@ -449,7 +450,7 @@ test("GET /v1/audit returns a typed archive budget continuation that resumes wit } return { kind: "budget_exhausted", - entries: entries.slice(0, 2), + entries, continuation: { kind: "archive_partition", orgId: "org_archive", @@ -461,7 +462,12 @@ test("GET /v1/audit returns a typed archive budget continuation that resumes wit }, filterKey: createAuditQueryContinuationFilterKey(query), }, - workBudget: { d1Pages: 4, d1Rows: 129, partitions: 128, r2Reads: 128 }, + workBudget: { + hotStorePages: 4, + hotStoreRows: 129, + archivePartitions: 128, + archiveReads: 128, + }, }; }; const app = createTestApp({}, { storage }); @@ -473,7 +479,7 @@ test("GET /v1/audit returns a typed archive budget continuation that resumes wit const first = await app.request( createTestRequest( "GET", - "/v1/audit?orgId=org_archive&identityId=agent_archive&action=token.validated&workspaceId=workspace_archive&plane=relayauth&result=allowed&from=2026-03-24T12%3A00%3A00.000Z&to=2026-03-24T13%3A00%3A00.000Z&limit=3", + "/v1/audit?orgId=org_archive&identityId=agent_archive_%E9%9B%AA&action=token.validated&workspaceId=workspace_archive&plane=relayauth&result=allowed&from=2026-03-24T12%3A00%3A00.000Z&to=2026-03-24T13%3A00%3A00.000Z&limit=2", undefined, { Authorization: token }, ), @@ -484,11 +490,16 @@ test("GET /v1/audit returns a typed archive budget continuation that resumes wit assert.equal(firstPage.partial, true); assert.equal(firstPage.hasMore, true); assert.equal(typeof firstPage.nextCursor, "string"); + assert.deepEqual( + firstPage.entries.map((entry) => entry.id), + ["aud_archive_003", "aud_archive_002"], + "the overflow row must be held for the resumed page", + ); assert.deepEqual(firstPage.workBudget, { - d1Pages: 4, - d1Rows: 129, - partitions: 128, - r2Reads: 128, + hotStorePages: 4, + hotStoreRows: 129, + archivePartitions: 128, + archiveReads: 128, }); const crossOrg = await app.request( @@ -513,7 +524,7 @@ test("GET /v1/audit returns a typed archive budget continuation that resumes wit const mismatchedFilter = await app.request( createTestRequest( "GET", - `/v1/audit?orgId=org_archive&identityId=agent_other&action=token.validated&workspaceId=workspace_archive&plane=relayauth&result=allowed&from=2026-03-24T12%3A00%3A00.000Z&to=2026-03-24T13%3A00%3A00.000Z&limit=3&cursor=${encodeURIComponent(firstPage.nextCursor!)}`, + `/v1/audit?orgId=org_archive&identityId=agent_other&action=token.validated&workspaceId=workspace_archive&plane=relayauth&result=allowed&from=2026-03-24T12%3A00%3A00.000Z&to=2026-03-24T13%3A00%3A00.000Z&limit=2&cursor=${encodeURIComponent(firstPage.nextCursor!)}`, undefined, { Authorization: token }, ), @@ -527,7 +538,7 @@ test("GET /v1/audit returns a typed archive budget continuation that resumes wit const second = await app.request( createTestRequest( "GET", - `/v1/audit?orgId=org_archive&identityId=agent_archive&action=token.validated&workspaceId=workspace_archive&plane=relayauth&result=allowed&from=2026-03-24T12%3A00%3A00.000Z&to=2026-03-24T13%3A00%3A00.000Z&limit=3&cursor=${encodeURIComponent(firstPage.nextCursor!)}`, + `/v1/audit?orgId=org_archive&identityId=agent_archive_%E9%9B%AA&action=token.validated&workspaceId=workspace_archive&plane=relayauth&result=allowed&from=2026-03-24T12%3A00%3A00.000Z&to=2026-03-24T13%3A00%3A00.000Z&limit=2&cursor=${encodeURIComponent(firstPage.nextCursor!)}`, undefined, { Authorization: token }, ), @@ -581,6 +592,29 @@ test("GET /v1/audit returns 400 for invalid cursor", async () => { assert.equal(body.error, "invalid cursor"); }); +test("GET /v1/audit returns 400 for an ISO-shaped impossible cursor timestamp", async () => { + const impossibleCursor = Buffer.from( + JSON.stringify({ + version: 1, + kind: "archive_partition", + orgId: "org_test", + timestamp: "2026-99-99T99:99:99Z", + filterKey: "irrelevant-invalid-cursor-filter", + }), + "utf8", + ).toString("base64url"); + const response = await queryAudit( + createAuditSearch({ orgId: "org_test", cursor: impossibleCursor }), + { + claims: { org: "org_test", scopes: ["relayauth:audit:read"] }, + }, + ); + const body = (await response.json()) as { error: string }; + + assert.equal(response.status, 400); + assert.equal(body.error, "invalid cursor"); +}); + test("GET /v1/audit returns 400 for invalid limit", async () => { const response = await queryAudit( createAuditSearch({ orgId: "org_test", limit: "abc" }), diff --git a/packages/server/src/__tests__/dashboard-stats-api.test.ts b/packages/server/src/__tests__/dashboard-stats-api.test.ts index e93bdcd..7be91b5 100644 --- a/packages/server/src/__tests__/dashboard-stats-api.test.ts +++ b/packages/server/src/__tests__/dashboard-stats-api.test.ts @@ -29,10 +29,10 @@ type DashboardStatsResponse = { nextCursor?: string; hasMore?: boolean; workBudget?: { - d1Pages: number; - d1Rows: number; - partitions: number; - r2Reads: number; + hotStorePages: number; + hotStoreRows: number; + archivePartitions: number; + archiveReads: number; }; }; @@ -742,7 +742,12 @@ test("GET /v1/stats exposes a typed, org-scoped bounded count continuation", asy timestamp: "2026-03-24T12:00:00.000Z", filterKey: createDashboardAuditContinuationFilterKey(query), }, - workBudget: { d1Pages: 1, d1Rows: 129, partitions: 128, r2Reads: 0 }, + workBudget: { + hotStorePages: 1, + hotStoreRows: 129, + archivePartitions: 128, + archiveReads: 0, + }, }; }; const app = createTestApp({}, { storage }); @@ -770,10 +775,10 @@ test("GET /v1/stats exposes a typed, org-scoped bounded count continuation", asy assert.equal(firstBody.hasMore, true); assert.equal(typeof firstBody.nextCursor, "string"); assert.deepEqual(firstBody.workBudget, { - d1Pages: 1, - d1Rows: 129, - partitions: 128, - r2Reads: 0, + hotStorePages: 1, + hotStoreRows: 129, + archivePartitions: 128, + archiveReads: 0, }); const mismatchedRange = await app.request( @@ -808,6 +813,48 @@ test("GET /v1/stats exposes a typed, org-scoped bounded count continuation", asy assert.equal(secondBody.partial, undefined); }); +test("GET /v1/stats fails closed instead of advertising an empty continuation", async () => { + const storage = createTestStorage(); + storage.audit.getActionCounts = async (orgId) => ({ + kind: "budget_exhausted", + counts: { + tokensIssued: 1, + tokensRevoked: 0, + tokensRefreshed: 0, + scopeChecks: 0, + scopeDenials: 0, + }, + continuation: { + kind: "archive_partition", + orgId, + timestamp: "", + filterKey: "invalid-continuation", + }, + workBudget: { + hotStorePages: 1, + hotStoreRows: 1, + archivePartitions: 1, + archiveReads: 1, + }, + }); + const app = createTestApp({}, { storage }); + const response = await app.request( + createTestRequest("GET", "/v1/stats", undefined, { + Authorization: `Bearer ${generateTestToken({ + org: "org_invalid_continuation", + scopes: ["relayauth:stats:read"], + })}`, + }), + undefined, + app.bindings, + ); + const body = await assertJsonResponse<{ error: string }>(response, 500); + + assert.equal(body.error, "invalid audit continuation"); + assert.equal("hasMore" in body, false); + assert.equal("nextCursor" in body, false); +}); + test("GET /v1/stats returns 401 without valid auth token", async () => { const app = createTestApp(); const request = createTestRequest("GET", "/v1/stats"); diff --git a/packages/server/src/__tests__/identity-activity-api.test.ts b/packages/server/src/__tests__/identity-activity-api.test.ts index f44b46e..9e61a25 100644 --- a/packages/server/src/__tests__/identity-activity-api.test.ts +++ b/packages/server/src/__tests__/identity-activity-api.test.ts @@ -2,10 +2,12 @@ import assert from "node:assert/strict"; import test from "node:test"; import type { AuditAction, AuditEntry, RelayAuthTokenClaims } from "@relayauth/types"; import type { StoredIdentity } from "../storage/identity-types.js"; +import { createAuditQueryContinuationFilterKey } from "../storage/interface.js"; import { assertJsonResponse, createTestApp, createTestRequest, + createTestStorage, generateTestIdentity, generateTestToken, seedAuditEntries, @@ -31,6 +33,13 @@ type IdentityActivityResponse = { entries: ActivityEntry[]; nextCursor: string | null; hasMore: boolean; + partial?: boolean; + workBudget?: { + hotStorePages: number; + hotStoreRows: number; + archivePartitions: number; + archiveReads: number; + }; sponsorChain: string[]; budgetUsage: IdentityActivityBudgetUsage; subAgents: IdentityActivitySubAgent[]; @@ -822,6 +831,107 @@ test("GET /v1/identities/:id/activity supports cursor-based pagination", async ( assert.equal(secondPage.hasMore, false); }); +test("GET /v1/identities/:id/activity trims a budget overflow row and resumes without duplication", async () => { + const identity = createStoredIdentity({ + id: "agent_activity_archive", + orgId: "org_activity_archive", + }); + const entries = [ + createAuditEntry(3, { + id: "aud_activity_archive_003", + identityId: identity.id, + orgId: identity.orgId, + timestamp: "2026-03-24T12:00:03.000Z", + }), + createAuditEntry(2, { + id: "aud_activity_archive_002", + identityId: identity.id, + orgId: identity.orgId, + timestamp: "2026-03-24T12:00:02.000Z", + }), + createAuditEntry(1, { + id: "aud_activity_archive_001", + identityId: identity.id, + orgId: identity.orgId, + timestamp: "2026-03-24T12:00:01.000Z", + }), + ]; + const storage = createTestStorage(); + await storage.identities.create(identity); + storage.audit.query = async (query) => { + if (query.cursor?.kind === "archive_partition") { + return { kind: "complete", entries: [entries[2]!] }; + } + return { + kind: "budget_exhausted", + entries, + continuation: { + kind: "archive_partition", + orgId: identity.orgId, + timestamp: entries[2]!.timestamp, + inclusive: true, + filterKey: createAuditQueryContinuationFilterKey(query), + }, + workBudget: { + hotStorePages: 1, + hotStoreRows: 3, + archivePartitions: 1, + archiveReads: 1, + }, + }; + }; + const app = createTestApp({}, { storage }); + const authorization = `Bearer ${generateTestToken({ + org: identity.orgId, + scopes: ["relayauth:audit:read"], + })}`; + + const firstResponse = await app.request( + createTestRequest( + "GET", + `/v1/identities/${identity.id}/activity?limit=2`, + undefined, + { Authorization: authorization }, + ), + undefined, + app.bindings, + ); + const firstPage = await assertJsonResponse( + firstResponse, + 200, + ); + assert.deepEqual( + firstPage.entries.map((entry) => entry.id), + ["aud_activity_archive_003", "aud_activity_archive_002"], + ); + assert.equal(firstPage.partial, true); + assert.equal(firstPage.hasMore, true); + assert.equal(typeof firstPage.nextCursor, "string"); + + const secondResponse = await app.request( + createTestRequest( + "GET", + `/v1/identities/${identity.id}/activity?limit=2&cursor=${encodeURIComponent(firstPage.nextCursor!)}`, + undefined, + { Authorization: authorization }, + ), + undefined, + app.bindings, + ); + const secondPage = await assertJsonResponse( + secondResponse, + 200, + ); + const received = [...firstPage.entries, ...secondPage.entries].map( + (entry) => entry.id, + ); + assert.deepEqual( + received, + entries.map((entry) => entry.id), + ); + assert.equal(new Set(received).size, received.length); +}); + test("GET /v1/identities/:id/activity returns 404 when the identity does not exist", async () => { const response = await getIdentityActivity( "agent_activity_missing", diff --git a/packages/server/src/__tests__/tokens-route.test.ts b/packages/server/src/__tests__/tokens-route.test.ts index 57017ed..1f82330 100644 --- a/packages/server/src/__tests__/tokens-route.test.ts +++ b/packages/server/src/__tests__/tokens-route.test.ts @@ -1801,6 +1801,86 @@ test("POST /v1/tokens/refresh", async (t) => { }, ); + await t.test( + "treats a concurrent refresh rotation loser as reuse and leaves no active session", + async () => { + const { app, identity } = await createHarness(); + const { pair, accessClaims, refreshClaims } = + createRs256TokenPair(identity); + await seedActiveTokens(app, identity.id, [ + accessClaims.jti, + refreshClaims.jti, + ]); + await app.storage.DB.prepare( + "UPDATE tokens SET session_id = ? WHERE identity_id = ?", + ) + .bind(refreshClaims.sid, identity.id) + .run(); + + const rotate = app.storage.tokens.rotateIssuedPairWithAudit.bind( + app.storage.tokens, + ); + let waitingRotations = 0; + let releaseRotations!: () => void; + const bothAtRotation = new Promise((resolve) => { + releaseRotations = resolve; + }); + app.storage.tokens.rotateIssuedPairWithAudit = async (input) => { + waitingRotations += 1; + if (waitingRotations === 2) { + releaseRotations(); + } + await bothAtRotation; + await rotate(input); + }; + + const responses = await Promise.all([ + requestRoute(app, "POST", "/v1/tokens/refresh", { + body: { refreshToken: pair.refreshToken }, + }), + requestRoute(app, "POST", "/v1/tokens/refresh", { + body: { refreshToken: pair.refreshToken }, + }), + ]); + assert.equal(waitingRotations, 2); + + const successful = responses.find((response) => response.status === 200); + const rejected = responses.find((response) => response.status === 401); + assert.ok(successful, "one concurrent refresh must win the rotation"); + assert.ok(rejected, "the losing refresh must enter the reuse cascade"); + assert.deepEqual(await rejected.json(), { + error: "Refresh token has been revoked", + }); + + const winningPair = (await successful.json()) as TokenPair; + const winningAccess = decodeJwtJsonSegment( + winningPair.accessToken, + 1, + ); + const winningRefresh = decodeJwtJsonSegment( + winningPair.refreshToken, + 1, + ); + assert.deepEqual( + await app.storage.tokens.listActiveBySessionId(refreshClaims.sid!), + [], + "the reuse cascade must revoke every token in the raced session", + ); + for (const tokenId of [ + accessClaims.jti, + refreshClaims.jti, + winningAccess.jti, + winningRefresh.jti, + ]) { + assert.equal( + (await app.storage.tokens.getById(tokenId))?.status, + "revoked", + `${tokenId} must not remain active after concurrent reuse`, + ); + } + }, + ); + await t.test( "rolls back the new pair and leaves the old JTI active when rotation audit fails", async () => { diff --git a/packages/server/src/routes/audit-export.ts b/packages/server/src/routes/audit-export.ts index 051957c..098b572 100644 --- a/packages/server/src/routes/audit-export.ts +++ b/packages/server/src/routes/audit-export.ts @@ -69,11 +69,15 @@ auditExport.post("/export", requireScope("relayauth:audit:read"), async (c) => { .get("storage") .audit.query(parsed.value, { includeOverflowRow: false }); if (result.kind === "budget_exhausted") { + const nextCursor = encodeAuditCursor(result.continuation); + if (!nextCursor) { + return c.json({ error: "invalid audit continuation" }, 500); + } return c.json( { error: "audit_archive_query_budget_exceeded", entries: result.entries, - nextCursor: encodeAuditCursor(result.continuation), + nextCursor, partial: true, workBudget: result.workBudget, }, diff --git a/packages/server/src/routes/audit-query.ts b/packages/server/src/routes/audit-query.ts index 287e2b2..ed1fce0 100644 --- a/packages/server/src/routes/audit-query.ts +++ b/packages/server/src/routes/audit-query.ts @@ -90,10 +90,14 @@ auditQuery.get("/", requireScope("relayauth:audit:read"), async (c) => { const result = await c.get("storage").audit.query(parsed.value); if (result.kind === "budget_exhausted") { + const nextCursor = encodeAuditCursor(result.continuation); + if (!nextCursor) { + return c.json({ error: "invalid audit continuation" }, 500); + } return c.json( { - entries: result.entries, - nextCursor: encodeAuditCursor(result.continuation), + entries: result.entries.slice(0, parsed.value.limit), + nextCursor, hasMore: true, partial: true, workBudget: result.workBudget, @@ -111,6 +115,9 @@ auditQuery.get("/", requireScope("relayauth:audit:read"), async (c) => { id: page[page.length - 1]?.id ?? "", }) : null; + if (hasMore && !nextCursor) { + return c.json({ error: "invalid audit continuation" }, 500); + } return c.json( { @@ -356,19 +363,69 @@ function parseLimit( } function isIsoTimestamp(value: string): boolean { - return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test( - value, + const match = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2}))$/.exec( + value, + ); + if (!match) { + return false; + } + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6]); + const offsetHour = match[7] === undefined ? 0 : Number(match[7]); + const offsetMinute = match[8] === undefined ? 0 : Number(match[8]); + const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const daysInMonth = [ + 31, + leapYear ? 29 : 28, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ]; + + return ( + month >= 1 && + month <= 12 && + day >= 1 && + day <= (daysInMonth[month - 1] ?? 0) && + hour <= 23 && + minute <= 59 && + second <= 59 && + offsetHour <= 23 && + offsetMinute <= 59 && + Number.isFinite(Date.parse(value)) ); } export function encodeAuditCursor( cursor: AuditQueryCursor | undefined, ): string | null { - if (!cursor?.timestamp) { + if (!cursor?.timestamp || !isIsoTimestamp(cursor.timestamp)) { return null; } if (cursor.kind === "archive_partition") { + if ( + cursor.orgId.trim().length === 0 || + cursor.filterKey.length === 0 || + (cursor.entryCursor !== undefined && + (!isIsoTimestamp(cursor.entryCursor.timestamp) || + cursor.entryCursor.id.trim().length === 0)) + ) { + return null; + } return toBase64Url( JSON.stringify({ version: 1, @@ -487,7 +544,12 @@ function isRecord(value: unknown): value is Record { } function toBase64Url(value: string): string { - return btoa(value) + const bytes = new TextEncoder().encode(value); + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary) .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/g, ""); @@ -499,7 +561,9 @@ function fromBase64Url(value: string): string { normalized.length + ((4 - (normalized.length % 4)) % 4), "=", ); - return atob(padded); + const binary = atob(padded); + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); } export default auditQuery; diff --git a/packages/server/src/routes/dashboard-stats.ts b/packages/server/src/routes/dashboard-stats.ts index f944472..d4f20f6 100644 --- a/packages/server/src/routes/dashboard-stats.ts +++ b/packages/server/src/routes/dashboard-stats.ts @@ -27,10 +27,10 @@ type DashboardStatsResponse = { nextCursor?: string; hasMore?: true; workBudget?: { - d1Pages: number; - d1Rows: number; - partitions: number; - r2Reads: number; + hotStorePages: number; + hotStoreRows: number; + archivePartitions: number; + archiveReads: number; }; }; @@ -108,6 +108,13 @@ dashboardStats.get("/", async (c) => { ]); const auditCounts = auditResult.counts; + const nextCursor = + auditResult.kind === "budget_exhausted" + ? encodeAuditCursor(auditResult.continuation) + : null; + if (auditResult.kind === "budget_exhausted" && !nextCursor) { + return c.json({ error: "invalid audit continuation" }, 500); + } const response: DashboardStatsResponse = { tokensIssued: auditCounts.tokensIssued, tokensRevoked: auditCounts.tokensRevoked, @@ -130,7 +137,7 @@ dashboardStats.get("/", async (c) => { ...(auditResult.kind === "budget_exhausted" ? { partial: true as const, - nextCursor: encodeAuditCursor(auditResult.continuation) ?? "", + nextCursor: nextCursor!, hasMore: true as const, } : {}), diff --git a/packages/server/src/routes/identity-activity.ts b/packages/server/src/routes/identity-activity.ts index d1110df..ce5cd37 100644 --- a/packages/server/src/routes/identity-activity.ts +++ b/packages/server/src/routes/identity-activity.ts @@ -85,10 +85,14 @@ identityActivity.get( const result = await storage.audit.query(parsed.value); if (result.kind === "budget_exhausted") { + const nextCursor = encodeAuditCursor(result.continuation); + if (!nextCursor) { + return c.json({ error: "invalid audit continuation" }, 500); + } return c.json( { - entries: result.entries, - nextCursor: encodeAuditCursor(result.continuation), + entries: result.entries.slice(0, parsed.value.limit), + nextCursor, hasMore: true, partial: true, workBudget: result.workBudget, diff --git a/packages/server/src/routes/tokens.ts b/packages/server/src/routes/tokens.ts index 15a1f10..df6a26b 100644 --- a/packages/server/src/routes/tokens.ts +++ b/packages/server/src/routes/tokens.ts @@ -27,10 +27,11 @@ import { signToken } from "../lib/sign.js"; import { verifyRs256Token } from "../lib/token-verifier.js"; import type { StoredIdentity } from "../storage/identity-types.js"; import type { StoredApiKey } from "../storage/api-key-types.js"; -import type { - AuditLogWriteEntry, - AuthStorage, - StoredTokenRecord, +import { + isStorageError, + type AuditLogWriteEntry, + type AuthStorage, + type StoredTokenRecord, } from "../storage/index.js"; type IssueTokenRequest = { @@ -727,38 +728,49 @@ tokens.post("/refresh", async (c) => { ); } - const tokenPair = await issueTokenPair(storage, c.env, identity, { - deferTask: c.get("deferTask"), - accessScopes: isDerivedClaims(verification.claims) - ? parseMetaStringArray( - verification.claims.meta?.accessScopes, - identity.scopes, - ) - : normalizeScopes(undefined, identity.scopes), - accessAudience: isDerivedClaims(verification.claims) - ? parseMetaStringArray( - verification.claims.meta?.accessAudience, - normalizeAudience(undefined, identity.scopes), - ) - : normalizeAudience(undefined, identity.scopes), - accessExpiresIn: isDerivedClaims(verification.claims) - ? MAX_AGENT_ACCESS_TOKEN_TTL_SECONDS - : DEFAULT_ACCESS_TOKEN_TTL_SECONDS, - refreshTokenTtlSeconds: parseMetaRefreshTokenTtl(verification.claims.meta), - sessionId: presentedSid, - action: "token.refreshed", - parentTokenId: normalizeOptionalString(verification.claims.parentTokenId), - meta: verification.claims.meta, - expiresNotAfter: delegationHorizon.epochSeconds, - wrapAccessToken: isDerivedClaims(verification.claims), - wrapRefreshToken: isDerivedClaims(verification.claims), - tokenIdPrefix: tokenPrefixForClaims(verification.claims), - previousRefreshToken: { - id: presentedJti, - identityId: identity.id, - expiresAt: verification.claims.exp, - }, - }); + let tokenPair: TokenPair; + try { + tokenPair = await issueTokenPair(storage, c.env, identity, { + deferTask: c.get("deferTask"), + accessScopes: isDerivedClaims(verification.claims) + ? parseMetaStringArray( + verification.claims.meta?.accessScopes, + identity.scopes, + ) + : normalizeScopes(undefined, identity.scopes), + accessAudience: isDerivedClaims(verification.claims) + ? parseMetaStringArray( + verification.claims.meta?.accessAudience, + normalizeAudience(undefined, identity.scopes), + ) + : normalizeAudience(undefined, identity.scopes), + accessExpiresIn: isDerivedClaims(verification.claims) + ? MAX_AGENT_ACCESS_TOKEN_TTL_SECONDS + : DEFAULT_ACCESS_TOKEN_TTL_SECONDS, + refreshTokenTtlSeconds: parseMetaRefreshTokenTtl( + verification.claims.meta, + ), + sessionId: presentedSid, + action: "token.refreshed", + parentTokenId: normalizeOptionalString(verification.claims.parentTokenId), + meta: verification.claims.meta, + expiresNotAfter: delegationHorizon.epochSeconds, + wrapAccessToken: isDerivedClaims(verification.claims), + wrapRefreshToken: isDerivedClaims(verification.claims), + tokenIdPrefix: tokenPrefixForClaims(verification.claims), + previousRefreshToken: { + id: presentedJti, + identityId: identity.id, + expiresAt: verification.claims.exp, + }, + }); + } catch (error) { + if (isStorageError(error) && error.code === "refresh_token_not_active") { + await cascadeRevokeSession(storage, identity, presentedSid, presentedJti); + return c.json({ error: "Refresh token has been revoked" }, 401); + } + throw error; + } await populateRevocationCache( storage, diff --git a/packages/server/src/storage/interface.ts b/packages/server/src/storage/interface.ts index 4da4237..b7673ca 100644 --- a/packages/server/src/storage/interface.ts +++ b/packages/server/src/storage/interface.ts @@ -103,10 +103,10 @@ export type AuditArchivePartitionCursor = { export type AuditQueryCursor = AuditEntryCursor | AuditArchivePartitionCursor; export type AuditQueryWorkBudget = { - d1Pages: number; - d1Rows: number; - partitions: number; - r2Reads: number; + hotStorePages: number; + hotStoreRows: number; + archivePartitions: number; + archiveReads: number; }; export type AuditQueryInput = { From 62ea1142fbf1124353abdfbac807b063e6bb894f Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Fri, 31 Jul 2026 07:21:13 +0200 Subject: [PATCH 11/20] fix(server): share audit timestamp validation --- .../server/src/__tests__/dashboard-stats-api.test.ts | 11 +++++++++++ packages/server/src/routes/audit-query.ts | 2 +- packages/server/src/routes/dashboard-stats.ts | 12 +++++------- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/server/src/__tests__/dashboard-stats-api.test.ts b/packages/server/src/__tests__/dashboard-stats-api.test.ts index 7be91b5..05b3f64 100644 --- a/packages/server/src/__tests__/dashboard-stats-api.test.ts +++ b/packages/server/src/__tests__/dashboard-stats-api.test.ts @@ -679,6 +679,17 @@ test("GET /v1/stats supports time range filter via from/to query params", async assert.equal(body.scopeDenials, 0); }); +test("GET /v1/stats rejects ISO-shaped impossible from/to timestamps", async () => { + for (const field of ["from", "to"] as const) { + const response = await getDashboardStats( + createStatsSearch({ [field]: "2026-99-99T99:99:99Z" }), + ); + const body = await assertJsonResponse<{ error: string }>(response, 400); + + assert.equal(body.error, `${field} must be an ISO 8601 timestamp`); + } +}); + test("GET /v1/stats is scoped to the caller's org", async () => { const response = await getDashboardStats("", { claims: { diff --git a/packages/server/src/routes/audit-query.ts b/packages/server/src/routes/audit-query.ts index ed1fce0..abec015 100644 --- a/packages/server/src/routes/audit-query.ts +++ b/packages/server/src/routes/audit-query.ts @@ -362,7 +362,7 @@ function parseLimit( return Math.min(parsed, maxLimit); } -function isIsoTimestamp(value: string): boolean { +export function isIsoTimestamp(value: string): boolean { const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2}))$/.exec( value, diff --git a/packages/server/src/routes/dashboard-stats.ts b/packages/server/src/routes/dashboard-stats.ts index d4f20f6..8e21fb9 100644 --- a/packages/server/src/routes/dashboard-stats.ts +++ b/packages/server/src/routes/dashboard-stats.ts @@ -2,7 +2,11 @@ import { Hono } from "hono"; import type { AppEnv } from "../env.js"; import { requireScope } from "../middleware/scope.js"; -import { decodeAuditCursor, encodeAuditCursor } from "./audit-query.js"; +import { + decodeAuditCursor, + encodeAuditCursor, + isIsoTimestamp, +} from "./audit-query.js"; import { createDashboardAuditContinuationFilterKey } from "../storage/interface.js"; type ScopeContextVars = { @@ -302,10 +306,4 @@ function normalizeQueryValue(value: string | undefined): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } -function isIsoTimestamp(value: string): boolean { - return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test( - value, - ); -} - export default dashboardStats; From 0a8d7d7bec76a74d061d026c07622d312ba78071 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Fri, 31 Jul 2026 07:37:38 +0200 Subject: [PATCH 12/20] test(server): harden activity continuation coverage --- .../__tests__/identity-activity-api.test.ts | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/server/src/__tests__/identity-activity-api.test.ts b/packages/server/src/__tests__/identity-activity-api.test.ts index 9e61a25..41a5961 100644 --- a/packages/server/src/__tests__/identity-activity-api.test.ts +++ b/packages/server/src/__tests__/identity-activity-api.test.ts @@ -858,10 +858,20 @@ test("GET /v1/identities/:id/activity trims a budget overflow row and resumes wi ]; const storage = createTestStorage(); await storage.identities.create(identity); + let auditQueryCalls = 0; + let continuationFilterKey: string | undefined; storage.audit.query = async (query) => { + auditQueryCalls += 1; + assert.equal(query.orgId, identity.orgId); + assert.equal(query.identityId, identity.id); if (query.cursor?.kind === "archive_partition") { + assert.equal(query.cursor.orgId, identity.orgId); + assert.equal(query.cursor.timestamp, entries[2]!.timestamp); + assert.equal(query.cursor.inclusive, true); + assert.equal(query.cursor.filterKey, continuationFilterKey); return { kind: "complete", entries: [entries[2]!] }; } + continuationFilterKey = createAuditQueryContinuationFilterKey(query); return { kind: "budget_exhausted", entries, @@ -870,7 +880,7 @@ test("GET /v1/identities/:id/activity trims a budget overflow row and resumes wi orgId: identity.orgId, timestamp: entries[2]!.timestamp, inclusive: true, - filterKey: createAuditQueryContinuationFilterKey(query), + filterKey: continuationFilterKey, }, workBudget: { hotStorePages: 1, @@ -907,6 +917,47 @@ test("GET /v1/identities/:id/activity trims a budget overflow row and resumes wi assert.equal(firstPage.partial, true); assert.equal(firstPage.hasMore, true); assert.equal(typeof firstPage.nextCursor, "string"); + assert.deepEqual(firstPage.workBudget, { + hotStorePages: 1, + hotStoreRows: 3, + archivePartitions: 1, + archiveReads: 1, + }); + + const crossOrgResponse = await app.request( + createTestRequest( + "GET", + `/v1/identities/${identity.id}/activity?limit=2&cursor=${encodeURIComponent(firstPage.nextCursor!)}`, + undefined, + { + Authorization: `Bearer ${generateTestToken({ + org: "org_activity_other", + scopes: ["relayauth:audit:read"], + })}`, + }, + ), + undefined, + app.bindings, + ); + assert.deepEqual( + await assertJsonResponse<{ error: string }>(crossOrgResponse, 404), + { error: "identity_not_found" }, + ); + + const mismatchedFilterResponse = await app.request( + createTestRequest( + "GET", + `/v1/identities/${identity.id}/activity?limit=2&action=token.refreshed&cursor=${encodeURIComponent(firstPage.nextCursor!)}`, + undefined, + { Authorization: authorization }, + ), + undefined, + app.bindings, + ); + assert.deepEqual( + await assertJsonResponse<{ error: string }>(mismatchedFilterResponse, 400), + { error: "invalid cursor" }, + ); const secondResponse = await app.request( createTestRequest( @@ -930,6 +981,11 @@ test("GET /v1/identities/:id/activity trims a budget overflow row and resumes wi entries.map((entry) => entry.id), ); assert.equal(new Set(received).size, received.length); + assert.equal( + auditQueryCalls, + 2, + "only the initial and valid resumed queries may reach audit storage", + ); }); test("GET /v1/identities/:id/activity returns 404 when the identity does not exist", async () => { From 950652e4a12c4247df2990fd14e0dc585bb621ca Mon Sep 17 00:00:00 2001 From: Miya Date: Fri, 31 Jul 2026 08:36:34 +0200 Subject: [PATCH 13/20] fix(server): validate archive cursor bounds in storage --- .../src/__tests__/storage-sqlite.test.ts | 102 ++++++++++++++++++ packages/server/src/routes/audit-query.ts | 55 +--------- packages/server/src/storage/interface.ts | 99 +++++++++++++++++ packages/server/src/storage/sqlite.ts | 30 +++--- 4 files changed, 220 insertions(+), 66 deletions(-) diff --git a/packages/server/src/__tests__/storage-sqlite.test.ts b/packages/server/src/__tests__/storage-sqlite.test.ts index 60227e2..acf2244 100644 --- a/packages/server/src/__tests__/storage-sqlite.test.ts +++ b/packages/server/src/__tests__/storage-sqlite.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test, { type TestContext } from "node:test"; import type { Policy, Role } from "@relayauth/types"; +import { StorageError } from "../storage/interface.js"; import { createSqliteStorage } from "../storage/sqlite.js"; function createTempStorage(t: TestContext) { @@ -19,6 +20,107 @@ function createTempStorage(t: TestContext) { return { directory, dbPath, storage }; } +async function assertInclusiveArchiveCursorQuery( + storage: ReturnType, +) { + for (const [id, timestamp] of [ + ["aud_after_partition", "2026-03-27T12:01:00.000Z"], + ["aud_in_partition", "2026-03-27T12:00:59.000Z"], + ["aud_partition_start", "2026-03-27T12:00:00.000Z"], + ["aud_before_partition", "2026-03-27T11:59:59.000Z"], + ]) { + await storage.audit.write({ + id, + action: "scope.checked", + identityId: "agent_audit_cursor", + orgId: "org_audit_cursor", + result: "allowed", + timestamp, + }); + } + + const result = await storage.audit.query( + { + orgId: "org_audit_cursor", + limit: 10, + cursor: { + kind: "archive_partition", + orgId: "org_audit_cursor", + timestamp: "2026-03-27T12:00:00.000Z", + inclusive: true, + filterKey: "storage-contract-test", + }, + }, + { includeOverflowRow: false }, + ); + + assert.deepEqual( + result.entries.map((entry) => entry.id), + [ + "aud_in_partition", + "aud_partition_start", + "aud_before_partition", + ], + ); +} + +test("TestSqliteAuditQuery rejects impossible archive timestamps before opening storage", async (t) => { + const { dbPath, storage } = createTempStorage(t); + + for (const timestamp of [ + "2026-02-29T12:00:00.000Z", + "2026-03-27T12:00:00.000+24:00", + ]) { + await assert.rejects( + () => + storage.audit.query({ + orgId: "org_audit_cursor", + limit: 10, + cursor: { + kind: "archive_partition", + orgId: "org_audit_cursor", + timestamp, + inclusive: true, + filterKey: "storage-contract-test", + }, + }), + (error: unknown) => { + assert.equal(error instanceof RangeError, false); + assert.ok(error instanceof StorageError); + assert.equal(error.name, "StorageError"); + assert.equal(error.code, "invalid_input"); + assert.equal(error.status, 400); + return true; + }, + ); + assert.equal( + existsSync(dbPath), + false, + "invalid archive cursors must not open the SQLite store", + ); + } +}); + +test("TestSqliteAuditQuery applies the inclusive archive cursor minute bound", async (t) => { + const { dbPath, storage } = createTempStorage(t); + await assertInclusiveArchiveCursorQuery(storage); + assert.equal(existsSync(dbPath), true, "expected the SQLite query path"); +}); + +test("TestSqliteAuditQuery applies the inclusive archive cursor minute bound in memory", async (t) => { + const directory = mkdtempSync(join(tmpdir(), "relayauth-memory-")); + // A directory cannot be opened as a SQLite database, so the provider takes + // its supported in-memory fallback path. + const storage = createSqliteStorage(directory); + + t.after(async () => { + await storage.close(); + rmSync(directory, { recursive: true, force: true }); + }); + + await assertInclusiveArchiveCursorQuery(storage); +}); + test("TestSqliteIdentityCRUD", async (t) => { const { storage } = createTempStorage(t); diff --git a/packages/server/src/routes/audit-query.ts b/packages/server/src/routes/audit-query.ts index abec015..06c781c 100644 --- a/packages/server/src/routes/audit-query.ts +++ b/packages/server/src/routes/audit-query.ts @@ -4,6 +4,8 @@ import { Hono } from "hono"; import type { AppEnv } from "../env.js"; import { createAuditQueryContinuationFilterKey, + getAuditArchiveCursorUpperBound, + isValidAuditTimestamp, type AuditQueryCursor, } from "../storage/interface.js"; import { requireScope } from "../middleware/scope.js"; @@ -248,13 +250,7 @@ export function buildAuditQuery( if (params.cursor?.kind === "archive_partition") { clauses.push("timestamp < ?"); - values.push( - params.cursor.inclusive && !params.cursor.chunk - ? new Date( - new Date(params.cursor.timestamp).getTime() + 60_000, - ).toISOString() - : params.cursor.timestamp, - ); + values.push(getAuditArchiveCursorUpperBound(params.cursor)); if (params.cursor.entryCursor) { clauses.push("(timestamp < ? OR (timestamp = ? AND id < ?))"); values.push( @@ -363,50 +359,7 @@ function parseLimit( } export function isIsoTimestamp(value: string): boolean { - const match = - /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2}))$/.exec( - value, - ); - if (!match) { - return false; - } - - const year = Number(match[1]); - const month = Number(match[2]); - const day = Number(match[3]); - const hour = Number(match[4]); - const minute = Number(match[5]); - const second = Number(match[6]); - const offsetHour = match[7] === undefined ? 0 : Number(match[7]); - const offsetMinute = match[8] === undefined ? 0 : Number(match[8]); - const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); - const daysInMonth = [ - 31, - leapYear ? 29 : 28, - 31, - 30, - 31, - 30, - 31, - 31, - 30, - 31, - 30, - 31, - ]; - - return ( - month >= 1 && - month <= 12 && - day >= 1 && - day <= (daysInMonth[month - 1] ?? 0) && - hour <= 23 && - minute <= 59 && - second <= 59 && - offsetHour <= 23 && - offsetMinute <= 59 && - Number.isFinite(Date.parse(value)) - ); + return isValidAuditTimestamp(value); } export function encodeAuditCursor( diff --git a/packages/server/src/storage/interface.ts b/packages/server/src/storage/interface.ts index b7673ca..d2ffd7a 100644 --- a/packages/server/src/storage/interface.ts +++ b/packages/server/src/storage/interface.ts @@ -465,6 +465,105 @@ export function isStorageError(error: unknown): error is StorageError { return error instanceof StorageError; } +const ISO_8601_TIMESTAMP = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2}))$/; + +/** + * Validate timestamp input before it becomes part of an audit storage query. + * Date.parse alone accepts impossible calendar dates on some runtimes. + */ +export function isValidAuditTimestamp(value: unknown): value is string { + if (typeof value !== "string") { + return false; + } + + const match = ISO_8601_TIMESTAMP.exec(value); + if (!match) { + return false; + } + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6]); + const offsetHour = match[7] === undefined ? 0 : Number(match[7]); + const offsetMinute = match[8] === undefined ? 0 : Number(match[8]); + const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const daysInMonth = [ + 31, + leapYear ? 29 : 28, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ]; + + return ( + month >= 1 && + month <= 12 && + day >= 1 && + day <= (daysInMonth[month - 1] ?? 0) && + hour <= 23 && + minute <= 59 && + second <= 59 && + offsetHour <= 23 && + offsetMinute <= 59 && + Number.isFinite(Date.parse(value)) + ); +} + +/** + * Return the exclusive hot-store upper bound for an archive partition cursor. + * This is deliberately part of the storage contract: direct callers bypass + * HTTP validation, and both SQL and in-memory implementations need identical + * inclusive-minute arithmetic. + */ +export function getAuditArchiveCursorUpperBound( + cursor: AuditArchivePartitionCursor, +): string { + if (!isValidAuditTimestamp(cursor.timestamp)) { + throw new StorageError( + "archive cursor timestamp must be an ISO 8601 timestamp", + 400, + "invalid_input", + ); + } + + if ( + cursor.entryCursor && + !isValidAuditTimestamp(cursor.entryCursor.timestamp) + ) { + throw new StorageError( + "archive entry cursor timestamp must be an ISO 8601 timestamp", + 400, + "invalid_input", + ); + } + + if (!cursor.inclusive || cursor.chunk) { + return cursor.timestamp; + } + + const upperBound = new Date(Date.parse(cursor.timestamp) + 60_000); + if (!Number.isFinite(upperBound.getTime())) { + throw new StorageError( + "archive cursor timestamp must be an ISO 8601 timestamp", + 400, + "invalid_input", + ); + } + + return upperBound.toISOString(); +} + export type { CreateApiKeyInput, IdentityBudget, diff --git a/packages/server/src/storage/sqlite.ts b/packages/server/src/storage/sqlite.ts index 81f512f..519370d 100644 --- a/packages/server/src/storage/sqlite.ts +++ b/packages/server/src/storage/sqlite.ts @@ -59,7 +59,10 @@ import type { RoleUpdate, WorkspaceContextRecord, } from "./interface.js"; -import { StorageError } from "./interface.js"; +import { + getAuditArchiveCursorUpperBound, + StorageError, +} from "./interface.js"; import { emitObserverEvent, now as observerNow } from "../lib/events.js"; const DEFAULT_DB_PATH = ".relay/relayauth.db"; @@ -3533,9 +3536,17 @@ function normalizeAuditWriteEntry(entry: AuditLogWriteEntry): AuditEntryRecord { } function normalizeAuditQuery(query: AuditQueryInput): AuditQueryInput { + const orgId = requireString(query.orgId, "orgId is required"); + + if (query.cursor?.kind === "archive_partition") { + // Validate before the provider is opened so invalid direct storage calls + // cannot reach a SQL statement or the in-memory scan. + getAuditArchiveCursorUpperBound(query.cursor); + } + return { ...query, - orgId: requireString(query.orgId, "orgId is required"), + orgId, limit: normalizeAuditLimit(query.limit), }; } @@ -3594,13 +3605,7 @@ function buildAuditQuerySql( } if (query.cursor?.kind === "archive_partition") { clauses.push("timestamp < ?"); - params.push( - query.cursor.inclusive && !query.cursor.chunk - ? new Date( - new Date(query.cursor.timestamp).getTime() + 60_000, - ).toISOString() - : query.cursor.timestamp, - ); + params.push(getAuditArchiveCursorUpperBound(query.cursor)); if (query.cursor.entryCursor) { clauses.push("(timestamp < ? OR (timestamp = ? AND id < ?))"); params.push( @@ -3748,12 +3753,7 @@ function matchesAuditQuery( return false; } if (query.cursor?.kind === "archive_partition") { - const upper = - query.cursor.inclusive && !query.cursor.chunk - ? new Date( - new Date(query.cursor.timestamp).getTime() + 60_000, - ).toISOString() - : query.cursor.timestamp; + const upper = getAuditArchiveCursorUpperBound(query.cursor); if (entry.timestamp >= upper) { return false; } From 7da6b427e986793fcd8d1adaeb863d12bb5e4fc1 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Fri, 31 Jul 2026 09:21:56 +0200 Subject: [PATCH 14/20] fix(server): normalize audit cursor boundaries --- .../src/__tests__/storage-sqlite.test.ts | 172 ++++++++++++++++-- packages/server/src/routes/audit-query.ts | 9 +- packages/server/src/storage/interface.ts | 59 +++--- packages/server/src/storage/sqlite.ts | 41 ++++- 4 files changed, 228 insertions(+), 53 deletions(-) diff --git a/packages/server/src/__tests__/storage-sqlite.test.ts b/packages/server/src/__tests__/storage-sqlite.test.ts index acf2244..ad96f91 100644 --- a/packages/server/src/__tests__/storage-sqlite.test.ts +++ b/packages/server/src/__tests__/storage-sqlite.test.ts @@ -4,7 +4,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test, { type TestContext } from "node:test"; import type { Policy, Role } from "@relayauth/types"; -import { StorageError } from "../storage/interface.js"; +import { + StorageError, + type AuditArchivePartitionCursor, +} from "../storage/interface.js"; import { createSqliteStorage } from "../storage/sqlite.js"; function createTempStorage(t: TestContext) { @@ -20,6 +23,16 @@ function createTempStorage(t: TestContext) { return { directory, dbPath, storage }; } +function createForcedMemoryStorage(t: TestContext) { + const storage = createSqliteStorage(undefined, { forceMemory: true }); + + t.after(async () => { + await storage.close(); + }); + + return storage; +} + async function assertInclusiveArchiveCursorQuery( storage: ReturnType, ) { @@ -56,14 +69,142 @@ async function assertInclusiveArchiveCursorQuery( assert.deepEqual( result.entries.map((entry) => entry.id), - [ - "aud_in_partition", - "aud_partition_start", - "aud_before_partition", - ], + ["aud_in_partition", "aud_partition_start", "aud_before_partition"], ); } +async function assertOffsetArchiveCursorParity( + storage: ReturnType, +) { + for (const [id, timestamp] of [ + ["aud_after_partition", "2026-03-27T12:01:00.000Z"], + ["aud_in_partition", "2026-03-27T12:00:30.000Z"], + ["aud_same_c", "2026-03-27T12:00:00.000Z"], + ["aud_same_b", "2026-03-27T12:00:00.000Z"], + ["aud_same_a", "2026-03-27T12:00:00.000Z"], + ["aud_before_partition", "2026-03-27T11:59:59.000Z"], + ]) { + await storage.audit.write({ + id, + action: "scope.checked", + identityId: "agent_audit_offset_cursor", + orgId: "org_audit_offset_cursor", + result: "allowed", + timestamp, + }); + } + + const baseCursor = { + kind: "archive_partition" as const, + orgId: "org_audit_offset_cursor", + filterKey: "storage-offset-contract-test", + }; + const cases: Array<{ + name: string; + utcCursor: AuditArchivePartitionCursor; + offsetCursor: AuditArchivePartitionCursor; + expectedIds: string[]; + }> = [ + { + name: "non-inclusive", + utcCursor: { + ...baseCursor, + timestamp: "2026-03-27T12:00:00.000Z", + }, + offsetCursor: { + ...baseCursor, + timestamp: "2026-03-27T13:00:00.000+01:00", + }, + expectedIds: ["aud_before_partition"], + }, + { + name: "chunked", + utcCursor: { + ...baseCursor, + timestamp: "2026-03-27T12:00:00.000Z", + inclusive: true, + chunk: { key: "chunk-2", sha256: "chunk-2-sha" }, + }, + offsetCursor: { + ...baseCursor, + timestamp: "2026-03-27T13:00:00.000+01:00", + inclusive: true, + chunk: { key: "chunk-2", sha256: "chunk-2-sha" }, + }, + expectedIds: ["aud_before_partition"], + }, + { + name: "entry cursor", + utcCursor: { + ...baseCursor, + timestamp: "2026-03-27T13:00:00.000Z", + entryCursor: { + timestamp: "2026-03-27T12:00:00.000Z", + id: "aud_same_b", + }, + }, + offsetCursor: { + ...baseCursor, + timestamp: "2026-03-27T14:00:00.000+01:00", + entryCursor: { + timestamp: "2026-03-27T13:00:00.000+01:00", + id: "aud_same_b", + }, + }, + expectedIds: ["aud_same_a", "aud_before_partition"], + }, + { + name: "inclusive", + utcCursor: { + ...baseCursor, + timestamp: "2026-03-27T12:00:00.000Z", + inclusive: true, + }, + offsetCursor: { + ...baseCursor, + timestamp: "2026-03-27T13:00:00.000+01:00", + inclusive: true, + }, + expectedIds: [ + "aud_in_partition", + "aud_same_c", + "aud_same_b", + "aud_same_a", + "aud_before_partition", + ], + }, + ]; + + for (const testCase of cases) { + const utcResult = await storage.audit.query( + { + orgId: "org_audit_offset_cursor", + limit: 10, + cursor: testCase.utcCursor, + }, + { includeOverflowRow: false }, + ); + const offsetResult = await storage.audit.query( + { + orgId: "org_audit_offset_cursor", + limit: 10, + cursor: testCase.offsetCursor, + }, + { includeOverflowRow: false }, + ); + const utcIds = utcResult.entries.map((entry) => entry.id); + const offsetIds = offsetResult.entries.map((entry) => entry.id); + + assert.deepEqual(offsetIds, utcIds, `${testCase.name} UTC parity`); + assert.deepEqual(offsetIds, testCase.expectedIds, testCase.name); + assert.equal( + new Set(offsetIds).size, + offsetIds.length, + `${testCase.name} must not duplicate equivalent instants`, + ); + } +} + test("TestSqliteAuditQuery rejects impossible archive timestamps before opening storage", async (t) => { const { dbPath, storage } = createTempStorage(t); @@ -108,17 +249,18 @@ test("TestSqliteAuditQuery applies the inclusive archive cursor minute bound", a }); test("TestSqliteAuditQuery applies the inclusive archive cursor minute bound in memory", async (t) => { - const directory = mkdtempSync(join(tmpdir(), "relayauth-memory-")); - // A directory cannot be opened as a SQLite database, so the provider takes - // its supported in-memory fallback path. - const storage = createSqliteStorage(directory); + const storage = createForcedMemoryStorage(t); + await assertInclusiveArchiveCursorQuery(storage); +}); - t.after(async () => { - await storage.close(); - rmSync(directory, { recursive: true, force: true }); - }); +test("TestSqliteAuditQuery normalizes offset archive cursor boundaries", async (t) => { + const { storage } = createTempStorage(t); + await assertOffsetArchiveCursorParity(storage); +}); - await assertInclusiveArchiveCursorQuery(storage); +test("TestSqliteAuditQuery normalizes offset archive cursor boundaries in forced memory", async (t) => { + const storage = createForcedMemoryStorage(t); + await assertOffsetArchiveCursorParity(storage); }); test("TestSqliteIdentityCRUD", async (t) => { diff --git a/packages/server/src/routes/audit-query.ts b/packages/server/src/routes/audit-query.ts index 06c781c..bb5f2c8 100644 --- a/packages/server/src/routes/audit-query.ts +++ b/packages/server/src/routes/audit-query.ts @@ -4,8 +4,8 @@ import { Hono } from "hono"; import type { AppEnv } from "../env.js"; import { createAuditQueryContinuationFilterKey, - getAuditArchiveCursorUpperBound, isValidAuditTimestamp, + normalizeAuditArchiveCursorBoundaries, type AuditQueryCursor, } from "../storage/interface.js"; import { requireScope } from "../middleware/scope.js"; @@ -249,13 +249,14 @@ export function buildAuditQuery( } if (params.cursor?.kind === "archive_partition") { + const boundaries = normalizeAuditArchiveCursorBoundaries(params.cursor); clauses.push("timestamp < ?"); - values.push(getAuditArchiveCursorUpperBound(params.cursor)); + values.push(boundaries.upperBound); if (params.cursor.entryCursor) { clauses.push("(timestamp < ? OR (timestamp = ? AND id < ?))"); values.push( - params.cursor.entryCursor.timestamp, - params.cursor.entryCursor.timestamp, + boundaries.entryCursorTimestamp, + boundaries.entryCursorTimestamp, params.cursor.entryCursor.id, ); } diff --git a/packages/server/src/storage/interface.ts b/packages/server/src/storage/interface.ts index d2ffd7a..a9dc0f0 100644 --- a/packages/server/src/storage/interface.ts +++ b/packages/server/src/storage/interface.ts @@ -524,11 +524,15 @@ export function isValidAuditTimestamp(value: unknown): value is string { * Return the exclusive hot-store upper bound for an archive partition cursor. * This is deliberately part of the storage contract: direct callers bypass * HTTP validation, and both SQL and in-memory implementations need identical - * inclusive-minute arithmetic. + * UTC normalization and inclusive-minute arithmetic. */ -export function getAuditArchiveCursorUpperBound( +export function normalizeAuditArchiveCursorBoundaries( cursor: AuditArchivePartitionCursor, -): string { +): { + cursorTimestamp: string; + upperBound: string; + entryCursorTimestamp?: string; +} { if (!isValidAuditTimestamp(cursor.timestamp)) { throw new StorageError( "archive cursor timestamp must be an ISO 8601 timestamp", @@ -537,31 +541,38 @@ export function getAuditArchiveCursorUpperBound( ); } - if ( - cursor.entryCursor && - !isValidAuditTimestamp(cursor.entryCursor.timestamp) - ) { - throw new StorageError( - "archive entry cursor timestamp must be an ISO 8601 timestamp", - 400, - "invalid_input", - ); - } + const cursorTimestamp = new Date(Date.parse(cursor.timestamp)).toISOString(); + let entryCursorTimestamp: string | undefined; - if (!cursor.inclusive || cursor.chunk) { - return cursor.timestamp; + if (cursor.entryCursor) { + if (!isValidAuditTimestamp(cursor.entryCursor.timestamp)) { + throw new StorageError( + "archive entry cursor timestamp must be an ISO 8601 timestamp", + 400, + "invalid_input", + ); + } + entryCursorTimestamp = new Date( + Date.parse(cursor.entryCursor.timestamp), + ).toISOString(); } - const upperBound = new Date(Date.parse(cursor.timestamp) + 60_000); - if (!Number.isFinite(upperBound.getTime())) { - throw new StorageError( - "archive cursor timestamp must be an ISO 8601 timestamp", - 400, - "invalid_input", - ); - } + const upperBound = + cursor.inclusive && !cursor.chunk + ? new Date(Date.parse(cursorTimestamp) + 60_000).toISOString() + : cursorTimestamp; + + return { + cursorTimestamp, + upperBound, + ...(entryCursorTimestamp ? { entryCursorTimestamp } : {}), + }; +} - return upperBound.toISOString(); +export function getAuditArchiveCursorUpperBound( + cursor: AuditArchivePartitionCursor, +): string { + return normalizeAuditArchiveCursorBoundaries(cursor).upperBound; } export type { diff --git a/packages/server/src/storage/sqlite.ts b/packages/server/src/storage/sqlite.ts index 519370d..a7d8955 100644 --- a/packages/server/src/storage/sqlite.ts +++ b/packages/server/src/storage/sqlite.ts @@ -61,6 +61,7 @@ import type { } from "./interface.js"; import { getAuditArchiveCursorUpperBound, + normalizeAuditArchiveCursorBoundaries, StorageError, } from "./interface.js"; import { emitObserverEvent, now as observerNow } from "../lib/events.js"; @@ -98,6 +99,11 @@ export type SqliteStorage = AuthStorage & { close(): Promise | void; }; +export type SqliteStorageOptions = { + /** Select the pure in-memory fallback explicitly for backend parity tests. */ + forceMemory?: boolean; +}; + // Schema lives in packages/server/src/db/migrations/*.sql and is applied via // `@relayauth/migrate`. // @@ -802,8 +808,14 @@ const dynamicImport = Function( ) as DynamicImportFunction; const MAX_REVOCATION_EXPIRY = 253402300799; -export function createSqliteStorage(dbPath?: string): SqliteStorage { - const provider = new BackendProvider(dbPath ?? DEFAULT_DB_PATH); +export function createSqliteStorage( + dbPath?: string, + options: SqliteStorageOptions = {}, +): SqliteStorage { + const provider = new BackendProvider( + dbPath ?? DEFAULT_DB_PATH, + options.forceMemory ?? false, + ); const revocations = new SqliteRevocationStorage(provider); const storage: AuthStorage = { identities: new SqliteIdentityStorage(provider), @@ -924,7 +936,10 @@ function normalizeRevocationKey(key: string): string { class BackendProvider { private backendPromise: Promise | null = null; - constructor(private readonly dbPath: string) {} + constructor( + private readonly dbPath: string, + private readonly forceMemory: boolean, + ) {} async getBackend(): Promise { if (!this.backendPromise) { @@ -946,6 +961,10 @@ class BackendProvider { } private async initialize(): Promise { + if (this.forceMemory) { + return createMemoryBackend(); + } + const candidates = await loadSqliteConstructors(); for (const Database of candidates) { @@ -3604,13 +3623,14 @@ function buildAuditQuerySql( params.push(query.to); } if (query.cursor?.kind === "archive_partition") { + const boundaries = normalizeAuditArchiveCursorBoundaries(query.cursor); clauses.push("timestamp < ?"); - params.push(getAuditArchiveCursorUpperBound(query.cursor)); + params.push(boundaries.upperBound); if (query.cursor.entryCursor) { clauses.push("(timestamp < ? OR (timestamp = ? AND id < ?))"); params.push( - query.cursor.entryCursor.timestamp, - query.cursor.entryCursor.timestamp, + boundaries.entryCursorTimestamp, + boundaries.entryCursorTimestamp, query.cursor.entryCursor.id, ); } @@ -3753,14 +3773,15 @@ function matchesAuditQuery( return false; } if (query.cursor?.kind === "archive_partition") { - const upper = getAuditArchiveCursorUpperBound(query.cursor); - if (entry.timestamp >= upper) { + const boundaries = normalizeAuditArchiveCursorBoundaries(query.cursor); + if (entry.timestamp >= boundaries.upperBound) { return false; } if ( query.cursor.entryCursor && - (entry.timestamp > query.cursor.entryCursor.timestamp || - (entry.timestamp === query.cursor.entryCursor.timestamp && + boundaries.entryCursorTimestamp && + (entry.timestamp > boundaries.entryCursorTimestamp || + (entry.timestamp === boundaries.entryCursorTimestamp && entry.id >= query.cursor.entryCursor.id)) ) { return false; From c1e27ab90f63db1a48338c89908088d0f0eeab1c Mon Sep 17 00:00:00 2001 From: kjgbot Date: Fri, 31 Jul 2026 10:19:00 +0200 Subject: [PATCH 15/20] fix(server): harden audit cursor integrity --- .../src/__tests__/audit-query-api.test.ts | 27 +++- .../__tests__/identity-activity-api.test.ts | 4 +- .../src/__tests__/storage-sqlite.test.ts | 149 ++++++++++++++++++ packages/server/src/routes/audit-query.ts | 45 +++--- packages/server/src/storage/interface.ts | 72 +++++++-- packages/server/src/storage/sqlite.ts | 14 +- 6 files changed, 269 insertions(+), 42 deletions(-) diff --git a/packages/server/src/__tests__/audit-query-api.test.ts b/packages/server/src/__tests__/audit-query-api.test.ts index 655d6d6..3fbfb9f 100644 --- a/packages/server/src/__tests__/audit-query-api.test.ts +++ b/packages/server/src/__tests__/audit-query-api.test.ts @@ -440,7 +440,7 @@ test("GET /v1/audit returns a typed archive budget continuation that resumes wit assert.equal(query.orgId, "org_archive"); assert.equal(query.identityId, "agent_archive_雪"); if (query.cursor?.kind === "archive_partition") { - assert.equal(query.cursor.timestamp, "2026-03-24T12:00:02.000Z"); + assert.equal(query.cursor.timestamp, "2026-03-24T12:00:00.000Z"); assert.equal(query.cursor.inclusive, true); assert.deepEqual(query.cursor.chunk, { key: "indexes/v1/org=org_archive/next.json", @@ -454,7 +454,7 @@ test("GET /v1/audit returns a typed archive budget continuation that resumes wit continuation: { kind: "archive_partition", orgId: "org_archive", - timestamp: "2026-03-24T12:00:02.000Z", + timestamp: "2026-03-24T12:00:00.000Z", inclusive: true, chunk: { key: "indexes/v1/org=org_archive/next.json", @@ -615,6 +615,29 @@ test("GET /v1/audit returns 400 for an ISO-shaped impossible cursor timestamp", assert.equal(body.error, "invalid cursor"); }); +test("GET /v1/audit returns 400 for a non-minute archive cursor timestamp", async () => { + const nonMinuteCursor = Buffer.from( + JSON.stringify({ + version: 1, + kind: "archive_partition", + orgId: "org_test", + timestamp: "2026-03-24T13:00:59.001+01:00", + filterKey: "irrelevant-invalid-cursor-filter", + }), + "utf8", + ).toString("base64url"); + const response = await queryAudit( + createAuditSearch({ orgId: "org_test", cursor: nonMinuteCursor }), + { + claims: { org: "org_test", scopes: ["relayauth:audit:read"] }, + }, + ); + const body = (await response.json()) as { error: string }; + + assert.equal(response.status, 400); + assert.equal(body.error, "invalid cursor"); +}); + test("GET /v1/audit returns 400 for invalid limit", async () => { const response = await queryAudit( createAuditSearch({ orgId: "org_test", limit: "abc" }), diff --git a/packages/server/src/__tests__/identity-activity-api.test.ts b/packages/server/src/__tests__/identity-activity-api.test.ts index 41a5961..5c7d60b 100644 --- a/packages/server/src/__tests__/identity-activity-api.test.ts +++ b/packages/server/src/__tests__/identity-activity-api.test.ts @@ -866,7 +866,7 @@ test("GET /v1/identities/:id/activity trims a budget overflow row and resumes wi assert.equal(query.identityId, identity.id); if (query.cursor?.kind === "archive_partition") { assert.equal(query.cursor.orgId, identity.orgId); - assert.equal(query.cursor.timestamp, entries[2]!.timestamp); + assert.equal(query.cursor.timestamp, "2026-03-24T12:00:00.000Z"); assert.equal(query.cursor.inclusive, true); assert.equal(query.cursor.filterKey, continuationFilterKey); return { kind: "complete", entries: [entries[2]!] }; @@ -878,7 +878,7 @@ test("GET /v1/identities/:id/activity trims a budget overflow row and resumes wi continuation: { kind: "archive_partition", orgId: identity.orgId, - timestamp: entries[2]!.timestamp, + timestamp: "2026-03-24T12:00:00.000Z", inclusive: true, filterKey: continuationFilterKey, }, diff --git a/packages/server/src/__tests__/storage-sqlite.test.ts b/packages/server/src/__tests__/storage-sqlite.test.ts index ad96f91..a7c58d5 100644 --- a/packages/server/src/__tests__/storage-sqlite.test.ts +++ b/packages/server/src/__tests__/storage-sqlite.test.ts @@ -205,6 +205,135 @@ async function assertOffsetArchiveCursorParity( } } +async function assertOffsetEntryCursorPagination( + storage: ReturnType, +) { + const expectedIds = [ + "aud_after", + "aud_same_c", + "aud_same_b", + "aud_same_a", + "aud_before", + ]; + for (const [id, timestamp] of [ + ["aud_after", "2026-03-27T12:00:01.000Z"], + ["aud_same_c", "2026-03-27T12:00:00.000Z"], + ["aud_same_b", "2026-03-27T12:00:00.000Z"], + ["aud_same_a", "2026-03-27T12:00:00.000Z"], + ["aud_before", "2026-03-27T11:59:59.000Z"], + ]) { + await storage.audit.write({ + id, + action: "scope.checked", + identityId: "agent_entry_cursor_offset", + orgId: "org_entry_cursor_offset", + result: "allowed", + timestamp, + }); + } + + const queryPage = (timestamp?: string, id?: string) => + storage.audit.query( + { + orgId: "org_entry_cursor_offset", + limit: 2, + ...(timestamp && id ? { cursor: { timestamp, id } } : {}), + }, + { includeOverflowRow: false }, + ); + + const firstPage = await queryPage(); + assert.deepEqual( + firstPage.entries.map((entry) => entry.id), + expectedIds.slice(0, 2), + ); + + const utcSecondPage = await queryPage( + "2026-03-27T12:00:00.000Z", + "aud_same_c", + ); + const offsetSecondPage = await queryPage( + "2026-03-27T13:00:00.000+01:00", + "aud_same_c", + ); + assert.deepEqual( + offsetSecondPage.entries.map((entry) => entry.id), + utcSecondPage.entries.map((entry) => entry.id), + "same-instant offset and UTC cursors must have identical timestamp/id tiebreaks", + ); + assert.deepEqual( + offsetSecondPage.entries.map((entry) => entry.id), + expectedIds.slice(2, 4), + ); + + const thirdPage = await queryPage( + "2026-03-27T13:00:00.000+01:00", + "aud_same_a", + ); + const receivedIds = [ + ...firstPage.entries, + ...offsetSecondPage.entries, + ...thirdPage.entries, + ].map((entry) => entry.id); + assert.deepEqual(receivedIds, expectedIds); + assert.equal(new Set(receivedIds).size, receivedIds.length); +} + +async function assertRejectsMisalignedArchiveCursors( + storage: ReturnType, +) { + const variants: AuditArchivePartitionCursor[] = [ + { + kind: "archive_partition", + orgId: "org_audit_cursor", + timestamp: "2026-03-27T12:00:59.000Z", + filterKey: "storage-contract-test", + }, + { + kind: "archive_partition", + orgId: "org_audit_cursor", + timestamp: "2026-03-27T13:00:00.001+01:00", + inclusive: true, + filterKey: "storage-contract-test", + }, + { + kind: "archive_partition", + orgId: "org_audit_cursor", + timestamp: "2026-03-27T12:00:30.000Z", + inclusive: true, + chunk: { key: "chunk-2", sha256: "chunk-2-sha" }, + filterKey: "storage-contract-test", + }, + { + kind: "archive_partition", + orgId: "org_audit_cursor", + timestamp: "2026-03-27T12:00:00.250Z", + entryCursor: { + timestamp: "2026-03-27T11:59:59.999Z", + id: "aud_entry", + }, + filterKey: "storage-contract-test", + }, + ]; + + for (const cursor of variants) { + await assert.rejects( + () => + storage.audit.query({ + orgId: "org_audit_cursor", + limit: 10, + cursor, + }), + (error: unknown) => { + assert.ok(error instanceof StorageError); + assert.equal(error.status, 400); + assert.equal(error.code, "invalid_input"); + return true; + }, + ); + } +} + test("TestSqliteAuditQuery rejects impossible archive timestamps before opening storage", async (t) => { const { dbPath, storage } = createTempStorage(t); @@ -263,6 +392,26 @@ test("TestSqliteAuditQuery normalizes offset archive cursor boundaries in forced await assertOffsetArchiveCursorParity(storage); }); +test("TestSqliteAuditQuery normalizes ordinary offset cursors without pagination gaps", async (t) => { + const { storage } = createTempStorage(t); + await assertOffsetEntryCursorPagination(storage); +}); + +test("TestSqliteAuditQuery normalizes ordinary offset cursors without pagination gaps in forced memory", async (t) => { + const storage = createForcedMemoryStorage(t); + await assertOffsetEntryCursorPagination(storage); +}); + +test("TestSqliteAuditQuery rejects non-minute archive cursor variants", async (t) => { + const { storage } = createTempStorage(t); + await assertRejectsMisalignedArchiveCursors(storage); +}); + +test("TestSqliteAuditQuery rejects non-minute archive cursor variants in forced memory", async (t) => { + const storage = createForcedMemoryStorage(t); + await assertRejectsMisalignedArchiveCursors(storage); +}); + test("TestSqliteIdentityCRUD", async (t) => { const { storage } = createTempStorage(t); diff --git a/packages/server/src/routes/audit-query.ts b/packages/server/src/routes/audit-query.ts index bb5f2c8..4905525 100644 --- a/packages/server/src/routes/audit-query.ts +++ b/packages/server/src/routes/audit-query.ts @@ -6,6 +6,7 @@ import { createAuditQueryContinuationFilterKey, isValidAuditTimestamp, normalizeAuditArchiveCursorBoundaries, + normalizeAuditQueryCursor, type AuditQueryCursor, } from "../storage/interface.js"; import { requireScope } from "../middleware/scope.js"; @@ -370,35 +371,37 @@ export function encodeAuditCursor( return null; } - if (cursor.kind === "archive_partition") { + let normalizedCursor: AuditQueryCursor; + try { + normalizedCursor = normalizeAuditQueryCursor(cursor); + } catch { + return null; + } + + if (normalizedCursor.kind === "archive_partition") { if ( - cursor.orgId.trim().length === 0 || - cursor.filterKey.length === 0 || - (cursor.entryCursor !== undefined && - (!isIsoTimestamp(cursor.entryCursor.timestamp) || - cursor.entryCursor.id.trim().length === 0)) + normalizedCursor.orgId.trim().length === 0 || + normalizedCursor.filterKey.length === 0 ) { return null; } return toBase64Url( JSON.stringify({ version: 1, - kind: cursor.kind, - orgId: cursor.orgId, - timestamp: cursor.timestamp, - inclusive: cursor.inclusive === true, - ...(cursor.chunk ? { chunk: cursor.chunk } : {}), - ...(cursor.entryCursor ? { entryCursor: cursor.entryCursor } : {}), - filterKey: cursor.filterKey, + kind: normalizedCursor.kind, + orgId: normalizedCursor.orgId, + timestamp: normalizedCursor.timestamp, + inclusive: normalizedCursor.inclusive === true, + ...(normalizedCursor.chunk ? { chunk: normalizedCursor.chunk } : {}), + ...(normalizedCursor.entryCursor + ? { entryCursor: normalizedCursor.entryCursor } + : {}), + filterKey: normalizedCursor.filterKey, }), ); } - if (!cursor.id) { - return null; - } - - return toBase64Url(`${cursor.timestamp}|${cursor.id}`); + return toBase64Url(`${normalizedCursor.timestamp}|${normalizedCursor.id}`); } export function decodeAuditCursor(value: string): AuditQueryCursor | null { @@ -419,7 +422,7 @@ export function decodeAuditCursor(value: string): AuditQueryCursor | null { return null; } - return { kind: "entry", timestamp, id }; + return normalizeAuditQueryCursor({ kind: "entry", timestamp, id }); } catch { return null; } @@ -464,7 +467,7 @@ function parseArchiveCursor( entryCursor.id.length > 0)) && typeof filterKey === "string" && filterKey.length > 0 - ? { + ? (normalizeAuditQueryCursor({ kind, orgId, timestamp, @@ -486,7 +489,7 @@ function parseArchiveCursor( } : {}), filterKey, - } + }) as Extract) : null; } catch { return null; diff --git a/packages/server/src/storage/interface.ts b/packages/server/src/storage/interface.ts index a9dc0f0..9bbc559 100644 --- a/packages/server/src/storage/interface.ts +++ b/packages/server/src/storage/interface.ts @@ -520,6 +520,28 @@ export function isValidAuditTimestamp(value: unknown): value is string { ); } +function normalizeAuditEntryCursor(cursor: AuditEntryCursor): AuditEntryCursor { + if (!isValidAuditTimestamp(cursor.timestamp)) { + throw new StorageError( + "audit cursor timestamp must be an ISO 8601 timestamp", + 400, + "invalid_input", + ); + } + if (typeof cursor.id !== "string" || cursor.id.trim().length === 0) { + throw new StorageError( + "audit cursor id is required", + 400, + "invalid_input", + ); + } + + return { + ...cursor, + timestamp: new Date(Date.parse(cursor.timestamp)).toISOString(), + }; +} + /** * Return the exclusive hot-store upper bound for an archive partition cursor. * This is deliberately part of the storage contract: direct callers bypass @@ -542,19 +564,23 @@ export function normalizeAuditArchiveCursorBoundaries( } const cursorTimestamp = new Date(Date.parse(cursor.timestamp)).toISOString(); + const cursorDate = new Date(cursorTimestamp); + if ( + cursorDate.getUTCSeconds() !== 0 || + cursorDate.getUTCMilliseconds() !== 0 + ) { + throw new StorageError( + "archive cursor timestamp must be aligned to a UTC minute", + 400, + "invalid_input", + ); + } let entryCursorTimestamp: string | undefined; if (cursor.entryCursor) { - if (!isValidAuditTimestamp(cursor.entryCursor.timestamp)) { - throw new StorageError( - "archive entry cursor timestamp must be an ISO 8601 timestamp", - 400, - "invalid_input", - ); - } - entryCursorTimestamp = new Date( - Date.parse(cursor.entryCursor.timestamp), - ).toISOString(); + entryCursorTimestamp = normalizeAuditEntryCursor( + cursor.entryCursor, + ).timestamp; } const upperBound = @@ -569,6 +595,32 @@ export function normalizeAuditArchiveCursorBoundaries( }; } +/** + * Validate and canonicalize an audit query cursor at the shared storage/codec + * boundary so SQL and in-memory comparisons use the same UTC values. + */ +export function normalizeAuditQueryCursor( + cursor: AuditQueryCursor, +): AuditQueryCursor { + if (cursor.kind !== "archive_partition") { + return normalizeAuditEntryCursor(cursor); + } + + const boundaries = normalizeAuditArchiveCursorBoundaries(cursor); + return { + ...cursor, + timestamp: boundaries.cursorTimestamp, + ...(cursor.entryCursor + ? { + entryCursor: { + ...cursor.entryCursor, + timestamp: boundaries.entryCursorTimestamp!, + }, + } + : {}), + }; +} + export function getAuditArchiveCursorUpperBound( cursor: AuditArchivePartitionCursor, ): string { diff --git a/packages/server/src/storage/sqlite.ts b/packages/server/src/storage/sqlite.ts index a7d8955..b8b92d5 100644 --- a/packages/server/src/storage/sqlite.ts +++ b/packages/server/src/storage/sqlite.ts @@ -60,8 +60,8 @@ import type { WorkspaceContextRecord, } from "./interface.js"; import { - getAuditArchiveCursorUpperBound, normalizeAuditArchiveCursorBoundaries, + normalizeAuditQueryCursor, StorageError, } from "./interface.js"; import { emitObserverEvent, now as observerNow } from "../lib/events.js"; @@ -3556,16 +3556,16 @@ function normalizeAuditWriteEntry(entry: AuditLogWriteEntry): AuditEntryRecord { function normalizeAuditQuery(query: AuditQueryInput): AuditQueryInput { const orgId = requireString(query.orgId, "orgId is required"); - - if (query.cursor?.kind === "archive_partition") { - // Validate before the provider is opened so invalid direct storage calls - // cannot reach a SQL statement or the in-memory scan. - getAuditArchiveCursorUpperBound(query.cursor); - } + // Validate and canonicalize before the provider is opened so both SQL and + // in-memory scans compare the same UTC cursor values. + const cursor = query.cursor + ? normalizeAuditQueryCursor(query.cursor) + : undefined; return { ...query, orgId, + cursor, limit: normalizeAuditLimit(query.limit), }; } From 3efbe6bbe854fd96fe1990ed77c466e97c580b25 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Fri, 31 Jul 2026 10:25:14 +0200 Subject: [PATCH 16/20] fix(server): normalize audit range boundaries --- .../src/__tests__/audit-query-api.test.ts | 27 +++++ .../src/__tests__/dashboard-stats-api.test.ts | 36 ++++++ .../src/__tests__/storage-sqlite.test.ts | 109 ++++++++++++++++++ packages/server/src/routes/audit-query.ts | 11 +- packages/server/src/routes/dashboard-stats.ts | 15 ++- packages/server/src/storage/interface.ts | 30 ++++- packages/server/src/storage/sqlite.ts | 7 +- 7 files changed, 220 insertions(+), 15 deletions(-) diff --git a/packages/server/src/__tests__/audit-query-api.test.ts b/packages/server/src/__tests__/audit-query-api.test.ts index 3fbfb9f..3496fd2 100644 --- a/packages/server/src/__tests__/audit-query-api.test.ts +++ b/packages/server/src/__tests__/audit-query-api.test.ts @@ -304,6 +304,33 @@ test("GET /v1/audit filters by date range using inclusive from and exclusive to" ); }); +test("GET /v1/audit canonicalizes offset-equivalent from/to boundaries before storage", async () => { + const storage = createTestStorage(); + storage.audit.query = async (query) => { + assert.equal(query.from, "2026-03-27T06:30:00.000Z"); + assert.equal(query.to, "2026-03-27T06:30:00.000Z"); + return { kind: "complete", entries: [] }; + }; + const app = createTestApp({}, { storage }); + const response = await app.request( + createTestRequest( + "GET", + "/v1/audit?orgId=org_offset_range&from=2026-03-27T12%3A00%3A00.000%2B05%3A30&to=2026-03-27T12%3A00%3A00.000%2B05%3A30", + undefined, + { + Authorization: `Bearer ${generateTestToken({ + org: "org_offset_range", + scopes: ["relayauth:audit:read"], + })}`, + }, + ), + undefined, + app.bindings, + ); + + await assertJsonResponse(response, 200); +}); + test("GET /v1/audit filters by result query param", async () => { const entries = [ createAuditEntry(1, { diff --git a/packages/server/src/__tests__/dashboard-stats-api.test.ts b/packages/server/src/__tests__/dashboard-stats-api.test.ts index 05b3f64..53e90f6 100644 --- a/packages/server/src/__tests__/dashboard-stats-api.test.ts +++ b/packages/server/src/__tests__/dashboard-stats-api.test.ts @@ -679,6 +679,42 @@ test("GET /v1/stats supports time range filter via from/to query params", async assert.equal(body.scopeDenials, 0); }); +test("GET /v1/stats canonicalizes offset-equivalent from/to boundaries before storage", async () => { + const storage = createTestStorage(); + storage.audit.getActionCounts = async (_orgId, query) => { + assert.equal(query.from, "2026-03-27T06:30:00.000Z"); + assert.equal(query.to, "2026-03-27T06:30:00.000Z"); + return { + kind: "complete", + counts: { + tokensIssued: 0, + tokensRevoked: 0, + tokensRefreshed: 0, + scopeChecks: 0, + scopeDenials: 0, + }, + }; + }; + const app = createTestApp({}, { storage }); + const response = await app.request( + createTestRequest( + "GET", + "/v1/stats?from=2026-03-27T12%3A00%3A00.000%2B05%3A30&to=2026-03-27T12%3A00%3A00.000%2B05%3A30", + undefined, + { + Authorization: `Bearer ${generateTestToken({ + org: "org_offset_stats", + scopes: ["relayauth:stats:read"], + })}`, + }, + ), + undefined, + app.bindings, + ); + + await assertJsonResponse(response, 200); +}); + test("GET /v1/stats rejects ISO-shaped impossible from/to timestamps", async () => { for (const field of ["from", "to"] as const) { const response = await getDashboardStats( diff --git a/packages/server/src/__tests__/storage-sqlite.test.ts b/packages/server/src/__tests__/storage-sqlite.test.ts index a7c58d5..385fdb7 100644 --- a/packages/server/src/__tests__/storage-sqlite.test.ts +++ b/packages/server/src/__tests__/storage-sqlite.test.ts @@ -5,6 +5,8 @@ import { join } from "node:path"; import test, { type TestContext } from "node:test"; import type { Policy, Role } from "@relayauth/types"; import { + createAuditQueryContinuationFilterKey, + createDashboardAuditContinuationFilterKey, StorageError, type AuditArchivePartitionCursor, } from "../storage/interface.js"; @@ -334,6 +336,83 @@ async function assertRejectsMisalignedArchiveCursors( } } +async function assertOffsetRangeBoundaryParity( + storage: ReturnType, +) { + await storage.audit.write({ + id: "aud_offset_range_boundary", + action: "token.issued", + identityId: "agent_offset_range_boundary", + orgId: "org_offset_range_boundary", + result: "allowed", + timestamp: "2026-03-27T06:30:00.000Z", + }); + + for (const testCase of [ + { + field: "from" as const, + utc: "2026-03-27T06:30:00.000Z", + offset: "2026-03-27T12:00:00.000+05:30", + expectedIds: ["aud_offset_range_boundary"], + expectedTokensIssued: 1, + }, + { + field: "to" as const, + utc: "2026-03-27T06:30:00.000Z", + offset: "2026-03-27T12:00:00.000+05:30", + expectedIds: [], + expectedTokensIssued: 0, + }, + ]) { + const utcQuery = { [testCase.field]: testCase.utc }; + const offsetQuery = { [testCase.field]: testCase.offset }; + const utcEntries = await storage.audit.query( + { + orgId: "org_offset_range_boundary", + limit: 10, + ...utcQuery, + }, + { includeOverflowRow: false }, + ); + const offsetEntries = await storage.audit.query( + { + orgId: "org_offset_range_boundary", + limit: 10, + ...offsetQuery, + }, + { includeOverflowRow: false }, + ); + + assert.deepEqual( + offsetEntries.entries.map((entry) => entry.id), + utcEntries.entries.map((entry) => entry.id), + `${testCase.field} offset boundary must equal UTC`, + ); + assert.deepEqual( + offsetEntries.entries.map((entry) => entry.id), + testCase.expectedIds, + ); + + const utcCounts = await storage.audit.getActionCounts( + "org_offset_range_boundary", + utcQuery, + ); + const offsetCounts = await storage.audit.getActionCounts( + "org_offset_range_boundary", + offsetQuery, + ); + assert.deepEqual( + offsetCounts.counts, + utcCounts.counts, + `${testCase.field} count boundary must equal UTC`, + ); + assert.equal( + offsetCounts.counts.tokensIssued, + testCase.expectedTokensIssued, + ); + } +} + test("TestSqliteAuditQuery rejects impossible archive timestamps before opening storage", async (t) => { const { dbPath, storage } = createTempStorage(t); @@ -412,6 +491,36 @@ test("TestSqliteAuditQuery rejects non-minute archive cursor variants in forced await assertRejectsMisalignedArchiveCursors(storage); }); +test("TestSqliteAuditQuery normalizes offset from/to query and count boundaries", async (t) => { + const { storage } = createTempStorage(t); + await assertOffsetRangeBoundaryParity(storage); +}); + +test("TestSqliteAuditQuery normalizes offset from/to query and count boundaries in forced memory", async (t) => { + const storage = createForcedMemoryStorage(t); + await assertOffsetRangeBoundaryParity(storage); +}); + +test("audit continuation filter keys canonicalize offset from/to boundaries", () => { + const utcRange = { + from: "2026-03-27T06:30:00.000Z", + to: "2026-03-27T07:30:00.000Z", + }; + const offsetRange = { + from: "2026-03-27T12:00:00.000+05:30", + to: "2026-03-27T13:00:00.000+05:30", + }; + + assert.equal( + createAuditQueryContinuationFilterKey({ ...offsetRange, limit: 10 }), + createAuditQueryContinuationFilterKey({ ...utcRange, limit: 10 }), + ); + assert.equal( + createDashboardAuditContinuationFilterKey(offsetRange), + createDashboardAuditContinuationFilterKey(utcRange), + ); +}); + test("TestSqliteIdentityCRUD", async (t) => { const { storage } = createTempStorage(t); diff --git a/packages/server/src/routes/audit-query.ts b/packages/server/src/routes/audit-query.ts index 4905525..e062ca6 100644 --- a/packages/server/src/routes/audit-query.ts +++ b/packages/server/src/routes/audit-query.ts @@ -7,6 +7,7 @@ import { isValidAuditTimestamp, normalizeAuditArchiveCursorBoundaries, normalizeAuditQueryCursor, + normalizeAuditQueryTimestamp, type AuditQueryCursor, } from "../storage/interface.js"; import { requireScope } from "../middleware/scope.js"; @@ -156,15 +157,17 @@ export function parseAuditQuery( return { ok: false, error: "invalid result" }; } - const from = normalizeQueryValue(query.from); - if (from && !isIsoTimestamp(from)) { + const rawFrom = normalizeQueryValue(query.from); + if (rawFrom && !isIsoTimestamp(rawFrom)) { return { ok: false, error: "from must be an ISO 8601 timestamp" }; } + const from = normalizeAuditQueryTimestamp(rawFrom, "from"); - const to = normalizeQueryValue(query.to); - if (to && !isIsoTimestamp(to)) { + const rawTo = normalizeQueryValue(query.to); + if (rawTo && !isIsoTimestamp(rawTo)) { return { ok: false, error: "to must be an ISO 8601 timestamp" }; } + const to = normalizeAuditQueryTimestamp(rawTo, "to"); const cursor = normalizeQueryValue(query.cursor); const decodedCursor = cursor ? decodeAuditCursor(cursor) : null; diff --git a/packages/server/src/routes/dashboard-stats.ts b/packages/server/src/routes/dashboard-stats.ts index 8e21fb9..6e770ca 100644 --- a/packages/server/src/routes/dashboard-stats.ts +++ b/packages/server/src/routes/dashboard-stats.ts @@ -7,7 +7,10 @@ import { encodeAuditCursor, isIsoTimestamp, } from "./audit-query.js"; -import { createDashboardAuditContinuationFilterKey } from "../storage/interface.js"; +import { + createDashboardAuditContinuationFilterKey, + normalizeAuditQueryTimestamp, +} from "../storage/interface.js"; type ScopeContextVars = { identity?: { @@ -154,15 +157,17 @@ function parseDashboardStatsQuery( query: Record, authenticatedOrgId: string | undefined, ): { ok: true; value: DashboardStatsQuery } | { ok: false; error: string } { - const from = normalizeQueryValue(query.from); - if (from && !isIsoTimestamp(from)) { + const rawFrom = normalizeQueryValue(query.from); + if (rawFrom && !isIsoTimestamp(rawFrom)) { return { ok: false, error: "from must be an ISO 8601 timestamp" }; } + const from = normalizeAuditQueryTimestamp(rawFrom, "from"); - const to = normalizeQueryValue(query.to); - if (to && !isIsoTimestamp(to)) { + const rawTo = normalizeQueryValue(query.to); + if (rawTo && !isIsoTimestamp(rawTo)) { return { ok: false, error: "to must be an ISO 8601 timestamp" }; } + const to = normalizeAuditQueryTimestamp(rawTo, "to"); const cursorValue = normalizeQueryValue(query.cursor); const decodedCursor = cursorValue diff --git a/packages/server/src/storage/interface.ts b/packages/server/src/storage/interface.ts index 9bbc559..296cfa3 100644 --- a/packages/server/src/storage/interface.ts +++ b/packages/server/src/storage/interface.ts @@ -163,6 +163,8 @@ export function createAuditQueryContinuationFilterKey( | "cursor" >, ): string { + const from = normalizeAuditQueryTimestamp(query.from, "from"); + const to = normalizeAuditQueryTimestamp(query.to, "to"); const entryCursor = query.cursor?.kind === "archive_partition" ? query.cursor.entryCursor @@ -176,8 +178,8 @@ export function createAuditQueryContinuationFilterKey( workspaceId: query.workspaceId ?? null, plane: query.plane ?? null, result: query.result ?? null, - from: query.from ?? null, - to: query.to ?? null, + from: from ?? null, + to: to ?? null, limit: query.limit, entryCursor: entryCursor ? { timestamp: entryCursor.timestamp, id: entryCursor.id } @@ -189,12 +191,14 @@ export function createAuditQueryContinuationFilterKey( export function createDashboardAuditContinuationFilterKey( query: Pick, ): string { + const from = normalizeAuditQueryTimestamp(query.from, "from"); + const to = normalizeAuditQueryTimestamp(query.to, "to"); return JSON.stringify({ version: 1, resource: "dashboard-audit-counts", order: "partition_minute_desc", - from: query.from ?? null, - to: query.to ?? null, + from: from ?? null, + to: to ?? null, }); } @@ -520,6 +524,24 @@ export function isValidAuditTimestamp(value: unknown): value is string { ); } +export function normalizeAuditQueryTimestamp( + value: string | undefined, + field: "from" | "to", +): string | undefined { + const normalized = value?.trim(); + if (!normalized) { + return undefined; + } + if (!isValidAuditTimestamp(normalized)) { + throw new StorageError( + `${field} must be an ISO 8601 timestamp`, + 400, + "invalid_input", + ); + } + return new Date(Date.parse(normalized)).toISOString(); +} + function normalizeAuditEntryCursor(cursor: AuditEntryCursor): AuditEntryCursor { if (!isValidAuditTimestamp(cursor.timestamp)) { throw new StorageError( diff --git a/packages/server/src/storage/sqlite.ts b/packages/server/src/storage/sqlite.ts index b8b92d5..e271c9c 100644 --- a/packages/server/src/storage/sqlite.ts +++ b/packages/server/src/storage/sqlite.ts @@ -62,6 +62,7 @@ import type { import { normalizeAuditArchiveCursorBoundaries, normalizeAuditQueryCursor, + normalizeAuditQueryTimestamp, StorageError, } from "./interface.js"; import { emitObserverEvent, now as observerNow } from "../lib/events.js"; @@ -2453,8 +2454,8 @@ class SqliteAuditStorage implements AuditStorage { async getActionCounts(orgId: string, query: DashboardAuditQuery) { const normalizedOrgId = requireString(orgId, "orgId is required"); - const from = normalizeOptionalString(query.from); - const to = normalizeOptionalString(query.to); + const from = normalizeAuditQueryTimestamp(query.from, "from"); + const to = normalizeAuditQueryTimestamp(query.to, "to"); const backend = await this.provider.getBackend(); if (backend.kind === "memory") { @@ -3565,6 +3566,8 @@ function normalizeAuditQuery(query: AuditQueryInput): AuditQueryInput { return { ...query, orgId, + from: normalizeAuditQueryTimestamp(query.from, "from"), + to: normalizeAuditQueryTimestamp(query.to, "to"), cursor, limit: normalizeAuditLimit(query.limit), }; From 954c42806e50de4627c54d175b030fed35a76373 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Fri, 31 Jul 2026 10:45:53 +0200 Subject: [PATCH 17/20] fix(server): canonicalize audit cursor ids --- .../src/__tests__/audit-query-api.test.ts | 39 +++++++++++++++++++ .../src/__tests__/storage-sqlite.test.ts | 4 +- packages/server/src/storage/interface.ts | 2 + 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/server/src/__tests__/audit-query-api.test.ts b/packages/server/src/__tests__/audit-query-api.test.ts index 3496fd2..3daf481 100644 --- a/packages/server/src/__tests__/audit-query-api.test.ts +++ b/packages/server/src/__tests__/audit-query-api.test.ts @@ -2,6 +2,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import type { AuditAction, AuditEntry } from "@relayauth/types"; import { createAuditQueryContinuationFilterKey } from "../storage/interface.js"; +import { + decodeAuditCursor, + encodeAuditCursor, +} from "../routes/audit-query.js"; import { assertJsonResponse, createTestApp, @@ -24,6 +28,41 @@ type AuditQueryResponse = { }; }; +test("audit cursor codecs trim ordinary and archive entry cursor IDs", () => { + const ordinary = encodeAuditCursor({ + timestamp: "2026-03-24T12:00:00.000Z", + id: " aud_same_b ", + }); + assert.ok(ordinary); + assert.deepEqual(decodeAuditCursor(ordinary), { + kind: "entry", + timestamp: "2026-03-24T12:00:00.000Z", + id: "aud_same_b", + }); + + const archive = encodeAuditCursor({ + kind: "archive_partition", + orgId: "org_archive", + timestamp: "2026-03-24T12:00:00.000Z", + entryCursor: { + timestamp: "2026-03-24T11:59:59.000Z", + id: " aud_same_a ", + }, + filterKey: "archive-filter", + }); + assert.ok(archive); + assert.deepEqual(decodeAuditCursor(archive), { + kind: "archive_partition", + orgId: "org_archive", + timestamp: "2026-03-24T12:00:00.000Z", + entryCursor: { + timestamp: "2026-03-24T11:59:59.000Z", + id: "aud_same_a", + }, + filterKey: "archive-filter", + }); +}); + function createAuditEntry( index: number, overrides: Partial = {}, diff --git a/packages/server/src/__tests__/storage-sqlite.test.ts b/packages/server/src/__tests__/storage-sqlite.test.ts index 385fdb7..727e411 100644 --- a/packages/server/src/__tests__/storage-sqlite.test.ts +++ b/packages/server/src/__tests__/storage-sqlite.test.ts @@ -150,7 +150,7 @@ async function assertOffsetArchiveCursorParity( timestamp: "2026-03-27T14:00:00.000+01:00", entryCursor: { timestamp: "2026-03-27T13:00:00.000+01:00", - id: "aud_same_b", + id: " aud_same_b ", }, }, expectedIds: ["aud_same_a", "aud_before_partition"], @@ -256,7 +256,7 @@ async function assertOffsetEntryCursorPagination( ); const offsetSecondPage = await queryPage( "2026-03-27T13:00:00.000+01:00", - "aud_same_c", + " aud_same_c ", ); assert.deepEqual( offsetSecondPage.entries.map((entry) => entry.id), diff --git a/packages/server/src/storage/interface.ts b/packages/server/src/storage/interface.ts index 296cfa3..2f8cde1 100644 --- a/packages/server/src/storage/interface.ts +++ b/packages/server/src/storage/interface.ts @@ -560,6 +560,7 @@ function normalizeAuditEntryCursor(cursor: AuditEntryCursor): AuditEntryCursor { return { ...cursor, + id: cursor.id.trim(), timestamp: new Date(Date.parse(cursor.timestamp)).toISOString(), }; } @@ -636,6 +637,7 @@ export function normalizeAuditQueryCursor( ? { entryCursor: { ...cursor.entryCursor, + id: cursor.entryCursor.id.trim(), timestamp: boundaries.entryCursorTimestamp!, }, } From 9588b9825765c4dfde2ae7ef3039c853da813988 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Fri, 31 Jul 2026 11:17:14 +0200 Subject: [PATCH 18/20] fix(server): harden activity cursor encoding --- .../__tests__/identity-activity-api.test.ts | 131 ++++++++++++++++++ .../server/src/routes/identity-activity.ts | 24 ++-- 2 files changed, 141 insertions(+), 14 deletions(-) diff --git a/packages/server/src/__tests__/identity-activity-api.test.ts b/packages/server/src/__tests__/identity-activity-api.test.ts index 5c7d60b..2d073f4 100644 --- a/packages/server/src/__tests__/identity-activity-api.test.ts +++ b/packages/server/src/__tests__/identity-activity-api.test.ts @@ -831,6 +831,137 @@ test("GET /v1/identities/:id/activity supports cursor-based pagination", async ( assert.equal(secondPage.hasMore, false); }); +test("GET /v1/identities/:id/activity round-trips a UTF-8 ordinary cursor", async () => { + const identity = createStoredIdentity({ + id: "agent_activity_utf8_cursor", + orgId: "org_activity_utf8_cursor", + }); + const entries = [ + createAuditEntry(1, { + id: "aud_utf8_cursor_003", + orgId: identity.orgId, + identityId: identity.id, + timestamp: "2026-03-24T12:00:03.000Z", + }), + createAuditEntry(2, { + id: "aud_utf8_cursor_雪", + orgId: identity.orgId, + identityId: identity.id, + timestamp: "2026-03-24T12:00:02.000Z", + }), + createAuditEntry(3, { + id: "aud_utf8_cursor_001", + orgId: identity.orgId, + identityId: identity.id, + timestamp: "2026-03-24T12:00:01.000Z", + }), + ]; + + const firstResponse = await getIdentityActivity( + identity.id, + createActivitySearch({ limit: 2 }), + { + claims: { + org: identity.orgId, + scopes: ["relayauth:audit:read"], + }, + entries, + identities: [identity], + }, + ); + const firstPage = await assertJsonResponse( + firstResponse, + 200, + ); + assert.deepEqual( + firstPage.entries.map((entry) => entry.id), + ["aud_utf8_cursor_003", "aud_utf8_cursor_雪"], + ); + assert.equal(firstPage.hasMore, true); + assert.equal( + firstPage.nextCursor, + encodeCursor("2026-03-24T12:00:02.000Z", "aud_utf8_cursor_雪"), + ); + + const secondResponse = await getIdentityActivity( + identity.id, + createActivitySearch({ + limit: 2, + cursor: firstPage.nextCursor ?? undefined, + }), + { + claims: { + org: identity.orgId, + scopes: ["relayauth:audit:read"], + }, + entries, + identities: [identity], + }, + ); + const secondPage = await assertJsonResponse( + secondResponse, + 200, + ); + assert.deepEqual( + secondPage.entries.map((entry) => entry.id), + ["aud_utf8_cursor_001"], + ); + assert.equal(secondPage.hasMore, false); + assert.equal(secondPage.nextCursor, null); +}); + +test("GET /v1/identities/:id/activity fails closed for an unencodable ordinary cursor", async () => { + const identity = createStoredIdentity({ + id: "agent_activity_invalid_cursor", + orgId: "org_activity_invalid_cursor", + }); + const storage = createTestStorage(); + await storage.identities.create(identity); + storage.audit.query = async (query) => { + assert.equal(query.orgId, identity.orgId); + assert.equal(query.identityId, identity.id); + assert.equal(query.limit, 1); + return { + kind: "complete", + entries: [ + createAuditEntry(1, { + id: "", + orgId: identity.orgId, + identityId: identity.id, + timestamp: "2026-03-24T12:00:02.000Z", + }), + createAuditEntry(2, { + id: "aud_invalid_cursor_overflow", + orgId: identity.orgId, + identityId: identity.id, + timestamp: "2026-03-24T12:00:01.000Z", + }), + ], + }; + }; + const app = createTestApp({}, { storage }); + const response = await app.request( + createTestRequest( + "GET", + `/v1/identities/${identity.id}/activity?limit=1`, + undefined, + { + Authorization: `Bearer ${generateTestToken({ + org: identity.orgId, + scopes: ["relayauth:audit:read"], + })}`, + }, + ), + undefined, + app.bindings, + ); + + assert.deepEqual( + await assertJsonResponse<{ error: string }>(response, 500), + { error: "invalid audit continuation" }, + ); +}); + test("GET /v1/identities/:id/activity trims a budget overflow row and resumes without duplication", async () => { const identity = createStoredIdentity({ id: "agent_activity_archive", diff --git a/packages/server/src/routes/identity-activity.ts b/packages/server/src/routes/identity-activity.ts index ce5cd37..ec37d16 100644 --- a/packages/server/src/routes/identity-activity.ts +++ b/packages/server/src/routes/identity-activity.ts @@ -110,11 +110,20 @@ identityActivity.get( const entries = result.entries; const hasMore = entries.length > parsed.value.limit; const page = hasMore ? entries.slice(0, parsed.value.limit) : entries; + const nextCursor = hasMore + ? encodeAuditCursor({ + timestamp: page[page.length - 1]?.timestamp ?? "", + id: page[page.length - 1]?.id ?? "", + }) + : null; + if (hasMore && !nextCursor) { + return c.json({ error: "invalid audit continuation" }, 500); + } return c.json( { entries: page, - nextCursor: hasMore ? encodeCursor(page[page.length - 1]) : null, + nextCursor, hasMore, sponsorChain: storedIdentity.identity.sponsorChain, budgetUsage: summarizeBudgetUsage(storedIdentity.identity), @@ -269,17 +278,4 @@ function summarizeBudgetUsage( }; } -function encodeCursor( - row: { timestamp?: string; id?: string } | undefined, -): string | null { - if (!row?.timestamp || !row.id) { - return null; - } - - return btoa(`${row.timestamp}|${row.id}`) - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/g, ""); -} - export default identityActivity; From ea0a6d9a4b217b76880ef4d2557b7818777c636e Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Fri, 31 Jul 2026 11:36:54 +0200 Subject: [PATCH 19/20] fix(server): reject empty archive chunk cursors --- .../src/__tests__/audit-query-api.test.ts | 70 +++++++++++++++++++ packages/server/src/storage/interface.ts | 11 +++ 2 files changed, 81 insertions(+) diff --git a/packages/server/src/__tests__/audit-query-api.test.ts b/packages/server/src/__tests__/audit-query-api.test.ts index 3daf481..626dced 100644 --- a/packages/server/src/__tests__/audit-query-api.test.ts +++ b/packages/server/src/__tests__/audit-query-api.test.ts @@ -63,6 +63,32 @@ test("audit cursor codecs trim ordinary and archive entry cursor IDs", () => { }); }); +test("audit cursor codecs reject empty archive chunk fields and preserve valid opaque values", () => { + const baseCursor = { + kind: "archive_partition" as const, + orgId: "org_archive", + timestamp: "2026-03-24T12:00:00.000Z", + filterKey: "archive-filter", + }; + + for (const chunk of [ + { key: "", sha256: "a".repeat(64) }, + { key: " ", sha256: "a".repeat(64) }, + { key: "indexes/v1/org=org_archive/next.json", sha256: "" }, + { key: "indexes/v1/org=org_archive/next.json", sha256: "\t \n" }, + ]) { + assert.equal(encodeAuditCursor({ ...baseCursor, chunk }), null); + } + + const chunk = { + key: " indexes/v1/org=org_archive/next.json ", + sha256: ` ${"a".repeat(64)} `, + }; + const encoded = encodeAuditCursor({ ...baseCursor, chunk }); + assert.ok(encoded); + assert.deepEqual(decodeAuditCursor(encoded), { ...baseCursor, chunk }); +}); + function createAuditEntry( index: number, overrides: Partial = {}, @@ -622,6 +648,50 @@ test("GET /v1/audit returns a typed archive budget continuation that resumes wit assert.equal(new Set(received).size, received.length); }); +test("GET /v1/audit fails closed for a malformed archive budget continuation", async () => { + const storage = createTestStorage(); + storage.audit.query = async (query) => ({ + kind: "budget_exhausted", + entries: [], + continuation: { + kind: "archive_partition", + orgId: query.orgId, + timestamp: "2026-03-24T12:00:00.000Z", + chunk: { + key: " ", + sha256: "a".repeat(64), + }, + filterKey: createAuditQueryContinuationFilterKey(query), + }, + workBudget: { + hotStorePages: 4, + hotStoreRows: 128, + archivePartitions: 128, + archiveReads: 128, + }, + }); + const app = createTestApp({}, { storage }); + const response = await app.request( + createTestRequest("GET", "/v1/audit?orgId=org_malformed", undefined, { + Authorization: `Bearer ${generateTestToken({ + org: "org_malformed", + scopes: ["relayauth:audit:read"], + })}`, + }), + undefined, + app.bindings, + ); + await assertJsonResponse<{ + error: string; + hasMore?: boolean; + nextCursor?: string | null; + }>(response, 500, (body) => { + assert.equal(body.error, "invalid audit continuation"); + assert.equal("hasMore" in body, false); + assert.equal("nextCursor" in body, false); + }); +}); + test("GET /v1/audit returns 400 when orgId is missing", async () => { const response = await queryAudit("", { claims: { org: "org_test", scopes: ["relayauth:audit:read"] }, diff --git a/packages/server/src/storage/interface.ts b/packages/server/src/storage/interface.ts index 2f8cde1..9dcea0d 100644 --- a/packages/server/src/storage/interface.ts +++ b/packages/server/src/storage/interface.ts @@ -630,6 +630,17 @@ export function normalizeAuditQueryCursor( } const boundaries = normalizeAuditArchiveCursorBoundaries(cursor); + if ( + cursor.chunk && + (cursor.chunk.key.trim().length === 0 || + cursor.chunk.sha256.trim().length === 0) + ) { + throw new StorageError( + "archive cursor chunk key and sha256 must be non-empty", + 400, + "invalid_input", + ); + } return { ...cursor, timestamp: boundaries.cursorTimestamp, From d2394319d30ac9a8cadf5cd74c40c8a11409e865 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Fri, 31 Jul 2026 12:10:07 +0200 Subject: [PATCH 20/20] fix(server): separate audit scope from pagination --- .../server/src/__tests__/audit-query-api.test.ts | 8 ++++++++ packages/server/src/storage/interface.ts | 12 +++--------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/server/src/__tests__/audit-query-api.test.ts b/packages/server/src/__tests__/audit-query-api.test.ts index 626dced..f3d32bc 100644 --- a/packages/server/src/__tests__/audit-query-api.test.ts +++ b/packages/server/src/__tests__/audit-query-api.test.ts @@ -538,6 +538,10 @@ test("GET /v1/audit returns a typed archive budget continuation that resumes wit key: "indexes/v1/org=org_archive/next.json", sha256: "a".repeat(64), }); + assert.deepEqual(query.cursor.entryCursor, { + timestamp: "2026-03-24T12:00:02.000Z", + id: "aud_archive_002", + }); return { kind: "complete", entries: [entries[2]!] }; } return { @@ -552,6 +556,10 @@ test("GET /v1/audit returns a typed archive budget continuation that resumes wit key: "indexes/v1/org=org_archive/next.json", sha256: "a".repeat(64), }, + entryCursor: { + timestamp: "2026-03-24T12:00:02.000Z", + id: "aud_archive_002", + }, filterKey: createAuditQueryContinuationFilterKey(query), }, workBudget: { diff --git a/packages/server/src/storage/interface.ts b/packages/server/src/storage/interface.ts index 9dcea0d..2c2e9f1 100644 --- a/packages/server/src/storage/interface.ts +++ b/packages/server/src/storage/interface.ts @@ -147,7 +147,9 @@ export type DashboardAuditQuery = { /** * The archive is ordered timestamp DESC, id DESC. Keep this canonical key in * the storage contract so every backend produces continuations accepted by - * the HTTP boundary without copying filter semantics. + * the HTTP boundary without copying filter semantics. Pagination position is + * carried by the continuation cursor itself and is deliberately not query + * scope: it changes between otherwise identical page requests. */ export function createAuditQueryContinuationFilterKey( query: Pick< @@ -160,15 +162,10 @@ export function createAuditQueryContinuationFilterKey( | "from" | "to" | "limit" - | "cursor" >, ): string { const from = normalizeAuditQueryTimestamp(query.from, "from"); const to = normalizeAuditQueryTimestamp(query.to, "to"); - const entryCursor = - query.cursor?.kind === "archive_partition" - ? query.cursor.entryCursor - : query.cursor; return JSON.stringify({ version: 1, resource: "audit", @@ -181,9 +178,6 @@ export function createAuditQueryContinuationFilterKey( from: from ?? null, to: to ?? null, limit: query.limit, - entryCursor: entryCursor - ? { timestamp: entryCursor.timestamp, id: entryCursor.id } - : null, }); }