diff --git a/.github/workflows/apps-api-acl-drift.yml b/.github/workflows/apps-api-acl-drift.yml index 5bf206bf..794f68b3 100644 --- a/.github/workflows/apps-api-acl-drift.yml +++ b/.github/workflows/apps-api-acl-drift.yml @@ -41,6 +41,13 @@ jobs: uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.3.14 + - name: Cache bun install + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ~/.bun/install/cache + key: bun-${{ runner.os }}-${{ hashFiles('apps/api/bun.lock') }} + restore-keys: | + bun-${{ runner.os }}- - name: Install apps/api deps working-directory: apps/api diff --git a/.github/workflows/apps-api-openapi-drift.yml b/.github/workflows/apps-api-openapi-drift.yml index 72f75be5..ccb36157 100644 --- a/.github/workflows/apps-api-openapi-drift.yml +++ b/.github/workflows/apps-api-openapi-drift.yml @@ -92,6 +92,14 @@ jobs: uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.3.14 + - name: Cache bun install + if: steps.filter.outputs.code == 'true' + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ~/.bun/install/cache + key: bun-${{ runner.os }}-${{ hashFiles('apps/api/bun.lock', 'apps/ui/bun.lock') }} + restore-keys: | + bun-${{ runner.os }}- - name: Install apps/api deps if: steps.filter.outputs.code == 'true' diff --git a/.github/workflows/apps-docs-linkcheck.yml b/.github/workflows/apps-docs-linkcheck.yml index f1eb5a6b..ec0410de 100644 --- a/.github/workflows/apps-docs-linkcheck.yml +++ b/.github/workflows/apps-docs-linkcheck.yml @@ -63,6 +63,14 @@ jobs: if: steps.filter.outputs.docs == 'true' with: bun-version: 1.3.14 + - name: Cache bun install + if: steps.filter.outputs.docs == 'true' + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ~/.bun/install/cache + key: bun-${{ runner.os }}-${{ hashFiles('apps/docs/bun.lock', 'apps/ui/bun.lock', 'apps/api/bun.lock') }} + restore-keys: | + bun-${{ runner.os }}- - name: Install docs deps if: steps.filter.outputs.docs == 'true' diff --git a/.github/workflows/apps-ui-bundle-diff.yml b/.github/workflows/apps-ui-bundle-diff.yml index 99b1b443..b5ef8c19 100644 --- a/.github/workflows/apps-ui-bundle-diff.yml +++ b/.github/workflows/apps-ui-bundle-diff.yml @@ -60,6 +60,14 @@ jobs: uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.3.14 + - name: Cache bun install + if: steps.filter.outputs.ui == 'true' + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ~/.bun/install/cache + key: bun-${{ runner.os }}-${{ hashFiles('apps/ui/bun.lock') }} + restore-keys: | + bun-${{ runner.os }}- - name: Install if: steps.filter.outputs.ui == 'true' diff --git a/.github/workflows/apps-ui-release.yml b/.github/workflows/apps-ui-release.yml index e212ccc9..afdbb3c2 100644 --- a/.github/workflows/apps-ui-release.yml +++ b/.github/workflows/apps-ui-release.yml @@ -48,6 +48,13 @@ jobs: uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.3.14 + - name: Cache bun install + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ~/.bun/install/cache + key: bun-${{ runner.os }}-${{ hashFiles('apps/ui/bun.lock') }} + restore-keys: | + bun-${{ runner.os }}- - name: Install run: bun install --frozen-lockfile diff --git a/.github/workflows/apps-ui-validate.yml b/.github/workflows/apps-ui-validate.yml index c10ce211..aad68748 100644 --- a/.github/workflows/apps-ui-validate.yml +++ b/.github/workflows/apps-ui-validate.yml @@ -41,6 +41,14 @@ jobs: uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.3.14 + - name: Cache bun install + if: steps.filter.outputs.code == 'true' + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ~/.bun/install/cache + key: bun-${{ runner.os }}-${{ hashFiles('apps/ui/bun.lock') }} + restore-keys: | + bun-${{ runner.os }}- - name: Install if: steps.filter.outputs.code == 'true' diff --git a/.github/workflows/infra-compose-playwright-e2e.yml b/.github/workflows/infra-compose-playwright-e2e.yml index 91c676f3..11c9c69f 100644 --- a/.github/workflows/infra-compose-playwright-e2e.yml +++ b/.github/workflows/infra-compose-playwright-e2e.yml @@ -98,6 +98,13 @@ jobs: uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.3.14 + - name: Cache bun install + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ~/.bun/install/cache + key: bun-${{ runner.os }}-${{ hashFiles('apps/ui/bun.lock', 'apps/api/bun.lock') }} + restore-keys: | + bun-${{ runner.os }}- - name: Install apps/ui deps working-directory: apps/ui diff --git a/.github/workflows/infra-compose-validate-compose.yml b/.github/workflows/infra-compose-validate-compose.yml index ff680e5e..0a2c8f48 100644 --- a/.github/workflows/infra-compose-validate-compose.yml +++ b/.github/workflows/infra-compose-validate-compose.yml @@ -10,6 +10,10 @@ on: branches: [main] paths: - "infra/compose/**" + # Root gate scripts are validated here (shellcheck job) — keep the + # trigger in lockstep so direct pushes touching them still run it. + - "scripts/**" + - "setup.sh" - ".github/workflows/infra-compose-validate-compose.yml" # Prod Dockerfiles COPY the whole app context, so any app source # change can break the prod image build — not just Dockerfile or @@ -86,205 +90,40 @@ jobs: # exempt; overlays ship sidecars and are not scanned. - name: Guardrail — prod services define healthchecks if: steps.filter.outputs.code == 'true' + # Single-source: the same script runs in the local pre-push gate, + # so CI and local guardrails cannot drift. + run: ../scripts/validate-guardrails.sh healthchecks working-directory: infra/compose/compose - run: | - docker compose \ - -f docker-compose.yml \ - --profile prod config --format json > /tmp/prod-config.json - python3 - <<'EOF' - import json - - with open("/tmp/prod-config.json", encoding="utf-8") as handle: - services = json.load(handle)["services"] - - missing = sorted( - name - for name, svc in services.items() - if svc.get("restart") != "no" and "healthcheck" not in svc - ) - - if missing: - raise SystemExit( - "prod services missing healthcheck: " + ", ".join(missing) - ) - - print("healthcheck present on: " + ", ".join(sorted(services))) - EOF - - name: Guardrail — compose images digest-pinned, no floating latest if: steps.filter.outputs.code == 'true' + # Single-source: the same script runs in the local pre-push gate, + # so CI and local guardrails cannot drift. + run: ../scripts/validate-guardrails.sh digest-pins working-directory: infra/compose/compose - run: | - python3 - <<'EOF' - import glob - import re - - # Literal image refs must pin a @sha256 digest and must not carry - # the floating :latest tag (even alongside a digest — the digest - # wins, but the tag misdocuments what is pinned). Interpolated - # refs (${VAR...}) are validated at runtime by the stack scripts. - IMAGE_RE = re.compile(r"^\s+image:\s*(?P[^\s#]+)") - bad: list[str] = [] - - for path in sorted(glob.glob("docker-compose*.yml")): - with open(path, encoding="utf-8") as handle: - for lineno, line in enumerate(handle, start=1): - match = IMAGE_RE.match(line) - if match is None: - continue - ref = match.group("ref") - if "${" in ref: - continue - if "@sha256:" not in ref: - bad.append(f"{path}:{lineno}: {ref} (no digest pin)") - elif ":latest@" in ref: - bad.append(f"{path}:{lineno}: {ref} (floating :latest tag)") - - if bad: - raise SystemExit("unpinned compose images:\n" + "\n".join(bad)) - - print("all literal compose image refs digest-pinned") - EOF - - name: Guardrail — no hardcoded credential fallbacks in compose if: steps.filter.outputs.code == 'true' + # Single-source: the same script runs in the local pre-push gate, + # so CI and local guardrails cannot drift. + run: ../scripts/validate-guardrails.sh credential-fallbacks working-directory: infra/compose/compose - run: | - python3 - <<'EOF' - import glob - import re - - # Secret-named env vars must not ship a literal fallback — - # `${VAR:-hunter2}` in a published template is a credential leak. - # Use `${VAR:?message}` and let dev.sh generate dev values. - # The allowlist documents the deliberate dev-only placeholders - # that dev.sh fail-closes in prod. - SECRET_FALLBACK_RE = re.compile( - r"\$\{(?P[A-Za-z0-9_]*(?:PASSWORD|SECRET|_TOKEN|_KEY)[A-Za-z0-9_]*):-(?P[^}]+)\}" - ) - ALLOWED = { - # Dev DB password documented in .env.example; dev.sh requires - # an explicit POSTGRES_PASSWORD when STACK=prod. - ("POSTGRES_PASSWORD", "app_dev_password"), - # api-dev / api-migrate-dev only run under the dev profile; - # prod api reads api.prod.env instead. - ("API_DEV_JWT_SECRET", "docker-compose-api-dev-jwt-secret-keys"), - ("API_DEV_MFA_ENCRYPTION_KEY", "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="), - } - bad: list[str] = [] - - for path in sorted(glob.glob("docker-compose*.yml")): - with open(path, encoding="utf-8") as handle: - for lineno, line in enumerate(handle, start=1): - for match in SECRET_FALLBACK_RE.finditer(line): - pair = (match.group("var"), match.group("fallback")) - if pair in ALLOWED: - continue - # Note: string concat on purpose — emitting a - # dollar followed by double braces here would be - # lexed by GitHub Actions as a broken expression - # and invalidate the entire workflow file. - bad.append(path + ":" + str(lineno) + ": $" + "{" + pair[0] + ":-...}") - - if bad: - raise SystemExit( - "hardcoded credential fallbacks in compose:\n" + "\n".join(bad) - ) - - print("no unallowed credential fallbacks in compose files") - EOF - - name: Guardrail — valkey enforces auth and prod requires the password if: steps.filter.outputs.code == 'true' + # Single-source: the same script runs in the local pre-push gate, + # so CI and local guardrails cannot drift. + run: ../scripts/validate-guardrails.sh valkey-auth working-directory: infra/compose/compose - run: | - # The valkey server must run with --requirepass wired to - # VALKEY_PASSWORD (empty = dev-only auth-off), and dev.sh must - # fail closed on a missing password in prod. - grep -A 3 'command:' docker-compose.yml | grep -q -- '--requirepass' || { - echo "FAIL: valkey command does not enforce --requirepass"; exit 1 - } - grep -q 'VALKEY_PASSWORD:?VALKEY_PASSWORD required in prod' dev.sh || { - echo "FAIL: dev.sh prod guard for VALKEY_PASSWORD missing"; exit 1 - } - echo "valkey auth guardrail satisfied" - - name: Guardrail — rooted prod services must drop capabilities if: steps.filter.outputs.code == 'true' + # Single-source: the same script runs in the local pre-push gate, + # so CI and local guardrails cannot drift. + run: ../scripts/validate-guardrails.sh rooted-caps working-directory: infra/compose/compose - run: | - docker compose \ - -f docker-compose.yml \ - --profile prod config --format json > /tmp/prod-config.json - python3 - <<'EOF' - import json - - # `user: root` is only tolerated when the root process is - # neutered: every capability dropped (allowlist re-adds only), - # plus no-new-privileges. Anything less is a full-power root - # container one RCE away from the host. - with open("/tmp/prod-config.json", encoding="utf-8") as handle: - services = json.load(handle)["services"] - - bad: list[str] = [] - - for name, svc in services.items(): - if svc.get("user") not in ("root", "0", "0:0"): - continue - if "ALL" not in (svc.get("cap_drop") or []): - bad.append(f"{name}: user root without cap_drop: [ALL]") - if "no-new-privileges:true" not in (svc.get("security_opt") or []): - bad.append(f"{name}: user root without no-new-privileges") - - if bad: - raise SystemExit("rooted services not neutered:\n" + "\n".join(bad)) - - print("all rooted prod services drop capabilities") - EOF - - name: Guardrail — prod rejects unpinned image tags (dev.sh fail-closed) if: steps.filter.outputs.code == 'true' + # Single-source: the same script runs in the local pre-push gate, + # so CI and local guardrails cannot drift. + run: ../scripts/validate-guardrails.sh prod-image-tags working-directory: infra/compose/compose - env: - STACK: prod - WITH_GLITCHTIP: "0" - WITH_OBSERVABILITY: "0" - POSTGRES_USER: app - POSTGRES_PASSWORD: ci-guardrail-placeholder - POSTGRES_DB: app - JWT_SECRET: ci-guardrail-placeholder-padded-to-32-chars - MFA_ENCRYPTION_KEY: MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY= - FRONTEND_URL: https://example.com - PUBLIC_API_URL: https://example.com/api - PUBLIC_UI_HOST: example.com - ACME_EMAIL: ops@example.com - # Required since the valkey-auth hardening: the prod guard - # checks VALKEY_PASSWORD before the image-tag checks this - # step exercises. - VALKEY_PASSWORD: ci-guardrail-placeholder - run: | - chmod +x dev.sh - - # 1. Missing tags must fail closed and name the missing var. - if output=$(./dev.sh config --quiet 2>&1); then - echo "FAIL: prod accepted unset image tags"; exit 1 - fi - echo "$output" | grep -q "API_IMAGE_TAG" || { - echo "FAIL: error does not mention API_IMAGE_TAG"; echo "$output"; exit 1 - } - - # 2. latest must be rejected explicitly. - if output=$(API_IMAGE_TAG=latest UI_IMAGE_TAG=latest ./dev.sh config --quiet 2>&1); then - echo "FAIL: prod accepted latest image tags"; exit 1 - fi - echo "$output" | grep -q "must be pinned in prod" || { - echo "FAIL: latest rejection message missing"; echo "$output"; exit 1 - } - - # 3. Pinned tags pass. - API_IMAGE_TAG=v0.1.0 UI_IMAGE_TAG=v0.1.0 ./dev.sh config --quiet - echo "prod image-tag pinning fail-closed checks passed" - - name: Validate — dev + observability if: steps.filter.outputs.code == 'true' working-directory: infra/compose/compose @@ -376,7 +215,14 @@ jobs: if: steps.filter.outputs.code == 'true' run: | # Strict: error on warnings, follow `source` directives. - shellcheck -x -S warning infra/compose/compose/dev.sh infra/compose/scripts/*.sh + # Root gate scripts (scripts/ci/*, stack-*.sh, setup.sh) are the + # repo's primary local defense — they get the same bar as infra. + shellcheck -x -S warning \ + infra/compose/compose/dev.sh \ + infra/compose/scripts/*.sh \ + scripts/*.sh \ + scripts/ci/*.sh \ + setup.sh prod-image-build: name: build prod images (sanity) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b8d9de3e..62717394 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,7 +26,11 @@ Compose stack up: Postgres, Valkey, api-dev with migrations applied, ui-dev on `localhost:7331`, OpenAPI client generated. No local Bun or Postgres needed. -Sign in at `http://localhost:7331` as `demo@example.com / password123`. +Create an account at `http://localhost:7331` — signup is open in dev and +the verification email lands in the local Mailpit (or use the seeded demo +account: uncomment `SUPERUSER_EMAIL` / `SUPERUSER_PASSWORD` in +`infra/compose/compose/.env` **before** first boot and the migrator seeds +it for you). ## The merge bar diff --git a/apps/api/AGENT_CONTRACT.md b/apps/api/AGENT_CONTRACT.md index dafd026c..337e9465 100644 --- a/apps/api/AGENT_CONTRACT.md +++ b/apps/api/AGENT_CONTRACT.md @@ -46,7 +46,7 @@ After schema changes: `bun run db:generate && bun run db:migrate`, commit the SQ ## ESLint plugins -`bun run check` runs 14 custom plugins on top of typescript-eslint +`bun run check` runs 16 custom plugins on top of typescript-eslint strict-type-checked. Each one catches a specific class of mistake the template doesn't tolerate. @@ -65,6 +65,8 @@ template doesn't tolerate. | `oauth-security` | OAuth state stored in Valkey (not cookies); PKCE on OIDC providers; bounded state TTL | | `stripe-webhooks` | handlers verify the signature header; no parsed body before verification; idempotent | | `bullmq` | workers implement `close` + listen on `failed`; constant job names; queue/job options set `removeOnComplete`/`removeOnFail`/`attempts` | +| `code-flow` | prefer early returns; no bare `Date.now()` / `new Date()` outside the canonical time helpers | +| `comment-hygiene` | no historical ("used to be…") or narration ("now we call…") comments — comments explain why, not what | | `test-conventions` | no committed `.only` / `fdescribe`; tests route DB through `tests/helpers/db`; every test mirrors a source file | | `scripts/lint-meta/` ([RULES.md](scripts/lint-meta/RULES.md)) | Static repo guardrails: source-text bans, CI parity, env cascade, cross-repo imports | diff --git a/apps/api/scripts/lint-meta/RULES.md b/apps/api/scripts/lint-meta/RULES.md index f48583d2..dd28cbe3 100644 --- a/apps/api/scripts/lint-meta/RULES.md +++ b/apps/api/scripts/lint-meta/RULES.md @@ -21,10 +21,12 @@ Run `bun run lint:meta --list-rules` for the machine-readable list from the regi | `github-actions-permissions` | ci | no | GitHub Actions workflows require permissions block and SHA-pinned uses: refs. | | `github-actions-permissions:verify` | ci | no | Pinned action SHAs resolve on github.com (lint:meta:verify only). | | `github-actions-timeout-required` | ci | no | GitHub Actions jobs require an explicit timeout-minutes (reusable-workflow calls exempt). | +| `github-actions-bun-cache` | ci | no | Workflows running bun install must cache ~/.bun/install/cache. | | `github-actions-concurrency-explicit` | ci | no | Workflows with a concurrency block must set cancel-in-progress explicitly. | | `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. | | `pre-push-ci-parity` | ci | no | CI workflow must include every command listed in scripts/ci/pre-push.manifest.json. | +| `tofu-bootstrap-hardening` | ci | no | infra/bootstrap must keep its hardening invariants: server lifecycle guard, no world-open variable defaults, no curl-pipe-sh. | | `engine-pin-parity` | ci | no | Bun version pin must stay aligned across package.json, Docker, and CI. | | `dockerfile-base-image-sha-pin` | ci | no | Dockerfile base images must be pinned by @sha256 digest, not tag alone. | | `env-cascade-drift` | env | no | TypeBox env schema keys must align with .env.example documentation. | @@ -34,6 +36,8 @@ Run `bun run lint:meta --list-rules` for the machine-readable list from the regi | `no-inline-lint-disable` | source-text | no | Inline ESLint disables are not allowed. | | `no-ts-ignore` | source-text | no | TypeScript suppression comments are not allowed. | | `canonical-helpers-single-home` | source-text | no | Helpers in the canonical registry must only be declared in their single source-of-truth file. | +| `docs-no-retired-credentials` | source-text | no | Documentation prose must not reference retired default credentials. | +| `external-client-timeout` | source-text | no | SDK clients (Stripe/OpenAI/Anthropic) need a timeout option; fetch() in src needs an AbortSignal. | | `no-raw-role-literal` | source-text | no | Use ROLE.* from acl.constants.ts instead of raw owner/admin/member/viewer string literals. | | `routes-require-test-sibling` | testing | no | Route modules must ship with a matching HTTP-level test under tests/api/. | | `logic-files-require-test-sibling` | testing | no | Logic modules must ship with a matching tests/**/*.test.ts sibling. | @@ -42,3 +46,4 @@ Run `bun run lint:meta --list-rules` for the machine-readable list from the regi | `eslint-config-no-warn` | config | no | ESLint severities must be "error" or "off", not "warn". | | `eslint-override-paths-exist` | config | no | Literal test-file paths in eslint.config.* overrides must exist on disk. | | `tsconfig-include-paths-exist` | config | no | Literal tsconfig include/files entries must point at files that exist (globs exempt); checks this app and sibling apps. | +| `eslint-plugin-contract-parity` | config | no | Every installed @boring-stack-pkg eslint plugin must appear in AGENT_CONTRACT.md, and vice versa. | diff --git a/apps/api/scripts/lint-meta/cli.ts b/apps/api/scripts/lint-meta/cli.ts index 0bcebc20..792657bc 100644 --- a/apps/api/scripts/lint-meta/cli.ts +++ b/apps/api/scripts/lint-meta/cli.ts @@ -24,19 +24,23 @@ import { checkPackageOverrideParity } from "./rules/supply-chain/package-overrid import { checkSharedToolVersionParity } from "./rules/supply-chain/shared-tool-version-parity"; import { checkEslintConfigNoWarn } from "./rules/config/eslint-config-no-warn"; import { checkEslintOverridePathsExist } from "./rules/config/eslint-override-paths-exist"; +import { checkEslintPluginContractParity } from "./rules/config/eslint-plugin-contract-parity"; import { checkTsconfigIncludePathsExist } from "./rules/config/tsconfig-include-paths-exist"; import { checkEnvSchemaDrift } from "./rules/env/env-cascade-drift"; import { checkNoDirectProcessEnv } from "./rules/env/no-direct-process-env"; import { checkGeneratedArtifactContracts } from "./rules/artifacts/generated-artifact-contract"; import { checkDockerfileBaseImageShaPin } from "./rules/ci/dockerfile-base-image-sha-pin"; import { checkEnginePinParity } from "./rules/ci/engine-pin-parity"; +import { checkWorkflowBunCache } from "./rules/ci/github-actions-bun-cache"; import { checkWorkflowConcurrencyExplicit } from "./rules/ci/github-actions-concurrency-explicit"; import { checkWorkflowExpressionSyntax } from "./rules/ci/github-actions-expression-syntax"; import { checkWorkflowServiceImageDigestPin } from "./rules/ci/github-actions-service-image-digest-pin"; import { checkWorkflowShas } from "./rules/ci/github-actions-permissions"; import { checkWorkflowTimeouts } from "./rules/ci/github-actions-timeout-required"; import { checkPrePushParity } from "./rules/ci/pre-push-ci-parity"; +import { checkTofuBootstrapHardening } from "./rules/ci/tofu-bootstrap-hardening"; import { checkCanonicalHelpersSingleHome } from "./rules/source-text/canonical-helpers-single-home"; +import { checkExternalClientTimeouts } from "./rules/source-text/external-client-timeout"; import { checkForbiddenText } from "./rules/source-text/forbidden-text"; import { checkNoRawRoleLiterals } from "./rules/source-text/no-raw-role-literals"; import { checkLogicFilesHaveTests } from "./rules/testing/logic-files-require-test-sibling"; @@ -102,8 +106,10 @@ export { checkDockerfileBaseImageShaPin, checkEnginePinParity, checkExactDependencyVersions, + checkExternalClientTimeouts, checkEslintConfigNoWarn, checkEslintOverridePathsExist, + checkEslintPluginContractParity, checkEnvSchemaDrift, checkForbiddenText, checkGeneratedArtifactContracts, @@ -114,8 +120,10 @@ export { checkPrePushParity, checkRouteFilesHaveTests, checkSharedToolVersionParity, + checkTofuBootstrapHardening, checkTouchedTests, checkTsconfigIncludePathsExist, + checkWorkflowBunCache, checkWorkflowConcurrencyExplicit, checkWorkflowExpressionSyntax, checkWorkflowServiceImageDigestPin, diff --git a/apps/api/scripts/lint-meta/registry.ts b/apps/api/scripts/lint-meta/registry.ts index 6ba4acb7..c958233f 100644 --- a/apps/api/scripts/lint-meta/registry.ts +++ b/apps/api/scripts/lint-meta/registry.ts @@ -1,18 +1,23 @@ import { generatedArtifactContractRule } from "./rules/artifacts/generated-artifact-contract"; import { dockerfileBaseImageShaPinRule } from "./rules/ci/dockerfile-base-image-sha-pin"; import { enginePinParityRule } from "./rules/ci/engine-pin-parity"; +import { githubActionsBunCacheRule } from "./rules/ci/github-actions-bun-cache"; import { githubActionsConcurrencyExplicitRule } from "./rules/ci/github-actions-concurrency-explicit"; import { githubActionsExpressionSyntaxRule } from "./rules/ci/github-actions-expression-syntax"; import { githubActionsPermissionsRule } from "./rules/ci/github-actions-permissions"; import { githubActionsServiceImageDigestPinRule } from "./rules/ci/github-actions-service-image-digest-pin"; import { githubActionsTimeoutRequiredRule } from "./rules/ci/github-actions-timeout-required"; import { prePushCiParityRule } from "./rules/ci/pre-push-ci-parity"; +import { tofuBootstrapHardeningRule } from "./rules/ci/tofu-bootstrap-hardening"; import { eslintConfigNoWarnRule } from "./rules/config/eslint-config-no-warn"; +import { eslintPluginContractParityRule } from "./rules/config/eslint-plugin-contract-parity"; import { eslintOverridePathsExistRule } from "./rules/config/eslint-override-paths-exist"; import { tsconfigIncludePathsExistRule } from "./rules/config/tsconfig-include-paths-exist"; import { envCascadeDriftRule } from "./rules/env/env-cascade-drift"; import { noDirectProcessEnvRule } from "./rules/env/no-direct-process-env"; import { canonicalHelpersSingleHomeRule } from "./rules/source-text/canonical-helpers-single-home"; +import { docsNoRetiredCredentialsRule } from "./rules/source-text/docs-no-retired-credentials"; +import { externalClientTimeoutRule } from "./rules/source-text/external-client-timeout"; import { forbiddenTextRule } from "./rules/source-text/forbidden-text"; import { noRawRoleLiteralsRule } from "./rules/source-text/no-raw-role-literals"; import { noOverlappingLibsRule } from "./rules/supply-chain/no-overlapping-libs"; @@ -32,10 +37,12 @@ export const META_RULES: readonly IMetaRule[] = [ sharedToolVersionParityRule, githubActionsPermissionsRule, githubActionsTimeoutRequiredRule, + githubActionsBunCacheRule, githubActionsConcurrencyExplicitRule, githubActionsExpressionSyntaxRule, githubActionsServiceImageDigestPinRule, prePushCiParityRule, + tofuBootstrapHardeningRule, enginePinParityRule, dockerfileBaseImageShaPinRule, envCascadeDriftRule, @@ -43,6 +50,8 @@ export const META_RULES: readonly IMetaRule[] = [ generatedArtifactContractRule, forbiddenTextRule, canonicalHelpersSingleHomeRule, + docsNoRetiredCredentialsRule, + externalClientTimeoutRule, noRawRoleLiteralsRule, routesRequireTestSiblingRule, logicFilesRequireTestSiblingRule, @@ -51,4 +60,5 @@ export const META_RULES: readonly IMetaRule[] = [ eslintConfigNoWarnRule, eslintOverridePathsExistRule, tsconfigIncludePathsExistRule, + eslintPluginContractParityRule, ]; diff --git a/apps/api/scripts/lint-meta/rules/ci/github-actions-bun-cache.ts b/apps/api/scripts/lint-meta/rules/ci/github-actions-bun-cache.ts new file mode 100644 index 00000000..938d3637 --- /dev/null +++ b/apps/api/scripts/lint-meta/rules/ci/github-actions-bun-cache.ts @@ -0,0 +1,40 @@ +import { readFileSync } from "node:fs"; + +import type { IMetaRule, IViolation } from "../../types"; + +/* + * Every `bun install` in CI re-downloads the dependency tree unless + * ~/.bun/install/cache is restored — 30-90s wasted per run, multiplied + * across workflows and pushes. One workflow carried the cache step and + * seven didn't; this pins the convention: bun install ⇒ actions/cache. + */ +export function checkWorkflowBunCache(file: string): IViolation[] { + const text = readFileSync(file, "utf8"); + + if (!text.includes("bun install")) { + return []; + } + + if (text.includes("actions/cache@")) { + return []; + } + + return [ + { + file, + rule: "github-actions-bun-cache", + message: + "Workflow runs `bun install` without an actions/cache step for ~/.bun/install/cache — copy the cache block from apps-api-ci.yml (keyed on the relevant bun.lock files).", + }, + ]; +} + +/** bun install in a workflow requires a bun cache step. */ +export const githubActionsBunCacheRule: IMetaRule = { + id: "github-actions-bun-cache", + category: "ci", + description: "Workflows running bun install must cache ~/.bun/install/cache.", + run({ workflowFiles }) { + return workflowFiles.flatMap(checkWorkflowBunCache); + }, +}; diff --git a/apps/api/scripts/lint-meta/rules/ci/tofu-bootstrap-hardening.ts b/apps/api/scripts/lint-meta/rules/ci/tofu-bootstrap-hardening.ts new file mode 100644 index 00000000..38530119 --- /dev/null +++ b/apps/api/scripts/lint-meta/rules/ci/tofu-bootstrap-hardening.ts @@ -0,0 +1,115 @@ +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { dirname, extname, join } from "node:path"; + +import type { IMetaRule, IViolation } from "../../types"; + +const TOFU_EXTENSIONS = new Set([".tf", ".tftpl"]); +const SERVER_RESOURCE = 'resource "hcloud_server"'; +const LIFECYCLE_GUARD_REGEX = + /lifecycle\s*\{[^}]*ignore_changes\s*=\s*\[[^\]]*user_data/su; +const OPEN_DEFAULT_REGEX = /default\s*=\s*\[[^\]]*0\.0\.0\.0\/0/u; +const CURL_PIPE_SH_REGEX = + /\b(?:curl|wget)\b[^|\n]*\|\s*(?:sudo\s+)?(?:ba)?sh\b/u; + +function collectTofuFiles(dir: string): string[] { + const out: string[] = []; + let entries: string[]; + + try { + entries = readdirSync(dir); + } catch { + return out; + } + + for (const entry of entries) { + if (entry === ".terraform") { + continue; + } + + const full = join(dir, entry); + + let isDir: boolean; + + try { + isDir = statSync(full).isDirectory(); + } catch { + continue; + } + + if (isDir) { + out.push(...collectTofuFiles(full)); + continue; + } + + if (TOFU_EXTENSIONS.has(extname(full))) { + out.push(full); + } + } + + return out; +} + +/* + * Three hardening invariants for the OpenTofu bootstrap, learned from the + * 2026-06-04 audit: + * - hcloud_server without lifecycle ignore_changes=[user_data] is one + * tfvars edit away from server REPLACEMENT (cloud-init interpolates + * tfvars; Hetzner replaces on user_data change; the prod data dies + * with the server). + * - variable defaults containing 0.0.0.0/0 silently open admin ports + * to the world; opening must be an explicit operator choice. + * - curl|sh in cloud-init executes unverified remote code as root at + * first boot. + */ +export function checkTofuBootstrapHardening(root: string): IViolation[] { + const violations: IViolation[] = []; + const bootstrapDir = join(dirname(dirname(root)), "infra", "bootstrap"); + + if (!existsSync(bootstrapDir)) { + return violations; + } + + for (const file of collectTofuFiles(bootstrapDir)) { + const text = readFileSync(file, "utf8"); + + if (text.includes(SERVER_RESOURCE) && !LIFECYCLE_GUARD_REGEX.test(text)) { + violations.push({ + file, + rule: "tofu-server-lifecycle-guard", + message: + "hcloud_server must declare lifecycle { ignore_changes = [user_data] } — without it any cloud-init/tfvars change replaces the server and destroys its volumes.", + }); + } + + for (const [index, line] of text.split("\n").entries()) { + if (OPEN_DEFAULT_REGEX.test(line)) { + violations.push({ + file, + rule: "tofu-no-open-admin-defaults", + message: `Line ${String(index + 1)}: a variable default contains 0.0.0.0/0 — world-open access must be an explicit operator choice, never a default.`, + }); + } + + if (CURL_PIPE_SH_REGEX.test(line)) { + violations.push({ + file, + rule: "no-curl-pipe-sh", + message: `Line ${String(index + 1)}: curl/wget piped to a shell executes unverified remote code — install via a GPG-verified package repo instead.`, + }); + } + } + } + + return violations; +} + +/** Bootstrap IaC hardening invariants (lifecycle guard, no open defaults, no curl|sh). */ +export const tofuBootstrapHardeningRule: IMetaRule = { + id: "tofu-bootstrap-hardening", + category: "ci", + description: + "infra/bootstrap must keep its hardening invariants: server lifecycle guard, no world-open variable defaults, no curl-pipe-sh.", + run({ root }) { + return checkTofuBootstrapHardening(root); + }, +}; diff --git a/apps/api/scripts/lint-meta/rules/config/eslint-plugin-contract-parity.ts b/apps/api/scripts/lint-meta/rules/config/eslint-plugin-contract-parity.ts new file mode 100644 index 00000000..c3d701d2 --- /dev/null +++ b/apps/api/scripts/lint-meta/rules/config/eslint-plugin-contract-parity.ts @@ -0,0 +1,84 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import type { IMetaRule, IViolation } from "../../types"; + +const PLUGIN_PREFIX = "@boring-stack-pkg/eslint-plugin-"; +const CONTRACT_FILE = "AGENT_CONTRACT.md"; + +/* + * AGENT_CONTRACT.md is the first document agents read; its plugin table + * is the canonical map of what `bun run check` enforces. Two installed + * plugins (code-flow, comment-hygiene) were missing from it — and one + * row referenced a plugin that was never installed in that app — so + * the contract silently drifted from package.json. Parity both ways: + * every installed plugin must be mentioned, and every mentioned plugin + * must be installed. + */ +export function checkEslintPluginContractParity(root: string): IViolation[] { + const violations: IViolation[] = []; + const contractPath = join(root, CONTRACT_FILE); + const packagePath = join(root, "package.json"); + + if (!existsSync(contractPath) || !existsSync(packagePath)) { + return violations; + } + + const contract = readFileSync(contractPath, "utf8"); + const parsed: unknown = JSON.parse(readFileSync(packagePath, "utf8")); + + if (typeof parsed !== "object" || parsed === null) { + return violations; + } + + const devDependencies = + "devDependencies" in parsed ? parsed.devDependencies : undefined; + + if (typeof devDependencies !== "object" || devDependencies === null) { + return violations; + } + + const installed = Object.keys(devDependencies) + .filter((name) => name.startsWith(PLUGIN_PREFIX)) + .map((name) => name.slice(PLUGIN_PREFIX.length)); + + for (const shortName of installed) { + if (!contract.includes(shortName)) { + violations.push({ + file: contractPath, + rule: "eslint-plugin-contract-parity", + message: `Installed plugin \`${PLUGIN_PREFIX}${shortName}\` is not documented in ${CONTRACT_FILE} — the contract's plugin table must cover everything \`bun run check\` enforces.`, + }); + } + } + + const mentioned = contract.matchAll( + /@boring-stack-pkg\/eslint-plugin-([a-z0-9-]+)/gu + ); + const installedSet = new Set(installed); + + for (const match of mentioned) { + const shortName = match[1]; + + if (shortName !== undefined && !installedSet.has(shortName)) { + violations.push({ + file: contractPath, + rule: "eslint-plugin-contract-parity", + message: `${CONTRACT_FILE} documents \`${PLUGIN_PREFIX}${shortName}\` but it is not installed in this app's package.json — remove or correct the row.`, + }); + } + } + + return violations; +} + +/** AGENT_CONTRACT.md's plugin table must match package.json, both ways. */ +export const eslintPluginContractParityRule: IMetaRule = { + id: "eslint-plugin-contract-parity", + category: "config", + description: + "Every installed @boring-stack-pkg eslint plugin must appear in AGENT_CONTRACT.md, and vice versa.", + run({ root }) { + return checkEslintPluginContractParity(root); + }, +}; diff --git a/apps/api/scripts/lint-meta/rules/source-text/docs-no-retired-credentials.ts b/apps/api/scripts/lint-meta/rules/source-text/docs-no-retired-credentials.ts new file mode 100644 index 00000000..67154973 --- /dev/null +++ b/apps/api/scripts/lint-meta/rules/source-text/docs-no-retired-credentials.ts @@ -0,0 +1,84 @@ +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { dirname, extname, join } from "node:path"; + +import type { IMetaRule, IViolation } from "../../types"; + +/* + * Credentials that once shipped as documented defaults and were removed + * (2026-06-03: dev.sh now generates random passwords). Docs teaching a + * retired default trains users to hardcode it back — every literal here + * must stay banned from documentation prose forever. + */ +const RETIRED_CREDENTIALS = ["admin123456", "admin / change-me"] as const; + +const DOC_EXTENSIONS = new Set([".md", ".mdx"]); + +function collectDocFiles(dir: string): string[] { + const out: string[] = []; + let entries: string[]; + + try { + entries = readdirSync(dir); + } catch { + return out; + } + + for (const entry of entries) { + const full = join(dir, entry); + + let isDir: boolean; + + try { + isDir = statSync(full).isDirectory(); + } catch { + continue; + } + + if (isDir) { + out.push(...collectDocFiles(full)); + continue; + } + + if (DOC_EXTENSIONS.has(extname(full))) { + out.push(full); + } + } + + return out; +} + +export function checkDocsNoRetiredCredentials(root: string): IViolation[] { + const violations: IViolation[] = []; + const docsContent = join(dirname(root), "docs", "src", "content"); + + if (!existsSync(docsContent)) { + return violations; + } + + for (const file of collectDocFiles(docsContent)) { + const text = readFileSync(file, "utf8"); + + for (const literal of RETIRED_CREDENTIALS) { + if (text.includes(literal)) { + violations.push({ + file, + rule: "docs-no-retired-credentials", + message: `Documentation references the retired default credential \`${literal}\` — it was removed from the stack (random per-install generation) and teaching it invites users to hardcode it back.`, + }); + } + } + } + + return violations; +} + +/** Docs must never teach credentials the stack has retired. */ +export const docsNoRetiredCredentialsRule: IMetaRule = { + id: "docs-no-retired-credentials", + category: "source-text", + description: + "Documentation prose must not reference retired default credentials.", + run({ root }) { + return checkDocsNoRetiredCredentials(root); + }, +}; diff --git a/apps/api/scripts/lint-meta/rules/source-text/external-client-timeout.ts b/apps/api/scripts/lint-meta/rules/source-text/external-client-timeout.ts new file mode 100644 index 00000000..a808e934 --- /dev/null +++ b/apps/api/scripts/lint-meta/rules/source-text/external-client-timeout.ts @@ -0,0 +1,73 @@ +import { readFileSync } from "node:fs"; + +import type { IMetaRule, IViolation } from "../../types"; + +/* + * Every external client gets an explicit time budget — implicit SDK + * defaults range from 80s (Stripe) to 10min (Anthropic), and a raw + * fetch without a signal waits for the socket. A hung upstream must + * never pin a request worker. Two checks: + * - known SDK constructors must pass a `timeout` option within the + * construction expression; + * - `fetch(` calls in src must carry `signal` in the same statement + * (window: the constructor/call plus the following ~15 lines). + */ +const SDK_CONSTRUCTORS = ["new Stripe(", "new OpenAI(", "new Anthropic("]; +const WINDOW_LINES = 15; + +function windowAfter(lines: string[], index: number): string { + return lines.slice(index, index + WINDOW_LINES).join("\n"); +} + +export function checkExternalClientTimeouts( + sourceFiles: readonly string[] +): IViolation[] { + const violations: IViolation[] = []; + + for (const file of sourceFiles) { + if (!file.includes("/src/") || file.endsWith(".test.ts")) { + continue; + } + + const lines = readFileSync(file, "utf8").split("\n"); + + for (const [index, line] of lines.entries()) { + for (const constructor of SDK_CONSTRUCTORS) { + if ( + line.includes(constructor) && + !windowAfter(lines, index).includes("timeout") + ) { + violations.push({ + file, + rule: "external-client-timeout", + message: `Line ${String(index + 1)}: ${constructor.slice(4, -1)} constructed without a timeout option — implicit SDK defaults (80s–10min) can pin request workers on a slow upstream.`, + }); + } + } + + if ( + /(?:await|return)\s+fetch\(/u.test(line) && + !windowAfter(lines, index).includes("signal") + ) { + violations.push({ + file, + rule: "external-client-timeout", + message: `Line ${String(index + 1)}: fetch() without an AbortSignal — a hung upstream waits for the socket lifetime. Pass signal: AbortSignal.timeout(...).`, + }); + } + } + } + + return violations; +} + +/** External SDK clients and raw fetch calls must carry explicit timeouts. */ +export const externalClientTimeoutRule: IMetaRule = { + id: "external-client-timeout", + category: "source-text", + description: + "SDK clients (Stripe/OpenAI/Anthropic) need a timeout option; fetch() in src needs an AbortSignal.", + run({ sourceFiles }) { + return checkExternalClientTimeouts(sourceFiles); + }, +}; diff --git a/apps/api/src/api/billing/billing.service.ts b/apps/api/src/api/billing/billing.service.ts index 5494b26b..ca75c549 100644 --- a/apps/api/src/api/billing/billing.service.ts +++ b/apps/api/src/api/billing/billing.service.ts @@ -65,6 +65,8 @@ const isOlderStripeEvent = ( return Date.parse(lastEventAt) > eventCreated * 1000; }; +const STRIPE_REQUEST_TIMEOUT_MS = 10_000; + export class BillingService { private readonly stripe: Stripe; @@ -75,7 +77,15 @@ export class BillingService { ); } - this.stripe = new Stripe(env.STRIPE_SECRET_KEY); + /* + * Explicit budget — the SDK's implicit 80s default would hold + * checkout/portal request handlers hostage to a slow Stripe API. + * The SDK retries idempotent calls internally, so each attempt + * gets this budget. + */ + this.stripe = new Stripe(env.STRIPE_SECRET_KEY, { + timeout: STRIPE_REQUEST_TIMEOUT_MS, + }); } async listPlans(): Promise { diff --git a/apps/api/src/clients/valkey/valkey.utils.ts b/apps/api/src/clients/valkey/valkey.utils.ts index 5f01f8eb..7837f442 100644 --- a/apps/api/src/clients/valkey/valkey.utils.ts +++ b/apps/api/src/clients/valkey/valkey.utils.ts @@ -16,6 +16,7 @@ import { env } from "../../config/env"; */ const APP_CONNECT_TIMEOUT_MS = 2000; +const APP_COMMAND_TIMEOUT_MS = 1_000; const baseConnection = () => ({ host: env.VALKEY_HOST, @@ -54,4 +55,10 @@ export const getValkeyAppClientOptions = ( maxRetriesPerRequest: 1, lazyConnect: true, connectTimeout: overrides.connectTimeout ?? APP_CONNECT_TIMEOUT_MS, + /* + * connectTimeout bounds the handshake; commandTimeout bounds every + * command after it. Without it an established-but-slow Valkey delays + * each cache / rate-limit / OAuth-state operation unboundedly. + */ + commandTimeout: APP_COMMAND_TIMEOUT_MS, }); diff --git a/apps/api/src/lib/ai/providers/anthropic/anthropic.ts b/apps/api/src/lib/ai/providers/anthropic/anthropic.ts index ecd6809d..518e0084 100644 --- a/apps/api/src/lib/ai/providers/anthropic/anthropic.ts +++ b/apps/api/src/lib/ai/providers/anthropic/anthropic.ts @@ -11,6 +11,8 @@ import type { import type { IAnthropicMessagesClient } from "./anthropic.types"; import { extractText, toAnthropicMessages } from "./anthropic.utils"; +const AI_REQUEST_TIMEOUT_MS = 60_000; + export class AnthropicProvider implements IAIProvider { public readonly providerName: AIProviderName = "anthropic"; private readonly messages: IAnthropicMessagesClient; @@ -22,7 +24,8 @@ export class AnthropicProvider implements IAIProvider { return; } - const client = new Anthropic({ apiKey }); + // The SDK default is 10 minutes — far past any request budget. + const client = new Anthropic({ apiKey, timeout: AI_REQUEST_TIMEOUT_MS }); this.messages = { create: (body) => client.messages.create(body), diff --git a/apps/api/src/lib/ai/providers/openai/openai.ts b/apps/api/src/lib/ai/providers/openai/openai.ts index 3db74a37..d8bc49a3 100644 --- a/apps/api/src/lib/ai/providers/openai/openai.ts +++ b/apps/api/src/lib/ai/providers/openai/openai.ts @@ -16,6 +16,8 @@ import { toOpenAIMessages } from "./openai.utils"; * the OpenAI v1 wire format — point `baseURL` at OpenRouter, Ollama, vLLM, * Together, Groq, LM Studio, etc. and it just works. */ +const AI_REQUEST_TIMEOUT_MS = 60_000; + export class OpenAIProvider implements IAIProvider { public readonly providerName: AIProviderName = "openai"; private readonly client: OpenAI; @@ -23,6 +25,8 @@ export class OpenAIProvider implements IAIProvider { constructor(options: IOpenAIProviderOptions) { this.client = new OpenAI({ apiKey: options.apiKey, + // The SDK default is 600s — far past any request budget. + timeout: AI_REQUEST_TIMEOUT_MS, baseURL: options.baseURL ?? OPENAI_DEFAULT_BASE_URL, ...(options.defaultHeaders !== undefined && { defaultHeaders: options.defaultHeaders, diff --git a/apps/api/src/lib/email/providers/cloudflare.ts b/apps/api/src/lib/email/providers/cloudflare.ts index 61fbd30d..12bd7619 100644 --- a/apps/api/src/lib/email/providers/cloudflare.ts +++ b/apps/api/src/lib/email/providers/cloudflare.ts @@ -25,6 +25,8 @@ import { * must be set in production — the env validator enforces this. */ +const CLOUDFLARE_REQUEST_TIMEOUT_MS = 10_000; + export class CloudflareEmailService implements IEmailService { public readonly providerName = "cloudflare" as const; private readonly endpoint: string; @@ -80,6 +82,13 @@ export class CloudflareEmailService implements IEmailService { return await retryWithBackoff(async () => { const response = await fetch(this.endpoint, { method: "POST", + /* + * Per-attempt budget: without a signal a hung Cloudflare API + * pins the caller for the socket lifetime, multiplied by the + * retry wrapper. Aborted attempts surface as a normal fetch + * rejection and consume one retry like any transient error. + */ + signal: AbortSignal.timeout(CLOUDFLARE_REQUEST_TIMEOUT_MS), headers: { "content-type": "application/json", authorization: `Bearer ${this.apiToken}`, diff --git a/apps/api/src/lib/oauth/oauth.utils.ts b/apps/api/src/lib/oauth/oauth.utils.ts index 583d4e1c..5c93b3d2 100644 --- a/apps/api/src/lib/oauth/oauth.utils.ts +++ b/apps/api/src/lib/oauth/oauth.utils.ts @@ -118,12 +118,22 @@ export const splitDisplayName = ( }; }; +const OAUTH_FETCH_TIMEOUT_MS = 10_000; + /** Wraps `fetch` for OAuth-shaped JSON; throws on non-2xx. */ export const fetchJson = async ( url: string, init: RequestInit ): Promise => { - const res = await fetch(url, init); + /* + * Bounded budget — these calls sit on the OAuth callback request + * path; a hung IdP token endpoint must not pin the handler. Callers + * may override via init.signal. + */ + const res = await fetch(url, { + signal: AbortSignal.timeout(OAUTH_FETCH_TIMEOUT_MS), + ...init, + }); if (!res.ok) { const body = await res.text(); diff --git a/apps/api/tests/lib/email/providers/cloudflare.test.ts b/apps/api/tests/lib/email/providers/cloudflare.test.ts index f3060981..50b04f18 100644 --- a/apps/api/tests/lib/email/providers/cloudflare.test.ts +++ b/apps/api/tests/lib/email/providers/cloudflare.test.ts @@ -132,6 +132,24 @@ describe("CloudflareEmailService", () => { expect(body.text).toBe("body"); }); + test("every attempt carries an abort signal (bounded request budget)", async () => { + const { calls } = installFakeFetch( + new Response(successBody("msg_abc123"), { status: 200 }) + ); + + const svc = new CloudflareEmailService(ACCOUNT_ID, API_TOKEN); + + await svc.send({ to: TO, subject: "subj", html: "

