import type { FastifyInstance, FastifyReply } from 'fastify'; import { z } from 'zod'; import type { InviteInfo, PendingInvite } from '@wellformed/shared'; import { badRequest, conflict, forbidden, notFound } from '../errors.js'; import { config } from '../config.js'; import { createAccount, getAccountByEmail, normaliseEmail, } from '../control/accounts.js'; import { transactControl } from '../control/db.js'; import { createSession, SESSION_COOKIE } from '../control/sessions.js'; import { claimInvite, createInvite, getLiveInviteByToken, listPendingInvites, revokeInvite, } from '../control/invites.js'; import { getMembershipRole, getWorkspaceById, listMembers, setMembership, } from '../control/workspaces.js'; import { checkPasswordStrength, hashPassword } from '../auth/password.js'; import { recordAudit } from '../engine/audit.js'; import { acceptInviteSchema, createInviteSchema } from './schemas.js'; const idParams = z.object({ id: z.string().min(1).max(64) }); const tokenParams = z.object({ token: z.string().min(20).max(200) }); /** Managing invites for one workspace. Registered inside the tenant scope. */ export async function inviteRoutes(app: FastifyInstance): Promise { app.get('/invites', async (request): Promise<{ invites: PendingInvite[] }> => { request.ws.require('admin'); return { invites: listPendingInvites(app.control, request.ws.workspace.id) }; }); app.post('/invites', async (request, reply) => { const { workspace } = request.ws; const actor = request.ws.require('admin'); const body = createInviteSchema.parse(request.body); const email = normaliseEmail(body.email); const existing = getAccountByEmail(app.control, email); if (existing && getMembershipRole(app.control, workspace.id, existing.id)) { throw conflict(`${email} is already in this workspace.`); } const { record, token } = createInvite(app.control, { workspaceId: workspace.id, email, role: body.role, invitedBy: actor.id, }); recordAudit(request.ws.db, request.ws.actor(), { kind: 'auth', nw: `invited ${email} as ${body.role}`, }); reply.code(201); return { invite: { id: record.id, email: record.email, role: record.role, invitedBy: actor.name, createdAt: record.createdAt, expiresAt: record.expiresAt, acceptPath: `/invite/${token}`, } satisfies PendingInvite, }; }); app.delete('/invites/:id', async (request) => { request.ws.require('admin'); const { id } = idParams.parse(request.params); revokeInvite(app.control, id); return { ok: true }; }); } /** * Accepting an invite. * * Outside the tenant scope on purpose: the whole point is that the person * following the link is not a member yet, so the membership check that guards * `/api/w/:slug` would reject them. The token is the authorisation here, and * it is checked on both routes. */ export async function inviteAcceptRoutes(app: FastifyInstance): Promise { app.get('/api/invites/:token', async (request): Promise<{ invite: InviteInfo }> => { const { token } = tokenParams.parse(request.params); const invite = getLiveInviteByToken(app.control, token); if (!invite) throw notFound('This invitation has expired or has already been used.'); const workspace = getWorkspaceById(app.control, invite.workspaceId); if (!workspace) throw notFound('That workspace no longer exists.'); return { invite: { workspaceName: workspace.name, workspaceSlug: workspace.slug, email: invite.email, role: invite.role, expiresAt: invite.expiresAt, needsAccount: !getAccountByEmail(app.control, invite.email), }, }; }); app.post('/api/invites/:token/accept', async (request, reply) => { const { token } = tokenParams.parse(request.params); const body = acceptInviteSchema.parse(request.body); const invite = getLiveInviteByToken(app.control, token); if (!invite) throw notFound('This invitation has expired or has already been used.'); const workspace = getWorkspaceById(app.control, invite.workspaceId); if (!workspace) throw notFound('That workspace no longer exists.'); const existing = getAccountByEmail(app.control, invite.email); // The invite names an address, and only that address can accept it. A // forwarded link must not be able to attach a stranger's account instead. if (request.account && request.account.email !== invite.email) { throw forbidden( `This invitation is for ${invite.email}. Sign out first, or ask for one addressed to ${request.account.email}.`, ); } let accountId: string; let issueSession = false; if (existing) { if (!request.account) { // They have an account but are not signed in. Sending them to the // login screen is better than accepting a password on this route, // which would be an unthrottled second way to check credentials. throw conflict(`${invite.email} already has an account. Sign in, then open this link again.`); } accountId = existing.id; } else { if (!body.name || !body.password) { throw badRequest('Choose a name and a password to accept this invitation.'); } const problem = checkPasswordStrength(body.password); if (problem) throw badRequest(problem.message); const hash = await hashPassword(body.password); accountId = createAccount(app.control, { email: invite.email, name: body.name, passwordHash: hash, }).id; issueSession = true; } const claimed = transactControl(app.control, () => { if (!claimInvite(app.control, invite.id, accountId)) return false; setMembership(app.control, workspace.id, accountId, invite.role); return true; }); if (!claimed) throw conflict('This invitation has already been used.'); if (issueSession) { const session = createSession( app.control, accountId, config.sessionTtlMs, request.headers['user-agent'], ); setSessionCookie(reply, session.token, session.expiresAt); } // Written to the workspace's own audit trail, where its admins will look. const joined = listMembers(app.control, workspace.id).find((m) => m.id === accountId); const workspaceDb = app.registry.acquire(workspace); try { recordAudit(workspaceDb, { id: accountId, name: joined?.name ?? invite.email }, { kind: 'auth', nw: `${invite.email} joined as ${invite.role}`, }); } finally { app.registry.release(workspace.id); } return { workspace: { slug: workspace.slug, name: workspace.name, role: invite.role } }; }); } function setSessionCookie(reply: FastifyReply, token: string, expiresAt: Date): void { reply.setCookie(SESSION_COOKIE, token, { httpOnly: true, sameSite: 'lax', secure: config.isProd, path: '/', expires: expiresAt, }); }