Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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);
};
}