Add CORS credentials, transactional endpoints, and container deployment

CORS
  cors.go never set Access-Control-Allow-Credentials, so the
  cookie-authenticated API was unreadable from any cross-origin frontend:
  the server answered correctly and the browser blocked the page from
  reading it. Set for allowlisted origins on both the preflight and the
  actual response. Three tests added.

  HTTP_COOKIE_SAMESITE (lax|none|strict, default lax) is new. CORS is only
  half of what a cross-origin browser call needs; SameSite is judged on
  registrable domain, so a frontend on an unrelated domain gets perfect CORS
  headers and still no cookie. "none" is the only value that survives that,
  and validate() refuses it without the Secure flag.

  The "*" rejection now explains itself: browsers refuse Allow-Origin "*"
  together with credentials, so it would break every authenticated call
  rather than loosen anything.

Transactional endpoints (api-contract.md 12.1)
  POST /api/v1/job-applications/{id}/hire
  POST /api/v1/job-postings/{id}/assignments

  Replaces two client-side loops that wrote several records with no
  transaction and no rollback. Each is now one endpoint and one transaction,
  built over repo.Repo so org scoping, derived columns, type casts and error
  translation are not re-derived. Authorization reuses the existing policy
  table rather than adding a parallel one: a workflow is exactly as
  privileged as the writes it performs. 13 tests, including both rollback
  paths.

Bug fix in the repository layer
  repo.bindValue handled int64/int/float64/string but not int32, which is
  what pgx returns for a PostgreSQL `int` column. Nothing previously read a
  record and wrote one of its fields elsewhere, so it never surfaced; the
  hire flow does exactly that and failed with "ai_score must be a number".
  Both KindInt and KindFloat now accept the widths pgx actually produces.

Deployment
  infrastructure/Dockerfile.api  multi-stage, cross-compiling (BUILDPLATFORM
    + GOARCH) so linux/amd64 builds from arm64 are compiled rather than
    emulated. Alpine runtime, non-root uid 10001, 22.1 MB. Ships api, seed,
    setpassword and migrate, plus the migrations, so a Kubernetes
    initContainer can apply the schema from the same image and tag as the
    API. HEALTHCHECK keys on status code, not body, so a "degraded" instance
    is not pulled from rotation during a migration window.

  infrastructure/docker-compose.yml  migrations run to completion before the
    API starts. Assumes a managed PostgreSQL; the local-db overlay adds one
    with TLS enabled so APP_ENV=production is met rather than dodged.

  scripts/drop_public_tables.go  the one-off used to clear an unrelated
    schema from krowdb on 2026-08-24, kept for the record. Build-tagged
    ignore and gated on CONFIRM_DROP=yes.

Verified against PostgreSQL: 16/16 new tests pass, and the image was built,
run and exercised end to end (login, CORS preflight, authenticated reads,
transaction rollback).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmQiGq73Uyfq7J4yR8Vxxw
This commit is contained in:
Suriyakumarvijayanayagam
2026-08-25 11:33:01 +05:30
parent 7d12ebef3d
commit 954ba9076f
17 changed files with 1868 additions and 9 deletions

View File

