Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/cli/me-apikey.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,9 @@ me apikey list [--agent <agent> | --service <service>]
| `--agent <agent>` | List one of your agents' keys (id or name) instead of your own. |
| `--service <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.

---

Expand Down
11 changes: 9 additions & 2 deletions packages/cli/commands/apikey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 });
Expand Down
34 changes: 32 additions & 2 deletions packages/database/core/migrate/idempotent/008_api_key.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -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
)
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
alter table {{schema}}.api_key
add column last_used_on date;
63 changes: 63 additions & 0 deletions packages/database/core/migrate/migrate.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
8 changes: 8 additions & 0 deletions packages/database/core/migrate/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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[] = [
Expand Down
2 changes: 1 addition & 1 deletion packages/database/core/version.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const CORE_SCHEMA_VERSION = "0.0.4";
export const CORE_SCHEMA_VERSION = "0.0.5";
7 changes: 7 additions & 0 deletions packages/engine/core/core.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).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();
Expand Down
7 changes: 7 additions & 0 deletions packages/engine/core/db.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
27 changes: 25 additions & 2 deletions packages/engine/core/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,8 @@ export interface CoreStore {
): Promise<ValidatedApiKey | null>;
getApiKey(id: string): Promise<ApiKeyInfo | null>;
listApiKeys(memberId: string): Promise<ApiKeyInfo[]>;
/** Best-effort day-level usage tracking. `usedOn` is UTC YYYY-MM-DD. */
touchApiKey(id: string, usedOn: string): Promise<boolean>;
/** Hard-delete a key (revoke ≡ delete; there is no soft-revoke state). */
deleteApiKey(id: string): Promise<boolean>;

Expand Down Expand Up @@ -356,9 +358,17 @@ function mapApiKeyInfo(row: Record<string, unknown>): 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

Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions packages/engine/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/protocol/user/api-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof apiKeyInfoResponse>;

Expand Down
Loading