import { createReadStream } from 'node:fs'; import type { FastifyInstance } from 'fastify'; import { slugifyWorkspaceName, type Workspace } from '@wellformed/shared'; import { badRequest, conflict } from '../errors.js'; import { transactControl } from '../control/db.js'; import { countActiveAdmins, deleteWorkspace, getWorkspaceBySlug, listMembers, listWorkspacesForAccount, removeMembership, renameWorkspace, setWorkspaceCurrency, setWorkspaceLocale, setWorkspaceSlug, workspaceForAccount, } from '../control/workspaces.js'; import { localeFromHeader } from '../locale.js'; import { startTrial } from '../control/billing.js'; import { availableSlug, provisionWorkspace } from '../tenancy/provision.js'; import { snapshotFilename, snapshotWorkspace } from '../tenancy/export.js'; import { recordAudit } from '../engine/audit.js'; import { createWorkspaceSchema, updateWorkspaceSchema } from './schemas.js'; /** * Questions about the account's workspaces rather than about their contents. * * Outside `/api/w/:slug` because both have to work for someone who has no * workspace yet, or is about to make their second. */ export async function workspaceRoutes(app: FastifyInstance): Promise { app.get('/api/workspaces', async (request): Promise<{ workspaces: Workspace[] }> => { const account = request.requireAccount(); return { workspaces: listWorkspacesForAccount(app.control, account.id) }; }); app.post('/api/workspaces', async (request, reply) => { const account = request.requireAccount(); const body = createWorkspaceSchema.parse(request.body); if (body.slug && getWorkspaceBySlug(app.control, body.slug)) { throw conflict(`The workspace address "${body.slug}" is already taken.`); } const slug = body.slug ?? availableSlug(app.control, slugifyWorkspaceName(body.name)); const created = provisionWorkspace(app.control, app.registry, { slug, name: body.name, ownerId: account.id, demo: body.demo, locale: body.locale ?? localeFromHeader(request.headers['accept-language']), }); // A new workspace starts on a trial of the paid plan, so the first thing // someone does in it is never met by a limit. startTrial(app.control, created.id); reply.code(201); return { workspace: workspaceForAccount(app.control, created.id, account.id)! }; }); } /** * Acting on one workspace. Registered inside the tenant scope, so membership * has already been proved by the time any of these run. */ export async function workspaceAdminRoutes(app: FastifyInstance): Promise { app.patch('/settings', async (request) => { const { workspace } = request.ws; const account = request.ws.require('admin'); const body = updateWorkspaceSchema.parse(request.body); if (body.slug && body.slug !== workspace.slug) { if (getWorkspaceBySlug(app.control, body.slug)) { throw conflict(`The workspace address "${body.slug}" is already taken.`); } setWorkspaceSlug(app.control, workspace.id, body.slug); } if (body.name && body.name !== workspace.name) { renameWorkspace(app.control, workspace.id, body.name); recordAudit(request.ws.db, request.ws.actor(), { kind: 'schema', old: workspace.name, nw: `RENAME WORKSPACE → ${body.name}`, }); } // Audited like a schema change, because that is what it is from the reader's // side: every amount in the workspace changes what it says it is worth. if (body.currency && body.currency !== workspace.currency) { setWorkspaceCurrency(app.control, workspace.id, body.currency); recordAudit(request.ws.db, request.ws.actor(), { kind: 'schema', old: workspace.currency, nw: `SET CURRENCY → ${body.currency}`, }); } // Same reasoning: every date and number in the workspace reads differently // afterwards, for everyone. if (body.locale && body.locale !== workspace.locale) { setWorkspaceLocale(app.control, workspace.id, body.locale); recordAudit(request.ws.db, request.ws.actor(), { kind: 'schema', old: workspace.locale, nw: `SET LANGUAGE → ${body.locale}`, }); } const updated = listWorkspacesForAccount(app.control, account.id).find( (w) => w.id === workspace.id, ); return { workspace: updated }; }); /** * Hand the whole workspace back as one SQLite file. * * Database-per-tenant earns its keep here: export is a file copy, not a * serialiser that has to be kept in step with every column type Wellformed grows. */ app.get('/export', async (request, reply) => { const { workspace, db } = request.ws; request.ws.require('admin'); // The snapshot carries the roster, which is the one thing an agent key is // never given a way to read. It should not arrive by the back door. request.ws.denyAgent('export this workspace'); const members = listMembers(app.control, workspace.id); const snapshot = snapshotWorkspace(db, members); reply .header('content-type', 'application/vnd.sqlite3') .header('content-disposition', `attachment; filename="${snapshotFilename(workspace.slug)}"`); const stream = createReadStream(snapshot.file); stream.once('close', snapshot.dispose); stream.once('error', snapshot.dispose); return reply.send(stream); }); /** * Delete a workspace and its database. * * Guarded by echoing the slug back, because nothing here is recoverable and * the control sits among ones that are. */ app.delete('/settings', async (request) => { const { workspace } = request.ws; const account = request.ws.require('admin'); // Everything else an agent does lands in the trail and can be argued with // afterwards. This deletes the trail too. request.ws.denyAgent('delete this workspace'); const confirmation = (request.query as { confirm?: string }).confirm; if (confirmation !== workspace.slug) { throw badRequest(`Confirm this by sending ?confirm=${workspace.slug}.`); } const otherAdmins = listMembers(app.control, workspace.id).filter( (m) => m.role === 'admin' && m.id !== account.id && !m.disabled, ); if (otherAdmins.length) { throw conflict( `"${workspace.name}" has other admins (${otherAdmins.map((m) => m.email).join(', ')}). Removing them first makes this a decision someone took, not one they discovered.`, ); } transactControl(app.control, () => deleteWorkspace(app.control, workspace.id)); // Order matters: an orphaned file is recoverable, a membership pointing at // a database that is no longer there is not. app.registry.destroy(workspace); return { ok: true }; }); /** Leave a workspace someone else runs. The last admin cannot simply walk out. */ app.post('/leave', async (request) => { const { workspace, role } = request.ws; const account = request.requireAccount(); // Giving up the membership the key depends on is a person's decision. request.ws.denyAgent('leave this workspace'); if (role === 'admin' && countActiveAdmins(app.control, workspace.id) <= 1) { throw conflict('You are the last admin here. Promote someone else, or delete the workspace.'); } recordAudit(request.ws.db, request.ws.actor(), { kind: 'auth', nw: `${account.email} left the workspace`, }); removeMembership(app.control, workspace.id, account.id); return { ok: true }; }); }