185 lines
8.2 KiB
Markdown
185 lines
8.2 KiB
Markdown
# 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.**
|
|
|
|
## 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.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.
|
|
|
|
```sh
|
|
# 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
|
|
|
|
```sh
|
|
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 `404`s; `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.
|
|
|
|
```sh
|
|
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.
|