From cdeebec021022144241cd98b7cec9af7814cf0dd Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Thu, 11 Jun 2026 16:17:16 +0000 Subject: [PATCH 01/14] 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); -- 2.52.0 From c01e4acf0aa79ff3fe3e69ed12d56fa906554729 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Thu, 18 Jun 2026 00:46:29 +0000 Subject: [PATCH 02/14] 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), }); })(); -- 2.52.0 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 03/14] 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 }); +} -- 2.52.0 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 04/14] 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; -- 2.52.0 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 05/14] 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) -- 2.52.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 06/14] 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:** -- 2.52.0 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 07/14] 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 -- 2.52.0 From af1af5c211c3dd89d20f0d94c6f4fb91d2934553 Mon Sep 17 00:00:00 2001 From: gb_flea Date: Wed, 5 Aug 2026 08:56:30 +0000 Subject: [PATCH 08/14] ci: trigger CI run for ignore-error=true cache-to fix -- 2.52.0 From 5accf73363fbb030dfbaed7579af19673b1e8c3c Mon Sep 17 00:00:00 2001 From: gb_flea Date: Wed, 5 Aug 2026 09:04:17 +0000 Subject: [PATCH 09/14] =?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 -- 2.52.0 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 10/14] 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 ✓" + -- 2.52.0 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 11/14] =?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"). + -- 2.52.0 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 12/14] 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 -- 2.52.0 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 13/14] 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; + } +} -- 2.52.0 From f1b0a5352002da98e6a3f4fdefea540e790a6a31 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Wed, 5 Aug 2026 10:04:31 +0000 Subject: [PATCH 14/14] 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 }; }); -- 2.52.0