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:
147
scripts/drop_public_tables.go
Normal file
147
scripts/drop_public_tables.go
Normal file
@@ -0,0 +1,147 @@
|
||||
//go:build ignore
|
||||
|
||||
// Command drop_public_tables removes every table in krowdb's `public` schema.
|
||||
//
|
||||
// DESTRUCTIVE AND IRREVERSIBLE. Authorised by the database admin on 2026-08-24
|
||||
// to clear an unrelated delivery-platform schema (132 tables, 222 MB) so the
|
||||
// Krow backend can take over this database.
|
||||
//
|
||||
// `hdb_catalog` — Hasura's own 8 metadata tables — is deliberately NOT touched.
|
||||
//
|
||||
// A structure-only snapshot of what this removes was taken beforehand:
|
||||
// /Users/tenext/Documents/Krow/krowdb_public_snapshot_2026-08-24.sql
|
||||
// It carries DDL, not rows. Row data is NOT recoverable after this runs.
|
||||
//
|
||||
// Everything happens in ONE transaction: it either clears the schema completely
|
||||
// or leaves it exactly as it was.
|
||||
//
|
||||
// Run:
|
||||
// cd go-api && go run ../scripts/drop_public_tables.go
|
||||
//
|
||||
// It reads DATABASE_* from the environment (the Makefile exports .env), and
|
||||
// requires CONFIRM_DROP=yes so it cannot fire by accident.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func env(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func main() {
|
||||
if os.Getenv("CONFIRM_DROP") != "yes" {
|
||||
fmt.Println("refusing: set CONFIRM_DROP=yes to run this.")
|
||||
fmt.Println("this drops EVERY table in the target database's public schema.")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
dsn := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=%s&connect_timeout=15",
|
||||
url.QueryEscape(env("DATABASE_USER", "admin")),
|
||||
url.QueryEscape(env("DATABASE_PASSWORD", "")),
|
||||
env("DATABASE_HOST", "127.0.0.1"),
|
||||
env("DATABASE_PORT", "5432"),
|
||||
url.QueryEscape(env("DATABASE_NAME", "krowdb")),
|
||||
env("DATABASE_SSLMODE", "disable"),
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
conn, err := pgx.Connect(ctx, dsn)
|
||||
if err != nil {
|
||||
fmt.Println("connect failed:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer conn.Close(ctx)
|
||||
|
||||
var dbname string
|
||||
_ = conn.QueryRow(ctx, `SELECT current_database()`).Scan(&dbname)
|
||||
fmt.Println("connected to:", dbname)
|
||||
|
||||
tx, err := conn.Begin(ctx)
|
||||
if err != nil {
|
||||
fmt.Println("begin failed:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
names, err := collect(ctx, tx, `
|
||||
SELECT c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'public' AND c.relkind = 'r' ORDER BY 1`)
|
||||
if err != nil {
|
||||
fmt.Println("listing tables failed:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
views, _ := collect(ctx, tx, `
|
||||
SELECT c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'public' AND c.relkind = 'v'`)
|
||||
seqs, _ := collect(ctx, tx, `
|
||||
SELECT sequence_name FROM information_schema.sequences WHERE sequence_schema = 'public'`)
|
||||
|
||||
fmt.Printf("dropping %d tables, %d views, %d sequences from public\n",
|
||||
len(names), len(views), len(seqs))
|
||||
fmt.Println("hdb_catalog (Hasura) is NOT touched.")
|
||||
|
||||
// CASCADE because the schema carries foreign keys between these tables;
|
||||
// dropping in dependency order would otherwise be required.
|
||||
for _, t := range names {
|
||||
if _, err := tx.Exec(ctx, fmt.Sprintf(`DROP TABLE IF EXISTS public.%q CASCADE`, t)); err != nil {
|
||||
fmt.Printf(" failed on %s: %v\n", t, err)
|
||||
fmt.Println("rolling back — nothing was dropped.")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
for _, v := range views {
|
||||
if _, err := tx.Exec(ctx, fmt.Sprintf(`DROP VIEW IF EXISTS public.%q CASCADE`, v)); err != nil {
|
||||
fmt.Printf(" failed on view %s: %v\n", v, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
for _, s := range seqs {
|
||||
if _, err := tx.Exec(ctx, fmt.Sprintf(`DROP SEQUENCE IF EXISTS public.%q CASCADE`, s)); err != nil {
|
||||
fmt.Printf(" failed on sequence %s: %v\n", s, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
fmt.Println("commit failed:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("committed.")
|
||||
|
||||
var pub, hdb int
|
||||
_ = conn.QueryRow(ctx, `SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public' AND c.relkind='r'`).Scan(&pub)
|
||||
_ = conn.QueryRow(ctx, `SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='hdb_catalog' AND c.relkind='r'`).Scan(&hdb)
|
||||
fmt.Println("\nafter:")
|
||||
fmt.Println(" public tables :", pub)
|
||||
fmt.Println(" hdb_catalog tables :", hdb, "(preserved)")
|
||||
}
|
||||
|
||||
func collect(ctx context.Context, tx pgx.Tx, q string) ([]string, error) {
|
||||
rows, err := tx.Query(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var s string
|
||||
if err := rows.Scan(&s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
Reference in New Issue
Block a user