From e59ec7484c0636cb0729205afa8d21c34acfbc05 Mon Sep 17 00:00:00 2001 From: John Pruitt Date: Fri, 17 Jul 2026 14:42:20 -0500 Subject: [PATCH] feat(auth): track API key last-used dates --- docs/cli/me-apikey.md | 4 +- packages/cli/commands/apikey.ts | 11 ++- .../core/migrate/idempotent/008_api_key.sql | 34 ++++++- .../incremental/016_api_key_last_used_on.sql | 2 + .../core/migrate/migrate.integration.test.ts | 63 +++++++++++++ packages/database/core/migrate/migrate.ts | 8 ++ packages/database/core/version.ts | 2 +- packages/engine/core/core.integration.test.ts | 7 ++ packages/engine/core/db.integration.test.ts | 7 ++ packages/engine/core/db.ts | 27 +++++- packages/engine/core/types.ts | 2 + packages/protocol/user/api-key.ts | 1 + .../server/middleware/api-key-usage.test.ts | 91 +++++++++++++++++++ packages/server/middleware/api-key-usage.ts | 73 +++++++++++++++ .../authenticate-space.integration.test.ts | 36 ++++++++ .../server/middleware/authenticate-space.ts | 2 + .../authenticate-user.integration.test.ts | 8 ++ .../server/middleware/authenticate-user.ts | 2 + .../rpc/user/api-key.integration.test.ts | 14 ++- packages/server/rpc/user/api-key.ts | 1 + 20 files changed, 383 insertions(+), 12 deletions(-) create mode 100644 packages/database/core/migrate/incremental/016_api_key_last_used_on.sql create mode 100644 packages/server/middleware/api-key-usage.test.ts create mode 100644 packages/server/middleware/api-key-usage.ts diff --git a/docs/cli/me-apikey.md b/docs/cli/me-apikey.md index a636ec47..530461fc 100644 --- a/docs/cli/me-apikey.md +++ b/docs/cli/me-apikey.md @@ -70,7 +70,9 @@ me apikey list [--agent | --service ] | `--agent ` | List one of your agents' keys (id or name) instead of your own. | | `--service ` | List a service account's keys (id or name) in the active space. | -Displays a table of keys with ID, name, created date, and expiry. +Displays a table of keys with ID, name, created date, expiry, and last-used date. + +Last used is tracked at day resolution (`YYYY-MM-DD`) in UTC. It updates at most once per key per UTC day per server process, so it is intended for operational visibility rather than precise audit logging. --- diff --git a/packages/cli/commands/apikey.ts b/packages/cli/commands/apikey.ts index a62108a5..f372a1f5 100644 --- a/packages/cli/commands/apikey.ts +++ b/packages/cli/commands/apikey.ts @@ -206,8 +206,14 @@ function createApiKeyListCommand(): Command { return; } table( - ["id", "name", "created", "expires"], - apiKeys.map((k) => [k.id, k.name, k.createdAt, k.expiresAt ?? ""]), + ["id", "name", "created", "expires", "last used"], + apiKeys.map((k) => [ + k.id, + k.name, + k.createdAt, + k.expiresAt ?? "", + k.lastUsedOn ?? "", + ]), ); }); } catch (error) { @@ -239,6 +245,7 @@ function createApiKeyGetCommand(): Command { console.log(` Member: ${apiKey.memberId}`); console.log(` Created: ${apiKey.createdAt}`); console.log(` Expires: ${apiKey.expiresAt ?? "(never)"}`); + console.log(` Last used: ${apiKey.lastUsedOn ?? "(never)"}`); }); } catch (error) { handleError(error, fmt, { creds }); diff --git a/packages/database/core/migrate/idempotent/008_api_key.sql b/packages/database/core/migrate/idempotent/008_api_key.sql index 2f1fb29e..06ab3b54 100644 --- a/packages/database/core/migrate/idempotent/008_api_key.sql +++ b/packages/database/core/migrate/idempotent/008_api_key.sql @@ -59,6 +59,7 @@ set search_path to pg_catalog, {{schema}}, public, pg_temp -- get_api_key -- Key metadata by id (never the secret). ------------------------------------------------------------------------------- +{{fn get_api_key(_id uuid) returns table(id uuid, member_id uuid, lookup_id text, name text, created_at timestamptz, expires_at timestamptz, last_used_on date)}} create or replace function {{schema}}.get_api_key ( _id uuid ) @@ -69,19 +70,22 @@ returns table , name text , created_at timestamptz , expires_at timestamptz +, last_used_on date ) as $func$ - select k.id, k.member_id, k.lookup_id, k.name, k.created_at, k.expires_at + select k.id, k.member_id, k.lookup_id, k.name, k.created_at, k.expires_at, k.last_used_on from {{schema}}.api_key k where k.id = _id $func$ language sql stable strict rows 1 security invoker set search_path to pg_catalog, {{schema}}, public, pg_temp ; +{{endfn}} ------------------------------------------------------------------------------- -- list_api_keys -- A member's keys (never the secret), newest first. ------------------------------------------------------------------------------- +{{fn list_api_keys(_member_id uuid) returns table(id uuid, member_id uuid, lookup_id text, name text, created_at timestamptz, expires_at timestamptz, last_used_on date)}} create or replace function {{schema}}.list_api_keys ( _member_id uuid ) @@ -92,15 +96,41 @@ returns table , name text , created_at timestamptz , expires_at timestamptz +, last_used_on date ) as $func$ - select k.id, k.member_id, k.lookup_id, k.name, k.created_at, k.expires_at + select k.id, k.member_id, k.lookup_id, k.name, k.created_at, k.expires_at, k.last_used_on from {{schema}}.api_key k where k.member_id = _member_id order by k.created_at desc $func$ language sql stable strict security invoker set search_path to pg_catalog, {{schema}}, public, pg_temp ; +{{endfn}} + +------------------------------------------------------------------------------- +-- touch_api_key +-- Best-effort day-level usage tracking. Validation stays read-only; callers touch +-- after a key authenticates. The predicate keeps row churn to one update per day. +------------------------------------------------------------------------------- +create or replace function {{schema}}.touch_api_key +( _id uuid +, _used_on date +) +returns bool +as $func$ + with updated as + ( + update {{schema}}.api_key + set last_used_on = _used_on + where id = _id + and (last_used_on is null or last_used_on < _used_on) + returning 1 + ) + select exists (select 1 from updated) +$func$ language sql volatile strict security invoker +set search_path to pg_catalog, {{schema}}, public, pg_temp +; ------------------------------------------------------------------------------- -- delete_api_key diff --git a/packages/database/core/migrate/incremental/016_api_key_last_used_on.sql b/packages/database/core/migrate/incremental/016_api_key_last_used_on.sql new file mode 100644 index 00000000..7ce39e7f --- /dev/null +++ b/packages/database/core/migrate/incremental/016_api_key_last_used_on.sql @@ -0,0 +1,2 @@ +alter table {{schema}}.api_key +add column last_used_on date; diff --git a/packages/database/core/migrate/migrate.integration.test.ts b/packages/database/core/migrate/migrate.integration.test.ts index 05d65e00..b48ad1f3 100644 --- a/packages/database/core/migrate/migrate.integration.test.ts +++ b/packages/database/core/migrate/migrate.integration.test.ts @@ -60,6 +60,7 @@ const EXPECTED_MIGRATIONS = [ "013_invite_groups", "014_space_access_defaults", "015_service_accounts", + "016_api_key_last_used_on", ]; const EXPECTED_FUNCTIONS = [ @@ -125,6 +126,16 @@ describe("provisioned core schema", () => { } }); + test("adds day-level api key usage metadata", async () => { + const [column] = await sql.unsafe( + `select data_type + from information_schema.columns + where table_schema = $1 and table_name = 'api_key' and column_name = 'last_used_on'`, + [canonical.schema], + ); + expect(column?.data_type).toBe("date"); + }); + test("records every incremental migration exactly once", async () => { expect(await appliedMigrations(sql, canonical.schema)).toEqual( EXPECTED_MIGRATIONS, @@ -2715,6 +2726,58 @@ describe("control-plane functions", () => { expect(valid[0]?.member_id).toBe(userId); // a user key has no owner expect(valid[0]?.owner_id).toBeNull(); + const [afterValidate] = await sql.unsafe( + `select last_used_on from ${s}.api_key where lookup_id = $1`, + [lookup], + ); + expect(afterValidate?.last_used_on).toBeNull(); + + const [firstTouch] = await sql.unsafe( + `select ${s}.touch_api_key(id, '2026-07-17'::date) as touched + from ${s}.api_key + where lookup_id = $1`, + [lookup], + ); + expect(firstTouch?.touched).toBe(true); + + const [sameDayTouch] = await sql.unsafe( + `select ${s}.touch_api_key(id, '2026-07-17'::date) as touched + from ${s}.api_key + where lookup_id = $1`, + [lookup], + ); + expect(sameDayTouch?.touched).toBe(false); + + const [earlierTouch] = await sql.unsafe( + `select ${s}.touch_api_key(id, '2026-07-16'::date) as touched + from ${s}.api_key + where lookup_id = $1`, + [lookup], + ); + expect(earlierTouch?.touched).toBe(false); + + const [laterTouch] = await sql.unsafe( + `select ${s}.touch_api_key(id, '2026-07-18'::date) as touched + from ${s}.api_key + where lookup_id = $1`, + [lookup], + ); + expect(laterTouch?.touched).toBe(true); + + const [got] = await sql.unsafe( + `select last_used_on::text as last_used_on + from ${s}.get_api_key((select id from ${s}.api_key where lookup_id = $1))`, + [lookup], + ); + expect(got?.last_used_on).toBe("2026-07-18"); + + const [listed] = await sql.unsafe( + `select last_used_on::text as last_used_on + from ${s}.list_api_keys($1) + where lookup_id = $2`, + [userId, lookup], + ); + expect(listed?.last_used_on).toBe("2026-07-18"); // an agent key reports the agent's owner (drives `~` home nesting) const agentId = await v7(); diff --git a/packages/database/core/migrate/migrate.ts b/packages/database/core/migrate/migrate.ts index 79ca99d7..f942b8e4 100644 --- a/packages/database/core/migrate/migrate.ts +++ b/packages/database/core/migrate/migrate.ts @@ -88,6 +88,9 @@ import incremental014 from "./incremental/014_space_access_defaults.sql" with { import incremental015 from "./incremental/015_service_accounts.sql" with { type: "text", }; +import incremental016 from "./incremental/016_api_key_last_used_on.sql" with { + type: "text", +}; import provisionSql from "./provision.sql" with { type: "text" }; const DIR = "packages/database/core/migrate"; @@ -164,6 +167,11 @@ const incrementals: Migration[] = [ file: "incremental/015_service_accounts.sql", sql: incremental015, }, + { + name: "016_api_key_last_used_on", + file: "incremental/016_api_key_last_used_on.sql", + sql: incremental016, + }, ]; const idempotents: Migration[] = [ diff --git a/packages/database/core/version.ts b/packages/database/core/version.ts index ab3e698d..acb22d68 100644 --- a/packages/database/core/version.ts +++ b/packages/database/core/version.ts @@ -1 +1 @@ -export const CORE_SCHEMA_VERSION = "0.0.4"; +export const CORE_SCHEMA_VERSION = "0.0.5"; diff --git a/packages/engine/core/core.integration.test.ts b/packages/engine/core/core.integration.test.ts index 6b619ef2..1b2a2982 100644 --- a/packages/engine/core/core.integration.test.ts +++ b/packages/engine/core/core.integration.test.ts @@ -686,11 +686,18 @@ test("api keys: create, get, list, delete (no secret leaked)", async () => { expect(got?.memberId).toBe(userId); expect(got?.lookupId).toBe(key.lookupId); expect(got?.name).toBe("ci"); + expect(got?.lastUsedOn).toBeNull(); // metadata only — no secret field on ApiKeyInfo expect((got as unknown as Record).secret).toBeUndefined(); + expect(await core.touchApiKey(key.id, "2026-07-17")).toBe(true); + expect(await core.touchApiKey(key.id, "2026-07-17")).toBe(false); + expect(await core.touchApiKey(key.id, "2026-07-18")).toBe(true); + expect((await core.getApiKey(key.id))?.lastUsedOn).toBe("2026-07-18"); + const list = await core.listApiKeys(userId); expect(list.map((k) => k.id)).toContain(key.id); + expect(list.find((k) => k.id === key.id)?.lastUsedOn).toBe("2026-07-18"); expect(await core.deleteApiKey(key.id)).toBe(true); expect(await core.getApiKey(key.id)).toBeNull(); diff --git a/packages/engine/core/db.integration.test.ts b/packages/engine/core/db.integration.test.ts index 214d8c26..1b42175f 100644 --- a/packages/engine/core/db.integration.test.ts +++ b/packages/engine/core/db.integration.test.ts @@ -127,6 +127,13 @@ test("createApiKey + validateApiKey (good / wrong secret)", async () => { // second principal lookup. expect(valid?.kind).toBe("u"); expect(valid?.name).toBe(userName); + expect((await db.getApiKey(key.id))?.lastUsedOn).toBeNull(); + + expect(await db.touchApiKey(key.id, "2026-07-17")).toBe(true); + expect(await db.touchApiKey(key.id, "2026-07-17")).toBe(false); + expect(await db.touchApiKey(key.id, "2026-07-16")).toBe(false); + expect(await db.touchApiKey(key.id, "2026-07-18")).toBe(true); + expect((await db.getApiKey(key.id))?.lastUsedOn).toBe("2026-07-18"); expect(await db.validateApiKey(key.lookupId, "wrong-secret")).toBeNull(); }); diff --git a/packages/engine/core/db.ts b/packages/engine/core/db.ts index e4a80a37..4b317260 100644 --- a/packages/engine/core/db.ts +++ b/packages/engine/core/db.ts @@ -213,6 +213,8 @@ export interface CoreStore { ): Promise; getApiKey(id: string): Promise; listApiKeys(memberId: string): Promise; + /** Best-effort day-level usage tracking. `usedOn` is UTC YYYY-MM-DD. */ + touchApiKey(id: string, usedOn: string): Promise; /** Hard-delete a key (revoke ≡ delete; there is no soft-revoke state). */ deleteApiKey(id: string): Promise; @@ -356,9 +358,17 @@ function mapApiKeyInfo(row: Record): ApiKeyInfo { name: row.name as string, createdAt: row.created_at as Date, expiresAt: (row.expires_at as Date | null) ?? null, + lastUsedOn: dateOnly(row.last_used_on), }; } +function dateOnly(value: unknown): string | null { + if (value === null || value === undefined) return null; + if (value instanceof Date) return value.toISOString().slice(0, 10); + if (typeof value === "string") return value.slice(0, 10); + return String(value).slice(0, 10); +} + export function coreStore(sql: Sql, schema: string = CORE_SCHEMA): CoreStore { const sch = sql(schema); // escaped schema identifier reused across queries @@ -705,15 +715,28 @@ export function coreStore(sql: Sql, schema: string = CORE_SCHEMA): CoreStore { }, async getApiKey(id) { - const [row] = await sql`select * from ${sch}.get_api_key(${id})`; + const [row] = await sql` + select id, member_id, lookup_id, name, created_at, expires_at, last_used_on::text as last_used_on + from ${sch}.get_api_key(${id}) + `; return row ? mapApiKeyInfo(row) : null; }, async listApiKeys(memberId) { - const rows = await sql`select * from ${sch}.list_api_keys(${memberId})`; + const rows = await sql` + select id, member_id, lookup_id, name, created_at, expires_at, last_used_on::text as last_used_on + from ${sch}.list_api_keys(${memberId}) + `; return rows.map(mapApiKeyInfo); }, + async touchApiKey(id, usedOn) { + const [row] = await sql` + select ${sch}.touch_api_key(${id}, ${usedOn}::date) as touched + `; + return Boolean(row?.touched); + }, + async deleteApiKey(id) { const [row] = await sql`select ${sch}.delete_api_key(${id}) as ok`; return Boolean(row?.ok); diff --git a/packages/engine/core/types.ts b/packages/engine/core/types.ts index 3f4a435d..8971f896 100644 --- a/packages/engine/core/types.ts +++ b/packages/engine/core/types.ts @@ -172,6 +172,8 @@ export interface ApiKeyInfo { name: string; createdAt: Date; expiresAt: Date | null; + /** UTC date string (YYYY-MM-DD) of the last successful api-key auth. */ + lastUsedOn: string | null; } /** diff --git a/packages/protocol/user/api-key.ts b/packages/protocol/user/api-key.ts index 5a8094df..ac920fa8 100644 --- a/packages/protocol/user/api-key.ts +++ b/packages/protocol/user/api-key.ts @@ -19,6 +19,7 @@ export const apiKeyInfoResponse = z.object({ name: z.string(), createdAt: z.string(), expiresAt: z.string().nullable(), + lastUsedOn: z.string().nullable(), }); export type ApiKeyInfoResponse = z.infer; diff --git a/packages/server/middleware/api-key-usage.test.ts b/packages/server/middleware/api-key-usage.test.ts new file mode 100644 index 00000000..30b77116 --- /dev/null +++ b/packages/server/middleware/api-key-usage.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import type { CoreStore } from "@memory.build/engine/core"; +import { + apiKeyUsageCacheSizeForTest, + recordApiKeyUse, + resetApiKeyUsageCacheForTest, +} from "./api-key-usage"; + +function fakeCore(touches: string[]): CoreStore { + return { + async touchApiKey(id: string, usedOn: string) { + touches.push(`${id}:${usedOn}`); + return true; + }, + } as unknown as CoreStore; +} + +describe("recordApiKeyUse", () => { + beforeEach(() => { + resetApiKeyUsageCacheForTest(new Date("2026-07-17T00:00:00Z")); + }); + + test("touches once per key per UTC day", async () => { + const touches: string[] = []; + const core = fakeCore(touches); + + await recordApiKeyUse(core, "key-1", new Date("2026-07-17T01:00:00Z")); + await recordApiKeyUse(core, "key-1", new Date("2026-07-17T23:00:00Z")); + await recordApiKeyUse(core, "key-1", new Date("2026-07-18T01:00:00Z")); + + expect(touches).toEqual(["key-1:2026-07-17", "key-1:2026-07-18"]); + }); + + test("swallows failed touches and does not retry the same key/day", async () => { + const touches: string[] = []; + const core = { + async touchApiKey(id: string, usedOn: string) { + touches.push(`${id}:${usedOn}`); + throw new Error("temporary failure"); + }, + } as unknown as CoreStore; + + await recordApiKeyUse(core, "key-1", new Date("2026-07-17T01:00:00Z")); + await recordApiKeyUse(core, "key-1", new Date("2026-07-17T02:00:00Z")); + + expect(touches).toEqual(["key-1:2026-07-17"]); + }); + + test("lazy flush clears the cache after 24 hours", async () => { + const touches: string[] = []; + const core = fakeCore(touches); + + // Two same-day touches populate the cache; direct inspection of the + // module-global size is the only way to observe the flush itself, since + // any date-changing test would trip a cache miss on the date alone. + await recordApiKeyUse(core, "key-1", new Date("2026-07-17T00:00:00Z")); + await recordApiKeyUse(core, "key-2", new Date("2026-07-17T12:00:00Z")); + expect(apiKeyUsageCacheSizeForTest()).toBe(2); + + // 24h + 1min after reset → the flush condition fires at the start of the + // call; the cache is then re-seeded with just the current call's key. + await recordApiKeyUse(core, "key-3", new Date("2026-07-18T00:01:00Z")); + expect(apiKeyUsageCacheSizeForTest()).toBe(1); + expect(touches).toEqual([ + "key-1:2026-07-17", + "key-2:2026-07-17", + "key-3:2026-07-18", + ]); + }); + + test("lazy flush clears entries after the maximum cache size", async () => { + const touches: string[] = []; + const core = fakeCore(touches); + const now = new Date("2026-07-17T01:00:00Z"); + + for (let i = 0; i <= 10_000; i += 1) { + await recordApiKeyUse(core, `key-${i}`, now); + } + // Cache is now bounded at 10_001 entries — the +1 is the deliberate soft + // overshoot documented in api-key-usage.ts (the size check runs before + // the current insert). The next call trips the check and clears. + expect(apiKeyUsageCacheSizeForTest()).toBe(10_001); + + await recordApiKeyUse(core, "key-0", now); + + // After the flush the cache holds only the current call's entry. + expect(apiKeyUsageCacheSizeForTest()).toBe(1); + expect(touches).toHaveLength(10_002); + expect(touches.at(-1)).toBe("key-0:2026-07-17"); + }); +}); diff --git a/packages/server/middleware/api-key-usage.ts b/packages/server/middleware/api-key-usage.ts new file mode 100644 index 00000000..26874f60 --- /dev/null +++ b/packages/server/middleware/api-key-usage.ts @@ -0,0 +1,73 @@ +import type { CoreStore } from "@memory.build/engine/core"; +import { warning } from "@pydantic/logfire-node"; + +// Best-effort day-level api-key usage recording. Validation stays read-only +// (`core.validateApiKey`); this helper writes `last_used_on` at most once per +// key per UTC day per server process. The cache below is a MODULE-GLOBAL +// singleton: every caller in the process shares the same map, which is what +// keeps the hot path to a Map lookup. Tests that call `recordApiKeyUse` +// therefore share state — use `resetApiKeyUsageCacheForTest` in `beforeEach`, +// or make sure every test mints a fresh key id (the middleware integration +// tests rely on that latter property). + +const CACHE_FLUSH_INTERVAL_MS = 24 * 60 * 60 * 1000; +// Soft upper bound on the cache. The threshold is compared *before* the +// current call inserts, so the map is bounded at `MAX_CACHE_ENTRIES + 1` +// entries in the worst case — the +1 falls off on the next call that trips +// the check. This is deliberate: the goal is a memory ceiling, not a hard +// count. A same-day miss just costs one extra no-op `touch_api_key` (the SQL +// predicate skips the update when `last_used_on` already equals today). +const MAX_CACHE_ENTRIES = 10_000; + +const touchedByProcess = new Map(); +let lastFlushMs = Date.now(); + +function maybeFlushCache(nowMs = Date.now()): void { + if ( + touchedByProcess.size > MAX_CACHE_ENTRIES || + nowMs - lastFlushMs >= CACHE_FLUSH_INTERVAL_MS + ) { + touchedByProcess.clear(); + lastFlushMs = nowMs; + } +} + +export async function recordApiKeyUse( + core: CoreStore, + apiKeyId: string, + now = new Date(), +): Promise { + maybeFlushCache(now.getTime()); + + const usedOn = now.toISOString().slice(0, 10); + if (touchedByProcess.get(apiKeyId) === usedOn) return; + + // Set BEFORE the write so a temporary DB failure cannot cause per-request + // retries against the same broken database. The SQL predicate keeps row + // churn to one update per day even if a later process crash lets this cache + // reset early. + touchedByProcess.set(apiKeyId, usedOn); + + try { + await core.touchApiKey(apiKeyId, usedOn); + } catch (error) { + // Best-effort telemetry — auth already succeeded, so a touch failure + // must not fail the request. `warning` (not `debug`) so a persistent + // outage is visible in production logs; a same-key retry storm is + // already prevented by the cache-first write above. + warning("api key usage touch failed", { + apiKeyId, + error: error instanceof Error ? error.message : String(error), + }); + } +} + +export function resetApiKeyUsageCacheForTest(now = new Date()): void { + touchedByProcess.clear(); + lastFlushMs = now.getTime(); +} + +/** Test-only inspector for the module-global cache size. */ +export function apiKeyUsageCacheSizeForTest(): number { + return touchedByProcess.size; +} diff --git a/packages/server/middleware/authenticate-space.integration.test.ts b/packages/server/middleware/authenticate-space.integration.test.ts index 3fc8974d..f9d3453e 100644 --- a/packages/server/middleware/authenticate-space.integration.test.ts +++ b/packages/server/middleware/authenticate-space.integration.test.ts @@ -36,6 +36,7 @@ const rand = () => { return s; }; const email = () => `space_${crypto.randomUUID().slice(0, 8)}@example.com`; +const today = () => new Date().toISOString().slice(0, 10); let sql: Sql; let authSchema: string; @@ -217,6 +218,7 @@ test("api key: agent of the space resolves with apiKeyId set", async () => { expect(result.context.apiKeyId).not.toBeNull(); expect(result.context.treeAccess.length).toBeGreaterThan(0); } + expect((await core.getApiKey(key.id))?.lastUsedOn).toBe(today()); }); test("api key: a user's own key (PAT) resolves as the user with full grants", async () => { @@ -457,6 +459,40 @@ test("api key: agent that is not a member of the requested space → 403", async ); expect(result.ok).toBe(false); if (!result.ok) expect(result.error.status).toBe(403); + expect((await core.getApiKey(key.id))?.lastUsedOn).toBe(today()); +}); + +test("api key: invalid secret does not record usage", async () => { + const p = await provision(); + const core = engineCore.coreStore(sql, coreSchema); + const key = await core.createApiKey(p.userId, "bad-secret-test"); + const invalid = engineCore.formatApiKey(key.lookupId, "s".repeat(32)); + + const result = await authenticateSpace( + req({ token: invalid, space: p.spaceSlug }), + deps(), + ); + + expect(result.ok).toBe(false); + expect((await core.getApiKey(key.id))?.lastUsedOn).toBeNull(); +}); + +test("session: authenticating with a session does not touch the user's api keys", async () => { + // Same user has both a valid session (via provision) and an unrelated PAT. + // A session auth must not touch the PAT's last_used_on — usage recording is + // scoped to the credential that was actually presented. + const p = await provision(); + const core = engineCore.coreStore(sql, coreSchema); + const key = await core.createApiKey(p.userId, `session-noop-${rand()}`); + + const result = await authenticateSpace( + req({ token: p.token, space: p.spaceSlug }), + deps(), + ); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.context.apiKeyId).toBeNull(); + expect((await core.getApiKey(key.id))?.lastUsedOn).toBeNull(); }); test("session: member of another space is not a member here → 403", async () => { diff --git a/packages/server/middleware/authenticate-space.ts b/packages/server/middleware/authenticate-space.ts index bfe9b045..256212a2 100644 --- a/packages/server/middleware/authenticate-space.ts +++ b/packages/server/middleware/authenticate-space.ts @@ -31,6 +31,7 @@ import type { Sql } from "postgres"; import type { Auth, VerifyOAuthAccessToken } from "../auth/betterauth"; import { error, forbidden, unauthorized } from "../util/response"; import { resolveOwnedAgent } from "./act-as-agent"; +import { recordApiKeyUse } from "./api-key-usage"; import { bearerOnlyHeaders, extractBearerToken, @@ -180,6 +181,7 @@ async function authenticateSpaceInner( return { ok: false, error: unauthorized("Invalid credentials") }; } principalKind = validated.kind; + await recordApiKeyUse(core, validated.apiKeyId); } else if (bearer && isLegacyApiKey(bearer)) { // A pre-global 4-part key (me...). These no longer // authenticate; tell the operator to recreate the key rather than failing diff --git a/packages/server/middleware/authenticate-user.integration.test.ts b/packages/server/middleware/authenticate-user.integration.test.ts index f6129ede..af141394 100644 --- a/packages/server/middleware/authenticate-user.integration.test.ts +++ b/packages/server/middleware/authenticate-user.integration.test.ts @@ -34,6 +34,7 @@ const rand = () => { }; const email = () => `u_${crypto.randomUUID().slice(0, 8)}@example.com`; const ALLOWED = ["https://test.example.com"]; +const today = () => new Date().toISOString().slice(0, 10); let sql: Sql; let authSchema: string; @@ -113,12 +114,18 @@ async function seedUser(): Promise { test("OAuth access token authenticates as the user (not viaApiKey)", async () => { const userId = await seedUser(); + // Mint a PAT for the same user so we can assert the session/OAuth path + // does NOT touch it — usage recording is scoped to the credential actually + // presented, not to every key the user owns. + const key = await core.createApiKey(userId, `oauth-noop-${rand()}`); + const result = await auth(await mintAccessToken(userId)); expect(result.ok).toBe(true); if (result.ok) { expect(result.context.userId).toBe(userId); expect(result.context.viaApiKey).toBe(false); } + expect((await core.getApiKey(key.id))?.lastUsedOn).toBeNull(); }); test("the user's own api key (PAT) authenticates as the user (viaApiKey), carrying the real emailVerified", async () => { @@ -133,6 +140,7 @@ test("the user's own api key (PAT) authenticates as the user (viaApiKey), carryi // PAT behaves like a session (incl. the email-keyed redemption step). expect(result.context.emailVerified).toBe(true); } + expect((await core.getApiKey(key.id))?.lastUsedOn).toBe(today()); }); test("a PAT for an unverified user reports emailVerified=false (read from the DB, not faked)", async () => { diff --git a/packages/server/middleware/authenticate-user.ts b/packages/server/middleware/authenticate-user.ts index 58de6737..327c304a 100644 --- a/packages/server/middleware/authenticate-user.ts +++ b/packages/server/middleware/authenticate-user.ts @@ -23,6 +23,7 @@ import type { } from "../auth/betterauth"; import { error, forbidden, unauthorized } from "../util/response"; import { resolveOwnedAgent } from "./act-as-agent"; +import { recordApiKeyUse } from "./api-key-usage"; import { bearerOnlyHeaders, extractBearerToken, @@ -131,6 +132,7 @@ export async function authenticateUser( error: unauthorized("Invalid or expired token"), }; } + await recordApiKeyUse(core, validated.apiKeyId); const isUser = validated.kind === "u"; debug("user auth succeeded (api key)", { userId: validated.memberId, diff --git a/packages/server/rpc/user/api-key.integration.test.ts b/packages/server/rpc/user/api-key.integration.test.ts index 34a6b908..9e42e7bd 100644 --- a/packages/server/rpc/user/api-key.integration.test.ts +++ b/packages/server/rpc/user/api-key.integration.test.ts @@ -100,15 +100,21 @@ test("create (global, no space needed) / list / get / delete", async () => { // Global format: me.. — no space slug. expect(created.key).toMatch(/^me\.[A-Za-z0-9_-]{16}\.[A-Za-z0-9_-]{32}$/); - const list = await call<{ apiKeys: { id: string }[] }>("apiKey.list", { + const list = await call<{ + apiKeys: { id: string; lastUsedOn: string | null }[]; + }>("apiKey.list", { memberId: agentId, }); expect(list.apiKeys.map((k) => k.id)).toContain(created.id); + expect(list.apiKeys.find((k) => k.id === created.id)?.lastUsedOn).toBeNull(); - const got = await call<{ apiKey: { id: string } | null }>("apiKey.get", { - id: created.id, - }); + await coreStore(sql, coreSchema).touchApiKey(created.id, "2026-07-17"); + + const got = await call<{ + apiKey: { id: string; lastUsedOn: string | null } | null; + }>("apiKey.get", { id: created.id }); expect(got.apiKey?.id).toBe(created.id); + expect(got.apiKey?.lastUsedOn).toBe("2026-07-17"); expect( (await call<{ deleted: boolean }>("apiKey.delete", { id: created.id })) diff --git a/packages/server/rpc/user/api-key.ts b/packages/server/rpc/user/api-key.ts index a37d7178..59623b9f 100644 --- a/packages/server/rpc/user/api-key.ts +++ b/packages/server/rpc/user/api-key.ts @@ -84,6 +84,7 @@ function toApiKeyInfoResponse(k: ApiKeyInfo): ApiKeyInfoResponse { name: k.name, createdAt: k.createdAt.toISOString(), expiresAt: k.expiresAt?.toISOString() ?? null, + lastUsedOn: k.lastUsedOn, }; }