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
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:**
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)
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;