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

View File

@@ -15,11 +15,19 @@
*/
import { readFileSync, readdirSync, writeFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
/* The frontend checkout. Overridable so this runs anywhere the two repos are
checked out side by side, which is the layout it defaults to. */
/* fileURLToPath, not .pathname: a URL percent-encodes, so a checkout under a
directory with a space in it resolved to "/Users/.../Krow%20Project%20/..."
— a path that does not exist. Vite then started with a root pointing nowhere
and failed on the first import, which reads as a missing source file rather
than a broken path. The effect was that this script could not run at all on
such a checkout, and the conformance test went on passing against whatever
oracle happened to be committed. */
const FRONTEND = process.env.KROW_FRONTEND
|| new URL('../../krow-demo', import.meta.url).pathname;
|| fileURLToPath(new URL('../../krow-demo', import.meta.url));
const { createServer } = await import(join(FRONTEND, 'node_modules/vite/dist/node/index.js'));
const { CASES } = await import(new URL('./cases.mjs', import.meta.url).href);
@@ -70,6 +78,10 @@ const projectAgent = (a) => ({
trigger: a.trigger,
webSearch: a.webSearch,
skills: a.skills,
/* Capability, and the field the two parsers most need to agree on: the
backend resolves an agent's tools from exactly this list, so a divergence
here is an agent that can do something in one process and not the other. */
tools: a.tools,
subagents: a.subagents,
starters: a.starters,
permissions: a.permissions,

303
scripts/verify-deploy.py Executable file
View File

@@ -0,0 +1,303 @@
#!/usr/bin/env python3
"""
Verify a deployed Krow API, endpoint by endpoint.
KROW_EMAIL=you@example.com KROW_PASSWORD=... \
python3 scripts/verify-deploy.py https://mcp.krowforce.com
make verify-deploy BASE=https://mcp.krowforce.com
Credentials come from the environment, never from an argument, so they do not
land in shell history or in a process list.
READ-ONLY by default. The two write paths (hire, assignment) are exercised only
with --write, because they change tenant data and a smoke test that mutates the
thing it is checking is not a smoke test.
Why this exists: this API runs its auth middleware BEFORE routing, so an
unauthenticated probe answers 401 for every path — including paths that do not
exist. `curl` against a deployed host therefore cannot tell a missing endpoint
from a guarded one, and the only honest check is an authenticated one.
Exit code is non-zero if any check fails.
"""
import json, os, sys, time, urllib.request, urllib.parse, urllib.error, http.cookiejar
BASE = (sys.argv[1] if len(sys.argv) > 1 and not sys.argv[1].startswith("-")
else os.environ.get("KROW_BASE_URL", "http://127.0.0.1:8080")).rstrip("/")
WRITE = "--write" in sys.argv
class BrowserLikePolicy(http.cookiejar.DefaultCookiePolicy):
"""Accept Secure cookies over http://localhost, as every browser does.
Browsers treat localhost as a potentially-trustworthy origin, so a Secure
cookie set through a dev-server proxy is stored and sent. Python's default
policy refuses it, which makes a perfectly working frontend look like a
broken session: login returns 200 and the very next request is 401.
Only localhost. Anywhere else, a Secure cookie over plaintext is refused as
it should be.
"""
@staticmethod
def _trustworthy(request):
host = urllib.parse.urlparse(request.get_full_url()).hostname or ""
return host in ("localhost", "127.0.0.1", "::1", "[::1]")
def set_ok_secure(self, cookie, request):
return self._trustworthy(request) or super().set_ok_secure(cookie, request)
def return_ok_secure(self, cookie, request):
return self._trustworthy(request) or super().return_ok_secure(cookie, request)
jar = http.cookiejar.CookieJar(policy=BrowserLikePolicy())
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
results = []
def call(method, path, body=None, accept="application/json", timeout=45):
"""Returns (status, text, headers). Never raises for an HTTP status."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data=data, method=method)
req.add_header("Content-Type", "application/json")
req.add_header("Accept", accept)
try:
with opener.open(req, timeout=timeout) as r:
return r.status, r.read().decode("utf-8", "replace"), dict(r.headers)
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace"), dict(e.headers)
except Exception as e:
return 0, f"{type(e).__name__}: {e}", {}
def check(name, ok, detail=""):
results.append((name, bool(ok), detail))
print(f"[{' ok ' if ok else ' FAIL '}] {name}" + (f" — {detail}" if detail else ""))
return ok
def group(title):
print(f"\n── {title} " + "─" * max(0, 66 - len(title)))
def as_json(text):
try:
return json.loads(text)
except Exception:
return None
# resource -> the operations it declares (internal/domain/resources_gen.go).
# Anything not declared is deliberately unregistered: "the database having a
# table is never a reason for an endpoint to exist" (api.go). `badges` declares
# nothing at all, so every badges path is correctly a 404.
RESOURCE_OPS = {
"job-postings": ["List", "Get", "Create", "Update"],
"job-applications": ["List", "Create", "Update", "Delete"],
"ai-interviews": ["List", "Create"],
"staff": ["List", "Create", "Update"],
"worker-profiles": ["List", "Create", "Update"],
"courses": ["List", "Get", "Create", "Update"],
"learning-paths": ["List"],
"role-categories": ["List", "Create"],
"certifications": ["List", "Create", "Delete"],
"user-activity": ["List", "Create"],
"evidence": ["List", "Create", "Update"],
"assignments": ["List", "Create"],
"shift-records": ["List"],
"badges": [],
}
RESOURCES = [r for r, ops in RESOURCE_OPS.items() if "List" in ops]
print(f"Verifying {BASE} ({'read/write' if WRITE else 'read-only'})")
# ── 1. Reachable, and guarded ────────────────────────────────────────────────
group("Reachable and guarded")
s, t, _ = call("GET", "/health")
health = as_json(t) or {}
check("/health answers 200", s == 200, f"{s} {health.get('status', t[:40])}")
check("/health reports a healthy database", health.get("status") == "ok",
f"status={health.get('status')} (degraded = schema unmigrated or dirty)")
s, _, _ = call("GET", "/api/v1/job-postings")
check("a protected endpoint refuses an anonymous caller", s in (401, 403), f"{s}")
# ── 2. Sign in ───────────────────────────────────────────────────────────────
group("Authentication")
email, password = os.environ.get("KROW_EMAIL"), os.environ.get("KROW_PASSWORD")
if not (email and password):
print("\nKROW_EMAIL / KROW_PASSWORD are not set — cannot check anything behind auth.")
print("Everything below needs a session. Set them and re-run.")
sys.exit(2)
s, t, _ = call("POST", "/api/v1/auth/login", {"email": email, "password": password})
if not check("sign-in succeeds", s == 200, str(s)):
print("\nNo session — stopping. Every remaining check needs one.")
sys.exit(1)
check("a session cookie was set", len(jar) > 0, f"{len(jar)} cookie(s)")
s, t, _ = call("GET", "/api/v1/me")
me = (as_json(t) or {}).get("data") or {}
check("GET /api/v1/me returns the signed-in user", s == 200 and bool(me), f"{s}")
check("...and it is the account that signed in",
str(me.get("email", "")).lower() == email.lower(), me.get("email", "?"))
role = me.get("role", "?")
print(f" signed in as {me.get('email','?')} (role: {role})")
s, _, _ = call("GET", "/api/v1/me/preferences")
check("GET /api/v1/me/preferences", s == 200, f"{s}")
# Which build is actually serving. Without this, "did my deploy land?" has no
# answer and a redeploy that silently rolled back looks identical to one that
# worked. Set KROW_EXPECT_VERSION to make a stale deployment a failure.
s, t, _ = call("GET", "/api/v1/version")
build = (as_json(t) or {}).get("data") or {}
running = build.get("version", "")
check("GET /api/v1/version reports the running build", s == 200 and bool(running),
f"{s}, version={running or 'none'}, env={build.get('env')}, "
f"endpoints={build.get('endpoints')}")
if running == "unknown":
check("...and the build was actually stamped", False,
"reports \"unknown\" — built without -X main.version, so it cannot be traced")
expected = os.environ.get("KROW_EXPECT_VERSION")
if expected:
check("...and it is the build you expected", running == expected,
f"running {running!r}, expected {expected!r}")
# ── 3. Every resource collection ─────────────────────────────────────────────
group(f"Resource endpoints ({len(RESOURCES)} collections)")
first_ids = {}
for r in RESOURCES:
s, t, _ = call("GET", f"/api/v1/{r}")
body = as_json(t) or {}
recs = body.get("data")
ok = s == 200 and isinstance(recs, list) and isinstance(body.get("meta"), dict)
total = (body.get("meta") or {}).get("total")
check(f"GET /api/v1/{r}", ok, f"{s}" + (f", {len(recs)} records, meta.total={total}" if ok else f" {t[:90]}"))
if ok and recs:
first_ids[r] = recs[0].get("id")
group("Reading one record by id (only where the resource declares Get)")
for r, rid in first_ids.items():
s, t, _ = call("GET", f"/api/v1/{r}/{rid}")
if "Get" in RESOURCE_OPS[r]:
check(f"GET /api/v1/{r}/{{id}}", s == 200, f"{s}")
else:
check(f"GET /api/v1/{r}/{{id}} is refused — it declares no Get",
s in (404, 405), f"{s}")
group("A resource that declares nothing exposes nothing")
for r, ops in RESOURCE_OPS.items():
if ops:
continue
s, _, _ = call("GET", f"/api/v1/{r}")
check(f"GET /api/v1/{r} → 404", s == 404, f"{s}")
group("An id that does not exist is 404, not 500")
s, _, _ = call("GET", "/api/v1/job-postings/00000000-0000-0000-0000-000000000000")
check("unknown id → 404", s == 404, f"{s}")
s, _, _ = call("GET", "/api/v1/job-postings/not-a-uuid")
check("malformed id → 4xx, never 5xx", 400 <= s < 500, f"{s}")
# ── 4. The agent layer ───────────────────────────────────────────────────────
group("Agent and skill registry")
s, t, _ = call("GET", "/api/v1/agent-definitions")
body = as_json(t) or {}
agents = body.get("data") if isinstance(body, dict) else body
agents = agents if isinstance(agents, list) else []
check("GET /api/v1/agent-definitions", s == 200 and isinstance(agents, list), f"{s}, {len(agents)} agents")
if agents:
print(" " + ", ".join(sorted(str(a.get("definition_id") or a.get("id")) for a in agents)))
# Two different keys, deliberately. The registry endpoint is a CRUD resource
# keyed by uuid (repo.GetAgent: WHERE id = $1::uuid); the run endpoint is
# addressed by the stable definition_id a spec author writes. Passing the
# definition_id to the registry endpoint is a 404, which is correct.
row_uuid = agents[0].get("id")
s, _, _ = call("GET", f"/api/v1/agent-definitions/{row_uuid}")
check("GET /api/v1/agent-definitions/{uuid}", s == 200, f"{s}")
s, _, _ = call("GET", f"/api/v1/agent-definitions/{agents[0].get('definition_id')}")
check("...and the definition_id is not a uuid, so it is refused there", s == 404, f"{s}")
s, t, _ = call("GET", "/api/v1/skill-definitions")
body = as_json(t) or {}
skills = body.get("data") if isinstance(body, dict) else body
skills = skills if isinstance(skills, list) else []
check("GET /api/v1/skill-definitions", s == 200, f"{s}, {len(skills)} skills")
s, t, _ = call("GET", "/api/v1/owliver/suggestions?page=control-center")
check("GET /api/v1/owliver/suggestions?page=...", s == 200, f"{s}")
s, _, _ = call("GET", "/api/v1/owliver/suggestions")
check("...and it requires a page rather than guessing one", s == 400, f"{s}")
s, _, _ = call("GET", "/api/v1/owliver/suggestions?page=not-a-real-page")
check("...and rejects a page that does not exist", s == 400, f"{s}")
# ── 5. An actual agent run ───────────────────────────────────────────────────
group("Running an agent (this calls the model — it costs tokens)")
run_id = None
if not agents:
check("an agent run completes", False, "no agents are published on this deployment")
else:
aid = agents[0].get("definition_id") or agents[0].get("id")
t0 = time.time()
s, t, _ = call("POST", f"/api/v1/agents/{aid}/runs",
{"input": "What can you help me with? Answer in one sentence."})
body = as_json(t) or {}
took = time.time() - t0
ok = s == 200 and body.get("termination") is not None
check(f"POST /api/v1/agents/{aid}/runs", ok,
f"{s}, termination={body.get('termination')}, {took:.1f}s" if ok else f"{s} {t[:160]}")
if ok:
run_id = body.get("runId") or body.get("run_id")
check("...the run terminated cleanly",
body.get("termination") in ("Completed", "ConfirmationPending"),
str(body.get("termination")))
check("...and it produced an answer",
bool(body.get("output") or body.get("message") or body.get("confirmations")),
(body.get("output") or body.get("message") or "")[:70] or "confirmation proposed")
usage = body.get("usage") or {}
check("...with token accounting attached",
(usage.get("inputTokens", 0) or 0) > 0,
f"in={usage.get('inputTokens')} out={usage.get('outputTokens')}")
if run_id:
s, t, _ = call("GET", f"/api/v1/runs/{run_id}")
rb = as_json(t) or {}
check("GET /api/v1/runs/{id} returns the trajectory", s == 200, f"{s}")
entries = rb.get("entries")
check("...with the trajectory persisted",
isinstance(entries, list) and len(entries) > 0,
f"{len(entries) if isinstance(entries, list) else 0} entries, "
f"termination={rb.get('termination')}, model={rb.get('model') or '?'}")
check("...and the run pins the agent version it started with",
isinstance(rb.get("agentVersion"), int) and rb["agentVersion"] > 0,
f"v{rb.get('agentVersion')}")
# Streaming is the path the chat panel actually uses.
s, t, h = call("POST", f"/api/v1/agents/{aid}/runs",
{"input": "Say hello in five words."}, accept="text/event-stream")
ctype = (h.get("Content-Type") or h.get("content-type") or "")
check("the same endpoint streams on Accept: text/event-stream",
s == 200 and "event-stream" in ctype, f"{s}, content-type={ctype or 'none'}")
check("...and the stream carries more than one event",
t.count("data:") > 1, f"{t.count('data:')} data frames")
# ── 6. Writes (only with --write) ────────────────────────────────────────────
group("Write paths")
if not WRITE:
print(" skipped — re-run with --write to exercise hire and assignment")
else:
s, t, _ = call("POST", "/api/v1/job-postings/x/assignments", {})
check("POST assignments rejects a bad request rather than 500", 400 <= s < 500, f"{s}")
s, t, _ = call("POST", "/api/v1/job-applications/x/hire", {})
check("POST hire rejects a bad request rather than 500", 400 <= s < 500, f"{s}")
# ── 7. Sign out ──────────────────────────────────────────────────────────────
group("Sign out")
s, _, _ = call("POST", "/api/v1/auth/logout")
check("POST /api/v1/auth/logout", s in (200, 204), f"{s}")
s, _, _ = call("GET", "/api/v1/me")
check("the session is dead afterwards", s in (401, 403), f"{s}")
# ── Summary ──────────────────────────────────────────────────────────────────
failed = [r for r in results if not r[1]]
print(f"\n{len(results) - len(failed)}/{len(results)} checks passed")
if failed:
print("\nFailed:")
for name, _, detail in failed:
print(f" - {name}{f' ({detail})' if detail else ''}")
sys.exit(1)

198
scripts/verify-deployment.sh Executable file
View File

@@ -0,0 +1,198 @@
#!/usr/bin/env bash
#
# Verify a Krow API deployment, from the outside.
#
# KROW_EMAIL=you@example.com KROW_PASSWORD=... ./scripts/verify-deployment.sh
# KROW_BASE=https://mcp.krowforce.com ./scripts/verify-deployment.sh --write
#
# WHAT THIS IS FOR. The 404 that started this was invisible to every
# unauthenticated probe: authentication wraps the whole mux, so a route that
# does not exist and a route you are not signed in for answer identically. The
# only way to tell them apart is to hold a session and ask. That is what this
# does, and it is why it needs credentials.
#
# READ-ONLY BY DEFAULT. Everything below is a GET unless --write is passed.
# With --write it creates ONE job posting with status "draft" — drafts are
# invisible to talent and to the public listing, and the JobPosting resource
# has no DELETE operation, so the row is permanent. That is the whole reason
# the write test is opt-in rather than default: verifying a deployment should
# not silently leave records in a production database.
#
# Exit status is the number of failed checks, so it can gate a rollout.
set -uo pipefail
BASE="${KROW_BASE:-https://mcp.krowforce.com}"
API="$BASE/api/v1"
JAR="$(mktemp -t krowjar.XXXXXX)"
DO_WRITE=false
[[ "${1:-}" == "--write" ]] && DO_WRITE=true
trap 'rm -f "$JAR"' EXIT
pass=0; fail=0; skip=0
ok() { printf ' \033[32mok\033[0m %s\n' "$1"; pass=$((pass+1)); }
bad() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; [[ -n "${2:-}" ]] && printf ' %s\n' "$2"; fail=$((fail+1)); }
note() { printf ' \033[33mskip\033[0m %s\n' "$1"; skip=$((skip+1)); }
head_() { printf '\n\033[1m%s\033[0m\n' "$1"; }
# status <method> <path> [body] — prints the HTTP status, keeps the body in $BODY
BODY=""
status() {
local method="$1" path="$2" body="${3:-}" code
if [[ -n "$body" ]]; then
code=$(curl -sS -m 20 -o /tmp/krowbody.$$ -w '%{http_code}' -X "$method" "$API$path" \
-b "$JAR" -c "$JAR" -H 'Content-Type: application/json' -H 'Accept: application/json' -d "$body")
else
code=$(curl -sS -m 20 -o /tmp/krowbody.$$ -w '%{http_code}' -X "$method" "$API$path" \
-b "$JAR" -c "$JAR" -H 'Accept: application/json')
fi
BODY=$(cat /tmp/krowbody.$$ 2>/dev/null); rm -f /tmp/krowbody.$$
printf '%s' "$code"
}
printf '\033[1mKrow deployment verification\033[0m\n'
printf 'target %s\n' "$BASE"
printf 'mode %s\n' "$([[ $DO_WRITE == true ]] && echo 'read + one draft write' || echo 'read-only')"
# ── 1. Reachability, before any credential ─────────────────────────────────
head_ '1 · Reachability'
health=$(curl -sS -m 20 -o /tmp/h.$$ -w '%{http_code}' "$BASE/health"); hbody=$(cat /tmp/h.$$); rm -f /tmp/h.$$
if [[ "$health" == "200" ]]; then ok "/health → 200 $(printf '%s' "$hbody" | tr -d ' \n')"
else bad "/health → $health (want 200)" "$hbody"; fi
# The status word matters: "degraded" means the process is fine and the schema
# is not — an unmigrated or dirty database. Deploying the binary before the
# migration is exactly how that happens.
if printf '%s' "$hbody" | grep -q 'degraded'; then
bad "schema is not current — run migrations before rolling out the binary"
fi
# ── 2. Session ─────────────────────────────────────────────────────────────
head_ '2 · Authentication'
if [[ -z "${KROW_EMAIL:-}" || -z "${KROW_PASSWORD:-}" ]]; then
printf ' \033[31mKROW_EMAIL / KROW_PASSWORD are not set.\033[0m\n'
printf ' Every check below needs a session: an unauthenticated request to a\n'
printf ' route that exists and one to a route that does not are both 401, so\n'
printf ' without credentials this script cannot tell you what is deployed.\n\n'
exit 1
fi
login=$(status POST /auth/login "$(printf '{"email":%s,"password":%s,"remember_me":false}' \
"$(printf '%s' "$KROW_EMAIL" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')" \
"$(printf '%s' "$KROW_PASSWORD" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')")")
if [[ "$login" == "200" ]]; then ok "POST /auth/login → 200"
else bad "POST /auth/login → $login" "$BODY"; printf '\nCannot continue without a session.\n'; exit 1; fi
# The cookie decides whether a browser will ever send this session again.
cookie_line=$(grep -i 'krow_session' "$JAR" | head -1)
if [[ -n "$cookie_line" ]]; then ok "session cookie issued"; else bad "no krow_session cookie in the response"; fi
me=$(status GET /me)
if [[ "$me" == "200" ]]; then
role=$(printf '%s' "$BODY" | python3 -c 'import json,sys; print(json.load(sys.stdin)["data"].get("role","?"))' 2>/dev/null)
ok "GET /me → 200, role=$role"
# Authorization is decided by `role`, never by `account_type`. A talent role
# is refused every operator write, which presents as an app that "does not
# work" rather than as a permission problem.
if [[ "$role" == "talent" ]]; then
bad "this account's role is 'talent'" \
"operator writes will 403 and the positions list will show only active postings"
fi
else bad "GET /me → $me" "$BODY"; fi
# ── 3. Which version is deployed ───────────────────────────────────────────
head_ '3 · Deployed version'
# The Owliver suggestions route is the discriminator: it exists only from
# commit b6f8655 onward. Authenticated, so 404 means "not in this binary"
# rather than "not signed in".
sug=$(status GET '/owliver/suggestions?page=positions&query=pipeline')
case "$sug" in
200) ok "GET /owliver/suggestions → 200 — b6f8655 or later is deployed" ;;
404) bad "GET /owliver/suggestions → 404 — the deployed binary predates b6f8655" \
"this is the deployment lag; the route exists in go-api/internal/httpserver/owliver.go" ;;
*) bad "GET /owliver/suggestions → $sug (want 200)" "$BODY" ;;
esac
# The resource is job-postings. /api/v1/positions has never existed in this
# API, and asserting that here stops anyone "fixing" a 404 by adding it.
pos=$(status GET /positions)
if [[ "$pos" == "404" ]]; then ok "GET /positions → 404 — correct, the resource is job-postings"
else bad "GET /positions → $pos (want 404)" "a duplicate positions route may have been added"; fi
# ── 4. The job-posting resource ────────────────────────────────────────────
head_ '4 · Job postings (the position resource)'
list=$(status GET '/job-postings?limit=5')
if [[ "$list" == "200" ]]; then
count=$(printf '%s' "$BODY" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)["data"]))' 2>/dev/null)
ok "GET /job-postings → 200, $count row(s)"
else bad "GET /job-postings → $list" "$BODY"; fi
if [[ "$DO_WRITE" == true ]]; then
stamp=$(date -u +%Y%m%dT%H%M%SZ)
# status draft: not published, not visible to talent. The least invasive
# record that still proves the whole write path reaches PostgreSQL.
payload=$(printf '{"title":"Deployment verification %s","company":"Verification","role_category":"Server","location":"n/a","status":"draft","headcount":1,"pay_range_min":0,"pay_range_max":0,"min_experience_years":0,"english_required":"basic","priority":"normal"}' "$stamp")
created=$(status POST /job-postings "$payload")
if [[ "$created" == "201" ]]; then
id=$(printf '%s' "$BODY" | python3 -c 'import json,sys; print(json.load(sys.stdin)["data"]["id"])' 2>/dev/null)
ok "POST /job-postings → 201, id=$id"
back=$(status GET "/job-postings/$id")
if [[ "$back" == "200" ]]; then ok "GET /job-postings/$id → 200 — persisted in PostgreSQL"
else bad "created row not readable back → $back"; fi
printf ' note: draft row %s is permanent (JobPosting has no DELETE)\n' "$id"
elif [[ "$created" == "403" ]]; then
bad "POST /job-postings → 403" "this account's role is not an operator (admin or employer)"
else
bad "POST /job-postings → $created" "$BODY"
fi
else
note "write test skipped (pass --write to create one draft posting)"
fi
# ── 5. Owliver, in both modes ──────────────────────────────────────────────
head_ '5 · Owliver suggestions'
if [[ "$sug" == "200" ]]; then
n=$(printf '%s' "$BODY" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)["data"]["suggestions"]))' 2>/dev/null)
if [[ "${n:-x}" =~ ^[0-9]+$ ]] && (( n <= 3 )); then ok "typed query returned $n suggestion(s), cap is 3"
else bad "typed query returned $n suggestions" "the contract caps this at 3"; fi
# No query: this is the path that reads the organization's state out of
# PostgreSQL, so it is the one that proves context building works.
untyped=$(status GET '/owliver/suggestions?page=positions')
if [[ "$untyped" == "200" ]]; then
m=$(printf '%s' "$BODY" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)["data"]["suggestions"]))' 2>/dev/null)
ok "untyped query → 200, $m suggestion(s) from live data"
else bad "untyped query → $untyped" "$BODY"; fi
bad_page=$(status GET '/owliver/suggestions?page=not-a-real-surface')
if [[ "$bad_page" == "400" ]]; then ok "unknown page → 400 invalid_query"
else bad "unknown page → $bad_page (want 400)"; fi
else
note "suggestion detail checks skipped — the route is not deployed"
fi
# ── 6. Cookie posture ──────────────────────────────────────────────────────
head_ '6 · Session cookie posture'
logout_hdrs=$(curl -sS -m 20 -D - -o /dev/null -X POST "$API/auth/logout" -b "$JAR")
setc=$(printf '%s' "$logout_hdrs" | grep -i '^set-cookie:' | head -1)
printf ' %s\n' "${setc:-(no Set-Cookie)}"
if printf '%s' "$setc" | grep -qi 'SameSite=None'; then
printf ' \033[33mSameSite=None\033[0m — the cookie travels cross-site. That is required only\n'
printf ' for a frontend on a DIFFERENT registrable domain. platform.krowforce.com\n'
printf ' and mcp.krowforce.com are the same site, so Lax would suffice for them —\n'
printf ' and SameSite is the only CSRF protection this API has.\n'
elif printf '%s' "$setc" | grep -qi 'SameSite=Lax'; then
ok "SameSite=Lax — CSRF protection retained"
fi
# ── Summary ────────────────────────────────────────────────────────────────
printf '\n\033[1m%d passed, %d failed, %d skipped\033[0m\n' "$pass" "$fail" "$skip"
exit "$fail"