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, 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<void> {
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<void> {
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,
});
}