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();