Spreadsheet-UX op een echte relationele engine

main

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

5.4 kB Ruw bestand
import { createHash, randomBytes } from 'node:crypto';
import type { PendingInvite, Role } from '@wellformed/shared';
import { newId } from '../engine/ids.js';
import { normaliseEmail } from './accounts.js';
import type { ControlDb } from './db.js';

/**
 * Invites.
 *
 * The link is the credential. Wellformed sends no email — an admin creates an
 * invite and passes the URL along themselves — so whoever holds the token can
 * accept it. Three things keep that honest: the token is 32 random bytes and
 * stored only as a hash, it expires, and it can be accepted exactly once.
 *
 * The invite also names an email, and accepting is only possible as that email:
 * either the signed-in account already has it, or accepting creates an account
 * with it. That stops a forwarded link from quietly attaching a stranger's
 * existing account to the workspace.
 */
export const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000;

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

export interface InviteRecord {
  id: string;
  workspaceId: string;
  email: string;
  role: Role;
  invitedBy: string | null;
  createdAt: string;
  expiresAt: string;
  acceptedAt: string | null;
}

interface InviteRow {
  id: string;
  workspace_id: string;
  email: string;
  role: string;
  invited_by: string | null;
  created_at: string;
  expires_at: string;
  accepted_at: string | null;
}

function toRecord(row: InviteRow): InviteRecord {
  return {
    id: row.id,
    workspaceId: row.workspace_id,
    email: row.email,
    role: row.role as Role,
    invitedBy: row.invited_by,
    createdAt: row.created_at,
    expiresAt: row.expires_at,
    acceptedAt: row.accepted_at,
  };
}

const COLUMNS = 'id, workspace_id, email, role, invited_by, created_at, expires_at, accepted_at';

export interface IssuedInvite {
  record: InviteRecord;
  /** Shown once, at creation. Never recoverable afterwards. */
  token: string;
}

export function createInvite(
  control: ControlDb,
  input: { workspaceId: string; email: string; role: Role; invitedBy: string | null },
): IssuedInvite {
  const token = randomBytes(32).toString('base64url');
  const id = newId('inv');
  const email = normaliseEmail(input.email);
  const expiresAt = new Date(Date.now() + INVITE_TTL_MS).toISOString();

  // One live invite per address per workspace: re-inviting replaces the old
  // link rather than leaving two working tokens behind.
  control.sqlite
    .prepare('DELETE FROM invite WHERE workspace_id = ? AND email = ? AND accepted_at IS NULL')
    .run(input.workspaceId, email);

  control.sqlite
    .prepare(
      `INSERT INTO invite (id, token_hash, workspace_id, email, role, invited_by, expires_at)
       VALUES (?, ?, ?, ?, ?, ?, ?)`,
    )
    .run(id, hashToken(token), input.workspaceId, email, input.role, input.invitedBy, expiresAt);

  return { record: getInviteById(control, id)!, token };
}

export function getInviteById(control: ControlDb, id: string): InviteRecord | null {
  const row = control.sqlite
    .prepare<[string], InviteRow>(`SELECT ${COLUMNS} FROM invite WHERE id = ?`)
    .get(id);
  return row ? toRecord(row) : null;
}

/** Look an invite up by its token. Returns null for unknown, spent or expired tokens alike. */
export function getLiveInviteByToken(control: ControlDb, token: string): InviteRecord | null {
  const row = control.sqlite
    .prepare<[string], InviteRow>(`SELECT ${COLUMNS} FROM invite WHERE token_hash = ?`)
    .get(hashToken(token));
  if (!row) return null;
  const record = toRecord(row);
  if (record.acceptedAt) return null;
  if (new Date(record.expiresAt).getTime() <= Date.now()) return null;
  return record;
}

/**
 * Mark an invite spent.
 *
 * The `accepted_at IS NULL` guard makes this the atomic claim: two requests
 * racing the same link produce exactly one winner, and `changes` says which.
 */
export function claimInvite(control: ControlDb, id: string, accountId: string): boolean {
  const info = control.sqlite
    .prepare(
      `UPDATE invite SET accepted_at = datetime('now'), accepted_by = ?
       WHERE id = ? AND accepted_at IS NULL`,
    )
    .run(accountId, id);
  return info.changes === 1;
}

export function revokeInvite(control: ControlDb, id: string): void {
  control.sqlite.prepare('DELETE FROM invite WHERE id = ? AND accepted_at IS NULL').run(id);
}

export function listPendingInvites(control: ControlDb, workspaceId: string): PendingInvite[] {
  return control.sqlite
    .prepare<[string], InviteRow & { inviter: string | null }>(
      `SELECT i.id, i.workspace_id, i.email, i.role, i.invited_by, i.created_at, i.expires_at,
              i.accepted_at, a.name AS inviter
       FROM invite i LEFT JOIN account a ON a.id = i.invited_by
       WHERE i.workspace_id = ? AND i.accepted_at IS NULL AND i.expires_at > datetime('now')
       ORDER BY i.created_at DESC`,
    )
    .all(workspaceId)
    .map((row) => ({
      id: row.id,
      email: row.email,
      role: row.role as Role,
      invitedBy: row.inviter,
      createdAt: row.created_at,
      expiresAt: row.expires_at,
    }));
}

/** Housekeeping: spent and expired invites are not worth keeping forever. */
export function pruneStaleInvites(control: ControlDb): number {
  const info = control.sqlite
    .prepare(
      `DELETE FROM invite
       WHERE expires_at <= datetime('now', '-30 days')
          OR accepted_at <= datetime('now', '-30 days')`,
    )
    .run();
  return info.changes;
}