@@ -0,0 +1,95 @@
# ============================================================================
# Krow API — Docker environment
#
# cd infrastructure && cp .env.docker.example .env
#
# Compose reads `.env` from the directory the compose file lives in, so this
# copy belongs in infrastructure/, NOT the repository root .env used by
# `make run`. Both are gitignored.
#
# Every value here is a placeholder. No real password or host belongs in a
# file that is committed.
# ============================================================================
# ── Application ─────────────────────────────────────────────────────────────
APP_ENV=production # development | staging | production
LOG_LEVEL=info # debug | info | warn | error
VERSION=1.0.0 # build arg only
IMAGE=doormile/krowbackend:latest # what `docker compose build` tags and `up` runs
# ── Where the API is published ──────────────────────────────────────────────
# Loopback by default: put a reverse proxy in front to terminate TLS. The API
# speaks plain HTTP and its session cookie is Secure outside development, so a
# browser will not send that cookie back over a plain connection anyway.
API_BIND=127.0.0.1
API_PORT=8080
# Browser origins allowed to call this API, comma-separated. Exact strings:
# scheme included, no trailing slash. Cookies are the auth mechanism, so
# anything listed here can hold a live session.
#
# "*" is REJECTED at startup — browsers refuse Allow-Origin: "*" together with
# credentials, so it would break every authenticated call rather than loosen
# anything.
HTTP_CORS_ORIGINS=https://platform.krowforce.com,https://mcp.krowforce.com
# SameSite on the session cookie: lax | none | strict.
#
# "lax" is right while the API is on *.krowforce.com — the two origins above
# are then cross-ORIGIN (CORS applies) but same-SITE (the cookie is still
# sent). Move the API to any other registrable domain and this must become
# "none", or login will succeed and every following request will arrive
# anonymous.
HTTP_COOKIE_SAMESITE=lax
# ── Database ────────────────────────────────────────────────────────────────
# Point at a managed PostgreSQL. With docker-compose.local-db.yml layered on
# top, set DATABASE_HOST=postgres instead.
DATABASE_HOST=your-database-host.example.com
DATABASE_PORT=5432
DATABASE_NAME=krow
DATABASE_USER=krow
DATABASE_PASSWORD=change-me
DATABASE_SCHEMA=public
# require encrypts but does not verify the server; verify-full also checks the
# certificate against a CA and is what you want against a managed database.
#
# `disable` is REJECTED at startup when APP_ENV=production. That is deliberate.
DATABASE_SSLMODE=require
# ── Migration URL ───────────────────────────────────────────────────────────
# golang-migrate takes a single URL, and Compose cannot assemble or encode one.
#
# ⚠️ PERCENT-ENCODE THE PASSWORD. A password containing @ : / ? # or % will
# otherwise be parsed as part of the host or the path, and the failure looks
# like a wrong host rather than a wrong password:
#
# @ → %40 : → %3A / → %2F ? → %3F # → %23 % → %25
#
# python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' 'your-password'
#
# search_path must match DATABASE_SCHEMA above.
DATABASE_URL=postgres://krow:change-me@your-database-host.example.com:5432/krow?sslmode=require&search_path=public
# ── Pool and timeouts ───────────────────────────────────────────────────────
DATABASE_MAX_OPEN_CONNS=25
DATABASE_MIN_IDLE_CONNS=2
DATABASE_CONN_MAX_LIFETIME=30m
DATABASE_CONNECT_TIMEOUT=10s
DATABASE_STATEMENT_TIMEOUT=10s
# ── HTTP timeouts ───────────────────────────────────────────────────────────
HTTP_READ_TIMEOUT=15s
HTTP_WRITE_TIMEOUT=30s
HTTP_IDLE_TIMEOUT=60s
HTTP_SHUTDOWN_TIMEOUT=10s
# ── Container resources ─────────────────────────────────────────────────────
API_CPU_LIMIT=2
API_MEMORY_LIMIT=512M
# ── Local database only (docker-compose.local-db.yml) ───────────────────────
POSTGRES_BIND=127.0.0.1
POSTGRES_PORT=5432
POSTGRES_MEMORY_LIMIT=1G

View File

