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
51 changes: 51 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Exclude anything that would slow a build, break it, or leak into an image.

# Host-installed dependencies — NEVER ship these. npm resolves platform-specific
# native binaries here (@cloudflare/workerd-*, @esbuild/*, @tailwindcss/oxide-*)
# and a win32 build cannot execute in a Linux container. Every image runs its
# own `npm ci` instead.
node_modules/
**/node_modules/

# Build output — rebuilt inside the image. NOTE: this is exactly why every
# Dockerfile runs `npm run build -w @toggleflow/engine`, and why the dev profile
# has an engine watcher service: @toggleflow/engine resolves through its
# package.json "exports" to dist/, which never arrives with the source.
dist/
**/dist/
.turbo/
**/.turbo/
coverage/
**/coverage/
*.tsbuildinfo

# Local workerd/miniflare KV state. Containers get the `wrangler-state` named
# volume; a Windows copy of a SQLite store must never seed it.
.wrangler/
**/.wrangler/

# Stale pnpm artefacts — this repo is npm workspaces (packageManager npm@11.16.0)
# and package-lock.json is the only lockfile the Dockerfiles copy.
pnpm-lock.yaml
pnpm-workspace.yaml

.git/
.github/
.gitignore
.gitattributes
*.md

# Env values arrive via compose `environment:` (runtime) and build args (build
# time) — never baked into a layer.
.env
.env.*
!.env.example

# Not needed in any image. docker/ IS needed: the nginx confs are COPYd.
docker-compose.yml

.vscode/
.idea/
.DS_Store
Thumbs.db
*.log
46 changes: 40 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,20 +1,44 @@
# Copy to .env and adjust. .env is gitignored.
#
# THIS FILE IS FOR BARE-METAL DEV (`npm run dev` on the host).
#
# Two things worth knowing:
#
# 1. Nothing loads this file into the app. There is no dotenv and no
# --env-file anywhere in the repo, and Node does not auto-load .env. Bare-metal
# dev works because every value below equals the zod default in
# apps/api/src/env.ts. Change one here and the app will NOT see it — export it
# in your shell instead.
#
# 2. Docker Compose DOES read this file, for ${VAR} interpolation. That means
# anything defined here overrides the `:-default` build args in
# docker-compose.yml. The VITE_* values below deliberately match those
# defaults so the shadowing is a no-op. Verify with:
# docker compose --profile prod config | grep VITE_
# Container runtime values are set inline in docker-compose.yml, not here —
# they differ (postgres:5432, not localhost:5434).

# --- Control-plane API (apps/api) ---
PORT=4000
HOST=0.0.0.0
# ToggleFlow's own dev Postgres (docker compose up -d). Host port 5434 —
# 5433 belongs to the fixmytext stack's velobits-dev-db.
# ToggleFlow's own dev Postgres (`docker compose --profile dev up -d postgres`).
# Host port 5434 — 5433 belongs to the fixmytext stack's velobits-dev-db.
DATABASE_URL=postgres://toggleflow:toggleflow@localhost:5434/toggleflow

# --- Auth (shared keycloak-dev + shared Velobits-Dev realm, velobits-infra stack) ---
# --- Auth (shared keycloak-dev + shared Velobits realm, velobits-infra stack) ---
# Start that stack first (`docker compose up -d` in ../velobits-infra).
# ToggleFlow uses the shared Velobits-Dev realm with its own clients
# ToggleFlow uses the shared Velobits realm with its own clients
# (toggleflow-dashboard SPA/PKCE + toggleflow-api bearer audience) —
# decided 2026-07-27, superseding the earlier dedicated-realm plan.
# Host address below; containerized services use http://keycloak-dev:8080.
# KEYCLOAK_URL is the ISSUER: it must match the `iss` claim byte-for-byte.
# velobits-infra pins KC_HOSTNAME=localhost, so every token says
# http://localhost:8080/realms/Velobits regardless of the request's Host header.
KEYCLOAK_URL=http://localhost:8080
KEYCLOAK_REALM=Velobits-Dev
# Containers only: the base used for the JWKS *network fetch*, because the issuer
# address above is unreachable from inside one. docker-compose.yml sets this to
# http://keycloak-dev:8080; leave it unset on bare metal.
# KEYCLOAK_INTERNAL_URL=http://keycloak-dev:8080
KEYCLOAK_REALM=Velobits
# Access-token audience the API requires (set by the dashboard client's audience mapper).
KEYCLOAK_AUDIENCE=toggleflow-api

Expand All @@ -36,3 +60,13 @@ PUBLISH_DEBOUNCE_MS=500
# Production only (KV_MODE=cloudflare); also used by wrangler for deploys.
# CLOUDFLARE_API_TOKEN=
# CLOUDFLARE_ACCOUNT_ID=

