Files
krow_backend/docs/deploy-b6f8655.md
2026-08-28 12:21:44 +05:30

8.2 KiB

Deploying to mcp.krowforce.com

Prepared and verified. Not executed — this machine has no Docker daemon, no SSH access to the host and no registry credentials, and a production rollout is not something to do without the operator watching.


1. What is running now

954ba90 — or equivalently cadea4b, the merge commit whose tree is byte-identical.

Established from the outside, without credentials:

Observation Command Conclusion
Preflight echoes the origin and Access-Control-Allow-Credentials OPTIONS /api/v1/job-postings -H 'Origin: https://platform.krowforce.com' → 204 ≥ 954ba90 — that header was added there and is absent at 7d12ebe
Session cookie is SameSite=Lax while a CORS allowlist is configured POST /api/v1/auth/logout → Set-Cookie: … Secure; SameSite=Lax < b6f8655 — from that commit a configured allowlist forces SameSite=None
routeOwliver absent from server.go at cadea4b git show cadea4b:…/server.go | grep s.route GET /api/v1/owliver/suggestions is not registered

52 endpoints deployed. 53 at HEAD. The missing one is the Owliver suggestions route, which is the 404 the frontend sees.

/health returns {"status":"ok"} — not degraded — so the remote schema is present and not dirty.

2. What is being deployed

b6f8655, which is HEAD and is already origin/main. Nothing needs pushing.

Plus one commit prepared here — see §4. It is required: without it this deploy silently removes the only CSRF protection the API has.

Local working-tree changes (the database-backed Owliver suggestion context) are not part of this deploy and are not on any branch. They do not reach the host, which builds from origin/main.

Verified before shipping

Check Result
go build ./..., go vet ./... clean
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build ./cmd/api 17 MB static ELF, builds clean
go test ./... 8/8 packages pass, against real migrated PostgreSQL
Migration delta cadea4b → HEAD none — git diff cadea4b HEAD -- migrations/ is empty
Config compatibility no new validation; the new binary accepts a strict superset of what the host is configured with

No schema step. This is a binary-only rollout.

3. What changes in behaviour

Beyond the new route, b6f8655 fixes three things already broken in production:

  • POST /api/v1/ai-interviews becomes transactional — it sets interview_id, advances the application to interview and copies the score. The deployed version writes only the interview, and the frontend deliberately does not patch the application afterwards, so completing an interview currently leaves the candidate un-advanced with nothing to show it.
  • POST /api/v1/job-postings/{id}/assignments may now file an application for a worker placed from the talent pool who never applied. It currently cannot, so those assignments fail.
  • serverSupplies now checks the caller's role. Previously a talent-only derived column was treated as server-supplied for every role, so an operator creating a job application without an email passed validation and hit a NOT NULL violation — a 500 where the contract promises 422.

b6f8655 alone derives SameSite from whether a CORS allowlist is configured: non-empty ⇒ None. On this host the allowlist is non-empty, so deploying it as-is flips the session cookie from Lax to None.

SameSite is the only CSRF protection this API has. There is no CSRF token.

The derivation is wrong for this topology, because CORS and SameSite answer different questions:

  • CORS is about origin. platform.krowforce.com → mcp.krowforce.com is cross-origin, so the allowlist is genuinely required. Confirmed live: that origin returns 204 with the origin echoed; an unlisted origin returns 403.
  • SameSite is about site. Both share the registrable domain krowforce.com, so they are same-site and a Lax cookie is already sent on those requests.

So the correct configuration is CORS on, SameSite=Lax — a combination b6f8655 cannot express.

The commit makes an explicit HTTP_COOKIE_SAMESITE authoritative and leaves the CORS-derived value as the default when it is unset. It also closes a trap: config.Load has always parsed and validated that variable, and nothing read it, so a deployment that set it saw it silently ignored.

internal/config/config.go     unset is "" rather than defaulting to "lax"
internal/httpserver/auth.go   explicit value wins; allowlist decides the default
internal/httpserver/samesite_test.go   the full matrix, pinned

docker-compose.yml already passes HTTP_COOKIE_SAMESITE: ${…:-lax}, so a compose deploy keeps Lax without any .env change.

Do not empty HTTP_CORS_ORIGINS. It looks like a way to keep Lax without a code change, and it would take platform.krowforce.com offline — that frontend calls the API cross-origin from the browser.

5. Rollout

Migrations first, then the binary — the order docker-compose.yml already encodes through depends_on: service_completed_successfully. There is nothing to migrate this time, but the step is a no-op rather than something to skip.

# On the host, from the repository root
git fetch origin && git checkout b6f8655   # or the extra commit from §4
cd infrastructure
docker compose build api
docker compose up -d --no-deps migrate     # exits 0, nothing to apply
docker compose up -d --no-deps api
docker compose ps                          # api healthy
docker compose logs -n 50 api              # expect: "endpoints":53

"endpoints":53 in the startup log is the single fastest confirmation that the right binary is running.

6. Verification

KROW_EMAIL=… KROW_PASSWORD=… ./scripts/verify-deployment.sh

Read-only by default. Add --write to prove the write path reaches PostgreSQL; it creates one posting with status: draft, which is invisible to talent — and permanent, because JobPosting has no DELETE.

It checks, in order: /health and whether the schema reads degraded; login and the issued cookie; the caller's role (a talent role explains almost every 403); GET /owliver/suggestions as the version discriminator; that /api/v1/positions still 404s; GET /job-postings; both suggestion modes and the 3-item cap; and the SameSite attribute actually being served.

Exit status is the number of failures, so it can gate a rollout.

The resource is job-postings

/api/v1/positions has never existed in this API. Every /positions in the frontend is a React Router UI route. Two tests assert the phantom stays absent — TestThereIsNoPositionsResource and the script's own check — so nobody "fixes" a future 404 by adding a duplicate resource.

7. Rollback

The previous image is still on the host.

docker compose down api
git checkout cadea4b
docker compose build api && docker compose up -d --no-deps api

No schema change means rollback is clean: nothing to reverse, and the old binary runs against the current schema unchanged.

8. Separately — the deployed frontends are not reaching the API

Found while verifying, out of scope for this deploy, and more severe than the 404.

platform.krowforce.com is built with VITE_API_BASE_URL=https://mcp.krowforce.com/api/v1 — an absolute cross-origin URL. The repository's own .env warns against this at length, and httpClient.js has a guard for it that is compiled out of production builds, so it fails silently.

app.krowforce.com is worse off: its origin is not on the backend's allowlist (403 at preflight), and its bundle carries neither auth/login nor any reference to the API host.

Both hosts also serve their SPA for /api/v1/* — GET /api/v1/me on either returns index.html with status 200. The shipped nginx.conf has no /api proxy at all, and try_files $uri $uri/ /index.html swallows every API path.

The fix is a /api and /health proxy_pass in the frontend's nginx plus a rebuild with VITE_API_BASE_URL=/api/v1, which is what the same-origin design assumes. That is a frontend deployment change and belongs in its own rollout.