@@ -0,0 +1,155 @@
# syntax=docker/dockerfile:1.7
# ============================================================================
# Krow API — production image
#
# Build from the REPOSITORY ROOT, not from this directory: the build needs
# go-api/ and seed/, and a Dockerfile can never reach above its context.
#
# docker build -f infrastructure/Dockerfile.api -t krow-api:latest .
#
# Three binaries ship in the image, because all three are things an operator
# needs against a running deployment and none of them justify a second image:
#
# /usr/local/bin/api the HTTP service (the default command)
# /usr/local/bin/seed loads the demo fixture
# /usr/local/bin/setpassword sets a user's password — without it a fresh
# database has no one who can sign in
#
# Migrations are deliberately NOT run by this image. They are a discrete deploy
# step against the target database BEFORE the new binary rolls out, which is
# what makes expand/migrate/contract possible: see README.md "Staging and
# production". docker-compose.yml runs them as their own one-shot service.
# ============================================================================
# ── Build ───────────────────────────────────────────────────────────────────
# --platform=$BUILDPLATFORM pins this stage to the MACHINE DOING THE BUILDING,
# not the machine that will run the image. Combined with GOARCH below, that
# turns a cross-platform build into cross-COMPILATION rather than emulation.
#
# It matters a lot in practice: building linux/amd64 from an Apple Silicon Mac
# without this runs the entire Go toolchain under QEMU, which is roughly an
# order of magnitude slower. Every binary here is CGO_ENABLED=0 and therefore
# pure Go, so the Go compiler can target another architecture directly and there
# is nothing emulation would buy.
FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS build
# Supplied automatically by buildx from --platform. Declared here so the build
# stage can read them; TARGETARCH is "amd64", "arm64", etc.
ARG TARGETOS=linux
ARG TARGETARCH
WORKDIR /src
# Dependencies first, in their own layer. go.mod and go.sum change far less
# often than source, so an edit to a handler does not re-download the module
# graph.
COPY go-api/go.mod go-api/go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY go-api/ ./
# CGO_ENABLED=0 produces a static binary, which is what lets the final stage be
# a near-empty image with no libc to keep patched.
# -trimpath keeps build-machine paths out of panics and binaries
# -s -w drops the symbol table and DWARF; roughly a third off the size
ARG VERSION=dev
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
-trimpath -ldflags="-s -w" \
-o /out/api ./cmd/api && \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
-trimpath -ldflags="-s -w" -o /out/seed ./cmd/seed && \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
-trimpath -ldflags="-s -w" -o /out/setpassword ./cmd/setpassword
# The golang-migrate CLI, built here rather than pulled as a second image.
#
# `go install pkg@version` resolves in its own module context, so this does NOT
# touch go.mod or go.sum. Having it in the image means the Kubernetes manifests
# can run migrations from an initContainer using the SAME image and tag as the
# API — so the schema and the binary that expects it can never be different
# versions, which is the failure a separate migrate image invites.
ARG MIGRATE_VERSION=v4.19.0
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go install -tags 'postgres' \
github.com/golang-migrate/migrate/v4/cmd/migrate@${MIGRATE_VERSION} && \
cp "$(go env GOPATH)/bin/${TARGETOS}_${TARGETARCH}/migrate" /out/migrate 2>/dev/null || \
cp "$(go env GOPATH)/bin/migrate" /out/migrate
# Fail the build rather than the deploy if the tests do not pass. Skipped by
# default because the database-backed tests need PostgreSQL, which a build
# container does not have; turn it on in CI where a service container does.
# Note: this runs on the BUILD architecture, so it is only meaningful when
# building natively. Under cross-compilation the test binaries would target the
# other architecture and could not execute here.
ARG RUN_TESTS=false
RUN if [ "$RUN_TESTS" = "true" ]; then go test ./... ; fi
# ── Runtime ─────────────────────────────────────────────────────────────────
# Alpine rather than distroless, for one concrete reason: HEALTHCHECK needs a
# command inside the container, and distroless has no shell and no wget. The
# trade is ~8 MB and an apk surface to keep updated, in exchange for the
# orchestrator being able to see whether this instance is actually serving.
FROM alpine:3.20 AS runtime
# ca-certificates: the pgx driver needs a trust store to verify a managed
# database's TLS certificate under sslmode=verify-full.
# tzdata: timestamps are timestamptz and the seeder anchors shift dates to now.
RUN apk add --no-cache ca-certificates tzdata wget && \
adduser -D -H -u 10001 -s /sbin/nologin krow
COPY --from=build /out/api /usr/local/bin/api
COPY --from=build /out/seed /usr/local/bin/seed
COPY --from=build /out/setpassword /usr/local/bin/setpassword
COPY --from=build /out/migrate /usr/local/bin/migrate
# The seed fixture, so `seed` works without a bind mount.
COPY seed/fixtures/seed.json /app/seed/fixtures/seed.json
ENV SEED_FIXTURE_PATH=/app/seed/fixtures/seed.json
# The migration files, so an initContainer can apply them from this image.
# They are read-only at runtime and the process is non-root, so nothing here
# can be rewritten by the service.
COPY migrations/ /app/migrations/
ENV MIGRATIONS_DIR=/app/migrations
WORKDIR /app
# Non-root. The service binds 8080, which needs no privilege, so there is no
# reason for this process to be able to write to its own filesystem or read
# another container's mounts.
USER krow:krow
# 0.0.0.0, NOT the 127.0.0.1 that config.Load defaults to. A container that
# binds loopback is reachable only from inside itself — the symptom is a
# published port that refuses every connection while the process looks healthy.
ENV HTTP_HOST=0.0.0.0 \
HTTP_PORT=8080 \
APP_ENV=production \
LOG_LEVEL=info
EXPOSE 8080
# The check keys on the STATUS CODE, not on the body, because the two say
# different things on purpose (httpserver.handleHealth):
#
# 200 "ok" serving normally
# 200 "degraded" process healthy, schema unmigrated or dirty — still 200,
# because the fault is the database's and pulling this
# instance would not fix it
# 503 "unavailable" database unreachable
#
# Grepping the body for "ok" would mark a degraded instance unhealthy and take
# the whole deployment out of rotation during a migration window, which is
# exactly the behaviour that 200 was chosen to avoid. wget exits non-zero on
# 503 and zero on either 200, which is the intended semantics.
HEALTHCHECK --interval=15s --timeout=3s --start-period=20s --retries=3 \
CMD wget -qO- http://127.0.0.1:8080/health >/dev/null 2>&1 || exit 1
# Exec form: the binary becomes PID 1 and receives SIGTERM directly, which is
# what cmd/api's signal.NotifyContext is waiting for to drain connections.
ENTRYPOINT ["/usr/local/bin/api"]

