This commit is contained in:
Stefan Lazic committed 2026-08-04 09:57:58 +01:00
1 parent 546613e677
commit 4785510144
82 files changed
+4903 -287

No files matched your search

+111
View File
@@ -0,0 +1,111 @@
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.
*/
export interface BrandingInfo {
configured: boolean;
name: string;
shortName: string;
description: string | null;
logoUrl: string | null;
coverImageUrl: string | null;
faviconUrl: string | null;
appIconUrl: string | null;
themeColor: string;
backgroundColor: string;
email: string | null;
phone: string | null;
}
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,
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_URL). 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,
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,
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}`;
}
+1 -1
View File
@@ -70,7 +70,7 @@ export function generateAppointmentIcs(input: AppointmentIcsInput): string {
const lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//NextChair//Booking//EN",
"PRODID:-//Booking Calendar//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"BEGIN:VEVENT",
@@ -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;
}
+56
View File
@@ -0,0 +1,56 @@
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,
];
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,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,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();
});
});
+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;
}
+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");
}
@@ -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");
});
});
+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;
}