Skip to content

Commit dc59862

Browse files
Merge branch 'master' into fix/api-claim-preview-items-alias-2026-05-30
2 parents 18101d2 + 3a4eb79 commit dc59862

4 files changed

Lines changed: 563 additions & 0 deletions

File tree

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
# Layer-2 auth-contract PR gate. Spins up a docker-compose stack with
2+
# postgres + redis + the api binary BUILT FROM THIS PR'S SOURCE, then runs
3+
# the same Playwright contract assertions that the Layer-1 prod-target
4+
# spec runs (instanode-web/e2e/auth-contract.spec.ts + this repo's
5+
# e2e/browser/tests/auth-contract-local.spec.ts). Difference: this fires
6+
# on every PR and reds the PR if the contract regresses — Layer-1 catches
7+
# regressions ~5 minutes POST-deploy, this catches them PRE-merge.
8+
#
9+
# Cost ceiling: ~5 min wall clock per PR (compose build dominates ~3 min).
10+
# No path filter — the auth surface is implicit (a router change, a CORS
11+
# config tweak, a magic-link handler tweak, a config.Load default flip
12+
# could all break it without touching obvious "auth" paths).
13+
#
14+
# What this does NOT cover:
15+
# - email delivery (worker + Brevo; covered by post-deploy auth-probe).
16+
# - dashboard SPA cookie exchange round-trip (covered by Layer-1 prod
17+
# spec — needs a real web origin DNS record).
18+
# - rate-limit / abuse-defence paths (covered by unit tests).
19+
# What this DOES cover that nothing else does:
20+
# - the literal CORS preflight headers from the PR's api binary, against
21+
# a real Chromium fetch — closes the 2026-05-29 / 2026-05-30 outage
22+
# class at PR time.
23+
24+
name: Auth Contract (Layer-2 compose Playwright)
25+
26+
on:
27+
pull_request:
28+
branches: [master]
29+
# NO paths-ignore. The auth surface is the union of:
30+
# internal/router/router.go (CORS config)
31+
# internal/handlers/auth*.go (Exchange / Email)
32+
# internal/handlers/magic_link.go
33+
# internal/middleware/preflight_allowlist.go
34+
# internal/config/config.go (Environment default)
35+
# internal/db/migrations/* (magic_link table shape)
36+
# Any of these can regress the contract — the only honest filter is
37+
# "every PR". The 5-min wall-clock budget makes this affordable.
38+
workflow_dispatch:
39+
40+
concurrency:
41+
group: auth-contract-compose-${{ github.ref }}
42+
cancel-in-progress: true
43+
44+
jobs:
45+
auth-contract:
46+
runs-on: ubuntu-latest
47+
timeout-minutes: 12
48+
steps:
49+
- name: Checkout api
50+
uses: actions/checkout@v6
51+
with:
52+
path: api
53+
54+
# The Dockerfile multi-stage build does `COPY proto/`, `COPY common/`,
55+
# `COPY api/` — so the build context needs all three as siblings.
56+
# Identical pattern to ci.yml / deploy.yml.
57+
- name: Checkout proto sibling (for go.mod replace ../proto)
58+
uses: actions/checkout@v6
59+
with:
60+
repository: ${{ vars.PROTO_REPO || format('{0}/proto', github.repository_owner) }}
61+
token: ${{ secrets.REPO_ACCESS_TOKEN || secrets.GITHUB_TOKEN }}
62+
path: proto
63+
64+
- name: Checkout common sibling (for go.mod replace ../common)
65+
uses: actions/checkout@v6
66+
with:
67+
repository: ${{ vars.COMMON_REPO || format('{0}/common', github.repository_owner) }}
68+
token: ${{ secrets.REPO_ACCESS_TOKEN || secrets.GITHUB_TOKEN }}
69+
path: common
70+
71+
- name: Set up Node (for Playwright)
72+
uses: actions/setup-node@v5
73+
with:
74+
node-version: '20'
75+
cache: 'npm'
76+
cache-dependency-path: api/e2e/browser/package-lock.json
77+
78+
- name: Install Playwright + Chromium
79+
working-directory: api/e2e/browser
80+
# `npm ci` keeps lockfile drift out of CI; --with-deps installs the
81+
# system libs Chromium needs on a fresh ubuntu-latest runner.
82+
run: |
83+
npm ci
84+
npx playwright install --with-deps chromium
85+
86+
- name: Build + start docker-compose stack
87+
# Compose resolves `context: ..` (in api/docker-compose.ci.yml)
88+
# RELATIVE TO THE COMPOSE FILE'S DIRECTORY by default, which lands
89+
# on the GitHub workspace root holding proto/, common/, api/ — exactly
90+
# the path the multi-stage Dockerfile expects for its three COPY
91+
# lines. Build args stamp /healthz commit_id with the real PR SHA
92+
# so the artifact emitted below is comparable to $GITHUB_SHA.
93+
env:
94+
GIT_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
95+
BUILD_TIME: ${{ github.event.repository.updated_at }}
96+
VERSION: pr-${{ github.event.pull_request.number || 'manual' }}
97+
run: |
98+
set -euo pipefail
99+
docker compose \
100+
-f api/docker-compose.ci.yml \
101+
up -d --build
102+
103+
- name: Wait for api /healthz to return 200
104+
# 90s ceiling — postgres pull + start + api migration apply +
105+
# listener bind. If we ever blow past this, the api isn't healthy
106+
# and the test would fail downstream anyway; failing here gives a
107+
# cleaner diagnostic.
108+
run: |
109+
set -euo pipefail
110+
for i in $(seq 1 45); do
111+
if curl -sf http://localhost:8080/healthz | tee /tmp/healthz.json | grep -q '"ok":true'; then
112+
echo "api healthy after ${i} attempts ($((i*2))s)"
113+
break
114+
fi
115+
echo "waiting for api (${i}/45)"
116+
sleep 2
117+
done
118+
if ! curl -sf http://localhost:8080/healthz >/dev/null; then
119+
echo "::error::api never became healthy in 90s"
120+
docker compose -f api/docker-compose.ci.yml ps
121+
docker compose -f api/docker-compose.ci.yml logs --tail=200 api
122+
exit 1
123+
fi
124+
echo "── /healthz ────────────────────────────────"
125+
cat /tmp/healthz.json
126+
echo
127+
128+
- name: Run Layer-2 Playwright spec
129+
working-directory: api/e2e/browser
130+
env:
131+
E2E_API_URL: http://localhost:8080
132+
E2E_WEB_ORIGIN: http://localhost:5173
133+
CI: 'true'
134+
# Use the chromium-compose-pna project so Chromium's Local /
135+
# Private Network Access checks are disabled (see playwright.config.ts
136+
# — both origin and api live in loopback under this stack, which
137+
# PNA blocks even though it never trips in prod's public→public flow).
138+
run: npx playwright test tests/auth-contract-local.spec.ts --project=chromium-compose-pna --reporter=list
139+
140+
- name: Emit gate-fired signal (rule 25 — observability)
141+
# Compose runs are a CI-internal signal, not a prod metric (so they
142+
# don't need an NR alert+dashboard per rule 25's literal text). But
143+
# we DO want to be able to answer "did the gate fire on the last
144+
# N PRs?" without scraping job logs. A 1-line newline-delimited
145+
# JSON artifact does that — downloadable per-run, greppable by
146+
# date, no infrastructure required.
147+
if: always()
148+
# SECURITY: route every GitHub-context interpolation through env:
149+
# rather than splicing into the shell, even though all four values
150+
# here are GitHub-controlled enums/integers/hashes (no user-author
151+
# input). Keeps the surface uniformly safe — same pattern as the
152+
# ci.yml::dispatch-auth-contract-e2e step.
153+
env:
154+
PR_NUMBER: ${{ github.event.pull_request.number || 'manual' }}
155+
PR_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
156+
JOB_STATUS: ${{ job.status }}
157+
run: |
158+
set -euo pipefail
159+
# Defensive shape checks — PR_NUMBER is an integer or "manual",
160+
# SHA is hex. Cheap to enforce, blocks the (theoretical) command
161+
# injection vector if a future GitHub bug ever lets these leak.
162+
case "$PR_NUMBER" in
163+
manual|[0-9]*) ;;
164+
*) echo "::error::unexpected PR_NUMBER shape"; exit 1 ;;
165+
esac
166+
case "$PR_SHA" in
167+
[0-9a-f]*) ;;
168+
*) echo "::error::unexpected SHA shape"; exit 1 ;;
169+
esac
170+
case "$JOB_STATUS" in
171+
success|failure|cancelled) ;;
172+
*) echo "::error::unexpected JOB_STATUS"; exit 1 ;;
173+
esac
174+
mkdir -p /tmp/gate-signal
175+
printf '{"gate":"auth-contract-compose-pw","pr":"%s","sha":"%s","status":"%s","ts":"%s"}\n' \
176+
"$PR_NUMBER" \
177+
"$PR_SHA" \
178+
"$JOB_STATUS" \
179+
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
180+
> /tmp/gate-signal/auth-contract-compose.jsonl
181+
cat /tmp/gate-signal/auth-contract-compose.jsonl
182+
183+
- name: Upload gate-fired signal artifact
184+
if: always()
185+
uses: actions/upload-artifact@v4
186+
with:
187+
name: auth-contract-gate-signal
188+
path: /tmp/gate-signal/auth-contract-compose.jsonl
189+
retention-days: 30
190+
191+
- name: Upload Playwright report on failure
192+
if: failure()
193+
uses: actions/upload-artifact@v4
194+
with:
195+
name: playwright-report-auth-contract-layer2
196+
path: api/e2e/browser/playwright-report/
197+
retention-days: 14
198+
199+
- name: Dump api logs on failure
200+
if: failure()
201+
run: |
202+
echo "── docker compose ps ───────────────────────"
203+
docker compose -f api/docker-compose.ci.yml ps || true
204+
echo "── api logs (tail 500) ─────────────────────"
205+
docker compose -f api/docker-compose.ci.yml logs --tail=500 api || true
206+
echo "── postgres logs (tail 200) ────────────────"
207+
docker compose -f api/docker-compose.ci.yml logs --tail=200 postgres || true
208+
209+
- name: Tear down
210+
if: always()
211+
run: |
212+
docker compose -f api/docker-compose.ci.yml down -v || true

