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