From 7360cef19d50ee68b6f57379934cc089dd9879e1 Mon Sep 17 00:00:00 2001 From: duyetbot <101855044+duyetbot@users.noreply.github.com> Date: Thu, 17 Sep 2026 03:13:47 +0700 Subject: [PATCH 1/4] fix(api): default capability tokens to 30-day expiry Minted tokens without expires_at previously never expired, turning any leaked delegation token into a permanent grant. Enforce at the shared service boundary for REST and MCP: default 30 days, reject past or beyond-365-day values with INVALID_REQUEST, surface MCP mint errors as tool errors, and document the policy. Closes #434 Co-Authored-By: Duyet Le Co-Authored-By: duyetbot --- docs/recipes/capability-tokens.md | 2 ++ .../api/src/routes/capability-tokens/index.ts | 20 +++++++---- packages/api/src/routes/mcp/tools.ts | 30 ++++++++-------- .../api/src/services/capability-tokens.ts | 36 +++++++++++++++++-- packages/api/test/mcp.test.ts | 18 +++++++++- .../test/v2-capability-tokens-leases.test.ts | 23 ++++++++++++ 6 files changed, 105 insertions(+), 24 deletions(-) diff --git a/docs/recipes/capability-tokens.md b/docs/recipes/capability-tokens.md index e807e609..efcb2ed2 100644 --- a/docs/recipes/capability-tokens.md +++ b/docs/recipes/capability-tokens.md @@ -245,6 +245,8 @@ The raw `as_cap_...` token appears in the mint response body and is never stored ### Expiry vs. revocation +New tokens minted through REST or MCP default to a 30-day expiry when `expires_at` is omitted. Explicit expiry must be a future Unix-millisecond integer within 365 days of minting; invalid horizons return `INVALID_REQUEST`. The mint response includes the effective `expires_at`. Existing tokens are not retroactively changed: revoke and reissue legacy tokens with no expiry. Prefer a shorter expiry matching the delegated task. + Set `expires_at` (Unix milliseconds) for time-bounded delegation — for example, a per-run token that expires when the job is done. Call `DELETE /api/v1/capability-tokens/:id` for immediate revocation at any time, regardless of `expires_at`. Both strategies invalidate the token; revocation takes effect synchronously. ### Using a capability token in the SDK diff --git a/packages/api/src/routes/capability-tokens/index.ts b/packages/api/src/routes/capability-tokens/index.ts index 4ebca18b..9aaf2073 100644 --- a/packages/api/src/routes/capability-tokens/index.ts +++ b/packages/api/src/routes/capability-tokens/index.ts @@ -6,6 +6,7 @@ import { apiKeyAuth } from "../../middleware/auth"; import { rateLimitMiddleware } from "../../middleware/rate-limit"; import { requireScope } from "../../middleware/require-scope"; import { + CapabilityTokenExpiryError, createCapabilityToken, listCapabilityTokens, revokeCapabilityToken, @@ -45,12 +46,19 @@ router.post("/", requireScope("keys:write"), async (c) => { ); } - const token = await createCapabilityToken(c.get("db"), c.get("projectId"), { - name: data.name, - scopes: data.scopes, - expires_at: data.expires_at, - }); - return c.json(token, 201); + try { + const token = await createCapabilityToken(c.get("db"), c.get("projectId"), { + name: data.name, + scopes: data.scopes, + expires_at: data.expires_at, + }); + return c.json(token, 201); + } catch (err) { + if (err instanceof CapabilityTokenExpiryError) { + return errorResponse(c, err.code, err.message, 400); + } + throw err; + } }); router.get("/", requireScope("keys:read"), async (c) => { diff --git a/packages/api/src/routes/mcp/tools.ts b/packages/api/src/routes/mcp/tools.ts index 56cbebd0..2d6ca51b 100644 --- a/packages/api/src/routes/mcp/tools.ts +++ b/packages/api/src/routes/mcp/tools.ts @@ -4,7 +4,7 @@ import { z } from "zod"; import { conversations as conversationsTable, messages as messagesTable } from "../../db/schema"; import { GrantableScopeSchema, scopesSatisfyAll } from "../../lib/scopes"; import { deserializeConversationFull, deserializeMessage } from "../../lib/serialization"; -import { CapabilityScopeSchema } from "../../lib/validation"; +import { CapabilityScopeSchema, MessagesInputSchema } from "../../lib/validation"; import * as capabilityTokensService from "../../services/capability-tokens"; import * as claimsService from "../../services/claims"; import * as keysService from "../../services/keys"; @@ -44,13 +44,6 @@ export class ToolError extends Error { // and local servers advertise identical tool input shapes. // --------------------------------------------------------------------------- -const messageSchema = z.object({ - role: z.enum(["user", "assistant", "system", "tool"]), - content: z.string(), - metadata: z.record(z.string(), z.unknown()).optional(), - token_count: z.number().int().optional(), -}); - const claimEvidenceSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("text_hash"), @@ -82,7 +75,7 @@ const storeConversationSchema = z.object({ external_id: z.string().optional().describe("Optional external identifier for deduplication"), title: z.string().optional().describe("Human-readable title for the conversation"), metadata: z.record(z.string(), z.unknown()).optional().describe("Arbitrary JSON metadata"), - messages: z.array(messageSchema).optional().describe("Initial messages to include"), + messages: MessagesInputSchema.optional(), }); const recallConversationSchema = z.object({ @@ -473,7 +466,7 @@ export const TOOLS: ToolDefinition[] = [ { name: "mint_capability_token", description: - "Mint a scoped capability token for delegation to sub-agents. The raw token is shown once — store it before discarding the response.", + "Mint a scoped capability token for delegation to sub-agents. Expiry defaults to 30 days and cannot exceed 365 days. The raw token is shown once — store it before discarding the response.", requiredScope: "keys:write", zodSchema: mintCapabilityTokenSchema, inputSchema: jsonSchema(mintCapabilityTokenSchema), @@ -485,11 +478,18 @@ export const TOOLS: ToolDefinition[] = [ if (!scopesSatisfyAll(callerScopes, args.scopes)) { throw new ToolError("FORBIDDEN", "Cannot grant scopes beyond the calling key's own scopes"); } - return capabilityTokensService.createCapabilityToken(c.get("db"), c.get("projectId"), { - name: args.name, - scopes: args.scopes, - expires_at: args.expires_at, - }); + try { + return await capabilityTokensService.createCapabilityToken(c.get("db"), c.get("projectId"), { + name: args.name, + scopes: args.scopes, + expires_at: args.expires_at, + }); + } catch (err) { + if (err instanceof capabilityTokensService.CapabilityTokenExpiryError) { + throw new ToolError(err.code, err.message); + } + throw err; + } }, }, diff --git a/packages/api/src/services/capability-tokens.ts b/packages/api/src/services/capability-tokens.ts index 6a224661..d830236a 100644 --- a/packages/api/src/services/capability-tokens.ts +++ b/packages/api/src/services/capability-tokens.ts @@ -6,6 +6,18 @@ import { generateCapabilityToken, generateId } from "../lib/id"; import { encodeJson } from "../lib/state-json"; import type { CapabilityScope } from "../lib/validation"; +export const CAPABILITY_TOKEN_DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1000; +export const CAPABILITY_TOKEN_MAX_TTL_MS = 365 * 24 * 60 * 60 * 1000; + +export class CapabilityTokenExpiryError extends Error { + readonly code = "INVALID_REQUEST"; + + constructor() { + super("expires_at must be a future Unix-millisecond integer no more than 365 days from now"); + this.name = "CapabilityTokenExpiryError"; + } +} + export interface CapabilityTokenResponse { id: string; name: string; @@ -48,10 +60,30 @@ export function mapCapabilityToken( export async function createCapabilityToken( db: DrizzleD1Database, projectId: string, - input: { name: string; scopes: CapabilityScope[]; expires_at?: number }, + input: { name: string; scopes: CapabilityScope[]; expires_at?: number | null }, ): Promise { const token = generateCapabilityToken(); const now = Date.now(); + + // Expiry is enforced here at the shared service boundary so REST and MCP + // minters behave identically: omitted -> now + 30 days, past/non-finite + // values and horizons beyond 365 days are rejected. Without a default, + // `expiresAt: null` means never-expiring (scoped-auth/mcp-auth treat NULL + // as no expiry), which turns any leaked delegation token into a permanent + // grant. + let expiresAt: number; + if (input.expires_at === undefined || input.expires_at === null) { + expiresAt = now + CAPABILITY_TOKEN_DEFAULT_TTL_MS; + } else if ( + !Number.isSafeInteger(input.expires_at) || + input.expires_at <= now || + input.expires_at - now > CAPABILITY_TOKEN_MAX_TTL_MS + ) { + throw new CapabilityTokenExpiryError(); + } else { + expiresAt = input.expires_at; + } + const row = { id: generateId(), projectId, @@ -59,7 +91,7 @@ export async function createCapabilityToken( keyPrefix: token.substring(0, 12), keyHash: await hashApiKey(token), scopes: encodeJson([...new Set(input.scopes)].sort()), - expiresAt: input.expires_at ?? null, + expiresAt, createdAt: now, }; diff --git a/packages/api/test/mcp.test.ts b/packages/api/test/mcp.test.ts index 84e89305..6fafb0ae 100644 --- a/packages/api/test/mcp.test.ts +++ b/packages/api/test/mcp.test.ts @@ -266,6 +266,20 @@ describe("remote MCP server", () => { expect(json.result.isError).toBeUndefined(); }); + it("reports invalid capability expiry as a tool error", async () => { + const { json } = await rpc(bearer(TEST_KEY), { + jsonrpc: "2.0", + id: 19, + method: "tools/call", + params: { + name: "mint_capability_token", + arguments: { name: "invalid", scopes: ["state:read"], expires_at: Date.now() + 366 * 24 * 60 * 60 * 1000 }, + }, + }); + expect(json.result.isError).toBe(true); + expect(json.result.content[0].text).toContain("INVALID_REQUEST"); + }); + it("a capability token with lease:write can call acquire_lease (scope-form normalization)", async () => { // Mint a capability token scoped to the singular capability form. const mint = await rpc(bearer(TEST_KEY), { @@ -278,7 +292,9 @@ describe("remote MCP server", () => { }, }); expect(mint.json.result.isError).toBeUndefined(); - const token = JSON.parse(mint.json.result.content[0].text).token as string; + const minted = JSON.parse(mint.json.result.content[0].text); + expect(minted.expires_at).toBe(minted.created_at + 30 * 24 * 60 * 60 * 1000); + const token = minted.token as string; expect(token.startsWith("as_cap_")).toBe(true); // The token's singular lease:write must satisfy the lease tool's plural diff --git a/packages/api/test/v2-capability-tokens-leases.test.ts b/packages/api/test/v2-capability-tokens-leases.test.ts index 0fc9a721..f2404df3 100644 --- a/packages/api/test/v2-capability-tokens-leases.test.ts +++ b/packages/api/test/v2-capability-tokens-leases.test.ts @@ -119,6 +119,29 @@ describe("V2 Capability Tokens", () => { await resetCapabilityTables(); }); + it("defaults new tokens to a persisted 30-day expiry", async () => { + const body = await createCapabilityTokenBody({ name: "default expiry", scopes: ["state:read"] }); + expect(body.expires_at).toBe(body.created_at + 30 * 24 * 60 * 60 * 1000); + const row = await env.DB.prepare("SELECT expires_at FROM capability_tokens WHERE id = ?") + .bind(body.id).first<{ expires_at: number }>(); + expect(row?.expires_at).toBe(body.expires_at); + }); + + it.each(["past", "beyond maximum"])("rejects %s expiry without creating a token", async (kind) => { + const expires_at = kind === "past" ? Date.now() - 1000 : Date.now() + 366 * 24 * 60 * 60 * 1000; + const res = await createCapabilityToken({ name: "invalid expiry", scopes: ["state:read"], expires_at }); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ error: { code: "INVALID_REQUEST" } }); + const row = await env.DB.prepare("SELECT COUNT(*) AS count FROM capability_tokens").first<{ count: number }>(); + expect(row?.count).toBe(0); + }); + + it("preserves an explicit expiry within the maximum", async () => { + const expires_at = Date.now() + 365 * 24 * 60 * 60 * 1000; + const body = await createCapabilityTokenBody({ name: "maximum expiry", scopes: ["state:read"], expires_at }); + expect(body.expires_at).toBe(expires_at); + }); + it("creates a scoped capability token and stores only its hash", async () => { const body = await createCapabilityTokenBody({ name: "state writer", From 1da34915c544e0845c270ee80cc86c3a527ed39e Mon Sep 17 00:00:00 2001 From: duyetbot <101855044+duyetbot@users.noreply.github.com> Date: Thu, 17 Sep 2026 03:16:49 +0700 Subject: [PATCH 2/4] fix: keep capability expiry independent of message limits Co-Authored-By: Duyet Le Co-Authored-By: duyetbot --- packages/api/src/routes/mcp/tools.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/api/src/routes/mcp/tools.ts b/packages/api/src/routes/mcp/tools.ts index 2d6ca51b..3035cf14 100644 --- a/packages/api/src/routes/mcp/tools.ts +++ b/packages/api/src/routes/mcp/tools.ts @@ -4,7 +4,7 @@ import { z } from "zod"; import { conversations as conversationsTable, messages as messagesTable } from "../../db/schema"; import { GrantableScopeSchema, scopesSatisfyAll } from "../../lib/scopes"; import { deserializeConversationFull, deserializeMessage } from "../../lib/serialization"; -import { CapabilityScopeSchema, MessagesInputSchema } from "../../lib/validation"; +import { CapabilityScopeSchema } from "../../lib/validation"; import * as capabilityTokensService from "../../services/capability-tokens"; import * as claimsService from "../../services/claims"; import * as keysService from "../../services/keys"; @@ -44,6 +44,13 @@ export class ToolError extends Error { // and local servers advertise identical tool input shapes. // --------------------------------------------------------------------------- +const messageSchema = z.object({ + role: z.enum(["user", "assistant", "system", "tool"]), + content: z.string(), + metadata: z.record(z.string(), z.unknown()).optional(), + token_count: z.number().int().optional(), +}); + const claimEvidenceSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("text_hash"), @@ -75,7 +82,7 @@ const storeConversationSchema = z.object({ external_id: z.string().optional().describe("Optional external identifier for deduplication"), title: z.string().optional().describe("Human-readable title for the conversation"), metadata: z.record(z.string(), z.unknown()).optional().describe("Arbitrary JSON metadata"), - messages: MessagesInputSchema.optional(), + messages: z.array(messageSchema).optional().describe("Initial messages to include"), }); const recallConversationSchema = z.object({ From fdcb5903d7b25c773acb2fac6576f1e829413536 Mon Sep 17 00:00:00 2001 From: duyetbot <101855044+duyetbot@users.noreply.github.com> Date: Thu, 17 Sep 2026 03:25:06 +0700 Subject: [PATCH 3/4] fix(api): align expired-capability test with mint-time validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mint API now rejects past expires_at with 400 INVALID_REQUEST, so the edge-case test minting an already-expired token got 400 instead of 201. Mint with a valid future expiry, then force-expire the capability_tokens row directly in D1 before the 401-on-use assertion — same direct-row pattern as insertLease(). Mint-time rejection coverage already exists in v2-capability-tokens-leases.test.ts. Co-Authored-By: Duyet Le Co-Authored-By: duyetbot Co-Authored-By: Claude Code --- .../test/v2-coordination-edge-cases.test.ts | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/api/test/v2-coordination-edge-cases.test.ts b/packages/api/test/v2-coordination-edge-cases.test.ts index 7f45202d..79bf2992 100644 --- a/packages/api/test/v2-coordination-edge-cases.test.ts +++ b/packages/api/test/v2-coordination-edge-cases.test.ts @@ -17,6 +17,9 @@ * scopedAuth checks `expiresAt > now`; a token past its expiry is silently * excluded by the DB query and returns 401 UNAUTHORIZED — identical to a * revoked token from the caller's perspective, but a distinct code branch. + * The mint API rejects past expires_at outright (INVALID_REQUEST), so the + * token is minted with a valid future expiry and force-expired via a direct + * DB update before the auth check. * * 3. RELEASE ALREADY-RELEASED LEASE → 404 * releaseLease guards against double-release (releasedAt IS NOT NULL → NOT_FOUND). @@ -102,6 +105,17 @@ async function acquireLease( }); } +/** + * Force-expire a capability token directly in the DB — mirrors the direct-row + * pattern of insertLease() below. The mint API rejects past expires_at + * (INVALID_REQUEST), so expiry is simulated without real sleeps. + */ +async function forceExpireToken(tokenId: string): Promise { + await env.DB.prepare("UPDATE capability_tokens SET expires_at = ? WHERE id = ?") + .bind(Date.now() - 1_000, tokenId) + .run(); +} + /** * Insert a lease row directly into the DB — mirrors insertLease() in * v2-capability-tokens-leases.test.ts. Allows setting expiresAt to a past @@ -224,13 +238,14 @@ describe("Expired capability token denial", () => { // through to authFailure → 401. Without this test, a regression in the expiry // filter would silently make time-limited delegation tokens permanent. // - // We mint a token with expires_at in the past via the management API (which does - // not validate that expires_at is in the future), then immediately try to use it. + // Mint with a valid future expires_at (the API rejects past values with 400 + // INVALID_REQUEST), then force-expire the row directly in the DB before use. const expiredToken = await mintCapabilityToken( ["lease:write"], "expired-token-test", - Date.now() - 1_000, // already expired when minted + Date.now() + 60_000, ); + await forceExpireToken(expiredToken.id); // Attempt to acquire a lease using the expired token. const res = await acquireLease("state:expired-token-attempt", expiredToken.token, { From 2e373eab124688a18fd11ca68aa36346c62a1247 Mon Sep 17 00:00:00 2001 From: duyetbot <101855044+duyetbot@users.noreply.github.com> Date: Thu, 17 Sep 2026 03:26:42 +0700 Subject: [PATCH 4/4] chore: nudge PR head sync for CI