From 27e6674b9acf3d6f38a8ba207ddcdad99618c55b Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Mon, 8 Jun 2026 23:15:51 +0000 Subject: [PATCH 01/34] feat(GRO-2225): UAT seed route cohort + receptionist credential (#187) --- UAT_PLAYBOOK.md | 7 +- packages/db/src/seed.ts | 210 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+), 1 deletion(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 3d0445b..3ab23ef 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -363,7 +363,12 @@ This means: ### 4.16 Route Optimization — Route CRUD + Optimize (GRO-2155, Phase 2.1) -A groomer's daily route is one row per `(staffId, routeDate)` in `groomer_routes`, with ordered `route_stops`. `POST /api/routes/optimize` pulls the day's non-cancelled appointments whose client is geocoded (GRO-2154), orders them (Google Directions `optimizeWaypoints` when a key is configured in `businessSettings.googleMapsApiKey`, else an offline nearest-neighbor heuristic), and persists `stopOrder`, `travelMinsFromPrev`, `travelDistanceKmFromPrev` plus route `totalTravelMins`/`totalDistanceKm`/`optimizedAt`. **Auth: manager (any groomer's route) or groomer (own route only); receptionists have no access.** Pre-condition: at least one geocoded client with appointments on the target date for the staff member (use §4.2 geocoding + a seed groomer). +A groomer's daily route is one row per `(staffId, routeDate)` in `groomer_routes`, with ordered `route_stops`. `POST /api/routes/optimize` pulls the day's non-cancelled appointments whose client is geocoded (GRO-2154), orders them (Google Directions `optimizeWaypoints` when a key is configured in `businessSettings.googleMapsApiKey`, else an offline nearest-neighbor heuristic), and persists `stopOrder`, `travelMinsFromPrev`, `travelDistanceKmFromPrev` plus route `totalTravelMins`/`totalDistanceKm`/`optimizedAt`. **Auth: manager (any groomer's route) or groomer (own route only); receptionists have no access.** + +**Pre-condition (GRO-2225 — zero-touch; no manual PATCH/geocoding needed).** A fresh UAT reset+seed now provisions a deterministic route cohort, so §4.16 runs directly against seed data: +- **Groomer:** `uat-groomer@groombook.dev` (staffId `00000000-0000-0000-0000-000000000004`). Resolve its id via `GET /api/staff` or sign in as the groomer and omit `staffId`. +- **Date:** `2026-09-15` (fixed). On this date the groomer has **12** confirmed appointments: **10 pre-geocoded** clients clustered in the Seattle metro (multi-stop route) + **2 intentionally un-geocoded** clients (exercise the skip-and-surface path, TC-API-16.4). Cohort clients are named `Route Demo — …` (emails `route-client-NN@uat.groombook.dev`). +- **Receptionist (TC-API-16.9 403):** sign in as `uat-receptionist@groombook.dev` (password from the `seed-uat-passwords` secret, key `SEED_UAT_RECEPTIONIST_PASSWORD`) — a standing receptionist login; no hand-built session required. | # | Scenario | Steps | Expected | |---|----------|-------|----------| diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 0959be0..55b2ee4 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -456,6 +456,36 @@ async function seedUatStaffAccounts( } } + // ── Staff: UAT Receptionist (GRO-2225) ────────────────────────────────────── + // Standing receptionist staff record so the route-optimization 403 path + // (TC-API-16.9: receptionist GET/POST /api/routes → 403) is reproducible + // without a hand-built session. The matching Better-Auth credential is + // provisioned below from SEED_UAT_RECEPTIONIST_PASSWORD. Created here (gated + // on the password env) so the credential loop's staff-link step finds it. + if (process.env.SEED_UAT_RECEPTIONIST_PASSWORD) { + const UAT_RECEPTIONIST_STAFF_ID = "00000000-0000-0000-0000-000000000099"; + const [existingReceptionist] = await db + .select() + .from(schema.staff) + .where(eq(schema.staff.email, "uat-receptionist@groombook.dev")) + .limit(1); + + if (existingReceptionist) { + console.log(`✓ Staff 'UAT Receptionist' already exists — skipping`); + } else { + await db.insert(schema.staff).values({ + id: UAT_RECEPTIONIST_STAFF_ID, + name: "UAT Receptionist", + email: "uat-receptionist@groombook.dev", + oidcSub: "uat-receptionist@groombook.dev", + role: "receptionist", + isSuperUser: false, + active: true, + }); + console.log(`✓ Created staff 'UAT Receptionist' (uat-receptionist@groombook.dev)`); + } + } + // ── Staff: UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ── const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? []; const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? []; @@ -495,6 +525,8 @@ async function seedUatStaffAccounts( { 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" }, + // GRO-2225: standing receptionist login for the route-optimization 403 path (TC-API-16.9). + { email: "uat-receptionist@groombook.dev", name: "UAT Receptionist", passwordEnv: "SEED_UAT_RECEPTIONIST_PASSWORD", staffEmail: "uat-receptionist@groombook.dev" }, ]; for (const acct of uatPasswordAccounts) { @@ -798,6 +830,179 @@ async function seedUatGroomerLinkage( ); } +// ── GRO-2225: deterministic route-optimization cohort ──────────────────────── + +/** + * GRO-2225: seed a deterministic, pre-geocoded client cohort + a fixed-date set + * of appointments for the UAT groomer so the route-optimization endpoints + * (`GET /api/routes/daily`, `POST /api/routes/optimize`, UAT §4.16 + * TC-API-16.1…16.11) are exercisable with ZERO manual PATCHing. + * + * Design (no live geocoder — UAT has no Google Maps key, provider is + * nearest_neighbor; coordinates are hand-picked fixtures clustered in the + * Seattle metro): + * - All appointments are on a FIXED calendar date (ROUTE_DATE) and assigned to + * the UAT groomer (`uat-groomer@groombook.dev`). The optimize endpoint pulls + * non-cancelled appointments in [date 00:00Z, +24h) joined to client coords. + * - 10 clients carry deterministic lat/lng → a multi-stop optimized route. + * - 2 clients are intentionally left UN-geocoded so the "skipped + surfaced" + * path (TC-API-16.5) stays reproducible. + * + * Idempotent: clients/pets are upserted by fixed UUID (they are NOT truncated on + * reset); appointments are upserted by fixed UUID too (they ARE truncated on + * reset, but the upsert keeps re-runs safe in non-truncating dev/test paths). + * Skips cleanly when the UAT groomer staff record is absent (e.g. prod/demo or a + * dev seed without the UAT personas). + */ +async function seedUatRouteCohort(db: ReturnType): Promise { + // Fixed calendar date the UAT playbook hardcodes for §4.16. Times are UTC so + // they fall inside the optimize endpoint's [date 00:00Z, +24h) day window. + const ROUTE_DATE = "2026-09-15"; + + const [uatGroomer] = await db + .select({ id: schema.staff.id }) + .from(schema.staff) + .where(eq(schema.staff.email, "uat-groomer@groombook.dev")) + .limit(1); + if (!uatGroomer) { + console.log("✓ GRO-2225: uat-groomer not present — skipping route cohort"); + return; + } + + // Resolve a service for the appointments: prefer Bath & Brush, else any active. + const BATH_AND_BRUSH_ID = "b0000001-0000-0000-0000-000000000001"; + const [bathService] = await db + .select({ id: schema.services.id }) + .from(schema.services) + .where(eq(schema.services.id, BATH_AND_BRUSH_ID)) + .limit(1); + let serviceId: string; + if (bathService) { + serviceId = bathService.id; + } else { + const [fallback] = await db + .select({ id: schema.services.id }) + .from(schema.services) + .where(eq(schema.services.active, true)) + .limit(1); + if (!fallback) { + console.warn("⚠ GRO-2225: no active services found — skipping route cohort"); + return; + } + serviceId = fallback.id; + } + + // Hand-picked fixture coordinates clustered in the Seattle metro. `coords:null` + // marks an intentionally un-geocoded client (skip-and-surface path TC-16.5). + const cohort: Array<{ + n: number; + name: string; + coords: { lat: number; lng: number } | null; + }> = [ + { n: 1, name: "Route Demo — Ada Lovelace", coords: { lat: 47.6097, lng: -122.3331 } }, + { n: 2, name: "Route Demo — Grace Hopper", coords: { lat: 47.6205, lng: -122.3493 } }, + { n: 3, name: "Route Demo — Alan Turing", coords: { lat: 47.5990, lng: -122.3300 } }, + { n: 4, name: "Route Demo — Katherine Johnson", coords: { lat: 47.6150, lng: -122.3200 } }, + { n: 5, name: "Route Demo — Edsger Dijkstra", coords: { lat: 47.6280, lng: -122.3550 } }, + { n: 6, name: "Route Demo — Barbara Liskov", coords: { lat: 47.5920, lng: -122.3150 } }, + { n: 7, name: "Route Demo — Donald Knuth", coords: { lat: 47.6350, lng: -122.3400 } }, + { n: 8, name: "Route Demo — Margaret Hamilton", coords: { lat: 47.6050, lng: -122.3600 } }, + { n: 9, name: "Route Demo — Ken Thompson", coords: { lat: 47.6420, lng: -122.3250 } }, + { n: 10, name: "Route Demo — Radia Perlman", coords: { lat: 47.5880, lng: -122.3450 } }, + // Intentionally un-geocoded — exercises the skip-and-surface path. + { n: 11, name: "Route Demo — Ungeocoded One", coords: null }, + { n: 12, name: "Route Demo — Ungeocoded Two", coords: null }, + ]; + + // Stagger appointments 45 min apart starting 15:00Z on ROUTE_DATE. + const dayStartMs = new Date(`${ROUTE_DATE}T15:00:00.000Z`).getTime(); + const SLOT_MS = 45 * 60 * 1000; + + let geocodedCount = 0; + let ungeocodedCount = 0; + for (const c of cohort) { + const pad = String(c.n).padStart(2, "0"); + const clientId = `d0000000-0000-0000-0000-0000000000${pad}`; + const petId = `d0000000-0000-0000-0000-0000000001${pad}`; + const apptId = `d0000000-0000-0000-0000-0000000002${pad}`; + const geocodedAt = c.coords ? new Date(`${ROUTE_DATE}T00:00:00.000Z`) : null; + + await db.insert(schema.clients) + .values({ + id: clientId, + name: c.name, + email: `route-client-${pad}@uat.groombook.dev`, + phone: `(206) 555-01${pad}`, + address: `${100 + c.n} Pike Street, Seattle, WA 98101`, + status: "active", + latitude: c.coords?.lat ?? null, + longitude: c.coords?.lng ?? null, + geocodedAt, + }) + .onConflictDoUpdate({ + target: schema.clients.id, + set: { + name: c.name, + address: `${100 + c.n} Pike Street, Seattle, WA 98101`, + latitude: c.coords?.lat ?? null, + longitude: c.coords?.lng ?? null, + geocodedAt, + }, + }); + + await db.insert(schema.pets) + .values({ + id: petId, + clientId, + name: `Route Pup ${c.n}`, + species: "Dog", + breed: "Mixed", + weightKg: "18.00", + }) + .onConflictDoUpdate({ + target: schema.pets.id, + set: { clientId, name: `Route Pup ${c.n}`, species: "Dog" }, + }); + + const startTime = new Date(dayStartMs + (c.n - 1) * SLOT_MS); + const endTime = new Date(startTime.getTime() + SLOT_MS); + await db.insert(schema.appointments) + .values({ + id: apptId, + clientId, + petId, + serviceId, + staffId: uatGroomer.id, + batherStaffId: null, + status: "confirmed", + startTime, + endTime, + notes: "GRO-2225: deterministic route-optimization cohort appointment.", + priceCents: null, + confirmationStatus: "confirmed", + }) + .onConflictDoUpdate({ + target: schema.appointments.id, + set: { + clientId, + petId, + serviceId, + staffId: uatGroomer.id, + status: "confirmed", + startTime, + endTime, + }, + }); + + if (c.coords) geocodedCount++; + else ungeocodedCount++; + } + + console.log( + `✓ GRO-2225: seeded route cohort for ${ROUTE_DATE} — ${geocodedCount} geocoded + ${ungeocodedCount} un-geocoded appointment(s) for uat-groomer (${uatGroomer.id})`, + ); +} + // ── Known-users-only seed (prod/demo) ─────────────────────────────────────── /** @@ -1169,6 +1374,11 @@ async function runSeedBody( // the time seedUatStaffAccounts() returns). await seedUatGroomerLinkage(db, uatCustomerClientId); + // GRO-2225: deterministic pre-geocoded route cohort + fixed-date appointments + // for the UAT groomer. Must run AFTER services are seeded (it looks up a + // service id for the appointments). Skips cleanly if uat-groomer is absent. + await seedUatRouteCohort(db); + // ── Clients & Pets ── const now = new Date(); const appointmentsBackDate = new Date(now); From 6702086c7bc4337ad8dba3a2a142beae4065085d Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Mon, 8 Jun 2026 23:50:21 +0000 Subject: [PATCH 02/34] fix(GRO-2235): return 409 on duplicate portal waitlist submit (#189) --- src/__tests__/portalWaitlistDuplicate.test.ts | 154 ++++++++++++++++++ src/routes/portal.ts | 36 ++-- 2 files changed, 180 insertions(+), 10 deletions(-) create mode 100644 src/__tests__/portalWaitlistDuplicate.test.ts diff --git a/src/__tests__/portalWaitlistDuplicate.test.ts b/src/__tests__/portalWaitlistDuplicate.test.ts new file mode 100644 index 0000000..c0edbc6 --- /dev/null +++ b/src/__tests__/portalWaitlistDuplicate.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +// GRO-2235: a duplicate active waitlist entry violates the partial unique index +// idx_waitlist_active_unique. postgres-js surfaces it as SQLSTATE 23505 — the +// handler must return a friendly 409, not a generic 500. The first insert still +// returns 201, and unrelated errors still surface as 500. + +const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001"; +const SESSION_ID = "770e8400-e29b-41d4-a716-446655440003"; +const PET_ID = "880e8400-e29b-41d4-a716-446655440004"; +const SERVICE_ID = "990e8400-e29b-41d4-a716-446655440005"; + +const futureDate = () => new Date(Date.now() + 30 * 60 * 1000); + +const ACTIVE_SESSION = { + id: SESSION_ID, + clientId: CLIENT_ID, + status: "active" as const, + reason: "manual", + startedAt: new Date(), + expiresAt: futureDate(), + createdAt: new Date(), +}; + +// Behaviour knob for the waitlist insert: "ok" returns a row, "duplicate" throws +// a postgres-js-shaped unique-violation, "other" throws an unrelated error. +let waitlistInsertMode: "ok" | "duplicate" | "other" = "ok"; + +function resetMock() { + waitlistInsertMode = "ok"; +} + +function tableProxy(name: string) { + return new Proxy( + { _name: name }, + { get: (t, p) => (p === "_name" ? name : { table: name, column: p }) } + ); +} + +vi.mock("@groombook/db", () => { + function makeChainable(data: unknown[]): unknown { + const arr = [...data]; + const chain = new Proxy(arr, { + get(target, prop) { + if (prop === "where" || prop === "orderBy" || prop === "limit") { + return () => chain; + } + // @ts-expect-error proxy + return target[prop]; + }, + }); + return chain; + } + + const impersonationSessions = tableProxy("impersonationSessions"); + const waitlistEntries = tableProxy("waitlistEntries"); + const impersonationAuditLogs = tableProxy("impersonationAuditLogs"); + + return { + getDb: () => ({ + select: () => ({ + from: (table: { _name: string }) => { + if (table._name === "impersonationSessions") { + return makeChainable([ACTIVE_SESSION]); + } + return makeChainable([]); + }, + }), + insert: (table: { _name: string }) => ({ + values: (vals: Record) => ({ + returning: () => { + if (table._name === "waitlistEntries") { + if (waitlistInsertMode === "duplicate") { + throw Object.assign(new Error("duplicate key value"), { code: "23505" }); + } + if (waitlistInsertMode === "other") { + throw Object.assign(new Error("not null violation"), { code: "23502" }); + } + return [{ id: "entry-1", ...vals }]; + } + // impersonationAuditLogs and anything else: succeed silently. + return [{ id: "audit-1", ...vals }]; + }, + }), + }), + update: () => ({ + set: () => ({ where: () => Promise.resolve() }), + }), + }), + impersonationSessions, + waitlistEntries, + impersonationAuditLogs, + appointments: tableProxy("appointments"), + clients: tableProxy("clients"), + pets: tableProxy("pets"), + services: tableProxy("services"), + staff: tableProxy("staff"), + invoices: tableProxy("invoices"), + invoiceLineItems: tableProxy("invoiceLineItems"), + eq: vi.fn(), + and: vi.fn(), + inArray: vi.fn(), + }; +}); + +const { portalRouter } = await import("../routes/portal.js"); + +const app = new Hono(); +app.route("/portal", portalRouter); + +function postWaitlist(body: unknown) { + return app.request("/portal/waitlist", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Impersonation-Session-Id": SESSION_ID, + }, + body: JSON.stringify(body), + }); +} + +const VALID_BODY = { + petId: PET_ID, + serviceId: SERVICE_ID, + preferredDate: "2026-07-01", + preferredTime: "09:00", +}; + +beforeEach(() => resetMock()); + +describe("POST /portal/waitlist duplicate handling (GRO-2235)", () => { + it("returns 201 for the first insert", async () => { + waitlistInsertMode = "ok"; + const res = await postWaitlist(VALID_BODY); + expect(res.status).toBe(201); + }); + + it("returns 409 with a friendly message for a duplicate (23505)", async () => { + waitlistInsertMode = "duplicate"; + const res = await postWaitlist(VALID_BODY); + expect(res.status).toBe(409); + const json = (await res.json()) as { error: string }; + expect(json.error).toBe( + "You already have a booking for this pet at that date and time." + ); + }); + + it("still surfaces unrelated DB errors as 500", async () => { + waitlistInsertMode = "other"; + const res = await postWaitlist(VALID_BODY); + expect(res.status).toBe(500); + }); +}); diff --git a/src/routes/portal.ts b/src/routes/portal.ts index d614e51..3c7dab9 100644 --- a/src/routes/portal.ts +++ b/src/routes/portal.ts @@ -596,16 +596,32 @@ portalRouter.post( const body = c.req.valid("json"); const clientId = c.get("portalClientId"); - const [entry] = await db - .insert(waitlistEntries) - .values({ - clientId, - petId: body.petId, - serviceId: body.serviceId, - preferredDate: body.preferredDate, - preferredTime: normalizeTime(body.preferredTime), - }) - .returning(); + let entry; + try { + [entry] = await db + .insert(waitlistEntries) + .values({ + clientId, + petId: body.petId, + serviceId: body.serviceId, + preferredDate: body.preferredDate, + preferredTime: normalizeTime(body.preferredTime), + }) + .returning(); + } catch (err) { + // An exact duplicate active waitlist entry violates the partial unique + // index idx_waitlist_active_unique (client_id, pet_id, service_id, + // preferred_date, preferred_time WHERE status='active'). postgres-js + // surfaces this as SQLSTATE 23505 — return a friendly 409 rather than a + // generic 500 (GRO-2235). Unrelated errors still surface as 500. + if ((err as { code?: string })?.code === "23505") { + return c.json( + { error: "You already have a booking for this pet at that date and time." }, + 409 + ); + } + throw err; + } return c.json(entry, 201); } From cd2f60e28206df3652fdf4b526e85e807b4aec8c Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 00:16:42 +0000 Subject: [PATCH 03/34] feat(GRO-2157): navigation export endpoints (Phase 2.3) (#190) --- UAT_PLAYBOOK.md | 23 ++++ src/__tests__/navigationExport.test.ts | 140 ++++++++++++++++++++++ src/routes/routes.ts | 69 ++++++++++- src/services/navigationExport.ts | 155 +++++++++++++++++++++++++ 4 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/navigationExport.test.ts create mode 100644 src/services/navigationExport.ts diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 3ab23ef..cf0a541 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -406,6 +406,29 @@ Builds on §4.16. After optimization each consecutive leg carries a travel `buff | TC-API-17.7 | Reorder invalid routeId | `PATCH /api/routes/not-a-uuid/reorder` | 400 `{ error: "routeId must be a UUID" }` | | TC-API-17.8 | Groomer cannot reorder another's route | As groomer, reorder a route owned by a different groomer | 403 Forbidden (`groomers may only access their own route`) | +### 4.18 Route Optimization — Navigation Export (GRO-2157, Phase 2.3) + +Builds on §4.16/§4.17. Two read-only endpoints turn an optimized route into a native-navigation deep-link URL the frontend opens on the groomer's phone: + +- `GET /api/routes/:routeId/export/google-maps` → Google Maps URLs API link (`https://www.google.com/maps/dir/?api=1&travelmode=driving&origin=…&destination=…&waypoints=…`) +- `GET /api/routes/:routeId/export/apple-maps` → Apple Maps URL scheme (`maps://?saddr=…&daddr=+to:…&dirflg=d`) + +Both use the stops' stored `latitude`/`longitude` in `stopOrder`: **origin = first stop, destination = last stop, the rest are ordered intermediate waypoints**. Each response body is `{ platform, url, stopCount, waypointCount }` where `waypointCount` = stops minus origin and destination. Waypoint limits are validated per platform: **Google Maps ≤ 9**, **Apple Maps ≤ 15** intermediate waypoints; over-limit routes return 400. **Auth: manager (any route) or groomer (own route only); receptionists have no access.** + +| ID | Scenario | Steps | Expected | +|----|----------|-------|----------| +| TC-API-18.1 | Google Maps export of a multi-stop route | As manager, optimize a multi-stop day (§4.16), then `GET /api/routes/{routeId}/export/google-maps` | 200 OK; `platform:"google-maps"`, `url` starts `https://www.google.com/maps/dir/?api=1`, contains `travelmode=driving`, `origin`/`destination` are the first/last stop coords, `waypoints` lists the middle stops in order (pipe-separated). `stopCount` = total stops, `waypointCount` = `stopCount − 2` | +| TC-API-18.2 | Apple Maps export of a multi-stop route | As manager, `GET /api/routes/{routeId}/export/apple-maps` for the same route | 200 OK; `platform:"apple-maps"`, `url` starts `maps://?saddr=`, `daddr` chains the remaining stops with `+to:`, ends `&dirflg=d`; `stopCount`/`waypointCount` as above | +| TC-API-18.3 | Single-stop route | Export a route (google-maps and apple-maps) that has exactly one stop | 200 OK; `waypointCount:0`. Google url has `destination` and no `waypoints=`; Apple url is `maps://?daddr=&dirflg=d` (no `saddr`) | +| TC-API-18.4 | Empty route rejected | Export a route with no stops (a fresh `draft` route) | 400 `{ error: "route has no stops to export" }` | +| TC-API-18.5 | Google waypoint limit | Export (google-maps) a route with >11 stops (>9 intermediate waypoints) | 400 with an `error` mentioning Google Maps' limit of 9 | +| TC-API-18.6 | Apple waypoint limit | Export (apple-maps) a route with >17 stops (>15 intermediate waypoints) | 400 with an `error` mentioning Apple Maps' limit of 15 | +| TC-API-18.7 | Unknown route | `GET /api/routes/{randomUuid}/export/google-maps` | 404 `{ error: "Route not found" }` | +| TC-API-18.8 | Invalid routeId | `GET /api/routes/not-a-uuid/export/apple-maps` | 400 `{ error: "routeId must be a UUID" }` | +| TC-API-18.9 | Groomer exports own route | As **groomer**, export a route owned by self | 200 OK; deep-link returned | +| TC-API-18.10 | Groomer cannot export another's route | As groomer, export a route owned by a different groomer | 403 Forbidden (`groomers may only access their own route`) | +| TC-API-18.11 | Receptionist denied | As **receptionist**, export any route | 403 Forbidden (role not permitted) | + ## Pass/Fail Criteria **Pass:** diff --git a/src/__tests__/navigationExport.test.ts b/src/__tests__/navigationExport.test.ts new file mode 100644 index 0000000..902cb9d --- /dev/null +++ b/src/__tests__/navigationExport.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from "vitest"; +import { + buildGoogleMapsUrl, + buildAppleMapsUrl, + buildNavigationUrl, + intermediateWaypointCount, + GOOGLE_MAPS_MAX_WAYPOINTS, + APPLE_MAPS_MAX_WAYPOINTS, + type NavigationStop, +} from "../services/navigationExport.js"; + +function stops(n: number): NavigationStop[] { + return Array.from({ length: n }, (_, i) => ({ + latitude: 47 + i / 100, + longitude: -122 - i / 100, + label: `Stop ${i + 1}`, + })); +} + +describe("intermediateWaypointCount", () => { + it("excludes origin and destination", () => { + expect(intermediateWaypointCount(0)).toBe(0); + expect(intermediateWaypointCount(1)).toBe(0); + expect(intermediateWaypointCount(2)).toBe(0); + expect(intermediateWaypointCount(5)).toBe(3); + }); +}); + +describe("buildGoogleMapsUrl", () => { + it("rejects an empty route", () => { + const r = buildGoogleMapsUrl([]); + expect(r).toEqual({ error: "route has no stops to export", status: 400 }); + }); + + it("builds a single-stop link (destination only, no waypoints)", () => { + const r = buildGoogleMapsUrl(stops(1)); + if ("error" in r) throw new Error(r.error); + expect(r.platform).toBe("google-maps"); + expect(r.stopCount).toBe(1); + expect(r.waypointCount).toBe(0); + expect(r.url).toContain("https://www.google.com/maps/dir/?"); + expect(r.url).toContain("api=1"); + expect(r.url).toContain("travelmode=driving"); + expect(r.url).toContain("origin=47%2C-122"); + expect(r.url).toContain("destination=47%2C-122"); + expect(r.url).not.toContain("waypoints="); + }); + + it("builds origin/destination only for two stops", () => { + const r = buildGoogleMapsUrl(stops(2)); + if ("error" in r) throw new Error(r.error); + expect(r.waypointCount).toBe(0); + expect(r.url).not.toContain("waypoints="); + expect(r.url).toContain("origin=47%2C-122"); + expect(r.url).toContain("destination=47.01%2C-122.01"); + }); + + it("includes intermediate waypoints in order, pipe-separated", () => { + const r = buildGoogleMapsUrl(stops(4)); + if ("error" in r) throw new Error(r.error); + expect(r.stopCount).toBe(4); + expect(r.waypointCount).toBe(2); + // waypoints param holds stops[1] and stops[2], pipe-joined (encoded %7C) + const url = new URL(r.url); + expect(url.searchParams.get("origin")).toBe("47,-122"); + expect(url.searchParams.get("destination")).toBe("47.03,-122.03"); + expect(url.searchParams.get("waypoints")).toBe( + "47.01,-122.01|47.02,-122.02" + ); + }); + + it("accepts a route at exactly the waypoint limit", () => { + const r = buildGoogleMapsUrl(stops(GOOGLE_MAPS_MAX_WAYPOINTS + 2)); + if ("error" in r) throw new Error(r.error); + expect(r.waypointCount).toBe(GOOGLE_MAPS_MAX_WAYPOINTS); + }); + + it("rejects a route over the waypoint limit", () => { + const r = buildGoogleMapsUrl(stops(GOOGLE_MAPS_MAX_WAYPOINTS + 3)); + expect("error" in r).toBe(true); + if ("error" in r) { + expect(r.status).toBe(400); + expect(r.error).toContain(`${GOOGLE_MAPS_MAX_WAYPOINTS}`); + } + }); +}); + +describe("buildAppleMapsUrl", () => { + it("rejects an empty route", () => { + const r = buildAppleMapsUrl([]); + expect(r).toEqual({ error: "route has no stops to export", status: 400 }); + }); + + it("builds a destination-only link for one stop", () => { + const r = buildAppleMapsUrl(stops(1)); + if ("error" in r) throw new Error(r.error); + expect(r.platform).toBe("apple-maps"); + expect(r.url).toBe("maps://?daddr=47,-122&dirflg=d"); + expect(r.url).not.toContain("saddr="); + }); + + it("chains destinations with +to: for multiple stops", () => { + const r = buildAppleMapsUrl(stops(3)); + if ("error" in r) throw new Error(r.error); + expect(r.stopCount).toBe(3); + expect(r.waypointCount).toBe(1); + expect(r.url).toBe( + "maps://?saddr=47,-122&daddr=47.01,-122.01+to:47.02,-122.02&dirflg=d" + ); + }); + + it("accepts a route at exactly the waypoint limit", () => { + const r = buildAppleMapsUrl(stops(APPLE_MAPS_MAX_WAYPOINTS + 2)); + if ("error" in r) throw new Error(r.error); + expect(r.waypointCount).toBe(APPLE_MAPS_MAX_WAYPOINTS); + }); + + it("rejects a route over the waypoint limit", () => { + const r = buildAppleMapsUrl(stops(APPLE_MAPS_MAX_WAYPOINTS + 3)); + expect("error" in r).toBe(true); + if ("error" in r) { + expect(r.status).toBe(400); + expect(r.error).toContain(`${APPLE_MAPS_MAX_WAYPOINTS}`); + } + }); +}); + +describe("buildNavigationUrl", () => { + it("dispatches to the google-maps builder", () => { + const r = buildNavigationUrl("google-maps", stops(2)); + if ("error" in r) throw new Error(r.error); + expect(r.platform).toBe("google-maps"); + }); + + it("dispatches to the apple-maps builder", () => { + const r = buildNavigationUrl("apple-maps", stops(2)); + if ("error" in r) throw new Error(r.error); + expect(r.platform).toBe("apple-maps"); + }); +}); diff --git a/src/routes/routes.ts b/src/routes/routes.ts index 3bf905a..898b16a 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -1,4 +1,4 @@ -import { Hono } from "hono"; +import { Hono, type Context } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; import { @@ -24,6 +24,11 @@ import { type RouteStopInput, type StopConflictFlags, } from "../services/routeOptimization.js"; +import { + buildNavigationUrl, + type NavigationPlatform, + type NavigationStop, +} from "../services/navigationExport.js"; export const routesRouter = new Hono(); @@ -460,3 +465,65 @@ routesRouter.patch( }); } ); + +/** + * GET /:routeId/export/:platform — build a native-navigation deep-link URL for an + * optimized route. Origin = first stop, destination = last stop, the rest carried + * as ordered intermediate waypoints. Waypoint count is validated against the + * platform's limit. Auth: manager (any route) or groomer (own route only). + */ +async function handleNavigationExport( + c: Context, + platform: NavigationPlatform +) { + const db = getDb(); + const routeId = c.req.param("routeId"); + if (!routeId || !z.string().uuid().safeParse(routeId).success) { + return c.json({ error: "routeId must be a UUID" }, 400); + } + + const [route] = await db + .select() + .from(groomerRoutes) + .where(eq(groomerRoutes.id, routeId)); + if (!route) { + return c.json({ error: "Route not found" }, 404); + } + + // Reuse the groomer-own / manager authorization rule against the route owner. + const resolved = resolveTargetStaffId(c.get("staff"), route.staffId); + if ("error" in resolved) { + return c.json({ error: resolved.error }, resolved.status); + } + + const stops = await loadRouteStops(db, routeId); + if (stops.length === 0) { + return c.json({ error: "route has no stops to export" }, 400); + } + + const navStops: NavigationStop[] = stops.map((s) => ({ + latitude: s.latitude, + longitude: s.longitude, + label: s.clientName, + })); + + const result = buildNavigationUrl(platform, navStops); + if ("error" in result) { + return c.json({ error: result.error }, result.status); + } + + return c.json({ + platform: result.platform, + url: result.url, + stopCount: result.stopCount, + waypointCount: result.waypointCount, + }); +} + +routesRouter.get("/:routeId/export/google-maps", (c) => + handleNavigationExport(c, "google-maps") +); + +routesRouter.get("/:routeId/export/apple-maps", (c) => + handleNavigationExport(c, "apple-maps") +); diff --git a/src/services/navigationExport.ts b/src/services/navigationExport.ts new file mode 100644 index 0000000..ecac68a --- /dev/null +++ b/src/services/navigationExport.ts @@ -0,0 +1,155 @@ +// Navigation export — turn an optimized groomer route into a deep-link URL that +// opens the device's native navigation app (Google Maps / Apple Maps). +// +// A route is exported as: origin = first stop, destination = last stop, with the +// in-between stops carried as ordered intermediate waypoints. Each platform caps +// how many intermediate waypoints a deep link may carry, so callers must validate +// the route length before handing the URL to the client. + +/** + * Max intermediate waypoints a Google Maps URLs API deep link supports + * (`https://www.google.com/maps/dir/?api=1&...&waypoints=...`). Google documents + * a ceiling of 9 waypoints between origin and destination. + */ +export const GOOGLE_MAPS_MAX_WAYPOINTS = 9; + +/** + * Max intermediate waypoints we allow in an Apple Maps `maps://` deep link. Apple's + * URL scheme chains destinations with `+to:` but does not publish a hard cap; 15 is + * a conservative practical limit that keeps the URL well under length limits. + */ +export const APPLE_MAPS_MAX_WAYPOINTS = 15; + +export type NavigationPlatform = "google-maps" | "apple-maps"; + +/** A single ordered point on the route. `label` is optional, for display only. */ +export interface NavigationStop { + latitude: number; + longitude: number; + label?: string | null; +} + +export interface NavigationExportSuccess { + platform: NavigationPlatform; + url: string; + /** Total stops included (origin + waypoints + destination). */ + stopCount: number; + /** Intermediate waypoints only (excludes origin and destination). */ + waypointCount: number; +} + +export interface NavigationExportError { + error: string; + status: 400; +} + +export type NavigationExportResult = + | NavigationExportSuccess + | NavigationExportError; + +function isError(r: NavigationExportResult): r is NavigationExportError { + return "error" in r; +} + +/** Intermediate waypoints = every stop that is neither origin nor destination. */ +export function intermediateWaypointCount(stopCount: number): number { + return Math.max(0, stopCount - 2); +} + +function coord(stop: NavigationStop): string { + return `${stop.latitude},${stop.longitude}`; +} + +/** + * Builds a Google Maps URLs API driving deep link. On mobile this opens the + * native Google Maps app; on desktop it opens maps.google.com. + */ +export function buildGoogleMapsUrl( + stops: NavigationStop[] +): NavigationExportResult { + if (stops.length === 0) { + return { error: "route has no stops to export", status: 400 }; + } + const waypointCount = intermediateWaypointCount(stops.length); + if (waypointCount > GOOGLE_MAPS_MAX_WAYPOINTS) { + return { + error: `route has ${waypointCount} intermediate waypoints, exceeding Google Maps' limit of ${GOOGLE_MAPS_MAX_WAYPOINTS}`, + status: 400, + }; + } + + const origin = stops[0]!; + const destination = stops[stops.length - 1]!; + const params = new URLSearchParams(); + params.set("api", "1"); + params.set("travelmode", "driving"); + params.set("origin", coord(origin)); + params.set("destination", coord(destination)); + if (stops.length > 2) { + const mids = stops + .slice(1, -1) + .map(coord) + .join("|"); + params.set("waypoints", mids); + } + + return { + platform: "google-maps", + url: `https://www.google.com/maps/dir/?${params.toString()}`, + stopCount: stops.length, + waypointCount, + }; +} + +/** + * Builds an Apple Maps `maps://` driving deep link. The first stop is the source + * (`saddr`); the remaining stops are chained as destinations with `+to:` (`daddr`). + * Built by hand because the `+to:` separators are part of Apple's scheme and must + * not be percent-encoded. + */ +export function buildAppleMapsUrl( + stops: NavigationStop[] +): NavigationExportResult { + if (stops.length === 0) { + return { error: "route has no stops to export", status: 400 }; + } + const waypointCount = intermediateWaypointCount(stops.length); + if (waypointCount > APPLE_MAPS_MAX_WAYPOINTS) { + return { + error: `route has ${waypointCount} intermediate waypoints, exceeding Apple Maps' limit of ${APPLE_MAPS_MAX_WAYPOINTS}`, + status: 400, + }; + } + + const params: string[] = ["dirflg=d"]; + if (stops.length === 1) { + // Single stop: destination only, no source. + params.unshift(`daddr=${coord(stops[0]!)}`); + } else { + const daddr = stops + .slice(1) + .map(coord) + .join("+to:"); + params.unshift(`daddr=${daddr}`); + params.unshift(`saddr=${coord(stops[0]!)}`); + } + + return { + platform: "apple-maps", + url: `maps://?${params.join("&")}`, + stopCount: stops.length, + waypointCount, + }; +} + +/** Dispatches to the correct builder for the requested platform. */ +export function buildNavigationUrl( + platform: NavigationPlatform, + stops: NavigationStop[] +): NavigationExportResult { + return platform === "google-maps" + ? buildGoogleMapsUrl(stops) + : buildAppleMapsUrl(stops); +} + +export { isError as isNavigationExportError }; From 37e42b3104e2f28bebe5f4d7d353d283d17c23e2 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Tue, 9 Jun 2026 00:21:03 +0000 Subject: [PATCH 04/34] ci: re-trigger checks (transient pnpm/action-setup runner flake) Co-Authored-By: Paperclip From fe412933ead4e10d01b21a9ec0feed10abd845ef Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 06:17:42 +0000 Subject: [PATCH 05/34] GRO-2294: Route Optimization security hardening (geocode-batch limit cap + redact settings secret) (#193) --- .mcp.json | 11 +++ UAT_PLAYBOOK.md | 5 +- src/__tests__/geocodeBatchLimit.test.ts | 89 ++++++++++++++++++++++++ src/__tests__/settings.test.ts | 91 +++++++++++++++++++++++++ src/routes/clients.ts | 11 ++- src/routes/settings.ts | 16 ++++- trigger-uat-1779751324.txt | 0 7 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 .mcp.json create mode 100644 src/__tests__/geocodeBatchLimit.test.ts create mode 100644 src/__tests__/settings.test.ts create mode 100644 trigger-uat-1779751324.txt diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..6efc1ca --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "gitea": { + "type": "http", + "url": "https://git-mcp.farh.net/mcp", + "headers": { + "Authorization": "Bearer ${GITEA_TOKEN}" + } + } + } +} diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index cf0a541..48082de 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -133,6 +133,7 @@ Geocoding turns a client's street address into `latitude`/`longitude` + `geocode | TC-API-2.11 | Geocode endpoint is manager-only | As **groomer** or **receptionist**, `POST /api/clients/{id}/geocode` | 403 Forbidden (role not permitted) | | TC-API-2.12 | Batch geocode un-geocoded clients | As manager, `POST /api/clients/geocode-batch?limit=10` on a DB with un-geocoded clients | 200 OK; body `{ provider, processed, geocoded, unresolved, errors, remaining, outcomes[] }`. `processed` ≤ 10; `remaining` reflects un-geocoded clients beyond this batch. Re-run while `remaining > 0` to finish (throttled to provider rate limit) | | TC-API-2.13 | Batch geocode — invalid limit | As manager, `POST /api/clients/geocode-batch?limit=0` (or non-numeric) | 400 `{ error: "limit must be a positive integer" }` | +| TC-API-2.13a | Batch geocode — `?limit` cap enforced (GRO-2294) | As manager, `POST /api/clients/geocode-batch?limit=100000` on a DB with un-geocoded clients | 200 OK; the request is **clamped to the documented max of 500** — `processed` ≤ 500 (never the raw 100000). A fractional `?limit` (e.g. `49.9`) is floored to `49`. Confirms a manager cannot hold one synchronous request open / accrue unbounded Google API cost via an oversized limit | | TC-API-2.14 | Batch geocode — manager-only | As groomer/receptionist, `POST /api/clients/geocode-batch` | 403 Forbidden | | TC-API-2.15 | Auto-geocode on create | As manager/receptionist, `POST /api/clients` with a valid `address` | 201 Created; response includes a `geocoding` object (`status: "geocoded"` for a resolvable address) and the persisted client carries `latitude`/`longitude`/`geocodedAt`. Creating without an address succeeds with no `geocoding` field | | TC-API-2.16 | Auto-geocode on address update | As manager/receptionist, `PATCH /api/clients/{id}` changing `address` to a new valid value | 200 OK; response includes a `geocoding` object and refreshed coordinates. Patching unrelated fields (e.g. `name`) does NOT re-geocode (no `geocoding` field) | @@ -165,6 +166,8 @@ Geocoding turns a client's street address into `latitude`/`longitude` + `geocode | TC-API-3.19b | Get pet profile summary — customer cross-tenant blocked (GRO-2013) | Sign in as `uat-customer@groombook.dev`; reuse the customer's sessionId from TC-API-3.19a; `GET /api/pets/{otherClientPetId}/profile-summary` for a pet owned by a different client (`c0000002-...` or any non-customer pet) | 403 Forbidden (owner-bypass requires session.clientId === pet.clientId) | | TC-API-3.19c | Get pet profile summary — customer without portal session header | Same as TC-API-3.19a but omit the `X-Impersonation-Session-Id` header | 403 Forbidden (no owner-bypass without valid portal session) | | TC-API-3.19d | Get pet profile summary — owner-bypass writes audit row (GRO-2063) | Same setup as TC-API-3.19a (sign in as `uat-customer@groombook.dev`, establish a portal session for the customer's own clientId, call `GET /api/pets/{ownPetId}/profile-summary` with `X-Impersonation-Session-Id: {sessionId}` and a 200 OK response). Then call `GET /api/impersonation/sessions/{sessionId}/audit-log` and confirm there is exactly one entry with `action === "read_profile_summary"`, `pageVisited` matching the profile-summary path, and `metadata` containing `petId` and `actorStaffId` for the customer. Repeat TC-API-3.19b (cross-tenant attempt) and confirm NO new `read_profile_summary` row was written for the cross-tenant attempt. | 200 OK on the profile-summary call AND an audit log entry is present with the correct shape (defense-in-depth audit row; bypass attempts against other clients must NOT log) | +| TC-UAT-2 | Groomer accesses linked pet profile summary (GRO-2100) | Sign in as `uat-groomer@groombook.dev`; `GET /api/pets/c0000001-0000-0000-0000-000000000002/profile-summary` (UAT Pup Alpha — linked via deterministic completed appointment `a0000001-0000-0000-0000-000000000001`, service `b0000001-…-0001` "Bath & Brush", `startTime` ~7 days ago) | 200 OK, `recentGroomingHistory[]` non-empty (>=1 entry), `visitCount >= 1`, `upcomingAppointment` null (the seeded appointment is in the past) | +| TC-UAT-3 | Groomer blocked from unlinked pet profile summary (GRO-2100) | Sign in as `uat-groomer@groombook.dev`; `GET /api/pets/c0000001-0000-0000-0000-000000000003/profile-summary` (UAT Pup Beta — intentionally UNLINKED; no appointment row references this pet's clientId+groomerId combo) | 403 Forbidden (RBAC `groomer` role lacks the appointment-linkage grant for this pet). NOTE: if 404 is returned instead of 403, file a separate RBAC defect (not against the seed) — see GRO-2100 verification note | | TC-API-3.29 | Get pet profile summary — unknown UUID returns 404 (GRO-2014) | GET /api/pets/00000000-0000-0000-0000-000000000001/profile-summary while authenticated (any role) | 404 Not Found with body `{"error":"Not found"}` (was empty-body 500 in GRO-2014) | | TC-API-3.30 | Get pet profile summary — malformed UUID returns 404 (GRO-2014) | GET /api/pets/not-a-uuid/profile-summary while authenticated | 404 Not Found with body `{"error":"Not found"}` (was empty-body 500 in GRO-2014 — Postgres uuid cast failure) | | TC-API-3.31 | Get pet profile summary — never empty-body 500 (GRO-2014) | GET /api/pets/{anyId}/profile-summary across the test sweep | No response has status 500 with an empty body. Any 500 must include a JSON body `{"error":"Internal Server Error"}` | @@ -329,7 +332,7 @@ This means: | # | Scenario | Steps | Expected | |---|----------|-------|----------| -| TC-API-13.1 | Get business settings | GET /api/admin/settings | 200 OK, business settings returned | +| TC-API-13.1 | Get business settings | GET /api/admin/settings | 200 OK, business settings returned. Response body **must NOT include `googleMapsApiKey`** — the encrypted secret is redacted from the projection (GRO-2294, defense-in-depth); non-secret fields (`businessName`, colors, `routeOptimizationProvider`, etc.) are still present | | TC-API-13.2 | Update business settings | PATCH /api/admin/settings with updated values | 200 OK, settings updated | | TC-API-13.3 | Upload logo | POST /api/admin/settings/logo/upload with file | 200 OK, logo uploaded and stored | | TC-API-13.4 | View logo | GET /api/admin/settings/logo | 200 OK, logo image returned | diff --git a/src/__tests__/geocodeBatchLimit.test.ts b/src/__tests__/geocodeBatchLimit.test.ts new file mode 100644 index 0000000..8731c02 --- /dev/null +++ b/src/__tests__/geocodeBatchLimit.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +// ─── Mocks ────────────────────────────────────────────────────────────────── +// GRO-2294: the POST /clients/geocode-batch handler must clamp ?limit to the +// documented maximum (500) before invoking the geocoding service. We mock the +// service to capture the exact limit the route forwards. + +const geocodeUngeocodedClients = vi.fn(async () => ({ + totalRemaining: 0, + processed: 0, + geocoded: 0, + failed: 0, + remaining: 0, +})); + +vi.mock("../services/clientGeocoding.js", () => ({ + geocodeUngeocodedClients, + geocodeClient: vi.fn(), + resolveClientGeocodingProvider: vi.fn(), +})); + +vi.mock("@groombook/db", () => { + const tableProxy = (name: string) => + new Proxy( + { _name: name }, + { get: (_t, p) => (p === "_name" ? name : { table: name, column: p }) } + ); + return { + getDb: () => ({}), + clients: tableProxy("clients"), + appointments: tableProxy("appointments"), + and: vi.fn(), + eq: vi.fn(), + or: vi.fn(), + exists: vi.fn(), + }; +}); + +const { clientsRouter } = await import("../routes/clients.js"); + +const app = new Hono(); +app.route("/clients", clientsRouter); + +function postBatch(query: string) { + return app.request(`/clients/geocode-batch${query}`, { method: "POST" }); +} + +describe("POST /clients/geocode-batch — ?limit cap (GRO-2294)", () => { + beforeEach(() => { + geocodeUngeocodedClients.mockClear(); + }); + + it("defaults to 50 when no ?limit is supplied", async () => { + const res = await postBatch(""); + expect(res.status).toBe(200); + expect(geocodeUngeocodedClients).toHaveBeenCalledWith(expect.anything(), 50); + }); + + it("passes through a value within the cap", async () => { + const res = await postBatch("?limit=120"); + expect(res.status).toBe(200); + expect(geocodeUngeocodedClients).toHaveBeenCalledWith(expect.anything(), 120); + }); + + it("clamps an over-cap value to 500", async () => { + const res = await postBatch("?limit=100000"); + expect(res.status).toBe(200); + expect(geocodeUngeocodedClients).toHaveBeenCalledWith(expect.anything(), 500); + }); + + it("floors a fractional value before clamping", async () => { + const res = await postBatch("?limit=49.9"); + expect(res.status).toBe(200); + expect(geocodeUngeocodedClients).toHaveBeenCalledWith(expect.anything(), 49); + }); + + it("rejects a non-positive limit with 400", async () => { + const res = await postBatch("?limit=0"); + expect(res.status).toBe(400); + expect(geocodeUngeocodedClients).not.toHaveBeenCalled(); + }); + + it("rejects a non-numeric limit with 400", async () => { + const res = await postBatch("?limit=abc"); + expect(res.status).toBe(400); + expect(geocodeUngeocodedClients).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/settings.test.ts b/src/__tests__/settings.test.ts new file mode 100644 index 0000000..c878999 --- /dev/null +++ b/src/__tests__/settings.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +// ─── Mocks ────────────────────────────────────────────────────────────────── +// GRO-2294: GET /api/admin/settings must not return the encrypted +// googleMapsApiKey ciphertext, on either the existing-row or auto-create branch. + +let selectRows: Record[] = []; +let insertReturning: Record[] = []; + +function makeChainable(data: unknown[]): unknown { + const arr = [...data]; + const chain = new Proxy(arr, { + get(target, prop) { + if (prop === "where" || prop === "orderBy" || prop === "limit") { + return () => chain; + } + // @ts-expect-error proxy passthrough + return target[prop]; + }, + }); + return chain; +} + +vi.mock("@groombook/db", () => { + const businessSettings = new Proxy( + { _name: "business_settings" }, + { get: (_t, p) => (p === "_name" ? "business_settings" : { column: p }) } + ); + return { + getDb: () => ({ + select: () => ({ from: () => makeChainable(selectRows) }), + insert: () => ({ + values: () => ({ returning: () => insertReturning }), + }), + }), + businessSettings, + eq: vi.fn(), + }; +}); + +vi.mock("../lib/s3.js", () => ({ + getPresignedUploadUrl: vi.fn(), + deleteObject: vi.fn(), + putObject: vi.fn(), + getObject: vi.fn(), +})); + +const { settingsRouter } = await import("../routes/settings.js"); + +const app = new Hono(); +app.route("/settings", settingsRouter); + +const FULL_ROW = { + id: "settings-uuid-1", + businessName: "GroomBook", + primaryColor: "#4f8a6f", + accentColor: "#8b7355", + routeOptimizationProvider: "google", + googleMapsApiKey: "ENCRYPTED::super-secret-ciphertext", + createdAt: new Date(), + updatedAt: new Date(), +}; + +describe("GET /settings — googleMapsApiKey redaction (GRO-2294)", () => { + beforeEach(() => { + selectRows = []; + insertReturning = []; + }); + + it("omits googleMapsApiKey from an existing settings row", async () => { + selectRows = [{ ...FULL_ROW }]; + const res = await app.request("/settings", { method: "GET" }); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).not.toHaveProperty("googleMapsApiKey"); + // Non-secret fields are still returned. + expect(body.businessName).toBe("GroomBook"); + expect(body.routeOptimizationProvider).toBe("google"); + }); + + it("omits googleMapsApiKey from the auto-create branch", async () => { + selectRows = []; + insertReturning = [{ ...FULL_ROW, id: "settings-uuid-new" }]; + const res = await app.request("/settings", { method: "GET" }); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).not.toHaveProperty("googleMapsApiKey"); + expect(body.id).toBe("settings-uuid-new"); + }); +}); diff --git a/src/routes/clients.ts b/src/routes/clients.ts index e7ac65c..328ed31 100644 --- a/src/routes/clients.ts +++ b/src/routes/clients.ts @@ -12,6 +12,12 @@ import { export const clientsRouter = new Hono(); +// Batch-geocode bounds (GRO-2294): default 50, hard cap 500. The cap bounds how +// long one synchronous request stays open and the per-request external API cost +// when routeOptimizationProvider = "google". +const GEOCODE_BATCH_DEFAULT_LIMIT = 50; +const GEOCODE_BATCH_MAX_LIMIT = 500; + type ClientRow = typeof clients.$inferSelect; /** @@ -185,12 +191,15 @@ clientsRouter.post("/:clientId/geocode", async (c) => { clientsRouter.post("/geocode-batch", async (c) => { const db = getDb(); const limitRaw = c.req.query("limit"); - let limit = 50; + let limit = GEOCODE_BATCH_DEFAULT_LIMIT; if (limitRaw !== undefined) { limit = Number(limitRaw); if (!Number.isFinite(limit) || limit <= 0) { return c.json({ error: "limit must be a positive integer" }, 400); } + // Clamp to the documented maximum to bound synchronous request duration + // and (for the Google provider) per-request external API cost. + limit = Math.min(Math.floor(limit), GEOCODE_BATCH_MAX_LIMIT); } const summary = await geocodeUngeocodedClients(db, limit); return c.json(summary); diff --git a/src/routes/settings.ts b/src/routes/settings.ts index 3b931db..8529135 100644 --- a/src/routes/settings.ts +++ b/src/routes/settings.ts @@ -7,6 +7,17 @@ import { requireSuperUser } from "../middleware/rbac.js"; export const settingsRouter = new Hono(); +type BusinessSettingsRow = typeof businessSettings.$inferSelect; + +// Strip the encrypted googleMapsApiKey ciphertext from settings responses +// (GRO-2294, defense-in-depth). The secret is never needed client-side; it is +// only written via the dedicated provider-config endpoint. +function redactSettings(row: BusinessSettingsRow) { + const rest: Partial = { ...row }; + delete rest.googleMapsApiKey; + return rest; +} + // GET /api/admin/settings — return current business settings settingsRouter.get("/", async (c) => { const db = getDb(); @@ -14,9 +25,10 @@ settingsRouter.get("/", async (c) => { if (!row) { // Auto-create default settings if none exist const [created] = await db.insert(businessSettings).values({}).returning(); - return c.json(created); + if (!created) throw new Error("Failed to create default settings"); + return c.json(redactSettings(created)); } - return c.json(row); + return c.json(redactSettings(row)); }); const hexColorRegex = /^#[0-9a-fA-F]{6}$/; diff --git a/trigger-uat-1779751324.txt b/trigger-uat-1779751324.txt new file mode 100644 index 0000000..e69de29 From 2566fb8f20affc843ba0e91cfd652a95a82fe12e Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 06:27:17 +0000 Subject: [PATCH 06/34] Promote GRO-2294 to UAT: Route Optimization security hardening (#194) --- UAT_PLAYBOOK.md | 3 +- src/__tests__/geocodeBatchLimit.test.ts | 89 ++++++++++++++++++++++++ src/__tests__/settings.test.ts | 91 +++++++++++++++++++++++++ src/routes/clients.ts | 11 ++- src/routes/settings.ts | 16 ++++- 5 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 src/__tests__/geocodeBatchLimit.test.ts create mode 100644 src/__tests__/settings.test.ts diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 78b73f3..48082de 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -133,6 +133,7 @@ Geocoding turns a client's street address into `latitude`/`longitude` + `geocode | TC-API-2.11 | Geocode endpoint is manager-only | As **groomer** or **receptionist**, `POST /api/clients/{id}/geocode` | 403 Forbidden (role not permitted) | | TC-API-2.12 | Batch geocode un-geocoded clients | As manager, `POST /api/clients/geocode-batch?limit=10` on a DB with un-geocoded clients | 200 OK; body `{ provider, processed, geocoded, unresolved, errors, remaining, outcomes[] }`. `processed` ≤ 10; `remaining` reflects un-geocoded clients beyond this batch. Re-run while `remaining > 0` to finish (throttled to provider rate limit) | | TC-API-2.13 | Batch geocode — invalid limit | As manager, `POST /api/clients/geocode-batch?limit=0` (or non-numeric) | 400 `{ error: "limit must be a positive integer" }` | +| TC-API-2.13a | Batch geocode — `?limit` cap enforced (GRO-2294) | As manager, `POST /api/clients/geocode-batch?limit=100000` on a DB with un-geocoded clients | 200 OK; the request is **clamped to the documented max of 500** — `processed` ≤ 500 (never the raw 100000). A fractional `?limit` (e.g. `49.9`) is floored to `49`. Confirms a manager cannot hold one synchronous request open / accrue unbounded Google API cost via an oversized limit | | TC-API-2.14 | Batch geocode — manager-only | As groomer/receptionist, `POST /api/clients/geocode-batch` | 403 Forbidden | | TC-API-2.15 | Auto-geocode on create | As manager/receptionist, `POST /api/clients` with a valid `address` | 201 Created; response includes a `geocoding` object (`status: "geocoded"` for a resolvable address) and the persisted client carries `latitude`/`longitude`/`geocodedAt`. Creating without an address succeeds with no `geocoding` field | | TC-API-2.16 | Auto-geocode on address update | As manager/receptionist, `PATCH /api/clients/{id}` changing `address` to a new valid value | 200 OK; response includes a `geocoding` object and refreshed coordinates. Patching unrelated fields (e.g. `name`) does NOT re-geocode (no `geocoding` field) | @@ -331,7 +332,7 @@ This means: | # | Scenario | Steps | Expected | |---|----------|-------|----------| -| TC-API-13.1 | Get business settings | GET /api/admin/settings | 200 OK, business settings returned | +| TC-API-13.1 | Get business settings | GET /api/admin/settings | 200 OK, business settings returned. Response body **must NOT include `googleMapsApiKey`** — the encrypted secret is redacted from the projection (GRO-2294, defense-in-depth); non-secret fields (`businessName`, colors, `routeOptimizationProvider`, etc.) are still present | | TC-API-13.2 | Update business settings | PATCH /api/admin/settings with updated values | 200 OK, settings updated | | TC-API-13.3 | Upload logo | POST /api/admin/settings/logo/upload with file | 200 OK, logo uploaded and stored | | TC-API-13.4 | View logo | GET /api/admin/settings/logo | 200 OK, logo image returned | diff --git a/src/__tests__/geocodeBatchLimit.test.ts b/src/__tests__/geocodeBatchLimit.test.ts new file mode 100644 index 0000000..8731c02 --- /dev/null +++ b/src/__tests__/geocodeBatchLimit.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +// ─── Mocks ────────────────────────────────────────────────────────────────── +// GRO-2294: the POST /clients/geocode-batch handler must clamp ?limit to the +// documented maximum (500) before invoking the geocoding service. We mock the +// service to capture the exact limit the route forwards. + +const geocodeUngeocodedClients = vi.fn(async () => ({ + totalRemaining: 0, + processed: 0, + geocoded: 0, + failed: 0, + remaining: 0, +})); + +vi.mock("../services/clientGeocoding.js", () => ({ + geocodeUngeocodedClients, + geocodeClient: vi.fn(), + resolveClientGeocodingProvider: vi.fn(), +})); + +vi.mock("@groombook/db", () => { + const tableProxy = (name: string) => + new Proxy( + { _name: name }, + { get: (_t, p) => (p === "_name" ? name : { table: name, column: p }) } + ); + return { + getDb: () => ({}), + clients: tableProxy("clients"), + appointments: tableProxy("appointments"), + and: vi.fn(), + eq: vi.fn(), + or: vi.fn(), + exists: vi.fn(), + }; +}); + +const { clientsRouter } = await import("../routes/clients.js"); + +const app = new Hono(); +app.route("/clients", clientsRouter); + +function postBatch(query: string) { + return app.request(`/clients/geocode-batch${query}`, { method: "POST" }); +} + +describe("POST /clients/geocode-batch — ?limit cap (GRO-2294)", () => { + beforeEach(() => { + geocodeUngeocodedClients.mockClear(); + }); + + it("defaults to 50 when no ?limit is supplied", async () => { + const res = await postBatch(""); + expect(res.status).toBe(200); + expect(geocodeUngeocodedClients).toHaveBeenCalledWith(expect.anything(), 50); + }); + + it("passes through a value within the cap", async () => { + const res = await postBatch("?limit=120"); + expect(res.status).toBe(200); + expect(geocodeUngeocodedClients).toHaveBeenCalledWith(expect.anything(), 120); + }); + + it("clamps an over-cap value to 500", async () => { + const res = await postBatch("?limit=100000"); + expect(res.status).toBe(200); + expect(geocodeUngeocodedClients).toHaveBeenCalledWith(expect.anything(), 500); + }); + + it("floors a fractional value before clamping", async () => { + const res = await postBatch("?limit=49.9"); + expect(res.status).toBe(200); + expect(geocodeUngeocodedClients).toHaveBeenCalledWith(expect.anything(), 49); + }); + + it("rejects a non-positive limit with 400", async () => { + const res = await postBatch("?limit=0"); + expect(res.status).toBe(400); + expect(geocodeUngeocodedClients).not.toHaveBeenCalled(); + }); + + it("rejects a non-numeric limit with 400", async () => { + const res = await postBatch("?limit=abc"); + expect(res.status).toBe(400); + expect(geocodeUngeocodedClients).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/settings.test.ts b/src/__tests__/settings.test.ts new file mode 100644 index 0000000..c878999 --- /dev/null +++ b/src/__tests__/settings.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +// ─── Mocks ────────────────────────────────────────────────────────────────── +// GRO-2294: GET /api/admin/settings must not return the encrypted +// googleMapsApiKey ciphertext, on either the existing-row or auto-create branch. + +let selectRows: Record[] = []; +let insertReturning: Record[] = []; + +function makeChainable(data: unknown[]): unknown { + const arr = [...data]; + const chain = new Proxy(arr, { + get(target, prop) { + if (prop === "where" || prop === "orderBy" || prop === "limit") { + return () => chain; + } + // @ts-expect-error proxy passthrough + return target[prop]; + }, + }); + return chain; +} + +vi.mock("@groombook/db", () => { + const businessSettings = new Proxy( + { _name: "business_settings" }, + { get: (_t, p) => (p === "_name" ? "business_settings" : { column: p }) } + ); + return { + getDb: () => ({ + select: () => ({ from: () => makeChainable(selectRows) }), + insert: () => ({ + values: () => ({ returning: () => insertReturning }), + }), + }), + businessSettings, + eq: vi.fn(), + }; +}); + +vi.mock("../lib/s3.js", () => ({ + getPresignedUploadUrl: vi.fn(), + deleteObject: vi.fn(), + putObject: vi.fn(), + getObject: vi.fn(), +})); + +const { settingsRouter } = await import("../routes/settings.js"); + +const app = new Hono(); +app.route("/settings", settingsRouter); + +const FULL_ROW = { + id: "settings-uuid-1", + businessName: "GroomBook", + primaryColor: "#4f8a6f", + accentColor: "#8b7355", + routeOptimizationProvider: "google", + googleMapsApiKey: "ENCRYPTED::super-secret-ciphertext", + createdAt: new Date(), + updatedAt: new Date(), +}; + +describe("GET /settings — googleMapsApiKey redaction (GRO-2294)", () => { + beforeEach(() => { + selectRows = []; + insertReturning = []; + }); + + it("omits googleMapsApiKey from an existing settings row", async () => { + selectRows = [{ ...FULL_ROW }]; + const res = await app.request("/settings", { method: "GET" }); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).not.toHaveProperty("googleMapsApiKey"); + // Non-secret fields are still returned. + expect(body.businessName).toBe("GroomBook"); + expect(body.routeOptimizationProvider).toBe("google"); + }); + + it("omits googleMapsApiKey from the auto-create branch", async () => { + selectRows = []; + insertReturning = [{ ...FULL_ROW, id: "settings-uuid-new" }]; + const res = await app.request("/settings", { method: "GET" }); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).not.toHaveProperty("googleMapsApiKey"); + expect(body.id).toBe("settings-uuid-new"); + }); +}); diff --git a/src/routes/clients.ts b/src/routes/clients.ts index e7ac65c..328ed31 100644 --- a/src/routes/clients.ts +++ b/src/routes/clients.ts @@ -12,6 +12,12 @@ import { export const clientsRouter = new Hono(); +// Batch-geocode bounds (GRO-2294): default 50, hard cap 500. The cap bounds how +// long one synchronous request stays open and the per-request external API cost +// when routeOptimizationProvider = "google". +const GEOCODE_BATCH_DEFAULT_LIMIT = 50; +const GEOCODE_BATCH_MAX_LIMIT = 500; + type ClientRow = typeof clients.$inferSelect; /** @@ -185,12 +191,15 @@ clientsRouter.post("/:clientId/geocode", async (c) => { clientsRouter.post("/geocode-batch", async (c) => { const db = getDb(); const limitRaw = c.req.query("limit"); - let limit = 50; + let limit = GEOCODE_BATCH_DEFAULT_LIMIT; if (limitRaw !== undefined) { limit = Number(limitRaw); if (!Number.isFinite(limit) || limit <= 0) { return c.json({ error: "limit must be a positive integer" }, 400); } + // Clamp to the documented maximum to bound synchronous request duration + // and (for the Google provider) per-request external API cost. + limit = Math.min(Math.floor(limit), GEOCODE_BATCH_MAX_LIMIT); } const summary = await geocodeUngeocodedClients(db, limit); return c.json(summary); diff --git a/src/routes/settings.ts b/src/routes/settings.ts index 3b931db..8529135 100644 --- a/src/routes/settings.ts +++ b/src/routes/settings.ts @@ -7,6 +7,17 @@ import { requireSuperUser } from "../middleware/rbac.js"; export const settingsRouter = new Hono(); +type BusinessSettingsRow = typeof businessSettings.$inferSelect; + +// Strip the encrypted googleMapsApiKey ciphertext from settings responses +// (GRO-2294, defense-in-depth). The secret is never needed client-side; it is +// only written via the dedicated provider-config endpoint. +function redactSettings(row: BusinessSettingsRow) { + const rest: Partial = { ...row }; + delete rest.googleMapsApiKey; + return rest; +} + // GET /api/admin/settings — return current business settings settingsRouter.get("/", async (c) => { const db = getDb(); @@ -14,9 +25,10 @@ settingsRouter.get("/", async (c) => { if (!row) { // Auto-create default settings if none exist const [created] = await db.insert(businessSettings).values({}).returning(); - return c.json(created); + if (!created) throw new Error("Failed to create default settings"); + return c.json(redactSettings(created)); } - return c.json(row); + return c.json(redactSettings(row)); }); const hexColorRegex = /^#[0-9a-fA-F]{6}$/; From b4b48f7b50d8ef9f5129024b2b65bb51d2ead802 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 06:52:48 +0000 Subject: [PATCH 07/34] fix(GRO-2299): redact googleMapsApiKey from PATCH /api/admin/settings response (#195) --- UAT_PLAYBOOK.md | 2 +- src/__tests__/settings.test.ts | 54 ++++++++++++++++++++++++++++++++++ src/routes/settings.ts | 3 +- 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 48082de..ecccc77 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -333,7 +333,7 @@ This means: | # | Scenario | Steps | Expected | |---|----------|-------|----------| | TC-API-13.1 | Get business settings | GET /api/admin/settings | 200 OK, business settings returned. Response body **must NOT include `googleMapsApiKey`** — the encrypted secret is redacted from the projection (GRO-2294, defense-in-depth); non-secret fields (`businessName`, colors, `routeOptimizationProvider`, etc.) are still present | -| TC-API-13.2 | Update business settings | PATCH /api/admin/settings with updated values | 200 OK, settings updated | +| TC-API-13.2 | Update business settings | PATCH /api/admin/settings with updated values | 200 OK, settings updated. Response body **must NOT include `googleMapsApiKey`** — the encrypted secret is redacted from the PATCH response symmetrically with the GET projection (GRO-2299, defense-in-depth); non-secret updated fields are still returned | | TC-API-13.3 | Upload logo | POST /api/admin/settings/logo/upload with file | 200 OK, logo uploaded and stored | | TC-API-13.4 | View logo | GET /api/admin/settings/logo | 200 OK, logo image returned | | TC-API-13.5 | Delete logo | DELETE /api/admin/settings/logo | 200 OK, logo removed | diff --git a/src/__tests__/settings.test.ts b/src/__tests__/settings.test.ts index c878999..5cdccca 100644 --- a/src/__tests__/settings.test.ts +++ b/src/__tests__/settings.test.ts @@ -7,6 +7,7 @@ import { Hono } from "hono"; let selectRows: Record[] = []; let insertReturning: Record[] = []; +let updateReturning: Record[] = []; function makeChainable(data: unknown[]): unknown { const arr = [...data]; @@ -33,6 +34,9 @@ vi.mock("@groombook/db", () => { insert: () => ({ values: () => ({ returning: () => insertReturning }), }), + update: () => ({ + set: () => ({ where: () => ({ returning: () => updateReturning }) }), + }), }), businessSettings, eq: vi.fn(), @@ -51,6 +55,17 @@ const { settingsRouter } = await import("../routes/settings.js"); const app = new Hono(); app.route("/settings", settingsRouter); +// PATCH /settings is guarded by requireSuperUser(), which reads the staff record +// from context. Inject a super-user staff row so the handler runs. +const patchApp = new Hono<{ + Variables: { staff: { id: string; isSuperUser: boolean } }; +}>(); +patchApp.use("*", async (c, next) => { + c.set("staff", { id: "staff-1", isSuperUser: true }); + await next(); +}); +patchApp.route("/settings", settingsRouter); + const FULL_ROW = { id: "settings-uuid-1", businessName: "GroomBook", @@ -89,3 +104,42 @@ describe("GET /settings — googleMapsApiKey redaction (GRO-2294)", () => { expect(body.id).toBe("settings-uuid-new"); }); }); + +describe("PATCH /settings — googleMapsApiKey redaction (GRO-2299)", () => { + beforeEach(() => { + selectRows = []; + insertReturning = []; + updateReturning = []; + }); + + function patchRequest(body: Record) { + return patchApp.request("/settings", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + } + + it("omits googleMapsApiKey from the PATCH response", async () => { + selectRows = [{ ...FULL_ROW }]; + updateReturning = [{ ...FULL_ROW, businessName: "Updated Name" }]; + const res = await patchRequest({ businessName: "Updated Name" }); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).not.toHaveProperty("googleMapsApiKey"); + // Non-secret updated fields are still returned. + expect(body.businessName).toBe("Updated Name"); + expect(body.routeOptimizationProvider).toBe("google"); + }); + + it("omits googleMapsApiKey on the auto-create-then-update branch", async () => { + selectRows = []; + insertReturning = [{ ...FULL_ROW, id: "settings-uuid-new" }]; + updateReturning = [{ ...FULL_ROW, id: "settings-uuid-new" }]; + const res = await patchRequest({ primaryColor: "#123456" }); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).not.toHaveProperty("googleMapsApiKey"); + expect(body.id).toBe("settings-uuid-new"); + }); +}); diff --git a/src/routes/settings.ts b/src/routes/settings.ts index 8529135..bcb4476 100644 --- a/src/routes/settings.ts +++ b/src/routes/settings.ts @@ -65,7 +65,8 @@ settingsRouter.patch( .where(eq(businessSettings.id, settingsId)) .returning(); - return c.json(updated); + if (!updated) throw new Error("Failed to update settings"); + return c.json(redactSettings(updated)); } ); From 8cd5a2ef4db1b5a1913c52d351e0e26e03ac629c Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 06:58:39 +0000 Subject: [PATCH 08/34] =?UTF-8?q?dev=20=E2=86=92=20uat:=20GRO-2299=20redac?= =?UTF-8?q?t=20googleMapsApiKey=20from=20PATCH=20/api/admin/settings=20(#1?= =?UTF-8?q?96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- UAT_PLAYBOOK.md | 2 +- src/__tests__/settings.test.ts | 54 ++++++++++++++++++++++++++++++++++ src/routes/settings.ts | 3 +- 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 48082de..ecccc77 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -333,7 +333,7 @@ This means: | # | Scenario | Steps | Expected | |---|----------|-------|----------| | TC-API-13.1 | Get business settings | GET /api/admin/settings | 200 OK, business settings returned. Response body **must NOT include `googleMapsApiKey`** — the encrypted secret is redacted from the projection (GRO-2294, defense-in-depth); non-secret fields (`businessName`, colors, `routeOptimizationProvider`, etc.) are still present | -| TC-API-13.2 | Update business settings | PATCH /api/admin/settings with updated values | 200 OK, settings updated | +| TC-API-13.2 | Update business settings | PATCH /api/admin/settings with updated values | 200 OK, settings updated. Response body **must NOT include `googleMapsApiKey`** — the encrypted secret is redacted from the PATCH response symmetrically with the GET projection (GRO-2299, defense-in-depth); non-secret updated fields are still returned | | TC-API-13.3 | Upload logo | POST /api/admin/settings/logo/upload with file | 200 OK, logo uploaded and stored | | TC-API-13.4 | View logo | GET /api/admin/settings/logo | 200 OK, logo image returned | | TC-API-13.5 | Delete logo | DELETE /api/admin/settings/logo | 200 OK, logo removed | diff --git a/src/__tests__/settings.test.ts b/src/__tests__/settings.test.ts index c878999..5cdccca 100644 --- a/src/__tests__/settings.test.ts +++ b/src/__tests__/settings.test.ts @@ -7,6 +7,7 @@ import { Hono } from "hono"; let selectRows: Record[] = []; let insertReturning: Record[] = []; +let updateReturning: Record[] = []; function makeChainable(data: unknown[]): unknown { const arr = [...data]; @@ -33,6 +34,9 @@ vi.mock("@groombook/db", () => { insert: () => ({ values: () => ({ returning: () => insertReturning }), }), + update: () => ({ + set: () => ({ where: () => ({ returning: () => updateReturning }) }), + }), }), businessSettings, eq: vi.fn(), @@ -51,6 +55,17 @@ const { settingsRouter } = await import("../routes/settings.js"); const app = new Hono(); app.route("/settings", settingsRouter); +// PATCH /settings is guarded by requireSuperUser(), which reads the staff record +// from context. Inject a super-user staff row so the handler runs. +const patchApp = new Hono<{ + Variables: { staff: { id: string; isSuperUser: boolean } }; +}>(); +patchApp.use("*", async (c, next) => { + c.set("staff", { id: "staff-1", isSuperUser: true }); + await next(); +}); +patchApp.route("/settings", settingsRouter); + const FULL_ROW = { id: "settings-uuid-1", businessName: "GroomBook", @@ -89,3 +104,42 @@ describe("GET /settings — googleMapsApiKey redaction (GRO-2294)", () => { expect(body.id).toBe("settings-uuid-new"); }); }); + +describe("PATCH /settings — googleMapsApiKey redaction (GRO-2299)", () => { + beforeEach(() => { + selectRows = []; + insertReturning = []; + updateReturning = []; + }); + + function patchRequest(body: Record) { + return patchApp.request("/settings", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + } + + it("omits googleMapsApiKey from the PATCH response", async () => { + selectRows = [{ ...FULL_ROW }]; + updateReturning = [{ ...FULL_ROW, businessName: "Updated Name" }]; + const res = await patchRequest({ businessName: "Updated Name" }); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).not.toHaveProperty("googleMapsApiKey"); + // Non-secret updated fields are still returned. + expect(body.businessName).toBe("Updated Name"); + expect(body.routeOptimizationProvider).toBe("google"); + }); + + it("omits googleMapsApiKey on the auto-create-then-update branch", async () => { + selectRows = []; + insertReturning = [{ ...FULL_ROW, id: "settings-uuid-new" }]; + updateReturning = [{ ...FULL_ROW, id: "settings-uuid-new" }]; + const res = await patchRequest({ primaryColor: "#123456" }); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).not.toHaveProperty("googleMapsApiKey"); + expect(body.id).toBe("settings-uuid-new"); + }); +}); diff --git a/src/routes/settings.ts b/src/routes/settings.ts index 8529135..bcb4476 100644 --- a/src/routes/settings.ts +++ b/src/routes/settings.ts @@ -65,7 +65,8 @@ settingsRouter.patch( .where(eq(businessSettings.id, settingsId)) .returning(); - return c.json(updated); + if (!updated) throw new Error("Failed to update settings"); + return c.json(redactSettings(updated)); } ); From 1e0747324d5c1c74033d0a1323d6f472573c0bc2 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 08:44:58 +0000 Subject: [PATCH 09/34] =?UTF-8?q?fix(GRO-2139):=20serialize=20reset?= =?UTF-8?q?=E2=86=92migrate=E2=86=92seed=20under=20the=20seed=20advisory?= =?UTF-8?q?=20lock=20(#160)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serialize the entire db:reset chain (DROP → migrate → seed) inside one withSeedAdvisoryLock callback so a concurrent same-PRNG seeder cannot interleave and collide on invoices_pkey. Pool sized max:6 (1 reserved for the lock + work headroom) to avoid the connection-starvation deadlock the CTO caught. Verified with three end-to-end live db:reset runs against a throwaway Postgres. cc @cpfarhood --- packages/db/package.json | 2 +- packages/db/src/reset.ts | 153 +++++++++++++++++++++++++++++---------- packages/db/src/seed.ts | 14 ++-- 3 files changed, 123 insertions(+), 46 deletions(-) diff --git a/packages/db/package.json b/packages/db/package.json index 7f97370..cb3811f 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -21,7 +21,7 @@ "wait-for-db": "node ./scripts/wait-for-db.mjs", "migrate": "node ./scripts/wait-for-db.mjs && drizzle-kit migrate", "seed": "node ./scripts/wait-for-db.mjs && tsx src/seed.ts", - "reset": "node ./scripts/wait-for-db.mjs && tsx src/reset.ts && drizzle-kit migrate && tsx src/seed.ts", + "reset": "node ./scripts/wait-for-db.mjs && tsx src/reset.ts", "studio": "drizzle-kit studio", "typecheck": "tsc --noEmit" }, diff --git a/packages/db/src/reset.ts b/packages/db/src/reset.ts index 41c3ce8..fb88e20 100644 --- a/packages/db/src/reset.ts +++ b/packages/db/src/reset.ts @@ -1,13 +1,52 @@ /** - * reset.ts — Drop all application tables and re-run migrations + seed. + * reset.ts — Drop all application tables, re-run migrations, and re-seed. * * Intended for local development only. Never run against production. * * Usage: * DATABASE_URL=postgres://... npx tsx packages/db/src/reset.ts + * + * GRO-2139: the entire drop→migrate→seed chain runs inside a single + * Postgres advisory lock (SEED_ADVISORY_LOCK_KEY) so a concurrent + * `seed.ts` (e.g. the dev `seed-test-data-*` Job being recreated at + * the top of the hour) cannot interleave between `reset.ts` (DROP) + * and `seed.ts` (TRUNCATE+insert) and collide on `invoices_pkey`. + * + * Why this matters: `seed.ts` derives every primary key from a single + * shared Mulberry32 PRNG seeded with 42 (see `createPrng(42)` and + * `uuid()` in seed.ts). Two concurrent same-profile seeders therefore + * emit *identical* ids for the same logical row, and any moment + * between a concurrent `seed.ts` TRUNCATE and INSERT is exactly the + * window in which the second seeder's INSERT can hit a pkey already + * taken by the first. Pre-GRO-2123 this raced unconditionally; + * GRO-2123 added the advisory lock around `runSeedBody` but left + * `reset.ts` and `drizzle-kit migrate` outside the lock. This script + * now wraps the *whole* chain in the same lock: `withSeedAdvisoryLock` + * pins the lock to one reserved session and the DROP → migrate → seed + * work runs on the rest of the pool, so the lock guarantees mutual + * exclusion against any concurrent seeder for the entire chain. + * + * See: groombook/infra `apps/base/reset-cronjob.yaml` (CronJob) and + * `apps/base/seed-job.yaml` (one-shot Job) — both invoke the same + * `seed.ts` code path on the same database in `groombook-dev`. */ - import postgres from "postgres"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { migrate } from "drizzle-orm/postgres-js/migrator"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import * as schema from "./schema.js"; +import { + SEED_ADVISORY_LOCK_KEY, + withSeedAdvisoryLock, + getProfile, + runSeedBody, + profiles, +} from "./seed.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const MIGRATIONS_FOLDER = resolve(__dirname, "../migrations"); async function reset() { const url = process.env.DATABASE_URL; @@ -16,52 +55,88 @@ async function reset() { process.exit(1); } - if (process.env.NODE_ENV === "production" && process.env.ALLOW_RESET !== "true") { - console.error("[FATAL] db:reset must not be run in production without ALLOW_RESET=true."); + if ( + process.env.NODE_ENV === "production" && + process.env.ALLOW_RESET !== "true" + ) { + console.error( + "[FATAL] db:reset must not be run in production without ALLOW_RESET=true.", + ); process.exit(1); } - const client = postgres(url, { max: 1 }); + // Pool sizing is load-bearing here. `withSeedAdvisoryLock` does + // `pool.reserve()` to pin the advisory lock to one dedicated session + // (a session-level lock released on a *different* pooled connection is + // a no-op), and the DROP / migrate / seed work then runs on the + // *remaining* pooled connections. The lock provides mutual exclusion + // across processes regardless of how many connections the work uses — + // it does NOT require the work to share the lock's session. + // + // Therefore `max` must be ≥ 2: 1 reserved for the lock + ≥1 free for + // the work. `max: 1` would let `reserve()` consume the only connection + // and every query inside the callback would block forever waiting for + // a connection that never frees (connection-starvation deadlock). We + // use `max: 6` to match `seed()`'s headroom (1 reserved + 5 work). + const client = postgres(url, { max: 6 }); + const db = drizzle(client, { schema }); - console.log("Dropping all application tables...\n"); + try { + await withSeedAdvisoryLock(client, async () => { + console.log("Dropping all application tables...\n"); - // Drop in dependency order (children before parents) - await client` - DO $$ DECLARE - r RECORD; - BEGIN - FOR r IN ( - SELECT tablename FROM pg_tables - WHERE schemaname = 'public' - ) LOOP - EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE'; - END LOOP; - END $$; - `; + // Drop dependencies (tables) first + await client` + DO $$ DECLARE + r RECORD; + BEGIN + FOR r IN ( + SELECT tablename FROM pg_tables + WHERE schemaname = 'public' + ) LOOP + EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE'; + END LOOP; + END $$; + `; - // Drop custom enums - await client` - DO $$ DECLARE - r RECORD; - BEGIN - FOR r IN ( - SELECT typname FROM pg_type - WHERE typtype = 'e' AND typnamespace = ( - SELECT oid FROM pg_namespace WHERE nspname = 'public' - ) - ) LOOP - EXECUTE 'DROP TYPE IF EXISTS ' || quote_ident(r.typname) || ' CASCADE'; - END LOOP; - END $$; - `; + // Drop custom enums + await client` + DO $$ DECLARE + r RECORD; + BEGIN + FOR r IN ( + SELECT typname FROM pg_type + WHERE typtype = 'e' AND typnamespace = ( + SELECT oid FROM pg_namespace WHERE nspname = 'public' + ) + ) LOOP + EXECUTE 'DROP TYPE IF EXISTS ' || quote_ident(r.typname) || ' CASCADE'; + END LOOP; + END $$; + `; - // Drop the drizzle migrations tracking table - await client`DROP TABLE IF EXISTS drizzle.__drizzle_migrations CASCADE`; - await client`DROP SCHEMA IF EXISTS drizzle CASCADE`; + // Drop the drizzle migrations tracking table + await client`DROP TABLE IF EXISTS drizzle.__drizzle_migrations CASCADE`; + await client`DROP SCHEMA IF EXISTS drizzle CASCADE`; - console.log("✓ All tables and enums dropped\n"); + console.log("✓ All tables and enums dropped\n"); - await client.end(); + console.log("Running migrations..."); + await migrate(db, { migrationsFolder: MIGRATIONS_FOLDER }); + console.log("✓ Migrations applied\n"); + + console.log("Seeding database..."); + const profile = getProfile(); + const cfg = profiles[profile]; + await runSeedBody(client, db, profile, cfg); + }); + + console.log( + `\n✓ Reset complete (advisory lock key=0x${SEED_ADVISORY_LOCK_KEY.toString(16)})`, + ); + } finally { + await client.end(); + } } reset().catch((err) => { diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 55b2ee4..b519c04 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -24,9 +24,9 @@ import type { MedicalAlert } from "@groombook/types"; // ── Seed profile configuration ───────────────────────────────────────────── -type SeedProfile = "dev" | "uat" | "demo"; +export type SeedProfile = "dev" | "uat" | "demo"; -interface ProfileConfig { +export interface ProfileConfig { staffCount: { manager: number; receptionist: number; groomer: number; bather: number }; clientCount: number; appointmentsBackDays: number; @@ -35,7 +35,7 @@ interface ProfileConfig { includeUatClients: boolean; } -const profiles: Record = { +export const profiles: Record = { dev: { staffCount: { manager: 1, receptionist: 1, groomer: 2, bather: 0 }, clientCount: 100, @@ -70,6 +70,8 @@ function getProfile(): SeedProfile { return "uat"; } +export { getProfile }; + // ── Deterministic PRNG (Mulberry32) ────────────────────────────────────────── /** @@ -1194,7 +1196,7 @@ async function seedKnownUsers() { // from runbooks without ambiguity and binds to the single-argument // `pg_advisory_lock(int)` form, which postgres-js serializes as a plain // number (no bigint type plumbing required). -const SEED_ADVISORY_LOCK_KEY = 0x47524f4f; // "GROO" in ASCII — arbitrary, stable +export const SEED_ADVISORY_LOCK_KEY = 0x47524f4f; // "GROO" in ASCII — arbitrary, stable /** * Reserve a dedicated connection from `pool`, take the seed advisory lock @@ -1207,7 +1209,7 @@ const SEED_ADVISORY_LOCK_KEY = 0x47524f4f; // "GROO" in ASCII — arbitrary, sta * for the lock and release it from the same reserved connection. The * seed work itself still runs on the pooled connections. */ -async function withSeedAdvisoryLock( +export async function withSeedAdvisoryLock( pool: ReturnType, fn: () => Promise, ): Promise { @@ -1265,7 +1267,7 @@ async function seed() { await client.end(); } -async function runSeedBody( +export async function runSeedBody( client: ReturnType, db: ReturnType, profile: SeedProfile, From 2853ce73a59e8a9513aa41bcfbc544f6d8daae2f Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 08:56:22 +0000 Subject: [PATCH 10/34] GRO-2172: add missing extended pet fields to create/update schemas (#199) --- src/routes/pets.ts | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/routes/pets.ts b/src/routes/pets.ts index 5c4aaec..229a047 100644 --- a/src/routes/pets.ts +++ b/src/routes/pets.ts @@ -57,6 +57,23 @@ const createPetSchema = z.object({ customFields: z.record(z.string(), z.string()).optional(), petSizeCategory: z.enum(["small", "medium", "large", "extra_large"]).optional(), coatType: z.enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]).optional(), + // Extended pet profile fields (api/#39, GRO-1178). + // GRO-2172: these were missing from the schema, causing POST/PATCH to + // silently drop them even though migrations 0034/0036 and seed data + // populate them. GRO-1472 was the original UAT regression. + temperamentScore: z.number().int().min(1).max(5).optional(), + temperamentFlags: z.array(z.string().max(100)).max(20).optional(), + medicalAlerts: z + .array( + z.object({ + type: z.string().max(100), + description: z.string().max(1000), + severity: z.enum(["low", "medium", "high"]), + }) + ) + .max(50) + .optional(), + preferredCuts: z.array(z.string().max(200)).max(20).optional(), }); const updatePetSchema = createPetSchema.partial().omit({ clientId: true }); @@ -333,7 +350,8 @@ petsRouter.get("/:id/profile-summary", async (c) => { petsRouter.post("/", zValidator("json", createPetSchema), async (c) => { const db = getDb(); - const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json"); + const { weightKg, dateOfBirth, customFields, medicalAlerts, ...rest } = + c.req.valid("json"); const [row] = await db .insert(pets) .values({ @@ -341,6 +359,10 @@ petsRouter.post("/", zValidator("json", createPetSchema), async (c) => { weightKg: weightKg?.toString(), dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined, customFields: customFields ?? {}, + // GRO-2172: medicalAlerts shape from the API request is + // { type, description, severity } — the @groombook/types MedicalAlert + // has an optional server-generated `id`, so cast for the jsonb column. + medicalAlerts: medicalAlerts as never, }) .returning(); return c.json(row, 201); @@ -351,7 +373,8 @@ petsRouter.patch( zValidator("json", updatePetSchema), async (c) => { const db = getDb(); - const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json"); + const { weightKg, dateOfBirth, customFields, medicalAlerts, ...rest } = + c.req.valid("json"); const [row] = await db .update(pets) .set({ @@ -359,6 +382,7 @@ petsRouter.patch( weightKg: weightKg?.toString(), dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined, ...(customFields !== undefined ? { customFields } : {}), + medicalAlerts: medicalAlerts as never, updatedAt: new Date(), }) .where(eq(pets.id, c.req.param("id"))) From c4385617c63d97933d6109aed7d1fb35ea5d1420 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 09:22:12 +0000 Subject: [PATCH 11/34] =?UTF-8?q?dev=20=E2=86=92=20uat:=20GRO-2172=20exten?= =?UTF-8?q?ded=20pet=20fields=20(#200)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/pets.ts | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/routes/pets.ts b/src/routes/pets.ts index 5c4aaec..229a047 100644 --- a/src/routes/pets.ts +++ b/src/routes/pets.ts @@ -57,6 +57,23 @@ const createPetSchema = z.object({ customFields: z.record(z.string(), z.string()).optional(), petSizeCategory: z.enum(["small", "medium", "large", "extra_large"]).optional(), coatType: z.enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]).optional(), + // Extended pet profile fields (api/#39, GRO-1178). + // GRO-2172: these were missing from the schema, causing POST/PATCH to + // silently drop them even though migrations 0034/0036 and seed data + // populate them. GRO-1472 was the original UAT regression. + temperamentScore: z.number().int().min(1).max(5).optional(), + temperamentFlags: z.array(z.string().max(100)).max(20).optional(), + medicalAlerts: z + .array( + z.object({ + type: z.string().max(100), + description: z.string().max(1000), + severity: z.enum(["low", "medium", "high"]), + }) + ) + .max(50) + .optional(), + preferredCuts: z.array(z.string().max(200)).max(20).optional(), }); const updatePetSchema = createPetSchema.partial().omit({ clientId: true }); @@ -333,7 +350,8 @@ petsRouter.get("/:id/profile-summary", async (c) => { petsRouter.post("/", zValidator("json", createPetSchema), async (c) => { const db = getDb(); - const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json"); + const { weightKg, dateOfBirth, customFields, medicalAlerts, ...rest } = + c.req.valid("json"); const [row] = await db .insert(pets) .values({ @@ -341,6 +359,10 @@ petsRouter.post("/", zValidator("json", createPetSchema), async (c) => { weightKg: weightKg?.toString(), dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined, customFields: customFields ?? {}, + // GRO-2172: medicalAlerts shape from the API request is + // { type, description, severity } — the @groombook/types MedicalAlert + // has an optional server-generated `id`, so cast for the jsonb column. + medicalAlerts: medicalAlerts as never, }) .returning(); return c.json(row, 201); @@ -351,7 +373,8 @@ petsRouter.patch( zValidator("json", updatePetSchema), async (c) => { const db = getDb(); - const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json"); + const { weightKg, dateOfBirth, customFields, medicalAlerts, ...rest } = + c.req.valid("json"); const [row] = await db .update(pets) .set({ @@ -359,6 +382,7 @@ petsRouter.patch( weightKg: weightKg?.toString(), dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined, ...(customFields !== undefined ? { customFields } : {}), + medicalAlerts: medicalAlerts as never, updatedAt: new Date(), }) .where(eq(pets.id, c.req.param("id"))) From d61607f4c5f24e3b5524196d87bc60f4e985634b Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 09:53:04 +0000 Subject: [PATCH 12/34] feat(seed): seed upcoming appointments across statuses for UAT portal customer (GRO-2311) (#201) --- packages/db/src/seed.ts | 170 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index b519c04..f6bdf4c 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -832,6 +832,168 @@ async function seedUatGroomerLinkage( ); } +// ── GRO-2311 / GRO-2313: portal customer StatusBadge coverage ──────────────── + +/** + * GRO-2311 / GRO-2313: give the UAT portal customer (`uat-customer@groombook.dev`) + * a deterministic spread of appointments so the customer-portal StatusBadge + * palette can be LIVE-observed (not just code-verified against the bundle). + * + * Scope is the subset of badge states reachable from the `appointment_status` + * enum (`scheduled, confirmed, in_progress, completed, cancelled, no_show`) — + * the portal's renders `appointment.status` verbatim. `pending` + * and `waitlisted` are NOT valid appointment statuses and cannot be seeded; the + * styled `no_show`→`no-show` badge fix and any pending/waitlisted derivation are + * tracked separately in GRO-2319 (web). CTO-approved Option A on GRO-2313. + * + * - confirmed → future startTime → renders as an Upcoming card (Confirmed badge) + * - scheduled → future startTime → renders as an Upcoming card (Scheduled badge) + * - cancelled → past startTime → Past tab (isUpcoming excludes cancelled) + * - no_show → past startTime → Past tab (raw `no_show` label until GRO-2319) + * + * The existing GRO-2100 `completed` appointment (a0000001-…-0001) is left + * untouched (AC #4), so Completed is also covered. + * + * Idempotent: each appointment uses a fixed UUID and is upserted with + * onConflictDoNothing, so the hourly reset-demo-data CronJob (which TRUNCATEs + * then re-seeds) and non-truncating dev re-seeds never dup-key + * (see GRO-2033 for the dup-key class). + */ +async function seedUatCustomerPortalAppointments( + db: ReturnType, + customerClientId: string | null, +): Promise { + const LINKED_PET_ID = "c0000001-0000-0000-0000-000000000002"; // UAT Pup Alpha + + // Skip silently outside the UAT persona profile (e.g. a dev/test seed that + // never created the UAT Customer client). + if (!customerClientId) { + return; + } + + // The customer's pet must exist (pets are NOT truncated on reset, so this is + // stable). Defensive: bail cleanly if the persona pet is absent. + const [linkedPet] = await db + .select({ id: schema.pets.id }) + .from(schema.pets) + .where(eq(schema.pets.id, LINKED_PET_ID)) + .limit(1); + if (!linkedPet) { + console.warn(`⚠ GRO-2311: UAT Pup Alpha (${LINKED_PET_ID}) not found — skipping portal appointment seed`); + return; + } + + // Stable "Bath & Brush" service; fall back to any active service. + const BATH_AND_BRUSH_ID = "b0000001-0000-0000-0000-000000000001"; + const [bathService] = await db + .select({ id: schema.services.id }) + .from(schema.services) + .where(eq(schema.services.id, BATH_AND_BRUSH_ID)) + .limit(1); + + let serviceId: string; + if (bathService) { + serviceId = bathService.id; + } else { + const [fallback] = await db + .select({ id: schema.services.id }) + .from(schema.services) + .where(eq(schema.services.active, true)) + .limit(1); + if (!fallback) { + console.warn(`⚠ GRO-2311: no active services found — skipping portal appointment seed`); + return; + } + serviceId = fallback.id; + } + + // Attach the UAT groomer when present (nicer "with " card); else null + // ("First Available"). Either way these are the customer's own appointments — + // no new groomer↔pet linkage invariant is created (uses the already-linked + // Pup Alpha), so GRO-1987 TC-UAT-3 (403 on the UNLINKED Pup Beta) is unaffected. + const [uatGroomerStaff] = await db + .select({ id: schema.staff.id }) + .from(schema.staff) + .where(eq(schema.staff.email, "uat-groomer@groombook.dev")) + .limit(1); + const staffId = uatGroomerStaff?.id ?? null; + + // Anchor all times to local wall-clock so future/past holds regardless of the + // hourly reset cadence. + const at = (deltaDays: number, hour: number): Date => { + const d = new Date(); + d.setDate(d.getDate() + deltaDays); + d.setHours(hour, 0, 0, 0); + return d; + }; + const DURATION_MS = 45 * 60 * 1000; + + const rows = [ + { + id: "a0000001-0000-0000-0000-000000000002", + status: "confirmed" as const, + start: at(3, 10), + confirmationStatus: "confirmed", + confirmedAt: new Date(), + cancelledAt: null as Date | null, + notes: "GRO-2311: upcoming confirmed appointment for portal StatusBadge coverage.", + }, + { + id: "a0000001-0000-0000-0000-000000000003", + status: "scheduled" as const, + start: at(5, 14), + confirmationStatus: "pending", + confirmedAt: null as Date | null, + cancelledAt: null as Date | null, + notes: "GRO-2311: upcoming scheduled appointment for portal StatusBadge coverage.", + }, + { + id: "a0000001-0000-0000-0000-000000000004", + status: "cancelled" as const, + start: at(-3, 11), + confirmationStatus: "cancelled", + confirmedAt: null as Date | null, + cancelledAt: new Date(), + notes: "GRO-2311: cancelled appointment (Past tab) for portal StatusBadge coverage.", + }, + { + id: "a0000001-0000-0000-0000-000000000005", + status: "no_show" as const, + start: at(-10, 9), + confirmationStatus: "confirmed", + confirmedAt: null as Date | null, + cancelledAt: null as Date | null, + notes: "GRO-2311: no_show appointment (Past tab) for portal StatusBadge coverage.", + }, + ]; + + await db + .insert(schema.appointments) + .values( + rows.map((r) => ({ + id: r.id, + clientId: customerClientId, + petId: LINKED_PET_ID, + serviceId, + staffId, + batherStaffId: null, + status: r.status, + startTime: r.start, + endTime: new Date(r.start.getTime() + DURATION_MS), + notes: r.notes, + priceCents: null, + confirmationStatus: r.confirmationStatus, + confirmedAt: r.confirmedAt, + cancelledAt: r.cancelledAt, + })), + ) + .onConflictDoNothing({ target: schema.appointments.id }); + + console.log( + `✓ GRO-2311: seeded ${rows.length} portal StatusBadge appointments (confirmed/scheduled/cancelled/no_show) for UAT customer`, + ); +} + // ── GRO-2225: deterministic route-optimization cohort ──────────────────────── /** @@ -1113,6 +1275,10 @@ async function seedKnownUsers() { // to attach to the appointment; on a fresh reset there are none yet at // the time seedUatStaffAccounts() returns). await seedUatGroomerLinkage(db, uatCustomerClientId); + // GRO-2311 / GRO-2313: portal customer StatusBadge palette coverage (reachable + // appointment statuses only). Runs after the groomer linkage so the customer + // client + Pup Alpha already exist. + await seedUatCustomerPortalAppointments(db, uatCustomerClientId); // ── Client: Demo Client ── const [existingClient] = await db @@ -1375,6 +1541,10 @@ export async function runSeedBody( // to attach to the appointment; on a fresh reset there are none yet at // the time seedUatStaffAccounts() returns). await seedUatGroomerLinkage(db, uatCustomerClientId); + // GRO-2311 / GRO-2313: portal customer StatusBadge palette coverage (reachable + // appointment statuses only). Runs after the groomer linkage so the customer + // client + Pup Alpha already exist. + await seedUatCustomerPortalAppointments(db, uatCustomerClientId); // GRO-2225: deterministic pre-geocoded route cohort + fixed-date appointments // for the UAT groomer. Must run AFTER services are seeded (it looks up a From 807ccb455fed2de98de7fe6e34ef2021b3925e69 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 09:56:34 +0000 Subject: [PATCH 13/34] =?UTF-8?q?dev=20=E2=86=92=20uat:=20GRO-2311=20seed?= =?UTF-8?q?=20portal=20StatusBadge=20appointments=20(#201)=20(#202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/db/src/seed.ts | 170 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 55b2ee4..24d3dc3 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -830,6 +830,168 @@ async function seedUatGroomerLinkage( ); } +// ── GRO-2311 / GRO-2313: portal customer StatusBadge coverage ──────────────── + +/** + * GRO-2311 / GRO-2313: give the UAT portal customer (`uat-customer@groombook.dev`) + * a deterministic spread of appointments so the customer-portal StatusBadge + * palette can be LIVE-observed (not just code-verified against the bundle). + * + * Scope is the subset of badge states reachable from the `appointment_status` + * enum (`scheduled, confirmed, in_progress, completed, cancelled, no_show`) — + * the portal's renders `appointment.status` verbatim. `pending` + * and `waitlisted` are NOT valid appointment statuses and cannot be seeded; the + * styled `no_show`→`no-show` badge fix and any pending/waitlisted derivation are + * tracked separately in GRO-2319 (web). CTO-approved Option A on GRO-2313. + * + * - confirmed → future startTime → renders as an Upcoming card (Confirmed badge) + * - scheduled → future startTime → renders as an Upcoming card (Scheduled badge) + * - cancelled → past startTime → Past tab (isUpcoming excludes cancelled) + * - no_show → past startTime → Past tab (raw `no_show` label until GRO-2319) + * + * The existing GRO-2100 `completed` appointment (a0000001-…-0001) is left + * untouched (AC #4), so Completed is also covered. + * + * Idempotent: each appointment uses a fixed UUID and is upserted with + * onConflictDoNothing, so the hourly reset-demo-data CronJob (which TRUNCATEs + * then re-seeds) and non-truncating dev re-seeds never dup-key + * (see GRO-2033 for the dup-key class). + */ +async function seedUatCustomerPortalAppointments( + db: ReturnType, + customerClientId: string | null, +): Promise { + const LINKED_PET_ID = "c0000001-0000-0000-0000-000000000002"; // UAT Pup Alpha + + // Skip silently outside the UAT persona profile (e.g. a dev/test seed that + // never created the UAT Customer client). + if (!customerClientId) { + return; + } + + // The customer's pet must exist (pets are NOT truncated on reset, so this is + // stable). Defensive: bail cleanly if the persona pet is absent. + const [linkedPet] = await db + .select({ id: schema.pets.id }) + .from(schema.pets) + .where(eq(schema.pets.id, LINKED_PET_ID)) + .limit(1); + if (!linkedPet) { + console.warn(`⚠ GRO-2311: UAT Pup Alpha (${LINKED_PET_ID}) not found — skipping portal appointment seed`); + return; + } + + // Stable "Bath & Brush" service; fall back to any active service. + const BATH_AND_BRUSH_ID = "b0000001-0000-0000-0000-000000000001"; + const [bathService] = await db + .select({ id: schema.services.id }) + .from(schema.services) + .where(eq(schema.services.id, BATH_AND_BRUSH_ID)) + .limit(1); + + let serviceId: string; + if (bathService) { + serviceId = bathService.id; + } else { + const [fallback] = await db + .select({ id: schema.services.id }) + .from(schema.services) + .where(eq(schema.services.active, true)) + .limit(1); + if (!fallback) { + console.warn(`⚠ GRO-2311: no active services found — skipping portal appointment seed`); + return; + } + serviceId = fallback.id; + } + + // Attach the UAT groomer when present (nicer "with " card); else null + // ("First Available"). Either way these are the customer's own appointments — + // no new groomer↔pet linkage invariant is created (uses the already-linked + // Pup Alpha), so GRO-1987 TC-UAT-3 (403 on the UNLINKED Pup Beta) is unaffected. + const [uatGroomerStaff] = await db + .select({ id: schema.staff.id }) + .from(schema.staff) + .where(eq(schema.staff.email, "uat-groomer@groombook.dev")) + .limit(1); + const staffId = uatGroomerStaff?.id ?? null; + + // Anchor all times to local wall-clock so future/past holds regardless of the + // hourly reset cadence. + const at = (deltaDays: number, hour: number): Date => { + const d = new Date(); + d.setDate(d.getDate() + deltaDays); + d.setHours(hour, 0, 0, 0); + return d; + }; + const DURATION_MS = 45 * 60 * 1000; + + const rows = [ + { + id: "a0000001-0000-0000-0000-000000000002", + status: "confirmed" as const, + start: at(3, 10), + confirmationStatus: "confirmed", + confirmedAt: new Date(), + cancelledAt: null as Date | null, + notes: "GRO-2311: upcoming confirmed appointment for portal StatusBadge coverage.", + }, + { + id: "a0000001-0000-0000-0000-000000000003", + status: "scheduled" as const, + start: at(5, 14), + confirmationStatus: "pending", + confirmedAt: null as Date | null, + cancelledAt: null as Date | null, + notes: "GRO-2311: upcoming scheduled appointment for portal StatusBadge coverage.", + }, + { + id: "a0000001-0000-0000-0000-000000000004", + status: "cancelled" as const, + start: at(-3, 11), + confirmationStatus: "cancelled", + confirmedAt: null as Date | null, + cancelledAt: new Date(), + notes: "GRO-2311: cancelled appointment (Past tab) for portal StatusBadge coverage.", + }, + { + id: "a0000001-0000-0000-0000-000000000005", + status: "no_show" as const, + start: at(-10, 9), + confirmationStatus: "confirmed", + confirmedAt: null as Date | null, + cancelledAt: null as Date | null, + notes: "GRO-2311: no_show appointment (Past tab) for portal StatusBadge coverage.", + }, + ]; + + await db + .insert(schema.appointments) + .values( + rows.map((r) => ({ + id: r.id, + clientId: customerClientId, + petId: LINKED_PET_ID, + serviceId, + staffId, + batherStaffId: null, + status: r.status, + startTime: r.start, + endTime: new Date(r.start.getTime() + DURATION_MS), + notes: r.notes, + priceCents: null, + confirmationStatus: r.confirmationStatus, + confirmedAt: r.confirmedAt, + cancelledAt: r.cancelledAt, + })), + ) + .onConflictDoNothing({ target: schema.appointments.id }); + + console.log( + `✓ GRO-2311: seeded ${rows.length} portal StatusBadge appointments (confirmed/scheduled/cancelled/no_show) for UAT customer`, + ); +} + // ── GRO-2225: deterministic route-optimization cohort ──────────────────────── /** @@ -1111,6 +1273,10 @@ async function seedKnownUsers() { // to attach to the appointment; on a fresh reset there are none yet at // the time seedUatStaffAccounts() returns). await seedUatGroomerLinkage(db, uatCustomerClientId); + // GRO-2311 / GRO-2313: portal customer StatusBadge palette coverage (reachable + // appointment statuses only). Runs after the groomer linkage so the customer + // client + Pup Alpha already exist. + await seedUatCustomerPortalAppointments(db, uatCustomerClientId); // ── Client: Demo Client ── const [existingClient] = await db @@ -1373,6 +1539,10 @@ async function runSeedBody( // to attach to the appointment; on a fresh reset there are none yet at // the time seedUatStaffAccounts() returns). await seedUatGroomerLinkage(db, uatCustomerClientId); + // GRO-2311 / GRO-2313: portal customer StatusBadge palette coverage (reachable + // appointment statuses only). Runs after the groomer linkage so the customer + // client + Pup Alpha already exist. + await seedUatCustomerPortalAppointments(db, uatCustomerClientId); // GRO-2225: deterministic pre-geocoded route cohort + fixed-date appointments // for the UAT groomer. Must run AFTER services are seeded (it looks up a From ef18ed737693f2d1eb1bd7973aca4cc7e9bd399e Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 10:41:08 +0000 Subject: [PATCH 14/34] feat(GRO-2319): surface active waitlist entries on portal appointments + seed (#204) --- UAT_PLAYBOOK.md | 1 + packages/db/src/seed.ts | 52 ++++++++++++++++++++++--- src/__tests__/portal.test.ts | 73 ++++++++++++++++++++++++++++++++++++ src/routes/portal.ts | 48 ++++++++++++++++++++++-- 4 files changed, 165 insertions(+), 9 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index ecccc77..71d00ff 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -287,6 +287,7 @@ This means: | TC-API-8.16 | Portal pet update — malformed (non-UUID) petId returns 404 (GRO-2203) | With a valid portal session, `PATCH /api/portal/pets/not-a-uuid` with header `X-Impersonation-Session-Id` and body `{"coatType":"short"}` | 404 Not Found with body `{"error":"Not found"}` (was an unhandled 500 from the Postgres uuid cast in GRO-2203; mirrors the GRO-2014 guard). No mutation persisted | | TC-API-8.17 | SSO portal session slides on activity (GRO-2234) | Establish a portal session (TC-API-8.8). Note the returned `sessionId`. Make any authenticated portal call (e.g. `GET /api/portal/me`) several times spaced over ≥1 minute, each with `X-Impersonation-Session-Id: {sessionId}`. | Every call returns 200; the session's `expiresAt` is extended (slid forward to ~30 min from each request) so the session stays valid during continuous use — it does NOT lapse mid-session. SSO-bridge sessions mint with a 30-min idle TTL bounded by an 8h absolute cap from `startedAt`. | | TC-API-8.18 | Slow-wizard Book New submit succeeds (GRO-2234) | Establish a portal session (TC-API-8.8). Wait >2 minutes while making at least one intervening authenticated portal call (mimicking the multi-step Book New wizard: pet/service/groomer/date GETs). Then `POST /api/portal/waitlist` with a valid pet+service payload and the same `X-Impersonation-Session-Id`. | 201 Created — the deliberately-paced wizard no longer 401s on submit because activity slid the session forward. (Regression guard for the GRO-2234 "session TTL too short → 401" defect.) | +| TC-API-8.19 | Portal appointments surface active waitlist entries (GRO-2319) | As `uat-customer@groombook.dev`, establish a portal session, then `GET /api/portal/appointments`. | 200 OK. In addition to the customer's appointments, the response includes the seeded ACTIVE waitlist entry as a synthetic card: `status: "waitlisted"`, `id` prefixed `waitlist:`, `confirmationStatus: null`, a non-null derived `startTime` (from the entry's preferred date/time), and the entry's `pet`. Cancelled/notified/expired waitlist entries are NOT surfaced. | ### 4.9 Waitlist diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index f6bdf4c..02dc913 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -839,12 +839,14 @@ async function seedUatGroomerLinkage( * a deterministic spread of appointments so the customer-portal StatusBadge * palette can be LIVE-observed (not just code-verified against the bundle). * - * Scope is the subset of badge states reachable from the `appointment_status` - * enum (`scheduled, confirmed, in_progress, completed, cancelled, no_show`) — - * the portal's renders `appointment.status` verbatim. `pending` - * and `waitlisted` are NOT valid appointment statuses and cannot be seeded; the - * styled `no_show`→`no-show` badge fix and any pending/waitlisted derivation are - * tracked separately in GRO-2319 (web). CTO-approved Option A on GRO-2313. + * `appointment_status` enum is (`scheduled, confirmed, in_progress, completed, + * cancelled, no_show`) — the portal's renders `appointment.status` + * verbatim. `pending` and `waitlisted` are NOT valid appointment statuses, so + * GRO-2319 derives them in the portal: `pending` from an upcoming appointment's + * `confirmationStatus` (the `scheduled` row below carries `pending`), and + * `waitlisted` from an ACTIVE `waitlist_entries` row (seeded at the end of this + * function) which `GET /api/portal/appointments` surfaces as a synthetic card. + * The `no_show`→`no-show` badge-key fix is the web side of GRO-2319. * * - confirmed → future startTime → renders as an Upcoming card (Confirmed badge) * - scheduled → future startTime → renders as an Upcoming card (Scheduled badge) @@ -992,6 +994,44 @@ async function seedUatCustomerPortalAppointments( console.log( `✓ GRO-2311: seeded ${rows.length} portal StatusBadge appointments (confirmed/scheduled/cancelled/no_show) for UAT customer`, ); + + // GRO-2319 item 2: seed one ACTIVE waitlist entry so the portal's `waitlisted` + // card (surfaced by GET /api/portal/appointments) is live-observable. Unlike + // appointments, `waitlist_entries` is NOT truncated on the hourly reset, so we + // upsert by fixed id and REFRESH the preferred date to a future-relative value + // each reset — otherwise the date would go stale and the card would drop out of + // the Upcoming list. (The seeded `scheduled` appointment above already carries + // `confirmationStatus: "pending"`, which drives the live Pending badge.) + const WAITLIST_ENTRY_ID = "e0000001-0000-0000-0000-000000000001"; + const pad2 = (n: number): string => String(n).padStart(2, "0"); + const wlStart = at(7, 13); // 7 days out, 1pm — comfortably "upcoming" + const wlPreferredDate = `${wlStart.getFullYear()}-${pad2(wlStart.getMonth() + 1)}-${pad2(wlStart.getDate())}`; + const wlPreferredTime = `${pad2(wlStart.getHours())}:00:00`; + + await db + .insert(schema.waitlistEntries) + .values({ + id: WAITLIST_ENTRY_ID, + clientId: customerClientId, + petId: LINKED_PET_ID, + serviceId, + preferredDate: wlPreferredDate, + preferredTime: wlPreferredTime, + status: "active", + }) + .onConflictDoUpdate({ + target: schema.waitlistEntries.id, + set: { + preferredDate: wlPreferredDate, + preferredTime: wlPreferredTime, + status: "active", + updatedAt: new Date(), + }, + }); + + console.log( + `✓ GRO-2319: seeded 1 active waitlist entry (${wlPreferredDate} ${wlPreferredTime}) for UAT customer portal Waitlisted card`, + ); } // ── GRO-2225: deterministic route-optimization cohort ──────────────────────── diff --git a/src/__tests__/portal.test.ts b/src/__tests__/portal.test.ts index 73f05ff..84f37ab 100644 --- a/src/__tests__/portal.test.ts +++ b/src/__tests__/portal.test.ts @@ -39,11 +39,17 @@ const APPOINTMENT = { let selectSessionRow: Record | null = null; let selectAppointmentRow: Record | null = null; +let selectWaitlistRows: Record[] = []; +let selectPetRows: Record[] = []; +let selectStaffRows: Record[] = []; let updatedValues: Record[] = []; function resetMock() { selectSessionRow = null; selectAppointmentRow = null; + selectWaitlistRows = []; + selectPetRows = []; + selectStaffRows = []; updatedValues = []; } @@ -72,6 +78,12 @@ vi.mock("@groombook/db", () => { { get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) } ); + const mkTable = (name: string) => + new Proxy({ _name: name }, { get: (t, p) => (p === "_name" ? name : { table: name, column: p }) }); + const waitlistEntries = mkTable("waitlistEntries"); + const pets = mkTable("pets"); + const staff = mkTable("staff"); + return { getDb: () => ({ select: () => ({ @@ -82,6 +94,15 @@ vi.mock("@groombook/db", () => { if (table._name === "appointments") { return makeChainable(selectAppointmentRow ? [selectAppointmentRow] : []); } + if (table._name === "waitlistEntries") { + return makeChainable(selectWaitlistRows); + } + if (table._name === "pets") { + return makeChainable(selectPetRows); + } + if (table._name === "staff") { + return makeChainable(selectStaffRows); + } return makeChainable([]); }, }), @@ -102,8 +123,12 @@ vi.mock("@groombook/db", () => { }), impersonationSessions, appointments, + waitlistEntries, + pets, + staff, eq: vi.fn(), and: vi.fn(), + inArray: vi.fn(), }; }); @@ -125,6 +150,54 @@ function jsonPatch(path: string, body: unknown, headers?: Record beforeEach(() => resetMock()); +// GRO-2319 item 2: the portal Upcoming list renders active waitlist entries as +// synthetic `waitlisted` cards, so GET /portal/appointments must surface them. +describe("GET /portal/appointments (waitlist surfacing — GRO-2319)", () => { + it("returns active waitlist entries as synthetic waitlisted cards", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT }; + selectWaitlistRows = [ + { + id: "11111111-1111-1111-1111-111111111111", + petId: "pet-1", + serviceId: "svc-1", + preferredDate: "2099-01-01", + preferredTime: "13:00:00", + }, + ]; + selectPetRows = [{ id: "pet-1", name: "Rex", photoKey: null }]; + + const res = await app.request("/portal/appointments", { + headers: { "X-Impersonation-Session-Id": SESSION_ID }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + const waitlistCard = body.appointments.find( + (a: { status: string }) => a.status === "waitlisted", + ); + expect(waitlistCard).toBeTruthy(); + expect(waitlistCard.id).toBe("waitlist:11111111-1111-1111-1111-111111111111"); + expect(waitlistCard.pet.name).toBe("Rex"); + expect(waitlistCard.confirmationStatus).toBeNull(); + // startTime is derived from preferredDate + preferredTime so the card sorts + // and classifies as Upcoming. + expect(waitlistCard.startTime).toBeTruthy(); + }); + + it("omits the waitlist section when the client has no active entries", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT }; + selectWaitlistRows = []; + + const res = await app.request("/portal/appointments", { + headers: { "X-Impersonation-Session-Id": SESSION_ID }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.appointments.some((a: { status: string }) => a.status === "waitlisted")).toBe(false); + }); +}); + describe("PATCH /portal/appointments/:id/notes", () => { it("returns updated appointment with safe fields only", async () => { selectSessionRow = ACTIVE_SESSION; diff --git a/src/routes/portal.ts b/src/routes/portal.ts index 3c7dab9..65c53a7 100644 --- a/src/routes/portal.ts +++ b/src/routes/portal.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; -import { eq, inArray } from "@groombook/db"; +import { and, eq, inArray } from "@groombook/db"; import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db"; import { validatePortalSession, PORTAL_SESSION_IDLE_TTL_MS } from "../middleware/portalSession.js"; import { portalAudit } from "../middleware/portalAudit.js"; @@ -195,7 +195,29 @@ portalRouter.get("/appointments", async (c) => { .where(eq(appointments.clientId, clientId)) .orderBy(appointments.startTime); - const petIds = allAppts.map(a => a.petId).filter((id): id is string => id !== null); + // GRO-2319: surface the client's ACTIVE waitlist entries alongside their + // appointments so the portal can render them as `waitlisted` cards in the + // Upcoming list. The `appointment_status` enum cannot represent `waitlisted`, + // so these are synthetic entries (status hard-set to `waitlisted`, id prefixed + // `waitlist:`) derived from `waitlist_entries`. + const waitlistRows = await db + .select({ + id: waitlistEntries.id, + petId: waitlistEntries.petId, + serviceId: waitlistEntries.serviceId, + preferredDate: waitlistEntries.preferredDate, + preferredTime: waitlistEntries.preferredTime, + }) + .from(waitlistEntries) + .where( + and(eq(waitlistEntries.clientId, clientId), eq(waitlistEntries.status, "active")), + ); + + // Pet lookups must cover both appointment and waitlist pets. + const petIds = [ + ...allAppts.map(a => a.petId).filter((id): id is string => id !== null), + ...waitlistRows.map(w => w.petId), + ]; const staffIds = allAppts.map(a => a.staffId).filter((id): id is string => id !== null); const petRows = petIds.length ? await db.select().from(pets).where(inArray(pets.id, petIds)) : []; @@ -217,7 +239,27 @@ portalRouter.get("/appointments", async (c) => { staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null, })); - return c.json({ appointments: appts }); + // Derive a display `startTime` from the entry's preferred date/time so the + // portal can sort/classify the synthetic card (an invalid combination simply + // yields a null startTime, which the portal tolerates). + const waitlistAppts = waitlistRows.map(w => { + const parsed = new Date(`${w.preferredDate}T${w.preferredTime}`); + const startTime = Number.isNaN(parsed.getTime()) ? null : parsed; + return { + id: `waitlist:${w.id}`, + startTime, + endTime: null, + status: "waitlisted" as const, + confirmationStatus: null, + customerNotes: null, + notes: null, + pet: { id: petMap[w.petId]?.id, name: petMap[w.petId]?.name, photo: petMap[w.petId]?.photoKey }, + service: { id: w.serviceId }, + staff: null, + }; + }); + + return c.json({ appointments: [...appts, ...waitlistAppts] }); }); portalRouter.get("/pets", async (c) => { From 18640908eda17885af0236fac0f9b7e7b6ef637a Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 11:04:16 +0000 Subject: [PATCH 15/34] =?UTF-8?q?feat(GRO-2319):=20dev=E2=86=92uat=20?= =?UTF-8?q?=E2=80=94=20portal=20waitlist=20surfacing=20+=20seed=20(api)=20?= =?UTF-8?q?(#205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- UAT_PLAYBOOK.md | 1 + packages/db/src/seed.ts | 52 ++++++++++++++++++++++--- src/__tests__/portal.test.ts | 73 ++++++++++++++++++++++++++++++++++++ src/routes/portal.ts | 48 ++++++++++++++++++++++-- 4 files changed, 165 insertions(+), 9 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index ecccc77..71d00ff 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -287,6 +287,7 @@ This means: | TC-API-8.16 | Portal pet update — malformed (non-UUID) petId returns 404 (GRO-2203) | With a valid portal session, `PATCH /api/portal/pets/not-a-uuid` with header `X-Impersonation-Session-Id` and body `{"coatType":"short"}` | 404 Not Found with body `{"error":"Not found"}` (was an unhandled 500 from the Postgres uuid cast in GRO-2203; mirrors the GRO-2014 guard). No mutation persisted | | TC-API-8.17 | SSO portal session slides on activity (GRO-2234) | Establish a portal session (TC-API-8.8). Note the returned `sessionId`. Make any authenticated portal call (e.g. `GET /api/portal/me`) several times spaced over ≥1 minute, each with `X-Impersonation-Session-Id: {sessionId}`. | Every call returns 200; the session's `expiresAt` is extended (slid forward to ~30 min from each request) so the session stays valid during continuous use — it does NOT lapse mid-session. SSO-bridge sessions mint with a 30-min idle TTL bounded by an 8h absolute cap from `startedAt`. | | TC-API-8.18 | Slow-wizard Book New submit succeeds (GRO-2234) | Establish a portal session (TC-API-8.8). Wait >2 minutes while making at least one intervening authenticated portal call (mimicking the multi-step Book New wizard: pet/service/groomer/date GETs). Then `POST /api/portal/waitlist` with a valid pet+service payload and the same `X-Impersonation-Session-Id`. | 201 Created — the deliberately-paced wizard no longer 401s on submit because activity slid the session forward. (Regression guard for the GRO-2234 "session TTL too short → 401" defect.) | +| TC-API-8.19 | Portal appointments surface active waitlist entries (GRO-2319) | As `uat-customer@groombook.dev`, establish a portal session, then `GET /api/portal/appointments`. | 200 OK. In addition to the customer's appointments, the response includes the seeded ACTIVE waitlist entry as a synthetic card: `status: "waitlisted"`, `id` prefixed `waitlist:`, `confirmationStatus: null`, a non-null derived `startTime` (from the entry's preferred date/time), and the entry's `pet`. Cancelled/notified/expired waitlist entries are NOT surfaced. | ### 4.9 Waitlist diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 24d3dc3..6ca4cc0 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -837,12 +837,14 @@ async function seedUatGroomerLinkage( * a deterministic spread of appointments so the customer-portal StatusBadge * palette can be LIVE-observed (not just code-verified against the bundle). * - * Scope is the subset of badge states reachable from the `appointment_status` - * enum (`scheduled, confirmed, in_progress, completed, cancelled, no_show`) — - * the portal's renders `appointment.status` verbatim. `pending` - * and `waitlisted` are NOT valid appointment statuses and cannot be seeded; the - * styled `no_show`→`no-show` badge fix and any pending/waitlisted derivation are - * tracked separately in GRO-2319 (web). CTO-approved Option A on GRO-2313. + * `appointment_status` enum is (`scheduled, confirmed, in_progress, completed, + * cancelled, no_show`) — the portal's renders `appointment.status` + * verbatim. `pending` and `waitlisted` are NOT valid appointment statuses, so + * GRO-2319 derives them in the portal: `pending` from an upcoming appointment's + * `confirmationStatus` (the `scheduled` row below carries `pending`), and + * `waitlisted` from an ACTIVE `waitlist_entries` row (seeded at the end of this + * function) which `GET /api/portal/appointments` surfaces as a synthetic card. + * The `no_show`→`no-show` badge-key fix is the web side of GRO-2319. * * - confirmed → future startTime → renders as an Upcoming card (Confirmed badge) * - scheduled → future startTime → renders as an Upcoming card (Scheduled badge) @@ -990,6 +992,44 @@ async function seedUatCustomerPortalAppointments( console.log( `✓ GRO-2311: seeded ${rows.length} portal StatusBadge appointments (confirmed/scheduled/cancelled/no_show) for UAT customer`, ); + + // GRO-2319 item 2: seed one ACTIVE waitlist entry so the portal's `waitlisted` + // card (surfaced by GET /api/portal/appointments) is live-observable. Unlike + // appointments, `waitlist_entries` is NOT truncated on the hourly reset, so we + // upsert by fixed id and REFRESH the preferred date to a future-relative value + // each reset — otherwise the date would go stale and the card would drop out of + // the Upcoming list. (The seeded `scheduled` appointment above already carries + // `confirmationStatus: "pending"`, which drives the live Pending badge.) + const WAITLIST_ENTRY_ID = "e0000001-0000-0000-0000-000000000001"; + const pad2 = (n: number): string => String(n).padStart(2, "0"); + const wlStart = at(7, 13); // 7 days out, 1pm — comfortably "upcoming" + const wlPreferredDate = `${wlStart.getFullYear()}-${pad2(wlStart.getMonth() + 1)}-${pad2(wlStart.getDate())}`; + const wlPreferredTime = `${pad2(wlStart.getHours())}:00:00`; + + await db + .insert(schema.waitlistEntries) + .values({ + id: WAITLIST_ENTRY_ID, + clientId: customerClientId, + petId: LINKED_PET_ID, + serviceId, + preferredDate: wlPreferredDate, + preferredTime: wlPreferredTime, + status: "active", + }) + .onConflictDoUpdate({ + target: schema.waitlistEntries.id, + set: { + preferredDate: wlPreferredDate, + preferredTime: wlPreferredTime, + status: "active", + updatedAt: new Date(), + }, + }); + + console.log( + `✓ GRO-2319: seeded 1 active waitlist entry (${wlPreferredDate} ${wlPreferredTime}) for UAT customer portal Waitlisted card`, + ); } // ── GRO-2225: deterministic route-optimization cohort ──────────────────────── diff --git a/src/__tests__/portal.test.ts b/src/__tests__/portal.test.ts index 73f05ff..84f37ab 100644 --- a/src/__tests__/portal.test.ts +++ b/src/__tests__/portal.test.ts @@ -39,11 +39,17 @@ const APPOINTMENT = { let selectSessionRow: Record | null = null; let selectAppointmentRow: Record | null = null; +let selectWaitlistRows: Record[] = []; +let selectPetRows: Record[] = []; +let selectStaffRows: Record[] = []; let updatedValues: Record[] = []; function resetMock() { selectSessionRow = null; selectAppointmentRow = null; + selectWaitlistRows = []; + selectPetRows = []; + selectStaffRows = []; updatedValues = []; } @@ -72,6 +78,12 @@ vi.mock("@groombook/db", () => { { get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) } ); + const mkTable = (name: string) => + new Proxy({ _name: name }, { get: (t, p) => (p === "_name" ? name : { table: name, column: p }) }); + const waitlistEntries = mkTable("waitlistEntries"); + const pets = mkTable("pets"); + const staff = mkTable("staff"); + return { getDb: () => ({ select: () => ({ @@ -82,6 +94,15 @@ vi.mock("@groombook/db", () => { if (table._name === "appointments") { return makeChainable(selectAppointmentRow ? [selectAppointmentRow] : []); } + if (table._name === "waitlistEntries") { + return makeChainable(selectWaitlistRows); + } + if (table._name === "pets") { + return makeChainable(selectPetRows); + } + if (table._name === "staff") { + return makeChainable(selectStaffRows); + } return makeChainable([]); }, }), @@ -102,8 +123,12 @@ vi.mock("@groombook/db", () => { }), impersonationSessions, appointments, + waitlistEntries, + pets, + staff, eq: vi.fn(), and: vi.fn(), + inArray: vi.fn(), }; }); @@ -125,6 +150,54 @@ function jsonPatch(path: string, body: unknown, headers?: Record beforeEach(() => resetMock()); +// GRO-2319 item 2: the portal Upcoming list renders active waitlist entries as +// synthetic `waitlisted` cards, so GET /portal/appointments must surface them. +describe("GET /portal/appointments (waitlist surfacing — GRO-2319)", () => { + it("returns active waitlist entries as synthetic waitlisted cards", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT }; + selectWaitlistRows = [ + { + id: "11111111-1111-1111-1111-111111111111", + petId: "pet-1", + serviceId: "svc-1", + preferredDate: "2099-01-01", + preferredTime: "13:00:00", + }, + ]; + selectPetRows = [{ id: "pet-1", name: "Rex", photoKey: null }]; + + const res = await app.request("/portal/appointments", { + headers: { "X-Impersonation-Session-Id": SESSION_ID }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + const waitlistCard = body.appointments.find( + (a: { status: string }) => a.status === "waitlisted", + ); + expect(waitlistCard).toBeTruthy(); + expect(waitlistCard.id).toBe("waitlist:11111111-1111-1111-1111-111111111111"); + expect(waitlistCard.pet.name).toBe("Rex"); + expect(waitlistCard.confirmationStatus).toBeNull(); + // startTime is derived from preferredDate + preferredTime so the card sorts + // and classifies as Upcoming. + expect(waitlistCard.startTime).toBeTruthy(); + }); + + it("omits the waitlist section when the client has no active entries", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT }; + selectWaitlistRows = []; + + const res = await app.request("/portal/appointments", { + headers: { "X-Impersonation-Session-Id": SESSION_ID }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.appointments.some((a: { status: string }) => a.status === "waitlisted")).toBe(false); + }); +}); + describe("PATCH /portal/appointments/:id/notes", () => { it("returns updated appointment with safe fields only", async () => { selectSessionRow = ACTIVE_SESSION; diff --git a/src/routes/portal.ts b/src/routes/portal.ts index 3c7dab9..65c53a7 100644 --- a/src/routes/portal.ts +++ b/src/routes/portal.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; -import { eq, inArray } from "@groombook/db"; +import { and, eq, inArray } from "@groombook/db"; import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db"; import { validatePortalSession, PORTAL_SESSION_IDLE_TTL_MS } from "../middleware/portalSession.js"; import { portalAudit } from "../middleware/portalAudit.js"; @@ -195,7 +195,29 @@ portalRouter.get("/appointments", async (c) => { .where(eq(appointments.clientId, clientId)) .orderBy(appointments.startTime); - const petIds = allAppts.map(a => a.petId).filter((id): id is string => id !== null); + // GRO-2319: surface the client's ACTIVE waitlist entries alongside their + // appointments so the portal can render them as `waitlisted` cards in the + // Upcoming list. The `appointment_status` enum cannot represent `waitlisted`, + // so these are synthetic entries (status hard-set to `waitlisted`, id prefixed + // `waitlist:`) derived from `waitlist_entries`. + const waitlistRows = await db + .select({ + id: waitlistEntries.id, + petId: waitlistEntries.petId, + serviceId: waitlistEntries.serviceId, + preferredDate: waitlistEntries.preferredDate, + preferredTime: waitlistEntries.preferredTime, + }) + .from(waitlistEntries) + .where( + and(eq(waitlistEntries.clientId, clientId), eq(waitlistEntries.status, "active")), + ); + + // Pet lookups must cover both appointment and waitlist pets. + const petIds = [ + ...allAppts.map(a => a.petId).filter((id): id is string => id !== null), + ...waitlistRows.map(w => w.petId), + ]; const staffIds = allAppts.map(a => a.staffId).filter((id): id is string => id !== null); const petRows = petIds.length ? await db.select().from(pets).where(inArray(pets.id, petIds)) : []; @@ -217,7 +239,27 @@ portalRouter.get("/appointments", async (c) => { staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null, })); - return c.json({ appointments: appts }); + // Derive a display `startTime` from the entry's preferred date/time so the + // portal can sort/classify the synthetic card (an invalid combination simply + // yields a null startTime, which the portal tolerates). + const waitlistAppts = waitlistRows.map(w => { + const parsed = new Date(`${w.preferredDate}T${w.preferredTime}`); + const startTime = Number.isNaN(parsed.getTime()) ? null : parsed; + return { + id: `waitlist:${w.id}`, + startTime, + endTime: null, + status: "waitlisted" as const, + confirmationStatus: null, + customerNotes: null, + notes: null, + pet: { id: petMap[w.petId]?.id, name: petMap[w.petId]?.name, photo: petMap[w.petId]?.photoKey }, + service: { id: w.serviceId }, + staff: null, + }; + }); + + return c.json({ appointments: [...appts, ...waitlistAppts] }); }); portalRouter.get("/pets", async (c) => { From 277f45923738a379d2f9dd7cc8b38cf98344d3e1 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Wed, 10 Jun 2026 09:11:08 +0000 Subject: [PATCH 16/34] fix(GRO-2342): portal waitlist card populates service {id, name} MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cosmetic follow-up to GRO-2319 (Phase 4 review by CTO). The synthetic waitlist card on GET /portal/appointments returned service: {id} only, so the portal fell back to the literal 'Service' label. CMPO spec did not call for a service name on the waitlist card, but populating the real name is non-urgent and closes the cosmetic gap. - src/routes/portal.ts: include a services SELECT (in addition to pets and staff) covering both appointment and waitlist serviceIds. serviceMap feeds a service.name lookup. The synthetic waitlist card's service object is now {id, name} — same shape the appointments join returns — so the portal renders the real name. The appointments join also gains a name (consistent shape, no regression for the existing path). - src/__tests__/portal.test.ts: mock the services table and assert service: {id, name} on both the synthetic waitlist card and the appointment card. - UAT_PLAYBOOK.md: TC-API-8.20 covering the waitlist card service name (TC-API-8.19 retained verbatim for the original GRO-2319 surfacing contract). Co-Authored-By: Paperclip --- UAT_PLAYBOOK.md | 1 + src/__tests__/portal.test.ts | 57 ++++++++++++++++++++++++++++++++++++ src/routes/portal.ts | 20 +++++++++++-- 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 71d00ff..2a85e1d 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -288,6 +288,7 @@ This means: | TC-API-8.17 | SSO portal session slides on activity (GRO-2234) | Establish a portal session (TC-API-8.8). Note the returned `sessionId`. Make any authenticated portal call (e.g. `GET /api/portal/me`) several times spaced over ≥1 minute, each with `X-Impersonation-Session-Id: {sessionId}`. | Every call returns 200; the session's `expiresAt` is extended (slid forward to ~30 min from each request) so the session stays valid during continuous use — it does NOT lapse mid-session. SSO-bridge sessions mint with a 30-min idle TTL bounded by an 8h absolute cap from `startedAt`. | | TC-API-8.18 | Slow-wizard Book New submit succeeds (GRO-2234) | Establish a portal session (TC-API-8.8). Wait >2 minutes while making at least one intervening authenticated portal call (mimicking the multi-step Book New wizard: pet/service/groomer/date GETs). Then `POST /api/portal/waitlist` with a valid pet+service payload and the same `X-Impersonation-Session-Id`. | 201 Created — the deliberately-paced wizard no longer 401s on submit because activity slid the session forward. (Regression guard for the GRO-2234 "session TTL too short → 401" defect.) | | TC-API-8.19 | Portal appointments surface active waitlist entries (GRO-2319) | As `uat-customer@groombook.dev`, establish a portal session, then `GET /api/portal/appointments`. | 200 OK. In addition to the customer's appointments, the response includes the seeded ACTIVE waitlist entry as a synthetic card: `status: "waitlisted"`, `id` prefixed `waitlist:`, `confirmationStatus: null`, a non-null derived `startTime` (from the entry's preferred date/time), and the entry's `pet`. Cancelled/notified/expired waitlist entries are NOT surfaced. | +| TC-API-8.20 | Portal waitlist card populates service {id, name} (GRO-2342) | As `uat-customer@groombook.dev`, establish a portal session, then `GET /api/portal/appointments`. | 200 OK. The synthetic `waitlisted` card returned for the active waitlist entry has `service: {id: "", name: ""}` (full service record, not just `{id}`), matching the shape the appointments join returns. The portal Upcoming list therefore renders the actual service name in place of the fallback "Service" label. | ### 4.9 Waitlist diff --git a/src/__tests__/portal.test.ts b/src/__tests__/portal.test.ts index 84f37ab..1ac8bce 100644 --- a/src/__tests__/portal.test.ts +++ b/src/__tests__/portal.test.ts @@ -42,6 +42,7 @@ let selectAppointmentRow: Record | null = null; let selectWaitlistRows: Record[] = []; let selectPetRows: Record[] = []; let selectStaffRows: Record[] = []; +let selectServiceRows: Record[] = []; let updatedValues: Record[] = []; function resetMock() { @@ -50,6 +51,7 @@ function resetMock() { selectWaitlistRows = []; selectPetRows = []; selectStaffRows = []; + selectServiceRows = []; updatedValues = []; } @@ -83,6 +85,7 @@ vi.mock("@groombook/db", () => { const waitlistEntries = mkTable("waitlistEntries"); const pets = mkTable("pets"); const staff = mkTable("staff"); + const services = mkTable("services"); return { getDb: () => ({ @@ -103,6 +106,9 @@ vi.mock("@groombook/db", () => { if (table._name === "staff") { return makeChainable(selectStaffRows); } + if (table._name === "services") { + return makeChainable(selectServiceRows); + } return makeChainable([]); }, }), @@ -126,6 +132,7 @@ vi.mock("@groombook/db", () => { waitlistEntries, pets, staff, + services, eq: vi.fn(), and: vi.fn(), inArray: vi.fn(), @@ -198,6 +205,56 @@ describe("GET /portal/appointments (waitlist surfacing — GRO-2319)", () => { }); }); +// GRO-2342: GET /portal/appointments must populate the synthetic waitlist +// card's `service` object with the full service record (id + name) — same +// shape the appointments join returns — so the portal renders the real +// service name in place of the fallback "Service" label. +describe("GET /portal/appointments (waitlist service name — GRO-2342)", () => { + it("returns service {id, name} on the synthetic waitlist card", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT }; + selectWaitlistRows = [ + { + id: "22222222-2222-2222-2222-222222222222", + petId: "pet-1", + serviceId: "svc-1", + preferredDate: "2099-01-01", + preferredTime: "13:00:00", + }, + ]; + selectPetRows = [{ id: "pet-1", name: "Rex", photoKey: null }]; + selectServiceRows = [{ id: "svc-1", name: "Full Groom" }]; + + const res = await app.request("/portal/appointments", { + headers: { "X-Impersonation-Session-Id": SESSION_ID }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + const waitlistCard = body.appointments.find( + (a: { status: string }) => a.status === "waitlisted", + ); + expect(waitlistCard).toBeTruthy(); + expect(waitlistCard.service).toEqual({ id: "svc-1", name: "Full Groom" }); + }); + + it("returns service {id, name} on the appointment card (same shape)", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT, serviceId: "svc-appt" }; + selectServiceRows = [{ id: "svc-appt", name: "Bath & Brush" }]; + + const res = await app.request("/portal/appointments", { + headers: { "X-Impersonation-Session-Id": SESSION_ID }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + const apptCard = body.appointments.find( + (a: { status: string }) => a.status === "scheduled", + ); + expect(apptCard).toBeTruthy(); + expect(apptCard.service).toEqual({ id: "svc-appt", name: "Bath & Brush" }); + }); +}); + describe("PATCH /portal/appointments/:id/notes", () => { it("returns updated appointment with safe fields only", async () => { selectSessionRow = ACTIVE_SESSION; diff --git a/src/routes/portal.ts b/src/routes/portal.ts index 65c53a7..487861d 100644 --- a/src/routes/portal.ts +++ b/src/routes/portal.ts @@ -219,12 +219,22 @@ portalRouter.get("/appointments", async (c) => { ...waitlistRows.map(w => w.petId), ]; const staffIds = allAppts.map(a => a.staffId).filter((id): id is string => id !== null); + // GRO-2342: services must be looked up for both appointment and waitlist cards + // so the portal can render `service.name` in place of the fallback "Service" + // label (CMPO sign-off on the GRO-2319 waitlist card explicitly excluded the + // service name; this follow-up closes the cosmetic gap). + const serviceIds = [ + ...allAppts.map(a => a.serviceId).filter((id): id is string => id !== null), + ...waitlistRows.map(w => w.serviceId).filter((id): id is string => id !== null), + ]; const petRows = petIds.length ? await db.select().from(pets).where(inArray(pets.id, petIds)) : []; const staffRows = staffIds.length ? await db.select().from(staff).where(inArray(staff.id, staffIds)) : []; + const serviceRows = serviceIds.length ? await db.select().from(services).where(inArray(services.id, serviceIds)) : []; const petMap = Object.fromEntries(petRows.map(p => [p.id, p])); const staffMap = Object.fromEntries(staffRows.map(s => [s.id, s])); + const serviceMap = Object.fromEntries(serviceRows.map(s => [s.id, s])); const appts = allAppts.map(a => ({ id: a.id, @@ -235,13 +245,17 @@ portalRouter.get("/appointments", async (c) => { customerNotes: a.customerNotes, notes: a.notes, pet: a.petId ? { id: petMap[a.petId]?.id, name: petMap[a.petId]?.name, photo: petMap[a.petId]?.photoKey } : null, - service: a.serviceId ? { id: a.serviceId } : null, + service: a.serviceId ? { id: a.serviceId, name: serviceMap[a.serviceId]?.name } : null, staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null, })); // Derive a display `startTime` from the entry's preferred date/time so the // portal can sort/classify the synthetic card (an invalid combination simply - // yields a null startTime, which the portal tolerates). + // yields a null startTime, which the portal tolerates). GRO-2342: also + // populate the synthetic card's `service` object with the full service + // record (id + name) — same shape the appointments join returns — so the + // portal renders the real service name in place of the fallback "Service" + // label. const waitlistAppts = waitlistRows.map(w => { const parsed = new Date(`${w.preferredDate}T${w.preferredTime}`); const startTime = Number.isNaN(parsed.getTime()) ? null : parsed; @@ -254,7 +268,7 @@ portalRouter.get("/appointments", async (c) => { customerNotes: null, notes: null, pet: { id: petMap[w.petId]?.id, name: petMap[w.petId]?.name, photo: petMap[w.petId]?.photoKey }, - service: { id: w.serviceId }, + service: w.serviceId ? { id: w.serviceId, name: serviceMap[w.serviceId]?.name } : null, staff: null, }; }); From cdeebec021022144241cd98b7cec9af7814cf0dd Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Thu, 11 Jun 2026 16:17:16 +0000 Subject: [PATCH 17/34] feat(GRO-2359): add POST /api/portal/clients-from-auth for OOBE (web) The OOBE flow on the web portal calls this endpoint to create a fresh `clients` row bound to the Better Auth user's email when the SSO bridge returns 404. Returns 201 on success, 409 if a client with that email already exists (portal-selection case), 401/503 on auth issues, 400 on invalid body. The OOBE success path navigates the user back to `/` and lets the existing `session-from-auth` re-bridge; the new client is now resolvable by email, so the bridge mints a real portal session. Tests cover: 401 (no session), 400 (zod), 201 + persisted values (name trimmed, optional fields normalized to null), 409 (existing client or unique-constraint race), 503 (auth not configured). Paired with the web PR on `feature/2357-p2-sso-to-oobe-routing`. Co-Authored-By: Paperclip --- src/__tests__/portalClientsFromAuth.test.ts | 201 ++++++++++++++++++++ src/routes/portal.ts | 108 +++++++++++ 2 files changed, 309 insertions(+) create mode 100644 src/__tests__/portalClientsFromAuth.test.ts diff --git a/src/__tests__/portalClientsFromAuth.test.ts b/src/__tests__/portalClientsFromAuth.test.ts new file mode 100644 index 0000000..dd2e899 --- /dev/null +++ b/src/__tests__/portalClientsFromAuth.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; +import { getAuth } from "../lib/auth.js"; + +const NEW_USER_EMAIL = "new-sso-user@example.com"; +const NEW_USER_NAME = "New SSO User"; +const NEW_USER_ID = "11111111-2222-3333-4444-555555555555"; + +const BETTER_AUTH_SESSION = { + user: { + id: "auth-user-new", + email: NEW_USER_EMAIL, + name: NEW_USER_NAME, + }, + session: { + id: "ba-session-new", + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, +}; + +let mockGetAuth: ReturnType; +let mockGetSession: ReturnType; +let existingClientRow: Record | null = null; +let insertedClientValues: Record | null = null; +let insertShouldThrow: { code?: string } | null = null; + +function makeChainable(data: unknown[]): unknown { + const arr = [...data]; + return new Proxy(arr, { + get(target, prop) { + if (prop === "where" || prop === "orderBy" || prop === "limit") { + return () => makeChainable(target); + } + // @ts-expect-error proxy + return target[prop]; + }, + }); +} + +vi.mock("@groombook/db", () => { + const clients = new Proxy( + { _name: "clients" }, + { get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) } + ); + + return { + getDb: () => ({ + select: () => ({ + from: (table: { _name: string }) => { + if (table._name === "clients") { + return makeChainable(existingClientRow ? [existingClientRow] : []); + } + return makeChainable([]); + }, + }), + insert: (table: { _name: string }) => ({ + values: (vals: Record) => { + if (insertShouldThrow) { + const err = new Error("unique violation") as Error & { code?: string }; + err.code = insertShouldThrow.code; + throw err; + } + return { + returning: () => { + if (table._name === "clients") { + insertedClientValues = { id: NEW_USER_ID, ...vals }; + return [insertedClientValues]; + } + return []; + }, + }; + }, + }), + }), + clients, + eq: vi.fn(), + and: vi.fn(), + inArray: vi.fn(), + }; +}); + +vi.mock("../lib/auth.js", () => ({ + getAuth: vi.fn(), +})); + +const { portalRouter } = await import("../routes/portal.js"); + +const app = new Hono(); +app.route("/portal", portalRouter); + +describe("POST /portal/clients-from-auth (GRO-2359)", () => { + beforeEach(() => { + existingClientRow = null; + insertedClientValues = null; + insertShouldThrow = null; + mockGetSession = vi.fn(); + mockGetAuth = vi.fn(() => ({ + api: { + getSession: mockGetSession, + }, + })); + vi.mocked(getAuth).mockImplementation(mockGetAuth); + }); + + it("returns 401 when no Better Auth session is present", async () => { + mockGetSession.mockResolvedValue(null); + const res = await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Test User" }), + }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 400 when body fails zod validation (empty name)", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + const res = await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "" }), + }); + expect(res.status).toBe(400); + }); + + it("creates a new client row bound to the auth user's email and returns 201", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + const res = await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: " New SSO User ", + phone: "555-1234", + address: "1 Main St", + notes: "test note", + }), + }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body).toMatchObject({ + id: NEW_USER_ID, + name: "New SSO User", + email: NEW_USER_EMAIL, + }); + // Trim must be applied to the persisted values. + expect(insertedClientValues).not.toBeNull(); + expect((insertedClientValues as Record).name).toBe("New SSO User"); + expect((insertedClientValues as Record).email).toBe(NEW_USER_EMAIL); + expect((insertedClientValues as Record).phone).toBe("555-1234"); + }); + + it("normalizes empty optional fields to null on insert", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Test", phone: "", address: " " }), + }); + expect(insertedClientValues).not.toBeNull(); + expect((insertedClientValues as Record).phone).toBeNull(); + expect((insertedClientValues as Record).address).toBeNull(); + }); + + it("returns 409 when a client row already exists for this email", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + existingClientRow = { id: "existing-client-id", email: NEW_USER_EMAIL }; + const res = await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Test" }), + }); + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.error).toMatch(/already exists/i); + expect(insertedClientValues).toBeNull(); + }); + + it("returns 409 on unique constraint race (23505)", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + insertShouldThrow = { code: "23505" }; + const res = await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Test" }), + }); + expect(res.status).toBe(409); + }); + + it("returns 503 when auth is not configured", async () => { + mockGetAuth.mockImplementation(() => { + throw new Error("Auth not initialized"); + }); + const res = await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Test" }), + }); + expect(res.status).toBe(503); + }); +}); diff --git a/src/routes/portal.ts b/src/routes/portal.ts index 487861d..17425a3 100644 --- a/src/routes/portal.ts +++ b/src/routes/portal.ts @@ -147,6 +147,114 @@ portalRouter.post("/session-from-auth", async (c) => { ); }); +// GRO-2359 — register a brand-new SSO user. The post-auth handler in the +// web portal redirects here when `session-from-auth` returns 404, so the +// OOBE can complete a customer record for the new user. Auth is via the +// Better Auth session (same shape as `session-from-auth`), so this is +// registered BEFORE the `validatePortalSession` middleware. +// +// Contract: +// POST /api/portal/clients-from-auth +// Body: { name: string; phone?: string|null; address?: string|null; notes?: string|null } +// 201: { id, name, email } +// 400: invalid body (zod failure) +// 401: no Better Auth session +// 409: a `clients` row already exists for this email (portal selection case) +// 500: insert failed +// +// We do NOT auto-link the user's auth account to the new client row; the +// existing `session-from-auth` endpoint re-resolves the row by email on the +// next call, so the OOBE's success path just navigates the user back to +// `/` and lets the bridge mint a portal session. +const createClientFromAuthSchema = z.object({ + name: z.string().min(1).max(200), + phone: z.string().max(50).nullish(), + address: z.string().max(500).nullish(), + notes: z.string().max(2000).nullish(), +}); + +portalRouter.post( + "/clients-from-auth", + zValidator("json", createClientFromAuthSchema), + async (c) => { + let auth; + try { + auth = getAuth(); + } catch { + return c.json({ error: "Authentication not configured" }, 503); + } + + const session = await auth.api.getSession({ + headers: c.req.raw.headers, + }); + + if (!session) { + return c.json({ error: "Unauthorized" }, 401); + } + + const body = c.req.valid("json"); + const db = getDb(); + + // Pre-check: if a client already exists for this email, return 409 so + // the OOBE can render the "portal selection" message (the user needs + // to contact their groomer to link the new SSO identity to the + // pre-existing customer record). We don't return the existing row to + // avoid leaking PII about other accounts. + const [existing] = await db + .select({ id: clients.id }) + .from(clients) + .where(eq(clients.email, session.user.email)) + .limit(1); + + if (existing) { + return c.json( + { error: "A customer record with this email already exists" }, + 409, + ); + } + + let row; + try { + [row] = await db + .insert(clients) + .values({ + name: body.name.trim(), + email: session.user.email, + phone: body.phone?.trim() || null, + address: body.address?.trim() || null, + notes: body.notes?.trim() || null, + }) + .returning(); + } catch (err) { + // Concurrent insert from a parallel OOBE submit — treat as 409. + if ( + err instanceof Error && + "code" in err && + (err as { code?: string }).code === "23505" + ) { + return c.json( + { error: "A customer record with this email already exists" }, + 409, + ); + } + throw err; + } + + if (!row) { + return c.json({ error: "Failed to create client" }, 500); + } + + return c.json( + { + id: row.id, + name: row.name, + email: row.email, + }, + 201, + ); + }, +); + // Apply middleware to all portal routes portalRouter.use("/*", validatePortalSession, portalAudit); From 5363e1d5dc3a7f0bcfcf95336521d4fc53647f6d Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Thu, 11 Jun 2026 16:17:16 +0000 Subject: [PATCH 18/34] feat(GRO-2359): add POST /api/portal/clients-from-auth for OOBE (web) The OOBE flow on the web portal calls this endpoint to create a fresh `clients` row bound to the Better Auth user's email when the SSO bridge returns 404. Returns 201 on success, 409 if a client with that email already exists (portal-selection case), 401/503 on auth issues, 400 on invalid body. The OOBE success path navigates the user back to `/` and lets the existing `session-from-auth` re-bridge; the new client is now resolvable by email, so the bridge mints a real portal session. Tests cover: 401 (no session), 400 (zod), 201 + persisted values (name trimmed, optional fields normalized to null), 409 (existing client or unique-constraint race), 503 (auth not configured). Paired with the web PR on `feature/2357-p2-sso-to-oobe-routing`. Co-Authored-By: Paperclip (cherry picked from commit cdeebec021022144241cd98b7cec9af7814cf0dd) --- src/__tests__/portalClientsFromAuth.test.ts | 201 ++++++++++++++++++++ src/routes/portal.ts | 108 +++++++++++ 2 files changed, 309 insertions(+) create mode 100644 src/__tests__/portalClientsFromAuth.test.ts diff --git a/src/__tests__/portalClientsFromAuth.test.ts b/src/__tests__/portalClientsFromAuth.test.ts new file mode 100644 index 0000000..dd2e899 --- /dev/null +++ b/src/__tests__/portalClientsFromAuth.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; +import { getAuth } from "../lib/auth.js"; + +const NEW_USER_EMAIL = "new-sso-user@example.com"; +const NEW_USER_NAME = "New SSO User"; +const NEW_USER_ID = "11111111-2222-3333-4444-555555555555"; + +const BETTER_AUTH_SESSION = { + user: { + id: "auth-user-new", + email: NEW_USER_EMAIL, + name: NEW_USER_NAME, + }, + session: { + id: "ba-session-new", + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, +}; + +let mockGetAuth: ReturnType; +let mockGetSession: ReturnType; +let existingClientRow: Record | null = null; +let insertedClientValues: Record | null = null; +let insertShouldThrow: { code?: string } | null = null; + +function makeChainable(data: unknown[]): unknown { + const arr = [...data]; + return new Proxy(arr, { + get(target, prop) { + if (prop === "where" || prop === "orderBy" || prop === "limit") { + return () => makeChainable(target); + } + // @ts-expect-error proxy + return target[prop]; + }, + }); +} + +vi.mock("@groombook/db", () => { + const clients = new Proxy( + { _name: "clients" }, + { get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) } + ); + + return { + getDb: () => ({ + select: () => ({ + from: (table: { _name: string }) => { + if (table._name === "clients") { + return makeChainable(existingClientRow ? [existingClientRow] : []); + } + return makeChainable([]); + }, + }), + insert: (table: { _name: string }) => ({ + values: (vals: Record) => { + if (insertShouldThrow) { + const err = new Error("unique violation") as Error & { code?: string }; + err.code = insertShouldThrow.code; + throw err; + } + return { + returning: () => { + if (table._name === "clients") { + insertedClientValues = { id: NEW_USER_ID, ...vals }; + return [insertedClientValues]; + } + return []; + }, + }; + }, + }), + }), + clients, + eq: vi.fn(), + and: vi.fn(), + inArray: vi.fn(), + }; +}); + +vi.mock("../lib/auth.js", () => ({ + getAuth: vi.fn(), +})); + +const { portalRouter } = await import("../routes/portal.js"); + +const app = new Hono(); +app.route("/portal", portalRouter); + +describe("POST /portal/clients-from-auth (GRO-2359)", () => { + beforeEach(() => { + existingClientRow = null; + insertedClientValues = null; + insertShouldThrow = null; + mockGetSession = vi.fn(); + mockGetAuth = vi.fn(() => ({ + api: { + getSession: mockGetSession, + }, + })); + vi.mocked(getAuth).mockImplementation(mockGetAuth); + }); + + it("returns 401 when no Better Auth session is present", async () => { + mockGetSession.mockResolvedValue(null); + const res = await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Test User" }), + }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 400 when body fails zod validation (empty name)", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + const res = await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "" }), + }); + expect(res.status).toBe(400); + }); + + it("creates a new client row bound to the auth user's email and returns 201", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + const res = await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: " New SSO User ", + phone: "555-1234", + address: "1 Main St", + notes: "test note", + }), + }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body).toMatchObject({ + id: NEW_USER_ID, + name: "New SSO User", + email: NEW_USER_EMAIL, + }); + // Trim must be applied to the persisted values. + expect(insertedClientValues).not.toBeNull(); + expect((insertedClientValues as Record).name).toBe("New SSO User"); + expect((insertedClientValues as Record).email).toBe(NEW_USER_EMAIL); + expect((insertedClientValues as Record).phone).toBe("555-1234"); + }); + + it("normalizes empty optional fields to null on insert", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Test", phone: "", address: " " }), + }); + expect(insertedClientValues).not.toBeNull(); + expect((insertedClientValues as Record).phone).toBeNull(); + expect((insertedClientValues as Record).address).toBeNull(); + }); + + it("returns 409 when a client row already exists for this email", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + existingClientRow = { id: "existing-client-id", email: NEW_USER_EMAIL }; + const res = await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Test" }), + }); + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.error).toMatch(/already exists/i); + expect(insertedClientValues).toBeNull(); + }); + + it("returns 409 on unique constraint race (23505)", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + insertShouldThrow = { code: "23505" }; + const res = await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Test" }), + }); + expect(res.status).toBe(409); + }); + + it("returns 503 when auth is not configured", async () => { + mockGetAuth.mockImplementation(() => { + throw new Error("Auth not initialized"); + }); + const res = await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Test" }), + }); + expect(res.status).toBe(503); + }); +}); diff --git a/src/routes/portal.ts b/src/routes/portal.ts index 487861d..17425a3 100644 --- a/src/routes/portal.ts +++ b/src/routes/portal.ts @@ -147,6 +147,114 @@ portalRouter.post("/session-from-auth", async (c) => { ); }); +// GRO-2359 — register a brand-new SSO user. The post-auth handler in the +// web portal redirects here when `session-from-auth` returns 404, so the +// OOBE can complete a customer record for the new user. Auth is via the +// Better Auth session (same shape as `session-from-auth`), so this is +// registered BEFORE the `validatePortalSession` middleware. +// +// Contract: +// POST /api/portal/clients-from-auth +// Body: { name: string; phone?: string|null; address?: string|null; notes?: string|null } +// 201: { id, name, email } +// 400: invalid body (zod failure) +// 401: no Better Auth session +// 409: a `clients` row already exists for this email (portal selection case) +// 500: insert failed +// +// We do NOT auto-link the user's auth account to the new client row; the +// existing `session-from-auth` endpoint re-resolves the row by email on the +// next call, so the OOBE's success path just navigates the user back to +// `/` and lets the bridge mint a portal session. +const createClientFromAuthSchema = z.object({ + name: z.string().min(1).max(200), + phone: z.string().max(50).nullish(), + address: z.string().max(500).nullish(), + notes: z.string().max(2000).nullish(), +}); + +portalRouter.post( + "/clients-from-auth", + zValidator("json", createClientFromAuthSchema), + async (c) => { + let auth; + try { + auth = getAuth(); + } catch { + return c.json({ error: "Authentication not configured" }, 503); + } + + const session = await auth.api.getSession({ + headers: c.req.raw.headers, + }); + + if (!session) { + return c.json({ error: "Unauthorized" }, 401); + } + + const body = c.req.valid("json"); + const db = getDb(); + + // Pre-check: if a client already exists for this email, return 409 so + // the OOBE can render the "portal selection" message (the user needs + // to contact their groomer to link the new SSO identity to the + // pre-existing customer record). We don't return the existing row to + // avoid leaking PII about other accounts. + const [existing] = await db + .select({ id: clients.id }) + .from(clients) + .where(eq(clients.email, session.user.email)) + .limit(1); + + if (existing) { + return c.json( + { error: "A customer record with this email already exists" }, + 409, + ); + } + + let row; + try { + [row] = await db + .insert(clients) + .values({ + name: body.name.trim(), + email: session.user.email, + phone: body.phone?.trim() || null, + address: body.address?.trim() || null, + notes: body.notes?.trim() || null, + }) + .returning(); + } catch (err) { + // Concurrent insert from a parallel OOBE submit — treat as 409. + if ( + err instanceof Error && + "code" in err && + (err as { code?: string }).code === "23505" + ) { + return c.json( + { error: "A customer record with this email already exists" }, + 409, + ); + } + throw err; + } + + if (!row) { + return c.json({ error: "Failed to create client" }, 500); + } + + return c.json( + { + id: row.id, + name: row.name, + email: row.email, + }, + 201, + ); + }, +); + // Apply middleware to all portal routes portalRouter.use("/*", validatePortalSession, portalAudit); From c01e4acf0aa79ff3fe3e69ed12d56fa906554729 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Thu, 18 Jun 2026 00:46:29 +0000 Subject: [PATCH 19/34] feat(GRO-2425): split CORS_ORIGIN on commas for multiple trusted auth origins (#216) feat(GRO-2425): split CORS_ORIGIN on commas for multiple trusted auth origins Co-authored-by: Flea Flicker Co-committed-by: Flea Flicker --- UAT_PLAYBOOK.md | 2 ++ src/lib/auth.ts | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 2a85e1d..66ef0d5 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -108,6 +108,8 @@ Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the | TC-API-1.24 | Complete setup creates super user | POST /api/setup with business name (after TC-API-1.23) | First user becomes super user, setup completes | Setup errors, 403 on admin endpoints | | TC-API-1.25 | Super user accesses admin features | After TC-API-1.24, GET /api/staff/me and verify isSuperUser: true | isSuperUser: true, admin endpoints accessible | 403 on admin, isSuperUser: false | | TC-API-1.26 | Auto-provision skipped during OOBE | During fresh setup (needsSetup: true), complete OIDC login — verify no duplicate staff record created before setup completes | No duplicate staff, OOBE completes successfully | Duplicate staff record, 403 before setup, auto-provision interferes with OOBE | +| TC-API-1.27 | Multi-origin CORS — demo host sign-in | `POST /api/auth/sign-in/social` with `callbackURL=https://demo.groombook.dev` | 200 OK, no origin-mismatch error | 400/403 "Origin mismatch" | +| TC-API-1.28 | Multi-origin CORS — farh.net host sign-in | `POST /api/auth/sign-in/social` with `callbackURL=https://groombook.farh.net` | 200 OK, no origin-mismatch error | 400/403 "Origin mismatch" | ### 4.2 Client Management diff --git a/src/lib/auth.ts b/src/lib/auth.ts index ff1e125..b28153d 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -118,7 +118,8 @@ export async function initAuth(): Promise { updateAge: 60 * 60 * 24, cookieCache: { enabled: false }, }, - trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"], + trustedOrigins: (process.env.CORS_ORIGIN ?? "http://localhost:5173") + .split(",").map((s) => s.trim()).filter(Boolean), }); return; } @@ -308,7 +309,8 @@ export async function initAuth(): Promise { maxAge: 5 * 60, // 5 minutes }, }, - trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"], + trustedOrigins: (process.env.CORS_ORIGIN ?? "http://localhost:5173") + .split(",").map((s) => s.trim()).filter(Boolean), }); })(); From 63d7aaa8c29a55583793f6580e70738ca15f752c Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Thu, 18 Jun 2026 01:30:43 +0000 Subject: [PATCH 20/34] =?UTF-8?q?chore:=20promote=20dev=20=E2=86=92=20uat?= =?UTF-8?q?=20(GRO-2425=20comma-split=20CORS=5FORIGIN)=20(#217)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chore: promote dev → uat (GRO-2425 comma-split CORS_ORIGIN) Co-authored-by: Flea Flicker Co-committed-by: Flea Flicker --- UAT_PLAYBOOK.md | 2 ++ src/lib/auth.ts | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 2a85e1d..66ef0d5 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -108,6 +108,8 @@ Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the | TC-API-1.24 | Complete setup creates super user | POST /api/setup with business name (after TC-API-1.23) | First user becomes super user, setup completes | Setup errors, 403 on admin endpoints | | TC-API-1.25 | Super user accesses admin features | After TC-API-1.24, GET /api/staff/me and verify isSuperUser: true | isSuperUser: true, admin endpoints accessible | 403 on admin, isSuperUser: false | | TC-API-1.26 | Auto-provision skipped during OOBE | During fresh setup (needsSetup: true), complete OIDC login — verify no duplicate staff record created before setup completes | No duplicate staff, OOBE completes successfully | Duplicate staff record, 403 before setup, auto-provision interferes with OOBE | +| TC-API-1.27 | Multi-origin CORS — demo host sign-in | `POST /api/auth/sign-in/social` with `callbackURL=https://demo.groombook.dev` | 200 OK, no origin-mismatch error | 400/403 "Origin mismatch" | +| TC-API-1.28 | Multi-origin CORS — farh.net host sign-in | `POST /api/auth/sign-in/social` with `callbackURL=https://groombook.farh.net` | 200 OK, no origin-mismatch error | 400/403 "Origin mismatch" | ### 4.2 Client Management diff --git a/src/lib/auth.ts b/src/lib/auth.ts index ff1e125..b28153d 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -118,7 +118,8 @@ export async function initAuth(): Promise { updateAge: 60 * 60 * 24, cookieCache: { enabled: false }, }, - trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"], + trustedOrigins: (process.env.CORS_ORIGIN ?? "http://localhost:5173") + .split(",").map((s) => s.trim()).filter(Boolean), }); return; } @@ -308,7 +309,8 @@ export async function initAuth(): Promise { maxAge: 5 * 60, // 5 minutes }, }, - trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"], + trustedOrigins: (process.env.CORS_ORIGIN ?? "http://localhost:5173") + .split(",").map((s) => s.trim()).filter(Boolean), }); })(); From dace2c4e66ffb6008f39d1a193d4240c4f2443e1 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Fri, 26 Jun 2026 13:35:59 +0000 Subject: [PATCH 21/34] fix(GRO-2586): enforce trusted-origins allowlist on Better Auth CORS responses (#219) fix(GRO-2586): enforce trusted-origins allowlist on Better Auth CORS responses Co-Authored-By: Paperclip --- UAT_PLAYBOOK.md | 3 ++ src/__tests__/authCors.test.ts | 60 ++++++++++++++++++++++++++++++++++ src/index.ts | 6 ++-- src/lib/auth-cors.ts | 22 +++++++++++++ 4 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/authCors.test.ts create mode 100644 src/lib/auth-cors.ts diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 66ef0d5..60acc87 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -110,6 +110,9 @@ Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the | TC-API-1.26 | Auto-provision skipped during OOBE | During fresh setup (needsSetup: true), complete OIDC login — verify no duplicate staff record created before setup completes | No duplicate staff, OOBE completes successfully | Duplicate staff record, 403 before setup, auto-provision interferes with OOBE | | TC-API-1.27 | Multi-origin CORS — demo host sign-in | `POST /api/auth/sign-in/social` with `callbackURL=https://demo.groombook.dev` | 200 OK, no origin-mismatch error | 400/403 "Origin mismatch" | | TC-API-1.28 | Multi-origin CORS — farh.net host sign-in | `POST /api/auth/sign-in/social` with `callbackURL=https://groombook.farh.net` | 200 OK, no origin-mismatch error | 400/403 "Origin mismatch" | +| TC-API-1.29 | CORS — untrusted origin blocked (GRO-2586) | POST /api/auth/sign-in/social with `Origin: https://evil.example.com` header | Response has **no** `Access-Control-Allow-Origin` header — attacker origin is not reflected | `Access-Control-Allow-Origin: https://evil.example.com` present in response | +| TC-API-1.30 | CORS — trusted origin allowed (GRO-2586) | POST /api/auth/sign-in/social with `Origin: https://uat.groombook.dev` header | `Access-Control-Allow-Origin: https://uat.groombook.dev` + `Access-Control-Allow-Credentials: true` | CORS header absent or trusted origin rejected | +| TC-API-1.31 | CORS — untrusted preflight blocked (GRO-2586) | `curl -i -X OPTIONS https://uat.groombook.dev/api/auth/sign-in/social -H 'Origin: https://evil.example.com' -H 'Access-Control-Request-Method: POST'` | Response has **no** `Access-Control-Allow-Origin: https://evil.example.com` | Preflight reflects attacker origin | ### 4.2 Client Management diff --git a/src/__tests__/authCors.test.ts b/src/__tests__/authCors.test.ts new file mode 100644 index 0000000..2603279 --- /dev/null +++ b/src/__tests__/authCors.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from "vitest"; +import { enforceAuthCors } from "../lib/auth-cors.js"; + +const TRUSTED = ["https://uat.groombook.dev", "https://dev.groombook.dev"]; + +/** Simulates Better Auth reflecting the request Origin (the pre-fix bug). */ +function makeReflectedResponse(origin: string | null): Response { + return new Response('{"ok":true}', { + status: 200, + headers: { + "Content-Type": "application/json", + ...(origin + ? { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Credentials": "true", + } + : {}), + }, + }); +} + +describe("enforceAuthCors (GRO-2586)", () => { + it("passes trusted origin through with credentials", () => { + const origin = "https://uat.groombook.dev"; + const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin)); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe(origin); + expect(res.headers.get("Access-Control-Allow-Credentials")).toBe("true"); + }); + + it("strips ACAO for attacker origin (credentialed cross-origin read blocked)", () => { + const origin = "https://evil.example.com"; + const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin)); + expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull(); + expect(res.headers.get("Access-Control-Allow-Credentials")).toBeNull(); + }); + + it("strips ACAO when no Origin header (undefined)", () => { + const res = enforceAuthCors(undefined, TRUSTED, makeReflectedResponse(null)); + expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull(); + expect(res.headers.get("Access-Control-Allow-Credentials")).toBeNull(); + }); + + it("preserves non-CORS response headers and status from Better Auth", () => { + const origin = "https://evil.example.com"; + const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin)); + expect(res.headers.get("Content-Type")).toBe("application/json"); + expect(res.status).toBe(200); + }); + + it("second trusted origin is also allowed", () => { + const origin = "https://dev.groombook.dev"; + const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin)); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe(origin); + }); + + it("empty string origin is treated as untrusted", () => { + const res = enforceAuthCors("", TRUSTED, makeReflectedResponse("")); + expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull(); + }); +}); diff --git a/src/index.ts b/src/index.ts index 681d731..6c0c930 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { Hono } from "hono"; import { logger } from "hono/logger"; import { cors } from "hono/cors"; import { getAuth, initAuth, getActiveProviders } from "./lib/auth.js"; +import { enforceAuthCors } from "./lib/auth-cors.js"; import { clientsRouter } from "./routes/clients.js"; import { petsRouter } from "./routes/pets.js"; import { servicesRouter } from "./routes/services.js"; @@ -200,9 +201,10 @@ 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) => { +authRouter.all("/*", async (c) => { try { - return getAuth().handler(c.req.raw); + const res = await getAuth().handler(c.req.raw); + return enforceAuthCors(c.req.header("origin"), TRUSTED_ORIGINS, res); } catch { return c.json({ error: "Authentication not configured" }, 503); } diff --git a/src/lib/auth-cors.ts b/src/lib/auth-cors.ts new file mode 100644 index 0000000..bd68f38 --- /dev/null +++ b/src/lib/auth-cors.ts @@ -0,0 +1,22 @@ +/** + * Enforces the trusted-origins CORS allowlist on a raw Response from Better Auth. + * Better Auth reflects the request Origin into Access-Control-Allow-Origin + * regardless of the trustedOrigins config, allowing credentialed cross-origin reads + * from arbitrary attacker origins. This wrapper strips CORS headers for any origin + * not in the allowlist. (GRO-2586) + */ +export function enforceAuthCors( + requestOrigin: string | undefined, + trustedOrigins: string[], + res: Response +): Response { + const headers = new Headers(res.headers); + if (requestOrigin && trustedOrigins.includes(requestOrigin)) { + headers.set("Access-Control-Allow-Origin", requestOrigin); + headers.set("Access-Control-Allow-Credentials", "true"); + } else { + headers.delete("Access-Control-Allow-Origin"); + headers.delete("Access-Control-Allow-Credentials"); + } + return new Response(res.body, { status: res.status, statusText: res.statusText, headers }); +} From 2d4edb6452cd4acde214f216a616a360308bee3a Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Fri, 26 Jun 2026 13:46:44 +0000 Subject: [PATCH 22/34] =?UTF-8?q?promote(GRO-2586):=20dev=20=E2=86=92=20ua?= =?UTF-8?q?t=20=E2=80=94=20CORS=20origin=20allowlist=20enforcement=20(#220?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit promote(GRO-2586): dev → uat — CORS origin allowlist enforcement --- UAT_PLAYBOOK.md | 3 ++ src/__tests__/authCors.test.ts | 60 ++++++++++++++++++++++++++++++++++ src/index.ts | 6 ++-- src/lib/auth-cors.ts | 22 +++++++++++++ 4 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/authCors.test.ts create mode 100644 src/lib/auth-cors.ts diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 66ef0d5..60acc87 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -110,6 +110,9 @@ Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the | TC-API-1.26 | Auto-provision skipped during OOBE | During fresh setup (needsSetup: true), complete OIDC login — verify no duplicate staff record created before setup completes | No duplicate staff, OOBE completes successfully | Duplicate staff record, 403 before setup, auto-provision interferes with OOBE | | TC-API-1.27 | Multi-origin CORS — demo host sign-in | `POST /api/auth/sign-in/social` with `callbackURL=https://demo.groombook.dev` | 200 OK, no origin-mismatch error | 400/403 "Origin mismatch" | | TC-API-1.28 | Multi-origin CORS — farh.net host sign-in | `POST /api/auth/sign-in/social` with `callbackURL=https://groombook.farh.net` | 200 OK, no origin-mismatch error | 400/403 "Origin mismatch" | +| TC-API-1.29 | CORS — untrusted origin blocked (GRO-2586) | POST /api/auth/sign-in/social with `Origin: https://evil.example.com` header | Response has **no** `Access-Control-Allow-Origin` header — attacker origin is not reflected | `Access-Control-Allow-Origin: https://evil.example.com` present in response | +| TC-API-1.30 | CORS — trusted origin allowed (GRO-2586) | POST /api/auth/sign-in/social with `Origin: https://uat.groombook.dev` header | `Access-Control-Allow-Origin: https://uat.groombook.dev` + `Access-Control-Allow-Credentials: true` | CORS header absent or trusted origin rejected | +| TC-API-1.31 | CORS — untrusted preflight blocked (GRO-2586) | `curl -i -X OPTIONS https://uat.groombook.dev/api/auth/sign-in/social -H 'Origin: https://evil.example.com' -H 'Access-Control-Request-Method: POST'` | Response has **no** `Access-Control-Allow-Origin: https://evil.example.com` | Preflight reflects attacker origin | ### 4.2 Client Management diff --git a/src/__tests__/authCors.test.ts b/src/__tests__/authCors.test.ts new file mode 100644 index 0000000..2603279 --- /dev/null +++ b/src/__tests__/authCors.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from "vitest"; +import { enforceAuthCors } from "../lib/auth-cors.js"; + +const TRUSTED = ["https://uat.groombook.dev", "https://dev.groombook.dev"]; + +/** Simulates Better Auth reflecting the request Origin (the pre-fix bug). */ +function makeReflectedResponse(origin: string | null): Response { + return new Response('{"ok":true}', { + status: 200, + headers: { + "Content-Type": "application/json", + ...(origin + ? { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Credentials": "true", + } + : {}), + }, + }); +} + +describe("enforceAuthCors (GRO-2586)", () => { + it("passes trusted origin through with credentials", () => { + const origin = "https://uat.groombook.dev"; + const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin)); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe(origin); + expect(res.headers.get("Access-Control-Allow-Credentials")).toBe("true"); + }); + + it("strips ACAO for attacker origin (credentialed cross-origin read blocked)", () => { + const origin = "https://evil.example.com"; + const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin)); + expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull(); + expect(res.headers.get("Access-Control-Allow-Credentials")).toBeNull(); + }); + + it("strips ACAO when no Origin header (undefined)", () => { + const res = enforceAuthCors(undefined, TRUSTED, makeReflectedResponse(null)); + expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull(); + expect(res.headers.get("Access-Control-Allow-Credentials")).toBeNull(); + }); + + it("preserves non-CORS response headers and status from Better Auth", () => { + const origin = "https://evil.example.com"; + const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin)); + expect(res.headers.get("Content-Type")).toBe("application/json"); + expect(res.status).toBe(200); + }); + + it("second trusted origin is also allowed", () => { + const origin = "https://dev.groombook.dev"; + const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin)); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe(origin); + }); + + it("empty string origin is treated as untrusted", () => { + const res = enforceAuthCors("", TRUSTED, makeReflectedResponse("")); + expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull(); + }); +}); diff --git a/src/index.ts b/src/index.ts index 681d731..6c0c930 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { Hono } from "hono"; import { logger } from "hono/logger"; import { cors } from "hono/cors"; import { getAuth, initAuth, getActiveProviders } from "./lib/auth.js"; +import { enforceAuthCors } from "./lib/auth-cors.js"; import { clientsRouter } from "./routes/clients.js"; import { petsRouter } from "./routes/pets.js"; import { servicesRouter } from "./routes/services.js"; @@ -200,9 +201,10 @@ 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) => { +authRouter.all("/*", async (c) => { try { - return getAuth().handler(c.req.raw); + const res = await getAuth().handler(c.req.raw); + return enforceAuthCors(c.req.header("origin"), TRUSTED_ORIGINS, res); } catch { return c.json({ error: "Authentication not configured" }, 503); } diff --git a/src/lib/auth-cors.ts b/src/lib/auth-cors.ts new file mode 100644 index 0000000..bd68f38 --- /dev/null +++ b/src/lib/auth-cors.ts @@ -0,0 +1,22 @@ +/** + * Enforces the trusted-origins CORS allowlist on a raw Response from Better Auth. + * Better Auth reflects the request Origin into Access-Control-Allow-Origin + * regardless of the trustedOrigins config, allowing credentialed cross-origin reads + * from arbitrary attacker origins. This wrapper strips CORS headers for any origin + * not in the allowlist. (GRO-2586) + */ +export function enforceAuthCors( + requestOrigin: string | undefined, + trustedOrigins: string[], + res: Response +): Response { + const headers = new Headers(res.headers); + if (requestOrigin && trustedOrigins.includes(requestOrigin)) { + headers.set("Access-Control-Allow-Origin", requestOrigin); + headers.set("Access-Control-Allow-Credentials", "true"); + } else { + headers.delete("Access-Control-Allow-Origin"); + headers.delete("Access-Control-Allow-Credentials"); + } + return new Response(res.body, { status: res.status, statusText: res.statusText, headers }); +} From 632dadd0644f49ee3e6af06aca9a3c46ccccdfe2 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Wed, 5 Aug 2026 08:17:31 +0000 Subject: [PATCH 23/34] fix(GRO-2652): retry DB query in initAuth on transient ECONNRESET The auth_provider_config DB query at boot has no error handling; a transient ECONNRESET causes authInitPromise to reject, propagating to the top-level await initAuth() and crashing the process (exit 1). Add up to 5 retry attempts with exponential backoff (1 s, 2 s, 4 s, 8 s) so a single connection reset does not abort initialization. Co-Authored-By: Paperclip --- src/lib/auth.ts | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/lib/auth.ts b/src/lib/auth.ts index b28153d..9ec3520 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -124,13 +124,28 @@ export async function initAuth(): Promise { return; } - // Step 1: Try to load config from DB + // Step 1: Try to load config from DB, with retry-with-backoff for transient ECONNRESET (GRO-2652). + // A single connection reset during boot must not abort initialization. const db = getDb(); - const [dbConfig] = await db - .select() - .from(authProviderConfig) - .where(eq(authProviderConfig.enabled, true)) - .limit(1); + let dbQueryRows: (typeof authProviderConfig.$inferSelect)[] = []; + let dbAttempt = 0; + while (true) { + try { + dbQueryRows = await db + .select() + .from(authProviderConfig) + .where(eq(authProviderConfig.enabled, true)) + .limit(1); + break; + } catch (err) { + dbAttempt++; + if (dbAttempt >= 5) throw err; + const delay = Math.min(1000 * 2 ** (dbAttempt - 1), 8_000); + console.warn(`[auth] DB query attempt ${dbAttempt} failed (${err}), retrying in ${delay}ms`); + await new Promise((r) => setTimeout(r, delay)); + } + } + const [dbConfig] = dbQueryRows; let providerConfig: { providerId: string; From 095efb1cca2b32662ae9d62f8337b4eb398023a5 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Wed, 5 Aug 2026 08:21:14 +0000 Subject: [PATCH 24/34] fix(GRO-2652): start server before initAuth; retry auth init on ECONNRESET MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously: await initAuth() was a top-level ESM await. Any boot-time ECONNRESET from Postgres propagated as an uncaught module-evaluation error and killed the process before the server even started. Now: - serve() starts immediately so /health and public routes are available - initAuth() runs in a retry loop (up to 10 attempts, exponential 500 ms → 30 s); a permanent failure degrades to 503 on auth routes (existing catch-to-503 in authRouter) rather than crashing the pod Co-Authored-By: Paperclip --- src/index.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 6c0c930..e08a2ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -292,14 +292,34 @@ api.route("/search", searchRouter); api.route("/buffer-rules", bufferRulesRouter); api.route("/routes", routesRouter); +// Start the HTTP server first so /health and public routes are available immediately. +// Auth initialization runs afterward with retry — a transient DB ECONNRESET at boot +// must not crash the process (GRO-2652). Auth routes return 503 until initAuth succeeds. const port = Number(process.env.PORT ?? 3000); -await initAuth(); -console.log(`API server listening on port ${port}`); const server = serve({ fetch: app.fetch, port }); +console.log(`API server listening on port ${port}`); // Start background reminder scheduler (runs every minute to check for upcoming appointments) startReminderScheduler(); +let initAttempt = 0; +while (true) { + try { + await initAuth(); + break; + } catch (err) { + initAttempt++; + const delay = Math.min(2 ** initAttempt * 500, 30_000); + console.error(`[auth] initAuth attempt ${initAttempt} failed: ${err}`); + if (initAttempt >= 10) { + console.error("[auth] auth init permanently failed — auth endpoints will serve 503"); + break; + } + console.error(`[auth] retrying in ${delay}ms`); + await new Promise((r) => setTimeout(r, delay)); + } +} + function shutdown() { console.log("Shutting down gracefully..."); // SIGTERM/SIGINT → server.close() → callback → process.exit(0) From 8d42bfffc9f2057dff690d38d4daee155921da76 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Wed, 5 Aug 2026 08:22:00 +0000 Subject: [PATCH 25/34] test(GRO-2652): add boot resilience UAT test cases (TC-API-19.x) New section 4.19 verifies: - /health available before initAuth completes - auth routes return 503 (not crash) during init retry window - pod restart count stays stable after deploy - retry log lines emitted correctly - auth recovers after transient DB hiccup Co-Authored-By: Paperclip --- UAT_PLAYBOOK.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 60acc87..7b5f9d8 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -439,6 +439,22 @@ Both use the stops' stored `latitude`/`longitude` in `stopOrder`: **origin = fir | TC-API-18.10 | Groomer cannot export another's route | As groomer, export a route owned by a different groomer | 403 Forbidden (`groomers may only access their own route`) | | TC-API-18.11 | Receptionist denied | As **receptionist**, export any route | 403 Forbidden (role not permitted) | + +### 4.19 Boot Resilience — ECONNRESET Recovery (GRO-2652) + +Verifies the API process does not crash on transient boot-time DB connection resets and that auth routes degrade gracefully until initialization succeeds. + +| TC | Test Case | Steps | Expected Result | +|----|-----------|-------|-----------------| +| TC-API-19.1 | Health endpoint available before auth init | 1. Deploy the image (or restart the api pod)
2. `GET /health` immediately (within first 2 s of pod start) | 200 `{"status":"ok"}` — server accepts requests before `initAuth()` completes | +| TC-API-19.2 | Auth routes return 503 when auth not yet initialized | 1. Temporarily set `OIDC_ISSUER` to an unreachable host so `initAuth()` keeps retrying
2. `POST /api/auth/sign-in/email` during the retry window | 503 `{"error":"Authentication not configured"}` — process stays alive, does not exit | +| TC-API-19.3 | Pod does not crash on first-attempt DB reset | 1. Review pod restart count after normal deployment
2. Confirm `kubectl get pod -n groombook` shows `RESTARTS: 0` (or same as before deploy) for the new pod | No new restarts — ECONNRESET causes retry, not process exit | +| TC-API-19.4 | DB query retry log lines visible | After deploy, `kubectl logs -n groombook ` | If any DB retry occurred, log lines matching `[auth] DB query attempt N failed` are present; on clean boot no retry lines appear | +| TC-API-19.5 | Auth init retry log lines visible | When auth init fails and retries, check pod logs | Log lines matching `[auth] initAuth attempt N failed` present; process continues; no `process.exit` | +| TC-API-19.6 | Auth succeeds after transient DB hiccup | 1. Allow pod to retry until DB is available
2. `POST /api/auth/sign-in/email` with valid credentials after init succeeds | 200 with session cookie — auth recovers without pod restart | +| TC-API-19.7 | Normal sign-in still works end-to-end | Follow TC-WEB-SSO-3 (SSO sign-in) on UAT | Successful sign-in, staff list visible — no regression from resilience changes | +| TC-API-19.8 | Public routes unaffected during auth retry | While auth is retrying (TC-API-19.2 setup), `GET /api/branding` | 200 with branding data — public routes bypass auth and serve normally | + ## Pass/Fail Criteria **Pass:** From 713e8bf415d05dab899f6c540f0956f1c47ea5b7 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Wed, 5 Aug 2026 08:54:00 +0000 Subject: [PATCH 26/34] ci: add ignore-error=true to all cache-to targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gitea OCI registry sporadically rejects cache blob writes with "error writing layer blob: unknown" — this fails the build step even though the image itself was pushed successfully. Adding ignore-error=true makes cache write failures non-fatal so the build proceeds regardless of registry-side cache issues. Fixes recurring CI failure on Build and push Seed image step. --- .gitea/workflows/ci.yml | 188 +--------------------------------------- 1 file changed, 1 insertion(+), 187 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 1529ed5..f523997 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,187 +1 @@ -name: CI - -on: - push: - branches: [main, dev, uat] - pull_request: - branches: [main, dev, uat] - workflow_dispatch: - inputs: - ref: - description: "Branch or ref to run CI against" - required: false - default: "main" - -jobs: - lint-typecheck: - name: Lint & Typecheck - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v4 - with: - version: '9.15.4' - - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Typecheck - run: | - pnpm run typecheck - pnpm --filter @groombook/db typecheck - - - name: Lint - run: pnpm run lint - - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v4 - with: - version: '9.15.4' - - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Run tests - run: pnpm run test - - docker: - name: Build & Push Docker Images - runs-on: ubuntu-latest - needs: [lint-typecheck, test] - steps: - - uses: actions/checkout@v4 - - - name: Generate image tag - id: version - run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - TAG="pr-${{ github.event.pull_request.number }}-${GITHUB_SHA::7}" - else - TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}" - fi - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "Image tag: $TAG" - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - with: - driver-opts: network=host - - - name: Log in to Gitea Container Registry - uses: docker/login-action@v3 - with: - registry: git.farh.net - username: ${{ gitea.actor }} - password: ${{ secrets.REGISTRY_TOKEN }} - - - name: Build and push API image - uses: docker/build-push-action@v6 - with: - provenance: false - context: . - file: Dockerfile - target: runner - push: true - tags: | - git.farh.net/groombook/api:${{ steps.version.outputs.tag }} - ${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/api:latest' || '' }} - cache-from: type=registry,ref=git.farh.net/groombook/cache:api - cache-to: type=registry,ref=git.farh.net/groombook/cache:api,mode=max - - - name: Build and push Migrate image - uses: docker/build-push-action@v6 - with: - provenance: false - context: . - file: Dockerfile - target: migrate - push: true - tags: | - git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }} - ${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/migrate:latest' || '' }} - cache-from: type=registry,ref=git.farh.net/groombook/cache:migrate - cache-to: type=registry,ref=git.farh.net/groombook/cache:migrate,mode=max - - - name: Smoke test migrate image (blackhole npmjs.org) - run: | - set -euo pipefail - IMAGE="git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }}" - docker pull "$IMAGE" - docker run --rm \ - --add-host registry.npmjs.org:127.0.0.1 \ - --entrypoint="" \ - "$IMAGE" \ - pnpm --version - - - name: Build and push Seed image - uses: docker/build-push-action@v6 - with: - provenance: false - context: . - file: Dockerfile - target: seed - push: true - tags: | - git.farh.net/groombook/seed:${{ steps.version.outputs.tag }} - ${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/seed:latest' || '' }} - cache-from: type=registry,ref=git.farh.net/groombook/cache:seed - cache-to: type=registry,ref=git.farh.net/groombook/cache:seed,mode=max - - - name: Build and push Reset image - uses: docker/build-push-action@v6 - with: - provenance: false - context: . - file: Dockerfile - target: reset - push: true - tags: | - git.farh.net/groombook/reset:${{ steps.version.outputs.tag }} - ${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/reset:latest' || '' }} - cache-from: type=registry,ref=git.farh.net/groombook/cache:reset - cache-to: type=registry,ref=git.farh.net/groombook/cache:reset,mode=max - - - name: Smoke test seed image (blackhole npmjs.org) - run: | - set -euo pipefail - IMAGE="git.farh.net/groombook/seed:${{ steps.version.outputs.tag }}" - docker pull "$IMAGE" - # GRO-1985: pnpm must be a real binary, not a Corepack shim, and must - # not try to reach registry.npmjs.org on invocation. - docker run --rm \ - --add-host registry.npmjs.org:127.0.0.1 \ - --entrypoint="" \ - "$IMAGE" \ - sh -c 'set -e; test "$(which pnpm)" = "/usr/local/bin/pnpm"; pnpm --version' - echo "seed image: pnpm resolves to /usr/local/bin/pnpm and runs offline ✓" - - - name: Smoke test reset image (blackhole npmjs.org) - run: | - set -euo pipefail - IMAGE="git.farh.net/groombook/reset:${{ steps.version.outputs.tag }}" - docker pull "$IMAGE" - # GRO-1985: pnpm must be a real binary, not a Corepack shim, and must - # not try to reach registry.npmjs.org on invocation. Validates the - # hard requirement from the issue: reset runs offline. - docker run --rm \ - --add-host registry.npmjs.org:127.0.0.1 \ - --entrypoint="" \ - "$IMAGE" \ - sh -c 'set -e; test "$(which pnpm)" = "/usr/local/bin/pnpm"; echo "HOME=$HOME"; pnpm --version' - echo "reset image: pnpm resolves to /usr/local/bin/pnpm, HOME=/tmp, runs offline ✓" +bmFtZTogQ0kKCm9uOgogIHB1c2g6CiAgICBicmFuY2hlczogW21haW4sIGRldiwgdWF0XQogIHB1bGxfcmVxdWVzdDoKICAgIGJyYW5jaGVzOiBbbWFpbiwgZGV2LCB1YXRdCiAgd29ya2Zsb3dfZGlzcGF0Y2g6CiAgICBpbnB1dHM6CiAgICAgIHJlZjoKICAgICAgICBkZXNjcmlwdGlvbjogIkJyYW5jaCBvciByZWYgdG8gcnVuIENJIGFnYWluc3QiCiAgICAgICAgcmVxdWlyZWQ6IGZhbHNlCiAgICAgICAgZGVmYXVsdDogIm1haW4iCgpqb2JzOgogIGxpbnQtdHlwZWNoZWNrOgogICAgbmFtZTogTGludCAmIFR5cGVjaGVjawogICAgcnVucy1vbjogdWJ1bnR1LWxhdGVzdAogICAgc3RlcHM6CiAgICAgIC0gdXNlczogYWN0aW9ucy9jaGVja291dEB2NAoKICAgICAgLSB1c2VzOiBwbnBtL2FjdGlvbi1zZXR1cEB2NAogICAgICAgIHdpdGg6CiAgICAgICAgICB2ZXJzaW9uOiAnOS4xNS40JwoKICAgICAgLSB1c2VzOiBhY3Rpb25zL3NldHVwLW5vZGVAdjQKICAgICAgICB3aXRoOgogICAgICAgICAgbm9kZS12ZXJzaW9uOiAyMgogICAgICAgICAgY2FjaGU6IHBucG0KCiAgICAgIC0gbmFtZTogSW5zdGFsbCBkZXBlbmRlbmNpZXMKICAgICAgICBydW46IHBucG0gaW5zdGFsbCAtLWZyb3plbi1sb2NrZmlsZQoKICAgICAgLSBuYW1lOiBUeXBlY2hlY2sKICAgICAgICBydW46IHwKICAgICAgICAgIHBucG0gcnVuIHR5cGVjaGVjawogICAgICAgICAgcG5wbSAtLWZpbHRlciBAZ3Jvb21ib29rL2RiIHR5cGVjaGVjawoKICAgICAgLSBuYW1lOiBMaW50CiAgICAgICAgcnVuOiBwbnBtIHJ1biBsaW50CgogIHRlc3Q6CiAgICBuYW1lOiBUZXN0CiAgICBydW5zLW9uOiB1YnVudHUtbGF0ZXN0CiAgICBzdGVwczoKICAgICAgLSB1c2VzOiBhY3Rpb25zL2NoZWNrb3V0QHY0CgogICAgICAtIHVzZXM6IHBucG0vYWN0aW9uLXNldHVwQHY0CiAgICAgICAgd2l0aDoKICAgICAgICAgIHZlcnNpb246ICc5LjE1LjQnCgogICAgICAtIHVzZXM6IGFjdGlvbnMvc2V0dXAtbm9kZUB2NAogICAgICAgIHdpdGg6CiAgICAgICAgICBub2RlLXZlcnNpb246IDIyCiAgICAgICAgICBjYWNoZTogcG5wbQoKICAgICAgLSBuYW1lOiBJbnN0YWxsIGRlcGVuZGVuY2llcwogICAgICAgIHJ1bjogcG5wbSBpbnN0YWxsIC0tZnJvemVuLWxvY2tmaWxlCgogICAgICAtIG5hbWU6IFJ1biB0ZXN0cwogICAgICAgIHJ1bjogcG5wbSBydW4gdGVzdAoKICBkb2NrZXI6CiAgICBuYW1lOiBCdWlsZCAmIFB1c2ggRG9ja2VyIEltYWdlcwogICAgcnVucy1vbjogdWJ1bnR1LWxhdGVzdAogICAgbmVlZHM6IFtsaW50LXR5cGVjaGVjaywgdGVzdF0KICAgIHN0ZXBzOgogICAgICAtIHVzZXM6IGFjdGlvbnMvY2hlY2tvdXRAdjQKCiAgICAgIC0gbmFtZTogR2VuZXJhdGUgaW1hZ2UgdGFnCiAgICAgICAgaWQ6IHZlcnNpb24KICAgICAgICBydW46IHwKICAgICAgICAgIGlmIFsgIiR7eyBnaXRodWIuZXZlbnRfbmFtZSB9fSIgPSAicHVsbF9yZXF1ZXN0IiBdOyB0aGVuCiAgICAgICAgICAgIFRBRz0icHItJHt7IGdpdGh1Yi5ldmVudC5wdWxsX3JlcXVlc3QubnVtYmVyIH19LSR7R0lUSFVCX1NIQTo6N30iCiAgICAgICAgICBlbHNlCiAgICAgICAgICAgIFRBRz0iJChkYXRlIC11ICslWS4lbS4lZCktJHtHSVRIVUJfU0hBOjo3fSIKICAgICAgICAgIGZpCiAgICAgICAgICBlY2hvICJ0YWc9JFRBRyIgPj4gIiRHSVRIVUJfT1VUUFVUIgogICAgICAgICAgZWNobyAiSW1hZ2UgdGFnOiAkVEFHIgoKICAgICAgLSBuYW1lOiBTZXQgdXAgRG9ja2VyIEJ1aWxkeAogICAgICAgIHVzZXM6IGRvY2tlci9zZXR1cC1idWlsZHgtYWN0aW9uQHYzCiAgICAgICAgd2l0aDoKICAgICAgICAgIGRyaXZlci1vcHRzOiBuZXR3b3JrPWhvc3QKCiAgICAgIC0gbmFtZTogTG9nIGluIHRvIEdpdGVhIENvbnRhaW5lciBSZWdpc3RyeQogICAgICAgIHVzZXM6IGRvY2tlci9sb2dpbi1hY3Rpb25AdjMKICAgICAgICB3aXRoOgogICAgICAgICAgcmVnaXN0cnk6IGdpdC5mYXJoLm5ldAogICAgICAgICAgdXNlcm5hbWU6ICR7eyBnaXRlYS5hY3RvciB9fQogICAgICAgICAgcGFzc3dvcmQ6ICR7eyBzZWNyZXRzLlJFR0lTVFJZX1RPS0VOIH19CgogICAgICAtIG5hbWU6IEJ1aWxkIGFuZCBwdXNoIEFQSSBpbWFnZQogICAgICAgIHVzZXM6IGRvY2tlci9idWlsZC1wdXNoLWFjdGlvbkB2NgogICAgICAgIHdpdGg6CiAgICAgICAgICBwcm92ZW5hbmNlOiBmYWxzZQogICAgICAgICAgY29udGV4dDogLgogICAgICAgICAgZmlsZTogRG9ja2VyZmlsZQogICAgICAgICAgdGFyZ2V0OiBydW5uZXIKICAgICAgICAgIHB1c2g6IHRydWUKICAgICAgICAgIHRhZ3M6IHwKICAgICAgICAgICAgZ2l0LmZhcmgubmV0L2dyb29tYm9vay9hcGk6JHt7IHN0ZXBzLnZlcnNpb24ub3V0cHV0cy50YWcgfX0KICAgICAgICAgICAgJHt7IGdpdGh1Yi5yZWYgPT0gJ3JlZnMvaGVhZHMvbWFpbicgJiYgJ2dpdC5mYXJoLm5ldC9ncm9vbWJvb2svYXBpOmxhdGVzdCcgfHwgJycgfX0KICAgICAgICAgIGNhY2hlLWZyb206IHR5cGU9cmVnaXN0cnkscmVmPWdpdC5mYXJoLm5ldC9ncm9vbWJvb2svY2FjaGU6YXBpCiAgICAgICAgICBjYWNoZS10bzogdHlwZT1yZWdpc3RyeSxyZWY9Z2l0LmZhcmgubmV0L2dyb29tYm9vay9jYWNoZTphcGksbW9kZT1tYXgsaWdub3JlLWVycm9yPXRydWUKCiAgICAgIC0gbmFtZTogQnVpbGQgYW5kIHB1c2ggTWlncmF0ZSBpbWFnZQogICAgICAgIHVzZXM6IGRvY2tlci9idWlsZC1wdXNoLWFjdGlvbkB2NgogICAgICAgIHdpdGg6CiAgICAgICAgICBwcm92ZW5hbmNlOiBmYWxzZQogICAgICAgICAgY29udGV4dDogLgogICAgICAgICAgZmlsZTogRG9ja2VyZmlsZQogICAgICAgICAgdGFyZ2V0OiBtaWdyYXRlCiAgICAgICAgICBwdXNoOiB0cnVlCiAgICAgICAgICB0YWdzOiB8CiAgICAgICAgICAgIGdpdC5mYXJoLm5ldC9ncm9vbWJvb2svbWlncmF0ZToke3sgc3RlcHMudmVyc2lvbi5vdXRwdWRzLnRhZyB9fQogICAgICAgICAgICAke3sgZ2l0aHViLnJlZiA9PSAncmVmcy9oZWFkcy9tYWluJyAmJiAnZ2l0LmZhcmgubmV0L2dyb29tYm9vay9taWdyYXRlOmxhdGVzdCcgfHwgJycgfX0KICAgICAgICAgIGNhY2hlLWZyb206IHR5cGU9cmVnaXN0cnkscmVmPWdpdC5mYXJoLm5ldC9ncm9vbWJvb2svY2FjaGU6bWlncmF0ZQogICAgICAgICAgY2FjaGUtdG86IHR5cGU9cmVnaXN0cnkscmVmPWdpdC5mYXJoLm5ldC9ncm9vbWJvb2svY2FjaGU6bWlncmF0ZSxtb2RlPW1heCxpZ25vcmUtZXJyb3I9dHJ1ZQoKICAgICAgLSBuYW1lOiBTbW9rZSB0ZXN0IG1pZ3JhdGUgaW1hZ2UgKGJsYWNraG9sZSBucG1qcy5vcmcpCiAgICAgICAgcnVuOiB8CiAgICAgICAgICBzZXQgLWV1byBwaXBlZmFpbAogICAgICAgICAgSU1BR0U9ImdpdC5mYXJoLm5ldC9ncm9vbWJvb2svbWlncmF0ZToke3sgc3RlcHMudmVyc2lvbi5vdXRwdXRzLnRhZyB9fSIKICAgICAgICAgIGRvY2tlciBwdWxsICIkSU1BR0UiCiAgICAgICAgICBkb2NrZXIgcnVuIC0tcm0gXAogICAgICAgICAgICAtLWFkZC1ob3N0IHJlZ2lzdHJ5Lm5wbWpzLm9yZzoxMjcuMC4wLjEgXAogICAgICAgICAgICAtLWVudHJ5cG9pbnQ9IiIgXAogICAgICAgICAgICAiJElNQUdFIiBcCiAgICAgICAgICAgIHBucG0gLS12ZXJzaW9uCgogICAgICAtIG5hbWU6IEJ1aWxkIGFuZCBwdXNoIFNlZWQgaW1hZ2UKICAgICAgICB1c2VzOiBkb2NrZXIvYnVpbGQtcHVzaC1hY3Rpb25AdjYKICAgICAgICB3aXRoOgogICAgICAgICAgcHJvdmVuYW5jZTogZmFsc2UKICAgICAgICAgIGNvbnRleHQ6IC4KICAgICAgICAgIGZpbGU6IERvY2tlcmZpbGUKICAgICAgICAgIHRhcmdldDogc2VlZAogICAgICAgICAgcHVzaDogdHJ1ZQogICAgICAgICAgdGFnczogfAogICAgICAgICAgICBnaXQuZmFyaC5uZXQvZ3Jvb21ib29rL3NlZWQ6JHt7IHN0ZXBzLnZlcnNpb24ub3V0cHV0cy50YWcgfX0KICAgICAgICAgICAgJHt7IGdpdGh1Yi5yZWYgPT0gJ3JlZnMvaGVhZHMvbWFpbicgJiYgJ2dpdC5mYXJoLm5ldC9ncm9vbWJvb2svc2VlZDpsYXRlc3QnIHx8ICcnIH19CiAgICAgICAgICBjYWNoZS1mcm9tOiB0eXBlPXJlZ2lzdHJ5LHJlZj1naXQuZmFyaC5uZXQvZ3Jvb21ib29rL2NhY2hlOnNlZWQKICAgICAgICAgIGNhY2hlLXRvOiB0eXBlPXJlZ2lzdHJ5LHJlZj1naXQuZmFyaC5uZXQvZ3Jvb21ib29rL2NhY2hlOnNlZWQsbW9kZT1tYXgsaWdub3JlLWVycm9yPXRydWUKCiAgICAgIC0gbmFtZTogQnVpbGQgYW5kIHB1c2ggUmVzZXQgaW1hZ2UKICAgICAgICB1c2VzOiBkb2NrZXIvYnVpbGQtcHVzaC1hY3Rpb25AdjYKICAgICAgICB3aXRoOgogICAgICAgICAgcHJvdmVuYW5jZTogZmFsc2UKICAgICAgICAgIGNvbnRleHQ6IC4KICAgICAgICAgIGZpbGU6IERvY2tlcmZpbGUKICAgICAgICAgIHRhcmdldDogcmVzZXQKICAgICAgICAgIHB1c2g6IHRydWUKICAgICAgICAgIHRhZ3M6IHwKICAgICAgICAgICAgZ2l0LmZhcmgubmV0L2dyb29tYm9vay9yZXNldDoke3sgc3RlcHMudmVyc2lvbi5vdXRwdXRzLnRhZyB9fQogICAgICAgICAgICAke3sgZ2l0aHViLnJlZiA9PSAncmVmcy9oZWFkcy9tYWluJyAmJiAnZ2l0LmZhcmgubmV0L2dyb29tYm9vay9yZXNldDpsYXRlc3QnIHx8ICcnIH19CiAgICAgICAgICBjYWNoZS1mcm9tOiB0eXBlPXJlZ2lzdHJ5LHJlZj1naXQuZmFyaC5uZXQvZ3Jvb21ib29rL2NhY2hlOnJlc2V0CiAgICAgICAgICBjYWNoZS10bzogdHlwZT1yZWdpc3RyeSxyZWY9Z2l0LmZhcmgubmV0L2dyb29tYm9vay9jYWNoZTpyZXNldCxtb2RlPW1heCxpZ25vcmUtZXJyb3I9dHJ1ZQoKICAgICAgLSBuYW1lOiBTbW9rZSB0ZXN0IHNlZWQgaW1hZ2UgKGJsYWNraG9sZSBucG1qcy5vcmcpCiAgICAgICAgcnVuOiB8CiAgICAgICAgICBzZXQgLWV1byBwaXBlZmFpbAogICAgICAgICAgSU1BR0U9ImdpdC5mYXJoLm5ldC9ncm9vbWJvb2svc2VlZDoke3sgc3RlcHMudmVyc2lvbi5vdXRwdXRzLnRhZyB9fSIKICAgICAgICAgIGRvY2tlciBwdWxsICIkSU1BR0UiCiAgICAgICAgICAjIEdSTy0xOTg1OiBwbnBtIG11c3QgYmUgYSByZWFsIGJpbmFyeSwgbm90IGEgQ29yZXBhY2sgc2hpbSwgYW5kIG11c3QKICAgICAgICAgICMgbm90IHRyeSB0byByZWFjaCByZWdpc3RyeS5ucG1qcy5vcmcgb24gaW52b2NhdGlvbi4KICAgICAgICAgIGRvY2tlciBydW4gLS1ybSBcCiAgICAgICAgICAgIC0tYWRkLWhvc3QgcmVnaXN0cnkubnBtanMub3JnOjEyNy4wLjAuMSBcCiAgICAgICAgICAgIC0tZW50cnlwb2ludD0iIiBcCiAgICAgICAgICAgICIkSU1BR0UiIFwKICAgICAgICAgICAgc2ggLWMgJ3NldCAtZTsgdGVzdCAiJCh3aGljaCBwbnBtKSIgPSAiL3Vzci9sb2NhbC9iaW4vcG5wbSI7IHBucG0gLS12ZXJzaW9uJwogICAgICAgICAgZWNobyAic2VlZCBpbWFnZTogcG5wbSByZXNvbHZlcyB0byAvdXNyL2xvY2FsL2Jpbi9wbnBtIGFuZCBydW5zIG9mZmxpbmUg4pyTIgoKICAgICAgLSBuYW1lOiBTbW9rZSB0ZXN0IHJlc2V0IGltYWdlIChibGFja2hvbGUgbnBtanMub3JnKQogICAgICAgIHJ1bjogfAogICAgICAgICAgc2V0IC1ldW8gcGlwZWZhaWwKICAgICAgICAgIElNQUdFPSJnaXQuZmFyaC5uZXQvZ3Jvb21ib29rL3Jlc2V0OiR7eyBzdGVwcy52ZXJzaW9uLm91dHB1dHMudGFnIH19IgogICAgICAgICAgZG9ja2VyIHB1bGwgIiRJTUFHRSIKICAgICAgICAgICMgR1JPLTE5ODU6IHBucG0gbXVzdCBiZSBhIHJlYWwgYmluYXJ5LCBub3QgYSBDb3JlcGFjayBzaGltLCBhbmQgbXVzdAogICAgICAgICAgIyBub3QgdHJ5IHRvIHJlYWNoIHJlZ2lzdHJ5Lm5wbWpzLm9yZyBvbiBpbnZvY2F0aW9uLiBWYWxpZGF0ZXMgdGhlCiAgICAgICAgICAjIGhhcmQgcmVxdWlyZW1lbnQgZnJvbSB0aGUgaXNzdWU6IHJlc2V0IHJ1bnMgb2ZmbGluZS4KICAgICAgICAgIGRvY2tlciBydW4gLS1ybSBcCiAgICAgICAgICAgIC0tYWRkLWhvc3QgcmVnaXN0cnkubnBtanMub3JnOjEyNy4wLjAuMSBcCiAgICAgICAgICAgIC0tZW50cnlwb2ludD0iIiBcCiAgICAgICAgICAgICIkSU1BR0UiIFwKICAgICAgICAgICAgc2ggLWMgJ3NldCAtZTsgdGVzdCAiJCh3aGljaCBwbnBtKSIgPSAiL3Vzci9sb2NhbC9iaW4vcG5wbSI7IGVjaG8gIkhPTUU9JEhPTUUiOyBwbnBtIC0tdmVyc2lvbicKICAgICAgICAgIGVjaG8gInJlc2V0IGltYWdlOiBwbnBtIHJlc29sdmVzIHRvIC91c3IvbG9jYWwvYmluL3BucG0sIEhPTUU9L3RtcCwgcnVucyBvZmZsaW5lIOKckyIK \ No newline at end of file From af1af5c211c3dd89d20f0d94c6f4fb91d2934553 Mon Sep 17 00:00:00 2001 From: gb_flea Date: Wed, 5 Aug 2026 08:56:30 +0000 Subject: [PATCH 27/34] ci: trigger CI run for ignore-error=true cache-to fix From 5accf73363fbb030dfbaed7579af19673b1e8c3c Mon Sep 17 00:00:00 2001 From: gb_flea Date: Wed, 5 Aug 2026 09:04:17 +0000 Subject: [PATCH 28/34] =?UTF-8?q?ci:=20trigger=20CI=20for=20dev=20branch?= =?UTF-8?q?=20(PR-223=20dev=E2=86=92uat)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From ab5f08e4c0dfd2a9a5b89b9cdc68a532150eed75 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Wed, 5 Aug 2026 09:19:55 +0000 Subject: [PATCH 29/34] fix(GRO-2652): restore ci.yml from double-base64 corruption; fix outpuds typo Co-Authored-By: Paperclip --- .gitea/workflows/ci.yml | 189 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 188 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index f523997..16683fa 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1 +1,188 @@ -bmFtZTogQ0kKCm9uOgogIHB1c2g6CiAgICBicmFuY2hlczogW21haW4sIGRldiwgdWF0XQogIHB1bGxfcmVxdWVzdDoKICAgIGJyYW5jaGVzOiBbbWFpbiwgZGV2LCB1YXRdCiAgd29ya2Zsb3dfZGlzcGF0Y2g6CiAgICBpbnB1dHM6CiAgICAgIHJlZjoKICAgICAgICBkZXNjcmlwdGlvbjogIkJyYW5jaCBvciByZWYgdG8gcnVuIENJIGFnYWluc3QiCiAgICAgICAgcmVxdWlyZWQ6IGZhbHNlCiAgICAgICAgZGVmYXVsdDogIm1haW4iCgpqb2JzOgogIGxpbnQtdHlwZWNoZWNrOgogICAgbmFtZTogTGludCAmIFR5cGVjaGVjawogICAgcnVucy1vbjogdWJ1bnR1LWxhdGVzdAogICAgc3RlcHM6CiAgICAgIC0gdXNlczogYWN0aW9ucy9jaGVja291dEB2NAoKICAgICAgLSB1c2VzOiBwbnBtL2FjdGlvbi1zZXR1cEB2NAogICAgICAgIHdpdGg6CiAgICAgICAgICB2ZXJzaW9uOiAnOS4xNS40JwoKICAgICAgLSB1c2VzOiBhY3Rpb25zL3NldHVwLW5vZGVAdjQKICAgICAgICB3aXRoOgogICAgICAgICAgbm9kZS12ZXJzaW9uOiAyMgogICAgICAgICAgY2FjaGU6IHBucG0KCiAgICAgIC0gbmFtZTogSW5zdGFsbCBkZXBlbmRlbmNpZXMKICAgICAgICBydW46IHBucG0gaW5zdGFsbCAtLWZyb3plbi1sb2NrZmlsZQoKICAgICAgLSBuYW1lOiBUeXBlY2hlY2sKICAgICAgICBydW46IHwKICAgICAgICAgIHBucG0gcnVuIHR5cGVjaGVjawogICAgICAgICAgcG5wbSAtLWZpbHRlciBAZ3Jvb21ib29rL2RiIHR5cGVjaGVjawoKICAgICAgLSBuYW1lOiBMaW50CiAgICAgICAgcnVuOiBwbnBtIHJ1biBsaW50CgogIHRlc3Q6CiAgICBuYW1lOiBUZXN0CiAgICBydW5zLW9uOiB1YnVudHUtbGF0ZXN0CiAgICBzdGVwczoKICAgICAgLSB1c2VzOiBhY3Rpb25zL2NoZWNrb3V0QHY0CgogICAgICAtIHVzZXM6IHBucG0vYWN0aW9uLXNldHVwQHY0CiAgICAgICAgd2l0aDoKICAgICAgICAgIHZlcnNpb246ICc5LjE1LjQnCgogICAgICAtIHVzZXM6IGFjdGlvbnMvc2V0dXAtbm9kZUB2NAogICAgICAgIHdpdGg6CiAgICAgICAgICBub2RlLXZlcnNpb246IDIyCiAgICAgICAgICBjYWNoZTogcG5wbQoKICAgICAgLSBuYW1lOiBJbnN0YWxsIGRlcGVuZGVuY2llcwogICAgICAgIHJ1bjogcG5wbSBpbnN0YWxsIC0tZnJvemVuLWxvY2tmaWxlCgogICAgICAtIG5hbWU6IFJ1biB0ZXN0cwogICAgICAgIHJ1bjogcG5wbSBydW4gdGVzdAoKICBkb2NrZXI6CiAgICBuYW1lOiBCdWlsZCAmIFB1c2ggRG9ja2VyIEltYWdlcwogICAgcnVucy1vbjogdWJ1bnR1LWxhdGVzdAogICAgbmVlZHM6IFtsaW50LXR5cGVjaGVjaywgdGVzdF0KICAgIHN0ZXBzOgogICAgICAtIHVzZXM6IGFjdGlvbnMvY2hlY2tvdXRAdjQKCiAgICAgIC0gbmFtZTogR2VuZXJhdGUgaW1hZ2UgdGFnCiAgICAgICAgaWQ6IHZlcnNpb24KICAgICAgICBydW46IHwKICAgICAgICAgIGlmIFsgIiR7eyBnaXRodWIuZXZlbnRfbmFtZSB9fSIgPSAicHVsbF9yZXF1ZXN0IiBdOyB0aGVuCiAgICAgICAgICAgIFRBRz0icHItJHt7IGdpdGh1Yi5ldmVudC5wdWxsX3JlcXVlc3QubnVtYmVyIH19LSR7R0lUSFVCX1NIQTo6N30iCiAgICAgICAgICBlbHNlCiAgICAgICAgICAgIFRBRz0iJChkYXRlIC11ICslWS4lbS4lZCktJHtHSVRIVUJfU0hBOjo3fSIKICAgICAgICAgIGZpCiAgICAgICAgICBlY2hvICJ0YWc9JFRBRyIgPj4gIiRHSVRIVUJfT1VUUFVUIgogICAgICAgICAgZWNobyAiSW1hZ2UgdGFnOiAkVEFHIgoKICAgICAgLSBuYW1lOiBTZXQgdXAgRG9ja2VyIEJ1aWxkeAogICAgICAgIHVzZXM6IGRvY2tlci9zZXR1cC1idWlsZHgtYWN0aW9uQHYzCiAgICAgICAgd2l0aDoKICAgICAgICAgIGRyaXZlci1vcHRzOiBuZXR3b3JrPWhvc3QKCiAgICAgIC0gbmFtZTogTG9nIGluIHRvIEdpdGVhIENvbnRhaW5lciBSZWdpc3RyeQogICAgICAgIHVzZXM6IGRvY2tlci9sb2dpbi1hY3Rpb25AdjMKICAgICAgICB3aXRoOgogICAgICAgICAgcmVnaXN0cnk6IGdpdC5mYXJoLm5ldAogICAgICAgICAgdXNlcm5hbWU6ICR7eyBnaXRlYS5hY3RvciB9fQogICAgICAgICAgcGFzc3dvcmQ6ICR7eyBzZWNyZXRzLlJFR0lTVFJZX1RPS0VOIH19CgogICAgICAtIG5hbWU6IEJ1aWxkIGFuZCBwdXNoIEFQSSBpbWFnZQogICAgICAgIHVzZXM6IGRvY2tlci9idWlsZC1wdXNoLWFjdGlvbkB2NgogICAgICAgIHdpdGg6CiAgICAgICAgICBwcm92ZW5hbmNlOiBmYWxzZQogICAgICAgICAgY29udGV4dDogLgogICAgICAgICAgZmlsZTogRG9ja2VyZmlsZQogICAgICAgICAgdGFyZ2V0OiBydW5uZXIKICAgICAgICAgIHB1c2g6IHRydWUKICAgICAgICAgIHRhZ3M6IHwKICAgICAgICAgICAgZ2l0LmZhcmgubmV0L2dyb29tYm9vay9hcGk6JHt7IHN0ZXBzLnZlcnNpb24ub3V0cHV0cy50YWcgfX0KICAgICAgICAgICAgJHt7IGdpdGh1Yi5yZWYgPT0gJ3JlZnMvaGVhZHMvbWFpbicgJiYgJ2dpdC5mYXJoLm5ldC9ncm9vbWJvb2svYXBpOmxhdGVzdCcgfHwgJycgfX0KICAgICAgICAgIGNhY2hlLWZyb206IHR5cGU9cmVnaXN0cnkscmVmPWdpdC5mYXJoLm5ldC9ncm9vbWJvb2svY2FjaGU6YXBpCiAgICAgICAgICBjYWNoZS10bzogdHlwZT1yZWdpc3RyeSxyZWY9Z2l0LmZhcmgubmV0L2dyb29tYm9vay9jYWNoZTphcGksbW9kZT1tYXgsaWdub3JlLWVycm9yPXRydWUKCiAgICAgIC0gbmFtZTogQnVpbGQgYW5kIHB1c2ggTWlncmF0ZSBpbWFnZQogICAgICAgIHVzZXM6IGRvY2tlci9idWlsZC1wdXNoLWFjdGlvbkB2NgogICAgICAgIHdpdGg6CiAgICAgICAgICBwcm92ZW5hbmNlOiBmYWxzZQogICAgICAgICAgY29udGV4dDogLgogICAgICAgICAgZmlsZTogRG9ja2VyZmlsZQogICAgICAgICAgdGFyZ2V0OiBtaWdyYXRlCiAgICAgICAgICBwdXNoOiB0cnVlCiAgICAgICAgICB0YWdzOiB8CiAgICAgICAgICAgIGdpdC5mYXJoLm5ldC9ncm9vbWJvb2svbWlncmF0ZToke3sgc3RlcHMudmVyc2lvbi5vdXRwdWRzLnRhZyB9fQogICAgICAgICAgICAke3sgZ2l0aHViLnJlZiA9PSAncmVmcy9oZWFkcy9tYWluJyAmJiAnZ2l0LmZhcmgubmV0L2dyb29tYm9vay9taWdyYXRlOmxhdGVzdCcgfHwgJycgfX0KICAgICAgICAgIGNhY2hlLWZyb206IHR5cGU9cmVnaXN0cnkscmVmPWdpdC5mYXJoLm5ldC9ncm9vbWJvb2svY2FjaGU6bWlncmF0ZQogICAgICAgICAgY2FjaGUtdG86IHR5cGU9cmVnaXN0cnkscmVmPWdpdC5mYXJoLm5ldC9ncm9vbWJvb2svY2FjaGU6bWlncmF0ZSxtb2RlPW1heCxpZ25vcmUtZXJyb3I9dHJ1ZQoKICAgICAgLSBuYW1lOiBTbW9rZSB0ZXN0IG1pZ3JhdGUgaW1hZ2UgKGJsYWNraG9sZSBucG1qcy5vcmcpCiAgICAgICAgcnVuOiB8CiAgICAgICAgICBzZXQgLWV1byBwaXBlZmFpbAogICAgICAgICAgSU1BR0U9ImdpdC5mYXJoLm5ldC9ncm9vbWJvb2svbWlncmF0ZToke3sgc3RlcHMudmVyc2lvbi5vdXRwdXRzLnRhZyB9fSIKICAgICAgICAgIGRvY2tlciBwdWxsICIkSU1BR0UiCiAgICAgICAgICBkb2NrZXIgcnVuIC0tcm0gXAogICAgICAgICAgICAtLWFkZC1ob3N0IHJlZ2lzdHJ5Lm5wbWpzLm9yZzoxMjcuMC4wLjEgXAogICAgICAgICAgICAtLWVudHJ5cG9pbnQ9IiIgXAogICAgICAgICAgICAiJElNQUdFIiBcCiAgICAgICAgICAgIHBucG0gLS12ZXJzaW9uCgogICAgICAtIG5hbWU6IEJ1aWxkIGFuZCBwdXNoIFNlZWQgaW1hZ2UKICAgICAgICB1c2VzOiBkb2NrZXIvYnVpbGQtcHVzaC1hY3Rpb25AdjYKICAgICAgICB3aXRoOgogICAgICAgICAgcHJvdmVuYW5jZTogZmFsc2UKICAgICAgICAgIGNvbnRleHQ6IC4KICAgICAgICAgIGZpbGU6IERvY2tlcmZpbGUKICAgICAgICAgIHRhcmdldDogc2VlZAogICAgICAgICAgcHVzaDogdHJ1ZQogICAgICAgICAgdGFnczogfAogICAgICAgICAgICBnaXQuZmFyaC5uZXQvZ3Jvb21ib29rL3NlZWQ6JHt7IHN0ZXBzLnZlcnNpb24ub3V0cHV0cy50YWcgfX0KICAgICAgICAgICAgJHt7IGdpdGh1Yi5yZWYgPT0gJ3JlZnMvaGVhZHMvbWFpbicgJiYgJ2dpdC5mYXJoLm5ldC9ncm9vbWJvb2svc2VlZDpsYXRlc3QnIHx8ICcnIH19CiAgICAgICAgICBjYWNoZS1mcm9tOiB0eXBlPXJlZ2lzdHJ5LHJlZj1naXQuZmFyaC5uZXQvZ3Jvb21ib29rL2NhY2hlOnNlZWQKICAgICAgICAgIGNhY2hlLXRvOiB0eXBlPXJlZ2lzdHJ5LHJlZj1naXQuZmFyaC5uZXQvZ3Jvb21ib29rL2NhY2hlOnNlZWQsbW9kZT1tYXgsaWdub3JlLWVycm9yPXRydWUKCiAgICAgIC0gbmFtZTogQnVpbGQgYW5kIHB1c2ggUmVzZXQgaW1hZ2UKICAgICAgICB1c2VzOiBkb2NrZXIvYnVpbGQtcHVzaC1hY3Rpb25AdjYKICAgICAgICB3aXRoOgogICAgICAgICAgcHJvdmVuYW5jZTogZmFsc2UKICAgICAgICAgIGNvbnRleHQ6IC4KICAgICAgICAgIGZpbGU6IERvY2tlcmZpbGUKICAgICAgICAgIHRhcmdldDogcmVzZXQKICAgICAgICAgIHB1c2g6IHRydWUKICAgICAgICAgIHRhZ3M6IHwKICAgICAgICAgICAgZ2l0LmZhcmgubmV0L2dyb29tYm9vay9yZXNldDoke3sgc3RlcHMudmVyc2lvbi5vdXRwdXRzLnRhZyB9fQogICAgICAgICAgICAke3sgZ2l0aHViLnJlZiA9PSAncmVmcy9oZWFkcy9tYWluJyAmJiAnZ2l0LmZhcmgubmV0L2dyb29tYm9vay9yZXNldDpsYXRlc3QnIHx8ICcnIH19CiAgICAgICAgICBjYWNoZS1mcm9tOiB0eXBlPXJlZ2lzdHJ5LHJlZj1naXQuZmFyaC5uZXQvZ3Jvb21ib29rL2NhY2hlOnJlc2V0CiAgICAgICAgICBjYWNoZS10bzogdHlwZT1yZWdpc3RyeSxyZWY9Z2l0LmZhcmgubmV0L2dyb29tYm9vay9jYWNoZTpyZXNldCxtb2RlPW1heCxpZ25vcmUtZXJyb3I9dHJ1ZQoKICAgICAgLSBuYW1lOiBTbW9rZSB0ZXN0IHNlZWQgaW1hZ2UgKGJsYWNraG9sZSBucG1qcy5vcmcpCiAgICAgICAgcnVuOiB8CiAgICAgICAgICBzZXQgLWV1byBwaXBlZmFpbAogICAgICAgICAgSU1BR0U9ImdpdC5mYXJoLm5ldC9ncm9vbWJvb2svc2VlZDoke3sgc3RlcHMudmVyc2lvbi5vdXRwdXRzLnRhZyB9fSIKICAgICAgICAgIGRvY2tlciBwdWxsICIkSU1BR0UiCiAgICAgICAgICAjIEdSTy0xOTg1OiBwbnBtIG11c3QgYmUgYSByZWFsIGJpbmFyeSwgbm90IGEgQ29yZXBhY2sgc2hpbSwgYW5kIG11c3QKICAgICAgICAgICMgbm90IHRyeSB0byByZWFjaCByZWdpc3RyeS5ucG1qcy5vcmcgb24gaW52b2NhdGlvbi4KICAgICAgICAgIGRvY2tlciBydW4gLS1ybSBcCiAgICAgICAgICAgIC0tYWRkLWhvc3QgcmVnaXN0cnkubnBtanMub3JnOjEyNy4wLjAuMSBcCiAgICAgICAgICAgIC0tZW50cnlwb2ludD0iIiBcCiAgICAgICAgICAgICIkSU1BR0UiIFwKICAgICAgICAgICAgc2ggLWMgJ3NldCAtZTsgdGVzdCAiJCh3aGljaCBwbnBtKSIgPSAiL3Vzci9sb2NhbC9iaW4vcG5wbSI7IHBucG0gLS12ZXJzaW9uJwogICAgICAgICAgZWNobyAic2VlZCBpbWFnZTogcG5wbSByZXNvbHZlcyB0byAvdXNyL2xvY2FsL2Jpbi9wbnBtIGFuZCBydW5zIG9mZmxpbmUg4pyTIgoKICAgICAgLSBuYW1lOiBTbW9rZSB0ZXN0IHJlc2V0IGltYWdlIChibGFja2hvbGUgbnBtanMub3JnKQogICAgICAgIHJ1bjogfAogICAgICAgICAgc2V0IC1ldW8gcGlwZWZhaWwKICAgICAgICAgIElNQUdFPSJnaXQuZmFyaC5uZXQvZ3Jvb21ib29rL3Jlc2V0OiR7eyBzdGVwcy52ZXJzaW9uLm91dHB1dHMudGFnIH19IgogICAgICAgICAgZG9ja2VyIHB1bGwgIiRJTUFHRSIKICAgICAgICAgICMgR1JPLTE5ODU6IHBucG0gbXVzdCBiZSBhIHJlYWwgYmluYXJ5LCBub3QgYSBDb3JlcGFjayBzaGltLCBhbmQgbXVzdAogICAgICAgICAgIyBub3QgdHJ5IHRvIHJlYWNoIHJlZ2lzdHJ5Lm5wbWpzLm9yZyBvbiBpbnZvY2F0aW9uLiBWYWxpZGF0ZXMgdGhlCiAgICAgICAgICAjIGhhcmQgcmVxdWlyZW1lbnQgZnJvbSB0aGUgaXNzdWU6IHJlc2V0IHJ1bnMgb2ZmbGluZS4KICAgICAgICAgIGRvY2tlciBydW4gLS1ybSBcCiAgICAgICAgICAgIC0tYWRkLWhvc3QgcmVnaXN0cnkubnBtanMub3JnOjEyNy4wLjAuMSBcCiAgICAgICAgICAgIC0tZW50cnlwb2ludD0iIiBcCiAgICAgICAgICAgICIkSU1BR0UiIFwKICAgICAgICAgICAgc2ggLWMgJ3NldCAtZTsgdGVzdCAiJCh3aGljaCBwbnBtKSIgPSAiL3Vzci9sb2NhbC9iaW4vcG5wbSI7IGVjaG8gIkhPTUU9JEhPTUUiOyBwbnBtIC0tdmVyc2lvbicKICAgICAgICAgIGVjaG8gInJlc2V0IGltYWdlOiBwbnBtIHJlc29sdmVzIHRvIC91c3IvbG9jYWwvYmluL3BucG0sIEhPTUU9L3RtcCwgcnVucyBvZmZsaW5lIOKckyIK \ No newline at end of file +name: CI + +on: + push: + branches: [main, dev, uat] + pull_request: + branches: [main, dev, uat] + workflow_dispatch: + inputs: + ref: + description: "Branch or ref to run CI against" + required: false + default: "main" + +jobs: + lint-typecheck: + name: Lint & Typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: '9.15.4' + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck + run: | + pnpm run typecheck + pnpm --filter @groombook/db typecheck + + - name: Lint + run: pnpm run lint + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: '9.15.4' + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run tests + run: pnpm run test + + docker: + name: Build & Push Docker Images + runs-on: ubuntu-latest + needs: [lint-typecheck, test] + steps: + - uses: actions/checkout@v4 + + - name: Generate image tag + id: version + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + TAG="pr-${{ github.event.pull_request.number }}-${GITHUB_SHA::7}" + else + TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}" + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "Image tag: $TAG" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver-opts: network=host + + - name: Log in to Gitea Container Registry + uses: docker/login-action@v3 + with: + registry: git.farh.net + username: ${{ gitea.actor }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Build and push API image + uses: docker/build-push-action@v6 + with: + provenance: false + context: . + file: Dockerfile + target: runner + push: true + tags: | + git.farh.net/groombook/api:${{ steps.version.outputs.tag }} + ${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/api:latest' || '' }} + cache-from: type=registry,ref=git.farh.net/groombook/cache:api + cache-to: type=registry,ref=git.farh.net/groombook/cache:api,mode=max,ignore-error=true + + - name: Build and push Migrate image + uses: docker/build-push-action@v6 + with: + provenance: false + context: . + file: Dockerfile + target: migrate + push: true + tags: | + git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }} + ${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/migrate:latest' || '' }} + cache-from: type=registry,ref=git.farh.net/groombook/cache:migrate + cache-to: type=registry,ref=git.farh.net/groombook/cache:migrate,mode=max,ignore-error=true + + - name: Smoke test migrate image (blackhole npmjs.org) + run: | + set -euo pipefail + IMAGE="git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }}" + docker pull "$IMAGE" + docker run --rm \ + --add-host registry.npmjs.org:127.0.0.1 \ + --entrypoint="" \ + "$IMAGE" \ + pnpm --version + + - name: Build and push Seed image + uses: docker/build-push-action@v6 + with: + provenance: false + context: . + file: Dockerfile + target: seed + push: true + tags: | + git.farh.net/groombook/seed:${{ steps.version.outputs.tag }} + ${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/seed:latest' || '' }} + cache-from: type=registry,ref=git.farh.net/groombook/cache:seed + cache-to: type=registry,ref=git.farh.net/groombook/cache:seed,mode=max,ignore-error=true + + - name: Build and push Reset image + uses: docker/build-push-action@v6 + with: + provenance: false + context: . + file: Dockerfile + target: reset + push: true + tags: | + git.farh.net/groombook/reset:${{ steps.version.outputs.tag }} + ${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/reset:latest' || '' }} + cache-from: type=registry,ref=git.farh.net/groombook/cache:reset + cache-to: type=registry,ref=git.farh.net/groombook/cache:reset,mode=max,ignore-error=true + + - name: Smoke test seed image (blackhole npmjs.org) + run: | + set -euo pipefail + IMAGE="git.farh.net/groombook/seed:${{ steps.version.outputs.tag }}" + docker pull "$IMAGE" + # GRO-1985: pnpm must be a real binary, not a Corepack shim, and must + # not try to reach registry.npmjs.org on invocation. + docker run --rm \ + --add-host registry.npmjs.org:127.0.0.1 \ + --entrypoint="" \ + "$IMAGE" \ + sh -c 'set -e; test "$(which pnpm)" = "/usr/local/bin/pnpm"; pnpm --version' + echo "seed image: pnpm resolves to /usr/local/bin/pnpm and runs offline ✓" + + - name: Smoke test reset image (blackhole npmjs.org) + run: | + set -euo pipefail + IMAGE="git.farh.net/groombook/reset:${{ steps.version.outputs.tag }}" + docker pull "$IMAGE" + # GRO-1985: pnpm must be a real binary, not a Corepack shim, and must + # not try to reach registry.npmjs.org on invocation. Validates the + # hard requirement from the issue: reset runs offline. + docker run --rm \ + --add-host registry.npmjs.org:127.0.0.1 \ + --entrypoint="" \ + "$IMAGE" \ + sh -c 'set -e; test "$(which pnpm)" = "/usr/local/bin/pnpm"; echo "HOME=$HOME"; pnpm --version' + echo "reset image: pnpm resolves to /usr/local/bin/pnpm, HOME=/tmp, runs offline ✓" + From 61f23e47f16fc69eddf658063cce3b7b665231e1 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Wed, 5 Aug 2026 09:20:03 +0000 Subject: [PATCH 30/34] =?UTF-8?q?docs(GRO-2359):=20add=20UAT=5FPLAYBOOK=20?= =?UTF-8?q?=C2=A74.20=20clients-from-auth=20test=20cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Paperclip --- UAT_PLAYBOOK.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 7b5f9d8..5f0bda8 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -455,6 +455,22 @@ Verifies the API process does not crash on transient boot-time DB connection res | TC-API-19.7 | Normal sign-in still works end-to-end | Follow TC-WEB-SSO-3 (SSO sign-in) on UAT | Successful sign-in, staff list visible — no regression from resilience changes | | TC-API-19.8 | Public routes unaffected during auth retry | While auth is retrying (TC-API-19.2 setup), `GET /api/branding` | 200 with branding data — public routes bypass auth and serve normally | +### 4.20 Portal OOBE — Create Client from Auth (GRO-2359) + +Verifies the `POST /api/portal/clients-from-auth` endpoint that creates a new `clients` row for a first-time SSO user (out-of-box-experience registration). This endpoint requires a valid Better Auth session but does NOT require a portal session; it is the pre-portal step in the new-user OOBE flow. + +| TC | Test Case | Steps | Expected Result | +|----|-----------|-------|-----------------| +| TC-API-20.1 | Successful client creation | 1. Sign in via SSO to obtain a Better Auth session
2. `POST /api/portal/clients-from-auth` with `{ "name": "Test User" }` | 201 `{ "id": "", "name": "Test User", "email": "" }` — new `clients` row created | +| TC-API-20.2 | All optional fields accepted | `POST /api/portal/clients-from-auth` with `{ "name": "Test User", "phone": "555-1234", "address": "1 Main St", "notes": "VIP" }` (authenticated) | 201 with `id`, `name`, `email`; row in DB has all four fields | +| TC-API-20.3 | Invalid body — missing name | `POST /api/portal/clients-from-auth` with `{}` (authenticated) | 400 (Zod validation failure); no row created | +| TC-API-20.4 | Invalid body — empty name | `POST /api/portal/clients-from-auth` with `{ "name": "" }` (authenticated) | 400 — name must be at least 1 character | +| TC-API-20.5 | No session — 401 | `POST /api/portal/clients-from-auth` with a valid body but **no** Better Auth session cookie | 401 `{ "error": "Unauthorized" }` | +| TC-API-20.6 | Existing email — 409 | 1. Create a client row whose email matches the signed-in SSO user's email
2. `POST /api/portal/clients-from-auth` as that user | 409 `{ "error": "A customer record with this email already exists" }` — no duplicate row | +| TC-API-20.7 | Auth not configured — 503 | Temporarily disable auth (e.g., point `OIDC_ISSUER` to an invalid host) so `getAuth()` throws
2. `POST /api/portal/clients-from-auth` | 503 `{ "error": "Authentication not configured" }` — graceful degradation | +| TC-API-20.8 | Concurrent insert race — 409 | Simulate two near-simultaneous requests from the same SSO user (before any row exists) | At most one request returns 201; the other returns 409 — no duplicate row, no 500 | + + ## Pass/Fail Criteria **Pass:** @@ -476,3 +492,4 @@ Verifies the API process does not crash on transient boot-time DB connection res ## Update Policy Any PR that changes user-facing behaviour MUST update this file. Test cases must be added, modified, or removed to reflect the new behaviour. The PR description must reference which playbook section was updated (e.g., "Updated UAT_PLAYBOOK.md §4.4 — new appointment rescheduling flow"). + From ae0ce3824fcba38028706158f5082e39bae81423 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Wed, 5 Aug 2026 09:44:12 +0000 Subject: [PATCH 31/34] fix(GRO-2652): reset authInitPromise on failure so retry loop actually retries Previously the initAuth() function set authInitPromise to the async IIFE's Promise but never cleared it on rejection. The index.ts retry loop called initAuth() up to 10 times, but on attempt 2+ the check `if (authInitPromise) { await authInitPromise; return; }` would immediately re-throw the original rejection without performing a real retry. Fix: wrap the final `await authInitPromise` in try/catch and reset authInitPromise = null on error. This allows the index.ts retry loop to create a fresh attempt on each call after a failure. Co-Authored-By: Paperclip --- src/lib/auth.ts | 334 +----------------------------------------------- 1 file changed, 1 insertion(+), 333 deletions(-) diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 9ec3520..e314cad 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,333 +1 @@ -import { betterAuth } from "better-auth"; -import { drizzleAdapter } from "better-auth/adapters/drizzle"; -import { genericOAuth } from "better-auth/plugins"; -import { getDb, authProviderConfig, eq } from "@groombook/db"; -import { decryptSecret } from "@groombook/db"; -import { sendEmail } from "../services/email.js"; - -const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET; -const BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000"; - -// Auth instance — initialized lazily via initAuth() -// eslint-disable-next-line @typescript-eslint/no-explicit-any -let authInstance: any = null; -let authInitPromise: Promise | null = null; - -/** Returns the current auth instance. Throws if not yet initialized. */ -export function getAuth() { - if (!authInstance) { - throw new Error( - "Auth not initialized. Call initAuth() at startup before handling requests." - ); - } - return authInstance; -} - -/** Returns a promise that resolves when auth is initialized. */ -export function getAuthPromise() { - return authInitPromise; -} - -/** Returns which OAuth/social providers are configured via env vars. */ -export function getActiveProviders(): string[] { - const providers: string[] = []; - if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) { - providers.push("google"); - } - if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) { - providers.push("github"); - } - if (process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID && process.env.OIDC_CLIENT_SECRET) { - providers.push("authentik"); - } - return providers; -} - -/** - * Re-initializes the Better-Auth instance after auth config changes. - * - * Clears both authInstance and authInitPromise, then calls initAuth() to - * re-read config from DB and build a fresh Better-Auth instance. - * Sessions are DB-backed and survive the re-init. - */ -export async function reinitAuth(): Promise { - authInstance = null; - authInitPromise = null; - await initAuth(); - console.log("[auth] Re-initialized auth instance after config change"); -} - -/** - * Initializes the Better-Auth instance. - * - * Config resolution chain: - * 1. Query auth_provider_config table for an enabled provider - * 2. If DB config exists → use it (decrypt clientSecret) - * 3. If no DB config → fall back to OIDC_* env vars - * 4. If neither → auth is unconfigured (getAuth() returns null, AUTH_DISABLED implied) - * - * Idempotent — subsequent calls return immediately after initialization completes. - */ -export async function initAuth(): Promise { - if (authInstance) return; // Already initialized - if (authInitPromise) { - await authInitPromise; - return; - } - - authInitPromise = (async () => { - // Guard: require BETTER_AUTH_SECRET unless explicitly in dev/demo mode - if (!BETTER_AUTH_SECRET && process.env.AUTH_DISABLED !== "true") { - throw new Error( - "[FATAL] BETTER_AUTH_SECRET environment variable is required when auth is enabled" - ); - } - - // AUTH_DISABLED=true means dev/demo mode — still build Better-Auth with placeholder - // config so auth.handler exists (middleware bypasses it anyway) - if (process.env.AUTH_DISABLED === "true") { - console.warn("[auth] AUTH_DISABLED=true — building placeholder auth instance"); - authInstance = betterAuth({ - database: drizzleAdapter(getDb(), { provider: "pg" }), - secret: BETTER_AUTH_SECRET!, - baseURL: BETTER_AUTH_URL, - rateLimit: { - enabled: true, - max: 100, - window: 10, - storage: "memory", - customRules: { - "/get-session": false, - }, - }, - plugins: [ - genericOAuth({ - config: [ - { - providerId: "authentik", - clientId: "placeholder", - clientSecret: "placeholder", - discoveryUrl: undefined, - scopes: ["openid", "profile", "email"], - }, - ], - }), - ], - session: { - expiresIn: 60 * 60 * 24 * 7, - updateAge: 60 * 60 * 24, - cookieCache: { enabled: false }, - }, - trustedOrigins: (process.env.CORS_ORIGIN ?? "http://localhost:5173") - .split(",").map((s) => s.trim()).filter(Boolean), - }); - return; - } - - // Step 1: Try to load config from DB, with retry-with-backoff for transient ECONNRESET (GRO-2652). - // A single connection reset during boot must not abort initialization. - const db = getDb(); - let dbQueryRows: (typeof authProviderConfig.$inferSelect)[] = []; - let dbAttempt = 0; - while (true) { - try { - dbQueryRows = await db - .select() - .from(authProviderConfig) - .where(eq(authProviderConfig.enabled, true)) - .limit(1); - break; - } catch (err) { - dbAttempt++; - if (dbAttempt >= 5) throw err; - const delay = Math.min(1000 * 2 ** (dbAttempt - 1), 8_000); - console.warn(`[auth] DB query attempt ${dbAttempt} failed (${err}), retrying in ${delay}ms`); - await new Promise((r) => setTimeout(r, delay)); - } - } - const [dbConfig] = dbQueryRows; - - let providerConfig: { - providerId: string; - clientId: string; - clientSecret: string; - issuerUrl: string; - internalBaseUrl?: string; - scopes: string; - }; - - if (dbConfig) { - // Step 2: Use DB config (decrypt clientSecret) - const decryptedSecret = decryptSecret(dbConfig.clientSecret); - providerConfig = { - providerId: dbConfig.providerId, - clientId: dbConfig.clientId, - clientSecret: decryptedSecret, - issuerUrl: dbConfig.issuerUrl, - internalBaseUrl: dbConfig.internalBaseUrl ?? undefined, - scopes: dbConfig.scopes, - }; - console.log("[auth] Using DB config for provider:", dbConfig.providerId); - } else { - // Step 3: Fall back to env vars - const oidcIssuer = process.env.OIDC_ISSUER; - const oidcClientId = process.env.OIDC_CLIENT_ID; - const oidcClientSecret = process.env.OIDC_CLIENT_SECRET; - - if (!oidcIssuer || !oidcClientId || !oidcClientSecret) { - // Step 4: Neither DB config nor env vars — auth is unconfigured - console.warn( - "[auth] No auth provider configured. Set up auth_provider_config in DB or OIDC_* env vars." - ); - return; // authInstance stays null — AUTH_DISABLED mode - } - - providerConfig = { - providerId: "authentik", - clientId: oidcClientId, - clientSecret: oidcClientSecret, - issuerUrl: oidcIssuer, - internalBaseUrl: process.env.OIDC_INTERNAL_BASE, - scopes: "openid profile email role", - }; - console.log("[auth] Using env var config (no DB config found)"); - } - - const hasGoogle = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET); - const hasGitHub = !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET); - - const issuerUrlObj = new URL(providerConfig.issuerUrl); - const issuerHostname = issuerUrlObj.hostname; - - const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`; - let oidcConfig: Record = {}; - try { - const discoveryRes = await fetch(discoveryUrlStr, { - signal: AbortSignal.timeout(5000), - }); - if (discoveryRes.ok) { - const discovery = await discoveryRes.json() as { - authorization_endpoint?: string; - token_endpoint?: string; - userinfo_endpoint?: string; - }; - const replaceHost = (url: string, newHost: string) => { - try { - const parsed = new URL(url); - const newParsed = new URL(newHost); - return `${newParsed.origin}${parsed.pathname}${parsed.search}`; - } catch { - return url; - } - }; - const authzUrl = discovery.authorization_endpoint; - const tokenUrl = discovery.token_endpoint; - const userInfoUrl = discovery.userinfo_endpoint; - if (authzUrl && tokenUrl && userInfoUrl) { - const authzUrlObj = new URL(authzUrl); - // Only validate authorizationUrl hostname against issuer — token/userinfo - // may legitimately use internal hostnames (OIDC_INTERNAL_BASE) for server-to-server calls. - if (authzUrlObj.hostname !== issuerHostname) { - throw new Error( - `[FATAL] OIDC discovery URL hostname mismatch: expected '${issuerHostname}' but got '${authzUrlObj.hostname}'. This may indicate a man-in-the-middle attack.` - ); - } - oidcConfig = { - authorizationUrl: authzUrl, - tokenUrl: providerConfig.internalBaseUrl - ? replaceHost(tokenUrl, providerConfig.internalBaseUrl) - : tokenUrl, - userInfoUrl: providerConfig.internalBaseUrl - ? replaceHost(userInfoUrl, providerConfig.internalBaseUrl) - : userInfoUrl, - }; - console.log("[auth] OIDC discovery successful, provider:", providerConfig.providerId); - } else { - console.warn("[auth] OIDC discovery missing required endpoints, using discoveryUrl only"); - } - } else { - console.warn(`[auth] OIDC discovery failed (${discoveryRes.status}), using discoveryUrl only`); - } - } catch (err) { - console.warn(`[auth] OIDC discovery fetch failed: ${err}, using discoveryUrl only`); - } - - // Build Better-Auth instance using resolved config - authInstance = betterAuth({ - database: drizzleAdapter(db, { - provider: "pg", - }), - secret: BETTER_AUTH_SECRET, - baseURL: BETTER_AUTH_URL, - rateLimit: { - enabled: true, - max: 100, - window: 10, - storage: "memory", - customRules: { - "/get-session": false, - }, - }, - account: { - accountLinking: { - enabled: true, - trustedProviders: ["authentik"], - }, - storeStateStrategy: "cookie" as const, - }, - emailAndPassword: { - enabled: true, - emailVerification: { - sendVerificationEmail: async ({ user, url }: { user: { email: string }; url: string }) => { - await sendEmail({ - to: user.email, - subject: "Verify your GroomBook email", - text: `Click the link to verify your email: ${url}`, - html: `

Click the link to verify your email:

${url}`, - }); - }, - }, - }, - plugins: [ - genericOAuth({ - config: [ - { - providerId: providerConfig.providerId, - clientId: providerConfig.clientId, - clientSecret: providerConfig.clientSecret, - discoveryUrl: discoveryUrlStr, - ...(Object.keys(oidcConfig).length > 0 ? oidcConfig : {}), - scopes: providerConfig.scopes.split(" ").filter(Boolean), - }, - ], - }), - ], - socialProviders: { - ...(hasGoogle ? { - google: { - clientId: process.env.GOOGLE_CLIENT_ID!, - clientSecret: process.env.GOOGLE_CLIENT_SECRET!, - }, - } : {}), - ...(hasGitHub ? { - github: { - clientId: process.env.GITHUB_CLIENT_ID!, - clientSecret: process.env.GITHUB_CLIENT_SECRET!, - }, - } : {}), - }, - session: { - expiresIn: 60 * 60 * 24 * 7, // 7 days - updateAge: 60 * 60 * 24, // 1 day - cookieCache: { - enabled: true, - maxAge: 5 * 60, // 5 minutes - }, - }, - trustedOrigins: (process.env.CORS_ORIGIN ?? "http://localhost:5173") - .split(",").map((s) => s.trim()).filter(Boolean), - }); - })(); - - await authInitPromise; -} +aW1wb3J0IHsgYmV0dGVyQXV0aCB9IGZyb20gImJldHRlci1hdXRoIjsKaW1wb3J0IHsgZHJpenpsZUFkYXB0ZXIgfSBmcm9tICJiZXR0ZXItYXV0aC9hZGFwdGVycy9kcml6emxlIjsKaW1wb3J0IHsgZ2VuZXJpY09BdXRoIH0gZnJvbSAiYmV0dGVyLWF1dGgvcGx1Z2lucyI7CmltcG9ydCB7IGdldERiLCBhdXRoUHJvdmlkZXJDb25maWcsIGVxIH0gZnJvbSAiQGdyb29tYm9vay9kYiI7CmltcG9ydCB7IGRlY3J5cHRTZWNyZXQgfSBmcm9tICJAZ3Jvb21ib29rL2RiIjsKaW1wb3J0IHsgc2VuZEVtYWlsIH0gZnJvbSAiLi4vc2VydmljZXMvZW1haWwuanMiOwoKY29uc3QgQkVUVEVSX0FVVEhfU0VDUkVUID0gcHJvY2Vzcy5lbnYuQkVUVEVSX0FVVEhfU0VDUkVUOwpjb25zdCBCRVRURVJfQVVUSF9VUkwgPSBwcm9jZXNzLmVudi5CRVRURVJfQVVUSF9VUkwgPz8gImh0dHA6Ly9sb2NhbGhvc3Q6MzAwMCI7CgovLyBBdXRoIGluc3RhbmNlIOKAlCBpbml0aWFsaXplZCBsYXppbHkgdmlhIGluaXRBdXRoKCkKLy8gZXNsaW50LWRpc2FibGUtbmV4dC1saW5lIEB0eXBlc2NyaXB0LWVzbGludC9uby1leHBsaWNpdC1hbnkKbGV0IGF1dGhJbnN0YW5jZTogYW55ID0gbnVsbDsKbGV0IGF1dGhJbml0UHJvbWlzZTogUHJvbWlzZTx2b2lkPiB8IG51bGwgPSBudWxsOwoKLyoqIFJldHVybnMgdGhlIGN1cnJlbnQgYXV0aCBpbnN0YW5jZS4gVGhyb3dzIGlmIG5vdCB5ZXQgaW5pdGlhbGl6ZWQuICovCmV4cG9ydCBmdW5jdGlvbiBnZXRBdXRoKCkgewogIGlmICghYXV0aEluc3RhbmNlKSB7CiAgICB0aHJvdyBuZXcgRXJyb3IoCiAgICAgICJBdXRoIG5vdCBpbml0aWFsaXplZC4gQ2FsbCBpbml0QXV0aCgpIGF0IHN0YXJ0dXAgYmVmb3JlIGhhbmRsaW5nIHJlcXVlc3RzLiIKICAgICk7CiAgfQogIHJldHVybiBhdXRoSW5zdGFuY2U7Cn0KCi8qKiBSZXR1cm5zIGEgcHJvbWlzZSB0aGF0IHJlc29sdmVzIHdoZW4gYXV0aCBpcyBpbml0aWFsaXplZC4gKi8KZXhwb3J0IGZ1bmN0aW9uIGdldEF1dGhQcm9taXNlKCkgewogIHJldHVybiBhdXRoSW5pdFByb21pc2U7Cn0KCi8qKiBSZXR1cm5zIHdoaWNoIE9BdXRoL3NvY2lhbCBwcm92aWRlcnMgYXJlIGNvbmZpZ3VyZWQgdmlhIGVudiB2YXJzLiAqLwpleHBvcnQgZnVuY3Rpb24gZ2V0QWN0aXZlUHJvdmlkZXJzKCk6IHN0cmluZ1tdIHsKICBjb25zdCBwcm92aWRlcnM6IHN0cmluZ1tdID0gW107CiAgaWYgKHByb2Nlc3MuZW52LkdPT0dMRV9DTElFTlRfSUQgJiYgcHJvY2Vzcy5lbnYuR09PR0xFX0NMSUVOVF9TRUNSRVQpIHsKICAgIHByb3ZpZGVycy5wdXNoKCJnb29nbGUiKTsKICB9CiAgaWYgKHByb2Nlc3MuZW52LkdJVEhVQl9DTElFTlRfSUQgJiYgcHJvY2Vzcy5lbnYuR0lUSFVCX0NMSUVOVF9TRUNSRVQpIHsKICAgIHByb3ZpZGVycy5wdXNoKCJnaXRodWIiKTsKICB9CiAgaWYgKHByb2Nlc3MuZW52Lk9JRENfSVNTVUVSICYmIHByb2Nlc3MuZW52Lk9JRENfQ0xJRU5UX0lEICYmIHByb2Nlc3MuZW52Lk9JRENfQ0xJRU5UX1NFQ1JFVCkgewogICAgcHJvdmlkZXJzLnB1c2goImF1dGhlbnRpayIpOwogIH0KICByZXR1cm4gcHJvdmlkZXJzOwp9CgovKioKICogUmUtaW5pdGlhbGl6ZXMgdGhlIEJldHRlci1BdXRoIGluc3RhbmNlIGFmdGVyIGF1dGggY29uZmlnIGNoYW5nZXMuCiAqCiAqIENsZWFycyBib3RoIGF1dGhJbnN0YW5jZSBhbmQgYXV0aEluaXRQcm9taXNlLCB0aGVuIGNhbGxzIGluaXRBdXRoKCkgdG8KICogcmUtcmVhZCBjb25maWcgZnJvbSBEQiBhbmQgYnVpbGQgYSBmcmVzaCBCZXR0ZXItQXV0aCBpbnN0YW5jZS4KICogU2Vzc2lvbnMgYXJlIERCLWJhY2tlZCBhbmQgc3Vydml2ZSB0aGUgcmUtaW5pdC4KICovCmV4cG9ydCBhc3luYyBmdW5jdGlvbiByZWluaXRBdXRoKCk6IFByb21pc2U8dm9pZD4gewogIGF1dGhJbnN0YW5jZSA9IG51bGw7CiAgYXV0aEluaXRQcm9taXNlID0gbnVsbDsKICBhd2FpdCBpbml0QXV0aCgpOwogIGNvbnNvbGUubG9nKCJbYXV0aF0gUmUtaW5pdGlhbGl6ZWQgYXV0aCBpbnN0YW5jZSBhZnRlciBjb25maWcgY2hhbmdlIik7Cn0KCi8qKgogKiBJbml0aWFsaXplcyB0aGUgQmV0dGVyLUF1dGggaW5zdGFuY2UuCiAqCiAqIENvbmZpZyByZXNvbHV0aW9uIGNoYWluOgogKiAxLiBRdWVyeSBhdXRoX3Byb3ZpZGVyX2NvbmZpZyB0YWJsZSBmb3IgYW4gZW5hYmxlZCBwcm92aWRlcgogKiAyLiBJZiBEQiBjb25maWcgZXhpc3RzIOKGkiB1c2UgaXQgKGRlY3J5cHQgY2xpZW50U2VjcmV0KQogKiAzLiBJZiBubyBEQiBjb25maWcg4oaSIGZhbGwgYmFjayB0byBPSURDXyogZW52IHZhcnMKICogNC4gSWYgbmVpdGhlciDihpIgYXV0aCBpcyB1bmNvbmZpZ3VyZWQgKGdldEF1dGgoKSByZXR1cm5zIG51bGwsIEFVVEhfRElTQUJMRUQgaW1wbGllZCkKICoKICogSWRlbXBvdGVudCDigJQgc3Vic2VxdWVudCBjYWxscyByZXR1cm4gaW1tZWRpYXRlbHkgYWZ0ZXIgaW5pdGlhbGl6YXRpb24gY29tcGxldGVzLgogKi8KZXhwb3J0IGFzeW5jIGZ1bmN0aW9uIGluaXRBdXRoKCk6IFByb21pc2U8dm9pZD4gewogIGlmIChhdXRoSW5zdGFuY2UpIHJldHVybjsgLy8gQWxyZWFkeSBpbml0aWFsaXplZAogIGlmIChhdXRoSW5pdFByb21pc2UpIHsKICAgIGF3YWl0IGF1dGhJbml0UHJvbWlzZTsKICAgIHJldHVybjsKICB9CgogIGF1dGhJbml0UHJvbWlzZSA9IChhc3luYyAoKSA9PiB7CiAgICAvLyBHdWFyZDogcmVxdWlyZSBCRVRURVJfQVVUSF9TRUNSRVQgdW5sZXNzIGV4cGxpY2l0bHkgaW4gZGV2L2RlbW8gbW9kZQogICAgaWYgKCFCRVRURVJfQVVUSF9TRUNSRVQgJiYgcHJvY2Vzcy5lbnYuQVVUSF9ESVNBQkxFRCAhPT0gInRydWUiKSB7CiAgICAgIHRocm93IG5ldyBFcnJvcigKICAgICAgICAiW0ZBVEFMXSBCRVRURVJfQVVUSF9TRUNSRVQgZW52aXJvbm1lbnQgdmFyaWFibGUgaXMgcmVxdWlyZWQgd2hlbiBhdXRoIGlzIGVuYWJsZWQiCiAgICAgICk7CiAgICB9CgogICAgLy8gQVVUSF9ESVNBQkxFRD10cnVlIG1lYW5zIGRldi9kZW1vIG1vZGUg4oCUIHN0aWxsIGJ1aWxkIEJldHRlci1BdXRoIHdpdGggcGxhY2Vob2xkZXIKICAgIC8vIGNvbmZpZyBzbyBhdXRoLmhhbmRsZXIgZXhpc3RzIChtaWRkbGV3YXJlIGJ5cGFzc2VzIGl0IGFueXdheSkKICAgIGlmIChwcm9jZXNzLmVudi5BVVRIX0RJU0FCTEVEID09PSAidHJ1ZSIpIHsKICAgICAgY29uc29sZS53YXJuKCJbYXV0aF0gQVVUSF9ESVNBQkxFRD10cnVlIOKAlCBidWlsZGluZyBwbGFjZWhvbGRlciBhdXRoIGluc3RhbmNlIik7CiAgICAgIGF1dGhJbnN0YW5jZSA9IGJldHRlckF1dGgoewogICAgICAgIGRhdGFiYXNlOiBkcml6emxlQWRhcHRlcihnZXREYigpLCB7IHByb3ZpZGVyOiAicGciIH0pLAogICAgICAgIHNlY3JldDogQkVUVEVSX0FVVEhfU0VDUkVUISwKICAgICAgICBiYXNlVVJMOiBCRVRURVJfQVVUSF9VUkwsCiAgICAgICAgcmF0ZUxpbWl0OiB7CiAgICAgICAgICBlbmFibGVkOiB0cnVlLAogICAgICAgICAgbWF4OiAxMDAsCiAgICAgICAgICB3aW5kb3c6IDEwLAogICAgICAgICAgc3RvcmFnZTogIm1lbW9yeSIsCiAgICAgICAgICBjdXN0b21SdWxlczogewogICAgICAgICAgICAiL2dldC1zZXNzaW9uIjogZmFsc2UsCiAgICAgICAgICB9LAogICAgICAgIH0sCiAgICAgICAgcGx1Z2luczogWwogICAgICAgICAgZ2VuZXJpY09BdXRoKHsKICAgICAgICAgICAgY29uZmlnOiBbCiAgICAgICAgICAgICAgewogICAgICAgICAgICAgICAgcHJvdmlkZXJJZDogImF1dGhlbnRpayIsCiAgICAgICAgICAgICAgICBjbGllbnRJZDogInBsYWNlaG9sZGVyIiwKICAgICAgICAgICAgICAgIGNsaWVudFNlY3JldDogInBsYWNlaG9sZGVyIiwKICAgICAgICAgICAgICAgIGRpc2NvdmVyeVVybDogdW5kZWZpbmVkLAogICAgICAgICAgICAgICAgc2NvcGVzOiBbIm9wZW5pZCIsICJwcm9maWxlIiwgImVtYWlsIl0sCiAgICAgICAgICAgICAgfSwKICAgICAgICAgICAgXSwKICAgICAgICAgIH0pLAogICAgICAgIF0sCiAgICAgICAgc2Vzc2lvbjogewogICAgICAgICAgZXhwaXJlc0luOiA2MCAqIDYwICogMjQgKiA3LAogICAgICAgICAgdXBkYXRlQWdlOiA2MCAqIDYwICogMjQsCiAgICAgICAgICBjb29raWVDYWNoZTogeyBlbmFibGVkOiBmYWxzZSB9LAogICAgICAgIH0sCiAgICAgICAgdHJ1c3RlZE9yaWdpbnM6IChwcm9jZXNzLmVudi5DT1JTX09SSUdJTiA/PyAiaHR0cDovL2xvY2FsaG9zdDo1MTczIikKICAgICAgICAgIC5zcGxpdCgiLCIpLm1hcCgocykgPT4gcy50cmltKCkpLmZpbHRlcihCb29sZWFuKSwKICAgICAgfSk7CiAgICAgIHJldHVybjsKICAgIH0KCiAgICAvLyBTdGVwIDE6IFRyeSB0byBsb2FkIGNvbmZpZyBmcm9tIERCLCB3aXRoIHJldHJ5LXdpdGgtYmFja29mZiBmb3IgdHJhbnNpZW50IEVDT05OUkVTRVQgKEdSTy0yNjUyKS4KICAgIC8vIEEgc2luZ2xlIGNvbm5lY3Rpb24gcmVzZXQgZHVyaW5nIGJvb3QgbXVzdCBub3QgYWJvcnQgaW5pdGlhbGl6YXRpb24uCiAgICBjb25zdCBkYiA9IGdldERiKCk7CiAgICBsZXQgZGJRdWVyeVJvd3M6ICh0eXBlb2YgYXV0aFByb3ZpZGVyQ29uZmlnLiRpbmZlclNlbGVjdClbXSA9IFtdOwogICAgbGV0IGRiQXR0ZW1wdCA9IDA7CiAgICB3aGlsZSAodHJ1ZSkgewogICAgICB0cnkgewogICAgICAgIGRiUXVlcnlSb3dzID0gYXdhaXQgZGIKICAgICAgICAgIC5zZWxlY3QoKQogICAgICAgICAgLmZyb20oYXV0aFByb3ZpZGVyQ29uZmlnKQogICAgICAgICAgLndoZXJlKGVxKGF1dGhQcm92aWRlckNvbmZpZy5lbmFibGVkLCB0cnVlKSkKICAgICAgICAgIC5saW1pdCgxKTsKICAgICAgICBicmVhazsKICAgICAgfSBjYXRjaCAoZXJyKSB7CiAgICAgICAgZGJBdHRlbXB0Kys7CiAgICAgICAgaWYgKGRiQXR0ZW1wdCA+PSA1KSB0aHJvdyBlcnI7CiAgICAgICAgY29uc3QgZGVsYXkgPSBNYXRoLm1pbigxMDAwICogMiAqKiAoZGJBdHRlbXB0IC0gMSksIDhfMDAwKTsKICAgICAgICBjb25zb2xlLndhcm4oYFthdXRoXSBEQiBxdWVyeSBhdHRlbXB0ICR7ZGJBdHRlbXB0fSBmYWlsZWQgKCR7ZXJyfSksIHJldHJ5aW5nIGluICR7ZGVsYXl9bXNgKTsKICAgICAgICBhd2FpdCBuZXcgUHJvbWlzZSgocikgPT4gc2V0VGltZW91dChyLCBkZWxheSkpOwogICAgICB9CiAgICB9CiAgICBjb25zdCBbZGJDb25maWddID0gZGJRdWVyeVJvd3M7CgogICAgbGV0IHByb3ZpZGVyQ29uZmlnOiB7CiAgICAgIHByb3ZpZGVySWQ6IHN0cmluZzsKICAgICAgY2xpZW50SWQ6IHN0cmluZzsKICAgICAgY2xpZW50U2VjcmV0OiBzdHJpbmc7CiAgICAgIGlzc3VlclVybDogc3RyaW5nOwogICAgICBpbnRlcm5hbEJhc2VVcmw/OiBzdHJpbmc7CiAgICAgIHNjb3Blczogc3RyaW5nOwogICAgfTsKCiAgICBpZiAoZGJDb25maWcpIHsKICAgICAgLy8gU3RlcCAyOiBVc2UgREIgY29uZmlnIChkZWNyeXB0IGNsaWVudFNlY3JldCkKICAgICAgY29uc3QgZGVjcnlwdGVkU2VjcmV0ID0gZGVjcnlwdFNlY3JldChkYkNvbmZpZy5jbGllbnRTZWNyZXQpOwogICAgICBwcm92aWRlckNvbmZpZyA9IHsKICAgICAgICBwcm92aWRlcklkOiBkYkNvbmZpZy5wcm92aWRlcklkLAogICAgICAgIGNsaWVudElkOiBkYkNvbmZpZy5jbGllbnRJZCwKICAgICAgICBjbGllbnRTZWNyZXQ6IGRlY3J5cHRlZFNlY3JldCwKICAgICAgICBpc3N1ZXJVcmw6IGRiQ29uZmlnLmlzc3VlclVybCwKICAgICAgICBpbnRlcm5hbEJhc2VVcmw6IGRiQ29uZmlnLmludGVybmFsQmFzZVVybCA/PyB1bmRlZmluZWQsCiAgICAgICAgc2NvcGVzOiBkYkNvbmZpZy5zY29wZXMsCiAgICAgIH07CiAgICAgIGNvbnNvbGUubG9nKCJbYXV0aF0gVXNpbmcgREIgY29uZmlnIGZvciBwcm92aWRlcjoiLCBkYkNvbmZpZy5wcm92aWRlcklkKTsKICAgIH0gZWxzZSB7CiAgICAgIC8vIFN0ZXAgMzogRmFsbCBiYWNrIHRvIGVudiB2YXJzCiAgICAgIGNvbnN0IG9pZGNJc3N1ZXIgPSBwcm9jZXNzLmVudi5PSURDX0lTU1VFUjsKICAgICAgY29uc3Qgb2lkY0NsaWVudElkID0gcHJvY2Vzcy5lbnYuT0lEQ19DTElFTlRfSUQ7CiAgICAgIGNvbnN0IG9pZGNDbGllbnRTZWNyZXQgPSBwcm9jZXNzLmVudi5PSURDX0NMSUVOVF9TRUNSRVQ7CgogICAgICBpZiAoIW9pZGNJc3N1ZXIgfHwgIW9pZGNDbGllbnRJZCB8fCAhb2lkY0NsaWVudFNlY3JldCkgewogICAgICAgIC8vIFN0ZXAgNDogTmVpdGhlciBEQiBjb25maWcgbm9yIGVudiB2YXJzIOKAlCBhdXRoIGlzIHVuY29uZmlndXJlZAogICAgICAgIGNvbnNvbGUud2FybigKICAgICAgICAgICJbYXV0aF0gTm8gYXV0aCBwcm92aWRlciBjb25maWd1cmVkLiBTZXQgdXAgYXV0aF9wcm92aWRlcl9jb25maWcgaW4gREIgb3IgT0lEQ18qIGVudiB2YXJzLiIKICAgICAgICApOwogICAgICAgIHJldHVybjsgLy8gYXV0aEluc3RhbmNlIHN0YXlzIG51bGwg4oCUIEFVVEhfRElTQUJMRUQgbW9kZQogICAgICB9CgogICAgICBwcm92aWRlckNvbmZpZyA9IHsKICAgICAgICBwcm92aWRlcklkOiAiYXV0aGVudGlrIiwKICAgICAgICBjbGllbnRJZDogb2lkY0NsaWVudElkLAogICAgICAgIGNsaWVudFNlY3JldDogb2lkY0NsaWVudFNlY3JldCwKICAgICAgICBpc3N1ZXJVcmw6IG9pZGNJc3N1ZXIsCiAgICAgICAgaW50ZXJuYWxCYXNlVXJsOiBwcm9jZXNzLmVudi5PSURDX0lOVEVSTkFMX0JBU0UsCiAgICAgICAgc2NvcGVzOiAib3BlbmlkIHByb2ZpbGUgZW1haWwgcm9sZSIsCiAgICAgIH07CiAgICAgIGNvbnNvbGUubG9nKCJbYXV0aF0gVXNpbmcgZW52IHZhciBjb25maWcgKG5vIERCIGNvbmZpZyBmb3VuZCkiKTsKICAgIH0KCiAgICBjb25zdCBoYXNHb29nbGUgPSAhIShwcm9jZXNzLmVudi5HT09HTEVfQ0xJRU5UX0lEICYmIHByb2Nlc3MuZW52LkdPT0dMRV9DTElFTlRfU0VDUkVUKTsKICAgIGNvbnN0IGhhc0dpdEh1YiA9ICEhKHByb2Nlc3MuZW52LkdJVEhVQl9DTElFTlRfSUQgJiYgcHJvY2Vzcy5lbnYuR0lUSFVCX0NMSUVOVF9TRUNSRVQpOwoKICAgIGNvbnN0IGlzc3VlclVybE9iaiA9IG5ldyBVUkwocHJvdmlkZXJDb25maWcuaXNzdWVyVXJsKTsKICAgIGNvbnN0IGlzc3Vlckhvc3RuYW1lID0gaXNzdWVyVXJsT2JqLmhvc3RuYW1lOwoKICAgIGNvbnN0IGRpc2NvdmVyeVVybFN0ciA9IGAke3Byb3ZpZGVyQ29uZmlnLmlzc3VlclVybH0vLndlbGwta25vd24vb3BlbmlkLWNvbmZpZ3VyYXRpb25gOwogICAgbGV0IG9pZGNDb25maWc6IFJlY29yZDxzdHJpbmcsIHN0cmluZz4gPSB7fTsKICAgIHRyeSB7CiAgICAgIGNvbnN0IGRpc2NvdmVyeVJlcyA9IGF3YWl0IGZldGNoKGRpc2NvdmVyeVVybFN0ciwgewogICAgICAgIHNpZ25hbDogQWJvcnRTaWduYWwudGltZW91dCg1MDAwKSwKICAgICAgfSk7CiAgICAgIGlmIChkaXNjb3ZlcnlSZXMub2spIHsKICAgICAgICBjb25zdCBkaXNjb3ZlcnkgPSBhd2FpdCBkaXNjb3ZlcnlSZXMuanNvbigpIGFzIHsKICAgICAgICAgIGF1dGhvcml6YXRpb25fZW5kcG9pbnQ/OiBzdHJpbmc7CiAgICAgICAgICB0b2tlbl9lbmRwb2ludD86IHN0cmluZzsKICAgICAgICAgIHVzZXJpbmZvX2VuZHBvaW50Pzogc3RyaW5nOwogICAgICAgIH07CiAgICAgICAgY29uc3QgcmVwbGFjZUhvc3QgPSAodXJsOiBzdHJpbmcsIG5ld0hvc3Q6IHN0cmluZykgPT4gewogICAgICAgICAgdHJ5IHsKICAgICAgICAgICAgY29uc3QgcGFyc2VkID0gbmV3IFVSTCh1cmwpOwogICAgICAgICAgICBjb25zdCBuZXdQYXJzZWQgPSBuZXcgVVJMKG5ld0hvc3QpOwogICAgICAgICAgICByZXR1cm4gYCR7bmV3UGFyc2VkLm9yaWdpbn0ke3BhcnNlZC5wYXRobmFtZX0ke3BhcnNlZC5zZWFyY2h9YDsKICAgICAgICAgIH0gY2F0Y2ggewogICAgICAgICAgICByZXR1cm4gdXJsOwogICAgICAgICAgfQogICAgICAgIH07CiAgICAgICAgY29uc3QgYXV0aHpVcmwgPSBkaXNjb3ZlcnkuYXV0aG9yaXphdGlvbl9lbmRwb2ludDsKICAgICAgICBjb25zdCB0b2tlblVybCA9IGRpc2NvdmVyeS50b2tlbl9lbmRwb2ludDsKICAgICAgICBjb25zdCB1c2VySW5mb1VybCA9IGRpc2NvdmVyeS51c2VyaW5mb19lbmRwb2ludDsKICAgICAgICBpZiAoYXV0aHpVcmwgJiYgdG9rZW5VcmwgJiYgdXNlckluZm9VcmwpIHsKICAgICAgICAgIGNvbnN0IGF1dGh6VXJsT2JqID0gbmV3IFVSTChhdXRoelVybCk7CiAgICAgICAgICAvLyBPbmx5IHZhbGlkYXRlIGF1dGhvcml6YXRpb25VcmwgaG9zdG5hbWUgYWdhaW5zdCBpc3N1ZXIg4oCUIHRva2VuL3VzZXJpbmZvCiAgICAgICAgICAvLyBtYXkgbGVnaXRpbWF0ZWx5IHVzZSBpbnRlcm5hbCBob3N0bmFtZXMgKE9JRENfSU5URVJOQUxfQkFTRSkgZm9yIHNlcnZlci10by1zZXJ2ZXIgY2FsbHMuCiAgICAgICAgICBpZiAoYXV0aHpVcmxPYmouaG9zdG5hbWUgIT09IGlzc3Vlckhvc3RuYW1lKSB7CiAgICAgICAgICAgIHRocm93IG5ldyBFcnJvcigKICAgICAgICAgICAgICBgW0ZBVEFMXSBPSURDIGRpc2NvdmVyeSBVUkwgaG9zdG5hbWUgbWlzbWF0Y2g6IGV4cGVjdGVkICcke2lzc3Vlckhvc3RuYW1lfScgYnV0IGdvdCAnJHthdXRoelVybE9iai5ob3N0bmFtZX0nLiBUaGlzIG1heSBpbmRpY2F0ZSBhIG1hbi1pbi10aGUtbWlkZGxlIGF0dGFjay5gCiAgICAgICAgICAgICk7CiAgICAgICAgICB9CiAgICAgICAgICBvaWRjQ29uZmlnID0gewogICAgICAgICAgICBhdXRob3JpemF0aW9uVXJsOiBhdXRoelVybCwKICAgICAgICAgICAgdG9rZW5Vcmw6IHByb3ZpZGVyQ29uZmlnLmludGVybmFsQmFzZVVybAogICAgICAgICAgICAgID8gcmVwbGFjZUhvc3QodG9rZW5VcmwsIHByb3ZpZGVyQ29uZmlnLmludGVybmFsQmFzZVVybCkKICAgICAgICAgICAgICA6IHRva2VuVXJsLAogICAgICAgICAgICB1c2VySW5mb1VybDogcHJvdmlkZXJDb25maWcuaW50ZXJuYWxCYXNlVXJsCiAgICAgICAgICAgICAgPyByZXBsYWNlSG9zdCh1c2VySW5mb1VybCwgcHJvdmlkZXJDb25maWcuaW50ZXJuYWxCYXNlVXJsKQogICAgICAgICAgICAgIDogdXNlckluZm9VcmwsCiAgICAgICAgICB9OwogICAgICAgICAgY29uc29sZS5sb2coIlthdXRoXSBPSURDIGRpc2NvdmVyeSBzdWNjZXNzZnVsLCBwcm92aWRlcjoiLCBwcm92aWRlckNvbmZpZy5wcm92aWRlcklkKTsKICAgICAgICB9IGVsc2UgewogICAgICAgICAgY29uc29sZS53YXJuKCJbYXV0aF0gT0lEQyBkaXNjb3ZlcnkgbWlzc2luZyByZXF1aXJlZCBlbmRwb2ludHMsIHVzaW5nIGRpc2NvdmVyeVVybCBvbmx5Iik7CiAgICAgICAgfQogICAgICB9IGVsc2UgewogICAgICAgIGNvbnNvbGUud2FybihgW2F1dGhdIE9JREMgZGlzY292ZXJ5IGZhaWxlZCAoJHtkaXNjb3ZlcnlSZXMuc3RhdHVzfSksIHVzaW5nIGRpc2NvdmVyeVVybCBvbmx5YCk7CiAgICAgIH0KICAgIH0gY2F0Y2ggKGVycikgewogICAgICBjb25zb2xlLndhcm4oYFthdXRoXSBPSURDIGRpc2NvdmVyeSBmZXRjaCBmYWlsZWQ6ICR7ZXJyfSwgdXNpbmcgZGlzY292ZXJ5VXJsIG9ubHlgKTsKICAgIH0KCiAgICAvLyBCdWlsZCBCZXR0ZXItQXV0aCBpbnN0YW5jZSB1c2luZyByZXNvbHZlZCBjb25maWcKICAgIGF1dGhJbnN0YW5jZSA9IGJldHRlckF1dGgoewogICAgICBkYXRhYmFzZTogZHJpenpsZUFkYXB0ZXIoZGIsIHsKICAgICAgICBwcm92aWRlcjogInBnIiwKICAgICAgfSksCiAgICAgIHNlY3JldDogQkVUVEVSX0FVVEhfU0VDUkVULAogICAgICBiYXNlVVJMOiBCRVRURVJfQVVUSF9VUkwsCiAgICAgIHJhdGVMaW1pdDogewogICAgICAgIGVuYWJsZWQ6IHRydWUsCiAgICAgICAgbWF4OiAxMDAsCiAgICAgICAgd2luZG93OiAxMCwKICAgICAgICBzdG9yYWdlOiAibWVtb3J5IiwKICAgICAgICBjdXN0b21SdWxlczogewogICAgICAgICAgIi9nZXQtc2Vzc2lvbiI6IGZhbHNlLAogICAgICAgIH0sCiAgICAgIH0sCiAgICAgIGFjY291bnQ6IHsKICAgICAgICBhY2NvdW50TGlua2luZzogewogICAgICAgICAgZW5hYmxlZDogdHJ1ZSwKICAgICAgICAgIHRydXN0ZWRQcm92aWRlcnM6IFsiYXV0aGVudGlrIl0sCiAgICAgICAgfSwKICAgICAgICBzdG9yZVN0YXRlU3RyYXRlZ3k6ICJjb29raWUiIGFzIGNvbnN0LAogICAgICB9LAogICAgICBlbWFpbEFuZFBhc3N3b3JkOiB7CiAgICAgICAgZW5hYmxlZDogdHJ1ZSwKICAgICAgICBlbWFpbFZlcmlmaWNhdGlvbjogewogICAgICAgICAgc2VuZFZlcmlmaWNhdGlvbkVtYWlsOiBhc3luYyAoeyB1c2VyLCB1cmwgfTogeyB1c2VyOiB7IGVtYWlsOiBzdHJpbmcgfTsgdXJsOiBzdHJpbmcgfSkgPT4gewogICAgICAgICAgICBhd2FpdCBzZW5kRW1haWwoewogICAgICAgICAgICAgIHRvOiB1c2VyLmVtYWlsLAogICAgICAgICAgICAgIHN1YmplY3Q6ICJWZXJpZnkgeW91ciBHcm9vbUJvb2sgZW1haWwiLAogICAgICAgICAgICAgIHRleHQ6IGBDbGljayB0aGUgbGluayB0byB2ZXJpZnkgeW91ciBlbWFpbDogJHt1cmx9YCwKICAgICAgICAgICAgICBodG1sOiBgPHA+Q2xpY2sgdGhlIGxpbmsgdG8gdmVyaWZ5IHlvdXIgZW1haWw6PC9wPjxhIGhyZWY9IiR7dXJsfSI+JHt1cmx9PC9hPmAsCiAgICAgICAgICAgIH0pOwogICAgICAgICAgfSwKICAgICAgICB9LAogICAgICB9LAogICAgICBwbHVnaW5zOiBbCiAgICAgICAgZ2VuZXJpY09BdXRoKHsKICAgICAgICAgIGNvbmZpZzogWwogICAgICAgICAgICB7CiAgICAgICAgICAgICAgcHJvdmlkZXJJZDogcHJvdmlkZXJDb25maWcucHJvdmlkZXJJZCwKICAgICAgICAgICAgICBjbGllbnRJZDogcHJvdmlkZXJDb25maWcuY2xpZW50SWQsCiAgICAgICAgICAgICAgY2xpZW50U2VjcmV0OiBwcm92aWRlckNvbmZpZy5jbGllbnRTZWNyZXQsCiAgICAgICAgICAgICAgZGlzY292ZXJ5VXJsOiBkaXNjb3ZlcnlVcmxTdHIsCiAgICAgICAgICAgICAgLi4uKE9iamVjdC5rZXlzKG9pZGNDb25maWcpLmxlbmd0aCA+IDAgPyBvaWRjQ29uZmlnIDoge30pLAogICAgICAgICAgICAgIHNjb3BlczogcHJvdmlkZXJDb25maWcuc2NvcGVzLnNwbGl0KCIgIikuZmlsdGVyKEJvb2xlYW4pLAogICAgICAgICAgICB9LAogICAgICAgICAgXSwKICAgICAgICB9KSwKICAgICAgXSwKICAgICAgc29jaWFsUHJvdmlkZXJzOiB7CiAgICAgICAgLi4uKGhhc0dvb2dsZSA/IHsKICAgICAgICAgIGdvb2dsZTogewogICAgICAgICAgICBjbGllbnRJZDogcHJvY2Vzcy5lbnYuR09PR0xFX0NMSUVOVF9JRCEsCiAgICAgICAgICAgIGNsaWVudFNlY3JldDogcHJvY2Vzcy5lbnYuR09PR0xFX0NMSUVOVF9TRUNSRVQhLAogICAgICAgICAgfSwKICAgICAgICB9IDoge30pLAogICAgICAgIC4uLihoYXNHaXRIdWIgPyB7CiAgICAgICAgICBnaXRodWI6IHsKICAgICAgICAgICAgY2xpZW50SWQ6IHByb2Nlc3MuZW52LkdJVEhVQl9DTElFTlRfSUQhLAogICAgICAgICAgICBjbGllbnRTZWNyZXQ6IHByb2Nlc3MuZW52LkdJVEhVQl9DTElFTlRfU0VDUkVUISwKICAgICAgICAgIH0sCiAgICAgICAgfSA6IHt9KSwKICAgICAgfSwKICAgICAgc2Vzc2lvbjogewogICAgICAgIGV4cGlyZXNJbjogNjAgKiA2MCAqIDI0ICogNywgLy8gNyBkYXlzCiAgICAgICAgdXBkYXRlQWdlOiA2MCAqIDYwICogMjQsIC8vIDEgZGF5CiAgICAgICAgY29va2llQ2FjaGU6IHsKICAgICAgICAgIGVuYWJsZWQ6IHRydWUsCiAgICAgICAgICBtYXhBZ2U6IDUgKiA2MCwgLy8gNSBtaW51dGVzCiAgICAgICAgfSwKICAgICAgfSwKICAgICAgdHJ1c3RlZE9yaWdpbnM6IChwcm9jZXNzLmVudi5DT1JTX09SSUdJTiA/PyAiaHR0cDovL2xvY2FsaG9zdDo1MTczIikKICAgICAgICAuc3BsaXQoIiwiKS5tYXAoKHMpID0+IHMudHJpbSgpKS5maWx0ZXIoQm9vbGVhbiksCiAgICB9KTsKICB9KSgpOwoKICB0cnkgewogICAgYXdhaXQgYXV0aEluaXRQcm9taXNlOwogIH0gY2F0Y2ggKGVycikgewogICAgYXV0aEluaXRQcm9taXNlID0gbnVsbDsgLy8gYWxsb3cgcmV0cnkgb24gbmV4dCBjYWxsCiAgICB0aHJvdyBlcnI7CiAgfQp9Cg== \ No newline at end of file From a1b27b550156ae5cb9ddc975365765cd1be58248 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Wed, 5 Aug 2026 09:52:31 +0000 Subject: [PATCH 32/34] fix(GRO-2652): reset authInitPromise on failure so retry loop actually retries Previously the initAuth() function set authInitPromise to the async IIFE's Promise but never cleared it on rejection. The index.ts retry loop called initAuth() up to 10 times, but on attempt 2+ the check `if (authInitPromise) { await authInitPromise; return; }` would immediately re-throw the original rejection without performing a real retry. Fix: wrap the final `await authInitPromise` in try/catch and reset authInitPromise = null on error. This allows the index.ts retry loop to create a fresh attempt on each call after a failure. Co-Authored-By: Paperclip --- src/lib/auth.ts | 339 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 338 insertions(+), 1 deletion(-) diff --git a/src/lib/auth.ts b/src/lib/auth.ts index e314cad..87d5284 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1 +1,338 @@ -aW1wb3J0IHsgYmV0dGVyQXV0aCB9IGZyb20gImJldHRlci1hdXRoIjsKaW1wb3J0IHsgZHJpenpsZUFkYXB0ZXIgfSBmcm9tICJiZXR0ZXItYXV0aC9hZGFwdGVycy9kcml6emxlIjsKaW1wb3J0IHsgZ2VuZXJpY09BdXRoIH0gZnJvbSAiYmV0dGVyLWF1dGgvcGx1Z2lucyI7CmltcG9ydCB7IGdldERiLCBhdXRoUHJvdmlkZXJDb25maWcsIGVxIH0gZnJvbSAiQGdyb29tYm9vay9kYiI7CmltcG9ydCB7IGRlY3J5cHRTZWNyZXQgfSBmcm9tICJAZ3Jvb21ib29rL2RiIjsKaW1wb3J0IHsgc2VuZEVtYWlsIH0gZnJvbSAiLi4vc2VydmljZXMvZW1haWwuanMiOwoKY29uc3QgQkVUVEVSX0FVVEhfU0VDUkVUID0gcHJvY2Vzcy5lbnYuQkVUVEVSX0FVVEhfU0VDUkVUOwpjb25zdCBCRVRURVJfQVVUSF9VUkwgPSBwcm9jZXNzLmVudi5CRVRURVJfQVVUSF9VUkwgPz8gImh0dHA6Ly9sb2NhbGhvc3Q6MzAwMCI7CgovLyBBdXRoIGluc3RhbmNlIOKAlCBpbml0aWFsaXplZCBsYXppbHkgdmlhIGluaXRBdXRoKCkKLy8gZXNsaW50LWRpc2FibGUtbmV4dC1saW5lIEB0eXBlc2NyaXB0LWVzbGludC9uby1leHBsaWNpdC1hbnkKbGV0IGF1dGhJbnN0YW5jZTogYW55ID0gbnVsbDsKbGV0IGF1dGhJbml0UHJvbWlzZTogUHJvbWlzZTx2b2lkPiB8IG51bGwgPSBudWxsOwoKLyoqIFJldHVybnMgdGhlIGN1cnJlbnQgYXV0aCBpbnN0YW5jZS4gVGhyb3dzIGlmIG5vdCB5ZXQgaW5pdGlhbGl6ZWQuICovCmV4cG9ydCBmdW5jdGlvbiBnZXRBdXRoKCkgewogIGlmICghYXV0aEluc3RhbmNlKSB7CiAgICB0aHJvdyBuZXcgRXJyb3IoCiAgICAgICJBdXRoIG5vdCBpbml0aWFsaXplZC4gQ2FsbCBpbml0QXV0aCgpIGF0IHN0YXJ0dXAgYmVmb3JlIGhhbmRsaW5nIHJlcXVlc3RzLiIKICAgICk7CiAgfQogIHJldHVybiBhdXRoSW5zdGFuY2U7Cn0KCi8qKiBSZXR1cm5zIGEgcHJvbWlzZSB0aGF0IHJlc29sdmVzIHdoZW4gYXV0aCBpcyBpbml0aWFsaXplZC4gKi8KZXhwb3J0IGZ1bmN0aW9uIGdldEF1dGhQcm9taXNlKCkgewogIHJldHVybiBhdXRoSW5pdFByb21pc2U7Cn0KCi8qKiBSZXR1cm5zIHdoaWNoIE9BdXRoL3NvY2lhbCBwcm92aWRlcnMgYXJlIGNvbmZpZ3VyZWQgdmlhIGVudiB2YXJzLiAqLwpleHBvcnQgZnVuY3Rpb24gZ2V0QWN0aXZlUHJvdmlkZXJzKCk6IHN0cmluZ1tdIHsKICBjb25zdCBwcm92aWRlcnM6IHN0cmluZ1tdID0gW107CiAgaWYgKHByb2Nlc3MuZW52LkdPT0dMRV9DTElFTlRfSUQgJiYgcHJvY2Vzcy5lbnYuR09PR0xFX0NMSUVOVF9TRUNSRVQpIHsKICAgIHByb3ZpZGVycy5wdXNoKCJnb29nbGUiKTsKICB9CiAgaWYgKHByb2Nlc3MuZW52LkdJVEhVQl9DTElFTlRfSUQgJiYgcHJvY2Vzcy5lbnYuR0lUSFVCX0NMSUVOVF9TRUNSRVQpIHsKICAgIHByb3ZpZGVycy5wdXNoKCJnaXRodWIiKTsKICB9CiAgaWYgKHByb2Nlc3MuZW52Lk9JRENfSVNTVUVSICYmIHByb2Nlc3MuZW52Lk9JRENfQ0xJRU5UX0lEICYmIHByb2Nlc3MuZW52Lk9JRENfQ0xJRU5UX1NFQ1JFVCkgewogICAgcHJvdmlkZXJzLnB1c2goImF1dGhlbnRpayIpOwogIH0KICByZXR1cm4gcHJvdmlkZXJzOwp9CgovKioKICogUmUtaW5pdGlhbGl6ZXMgdGhlIEJldHRlci1BdXRoIGluc3RhbmNlIGFmdGVyIGF1dGggY29uZmlnIGNoYW5nZXMuCiAqCiAqIENsZWFycyBib3RoIGF1dGhJbnN0YW5jZSBhbmQgYXV0aEluaXRQcm9taXNlLCB0aGVuIGNhbGxzIGluaXRBdXRoKCkgdG8KICogcmUtcmVhZCBjb25maWcgZnJvbSBEQiBhbmQgYnVpbGQgYSBmcmVzaCBCZXR0ZXItQXV0aCBpbnN0YW5jZS4KICogU2Vzc2lvbnMgYXJlIERCLWJhY2tlZCBhbmQgc3Vydml2ZSB0aGUgcmUtaW5pdC4KICovCmV4cG9ydCBhc3luYyBmdW5jdGlvbiByZWluaXRBdXRoKCk6IFByb21pc2U8dm9pZD4gewogIGF1dGhJbnN0YW5jZSA9IG51bGw7CiAgYXV0aEluaXRQcm9taXNlID0gbnVsbDsKICBhd2FpdCBpbml0QXV0aCgpOwogIGNvbnNvbGUubG9nKCJbYXV0aF0gUmUtaW5pdGlhbGl6ZWQgYXV0aCBpbnN0YW5jZSBhZnRlciBjb25maWcgY2hhbmdlIik7Cn0KCi8qKgogKiBJbml0aWFsaXplcyB0aGUgQmV0dGVyLUF1dGggaW5zdGFuY2UuCiAqCiAqIENvbmZpZyByZXNvbHV0aW9uIGNoYWluOgogKiAxLiBRdWVyeSBhdXRoX3Byb3ZpZGVyX2NvbmZpZyB0YWJsZSBmb3IgYW4gZW5hYmxlZCBwcm92aWRlcgogKiAyLiBJZiBEQiBjb25maWcgZXhpc3RzIOKGkiB1c2UgaXQgKGRlY3J5cHQgY2xpZW50U2VjcmV0KQogKiAzLiBJZiBubyBEQiBjb25maWcg4oaSIGZhbGwgYmFjayB0byBPSURDXyogZW52IHZhcnMKICogNC4gSWYgbmVpdGhlciDihpIgYXV0aCBpcyB1bmNvbmZpZ3VyZWQgKGdldEF1dGgoKSByZXR1cm5zIG51bGwsIEFVVEhfRElTQUJMRUQgaW1wbGllZCkKICoKICogSWRlbXBvdGVudCDigJQgc3Vic2VxdWVudCBjYWxscyByZXR1cm4gaW1tZWRpYXRlbHkgYWZ0ZXIgaW5pdGlhbGl6YXRpb24gY29tcGxldGVzLgogKi8KZXhwb3J0IGFzeW5jIGZ1bmN0aW9uIGluaXRBdXRoKCk6IFByb21pc2U8dm9pZD4gewogIGlmIChhdXRoSW5zdGFuY2UpIHJldHVybjsgLy8gQWxyZWFkeSBpbml0aWFsaXplZAogIGlmIChhdXRoSW5pdFByb21pc2UpIHsKICAgIGF3YWl0IGF1dGhJbml0UHJvbWlzZTsKICAgIHJldHVybjsKICB9CgogIGF1dGhJbml0UHJvbWlzZSA9IChhc3luYyAoKSA9PiB7CiAgICAvLyBHdWFyZDogcmVxdWlyZSBCRVRURVJfQVVUSF9TRUNSRVQgdW5sZXNzIGV4cGxpY2l0bHkgaW4gZGV2L2RlbW8gbW9kZQogICAgaWYgKCFCRVRURVJfQVVUSF9TRUNSRVQgJiYgcHJvY2Vzcy5lbnYuQVVUSF9ESVNBQkxFRCAhPT0gInRydWUiKSB7CiAgICAgIHRocm93IG5ldyBFcnJvcigKICAgICAgICAiW0ZBVEFMXSBCRVRURVJfQVVUSF9TRUNSRVQgZW52aXJvbm1lbnQgdmFyaWFibGUgaXMgcmVxdWlyZWQgd2hlbiBhdXRoIGlzIGVuYWJsZWQiCiAgICAgICk7CiAgICB9CgogICAgLy8gQVVUSF9ESVNBQkxFRD10cnVlIG1lYW5zIGRldi9kZW1vIG1vZGUg4oCUIHN0aWxsIGJ1aWxkIEJldHRlci1BdXRoIHdpdGggcGxhY2Vob2xkZXIKICAgIC8vIGNvbmZpZyBzbyBhdXRoLmhhbmRsZXIgZXhpc3RzIChtaWRkbGV3YXJlIGJ5cGFzc2VzIGl0IGFueXdheSkKICAgIGlmIChwcm9jZXNzLmVudi5BVVRIX0RJU0FCTEVEID09PSAidHJ1ZSIpIHsKICAgICAgY29uc29sZS53YXJuKCJbYXV0aF0gQVVUSF9ESVNBQkxFRD10cnVlIOKAlCBidWlsZGluZyBwbGFjZWhvbGRlciBhdXRoIGluc3RhbmNlIik7CiAgICAgIGF1dGhJbnN0YW5jZSA9IGJldHRlckF1dGgoewogICAgICAgIGRhdGFiYXNlOiBkcml6emxlQWRhcHRlcihnZXREYigpLCB7IHByb3ZpZGVyOiAicGciIH0pLAogICAgICAgIHNlY3JldDogQkVUVEVSX0FVVEhfU0VDUkVUISwKICAgICAgICBiYXNlVVJMOiBCRVRURVJfQVVUSF9VUkwsCiAgICAgICAgcmF0ZUxpbWl0OiB7CiAgICAgICAgICBlbmFibGVkOiB0cnVlLAogICAgICAgICAgbWF4OiAxMDAsCiAgICAgICAgICB3aW5kb3c6IDEwLAogICAgICAgICAgc3RvcmFnZTogIm1lbW9yeSIsCiAgICAgICAgICBjdXN0b21SdWxlczogewogICAgICAgICAgICAiL2dldC1zZXNzaW9uIjogZmFsc2UsCiAgICAgICAgICB9LAogICAgICAgIH0sCiAgICAgICAgcGx1Z2luczogWwogICAgICAgICAgZ2VuZXJpY09BdXRoKHsKICAgICAgICAgICAgY29uZmlnOiBbCiAgICAgICAgICAgICAgewogICAgICAgICAgICAgICAgcHJvdmlkZXJJZDogImF1dGhlbnRpayIsCiAgICAgICAgICAgICAgICBjbGllbnRJZDogInBsYWNlaG9sZGVyIiwKICAgICAgICAgICAgICAgIGNsaWVudFNlY3JldDogInBsYWNlaG9sZGVyIiwKICAgICAgICAgICAgICAgIGRpc2NvdmVyeVVybDogdW5kZWZpbmVkLAogICAgICAgICAgICAgICAgc2NvcGVzOiBbIm9wZW5pZCIsICJwcm9maWxlIiwgImVtYWlsIl0sCiAgICAgICAgICAgICAgfSwKICAgICAgICAgICAgXSwKICAgICAgICAgIH0pLAogICAgICAgIF0sCiAgICAgICAgc2Vzc2lvbjogewogICAgICAgICAgZXhwaXJlc0luOiA2MCAqIDYwICogMjQgKiA3LAogICAgICAgICAgdXBkYXRlQWdlOiA2MCAqIDYwICogMjQsCiAgICAgICAgICBjb29raWVDYWNoZTogeyBlbmFibGVkOiBmYWxzZSB9LAogICAgICAgIH0sCiAgICAgICAgdHJ1c3RlZE9yaWdpbnM6IChwcm9jZXNzLmVudi5DT1JTX09SSUdJTiA/PyAiaHR0cDovL2xvY2FsaG9zdDo1MTczIikKICAgICAgICAgIC5zcGxpdCgiLCIpLm1hcCgocykgPT4gcy50cmltKCkpLmZpbHRlcihCb29sZWFuKSwKICAgICAgfSk7CiAgICAgIHJldHVybjsKICAgIH0KCiAgICAvLyBTdGVwIDE6IFRyeSB0byBsb2FkIGNvbmZpZyBmcm9tIERCLCB3aXRoIHJldHJ5LXdpdGgtYmFja29mZiBmb3IgdHJhbnNpZW50IEVDT05OUkVTRVQgKEdSTy0yNjUyKS4KICAgIC8vIEEgc2luZ2xlIGNvbm5lY3Rpb24gcmVzZXQgZHVyaW5nIGJvb3QgbXVzdCBub3QgYWJvcnQgaW5pdGlhbGl6YXRpb24uCiAgICBjb25zdCBkYiA9IGdldERiKCk7CiAgICBsZXQgZGJRdWVyeVJvd3M6ICh0eXBlb2YgYXV0aFByb3ZpZGVyQ29uZmlnLiRpbmZlclNlbGVjdClbXSA9IFtdOwogICAgbGV0IGRiQXR0ZW1wdCA9IDA7CiAgICB3aGlsZSAodHJ1ZSkgewogICAgICB0cnkgewogICAgICAgIGRiUXVlcnlSb3dzID0gYXdhaXQgZGIKICAgICAgICAgIC5zZWxlY3QoKQogICAgICAgICAgLmZyb20oYXV0aFByb3ZpZGVyQ29uZmlnKQogICAgICAgICAgLndoZXJlKGVxKGF1dGhQcm92aWRlckNvbmZpZy5lbmFibGVkLCB0cnVlKSkKICAgICAgICAgIC5saW1pdCgxKTsKICAgICAgICBicmVhazsKICAgICAgfSBjYXRjaCAoZXJyKSB7CiAgICAgICAgZGJBdHRlbXB0Kys7CiAgICAgICAgaWYgKGRiQXR0ZW1wdCA+PSA1KSB0aHJvdyBlcnI7CiAgICAgICAgY29uc3QgZGVsYXkgPSBNYXRoLm1pbigxMDAwICogMiAqKiAoZGJBdHRlbXB0IC0gMSksIDhfMDAwKTsKICAgICAgICBjb25zb2xlLndhcm4oYFthdXRoXSBEQiBxdWVyeSBhdHRlbXB0ICR7ZGJBdHRlbXB0fSBmYWlsZWQgKCR7ZXJyfSksIHJldHJ5aW5nIGluICR7ZGVsYXl9bXNgKTsKICAgICAgICBhd2FpdCBuZXcgUHJvbWlzZSgocikgPT4gc2V0VGltZW91dChyLCBkZWxheSkpOwogICAgICB9CiAgICB9CiAgICBjb25zdCBbZGJDb25maWddID0gZGJRdWVyeVJvd3M7CgogICAgbGV0IHByb3ZpZGVyQ29uZmlnOiB7CiAgICAgIHByb3ZpZGVySWQ6IHN0cmluZzsKICAgICAgY2xpZW50SWQ6IHN0cmluZzsKICAgICAgY2xpZW50U2VjcmV0OiBzdHJpbmc7CiAgICAgIGlzc3VlclVybDogc3RyaW5nOwogICAgICBpbnRlcm5hbEJhc2VVcmw/OiBzdHJpbmc7CiAgICAgIHNjb3Blczogc3RyaW5nOwogICAgfTsKCiAgICBpZiAoZGJDb25maWcpIHsKICAgICAgLy8gU3RlcCAyOiBVc2UgREIgY29uZmlnIChkZWNyeXB0IGNsaWVudFNlY3JldCkKICAgICAgY29uc3QgZGVjcnlwdGVkU2VjcmV0ID0gZGVjcnlwdFNlY3JldChkYkNvbmZpZy5jbGllbnRTZWNyZXQpOwogICAgICBwcm92aWRlckNvbmZpZyA9IHsKICAgICAgICBwcm92aWRlcklkOiBkYkNvbmZpZy5wcm92aWRlcklkLAogICAgICAgIGNsaWVudElkOiBkYkNvbmZpZy5jbGllbnRJZCwKICAgICAgICBjbGllbnRTZWNyZXQ6IGRlY3J5cHRlZFNlY3JldCwKICAgICAgICBpc3N1ZXJVcmw6IGRiQ29uZmlnLmlzc3VlclVybCwKICAgICAgICBpbnRlcm5hbEJhc2VVcmw6IGRiQ29uZmlnLmludGVybmFsQmFzZVVybCA/PyB1bmRlZmluZWQsCiAgICAgICAgc2NvcGVzOiBkYkNvbmZpZy5zY29wZXMsCiAgICAgIH07CiAgICAgIGNvbnNvbGUubG9nKCJbYXV0aF0gVXNpbmcgREIgY29uZmlnIGZvciBwcm92aWRlcjoiLCBkYkNvbmZpZy5wcm92aWRlcklkKTsKICAgIH0gZWxzZSB7CiAgICAgIC8vIFN0ZXAgMzogRmFsbCBiYWNrIHRvIGVudiB2YXJzCiAgICAgIGNvbnN0IG9pZGNJc3N1ZXIgPSBwcm9jZXNzLmVudi5PSURDX0lTU1VFUjsKICAgICAgY29uc3Qgb2lkY0NsaWVudElkID0gcHJvY2Vzcy5lbnYuT0lEQ19DTElFTlRfSUQ7CiAgICAgIGNvbnN0IG9pZGNDbGllbnRTZWNyZXQgPSBwcm9jZXNzLmVudi5PSURDX0NMSUVOVF9TRUNSRVQ7CgogICAgICBpZiAoIW9pZGNJc3N1ZXIgfHwgIW9pZGNDbGllbnRJZCB8fCAhb2lkY0NsaWVudFNlY3JldCkgewogICAgICAgIC8vIFN0ZXAgNDogTmVpdGhlciBEQiBjb25maWcgbm9yIGVudiB2YXJzIOKAlCBhdXRoIGlzIHVuY29uZmlndXJlZAogICAgICAgIGNvbnNvbGUud2FybigKICAgICAgICAgICJbYXV0aF0gTm8gYXV0aCBwcm92aWRlciBjb25maWd1cmVkLiBTZXQgdXAgYXV0aF9wcm92aWRlcl9jb25maWcgaW4gREIgb3IgT0lEQ18qIGVudiB2YXJzLiIKICAgICAgICApOwogICAgICAgIHJldHVybjsgLy8gYXV0aEluc3RhbmNlIHN0YXlzIG51bGwg4oCUIEFVVEhfRElTQUJMRUQgbW9kZQogICAgICB9CgogICAgICBwcm92aWRlckNvbmZpZyA9IHsKICAgICAgICBwcm92aWRlcklkOiAiYXV0aGVudGlrIiwKICAgICAgICBjbGllbnRJZDogb2lkY0NsaWVudElkLAogICAgICAgIGNsaWVudFNlY3JldDogb2lkY0NsaWVudFNlY3JldCwKICAgICAgICBpc3N1ZXJVcmw6IG9pZGNJc3N1ZXIsCiAgICAgICAgaW50ZXJuYWxCYXNlVXJsOiBwcm9jZXNzLmVudi5PSURDX0lOVEVSTkFMX0JBU0UsCiAgICAgICAgc2NvcGVzOiAib3BlbmlkIHByb2ZpbGUgZW1haWwgcm9sZSIsCiAgICAgIH07CiAgICAgIGNvbnNvbGUubG9nKCJbYXV0aF0gVXNpbmcgZW52IHZhciBjb25maWcgKG5vIERCIGNvbmZpZyBmb3VuZCkiKTsKICAgIH0KCiAgICBjb25zdCBoYXNHb29nbGUgPSAhIShwcm9jZXNzLmVudi5HT09HTEVfQ0xJRU5UX0lEICYmIHByb2Nlc3MuZW52LkdPT0dMRV9DTElFTlRfU0VDUkVUKTsKICAgIGNvbnN0IGhhc0dpdEh1YiA9ICEhKHByb2Nlc3MuZW52LkdJVEhVQl9DTElFTlRfSUQgJiYgcHJvY2Vzcy5lbnYuR0lUSFVCX0NMSUVOVF9TRUNSRVQpOwoKICAgIGNvbnN0IGlzc3VlclVybE9iaiA9IG5ldyBVUkwocHJvdmlkZXJDb25maWcuaXNzdWVyVXJsKTsKICAgIGNvbnN0IGlzc3Vlckhvc3RuYW1lID0gaXNzdWVyVXJsT2JqLmhvc3RuYW1lOwoKICAgIGNvbnN0IGRpc2NvdmVyeVVybFN0ciA9IGAke3Byb3ZpZGVyQ29uZmlnLmlzc3VlclVybH0vLndlbGwta25vd24vb3BlbmlkLWNvbmZpZ3VyYXRpb25gOwogICAgbGV0IG9pZGNDb25maWc6IFJlY29yZDxzdHJpbmcsIHN0cmluZz4gPSB7fTsKICAgIHRyeSB7CiAgICAgIGNvbnN0IGRpc2NvdmVyeVJlcyA9IGF3YWl0IGZldGNoKGRpc2NvdmVyeVVybFN0ciwgewogICAgICAgIHNpZ25hbDogQWJvcnRTaWduYWwudGltZW91dCg1MDAwKSwKICAgICAgfSk7CiAgICAgIGlmIChkaXNjb3ZlcnlSZXMub2spIHsKICAgICAgICBjb25zdCBkaXNjb3ZlcnkgPSBhd2FpdCBkaXNjb3ZlcnlSZXMuanNvbigpIGFzIHsKICAgICAgICAgIGF1dGhvcml6YXRpb25fZW5kcG9pbnQ/OiBzdHJpbmc7CiAgICAgICAgICB0b2tlbl9lbmRwb2ludD86IHN0cmluZzsKICAgICAgICAgIHVzZXJpbmZvX2VuZHBvaW50Pzogc3RyaW5nOwogICAgICAgIH07CiAgICAgICAgY29uc3QgcmVwbGFjZUhvc3QgPSAodXJsOiBzdHJpbmcsIG5ld0hvc3Q6IHN0cmluZykgPT4gewogICAgICAgICAgdHJ5IHsKICAgICAgICAgICAgY29uc3QgcGFyc2VkID0gbmV3IFVSTCh1cmwpOwogICAgICAgICAgICBjb25zdCBuZXdQYXJzZWQgPSBuZXcgVVJMKG5ld0hvc3QpOwogICAgICAgICAgICByZXR1cm4gYCR7bmV3UGFyc2VkLm9yaWdpbn0ke3BhcnNlZC5wYXRobmFtZX0ke3BhcnNlZC5zZWFyY2h9YDsKICAgICAgICAgIH0gY2F0Y2ggewogICAgICAgICAgICByZXR1cm4gdXJsOwogICAgICAgICAgfQogICAgICAgIH07CiAgICAgICAgY29uc3QgYXV0aHpVcmwgPSBkaXNjb3ZlcnkuYXV0aG9yaXphdGlvbl9lbmRwb2ludDsKICAgICAgICBjb25zdCB0b2tlblVybCA9IGRpc2NvdmVyeS50b2tlbl9lbmRwb2ludDsKICAgICAgICBjb25zdCB1c2VySW5mb1VybCA9IGRpc2NvdmVyeS51c2VyaW5mb19lbmRwb2ludDsKICAgICAgICBpZiAoYXV0aHpVcmwgJiYgdG9rZW5VcmwgJiYgdXNlckluZm9VcmwpIHsKICAgICAgICAgIGNvbnN0IGF1dGh6VXJsT2JqID0gbmV3IFVSTChhdXRoelVybCk7CiAgICAgICAgICAvLyBPbmx5IHZhbGlkYXRlIGF1dGhvcml6YXRpb25VcmwgaG9zdG5hbWUgYWdhaW5zdCBpc3N1ZXIg4oCUIHRva2VuL3VzZXJpbmZvCiAgICAgICAgICAvLyBtYXkgbGVnaXRpbWF0ZWx5IHVzZSBpbnRlcm5hbCBob3N0bmFtZXMgKE9JRENfSU5URVJOQUxfQkFTRSkgZm9yIHNlcnZlci10by1zZXJ2ZXIgY2FsbHMuCiAgICAgICAgICBpZiAoYXV0aHpVcmxPYmouaG9zdG5hbWUgIT09IGlzc3Vlckhvc3RuYW1lKSB7CiAgICAgICAgICAgIHRocm93IG5ldyBFcnJvcigKICAgICAgICAgICAgICBgW0ZBVEFMXSBPSURDIGRpc2NvdmVyeSBVUkwgaG9zdG5hbWUgbWlzbWF0Y2g6IGV4cGVjdGVkICcke2lzc3Vlckhvc3RuYW1lfScgYnV0IGdvdCAnJHthdXRoelVybE9iai5ob3N0bmFtZX0nLiBUaGlzIG1heSBpbmRpY2F0ZSBhIG1hbi1pbi10aGUtbWlkZGxlIGF0dGFjay5gCiAgICAgICAgICAgICk7CiAgICAgICAgICB9CiAgICAgICAgICBvaWRjQ29uZmlnID0gewogICAgICAgICAgICBhdXRob3JpemF0aW9uVXJsOiBhdXRoelVybCwKICAgICAgICAgICAgdG9rZW5Vcmw6IHByb3ZpZGVyQ29uZmlnLmludGVybmFsQmFzZVVybAogICAgICAgICAgICAgID8gcmVwbGFjZUhvc3QodG9rZW5VcmwsIHByb3ZpZGVyQ29uZmlnLmludGVybmFsQmFzZVVybCkKICAgICAgICAgICAgICA6IHRva2VuVXJsLAogICAgICAgICAgICB1c2VySW5mb1VybDogcHJvdmlkZXJDb25maWcuaW50ZXJuYWxCYXNlVXJsCiAgICAgICAgICAgICAgPyByZXBsYWNlSG9zdCh1c2VySW5mb1VybCwgcHJvdmlkZXJDb25maWcuaW50ZXJuYWxCYXNlVXJsKQogICAgICAgICAgICAgIDogdXNlckluZm9VcmwsCiAgICAgICAgICB9OwogICAgICAgICAgY29uc29sZS5sb2coIlthdXRoXSBPSURDIGRpc2NvdmVyeSBzdWNjZXNzZnVsLCBwcm92aWRlcjoiLCBwcm92aWRlckNvbmZpZy5wcm92aWRlcklkKTsKICAgICAgICB9IGVsc2UgewogICAgICAgICAgY29uc29sZS53YXJuKCJbYXV0aF0gT0lEQyBkaXNjb3ZlcnkgbWlzc2luZyByZXF1aXJlZCBlbmRwb2ludHMsIHVzaW5nIGRpc2NvdmVyeVVybCBvbmx5Iik7CiAgICAgICAgfQogICAgICB9IGVsc2UgewogICAgICAgIGNvbnNvbGUud2FybihgW2F1dGhdIE9JREMgZGlzY292ZXJ5IGZhaWxlZCAoJHtkaXNjb3ZlcnlSZXMuc3RhdHVzfSksIHVzaW5nIGRpc2NvdmVyeVVybCBvbmx5YCk7CiAgICAgIH0KICAgIH0gY2F0Y2ggKGVycikgewogICAgICBjb25zb2xlLndhcm4oYFthdXRoXSBPSURDIGRpc2NvdmVyeSBmZXRjaCBmYWlsZWQ6ICR7ZXJyfSwgdXNpbmcgZGlzY292ZXJ5VXJsIG9ubHlgKTsKICAgIH0KCiAgICAvLyBCdWlsZCBCZXR0ZXItQXV0aCBpbnN0YW5jZSB1c2luZyByZXNvbHZlZCBjb25maWcKICAgIGF1dGhJbnN0YW5jZSA9IGJldHRlckF1dGgoewogICAgICBkYXRhYmFzZTogZHJpenpsZUFkYXB0ZXIoZGIsIHsKICAgICAgICBwcm92aWRlcjogInBnIiwKICAgICAgfSksCiAgICAgIHNlY3JldDogQkVUVEVSX0FVVEhfU0VDUkVULAogICAgICBiYXNlVVJMOiBCRVRURVJfQVVUSF9VUkwsCiAgICAgIHJhdGVMaW1pdDogewogICAgICAgIGVuYWJsZWQ6IHRydWUsCiAgICAgICAgbWF4OiAxMDAsCiAgICAgICAgd2luZG93OiAxMCwKICAgICAgICBzdG9yYWdlOiAibWVtb3J5IiwKICAgICAgICBjdXN0b21SdWxlczogewogICAgICAgICAgIi9nZXQtc2Vzc2lvbiI6IGZhbHNlLAogICAgICAgIH0sCiAgICAgIH0sCiAgICAgIGFjY291bnQ6IHsKICAgICAgICBhY2NvdW50TGlua2luZzogewogICAgICAgICAgZW5hYmxlZDogdHJ1ZSwKICAgICAgICAgIHRydXN0ZWRQcm92aWRlcnM6IFsiYXV0aGVudGlrIl0sCiAgICAgICAgfSwKICAgICAgICBzdG9yZVN0YXRlU3RyYXRlZ3k6ICJjb29raWUiIGFzIGNvbnN0LAogICAgICB9LAogICAgICBlbWFpbEFuZFBhc3N3b3JkOiB7CiAgICAgICAgZW5hYmxlZDogdHJ1ZSwKICAgICAgICBlbWFpbFZlcmlmaWNhdGlvbjogewogICAgICAgICAgc2VuZFZlcmlmaWNhdGlvbkVtYWlsOiBhc3luYyAoeyB1c2VyLCB1cmwgfTogeyB1c2VyOiB7IGVtYWlsOiBzdHJpbmcgfTsgdXJsOiBzdHJpbmcgfSkgPT4gewogICAgICAgICAgICBhd2FpdCBzZW5kRW1haWwoewogICAgICAgICAgICAgIHRvOiB1c2VyLmVtYWlsLAogICAgICAgICAgICAgIHN1YmplY3Q6ICJWZXJpZnkgeW91ciBHcm9vbUJvb2sgZW1haWwiLAogICAgICAgICAgICAgIHRleHQ6IGBDbGljayB0aGUgbGluayB0byB2ZXJpZnkgeW91ciBlbWFpbDogJHt1cmx9YCwKICAgICAgICAgICAgICBodG1sOiBgPHA+Q2xpY2sgdGhlIGxpbmsgdG8gdmVyaWZ5IHlvdXIgZW1haWw6PC9wPjxhIGhyZWY9IiR7dXJsfSI+JHt1cmx9PC9hPmAsCiAgICAgICAgICAgIH0pOwogICAgICAgICAgfSwKICAgICAgICB9LAogICAgICB9LAogICAgICBwbHVnaW5zOiBbCiAgICAgICAgZ2VuZXJpY09BdXRoKHsKICAgICAgICAgIGNvbmZpZzogWwogICAgICAgICAgICB7CiAgICAgICAgICAgICAgcHJvdmlkZXJJZDogcHJvdmlkZXJDb25maWcucHJvdmlkZXJJZCwKICAgICAgICAgICAgICBjbGllbnRJZDogcHJvdmlkZXJDb25maWcuY2xpZW50SWQsCiAgICAgICAgICAgICAgY2xpZW50U2VjcmV0OiBwcm92aWRlckNvbmZpZy5jbGllbnRTZWNyZXQsCiAgICAgICAgICAgICAgZGlzY292ZXJ5VXJsOiBkaXNjb3ZlcnlVcmxTdHIsCiAgICAgICAgICAgICAgLi4uKE9iamVjdC5rZXlzKG9pZGNDb25maWcpLmxlbmd0aCA+IDAgPyBvaWRjQ29uZmlnIDoge30pLAogICAgICAgICAgICAgIHNjb3BlczogcHJvdmlkZXJDb25maWcuc2NvcGVzLnNwbGl0KCIgIikuZmlsdGVyKEJvb2xlYW4pLAogICAgICAgICAgICB9LAogICAgICAgICAgXSwKICAgICAgICB9KSwKICAgICAgXSwKICAgICAgc29jaWFsUHJvdmlkZXJzOiB7CiAgICAgICAgLi4uKGhhc0dvb2dsZSA/IHsKICAgICAgICAgIGdvb2dsZTogewogICAgICAgICAgICBjbGllbnRJZDogcHJvY2Vzcy5lbnYuR09PR0xFX0NMSUVOVF9JRCEsCiAgICAgICAgICAgIGNsaWVudFNlY3JldDogcHJvY2Vzcy5lbnYuR09PR0xFX0NMSUVOVF9TRUNSRVQhLAogICAgICAgICAgfSwKICAgICAgICB9IDoge30pLAogICAgICAgIC4uLihoYXNHaXRIdWIgPyB7CiAgICAgICAgICBnaXRodWI6IHsKICAgICAgICAgICAgY2xpZW50SWQ6IHByb2Nlc3MuZW52LkdJVEhVQl9DTElFTlRfSUQhLAogICAgICAgICAgICBjbGllbnRTZWNyZXQ6IHByb2Nlc3MuZW52LkdJVEhVQl9DTElFTlRfU0VDUkVUISwKICAgICAgICAgIH0sCiAgICAgICAgfSA6IHt9KSwKICAgICAgfSwKICAgICAgc2Vzc2lvbjogewogICAgICAgIGV4cGlyZXNJbjogNjAgKiA2MCAqIDI0ICogNywgLy8gNyBkYXlzCiAgICAgICAgdXBkYXRlQWdlOiA2MCAqIDYwICogMjQsIC8vIDEgZGF5CiAgICAgICAgY29va2llQ2FjaGU6IHsKICAgICAgICAgIGVuYWJsZWQ6IHRydWUsCiAgICAgICAgICBtYXhBZ2U6IDUgKiA2MCwgLy8gNSBtaW51dGVzCiAgICAgICAgfSwKICAgICAgfSwKICAgICAgdHJ1c3RlZE9yaWdpbnM6IChwcm9jZXNzLmVudi5DT1JTX09SSUdJTiA/PyAiaHR0cDovL2xvY2FsaG9zdDo1MTczIikKICAgICAgICAuc3BsaXQoIiwiKS5tYXAoKHMpID0+IHMudHJpbSgpKS5maWx0ZXIoQm9vbGVhbiksCiAgICB9KTsKICB9KSgpOwoKICB0cnkgewogICAgYXdhaXQgYXV0aEluaXRQcm9taXNlOwogIH0gY2F0Y2ggKGVycikgewogICAgYXV0aEluaXRQcm9taXNlID0gbnVsbDsgLy8gYWxsb3cgcmV0cnkgb24gbmV4dCBjYWxsCiAgICB0aHJvdyBlcnI7CiAgfQp9Cg== \ No newline at end of file +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { genericOAuth } from "better-auth/plugins"; +import { getDb, authProviderConfig, eq } from "@groombook/db"; +import { decryptSecret } from "@groombook/db"; +import { sendEmail } from "../services/email.js"; + +const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET; +const BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000"; + +// Auth instance — initialized lazily via initAuth() +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let authInstance: any = null; +let authInitPromise: Promise | null = null; + +/** Returns the current auth instance. Throws if not yet initialized. */ +export function getAuth() { + if (!authInstance) { + throw new Error( + "Auth not initialized. Call initAuth() at startup before handling requests." + ); + } + return authInstance; +} + +/** Returns a promise that resolves when auth is initialized. */ +export function getAuthPromise() { + return authInitPromise; +} + +/** Returns which OAuth/social providers are configured via env vars. */ +export function getActiveProviders(): string[] { + const providers: string[] = []; + if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) { + providers.push("google"); + } + if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) { + providers.push("github"); + } + if (process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID && process.env.OIDC_CLIENT_SECRET) { + providers.push("authentik"); + } + return providers; +} + +/** + * Re-initializes the Better-Auth instance after auth config changes. + * + * Clears both authInstance and authInitPromise, then calls initAuth() to + * re-read config from DB and build a fresh Better-Auth instance. + * Sessions are DB-backed and survive the re-init. + */ +export async function reinitAuth(): Promise { + authInstance = null; + authInitPromise = null; + await initAuth(); + console.log("[auth] Re-initialized auth instance after config change"); +} + +/** + * Initializes the Better-Auth instance. + * + * Config resolution chain: + * 1. Query auth_provider_config table for an enabled provider + * 2. If DB config exists → use it (decrypt clientSecret) + * 3. If no DB config → fall back to OIDC_* env vars + * 4. If neither → auth is unconfigured (getAuth() returns null, AUTH_DISABLED implied) + * + * Idempotent — subsequent calls return immediately after initialization completes. + */ +export async function initAuth(): Promise { + if (authInstance) return; // Already initialized + if (authInitPromise) { + await authInitPromise; + return; + } + + authInitPromise = (async () => { + // Guard: require BETTER_AUTH_SECRET unless explicitly in dev/demo mode + if (!BETTER_AUTH_SECRET && process.env.AUTH_DISABLED !== "true") { + throw new Error( + "[FATAL] BETTER_AUTH_SECRET environment variable is required when auth is enabled" + ); + } + + // AUTH_DISABLED=true means dev/demo mode — still build Better-Auth with placeholder + // config so auth.handler exists (middleware bypasses it anyway) + if (process.env.AUTH_DISABLED === "true") { + console.warn("[auth] AUTH_DISABLED=true — building placeholder auth instance"); + authInstance = betterAuth({ + database: drizzleAdapter(getDb(), { provider: "pg" }), + secret: BETTER_AUTH_SECRET!, + baseURL: BETTER_AUTH_URL, + rateLimit: { + enabled: true, + max: 100, + window: 10, + storage: "memory", + customRules: { + "/get-session": false, + }, + }, + plugins: [ + genericOAuth({ + config: [ + { + providerId: "authentik", + clientId: "placeholder", + clientSecret: "placeholder", + discoveryUrl: undefined, + scopes: ["openid", "profile", "email"], + }, + ], + }), + ], + session: { + expiresIn: 60 * 60 * 24 * 7, + updateAge: 60 * 60 * 24, + cookieCache: { enabled: false }, + }, + trustedOrigins: (process.env.CORS_ORIGIN ?? "http://localhost:5173") + .split(",").map((s) => s.trim()).filter(Boolean), + }); + return; + } + + // Step 1: Try to load config from DB, with retry-with-backoff for transient ECONNRESET (GRO-2652). + // A single connection reset during boot must not abort initialization. + const db = getDb(); + let dbQueryRows: (typeof authProviderConfig.$inferSelect)[] = []; + let dbAttempt = 0; + while (true) { + try { + dbQueryRows = await db + .select() + .from(authProviderConfig) + .where(eq(authProviderConfig.enabled, true)) + .limit(1); + break; + } catch (err) { + dbAttempt++; + if (dbAttempt >= 5) throw err; + const delay = Math.min(1000 * 2 ** (dbAttempt - 1), 8_000); + console.warn(`[auth] DB query attempt ${dbAttempt} failed (${err}), retrying in ${delay}ms`); + await new Promise((r) => setTimeout(r, delay)); + } + } + const [dbConfig] = dbQueryRows; + + let providerConfig: { + providerId: string; + clientId: string; + clientSecret: string; + issuerUrl: string; + internalBaseUrl?: string; + scopes: string; + }; + + if (dbConfig) { + // Step 2: Use DB config (decrypt clientSecret) + const decryptedSecret = decryptSecret(dbConfig.clientSecret); + providerConfig = { + providerId: dbConfig.providerId, + clientId: dbConfig.clientId, + clientSecret: decryptedSecret, + issuerUrl: dbConfig.issuerUrl, + internalBaseUrl: dbConfig.internalBaseUrl ?? undefined, + scopes: dbConfig.scopes, + }; + console.log("[auth] Using DB config for provider:", dbConfig.providerId); + } else { + // Step 3: Fall back to env vars + const oidcIssuer = process.env.OIDC_ISSUER; + const oidcClientId = process.env.OIDC_CLIENT_ID; + const oidcClientSecret = process.env.OIDC_CLIENT_SECRET; + + if (!oidcIssuer || !oidcClientId || !oidcClientSecret) { + // Step 4: Neither DB config nor env vars — auth is unconfigured + console.warn( + "[auth] No auth provider configured. Set up auth_provider_config in DB or OIDC_* env vars." + ); + return; // authInstance stays null — AUTH_DISABLED mode + } + + providerConfig = { + providerId: "authentik", + clientId: oidcClientId, + clientSecret: oidcClientSecret, + issuerUrl: oidcIssuer, + internalBaseUrl: process.env.OIDC_INTERNAL_BASE, + scopes: "openid profile email role", + }; + console.log("[auth] Using env var config (no DB config found)"); + } + + const hasGoogle = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET); + const hasGitHub = !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET); + + const issuerUrlObj = new URL(providerConfig.issuerUrl); + const issuerHostname = issuerUrlObj.hostname; + + const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`; + let oidcConfig: Record = {}; + try { + const discoveryRes = await fetch(discoveryUrlStr, { + signal: AbortSignal.timeout(5000), + }); + if (discoveryRes.ok) { + const discovery = await discoveryRes.json() as { + authorization_endpoint?: string; + token_endpoint?: string; + userinfo_endpoint?: string; + }; + const replaceHost = (url: string, newHost: string) => { + try { + const parsed = new URL(url); + const newParsed = new URL(newHost); + return `${newParsed.origin}${parsed.pathname}${parsed.search}`; + } catch { + return url; + } + }; + const authzUrl = discovery.authorization_endpoint; + const tokenUrl = discovery.token_endpoint; + const userInfoUrl = discovery.userinfo_endpoint; + if (authzUrl && tokenUrl && userInfoUrl) { + const authzUrlObj = new URL(authzUrl); + // Only validate authorizationUrl hostname against issuer — token/userinfo + // may legitimately use internal hostnames (OIDC_INTERNAL_BASE) for server-to-server calls. + if (authzUrlObj.hostname !== issuerHostname) { + throw new Error( + `[FATAL] OIDC discovery URL hostname mismatch: expected '${issuerHostname}' but got '${authzUrlObj.hostname}'. This may indicate a man-in-the-middle attack.` + ); + } + oidcConfig = { + authorizationUrl: authzUrl, + tokenUrl: providerConfig.internalBaseUrl + ? replaceHost(tokenUrl, providerConfig.internalBaseUrl) + : tokenUrl, + userInfoUrl: providerConfig.internalBaseUrl + ? replaceHost(userInfoUrl, providerConfig.internalBaseUrl) + : userInfoUrl, + }; + console.log("[auth] OIDC discovery successful, provider:", providerConfig.providerId); + } else { + console.warn("[auth] OIDC discovery missing required endpoints, using discoveryUrl only"); + } + } else { + console.warn(`[auth] OIDC discovery failed (${discoveryRes.status}), using discoveryUrl only`); + } + } catch (err) { + console.warn(`[auth] OIDC discovery fetch failed: ${err}, using discoveryUrl only`); + } + + // Build Better-Auth instance using resolved config + authInstance = betterAuth({ + database: drizzleAdapter(db, { + provider: "pg", + }), + secret: BETTER_AUTH_SECRET, + baseURL: BETTER_AUTH_URL, + rateLimit: { + enabled: true, + max: 100, + window: 10, + storage: "memory", + customRules: { + "/get-session": false, + }, + }, + account: { + accountLinking: { + enabled: true, + trustedProviders: ["authentik"], + }, + storeStateStrategy: "cookie" as const, + }, + emailAndPassword: { + enabled: true, + emailVerification: { + sendVerificationEmail: async ({ user, url }: { user: { email: string }; url: string }) => { + await sendEmail({ + to: user.email, + subject: "Verify your GroomBook email", + text: `Click the link to verify your email: ${url}`, + html: `

Click the link to verify your email:

${url}`, + }); + }, + }, + }, + plugins: [ + genericOAuth({ + config: [ + { + providerId: providerConfig.providerId, + clientId: providerConfig.clientId, + clientSecret: providerConfig.clientSecret, + discoveryUrl: discoveryUrlStr, + ...(Object.keys(oidcConfig).length > 0 ? oidcConfig : {}), + scopes: providerConfig.scopes.split(" ").filter(Boolean), + }, + ], + }), + ], + socialProviders: { + ...(hasGoogle ? { + google: { + clientId: process.env.GOOGLE_CLIENT_ID!, + clientSecret: process.env.GOOGLE_CLIENT_SECRET!, + }, + } : {}), + ...(hasGitHub ? { + github: { + clientId: process.env.GITHUB_CLIENT_ID!, + clientSecret: process.env.GITHUB_CLIENT_SECRET!, + }, + } : {}), + }, + session: { + expiresIn: 60 * 60 * 24 * 7, // 7 days + updateAge: 60 * 60 * 24, // 1 day + cookieCache: { + enabled: true, + maxAge: 5 * 60, // 5 minutes + }, + }, + trustedOrigins: (process.env.CORS_ORIGIN ?? "http://localhost:5173") + .split(",").map((s) => s.trim()).filter(Boolean), + }); + })(); + + try { + await authInitPromise; + } catch (err) { + authInitPromise = null; // allow retry on next call + throw err; + } +} From f1b0a5352002da98e6a3f4fdefea540e790a6a31 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Wed, 5 Aug 2026 10:04:31 +0000 Subject: [PATCH 33/34] test(GRO-2652): stub fetch in auth tests to fix OIDC discovery timeout flake AbortSignal.timeout(5000) in initAuth's discovery fetch races with vitest's 5000ms default test timeout, causing intermittent failures on CI push runs. Stub fetch to return ok:false instantly so tests complete in <1s. Co-Authored-By: Paperclip --- apps/api/src/__tests__/auth.test.ts | 5 +++++ src/__tests__/auth.test.ts | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/apps/api/src/__tests__/auth.test.ts b/apps/api/src/__tests__/auth.test.ts index 5446a3e..c338680 100644 --- a/apps/api/src/__tests__/auth.test.ts +++ b/apps/api/src/__tests__/auth.test.ts @@ -69,9 +69,14 @@ describe("auth init", () => { beforeEach(() => { dbSelectResult = []; vi.clearAllMocks(); + // Stub fetch so OIDC discovery requests resolve instantly during tests. + // Without this, AbortSignal.timeout(5000) in auth.ts races with vitest's + // 5000ms default test timeout and causes flaky failures. + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 503 })); }); afterEach(() => { + vi.unstubAllGlobals(); process.env = { ...originalEnv }; }); diff --git a/src/__tests__/auth.test.ts b/src/__tests__/auth.test.ts index 7b4db22..cf5ff60 100644 --- a/src/__tests__/auth.test.ts +++ b/src/__tests__/auth.test.ts @@ -69,9 +69,14 @@ describe("auth init", () => { beforeEach(() => { dbSelectResult = []; vi.clearAllMocks(); + // Stub fetch so OIDC discovery requests resolve instantly during tests. + // Without this, AbortSignal.timeout(5000) in auth.ts races with vitest's + // 5000ms default test timeout and causes flaky failures. + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 503 })); }); afterEach(() => { + vi.unstubAllGlobals(); process.env = { ...originalEnv }; }); From 81a833e392f813f2b70653a172f3cef021dcbd07 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Wed, 5 Aug 2026 10:57:27 +0000 Subject: [PATCH 34/34] chore: remove CI-trigger litter and agent tooling artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove trigger-uat-1779751324.txt (empty CI-trigger file) and .mcp.json (agent tooling config with bearer token) — neither belongs in main. Co-Authored-By: Paperclip --- .mcp.json | 11 ----------- trigger-uat-1779751324.txt | 0 2 files changed, 11 deletions(-) delete mode 100644 .mcp.json delete mode 100644 trigger-uat-1779751324.txt diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index 6efc1ca..0000000 --- a/.mcp.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "mcpServers": { - "gitea": { - "type": "http", - "url": "https://git-mcp.farh.net/mcp", - "headers": { - "Authorization": "Bearer ${GITEA_TOKEN}" - } - } - } -} diff --git a/trigger-uat-1779751324.txt b/trigger-uat-1779751324.txt deleted file mode 100644 index e69de29..0000000