Compare commits

...

28 Commits

Author SHA1 Message Date
Flea Flicker 413849f066 fix(auth): add /api/readyz to auth bypass list (GRO-2692)
CI / Lint & Typecheck (push) Successful in 19s
CI / Test (push) Successful in 21s
CI / Lint & Typecheck (pull_request) Successful in 19s
CI / Test (pull_request) Successful in 20s
CI / Build & Push Docker Images (pull_request) Successful in 26s
CI / Build & Push Docker Images (push) Successful in 49s
CI passed (run #4322). Self-merging Phase 1 per SDLC gate.
2026-08-09 10:15:45 +00:00
Flea Flicker 9227cf4883 fix(api): add /api/readyz to authMiddleware bypass (GRO-2687 UAT fix)
CI / Test (push) Successful in 19s
CI / Lint & Typecheck (push) Successful in 24s
CI / Lint & Typecheck (pull_request) Successful in 20s
CI / Test (pull_request) Successful in 25s
CI / Build & Push Docker Images (push) Successful in 58s
CI / Build & Push Docker Images (pull_request) Successful in 29s
2026-08-09 09:54:35 +00:00
Flea Flicker 7dfa1ad830 fix(auth): add /api/readyz to auth middleware bypass list (GRO-2692)
CI / Lint & Typecheck (pull_request) Successful in 24s
CI / Test (pull_request) Successful in 35s
CI / Build & Push Docker Images (pull_request) Successful in 1m19s
/api/readyz is a public monitoring endpoint like /api/health.
app.basePath("/api") + api.use("*", authMiddleware) applies to all
/api/* paths regardless of route registration order (GRO-2692 UAT
failure: Hono basePath middleware bypass).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-09 09:53:19 +00:00
Flea Flicker 8f5a069e77 fix(api): add /api/readyz to authMiddleware bypass list (GRO-2687)
CI / Lint & Typecheck (pull_request) Successful in 19s
CI / Test (pull_request) Successful in 21s
CI / Build & Push Docker Images (pull_request) Successful in 55s
/api/readyz was returning 401 Unauthorized in UAT (GRO-2692 Shedward
regression). The authMiddleware for /api/* did not whitelist /api/readyz,
causing every unauthenticated probe request to be rejected.

Add /api/readyz to the bypass condition alongside /api/auth/* and
/api/health. Add readyz-auth-bypass.test.ts confirming the route passes
through the middleware without auth and that non-whitelisted paths still
block (503 when auth not configured).

Closes GRO-2692 regression (UAT fail).
2026-08-09 09:52:37 +00:00
Flea Flicker 07717afd01 revert(api): remove /health/ready — superseded by /api/readyz (GRO-2689)
CI / Lint & Typecheck (push) Successful in 19s
CI / Test (push) Successful in 21s
CI / Build & Push Docker Images (push) Successful in 43s
2026-08-09 09:50:43 +00:00
Flea Flicker d8f6981be1 revert(api): remove /health/ready — superseded by /api/readyz (GRO-2689)
CI / Lint & Typecheck (pull_request) Successful in 18s
CI / Test (pull_request) Successful in 25s
CI / Build & Push Docker Images (pull_request) Successful in 53s
CTO architectural ruling (PR #235 review): K8s readiness/liveness probes
must remain DB-less to prevent transient DB blips from cycling pods. The
/api/readyz endpoint (GRO-2687) is the canonical DB-health signal for the
monitoring layer and satisfies the GRO-2678 detection-gap requirement.

Removes:
- GET /health/ready route from src/index.ts
- src/__tests__/health-ready.test.ts

Refs GRO-2689, GRO-2687, GRO-2678
2026-08-09 09:48:10 +00:00
Flea Flicker d7bb314087 feat(api): add DB-touching /health/ready readiness probe (GRO-2678)
CI / Lint & Typecheck (push) Successful in 19s
CI / Test (push) Successful in 21s
CI / Build & Push Docker Images (push) Successful in 34s
CI / Build & Push Docker Images (pull_request) Successful in 23s
CI / Lint & Typecheck (pull_request) Successful in 20s
CI / Test (pull_request) Successful in 21s
2026-08-09 09:26:17 +00:00
Flea Flicker 7679fada0a feat(api): add DB-touching /health/ready readiness probe (GRO-2678)
CI / Lint & Typecheck (pull_request) Successful in 19s
CI / Test (pull_request) Successful in 21s
CI / Build & Push Docker Images (pull_request) Successful in 3m25s
Register GET /health/ready before the /api/* auth middleware so it is
public and reachable by K8s readinessProbe on port 3000 without auth.
On success → 200 {"status":"ready"}; on any DB/schema failure → 503
{"status":"degraded"}. Logs the pg error code; never leaks SQL in body.
A dropped schema (42P01) surfaces as non-200, closing the /health mask
that allowed the GRO-2678 incident to go undetected for ~43h.

Add health-ready.test.ts covering the 200 success path, 503 on schema
drop (42P01), and 503 on connection error; all assert no SQL leakage.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-09 09:21:56 +00:00
Flea Flicker a164c24e8e feat(api): add DB-touching /api/readyz so schema loss cannot hide behind /health (GRO-2678)
CI / Lint & Typecheck (pull_request) Successful in 19s
CI / Test (pull_request) Successful in 21s
CI / Build & Push Docker Images (pull_request) Successful in 31s
CI / Lint & Typecheck (push) Successful in 20s
CI / Test (push) Successful in 22s
CI / Build & Push Docker Images (push) Successful in 34s
2026-08-09 09:11:35 +00:00
Flea Flicker 6148ae6439 ci: retrigger after seed-image registry push flake
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Test (pull_request) Successful in 19s
CI / Build & Push Docker Images (pull_request) Successful in 46s
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-09 09:10:10 +00:00
Flea Flicker f54a13fc8a feat(api): add DB-touching /api/readyz so schema loss cannot hide behind /health (GRO-2678)
CI / Test (pull_request) Failing after 30s
CI / Lint & Typecheck (pull_request) Successful in 2m31s
CI / Build & Push Docker Images (pull_request) Has been skipped
- Register GET /api/readyz after /api/health in src/index.ts (before authMiddleware)
- Runs `getDb().select({id: staff.id}).from(staff).limit(1)` inside try/catch
- Success → 200 {"status":"ready"}; failure → 503 {"status":"degraded","check":"db"}
- Raw driver error is console.error'd but never leaked to the response body
- /health and /api/health are unchanged (K8s probes remain DB-less per CTO decision)
- New unit tests: mock getDb to resolve → assert 200/ready; throw → assert 503/degraded
- Updated UAT_PLAYBOOK.md §4.0 with TC-API-0.2 and TC-API-0.3

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-09 09:02:39 +00:00
Flea Flicker f7f90a71fc fix(GRO-2672): use drizzle-kit migrate in reset.ts to bypass HWM bug
CI / Lint & Typecheck (push) Successful in 20s
CI / Test (push) Successful in 21s
CI / Build & Push Docker Images (push) Successful in 37s
CI / Test (pull_request) Successful in 20s
CI / Lint & Typecheck (pull_request) Successful in 37s
CI / Build & Push Docker Images (pull_request) Successful in 42s
drizzle-orm's migrate() has a high-water-mark (HWM) bug: on a fresh DB,
migration 0000 sets the watermark to 2026-03-17. Migrations 0001, 0003,
0010, 0011 have stale 2025-era `when` timestamps and are silently
skipped. Migration 0003 (recurring_series) is the blocker — its skip
leaves `recurring_series`, `appointments.series_id`, and
`appointments.series_index` missing. A downstream migration inside
migrate()'s single Postgres transaction then fails, rolling back
everything including 0000's `staff` and `services` tables.

Replace the drizzle-orm migrate() call with `pnpm exec drizzle-kit
migrate` (hash-based). drizzle-kit applies every unhashed migration
regardless of `when` ordering, matching the K8s migrate Job exactly.
2026-08-06 09:14:55 +00:00
Flea Flicker f1b0a53520 test(GRO-2652): stub fetch in auth tests to fix OIDC discovery timeout flake
CI / Lint & Typecheck (push) Successful in 19s
CI / Test (push) Successful in 22s
CI / Lint & Typecheck (pull_request) Successful in 19s
CI / Test (pull_request) Successful in 20s
CI / Build & Push Docker Images (pull_request) Successful in 52s
CI / Build & Push Docker Images (push) Successful in 1m45s
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 <noreply@paperclip.ing>
2026-08-05 10:04:31 +00:00
Flea Flicker f32e9a6889 fix(GRO-2652): reset authInitPromise on failure so retry loop actually retries
CI / Lint & Typecheck (push) Successful in 20s
CI / Test (push) Failing after 21s
CI / Build & Push Docker Images (push) Has been skipped
CI / Lint & Typecheck (pull_request) Successful in 18s
CI / Test (pull_request) Failing after 25s
CI / Build & Push Docker Images (pull_request) Has been skipped
Merging Phase 1 fix — CI all green (lint, test, build all success). Resolves QA's retry-memoization concern from PR #223 review.
2026-08-05 10:00:12 +00:00
Flea Flicker a1b27b5501 fix(GRO-2652): reset authInitPromise on failure so retry loop actually retries
CI / Lint & Typecheck (pull_request) Successful in 19s
CI / Test (pull_request) Successful in 21s
CI / Build & Push Docker Images (pull_request) Successful in 53s
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 <noreply@paperclip.ing>
2026-08-05 09:52:31 +00:00
Flea Flicker ae0ce3824f fix(GRO-2652): reset authInitPromise on failure so retry loop actually retries
CI / Lint & Typecheck (pull_request) Failing after 10s
CI / Test (pull_request) Failing after 24s
CI / Build & Push Docker Images (pull_request) Has been skipped
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 <noreply@paperclip.ing>
2026-08-05 09:44:12 +00:00
Flea Flicker f98c5ccaf7 fix(GRO-2652): restore ci.yml from double-base64 corruption + UAT_PLAYBOOK §4.20 clients-from-auth
CI / Lint & Typecheck (push) Successful in 21s
CI / Test (pull_request) Successful in 21s
CI / Lint & Typecheck (pull_request) Successful in 34s
CI / Test (push) Failing after 40s
CI / Build & Push Docker Images (push) Has been skipped
CI / Build & Push Docker Images (pull_request) Successful in 27s
2026-08-05 09:29:16 +00:00
Flea Flicker 61f23e47f1 docs(GRO-2359): add UAT_PLAYBOOK §4.20 clients-from-auth test cases
CI / Lint & Typecheck (pull_request) Successful in 19s
CI / Test (pull_request) Successful in 21s
CI / Build & Push Docker Images (pull_request) Successful in 1m9s
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-05 09:20:03 +00:00
Flea Flicker ab5f08e4c0 fix(GRO-2652): restore ci.yml from double-base64 corruption; fix outpuds typo
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-05 09:19:55 +00:00
gb_flea 5accf73363 ci: trigger CI for dev branch (PR-223 dev→uat) 2026-08-05 09:04:17 +00:00
Flea Flicker 299a157561 fix(GRO-2652): boot ECONNRESET resilience — retry-with-backoff in initAuth, server-first startup
Merges feature/GRO-2652-boot-econnreset-resilience into dev. CI passed (run #440). Fixes PROD CrashLoopBackOff caused by bare top-level await initAuth() + unhandled ECONNRESET in auth_provider_config DB query.
2026-08-05 09:01:12 +00:00
gb_flea af1af5c211 ci: trigger CI run for ignore-error=true cache-to fix 2026-08-05 08:56:30 +00:00
Flea Flicker 713e8bf415 ci: add ignore-error=true to all cache-to targets
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.
2026-08-05 08:54:00 +00:00
Flea Flicker 8d42bfffc9 test(GRO-2652): add boot resilience UAT test cases (TC-API-19.x)
CI / Test (pull_request) Successful in 25s
CI / Lint & Typecheck (pull_request) Successful in 28s
CI / Build & Push Docker Images (pull_request) Successful in 56s
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 <noreply@paperclip.ing>
2026-08-05 08:22:00 +00:00
Flea Flicker 095efb1cca fix(GRO-2652): start server before initAuth; retry auth init on ECONNRESET
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 <noreply@paperclip.ing>
2026-08-05 08:21:14 +00:00
Flea Flicker 632dadd064 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 <noreply@paperclip.ing>
2026-08-05 08:17:31 +00:00
Flea Flicker dace2c4e66 fix(GRO-2586): enforce trusted-origins allowlist on Better Auth CORS responses (#219)
CI / Lint & Typecheck (push) Successful in 19s
CI / Test (push) Successful in 21s
CI / Build & Push Docker Images (push) Successful in 1m4s
fix(GRO-2586): enforce trusted-origins allowlist on Better Auth CORS responses

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-26 13:35:59 +00:00
Flea Flicker c01e4acf0a feat(GRO-2425): split CORS_ORIGIN on commas for multiple trusted auth origins (#216)
CI / Test (push) Successful in 30s
CI / Lint & Typecheck (push) Successful in 45s
CI / Build & Push Docker Images (push) Successful in 1m10s
CI / Test (pull_request) Successful in 25s
CI / Lint & Typecheck (pull_request) Failing after 12m18s
CI / Build & Push Docker Images (pull_request) Has been skipped
feat(GRO-2425): split CORS_ORIGIN on commas for multiple trusted auth origins

Co-authored-by: Flea Flicker <flea@groombook.dev>
Co-committed-by: Flea Flicker <flea@groombook.dev>
2026-06-18 00:46:29 +00:00
12 changed files with 349 additions and 22 deletions
+5 -4
View File
@@ -102,7 +102,7 @@ jobs:
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
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
@@ -116,7 +116,7 @@ jobs:
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
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: |
@@ -141,7 +141,7 @@ jobs:
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
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
@@ -155,7 +155,7 @@ jobs:
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
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: |
@@ -185,3 +185,4 @@ jobs:
"$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 ✓"
+41
View File
@@ -65,8 +65,11 @@ Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-API-0.1 | Unauthenticated health check | GET /api/health | 200 OK, `{"status":"ok"}` |
| TC-API-0.2 | DB-touching readiness check — healthy (GRO-2678) | GET /api/readyz | 200 OK, `{"status":"ready"}` |
| TC-API-0.3 | DB-touching readiness check — response body safe | GET /api/readyz and inspect body | Body contains only `status` and (on error) `check` fields — no raw SQL, driver messages, or stack traces |
> **Note (GRO-1544):** Health endpoint registered on `api` basePath before auth middleware at `/api/health`. The old path `/health` was incorrect (routed to web pod via HTTPRoute `/*` rule).
> **Note (GRO-2678):** `/api/readyz` is a separate DB-touching endpoint for monitoring. It is intentionally NOT used for K8s liveness/readiness probes — those remain DB-less to avoid pod cycling on transient DB blips.
### 4.1 Authentication
@@ -108,6 +111,11 @@ 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" |
| 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
@@ -434,6 +442,38 @@ 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)<br>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<br>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<br>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 <api-pod>` | 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<br>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 |
### 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<br>2. `POST /api/portal/clients-from-auth` with `{ "name": "Test User" }` | 201 `{ "id": "<uuid>", "name": "Test User", "email": "<sso-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<br>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<br>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:**
@@ -455,3 +495,4 @@ Both use the stops' stored `latitude`/`longitude` in `stopOrder`: **origin = fir
## 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").
+5
View File
@@ -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 };
});
+11 -4
View File
@@ -32,7 +32,7 @@
*/
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import { execSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import * as schema from "./schema.js";
@@ -46,7 +46,6 @@ import {
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;
@@ -73,7 +72,7 @@ async function reset() {
// 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
// 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
@@ -122,7 +121,15 @@ async function reset() {
console.log("✓ All tables and enums dropped\n");
console.log("Running migrations...");
await migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
// GRO-2672: drizzle-orm's migrate() has a high-water-mark bug that skips
// migrations with stale `when` timestamps (0001, 0003, 0010, 0011). Use
// drizzle-kit instead -- it applies migrations by hash, matching the K8s
// migrate Job behaviour exactly.
execSync("pnpm exec drizzle-kit migrate", {
stdio: "inherit",
env: { ...process.env },
cwd: resolve(__dirname, ".."),
});
console.log("✓ Migrations applied\n");
console.log("Seeding database...");
+5
View File
@@ -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 };
});
+60
View File
@@ -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();
});
});
+44
View File
@@ -0,0 +1,44 @@
import { describe, it, expect, vi } from "vitest";
import { Hono } from "hono";
// Mock auth lib so getAuth() throws — non-bypass paths hit the 503 "not configured" guard.
vi.mock("../lib/auth.js", () => ({
getAuth: () => {
throw new Error("auth not configured");
},
initAuth: vi.fn(),
getActiveProviders: vi.fn(() => []),
}));
describe("authMiddleware bypass: /api/readyz", () => {
it("serves /api/readyz without auth (bypass before auth check)", async () => {
// Ensure AUTH_DISABLED is not set so the bypass is exercised, not AUTH_DISABLED shortcut.
const prev = process.env.AUTH_DISABLED;
delete process.env.AUTH_DISABLED;
const { authMiddleware } = await import("../middleware/auth.js");
const app = new Hono();
app.use("/api/*", authMiddleware);
app.get("/api/readyz", (c) => c.json({ status: "ok" }, 200));
const res = await app.request("/api/readyz", { method: "GET" });
expect(res.status).toBe(200);
if (prev !== undefined) process.env.AUTH_DISABLED = prev;
});
it("blocks non-whitelisted /api/* paths when auth is not configured", async () => {
const prev = process.env.AUTH_DISABLED;
delete process.env.AUTH_DISABLED;
const { authMiddleware } = await import("../middleware/auth.js");
const app = new Hono();
app.use("/api/*", authMiddleware);
app.get("/api/staff", (c) => c.json({ ok: true }, 200));
const res = await app.request("/api/staff", { method: "GET" });
expect(res.status).toBe(503);
if (prev !== undefined) process.env.AUTH_DISABLED = prev;
});
});
+87
View File
@@ -0,0 +1,87 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
// ─── Mock db module ───────────────────────────────────────────────────────────
let selectImpl: () => Promise<unknown>;
vi.mock("@groombook/db", () => {
const staff = new Proxy(
{ _name: "staff" },
{
get(_target, prop) {
if (prop === "_name") return "staff";
return { table: "staff", column: prop };
},
}
);
return {
getDb: () => ({
select: (_fields: unknown) => ({
from: (_table: unknown) => ({
limit: (_n: number) => selectImpl(),
}),
}),
}),
staff,
};
});
// ─── Build test app ───────────────────────────────────────────────────────────
async function makeApp() {
// Import after mocks are in place
const { getDb, staff } = await import("@groombook/db");
const app = new Hono();
app.get("/api/readyz", async (c) => {
try {
await getDb().select({ id: staff.id }).from(staff).limit(1);
return c.json({ status: "ready" }, 200);
} catch (err) {
console.error("[readyz] DB check failed:", err);
return c.json({ status: "degraded", check: "db" }, 503);
}
});
return app;
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("GET /api/readyz", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("returns 200 {status:'ready'} when DB query succeeds", async () => {
selectImpl = () => Promise.resolve([{ id: "staff-1" }]);
const app = await makeApp();
const res = await app.request("/api/readyz", { method: "GET" });
const body = (await res.json()) as Record<string, unknown>;
expect(res.status).toBe(200);
expect(body.status).toBe("ready");
});
it("returns 503 {status:'degraded',check:'db'} when DB query throws", async () => {
selectImpl = () => Promise.reject(new Error("42P01: relation staff does not exist"));
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const app = await makeApp();
const res = await app.request("/api/readyz", { method: "GET" });
const body = (await res.json()) as Record<string, unknown>;
expect(res.status).toBe(503);
expect(body.status).toBe("degraded");
expect(body.check).toBe("db");
// Raw SQL / driver error must NOT appear in the response body
expect(JSON.stringify(body)).not.toContain("42P01");
expect(JSON.stringify(body)).not.toContain("relation");
consoleSpy.mockRestore();
});
});
+37 -4
View File
@@ -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";
@@ -64,6 +65,17 @@ app.use(
app.get("/health", (c) => c.json({ status: "ok" }));
// /api/health: used by Gateway HTTPRoute (/api/* → API pod)
app.get("/api/health", (c) => c.json({ status: "ok" }));
// /api/readyz: DB-touching deep health check consumed by monitoring (not K8s probes)
// Distinct from /health so a dropped schema triggers an alert without cycling pods (GRO-2678)
app.get("/api/readyz", async (c) => {
try {
await getDb().select({ id: staff.id }).from(staff).limit(1);
return c.json({ status: "ready" }, 200);
} catch (err) {
console.error("[readyz] DB check failed:", err);
return c.json({ status: "degraded", check: "db" }, 503);
}
});
// Public booking routes — no auth required, must be registered before auth middleware
app.route("/api/book", bookRouter);
@@ -200,9 +212,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);
}
@@ -290,14 +303,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)
+22
View File
@@ -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 });
}
+31 -9
View File
@@ -118,18 +118,34 @@ export async function initAuth(): Promise<void> {
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;
}
// 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;
@@ -308,9 +324,15 @@ export async function initAuth(): Promise<void> {
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),
});
})();
await authInitPromise;
try {
await authInitPromise;
} catch (err) {
authInitPromise = null; // allow retry on next call
throw err;
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ if (process.env.AUTH_DISABLED === "true") {
}
export const authMiddleware: MiddlewareHandler = async (c, next) => {
if (c.req.path.startsWith("/api/auth/") || c.req.path === "/api/health") {
if (c.req.path.startsWith("/api/auth/") || c.req.path === "/api/health" || c.req.path === "/api/readyz") {
await next();
return;
}