import * as crypto from 'crypto'; import {NachklangAdminDB} from '../Admin.db.js'; import {AppName, APP_NAMES, isAppName} 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; apps: AppName[]; invitedBy: string | null; createdAt: Date; expiresAt: Date; } export interface AcceptableInvitation { id: number; email: string; name: string; apps: AppName[]; } 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'); }; const parseApps = (value: unknown): AppName[] => { // 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. const raw = typeof value === 'string' ? JSON.parse(value) : value; return Array.isArray(raw) ? raw.filter(isAppName) : []; }; 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, apps: AppName[], invitedBy: string | null ): Promise<{id: number; token: string; expiresAt: Date}> => { const token = generateToken(); const expiresAt = expiryFromNow(); const validApps = Array.from(new Set(apps)).filter(app => APP_NAMES.includes(app)); 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), apps: JSON.stringify(validApps), 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', 'apps']) .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, apps: parseApps(row.apps)}; }; /** 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; }; export const listOpenInvitations = async (): Promise => { const rows = await db .selectFrom('invitations') .select(['id', 'email', 'name', 'apps', '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, apps: parseApps(row.apps), 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); };