agent build

This commit is contained in:
2026-08-28 12:21:44 +05:30
parent b6f8655909
commit f7df96c973
138 changed files with 24164 additions and 207 deletions

184
docs/deploy-b6f8655.md Normal file
View File

@@ -0,0 +1,184 @@
# 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.