import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import type { PrismaClient } from "@/generated/prisma/client"; /** * Single-purpose access tokens for guest (no linked User) appointments — * closes the Phase 4 gap where a guest's only credential was the * appointment's own id (see docs/phase-4-summary.md, "Security", and * GuestAppointmentAccessToken in schema.prisma). A registered customer's * access is still session-based and untouched by this file — see * src/features/booking/authorization.ts. * * Only the SHA-256 hash of the token is ever persisted. The raw token * exists solely in this module's return values, for a caller to embed in * a link (the booking confirmation page today; a future notification * email) and never log or store elsewhere. */ const TOKEN_BYTES = 32; const VALID_FOR_DAYS_AFTER_APPOINTMENT = 90; function hashToken(rawToken: string): string { return createHash("sha256").update(rawToken).digest("hex"); } /** * Issues this appointment's guest access token, generating one if none * exists or overwriting the previous hash if one does — deliberately a * single row per appointment (see the model's `appointmentId @unique`), * so re-issuing invalidates any earlier copy of the raw token rather than * leaving multiple valid links outstanding. Reschedule/cancel do not * currently re-issue — the same link a guest already has keeps working * across those, which is the more useful default until Phase 7's * notification emails (not yet built) give a concrete reason to rotate on * a specific event. */ export async function issueGuestAccessToken( prisma: PrismaClient, params: { appointmentId: string; appointmentEndAt: Date }, ): Promise { const rawToken = randomBytes(TOKEN_BYTES).toString("base64url"); const expiresAt = new Date( params.appointmentEndAt.getTime() + VALID_FOR_DAYS_AFTER_APPOINTMENT * 24 * 60 * 60 * 1000, ); await prisma.guestAppointmentAccessToken.upsert({ where: { appointmentId: params.appointmentId }, create: { appointmentId: params.appointmentId, tokenHash: hashToken(rawToken), expiresAt }, update: { tokenHash: hashToken(rawToken), expiresAt, revokedAt: null, lastUsedAt: null }, }); return rawToken; } /** * True only if `rawToken` is the CURRENT token for exactly this * appointment, not expired, and not revoked. Compares hashes with * `timingSafeEqual` (matching the convention in * src/lib/booking-draft/sign.ts) since, unlike a lookup keyed by the hash * itself, this comparison happens in application code after fetching the * row by appointmentId. */ export async function validateGuestAccessToken( prisma: PrismaClient, params: { appointmentId: string; rawToken: string }, ): Promise { const record = await prisma.guestAppointmentAccessToken.findUnique({ where: { appointmentId: params.appointmentId }, }); if (!record || record.revokedAt || record.expiresAt < new Date()) return false; const provided = Buffer.from(hashToken(params.rawToken), "hex"); const stored = Buffer.from(record.tokenHash, "hex"); if (provided.length !== stored.length || !timingSafeEqual(provided, stored)) return false; // Best-effort — a stale lastUsedAt from a lost race isn't a security concern. await prisma.guestAppointmentAccessToken .update({ where: { appointmentId: params.appointmentId }, data: { lastUsedAt: new Date() } }) .catch(() => {}); return true; } /** Immediately invalidates a guest appointment's current access link — e.g. if it was sent to the wrong address. */ export async function revokeGuestAccessToken(prisma: PrismaClient, appointmentId: string): Promise { await prisma.guestAppointmentAccessToken.updateMany({ where: { appointmentId, revokedAt: null }, data: { revokedAt: new Date() }, }); }