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-interviewsbecomes transactional — it setsinterview_id, advances the application tointerviewand 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}/assignmentsmay now file an application for a worker placed from the talent pool who never applied. It currently cannot, so those assignments fail.serverSuppliesnow 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.
4. The required extra commit — cookie posture
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.comis cross-origin, so the allowlist is genuinely required. Confirmed live: that origin returns204with the origin echoed; an unlisted origin returns403. - SameSite is about site. Both share the registrable domain
krowforce.com, so they are same-site and aLaxcookie 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 keepLaxwithout a code change, and it would takeplatform.krowforce.comoffline — 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.