Files
NextChair/src/services/scheduling/guest-access.service.ts
T
2026-08-03 21:15:24 +01:00

90 lines
3.8 KiB
TypeScript

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<string> {
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<boolean> {
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<void> {
await prisma.guestAppointmentAccessToken.updateMany({
where: { appointmentId, revokedAt: null },
data: { revokedAt: new Date() },
});
}