Revert "PHP Rebuild"

This reverts commit 2ef3654841.
This commit is contained in:
Stefan Lazic committed 2026-09-05 09:32:05 +01:00
1 parent a59f998f2b
commit 2aa8405006
690 files changed
+12045 -33943

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
/**
* A thin, typed wrapper over the (previously unused, see schema.prisma)
* AuditLog table — used for owner actions that change shared business
* configuration (staff, roles, services, hours, booking rules, loyalty,
* reward redemption). Never stores passwords, invitation tokens, or other
* secrets in `metadata` — callers pass only display-safe before/after
* values.
*/
export async function writeAuditLog(
prisma: PrismaClient,
params: {
actorUserId: string | null;
action: string;
entityType: string;
entityId: string;
metadata?: Prisma.InputJsonValue;
},
): Promise<void> {
await prisma.auditLog.create({
data: {
actorUserId: params.actorUserId,
action: params.action,
entityType: params.entityType,
entityId: params.entityId,
metadata: params.metadata,
},
});
}
@@ -0,0 +1,60 @@
import { afterEach, describe, expect, it } from "vitest";
import { getNavRole } from "../permissions";
import { cleanupTestBusiness, createTestBusiness, createTestStaff, prisma } from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
/**
* getNavRole is what both (staff)/layout.tsx and (owner)/layout.tsx use to
* pick a merged nav item set (see lib/navigation/nav-config.ts) and to
* gate entry to the (staff) route group at all. These tests cover the
* three roles the merged-nav brief distinguishes, plus the "signed in but
* neither staff nor super admin" case that must still redirect a
* registered customer away.
*/
describe("getNavRole (integration)", () => {
let business: TestBusiness | null = null;
const extraUserIds: string[] = [];
afterEach(async () => {
if (business) {
await cleanupTestBusiness(business);
business = null;
}
if (extraUserIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: extraUserIds } } }).catch(() => {});
extraUserIds.length = 0;
}
});
it("classifies an ordinary barber (no MANAGE_STAFF) as staff", async () => {
business = await createTestBusiness();
const { userId } = await createTestStaff(business);
await expect(getNavRole(userId)).resolves.toBe("staff");
});
it("classifies a staff member whose Role grants MANAGE_STAFF as owner", async () => {
business = await createTestBusiness();
const role = await prisma.role.create({
data: { businessId: business.businessId, name: "Owner", permissions: { create: [{ permission: "MANAGE_STAFF" }] } },
});
const user = await prisma.user.create({
data: { email: "owner-role@test.local", name: "Owner", staffProfile: { create: { businessId: business.businessId, roleId: role.id } } },
});
business.createdUserIds.push(user.id);
await expect(getNavRole(user.id)).resolves.toBe("owner");
});
it("classifies a Super Administrator as superadmin even with no StaffProfile at all", async () => {
const user = await prisma.user.create({
data: { email: "superadmin-navrole@test.local", name: "Super Admin", isSuperAdmin: true },
});
extraUserIds.push(user.id);
await expect(getNavRole(user.id)).resolves.toBe("superadmin");
});
it("returns null for a signed-in user who is neither staff nor a super admin (a registered customer)", async () => {
const user = await prisma.user.create({ data: { email: "customer-navrole@test.local", name: "Customer" } });
extraUserIds.push(user.id);
await expect(getNavRole(user.id)).resolves.toBeNull();
});
});
+69
View File
@@ -0,0 +1,69 @@
import { cache } from "react";
import { prisma } from "@/lib/db/prisma";
import { Permission } from "@/generated/prisma/enums";
import type { NavRole } from "@/lib/navigation/nav-config";
/**
* Foundation for Phase 3+ role gating — not exercised by any UI yet.
* Deliberately re-checks the database rather than trusting only the JWT:
* permission changes (e.g. an owner demoting a barber) must take effect
* without waiting for the token to expire. See
* docs/implementation-plan.md §11 ("Permission enforcement happens AGAIN
* at the server-action/service layer — never trust the route group alone").
*
* `cache()`-wrapped (same request-dedup pattern as getActingStaff/
* getPrimaryBusiness) — every (owner) page checks 1-2 permissions across
* its layout and the page itself, which without this repeated the same
* StaffProfile+Role+RolePermission join 2-3× per request (see the Phase 7
* performance pass).
*/
export const getStaffPermissions = cache(async (userId: string): Promise<Set<Permission>> => {
const staffProfile = await prisma.staffProfile.findUnique({
where: { userId },
include: { role: { include: { permissions: true } } },
});
if (!staffProfile) return new Set();
return new Set(staffProfile.role.permissions.map((rolePermission) => rolePermission.permission));
});
/**
* Phase 9 — the platform-level Super Administrator (see
* scripts/ensure-super-admin.ts). Deliberately re-checks the database
* rather than trusting the JWT, same rationale as getStaffPermissions.
*/
export const isSuperAdmin = cache(async (userId: string): Promise<boolean> => {
const user = await prisma.user.findUnique({ where: { id: userId }, select: { isSuperAdmin: true } });
return user?.isSuperAdmin ?? false;
});
/**
* A Super Administrator has every permission an Owner has — OWNER_PERMISSIONS
* (src/lib/roles/default-permissions.ts) already IS the full Permission enum,
* so short-circuiting true here rather than granting a StaffProfile/Role is
* exactly equivalent, without giving the Super Admin a StaffProfile row at
* all (see docs/phase-9-summary.md, "Super Administrator" — this is what
* keeps them out of the Team list, barber picker, and appointment FKs
* structurally rather than via a filter).
*/
export async function hasPermission(userId: string, permission: Permission): Promise<boolean> {
if (await isSuperAdmin(userId)) return true;
const permissions = await getStaffPermissions(userId);
return permissions.has(permission);
}
/**
* The single role classification behind the merged nav (see
* lib/navigation/nav-config.ts) — "staff" vs "owner" is just whether the
* account's Role grants MANAGE_STAFF, and "superadmin" short-circuits both
* (a Super Administrator has no StaffProfile at all, see isSuperAdmin).
* Returns null for a signed-in user who is neither staff nor a super
* admin — callers should redirect those away, same as today.
*/
export const getNavRole = cache(async (userId: string): Promise<NavRole | null> => {
if (await isSuperAdmin(userId)) return "superadmin";
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId }, select: { id: true } });
if (!staffProfile) return null;
const permissions = await getStaffPermissions(userId);
return permissions.has(Permission.MANAGE_STAFF) ? "owner" : "staff";
});
@@ -0,0 +1,44 @@
import { beforeEach, describe, expect, it } from "vitest";
import { sign, verify } from "../sign";
describe("sign / verify", () => {
beforeEach(() => {
process.env.AUTH_SECRET = "test-secret-not-for-production-use-only";
});
it("round-trips a payload", () => {
const token = sign('{"serviceIds":["svc_1"]}');
expect(verify(token)).toBe('{"serviceIds":["svc_1"]}');
});
it("rejects a tampered payload", () => {
const token = sign('{"serviceIds":["svc_1"]}');
const [, mac] = token.split(".");
const tamperedPayload = Buffer.from('{"serviceIds":["svc_2"]}', "utf8").toString("base64url");
expect(verify(`${tamperedPayload}.${mac}`)).toBeNull();
});
it("rejects a tampered signature", () => {
const token = sign('{"a":1}');
const [payload] = token.split(".");
expect(verify(`${payload}.not-a-real-signature`)).toBeNull();
});
it("rejects malformed tokens without throwing", () => {
expect(verify("")).toBeNull();
expect(verify("no-separator")).toBeNull();
expect(verify("...")).toBeNull();
expect(verify("!!!not-base64!!!.mac")).toBeNull();
});
it("rejects a token signed with a different secret", () => {
const token = sign('{"a":1}');
process.env.AUTH_SECRET = "a-completely-different-secret-value";
expect(verify(token)).toBeNull();
});
it("throws clearly if AUTH_SECRET is missing rather than signing with an empty key", () => {
delete process.env.AUTH_SECRET;
expect(() => sign("payload")).toThrow(/AUTH_SECRET/);
});
});
+134
View File
@@ -0,0 +1,134 @@
import "server-only";
import { cookies } from "next/headers";
import { isSecureDeployment } from "@/lib/security/cookie-security";
import { bookingDraftSchema, emptyDraft, emptyStaffDraft, staffBookingDraftSchema } from "./schema";
import type { BookingDraft, StaffBookingDraft } from "./schema";
import { sign, verify } from "./sign";
/**
* Server-only I/O around the signed booking-draft cookie. Callable from
* Server Components (read) and Server Actions/Route Handlers (read +
* write) — `next/headers`'s `cookies()` enforces that boundary itself.
*/
const COOKIE_NAME = "nc_booking_draft";
const MAX_AGE_SECONDS = 60 * 60; // 1 hour — long enough to finish a booking, short enough not to linger
export async function readDraft(businessId: string): Promise<BookingDraft> {
const store = await cookies();
const raw = store.get(COOKIE_NAME)?.value;
if (!raw) return emptyDraft(businessId);
const payload = verify(raw);
if (!payload) return emptyDraft(businessId);
try {
const parsed = bookingDraftSchema.safeParse(JSON.parse(payload));
if (!parsed.success || parsed.data.businessId !== businessId) return emptyDraft(businessId);
return parsed.data;
} catch {
return emptyDraft(businessId);
}
}
export async function writeDraft(draft: BookingDraft): Promise<void> {
const store = await cookies();
const token = sign(JSON.stringify(draft));
store.set(COOKIE_NAME, token, {
httpOnly: true,
secure: isSecureDeployment(),
sameSite: "lax",
path: "/",
maxAge: MAX_AGE_SECONDS,
});
}
export async function clearDraft(): Promise<void> {
const store = await cookies();
store.delete(COOKIE_NAME);
}
// ── Post-booking confirmation reference ─────────────────────────────────
// Deliberately a separate, tiny cookie rather than a URL query param, so
// a bookmarked/shared confirmation URL never carries even a non-sensitive
// appointment id — see docs/phase-4-summary.md.
const CONFIRMATION_COOKIE_NAME = "nc_last_booking";
const CONFIRMATION_MAX_AGE_SECONDS = 60 * 30;
export interface LastBookingReference {
appointmentId: string;
/** Raw guest access token, present only for a guest booking — see guest-access.service.ts. Carried through this already-signed, short-lived cookie rather than a URL, for the same "never even a non-sensitive id in a bookmarkable URL" reason documented below. */
guestAccessToken?: string;
}
export async function writeLastBookingReference(reference: LastBookingReference): Promise<void> {
const store = await cookies();
const token = sign(JSON.stringify(reference));
store.set(CONFIRMATION_COOKIE_NAME, token, {
httpOnly: true,
secure: isSecureDeployment(),
sameSite: "lax",
path: "/",
maxAge: CONFIRMATION_MAX_AGE_SECONDS,
});
}
export async function readLastBookingReference(): Promise<LastBookingReference | null> {
const store = await cookies();
const raw = store.get(CONFIRMATION_COOKIE_NAME)?.value;
if (!raw) return null;
const payload = verify(raw);
if (!payload) return null;
try {
const parsed = JSON.parse(payload) as { appointmentId?: unknown; guestAccessToken?: unknown };
if (typeof parsed.appointmentId !== "string") return null;
return {
appointmentId: parsed.appointmentId,
guestAccessToken: typeof parsed.guestAccessToken === "string" ? parsed.guestAccessToken : undefined,
};
} catch {
return null;
}
}
// ── Staff "book for customer" draft ─────────────────────────────────────
// A separate cookie from the customer draft above — see the schema
// comment on StaffBookingDraft for why.
const STAFF_COOKIE_NAME = "nc_staff_booking_draft";
export async function readStaffDraft(businessId: string): Promise<StaffBookingDraft> {
const store = await cookies();
const raw = store.get(STAFF_COOKIE_NAME)?.value;
if (!raw) return emptyStaffDraft(businessId);
const payload = verify(raw);
if (!payload) return emptyStaffDraft(businessId);
try {
const parsed = staffBookingDraftSchema.safeParse(JSON.parse(payload));
if (!parsed.success || parsed.data.businessId !== businessId)
return emptyStaffDraft(businessId);
return parsed.data;
} catch {
return emptyStaffDraft(businessId);
}
}
export async function writeStaffDraft(draft: StaffBookingDraft): Promise<void> {
const store = await cookies();
const token = sign(JSON.stringify(draft));
store.set(STAFF_COOKIE_NAME, token, {
httpOnly: true,
secure: isSecureDeployment(),
sameSite: "lax",
path: "/",
maxAge: MAX_AGE_SECONDS,
});
}
export async function clearStaffDraft(): Promise<void> {
const store = await cookies();
store.delete(STAFF_COOKIE_NAME);
}
+69
View File
@@ -0,0 +1,69 @@
import { z } from "zod";
/**
* The booking-in-progress draft. Lives in a signed, httpOnly cookie (see
* cookie.ts) rather than only in React state, so it survives a page
* refresh, browser back/forward, and a sign-in that happens mid-flow —
* see docs/phase-4-summary.md for the full reasoning.
*/
export const guestDetailsSchema = z.object({
firstName: z.string().trim().min(1, "First name is required"),
lastName: z.string().trim().min(1, "Last name is required"),
email: z.email("Enter a valid email address"),
phone: z.string().trim().min(1, "Phone number is required"),
});
export type GuestDetails = z.infer<typeof guestDetailsSchema>;
export const bookingDraftSchema = z.object({
businessId: z.string(),
serviceIds: z.array(z.string()).default([]),
/** undefined = "any available barber". */
staffId: z.string().optional(),
date: z.string().optional(), // CalendarDate, "YYYY-MM-DD"
startAt: z.iso.datetime().optional(),
notes: z.string().max(2000).optional(),
guest: guestDetailsSchema.optional(),
policyAcknowledged: z.boolean().default(false),
/** Set by "Book again" so the services step can show which appointment this draft originated from — display only, never trusted for pricing (see BookingService, which always re-reads current Service prices). */
rebookFromAppointmentId: z.string().optional(),
});
export type BookingDraft = z.infer<typeof bookingDraftSchema>;
export function emptyDraft(businessId: string): BookingDraft {
return {
businessId,
serviceIds: [],
policyAcknowledged: false,
};
}
/**
* The staff-facing "book for customer" draft (src/app/staff/book) — a
* deliberately separate shape and cookie from the customer one above
* (see cookie.ts), even though the two overlap heavily, so a staff member
* booking on a customer's behalf can never collide with their own
* in-progress personal booking in another tab. Reuses `guestDetailsSchema`
* as-is for "staff is entering a brand-new customer's details" — the same
* shape CustomerService.resolveCustomerForBooking already knows how to
* resolve/match, so no new matching logic is needed for that path.
*/
export const staffBookingDraftSchema = z.object({
businessId: z.string(),
serviceIds: z.array(z.string()).default([]),
/** undefined = "any available barber". */
staffId: z.string().optional(),
date: z.string().optional(),
startAt: z.iso.datetime().optional(),
notes: z.string().max(2000).optional(),
/** An existing CustomerProfile the staff member picked from search. */
customerId: z.string().optional(),
/** A brand-new guest customer the staff member is entering inline. Ignored if customerId is set. */
guest: guestDetailsSchema.optional(),
/** Set by "Book Follow-Up" so the services step can show which appointment this draft originated from — display only, never trusted for pricing. */
followUpFromAppointmentId: z.string().optional(),
});
export type StaffBookingDraft = z.infer<typeof staffBookingDraftSchema>;
export function emptyStaffDraft(businessId: string): StaffBookingDraft {
return { businessId, serviceIds: [] };
}
+62
View File
@@ -0,0 +1,62 @@
import { createHmac, timingSafeEqual } from "node:crypto";
/**
* Tamper-evident (not encrypted) signing for the booking-draft cookie —
* an HMAC-SHA256 over the JSON payload using Node's built-in `crypto`,
* not a hand-rolled cipher. Signing rather than encrypting is a
* deliberate choice: the draft holds data the customer themselves just
* typed into a form on this domain (their own name/email/phone, plus
* non-sensitive selections), so the realistic threat is tampering
* (editing the cookie to book at a different price or under a different
* identity), not disclosure to the customer's own browser. httpOnly +
* Secure + SameSite=Lax (set in cookie.ts) cover disclosure to anyone
* else. Kept as a separate module from cookie.ts so the crypto itself is
* unit-testable without Next's `cookies()` API.
*/
const CONTEXT = "nextchair-booking-draft-v1";
function getSigningKey(): Buffer {
const secret = process.env.AUTH_SECRET;
if (!secret) {
throw new Error("AUTH_SECRET must be set to sign the booking draft cookie.");
}
// Derive a purpose-specific key from the shared app secret rather than
// reusing it directly, so this cookie's signing key and NextAuth's JWT
// key are cryptographically independent even though they start from
// the same secret.
return createHmac("sha256", secret).update(CONTEXT).digest();
}
/** payload.signature, both base64url — a compact, cookie-safe format. */
export function sign(payload: string): string {
const mac = createHmac("sha256", getSigningKey()).update(payload).digest("base64url");
const encodedPayload = Buffer.from(payload, "utf8").toString("base64url");
return `${encodedPayload}.${mac}`;
}
/** Returns the original payload if the signature is valid, otherwise null. Never throws on malformed input. */
export function verify(token: string): string | null {
const separatorIndex = token.indexOf(".");
if (separatorIndex === -1) return null;
const encodedPayload = token.slice(0, separatorIndex);
const mac = token.slice(separatorIndex + 1);
if (!encodedPayload || !mac) return null;
let payload: string;
try {
payload = Buffer.from(encodedPayload, "base64url").toString("utf8");
} catch {
return null;
}
const expectedMac = createHmac("sha256", getSigningKey()).update(payload).digest("base64url");
const macBuffer = Buffer.from(mac);
const expectedBuffer = Buffer.from(expectedMac);
if (macBuffer.length !== expectedBuffer.length) return null;
if (!timingSafeEqual(macBuffer, expectedBuffer)) return null;
return payload;
}
+166
View File
@@ -0,0 +1,166 @@
import { cache } from "react";
import { prisma } from "@/lib/db/prisma";
/**
* The one place every customer/staff/owner-visible surface gets the
* business's white-label identity from — see docs/phase-8-summary.md.
* "NextChair" is the internal product name only (repo, Docker project,
* package name, technical docs/logs); it must never be the fallback a
* real person sees. Fallback order, per the brief:
* 1. Configured business name + logo
* 2. Configured business name + the app's own neutral icon (handled
* naturally: leaving `logoUrl`/`appIconUrl`/`faviconUrl` null falls
* through to the static /icon.png / /apple-icon.png files Next
* already serves via its file-convention icons — no separate
* "neutral icon" asset needed)
* 3. Neutral wording with no visible product name at all — only
* reachable before installation completes, since every other route
* is gated behind a completed install (see
* src/lib/installation/installation-state.ts).
*
* Deliberately a separate, lighter query from getPrimaryBusiness() (which
* `findFirstOrThrow`s and eager-loads locations/hours/settings/
* bookingRule) — this is called from `generateMetadata` and the root
* layout on *every* request, including pre-install ones where no
* Business row exists yet, so it must never throw.
*/
/** One iOS `apple-touch-startup-image` entry — an exact per-device media query paired with its splash image, matching the shape a generator like progressier.com's ZIP bundle produces. See importAppIconZipAction. */
export interface SplashScreenEntry {
media: string;
url: string;
}
export interface BrandingInfo {
configured: boolean;
name: string;
shortName: string;
description: string | null;
logoUrl: string | null;
coverImageUrl: string | null;
faviconUrl: string | null;
/** Apple touch icon (iOS "Add to Home Screen") — distinct from the manifest icon fields below, which Android/Chrome installs read instead. */
appIconUrl: string | null;
themeColor: string;
backgroundColor: string;
/** PWA manifest `icons` array — see src/app/manifest.ts. Each falls back independently to the app's own generated icon when unset. */
icon192Url: string | null;
icon512Url: string | null;
maskableIcon192Url: string | null;
maskableIcon512Url: string | null;
/** iOS `apple-touch-startup-image` splash screens — see src/app/layout.tsx. */
splashScreens: SplashScreenEntry[];
/** Icon-only vs icon+label bottom navigation — see components/navigation/bottom-nav.tsx. */
bottomNavShowLabels: boolean;
/** Welcome page (/) background — see src/app/page.tsx. Independent images, not one image cropped two ways. */
welcomeBackgroundDesktopUrl: string | null;
welcomeBackgroundMobileUrl: string | null;
email: string | null;
phone: string | null;
}
/** Defensive parse of the `Business.splashScreens` JSON column — never trusts its shape at compile time. Drops any entry missing a string `media`/`url` rather than throwing. */
export function parseSplashScreens(value: unknown): SplashScreenEntry[] {
if (!Array.isArray(value)) return [];
return value.filter(
(entry): entry is SplashScreenEntry =>
typeof entry === "object" &&
entry !== null &&
typeof (entry as Record<string, unknown>).media === "string" &&
typeof (entry as Record<string, unknown>).url === "string",
);
}
const DEFAULT_THEME_COLOR = "#111b15";
const DEFAULT_BACKGROUND_COLOR = "#f4f6f4";
/** Neutral wording only — never the internal product name. */
const UNCONFIGURED_BRANDING: BrandingInfo = {
configured: false,
name: "Book an Appointment",
shortName: "Booking",
description: null,
logoUrl: null,
coverImageUrl: null,
faviconUrl: null,
appIconUrl: null,
themeColor: DEFAULT_THEME_COLOR,
backgroundColor: DEFAULT_BACKGROUND_COLOR,
icon192Url: null,
icon512Url: null,
maskableIcon192Url: null,
maskableIcon512Url: null,
splashScreens: [],
bottomNavShowLabels: true,
welcomeBackgroundDesktopUrl: null,
welcomeBackgroundMobileUrl: null,
email: null,
phone: null,
};
export const getBranding = cache(async (): Promise<BrandingInfo> => {
// Reached from the root layout's generateMetadata, which Next may
// invoke while statically prerendering a route at *build* time (e.g.
// /_not-found) — no real database is reachable then (see the
// Dockerfile's placeholder build-time database env vars). A transient/
// unreachable database is treated exactly like "not installed yet":
// neutral fallback wording, never a crashed build or a 500 at runtime.
let business;
try {
business = await prisma.business.findFirst({
orderBy: { createdAt: "asc" },
select: {
name: true,
shortName: true,
description: true,
logoUrl: true,
coverImageUrl: true,
faviconUrl: true,
appIconUrl: true,
themeColor: true,
backgroundColor: true,
icon192Url: true,
icon512Url: true,
maskableIcon192Url: true,
maskableIcon512Url: true,
splashScreens: true,
bottomNavShowLabels: true,
welcomeBackgroundDesktopUrl: true,
welcomeBackgroundMobileUrl: true,
email: true,
phone: true,
},
});
} catch {
return UNCONFIGURED_BRANDING;
}
if (!business) return UNCONFIGURED_BRANDING;
return {
configured: true,
name: business.name,
shortName: business.shortName?.trim() || business.name,
description: business.description,
logoUrl: business.logoUrl,
coverImageUrl: business.coverImageUrl,
faviconUrl: business.faviconUrl,
appIconUrl: business.appIconUrl,
themeColor: business.themeColor || DEFAULT_THEME_COLOR,
backgroundColor: business.backgroundColor || DEFAULT_BACKGROUND_COLOR,
icon192Url: business.icon192Url,
icon512Url: business.icon512Url,
maskableIcon192Url: business.maskableIcon192Url,
maskableIcon512Url: business.maskableIcon512Url,
splashScreens: parseSplashScreens(business.splashScreens),
bottomNavShowLabels: business.bottomNavShowLabels,
welcomeBackgroundDesktopUrl: business.welcomeBackgroundDesktopUrl,
welcomeBackgroundMobileUrl: business.welcomeBackgroundMobileUrl,
email: business.email,
phone: business.phone,
};
});
/** "Book an Appointment | Business Name" — the shared page-title format used in generateMetadata across the app. */
export function brandedTitle(pageTitle: string, branding: BrandingInfo): string {
return `${pageTitle} | ${branding.name}`;
}
@@ -0,0 +1,57 @@
import { cache } from "react";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { isSuperAdmin } from "@/lib/auth/permissions";
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
/**
* Resolves "which business is the signed-in user acting on behalf of" for
* pages/actions that only need the business id/name/timezone — not full
* staff identity (see getActingStaff for that, which stays the right
* choice for anything that needs a real StaffProfile id, e.g. loyalty
* stamp authorship or appointment reassignment).
*
* A regular staff member/owner resolves via their StaffProfile, same as
* getActingStaff. A Super Administrator (see scripts/ensure-super-admin.ts)
* deliberately has no StaffProfile at all — that's what keeps them out of
* the Team list/barber picker/appointment FKs by construction rather than
* a filter — so they resolve straight to the app's one business instead
* (v1 is single-business, same assumption getPrimaryBusiness() already
* makes everywhere else). Returns null only for a signed-in user who is
* neither staff nor a super admin.
*/
export const getActingBusinessContext = cache(async () => {
const session = await auth();
if (!session?.user?.id) return null;
const staffProfile = await prisma.staffProfile.findUnique({
where: { userId: session.user.id },
select: { id: true, businessId: true, business: { select: { name: true, timezone: true } } },
});
if (staffProfile) {
return {
userId: session.user.id,
staffId: staffProfile.id,
businessId: staffProfile.businessId,
businessName: staffProfile.business.name,
businessTimezone: staffProfile.business.timezone,
};
}
if (!(await isSuperAdmin(session.user.id))) return null;
const business = await getPrimaryBusiness();
return {
userId: session.user.id,
// A Super Administrator has no StaffProfile by design (see isSuperAdmin) —
// null here is what lets callers tell "acting as real staff" apart from
// "acting as a Super Administrator with no personal staff identity",
// e.g. to withhold note authorship (see CustomerNotes' actingStaffId).
staffId: null,
businessId: business.id,
businessName: business.name,
businessTimezone: business.timezone,
};
});
export type ActingBusinessContext = NonNullable<Awaited<ReturnType<typeof getActingBusinessContext>>>;
+21
View File
@@ -0,0 +1,21 @@
import { cache } from "react";
import { prisma } from "@/lib/db/prisma";
/**
* v1 is single-business (see docs/implementation-plan.md §11) — every
* page that needs "the business" fetches it through here rather than
* hardcoding a slug or re-querying independently. Wrapped in React's
* `cache()` so multiple Server Components in the same request (a layout
* and its page, for instance) share one query instead of issuing it
* once each.
*/
export const getPrimaryBusiness = cache(async () => {
return prisma.business.findFirstOrThrow({
include: {
locations: true,
hours: true,
settings: true,
bookingRule: true,
},
});
});
+45
View File
@@ -0,0 +1,45 @@
import { minutesFromMidnight, todayIn, weekdayOf } from "@/lib/time/business-time";
import { Weekday } from "@/generated/prisma/enums";
export interface OpeningStatus {
isOpen: boolean;
label: string;
}
interface DayHoursRow {
dayOfWeek: Weekday;
isClosed: boolean;
opensAtMinutes: number | null;
closesAtMinutes: number | null;
}
function formatMinutes(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
/**
* Purely presentational (read-only "open now?" label for the home page)
* — not part of the booking engine's own open/closed check, which
* AvailabilityService already performs independently as a real booking
* rule. This is display-only, so it lives here rather than in
* services/scheduling.
*/
export function computeOpeningStatus(hours: DayHoursRow[], timezone: string): OpeningStatus {
const today = todayIn(timezone);
const weekday = weekdayOf(today);
const todayHours = hours.find((h) => h.dayOfWeek === weekday);
if (!todayHours || todayHours.isClosed || todayHours.opensAtMinutes === null || todayHours.closesAtMinutes === null) {
return { isOpen: false, label: "Closed today" };
}
const nowMinutes = minutesFromMidnight(new Date(), timezone);
const isOpen = nowMinutes >= todayHours.opensAtMinutes && nowMinutes < todayHours.closesAtMinutes;
return {
isOpen,
label: isOpen ? `Open until ${formatMinutes(todayHours.closesAtMinutes)}` : `Closed · Opens ${formatMinutes(todayHours.opensAtMinutes)}`,
};
}
+94
View File
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import { generateAppointmentIcs } from "../ics";
const BASE_INPUT = {
appointmentId: "appt_123",
businessName: "Blake Heritage Barbershop",
businessAddress: "79 Joel Street, HA6 1LW",
staffName: "Marcus Reed",
serviceNames: ["Haircut & Style"],
startAt: new Date("2026-08-10T09:00:00.000Z"),
endAt: new Date("2026-08-10T09:30:00.000Z"),
};
describe("generateAppointmentIcs", () => {
it("produces a well-formed VCALENDAR/VEVENT block", () => {
const ics = generateAppointmentIcs(BASE_INPUT);
expect(ics).toMatch(/^BEGIN:VCALENDAR\r\n/);
expect(ics).toContain("BEGIN:VEVENT\r\n");
expect(ics).toContain("END:VEVENT\r\n");
expect(ics.trimEnd()).toMatch(/END:VCALENDAR$/);
});
it("formats DTSTART/DTEND as UTC instants regardless of local time zone", () => {
const ics = generateAppointmentIcs(BASE_INPUT);
expect(ics).toContain("DTSTART:20260810T090000Z");
expect(ics).toContain("DTEND:20260810T093000Z");
});
it("includes a UID derived from the appointment id, stable across re-generation", () => {
const first = generateAppointmentIcs(BASE_INPUT);
const second = generateAppointmentIcs(BASE_INPUT);
const uidPattern = /UID:appt_123@nextchair/;
expect(first).toMatch(uidPattern);
expect(second).toMatch(uidPattern);
});
it("combines multiple service names in the summary and description", () => {
const ics = generateAppointmentIcs({ ...BASE_INPUT, serviceNames: ["Haircut & Style", "Beard Trim"] });
expect(ics).toContain("SUMMARY:Haircut & Style + Beard Trim at Blake Heritage Barbershop");
expect(ics).toContain("Services: Haircut & Style\\, Beard Trim");
});
it("escapes commas, semicolons, and backslashes per RFC 5545", () => {
const ics = generateAppointmentIcs({
...BASE_INPUT,
businessName: "Sam's Cuts; Trims, & More\\Co",
});
// Escaped backslash first, then semicolon, then comma — order matters so
// the escaping backslashes themselves don't get re-escaped.
expect(ics).toContain("Sam's Cuts\\; Trims\\, & More\\\\Co");
});
it("escapes newlines in notes as literal \\n rather than breaking the content line", () => {
const ics = generateAppointmentIcs({ ...BASE_INPUT, notes: "Line one\nLine two" });
expect(ics).toContain("Line one\\nLine two");
// No raw, unescaped newline should appear inside the DESCRIPTION value itself.
const descriptionLine = ics.split("\r\n").find((l) => l.startsWith("DESCRIPTION"));
expect(descriptionLine).toBeDefined();
});
it("omits the Notes line entirely when there are no notes", () => {
const ics = generateAppointmentIcs(BASE_INPUT);
expect(ics).not.toContain("Notes:");
});
it("falls back to just the business name when no address is given", () => {
const ics = generateAppointmentIcs({ ...BASE_INPUT, businessAddress: undefined });
expect(ics).toContain("LOCATION:Blake Heritage Barbershop");
});
it("folds content lines longer than 75 octets per RFC 5545", () => {
const longNotes = "N".repeat(200);
const ics = generateAppointmentIcs({ ...BASE_INPUT, notes: longNotes });
const rawLines = ics.split("\r\n");
// No single physical line (before folding continuation) should exceed 75 octets.
for (const line of rawLines) {
expect(line.length).toBeLessThanOrEqual(75);
}
// Continuation lines start with a single space, per the spec.
const descriptionStart = rawLines.findIndex((l) => l.startsWith("DESCRIPTION"));
expect(rawLines[descriptionStart + 1]?.startsWith(" ")).toBe(true);
});
it("uses CRLF line endings throughout", () => {
const ics = generateAppointmentIcs(BASE_INPUT);
expect(ics.includes("\r\n")).toBe(true);
// Every bare \n should be immediately preceded by \r.
for (let i = 0; i < ics.length; i++) {
if (ics[i] === "\n") {
expect(ics[i - 1]).toBe("\r");
}
}
});
});
+89
View File
@@ -0,0 +1,89 @@
/**
* RFC 5545 (.ics) generation — provider-independent, works with every
* calendar app without any Google/Apple API access, per the Phase 4
* brief ("Do not require Google Calendar API access for the basic
* feature"). Pure string formatting, no I/O — the caller (a route
* handler) supplies already-validated, already-loaded data.
*
* Time zone handling: DTSTART/DTEND are emitted as UTC instants
* (`YYYYMMDDTHHMMSSZ`). This is the simplest *correct* approach — every
* calendar app converts a UTC-suffixed time to the viewer's own local
* time zone automatically, so there's no VTIMEZONE block to get wrong
* and no ambiguity around DST for whoever's viewing the event.
*/
export interface AppointmentIcsInput {
appointmentId: string;
businessName: string;
businessAddress?: string;
staffName: string;
serviceNames: string[];
startAt: Date;
endAt: Date;
notes?: string;
}
function pad(n: number, width = 2): string {
return String(n).padStart(width, "0");
}
function formatUtc(date: Date): string {
return (
`${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}` +
`T${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z`
);
}
/** Escapes text per RFC 5545 §3.3.11 — backslash, semicolon, comma, then newlines. Order matters: backslash must be escaped first, or the escaping backslashes we add for the other characters would themselves get re-escaped. */
function escapeText(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\r\n|\r|\n/g, "\\n");
}
/** Folds a content line at 75 octets per RFC 5545 §3.1, continuation lines prefixed with a single space. */
function foldLine(line: string): string {
const CRLF_FOLD = "\r\n ";
if (line.length <= 75) return line;
const chunks: string[] = [];
let rest = line;
let first = true;
while (rest.length > 0) {
const limit = first ? 75 : 74; // continuation lines lose one column to the leading space
chunks.push(rest.slice(0, limit));
rest = rest.slice(limit);
first = false;
}
return chunks.join(CRLF_FOLD);
}
export function generateAppointmentIcs(input: AppointmentIcsInput): string {
const summary = escapeText(`${input.serviceNames.join(" + ")} at ${input.businessName}`);
const descriptionParts = [
`Barber: ${input.staffName}`,
`Services: ${input.serviceNames.join(", ")}`,
`Booking reference: ${input.appointmentId}`,
];
if (input.notes) descriptionParts.push(`Notes: ${input.notes}`);
const description = escapeText(descriptionParts.join("\n"));
const location = input.businessAddress ? escapeText(`${input.businessName}, ${input.businessAddress}`) : escapeText(input.businessName);
const lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Booking Calendar//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"BEGIN:VEVENT",
`UID:${input.appointmentId}@nextchair`,
`DTSTAMP:${formatUtc(new Date())}`,
`DTSTART:${formatUtc(input.startAt)}`,
`DTEND:${formatUtc(input.endAt)}`,
`SUMMARY:${summary}`,
`DESCRIPTION:${description}`,
`LOCATION:${location}`,
"END:VEVENT",
"END:VCALENDAR",
];
return lines.map(foldLine).join("\r\n") + "\r\n";
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Builds the `mariadb` pool config from the discrete `DATABASE_HOST` /
* `DATABASE_PORT` / `DATABASE_NAME` / `DATABASE_USER` / `DATABASE_PASSWORD`
* env vars, shared by every Prisma Client instance in the app (the main
* app client, the seed script, and the integration-test fixtures) so the
* UTC-timezone and connection-limit handling can't drift between them. See
* src/lib/db/prisma.ts for why `timezone`/`sessionVariables` matter — this
* file exists only because that reasoning needs to apply to all three
* call sites, not because the logic itself is complex.
*/
export function buildMysqlPoolConfig() {
const host = process.env.DATABASE_HOST;
const database = process.env.DATABASE_NAME;
const user = process.env.DATABASE_USER;
const password = process.env.DATABASE_PASSWORD;
if (!host || !database || !user || password === undefined) {
throw new Error(
"Database connection is not configured. Set DATABASE_HOST, DATABASE_NAME, DATABASE_USER, and DATABASE_PASSWORD (DATABASE_PORT is optional, defaults to 3306).",
);
}
return {
host,
port: process.env.DATABASE_PORT ? Number(process.env.DATABASE_PORT) : 3306,
user,
password,
database,
connectionLimit: Number(process.env.DATABASE_CONNECTION_LIMIT ?? 5),
connectTimeout: Number(process.env.DATABASE_CONNECT_TIMEOUT_MS ?? 10_000),
// Load-bearing, not cosmetic — see src/lib/db/prisma.ts's file header.
timezone: "Etc/UTC",
sessionVariables: { time_zone: "+00:00" },
ssl: process.env.DATABASE_SSL === "true" ? { rejectUnauthorized: true } : undefined,
// MySQL 8's default auth plugin (caching_sha2_password) needs to send
// the client its RSA public key to encrypt the password, and the
// `mariadb` driver refuses to fetch it over a plain connection unless
// told to — surfacing as "Public Key Retrieval is not allowed" on
// every connection attempt (not intermittent, not env-specific: it
// fires against any caching_sha2_password server whenever DATABASE_SSL
// isn't "true"). Opting in doesn't weaken authentication itself, only
// lets the client ask for that public key; a connection that's already
// on `ssl` above doesn't need this exchange in the first place.
allowPublicKeyRetrieval: true,
};
}
/**
* Builds a `mysql://` connection string from the same discrete env vars,
* for the one place that genuinely needs a URL rather than a pool config
* object: prisma.config.ts, whose `datasource.url` is a plain string
* because the Prisma CLI (migrate/introspect) doesn't accept discrete
* connection fields.
*/
export function buildMysqlConnectionUrl(): string | undefined {
const host = process.env.DATABASE_HOST;
const database = process.env.DATABASE_NAME;
const user = process.env.DATABASE_USER;
const password = process.env.DATABASE_PASSWORD;
if (!host || !database || !user || password === undefined) return undefined;
const port = process.env.DATABASE_PORT || "3306";
return `mysql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${host}:${port}/${database}`;
}
+44
View File
@@ -0,0 +1,44 @@
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import { buildMysqlPoolConfig } from "./mysql-pool-config";
/**
* Single Prisma Client instance for the whole app.
*
* Prisma 7 requires a driver adapter instead of a `datasource.url` in the
* schema (see prisma/schema.prisma). This is the ONLY file that imports a
* database driver directly — every other file goes through `prisma`.
*
* Migrated from PostgreSQL (`@prisma/adapter-pg`) to MySQL in Phase 7 — see
* docs/phase-7-summary.md for the full record. `@prisma/adapter-mariadb` is
* Prisma's one official driver adapter for BOTH MySQL and MariaDB (there is
* no separate "adapter-mysql" package): the underlying `mariadb` npm driver
* speaks the MySQL wire protocol and is Prisma's documented, supported way
* to connect to a genuine MySQL server. The package name is not a sign
* we're accidentally targeting MariaDB — `datasource.provider` in
* schema.prisma is `"mysql"`, and every environment this app runs against
* (local Docker, CI, and the documented production target) is real MySQL.
*
* See mysql-pool-config.ts for why the pool is built from a parsed object
* rather than passed the raw connection string — the short version is that
* the UTC-timezone handling it sets is load-bearing, not cosmetic.
*/
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
const adapter = new PrismaMariaDb(buildMysqlPoolConfig());
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
adapter,
log: process.env.NODE_ENV === "development" ? ["warn", "error"] : ["error"],
});
// Reuse the client across hot-reloads in dev so we don't exhaust the
// connection pool every time a file changes.
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
+102
View File
@@ -0,0 +1,102 @@
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import {
faAward,
faBell,
faBolt,
faBriefcase,
faBrush,
faCalendarCheck,
faCamera,
faCheck,
faCircleCheck,
faClock,
faCompass,
faCrown,
faDiamond,
faFire,
faGem,
faGift,
faGlassCheers,
faHandSparkles,
faHatCowboy,
faHeart,
faLeaf,
faLightbulb,
faMedal,
faMugSaucer,
faPaintbrush,
faRibbon,
faScissors,
faShieldHeart,
faSprayCanSparkles,
faStamp,
faStar,
faStarHalfStroke,
faSun,
faTag,
faThumbsUp,
faTicket,
faTrophy,
faUserTie,
faWandMagicSparkles,
} from "@fortawesome/free-solid-svg-icons";
/**
* Curated, not the full ~2000-icon Font Awesome solid set — deliberately:
* a searchable picker over every icon Font Awesome ships (razors,
* spreadsheets, ambulances...) is a worse experience for "pick a loyalty
* stamp icon" than a relevant shortlist, and named imports here (rather
* than `import { fas } from "..."`) keep only these icons' path data in
* the bundle — this component is only ever loaded on the owner Loyalty
* settings page, never the customer bundle, but there's no reason to
* bundle 2000 unused icons regardless. Add to this list as new use cases
* come up; nothing about IconPicker itself is tied to this specific set.
*/
export const ICON_CATALOG: IconDefinition[] = [
faScissors,
faStar,
faStarHalfStroke,
faHeart,
faGift,
faCrown,
faGem,
faDiamond,
faAward,
faMedal,
faTrophy,
faRibbon,
faStamp,
faTag,
faTicket,
faThumbsUp,
faCheck,
faCircleCheck,
faFire,
faBolt,
faSun,
faLeaf,
faMugSaucer,
faGlassCheers,
faHandSparkles,
faSprayCanSparkles,
faWandMagicSparkles,
faBrush,
faPaintbrush,
faCamera,
faHatCowboy,
faUserTie,
faBriefcase,
faShieldHeart,
faLightbulb,
faCompass,
faClock,
faCalendarCheck,
faBell,
];
const ICON_BY_NAME = new Map<string, IconDefinition>(ICON_CATALOG.map((icon) => [icon.iconName, icon]));
/** Falls back to a neutral default for anything not in the catalogue — covers legacy free-text values from before this picker existed. */
export function findCatalogIcon(iconName: string | null | undefined): IconDefinition {
return (iconName && ICON_BY_NAME.get(iconName)) || faScissors;
}
@@ -0,0 +1,55 @@
import { cache } from "react";
import { prisma } from "@/lib/db/prisma";
import { InstallationStatus } from "@/generated/prisma/enums";
/**
* ApplicationInstallation always uses this fixed id — see the model's own
* header in prisma/schema.prisma for why a fixed id (not a lookup by "any
* row") is the concurrency-safety mechanism, not just a convention.
*/
export const INSTALLATION_SINGLETON_ID = "singleton";
export interface InstallationState {
installed: boolean;
status: InstallationStatus;
}
/**
* Server Component-scoped read (React `cache()`-deduped per request) — use
* this from pages/layouts. `proxy.ts` has its own process-lifetime-cached
* check instead (see isInstalledForRequest below): a Server Component
* dedupes per-request, but the proxy runs on every single request and a
* fresh DB round trip each time would be real, avoidable cost once the app
* is (permanently, for the life of the process) installed.
*/
export const getInstallationState = cache(async (): Promise<InstallationState> => {
const row = await prisma.applicationInstallation.findUnique({
where: { id: INSTALLATION_SINGLETON_ID },
select: { status: true },
});
const status = row?.status ?? InstallationStatus.NOT_INSTALLED;
return { installed: status === InstallationStatus.INSTALLED, status };
});
/**
* Module-level (not request-level) cache for `proxy.ts`. Installation is a
* one-way, one-time transition for the life of a deployment — once this
* process has observed `INSTALLED`, it can never legitimately become
* un-installed again (there is no "reset" action, per the brief's own
* "no unauthenticated reset button" requirement), so re-querying the
* database on every single request forever after is pure waste. Before
* install, every request still does the real check, which is exactly the
* window where correctness (redirecting to /install) actually matters.
*/
let processObservedInstalled = false;
export async function isInstalledForRequest(): Promise<boolean> {
if (processObservedInstalled) return true;
const row = await prisma.applicationInstallation.findUnique({
where: { id: INSTALLATION_SINGLETON_ID },
select: { status: true },
});
const installed = row?.status === InstallationStatus.INSTALLED;
if (installed) processObservedInstalled = true;
return installed;
}
@@ -0,0 +1,98 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
/**
* Real filesystem I/O against a throwaway temp directory (via
* MEDIA_STORAGE_ROOT), not mocks — this module's whole job is getting
* real file paths right, so a mocked `fs` would test nothing useful. No
* database/Prisma dependency here (media-storage.ts is DB-free), so
* this stays safe to run in the plain unit suite. The module is
* dynamically imported after MEDIA_STORAGE_ROOT is set, since it reads
* that env var once at module-load time.
*/
describe("media-storage", () => {
let tempRoot: string;
let storage: typeof import("../media-storage");
beforeAll(async () => {
tempRoot = await mkdtemp(path.join(tmpdir(), "nextchair-media-test-"));
process.env.MEDIA_STORAGE_ROOT = tempRoot;
storage = await import("../media-storage");
});
afterAll(async () => {
delete process.env.MEDIA_STORAGE_ROOT;
await rm(tempRoot, { recursive: true, force: true });
});
const PNG_BYTES = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
);
it("stores a file and returns a stable /api/media/ URL", async () => {
const result = await storage.storeMediaFile({ buffer: PNG_BYTES, mimeType: "image/png", category: "branding" });
expect(result.url).toMatch(/^\/api\/media\/branding\/[a-f0-9-]+\.png$/);
});
it("readStoredMediaFile resolves the stored file back with the right bytes and content type", async () => {
const stored = await storage.storeMediaFile({ buffer: PNG_BYTES, mimeType: "image/png", category: "gallery" });
const segments = stored.url.replace(storage.MEDIA_URL_PREFIX, "").split("/");
const file = await storage.readStoredMediaFile(segments);
expect(file).not.toBeNull();
expect(file?.mimeType).toBe("image/png");
expect(file?.buffer.equals(PNG_BYTES)).toBe(true);
});
it("readStoredMediaFile returns null for a file that was never written", async () => {
const file = await storage.readStoredMediaFile(["branding", "does-not-exist.png"]);
expect(file).toBeNull();
});
it("deleteMediaFile removes the file — a subsequent read returns null", async () => {
const stored = await storage.storeMediaFile({ buffer: PNG_BYTES, mimeType: "image/png", category: "staff" });
const segments = stored.url.replace(storage.MEDIA_URL_PREFIX, "").split("/");
expect(await storage.readStoredMediaFile(segments)).not.toBeNull();
await storage.deleteMediaFile(stored.url);
expect(await storage.readStoredMediaFile(segments)).toBeNull();
});
it("deleteMediaFile on an external (non-/api/media/) URL is a safe no-op", async () => {
await expect(storage.deleteMediaFile("https://example.com/logo.png")).resolves.toBeUndefined();
});
it("rejects unsupported mime types before ever touching the filesystem", async () => {
await expect(storage.storeMediaFile({ buffer: PNG_BYTES, mimeType: "image/svg+xml", category: "branding" })).rejects.toThrow();
});
it("blocks path traversal via '..' segments", () => {
expect(storage.resolveMediaFilePath(["..", "..", "etc", "passwd"])).toBeNull();
expect(storage.resolveMediaFilePath(["branding", ".."])).toBeNull();
expect(storage.resolveMediaFilePath(["branding", "..", "..", "package.json"])).toBeNull();
});
it("blocks segments that smuggle a path separator", () => {
expect(storage.resolveMediaFilePath(["branding/../../../etc/passwd"])).toBeNull();
expect(storage.resolveMediaFilePath(["a\\b"])).toBeNull();
});
it("readStoredMediaFile returns null for a path-traversal attempt even if a file exists there", async () => {
// Prove the guard rejects it via the real read path, not just the resolver in isolation.
const file = await storage.readStoredMediaFile(["..", "..", "..", "etc", "passwd"]);
expect(file).toBeNull();
});
it("mimeTypeForFilename covers every allowed extension and rejects unknown ones", () => {
expect(storage.mimeTypeForFilename("x.png")).toBe("image/png");
expect(storage.mimeTypeForFilename("x.jpg")).toBe("image/jpeg");
expect(storage.mimeTypeForFilename("x.jpeg")).toBe("image/jpeg");
expect(storage.mimeTypeForFilename("x.webp")).toBe("image/webp");
expect(storage.mimeTypeForFilename("x.gif")).toBe("image/gif");
expect(storage.mimeTypeForFilename("x.svg")).toBeNull();
expect(storage.mimeTypeForFilename("x")).toBeNull();
});
});
+148
View File
@@ -0,0 +1,148 @@
import { randomUUID } from "node:crypto";
import { access, mkdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
/**
* Local-disk storage for the Media Manager (see src/services/media).
*
* Files live under a storage root OUTSIDE `public/` (`storage/media` by
* default — `/app/storage/media` in the Docker image, matching the
* `next-chair-media-data` volume in docker-compose.local.yaml). This was
* previously `public/media/`, served by Next's own static file handling
* — that turned out not to work: verified directly against a real
* production build/container that Next.js 16's `next start` does NOT
* dynamically serve files added to `public/` after the build (a
* build-time-present file served 200; a file written into `public/media`
* at runtime served 404, even though it existed on disk with correct
* permissions). See src/app/api/media/[...path]/route.ts, the dedicated
* route that now serves these files itself instead of relying on Next's
* public-asset resolution.
*
* SVG is deliberately not in the allow-list — an uploaded SVG can embed
* <script>, making it a stored-XSS vector if ever served inline.
*
* Kept provider-neutral by design: every caller only ever sees a
* MediaAsset.url string and calls storeMediaFile/readStoredMediaFile/
* deleteMediaFile — swapping this file for an S3-compatible backend
* later doesn't require touching any caller.
*/
const ALLOWED_MIME_EXTENSIONS: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
"image/gif": "gif",
};
const EXTENSION_MIME_TYPES: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
webp: "image/webp",
gif: "image/gif",
};
export const MEDIA_MAX_UPLOAD_BYTES = Number(process.env.MEDIA_MAX_UPLOAD_BYTES ?? 5_000_000);
/** Overridable so a non-Docker deployment can point this somewhere else; defaults to a directory next to the app, outside public/. */
const MEDIA_ROOT = path.resolve(process.env.MEDIA_STORAGE_ROOT || "storage/media");
export const MEDIA_URL_PREFIX = "/api/media/";
export function isAllowedMediaMimeType(mimeType: string): boolean {
return mimeType in ALLOWED_MIME_EXTENSIONS;
}
export interface StoredMediaFile {
/** Public path — what gets saved on MediaAsset.url and rendered directly. Resolved by src/app/api/media/[...path]/route.ts, not Next's static handling. */
url: string;
}
function sanitizeCategory(category: string): string {
return category.replace(/[^a-z0-9_-]/gi, "").toLowerCase() || "other";
}
/**
* Resolves a `/api/media/<...segments>` route param array to an absolute
* path, refusing anything that would escape MEDIA_ROOT — the only
* defense that actually matters against path traversal (`..` segments,
* absolute-path segments, encoded separators once decoded by Next's own
* routing). Returns null for anything unsafe rather than throwing, so
* callers can turn that straight into a 404 without distinguishing
* "malicious" from "malformed."
*/
export function resolveMediaFilePath(segments: string[]): string | null {
if (segments.length === 0) return null;
if (segments.some((segment) => !segment || segment === "." || segment === ".." || segment.includes("/") || segment.includes("\\"))) {
return null;
}
const absolutePath = path.resolve(MEDIA_ROOT, ...segments);
if (absolutePath !== MEDIA_ROOT && !absolutePath.startsWith(MEDIA_ROOT + path.sep)) return null;
return absolutePath;
}
export function mimeTypeForFilename(filename: string): string | null {
const extension = filename.split(".").pop()?.toLowerCase();
if (!extension) return null;
return EXTENSION_MIME_TYPES[extension] ?? null;
}
export interface StoredMediaFileContent {
buffer: Buffer;
mimeType: string;
}
/** Used only by GET /api/media/[...path] — returns null for "doesn't exist" or "not a regular file," never throws, so the route can turn that into a clean 404. */
export async function readStoredMediaFile(segments: string[]): Promise<StoredMediaFileContent | null> {
const absolutePath = resolveMediaFilePath(segments);
if (!absolutePath) return null;
const mimeType = mimeTypeForFilename(absolutePath);
if (!mimeType) return null;
try {
const fileStat = await stat(absolutePath);
if (!fileStat.isFile()) return null;
const buffer = await readFile(absolutePath);
return { buffer, mimeType };
} catch {
return null;
}
}
export async function storeMediaFile(params: {
buffer: Buffer;
mimeType: string;
category: string;
}): Promise<StoredMediaFile> {
const extension = ALLOWED_MIME_EXTENSIONS[params.mimeType];
if (!extension) throw new Error(`Unsupported media type: ${params.mimeType}`);
const safeCategory = sanitizeCategory(params.category);
const filename = `${randomUUID()}.${extension}`;
const dir = path.join(MEDIA_ROOT, safeCategory);
const absolutePath = path.join(dir, filename);
await mkdir(dir, { recursive: true });
await writeFile(absolutePath, params.buffer);
// Never report success on a write we can't actually read back — see
// this file's header. A silent permission/filesystem issue on write
// (e.g. a volume mounted read-only) should surface as a failed upload,
// not a broken URL discovered later.
try {
await access(absolutePath);
} catch {
throw new Error("Upload failed: the file could not be verified after writing.");
}
return { url: `${MEDIA_URL_PREFIX}${safeCategory}/${filename}` };
}
/** Best-effort — a missing file (already deleted, or a URL that was never ours) never blocks removing the DB row. */
export async function deleteMediaFile(url: string): Promise<void> {
if (!url.startsWith(MEDIA_URL_PREFIX)) return;
const relative = url.slice(MEDIA_URL_PREFIX.length);
const absolutePath = resolveMediaFilePath(relative.split("/"));
if (!absolutePath) return;
await unlink(absolutePath).catch(() => {});
}
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { getNavItems, resolveActiveHref } from "../nav-config";
describe("getNavItems", () => {
it("gives a barber exactly Calendar/Now/Clients/Settings", () => {
const labels = getNavItems("staff").map((i) => i.label);
expect(labels).toEqual(["Calendar", "Now", "Clients", "Settings"]);
});
it("gives an owner Calendar/Now/Clients/Manage/Settings — barber tools and business tools in one menu", () => {
const labels = getNavItems("owner").map((i) => i.label);
expect(labels).toEqual(["Calendar", "Now", "Clients", "Manage", "Settings"]);
});
it("gives a Super Administrator no 'Now' tab — there is no personal barber schedule to show", () => {
const labels = getNavItems("superadmin").map((i) => i.label);
expect(labels).toEqual(["Calendar", "Clients", "Manage", "Settings"]);
expect(labels).not.toContain("Now");
});
it("points owner's Now/Clients/Settings at the same routes a barber uses — no duplicate business-wide pages", () => {
const owner = getNavItems("owner");
const staff = getNavItems("staff");
expect(owner.find((i) => i.label === "Now")?.href).toBe(staff.find((i) => i.label === "Now")?.href);
expect(owner.find((i) => i.label === "Clients")?.href).toBe(staff.find((i) => i.label === "Clients")?.href);
expect(owner.find((i) => i.label === "Settings")?.href).toBe(staff.find((i) => i.label === "Settings")?.href);
});
it("gives owner and super-admin a business-wide Calendar, distinct from the barber's personal one", () => {
expect(getNavItems("owner").find((i) => i.label === "Calendar")?.href).toBe("/owner/calendar");
expect(getNavItems("superadmin").find((i) => i.label === "Calendar")?.href).toBe("/owner/calendar");
expect(getNavItems("staff").find((i) => i.label === "Calendar")?.href).toBe("/staff");
});
it("caps every role's nav at 5 items", () => {
for (const role of ["staff", "owner", "superadmin"] as const) {
expect(getNavItems(role).length).toBeLessThanOrEqual(5);
}
});
});
describe("resolveActiveHref", () => {
const staffItems = getNavItems("staff");
const ownerItems = getNavItems("owner");
it("activates Calendar for the barber's own calendar root", () => {
expect(resolveActiveHref("/staff", staffItems)).toBe("/staff");
});
it("activates Now for /staff/current, not Calendar — the classic longest-prefix trap", () => {
expect(resolveActiveHref("/staff/current", staffItems)).toBe("/staff/current");
});
it("activates Clients for a nested client detail route", () => {
expect(resolveActiveHref("/staff/clients/abc123", staffItems)).toBe("/staff/clients");
});
it("activates Settings for a nested personal-settings route", () => {
expect(resolveActiveHref("/staff/settings/calendar", staffItems)).toBe("/staff/settings");
});
it("activates Manage for every /owner/* business-management route", () => {
expect(resolveActiveHref("/owner/team", ownerItems)).toBe("/owner");
expect(resolveActiveHref("/owner/services", ownerItems)).toBe("/owner");
expect(resolveActiveHref("/owner/settings/loyalty", ownerItems)).toBe("/owner");
});
it("activates the business-wide Calendar for /owner/calendar, not Manage", () => {
expect(resolveActiveHref("/owner/calendar", ownerItems)).toBe("/owner/calendar");
});
it("activates Now for /staff/current even from the owner nav (shared personal route)", () => {
expect(resolveActiveHref("/staff/current", ownerItems)).toBe("/staff/current");
});
it("returns null for a route no item covers", () => {
expect(resolveActiveHref("/sign-in", staffItems)).toBeNull();
});
});
+50
View File
@@ -0,0 +1,50 @@
import type { BottomNavItem } from "@/components/navigation/bottom-nav";
/**
* Single source of truth for the merged staff/owner/super-admin bottom nav
* and desktop sidebar (see docs/implementation-plan.md and the Phase 11
* "merged navigation" brief). Owner is a permission on a staff account, not
* a separate app — Owner and SuperAdmin nav reuse the exact same personal
* routes (`/staff/current`, `/staff/clients`, `/staff/settings`) a barber
* uses, rather than duplicating them under `/owner`. This is what lets
* owners reach barber tools and business tools from one menu with zero new
* routes.
*
* A Super Administrator has no StaffProfile by construction (see
* isSuperAdmin/getActingBusinessContext) — they get no "Now" tab, since
* there is no personal schedule to show, per the brief's explicit "do not
* fabricate a personal barber schedule for the Super Administrator".
*/
export type NavRole = "staff" | "owner" | "superadmin";
const CALENDAR: BottomNavItem = { href: "/staff", label: "Calendar", icon: "calendar-days" };
const OWNER_CALENDAR: BottomNavItem = { href: "/owner/calendar", label: "Calendar", icon: "calendar-days" };
const NOW: BottomNavItem = { href: "/staff/current", label: "Now", icon: "scissors" };
const CLIENTS: BottomNavItem = { href: "/staff/clients", label: "Clients", icon: "users" };
const MANAGE: BottomNavItem = { href: "/owner", label: "Manage", icon: "briefcase" };
const SETTINGS: BottomNavItem = { href: "/staff/settings", label: "Settings", icon: "settings" };
const NAV_ITEMS_BY_ROLE: Record<NavRole, BottomNavItem[]> = {
staff: [CALENDAR, NOW, CLIENTS, SETTINGS],
owner: [OWNER_CALENDAR, NOW, CLIENTS, MANAGE, SETTINGS],
superadmin: [OWNER_CALENDAR, CLIENTS, MANAGE, SETTINGS],
};
export function getNavItems(role: NavRole): BottomNavItem[] {
return NAV_ITEMS_BY_ROLE[role];
}
/**
* Longest-matching-href wins, rather than "every item whose href is a
* pathname prefix" (the previous per-component logic) — that scheme let
* `/staff/current` mark both "Calendar" (href `/staff`) and "Now" active at
* once, since `/staff/current` also starts with `/staff`. Sorting by href
* length and taking the first match makes only the most specific item win,
* which is what makes nested routes (e.g. `/owner/settings/loyalty`,
* `/staff/clients/[id]`) activate exactly one, correct primary tab.
*/
export function resolveActiveHref(pathname: string, items: BottomNavItem[]): string | null {
const matches = items.filter((item) => pathname === item.href || pathname.startsWith(`${item.href}/`));
if (matches.length === 0) return null;
return matches.reduce((longest, item) => (item.href.length > longest.href.length ? item : longest)).href;
}
+58
View File
@@ -0,0 +1,58 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { Permission } from "@/generated/prisma/enums";
/**
* The two system roles' permission sets — single source of truth, used by
* both `prisma/seed.ts` (dev/demo fixtures) and the installation wizard
* (`src/features/installation/installer.service.ts`, the real "first
* owner" creation path). Previously duplicated only in seed.ts; extracted
* here in Phase 8 so a permission added to one path can't silently miss
* the other (see docs/phase-8-summary.md).
*/
export const OWNER_PERMISSIONS: Permission[] = [
Permission.MANAGE_BUSINESS_PROFILE,
Permission.MANAGE_BOOKING_RULES,
Permission.MANAGE_SERVICES,
Permission.MANAGE_STAFF,
Permission.MANAGE_LOYALTY_PROGRAMME,
Permission.MANAGE_NOTIFICATION_SETTINGS,
Permission.VIEW_ALL_STAFF_APPOINTMENTS,
Permission.MANAGE_ALL_APPOINTMENTS,
Permission.MANAGE_OWN_APPOINTMENTS,
Permission.CHECK_IN_CUSTOMERS,
Permission.MANAGE_LOYALTY_STAMPS,
Permission.MANAGE_OWN_AVAILABILITY,
Permission.VIEW_CUSTOMER_DIRECTORY,
Permission.BOOK_ON_BEHALF_OF_CUSTOMER,
Permission.VIEW_REPORTING,
Permission.MANAGE_MEDIA_LIBRARY,
Permission.MANAGE_REVIEWS,
];
export const BARBER_PERMISSIONS: Permission[] = [
Permission.MANAGE_OWN_APPOINTMENTS,
Permission.CHECK_IN_CUSTOMERS,
Permission.MANAGE_LOYALTY_STAMPS,
Permission.MANAGE_OWN_AVAILABILITY,
Permission.VIEW_CUSTOMER_DIRECTORY,
Permission.BOOK_ON_BEHALF_OF_CUSTOMER,
];
/**
* `role.upsert`'s `update: {}` deliberately does nothing on an existing
* role, so a plain nested `permissions: { create: [...] }` only fires on
* the `create` branch — this syncs RolePermission rows to exactly match
* the target list every time, safe to call repeatedly. See prisma/seed.ts
* for the original bug this fixed.
*/
export async function syncRolePermissions(
prisma: Pick<PrismaClient, "rolePermission">,
roleId: string,
permissions: Permission[],
): Promise<void> {
await prisma.rolePermission.deleteMany({ where: { roleId, permission: { notIn: permissions } } });
await prisma.rolePermission.createMany({
data: permissions.map((permission) => ({ roleId, permission })),
skipDuplicates: true,
});
}
@@ -0,0 +1,31 @@
import { afterEach, describe, expect, it } from "vitest";
import { isSecureDeployment } from "../cookie-security";
describe("isSecureDeployment", () => {
const originalAppUrl = process.env.NEXT_PUBLIC_APP_URL;
afterEach(() => {
if (originalAppUrl === undefined) delete process.env.NEXT_PUBLIC_APP_URL;
else process.env.NEXT_PUBLIC_APP_URL = originalAppUrl;
});
it("is true when the deployment's own URL is https", () => {
process.env.NEXT_PUBLIC_APP_URL = "https://barbershop.example.com";
expect(isSecureDeployment()).toBe(true);
});
it("is false for a plain-http deployment — e.g. the documented default self-hosted setup", () => {
process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000";
expect(isSecureDeployment()).toBe(false);
});
it("is false for a plain-http LAN address, regardless of NODE_ENV", () => {
process.env.NEXT_PUBLIC_APP_URL = "http://192.168.1.50:3000";
expect(isSecureDeployment()).toBe(false);
});
it("is false when unset", () => {
delete process.env.NEXT_PUBLIC_APP_URL;
expect(isSecureDeployment()).toBe(false);
});
});
@@ -0,0 +1,37 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createOAuthState, verifyOAuthState } from "../oauth-state";
describe("oauth-state", () => {
beforeEach(() => {
process.env.AUTH_SECRET = "test-auth-secret-not-for-production";
});
it("verifies a state token for the staff member it was issued to", () => {
const state = createOAuthState("staff-123");
expect(verifyOAuthState(state, "staff-123")).toBe(true);
});
it("rejects a state token issued to a different staff member", () => {
const state = createOAuthState("staff-123");
expect(verifyOAuthState(state, "staff-999")).toBe(false);
});
it("rejects a tampered token", () => {
const state = createOAuthState("staff-123");
const tampered = `${state.slice(0, -2)}xx`;
expect(verifyOAuthState(tampered, "staff-123")).toBe(false);
});
it("rejects a malformed token", () => {
expect(verifyOAuthState("not-a-real-token", "staff-123")).toBe(false);
expect(verifyOAuthState("", "staff-123")).toBe(false);
});
it("rejects an expired token", () => {
vi.useFakeTimers();
const state = createOAuthState("staff-123");
vi.advanceTimersByTime(11 * 60 * 1000); // past the 10-minute max age
expect(verifyOAuthState(state, "staff-123")).toBe(false);
vi.useRealTimers();
});
});
@@ -0,0 +1,50 @@
import { describe, expect, it, vi, afterEach } from "vitest";
import { checkRateLimit } from "../rate-limit";
describe("checkRateLimit", () => {
afterEach(() => {
vi.useRealTimers();
});
it("allows requests under the limit", () => {
const key = `test:${crypto.randomUUID()}`;
for (let i = 0; i < 3; i++) {
const result = checkRateLimit(key, { maxAttempts: 3, windowMs: 60_000 });
expect(result.allowed).toBe(true);
}
});
it("blocks the request once the limit is exceeded within the window", () => {
const key = `test:${crypto.randomUUID()}`;
for (let i = 0; i < 3; i++) {
checkRateLimit(key, { maxAttempts: 3, windowMs: 60_000 });
}
const blocked = checkRateLimit(key, { maxAttempts: 3, windowMs: 60_000 });
expect(blocked.allowed).toBe(false);
expect(blocked.retryAfterSeconds).toBeGreaterThan(0);
});
it("tracks each key independently", () => {
const keyA = `test:${crypto.randomUUID()}`;
const keyB = `test:${crypto.randomUUID()}`;
for (let i = 0; i < 3; i++) {
checkRateLimit(keyA, { maxAttempts: 3, windowMs: 60_000 });
}
// keyA is now exhausted, but keyB has never been touched.
expect(checkRateLimit(keyA, { maxAttempts: 3, windowMs: 60_000 }).allowed).toBe(false);
expect(checkRateLimit(keyB, { maxAttempts: 3, windowMs: 60_000 }).allowed).toBe(true);
});
it("resets once the window elapses", () => {
vi.useFakeTimers();
const key = `test:${crypto.randomUUID()}`;
for (let i = 0; i < 2; i++) {
checkRateLimit(key, { maxAttempts: 2, windowMs: 1_000 });
}
expect(checkRateLimit(key, { maxAttempts: 2, windowMs: 1_000 }).allowed).toBe(false);
vi.advanceTimersByTime(1_001);
expect(checkRateLimit(key, { maxAttempts: 2, windowMs: 1_000 }).allowed).toBe(true);
});
});
@@ -0,0 +1,34 @@
import { beforeEach, describe, expect, it } from "vitest";
import { decryptToken, encryptToken } from "../token-encryption";
describe("token-encryption", () => {
beforeEach(() => {
process.env.CALENDAR_TOKEN_ENCRYPTION_KEY = "test-key-not-for-production-use";
});
it("round-trips a plaintext value", () => {
const plaintext = "1//0gExampleGoogleRefreshToken";
const encrypted = encryptToken(plaintext);
expect(encrypted).not.toBe(plaintext);
expect(decryptToken(encrypted)).toBe(plaintext);
});
it("produces a different ciphertext for the same plaintext each time (random IV)", () => {
const a = encryptToken("same-value");
const b = encryptToken("same-value");
expect(a).not.toBe(b);
expect(decryptToken(a)).toBe("same-value");
expect(decryptToken(b)).toBe("same-value");
});
it("throws if CALENDAR_TOKEN_ENCRYPTION_KEY is not set", () => {
delete process.env.CALENDAR_TOKEN_ENCRYPTION_KEY;
expect(() => encryptToken("x")).toThrow(/CALENDAR_TOKEN_ENCRYPTION_KEY/);
});
it("fails to decrypt with a tampered ciphertext (authentication tag mismatch)", () => {
const encrypted = encryptToken("secret-value");
const tampered = encrypted.slice(0, -4) + "AAAA";
expect(() => decryptToken(tampered)).toThrow();
});
});
+25
View File
@@ -0,0 +1,25 @@
/**
* Whether this deployment is actually served over HTTPS — the correct
* signal for a cookie's `Secure` attribute, which every browser enforces
* by silently refusing to store if the response wasn't delivered over
* HTTPS (see MDN/RFC 6265bis). `NODE_ENV === "production"` is NOT that
* signal: this app's own Dockerfile hard-sets `NODE_ENV=production`
* regardless of whether the operator has put TLS in front of it, and
* docker-compose.local.yaml ships with no TLS termination at all — a
* self-hosted instance reached at "http://<lan-ip>:3000" (the default,
* documented setup) is exactly `NODE_ENV=production` *and* plain HTTP.
* A cookie marked `Secure` there is silently dropped by the browser on
* write, with no error surfaced anywhere — this was the root cause of a
* real bug where the staff "book for customer" flow's draft cookie
* vanished between steps, bouncing the user back to customer search with
* no explanation. NextAuth's own session cookie doesn't hit this because
* it adapts via `AUTH_TRUST_HOST`/its own request-aware cookie config;
* this app's hand-rolled cookies (booking draft, staff booking draft,
* last-booking reference, Google Calendar OAuth state) need the same
* request-aware treatment, derived here from the deployer-declared
* `NEXT_PUBLIC_APP_URL` (see .env.local.example — defaults to
* "http://localhost:3000") rather than blindly trusting NODE_ENV.
*/
export function isSecureDeployment(): boolean {
return (process.env.NEXT_PUBLIC_APP_URL ?? "").startsWith("https://");
}
+65
View File
@@ -0,0 +1,65 @@
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
/**
* Signed, short-lived OAuth `state` for the Google Calendar connect flow
* (see src/app/api/staff/calendar/google/{authorize,callback}/route.ts).
* Same HMAC-over-JSON approach as src/lib/booking-draft/sign.ts, with its
* own context string — kept as a separate module rather than reusing that
* one directly, so the two signing keys stay cryptographically
* independent (a leaked booking-draft signature should tell an attacker
* nothing about this one, and vice versa).
*/
const CONTEXT = "nextchair-calendar-oauth-state-v1";
const MAX_AGE_MS = 10 * 60 * 1000;
interface StatePayload {
nonce: string;
staffId: string;
issuedAt: number;
}
function getSigningKey(): Buffer {
const secret = process.env.AUTH_SECRET;
if (!secret) throw new Error("AUTH_SECRET must be set to sign the calendar OAuth state.");
return createHmac("sha256", secret).update(CONTEXT).digest();
}
export function createOAuthState(staffId: string): string {
const payload: StatePayload = { nonce: randomBytes(16).toString("hex"), staffId, issuedAt: Date.now() };
const json = JSON.stringify(payload);
const mac = createHmac("sha256", getSigningKey()).update(json).digest("base64url");
return `${Buffer.from(json, "utf8").toString("base64url")}.${mac}`;
}
/** Returns the staffId if `token` is a validly-signed, non-expired state for this exact staff member — null otherwise. Never throws. */
export function verifyOAuthState(token: string, expectedStaffId: string): boolean {
const separatorIndex = token.indexOf(".");
if (separatorIndex === -1) return false;
const encodedPayload = token.slice(0, separatorIndex);
const mac = token.slice(separatorIndex + 1);
if (!encodedPayload || !mac) return false;
let json: string;
try {
json = Buffer.from(encodedPayload, "base64url").toString("utf8");
} catch {
return false;
}
const expectedMac = createHmac("sha256", getSigningKey()).update(json).digest("base64url");
const macBuffer = Buffer.from(mac);
const expectedBuffer = Buffer.from(expectedMac);
if (macBuffer.length !== expectedBuffer.length || !timingSafeEqual(macBuffer, expectedBuffer)) return false;
let payload: StatePayload;
try {
payload = JSON.parse(json);
} catch {
return false;
}
if (payload.staffId !== expectedStaffId) return false;
if (Date.now() - payload.issuedAt > MAX_AGE_MS) return false;
return true;
}
+60
View File
@@ -0,0 +1,60 @@
/**
* In-memory, single-process rate limiting — appropriate for this app's
* deployment shape (one Node.js server, no Redis/shared cache in the
* stack; see docs/implementation-plan.md). Explicit trade-off: a
* multi-instance deployment would need a shared store instead, since each
* instance would otherwise track its own counters. Fixed-window counter
* per key, not sliding-window — simpler, and the imprecision at window
* boundaries doesn't matter for the abuse patterns this defends against
* (credential stuffing, registration/booking spam).
*/
interface WindowState {
count: number;
resetAt: number;
}
const buckets = new Map<string, WindowState>();
// Bounds memory growth from an unbounded set of keys (e.g. one per
// attacker-controlled IP/email) — a lazy sweep on every call is cheap
// relative to the rest of the request and needs no background timer.
const MAX_TRACKED_KEYS = 50_000;
function sweepExpired(now: number): void {
if (buckets.size < MAX_TRACKED_KEYS) return;
for (const [key, state] of buckets) {
if (state.resetAt <= now) buckets.delete(key);
}
}
export interface RateLimitResult {
allowed: boolean;
retryAfterSeconds: number;
}
/**
* `key` should already encode the thing being limited (e.g.
* `"signin:alex@example.com"`, `"register:203.0.113.4"`) — this module
* has no concept of the caller's domain, just fixed-window counters.
*/
export function checkRateLimit(
key: string,
params: { maxAttempts: number; windowMs: number },
): RateLimitResult {
const now = Date.now();
sweepExpired(now);
const existing = buckets.get(key);
if (!existing || existing.resetAt <= now) {
buckets.set(key, { count: 1, resetAt: now + params.windowMs });
return { allowed: true, retryAfterSeconds: 0 };
}
if (existing.count >= params.maxAttempts) {
return { allowed: false, retryAfterSeconds: Math.ceil((existing.resetAt - now) / 1000) };
}
existing.count += 1;
return { allowed: true, retryAfterSeconds: 0 };
}
+22
View File
@@ -0,0 +1,22 @@
import { headers } from "next/headers";
/**
* Best-effort caller IP for rate-limiting Server Actions, which (unlike
* Route Handlers) never receive a `Request` object directly. Reads the
* headers a reverse proxy/hosting platform sets — `x-forwarded-for` first
* (its first entry is the original client, per the standard convention),
* falling back to `x-real-ip`. Returns a constant placeholder if neither
* is present (e.g. a direct connection with no proxy in front, or a test
* environment) — that collapses everyone onto one shared bucket rather
* than silently disabling the limit, which is the safer failure mode.
*/
export async function getRequestIp(): Promise<string> {
const headerList = await headers();
const forwardedFor = headerList.get("x-forwarded-for");
if (forwardedFor) return forwardedFor.split(",")[0]!.trim();
const realIp = headerList.get("x-real-ip");
if (realIp) return realIp.trim();
return "unknown";
}
+17
View File
@@ -0,0 +1,17 @@
import { timingSafeEqual } from "node:crypto";
/**
* Constant-time string comparison for secrets (cron bearer tokens, the
* installation token) — a plain `!==` leaks a secret's length and correct
* prefix via response timing. Shared by
* src/app/api/cron/process-notifications/route.ts and
* src/features/installation/actions/install.ts; see also
* src/lib/booking-draft/sign.ts, which uses the same primitive directly
* for HMAC comparison rather than through this helper.
*/
export function secretsMatch(provided: string, expected: string): boolean {
const providedBuffer = Buffer.from(provided);
const expectedBuffer = Buffer.from(expected);
if (providedBuffer.length !== expectedBuffer.length) return false;
return timingSafeEqual(providedBuffer, expectedBuffer);
}
+47
View File
@@ -0,0 +1,47 @@
import { createCipheriv, createDecipheriv, randomBytes, createHash } from "node:crypto";
/**
* AES-256-GCM at-rest encryption for Google Calendar OAuth tokens (see
* StaffCalendarConnection in prisma/schema.prisma) — refresh tokens are
* long-lived credentials for a third-party account and must never be
* stored in plain text. Output format: base64(iv[12] + authTag[16] +
* ciphertext) — everything needed to decrypt in one column, no separate
* iv/tag columns to keep in sync.
*
* CALENDAR_TOKEN_ENCRYPTION_KEY is expected to be a 32-byte value,
* hex-encoded (64 hex chars) — generate with `openssl rand -hex 32`. A
* SHA-256 of the raw env value is used as the actual AES key (not the
* raw bytes directly) so a key of any length/format still yields a
* valid 32-byte AES-256 key, rather than throwing at startup for a
* slightly-wrong-length env var.
*/
const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
function getKey(): Buffer {
const secret = process.env.CALENDAR_TOKEN_ENCRYPTION_KEY;
if (!secret) {
throw new Error("CALENDAR_TOKEN_ENCRYPTION_KEY must be set to store Google Calendar tokens.");
}
return createHash("sha256").update(secret).digest();
}
export function encryptToken(plaintext: string): string {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, getKey(), iv);
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const authTag = cipher.getAuthTag();
return Buffer.concat([iv, authTag, ciphertext]).toString("base64");
}
export function decryptToken(encoded: string): string {
const raw = Buffer.from(encoded, "base64");
const iv = raw.subarray(0, IV_LENGTH);
const authTag = raw.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
const ciphertext = raw.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
const decipher = createDecipheriv(ALGORITHM, getKey(), iv);
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
}
+29
View File
@@ -0,0 +1,29 @@
import { cache } from "react";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
/**
* The signed-in staff member's own profile, request-deduped via React's
* `cache()` the same way getPrimaryBusiness() is — every staff page needs
* this, and several call it more than once per render (page + nested
* layout-level checks), so this avoids repeating the query.
*
* Returns null for a guest or a customer-only account; (staff)/layout.tsx
* already redirects those away, but every page re-checks anyway per the
* "permission enforcement happens again at each layer" rule established in
* Phase 2 (see src/lib/auth/permissions.ts).
*/
export const getActingStaff = cache(async () => {
const session = await auth();
if (!session?.user?.id) return null;
return prisma.staffProfile.findUnique({
where: { userId: session.user.id },
include: {
user: { select: { id: true, name: true, email: true, phone: true, image: true } },
business: { select: { id: true, name: true, timezone: true } },
},
});
});
export type ActingStaffProfile = NonNullable<Awaited<ReturnType<typeof getActingStaff>>>;
@@ -0,0 +1,149 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { wallClockToInstant } from "../business-time";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
/**
* Phase 7's MySQL migration made the connection/session timezone handling
* load-bearing (see src/lib/db/mysql-pool-config.ts) — a driver defaulting
* to the Node process's local timezone, or a MySQL session not forced to
* UTC, would silently corrupt `Appointment.startAt`/`endAt` around a DST
* boundary specifically (a fixed-offset zone would just be wrong by a
* constant, easy to miss in a spot check; DST exposes it because the
* *same* wall-clock local time produces two *different* UTC instants a
* few days apart). These tests write real appointments spanning the UK's
* 2026 DST transitions through Prisma and read them back, proving the
* round-trip survives the real database, not just the pure `business-time`
* helpers already covered in business-time.test.ts (business-time.test.ts
* has no database in the loop at all).
*/
describe("DST boundary round-trip through MySQL (integration)", () => {
let business: TestBusiness;
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
it("preserves the exact UTC instant across the UK spring-forward transition (2026-03-29)", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
// Same 10:00 local wall-clock time either side of the transition —
// GMT (UTC+0) before, BST (UTC+1) after — see business-time.test.ts
// for why these specific instants are correct.
const beforeInstant = wallClockToInstant("2026-03-28", 10 * 60, business.timezone);
const afterInstant = wallClockToInstant("2026-03-30", 10 * 60, business.timezone);
expect(beforeInstant.toISOString()).toBe("2026-03-28T10:00:00.000Z");
expect(afterInstant.toISOString()).toBe("2026-03-30T09:00:00.000Z");
const before = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: beforeInstant,
endAt: new Date(beforeInstant.getTime() + 30 * 60_000),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const after = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: afterInstant,
endAt: new Date(afterInstant.getTime() + 30 * 60_000),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const [readBefore, readAfter] = await Promise.all([
prisma.appointment.findUniqueOrThrow({ where: { id: before.id } }),
prisma.appointment.findUniqueOrThrow({ where: { id: after.id } }),
]);
expect(readBefore.startAt.getTime()).toBe(beforeInstant.getTime());
expect(readAfter.startAt.getTime()).toBe(afterInstant.getTime());
// The two rows are 47 real-world hours apart (a wall-clock hour was
// skipped), not the naive 48 — this only holds if MySQL round-tripped
// the actual UTC instants rather than silently applying a host-local
// timezone conversion on the way in or out.
expect((readAfter.startAt.getTime() - readBefore.startAt.getTime()) / 3_600_000).toBe(47);
});
it("preserves the exact UTC instant across the UK autumn-back transition (2026-10-25)", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
// 10:00 local either side of the autumn transition — BST (UTC+1) before, GMT (UTC+0) after.
const beforeInstant = wallClockToInstant("2026-10-24", 10 * 60, business.timezone);
const afterInstant = wallClockToInstant("2026-10-26", 10 * 60, business.timezone);
expect(beforeInstant.toISOString()).toBe("2026-10-24T09:00:00.000Z");
expect(afterInstant.toISOString()).toBe("2026-10-26T10:00:00.000Z");
const before = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: beforeInstant,
endAt: new Date(beforeInstant.getTime() + 30 * 60_000),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const after = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: afterInstant,
endAt: new Date(afterInstant.getTime() + 30 * 60_000),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const [readBefore, readAfter] = await Promise.all([
prisma.appointment.findUniqueOrThrow({ where: { id: before.id } }),
prisma.appointment.findUniqueOrThrow({ where: { id: after.id } }),
]);
expect(readBefore.startAt.getTime()).toBe(beforeInstant.getTime());
expect(readAfter.startAt.getTime()).toBe(afterInstant.getTime());
// An extra wall-clock hour was repeated this time — 49 real-world hours apart, not 48.
expect((readAfter.startAt.getTime() - readBefore.startAt.getTime()) / 3_600_000).toBe(49);
});
it("createdAt/updatedAt (SQL CURRENT_TIMESTAMP) land within seconds of the real time, not skewed by a server-side timezone", async () => {
const beforeWrite = Date.now();
const { staffId } = await createTestStaff(business);
const staff = await prisma.staffProfile.findUniqueOrThrow({ where: { id: staffId } });
const afterWrite = Date.now();
// A server session not forced to UTC (see mysql-pool-config.ts) would skew
// this by whole hours (the local UTC offset), not by network/test latency.
expect(staff.createdAt.getTime()).toBeGreaterThanOrEqual(beforeWrite - 5_000);
expect(staff.createdAt.getTime()).toBeLessThanOrEqual(afterWrite + 5_000);
});
});
@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import { Weekday } from "@/generated/prisma/enums";
import {
addDays,
calendarDateOf,
compareCalendarDates,
eachCalendarDate,
isAfterCalendarDate,
isBeforeCalendarDate,
minutesFromMidnight,
todayIn,
wallClockToInstant,
weekdayOf,
} from "../business-time";
describe("weekdayOf", () => {
it("maps calendar dates to the correct Weekday", () => {
// 2026-08-03 is a Monday.
expect(weekdayOf("2026-08-03")).toBe(Weekday.MONDAY);
expect(weekdayOf("2026-08-04")).toBe(Weekday.TUESDAY);
expect(weekdayOf("2026-08-08")).toBe(Weekday.SATURDAY);
expect(weekdayOf("2026-08-09")).toBe(Weekday.SUNDAY);
});
});
describe("wallClockToInstant / minutesFromMidnight round-trip", () => {
it("round-trips a plain wall-clock time in a fixed-offset zone", () => {
const instant = wallClockToInstant("2026-01-15", 9 * 60 + 30, "Europe/London");
expect(minutesFromMidnight(instant, "Europe/London")).toBe(9 * 60 + 30);
expect(calendarDateOf(instant, "Europe/London")).toBe("2026-01-15");
});
it("produces the documented UTC instant for a known GMT (winter) date", () => {
// 2026-01-15 is outside BST — Europe/London is UTC+0.
const instant = wallClockToInstant("2026-01-15", 10 * 60, "Europe/London");
expect(instant.toISOString()).toBe("2026-01-15T10:00:00.000Z");
});
});
describe("DST correctness (Europe/London BST transition, last Sunday of March 2026)", () => {
it("the same wall-clock time yields UTC instants shifted by the DST offset change", () => {
// Before the 2026-03-29 transition: Europe/London is GMT (UTC+0).
const beforeTransition = wallClockToInstant("2026-03-28", 10 * 60, "Europe/London");
expect(beforeTransition.toISOString()).toBe("2026-03-28T10:00:00.000Z");
// After the transition: Europe/London is BST (UTC+1).
const afterTransition = wallClockToInstant("2026-03-30", 10 * 60, "Europe/London");
expect(afterTransition.toISOString()).toBe("2026-03-30T09:00:00.000Z");
// A naive "always UTC+0" implementation would put these exactly 48h
// apart; DST-correct math puts them 47h apart because a wall-clock
// hour was skipped.
const diffHours = (afterTransition.getTime() - beforeTransition.getTime()) / 3_600_000;
expect(diffHours).toBe(47);
});
it("minutesFromMidnight recovers the same local wall-clock time on both sides of the transition", () => {
const before = wallClockToInstant("2026-03-28", 10 * 60, "Europe/London");
const after = wallClockToInstant("2026-03-30", 10 * 60, "Europe/London");
expect(minutesFromMidnight(before, "Europe/London")).toBe(10 * 60);
expect(minutesFromMidnight(after, "Europe/London")).toBe(10 * 60);
});
});
describe("calendar date arithmetic", () => {
it("addDays is pure calendar-date math, independent of any zone", () => {
expect(addDays("2026-02-27", 1)).toBe("2026-02-28");
expect(addDays("2026-02-28", 1)).toBe("2026-03-01"); // 2026 is not a leap year
expect(addDays("2026-12-31", 1)).toBe("2027-01-01");
});
it("compares calendar dates correctly", () => {
expect(compareCalendarDates("2026-01-01", "2026-01-02")).toBeLessThan(0);
expect(compareCalendarDates("2026-01-02", "2026-01-01")).toBeGreaterThan(0);
expect(compareCalendarDates("2026-01-01", "2026-01-01")).toBe(0);
expect(isBeforeCalendarDate("2026-01-01", "2026-01-02")).toBe(true);
expect(isAfterCalendarDate("2026-01-02", "2026-01-01")).toBe(true);
});
it("eachCalendarDate yields an inclusive range and nothing when from > to", () => {
expect([...eachCalendarDate("2026-01-01", "2026-01-03")]).toEqual([
"2026-01-01",
"2026-01-02",
"2026-01-03",
]);
expect([...eachCalendarDate("2026-01-03", "2026-01-01")]).toEqual([]);
});
it("eachCalendarDate guards against absurdly large ranges", () => {
expect(() => [...eachCalendarDate("2026-01-01", "2030-01-01")]).toThrow();
});
});
describe("todayIn", () => {
it("returns a well-formed calendar date", () => {
expect(todayIn("Europe/London")).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
});
+120
View File
@@ -0,0 +1,120 @@
import { DateTime } from "luxon";
import { Weekday } from "@/generated/prisma/enums";
/**
* All scheduling math (working hours, breaks, booking rules) happens in
* two currencies: a `CalendarDate` ("2026-08-12", no time zone attached —
* a calendar date is a calendar date regardless of zone) and
* minutes-since-local-midnight (0–1439) for wall-clock times within a
* day. Appointments themselves are stored as UTC `Date` instants (see
* prisma/schema.prisma, Appointment.startAt/endAt). This module is the
* ONLY place that converts between the two, via Luxon, so DST transitions
* are handled correctly in exactly one place instead of scattered
* `getHours()`/`setHours()` calls that silently assume the server's own
* time zone. See docs/implementation-plan.md §14.
*/
export type CalendarDate = string; // "YYYY-MM-DD"
const ISO_WEEKDAY_ORDER: Weekday[] = [
Weekday.MONDAY,
Weekday.TUESDAY,
Weekday.WEDNESDAY,
Weekday.THURSDAY,
Weekday.FRIDAY,
Weekday.SATURDAY,
Weekday.SUNDAY,
];
function requireISODate(dt: DateTime, context: string): string {
const iso = dt.toISODate();
if (!iso) throw new Error(`business-time: invalid date in ${context}`);
return iso;
}
/** Which Weekday a calendar date falls on, independent of time zone (a calendar date has no time-of-day ambiguity). */
export function weekdayOf(date: CalendarDate): Weekday {
const dt = DateTime.fromISO(date, { zone: "utc" });
return ISO_WEEKDAY_ORDER[dt.weekday - 1]!;
}
/** Minutes since local midnight (0–1439) for a UTC instant, as observed in `zone`. */
export function minutesFromMidnight(instant: Date, zone: string): number {
const dt = DateTime.fromJSDate(instant, { zone });
return dt.hour * 60 + dt.minute;
}
/** The business-local calendar date a UTC instant falls on. */
export function calendarDateOf(instant: Date, zone: string): CalendarDate {
return requireISODate(DateTime.fromJSDate(instant, { zone }), "calendarDateOf");
}
/**
* Combine a calendar date + minutes-from-midnight (a business-local wall
* clock time) into the correct UTC instant. DST-safe: Luxon resolves the
* wall clock against the zone's actual offset for that specific date, not
* a fixed offset — the same "10:00" on either side of a DST transition
* produces different UTC instants, correctly.
*/
export function wallClockToInstant(date: CalendarDate, minutesFromMidnightValue: number, zone: string): Date {
const dt = DateTime.fromISO(date, { zone }).startOf("day").plus({ minutes: minutesFromMidnightValue });
return dt.toJSDate();
}
export function todayIn(zone: string): CalendarDate {
return requireISODate(DateTime.now().setZone(zone), "todayIn");
}
/** The UTC instant at which `date` begins in `zone` — the inclusive lower bound for "appointments that fall on this business day". */
export function startOfBusinessDay(date: CalendarDate, zone: string): Date {
return wallClockToInstant(date, 0, zone);
}
/** The UTC instant at which `date` ends in `zone` (= the start of the next day) — the exclusive upper bound for "appointments that fall on this business day". */
export function endOfBusinessDay(date: CalendarDate, zone: string): Date {
return wallClockToInstant(date, 24 * 60, zone);
}
/**
* `TimeOff`/date-only columns (`@db.Date`) are compared as UTC midnight
* regardless of business time zone — a day off is the same calendar day
* everywhere, it isn't a wall-clock-in-a-zone concept the way appointment
* times are. Always go through this helper when reading or writing a
* `@db.Date` column so the convention stays consistent both directions.
*/
export function calendarDateToUtcMidnight(date: CalendarDate): Date {
return new Date(`${date}T00:00:00.000Z`);
}
/** Pure calendar-date arithmetic — deliberately zone-independent (adding whole days never crosses a DST boundary ambiguity the way wall-clock-to-instant conversion can). */
export function addDays(date: CalendarDate, days: number): CalendarDate {
return requireISODate(DateTime.fromISO(date, { zone: "utc" }).plus({ days }), "addDays");
}
export function compareCalendarDates(a: CalendarDate, b: CalendarDate): number {
return a < b ? -1 : a > b ? 1 : 0;
}
export function isBeforeCalendarDate(a: CalendarDate, b: CalendarDate): boolean {
return compareCalendarDates(a, b) < 0;
}
export function isAfterCalendarDate(a: CalendarDate, b: CalendarDate): boolean {
return compareCalendarDates(a, b) > 0;
}
export function diffMinutes(a: Date, b: Date): number {
return (a.getTime() - b.getTime()) / 60_000;
}
/** Every CalendarDate from `from` to `to` inclusive. Throws if the range is absurdly large, as a guard against a caller accidentally passing unbounded input. */
export function* eachCalendarDate(from: CalendarDate, to: CalendarDate): Generator<CalendarDate> {
if (isAfterCalendarDate(from, to)) return;
let cursor = from;
let guard = 0;
while (!isAfterCalendarDate(cursor, to)) {
if (++guard > 366) throw new Error("eachCalendarDate: range exceeds 366 days");
yield cursor;
cursor = addDays(cursor, 1);
}
}
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { safeRedirectPath } from "../safe-redirect";
describe("safeRedirectPath", () => {
it("accepts a plain internal path", () => {
expect(safeRedirectPath("/book/details", "/home")).toBe("/book/details");
});
it("falls back for null/undefined/empty input", () => {
expect(safeRedirectPath(null, "/home")).toBe("/home");
expect(safeRedirectPath(undefined, "/home")).toBe("/home");
expect(safeRedirectPath("", "/home")).toBe("/home");
});
it("rejects protocol-relative URLs", () => {
expect(safeRedirectPath("//evil.com", "/home")).toBe("/home");
});
it("rejects absolute URLs to another host", () => {
expect(safeRedirectPath("https://evil.com/phish", "/home")).toBe("/home");
expect(safeRedirectPath("http://evil.com", "/home")).toBe("/home");
});
it("rejects a path not starting with a single slash", () => {
expect(safeRedirectPath("book/details", "/home")).toBe("/home");
expect(safeRedirectPath("javascript:alert(1)", "/home")).toBe("/home");
});
});
+10
View File
@@ -0,0 +1,10 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* Merge Tailwind class lists safely — later classes win over earlier ones
* for the same utility group (e.g. `cn("px-4", condition && "px-6")`).
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+18
View File
@@ -0,0 +1,18 @@
/**
* Validates a caller-supplied return path (a `callbackUrl` query param,
* a booking-draft continuation target) is a safe, internal, relative
* path before it's ever used in a redirect — see the brief's "Accept
* only safe internal return paths. Prevent open redirects." Auth.js's
* own `signIn()`/`redirectTo` already restricts to same-origin
* internally, but this is checked explicitly rather than relied on
* implicitly, since it guards a value that reached this code from a URL
* query string, not from a trusted internal source.
*/
export function safeRedirectPath(path: string | null | undefined, fallback: string): string {
if (!path) return fallback;
// Must start with exactly one "/" — rejects "//evil.com" (protocol-relative),
// "https://evil.com", and "javascript:..." in one check.
if (!path.startsWith("/") || path.startsWith("//")) return fallback;
if (path.includes("://")) return fallback;
return path;
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Generic version of the {valid, issues} shape scheduling/types.ts
* pioneered in Phase 3 — new Phase 5 services (Staff, Loyalty) want the
* same "typed error code + human message, never throw for an expected
* business-rule failure" pattern but with their own closed code unions.
* scheduling/types.ts itself is left untouched (its BookingValidationErrorCode
* union is already relied on across Phases 3-4) rather than widened to
* cover unrelated domains.
*/
export interface ValidationIssue<Code extends string> {
code: Code;
message: string;
}
export interface ValidationResult<Code extends string> {
valid: boolean;
issues: ValidationIssue<Code>[];
}
export function ok<Code extends string>(): ValidationResult<Code> {
return { valid: true, issues: [] };
}
export function fail<Code extends string>(
...issues: ValidationIssue<Code>[]
): ValidationResult<Code> {
return { valid: false, issues };
}
export function mergeValidation<Code extends string>(
...results: ValidationResult<Code>[]
): ValidationResult<Code> {
const issues = results.flatMap((r) => r.issues);
return { valid: issues.length === 0, issues };
}