From 83409bef7ac4f56929cc77d0a0d06fccac7b72de Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 20 Aug 2026 16:14:09 +0900 Subject: [PATCH] feat: give the frontend a deployment artifact, and show its logo The repository had no container image and no production-shaped serving configuration. `dist/server.mjs` is a preview server that applies neither the security headers nor the cache policy `config/hosting/` declares, so a deployment had nothing correct to run. `scripts/generate-nginx-config.ts` derives the server block from `dist/tech-log-serving-contract.json` plus the two hosting policy files, so the served headers and cache lifetimes cannot drift from what the contract declares. It emits no TLS and no proxy blocks: the edge terminates TLS and routes /api, and baking a backend address into the image would tie the bundle to one deployment. Static surfaces use `alias` because a base-path build serves /dev/assets/... out of dist/assets/..., which `root` plus URI would look for one directory too deep. The image copies that config next to the bundle and normalises permissions: the build writes config.json 0600, which nginx cannot read, so the container came up healthy and answered 403 for the one file the SPA needs to boot. index.html never referenced public/favicon.svg. The file shipped and nginx served it, but browsers asked for /favicon.ico, got a 404, and fell back to the default icon. `%BASE_URL%` rather than an absolute path so a prefixed deployment points at its own copy. development.json moves to the HTTP Studio source; the mock source has no backend to authenticate against, which is the whole point of that profile. --- Dockerfile | 96 ++++++++++++ config/runtime/development.json | 2 +- deploy/keycloak/README.md | 24 +++ deploy/keycloak/tech-log-realm.json | 57 +++++++ docker-compose.dev-stack.yml | 180 ++++++++++++++++++++++ index.html | 5 + scripts/generate-nginx-config.ts | 228 ++++++++++++++++++++++++++++ 7 files changed, 591 insertions(+), 1 deletion(-) create mode 100644 Dockerfile create mode 100644 deploy/keycloak/README.md create mode 100644 deploy/keycloak/tech-log-realm.json create mode 100644 docker-compose.dev-stack.yml create mode 100644 scripts/generate-nginx-config.ts diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..67e39ce --- /dev/null +++ b/Dockerfile @@ -0,0 +1,96 @@ +# syntax=docker/dockerfile:1 +# +# The frontend deployment artifact. The repository had none — `dist/server.mjs` +# is a preview server that applies neither the security headers nor the cache +# policy `config/hosting/` declares — so a deployment had nothing to run. +# +# Two stages: the build produces `dist/` and, from the serving contract, the +# nginx configuration that matches it; the runtime is nginx with both. + +# --------------------------------------------------------------------------- +# build +# --------------------------------------------------------------------------- +# Pinned by digest: the release-provenance gate requires an immutable runner +# identity, and a floating tag cannot give one. +FROM node@sha256:3638d9a6fe4030bd716be989438248074489337ba3275657f93595428be4fc03 AS build +WORKDIR /src + +# The profile is baked at build time (scripts/generate-runtime-config.ts), so it +# has to be chosen here rather than at `docker run`. `dist/config.json` stays a +# separate file in the image, which is what makes build-once/promote possible: +# a deployment can replace just that file without rebuilding the bundle. +ARG APP_PROFILE=production +ENV APP_PROFILE=${APP_PROFILE} + +# The bundle and the nginx locations must agree on the prefix the deployment +# serves this under: "/" at a domain root, "/dev/" behind a path prefix. +ARG VITE_ROUTER_BASE_PATH=/ +ENV VITE_ROUTER_BASE_PATH=${VITE_ROUTER_BASE_PATH} + +# `CI=true` turns on the release-provenance gate (scripts/lib/build-environment.ts), +# which refuses to build without an identity for the artifact. That is the point: +# a deployed bundle that cannot say which commit it came from is not traceable, +# and the checklist asks exactly that. Supplied as build args so the caller — +# a pipeline or the deploy script — owns the values. +ENV CI=true +ARG VITE_BUILD_ID +ARG VITE_COMMIT_SHA +ARG RELEASE_ID +ARG CI_RUNNER_IMAGE +ARG SOURCE_DATE_EPOCH +ENV VITE_BUILD_ID=${VITE_BUILD_ID} +ENV VITE_COMMIT_SHA=${VITE_COMMIT_SHA} +ENV RELEASE_ID=${RELEASE_ID} +ENV CI_RUNNER_IMAGE=${CI_RUNNER_IMAGE} +ENV SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH} + +# The two values a deployment is allowed to supply (scripts/generate-runtime- +# config.ts OVERRIDES); everything else is fixed by the profile. API_BASE_URL +# has to be absolute — the runtime canonicalises it with `new URL(value)` — so +# even a same-origin deployment names its own origin here. The committed +# production profile ships a placeholder (https://api.example.com/), which is +# what a deployment that forgets this would silently serve. +ARG RUNTIME_API_BASE_URL +ARG RUNTIME_TELEMETRY_ENDPOINT +ENV RUNTIME_API_BASE_URL=${RUNTIME_API_BASE_URL} +ENV RUNTIME_TELEMETRY_ENDPOINT=${RUNTIME_TELEMETRY_ENDPOINT} + +RUN corepack enable + +# Dependencies first so a source-only change does not re-resolve them. +COPY package.json pnpm-lock.yaml ./ +RUN corepack pnpm install --frozen-lockfile --ignore-scripts + +COPY . . +RUN corepack pnpm build \ + && node scripts/generate-nginx-config.ts + +# --------------------------------------------------------------------------- +# runtime +# --------------------------------------------------------------------------- +FROM nginx@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10 AS runtime + +# Replaces the packaged default server block; the generated file is the whole +# server definition, including the BFF proxy locations. +RUN rm /etc/nginx/conf.d/default.conf +COPY --from=build /src/dist/nginx.conf /etc/nginx/conf.d/tech-log.conf +COPY --from=build /src/dist/ /usr/share/nginx/html/ + +# The generated config is served from /usr/share/nginx/html as root, so the two +# copies above would also publish nginx.conf itself. It is not secret, but it is +# not a page either. +RUN rm -f /usr/share/nginx/html/nginx.conf /usr/share/nginx/html/server.mjs \ + && rm -rf /usr/share/nginx/html/.vite \ + # The build writes config.json 0600, which nginx (running as `nginx`) cannot + # read — the container came up healthy and answered 403 for the one file the + # SPA needs before it can boot. Normalise what is served to world-readable. + && chmod -R a+rX /usr/share/nginx/html + +EXPOSE 80 + +# No `nginx -t` here: proxy_pass names are resolved when the config loads, and +# `backend`/`keycloak` only exist on the compose network. The container's own +# startup is the check, and it fails loudly. + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://127.0.0.1${VITE_ROUTER_BASE_PATH:-/}config.json || exit 1 diff --git a/config/runtime/development.json b/config/runtime/development.json index 6c26b54..9b189e5 100644 --- a/config/runtime/development.json +++ b/config/runtime/development.json @@ -13,7 +13,7 @@ "SERVICE_WORKER": "DEFAULT", "OFFLINE_COMMANDS": "DEFAULT" }, - "TECH_LOG_STUDIO_SOURCE": "MOCK", + "TECH_LOG_STUDIO_SOURCE": "HTTP", "FEATURE_OVERRIDES": { "reference-feature": "DEFAULT" } diff --git a/deploy/keycloak/README.md b/deploy/keycloak/README.md new file mode 100644 index 0000000..660979d --- /dev/null +++ b/deploy/keycloak/README.md @@ -0,0 +1,24 @@ +# Keycloak realm + +`tech-log-realm.json` is imported by the `keycloak` service at start +(`--import-realm`). It exists because the realm was previously created by hand, +which meant §27 of the release checklist — "Keycloak Realm 설정을 복원할 수 +있다" — had no answer: nothing in either repository described the realm. + +What it declares, and why each part is load-bearing: + +- **`studio-author` realm role.** `StudioAuthzEnvironmentPostProcessor` maps this + name to `studio:read` and `studio:write`. The name is configurable through + `APP_STUDIO_AUTHOR_ROLE`; if you change it here, change it there too. +- **`tech-log-bff` confidential client.** The Authorization Code flow belongs to + the backend, not the browser — the SPA never holds a token. `redirectUris` is + relative so the same realm works on any origin the deployment is served from. +- **`realm-roles` protocol mapper.** Without it the roles never reach the token, + the registry resolves zero permissions, and every Studio call answers 403. + +## Values that must be replaced + +`CHANGE_ME_BFF_SECRET` and `CHANGE_ME_STUDIO_PASSWORD` are placeholders, and the +deploy script substitutes them from the environment before import. They are left +visible rather than pre-filled so a realm file committed with a real secret is an +obvious mistake rather than a quiet one. diff --git a/deploy/keycloak/tech-log-realm.json b/deploy/keycloak/tech-log-realm.json new file mode 100644 index 0000000..8dfd229 --- /dev/null +++ b/deploy/keycloak/tech-log-realm.json @@ -0,0 +1,57 @@ +{ + "realm": "tech-log", + "enabled": true, + "sslRequired": "none", + "registrationAllowed": false, + "loginTheme": "keycloak", + "accessTokenLifespan": 300, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "roles": { + "realm": [ + { "name": "studio-author", "description": "Tech Log Studio 편집 권한 (studio:read + studio:write)" } + ] + }, + "clients": [ + { + "clientId": "tech-log-bff", + "name": "Tech Log BFF", + "description": "백엔드가 소유하는 Authorization Code 클라이언트. SPA 는 토큰을 직접 들지 않는다.", + "enabled": true, + "publicClient": false, + "secret": "CHANGE_ME_BFF_SECRET", + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "redirectUris": ["/login/oauth2/code/*"], + "webOrigins": ["+"], + "protocolMappers": [ + { + "name": "realm-roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "config": { + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true", + "access.token.claim": "true", + "id.token.claim": "true", + "userinfo.token.claim": "true" + } + } + ] + } + ], + "users": [ + { + "username": "studio", + "enabled": true, + "emailVerified": true, + "email": "studio@tech-log.local", + "firstName": "Studio", + "lastName": "Author", + "credentials": [{ "type": "password", "value": "CHANGE_ME_STUDIO_PASSWORD", "temporary": false }], + "realmRoles": ["default-roles-tech-log", "studio-author"] + } + ] +} diff --git a/docker-compose.dev-stack.yml b/docker-compose.dev-stack.yml new file mode 100644 index 0000000..9e11040 --- /dev/null +++ b/docker-compose.dev-stack.yml @@ -0,0 +1,180 @@ +# The Tech Log dev stack: one origin, five services. +# +# nginx is the only published port. Everything the browser touches — the SPA, +# /api, the OIDC redirect chain, and Keycloak under /auth — arrives on the same +# origin, which is what lets the session be a plain first-party httpOnly cookie +# instead of a cross-site one needing SameSite=None. +# +# browser ──> frontend(nginx) ──┬─> / SPA bundle +# ├─> /api backend +# ├─> /oauth2 /login /logout backend (BFF) +# └─> /auth keycloak +# +# Secrets here are development values and are meant to be replaced by the +# deployment; they are named in .env so nothing is baked into an image. + +name: tech-log + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB:-tech_log} + POSTGRES_USER: ${POSTGRES_USER:-tech_log} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD} + TZ: UTC + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tech_log} -d ${POSTGRES_DB:-tech_log}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + restart: unless-stopped + networks: [tech-log] + + redis: + # Holds the Studio session. Losing it signs everyone out; it holds nothing + # else, so it is not backed by a volume on purpose. + image: redis:7-alpine + command: ["redis-server", "--save", "", "--appendonly", "no"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 5 + restart: unless-stopped + networks: [tech-log] + + keycloak: + image: quay.io/keycloak/keycloak:26.7.0 + command: ["start-dev", "--import-realm", "--http-relative-path=/auth"] + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN:-admin} + KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?set KEYCLOAK_ADMIN_PASSWORD} + KC_HTTP_ENABLED: "true" + # Behind nginx: Keycloak must build its URLs from the forwarded host, or + # the redirect back from the login page points at the container. + KC_HOSTNAME: ${PUBLIC_ORIGIN:?set PUBLIC_ORIGIN}/auth + KC_HOSTNAME_STRICT: "false" + KC_PROXY_HEADERS: xforwarded + KC_HEALTH_ENABLED: "true" + volumes: + - ${KEYCLOAK_IMPORT_DIR:-./deploy/keycloak}:/opt/keycloak/data/import:ro + healthcheck: + test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && echo -e 'GET /auth/health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '\"status\": \"UP\"'"] + interval: 15s + timeout: 5s + retries: 20 + start_period: 40s + restart: unless-stopped + networks: [tech-log] + + backend: + image: ${BACKEND_IMAGE:-tech-log-backend:local} + volumes: + - tls-public:/tls-public:ro + # The image entrypoint is `java -jar /app/app.jar`; this wraps it so the + # frontend's certificate lands in the JVM truststore first. Without it the + # OIDC metadata fetch fails PKIX validation and the process crash-loops. + entrypoint: + - /bin/sh + - -c + - | + until [ -f /tls-public/server.crt ]; do sleep 1; done + # The image runs as a non-root user, so the JVM's own cacerts is not + # writable — importing there silently did nothing and the metadata fetch + # kept failing PKIX. Copy it somewhere writable, add the edge + # certificate, and point the JVM at that. + cp "/opt/java/openjdk/lib/security/cacerts" /tmp/truststore.jks + keytool -importcert -noprompt -trustcacerts -alias tech-log-edge \ + -file /tls-public/server.crt \ + -keystore /tmp/truststore.jks -storepass changeit + exec java \ + -Djavax.net.ssl.trustStore=/tmp/truststore.jks \ + -Djavax.net.ssl.trustStorePassword=changeit \ + -jar /app/app.jar + environment: + SPRING_PROFILES_ACTIVE: local + # Persistence + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-tech_log} + SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-tech_log} + SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD} + SPRING_DATASOURCE_DRIVER_CLASS_NAME: org.postgresql.Driver + SPRING_FLYWAY_ENABLED: "true" + SPRING_JPA_HIBERNATE_DDL_AUTO: none + CA_SKELETON_PERSISTENCE_VENDOR: postgresql + # BFF session + CA_SKELETON_SECURITY_AUTH_MODE: redis-session + CA_SKELETON_SECURITY_SESSION_COOKIE_NAME: TECHLOG_SESSION + APP_REDIS_ENABLED: "true" + APP_REDIS_AUTHENTICATION_ANONYMOUS_ACCESS_ACCEPTED: "true" + SPRING_DATA_REDIS_HOST: redis + SPRING_DATA_REDIS_PORT: "6379" + # OIDC. The issuer is the browser-facing URL because the tokens carry it + # and the browser is redirected there; the container reaches the same + # Keycloak through nginx on the compose network. + SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_CLIENT_ID: ${OIDC_CLIENT_ID:-tech-log-bff} + SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:?set OIDC_CLIENT_SECRET} + SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_SCOPE: openid,profile,email + SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_AUTHORIZATION_GRANT_TYPE: authorization_code + SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_REDIRECT_URI: "${PUBLIC_ORIGIN}/login/oauth2/code/keycloak" + SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KEYCLOAK_ISSUER_URI: ${PUBLIC_ORIGIN}/auth/realms/${KEYCLOAK_REALM:-tech-log} + APP_STUDIO_AUTHOR_ROLE: ${STUDIO_AUTHOR_ROLE:-studio-author} + APP_STUDIO_POST_LOGIN_REDIRECT: "${PUBLIC_ORIGIN}/studio" + # Behind a proxy: trust the forwarded headers nginx sets, so redirect URLs + # and client IPs are the browser's, not the container's. + APP_SERVER_FORWARD_HEADERS_STRATEGY: framework + TZ: UTC + # The issuer in a token is the browser-facing URL, and the backend has to + # both validate that exact string and fetch the realm's metadata from it. + # Inside the container that host does not resolve, so discovery failed and + # the process crash-looped. Mapping the public host to the docker gateway + # makes one URL work from both sides — the browser reaches nginx directly, + # the backend reaches the same nginx through the published port. + extra_hosts: + - "${PUBLIC_HOST:?set PUBLIC_HOST}:host-gateway" + depends_on: + postgres: { condition: service_healthy } + redis: { condition: service_healthy } + keycloak: { condition: service_healthy } + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/healthcheck"] + interval: 15s + timeout: 5s + retries: 10 + start_period: 45s + restart: unless-stopped + networks: [tech-log] + + frontend: + image: ${FRONTEND_IMAGE:-tech-log-frontend:local} + # The backend fetches the realm metadata from the same HTTPS origin the + # browser uses, so it has to trust this certificate. Publishing it to a + # shared volume keeps one certificate for both sides; a deployment that + # mounts a CA-issued certificate over /etc/nginx/tls needs neither this nor + # the backend's import step. + volumes: + - tls-public:/tls-public + command: + - /bin/sh + - -c + - "cp /etc/nginx/tls/server.crt /tls-public/server.crt && exec nginx -g 'daemon off;'" + + ports: + - "${PUBLIC_HTTP_PORT:-8088}:80" + - "${PUBLIC_PORT:-8443}:443" + depends_on: + backend: { condition: service_started } + keycloak: { condition: service_started } + restart: unless-stopped + networks: [tech-log] + +networks: + tech-log: + driver: bridge + +volumes: + postgres-data: + tls-public: diff --git a/index.html b/index.html index cd9b3c5..062b26a 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,11 @@ Tech Log + +
diff --git a/scripts/generate-nginx-config.ts b/scripts/generate-nginx-config.ts new file mode 100644 index 0000000..0a73d99 --- /dev/null +++ b/scripts/generate-nginx-config.ts @@ -0,0 +1,228 @@ +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +/** + * Emits the nginx configuration the deployed frontend is served with. + * + * Generated rather than hand-written because three files already decide what it + * must say, and a copy of them would drift: `dist/tech-log-serving-contract.json` + * (which paths are SPA routes and what a miss answers with), + * `config/hosting/security-headers.json`, and `config/hosting/cache-policy.json`. + * The repository had no frontend deployment artifact at all — no Dockerfile, no + * server config — so those two hosting files described a contract nothing + * fulfilled: `dist/server.mjs` applies neither, answering `no-cache` for hashed + * assets and sending no security headers. + * + * This serves static files only. TLS and the BFF paths belong to the edge: the + * deployment's own nginx terminates HTTPS and sends `/api`, the OIDC redirect + * chain and the identity provider to the backend directly (in Kubernetes, + * Traefik does). A second proxy hop here would only add a place for the two + * routing tables to disagree. + * + * The base path comes from `VITE_ROUTER_BASE_PATH`, the same value the bundle is + * built with: served under a prefix, every route and asset lives under it too. + */ + +const DIST = "dist"; +const OUT = path.join(DIST, "nginx.conf"); + +type ServingContract = Readonly<{ + publicSpaPaths: readonly string[]; + studioPathPrefix: string; + studioSpaPathPatterns: readonly string[]; + notFound: Readonly<{ status: number; contentType: string; body: string }>; +}>; + +type HostingHeaders = Readonly<{ headers: Readonly> }>; + +type CachePolicy = Readonly<{ + surfaces: Readonly< + Record< + string, + Readonly<{ + path?: string; + pathPattern?: string; + cacheControl?: string; + securityHeaders?: boolean; + }> + > + >; +}>; + +async function readJson(file: string): Promise { + return JSON.parse(await readFile(file, "utf8")) as T; +} + +/** nginx location matching is not regex-escaped for us; only `=` exact paths are literal. */ +function exactLocation(pathname: string): string { + return pathname; +} + +/** + * A JS regex from the contract translated for nginx. Both use PCRE-ish syntax + * for what the contract uses (`^`, `$`, `[^/]+`, alternation), so the pattern + * carries over unchanged — asserted rather than assumed, because a pattern that + * silently failed to translate would open a Studio route to the 404 branch. + */ +function studioRegex(pattern: string): string { + // nginx uses PCRE, so anchors, character classes, alternation and plain groups + // carry over as written. Lookaround and backreferences do not translate the + // same way and would silently change which paths match, so they are refused. + if (/\(\?[=!<]|\\[1-9]/.test(pattern)) { + throw new Error( + `studio SPA pattern uses a construct this generator does not translate: ${pattern}`, + ); + } + return pattern; +} + +function headerDirectives( + headers: Readonly>, + indent: string, +): string { + return Object.entries(headers) + .map(([name, value]) => `${indent}add_header ${name} "${value}" always;`) + .join("\n"); +} + +async function main(): Promise { + const contract = await readJson( + path.join(DIST, "tech-log-serving-contract.json"), + ); + const security = await readJson( + "config/hosting/security-headers.json", + ); + const cache = await readJson("config/hosting/cache-policy.json"); + + const surfaces = cache.surfaces; + const indexCache = surfaces["index"]?.cacheControl ?? "no-cache"; + const configCache = surfaces["runtimeConfig"]?.cacheControl ?? "no-store"; + const manifestCache = surfaces["releaseManifest"]?.cacheControl ?? "no-store"; + const assetCache = surfaces["hashedAsset"]?.cacheControl ?? "no-cache"; + + const secure = headerDirectives(security.headers, " "); + + // The bundle's own base path. `/` for a deployment at the domain root, `/dev/` + // for one served under a prefix — the routes below have to carry it or nginx + // matches paths the browser never asks for. + const rawBase = process.env["VITE_ROUTER_BASE_PATH"] ?? "/"; + const basePath = rawBase.endsWith("/") ? rawBase.slice(0, -1) : rawBase; + + const [notFoundType, notFoundCharsetParam] = contract.notFound.contentType + .split(";") + .map((part) => part.trim()); + const notFoundCharset = (notFoundCharsetParam ?? "charset=utf-8") + .replace(/^charset=/i, "") + .toLowerCase(); + + const publicLocations = contract.publicSpaPaths + .map( + (pathname) => ` location = ${basePath}${exactLocation(pathname)} { +${secure} + add_header Cache-Control "${indexCache}" always; + try_files /index.html =404; + }`, + ) + .join("\n\n"); + + const studioLocations = contract.studioSpaPathPatterns + .map( + (pattern) => ` location ~ ^${basePath}${studioRegex(pattern).slice(1)} { +${secure} + add_header Cache-Control "${indexCache}" always; + try_files /index.html =404; + }`, + ) + .join("\n\n"); + + const conf = `# Generated by scripts/generate-nginx-config.ts — do not edit. +# Sources: dist/tech-log-serving-contract.json, config/hosting/security-headers.json, +# config/hosting/cache-policy.json +# +# Plain HTTP on purpose: the edge terminates TLS and this container is only ever +# reached from inside the deployment network. + +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + + # The bundle is small and already compressed at rest by the build; gzip here + # covers the JSON surfaces and index.html. + gzip on; + gzip_types text/css application/javascript text/javascript application/json; + gzip_min_length 1024; + + # Static surfaces, per config/hosting/cache-policy.json + # --------------------------------------------------------------------------- + location = ${basePath}/config.json { + alias /usr/share/nginx/html/config.json; +${secure} + add_header Cache-Control "${configCache}" always; + } + + location = ${basePath}/release-manifest.json { + alias /usr/share/nginx/html/release-manifest.json; +${secure} + add_header Cache-Control "${manifestCache}" always; + } + + # Content-hashed filenames, so the long TTL is safe and revalidation is waste. + location ${basePath}/assets/ { + # alias, not root + URI: under a base path the request is /dev/assets/x.js + # while the file is dist/assets/x.js, so root would look for + # dist/dev/assets/x.js and answer 404 for every script on the page. + alias /usr/share/nginx/html/assets/; + add_header Cache-Control "${assetCache}" always; + } + + # Source maps are not published (cache-policy sourceMap.public = false). + location ~ \\.map$ { + return 404; + } + + # --------------------------------------------------------------------------- + # SPA routes. Enumerated from the serving contract rather than a catch-all: + # a path that is not a real route answers 404 instead of a 200 shell, which is + # what tells a crawler the difference. + # --------------------------------------------------------------------------- +${publicLocations} + +${studioLocations} + + location = ${basePath}/favicon.svg { + alias /usr/share/nginx/html/favicon.svg; + add_header Cache-Control "${assetCache}" always; + } + + location = ${basePath}/media/ { + return 404; + } + + location ${basePath}/media/ { + alias /usr/share/nginx/html/media/; + add_header Cache-Control "${assetCache}" always; + } + + # Anything else is not a route this deployment serves. + location / { + # The contract states the content type with its charset attached + # (text/plain;charset=UTF-8), but nginx takes the two separately — + # default_type rejects a parameter outright. + default_type ${notFoundType}; + charset ${notFoundCharset}; + return ${contract.notFound.status} "${contract.notFound.body}"; + } +} +`; + + await writeFile(OUT, conf, "utf8"); + process.stdout.write( + `nginx config: ${OUT} (${contract.publicSpaPaths.length} public routes, ` + + `${contract.studioSpaPathPatterns.length} studio patterns)\n`, + ); +} + +await main();