diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..90c962d --- /dev/null +++ b/src/index.ts @@ -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 — registered on api before auth middleware so it is reachable at /api/health +api.get("/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 = { + "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;