docker-compose.ci.yml

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# docker-compose.ci.yml — Layer-2 PR-gate harness.
2+
#
3+
# Purpose
4+
# -------
5+
# The Layer-1 auth-contract Playwright spec
6+
# (instanode-web/e2e/auth-contract.spec.ts + this repo's PR-time dispatch in
7+
# ci.yml::dispatch-auth-contract-e2e) drives Chromium against PRODUCTION api.
8+
# It catches the AUTH-004-class regression AFTER an api PR merges + deploys.
9+
#
10+
# This compose stack is the Layer-2 gate: spin up a minimal real-binary api
11+
# built from the PR's branch, run the SAME contract assertions against it
12+
# locally inside the GH Actions runner, fail the PR BEFORE merge if the
13+
# preflight loses ACAO / ACAC or /auth/email/start stops returning 202.
14+
#
15+
# Anti-goals (deliberately not in here)
16+
# -------------------------------------
17+
# - worker + provisioner. The auth surface does not need them. Magic-link
18+
# /auth/email/start writes a row and returns 202 even if the downstream
19+
# email backend is missing — that's deliberate enumeration defence (see
20+
# handlers/magic_link.go::Start) and exactly what makes this stack
21+
# viable without a worker.
22+
# - object storage, NATS, mongo. None on the auth path.
23+
# - shipping this to prod. This file is CI-only. infra/docker-compose.yml
24+
# remains the local-dev stack; this file is a peer not a replacement.
25+
#
26+
# Build context
27+
# -------------
28+
# Built from the REPO PARENT (the workspace that holds proto/, common/, api/
29+
# as siblings) because the Dockerfile expects all three. In CI the workflow
30+
# checks out proto+common as siblings of api/ and runs
31+
# docker compose -f api/docker-compose.ci.yml --project-directory .. up -d --build
32+
# so the build context resolves the COPY proto/, COPY common/, COPY api/
33+
# lines exactly as deploy.yml does.
34+
#
35+
# Resources
36+
# ---------
37+
# postgres:17-alpine + redis:7-alpine. The api auto-runs migrations on boot
38+
# (main.go::runMigrations) so no separate migrator container is needed.
39+
#
40+
# CORS contract
41+
# -------------
42+
# The router (internal/router/router.go ~L237) appends
43+
# http://localhost:5173,3000,5174 to the CORS allowlist when ENVIRONMENT=
44+
# development. The Playwright spec stubs out http://localhost:5173 as the
45+
# document origin so the cross-origin fetch to http://localhost:8080 is
46+
# genuinely cross-origin and exercises the same code path that ships to
47+
# prod.
48+
49+
services:
50+
postgres:
51+
# Postgres 17 — newer than CI's :16-alpine but compatible with every
52+
# migration in internal/db/migrations/. Pinning to a recent major catches
53+
# any forward-compat breakage at PR time rather than at infra-bump time.
54+
image: postgres:17-alpine
55+
environment:
56+
POSTGRES_USER: postgres
57+
POSTGRES_PASSWORD: postgres
58+
POSTGRES_DB: instant_platform
59+
healthcheck:
60+
# `pg_isready` is the standard probe — accepting connections == ready
61+
# for the api's RunMigrations call.
62+
test: ["CMD-SHELL", "pg_isready -U postgres -d instant_platform"]
63+
interval: 2s
64+
timeout: 3s
65+
retries: 30
66+
67+
redis:
68+
image: redis:7-alpine
69+
healthcheck:
70+
test: ["CMD", "redis-cli", "ping"]
71+
interval: 2s
72+
timeout: 3s
73+
retries: 30
74+
75+
api:
76+
# Build from repo PARENT so the multi-stage Dockerfile can COPY proto/
77+
# + common/ + api/ as siblings. The CI workflow runs `docker compose
78+
# ... --project-directory ..` to set the build context root accordingly.
79+
build:
80+
context: ..
81+
dockerfile: api/Dockerfile
82+
args:
83+
# Deterministic stamp so /healthz commit_id is comparable across runs
84+
# (we want it to equal $GITHUB_SHA in CI; falls back to "ci-local"
85+
# for laptop runs).
86+
GIT_SHA: ${GIT_SHA:-ci-local}
87+
BUILD_TIME: ${BUILD_TIME:-ci-local}
88+
VERSION: ${VERSION:-ci-local}
89+
depends_on:
90+
postgres:
91+
condition: service_healthy
92+
redis:
93+
condition: service_healthy
94+
ports:
95+
- "8080:8080"
96+
environment:
97+
# Required by config.Load (see internal/config/config.go::Load).
98+
DATABASE_URL: postgres://postgres:postgres@postgres:5432/instant_platform?sslmode=disable
99+
# 64-hex = 32 raw bytes — matches AES-256-GCM key requirement. Public
100+
# test value, never reused outside this compose stack.
101+
AES_KEY: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
102+
JWT_SECRET: ci-test-jwt-secret-not-used-in-prod
103+
# ENVIRONMENT=development is what unlocks the http://localhost:5173,
104+
# :3000, :5174 origins in the CORS allowlist (router.go ~L237). Without
105+
# this, the Playwright spec's cross-origin POST would be blocked by
106+
# CORS and silently pass the wrong contract.
107+
ENVIRONMENT: development
108+
REDIS_URL: redis://redis:6379
109+
# Disable expensive optional providers. None of these are on the auth
110+
# surface so we set them to "noop"/empty equivalents.
111+
INSTANT_ENABLED_SERVICES: redis,postgres
112+
# PostgresCustomersURL is required by config.Load (default points at a
113+
# k8s DNS name that doesn't resolve here). Point at the same Postgres
114+
# — the auth contract doesn't exercise customer-DB provisioning so
115+
# whether the URL works is irrelevant; we just need config.Load to
116+
# accept it.
117+
POSTGRES_CUSTOMERS_URL: postgres://postgres:postgres@postgres:5432/instant_platform?sslmode=disable
118+
# Skip the geo-IP DB lookup (no MMDB volume in this stack). middleware
119+
# GeoEnrich no-ops when the DB pointer is nil.
120+
GEOLITE2_DB_PATH: /tmp/no-such-geolite2.mmdb
121+
healthcheck:
122+
# /healthz returns 200 once migrations + DB ping succeed. The wget is
123+
# alpine-bundled so no extra package install.
124+
test: ["CMD-SHELL", "wget -q -O- http://localhost:8080/healthz | grep -q ok || exit 1"]
125+
interval: 3s
126+
timeout: 3s
127+
retries: 40
128+
start_period: 10s