View File

@@ -0,0 +1,101 @@
# ============================================================================
# Overlay: run PostgreSQL alongside the API.
#
# docker compose -f docker-compose.yml -f docker-compose.local-db.yml up -d
#
# FOR STAGING AND FOR TESTING THE STACK ON ONE HOST. Not for production data.
# A database in a container on the same host as its application has no backup,
# no point-in-time recovery, no failover, and dies with the host. The base file
# assumes a managed PostgreSQL for exactly that reason.
#
# ── TLS ─────────────────────────────────────────────────────────────────────
#
# PostgreSQL is started with SSL on, using a self-signed certificate generated
# on first boot into the data volume. That is enough for DATABASE_SSLMODE=
# require, which encrypts the connection but does not verify who is on the
# other end — appropriate for a private compose network, NOT a substitute for
# verify-full against a real CA.
#
# It also means APP_ENV=production works here without weakening the check in
# internal/config, which is the point: the guard stays honest and the local
# stack meets it rather than dodging it.
#
# Set these in .env to match:
# DATABASE_HOST=postgres
# DATABASE_SSLMODE=require
# DATABASE_URL=postgres://krow:<url-encoded-password>@postgres:5432/krow?sslmode=require
# ============================================================================
services:
postgres:
image: postgres:16-alpine
container_name: krow-postgres
environment:
POSTGRES_DB: ${DATABASE_NAME:-krow}
POSTGRES_USER: ${DATABASE_USER:-krow}
POSTGRES_PASSWORD: ${DATABASE_PASSWORD:?DATABASE_PASSWORD is required}
# scram-sha-256 rather than the image's md5 default. md5 has been
# deprecated upstream for years and is trivial to crack from a captured
# handshake.
POSTGRES_INITDB_ARGS: "--auth-host=scram-sha-256 --auth-local=scram-sha-256"
# Generate a self-signed certificate on first boot, then hand off to the
# image's own entrypoint with SSL enabled. The key must be 0600 and owned
# by the postgres user or the server refuses to start — that check is the
# reason this is done here rather than in a bind mount, where host
# ownership would leak in.
command:
- sh
- -c
- |
set -e
CERT=/var/lib/postgresql/data/server.crt
KEY=/var/lib/postgresql/data/server.key
if [ ! -f "$$CERT" ]; then
mkdir -p /var/lib/postgresql/data
openssl req -new -x509 -days 3650 -nodes -text \
-out "$$CERT" -keyout "$$KEY" -subj "/CN=postgres" 2>/dev/null
chmod 0600 "$$KEY"
chown postgres:postgres "$$CERT" "$$KEY"
fi
exec docker-entrypoint.sh postgres \
-c ssl=on -c ssl_cert_file="$$CERT" -c ssl_key_file="$$KEY"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
# -U and -d so this reports on the application's database, not on
# whatever `postgres` happens to be reachable.
test: ["CMD-SHELL", "pg_isready -U ${DATABASE_USER:-krow} -d ${DATABASE_NAME:-krow}"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
ports:
# Loopback only. Publishing 5432 to the world is how a database ends up
# in someone else's botnet.
- "${POSTGRES_BIND:-127.0.0.1}:${POSTGRES_PORT:-5432}:5432"
restart: unless-stopped
stop_grace_period: 60s
security_opt:
- no-new-privileges:true
deploy:
resources:
limits:
memory: ${POSTGRES_MEMORY_LIMIT:-1G}
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
networks: [krow]
# The migration runner now has to wait for the database to accept
# connections, which it does not need to do when the database is managed and
# already up.
migrate:
depends_on:
postgres:
condition: service_healthy
volumes:
postgres-data:
driver: local

View File

@@ -0,0 +1,142 @@
# ============================================================================
# Krow API — production stack
#
# cd infrastructure
# cp .env.docker.example .env
# docker compose up -d --build
#
# This file assumes a MANAGED PostgreSQL (RDS, Cloud SQL, Neon, a DBA's
# cluster) reachable from wherever these containers run. That is deliberate:
# a production database wants backups, point-in-time recovery, failover and a
# patch schedule, none of which a container in this file provides.
#
# To run PostgreSQL alongside the API — for staging, or to test the stack
# end-to-end on one host — layer the override on top:
#
# docker compose -f docker-compose.yml -f docker-compose.local-db.yml up -d
#
# ── ORDER OF OPERATIONS ─────────────────────────────────────────────────────
#
# `migrate` runs to completion BEFORE `api` starts, and `api` will not start if
# it fails. That is the expand/migrate/contract discipline from README.md
# expressed in the dependency graph rather than in a runbook: schema first,
# binary second, and every migration backwards-compatible with the version
# still running.
#
# ── TLS IS NOT OPTIONAL HERE ────────────────────────────────────────────────
#
# internal/config rejects DATABASE_SSLMODE=disable when APP_ENV=production, so
# this stack will refuse to start against a database that cannot do TLS. That
# is the intended behaviour and not something to work around by dropping to
# APP_ENV=staging — a staging flag on a production deployment is a lie the next
# person has to discover.
# ============================================================================
name: krow
# The database settings the API and the migration runner must agree on.
x-database-env: &database-env
DATABASE_HOST: ${DATABASE_HOST:?DATABASE_HOST is required}
DATABASE_PORT: ${DATABASE_PORT:-5432}
DATABASE_NAME: ${DATABASE_NAME:?DATABASE_NAME is required}
DATABASE_USER: ${DATABASE_USER:?DATABASE_USER is required}
DATABASE_PASSWORD: ${DATABASE_PASSWORD:?DATABASE_PASSWORD is required}
DATABASE_SCHEMA: ${DATABASE_SCHEMA:-public}
DATABASE_SSLMODE: ${DATABASE_SSLMODE:-require}
x-logging: &logging
driver: json-file
options:
max-size: "10m"
max-file: "5"
services:
# ── Schema ────────────────────────────────────────────────────────────────
# One-shot. Exits 0 when the schema is current, including when there was
# nothing to apply. `api` waits on that exit code.
#
# DATABASE_URL is a single pre-built string rather than assembled from the
# parts above because golang-migrate takes a URL, and a password containing
# @ : / ? or # must be percent-encoded in one. Compose cannot encode it, so
# the encoding is the operator's job — see .env.docker.example.
migrate:
image: migrate/migrate:v4.19.0
container_name: krow-migrate
volumes:
- ../migrations:/migrations:ro
command:
- -path=/migrations
- -database=${DATABASE_URL:?DATABASE_URL is required — see .env.docker.example}
- up
restart: "no"
logging: *logging
networks: [krow]
# ── API ───────────────────────────────────────────────────────────────────
api:
build:
context: ..
dockerfile: infrastructure/Dockerfile.api
args:
VERSION: ${VERSION:-dev}
image: ${IMAGE:-doormile/krowbackend:latest}
container_name: krow-api
depends_on:
migrate:
condition: service_completed_successfully
environment:
<<: *database-env
APP_ENV: ${APP_ENV:-production}
LOG_LEVEL: ${LOG_LEVEL:-info}
# 0.0.0.0 so the published port actually reaches the process. The binary
# defaults to 127.0.0.1, which inside a container means "nobody".
HTTP_HOST: 0.0.0.0
HTTP_PORT: "8080"
HTTP_READ_TIMEOUT: ${HTTP_READ_TIMEOUT:-15s}
HTTP_WRITE_TIMEOUT: ${HTTP_WRITE_TIMEOUT:-30s}
HTTP_IDLE_TIMEOUT: ${HTTP_IDLE_TIMEOUT:-60s}
HTTP_SHUTDOWN_TIMEOUT: ${HTTP_SHUTDOWN_TIMEOUT:-10s}
# Browser origins allowed to call this API. Empty means same-origin only,
# and the CORS middleware is then not installed at all. Cookies are the
# auth mechanism, so any origin listed here can hold a session.
#
# "*" is refused at startup: browsers reject Allow-Origin "*" together
# with credentials, so it would break authenticated calls rather than
# loosen anything.
HTTP_CORS_ORIGINS: ${HTTP_CORS_ORIGINS:-}
# lax | none | strict. "none" is required when the frontend is on a
# different registrable domain from the API — otherwise the browser
# withholds the cookie however correct the CORS headers are.
HTTP_COOKIE_SAMESITE: ${HTTP_COOKIE_SAMESITE:-lax}
DATABASE_MAX_OPEN_CONNS: ${DATABASE_MAX_OPEN_CONNS:-25}
DATABASE_MIN_IDLE_CONNS: ${DATABASE_MIN_IDLE_CONNS:-2}
DATABASE_CONN_MAX_LIFETIME: ${DATABASE_CONN_MAX_LIFETIME:-30m}
DATABASE_CONNECT_TIMEOUT: ${DATABASE_CONNECT_TIMEOUT:-10s}
DATABASE_STATEMENT_TIMEOUT: ${DATABASE_STATEMENT_TIMEOUT:-10s}
ports:
# Bound to loopback by default. A reverse proxy in front of this is what
# terminates TLS; publishing 0.0.0.0:8080 would put a plain-HTTP service
# carrying session cookies straight onto the network.
- "${API_BIND:-127.0.0.1}:${API_PORT:-8080}:8080"
restart: unless-stopped
stop_grace_period: 30s
read_only: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
tmpfs:
- /tmp:size=32m
deploy:
resources:
limits:
cpus: "${API_CPU_LIMIT:-2}"
memory: ${API_MEMORY_LIMIT:-512M}
reservations:
memory: 128M
logging: *logging
networks: [krow]
networks:
krow:
driver: bridge