From 1c7628459f750b8078085415901e42b4d451a485 Mon Sep 17 00:00:00 2001 From: Paperclip Date: Sat, 4 Apr 2026 13:14:18 +0000 Subject: [PATCH 01/10] fix(db): use random per-encryption salt in crypto.ts (GRO-453) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generate a unique 16-byte random salt for each encryptSecret() call and store it as a prefix in the ciphertext. Format changed from iv:ciphertext:authTag → salt:iv:ciphertext:authTag decryptSecret() detects legacy 3-part format and uses the fixed package salt for backward compatibility with existing encrypted rows. Co-Authored-By: Paperclip --- apps/api/src/__tests__/crypto.test.ts | 11 +++-- packages/db/src/crypto.ts | 58 ++++++++++++++++++--------- 2 files changed, 43 insertions(+), 26 deletions(-) diff --git a/apps/api/src/__tests__/crypto.test.ts b/apps/api/src/__tests__/crypto.test.ts index 36663f3..765c327 100644 --- a/apps/api/src/__tests__/crypto.test.ts +++ b/apps/api/src/__tests__/crypto.test.ts @@ -24,11 +24,11 @@ describe("encryptSecret / decryptSecret", () => { expect(decrypted).toBe(plaintext); }); - it("produces output in iv:ciphertext:authTag format", () => { + it("produces output in salt:iv:ciphertext:authTag format", () => { const encrypted = encryptSecret("test"); const parts = encrypted.split(":"); - expect(parts).toHaveLength(3); + expect(parts).toHaveLength(4); // Each part should be valid base64 parts.forEach((part) => { expect(() => Buffer.from(part, "base64")).not.toThrow(); @@ -61,12 +61,11 @@ describe("encryptSecret / decryptSecret", () => { }); it("throws when decrypting invalid format (wrong number of parts)", () => { - const encrypted = encryptSecret("test"); - // Replace the last ":authTag" part by matching colon + non-colon chars at the end - const invalid = encrypted.replace(/:[^:]+$/, ""); + // 2 parts is invalid for both legacy (3) and new (4) format + const invalid = "not-enough-parts"; expect(() => decryptSecret(invalid)).toThrow( - "Invalid encrypted value format: expected iv:ciphertext:authTag" + "Invalid encrypted value format: expected salt:iv:ciphertext:authTag or iv:ciphertext:authTag" ); }); diff --git a/packages/db/src/crypto.ts b/packages/db/src/crypto.ts index 371c3d3..b335af4 100644 --- a/packages/db/src/crypto.ts +++ b/packages/db/src/crypto.ts @@ -6,19 +6,22 @@ const AUTH_TAG_LENGTH = 16; // 128-bit auth tag const SALT_LENGTH = 16; /** - * Derives a 32-byte key from BETTER_AUTH_SECRET using scrypt. - * BETTER_AUTH_SECRET is used as the password, with a fixed salt derived from the package name. + * Legacy fixed salt used for backward-compatible decryption of pre-salt format values. + * Do not use for new encryptions. */ -function deriveKey(secret: string): Buffer { - // Use a fixed salt derived from the package name for key derivation - // This gives us stable key derivation without storing an extra salt - const packageSalt = scryptSync("groombook-auth-provider-config", "", SALT_LENGTH); - return scryptSync(secret, packageSalt, 32); +const LEGACY_PACKAGE_SALT = scryptSync("groombook-auth-provider-config", "", SALT_LENGTH); + +/** + * Derives a 32-byte key from BETTER_AUTH_SECRET using scrypt. + * Uses the provided salt (random per encryption for new values). + */ +function deriveKey(secret: string, salt: Buffer): Buffer { + return scryptSync(secret, salt, 32); } /** * Encrypts a plaintext string using AES-256-GCM. - * Returns a base64-encoded string in the format: iv:ciphertext:authTag + * Returns a base64-encoded string in the format: salt:iv:ciphertext:authTag */ export function encryptSecret(plaintext: string): string { const secret = process.env.BETTER_AUTH_SECRET; @@ -26,7 +29,8 @@ export function encryptSecret(plaintext: string): string { throw new Error("BETTER_AUTH_SECRET environment variable is required"); } - const key = deriveKey(secret); + const salt = randomBytes(SALT_LENGTH); + const key = deriveKey(secret, salt); const iv = randomBytes(IV_LENGTH); const cipher = createCipheriv(ALGORITHM, key, iv, { @@ -38,8 +42,9 @@ export function encryptSecret(plaintext: string): string { const authTag = cipher.getAuthTag(); - // Format: base64(iv):base64(ciphertext):base64(authTag) + // Format: base64(salt):base64(iv):base64(ciphertext):base64(authTag) return [ + salt.toString("base64"), iv.toString("base64"), ciphertext.toString("base64"), authTag.toString("base64"), @@ -48,7 +53,8 @@ export function encryptSecret(plaintext: string): string { /** * Decrypts a ciphertext string produced by encryptSecret. - * Expects the format: iv:ciphertext:authTag (all base64-encoded) + * Supports both new format (salt:iv:ciphertext:authTag) and legacy format (iv:ciphertext:authTag). + * All values are base64-encoded. */ export function decryptSecret(encrypted: string): string { const secret = process.env.BETTER_AUTH_SECRET; @@ -57,18 +63,30 @@ export function decryptSecret(encrypted: string): string { } const parts = encrypted.split(":"); - if (parts.length !== 3) { - throw new Error("Invalid encrypted value format: expected iv:ciphertext:authTag"); + if (parts.length !== 3 && parts.length !== 4) { + throw new Error("Invalid encrypted value format: expected salt:iv:ciphertext:authTag or iv:ciphertext:authTag"); } - const ivBase64 = parts[0]!; - const ciphertextBase64 = parts[1]!; - const authTagBase64 = parts[2]!; - const iv = Buffer.from(ivBase64, "base64"); - const ciphertext = Buffer.from(ciphertextBase64, "base64"); - const authTag = Buffer.from(authTagBase64, "base64"); + let salt: Buffer; + let iv: Buffer; + let ciphertext: Buffer; + let authTag: Buffer; - const key = deriveKey(secret); + if (parts.length === 4) { + // New format: salt:iv:ciphertext:authTag + salt = Buffer.from(parts[0]!, "base64"); + iv = Buffer.from(parts[1]!, "base64"); + ciphertext = Buffer.from(parts[2]!, "base64"); + authTag = Buffer.from(parts[3]!, "base64"); + } else { + // Legacy format: iv:ciphertext:authTag — use fixed package salt + salt = LEGACY_PACKAGE_SALT; + iv = Buffer.from(parts[0]!, "base64"); + ciphertext = Buffer.from(parts[1]!, "base64"); + authTag = Buffer.from(parts[2]!, "base64"); + } + + const key = deriveKey(secret, salt); const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH, From d9e6b09fe5fa82dc9ad07cd7543670441f092aa9 Mon Sep 17 00:00:00 2001 From: Paperclip Date: Sat, 4 Apr 2026 13:16:19 +0000 Subject: [PATCH 02/10] fix(api): use correct schema in POST /admin/auth-provider/test (GRO-454) Switch the test endpoint from putAuthProviderSchema.omit({ clientSecret }) (which requires providerId, displayName, clientId, scopes) to the minimal authProviderTestSchema (issuerUrl, internalBaseUrl?) that matches what the Settings.tsx frontend actually sends. Co-Authored-By: Paperclip --- apps/api/src/routes/authProvider.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/api/src/routes/authProvider.ts b/apps/api/src/routes/authProvider.ts index 4467afa..e53e909 100644 --- a/apps/api/src/routes/authProvider.ts +++ b/apps/api/src/routes/authProvider.ts @@ -19,6 +19,12 @@ const putAuthProviderSchema = z.object({ scopes: z.string().default("openid profile email"), }); +/** Minimal schema for the test endpoint — only issuer/internal URLs are needed for OIDC discovery. */ +const authProviderTestSchema = z.object({ + issuerUrl: z.string().url(), + internalBaseUrl: z.string().url().nullable().optional(), +}); + /** * GET /api/admin/auth-provider * Returns the current provider config with clientSecret redacted. @@ -131,7 +137,7 @@ let encryptedSecret: string; authProviderRouter.post( "/test", requireSuperUser(), - zValidator("json", putAuthProviderSchema.omit({ clientSecret: true })), + zValidator("json", authProviderTestSchema), async (c) => { const body = c.req.valid("json"); From 78a67583499a355c4d6a9e69783794a91079b07d Mon Sep 17 00:00:00 2001 From: Paperclip Date: Sat, 4 Apr 2026 21:25:32 +0000 Subject: [PATCH 03/10] fix(db): generate unique random salt per encryptSecret call (GRO-453) Use a 16-byte random salt per encryption instead of the fixed "groombook-auth-provider-config" salt. This prevents identical plaintexts from producing identical ciphertexts, closing the timing/anagram security gap identified in GRO-452. New format: salt:iv:ciphertext:authTag (all base64). Legacy format (iv:ciphertext:authTag) is still accepted for backward-compatible decryption of existing stored values. Co-Authored-By: Paperclip --- apps/api/src/__tests__/crypto.test.ts | 8 +++++--- packages/db/src/crypto.ts | 22 ++++++++-------------- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/apps/api/src/__tests__/crypto.test.ts b/apps/api/src/__tests__/crypto.test.ts index 765c327..2602264 100644 --- a/apps/api/src/__tests__/crypto.test.ts +++ b/apps/api/src/__tests__/crypto.test.ts @@ -61,8 +61,10 @@ describe("encryptSecret / decryptSecret", () => { }); it("throws when decrypting invalid format (wrong number of parts)", () => { - // 2 parts is invalid for both legacy (3) and new (4) format - const invalid = "not-enough-parts"; + const encrypted = encryptSecret("test"); + // Replace the last two parts with a single part to create a 2-part string + // This can't be parsed as either legacy (3 parts) or new (4 parts) format + const invalid = encrypted.replace(/:[^:]+$/, "").replace(/:[^:]+$/, ""); expect(() => decryptSecret(invalid)).toThrow( "Invalid encrypted value format: expected salt:iv:ciphertext:authTag or iv:ciphertext:authTag" @@ -92,4 +94,4 @@ describe("encryptSecret / decryptSecret", () => { expect(decrypted).toBe(plaintext); }); -}); \ No newline at end of file +}); diff --git a/packages/db/src/crypto.ts b/packages/db/src/crypto.ts index b335af4..541d5a3 100644 --- a/packages/db/src/crypto.ts +++ b/packages/db/src/crypto.ts @@ -5,15 +5,9 @@ const IV_LENGTH = 12; // 96-bit IV for GCM const AUTH_TAG_LENGTH = 16; // 128-bit auth tag const SALT_LENGTH = 16; -/** - * Legacy fixed salt used for backward-compatible decryption of pre-salt format values. - * Do not use for new encryptions. - */ -const LEGACY_PACKAGE_SALT = scryptSync("groombook-auth-provider-config", "", SALT_LENGTH); - /** * Derives a 32-byte key from BETTER_AUTH_SECRET using scrypt. - * Uses the provided salt (random per encryption for new values). + * A unique random salt is generated per encryptSecret() call and prepended to the output. */ function deriveKey(secret: string, salt: Buffer): Buffer { return scryptSync(secret, salt, 32); @@ -54,7 +48,6 @@ export function encryptSecret(plaintext: string): string { /** * Decrypts a ciphertext string produced by encryptSecret. * Supports both new format (salt:iv:ciphertext:authTag) and legacy format (iv:ciphertext:authTag). - * All values are base64-encoded. */ export function decryptSecret(encrypted: string): string { const secret = process.env.BETTER_AUTH_SECRET; @@ -63,9 +56,6 @@ export function decryptSecret(encrypted: string): string { } const parts = encrypted.split(":"); - if (parts.length !== 3 && parts.length !== 4) { - throw new Error("Invalid encrypted value format: expected salt:iv:ciphertext:authTag or iv:ciphertext:authTag"); - } let salt: Buffer; let iv: Buffer; @@ -78,12 +68,16 @@ export function decryptSecret(encrypted: string): string { iv = Buffer.from(parts[1]!, "base64"); ciphertext = Buffer.from(parts[2]!, "base64"); authTag = Buffer.from(parts[3]!, "base64"); - } else { + } else if (parts.length === 3) { // Legacy format: iv:ciphertext:authTag — use fixed package salt - salt = LEGACY_PACKAGE_SALT; + salt = scryptSync("groombook-auth-provider-config", "", SALT_LENGTH); iv = Buffer.from(parts[0]!, "base64"); ciphertext = Buffer.from(parts[1]!, "base64"); authTag = Buffer.from(parts[2]!, "base64"); + } else { + throw new Error( + "Invalid encrypted value format: expected salt:iv:ciphertext:authTag or iv:ciphertext:authTag" + ); } const key = deriveKey(secret, salt); @@ -97,4 +91,4 @@ export function decryptSecret(encrypted: string): string { plaintext = Buffer.concat([plaintext, decipher.final()]); return plaintext.toString("utf8"); -} \ No newline at end of file +} From ff216ea54c4ab43513e298097e30d0c54400498b Mon Sep 17 00:00:00 2001 From: "groombook-engineer[bot]" <269742240+groombook-engineer[bot]@users.noreply.github.com> Date: Sat, 4 Apr 2026 23:29:18 +0000 Subject: [PATCH 04/10] fix(api): remove duplicate authProviderRouter registration (#226) The authProviderRouter was registered twice at /admin/auth-provider in apps/api/src/index.ts. The second registration is a no-op but creates confusion. Remove the duplicate line. Co-authored-by: Paperclip --- apps/api/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 2d93fbd..e663986 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -167,7 +167,6 @@ api.route("/impersonation", impersonationRouter); api.route("/admin/settings", settingsRouter); api.route("/admin/auth-provider", authProviderRouter); api.route("/admin/seed", adminSeedRouter); -api.route("/admin/auth-provider", authProviderRouter); api.route("/search", searchRouter); const port = Number(process.env.PORT ?? 3000); From b090f8b810964e34ec38dba14e1408100fc4ce8f Mon Sep 17 00:00:00 2001 From: "groombook-engineer[bot]" <269742240+groombook-engineer[bot]@users.noreply.github.com> Date: Sun, 5 Apr 2026 08:55:07 +0000 Subject: [PATCH 05/10] fix(GRO-472): exclude OAuth callback from service worker caching (#228) The NetworkFirst route for /api/* was intercepting the OIDC callback (/api/auth/oauth2/callback/authentik?code=...), returning a cached index.html instead of forwarding to the API server. Added navigateFallbackDenylist regex to exclude the callback path from service worker navigation handling, allowing the callback request to reach the API server normally. Fixes GRO-472. Co-authored-by: Flea Flicker Co-authored-by: Paperclip --- apps/web/vite.config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 066b753..7beaaa5 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -40,6 +40,9 @@ export default defineConfig({ }, workbox: { globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"], + navigateFallbackDenylist: [ + /^\/api\/auth\/oauth2\/callback\//, + ], runtimeCaching: [ { urlPattern: /^http.*\/api\/.*/i, From 90ad46f0d5968f1d8ad73e91fc5113eae236a0e7 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Sun, 5 Apr 2026 11:14:17 +0000 Subject: [PATCH 06/10] fix(ci): rename base Jobs in promote-to-uat and promote-prod workflows (GRO-311) Both workflows now update base migration/seed Job names with short SHA extracted from the image tag, matching the dev CI cd job pattern. This prevents Flux immutable-field errors on consecutive UAT/prod promotions. Co-Authored-By: Paperclip --- .github/workflows/promote-prod.yml | 26 ++++++++++++++++++++++++-- .github/workflows/promote-to-uat.yml | 24 ++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/.github/workflows/promote-prod.yml b/.github/workflows/promote-prod.yml index 65cd94c..e890112 100644 --- a/.github/workflows/promote-prod.yml +++ b/.github/workflows/promote-prod.yml @@ -31,16 +31,38 @@ jobs: sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 sudo chmod +x /usr/local/bin/yq - - name: Update prod overlay image tags + - name: Update prod overlay image tags and base Job names env: TAG: ${{ inputs.tag }} run: | cd /tmp/infra PROD_KUST="apps/groombook/overlays/prod/kustomization.yaml" + + SHORT_SHA="${TAG##*-}" + export SHORT_SHA + export TAG + yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$PROD_KUST" yq -i '(.images[] | select(.name == "ghcr.io/groombook/web")).newTag = env(TAG)' "$PROD_KUST" yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$PROD_KUST" yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$PROD_KUST" + + # Update migrate Job name to include short SHA (immutable template fix) + MIGRATE_JOB="apps/groombook/base/migrate-job.yaml" + if [ -f "$MIGRATE_JOB" ]; then + yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB" + yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB" + yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$MIGRATE_JOB" + fi + + # Update seed Job name to include short SHA (immutable template fix) + SEED_JOB="apps/groombook/base/seed-job.yaml" + if [ -f "$SEED_JOB" ]; then + yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB" + yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB" + yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$SEED_JOB" + fi + git -C /tmp/infra diff --stat - name: Create PR on groombook/infra @@ -52,7 +74,7 @@ jobs: git config user.name "groombook-engineer[bot]" git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com" git checkout -b "release/promote-prod-${TAG}" - git add apps/groombook/overlays/prod/ + git add apps/groombook/overlays/prod/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml git commit -m "release: promote ${TAG} to production" git push -u origin "release/promote-prod-${TAG}" gh pr create \ diff --git a/.github/workflows/promote-to-uat.yml b/.github/workflows/promote-to-uat.yml index 587e749..c0ccff9 100644 --- a/.github/workflows/promote-to-uat.yml +++ b/.github/workflows/promote-to-uat.yml @@ -32,7 +32,7 @@ jobs: sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 sudo chmod +x /usr/local/bin/yq - - name: Update UAT overlay image tags + - name: Update UAT overlay image tags and base Job names env: TAG: ${{ inputs.image_tag }} run: | @@ -45,11 +45,31 @@ jobs: exit 1 fi + SHORT_SHA="${TAG##*-}" + export SHORT_SHA + export TAG + yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$UAT_KUST" yq -i '(.images[] | select(.name == "ghcr.io/groombook/web")).newTag = env(TAG)' "$UAT_KUST" yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$UAT_KUST" yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$UAT_KUST" + # Update migrate Job name to include short SHA (immutable template fix) + MIGRATE_JOB="apps/groombook/base/migrate-job.yaml" + if [ -f "$MIGRATE_JOB" ]; then + yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB" + yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB" + yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$MIGRATE_JOB" + fi + + # Update seed Job name to include short SHA (immutable template fix) + SEED_JOB="apps/groombook/base/seed-job.yaml" + if [ -f "$SEED_JOB" ]; then + yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB" + yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB" + yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$SEED_JOB" + fi + git -C /tmp/infra diff --stat - name: Create PR on groombook/infra @@ -61,7 +81,7 @@ jobs: git config user.name "groombook-engineer[bot]" git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com" git checkout -b "chore/update-uat-image-tags-${TAG}" - git add apps/groombook/overlays/uat/ + git add apps/groombook/overlays/uat/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml git commit -m "chore: promote ${TAG} to UAT" git push -u origin "chore/update-uat-image-tags-${TAG}" From 711981e6f34e38da20b38b1348f10b8491e9dc5a Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Sun, 5 Apr 2026 14:30:25 +0000 Subject: [PATCH 07/10] fix(api): auto-link staff to Better-Auth user via email on first SSO login (GRO-480) When a staff record exists with a matching email but no userId (e.g. seed data or admin UI-created records), resolveStaffMiddleware now auto-links it to the Better-Auth user record on first SSO login instead of returning 403. Safety: only links when userId IS NULL, never overwrites an existing link. Email matching is safe since it comes from the trusted SSO provider (Authentik). Staff emails are unique by schema. Co-Authored-By: Paperclip --- apps/api/src/middleware/rbac.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/api/src/middleware/rbac.ts b/apps/api/src/middleware/rbac.ts index d5e764e..9075ee9 100644 --- a/apps/api/src/middleware/rbac.ts +++ b/apps/api/src/middleware/rbac.ts @@ -1,5 +1,6 @@ import type { MiddlewareHandler } from "hono"; -import { eq, getDb, staff } from "@groombook/db"; +import { isNull } from "drizzle-orm"; +import { and, eq, getDb, staff } from "@groombook/db"; export type StaffRole = "groomer" | "receptionist" | "manager"; export type StaffRow = typeof staff.$inferSelect; @@ -89,6 +90,25 @@ export const resolveStaffMiddleware: MiddlewareHandler = async ( .from(staff) .where(eq(staff.oidcSub, jwt.sub)); if (!fallbackRow) { + // Auto-link: staff record exists with matching email but no userId — link it now + if (jwt.email) { + const [linkedStaff] = await db + .select() + .from(staff) + .where(and(eq(staff.email, jwt.email), isNull(staff.userId))); + if (linkedStaff) { + await db + .update(staff) + .set({ userId: jwt.sub }) + .where(eq(staff.id, linkedStaff.id)); + console.log( + `[rbac] Auto-linked staff ${linkedStaff.id} to Better-Auth user ${jwt.sub} via email ${jwt.email}` + ); + c.set("staff", linkedStaff); + await next(); + return; + } + } return c.json( { error: "Forbidden: no staff record found for authenticated user" }, 403 From e39924b236afde31e0d9577871050f645964c20b Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Sun, 5 Apr 2026 14:39:22 +0000 Subject: [PATCH 08/10] fix(api): import isNull from @groombook/db instead of drizzle-orm directly drizzle-orm is not a direct dependency of @groombook/api, causing TS2307 at typecheck time. Re-export isNull from @groombook/db and update the import in rbac.ts. Co-Authored-By: Paperclip --- apps/api/src/middleware/rbac.ts | 3 +-- packages/db/src/index.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/api/src/middleware/rbac.ts b/apps/api/src/middleware/rbac.ts index 9075ee9..1fab0cc 100644 --- a/apps/api/src/middleware/rbac.ts +++ b/apps/api/src/middleware/rbac.ts @@ -1,6 +1,5 @@ import type { MiddlewareHandler } from "hono"; -import { isNull } from "drizzle-orm"; -import { and, eq, getDb, staff } from "@groombook/db"; +import { and, eq, getDb, isNull, staff } from "@groombook/db"; export type StaffRole = "groomer" | "receptionist" | "manager"; export type StaffRow = typeof staff.$inferSelect; diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 9cd8c01..8b3b01f 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -4,7 +4,7 @@ import * as schema from "./schema.js"; export * from "./schema.js"; export { encryptSecret, decryptSecret } from "./crypto.js"; -export { and, asc, desc, eq, exists, gte, gt, ilike, inArray, lt, lte, ne, or, sql } from "drizzle-orm"; +export { and, asc, desc, eq, exists, gte, gt, ilike, inArray, isNull, lt, lte, ne, or, sql } from "drizzle-orm"; let _db: ReturnType | null = null; From 006c05ac777b817619f8db7312adff24402fbe07 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Sun, 5 Apr 2026 15:44:29 +0000 Subject: [PATCH 09/10] fix(ci): delete completed Jobs before Flux reconciles (GRO-481) Both promote-to-uat and promote-prod workflows now delete any existing completed Jobs with the same short SHA suffix before Flux reconciles. This prevents the immutable-podTemplate error that was blocking UAT at image tag a67e541: Job.batch "migrate-schema-xxx" is invalid: spec.template: field is immutable Also added missing failure notification step to promote-prod workflow. Co-Authored-By: Paperclip --- .github/workflows/promote-prod.yml | 24 +++++++++++++++++++++++- .github/workflows/promote-to-uat.yml | 10 ++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/.github/workflows/promote-prod.yml b/.github/workflows/promote-prod.yml index e890112..da8c9b0 100644 --- a/.github/workflows/promote-prod.yml +++ b/.github/workflows/promote-prod.yml @@ -82,4 +82,26 @@ jobs: --base main \ --head "release/promote-prod-${TAG}" \ --title "release: promote ${TAG} to production" \ - --body "Promote image tag ${TAG} to production after UAT sign-off. cc @cpfarhood" \ No newline at end of file + --body "Promote image tag ${TAG} to production after UAT sign-off. cc @cpfarhood" + + - name: Delete existing completed Jobs before Flux reconciles + env: + TAG: ${{ inputs.tag }} + run: | + SHORT_SHA="${TAG##*-}" + echo "Deleting completed Jobs with name suffix: $SHORT_SHA" + kubectl delete job "migrate-schema-${SHORT_SHA}" -n groombook --ignore-not-found + kubectl delete job "seed-test-data-${SHORT_SHA}" -n groombook --ignore-not-found + echo "Jobs deleted, Flux will reconcile with fresh objects" + + - name: Notify on failure + if: failure() + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: '## Production Promotion Failed\n\nThe `promote-prod` workflow failed. Check the workflow run logs for details.' + }); diff --git a/.github/workflows/promote-to-uat.yml b/.github/workflows/promote-to-uat.yml index c0ccff9..8fe3a37 100644 --- a/.github/workflows/promote-to-uat.yml +++ b/.github/workflows/promote-to-uat.yml @@ -95,6 +95,16 @@ jobs: --body "[GRO-429](/GRO/issues/GRO-429) — UAT promotion triggered by CTO") gh pr merge "$PR_URL" --merge + - name: Delete existing completed Jobs before Flux reconciles + env: + TAG: ${{ inputs.image_tag }} + run: | + SHORT_SHA="${TAG##*-}" + echo "Deleting completed Jobs with name suffix: $SHORT_SHA" + kubectl delete job "migrate-schema-${SHORT_SHA}" -n groombook-uat --ignore-not-found + kubectl delete job "seed-test-data-${SHORT_SHA}" -n groombook-uat --ignore-not-found + echo "Jobs deleted, Flux will reconcile with fresh objects" + - name: Notify on failure if: failure() uses: actions/github-script@v7 From 25ac34828f8f77fba8e132122254608ba6aac03e Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Sun, 5 Apr 2026 16:32:23 +0000 Subject: [PATCH 10/10] fix(ci): remove dead kubectl steps and misleading TTL fallback lines These steps always fail because the runner has no kubeconfig. Job names are already unique per deploy (include SHORT_SHA), and base manifests already set ttlSecondsAfterFinished: 120 for auto-cleanup. Co-Authored-By: Paperclip --- .github/workflows/promote-prod.yml | 12 ------------ .github/workflows/promote-to-uat.yml | 12 ------------ 2 files changed, 24 deletions(-) diff --git a/.github/workflows/promote-prod.yml b/.github/workflows/promote-prod.yml index da8c9b0..483e8cd 100644 --- a/.github/workflows/promote-prod.yml +++ b/.github/workflows/promote-prod.yml @@ -52,7 +52,6 @@ jobs: if [ -f "$MIGRATE_JOB" ]; then yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB" yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB" - yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$MIGRATE_JOB" fi # Update seed Job name to include short SHA (immutable template fix) @@ -60,7 +59,6 @@ jobs: if [ -f "$SEED_JOB" ]; then yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB" yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB" - yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$SEED_JOB" fi git -C /tmp/infra diff --stat @@ -84,16 +82,6 @@ jobs: --title "release: promote ${TAG} to production" \ --body "Promote image tag ${TAG} to production after UAT sign-off. cc @cpfarhood" - - name: Delete existing completed Jobs before Flux reconciles - env: - TAG: ${{ inputs.tag }} - run: | - SHORT_SHA="${TAG##*-}" - echo "Deleting completed Jobs with name suffix: $SHORT_SHA" - kubectl delete job "migrate-schema-${SHORT_SHA}" -n groombook --ignore-not-found - kubectl delete job "seed-test-data-${SHORT_SHA}" -n groombook --ignore-not-found - echo "Jobs deleted, Flux will reconcile with fresh objects" - - name: Notify on failure if: failure() uses: actions/github-script@v7 diff --git a/.github/workflows/promote-to-uat.yml b/.github/workflows/promote-to-uat.yml index 8fe3a37..a1a79d4 100644 --- a/.github/workflows/promote-to-uat.yml +++ b/.github/workflows/promote-to-uat.yml @@ -59,7 +59,6 @@ jobs: if [ -f "$MIGRATE_JOB" ]; then yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB" yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB" - yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$MIGRATE_JOB" fi # Update seed Job name to include short SHA (immutable template fix) @@ -67,7 +66,6 @@ jobs: if [ -f "$SEED_JOB" ]; then yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB" yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB" - yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$SEED_JOB" fi git -C /tmp/infra diff --stat @@ -95,16 +93,6 @@ jobs: --body "[GRO-429](/GRO/issues/GRO-429) — UAT promotion triggered by CTO") gh pr merge "$PR_URL" --merge - - name: Delete existing completed Jobs before Flux reconciles - env: - TAG: ${{ inputs.image_tag }} - run: | - SHORT_SHA="${TAG##*-}" - echo "Deleting completed Jobs with name suffix: $SHORT_SHA" - kubectl delete job "migrate-schema-${SHORT_SHA}" -n groombook-uat --ignore-not-found - kubectl delete job "seed-test-data-${SHORT_SHA}" -n groombook-uat --ignore-not-found - echo "Jobs deleted, Flux will reconcile with fresh objects" - - name: Notify on failure if: failure() uses: actions/github-script@v7