e2e/browser/playwright.config.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,33 @@ export default defineConfig({
2929
name: 'chromium',
3030
use: { ...devices['Desktop Chrome'] },
3131
},
32+
{
33+
// Layer-2 docker-compose auth-contract gate
34+
// (tests/auth-contract-local.spec.ts) — needs Chromium's Local /
35+
// Private Network Access checks disabled because both the document
36+
// origin (http://localhost:5173, stubbed) and the api (http://localhost
37+
// :8080) live in the loopback address space, and Chromium blocks
38+
// even loopback→loopback fetches as a CORS pre-PNA "permission denied"
39+
// when there is no Access-Control-Allow-Private-Network header.
40+
// PROD does not hit this case (instanode.dev → api.instanode.dev are
41+
// both public addresses), so the PNA disable is strictly a localhost
42+
// shim — it does NOT weaken the contract under test, which is the
43+
// CORS allow-origin + allow-credentials response from the api.
44+
name: 'chromium-compose-pna',
45+
testMatch: /auth-contract-local\.spec\.ts/,
46+
use: {
47+
...devices['Desktop Chrome'],
48+
launchOptions: {
49+
args: [
50+
// Disable the full family of PNA / LNA blocking features. Names
51+
// have shifted across Chromium versions (PrivateNetworkAccess*
52+
// → LocalNetworkAccessChecks) so we list both — unknown names
53+
// are silently ignored by Chromium, so over-listing is safe.
54+
'--disable-features=LocalNetworkAccessChecks,PrivateNetworkAccessSendPreflights,PrivateNetworkAccessRespectPreflightResults,BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessPermissionPrompt',
55+
],
56+
},
57+
},
58+
},
3259
],
3360
// No webServer — the k8s API is already running.
3461
});

0 commit comments

Comments
 (0)