From c02f774acda85fec278ed716a059fbd55a721ecd Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 20 Oct 2026 14:03:05 -0700 Subject: [PATCH] feat: record agent token usage with lastUsedAt and opt-in logging Closes CL-10971 --- e2e/agent-token.drizzle.test.ts | 47 ++++++++++++++++++++++++++++++++ migrations/0003_last_used_at.sql | 1 + src/index.ts | 1 + src/middleware.ts | 38 ++++++++++++++++++++++++-- src/mount.ts | 1 + src/schema.ts | 1 + src/tokens.ts | 12 ++++++++ 7 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 migrations/0003_last_used_at.sql diff --git a/e2e/agent-token.drizzle.test.ts b/e2e/agent-token.drizzle.test.ts index 3347ab7..0f0e3e6 100644 --- a/e2e/agent-token.drizzle.test.ts +++ b/e2e/agent-token.drizzle.test.ts @@ -15,6 +15,7 @@ import { hashAgentToken, mintAgentToken, mintRunScopedToken, + recordTokenUse, revokeAgentToken, verifyAgentToken, } from "../src/tokens"; @@ -759,4 +760,50 @@ describeIfDb("agent tokens", () => { await close(); } }); + + test("recordTokenUse touches lastUsedAt", async () => { + const { db, close } = createDB({ ...target, schema: SCHEMA }); + try { + const tenantId = `tnt_rec_${randomUUID().slice(0, 8)}`; + await seedTenant(db, tenantId); + + const minted = await mintAgentToken(db, { + tenantId, + definitionId: "def_artifacts", + name: "artifacts", + }); + + // Verify lastUsedAt is null initially + const { agentTokenTable } = await import("../src/schema"); + const [before] = await db + .select() + .from(agentTokenTable) + .where(eq(agentTokenTable.id, minted.id)); + expect(before!.lastUsedAt).toBeNull(); + + // Call recordTokenUse + const beforeUse = Date.now(); + await recordTokenUse(db, minted.id); + + // Verify lastUsedAt is now set and recent + const [after] = await db + .select() + .from(agentTokenTable) + .where(eq(agentTokenTable.id, minted.id)); + expect(after!.lastUsedAt).toBeInstanceOf(Date); + expect(after!.lastUsedAt!.getTime()).toBeGreaterThanOrEqual(beforeUse); + expect(after!.lastUsedAt!.getTime()).toBeLessThanOrEqual(Date.now()); + + // A second call advances the timestamp + const firstUsedAt = after!.lastUsedAt!.getTime(); + await recordTokenUse(db, minted.id); + const [again] = await db + .select() + .from(agentTokenTable) + .where(eq(agentTokenTable.id, minted.id)); + expect(again!.lastUsedAt!.getTime()).toBeGreaterThanOrEqual(firstUsedAt); + } finally { + await close(); + } + }); }); diff --git a/migrations/0003_last_used_at.sql b/migrations/0003_last_used_at.sql new file mode 100644 index 0000000..778ad9f --- /dev/null +++ b/migrations/0003_last_used_at.sql @@ -0,0 +1 @@ +ALTER TABLE "agent_token"."token" ADD COLUMN IF NOT EXISTS "last_used_at" timestamptz; diff --git a/src/index.ts b/src/index.ts index 642eb9f..df0365d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ export { mintAgentToken, mintRunScopedToken, + recordTokenUse, revokeAgentToken, verifyAgentToken, type AgentTokenDb, diff --git a/src/middleware.ts b/src/middleware.ts index d77466f..4e078c5 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -6,6 +6,7 @@ import type { Context, MiddlewareHandler } from "hono"; import { bearerFromAuthorization, + recordTokenUse, verifyAgentToken, type AgentTokenDb, } from "./tokens.js"; @@ -23,6 +24,7 @@ export type AgentTokenVariables = { agentToken: AgentTokenContext }; export type RequireAgentTokenOpts> = { db: AgentTokenDb; + recordUse?: boolean; }; /** A host-side verifier, for mounts that want to authenticate a bearer @@ -55,12 +57,29 @@ export function createAgentTokenVerifier< >(opts: RequireAgentTokenOpts): AgentTokenVerifier { // The tenant is read before the header, so a host missing the tenant // middleware rejects every request, not only valid tokens. - return async (c) => - verifyAuthorization( + return async (c) => { + const identity = await verifyAuthorization( opts.db, c.get("tenant").id, c.req.header("authorization"), ); + if (identity !== undefined && opts.recordUse) { + recordTokenUse(opts.db, identity.id).catch((err: unknown) => { + console.error("recordTokenUse failed (verifier)", err); + }); + console.info( + JSON.stringify({ + event: "agent_token_used", + tokenId: identity.id, + tenantId: identity.tenantId, + definitionId: identity.definitionId, + route: c.req.path, + method: c.req.method, + }), + ); + } + return identity; + }; } /** Middleware that verifies a bearer and sets `agentToken` on the context. @@ -91,6 +110,21 @@ export function requireAgentToken>( } if (identity === undefined) return c.json(unauthorized, 401); c.set("agentToken", identity); + if (opts.recordUse) { + recordTokenUse(opts.db, identity.id).catch((err: unknown) => { + console.error("recordTokenUse failed (middleware)", err); + }); + console.info( + JSON.stringify({ + event: "agent_token_used", + tokenId: identity.id, + tenantId: identity.tenantId, + definitionId: identity.definitionId, + route: c.req.path, + method: c.req.method, + }), + ); + } await next(); return undefined; }; diff --git a/src/mount.ts b/src/mount.ts index d2feb35..047bfcd 100644 --- a/src/mount.ts +++ b/src/mount.ts @@ -57,6 +57,7 @@ export function mountAgentTokens< name: agentTokenTable.name, createdAt: agentTokenTable.createdAt, revokedAt: agentTokenTable.revokedAt, + lastUsedAt: agentTokenTable.lastUsedAt, }) .from(agentTokenTable) .where(eq(agentTokenTable.tenantId, c.get("tenant").id)) diff --git a/src/schema.ts b/src/schema.ts index bc2be01..5d2b40a 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -21,4 +21,5 @@ export const agentTokenTable = agentTokenSchema.table("token", { .notNull() .defaultNow(), revokedAt: timestamp("revoked_at", { withTimezone: true }), + lastUsedAt: timestamp("last_used_at", { withTimezone: true }), }); diff --git a/src/tokens.ts b/src/tokens.ts index 228e0ac..0f26ca5 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -173,6 +173,18 @@ export async function mintRunScopedToken< }; } +/** Updates the last-used timestamp on a token row. Fire after a + * successful verification to leave an audit trail. */ +export async function recordTokenUse>( + db: AgentTokenDb, + tokenId: string, +): Promise { + await db + .update(agentTokenTable) + .set({ lastUsedAt: new Date() }) + .where(eq(agentTokenTable.id, tokenId)); +} + /** Reads a `Bearer` credential out of an `Authorization` header value. */ export function bearerFromAuthorization( header: string | undefined,