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
378 lines
13 KiB
Go
378 lines
13 KiB
Go
// Package config loads and validates the backend's runtime configuration.
|
|
//
|
|
// Configuration comes from the process environment. A .env file in the
|
|
// repository root is read first as a convenience for local development, and
|
|
// never overrides a variable that is already set — so an explicit
|
|
// `DATABASE_PASSWORD=… go run ./cmd/api` always wins over the file.
|
|
//
|
|
// Nothing here has a credential baked in. Load fails loudly rather than
|
|
// falling back to a default host, database or user, because a silent default
|
|
// is how a development process ends up pointed at the wrong database.
|
|
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Config is the whole of the Phase 1 configuration surface.
|
|
type Config struct {
|
|
AppEnv string
|
|
Log LogConfig
|
|
HTTP HTTPConfig
|
|
DB DBConfig
|
|
Seed SeedConfig
|
|
}
|
|
|
|
// SeedConfig locates the demo fixture. The file is generated from the frontend
|
|
// repository, so it lives beside the migrations rather than inside the Go
|
|
// module: regenerating it must not require rebuilding the binary.
|
|
type SeedConfig struct {
|
|
FixturePath string
|
|
}
|
|
|
|
type LogConfig struct {
|
|
Level string
|
|
}
|
|
|
|
type HTTPConfig struct {
|
|
Host string
|
|
Port int
|
|
ReadTimeout time.Duration
|
|
WriteTimeout time.Duration
|
|
IdleTimeout time.Duration
|
|
ShutdownTimeout time.Duration
|
|
|
|
// CookieSameSite is the SameSite attribute on the session cookie:
|
|
// "lax" (default), "none" or "strict".
|
|
//
|
|
// This exists because CORS is only half of what a cross-origin browser call
|
|
// needs, and the other half is easy to miss. SameSite is judged on SITE
|
|
// (registrable domain), not origin:
|
|
//
|
|
// app.krow.com → api.krow.com SAME site. Lax sends the cookie. ✓
|
|
// krow.vercel.app → api.krow.com CROSS site. Lax does NOT send it. ✗
|
|
// localhost:5173 → 127.0.0.1:8080 CROSS site — different hosts. ✗
|
|
//
|
|
// So a deployment whose frontend is on an unrelated domain gets a perfect
|
|
// set of CORS headers and still no session, because the browser never
|
|
// attaches the cookie. "none" is the only value that survives that, and it
|
|
// requires Secure, which means HTTPS.
|
|
//
|
|
// Default stays "lax": it is the safe value, it is correct for the
|
|
// same-site and same-origin deployments this is normally run as, and it
|
|
// gives CSRF protection that "none" gives up.
|
|
CookieSameSite string
|
|
|
|
// CORSOrigins is the exact set of browser origins allowed to call the API.
|
|
//
|
|
// It exists for one reason: in local development the Vite dev server is an
|
|
// origin of its own (http://localhost:5173) and the API is another
|
|
// (http://127.0.0.1:8080), so every fetch from the frontend is
|
|
// cross-origin. Empty means CORS is off and the API answers only
|
|
// same-origin callers, which is the correct posture everywhere the
|
|
// frontend is served from the same host as the API.
|
|
//
|
|
// Origins are matched exactly and echoed back one at a time. There is no
|
|
// wildcard and no pattern: "*" would let any page on the internet read
|
|
// this API, and once authentication exists that becomes a real hole rather
|
|
// than a theoretical one.
|
|
CORSOrigins []string
|
|
}
|
|
|
|
type DBConfig struct {
|
|
Host string
|
|
Port int
|
|
Name string
|
|
User string
|
|
Password string
|
|
Schema string
|
|
SSLMode string
|
|
MaxOpenConns int32
|
|
MinIdleConns int32
|
|
ConnMaxLifetime time.Duration
|
|
ConnectTimeout time.Duration
|
|
StatementTimeout time.Duration
|
|
}
|
|
|
|
// DSN builds a libpq-style connection URL.
|
|
//
|
|
// Every component is URL-escaped: the local database is called "Krow-force",
|
|
// which is both mixed-case and hyphenated, and a password may contain anything
|
|
// at all. Escaping is not optional here.
|
|
func (d DBConfig) DSN() string {
|
|
u := &url.URL{
|
|
Scheme: "postgres",
|
|
User: url.UserPassword(d.User, d.Password),
|
|
Host: fmt.Sprintf("%s:%d", d.Host, d.Port),
|
|
Path: "/" + d.Name,
|
|
}
|
|
q := u.Query()
|
|
q.Set("sslmode", d.SSLMode)
|
|
// Pin the schema on every connection so no query can accidentally resolve
|
|
// against a different one, and so nothing reaches for a system schema.
|
|
q.Set("search_path", d.Schema)
|
|
q.Set("connect_timeout", strconv.Itoa(int(d.ConnectTimeout.Seconds())))
|
|
q.Set("statement_timeout", strconv.Itoa(int(d.StatementTimeout.Milliseconds())))
|
|
u.RawQuery = q.Encode()
|
|
return u.String()
|
|
}
|
|
|
|
// Redacted returns the DSN with the password replaced, for logs.
|
|
func (d DBConfig) Redacted() string {
|
|
u, err := url.Parse(d.DSN())
|
|
if err != nil {
|
|
return "postgres://<unparseable>"
|
|
}
|
|
if _, hasPassword := u.User.Password(); hasPassword {
|
|
u.User = url.UserPassword(u.User.Username(), "xxxxx")
|
|
}
|
|
return u.String()
|
|
}
|
|
|
|
// Load reads the environment, applies defaults and validates the result.
|
|
func Load() (*Config, error) {
|
|
loadDotEnv(".env")
|
|
|
|
var missing []string
|
|
required := func(key string) string {
|
|
v := strings.TrimSpace(os.Getenv(key))
|
|
if v == "" {
|
|
missing = append(missing, key)
|
|
}
|
|
return v
|
|
}
|
|
|
|
cfg := &Config{
|
|
AppEnv: withDefault("APP_ENV", "development"),
|
|
Log: LogConfig{Level: withDefault("LOG_LEVEL", "info")},
|
|
HTTP: HTTPConfig{
|
|
Host: withDefault("HTTP_HOST", "127.0.0.1"),
|
|
Port: intDefault("HTTP_PORT", 8080),
|
|
ReadTimeout: durationDefault("HTTP_READ_TIMEOUT", 15*time.Second),
|
|
WriteTimeout: durationDefault("HTTP_WRITE_TIMEOUT", 30*time.Second),
|
|
IdleTimeout: durationDefault("HTTP_IDLE_TIMEOUT", 60*time.Second),
|
|
ShutdownTimeout: durationDefault("HTTP_SHUTDOWN_TIMEOUT", 10*time.Second),
|
|
CORSOrigins: corsOrigins(withDefault("APP_ENV", "development")),
|
|
CookieSameSite: strings.ToLower(withDefault("HTTP_COOKIE_SAMESITE", "lax")),
|
|
},
|
|
Seed: SeedConfig{
|
|
FixturePath: withDefault("SEED_FIXTURE_PATH", "./seed/fixtures/seed.json"),
|
|
},
|
|
DB: DBConfig{
|
|
Host: required("DATABASE_HOST"),
|
|
Port: intDefault("DATABASE_PORT", 5432),
|
|
Name: required("DATABASE_NAME"),
|
|
User: required("DATABASE_USER"),
|
|
Password: os.Getenv("DATABASE_PASSWORD"), // may legitimately be empty (trust/peer auth)
|
|
Schema: withDefault("DATABASE_SCHEMA", "public"),
|
|
SSLMode: withDefault("DATABASE_SSLMODE", "disable"),
|
|
MaxOpenConns: int32(intDefault("DATABASE_MAX_OPEN_CONNS", 25)),
|
|
MinIdleConns: int32(intDefault("DATABASE_MIN_IDLE_CONNS", 2)),
|
|
ConnMaxLifetime: durationDefault("DATABASE_CONN_MAX_LIFETIME", 30*time.Minute),
|
|
ConnectTimeout: durationDefault("DATABASE_CONNECT_TIMEOUT", 5*time.Second),
|
|
StatementTimeout: durationDefault("DATABASE_STATEMENT_TIMEOUT", 10*time.Second),
|
|
},
|
|
}
|
|
|
|
if len(missing) > 0 {
|
|
return nil, fmt.Errorf("missing required environment variables: %s "+
|
|
"(copy .env.example to .env and fill them in)", strings.Join(missing, ", "))
|
|
}
|
|
if err := cfg.validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func (c *Config) validate() error {
|
|
switch c.AppEnv {
|
|
case "development", "staging", "production":
|
|
default:
|
|
return fmt.Errorf("APP_ENV must be development, staging or production, got %q", c.AppEnv)
|
|
}
|
|
if c.HTTP.Port < 1 || c.HTTP.Port > 65535 {
|
|
return fmt.Errorf("HTTP_PORT out of range: %d", c.HTTP.Port)
|
|
}
|
|
if c.DB.Port < 1 || c.DB.Port > 65535 {
|
|
return fmt.Errorf("DATABASE_PORT out of range: %d", c.DB.Port)
|
|
}
|
|
// The application owns exactly one schema and it is never a system schema.
|
|
switch c.DB.Schema {
|
|
case "pg_catalog", "pg_toast", "information_schema":
|
|
return fmt.Errorf("DATABASE_SCHEMA must not be a PostgreSQL system schema, got %q", c.DB.Schema)
|
|
}
|
|
if strings.HasPrefix(c.DB.Schema, "pg_") {
|
|
return fmt.Errorf("DATABASE_SCHEMA must not start with \"pg_\", got %q", c.DB.Schema)
|
|
}
|
|
if c.DB.MinIdleConns > c.DB.MaxOpenConns {
|
|
return fmt.Errorf("DATABASE_MIN_IDLE_CONNS (%d) exceeds DATABASE_MAX_OPEN_CONNS (%d)",
|
|
c.DB.MinIdleConns, c.DB.MaxOpenConns)
|
|
}
|
|
if c.AppEnv == "production" && c.DB.SSLMode == "disable" {
|
|
return fmt.Errorf("DATABASE_SSLMODE=disable is not allowed when APP_ENV=production")
|
|
}
|
|
switch c.HTTP.CookieSameSite {
|
|
case "lax", "strict":
|
|
case "none":
|
|
// SameSite=None without Secure is ignored — and in current browsers,
|
|
// rejected outright — so the cookie would simply never be stored. The
|
|
// Secure flag is set for every APP_ENV except development, so this is
|
|
// the one combination that produces a silently sessionless deployment.
|
|
if c.AppEnv == "development" {
|
|
return fmt.Errorf("HTTP_COOKIE_SAMESITE=none requires the Secure cookie flag, " +
|
|
"which is not set when APP_ENV=development; SameSite=None over plain HTTP " +
|
|
"is rejected by browsers")
|
|
}
|
|
default:
|
|
return fmt.Errorf("HTTP_COOKIE_SAMESITE must be lax, none or strict, got %q",
|
|
c.HTTP.CookieSameSite)
|
|
}
|
|
for _, origin := range c.HTTP.CORSOrigins {
|
|
// "*" is rejected rather than quietly honoured. The middleware echoes a
|
|
// single matched origin, so a wildcard could only ever be a
|
|
// misunderstanding of what this setting does.
|
|
// "*" is not a stricter-than-necessary policy choice — it cannot work
|
|
// here at all. Authentication is a cookie, so the API must answer
|
|
// Access-Control-Allow-Credentials: true, and every browser REFUSES
|
|
// the pairing of that header with Allow-Origin: "*". A deployment
|
|
// configured this way would send correct-looking headers and have
|
|
// every authenticated call blocked client-side.
|
|
if origin == "*" {
|
|
return fmt.Errorf(`HTTP_CORS_ORIGINS must list explicit origins; "*" cannot be used ` +
|
|
`because this API authenticates with a cookie, and browsers reject ` +
|
|
`Access-Control-Allow-Origin: "*" together with credentials. ` +
|
|
`List each frontend origin, or serve the frontend from the API's own origin ` +
|
|
`(then leave this unset and CORS is not involved at all)`)
|
|
}
|
|
if !strings.HasPrefix(origin, "http://") && !strings.HasPrefix(origin, "https://") {
|
|
return fmt.Errorf("HTTP_CORS_ORIGINS entry %q must be a full origin including the scheme", origin)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// devCORSOrigins are the origins the Vite dev server can occupy. Vite binds
|
|
// localhost by default and 127.0.0.1 when asked, and a browser treats those two
|
|
// as different origins, so both are listed. 4173 is `vite preview`.
|
|
var devCORSOrigins = []string{
|
|
"http://localhost:5173", "http://127.0.0.1:5173",
|
|
"http://localhost:4173", "http://127.0.0.1:4173",
|
|
}
|
|
|
|
// corsOrigins reads HTTP_CORS_ORIGINS, a comma-separated allowlist.
|
|
//
|
|
// The development default is the Vite dev server, because that is the whole
|
|
// point of the setting in Phase 2D. Outside development the default is empty:
|
|
// a staging or production deployment that genuinely serves its frontend from
|
|
// another origin has to say so explicitly, rather than inheriting a list of
|
|
// localhost origins nobody reviewed.
|
|
func corsOrigins(appEnv string) []string {
|
|
raw, set := os.LookupEnv("HTTP_CORS_ORIGINS")
|
|
if !set {
|
|
if appEnv == "development" {
|
|
return devCORSOrigins
|
|
}
|
|
return nil
|
|
}
|
|
var out []string
|
|
for _, part := range strings.Split(raw, ",") {
|
|
// A trailing slash makes the string unequal to the Origin header the
|
|
// browser actually sends, which fails in a way that looks like a
|
|
// server bug rather than a typo.
|
|
if o := strings.TrimRight(strings.TrimSpace(part), "/"); o != "" {
|
|
out = append(out, o)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func withDefault(key, fallback string) string {
|
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func intDefault(key string, fallback int) int {
|
|
v := strings.TrimSpace(os.Getenv(key))
|
|
if v == "" {
|
|
return fallback
|
|
}
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil {
|
|
return fallback
|
|
}
|
|
return n
|
|
}
|
|
|
|
func durationDefault(key string, fallback time.Duration) time.Duration {
|
|
v := strings.TrimSpace(os.Getenv(key))
|
|
if v == "" {
|
|
return fallback
|
|
}
|
|
d, err := time.ParseDuration(v)
|
|
if err != nil {
|
|
return fallback
|
|
}
|
|
return d
|
|
}
|
|
|
|
// loadDotEnv reads KEY=VALUE lines, walking up from the working directory so
|
|
// `go run ./cmd/api` finds the repository-root .env. Existing environment
|
|
// variables always win. Absence of the file is not an error.
|
|
func loadDotEnv(name string) {
|
|
dir, err := os.Getwd()
|
|
if err != nil {
|
|
return
|
|
}
|
|
for i := 0; i < 5; i++ {
|
|
path := dir + string(os.PathSeparator) + name
|
|
if data, err := os.ReadFile(path); err == nil {
|
|
applyDotEnv(string(data))
|
|
return
|
|
}
|
|
parent := parentDir(dir)
|
|
if parent == dir {
|
|
return
|
|
}
|
|
dir = parent
|
|
}
|
|
}
|
|
|
|
func parentDir(dir string) string {
|
|
i := strings.LastIndex(dir, string(os.PathSeparator))
|
|
if i <= 0 {
|
|
return dir
|
|
}
|
|
return dir[:i]
|
|
}
|
|
|
|
func applyDotEnv(content string) {
|
|
for _, line := range strings.Split(content, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
key, value, ok := strings.Cut(line, "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
key = strings.TrimSpace(strings.TrimPrefix(key, "export "))
|
|
value = strings.TrimSpace(value)
|
|
if len(value) >= 2 {
|
|
if (value[0] == '"' && value[len(value)-1] == '"') ||
|
|
(value[0] == '\'' && value[len(value)-1] == '\'') {
|
|
value = value[1 : len(value)-1]
|
|
}
|
|
}
|
|
if _, present := os.LookupEnv(key); !present {
|
|
_ = os.Setenv(key, value)
|
|
}
|
|
}
|
|
}
|