Phase 8
This commit is contained in:
82 files changed
+4903
-287
No files matched your search
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user