Spreadsheet-UX op een echte relationele engine

main

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

4.1 kB Ruw bestand
import { createHash, randomBytes } from 'node:crypto';
import { newId } from '../engine/ids.js';
import type { ControlDb } from './db.js';

/**
 * One-shot account tokens.
 *
 * Same shape as an invite, and for the same reasons: 32 random bytes, stored
 * only as a SHA-256 hash, spendable exactly once, and expiring on its own. The
 * difference is what they stand for — an invite grants a role in one workspace,
 * these two stand in for the *account itself*.
 *
 * That is why a reset lives for an hour rather than a week. An invite link that
 * leaks costs one workspace; a reset link that leaks costs every workspace the
 * account can open. Verification is harmless by comparison and gets a day.
 */
export type TokenPurpose = 'reset' | 'verify';

export const TOKEN_TTL_MS: Record<TokenPurpose, number> = {
  reset: 60 * 60_000,
  verify: 24 * 60 * 60_000,
};

function hashToken(token: string): string {
  return createHash('sha256').update(token).digest('hex');
}

export interface AccountToken {
  id: string;
  accountId: string;
  purpose: TokenPurpose;
  createdAt: string;
  expiresAt: string;
  usedAt: string | null;
}

interface TokenRow {
  id: string;
  account_id: string;
  purpose: string;
  created_at: string;
  expires_at: string;
  used_at: string | null;
}

function toToken(row: TokenRow): AccountToken {
  return {
    id: row.id,
    accountId: row.account_id,
    purpose: row.purpose as TokenPurpose,
    createdAt: row.created_at,
    expiresAt: row.expires_at,
    usedAt: row.used_at,
  };
}

export interface IssuedToken {
  record: AccountToken;
  /** Returned once, at creation. Only its hash is stored. */
  token: string;
}

/**
 * Issue a token, replacing any live one for the same account and purpose.
 *
 * Replacing rather than adding matters: someone who clicks "forgot password"
 * four times should end up with one working link, not four, and the newest is
 * the one in the message they are actually looking at.
 */
export function issueToken(
  control: ControlDb,
  accountId: string,
  purpose: TokenPurpose,
): IssuedToken {
  const token = randomBytes(32).toString('base64url');
  const id = newId('tok');
  const expiresAt = new Date(Date.now() + TOKEN_TTL_MS[purpose]).toISOString();

  control.sqlite
    .prepare('DELETE FROM account_token WHERE account_id = ? AND purpose = ? AND used_at IS NULL')
    .run(accountId, purpose);

  control.sqlite
    .prepare(
      `INSERT INTO account_token (id, token_hash, account_id, purpose, expires_at)
       VALUES (?, ?, ?, ?, ?)`,
    )
    .run(id, hashToken(token), accountId, purpose, expiresAt);

  const record = control.sqlite
    .prepare<[string], TokenRow>(
      'SELECT id, account_id, purpose, created_at, expires_at, used_at FROM account_token WHERE id = ?',
    )
    .get(id)!;

  return { record: toToken(record), token };
}

/** Null for unknown, spent, expired and wrong-purpose tokens alike. */
export function getLiveToken(
  control: ControlDb,
  token: string,
  purpose: TokenPurpose,
): AccountToken | null {
  const row = control.sqlite
    .prepare<[string], TokenRow>(
      'SELECT id, account_id, purpose, created_at, expires_at, used_at FROM account_token WHERE token_hash = ?',
    )
    .get(hashToken(token));
  if (!row) return null;

  const record = toToken(row);
  if (record.purpose !== purpose) return null;
  if (record.usedAt) return null;
  if (new Date(record.expiresAt).getTime() <= Date.now()) return null;
  return record;
}

/**
 * Spend a token. The `used_at IS NULL` guard is the atomic claim, so two
 * requests racing the same link produce exactly one winner.
 */
export function claimToken(control: ControlDb, id: string): boolean {
  const info = control.sqlite
    .prepare(`UPDATE account_token SET used_at = datetime('now') WHERE id = ? AND used_at IS NULL`)
    .run(id);
  return info.changes === 1;
}

/** Housekeeping, same as invites: spent and expired tokens are not history. */
export function pruneStaleTokens(control: ControlDb): number {
  const info = control.sqlite
    .prepare(
      `DELETE FROM account_token
       WHERE expires_at <= datetime('now', '-7 days')
          OR used_at <= datetime('now', '-7 days')`,
    )
    .run();
  return info.changes;
}