Spreadsheet-UX op een echte relationele engine

main

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

8.8 kB Ruw bestand
import type { FastifyInstance, FastifyPluginAsync, FastifyRequest } from 'fastify';
import { isValidWorkspaceSlug, ROLE_RANK, type Account, type PlanId, type Role } from '@wellformed/shared';
import { badRequest, forbidden, notFound, unauthorized } from '../errors.js';
import { config } from '../config.js';
import type { Db } from '../db/driver.js';
import { loadMeta, type MetaSnapshot } from '../db/meta.js';
import { getMembershipRole, getWorkspaceBySlug, type WorkspaceRecord } from '../control/workspaces.js';
import { effectivePlan, getSubscription } from '../control/billing.js';
import type { Actor } from '../engine/audit.js';
import { recordActiveDay } from '../voice/store.js';

const WRITE_METHODS = new Set(['POST', 'PATCH', 'PUT', 'DELETE']);

/** Everything a workspace-scoped route needs, resolved once per request. */
export interface TenantContext {
  workspace: WorkspaceRecord;
  /** The calling account's role *in this workspace*. */
  role: Role;
  db: Db;
  meta(): MetaSnapshot;
  /**
   * Throws 401/403 unless the caller holds at least `role` here.
   *
   * Anything above `viewer` implies an account, so those overloads return one.
   * `viewer` does not: the published demo workspace is readable by someone who
   * has no account at all, and the null is there so a route that wants to
   * attribute a read has to notice that there may be nobody to attribute it to.
   */
  require(role: 'editor' | 'admin'): Account;
  require(role: Role): Account | null;
  /**
   * Throws 403 when this request came through an agent key.
   *
   * For the handful of actions that change *who can reach the workspace*
   * rather than what is in it — the roster, invites, billing, deleting the
   * workspace, and minting more keys. An assistant does the work; it does not
   * decide who else gets to. Anything a member does to the data is fair game
   * and deliberately not behind this.
   */
  denyAgent(what: string): void;
  actor(): Actor;
  /**
   * The plan whose limits apply to this workspace right now.
   *
   * Resolved here rather than at each write site so that "which plan is this?"
   * has exactly one answer per request, and so a route that grows the
   * workspace cannot forget which control-plane row to ask.
   */
  plan(): PlanId;
}

declare module 'fastify' {
  interface FastifyRequest {
    /**
     * The workspace this request addresses.
     *
     * Only ever populated inside the `/api/w/:slug` scope — routes registered
     * anywhere else never see the hook that sets it.
     */
    ws: TenantContext;
  }
}

export const WORKSPACE_PREFIX = '/api/w/:slug';

/**
 * The tenant boundary.
 *
 * Every workspace-scoped route sits inside this plugin, and the only way in is
 * the hook below: resolve the slug to a workspace, look up the caller's
 * membership, and open that workspace's database. No membership row means 404,
 * not 403 — an outsider probing slugs learns nothing about which ones exist.
 *
 * Because the handle comes from the registry, which pools and evicts, the
 * request holds a reference for as long as it runs and gives it back in
 * `onResponse`. Without that, an evicted handle could be closed underneath a
 * handler that awaited something between two queries.
 */
