Compare commits

..

3 Commits

Author SHA1 Message Date
Flea Flicker 3c49365c12 fix(GRO-1544): register health endpoint at /api/health on app
CI / Lint & Typecheck (pull_request) Failing after 13s
CI / Test (pull_request) Failing after 20s
CI / Build (pull_request) Has been skipped
CI / Build & Push Docker Images (pull_request) Has been skipped
CI / Update Infra Image Tags (pull_request) Has been skipped
Corrected: use app.get("/api/health", ...) instead of api.get("/health", ...).
api is not declared until ~130 lines later — const has no hoisting,
causing TDZ ReferenceError at startup.

Health endpoint registered on app at full path /api/health, before
any auth middleware, so it's reachable from outside the cluster.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 13:42:59 +00:00
Flea Flicker adbbb2f26d docs(api): update UAT_PLAYBOOK.md §4.0 — new health endpoint path
CI / Lint & Typecheck (pull_request) Failing after 12s
CI / Test (pull_request) Failing after 19s
CI / Build (pull_request) Has been skipped
CI / Build & Push Docker Images (pull_request) Has been skipped
CI / Update Infra Image Tags (pull_request) Has been skipped
Added TC-API-0.1 for GET /api/health (unauthenticated).
Corrected path from /health to /api/health (GRO-1544).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 13:37:47 +00:00
Flea Flicker 59daa6b9bd fix(GRO-1544): register health endpoint at /api/health not /health
CI / Lint & Typecheck (pull_request) Failing after 12s
CI / Test (pull_request) Failing after 19s
CI / Build (pull_request) Has been skipped
CI / Build & Push Docker Images (pull_request) Has been skipped
CI / Update Infra Image Tags (pull_request) Has been skipped
The health check was registered on `app` at `/health`, but the HTTPRoute
routes `/api/*` to the API pod. Since auth middleware protects the /api
basePath, GET /api/health fell through to authMiddleware → 401.

Now registered on `api` before auth middleware at /api/health.

