Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

6.3 kB Ruw bestand
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;
}