export function tenantScope(routes: FastifyPluginAsync[]): FastifyPluginAsync {
  return async (app: FastifyInstance) => {
    const held = new WeakSet<FastifyRequest>();

    app.addHook('onRequest', async (request) => {
      const { slug } = request.params as { slug?: string };
      if (!slug || !isValidWorkspaceSlug(slug)) {
        throw badRequest('That is not a valid workspace address.');
      }

      const workspace = getWorkspaceBySlug(app.control, slug);

      /**
       * One of the workspaces the operator published, if this is one.
       *
       * Named in the environment rather than togglable in the product, so
       * publishing a customer's database takes shell access — see `demoSlugs`
       * in the config. Everything below still runs: a visitor is a `viewer`
       * with no account, not a bypass of the tenant check.
       */
      const published = workspace !== null && config.demoSlugs.includes(workspace.slug);

      const account = published ? request.account : request.requireAccount();
      const membership =
        workspace && account ? getMembershipRole(app.control, workspace.id, account.id) : null;
      let role: Role | null = membership ?? (published ? 'viewer' : null);

      /**
       * An agent key names one workspace, and this is where that binds.
       *
       * A key for another workspace is not a weaker credential here, it is no
       * credential at all — so it gets the same 404 as a stranger, rather than
       * falling back on whatever membership the account happens to hold. That
       * is what makes a key safe to hand out: it cannot be pointed somewhere
       * else by editing the URL.
       */
      const agent = request.agent;
      if (agent) {
        if (!workspace || agent.workspaceId !== workspace.id) throw notFound(`No workspace "${slug}".`);
        // The ceiling caps, it never grants. Demote the account and every key
        // it made drops with it, on the next request, with nothing to clean up.
        if (role && ROLE_RANK[agent.ceiling] < ROLE_RANK[role]) role = agent.ceiling;
      }

      // One message for "no such workspace" and "not yours". Distinguishing
      // them would turn this route into a directory of every customer here.
      if (!workspace || !role) throw notFound(`No workspace "${slug}".`);

      if (workspace.status === 'suspended') {
        throw forbidden(`The workspace "${workspace.name}" is suspended.`);
      }

      const db = app.registry.acquire(workspace);
      held.add(request);

      const require = (needed: Role): Account | null => {
        if (ROLE_RANK[role] < ROLE_RANK[needed]) {
          if (!account) throw unauthorized('Sign in to do that.');
          throw forbidden(`This action needs the ${needed} role; you are a ${role} here.`);
        }
        return account;
      };

      request.ws = {
        workspace,
        role,
        db,
        meta: () => loadMeta(db),
        require: require as TenantContext['require'],
        denyAgent: (what: string) => {
          if (!agent) return;
          throw forbidden(
            `The agent key "${agent.label}" cannot ${what}. Sign in to do that yourself.`,
          );
        },
        plan: () => effectivePlan(getSubscription(app.control, workspace.id)),
        // Only reached from routes that write, and every one of those requires
        // at least `editor`, which a visitor can never hold.
        actor: () => {
          if (!account) throw unauthorized('Sign in to do that.');
          return { id: account.id, name: account.name, via: agent?.label ?? null };
        },
      };
    });

    app.addHook('onResponse', async (request, reply) => {
      /**
       * A day on which this person did something.
       *
       * Here rather than in the request context plugin because *use* means
       * writing, not looking: a browser left open on a grid overnight polls,
       * and counting that as engagement would make the landing page's reward
       * signal mostly a measure of who leaves tabs open. A write that was
       * refused does not count either, which is why this reads the status.
       *
       * Cheap enough to leave unguarded — one INSERT OR IGNORE against a
       * two-column primary key, at most once per account per day that does
       * anything at all.
       */
      const account = request.account;
      if (account && WRITE_METHODS.has(request.method) && reply.statusCode < 400) {
        recordActiveDay(app.control, account.id);
      }

      if (!held.delete(request)) return;
      app.registry.release(request.ws.workspace.id);
    });

    // The same release has to happen when the connection dies before a response.
    app.addHook('onRequestAbort', (request, done) => {
      if (held.delete(request)) app.registry.release(request.ws.workspace.id);
      done();
    });

    for (const route of routes) await app.register(route);
  };
}

/**
 * Wrap route plugins that an agent key may never reach.
 *
 * The same argument as `tenantScope` itself: this is enforced by where a route
 * sits, not by each handler remembering to ask. What belongs in here is
 * anything that changes *who can reach the workspace* rather than what is in
 * it — the roster, invites, and the money. Everything an assistant is for,
 * including schema work, stays outside.
 *
 * The hook runs after `tenantScope`'s, so `request.ws` is already resolved and
 * a caller with no business here still gets its 404 first.
 */
export function humanOnly(what: string, routes: FastifyPluginAsync[]): FastifyPluginAsync {
  return async (app: FastifyInstance) => {
    app.addHook('onRequest', async (request) => {
      request.ws.denyAgent(what);
    });
    for (const route of routes) await app.register(route);
  };
}