body

" }); + + const init = calls[0]?.init; + + if (!init) { + throw new Error("expected a captured fetch call"); + } + + expect(init.signal).toBeInstanceOf(AbortSignal); + }); + test("omits `text` from the body when not provided", async () => { const { calls } = installFakeFetch( new Response(successBody("msg_abc123"), { status: 200 }) diff --git a/apps/api/tests/lint-meta/lint-meta.test.ts b/apps/api/tests/lint-meta/lint-meta.test.ts index 4a5aee95..404888ea 100644 --- a/apps/api/tests/lint-meta/lint-meta.test.ts +++ b/apps/api/tests/lint-meta/lint-meta.test.ts @@ -21,13 +21,16 @@ import { checkEnvSchemaDrift, checkEslintConfigNoWarn, checkEslintOverridePathsExist, + checkEslintPluginContractParity, checkExactDependencyVersions, + checkExternalClientTimeouts, checkForbiddenText, checkLogicFilesHaveTests, checkNoDirectProcessEnv, checkRouteFilesHaveTests, checkTouchedTests, checkTsconfigIncludePathsExist, + checkWorkflowBunCache, checkWorkflowConcurrencyExplicit, checkWorkflowExpressionSyntax, checkWorkflowServiceImageDigestPin, @@ -40,6 +43,7 @@ import { checkPackageOverrideParity, checkPrePushParity, checkSharedToolVersionParity, + checkTofuBootstrapHardening, } from "../../scripts/lint-meta/cli"; const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "fixtures"); @@ -412,6 +416,42 @@ describe("checkWorkflowConcurrencyExplicit", () => { }); }); +describe("checkWorkflowBunCache", () => { + test("flags bun install without a cache step; passes cached and bun-free workflows", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-bun-cache-")); + + try { + const uncached = join(root, "uncached.yml"); + + writeFileSync( + uncached, + "jobs:\n x:\n steps:\n - run: bun install\n" + ); + + expect(checkWorkflowBunCache(uncached).map((row) => row.rule)).toContain( + "github-actions-bun-cache" + ); + + const cached = join(root, "cached.yml"); + + writeFileSync( + cached, + "jobs:\n x:\n steps:\n - uses: actions/cache@abc\n - run: bun install\n" + ); + + expect(checkWorkflowBunCache(cached)).toEqual([]); + + const noBun = join(root, "nobun.yml"); + + writeFileSync(noBun, "jobs: {}\n"); + + expect(checkWorkflowBunCache(noBun)).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe("checkWorkflowServiceImageDigestPin", () => { test("flags a service image without a digest", () => { const root = mkdtempSync(join(tmpdir(), "lint-meta-svc-image-")); @@ -551,6 +591,170 @@ describe("checkWorkflowExpressionSyntax", () => { }); }); +const CONTRACT_MD = "AGENT_CONTRACT.md"; +const PKG_JSON = "package.json"; + +describe("checkExternalClientTimeouts", () => { + test("flags SDK constructors without timeout and bare fetch; passes bounded ones", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-timeouts-")); + + try { + mkdirSync(join(root, "src"), { recursive: true }); + + const bad = join(root, "src", "bad.ts"); + + writeFileSync( + bad, + 'const s = new Stripe(key);\nconst r = await fetch(url, { method: "POST" });\n' + ); + + const rules = checkExternalClientTimeouts([bad]).map((row) => row.rule); + + expect(rules).toHaveLength(2); + + const good = join(root, "src", "good.ts"); + + writeFileSync( + good, + "const s = new Stripe(key, { timeout: 10_000 });\nconst r = await fetch(url, { signal: AbortSignal.timeout(10_000) });\n" + ); + + expect(checkExternalClientTimeouts([good])).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe("checkTofuBootstrapHardening", () => { + function makeBootstrap(root: string, mainTf: string): void { + const dir = join(root, "apps", "self"); + const bootstrapDir = join(root, "infra", "bootstrap"); + + mkdirSync(dir, { recursive: true }); + mkdirSync(bootstrapDir, { recursive: true }); + writeFileSync(join(bootstrapDir, "main.tf"), mainTf); + } + + test("flags missing lifecycle guard, open defaults, and curl|sh", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-tofu-")); + + try { + makeBootstrap( + root, + [ + 'resource "hcloud_server" "main" {', + " user_data = var.cloud_init", + "}", + 'variable "ssh_allowed_ips" {', + ' default = ["0.0.0.0/0"]', + "}", + '# - [bash, -c, "curl -fsSL https://get.docker.com | sh"]', + "", + ].join("\n") + ); + + const rules = checkTofuBootstrapHardening(join(root, "apps", "self")).map( + (row) => row.rule + ); + + expect(rules).toContain("tofu-server-lifecycle-guard"); + expect(rules).toContain("tofu-no-open-admin-defaults"); + expect(rules).toContain("no-curl-pipe-sh"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("passes a guarded server with explicit inputs and verified installs", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-tofu-")); + + try { + makeBootstrap( + root, + [ + 'resource "hcloud_server" "main" {', + " user_data = var.cloud_init", + " lifecycle {", + " ignore_changes = [user_data]", + " }", + "}", + 'variable "ssh_allowed_ips" {', + "}", + "", + ].join("\n") + ); + + expect(checkTofuBootstrapHardening(join(root, "apps", "self"))).toEqual( + [] + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe("checkEslintPluginContractParity", () => { + test("flags installed-but-undocumented and documented-but-missing plugins", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-contract-")); + + try { + writeFileSync( + join(root, PKG_JSON), + JSON.stringify({ + devDependencies: { + "@boring-stack-pkg/eslint-plugin-code-flow": "0.2.0", + "@boring-stack-pkg/eslint-plugin-comment-hygiene": "0.2.0", + }, + }) + ); + writeFileSync( + join(root, CONTRACT_MD), + "| `code-flow` | early returns |\n| `@boring-stack-pkg/eslint-plugin-ghost-plugin` | not installed |\n" + ); + + const messages = checkEslintPluginContractParity(root).map( + (row) => row.message + ); + + expect( + messages.some((message) => message.includes("comment-hygiene")) + ).toBe(true); + expect(messages.some((message) => message.includes("ghost-plugin"))).toBe( + true + ); + expect(messages.some((message) => message.includes("code-flow"))).toBe( + false + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("passes when contract and package.json agree", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-contract-")); + + try { + writeFileSync( + join(root, PKG_JSON), + JSON.stringify({ + devDependencies: { + "@boring-stack-pkg/eslint-plugin-code-flow": "0.2.0", + }, + }) + ); + writeFileSync( + join(root, CONTRACT_MD), + "| `@boring-stack-pkg/eslint-plugin-code-flow` | early returns |\n" + ); + + expect(checkEslintPluginContractParity(root)).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe("checkTsconfigIncludePathsExist", () => { test("flags a literal include entry that does not exist, skips globs and hidden dirs", () => { const root = mkdtempSync(join(tmpdir(), "lint-meta-tsconfig-")); @@ -611,7 +815,7 @@ describe("checkEnginePinParity", () => { } ): void { writeFileSync( - join(root, "package.json"), + join(root, PKG_JSON), JSON.stringify({ engines: options.engines }) ); writeFileSync( @@ -648,7 +852,7 @@ describe("checkEnginePinParity", () => { const monorepo = mkdtempSync(join(tmpdir(), "lint-meta-engine-mono-")); try { - writeFileSync(join(monorepo, "package.json"), JSON.stringify({})); + writeFileSync(join(monorepo, PKG_JSON), JSON.stringify({})); const appRoot = join(monorepo, "apps", "api"); @@ -668,7 +872,7 @@ describe("checkEnginePinParity", () => { ).toBe(true); writeFileSync( - join(monorepo, "package.json"), + join(monorepo, PKG_JSON), JSON.stringify({ engines: { bun: "1.3.14" } }) ); diff --git a/apps/docs/src/content/docs/api/auth.mdx b/apps/docs/src/content/docs/api/auth.mdx index c79a9255..0b83c2c6 100644 --- a/apps/docs/src/content/docs/api/auth.mdx +++ b/apps/docs/src/content/docs/api/auth.mdx @@ -13,7 +13,7 @@ Once verified, the session uses two tokens. An `auth_token` is a 15-minute state ## Verify-before-account -Password signup splits across two endpoints. `POST /auth/register` writes only the pending user row, the argon2id hash, and a single-use verification token. No `app.accounts` row, no membership, no session cookies. The response is a `{ message }` envelope so the SPA can render "check your inbox at user@example.com." `POST /auth/verify-email` flips `users.email_verified_at`, atomically calls `provisionAfterVerification`, and _then_ issues the auth + refresh cookies. That's where the user gets their account. +Password signup splits across two endpoints. `POST /auth/register` writes only the pending user row, the argon2id hash, and a single-use verification token. No `app.accounts` row, no membership, no session cookies. The response is a `{ message }` envelope so the SPA can render "check your inbox at user@example.com." The endpoint is enumeration-safe: an already-registered email gets the **identical 200 response** (no 409, no distinguishable body) — the account owner receives a "you already have an account" notice email with sign-in and reset links instead of a duplicate account. `POST /auth/verify-email` flips `users.email_verified_at`, atomically calls `provisionAfterVerification`, and _then_ issues the auth + refresh cookies. That's where the user gets their account. ```mermaid sequenceDiagram diff --git a/apps/docs/src/content/docs/reference/env-vars.mdx b/apps/docs/src/content/docs/reference/env-vars.mdx index 0cce877c..ea78e33d 100644 --- a/apps/docs/src/content/docs/reference/env-vars.mdx +++ b/apps/docs/src/content/docs/reference/env-vars.mdx @@ -32,7 +32,7 @@ These deserve special handling because they affect security, availability, or st **MFA_ENCRYPTION_KEY**: AES-256-GCM key for TOTP secrets. Once users enrol, losing this key means every MFA user re-enrols from scratch. Generate with `openssl rand -base64 32` and back up to hardware or paper. -**POSTGRES_PASSWORD / VALKEY_PASSWORD**: Data plane secrets. Rotate in production by updating the var, then restarting services. Database passwords should be 32+ random bytes. +**POSTGRES_PASSWORD / VALKEY_PASSWORD**: Data plane secrets. Rotate in production by updating the var, then restarting services. Database passwords should be 32+ random bytes. `VALKEY_PASSWORD` is required in production — the Valkey server itself enforces it via `--requirepass`, and the API's env validator refuses to boot without it whenever queues, the Valkey cache, SSE notifications, or OAuth are enabled. Leaving it empty (the dev default) disables Valkey auth for local convenience only. **DATABASE_URL**: Postgres connection string. Must include TLS mode for production (`?ssl=require`). In development defaults to `localhost`; in production, points to a managed provider or socket. diff --git a/apps/docs/src/content/docs/topics/error-tracking.mdx b/apps/docs/src/content/docs/topics/error-tracking.mdx index b9cf114c..acb0281d 100644 --- a/apps/docs/src/content/docs/topics/error-tracking.mdx +++ b/apps/docs/src/content/docs/topics/error-tracking.mdx @@ -60,7 +60,7 @@ GlitchTip is Apache-licensed and Sentry-API-compatible. It runs as part of the d WITH_GLITCHTIP=0 ./dev.sh up -d # opt out if you'd rather not run it ``` -First boot bootstraps a superuser (`admin@localhost` / `admin123456`), a default org, and two projects (`API` and `Frontend`). The `dev.sh up -d` command then runs `scripts/glitchtip-fetch-dsn.sh` in the background. It pulls the DSNs from GlitchTip's Django ORM, writes them into `compose/.env` as `SENTRY_DSN` and `VITE_SENTRY_DSN`, and restarts `api-dev` and `ui-dev`. Visit `http://glitchtip.localhost` to browse events. The manual DSN paste step is gone. +First boot bootstraps a superuser (`admin@localhost` with a random password `dev.sh` generates and persists to `compose/.env` as `GLITCHTIP_SUPERUSER_PASSWORD` — there is deliberately no published default), a default org, and two projects (`API` and `Frontend`). The `dev.sh up -d` command then runs `scripts/glitchtip-fetch-dsn.sh` in the background. It pulls the DSNs from GlitchTip's Django ORM, writes them into `compose/.env` as `SENTRY_DSN` and `VITE_SENTRY_DSN`, and restarts `api-dev` and `ui-dev`. Visit `http://glitchtip.localhost` to browse events. The manual DSN paste step is gone. The overlay reuses the base stack's Postgres (in a separate `glitchtip` database) and Valkey (DB 1). Adding GlitchTip costs two extra containers, not a separate database server. diff --git a/apps/docs/src/content/docs/topics/observability.mdx b/apps/docs/src/content/docs/topics/observability.mdx index c759f537..14997282 100644 --- a/apps/docs/src/content/docs/topics/observability.mdx +++ b/apps/docs/src/content/docs/topics/observability.mdx @@ -31,7 +31,7 @@ flowchart LR ### Grafana -Host `:3010`. Default credentials: admin / change-me (override via env). Datasources for Prometheus and Loki are auto-provisioned. Five baseline dashboards auto-load (see "Default dashboards" section below). +Host `:3010`. Credentials: `admin` with a random password `dev.sh` generates and persists to `compose/.env` as `GRAFANA_ADMIN_PASSWORD` on first boot (there is deliberately no published default; prod requires an explicit value). Datasources for Prometheus and Loki are auto-provisioned. Five baseline dashboards auto-load (see "Default dashboards" section below). **Verify:** Open `http://localhost:3010`. You should see the BoringStack folder with the five dashboards listed. diff --git a/apps/docs/src/data/lint-meta-catalog.json b/apps/docs/src/data/lint-meta-catalog.json index 2b7314e7..64b3f236 100644 --- a/apps/docs/src/data/lint-meta-catalog.json +++ b/apps/docs/src/data/lint-meta-catalog.json @@ -30,6 +30,12 @@ "ciCritical": false, "description": "GitHub Actions jobs require an explicit timeout-minutes (reusable-workflow calls exempt)." }, + { + "id": "github-actions-bun-cache", + "category": "ci", + "ciCritical": false, + "description": "Workflows running bun install must cache ~/.bun/install/cache." + }, { "id": "github-actions-concurrency-explicit", "category": "ci", @@ -54,6 +60,12 @@ "ciCritical": false, "description": "CI workflow must include every command listed in scripts/ci/pre-push.manifest.json." }, + { + "id": "tofu-bootstrap-hardening", + "category": "ci", + "ciCritical": false, + "description": "infra/bootstrap must keep its hardening invariants: server lifecycle guard, no world-open variable defaults, no curl-pipe-sh." + }, { "id": "engine-pin-parity", "category": "ci", @@ -96,6 +108,18 @@ "ciCritical": false, "description": "Helpers in the canonical registry must only be declared in their single source-of-truth file." }, + { + "id": "docs-no-retired-credentials", + "category": "source-text", + "ciCritical": false, + "description": "Documentation prose must not reference retired default credentials." + }, + { + "id": "i18n-locale-keys-used", + "category": "source-text", + "ciCritical": false, + "description": "Locale keys defined in en/common.json must be referenced in src (dynamic t() prefixes exempt)." + }, { "id": "forbidden-text", "category": "source-text", @@ -209,6 +233,12 @@ "category": "config", "ciCritical": false, "description": "Literal tsconfig include/files entries must point at files that exist (globs exempt); checks this app and sibling apps." + }, + { + "id": "eslint-plugin-contract-parity", + "category": "config", + "ciCritical": false, + "description": "Every installed @boring-stack-pkg eslint plugin must appear in AGENT_CONTRACT.md, and vice versa." } ], "api": [ @@ -254,6 +284,12 @@ "ciCritical": false, "description": "GitHub Actions jobs require an explicit timeout-minutes (reusable-workflow calls exempt)." }, + { + "id": "github-actions-bun-cache", + "category": "ci", + "ciCritical": false, + "description": "Workflows running bun install must cache ~/.bun/install/cache." + }, { "id": "github-actions-concurrency-explicit", "category": "ci", @@ -278,6 +314,12 @@ "ciCritical": false, "description": "CI workflow must include every command listed in scripts/ci/pre-push.manifest.json." }, + { + "id": "tofu-bootstrap-hardening", + "category": "ci", + "ciCritical": false, + "description": "infra/bootstrap must keep its hardening invariants: server lifecycle guard, no world-open variable defaults, no curl-pipe-sh." + }, { "id": "engine-pin-parity", "category": "ci", @@ -332,6 +374,18 @@ "ciCritical": false, "description": "Helpers in the canonical registry must only be declared in their single source-of-truth file." }, + { + "id": "docs-no-retired-credentials", + "category": "source-text", + "ciCritical": false, + "description": "Documentation prose must not reference retired default credentials." + }, + { + "id": "external-client-timeout", + "category": "source-text", + "ciCritical": false, + "description": "SDK clients (Stripe/OpenAI/Anthropic) need a timeout option; fetch() in src needs an AbortSignal." + }, { "id": "no-raw-role-literal", "category": "source-text", @@ -379,6 +433,12 @@ "category": "config", "ciCritical": false, "description": "Literal tsconfig include/files entries must point at files that exist (globs exempt); checks this app and sibling apps." + }, + { + "id": "eslint-plugin-contract-parity", + "category": "config", + "ciCritical": false, + "description": "Every installed @boring-stack-pkg eslint plugin must appear in AGENT_CONTRACT.md, and vice versa." } ] } diff --git a/apps/ui/AGENT_CONTRACT.md b/apps/ui/AGENT_CONTRACT.md index 94fd33f5..557837f3 100644 --- a/apps/ui/AGENT_CONTRACT.md +++ b/apps/ui/AGENT_CONTRACT.md @@ -104,7 +104,7 @@ installed as ordinary semver-pinned `devDependencies` in `package.json`: | Plugin | Enforces | | -------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | `@boring-stack-pkg/eslint-plugin-module-boundaries` | The import-boundary table above | -| `@boring-stack-pkg/eslint-plugin-resource-architecture` | Feature-folder + component-folder shape | +| `@boring-stack-pkg/eslint-plugin-comment-hygiene` | No historical / narration comments — comments explain why, not what | | `@boring-stack-pkg/eslint-plugin-test-conventions` | No `.only`, tests mirror source | | `scripts/lint-meta/` ([RULES.md](scripts/lint-meta/RULES.md)) | Static repo guardrails: source-text bans, CI parity, env cascade, cross-repo imports | | `@boring-stack-pkg/eslint-plugin-structured-logging` | `logger.*({ event, ... })`, no console, no PII | diff --git a/apps/ui/scripts/lint-meta/RULES.md b/apps/ui/scripts/lint-meta/RULES.md index 8a2b65ef..ab98fa4f 100644 --- a/apps/ui/scripts/lint-meta/RULES.md +++ b/apps/ui/scripts/lint-meta/RULES.md @@ -19,10 +19,12 @@ Run `bun run lint:meta --list-rules` for the machine-readable list from the regi | `github-actions-permissions` | ci | no | GitHub Actions workflows require permissions block and SHA-pinned uses: refs. | | `github-actions-permissions:verify` | ci | no | Pinned action SHAs resolve on github.com (lint:meta:verify only). | | `github-actions-timeout-required` | ci | no | GitHub Actions jobs require an explicit timeout-minutes (reusable-workflow calls exempt). | +| `github-actions-bun-cache` | ci | no | Workflows running bun install must cache ~/.bun/install/cache. | | `github-actions-concurrency-explicit` | ci | no | Workflows with a concurrency block must set cancel-in-progress explicitly. | | `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. | | `pre-push-ci-parity` | ci | no | CI workflow must include every command listed in scripts/ci/pre-push.manifest.json. | +| `tofu-bootstrap-hardening` | ci | no | infra/bootstrap must keep its hardening invariants: server lifecycle guard, no world-open variable defaults, no curl-pipe-sh. | | `engine-pin-parity` | ci | no | Node and Bun version pins must stay aligned across .nvmrc, package.json, Docker, and CI. | | `dockerfile-base-image-sha-pin` | ci | no | Dockerfile base images must be pinned by @sha256 digest, not tag alone. | | `env-cascade-drift` | env | no | Vite env keys must align across schema.ts, .env.example, and vite-env.d.ts. | @@ -30,6 +32,8 @@ Run `bun run lint:meta --list-rules` for the machine-readable list from the regi | `generated-artifact-contract` | artifacts | no | Generated ACL types and OpenAPI schema files must exist with required banner text. | | `modulepreload-size-limit-coverage` | artifacts | no | .size-limit.json must include globs for all modulepreload entry chunks. | | `canonical-helpers-single-home` | source-text | no | Helpers in the canonical registry must only be declared in their single source-of-truth file. | +| `docs-no-retired-credentials` | source-text | no | Documentation prose must not reference retired default credentials. | +| `i18n-locale-keys-used` | source-text | no | Locale keys defined in en/common.json must be referenced in src (dynamic t() prefixes exempt). | | `forbidden-text` | source-text | no | Source files must not contain inline lint/TS suppressions, raw HTML, direct env access, raw fetch, or banned Tailwind dark-mode variant classes. | | `no-inline-lint-disable` | source-text | no | Inline ESLint disables are not allowed. | | `no-ts-ignore` | source-text | no | TypeScript suppression comments are not allowed. | @@ -49,6 +53,7 @@ Run `bun run lint:meta --list-rules` for the machine-readable list from the regi | `skipped-tests-need-tracking` | testing | no | Skipped tests (.skip/.only/xit/xdescribe) must carry an issue URL or TODO(@owner) so the debt has a tracked owner. | | `eslint-config-no-warn` | config | no | ESLint severities must be "error" or "off", not "warn". | | `tsconfig-include-paths-exist` | config | no | Literal tsconfig include/files entries must point at files that exist (globs exempt); checks this app and sibling apps. | +| `eslint-plugin-contract-parity` | config | no | Every installed @boring-stack-pkg eslint plugin must appear in AGENT_CONTRACT.md, and vice versa. | ## CI-critical rules diff --git a/apps/ui/scripts/lint-meta/cli.ts b/apps/ui/scripts/lint-meta/cli.ts index 2d658ad9..38cdc95c 100644 --- a/apps/ui/scripts/lint-meta/cli.ts +++ b/apps/ui/scripts/lint-meta/cli.ts @@ -74,7 +74,11 @@ export { checkDependencyPairs } from "./rules/supply-chain/no-overlapping-libs"; export { checkPackageJson } from "./rules/supply-chain/package-json-exact-deps"; export { checkDockerfileBaseImageShaPin } from "./rules/ci/dockerfile-base-image-sha-pin"; export { checkEnginePinParity } from "./rules/ci/engine-pin-parity"; +export { checkEslintPluginContractParity } from "./rules/config/eslint-plugin-contract-parity"; +export { checkI18nLocaleKeysUsed } from "./rules/source-text/i18n-locale-keys-used"; +export { checkTofuBootstrapHardening } from "./rules/ci/tofu-bootstrap-hardening"; export { checkTsconfigIncludePathsExist } from "./rules/config/tsconfig-include-paths-exist"; +export { checkWorkflowBunCache } from "./rules/ci/github-actions-bun-cache"; export { checkWorkflowConcurrencyExplicit } from "./rules/ci/github-actions-concurrency-explicit"; export { checkWorkflowExpressionSyntax } from "./rules/ci/github-actions-expression-syntax"; export { checkWorkflowServiceImageDigestPin } from "./rules/ci/github-actions-service-image-digest-pin"; diff --git a/apps/ui/scripts/lint-meta/registry.ts b/apps/ui/scripts/lint-meta/registry.ts index da15af33..43f57488 100644 --- a/apps/ui/scripts/lint-meta/registry.ts +++ b/apps/ui/scripts/lint-meta/registry.ts @@ -2,19 +2,24 @@ import { generatedArtifactContractRule } from "./rules/artifacts/generated-artif import { modulepreloadSizeLimitRule } from "./rules/artifacts/modulepreload-size-limit"; import { dockerfileBaseImageShaPinRule } from "./rules/ci/dockerfile-base-image-sha-pin"; import { enginePinParityRule } from "./rules/ci/engine-pin-parity"; +import { githubActionsBunCacheRule } from "./rules/ci/github-actions-bun-cache"; import { githubActionsConcurrencyExplicitRule } from "./rules/ci/github-actions-concurrency-explicit"; import { githubActionsExpressionSyntaxRule } from "./rules/ci/github-actions-expression-syntax"; import { githubActionsPermissionsRule } from "./rules/ci/github-actions-permissions"; import { githubActionsServiceImageDigestPinRule } from "./rules/ci/github-actions-service-image-digest-pin"; import { githubActionsTimeoutRequiredRule } from "./rules/ci/github-actions-timeout-required"; import { prePushCiParityRule } from "./rules/ci/pre-push-ci-parity"; +import { tofuBootstrapHardeningRule } from "./rules/ci/tofu-bootstrap-hardening"; import { eslintConfigNoWarnRule } from "./rules/config/eslint-config-no-warn"; +import { eslintPluginContractParityRule } from "./rules/config/eslint-plugin-contract-parity"; import { tsconfigIncludePathsExistRule } from "./rules/config/tsconfig-include-paths-exist"; import { envCascadeDriftRule } from "./rules/env/env-cascade-drift"; import { noDirectImportMetaEnvRule } from "./rules/env/no-direct-import-meta-env"; import { noSilentErrorSwallowRule } from "./rules/queries/no-silent-error-swallow"; import { canonicalHelpersSingleHomeRule } from "./rules/source-text/canonical-helpers-single-home"; +import { docsNoRetiredCredentialsRule } from "./rules/source-text/docs-no-retired-credentials"; import { forbiddenTextRule } from "./rules/source-text/forbidden-text"; +import { i18nLocaleKeysUsedRule } from "./rules/source-text/i18n-locale-keys-used"; import { noCrossRepoImportRule } from "./rules/source-text/no-cross-repo-import"; import { noRawRoleLiteralsRule } from "./rules/source-text/no-raw-role-literals"; import { scriptRawFetchRule } from "./rules/source-text/script-raw-fetch"; @@ -32,10 +37,12 @@ export const META_RULES: readonly IMetaRule[] = [ // --- ci --- githubActionsPermissionsRule, githubActionsTimeoutRequiredRule, + githubActionsBunCacheRule, githubActionsConcurrencyExplicitRule, githubActionsExpressionSyntaxRule, githubActionsServiceImageDigestPinRule, prePushCiParityRule, + tofuBootstrapHardeningRule, enginePinParityRule, dockerfileBaseImageShaPinRule, // --- env --- @@ -46,6 +53,8 @@ export const META_RULES: readonly IMetaRule[] = [ modulepreloadSizeLimitRule, // --- source-text --- canonicalHelpersSingleHomeRule, + docsNoRetiredCredentialsRule, + i18nLocaleKeysUsedRule, forbiddenTextRule, noCrossRepoImportRule, noRawRoleLiteralsRule, @@ -57,5 +66,6 @@ export const META_RULES: readonly IMetaRule[] = [ skippedTestsNeedTrackingRule, // --- config --- eslintConfigNoWarnRule, - tsconfigIncludePathsExistRule + tsconfigIncludePathsExistRule, + eslintPluginContractParityRule ]; diff --git a/apps/ui/scripts/lint-meta/rules/ci/github-actions-bun-cache.ts b/apps/ui/scripts/lint-meta/rules/ci/github-actions-bun-cache.ts new file mode 100644 index 00000000..a24ae1cc --- /dev/null +++ b/apps/ui/scripts/lint-meta/rules/ci/github-actions-bun-cache.ts @@ -0,0 +1,40 @@ +import { readFileSync } from "node:fs"; + +import type { IMetaRule, IViolation } from "../../types"; + +/* + * Every `bun install` in CI re-downloads the dependency tree unless + * ~/.bun/install/cache is restored — 30-90s wasted per run, multiplied + * across workflows and pushes. One workflow carried the cache step and + * seven didn't; this pins the convention: bun install ⇒ actions/cache. + */ +export function checkWorkflowBunCache(file: string): IViolation[] { + const text = readFileSync(file, "utf8"); + + if (!text.includes("bun install")) { + return []; + } + + if (text.includes("actions/cache@")) { + return []; + } + + return [ + { + file, + rule: "github-actions-bun-cache", + message: + "Workflow runs `bun install` without an actions/cache step for ~/.bun/install/cache — copy the cache block from apps-api-ci.yml (keyed on the relevant bun.lock files)." + } + ]; +} + +/** bun install in a workflow requires a bun cache step. */ +export const githubActionsBunCacheRule: IMetaRule = { + id: "github-actions-bun-cache", + category: "ci", + description: "Workflows running bun install must cache ~/.bun/install/cache.", + run({ workflowFiles }) { + return workflowFiles.flatMap(checkWorkflowBunCache); + } +}; diff --git a/apps/ui/scripts/lint-meta/rules/ci/tofu-bootstrap-hardening.ts b/apps/ui/scripts/lint-meta/rules/ci/tofu-bootstrap-hardening.ts new file mode 100644 index 00000000..983392c7 --- /dev/null +++ b/apps/ui/scripts/lint-meta/rules/ci/tofu-bootstrap-hardening.ts @@ -0,0 +1,115 @@ +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { dirname, extname, join } from "node:path"; + +import type { IMetaRule, IViolation } from "../../types"; + +const TOFU_EXTENSIONS = new Set([".tf", ".tftpl"]); +const SERVER_RESOURCE = 'resource "hcloud_server"'; +const LIFECYCLE_GUARD_REGEX = + /lifecycle\s*\{[^}]*ignore_changes\s*=\s*\[[^\]]*user_data/su; +const OPEN_DEFAULT_REGEX = /default\s*=\s*\[[^\]]*0\.0\.0\.0\/0/u; +const CURL_PIPE_SH_REGEX = + /\b(?:curl|wget)\b[^|\n]*\|\s*(?:sudo\s+)?(?:ba)?sh\b/u; + +function collectTofuFiles(dir: string): string[] { + const out: string[] = []; + let entries: string[]; + + try { + entries = readdirSync(dir); + } catch { + return out; + } + + for (const entry of entries) { + if (entry === ".terraform") { + continue; + } + + const full = join(dir, entry); + + let isDir: boolean; + + try { + isDir = statSync(full).isDirectory(); + } catch { + continue; + } + + if (isDir) { + out.push(...collectTofuFiles(full)); + continue; + } + + if (TOFU_EXTENSIONS.has(extname(full))) { + out.push(full); + } + } + + return out; +} + +/* + * Three hardening invariants for the OpenTofu bootstrap, learned from the + * 2026-06-04 audit: + * - hcloud_server without lifecycle ignore_changes=[user_data] is one + * tfvars edit away from server REPLACEMENT (cloud-init interpolates + * tfvars; Hetzner replaces on user_data change; the prod data dies + * with the server). + * - variable defaults containing 0.0.0.0/0 silently open admin ports + * to the world; opening must be an explicit operator choice. + * - curl|sh in cloud-init executes unverified remote code as root at + * first boot. + */ +export function checkTofuBootstrapHardening(root: string): IViolation[] { + const violations: IViolation[] = []; + const bootstrapDir = join(dirname(dirname(root)), "infra", "bootstrap"); + + if (!existsSync(bootstrapDir)) { + return violations; + } + + for (const file of collectTofuFiles(bootstrapDir)) { + const text = readFileSync(file, "utf8"); + + if (text.includes(SERVER_RESOURCE) && !LIFECYCLE_GUARD_REGEX.test(text)) { + violations.push({ + file, + rule: "tofu-server-lifecycle-guard", + message: + "hcloud_server must declare lifecycle { ignore_changes = [user_data] } — without it any cloud-init/tfvars change replaces the server and destroys its volumes." + }); + } + + for (const [index, line] of text.split("\n").entries()) { + if (OPEN_DEFAULT_REGEX.test(line)) { + violations.push({ + file, + rule: "tofu-no-open-admin-defaults", + message: `Line ${String(index + 1)}: a variable default contains 0.0.0.0/0 — world-open access must be an explicit operator choice, never a default.` + }); + } + + if (CURL_PIPE_SH_REGEX.test(line)) { + violations.push({ + file, + rule: "no-curl-pipe-sh", + message: `Line ${String(index + 1)}: curl/wget piped to a shell executes unverified remote code — install via a GPG-verified package repo instead.` + }); + } + } + } + + return violations; +} + +/** Bootstrap IaC hardening invariants (lifecycle guard, no open defaults, no curl|sh). */ +export const tofuBootstrapHardeningRule: IMetaRule = { + id: "tofu-bootstrap-hardening", + category: "ci", + description: + "infra/bootstrap must keep its hardening invariants: server lifecycle guard, no world-open variable defaults, no curl-pipe-sh.", + run({ root }) { + return checkTofuBootstrapHardening(root); + } +}; diff --git a/apps/ui/scripts/lint-meta/rules/config/eslint-plugin-contract-parity.ts b/apps/ui/scripts/lint-meta/rules/config/eslint-plugin-contract-parity.ts new file mode 100644 index 00000000..944a3b4a --- /dev/null +++ b/apps/ui/scripts/lint-meta/rules/config/eslint-plugin-contract-parity.ts @@ -0,0 +1,84 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import type { IMetaRule, IViolation } from "../../types"; + +const PLUGIN_PREFIX = "@boring-stack-pkg/eslint-plugin-"; +const CONTRACT_FILE = "AGENT_CONTRACT.md"; + +/* + * AGENT_CONTRACT.md is the first document agents read; its plugin table + * is the canonical map of what `bun run check` enforces. Two installed + * plugins (code-flow, comment-hygiene) were missing from it — and one + * row referenced a plugin that was never installed in that app — so + * the contract silently drifted from package.json. Parity both ways: + * every installed plugin must be mentioned, and every mentioned plugin + * must be installed. + */ +export function checkEslintPluginContractParity(root: string): IViolation[] { + const violations: IViolation[] = []; + const contractPath = join(root, CONTRACT_FILE); + const packagePath = join(root, "package.json"); + + if (!existsSync(contractPath) || !existsSync(packagePath)) { + return violations; + } + + const contract = readFileSync(contractPath, "utf8"); + const parsed: unknown = JSON.parse(readFileSync(packagePath, "utf8")); + + if (typeof parsed !== "object" || parsed === null) { + return violations; + } + + const devDependencies = + "devDependencies" in parsed ? parsed.devDependencies : undefined; + + if (typeof devDependencies !== "object" || devDependencies === null) { + return violations; + } + + const installed = Object.keys(devDependencies) + .filter((name) => name.startsWith(PLUGIN_PREFIX)) + .map((name) => name.slice(PLUGIN_PREFIX.length)); + + for (const shortName of installed) { + if (!contract.includes(shortName)) { + violations.push({ + file: contractPath, + rule: "eslint-plugin-contract-parity", + message: `Installed plugin \`${PLUGIN_PREFIX}${shortName}\` is not documented in ${CONTRACT_FILE} — the contract's plugin table must cover everything \`bun run check\` enforces.` + }); + } + } + + const mentioned = contract.matchAll( + /@boring-stack-pkg\/eslint-plugin-([a-z0-9-]+)/gu + ); + const installedSet = new Set(installed); + + for (const match of mentioned) { + const shortName = match[1]; + + if (shortName !== undefined && !installedSet.has(shortName)) { + violations.push({ + file: contractPath, + rule: "eslint-plugin-contract-parity", + message: `${CONTRACT_FILE} documents \`${PLUGIN_PREFIX}${shortName}\` but it is not installed in this app's package.json — remove or correct the row.` + }); + } + } + + return violations; +} + +/** AGENT_CONTRACT.md's plugin table must match package.json, both ways. */ +export const eslintPluginContractParityRule: IMetaRule = { + id: "eslint-plugin-contract-parity", + category: "config", + description: + "Every installed @boring-stack-pkg eslint plugin must appear in AGENT_CONTRACT.md, and vice versa.", + run({ root }) { + return checkEslintPluginContractParity(root); + } +}; diff --git a/apps/ui/scripts/lint-meta/rules/source-text/docs-no-retired-credentials.ts b/apps/ui/scripts/lint-meta/rules/source-text/docs-no-retired-credentials.ts new file mode 100644 index 00000000..cdd853f6 --- /dev/null +++ b/apps/ui/scripts/lint-meta/rules/source-text/docs-no-retired-credentials.ts @@ -0,0 +1,84 @@ +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { dirname, extname, join } from "node:path"; + +import type { IMetaRule, IViolation } from "../../types"; + +/* + * Credentials that once shipped as documented defaults and were removed + * (2026-06-03: dev.sh now generates random passwords). Docs teaching a + * retired default trains users to hardcode it back — every literal here + * must stay banned from documentation prose forever. + */ +const RETIRED_CREDENTIALS = ["admin123456", "admin / change-me"] as const; + +const DOC_EXTENSIONS = new Set([".md", ".mdx"]); + +function collectDocFiles(dir: string): string[] { + const out: string[] = []; + let entries: string[]; + + try { + entries = readdirSync(dir); + } catch { + return out; + } + + for (const entry of entries) { + const full = join(dir, entry); + + let isDir: boolean; + + try { + isDir = statSync(full).isDirectory(); + } catch { + continue; + } + + if (isDir) { + out.push(...collectDocFiles(full)); + continue; + } + + if (DOC_EXTENSIONS.has(extname(full))) { + out.push(full); + } + } + + return out; +} + +export function checkDocsNoRetiredCredentials(root: string): IViolation[] { + const violations: IViolation[] = []; + const docsContent = join(dirname(root), "docs", "src", "content"); + + if (!existsSync(docsContent)) { + return violations; + } + + for (const file of collectDocFiles(docsContent)) { + const text = readFileSync(file, "utf8"); + + for (const literal of RETIRED_CREDENTIALS) { + if (text.includes(literal)) { + violations.push({ + file, + rule: "docs-no-retired-credentials", + message: `Documentation references the retired default credential \`${literal}\` — it was removed from the stack (random per-install generation) and teaching it invites users to hardcode it back.` + }); + } + } + } + + return violations; +} + +/** Docs must never teach credentials the stack has retired. */ +export const docsNoRetiredCredentialsRule: IMetaRule = { + id: "docs-no-retired-credentials", + category: "source-text", + description: + "Documentation prose must not reference retired default credentials.", + run({ root }) { + return checkDocsNoRetiredCredentials(root); + } +}; diff --git a/apps/ui/scripts/lint-meta/rules/source-text/i18n-locale-keys-used.ts b/apps/ui/scripts/lint-meta/rules/source-text/i18n-locale-keys-used.ts new file mode 100644 index 00000000..2b188d7e --- /dev/null +++ b/apps/ui/scripts/lint-meta/rules/source-text/i18n-locale-keys-used.ts @@ -0,0 +1,99 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import type { IMetaRule, IViolation } from "../../types"; + +const CANONICAL_LOCALE = join( + "src", + "lib", + "i18n", + "locales", + "en", + "common.json" +); + +/* + * The cross-repo i18n-keys plugin guarantees every `t("…")` literal has a + * locale entry (used → defined). This rule closes the other direction + * (defined → used): a leaf key nobody references is dead translation + * surface that still costs every locale a translated string. + * + * A key counts as used when its full dotted path appears as a string + * literal anywhere in src (covers indirect `labelKey:` config tables), + * or when it sits under a prefix that some `t(`…${…}`)` template builds + * dynamically (e.g. auth.oauth.${provider}). + */ +function flattenKeys(value: unknown, prefix: string, out: string[]): void { + if (typeof value !== "object" || value === null) { + out.push(prefix); + + return; + } + + for (const [key, child] of Object.entries(value)) { + flattenKeys(child, prefix === "" ? key : `${prefix}.${key}`, out); + } +} + +const DYNAMIC_PREFIX_REGEX = /t\(\s*`([^`$]+)\$\{/gu; + +export function checkI18nLocaleKeysUsed( + root: string, + sourceFiles: readonly string[] +): IViolation[] { + const localePath = join(root, CANONICAL_LOCALE); + let parsed: unknown; + + try { + parsed = JSON.parse(readFileSync(localePath, "utf8")); + } catch { + return []; + } + + const keys: string[] = []; + + flattenKeys(parsed, "", keys); + + const sources = sourceFiles + .filter((file) => !file.includes("/locales/")) + .map((file) => readFileSync(file, "utf8")); + const corpus = sources.join("\n"); + const dynamicPrefixes: string[] = []; + + for (const match of corpus.matchAll(DYNAMIC_PREFIX_REGEX)) { + const prefix = match[1]; + + if (prefix !== undefined) { + dynamicPrefixes.push(prefix); + } + } + + const violations: IViolation[] = []; + + for (const key of keys) { + if (dynamicPrefixes.some((prefix) => key.startsWith(prefix))) { + continue; + } + + if (!corpus.includes(`"${key}"`) && !corpus.includes(`'${key}'`)) { + violations.push({ + file: localePath, + rule: "i18n-locale-keys-used", + message: `Locale key \`${key}\` is defined but never referenced in src — dead translation surface (remove it from every locale, or wire it up).` + }); + } + } + + return violations; +} + +/** Every locale leaf key must be referenced somewhere in src. */ +export const i18nLocaleKeysUsedRule: IMetaRule = { + id: "i18n-locale-keys-used", + category: "source-text", + description: + "Locale keys defined in en/common.json must be referenced in src (dynamic t() prefixes exempt).", + run({ root, sourceFiles }) { + return checkI18nLocaleKeysUsed(root, sourceFiles); + } +}; diff --git a/apps/ui/src/lib/i18n/locales/de/common.json b/apps/ui/src/lib/i18n/locales/de/common.json index cb0c7bbc..72e024ef 100644 --- a/apps/ui/src/lib/i18n/locales/de/common.json +++ b/apps/ui/src/lib/i18n/locales/de/common.json @@ -1,14 +1,12 @@ { "app": { - "name": "BoringStack", - "tagline": "Produktionsreifes SPA-Starter" + "name": "BoringStack" }, "a11y": { "skipToContent": "Zum Hauptinhalt springen" }, "common": { "loading": "Wird geladen…", - "loadingAria": "Wird geladen", "close": "Schließen" }, "offline": { @@ -62,8 +60,7 @@ "signIn": "Anmelden", "errors": { "network": "Server nicht erreichbar. Bitte erneut versuchen.", - "emailTaken": "Mit dieser E-Mail-Adresse existiert bereits ein Konto.", - "weakPassword": "Verwende ein stärkeres Passwort (Groß-, Kleinbuchstaben und eine Ziffer)." + "emailTaken": "Mit dieser E-Mail-Adresse existiert bereits ein Konto." } }, "verifyEmail": { @@ -194,7 +191,6 @@ "nav": { "ariaPrimary": "Hauptnavigation", "openMenu": "Menü öffnen", - "closeMenu": "Menü schließen", "menuTitle": "Menü", "dashboard": "Übersicht", "notifications": "Benachrichtigungen", @@ -213,8 +209,7 @@ "loadError": "Abrechnungsdetails konnten nicht geladen werden. Seite aktualisieren oder später erneut versuchen.", "currentPlan": { "label": "Aktueller Tarif", - "free": "Kostenlos", - "paid": "Bezahlt" + "free": "Kostenlos" }, "plansHeading": "Verfügbare Tarife", "manage": "Abonnement verwalten", @@ -287,11 +282,6 @@ } } }, - "languageSwitcher": { - "label": "Sprache", - "english": "English", - "german": "Deutsch" - }, "accounts": { "settings": { "pageTitle": "Einstellungen", @@ -428,7 +418,6 @@ "saveSuccess": "Profil aktualisiert", "saveError": "Profil konnte nicht aktualisiert werden. Bitte erneut versuchen.", "fields": { - "name": "Name", "firstName": "Vorname", "lastName": "Nachname", "email": "E-Mail", @@ -437,9 +426,7 @@ "identityLabel": "Identität" }, "switcher": { - "ariaLabel": "Aktives Konto wechseln", - "currentBadge": "Aktiv", - "rolePrefix": "Rolle" + "ariaLabel": "Aktives Konto wechseln" }, "auditLog": { "pageTitle": "Audit-Log", @@ -447,13 +434,11 @@ "loading": "Audit-Einträge werden geladen", "error": "Audit-Log konnte nicht geladen werden. Bitte erneut versuchen.", "empty": "Noch keine Audit-Einträge für dieses Konto.", - "systemActor": "System", - "navLabel": "Audit-Log" + "systemActor": "System" }, "invitations": { "pageTitle": "Team", "pageSubtitle": "Lade Teammitglieder ein und verwalte offene Einladungen.", - "inviteCta": "Teammitglied einladen", "tableHeading": "Offene Einladungen", "empty": "Keine offenen Einladungen.", "columns": { @@ -471,8 +456,6 @@ "roleLabel": "Rolle", "submit": "Einladung senden", "submitting": "Wird gesendet…", - "cancel": "Abbrechen", - "success": "Einladung gesendet", "errorGeneric": "Einladung konnte nicht gesendet werden." }, "roles": { @@ -516,7 +499,6 @@ "joinRequests": { "pageTitle": "Beitrittsanfragen", "pageSubtitle": "Offene Anfragen von Nutzern mit passender E-Mail-Domain.", - "navLabel": "Beitrittsanfragen", "empty": "Keine offenen Beitrittsanfragen.", "loading": "Beitrittsanfragen werden geladen…", "error": "Beitrittsanfragen konnten nicht geladen werden. Versuche es erneut.", diff --git a/apps/ui/src/lib/i18n/locales/en/common.json b/apps/ui/src/lib/i18n/locales/en/common.json index ca007db1..3fbbc318 100644 --- a/apps/ui/src/lib/i18n/locales/en/common.json +++ b/apps/ui/src/lib/i18n/locales/en/common.json @@ -1,14 +1,12 @@ { "app": { - "name": "BoringStack", - "tagline": "Production-grade SPA starter" + "name": "BoringStack" }, "a11y": { "skipToContent": "Skip to main content" }, "common": { "loading": "Loading…", - "loadingAria": "Loading", "close": "Close" }, "offline": { @@ -62,8 +60,7 @@ "signIn": "Sign in", "errors": { "network": "Could not reach the server. Try again.", - "emailTaken": "An account with this email already exists.", - "weakPassword": "Use a stronger password (mix of upper, lower, and a digit)." + "emailTaken": "An account with this email already exists." } }, "verifyEmail": { @@ -194,7 +191,6 @@ "nav": { "ariaPrimary": "Primary navigation", "openMenu": "Open menu", - "closeMenu": "Close menu", "menuTitle": "Menu", "dashboard": "Dashboard", "notifications": "Notifications", @@ -213,8 +209,7 @@ "loadError": "Could not load billing details. Refresh the page or try again in a moment.", "currentPlan": { "label": "Current plan", - "free": "Free", - "paid": "Paid" + "free": "Free" }, "plansHeading": "Available plans", "manage": "Manage subscription", @@ -266,11 +261,6 @@ } } }, - "languageSwitcher": { - "label": "Language", - "english": "English", - "german": "Deutsch" - }, "accounts": { "settings": { "pageTitle": "Settings", @@ -407,7 +397,6 @@ "saveSuccess": "Profile updated", "saveError": "Couldn't update your profile. Try again.", "fields": { - "name": "Name", "firstName": "First name", "lastName": "Last name", "email": "Email", @@ -416,9 +405,7 @@ "identityLabel": "Identity" }, "switcher": { - "ariaLabel": "Switch active account", - "currentBadge": "Active", - "rolePrefix": "Role" + "ariaLabel": "Switch active account" }, "auditLog": { "pageTitle": "Audit log", @@ -426,13 +413,11 @@ "loading": "Loading audit entries", "error": "Could not load the audit log. Try again.", "empty": "No audit entries for this account yet.", - "systemActor": "System", - "navLabel": "Audit log" + "systemActor": "System" }, "invitations": { "pageTitle": "Team", "pageSubtitle": "Invite teammates and manage pending invitations.", - "inviteCta": "Invite a teammate", "tableHeading": "Pending invitations", "empty": "No pending invitations.", "columns": { @@ -450,8 +435,6 @@ "roleLabel": "Role", "submit": "Send invitation", "submitting": "Sending…", - "cancel": "Cancel", - "success": "Invitation sent", "errorGeneric": "Couldn't send the invitation." }, "roles": { @@ -495,7 +478,6 @@ "joinRequests": { "pageTitle": "Join requests", "pageSubtitle": "Pending requests from users whose email domain matches this account.", - "navLabel": "Join requests", "empty": "No pending join requests.", "loading": "Loading join requests…", "error": "Couldn't load join requests. Try again.", diff --git a/apps/ui/tests/lint-meta/lint-meta.test.ts b/apps/ui/tests/lint-meta/lint-meta.test.ts index 01355f90..c356a63b 100644 --- a/apps/ui/tests/lint-meta/lint-meta.test.ts +++ b/apps/ui/tests/lint-meta/lint-meta.test.ts @@ -15,7 +15,9 @@ import { checkDependencyPairs, checkDockerfileBaseImageShaPin, checkEnginePinParity, + checkEslintPluginContractParity, checkForbiddenText, + checkI18nLocaleKeysUsed, checkNoCrossRepoImports, checkNoDirectImportMetaEnv, checkNoRawRoleLiterals, @@ -24,9 +26,11 @@ import { checkPrePushParity, checkScriptRawFetch, checkTestFilesHaveSource, + checkTofuBootstrapHardening, checkTsconfigIncludePathsExist, checkUiEnvCascadeDrift, checkWorkflow, + checkWorkflowBunCache, checkWorkflowConcurrencyExplicit, checkWorkflowExpressionSyntax, checkWorkflowServiceImageDigestPin, @@ -673,6 +677,170 @@ describe("checkWorkflowExpressionSyntax", () => { }); }); +describe("checkI18nLocaleKeysUsed", () => { + test("flags a defined-but-unused leaf key; honors literals and dynamic prefixes", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-i18n-used-")); + + try { + const localeDir = join(root, "src", "lib", "i18n", "locales", "en"); + + mkdirSync(localeDir, { recursive: true }); + writeFileSync( + join(localeDir, "common.json"), + JSON.stringify({ + billing: { currentPlan: { free: "Free", paid: "Paid" } }, + auth: { oauth: { google: "Google", github: "GitHub" } } + }) + ); + + const srcFile = join(root, "src", "page.tsx"); + + writeFileSync( + srcFile, + 't("billing.currentPlan.free");\nt(`auth.oauth.${provider}`);\n' + ); + + const violations = checkI18nLocaleKeysUsed(root, [srcFile]); + + expect(violations.map((row) => row.message)).toContainEqual( + expect.stringContaining("billing.currentPlan.paid") + ); + expect(violations).toHaveLength(1); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe("checkTofuBootstrapHardening", () => { + function makeBootstrap(root: string, mainTf: string): void { + const dir = join(root, "apps", "self"); + const bootstrapDir = join(root, "infra", "bootstrap"); + + mkdirSync(dir, { recursive: true }); + mkdirSync(bootstrapDir, { recursive: true }); + writeFileSync(join(bootstrapDir, "main.tf"), mainTf); + } + + test("flags missing lifecycle guard, open defaults, and curl|sh", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-tofu-")); + + try { + makeBootstrap( + root, + [ + 'resource "hcloud_server" "main" {', + " user_data = var.cloud_init", + "}", + 'variable "ssh_allowed_ips" {', + ' default = ["0.0.0.0/0"]', + "}", + '# - [bash, -c, "curl -fsSL https://get.docker.com | sh"]', + "" + ].join("\n") + ); + + const rules = checkTofuBootstrapHardening(join(root, "apps", "self")).map( + (row) => row.rule + ); + + expect(rules).toContain("tofu-server-lifecycle-guard"); + expect(rules).toContain("tofu-no-open-admin-defaults"); + expect(rules).toContain("no-curl-pipe-sh"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("passes a guarded server with explicit inputs and verified installs", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-tofu-")); + + try { + makeBootstrap( + root, + [ + 'resource "hcloud_server" "main" {', + " user_data = var.cloud_init", + " lifecycle {", + " ignore_changes = [user_data]", + " }", + "}", + 'variable "ssh_allowed_ips" {', + "}", + "" + ].join("\n") + ); + + expect(checkTofuBootstrapHardening(join(root, "apps", "self"))).toEqual( + [] + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe("checkEslintPluginContractParity", () => { + test("flags installed-but-undocumented and documented-but-missing plugins", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-contract-")); + + try { + writeFileSync( + join(root, "package.json"), + JSON.stringify({ + devDependencies: { + "@boring-stack-pkg/eslint-plugin-code-flow": "0.2.0", + "@boring-stack-pkg/eslint-plugin-comment-hygiene": "0.2.0" + } + }) + ); + writeFileSync( + join(root, "AGENT_CONTRACT.md"), + "| `code-flow` | early returns |\n| `@boring-stack-pkg/eslint-plugin-ghost-plugin` | not installed |\n" + ); + + const messages = checkEslintPluginContractParity(root).map( + (row) => row.message + ); + + expect( + messages.some((message) => message.includes("comment-hygiene")) + ).toBe(true); + expect(messages.some((message) => message.includes("ghost-plugin"))).toBe( + true + ); + expect(messages.some((message) => message.includes("code-flow"))).toBe( + false + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("passes when contract and package.json agree", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-contract-")); + + try { + writeFileSync( + join(root, "package.json"), + JSON.stringify({ + devDependencies: { + "@boring-stack-pkg/eslint-plugin-code-flow": "0.2.0" + } + }) + ); + writeFileSync( + join(root, "AGENT_CONTRACT.md"), + "| `@boring-stack-pkg/eslint-plugin-code-flow` | early returns |\n" + ); + + expect(checkEslintPluginContractParity(root)).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe("checkTsconfigIncludePathsExist", () => { test("flags a literal include entry that does not exist, skips globs and hidden dirs", () => { const root = mkdtempSync(join(tmpdir(), "lint-meta-tsconfig-")); @@ -803,6 +971,42 @@ describe("checkEnginePinParity", () => { }); }); +describe("checkWorkflowBunCache", () => { + test("flags bun install without a cache step; passes cached and bun-free workflows", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-bun-cache-")); + + try { + const uncached = join(root, "uncached.yml"); + + writeFileSync( + uncached, + "jobs:\n x:\n steps:\n - run: bun install\n" + ); + + expect(checkWorkflowBunCache(uncached).map((row) => row.rule)).toContain( + "github-actions-bun-cache" + ); + + const cached = join(root, "cached.yml"); + + writeFileSync( + cached, + "jobs:\n x:\n steps:\n - uses: actions/cache@abc\n - run: bun install\n" + ); + + expect(checkWorkflowBunCache(cached)).toEqual([]); + + const noBun = join(root, "nobun.yml"); + + writeFileSync(noBun, "jobs: {}\n"); + + expect(checkWorkflowBunCache(noBun)).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe("checkWorkflowServiceImageDigestPin", () => { test("flags a service image without a digest", () => { const root = mkdtempSync(join(tmpdir(), "lint-meta-svc-image-")); diff --git a/infra/bootstrap/modules/bootstrap/templates/cloud-init.yaml.tftpl b/infra/bootstrap/modules/bootstrap/templates/cloud-init.yaml.tftpl index 60a7bea3..23f01dc2 100644 --- a/infra/bootstrap/modules/bootstrap/templates/cloud-init.yaml.tftpl +++ b/infra/bootstrap/modules/bootstrap/templates/cloud-init.yaml.tftpl @@ -47,10 +47,18 @@ write_files: %{ endif ~} runcmd: - # Install Docker via the official convenience script. For a long-lived - # production host this would normally pin a version; cloud-init is one-shot - # so the date of the deploy pins the version implicitly. - - [bash, -o, pipefail, -c, "curl -fsSL https://get.docker.com | sh"] + # Install Docker from the official apt repository with GPG verification — + # piping get.docker.com to sh executes an unverified remote script as + # root at the most privileged moment of the server's life. The key's + # fingerprint is pinned (Docker's published release key); apt then + # signature-checks every package against it. + - [install, -m, "0755", -d, /etc/apt/keyrings] + - [bash, -o, pipefail, -c, "curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc"] + - [chmod, a+r, /etc/apt/keyrings/docker.asc] + - [bash, -o, pipefail, -c, "gpg --show-keys --with-fingerprint --with-colons /etc/apt/keyrings/docker.asc | grep -q '^fpr:::::::::9DC858229FC7DD38854AE2D88D81803C0EBFCD88:' || { echo 'Docker GPG key fingerprint mismatch' >&2; exit 1; }"] + - [bash, -o, pipefail, -c, "echo \"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable\" > /etc/apt/sources.list.d/docker.list"] + - [apt-get, update] + - [apt-get, install, -y, docker-ce, docker-ce-cli, containerd.io, docker-buildx-plugin, docker-compose-plugin] - [systemctl, enable, --now, docker] # Run the bootstrap script. Output goes to /var/log/boringstack-bootstrap.log. - [bash, -o, pipefail, -c, "/opt/boringstack/bootstrap.sh 2>&1 | tee /var/log/boringstack-bootstrap.log"] diff --git a/infra/bootstrap/modules/hetzner/main.tf b/infra/bootstrap/modules/hetzner/main.tf index a645783f..d19dacca 100644 --- a/infra/bootstrap/modules/hetzner/main.tf +++ b/infra/bootstrap/modules/hetzner/main.tf @@ -106,4 +106,15 @@ resource "hcloud_server" "main" { role = "boringstack" managed = "opentofu" } + + # cloud-init only executes on FIRST boot, but Hetzner replaces the + # server whenever user_data changes — so a routine tfvars edit (repo + # URL, superuser credentials, backup settings) would otherwise destroy + # the VPS and every Docker volume on it (Postgres data, acme.json, + # GlitchTip). Ignore post-create drift; rebuild deliberately with + # tofu apply -replace=module.hetzner.hcloud_server.main + # when you really want a fresh server. + lifecycle { + ignore_changes = [user_data] + } } diff --git a/infra/bootstrap/terraform.tfvars.example b/infra/bootstrap/terraform.tfvars.example index 5bed9132..5f50abb4 100644 --- a/infra/bootstrap/terraform.tfvars.example +++ b/infra/bootstrap/terraform.tfvars.example @@ -22,7 +22,9 @@ cloudflare_zone_id = "" # Zone overview page, right sidebar domain = "example.com" ssh_public_key = "" # paste full key including "ssh-ed25519 ..." -# ssh_allowed_ips = ["0.0.0.0/0", "::/0"] # uncomment to restrict SSH source +ssh_allowed_ips = [] # REQUIRED: your admin CIDRs, e.g. ["203.0.113.7/32"]. + # ["0.0.0.0/0", "::/0"] opens SSH to the world - only + # if you truly mean it. Empty fails validation. # ---------------------------------------------------------------------------- # Edge security + performance (optional; all on by default, all free-tier) diff --git a/infra/bootstrap/variables.tf b/infra/bootstrap/variables.tf index 4a453cb8..3d839b46 100644 --- a/infra/bootstrap/variables.tf +++ b/infra/bootstrap/variables.tf @@ -60,12 +60,11 @@ variable "ssh_public_key" { variable "ssh_allowed_ips" { type = list(string) - description = "Source CIDRs allowed to reach SSH (port 22). Defaults to anywhere; restrict to your bastion / VPN for hardening." - default = ["0.0.0.0/0", "::/0"] + description = "Source CIDRs allowed to reach SSH (port 22). No default on purpose — opening an admin port to the world must be an explicit operator choice (your IP, bastion, or VPN range; [\"0.0.0.0/0\", \"::/0\"] if you really mean anywhere)." validation { condition = length(var.ssh_allowed_ips) > 0 && alltrue([for cidr in var.ssh_allowed_ips : can(cidrhost(cidr, 0))]) - error_message = "ssh_allowed_ips must contain at least one valid IPv4 or IPv6 CIDR." + error_message = "ssh_allowed_ips must contain at least one valid IPv4 or IPv6 CIDR. Set it explicitly — e.g. [\"203.0.113.7/32\"] for a single admin IP." } } @@ -183,8 +182,8 @@ variable "monorepo_repo" { default = "https://github.com/boringstack-xyz/boringstack" validation { - condition = length(trimspace(var.monorepo_repo)) > 0 && length(regexall("[\r\n]", var.monorepo_repo)) == 0 - error_message = "monorepo_repo must be a single-line Git URL." + condition = can(regex("^(https://github\\.com/|git@github\\.com:)[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(\\.git)?$", trimspace(var.monorepo_repo))) + error_message = "monorepo_repo must be a GitHub URL: https://github.com/owner/repo or git@github.com:owner/repo (.git optional). A malformed value otherwise only fails inside cloud-init on the booted server." } } diff --git a/infra/compose/scripts/pre-push.sh b/infra/compose/scripts/pre-push.sh index c83e7cd9..ce4e1c94 100755 --- a/infra/compose/scripts/pre-push.sh +++ b/infra/compose/scripts/pre-push.sh @@ -55,7 +55,7 @@ config() { (cd "$COMPOSE_DIR" && docker compose "$@" --quiet >/dev/null) } -step "1/3 Compose config validation (every overlay combo)" +step "1/4 Compose config validation (every overlay combo)" config "dev (base)" -f docker-compose.yml -f docker-compose.development-labels.yml --profile dev config config "prod (base)" -f docker-compose.yml -f docker-compose.production-labels.yml --profile prod config config "dev + observability" -f docker-compose.yml -f docker-compose.development-labels.yml -f docker-compose.observability.yml --profile dev --profile observability config @@ -66,15 +66,20 @@ config "dev + wud" -f docker-compose.yml -f docker-compose.development-labels.ym config "kitchen-sink (dev + all overlays)" -f docker-compose.yml -f docker-compose.development-labels.yml -f docker-compose.observability.yml -f docker-compose.glitchtip.yml -f docker-compose.bullmq.yml -f docker-compose.wud.yml --profile dev --profile observability --profile glitchtip-dev --profile bullmq --profile wud config ok "compose configs valid" -step "2/3 shellcheck" +step "2/4 Compose guardrails (same script CI runs)" +"$INFRA_ROOT/scripts/validate-guardrails.sh" all +ok "guardrails clean" + +step "3/4 shellcheck" if ! command -v shellcheck >/dev/null 2>&1; then c_blue " skipped — shellcheck not installed (brew install shellcheck). CI still runs it." else - shellcheck -x -S warning "$COMPOSE_DIR/dev.sh" "$INFRA_ROOT"/scripts/*.sh + shellcheck -x -S warning "$COMPOSE_DIR/dev.sh" "$INFRA_ROOT"/scripts/*.sh \ + "$ROOT"/scripts/*.sh "$ROOT"/scripts/ci/*.sh "$ROOT/setup.sh" ok "shellcheck clean" fi -step "3/3 yamllint" +step "4/4 yamllint" if ! command -v yamllint >/dev/null 2>&1; then c_blue " skipped — yamllint not installed (brew install yamllint). CI still runs it." else diff --git a/infra/compose/scripts/validate-guardrails.sh b/infra/compose/scripts/validate-guardrails.sh new file mode 100755 index 00000000..575d0acb --- /dev/null +++ b/infra/compose/scripts/validate-guardrails.sh @@ -0,0 +1,235 @@ +#!/usr/bin/env bash +# Single source of truth for the compose guardrails. CI +# (infra-compose-validate-compose.yml) and the local pre-push gate both +# invoke THIS script, so the two layers cannot drift — the 2026-06-03 +# incident (a guardrail env seeded in CI but not locally) is structurally +# impossible to repeat. +# +# Usage: +# ./validate-guardrails.sh [check] +# +# Checks: healthchecks | digest-pins | credential-fallbacks | valkey-auth +# | rooted-caps | prod-image-tags | all (default) +# +# Requires: docker (compose config rendering), python3. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +COMPOSE_DIR="$SCRIPT_DIR/../compose" +cd "$COMPOSE_DIR" + +CHECK="${1:-all}" + +c_red() { printf '\033[1;31m%s\033[0m\n' "$*"; } +c_green() { printf '\033[1;32m%s\033[0m\n' "$*"; } +fail() { c_red "✗ $*"; exit 1; } +ok() { c_green "✓ $*"; } + +render_prod_config() { + docker compose -f docker-compose.yml --profile prod config --format json \ + > /tmp/guardrails-prod-config.json +} + +check_healthchecks() { + render_prod_config + python3 - <<'EOF' +import json + +with open("/tmp/guardrails-prod-config.json", encoding="utf-8") as handle: + services = json.load(handle)["services"] + +missing = sorted( + name + for name, svc in services.items() + if svc.get("restart") != "no" and "healthcheck" not in svc +) + +if missing: + raise SystemExit("prod services missing healthcheck: " + ", ".join(missing)) + +print("healthcheck present on: " + ", ".join(sorted(services))) +EOF + ok "healthchecks" +} + +check_digest_pins() { + python3 - <<'EOF' +import glob +import re + +# Literal image refs must pin a @sha256 digest and must not carry the +# floating :latest tag (even alongside a digest — the digest wins, but +# the tag misdocuments what is pinned). Interpolated refs (with a +# dollar-brace variable) are validated at runtime by the stack scripts. +IMAGE_RE = re.compile(r"^\s+image:\s*(?P[^\s#]+)") +bad: list[str] = [] + +for path in sorted(glob.glob("docker-compose*.yml")): + with open(path, encoding="utf-8") as handle: + for lineno, line in enumerate(handle, start=1): + match = IMAGE_RE.match(line) + if match is None: + continue + ref = match.group("ref") + if "${" in ref: + continue + if "@sha256:" not in ref: + bad.append(f"{path}:{lineno}: {ref} (no digest pin)") + elif ":latest@" in ref: + bad.append(f"{path}:{lineno}: {ref} (floating :latest tag)") + +if bad: + raise SystemExit("unpinned compose images:\n" + "\n".join(bad)) + +print("all literal compose image refs digest-pinned") +EOF + ok "digest-pins" +} + +check_credential_fallbacks() { + python3 - <<'EOF' +import glob +import re + +# Secret-named env vars must not ship a literal fallback — a +# dollar-brace VAR with a :-hunter2 default in a published template is +# a credential leak. Use the :?message form and let dev.sh generate dev +# values. The allowlist documents the deliberate dev-only placeholders +# that dev.sh fail-closes in prod. +SECRET_FALLBACK_RE = re.compile( + r"\$\{(?P[A-Za-z0-9_]*(?:PASSWORD|SECRET|_TOKEN|_KEY)[A-Za-z0-9_]*):-(?P[^}]+)\}" +) +ALLOWED = { + # Dev DB password documented in .env.example; dev.sh requires an + # explicit POSTGRES_PASSWORD when STACK=prod. + ("POSTGRES_PASSWORD", "app_dev_password"), + # api-dev / api-migrate-dev only run under the dev profile; prod api + # reads api.prod.env instead. + ("API_DEV_JWT_SECRET", "docker-compose-api-dev-jwt-secret-keys"), + ("API_DEV_MFA_ENCRYPTION_KEY", "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="), +} +bad: list[str] = [] + +for path in sorted(glob.glob("docker-compose*.yml")): + with open(path, encoding="utf-8") as handle: + for lineno, line in enumerate(handle, start=1): + for match in SECRET_FALLBACK_RE.finditer(line): + pair = (match.group("var"), match.group("fallback")) + if pair in ALLOWED: + continue + bad.append(path + ":" + str(lineno) + ": $" + "{" + pair[0] + ":-...}") + +if bad: + raise SystemExit("hardcoded credential fallbacks in compose:\n" + "\n".join(bad)) + +print("no unallowed credential fallbacks in compose files") +EOF + ok "credential-fallbacks" +} + +check_valkey_auth() { + grep -A 3 'command:' docker-compose.yml | grep -q -- '--requirepass' \ + || fail "valkey command does not enforce --requirepass" + grep -q 'VALKEY_PASSWORD:?VALKEY_PASSWORD required in prod' dev.sh \ + || fail "dev.sh prod guard for VALKEY_PASSWORD missing" + ok "valkey-auth" +} + +check_rooted_caps() { + render_prod_config + python3 - <<'EOF' +import json + +# `user: root` is only tolerated when the root process is neutered: +# every capability dropped (allowlist re-adds only), plus +# no-new-privileges. Anything less is a full-power root container one +# RCE away from the host. +with open("/tmp/guardrails-prod-config.json", encoding="utf-8") as handle: + services = json.load(handle)["services"] + +bad: list[str] = [] + +for name, svc in services.items(): + if svc.get("user") not in ("root", "0", "0:0"): + continue + if "ALL" not in (svc.get("cap_drop") or []): + bad.append(f"{name}: user root without cap_drop: [ALL]") + if "no-new-privileges:true" not in (svc.get("security_opt") or []): + bad.append(f"{name}: user root without no-new-privileges") + +if bad: + raise SystemExit("rooted services not neutered:\n" + "\n".join(bad)) + +print("all rooted prod services drop capabilities") +EOF + ok "rooted-caps" +} + +check_prod_image_tags() { + # Behavioral test of dev.sh's fail-closed prod guard, with a curated + # env so only the image-tag checks are exercised. ENV_FILE diverts the + # .env sourcing so the operator's real values never leak in. + local guard_env=( + STACK=prod + WITH_GLITCHTIP=0 + WITH_OBSERVABILITY=0 + POSTGRES_USER=app + POSTGRES_PASSWORD=ci-guardrail-placeholder + POSTGRES_DB=app + JWT_SECRET=ci-guardrail-placeholder-padded-to-32-chars + MFA_ENCRYPTION_KEY=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY= + FRONTEND_URL=https://example.com + PUBLIC_API_URL=https://example.com/api + PUBLIC_UI_HOST=example.com + ACME_EMAIL=ops@example.com + VALKEY_PASSWORD=ci-guardrail-placeholder + ENV_FILE=/tmp/guardrails-empty.env + ) + + : > /tmp/guardrails-empty.env + chmod +x dev.sh + + local output + + # 1. Missing tags must fail closed and name the missing var. + if output=$(env "${guard_env[@]}" ./dev.sh config --quiet 2>&1); then + fail "prod accepted unset image tags" + fi + echo "$output" | grep -q "API_IMAGE_TAG" \ + || fail "error does not mention API_IMAGE_TAG: $output" + + # 2. latest must be rejected explicitly. + if output=$(env "${guard_env[@]}" API_IMAGE_TAG=latest UI_IMAGE_TAG=latest ./dev.sh config --quiet 2>&1); then + fail "prod accepted latest image tags" + fi + echo "$output" | grep -q "must be pinned in prod" \ + || fail "latest rejection message missing: $output" + + # 3. Pinned tags pass. + env "${guard_env[@]}" API_IMAGE_TAG=v0.1.0 UI_IMAGE_TAG=v0.1.0 ./dev.sh config --quiet \ + || fail "prod rejected properly pinned tags" + + ok "prod-image-tags" +} + +case "$CHECK" in + healthchecks) check_healthchecks ;; + digest-pins) check_digest_pins ;; + credential-fallbacks) check_credential_fallbacks ;; + valkey-auth) check_valkey_auth ;; + rooted-caps) check_rooted_caps ;; + prod-image-tags) check_prod_image_tags ;; + all) + check_digest_pins + check_credential_fallbacks + check_valkey_auth + check_healthchecks + check_rooted_caps + check_prod_image_tags + c_green "✓ all compose guardrails passed" + ;; + *) + fail "unknown check: $CHECK (healthchecks|digest-pins|credential-fallbacks|valkey-auth|rooted-caps|prod-image-tags|all)" + ;; +esac diff --git a/scripts/rename-project.sh b/scripts/rename-project.sh index 88688f1e..ff1e402f 100755 --- a/scripts/rename-project.sh +++ b/scripts/rename-project.sh @@ -14,12 +14,10 @@ # The compose stack name (boringstack-infra) and all container/volume # names rename via the bare boringstack → rule below. # -# Excluded from rewrite: -# - apps/api/src/lib/email/providers/cloudflare.ts and similar prose that -# legitimately references example.com URLs in docstrings. -# - generated artifacts: dist/, node_modules/, build/, .turbo/, coverage/. -# - lockfiles (bun.lock) — rerun `bun install` after the rename. -# - CHANGELOG.md history. +# Coverage is inventory-driven (every file matching the identifiers, minus +# the exclude list below) and self-verified: the script fails if any +# upstream identifier survives the rewrite. Bare example.com docstring URLs +# are untouched — only noreply@/demo@ mailbox forms are rewritten. # # Idempotent: re-running with the same names is a no-op. # @@ -112,47 +110,40 @@ printf ' %-26s → %s\n' "noreply@example.com" "noreply@$DOMAIN" printf ' %-26s → %s\n' "demo@example.com" "demo@$DOMAIN" echo -# Files to rewrite. Trade thoroughness against false positives — we keep -# the list narrow enough that "example.com" inside provider docstrings -# (cloudflare.ts, oauth provider URLs) survives. find -prune skips trees -# we never want to touch. -SCAN_PATHS=( - README.md - AGENTS.md - ROADMAP.md - setup.sh - package.json - apps/api/package.json - apps/api/.env.example - apps/api/Dockerfile - apps/api/Dockerfile.prod - apps/api/README.md - apps/api/AGENTS.md - apps/api/AGENT_CONTRACT.md - apps/api/CLAUDE.md - apps/api/SECURITY.md - apps/api/CONTRIBUTING.md - apps/api/src/config/swagger/swagger.ts - apps/ui/package.json - apps/ui/.env.example - apps/ui/README.md - apps/ui/AGENTS.md - apps/ui/AGENT_CONTRACT.md - apps/ui/CLAUDE.md - apps/ui/SECURITY.md - apps/docs/package.json - apps/docs/astro.config.mjs - apps/docs/README.md - apps/docs/DEPLOY.md - infra/compose/compose/docker-compose.yml - infra/compose/compose/.env.example - infra/compose/scripts/compose-up.sh - infra/compose/scripts/compose-down.sh - infra/compose/scripts/compose-down-clean.sh - infra/bootstrap/variables.tf - infra/bootstrap/terraform.tfvars.example +# Inventory-driven: rewrite EVERY file that carries an upstream identifier, +# minus an explicit exclude list. The previous static allowlist silently +# missed new files (metrics labels, tracer names, dev.sh project names); +# with an inventory the failure mode is gone — and the zero-hits assertion +# at the end proves it on every run. +# +# Exclusions: +# - .git / generated trees (node_modules, dist, build, coverage, .astro, +# .wrangler, .audit) — regenerated or never shipped. +# - bun.lock — rerun `bun install` after the rename instead. +# - CHANGELOG.md — history stays history. +# - LICENSE — the MIT copyright line keeps the original attribution. +# - this script itself (it must keep the upstream literals to find them). +IDENTIFIER_PATTERN='boringstack|BoringStack|API Template|noreply@example\.com|demo@example\.com' +GREP_EXCLUDES=( + -I + --exclude-dir=.git + --exclude-dir=node_modules + --exclude-dir=dist + --exclude-dir=build + --exclude-dir=coverage + --exclude-dir=.astro + --exclude-dir=.wrangler + --exclude-dir=.audit + --exclude=bun.lock + --exclude=CHANGELOG.md + --exclude=LICENSE + --exclude=rename-project.sh ) +inventory_files() { + grep -rlE "$IDENTIFIER_PATTERN" "${GREP_EXCLUDES[@]}" . 2>/dev/null || true +} + # Apply the substitutions to a single file in-place. We orchestrate # multiple sed expressions because BSD/macOS and GNU sed both accept this # layout. The order matters — longer matches first so "boringstack-api" @@ -163,9 +154,8 @@ apply_to_file() { [[ -f "$file" ]] || return 0 if [[ "$DRY_RUN" == "1" ]]; then - if grep -qE 'boringstack|BoringStack|API Template|@example\.com' "$file" 2>/dev/null; then - echo " would edit: $file" - fi + # inventory_files already guarantees a match + echo " would edit: $file" return 0 fi @@ -186,14 +176,15 @@ apply_to_file() { -e "s/boringstack/${PROJECT}/g" \ -e "s/\"API Template\"/\"${PROJECT_TITLE}\"/g" \ -e "s/APP_NAME=API Template/APP_NAME=${PROJECT_TITLE}/g" \ + -e "s/APP_NAME: API Template/APP_NAME: ${PROJECT_TITLE}/g" \ -e "s/noreply@example\.com/noreply@${DOMAIN}/g" \ -e "s/demo@example\.com/demo@${DOMAIN}/g" \ "$file" } -for path in "${SCAN_PATHS[@]}"; do +while IFS= read -r path; do apply_to_file "$path" -done +done < <(inventory_files) if [[ "$DRY_RUN" == "1" ]]; then echo @@ -201,6 +192,16 @@ if [[ "$DRY_RUN" == "1" ]]; then exit 0 fi +# Self-verification: after a real run, no upstream identifier may survive +# outside the excluded paths. If one does, the rename has a coverage bug — +# fail loudly instead of shipping a half-branded fork. +leftover="$(inventory_files)" +if [[ -n "$leftover" ]]; then + echo "ERROR: rename incomplete — upstream identifiers survive in:" >&2 + echo "$leftover" >&2 + exit 1 +fi + cat <