Spreadsheet-UX op een echte relationele engine

main

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

3.9 kB Ruw bestand
import type { FastifyInstance, FastifyRequest } from 'fastify';
import fp from 'fastify-plugin';
import type { Account } from '@wellformed/shared';
import { unauthorized } from '../errors.js';
import type { ControlDb } from '../control/db.js';
import { touchLastSeen } from '../control/accounts.js';
import { resolveSession, SESSION_COOKIE } from '../control/sessions.js';
import { getAccountById } from '../control/accounts.js';
import { resolveAgentKey, touchAgentKey, type AgentKeyRecord } from '../control/agent-keys.js';
import type { TenantRegistry } from '../tenancy/registry.js';
import type { Actor } from '../engine/audit.js';

declare module 'fastify' {
  interface FastifyInstance {
    control: ControlDb;
    registry: TenantRegistry;
  }
  interface FastifyRequest {
    /** Who is calling, installation-wide. Says nothing about what they may reach. */
    account: Account | null;
    /**
     * The agent key this request arrived on, when it came from an assistant
     * rather than a browser.
     *
     * Setting this does not widen anything: the account is still the account,
     * and the key's workspace and ceiling only ever narrow what that account
     * could already do. The tenant plugin is where both are applied.
     */
    agent: AgentKeyRecord | null;
    /** Throws 401 when unauthenticated. */
    requireAccount(): Account;
    actor(): Actor;
  }
}

export interface ContextOptions {
  control: ControlDb;
  registry: TenantRegistry;
}

/**
 * Attaches the control database, the workspace registry and the calling
 * account to every request.
 *
 * Authentication only. Authorisation is deliberately not global: a route is
 * public until it asks otherwise, so a route added without a `requireAccount()`
 * or a workspace scope is visibly public rather than accidentally so.
 */
export const contextPlugin = fp<ContextOptions>(async (app: FastifyInstance, opts) => {
  const { control, registry } = opts;

  app.decorate('control', control);
  app.decorate('registry', registry);

  app.decorateRequest('account', null);
  app.decorateRequest('agent', null);

  app.decorateRequest('requireAccount', function (this: FastifyRequest): Account {
    if (!this.account) throw unauthorized();
    return this.account;
  });

  app.decorateRequest('actor', function (this: FastifyRequest): Actor {
    if (!this.account) return { id: null, name: 'anonymous' };
    return { id: this.account.id, name: this.account.name, via: this.agent?.label ?? null };
  });

  const lastTouch = new Map<string, number>();

  app.addHook('onRequest', async (request) => {
    /**
     * A presented agent key wins over an ambient cookie.
     *
     * They should never both be there — an assistant has no cookie jar — but if
     * they are, the credential the caller went out of its way to send is the
     * one it meant, and it is the narrower of the two.
     */
    const agent = resolveAgentKey(control, bearerToken(request));
    if (agent) {
      const account = getAccountById(control, agent.accountId);
      // A disabled account takes its keys down with it, same as its sessions.
      if (account && !account.disabled) {
        request.agent = agent;
        request.account = account;
        touchAgentKey(control, agent.id);
        return;
      }
    }

    const token = request.cookies[SESSION_COOKIE];
    request.account = resolveSession(control, token);

    // Refresh `last_seen_at` at most once a minute per account; it is a nicety,
    // not a reason to write to disk on every request.
    if (request.account) {
      const now = Date.now();
      const previous = lastTouch.get(request.account.id) ?? 0;
      if (now - previous > 60_000) {
        lastTouch.set(request.account.id, now);
        touchLastSeen(control, request.account.id);
      }
    }
  });
});

function bearerToken(request: FastifyRequest): string | undefined {
  const header = request.headers.authorization;
  if (!header) return undefined;
  const match = /^Bearer\s+(\S+)$/i.exec(header);
  return match?.[1];
}