import * as crypto from 'crypto'; import {NachklangAdminDB} from '../Admin.db.js'; import {AppPermission, isAppName, isAppPermission, ACCESS_ROLE} from '../admin.schema.js'; const db = NachklangAdminDB.db; /** * Invitations are this API's only path to a new account (there is no public * sign-up). The raw token exists exactly twice: in the mail we send and in the * request body when it comes back. What we store is its SHA-256 hash, so a * database dump does not hand out account access - the same reasoning as the * calendar module's session key hashing, and the reason lookups go through * `findByToken` rather than any query on a plaintext column. */ export const INVITATION_TTL_DAYS = 7; export interface OpenInvitation { id: number; email: string; name: string; permissions: AppPermission[]; invitedBy: string | null; createdAt: Date; expiresAt: Date; } export interface AcceptableInvitation { id: number; email: string; name: string; permissions: AppPermission[]; } const hashToken = (token: string): string => { return crypto.createHash('sha256').update(token).digest('hex'); }; const generateToken = (): string => { // 32 bytes, url-safe: it travels in a mail link's query string. return crypto.randomBytes(32).toString('base64url'); }; /** * Reads the stored permission list. Two shapes are accepted: the current * `[{app, role}]`, and a bare `['tickets', ...]` from before roles existed, * which means the same thing at the `access` role. Invitations live for seven * days, so a deploy that changes the shape has in-flight rows in the old one - * tolerating both is what stops those invitees from being stranded. * * mysql2 hands back a JSON column already parsed; a driver or column-type * change that turns it into a string must not break the read path either. */ const parsePermissions = (value: unknown): AppPermission[] => { const raw = typeof value === 'string' ? JSON.parse(value) : value; if (!Array.isArray(raw)) { return []; } return raw.flatMap((entry): AppPermission[] => { if (isAppName(entry)) { return [{app: entry, role: ACCESS_ROLE}]; } return isAppPermission(entry) ? [{app: entry.app, role: entry.role}] : []; }); }; const expiryFromNow = (): Date => { return new Date(Date.now() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000); }; /** * Creates an invitation and returns the raw token for the mail. Any earlier * open invitation for the same address is revoked first: two valid links for * one mailbox is a needless second live credential, and "resend" would * otherwise quietly accumulate them. */ export const createInvitation = async ( email: string, name: string, permissions: AppPermission[], invitedBy: string | null ): Promise<{id: number; token: string; expiresAt: Date}> => { const token = generateToken(); const expiresAt = expiryFromNow(); const valid = permissions.filter(isAppPermission); const id = await db.transaction().execute(async trx => { await trx .updateTable('invitations') .set({revoked_at: new Date()}) .where('email', '=', email) .where('accepted_at', 'is', null) .where('revoked_at', 'is', null) .execute(); const result = await trx .insertInto('invitations') .values({ email, name, token_hash: hashToken(token), permissions: JSON.stringify(valid), invited_by: invitedBy, created_at: new Date(), expires_at: expiresAt }) .executeTakeFirst(); return Number(result.insertId); }); return {id, token, expiresAt}; }; /** * Looks up a still-usable invitation by raw token. Callers must not * distinguish "unknown", "expired", "revoked" and "already accepted" to the * client: all four answer with the same shape, so a stranger cannot probe which * tokens ever existed. */ export const findByToken = async (token: string): Promise => { const row = await db .selectFrom('invitations') .select(['id', 'email', 'name', 'permissions']) .where('token_hash', '=', hashToken(token)) .where('accepted_at', 'is', null) .where('revoked_at', 'is', null) .where('expires_at', '>', new Date()) .executeTakeFirst(); if (!row) { return null; } return {id: row.id, email: row.email, name: row.name, permissions: parsePermissions(row.permissions)}; }; /** Marks the invitation accepted. Conditional on it still being open so two * concurrent accepts of the same link cannot both create an account. */ export const markAccepted = async (invitationId: number): Promise => { const result = await db .updateTable('invitations') .set({accepted_at: new Date()}) .where('id', '=', invitationId) .where('accepted_at', 'is', null) .where('revoked_at', 'is', null) .executeTakeFirst(); return Number(result.numUpdatedRows) > 0; }; /** * Reverses markAccepted. Used only to compensate a failed acceptance: the user * could not be created, so the link must become usable again rather than * stranding the invitee with a burnt token and no account. */ export const unmarkAccepted = async (invitationId: number): Promise => { await db .updateTable('invitations') .set({accepted_at: null}) .where('id', '=', invitationId) .execute(); }; export const listOpenInvitations = async (): Promise => { const rows = await db .selectFrom('invitations') .select(['id', 'email', 'name', 'permissions', 'invited_by', 'created_at', 'expires_at']) .where('accepted_at', 'is', null) .where('revoked_at', 'is', null) .where('expires_at', '>', new Date()) .orderBy('created_at', 'desc') .execute(); return rows.map(row => ({ id: row.id, email: row.email, name: row.name, permissions: parsePermissions(row.permissions), invitedBy: row.invited_by, createdAt: row.created_at, expiresAt: row.expires_at })); }; export const getOpenInvitation = async (invitationId: number): Promise => { const all = await listOpenInvitations(); return all.find(invitation => invitation.id === invitationId) ?? null; }; /** Resend issues a *new* token and expiry and invalidates the old one, rather * than re-mailing the existing link: if the first mail leaked, resending it * would extend the leak's lifetime. */ export const resendInvitation = async ( invitationId: number ): Promise<{token: string; email: string; name: string; expiresAt: Date} | null> => { const invitation = await getOpenInvitation(invitationId); if (!invitation) { return null; } const token = generateToken(); const expiresAt = expiryFromNow(); await db .updateTable('invitations') .set({token_hash: hashToken(token), expires_at: expiresAt, created_at: new Date()}) .where('id', '=', invitationId) .execute(); return {token, email: invitation.email, name: invitation.name, expiresAt}; }; export const revokeInvitation = async (invitationId: number): Promise => { const result = await db .updateTable('invitations') .set({revoked_at: new Date()}) .where('id', '=', invitationId) .where('accepted_at', 'is', null) .where('revoked_at', 'is', null) .executeTakeFirst(); return Number(result.numUpdatedRows) > 0; }; /** Used by the bootstrap to stay idempotent across restarts. */ export const hasOpenInvitationFor = async (email: string): Promise => { const row = await db .selectFrom('invitations') .select('id') .where('email', '=', email) .where('accepted_at', 'is', null) .where('revoked_at', 'is', null) .where('expires_at', '>', new Date()) .executeTakeFirst(); return Boolean(row); };