# --- Dashboard (apps/dashboard) ---
# Vite BAKES these into the bundle at build time (apps/dashboard/src/auth/oidc.ts),
# so they are browser-facing HOST addresses even for containerized builds. These
# also feed the ${} build args in docker-compose.yml — see the header note about
# shadowing, and keep VITE_KEYCLOAK_URL in sync with the CSP connect-src in
# docker/nginx.router.prod.conf.
VITE_KEYCLOAK_URL=http://localhost:8080
VITE_KEYCLOAK_REALM=Velobits
VITE_KEYCLOAK_CLIENT_ID=toggleflow-dashboard
14 changes: 14 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Normalise line endings to LF in the repository. CRLF reaching a Linux
# container produces errors that look nothing like a line-ending problem:
# Dockerfile with CRLF → exec /bin/sh: no such file or directory
# *.sh with CRLF → $'\r': command not found
# env file with CRLF → a trailing \r INSIDE the value, so a DATABASE_URL
# becomes "…/toggleflow\r" and Postgres rejects it
* text=auto eol=lf

Dockerfile* text eol=lf
*.sh text eol=lf
*.conf text eol=lf
.env* text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
102 changes: 93 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,114 @@
jobs:
ci:
runs-on: ubuntu-latest

services:
# apps/api's integration tests hit a real Postgres and TRUNCATE all 13
# tables between suites (apps/api/test/helpers.ts). Port 5434 matches the
# DATABASE_URL default in apps/api/src/env.ts and docker-compose.yml, so
# nothing needs a special-case URL.
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: toggleflow
POSTGRES_PASSWORD: toggleflow
POSTGRES_DB: toggleflow
ports:
- 5434:5432
options: >-
--health-cmd "pg_isready -U toggleflow -d toggleflow"
--health-interval 5s
--health-timeout 3s
--health-retries 10

steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
cache: npm

- name: Install dependencies
run: pnpm install --frozen-lockfile
run: npm ci

- name: Lint
run: pnpm lint
run: npm run lint

- name: Format check
run: pnpm format:check
run: npm run format:check

- name: Typecheck
run: pnpm typecheck
run: npm run typecheck

- name: Build
run: pnpm build
run: npm run build

- name: Test
run: pnpm test
run: npm test
env:
# Set explicitly rather than relying on the zod default: every var in
# apps/api/src/env.ts has one, so a mis-wired database never throws at
# boot — it fails at the first query, much further from the cause.
DATABASE_URL: postgres://toggleflow:toggleflow@localhost:5434/toggleflow

docker-smoke:
name: Docker prod smoke
runs-on: ubuntu-latest
# Fail fast rather than sitting until GitHub's 6h default if something hangs.
timeout-minutes: 25

steps:
- uses: actions/checkout@v4

# ../velobits-infra owns velobits-net and does not exist in CI. api-prod
# joins it as external, so it must exist or `up` errors out. No Keycloak is
# needed here: /health sits outside the /v1/ auth hook and jose's
# createRemoteJWKSet fetches lazily — only an authenticated request would
# try to reach it.
- name: Create the external network the compose file expects
run: docker network create velobits-net || true

- name: Start the prod stack
run: docker compose --profile prod up --build -d

- name: Wait for the router
run: |
for i in $(seq 1 60); do
if curl -sf -o /dev/null http://localhost:3200/healthz; then
echo "router up after ${i} attempts"; exit 0
fi
sleep 5
done
echo "router did not become ready" >&2
exit 1

- name: Smoke — the stack through one origin
run: |
set -euo pipefail
# Dashboard: nginx serves the built SPA at the webroot.
curl -sf http://localhost:3200/ | grep -q '<div id="root"'
# SPA history fallback — react-router-dom owns /tools client-side, so a
# deep link must return the app shell rather than a 404.
curl -sf -o /dev/null http://localhost:3200/tools
# Control plane, through the /api prefix strip.
curl -sf http://localhost:3200/api/health | grep -q '"status":"ok"'
# Migrations actually ran (api-prod gates on service_completed_successfully).
docker compose --profile prod exec -T postgres \
psql -U toggleflow -d toggleflow -c '\dt' | grep -q ' tools '
# Unauthenticated /v1/* is rejected — proves the auth hook is wired.
test "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3200/api/v1/me)" = 401
# The delivery plane is Cloudflare-only in prod, so /edge/* must 404
# rather than fall through to the SPA fallback and return HTML with a 200.
test "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3200/edge/health)" = 404
# Exactly one of each security header — the router hides the static
# image's duplicates.
test "$(curl -sI http://localhost:3200/ | grep -ci '^x-frame-options:')" = 1

- name: Dump logs
if: always()
run: docker compose --profile prod logs --no-color | tail -200

- name: Tear down
if: always()
run: docker compose --profile prod down -v

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
Comment on lines +63 to +121
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ coverage/
# misc
*.log
.DS_Store

.claude*
2 changes: 1 addition & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ dist/
.turbo/
.wrangler/
coverage/
pnpm-lock.yaml
package-lock.json
apps/api/drizzle/
Loading
Loading