Merge pull request 'Add CORS credentials, transactional endpoints, and container deployment' (#1) from feat/cors-transactions-docker into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
45
.dockerignore
Normal file
45
.dockerignore
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Keep the build context small and keep secrets out of it.
|
||||||
|
#
|
||||||
|
# Everything the image needs is go-api/ and seed/fixtures/seed.json. Anything
|
||||||
|
# listed here is either a secret, a build output, or bytes that would be sent
|
||||||
|
# to the daemon and then ignored — each of which slows every build.
|
||||||
|
|
||||||
|
# ── Secrets. Never in an image, never in a layer. ───────────────────────────
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
|
||||||
|
# ── Version control and CI ──────────────────────────────────────────────────
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.github
|
||||||
|
|
||||||
|
# ── Build output ────────────────────────────────────────────────────────────
|
||||||
|
go-api/bin/
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
coverage.*
|
||||||
|
|
||||||
|
# ── Things the runtime image does not need ──────────────────────────────────
|
||||||
|
# NOTE: migrations/ is deliberately NOT ignored. They are baked into the image
|
||||||
|
# so a Kubernetes initContainer can apply them using the same image and tag as
|
||||||
|
# the API — see Dockerfile.api. docker-compose uses a bind mount instead, which
|
||||||
|
# works either way.
|
||||||
|
docs/
|
||||||
|
scripts/
|
||||||
|
infrastructure/
|
||||||
|
Makefile
|
||||||
|
README.md
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# The snapshot taken before the krowdb public schema was dropped. Large, and
|
||||||
|
# has no business in a container.
|
||||||
|
krowdb_public_snapshot_*.sql
|
||||||
|
|
||||||
|
# ── Editor / OS ─────────────────────────────────────────────────────────────
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -1,8 +1,18 @@
|
|||||||
# Secrets and local configuration
|
# Secrets and local configuration
|
||||||
|
# A bare pattern matches at any depth, so this covers infrastructure/.env too.
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
|
# TLS material. The local-db overlay generates a self-signed pair inside the
|
||||||
|
# postgres volume, but nothing stops someone dropping certs here by hand.
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
|
||||||
|
# Structural snapshots taken before destructive migrations
|
||||||
|
krowdb_public_snapshot_*.sql
|
||||||
|
|
||||||
# Go build output
|
# Go build output
|
||||||
/go-api/bin/
|
/go-api/bin/
|
||||||
*.test
|
*.test
|
||||||
@@ -12,3 +22,6 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
# Filled-in Kubernetes secret (the .example is the committed template)
|
||||||
|
infrastructure/k8s/10-secret.yaml
|
||||||
|
|||||||
54
Makefile
54
Makefile
@@ -135,3 +135,57 @@ migrate-force: ## Clear a dirty state: make migrate-force VERSION=1
|
|||||||
.PHONY: verify-schema
|
.PHONY: verify-schema
|
||||||
verify-schema: ## Print the tables, enums, FKs and indexes that exist in the target schema
|
verify-schema: ## Print the tables, enums, FKs and indexes that exist in the target schema
|
||||||
@$(PSQL) -f scripts/verify_schema.sql
|
@$(PSQL) -f scripts/verify_schema.sql
|
||||||
|
|
||||||
|
# ── Docker ──────────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# These read infrastructure/.env, NOT the repository-root .env that `make run`
|
||||||
|
# uses. Two files on purpose: the root one points at a developer's database
|
||||||
|
# from the host, the infrastructure one points at a deployment's database from
|
||||||
|
# inside a container, and DATABASE_HOST is different in each.
|
||||||
|
|
||||||
|
COMPOSE_DIR := infrastructure
|
||||||
|
COMPOSE_FILES := -f docker-compose.yml
|
||||||
|
# Layer the local database in with: make docker-up LOCAL_DB=1
|
||||||
|
ifdef LOCAL_DB
|
||||||
|
COMPOSE_FILES += -f docker-compose.local-db.yml
|
||||||
|
endif
|
||||||
|
COMPOSE = cd $(COMPOSE_DIR) && docker compose $(COMPOSE_FILES)
|
||||||
|
|
||||||
|
.PHONY: docker-build
|
||||||
|
docker-build: ## Build the API image
|
||||||
|
$(COMPOSE) build
|
||||||
|
|
||||||
|
.PHONY: docker-up
|
||||||
|
docker-up: ## Start the stack (migrations run first). LOCAL_DB=1 adds PostgreSQL
|
||||||
|
$(COMPOSE) up -d --build
|
||||||
|
@echo "api: http://127.0.0.1:8080/health"
|
||||||
|
@echo "logs: make docker-logs"
|
||||||
|
|
||||||
|
.PHONY: docker-down
|
||||||
|
docker-down: ## Stop the stack, keeping volumes
|
||||||
|
$(COMPOSE) down
|
||||||
|
|
||||||
|
.PHONY: docker-logs
|
||||||
|
docker-logs: ## Follow the API logs
|
||||||
|
$(COMPOSE) logs -f api
|
||||||
|
|
||||||
|
.PHONY: docker-ps
|
||||||
|
docker-ps: ## Show stack status and health
|
||||||
|
$(COMPOSE) ps
|
||||||
|
|
||||||
|
.PHONY: docker-migrate
|
||||||
|
docker-migrate: ## Apply migrations only, without starting the API
|
||||||
|
$(COMPOSE) run --rm migrate
|
||||||
|
|
||||||
|
.PHONY: docker-seed
|
||||||
|
docker-seed: ## Load the demo fixture into the deployment's database
|
||||||
|
$(COMPOSE) run --rm --entrypoint /usr/local/bin/seed api
|
||||||
|
|
||||||
|
.PHONY: docker-setpassword
|
||||||
|
docker-setpassword: ## Set a password: make docker-setpassword EMAIL=demo@krow.app
|
||||||
|
@test -n "$(EMAIL)" || { echo "usage: make docker-setpassword EMAIL=demo@krow.app"; exit 1; }
|
||||||
|
$(COMPOSE) run --rm -it --entrypoint /usr/local/bin/setpassword api -email $(EMAIL)
|
||||||
|
|
||||||
|
.PHONY: docker-config
|
||||||
|
docker-config: ## Render the fully resolved compose configuration
|
||||||
|
$(COMPOSE) config
|
||||||
|
|||||||
@@ -47,6 +47,27 @@ type HTTPConfig struct {
|
|||||||
IdleTimeout time.Duration
|
IdleTimeout time.Duration
|
||||||
ShutdownTimeout 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.
|
// 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
|
// It exists for one reason: in local development the Vite dev server is an
|
||||||
@@ -137,6 +158,7 @@ func Load() (*Config, error) {
|
|||||||
IdleTimeout: durationDefault("HTTP_IDLE_TIMEOUT", 60*time.Second),
|
IdleTimeout: durationDefault("HTTP_IDLE_TIMEOUT", 60*time.Second),
|
||||||
ShutdownTimeout: durationDefault("HTTP_SHUTDOWN_TIMEOUT", 10*time.Second),
|
ShutdownTimeout: durationDefault("HTTP_SHUTDOWN_TIMEOUT", 10*time.Second),
|
||||||
CORSOrigins: corsOrigins(withDefault("APP_ENV", "development")),
|
CORSOrigins: corsOrigins(withDefault("APP_ENV", "development")),
|
||||||
|
CookieSameSite: strings.ToLower(withDefault("HTTP_COOKIE_SAMESITE", "lax")),
|
||||||
},
|
},
|
||||||
Seed: SeedConfig{
|
Seed: SeedConfig{
|
||||||
FixturePath: withDefault("SEED_FIXTURE_PATH", "./seed/fixtures/seed.json"),
|
FixturePath: withDefault("SEED_FIXTURE_PATH", "./seed/fixtures/seed.json"),
|
||||||
@@ -194,12 +216,38 @@ func (c *Config) validate() error {
|
|||||||
if c.AppEnv == "production" && c.DB.SSLMode == "disable" {
|
if c.AppEnv == "production" && c.DB.SSLMode == "disable" {
|
||||||
return fmt.Errorf("DATABASE_SSLMODE=disable is not allowed when APP_ENV=production")
|
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 {
|
for _, origin := range c.HTTP.CORSOrigins {
|
||||||
// "*" is rejected rather than quietly honoured. The middleware echoes a
|
// "*" is rejected rather than quietly honoured. The middleware echoes a
|
||||||
// single matched origin, so a wildcard could only ever be a
|
// single matched origin, so a wildcard could only ever be a
|
||||||
// misunderstanding of what this setting does.
|
// 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 == "*" {
|
if origin == "*" {
|
||||||
return fmt.Errorf("HTTP_CORS_ORIGINS must list explicit origins; \"*\" is not accepted")
|
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://") {
|
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 fmt.Errorf("HTTP_CORS_ORIGINS entry %q must be a full origin including the scheme", origin)
|
||||||
|
|||||||
@@ -42,6 +42,29 @@ const sessionCookieName = "krow_session"
|
|||||||
// that never authenticate anything.
|
// that never authenticate anything.
|
||||||
func (s *Server) secureCookies() bool { return s.cfg.AppEnv != "development" }
|
func (s *Server) secureCookies() bool { return s.cfg.AppEnv != "development" }
|
||||||
|
|
||||||
|
// sameSite resolves the configured SameSite mode.
|
||||||
|
//
|
||||||
|
// Lax remains the default and the recommendation. "none" exists for the one
|
||||||
|
// deployment shape that cannot work without it: a frontend on a different
|
||||||
|
// registrable domain from the API. In that case Lax withholds the cookie on
|
||||||
|
// every cross-site fetch, so the sign-in succeeds, the Set-Cookie arrives, and
|
||||||
|
// the next request carries nothing — which reads as a broken session rather
|
||||||
|
// than as a cookie policy.
|
||||||
|
//
|
||||||
|
// An unrecognised value falls back to Lax rather than to None. config.validate
|
||||||
|
// rejects those before startup, so this is only a belt-and-braces default in
|
||||||
|
// the safe direction.
|
||||||
|
func (s *Server) sameSite() http.SameSite {
|
||||||
|
switch s.cfg.HTTP.CookieSameSite {
|
||||||
|
case "none":
|
||||||
|
return http.SameSiteNoneMode
|
||||||
|
case "strict":
|
||||||
|
return http.SameSiteStrictMode
|
||||||
|
default:
|
||||||
|
return http.SameSiteLaxMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// setSessionCookie writes the raw token to the browser.
|
// setSessionCookie writes the raw token to the browser.
|
||||||
//
|
//
|
||||||
// This is the only place the raw token is written to a response, and it goes
|
// This is the only place the raw token is written to a response, and it goes
|
||||||
@@ -59,12 +82,14 @@ func (s *Server) setSessionCookie(w http.ResponseWriter, token string, lifetime
|
|||||||
Path: "/",
|
Path: "/",
|
||||||
// HttpOnly: script cannot read it.
|
// HttpOnly: script cannot read it.
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
// Lax, not Strict and not None. Strict would drop the cookie on any
|
// Lax by default, and Strict/None available through
|
||||||
// cross-site navigation, so following a link into the app would land on
|
// HTTP_COOKIE_SAMESITE. Strict would drop the cookie on any cross-site
|
||||||
// a login page despite a live session. None would require Secure and
|
// navigation, so following a link into the app would land on a login
|
||||||
// would send the cookie on cross-site POSTs, which is the CSRF hole Lax
|
// page despite a live session. None sends it on cross-site requests,
|
||||||
// exists to close.
|
// which is the CSRF hole Lax exists to close — and is nonetheless the
|
||||||
SameSite: http.SameSiteLaxMode,
|
// only workable value when the frontend is on a different registrable
|
||||||
|
// domain. See Server.sameSite.
|
||||||
|
SameSite: s.sameSite(),
|
||||||
Secure: s.secureCookies(),
|
Secure: s.secureCookies(),
|
||||||
MaxAge: int(lifetime.Seconds()),
|
MaxAge: int(lifetime.Seconds()),
|
||||||
})
|
})
|
||||||
@@ -82,7 +107,9 @@ func (s *Server) clearSessionCookie(w http.ResponseWriter) {
|
|||||||
Value: "",
|
Value: "",
|
||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
SameSite: http.SameSiteLaxMode,
|
// Must match the attributes it was set with, SameSite included, or the
|
||||||
|
// browser treats this as a different cookie and leaves the original.
|
||||||
|
SameSite: s.sameSite(),
|
||||||
Secure: s.secureCookies(),
|
Secure: s.secureCookies(),
|
||||||
MaxAge: -1,
|
MaxAge: -1,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -73,6 +73,19 @@ func cors(origins []string) func(http.Handler) http.Handler {
|
|||||||
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
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) {
|
if isPreflight(r) {
|
||||||
w.Header().Add("Vary", "Access-Control-Request-Method")
|
w.Header().Add("Vary", "Access-Control-Request-Method")
|
||||||
w.Header().Add("Vary", "Access-Control-Request-Headers")
|
w.Header().Add("Vary", "Access-Control-Request-Headers")
|
||||||
|
|||||||
@@ -133,6 +133,62 @@ func TestCORSOffByDefault(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Authentication is a cookie, so a cross-origin frontend calling with
|
||||||
|
// `credentials: 'include'` needs Access-Control-Allow-Credentials on the actual
|
||||||
|
// response. Without it the browser blocks the page from reading a reply the
|
||||||
|
// server answered perfectly well, and the app sees an opaque network failure
|
||||||
|
// beside a 200 in the server log.
|
||||||
|
func TestCORSAllowsCredentialsOnResponse(t *testing.T) {
|
||||||
|
handler := corsAPI(t, devOrigin)
|
||||||
|
|
||||||
|
rec := send(handler, "GET", "/api/v1/job-postings", map[string]string{"Origin": devOrigin})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
|
||||||
|
t.Fatalf("Access-Control-Allow-Credentials = %q, want \"true\" — "+
|
||||||
|
"a cookie-authenticated API is unreadable cross-origin without it", got)
|
||||||
|
}
|
||||||
|
// Allow-Credentials with a wildcard origin is rejected by every browser, so
|
||||||
|
// the two must never appear together.
|
||||||
|
if got := rec.Header().Get("Access-Control-Allow-Origin"); got == "*" {
|
||||||
|
t.Fatal("Access-Control-Allow-Origin is \"*\" alongside credentials; browsers refuse that pairing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The preflight decides whether the browser is willing to SEND the cookie at
|
||||||
|
// all, so it needs the header too — separately from the actual response.
|
||||||
|
func TestCORSAllowsCredentialsOnPreflight(t *testing.T) {
|
||||||
|
handler := corsAPI(t, devOrigin)
|
||||||
|
|
||||||
|
rec := send(handler, "OPTIONS", "/api/v1/job-postings", map[string]string{
|
||||||
|
"Origin": devOrigin,
|
||||||
|
"Access-Control-Request-Method": "POST",
|
||||||
|
})
|
||||||
|
if rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("expected 204, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
|
||||||
|
t.Fatalf("preflight Access-Control-Allow-Credentials = %q, want \"true\"", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An origin that is not on the allowlist must not be handed credentials
|
||||||
|
// permission — the header is worthless on its own, but pairing it with a
|
||||||
|
// reflected origin would be the classic misconfiguration.
|
||||||
|
func TestCORSWithholdsCredentialsFromUnknownOrigin(t *testing.T) {
|
||||||
|
handler := corsAPI(t, devOrigin)
|
||||||
|
|
||||||
|
rec := send(handler, "GET", "/api/v1/job-postings",
|
||||||
|
map[string]string{"Origin": "http://evil.example"})
|
||||||
|
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "" {
|
||||||
|
t.Fatalf("an unlisted origin was granted credentials: %q", got)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||||||
|
t.Fatalf("an unlisted origin was echoed back: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func contains(haystack, needle string) bool {
|
func contains(haystack, needle string) bool {
|
||||||
for i := 0; i+len(needle) <= len(haystack); i++ {
|
for i := 0; i+len(needle) <= len(haystack); i++ {
|
||||||
if haystack[i:i+len(needle)] == needle {
|
if haystack[i:i+len(needle)] == needle {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ type Server struct {
|
|||||||
db *db.DB
|
db *db.DB
|
||||||
api *service.Registry
|
api *service.Registry
|
||||||
definitions *service.DefinitionsService
|
definitions *service.DefinitionsService
|
||||||
|
workflows *service.WorkflowService
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
http *http.Server
|
http *http.Server
|
||||||
started time.Time
|
started time.Time
|
||||||
@@ -124,6 +125,7 @@ func New(cfg *config.Config, database *db.DB, log *slog.Logger, opts ...Option)
|
|||||||
cfg: cfg, db: database, log: log,
|
cfg: cfg, db: database, log: log,
|
||||||
api: service.NewRegistry(database.Pool),
|
api: service.NewRegistry(database.Pool),
|
||||||
definitions: service.NewDefinitions(database.Pool),
|
definitions: service.NewDefinitions(database.Pool),
|
||||||
|
workflows: service.NewWorkflows(database.Pool).WithClock(o.now),
|
||||||
started: o.now(),
|
started: o.now(),
|
||||||
sessions: sessions,
|
sessions: sessions,
|
||||||
users: users,
|
users: users,
|
||||||
@@ -135,7 +137,8 @@ func New(cfg *config.Config, database *db.DB, log *slog.Logger, opts ...Option)
|
|||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /health", s.handleHealth)
|
mux.HandleFunc("GET /health", s.handleHealth)
|
||||||
s.endpoints = s.routeAuth(mux) + s.routeResources(mux) + s.routeMe(mux) + s.routeDefinitions(mux)
|
s.endpoints = s.routeAuth(mux) + s.routeResources(mux) + s.routeMe(mux) +
|
||||||
|
s.routeDefinitions(mux) + s.routeWorkflows(mux)
|
||||||
|
|
||||||
handler := jsonErrors(mux)
|
handler := jsonErrors(mux)
|
||||||
// Authentication sits where devOrgMiddleware used to, so every route below
|
// Authentication sits where devOrgMiddleware used to, so every route below
|
||||||
|
|||||||
153
go-api/internal/httpserver/workflows.go
Normal file
153
go-api/internal/httpserver/workflows.go
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/krow/krow-backend/go-api/internal/authctx"
|
||||||
|
"github.com/krow/krow-backend/go-api/internal/domain"
|
||||||
|
"github.com/krow/krow-backend/go-api/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The multi-record endpoints from api-contract.md §12.1.
|
||||||
|
//
|
||||||
|
// These are the first routes that are not a plain CRUD projection of a table,
|
||||||
|
// and they are shaped as verbs on the record they act on — `.../{id}/hire`,
|
||||||
|
// `.../{id}/assignments` — rather than as new collections. The action is the
|
||||||
|
// thing being requested, and it has no independent existence to GET.
|
||||||
|
//
|
||||||
|
// AUTHORIZATION REUSES THE POLICY TABLE RATHER THAN ADDING TO IT.
|
||||||
|
//
|
||||||
|
// A workflow is exactly as privileged as the writes it performs, so each one
|
||||||
|
// is gated on the operations it will actually carry out — hire needs UPDATE on
|
||||||
|
// job-applications and CREATE on staff; assign needs CREATE on assignments and
|
||||||
|
// UPDATE on job-applications. Inventing a separate `hire` permission would
|
||||||
|
// create a second place where the answer to "who may do this" lives, and the
|
||||||
|
// two would eventually disagree. Every pair below resolves to `operators`
|
||||||
|
// today, which is the intended answer: a talent user cannot hire themselves or
|
||||||
|
// place themselves on a shift.
|
||||||
|
|
||||||
|
// requirement is one (resource, operation) pair a workflow depends on.
|
||||||
|
type requirement struct {
|
||||||
|
path string
|
||||||
|
op domain.Op
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorizeAll refuses unless the caller may perform every listed operation.
|
||||||
|
//
|
||||||
|
// All-or-nothing, checked before any transaction opens: a caller who may update
|
||||||
|
// an application but not create staff must not get halfway through a hire and
|
||||||
|
// be rolled back. The refusal is the same 403 a single-operation handler gives,
|
||||||
|
// and names no resource — see domain.Forbidden.
|
||||||
|
func (s *Server) authorizeAll(w http.ResponseWriter, r *http.Request,
|
||||||
|
reqs ...requirement) (authctx.Identity, bool) {
|
||||||
|
|
||||||
|
ident, err := authctx.MustFrom(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
// Unreachable: the middleware refuses an unauthenticated request before
|
||||||
|
// the router sees it. A missing identity here is a wiring bug.
|
||||||
|
writeError(w, s.log, domain.Internal(err))
|
||||||
|
return authctx.Identity{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
role, known := domain.ParseRole(ident.Role)
|
||||||
|
if !known {
|
||||||
|
s.log.Warn("workflow refused: unknown role",
|
||||||
|
"user_id", ident.UserID, "role", ident.Role, "path", r.URL.Path)
|
||||||
|
writeError(w, s.log, domain.Forbidden())
|
||||||
|
return authctx.Identity{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, req := range reqs {
|
||||||
|
svc, ok := s.api.Get(req.path)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, s.log, domain.Internal(
|
||||||
|
errUnregisteredResource(req.path)))
|
||||||
|
return authctx.Identity{}, false
|
||||||
|
}
|
||||||
|
if !svc.Resource().Policy.Allows(req.op, role) {
|
||||||
|
s.log.Warn("workflow authorization refused",
|
||||||
|
"user_id", ident.UserID, "role", ident.Role,
|
||||||
|
"required_resource", req.path, "path", r.URL.Path)
|
||||||
|
writeError(w, s.log, domain.Forbidden())
|
||||||
|
return authctx.Identity{}, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ident, true
|
||||||
|
}
|
||||||
|
|
||||||
|
type unregisteredResourceError string
|
||||||
|
|
||||||
|
func (e unregisteredResourceError) Error() string {
|
||||||
|
return "httpserver: workflow depends on unregistered resource " + string(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
func errUnregisteredResource(path string) error { return unregisteredResourceError(path) }
|
||||||
|
|
||||||
|
func (s *Server) routeWorkflows(mux *http.ServeMux) int {
|
||||||
|
mux.HandleFunc("POST /api/v1/job-applications/{id}/hire", s.handleHire)
|
||||||
|
mux.HandleFunc("POST /api/v1/job-postings/{id}/assignments", s.handleAssign)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleHire moves an application to `hired` and creates the staff record in
|
||||||
|
// one transaction. Replaces the two-call sequence at krowHooks.js:302-303.
|
||||||
|
func (s *Server) handleHire(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ident, ok := s.authorizeAll(w, r,
|
||||||
|
requirement{"job-applications", domain.OpUpdate},
|
||||||
|
requirement{"staff", domain.OpCreate},
|
||||||
|
requirement{"user-activity", domain.OpCreate},
|
||||||
|
)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := decodeBody(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, s.log, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := s.workflows.Hire(r.Context(), ident, r.PathValue("id"), body)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, s.log, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.log.Info("candidate hired", "user_id", ident.UserID,
|
||||||
|
"application_id", r.PathValue("id"), "staff_id", result.Staff["id"])
|
||||||
|
|
||||||
|
// 201: the request created a staff record. The application it also updated
|
||||||
|
// is returned alongside so the caller can render the new state without a
|
||||||
|
// second read.
|
||||||
|
writeJSON(w, http.StatusCreated, envelope{Data: result})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAssign places workers on a posting in one transaction. Replaces the 3n
|
||||||
|
// sequential round-trips at krowHooks.js:421/449/466.
|
||||||
|
func (s *Server) handleAssign(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ident, ok := s.authorizeAll(w, r,
|
||||||
|
requirement{"assignments", domain.OpCreate},
|
||||||
|
requirement{"job-applications", domain.OpUpdate},
|
||||||
|
requirement{"user-activity", domain.OpCreate},
|
||||||
|
)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req service.AssignRequest
|
||||||
|
if err := decodeInto(r, &req); err != nil {
|
||||||
|
writeError(w, s.log, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := s.workflows.Assign(r.Context(), ident, r.PathValue("id"), req)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, s.log, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.log.Info("workers assigned", "user_id", ident.UserID,
|
||||||
|
"job_posting_id", r.PathValue("id"), "count", result.Count)
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, envelope{Data: result})
|
||||||
|
}
|
||||||
331
go-api/internal/httpserver/workflows_test.go
Normal file
331
go-api/internal/httpserver/workflows_test.go
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
package httpserver_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The multi-record endpoints from api-contract.md §12.1.
|
||||||
|
//
|
||||||
|
// The property worth testing here is not that the happy path works — it is that
|
||||||
|
// a failure part-way through leaves NOTHING behind. Every rollback test below
|
||||||
|
// counts rows before and after, because "the request returned an error" and
|
||||||
|
// "the request changed nothing" are different claims and only the second one is
|
||||||
|
// what a transaction is for.
|
||||||
|
|
||||||
|
// applicationFor creates an application that can be hired.
|
||||||
|
func applicationFor(t *testing.T, r *rbac, posting, name, email string) string {
|
||||||
|
t.Helper()
|
||||||
|
return mustCreate(t, r, r.admin, "/api/v1/job-applications", map[string]any{
|
||||||
|
"job_posting_id": posting,
|
||||||
|
"applicant_name": name,
|
||||||
|
"email": email,
|
||||||
|
"status": "shortlisted",
|
||||||
|
"ai_score": 77,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func countRows(t *testing.T, r *rbac, table string) int {
|
||||||
|
t.Helper()
|
||||||
|
var n int
|
||||||
|
if err := r.h.Pool.QueryRow(context.Background(),
|
||||||
|
`SELECT count(*) FROM `+table+` WHERE org_id = $1::uuid`, r.orgID).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count %s: %v", table, err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hire ───────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestHireCreatesStaffAndMovesApplication(t *testing.T) {
|
||||||
|
r := newRBAC(t)
|
||||||
|
app := applicationFor(t, r, r.activePosting, "Hire Me", "hire-me@example.test")
|
||||||
|
|
||||||
|
before := countRows(t, r, "staff")
|
||||||
|
got := r.as(r.admin, "POST", "/api/v1/job-applications/"+app+"/hire", map[string]any{
|
||||||
|
"role": "Event Server", "profile_tier": "Skilled",
|
||||||
|
})
|
||||||
|
if got.code != http.StatusCreated {
|
||||||
|
t.Fatalf("hire: got %d, want 201 (%v)", got.code, got.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := got.body["data"].(map[string]any)
|
||||||
|
application, _ := data["application"].(map[string]any)
|
||||||
|
staff, _ := data["staff"].(map[string]any)
|
||||||
|
if application == nil || staff == nil {
|
||||||
|
t.Fatalf("hire response is missing application or staff: %v", got.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if application["status"] != "hired" {
|
||||||
|
t.Errorf("application.status = %v, want hired", application["status"])
|
||||||
|
}
|
||||||
|
if staff["name"] != "Hire Me" {
|
||||||
|
t.Errorf("staff.name = %v, want the applicant's name", staff["name"])
|
||||||
|
}
|
||||||
|
if staff["email"] != "hire-me@example.test" {
|
||||||
|
t.Errorf("staff.email = %v, want the application's email", staff["email"])
|
||||||
|
}
|
||||||
|
// The caller's overrides win over the derived defaults.
|
||||||
|
if staff["role"] != "Event Server" {
|
||||||
|
t.Errorf("staff.role = %v, want the supplied role", staff["role"])
|
||||||
|
}
|
||||||
|
if staff["profile_tier"] != "Skilled" {
|
||||||
|
t.Errorf("staff.profile_tier = %v, want the supplied tier", staff["profile_tier"])
|
||||||
|
}
|
||||||
|
// ai_score is carried across from the application. It is an `int` column,
|
||||||
|
// so it arrives from pgx as int32 — the case repo.bindValue did not handle
|
||||||
|
// until this endpoint existed to read a record and write it elsewhere.
|
||||||
|
if score, ok := staff["ai_score"].(float64); !ok || int(score) != 77 {
|
||||||
|
t.Errorf("staff.ai_score = %v, want 77 carried from the application", staff["ai_score"])
|
||||||
|
}
|
||||||
|
if staff["application_id"] != app {
|
||||||
|
t.Errorf("staff.application_id = %v, want %s", staff["application_id"], app)
|
||||||
|
}
|
||||||
|
if after := countRows(t, r, "staff"); after != before+1 {
|
||||||
|
t.Errorf("staff rows: %d -> %d, want exactly one more", before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hiring the same application twice would create a second employment record for
|
||||||
|
// one person, so the second attempt is a conflict rather than a repeat.
|
||||||
|
func TestHireIsNotRepeatable(t *testing.T) {
|
||||||
|
r := newRBAC(t)
|
||||||
|
app := applicationFor(t, r, r.activePosting, "Twice", "twice@example.test")
|
||||||
|
|
||||||
|
if got := r.as(r.admin, "POST", "/api/v1/job-applications/"+app+"/hire", map[string]any{}); got.code != http.StatusCreated {
|
||||||
|
t.Fatalf("first hire: got %d, want 201 (%v)", got.code, got.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
before := countRows(t, r, "staff")
|
||||||
|
got := r.as(r.admin, "POST", "/api/v1/job-applications/"+app+"/hire", map[string]any{})
|
||||||
|
if got.code != http.StatusConflict {
|
||||||
|
t.Fatalf("second hire: got %d, want 409 (%v)", got.code, got.body)
|
||||||
|
}
|
||||||
|
if after := countRows(t, r, "staff"); after != before {
|
||||||
|
t.Errorf("a refused hire still wrote a staff row: %d -> %d", before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole point of the endpoint: the two writes succeed together or not at
|
||||||
|
// all. A staff insert that violates a constraint must leave the application
|
||||||
|
// untouched, not merely report an error.
|
||||||
|
func TestHireRollsBackTheApplicationWhenStaffFails(t *testing.T) {
|
||||||
|
r := newRBAC(t)
|
||||||
|
app := applicationFor(t, r, r.activePosting, "Rollback", "rollback@example.test")
|
||||||
|
|
||||||
|
staffBefore := countRows(t, r, "staff")
|
||||||
|
|
||||||
|
// profile_tier is a native enum; a value outside it fails the staff INSERT
|
||||||
|
// after the application UPDATE has already been issued in this transaction.
|
||||||
|
got := r.as(r.admin, "POST", "/api/v1/job-applications/"+app+"/hire", map[string]any{
|
||||||
|
"profile_tier": "NotARealTier",
|
||||||
|
})
|
||||||
|
if got.code == http.StatusCreated {
|
||||||
|
t.Fatalf("an invalid profile_tier was accepted: %v", got.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if after := countRows(t, r, "staff"); after != staffBefore {
|
||||||
|
t.Errorf("staff rows changed despite a failed hire: %d -> %d", staffBefore, after)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The decisive assertion: the application must NOT be hired.
|
||||||
|
reread := r.as(r.admin, "GET", "/api/v1/job-applications?limit=500", nil)
|
||||||
|
if reread.code != http.StatusOK {
|
||||||
|
t.Fatalf("re-read applications: %d", reread.code)
|
||||||
|
}
|
||||||
|
for _, raw := range reread.body["data"].([]any) {
|
||||||
|
rec := raw.(map[string]any)
|
||||||
|
if rec["id"] == app && rec["status"] == "hired" {
|
||||||
|
t.Fatal("the application was left hired after the staff insert failed — " +
|
||||||
|
"the two writes are not in one transaction")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hiring is an operator action. A talent user must not be able to hire anyone,
|
||||||
|
// including themselves.
|
||||||
|
func TestHireIsRefusedToTalent(t *testing.T) {
|
||||||
|
r := newRBAC(t)
|
||||||
|
app := applicationFor(t, r, r.activePosting, "Self", r.talA.email)
|
||||||
|
|
||||||
|
before := countRows(t, r, "staff")
|
||||||
|
got := r.as(r.talA, "POST", "/api/v1/job-applications/"+app+"/hire", map[string]any{})
|
||||||
|
if got.code != http.StatusForbidden {
|
||||||
|
t.Fatalf("talent hire: got %d, want 403 (%v)", got.code, got.body)
|
||||||
|
}
|
||||||
|
if after := countRows(t, r, "staff"); after != before {
|
||||||
|
t.Errorf("a refused hire still wrote a staff row: %d -> %d", before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHireRejectsUnknownApplication(t *testing.T) {
|
||||||
|
r := newRBAC(t)
|
||||||
|
got := r.as(r.admin, "POST",
|
||||||
|
"/api/v1/job-applications/00000000-0000-0000-0000-000000000000/hire", map[string]any{})
|
||||||
|
if got.code != http.StatusNotFound {
|
||||||
|
t.Fatalf("hire unknown application: got %d, want 404 (%v)", got.code, got.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Another organization's application is absent, not forbidden — the same 404 a
|
||||||
|
// nonexistent id gets, so existence does not leak across tenants.
|
||||||
|
func TestHireCannotReachAnotherOrganization(t *testing.T) {
|
||||||
|
r := newRBAC(t)
|
||||||
|
app := applicationFor(t, r, r.activePosting, "Ours", "ours@example.test")
|
||||||
|
|
||||||
|
got := r.as(r.outsider, "POST", "/api/v1/job-applications/"+app+"/hire", map[string]any{})
|
||||||
|
if got.code != http.StatusNotFound {
|
||||||
|
t.Fatalf("cross-tenant hire: got %d, want 404 (%v)", got.code, got.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Assign ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestAssignPlacesWorkersAndUpdatesApplications(t *testing.T) {
|
||||||
|
r := newRBAC(t)
|
||||||
|
a1 := applicationFor(t, r, r.activePosting, "Worker One", "w1@example.test")
|
||||||
|
a2 := applicationFor(t, r, r.activePosting, "Worker Two", "w2@example.test")
|
||||||
|
|
||||||
|
before := countRows(t, r, "assignments")
|
||||||
|
got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments", map[string]any{
|
||||||
|
"workers": []map[string]any{
|
||||||
|
{"worker_email": "w1@example.test", "worker_name": "Worker One",
|
||||||
|
"starts_at": "2026-09-01T09:00:00Z", "application_id": a1, "match_score": 91},
|
||||||
|
{"worker_email": "w2@example.test", "worker_name": "Worker Two",
|
||||||
|
"starts_at": "2026-09-01T09:00:00Z", "application_id": a2},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if got.code != http.StatusCreated {
|
||||||
|
t.Fatalf("assign: got %d, want 201 (%v)", got.code, got.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := got.body["data"].(map[string]any)
|
||||||
|
if count, ok := data["count"].(float64); !ok || int(count) != 2 {
|
||||||
|
t.Errorf("count = %v, want 2", data["count"])
|
||||||
|
}
|
||||||
|
if after := countRows(t, r, "assignments"); after != before+2 {
|
||||||
|
t.Errorf("assignment rows: %d -> %d, want two more", before, after)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both applications must now read as assigned.
|
||||||
|
list := r.as(r.admin, "GET", "/api/v1/job-applications?status=assigned", nil)
|
||||||
|
assigned := map[string]bool{}
|
||||||
|
for _, raw := range list.body["data"].([]any) {
|
||||||
|
assigned[raw.(map[string]any)["id"].(string)] = true
|
||||||
|
}
|
||||||
|
if !assigned[a1] || !assigned[a2] {
|
||||||
|
t.Errorf("applications were not moved to assigned: a1=%v a2=%v", assigned[a1], assigned[a2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A worker with no application is legitimate — that is what the talent pool is
|
||||||
|
// for — and must not be invented one.
|
||||||
|
func TestAssignAcceptsWorkerWithoutApplication(t *testing.T) {
|
||||||
|
r := newRBAC(t)
|
||||||
|
got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments", map[string]any{
|
||||||
|
"workers": []map[string]any{
|
||||||
|
{"worker_email": "pool@example.test", "worker_name": "Pool Worker",
|
||||||
|
"starts_at": "2026-09-01T09:00:00Z"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if got.code != http.StatusCreated {
|
||||||
|
t.Fatalf("assign without application: got %d, want 201 (%v)", got.code, got.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The batch is all-or-nothing. A bad reference on the SECOND worker must undo
|
||||||
|
// the first worker's assignment, not leave it stranded.
|
||||||
|
func TestAssignRollsBackTheWholeBatch(t *testing.T) {
|
||||||
|
r := newRBAC(t)
|
||||||
|
before := countRows(t, r, "assignments")
|
||||||
|
|
||||||
|
got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments", map[string]any{
|
||||||
|
"workers": []map[string]any{
|
||||||
|
{"worker_email": "first@example.test", "worker_name": "First",
|
||||||
|
"starts_at": "2026-09-01T09:00:00Z"},
|
||||||
|
{"worker_email": "second@example.test", "worker_name": "Second",
|
||||||
|
"starts_at": "2026-09-01T09:00:00Z",
|
||||||
|
"application_id": "00000000-0000-0000-0000-000000000000"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if got.code == http.StatusCreated {
|
||||||
|
t.Fatalf("a batch naming a nonexistent application was accepted: %v", got.body)
|
||||||
|
}
|
||||||
|
if after := countRows(t, r, "assignments"); after != before {
|
||||||
|
t.Fatalf("the first worker survived the second's failure: %d -> %d — "+
|
||||||
|
"the batch is not one transaction", before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssignIsRefusedToTalent(t *testing.T) {
|
||||||
|
r := newRBAC(t)
|
||||||
|
before := countRows(t, r, "assignments")
|
||||||
|
|
||||||
|
got := r.as(r.talA, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments", map[string]any{
|
||||||
|
"workers": []map[string]any{
|
||||||
|
{"worker_email": r.talA.email, "worker_name": "Self",
|
||||||
|
"starts_at": "2026-09-01T09:00:00Z"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if got.code != http.StatusForbidden {
|
||||||
|
t.Fatalf("talent assign: got %d, want 403 (%v)", got.code, got.body)
|
||||||
|
}
|
||||||
|
if after := countRows(t, r, "assignments"); after != before {
|
||||||
|
t.Errorf("a refused assign still wrote a row: %d -> %d", before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssignValidatesTheBatchBeforeWriting(t *testing.T) {
|
||||||
|
r := newRBAC(t)
|
||||||
|
before := countRows(t, r, "assignments")
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
body map[string]any
|
||||||
|
}{
|
||||||
|
{"no workers", map[string]any{"workers": []map[string]any{}}},
|
||||||
|
{"missing email", map[string]any{"workers": []map[string]any{
|
||||||
|
{"worker_name": "No Email", "starts_at": "2026-09-01T09:00:00Z"}}}},
|
||||||
|
{"missing starts_at", map[string]any{"workers": []map[string]any{
|
||||||
|
{"worker_email": "x@example.test", "worker_name": "No Start"}}}},
|
||||||
|
{"malformed application_id", map[string]any{"workers": []map[string]any{
|
||||||
|
{"worker_email": "x@example.test", "starts_at": "2026-09-01T09:00:00Z",
|
||||||
|
"application_id": "not-a-uuid"}}}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments", tc.body)
|
||||||
|
if got.code != http.StatusUnprocessableEntity {
|
||||||
|
t.Errorf("%s: got %d, want 422 (%v)", tc.name, got.code, got.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if after := countRows(t, r, "assignments"); after != before {
|
||||||
|
t.Errorf("a rejected batch wrote rows: %d -> %d", before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssignRejectsUnknownPosting(t *testing.T) {
|
||||||
|
r := newRBAC(t)
|
||||||
|
got := r.as(r.admin, "POST",
|
||||||
|
"/api/v1/job-postings/00000000-0000-0000-0000-000000000000/assignments", map[string]any{
|
||||||
|
"workers": []map[string]any{
|
||||||
|
{"worker_email": "x@example.test", "starts_at": "2026-09-01T09:00:00Z"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if got.code != http.StatusNotFound {
|
||||||
|
t.Fatalf("assign to unknown posting: got %d, want 404 (%v)", got.code, got.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both endpoints are behind the session like everything else.
|
||||||
|
func TestWorkflowEndpointsRequireASession(t *testing.T) {
|
||||||
|
r := newRBAC(t)
|
||||||
|
for _, path := range []string{
|
||||||
|
"/api/v1/job-applications/00000000-0000-0000-0000-000000000000/hire",
|
||||||
|
"/api/v1/job-postings/00000000-0000-0000-0000-000000000000/assignments",
|
||||||
|
} {
|
||||||
|
if got := r.doAnon("POST", path, map[string]any{}); got.code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("%s unauthenticated: got %d, want 401", path, got.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -572,6 +572,13 @@ func bindValue(c domain.Column, v any) (any, error) {
|
|||||||
return raw, nil
|
return raw, nil
|
||||||
|
|
||||||
case domain.KindInt:
|
case domain.KindInt:
|
||||||
|
// The int8/16/32 cases are not JSON shapes — encoding/json only ever
|
||||||
|
// produces float64. They are the widths pgx hands back when a value was
|
||||||
|
// READ from the database and is being written somewhere else: an `int`
|
||||||
|
// column arrives as int32, and a service that copies a field from one
|
||||||
|
// record to another (see service.Hire, which carries ai_score from an
|
||||||
|
// application onto the staff row) would otherwise be told its own
|
||||||
|
// database's value "must be a number".
|
||||||
switch t := v.(type) {
|
switch t := v.(type) {
|
||||||
case float64:
|
case float64:
|
||||||
if t != float64(int64(t)) {
|
if t != float64(int64(t)) {
|
||||||
@@ -592,19 +599,45 @@ func bindValue(c domain.Column, v any) (any, error) {
|
|||||||
return t, nil
|
return t, nil
|
||||||
case int:
|
case int:
|
||||||
return int64(t), nil
|
return int64(t), nil
|
||||||
|
case int32:
|
||||||
|
return int64(t), nil
|
||||||
|
case int16:
|
||||||
|
return int64(t), nil
|
||||||
|
case int8:
|
||||||
|
return int64(t), nil
|
||||||
|
case float32:
|
||||||
|
if t != float32(int64(t)) {
|
||||||
|
return nil, domain.Validation(
|
||||||
|
fmt.Sprintf("%s must be a whole number", c.Name),
|
||||||
|
map[string]string{c.Name: "expected an integer"})
|
||||||
|
}
|
||||||
|
return int64(t), nil
|
||||||
}
|
}
|
||||||
return nil, domain.Validation(fmt.Sprintf("%s must be a number", c.Name), nil)
|
return nil, domain.Validation(fmt.Sprintf("%s must be a number", c.Name), nil)
|
||||||
|
|
||||||
case domain.KindFloat:
|
case domain.KindFloat:
|
||||||
|
// float32 and the integer widths for the same reason as above: a
|
||||||
|
// numeric column is projected as float8 and returns float64, but an
|
||||||
|
// integer read from elsewhere may legitimately be written into one.
|
||||||
switch t := v.(type) {
|
switch t := v.(type) {
|
||||||
case float64:
|
case float64:
|
||||||
return t, nil
|
return t, nil
|
||||||
|
case float32:
|
||||||
|
return float64(t), nil
|
||||||
case string:
|
case string:
|
||||||
f, err := strconv.ParseFloat(t, 64)
|
f, err := strconv.ParseFloat(t, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, domain.Validation(fmt.Sprintf("%s must be a number", c.Name), nil)
|
return nil, domain.Validation(fmt.Sprintf("%s must be a number", c.Name), nil)
|
||||||
}
|
}
|
||||||
return f, nil
|
return f, nil
|
||||||
|
case int64:
|
||||||
|
return float64(t), nil
|
||||||
|
case int:
|
||||||
|
return float64(t), nil
|
||||||
|
case int32:
|
||||||
|
return float64(t), nil
|
||||||
|
case int16:
|
||||||
|
return float64(t), nil
|
||||||
}
|
}
|
||||||
return nil, domain.Validation(fmt.Sprintf("%s must be a number", c.Name), nil)
|
return nil, domain.Validation(fmt.Sprintf("%s must be a number", c.Name), nil)
|
||||||
|
|
||||||
|
|||||||
443
go-api/internal/service/workflows.go
Normal file
443
go-api/internal/service/workflows.go
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
// Multi-record writes, in one transaction each.
|
||||||
|
//
|
||||||
|
// api-contract.md §12.1 lists four flows that the frontend performs as a
|
||||||
|
// sequence of independent HTTP calls, with no transaction and no rollback:
|
||||||
|
// hiring a candidate, assigning workers to a posting, screening a whole list,
|
||||||
|
// and submitting a challenge. A failure halfway through leaves the database
|
||||||
|
// inconsistent — an application marked hired with no staff row, or an
|
||||||
|
// assignment with an application still showing as merely shortlisted.
|
||||||
|
//
|
||||||
|
// This file collapses the first two into one endpoint and one transaction
|
||||||
|
// each, which is what §12.1 says Phase 3 should do. The other two are not here:
|
||||||
|
// `useScreenAllCandidates` is n independent PATCHes that are individually
|
||||||
|
// meaningful (a partial screen is not a corrupt state), and `useSubmitChallenge`
|
||||||
|
// writes evidence the talent user owns, which needs the talent-scoped predicate
|
||||||
|
// and is a different shape of problem.
|
||||||
|
//
|
||||||
|
// WHY THE REPOSITORY IS REUSED RATHER THAN HAND-WRITTEN SQL
|
||||||
|
//
|
||||||
|
// Every write below goes through repo.Repo, built over the transaction rather
|
||||||
|
// than the pool. That is deliberate: the repository is where org_id is forced
|
||||||
|
// from the session, where server-derived columns are filled, where values are
|
||||||
|
// bound and cast to their declared types, and where a constraint violation is
|
||||||
|
// translated into the contract's error codes. Writing raw SQL here would mean
|
||||||
|
// re-deriving all four, and getting one of them subtly wrong.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"github.com/krow/krow-backend/go-api/internal/authctx"
|
||||||
|
"github.com/krow/krow-backend/go-api/internal/domain"
|
||||||
|
"github.com/krow/krow-backend/go-api/internal/repo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxAssignmentBatch bounds one assign call.
|
||||||
|
//
|
||||||
|
// `useAssignWorkers` assigns a selection from the talent pool, which is a
|
||||||
|
// human-sized list. The cap exists so a single request cannot hold a
|
||||||
|
// transaction open across thousands of inserts, blocking every other write to
|
||||||
|
// these tables for the duration.
|
||||||
|
const maxAssignmentBatch = 200
|
||||||
|
|
||||||
|
// TxBeginner is the part of the pool this file needs. An interface rather than
|
||||||
|
// *pgxpool.Pool so the service stays testable and consistent with repo.Querier.
|
||||||
|
type TxBeginner interface {
|
||||||
|
Begin(ctx context.Context) (pgx.Tx, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkflowService owns the multi-record flows.
|
||||||
|
type WorkflowService struct {
|
||||||
|
db TxBeginner
|
||||||
|
// now is injectable so tests can assert on generated dates without
|
||||||
|
// depending on the day they run.
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWorkflows builds the workflow service over a pool.
|
||||||
|
func NewWorkflows(db TxBeginner) *WorkflowService {
|
||||||
|
return &WorkflowService{db: db, now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithClock replaces the clock. For tests.
|
||||||
|
func (s *WorkflowService) WithClock(now func() time.Time) *WorkflowService {
|
||||||
|
if now != nil {
|
||||||
|
s.now = now
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// inTx runs fn inside a transaction, rolling back on any error.
|
||||||
|
//
|
||||||
|
// The rollback is deferred rather than called on each error path: an early
|
||||||
|
// return, a panic in a callee, and an explicit failure all have to undo the
|
||||||
|
// work, and only a deferred rollback covers the second. Rolling back an
|
||||||
|
// already-committed transaction is a no-op in pgx, so the deferred call is safe
|
||||||
|
// on the success path too.
|
||||||
|
func (s *WorkflowService) inTx(ctx context.Context, fn func(tx pgx.Tx) error) error {
|
||||||
|
tx, err := s.db.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
|
if err := fn(tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resourceByPath(path string) (*domain.Resource, error) {
|
||||||
|
res, ok := domain.ResourceByPath[path]
|
||||||
|
if !ok {
|
||||||
|
return nil, domain.Internal(fmt.Errorf("service: resource %q is not registered", path))
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hire ───────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
// HireResult is what a completed hire returns: both records the flow touched,
|
||||||
|
// so the caller does not need a follow-up read to render the outcome.
|
||||||
|
type HireResult struct {
|
||||||
|
Application domain.Record `json:"application"`
|
||||||
|
Staff domain.Record `json:"staff"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hire moves an application to `hired` and creates the staff record, atomically.
|
||||||
|
//
|
||||||
|
// Replaces the two-call sequence at krowHooks.js:302-303. The failure that
|
||||||
|
// motivated it: the PATCH succeeds, the POST fails, and the candidate is now
|
||||||
|
// hired with no employment record and no way for the UI to notice.
|
||||||
|
//
|
||||||
|
// Fields the caller may supply are the ones a hiring form collects — hire_date,
|
||||||
|
// role, phone, profile_tier, status, reviewer_name. Everything else is carried
|
||||||
|
// across from the application, because it is already the truth about this
|
||||||
|
// person and retyping it is how the two records drift apart.
|
||||||
|
func (s *WorkflowService) Hire(ctx context.Context, ident authctx.Identity,
|
||||||
|
applicationID string, body domain.Record) (*HireResult, error) {
|
||||||
|
|
||||||
|
if !isUUID(applicationID) {
|
||||||
|
return nil, domain.NotFound("JobApplication", applicationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
apps, err := resourceByPath("job-applications")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
staffRes, err := resourceByPath("staff")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
activity, err := resourceByPath("user-activity")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var out HireResult
|
||||||
|
err = s.inTx(ctx, func(tx pgx.Tx) error {
|
||||||
|
appRepo := repo.New(apps, tx)
|
||||||
|
|
||||||
|
// Read inside the transaction. Reading outside it would leave a window
|
||||||
|
// where two concurrent hires both see a not-yet-hired application.
|
||||||
|
app, err := appRepo.Get(ctx, ident, applicationID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if app == nil {
|
||||||
|
return domain.NotFound("JobApplication", applicationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hiring twice is a conflict, not an idempotent repeat: the second call
|
||||||
|
// would create a second employment record for one application. 409
|
||||||
|
// rather than 200 because the caller asked for something that cannot be
|
||||||
|
// done, and silently returning the first hire would hide a double
|
||||||
|
// submission rather than report it.
|
||||||
|
if status, _ := app["status"].(string); status == "hired" {
|
||||||
|
return domain.Conflict("this application has already been hired")
|
||||||
|
}
|
||||||
|
|
||||||
|
email, _ := app["email"].(string)
|
||||||
|
name, _ := app["applicant_name"].(string)
|
||||||
|
if email == "" || name == "" {
|
||||||
|
return domain.Validation(
|
||||||
|
"this application cannot be hired: it has no applicant name or email",
|
||||||
|
map[string]string{"application": "incomplete"})
|
||||||
|
}
|
||||||
|
|
||||||
|
staffRecord := domain.Record{
|
||||||
|
"application_id": applicationID,
|
||||||
|
"job_posting_id": app["job_posting_id"],
|
||||||
|
"worker_profile_id": app["worker_profile_id"],
|
||||||
|
"name": name,
|
||||||
|
"email": email,
|
||||||
|
"phone": pick(body, "phone", app["phone"]),
|
||||||
|
"role": pick(body, "role", app["job_title"]),
|
||||||
|
"hire_date": pick(body, "hire_date", s.now().UTC().Format("2006-01-02")),
|
||||||
|
"ai_score": app["ai_score"],
|
||||||
|
"profile_tier": pick(body, "profile_tier", "Beginner"),
|
||||||
|
"status": pick(body, "status", "onboarding"),
|
||||||
|
"client_rating": app["client_rating"],
|
||||||
|
"reviewer_name": pick(body, "reviewer_name", ""),
|
||||||
|
}
|
||||||
|
// A null worker_profile_id is legitimate — not every applicant has a
|
||||||
|
// profile — but the column list must not carry an explicit nil for a
|
||||||
|
// NOT NULL column, so drop the keys the application had nothing for.
|
||||||
|
dropNil(staffRecord, "worker_profile_id", "job_posting_id")
|
||||||
|
|
||||||
|
hired, err := appRepo.Update(ctx, ident, applicationID, domain.Record{"status": "hired"})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if hired == nil {
|
||||||
|
// Unreachable: Get above found it under the same predicate.
|
||||||
|
return domain.NotFound("JobApplication", applicationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := repo.New(staffRes, tx).Insert(ctx, ident, staffRecord)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// The audit entry is part of the transaction on purpose: a hire that
|
||||||
|
// happened without a log entry, or a log entry for a hire that rolled
|
||||||
|
// back, are both worse than neither.
|
||||||
|
if _, err := repo.New(activity, tx).Insert(ctx, ident, domain.Record{
|
||||||
|
"event_type": "candidate_hired",
|
||||||
|
"details": fmt.Sprintf("%s was hired", name),
|
||||||
|
"application_id": applicationID,
|
||||||
|
"position_id": app["job_posting_id"],
|
||||||
|
"worker_email": email,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
out.Application, out.Staff = hired, created
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Assign ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
// AssignWorker is one worker in an assign request.
|
||||||
|
type AssignWorker struct {
|
||||||
|
WorkerEmail string `json:"worker_email"`
|
||||||
|
WorkerName string `json:"worker_name"`
|
||||||
|
StartsAt string `json:"starts_at"`
|
||||||
|
EndsAt *string `json:"ends_at"`
|
||||||
|
ApplicationID *string `json:"application_id"`
|
||||||
|
WorkerProfileID *string `json:"worker_profile_id"`
|
||||||
|
MatchScore *int `json:"match_score"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssignRequest is the body of POST /job-postings/{id}/assignments.
|
||||||
|
type AssignRequest struct {
|
||||||
|
Workers []AssignWorker `json:"workers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssignResult reports what the batch created.
|
||||||
|
type AssignResult struct {
|
||||||
|
Assignments []domain.Record `json:"assignments"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign places workers on a posting, atomically.
|
||||||
|
//
|
||||||
|
// Replaces the 3n sequential round-trips at krowHooks.js:421/449/466 with one
|
||||||
|
// request and one transaction. All-or-nothing across the whole batch: assigning
|
||||||
|
// six workers and having the fourth fail should not leave three assigned, three
|
||||||
|
// not, and the caller unsure which.
|
||||||
|
func (s *WorkflowService) Assign(ctx context.Context, ident authctx.Identity,
|
||||||
|
postingID string, req AssignRequest) (*AssignResult, error) {
|
||||||
|
|
||||||
|
if !isUUID(postingID) {
|
||||||
|
return nil, domain.NotFound("JobPosting", postingID)
|
||||||
|
}
|
||||||
|
if len(req.Workers) == 0 {
|
||||||
|
return nil, domain.Validation("at least one worker is required",
|
||||||
|
map[string]string{"workers": "must not be empty"})
|
||||||
|
}
|
||||||
|
if len(req.Workers) > maxAssignmentBatch {
|
||||||
|
return nil, domain.Validation(
|
||||||
|
fmt.Sprintf("at most %d workers can be assigned in one request", maxAssignmentBatch),
|
||||||
|
map[string]string{"workers": "too many"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the whole batch before opening a transaction. A malformed
|
||||||
|
// request should never have caused a BEGIN.
|
||||||
|
details := map[string]string{}
|
||||||
|
for i, w := range req.Workers {
|
||||||
|
if w.WorkerEmail == "" {
|
||||||
|
details[fmt.Sprintf("workers[%d].worker_email", i)] = "required"
|
||||||
|
}
|
||||||
|
if w.StartsAt == "" {
|
||||||
|
details[fmt.Sprintf("workers[%d].starts_at", i)] = "required"
|
||||||
|
}
|
||||||
|
if w.ApplicationID != nil && !isUUID(*w.ApplicationID) {
|
||||||
|
details[fmt.Sprintf("workers[%d].application_id", i)] = "must be a uuid"
|
||||||
|
}
|
||||||
|
if w.WorkerProfileID != nil && !isUUID(*w.WorkerProfileID) {
|
||||||
|
details[fmt.Sprintf("workers[%d].worker_profile_id", i)] = "must be a uuid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(details) > 0 {
|
||||||
|
return nil, domain.Validation("assignment payload is not valid", details)
|
||||||
|
}
|
||||||
|
|
||||||
|
postings, err := resourceByPath("job-postings")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
assignments, err := resourceByPath("assignments")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
apps, err := resourceByPath("job-applications")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
activity, err := resourceByPath("user-activity")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := &AssignResult{Assignments: []domain.Record{}}
|
||||||
|
err = s.inTx(ctx, func(tx pgx.Tx) error {
|
||||||
|
posting, err := repo.New(postings, tx).Get(ctx, ident, postingID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if posting == nil {
|
||||||
|
return domain.NotFound("JobPosting", postingID)
|
||||||
|
}
|
||||||
|
|
||||||
|
assignRepo := repo.New(assignments, tx)
|
||||||
|
appRepo := repo.New(apps, tx)
|
||||||
|
activityRepo := repo.New(activity, tx)
|
||||||
|
|
||||||
|
for i, w := range req.Workers {
|
||||||
|
record := domain.Record{
|
||||||
|
"job_posting_id": postingID,
|
||||||
|
"worker_email": w.WorkerEmail,
|
||||||
|
"worker_name": w.WorkerName,
|
||||||
|
"starts_at": w.StartsAt,
|
||||||
|
"status": "active",
|
||||||
|
"source": orDefault(w.Source, "manual"),
|
||||||
|
}
|
||||||
|
if w.EndsAt != nil {
|
||||||
|
record["ends_at"] = *w.EndsAt
|
||||||
|
}
|
||||||
|
if w.ApplicationID != nil {
|
||||||
|
record["application_id"] = *w.ApplicationID
|
||||||
|
}
|
||||||
|
if w.WorkerProfileID != nil {
|
||||||
|
record["worker_profile_id"] = *w.WorkerProfileID
|
||||||
|
}
|
||||||
|
if w.MatchScore != nil {
|
||||||
|
record["match_score"] = *w.MatchScore
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := assignRepo.Insert(ctx, ident, record)
|
||||||
|
if err != nil {
|
||||||
|
return annotate(err, i)
|
||||||
|
}
|
||||||
|
out.Assignments = append(out.Assignments, created)
|
||||||
|
|
||||||
|
// The application moves to `assigned` only when one was named. A
|
||||||
|
// worker can be placed without having applied — that is what the
|
||||||
|
// talent pool is for — and inventing an application for them would
|
||||||
|
// be worse than leaving the link absent.
|
||||||
|
if w.ApplicationID != nil {
|
||||||
|
updated, err := appRepo.Update(ctx, ident, *w.ApplicationID,
|
||||||
|
domain.Record{"status": "assigned"})
|
||||||
|
if err != nil {
|
||||||
|
return annotate(err, i)
|
||||||
|
}
|
||||||
|
if updated == nil {
|
||||||
|
return domain.NotFound("JobApplication", *w.ApplicationID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := domain.Record{
|
||||||
|
"event_type": "worker_assigned",
|
||||||
|
"details": fmt.Sprintf("%s was assigned", orDefault(w.WorkerName, w.WorkerEmail)),
|
||||||
|
"position_id": postingID,
|
||||||
|
"worker_email": w.WorkerEmail,
|
||||||
|
}
|
||||||
|
if w.ApplicationID != nil {
|
||||||
|
entry["application_id"] = *w.ApplicationID
|
||||||
|
}
|
||||||
|
if _, err := activityRepo.Insert(ctx, ident, entry); err != nil {
|
||||||
|
return annotate(err, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out.Count = len(out.Assignments)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Helpers ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
// pick takes the caller's value for a key when they supplied a usable one, and
|
||||||
|
// the fallback otherwise. A present-but-null key means "use the fallback"
|
||||||
|
// rather than "write null", because these columns are NOT NULL.
|
||||||
|
func pick(body domain.Record, key string, fallback any) any {
|
||||||
|
if body == nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
v, ok := body[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
if s, isStr := v.(string); isStr && s == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropNil removes keys whose value is nil, so a NOT NULL column is left to its
|
||||||
|
// default instead of being sent an explicit null.
|
||||||
|
func dropNil(rec domain.Record, keys ...string) {
|
||||||
|
for _, k := range keys {
|
||||||
|
if v, ok := rec[k]; ok && v == nil {
|
||||||
|
delete(rec, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func orDefault(v, fallback string) string {
|
||||||
|
if v == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// annotate points a validation error at the batch element that produced it.
|
||||||
|
// Without this, "worker_email must not be null" on a batch of forty says
|
||||||
|
// nothing about which one.
|
||||||
|
func annotate(err error, index int) error {
|
||||||
|
var apiErr *domain.Error
|
||||||
|
if !errors.As(err, &apiErr) || apiErr.Details == nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
moved := make(map[string]string, len(apiErr.Details))
|
||||||
|
for k, v := range apiErr.Details {
|
||||||
|
moved[fmt.Sprintf("workers[%d].%s", index, k)] = v
|
||||||
|
}
|
||||||
|
return domain.Validation(apiErr.Message, moved)
|
||||||
|
}
|
||||||
95
infrastructure/.env.docker.example
Normal file
95
infrastructure/.env.docker.example
Normal 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
|
||||||
155
infrastructure/Dockerfile.api
Normal file
155
infrastructure/Dockerfile.api
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
# ============================================================================
|
||||||
|
# Krow API — production image
|
||||||
|
#
|
||||||
|
# Build from the REPOSITORY ROOT, not from this directory: the build needs
|
||||||
|
# go-api/ and seed/, and a Dockerfile can never reach above its context.
|
||||||
|
#
|
||||||
|
# docker build -f infrastructure/Dockerfile.api -t krow-api:latest .
|
||||||
|
#
|
||||||
|
# Three binaries ship in the image, because all three are things an operator
|
||||||
|
# needs against a running deployment and none of them justify a second image:
|
||||||
|
#
|
||||||
|
# /usr/local/bin/api the HTTP service (the default command)
|
||||||
|
# /usr/local/bin/seed loads the demo fixture
|
||||||
|
# /usr/local/bin/setpassword sets a user's password — without it a fresh
|
||||||
|
# database has no one who can sign in
|
||||||
|
#
|
||||||
|
# Migrations are deliberately NOT run by this image. They are a discrete deploy
|
||||||
|
# step against the target database BEFORE the new binary rolls out, which is
|
||||||
|
# what makes expand/migrate/contract possible: see README.md "Staging and
|
||||||
|
# production". docker-compose.yml runs them as their own one-shot service.
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# ── Build ───────────────────────────────────────────────────────────────────
|
||||||
|
# --platform=$BUILDPLATFORM pins this stage to the MACHINE DOING THE BUILDING,
|
||||||
|
# not the machine that will run the image. Combined with GOARCH below, that
|
||||||
|
# turns a cross-platform build into cross-COMPILATION rather than emulation.
|
||||||
|
#
|
||||||
|
# It matters a lot in practice: building linux/amd64 from an Apple Silicon Mac
|
||||||
|
# without this runs the entire Go toolchain under QEMU, which is roughly an
|
||||||
|
# order of magnitude slower. Every binary here is CGO_ENABLED=0 and therefore
|
||||||
|
# pure Go, so the Go compiler can target another architecture directly and there
|
||||||
|
# is nothing emulation would buy.
|
||||||
|
FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS build
|
||||||
|
|
||||||
|
# Supplied automatically by buildx from --platform. Declared here so the build
|
||||||
|
# stage can read them; TARGETARCH is "amd64", "arm64", etc.
|
||||||
|
ARG TARGETOS=linux
|
||||||
|
ARG TARGETARCH
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Dependencies first, in their own layer. go.mod and go.sum change far less
|
||||||
|
# often than source, so an edit to a handler does not re-download the module
|
||||||
|
# graph.
|
||||||
|
COPY go-api/go.mod go-api/go.sum ./
|
||||||
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||||
|
go mod download
|
||||||
|
|
||||||
|
COPY go-api/ ./
|
||||||
|
|
||||||
|
# CGO_ENABLED=0 produces a static binary, which is what lets the final stage be
|
||||||
|
# a near-empty image with no libc to keep patched.
|
||||||
|
# -trimpath keeps build-machine paths out of panics and binaries
|
||||||
|
# -s -w drops the symbol table and DWARF; roughly a third off the size
|
||||||
|
ARG VERSION=dev
|
||||||
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||||
|
--mount=type=cache,target=/root/.cache/go-build \
|
||||||
|
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
|
||||||
|
-trimpath -ldflags="-s -w" \
|
||||||
|
-o /out/api ./cmd/api && \
|
||||||
|
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
|
||||||
|
-trimpath -ldflags="-s -w" -o /out/seed ./cmd/seed && \
|
||||||
|
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
|
||||||
|
-trimpath -ldflags="-s -w" -o /out/setpassword ./cmd/setpassword
|
||||||
|
|
||||||
|
# The golang-migrate CLI, built here rather than pulled as a second image.
|
||||||
|
#
|
||||||
|
# `go install pkg@version` resolves in its own module context, so this does NOT
|
||||||
|
# touch go.mod or go.sum. Having it in the image means the Kubernetes manifests
|
||||||
|
# can run migrations from an initContainer using the SAME image and tag as the
|
||||||
|
# API — so the schema and the binary that expects it can never be different
|
||||||
|
# versions, which is the failure a separate migrate image invites.
|
||||||
|
ARG MIGRATE_VERSION=v4.19.0
|
||||||
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||||
|
--mount=type=cache,target=/root/.cache/go-build \
|
||||||
|
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go install -tags 'postgres' \
|
||||||
|
github.com/golang-migrate/migrate/v4/cmd/migrate@${MIGRATE_VERSION} && \
|
||||||
|
cp "$(go env GOPATH)/bin/${TARGETOS}_${TARGETARCH}/migrate" /out/migrate 2>/dev/null || \
|
||||||
|
cp "$(go env GOPATH)/bin/migrate" /out/migrate
|
||||||
|
|
||||||
|
# Fail the build rather than the deploy if the tests do not pass. Skipped by
|
||||||
|
# default because the database-backed tests need PostgreSQL, which a build
|
||||||
|
# container does not have; turn it on in CI where a service container does.
|
||||||
|
# Note: this runs on the BUILD architecture, so it is only meaningful when
|
||||||
|
# building natively. Under cross-compilation the test binaries would target the
|
||||||
|
# other architecture and could not execute here.
|
||||||
|
ARG RUN_TESTS=false
|
||||||
|
RUN if [ "$RUN_TESTS" = "true" ]; then go test ./... ; fi
|
||||||
|
|
||||||
|
|
||||||
|
# ── Runtime ─────────────────────────────────────────────────────────────────
|
||||||
|
# Alpine rather than distroless, for one concrete reason: HEALTHCHECK needs a
|
||||||
|
# command inside the container, and distroless has no shell and no wget. The
|
||||||
|
# trade is ~8 MB and an apk surface to keep updated, in exchange for the
|
||||||
|
# orchestrator being able to see whether this instance is actually serving.
|
||||||
|
FROM alpine:3.20 AS runtime
|
||||||
|
|
||||||
|
# ca-certificates: the pgx driver needs a trust store to verify a managed
|
||||||
|
# database's TLS certificate under sslmode=verify-full.
|
||||||
|
# tzdata: timestamps are timestamptz and the seeder anchors shift dates to now.
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata wget && \
|
||||||
|
adduser -D -H -u 10001 -s /sbin/nologin krow
|
||||||
|
|
||||||
|
COPY --from=build /out/api /usr/local/bin/api
|
||||||
|
COPY --from=build /out/seed /usr/local/bin/seed
|
||||||
|
COPY --from=build /out/setpassword /usr/local/bin/setpassword
|
||||||
|
COPY --from=build /out/migrate /usr/local/bin/migrate
|
||||||
|
|
||||||
|
# The seed fixture, so `seed` works without a bind mount.
|
||||||
|
COPY seed/fixtures/seed.json /app/seed/fixtures/seed.json
|
||||||
|
ENV SEED_FIXTURE_PATH=/app/seed/fixtures/seed.json
|
||||||
|
|
||||||
|
# The migration files, so an initContainer can apply them from this image.
|
||||||
|
# They are read-only at runtime and the process is non-root, so nothing here
|
||||||
|
# can be rewritten by the service.
|
||||||
|
COPY migrations/ /app/migrations/
|
||||||
|
ENV MIGRATIONS_DIR=/app/migrations
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Non-root. The service binds 8080, which needs no privilege, so there is no
|
||||||
|
# reason for this process to be able to write to its own filesystem or read
|
||||||
|
# another container's mounts.
|
||||||
|
USER krow:krow
|
||||||
|
|
||||||
|
# 0.0.0.0, NOT the 127.0.0.1 that config.Load defaults to. A container that
|
||||||
|
# binds loopback is reachable only from inside itself — the symptom is a
|
||||||
|
# published port that refuses every connection while the process looks healthy.
|
||||||
|
ENV HTTP_HOST=0.0.0.0 \
|
||||||
|
HTTP_PORT=8080 \
|
||||||
|
APP_ENV=production \
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# The check keys on the STATUS CODE, not on the body, because the two say
|
||||||
|
# different things on purpose (httpserver.handleHealth):
|
||||||
|
#
|
||||||
|
# 200 "ok" serving normally
|
||||||
|
# 200 "degraded" process healthy, schema unmigrated or dirty — still 200,
|
||||||
|
# because the fault is the database's and pulling this
|
||||||
|
# instance would not fix it
|
||||||
|
# 503 "unavailable" database unreachable
|
||||||
|
#
|
||||||
|
# Grepping the body for "ok" would mark a degraded instance unhealthy and take
|
||||||
|
# the whole deployment out of rotation during a migration window, which is
|
||||||
|
# exactly the behaviour that 200 was chosen to avoid. wget exits non-zero on
|
||||||
|
# 503 and zero on either 200, which is the intended semantics.
|
||||||
|
HEALTHCHECK --interval=15s --timeout=3s --start-period=20s --retries=3 \
|
||||||
|
CMD wget -qO- http://127.0.0.1:8080/health >/dev/null 2>&1 || exit 1
|
||||||
|
|
||||||
|
# Exec form: the binary becomes PID 1 and receives SIGTERM directly, which is
|
||||||
|
# what cmd/api's signal.NotifyContext is waiting for to drain connections.
|
||||||
|
ENTRYPOINT ["/usr/local/bin/api"]
|
||||||
101
infrastructure/docker-compose.local-db.yml
Normal file
101
infrastructure/docker-compose.local-db.yml
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# ============================================================================
|
||||||
|
# Overlay: run PostgreSQL alongside the API.
|
||||||
|
#
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.local-db.yml up -d
|
||||||
|
#
|
||||||
|
# FOR STAGING AND FOR TESTING THE STACK ON ONE HOST. Not for production data.
|
||||||
|
# A database in a container on the same host as its application has no backup,
|
||||||
|
# no point-in-time recovery, no failover, and dies with the host. The base file
|
||||||
|
# assumes a managed PostgreSQL for exactly that reason.
|
||||||
|
#
|
||||||
|
# ── TLS ─────────────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# PostgreSQL is started with SSL on, using a self-signed certificate generated
|
||||||
|
# on first boot into the data volume. That is enough for DATABASE_SSLMODE=
|
||||||
|
# require, which encrypts the connection but does not verify who is on the
|
||||||
|
# other end — appropriate for a private compose network, NOT a substitute for
|
||||||
|
# verify-full against a real CA.
|
||||||
|
#
|
||||||
|
# It also means APP_ENV=production works here without weakening the check in
|
||||||
|
# internal/config, which is the point: the guard stays honest and the local
|
||||||
|
# stack meets it rather than dodging it.
|
||||||
|
#
|
||||||
|
# Set these in .env to match:
|
||||||
|
# DATABASE_HOST=postgres
|
||||||
|
# DATABASE_SSLMODE=require
|
||||||
|
# DATABASE_URL=postgres://krow:<url-encoded-password>@postgres:5432/krow?sslmode=require
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: krow-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${DATABASE_NAME:-krow}
|
||||||
|
POSTGRES_USER: ${DATABASE_USER:-krow}
|
||||||
|
POSTGRES_PASSWORD: ${DATABASE_PASSWORD:?DATABASE_PASSWORD is required}
|
||||||
|
# scram-sha-256 rather than the image's md5 default. md5 has been
|
||||||
|
# deprecated upstream for years and is trivial to crack from a captured
|
||||||
|
# handshake.
|
||||||
|
POSTGRES_INITDB_ARGS: "--auth-host=scram-sha-256 --auth-local=scram-sha-256"
|
||||||
|
# Generate a self-signed certificate on first boot, then hand off to the
|
||||||
|
# image's own entrypoint with SSL enabled. The key must be 0600 and owned
|
||||||
|
# by the postgres user or the server refuses to start — that check is the
|
||||||
|
# reason this is done here rather than in a bind mount, where host
|
||||||
|
# ownership would leak in.
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
set -e
|
||||||
|
CERT=/var/lib/postgresql/data/server.crt
|
||||||
|
KEY=/var/lib/postgresql/data/server.key
|
||||||
|
if [ ! -f "$$CERT" ]; then
|
||||||
|
mkdir -p /var/lib/postgresql/data
|
||||||
|
openssl req -new -x509 -days 3650 -nodes -text \
|
||||||
|
-out "$$CERT" -keyout "$$KEY" -subj "/CN=postgres" 2>/dev/null
|
||||||
|
chmod 0600 "$$KEY"
|
||||||
|
chown postgres:postgres "$$CERT" "$$KEY"
|
||||||
|
fi
|
||||||
|
exec docker-entrypoint.sh postgres \
|
||||||
|
-c ssl=on -c ssl_cert_file="$$CERT" -c ssl_key_file="$$KEY"
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
# -U and -d so this reports on the application's database, not on
|
||||||
|
# whatever `postgres` happens to be reachable.
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DATABASE_USER:-krow} -d ${DATABASE_NAME:-krow}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
ports:
|
||||||
|
# Loopback only. Publishing 5432 to the world is how a database ends up
|
||||||
|
# in someone else's botnet.
|
||||||
|
- "${POSTGRES_BIND:-127.0.0.1}:${POSTGRES_PORT:-5432}:5432"
|
||||||
|
restart: unless-stopped
|
||||||
|
stop_grace_period: 60s
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: ${POSTGRES_MEMORY_LIMIT:-1G}
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
|
networks: [krow]
|
||||||
|
|
||||||
|
# The migration runner now has to wait for the database to accept
|
||||||
|
# connections, which it does not need to do when the database is managed and
|
||||||
|
# already up.
|
||||||
|
migrate:
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
driver: local
|
||||||
142
infrastructure/docker-compose.yml
Normal file
142
infrastructure/docker-compose.yml
Normal 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
|
||||||
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