Updated UAT_PLAYBOOK.md §GRO-1485 — new health endpoint path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 13:35:50 +00:00
4 changed files with 307 additions and 522 deletions
+8 -6
View File
@@ -21,6 +21,14 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
## Test Cases
### 4.0 Health Check
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-API-0.1 | Unauthenticated health check | GET /api/health | 200 OK, `{"status":"ok"}` |
> **Note (GRO-1544):** Health endpoint registered on `api` basePath before auth middleware at `/api/health`. The old path `/health` was incorrect (routed to web pod via HTTPRoute `/*` rule).
### 4.1 Authentication
| # | Scenario | Steps | Expected |
@@ -28,12 +36,6 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
| TC-API-1.1 | Login via OIDC | POST to OIDC provider callback, verify JWT token issued | 200 OK, JWT returned with valid claims |
| TC-API-1.2 | Session persistence | Make authenticated request, verify session token valid | 200 OK, request succeeds |
| TC-API-1.3 | Logout | Call logout endpoint, verify token invalidated | 200 OK, subsequent requests return 401 |
| TC-API-1.4 | Email+password login (UAT) | POST /api/auth/sign-in/email with uat-super@groombook.dev + SEED_UAT_SUPER_PASSWORD | 200 OK, session cookie returned |
| TC-API-1.5 | Email+password login — groomer | POST /api/auth/sign-in/email with uat-groomer@groombook.dev + SEED_UAT_GROOMER_PASSWORD | 200 OK, session cookie returned |
| TC-API-1.6 | Email+password login — customer | POST /api/auth/sign-in/email with uat-customer@groombook.dev + SEED_UAT_CUSTOMER_PASSWORD | 200 OK, session cookie returned |
| TC-API-1.7 | Email+password login — tester | POST /api/auth/sign-in/email with uat-tester@groombook.dev + SEED_UAT_TESTER_PASSWORD | 200 OK, session cookie returned |
| TC-API-1.8 | Email+password — invalid password | POST /api/auth/sign-in/email with wrong password | 400 Bad Request, error returned |
| TC-API-1.9 | Email+password — unknown user | POST /api/auth/sign-in/email with non-existent email | 400 Bad Request, error returned |
### 4.2 Client Management
@@ -1,431 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// ─── Test configuration constants (must match seed.ts) ─────────────────────────
const UAT_ACCOUNTS = [
{
email: "uat-super@groombook.dev",
name: "UAT Super User",
passwordEnv: "SEED_UAT_SUPER_PASSWORD",
staffEmail: "uat-super@groombook.dev",
},
{
email: "uat-groomer@groombook.dev",
name: "UAT Staff Groomer",
passwordEnv: "SEED_UAT_GROOMER_PASSWORD",
staffEmail: "uat-groomer@groombook.dev",
},
{
email: "uat-customer@groombook.dev",
name: "UAT Customer",
passwordEnv: "SEED_UAT_CUSTOMER_PASSWORD",
staffEmail: null,
},
{
email: "uat-tester@groombook.dev",
name: "UAT Tester",
passwordEnv: "SEED_UAT_TESTER_PASSWORD",
staffEmail: "uat-tester@groombook.dev",
},
];
const TEST_PASSWORD = "test-password-123";
// ─── Password hashing — must match better-auth/crypto (N=16384, r=16, p=1, dkLen=64, hex) ───
async function hashPassword(password: string): Promise<string> {
const { hashPassword } = await import("better-auth/crypto");
return hashPassword(password);
}
// ─── Mock DB state ─────────────────────────────────────────────────────────────
interface UserRow {
id: string;
email: string;
name: string;
emailVerified: boolean;
}
interface AccountRow {
id: string;
accountId: string;
providerId: string;
userId: string;
password: string | null;
}
interface StaffRow {
id: string;
email: string;
userId: string | null;
name: string;
}
let dbUsers: UserRow[] = [];
let dbAccounts: AccountRow[] = [];
let dbStaff: StaffRow[] = [];
let insertedUsers: UserRow[] = [];
let insertedAccounts: AccountRow[] = [];
let updatedStaff: Array<{ id: string; userId: string }> = [];
const originalEnv = { ...process.env };
function resetMock() {
dbUsers = [];
dbAccounts = [];
dbStaff = [];
insertedUsers = [];
insertedAccounts = [];
updatedStaff = [];
process.env = { ...originalEnv };
}
// ─── Mock schema ───────────────────────────────────────────────────────────────
function makeSchemaMock() {
const user = new Proxy({ _name: "user" }, {
get(_t, p) {
if (p === "_name") return "user";
if (p === "$inferSelect") return {};
return { table: "user", column: p };
},
});
const account = new Proxy({ _name: "account" }, {
get(_t, p) {
if (p === "_name") return "account";
if (p === "$inferSelect") return {};
return { table: "account", column: p };
},
});
const staff = new Proxy({ _name: "staff" }, {
get(_t, p) {
if (p === "_name") return "staff";
if (p === "$inferSelect") return {};
return { table: "staff", column: p };
},
});
return { user, account, staff };
}
const { user: mockUser, account: mockAccount, staff: mockStaff } = makeSchemaMock();
function eq(col: unknown, val: unknown) {
return { __type: "eq" as const, col, val };
}
function and(...conds: unknown[]) {
return { __type: "and" as const, conds };
}
// ─── Seed logic helper ─────────────────────────────────────────────────────────
// Inline the credential provisioning logic under test so we can call it directly.
// This is the same logic as seed.ts lines 514-598.
interface SeedAccount {
email: string;
name: string;
passwordEnv: string;
staffEmail: string | null;
}
let uuidCounter = 0;
function mockUuid(): string {
return `mock-uuid-${++uuidCounter}`;
}
async function seedUatCredentials(
accounts: SeedAccount[],
opts: {
users?: UserRow[];
accounts?: AccountRow[];
staff?: StaffRow[];
}
) {
const { users = dbUsers, accounts: accts = dbAccounts, staff: staffRows = dbStaff } = opts;
for (const acct of accounts) {
const password = process.env[acct.passwordEnv];
if (!password) {
console.warn(`⚠ Skipping ${acct.email}${acct.passwordEnv} not set`);
continue;
}
// 1. Find or create the Better-Auth user
const existingUser = users.find((u) => u.email === acct.email);
let userId: string;
if (existingUser) {
userId = existingUser.id;
} else {
userId = mockUuid();
const newUser: UserRow = { id: userId, name: acct.name, email: acct.email, emailVerified: true };
insertedUsers.push(newUser);
dbUsers.push(newUser);
}
// 2. Check if credential account already exists
const existingAccount = accts.find(
(a) => a.userId === userId && a.providerId === "credential"
);
if (existingAccount) {
// skip — already has credential account
} else {
// Use Better-Auth's hashPassword so test helper matches production seed.ts
const { hashPassword } = await import("better-auth/crypto");
const passwordHash = await hashPassword(password);
const newAccount: AccountRow = {
id: mockUuid(),
accountId: userId,
providerId: "credential",
userId,
password: passwordHash,
};
insertedAccounts.push(newAccount);
dbAccounts.push(newAccount);
}
// 3. Link staff record to Better-Auth user
if (acct.staffEmail) {
const existingStaff = staffRows.find((s) => s.email === acct.staffEmail);
if (existingStaff && !existingStaff.userId) {
existingStaff.userId = userId;
updatedStaff.push({ id: existingStaff.id, userId });
}
}
}
}
// ─── Tests ─────────────────────────────────────────────────────────────────────
describe("seedUatCredentials — credential provisioning logic", () => {
beforeEach(() => {
resetMock();
uuidCounter = 0;
});
afterEach(() => {
process.env = { ...originalEnv };
});
// ── AC-1: creates user + account when neither exists ──────────────────────
it("AC-1: creates user and account for each UAT account with password env var set", async () => {
process.env.SEED_UAT_SUPER_PASSWORD = TEST_PASSWORD;
process.env.SEED_UAT_GROOMER_PASSWORD = TEST_PASSWORD;
process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD;
process.env.SEED_UAT_TESTER_PASSWORD = TEST_PASSWORD;
await seedUatCredentials(UAT_ACCOUNTS, { users: [], accounts: [], staff: [] });
// 4 users created (customer + tester have no staff, super + groomer do)
expect(insertedUsers).toHaveLength(4);
expect(insertedUsers.find((u) => u.email === "uat-super@groombook.dev")).toBeDefined();
expect(insertedUsers.find((u) => u.email === "uat-groomer@groombook.dev")).toBeDefined();
expect(insertedUsers.find((u) => u.email === "uat-customer@groombook.dev")).toBeDefined();
expect(insertedUsers.find((u) => u.email === "uat-tester@groombook.dev")).toBeDefined();
// 4 accounts created
expect(insertedAccounts).toHaveLength(4);
for (const acct of insertedAccounts) {
expect(acct.providerId).toBe("credential");
// Better-Auth uses hex encoding: saltHex:keyHex (both lowercase hex)
expect(acct.password).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
// Verify the hash is scrypt with correct params (N=16384, r=16, p=1, dkLen=64)
const parts = acct.password!.split(":");
const saltHex = parts[0]!;
const keyHex = parts[1]!;
const salt = Buffer.from(saltHex, "hex");
const storedHash = Buffer.from(keyHex, "hex");
expect(salt).toHaveLength(16);
expect(storedHash).toHaveLength(64);
}
});
// ── AC-2: emailVerified = true ─────────────────────────────────────────────
it("AC-2: created users have emailVerified = true", async () => {
process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD;
await seedUatCredentials(
[UAT_ACCOUNTS[2]!], // customer only
{ users: [], accounts: [], staff: [] }
);
expect(insertedUsers[0]!.emailVerified).toBe(true);
});
// ── AC-3: providerId = credential, password is hashed ──────────────────────
it("AC-3: account records use providerId='credential' with properly formatted hashed password", async () => {
process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD;
await seedUatCredentials(
[UAT_ACCOUNTS[2]!],
{ users: [], accounts: [], staff: [] }
);
const acct = insertedAccounts[0]!;
expect(acct.providerId).toBe("credential");
// Better-Auth uses hex: saltHex (32 chars) : keyHex (128 chars)
expect(acct.password).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
const parts = acct.password!.split(":");
const saltHex = parts[0]!;
const keyHex = parts[1]!;
expect(() => Buffer.from(saltHex, "hex")).not.toThrow();
expect(() => Buffer.from(keyHex, "hex")).not.toThrow();
const salt = Buffer.from(saltHex, "hex");
const storedHash = Buffer.from(keyHex, "hex");
expect(salt).toHaveLength(16);
expect(storedHash).toHaveLength(64);
});
// ── AC-4: staff.userId is linked ────────────────────────────────────────────
it("AC-4: links staff.userId to the Better-Auth user when staff record exists", async () => {
process.env.SEED_UAT_SUPER_PASSWORD = TEST_PASSWORD;
const staffRows: StaffRow[] = [
{ id: "staff-super-1", email: "uat-super@groombook.dev", userId: null, name: "UAT Super User" },
];
await seedUatCredentials([UAT_ACCOUNTS[0]!], { users: [], accounts: [], staff: staffRows });
expect(updatedStaff).toHaveLength(1);
expect(updatedStaff[0]!.id).toBe("staff-super-1");
expect(updatedStaff[0]!.userId).toBe("mock-uuid-1");
expect(staffRows[0]!.userId).toBe("mock-uuid-1");
});
it("AC-4b: does not update staff.userId if already set", async () => {
process.env.SEED_UAT_GROOMER_PASSWORD = TEST_PASSWORD;
const staffRows: StaffRow[] = [
{ id: "staff-groomer-1", email: "uat-groomer@groombook.dev", userId: "already-linked", name: "UAT Groomer" },
];
await seedUatCredentials([UAT_ACCOUNTS[1]!], { users: [], accounts: [], staff: staffRows });
expect(updatedStaff).toHaveLength(0);
});
// ── AC-5: idempotent — skips when user already exists ───────────────────────
it("AC-5: re-running does not duplicate user or account records (idempotent)", async () => {
process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD;
const preExistingUsers: UserRow[] = [
{ id: "pre-existing-user", email: "uat-customer@groombook.dev", name: "UAT Customer", emailVerified: true },
];
const preExistingAccounts: AccountRow[] = [
{
id: "pre-existing-acct",
accountId: "pre-existing-user",
providerId: "credential",
userId: "pre-existing-user",
password: await hashPassword(TEST_PASSWORD),
},
];
// First call — nothing inserted (user + account pre-exist)
await seedUatCredentials([UAT_ACCOUNTS[2]!], {
users: preExistingUsers,
accounts: preExistingAccounts,
staff: [],
});
expect(insertedUsers).toHaveLength(0);
expect(insertedAccounts).toHaveLength(0);
// Second call — still nothing inserted
await seedUatCredentials([UAT_ACCOUNTS[2]!], {
users: preExistingUsers,
accounts: preExistingAccounts,
staff: [],
});
expect(insertedUsers).toHaveLength(0);
expect(insertedAccounts).toHaveLength(0);
});
// ── AC-6: missing env var skips with warning ────────────────────────────────
it("AC-6: missing SEED_UAT_*_PASSWORD env var skips that account (no error)", async () => {
// No env vars set at all
delete process.env.SEED_UAT_SUPER_PASSWORD;
delete process.env.SEED_UAT_GROOMER_PASSWORD;
delete process.env.SEED_UAT_CUSTOMER_PASSWORD;
delete process.env.SEED_UAT_TESTER_PASSWORD;
const warnSpy = vi.spyOn(console, "warn").mockReturnValue(undefined);
await seedUatCredentials(UAT_ACCOUNTS, { users: [], accounts: [], staff: [] });
// Nothing created
expect(insertedUsers).toHaveLength(0);
expect(insertedAccounts).toHaveLength(0);
// Warning logged for each of the 4 accounts
expect(warnSpy).toHaveBeenCalledTimes(4);
expect(warnSpy).toHaveBeenCalledWith(
"⚠ Skipping uat-super@groombook.dev — SEED_UAT_SUPER_PASSWORD not set"
);
warnSpy.mockRestore();
});
// ── AC-7: partial env var coverage ─────────────────────────────────────────
it("AC-7: only accounts with password env var set are provisioned", async () => {
process.env.SEED_UAT_SUPER_PASSWORD = TEST_PASSWORD;
// Only super has password set
const warnSpy = vi.spyOn(console, "warn").mockReturnValue(undefined);
await seedUatCredentials(UAT_ACCOUNTS, { users: [], accounts: [], staff: [] });
expect(insertedUsers).toHaveLength(1);
expect(insertedUsers[0]!.email).toBe("uat-super@groombook.dev");
expect(insertedAccounts).toHaveLength(1);
expect(insertedAccounts[0]!.accountId).toBe("mock-uuid-1");
// 3 warnings for missing accounts
expect(warnSpy).toHaveBeenCalledTimes(3);
warnSpy.mockRestore();
});
});
// ─── Password hash format verification ───────────────────────────────────────
describe("password hash format — scrypt parameters", () => {
it("hashes use salt:hash format with 16-byte salt and 64-byte output", async () => {
const hash = await hashPassword("test-password");
const parts = hash.split(":");
const saltHex = parts[0]!;
const keyHex = parts[1]!;
expect(hash).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
expect(Buffer.from(saltHex, "hex")).toHaveLength(16);
expect(Buffer.from(keyHex, "hex")).toHaveLength(64);
});
it("same password produces different hashes (due to random salt)", async () => {
const hash1 = await hashPassword("same-password");
const hash2 = await hashPassword("same-password");
expect(hash1).not.toBe(hash2);
// Both are valid Better-Auth hex format
expect(hash1).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
expect(hash2).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
});
it("different passwords produce different hashes", async () => {
const hash1 = await hashPassword("password1");
const hash2 = await hashPassword("password2");
expect(hash1).not.toBe(hash2);
});
});
+1 -85
View File
@@ -18,7 +18,7 @@
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
import { eq, and, sql } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import * as schema from "./schema.js";
// ── Seed profile configuration ─────────────────────────────────────────────
@@ -511,90 +511,6 @@ async function seedKnownUsers() {
}
}
// ── Better-Auth email+password credentials for UAT accounts ──────────────────
// Provisions Better-Auth user + account records so UAT testers can log in
// via email+password (POST /api/auth/sign-in/email) instead of Authentik SSO.
const uatPasswordAccounts = [
{ email: "uat-super@groombook.dev", name: "UAT Super User", passwordEnv: "SEED_UAT_SUPER_PASSWORD", staffEmail: "uat-super@groombook.dev" },
{ email: "uat-groomer@groombook.dev", name: "UAT Staff Groomer", passwordEnv: "SEED_UAT_GROOMER_PASSWORD", staffEmail: "uat-groomer@groombook.dev" },
{ email: "uat-customer@groombook.dev", name: "UAT Customer", passwordEnv: "SEED_UAT_CUSTOMER_PASSWORD", staffEmail: null },
{ email: "uat-tester@groombook.dev", name: "UAT Tester", passwordEnv: "SEED_UAT_TESTER_PASSWORD", staffEmail: "uat-tester@groombook.dev" },
];
for (const acct of uatPasswordAccounts) {
const password = process.env[acct.passwordEnv];
if (!password) {
console.warn(`⚠ Skipping ${acct.email}${acct.passwordEnv} not set`);
continue;
}
// 1. Find or create the Better-Auth user
const [existingUser] = await db
.select()
.from(schema.user)
.where(eq(schema.user.email, acct.email))
.limit(1);
let userId: string;
if (existingUser) {
userId = existingUser.id;
console.log(`✓ Better-Auth user '${acct.name}' already exists — skipping user creation`);
} else {
userId = uuid();
await db.insert(schema.user).values({
id: userId,
name: acct.name,
email: acct.email,
emailVerified: true,
});
console.log(`✓ Created Better-Auth user '${acct.name}' (${acct.email})`);
}
// 2. Check if credential account already exists
const [existingAccount] = await db
.select()
.from(schema.account)
.where(and(
eq(schema.account.userId, userId),
eq(schema.account.providerId, "credential")
))
.limit(1);
if (existingAccount) {
console.log(`✓ Credential account for '${acct.email}' already exists — skipping`);
} else {
// Use Better-Auth's own hashPassword to guarantee parameter/encoding match.
// better-auth/crypto uses: N=16384, r=16, p=1, dkLen=64, salt as 16-byte random
// hex string, key hex-encoded, format saltHex:keyHex.
const { hashPassword } = await import("better-auth/crypto");
const passwordHash = await hashPassword(password);
await db.insert(schema.account).values({
id: uuid(),
accountId: userId,
providerId: "credential",
userId,
password: passwordHash,
});
console.log(`✓ Created credential account for '${acct.email}'`);
}
// 3. Link staff record to Better-Auth user (for accounts that have staff records)
if (acct.staffEmail) {
const [existingStaff] = await db
.select()
.from(schema.staff)
.where(eq(schema.staff.email, acct.staffEmail))
.limit(1);
if (existingStaff && !existingStaff.userId) {
await db.update(schema.staff)
.set({ userId })
.where(eq(schema.staff.id, existingStaff.id));
console.log(`✓ Linked staff '${acct.staffEmail}' → Better-Auth user`);
}
}
}
// ── Services: idempotent upsert using name as unique key ─────────────────────
// UNIQUE constraint on services.name (migration 0020) must exist first.
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.
+298
View File
@@ -0,0 +1,298 @@
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { logger } from "hono/logger";
import { cors } from "hono/cors";
import { getAuth, initAuth, getActiveProviders } from "./lib/auth.js";
import { clientsRouter } from "./routes/clients.js";
import { petsRouter } from "./routes/pets.js";
import { servicesRouter } from "./routes/services.js";
import { appointmentsRouter } from "./routes/appointments.js";
import { waitlistRouter } from "./routes/waitlist.js";
import { portalRouter } from "./routes/portal.js";
import { staffRouter } from "./routes/staff.js";
import { invoicesRouter } from "./routes/invoices.js";
import { bookRouter } from "./routes/book.js";
import { reportsRouter } from "./routes/reports.js";
import { appointmentGroupsRouter } from "./routes/appointmentGroups.js";
import { groomingLogsRouter } from "./routes/groomingLogs.js";
import { impersonationRouter } from "./routes/impersonation.js";
import { settingsRouter } from "./routes/settings.js";
import { authProviderRouter } from "./routes/authProvider.js";
import { searchRouter } from "./routes/search.js";
import { bufferRulesRouter } from "./routes/buffer-rules.js";
import { getObject } from "./lib/s3.js";
import { calendarRouter } from "./routes/calendar.js";
import { setupRouter } from "./routes/setup.js";
import { getDb, businessSettings, eq, staff } from "@groombook/db";
import { authMiddleware } from "./middleware/auth.js";
import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSuperUser } from "./middleware/rbac.js";
import { devRouter } from "./routes/dev.js";
import { adminSeedRouter } from "./routes/admin/seed.js";
import { startReminderScheduler } from "./services/reminders.js";
import { webhooksRouter } from "./routes/stripe-webhooks.js";
const app = new Hono();
// Global middleware
const TRUSTED_ORIGINS = (process.env.CORS_ORIGIN ?? "http://localhost:5173")
.split(",")
.map((o) => o.trim());
const ALLOWED_ORIGIN = process.env.CORS_ORIGIN ?? "http://localhost:5173";
app.use("*", logger());
app.use(
"/api/*",
cors({
origin: (origin, ctx) => {
if (!origin) {
return ALLOWED_ORIGIN;
}
if (TRUSTED_ORIGINS.includes(origin)) {
return origin;
}
ctx.status(403);
return null;
},
credentials: true,
})
);
// Health check — no auth required, registered on app at full path before auth middleware
app.get("/api/health", (c) => c.json({ status: "ok" }));
// Public booking routes — no auth required, must be registered before auth middleware
app.route("/api/book", bookRouter);
// Public portal routes — client-facing, authenticated via impersonation session header
app.route("/api/portal", portalRouter);
// Public Stripe webhook endpoint — signature-verified, no auth required
app.route("/api/webhooks/stripe", webhooksRouter);
// Dev/demo routes — config is always public, users endpoint is guarded internally
app.route("/api/dev", devRouter);
// Magic bytes for allowed image types
const ALLOWED_IMAGE_TYPES: Record<string, Uint8Array> = {
"image/png": new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
"image/jpeg": new Uint8Array([0xff, 0xd8, 0xff]),
"image/gif": new Uint8Array([0x47, 0x49, 0x46, 0x38]),
"image/webp": new Uint8Array([0x52, 0x49, 0x46, 0x46]), // followed by size then WEBP
};
/**
* Validates that the given base64 content matches the declared MIME type
* by checking magic bytes. Returns null if valid, or the field to clear if not.
*/
function validateLogoMagicBytes(
logoBase64: string | null,
logoMimeType: string | null
): "logoBase64" | "logoMimeType" | null {
if (!logoBase64 || !logoMimeType) return null;
const expectedMagic = ALLOWED_IMAGE_TYPES[logoMimeType];
if (!expectedMagic) return "logoMimeType"; // unknown MIME type — reject
try {
const binary = Buffer.from(logoBase64, "base64");
// WebP needs a special check (RIFF....WEBP at offset 0, size at offset 4)
if (logoMimeType === "image/webp") {
if (binary.length < 12) return "logoBase64";
const webpMagic = binary.slice(0, 4);
const webpSig = binary.slice(8, 12);
if (
webpMagic[0] !== 0x52 ||
webpMagic[1] !== 0x49 ||
webpMagic[2] !== 0x46 ||
webpMagic[3] !== 0x46 ||
webpSig[0] !== 0x57 ||
webpSig[1] !== 0x45 ||
webpSig[2] !== 0x42 ||
webpSig[3] !== 0x50
) {
return "logoBase64";
}
return null;
}
// All other types: check prefix
if (binary.length < expectedMagic.length) return "logoBase64";
for (let i = 0; i < expectedMagic.length; i++) {
if (binary[i] !== expectedMagic[i]) return "logoBase64";
}
return null;
} catch {
return "logoBase64";
}
}
// Public logo proxy — no auth required, streams logo from S3 so browser never sees raw S3 URL
app.get("/api/branding/logo", async (c) => {
const db = getDb();
const [row] = await db.select().from(businessSettings).limit(1);
if (!row) return c.json({ error: "Settings not found" }, 404);
if (!row.logoKey) return c.json({ error: "No logo on file" }, 404);
const { body, contentType } = await getObject(row.logoKey);
return new Response(Buffer.from(body), {
status: 200,
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=86400",
},
});
});
// Public branding endpoint — no auth required, returns business name/colors/logo
app.get("/api/branding", async (c) => {
const db = getDb();
const [row] = await db.select().from(businessSettings).limit(1);
const settings = row ?? { businessName: "GroomBook", primaryColor: "#4f8a6f", accentColor: "#8b7355", logoBase64: null, logoMimeType: null, logoKey: null };
// Return the public proxy path so browser never sees a raw S3 URL
const logoUrl = settings.logoKey ? "/api/branding/logo" : null;
// Defensive: validate magic bytes to prevent MIME type confusion attacks
// via the legacy base64 logo fields
const badField = validateLogoMagicBytes(settings.logoBase64 ?? null, settings.logoMimeType ?? null);
const safeLogoBase64 = badField === "logoBase64" ? null : settings.logoBase64;
const safeLogoMimeType = badField === "logoMimeType" ? null : settings.logoMimeType;
return c.json({
businessName: settings.businessName,
primaryColor: settings.primaryColor,
accentColor: settings.accentColor,
logoUrl,
logoBase64: safeLogoBase64,
logoMimeType: safeLogoMimeType,
});
});
// Public iCal calendar feed — token auth in URL, no auth middleware required
app.route("/api/calendar", calendarRouter);
// Public setup status — no auth required, must be registered before auth middleware
app.get("/api/setup/status", async (c) => {
const db = getDb();
const [superUser] = await db
.select({ id: staff.id })
.from(staff)
.where(eq(staff.isSuperUser, true))
.limit(1);
return c.json({ needsSetup: !superUser });
});
// Public auth providers endpoint — no auth required, tells frontend which login options are available
app.get("/api/auth/providers", async (c) => {
return c.json({ providers: getActiveProviders() });
});
// Protected API routes
const api = app.basePath("/api");
api.use("*", authMiddleware);
api.use("*", resolveStaffMiddleware);
// Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes
// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths
const authRouter = new Hono();
authRouter.all("/*", (c) => {
try {
return getAuth().handler(c.req.raw);
} catch {
return c.json({ error: "Authentication not configured" }, 503);
}
});
api.route("/auth", authRouter);
// ── Role guards ────────────────────────────────────────────────────────────────
// Manager-only: admin settings, reports, invoices, impersonation
// Staff CRUD: all roles may READ; manager-only for CREATE/UPDATE/DELETE
api.on(["GET"], "/staff/*", requireRole("manager", "receptionist", "groomer"));
// Staff write routes: manager OR super-user (combined guard — avoids AND stacking)
api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager"));
api.use("/admin/*", requireRoleOrSuperUser("manager"));
api.use("/admin/settings/*", requireSuperUser());
api.use("/reports/*", requireRole("manager"));
api.use("/invoices/*", requireRole("manager", "groomer"));
api.use("/impersonation/*", requireRole("manager"));
// Manager + Receptionist only (groomers have no access): appointment-groups, grooming-logs, waitlist
api.use("/appointment-groups/*", requireRole("manager", "receptionist"));
api.use("/grooming-logs/*", requireRole("manager", "receptionist"));
api.use("/waitlist/*", requireRole("manager", "receptionist"));
// Pet photo routes: all staff roles may upload/delete (groomers take photos during grooms)
// These must be registered before the general pets write guard. Because Hono path params
// match single segments, "/pets/:petId" does NOT match "/pets/:petId/photo/:action",
// so there is no guard overlap.
api.on(
["POST", "DELETE"],
["/pets/:petId/photo", "/pets/:petId/photo/:action"],
requireRole("manager", "receptionist", "groomer")
);
// Clients, appointments: all roles may read; only manager + receptionist may write
api.on(
["POST", "PUT", "PATCH", "DELETE"],
["/clients/*", "/appointments/*"],
requireRole("manager", "receptionist")
);
// Pets (non-photo CRUD): manager + receptionist for writes
// ":petId" matches only single-segment paths — photo sub-routes are unaffected
api.post("/pets", requireRole("manager", "receptionist"));
api.on(["PUT", "PATCH", "DELETE"], "/pets/:petId", requireRole("manager", "receptionist"));
// Services: all roles may read; only managers may write
api.on(
["POST", "PUT", "PATCH", "DELETE"],
"/services/*",
requireRole("manager")
);
// ──────────────────────────────────────────────────────────────────────────────
// Setup: POST /api/setup (authenticated) — requires staff context from auth middleware
api.route("/setup", setupRouter);
api.route("/clients", clientsRouter);
api.route("/pets", petsRouter);
api.route("/services", servicesRouter);
api.route("/appointments", appointmentsRouter);
api.route("/waitlist", waitlistRouter);
api.route("/staff", staffRouter);
api.route("/invoices", invoicesRouter);
api.route("/reports", reportsRouter);
api.route("/appointment-groups", appointmentGroupsRouter);
api.route("/grooming-logs", groomingLogsRouter);
api.route("/impersonation", impersonationRouter);
api.route("/admin/settings", settingsRouter);
api.route("/admin/auth-provider", authProviderRouter);
api.route("/admin/seed", adminSeedRouter);
api.route("/search", searchRouter);
api.route("/buffer-rules", bufferRulesRouter);
const port = Number(process.env.PORT ?? 3000);
await initAuth();
console.log(`API server listening on port ${port}`);
const server = serve({ fetch: app.fetch, port });
// Start background reminder scheduler (runs every minute to check for upcoming appointments)
startReminderScheduler();
function shutdown() {
console.log("Shutting down gracefully...");
server.close(() => {
console.log("HTTP server closed");
process.exit(0);
});
setTimeout(() => {
console.error("Forced shutdown after timeout");
process.exit(1);
}, 10_000);
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
export default app;