diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a0017c55..22d787e3 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -121,6 +121,53 @@ updates: - dependency-name: "@astrojs/cloudflare" update-types: ["version-update:semver-major"] + # Shared workspace packages under packages/. The glob matches nothing when the + # directory is absent, so products without packages/ get no entry in effect. + - package-ecosystem: bun + directories: + - /packages/* + schedule: + interval: weekly + day: monday + time: "09:00" + timezone: Europe/Berlin + open-pull-requests-limit: 4 + labels: ["dependencies"] + commit-message: + prefix: "chore(deps)" + include: scope + cooldown: + default-days: 7 + groups: + production-dependencies: + dependency-type: "production" + patterns: ["*"] + development-dependencies: + dependency-type: "development" + patterns: ["*"] + + - package-ecosystem: bun + directory: /apps/site + schedule: + interval: weekly + day: monday + time: "09:00" + timezone: Europe/Berlin + open-pull-requests-limit: 4 + labels: ["dependencies"] + commit-message: + prefix: "chore(deps)" + include: scope + cooldown: + default-days: 7 + groups: + production-dependencies: + dependency-type: "production" + patterns: ["*"] + development-dependencies: + dependency-type: "development" + patterns: ["*"] + - package-ecosystem: github-actions directory: / schedule: @@ -185,6 +232,26 @@ updates: - dependency-name: "node" update-types: ["version-update:semver-major"] + - package-ecosystem: docker + directory: /apps/site + schedule: + interval: weekly + day: monday + time: "09:00" + timezone: Europe/Berlin + open-pull-requests-limit: 4 + labels: ["dependencies", "docker"] + commit-message: + prefix: "chore(docker)" + groups: + docker-images: + patterns: ["*"] + ignore: + - dependency-name: "oven/bun" + update-types: ["version-update:semver-major"] + - dependency-name: "nginx" + update-types: ["version-update:semver-major"] + - package-ecosystem: docker directory: /infra/compose/compose schedule: diff --git a/.github/desired-repo-settings.json b/.github/desired-repo-settings.json index fa8d2c52..62a6393d 100644 --- a/.github/desired-repo-settings.json +++ b/.github/desired-repo-settings.json @@ -9,7 +9,8 @@ "delete_branch_on_merge": true, "allow_squash_merge": true, "allow_merge_commit": false, - "allow_rebase_merge": false + "allow_rebase_merge": false, + "squash_merge_commit_title": "PR_TITLE" }, "branch_protection_main": { "required_status_check_contexts": [ @@ -23,7 +24,8 @@ "typecheck + lint + test", "validate", "yamllint (compose + workflows)", - "agent verification contract" + "agent verification contract", + "pull request title (conventional commit)" ], "required_approving_review_count": 0, "dismiss_stale_reviews": true, diff --git a/.github/workflows/agent-verification.yml b/.github/workflows/agent-verification.yml index cf0c77bb..70d9e4d3 100644 --- a/.github/workflows/agent-verification.yml +++ b/.github/workflows/agent-verification.yml @@ -1,3 +1,7 @@ +# Known gap: this workflow does not run the API, UI or browser lanes, so it never +# compares observed cases with tools/agent/inventories/*.json. agent:check only +# validates that the committed baselines parse and are non-empty. See +# tools/agent/README.md "CI enforcement". name: agent-verification on: pull_request: @@ -24,7 +28,7 @@ permissions: jobs: verify: name: agent verification contract - runs-on: ubuntu-24.04 + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 40 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/apps-api-acl-drift.yml b/.github/workflows/apps-api-acl-drift.yml index 8c0e3ffe..24e627b4 100644 --- a/.github/workflows/apps-api-acl-drift.yml +++ b/.github/workflows/apps-api-acl-drift.yml @@ -31,7 +31,7 @@ jobs: defaults: run: working-directory: apps/api - runs-on: ubuntu-24.04 + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 5 steps: diff --git a/.github/workflows/apps-api-ci.yml b/.github/workflows/apps-api-ci.yml index 8fa48377..a84da9d5 100644 --- a/.github/workflows/apps-api-ci.yml +++ b/.github/workflows/apps-api-ci.yml @@ -23,7 +23,7 @@ jobs: defaults: run: working-directory: apps/api - runs-on: ubuntu-24.04 + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 10 services: diff --git a/.github/workflows/apps-api-openapi-drift.yml b/.github/workflows/apps-api-openapi-drift.yml index 2016f2bf..748c617a 100644 --- a/.github/workflows/apps-api-openapi-drift.yml +++ b/.github/workflows/apps-api-openapi-drift.yml @@ -20,7 +20,7 @@ permissions: jobs: drift: name: apps/ui OpenAPI schema is fresh - runs-on: ubuntu-24.04 + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 8 # The job always runs (so the required-status-check rule on `main` is diff --git a/.github/workflows/apps-api-release.yml b/.github/workflows/apps-api-release.yml index 675b280f..1b1df5d6 100644 --- a/.github/workflows/apps-api-release.yml +++ b/.github/workflows/apps-api-release.yml @@ -29,7 +29,7 @@ jobs: defaults: run: working-directory: apps/api - runs-on: ubuntu-24.04 + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 20 steps: - name: Checkout diff --git a/.github/workflows/apps-api-security-deps.yml b/.github/workflows/apps-api-security-deps.yml index bb1879bc..53b427e2 100644 --- a/.github/workflows/apps-api-security-deps.yml +++ b/.github/workflows/apps-api-security-deps.yml @@ -25,7 +25,7 @@ jobs: defaults: run: working-directory: apps/api - runs-on: ubuntu-24.04 + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 10 env: @@ -38,6 +38,9 @@ jobs: - name: Detect relevant changes uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: filter + # An app that was removed from the checkout has no lockfile: skip its scan + # (every later step gates on this filter's output). + if: hashFiles('apps/api/bun.lock') != '' with: filters: | code: diff --git a/.github/workflows/apps-api-security-sast.yml b/.github/workflows/apps-api-security-sast.yml index 63134db5..86c1ed96 100644 --- a/.github/workflows/apps-api-security-sast.yml +++ b/.github/workflows/apps-api-security-sast.yml @@ -26,7 +26,7 @@ jobs: defaults: run: working-directory: apps/api - runs-on: ubuntu-24.04 + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 15 container: image: semgrep/semgrep:1.142.0@sha256:03402a5040a88a570dec58375ef1a19fa777dd61575afdc7d5527ddf308dd765 diff --git a/.github/workflows/apps-api-security-secrets.yml b/.github/workflows/apps-api-security-secrets.yml index c0e029a8..d10f3495 100644 --- a/.github/workflows/apps-api-security-secrets.yml +++ b/.github/workflows/apps-api-security-secrets.yml @@ -26,7 +26,7 @@ jobs: defaults: run: working-directory: apps/api - runs-on: ubuntu-24.04 + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 10 env: diff --git a/.github/workflows/apps-api-security-spec.yml b/.github/workflows/apps-api-security-spec.yml index 68ba0af1..da5413d3 100644 --- a/.github/workflows/apps-api-security-spec.yml +++ b/.github/workflows/apps-api-security-spec.yml @@ -39,7 +39,7 @@ jobs: defaults: run: working-directory: apps/api - runs-on: ubuntu-24.04 + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 15 services: diff --git a/.github/workflows/apps-docs-linkcheck.yml b/.github/workflows/apps-docs-linkcheck.yml index 0ab46fb5..d49caf2e 100644 --- a/.github/workflows/apps-docs-linkcheck.yml +++ b/.github/workflows/apps-docs-linkcheck.yml @@ -60,7 +60,7 @@ jobs: defaults: run: working-directory: apps/docs - runs-on: ubuntu-24.04 + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/apps-docs-security-deps.yml b/.github/workflows/apps-docs-security-deps.yml index ffab84e4..470facb8 100644 --- a/.github/workflows/apps-docs-security-deps.yml +++ b/.github/workflows/apps-docs-security-deps.yml @@ -25,7 +25,7 @@ jobs: defaults: run: working-directory: apps/docs - runs-on: ubuntu-24.04 + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 10 env: @@ -38,6 +38,9 @@ jobs: - name: Detect relevant changes uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: filter + # An app that was removed from the checkout has no lockfile: skip its scan + # (every later step gates on this filter's output). + if: hashFiles('apps/docs/bun.lock') != '' with: filters: | code: diff --git a/.github/workflows/apps-docs-security-secrets.yml b/.github/workflows/apps-docs-security-secrets.yml index cd550747..1a9527b5 100644 --- a/.github/workflows/apps-docs-security-secrets.yml +++ b/.github/workflows/apps-docs-security-secrets.yml @@ -26,7 +26,7 @@ jobs: defaults: run: working-directory: apps/docs - runs-on: ubuntu-24.04 + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 10 env: diff --git a/.github/workflows/apps-site-release.yml b/.github/workflows/apps-site-release.yml new file mode 100644 index 00000000..d129e4d7 --- /dev/null +++ b/.github/workflows/apps-site-release.yml @@ -0,0 +1,88 @@ +name: release + +# Builds and publishes the public site's serving image to GitHub Container +# Registry on every push to main (tags: latest + sha-<7>), for the Compose/WUD +# path. Kubernetes never uses these tags: production-release.yml builds the site +# as a candidate and pins its digest together with the API, migrations and UI. +# The build-time public configuration comes from +# infra/k3s/overlays/prod/site-build.env, so a configuration change rebuilds the +# image. + +on: + push: + branches: [main] + paths: + - "apps/site/**" + - "infra/k3s/overlays/prod/site-build.env" + - ".github/workflows/apps-site-release.yml" + +concurrency: + group: apps-site-release-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + packages: write + +jobs: + release: + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Load production site configuration + id: config + run: | + set -a + . infra/k3s/overlays/prod/site-build.env + set +a + { + echo "site_url=$SITE_URL" + echo "app_url=$APP_URL" + echo "contact_email=$CONTACT_EMAIL" + echo "analytics_script_url=$ANALYTICS_SCRIPT_URL" + echo "analytics_website_id=$ANALYTICS_WEBSITE_ID" + } >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + + - name: Log in to GHCR + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Compute image metadata + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}-site + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=sha,prefix=sha-,format=short + labels: | + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.title=BoringStack site + org.opencontainers.image.description=Static marketing site served by nginx + + - name: Build and push + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: apps/site + file: apps/site/Dockerfile + target: production + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + SITE_URL=${{ steps.config.outputs.site_url }} + APP_URL=${{ steps.config.outputs.app_url }} + CONTACT_EMAIL=${{ steps.config.outputs.contact_email }} + ANALYTICS_SCRIPT_URL=${{ steps.config.outputs.analytics_script_url }} + ANALYTICS_WEBSITE_ID=${{ steps.config.outputs.analytics_website_id }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/apps-site-security-deps.yml b/.github/workflows/apps-site-security-deps.yml new file mode 100644 index 00000000..35360b46 --- /dev/null +++ b/.github/workflows/apps-site-security-deps.yml @@ -0,0 +1,97 @@ +name: security-deps + +on: + push: + branches: [main] + paths: + - "apps/site/**" + - ".github/workflows/apps-site-security-deps.yml" + pull_request: + branches: [main] + schedule: + - cron: "29 6 * * 1" + +concurrency: + group: apps-site-security-deps-security-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + security-events: write + +jobs: + scan: + name: dep vuln scan (osv + audit) + defaults: + run: + working-directory: apps/site + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} + timeout-minutes: 10 + + env: + OSV_SCANNER_VERSION: "2.3.8" + OSV_SCANNER_SHA256: "bc98e15319ed0d515e3f9235287ba53cdc5535d576d24fd573978ecfe9ab92dc" + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Detect relevant changes + uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 + id: filter + with: + filters: | + code: + - 'apps/site/**' + - '.github/workflows/apps-site-security-deps.yml' + + - name: Install osv-scanner CLI + if: steps.filter.outputs.code == 'true' + run: | + set -euo pipefail + curl -sSL -o /tmp/osv-scanner \ + "https://github.com/google/osv-scanner/releases/download/v${OSV_SCANNER_VERSION}/osv-scanner_linux_amd64" + echo "${OSV_SCANNER_SHA256} /tmp/osv-scanner" | sha256sum -c - + chmod +x /tmp/osv-scanner + sudo mv /tmp/osv-scanner /usr/local/bin/ + osv-scanner --version + + - name: Run osv-scanner + if: steps.filter.outputs.code == 'true' + run: | + set +e + osv-scanner \ + --config=osv-scanner.toml \ + --lockfile=bun.lock \ + --format=sarif \ + --output-file=osv-results.sarif + echo "OSV_EXIT=$?" >> "$GITHUB_ENV" + set -e + + - name: Upload osv SARIF + if: always() + uses: github/codeql-action/upload-sarif@7c1e4cf0b20d7c1872b26569c00ba908797a59bf # v4 + with: + sarif_file: osv-results.sarif + category: osv-scanner-site + continue-on-error: true + + - name: Fail on osv findings + if: steps.filter.outputs.code == 'true' + run: | + # The SARIF is the source of truth: only findings not documented in + # osv-scanner.toml fail the build. + if [ ! -f osv-results.sarif ]; then + echo "osv-results.sarif missing: osv-scanner failed to write output." + exit 1 + fi + + UNFILTERED=$(jq -r '[.runs[].results[]?] | length' osv-results.sarif) + echo "Unfiltered osv-scanner findings: ${UNFILTERED}" + + if [ "${UNFILTERED}" != "0" ]; then + echo "osv-scanner reported ${UNFILTERED} unfiltered vulnerabilities. See SARIF in the Security tab." + exit 1 + fi + + # Dependency audit runs through osv-scanner above, with the same allowlist. + # The site is a static build with no runtime code, so osv alone is the gate. diff --git a/.github/workflows/apps-site-security-secrets.yml b/.github/workflows/apps-site-security-secrets.yml new file mode 100644 index 00000000..44800838 --- /dev/null +++ b/.github/workflows/apps-site-security-secrets.yml @@ -0,0 +1,81 @@ +name: security-secrets + +on: + push: + branches: [main] + paths: + - "apps/site/**" + - "infra/k3s/overlays/prod/site-build.env" + - ".github/workflows/apps-site-security-secrets.yml" + pull_request: + branches: [main] + schedule: + - cron: "19 6 * * 1" + +concurrency: + group: apps-site-security-secrets-security-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + security-events: write + pull-requests: read + +jobs: + gitleaks: + name: gitleaks secret scan + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} + timeout-minutes: 10 + + env: + GITLEAKS_VERSION: "8.30.1" + GITLEAKS_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Full history so gitleaks can scan every commit. + fetch-depth: 0 + + - name: Detect relevant changes + uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 + id: filter + with: + filters: | + code: + - 'apps/site/**' + - 'infra/k3s/overlays/prod/site-build.env' + - '.github/workflows/apps-site-security-secrets.yml' + + - name: Install gitleaks CLI + if: steps.filter.outputs.code == 'true' + run: | + set -euo pipefail + TARBALL="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + curl -sSL -o "/tmp/${TARBALL}" \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/${TARBALL}" + echo "${GITLEAKS_SHA256} /tmp/${TARBALL}" | sha256sum -c - + tar -xzf "/tmp/${TARBALL}" -C /tmp + sudo mv /tmp/gitleaks /usr/local/bin/ + gitleaks version + + - name: Scan + if: steps.filter.outputs.code == 'true' + run: | + gitleaks detect \ + --source . \ + --config .gitleaks.toml \ + --no-banner \ + --redact \ + --verbose \ + --report-format=sarif \ + --report-path=gitleaks-results.sarif \ + --exit-code=1 + + - name: Upload SARIF + if: always() + uses: github/codeql-action/upload-sarif@7c1e4cf0b20d7c1872b26569c00ba908797a59bf # v4 + with: + sarif_file: gitleaks-results.sarif + category: gitleaks-site + continue-on-error: true diff --git a/.github/workflows/apps-site-validate.yml b/.github/workflows/apps-site-validate.yml new file mode 100644 index 00000000..082e7866 --- /dev/null +++ b/.github/workflows/apps-site-validate.yml @@ -0,0 +1,96 @@ +name: validate + +on: + pull_request: + branches: [main] + push: + branches: [main] + paths: + - "apps/site/**" + - "infra/k3s/overlays/prod/site-build.env" + - ".github/workflows/apps-site-validate.yml" + +concurrency: + group: apps-site-validate-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + validate: + defaults: + run: + working-directory: apps/site + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Detect relevant changes + uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 + id: filter + with: + filters: | + code: + - 'apps/site/**' + - 'infra/k3s/overlays/prod/site-build.env' + - '.github/workflows/apps-site-validate.yml' + + - name: Set up Bun + if: steps.filter.outputs.code == 'true' + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.2 + + - name: Cache bun install + if: steps.filter.outputs.code == 'true' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.bun/install/cache + key: bun-${{ runner.os }}-${{ hashFiles('apps/site/bun.lock') }} + restore-keys: | + bun-${{ runner.os }}- + + - name: Install + if: steps.filter.outputs.code == 'true' + run: bun install --frozen-lockfile + + # Format, types, unit tests, production-config build, rendered nginx config, + # and the output checks (canonical URLs, sitemap, CSP compatibility). + - name: Validate + if: steps.filter.outputs.code == 'true' + run: bun run validate + + - name: Set up Docker Buildx + if: steps.filter.outputs.code == 'true' + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + + # Build the serving image with the same public configuration as release. + # Running it read-only proves the container needs no writable root filesystem. + - name: Build serving image and smoke-test it read-only + if: steps.filter.outputs.code == 'true' + working-directory: apps/site + run: | + set -euo pipefail + set -a + . ../../infra/k3s/overlays/prod/site-build.env + set +a + docker build \ + --build-arg SITE_URL="$SITE_URL" \ + --build-arg APP_URL="$APP_URL" \ + --build-arg CONTACT_EMAIL="$CONTACT_EMAIL" \ + --build-arg ANALYTICS_SCRIPT_URL="$ANALYTICS_SCRIPT_URL" \ + --build-arg ANALYTICS_WEBSITE_ID="$ANALYTICS_WEBSITE_ID" \ + -t boringstack-site:ci . + docker run -d --name site-smoke --read-only --tmpfs /tmp:rw,mode=1777 \ + -p 8080:8080 boringstack-site:ci + for _attempt in $(seq 1 20); do + if curl --fail --silent http://127.0.0.1:8080/healthz > /dev/null; then break; fi + sleep 1 + done + curl --fail --silent http://127.0.0.1:8080/ > /dev/null + curl --fail --silent http://127.0.0.1:8080/sitemap-index.xml > /dev/null + test "$(curl --silent --output /dev/null --write-out '%{http_code}' http://127.0.0.1:8080/does-not-exist)" = "404" + docker rm -f site-smoke > /dev/null diff --git a/.github/workflows/apps-ui-bundle-diff.yml b/.github/workflows/apps-ui-bundle-diff.yml index c581ea87..c03efd5a 100644 --- a/.github/workflows/apps-ui-bundle-diff.yml +++ b/.github/workflows/apps-ui-bundle-diff.yml @@ -32,7 +32,7 @@ jobs: # straight bun keeps the toolchain consistent with local dev. size-diff: if: github.actor != 'dependabot[bot]' - runs-on: ubuntu-24.04 + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 8 steps: - name: Checkout diff --git a/.github/workflows/apps-ui-release.yml b/.github/workflows/apps-ui-release.yml index 34458039..369e6fb8 100644 --- a/.github/workflows/apps-ui-release.yml +++ b/.github/workflows/apps-ui-release.yml @@ -34,7 +34,7 @@ jobs: defaults: run: working-directory: apps/ui - runs-on: ubuntu-24.04 + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 20 steps: - name: Checkout diff --git a/.github/workflows/apps-ui-security-deps.yml b/.github/workflows/apps-ui-security-deps.yml index de786233..c02bbf7c 100644 --- a/.github/workflows/apps-ui-security-deps.yml +++ b/.github/workflows/apps-ui-security-deps.yml @@ -25,7 +25,7 @@ jobs: defaults: run: working-directory: apps/ui - runs-on: ubuntu-24.04 + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 10 env: @@ -38,6 +38,9 @@ jobs: - name: Detect relevant changes uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: filter + # An app that was removed from the checkout has no lockfile: skip its scan + # (every later step gates on this filter's output). + if: hashFiles('apps/ui/bun.lock') != '' with: filters: | code: diff --git a/.github/workflows/apps-ui-security-sast.yml b/.github/workflows/apps-ui-security-sast.yml index 1177b968..28e02512 100644 --- a/.github/workflows/apps-ui-security-sast.yml +++ b/.github/workflows/apps-ui-security-sast.yml @@ -26,7 +26,7 @@ jobs: defaults: run: working-directory: apps/ui - runs-on: ubuntu-24.04 + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 15 container: image: semgrep/semgrep:1.142.0@sha256:03402a5040a88a570dec58375ef1a19fa777dd61575afdc7d5527ddf308dd765 diff --git a/.github/workflows/apps-ui-security-secrets.yml b/.github/workflows/apps-ui-security-secrets.yml index 2c4ad32d..ca122fff 100644 --- a/.github/workflows/apps-ui-security-secrets.yml +++ b/.github/workflows/apps-ui-security-secrets.yml @@ -26,7 +26,7 @@ jobs: defaults: run: working-directory: apps/ui - runs-on: ubuntu-24.04 + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 10 env: diff --git a/.github/workflows/apps-ui-validate.yml b/.github/workflows/apps-ui-validate.yml index 6be8411f..98c888f5 100644 --- a/.github/workflows/apps-ui-validate.yml +++ b/.github/workflows/apps-ui-validate.yml @@ -7,6 +7,7 @@ on: branches: [main] paths: - "apps/ui/**" + - "packages/**" - ".github/workflows/apps-ui-validate.yml" concurrency: @@ -21,11 +22,14 @@ jobs: defaults: run: working-directory: apps/ui - runs-on: ubuntu-24.04 + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Full history: the budget gate diffs .size-limit.json against the merge-base. + fetch-depth: 0 - name: Detect relevant changes uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 @@ -34,6 +38,7 @@ jobs: filters: | code: - 'apps/ui/**' + - 'packages/**' - '.github/workflows/apps-ui-validate.yml' - name: Set up Bun @@ -62,6 +67,10 @@ jobs: if: steps.filter.outputs.code == 'true' run: bun run check + - name: Shared packages validate + if: steps.filter.outputs.code == 'true' + run: bash -c 'source ../../scripts/stack-lib.sh && validate_packages' + - name: Unit + integration tests if: steps.filter.outputs.code == 'true' run: bun run test:ci @@ -86,6 +95,10 @@ jobs: if: steps.filter.outputs.code == 'true' run: bun run size:check + - name: Bundle budget reasons + if: steps.filter.outputs.code == 'true' + run: bun run check:size-budget + - name: Modulepreload size coverage if: steps.filter.outputs.code == 'true' run: bun run size:check:modulepreload diff --git a/.github/workflows/infra-bootstrap-security-deps.yml b/.github/workflows/infra-bootstrap-security-deps.yml index d6809463..1c3baacb 100644 --- a/.github/workflows/infra-bootstrap-security-deps.yml +++ b/.github/workflows/infra-bootstrap-security-deps.yml @@ -27,7 +27,7 @@ jobs: defaults: run: working-directory: infra/bootstrap - runs-on: ubuntu-24.04 + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 10 steps: diff --git a/.github/workflows/infra-bootstrap-security-secrets.yml b/.github/workflows/infra-bootstrap-security-secrets.yml index 0eeeb783..9ea976d6 100644 --- a/.github/workflows/infra-bootstrap-security-secrets.yml +++ b/.github/workflows/infra-bootstrap-security-secrets.yml @@ -28,7 +28,7 @@ jobs: defaults: run: working-directory: infra/bootstrap - runs-on: ubuntu-24.04 + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 10 env: diff --git a/.github/workflows/infra-bootstrap-validate.yml b/.github/workflows/infra-bootstrap-validate.yml index b85a22ad..036055cd 100644 --- a/.github/workflows/infra-bootstrap-validate.yml +++ b/.github/workflows/infra-bootstrap-validate.yml @@ -19,7 +19,7 @@ jobs: defaults: run: working-directory: infra/bootstrap - runs-on: ubuntu-24.04 + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/infra-compose-full-stack-smoke.yml b/.github/workflows/infra-compose-full-stack-smoke.yml index ad5ffc4b..0f8257d4 100644 --- a/.github/workflows/infra-compose-full-stack-smoke.yml +++ b/.github/workflows/infra-compose-full-stack-smoke.yml @@ -39,7 +39,7 @@ permissions: jobs: smoke: name: register → login → /me through the full stack - runs-on: ubuntu-24.04 + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} # 8 min upper bound: with Playwright moved out, the curl-only path # completes in ~3–4 min on a warm runner. A short ceiling means a # stuck step fails fast instead of running for 20 min like the old diff --git a/.github/workflows/infra-compose-playwright-e2e.yml b/.github/workflows/infra-compose-playwright-e2e.yml index 14b62d9d..6480a19d 100644 --- a/.github/workflows/infra-compose-playwright-e2e.yml +++ b/.github/workflows/infra-compose-playwright-e2e.yml @@ -39,7 +39,7 @@ permissions: jobs: playwright: name: UI Playwright E2E (browser-based smoke) - runs-on: ubuntu-24.04 + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} # 15 min upper bound: Playwright's own inner timeout is 12 min; the # buffer covers compose build + image pulls + browser install. A # genuine hang surfaces within minutes of the inner timeout rather diff --git a/.github/workflows/infra-compose-security-secrets.yml b/.github/workflows/infra-compose-security-secrets.yml index 41e3432d..956085aa 100644 --- a/.github/workflows/infra-compose-security-secrets.yml +++ b/.github/workflows/infra-compose-security-secrets.yml @@ -26,7 +26,7 @@ jobs: defaults: run: working-directory: infra/compose - runs-on: ubuntu-24.04 + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 10 env: diff --git a/.github/workflows/infra-compose-validate-compose.yml b/.github/workflows/infra-compose-validate-compose.yml index 94d61c7a..587cb103 100644 --- a/.github/workflows/infra-compose-validate-compose.yml +++ b/.github/workflows/infra-compose-validate-compose.yml @@ -38,7 +38,7 @@ permissions: jobs: compose-config: name: docker compose config (all overlay combinations) - runs-on: ubuntu-24.04 + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 5 # Build contexts point at monorepo apps (`../../../apps/api` and @@ -141,6 +141,31 @@ jobs: # so CI and local guardrails cannot drift. run: ../scripts/validate-guardrails.sh prod-image-tags working-directory: infra/compose/compose + - name: Validate — dev + site + if: steps.filter.outputs.code == 'true' + working-directory: infra/compose/compose + run: | + docker compose \ + -f docker-compose.yml \ + -f docker-compose.development-labels.yml \ + -f docker-compose.site.yml \ + --profile dev config --quiet + + - name: Validate — prod + site (+ prod labels) + if: steps.filter.outputs.code == 'true' + working-directory: infra/compose/compose + env: + PUBLIC_SITE_HOST: example.com + PUBLIC_UI_HOST: app.example.com + SITE_CONTACT_EMAIL: hello@example.com + SITE_IMAGE_TAG: v0.1.0 + run: | + docker compose \ + -f docker-compose.yml \ + -f docker-compose.production-labels.yml \ + -f docker-compose.site.yml \ + --profile prod config --quiet + - name: Validate — dev + observability if: steps.filter.outputs.code == 'true' working-directory: infra/compose/compose @@ -203,6 +228,7 @@ jobs: -f docker-compose.glitchtip.yml \ -f docker-compose.bullmq.yml \ -f docker-compose.wud.yml \ + -f docker-compose.site.yml \ --profile dev \ --profile observability \ --profile glitchtip-dev \ @@ -212,7 +238,7 @@ jobs: shellcheck: name: shellcheck (scripts/) - runs-on: ubuntu-24.04 + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 2 steps: - name: Checkout @@ -433,7 +459,7 @@ jobs: prod-image-build: name: build prod images (sanity) - runs-on: ubuntu-24.04 + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 15 # Cheaper than booting the prod profile end-to-end, but still # exercises the actual prod Dockerfiles. Catches broken @@ -489,7 +515,7 @@ jobs: yamllint: name: yamllint (compose + workflows) - runs-on: ubuntu-24.04 + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 2 env: # Single source for the yamllint pin: the local pre-push gate diff --git a/.github/workflows/infra-k3s-validate.yml b/.github/workflows/infra-k3s-validate.yml index dce1d3a0..9f40afe3 100644 --- a/.github/workflows/infra-k3s-validate.yml +++ b/.github/workflows/infra-k3s-validate.yml @@ -16,7 +16,7 @@ concurrency: jobs: validate: name: kustomize build - runs-on: ubuntu-24.04 + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -28,12 +28,22 @@ jobs: filters: | code: - 'infra/k3s/**' + - 'scripts/release/**' + - 'apps/api/Dockerfile.prod' + - 'apps/api/drizzle/**' - '.github/workflows/infra-k3s-validate.yml' + - '.github/workflows/production-release.yml' - name: Install kubectl (bundles kustomize) if: steps.filter.outputs.code == 'true' uses: azure/setup-kubectl@829323503d1be3d00ca8346e5391ca0b07a9ab0d # v5.1.0 + - name: Set up Bun + if: steps.filter.outputs.code == 'true' + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.2 + # `kubectl kustomize` renders base + prod overlay offline. Catches the # realistic breakage: bad resource refs, malformed YAML, broken patches, # and the swappable secrets component wiring. CRDs (CNPG, Traefik, @@ -59,3 +69,10 @@ jobs: printf 'JWT_SECRET=x\nVALKEY_PASSWORD=x\n' > infra/k3s/overlays/prod/secrets/plain/secret.env trap 'rm -f infra/k3s/overlays/prod/secrets/plain/secret.env' EXIT kubectl kustomize infra/k3s/overlays/prod/secrets/plain > /dev/null + + # Release invariants on the real render: digest-pinned images, no + # image-updater, a migrate-only Job, a read-only API, and the release + # script's pin contract. See infra/k3s/README.md. + - name: Test production release invariants + if: steps.filter.outputs.code == 'true' + run: bun test infra/k3s/tests scripts/release diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 00000000..b44a5521 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,30 @@ +name: pr-title + +# Squash merges make the PR title the commit subject on main. The commit-msg +# hook only sees local commits, so the title gets the same check here. + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +concurrency: + group: pr-title-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + title: + name: pull request title (conventional commit) + runs-on: ${{ !github.event.pull_request.head.repo.fork && vars.CI_RUNNER || 'ubuntu-24.04' }} + timeout-minutes: 2 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: scripts/ci + - name: Check the title + env: + TITLE: ${{ github.event.pull_request.title }} + run: bash scripts/ci/check-commit-title.sh "$TITLE" diff --git a/.github/workflows/production-release.yml b/.github/workflows/production-release.yml new file mode 100644 index 00000000..f3dd9738 --- /dev/null +++ b/.github/workflows/production-release.yml @@ -0,0 +1,163 @@ +name: Coordinated production release + +# The only workflow that publishes images for the Kubernetes (Argo) target. +# It builds the API, migrations, UI and public site for one commit as +# candidates. Only when every build succeeds does one commit pin all four digests in +# infra/k3s/overlays/prod/kustomization.yaml. Argo then syncs that single set, +# so the UI can never run ahead of the API it needs. +# Compose/WUD keeps its own publishers (apps-api-release.yml, apps-ui-release.yml). +# They push :latest and sha-* tags that Kubernetes never references. +# See infra/k3s/README.md. + +on: + push: + branches: [main] + paths: + - "apps/**" + - "packages/**" + - "scripts/release/**" + - ".github/workflows/**" + - "infra/k3s/**" + - "!infra/k3s/overlays/prod/kustomization.yaml" + +# Serialize releases. A stale source commit fails before promotion. +concurrency: + group: production-release + cancel-in-progress: false + +permissions: + contents: read + actions: read + +jobs: + release: + # Do not compete with PR checks for self-hosted runners. + runs-on: ubuntu-24.04 + timeout-minutes: 90 + permissions: + contents: write + actions: read + packages: write + steps: + - name: Require coordinated deployment activation + env: + RELEASE_MODE: ${{ vars.PRODUCTION_RELEASE_MODE }} + run: | + if [ "$RELEASE_MODE" != coordinated ]; then + echo '::error::Remove the argocd-image-updater annotations from the live Argo Application, then set PRODUCTION_RELEASE_MODE=coordinated. See infra/k3s/README.md.' + exit 1 + fi + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.2 + - name: Wait for this commit's applicable CI checks + env: + GH_TOKEN: ${{ github.token }} + run: bun scripts/release/release.mjs gate + - name: Reject superseded release + run: | + git fetch origin main + test "$(git rev-parse origin/main)" = "$GITHUB_SHA" + - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + # Candidate tags only. Nothing moves `latest` or `sha-*`, and nothing is + # promoted until all four builds below have succeeded. + - name: Build API candidate + id: api + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: apps/api + file: apps/api/Dockerfile.prod + push: true + tags: ghcr.io/${{ github.repository }}-api:candidate-${{ github.sha }} + labels: org.opencontainers.image.revision=${{ github.sha }} + cache-from: type=gha,scope=release-api + cache-to: type=gha,scope=release-api,mode=max + - name: Build migrations candidate + id: migrations + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: apps/api + file: apps/api/Dockerfile.prod + target: migrations + push: true + tags: ghcr.io/${{ github.repository }}-migrations:candidate-${{ github.sha }} + labels: org.opencontainers.image.revision=${{ github.sha }} + cache-from: type=gha,scope=release-migrations + cache-to: type=gha,scope=release-migrations,mode=max + - name: Build UI candidate + id: ui + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: apps/ui + file: apps/ui/Dockerfile.prod + push: true + tags: ghcr.io/${{ github.repository }}-ui:candidate-${{ github.sha }} + # Same-origin API by default. Public URL, Sentry and VAPID values come + # from repository variables, so no product domain is hard-coded here. + build-args: | + VITE_API_URL= + VITE_PUBLIC_URL=${{ vars.VITE_PUBLIC_URL }} + VITE_SENTRY_DSN=${{ vars.VITE_SENTRY_DSN }} + VITE_VAPID_PUBLIC_KEY=${{ vars.VITE_VAPID_PUBLIC_KEY }} + labels: org.opencontainers.image.revision=${{ github.sha }} + cache-from: type=gha,scope=release-ui + cache-to: type=gha,scope=release-ui,mode=max + # Public site configuration is build-time only (validated by the Astro + # build); the values live in git so a change rebuilds the image. + - name: Load production site configuration + id: site_config + run: | + set -a + . infra/k3s/overlays/prod/site-build.env + set +a + { + echo "site_url=$SITE_URL" + echo "app_url=$APP_URL" + echo "contact_email=$CONTACT_EMAIL" + echo "analytics_script_url=$ANALYTICS_SCRIPT_URL" + echo "analytics_website_id=$ANALYTICS_WEBSITE_ID" + } >> "$GITHUB_OUTPUT" + - name: Build site candidate + id: site + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: apps/site + file: apps/site/Dockerfile + target: production + push: true + tags: ghcr.io/${{ github.repository }}-site:candidate-${{ github.sha }} + build-args: | + SITE_URL=${{ steps.site_config.outputs.site_url }} + APP_URL=${{ steps.site_config.outputs.app_url }} + CONTACT_EMAIL=${{ steps.site_config.outputs.contact_email }} + ANALYTICS_SCRIPT_URL=${{ steps.site_config.outputs.analytics_script_url }} + ANALYTICS_WEBSITE_ID=${{ steps.site_config.outputs.analytics_website_id }} + labels: org.opencontainers.image.revision=${{ github.sha }} + cache-from: type=gha,scope=release-site + cache-to: type=gha,scope=release-site,mode=max + - name: Promote complete image set in one GitOps commit + env: + API_DIGEST: ${{ steps.api.outputs.digest }} + MIGRATIONS_DIGEST: ${{ steps.migrations.outputs.digest }} + UI_DIGEST: ${{ steps.ui.outputs.digest }} + SITE_DIGEST: ${{ steps.site.outputs.digest }} + run: | + set -euo pipefail + git fetch origin main + # No force pushes, rebases, partial promotions or stale release retries. + test "$(git rev-parse origin/main)" = "$GITHUB_SHA" + bun scripts/release/release.mjs pin + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add infra/k3s/overlays/prod/kustomization.yaml + git commit -m "deploy: promote complete release $GITHUB_SHA" + git push origin HEAD:main + echo "Promoted four images from $GITHUB_SHA. Argo rollout health must still be checked." >> "$GITHUB_STEP_SUMMARY" diff --git a/.tsforge/scaffold-manifest.json b/.tsforge/scaffold-manifest.json index 34f6e933..9489e860 100644 --- a/.tsforge/scaffold-manifest.json +++ b/.tsforge/scaffold-manifest.json @@ -68,6 +68,15 @@ "devDefault": "1", "addsServices": { "1": ["bullmq-dashboard"] } }, + { + "key": "WITH_SITE", + "kind": "toggle", + "group": "infra", + "label": "Public marketing site (apps/site). Dev: Astro dev server on 7333. Prod: static site on the apex host, app on app.", + "devDefault": "1", + "prodDefault": "0", + "addsServices": { "1": ["site-dev", "site"] } + }, { "key": "WUD", "kind": "toggle", diff --git a/AGENTS.md b/AGENTS.md index 0b5cbea2..886eee1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # BoringStack monorepo -Single repository: `apps/api`, `apps/ui`, `apps/docs`, `infra/compose`, `infra/bootstrap`. +Single repository: `apps/api`, `apps/ui`, `apps/docs`, `apps/site`, `infra/compose`, `infra/bootstrap`. ## Starting a new project from this template @@ -53,12 +53,20 @@ checks types, lint and formatting and is also included in root `check`. | `apps/api` | Bun + Elysia API | | `apps/ui` | Vite + React UI | | `apps/docs` | Astro docs site | +| `apps/site` | Astro public marketing site (static, nginx) | | `infra/compose` | Docker Compose runtime | | `infra/bootstrap` | OpenTofu bootstrap | +| `packages/*` | Shared code used by more than one app ([guide](apps/ui/docs/agents/shared-packages.md)) | | `.tsforge` | tsforge scaffold manifest (see below) | CI: `.github/workflows/` at repo root with path filters. +Self-hosted runners: set the repository variable `CI_RUNNER` to a runner label +and every job moves off GitHub-hosted minutes. Pull requests from forks always +stay on `ubuntu-24.04`, so outside code never runs on your machines, and +`production-release.yml` always runs GitHub-hosted. The lint-meta rule +`github-actions-runner-pinned` rejects a `vars.CI_RUNNER` without that guard. + Remote: https://github.com/boringstack-xyz/boringstack ## Scaffold manifest: keep it in sync @@ -94,3 +102,26 @@ cross-checks this manifest against the real `.env.example` files and FAILS if a watched toggle is neither modelled here nor waived via `watchIgnore`. A drifted manifest is a red build, not a silent gap. The `_about` field at the top of the JSON restates this for anyone who opens the file directly. + +## Commits and pull requests + +Every commit subject and PR title is a Conventional Commit: +`()!?: `, at most 100 characters, for example +`fix(api): keep the session after refresh`. This is a gate, not a guideline. +The `commit-msg` hook (installed by `bun install`) and the `pr-title` workflow +both run `scripts/ci/check-commit-title.sh`. Main is squash-merged, so the PR +title is the commit that lands. + +These instructions are harness-neutral. Codex, Claude Code and other agents all +start here, and `CLAUDE.md` only points at this file. Put a convention a +product depends on into a hook, a lint rule or CI, and keep this file in sync +with it. Prose that only one harness reads stops being followed when the +harness changes. + +## Generated content and data + +Large generated artifacts, such as content libraries, catalogue imports and +rendered diagrams, do not belong in the same commit or PR as hand-written +code. Commit the generator and its source data, then regenerate in a separate +`chore(content): ...` change, or build the artifacts in CI. A 500k-line diff +cannot be reviewed, and it hides the code change that travels with it. diff --git a/apps/api/.env.example b/apps/api/.env.example index 3aa3b68d..c1103c2d 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -58,8 +58,8 @@ AUTH_RATE_LIMIT_WINDOW_MS=60000 # proxy in front). TRUST_PROXY=false -# Optional first-boot superuser. When both are set, `bun run db:seed` -# creates a user with admin role on the first run. Empty = no user +# Optional superuser. When both are set, `bun run db:seed` creates a user +# with admin role (an existing user is left alone). Empty = no user # created; sign up via the registration flow instead. # # Running under docker-compose? Setting these HERE has no effect — the diff --git a/apps/api/Dockerfile.prod b/apps/api/Dockerfile.prod index 2adbd5ad..c5ec5d4c 100644 --- a/apps/api/Dockerfile.prod +++ b/apps/api/Dockerfile.prod @@ -20,6 +20,45 @@ COPY . . # the API into /app/dist. Both are needed at runtime. RUN bun run build +# Migration image (`--target migrations`): drizzle-kit is a devDependency, so it +# is installed here and kept out of the lean runtime image. The Kubernetes +# migration Job runs it. This stage must stay before `production`: a plain +# `docker build` builds the last stage, and that must be the API. +FROM oven/bun:1.4.2-alpine@sha256:d888c0ae6c86d7866ff10c5aafdd9077b36aee6455b33dd270fb93c0dd5cef6f AS migrations + +RUN apk add --no-cache dumb-init + +RUN addgroup -g 1001 -S nodejs && \ + adduser -S app -u 1001 -G nodejs + +WORKDIR /app + +ENV HUSKY=0 + +COPY package.json bun.lock* ./ + +RUN bun install --frozen-lockfile + +# `db:migrate` reads the drizzle config and SQL history. The source is here for +# an explicit one-off `bun run db:seed` (first admin only). The migration Job +# never runs it. The runtime image has no source tree, so seeds cannot run there. +COPY tsconfig.json ./ +COPY drizzle.config.ts ./ +COPY drizzle ./drizzle +COPY scripts/db ./scripts/db +COPY src ./src + +# Set after install so `bun install` above still sees devDependencies. +ENV NODE_ENV=production + +RUN chown -R app:nodejs /app + +USER app + +ENTRYPOINT ["dumb-init", "--"] + +CMD ["bun", "run", "db:migrate"] + # Production stage FROM oven/bun:1.4.2-alpine@sha256:d888c0ae6c86d7866ff10c5aafdd9077b36aee6455b33dd270fb93c0dd5cef6f AS production @@ -53,4 +92,7 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ ENTRYPOINT ["dumb-init", "--"] -CMD ["bun", "run", "dist/index.js"] +# --no-install: bun must never try to install packages at startup. Production +# root filesystems are read-only (readOnlyRootFilesystem), so an install attempt +# crashes with EROFS. Writable scratch space is /tmp, mounted by the manifests. +CMD ["bun", "--no-install", "run", "dist/index.js"] diff --git a/apps/api/bun.lock b/apps/api/bun.lock index 2b8e0485..27644e81 100644 --- a/apps/api/bun.lock +++ b/apps/api/bun.lock @@ -28,7 +28,7 @@ "fs-extra": "11.4.0", "handlebars": "4.7.9", "ioredis": "5.11.1", - "nodemailer": "10.0.0", + "nodemailer": "10.0.9", "openai": "7.10.0", "otpauth": "9.5.2", "pg": "8.23.0", @@ -82,11 +82,14 @@ }, }, "overrides": { + "@grpc/grpc-js": "1.14.5", "@typescript-eslint/utils": "8.61.1", - "axios": "1.18.0", + "axios": "1.20.0", "form-data": "4.0.6", "ioredis": "5.11.1", "protobufjs": "7.6.5", + "smol-toml": "1.9.0", + "source-map-js": "1.2.2", }, "packages": { "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], @@ -225,7 +228,7 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="], - "@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="], + "@grpc/grpc-js": ["@grpc/grpc-js@1.14.5", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw=="], "@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="], @@ -669,7 +672,7 @@ "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], - "axios": ["axios@1.18.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw=="], + "axios": ["axios@1.20.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg=="], "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], @@ -681,7 +684,7 @@ "bn.js": ["bn.js@4.12.5", "", {}, "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ=="], - "brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], + "brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -1137,7 +1140,7 @@ "node-releases": ["node-releases@2.0.53", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="], - "nodemailer": ["nodemailer@10.0.0", "", {}, "sha512-wdv+hXBg0iIPIOf108FaZyTxqjQpCvQeAJt9pHHTooPy/y3vQtI9eiImM9AIVKR3e/0FeMA1oueNfBjFJcaK+Q=="], + "nodemailer": ["nodemailer@10.0.9", "", {}, "sha512-BF0qcyplCwp+jMk6HCjFykBz/YhhZSsxrARhOldLwFWH+8kGjQsd2WIMIZhqEuyXRoGFi0ONbDeWDMDoL8MhLw=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -1323,13 +1326,13 @@ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - "smol-toml": ["smol-toml@1.8.0", "", {}, "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ=="], + "smol-toml": ["smol-toml@1.9.0", "", {}, "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ=="], "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], @@ -1651,9 +1654,9 @@ "elysia-rate-limit/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="], - "eslint-plugin-import/minimatch/brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="], + "eslint-plugin-import/minimatch/brace-expansion": ["brace-expansion@1.1.21", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw=="], - "glob/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="], + "glob/minimatch/brace-expansion": ["brace-expansion@2.1.7", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g=="], "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], diff --git a/apps/api/bunfig.toml b/apps/api/bunfig.toml index ebb72912..4ad49013 100644 --- a/apps/api/bunfig.toml +++ b/apps/api/bunfig.toml @@ -50,10 +50,6 @@ minimumReleaseAgeExcludes = [ # we override `form-data` directly to clear `bun audit`. The patched release # is <7 days old, so exempt it from the quarantine window. "form-data", - # nodemailer 9.0.1 patches GHSA-p6gq-j5cr-w38f (raw-option file-read/SSRF). - # Direct dep; the patched release is <7 days old, so exempt it from the - # quarantine window for this security upgrade. - "nodemailer", ] [test] diff --git a/apps/api/drizzle.config.ts b/apps/api/drizzle.config.ts index 9c92a120..bb103bc2 100644 --- a/apps/api/drizzle.config.ts +++ b/apps/api/drizzle.config.ts @@ -1,17 +1,35 @@ import * as dotenv from "dotenv"; import type { Config } from "drizzle-kit"; +import { + buildDrizzleCredentials, + buildPostgresSsl, +} from "./src/lib/postgres/connection"; +import { toBoolWithDefault } from "./src/config/env/validate"; + dotenv.config({ path: process.env.DOTENV_CONFIG_PATH ?? ".env" }); +// Same TLS policy as the runtime client, so migrations verify the database +// certificate with the same CA the API uses (see src/lib/postgres/connection.ts). +const ssl = buildPostgresSsl({ + isProduction: process.env.NODE_ENV === "production", + rejectUnauthorized: toBoolWithDefault( + process.env.DATABASE_SSL_REJECT_UNAUTHORIZED, + true, + "DATABASE_SSL_REJECT_UNAUTHORIZED" + ), + ca: process.env.DATABASE_SSL_CA ?? "", +}); + export default { schema: "./src/clients/postgres/schema/*.ts", out: "./drizzle", dialect: "postgresql", - dbCredentials: { - url: - process.env.DATABASE_URL ?? + dbCredentials: buildDrizzleCredentials( + process.env.DATABASE_URL ?? "postgresql://app:app_dev_password@localhost:5432/app", - }, + ssl + ), schemaFilter: ["auth", "billing", "audit", "app", "notifications"], verbose: true, strict: true, diff --git a/apps/api/drizzle/0002_metrics_exporter_grants.sql b/apps/api/drizzle/0002_metrics_exporter_grants.sql new file mode 100644 index 00000000..bcc7ccf7 --- /dev/null +++ b/apps/api/drizzle/0002_metrics_exporter_grants.sql @@ -0,0 +1,21 @@ +-- Business metrics for the cluster Grafana come from CloudNativePG's metrics +-- exporter running aggregate queries (sign-ups per day, active users, paid +-- accounts per plan, cancellations per day, Stripe event counts) defined in +-- infra/k3s/overlays/prod/monitoring/cnpg-business-queries.yaml. +-- The exporter is not a superuser: it runs as pg_monitor (CNPG <= 1.26) or as +-- cnpg_metrics_exporter, which inherits pg_monitor. Granting to the built-in +-- pg_monitor role covers both and exists in every environment. +-- +-- Column-level on purpose: only what the counts need. No emails, names, IPs, +-- user agents, audit metadata, password or MFA material, or Stripe identifiers. +GRANT USAGE ON SCHEMA "audit", "auth", "billing" TO pg_monitor; +--> statement-breakpoint +GRANT SELECT ("created_at") ON "auth"."users" TO pg_monitor; +--> statement-breakpoint +GRANT SELECT ("user_id", "action", "created_at") ON "audit"."audit_log" TO pg_monitor; +--> statement-breakpoint +GRANT SELECT ("plan_id", "status", "source", "revoked_at", "updated_at") ON "billing"."account_plans" TO pg_monitor; +--> statement-breakpoint +GRANT SELECT ("id", "name") ON "billing"."plans" TO pg_monitor; +--> statement-breakpoint +GRANT SELECT ("type") ON "billing"."stripe_webhook_events" TO pg_monitor; diff --git a/apps/api/drizzle/meta/0002_snapshot.json b/apps/api/drizzle/meta/0002_snapshot.json new file mode 100644 index 00000000..469e068a --- /dev/null +++ b/apps/api/drizzle/meta/0002_snapshot.json @@ -0,0 +1,2941 @@ +{ + "id": "cdff6c76-4bae-42d4-918b-2642a11bd955", + "prevId": "30ade90b-c35f-497e-b429-398e0c51c47c", + "version": "7", + "dialect": "postgresql", + "tables": { + "app.account_feature_overrides": { + "name": "account_feature_overrides", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "feature_key": { + "name": "feature_key", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "visibility": { + "name": "visibility", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "granted_by_user_id": { + "name": "granted_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "granted_by_membership_id": { + "name": "granted_by_membership_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by_user_id": { + "name": "revoked_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoked_reason": { + "name": "revoked_reason", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_account_feature_overrides_account_id": { + "name": "idx_account_feature_overrides_account_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_account_feature_overrides_feature_key": { + "name": "idx_account_feature_overrides_feature_key", + "columns": [ + { + "expression": "feature_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "uniq_account_feature_overrides_active": { + "name": "uniq_account_feature_overrides_active", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "feature_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "revoked_at IS NULL", + "concurrently": false + } + }, + "foreignKeys": { + "account_feature_overrides_account_id_fkey": { + "name": "account_feature_overrides_account_id_fkey", + "tableFrom": "account_feature_overrides", + "columnsFrom": [ + "account_id" + ], + "tableTo": "accounts", + "schemaTo": "app", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "account_feature_overrides_granted_by_user_id_fkey": { + "name": "account_feature_overrides_granted_by_user_id_fkey", + "tableFrom": "account_feature_overrides", + "columnsFrom": [ + "granted_by_user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "restrict" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.account_invitations": { + "name": "account_invitations", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "role_to_assign": { + "name": "role_to_assign", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "invited_by_membership_id": { + "name": "invited_by_membership_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_account_invitations_account_id": { + "name": "idx_account_invitations_account_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_account_invitations_email": { + "name": "idx_account_invitations_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_account_invitations_token_hash": { + "name": "idx_account_invitations_token_hash", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "uniq_account_invitations_active": { + "name": "uniq_account_invitations_active", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"email\")", + "isExpression": true, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "accepted_at IS NULL AND revoked_at IS NULL", + "concurrently": false + } + }, + "foreignKeys": { + "account_invitations_account_id_fkey": { + "name": "account_invitations_account_id_fkey", + "tableFrom": "account_invitations", + "columnsFrom": [ + "account_id" + ], + "tableTo": "accounts", + "schemaTo": "app", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "account_invitations_token_hash_key": { + "name": "account_invitations_token_hash_key", + "columns": [ + "token_hash" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.account_join_requests": { + "name": "account_join_requests", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decided_by_user_id": { + "name": "decided_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_account_join_requests_account_id": { + "name": "idx_account_join_requests_account_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_account_join_requests_user_id": { + "name": "idx_account_join_requests_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "uniq_account_join_requests_pending": { + "name": "uniq_account_join_requests_pending", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "status = 'pending'", + "concurrently": false + } + }, + "foreignKeys": { + "account_join_requests_account_id_fkey": { + "name": "account_join_requests_account_id_fkey", + "tableFrom": "account_join_requests", + "columnsFrom": [ + "account_id" + ], + "tableTo": "accounts", + "schemaTo": "app", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "account_join_requests_user_id_fkey": { + "name": "account_join_requests_user_id_fkey", + "tableFrom": "account_join_requests", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.account_ownership_transfers": { + "name": "account_ownership_transfers", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "from_user_id": { + "name": "from_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "to_user_id": { + "name": "to_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "declined_at": { + "name": "declined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_account_ownership_transfers_account_id": { + "name": "idx_account_ownership_transfers_account_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_account_ownership_transfers_token_hash": { + "name": "idx_account_ownership_transfers_token_hash", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "uniq_account_ownership_transfers_pending": { + "name": "uniq_account_ownership_transfers_pending", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "accepted_at IS NULL AND declined_at IS NULL AND cancelled_at IS NULL", + "concurrently": false + } + }, + "foreignKeys": { + "account_ownership_transfers_account_id_fkey": { + "name": "account_ownership_transfers_account_id_fkey", + "tableFrom": "account_ownership_transfers", + "columnsFrom": [ + "account_id" + ], + "tableTo": "accounts", + "schemaTo": "app", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "account_ownership_transfers_from_user_id_fkey": { + "name": "account_ownership_transfers_from_user_id_fkey", + "tableFrom": "account_ownership_transfers", + "columnsFrom": [ + "from_user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "account_ownership_transfers_to_user_id_fkey": { + "name": "account_ownership_transfers_to_user_id_fkey", + "tableFrom": "account_ownership_transfers", + "columnsFrom": [ + "to_user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "account_ownership_transfers_token_hash_key": { + "name": "account_ownership_transfers_token_hash_key", + "columns": [ + "token_hash" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.accounts": { + "name": "accounts", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "claimed_domain": { + "name": "claimed_domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_accounts_deleted_at": { + "name": "idx_accounts_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_accounts_claimed_domain": { + "name": "idx_accounts_claimed_domain", + "columns": [ + { + "expression": "claimed_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "uniq_accounts_claimed_domain_active": { + "name": "uniq_accounts_claimed_domain_active", + "columns": [ + { + "expression": "claimed_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "claimed_domain IS NOT NULL AND deleted_at IS NULL", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "accounts_stripe_customer_id_key": { + "name": "accounts_stripe_customer_id_key", + "columns": [ + "stripe_customer_id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "audit.audit_log": { + "name": "audit_log", + "schema": "audit", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_membership_id": { + "name": "actor_membership_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_account_id": { + "name": "target_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_membership_id": { + "name": "target_membership_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "ip": { + "name": "ip", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_audit_log_user_id": { + "name": "idx_audit_log_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_audit_log_action": { + "name": "idx_audit_log_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_audit_log_created_at": { + "name": "idx_audit_log_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_audit_log_target_account_id": { + "name": "idx_audit_log_target_account_id", + "columns": [ + { + "expression": "target_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "audit_log_user_id_fkey": { + "name": "audit_log_user_id_fkey", + "tableFrom": "audit_log", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "audit.redactions": { + "name": "redactions", + "schema": "audit", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "original_user_id_hash": { + "name": "original_user_id_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "redacted_at": { + "name": "redacted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reason": { + "name": "reason", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_redactions_original_user_id_hash": { + "name": "idx_redactions_original_user_id_hash", + "columns": [ + { + "expression": "original_user_id_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.session_retired_tokens": { + "name": "session_retired_tokens", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "family_id": { + "name": "family_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_session_retired_tokens_hash_unique": { + "name": "idx_session_retired_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_session_retired_tokens_family_id": { + "name": "idx_session_retired_tokens_family_id", + "columns": [ + { + "expression": "family_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "session_retired_tokens_session_id_fkey": { + "name": "session_retired_tokens_session_id_fkey", + "tableFrom": "session_retired_tokens", + "columnsFrom": [ + "session_id" + ], + "tableTo": "sessions", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.sessions": { + "name": "sessions", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "family_id": { + "name": "family_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "previous_token_hash": { + "name": "previous_token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_sessions_token_hash_unique": { + "name": "idx_sessions_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_sessions_previous_token_hash": { + "name": "idx_sessions_previous_token_hash", + "columns": [ + { + "expression": "previous_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_sessions_family_id": { + "name": "idx_sessions_family_id", + "columns": [ + { + "expression": "family_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_sessions_user_id": { + "name": "idx_sessions_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_sessions_expires_at": { + "name": "idx_sessions_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "sessions_user_id_fkey": { + "name": "sessions_user_id_fkey", + "tableFrom": "sessions", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.email_verification_tokens": { + "name": "email_verification_tokens", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_verification_tokens_token_hash": { + "name": "idx_email_verification_tokens_token_hash", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_email_verification_tokens_user_id": { + "name": "idx_email_verification_tokens_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "email_verification_tokens_user_id_fkey": { + "name": "email_verification_tokens_user_id_fkey", + "tableFrom": "email_verification_tokens", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_verification_tokens_token_hash_key": { + "name": "email_verification_tokens_token_hash_key", + "columns": [ + "token_hash" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.mfa_recovery_codes": { + "name": "mfa_recovery_codes", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_mfa_recovery_codes_user_id": { + "name": "idx_mfa_recovery_codes_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "mfa_recovery_codes_user_id_fkey": { + "name": "mfa_recovery_codes_user_id_fkey", + "tableFrom": "mfa_recovery_codes", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.password_reset_tokens": { + "name": "password_reset_tokens", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_password_reset_tokens_token_hash": { + "name": "idx_password_reset_tokens_token_hash", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_password_reset_tokens_user_id": { + "name": "idx_password_reset_tokens_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "password_reset_tokens_user_id_fkey": { + "name": "password_reset_tokens_user_id_fkey", + "tableFrom": "password_reset_tokens", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_reset_tokens_token_hash_key": { + "name": "password_reset_tokens_token_hash_key", + "columns": [ + "token_hash" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.user_auth_providers": { + "name": "user_auth_providers", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_user_auth_providers_provider_id": { + "name": "idx_user_auth_providers_provider_id", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_user_auth_providers_user_id": { + "name": "idx_user_auth_providers_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "user_auth_providers_user_id_fkey": { + "name": "user_auth_providers_user_id_fkey", + "tableFrom": "user_auth_providers", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_auth_providers_provider_provider_user_id_key": { + "name": "user_auth_providers_provider_provider_user_id_key", + "columns": [ + "provider", + "provider_user_id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.users": { + "name": "users", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_name": { + "name": "last_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_platform_admin": { + "name": "is_platform_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "mfa_enabled_at": { + "name": "mfa_enabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "mfa_secret_encrypted": { + "name": "mfa_secret_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mfa_last_totp_step": { + "name": "mfa_last_totp_step", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_users_email": { + "name": "idx_users_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_users_is_platform_admin": { + "name": "idx_users_is_platform_admin", + "columns": [ + { + "expression": "is_platform_admin", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_key": { + "name": "users_email_key", + "columns": [ + "email" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "billing.account_plans": { + "name": "account_plans", + "schema": "billing", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plan_id": { + "name": "plan_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'stripe'" + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_created_at": { + "name": "stripe_subscription_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_stripe_event_id": { + "name": "last_stripe_event_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "last_stripe_event_at": { + "name": "last_stripe_event_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_account_plans_account_id": { + "name": "idx_account_plans_account_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_account_plans_status": { + "name": "idx_account_plans_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "uniq_account_plans_current": { + "name": "uniq_account_plans_current", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "revoked_at IS NULL", + "concurrently": false + } + }, + "foreignKeys": { + "account_plans_account_id_fkey": { + "name": "account_plans_account_id_fkey", + "tableFrom": "account_plans", + "columnsFrom": [ + "account_id" + ], + "tableTo": "accounts", + "schemaTo": "app", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "account_plans_plan_id_fkey": { + "name": "account_plans_plan_id_fkey", + "tableFrom": "account_plans", + "columnsFrom": [ + "plan_id" + ], + "tableTo": "plans", + "schemaTo": "billing", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "restrict" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "billing.plan_features": { + "name": "plan_features", + "schema": "billing", + "columns": { + "plan_id": { + "name": "plan_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "feature_key": { + "name": "feature_key", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "plan_features_plan_id_fkey": { + "name": "plan_features_plan_id_fkey", + "tableFrom": "plan_features", + "columnsFrom": [ + "plan_id" + ], + "tableTo": "plans", + "schemaTo": "billing", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "plan_features_pkey": { + "name": "plan_features_pkey", + "columns": [ + "plan_id", + "feature_key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "billing.plans": { + "name": "plans", + "schema": "billing", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "stripe_product_id": { + "name": "stripe_product_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "plans_name_key": { + "name": "plans_name_key", + "columns": [ + "name" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "billing.stripe_webhook_events": { + "name": "stripe_webhook_events", + "schema": "billing", + "columns": { + "event_id": { + "name": "event_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.account_memberships": { + "name": "account_memberships", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_reason": { + "name": "revoked_reason", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_account_memberships_account_id": { + "name": "idx_account_memberships_account_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_account_memberships_user_id": { + "name": "idx_account_memberships_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "uniq_account_memberships_active_user": { + "name": "uniq_account_memberships_active_user", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "revoked_at IS NULL", + "concurrently": false + }, + "uniq_account_memberships_active_owner": { + "name": "uniq_account_memberships_active_owner", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "role = 'owner' AND revoked_at IS NULL", + "concurrently": false + } + }, + "foreignKeys": { + "account_memberships_account_id_fkey": { + "name": "account_memberships_account_id_fkey", + "tableFrom": "account_memberships", + "columnsFrom": [ + "account_id" + ], + "tableTo": "accounts", + "schemaTo": "app", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "account_memberships_user_id_fkey": { + "name": "account_memberships_user_id_fkey", + "tableFrom": "account_memberships", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "notifications.email_suppression": { + "name": "email_suppression", + "schema": "notifications", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "suppressed_at": { + "name": "suppressed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_suppression_email": { + "name": "idx_email_suppression_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_email_suppression_suppressed_at": { + "name": "idx_email_suppression_suppressed_at", + "columns": [ + { + "expression": "suppressed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "notifications.notification": { + "name": "notification", + "schema": "notifications", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "rendered": { + "name": "rendered", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'unread'" + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_notification_recipient_status_created_at": { + "name": "idx_notification_recipient_status_created_at", + "columns": [ + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_notification_event_type": { + "name": "idx_notification_event_type", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "notification_recipient_user_id_fkey": { + "name": "notification_recipient_user_id_fkey", + "tableFrom": "notification", + "columnsFrom": [ + "recipient_user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "notifications.notification_dedup": { + "name": "notification_dedup", + "schema": "notifications", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "dedup_key": { + "name": "dedup_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_notification_dedup_key_unique": { + "name": "idx_notification_dedup_key_unique", + "columns": [ + { + "expression": "dedup_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_notification_dedup_expires_at": { + "name": "idx_notification_dedup_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "notifications.notification_delivery": { + "name": "notification_delivery", + "schema": "notifications", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "notification_id": { + "name": "notification_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failed_at": { + "name": "failed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_notification_delivery_notification_channel": { + "name": "idx_notification_delivery_notification_channel", + "columns": [ + { + "expression": "notification_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "notification_delivery_notification_id_fkey": { + "name": "notification_delivery_notification_id_fkey", + "tableFrom": "notification_delivery", + "columnsFrom": [ + "notification_id" + ], + "tableTo": "notification", + "schemaTo": "notifications", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "notifications.notification_preference": { + "name": "notification_preference", + "schema": "notifications", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_notification_preference_user_event_channel": { + "name": "idx_notification_preference_user_event_channel", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "notification_preference_user_id_fkey": { + "name": "notification_preference_user_id_fkey", + "tableFrom": "notification_preference", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "notifications.push_subscription": { + "name": "push_subscription", + "schema": "notifications", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p256dh_key": { + "name": "p256dh_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_key": { + "name": "auth_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_push_subscription_user_endpoint": { + "name": "idx_push_subscription_user_endpoint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_push_subscription_user_id": { + "name": "idx_push_subscription_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "push_subscription_user_id_fkey": { + "name": "push_subscription_user_id_fkey", + "tableFrom": "push_subscription", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": { + "app": "app", + "audit": "audit", + "auth": "auth", + "billing": "billing", + "notifications": "notifications" + }, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 2c815928..a65342d0 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1789019374688, "tag": "0001_wild_gladiator", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1791497355427, + "tag": "0002_metrics_exporter_grants", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/osv-scanner.toml b/apps/api/osv-scanner.toml index 5b6321c2..9434a9d7 100644 --- a/apps/api/osv-scanner.toml +++ b/apps/api/osv-scanner.toml @@ -24,3 +24,43 @@ micromatch; our usage is internal build-time tooling (vite, tsconfig resolution). No path from user input to micromatch in api source. Awaiting upstream patch via vite/jest transitive deps. """ + +[[IgnoredVulns]] +id = "GHSA-vfj7-8cjw-p6xm" +ignoreUntil = "2026-11-18T00:00:00Z" +reason = """ +braces 3.0.3 (no fixed release yet). Reached only through micromatch 4.0.5 in +the @boring-stack-pkg ESLint plugins: lint-time tooling, never in the +production image or reached by request input. Remove once braces is patched. +""" + +# Handlebars has no release that is both patched and past the 7-day quarantine +# (4.7.10 was published 2026-10-05, eligible from 2026-10-12). Bump +# handlebars to 4.7.10 when it ages in, then drop these three entries. +[[IgnoredVulns]] +id = "GHSA-8r5x-fm3f-whwj" +ignoreUntil = "2026-10-16T00:00:00Z" +reason = """ +Handlebars compile-path JavaScript injection (bypass of CVE-2026-33937), fixed +in 4.7.10. Templates are compiled only at build time (src/templates/email/build.ts, +preview.ts) from files we own; runtime rendering uses precompiled templates via +handlebars/runtime. No user-supplied template source reaches compile(). +""" + +[[IgnoredVulns]] +id = "GHSA-p8wg-vrv2-v86f" +ignoreUntil = "2026-10-16T00:00:00Z" +reason = """ +Handlebars own-property check bypass leading to JavaScript injection, fixed in +4.7.10. Same usage as GHSA-8r5x-fm3f-whwj: compile() runs only over owned +templates at build time; no attacker-controlled template or helper source. +""" + +[[IgnoredVulns]] +id = "GHSA-xw65-4hp5-5hc7" +ignoreUntil = "2026-10-16T00:00:00Z" +reason = """ +Handlebars unsafe inline embedding of precompiled templates, fixed in 4.7.10. +Only precompiled, repo-owned templates are embedded; no template content is +derived from request input. Same expiry as GHSA-8r5x-fm3f-whwj. +""" diff --git a/apps/api/package.json b/apps/api/package.json index 03e26116..a291fed2 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -78,7 +78,7 @@ "fs-extra": "11.4.0", "handlebars": "4.7.9", "ioredis": "5.11.1", - "nodemailer": "10.0.0", + "nodemailer": "10.0.9", "openai": "7.10.0", "otpauth": "9.5.2", "pg": "8.23.0", @@ -130,18 +130,24 @@ "typescript-eslint": "8.69.0" }, "//overrides": { - "_": "Why these transitive deps are pinned. Keep each entry in sync with the matching `overrides` key \u2014 enforced by the package-override-parity lint-meta rule.", + "_": "Why these transitive deps are pinned. Keep each entry in sync with the matching `overrides` key — enforced by the package-override-parity lint-meta rule.", "@typescript-eslint/utils": "Single @typescript-eslint/utils resolution across the workspace so the shared @boring-stack-pkg ESLint plugins all load the same utils version. The UI and docs apps mirror this exact pin; a mismatch makes the custom plugins resolve divergent utils copies and fail to load.", - "axios": "Pin patched axios (GHSA-gcfj-64vw-6mp9 and related SSRF/credential-leak advisories). 1.16.0 is vulnerable; 1.18.0 patches it. Pulled in transitively via @sendgrid/mail -> @sendgrid/client.", + "axios": "Pin patched axios (GHSA-gcfj-64vw-6mp9 and related SSRF/credential-leak advisories, then GHSA-r4gj-5m52-g5wh, GHSA-vh66-26gq-q6x8, GHSA-x97p-jq2g-jp4f). 1.18.0 is vulnerable; 1.20.0 patches it. Pulled in transitively via @sendgrid/mail -> @sendgrid/client. Mirrored by apps/docs.", + "@grpc/grpc-js": "Pin patched @grpc/grpc-js (GHSA-f596-whhp-79r4, GHSA-m9gg-hp2v-232j). 1.14.4 is vulnerable; 1.14.5 patches it. Pulled in transitively via @opentelemetry/otlp-grpc-exporter-base, which accepts ^1.14.3.", "form-data": "Pin patched form-data (GHSA-hmw2-7cc7-3qxx CRLF injection via unescaped multipart field names); 4.0.5 is vulnerable, 4.0.6 patches it. Pulled in transitively via @sendgrid/mail -> @sendgrid/client -> axios. Excluded from the install quarantine while <7 days old.", - "ioredis": "Force a single ioredis resolution across the tree. bullmq 5.80.2 exact-pins ioredis@5.10.1, so bumping the top-level dep to 5.11.1 otherwise leaves bullmq on its own nested 5.10.1 copy \u2014 two ioredis instances whose RedisOptions types are structurally incompatible (tsc fails on new Redis(options) calls). This override collapses bullmq's nested copy onto 5.11.1 too; 5.10.1 -> 5.11.1 is a semver patch and the Redis/BullMQ integration tests verify runtime compatibility. Drop this once bullmq advances its own ioredis pin.", - "protobufjs": "Pin patched protobufjs (GHSA-j3f2-48v5-ccww). 7.6.1 is vulnerable; 7.6.5 patches it. Transitive via the OpenTelemetry OTLP exporter (build/telemetry path)." + "ioredis": "Force a single ioredis resolution across the tree. bullmq 5.80.2 exact-pins ioredis@5.10.1, so bumping the top-level dep to 5.11.1 otherwise leaves bullmq on its own nested 5.10.1 copy — two ioredis instances whose RedisOptions types are structurally incompatible (tsc fails on new Redis(options) calls). This override collapses bullmq's nested copy onto 5.11.1 too; 5.10.1 -> 5.11.1 is a semver patch and the Redis/BullMQ integration tests verify runtime compatibility. Drop this once bullmq advances its own ioredis pin.", + "protobufjs": "Pin patched protobufjs (GHSA-j3f2-48v5-ccww). 7.6.1 is vulnerable; 7.6.5 patches it. Transitive via the OpenTelemetry OTLP exporter (build/telemetry path).", + "smol-toml": "Pin patched smol-toml (GHSA-r4xh-jqrq-34v2). 1.8.0 is vulnerable; 1.9.0 patches it. Transitive via knip (lint-time only). Mirrored in every app by the override-parity rule.", + "source-map-js": "Pin patched source-map-js (GHSA-68fv-2mgg-jv7q). 1.2.1 is vulnerable; 1.2.2 patches it. Transitive via postcss / css-tree build tooling. Mirrored in every app by the override-parity rule." }, "overrides": { + "@grpc/grpc-js": "1.14.5", "@typescript-eslint/utils": "8.61.1", - "axios": "1.18.0", + "axios": "1.20.0", "form-data": "4.0.6", "ioredis": "5.11.1", - "protobufjs": "7.6.5" + "protobufjs": "7.6.5", + "smol-toml": "1.9.0", + "source-map-js": "1.2.2" } } diff --git a/apps/api/scripts/db/seed-superuser.ts b/apps/api/scripts/db/seed-superuser.ts index 7a4cfc85..db2e585d 100644 --- a/apps/api/scripts/db/seed-superuser.ts +++ b/apps/api/scripts/db/seed-superuser.ts @@ -33,7 +33,7 @@ async function main(): Promise { if (email === "" || password === "") { console.log( "[seed-superuser] SUPERUSER_EMAIL or SUPERUSER_PASSWORD unset — skipping. " + - "Set both to create a privileged user on first migrate, or sign up via the UI. " + + "Set both, then run `bun run db:seed`, to create a privileged user, or sign up via the UI. " + "Running under docker-compose? The api-migrate service interpolates these " + "from compose/.env (NOT apps/api/.env)." ); diff --git a/apps/api/scripts/lint-meta/RULES.md b/apps/api/scripts/lint-meta/RULES.md index 81f53b55..eea8692e 100644 --- a/apps/api/scripts/lint-meta/RULES.md +++ b/apps/api/scripts/lint-meta/RULES.md @@ -25,7 +25,7 @@ Run `bun run lint:meta --list-rules` for the machine-readable list from the regi | `github-actions-concurrency-explicit` | ci | no | Workflows with a concurrency block must set cancel-in-progress explicitly. | | `github-actions-paths-filter-parity` | ci | no | Workflows pairing push.paths with a dorny/paths-filter gate must keep the two path sets mutually covered. | | `github-actions-pip-install-pinned` | ci | no | Workflow pip install steps must pin package versions with == so CI tools cannot drift with PyPI releases. | -| `github-actions-runner-pinned` | ci | no | Workflows must pin runner images to an explicit OS version instead of floating *-latest labels. | +| `github-actions-runner-pinned` | ci | no | Workflows must pin runner images to an explicit OS version instead of floating *-latest labels, and guard vars.CI_RUNNER against fork pull requests. | | `github-actions-security-no-cancel` | ci | no | Security scan workflows (*-security-{sast,secrets,deps}) must set concurrency cancel-in-progress: false so no pushed ref goes unscanned. | | `github-actions-expression-syntax` | ci | no | Every expression opener in a workflow must be a well-formed Actions expression. | | `github-actions-service-image-digest-pin` | ci | no | Workflow service/container images must be pinned by @sha256 digest, not tag alone. | diff --git a/apps/api/scripts/lint-meta/rules/ci/github-actions-runner-pinned.ts b/apps/api/scripts/lint-meta/rules/ci/github-actions-runner-pinned.ts index a916d604..76325085 100644 --- a/apps/api/scripts/lint-meta/rules/ci/github-actions-runner-pinned.ts +++ b/apps/api/scripts/lint-meta/rules/ci/github-actions-runner-pinned.ts @@ -5,6 +5,7 @@ import type { IMetaRule, IViolation } from "../../types"; const RULE_ID = "github-actions-runner-pinned"; const RUNS_ON_REGEX = /^\s*runs-on:\s*(?