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,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