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(); 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); }; }