Phase 7
This commit is contained in:
189 files changed
+9873
-655
No files matched your search
@@ -0,0 +1,89 @@
|
||||
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() },
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user