This commit is contained in:
Stefan Lazic committed 2026-08-03 21:15:24 +01:00
1 parent ec5583f972
commit 546613e677
189 files changed
+9873 -655

No files matched your search

+9 -2
View File
@@ -1,3 +1,4 @@
import { cache } from "react";
import { prisma } from "@/lib/db/prisma";
import type { Permission } from "@/generated/prisma/enums";
@@ -8,8 +9,14 @@ import type { Permission } from "@/generated/prisma/enums";
* 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 async function getStaffPermissions(userId: string): Promise<Set<Permission>> {
export const getStaffPermissions = cache(async (userId: string): Promise<Set<Permission>> => {
const staffProfile = await prisma.staffProfile.findUnique({
where: { userId },
include: { role: { include: { permissions: true } } },
@@ -17,7 +24,7 @@ export async function getStaffPermissions(userId: string): Promise<Set<Permissio
if (!staffProfile) return new Set();
return new Set(staffProfile.role.permissions.map((rolePermission) => rolePermission.permission));
}
});
export async function hasPermission(userId: string, permission: Permission): Promise<boolean> {
const permissions = await getStaffPermissions(userId);
+15 -5
View File
@@ -55,9 +55,15 @@ export async function clearDraft(): Promise<void> {
const CONFIRMATION_COOKIE_NAME = "nc_last_booking";
const CONFIRMATION_MAX_AGE_SECONDS = 60 * 30;
export async function writeLastBookingReference(appointmentId: string): Promise<void> {
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({ appointmentId }));
const token = sign(JSON.stringify(reference));
store.set(CONFIRMATION_COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
@@ -67,15 +73,19 @@ export async function writeLastBookingReference(appointmentId: string): Promise<
});
}
export async function readLastBookingReference(): Promise<string | null> {
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 };
return typeof parsed.appointmentId === "string" ? parsed.appointmentId : null;
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;
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Builds the `mariadb` pool config from `DATABASE_URL`, 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(databaseUrl: string | undefined) {
if (!databaseUrl) throw new Error("DATABASE_URL is not set.");
// Accept both `mysql://` and `mariadb://` — the `mariadb` driver's own
// string parser only recognizes the latter, but the `URL` global parses
// either scheme fine since we build the pool config object ourselves.
const url = new URL(databaseUrl);
return {
host: url.hostname,
port: url.port ? Number(url.port) : 3306,
user: decodeURIComponent(url.username),
password: decodeURIComponent(url.password),
database: url.pathname.replace(/^\//, ""),
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,
};
}
+19 -9
View File
@@ -1,24 +1,34 @@
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
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
* `@prisma/adapter-pg` — switching to MySQL later means swapping this one
* adapter (`@prisma/adapter-mariadb`), updating `datasource.provider` in
* the schema, and re-running `prisma migrate dev`. No other file in the
* app touches the database driver directly.
* 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 PrismaPg({
connectionString: process.env.DATABASE_URL,
});
const adapter = new PrismaMariaDb(buildMysqlPoolConfig(process.env.DATABASE_URL));
export const prisma =
globalForPrisma.prisma ??
@@ -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);
});
});
+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";
}
@@ -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);
});
});