agent build
This commit is contained in:
@@ -134,9 +134,33 @@ these would leave the shim with methods that 404. See §11 (D6).
|
||||
`GET /api/v1/owliver/suggestions?page={surface}&query={typed}`
|
||||
|
||||
The one endpoint here that serves no resource. It answers "what could I usefully
|
||||
ask on this page?" for the Owliver panel, which calls it while the user types —
|
||||
so it reads no table, opens no transaction, calls no model, and its whole answer
|
||||
is computed from a static catalogue in `internal/owliver`.
|
||||
ask on this page?" for the Owliver panel, and it answers two different questions
|
||||
depending on whether anything has been typed:
|
||||
|
||||
- **`query` present** — ranked against the static catalogue in `internal/owliver`.
|
||||
No table is read, no transaction is opened and no model is called: the panel
|
||||
issues one of these per keystroke, so the whole answer is a few string
|
||||
comparisons.
|
||||
- **`query` absent** — ranked against **the organization's actual state**, read
|
||||
from PostgreSQL in one statement: how many positions are unfinished drafts,
|
||||
how many active roles nobody has applied to, how many candidates are waiting
|
||||
on a score, how many interviews carry a flag. This is one query per opened
|
||||
panel, and it is what makes a suggestion react to the data — creating a
|
||||
position changes what comes back next time it is asked.
|
||||
|
||||
Ranking here is by **tier first, count second**. Each reading declares how
|
||||
much its subject matters when it is happening at all — a role nobody has
|
||||
applied to outranks a queue of unscored candidates, which outranks a
|
||||
headcount — and the count only orders readings inside a tier. Weight × count
|
||||
would mean the largest pile always won, so a workspace with forty
|
||||
applications and one abandoned role would be asked about the forty. A count
|
||||
of zero scores nothing whatever its tier, so a page with nothing to report is
|
||||
offered nothing rather than an urgent-sounding question about an empty set.
|
||||
|
||||
Only counts are read. Nothing that could name a record, a person or an id
|
||||
reaches the ranking, and a talent caller's counts are never read at all: every
|
||||
figure behind a highlight is organization-wide, and their rows are narrowed by
|
||||
the policy table.
|
||||
|
||||
It is not on the public allowlist. Which readings exist depends on the caller's
|
||||
role, so there is no anonymous answer to give.
|
||||
@@ -153,8 +177,11 @@ here** — role, organization and user are read from the session, and a request
|
||||
that names one is refused rather than ignored.
|
||||
|
||||
An unknown `page` is `invalid_query`, with the frontend's own wording:
|
||||
`Unsupported page: {value}. Supported pages: {…}.` An absent, blank or
|
||||
too-short `query` is **not** an error — there is simply nothing to rank yet.
|
||||
`Unsupported page: {value}. Supported pages: {…}.` An absent or blank `query` is
|
||||
**not** an error — it is a request for what the data itself suggests. A `query`
|
||||
that was typed but is too short to rank (under two letters or digits) answers
|
||||
with `[]` rather than falling back to the data: the user is mid-word, and
|
||||
replacing what they are typing towards would flicker.
|
||||
|
||||
### Response
|
||||
|
||||
@@ -170,7 +197,18 @@ too-short `query` is **not** an error — there is simply nothing to rank yet.
|
||||
```
|
||||
|
||||
`data.suggestions` is always an array — `[]` when nothing matches, never `null`
|
||||
and never an error. There is no `meta`: the list is capped rather than paged.
|
||||
and never an error. At most **three**, always. There is no `meta`: the list is
|
||||
capped rather than paged.
|
||||
|
||||
`capability` names the section type the answer should be drawn as, and is
|
||||
present only where the query asked for one — so a highlight, which nobody typed,
|
||||
never carries it. `intent` is the frontend capability id the panel dispatches
|
||||
on; it is not invented server-side, and
|
||||
`TestIntentIDsAreFrontendCapabilities` holds the two vocabularies together.
|
||||
|
||||
An empty array with no query typed means the organization has nothing worth
|
||||
raising — a workspace with no positions is asked nothing rather than asked three
|
||||
questions about empty sets.
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
|
||||
184
docs/deploy-b6f8655.md
Normal file
184
docs/deploy-b6f8655.md
Normal 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.
|
||||
Reference in New Issue
Block a user