Merge pull request 'Phase 4 slice 3 - hosted build profile + SSE-safe HAProxy front template + deploy wiring (REQ-84/85 legs)' (#34) from po/phase4-host-live into milestone/v0.3-hosted
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (push) Successful in 1m18s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (push) Successful in 2m12s

Reviewed-on: #34
This commit was merged in pull request #34.
This commit is contained in:
2026-10-31 21:24:23 +00:00
3 changed files with 91 additions and 1 deletions
+15 -1
View File
@@ -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
+59
View File
@@ -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).
+17
View File
@@ -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