Files
krow_backend/go-api/internal/httpserver/cors.go
Suriyakumarvijayanayagam 954ba9076f 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
2026-08-25 11:33:01 +05:30

118 lines
4.7 KiB
Go

package httpserver
import (
"net/http"
"strconv"
"strings"
)
// Cross-origin access, for local development.
//
// In Phase 2D the frontend fetches this API directly from the Vite dev server,
// which is a different origin (http://localhost:5173 → http://127.0.0.1:8080).
// Without these headers the browser makes the request and then refuses to let
// the page read the response, which surfaces in the app as an opaque "Failed to
// fetch" with a perfectly healthy 200 in the server log.
//
// This is a transport concern only. No endpoint, request shape, response shape
// or status code in docs/api-contract.md changes because of it.
// corsMaxAge is how long a browser may cache a preflight result. Ten minutes
// keeps preflight off the hot path without making an allowlist change take an
// awkwardly long time to be noticed in development.
const corsMaxAge = 600
// allowedCORSMethods is every method the router actually registers, plus
// OPTIONS for the preflight itself. It is a fixed list rather than something
// derived per path: the browser asks about one method at a time and only needs
// to know it is permitted in general.
var allowedCORSMethods = []string{
http.MethodGet, http.MethodPost, http.MethodPatch,
http.MethodDelete, http.MethodOptions,
}
// cors answers preflights and marks cross-origin responses as readable.
//
// Origins are matched exactly against the allowlist and echoed back one at a
// time — never "*" — so adding credentials later does not require rewriting
// this. A request whose Origin is not on the list is served normally, with no
// CORS headers: the API does not refuse it, the browser simply will not hand
// the response to the page. That distinction matters, because curl, the health
// checker and any server-to-server caller send no Origin at all and must not be
// affected by this middleware.
//
// With an empty allowlist the middleware is not installed at all (see New), so
// the same-origin deployment pays nothing for it.
func cors(origins []string) func(http.Handler) http.Handler {
allowed := make(map[string]bool, len(origins))
for _, o := range origins {
allowed[o] = true
}
methods := strings.Join(allowedCORSMethods, ", ")
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
// Vary on Origin whether or not this particular origin matched: the
// response differs by Origin, so a cache that ignored it could hand
// one origin's headers to another.
w.Header().Add("Vary", "Origin")
if origin == "" || !allowed[origin] {
if isPreflight(r) {
// A preflight is never a real request. Answering it with
// the router's 404 for "OPTIONS /api/v1/…" would be
// misleading; 403 says plainly that the origin was refused.
w.WriteHeader(http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
return
}
w.Header().Set("Access-Control-Allow-Origin", origin)
// Authentication is a cookie, so the browser will neither send it
// nor expose the response without this. It is set for allowlisted
// origins only, and the origin above is always a specific one —
// the pairing of Allow-Credentials with "*" is rejected outright by
// browsers, which is the second reason this middleware never echoes
// a wildcard.
//
// Both the preflight and the actual response need it: the preflight
// decides whether the browser is willing to SEND the cookie, and the
// actual response decides whether the page may READ the result.
// Setting it here, before the preflight branch, covers both.
w.Header().Set("Access-Control-Allow-Credentials", "true")
if isPreflight(r) {
w.Header().Add("Vary", "Access-Control-Request-Method")
w.Header().Add("Vary", "Access-Control-Request-Headers")
w.Header().Set("Access-Control-Allow-Methods", methods)
// Echo the requested headers rather than listing them. The
// frontend sends only Content-Type today; echoing means a
// future header does not need a change here to be allowed from
// an origin that is already trusted.
if h := r.Header.Get("Access-Control-Request-Headers"); h != "" {
w.Header().Set("Access-Control-Allow-Headers", h)
} else {
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
}
w.Header().Set("Access-Control-Max-Age", strconv.Itoa(corsMaxAge))
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
// isPreflight identifies the browser's OPTIONS probe. A bare OPTIONS with no
// Access-Control-Request-Method is not a preflight and is left to the router.
func isPreflight(r *http.Request) bool {
return r.Method == http.MethodOptions &&
r.Header.Get("Access-Control-Request-Method") != ""
}