Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
import { createHash, randomBytes } from 'node:crypto';
import type { AgentKey, Role } from '@wellformed/shared';
import { newId } from '../engine/ids.js';
import type { ControlDb } from './db.js';
/**
* Agent keys.
*
* The credential an AI assistant presents to reach one workspace. Same storage
* discipline as sessions and invites — 32 random bytes, kept only as a SHA-256
* hash, shown once — but long-lived and scoped, which is what makes it usable
* by a program that has no browser and no way to answer a password prompt.
*
* The scope is the point. A session opens every workspace its account belongs
* to; a key opens exactly one. So handing an assistant a key is a decision
* about one workspace, and revoking it is too.
*/
/** Marks the secret as ours in a log, a leak scanner or a pasted config file. */
export const AGENT_KEY_PREFIX = 'wf_';
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
export interface AgentKeyRecord {
id: string;
accountId: string;
workspaceId: string;
label: string;
ceiling: Role;
createdAt: string;
lastUsedAt: string | null;
expiresAt: string | null;
revokedAt: string | null;
}
interface KeyRow {
id: string;
account_id: string;
workspace_id: string;
label: string;
ceiling: string;
created_at: string;
last_used_at: string | null;
expires_at: string | null;
revoked_at: string | null;
}
const COLUMNS =
'id, account_id, workspace_id, label, ceiling, created_at, last_used_at, expires_at, revoked_at';
function toRecord(row: KeyRow): AgentKeyRecord {
return {
id: row.id,
accountId: row.account_id,
workspaceId: row.workspace_id,
label: row.label,
ceiling: row.ceiling as Role,
createdAt: row.created_at,
lastUsedAt: row.last_used_at,
expiresAt: row.expires_at,
revokedAt: row.revoked_at,
};
}
export interface NewAgentKey {
accountId: string;
workspaceId: string;
label: string;
ceiling: Role;
/** Days until it stops working on its own. Omitted means until revoked. */
expiresInDays?: number | null;
}
export interface IssuedAgentKey {
record: AgentKeyRecord;
/** Returned once, at creation. Only its hash is stored. */
secret: string;
}
export function createAgentKey(control: ControlDb, input: NewAgentKey): IssuedAgentKey {
const secret = AGENT_KEY_PREFIX + randomBytes(32).toString('base64url');
const id = newId('key');
const expiresAt =
input.expiresInDays && input.expiresInDays > 0
? new Date(Date.now() + input.expiresInDays * 24 * 60 * 60_000).toISOString()
: null;
control.sqlite
.prepare(
`INSERT INTO agent_key (id, token_hash, account_id, workspace_id, label, ceiling, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
.run(
id,
hashToken(secret),
input.accountId,
input.workspaceId,
input.label.trim(),
input.ceiling,
expiresAt,
);
return { record: getAgentKey(control, id)!, secret };
}
export function getAgentKey(control: ControlDb, id: string): AgentKeyRecord | null {
const row = control.sqlite
.prepare<[string], KeyRow>(`SELECT ${COLUMNS} FROM agent_key WHERE id = ?`)
.get(id);
return row ? toRecord(row) : null;
}
/**
* Resolve a presented secret to a live key.
*
* Null for unknown, revoked and expired alike — a caller learns only that it
* did not work, never which of the three it was.
*/
export function resolveAgentKey(control: ControlDb, presented: string | undefined): AgentKeyRecord | null {
if (!presented || !presented.startsWith(AGENT_KEY_PREFIX)) return null;
const row = control.sqlite
.prepare<[string], KeyRow>(`SELECT ${COLUMNS} FROM agent_key WHERE token_hash = ?`)
.get(hashToken(presented));
if (!row) return null;
const record = toRecord(row);
if (record.revokedAt) return null;
if (record.expiresAt && new Date(record.expiresAt).getTime() <= Date.now()) return null;
return record;
}
/** Bump `last_used_at`, at most once a minute per key. */
const lastTouch = new Map<string, number>();
export function touchAgentKey(control: ControlDb, id: string): void {
const now = Date.now();
const previous = lastTouch.get(id) ?? 0;
if (now - previous < 60_000) return;
lastTouch.set(id, now);
control.sqlite.prepare(`UPDATE agent_key SET last_used_at = datetime('now') WHERE id = ?`).run(id);
}
export function revokeAgentKey(control: ControlDb, id: string): boolean {
const info = control.sqlite
.prepare(`UPDATE agent_key SET revoked_at = datetime('now') WHERE id = ? AND revoked_at IS NULL`)
.run(id);
return info.changes === 1;
}
export interface ListOptions {
workspaceId: string;
/** Restrict to one account's keys. Omitted lists the whole workspace. */
accountId?: string;
}
/**
* List keys, newest first, with the holder's name joined in.
*
* Revoked keys are dropped rather than shown struck through: a key that no
* longer works is not a thing to manage, and the audit trail is where the
* history of what it did already lives.
*/
export function listAgentKeys(control: ControlDb, options: ListOptions): AgentKey[] {
const params: unknown[] = [options.workspaceId];
let scope = '';
if (options.accountId) {
scope = ' AND k.account_id = ?';
params.push(options.accountId);
}
return control.sqlite
.prepare<unknown[], KeyRow & { account_name: string }>(
`SELECT k.id, k.account_id, k.workspace_id, k.label, k.ceiling, k.created_at,
k.last_used_at, k.expires_at, k.revoked_at, a.name AS account_name
FROM agent_key k JOIN account a ON a.id = k.account_id
WHERE k.workspace_id = ? AND k.revoked_at IS NULL${scope}
ORDER BY k.created_at DESC`,
)
.all(...params)
.map((row) => ({
id: row.id,
label: row.label,
ceiling: row.ceiling as Role,
createdAt: row.created_at,
lastUsedAt: row.last_used_at,
expiresAt: row.expires_at,
accountId: row.account_id,
accountName: row.account_name,
}));
}
/** Housekeeping: revoked and long-expired keys are not history worth keeping. */
export function pruneStaleAgentKeys(control: ControlDb): number {
const info = control.sqlite
.prepare(
`DELETE FROM agent_key
WHERE revoked_at <= datetime('now', '-30 days')
OR expires_at <= datetime('now', '-30 days')`,
)
.run();
return info.changes;
}