Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/apps-api-acl-drift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/apps-api-openapi-drift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/apps-docs-linkcheck.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/apps-ui-bundle-diff.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/apps-ui-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/apps-ui-validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/infra-compose-playwright-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
214 changes: 30 additions & 184 deletions .github/workflows/infra-compose-validate-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<ref>[^\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<var>[A-Za-z0-9_]*(?:PASSWORD|SECRET|_TOKEN|_KEY)[A-Za-z0-9_]*):-(?P<fallback>[^}]+)\}"
)
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
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion apps/api/AGENT_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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 |

Expand Down
Loading
Loading