diff --git a/.ciagent/release/deploy.sh b/.ciagent/release/deploy.sh index 2d29b67..551234b 100755 --- a/.ciagent/release/deploy.sh +++ b/.ciagent/release/deploy.sh @@ -204,7 +204,10 @@ SSH "cd '$RELEASE_DIR/backend' && \ docker build -t '$IMAGE_BACKEND:$VERSION' -t '$IMAGE_BACKEND:latest' ." \ || die "backend docker build failed on host" 4 SSH "cd '$RELEASE_DIR/frontend' && \ - docker build -t '$IMAGE_NAME:$VERSION' -t '$IMAGE_NAME:latest' ." \ + docker build \ + --build-arg NEXT_PUBLIC_RESEARCHX_API_MODE=live \ + --build-arg NEXT_PUBLIC_API_BASE_URL=\"\" \ + -t '$IMAGE_NAME:$VERSION' -t '$IMAGE_NAME:latest' ." \ || die "frontend docker build failed on host" 4 # ---- swap container ------------------------------------------------------ @@ -260,6 +263,17 @@ log "checking the backend can write its data root" SSH "docker exec '$CONTAINER_BACKEND' sh -c 'touch \"\$RESEARCHX_DATA_DIR/.write-probe\" && rm -f \"\$RESEARCHX_DATA_DIR/.write-probe\"'" \ || die "backend data root is not writable by the container user" 5 +# Auth smoke (RESEARCH-v03.md section 10 pitfall; PLAN.md T-4.7(1) host-side +# leg): the auth-guarded surface answers 401 unauthenticated. This proves +# the guarded surface is up through the deployed backend -- it does NOT +# discriminate the serving entrypoint (both create_app shapes default +# auth_enabled=True; the entrypoint is pinned by the Dockerfile CMD + the +# build-stage assert). The live-mode tier marker (an authenticated +# /api/config) rides the staged smoke suite, owner directive (D-019). +log "checking GET /api/config answers 401 unauthenticated" +AUTH_CODE=$(SSH "curl -s -o /dev/null -w '%{http_code}' 'http://127.0.0.1:$HOST_PORT_BACKEND/api/config'") +[ "$AUTH_CODE" = "401" ] || die "GET /api/config answered '$AUTH_CODE' unauthenticated, expected 401" 5 + log "health-checking frontend http://$DEPLOY_HOST:$HOST_PORT$HEALTH_PATH (retries: $HEALTH_RETRIES)" i=0 while [ "$i" -lt "$HEALTH_RETRIES" ]; do diff --git a/.ciagent/release/haproxy-researchx.cfg b/.ciagent/release/haproxy-researchx.cfg new file mode 100644 index 0000000..7b241dc --- /dev/null +++ b/.ciagent/release/haproxy-researchx.cfg @@ -0,0 +1,59 @@ +# research.coreci.dev — HAProxy front config for phase 4 (REQ-84, G-104) +# +# DOCUMENTATION + idempotent template (PLAN.md T-4.5, RESEARCH-v03.md §9): +# the host already runs HAProxy 2.8 with the TLS frontend for +# research.coreci.dev. This file is the reviewed shape to fold into that +# front. It is applied to the live host ONLY on owner deploy directive +# (D-019) — never autonomously from a deploy run. The host's live config +# is not readable from the authoring seat, so `timeout client` / +# `defaults` values below are stated as requirements, not as the +# host's current numbers. + +# ---- requirements the host's defaults must already satisfy ---------------- +# timeout client 3600s # MUST exceed the 15s sse-starlette ping +# # interval (RESEARCH-v03.md §9) or idle SSE +# # streams are cut mid-run. +# keep-alive default ON # keep http-keep-alive; the SSE stream rides it. + +# ---- frontend routes ------------------------------------------------------- +frontend https-in + acl is_api path_beg /api + acl is_health path /healthz + use_backend backend_api if is_api or is_health + default_backend frontend_next + + # G-104 per-IP login rate limit (PLAN.md T-4.5 scope): stick-table on + # /api/auth/*. Threshold below is a STARTING VALUE for the + # security-engineer review, not a measured traffic figure — rate + # before applying. 30 auth requests / 60s / IP. + acl is_auth path_beg /api/auth + stick-table type ip size 100k expire 60s store http_req_rate(60s) + http-request track-sc0 src if is_auth + http-request deny deny_status 429 if is_auth { sc_http_req_rate(0) gt 30 } + +# ---- backend: the API container -------------------------------------------- +# SSE hazards (RESEARCH-v03.md §9): HAProxy 2.x does NOT buffer response +# bodies in HTTP mode by default, so text/event-stream streams natively. +# The three things that would break it are deliberately absent here: +# - NO `option http-buffer-response` on this backend +# - NO compression (gzip) on this backend — gzip buffers +# text/event-stream; the "classic" silent SSE killer +# - keep the sse-starlette ping (15s) untouched +backend backend_api + option httpchk GET /healthz + default-server inter 5s fall 3 rise 2 + server api1 127.0.0.1:8000 check + timeout server 3600s + timeout tunnel 3600s + +# ---- backend: the Next.js container ---------------------------------------- +backend frontend_next + timeout server 300s + server next1 127.0.0.1:3000 check + +# ---- health contract ------------------------------------------------------- +# GET /healthz -> 200 {"status":"ok"} (public router, outside /api per +# D-103; the only unauthenticated route — T-4.8). +# The API container must publish on 127.0.0.1:8000 (loopback only: the +# public surface is the TLS front, so the backend is never directly +# reachable from outside the host). \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 55d2830..1accdb9 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -14,6 +14,23 @@ FROM node:22-alpine AS build RUN corepack enable WORKDIR /app ENV NEXT_TELEMETRY_DISABLED=1 + +# Hosted build profile (PLAN.md T-4.3, RESEARCH-v03.md section 10): Next.js +# inlines NEXT_PUBLIC_* at BUILD time, so the profile is ARG/ENV here -- +# never runtime flags. live mode + EMPTY base ("" = same-origin through the +# front proxy's /api path route): a non-empty base would break the +# same-origin session cookie behind the TLS front. +ARG NEXT_PUBLIC_RESEARCHX_API_MODE=live +ARG NEXT_PUBLIC_API_BASE_URL="" +ENV NEXT_PUBLIC_RESEARCHX_API_MODE="${NEXT_PUBLIC_RESEARCHX_API_MODE}" \ + NEXT_PUBLIC_API_BASE_URL="${NEXT_PUBLIC_API_BASE_URL}" + +# Build assert: the hosted profile is hardwired -- mode=live, empty base -- +# or the build fails before a wrong image can be produced. +RUN [ "$NEXT_PUBLIC_RESEARCHX_API_MODE" = "live" ] \ + && [ -z "$NEXT_PUBLIC_API_BASE_URL" ] \ + || { echo "hosted build profile violated: mode=$NEXT_PUBLIC_RESEARCHX_API_MODE" >&2; exit 1; } + COPY --from=deps /app/node_modules ./node_modules COPY . . RUN pnpm build