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

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"