import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import type { Member } from '@wellformed/shared'; import { badRequest, conflict, notFound, unauthorized } from '../errors.js'; import { countActiveAdmins, listMembers, removeMembership, setMembership, } from '../control/workspaces.js'; import { recordAudit } from '../engine/audit.js'; import { updateMemberSchema } from './schemas.js'; const idParams = z.object({ id: z.string().min(1).max(64) }); /** * Who is in this workspace, and what they may do here. * * Note what is *not* here. There is no create-a-person route any more: an * account is global, so a workspace admin cannot mint one, only invite one. * And there is no password reset or disable: those belong to the account, not * to any single workspace, and letting one customer's admin disable an account * shared with another customer would be a cross-tenant hole. */ export async function memberRoutes(app: FastifyInstance): Promise { app.get('/members', async (request): Promise<{ members: Member[] }> => { // Reading the workspace is not reading the people in it. A visitor to the // published demo holds `viewer` without holding an account, and this list // is names, email addresses and when each of them was last here. if (!request.ws.require('viewer')) { throw unauthorized('Sign in to see who is in this workspace.'); } return { members: listMembers(app.control, request.ws.workspace.id) }; }); app.patch('/members/:id', async (request) => { const { workspace } = request.ws; const actor = request.ws.require('admin'); const { id } = idParams.parse(request.params); const body = updateMemberSchema.parse(request.body); const target = listMembers(app.control, workspace.id).find((m) => m.id === id); if (!target) throw notFound('That person is not in this workspace.'); if (!body.role || body.role === target.role) return { member: target }; // An admin must not be able to leave the workspace with no way back in. if (target.role === 'admin' && countActiveAdmins(app.control, workspace.id) <= 1) { throw conflict('This is the last active admin. Promote someone else first.'); } setMembership(app.control, workspace.id, id, body.role); recordAudit(request.ws.db, request.ws.actor(), { kind: 'auth', old: target.email, nw: `${target.email}: role ${target.role} → ${body.role}`, }); return { member: listMembers(app.control, workspace.id).find((m) => m.id === id), /** A caller who just demoted themselves needs to know before their next request 403s. */ you: id === actor.id ? body.role : undefined, }; }); app.delete('/members/:id', async (request) => { const { workspace } = request.ws; const actor = request.ws.require('admin'); const { id } = idParams.parse(request.params); if (id === actor.id) { throw badRequest('Use "leave workspace" to remove yourself.'); } const target = listMembers(app.control, workspace.id).find((m) => m.id === id); if (!target) throw notFound('That person is not in this workspace.'); if (target.role === 'admin' && countActiveAdmins(app.control, workspace.id) <= 1) { throw conflict('This is the last active admin.'); } // The membership goes; the account does not. They may well be the sole // admin of a workspace of their own. removeMembership(app.control, workspace.id, id); recordAudit(request.ws.db, request.ws.actor(), { kind: 'auth', old: target.email, nw: `removed ${target.email} from the workspace`, }); return { ok: true }; }); }