Compare commits
143
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
139bd734f0 | ||
|
|
6e24fcfd28 | ||
|
|
9e7931e81c | ||
|
|
494a6582c4 | ||
|
|
b0254454c9 | ||
|
|
c93ddf92bf | ||
|
|
74478bb9a2 | ||
|
|
f76e714d37 | ||
|
|
53cd7c585d | ||
|
|
8479418021 | ||
|
|
309c830abb | ||
|
|
f4ab7e2bd3 | ||
|
|
94873e3e0c | ||
|
|
0e377c52ad | ||
|
|
66e4999a44 | ||
|
|
1cf505ebd1 | ||
|
|
25fcaa4351 | ||
|
|
f49d283282 | ||
|
|
f20e7679ef | ||
|
|
c93fd2c4dd | ||
|
|
2f4095e68a | ||
|
|
6233b3c805 | ||
|
|
52acb303c2 | ||
|
|
951703f486 | ||
|
|
7022064723 | ||
|
|
8dabc5c469 | ||
|
|
ac65cf4ec3 | ||
|
|
5e06e29de5 | ||
|
|
095e1cce4e | ||
|
|
bffe597ff0 | ||
|
|
601d5660b7 | ||
|
|
8bf2dda4d1 | ||
|
|
db226b321b | ||
|
|
f24596453e | ||
|
|
185cca68f2 | ||
|
|
db4801d787 | ||
|
|
337c159db0 | ||
|
|
20e7591f53 | ||
|
|
9d5d51936a | ||
|
|
c5cefc39a6 | ||
|
|
7f6b3a4cfe | ||
|
|
e538ae0b7f | ||
|
|
2593b1b18e | ||
|
|
40ca6a48a8 | ||
|
|
2cc52957b4 | ||
|
|
28aaa39d56 | ||
|
|
dfe30a3883 | ||
|
|
a74b7de5c4 | ||
|
|
f662973eb2 | ||
|
|
e72d77b21b | ||
|
|
b2c5b4f81e | ||
|
|
6c77cfccab | ||
|
|
34800f6991 | ||
|
|
e4b3c88f82 | ||
|
|
6a47436062 | ||
|
|
b31c642959 | ||
|
|
2ac7f835e2 | ||
|
|
c563d8a4a6 | ||
|
|
724d011417 | ||
|
|
53e22f0875 | ||
|
|
7b63b49324 | ||
|
|
0494a6d3c7 | ||
|
|
27cc091884 | ||
|
|
64b0d1f8a1 | ||
|
|
54b8bae797 | ||
|
|
5fb8f50e0a | ||
|
|
bd520de13f | ||
|
|
65e782a855 | ||
|
|
74d94d1f3e | ||
|
|
ce6c4dc374 | ||
|
|
2134876826 | ||
|
|
f0abd2116e | ||
|
|
ebf267908c | ||
|
|
4a64ef568b | ||
|
|
73632723f6 | ||
|
|
f5d57e35f1 | ||
|
|
8f61d57963 | ||
|
|
5bfe2842a1 | ||
|
|
4608168991 | ||
|
|
a74c95abc2 | ||
|
|
b7e4843cd7 | ||
|
|
9b97ca9164 | ||
|
|
4b255e8e9a | ||
|
|
41be25c713 | ||
|
|
301706dd54 | ||
|
|
746b42209f | ||
|
|
58d39546b1 | ||
|
|
a7fcf77247 | ||
|
|
e324fdb236 | ||
|
|
d678ba2f6b | ||
|
|
c24b601825 | ||
|
|
3d2b7c87b5 | ||
|
|
7dd7a9b4dd | ||
|
|
8d719c9821 | ||
|
|
f58f0b7c2a | ||
|
|
94ec6ecbb9 | ||
|
|
f7fd44618c | ||
|
|
3ddbe91e63 | ||
|
|
8360315345 | ||
|
|
6781eed94f | ||
|
|
cd082ca1cb | ||
|
|
6c7a89f8d9 | ||
|
|
b5b1a09952 | ||
|
|
767e089410 | ||
|
|
5cbf1ed90e | ||
|
|
9d8f86d191 | ||
|
|
f49a6ac2b2 | ||
|
|
92004893be | ||
|
|
3d294ce07c | ||
|
|
1b552d890c | ||
|
|
23d9c4809a | ||
|
|
9c2eb2cf64 | ||
|
|
0414fe2975 | ||
|
|
6529cf7d3e | ||
|
|
33072f27b3 | ||
|
|
ea3ccdbccd | ||
|
|
8417cba676 | ||
|
|
493d4ef01a | ||
|
|
fb5a9024db | ||
|
|
fbf347d851 | ||
|
|
3322f7377f | ||
|
|
a389074817 | ||
|
|
83d7e65e8f | ||
|
|
cb579a8a79 | ||
|
|
3662f6982d | ||
|
|
8813dd68c5 | ||
|
|
a5f94b0ef2 | ||
|
|
935974d230 | ||
|
|
4d42d6719e | ||
|
|
c093a93d34 | ||
|
|
68eebac230 | ||
|
|
345264d2f5 | ||
|
|
888d265469 | ||
|
|
e4a80c7de6 | ||
|
|
669fee5cba | ||
|
|
0b69af3e62 | ||
|
|
78d299e6b4 | ||
|
|
f766c8aec1 | ||
|
|
cabbfa297a | ||
|
|
bb9bcbf307 | ||
|
|
a4cb8e9305 | ||
|
|
f0d9f8cefb | ||
|
|
798a85fddf |
@@ -89,15 +89,25 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: go env for private modules (matches Dockerfile — zap-proto is direct+authenticated)
|
||||
- name: go env for private modules
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
# GOPRIVATE names exactly the namespace that is private. github.com/hanzoai/*
|
||||
# is: ai, account, commerce, orm, xorm, beego, csqlite and ~30 more are
|
||||
# private repos, so they must resolve direct+authenticated and skip a sumdb
|
||||
# that cannot see them. Everything else stays on the public proxy + checksum
|
||||
# db, which is what makes a module hash immutable: zap-proto (all 55 repos)
|
||||
# and luxfi (all 37 deps here) are public and proxy-served.
|
||||
#
|
||||
# This previously named zap-proto — public, and never the reason anything
|
||||
# here was direct — and then set GOSUMDB=off to compensate for hanzoai/*
|
||||
# being absent, which disabled checksum verification for EVERY module in the
|
||||
# build, public ones included. Naming the private namespace is what the off
|
||||
# switch was standing in for.
|
||||
run: |
|
||||
git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "https://github.com/"
|
||||
{
|
||||
echo "GOPRIVATE=github.com/zap-proto/*"
|
||||
echo "GONOSUMDB=github.com/zap-proto/*"
|
||||
echo "GOSUMDB=off"
|
||||
echo "GOPRIVATE=github.com/hanzoai/*"
|
||||
echo "GOPROXY=https://proxy.golang.org,direct"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
|
||||
@@ -96,9 +96,9 @@ jobs:
|
||||
# an image (even from a run that died before tagging) is never reused.
|
||||
cont_max=""
|
||||
if command -v gh >/dev/null 2>&1; then
|
||||
cont_max="$(GH_TOKEN="$GH_PAT" gh api --paginate \
|
||||
'/orgs/hanzoai/packages/container/cloud/versions' \
|
||||
--jq '.[].metadata.container.tags[]' 2>/dev/null \
|
||||
cont_max="$(GH_TOKEN="$GH_PAT" gh api \
|
||||
'/orgs/hanzoai/packages/container/cloud/versions?per_page=100' \
|
||||
--jq '.[].metadata.container.tags[]?' 2>/dev/null \
|
||||
| sed 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true)"
|
||||
fi
|
||||
|
||||
@@ -221,10 +221,10 @@ jobs:
|
||||
# frozen snapshot the persistent BuildKit cache would otherwise serve forever.
|
||||
build-args: |
|
||||
CONSOLE_CACHEBUST=${{ steps.ver.outputs.cachebust }}
|
||||
# gh_token: BuildKit secret the Dockerfile consumes to fetch private
|
||||
# GIT_AUTH_TOKEN: BuildKit secret the Dockerfile consumes to fetch private
|
||||
# cross-org Go modules (hanzoai/*, luxfi/*) over authenticated git.
|
||||
secrets: |
|
||||
gh_token=${{ secrets.GH_PAT }}
|
||||
GIT_AUTH_TOKEN=${{ secrets.GH_PAT }}
|
||||
|
||||
- name: Smoke test — the binary MUST boot to "listening" with no crash signature
|
||||
run: |
|
||||
@@ -293,6 +293,67 @@ jobs:
|
||||
|
||||
echo "SMOKE PASS: cloud:smoke booted to \"listening\" with no crash signature"
|
||||
|
||||
# ── Functional smoke — authenticated per-subsystem probe (the REAL gate) ─────
|
||||
# The boot check above proves the process REACHES "listening"; this proves the
|
||||
# mounted HTTP surface actually WORKS. /smoke (cmd/smoke, baked into the image)
|
||||
# hits ONE side-effect-free read per core subsystem and FAILS the release on any
|
||||
# broken code — above all a 402 on a READ (the balance-gate-over-blocks-reads
|
||||
# regression) or a 5xx (a crash, e.g. the /v1/billing/usage self-dispatch 500).
|
||||
# So a release can never ship with chat/billing/projects/kms/... down.
|
||||
- name: Functional smoke — per-subsystem probe (fails the release if a core endpoint is broken)
|
||||
env:
|
||||
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
|
||||
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}
|
||||
KMS_ENDPOINT: ${{ vars.KMS_ENDPOINT || 'https://kms.hanzo.ai' }}
|
||||
# A KMS-provisioned short-lived smoke bearer, injected as a secret (NEVER
|
||||
# hardcoded). Absent → the anonymous matrix still gates public/authed and
|
||||
# catches every 402-on-read / 5xx.
|
||||
SMOKE_TOKEN: ${{ secrets.SMOKE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IMAGE=cloud:smoke
|
||||
CID=""
|
||||
cleanup() { [ -n "$CID" ] && docker rm -f "$CID" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT
|
||||
|
||||
KEY="$(head -c 32 /dev/urandom | base64 | tr -d '\n')"
|
||||
CID="$(docker run -d \
|
||||
--tmpfs /data:rw,size=64m \
|
||||
-e CLOUD_DATA_DIR=/data -e CLOUD_ENV=smoke -e CLOUD_KMS_MASTER_KEY_REF="$KEY" \
|
||||
"$IMAGE")"
|
||||
|
||||
# Wait for the HTTP listener to bind (or the process to die).
|
||||
up=0
|
||||
for _ in $(seq 1 60); do
|
||||
lg="$(docker logs "$CID" 2>&1 || true)"
|
||||
printf '%s' "$lg" | grep -q '"message":"listening"' && { up=1; break; }
|
||||
[ "$(docker inspect -f '{{.State.Running}}' "$CID" 2>/dev/null || echo false)" != "true" ] && break
|
||||
sleep 1
|
||||
done
|
||||
if [ "$up" != 1 ]; then
|
||||
echo "::group::boot logs"; docker logs "$CID" 2>&1 || true; echo "::endgroup::"
|
||||
echo "FUNCTIONAL SMOKE INFRA FAIL: image never reached \"listening\""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Token: prefer the injected secret; else mint from KMS (a provisioned smoke
|
||||
# identity); else run the anonymous matrix. Never hardcoded.
|
||||
if [ -z "${SMOKE_TOKEN:-}" ] && [ -n "${KMS_CLIENT_ID:-}" ]; then
|
||||
KT=$(curl -sf "$KMS_ENDPOINT/v1/kms/auth/login" -H 'Content-Type: application/json' \
|
||||
-d "{\"clientId\":\"$KMS_CLIENT_ID\",\"clientSecret\":\"$KMS_CLIENT_SECRET\"}" | jq -r '.accessToken // empty' || true)
|
||||
[ -n "$KT" ] && SMOKE_TOKEN=$(curl -sf "$KMS_ENDPOINT/v1/kms/orgs/hanzo/secrets/smoke/TOKEN?env=prod" \
|
||||
-H "Authorization: Bearer $KT" | jq -r '.secret.value // empty' || true)
|
||||
fi
|
||||
if [ -n "${SMOKE_TOKEN:-}" ]; then echo "::add-mask::$SMOKE_TOKEN"; echo "smoke: AUTHENTICATED matrix"; else echo "smoke: ANONYMOUS matrix (no SMOKE_TOKEN wired)"; fi
|
||||
|
||||
# /smoke is baked into the image (Dockerfile) — exec it INSIDE the container,
|
||||
# so it probes the real mounted surface at localhost:8080 with no port/network
|
||||
# plumbing. A non-zero exit here fails the release BEFORE any image is pushed.
|
||||
docker exec \
|
||||
-e SMOKE_BASE_URL=http://127.0.0.1:8080 \
|
||||
-e SMOKE_TOKEN="${SMOKE_TOKEN:-}" \
|
||||
"$CID" /smoke
|
||||
|
||||
# ── Migration smoke — the gate the v1.800.1 crashloop would have tripped ─────
|
||||
# The plain smoke above boots on a FRESH /data, so every subsystem's migrate()
|
||||
# takes its CREATE-TABLE path and no forward-migration is exercised — which is
|
||||
@@ -406,7 +467,7 @@ jobs:
|
||||
build-args: |
|
||||
CONSOLE_CACHEBUST=${{ steps.ver.outputs.cachebust }}
|
||||
secrets: |
|
||||
gh_token=${{ secrets.GH_PAT }}
|
||||
GIT_AUTH_TOKEN=${{ secrets.GH_PAT }}
|
||||
|
||||
# THE RECEIPT + ATOMIC VERSION ASSIGNMENT (race-safe). Reached only because
|
||||
# build + smoke + push all succeeded, so a proven image exists under the unique
|
||||
@@ -434,11 +495,15 @@ jobs:
|
||||
git fetch --tags --force --quiet
|
||||
git_max="$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' \
|
||||
| sed 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true)"
|
||||
# Newest page only — NOT --paginate. Container versions are created
|
||||
# newest-first and version tags are monotonic, so the highest version
|
||||
# is always among the most-recent versions; paginating the WHOLE
|
||||
# registry history is what livelocked this step as tags accumulated.
|
||||
cont_max=""
|
||||
if command -v gh >/dev/null 2>&1; then
|
||||
cont_max="$(GH_TOKEN="$GH_PAT" gh api --paginate \
|
||||
'/orgs/hanzoai/packages/container/cloud/versions' \
|
||||
--jq '.[].metadata.container.tags[]' 2>/dev/null \
|
||||
cont_max="$(GH_TOKEN="$GH_PAT" gh api \
|
||||
'/orgs/hanzoai/packages/container/cloud/versions?per_page=100' \
|
||||
--jq '.[].metadata.container.tags[]?' 2>/dev/null \
|
||||
| sed 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true)"
|
||||
fi
|
||||
max="$(printf '%s\n%s\n%s\n' "1.786.0" "$git_max" "$cont_max" \
|
||||
@@ -454,12 +519,22 @@ jobs:
|
||||
-t "ghcr.io/hanzoai/cloud:${major}.${minor}" \
|
||||
"$SHA_IMG"
|
||||
# Dual-host: mirror the release tags to OUR fleet registry (server-
|
||||
# side copy) so the cluster never depends on GHCR to deploy. Best-
|
||||
# effort — a mirror hiccup must never block the tag-receipt.
|
||||
# side copy) so the cluster never depends on GHCR to deploy. crane,
|
||||
# not buildx imagetools: the IAM token realm doesn't answer buildx's
|
||||
# multi-scope token request (spec gap, tracked), crane's single-scope
|
||||
# flow works. Best-effort — a mirror hiccup never blocks the receipt.
|
||||
if [ "${MIRROR_OK:-}" = "1" ]; then
|
||||
command -v crane >/dev/null 2>&1 || {
|
||||
curl -fsSL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz \
|
||||
| tar -xz -C "$HOME/.local/bin" crane 2>/dev/null || {
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
curl -fsSL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz \
|
||||
| tar -xz -C "$HOME/.local/bin" crane
|
||||
}
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
}
|
||||
for MT in "${V}" "${VER}" "${major}.${minor}"; do
|
||||
docker buildx imagetools create \
|
||||
-t "registry.hanzo.ai/hanzoai/cloud:${MT}" "$SHA_IMG" \
|
||||
crane copy "$SHA_IMG" "registry.hanzo.ai/hanzoai/cloud:${MT}" \
|
||||
|| echo "::warning::mirror registry.hanzo.ai/hanzoai/cloud:${MT} failed"
|
||||
done
|
||||
fi
|
||||
@@ -477,25 +552,9 @@ jobs:
|
||||
echo "::error::could not acquire a free version tag after 8 attempts"
|
||||
exit 1
|
||||
|
||||
# Notify universe so the GitOps pipeline rolls the new image to prod — same
|
||||
# image-update contract every service uses (gateway, iam, …). Runs ONLY after
|
||||
# build-amd64 succeeds, i.e. only for a version whose image is proven pushed and
|
||||
# tagged. A failed release never reaches here, so universe is never asked to
|
||||
# deploy a phantom tag.
|
||||
notify-universe:
|
||||
needs: build-amd64
|
||||
runs-on: [hanzo-build-linux-amd64]
|
||||
steps:
|
||||
- name: Repository dispatch (image-update)
|
||||
uses: peter-evans/repository-dispatch@v3
|
||||
with:
|
||||
token: ${{ secrets.UNIVERSE_DISPATCH_TOKEN }}
|
||||
repository: hanzoai/universe
|
||||
event-type: image-update
|
||||
client-payload: |
|
||||
{
|
||||
"service": "cloud",
|
||||
"image": "ghcr.io/hanzoai/cloud:${{ needs.build-amd64.outputs.version_v }}",
|
||||
"sha": "${{ github.sha }}",
|
||||
"env": "all"
|
||||
}
|
||||
# Deploy = a declared-tag bump in hanzoai/universe crs/cloud.yaml — Hanzo CD
|
||||
# (the ArgoCD instance in ns hanzo-cd) syncs universe→cluster and the operator
|
||||
# reconciles the CR. The old notify-universe repository_dispatch hub is retired
|
||||
# (its flagged-sender dispatches were silently suppressed anyway); the native
|
||||
# release path (release.go rolloutRelease) and deliberate promote commits own
|
||||
# the bump.
|
||||
|
||||
@@ -34,3 +34,4 @@ Thumbs.db
|
||||
.shots/
|
||||
|
||||
.claude/
|
||||
native/flags/target
|
||||
|
||||
+73
-129
@@ -2,122 +2,50 @@
|
||||
#
|
||||
# This image is a SINGLE artifact that serves BOTH the /v1 API AND the console
|
||||
# UI from one process: the console is compiled into the Go binary via
|
||||
# //go:embed (see webui.go). The pipeline is:
|
||||
# //go:embed (see webui.go). The final `/cloud` binary already carries the UI —
|
||||
# no separate console Service, no second origin; the embedded console calls /v1
|
||||
# on its own host.
|
||||
#
|
||||
# 1. console stage → build the hanzoai/console static bundle
|
||||
# 2. (copied) → into webui/dist/ of the Go build context
|
||||
# 3. build stage → `go build` bakes webui/dist into the binary (go:embed)
|
||||
#
|
||||
# so the final `/cloud` binary already carries the UI. No separate console
|
||||
# Service, no second origin — the embedded console calls /v1 on its own host.
|
||||
#
|
||||
# ── console UI stage ─────────────────────────────────────────────────────────
|
||||
# Builds the console SPA and emits a STATIC bundle at /out. console is fetched
|
||||
# at a pinned ref (CONSOLE_REF) using the same gh_token BuildKit secret the Go
|
||||
# build uses for private modules.
|
||||
#
|
||||
# console exposes `npm run build:embed` (scripts/build-embed.mjs): it prunes the
|
||||
# Next server route handlers (BFF proxies — they collapse to the cloud /v1/* the
|
||||
# SPA calls same-origin), wraps the client catch-all pages for output:'export',
|
||||
# and neutralizes the root layout's request-time headers() read (the per-host
|
||||
# <title>, resolved client-side in the embed) so the STATIC export prerenders
|
||||
# clean — emitting out/. This stage runs it and copies out/ into /out, which the
|
||||
# Go build drops into webui/dist so //go:embed bakes the FULL @hanzo/gui console
|
||||
# into the ONE binary. This stage FAILS HARD: the prod image MUST carry the real
|
||||
# console — a missing/broken build:embed is a build ERROR, never a silent degrade
|
||||
# to the placeholder shell. The one escape hatch is --build-arg ALLOW_PLACEHOLDER=1
|
||||
# (pure-Go dev image with no Node console), which is NEVER set for prod.
|
||||
FROM public.ecr.aws/docker/library/node:24-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd AS console
|
||||
ARG CONSOLE_REPO=https://github.com/hanzoai/console.git
|
||||
ARG CONSOLE_REF=main
|
||||
# CONSOLE_CACHEBUST busts this stage's BuildKit layer cache every build. WHY it must
|
||||
# exist: the clone+build layer's cache key is derived from the RUN text + build args.
|
||||
# With only a static `git clone --branch main`, the key NEVER changes, so on the
|
||||
# persistent ARC dind BuildKit cache every cloud image re-embedded the SAME frozen
|
||||
# console snapshot — new console work (the native Tracker, …) silently never shipped,
|
||||
# even on a freshly-built+deployed image. release.yml feeds this the cloud commit sha
|
||||
# (unique per push) so the clone RUN re-runs each build and re-fetches console
|
||||
# ${CONSOLE_REF} (main HEAD) fresh. Correctness over cache reuse: the console stage
|
||||
# rebuilds every time, but the embed is never stale.
|
||||
ARG CONSOLE_CACHEBUST=none
|
||||
RUN apk add --no-cache git
|
||||
WORKDIR /console
|
||||
# The static export prerenders every page (webpack compile + export prerender);
|
||||
# give the heap headroom so a large @hanzo/gui build never OOMs into the stub.
|
||||
ENV NEXT_TELEMETRY_DISABLED=1 NODE_OPTIONS=--max-old-space-size=8192
|
||||
# Hanzo Analytics: the console's <HanzoAnalytics/> (env-gated) renders the one
|
||||
# native analytics.hanzo.ai tag only when a website-id is baked in. Default to the
|
||||
# console.hanzo.ai property (7dce54ee, public per-site) so console+team track on
|
||||
# the next cloud build. GA4/Pixel stay off (unset). Public id, not a KMS secret.
|
||||
ARG NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=7dce54ee-41f6-4751-96bf-fe005067c7c7
|
||||
ENV NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=$NEXT_PUBLIC_ANALYTICS_WEBSITE_ID
|
||||
RUN --mount=type=secret,id=gh_token \
|
||||
if [ -s /run/secrets/gh_token ]; then \
|
||||
git config --global url."https://x-access-token:$(cat /run/secrets/gh_token)@github.com/".insteadOf "https://github.com/"; \
|
||||
fi && \
|
||||
echo ">> embedding console ${CONSOLE_REF} (cachebust ${CONSOLE_CACHEBUST})" && \
|
||||
git clone --depth 1 --branch "${CONSOLE_REF}" "${CONSOLE_REPO}" . && \
|
||||
echo ">> console @ $(git rev-parse HEAD)" && \
|
||||
npm install --no-audit --no-fund --fetch-retries=5 --fetch-retry-mintimeout=20000 --fetch-timeout=120000
|
||||
# FAIL-HARD. build:embed MUST emit a REAL bundle — a non-empty out/index.html AND
|
||||
# an out/_next/ chunk dir — and /out then carries it into the Go embed path. If the
|
||||
# target is absent, the export fails, or the output is the placeholder shape, this
|
||||
# is a build ERROR (exit 1): the prod image can NEVER silently ship the committed
|
||||
# fallback shell. Escape hatch: --build-arg ALLOW_PLACEHOLDER=1 leaves /out empty
|
||||
# (Go build keeps the committed shell) for a pure-Go dev image — NEVER set in prod.
|
||||
ARG ALLOW_PLACEHOLDER=0
|
||||
RUN mkdir -p /out; \
|
||||
ok=0; \
|
||||
if npm run 2>/dev/null | grep -q ' build:embed'; then \
|
||||
if npm run build:embed && [ -s out/index.html ] && [ -d out/_next ]; then \
|
||||
cp -r out/. /out/; \
|
||||
echo ">> embedded REAL console static bundle: $(wc -c < out/index.html)-byte index.html, $(du -sh out/_next | cut -f1) _next/"; \
|
||||
ok=1; \
|
||||
else \
|
||||
echo ">> console build:embed produced NO real bundle (missing/empty out/index.html or out/_next)"; \
|
||||
fi; \
|
||||
else \
|
||||
echo ">> console exposes no build:embed target"; \
|
||||
fi; \
|
||||
if [ "$ok" != "1" ]; then \
|
||||
if [ "$ALLOW_PLACEHOLDER" = "1" ]; then \
|
||||
echo ">> ALLOW_PLACEHOLDER=1 — keeping committed fallback shell (DEV image only; NEVER prod)"; \
|
||||
else \
|
||||
echo ">> FATAL: refusing to ship the placeholder console. Fix the console build:embed, or pass --build-arg ALLOW_PLACEHOLDER=1 for a pure-Go dev image."; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
fi
|
||||
# ── prebuilt decomplection artifacts (cloud compiles ONLY Go) ────────────────
|
||||
# The console SPA, the agent-skills catalog, and the native flags staticlib are
|
||||
# each built by THEIR OWN CI as a versioned immutable image and PULLED here,
|
||||
# instead of rebuilding node + python + rust from scratch every cloud release.
|
||||
# The heavy one (console: a cold `npm install` + full Next.js static export,
|
||||
# force-cache-busted every build) used to dominate the ~20-min build; it is now
|
||||
# a registry pull.
|
||||
# console-embed (hanzoai/console Dockerfile.embed) → /dist → webui/dist (go:embed)
|
||||
# agent-skills (hanzoai/openapi Dockerfile.skills) → /catalog → clients/agentskills/catalog (go:embed)
|
||||
# cloud-flags (native/flags Dockerfile) → /libhanzo_flags.a → CGO link (clients/featureflags)
|
||||
# Pinned to ghcr.io so BOTH buildx lanes (release.yml + platform arcbuild) pull
|
||||
# it directly; the SAME tags are mirrored to registry.hanzo.ai (S3-backed) for
|
||||
# GET-flow consumers (docker/kaniko/crane). Override any pin with
|
||||
# --build-arg <NAME>_IMAGE=… — release.yml resolves CONSOLE_IMAGE to a fresh
|
||||
# console-embed digest, exactly as CONSOLE_CACHEBUST re-fetched console before.
|
||||
ARG CONSOLE_IMAGE=ghcr.io/hanzoai/console-embed:latest
|
||||
ARG SKILLS_IMAGE=ghcr.io/hanzoai/agent-skills:latest
|
||||
ARG FLAGS_IMAGE=ghcr.io/hanzoai/cloud-flags:latest
|
||||
|
||||
# ── Go build stage (CGO=1 + SQLCipher — REAL at-rest encryption) ─────────────
|
||||
# The unified binary embeds IAM (clients/iam) whose per-org store is SQLCipher-
|
||||
# encrypted (orgIsolation=sqlite), and commerce's per-tenant money DBs likewise.
|
||||
# A CGO=0 modernc build SILENTLY SHIPS PLAINTEXT. So this builds CGO=1 against
|
||||
# system libsqlcipher — hanzoai/iam's proven recipe: the `libsqlite3` tag + a
|
||||
# libsqlcipher symlink + -DSQLITE_HAS_CODEC, with the modernc double-registration
|
||||
# guard, TestEncryptionProof, and the cek.go golden-vector KAT baked in — so a
|
||||
# build that fails to link REAL SQLCipher, or that would decrypt existing stores
|
||||
# differently, produces NO image. alpine3.22 MATCHES the runtime base so the
|
||||
# libsqlcipher soname the binary links is the SAME one present at runtime. ECR
|
||||
# Public mirror avoids Docker Hub's 429 rate-limit on shared CI runners.
|
||||
# ---- agent-skills stage: regenerate the FULL /.well-known/agent-skills catalog
|
||||
# from the hanzoai/openapi SOT (skills.py) and carry it into the Go embed path
|
||||
# BEFORE `go build`, the SAME way the console bundle is produced. The committed
|
||||
# catalog is only the tiny `ai` fallback; prod must embed the full set. FAIL-HARD:
|
||||
# if the clone/generation can't produce the master index, the image is not built.
|
||||
FROM public.ecr.aws/docker/library/python:3.12-alpine AS skills
|
||||
ARG OPENAPI_REPO=https://github.com/hanzoai/openapi.git
|
||||
ARG OPENAPI_REF=main
|
||||
RUN apk add --no-cache git && pip install --no-cache-dir pyyaml
|
||||
WORKDIR /openapi
|
||||
RUN --mount=type=secret,id=gh_token \
|
||||
if [ -s /run/secrets/gh_token ]; then \
|
||||
git config --global url."https://x-access-token:$(cat /run/secrets/gh_token)@github.com/".insteadOf "https://github.com/"; \
|
||||
fi && \
|
||||
git clone --depth 1 --branch "${OPENAPI_REF}" "${OPENAPI_REPO}" . && \
|
||||
python3 skills.py --no-services --out /catalog && \
|
||||
test -s /catalog/hanzo/index.json
|
||||
# ── toolchain base images: the golang + alpine FROMs below pull from our own
|
||||
# GHCR mirror (ghcr.io/hanzoai/mirror/*), pinned by digest. WHY: public.ecr.aws
|
||||
# rate-limits anonymous pulls (HTTP 429) on shared CI runners and a 429 on ANY
|
||||
# base pull aborts the release. The mirror packages are 1:1 amd64 copies of the
|
||||
# upstream public images, digest-pinned for immutability; release.yml logs the
|
||||
# build into ghcr.io (GH_PAT) before building so they resolve. REFRESH on a
|
||||
# toolchain bump: crane/regctl copy the new upstream into
|
||||
# ghcr.io/hanzoai/mirror/<name>:<tag> and repoint the digest below. Canonical
|
||||
# long-term home is registry.hanzo.ai/hanzoai/mirror/* — repoint once the runners
|
||||
# carry its IAM pull credentials (follow-up).
|
||||
|
||||
FROM public.ecr.aws/docker/library/golang:1.26-alpine3.22@sha256:727cfc3c40be55cd1bc9a4a059406b28a059857e3be752aa9d09531e12c20c56 AS build
|
||||
# ── console SPA static export (prebuilt → /dist) ─────────────────────────────
|
||||
FROM ${CONSOLE_IMAGE} AS console
|
||||
|
||||
# ── agent-skills catalog (prebuilt → /catalog) ──────────────────────────────
|
||||
FROM ${SKILLS_IMAGE} AS skills
|
||||
|
||||
# ── native flags evaluator staticlib (prebuilt → /libhanzo_flags.a) ──────────
|
||||
FROM ${FLAGS_IMAGE} AS flagslib
|
||||
|
||||
FROM ghcr.io/hanzoai/mirror/golang:1.26-alpine3.22@sha256:47d47cb5cc3c7dac409dcb6c3a98a6263571218046cd02d709527feef804a77c AS build
|
||||
# CIPHER-FORMAT FREEZE (cek depends on this). The data-plane stores are
|
||||
# SQLCipher pages in a fixed on-disk format (cipher_compatibility 4). An at-open
|
||||
# compat pin is infeasible (mattn keys via URI before any pragma), so the format
|
||||
@@ -138,18 +66,21 @@ RUN set -eux; \
|
||||
ln -sf "$SC" /usr/lib/libsqlite3.so; \
|
||||
ln -sf "$SC" /usr/lib/libsqlite3.so.0
|
||||
WORKDIR /src
|
||||
# hanzoai/* and luxfi/* are PUBLIC and resolve via the IMMUTABLE public proxy +
|
||||
# sumdb — go.sum pins those canonical hashes, so a force-re-pointed tag can never
|
||||
# break the build. GOSUMDB stays ON (a money image must not blanket-disable the
|
||||
# checksum database); only zap-proto/* is exempt (first-party-direct via GOPRIVATE,
|
||||
# authenticated git over gh_token). -mod=readonly means the committed go.sum is the
|
||||
# SOLE source of truth: any drift (a needed hash not present) FAILS the build
|
||||
# instead of being silently re-recorded. CGO_CFLAGS/LDFLAGS enable the SQLCipher
|
||||
# codec + URI keying.
|
||||
# zap-proto/* (all 55 repos) and luxfi/* (all 37 deps here) are PUBLIC and resolve
|
||||
# via the IMMUTABLE public proxy + sumdb — go.sum pins those canonical hashes, so a
|
||||
# force-re-pointed tag can never break the build. GOSUMDB stays ON (a money image
|
||||
# must not blanket-disable the checksum database); github.com/hanzoai/* is the
|
||||
# exempt namespace — ai, account, commerce, orm, xorm, beego, csqlite and ~30 more
|
||||
# are PRIVATE repos, so they resolve direct+authenticated (git over gh_token) and
|
||||
# skip a sumdb that cannot see them. GOPRIVATE named zap-proto until now, which is
|
||||
# public and was never the reason anything was direct; the private namespace it
|
||||
# stood for went unnamed and worked only on the GOPROXY `direct` fallback.
|
||||
# -mod=readonly means the committed go.sum is the SOLE source of truth: any drift
|
||||
# (a needed hash not present) FAILS the build instead of being silently
|
||||
# re-recorded. CGO_CFLAGS/LDFLAGS enable the SQLCipher codec + URI keying.
|
||||
ENV CGO_CFLAGS="-DSQLITE_HAS_CODEC -DSQLITE_USE_URI=1 -I/usr/include/sqlcipher" \
|
||||
CGO_LDFLAGS="-lsqlcipher" \
|
||||
GOPRIVATE=github.com/zap-proto/* \
|
||||
GONOSUMDB=github.com/zap-proto/* \
|
||||
GOPRIVATE=github.com/hanzoai/* \
|
||||
GOPROXY=https://proxy.golang.org,direct \
|
||||
GOFLAGS=-mod=readonly
|
||||
COPY go.mod go.sum ./
|
||||
@@ -160,19 +91,22 @@ COPY go.mod go.sum ./
|
||||
# and resolves fine from a clean cache. That is exactly what wedged the release
|
||||
# on otel-collector v0.144.10. BUMP THE SUFFIX (-v4 -> -v5) to force a cold
|
||||
# module cache the next time a phantom pin poisons it.
|
||||
RUN --mount=type=secret,id=gh_token \
|
||||
RUN --mount=type=secret,id=GIT_AUTH_TOKEN \
|
||||
--mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
|
||||
if [ -s /run/secrets/gh_token ]; then \
|
||||
git config --global url."https://x-access-token:$(cat /run/secrets/gh_token)@github.com/".insteadOf "https://github.com/"; \
|
||||
if [ -s /run/secrets/GIT_AUTH_TOKEN ]; then \
|
||||
git config --global url."https://x-access-token:$(cat /run/secrets/GIT_AUTH_TOKEN)@github.com/".insteadOf "https://github.com/"; \
|
||||
fi && \
|
||||
go mod download
|
||||
COPY . .
|
||||
# Drop the console static bundle into the embed path BEFORE `go build`, so
|
||||
# //go:embed all:webui/dist bakes it into the binary (same-origin console).
|
||||
COPY --from=console /out/ /src/webui/dist/
|
||||
COPY --from=console /dist/ /src/webui/dist/
|
||||
# Overlay the FULL agent-skills catalog before `go build` so //go:embed all:catalog
|
||||
# bakes the complete set (all services × brands), not the committed `ai` fallback.
|
||||
COPY --from=skills /catalog/ /src/clients/agentskills/catalog/
|
||||
# The native flags staticlib at the exact ${SRCDIR}-relative path the cgo
|
||||
# directive in clients/featureflags/engine.go links.
|
||||
COPY --from=flagslib /libhanzo_flags.a /src/native/flags/target/release/libhanzo_flags.a
|
||||
# RED gate — modernc double-registration guard: 0 modernc under CGO=1, else the
|
||||
# "sqlite" driver is registered twice (mattn + modernc) → panic at init.
|
||||
RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
|
||||
@@ -201,12 +135,18 @@ RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
|
||||
RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
|
||||
--mount=type=cache,id=cloud-gobuild-v4,target=/root/.cache/go-build,sharing=locked \
|
||||
CGO_ENABLED=1 go build -tags "libsqlite3 sqlite_fts5" -ldflags="-s -w" -o /cloud ./cmd/cloud
|
||||
# The functional smoke prober (cmd/smoke) — a stdlib-only, static binary shipped
|
||||
# alongside /cloud so the release gate can `docker exec` it against the freshly-built
|
||||
# image (and any deployment can be smoked via `docker run --entrypoint /smoke ...`).
|
||||
RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
|
||||
--mount=type=cache,id=cloud-gobuild-v4,target=/root/.cache/go-build,sharing=locked \
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o /smoke ./cmd/smoke
|
||||
# Prove the SHIPPED binary binds sqlite3_* to libsqlcipher, not a plaintext libsqlite3.
|
||||
RUN readelf -d /cloud | grep -qE 'NEEDED.*(sqlcipher|sqlite3)' || { echo "FATAL: /cloud links no sqlite/sqlcipher .so"; exit 1; }; \
|
||||
! ldd /cloud 2>/dev/null | grep -E 'libsqlite3' | grep -vq 'libsqlcipher' || { echo "FATAL: /cloud resolves a NON-sqlcipher libsqlite3 (plaintext risk)"; exit 1; }
|
||||
|
||||
# ── final image (alpine, NOT scratch — CGO needs libc + libsqlcipher) ─────────
|
||||
FROM public.ecr.aws/docker/library/alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
|
||||
FROM ghcr.io/hanzoai/mirror/alpine:3.22@sha256:7c8cb692ae09657cbc4a3f3cbd0e8d5a2690ba38386aaaf252dbb060bf5eb2e6
|
||||
ARG REVISION=unknown
|
||||
LABEL org.opencontainers.image.revision="${REVISION}" \
|
||||
org.opencontainers.image.source="https://github.com/hanzoai/cloud"
|
||||
@@ -220,7 +160,10 @@ LABEL org.opencontainers.image.revision="${REVISION}" \
|
||||
# (upload-pack / receive-pack --stateless-rpc / fetch) so multi-GB packs stream
|
||||
# to and from disk with bounded memory instead of buffering whole packs in RAM.
|
||||
# The `git` apk package carries upload-pack/receive-pack/http-backend/git-remote-https.
|
||||
RUN apk add --no-cache ca-certificates tzdata sqlcipher-libs git \
|
||||
# libgcc: the hanzo-flags Rust staticlib (clients/featureflags FFI) references the
|
||||
# _Unwind_* unwinder symbols; musl needs libgcc_s at load time or the binary fails
|
||||
# relocation ("Error relocating /cloud: _Unwind_GetIP: symbol not found").
|
||||
RUN apk add --no-cache ca-certificates tzdata sqlcipher-libs git libgcc \
|
||||
&& SC="$(find /usr/lib /lib -name 'libsqlcipher.so*' 2>/dev/null | sort | head -1)" \
|
||||
&& test -n "$SC" \
|
||||
&& ln -sf "$SC" /usr/lib/libsqlite3.so.0
|
||||
@@ -229,6 +172,7 @@ COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo
|
||||
COPY --from=build /etc/passwd /etc/passwd
|
||||
COPY --from=build /etc/group /etc/group
|
||||
COPY --from=build /cloud /cloud
|
||||
COPY --from=build /smoke /smoke
|
||||
EXPOSE 8080 9090 9653
|
||||
USER 65532:65532
|
||||
ENTRYPOINT ["/cloud"]
|
||||
|
||||
@@ -12,25 +12,34 @@ One way to do everything. Composable, orthogonal, DRY. A new subsystem is a
|
||||
package under `clients/<name>` that obeys these seams — nothing more.
|
||||
|
||||
- **Subsystem shape.** A subsystem exposes `func Mount(app *zip.App, deps cloud.Deps) error`
|
||||
and self-registers at init with `cloud.Register("<name>", <order>, cloud.Typed(Mount))`
|
||||
(or `RegisterWithShutdown`). `Mount` wires that subsystem's `/v1/<name>/*` routes
|
||||
onto the shared `*zip.App`; `cloud.Deps` carries the process-wide handles
|
||||
(Logger, DataDir, the subsystem `Client` seams). No subsystem reaches into
|
||||
another's internals.
|
||||
and is listed in `apps.Wire()` as a `cloud.MountSpec{Name, Mount: cloud.Typed(Mount)}`
|
||||
(plus `Shutdown`/`OwnsHealth` where it owns them). `Mount` wires that subsystem's
|
||||
`/v1/<name>/*` routes onto the shared `*zip.App`; `cloud.Deps` carries the
|
||||
process-wide handles (Logger, DataDir, the subsystem `Client` seams). No
|
||||
subsystem reaches into another's internals. There is no init()-registry and no
|
||||
`cloud.Register` — subsystems do NOT self-register.
|
||||
- **Client seams.** Cross-subsystem calls go through a narrow in-process interface
|
||||
published in `types` and aliased at the provider, e.g. `commerce.Client =
|
||||
types.CommerceClient` (`GetOrgConfig` + `CheckEntitlement`). Consumers depend on
|
||||
the interface, never the implementation; the seam rides zap-proto/zip. Keep each
|
||||
interface minimal — add a method only when a consumer needs it.
|
||||
- **Composition root.** `subsystems/subsystems.go` blank-imports every subsystem
|
||||
(its init runs `cloud.Register`), populating `cloud.Registry`. `MountAll`
|
||||
(build.go) sorts the registry by `Order` and calls `Mount` on each ENABLED
|
||||
subsystem (`cfg.Enabled`). That ordered blank-import set IS the wiring — there
|
||||
is no separate `Wire()` function; to add a subsystem you add one import line.
|
||||
- **Route precedence is a framework guarantee.** The router is zap-proto/fiber
|
||||
(zip v1.3.0). Most-specific route wins regardless of mount order; a genuine
|
||||
route CONFLICT panics at mount rather than resolving ambiguously. Subsystems may
|
||||
therefore mount in any order and still compose deterministically.
|
||||
- **Composition root.** `apps/apps.go:Wire()` returns `[]cloud.MountSpec` — every
|
||||
linked subsystem, in mount order, as ONE explicit slice read top-to-bottom.
|
||||
Slice position IS the order: there is no `Order` field and `MountAll`
|
||||
(build.go) does NOT sort; it iterates as-given and mounts each ENABLED spec
|
||||
(`cfg.Enabled`). To add a subsystem you add one line to `Wire()`.
|
||||
`apps/wire_test.go` freezes the sequence, so a reorder/drop/add fails there.
|
||||
- **Route precedence.** The router is zap-proto/fiber (zip v1.8.3). Most-specific
|
||||
route wins regardless of mount order, so subsystems may mount in any order and
|
||||
still compose deterministically. But precedence is NOT a conflict guard: two
|
||||
registrations of a byte-identical pattern do NOT panic — fiber MERGES them into
|
||||
ONE route with both handlers chained, resolving by first-registration. That is
|
||||
invisible to a `GetRoutes()` entry count (see the bots note below), and it is
|
||||
NOT distinguishable from a legitimate middleware chain: `app.Post(path, mw1,
|
||||
mw2, mw3, handler)` is one registration with four handlers (apps/commerce.go:151),
|
||||
and the whole `/v1/store/*` surface is that shape. A high handler count is
|
||||
therefore evidence of nothing on its own; only a subsystem that never chains
|
||||
middleware (bots/visor/runtime) can read `len(Handlers) > 1` as a collision.
|
||||
- **Per-org data.** The ONE way any subsystem opens a per-org SQLite file is
|
||||
`cloud.OrgDB(dataDir, org, project, sub)` — or the cached `cloud.OrgStore[T]`
|
||||
(`NewOrgStore` + `For(org, project)`). Path convention:
|
||||
@@ -42,6 +51,51 @@ package under `clients/<name>` that obeys these seams — nothing more.
|
||||
the SOLE driver (blank-imported once, in orgdb.go); subsystems never import a
|
||||
SQLite driver themselves. The caller owns its schema/migration and Close.
|
||||
|
||||
## The route table has three projections, and the router is the source
|
||||
|
||||
`serve.go` composes ONE route table and projects it three ways, all after
|
||||
`MountAll` so each sees a complete table: `/zap` REPLAYS the /v1 handlers
|
||||
(zapface), the console RENDERS them, and `GET /v1/openapi.json` DESCRIBES them
|
||||
(`openapi.Mount`). None holds a second copy of anything; none can drift.
|
||||
|
||||
- **The spec IS the router.** `openapi.Live(app)` reads
|
||||
`app.Fiber().GetRoutes(true)` — fiber's own filter drops `Use()` middleware —
|
||||
and every other function in `openapi/` is a pure function of that `[]Route`.
|
||||
There is NO checked-in spec file to hand-maintain and no second registry. The
|
||||
drift guard is `cmd/cloud/openapi_test.go`: a BIJECTION over the fully-mounted
|
||||
`apps.Wire()` (983 operations / 692 paths / 109 products) — every live route
|
||||
appears as an operation, every operation is backed by a live route. It is the
|
||||
only test whose failure means the document lies.
|
||||
- **Reading the LIVE router is the only total source.** `POST /v1/kms/auth/login`
|
||||
is registered as `Group("/v1/kms/auth").Post("/login")` — no grep can find that
|
||||
path; only the assembled router knows it. And the route set is a function of
|
||||
deployment config (`cfg.Enabled`, plus internal gates like kms's `if kc != nil`),
|
||||
so **the spec VARIES PER DEPLOYMENT** — correctly: a deployment that does not
|
||||
mount admin does not advertise it. That is why the document is generated
|
||||
per-process at request time, not built once in CI.
|
||||
- **The product axis is mechanical.** The first path segment after `/v1/` IS the
|
||||
product (`openapi.Product`), tagged onto each operation so a CLI can build
|
||||
`hanzo <product> <resource> <verb>` with no judgment. It is deliberately NOT the
|
||||
subsystem name: `clients/billing` also serves `/v1/finance/*`.
|
||||
- **What the router CANNOT tell you — do not try to fix this in the generator.**
|
||||
Method, path, path params, and product are derivable; request/response schemas,
|
||||
query/header params, status codes, and auth are NOT. The router holds a
|
||||
`func(*zip.Ctx) error`; the request type is a LOCAL inside the handler
|
||||
(`var req secretPutRequest; json.Unmarshal(ctx.Body(), &req)`), and Go cannot
|
||||
reflect from a func value into its body. `cloud.Handle[S]` does not help — `S`
|
||||
is the SERVICE (service.go:90), not the payload; `cloud.Typed` is an
|
||||
`any→*zip.App` mount adapter. The ONE path to schemas is zip's typed ops
|
||||
(`zip.Get[In,Out]`), which carry the In/Out types and also yield an MCP tool
|
||||
from the same registry (zip/openapi.go, zip/mcp.go — today `len(a.ops) == 0`,
|
||||
so zip's own generator emits nothing here). `GetRoutes()` is a superset of
|
||||
`app.ops`, so migrating a handler to a typed op adds schema without changing
|
||||
this pipeline.
|
||||
- **Catch-alls are opaque, by construction.** `app.Post("/v1/billing/*")` proxies
|
||||
to another service, so `POST /v1/billing/deposit` is NOT a route in this process
|
||||
and cannot appear. Measured on the live table: 3 products are wholly opaque
|
||||
(`bot`, `licensing`, `sentry` — the catch-all IS the product) and 12 more mix
|
||||
concrete ops with a catch-all hiding an unknown remainder.
|
||||
|
||||
## Cross-subsystem seams that are values, not places
|
||||
|
||||
- **The per-principal MCP plane is callable in-process.** `clients/automations`
|
||||
@@ -53,6 +107,36 @@ package under `clients/<name>` that obeys these seams — nothing more.
|
||||
calls `InvokeTool` with `principal.Org(c)` — same 403 gate, per-org concurrency
|
||||
bound, one metered unit, one audit record as the HTTP door — so it can never
|
||||
exceed the caller's authority. Use this seam; never re-implement tool dispatch.
|
||||
- **"Bot" is three values; each has one home and one namespace.** Do not merge
|
||||
them and do not let them share a route prefix — they did once, and the router
|
||||
resolves byte-identical patterns by first-registration with no panic (it MERGES
|
||||
the handlers, so counting `GetRoutes()` entries cannot see it), and visor's
|
||||
machine list silently answered the console's run list.
|
||||
(1) A bot RUN — a task the runtime executes on a surface — is `clients/bots` at
|
||||
`/v1/bots`. (2) A bot MACHINE — visor-provisioned compute of kind=bot plus its
|
||||
agent binding — is `clients/visor` at `/v1/compute/bots`; what it rents you is
|
||||
compute, so it nests in visor's domain. (3) The runtime SERVICE — the TS bot
|
||||
(channels/skills), never reimplemented in Go — is reached through
|
||||
`clients/runtime`, which is a TRANSPORT, not a domain: base address, identity,
|
||||
framing, cleartext policy, and the `/v1/bot/*` ops face. It is named for what it
|
||||
does, not for the host it dials, and it must never import `bots`/`coding` — each
|
||||
of those owns its own wire stub (`bots/wire.go`, `coding/task.go`) and speaks
|
||||
through the seam. That isolation is what makes the HIP-0106/HIP-0120 ZAP swap a
|
||||
seam swap instead of a rewrite.
|
||||
- **Cloud owns policy; the runtime owns the run. Do not copy state you do not
|
||||
own.** `clients/bots` holds NO store. The sandbox lives in the bot runtime,
|
||||
keyed in the runtime's own tenant store, which is the only thing that knows
|
||||
whether a run is alive — so list and stop PROXY it, gated by cloud's
|
||||
principal/org. A cloud-side registry was tried and was wrong: it minted an id
|
||||
the runtime had never heard of, so it listed runs that did not exist and
|
||||
"stopped" runs that were never started. Isolation holds because the org is the
|
||||
validated one cloud sends, never a client's, and the runtime keys every run
|
||||
under `tenants/{org}/`.
|
||||
- **Absence is only meaningful from a callee that could have said otherwise.**
|
||||
`runtime.ErrNotFound` (the operation ANSWERED "no such target") is separate from
|
||||
`runtime.ErrNotServed` (the operation does not exist). Conflating them makes a
|
||||
stop that cannot fail: a runtime without the route reports absent for EVERY run,
|
||||
so "already gone" becomes permanently true. A bare 404 is 502, never success.
|
||||
- **The Business AI Guide (`clients/guide`, `/v1/guide/*`)** is the on-site launch
|
||||
checklist: a pure engine (`curriculum.go` — parse/validate/next-step/dependency
|
||||
gating over plain data) + per-org progress (`cloud.OrgStore[*Store]`) + an
|
||||
|
||||
@@ -24,7 +24,7 @@ OPENAPI_DIR ?= ../openapi
|
||||
# forces the fork to modernc too so the whole binary registers "sqlite" once.
|
||||
CGO_ENABLED ?= 0
|
||||
|
||||
.PHONY: help webui agentskills build build-standalone hanzo run smoke test test-cgo vet tidy docker docker-push clean
|
||||
.PHONY: help native webui agentskills build build-standalone hanzo run smoke test test-cgo vet tidy docker docker-push clean
|
||||
|
||||
help: ## Show this help.
|
||||
@awk 'BEGIN{FS=":.*##";printf "\nUsage: make <target>\n\nTargets:\n"} /^[a-zA-Z_-]+:.*##/{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
@@ -84,3 +84,6 @@ docker-push: docker ## Push the Docker image to ghcr.io. Requires docker login.
|
||||
|
||||
clean: ## Remove built artifacts.
|
||||
rm -rf bin
|
||||
|
||||
native: ## Build the native flags evaluator staticlib (required for CGO=1 builds/tests).
|
||||
cargo build --release --manifest-path native/flags/Cargo.toml
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package subsystems is the composition root: the single, explicit list of which
|
||||
// Package apps is the composition root: the single, explicit list of which
|
||||
// Hanzo cloud subsystems are linked into the binary AND the order they mount in.
|
||||
//
|
||||
// Wire() returns []cloud.MountSpec in mount order (slice position == order). There
|
||||
@@ -27,11 +27,14 @@
|
||||
// composition root fixes). They are wired back at their order-int slots — o11y-ext
|
||||
// kept adjacent to the in-repo o11y read-plane (@69); ai as the last /v1/* catch-all
|
||||
// before plugins (@900). Do not re-sort; edit positions deliberately.
|
||||
package subsystems
|
||||
//
|
||||
//go:generate go run ../cmd/gen-app-cmds
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
@@ -43,7 +46,6 @@ import (
|
||||
"github.com/hanzoai/authz"
|
||||
"github.com/hanzoai/licensing"
|
||||
"github.com/hanzoai/metrics"
|
||||
o11ymod "github.com/hanzoai/o11y"
|
||||
|
||||
// In-repo subsystem packages (clients/*). Each exports a Mount (and, where it
|
||||
// owns process-lifetime resources, a Shutdown); Wire references them directly.
|
||||
@@ -51,6 +53,7 @@ import (
|
||||
"github.com/hanzoai/cloud/clients/admin"
|
||||
"github.com/hanzoai/cloud/clients/ads"
|
||||
"github.com/hanzoai/cloud/clients/affiliates"
|
||||
"github.com/hanzoai/cloud/clients/agent"
|
||||
"github.com/hanzoai/cloud/clients/agents"
|
||||
"github.com/hanzoai/cloud/clients/agentskills"
|
||||
"github.com/hanzoai/cloud/clients/analytics"
|
||||
@@ -59,34 +62,36 @@ import (
|
||||
"github.com/hanzoai/cloud/clients/automations"
|
||||
"github.com/hanzoai/cloud/clients/base"
|
||||
"github.com/hanzoai/cloud/clients/billing"
|
||||
"github.com/hanzoai/cloud/clients/bot"
|
||||
"github.com/hanzoai/cloud/clients/bots"
|
||||
"github.com/hanzoai/cloud/clients/captable"
|
||||
"github.com/hanzoai/cloud/clients/catalogsync"
|
||||
"github.com/hanzoai/cloud/clients/code"
|
||||
"github.com/hanzoai/cloud/clients/company"
|
||||
"github.com/hanzoai/cloud/clients/connectorruntime"
|
||||
"github.com/hanzoai/cloud/clients/content"
|
||||
"github.com/hanzoai/cloud/clients/crm"
|
||||
"github.com/hanzoai/cloud/clients/cron"
|
||||
"github.com/hanzoai/cloud/clients/dataroom"
|
||||
"github.com/hanzoai/cloud/clients/deploy"
|
||||
"github.com/hanzoai/cloud/clients/do"
|
||||
"github.com/hanzoai/cloud/clients/entitlements"
|
||||
"github.com/hanzoai/cloud/clients/eval"
|
||||
"github.com/hanzoai/cloud/clients/exec"
|
||||
"github.com/hanzoai/cloud/clients/featureflags"
|
||||
"github.com/hanzoai/cloud/clients/flags"
|
||||
"github.com/hanzoai/cloud/clients/framework"
|
||||
"github.com/hanzoai/cloud/clients/functions"
|
||||
"github.com/hanzoai/cloud/clients/gateway"
|
||||
"github.com/hanzoai/cloud/clients/git"
|
||||
"github.com/hanzoai/cloud/clients/gitops"
|
||||
"github.com/hanzoai/cloud/clients/graph"
|
||||
"github.com/hanzoai/cloud/clients/guide"
|
||||
"github.com/hanzoai/cloud/clients/iam"
|
||||
"github.com/hanzoai/cloud/clients/iam2"
|
||||
"github.com/hanzoai/cloud/clients/ingress"
|
||||
"github.com/hanzoai/cloud/clients/integrations"
|
||||
"github.com/hanzoai/cloud/clients/kafka"
|
||||
"github.com/hanzoai/cloud/clients/kms"
|
||||
"github.com/hanzoai/cloud/clients/knowledge"
|
||||
"github.com/hanzoai/cloud/clients/link"
|
||||
"github.com/hanzoai/cloud/clients/marketing"
|
||||
"github.com/hanzoai/cloud/clients/marketplace"
|
||||
"github.com/hanzoai/cloud/clients/ml"
|
||||
@@ -103,12 +108,14 @@ import (
|
||||
"github.com/hanzoai/cloud/clients/provisioning"
|
||||
"github.com/hanzoai/cloud/clients/pubsub"
|
||||
"github.com/hanzoai/cloud/clients/referrals"
|
||||
"github.com/hanzoai/cloud/clients/runtime"
|
||||
"github.com/hanzoai/cloud/clients/sbom"
|
||||
"github.com/hanzoai/cloud/clients/security"
|
||||
"github.com/hanzoai/cloud/clients/settings"
|
||||
"github.com/hanzoai/cloud/clients/sign"
|
||||
"github.com/hanzoai/cloud/clients/social"
|
||||
"github.com/hanzoai/cloud/clients/storage"
|
||||
"github.com/hanzoai/cloud/clients/sync"
|
||||
"github.com/hanzoai/cloud/clients/tasks"
|
||||
"github.com/hanzoai/cloud/clients/team"
|
||||
"github.com/hanzoai/cloud/clients/templates"
|
||||
@@ -156,6 +163,22 @@ func init() {
|
||||
})
|
||||
}
|
||||
|
||||
// identitySpec selects the ONE identity backend that owns /v1/iam/* (+ /login/oauth/*)
|
||||
// for this boot. CLOUD_IAM_IMPL=iam2 picks the clean-room iam2 (zip+orm, beego-free);
|
||||
// anything else — including unset, the production default — keeps the legacy beego
|
||||
// Casdoor embed, byte-for-byte today's behavior. The two impls register the SAME
|
||||
// absolute prefixes and therefore cannot co-mount, so selection (this func) stays
|
||||
// separate from activation (cfg.Enabled): exactly one spec occupies the identity slot
|
||||
// in Wire, preserving mount order either way. os.Getenv (not the unexported
|
||||
// cloud.getenv, which is unreachable from package apps) is the read — CLOUD_IAM_IMPL is
|
||||
// the deliberate, off-by-default opt-in that keeps iam2 inert until a canary flips it.
|
||||
func identitySpec() cloud.MountSpec {
|
||||
if os.Getenv("CLOUD_IAM_IMPL") == "iam2" {
|
||||
return cloud.MountSpec{Name: "iam2", Mount: iam2.Mount}
|
||||
}
|
||||
return cloud.MountSpec{Name: "iam", Mount: iam.Mount}
|
||||
}
|
||||
|
||||
// Wire returns every linked subsystem as a cloud.MountSpec, in mount order. The
|
||||
// slice position IS the order: cloud.MountAll iterates it as-given, registering each
|
||||
// subsystem's teardown as a zip shutdown hook so teardown runs in reverse (LIFO).
|
||||
@@ -164,170 +187,200 @@ func init() {
|
||||
func Wire() []cloud.MountSpec {
|
||||
return []cloud.MountSpec{
|
||||
// embedded NATS :4222 + JetStream.
|
||||
{Name: "pubsub", Mount: cloud.Typed(pubsub.Mount), Shutdown: pubsub.Shutdown},
|
||||
{Name: "pubsub", Mount: pubsub.Mount, Shutdown: pubsub.Shutdown},
|
||||
// embedded Kafka adaptor :9092.
|
||||
{Name: "kafka", Mount: cloud.Typed(kafka.Mount), Shutdown: kafka.Shutdown},
|
||||
{Name: "kafka", Mount: kafka.Mount, Shutdown: kafka.Shutdown},
|
||||
// /.well-known/agent-skills/* — before IAM's /.well-known/* wildcard (50).
|
||||
{Name: "agentskills", Mount: cloud.Typed(agentskills.Mount)},
|
||||
{Name: "agentskills", Mount: agentskills.Mount},
|
||||
// Insights feature-flag evaluation seam (no routes; a hot value plane).
|
||||
{Name: "featureflags", Mount: cloud.Typed(featureflags.Mount)},
|
||||
{Name: "flags", Mount: flags.Mount, Shutdown: flags.Shutdown, OwnsHealth: true},
|
||||
// Embedded KMS secrets plane /v1/kms/*. OwnsHealth: serves its own fail-closed
|
||||
// /v1/kms/health (the generic always-ok route must not shadow it). Fails closed
|
||||
// until the operator injects CLOUD_KMS_MASTER_KEY_REF. (Its in-process client
|
||||
// factory is registered separately via cloud.RegisterKMSClientFactory.)
|
||||
{Name: "kms", Mount: cloud.Typed(kms.Mount), OwnsHealth: true},
|
||||
{Name: "kms", Mount: kms.Mount, OwnsHealth: true},
|
||||
// hanzoai/metrics — native o11y. It declares its OWN narrow metrics.Deps (no
|
||||
// hanzoai/cloud import), so Typed cannot adapt it; mountMetrics builds that Deps
|
||||
// from cloud.Deps and calls metrics.Mount explicitly.
|
||||
{Name: "metrics", Mount: mountMetrics},
|
||||
// Embedded runtime edge (/v1/ingress/*). STAGED — edge listeners stay off unless
|
||||
// the operator names "ingress" in CLOUD_ENABLE.
|
||||
{Name: "ingress", Mount: cloud.Typed(ingress.Mount), Shutdown: ingress.Shutdown},
|
||||
{Name: "ingress", Mount: ingress.Mount, Shutdown: ingress.Shutdown},
|
||||
// SPECIFIC self-service routes (/v1/iam/{keys,onboard}, /v1/csrf, /v1/embed-status,
|
||||
// /v1/commerce/topup/wallet). MUST mount before the IAM /v1/iam/* wildcard (50) so
|
||||
// they win Fiber's first-match scan (framework-guaranteed since zip v1.3.0).
|
||||
{Name: "account", Mount: cloud.Typed(account.MountAccount)},
|
||||
{Name: "account", Mount: account.MountAccount},
|
||||
// Embedded IAM identity plane (/v1/iam/*, /.well-known/*, /login/oauth/*, /_/iam/*,
|
||||
// /cas/*, /scim/*) — the identity authority, mounts before its dependents. STAGED:
|
||||
// the operator adds "iam" to --enable only after IAM config + the fold are verified.
|
||||
{Name: "iam", Mount: cloud.Typed(iam.Mount)},
|
||||
// Which IMPLEMENTATION owns these prefixes is selected by CLOUD_IAM_IMPL
|
||||
// (identitySpec): the clean-room iam2 (zip+orm, beego-free) when =="iam2", else the
|
||||
// legacy beego Casdoor embed — the default (unset = today's behavior, byte-for-byte).
|
||||
// Both register the SAME absolute paths and cannot co-mount, so this is an either/or
|
||||
// switch at this ONE slot, never a shadow prefix.
|
||||
identitySpec(),
|
||||
// Embedded Base app engine + viral waitlist (/v1/waitlist/*). STAGED behind
|
||||
// CLOUD_BASE_EMBED. OwnsHealth: native /v1/base/health.
|
||||
{Name: "base", Mount: cloud.Typed(base.Mount), Shutdown: base.Shutdown, OwnsHealth: true},
|
||||
// In-repo o11y READ plane + the runtime-handler install (o11y.SetHandler). Every
|
||||
// specific /v1/o11y/* route registers INSIDE this one mount, hence BEFORE the
|
||||
// hanzoai/o11y module wildcard (70) — Fiber's in-order match gives them precedence.
|
||||
// OwnsHealth: the module's order-70 co-entry below owns the single /v1/o11y/health.
|
||||
{Name: "o11y", Mount: o11y.MountO11y, Shutdown: o11y.ShutdownO11y, OwnsHealth: true},
|
||||
// hanzoai/o11y module wildcard /v1/o11y/* — co-owner of the ONE o11y concept with
|
||||
// the in-repo entry above (same name), delegated to via o11y.SetHandler.
|
||||
{Name: "o11y", Mount: cloud.Typed(o11ymod.Mount)},
|
||||
{Name: "authz", Mount: cloud.Typed(authz.Mount)},
|
||||
{Name: "base", Mount: base.Mount, Shutdown: base.Shutdown, OwnsHealth: true},
|
||||
// The ONE observability subsystem: the in-repo o11y READ plane + runtime-handler
|
||||
// install (o11y.SetHandler), with the hanzoai/o11y module wildcard /v1/o11y/*
|
||||
// folded in as the TERMINAL sub-mount INSIDE o11y.MountO11y. Every specific
|
||||
// /v1/o11y/* route registers before that wildcard, so Fiber's in-order match gives
|
||||
// them precedence. NOT OwnsHealth: /v1/o11y/health stays the generic always-ok
|
||||
// route (registered before MountAll), exactly as when the former module co-entry —
|
||||
// which also set OwnsHealth=false — triggered it.
|
||||
{Name: "o11y", Mount: o11y.MountO11y, Shutdown: o11y.ShutdownO11y},
|
||||
{Name: "authz", Mount: authz.Mount},
|
||||
// Embedded commerce plane /v1/commerce/*, /_/commerce/* — the hanzoai/commerce
|
||||
// MODULE via the adapter in commerce.go (un-forked; the in-process
|
||||
// CommerceClient is wired directly in pickCommerceClient).
|
||||
{Name: "commerce", Mount: cloud.Typed(mountCommerce)},
|
||||
// hanzoai/licensing. Its Mount is func(any, cloud.Deps) error — a MountFunc
|
||||
// already — so Wire references it DIRECTLY, not through Typed.
|
||||
{Name: "commerce", Mount: mountCommerce},
|
||||
{Name: "licensing", Mount: licensing.Mount},
|
||||
{Name: "plans", Mount: cloud.Typed(plan.Mount), OwnsHealth: true},
|
||||
{Name: "pricing", Mount: cloud.Typed(pricing.Mount), OwnsHealth: true},
|
||||
{Name: "plans", Mount: plan.Mount, OwnsHealth: true},
|
||||
{Name: "pricing", Mount: pricing.Mount, OwnsHealth: true},
|
||||
// /v1/s3/buckets/* + /v1/s3/health. Mounts BEFORE provisioning (120) so its static
|
||||
// routes win over provisioning's /v1/s3/:name. OwnsHealth (real fail-closed probe).
|
||||
{Name: "storage", Mount: cloud.Typed(storage.Mount), OwnsHealth: true},
|
||||
{Name: "storage", Mount: storage.Mount, OwnsHealth: true},
|
||||
// Provisioning control plane: /v1/sql,/v1/vector,/v1/datastore,/v1/kv,/v1/search,/v1/s3,/v1/docdb.
|
||||
{Name: "provisioning", Mount: cloud.Typed(provisioning.Mount)},
|
||||
{Name: "billing", Mount: cloud.Typed(billing.Mount)},
|
||||
{Name: "provisioning", Mount: provisioning.Mount},
|
||||
{Name: "billing", Mount: billing.Mount},
|
||||
// CATCH-ALL /v1/billing/* + /v1/commerce/* data bridges — AFTER clients/billing
|
||||
// (121) + the commerce embed (100). Same clients/account package as "account" (48).
|
||||
{Name: "account-bridge", Mount: cloud.Typed(account.MountBridge)},
|
||||
{Name: "do", Mount: cloud.Typed(do.Mount)},
|
||||
{Name: "platform", Mount: cloud.Typed(platform.Mount), OwnsHealth: true},
|
||||
{Name: "projects", Mount: cloud.Typed(projects.Mount)},
|
||||
{Name: "prompts", Mount: cloud.Typed(prompts.Mount)},
|
||||
{Name: "agents", Mount: cloud.Typed(agents.Mount), Shutdown: agents.Shutdown},
|
||||
{Name: "wallets", Mount: cloud.Typed(wallets.Mount), Shutdown: ctxShutdown(wallets.Shutdown)},
|
||||
{Name: "account-bridge", Mount: account.MountBridge},
|
||||
{Name: "do", Mount: do.Mount},
|
||||
{Name: "platform", Mount: platform.Mount, OwnsHealth: true},
|
||||
{Name: "projects", Mount: projects.Mount},
|
||||
{Name: "prompts", Mount: prompts.Mount},
|
||||
{Name: "agents", Mount: agents.Mount, Shutdown: agents.Shutdown},
|
||||
// The unified AI login manager registry (/v1/links). Mounts AFTER agents so
|
||||
// a link revoke can stop the affected agent sessions in-process.
|
||||
{Name: "link", Mount: link.Mount, Shutdown: link.Shutdown},
|
||||
{Name: "wallets", Mount: wallets.Mount, Shutdown: ctxShutdown(wallets.Shutdown)},
|
||||
// x402 pay-per-use: settles a signed ERC-3009 authorization to a recipient
|
||||
// wallet through the metering spine. Mounts AFTER wallets (it resolves the
|
||||
// recipient via wallets.ResolvePaymentTarget) and provides the Enforce
|
||||
// middleware a marketplace applies to its priced routes.
|
||||
{Name: "x402", Mount: cloud.Typed(x402.Mount), Shutdown: ctxShutdown(x402.Shutdown)},
|
||||
{Name: "paas", Mount: cloud.Typed(paas.Mount), OwnsHealth: true},
|
||||
// GitOps deploy dashboard /v1/gitops/* (the ArgoCD-grade fleet view over the
|
||||
{Name: "x402", Mount: x402.Mount, Shutdown: ctxShutdown(x402.Shutdown)},
|
||||
{Name: "paas", Mount: paas.Mount, OwnsHealth: true},
|
||||
// GitOps deploy dashboard /v1/deploy/* (the ArgoCD-grade fleet view over the
|
||||
// operator App CRs). After paas so the release seam paas installs is registered
|
||||
// before a gitops rollback delegates to it; owns its own /v1/gitops/health.
|
||||
{Name: "gitops", Mount: cloud.Typed(gitops.Mount), OwnsHealth: true},
|
||||
{Name: "functions", Mount: cloud.Typed(functions.Mount)},
|
||||
{Name: "tracker", Mount: cloud.Typed(tracker.Mount)},
|
||||
{Name: "templates", Mount: cloud.Typed(templates.Mount)},
|
||||
{Name: "framework", Mount: cloud.Typed(framework.Mount), Shutdown: ctxShutdown(framework.Shutdown)},
|
||||
{Name: "knowledge", Mount: cloud.Typed(knowledge.Mount)},
|
||||
// before a gitops rollback delegates to it; owns its own /v1/deploy/health.
|
||||
{Name: "deploy", Mount: deploy.Mount, OwnsHealth: true},
|
||||
{Name: "functions", Mount: functions.Mount},
|
||||
{Name: "tracker", Mount: tracker.Mount},
|
||||
{Name: "templates", Mount: templates.Mount},
|
||||
{Name: "framework", Mount: framework.Mount, Shutdown: ctxShutdown(framework.Shutdown)},
|
||||
{Name: "knowledge", Mount: knowledge.Mount},
|
||||
// Marketing content loop /v1/content/* (generate → CMS → transition → publish).
|
||||
// After framework (its DocType store the ops read/write) + knowledge (the sibling
|
||||
// framework lane); before the AI /v1/* catch-all so /v1/content/* resolves here.
|
||||
// CRUD/tenancy/install are framework's; this adds the board, lifecycle transition,
|
||||
// and the generate/publish orchestration over the zen5 + studio + social edges.
|
||||
{Name: "content", Mount: cloud.Typed(content.Mount), Shutdown: ctxShutdown(content.Shutdown)},
|
||||
{Name: "content", Mount: content.Mount, Shutdown: ctxShutdown(content.Shutdown)},
|
||||
// Reverse storefront loop: consume the commerce COMMERCE stream (product.created)
|
||||
// → content.EnsureCatalogAsset (render the new product's ecom asset, design==slug).
|
||||
// After content (whose EnsureCatalogAsset it drives). Inert until CLOUD_COMMERCE_NATS_URL
|
||||
// names the NATS carrying commerce catalog events — the reverse of the forward edge.
|
||||
{Name: "catalogsync", Mount: cloud.Typed(catalogsync.Mount), Shutdown: catalogsync.Shutdown},
|
||||
{Name: "ml", Mount: cloud.Typed(ml.Mount), OwnsHealth: true},
|
||||
{Name: "usage", Mount: cloud.Typed(usage.Mount)},
|
||||
{Name: "crm", Mount: cloud.Typed(crm.Mount)},
|
||||
{Name: "catalogsync", Mount: catalogsync.Mount, Shutdown: catalogsync.Shutdown},
|
||||
{Name: "ml", Mount: ml.Mount, OwnsHealth: true},
|
||||
{Name: "usage", Mount: usage.Mount},
|
||||
{Name: "crm", Mount: crm.Mount},
|
||||
// Native /v1/marketing/* — the in-process fold of github.com/hanzoai/marketing
|
||||
// (per-org campaign store on Base/SQLite), twin of crm. Owns a DB handle, so
|
||||
// its Shutdown closes it cleanly on SIGTERM (ctxShutdown adapts func() error).
|
||||
{Name: "marketing", Mount: cloud.Typed(marketing.Mount), Shutdown: ctxShutdown(marketing.Shutdown)},
|
||||
{Name: "marketing", Mount: marketing.Mount, Shutdown: ctxShutdown(marketing.Shutdown)},
|
||||
// Native /v1/ads/* — the net-new per-org ad-campaign store on Base/SQLite,
|
||||
// twin of crm/marketing. Owns a DB handle, so its Shutdown closes it cleanly
|
||||
// on SIGTERM (ctxShutdown adapts func() error).
|
||||
{Name: "ads", Mount: cloud.Typed(ads.Mount), Shutdown: ctxShutdown(ads.Shutdown)},
|
||||
{Name: "ads", Mount: ads.Mount, Shutdown: ctxShutdown(ads.Shutdown)},
|
||||
// Native /v1/social/* — the in-process fold of the live social stack
|
||||
// (github.com/hanzoai/social: social-backend/frontend/orchestrator, a Postiz-style
|
||||
// scheduler), a per-org accounts+posts store on Base/SQLite, twin of crm. Owns a DB
|
||||
// handle, so its Shutdown closes it cleanly on SIGTERM (ctxShutdown adapts func() error).
|
||||
{Name: "social", Mount: cloud.Typed(social.Mount), Shutdown: ctxShutdown(social.Shutdown)},
|
||||
{Name: "analytics", Mount: cloud.Typed(analytics.Mount), OwnsHealth: true},
|
||||
{Name: "git", Mount: cloud.Typed(git.Mount)},
|
||||
{Name: "visor", Mount: cloud.Typed(visor.Mount)},
|
||||
{Name: "social", Mount: social.Mount, Shutdown: ctxShutdown(social.Shutdown)},
|
||||
{Name: "analytics", Mount: analytics.Mount, OwnsHealth: true},
|
||||
{Name: "git", Mount: git.Mount},
|
||||
// Universal sync (/v1/sync/links + engine). Registers the cloud.SyncEngine the
|
||||
// GitHub/Gitea webhooks enqueue to; git is its first provider. Owns per-org
|
||||
// DB handles, so its Shutdown closes them on SIGTERM.
|
||||
{Name: "sync", Mount: sync.Mount, Shutdown: ctxShutdown(sync.Shutdown)},
|
||||
{Name: "visor", Mount: visor.Mount},
|
||||
// Cap table on Base via goja. STAGED behind CLOUD_ENABLE.
|
||||
{Name: "captable", Mount: cloud.Typed(captable.Mount), Shutdown: captable.Shutdown},
|
||||
{Name: "code", Mount: cloud.Typed(code.Mount), Shutdown: code.Shutdown},
|
||||
{Name: "zero-trust", Mount: cloud.Typed(zt.Mount)},
|
||||
{Name: "captable", Mount: captable.Mount, Shutdown: captable.Shutdown},
|
||||
{Name: "code", Mount: code.Mount, Shutdown: code.Shutdown},
|
||||
{Name: "zero-trust", Mount: zt.Mount},
|
||||
// Data rooms via goja + per-tenant Base. STAGED behind CLOUD_ENABLE. OwnsHealth.
|
||||
{Name: "dataroom", Mount: cloud.Typed(dataroom.Mount), Shutdown: dataroom.Shutdown, OwnsHealth: true},
|
||||
{Name: "graph", Mount: cloud.Typed(graph.Mount)},
|
||||
{Name: "security", Mount: cloud.Typed(security.Mount), Shutdown: ctxShutdown(security.Shutdown), OwnsHealth: true},
|
||||
{Name: "integrations", Mount: cloud.Typed(integrations.Mount), Shutdown: integrations.Shutdown},
|
||||
{Name: "sbom", Mount: cloud.Typed(sbom.Mount), OwnsHealth: true},
|
||||
{Name: "team", Mount: cloud.Typed(team.Mount), Shutdown: ctxShutdown(team.Shutdown)},
|
||||
{Name: "settings", Mount: cloud.Typed(settings.Mount), Shutdown: settings.Shutdown},
|
||||
{Name: "notify", Mount: cloud.Typed(notify.Mount), OwnsHealth: true},
|
||||
{Name: "gateway", Mount: cloud.Typed(gateway.Mount)},
|
||||
{Name: "entitlements", Mount: cloud.Typed(entitlements.Mount), Shutdown: entitlements.Shutdown},
|
||||
{Name: "exec", Mount: cloud.Typed(exec.Mount)},
|
||||
{Name: "websearch", Mount: cloud.Typed(websearch.Mount)},
|
||||
{Name: "world", Mount: cloud.Typed(world.Mount), Shutdown: ctxShutdown(world.Shutdown)},
|
||||
{Name: "bot", Mount: cloud.Typed(bot.Mount)},
|
||||
{Name: "authors", Mount: cloud.Typed(authors.Mount), Shutdown: ctxShutdown(authors.Shutdown)},
|
||||
{Name: "bots", Mount: cloud.Typed(bots.Mount)},
|
||||
{Name: "audit", Mount: cloud.Typed(auditlog.Mount)},
|
||||
{Name: "affiliates", Mount: cloud.Typed(affiliates.Mount)},
|
||||
{Name: "dataroom", Mount: dataroom.Mount, Shutdown: dataroom.Shutdown, OwnsHealth: true},
|
||||
{Name: "graph", Mount: graph.Mount},
|
||||
{Name: "security", Mount: security.Mount, Shutdown: ctxShutdown(security.Shutdown), OwnsHealth: true},
|
||||
{Name: "integrations", Mount: integrations.Mount, Shutdown: integrations.Shutdown},
|
||||
{Name: "sbom", Mount: sbom.Mount, OwnsHealth: true},
|
||||
{Name: "team", Mount: team.Mount, Shutdown: ctxShutdown(team.Shutdown)},
|
||||
{Name: "settings", Mount: settings.Mount, Shutdown: settings.Shutdown},
|
||||
{Name: "notify", Mount: notify.Mount, OwnsHealth: true},
|
||||
{Name: "gateway", Mount: gateway.Mount},
|
||||
{Name: "entitlements", Mount: entitlements.Mount, Shutdown: entitlements.Shutdown},
|
||||
{Name: "exec", Mount: exec.Mount},
|
||||
{Name: "websearch", Mount: websearch.Mount},
|
||||
{Name: "world", Mount: world.Mount, Shutdown: ctxShutdown(world.Shutdown)},
|
||||
// The bot runtime's ops face (/v1/bot/*). The transport itself is domain-free;
|
||||
// the run control plane is "bots" below.
|
||||
{Name: "runtime", Mount: runtime.Mount},
|
||||
{Name: "authors", Mount: authors.Mount, Shutdown: ctxShutdown(authors.Shutdown)},
|
||||
{Name: "bots", Mount: bots.Mount},
|
||||
{Name: "audit", Mount: auditlog.Mount},
|
||||
{Name: "affiliates", Mount: affiliates.Mount},
|
||||
// Hanzo Sign (e-signature) via goja + per-tenant Base. STAGED behind CLOUD_ENABLE. OwnsHealth.
|
||||
{Name: "sign", Mount: cloud.Typed(sign.Mount), Shutdown: sign.Shutdown, OwnsHealth: true},
|
||||
{Name: "product", Mount: cloud.Typed(product.Mount)},
|
||||
{Name: "evals", Mount: cloud.Typed(eval.Mount)},
|
||||
{Name: "treasury", Mount: cloud.Typed(treasury.Mount), Shutdown: ctxShutdown(treasury.Shutdown)},
|
||||
{Name: "admin", Mount: cloud.Typed(admin.Mount)},
|
||||
{Name: "tasks", Mount: cloud.Typed(tasks.Mount)},
|
||||
{Name: "sign", Mount: sign.Mount, Shutdown: sign.Shutdown, OwnsHealth: true},
|
||||
{Name: "product", Mount: product.Mount},
|
||||
{Name: "evals", Mount: eval.Mount},
|
||||
{Name: "treasury", Mount: treasury.Mount, Shutdown: ctxShutdown(treasury.Shutdown)},
|
||||
{Name: "admin", Mount: admin.Mount},
|
||||
// Launch-control (per-service waitlist mode) folded into the flags engine: the
|
||||
// mode IS the switch waitlist.<svc>, the board is the /v1/admin/services lens,
|
||||
// and /v1/featuregate/mode is served by flags. featuregate is no longer a mounted
|
||||
// subsystem — it exposes only the native Enforce middleware (wired in serve.go),
|
||||
// a consumer of flags.WaitlistModeForHost.
|
||||
{Name: "tasks", Mount: tasks.Mount},
|
||||
// Platform cron: durable schedules on the shared tasks engine replacing
|
||||
// every k8s CronJob — entries are cron.hanzo.ai ConfigMaps (universe git),
|
||||
// runs visible in the Tasks console. Mounts no routes; starts after the
|
||||
// engine is wired.
|
||||
{Name: "cron", Mount: cloud.Typed(cron.Mount)},
|
||||
{Name: "automations", Mount: cloud.Typed(automations.Mount), Shutdown: automations.Shutdown},
|
||||
{Name: "cron", Mount: cron.Mount},
|
||||
{Name: "automations", Mount: automations.Mount, Shutdown: automations.Shutdown},
|
||||
// Native single-connector execution (HIP-0126): runs an ActivePieces JS
|
||||
// connector action in-process via goja (clients/connectorruntime), retiring
|
||||
// the standalone auto Node engine. Mounts POST /v1/automations/connectors/:id/run,
|
||||
// paired with the automations catalogue above; STAGED like the rest.
|
||||
{Name: "connectorruntime", Mount: connectorruntime.Mount},
|
||||
// Unified tool plane: /v1/tools/* — the ONE registry (connectors, functions,
|
||||
// agents, skills, external MCP servers, full-cloud-control /v1 routes), per-org
|
||||
// activation, and the unified MCP endpoint. Sources register into it from their
|
||||
// own Mounts, so mount position is not load-bearing (List/Dispatch run at request
|
||||
// time); placed after automations, before the zen/ai catch-all so /v1/tools wins.
|
||||
{Name: "tools", Mount: cloud.Typed(tools.Mount), Shutdown: tools.Shutdown},
|
||||
{Name: "tools", Mount: tools.Mount, Shutdown: tools.Shutdown},
|
||||
// Marketplace: /v1/marketplace/* — listing/discovery/install over the tool plane,
|
||||
// with x402-priced monetized listings. Mounts after tools (it fills the price seam).
|
||||
{Name: "marketplace", Mount: cloud.Typed(marketplace.Mount), Shutdown: marketplace.Shutdown},
|
||||
{Name: "referrals", Mount: cloud.Typed(referrals.Mount)},
|
||||
{Name: "marketplace", Mount: marketplace.Mount, Shutdown: marketplace.Shutdown},
|
||||
{Name: "referrals", Mount: referrals.Mount},
|
||||
// Business AI Guide /v1/guide/* — the interactive launch checklist engine +
|
||||
// the agent that executes a step through the per-principal MCP plane. After
|
||||
// automations (whose InvokeTool it drives) and referrals; before the ai
|
||||
// catch-all. Owns per-org SQLite, so its Shutdown closes the stores.
|
||||
{Name: "guide", Mount: cloud.Typed(guide.Mount), Shutdown: ctxShutdown(guide.Shutdown)},
|
||||
{Name: "guide", Mount: guide.Mount, Shutdown: ctxShutdown(guide.Shutdown)},
|
||||
// Hanzo Company — the incorporation + fundraising state machine
|
||||
// (/v1/company/*). Mounts after the seams it composes (integrations for the
|
||||
// google token custody; captable/dataroom facades) and before the /v1/* AI
|
||||
// catch-all so its routes resolve here.
|
||||
{Name: "company", Mount: cloud.Typed(company.Mount), Shutdown: company.Shutdown},
|
||||
{Name: "company", Mount: company.Mount, Shutdown: company.Shutdown},
|
||||
// Chat orchestrator — POST /v1/chat: ONE LLM tool-calling round over the tool
|
||||
// plane. It COMPOSES the ai completion path (in-process, so per-org billing
|
||||
// runs) + the unified tool registry, and splits the model's tool calls into
|
||||
// server-executed actions and client-applied ops. Mounts BEFORE the zen/ai
|
||||
// catch-all so /v1/chat resolves here (Fiber first-match); the ai module's
|
||||
// beego /v1/chat alias behind its /v1/* glob is thereby shadowed, while ai
|
||||
// keeps /v1/chat/completions + /v1/completions.
|
||||
{Name: "agent", Mount: agent.Mount},
|
||||
// The bare /v1/* AI catch-all — the LAST route position. Every owning subsystem above
|
||||
// wins its own namespace (Fiber first-match); AI is the fallback for the rest of /v1/*.
|
||||
// zen mounts as a /v1-scoped Claim middleware BEFORE ai: it routes zen* models
|
||||
@@ -336,21 +389,36 @@ func Wire() []cloud.MountSpec {
|
||||
// other model and the /v1/models list. Order is load-bearing — Claim must
|
||||
// run before ai's catch-all. (See hip-00NN.)
|
||||
{Name: "zen", Mount: mountZen},
|
||||
{Name: "ai", Mount: cloud.Typed(ai.Mount)},
|
||||
{Name: "ai", Mount: ai.Mount},
|
||||
// Runtime wasm/proxy plugins — mounts dead last.
|
||||
{Name: "plugins", Mount: cloud.Typed(plugin.Mount)},
|
||||
{Name: "plugins", Mount: plugin.Mount},
|
||||
}
|
||||
}
|
||||
|
||||
// ServeSingle is the ONE way to run a single app standalone: validate `name`
|
||||
// against Wire(), then serve exactly it (cloud.Serve with a one-name enable
|
||||
// list — MountAll mounts only it). It is the path `hanzo <name>` already uses;
|
||||
// promoting it here lets each cmd/<app>/main.go stub reuse it instead of
|
||||
// re-implementing the dispatch, so adding an app in Wire() is still the one edit
|
||||
// and its standalone binary comes for free (generated). Returns an error for an
|
||||
// unknown name rather than booting a no-op.
|
||||
func ServeSingle(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("ServeSingle: empty app name")
|
||||
}
|
||||
for _, spec := range Wire() {
|
||||
if spec.Name == name {
|
||||
return cloud.Serve(Wire(), []string{name})
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("ServeSingle: unknown app %q — run `hanzo code ls`/`hanzo` for the list", name)
|
||||
}
|
||||
|
||||
// mountMetrics adapts hanzoai/metrics into a cloud.MountFunc. Unlike the other
|
||||
// externals, metrics declares its OWN narrow Deps (Logger, DataDir, Brand) and does
|
||||
// not import hanzoai/cloud, so cloud.Typed cannot bridge it: the composition root
|
||||
// builds metrics.Deps from cloud.Deps and calls metrics.Mount explicitly here.
|
||||
func mountMetrics(app any, deps cloud.Deps) error {
|
||||
a, ok := app.(*zip.App)
|
||||
if !ok {
|
||||
return fmt.Errorf("metrics.Mount: app is %T, want *zip.App", app)
|
||||
}
|
||||
func mountMetrics(a *zip.App, deps cloud.Deps) error {
|
||||
return metrics.Mount(a, metrics.Deps{Logger: deps.Logger, DataDir: deps.DataDir, Brand: deps.Brand})
|
||||
}
|
||||
|
||||
@@ -13,13 +13,14 @@
|
||||
//
|
||||
// FAIL-SOFT. A broken Embed does NOT crash the binary: commerce degrades to a 503
|
||||
// on its own prefixes while every co-resident subsystem stays up.
|
||||
package subsystems
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/commerceclient"
|
||||
@@ -115,6 +116,13 @@ func mountCommerce(app *zip.App, deps cloud.Deps) error {
|
||||
// THE native co-residence contract: commerce registers its routes on
|
||||
// cloud's own app — no second engine, no net/http adaptation.
|
||||
App: app,
|
||||
// ONE LEDGER: commerce's POST /v1/billing/credit mints into cloud's native
|
||||
// finance ledger (the SAME per-org account the AI spend-gate reads), so a
|
||||
// granted credit is immediately spendable. commerce.Embed calls
|
||||
// creditledger.Set(this) before routes register; nil would leave commerce on
|
||||
// its own datastore (standalone), but in this unified binary finance is
|
||||
// co-resident, so we inject the finance-backed ledger adapter.
|
||||
Ledger: ledger{},
|
||||
})
|
||||
if err != nil {
|
||||
lg.Error("commerce embed failed — serving fail-closed 503 (cloud stays up)", "err", err)
|
||||
@@ -126,7 +134,7 @@ func mountCommerce(app *zip.App, deps cloud.Deps) error {
|
||||
// mirrors the standalone /v1 bundle: gated request context, host, IAM
|
||||
// resolution; store.Route's own tokenRequired arg gates the CRUD.
|
||||
storeV1 := app.Group("/v1")
|
||||
storeV1.Use(commercemid.AddHost(), commercemid.RequestContext(), commercemid.ErrorHandlerJSON())
|
||||
storeV1.Use(commercemid.AddHost(), commercemid.RequestContext(), commerceErrorScope())
|
||||
// Unconditional, exactly like the standalone bundle: IAMTokenRequired
|
||||
// no-ops gracefully when IAM is not initialized.
|
||||
storeV1.Use(iammiddleware.IAMTokenRequired())
|
||||
@@ -166,6 +174,37 @@ func mountCommerce(app *zip.App, deps cloud.Deps) error {
|
||||
|
||||
// mountCommerceFailClosed serves an honest JSON 503 on every commerce prefix when
|
||||
// the embed cannot boot, so /v1/commerce/* answers "commerce unavailable" instead
|
||||
// commerceErrorScope confines commerce's JSON error envelope to commerce's OWN
|
||||
// routes. commercemid.ErrorHandlerJSON is a `/v1` GROUP middleware, and fiber
|
||||
// matches group middleware by PREFIX, not by the handle a route registered on —
|
||||
// so on the shared `/v1` it wraps every subsystem mounted AFTER commerce and
|
||||
// flattens their typed zip.HTTPError (403/400/…) into a blanket 500 (the store
|
||||
// envelope always renders 500). Guarded by commercePrefixes, the envelope stays on
|
||||
// commerce and every other subsystem renders its own status via zip's default
|
||||
// handler — the pre-commerce subsystems (kms, o11y, …) already do; this makes the
|
||||
// post-commerce ones (projects, agents, wallets, …) match.
|
||||
func commerceErrorScope() zip.Handler {
|
||||
envelope := commercemid.ErrorHandlerJSON()
|
||||
return func(c *zip.Ctx) error {
|
||||
if hasCommercePrefix(c.Path()) {
|
||||
return envelope(c)
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// hasCommercePrefix reports whether path is a commerce-owned root (an exact prefix
|
||||
// or a child of one), the SAME ownership commercePrefixes encodes for the
|
||||
// fail-closed mount.
|
||||
func hasCommercePrefix(path string) bool {
|
||||
for _, p := range commercePrefixes {
|
||||
if path == p || strings.HasPrefix(path, p+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// of falling through to another subsystem's catch-all.
|
||||
func mountCommerceFailClosed(app *zip.App) {
|
||||
failed := func(c *zip.Ctx) error {
|
||||
@@ -0,0 +1,82 @@
|
||||
package apps
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commercemid "github.com/hanzoai/commerce/middleware"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// TestCommerceErrorScope proves commerceErrorScope() confines commerce's always-500
|
||||
// JSON envelope to commerce-owned prefixes: a post-commerce subsystem route
|
||||
// (/v1/projects) that returns a typed 403 renders 403 (zip default), while a
|
||||
// commerce route (/v1/store/...) still gets commerce's envelope. Mirrors the frozen
|
||||
// order: kms (before commerce) → commerce /v1 group chain → projects + store (after).
|
||||
// Regression for the release-smoke failure where 14 post-commerce endpoints 500'd.
|
||||
func TestCommerceErrorScope(t *testing.T) {
|
||||
app := zip.New(zip.Config{})
|
||||
|
||||
// kms (before commerce) — a clean 403 baseline (never wrapped by commerce).
|
||||
app.Get("/v1/kms/health", func(c *zip.Ctx) error { return zip.ErrForbidden("kms says no") })
|
||||
|
||||
// commerce (position 39): the REAL group chain, now with the scoped envelope.
|
||||
sv1 := app.Group("/v1")
|
||||
sv1.Use(commercemid.AddHost(), commercemid.RequestContext(), commerceErrorScope())
|
||||
|
||||
// projects (after commerce) — typed 403; must NOT be clobbered to 500.
|
||||
app.Get("/v1/projects", func(c *zip.Ctx) error { return zip.ErrForbidden("X-Org-Id required") })
|
||||
// a commerce store route (after commerce) — typed 403; commerce envelope applies.
|
||||
app.Get("/v1/store/current", func(c *zip.Ctx) error { return zip.ErrForbidden("store needs org") })
|
||||
|
||||
probe := func(path string) (int, string) {
|
||||
req := httptest.NewRequest("GET", path, nil)
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(b)
|
||||
}
|
||||
|
||||
// The invariant the scope guards: a typed zip.HTTPError (403) a subsystem returns
|
||||
// is NEVER flattened to a 500 — not before commerce, not after it. (commerce
|
||||
// >=1.48.10 honors the status itself; the scope keeps commerce's error handler off
|
||||
// other subsystems' routes regardless, so a future commerce regression can't
|
||||
// re-clobber them.)
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
wantCode int
|
||||
}{
|
||||
{"/v1/kms/health", 403}, // before commerce
|
||||
{"/v1/projects", 403}, // after commerce — must NOT be clobbered to 500
|
||||
{"/v1/store/current", 403}, // commerce's own route — its handler still honors 403
|
||||
} {
|
||||
code, body := probe(tc.path)
|
||||
t.Logf("%-20s -> %d %s", tc.path, code, body)
|
||||
if code != tc.wantCode {
|
||||
t.Errorf("%s: got %d, want %d (%s)", tc.path, code, tc.wantCode, body)
|
||||
}
|
||||
if strings.Contains(body, "\"status\":5") || code >= 500 {
|
||||
t.Errorf("%s: a typed 403 was flattened to a 5xx (%s)", tc.path, body)
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range []struct {
|
||||
path string
|
||||
own bool
|
||||
}{
|
||||
{"/v1/store/current", true},
|
||||
{"/v1/commerce/checkout", true},
|
||||
{"/v1/projects", false},
|
||||
{"/v1/agent/presets", false},
|
||||
{"/v1/agents", false},
|
||||
} {
|
||||
if got := hasCommercePrefix(p.path); got != p.own {
|
||||
t.Errorf("hasCommercePrefix(%q) = %v, want %v", p.path, got, p.own)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Copyright © 2026 Hanzo AI. MIT License.
|
||||
|
||||
package subsystems
|
||||
package apps
|
||||
|
||||
import (
|
||||
"io"
|
||||
@@ -1,4 +1,4 @@
|
||||
package subsystems
|
||||
package apps
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
// (cms/erp/help) against silent removal. They are not mount subsystems, so they
|
||||
// never appear in Wire(); they register their DocTypes and — for erp — the
|
||||
// ledger-posting lifecycle hooks into the framework engine from a package
|
||||
// init(), reached ONLY via the blank imports in subsystems.go. #248 dropped
|
||||
// init(), reached ONLY via the blank imports in apps.go. #248 dropped
|
||||
// those imports, which stripped the erp ledger hooks from the binary with no
|
||||
// mount change and no failing mount test. This asserts the engine's module
|
||||
// registry carries each lane, so that money-adjacent regression cannot recur.
|
||||
@@ -21,7 +21,7 @@ func TestFrameworkContentModulesLinked(t *testing.T) {
|
||||
}
|
||||
for _, want := range []string{"cms", "erp", "help"} {
|
||||
if !got[want] {
|
||||
t.Errorf("framework content module %q not registered — a blank import in subsystems.go is missing (erp drop = ledger hooks gone from the binary)", want)
|
||||
t.Errorf("framework content module %q not registered — a blank import in apps.go is missing (erp drop = ledger hooks gone from the binary)", want)
|
||||
}
|
||||
}
|
||||
// The module registry proves DocTypes are linked, but erp's ledger-posting
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright © 2026 Hanzo AI. MIT License.
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/finance"
|
||||
"github.com/hanzoai/cloud/clients/money"
|
||||
"github.com/hanzoai/cloud/types"
|
||||
"github.com/hanzoai/commerce/billing/creditledger"
|
||||
)
|
||||
|
||||
// ledger implements commerce's creditledger.CreditLedger over cloud's native
|
||||
// finance ledger — the SAME per-org account (finance.Current()) the AI spend-gate
|
||||
// reads and the edge meter debits. Injected at mountCommerce (EmbedConfig.Ledger),
|
||||
// it makes commerce's POST /v1/billing/credit mint into the ONE ledger: a granted
|
||||
// credit is immediately visible to the gate (one ledger, no split). This is the
|
||||
// cloud half of the one-ledger seam — commerce defines the interface, cloud
|
||||
// implements it once here, the compiler enforces the match.
|
||||
//
|
||||
// Fails closed when no finance ledger is co-resident; in the unified cloud binary
|
||||
// finance is always published, so Get() != nil ⇒ credit routes here.
|
||||
type ledger struct{}
|
||||
|
||||
// compile-time proof the adapter satisfies commerce's exported seam.
|
||||
var _ creditledger.CreditLedger = ledger{}
|
||||
|
||||
// Credit posts a balanced deposit (funding:platform → wallet) to the org's POOL
|
||||
// account (Subject == Org, the wallet the gate reads) and returns the ledger entry
|
||||
// id + the org's new available balance in cents. Idempotent on IdempotencyKey:
|
||||
// finance dedups on Ref, so the same key credits AT MOST once.
|
||||
func (ledger) Credit(ctx context.Context, in creditledger.CreditInput) (string, int64, error) {
|
||||
fin := finance.Current()
|
||||
if fin == nil {
|
||||
return "", 0, fmt.Errorf("commerce credit: no finance ledger co-resident")
|
||||
}
|
||||
cur := in.Currency
|
||||
if cur == "" {
|
||||
cur = "usd"
|
||||
}
|
||||
tag := in.Tag
|
||||
if tag == "" {
|
||||
tag = "grant:admin" // non-cash grant bucket (finance is a single wallet; Tags is a memo)
|
||||
}
|
||||
id, err := fin.Deposit(ctx, types.DepositInput{
|
||||
Org: in.Org,
|
||||
Subject: in.Org, // org-pool wallet == the account the AI gate reads
|
||||
Amount: money.FromCents(in.AmountCents),
|
||||
Currency: cur,
|
||||
Notes: in.Reason,
|
||||
Tags: tag,
|
||||
Ref: in.IdempotencyKey,
|
||||
})
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
bal, berr := fin.Balance(ctx, in.Org, in.Org, cur, false)
|
||||
if berr != nil {
|
||||
return id, 0, berr
|
||||
}
|
||||
return id, bal.Cents(), nil
|
||||
}
|
||||
|
||||
// Balance returns the org pool's available balance in cents for currency — the
|
||||
// same read the AI gate performs, so GET /v1/billing/balance and the gate agree.
|
||||
func (ledger) Balance(ctx context.Context, org, currency string) (int64, error) {
|
||||
fin := finance.Current()
|
||||
if fin == nil {
|
||||
return 0, fmt.Errorf("commerce balance: no finance ledger co-resident")
|
||||
}
|
||||
if currency == "" {
|
||||
currency = "usd"
|
||||
}
|
||||
bal, err := fin.Balance(ctx, org, org, currency, false)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return bal.Cents(), nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package subsystems
|
||||
package apps
|
||||
|
||||
import (
|
||||
"github.com/hanzoai/cloud/clients/coding"
|
||||
@@ -0,0 +1,152 @@
|
||||
package apps
|
||||
|
||||
import "testing"
|
||||
|
||||
// frozen is the EXACT subsystem mount sequence of the PRE-refactor binary —
|
||||
// name, OwnsHealth, and whether it has a Shutdown — one row per mounted spec, in
|
||||
// mount order. It was captured empirically from the pre-refactor cloud binary by
|
||||
// cmd/dumpregistry (a throwaway tool that blank-imported the old init()-registry
|
||||
// and replayed the legacy MountAll bubble-sort: ascending order-int, ties broken by
|
||||
// the non-stable sort over init-registration order). The "was order N" trailing
|
||||
// comment records each spec's deleted order-int for provenance.
|
||||
//
|
||||
// This is the SOLE guardian of mount order now that the order-ints are gone:
|
||||
// Wire() must reproduce this sequence byte-for-byte (the func pointers aside), so a
|
||||
// reorder, drop, add, or flag change in the composition root fails HERE. Captured on
|
||||
// origin/main @c504d2b: 68 specs from the live init()-registry, PLUS the two the
|
||||
// wave-2 external bumps (ai v1.805.2, o11y v1.5.12) stopped self-registering — ai
|
||||
// (@150 catch-all) and the hanzoai/o11y module wildcard (@70), which main currently
|
||||
// DROPS and this PR restores. The module wildcard (order 70) is now folded in as the
|
||||
// terminal sub-mount of the in-repo o11y read plane (order 69), so "o11y" is ONE spec.
|
||||
var frozen = []struct {
|
||||
name string
|
||||
ownsHealth bool
|
||||
hasShutdown bool
|
||||
}{
|
||||
{"pubsub", false, true}, // was order 5
|
||||
{"kafka", false, true}, // was order 6
|
||||
{"agentskills", false, false}, // was order 8
|
||||
{"flags", true, true}, // was order 9; native engine: /v1/flags health + store shutdown
|
||||
{"kms", true, false}, // was order 10
|
||||
{"metrics", false, false}, // was order 40
|
||||
{"ingress", false, true}, // was order 42
|
||||
{"account", false, false}, // was order 48
|
||||
{"iam", false, false}, // was order 50
|
||||
{"base", true, true}, // was order 60; per-org embed added Shutdown (#298)
|
||||
{"o11y", false, true}, // ONE observability subsystem (was co-owned orders 69+70): read plane + the hanzoai/o11y module wildcard folded in as MountO11y's terminal sub-mount. OwnsHealth=false keeps /v1/o11y/health the generic always-ok route the module co-entry used to trigger.
|
||||
{"authz", false, false}, // was order 70
|
||||
{"commerce", false, false}, // was order 100
|
||||
{"licensing", false, false}, // was order 110
|
||||
{"plans", true, false}, // was order 111
|
||||
{"pricing", true, false}, // was order 112
|
||||
{"storage", true, false}, // was order 118
|
||||
{"provisioning", false, false}, // was order 120
|
||||
{"billing", false, false}, // was order 121
|
||||
{"account-bridge", false, false}, // was order 122
|
||||
{"do", false, false}, // was order 123
|
||||
{"platform", true, false}, // was order 124
|
||||
{"projects", false, false}, // was order 125
|
||||
{"prompts", false, false}, // was order 126
|
||||
{"agents", false, true}, // was order 127
|
||||
{"link", false, true}, // new: unified AI login manager (/v1/links), after agents
|
||||
{"wallets", false, true}, // was order 127
|
||||
{"x402", false, true}, // new: x402 pay-per-use settlement (after wallets)
|
||||
{"paas", true, false}, // was order 128
|
||||
{"deploy", true, false}, // after paas (release seam), before functions
|
||||
{"functions", false, false}, // was order 128
|
||||
{"tracker", false, false}, // was order 129
|
||||
{"templates", false, false}, // was order 129
|
||||
{"framework", false, true}, // was order 129
|
||||
{"knowledge", false, false}, // was order 130
|
||||
{"content", false, true}, // new: marketing content loop (after knowledge)
|
||||
{"catalogsync", false, true}, // new: reverse loop (product.created → render) after content
|
||||
{"ml", true, false}, // was order 130
|
||||
{"usage", false, false}, // was order 131
|
||||
{"crm", false, false}, // was order 131
|
||||
{"marketing", false, true}, // new: marketing domain fold (after crm)
|
||||
{"ads", false, true}, // new: ads domain fold (after crm)
|
||||
{"social", false, true}, // new: /v1/social fold (after crm)
|
||||
{"analytics", true, false}, // was order 132
|
||||
{"git", false, false}, // was order 132
|
||||
{"sync", false, true}, // /v1/sync engine (owns per-org DB handles → Shutdown)
|
||||
{"visor", false, false}, // was order 133
|
||||
{"captable", false, true}, // was order 133
|
||||
{"code", false, true}, // was order 134
|
||||
{"zero-trust", false, false}, // was order 134
|
||||
{"dataroom", true, true}, // was order 134
|
||||
{"graph", false, false}, // was order 135
|
||||
{"security", true, true}, // was order 136
|
||||
{"integrations", false, true}, // was order 137
|
||||
{"sbom", true, false}, // was order 137
|
||||
{"team", false, true}, // was order 138
|
||||
{"settings", false, true}, // was order 138
|
||||
{"notify", true, false}, // was order 139
|
||||
{"gateway", false, false}, // was order 139
|
||||
{"entitlements", false, true}, // was order 139
|
||||
{"exec", false, false}, // was order 140
|
||||
{"websearch", false, false}, // was order 141
|
||||
{"world", false, true}, // was order 142
|
||||
{"runtime", false, false}, // was order 143; was "bot" until the transport was named for what it is
|
||||
{"authors", false, true}, // was order 143
|
||||
{"bots", false, false}, // was order 143
|
||||
{"audit", false, false}, // was order 144
|
||||
{"affiliates", false, false}, // was order 144
|
||||
{"sign", true, true}, // was order 145
|
||||
{"product", false, false}, // was order 145
|
||||
{"evals", false, false}, // was order 145
|
||||
{"treasury", false, true}, // was order 146
|
||||
{"admin", false, false}, // was order 146
|
||||
{"tasks", false, false}, // was order 147
|
||||
{"cron", false, false}, // durable platform cron on the shared engine (post-freeze add)
|
||||
{"automations", false, true}, // was order 148
|
||||
{"connectorruntime", false, false}, // new: native single-connector exec via goja (after automations, HIP-0126)
|
||||
{"tools", false, true}, // new: unified tool plane (after automations)
|
||||
{"marketplace", false, true}, // new: marketplace over the tool plane (after tools)
|
||||
{"referrals", false, false}, // was order 149
|
||||
{"guide", false, true}, // new: Business AI Guide (after referrals, before ai)
|
||||
{"company", false, true}, // new: Hanzo Company formation state machine (after guide)
|
||||
{"agent", false, false}, // new: /v1/agent tool-calling round (before zen/ai catch-all)
|
||||
{"zen", false, false}, // zen* claim middleware before ai's catch-all (hip-00NN)
|
||||
{"ai", false, false}, // was order 150
|
||||
{"plugins", false, false}, // was order 900
|
||||
}
|
||||
|
||||
// TestWireOrderMatchesFrozen proves the composition root's mount order is
|
||||
// byte-identical to the legacy init()-registry's, position by position.
|
||||
func TestWireOrderMatchesFrozen(t *testing.T) {
|
||||
wire := Wire()
|
||||
if len(wire) != len(frozen) {
|
||||
t.Fatalf("Wire() has %d specs, frozen sequence has %d", len(wire), len(frozen))
|
||||
}
|
||||
for i, s := range wire {
|
||||
w := frozen[i]
|
||||
if s.Name != w.name {
|
||||
t.Errorf("position %d: Wire() = %q, frozen = %q (mount ORDER changed)", i, s.Name, w.name)
|
||||
}
|
||||
if s.OwnsHealth != w.ownsHealth {
|
||||
t.Errorf("position %d (%s): OwnsHealth = %v, frozen = %v", i, s.Name, s.OwnsHealth, w.ownsHealth)
|
||||
}
|
||||
if (s.Shutdown != nil) != w.hasShutdown {
|
||||
t.Errorf("position %d (%s): hasShutdown = %v, frozen = %v", i, s.Name, s.Shutdown != nil, w.hasShutdown)
|
||||
}
|
||||
if s.Mount == nil {
|
||||
t.Errorf("position %d (%s): Mount is nil", i, s.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireNoDuplicateEnablement guards that every subsystem name is unique: a name
|
||||
// maps 1:1 to an enable id, so a duplicate would mount two specs under one id. (The
|
||||
// former o11y co-ownership was collapsed — the module wildcard is now a sub-mount of
|
||||
// the in-repo o11y read plane — so there is no longer any exempt duplicate.)
|
||||
func TestWireNoDuplicateEnablement(t *testing.T) {
|
||||
seen := map[string]int{}
|
||||
for _, s := range Wire() {
|
||||
seen[s.Name]++
|
||||
}
|
||||
for name, n := range seen {
|
||||
if n > 1 {
|
||||
t.Errorf("subsystem %q wired %d times (each enable id must be unique)", name, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
+112
-26
@@ -1,13 +1,16 @@
|
||||
// Copyright 2026 Hanzo AI Inc. All Rights Reserved.
|
||||
|
||||
package subsystems
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
aicontrollers "github.com/hanzoai/ai/controllers"
|
||||
aiobject "github.com/hanzoai/ai/object"
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/metering"
|
||||
cloudmoney "github.com/hanzoai/cloud/clients/money"
|
||||
@@ -53,11 +56,7 @@ import (
|
||||
//
|
||||
// It is wired BEFORE ai in Wire() so Claim's c.Next() falls through to ai's
|
||||
// catch-all. zen's catalog reads its upstream keys from KMS via the Key resolver.
|
||||
func mountZen(app any, deps cloud.Deps) error {
|
||||
a, ok := app.(*zip.App)
|
||||
if !ok {
|
||||
return fmt.Errorf("zen.Mount: app is %T, want *zip.App", app)
|
||||
}
|
||||
func mountZen(a *zip.App, deps cloud.Deps) error {
|
||||
z, err := zen.New(zen.Config{
|
||||
Logger: deps.Logger,
|
||||
Key: zenKeyResolver(deps.KMS),
|
||||
@@ -110,11 +109,17 @@ func commerceGate(m *metering.Client) zen.Gate {
|
||||
if t.BillingOrg == "" {
|
||||
return fmt.Errorf("a billable tenant is required (no anonymous usage)")
|
||||
}
|
||||
// zen's estimate is exact 18-dp atto-USD (hanzoai/money). Fold to whole
|
||||
// cents for the balance check via cloud's typed money.Amount.Cents() — a
|
||||
// sub-cent estimate gates as 0 (any-positive-balance), matching the edge
|
||||
// gate's AmountCents contract. The post-serve Meter debits the exact 18-dp.
|
||||
cents := cloudmoney.FromInt(est.Minor()).Cents()
|
||||
// zen's estimate is an exact 18-dp USD value. Fold it to whole cents for
|
||||
// the balance check via cloud's typed money.Amount.Cents() — a sub-cent
|
||||
// estimate gates as 0 (any-positive-balance), matching the edge gate's
|
||||
// AmountCents contract. The post-serve Meter debits the exact 18-dp.
|
||||
//
|
||||
// The fold is Cents() on the CREDIT amount, never Minor() on the zen one:
|
||||
// Minor() renders money.USD's 2 decimals, so it returned cents that FromInt
|
||||
// then read as atto — every estimate came back 0, and AuthorizeVerdict skips
|
||||
// its `available >= AmountCents` check when AmountCents is 0, admitting a
|
||||
// request of ANY size against any positive balance.
|
||||
cents := credit(est).Cents()
|
||||
v, err := m.AuthorizeVerdict(ctx, metering.AuthInput{
|
||||
User: t.BillingOrg,
|
||||
Org: t.BillingOrg,
|
||||
@@ -158,25 +163,106 @@ func (g commerceMeterImpl) Record(ctx context.Context, u zen.Usage) {
|
||||
if u.Tenant.BillingOrg == "" {
|
||||
return // never debit an unattributable request
|
||||
}
|
||||
usage := metering.Usage{
|
||||
User: u.Tenant.BillingOrg,
|
||||
Org: u.Tenant.BillingOrg,
|
||||
Actor: u.Tenant.User,
|
||||
Model: u.Model,
|
||||
Provider: zenProvider,
|
||||
Service: zenService,
|
||||
Project: u.Tenant.Project,
|
||||
PromptTokens: u.PromptTokens,
|
||||
// Beside the commerce debit, land the SAME warehouse row + gen_ai span every
|
||||
// native ai path writes (TraceServedUsage = recordTrace WITHOUT recordUsage —
|
||||
// the debit below is the one billing source, never doubled). zen knows its
|
||||
// EXACT per-tier retail (Charge) and upstream COGS (Cost), so the row carries
|
||||
// true margin (credit → nano). Without this, zen* traffic is
|
||||
// warehouse/o11y-blind exactly where prod runs (the unified binary).
|
||||
aicontrollers.TraceServedUsage(context.Background(), aicontrollers.ServedUsage{
|
||||
Owner: u.Tenant.BillingOrg,
|
||||
User: u.Tenant.User,
|
||||
Model: u.Model,
|
||||
Provider: zenProvider,
|
||||
RequestID: u.RequestID,
|
||||
Status: "success",
|
||||
PromptTokens: u.PromptTokens,
|
||||
CompletionTokens: u.CompletionTokens,
|
||||
TotalTokens: u.PromptTokens + u.CompletionTokens,
|
||||
Amount: cloudmoney.FromInt(u.Cost.Minor()), // exact 18-dp USD, no floor
|
||||
RequestID: u.RequestID,
|
||||
Currency: "usd",
|
||||
Status: "success",
|
||||
}
|
||||
BilledNano: nano(credit(u.Charge)),
|
||||
CostNano: nano(credit(u.Cost)),
|
||||
})
|
||||
// Detached: the request context is recycled once the handler returns, so a
|
||||
// background context carries the debit to commerce without racing the reply.
|
||||
usage := meterUsage(u)
|
||||
go func() { _, _ = g.m.Record(context.Background(), usage) }()
|
||||
|
||||
// Enso learning ledger: the embedded zen mount serves the zen catalog in-process
|
||||
// and never reaches ai's pipeToFamily, so ai's family-event writer never runs for
|
||||
// zen traffic. Write the SAME RoutingEvent here (source="family") through the ONE
|
||||
// shared writer, keyed on the client-visible response id (zen.Usage.ResponseID), so
|
||||
// zen* calls land in the same ledger — stats, world, spark retrain, and /v1/feedback
|
||||
// all read these rows. No prompt text; no shadow (zen.Usage carries no request
|
||||
// text — that stays the auto/enso-proxy path's job). Fire-and-forget.
|
||||
owner := u.Tenant.Org
|
||||
if owner == "" {
|
||||
owner = u.Tenant.BillingOrg
|
||||
}
|
||||
go aiobject.RecordFamilyRouting(aiobject.FamilyRoutingInput{
|
||||
Owner: owner,
|
||||
User: u.Tenant.User,
|
||||
RequestedModel: u.Model,
|
||||
RoutedModel: u.Upstream,
|
||||
ResponseId: u.ResponseID,
|
||||
PromptTokens: u.PromptTokens,
|
||||
CompletionTokens: u.CompletionTokens,
|
||||
CostCents: credit(u.Charge).Cents(),
|
||||
RouterEndpoint: os.Getenv("ROUTER_ENDPOINT"),
|
||||
})
|
||||
}
|
||||
|
||||
// meterUsage projects a served zen.Usage onto the commerce debit. It is the ONE
|
||||
// place the debit's amount is chosen, and it is pure — no ledger, no warehouse —
|
||||
// so the money property is a unit test rather than an integration.
|
||||
//
|
||||
// The amount is the RETAIL Charge: what the caller pays. Cost is the upstream
|
||||
// COGS we pay to serve the call; it is never the debit. It rides only the
|
||||
// warehouse row (CostNano), where margin = Charge − Cost stays exact. Debiting
|
||||
// Cost would collect our own COGS and book zero margin on every zen call — and
|
||||
// because the affiliate and OSS payout bases read this debit, it would fund
|
||||
// their shares out of principal. This mirrors ai, whose debit is likewise the
|
||||
// customer price (usageBilledCents), never its CostIn/CostOut COGS.
|
||||
func meterUsage(u zen.Usage) metering.Usage {
|
||||
return metering.Usage{
|
||||
User: u.Tenant.BillingOrg,
|
||||
Org: u.Tenant.BillingOrg,
|
||||
Actor: u.Tenant.User,
|
||||
Model: u.Model,
|
||||
Provider: zenProvider,
|
||||
Service: zenService,
|
||||
Project: u.Tenant.Project,
|
||||
PromptTokens: u.PromptTokens,
|
||||
CompletionTokens: u.CompletionTokens,
|
||||
TotalTokens: u.PromptTokens + u.CompletionTokens,
|
||||
Amount: credit(u.Charge), // exact 18-dp USD, no floor
|
||||
RequestID: u.RequestID,
|
||||
Currency: "usd",
|
||||
Status: "success",
|
||||
}
|
||||
}
|
||||
|
||||
// credit re-denominates a zen price into cloud's credit unit. It is the ONE
|
||||
// conversion at this seam — every site below goes through it, so the unit is
|
||||
// decided once rather than re-derived per call site.
|
||||
//
|
||||
// zen prices every SKU as an exact 18-dp value tagged money.USD (meter.go:
|
||||
// money.New(<18-dp decimal>, money.USD)), and cloud's credit unit is the SAME USD
|
||||
// value at 18-dp storage scale. So the conversion carries the exact decimal across
|
||||
// and changes only the minor-unit convention: no rescale, no rounding, no factor.
|
||||
// It is right by construction because the decimal is the value — the currency's
|
||||
// Decimals is a rendering convention, not part of it.
|
||||
//
|
||||
// It must NEVER go through Amount.Minor(). money.USD declares 2 decimals, so
|
||||
// Minor() rescales zen's 18-dp value to CENTS; feeding cents to the 18-dp
|
||||
// FromInt understates the debit by 10^16 (a $17.376 charge debits $0.0000000000000017),
|
||||
// and folds every sub-cent charge to a zero the ledger drops entirely.
|
||||
func credit(a hmoney.Amount) cloudmoney.Amount { return cloudmoney.FromDecimal(a.Decimal()) }
|
||||
|
||||
// nano folds an exact credit Amount to nano-USD (1e-9) for the warehouse margin
|
||||
// columns. It takes the typed Amount rather than a bare *big.Int so the unit is
|
||||
// carried by the type: a cents integer is not a cloudmoney.Amount and can no
|
||||
// longer be passed here. A single request's cost always fits int64 at nano.
|
||||
func nano(a cloudmoney.Amount) int64 {
|
||||
return new(big.Int).Div(a.Atto(), big.NewInt(1_000_000_000)).Int64()
|
||||
}
|
||||
|
||||
// zenService is the commerce service axis zen* spend attributes to. zen serves
|
||||
@@ -0,0 +1,305 @@
|
||||
// Copyright 2026 Hanzo AI Inc. All Rights Reserved.
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/metering"
|
||||
cloudmoney "github.com/hanzoai/cloud/clients/money"
|
||||
"github.com/hanzoai/decimal"
|
||||
hmoney "github.com/hanzoai/money"
|
||||
"github.com/hanzoai/zen"
|
||||
)
|
||||
|
||||
// usd builds an exact USD amount from a decimal string, the way zen prices a
|
||||
// call (18-dp native, never through float).
|
||||
//
|
||||
// The decimal here must be hanzoai/decimal, the one hmoney.New takes and the one
|
||||
// zen prices with — not shopspring's identically-named type. Money has exactly one
|
||||
// decimal; a second one that merely LOOKS like it is how a price silently becomes
|
||||
// a different number.
|
||||
func usd(t *testing.T, s string) hmoney.Amount {
|
||||
t.Helper()
|
||||
d, err := decimal.Parse(s)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", s, err)
|
||||
}
|
||||
return hmoney.New(d, hmoney.USD)
|
||||
}
|
||||
|
||||
// zen5Usage is one served call at zen5's live rates: 1M in + 1M out, priced at
|
||||
// the family's 3× margin (retail = cost × margin).
|
||||
//
|
||||
// in : cost 1.392 → retail 4.176
|
||||
// out: cost 4.40 → retail 13.20
|
||||
func zen5Usage(t *testing.T) zen.Usage {
|
||||
t.Helper()
|
||||
return zen.Usage{
|
||||
Tenant: zen.Tenant{BillingOrg: "acme", User: "acme/alice", Project: "p1"},
|
||||
Model: "zen5",
|
||||
PromptTokens: 1_000_000,
|
||||
CompletionTokens: 1_000_000,
|
||||
Charge: usd(t, "17.376"), // 4.176 + 13.20 — what the caller pays
|
||||
Cost: usd(t, "5.792"), // 1.392 + 4.40 — what we pay upstream
|
||||
RequestID: "req-1",
|
||||
}
|
||||
}
|
||||
|
||||
// dollars is the EXPECTED money, built from a plain dollar literal through cloud's
|
||||
// OWN ParseUSD — deliberately a DIFFERENT constructor than the code under test uses.
|
||||
//
|
||||
// That is what pins the UNIT. These tests once asserted meterUsage(u).Amount.Int()
|
||||
// against u.Charge.Minor(): both sides re-derived the number through the same
|
||||
// conversion, so the assertion only proved an integer round-tripped and was blind
|
||||
// to what the integer MEANT. It passed while every zen debit was 10^16 too small.
|
||||
// Comparing money to a known dollar amount cannot be blind that way: if the debit
|
||||
// is off by any factor, it is not $17.376 and the test fails.
|
||||
func dollars(t *testing.T, s string) cloudmoney.Amount {
|
||||
t.Helper()
|
||||
a, err := cloudmoney.ParseUSD(s)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", s, err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// The debit is the RETAIL Charge — what the caller pays — never the upstream
|
||||
// COGS. This is the money property: it fails if the Amount is built from
|
||||
// u.Cost. The tier is margin-bearing (retail != cost), so the two values are
|
||||
// distinguishable and the assertion cannot pass by coincidence.
|
||||
func TestMeterUsageDebitsRetailNotCost(t *testing.T) {
|
||||
u := zen5Usage(t)
|
||||
|
||||
if u.Charge.Cmp(u.Cost) == 0 {
|
||||
t.Fatal("fixture is not margin-bearing: retail == cost, so the test could not tell them apart")
|
||||
}
|
||||
|
||||
got := meterUsage(u).Amount
|
||||
|
||||
// The known dollar value the fixture charges — a $17.376 call debits $17.376.
|
||||
if want := dollars(t, "17.376"); got.Cmp(want) != 0 {
|
||||
t.Errorf("debit = $%s, want the retail Charge $%s", got, want)
|
||||
}
|
||||
if cogs := dollars(t, "5.792"); got.Cmp(cogs) == 0 {
|
||||
t.Errorf("debit = $%s, which is the upstream COGS — the caller must be billed retail, not our cost", got)
|
||||
}
|
||||
}
|
||||
|
||||
// At the family's 3× margin the debit is exactly 3× the COGS: we collect the
|
||||
// full retail price, not the wholesale one. Debiting Cost would collect 1/3 —
|
||||
// our own COGS — and book zero margin.
|
||||
func TestMeterUsageCollectsTheFullMargin(t *testing.T) {
|
||||
u := zen5Usage(t)
|
||||
|
||||
debit := meterUsage(u).Amount
|
||||
// 3x the COGS in the SAME unit as the debit — $5.792 + $5.792 + $5.792.
|
||||
// Summing the credit Amount keeps the comparison in exact dollars; the old
|
||||
// version multiplied Cost.Minor() (cents, 579 after rounding away 5.792's
|
||||
// third decimal) and compared it to a debit that was not cents at all.
|
||||
cogs := dollars(t, "5.792")
|
||||
thriceCOGS := cogs.Add(cogs).Add(cogs)
|
||||
|
||||
if debit.Cmp(thriceCOGS) != 0 {
|
||||
t.Errorf("debit = $%s, want 3x COGS = $%s (retail = cost x margin, margin 3.0)", debit, thriceCOGS)
|
||||
}
|
||||
if want := dollars(t, "17.376"); debit.Cmp(want) != 0 {
|
||||
t.Errorf("debit = $%s, want $%s", debit, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The debit carries zen's exact 18-dp value with no floor: a sub-cent call must
|
||||
// not round to zero on the way to the ledger.
|
||||
func TestMeterUsageKeepsExactSubCentCharge(t *testing.T) {
|
||||
u := zen5Usage(t)
|
||||
u.Charge = usd(t, "0.004176") // 1k input tokens at 4.176/MTok — well under a cent
|
||||
u.Cost = usd(t, "0.001392")
|
||||
|
||||
got := meterUsage(u).Amount
|
||||
|
||||
if want := dollars(t, "0.004176"); got.Cmp(want) != 0 {
|
||||
t.Errorf("debit = $%s, want the exact sub-cent charge $%s", got, want)
|
||||
}
|
||||
// metering.Record drops a zero Amount before it ever reaches the ledger
|
||||
// (`if !c.Enabled() || amt.IsZero() ... return nil`), so a floored sub-cent
|
||||
// charge is not a small debit — it is NO DEBIT ROW, and the call is free.
|
||||
if got.IsZero() {
|
||||
t.Error("sub-cent charge floored to zero — Record drops a zero Amount, so the call is served free with no debit row")
|
||||
}
|
||||
}
|
||||
|
||||
// The unit trap this seam shipped with, pinned so it cannot come back. zen prices
|
||||
// an exact 18-dp value but tags it money.USD, whose Currency declares 2 decimals —
|
||||
// so Charge.Minor() renders CENTS, while cloudmoney.FromInt reads its argument as
|
||||
// 18-dp. Composing them silently divides every debit by 10^16.
|
||||
//
|
||||
// The tests above already fail if credit() regresses to that composition; this one
|
||||
// names WHY, and proves the two conversions are still distinguishable — an
|
||||
// assertion that cannot tell right from wrong is worse than no assertion.
|
||||
func TestCreditIsNotTheMinorUnit(t *testing.T) {
|
||||
charge := usd(t, "17.376")
|
||||
|
||||
if got, want := credit(charge), dollars(t, "17.376"); got.Cmp(want) != 0 {
|
||||
t.Fatalf("credit($17.376) = $%s, want $%s", got, want)
|
||||
}
|
||||
|
||||
// The conversion that shipped, spelled out.
|
||||
old := cloudmoney.FromAtto(charge.Minor())
|
||||
if old.Cmp(credit(charge)) == 0 {
|
||||
t.Fatal("FromInt(Minor()) agrees with credit() — the fixture can no longer tell the units apart, so these tests prove nothing")
|
||||
}
|
||||
if old.Cents() != 0 {
|
||||
t.Errorf("FromInt(Minor()).Cents() = %d, want 0 — that this was ALWAYS 0 is what made the spend gate admit every request", old.Cents())
|
||||
}
|
||||
}
|
||||
|
||||
// The identity fields ride with the debit unchanged: the debit lands on the org
|
||||
// that PAYS (BillingOrg), scoped to its project, with the actor for the audit
|
||||
// trail.
|
||||
func TestMeterUsageAttribution(t *testing.T) {
|
||||
u := zen5Usage(t)
|
||||
m := meterUsage(u)
|
||||
|
||||
for _, c := range []struct{ name, got, want string }{
|
||||
{"User", m.User, "acme"},
|
||||
{"Org", m.Org, "acme"},
|
||||
{"Actor", m.Actor, "acme/alice"},
|
||||
{"Project", m.Project, "p1"},
|
||||
{"Model", m.Model, "zen5"},
|
||||
{"Provider", m.Provider, zenProvider},
|
||||
{"Service", m.Service, zenService},
|
||||
{"Currency", m.Currency, "usd"},
|
||||
} {
|
||||
if c.got != c.want {
|
||||
t.Errorf("%s = %q, want %q", c.name, c.got, c.want)
|
||||
}
|
||||
}
|
||||
if m.TotalTokens != 2_000_000 {
|
||||
t.Errorf("TotalTokens = %d, want 2000000", m.TotalTokens)
|
||||
}
|
||||
}
|
||||
|
||||
// The spend gate must REFUSE a request whose estimate exceeds the balance.
|
||||
//
|
||||
// This is the sharpest edge of the unit bug and the reason it is a security
|
||||
// finding, not only a revenue one. AuthorizeVerdict gates size like this:
|
||||
//
|
||||
// funded := available > 0
|
||||
// if in.AmountCents > 0 { funded = available >= in.AmountCents }
|
||||
//
|
||||
// The estimate reached it as FromInt(est.Minor()).Cents(), which is ALWAYS 0 —
|
||||
// so the size branch was DEAD and every request rode `available > 0`. Any org
|
||||
// with a single cent of balance could draw an unbounded call. The debits were
|
||||
// dust too, so the balance never fell and the cap could never trip.
|
||||
func TestCommerceGateRefusesAnOverCapRequest(t *testing.T) {
|
||||
const availableCents = 500 // the org holds $5.00
|
||||
|
||||
var authorized atomic.Bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/billing/balance":
|
||||
_, _ = io.WriteString(w, `{"available":`+strconv.Itoa(availableCents)+`}`)
|
||||
case "/v1/billing/spend-alerts/authorize":
|
||||
authorized.Store(true)
|
||||
_, _ = io.WriteString(w, `{"allow":true}`)
|
||||
default:
|
||||
_, _ = io.WriteString(w, `[]`)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
m, err := metering.New(metering.Config{BaseURL: srv.URL, Token: "t", Org: "acme"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gate := commerceGate(m)
|
||||
if gate == nil {
|
||||
t.Fatal("gate is nil — the client must be enabled for this proof to mean anything")
|
||||
}
|
||||
tenant := zen.Tenant{BillingOrg: "acme", User: "acme/alice", Project: "p1"}
|
||||
|
||||
// $17.376 against a $5.00 balance: over cap, must be refused.
|
||||
if err := gate(context.Background(), tenant, "zen5", usd(t, "17.376")); err == nil {
|
||||
t.Error("gate ADMITTED a $17.376 request against a $5.00 balance — the estimate is reaching AuthorizeVerdict as 0 cents, so the size check never runs")
|
||||
}
|
||||
|
||||
// The same balance must still admit a request it can actually cover, or the
|
||||
// test would pass by refusing everything.
|
||||
if err := gate(context.Background(), tenant, "zen5", usd(t, "1.00")); err != nil {
|
||||
t.Errorf("gate refused an affordable $1.00 request against a $5.00 balance: %v", err)
|
||||
}
|
||||
if !authorized.Load() {
|
||||
t.Error("the affordable request never reached the spend-cap authorize step")
|
||||
}
|
||||
}
|
||||
|
||||
// The estimate must reach the balance check as the RIGHT number of cents. The
|
||||
// refusal test above proves the gate says no; this proves it says no for the
|
||||
// right reason — that $17.376 is folded to 1738 cents, not to 0.
|
||||
func TestCommerceGateFoldsTheEstimateToRealCents(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
charge string
|
||||
want int64
|
||||
}{
|
||||
{"17.376", 1738}, // rounds half-away-from-zero at the cent
|
||||
{"1000.00", 100000},
|
||||
{"1.00", 100},
|
||||
{"0.004176", 0}, // sub-cent gates as "any positive balance"
|
||||
} {
|
||||
if got := credit(usd(t, c.charge)).Cents(); got != c.want {
|
||||
t.Errorf("$%s folds to %d cents, want %d", c.charge, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A zero-value zen price must convert and fold without panicking. zen leaves
|
||||
// Charge/Cost as the zero Amount for a free SKU, and that value carries the
|
||||
// EMPTY currency code, not "USD" — so this also pins that credit() reads the
|
||||
// decimal rather than dispatching on the currency. The old attoToNano guarded a
|
||||
// nil *big.Int here; nano() needs no guard because decimal.Coef() returns a real
|
||||
// zero big.Int, never nil, but the property is worth holding.
|
||||
func TestCreditAndNanoHandleTheZeroPrice(t *testing.T) {
|
||||
var free hmoney.Amount // zero value: no currency, no coefficient
|
||||
|
||||
got := credit(free)
|
||||
if !got.IsZero() {
|
||||
t.Errorf("credit(zero) = $%s, want $0", got)
|
||||
}
|
||||
if n := nano(got); n != 0 {
|
||||
t.Errorf("nano(credit(zero)) = %d, want 0", n)
|
||||
}
|
||||
if c := got.Cents(); c != 0 {
|
||||
t.Errorf("credit(zero).Cents() = %d, want 0", c)
|
||||
}
|
||||
// And a free call books no debit row, which is correct — nothing is owed.
|
||||
u := zen5Usage(t)
|
||||
u.Charge, u.Cost = free, free
|
||||
if amt := meterUsage(u).Amount; !amt.IsZero() {
|
||||
t.Errorf("free call debits $%s, want $0", amt)
|
||||
}
|
||||
}
|
||||
|
||||
// nano carries real money to the warehouse margin columns. It is the last place
|
||||
// the unit could silently collapse: attoToNano(cents) divided a cents integer by
|
||||
// 1e9 and produced 0 for every charge under $10,000,000.
|
||||
func TestNanoFoldsCreditToRealNano(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
charge string
|
||||
want int64
|
||||
}{
|
||||
{"17.376", 17_376_000_000},
|
||||
{"5.792", 5_792_000_000},
|
||||
{"0.004176", 4_176_000},
|
||||
{"1000.00", 1_000_000_000_000},
|
||||
} {
|
||||
if got := nano(credit(usd(t, c.charge))); got != c.want {
|
||||
t.Errorf("nano($%s) = %d, want %d", c.charge, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-2
@@ -15,6 +15,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud/audit"
|
||||
@@ -57,6 +58,18 @@ func buildAuditRecorder(cfg *Config, logger luxlog.Logger) (*audit.Recorder, err
|
||||
return nil, fmt.Errorf("open audit store: %w", err)
|
||||
}
|
||||
|
||||
// PER-SHARD audit under horizontal scale. The trail lives at {DataDir}/audit.db on
|
||||
// THIS pod's own RWO PVC, so under shard routing each pod's chain covers ONLY the
|
||||
// tenants routed to it (its shard) — and org-scoped audit queries route to the
|
||||
// owning shard where those records live. Soundness: the chain is a per-FILE hash
|
||||
// chain whose head is recovered at open; because no two pods share the file, there
|
||||
// is no cross-pod head to fork (the very failure that pinned cloud to replicas:1 was
|
||||
// two pods on ONE audit file). Integrity is preserved WITHIN each partition; a
|
||||
// deployment-wide view is the union of the N per-shard chains. The shard id is
|
||||
// stamped on the AU-9 checkpoint stream below so the external tail-truncation monitor
|
||||
// tracks N heads (one per shard) rather than expecting a single global head.
|
||||
shard := strings.TrimSpace(cfg.ShardSelf) // "" when single-pod — a harmless empty tag
|
||||
|
||||
// AU-9 tail-truncation anchor: emit a periodic head-digest checkpoint to the
|
||||
// append-only observability log (and, when a mirror supports it, an
|
||||
// independent digest store). An external o11y monitor compares consecutive
|
||||
@@ -67,7 +80,7 @@ func buildAuditRecorder(cfg *Config, logger luxlog.Logger) (*audit.Recorder, err
|
||||
if logger != nil {
|
||||
rec.StartCheckpoints(interval, func(cp audit.Checkpoint) {
|
||||
logger.Info("audit_head_checkpoint",
|
||||
"count", cp.Count, "head", cp.Head, "ts", cp.Time.Format(time.RFC3339Nano))
|
||||
"shard", shard, "count", cp.Count, "head", cp.Head, "ts", cp.Time.Format(time.RFC3339Nano))
|
||||
})
|
||||
} else {
|
||||
rec.StartCheckpoints(interval, nil)
|
||||
@@ -76,7 +89,7 @@ func buildAuditRecorder(cfg *Config, logger luxlog.Logger) (*audit.Recorder, err
|
||||
if logger != nil {
|
||||
count, head := rec.Head()
|
||||
logger.Info("audit trail ready (tamper-evident, append-only)",
|
||||
"store", dbPath, "records", count, "head", head,
|
||||
"store", dbPath, "shard", shard, "records", count, "head", head,
|
||||
"mirror", mirror != nil, "checkpoint_interval", interval.String())
|
||||
}
|
||||
return rec, nil
|
||||
|
||||
+18
-9
@@ -47,21 +47,30 @@ type iamKeys struct {
|
||||
// credential it returns a resolver that resolves nothing (keys stay anonymous —
|
||||
// never a fabricated principal), so a deployment lacking the credential is safe.
|
||||
func newIAMKeys() *iamKeys {
|
||||
base := strings.TrimRight(env("IAM_URL", "IAM_INTERNAL_URL"), "/")
|
||||
id := strings.TrimSpace(os.Getenv("IAM_MINT_CLIENT_ID"))
|
||||
secret := strings.TrimSpace(os.Getenv("IAM_MINT_CLIENT_SECRET"))
|
||||
var auth string
|
||||
if id != "" && secret != "" {
|
||||
auth = "Basic " + base64.StdEncoding.EncodeToString([]byte(id+":"+secret))
|
||||
}
|
||||
return &iamKeys{
|
||||
base: base,
|
||||
auth: auth,
|
||||
base: iamHost(),
|
||||
auth: iamCred(),
|
||||
http: &http.Client{Timeout: 5 * time.Second},
|
||||
cache: newCache[string, *idClaims](60 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
// iamHost is the standalone IAM origin cloud talks to; iamCred is the service
|
||||
// credential (client_secret_basic) it presents — the ONE IAM identity, shared by
|
||||
// the API-key resolver here and the /v1/iam edge (iam_edge.go), so both
|
||||
// authenticate to IAM the same way. Empty cred → a deployment lacking the
|
||||
// credential stays safe (the caller treats "" as unconfigured).
|
||||
func iamHost() string { return strings.TrimRight(env("IAM_URL", "IAM_INTERNAL_URL"), "/") }
|
||||
|
||||
func iamCred() string {
|
||||
id := strings.TrimSpace(os.Getenv("IAM_MINT_CLIENT_ID"))
|
||||
secret := strings.TrimSpace(os.Getenv("IAM_MINT_CLIENT_SECRET"))
|
||||
if id == "" || secret == "" {
|
||||
return ""
|
||||
}
|
||||
return "Basic " + base64.StdEncoding.EncodeToString([]byte(id+":"+secret))
|
||||
}
|
||||
|
||||
func env(names ...string) string {
|
||||
for _, n := range names {
|
||||
if v := strings.TrimSpace(os.Getenv(n)); v != "" {
|
||||
|
||||
@@ -113,6 +113,17 @@ func BrandForHost(host string) string {
|
||||
return DefaultBrand
|
||||
}
|
||||
|
||||
// brandDisplay is a brand id's human display name: the id with an upper-cased
|
||||
// first letter (lux → "Lux", hanzo → "Hanzo"). Derived from the id — one source
|
||||
// of truth with the brands registry, no hand-maintained display list. Used to
|
||||
// build the white-label console <title> (webui.go).
|
||||
func brandDisplay(id string) string {
|
||||
if id == "" {
|
||||
id = DefaultBrand
|
||||
}
|
||||
return strings.ToUpper(id[:1]) + id[1:]
|
||||
}
|
||||
|
||||
// BrandIssuers returns the OIDC issuer of every configured white-label brand. The
|
||||
// in-binary identity validator (auth_identity.go) trusts a token whose `iss` is
|
||||
// any of these, so ONE cloud binary validates hanzo AND lux/zoo/pars tokens. One
|
||||
|
||||
@@ -504,7 +504,7 @@ func EmitLifecycle(ctx context.Context, ev LifecycleEvent) {
|
||||
// pickCommerceClient resolves deps.Commerce — the typed inter-subsystem client the
|
||||
// entitlements/licensing tier calls (GetOrgConfig, CheckEntitlement). When the
|
||||
// commerce subsystem is co-resident (Enabled("commerce")) it returns the IN-PROCESS
|
||||
// client via the factory subsystems/commerce.go registers in init() — a direct Go
|
||||
// client via the factory apps/commerce.go registers in init() — a direct Go
|
||||
// call that reads the embedded commerce datastore (hanzoai/commerce MODULE, since
|
||||
// the un-fork) + the @hanzo/plans vocabulary, no network hop (the HIP-0106
|
||||
// co-resident default). The factory inversion stays because the concrete client
|
||||
@@ -533,14 +533,14 @@ func pickCommerceClient(cfg *Config, log luxlog.Logger) CommerceClient {
|
||||
}
|
||||
|
||||
// commerceClientFactory constructs the embedded in-process Commerce client.
|
||||
// subsystems/commerce.go registers it in init(); pickCommerceClient calls it so
|
||||
// apps/commerce.go registers it in init(); pickCommerceClient calls it so
|
||||
// package cloud depends on the CommerceClient interface + this hook, never the
|
||||
// concrete commerceinproc package (whose entitlement client pulls clients/plan,
|
||||
// which imports cloud — the hook is what keeps the package graph acyclic).
|
||||
var commerceClientFactory func(cfg *Config, log luxlog.Logger) CommerceClient
|
||||
|
||||
// RegisterCommerceClientFactory installs the embedded-Commerce client constructor.
|
||||
// subsystems/commerce.go calls this from its init(); exactly one registration.
|
||||
// apps/commerce.go calls this from its init(); exactly one registration.
|
||||
func RegisterCommerceClientFactory(f func(cfg *Config, log luxlog.Logger) CommerceClient) {
|
||||
commerceClientFactory = f
|
||||
}
|
||||
@@ -656,30 +656,17 @@ func pickVaultClient(cfg *Config, log luxlog.Logger) VaultClient {
|
||||
return clients.DisabledVault()
|
||||
}
|
||||
|
||||
// MountFunc is a subsystem's mount contract. app is `any`, not *zip.App, and that
|
||||
// is load-bearing: some external modules expose Mount as func(any, Deps) error
|
||||
// (e.g. hanzoai/licensing), which subsystems.Wire references DIRECTLY — a
|
||||
// func(any,…) value is not assignable to a func(*zip.App,…) parameter, so
|
||||
// narrowing the type would break them at compile time. The concrete value is
|
||||
// always a *zip.App; strongly-typed Mounts (func(*zip.App, Deps) error, what every
|
||||
// in-repo subsystem exports) are adapted by Typed, which recovers it in ONE place.
|
||||
type MountFunc func(app any, deps Deps) error
|
||||
|
||||
// Typed adapts a strongly-typed subsystem Mount — func(*zip.App, Deps) error,
|
||||
// the signature every in-repo subsystem already exports — into the registry's
|
||||
// MountFunc. It performs the *zip.App recovery in ONE place, fail-closed with a
|
||||
// clear error, so no subsystem repeats the `a, ok := app.(*zip.App)` boilerplate.
|
||||
// The concrete value MountAll passes is always a *zip.App, so the assertion is
|
||||
// total in practice; it stays as a defensive, self-documenting guard.
|
||||
func Typed(mount func(*zip.App, Deps) error) MountFunc {
|
||||
return func(app any, deps Deps) error {
|
||||
a, ok := app.(*zip.App)
|
||||
if !ok {
|
||||
return fmt.Errorf("cloud.Mount: app is %T, want *zip.App", app)
|
||||
}
|
||||
return mount(a, deps)
|
||||
}
|
||||
}
|
||||
// MountFunc is a subsystem's mount contract: register your routes on app, using
|
||||
// deps for everything shared. Every subsystem in the fleet exports exactly this
|
||||
// signature, so Wire references each one directly and the compiler checks it.
|
||||
//
|
||||
// app was once `any`, on the stated grounds that an external module (licensing)
|
||||
// exposed func(any, Deps) error and narrowing would break it — while licensing
|
||||
// said it used `any` to avoid an import cycle in pkg/cloud. Each cited the other,
|
||||
// and the cycle could not exist: this package already imports zip, and zip does
|
||||
// not import cloud. The `any` bought nothing and cost every subsystem a Typed()
|
||||
// wrapper plus a runtime type assertion whose failure branch was unreachable.
|
||||
type MountFunc func(app *zip.App, deps Deps) error
|
||||
|
||||
// ShutdownFunc releases a subsystem's process-lifetime resources (background
|
||||
// goroutines, open DB handles) on graceful shutdown. It must be idempotent and
|
||||
@@ -688,7 +675,7 @@ func Typed(mount func(*zip.App, Deps) error) MountFunc {
|
||||
type ShutdownFunc func(ctx context.Context) error
|
||||
|
||||
// MountSpec describes one subsystem to mount. There is NO Order field: the slice
|
||||
// position in subsystems.Wire() IS the mount order — the composition root lists
|
||||
// position in apps.Wire() IS the mount order — the composition root lists
|
||||
// subsystems in the exact sequence they mount (and, reversed, tear down), so order
|
||||
// is data read top-to-bottom in one file, not ints scattered across the tree.
|
||||
type MountSpec struct {
|
||||
@@ -703,9 +690,8 @@ type MountSpec struct {
|
||||
}
|
||||
|
||||
// MountAll mounts every ENABLED subsystem in specs, in slice order — the order is
|
||||
// the composition root's (subsystems.Wire()); MountAll does NOT sort. app is the
|
||||
// concrete *zip.App from Serve; the MountFunc accepts it as `any` and in-repo
|
||||
// subsystems recover it via Typed.
|
||||
// the composition root's (apps.Wire()); MountAll does NOT sort. app is the
|
||||
// concrete *zip.App from Serve, handed to each MountFunc as itself.
|
||||
//
|
||||
// Teardown is wired HERE, at mount time: right after a subsystem mounts, its
|
||||
// ShutdownFunc (if any) is registered via app.OnShutdown. zip drains those hooks
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
|
||||
// noopMount mounts nothing: the fake specs below carry the behavior under test in
|
||||
// their Shutdown, not their Mount.
|
||||
func noopMount(any, cloud.Deps) error { return nil }
|
||||
func noopMount(*zip.App, cloud.Deps) error { return nil }
|
||||
|
||||
// freeAddr reserves an ephemeral loopback port and hands back its address; the
|
||||
// listener is closed so the app under test can bind it.
|
||||
|
||||
+18
-39
@@ -1,49 +1,28 @@
|
||||
package cloud_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// TestTyped_RecoversZipApp verifies cloud.Typed adapts a strongly-typed
|
||||
// func(*zip.App, Deps) into the registry MountFunc: it hands the concrete
|
||||
// *zip.App straight through to the wrapped mount.
|
||||
func TestTyped_RecoversZipApp(t *testing.T) {
|
||||
app := zip.New(zip.Config{})
|
||||
var got *zip.App
|
||||
mf := cloud.Typed(func(a *zip.App, _ cloud.Deps) error {
|
||||
got = a
|
||||
return nil
|
||||
})
|
||||
if err := mf(app, cloud.Deps{}); err != nil {
|
||||
t.Fatalf("Typed mount returned error: %v", err)
|
||||
}
|
||||
if got != app {
|
||||
t.Fatalf("Typed did not pass the concrete *zip.App through (got %p, want %p)", got, app)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTyped_WrongTypeFailsClosed verifies cloud.Typed fails closed with a clear
|
||||
// error — never a panic — when the registry passes a value that is not a
|
||||
// *zip.App. This is the single, central replacement for the per-subsystem
|
||||
// assertion boilerplate.
|
||||
func TestTyped_WrongTypeFailsClosed(t *testing.T) {
|
||||
called := false
|
||||
mf := cloud.Typed(func(*zip.App, cloud.Deps) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
err := mf("not-a-zip-app", cloud.Deps{})
|
||||
if err == nil {
|
||||
t.Fatal("Typed must return an error on a non-*zip.App value")
|
||||
}
|
||||
if called {
|
||||
t.Fatal("Typed must NOT invoke the wrapped mount on a type mismatch")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "*zip.App") {
|
||||
t.Errorf("error should name the wanted type *zip.App, got: %v", err)
|
||||
}
|
||||
// TestMountFunc_IsTheSubsystemSignature pins the registry's mount contract: the
|
||||
// signature every subsystem exports IS a cloud.MountFunc, checked by the compiler.
|
||||
//
|
||||
// This file used to test cloud.Typed, the adapter that took a MountFunc's `any`
|
||||
// app and asserted it back to *zip.App. Both of its tests went with it, and
|
||||
// neither is a loss:
|
||||
//
|
||||
// - "Typed recovers the *zip.App" only ever proved the adapter handed through
|
||||
// the value it was given. MountFunc now names *zip.App, so there is no
|
||||
// recovery step left to get wrong.
|
||||
// - "Typed fails closed on a wrong type" can no longer be written: passing
|
||||
// "not-a-zip-app" to a MountFunc is a compile error, so the runtime branch it
|
||||
// exercised does not exist. A test asserting a wrong type is rejected is
|
||||
// precisely what a type already is.
|
||||
//
|
||||
// What remains is the only claim worth making, and the build enforces it.
|
||||
func TestMountFunc_IsTheSubsystemSignature(t *testing.T) {
|
||||
var _ cloud.MountFunc = func(*zip.App, cloud.Deps) error { return nil }
|
||||
}
|
||||
|
||||
+128
-22
@@ -160,24 +160,21 @@ type loginFlags struct {
|
||||
}
|
||||
|
||||
func runLogin(env *Env, lf *loginFlags, cmd *cobra.Command) error {
|
||||
creds, err := LoadCredentials()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var creds *Credentials
|
||||
|
||||
switch {
|
||||
case lf.token != "":
|
||||
// Paste an externally-minted token. Decode claims for identity.
|
||||
tr := &tokenResp{AccessToken: lf.token, TokenType: "Bearer"}
|
||||
creds = credsFromToken(tr)
|
||||
creds = credsFromToken(&tokenResp{AccessToken: lf.token, TokenType: "Bearer"})
|
||||
case lf.username != "" || lf.passwordStdin:
|
||||
// Password grant — kept for automation (--username/--password-stdin).
|
||||
username := lf.username
|
||||
if username == "" {
|
||||
username, err = prompt(cmd, "Email: ")
|
||||
u, err := prompt(cmd, "Email: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
username = u
|
||||
}
|
||||
password, err := readPassword(cmd, lf.passwordStdin)
|
||||
if err != nil {
|
||||
@@ -192,14 +189,15 @@ func runLogin(env *Env, lf *loginFlags, cmd *cobra.Command) error {
|
||||
default:
|
||||
// The ONE interactive way: RFC 8628 device flow — link + QR + code,
|
||||
// approve from any signed-in browser or phone. Headless-safe.
|
||||
creds, err = runDeviceLogin(cmd, env, lf.scope)
|
||||
c, err := runDeviceLogin(cmd, env, lf.scope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
creds = c
|
||||
}
|
||||
|
||||
// Optional machine-to-machine tokens for the platform control plane,
|
||||
// stored alongside the identity so apps/deploy work post-login.
|
||||
// stored with this identity so apps/deploy work post-login.
|
||||
if lf.platformToken != "" {
|
||||
creds.PlatformToken = lf.platformToken
|
||||
}
|
||||
@@ -207,14 +205,23 @@ func runLogin(env *Env, lf *loginFlags, cmd *cobra.Command) error {
|
||||
creds.BuildToken = lf.buildToken
|
||||
}
|
||||
|
||||
if err := creds.Save(); err != nil {
|
||||
// Persist under this identity's stable key and make it active. A second
|
||||
// login as a different owner (e.g. admin vs hanzo for the same email, via a
|
||||
// different --client-id) is stored beside the first, never over it;
|
||||
// credentials.json mirrors whichever is active for legacy readers.
|
||||
store, err := LoadIdentities()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := store.Put(creds)
|
||||
if err := store.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
who := firstNonEmpty(creds.Subject, "(unknown)")
|
||||
if creds.Owner != "" {
|
||||
who += " @ " + creds.Owner
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Logged in as %s (token expires %s)\n", who, shortTime(creds.Expiry))
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Logged in as %s [%s] (token expires %s)\n", who, key, shortTime(creds.Expiry))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -249,28 +256,92 @@ func bindLoginFlags(cmd *cobra.Command, lf *loginFlags) {
|
||||
|
||||
func newLogoutCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "logout",
|
||||
Short: "Remove stored credentials",
|
||||
Args: cobra.NoArgs,
|
||||
Use: "logout [<owner>]",
|
||||
Short: "Remove a stored identity (the active one, or the named owner)",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
PersistentPreRunE: func(*cobra.Command, []string) error { return nil },
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if err := DeleteCredentials(); err != nil {
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
store, err := LoadIdentities()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "Logged out.")
|
||||
if len(store.Identities) == 0 {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "Not logged in.")
|
||||
return nil
|
||||
}
|
||||
target := store.Active
|
||||
if len(args) == 1 {
|
||||
if target, err = store.resolve(args[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
store.Remove(target)
|
||||
if err := store.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
msg := "Logged out of " + target + "."
|
||||
if store.Active != "" {
|
||||
msg += " Active is now " + store.Active + "."
|
||||
}
|
||||
fmt.Fprintln(cmd.OutOrStdout(), msg)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// identityRow is the JSON/table projection of one stored identity.
|
||||
type identityRow struct {
|
||||
Key string `json:"key"`
|
||||
Owner string `json:"owner"`
|
||||
Subject string `json:"subject"`
|
||||
Expiry int64 `json:"expiry,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
// listIdentities renders every stored identity (active marked with *) — the
|
||||
// shared body of `hanzo auth list` and `hanzo whoami --all`.
|
||||
func listIdentities(env *Env, _ *cobra.Command) error {
|
||||
store, err := LoadIdentities()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([]identityRow, 0, len(store.Identities))
|
||||
for _, k := range store.keys() {
|
||||
c := store.Identities[k]
|
||||
rows = append(rows, identityRow{
|
||||
Key: k, Owner: c.Owner, Subject: c.Subject, Expiry: c.Expiry,
|
||||
Active: k == store.Active,
|
||||
})
|
||||
}
|
||||
return env.emit(rows, func(w io.Writer) {
|
||||
if len(rows) == 0 {
|
||||
fmt.Fprintln(w, "No stored identities. Run `hanzo login`.")
|
||||
return
|
||||
}
|
||||
tw := newTab(w)
|
||||
fmt.Fprintln(tw, "ACTIVE\tKEY\tOWNER\tSUBJECT\tEXPIRES")
|
||||
for _, r := range rows {
|
||||
active := ""
|
||||
if r.Active {
|
||||
active = "*"
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", active, r.Key, r.Owner, r.Subject, shortTime(r.Expiry))
|
||||
}
|
||||
tw.Flush()
|
||||
})
|
||||
}
|
||||
|
||||
func newWhoamiCmd(envOf func() *Env) *cobra.Command {
|
||||
var verify bool
|
||||
var verify, all bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "whoami",
|
||||
Short: "Show the current identity from the stored token",
|
||||
Short: "Show the active identity from the stored token (--all lists every stored identity)",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
env := envOf()
|
||||
if all {
|
||||
return listIdentities(env, cmd)
|
||||
}
|
||||
tok := env.accessToken()
|
||||
if tok == "" {
|
||||
return fmt.Errorf("not logged in: run `hanzo login`")
|
||||
@@ -300,6 +371,7 @@ func newWhoamiCmd(envOf func() *Env) *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&verify, "verify", false, "verify the token against the IAM userinfo endpoint")
|
||||
cmd.Flags().BoolVar(&all, "all", false, "list every stored identity (active marked with *)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -329,11 +401,11 @@ func verifyUserInfo(ctx context.Context, env *Env, token string) error {
|
||||
func newAuthCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "Manage authentication",
|
||||
Short: "Manage authentication and stored identities",
|
||||
}
|
||||
tokenCmd := &cobra.Command{
|
||||
Use: "token",
|
||||
Short: "Print the stored access token",
|
||||
Short: "Print the active access token",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
tok := envOf().accessToken()
|
||||
@@ -344,7 +416,41 @@ func newAuthCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(newLoginCmd(envOf, gf), newLogoutCmd(), newWhoamiCmd(envOf), tokenCmd)
|
||||
listCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"ls", "identities"},
|
||||
Short: "List stored identities (active marked with *)",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error { return listIdentities(envOf(), cmd) },
|
||||
}
|
||||
switchCmd := &cobra.Command{
|
||||
Use: "switch <owner>",
|
||||
Aliases: []string{"use"},
|
||||
Short: "Make a stored identity active (accepts an owner, or a full owner/name key)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
store, err := LoadIdentities()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key, err := store.resolve(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store.Active = key
|
||||
if err := store.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
c := store.Identities[key]
|
||||
who := firstNonEmpty(c.Subject, "(unknown)")
|
||||
if c.Owner != "" {
|
||||
who += " @ " + c.Owner
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Switched to %s [%s] (token expires %s)\n", who, key, shortTime(c.Expiry))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(newLoginCmd(envOf, gf), newLogoutCmd(), newWhoamiCmd(envOf), tokenCmd, listCmd, switchCmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -208,3 +208,178 @@ func TestAuthTokenCommand(t *testing.T) {
|
||||
t.Fatalf("auth token output: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultiIdentityLoginSwitch is the full multi-identity story: two logins for
|
||||
// the same email under different owners (admin vs hanzo — the privilege-
|
||||
// separation case) coexist, `auth list` shows both, `switch` flips the active
|
||||
// pointer and rewrites credentials.json, and legacy single-file readers always
|
||||
// see the active identity.
|
||||
func TestMultiIdentityLoginSwitch(t *testing.T) {
|
||||
sandbox(t)
|
||||
adminTok := makeJWT(map[string]any{"email": "z@hanzo.ai", "owner": "admin", "sub": "u-admin", "exp": float64(2000000000)})
|
||||
hanzoTok := makeJWT(map[string]any{"email": "z@hanzo.ai", "owner": "hanzo", "sub": "u-hanzo", "exp": float64(2000000001)})
|
||||
|
||||
// First login → admin/z is stored and active.
|
||||
out, err := runRoot(t, "", "login", "--token", adminTok)
|
||||
if err != nil {
|
||||
t.Fatalf("login admin: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "admin/z") {
|
||||
t.Fatalf("login should report the key: %q", out)
|
||||
}
|
||||
if c, _ := LoadCredentials(); c.Owner != "admin" || c.Subject != "z@hanzo.ai" {
|
||||
t.Fatalf("active not admin after first login: %+v", c)
|
||||
}
|
||||
|
||||
// Second login (different owner) → added beside admin/z, becomes active,
|
||||
// does NOT clobber the first.
|
||||
if _, err := runRoot(t, "", "login", "--token", hanzoTok); err != nil {
|
||||
t.Fatalf("login hanzo: %v", err)
|
||||
}
|
||||
store, err := LoadIdentities()
|
||||
if err != nil {
|
||||
t.Fatalf("load identities: %v", err)
|
||||
}
|
||||
if len(store.Identities) != 2 {
|
||||
t.Fatalf("want 2 identities, got %d: %v", len(store.Identities), store.keys())
|
||||
}
|
||||
if store.Identities["admin/z"] == nil || store.Identities["hanzo/z"] == nil {
|
||||
t.Fatalf("both identities must persist, got %v", store.keys())
|
||||
}
|
||||
if store.Active != "hanzo/z" {
|
||||
t.Fatalf("active = %q, want hanzo/z (last login)", store.Active)
|
||||
}
|
||||
// Legacy reader sees the active (hanzo) identity.
|
||||
if c, _ := LoadCredentials(); c.Owner != "hanzo" {
|
||||
t.Fatalf("credentials.json not mirroring active: %+v", c)
|
||||
}
|
||||
|
||||
// auth list shows both, with the active row marked.
|
||||
out, err = runRoot(t, "", "auth", "list")
|
||||
if err != nil {
|
||||
t.Fatalf("auth list: %v", err)
|
||||
}
|
||||
for _, want := range []string{"admin/z", "hanzo/z", "z@hanzo.ai", "*"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("auth list missing %q in:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
|
||||
// switch admin → active flips + credentials.json is rewritten to admin.
|
||||
if _, err := runRoot(t, "", "auth", "switch", "admin"); err != nil {
|
||||
t.Fatalf("auth switch admin: %v", err)
|
||||
}
|
||||
if st, _ := LoadIdentities(); st.Active != "admin/z" {
|
||||
t.Fatalf("active after switch = %q, want admin/z", st.Active)
|
||||
}
|
||||
if c, _ := LoadCredentials(); c.Owner != "admin" || c.Subject != "z@hanzo.ai" {
|
||||
t.Fatalf("switch did not rewrite credentials.json: %+v", c)
|
||||
}
|
||||
|
||||
// whoami (top-level, reads the active token) reflects admin.
|
||||
out, err = runRoot(t, "", "whoami")
|
||||
if err != nil {
|
||||
t.Fatalf("whoami: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "admin") || !strings.Contains(out, "z@hanzo.ai") {
|
||||
t.Fatalf("whoami not reflecting the switched-to identity: %q", out)
|
||||
}
|
||||
|
||||
// switch by the full owner/name key works too.
|
||||
if _, err := runRoot(t, "", "auth", "switch", "hanzo/z"); err != nil {
|
||||
t.Fatalf("auth switch hanzo/z: %v", err)
|
||||
}
|
||||
if c, _ := LoadCredentials(); c.Owner != "hanzo" {
|
||||
t.Fatalf("switch by full key failed: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthListJSON checks the machine-readable projection.
|
||||
func TestAuthListJSON(t *testing.T) {
|
||||
sandbox(t)
|
||||
tok := makeJWT(map[string]any{"email": "z@hanzo.ai", "owner": "admin", "sub": "a"})
|
||||
if _, err := runRoot(t, "", "login", "--token", tok); err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
out, err := runRoot(t, "", "auth", "list", "-o", "json")
|
||||
if err != nil {
|
||||
t.Fatalf("auth list json: %v", err)
|
||||
}
|
||||
var rows []identityRow
|
||||
if err := json.Unmarshal([]byte(out), &rows); err != nil {
|
||||
t.Fatalf("json unmarshal: %v\n%s", err, out)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].Key != "admin/z" || rows[0].Owner != "admin" || !rows[0].Active {
|
||||
t.Fatalf("json rows wrong: %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogoutOneOfMany removes a single identity and, only when the last one is
|
||||
// gone, clears the store entirely.
|
||||
func TestLogoutOneOfMany(t *testing.T) {
|
||||
sandbox(t)
|
||||
admin := makeJWT(map[string]any{"email": "z@hanzo.ai", "owner": "admin", "sub": "a"})
|
||||
hanzo := makeJWT(map[string]any{"email": "z@hanzo.ai", "owner": "hanzo", "sub": "h"})
|
||||
if _, err := runRoot(t, "", "login", "--token", admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := runRoot(t, "", "login", "--token", hanzo); err != nil { // active = hanzo/z
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// logout of the named owner (admin) leaves hanzo/z active.
|
||||
if _, err := runRoot(t, "", "logout", "admin"); err != nil {
|
||||
t.Fatalf("logout admin: %v", err)
|
||||
}
|
||||
store, _ := LoadIdentities()
|
||||
if store.Identities["admin/z"] != nil {
|
||||
t.Fatalf("admin/z not removed: %v", store.keys())
|
||||
}
|
||||
if store.Active != "hanzo/z" {
|
||||
t.Fatalf("active = %q, want hanzo/z", store.Active)
|
||||
}
|
||||
if c, _ := LoadCredentials(); c.Owner != "hanzo" {
|
||||
t.Fatalf("credentials.json not mirroring survivor: %+v", c)
|
||||
}
|
||||
|
||||
// logout of the active (no arg) removes the last identity → both files gone.
|
||||
if _, err := runRoot(t, "", "logout"); err != nil {
|
||||
t.Fatalf("logout active: %v", err)
|
||||
}
|
||||
if c, _ := LoadCredentials(); c.AccessToken != "" {
|
||||
t.Fatalf("credentials.json not cleared: %+v", c)
|
||||
}
|
||||
if st, _ := LoadIdentities(); len(st.Identities) != 0 {
|
||||
t.Fatalf("identity store not cleared: %v", st.keys())
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateLegacyCredentials proves a pre-multi-identity credentials.json is
|
||||
// adopted into the store and preserved when a new identity is added.
|
||||
func TestMigrateLegacyCredentials(t *testing.T) {
|
||||
sandbox(t)
|
||||
// Simulate an old single-file login: only credentials.json exists.
|
||||
legacy := credsFromToken(&tokenResp{AccessToken: makeJWT(map[string]any{
|
||||
"email": "z@hanzo.ai", "owner": "hanzo", "sub": "h",
|
||||
})})
|
||||
if err := legacy.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := LoadIdentities()
|
||||
if err != nil {
|
||||
t.Fatalf("load identities: %v", err)
|
||||
}
|
||||
if store.Identities["hanzo/z"] == nil || store.Active != "hanzo/z" {
|
||||
t.Fatalf("legacy credentials not migrated: active=%q keys=%v", store.Active, store.keys())
|
||||
}
|
||||
// A fresh login as a different owner preserves the migrated identity.
|
||||
if _, err := runRoot(t, "", "login", "--token", makeJWT(map[string]any{
|
||||
"email": "z@hanzo.ai", "owner": "admin", "sub": "a",
|
||||
})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st2, _ := LoadIdentities()
|
||||
if len(st2.Identities) != 2 || st2.Identities["hanzo/z"] == nil {
|
||||
t.Fatalf("migrated identity lost after new login: %v", st2.keys())
|
||||
}
|
||||
}
|
||||
|
||||
+181
-1
@@ -55,7 +55,7 @@ var controlCommands = map[string]string{
|
||||
"login": "authenticate against Hanzo IAM (hanzo.id) and store a token",
|
||||
"logout": "remove stored credentials",
|
||||
"whoami": "show the current identity from the stored token",
|
||||
"auth": "manage authentication (login, logout, whoami, token)",
|
||||
"auth": "manage authentication + stored identities (login, logout, whoami, list, switch, token)",
|
||||
"apps": "list/get the platform apps board (declared/running/drift)",
|
||||
"deploy": "drive a platform redeploy (rolling restart, zero-downtime)",
|
||||
"clusters": "provision/list/select dedicated DOKS clusters",
|
||||
@@ -236,6 +236,170 @@ func DeleteCredentials() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identity store — ~/.hanzo/identities.json. Holds EVERY logged-in identity
|
||||
// keyed by its stable "<owner>/<name>" key, with an Active pointer. On every
|
||||
// write the active identity is mirrored into credentials.json (above), so every
|
||||
// legacy single-file reader keeps seeing the current identity unchanged. This
|
||||
// is the ONE credential store; credentials.json is its active-view mirror.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// IdentityStore is the on-disk shape of ~/.hanzo/identities.json.
|
||||
type IdentityStore struct {
|
||||
Active string `json:"active,omitempty"`
|
||||
Identities map[string]*Credentials `json:"identities,omitempty"`
|
||||
}
|
||||
|
||||
// key is the stable per-identity store key "<owner>/<name>", where name is the
|
||||
// email local-part (else the raw subject). The same identity yields the same
|
||||
// key every login, so re-login updates in place; the same email under a
|
||||
// different org (privilege separation) yields a distinct key (admin/z vs
|
||||
// hanzo/z) and is stored side by side rather than clobbering.
|
||||
func (c *Credentials) key() string {
|
||||
name := c.Subject
|
||||
if i := strings.IndexByte(name, '@'); i > 0 {
|
||||
name = name[:i]
|
||||
}
|
||||
return firstNonEmpty(c.Owner, "-") + "/" + firstNonEmpty(name, "-")
|
||||
}
|
||||
|
||||
func identitiesPath() (string, error) {
|
||||
dir, err := hanzoDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "identities.json"), nil
|
||||
}
|
||||
|
||||
// LoadIdentities reads the store. A pre-existing single-file credentials.json
|
||||
// with no store yet is migrated in (read-only) as the sole, active identity, so
|
||||
// upgrades are seamless — the first write persists it into the store.
|
||||
func LoadIdentities() (*IdentityStore, error) {
|
||||
p, err := identitiesPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &IdentityStore{Identities: map[string]*Credentials{}}
|
||||
if err := loadJSON(p, s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.Identities == nil {
|
||||
s.Identities = map[string]*Credentials{}
|
||||
}
|
||||
if len(s.Identities) == 0 {
|
||||
if c, err := LoadCredentials(); err == nil && c.AccessToken != "" {
|
||||
k := c.key()
|
||||
s.Identities[k] = c
|
||||
s.Active = k
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// keys returns the identity keys, sorted, for deterministic output.
|
||||
func (s *IdentityStore) keys() []string {
|
||||
ks := make([]string, 0, len(s.Identities))
|
||||
for k := range s.Identities {
|
||||
ks = append(ks, k)
|
||||
}
|
||||
sort.Strings(ks)
|
||||
return ks
|
||||
}
|
||||
|
||||
// Put stores c under its key and makes it active, returning the key.
|
||||
func (s *IdentityStore) Put(c *Credentials) string {
|
||||
if s.Identities == nil {
|
||||
s.Identities = map[string]*Credentials{}
|
||||
}
|
||||
k := c.key()
|
||||
s.Identities[k] = c
|
||||
s.Active = k
|
||||
return k
|
||||
}
|
||||
|
||||
// Remove deletes an identity; Save re-points Active if it was the one removed.
|
||||
func (s *IdentityStore) Remove(key string) { delete(s.Identities, key) }
|
||||
|
||||
// resolve turns a user selector into a stored key: an exact key wins; otherwise
|
||||
// a bare owner matches iff exactly one identity carries it.
|
||||
func (s *IdentityStore) resolve(sel string) (string, error) {
|
||||
if _, ok := s.Identities[sel]; ok {
|
||||
return sel, nil
|
||||
}
|
||||
var match []string
|
||||
for _, k := range s.keys() {
|
||||
if s.Identities[k].Owner == sel {
|
||||
match = append(match, k)
|
||||
}
|
||||
}
|
||||
switch len(match) {
|
||||
case 1:
|
||||
return match[0], nil
|
||||
case 0:
|
||||
return "", fmt.Errorf("no stored identity for %q (see `hanzo auth list`)", sel)
|
||||
default:
|
||||
return "", fmt.Errorf("%q is ambiguous across %s — pass the full owner/name key", sel, strings.Join(match, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// Save persists the store (0600) and mirrors the active identity into
|
||||
// credentials.json for legacy single-file readers. When the store is empty it
|
||||
// removes both files. Active is normalized to a real key first.
|
||||
func (s *IdentityStore) Save() error {
|
||||
if _, ok := s.Identities[s.Active]; !ok {
|
||||
s.Active = ""
|
||||
if ks := s.keys(); len(ks) > 0 {
|
||||
s.Active = ks[0]
|
||||
}
|
||||
}
|
||||
if len(s.Identities) == 0 {
|
||||
return clearCredentialStore()
|
||||
}
|
||||
p, err := identitiesPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeJSON(p, s, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Identities[s.Active].Save() // mirror active → credentials.json (0600)
|
||||
}
|
||||
|
||||
// SaveActive writes c back as the active identity (store + mirror), keeping the
|
||||
// two consistent after an in-place token refresh. With no store yet it falls
|
||||
// back to the single-file write.
|
||||
func SaveActive(c *Credentials) error {
|
||||
s, err := LoadIdentities()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(s.Identities) == 0 {
|
||||
return c.Save()
|
||||
}
|
||||
k := s.Active
|
||||
if k == "" || s.Identities[k] == nil {
|
||||
k = c.key()
|
||||
}
|
||||
s.Identities[k] = c
|
||||
s.Active = k
|
||||
return s.Save()
|
||||
}
|
||||
|
||||
// clearCredentialStore removes the identity store and its credentials.json
|
||||
// mirror (used by logout when the last identity is removed).
|
||||
func clearCredentialStore() error {
|
||||
for _, pathOf := range []func() (string, error){credentialsPath, identitiesPath} {
|
||||
p, err := pathOf()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Env — the effective, resolved settings a command operates with.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -294,6 +458,22 @@ func (e *Env) accessToken() string {
|
||||
return firstNonEmpty(os.Getenv("HANZO_TOKEN"), e.creds.AccessToken)
|
||||
}
|
||||
|
||||
// freshAccessToken returns the IAM user token only while it is not yet expired.
|
||||
// accessToken() stays expiry-agnostic so `hanzo whoami` can report a dead token
|
||||
// rather than masking it as logged-out; freshAccessToken is the code-agent path,
|
||||
// where an expired token would 401 a session a still-valid hk- key could serve.
|
||||
// No expiry recorded (a raw HANZO_TOKEN with no claims) ⟹ trust it as-is.
|
||||
func (e *Env) freshAccessToken() string {
|
||||
tok := e.accessToken()
|
||||
if tok == "" {
|
||||
return ""
|
||||
}
|
||||
if exp := e.creds.Expiry; exp > 0 && time.Now().Unix() >= exp {
|
||||
return ""
|
||||
}
|
||||
return tok
|
||||
}
|
||||
|
||||
// platformToken resolves the platform control-plane service token. The
|
||||
// platform REST surface is machine-to-machine (it cannot validate IAM user
|
||||
// tokens), so apps/clusters/redeploy authenticate with this, sourced from
|
||||
|
||||
+14
-4
@@ -535,11 +535,21 @@ func writeIfAbsent(path, content string) error {
|
||||
return os.WriteFile(path, []byte(content), 0o600)
|
||||
}
|
||||
|
||||
// codeToken resolves the credential the agents authenticate with: an explicit
|
||||
// API key wins, then the stored key, then the key the rest of the Hanzo
|
||||
// toolchain already keeps in ~/.hanzo/config.json, then the `hanzo login` token.
|
||||
// codeToken resolves the credential the agents authenticate with. Precedence:
|
||||
// an explicit HANZO_API_KEY always wins (the operator's deliberate override),
|
||||
// then the live `hanzo login` JWT, then the hk- API key chain.
|
||||
//
|
||||
// Why the JWT beats the hk- key: the JWT carries the caller's owner/project/sub
|
||||
// claims verbatim, so the identity boundary mints a billing principal on EVERY
|
||||
// deployment. The hk- key only mints a principal when the server can resolve it
|
||||
// (iamKeys.resolve, which is a no-op without IAM_MINT_CLIENT_ID/SECRET) — on a
|
||||
// deployment lacking that credential an hk- request arrives anonymous and zen's
|
||||
// billing gate 402s ("a billable tenant is required"). Preferring a FRESH JWT
|
||||
// keeps `hanzo code` working everywhere; the hk- key stays the fallback for
|
||||
// mint-credentialed servers and the explicit-override case. freshAccessToken
|
||||
// skips an expired token so it can't 401 a session a valid hk- key would serve.
|
||||
func codeToken(env *Env) string {
|
||||
return firstNonEmpty(os.Getenv("HANZO_API_KEY"), env.cfg.APIKey, storedAPIKey(), env.accessToken())
|
||||
return firstNonEmpty(os.Getenv("HANZO_API_KEY"), env.freshAccessToken(), env.cfg.APIKey, storedAPIKey())
|
||||
}
|
||||
|
||||
// storedAPIKey reads (never writes) the hk- key that hanzo-mcp and the rest of
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCodeAgentsBypassPermissionsByDefault(t *testing.T) {
|
||||
@@ -47,6 +48,73 @@ func TestCodeAgentsBypassPermissionsByDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodeTokenPrecedence locks in the 402 unblock: a fresh `hanzo login` JWT
|
||||
// (which carries owner/project/sub on EVERY deployment) beats the hk- API key
|
||||
// (which only mints a billing principal where the server has IAM_MINT_CLIENT_*).
|
||||
// On a mint-less deployment an hk- request arrives anonymous and zen 402s; the
|
||||
// JWT must win when it is live. An EXPIRED JWT must NOT win — it would 401 a
|
||||
// session a valid hk- key would still serve — so it falls through to the key.
|
||||
// HANZO_API_KEY stays the deliberate operator override at the top.
|
||||
func TestCodeTokenPrecedence(t *testing.T) {
|
||||
// freshExpiry is comfortably in the future without a literal unix timestamp.
|
||||
freshExpiry := time.Now().Add(1 * time.Hour).Unix()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
envKey string // HANZO_API_KEY override
|
||||
creds Credentials
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "fresh JWT beats hk- key",
|
||||
creds: Credentials{AccessToken: "jwt-live", Expiry: freshExpiry},
|
||||
want: "jwt-live",
|
||||
},
|
||||
{
|
||||
name: "expired JWT falls through to stored hk- key",
|
||||
creds: Credentials{AccessToken: "jwt-dead", Expiry: time.Now().Add(-1 * time.Hour).Unix()},
|
||||
want: "hk-stored",
|
||||
},
|
||||
{
|
||||
name: "no JWT, no expiry record ⟹ hk- key (mint-credentialed servers)",
|
||||
creds: Credentials{},
|
||||
want: "hk-stored",
|
||||
},
|
||||
{
|
||||
name: "HANZO_API_KEY overrides everything (deliberate operator override)",
|
||||
envKey: "hk-explicit",
|
||||
creds: Credentials{AccessToken: "jwt-live", Expiry: freshExpiry},
|
||||
want: "hk-explicit",
|
||||
},
|
||||
{
|
||||
name: "HANZO_API_KEY overrides even an expired JWT",
|
||||
envKey: "hk-explicit",
|
||||
creds: Credentials{AccessToken: "jwt-dead", Expiry: time.Now().Add(-1 * time.Hour).Unix()},
|
||||
want: "hk-explicit",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sandbox(t) // isolates HANZO_HOME + clears HANZO_TOKEN/HANZO_API_KEY
|
||||
if tc.envKey != "" {
|
||||
t.Setenv("HANZO_API_KEY", tc.envKey)
|
||||
}
|
||||
// Put the hk- key where storedAPIKey() reads it (~/.hanzo/config.json),
|
||||
// the same path the rest of the toolchain shares it from.
|
||||
cfgDir, _ := hanzoDir()
|
||||
if err := os.WriteFile(filepath.Join(cfgDir, "config.json"),
|
||||
[]byte(`{"apiKey":"hk-stored"}`), 0o600); err != nil {
|
||||
t.Fatalf("write config.json: %v", err)
|
||||
}
|
||||
env := resolve(&Config{}, &tc.creds, globalFlags{})
|
||||
if got := codeToken(env); got != tc.want {
|
||||
t.Fatalf("codeToken = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultCodeModelIsToolCapableAlias(t *testing.T) {
|
||||
// zen5 is the flagship GLM-5.2-class alias — tool-capable (verified live: the
|
||||
// glm-5.2 upstream returns tool_use / stop_reason:tool_use) and the model the
|
||||
|
||||
+172
-2
@@ -54,6 +54,10 @@ const (
|
||||
heartbeatEvery = 30 * time.Second
|
||||
claimPoll = 2 * time.Second
|
||||
claimLeaseSecs = 120
|
||||
// renderWindow matches the dispatch cap (studio gpu_dispatch sets
|
||||
// startToCloseTimeout 14400s). The old 10m local poll undercut it and
|
||||
// marked live renders failed while they kept sampling (observed 8-70m).
|
||||
renderWindow = 4 * time.Hour
|
||||
// localComfyUI is the studio render backend the studio.render handler drives.
|
||||
localComfyUI = "http://127.0.0.1:8188"
|
||||
// defaultStudioUploadURL is where finished render outputs are POSTed so they
|
||||
@@ -95,6 +99,7 @@ func newGPUCmd(envOf func() *Env, _ *globalFlags) *cobra.Command {
|
||||
var engineEndpoint string
|
||||
var registerProvider bool
|
||||
var studioDir string
|
||||
var studioURL string
|
||||
connect := &cobra.Command{
|
||||
Use: "connect",
|
||||
Short: "Register this GPU and run the outbound worker loop",
|
||||
@@ -107,6 +112,7 @@ func newGPUCmd(envOf func() *Env, _ *globalFlags) *cobra.Command {
|
||||
engineEndpoint: engineEndpoint,
|
||||
registerProvider: registerProvider,
|
||||
studioDir: studioDir,
|
||||
studioURL: studioURL,
|
||||
}
|
||||
if daemon {
|
||||
return installDaemon(cmd, opts)
|
||||
@@ -121,6 +127,7 @@ func newGPUCmd(envOf func() *Env, _ *globalFlags) *cobra.Command {
|
||||
connect.Flags().StringVar(&engineEndpoint, "engine-endpoint", "", "public URL to advertise for gateway routing (defaults to --engine-url; a BYO node needs a reachable URL/tunnel)")
|
||||
connect.Flags().BoolVar(®isterProvider, "register-provider", false, "auto-register the engine endpoint as an org model provider (POST /v1/add-provider)")
|
||||
connect.Flags().StringVar(&studioDir, "studio-dir", os.Getenv("HANZO_STUDIO_DIR"), "local Hanzo Studio checkout; when set, connect launches and supervises the render backend on 127.0.0.1:8188")
|
||||
connect.Flags().StringVar(&studioURL, "studio-url", firstNonEmpty(os.Getenv("HANZO_STUDIO_UPLOAD_URL"), defaultStudioUploadURL), "studio base URL the render mirror uploads finished images to (POST /v1/library/upload)")
|
||||
|
||||
status := &cobra.Command{
|
||||
Use: "status",
|
||||
@@ -370,6 +377,7 @@ type connectOpts struct {
|
||||
engineEndpoint string // public URL to advertise (defaults to engineURL)
|
||||
registerProvider bool // auto POST /v1/add-provider for the engine
|
||||
studioDir string // local Studio checkout to launch + supervise on :8188
|
||||
studioURL string // studio base the render mirror uploads finished images to
|
||||
}
|
||||
|
||||
func runConnect(cmd *cobra.Command, env *Env, opts connectOpts) error {
|
||||
@@ -422,6 +430,25 @@ func runConnect(cmd *cobra.Command, env *Env, opts connectOpts) error {
|
||||
poll := time.NewTicker(claimPoll)
|
||||
defer poll.Stop()
|
||||
|
||||
// Render mirror — independent of claims by design. It scans the local studio
|
||||
// output tree every heartbeatEvery and uploads every image to the org's library
|
||||
// (POST /v1/library/upload), so EVERY render lands in studio.hanzo.ai even when
|
||||
// it was produced outside the job path — a graph hand-run on this node, or a
|
||||
// render that finished after its activity was reaped (the stranded-late-render
|
||||
// class). Active only when a studio checkout is named (there is local output to
|
||||
// mirror); a nil channel case never fires when it is not.
|
||||
w.studioUploadURL = firstNonEmpty(opts.studioURL, w.studioUploadURL)
|
||||
mirrorBase := w.studioUploadURL
|
||||
mirrorDir := ""
|
||||
seen := map[string]int64{}
|
||||
var mirC <-chan time.Time
|
||||
if opts.studioDir != "" {
|
||||
mirrorDir = filepath.Join(opts.studioDir, "output")
|
||||
mir := time.NewTicker(heartbeatEvery)
|
||||
defer mir.Stop()
|
||||
mirC = mir.C
|
||||
}
|
||||
|
||||
// Heartbeat once immediately so the machine reports online without waiting a
|
||||
// full interval.
|
||||
_ = w.heartbeat(ctx)
|
||||
@@ -449,6 +476,8 @@ func runConnect(cmd *cobra.Command, env *Env, opts connectOpts) error {
|
||||
if err := w.claimAndRun(ctx, out); err != nil {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "claim: %v\n", err)
|
||||
}
|
||||
case <-mirC:
|
||||
w.mirrorRenders(ctx, out, mirrorDir, mirrorBase, seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -542,7 +571,29 @@ func (w *worker) claimAndRun(ctx context.Context, out io.Writer) error {
|
||||
fmt.Fprintf(out, " → failed: %s\n", cause)
|
||||
return nil
|
||||
}
|
||||
// Keep BOTH the claimed activity and this machine's fleet presence alive while
|
||||
// the handler runs. A render blocks this call for minutes (a cold GB10 reloads
|
||||
// ~40GB before sampling); without heartbeats the studio.render activity hits its
|
||||
// heartbeatTimeout AND the fleet presence (120s) goes stale, so the machine
|
||||
// drops offline mid-render and the next dispatch sees no online GPU. A ticker in
|
||||
// a child context heartbeats both every heartbeatEvery until the handler returns.
|
||||
hbCtx, stopHB := context.WithCancel(ctx)
|
||||
go func() {
|
||||
t := time.NewTicker(heartbeatEvery)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-hbCtx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
_, _ = w.call(ctx, http.MethodPost, w.actPath(wf, run, "heartbeat"),
|
||||
map[string]any{"identity": w.identity}, nil)
|
||||
_ = w.heartbeat(ctx) // fleet presence — stays online through the render
|
||||
}
|
||||
}
|
||||
}()
|
||||
result, herr := h(ctx, act.Input)
|
||||
stopHB()
|
||||
if herr != nil {
|
||||
_, _ = w.call(ctx, http.MethodPost, w.actPath(wf, run, "fail"), map[string]any{"cause": herr.Error(), "identity": w.identity}, nil)
|
||||
fmt.Fprintf(out, " → failed: %v\n", herr)
|
||||
@@ -710,7 +761,7 @@ func (e *Env) ensureToken(ctx context.Context) (string, error) {
|
||||
nc.RefreshToken = e.creds.RefreshToken
|
||||
}
|
||||
*e.creds = *nc
|
||||
_ = e.creds.Save()
|
||||
_ = SaveActive(e.creds) // refresh the active identity in the store + mirror
|
||||
}
|
||||
// On refresh failure fall through: the current token may still be valid
|
||||
// (clock skew) and the server is the authority.
|
||||
@@ -801,7 +852,7 @@ func (w *worker) studioRenderHandler(ctx context.Context, input json.RawMessage)
|
||||
return nil, fmt.Errorf("studio.render: no prompt_id in /prompt response")
|
||||
}
|
||||
// Poll history until the prompt shows up (completed).
|
||||
deadline := time.Now().Add(10 * time.Minute)
|
||||
deadline := time.Now().Add(renderWindow)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -825,6 +876,11 @@ func (w *worker) studioRenderHandler(ctx context.Context, input json.RawMessage)
|
||||
if uerr != nil {
|
||||
return nil, fmt.Errorf("studio.render: prompt %s rendered but gallery upload failed: %w", pr.PromptID, uerr)
|
||||
}
|
||||
// The engine leaks ~58GB per render; recycling after each completed
|
||||
// render caps it at one render's worth. Boot (~20s) is noise next to
|
||||
// 8-70m renders. Never recycle on the timeout path — the engine may
|
||||
// still be sampling and the mirror rescues late finishes.
|
||||
requestStudioRecycle()
|
||||
return map[string]any{"promptId": pr.PromptID, "outputs": outputs, "gallery": gallery}, nil
|
||||
}
|
||||
}
|
||||
@@ -939,6 +995,120 @@ func (w *worker) postGalleryOutput(ctx context.Context, base, tok, org, name, su
|
||||
return filepath.Join(out.Subfolder, out.Name), nil
|
||||
}
|
||||
|
||||
// isImageFile reports whether name carries a render image extension the library accepts.
|
||||
func isImageFile(name string) bool {
|
||||
switch strings.ToLower(filepath.Ext(name)) {
|
||||
case ".png", ".jpg", ".jpeg", ".webp":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// mirrorRenders scans dir (the local studio output tree) for image files new or
|
||||
// changed since the last scan and POSTs each to base/v1/library/upload with the
|
||||
// worker's bearer, tagged with this node's identity, so EVERY render lands in the
|
||||
// org's studio library — including ones produced OUTSIDE the job path. seen (rel
|
||||
// path -> size) skips unchanged files; the endpoint dedupes, so a re-scan after a
|
||||
// restart is cheap and harmless. One log line per newly stored file; upload
|
||||
// failures are summarized once per scan and retried next tick (no 5xx log spam).
|
||||
func (w *worker) mirrorRenders(ctx context.Context, out io.Writer, dir, base string, seen map[string]int64) {
|
||||
tok, err := w.env.ensureToken(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
base = strings.TrimRight(base, "/")
|
||||
failed := 0
|
||||
var firstErr error
|
||||
_ = filepath.Walk(dir, func(p string, info os.FileInfo, werr error) error {
|
||||
if werr != nil || info == nil || info.IsDir() || !isImageFile(p) {
|
||||
return nil
|
||||
}
|
||||
// Hidden files and AppleDouble forks (`._*`, `.DS_Store`) ride along with
|
||||
// mac scp and are not renders — `._foo.png` passes the extension check
|
||||
// but is a 4KB resource fork that poisons the library.
|
||||
if strings.HasPrefix(filepath.Base(p), ".") {
|
||||
return nil
|
||||
}
|
||||
rel, rerr := filepath.Rel(dir, p)
|
||||
if rerr != nil {
|
||||
return nil
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if seen[rel] == info.Size() {
|
||||
return nil
|
||||
}
|
||||
data, derr := os.ReadFile(p)
|
||||
if derr != nil || len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
sub, name := "", rel
|
||||
if i := strings.LastIndex(rel, "/"); i >= 0 {
|
||||
sub, name = rel[:i], rel[i+1:]
|
||||
}
|
||||
existed, perr := w.postLibraryUpload(ctx, base, tok, sub, name, data)
|
||||
if perr != nil {
|
||||
failed++
|
||||
if firstErr == nil {
|
||||
firstErr = perr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
seen[rel] = info.Size()
|
||||
if !existed {
|
||||
fmt.Fprintf(out, "mirrored %s (%d bytes) -> %s\n", rel, len(data), base)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if failed > 0 {
|
||||
fmt.Fprintf(out, "mirror: %d file(s) failed to upload, will retry: %v\n", failed, firstErr)
|
||||
}
|
||||
}
|
||||
|
||||
// postLibraryUpload multipart-POSTs one image to base/v1/library/upload with the
|
||||
// worker's IAM bearer, landing it in the org's library (orgs/{org}/output). The
|
||||
// file's subfolder rides as ?subpath and this node's identity as ?node so the
|
||||
// render is filterable by its source in Queue & History. Returns whether the
|
||||
// endpoint already had a byte-identical copy (dedup).
|
||||
func (w *worker) postLibraryUpload(ctx context.Context, base, tok, sub, name string, data []byte) (bool, error) {
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
part, err := mw.CreateFormFile("image", name)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, err := part.Write(data); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
q := url.Values{"node": {w.identity}}
|
||||
if sub != "" {
|
||||
q.Set("subpath", sub)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/v1/library/upload?"+q.Encode(), &buf)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := w.http.Do(req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return false, fmt.Errorf("POST /v1/library/upload HTTP %d: %s", resp.StatusCode, serverMessage(raw))
|
||||
}
|
||||
var out struct {
|
||||
Existed bool `json:"existed"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &out)
|
||||
return out.Existed, nil
|
||||
}
|
||||
|
||||
// inputImage is one uploaded input shipped with the job: a base64 blob plus the
|
||||
// input-dir-relative location it must occupy on this worker so LoadImage finds it.
|
||||
type inputImage struct {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestMirrorRenders verifies the render mirror: it scans the local studio output
|
||||
// tree and POSTs every image (new or changed) to /v1/library/upload with the node's
|
||||
// identity + subfolder + bearer, skips unchanged files across scans, and re-uploads
|
||||
// a changed file. This is the path that lands EVERY render in studio.hanzo.ai even
|
||||
// when it was produced outside the job/claim path.
|
||||
func TestMirrorRenders(t *testing.T) {
|
||||
t.Setenv("HANZO_TOKEN", "test-bearer")
|
||||
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "renders"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write := func(rel, body string) {
|
||||
if err := os.WriteFile(filepath.Join(dir, filepath.FromSlash(rel)), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("renders/a.png", "\x89PNG-a")
|
||||
write("top.jpg", "jpg-top")
|
||||
write("notes.txt", "not an image")
|
||||
|
||||
type up struct{ name, node, subpath, auth string }
|
||||
var got []up
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/library/upload" {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_ = r.ParseMultipartForm(1 << 20)
|
||||
name := ""
|
||||
if r.MultipartForm != nil {
|
||||
for _, fh := range r.MultipartForm.File["image"] {
|
||||
name = fh.Filename
|
||||
}
|
||||
}
|
||||
got = append(got, up{
|
||||
name: name,
|
||||
node: r.URL.Query().Get("node"),
|
||||
subpath: r.URL.Query().Get("subpath"),
|
||||
auth: r.Header.Get("Authorization"),
|
||||
})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true,"path":"x"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
w := &worker{env: &Env{}, http: &http.Client{Timeout: 10 * time.Second}, identity: "spark"}
|
||||
seen := map[string]int64{}
|
||||
var buf bytes.Buffer
|
||||
|
||||
w.mirrorRenders(context.Background(), &buf, dir, srv.URL, seen)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("uploaded %d files, want 2 images (the .txt is skipped): %+v", len(got), got)
|
||||
}
|
||||
var a *up
|
||||
for i := range got {
|
||||
if got[i].name == "a.png" {
|
||||
a = &got[i]
|
||||
}
|
||||
}
|
||||
if a == nil || a.subpath != "renders" || a.node != "spark" || a.auth != "Bearer test-bearer" {
|
||||
t.Fatalf("renders/a.png upload = %+v, want subpath=renders node=spark bearer set", a)
|
||||
}
|
||||
|
||||
// A second scan re-uploads nothing (seen matches every size).
|
||||
got = nil
|
||||
w.mirrorRenders(context.Background(), &buf, dir, srv.URL, seen)
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("second scan uploaded %d files, want 0 (all unchanged): %+v", len(got), got)
|
||||
}
|
||||
|
||||
// A changed file is re-uploaded on the next scan.
|
||||
write("renders/a.png", "\x89PNG-a-grew")
|
||||
got = nil
|
||||
w.mirrorRenders(context.Background(), &buf, dir, srv.URL, seen)
|
||||
if len(got) != 1 || got[0].name != "a.png" {
|
||||
t.Fatalf("after change, uploaded %+v, want just renders/a.png", got)
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,17 @@ func stopStudio(cmd *exec.Cmd) {
|
||||
}
|
||||
}
|
||||
|
||||
// studioRecycle carries at most one pending recycle request; the render
|
||||
// handler signals it after each completed render (see gpu.go).
|
||||
var studioRecycle = make(chan struct{}, 1)
|
||||
|
||||
func requestStudioRecycle() {
|
||||
select {
|
||||
case studioRecycle <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// superviseStudio keeps the local render backend on :8188 alive until ctx
|
||||
// ends. Quiet by design: one line per restart event, not a probe firehose.
|
||||
func superviseStudio(ctx context.Context, dir string, out io.Writer) {
|
||||
@@ -145,6 +156,8 @@ func superviseStudio(ctx context.Context, dir string, out io.Writer) {
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM)
|
||||
}
|
||||
return
|
||||
case <-studioRecycle:
|
||||
restart("recycle")
|
||||
case <-tick.C:
|
||||
if studioHealthy(ctx) {
|
||||
continue
|
||||
|
||||
@@ -161,6 +161,10 @@ func routesBridge(s *cloud.Service[state], app *zip.App) {
|
||||
// console calls, forwarded to commerce with the admin service token and SCOPED to the
|
||||
// validated caller's own subject (billing.go). Registered AFTER clients/billing's
|
||||
// specific routes (121 < 122) so those win and this catches the rest. GET+POST only.
|
||||
// The wildcard is what the ROUTER matches; it is NOT the forwardable set — billing.go's
|
||||
// billingForwardable allowlist decides that, per method, and 404s everything else
|
||||
// BEFORE the admin service token is attached. Widening this pattern grants nothing on
|
||||
// its own; adding a line to that table is the only way to expose an endpoint.
|
||||
app.Get("/v1/billing/*", cloud.Handle(s, billingData))
|
||||
app.Post("/v1/billing/*", requireCSRF(s, cloud.Handle(s, billingData)))
|
||||
// Per-tenant STORE DATA bridge — the canonical /v1/commerce/* the console calls,
|
||||
|
||||
+126
-13
@@ -7,6 +7,14 @@
|
||||
// read/act on its OWN ledger (balance / usage / invoices / subscriptions /
|
||||
// payment-methods / spend-alerts / …), never another's.
|
||||
//
|
||||
// TWO INDEPENDENT BOUNDS, because the token makes this a privileged forwarder:
|
||||
// 1. WHICH ENDPOINT — billingForwardable, the per-method allowlist below. It is the
|
||||
// authorization gate: an unlisted path is 404'd before the token is ever attached, so
|
||||
// no money-MINT route (deposit/credit/refund/…) can be reached through this bridge.
|
||||
// 2. WHOSE DATA — the subject-pinning below. It aims a permitted call at the caller's own
|
||||
// ledger. It is an IDOR control and NOT an authority control: on a mint route it would
|
||||
// have pinned the CREDIT to the attacker's own account. (1) is what stops that.
|
||||
//
|
||||
// WHY A SERVER HANDLER (not a same-origin passthrough). Commerce's billing surface is
|
||||
// service-token-gated and filters DIFFERENT endpoints on DIFFERENT subject params —
|
||||
// subscriptions on ?userId, payment-methods on ?customerId, usage on ?user. Pinning
|
||||
@@ -28,10 +36,115 @@ import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/hanzoai/account"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// billingForwardable — THE allowlist of billing endpoints this bridge may forward, keyed
|
||||
// by method. It is the whole authorization story of the bridge, because forwarding IS
|
||||
// authorization here: every forwarded request carries the admin COMMERCE_SERVICE_TOKEN,
|
||||
// and commerce's money gate is MayMintMoney(c) = IsServiceToken(c) || IsSuperAdmin(c)
|
||||
// (middleware/platformonly.go). The token satisfies IsServiceToken, so ANY subpath that
|
||||
// reaches commerce is executed with PLATFORM authority — not the caller's. Commerce 403s
|
||||
// an org admin who calls POST /v1/billing/deposit directly; without this table the bridge
|
||||
// handed that same person the platform's own credential and minted it for them, scoped —
|
||||
// by the subject-pinning below — to their OWN account. That is the escalation, and
|
||||
// subject-pinning is what AIMS it, not what stops it. Only a path gate stops it.
|
||||
//
|
||||
// It is an ALLOWLIST, never a denylist: a denylist must enumerate every mint route
|
||||
// (deposit/credit/refund/credit-grants/payouts/husd/allotment…) and stays correct only
|
||||
// until commerce adds the next one — a route this file has never heard of is then
|
||||
// forwarded by default. Here the default is REFUSE, so a new commerce mint route is
|
||||
// unreachable the day it lands, with no change on this side. One table, one place; a path
|
||||
// not in it cannot reach commerce, by construction.
|
||||
//
|
||||
// GET and POST are SEPARATE sets because a read bridge and a write bridge are different
|
||||
// concerns: `payouts` is a legitimate read and a money-MINT write (api/billing/handlers.go
|
||||
// `api.Get("/payouts", ListPayouts)` vs `api.Post("/payouts", mintRequired, CreatePayout)`),
|
||||
// so one method-blind set would hand the mint to every reader. The POST set is therefore
|
||||
// deliberately tiny and holds NOTHING that creates spendable balance from a client-named
|
||||
// amount: cancel/reactivate a subscription, vault a card, create a budget, and a top-up
|
||||
// that CHARGES a real card (money in, not minted). Every entry is a call the console
|
||||
// actually makes; `{}` matches exactly one opaque id segment.
|
||||
//
|
||||
// EVIDENCE — each entry is a live console call (repo hanzoai/console):
|
||||
//
|
||||
// GET balance src/lib/api/billing.ts:397 sidebar wallet + billing overview
|
||||
// GET usage src/lib/api/billing.ts:415 cost reports / AI metrics
|
||||
// GET invoices src/lib/api/billing.ts:419 invoice history table
|
||||
// GET invoices/{}/pdf src/components/products/billing/BillingInvoices.tsx:31
|
||||
// GET subscriptions src/lib/api/billing.ts:423 subscriptions list
|
||||
// GET payment-methods src/lib/api/billing.ts:450 saved cards (masked)
|
||||
// GET spend-alerts src/lib/api/billing.ts:482 budgets / spend caps
|
||||
// GET payment-config src/lib/api/billing.ts:552 public Square app/location id
|
||||
// GET plans src/lib/api/plans.ts:126 published tiers
|
||||
// GET payouts src/components/products/SettlementModule.tsx:61 settlement view
|
||||
// POST subscriptions/{}/cancel src/lib/api/billing.ts:434
|
||||
// POST subscriptions/{}/reactivate src/lib/api/billing.ts:444
|
||||
// POST payment-methods src/lib/api/billing.ts:461 vault a Square nonce (no PAN)
|
||||
// POST spend-alerts src/lib/api/billing.ts:500 create a budget
|
||||
// POST topup/token src/lib/api/billing.ts:565 charge a card → credit
|
||||
//
|
||||
// balance/usage/payment-methods are ALSO served natively by clients/billing (order 121),
|
||||
// which wins over this catch-all (122), so those entries are reached only on a deploy
|
||||
// where that subsystem is disabled. They are listed because they are legitimate reads of
|
||||
// the caller's own ledger, not because this bridge is their primary route.
|
||||
//
|
||||
// NOT LISTED, deliberately: `me/welcome` and `grant-starter` (console calls the first at
|
||||
// billing.ts:407 and the second server-side at src/lib/server/billing-grant.ts:35) exist
|
||||
// in NEITHER the pinned commerce (v1.48.5) route table — both 404 today whether or not
|
||||
// this bridge forwards them, and grant-starter is mint-gated and browser-unreachable by
|
||||
// design. The console's PATCH/DELETE calls (spend-alerts/{}, payment-methods/{}) are absent
|
||||
// because routesBridge mounts GET+POST only, so they never reached this handler.
|
||||
var billingForwardable = map[string][]string{
|
||||
http.MethodGet: {
|
||||
"balance",
|
||||
"usage",
|
||||
"invoices",
|
||||
"invoices/{}/pdf",
|
||||
"subscriptions",
|
||||
"payment-methods",
|
||||
"spend-alerts",
|
||||
"payment-config",
|
||||
"plans",
|
||||
"payouts",
|
||||
},
|
||||
http.MethodPost: {
|
||||
"subscriptions/{}/cancel",
|
||||
"subscriptions/{}/reactivate",
|
||||
"payment-methods",
|
||||
"spend-alerts",
|
||||
"topup/token",
|
||||
},
|
||||
}
|
||||
|
||||
// isForwardableBilling reports whether method+sub is in billingForwardable. sub has
|
||||
// already passed isSafeSegment, so no segment can contain a slash, a percent-escape, or a
|
||||
// traversal — a pattern segment therefore matches exactly one real segment and `{}` cannot
|
||||
// swallow a path. Fail-closed: an unknown method or an unlisted path is false.
|
||||
func isForwardableBilling(method, sub string) bool {
|
||||
got := strings.Split(sub, "/")
|
||||
for _, pattern := range billingForwardable[method] {
|
||||
want := strings.Split(pattern, "/")
|
||||
if len(want) != len(got) {
|
||||
continue
|
||||
}
|
||||
match := true
|
||||
for i, seg := range want {
|
||||
if seg != "{}" && seg != got[i] {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// billingSubjectKeys — every query/body param through which a commerce billing endpoint
|
||||
// identifies its subject. Kept identical to commerce's edge-auth billingSubjectKeys
|
||||
// {user,userId,customerId} AND console's billing-scope.ts BILLING_SUBJECT_KEYS. Change
|
||||
@@ -48,17 +161,6 @@ func isSubjectKey(k string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// billingSubject — the commerce billing subject for an org+user: ALWAYS the org
|
||||
// (`org`), lowercased. Every member of an org reads/scopes to the ONE org billing
|
||||
// account — the same subject the gateway gate reads and debits. `name` is recorded
|
||||
// for metrics, never for the billing key. This is the ONE rule; the former
|
||||
// PERSONAL_BILLING_ORGS / ORG_BILLING_ORGS allowlists are gone. Keep in lockstep
|
||||
// with ai/object.BillingSubject so the console view and the gate never disagree.
|
||||
func billingSubject(org, name string) string {
|
||||
_ = name
|
||||
return strings.ToLower(strings.TrimSpace(org))
|
||||
}
|
||||
|
||||
// scopedBillingSearch — pin every billingSubjectKey to subject (OVERWRITING any client
|
||||
// value — the browser cannot widen scope) and DROP org. Every OTHER param (currency,
|
||||
// status, date range) passes through untouched. Mirrors billing-scope.ts.
|
||||
@@ -165,10 +267,21 @@ func billingData(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
return zip.ErrBadRequest("invalid billing path")
|
||||
}
|
||||
}
|
||||
// THE authorization gate. Forwarding is authorization: the request below carries the
|
||||
// admin service token, which satisfies commerce's MayMintMoney. So refuse anything the
|
||||
// console does not actually call — BEFORE the token is attached. Fail closed (404, the
|
||||
// same answer an unrouted path gives, so this leaks no map of the money surface).
|
||||
if !isForwardableBilling(method, sub) {
|
||||
return zip.Errorf(http.StatusNotFound, "not a forwardable billing endpoint")
|
||||
}
|
||||
|
||||
// Scope EVERY request to the caller's OWN subject — query AND write body — so
|
||||
// commerce's per-tenant isolation can never be crossed from the browser.
|
||||
subject := billingSubject(cr.owner, cr.name)
|
||||
// commerce's per-tenant isolation can never be crossed from the browser. The
|
||||
// subject comes from the ONE rule (ai/object.Payer), keyed on the IAM username
|
||||
// (cr.username = X-User-Name) the gate also keys on — so a top-up credits the
|
||||
// SAME account the gate debits. Keying on cr.name (X-User-Id, a UUID on the
|
||||
// direct-bearer path) would fund an account the gate never reads: the split.
|
||||
subject := account.Payer(account.Credential{Owner: cr.owner, Name: cr.username}).Subject()
|
||||
inQuery, _ := url.ParseQuery(string(c.Fiber().Request().URI().QueryString()))
|
||||
q := scopedBillingSearch(inQuery, subject)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package account
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/hanzoai/account"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -16,37 +17,44 @@ import (
|
||||
|
||||
// ── pure scoping ─────────────────────────────────────────────────────────────
|
||||
|
||||
// TestBillingSubject proves the top-up subject is resolved through the ONE rule
|
||||
// (ai/object.Payer) — so a top-up credits the SAME account the ai gate debits and
|
||||
// the console reads. The signup org bills per-person (matching the gate), which is
|
||||
// the whole fix: money and gate land on one account.
|
||||
func TestBillingSubject(t *testing.T) {
|
||||
cases := []struct{ org, name, want string }{
|
||||
{"acme", "alice", "acme"}, // any member bills the ONE org account
|
||||
{"hanzo", "Dave", "hanzo"}, // no per-user wallet; org, lowercased
|
||||
{"hanzo", "z", "hanzo"}, // another member — same org account
|
||||
{"hanzo", "", "hanzo"}, // no name → org
|
||||
{"Hanzo", "z", "hanzo"}, // lowercased
|
||||
{"", "x", ""}, // no org → empty subject
|
||||
{"acme", "alice", "acme"}, // real org: any member bills the ONE org account
|
||||
{"hanzo", "Dave", "hanzo/dave"}, // signup org: each person bills their OWN account
|
||||
{"hanzo", "z", "hanzo/z"}, // another signup person — their own account
|
||||
{"hanzo", "", "hanzo"}, // no name (org-owned principal) → org pool
|
||||
{"Hanzo", "Z", "hanzo/z"}, // folded
|
||||
{"", "x", ""}, // no org → empty subject (cannot bill)
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := billingSubject(c.org, c.name); got != c.want {
|
||||
t.Fatalf("billingSubject(%q,%q): want %q, got %q", c.org, c.name, c.want, got)
|
||||
got := account.Payer(account.Credential{Owner: c.org, Name: c.name}).Subject()
|
||||
if got != c.want {
|
||||
t.Fatalf("Payer(%q,%q).Subject(): want %q, got %q", c.org, c.name, c.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingSubject_IgnoresLegacyEnv locks that the killed allowlist envs have NO
|
||||
// effect: the subject is ALWAYS the org, whether or not the old PERSONAL_BILLING_ORGS
|
||||
// / ORG_BILLING_ORGS knobs are set. This mirrors ai/object.BillingSubject (one rule,
|
||||
// no config) so the console view and the gateway gate can never disagree.
|
||||
// effect: nothing reads them. Set to values that WOULD have flipped every
|
||||
// resolution — the subject is unchanged. This is the console/top-up half of the
|
||||
// same proof ai carries (one rule, no config), so the view and the gate can never
|
||||
// disagree, and the deleted CR env is a genuine no-op.
|
||||
func TestBillingSubject_IgnoresLegacyEnv(t *testing.T) {
|
||||
t.Setenv("PERSONAL_BILLING_ORGS", "hanzo,acme")
|
||||
t.Setenv("ORG_BILLING_ORGS", "hanzo")
|
||||
t.Setenv("PERSONAL_BILLING_ORGS", "hanzo,acme") // would have split acme per-user
|
||||
t.Setenv("ORG_BILLING_ORGS", "hanzo") // would have pooled the signup org
|
||||
cases := []struct{ org, name, want string }{
|
||||
{"hanzo", "z", "hanzo"},
|
||||
{"acme", "alice", "acme"},
|
||||
{"maxpower", "dave", "maxpower"},
|
||||
{"hanzo", "z", "hanzo/z"}, // env cannot pool the signup org
|
||||
{"acme", "alice", "acme"}, // env cannot split a real org per-user
|
||||
{"maxpower", "dave", "maxpower"}, // untouched
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := billingSubject(c.org, c.name); got != c.want {
|
||||
t.Fatalf("legacy env must be ignored: billingSubject(%q,%q) want %q, got %q", c.org, c.name, c.want, got)
|
||||
got := account.Payer(account.Credential{Owner: c.org, Name: c.name}).Subject()
|
||||
if got != c.want {
|
||||
t.Fatalf("legacy env must be ignored: Payer(%q,%q).Subject() want %q, got %q", c.org, c.name, c.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commercebilling "github.com/hanzoai/commerce/api/billing"
|
||||
commercemid "github.com/hanzoai/commerce/middleware"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// bridge_mint_test.go — the privilege-escalation boundary of the /v1/billing/*
|
||||
// bridge: an ordinary signed-in ORG user must never reach commerce's money-MINT
|
||||
// surface.
|
||||
//
|
||||
// THE ESCALATION THIS LOCKS OUT. The bridge forwards to commerce with the admin
|
||||
// COMMERCE_SERVICE_TOKEN. Commerce gates every mint on
|
||||
// MayMintMoney(c) = IsServiceToken(c) || IsSuperAdmin(c) (middleware/platformonly.go)
|
||||
// — and the bridge's service token satisfies IsServiceToken. So ANY subpath the
|
||||
// bridge forwards is executed by commerce as the PLATFORM, not as the caller.
|
||||
// billingData scopes the SUBJECT to the caller's own account, which is exactly the
|
||||
// attack rather than a defense: an org user mints to THEMSELVES. Commerce's own
|
||||
// gate comment names this: "let ANY org owner self-credit unlimited balance (POST
|
||||
// /v1/billing/deposit &c.) → unlimited free inference."
|
||||
//
|
||||
// Commerce 403s that same org admin when they call it DIRECTLY
|
||||
// (TestC1_OrgAdminDeniedOnEveryMintRoute) and mints 201 for the service token
|
||||
// (TestC1_ServiceTokenMintsDeposit). The bridge is what converts the former into
|
||||
// the latter. The gate therefore has to live HERE, at the point that hands out the
|
||||
// token: forwardable subpaths are an ALLOWLIST, and a mint path is not on it.
|
||||
//
|
||||
// alice is an ordinary org user — X-Org-Id "acme", owner != "admin", NOT a
|
||||
// SuperAdmin — i.e. precisely the principal commerce refuses at the front door.
|
||||
|
||||
// TestBridge_OrgUserCannotReachMint is the reproduction. Each of these commerce
|
||||
// subpaths is PlatformOnly-gated (api/billing/handlers.go: `mintRequired`), meaning
|
||||
// possession of the service token IS authority to create spendable balance. None
|
||||
// may leave cloud. A request that never reaches commerce cannot mint, so the
|
||||
// assertion is twofold: the caller is refused AND upstream saw nothing.
|
||||
// mintSurface asks COMMERCE which routes it gates, rather than keeping a copy.
|
||||
//
|
||||
// The list used to live here by hand under "kept in lockstep with
|
||||
// api/billing/handlers.go" — and it had already drifted: 10 paths here against
|
||||
// 16 commerce actually gates. A comment cannot hold two lists together. Now
|
||||
// commerce DECLARES its gated surface (middleware.Mint records what it gates)
|
||||
// and we read that declaration, so a mint route added there is covered here with
|
||||
// nobody remembering to do anything.
|
||||
//
|
||||
// Registration is what populates the registry, so register first, then read.
|
||||
func mintSurface(t *testing.T) []commercemid.MintRoute {
|
||||
t.Helper()
|
||||
commercebilling.Route(zip.New(zip.Config{DisableStartupMessage: true}).Group("/v1"))
|
||||
|
||||
var out []commercemid.MintRoute
|
||||
for _, r := range commercemid.MintRoutes() {
|
||||
// Only what THIS bridge can address: it forwards /v1/billing/* alone.
|
||||
if !strings.HasPrefix(r.Path, "/v1/billing/") {
|
||||
continue
|
||||
}
|
||||
// A wildcard segment needs some concrete value to be requestable; which
|
||||
// one is irrelevant, since a refused call never reaches an id.
|
||||
parts := strings.Split(r.Path, "/")
|
||||
for i, seg := range parts {
|
||||
if strings.HasPrefix(seg, ":") || seg == "{}" {
|
||||
parts[i] = "probe"
|
||||
}
|
||||
}
|
||||
r.Path = strings.Join(parts, "/")
|
||||
out = append(out, r)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatal("commerce declared no /v1/billing mint routes — the registry is not being populated")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestBridge_OrgUserCannotReachMint(t *testing.T) {
|
||||
mintPaths := mintSurface(t)
|
||||
t.Logf("commerce declares %d gated /v1/billing mint routes", len(mintPaths))
|
||||
|
||||
for _, m := range mintPaths {
|
||||
t.Run(m.Method+" "+m.Path, func(t *testing.T) {
|
||||
f := &fakeBilling{}
|
||||
t.Setenv("COMMERCE_URL", f.server(t).URL)
|
||||
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-tok")
|
||||
app := mountApp(t, "http://iam.invalid", "", "")
|
||||
|
||||
code, body := callH(t, app, m.Method, m.Path, alice, `{}`)
|
||||
|
||||
// The mint request must NEVER reach commerce: arriving there at all means
|
||||
// it arrived bearing the admin service token, which IS the authority to
|
||||
// mint (MayMintMoney → AuthorizeMint → the ledger write).
|
||||
if f.path != "" {
|
||||
t.Fatalf("ESCALATION: an ordinary org user's %s %s reached commerce at %q "+
|
||||
"carrying %q — the service token that satisfies MayMintMoney. "+
|
||||
"Minted subject=%v amount=%v in org=%q.",
|
||||
m.Method, m.Path, f.path, f.auth, f.body["user"], f.body["amount"], f.org)
|
||||
}
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("%s %s: want 404 (not a forwardable billing endpoint), got %d (%s)",
|
||||
m.Method, m.Path, code, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBridge_ConsoleCallsStillForward is the other half of the allowlist: the calls the
|
||||
// console ACTUALLY makes must still reach commerce. An allowlist that blocks the product
|
||||
// is not a fix, so each entry here is a live console call (cited in billing.go), and this
|
||||
// test fails if a future edit narrows the table below the console's real needs.
|
||||
func TestBridge_ConsoleCallsStillForward(t *testing.T) {
|
||||
calls := []struct{ method, path, want string }{
|
||||
{http.MethodGet, "/v1/billing/invoices", "/v1/billing/invoices"},
|
||||
{http.MethodGet, "/v1/billing/invoices/inv_123/pdf", "/v1/billing/invoices/inv_123/pdf"},
|
||||
{http.MethodGet, "/v1/billing/subscriptions", "/v1/billing/subscriptions"},
|
||||
{http.MethodGet, "/v1/billing/spend-alerts", "/v1/billing/spend-alerts"},
|
||||
{http.MethodGet, "/v1/billing/payment-config", "/v1/billing/payment-config"},
|
||||
{http.MethodGet, "/v1/billing/plans", "/v1/billing/plans"},
|
||||
{http.MethodGet, "/v1/billing/payouts", "/v1/billing/payouts"},
|
||||
{http.MethodPost, "/v1/billing/subscriptions/sub_1/cancel", "/v1/billing/subscriptions/sub_1/cancel"},
|
||||
{http.MethodPost, "/v1/billing/subscriptions/sub_1/reactivate", "/v1/billing/subscriptions/sub_1/reactivate"},
|
||||
{http.MethodPost, "/v1/billing/payment-methods", "/v1/billing/payment-methods"},
|
||||
{http.MethodPost, "/v1/billing/spend-alerts", "/v1/billing/spend-alerts"},
|
||||
{http.MethodPost, "/v1/billing/topup/token", "/v1/billing/topup/token"},
|
||||
}
|
||||
for _, call := range calls {
|
||||
t.Run(call.method+" "+call.path, func(t *testing.T) {
|
||||
f := &fakeBilling{}
|
||||
t.Setenv("COMMERCE_URL", f.server(t).URL)
|
||||
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-tok")
|
||||
app := mountApp(t, "http://iam.invalid", "", "")
|
||||
|
||||
code, body := callH(t, app, call.method, call.path, alice, "{}")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("%s %s: want 200 (the console needs this), got %d (%s)",
|
||||
call.method, call.path, code, body)
|
||||
}
|
||||
if f.path != call.want {
|
||||
t.Fatalf("%s %s must forward to %q, got %q", call.method, call.path, call.want, f.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBridge_UnlistedPathsAreRefused covers the rest of the money surface — routes that
|
||||
// are NOT mint-gated but that the console never calls. The bridge is not a general
|
||||
// commerce proxy; least privilege means "only what the product needs", so these 404
|
||||
// even though commerce would have served them to a service token.
|
||||
func TestBridge_UnlistedPathsAreRefused(t *testing.T) {
|
||||
unlisted := []struct{ method, path string }{
|
||||
{http.MethodPost, "/v1/billing/invoices"}, // CreateInvoice (admin group)
|
||||
{http.MethodPost, "/v1/billing/invoices/i1/pay"}, // PayInvoice
|
||||
{http.MethodPost, "/v1/billing/invoices/i1/void"}, // VoidInvoice
|
||||
{http.MethodPost, "/v1/billing/meters"}, // CreateMeter
|
||||
{http.MethodPost, "/v1/billing/pricing-rules"}, // CreatePricingRule
|
||||
{http.MethodPost, "/v1/billing/withdraw"}, // money OUT
|
||||
{http.MethodPost, "/v1/billing/usage"}, // RecordUsage — the meter itself
|
||||
{http.MethodGet, "/v1/billing/balance/all"}, // every subject's balance
|
||||
{http.MethodGet, "/v1/billing/sbom"}, // OSS payout surface
|
||||
{http.MethodGet, "/v1/billing/oss-payout/summary"}, // OSS payout rollup
|
||||
{http.MethodPost, "/v1/billing/subscriptions"}, // CreateBillingSubscription
|
||||
}
|
||||
for _, u := range unlisted {
|
||||
t.Run(u.method+" "+u.path, func(t *testing.T) {
|
||||
f := &fakeBilling{}
|
||||
t.Setenv("COMMERCE_URL", f.server(t).URL)
|
||||
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-tok")
|
||||
app := mountApp(t, "http://iam.invalid", "", "")
|
||||
|
||||
code, _ := callH(t, app, u.method, u.path, alice, "{}")
|
||||
if f.path != "" {
|
||||
t.Fatalf("%s %s is not a console call and must not reach commerce, but upstream saw %q",
|
||||
u.method, u.path, f.path)
|
||||
}
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("%s %s: want 404, got %d", u.method, u.path, code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBridge_ReadAllowlistIsNotAWriteAllowlist pins the method split. `payouts` is the
|
||||
// proof that one method-blind set would be a hole: GET /payouts is a plain read, POST
|
||||
// /payouts is `mintRequired` (api/billing/handlers.go). The same string must resolve
|
||||
// differently by method, or reading the settlement view would grant minting a payout.
|
||||
func TestBridge_ReadAllowlistIsNotAWriteAllowlist(t *testing.T) {
|
||||
if !isForwardableBilling(http.MethodGet, "payouts") {
|
||||
t.Fatal("GET payouts is a live console read and must be forwardable")
|
||||
}
|
||||
if isForwardableBilling(http.MethodPost, "payouts") {
|
||||
t.Fatal("POST payouts is mint-gated in commerce and must NEVER be forwardable")
|
||||
}
|
||||
// A GET-only entry must not leak into POST, and vice-versa.
|
||||
if isForwardableBilling(http.MethodPost, "invoices") {
|
||||
t.Fatal("POST invoices must not inherit the GET entry")
|
||||
}
|
||||
if isForwardableBilling(http.MethodGet, "topup/token") {
|
||||
t.Fatal("GET topup/token must not inherit the POST entry")
|
||||
}
|
||||
// An unknown method fails closed (the router mounts GET+POST only; defense in depth).
|
||||
for _, m := range []string{http.MethodPut, http.MethodPatch, http.MethodDelete, ""} {
|
||||
if isForwardableBilling(m, "balance") {
|
||||
t.Fatalf("method %q must fail closed", m)
|
||||
}
|
||||
}
|
||||
// `{}` matches exactly ONE segment — it can never swallow a path into a mint route.
|
||||
if isForwardableBilling(http.MethodPost, "subscriptions/a/b/cancel") {
|
||||
t.Fatal("{} must match exactly one segment")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBridge_StoreBridgeCannotReachBilling is the sibling lock. /v1/commerce/* carries the
|
||||
// SAME admin token with FULL CRUD, and its own allowlist (commerceStoreHeads) is what keeps
|
||||
// it a store proxy. Prove it cannot tunnel into the money surface — a store head that
|
||||
// resolved to `billing` would reopen this hole from the other bridge.
|
||||
func TestBridge_StoreBridgeCannotReachBilling(t *testing.T) {
|
||||
for _, p := range []string{
|
||||
"/v1/commerce/billing/deposit",
|
||||
"/v1/commerce/billing",
|
||||
"/v1/commerce/checkout",
|
||||
"/v1/commerce/_/commerce/tenants",
|
||||
} {
|
||||
f := &fakeBilling{}
|
||||
t.Setenv("COMMERCE_URL", f.server(t).URL)
|
||||
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-tok")
|
||||
app := mountApp(t, "http://iam.invalid", "", "")
|
||||
|
||||
code, _ := callH(t, app, http.MethodPost, p, alice, `{"amount":100000000}`)
|
||||
if f.path != "" {
|
||||
t.Fatalf("store bridge %q must never reach commerce, but upstream saw %q", p, f.path)
|
||||
}
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("store bridge %q: want 404, got %d", p, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBridge_MintIsRefusedEvenWithForgedSubject proves the refusal does not depend
|
||||
// on the subject-pinning. Pinning is an IDOR control, not an authority control: it
|
||||
// makes the mint land on the CALLER's own account, which is the attack, not a
|
||||
// defense. The path gate must refuse before any of that logic runs.
|
||||
func TestBridge_MintIsRefusedEvenWithForgedSubject(t *testing.T) {
|
||||
f := &fakeBilling{}
|
||||
t.Setenv("COMMERCE_URL", f.server(t).URL)
|
||||
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-tok")
|
||||
app := mountApp(t, "http://iam.invalid", "", "")
|
||||
|
||||
code, _ := callH(t, app, http.MethodPost, "/v1/billing/deposit", alice,
|
||||
`{"user":"victim","userId":"victim","amount":100000000}`)
|
||||
if f.path != "" {
|
||||
t.Fatalf("ESCALATION: deposit reached commerce at %q with the service token", f.path)
|
||||
}
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("forged-subject deposit: want 404, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,13 @@ func routes(app *zip.App, s *cloud.Service[core.State]) {
|
||||
app.Get("/v1/admin/bases", core.GuardScoped(s, bases))
|
||||
|
||||
// ── Platform control plane — SuperAdmin ONLY (launch/release/flags + access). ──
|
||||
app.Get("/v1/admin/flags", core.Guard(s, flags))
|
||||
app.Get("/v1/admin/flags", core.Guard(s, flagsBoard))
|
||||
app.Put("/v1/admin/flags/:key", core.Guard(s, setFlag))
|
||||
// Launch-control services board — the waitlist-mode lens on the flag engine (twin
|
||||
// of /v1/admin/flags), folded in from the former featuregate control plane.
|
||||
app.Get("/v1/admin/services", core.Guard(s, services))
|
||||
app.Post("/v1/admin/services", core.Guard(s, upsertService))
|
||||
app.Post("/v1/admin/services/:service/mode", core.Guard(s, setServiceMode))
|
||||
app.Get("/v1/admin/waitlist", core.Guard(s, waitlist))
|
||||
app.Post("/v1/admin/waitlist/boost", core.Guard(s, waitlistBoost))
|
||||
|
||||
|
||||
+28
-12
@@ -18,9 +18,8 @@ import (
|
||||
"github.com/hanzoai/cloud/audit"
|
||||
"github.com/hanzoai/cloud/clients/admin/money"
|
||||
"github.com/hanzoai/cloud/clients/finance"
|
||||
finmoney "github.com/hanzoai/cloud/clients/money"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
"github.com/hanzoai/cloud/types"
|
||||
"github.com/hanzoai/commerce/billing/creditledger"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
@@ -183,22 +182,39 @@ func ApplyGrant(s *cloud.Service[State], c *zip.Ctx, org string, req CreditReque
|
||||
// one shape regardless of which path moved the money.
|
||||
func grantDeposit(s *cloud.Service[State], c *zip.Ctx, org, currency, notes, tag, source string, amountCents int64) (before int64, txID string, after int64, afterExact string, err error) {
|
||||
ctx := c.Context()
|
||||
if fin := finance.Current(); fin != nil {
|
||||
if bal, berr := fin.Balance(ctx, org, org, currency, false); berr == nil {
|
||||
before = bal.Cents()
|
||||
// ONE credit path: prefer the in-proc commerce credit ledger (creditledger) — the
|
||||
// SAME injected ledger adapter commerce's POST /v1/billing/credit mints through
|
||||
// and the ai prepaid gate reads. An admin grant and a self-serve credit thus move
|
||||
// money the ONE way, into the ONE ledger; the admin path no longer carries its own
|
||||
// parallel finance.Deposit. The operator-nonce idempotency key rides through so a
|
||||
// retried grant dedupes (finance dedups on Ref). Before/after balances are read from
|
||||
// the SAME co-resident finance ledger for the audit trail (exact, sub-cent visible).
|
||||
if led := creditledger.Get(); led != nil {
|
||||
if fin := finance.Current(); fin != nil {
|
||||
if bal, berr := fin.Balance(ctx, org, org, currency, false); berr == nil {
|
||||
before = bal.Cents()
|
||||
}
|
||||
}
|
||||
id, derr := fin.Deposit(ctx, types.DepositInput{
|
||||
Org: org, Subject: org, Amount: finmoney.FromCents(amountCents), Currency: currency, Notes: notes, Tags: tag,
|
||||
id, balCents, cerr := led.Credit(ctx, creditledger.CreditInput{
|
||||
Org: org,
|
||||
Currency: currency,
|
||||
Reason: notes,
|
||||
Tag: tag,
|
||||
IdempotencyKey: grantIdempotencyKey(c, org, currency, source, amountCents),
|
||||
AmountCents: amountCents,
|
||||
})
|
||||
if derr != nil {
|
||||
return before, "", before, "", derr
|
||||
if cerr != nil {
|
||||
return before, "", before, "", cerr
|
||||
}
|
||||
if bal, berr := fin.Balance(ctx, org, org, currency, false); berr == nil {
|
||||
after, afterExact = bal.Cents(), bal.IntString() // afterExact = the EXACT balance (sub-cent visible)
|
||||
after = balCents
|
||||
if fin := finance.Current(); fin != nil {
|
||||
if bal, berr := fin.Balance(ctx, org, org, currency, false); berr == nil {
|
||||
afterExact = bal.AttoString() // afterExact = the EXACT balance (sub-cent visible)
|
||||
}
|
||||
}
|
||||
return before, id, after, afterExact, nil
|
||||
}
|
||||
// Split deploy: no co-resident finance ledger → the commerce billing HTTP deposit, with
|
||||
// Split deploy: no co-resident credit ledger → the commerce billing HTTP deposit, with
|
||||
// its operator-nonce idempotency key so a retried grant dedupes at commerce.
|
||||
beforeC, _ := s.State.Commerce.Credits(ctx, org)
|
||||
idem := grantIdempotencyKey(c, org, currency, source, amountCents)
|
||||
|
||||
+36
-14
@@ -2,26 +2,48 @@ package admin
|
||||
|
||||
// The PLATFORM CONTROL PLANE board (/v1/admin/flags) — every runtime LAUNCH / RELEASE
|
||||
// switch (waitlist, public signup, subsystem activation, gateway limits, network ids)
|
||||
// with its LIVE value, evaluated through the Hanzo Insights feature-flag engine
|
||||
// (clients/featureflags → insights rust/feature-flags). SuperAdmin only (mounted
|
||||
// behind s.guard, like every /v1/admin/* route).
|
||||
// with its LIVE value, evaluated through the embedded native flag engine
|
||||
// (clients/flags → native/flags, SQLite-per-project definitions + Rust FFI
|
||||
// evaluation). SuperAdmin only (mounted behind core.Guard, like every /v1/admin/*).
|
||||
//
|
||||
// ONE flag engine, not two. Insights OWNS the flag definitions, targeting, percentage
|
||||
// rollout, and the change/activity log. This endpoint READS the switches for the
|
||||
// cockpit and hands the operator the deep-links to the Insights flag MANAGER (where a
|
||||
// switch is toggled / rolled out / cohort-targeted) and its ACTIVITY LOG (the native
|
||||
// change audit). A flip there is hot — the consuming subsystems re-read within one
|
||||
// evaluation TTL, no redeploy. The board is read-only here on purpose: management is
|
||||
// the native Insights UI (the one-and-one-way flag surface), surfaced in the cockpit.
|
||||
// ONE flag engine, TWO verbs. GET reads the board; PUT writes a switch's definition
|
||||
// through flags.SetPlatformSwitch — the ONE write path, audited in the store's
|
||||
// activity log. A flip is hot: this pod applies immediately, peers converge within one
|
||||
// evaluation TTL (default 15s), no redeploy. Org/project product flags are managed on
|
||||
// /v1/flags (org-scoped); this surface is the platform's own switchboard.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/admin/core"
|
||||
"github.com/hanzoai/cloud/clients/featureflags"
|
||||
"github.com/hanzoai/cloud/clients/flags"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// flags answers GET /v1/admin/flags — the platform control-plane read board.
|
||||
func flags(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
return core.OK(c, featureflags.Board())
|
||||
// flagsBoard answers GET /v1/admin/flags — the platform control-plane read board.
|
||||
func flagsBoard(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
return core.OK(c, flags.Board())
|
||||
}
|
||||
|
||||
// setFlag answers PUT /v1/admin/flags/:key — store/overwrite one platform switch's
|
||||
// definition. The body is the flag definition JSON; the two common shapes:
|
||||
//
|
||||
// {"active": true} — boolean switch on/off
|
||||
// {"active": true, "filters": {"groups": [{"properties": [], "rollout_percentage": 100}],
|
||||
// "payloads": {"true": 250}}} — valued switch (int/string payload)
|
||||
func setFlag(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
key := strings.TrimSpace(c.Param("key"))
|
||||
if key == "" {
|
||||
return zip.ErrBadRequest("key is required")
|
||||
}
|
||||
body := c.Body()
|
||||
if len(body) == 0 || !json.Valid(body) {
|
||||
return zip.ErrBadRequest("body must be the flag definition JSON")
|
||||
}
|
||||
if err := flags.SetPlatformSwitch(key, json.RawMessage(body), c.UserEmail()); err != nil {
|
||||
return zip.ErrBadRequest(err.Error())
|
||||
}
|
||||
return core.OK(c, flags.Board())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package admin
|
||||
|
||||
// The /v1/admin/services board — the launch-control LENS on the ONE flag engine, twin
|
||||
// of /v1/admin/flags. Every hosted service (studio/chat/console/app/api/team + runtime
|
||||
// onboards) with its LIVE waitlist mode — the switch waitlist.<svc> evaluated through
|
||||
// clients/flags. This is the "remove the waitlist one service at a time" toggle.
|
||||
// SuperAdmin only (core.Guard), like every platform /v1/admin/*.
|
||||
//
|
||||
// Formerly clients/featuregate owned its OWN SQLite mode store + this control plane;
|
||||
// both folded onto the flag engine so the platform has ONE decision plane. featuregate
|
||||
// now owns only the native Enforce middleware — a consumer of flags.WaitlistModeForHost.
|
||||
// Per-user approval (the second, orthogonal axis) stays IAM's, reached via the existing
|
||||
// admin IAM proxy — not re-served here.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/admin/core"
|
||||
"github.com/hanzoai/cloud/clients/flags"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// services answers GET /v1/admin/services — the launch board (every service + live mode).
|
||||
func services(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
rows, err := flags.ListWaitlistServices(c.Context())
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "list services: %v", err)
|
||||
}
|
||||
return core.OK(c, map[string]any{"services": rows})
|
||||
}
|
||||
|
||||
// upsertService answers POST /v1/admin/services — onboard or edit a hosted service so a
|
||||
// new host is governed WITHOUT a redeploy. A re-register PRESERVES the live switch.
|
||||
func upsertService(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
var in flags.ServiceInput
|
||||
if err := c.Bind(&in); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(in.Service) == "" {
|
||||
return zip.ErrBadRequest("service slug is required")
|
||||
}
|
||||
view, err := flags.UpsertWaitlistService(c.Context(), in, c.UserEmail())
|
||||
if err != nil {
|
||||
return zip.ErrBadRequest(err.Error())
|
||||
}
|
||||
return core.OK(c, map[string]any{"service": view})
|
||||
}
|
||||
|
||||
// setServiceMode answers POST /v1/admin/services/:service/mode — flip one service's
|
||||
// waitlist switch {waitlistMode:bool}. The launch lever; hot, no redeploy.
|
||||
func setServiceMode(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
service := strings.TrimSpace(c.Param("service"))
|
||||
if service == "" {
|
||||
return zip.ErrBadRequest("service is required")
|
||||
}
|
||||
var body struct {
|
||||
WaitlistMode bool `json:"waitlistMode"`
|
||||
}
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return err
|
||||
}
|
||||
view, err := flags.SetWaitlistMode(c.Context(), service, body.WaitlistMode, c.UserEmail())
|
||||
if err != nil {
|
||||
if errors.Is(err, flags.ErrServiceNotFound) {
|
||||
return zip.ErrNotFound("service not found: " + service)
|
||||
}
|
||||
return zip.Errorf(http.StatusInternalServerError, "set mode: %v", err)
|
||||
}
|
||||
return core.OK(c, map[string]any{"service": view})
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// Package agent mounts the hanzoai/agent orchestrator into cloud: POST /v1/agent
|
||||
// (+ /v1/agent/presets, /v1/agent/conversations). The orchestrator logic and its
|
||||
// per-org conversation history live in github.com/hanzoai/agent, which imports
|
||||
// NEITHER cloud NOR ai. Cloud is the composition root: it injects the two seams —
|
||||
// - Completer: the ai subsystem's /v1/chat/completions, replayed in-process (the
|
||||
// one path that returns tool_calls AND carries per-org reserve/settle billing);
|
||||
// - ToolPlane: the unified tool registry (tools.Default()), so /v1/agent's
|
||||
// server-executed tools are the org's activated MCP/registry tools.
|
||||
// /v1/agent is a DISTINCT path (not /v1/chat, which ai owns as completions), so a
|
||||
// specific route wins over ai's /v1/* glob — no collision.
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
hz "github.com/hanzoai/agent"
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/tools"
|
||||
openai "github.com/sashabaranov/go-openai"
|
||||
fiber "github.com/zap-proto/fiber/v3"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// maxCompletionResponse bounds the in-process completion body read so a hostile or
|
||||
// broken upstream cannot balloon memory.
|
||||
const maxCompletionResponse = 8 << 20
|
||||
|
||||
// Mount wires POST /v1/agent (+ reads) into cloud, injecting the ai completion and
|
||||
// the tool plane. The caller identity comes from cloud's validated principal.
|
||||
func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("agent.Mount: nil zip.App")
|
||||
}
|
||||
_, err := hz.Mount(app, hz.Deps{
|
||||
Logger: deps.Logger,
|
||||
DataDir: deps.DataDir,
|
||||
Brand: deps.Brand,
|
||||
Model: deps.AIDefaultModel,
|
||||
Principal: func(c *zip.Ctx) (hz.Principal, bool) {
|
||||
p, ok := tools.PrincipalFrom(c)
|
||||
if !ok {
|
||||
return hz.Principal{}, false
|
||||
}
|
||||
return hz.Principal{Org: p.Org, Project: p.Project, User: p.User, Cred: credential(c)}, true
|
||||
},
|
||||
}, aiCompleter{app: app}, toolPlane{})
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Completer: replay /v1/chat/completions in-process ─────────────────────────────
|
||||
|
||||
type aiCompleter struct{ app *zip.App }
|
||||
|
||||
// Complete replays the request against the SAME app at /v1/chat/completions, so it
|
||||
// flows the whole middleware chain (per-org reserve/settle billing) and returns
|
||||
// tool_calls. Non-streaming. Mirrors the tool plane's in-process dispatch contract:
|
||||
// the caller's OWN credential headers are replayed; no minted authority header.
|
||||
func (a aiCompleter) Complete(ctx context.Context, cred map[string]string, req openai.ChatCompletionRequest) (openai.ChatCompletionResponse, error) {
|
||||
req.Stream = false
|
||||
b, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return openai.ChatCompletionResponse{}, err
|
||||
}
|
||||
hreq := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(b)).WithContext(ctx)
|
||||
hreq.Header.Set("Content-Type", "application/json")
|
||||
for k, v := range cred {
|
||||
hreq.Header.Set(k, v)
|
||||
}
|
||||
resp, err := a.app.Fiber().Test(hreq, fiber.TestConfig{Timeout: 0})
|
||||
if err != nil {
|
||||
return openai.ChatCompletionResponse{}, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, maxCompletionResponse))
|
||||
if resp.StatusCode/100 != 2 {
|
||||
// Carry the completion's OWN status + body so the round can pass a
|
||||
// caller-facing refusal (402 insufficient_balance, 429, 403) straight
|
||||
// through instead of masking it as a gateway 502. hz.UpstreamError is the
|
||||
// agent's typed seam for exactly this.
|
||||
return openai.ChatCompletionResponse{}, &hz.UpstreamError{Status: resp.StatusCode, Body: raw}
|
||||
}
|
||||
var out openai.ChatCompletionResponse
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return openai.ChatCompletionResponse{}, fmt.Errorf("decode completion: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ── ToolPlane: adapter over the unified registry ──────────────────────────────────
|
||||
|
||||
type toolPlane struct{}
|
||||
|
||||
func (toolPlane) List(ctx context.Context, scope hz.Scope) []hz.Tool {
|
||||
ts := tools.Default().List(ctx, tools.Scope{Org: scope.Org, Project: scope.Project})
|
||||
out := make([]hz.Tool, 0, len(ts))
|
||||
for _, t := range ts {
|
||||
out = append(out, hz.Tool{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
Schema: t.Schema,
|
||||
Activated: t.Activated,
|
||||
Dispatchable: t.Dispatchable,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (toolPlane) Exists(ctx context.Context, scope hz.Scope, name string) bool {
|
||||
return tools.Default().Exists(ctx, tools.Scope{Org: scope.Org, Project: scope.Project}, name)
|
||||
}
|
||||
|
||||
// Dispatch resolves the caller the ONE canonical way — tools.PrincipalFrom(c) — so
|
||||
// the tool runs under the SAME validated identity + credential as a direct call.
|
||||
// No reconstruction: the credential is only ever read from the live request.
|
||||
func (toolPlane) Dispatch(c *zip.Ctx, name string, args map[string]any) (any, error) {
|
||||
p, ok := tools.PrincipalFrom(c)
|
||||
if !ok {
|
||||
return nil, zip.ErrForbidden("a validated principal is required")
|
||||
}
|
||||
return tools.Default().Dispatch(c.Context(), p, name, args)
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
// credential extracts the caller's replayable credential headers (the same set the
|
||||
// tool plane replays) so the in-process completion runs as the caller.
|
||||
func credential(c *zip.Ctx) map[string]string {
|
||||
cred := map[string]string{}
|
||||
for _, h := range []string{"Authorization", "X-Authorization", "Cookie", "Accept-Language", "X-Forwarded-For"} {
|
||||
if v := c.Header(h); v != "" {
|
||||
cred[h] = v
|
||||
}
|
||||
}
|
||||
return cred
|
||||
}
|
||||
@@ -278,19 +278,18 @@ func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
|
||||
app.Get("/v1/agents", cloud.Handle(s, list))
|
||||
app.Post("/v1/agents", cloud.Handle(s, create))
|
||||
// Static org-wide surfaces MUST register before the :ref wildcard: Fiber
|
||||
// matches routes in registration order, so a bare `/v1/agents/:ref` would
|
||||
// otherwise capture "metrics"/"activity"/"sessions" as a ref and 404 them
|
||||
// (Red route audit). Registering the literals first makes them win.
|
||||
// The static org-wide surfaces are listed before the :ref wildcard for reading
|
||||
// order, not for matching: the router resolves by SPECIFICITY, so a literal
|
||||
// beats a param whatever order they register in ("metrics" is never captured as
|
||||
// a ref). Registration order decides nothing here — it only decides which
|
||||
// handler silently wins when two patterns are byte-identical, which is a
|
||||
// collision, not a precedence.
|
||||
app.Get("/v1/agents/metrics", cloud.Handle(s, metrics))
|
||||
app.Get("/v1/agents/activity", cloud.Handle(s, activity))
|
||||
// Live agent-session control plane: /v1/agents/sessions[/...]. Registered
|
||||
// before :name for the same registration-order reason (and internally the
|
||||
// static /stream precedes /:id).
|
||||
// Live agent-session control plane: /v1/agents/sessions[/...].
|
||||
mountSessions(s, app)
|
||||
// Agent targets: /v1/agents/targets[/...] — the #48 dispatch destinations a
|
||||
// session runs on. Registered before :ref for the same registration-order reason
|
||||
// (and internally /targets precedes /targets/:id).
|
||||
// session runs on.
|
||||
mountTargets(s, app)
|
||||
app.Get("/v1/agents/:ref", cloud.Handle(s, get))
|
||||
app.Patch("/v1/agents/:ref", cloud.Handle(s, update))
|
||||
@@ -992,6 +991,13 @@ func billingActor(org, sub string) string {
|
||||
return org
|
||||
}
|
||||
|
||||
// BillingActor is the exported form of the actor identity a session is recorded
|
||||
// under. The login-manager adapter (the only external caller) uses it to scope a
|
||||
// session stop/count to the REVOKING user's own actor, so a revoke can never reach a
|
||||
// co-tenant's sessions. It mirrors what sessions.go stamps on Session.Actor, so a
|
||||
// stop's actor predicate matches exactly the sessions that user created.
|
||||
func BillingActor(org, sub string) string { return billingActor(org, sub) }
|
||||
|
||||
func cleanList(xs []string) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
|
||||
@@ -61,6 +61,8 @@ const (
|
||||
maxHost = 256
|
||||
maxCwd = 1024
|
||||
maxRepo = 512
|
||||
maxProvider = 64
|
||||
maxAccount = 256
|
||||
)
|
||||
|
||||
func validKind(k string) bool {
|
||||
@@ -85,10 +87,12 @@ type sessionView struct {
|
||||
TaskRunID string `json:"taskRunId,omitempty"`
|
||||
// Execution context (mission-control): the machine/repo/cwd a card shows and
|
||||
// the run-target a session is dispatched to. Omitted when a surface didn't report it.
|
||||
Host string `json:"host,omitempty"`
|
||||
Cwd string `json:"cwd,omitempty"`
|
||||
Repo string `json:"repo,omitempty"`
|
||||
Target string `json:"target,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Cwd string `json:"cwd,omitempty"`
|
||||
Repo string `json:"repo,omitempty"`
|
||||
Target string `json:"target,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Account string `json:"account,omitempty"`
|
||||
|
||||
Events int `json:"events"`
|
||||
Children int `json:"children"`
|
||||
@@ -155,6 +159,7 @@ func toSessionView(x Session, events, children int) sessionView {
|
||||
ParentSessionID: x.ParentID, RootSessionID: x.RootID, Title: x.Title,
|
||||
TaskWorkflowID: x.TaskWorkflowID, TaskRunID: x.TaskRunID,
|
||||
Host: x.Host, Cwd: x.Cwd, Repo: x.Repo, Target: x.Target,
|
||||
Provider: x.Provider, Account: x.Account,
|
||||
Events: events, Children: children,
|
||||
StartedAt: rfc3339(x.StartedAt), EndedAt: rfc3339(x.EndedAt),
|
||||
CreatedAt: rfc3339(x.CreatedAt), UpdatedAt: rfc3339(x.UpdatedAt),
|
||||
@@ -207,6 +212,9 @@ type registerReq struct {
|
||||
Cwd string `json:"cwd"`
|
||||
Repo string `json:"repo"`
|
||||
Target string `json:"target"`
|
||||
// Account tag — the linked AI account this session ran under (login manager).
|
||||
Provider string `json:"provider"`
|
||||
Account string `json:"account"`
|
||||
}
|
||||
|
||||
func registerSession(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
@@ -249,6 +257,14 @@ func registerSession(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
if cerr != nil {
|
||||
return cerr
|
||||
}
|
||||
provider := strings.TrimSpace(body.Provider)
|
||||
account := strings.TrimSpace(body.Account)
|
||||
if len(provider) > maxProvider {
|
||||
return zip.ErrBadRequest("provider too long")
|
||||
}
|
||||
if len(account) > maxAccount {
|
||||
return zip.ErrBadRequest("account too long")
|
||||
}
|
||||
|
||||
id, err := genID("sess")
|
||||
if err != nil {
|
||||
@@ -261,6 +277,7 @@ func registerSession(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
TaskWorkflowID: strings.TrimSpace(body.TaskWorkflowID),
|
||||
TaskRunID: strings.TrimSpace(body.TaskRunID),
|
||||
Host: host, Cwd: cwd, Repo: repo, Target: target,
|
||||
Provider: provider, Account: account,
|
||||
StartedAt: now, CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
if isTerminalStatus(status) {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sessions_stop.go is the login-manager tie-in: the in-process action a link
|
||||
// revoke takes to tear down the live sessions that ran under a revoked account or
|
||||
// device, plus the active-session count the device view shows. Both are org-scoped
|
||||
// (org is the ONLY tenant key) and nil-safe (no agents mounted → 0), and neither
|
||||
// can fan out to another tenant or to an org's every session by accident.
|
||||
|
||||
// SessionMatch selects live (running|paused) sessions to stop or count. Actor (the
|
||||
// owning subject, org/user) is MANDATORY and always ANDed, so a match can only ever
|
||||
// affect the caller's OWN sessions — never a co-tenant's. Host/Provider/Account are
|
||||
// optional narrowing WITHIN the actor's sessions (empty = any of the actor's). A
|
||||
// match with no actor selects NOTHING (fail-closed), so a login-out can never sweep
|
||||
// another user's — or an org's every — session, even when Host/Provider/Account are
|
||||
// attacker-set at link upsert.
|
||||
type SessionMatch struct {
|
||||
Actor string
|
||||
Host string
|
||||
Provider string
|
||||
Account string
|
||||
}
|
||||
|
||||
// empty reports whether the match lacks its mandatory actor scope. Without an actor
|
||||
// the match selects nothing — the fail-closed direction (an under-stop, never a
|
||||
// cross-user over-stop).
|
||||
func (m SessionMatch) empty() bool {
|
||||
return strings.TrimSpace(m.Actor) == ""
|
||||
}
|
||||
|
||||
// where builds the ANDed predicate + args for a live-session match under org. Actor
|
||||
// is always in the base predicate (the guard rejects an empty actor before this runs),
|
||||
// so a stop/count is bounded to the caller's own sessions before any optional narrowing.
|
||||
func (m SessionMatch) where(org string) (string, []any) {
|
||||
where := "org=? AND actor=? AND status IN (?,?)"
|
||||
args := []any{org, strings.TrimSpace(m.Actor), StatusRunning, StatusPaused}
|
||||
if h := strings.TrimSpace(m.Host); h != "" {
|
||||
where += " AND host=?"
|
||||
args = append(args, h)
|
||||
}
|
||||
if p := strings.TrimSpace(m.Provider); p != "" {
|
||||
where += " AND provider=?"
|
||||
args = append(args, p)
|
||||
}
|
||||
if a := strings.TrimSpace(m.Account); a != "" {
|
||||
where += " AND account=?"
|
||||
args = append(args, a)
|
||||
}
|
||||
return where, args
|
||||
}
|
||||
|
||||
// listActiveMatch returns org's live sessions matching m, oldest first.
|
||||
func (s *Store) listActiveMatch(ctx context.Context, org string, m SessionMatch) ([]Session, error) {
|
||||
where, args := m.where(org)
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT `+sessionCols+` FROM agent_sessions WHERE `+where+` ORDER BY created_at ASC`, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list active match: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var out []Session
|
||||
for rows.Next() {
|
||||
x, err := scanSession(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan session: %w", err)
|
||||
}
|
||||
out = append(out, x)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// countActiveMatch counts org's live sessions matching m.
|
||||
func (s *Store) countActiveMatch(ctx context.Context, org string, m SessionMatch) (int, error) {
|
||||
where, args := m.where(org)
|
||||
var n int
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM agent_sessions WHERE `+where, args...).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("count active match: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// StopSessions closes every RUNNING|PAUSED session of org matching m — recording a
|
||||
// control "stop" event on each and transitioning it to a terminal state — and
|
||||
// returns how many it stopped. It is the action a login-out (link revoke) takes so
|
||||
// the sessions that ran under a revoked account/device are torn down. Org AND Actor
|
||||
// scope it: the caller passes their own actor (org/user), so a revoke can only ever
|
||||
// stop the caller's OWN sessions — never a co-tenant's, never an org's every session
|
||||
// — even though m's Host/Provider/Account come from an attacker-controllable link
|
||||
// row. A match with no actor stops nothing (fail-closed). Not-mounted → (0, nil), so
|
||||
// a revoke tolerates a deployment with no session plane.
|
||||
func StopSessions(ctx context.Context, org string, m SessionMatch) (int, error) {
|
||||
if mounted == nil {
|
||||
return 0, nil
|
||||
}
|
||||
org = strings.TrimSpace(org)
|
||||
if org == "" || m.empty() {
|
||||
return 0, nil
|
||||
}
|
||||
live, err := mounted.State.store.listActiveMatch(ctx, org, m)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
stopped := 0
|
||||
for _, x := range live {
|
||||
if err := stopOne(ctx, x); err != nil {
|
||||
// Best-effort per session: a failure on one does not abort the rest, so a
|
||||
// revoke tears down as many as it can and reports the true count.
|
||||
mounted.Log.Warn("agents: stop session", "org", org, "session", x.ID, "err", err)
|
||||
continue
|
||||
}
|
||||
stopped++
|
||||
}
|
||||
return stopped, nil
|
||||
}
|
||||
|
||||
// stopOne records a stop control event on a live session and moves it to a
|
||||
// terminal (error) state — the forced-teardown transition. A session already
|
||||
// terminal is skipped (monotonic terminal rule).
|
||||
func stopOne(ctx context.Context, x Session) error {
|
||||
if isTerminalStatus(x.Status) {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
if evID, err := genID("evt"); err == nil {
|
||||
payload, _ := json.Marshal(controlPayload{Command: CmdStop, Message: "account logged out via login manager"})
|
||||
e, aerr := mounted.State.store.AppendEvent(ctx, Event{
|
||||
ID: evID, SessionID: x.ID, Org: x.Org, Kind: KindControl,
|
||||
Actor: billingActor(x.Org, ""), Payload: string(payload), CreatedAt: now,
|
||||
})
|
||||
if aerr == nil {
|
||||
publishEvent(mounted, x.Org, x.RootID, e)
|
||||
}
|
||||
}
|
||||
x.Status = StatusError
|
||||
x.EndedAt = now
|
||||
x.UpdatedAt = now
|
||||
if err := mounted.State.store.UpdateSession(ctx, x); err != nil {
|
||||
return err
|
||||
}
|
||||
ev, _ := mounted.State.store.CountEvents(ctx, x.Org, x.ID)
|
||||
ch, _ := mounted.State.store.CountChildren(ctx, x.Org, x.ID)
|
||||
publishSession(mounted, x, ev, ch)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountActiveSessions returns how many of org's sessions matching m are live
|
||||
// (running|paused) — the device view's "active sessions". Org-scoped; 0 when not
|
||||
// mounted or the match is empty.
|
||||
func CountActiveSessions(ctx context.Context, org string, m SessionMatch) (int, error) {
|
||||
if mounted == nil {
|
||||
return 0, nil
|
||||
}
|
||||
org = strings.TrimSpace(org)
|
||||
if org == "" || m.empty() {
|
||||
return 0, nil
|
||||
}
|
||||
return mounted.State.store.countActiveMatch(ctx, org, m)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mkLive inserts a running session under a given actor/host/provider/account so the
|
||||
// stop-scope tests can craft the exact overlap a hostile revoke would try to exploit.
|
||||
func mkLive(t *testing.T, s *Store, id, org, actor, host, provider, account string) {
|
||||
t.Helper()
|
||||
if err := s.CreateSession(context.Background(), Session{
|
||||
ID: id, Org: org, Agent: "hanzo", Actor: actor, Status: StatusRunning,
|
||||
RootID: id, Title: "t", StartedAt: 1, CreatedAt: 1, UpdatedAt: 1,
|
||||
Host: host, Provider: provider, Account: account,
|
||||
}); err != nil {
|
||||
t.Fatalf("create %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func statusOf(t *testing.T, s *Store, org, id string) string {
|
||||
t.Helper()
|
||||
x, err := s.GetSession(context.Background(), org, id)
|
||||
if err != nil {
|
||||
t.Fatalf("get %s: %v", id, err)
|
||||
}
|
||||
return string(x.Status)
|
||||
}
|
||||
|
||||
// TestStopSessions_ActorScoped is the HIGH-1 regression. A login-out stop is bounded
|
||||
// to the REVOKING user's own actor, so no org member can terminate another member's
|
||||
// live sessions — even though the match's Host/Provider/Account come from a link row
|
||||
// the caller fully controls (attacker-set at upsert). It also proves the fail-closed
|
||||
// direction: a match with no actor stops NOTHING (an under-stop, never a cross-user
|
||||
// over-stop).
|
||||
func TestStopSessions_ActorScoped(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("provider wildcard stops only the caller's own sessions", func(t *testing.T) {
|
||||
mountInproc(t)
|
||||
st := mounted.State.store
|
||||
// One org, three users; Alice + Bob overlap on host AND provider so an
|
||||
// org-only match (the pre-fix behavior) would sweep every claude session.
|
||||
mkLive(t, st, "alice", "acme", "acme/alice", "box1", "claude", "alice@x")
|
||||
mkLive(t, st, "bob", "acme", "acme/bob", "box1", "claude", "bob@x")
|
||||
mkLive(t, st, "carol", "acme", "acme/carol", "box2", "openai", "carol@x")
|
||||
|
||||
n, err := StopSessions(ctx, "acme", SessionMatch{Actor: "acme/alice", Provider: "claude"})
|
||||
if err != nil {
|
||||
t.Fatalf("stop: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("wildcard-claude stop must hit only Alice's 1 session, got %d", n)
|
||||
}
|
||||
if s := statusOf(t, st, "acme", "alice"); s != string(StatusError) {
|
||||
t.Fatalf("Alice's own session must be stopped, got %q", s)
|
||||
}
|
||||
if s := statusOf(t, st, "acme", "bob"); s != string(StatusRunning) {
|
||||
t.Fatalf("co-tenant Bob must survive, got %q", s)
|
||||
}
|
||||
if s := statusOf(t, st, "acme", "carol"); s != string(StatusRunning) {
|
||||
t.Fatalf("co-tenant Carol must survive, got %q", s)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("host forge cannot reach a co-tenant on the same device", func(t *testing.T) {
|
||||
mountInproc(t)
|
||||
st := mounted.State.store
|
||||
// Alice and Bob both have a live session on the SAME host. Alice forges her
|
||||
// link Host to that shared box and revokes it.
|
||||
mkLive(t, st, "alice", "acme", "acme/alice", "shared", "claude", "alice@x")
|
||||
mkLive(t, st, "bob", "acme", "acme/bob", "shared", "claude", "bob@x")
|
||||
|
||||
n, err := StopSessions(ctx, "acme", SessionMatch{Actor: "acme/alice", Host: "shared"})
|
||||
if err != nil {
|
||||
t.Fatalf("stop: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("host-forge stop must hit only Alice's session on the host, got %d", n)
|
||||
}
|
||||
if s := statusOf(t, st, "acme", "bob"); s != string(StatusRunning) {
|
||||
t.Fatalf("co-tenant Bob on the same host must survive, got %q", s)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a match with no actor fails closed (stops nothing)", func(t *testing.T) {
|
||||
mountInproc(t)
|
||||
st := mounted.State.store
|
||||
mkLive(t, st, "alice", "acme", "acme/alice", "box1", "claude", "alice@x")
|
||||
mkLive(t, st, "bob", "acme", "acme/bob", "box1", "claude", "bob@x")
|
||||
|
||||
// An org+provider match that lost its caller identity must NOT sweep the org.
|
||||
n, err := StopSessions(ctx, "acme", SessionMatch{Provider: "claude", Host: "box1"})
|
||||
if err != nil {
|
||||
t.Fatalf("stop: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("no-actor match must stop nothing (fail-closed), got %d", n)
|
||||
}
|
||||
if s := statusOf(t, st, "acme", "alice"); s != string(StatusRunning) {
|
||||
t.Fatalf("no session may be stopped without an actor, Alice got %q", s)
|
||||
}
|
||||
if s := statusOf(t, st, "acme", "bob"); s != string(StatusRunning) {
|
||||
t.Fatalf("no session may be stopped without an actor, Bob got %q", s)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("count is actor scoped too", func(t *testing.T) {
|
||||
mountInproc(t)
|
||||
st := mounted.State.store
|
||||
mkLive(t, st, "alice", "acme", "acme/alice", "box1", "claude", "alice@x")
|
||||
mkLive(t, st, "bob", "acme", "acme/bob", "box1", "claude", "bob@x")
|
||||
|
||||
// Alice counting her device's active sessions sees only HER own, not Bob's.
|
||||
n, err := CountActiveSessions(ctx, "acme", SessionMatch{Actor: "acme/alice", Host: "box1"})
|
||||
if err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("count must be scoped to Alice's own sessions on the host, got %d", n)
|
||||
}
|
||||
// A count with no actor is 0, never the whole host.
|
||||
if n, _ := CountActiveSessions(ctx, "acme", SessionMatch{Host: "box1"}); n != 0 {
|
||||
t.Fatalf("no-actor count must be 0 (fail-closed), got %d", n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("caller still stops their OWN matching session", func(t *testing.T) {
|
||||
mountInproc(t)
|
||||
st := mounted.State.store
|
||||
// The fix must not over-restrict: Alice logging out her claude account DOES
|
||||
// tear down her matching session (the intended teardown).
|
||||
mkLive(t, st, "alice", "acme", "acme/alice", "box1", "claude", "alice@x")
|
||||
|
||||
n, err := StopSessions(ctx, "acme", SessionMatch{Actor: "acme/alice", Provider: "claude", Account: "alice@x"})
|
||||
if err != nil {
|
||||
t.Fatalf("stop: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("Alice's own-account logout must stop her session, got %d", n)
|
||||
}
|
||||
if s := statusOf(t, st, "acme", "alice"); s != string(StatusError) {
|
||||
t.Fatalf("Alice's session must be terminal after her own logout, got %q", s)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -52,6 +52,14 @@ type Session struct {
|
||||
Cwd string
|
||||
Repo string
|
||||
Target string
|
||||
|
||||
// Provider/Account tag a session with the linked AI account it ran under (the
|
||||
// login-manager tie-in): which provider (claude|codex|hanzo|…) and which
|
||||
// subscription/api account served this run. Optional (a surface that doesn't
|
||||
// know sets ""), surfaced so the cockpit shows "this ran on your Claude Max
|
||||
// acct" and so a login-out (link revoke) can stop the sessions that used it.
|
||||
Provider string
|
||||
Account string
|
||||
}
|
||||
|
||||
// Event is one entry in a session's ordered log: a model message, a tool call, a
|
||||
@@ -116,7 +124,9 @@ CREATE TABLE IF NOT EXISTS agent_sessions (
|
||||
host TEXT NOT NULL DEFAULT '',
|
||||
cwd TEXT NOT NULL DEFAULT '',
|
||||
repo TEXT NOT NULL DEFAULT '',
|
||||
target TEXT NOT NULL DEFAULT ''
|
||||
target TEXT NOT NULL DEFAULT '',
|
||||
provider TEXT NOT NULL DEFAULT '',
|
||||
account TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_sessions_org_root ON agent_sessions(org, root_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS ix_sessions_org_parent ON agent_sessions(org, parent_id, created_at);
|
||||
@@ -141,10 +151,12 @@ CREATE INDEX IF NOT EXISTS ix_events_org_session_seq ON agent_session_events(org
|
||||
// Forward, idempotent: a sessions table created before the execution-context
|
||||
// columns existed gains them here (the CREATE above only runs on a fresh DB).
|
||||
if err := s.addColumns("agent_sessions", map[string]string{
|
||||
"host": "TEXT NOT NULL DEFAULT ''",
|
||||
"cwd": "TEXT NOT NULL DEFAULT ''",
|
||||
"repo": "TEXT NOT NULL DEFAULT ''",
|
||||
"target": "TEXT NOT NULL DEFAULT ''",
|
||||
"host": "TEXT NOT NULL DEFAULT ''",
|
||||
"cwd": "TEXT NOT NULL DEFAULT ''",
|
||||
"repo": "TEXT NOT NULL DEFAULT ''",
|
||||
"target": "TEXT NOT NULL DEFAULT ''",
|
||||
"provider": "TEXT NOT NULL DEFAULT ''",
|
||||
"account": "TEXT NOT NULL DEFAULT ''",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -154,19 +166,21 @@ CREATE INDEX IF NOT EXISTS ix_events_org_session_seq ON agent_session_events(org
|
||||
// column and fail on the old schema ("no such column: target").
|
||||
if _, err := s.db.Exec(`
|
||||
CREATE INDEX IF NOT EXISTS ix_sessions_org_target ON agent_sessions(org, target);
|
||||
CREATE INDEX IF NOT EXISTS ix_sessions_org_host ON agent_sessions(org, host);`); err != nil {
|
||||
CREATE INDEX IF NOT EXISTS ix_sessions_org_host ON agent_sessions(org, host);
|
||||
CREATE INDEX IF NOT EXISTS ix_sessions_org_account ON agent_sessions(org, provider, account);`); err != nil {
|
||||
return fmt.Errorf("migrate sessions indexes: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const sessionCols = `id,org,agent,actor,status,parent_id,root_id,title,started_at,ended_at,created_at,updated_at,task_workflow_id,task_run_id,host,cwd,repo,target`
|
||||
const sessionCols = `id,org,agent,actor,status,parent_id,root_id,title,started_at,ended_at,created_at,updated_at,task_workflow_id,task_run_id,host,cwd,repo,target,provider,account`
|
||||
|
||||
func scanSession(sc interface{ Scan(...any) error }) (Session, error) {
|
||||
var x Session
|
||||
err := sc.Scan(&x.ID, &x.Org, &x.Agent, &x.Actor, &x.Status, &x.ParentID, &x.RootID,
|
||||
&x.Title, &x.StartedAt, &x.EndedAt, &x.CreatedAt, &x.UpdatedAt,
|
||||
&x.TaskWorkflowID, &x.TaskRunID, &x.Host, &x.Cwd, &x.Repo, &x.Target)
|
||||
&x.TaskWorkflowID, &x.TaskRunID, &x.Host, &x.Cwd, &x.Repo, &x.Target,
|
||||
&x.Provider, &x.Account)
|
||||
return x, err
|
||||
}
|
||||
|
||||
@@ -191,10 +205,10 @@ func (s *Store) CreateSession(ctx context.Context, x Session) error {
|
||||
}
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO agent_sessions (`+sessionCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
`INSERT INTO agent_sessions (`+sessionCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
x.ID, x.Org, x.Agent, x.Actor, x.Status, x.ParentID, x.RootID, x.Title,
|
||||
x.StartedAt, x.EndedAt, x.CreatedAt, x.UpdatedAt, x.TaskWorkflowID, x.TaskRunID,
|
||||
x.Host, x.Cwd, x.Repo, x.Target)
|
||||
x.Host, x.Cwd, x.Repo, x.Target, x.Provider, x.Account)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert session: %w", err)
|
||||
}
|
||||
|
||||
+147
-33
@@ -71,15 +71,21 @@ func validTargetStatus(s string) bool {
|
||||
|
||||
var errTargetNotFound = errors.New("agents: target not found")
|
||||
|
||||
// Target is a registered agent run-target (metadata only). Owned by one org.
|
||||
// Target is a registered agent run-target. Owned by one org. Spec is its static
|
||||
// capability (os/arch/cpus/memory/gpus) and Metrics its last live heartbeat
|
||||
// (loadavg/memory/gpu-util); MetricsAt is the unix second that heartbeat was recorded
|
||||
// (0 = never). See targetspec.go for the value plane.
|
||||
type Target struct {
|
||||
ID string
|
||||
Org string
|
||||
Label string
|
||||
Kind string // laptop | cloud | gpu | cluster | machine
|
||||
Status string // online | offline | draining
|
||||
Capacity string // free-form ("8 vCPU / 32G", "1× GB10")
|
||||
Capacity string // free-form ("8 vCPU / 32G", "1× GB10") — human summary
|
||||
Host string // hostname sessions on this machine report (maps sessions -> target)
|
||||
Spec Spec // static capability
|
||||
Metrics Metrics
|
||||
MetricsAt int64
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
}
|
||||
@@ -102,6 +108,9 @@ CREATE TABLE IF NOT EXISTS agent_targets (
|
||||
status TEXT NOT NULL DEFAULT 'online',
|
||||
capacity TEXT NOT NULL DEFAULT '',
|
||||
host TEXT NOT NULL DEFAULT '',
|
||||
spec TEXT NOT NULL DEFAULT '',
|
||||
metrics TEXT NOT NULL DEFAULT '',
|
||||
metrics_at INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
@@ -110,23 +119,40 @@ CREATE INDEX IF NOT EXISTS ix_targets_org_created ON agent_targets(org, created_
|
||||
if _, err := s.db.Exec(ddl); err != nil {
|
||||
return fmt.Errorf("migrate targets: %w", err)
|
||||
}
|
||||
// Forward, idempotent upgrade for target rows created before the capability +
|
||||
// metrics columns existed. PRAGMA-guarded, so re-running on an upgraded DB is a
|
||||
// no-op — the DDL above covers fresh installs, this covers pre-existing ones.
|
||||
if err := s.addColumns("agent_targets", map[string]string{
|
||||
"spec": "TEXT NOT NULL DEFAULT ''",
|
||||
"metrics": "TEXT NOT NULL DEFAULT ''",
|
||||
"metrics_at": "INTEGER NOT NULL DEFAULT 0",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const targetCols = `id,org,label,kind,status,capacity,host,created_at,updated_at`
|
||||
const targetCols = `id,org,label,kind,status,capacity,host,spec,metrics,metrics_at,created_at,updated_at`
|
||||
|
||||
func scanTarget(sc interface{ Scan(...any) error }) (Target, error) {
|
||||
var t Target
|
||||
var spec, metrics string
|
||||
err := sc.Scan(&t.ID, &t.Org, &t.Label, &t.Kind, &t.Status, &t.Capacity, &t.Host,
|
||||
&t.CreatedAt, &t.UpdatedAt)
|
||||
return t, err
|
||||
&spec, &metrics, &t.MetricsAt, &t.CreatedAt, &t.UpdatedAt)
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
t.Spec = decodeSpec(spec)
|
||||
t.Metrics = decodeMetrics(metrics)
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// CreateTarget inserts one target. The id is caller-generated (genID("tgt")).
|
||||
func (s *Store) CreateTarget(ctx context.Context, t Target) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO agent_targets (`+targetCols+`) VALUES (?,?,?,?,?,?,?,?,?)`,
|
||||
t.ID, t.Org, t.Label, t.Kind, t.Status, t.Capacity, t.Host, t.CreatedAt, t.UpdatedAt)
|
||||
`INSERT INTO agent_targets (`+targetCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
t.ID, t.Org, t.Label, t.Kind, t.Status, t.Capacity, t.Host,
|
||||
encodeSpec(t.Spec), encodeMetrics(t.Metrics), t.MetricsAt, t.CreatedAt, t.UpdatedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert target: %w", err)
|
||||
}
|
||||
@@ -171,9 +197,10 @@ func (s *Store) ListTargets(ctx context.Context, org string) ([]Target, error) {
|
||||
// so a cross-tenant id can never mutate another's target.
|
||||
func (s *Store) UpdateTarget(ctx context.Context, t Target) error {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE agent_targets SET label=?, kind=?, status=?, capacity=?, host=?, updated_at=?
|
||||
`UPDATE agent_targets SET label=?, kind=?, status=?, capacity=?, host=?, spec=?, metrics=?, metrics_at=?, updated_at=?
|
||||
WHERE org=? AND id=?`,
|
||||
t.Label, t.Kind, t.Status, t.Capacity, t.Host, t.UpdatedAt, t.Org, t.ID)
|
||||
t.Label, t.Kind, t.Status, t.Capacity, t.Host,
|
||||
encodeSpec(t.Spec), encodeMetrics(t.Metrics), t.MetricsAt, t.UpdatedAt, t.Org, t.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update target: %w", err)
|
||||
}
|
||||
@@ -184,6 +211,28 @@ func (s *Store) UpdateTarget(ctx context.Context, t Target) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTargetByHost returns an org's target reporting the given host, or
|
||||
// errTargetNotFound. It is how a re-link of the SAME machine finds its existing target
|
||||
// (idempotent register) instead of creating a duplicate. Org-scoped: a host string can
|
||||
// never resolve another tenant's target. Newest wins if a host was ever double-listed.
|
||||
func (s *Store) GetTargetByHost(ctx context.Context, org, host string) (Target, error) {
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" {
|
||||
return Target{}, errTargetNotFound
|
||||
}
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT `+targetCols+` FROM agent_targets WHERE org=? AND host=? ORDER BY created_at DESC, id ASC LIMIT 1`,
|
||||
org, host)
|
||||
t, err := scanTarget(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Target{}, errTargetNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Target{}, fmt.Errorf("get target by host: %w", err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// DeleteTarget removes an org's target. Sessions keep their recorded target id (a
|
||||
// historical fact); a detached target simply stops appearing in the registry.
|
||||
func (s *Store) DeleteTarget(ctx context.Context, org, id string) (bool, error) {
|
||||
@@ -216,25 +265,41 @@ func (s *Store) SessionLoad(ctx context.Context, org, id, host string) (TargetLo
|
||||
// ---- HTTP shapes (the published contract) ----
|
||||
|
||||
type targetView struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Kind string `json:"kind"`
|
||||
Status string `json:"status"`
|
||||
Capacity string `json:"capacity,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Sessions int `json:"sessions"`
|
||||
Running int `json:"running"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Kind string `json:"kind"`
|
||||
Status string `json:"status"`
|
||||
Capacity string `json:"capacity,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Spec *Spec `json:"spec,omitempty"`
|
||||
Metrics *Metrics `json:"metrics,omitempty"`
|
||||
MetricsAt string `json:"metricsAt,omitempty"`
|
||||
Sessions int `json:"sessions"`
|
||||
Running int `json:"running"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func toTargetView(t Target, load TargetLoad) targetView {
|
||||
return targetView{
|
||||
v := targetView{
|
||||
ID: t.ID, Label: t.Label, Kind: t.Kind, Status: t.Status,
|
||||
Capacity: t.Capacity, Host: t.Host,
|
||||
Sessions: load.Sessions, Running: load.Running,
|
||||
CreatedAt: rfc3339(t.CreatedAt), UpdatedAt: rfc3339(t.UpdatedAt),
|
||||
}
|
||||
if !t.Spec.IsZero() {
|
||||
spec := t.Spec
|
||||
v.Spec = &spec
|
||||
}
|
||||
if !t.Metrics.IsZero() {
|
||||
m := t.Metrics
|
||||
m.At = t.MetricsAt
|
||||
v.Metrics = &m
|
||||
}
|
||||
if t.MetricsAt > 0 {
|
||||
v.MetricsAt = rfc3339(t.MetricsAt)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// mountTargets registers the target routes. Called from Mount BEFORE the
|
||||
@@ -251,11 +316,13 @@ func mountTargets(s *cloud.Service[state], app *zip.App) {
|
||||
// ---- register ----
|
||||
|
||||
type targetReq struct {
|
||||
Label string `json:"label"`
|
||||
Kind string `json:"kind"`
|
||||
Status string `json:"status"`
|
||||
Capacity string `json:"capacity"`
|
||||
Host string `json:"host"`
|
||||
Label string `json:"label"`
|
||||
Kind string `json:"kind"`
|
||||
Status string `json:"status"`
|
||||
Capacity string `json:"capacity"`
|
||||
Host string `json:"host"`
|
||||
Spec Spec `json:"spec"`
|
||||
Metrics Metrics `json:"metrics"`
|
||||
}
|
||||
|
||||
func registerTarget(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
@@ -296,14 +363,42 @@ func registerTarget(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
if len(host) > maxHost {
|
||||
return zip.ErrBadRequest("host too long")
|
||||
}
|
||||
if len(body.Spec.GPUs) > maxGPUs {
|
||||
return zip.ErrBadRequest("too many gpus")
|
||||
}
|
||||
spec := body.Spec.Sanitize()
|
||||
metrics := body.Metrics.Sanitize()
|
||||
now := time.Now().Unix()
|
||||
metricsAt := int64(0)
|
||||
if !metrics.IsZero() {
|
||||
metricsAt = now // the server owns the staleness clock; a client can't forge it
|
||||
}
|
||||
|
||||
// Idempotent re-link: the SAME machine (org+host) refreshes its existing target
|
||||
// rather than piling up duplicates, so mission-control shows one row per machine
|
||||
// with live spec/metrics. Only an explicit host keys this — an anonymous target
|
||||
// (no host) always creates.
|
||||
if host != "" {
|
||||
if existing, err := s.State.store.GetTargetByHost(c.Context(), org, host); err == nil {
|
||||
existing.Label, existing.Kind, existing.Status, existing.Capacity = label, kind, status, capacity
|
||||
existing.Spec, existing.Metrics, existing.MetricsAt = spec, metrics, metricsAt
|
||||
existing.UpdatedAt = now
|
||||
if err := s.State.store.UpdateTarget(c.Context(), existing); err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "persist: %v", err)
|
||||
}
|
||||
load, _ := s.State.store.SessionLoad(c.Context(), org, existing.ID, existing.Host)
|
||||
return c.JSON(http.StatusOK, toTargetView(existing, load))
|
||||
}
|
||||
}
|
||||
|
||||
id, err := genID("tgt")
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "rng: %v", err)
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
t := Target{
|
||||
ID: id, Org: org, Label: label, Kind: kind, Status: status,
|
||||
Capacity: capacity, Host: host, CreatedAt: now, UpdatedAt: now,
|
||||
Capacity: capacity, Host: host, Spec: spec, Metrics: metrics, MetricsAt: metricsAt,
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
if err := s.State.store.CreateTarget(c.Context(), t); err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "persist: %v", err)
|
||||
@@ -355,11 +450,13 @@ func getTarget(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
// ---- patch ----
|
||||
|
||||
type patchTargetReq struct {
|
||||
Label *string `json:"label"`
|
||||
Kind *string `json:"kind"`
|
||||
Status *string `json:"status"`
|
||||
Capacity *string `json:"capacity"`
|
||||
Host *string `json:"host"`
|
||||
Label *string `json:"label"`
|
||||
Kind *string `json:"kind"`
|
||||
Status *string `json:"status"`
|
||||
Capacity *string `json:"capacity"`
|
||||
Host *string `json:"host"`
|
||||
Spec *Spec `json:"spec"`
|
||||
Metrics *Metrics `json:"metrics"` // present => a heartbeat; the server stamps its time
|
||||
}
|
||||
|
||||
func patchTarget(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
@@ -417,7 +514,24 @@ func patchTarget(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
}
|
||||
t.Host = nh
|
||||
}
|
||||
t.UpdatedAt = time.Now().Unix()
|
||||
now := time.Now().Unix()
|
||||
if body.Spec != nil {
|
||||
if len(body.Spec.GPUs) > maxGPUs {
|
||||
return zip.ErrBadRequest("too many gpus")
|
||||
}
|
||||
t.Spec = body.Spec.Sanitize()
|
||||
}
|
||||
if body.Metrics != nil {
|
||||
// A metrics patch IS a heartbeat: refresh the sample and stamp the server's
|
||||
// own clock (a client can never forge or backdate the staleness time).
|
||||
t.Metrics = body.Metrics.Sanitize()
|
||||
if t.Metrics.IsZero() {
|
||||
t.MetricsAt = 0
|
||||
} else {
|
||||
t.MetricsAt = now
|
||||
}
|
||||
}
|
||||
t.UpdatedAt = now
|
||||
if err := s.State.store.UpdateTarget(c.Context(), t); err != nil {
|
||||
if err == errTargetNotFound {
|
||||
return zip.ErrNotFound("target not found")
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// targetspec.go is the machine-capability value plane for a run-target: two orthogonal
|
||||
// values a linked computer carries so mission-control can answer "which machine, and
|
||||
// can it run this?" without copying the fact onto every session.
|
||||
//
|
||||
// - Spec — what the machine IS (os/arch/cpus/memory/gpus): static, rarely changes.
|
||||
// - Metrics — what the machine is DOING now (loadavg/memory/gpu-util): the last
|
||||
// heartbeat, with At = the server second it was recorded (the staleness clock).
|
||||
//
|
||||
// `hanzo code --link` captures both from explicit system sources (never the process
|
||||
// environment) and reports them on the target. They are stored as JSON on the target
|
||||
// row (agent_targets.spec / .metrics) — one column per concept, extensible without
|
||||
// schema churn — and every field is bounded on write (sanitize) so a hostile or buggy
|
||||
// client can never bloat the row or smuggle a non-finite float that would break JSON.
|
||||
|
||||
// GPU is one accelerator on a machine.
|
||||
type GPU struct {
|
||||
Vendor string `json:"vendor,omitempty"` // nvidia | amd | apple | intel | ...
|
||||
Model string `json:"model,omitempty"` // "GB10", "8060S", "RTX 4090"
|
||||
Memory int64 `json:"memory,omitempty"` // VRAM bytes, 0 = unknown
|
||||
}
|
||||
|
||||
// Spec is a machine's static capability.
|
||||
type Spec struct {
|
||||
OS string `json:"os,omitempty"` // linux | darwin | windows
|
||||
Arch string `json:"arch,omitempty"` // amd64 | arm64 | ...
|
||||
CPUs int `json:"cpus,omitempty"` // logical cores
|
||||
Memory int64 `json:"memory,omitempty"` // total RAM, bytes
|
||||
GPUs []GPU `json:"gpus,omitempty"`
|
||||
}
|
||||
|
||||
// Metrics is a machine's live state from the last heartbeat.
|
||||
type Metrics struct {
|
||||
Load1 float64 `json:"load1,omitempty"`
|
||||
Load5 float64 `json:"load5,omitempty"`
|
||||
Load15 float64 `json:"load15,omitempty"`
|
||||
MemUsed int64 `json:"memUsed,omitempty"` // bytes
|
||||
MemFree int64 `json:"memFree,omitempty"` // bytes
|
||||
GPUUtil float64 `json:"gpuUtil,omitempty"` // 0..1 aggregate utilization
|
||||
At int64 `json:"at,omitempty"` // unix seconds, server-stamped
|
||||
}
|
||||
|
||||
const (
|
||||
maxGPUs = 32 // an absurd count is a bug or an attack, not a real host
|
||||
maxSpecField = 64 // os/arch/gpu vendor/model
|
||||
maxCPUs = 8192 // clamps a garbage core count
|
||||
)
|
||||
|
||||
// IsZero reports an all-empty spec (nothing worth storing).
|
||||
func (s Spec) IsZero() bool {
|
||||
return s.OS == "" && s.Arch == "" && s.CPUs == 0 && s.Memory == 0 && len(s.GPUs) == 0
|
||||
}
|
||||
|
||||
// IsZero reports an all-empty metrics sample.
|
||||
func (m Metrics) IsZero() bool {
|
||||
return m.Load1 == 0 && m.Load5 == 0 && m.Load15 == 0 &&
|
||||
m.MemUsed == 0 && m.MemFree == 0 && m.GPUUtil == 0 && m.At == 0
|
||||
}
|
||||
|
||||
// Sanitize bounds every field so a target row stays small and well-formed no matter
|
||||
// what a client sends: strings trimmed + length-capped, counts/sizes non-negative and
|
||||
// clamped, GPU list truncated, floats coerced finite. It is total (never errors) so
|
||||
// the write path can always proceed with a safe value.
|
||||
func (s Spec) Sanitize() Spec {
|
||||
out := Spec{
|
||||
OS: clampStr(s.OS, maxSpecField),
|
||||
Arch: clampStr(s.Arch, maxSpecField),
|
||||
CPUs: clampInt(s.CPUs, maxCPUs),
|
||||
Memory: nonNegI64(s.Memory),
|
||||
}
|
||||
for i, g := range s.GPUs {
|
||||
if i >= maxGPUs {
|
||||
break
|
||||
}
|
||||
g = GPU{Vendor: clampStr(g.Vendor, maxSpecField), Model: clampStr(g.Model, maxSpecField), Memory: nonNegI64(g.Memory)}
|
||||
if g == (GPU{}) {
|
||||
continue
|
||||
}
|
||||
out.GPUs = append(out.GPUs, g)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Sanitize coerces a metrics sample into a safe, finite range. It does NOT set At —
|
||||
// the server stamps that so a client can never backdate or forge the staleness clock.
|
||||
func (m Metrics) Sanitize() Metrics {
|
||||
return Metrics{
|
||||
Load1: nonNegF(m.Load1),
|
||||
Load5: nonNegF(m.Load5),
|
||||
Load15: nonNegF(m.Load15),
|
||||
MemUsed: nonNegI64(m.MemUsed),
|
||||
MemFree: nonNegI64(m.MemFree),
|
||||
GPUUtil: clampF01(m.GPUUtil),
|
||||
}
|
||||
}
|
||||
|
||||
func clampStr(s string, n int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) > n {
|
||||
return strings.ToValidUTF8(s[:n], "")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func clampInt(i, hi int) int {
|
||||
if i < 0 {
|
||||
return 0
|
||||
}
|
||||
if i > hi {
|
||||
return hi
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
func nonNegI64(i int64) int64 {
|
||||
if i < 0 {
|
||||
return 0
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// nonNegF returns a finite, non-negative float (NaN/Inf/negative → 0), so a hostile
|
||||
// loadavg can never poison the JSON encode or the display.
|
||||
func nonNegF(f float64) float64 {
|
||||
if math.IsNaN(f) || math.IsInf(f, 0) || f < 0 {
|
||||
return 0
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// clampF01 returns a finite float in [0,1] (utilization).
|
||||
func clampF01(f float64) float64 {
|
||||
if math.IsNaN(f) || math.IsInf(f, 0) || f < 0 {
|
||||
return 0
|
||||
}
|
||||
if f > 1 {
|
||||
return 1
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// encodeSpec/decodeSpec + encodeMetrics/decodeMetrics are the column codecs. An empty
|
||||
// value encodes to "" (a NULL-equivalent the column defaults to), and a malformed
|
||||
// stored blob decodes to the zero value rather than failing a whole target read.
|
||||
func encodeSpec(s Spec) string {
|
||||
if s.IsZero() {
|
||||
return ""
|
||||
}
|
||||
b, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func decodeSpec(raw string) Spec {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return Spec{}
|
||||
}
|
||||
var s Spec
|
||||
if json.Unmarshal([]byte(raw), &s) != nil {
|
||||
return Spec{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func encodeMetrics(m Metrics) string {
|
||||
if m.IsZero() {
|
||||
return ""
|
||||
}
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func decodeMetrics(raw string) Metrics {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return Metrics{}
|
||||
}
|
||||
var m Metrics
|
||||
if json.Unmarshal([]byte(raw), &m) != nil {
|
||||
return Metrics{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSpecMetricsSanitize proves the capability + live-metrics values are bounded on
|
||||
// the way in: no unbounded strings, no absurd GPU counts, no negative sizes, and no
|
||||
// non-finite float that would break the JSON encode or the display. Sanitize is total
|
||||
// (never errors) so the write path always has a safe value.
|
||||
func TestSpecMetricsSanitize(t *testing.T) {
|
||||
longVendor := strings.Repeat("x", 500)
|
||||
huge := make([]GPU, 100)
|
||||
for i := range huge {
|
||||
huge[i] = GPU{Vendor: "nvidia", Model: "GB10", Memory: 1 << 40}
|
||||
}
|
||||
spec := Spec{
|
||||
OS: "linux", Arch: "arm64", CPUs: -5, Memory: -1,
|
||||
GPUs: append([]GPU{{Vendor: longVendor, Model: "x", Memory: -9}}, huge...),
|
||||
}.Sanitize()
|
||||
if spec.CPUs != 0 || spec.Memory != 0 {
|
||||
t.Fatalf("negative cpus/memory must clamp to 0, got cpus=%d mem=%d", spec.CPUs, spec.Memory)
|
||||
}
|
||||
if len(spec.GPUs) > maxGPUs {
|
||||
t.Fatalf("gpu list must cap at %d, got %d", maxGPUs, len(spec.GPUs))
|
||||
}
|
||||
if len(spec.GPUs[0].Vendor) > maxSpecField {
|
||||
t.Fatalf("gpu vendor must be length-capped, got %d", len(spec.GPUs[0].Vendor))
|
||||
}
|
||||
if spec.GPUs[0].Memory != 0 {
|
||||
t.Fatalf("negative gpu memory must clamp to 0, got %d", spec.GPUs[0].Memory)
|
||||
}
|
||||
|
||||
m := Metrics{
|
||||
Load1: math.NaN(), Load5: math.Inf(1), Load15: -3,
|
||||
MemUsed: -1, MemFree: 1 << 30, GPUUtil: 9.5, At: 999,
|
||||
}.Sanitize()
|
||||
if m.Load1 != 0 || m.Load5 != 0 || m.Load15 != 0 {
|
||||
t.Fatalf("NaN/Inf/negative load must become 0, got %+v", m)
|
||||
}
|
||||
if m.MemUsed != 0 {
|
||||
t.Fatalf("negative memUsed must clamp to 0, got %d", m.MemUsed)
|
||||
}
|
||||
if m.GPUUtil != 1 {
|
||||
t.Fatalf("gpuUtil must clamp to [0,1], got %v", m.GPUUtil)
|
||||
}
|
||||
if m.At != 0 {
|
||||
t.Fatalf("Sanitize must NOT carry a client-supplied At (server owns the clock), got %d", m.At)
|
||||
}
|
||||
// A JSON encode of the sanitized metrics must succeed (proves no residual NaN/Inf).
|
||||
if encodeMetrics(m) == "" && !m.IsZero() {
|
||||
t.Fatal("sanitized non-zero metrics must encode to a non-empty blob")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTargetStoreSpecMetricsRoundTrip proves the capability + metrics survive a store
|
||||
// write/read exactly (JSON column codec), and a malformed stored blob decodes to the
|
||||
// zero value rather than failing the whole target read.
|
||||
func TestTargetStoreSpecMetricsRoundTrip(t *testing.T) {
|
||||
s := testSessionStore(t)
|
||||
ctx := context.Background()
|
||||
spec := Spec{OS: "linux", Arch: "arm64", CPUs: 20, Memory: 128 << 30,
|
||||
GPUs: []GPU{{Vendor: "nvidia", Model: "GB10", Memory: 96 << 30}}}
|
||||
metrics := Metrics{Load1: 2.5, MemFree: 64 << 30, GPUUtil: 0.8}
|
||||
tg := Target{ID: "t1", Org: "acme", Label: "spark", Kind: TargetGPU, Status: TargetOnline,
|
||||
Host: "spark", Spec: spec, Metrics: metrics, MetricsAt: 1234, CreatedAt: 1, UpdatedAt: 1}
|
||||
if err := s.CreateTarget(ctx, tg); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
got, err := s.GetTarget(ctx, "acme", "t1")
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got.Spec, spec) {
|
||||
t.Fatalf("spec round-trip mismatch:\n got %+v\nwant %+v", got.Spec, spec)
|
||||
}
|
||||
if got.Metrics.Load1 != 2.5 || got.Metrics.MemFree != 64<<30 || got.Metrics.GPUUtil != 0.8 {
|
||||
t.Fatalf("metrics round-trip mismatch: %+v", got.Metrics)
|
||||
}
|
||||
if got.MetricsAt != 1234 {
|
||||
t.Fatalf("metricsAt column mismatch: %d", got.MetricsAt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetTargetByHost proves the idempotent-relink lookup is org-scoped (fail-closed
|
||||
// cross-tenant), empty-host is a miss, and the newest wins on a duplicate host.
|
||||
func TestGetTargetByHost(t *testing.T) {
|
||||
s := testSessionStore(t)
|
||||
ctx := context.Background()
|
||||
_ = s.CreateTarget(ctx, Target{ID: "a1", Org: "acme", Host: "box", Label: "old", CreatedAt: 10, UpdatedAt: 10, Kind: TargetMachine, Status: TargetOnline})
|
||||
_ = s.CreateTarget(ctx, Target{ID: "a2", Org: "acme", Host: "box", Label: "new", CreatedAt: 20, UpdatedAt: 20, Kind: TargetMachine, Status: TargetOnline})
|
||||
_ = s.CreateTarget(ctx, Target{ID: "e1", Org: "evil", Host: "box", Label: "evil", CreatedAt: 15, UpdatedAt: 15, Kind: TargetMachine, Status: TargetOnline})
|
||||
|
||||
got, err := s.GetTargetByHost(ctx, "acme", "box")
|
||||
if err != nil || got.ID != "a2" {
|
||||
t.Fatalf("newest of acme's host wins, got %+v %v", got, err)
|
||||
}
|
||||
if _, err := s.GetTargetByHost(ctx, "acme", " "); err != errTargetNotFound {
|
||||
t.Fatalf("empty host must miss, got %v", err)
|
||||
}
|
||||
if _, err := s.GetTargetByHost(ctx, "nobody", "box"); err != errTargetNotFound {
|
||||
t.Fatalf("a foreign org's host must fail-closed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPTargetCapabilityAndHeartbeat proves register carries spec + metrics into the
|
||||
// view, the server stamps the metrics clock (not the client), a metrics PATCH is a
|
||||
// heartbeat that refreshes the sample + clock, and a spec PATCH updates capability.
|
||||
func TestHTTPTargetCapabilityAndHeartbeat(t *testing.T) {
|
||||
app := mountApp(t, &fakeAI{content: "x"})
|
||||
code, b := do(t, app, http.MethodPost, "/v1/agents/targets", "acme", map[string]any{
|
||||
"label": "spark", "kind": TargetGPU, "host": "spark",
|
||||
"spec": map[string]any{"os": "linux", "arch": "arm64", "cpus": 20, "memory": 137438953472,
|
||||
"gpus": []map[string]any{{"vendor": "nvidia", "model": "GB10", "memory": 103079215104}}},
|
||||
// A client cannot forge the clock: it sends "at" but the server ignores it.
|
||||
"metrics": map[string]any{"load1": 1.5, "memFree": 68719476736, "gpuUtil": 0.4, "at": 42},
|
||||
})
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("register want 201, got %d (%s)", code, b)
|
||||
}
|
||||
var tv targetView
|
||||
mustJSON(t, b, &tv)
|
||||
if tv.Spec == nil || tv.Spec.Arch != "arm64" || tv.Spec.CPUs != 20 || len(tv.Spec.GPUs) != 1 || tv.Spec.GPUs[0].Model != "GB10" {
|
||||
t.Fatalf("spec not carried into view: %+v", tv.Spec)
|
||||
}
|
||||
if tv.Metrics == nil || tv.Metrics.Load1 != 1.5 || tv.Metrics.GPUUtil != 0.4 {
|
||||
t.Fatalf("metrics not carried into view: %+v", tv.Metrics)
|
||||
}
|
||||
if tv.Metrics.At == 42 || tv.Metrics.At <= 0 {
|
||||
t.Fatalf("metrics clock must be server-stamped (not client 42), got %d", tv.Metrics.At)
|
||||
}
|
||||
if tv.MetricsAt == "" {
|
||||
t.Fatalf("metricsAt (rfc3339) must be set when metrics present")
|
||||
}
|
||||
|
||||
// A metrics PATCH is a heartbeat: refresh the sample, keep the clock owned by us.
|
||||
code, b = do(t, app, http.MethodPatch, "/v1/agents/targets/"+tv.ID, "acme", map[string]any{
|
||||
"metrics": map[string]any{"load1": 3.0, "memFree": 1000, "at": 99},
|
||||
})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("heartbeat patch want 200, got %d (%s)", code, b)
|
||||
}
|
||||
var hb targetView
|
||||
mustJSON(t, b, &hb)
|
||||
if hb.Metrics == nil || hb.Metrics.Load1 != 3.0 {
|
||||
t.Fatalf("heartbeat did not refresh metrics: %+v", hb.Metrics)
|
||||
}
|
||||
if hb.Metrics.At <= 0 || hb.Metrics.At == 99 {
|
||||
t.Fatalf("heartbeat clock must be server-stamped, got %d", hb.Metrics.At)
|
||||
}
|
||||
// Spec is untouched by a metrics-only heartbeat.
|
||||
if hb.Spec == nil || hb.Spec.Arch != "arm64" {
|
||||
t.Fatalf("metrics heartbeat must not drop spec: %+v", hb.Spec)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPTargetUpsertByHost proves re-linking the SAME machine (org+host) refreshes
|
||||
// ONE target (200, not a duplicate), while a different host makes a second target.
|
||||
func TestHTTPTargetUpsertByHost(t *testing.T) {
|
||||
app := mountApp(t, &fakeAI{content: "x"})
|
||||
// First link on host "evo" -> created (201).
|
||||
code, b := do(t, app, http.MethodPost, "/v1/agents/targets", "acme", map[string]any{
|
||||
"label": "evo", "host": "evo", "capacity": "old", "metrics": map[string]any{"load1": 1},
|
||||
})
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("first link want 201, got %d (%s)", code, b)
|
||||
}
|
||||
var first targetView
|
||||
mustJSON(t, b, &first)
|
||||
|
||||
// Re-link the SAME host -> updated in place (200), same id, refreshed fields.
|
||||
code, b = do(t, app, http.MethodPost, "/v1/agents/targets", "acme", map[string]any{
|
||||
"label": "evo", "host": "evo", "capacity": "new", "metrics": map[string]any{"load1": 5},
|
||||
})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("re-link same host want 200 (upsert), got %d (%s)", code, b)
|
||||
}
|
||||
var second targetView
|
||||
mustJSON(t, b, &second)
|
||||
if second.ID != first.ID {
|
||||
t.Fatalf("re-link must reuse the machine's target id: %s != %s", second.ID, first.ID)
|
||||
}
|
||||
if second.Capacity != "new" || second.Metrics == nil || second.Metrics.Load1 != 5 {
|
||||
t.Fatalf("re-link must refresh capacity+metrics: %+v", second)
|
||||
}
|
||||
|
||||
// A different host is a distinct machine -> a second target.
|
||||
_, _ = do(t, app, http.MethodPost, "/v1/agents/targets", "acme", map[string]any{"label": "dbc", "host": "dbc"})
|
||||
|
||||
_, lb := do(t, app, http.MethodGet, "/v1/agents/targets", "acme", nil)
|
||||
var list targetsResp
|
||||
mustJSON(t, lb, &list)
|
||||
if len(list.Targets) != 2 {
|
||||
t.Fatalf("upsert must leave 2 machines (evo, dbc), got %d: %+v", len(list.Targets), list.Targets)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPTargetRejectsOversizeGPUList proves an absurd GPU array is rejected at the
|
||||
// handler (a clean 400) rather than silently truncated after a large allocation — the
|
||||
// bound is communicated to the client, and a normal list still registers.
|
||||
func TestHTTPTargetRejectsOversizeGPUList(t *testing.T) {
|
||||
app := mountApp(t, &fakeAI{content: "x"})
|
||||
huge := make([]map[string]any, maxGPUs+50)
|
||||
for i := range huge {
|
||||
huge[i] = map[string]any{"vendor": "nvidia", "model": "x"}
|
||||
}
|
||||
if code, b := do(t, app, http.MethodPost, "/v1/agents/targets", "acme", map[string]any{
|
||||
"label": "box", "host": "box", "spec": map[string]any{"gpus": huge},
|
||||
}); code != http.StatusBadRequest {
|
||||
t.Fatalf("oversize gpu list must be rejected 400, got %d (%s)", code, b)
|
||||
}
|
||||
// A normal-sized list is accepted.
|
||||
ok := []map[string]any{{"vendor": "nvidia", "model": "GB10"}, {"vendor": "amd", "model": "8060S"}}
|
||||
if code, b := do(t, app, http.MethodPost, "/v1/agents/targets", "acme", map[string]any{
|
||||
"label": "box", "host": "box", "spec": map[string]any{"gpus": ok},
|
||||
}); code != http.StatusCreated {
|
||||
t.Fatalf("normal gpu list must be accepted, got %d (%s)", code, b)
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,12 @@ func routes(app *zip.App, s *cloud.Service[state]) {
|
||||
app.Post("/v1/analytics", cloud.Handle(s, capture))
|
||||
app.Post("/v1/analytics/batch", cloud.Handle(s, capture))
|
||||
app.Post("/v1/tracker", cloud.Handle(s, capture))
|
||||
|
||||
// /v1/insights — the unified native surface (insights.go): PostHog-wire
|
||||
// ingest + console reads over the SAME engine. Flags live at /v1/flags.
|
||||
app.Get("/v1/insights/health", cloud.Handle(s, insightsHealth))
|
||||
app.Post("/v1/insights/e", cloud.Handle(s, insightsIngest))
|
||||
app.Get("/v1/insights/events", cloud.Handle(s, insightsEvents))
|
||||
}
|
||||
|
||||
// ── shared helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package analytics
|
||||
|
||||
// /v1/insights — the UNIFIED native insights surface on the SAME engine.
|
||||
//
|
||||
// This file is a WIRE ADAPTER, not a second pipeline: PostHog-shaped payloads
|
||||
// (what @hanzo/insights and every PostHog-compatible SDK emit) are mapped onto
|
||||
// the native CaptureEvent and flow through the ONE capture path (normalize →
|
||||
// scrub → hanzo.events), and the console reads recent events back through the
|
||||
// ONE datastore client. Flags stay at /v1/flags (the native flags engine) —
|
||||
// this namespace deliberately does not duplicate them.
|
||||
//
|
||||
// Routes (org resolved SERVER-SIDE — same tenant gates as the rest):
|
||||
//
|
||||
// POST /v1/insights/e PostHog-compatible ingest: one event or {batch:[...]}
|
||||
// GET /v1/insights/events recent events for the org (console read; limit<=200)
|
||||
// GET /v1/insights/health liveness of the unified surface
|
||||
//
|
||||
// SCALE PATH: accept is stateless (any replica) and the sink is the pooled
|
||||
// batch INSERT into Datastore. When ingest volume outgrows direct sink, the
|
||||
// seam is buildEventsInsert — swap the exec for a queue producer (mq/pubsub)
|
||||
// with a Datastore consumer, no handler changes.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
aiobject "github.com/hanzoai/ai/object"
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// insightsEvent is the PostHog wire shape (subset that matters for ingest).
|
||||
type insightsEvent struct {
|
||||
Event string `json:"event"`
|
||||
DistinctID string `json:"distinct_id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Properties map[string]any `json:"properties"`
|
||||
}
|
||||
|
||||
// insightsBody accepts both the single-event and batch PostHog shapes.
|
||||
type insightsBody struct {
|
||||
insightsEvent
|
||||
Batch []insightsEvent `json:"batch"`
|
||||
}
|
||||
|
||||
// toCapture maps one PostHog event onto the native CaptureEvent. Well-known
|
||||
// $-properties become first-class columns; the rest stay in properties (the
|
||||
// scrubber runs downstream in normalizeEvent, same as every native event).
|
||||
func (e insightsEvent) toCapture() CaptureEvent {
|
||||
props := e.Properties
|
||||
str := func(key string) string {
|
||||
if props == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := props[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
typ := "event"
|
||||
if e.Event == "$pageview" {
|
||||
typ = "pageview"
|
||||
}
|
||||
return CaptureEvent{
|
||||
Type: typ,
|
||||
Event: e.Event,
|
||||
Timestamp: e.Timestamp,
|
||||
DistinctID: e.DistinctID,
|
||||
SessionID: str("$session_id"),
|
||||
URL: str("$current_url"),
|
||||
Path: str("$pathname"),
|
||||
Referrer: str("$referrer"),
|
||||
Product: str("product"),
|
||||
Library: str("$lib"),
|
||||
LibraryVer: str("$lib_version"),
|
||||
Properties: props,
|
||||
}
|
||||
}
|
||||
|
||||
// insightsIngest answers POST /v1/insights/e — the PostHog-compatible front
|
||||
// door. Same tenant gate, same normalize/scrub, same warehouse as /v1/analytics.
|
||||
func insightsIngest(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := captureTenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("valid bearer or a recognized brand host required")
|
||||
}
|
||||
var body insightsBody
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return zip.ErrBadRequest("malformed insights payload")
|
||||
}
|
||||
events := body.Batch
|
||||
if len(events) == 0 && body.Event != "" {
|
||||
events = []insightsEvent{body.insightsEvent}
|
||||
}
|
||||
if len(events) == 0 {
|
||||
return c.JSON(http.StatusOK, CaptureResult{})
|
||||
}
|
||||
if len(events) > maxBatch {
|
||||
return zip.ErrBadRequest("batch too large")
|
||||
}
|
||||
if err := requireDatastore(); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx := c.Context()
|
||||
if err := EnsureEventsTable(ctx); err != nil {
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "analytics warehouse unavailable: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
rows := make([]eventRow, 0, len(events))
|
||||
dropped := 0
|
||||
for _, e := range events {
|
||||
row, ok := normalizeEvent(org, now, e.toCapture())
|
||||
if !ok {
|
||||
dropped++
|
||||
continue
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return c.JSON(http.StatusOK, CaptureResult{Dropped: dropped})
|
||||
}
|
||||
stmt, args := buildEventsInsert(rows)
|
||||
if err := aiobject.DatastoreExec(ctx, stmt, args...); err != nil {
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "analytics warehouse write failed: %v", err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, CaptureResult{Accepted: len(rows), Dropped: dropped})
|
||||
}
|
||||
|
||||
// insightsEvents answers GET /v1/insights/events — the console's recent-events
|
||||
// read (newest first). Tenant-scoped server-side; limit defaults 50, caps 200.
|
||||
func insightsEvents(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("valid bearer required")
|
||||
}
|
||||
limit, _ := strconv.Atoi(strings.TrimSpace(c.Query("limit")))
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
rows, err := aiobject.DatastoreQuery(c.Context(), `
|
||||
SELECT id, timestamp, event, event_type, distinct_id, session_id,
|
||||
product, url, path, properties
|
||||
FROM hanzo.events
|
||||
WHERE tenant_id = ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?`, org, limit)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "analytics warehouse unavailable: %v", err)
|
||||
}
|
||||
type ev struct {
|
||||
ID string `json:"id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Event string `json:"event"`
|
||||
Type string `json:"type"`
|
||||
DistinctID string `json:"distinctId"`
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
Product string `json:"product,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Properties json.RawMessage `json:"properties,omitempty"`
|
||||
}
|
||||
out := make([]ev, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
e := ev{
|
||||
ID: asStr(r["id"]), Timestamp: asStr(r["timestamp"]), Event: asStr(r["event"]),
|
||||
Type: asStr(r["event_type"]), DistinctID: asStr(r["distinct_id"]),
|
||||
SessionID: asStr(r["session_id"]), Product: asStr(r["product"]),
|
||||
URL: asStr(r["url"]), Path: asStr(r["path"]),
|
||||
}
|
||||
if p := asStr(r["properties"]); p != "" && json.Valid([]byte(p)) {
|
||||
e.Properties = json.RawMessage(p)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{"data": out})
|
||||
}
|
||||
|
||||
func insightsHealth(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
return c.JSON(http.StatusOK, map[string]any{"ok": true, "engine": "hanzo-analytics", "surface": "/v1/insights"})
|
||||
}
|
||||
|
||||
func asStr(v any) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case time.Time:
|
||||
return t.UTC().Format(time.RFC3339)
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
b, err := json.Marshal(t)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.Trim(string(b), `"`)
|
||||
}
|
||||
}
|
||||
@@ -676,7 +676,7 @@ func recordRunEnd(s *cloud.Service[state], ctx context.Context, in RunEndInput)
|
||||
|
||||
// meterUnit records one metered unit for an HTTP caller's org. Nil/disabled meter → no-op.
|
||||
func meterUnit(s *cloud.Service[state], org string, c *zip.Ctx) {
|
||||
s.Bill.Meter(principal.Payer(c), principal.Project(c), meterKind, cloud.ResourceFeeCents(feeEnvPrefix, meterKind), c.RequestID(), cloud.ClientIP(c))
|
||||
s.Bill.Meter(principal.HomeOrg(c), principal.Project(c), meterKind, cloud.ResourceFeeCents(feeEnvPrefix, meterKind), c.RequestID(), cloud.ClientIP(c))
|
||||
}
|
||||
|
||||
// meterRun records one metered unit for a flow run from the durable path (no HTTP
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/account"
|
||||
"github.com/hanzoai/cloud/clients/finance"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// The ONE prepaid-balance read for the customer surface (/v1/billing/balance and the
|
||||
// /v1/finance/balance projection).
|
||||
//
|
||||
// WHY IT IS NOT A COMMERCE PROXY. Co-resident, commerce registers its routes on the
|
||||
// HOST's zip app (apps/commerce.go mountCommerce → commerce.Embed with EmbedConfig.App),
|
||||
// and commerceinproc publishes that SAME shared app as the S2S "commerce" transport,
|
||||
// which re-dispatches BY PATH. commerce's own billing routes are NOT registered in this
|
||||
// binary — api.Route(), which registers GET /v1/billing/balance, is called only from
|
||||
// commerce's mount.go, which is behind `//go:build cloud` and never compiled (cloud ships
|
||||
// -tags "libsqlite3 sqlite_fts5"). So a GET of "/v1/billing/balance" through the S2S seam
|
||||
// matches the ONLY registration of that path — the handler below — and re-enters it with
|
||||
// no principal, which answers "sign in to view billing". The proxy was calling itself.
|
||||
//
|
||||
// WHERE THE MONEY IS. Co-resident, the prepaid wallet lives in cloud's OWN finance ledger
|
||||
// (clients/finance, per-org double-entry SQLite): wireFinance (build.go) points the ai
|
||||
// prepaid gate's balance read at it, the edge meter debits it, and an admin grant credits
|
||||
// it (clients/admin/core.grantDeposit prefers finance.Current() for exactly this reason).
|
||||
// So the customer's balance is read from that ledger DIRECTLY — no HTTP hop, nothing to
|
||||
// self-dispatch, and the number shown is the number that admits or refuses a request.
|
||||
// The commerce S2S read stays as the split-deploy fallback, unchanged.
|
||||
|
||||
// subjectFor resolves the billing subject for the caller by CALLING the ONE rule,
|
||||
// ai/object.Payer — the same function the ai prepaid gate (routers/filter_balance.go
|
||||
// resolveBillingKey) and the usage debit resolve. It is the SHARED resolver for every
|
||||
// commerce-projected read in this package (the balance read AND the finance reads), so
|
||||
// there is exactly one copy of the rule. cloud and ai each keeping their own copy is
|
||||
// what let them drift apart (cloud's console view scoping to the org while the gate
|
||||
// scoped to "org/user"), so the view showed a funded org while the gate refused the
|
||||
// member. One function, one rule, one wallet.
|
||||
//
|
||||
// org is the VALIDATED principal org. The name half uses the SAME precedence as
|
||||
// clients/account.resolveCaller: X-User-Name (the IAM username the identity boundary mints
|
||||
// from the validated `name` claim — expressly "the `name` half of <owner>/<name>"), then
|
||||
// X-User-Id. Both are authorityHeaders — stripped on ingress and re-injected only from
|
||||
// verified claims — so neither is a client value.
|
||||
//
|
||||
// The X-User-Id fallback goes through the shared account.PayerOf because that header's shape is
|
||||
// path-dependent: the gateway historically minted X-User-Id == the username, while the
|
||||
// in-binary direct-Bearer path mints the UUID subject, and callers hold it as an
|
||||
// "<owner>/<name>" key. PayerOf is the parse ai already uses to fold that key form back
|
||||
// to the payer, so the two agree by construction instead of by a re-implemented split.
|
||||
//
|
||||
// KNOWN RESIDUAL: a validated principal carrying NEITHER X-User-Name NOR an "<owner>/<name>"
|
||||
// X-User-Id (i.e. a bare username id) folds to the org pool. That requires a JWT with no
|
||||
// `name` and no `preferred_username`, since the boundary mints X-User-Name from either;
|
||||
// production tokens carry one. Called out for review rather than papered over.
|
||||
func subjectFor(c *zip.Ctx, org string) string {
|
||||
if name := strings.TrimSpace(c.Header("X-User-Name")); name != "" {
|
||||
return account.Payer(account.Credential{Owner: org, Name: name}).Subject()
|
||||
}
|
||||
return account.PayerOf(org, strings.TrimSpace(c.User())).Subject()
|
||||
}
|
||||
|
||||
// availableCents returns the caller's spendable prepaid balance from the co-resident
|
||||
// finance ledger. ok is false when no finance ledger is published (split deploy) and the
|
||||
// caller must fall back to the commerce S2S read; a non-nil err is a REAL read failure and
|
||||
// must be surfaced, never rendered as a zero balance — a balance that cannot be read is
|
||||
// unknown, and unknown is not "broke".
|
||||
func availableCents(ctx context.Context, org, subject string) (cents int64, ok bool, err error) {
|
||||
fin := finance.Current()
|
||||
if fin == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
bal, err := fin.Balance(ctx, org, subject, "usd", false)
|
||||
if err != nil {
|
||||
return 0, true, err
|
||||
}
|
||||
return bal.Cents(), true, nil
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/hanzoai/account"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/finance"
|
||||
"github.com/hanzoai/cloud/clients/money"
|
||||
"github.com/hanzoai/cloud/types"
|
||||
)
|
||||
|
||||
// fakeFinance is the co-resident finance ledger seam (types.FinanceClient). It records
|
||||
// the exact (org, subject) the handler read so a test can prove WHICH wallet the console
|
||||
// shows, and can be made to fail so a test can prove an unreadable balance is never
|
||||
// rendered as zero.
|
||||
type fakeFinance struct {
|
||||
wallets map[string]int64 // "org|subject" -> cents
|
||||
usageRows []finance.UsageRow
|
||||
err error
|
||||
gotOrg string
|
||||
gotSubj string
|
||||
calls int
|
||||
}
|
||||
|
||||
// ListUsage satisfies the optional co-resident usage-read capability coResidentUsage
|
||||
// resolves; returns the seeded rows so a test can prove the usage view answers from the
|
||||
// ledger instead of the self-dispatching commerce hop.
|
||||
func (f *fakeFinance) ListUsage(context.Context, string, int) ([]finance.UsageRow, error) {
|
||||
return f.usageRows, f.err
|
||||
}
|
||||
|
||||
func (f *fakeFinance) Balance(_ context.Context, org, subject, _ string, _ bool) (money.Amount, error) {
|
||||
f.calls++
|
||||
f.gotOrg, f.gotSubj = org, subject
|
||||
if f.err != nil {
|
||||
return money.Zero(), f.err
|
||||
}
|
||||
return money.FromCents(f.wallets[org+"|"+subject]), nil
|
||||
}
|
||||
|
||||
func (f *fakeFinance) Deposit(context.Context, types.DepositInput) (string, error) { return "", nil }
|
||||
func (f *fakeFinance) RecordUsage(context.Context, types.UsageInput) error { return nil }
|
||||
|
||||
func publishFinance(t *testing.T, f *fakeFinance) {
|
||||
t.Helper()
|
||||
finance.Publish(f)
|
||||
t.Cleanup(func() { finance.Publish(nil) })
|
||||
}
|
||||
|
||||
// TestBalance_ReadsFinanceLedgerNotCommerce is the regression for the live incident:
|
||||
// GET /v1/billing/balance proxied to commerce at the SAME path, which re-entered THIS
|
||||
// handler (commerceinproc dispatches the shared app by path; commerce's own billing
|
||||
// routes are never registered in the co-resident binary), answered "sign in to view
|
||||
// billing", and surfaced as "billing upstream status 500".
|
||||
//
|
||||
// Co-resident the balance now comes from the finance ledger — the wallet the ai gate
|
||||
// actually reads — and commerce is NOT called at all, so there is nothing to self-dispatch.
|
||||
func TestBalance_ReadsFinanceLedgerNotCommerce(t *testing.T) {
|
||||
fin := &fakeFinance{wallets: map[string]int64{"maxpower|maxpower": 6875}}
|
||||
publishFinance(t, fin)
|
||||
|
||||
f := &fakeCommerce{status: 200, body: `{"balance":1,"holds":0,"available":1}`}
|
||||
app := mountApp(t, f.server(t).URL, "svc-token")
|
||||
|
||||
code, body := call(t, app, http.MethodGet, "/v1/billing/balance", "maxpower/dave", "maxpower")
|
||||
if code != 200 {
|
||||
t.Fatalf("balance: want 200, got %d (%s)", code, body)
|
||||
}
|
||||
var got commerceBalance
|
||||
if err := json.Unmarshal(body, &got); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, body)
|
||||
}
|
||||
if got.Available != 6875 || got.Balance != 6875 {
|
||||
t.Fatalf("balance not read from the finance ledger: %s", body)
|
||||
}
|
||||
// The proof there is no self-dispatch: the commerce hop never happened.
|
||||
if f.gotPath != "" {
|
||||
t.Fatalf("commerce was called at %q — the self-dispatching proxy is back", f.gotPath)
|
||||
}
|
||||
if fin.gotOrg != "maxpower" {
|
||||
t.Fatalf("read org %q, want the validated principal's org", fin.gotOrg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBalance_SubjectIsTheGateSubject pins the invariant this incident broke: the wallet
|
||||
// the console SHOWS must be the wallet the ai prepaid gate READS. Both derive it from the
|
||||
// one function, ai/object.Payer, so they cannot drift apart again — cloud keeping its own
|
||||
// copy of this rule is exactly what let the console show a funded org while the gate
|
||||
// refused the member.
|
||||
func TestBalance_SubjectIsTheGateSubject(t *testing.T) {
|
||||
// The allowlists are gone; set them to values that WOULD have flipped the resolution
|
||||
// to prove they are inert — nothing reads them, the signup org still bills per-person.
|
||||
t.Setenv("PERSONAL_BILLING_ORGS", "")
|
||||
t.Setenv("ORG_BILLING_ORGS", "hanzo")
|
||||
|
||||
// The gate's subject for this principal, from ai itself — not a value this test invents.
|
||||
// This is what routers/filter_balance.go resolveBillingKey computes from the JWT claims:
|
||||
// a person in the signup org bills their OWN account, hanzo/z.
|
||||
want := account.Payer(account.Credential{Owner: "hanzo", Name: "z"}).Subject()
|
||||
if want != "hanzo/z" {
|
||||
t.Fatalf("precondition: ai resolves a signup-org person to %q, want hanzo/z", want)
|
||||
}
|
||||
|
||||
// Every identity shape a validated principal can arrive in must land on that ONE wallet.
|
||||
for _, tc := range []struct{ name, userName, userID string }{
|
||||
// Production: the identity boundary mints X-User-Name from the `name` claim.
|
||||
{"gateway mints X-User-Name", "z", "8f14e45f-ea1b-4c2a-9f3d-000000000001"},
|
||||
// In-binary direct-Bearer: X-User-Id is the UUID subject, X-User-Name carries the name.
|
||||
{"in-binary direct bearer", "z", "hanzo/z"},
|
||||
// No X-User-Name: the "<owner>/<name>" key form folds back via PayerOf.
|
||||
{"owner/name id, no X-User-Name", "", "hanzo/z"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fin := &fakeFinance{wallets: map[string]int64{}}
|
||||
publishFinance(t, fin)
|
||||
app := mountApp(t, "", "")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/billing/balance", nil)
|
||||
req.Header.Set("X-User-Id", tc.userID)
|
||||
req.Header.Set("X-Org-Id", "hanzo")
|
||||
if tc.userName != "" {
|
||||
req.Header.Set("X-User-Name", tc.userName)
|
||||
}
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Test: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
if fin.calls == 0 {
|
||||
t.Fatal("finance ledger was never read")
|
||||
}
|
||||
if fin.gotSubj != want {
|
||||
t.Fatalf("console read wallet %q but the ai gate reads %q — the view and the gate disagree", fin.gotSubj, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBalance_UnreadableIsNotZero is the fail-closed guard. A balance that cannot be read
|
||||
// is UNKNOWN, and unknown must never render as a zero balance (a fabricated "you're broke")
|
||||
// nor as a fabricated positive. It surfaces as an upstream failure.
|
||||
func TestBalance_UnreadableIsNotZero(t *testing.T) {
|
||||
publishFinance(t, &fakeFinance{err: fmt.Errorf("ledger open failed")})
|
||||
app := mountApp(t, "", "")
|
||||
|
||||
code, body := call(t, app, http.MethodGet, "/v1/billing/balance", "maxpower/dave", "maxpower")
|
||||
if code != http.StatusBadGateway {
|
||||
t.Fatalf("unreadable balance: want 502, got %d (%s)", code, body)
|
||||
}
|
||||
// The body must be an honest error, never a balance object — a caller must not be able
|
||||
// to mistake "unknown" for "zero".
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(body, &got); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, body)
|
||||
}
|
||||
if _, isBalance := got["available"]; isBalance {
|
||||
t.Fatalf("an unreadable balance was rendered as a balance object: %s", body)
|
||||
}
|
||||
if got["error"] == nil {
|
||||
t.Fatalf("want an honest error body, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBalance_RequiresSignIn proves the browser-facing sign-in gate is intact: no
|
||||
// validated principal ⇒ 401, and the ledger is never touched. A forged service-token
|
||||
// header cannot reach a wallet, because the org comes from the validated principal only.
|
||||
func TestBalance_RequiresSignIn(t *testing.T) {
|
||||
fin := &fakeFinance{wallets: map[string]int64{"hanzo|hanzo": 14953300}}
|
||||
publishFinance(t, fin)
|
||||
app := mountApp(t, "", "")
|
||||
|
||||
// No principal at all.
|
||||
if code, _ := call(t, app, http.MethodGet, "/v1/billing/balance", "", ""); code != http.StatusUnauthorized {
|
||||
t.Fatalf("anonymous: want 401, got %d", code)
|
||||
}
|
||||
// A client-supplied X-Org-Id with NO validated user is not a principal either — this
|
||||
// is the off-cluster forge Red will try.
|
||||
if code, _ := call(t, app, http.MethodGet, "/v1/billing/balance", "", "hanzo"); code != http.StatusUnauthorized {
|
||||
t.Fatalf("forged org, no principal: want 401, got %d", code)
|
||||
}
|
||||
if fin.calls != 0 {
|
||||
t.Fatalf("the ledger was read %d times without a validated principal", fin.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBalance_ScopedToCallerOrg proves tenant isolation: the org is taken from the
|
||||
// VALIDATED principal, so a caller reads only its OWN org's ledger no matter what the
|
||||
// request says. There is no client-supplied input that can widen it.
|
||||
func TestBalance_ScopedToCallerOrg(t *testing.T) {
|
||||
fin := &fakeFinance{wallets: map[string]int64{
|
||||
"victim|victim": 999999,
|
||||
"attacker|attacker": 5,
|
||||
}}
|
||||
publishFinance(t, fin)
|
||||
app := mountApp(t, "", "")
|
||||
|
||||
// Attacker is validated in its OWN org and tries to name the victim's org/subject
|
||||
// through every client-controlled channel the old proxy forwarded.
|
||||
code, body := call(t, app, http.MethodGet,
|
||||
"/v1/billing/balance?user=victim&userId=victim&customerId=victim&org=victim¤cy=usd",
|
||||
"attacker/mallory", "attacker")
|
||||
if code != 200 {
|
||||
t.Fatalf("want 200, got %d (%s)", code, body)
|
||||
}
|
||||
if fin.gotOrg != "attacker" {
|
||||
t.Fatalf("cross-org read: ledger org = %q, want attacker", fin.gotOrg)
|
||||
}
|
||||
var got commerceBalance
|
||||
_ = json.Unmarshal(body, &got)
|
||||
if got.Available == 999999 {
|
||||
t.Fatalf("attacker read the victim's balance: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -246,6 +246,24 @@ func usage(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
if !ok {
|
||||
return zip.ErrUnauthorized("sign in to view billing")
|
||||
}
|
||||
|
||||
// Co-resident, read the usage ledger DIRECTLY from cloud's own finance ledger
|
||||
// (usage_coresident.go explains why this is NOT a commerce proxy: proxying
|
||||
// "/v1/billing/usage" re-enters THIS handler — commerce's own /v1/billing/usage
|
||||
// route is behind //go:build cloud and never compiled here, so the only
|
||||
// registration of that path is this handler — and the in-proc S2S hop carries no
|
||||
// validated principal, so usage() self-answered "sign in to view billing"; that
|
||||
// self-dispatch is the 500 a valid caller saw). This is the exact move balance()
|
||||
// already makes. Off the co-resident path the commerce S2S proxy is unchanged.
|
||||
if body, coResident, err := coResidentUsage(c.Context(), org, strings.TrimSpace(c.Query("product")), strings.TrimSpace(c.Query("groupBy"))); err != nil {
|
||||
s.Log.Warn("finance usage read failed", "org", org, "err", err)
|
||||
return zip.Errorf(http.StatusBadGateway, "billing upstream unreachable")
|
||||
} else if coResident {
|
||||
c.SetHeader("Content-Type", "application/json")
|
||||
c.SetHeader("Cache-Control", "no-store")
|
||||
return c.Bytes(http.StatusOK, body)
|
||||
}
|
||||
|
||||
if !s.State.commerce.configured() {
|
||||
return zip.Errorf(http.StatusNotImplemented, "billing is not configured")
|
||||
}
|
||||
@@ -267,10 +285,39 @@ func usage(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
return c.Bytes(status, body)
|
||||
}
|
||||
|
||||
// balance → commerce GET /v1/billing/balance: the org's prepaid credit balance
|
||||
// ({balance,holds,available} in USD cents), the SAME wallet the gateway debits.
|
||||
// balance answers the caller's prepaid credit balance ({balance,holds,available} in USD
|
||||
// cents) — the SAME wallet the ai prepaid gate reads, the edge meter debits, and an admin
|
||||
// grant credits.
|
||||
//
|
||||
// Co-resident it reads cloud's own finance ledger DIRECTLY (balance.go explains why this
|
||||
// is NOT a commerce proxy: proxying "/v1/billing/balance" re-enters THIS handler, because
|
||||
// commerceinproc dispatches the shared app by path and commerce's own billing routes are
|
||||
// never registered in this binary — the proxy called itself and answered "sign in to view
|
||||
// billing"). Off the co-resident path the commerce S2S proxy is unchanged.
|
||||
//
|
||||
// Holds are the ai gate's in-pod reservations, not a persisted ledger position (see
|
||||
// types.FinanceClient.Balance), so the settled balance IS the available balance here.
|
||||
func balance(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
return proxy(s, c, "/v1/billing/balance", "currency")
|
||||
org, ok := principal.Org(c)
|
||||
if !ok {
|
||||
// A customer's OWN billing — never admin-gate it; an absent identity is a
|
||||
// true "not signed in" (401), matching usage/gpuCharge.
|
||||
return zip.ErrUnauthorized("sign in to view billing")
|
||||
}
|
||||
cents, coResident, err := availableCents(c.Context(), org, subjectFor(c, org))
|
||||
if err != nil {
|
||||
// A balance that cannot be READ is unknown — surface it as an upstream failure.
|
||||
// It must never render as a zero balance: unknown is not "broke".
|
||||
s.Log.Warn("finance balance read failed", "org", org, "err", err)
|
||||
return zip.Errorf(http.StatusBadGateway, "billing upstream unreachable")
|
||||
}
|
||||
if !coResident {
|
||||
return proxy(s, c, "/v1/billing/balance", "currency") // split deploy
|
||||
}
|
||||
c.SetHeader("Content-Type", "application/json")
|
||||
// Per-tenant money must never be cached by the browser or an intermediary.
|
||||
c.SetHeader("Cache-Control", "no-store")
|
||||
return c.JSON(http.StatusOK, commerceBalance{Balance: cents, Holds: 0, Available: cents})
|
||||
}
|
||||
|
||||
// gpuEligibility → commerce GET /v1/billing/gpu-eligibility: the read-only launch gate
|
||||
|
||||
@@ -199,6 +199,21 @@ func financeBalance(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
if !ok {
|
||||
return zip.ErrUnauthorized("sign in to view finance")
|
||||
}
|
||||
// The ONE balance read (balance.go) — the same wallet /v1/billing/balance answers, so
|
||||
// the two surfaces can never disagree. Co-resident this is the finance ledger; only a
|
||||
// split deploy falls through to the commerce S2S read below.
|
||||
if cents, coResident, err := availableCents(c.Context(), org, subjectFor(c, org)); err != nil {
|
||||
s.Log.Warn("finance balance read failed", "org", org, "err", err)
|
||||
return zip.Errorf(http.StatusBadGateway, "billing upstream unreachable")
|
||||
} else if coResident {
|
||||
return financeJSON(s, c, financeBalanceView{
|
||||
Currency: "usd",
|
||||
AvailableCents: cents,
|
||||
PendingCents: 0,
|
||||
DueCents: 0,
|
||||
AsOf: time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
if !s.State.commerce.configured() {
|
||||
return zip.Errorf(http.StatusNotImplemented, "billing is not configured")
|
||||
}
|
||||
@@ -339,7 +354,7 @@ func financePaymentMethods(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
return zip.Errorf(http.StatusNotImplemented, "billing is not configured")
|
||||
}
|
||||
// Portal read filters on customerId; the subject is pinned to the caller's own org.
|
||||
body, status, err := s.State.commerce.get(c.Context(), "/v1/billing/portal/payment-methods", org, financeSubject(org, nil))
|
||||
body, status, err := s.State.commerce.get(c.Context(), "/v1/billing/portal/payment-methods", org, financeSubject(subjectFor(c, org), nil))
|
||||
if err != nil {
|
||||
s.Log.Warn("commerce payment-methods read failed", "org", org, "err", err)
|
||||
return zip.Errorf(http.StatusBadGateway, "billing upstream unreachable")
|
||||
@@ -421,12 +436,12 @@ func financeCaller(s *cloud.Service[state], c *zip.Ctx) (string, bool) {
|
||||
return principal.Org(c)
|
||||
}
|
||||
|
||||
// financeSubject builds the commerce query with every billing-subject key PINNED to org
|
||||
// (the client can never widen scope), plus any extra passthrough params.
|
||||
func financeSubject(org string, extra url.Values) url.Values {
|
||||
// financeSubject builds the commerce query with every billing-subject key PINNED to
|
||||
// subject (the client can never widen scope), plus any extra passthrough params.
|
||||
func financeSubject(subject string, extra url.Values) url.Values {
|
||||
q := url.Values{}
|
||||
for _, k := range billingSubjectKeys {
|
||||
q.Set(k, org)
|
||||
q.Set(k, subject)
|
||||
}
|
||||
for k, vs := range extra {
|
||||
for _, v := range vs {
|
||||
@@ -439,7 +454,7 @@ func financeSubject(org string, extra url.Values) url.Values {
|
||||
// financeGet does one org-scoped commerce GET and decodes the 2xx body into out. A
|
||||
// non-2xx or unreachable upstream is surfaced honestly (never masked as empty data).
|
||||
func financeGet(s *cloud.Service[state], c *zip.Ctx, path, org string, extra url.Values, out any) error {
|
||||
body, status, err := s.State.commerce.get(c.Context(), path, org, financeSubject(org, extra))
|
||||
body, status, err := s.State.commerce.get(c.Context(), path, org, financeSubject(subjectFor(c, org), extra))
|
||||
if err != nil {
|
||||
s.Log.Warn("commerce finance read failed", "org", org, "path", path, "err", err)
|
||||
return zip.Errorf(http.StatusBadGateway, "billing upstream unreachable")
|
||||
@@ -457,7 +472,7 @@ func financeGet(s *cloud.Service[state], c *zip.Ctx, path, org string, extra url
|
||||
// credits/usage/ledger projections share). Tolerates the wrapped {transactions:[…]}
|
||||
// shape and a bare array.
|
||||
func financeTxns(s *cloud.Service[state], c *zip.Ctx, org string) ([]commerceTxn, error) {
|
||||
body, status, err := s.State.commerce.get(c.Context(), "/v1/billing/transactions", org, financeSubject(org, url.Values{"limit": {"2000"}}))
|
||||
body, status, err := s.State.commerce.get(c.Context(), "/v1/billing/transactions", org, financeSubject(subjectFor(c, org), url.Values{"limit": {"2000"}}))
|
||||
if err != nil {
|
||||
s.Log.Warn("commerce transactions read failed", "org", org, "err", err)
|
||||
return nil, zip.Errorf(http.StatusBadGateway, "billing upstream unreachable")
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package billing
|
||||
|
||||
// usage_coresident.go — the co-resident source for GET /v1/billing/usage.
|
||||
//
|
||||
// WHY IT EXISTS. balance() already reads cloud's OWN finance ledger directly rather
|
||||
// than proxying "/v1/billing/balance" through commerceinproc, because co-resident the
|
||||
// ONLY registration of that path is balance() itself (commerce's api.Route() is behind
|
||||
// //go:build cloud and never compiled here), so the S2S proxy re-dispatches BY PATH
|
||||
// straight back into the same handler, which self-answers "sign in to view billing"
|
||||
// (the in-proc hop carries no validated principal). usage() had the SAME defect on
|
||||
// "/v1/billing/usage" — a valid caller's usage read re-entered usage() and failed. This
|
||||
// file gives usage() the co-resident answer balance() already has: the usage ledger read
|
||||
// straight from finance (the wallet→revenue debits RecordUsage wrote), off the
|
||||
// self-dispatching hop. Split deploy (no co-resident finance) falls back to the commerce
|
||||
// S2S read, unchanged.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/finance"
|
||||
)
|
||||
|
||||
// coResidentUsage builds the customer usage envelope from cloud's OWN finance ledger
|
||||
// when the money plane is co-resident (finance.Current() published AND exposes the
|
||||
// usage read). It returns (body, true, nil) with the commerce-shaped
|
||||
// {user,count,usage:[...]} envelope — enriched + optionally ?product=filtered /
|
||||
// ?groupBy=product-reduced exactly like the proxied path — or (nil, false, nil) when
|
||||
// finance is not co-resident (split deploy), so the caller falls back to the commerce
|
||||
// S2S read. This is what keeps /v1/billing/usage off the self-dispatching commerceinproc
|
||||
// hop; a real read failure surfaces as a non-nil error (never a masked-empty ledger).
|
||||
func coResidentUsage(ctx context.Context, org, product, groupBy string) ([]byte, bool, error) {
|
||||
fin := finance.Current()
|
||||
if fin == nil {
|
||||
return nil, false, nil // split deploy → commerce S2S read
|
||||
}
|
||||
// The usage read is an OPTIONAL capability (the base FinanceClient is
|
||||
// Balance+Deposit+RecordUsage); a finance impl without it falls back to the proxy.
|
||||
lister, ok := fin.(interface {
|
||||
ListUsage(context.Context, string, int) ([]finance.UsageRow, error)
|
||||
})
|
||||
if !ok {
|
||||
return nil, false, nil
|
||||
}
|
||||
rows, err := lister.ListUsage(ctx, org, 2000)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
env := usageEnvelope(org, rows)
|
||||
if out, ok := enrichUsageLedger(env, product, groupBy); ok {
|
||||
return out, true, nil
|
||||
}
|
||||
return env, true, nil
|
||||
}
|
||||
|
||||
// usageEnvelope renders finance usage rows as commerce's GetUsage envelope
|
||||
// ({user,count,usage:[{transactionId,amount,metadata,createdAt}]}) — the exact shape the
|
||||
// console's normalizeUsageRecords + this package's enrichUsageLedger already parse.
|
||||
// amount is USD cents; metadata carries the metered unit (model) the debit recorded, so
|
||||
// enrichUsageLedger can still attribute a product where the unit implies one.
|
||||
func usageEnvelope(org string, rows []finance.UsageRow) []byte {
|
||||
type usageRow struct {
|
||||
TransactionID string `json:"transactionId"`
|
||||
Amount int64 `json:"amount"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
out := make([]usageRow, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
md := map[string]any{}
|
||||
if r.Model != "" {
|
||||
md["model"] = r.Model
|
||||
}
|
||||
out = append(out, usageRow{
|
||||
TransactionID: r.ID,
|
||||
Amount: r.Cents,
|
||||
Metadata: md,
|
||||
CreatedAt: time.Unix(r.CreatedAt, 0).UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{"user": org, "count": len(out), "usage": out})
|
||||
return body
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/finance"
|
||||
)
|
||||
|
||||
// TestCoResidentUsage proves usage() answers from the finance ledger (never the
|
||||
// self-dispatching commerce hop) and shapes the commerce GetUsage envelope the
|
||||
// console parses. fakeFinance + publishFinance live in balance_test.go.
|
||||
func TestCoResidentUsage(t *testing.T) {
|
||||
publishFinance(t, &fakeFinance{usageRows: []finance.UsageRow{
|
||||
{ID: "u1", Cents: 150, Model: "gpt-x", CreatedAt: 1_700_000_000},
|
||||
{ID: "u2", Cents: 75, Model: "embed-y", CreatedAt: 1_700_000_100},
|
||||
}})
|
||||
|
||||
body, coResident, err := coResidentUsage(context.Background(), "acme", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("coResidentUsage: %v", err)
|
||||
}
|
||||
if !coResident {
|
||||
t.Fatal("want coResident=true when finance is published")
|
||||
}
|
||||
var env struct {
|
||||
User string `json:"user"`
|
||||
Count int `json:"count"`
|
||||
Usage []struct {
|
||||
TransactionID string `json:"transactionId"`
|
||||
Amount int64 `json:"amount"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
t.Fatalf("envelope not valid JSON: %v\n%s", err, body)
|
||||
}
|
||||
if env.User != "acme" || env.Count != 2 || len(env.Usage) != 2 {
|
||||
t.Fatalf("bad envelope: user=%q count=%d rows=%d", env.User, env.Count, len(env.Usage))
|
||||
}
|
||||
if env.Usage[0].TransactionID != "u1" || env.Usage[0].Amount != 150 {
|
||||
t.Errorf("row0 = %+v", env.Usage[0])
|
||||
}
|
||||
if env.Usage[0].Metadata["model"] != "gpt-x" {
|
||||
t.Errorf("row0 metadata missing model: %+v", env.Usage[0].Metadata)
|
||||
}
|
||||
if env.Usage[0].CreatedAt == "" {
|
||||
t.Error("row0 createdAt should be RFC3339, got empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCoResidentUsageSplitDeploy proves that with no co-resident finance, usage()
|
||||
// falls through to the commerce S2S proxy (coResident=false), unchanged.
|
||||
func TestCoResidentUsageSplitDeploy(t *testing.T) {
|
||||
finance.Publish(nil)
|
||||
body, coResident, err := coResidentUsage(context.Background(), "acme", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("coResidentUsage: %v", err)
|
||||
}
|
||||
if coResident || body != nil {
|
||||
t.Fatalf("want fall-through (coResident=false, nil body); got coResident=%v body=%s", coResident, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCoResidentUsageGroupBy proves the ?groupBy=product reduction still runs on the
|
||||
// co-resident path (a token-metered row attributes to product "inference").
|
||||
func TestCoResidentUsageGroupBy(t *testing.T) {
|
||||
publishFinance(t, &fakeFinance{usageRows: []finance.UsageRow{{ID: "u1", Cents: 150, Model: "gpt-x", CreatedAt: 1_700_000_000}}})
|
||||
body, ok, err := coResidentUsage(context.Background(), "acme", "", "product")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("coResidentUsage groupBy: ok=%v err=%v", ok, err)
|
||||
}
|
||||
var grouped struct {
|
||||
GroupBy string `json:"groupBy"`
|
||||
Groups []struct {
|
||||
Product string `json:"product"`
|
||||
Requests int `json:"requests"`
|
||||
AmountCents int64 `json:"amountCents"`
|
||||
} `json:"groups"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &grouped); err != nil {
|
||||
t.Fatalf("grouped not valid JSON: %v\n%s", err, body)
|
||||
}
|
||||
if grouped.GroupBy != "product" || len(grouped.Groups) != 1 {
|
||||
t.Fatalf("bad grouped envelope: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// coding.go is the IN-PROCESS client for bot-gateway's native coding-task runner
|
||||
// (POST /v1/coding-tasks). It is the ONE wire seam cloud uses to hand a coding job
|
||||
// to the sandbox runtime — distinct from the /v1/bot/* reverse proxy (bot.go),
|
||||
// which relays an inbound browser/API request. Here cloud ORIGINATES the call
|
||||
// server-side (from a Slack coding trigger), so it mints the identity headers
|
||||
// itself (X-Org-Id / X-User-Id) and presents the shared gateway service token as
|
||||
// the pod-boundary bearer.
|
||||
//
|
||||
// The transport is a line-delimited JSON (NDJSON) stream: bot-gateway emits one
|
||||
// JSON object per line as the run progresses (clone → dev exec → commit → push),
|
||||
// each a `step`/`log`, and a final `result` (or `error`) line. Cloud mirrors every
|
||||
// step into the agent session as it arrives, so `GET /v1/agents/sessions/:id/stream`
|
||||
// carries the run live, and returns the terminal result.
|
||||
//
|
||||
// CREDENTIAL CUSTODY: the per-org agent git credential travels in the request
|
||||
// BODY (never on a URL, never on argv, never logged). bot-gateway injects it into
|
||||
// git via an env-fed http.extraHeader inside the sandbox; cloud never logs the
|
||||
// Credential field and this client never places it in an error string.
|
||||
|
||||
// Credential is the per-org agent git credential the sandbox presents to native
|
||||
// git. Token is the secret (an hk- key); Username is the basic-auth user label.
|
||||
// Marshaled into the request body only — never logged.
|
||||
type Credential struct {
|
||||
Username string `json:"username"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// CodingTaskRequest is the cloud→bot contract for one coding run.
|
||||
type CodingTaskRequest struct {
|
||||
CloneURL string `json:"cloneUrl"` // https://<domain>/v1/git/<org>/<repo>.git
|
||||
BaseBranch string `json:"baseBranch"` // branch to start from (default repo default)
|
||||
Branch string `json:"branch"` // branch to create + push (e.g. agent/<sessionid>)
|
||||
Prompt string `json:"prompt"` // the engineering task
|
||||
SessionID string `json:"sessionId"` // cloud session id (correlation)
|
||||
RunTimeoutSeconds int `json:"runTimeoutSeconds"` // sandbox run budget
|
||||
Credential Credential `json:"credential"` // agent git credential (write-only)
|
||||
}
|
||||
|
||||
// CodingStep is one progress event mirrored into the session. Type is "step" or
|
||||
// "log"; Step names the phase (clone|plan|edit|commit|push); Status is ok|error
|
||||
// for a completed phase. All fields are safe to surface (no credential).
|
||||
type CodingStep struct {
|
||||
Type string `json:"type"`
|
||||
Step string `json:"step,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// CodingTaskResult is the terminal outcome bot-gateway reports.
|
||||
type CodingTaskResult struct {
|
||||
Branch string `json:"branch"`
|
||||
CommitSha string `json:"commitSha"`
|
||||
Diffstat string `json:"diffstat"`
|
||||
Changed bool `json:"changed"`
|
||||
OK bool `json:"ok"`
|
||||
LogTail string `json:"logTail"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// codingEnvelope is the discriminated line shape: one of step/log/result/error.
|
||||
type codingEnvelope struct {
|
||||
Type string `json:"type"` // step | log | result | error
|
||||
Step string `json:"step,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
CommitSha string `json:"commitSha,omitempty"`
|
||||
Diffstat string `json:"diffstat,omitempty"`
|
||||
Changed bool `json:"changed,omitempty"`
|
||||
OK bool `json:"ok,omitempty"`
|
||||
LogTail string `json:"logTail,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
// codingLineCap bounds one NDJSON line (a diffstat/log line can be large but
|
||||
// never unbounded) so a hostile/huge line can't exhaust cloud memory.
|
||||
codingLineCap = 1 << 20 // 1 MiB per line
|
||||
// codingErrBodyCap bounds the non-2xx error body we read for a message.
|
||||
codingErrBodyCap = 64 << 10
|
||||
)
|
||||
|
||||
// codingHTTP has NO client-side timeout: a coding run legitimately streams for
|
||||
// minutes. The deadline is the caller's ctx (bounded by the coding job), applied
|
||||
// to the request; a stuck stream is cut when ctx expires.
|
||||
var codingHTTP = &http.Client{}
|
||||
|
||||
// RunCodingTask POSTs a coding job to bot-gateway and streams the NDJSON result,
|
||||
// invoking onStep for each progress line as it arrives. It returns the terminal
|
||||
// result. A non-2xx status, a transport error, or a stream that ends without a
|
||||
// terminal line is an error (no partial success is fabricated). org/userID are
|
||||
// the gateway-minted tenant context bot-gateway trusts AFTER its own bearer gate.
|
||||
func RunCodingTask(ctx context.Context, org, userID string, req CodingTaskRequest, onStep func(CodingStep)) (CodingTaskResult, error) {
|
||||
payload, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return CodingTaskResult{}, fmt.Errorf("bot: marshal coding request: %w", err)
|
||||
}
|
||||
// This POST carries the org's hk- git credential AND the shared gateway bearer,
|
||||
// so it must not travel cleartext. Require https by default; a plaintext in-cluster
|
||||
// target is allowed ONLY when the operator asserts the hop is secured by mesh mTLS
|
||||
// (BOT_GATEWAY_ALLOW_PLAINTEXT=1) — an explicit, documented trust decision, never a
|
||||
// silent default.
|
||||
target, terr := codingTaskTarget()
|
||||
if terr != nil {
|
||||
return CodingTaskResult{}, terr
|
||||
}
|
||||
hreq, err := http.NewRequestWithContext(ctx, http.MethodPost, target, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return CodingTaskResult{}, fmt.Errorf("bot: build coding request: %w", err)
|
||||
}
|
||||
hreq.Header.Set("Content-Type", "application/json")
|
||||
hreq.Header.Set("Accept", "application/x-ndjson")
|
||||
// Server-originated identity: cloud has already resolved the tenant, so it mints
|
||||
// the headers bot-gateway trusts (post pod-boundary auth). The service bearer is
|
||||
// the shared gateway token (KMS-injected env); absent => bot-gateway fails the
|
||||
// request closed at its auth gate.
|
||||
hreq.Header.Set("X-Org-Id", org)
|
||||
if userID != "" {
|
||||
hreq.Header.Set("X-User-Id", userID)
|
||||
}
|
||||
if tok := getenv("BOT_GATEWAY_TOKEN"); tok != "" {
|
||||
hreq.Header.Set("Authorization", "Bearer "+tok)
|
||||
}
|
||||
|
||||
resp, err := codingHTTP.Do(hreq)
|
||||
if err != nil {
|
||||
return CodingTaskResult{}, fmt.Errorf("bot: coding gateway unreachable: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, codingErrBodyCap))
|
||||
return CodingTaskResult{}, fmt.Errorf("bot: coding gateway status %d: %s",
|
||||
resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
sc := bufio.NewScanner(resp.Body)
|
||||
sc.Buffer(make([]byte, 0, 64<<10), codingLineCap)
|
||||
var result CodingTaskResult
|
||||
var gotTerminal bool
|
||||
for sc.Scan() {
|
||||
line := bytes.TrimSpace(sc.Bytes())
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var env codingEnvelope
|
||||
if err := json.Unmarshal(line, &env); err != nil {
|
||||
continue // skip a malformed line rather than abort the whole run
|
||||
}
|
||||
switch env.Type {
|
||||
case "result":
|
||||
result = CodingTaskResult{
|
||||
Branch: env.Branch, CommitSha: env.CommitSha, Diffstat: env.Diffstat,
|
||||
Changed: env.Changed, OK: env.OK, LogTail: env.LogTail,
|
||||
}
|
||||
gotTerminal = true
|
||||
case "error":
|
||||
result = CodingTaskResult{OK: false, LogTail: env.LogTail, Error: nonEmpty(env.Message, "coding task failed")}
|
||||
gotTerminal = true
|
||||
default: // step | log — mirror live
|
||||
if onStep != nil {
|
||||
onStep(CodingStep{Type: env.Type, Step: env.Step, Message: env.Message, Status: env.Status})
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return CodingTaskResult{}, fmt.Errorf("bot: coding stream read: %w", err)
|
||||
}
|
||||
if !gotTerminal {
|
||||
return CodingTaskResult{}, fmt.Errorf("bot: coding stream ended without a result")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// codingTaskTarget resolves the /v1/coding-tasks endpoint and fails closed on a
|
||||
// cleartext http:// target — the request carries the org's git credential and the
|
||||
// gateway bearer. BOT_GATEWAY_ALLOW_PLAINTEXT=1 is the explicit, operator-set
|
||||
// opt-out for a deployment whose bot-gateway hop is secured by mesh mTLS.
|
||||
func codingTaskTarget() (string, error) {
|
||||
base := botURL()
|
||||
u, err := url.Parse(base)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("bot: invalid gateway url")
|
||||
}
|
||||
if u.Scheme != "https" && getenv("BOT_GATEWAY_ALLOW_PLAINTEXT") != "1" {
|
||||
return "", fmt.Errorf("bot: refusing to send the coding credential over cleartext %q (set BOT_GATEWAY_URL to https, or BOT_GATEWAY_ALLOW_PLAINTEXT=1 if the hop is mesh-mTLS secured)", u.Scheme)
|
||||
}
|
||||
return base + "/v1/coding-tasks", nil
|
||||
}
|
||||
|
||||
// nonEmpty returns a when non-empty, else b.
|
||||
func nonEmpty(a, b string) string {
|
||||
if strings.TrimSpace(a) != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
+135
-413
@@ -1,166 +1,98 @@
|
||||
// Package bots mounts the Hanzo Cloud POST /v1/bots/run surface: launch a
|
||||
// computer-using agent (a "bot") — a booted desktop or terminal sandbox the
|
||||
// operative computer-use runtime drives to do a task — and hand back a LIVE
|
||||
// Package bots is the CONTROL PLANE for a bot run: a task the bot runtime
|
||||
// executes on a surface — a desktop or terminal sandbox it drives — with a LIVE
|
||||
// session (the URL the hanzo.app /vnc panel embeds to watch/attach).
|
||||
//
|
||||
// This handler is a THIN ORCHESTRATOR, deliberately. It does NOT boot machines,
|
||||
// speak VNC, or reimplement visor: those live in the separate TS `bot` service
|
||||
// (its gateway exposes the browser<->gateway<->node HMAC VNC tunnel at
|
||||
// /vnc?nodeId=<id>) and in visor (machine provisioning). This handler owns the
|
||||
// three things a cloud control-plane owns:
|
||||
// A bot run is ONE value with ONE home. It is not the bot MACHINE that hosts a
|
||||
// runtime (visor's /v1/compute/bots — a machine you rent), and it is not the
|
||||
// runtime service itself (clients/runtime — the transport to the executor).
|
||||
//
|
||||
// 1. authenticate the caller (a run MOVES MONEY, so a VALIDATED principal is
|
||||
// required — never a bare, forgeable org header);
|
||||
// 2. gate + meter the run against the caller's OWN org ledger (a flat per-run
|
||||
// fee, the same ResourceMeter path every non-LLM resource uses);
|
||||
// 3. mint the run id and return the session descriptor whose sessionUrl points
|
||||
// at the bot VNC gateway for that run.
|
||||
// CLOUD OWNS POLICY, THE RUNTIME OWNS THE RUN. The sandbox lives in the runtime,
|
||||
// keyed in the runtime's own store under the tenant that started it; that store is
|
||||
// the only thing that knows whether a run is alive. So this package keeps no
|
||||
// second copy of it. It owns what a control plane owns — who you are, which org
|
||||
// you are, and whether you may — and then asks the runtime, which IS the registry.
|
||||
// Copying that state into cloud would create a second id space agreeing with
|
||||
// nothing: listing runs that do not exist and stopping runs never started.
|
||||
//
|
||||
// Tenant isolation is the gateway-minted X-Org-Id (HIP-0026), resolved via
|
||||
// principal.Org and NEVER read from the request body — so one tenant can
|
||||
// never launch, or bill, a bot against another's org.
|
||||
// Isolation: the org is the gateway-minted X-Org-Id (HIP-0026) resolved via
|
||||
// principal.Org, NEVER a request field, and it is what cloud sends the runtime,
|
||||
// which keys every run under tenants/{org}/. A caller cannot name another tenant's
|
||||
// org, so it cannot read or stop another tenant's runs; a foreign run id resolves
|
||||
// under the CALLER's org, where it does not exist, and answers 404.
|
||||
//
|
||||
// Surface (org-scoped; the CLI `hanzo bot run` calls it):
|
||||
// Surface (org-scoped; the console BotsApi and the CLI `hanzo bot run` call it):
|
||||
//
|
||||
// POST /v1/bots/run {task, surface, gpu, timeout} -> {runId, status, sessionUrl}
|
||||
//
|
||||
// The billed unit is a flat per-RUN fee — the honest, policy-set unit a bot
|
||||
// launch bills. GB-seconds / GPU-hour metering is intentionally NOT fabricated
|
||||
// here: this endpoint launches the run, it does not observe its runtime
|
||||
// footprint (visor/bot-gateway do). The fee is ops-configurable per deployment
|
||||
// via cloud.ResourceFeeCents(botFeeEnvPrefix, meterKind); set it to 0 to make
|
||||
// bot launches free (and therefore un-gated), exactly like the agents run fee.
|
||||
// POST /v1/bots/run -> 501: no runtime launch operation exists yet
|
||||
// GET /v1/bots -> {bots:[{runId,task,surface,status,sessionUrl,startedAt}]}
|
||||
// POST /v1/bots/:runId/stop -> {runId, status}
|
||||
package bots
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/metering"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
"github.com/hanzoai/cloud/clients/runtime"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
const (
|
||||
// meterKind is the commerce "provider"/attribution label for bot spend — the
|
||||
// product:"bot". One value so every bot launch is attributed identically on
|
||||
// the ledger.
|
||||
meterKind = "bot"
|
||||
|
||||
// botFeeEnvPrefix is the operator knob for the flat per-run launch fee. The
|
||||
// effective fee is cloud.ResourceFeeCents(botFeeEnvPrefix, meterKind): a
|
||||
// CLOUD_BOT_FEE_CENTS override wins over the $1.00 default; set it to 0 to
|
||||
// make bot launches free (and therefore un-gated). A flat per-RUN fee — the
|
||||
// policy-set unit a launch bills; NOT a fabricated GB-second/GPU-hour price.
|
||||
botFeeEnvPrefix = "CLOUD_BOT_FEE_CENTS"
|
||||
|
||||
// gatewayURLEnv configures the browser-facing bot VNC gateway base — the
|
||||
// public origin the TS bot service serves /vnc?nodeId=<id> from, which the
|
||||
// hanzo.app /vnc panel embeds. It is DISTINCT from clients/bot's server-side
|
||||
// BOT_GATEWAY_URL (the in-cluster reverse-proxy target http://bot-gateway.hanzo.svc,
|
||||
// which a browser cannot reach): a session URL must be publicly embeddable, so
|
||||
// it carries its own knob. Ops sets it per deployment/brand.
|
||||
// gatewayURLEnv configures the browser-facing bot VNC gateway base — the public
|
||||
// origin the TS bot service serves /vnc?nodeId=<id> from, which the hanzo.app
|
||||
// /vnc panel embeds. It is DISTINCT from the runtime's in-cluster address
|
||||
// (clients/runtime's BOT_GATEWAY_URL, a pod-internal DNS name a browser cannot
|
||||
// reach): a session URL must be publicly embeddable, so it carries its own knob.
|
||||
gatewayURLEnv = "CLOUD_BOT_GATEWAY_URL"
|
||||
defaultGatewayURL = "https://bot.hanzo.ai"
|
||||
|
||||
// serverGatewayURLEnv is the IN-CLUSTER, server-side bot-gateway base the
|
||||
// control plane calls to list/stop live runs. It is the SAME knob clients/bot's
|
||||
// reverse proxy uses (BOT_GATEWAY_URL, default http://bot-gateway.hanzo.svc) —
|
||||
// NOT the browser-facing gatewayURLEnv above (a pod-internal DNS name a browser
|
||||
// can't reach). List/stop are server->server calls, so they ride the in-cluster
|
||||
// target; only the returned sessionUrl carries the public origin.
|
||||
serverGatewayURLEnv = "BOT_GATEWAY_URL"
|
||||
defaultServerGatewayURL = "http://bot-gateway.hanzo.svc"
|
||||
// maxRunID bounds the :runId path param before it is sent onward — an oversize
|
||||
// id is not a run this org owns, so it is a 404 like any other miss.
|
||||
maxRunID = 128
|
||||
|
||||
// gatewayCallTimeout bounds a single list/stop round-trip to the bot-gateway so
|
||||
// a hung gateway can't stall the control-plane request; on timeout list is
|
||||
// honest-empty and stop is a clean 502.
|
||||
gatewayCallTimeout = 15 * time.Second
|
||||
|
||||
// maxTask bounds the launch task/prompt at the create boundary.
|
||||
maxTask = 32 * 1024
|
||||
// maxTimeout caps the requested wall-clock so a client can't ask for an
|
||||
// unbounded run; the runtime enforces the real limit, this is the sane input
|
||||
// bound.
|
||||
maxTimeout = 24 * time.Hour
|
||||
|
||||
surfaceDesktop = "desktop"
|
||||
surfaceTerminal = "terminal"
|
||||
|
||||
// statusRunning is the launch outcome this endpoint reports: the run was
|
||||
// authorized, metered, and its live session URL is returned for the client to
|
||||
// attach to. The bot's fine-grained runtime lifecycle (booting/ready/stopped)
|
||||
// is visor/bot-gateway's concern and is observed there — this orchestration
|
||||
// record carries ONE status: the launch succeeded.
|
||||
// statusRunning is what a listed run reports when the runtime names no status of
|
||||
// its own. statusStopped is the terminal outcome a stop reports.
|
||||
statusRunning = "running"
|
||||
|
||||
// statusStopped is the terminal outcome POST /v1/bots/:runId/stop reports once
|
||||
// the bot-gateway has torn the run's live session down.
|
||||
statusStopped = "stopped"
|
||||
)
|
||||
|
||||
// identityHeaders are the gateway-minted tenant-context headers forwarded on a
|
||||
// server-side list/stop call so the bot-gateway scopes the operation to the SAME
|
||||
// caller — the exact set clients/bot's reverse proxy forwards. X-Org-Id is set
|
||||
// explicitly to the validated org (never the raw request header), so a forged
|
||||
// org can never reach the gateway; the rest ride through for the audit trail.
|
||||
var identityHeaders = []string{
|
||||
"Authorization", "X-User-Id", "X-User-Email", "X-Project-Id", "X-Environment",
|
||||
// Runtime is the seam onto the run registry — the bot runtime, which owns the
|
||||
// sandboxes and is therefore the only truthful answer to "what is running". Bound
|
||||
// to the real transport in wire.go; a fake in tests.
|
||||
//
|
||||
// Every method takes org FIRST and the runtime scopes by it. The seam carries no
|
||||
// authority: cloud decides WHETHER a caller may ask, the runtime answers WHAT it
|
||||
// holds for that org.
|
||||
type Runtime interface {
|
||||
List(ctx context.Context, org string) ([]Run, error)
|
||||
Stop(ctx context.Context, org, runID string) error
|
||||
}
|
||||
|
||||
// Run is one bot run as the runtime reports it.
|
||||
type Run struct {
|
||||
ID string
|
||||
Task string
|
||||
Surface string
|
||||
Status string
|
||||
StartedAt string // RFC3339, as the runtime stamps it
|
||||
}
|
||||
|
||||
// state is bots' own data; shared deps live in the embedded cloud.Base.
|
||||
type state struct {
|
||||
// bill is the shared per-org gate+meter (reuses deps.Metering, the ONE
|
||||
// commerce client the agents/provisioning/ml subsystems use). Nil/!Enabled()
|
||||
// makes Gate allow and Meter a no-op, so an unconfigured deployment launches
|
||||
// bots without billing rather than failing closed on a missing ledger. It stays
|
||||
// here (not Base.Bill) because its provider label is meterKind ("bot"), which
|
||||
// diverges from the subsystem name ("bots") — Base.Bill would attribute to the
|
||||
// wrong product.
|
||||
bill *cloud.ResourceMeter
|
||||
// gateway is the browser-facing bot VNC gateway base (no trailing slash) that
|
||||
// every returned sessionUrl is derived from.
|
||||
gateway string
|
||||
// serverGateway is the in-cluster bot-gateway base (no trailing slash) the
|
||||
// control plane calls server-side to list + stop an org's live runs.
|
||||
serverGateway string
|
||||
// cc is the outbound client for those server->server calls; a bounded timeout
|
||||
// keeps a hung gateway from stalling the request (list falls back to empty).
|
||||
cc *http.Client
|
||||
}
|
||||
|
||||
// runReq is the boot-a-computer-using-agent body — the exact shape the CLI
|
||||
// (cli/bot.go BotRunReq) sends. Org is NEVER here: it is the gateway-minted
|
||||
// X-Org-Id, resolved from the validated principal, never the body.
|
||||
type runReq struct {
|
||||
Task string `json:"task"`
|
||||
Surface string `json:"surface"` // desktop | terminal
|
||||
GPU bool `json:"gpu"`
|
||||
Timeout string `json:"timeout"` // optional wall-clock, e.g. "30m"
|
||||
}
|
||||
|
||||
// runView is the live-session descriptor — the exact shape the CLI
|
||||
// (cli/bot.go BotRunResult) decodes: the run id, its status, and the URL the
|
||||
// hanzo.app /vnc panel embeds.
|
||||
type runView struct {
|
||||
RunID string `json:"runId"`
|
||||
Status string `json:"status"`
|
||||
SessionURL string `json:"sessionUrl"`
|
||||
// runtime is the run registry — what list reads and what stop drives.
|
||||
runtime Runtime
|
||||
}
|
||||
|
||||
// botView is one row of GET /v1/bots — the console list item. sessionUrl is
|
||||
// derived control-plane side from runId (the ONE place a session URL is built,
|
||||
// sessionURL below), so the gateway never has to know its own public origin.
|
||||
// derived control-plane side from runId (the ONE place a session URL is built), so
|
||||
// the runtime never has to know its own public origin.
|
||||
type botView struct {
|
||||
RunID string `json:"runId"`
|
||||
Task string `json:"task"`
|
||||
@@ -170,8 +102,8 @@ type botView struct {
|
||||
StartedAt string `json:"startedAt"`
|
||||
}
|
||||
|
||||
// botsView is the GET /v1/bots envelope; Bots is always non-nil so an empty org
|
||||
// (or an unreachable gateway) serializes as {"bots":[]}, never {"bots":null}.
|
||||
// botsView is the GET /v1/bots envelope; Bots is always non-nil so an org with no
|
||||
// runs serializes as {"bots":[]}, never {"bots":null}.
|
||||
type botsView struct {
|
||||
Bots []botView `json:"bots"`
|
||||
}
|
||||
@@ -182,20 +114,7 @@ type stopView struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// gatewayBot is the bot-gateway's session shape: the caller's run minus the
|
||||
// sessionUrl (control-plane-derived). Its own /v1/bots emits exactly these
|
||||
// fields; a shape drift that fails to decode collapses to honest-empty.
|
||||
type gatewayBot struct {
|
||||
RunID string `json:"runId"`
|
||||
Task string `json:"task"`
|
||||
Surface string `json:"surface"`
|
||||
Status string `json:"status"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
}
|
||||
|
||||
// Mount wires the bots surface onto app per HIP-0106. Constructs the value directly
|
||||
// (cloud.NewBase) because the metered launch fee uses a meter keyed to meterKind
|
||||
// ("bot"), not the subsystem name — so it lives in State, built from Deps here.
|
||||
// Mount wires the bots surface onto app per HIP-0106.
|
||||
func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("bots.Mount: nil zip.App")
|
||||
@@ -204,109 +123,45 @@ func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
return fmt.Errorf("bots.Mount: nil deps.Logger")
|
||||
}
|
||||
s := &cloud.Service[state]{
|
||||
Base: cloud.NewBase(deps, "bots"),
|
||||
State: state{
|
||||
bill: cloud.NewResourceMeter(deps, meterKind),
|
||||
gateway: gatewayBase(),
|
||||
serverGateway: serverGatewayBase(),
|
||||
cc: &http.Client{Timeout: gatewayCallTimeout},
|
||||
},
|
||||
Base: cloud.NewBase(deps, "bots"),
|
||||
State: state{gateway: gatewayBase(), runtime: wire{}},
|
||||
}
|
||||
routes(app, s)
|
||||
s.Log.Info("bots surface mounted", "gateway", s.State.gateway,
|
||||
"serverGateway", s.State.serverGateway, "billing", s.State.bill.Enabled(),
|
||||
"brand", deps.Brand)
|
||||
s.Log.Info("bots surface mounted", "gateway", s.State.gateway, "brand", deps.Brand)
|
||||
return nil
|
||||
}
|
||||
|
||||
// routes registers the bots surface: launch, list, and stop. list/stop are the
|
||||
// read/lifecycle half — org-scoped proxies onto the bot-gateway's live runs.
|
||||
// routes registers the bots surface. The static /run literal and the :runId param
|
||||
// are resolved by specificity, so /v1/bots/run can never bind as a run id.
|
||||
func routes(app *zip.App, s *cloud.Service[state]) {
|
||||
app.Post("/v1/bots/run", cloud.Handle(s, run))
|
||||
app.Get("/v1/bots", cloud.Handle(s, list))
|
||||
app.Post("/v1/bots/:runId/stop", cloud.Handle(s, stop))
|
||||
}
|
||||
|
||||
// run launches a computer-using bot: it authenticates the caller, gates+meters a
|
||||
// flat per-run fee against the caller's OWN org, mints the run id, and returns
|
||||
// the live VNC session descriptor. Every 200 reflects an authorized, metered
|
||||
// launch — an unfunded org gets 402 and no session, an unreachable commerce 503.
|
||||
func run(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := principal.Org(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
// A launch MOVES MONEY (debits the org's commerce ledger), so it requires a
|
||||
// VALIDATED principal — never a bare, forgeable X-Org-Id from the direct-to-pod
|
||||
// path. Same money-path guard the agents run + s3 + provisioning surfaces ship.
|
||||
if !principal.Validated(c) {
|
||||
return zip.ErrForbidden("a validated principal is required to launch a bot")
|
||||
}
|
||||
var body runReq
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return err
|
||||
}
|
||||
task := strings.TrimSpace(body.Task)
|
||||
if task == "" {
|
||||
return zip.ErrBadRequest("task is required")
|
||||
}
|
||||
if len(task) > maxTask {
|
||||
return zip.ErrBadRequest("task too large")
|
||||
}
|
||||
surface, err := validateSurface(body.Surface)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateTimeout(body.Timeout); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runID, err := genID("bot")
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "rng: %v", err)
|
||||
}
|
||||
|
||||
// Pre-authorize the CALLER's org balance BEFORE returning a session (fail-
|
||||
// closed): an unfunded org gets 402 and no bot, an unreachable commerce 503.
|
||||
// project = the caller's validated org sub-scope, so a per-scope spend cap is
|
||||
// enforced on a bot launch exactly as on the request edge. fee<=0 or
|
||||
// unconfigured billing makes this a no-op (allow).
|
||||
fee := cloud.ResourceFeeCents(botFeeEnvPrefix, meterKind)
|
||||
project, projectValidated := principal.ValidatedProject(c)
|
||||
if gateErr := s.State.bill.Gate(c.Context(), principal.Payer(c), project, projectValidated, meterKind, fee); gateErr != nil {
|
||||
return cloud.DenyResource(c, gateErr)
|
||||
}
|
||||
|
||||
// The launch is authorized. The durable, attributable record of this run is
|
||||
// the commerce ledger debit (product=bot, the surface as the billed unit, the
|
||||
// acting principal for the audit trail) — fire-and-forget, exactly like the
|
||||
// agents run fee. GPU/surface ride the log line for operator visibility.
|
||||
s.State.bill.MeterUsage(principal.Payer(c), meterKind, metering.Usage{
|
||||
AmountCents: fee,
|
||||
Model: surface,
|
||||
Actor: billingActor(org, c.User()),
|
||||
RequestID: c.RequestID(),
|
||||
ClientIP: cloud.ClientIP(c),
|
||||
})
|
||||
s.Log.Info("bot launched", "org", org, "run", runID,
|
||||
"surface", surface, "gpu", body.GPU)
|
||||
|
||||
return c.JSON(http.StatusOK, runView{
|
||||
RunID: runID,
|
||||
Status: statusRunning,
|
||||
SessionURL: sessionURL(s, runID),
|
||||
})
|
||||
// run reports that launching is not implemented.
|
||||
//
|
||||
// There is no launch operation on the bot runtime, so nothing in cloud can start a
|
||||
// sandbox. This endpoint used to mint a run id, charge a flat per-run fee, and hand
|
||||
// back a sessionUrl for a bot that never booted — an id the runtime had never heard
|
||||
// of, pointing at a VNC node that did not exist, for money that was really taken.
|
||||
// 501 is the truth, and the truth is cheaper than a plausible lie.
|
||||
//
|
||||
// Restoring it needs a runtime-side launch operation first (TS, cross-repo); the
|
||||
// gate and the meter belong in the same change that can prove a bot boots.
|
||||
func run(_ *cloud.Service[state], _ *zip.Ctx) error {
|
||||
return zip.Errorf(http.StatusNotImplemented,
|
||||
"launching a bot is not implemented: the bot runtime exposes no launch operation, so cloud cannot start one")
|
||||
}
|
||||
|
||||
// list returns the caller org's live bot runs. It proxies the bot-gateway's
|
||||
// org-scoped session list (server-side, forwarding the caller's tenant context)
|
||||
// and normalizes each row into the console contract, deriving sessionUrl here.
|
||||
// list returns the caller org's live bot runs, read from the runtime and projected
|
||||
// into the console contract with sessionUrl derived here.
|
||||
//
|
||||
// The org is ALWAYS the validated principal's org, NEVER a request param — one
|
||||
// tenant can never enumerate another's runs. It is honest-empty by construction:
|
||||
// an unconfigured or unreachable gateway, a non-2xx, or a shape it can't decode
|
||||
// all yield {"bots":[]} (a 200), never a 5xx — the console renders "no bots"
|
||||
// rather than an error when the runtime plane is simply down.
|
||||
// The org is ALWAYS the validated principal's org, NEVER a request param, and it is
|
||||
// what scopes the runtime's answer — so one tenant can never enumerate another's
|
||||
// runs. A runtime that cannot answer is an error, not an empty list: [] would tell
|
||||
// the caller "your org has no runs", which is a different claim from "we could not
|
||||
// ask", and the difference is the whole reason this endpoint exists.
|
||||
func list(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := principal.Org(c)
|
||||
if !ok {
|
||||
@@ -314,18 +169,47 @@ func list(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
}
|
||||
// Org-scoping is only trustworthy behind a validated principal: a bare,
|
||||
// forgeable X-Org-Id (the direct-to-pod path) must not enumerate a victim
|
||||
// tenant's runs. Same guard clients/bot's proxy applies before handing the
|
||||
// gateway a tenant context.
|
||||
// tenant's runs.
|
||||
if !principal.Validated(c) {
|
||||
return zip.ErrForbidden("a validated principal is required to list bots")
|
||||
}
|
||||
return c.JSON(http.StatusOK, botsView{Bots: fetchBots(s, c, org)})
|
||||
runs, err := s.State.runtime.List(c.Context(), org)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusBadGateway, "bots: the runtime could not list this org's runs: %v", err)
|
||||
}
|
||||
out := make([]botView, 0, len(runs))
|
||||
for _, r := range runs {
|
||||
out = append(out, toBotView(s, r))
|
||||
}
|
||||
return c.JSON(http.StatusOK, botsView{Bots: out})
|
||||
}
|
||||
|
||||
// stop terminates one of the caller org's live runs. It proxies the bot-gateway's
|
||||
// org-scoped stop; a run the caller's org does not own is a 404 (never a 200,
|
||||
// never another tenant's teardown). An unreachable gateway is a clean 502 — a
|
||||
// stop that could not reach the runtime must not claim the run was stopped.
|
||||
// toBotView projects a run into one list row, deriving sessionUrl from the run id.
|
||||
func toBotView(s *cloud.Service[state], r Run) botView {
|
||||
status := strings.TrimSpace(r.Status)
|
||||
if status == "" {
|
||||
status = statusRunning
|
||||
}
|
||||
return botView{
|
||||
RunID: r.ID,
|
||||
Task: r.Task,
|
||||
Surface: r.Surface,
|
||||
Status: status,
|
||||
SessionURL: sessionURL(s, r.ID),
|
||||
StartedAt: r.StartedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// stop terminates one of the caller org's own runs.
|
||||
//
|
||||
// The own-key guard is the org: it is the caller's validated org, never theirs to
|
||||
// choose, and the runtime resolves the run id UNDER it. A run belonging to another
|
||||
// tenant is not among this org's runs, so it answers absent — the same 404 a
|
||||
// nonexistent id gets, which is what keeps this from being an oracle.
|
||||
//
|
||||
// Absence is honoured ONLY when the runtime answers it. A runtime that does not
|
||||
// serve stop reports nothing about the run, and reporting "stopped" on that basis
|
||||
// would be a stop that cannot fail — so it is a 502.
|
||||
func stop(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := principal.Org(c)
|
||||
if !ok {
|
||||
@@ -338,175 +222,33 @@ func stop(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
if runID == "" {
|
||||
return zip.ErrBadRequest("runId is required")
|
||||
}
|
||||
code, err := stopBot(s, c, org, runID)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusBadGateway, "bots: gateway unreachable: %v", err)
|
||||
if len(runID) > maxRunID {
|
||||
return zip.ErrNotFound("no such bot for this org")
|
||||
}
|
||||
switch {
|
||||
case code >= 200 && code < 300:
|
||||
switch err := s.State.runtime.Stop(c.Context(), org, runID); {
|
||||
case err == nil:
|
||||
s.Log.Info("bot stopped", "org", org, "run", runID)
|
||||
return c.JSON(http.StatusOK, stopView{RunID: runID, Status: statusStopped})
|
||||
case code == http.StatusNotFound:
|
||||
case errors.Is(err, runtime.ErrNotFound):
|
||||
return zip.ErrNotFound("no such bot for this org")
|
||||
case errors.Is(err, runtime.ErrNotServed):
|
||||
return zip.Errorf(http.StatusBadGateway,
|
||||
"bots: the runtime does not serve stop, so this run's state is unknown — it was NOT stopped")
|
||||
default:
|
||||
return zip.Errorf(http.StatusBadGateway, "bots: gateway rejected stop (%d)", code)
|
||||
return zip.Errorf(http.StatusBadGateway, "bots: the runtime could not stop this run: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// fetchBots calls the bot-gateway's GET /v1/bots scoped to org and maps the
|
||||
// result into the contract. Every failure path — no server gateway configured,
|
||||
// build error, transport error, non-2xx, or an undecodable body — returns a
|
||||
// non-nil empty slice so the caller serializes {"bots":[]} and never a 5xx.
|
||||
func fetchBots(s *cloud.Service[state], c *zip.Ctx, org string) []botView {
|
||||
out := []botView{}
|
||||
if s.State.serverGateway == "" {
|
||||
return out
|
||||
}
|
||||
req, err := gatewayRequest(c, http.MethodGet, s.State.serverGateway+"/v1/bots", org, nil)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
resp, err := s.State.cc.Do(req)
|
||||
if err != nil {
|
||||
s.Log.Warn("bots list: gateway unreachable, returning empty", "org", org, "err", err)
|
||||
return out
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
s.Log.Warn("bots list: gateway non-2xx, returning empty", "org", org, "status", resp.StatusCode)
|
||||
return out
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
var decoded struct {
|
||||
Bots []gatewayBot `json:"bots"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &decoded); err != nil {
|
||||
s.Log.Warn("bots list: undecodable gateway body, returning empty", "org", org, "err", err)
|
||||
return out
|
||||
}
|
||||
for _, b := range decoded.Bots {
|
||||
runID := strings.TrimSpace(b.RunID)
|
||||
if runID == "" {
|
||||
continue
|
||||
}
|
||||
status := strings.TrimSpace(b.Status)
|
||||
if status == "" {
|
||||
status = statusRunning
|
||||
}
|
||||
out = append(out, botView{
|
||||
RunID: runID,
|
||||
Task: b.Task,
|
||||
Surface: b.Surface,
|
||||
Status: status,
|
||||
SessionURL: sessionURL(s, runID),
|
||||
StartedAt: b.StartedAt,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// stopBot calls the bot-gateway's POST /v1/bots/{runId}/stop scoped to org and
|
||||
// returns the gateway status code. A transport failure is a non-nil error the
|
||||
// caller maps to 502; the status code drives 200-vs-404.
|
||||
func stopBot(s *cloud.Service[state], c *zip.Ctx, org, runID string) (int, error) {
|
||||
if s.State.serverGateway == "" {
|
||||
return 0, fmt.Errorf("bot gateway not configured")
|
||||
}
|
||||
target := s.State.serverGateway + "/v1/bots/" + url.PathEscape(runID) + "/stop"
|
||||
req, err := gatewayRequest(c, http.MethodPost, target, org, bytes.NewReader(nil))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
resp, err := s.State.cc.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// gatewayRequest builds a server-side call to the bot-gateway carrying the
|
||||
// caller's tenant context: X-Org-Id is pinned to the validated org, the other
|
||||
// identity headers ride through, so the gateway scopes to exactly this caller.
|
||||
func gatewayRequest(c *zip.Ctx, method, target, org string, body io.Reader) (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(c.Context(), method, target, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, h := range identityHeaders {
|
||||
if v := c.Header(h); v != "" {
|
||||
req.Header.Set(h, v)
|
||||
}
|
||||
}
|
||||
// Pin the validated org last so a forged X-Org-Id in the incoming headers can
|
||||
// never override the tenant the gateway scopes to.
|
||||
req.Header.Set("X-Org-Id", org)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// sessionURL derives the live VNC session URL for a run: the browser-facing bot
|
||||
// gateway base + the node's VNC path. One id per run — the run id IS the node id
|
||||
// the bot machine for this run registers under, so the tunnel is addressable by
|
||||
// exactly the id the client holds.
|
||||
// gateway base + the node's VNC path. The run id IS the node id the runtime
|
||||
// registers the session under, so the tunnel is addressable by exactly the id the
|
||||
// client holds.
|
||||
func sessionURL(s *cloud.Service[state], runID string) string {
|
||||
return s.State.gateway + "/vnc?" + url.Values{"nodeId": {runID}}.Encode()
|
||||
}
|
||||
|
||||
// validateSurface normalizes the requested sandbox. Empty defaults to desktop
|
||||
// (the noVNC GUI); an unknown surface is a clean 400 rather than a launch the
|
||||
// runtime can't honor.
|
||||
func validateSurface(raw string) (string, error) {
|
||||
switch strings.TrimSpace(strings.ToLower(raw)) {
|
||||
case "", surfaceDesktop:
|
||||
return surfaceDesktop, nil
|
||||
case surfaceTerminal:
|
||||
return surfaceTerminal, nil
|
||||
default:
|
||||
return "", zip.ErrBadRequest("surface must be 'desktop' or 'terminal'")
|
||||
}
|
||||
}
|
||||
|
||||
// validateTimeout bounds the optional wall-clock at the boundary: it must parse
|
||||
// as a Go duration and be within (0, maxTimeout]. Empty means "runtime default".
|
||||
func validateTimeout(raw string) error {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
d, err := time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
return zip.ErrBadRequest("invalid 'timeout': " + err.Error())
|
||||
}
|
||||
if d <= 0 || d > maxTimeout {
|
||||
return zip.ErrBadRequest("timeout must be > 0 and <= 24h")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// billingActor is the "org/sub" identity recorded on a debit for the audit
|
||||
// trail. It never selects which balance is gated — that is always the org — but
|
||||
// attributes the spend to the acting principal. Falls back to the bare org when
|
||||
// no validated subject is present.
|
||||
func billingActor(org, sub string) string {
|
||||
sub = strings.TrimSpace(sub)
|
||||
if org != "" && sub != "" {
|
||||
return org + "/" + sub
|
||||
}
|
||||
if sub != "" {
|
||||
return sub
|
||||
}
|
||||
return org
|
||||
}
|
||||
|
||||
// gatewayBase resolves the browser-facing bot VNC gateway base (no trailing
|
||||
// slash) from CLOUD_BOT_GATEWAY_URL, falling back to the public default.
|
||||
// gatewayBase resolves the browser-facing bot VNC gateway base (no trailing slash)
|
||||
// from CLOUD_BOT_GATEWAY_URL, falling back to the public default.
|
||||
func gatewayBase() string {
|
||||
base := strings.TrimSpace(os.Getenv(gatewayURLEnv))
|
||||
if base == "" {
|
||||
@@ -514,23 +256,3 @@ func gatewayBase() string {
|
||||
}
|
||||
return strings.TrimRight(base, "/")
|
||||
}
|
||||
|
||||
// serverGatewayBase resolves the in-cluster bot-gateway base (no trailing slash)
|
||||
// the control plane calls server-side for list/stop, from BOT_GATEWAY_URL — the
|
||||
// SAME knob clients/bot's reverse proxy uses — falling back to the in-cluster
|
||||
// service DNS default.
|
||||
func serverGatewayBase() string {
|
||||
base := strings.TrimSpace(os.Getenv(serverGatewayURLEnv))
|
||||
if base == "" {
|
||||
base = defaultServerGatewayURL
|
||||
}
|
||||
return strings.TrimRight(base, "/")
|
||||
}
|
||||
|
||||
func genID(prefix string) (string, error) {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return prefix + "_" + hex.EncodeToString(b[:]), nil
|
||||
}
|
||||
|
||||
+315
-187
@@ -1,94 +1,124 @@
|
||||
package bots
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/runtime"
|
||||
luxlog "github.com/luxfi/log"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// gwServer is a minimal bot-gateway double for the list/stop path. It records
|
||||
// the X-Org-Id it was called with (a wrong tenant here would prove a cross-tenant
|
||||
// leak) and serves configurable list/stop responses so the cloud proxy's
|
||||
// normalization + honest-empty behavior can be asserted hermetically.
|
||||
type gwServer struct {
|
||||
bots []map[string]any // rows GET /v1/bots returns
|
||||
listCode int // status for GET /v1/bots (0 => 200)
|
||||
stopCode int // status for POST /v1/bots/{id}/stop (0 => 200)
|
||||
// The control plane against a fake runtime. The fake is GENUINELY org-scoped (a
|
||||
// run lives under exactly one org key), so a test that reads another tenant's run
|
||||
// has to get past the same boundary the real runtime enforces by tenant path — a
|
||||
// handler that forgot to pass the validated org, or passed a client-supplied one,
|
||||
// fails here.
|
||||
|
||||
mu sync.Mutex
|
||||
listOrg string // X-Org-Id seen on the last list
|
||||
stopOrg string // X-Org-Id seen on the last stop
|
||||
stopID string // runId parsed from the last stop path
|
||||
type runKey struct{ org, id string }
|
||||
|
||||
type listCall struct{ org string }
|
||||
|
||||
type fakeRuntime struct {
|
||||
mu sync.Mutex
|
||||
rows map[runKey]Run
|
||||
lists []listCall
|
||||
stops []runKey
|
||||
// Injected outcomes for the honest-failure paths.
|
||||
listErr, stopErr error
|
||||
}
|
||||
|
||||
func (g *gwServer) start(t *testing.T) string {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v1/bots", func(w http.ResponseWriter, r *http.Request) {
|
||||
g.mu.Lock()
|
||||
g.listOrg = r.Header.Get("X-Org-Id")
|
||||
g.mu.Unlock()
|
||||
if g.listCode != 0 {
|
||||
w.WriteHeader(g.listCode)
|
||||
return
|
||||
func newFake() *fakeRuntime { return &fakeRuntime{rows: map[runKey]Run{}} }
|
||||
|
||||
func (f *fakeRuntime) seed(org string, r Run) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.rows[runKey{org, r.ID}] = r
|
||||
}
|
||||
|
||||
func (f *fakeRuntime) List(_ context.Context, org string) ([]Run, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.lists = append(f.lists, listCall{org})
|
||||
if f.listErr != nil {
|
||||
return nil, f.listErr
|
||||
}
|
||||
var out []Run
|
||||
for k, r := range f.rows {
|
||||
if k.org == org {
|
||||
out = append(out, r)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"bots": g.bots})
|
||||
})
|
||||
mux.HandleFunc("/v1/bots/", func(w http.ResponseWriter, r *http.Request) {
|
||||
// path is /v1/bots/{id}/stop
|
||||
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/v1/bots/"), "/stop")
|
||||
g.mu.Lock()
|
||||
g.stopOrg, g.stopID = r.Header.Get("X-Org-Id"), id
|
||||
g.mu.Unlock()
|
||||
if g.stopCode != 0 {
|
||||
w.WriteHeader(g.stopCode)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv.URL
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (g *gwServer) seenListOrg() string {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.listOrg
|
||||
func (f *fakeRuntime) Stop(_ context.Context, org, runID string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.stops = append(f.stops, runKey{org, runID})
|
||||
if f.stopErr != nil {
|
||||
return f.stopErr
|
||||
}
|
||||
k := runKey{org, runID}
|
||||
if _, ok := f.rows[k]; !ok {
|
||||
// The real runtime resolves under tenants/{org}/ and ANSWERS absent.
|
||||
return runtime.ErrNotFound
|
||||
}
|
||||
delete(f.rows, k)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *gwServer) seenStop() (string, string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.stopOrg, g.stopID
|
||||
func (f *fakeRuntime) stopCalls() []runKey {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]runKey(nil), f.stops...)
|
||||
}
|
||||
|
||||
// mountGW mounts the bots surface with the server-side bot-gateway pinned to
|
||||
// serverURL (empty => the in-cluster default, i.e. unreachable in tests). It
|
||||
// reuses mount(t, "") so the browser-facing gateway base stays the deterministic
|
||||
// https://bot.example.test that sessionUrl assertions depend on.
|
||||
func mountGW(t *testing.T, serverURL string) *zip.App {
|
||||
func (f *fakeRuntime) listCalls() []listCall {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]listCall(nil), f.lists...)
|
||||
}
|
||||
|
||||
func (f *fakeRuntime) has(org, id string) bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
_, ok := f.rows[runKey{org, id}]
|
||||
return ok
|
||||
}
|
||||
|
||||
// mountWith builds the surface over an injected runtime, exactly as Mount does over
|
||||
// the real one — routes() is the shared registration path, so what a test drives is
|
||||
// the code that ships.
|
||||
func mountWith(t *testing.T, rt Runtime) *zip.App {
|
||||
t.Helper()
|
||||
t.Setenv(serverGatewayURLEnv, serverURL)
|
||||
return mount(t, "")
|
||||
t.Setenv(gatewayURLEnv, "https://bot.example.test")
|
||||
s := &cloud.Service[state]{
|
||||
Base: cloud.NewBase(cloud.Deps{Logger: luxlog.New("test")}, "bots"),
|
||||
State: state{gateway: gatewayBase(), runtime: rt},
|
||||
}
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test"), DisableStartupMessage: true})
|
||||
routes(app, s)
|
||||
return app
|
||||
}
|
||||
|
||||
// getBots issues GET /v1/bots with the gateway-minted identity headers (org +
|
||||
// its validated X-User-Id). An empty org sends neither (the anonymous path).
|
||||
func getBots(t *testing.T, app *zip.App, org string) (int, []byte) {
|
||||
// call sends a request with the gateway-minted identity headers. A non-empty org
|
||||
// also sets X-User-Id (the validated principal); an empty org sends neither.
|
||||
func call(t *testing.T, app *zip.App, method, path, org string) (int, []byte) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/bots", nil)
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
if org != "" {
|
||||
req.Header.Set("X-Org-Id", org)
|
||||
req.Header.Set("X-User-Id", "u-"+org)
|
||||
}
|
||||
resp, err := app.Fiber().Test(req)
|
||||
resp, err := app.Fiber().Test(req, testCfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Test: %v", err)
|
||||
}
|
||||
@@ -97,176 +127,274 @@ func getBots(t *testing.T, app *zip.App, org string) (int, []byte) {
|
||||
return resp.StatusCode, b
|
||||
}
|
||||
|
||||
// stopBotReq issues POST /v1/bots/{runID}/stop with identity headers.
|
||||
func stopBotReq(t *testing.T, app *zip.App, org, runID string) (int, []byte) {
|
||||
func listRunIDs(t *testing.T, body []byte) []string {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/bots/"+runID+"/stop", nil)
|
||||
if org != "" {
|
||||
req.Header.Set("X-Org-Id", org)
|
||||
req.Header.Set("X-User-Id", "u-"+org)
|
||||
var v botsView
|
||||
if err := json.Unmarshal(body, &v); err != nil {
|
||||
t.Fatalf("decode list: %v (%s)", err, body)
|
||||
}
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Test: %v", err)
|
||||
ids := make([]string, 0, len(v.Bots))
|
||||
for _, b := range v.Bots {
|
||||
ids = append(ids, b.RunID)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, b
|
||||
return ids
|
||||
}
|
||||
|
||||
// TestListRequiresOrg: no X-Org-Id is 403 — no tenant, no list.
|
||||
func TestListRequiresOrg(t *testing.T) {
|
||||
app := mountGW(t, "http://127.0.0.1:1")
|
||||
if code, _ := getBots(t, app, ""); code != http.StatusForbidden {
|
||||
t.Fatalf("no-org list want 403, got %d", code)
|
||||
// ---- run ----
|
||||
|
||||
// Launching is not implemented and must say so rather than charge for a bot that
|
||||
// never boots. The runtime has no launch operation, so a 200 here would be a lie
|
||||
// with a price on it.
|
||||
func TestRunIsNotImplementedAndStartsNothing(t *testing.T) {
|
||||
rt := newFake()
|
||||
app := mountWith(t, rt)
|
||||
|
||||
if code, _ := call(t, app, http.MethodPost, "/v1/bots/run", "acme"); code != http.StatusNotImplemented {
|
||||
t.Fatalf("launch want 501, got %d", code)
|
||||
}
|
||||
// Nothing was started, so nothing may be listed as started.
|
||||
_, body := call(t, app, http.MethodGet, "/v1/bots", "acme")
|
||||
if ids := listRunIDs(t, body); len(ids) != 0 {
|
||||
t.Fatalf("a refused launch must not produce a run, got %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListRequiresValidatedPrincipal: a bare, forgeable X-Org-Id (no validated
|
||||
// X-User-Id) is 403 — an unvalidated caller cannot enumerate a victim tenant.
|
||||
func TestListRequiresValidatedPrincipal(t *testing.T) {
|
||||
app := mountGW(t, "http://127.0.0.1:1")
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/bots", nil)
|
||||
req.Header.Set("X-Org-Id", "acme") // forged; no X-User-Id => no validated principal
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Test: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("no-principal list want 403, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
// ---- list ----
|
||||
|
||||
// TestListNormalizesAndScopesToCaller: the gateway's session rows are normalized
|
||||
// into the contract (sessionUrl derived control-plane side from runId), and the
|
||||
// gateway is called scoped to the CALLER org (acme), never the client default.
|
||||
func TestListNormalizesAndScopesToCaller(t *testing.T) {
|
||||
gw := &gwServer{bots: []map[string]any{
|
||||
{"runId": "bot_abc", "task": "summarize inbox", "surface": "desktop", "status": "running", "startedAt": "2026-07-11T00:00:00Z"},
|
||||
{"runId": "bot_def", "task": "book flight", "surface": "terminal", "status": "running", "startedAt": "2026-07-11T01:00:00Z"},
|
||||
}}
|
||||
app := mountGW(t, gw.start(t))
|
||||
// A list returns the CALLER's runs and only those, scoped by the validated org the
|
||||
// runtime is asked with — never a client-supplied one.
|
||||
func TestListIsScopedToTheCallerOrg(t *testing.T) {
|
||||
rt := newFake()
|
||||
rt.seed("acme", Run{ID: "run_acme", Task: "acme work", Surface: "desktop", Status: "running", StartedAt: "2023-11-14T22:13:20Z"})
|
||||
rt.seed("globex", Run{ID: "run_globex", Task: "globex secret", Surface: "terminal", Status: "running", StartedAt: "2023-11-14T22:13:20Z"})
|
||||
app := mountWith(t, rt)
|
||||
|
||||
code, body := getBots(t, app, "acme")
|
||||
code, body := call(t, app, http.MethodGet, "/v1/bots", "acme")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("list want 200, got %d (%s)", code, body)
|
||||
}
|
||||
var got botsView
|
||||
if err := json.Unmarshal(body, &got); err != nil {
|
||||
t.Fatalf("shape: %v (%s)", err, body)
|
||||
if ids := listRunIDs(t, body); len(ids) != 1 || ids[0] != "run_acme" {
|
||||
t.Fatalf("acme must see exactly its own run, got %v (%s)", ids, body)
|
||||
}
|
||||
if len(got.Bots) != 2 {
|
||||
t.Fatalf("want 2 bots, got %d (%s)", len(got.Bots), body)
|
||||
}
|
||||
first := got.Bots[0]
|
||||
if first.RunID != "bot_abc" || first.Task != "summarize inbox" || first.Surface != "desktop" {
|
||||
t.Fatalf("row not normalized: %+v", first)
|
||||
}
|
||||
if first.Status != statusRunning {
|
||||
t.Fatalf("status want %q, got %q", statusRunning, first.Status)
|
||||
}
|
||||
if first.StartedAt != "2026-07-11T00:00:00Z" {
|
||||
t.Fatalf("startedAt passthrough want set, got %q", first.StartedAt)
|
||||
}
|
||||
// sessionUrl is derived here from the browser-facing base + nodeId=runId, NOT
|
||||
// taken from the gateway — the ONE place a session URL is built.
|
||||
want := "https://bot.example.test/vnc?nodeId=bot_abc"
|
||||
if first.SessionURL != want {
|
||||
t.Fatalf("sessionUrl want %q, got %q", want, first.SessionURL)
|
||||
}
|
||||
if org := gw.seenListOrg(); org != "acme" {
|
||||
t.Fatalf("gateway saw X-Org-Id=%q, want caller %q (never default 'hanzo')", org, "acme")
|
||||
// The runtime was asked with the caller's validated org, nothing else.
|
||||
if got := rt.listCalls(); len(got) != 1 || got[0].org != "acme" {
|
||||
t.Fatalf("runtime must be asked with the validated org only, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListHonestEmptyWhenGatewayUnreachable: an unreachable gateway yields a 200
|
||||
// {"bots":[]}, never a 5xx — the console renders "no bots", not an error.
|
||||
func TestListHonestEmptyWhenGatewayUnreachable(t *testing.T) {
|
||||
app := mountGW(t, "http://127.0.0.1:1") // connection refused
|
||||
code, body := getBots(t, app, "acme")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("unreachable list want 200, got %d (%s)", code, body)
|
||||
// The row carries the run's real attributes as the runtime reported them, plus a
|
||||
// sessionUrl derived here from the runtime's own id.
|
||||
func TestListRowShape(t *testing.T) {
|
||||
rt := newFake()
|
||||
rt.seed("acme", Run{ID: "run_1", Task: "ship it", Surface: "terminal", Status: "running", StartedAt: "2023-11-14T22:13:20Z"})
|
||||
app := mountWith(t, rt)
|
||||
|
||||
_, body := call(t, app, http.MethodGet, "/v1/bots", "acme")
|
||||
var v botsView
|
||||
if err := json.Unmarshal(body, &v); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(v.Bots) != 1 {
|
||||
t.Fatalf("want 1 row, got %d", len(v.Bots))
|
||||
}
|
||||
want := botView{
|
||||
RunID: "run_1", Task: "ship it", Surface: "terminal", Status: "running",
|
||||
SessionURL: "https://bot.example.test/vnc?nodeId=run_1",
|
||||
StartedAt: "2023-11-14T22:13:20Z",
|
||||
}
|
||||
if v.Bots[0] != want {
|
||||
t.Fatalf("row\n got %+v\nwant %+v", v.Bots[0], want)
|
||||
}
|
||||
assertEmptyBots(t, body)
|
||||
}
|
||||
|
||||
// TestListHonestEmptyOnGatewayError: a non-2xx from the gateway is also honest-
|
||||
// empty, not a propagated 5xx.
|
||||
func TestListHonestEmptyOnGatewayError(t *testing.T) {
|
||||
gw := &gwServer{listCode: http.StatusInternalServerError}
|
||||
app := mountGW(t, gw.start(t))
|
||||
code, body := getBots(t, app, "acme")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("gateway-500 list want 200, got %d (%s)", code, body)
|
||||
// No tenant, no read — and the runtime is never even asked.
|
||||
func TestListFailsClosedWithoutTenant(t *testing.T) {
|
||||
rt := newFake()
|
||||
rt.seed("acme", Run{ID: "run_acme", Status: "running"})
|
||||
app := mountWith(t, rt)
|
||||
|
||||
if code, _ := call(t, app, http.MethodGet, "/v1/bots", ""); code != http.StatusForbidden {
|
||||
t.Fatalf("no-tenant list want 403, got %d", code)
|
||||
}
|
||||
if got := rt.listCalls(); len(got) != 0 {
|
||||
t.Fatalf("runtime asked without a tenant: %+v", got)
|
||||
}
|
||||
assertEmptyBots(t, body)
|
||||
}
|
||||
|
||||
// TestStopStopsCallerRun: a stop the gateway accepts returns {runId,"stopped"}
|
||||
// and forwards the CALLER org + the exact runId to the gateway.
|
||||
func TestStopStopsCallerRun(t *testing.T) {
|
||||
gw := &gwServer{}
|
||||
app := mountGW(t, gw.start(t))
|
||||
// A forged X-Org-Id with no validated principal (the direct-to-pod path) must not
|
||||
// enumerate the victim tenant: principal.Org requires a validated X-User-Id.
|
||||
func TestListRefusesForgedOrgWithoutValidatedPrincipal(t *testing.T) {
|
||||
rt := newFake()
|
||||
rt.seed("acme", Run{ID: "run_acme", Task: "secret", Status: "running"})
|
||||
app := mountWith(t, rt)
|
||||
|
||||
code, body := stopBotReq(t, app, "acme", "bot_abc")
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/bots", nil)
|
||||
req.Header.Set("X-Org-Id", "acme") // forged: no X-User-Id
|
||||
resp, err := app.Fiber().Test(req, testCfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Test: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("forged-org list want 403, got %d", resp.StatusCode)
|
||||
}
|
||||
if got := rt.listCalls(); len(got) != 0 {
|
||||
t.Fatalf("runtime asked on a forged org: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A runtime that cannot answer is an error, never an empty list: [] would claim
|
||||
// "your org has no runs", which is not what we learned.
|
||||
func TestListReportsRuntimeFailureInsteadOfEmpty(t *testing.T) {
|
||||
rt := newFake()
|
||||
rt.listErr = fmt.Errorf("runtime is down")
|
||||
app := mountWith(t, rt)
|
||||
|
||||
code, body := call(t, app, http.MethodGet, "/v1/bots", "acme")
|
||||
if code != http.StatusBadGateway {
|
||||
t.Fatalf("runtime failure want 502, got %d (%s)", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- stop ----
|
||||
|
||||
// One tenant may not stop another's run. The id is real and the caller is
|
||||
// validated — and it is still a 404, because the runtime is asked under the
|
||||
// CALLER's org, where that run does not exist.
|
||||
func TestStopCannotReachAnotherOrgsRun(t *testing.T) {
|
||||
rt := newFake()
|
||||
rt.seed("acme", Run{ID: "run_victim", Task: "acme work", Status: "running"})
|
||||
app := mountWith(t, rt)
|
||||
|
||||
code, body := call(t, app, http.MethodPost, "/v1/bots/run_victim/stop", "globex")
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("cross-org stop want 404, got %d (%s)", code, body)
|
||||
}
|
||||
// The runtime was asked under globex — never under the victim's org.
|
||||
for _, s := range rt.stopCalls() {
|
||||
if s.org != "globex" {
|
||||
t.Fatalf("stop escaped the caller's org: %+v", s)
|
||||
}
|
||||
}
|
||||
if !rt.has("acme", "run_victim") {
|
||||
t.Fatal("victim run must survive a foreign stop")
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown id and another tenant's id are indistinguishable: both 404, so the
|
||||
// endpoint is not an oracle for which run ids exist.
|
||||
func TestStopUnknownRunIsNotFound(t *testing.T) {
|
||||
app := mountWith(t, newFake())
|
||||
if code, _ := call(t, app, http.MethodPost, "/v1/bots/run_nope/stop", "acme"); code != http.StatusNotFound {
|
||||
t.Fatalf("unknown stop want 404, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// The happy path: the runtime is driven with the caller's own org and run id, and
|
||||
// the run is gone from its list.
|
||||
func TestStopHaltsTheRun(t *testing.T) {
|
||||
rt := newFake()
|
||||
rt.seed("acme", Run{ID: "run_1", Task: "work", Status: "running"})
|
||||
app := mountWith(t, rt)
|
||||
|
||||
code, body := call(t, app, http.MethodPost, "/v1/bots/run_1/stop", "acme")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("stop want 200, got %d (%s)", code, body)
|
||||
}
|
||||
var got stopView
|
||||
if err := json.Unmarshal(body, &got); err != nil {
|
||||
t.Fatalf("shape: %v (%s)", err, body)
|
||||
var v stopView
|
||||
if err := json.Unmarshal(body, &v); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got.RunID != "bot_abc" || got.Status != statusStopped {
|
||||
t.Fatalf("stop response want {bot_abc,stopped}, got %+v", got)
|
||||
if v.RunID != "run_1" || v.Status != statusStopped {
|
||||
t.Fatalf("stop view %+v", v)
|
||||
}
|
||||
org, id := gw.seenStop()
|
||||
if org != "acme" {
|
||||
t.Fatalf("gateway saw stop X-Org-Id=%q, want caller %q", org, "acme")
|
||||
if got := rt.stopCalls(); len(got) != 1 || got[0] != (runKey{"acme", "run_1"}) {
|
||||
t.Fatalf("runtime must be driven once with the caller's org+run, got %v", got)
|
||||
}
|
||||
if id != "bot_abc" {
|
||||
t.Fatalf("gateway saw stop runId=%q, want %q", id, "bot_abc")
|
||||
if _, body := call(t, app, http.MethodGet, "/v1/bots", "acme"); len(listRunIDs(t, body)) != 0 {
|
||||
t.Fatal("a stopped run must leave the list")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStopNotFound: a run the caller's org does not own (gateway 404) is a 404,
|
||||
// never a 200 — stop cannot claim a teardown that did not happen.
|
||||
func TestStopNotFound(t *testing.T) {
|
||||
gw := &gwServer{stopCode: http.StatusNotFound}
|
||||
app := mountGW(t, gw.start(t))
|
||||
if code, body := stopBotReq(t, app, "acme", "bot_nope"); code != http.StatusNotFound {
|
||||
t.Fatalf("stop of unowned run want 404, got %d (%s)", code, body)
|
||||
// THE correctness lie this endpoint must never tell: a runtime that does not serve
|
||||
// stop reports nothing about the run, so claiming "stopped" would make a stop that
|
||||
// cannot fail. It is a 502, and it says the run was NOT stopped.
|
||||
func TestStopFailsClosedWhenTheRuntimeDoesNotServeStop(t *testing.T) {
|
||||
rt := newFake()
|
||||
rt.seed("acme", Run{ID: "run_1", Status: "running"})
|
||||
rt.stopErr = runtime.ErrNotServed
|
||||
app := mountWith(t, rt)
|
||||
|
||||
code, body := call(t, app, http.MethodPost, "/v1/bots/run_1/stop", "acme")
|
||||
if code != http.StatusBadGateway {
|
||||
t.Fatalf("unserved stop want 502, got %d (%s)", code, body)
|
||||
}
|
||||
if !rt.has("acme", "run_1") {
|
||||
t.Fatal("the run must not be treated as gone when the runtime never answered")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStopUnreachableIs502: a stop that cannot reach the gateway is a clean 502,
|
||||
// not a false "stopped".
|
||||
func TestStopUnreachableIs502(t *testing.T) {
|
||||
app := mountGW(t, "http://127.0.0.1:1")
|
||||
if code, _ := stopBotReq(t, app, "acme", "bot_abc"); code != http.StatusBadGateway {
|
||||
t.Fatalf("stop with unreachable gateway want 502, got %d", code)
|
||||
// A stop that could not reach the executor must not claim the run was stopped.
|
||||
func TestStopWithUnreachableRuntimeIs502(t *testing.T) {
|
||||
rt := newFake()
|
||||
rt.seed("acme", Run{ID: "run_1", Status: "running"})
|
||||
rt.stopErr = fmt.Errorf("connection refused")
|
||||
app := mountWith(t, rt)
|
||||
|
||||
if code, _ := call(t, app, http.MethodPost, "/v1/bots/run_1/stop", "acme"); code != http.StatusBadGateway {
|
||||
t.Fatalf("unreachable runtime want 502, got %d", code)
|
||||
}
|
||||
if !rt.has("acme", "run_1") {
|
||||
t.Fatal("the run must stay live when the halt failed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStopRequiresOrg: no X-Org-Id is 403.
|
||||
func TestStopRequiresOrg(t *testing.T) {
|
||||
app := mountGW(t, "http://127.0.0.1:1")
|
||||
if code, _ := stopBotReq(t, app, "", "bot_abc"); code != http.StatusForbidden {
|
||||
t.Fatalf("no-org stop want 403, got %d", code)
|
||||
// No tenant, no stop — and the runtime is never driven.
|
||||
func TestStopFailsClosedWithoutTenant(t *testing.T) {
|
||||
rt := newFake()
|
||||
rt.seed("acme", Run{ID: "run_1", Status: "running"})
|
||||
app := mountWith(t, rt)
|
||||
|
||||
if code, _ := call(t, app, http.MethodPost, "/v1/bots/run_1/stop", ""); code != http.StatusForbidden {
|
||||
t.Fatalf("no-tenant stop want 403, got %d", code)
|
||||
}
|
||||
if len(rt.stopCalls()) != 0 {
|
||||
t.Fatalf("runtime driven without a tenant: %v", rt.stopCalls())
|
||||
}
|
||||
if !rt.has("acme", "run_1") {
|
||||
t.Fatal("run stopped without a tenant")
|
||||
}
|
||||
}
|
||||
|
||||
func assertEmptyBots(t *testing.T, body []byte) {
|
||||
t.Helper()
|
||||
// Must be an explicit empty array, not null — {"bots":[]}.
|
||||
if !strings.Contains(string(body), `"bots":[]`) {
|
||||
t.Fatalf("want honest-empty {\"bots\":[]}, got %s", body)
|
||||
// An oversize id is a miss like any other — it never reaches the runtime.
|
||||
func TestStopOversizeRunIDIsNotFound(t *testing.T) {
|
||||
rt := newFake()
|
||||
app := mountWith(t, rt)
|
||||
|
||||
long := make([]byte, maxRunID+1)
|
||||
for i := range long {
|
||||
long[i] = 'a'
|
||||
}
|
||||
var got botsView
|
||||
if err := json.Unmarshal(body, &got); err != nil {
|
||||
t.Fatalf("shape: %v (%s)", err, body)
|
||||
if code, _ := call(t, app, http.MethodPost, "/v1/bots/"+string(long)+"/stop", "acme"); code != http.StatusNotFound {
|
||||
t.Fatalf("oversize runId want 404, got %d", code)
|
||||
}
|
||||
if len(got.Bots) != 0 {
|
||||
t.Fatalf("want 0 bots, got %d", len(got.Bots))
|
||||
if len(rt.stopCalls()) != 0 {
|
||||
t.Fatalf("runtime driven for an oversize id: %v", rt.stopCalls())
|
||||
}
|
||||
}
|
||||
|
||||
// /v1/bots/run is the launch literal, never a run id: the router resolves the
|
||||
// static segment over the :runId param regardless of registration order.
|
||||
func TestRunLiteralDoesNotBindAsARunID(t *testing.T) {
|
||||
rt := newFake()
|
||||
app := mountWith(t, rt)
|
||||
|
||||
// POST /v1/bots/run reaches the launch handler (501), NOT the stop handler
|
||||
// (which would 404 a run named "run" and would have driven the runtime).
|
||||
if code, _ := call(t, app, http.MethodPost, "/v1/bots/run", "acme"); code != http.StatusNotImplemented {
|
||||
t.Fatalf("POST /v1/bots/run must hit launch (501), got %d", code)
|
||||
}
|
||||
if len(rt.stopCalls()) != 0 {
|
||||
t.Fatalf("/v1/bots/run bound as a run id and drove a stop: %v", rt.stopCalls())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
package bots
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/metering"
|
||||
luxlog "github.com/luxfi/log"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// billServer is a minimal commerce double: it returns a fixed balance and
|
||||
// records the X-Org-Id + usage body of every debit — the SAME double the agents
|
||||
// billing tests use. A wrong tenant here would prove a cross-tenant leak.
|
||||
type billServer struct {
|
||||
available int64
|
||||
|
||||
mu sync.Mutex
|
||||
usageOrg string
|
||||
usageBody []byte
|
||||
usages int32
|
||||
}
|
||||
|
||||
func (b *billServer) start(t *testing.T) string {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v1/billing/balance", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"available": b.available})
|
||||
})
|
||||
mux.HandleFunc("/v1/billing/usage", func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&b.usages, 1)
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
b.mu.Lock()
|
||||
b.usageOrg, b.usageBody = r.Header.Get("X-Org-Id"), body
|
||||
b.mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.WriteString(w, `{"transactionId":"tx_1","type":"usage"}`)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv.URL
|
||||
}
|
||||
|
||||
func (b *billServer) debits() int32 { return atomic.LoadInt32(&b.usages) }
|
||||
func (b *billServer) lastDebit() (string, []byte) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.usageOrg, b.usageBody
|
||||
}
|
||||
|
||||
// waitForDebit polls briefly — debits land on a detached goroutine.
|
||||
func waitForDebit(cond func() bool) bool {
|
||||
for i := 0; i < 200; i++ {
|
||||
if cond() {
|
||||
return true
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
return cond()
|
||||
}
|
||||
|
||||
// mount builds the bots surface. commerceURL=="" mounts with no billing
|
||||
// (Gate allows, Meter is a no-op — the unconfigured deployment path).
|
||||
func mount(t *testing.T, commerceURL string) *zip.App {
|
||||
t.Helper()
|
||||
// Pin a deterministic gateway base so the session URL is assertable. Set
|
||||
// BEFORE Mount, which snapshots gatewayBase() once.
|
||||
t.Setenv(gatewayURLEnv, "https://bot.example.test")
|
||||
deps := cloud.Deps{Logger: luxlog.New("test")}
|
||||
if commerceURL != "" {
|
||||
// Default org "hanzo" so every "acme is billed" assertion proves the
|
||||
// per-call org override scopes the ledger to the CALLER, not the default.
|
||||
m, err := metering.New(metering.Config{BaseURL: commerceURL, Token: "svc-tok", Org: "hanzo"})
|
||||
if err != nil {
|
||||
t.Fatalf("metering.New: %v", err)
|
||||
}
|
||||
deps.Metering = m
|
||||
}
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
if err := Mount(app, deps); err != nil {
|
||||
t.Fatalf("Mount: %v", err)
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
// do sends a request with the gateway-minted identity headers. A non-empty org
|
||||
// also sets X-User-Id (the validated principal the money path requires); an
|
||||
// empty org sends neither (the anonymous path).
|
||||
func do(t *testing.T, app *zip.App, org string, body any) (int, []byte) {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
r = bytes.NewReader(b)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/bots/run", r)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if org != "" {
|
||||
req.Header.Set("X-Org-Id", org)
|
||||
req.Header.Set("X-User-Id", "u-"+org)
|
||||
}
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Test: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, b
|
||||
}
|
||||
|
||||
// TestRunRequiresOrg: a launch with no X-Org-Id is 403 — no tenant, no bot.
|
||||
func TestRunRequiresOrg(t *testing.T) {
|
||||
app := mount(t, "")
|
||||
if code, _ := do(t, app, "", map[string]any{"task": "do a thing"}); code != http.StatusForbidden {
|
||||
t.Fatalf("no-org launch want 403, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunRequiresValidatedPrincipal: a launch carrying only a forgeable X-Org-Id
|
||||
// (no validated X-User-Id — the direct-to-pod path) is 403. A money-moving
|
||||
// launch can't ride an unauthenticated org header.
|
||||
func TestRunRequiresValidatedPrincipal(t *testing.T) {
|
||||
app := mount(t, "")
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/bots/run",
|
||||
bytes.NewReader([]byte(`{"task":"x"}`)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Org-Id", "acme") // forged; no X-User-Id → no validated principal
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Test: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("no-principal launch want 403, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunValidatesInput: an empty task is 400; an unknown surface is 400.
|
||||
func TestRunValidatesInput(t *testing.T) {
|
||||
app := mount(t, "")
|
||||
if code, _ := do(t, app, "acme", map[string]any{"task": " "}); code != http.StatusBadRequest {
|
||||
t.Fatalf("empty task want 400, got %d", code)
|
||||
}
|
||||
if code, _ := do(t, app, "acme", map[string]any{"task": "x", "surface": "hologram"}); code != http.StatusBadRequest {
|
||||
t.Fatalf("unknown surface want 400, got %d", code)
|
||||
}
|
||||
if code, _ := do(t, app, "acme", map[string]any{"task": "x", "timeout": "banana"}); code != http.StatusBadRequest {
|
||||
t.Fatalf("bad timeout want 400, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunResponseShapeAndSessionURL: a launch (billing unconfigured → un-gated)
|
||||
// returns {runId, status, sessionUrl}, with the session URL derived from the
|
||||
// configured gateway base + nodeId=<runId>.
|
||||
func TestRunResponseShapeAndSessionURL(t *testing.T) {
|
||||
app := mount(t, "")
|
||||
code, body := do(t, app, "acme", map[string]any{"task": "summarize my inbox", "surface": "desktop"})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("launch want 200, got %d (%s)", code, body)
|
||||
}
|
||||
var rv runView
|
||||
if err := json.Unmarshal(body, &rv); err != nil {
|
||||
t.Fatalf("shape: %v (%s)", err, body)
|
||||
}
|
||||
if !strings.HasPrefix(rv.RunID, "bot_") {
|
||||
t.Fatalf("runId want bot_ prefix, got %q", rv.RunID)
|
||||
}
|
||||
if rv.Status != statusRunning {
|
||||
t.Fatalf("status want %q, got %q", statusRunning, rv.Status)
|
||||
}
|
||||
want := "https://bot.example.test/vnc?nodeId=" + rv.RunID
|
||||
if rv.SessionURL != want {
|
||||
t.Fatalf("sessionUrl want %q, got %q", want, rv.SessionURL)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunGatesUnfundedOrg: a launch for an org with a balance below the flat fee
|
||||
// is refused 402 and NEVER debits — the gate is called and fails closed, so an
|
||||
// unfunded tenant gets no bot.
|
||||
func TestRunGatesUnfundedOrg(t *testing.T) {
|
||||
bs := &billServer{available: 0}
|
||||
app := mount(t, bs.start(t))
|
||||
code, body := do(t, app, "acme", map[string]any{"task": "x"})
|
||||
if code != http.StatusPaymentRequired {
|
||||
t.Fatalf("unfunded launch want 402, got %d (%s)", code, body)
|
||||
}
|
||||
if bs.debits() != 0 {
|
||||
t.Fatalf("a gate-refused launch must not debit, got %d", bs.debits())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunDebitsCallerOrg: a funded launch returns the session AND debits the
|
||||
// CALLER org (acme, never the client default 'hanzo') with product=bot, the flat
|
||||
// fee, and the surface as the billed unit.
|
||||
func TestRunDebitsCallerOrg(t *testing.T) {
|
||||
bs := &billServer{available: 100000}
|
||||
app := mount(t, bs.start(t))
|
||||
code, body := do(t, app, "acme", map[string]any{"task": "x", "surface": "terminal"})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("funded launch want 200, got %d (%s)", code, body)
|
||||
}
|
||||
if !waitForDebit(func() bool { return bs.debits() == 1 }) {
|
||||
t.Fatalf("a launch must debit once, got %d", bs.debits())
|
||||
}
|
||||
org, ubody := bs.lastDebit()
|
||||
if org != "acme" {
|
||||
t.Fatalf("debited org %q, want caller %q (never default 'hanzo')", org, "acme")
|
||||
}
|
||||
var u struct {
|
||||
User string `json:"user"`
|
||||
Amount int64 `json:"amount"`
|
||||
Model string `json:"model"`
|
||||
Provider string `json:"provider"`
|
||||
Actor string `json:"actor"`
|
||||
}
|
||||
_ = json.Unmarshal(ubody, &u)
|
||||
if u.User != "acme" {
|
||||
t.Fatalf("debit user = %q, want caller org %q", u.User, "acme")
|
||||
}
|
||||
if u.Amount != cloud.DefaultResourceFeeCents {
|
||||
t.Fatalf("debit amount = %d, want default fee %d", u.Amount, cloud.DefaultResourceFeeCents)
|
||||
}
|
||||
if u.Provider != meterKind {
|
||||
t.Fatalf("debit provider = %q, want %q (product:bot)", u.Provider, meterKind)
|
||||
}
|
||||
if u.Model != surfaceTerminal {
|
||||
t.Fatalf("debit model = %q, want the surface %q", u.Model, surfaceTerminal)
|
||||
}
|
||||
if u.Actor == "" {
|
||||
t.Fatalf("debit must carry an actor for the audit trail")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package bots
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/runtime"
|
||||
"github.com/hanzoai/cloud/clients/visor"
|
||||
luxlog "github.com/luxfi/log"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// Regression guard for the /v1/bots route collision.
|
||||
//
|
||||
// clients/visor once registered GET /v1/bots for its bot MACHINES and this
|
||||
// package registered GET /v1/bots for bot RUNS. The router resolves byte-identical
|
||||
// patterns by first-registration — silently, with no panic — and visor mounts
|
||||
// first (apps.Wire: visor, then runtime, then bots), so visor's machine list answered
|
||||
// the console's run list and this package's handler was unreachable. Two values
|
||||
// sharing one name, one namespace.
|
||||
//
|
||||
// These tests pin the fix from both ends: structurally (no two subsystems may
|
||||
// register the same method+path) and behaviourally (GET /v1/bots serves RUNS),
|
||||
// with the subsystems mounted in the real Wire order that produced the bug.
|
||||
|
||||
// stubRuntime stands in for the bot runtime, serving its documented contract and
|
||||
// recording the paths + org it was asked with. Real Mounts talk to it through the
|
||||
// real transport, so these tests exercise the shipping path end to end.
|
||||
type stubRuntime struct {
|
||||
mu sync.Mutex
|
||||
runs map[string][]map[string]any // org -> rows
|
||||
paths []string
|
||||
orgs []string
|
||||
}
|
||||
|
||||
func (s *stubRuntime) start(t *testing.T) {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v1/bots", func(w http.ResponseWriter, r *http.Request) {
|
||||
org := r.Header.Get("X-Org-Id")
|
||||
s.mu.Lock()
|
||||
s.paths, s.orgs = append(s.paths, r.URL.Path), append(s.orgs, org)
|
||||
rows := s.runs[org]
|
||||
s.mu.Unlock()
|
||||
if rows == nil {
|
||||
rows = []map[string]any{}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"bots": rows})
|
||||
})
|
||||
mux.HandleFunc("/v1/bots/", func(w http.ResponseWriter, r *http.Request) { // {id}/stop
|
||||
s.mu.Lock()
|
||||
s.paths, s.orgs = append(s.paths, r.URL.Path), append(s.orgs, r.Header.Get("X-Org-Id"))
|
||||
s.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"status":"stopped"}`))
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
t.Setenv("BOT_GATEWAY_URL", srv.URL)
|
||||
}
|
||||
|
||||
func (s *stubRuntime) seen() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]string(nil), s.paths...)
|
||||
}
|
||||
|
||||
// mountFleet mounts, in apps.Wire order, the three subsystems that shared the
|
||||
// /v1/bot* namespace, over a stub runtime.
|
||||
func mountFleet(t *testing.T, rt *stubRuntime) *zip.App {
|
||||
t.Helper()
|
||||
t.Setenv(gatewayURLEnv, "https://bot.example.test")
|
||||
rt.start(t)
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test"), DisableStartupMessage: true})
|
||||
deps := cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir()}
|
||||
if err := visor.Mount(app, deps); err != nil { // Wire order: visor first — the shadowing mount
|
||||
t.Fatalf("visor.Mount: %v", err)
|
||||
}
|
||||
if err := runtime.Mount(app, deps); err != nil {
|
||||
t.Fatalf("runtime.Mount: %v", err)
|
||||
}
|
||||
if err := Mount(app, deps); err != nil { // …bots last, as in Wire
|
||||
t.Fatalf("bots.Mount: %v", err)
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
// collisions reports every route claimed by more than one registration.
|
||||
//
|
||||
// Counting GetRoutes() entries is NOT enough and looking only at that is how this
|
||||
// guard was blind at first: the router MERGES byte-identical patterns into ONE
|
||||
// Route carrying both handlers chained, so the second registration leaves the
|
||||
// entry count at one and shows up only as a handler count above one. Every real
|
||||
// route in the fleet registers exactly one handler, so >1 is the collision. The
|
||||
// entry count is kept as well, for any overlap the router does not merge.
|
||||
func collisions(app *zip.App) []string {
|
||||
var out []string
|
||||
seen := map[string]int{}
|
||||
for _, r := range app.Fiber().GetRoutes() {
|
||||
key := r.Method + " " + r.Path
|
||||
if n := len(r.Handlers); n > 1 {
|
||||
out = append(out, fmt.Sprintf("%s — %d handlers chained on one route", key, n))
|
||||
continue
|
||||
}
|
||||
if seen[key]++; seen[key] > 1 {
|
||||
out = append(out, fmt.Sprintf("%s — %d separate registrations", key, seen[key]))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The guard itself must be shown to FIRE on the bug, or it is decoration. This
|
||||
// reproduces the original collision — two subsystems, one pattern — and asserts
|
||||
// collisions() sees it, through the SAME function the real check below uses.
|
||||
func TestDuplicateRouteGuardDetectsACollision(t *testing.T) {
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test"), DisableStartupMessage: true})
|
||||
app.Get("/v1/bots", func(c *zip.Ctx) error { return c.JSON(200, map[string]any{"bots": []string{"machine"}}) })
|
||||
app.Get("/v1/bots", func(c *zip.Ctx) error { return c.JSON(200, map[string]any{"bots": []string{"run"}}) })
|
||||
|
||||
got := collisions(app)
|
||||
if len(got) == 0 {
|
||||
t.Fatal("the guard cannot see the very collision it guards: two GET /v1/bots handlers are registered and collisions() reported none")
|
||||
}
|
||||
if !strings.Contains(got[0], "GET /v1/bots") {
|
||||
t.Fatalf("guard named the wrong route: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// No two subsystems may claim the same method+path. The router does not panic on
|
||||
// a byte-identical duplicate — it silently merges and keeps the first — so
|
||||
// nothing but this catches the class of bug.
|
||||
func TestSubsystemsDoNotRegisterDuplicateRoutes(t *testing.T) {
|
||||
for _, c := range collisions(mountFleet(t, &stubRuntime{})) {
|
||||
t.Errorf("route collision: %s — one handler silently shadows the other", c)
|
||||
}
|
||||
}
|
||||
|
||||
// The behavioural half: GET /v1/bots serves bot RUNS from the runtime. The run the
|
||||
// runtime holds must come back out — which it cannot do if visor's machine list is
|
||||
// answering this route, since a machine list never asks the runtime anything.
|
||||
func TestGetBotsServesRunsNotMachines(t *testing.T) {
|
||||
rt := &stubRuntime{runs: map[string][]map[string]any{
|
||||
"acme": {{"runId": "run_1", "task": "prove the route", "surface": "desktop", "status": "running", "startedAt": "2023-11-14T22:13:20Z"}},
|
||||
}}
|
||||
app := mountFleet(t, rt)
|
||||
|
||||
code, body := call(t, app, http.MethodGet, "/v1/bots", "acme")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("GET /v1/bots want 200, got %d (%s)", code, body)
|
||||
}
|
||||
var v botsView
|
||||
if err := json.Unmarshal(body, &v); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, body)
|
||||
}
|
||||
if len(v.Bots) != 1 || v.Bots[0].RunID != "run_1" {
|
||||
t.Fatalf("GET /v1/bots must serve the org's runs, got %s", body)
|
||||
}
|
||||
// A run row, not a machine row: the fields the console reads are populated.
|
||||
if v.Bots[0].Task != "prove the route" || v.Bots[0].SessionURL != "https://bot.example.test/vnc?nodeId=run_1" {
|
||||
t.Fatalf("row is not a run: %+v", v.Bots[0])
|
||||
}
|
||||
// It reached the RUNTIME — the only place a run has ever existed.
|
||||
if got := rt.seen(); len(got) != 1 || got[0] != "/v1/bots" {
|
||||
t.Fatalf("bots must read the runtime's run list, saw %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The bot MACHINE surface still exists — under visor's own namespace, where a
|
||||
// machine belongs. It is reachable and it is NOT /v1/bots.
|
||||
func TestBotMachineSurfaceMovedToCompute(t *testing.T) {
|
||||
app := mountFleet(t, &stubRuntime{})
|
||||
|
||||
paths := map[string]bool{}
|
||||
for _, r := range app.Fiber().GetRoutes() {
|
||||
paths[r.Method+" "+r.Path] = true
|
||||
}
|
||||
for _, want := range []string{
|
||||
"GET /v1/compute/bots",
|
||||
"POST /v1/compute/bots/launch",
|
||||
"GET /v1/compute/bots/:id",
|
||||
"DELETE /v1/compute/bots/:id",
|
||||
"POST /v1/compute/bots/:id/:action",
|
||||
} {
|
||||
if !paths[want] {
|
||||
t.Errorf("bot machine route %q is missing", want)
|
||||
}
|
||||
}
|
||||
// …and nothing but the run control plane claims /v1/bots.
|
||||
for _, gone := range []string{"GET /v1/bots/:id", "DELETE /v1/bots/:id", "POST /v1/bots/launch", "POST /v1/bots/:id/:action"} {
|
||||
if paths[gone] {
|
||||
t.Errorf("machine route %q still squats on the run namespace", gone)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The three values keep three namespaces: runs at /v1/bots, machines under
|
||||
// /v1/compute/bots, and the runtime passthrough at /v1/bot/*. Nothing in the run
|
||||
// namespace may be a wildcard, which would swallow every run id.
|
||||
func TestRunNamespaceHasNoWildcard(t *testing.T) {
|
||||
app := mountFleet(t, &stubRuntime{})
|
||||
for _, r := range app.Fiber().GetRoutes() {
|
||||
if r.Path == "/v1/bots/*" {
|
||||
t.Fatalf("%s /v1/bots/* would swallow every run id", r.Method)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The runtime ops face is a SIBLING namespace, not a parent: /v1/bot/* must never
|
||||
// match a /v1/bots path, or the control plane would be relayed away.
|
||||
//
|
||||
// The discriminator is the op the runtime is asked for. bots' stub addresses
|
||||
// /v1/bots/{id}/stop; the ops face strips its own /v1/bot prefix and would ask for
|
||||
// something else entirely. So the path the runtime SAW names which handler ran.
|
||||
func TestRuntimeOpsFaceDoesNotSwallowTheRunNamespace(t *testing.T) {
|
||||
rt := &stubRuntime{}
|
||||
app := mountFleet(t, rt)
|
||||
|
||||
if code, body := call(t, app, http.MethodPost, "/v1/bots/run_1/stop", "acme"); code != http.StatusOK {
|
||||
t.Fatalf("stop want 200, got %d (%s)", code, body)
|
||||
}
|
||||
if got := rt.seen(); len(got) != 1 || got[0] != "/v1/bots/run_1/stop" {
|
||||
t.Fatalf("the run namespace must be served by bots' own stub, runtime saw %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package bots
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/zap-proto/fiber/v3"
|
||||
)
|
||||
|
||||
// testCfg replaces fiber's Test() default of Timeout: 1s (fiber/v3@v3.2.1
|
||||
// app.go:1202, FailOnTimeout: true). That default is a WALL-CLOCK deadline on an
|
||||
// in-process request: under load — a full suite, encrypted SQLite, a busy box — a
|
||||
// correct handler blows it and the test reports "i/o timeout". A guard that fails
|
||||
// for reasons unrelated to what it guards teaches nothing, and a tenant-isolation
|
||||
// guard that is a coin flip is worse than none. The generous bound still fails a
|
||||
// genuine hang.
|
||||
var testCfg = fiber.TestConfig{Timeout: 30 * time.Second, FailOnTimeout: true}
|
||||
@@ -0,0 +1,68 @@
|
||||
package bots
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/runtime"
|
||||
)
|
||||
|
||||
// wire.go is bots' WIRE CONTRACT with the bot runtime — the stub behind the
|
||||
// Runtime seam. It says WHAT bots asks the runtime (list this org's runs, halt one
|
||||
// of them); how the bytes get there is runtime's problem.
|
||||
//
|
||||
// This file is the ONE place in clients/bots that knows the runtime exists. The
|
||||
// handlers (bots.go) never see it: they hold the Runtime seam, which a test fills
|
||||
// with a fake.
|
||||
//
|
||||
// The id space is the RUNTIME'S. A run id here is whatever the runtime named its
|
||||
// session — cloud does not mint it, does not store it, and could not resolve it if
|
||||
// it tried. That is exactly why list and stop agree: both speak the only id space
|
||||
// that has ever held a real run.
|
||||
|
||||
const listOp = "/v1/bots"
|
||||
|
||||
func stopOp(runID string) string { return "/v1/bots/" + url.PathEscape(runID) + "/stop" }
|
||||
|
||||
// wire is the Runtime seam over the real runtime.
|
||||
type wire struct{}
|
||||
|
||||
// runRow is the runtime's row shape (its GET /v1/bots emits exactly these). It
|
||||
// carries no sessionUrl: the runtime does not know its own public origin, so the
|
||||
// control plane derives that from runId.
|
||||
type runRow struct {
|
||||
RunID string `json:"runId"`
|
||||
Task string `json:"task"`
|
||||
Surface string `json:"surface"`
|
||||
Status string `json:"status"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
}
|
||||
|
||||
// List reads org's runs. The runtime scopes them to the tenant cloud names, so the
|
||||
// answer is already this org's and only this org's.
|
||||
func (wire) List(ctx context.Context, org string) ([]Run, error) {
|
||||
var answer struct {
|
||||
Bots []runRow `json:"bots"`
|
||||
}
|
||||
if err := runtime.Read(ctx, runtime.Call{Op: listOp, Org: org}, &answer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Run, 0, len(answer.Bots))
|
||||
for _, b := range answer.Bots {
|
||||
if b.RunID == "" {
|
||||
continue // a row cloud cannot address is not a run it can offer
|
||||
}
|
||||
out = append(out, Run{
|
||||
ID: b.RunID, Task: b.Task, Surface: b.Surface,
|
||||
Status: b.Status, StartedAt: b.StartedAt,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Stop halts org's run. It reports the runtime's own answer verbatim — absent,
|
||||
// unserved, or a failure — so the handler decides what each MEANS rather than this
|
||||
// stub deciding for it.
|
||||
func (wire) Stop(ctx context.Context, org, runID string) error {
|
||||
return runtime.Do(ctx, runtime.Call{Op: stopOp(runID), Org: org})
|
||||
}
|
||||
@@ -198,7 +198,7 @@ func (s *service) handleSearch(c *zip.Ctx) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
eng, err := s.engineFor(org, principal.Payer(c), principal.Project(c))
|
||||
eng, err := s.engineFor(org, principal.HomeOrg(c), principal.Project(c))
|
||||
if err != nil {
|
||||
return zip.ErrInternal("open index")
|
||||
}
|
||||
@@ -238,7 +238,7 @@ func (s *service) handleContext(c *zip.Ctx) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
eng, err := s.engineFor(org, principal.Payer(c), principal.Project(c))
|
||||
eng, err := s.engineFor(org, principal.HomeOrg(c), principal.Project(c))
|
||||
if err != nil {
|
||||
return zip.ErrInternal("open index")
|
||||
}
|
||||
@@ -343,7 +343,7 @@ func (s *service) handleAsk(c *zip.Ctx) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
eng, err := s.engineFor(org, principal.Payer(c), principal.Project(c))
|
||||
eng, err := s.engineFor(org, principal.HomeOrg(c), principal.Project(c))
|
||||
if err != nil {
|
||||
return zip.ErrInternal("open index")
|
||||
}
|
||||
@@ -393,7 +393,7 @@ func (s *service) handleIndex(c *zip.Ctx) error {
|
||||
if err != nil {
|
||||
return zip.ErrInternal("open index")
|
||||
}
|
||||
res, err := s.indexRepo(c.Context(), org, principal.Payer(c), principal.Project(c), store, repo, body.Files, body.Prune)
|
||||
res, err := s.indexRepo(c.Context(), org, principal.HomeOrg(c), principal.Project(c), store, repo, body.Files, body.Prune)
|
||||
if err != nil {
|
||||
s.log.Warn("code index failed", "org", org, "repo", repo, "err", err)
|
||||
return zip.ErrInternal("index failed")
|
||||
|
||||
@@ -4,17 +4,18 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/agents"
|
||||
"github.com/hanzoai/cloud/clients/bot"
|
||||
"github.com/hanzoai/cloud/clients/tracker"
|
||||
)
|
||||
|
||||
// adapters.go binds the coding seams to the real in-process packages. This is the
|
||||
// ONLY file in clients/coding that imports agents/tracker/bot; coding.go stays
|
||||
// pure so the orchestration is unit-tested against fakes. None of agents/tracker/
|
||||
// bot imports clients/git or clients/integrations, so these imports are cycle-free.
|
||||
// adapters.go binds the session + tracker seams to the real in-process packages.
|
||||
// This is the ONLY file in clients/coding that imports agents/tracker; coding.go
|
||||
// stays pure so the orchestration is unit-tested against fakes. Neither agents nor
|
||||
// tracker imports clients/git or clients/integrations, so these imports are
|
||||
// cycle-free. The third seam, Runner, is bound in task.go — coding's own wire
|
||||
// contract with the bot runtime.
|
||||
|
||||
// NewDispatcher assembles the production Dispatcher: sessions on the live agent
|
||||
// registry, PRs on the tracker, the runner on the bot-gateway client, plus the
|
||||
// registry, PRs on the tracker, the runner on coding's own runtime stub, plus the
|
||||
// two git seams (cloneURL, verifyRef) the composition root passes from clients/git
|
||||
// (which coding cannot import directly). log is the structured logger for
|
||||
// best-effort mirror failures.
|
||||
@@ -26,7 +27,7 @@ func NewDispatcher(
|
||||
return Dispatcher{
|
||||
Sessions: sessionAdapter{},
|
||||
Tracker: trackerAdapter{},
|
||||
Runner: botAdapter{},
|
||||
Runner: runner{},
|
||||
CloneURL: cloneURL,
|
||||
VerifyRef: verifyRef,
|
||||
Log: log,
|
||||
@@ -59,26 +60,3 @@ func (trackerAdapter) CreatePR(ctx context.Context, in PRInput) (PRRef, error) {
|
||||
}
|
||||
return PRRef{Identifier: pr.Identifier, ProjectKey: pr.ProjectKey, Number: pr.Number}, nil
|
||||
}
|
||||
|
||||
// botAdapter forwards to the bot-gateway coding-task client (bot/coding.go),
|
||||
// bridging the coding Step/RunResult shapes to the bot ones.
|
||||
type botAdapter struct{}
|
||||
|
||||
func (botAdapter) Run(ctx context.Context, org, userID string, req RunRequest, onStep func(Step)) (RunResult, error) {
|
||||
res, err := bot.RunCodingTask(ctx, org, userID, bot.CodingTaskRequest{
|
||||
CloneURL: req.CloneURL, BaseBranch: req.BaseBranch, Branch: req.Branch,
|
||||
Prompt: req.Prompt, SessionID: req.SessionID, RunTimeoutSeconds: req.RunTimeoutSeconds,
|
||||
Credential: bot.Credential{Username: req.CredUser, Token: req.CredToken},
|
||||
}, func(s bot.CodingStep) {
|
||||
if onStep != nil {
|
||||
onStep(Step{Type: s.Type, Step: s.Step, Message: s.Message, Status: s.Status})
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return RunResult{}, err
|
||||
}
|
||||
return RunResult{
|
||||
Branch: res.Branch, CommitSha: res.CommitSha, Diffstat: res.Diffstat,
|
||||
Changed: res.Changed, OK: res.OK, LogTail: res.LogTail, Error: res.Error,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package coding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/runtime"
|
||||
)
|
||||
|
||||
// task.go is coding's WIRE CONTRACT with the bot runtime — the stub behind the
|
||||
// Runner seam. It says WHAT coding asks the runtime to do (run one coding job)
|
||||
// and how a progress line is shaped; how the bytes get there is runtime's problem.
|
||||
//
|
||||
// This file is the ONE place in clients/coding that knows the runtime exists.
|
||||
// coding.go (the orchestrator) is pure and never sees it.
|
||||
//
|
||||
// The runtime answers a stream: one message per step/log as the job progresses
|
||||
// (clone → dev exec → commit → push), then a terminal result (or error). Coding
|
||||
// mirrors every step into the agent session live, so the run is watchable at
|
||||
// GET /v1/agents/sessions/:id/stream, and returns the terminal outcome.
|
||||
//
|
||||
// CREDENTIAL CUSTODY: the per-org agent git credential travels in the request
|
||||
// BODY (never a URL, never argv, never a log). That is why the call declares
|
||||
// Secret — the transport then refuses to carry it over a cleartext hop.
|
||||
|
||||
// taskOp addresses the runtime's coding-task operation.
|
||||
const taskOp = "/v1/coding-tasks"
|
||||
|
||||
// credential is the per-org agent git credential the sandbox presents to native
|
||||
// git. Token is the secret (an hk- key); Username is the basic-auth user label.
|
||||
// Encoded into the request body only — never logged.
|
||||
type credential struct {
|
||||
Username string `json:"username"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// taskRequest is the cloud→runtime body for one coding run.
|
||||
type taskRequest struct {
|
||||
CloneURL string `json:"cloneUrl"` // https://<domain>/v1/git/<org>/<repo>.git
|
||||
BaseBranch string `json:"baseBranch"` // branch to start from (default repo default)
|
||||
Branch string `json:"branch"` // branch to create + push (e.g. agent/<sessionid>)
|
||||
Prompt string `json:"prompt"` // the engineering task
|
||||
SessionID string `json:"sessionId"` // cloud session id (correlation)
|
||||
RunTimeoutSeconds int `json:"runTimeoutSeconds"` // sandbox run budget
|
||||
Credential credential `json:"credential"` // agent git credential (write-only)
|
||||
}
|
||||
|
||||
// message is the discriminated shape of one streamed line: step/log while the job
|
||||
// runs, result/error to end it.
|
||||
type message struct {
|
||||
Type string `json:"type"` // step | log | result | error
|
||||
Step string `json:"step,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
CommitSha string `json:"commitSha,omitempty"`
|
||||
Diffstat string `json:"diffstat,omitempty"`
|
||||
Changed bool `json:"changed,omitempty"`
|
||||
OK bool `json:"ok,omitempty"`
|
||||
LogTail string `json:"logTail,omitempty"`
|
||||
}
|
||||
|
||||
// runner is the Runner seam over the real runtime. Its fake twin in coding_test.go
|
||||
// is what the orchestrator is tested against.
|
||||
type runner struct{}
|
||||
|
||||
// Run hands one coding job to the runtime and streams its progress, invoking
|
||||
// onStep for each line as it arrives, then returns the terminal result. A
|
||||
// transport failure, or a stream that ends without a terminal message, is an
|
||||
// error — no partial success is fabricated. org/userID are the tenant context the
|
||||
// runtime trusts AFTER its own bearer gate.
|
||||
func (runner) Run(ctx context.Context, org, userID string, req RunRequest, onStep func(Step)) (RunResult, error) {
|
||||
var out RunResult
|
||||
var terminal bool
|
||||
err := runtime.Stream(ctx, runtime.Call{
|
||||
Op: taskOp,
|
||||
Org: org,
|
||||
User: userID,
|
||||
Body: taskRequest{
|
||||
CloneURL: req.CloneURL, BaseBranch: req.BaseBranch, Branch: req.Branch,
|
||||
Prompt: req.Prompt, SessionID: req.SessionID, RunTimeoutSeconds: req.RunTimeoutSeconds,
|
||||
Credential: credential{Username: req.CredUser, Token: req.CredToken},
|
||||
},
|
||||
Secret: true, // the body carries the org's git credential
|
||||
}, func(msg []byte) {
|
||||
var m message
|
||||
if json.Unmarshal(msg, &m) != nil {
|
||||
return // skip a malformed message rather than abort the whole run
|
||||
}
|
||||
switch m.Type {
|
||||
case "result":
|
||||
out = RunResult{
|
||||
Branch: m.Branch, CommitSha: m.CommitSha, Diffstat: m.Diffstat,
|
||||
Changed: m.Changed, OK: m.OK, LogTail: m.LogTail,
|
||||
}
|
||||
terminal = true
|
||||
case "error":
|
||||
out = RunResult{OK: false, LogTail: m.LogTail, Error: nonEmpty(m.Message, "coding task failed")}
|
||||
terminal = true
|
||||
default: // step | log — mirror live
|
||||
if onStep != nil {
|
||||
onStep(Step{Type: m.Type, Step: m.Step, Message: m.Message, Status: m.Status})
|
||||
}
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return RunResult{}, fmt.Errorf("coding: run task: %w", err)
|
||||
}
|
||||
if !terminal {
|
||||
return RunResult{}, fmt.Errorf("coding: stream ended without a result")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package bot
|
||||
package coding
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -10,6 +10,11 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// These pin coding's wire contract with the runtime through the REAL transport
|
||||
// (clients/runtime) against a stub server — so the credential-custody and
|
||||
// fail-closed properties are proven end to end over the seam, not against a fake
|
||||
// of it.
|
||||
|
||||
// ndjsonServer streams the given lines as application/x-ndjson and records the
|
||||
// request it received, so a test can assert the wire contract (path, headers,
|
||||
// body) AND that the credential travels only in the body.
|
||||
@@ -31,7 +36,7 @@ func ndjsonServer(t *testing.T, lines []string, capture *http.Request, capBody *
|
||||
}))
|
||||
}
|
||||
|
||||
func TestRunCodingTask_StreamsStepsAndResult(t *testing.T) {
|
||||
func TestTask_StreamsStepsAndResult(t *testing.T) {
|
||||
lines := []string{
|
||||
`{"type":"step","step":"clone","status":"ok"}`,
|
||||
`{"type":"log","message":"editing handler.go"}`,
|
||||
@@ -46,11 +51,11 @@ func TestRunCodingTask_StreamsStepsAndResult(t *testing.T) {
|
||||
t.Setenv("BOT_GATEWAY_ALLOW_PLAINTEXT", "1") // httptest is http://; assert-plaintext guard tested separately
|
||||
t.Setenv("BOT_GATEWAY_TOKEN", "svc-token-xyz")
|
||||
|
||||
var steps []CodingStep
|
||||
res, err := RunCodingTask(context.Background(), "acme", "u-1", CodingTaskRequest{
|
||||
var steps []Step
|
||||
res, err := runner{}.Run(context.Background(), "acme", "u-1", RunRequest{
|
||||
CloneURL: "https://git.test/v1/git/acme/api.git", Branch: "agent/x", Prompt: "fix",
|
||||
Credential: Credential{Username: "x-access-token", Token: "hk-SECRETtoken"},
|
||||
}, func(s CodingStep) { steps = append(steps, s) })
|
||||
CredUser: "x-access-token", CredToken: "hk-SECRETtoken",
|
||||
}, func(s Step) { steps = append(steps, s) })
|
||||
if err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
@@ -81,7 +86,7 @@ func TestRunCodingTask_StreamsStepsAndResult(t *testing.T) {
|
||||
if strings.Contains(gotReq.URL.RawQuery, "SECRETtoken") || strings.Contains(gotReq.Header.Get("Authorization"), "SECRETtoken") {
|
||||
t.Fatal("credential leaked onto URL/header")
|
||||
}
|
||||
var body CodingTaskRequest
|
||||
var body taskRequest
|
||||
if err := json.Unmarshal(gotBody, &body); err != nil {
|
||||
t.Fatalf("body decode: %v", err)
|
||||
}
|
||||
@@ -90,13 +95,13 @@ func TestRunCodingTask_StreamsStepsAndResult(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCodingTask_ErrorLine(t *testing.T) {
|
||||
func TestTask_ErrorLine(t *testing.T) {
|
||||
srv := ndjsonServer(t, []string{`{"type":"error","message":"dev exec failed","logTail":"boom"}`}, nil, nil)
|
||||
defer srv.Close()
|
||||
t.Setenv("BOT_GATEWAY_URL", srv.URL)
|
||||
t.Setenv("BOT_GATEWAY_ALLOW_PLAINTEXT", "1") // httptest is http://; assert-plaintext guard tested separately
|
||||
|
||||
res, err := RunCodingTask(context.Background(), "acme", "u", CodingTaskRequest{}, nil)
|
||||
res, err := runner{}.Run(context.Background(), "acme", "u", RunRequest{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("a clean error line is a terminal result, not a transport error: %v", err)
|
||||
}
|
||||
@@ -105,29 +110,29 @@ func TestRunCodingTask_ErrorLine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCodingTask_Non2xxIsError(t *testing.T) {
|
||||
func TestTask_Non2xxIsError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
}))
|
||||
defer srv.Close()
|
||||
t.Setenv("BOT_GATEWAY_URL", srv.URL)
|
||||
t.Setenv("BOT_GATEWAY_ALLOW_PLAINTEXT", "1") // httptest is http://; assert-plaintext guard tested separately
|
||||
if _, err := RunCodingTask(context.Background(), "acme", "u", CodingTaskRequest{}, nil); err == nil {
|
||||
if _, err := (runner{}).Run(context.Background(), "acme", "u", RunRequest{}, nil); err == nil {
|
||||
t.Fatal("a 401 must be an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCodingTask_NoTerminalIsError(t *testing.T) {
|
||||
func TestTask_NoTerminalIsError(t *testing.T) {
|
||||
srv := ndjsonServer(t, []string{`{"type":"step","step":"clone"}`}, nil, nil)
|
||||
defer srv.Close()
|
||||
t.Setenv("BOT_GATEWAY_URL", srv.URL)
|
||||
t.Setenv("BOT_GATEWAY_ALLOW_PLAINTEXT", "1") // httptest is http://; assert-plaintext guard tested separately
|
||||
if _, err := RunCodingTask(context.Background(), "acme", "u", CodingTaskRequest{}, nil); err == nil {
|
||||
if _, err := (runner{}).Run(context.Background(), "acme", "u", RunRequest{}, nil); err == nil {
|
||||
t.Fatal("a stream with no result/error line must be an error (no fabricated success)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCodingTask_RefusesCleartextByDefault(t *testing.T) {
|
||||
func TestTask_RefusesCleartextByDefault(t *testing.T) {
|
||||
// The credential-bearing POST must not go cleartext without an explicit mesh
|
||||
// opt-in: an http target with BOT_GATEWAY_ALLOW_PLAINTEXT unset fails closed and
|
||||
// never dials.
|
||||
@@ -135,8 +140,8 @@ func TestRunCodingTask_RefusesCleartextByDefault(t *testing.T) {
|
||||
defer srv.Close()
|
||||
t.Setenv("BOT_GATEWAY_URL", srv.URL) // http://
|
||||
t.Setenv("BOT_GATEWAY_ALLOW_PLAINTEXT", "")
|
||||
_, err := RunCodingTask(context.Background(), "acme", "u", CodingTaskRequest{
|
||||
Credential: Credential{Username: "x", Token: "hk-SECRET"},
|
||||
_, err := runner{}.Run(context.Background(), "acme", "u", RunRequest{
|
||||
CredUser: "x", CredToken: "hk-SECRET",
|
||||
}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("cleartext coding POST must fail closed by default")
|
||||
@@ -0,0 +1,79 @@
|
||||
package commerceinproc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// TestRouterSemantics establishes, by PROBE rather than by reading the router, the three
|
||||
// behaviours that decide what happens if commerce's real /v1/billing/* routes are mounted
|
||||
// alongside cloud's existing ones. Registration order here mirrors production: commerce
|
||||
// mounts BEFORE billing (apps/apps.go — commerce at slice position 214, billing at 225),
|
||||
// and fiber/zip walks its own stack, so "first" below means "commerce".
|
||||
func TestRouterSemantics(t *testing.T) {
|
||||
probe := func(t *testing.T, register func(app *zip.App), path string) (status int, body string, panicked any) {
|
||||
t.Helper()
|
||||
defer func() { panicked = recover() }()
|
||||
app := zip.New(zip.Config{})
|
||||
register(app)
|
||||
resp, err := app.Fiber().Test(httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("probe %s: %v", path, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
buf := make([]byte, 64)
|
||||
n, _ := resp.Body.Read(buf)
|
||||
return resp.StatusCode, string(buf[:n]), nil
|
||||
}
|
||||
|
||||
// CASE 1 — BYTE-IDENTICAL patterns. This is exactly commerce's GET /v1/billing/balance
|
||||
// vs cloud's GET /v1/billing/balance. Does it panic, or silently shadow?
|
||||
t.Run("byte identical", func(t *testing.T) {
|
||||
status, body, panicked := probe(t, func(app *zip.App) {
|
||||
app.Get("/v1/billing/balance", func(c *zip.Ctx) error { return c.Bytes(200, []byte("FIRST")) })
|
||||
app.Get("/v1/billing/balance", func(c *zip.Ctx) error { return c.Bytes(200, []byte("SECOND")) })
|
||||
}, "/v1/billing/balance")
|
||||
t.Logf("byte-identical => panicked=%v status=%d body=%q", panicked, status, body)
|
||||
if panicked != nil {
|
||||
t.Logf("VERDICT: duplicate registration PANICS — a boot-time failure, loud not silent")
|
||||
return
|
||||
}
|
||||
t.Logf("VERDICT: NO panic; %q wins => the loser is SILENTLY SHADOWED DEAD CODE", body)
|
||||
})
|
||||
|
||||
// CASE 2 — equal specificity, DIFFERENT param names. If commerce registers
|
||||
// /v1/billing/:id where cloud has /v1/billing/:runId, does the binary panic AT BOOT?
|
||||
// That would be a production-down deploy gate.
|
||||
t.Run("equal specificity different param names", func(t *testing.T) {
|
||||
status, body, panicked := probe(t, func(app *zip.App) {
|
||||
app.Get("/v1/billing/:id", func(c *zip.Ctx) error { return c.Bytes(200, []byte("ID")) })
|
||||
app.Get("/v1/billing/:runId", func(c *zip.Ctx) error { return c.Bytes(200, []byte("RUNID")) })
|
||||
}, "/v1/billing/xyz")
|
||||
t.Logf("param-name conflict => panicked=%v status=%d body=%q", panicked, status, body)
|
||||
if panicked != nil {
|
||||
t.Logf("VERDICT: PANICS at registration — mounting a conflicting param route takes the binary DOWN AT BOOT")
|
||||
return
|
||||
}
|
||||
t.Logf("VERDICT: no panic; %q wins", body)
|
||||
})
|
||||
|
||||
// CASE 3 — different specificity, registered WORST-first. Does a later, more specific
|
||||
// static route beat an earlier wildcard? This decides whether the account bridge's
|
||||
// /v1/billing/* can shadow a static commerce route.
|
||||
t.Run("wildcard registered before static", func(t *testing.T) {
|
||||
status, body, panicked := probe(t, func(app *zip.App) {
|
||||
app.Get("/v1/billing/*", func(c *zip.Ctx) error { return c.Bytes(200, []byte("WILDCARD")) })
|
||||
app.Get("/v1/billing/balance", func(c *zip.Ctx) error { return c.Bytes(200, []byte("STATIC")) })
|
||||
}, "/v1/billing/balance")
|
||||
t.Logf("wildcard-then-static => panicked=%v status=%d body=%q", panicked, status, body)
|
||||
switch body {
|
||||
case "STATIC":
|
||||
t.Logf("VERDICT: MOST-SPECIFIC wins regardless of registration order (ServeMux-1.22 semantics)")
|
||||
case "WILDCARD":
|
||||
t.Logf("VERDICT: FIRST REGISTRATION wins (registration-stack order)")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package commerceinproc
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// TestSetAppSelfDispatch pins the hazard that took /v1/billing/balance down in
|
||||
// production, and that TestInProcessDispatch cannot see because it stubs a SEPARATE
|
||||
// commerce handler via SetHandler — a condition that is FALSE in the shipped binary.
|
||||
//
|
||||
// SetApp publishes the HOST's shared zip app as the "commerce" transport, and RoundTrip
|
||||
// re-dispatches BY PATH. commerce's own /v1/billing/* routes are NOT registered in the
|
||||
// co-resident binary (api.Route() is called only from commerce's mount.go, which is
|
||||
// //go:build cloud and never compiled). So when a cloud handler registered at path P
|
||||
// "calls commerce" at that same P, the request re-enters THAT HANDLER — the proxy calls
|
||||
// itself. The re-entrant request carries the service bearer but no validated principal,
|
||||
// so the handler's own sign-in gate refuses it, and the outer hop reports the refusal as
|
||||
// an upstream failure.
|
||||
//
|
||||
// This test asserts the CURRENT, REAL mechanics: the self-call happens, and it is
|
||||
// BOUNDED AT DEPTH 2 by the sign-in gate (it is not infinite recursion).
|
||||
func TestSetAppSelfDispatch(t *testing.T) {
|
||||
t.Cleanup(func() { SetHandler(nil) })
|
||||
|
||||
var depth int32
|
||||
app := zip.New(zip.Config{})
|
||||
|
||||
// A cloud-style handler at /v1/billing/balance that proxies to commerce at the SAME
|
||||
// path — exactly clients/billing.balance → proxy(s, c, "/v1/billing/balance").
|
||||
app.Get("/v1/billing/balance", func(c *zip.Ctx) error {
|
||||
d := atomic.AddInt32(&depth, 1)
|
||||
// The sign-in gate: no validated principal ⇒ refuse. On the re-entrant hop
|
||||
// there is none, which is what terminates the recursion.
|
||||
if d > 1 {
|
||||
return c.Bytes(http.StatusUnauthorized, []byte(`{"error":"sign in to view billing"}`))
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, BaseURL("")+"/v1/billing/balance", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer service-token")
|
||||
req.Header.Set("X-Org-Id", "hanzo")
|
||||
resp, err := Client(5 * time.Second).Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
// financeGet's shape: a non-2xx upstream becomes "billing upstream status %d".
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return c.Bytes(http.StatusBadGateway, []byte(`{"error":"billing upstream status `+http.StatusText(resp.StatusCode)+`"}`))
|
||||
}
|
||||
return c.Bytes(http.StatusOK, body)
|
||||
})
|
||||
|
||||
SetApp(app)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, BaseURL("")+"/v1/billing/balance", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := Client(5 * time.Second).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
got := atomic.LoadInt32(&depth)
|
||||
t.Logf("handler entered %d times; outer status=%d body=%s", got, resp.StatusCode, body)
|
||||
|
||||
if got != 2 {
|
||||
t.Fatalf("handler entered %d times, want exactly 2 (self-call, then the sign-in gate terminates it)", got)
|
||||
}
|
||||
if resp.StatusCode != http.StatusBadGateway {
|
||||
t.Errorf("outer status = %d, want 502 — the self-call is reported as an upstream failure", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package connectorruntime
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/evanw/esbuild/pkg/api"
|
||||
)
|
||||
|
||||
// apExternals are the packages a connector's source resolves at RUNTIME
|
||||
// against the in-process shim (shim.js) instead of bundling. The two
|
||||
// @activepieces/* framework packages are always shimmed; @activepieces/shared
|
||||
// is shimmed too because pieces import a handful of its pure helpers
|
||||
// (isNil/isEmpty/assertNotNullOrUndefined) that the shim provides. Everything
|
||||
// else a piece imports (its own relative files) is bundled into one program.
|
||||
var apExternals = []string{
|
||||
"@activepieces/pieces-framework",
|
||||
"@activepieces/pieces-common",
|
||||
"@activepieces/shared",
|
||||
}
|
||||
|
||||
// Bundle compiles ONE ActivePieces connector's TypeScript source tree into a
|
||||
// single CommonJS program, with the framework packages left external so they
|
||||
// resolve to the in-process shim at run time. This is the connector-ingest
|
||||
// build step: run it once per connector (offline / CI), commit the JS blob,
|
||||
// and the runtime compiles+executes that blob natively in goja — no Node.
|
||||
//
|
||||
// entryPoint is the connector's index.ts. extraExternal lets a heavier
|
||||
// connector mark additional npm deps external (each then needs a shim); for
|
||||
// the framework-only pieces (the long tail) apExternals alone suffice.
|
||||
func Bundle(entryPoint string, extraExternal ...string) ([]byte, error) {
|
||||
res := api.Build(api.BuildOptions{
|
||||
EntryPoints: []string{entryPoint},
|
||||
Bundle: true,
|
||||
Format: api.FormatCommonJS,
|
||||
Platform: api.PlatformNode,
|
||||
// ES2017 keeps async/await native (goja supports it) so the runtime
|
||||
// never has to drive a regenerator/generator downlevel.
|
||||
Target: api.ES2017,
|
||||
Write: false,
|
||||
LogLevel: api.LogLevelSilent,
|
||||
Sourcemap: api.SourceMapNone,
|
||||
External: append(append([]string{}, apExternals...), extraExternal...),
|
||||
})
|
||||
if len(res.Errors) > 0 {
|
||||
return nil, fmt.Errorf("connectorruntime: bundle %s: %s", entryPoint, esbuildErrs(res.Errors))
|
||||
}
|
||||
if len(res.OutputFiles) == 0 {
|
||||
return nil, fmt.Errorf("connectorruntime: bundle %s: no output", entryPoint)
|
||||
}
|
||||
return res.OutputFiles[0].Contents, nil
|
||||
}
|
||||
|
||||
func esbuildErrs(errs []api.Message) string {
|
||||
var b strings.Builder
|
||||
for i, e := range errs {
|
||||
if i > 0 {
|
||||
b.WriteString("; ")
|
||||
}
|
||||
if e.Location != nil {
|
||||
fmt.Fprintf(&b, "%s:%d: %s", e.Location.File, e.Location.Line, e.Text)
|
||||
} else {
|
||||
b.WriteString(e.Text)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package connectorruntime
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// Mount wires the native single-connector execution surface onto the cloud
|
||||
// binary, per HIP-0126 / HIP-0106:
|
||||
//
|
||||
// POST /v1/automations/connectors/:id/run run one connector action in-process
|
||||
//
|
||||
// This is the native replacement for the standalone ActivePieces Node engine's
|
||||
// /v1/auto/pieces/{piece}/run — same {action,auth,props} -> {ok,output,error}
|
||||
// contract, executed in goja in-process (no `auto` pod). It is org-gated: only
|
||||
// a validated principal may run a connector, and the caller's resolved
|
||||
// credential travels in the request `auth`. The route is DISTINCT from
|
||||
// automations' GET /v1/automations/connectors (the catalogue), so the two
|
||||
// subsystems compose without collision.
|
||||
func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("connectorruntime.Mount: nil zip.App")
|
||||
}
|
||||
log := deps.Logger
|
||||
app.Post("/v1/automations/connectors/:id/run", runHandler)
|
||||
if log != nil {
|
||||
log.New("subsystem", "connectorruntime").Info(
|
||||
"connector runtime mounted", "connectors", len(Connectors()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runReq mirrors the ActivePieces piece-run body.
|
||||
type runReq struct {
|
||||
Action string `json:"action"`
|
||||
Auth any `json:"auth"`
|
||||
Props map[string]any `json:"props"`
|
||||
}
|
||||
|
||||
// runResp mirrors the ActivePieces piece-run response. A connector-level
|
||||
// failure is ok:false with a message (HTTP 200) — an unknown connector or a
|
||||
// missing action is a 4xx, matching the old engine's infra-vs-piece split.
|
||||
type runResp struct {
|
||||
Ok bool `json:"ok"`
|
||||
Output any `json:"output,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func runHandler(c *zip.Ctx) error {
|
||||
org, ok := principal.Org(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("a validated principal is required")
|
||||
}
|
||||
id := c.Param("id")
|
||||
if !Has(id) {
|
||||
return zip.Errorf(http.StatusNotFound, "unknown connector %q", id)
|
||||
}
|
||||
var body runReq
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return err
|
||||
}
|
||||
if body.Action == "" {
|
||||
return zip.Errorf(http.StatusUnprocessableEntity, "action is required")
|
||||
}
|
||||
out, err := Run(c.Context(), org, id, body.Action, body.Auth, body.Props)
|
||||
if err != nil {
|
||||
// The action ran but failed (or the action name is unknown): surface as
|
||||
// a piece-level failure, not an infra 5xx — the caller inspects ok.
|
||||
return c.JSON(http.StatusOK, runResp{Ok: false, Error: err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusOK, runResp{Ok: true, Output: out})
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
|
||||
// ../auto/packages/pieces/community/brave-search/src/index.ts
|
||||
var index_exports = {};
|
||||
__export(index_exports, {
|
||||
braveSearch: () => braveSearch
|
||||
});
|
||||
module.exports = __toCommonJS(index_exports);
|
||||
var import_pieces_framework3 = require("@activepieces/pieces-framework");
|
||||
|
||||
// ../auto/packages/pieces/community/brave-search/src/lib/actions/web-search.ts
|
||||
var import_pieces_framework2 = require("@activepieces/pieces-framework");
|
||||
var import_pieces_common = require("@activepieces/pieces-common");
|
||||
|
||||
// ../auto/packages/pieces/community/brave-search/src/lib/auth.ts
|
||||
var import_pieces_framework = require("@activepieces/pieces-framework");
|
||||
var braveSearchAuth = import_pieces_framework.PieceAuth.SecretText({
|
||||
displayName: "API Key",
|
||||
required: true,
|
||||
description: "Your Brave Search API Key (get it from https://brave.com/search/api/)"
|
||||
});
|
||||
|
||||
// ../auto/packages/pieces/community/brave-search/src/lib/actions/web-search.ts
|
||||
var braveWebSearchAction = (0, import_pieces_framework2.createAction)({
|
||||
auth: braveSearchAuth,
|
||||
name: "web_search",
|
||||
displayName: "Web Search",
|
||||
description: "Search the web using Brave Search",
|
||||
props: {
|
||||
query: import_pieces_framework2.Property.ShortText({
|
||||
displayName: "Query",
|
||||
description: "The search query",
|
||||
required: true
|
||||
}),
|
||||
count: import_pieces_framework2.Property.Number({
|
||||
displayName: "Count",
|
||||
description: "Number of results (1-20)",
|
||||
required: false,
|
||||
defaultValue: 10
|
||||
})
|
||||
},
|
||||
async run(context) {
|
||||
const query = context.propsValue.query;
|
||||
const count = context.propsValue.count;
|
||||
const response = await import_pieces_common.httpClient.sendRequest({
|
||||
method: import_pieces_common.HttpMethod.GET,
|
||||
url: "https://api.search.brave.com/res/v1/web/search",
|
||||
headers: {
|
||||
"X-Subscription-Token": context.auth.secret_text,
|
||||
Accept: "application/json"
|
||||
},
|
||||
queryParams: {
|
||||
q: query,
|
||||
count
|
||||
}
|
||||
});
|
||||
return response.body;
|
||||
}
|
||||
});
|
||||
|
||||
// ../auto/packages/pieces/community/brave-search/src/index.ts
|
||||
var import_pieces_common2 = require("@activepieces/pieces-common");
|
||||
var braveSearch = (0, import_pieces_framework3.createPiece)({
|
||||
displayName: "Brave Search",
|
||||
description: "Privacy-preserving search engine",
|
||||
auth: braveSearchAuth,
|
||||
minimumSupportedRelease: "0.30.0",
|
||||
logoUrl: "https://cdn.activepieces.com/pieces/brave-search.png",
|
||||
authors: ["ErisMorn", "sanket-a11y"],
|
||||
actions: [
|
||||
braveWebSearchAction,
|
||||
(0, import_pieces_common2.createCustomApiCallAction)({
|
||||
auth: braveSearchAuth,
|
||||
baseUrl: () => "https://api.search.brave.com/res/v1",
|
||||
authMapping: async (auth) => {
|
||||
return {
|
||||
"X-Subscription-Token": auth.secret_text
|
||||
};
|
||||
}
|
||||
})
|
||||
],
|
||||
triggers: []
|
||||
});
|
||||
// Annotate the CommonJS export names for ESM import in node:
|
||||
0 && (module.exports = {
|
||||
braveSearch
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
// notion.js is the in-process Notion connector for the KB long-tail sync. It
|
||||
// is the generic custom_api_call action bound to Notion's base URL + bearer
|
||||
// auth — behaviourally identical to @activepieces/piece-notion's own
|
||||
// custom_api_call (baseUrl https://api.notion.com/v1, Authorization: Bearer
|
||||
// <access_token>), which is the ONLY Notion action the KB sync invokes. It
|
||||
// carries none of the 12 Notion actions/triggers the sync never touches, so it
|
||||
// needs no @notionhq/client bundle. When the full Notion piece is vendored via
|
||||
// the bundle step, this file is replaced by that blob under the same name.
|
||||
const { createPiece, PieceAuth } = require('@activepieces/pieces-framework');
|
||||
const { createCustomApiCallAction } = require('@activepieces/pieces-common');
|
||||
|
||||
const notionAuth = PieceAuth.OAuth2({ required: true, authUrl: '', tokenUrl: '', scope: [] });
|
||||
|
||||
module.exports.notion = createPiece({
|
||||
displayName: 'Notion',
|
||||
description: 'The all-in-one workspace',
|
||||
auth: notionAuth,
|
||||
actions: [
|
||||
createCustomApiCallAction({
|
||||
auth: notionAuth,
|
||||
baseUrl: () => 'https://api.notion.com/v1',
|
||||
authMapping: async (auth) => ({
|
||||
Authorization: 'Bearer ' + (auth && (auth.access_token || auth)),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
triggers: [],
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
package connectorruntime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// shimJS is the in-process ActivePieces framework, embedded so the runtime is
|
||||
// a single self-contained binary — no JS files on disk at run time.
|
||||
//
|
||||
//go:embed shim.js
|
||||
var shimJS string
|
||||
|
||||
// defaultTimeout bounds a connector HTTP call when the connector sets none.
|
||||
const defaultTimeout = 30 * time.Second
|
||||
|
||||
// maxResponseBytes caps a connector response so a hostile endpoint cannot
|
||||
// exhaust memory (fail-secure, mirrors the KB ingest limit).
|
||||
const maxResponseBytes = 32 << 20
|
||||
|
||||
// defaultDoerClient has NO timeout of its own; per-request deadlines come from
|
||||
// the ctx the doer derives, so a slow endpoint is bounded by the caller's
|
||||
// context or defaultTimeout, whichever is first.
|
||||
var defaultDoerClient = &http.Client{}
|
||||
|
||||
// defaultHTTPDoer performs a connector HTTP request with Go net/http. It builds
|
||||
// the final URL (merging queryParams), JSON-encodes an object body, sends, and
|
||||
// decodes the response as JSON when it looks like JSON (matching axios' default
|
||||
// responseType:'json' the connectors were written against).
|
||||
func defaultHTTPDoer(ctx context.Context, req HTTPRequest) (HTTPResponse, error) {
|
||||
timeout := defaultTimeout
|
||||
if req.TimeoutMS > 0 {
|
||||
timeout = time.Duration(req.TimeoutMS) * time.Millisecond
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
u, err := url.Parse(strings.TrimSpace(req.URL))
|
||||
if err != nil {
|
||||
return HTTPResponse{}, fmt.Errorf("bad url: %w", err)
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return HTTPResponse{}, fmt.Errorf("unsupported url scheme %q", u.Scheme)
|
||||
}
|
||||
if len(req.QueryParams) > 0 {
|
||||
q := u.Query()
|
||||
for k, v := range req.QueryParams {
|
||||
q.Set(k, v)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
}
|
||||
|
||||
method := strings.ToUpper(strings.TrimSpace(req.Method))
|
||||
if method == "" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
|
||||
var bodyReader io.Reader
|
||||
jsonBody := false
|
||||
if req.Body != nil {
|
||||
switch b := req.Body.(type) {
|
||||
case string:
|
||||
bodyReader = strings.NewReader(b)
|
||||
case []byte:
|
||||
bodyReader = bytes.NewReader(b)
|
||||
default:
|
||||
raw, e := json.Marshal(b)
|
||||
if e != nil {
|
||||
return HTTPResponse{}, fmt.Errorf("encode body: %w", e)
|
||||
}
|
||||
bodyReader = bytes.NewReader(raw)
|
||||
jsonBody = true
|
||||
}
|
||||
}
|
||||
|
||||
r, err := http.NewRequestWithContext(cctx, method, u.String(), bodyReader)
|
||||
if err != nil {
|
||||
return HTTPResponse{}, err
|
||||
}
|
||||
for k, v := range req.Headers {
|
||||
r.Header.Set(k, v)
|
||||
}
|
||||
if jsonBody && r.Header.Get("Content-Type") == "" {
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := defaultDoerClient.Do(r)
|
||||
if err != nil {
|
||||
return HTTPResponse{}, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
|
||||
if err != nil {
|
||||
return HTTPResponse{}, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
return HTTPResponse{
|
||||
Status: resp.StatusCode,
|
||||
Headers: firstValueHeaders(resp.Header),
|
||||
Body: decodeBody(resp.Header.Get("Content-Type"), raw),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// decodeBody returns parsed JSON when the response is JSON, else the raw string
|
||||
// — the same shape axios handed connectors.
|
||||
func decodeBody(contentType string, raw []byte) any {
|
||||
looksJSON := strings.Contains(strings.ToLower(contentType), "json")
|
||||
if !looksJSON {
|
||||
trimmed := bytes.TrimSpace(raw)
|
||||
if len(trimmed) > 0 && (trimmed[0] == '{' || trimmed[0] == '[') {
|
||||
looksJSON = true
|
||||
}
|
||||
}
|
||||
if looksJSON {
|
||||
var v any
|
||||
if json.Unmarshal(raw, &v) == nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func firstValueHeaders(h http.Header) map[string]string {
|
||||
out := make(map[string]string, len(h))
|
||||
for k, v := range h {
|
||||
if len(v) > 0 {
|
||||
out[strings.ToLower(k)] = v[0]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// installConsole wires a no-op console so a connector that logs never throws
|
||||
// ReferenceError (goja has no console by default).
|
||||
func installConsole(vm *goja.Runtime) {
|
||||
if !goja.IsUndefined(vm.Get("console")) {
|
||||
return
|
||||
}
|
||||
noop := func(goja.FunctionCall) goja.Value { return goja.Undefined() }
|
||||
console := vm.NewObject()
|
||||
for _, m := range []string{"log", "info", "warn", "error", "debug", "trace"} {
|
||||
_ = console.Set(m, noop)
|
||||
}
|
||||
_ = vm.Set("console", console)
|
||||
}
|
||||
|
||||
// toStringMap converts an exported JS object to string values (headers /
|
||||
// queryParams are string-keyed in HTTP). Numeric values stringify without a
|
||||
// trailing .0 so `count: 10` becomes "10", not "10.000000".
|
||||
func toStringMap(v any) map[string]string {
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok || len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(m))
|
||||
for k, val := range m {
|
||||
out[k] = scalarString(val)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func scalarString(val any) string {
|
||||
switch x := val.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return x
|
||||
case bool:
|
||||
return strconv.FormatBool(x)
|
||||
case int64:
|
||||
return strconv.FormatInt(x, 10)
|
||||
case int:
|
||||
return strconv.Itoa(x)
|
||||
case float64:
|
||||
if x == float64(int64(x)) {
|
||||
return strconv.FormatInt(int64(x), 10)
|
||||
}
|
||||
return strconv.FormatFloat(x, 'g', -1, 64)
|
||||
default:
|
||||
return fmt.Sprintf("%v", x)
|
||||
}
|
||||
}
|
||||
|
||||
func toInt(v any) int {
|
||||
switch x := v.(type) {
|
||||
case int64:
|
||||
return int(x)
|
||||
case int:
|
||||
return x
|
||||
case float64:
|
||||
return int(x)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// jsError normalizes a goja call error into a Go error with the JS message.
|
||||
func jsError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var ex *goja.Exception
|
||||
if ok := asException(err, &ex); ok {
|
||||
return fmt.Errorf("connector: %s", ex.Value().String())
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func asException(err error, target **goja.Exception) bool {
|
||||
if ex, ok := err.(*goja.Exception); ok {
|
||||
*target = ex
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// promiseRejection turns a rejected promise value into a Go error. An Error
|
||||
// object stringifies to "Error: msg"; other values marshal to JSON.
|
||||
func promiseRejection(vm *goja.Runtime, v goja.Value) error {
|
||||
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
|
||||
return fmt.Errorf("connector: rejected")
|
||||
}
|
||||
if obj := v.ToObject(vm); obj != nil {
|
||||
if msg := obj.Get("message"); msg != nil && !goja.IsUndefined(msg) {
|
||||
return fmt.Errorf("connector: %s", msg.String())
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("connector: %s", v.String())
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Command bundlecmd is the offline connector-ingest step: it esbuild-bundles
|
||||
// ONE ActivePieces connector source tree (its index.ts) into a single
|
||||
// CommonJS program with the framework packages left external, and writes the
|
||||
// blob. Run it per connector to vendor the JS the runtime executes natively.
|
||||
//
|
||||
// go run ./clients/connectorruntime/internal/bundlecmd <index.ts> <out.js> [extraExternal...]
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
connectorruntime "github.com/hanzoai/cloud/clients/connectorruntime"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Fprintln(os.Stderr, "usage: bundlecmd <entry index.ts> <out.js> [extraExternal...]")
|
||||
os.Exit(2)
|
||||
}
|
||||
entry, out := os.Args[1], os.Args[2]
|
||||
js, err := connectorruntime.Bundle(entry, os.Args[3:]...)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "bundle:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := os.WriteFile(out, js, 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "write:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("wrote %s (%d bytes)\n", out, len(js))
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package connectorruntime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// connectorsFS holds every vendored connector program: real ActivePieces piece
|
||||
// bundles (produced by the bundle step, *.connector.js) and hand-written
|
||||
// generic connectors (*.js). Each file's base name (sans extension, sans the
|
||||
// ".connector" tag) is the connector id.
|
||||
//
|
||||
//go:embed connectors/*.js
|
||||
var connectorsFS embed.FS
|
||||
|
||||
// pkg is the process-wide runtime + registry KB and the HTTP surface share.
|
||||
// One shim, one net/http doer, N compiled connectors. Built at init so the
|
||||
// in-process API is ready the moment the package is imported (KB does not wait
|
||||
// for Mount).
|
||||
var pkg = mustBuildRegistry()
|
||||
|
||||
type registry struct {
|
||||
rt *Runtime
|
||||
mu sync.RWMutex
|
||||
byID map[string]*Connector
|
||||
}
|
||||
|
||||
func mustBuildRegistry() *registry {
|
||||
rt, err := NewRuntime(nil)
|
||||
if err != nil {
|
||||
panic("connectorruntime: build runtime: " + err.Error())
|
||||
}
|
||||
reg := ®istry{rt: rt, byID: map[string]*Connector{}}
|
||||
entries, err := connectorsFS.ReadDir("connectors")
|
||||
if err != nil {
|
||||
panic("connectorruntime: read connectors: " + err.Error())
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
src, err := fs.ReadFile(connectorsFS, "connectors/"+e.Name())
|
||||
if err != nil {
|
||||
panic("connectorruntime: read " + e.Name() + ": " + err.Error())
|
||||
}
|
||||
id := connectorID(e.Name())
|
||||
c, err := rt.Compile(id, src)
|
||||
if err != nil {
|
||||
panic("connectorruntime: compile " + id + ": " + err.Error())
|
||||
}
|
||||
reg.byID[id] = c
|
||||
}
|
||||
return reg
|
||||
}
|
||||
|
||||
// connectorID derives the connector id from a file name:
|
||||
// "brave-search.connector.js" -> "brave-search", "notion.js" -> "notion".
|
||||
func connectorID(name string) string {
|
||||
name = strings.TrimSuffix(name, ".js")
|
||||
name = strings.TrimSuffix(name, ".connector")
|
||||
return name
|
||||
}
|
||||
|
||||
// Run executes one connector action natively in-process. org is the caller's
|
||||
// already-validated tenant — recorded for attribution; the runtime resolves no
|
||||
// credential itself, it runs with the auth the caller passes (the KB path
|
||||
// hands it this org's KMS-sealed OAuth token). Returns the action result or an
|
||||
// error (unknown connector/action, or the action's own failure).
|
||||
func Run(ctx context.Context, org, connector, action string, auth any, props map[string]any) (any, error) {
|
||||
pkg.mu.RLock()
|
||||
c, ok := pkg.byID[connector]
|
||||
pkg.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("connectorruntime: unknown connector %q", connector)
|
||||
}
|
||||
return pkg.rt.Run(ctx, c, RunInput{Action: action, Auth: auth, Props: props})
|
||||
}
|
||||
|
||||
// Has reports whether a connector is registered (used to fail closed before a
|
||||
// doomed call).
|
||||
func Has(connector string) bool {
|
||||
pkg.mu.RLock()
|
||||
defer pkg.mu.RUnlock()
|
||||
_, ok := pkg.byID[connector]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Connectors lists the registered connector ids (stable order) for the
|
||||
// catalogue / health surface.
|
||||
func Connectors() []string {
|
||||
pkg.mu.RLock()
|
||||
defer pkg.mu.RUnlock()
|
||||
out := make([]string, 0, len(pkg.byID))
|
||||
for id := range pkg.byID {
|
||||
out = append(out, id)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package connectorruntime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRegistry_NotionCustomApiCall exercises the EXACT call the KB long-tail
|
||||
// sync makes — Run(ctx, org, "notion", "custom_api_call", auth, props) — end to
|
||||
// end through the package registry and the default net/http doer. It proves the
|
||||
// KB path runs native in-process: the Notion bearer auth is injected, the
|
||||
// request KB builds (url/method/headers/body) is sent, and the JSON response is
|
||||
// returned. No auto pod, no PIECES_RUNNER_SECRET, no cross-service hop.
|
||||
func TestRegistry_NotionCustomApiCall(t *testing.T) {
|
||||
var gotAuth, gotVersion string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
gotVersion = r.Header.Get("Notion-Version")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"object": "list",
|
||||
"results": []any{map[string]any{"id": "page-1"}},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if !Has("notion") {
|
||||
t.Fatal("notion connector not registered")
|
||||
}
|
||||
|
||||
out, err := Run(context.Background(), "org-abc", "notion", "custom_api_call",
|
||||
map[string]any{"access_token": "secret_notion_token"},
|
||||
map[string]any{
|
||||
"method": "POST",
|
||||
"url": map[string]any{"url": srv.URL},
|
||||
"headers": map[string]any{"Notion-Version": "2022-06-28"},
|
||||
"body_type": "json",
|
||||
"body": map[string]any{"data": map[string]any{"page_size": 100}},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("notion custom_api_call: %v", err)
|
||||
}
|
||||
|
||||
if gotAuth != "Bearer secret_notion_token" {
|
||||
t.Errorf("Authorization = %q, want 'Bearer secret_notion_token'", gotAuth)
|
||||
}
|
||||
if gotVersion != "2022-06-28" {
|
||||
t.Errorf("Notion-Version = %q, want 2022-06-28", gotVersion)
|
||||
}
|
||||
|
||||
resp, _ := out.(map[string]any)
|
||||
body, _ := resp["body"].(map[string]any)
|
||||
results, _ := body["results"].([]any)
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("results = %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_UnknownConnector fails closed on an unregistered provider.
|
||||
func TestRegistry_UnknownConnector(t *testing.T) {
|
||||
if _, err := Run(context.Background(), "org", "does-not-exist", "x", nil, nil); err == nil {
|
||||
t.Fatal("expected error for unknown connector")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_Lists confirms both the real bundle and the generic connector
|
||||
// are present.
|
||||
func TestRegistry_Lists(t *testing.T) {
|
||||
got := map[string]bool{}
|
||||
for _, id := range Connectors() {
|
||||
got[id] = true
|
||||
}
|
||||
for _, want := range []string{"brave-search", "notion"} {
|
||||
if !got[want] {
|
||||
t.Errorf("connector %q not registered; have %v", want, Connectors())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
// Package connectorruntime executes an automation connector's action NATIVELY
|
||||
// in-process — no Node. A connector authored against the ActivePieces
|
||||
// framework (@activepieces/pieces-framework + pieces-common) is compiled once
|
||||
// to a single CommonJS program (see Bundle) and run inside goja, with the
|
||||
// framework supplied by an in-process shim (shim.js) whose only impure
|
||||
// primitive is a Go HTTP doer. Because that doer resolves synchronously, an
|
||||
// action's `async run(ctx)` settles inside goja's own microtask drain, so a
|
||||
// connector executes as ordinary in-process work — a goroutine, not a service.
|
||||
//
|
||||
// This is the substrate that retires the standalone ActivePieces Node engine
|
||||
// (the `auto` pod). It sits ALONGSIDE clients/automations' Tier-A native Go
|
||||
// connectors: those are hand-written Go; this runs the long tail of JS
|
||||
// connectors unchanged. Both are org-scoped by the caller — the runtime never
|
||||
// resolves a credential itself; it receives the already-resolved `auth`.
|
||||
package connectorruntime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// HTTPRequest is the request an in-VM httpClient.sendRequest hands to the Go
|
||||
// doer. It mirrors the ActivePieces HttpRequest fields connectors actually set.
|
||||
type HTTPRequest struct {
|
||||
Method string
|
||||
URL string
|
||||
Headers map[string]string
|
||||
QueryParams map[string]string
|
||||
Body any
|
||||
TimeoutMS int
|
||||
}
|
||||
|
||||
// HTTPResponse is what the doer returns; Body is the parsed value (JSON
|
||||
// decoded when the response is JSON, else the raw string) the connector sees
|
||||
// as response.body.
|
||||
type HTTPResponse struct {
|
||||
Status int
|
||||
Headers map[string]string
|
||||
Body any
|
||||
}
|
||||
|
||||
// HTTPDoer performs one connector HTTP call. ctx bounds it (the caller's
|
||||
// request/flow context); the default doer is net/http (see http.go). Injecting
|
||||
// a doer lets the host enforce SSRF policy or record egress.
|
||||
type HTTPDoer func(ctx context.Context, req HTTPRequest) (HTTPResponse, error)
|
||||
|
||||
// Runtime holds the compiled shim shared across all connectors and the HTTP
|
||||
// doer. It is immutable after construction and safe for concurrent use — each
|
||||
// Run builds its own goja VM, so no connector state crosses invocations
|
||||
// (the tenant-isolation property: org A's run shares no heap with org B's).
|
||||
type Runtime struct {
|
||||
shim *goja.Program
|
||||
http HTTPDoer
|
||||
}
|
||||
|
||||
// Connector is one compiled connector program (an ActivePieces piece bundle
|
||||
// wrapped as a CommonJS module). Compile once, Run many times.
|
||||
type Connector struct {
|
||||
Name string
|
||||
prog *goja.Program
|
||||
}
|
||||
|
||||
// RunInput is one action invocation.
|
||||
type RunInput struct {
|
||||
Action string // action name, e.g. "web_search" / "custom_api_call"
|
||||
Auth any // resolved credential value handed to ctx.auth
|
||||
Props map[string]any // ctx.propsValue
|
||||
}
|
||||
|
||||
// NewRuntime compiles the shim and returns a runtime. A nil doer selects the
|
||||
// default net/http doer.
|
||||
func NewRuntime(doer HTTPDoer) (*Runtime, error) {
|
||||
prog, err := goja.Compile("connectorruntime/shim.js", shimJS, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connectorruntime: compile shim: %w", err)
|
||||
}
|
||||
if doer == nil {
|
||||
doer = defaultHTTPDoer
|
||||
}
|
||||
return &Runtime{shim: prog, http: doer}, nil
|
||||
}
|
||||
|
||||
// Compile wraps a connector's bundled CommonJS source as a callable module and
|
||||
// compiles it. bundledJS is the output of Bundle (framework packages external).
|
||||
func (rt *Runtime) Compile(name string, bundledJS []byte) (*Connector, error) {
|
||||
if name == "" {
|
||||
return nil, errors.New("connectorruntime: empty connector name")
|
||||
}
|
||||
// CommonJS harness: the bundle assigns to module.exports / calls require();
|
||||
// wrapping it as a function lets us supply (module, exports, require).
|
||||
wrapped := "(function(module, exports, require){\n" + string(bundledJS) + "\n})"
|
||||
prog, err := goja.Compile(name+".connector.js", wrapped, false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connectorruntime: compile connector %s: %w", name, err)
|
||||
}
|
||||
return &Connector{Name: name, prog: prog}, nil
|
||||
}
|
||||
|
||||
// Run executes c's action with the given auth+props and returns the action's
|
||||
// result (JSON-shaped Go values). It is the single-connector execution the KB
|
||||
// long-tail sync and the /v1/automations/connectors/:id/run surface call.
|
||||
func (rt *Runtime) Run(ctx context.Context, c *Connector, in RunInput) (any, error) {
|
||||
if c == nil {
|
||||
return nil, errors.New("connectorruntime: nil connector")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
vm := goja.New()
|
||||
installConsole(vm)
|
||||
|
||||
// The sole impure primitive: perform the HTTP call synchronously on this
|
||||
// goroutine. A doer error is thrown into JS (caught by the shim's Promise
|
||||
// executor -> reject) so the action's try/catch and failsafe paths work.
|
||||
if err := vm.Set("__hanzoHttpSend", func(call goja.FunctionCall) goja.Value {
|
||||
resp, err := rt.http(ctx, parseHTTPRequest(vm, call.Argument(0)))
|
||||
if err != nil {
|
||||
panic(vm.ToValue(vm.NewGoError(err)))
|
||||
}
|
||||
return vm.ToValue(map[string]any{
|
||||
"status": resp.Status,
|
||||
"headers": resp.Headers,
|
||||
"body": resp.Body,
|
||||
})
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("connectorruntime: bind http: %w", err)
|
||||
}
|
||||
|
||||
// Install the framework shim (defines require + module table), then the
|
||||
// connector program (which require()s the shim and calls createPiece).
|
||||
if _, err := vm.RunProgram(rt.shim); err != nil {
|
||||
return nil, fmt.Errorf("connectorruntime: init shim: %w", err)
|
||||
}
|
||||
if err := runConnectorModule(vm, c.prog); err != nil {
|
||||
return nil, fmt.Errorf("connectorruntime: load connector %s: %w", c.Name, err)
|
||||
}
|
||||
|
||||
action, err := resolveAction(vm, c.Name, in.Action)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctxObj, err := makeContext(vm, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
runFn, ok := goja.AssertFunction(action.Get("run"))
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("connectorruntime: action %q has no run()", in.Action)
|
||||
}
|
||||
res, err := runFn(action, ctxObj)
|
||||
if err != nil {
|
||||
return nil, jsError(err)
|
||||
}
|
||||
return settle(vm, res)
|
||||
}
|
||||
|
||||
// runConnectorModule invokes the wrapped bundle with a fresh module/exports and
|
||||
// the shim-provided require, executing the connector body (which calls
|
||||
// createPiece).
|
||||
func runConnectorModule(vm *goja.Runtime, prog *goja.Program) error {
|
||||
fnVal, err := vm.RunProgram(prog)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fn, ok := goja.AssertFunction(fnVal)
|
||||
if !ok {
|
||||
return errors.New("connector program is not a function")
|
||||
}
|
||||
module := vm.NewObject()
|
||||
exports := vm.NewObject()
|
||||
if err := module.Set("exports", exports); err != nil {
|
||||
return err
|
||||
}
|
||||
requireFn := vm.Get("require")
|
||||
_, err = fn(goja.Undefined(), module, exports, requireFn)
|
||||
return err
|
||||
}
|
||||
|
||||
// resolveAction returns the named action object off the connector's piece. A
|
||||
// connector defines exactly one piece (createPiece); reading the last-created
|
||||
// piece is unambiguous per VM.
|
||||
func resolveAction(vm *goja.Runtime, connector, action string) (*goja.Object, error) {
|
||||
piecesVal := vm.Get("__ap_pieces")
|
||||
if piecesVal == nil || goja.IsUndefined(piecesVal) || goja.IsNull(piecesVal) {
|
||||
return nil, fmt.Errorf("connector %q defined no piece", connector)
|
||||
}
|
||||
arr := piecesVal.ToObject(vm)
|
||||
n := int(arr.Get("length").ToInteger())
|
||||
if n == 0 {
|
||||
return nil, fmt.Errorf("connector %q did not call createPiece", connector)
|
||||
}
|
||||
piece := arr.Get(strconv.Itoa(n - 1)).ToObject(vm)
|
||||
getAction, ok := goja.AssertFunction(piece.Get("getAction"))
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("connector %q piece missing getAction", connector)
|
||||
}
|
||||
actVal, err := getAction(piece, vm.ToValue(action))
|
||||
if err != nil {
|
||||
return nil, jsError(err)
|
||||
}
|
||||
if actVal == nil || goja.IsUndefined(actVal) || goja.IsNull(actVal) {
|
||||
return nil, fmt.Errorf("unknown action %q on connector %q", action, connector)
|
||||
}
|
||||
return actVal.ToObject(vm), nil
|
||||
}
|
||||
|
||||
// makeContext calls the shim's __makeContext(auth, propsValue) to build the
|
||||
// ActionContext.
|
||||
func makeContext(vm *goja.Runtime, in RunInput) (goja.Value, error) {
|
||||
mk, ok := goja.AssertFunction(vm.Get("__makeContext"))
|
||||
if !ok {
|
||||
return nil, errors.New("connectorruntime: shim __makeContext missing")
|
||||
}
|
||||
props := in.Props
|
||||
if props == nil {
|
||||
props = map[string]any{}
|
||||
}
|
||||
ctxVal, err := mk(goja.Undefined(), vm.ToValue(in.Auth), vm.ToValue(props))
|
||||
if err != nil {
|
||||
return nil, jsError(err)
|
||||
}
|
||||
return ctxVal, nil
|
||||
}
|
||||
|
||||
// settle resolves the action's return value. An async run returns a Promise
|
||||
// that — because every await resolved synchronously through the Go doer — is
|
||||
// already Fulfilled by the time goja hands control back (the microtask queue
|
||||
// drained). A Pending promise means the connector used a timer or off-thread
|
||||
// async the in-process runtime does not drive; that is an honest error, not a
|
||||
// hang.
|
||||
func settle(vm *goja.Runtime, v goja.Value) (any, error) {
|
||||
p, ok := v.Export().(*goja.Promise)
|
||||
if !ok {
|
||||
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
|
||||
return nil, nil
|
||||
}
|
||||
return v.Export(), nil
|
||||
}
|
||||
switch p.State() {
|
||||
case goja.PromiseStateFulfilled:
|
||||
r := p.Result()
|
||||
if r == nil || goja.IsUndefined(r) || goja.IsNull(r) {
|
||||
return nil, nil
|
||||
}
|
||||
return r.Export(), nil
|
||||
case goja.PromiseStateRejected:
|
||||
return nil, promiseRejection(vm, p.Result())
|
||||
default:
|
||||
return nil, errors.New("connectorruntime: action did not settle synchronously (unsupported timer/async)")
|
||||
}
|
||||
}
|
||||
|
||||
// parseHTTPRequest extracts the Go HTTPRequest from the in-VM request object.
|
||||
func parseHTTPRequest(vm *goja.Runtime, v goja.Value) HTTPRequest {
|
||||
req := HTTPRequest{}
|
||||
m, _ := v.Export().(map[string]any)
|
||||
if m == nil {
|
||||
return req
|
||||
}
|
||||
req.Method, _ = m["method"].(string)
|
||||
req.URL, _ = m["url"].(string)
|
||||
req.Headers = toStringMap(m["headers"])
|
||||
req.QueryParams = toStringMap(m["queryParams"])
|
||||
req.Body = m["body"]
|
||||
req.TimeoutMS = toInt(m["timeout"])
|
||||
return req
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package connectorruntime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// loadBrave compiles the committed brave-search bundle — the REAL ActivePieces
|
||||
// piece source (packages/pieces/community/brave-search) esbuild-bundled with
|
||||
// the framework left external. This is the keystone: one real connector
|
||||
// running native in goja proves the pattern for all of them.
|
||||
func loadBrave(t *testing.T, rt *Runtime) *Connector {
|
||||
t.Helper()
|
||||
js, err := os.ReadFile(filepath.Join("connectors", "brave-search.connector.js"))
|
||||
if err != nil {
|
||||
t.Fatalf("read brave bundle: %v", err)
|
||||
}
|
||||
c, err := rt.Compile("brave-search", js)
|
||||
if err != nil {
|
||||
t.Fatalf("compile brave: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// TestBraveWebSearch_NativeGoja runs brave-search's real web_search action
|
||||
// in-process. A doer captures the request the connector built so we can prove
|
||||
// the framework shim delivered auth + props correctly: the action reads
|
||||
// context.auth.secret_text into the X-Subscription-Token header and
|
||||
// context.propsValue.{query,count} into the query params, then returns
|
||||
// response.body. No Node, no auto pod.
|
||||
func TestBraveWebSearch_NativeGoja(t *testing.T) {
|
||||
var gotReq HTTPRequest
|
||||
rt, err := NewRuntime(func(_ context.Context, req HTTPRequest) (HTTPResponse, error) {
|
||||
gotReq = req
|
||||
return HTTPResponse{
|
||||
Status: 200,
|
||||
Headers: map[string]string{"content-type": "application/json"},
|
||||
Body: map[string]any{
|
||||
"web": map[string]any{
|
||||
"results": []any{
|
||||
map[string]any{"title": "Hanzo AI", "url": "https://hanzo.ai"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new runtime: %v", err)
|
||||
}
|
||||
brave := loadBrave(t, rt)
|
||||
|
||||
out, err := rt.Run(context.Background(), brave, RunInput{
|
||||
Action: "web_search",
|
||||
Auth: map[string]any{"secret_text": "sk-brave-test"},
|
||||
Props: map[string]any{"query": "hanzo ai", "count": 5},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run web_search: %v", err)
|
||||
}
|
||||
|
||||
// The action built the exact Brave request from the shim-delivered context.
|
||||
if gotReq.Method != "GET" {
|
||||
t.Errorf("method = %q, want GET", gotReq.Method)
|
||||
}
|
||||
if gotReq.URL != "https://api.search.brave.com/res/v1/web/search" {
|
||||
t.Errorf("url = %q", gotReq.URL)
|
||||
}
|
||||
if gotReq.Headers["X-Subscription-Token"] != "sk-brave-test" {
|
||||
t.Errorf("auth header = %q, want sk-brave-test (auth injection failed)", gotReq.Headers["X-Subscription-Token"])
|
||||
}
|
||||
if gotReq.QueryParams["q"] != "hanzo ai" {
|
||||
t.Errorf("query q = %q, want 'hanzo ai'", gotReq.QueryParams["q"])
|
||||
}
|
||||
if gotReq.QueryParams["count"] != "5" {
|
||||
t.Errorf("query count = %q, want 5", gotReq.QueryParams["count"])
|
||||
}
|
||||
|
||||
// The action returned response.body — the search results — verbatim.
|
||||
body, ok := out.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("output type = %T, want map", out)
|
||||
}
|
||||
web, _ := body["web"].(map[string]any)
|
||||
results, _ := web["results"].([]any)
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("results = %v", body)
|
||||
}
|
||||
first, _ := results[0].(map[string]any)
|
||||
if first["title"] != "Hanzo AI" {
|
||||
t.Errorf("first result title = %v", first["title"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestCustomApiCall_DefaultDoer_EndToEnd exercises the FULL native path with
|
||||
// the real net/http doer against a live test server: brave-search's
|
||||
// createCustomApiCallAction (the same generic action the KB long-tail sync
|
||||
// invokes for notion). It proves the default doer builds a correct request,
|
||||
// the shim's authMapping injects auth, and the JSON response round-trips.
|
||||
func TestCustomApiCall_DefaultDoer_EndToEnd(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-Subscription-Token"); got != "sk-live" {
|
||||
t.Errorf("server saw auth header %q, want sk-live", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("q"); got != "native" {
|
||||
t.Errorf("server saw q=%q, want native", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "echo": "native"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
rt, err := NewRuntime(nil) // nil => default net/http doer
|
||||
if err != nil {
|
||||
t.Fatalf("new runtime: %v", err)
|
||||
}
|
||||
brave := loadBrave(t, rt)
|
||||
|
||||
out, err := rt.Run(context.Background(), brave, RunInput{
|
||||
Action: "custom_api_call",
|
||||
Auth: map[string]any{"secret_text": "sk-live"},
|
||||
Props: map[string]any{
|
||||
"method": "GET",
|
||||
"url": map[string]any{"url": srv.URL},
|
||||
"queryParams": map[string]any{"q": "native"},
|
||||
"headers": map[string]any{},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run custom_api_call: %v", err)
|
||||
}
|
||||
resp, ok := out.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("output type = %T, want map", out)
|
||||
}
|
||||
if resp["status"] != int64(200) && resp["status"] != float64(200) {
|
||||
t.Errorf("status = %v (%T), want 200", resp["status"], resp["status"])
|
||||
}
|
||||
body, _ := resp["body"].(map[string]any)
|
||||
if body["ok"] != true || body["echo"] != "native" {
|
||||
t.Errorf("body = %v, want {ok:true, echo:native}", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownActionAndConnector proves honest failures (no silent success).
|
||||
func TestUnknownAction(t *testing.T) {
|
||||
rt, err := NewRuntime(func(_ context.Context, _ HTTPRequest) (HTTPResponse, error) {
|
||||
return HTTPResponse{Status: 200}, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new runtime: %v", err)
|
||||
}
|
||||
brave := loadBrave(t, rt)
|
||||
if _, err := rt.Run(context.Background(), brave, RunInput{Action: "nope"}); err == nil {
|
||||
t.Fatal("expected error for unknown action, got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
// shim.js is the in-process implementation of the ActivePieces authoring
|
||||
// framework, evaluated once per connector VM. A connector's compiled bundle
|
||||
// leaves @activepieces/pieces-framework, @activepieces/pieces-common and
|
||||
// @activepieces/shared external; this file supplies them via a minimal
|
||||
// require() so the SAME connector source that ran under the Node engine runs
|
||||
// unmodified in goja.
|
||||
//
|
||||
// The only impure primitive is __hanzoHttpSend(request) — a Go function
|
||||
// injected per invocation that performs the HTTP call synchronously (Go
|
||||
// net/http) and returns {status,headers,body} or throws. Because it resolves
|
||||
// synchronously, an action's `async run(ctx)` settles within goja's own
|
||||
// microtask drain: no event loop, connectors run as plain in-process work.
|
||||
(function (g) {
|
||||
'use strict';
|
||||
|
||||
// ---- @activepieces/pieces-framework ---------------------------------------
|
||||
|
||||
// Property.* / PieceAuth.* are pure metadata builders: they return the
|
||||
// config object tagged with a type. Execution never inspects the schema —
|
||||
// propsValue arrives already resolved — so a shallow copy is faithful.
|
||||
function tagged(type) {
|
||||
return function (cfg) {
|
||||
return Object.assign({ type: type, valueSchema: undefined }, cfg || {});
|
||||
};
|
||||
}
|
||||
var Property = {
|
||||
ShortText: tagged('SHORT_TEXT'),
|
||||
LongText: tagged('LONG_TEXT'),
|
||||
Number: tagged('NUMBER'),
|
||||
Checkbox: tagged('CHECKBOX'),
|
||||
Json: tagged('JSON'),
|
||||
Object: tagged('OBJECT'),
|
||||
Array: tagged('ARRAY'),
|
||||
File: tagged('FILE'),
|
||||
DateTime: tagged('DATE_TIME'),
|
||||
Color: tagged('COLOR'),
|
||||
Markdown: tagged('MARKDOWN'),
|
||||
StaticDropdown: tagged('STATIC_DROPDOWN'),
|
||||
Dropdown: tagged('DROPDOWN'),
|
||||
StaticMultiSelectDropdown: tagged('STATIC_MULTI_SELECT_DROPDOWN'),
|
||||
MultiSelectDropdown: tagged('MULTI_SELECT_DROPDOWN'),
|
||||
DynamicProperties: tagged('DYNAMIC'),
|
||||
Custom: tagged('CUSTOM'),
|
||||
SecretText: tagged('SECRET_TEXT'),
|
||||
};
|
||||
var PieceAuth = {
|
||||
SecretText: tagged('SECRET_TEXT'),
|
||||
OAuth2: tagged('OAUTH2'),
|
||||
BasicAuth: tagged('BASIC_AUTH'),
|
||||
CustomAuth: tagged('CUSTOM_AUTH'),
|
||||
None: function () { return undefined; },
|
||||
};
|
||||
|
||||
function createAction(p) {
|
||||
return {
|
||||
name: p.name,
|
||||
displayName: p.displayName,
|
||||
description: p.description,
|
||||
props: p.props || {},
|
||||
run: p.run,
|
||||
test: p.test || p.run,
|
||||
requireAuth: p.requireAuth !== false,
|
||||
__isAction: true,
|
||||
};
|
||||
}
|
||||
function createTrigger(p) {
|
||||
return {
|
||||
name: p.name,
|
||||
displayName: p.displayName,
|
||||
description: p.description,
|
||||
props: p.props || {},
|
||||
type: p.type,
|
||||
__isTrigger: true,
|
||||
};
|
||||
}
|
||||
|
||||
function Piece(params) {
|
||||
this.displayName = params.displayName;
|
||||
this.description = params.description || '';
|
||||
this.auth = params.auth;
|
||||
this.logoUrl = params.logoUrl;
|
||||
this._actions = {};
|
||||
(params.actions || []).forEach(function (a) { this._actions[a.name] = a; }, this);
|
||||
this._triggers = {};
|
||||
(params.triggers || []).forEach(function (t) { this._triggers[t.name] = t; }, this);
|
||||
}
|
||||
Piece.prototype.getAction = function (n) { return this._actions[n]; };
|
||||
Piece.prototype.getTrigger = function (n) { return this._triggers[n]; };
|
||||
Piece.prototype.actions = function () { return this._actions; };
|
||||
Piece.prototype.triggers = function () { return this._triggers; };
|
||||
|
||||
// createPiece registers the constructed piece on a per-VM list; the runtime
|
||||
// reads the last one after evaluating the bundle (a bundle calls createPiece
|
||||
// exactly once at module top level).
|
||||
function createPiece(params) {
|
||||
var p = new Piece(params);
|
||||
g.__ap_pieces.push(p);
|
||||
return p;
|
||||
}
|
||||
g.__ap_pieces = [];
|
||||
|
||||
var framework = {
|
||||
Property: Property,
|
||||
PieceAuth: PieceAuth,
|
||||
createAction: createAction,
|
||||
createTrigger: createTrigger,
|
||||
createPiece: createPiece,
|
||||
PieceCategory: {},
|
||||
getAuthPropertyForValue: function () { return undefined; },
|
||||
};
|
||||
|
||||
// ---- @activepieces/pieces-common ------------------------------------------
|
||||
|
||||
var HttpMethod = {
|
||||
GET: 'GET', POST: 'POST', PUT: 'PUT', PATCH: 'PATCH', DELETE: 'DELETE', HEAD: 'HEAD',
|
||||
};
|
||||
var AuthenticationType = {
|
||||
BEARER_TOKEN: 'BEARER_TOKEN', BASIC: 'BASIC', API_KEY: 'API_KEY',
|
||||
};
|
||||
|
||||
// httpClient.sendRequest hands the request to the Go doer synchronously and
|
||||
// wraps the result in a resolved Promise so `await httpClient.sendRequest`
|
||||
// and `.then(...)` chains both work.
|
||||
var httpClient = {
|
||||
sendRequest: function (req) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
try { resolve(g.__hanzoHttpSend(req)); }
|
||||
catch (e) { reject(e); }
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
function getAccessTokenOrThrow(auth) {
|
||||
var t = auth && auth.access_token;
|
||||
if (t === undefined || t === null) throw new Error('Invalid bearer token');
|
||||
return t;
|
||||
}
|
||||
|
||||
function joinUrl(base, rel) {
|
||||
base = base || '';
|
||||
if (base.charAt(base.length - 1) !== '/') base += '/';
|
||||
if (rel.charAt(0) === '/') rel = rel.slice(1);
|
||||
return base + rel;
|
||||
}
|
||||
|
||||
// createCustomApiCallAction is the generic HTTP action every provider gets.
|
||||
// It is behaviourally identical to @activepieces/pieces-common's own: build
|
||||
// the request from propsValue (url/method/headers/queryParams/body[_type]),
|
||||
// fold in the provider authMapping, send. This is the exact action the KB
|
||||
// long-tail sync invokes (notion custom_api_call), so shimming it here is
|
||||
// what lets KB run native.
|
||||
function createCustomApiCallAction(opts) {
|
||||
opts = opts || {};
|
||||
var baseUrl = opts.baseUrl || function () { return ''; };
|
||||
var authMapping = opts.authMapping;
|
||||
var authLocation = opts.authLocation || 'headers';
|
||||
return createAction({
|
||||
name: opts.name || 'custom_api_call',
|
||||
displayName: opts.displayName || 'Custom API Call',
|
||||
description: opts.description || 'Make a custom API call to a specific endpoint',
|
||||
requireAuth: !!opts.auth,
|
||||
props: opts.props || {},
|
||||
run: async function (ctx) {
|
||||
var pv = ctx.propsValue || {};
|
||||
var method = pv.method;
|
||||
var headers = pv.headers || {};
|
||||
var queryParams = pv.queryParams || {};
|
||||
var body = pv.body;
|
||||
var bodyType = pv.body_type;
|
||||
var urlProp = pv.url;
|
||||
var urlValue = (urlProp && typeof urlProp === 'object') ? urlProp.url : urlProp;
|
||||
if (!method) throw new Error('Method is required');
|
||||
if (!urlValue) throw new Error('URL is required');
|
||||
|
||||
var authValue = authMapping ? await authMapping(ctx.auth, pv) : {};
|
||||
var fullUrl =
|
||||
(urlValue.indexOf('http://') === 0 || urlValue.indexOf('https://') === 0)
|
||||
? urlValue
|
||||
: joinUrl(baseUrl(ctx.auth), urlValue);
|
||||
|
||||
var reqHeaders = Object.assign({}, headers, authLocation === 'headers' ? authValue : {});
|
||||
var reqQuery = Object.assign({}, authLocation === 'queryParams' ? authValue : {}, queryParams);
|
||||
|
||||
var reqBody;
|
||||
if (body) {
|
||||
if (bodyType && bodyType !== 'none') reqBody = body.data;
|
||||
else if (!bodyType) reqBody = body;
|
||||
}
|
||||
|
||||
var resp = await httpClient.sendRequest({
|
||||
method: method,
|
||||
url: fullUrl,
|
||||
headers: reqHeaders,
|
||||
queryParams: reqQuery,
|
||||
body: reqBody,
|
||||
});
|
||||
return { status: resp.status, headers: resp.headers, body: resp.body };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
var common = {
|
||||
httpClient: httpClient,
|
||||
HttpMethod: HttpMethod,
|
||||
AuthenticationType: AuthenticationType,
|
||||
getAccessTokenOrThrow: getAccessTokenOrThrow,
|
||||
createCustomApiCallAction: createCustomApiCallAction,
|
||||
// HttpError is referenced as a type by some pieces; a constructor keeps
|
||||
// `instanceof`/`new HttpError` from throwing if executed.
|
||||
HttpError: function HttpError(request, cause) { this.request = request; this.cause = cause; },
|
||||
};
|
||||
|
||||
// ---- @activepieces/shared (pure helpers pieces import) --------------------
|
||||
|
||||
function isNil(v) { return v === null || v === undefined; }
|
||||
function isEmpty(v) {
|
||||
if (isNil(v)) return true;
|
||||
if (typeof v === 'string' || Array.isArray(v)) return v.length === 0;
|
||||
if (typeof v === 'object') return Object.keys(v).length === 0;
|
||||
return false;
|
||||
}
|
||||
function assertNotNullOrUndefined(v, name) {
|
||||
if (isNil(v)) throw new Error('Expected ' + (name || 'value') + ' to be defined, received ' + v);
|
||||
return v;
|
||||
}
|
||||
var shared = {
|
||||
isNil: isNil,
|
||||
isEmpty: isEmpty,
|
||||
assertNotNullOrUndefined: assertNotNullOrUndefined,
|
||||
PieceCategory: {},
|
||||
TriggerStrategy: { POLLING: 'POLLING', WEBHOOK: 'WEBHOOK', APP_WEBHOOK: 'APP_WEBHOOK' },
|
||||
};
|
||||
|
||||
// ---- module resolution -----------------------------------------------------
|
||||
|
||||
g.__ap_modules = {
|
||||
'@activepieces/pieces-framework': framework,
|
||||
'@activepieces/pieces-common': common,
|
||||
'@activepieces/shared': shared,
|
||||
};
|
||||
g.require = function (name) {
|
||||
var m = g.__ap_modules[name];
|
||||
if (m) return m;
|
||||
throw new Error('connectorruntime: module not available in-process: ' + name);
|
||||
};
|
||||
|
||||
// __makeContext builds the ActionContext an action's run(ctx) receives.
|
||||
// auth + propsValue are the real inputs; the rest are safe in-process stubs
|
||||
// (an in-memory store, no-op files/connections/server) so a connector that
|
||||
// touches them does not throw. HTTP-only connectors ignore all but the first
|
||||
// two.
|
||||
g.__makeContext = function (auth, propsValue) {
|
||||
var mem = {};
|
||||
return {
|
||||
auth: auth,
|
||||
propsValue: propsValue || {},
|
||||
store: {
|
||||
get: async function (k) { return k in mem ? mem[k] : null; },
|
||||
put: async function (k, v) { mem[k] = v; return v; },
|
||||
delete: async function (k) { delete mem[k]; },
|
||||
},
|
||||
files: {
|
||||
write: async function (o) { return (o && o.data) || null; },
|
||||
},
|
||||
connections: {
|
||||
get: async function () { return null; },
|
||||
},
|
||||
server: { apiUrl: '', publicUrl: '', token: '' },
|
||||
project: { id: '', externalId: async function () { return undefined; } },
|
||||
run: { id: '', stop: function () {}, pause: function () {} },
|
||||
generateResumeUrl: function () { return ''; },
|
||||
flows: { current: { id: '', version: { id: '' } } },
|
||||
step: { name: '' },
|
||||
payload: {},
|
||||
};
|
||||
};
|
||||
})(globalThis);
|
||||
@@ -39,7 +39,7 @@ import (
|
||||
// drive the SAME single implementation. The subsystem is a stateless orchestrator over
|
||||
// framework (which holds the state) + the AI/social edges — it opens no store of its own.
|
||||
//
|
||||
// Registration is a one-line cloud.MountSpec in subsystems.Wire() (after framework +
|
||||
// Registration is a one-line cloud.MountSpec in apps.Wire() (after framework +
|
||||
// knowledge, before the AI /v1/* catch-all); the module fixtures + lifecycle hooks are
|
||||
// registered in doctypes.go's init(), process-global and mount-order-independent.
|
||||
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
//go:build controlplane
|
||||
|
||||
package controlplane
|
||||
|
||||
// cert.go — seam (c) crypto core: the REAL control-plane finality certificate.
|
||||
//
|
||||
// This is the independent-signature weighted-quorum certificate the design
|
||||
// chose over the blocked threshold-Pulsar path (control-plane-increment-2.md
|
||||
// §2c). Each pod signs the canonical quorum message INDEPENDENTLY with its own
|
||||
// ML-DSA-65 identity key (seam a) under a DISTINCT cert context; the cert is a
|
||||
// quasar.ConsensusCert carrying one EvidenceWeightedSigSet leg (a
|
||||
// WeightedQuorumCert of N independent FIPS-204 signatures + a weighted-Merkle
|
||||
// quorum). Verification is quasar.VerifyConsensusCert under a control-plane
|
||||
// policy — the shipped, audited Gen-3 verifier. No DKG, no threshold aggregate,
|
||||
// no unshipped luxfi/pulsar core: soundness rests only on stock FIPS-204 verify
|
||||
// + the weighted-validator-set Merkle commitment.
|
||||
//
|
||||
// This file is self-contained crypto (independent of the ceremony wiring): it
|
||||
// composes and verifies a cert from a position + the validator key set + a set
|
||||
// of collected signatures. cert_test.go exercises it directly.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/luxfi/consensus/config"
|
||||
"github.com/luxfi/consensus/protocol/quasar"
|
||||
"github.com/luxfi/crypto/mldsa"
|
||||
pulsar "github.com/luxfi/pulsar/pkg/pulsar"
|
||||
)
|
||||
|
||||
const (
|
||||
// controlPlaneCertVersion pins the quasar.ConsensusCert envelope version the
|
||||
// control plane emits. Wire-stable; a quasar bump surfaces loudly as
|
||||
// ErrConsensusCertVersion (the value is bound into the domain message).
|
||||
controlPlaneCertVersion uint16 = 1
|
||||
|
||||
// controlPlanePolicyID is the single control-plane cert policy the store
|
||||
// resolves. One posture, one policy — decomplected from the cert bytes.
|
||||
controlPlanePolicyID uint32 = 1
|
||||
|
||||
// controlPlaneQCType names the certificate role (finality) bound into the
|
||||
// signed quorum message so a signature for one role cannot be replayed as
|
||||
// another.
|
||||
controlPlaneQCType uint8 = 0x03
|
||||
|
||||
// mldsa65ParamByte is the ML-DSA-65 parameter-set wire byte
|
||||
// (config.SigSchemeID / QuorumSchemeMLDSA65). Bound into every validator
|
||||
// leaf and signer record.
|
||||
mldsa65ParamByte uint8 = 0x42
|
||||
)
|
||||
|
||||
// certContext is the FIPS 204 §5.2 domain-separation context every control-plane
|
||||
// CERT signature is produced and verified under. It is DELIBERATELY DISTINCT from
|
||||
// popContext (RED finding R4): reusing the proof-of-possession context for cert
|
||||
// signing would allow a PoP signature to be cross-protocol-confused with a cert
|
||||
// signature. It is also the QuorumVerifierConfig.Context the weighted-sig-set
|
||||
// verifier checks every ML-DSA record under (contextForScheme → cfg.Context), so
|
||||
// signer and verifier are pinned to the same context by construction.
|
||||
var certContext = []byte("hanzo/controlplane/cert/v1")
|
||||
|
||||
var (
|
||||
// ErrNoValidators rejects composing/verifying against an empty key set.
|
||||
ErrNoValidators = errors.New("controlplane: empty validator key set")
|
||||
// ErrSignerNotInSet rejects a collected signature from a node absent from
|
||||
// the validator key set (a rogue signer).
|
||||
ErrSignerNotInSet = errors.New("controlplane: signature from a node not in the validator set")
|
||||
// ErrInsufficientCertSigners rejects composing a cert below the quorum weight.
|
||||
ErrInsufficientCertSigners = errors.New("controlplane: collected signatures below quorum weight")
|
||||
// ErrCertPositionMismatch rejects a cert whose self-described position does
|
||||
// not match the block the caller expects it to certify. VerifyConsensusCert
|
||||
// pins the validator set + policy but NOT the caller's height/round/block, so
|
||||
// the caller MUST bind the cert to its expected position — otherwise a valid
|
||||
// cert for a DIFFERENT block/height/round would be accepted here.
|
||||
ErrCertPositionMismatch = errors.New("controlplane: cert position does not match the expected block")
|
||||
// ErrUnsafeCertFloor is the two-threshold self-defence (RED finding 1): the
|
||||
// cert quorum floor MUST be the byzantine-safe BFT quorum for the validator-
|
||||
// set size, NEVER a wallet-custody t = floor(n/2)+1. For n=5 that t is 3, and
|
||||
// 2*3 is not > N+f = 6, so a 3-of-5 cert could finalize two conflicting
|
||||
// blocks. The core enforces whatever floor it is handed AND refuses a
|
||||
// structurally unsafe one — so a mis-wired driver cannot get an unsafe cert
|
||||
// verified even if it passes the wrong threshold.
|
||||
ErrUnsafeCertFloor = errors.New("controlplane: cert quorum floor is not the byzantine-safe BFT quorum for the validator set (a wallet-custody t must never be a cert floor)")
|
||||
)
|
||||
|
||||
// guardBFTFloor fails closed unless quorumWeight is a byzantine-safe cert floor
|
||||
// for a set of n validators: at least the canonical BFT quorum (2n/3+1), no more
|
||||
// than n, and a (n, quorum, f) triple that satisfies 2q > n+f. This is the
|
||||
// self-defence RED finding 1 mandates — it is derived from len(keys), so a
|
||||
// future driver that mis-wires the wallet-custody t (=3 for n=5) instead of the
|
||||
// BFT quorum (=4) cannot get a cert composed OR verified.
|
||||
func guardBFTFloor(n int, quorumWeight uint64) error {
|
||||
if n <= 0 {
|
||||
return ErrNoValidators
|
||||
}
|
||||
floor := bftQuorum(n)
|
||||
if quorumWeight < uint64(floor) {
|
||||
return fmt.Errorf("%w: quorum %d < BFT floor %d for N=%d", ErrUnsafeCertFloor, quorumWeight, floor, n)
|
||||
}
|
||||
if quorumWeight > uint64(n) {
|
||||
return fmt.Errorf("%w: quorum %d exceeds validator count %d", ErrUnsafeCertFloor, quorumWeight, n)
|
||||
}
|
||||
if err := checkQuorumSafety(n, int(quorumWeight), bftFaultTolerance(n)); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrUnsafeCertFloor, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// certPosition is the consensus position a control-plane cert finalizes. It is
|
||||
// the minimal, ceremony-independent input to compose/verify a cert, so this
|
||||
// crypto core is testable in isolation.
|
||||
type certPosition struct {
|
||||
NetworkID uint32
|
||||
ChainID uint32
|
||||
Epoch uint64
|
||||
Height uint64
|
||||
Round uint32
|
||||
BlockHash [32]byte // the block / value being finalized (inner ValueHash + envelope BlockHash)
|
||||
ParentRoot [32]byte // chain-extension anchor bound into the round digest
|
||||
}
|
||||
|
||||
// validatorKey pairs a pod's node id with its ML-DSA-65 identity public key —
|
||||
// the (id, key) the weighted-validator-set leaf commits to. Each pod carries
|
||||
// unit voting weight; the quorum floor is a signer COUNT.
|
||||
type validatorKey struct {
|
||||
Node pulsar.NodeID
|
||||
Pub *mldsa.PublicKey
|
||||
}
|
||||
|
||||
// sortedValidatorKeys returns the key set sorted strictly by node id (the
|
||||
// canonical leaf order BuildWeightedValidatorSet imposes). Deterministic across
|
||||
// all pods so every voter builds the byte-identical validator set.
|
||||
func sortedValidatorKeys(keys map[pulsar.NodeID]*mldsa.PublicKey) []validatorKey {
|
||||
out := make([]validatorKey, 0, len(keys))
|
||||
for n, k := range keys {
|
||||
out = append(out, validatorKey{Node: n, Pub: k})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return bytesLessNode(out[i].Node, out[j].Node)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func bytesLessNode(a, b pulsar.NodeID) bool {
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return a[i] < b[i]
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// buildValidatorSet builds the weighted-validator-set commitment (the shipped
|
||||
// quasar.WeightedValidatorSet) from the pod key set. Unit weights; ML-DSA-65
|
||||
// parameter byte; key version 0 (rotation is seam-b's concern). Deterministic.
|
||||
func buildValidatorSet(epoch uint64, keys map[pulsar.NodeID]*mldsa.PublicKey) (*quasar.WeightedValidatorSet, error) {
|
||||
if len(keys) == 0 {
|
||||
return nil, ErrNoValidators
|
||||
}
|
||||
sorted := sortedValidatorKeys(keys)
|
||||
leaves := make([]quasar.WeightedValidatorLeaf, 0, len(sorted))
|
||||
for _, vk := range sorted {
|
||||
leaves = append(leaves, quasar.WeightedValidatorLeaf{
|
||||
ValidatorID: vk.Node,
|
||||
PublicKey: vk.Pub.Bytes(),
|
||||
VotingWeight: 1,
|
||||
ParameterSetID: mldsa65ParamByte,
|
||||
KeyVersion: 0,
|
||||
})
|
||||
}
|
||||
return quasar.BuildWeightedValidatorSet(epoch, leaves)
|
||||
}
|
||||
|
||||
// certEnvelope builds the round-digest envelope the quorum message is derived
|
||||
// from. The posture axes come from the canonical StrictPQ profile, but the
|
||||
// proof backend/format/verifier are pinned to the DIRECT weighted-quorum trust
|
||||
// model (a cert produced under this backend cannot be re-presented under a STARK
|
||||
// backend's envelope). The committee/group-key/signer roots are bound non-zero
|
||||
// (ComputeRoundDigest refuses zero security-relevant inputs), deterministically
|
||||
// derived from the set root so compose and verify agree byte-for-byte.
|
||||
func certEnvelope(pos certPosition, vsetRoot [48]byte, quorumWeight uint64) quasar.QuorumMessageEnvelope {
|
||||
p := config.StrictPQProfile
|
||||
committee := root48("cp-cert-committee", vsetRoot[:])
|
||||
groupKey := root48("cp-cert-groupkey", vsetRoot[:])
|
||||
signerSet := root48("cp-cert-signerset", vsetRoot[:])
|
||||
return quasar.QuorumMessageEnvelope{
|
||||
ProfileID: p.ProfileID,
|
||||
HashSuite: p.HashSuiteID,
|
||||
IdentityScheme: config.IdentitySchemeID(p.IdentitySchemeID),
|
||||
FinalityScheme: p.FinalitySchemeID,
|
||||
ProofPolicy: p.ProofPolicyID,
|
||||
ProofBackend: config.ProofBackendDirectWeightedQuorum,
|
||||
ProofFormat: config.ProofFormatDirectWeightedQuorumV1,
|
||||
VerifierID: config.VerifierDirectWeightedQuorumPQ,
|
||||
EffectivePolicy: byte(p.ProfileID),
|
||||
NetworkID: pos.NetworkID,
|
||||
|
||||
ChainID: pos.ChainID,
|
||||
Epoch: pos.Epoch,
|
||||
Height: pos.Height,
|
||||
Round: pos.Round,
|
||||
ValueHash: pos.BlockHash,
|
||||
QCType: controlPlaneQCType,
|
||||
ValidatorSetRoot: vsetRoot,
|
||||
QuorumThreshold: quorumWeight,
|
||||
|
||||
ParentQBlockHash: pos.ParentRoot,
|
||||
CommitteeRoot: committee,
|
||||
GroupPublicKeyHash: groupKey,
|
||||
SignerSetCommit: signerSet,
|
||||
}
|
||||
}
|
||||
|
||||
// CertSigningMessage is the canonical quorum message every pod signs for this
|
||||
// position + validator set + quorum. All pods derive it identically (the set
|
||||
// root is a deterministic commitment to every pod's registered key), so each
|
||||
// signs the SAME bytes independently. This is the value passed to
|
||||
// mldsa PrivateKey.SignCtxDeterministic(msg, certContext).
|
||||
func CertSigningMessage(pos certPosition, keys map[pulsar.NodeID]*mldsa.PublicKey, quorumWeight uint64) ([]byte, error) {
|
||||
vset, err := buildValidatorSet(pos.Epoch, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return quasar.QuorumConsensusMessage(certEnvelope(pos, vset.Root(), quorumWeight))
|
||||
}
|
||||
|
||||
// signRecords assembles the per-signer QuorumSignerRecords for the collected
|
||||
// signatures, attaching each signer's weighted-Merkle inclusion proof against
|
||||
// the set root. A signature from a node absent from the set is a rogue signer
|
||||
// (ErrSignerNotInSet) — refused at assembly so it can never reach the cert.
|
||||
func signRecords(vset *quasar.WeightedValidatorSet, keys map[pulsar.NodeID]*mldsa.PublicKey, sigs map[pulsar.NodeID][]byte) ([]quasar.QuorumSignerRecord, error) {
|
||||
leaves := vset.Leaves() // canonical sorted order
|
||||
indexOf := make(map[pulsar.NodeID]int, len(leaves))
|
||||
for i := range leaves {
|
||||
var id pulsar.NodeID
|
||||
copy(id[:], leaves[i].ValidatorID[:])
|
||||
indexOf[id] = i
|
||||
}
|
||||
records := make([]quasar.QuorumSignerRecord, 0, len(sigs))
|
||||
for node, sig := range sigs {
|
||||
pub, ok := keys[node]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %x", ErrSignerNotInSet, node[:8])
|
||||
}
|
||||
idx, ok := indexOf[node]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %x", ErrSignerNotInSet, node[:8])
|
||||
}
|
||||
proof, err := vset.InclusionProof(idx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, quasar.QuorumSignerRecord{
|
||||
ValidatorID: node,
|
||||
PublicKey: pub.Bytes(),
|
||||
VotingWeight: 1,
|
||||
Scheme: quasar.QuorumSchemeMLDSA65,
|
||||
ParamSetID: mldsa65ParamByte,
|
||||
KeyVersion: 0,
|
||||
MerklePath: proof,
|
||||
Signature: append([]byte(nil), sig...),
|
||||
})
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// ComposeControlPlaneCert builds the REAL control-plane finality certificate: a
|
||||
// quasar.ConsensusCert carrying one EvidenceWeightedSigSet leg over the collected
|
||||
// independent ML-DSA-65 signatures. Permissionless and deterministic — no
|
||||
// secrets, no randomness. Every honest voter holding the same (position, keys,
|
||||
// signature set) composes the byte-identical cert.
|
||||
func ComposeControlPlaneCert(pos certPosition, keys map[pulsar.NodeID]*mldsa.PublicKey, sigs map[pulsar.NodeID][]byte, quorumWeight uint64) (*quasar.ConsensusCert, error) {
|
||||
// Two-threshold self-defence (RED finding 1): refuse an unsafe floor derived
|
||||
// from the validator-set size, so a mis-wired wallet-custody t can never
|
||||
// compose a cert.
|
||||
if err := guardBFTFloor(len(keys), quorumWeight); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if uint64(len(sigs)) < quorumWeight {
|
||||
return nil, fmt.Errorf("%w: have %d need %d", ErrInsufficientCertSigners, len(sigs), quorumWeight)
|
||||
}
|
||||
vset, err := buildValidatorSet(pos.Epoch, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records, err := signRecords(vset, keys, sigs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wqc, err := quasar.BuildWeightedQuorumCert(quasar.QuorumCertParams{
|
||||
ChainID: pos.ChainID,
|
||||
Epoch: pos.Epoch,
|
||||
Height: pos.Height,
|
||||
Round: pos.Round,
|
||||
ValueHash: pos.BlockHash,
|
||||
QCType: controlPlaneQCType,
|
||||
ValidatorSetRoot: vset.Root(),
|
||||
QuorumThreshold: quorumWeight,
|
||||
}, records)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wqcBytes, err := wqc.MarshalBinary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := config.StrictPQProfile
|
||||
cert := &quasar.ConsensusCert{
|
||||
Version: controlPlaneCertVersion,
|
||||
Profile: byte(p.ProfileID),
|
||||
ChainID: pos.ChainID,
|
||||
Epoch: pos.Epoch,
|
||||
Height: pos.Height,
|
||||
Round: pos.Round,
|
||||
BlockHash: pos.BlockHash,
|
||||
ValidatorSetRoot: vset.Root(),
|
||||
PolicyID: controlPlanePolicyID,
|
||||
RequiredLegsRoot: quasar.HashRequiredLegs(controlPlanePolicy{quorumWeight}.RequiredLegs()),
|
||||
AggregateWeight: wqc.AggregateWeight,
|
||||
Evidence: []quasar.LegEvidence{{
|
||||
Leg: quasar.LegSpec{Kind: quasar.LegPulsarMLDSA, ParamSetID: mldsa65ParamByte},
|
||||
Mode: quasar.EvidenceWeightedSigSet,
|
||||
Payload: wqcBytes,
|
||||
}},
|
||||
}
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
// VerifyControlPlaneCert verifies a cert with the shipped, audited Gen-3
|
||||
// verifier quasar.VerifyConsensusCert under the control-plane policy + the
|
||||
// verifier-pinned validator set. NEVER the structural QuasarCert.Verify. Returns
|
||||
// the verifier's typed error verbatim on failure.
|
||||
func VerifyControlPlaneCert(pos certPosition, keys map[pulsar.NodeID]*mldsa.PublicKey, quorumWeight uint64, cert *quasar.ConsensusCert) error {
|
||||
if cert == nil {
|
||||
return quasar.ErrConsensusCertNil
|
||||
}
|
||||
// Bind the cert to the EXPECTED position (the caller's block) BEFORE the
|
||||
// cryptographic verify. A cert self-describes its position; without this a
|
||||
// perfectly valid cert for a different block/height/round would verify here.
|
||||
if cert.ChainID != pos.ChainID || cert.Epoch != pos.Epoch ||
|
||||
cert.Height != pos.Height || cert.Round != pos.Round ||
|
||||
cert.BlockHash != pos.BlockHash {
|
||||
return ErrCertPositionMismatch
|
||||
}
|
||||
// Two-threshold self-defence (RED finding 1): a mis-wired wallet-custody t
|
||||
// cannot verify even if a driver passes it here — the floor is re-derived
|
||||
// from the validator-set size and must be byzantine-safe.
|
||||
if err := guardBFTFloor(len(keys), quorumWeight); err != nil {
|
||||
return err
|
||||
}
|
||||
vset, err := buildValidatorSet(pos.Epoch, keys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store := controlPlaneStore{policy: controlPlanePolicy{quorumWeight}}
|
||||
vs := &controlPlaneValidatorSet{set: vset, pos: pos, quorumWeight: quorumWeight}
|
||||
return quasar.VerifyConsensusCert(store, vs, cert)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Policy + validator-set interface implementations (the decomplected inputs to
|
||||
// quasar.VerifyConsensusCert). The control plane is its OWN policy domain: a
|
||||
// small fixed committee whose only required leg is the independent-sig
|
||||
// weighted-sig-set PQ leg. No threshold-sig, no classical, no STARK.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// controlPlanePolicy is the control-plane cert posture: exactly one required
|
||||
// leg — LegPulsarMLDSA proven by EvidenceWeightedSigSet — at the BFT quorum
|
||||
// weight floor. Classical and threshold-sig are forbidden.
|
||||
type controlPlanePolicy struct {
|
||||
quorumWeight uint64
|
||||
}
|
||||
|
||||
func (controlPlanePolicy) RequiredLegs() []quasar.LegSpec {
|
||||
return []quasar.LegSpec{{Kind: quasar.LegPulsarMLDSA, ParamSetID: mldsa65ParamByte}}
|
||||
}
|
||||
|
||||
func (controlPlanePolicy) Allows(leg quasar.LegSpec, mode quasar.EvidenceMode, paramSet uint8) bool {
|
||||
return leg.Kind == quasar.LegPulsarMLDSA &&
|
||||
mode == quasar.EvidenceWeightedSigSet &&
|
||||
paramSet == mldsa65ParamByte
|
||||
}
|
||||
|
||||
func (p controlPlanePolicy) ThresholdWeight() uint64 { return p.quorumWeight }
|
||||
|
||||
func (controlPlanePolicy) AllowsClassicalScheme(quasar.ClassicalScheme) bool { return false }
|
||||
|
||||
// controlPlaneStore resolves the single control-plane policy. The verifier loads
|
||||
// policy from here, never from the cert (invariant I1).
|
||||
type controlPlaneStore struct {
|
||||
policy controlPlanePolicy
|
||||
}
|
||||
|
||||
func (s controlPlaneStore) Policy(_ uint32, _ uint64, policyID uint32) (quasar.ConsensusCertPolicy, error) {
|
||||
if policyID != controlPlanePolicyID {
|
||||
return nil, fmt.Errorf("controlplane: unknown policy id %d", policyID)
|
||||
}
|
||||
return s.policy, nil
|
||||
}
|
||||
|
||||
// controlPlaneValidatorSet is the committed epoch validator set the verifier
|
||||
// pins the cert against. It wraps the weighted set and supplies the weighted-
|
||||
// sig-set verify axes (allowed schemes, the DISTINCT cert FIPS context, and the
|
||||
// Direct-weighted-quorum envelope). No threshold group keys, no classical keys.
|
||||
type controlPlaneValidatorSet struct {
|
||||
set *quasar.WeightedValidatorSet
|
||||
pos certPosition
|
||||
quorumWeight uint64
|
||||
}
|
||||
|
||||
func (v *controlPlaneValidatorSet) Root() [48]byte { return v.set.Root() }
|
||||
func (v *controlPlaneValidatorSet) Epoch() uint64 { return v.set.Epoch() }
|
||||
|
||||
func (v *controlPlaneValidatorSet) WeightedConfig() quasar.QuorumVerifierConfig {
|
||||
return quasar.QuorumVerifierConfig{
|
||||
AllowedSchemes: map[quasar.QuorumSchemeID]bool{quasar.QuorumSchemeMLDSA65: true},
|
||||
Context: certContext,
|
||||
MinThreshold: v.quorumWeight,
|
||||
}
|
||||
}
|
||||
|
||||
func (v *controlPlaneValidatorSet) WeightedEnvelope() quasar.QuorumMessageEnvelope {
|
||||
return certEnvelope(v.pos, v.set.Root(), v.quorumWeight)
|
||||
}
|
||||
|
||||
func (v *controlPlaneValidatorSet) ThresholdGroupKey(quasar.LegKind) (quasar.ThresholdGroupKey, bool) {
|
||||
return quasar.ThresholdGroupKey{}, false
|
||||
}
|
||||
|
||||
func (v *controlPlaneValidatorSet) ClassicalAggregateKey(quasar.ClassicalScheme) ([]byte, bool) {
|
||||
return nil, false
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
//go:build controlplane
|
||||
|
||||
package controlplane
|
||||
|
||||
// cert_test.go — standalone crypto tests for the seam (c) real certificate.
|
||||
// These exercise ComposeControlPlaneCert / VerifyControlPlaneCert directly
|
||||
// (independent of the ceremony wiring) and are the acceptance suite for the
|
||||
// independent-sig weighted-quorum cert: a real cert verifies under policy; a
|
||||
// cert missing a leg, below quorum, or carrying a forged / rogue / wrong-context
|
||||
// signature is REJECTED with the exact typed error.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/consensus/config"
|
||||
"github.com/luxfi/consensus/protocol/quasar"
|
||||
"github.com/luxfi/crypto/mldsa"
|
||||
pulsar "github.com/luxfi/pulsar/pkg/pulsar"
|
||||
)
|
||||
|
||||
// certTestPods mints n pods each with a fresh ML-DSA-65 identity keypair,
|
||||
// returning the public key set, the private keys, and the node order.
|
||||
func certTestPods(t *testing.T, n int) (map[pulsar.NodeID]*mldsa.PublicKey, map[pulsar.NodeID]*mldsa.PrivateKey, []pulsar.NodeID) {
|
||||
t.Helper()
|
||||
keys := map[pulsar.NodeID]*mldsa.PublicKey{}
|
||||
privs := map[pulsar.NodeID]*mldsa.PrivateKey{}
|
||||
order := make([]pulsar.NodeID, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
node := NodeIDFromName(fmt.Sprintf("cloud-%d", i))
|
||||
sk, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
|
||||
if err != nil {
|
||||
t.Fatalf("keygen %d: %v", i, err)
|
||||
}
|
||||
keys[node] = sk.PublicKey
|
||||
privs[node] = sk
|
||||
order = append(order, node)
|
||||
}
|
||||
return keys, privs, order
|
||||
}
|
||||
|
||||
func certTestPosition() certPosition {
|
||||
return certPosition{
|
||||
NetworkID: 0x48414e5a, // "HANZ"
|
||||
ChainID: 0x43504c4e, // "CPLN"
|
||||
Epoch: 1,
|
||||
Height: 7,
|
||||
Round: 0,
|
||||
BlockHash: hash32("cp-cert-test-block", []byte("shard-X->cloud-1")),
|
||||
ParentRoot: hash32("cp-cert-test-parent", []byte("genesis")),
|
||||
}
|
||||
}
|
||||
|
||||
// signQuorum has the first k pods each independently sign the canonical cert
|
||||
// message under the DISTINCT cert context.
|
||||
func signQuorum(t *testing.T, pos certPosition, keys map[pulsar.NodeID]*mldsa.PublicKey, privs map[pulsar.NodeID]*mldsa.PrivateKey, order []pulsar.NodeID, quorumWeight uint64, k int) map[pulsar.NodeID][]byte {
|
||||
t.Helper()
|
||||
msg, err := CertSigningMessage(pos, keys, quorumWeight)
|
||||
if err != nil {
|
||||
t.Fatalf("cert message: %v", err)
|
||||
}
|
||||
sigs := map[pulsar.NodeID][]byte{}
|
||||
for i := 0; i < k; i++ {
|
||||
node := order[i]
|
||||
sig, err := privs[node].SignCtxDeterministic(msg, certContext)
|
||||
if err != nil {
|
||||
t.Fatalf("sign %d: %v", i, err)
|
||||
}
|
||||
sigs[node] = sig
|
||||
}
|
||||
return sigs
|
||||
}
|
||||
|
||||
// TestControlPlaneCert_VerifiesUnderPolicy — the happy path: a 4-of-5 quorum of
|
||||
// independent ML-DSA-65 signatures composes a real ConsensusCert that
|
||||
// VerifyConsensusCert accepts under the control-plane weighted-sig-set policy.
|
||||
func TestControlPlaneCert_VerifiesUnderPolicy(t *testing.T) {
|
||||
keys, privs, order := certTestPods(t, 5)
|
||||
pos := certTestPosition()
|
||||
const quorum uint64 = 4
|
||||
sigs := signQuorum(t, pos, keys, privs, order, quorum, 4)
|
||||
|
||||
cert, err := ComposeControlPlaneCert(pos, keys, sigs, quorum)
|
||||
if err != nil {
|
||||
t.Fatalf("compose: %v", err)
|
||||
}
|
||||
if err := VerifyControlPlaneCert(pos, keys, quorum, cert); err != nil {
|
||||
t.Fatalf("verify a real quorum cert: %v", err)
|
||||
}
|
||||
// The cert routes through the shipped Gen-3 verifier — confirm it is a
|
||||
// weighted-sig-set leg, not a structural pass.
|
||||
if len(cert.Evidence) != 1 || cert.Evidence[0].Mode != quasar.EvidenceWeightedSigSet {
|
||||
t.Fatalf("cert is not a single weighted-sig-set leg: %+v", cert.Evidence)
|
||||
}
|
||||
}
|
||||
|
||||
// TestControlPlaneCert_FullQuorumVerifies — all 5 sign; still verifies (weight
|
||||
// above the floor).
|
||||
func TestControlPlaneCert_FullQuorumVerifies(t *testing.T) {
|
||||
keys, privs, order := certTestPods(t, 5)
|
||||
pos := certTestPosition()
|
||||
const quorum uint64 = 4
|
||||
sigs := signQuorum(t, pos, keys, privs, order, quorum, 5)
|
||||
cert, err := ComposeControlPlaneCert(pos, keys, sigs, quorum)
|
||||
if err != nil {
|
||||
t.Fatalf("compose: %v", err)
|
||||
}
|
||||
if err := VerifyControlPlaneCert(pos, keys, quorum, cert); err != nil {
|
||||
t.Fatalf("verify full quorum: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestControlPlaneCert_Deterministic — two honest composers over the same inputs
|
||||
// produce the byte-identical evidence payload (deterministic ML-DSA + permissionless
|
||||
// assembly), so every voter converges on the same cert.
|
||||
func TestControlPlaneCert_Deterministic(t *testing.T) {
|
||||
keys, privs, order := certTestPods(t, 5)
|
||||
pos := certTestPosition()
|
||||
const quorum uint64 = 4
|
||||
sigs := signQuorum(t, pos, keys, privs, order, quorum, 4)
|
||||
a, err := ComposeControlPlaneCert(pos, keys, sigs, quorum)
|
||||
if err != nil {
|
||||
t.Fatalf("compose a: %v", err)
|
||||
}
|
||||
b, err := ComposeControlPlaneCert(pos, keys, sigs, quorum)
|
||||
if err != nil {
|
||||
t.Fatalf("compose b: %v", err)
|
||||
}
|
||||
if !bytes.Equal(a.Evidence[0].Payload, b.Evidence[0].Payload) {
|
||||
t.Fatal("honest composers diverged on the cert payload (non-deterministic)")
|
||||
}
|
||||
if a.RequiredLegsRoot != b.RequiredLegsRoot || a.ValidatorSetRoot != b.ValidatorSetRoot {
|
||||
t.Fatal("honest composers diverged on the cert header roots")
|
||||
}
|
||||
}
|
||||
|
||||
// TestControlPlaneCert_BelowQuorumRejected — fewer than quorum signatures cannot
|
||||
// compose a cert (fail-closed at assembly).
|
||||
func TestControlPlaneCert_BelowQuorumRejected(t *testing.T) {
|
||||
keys, privs, order := certTestPods(t, 5)
|
||||
pos := certTestPosition()
|
||||
const quorum uint64 = 4
|
||||
sigs := signQuorum(t, pos, keys, privs, order, quorum, 3) // only 3
|
||||
if _, err := ComposeControlPlaneCert(pos, keys, sigs, quorum); !errors.Is(err, ErrInsufficientCertSigners) {
|
||||
t.Fatalf("below quorum: want ErrInsufficientCertSigners, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestControlPlaneCert_MissingLegRejected — a cert with the required PQ leg
|
||||
// stripped is rejected by the envelope (I5).
|
||||
func TestControlPlaneCert_MissingLegRejected(t *testing.T) {
|
||||
keys, privs, order := certTestPods(t, 5)
|
||||
pos := certTestPosition()
|
||||
const quorum uint64 = 4
|
||||
sigs := signQuorum(t, pos, keys, privs, order, quorum, 4)
|
||||
cert, err := ComposeControlPlaneCert(pos, keys, sigs, quorum)
|
||||
if err != nil {
|
||||
t.Fatalf("compose: %v", err)
|
||||
}
|
||||
cert.Evidence = nil // strip the required leg
|
||||
if err := VerifyControlPlaneCert(pos, keys, quorum, cert); !errors.Is(err, quasar.ErrMissingRequiredLeg) {
|
||||
t.Fatalf("missing leg: want ErrMissingRequiredLeg, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestControlPlaneCert_ForgedSigRejected — a correctly-formed signature under an
|
||||
// ATTACKER key, attributed to a legitimate validator, is rejected by the stock
|
||||
// FIPS-204 verify inside the weighted-sig-set predicate.
|
||||
func TestControlPlaneCert_ForgedSigRejected(t *testing.T) {
|
||||
keys, privs, order := certTestPods(t, 5)
|
||||
pos := certTestPosition()
|
||||
const quorum uint64 = 4
|
||||
sigs := signQuorum(t, pos, keys, privs, order, quorum, 4)
|
||||
|
||||
msg, err := CertSigningMessage(pos, keys, quorum)
|
||||
if err != nil {
|
||||
t.Fatalf("cert message: %v", err)
|
||||
}
|
||||
attacker, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
|
||||
if err != nil {
|
||||
t.Fatalf("attacker keygen: %v", err)
|
||||
}
|
||||
badSig, err := attacker.SignCtxDeterministic(msg, certContext)
|
||||
if err != nil {
|
||||
t.Fatalf("attacker sign: %v", err)
|
||||
}
|
||||
sigs[order[0]] = badSig // legit validator id, attacker signature
|
||||
|
||||
cert, err := ComposeControlPlaneCert(pos, keys, sigs, quorum) // assembly does not verify sigs
|
||||
if err != nil {
|
||||
t.Fatalf("compose: %v", err)
|
||||
}
|
||||
if err := VerifyControlPlaneCert(pos, keys, quorum, cert); !errors.Is(err, quasar.ErrQCSigInvalid) {
|
||||
t.Fatalf("forged sig: want ErrQCSigInvalid, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestControlPlaneCert_RogueSignerRejected — a signature from a node absent from
|
||||
// the validator key set is refused at assembly (never reaches the cert).
|
||||
func TestControlPlaneCert_RogueSignerRejected(t *testing.T) {
|
||||
keys, privs, order := certTestPods(t, 5)
|
||||
pos := certTestPosition()
|
||||
const quorum uint64 = 4
|
||||
sigs := signQuorum(t, pos, keys, privs, order, quorum, 4)
|
||||
|
||||
msg, _ := CertSigningMessage(pos, keys, quorum)
|
||||
rogue := NodeIDFromName("attacker-pod")
|
||||
rogueKey, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
|
||||
if err != nil {
|
||||
t.Fatalf("rogue keygen: %v", err)
|
||||
}
|
||||
rsig, _ := rogueKey.SignCtxDeterministic(msg, certContext)
|
||||
sigs[rogue] = rsig // a node not in the registered set
|
||||
|
||||
if _, err := ComposeControlPlaneCert(pos, keys, sigs, quorum); !errors.Is(err, ErrSignerNotInSet) {
|
||||
t.Fatalf("rogue signer: want ErrSignerNotInSet, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestControlPlaneCert_WrongPositionRejected — a valid cert for one block is
|
||||
// rejected when a verifier expects a different block/height (cross-position
|
||||
// binding; ErrCertPositionMismatch).
|
||||
func TestControlPlaneCert_WrongPositionRejected(t *testing.T) {
|
||||
keys, privs, order := certTestPods(t, 5)
|
||||
pos := certTestPosition()
|
||||
const quorum uint64 = 4
|
||||
sigs := signQuorum(t, pos, keys, privs, order, quorum, 4)
|
||||
cert, err := ComposeControlPlaneCert(pos, keys, sigs, quorum)
|
||||
if err != nil {
|
||||
t.Fatalf("compose: %v", err)
|
||||
}
|
||||
other := pos
|
||||
other.Height = pos.Height + 1
|
||||
if err := VerifyControlPlaneCert(other, keys, quorum, cert); !errors.Is(err, ErrCertPositionMismatch) {
|
||||
t.Fatalf("wrong position: want ErrCertPositionMismatch, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestControlPlaneCert_WrongValidatorSetRejected — a cert built for one key set
|
||||
// is rejected against a different key set (validator-set root pinned by the
|
||||
// verifier, I3).
|
||||
func TestControlPlaneCert_WrongValidatorSetRejected(t *testing.T) {
|
||||
keys, privs, order := certTestPods(t, 5)
|
||||
pos := certTestPosition()
|
||||
const quorum uint64 = 4
|
||||
sigs := signQuorum(t, pos, keys, privs, order, quorum, 4)
|
||||
cert, err := ComposeControlPlaneCert(pos, keys, sigs, quorum)
|
||||
if err != nil {
|
||||
t.Fatalf("compose: %v", err)
|
||||
}
|
||||
otherKeys, _, _ := certTestPods(t, 5) // same names, different keys → different set root
|
||||
if err := VerifyControlPlaneCert(pos, otherKeys, quorum, cert); !errors.Is(err, quasar.ErrValidatorSetRootMismatch) {
|
||||
t.Fatalf("wrong validator set: want ErrValidatorSetRootMismatch, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestControlPlaneCert_PopContextSigRejected — proves RED finding R4: a signature
|
||||
// produced under the PoP context (popContext) is NOT accepted as a cert
|
||||
// signature, because cert signing uses a DISTINCT FIPS-204 context. This is the
|
||||
// cross-protocol-confusion defence between the identity PoP and the cert leg.
|
||||
func TestControlPlaneCert_PopContextSigRejected(t *testing.T) {
|
||||
keys, privs, order := certTestPods(t, 5)
|
||||
pos := certTestPosition()
|
||||
const quorum uint64 = 4
|
||||
|
||||
msg, err := CertSigningMessage(pos, keys, quorum)
|
||||
if err != nil {
|
||||
t.Fatalf("cert message: %v", err)
|
||||
}
|
||||
if bytes.Equal(certContext, popContext) {
|
||||
t.Fatal("R4 VIOLATED: cert context must differ from the PoP context")
|
||||
}
|
||||
sigs := map[pulsar.NodeID][]byte{}
|
||||
for i := 0; i < 4; i++ {
|
||||
node := order[i]
|
||||
// Sign the correct message but under the WRONG (PoP) context.
|
||||
sig, err := privs[node].SignCtxDeterministic(msg, popContext)
|
||||
if err != nil {
|
||||
t.Fatalf("sign %d: %v", i, err)
|
||||
}
|
||||
sigs[node] = sig
|
||||
}
|
||||
cert, err := ComposeControlPlaneCert(pos, keys, sigs, quorum)
|
||||
if err != nil {
|
||||
t.Fatalf("compose: %v", err)
|
||||
}
|
||||
if err := VerifyControlPlaneCert(pos, keys, quorum, cert); !errors.Is(err, quasar.ErrQCSigInvalid) {
|
||||
t.Fatalf("pop-context sig accepted as cert sig (R4 broken): want ErrQCSigInvalid, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// RED finding 1 (two-threshold self-guard) + finding 3 (raw-craft verify-side
|
||||
// tests). These build malicious certs by hand — bypassing the honest composer —
|
||||
// and drive them STRAIGHT at VerifyControlPlaneCert, locking the BFT-floor and
|
||||
// weighted-Merkle gates black-box rather than trusting only the audited library.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// certRecordFor builds one signer record for `node`, using an explicit public
|
||||
// key / weight / signature so a test can stuff a wrong key or an inflated
|
||||
// weight. The Merkle proof is for the node's real committed leaf; a mismatched
|
||||
// key/weight therefore reconstructs a leaf that is NOT under the root.
|
||||
func certRecordFor(t *testing.T, vset *quasar.WeightedValidatorSet, node pulsar.NodeID, pub *mldsa.PublicKey, weight uint64, sig []byte) quasar.QuorumSignerRecord {
|
||||
t.Helper()
|
||||
leaves := vset.Leaves()
|
||||
idx := -1
|
||||
for i := range leaves {
|
||||
var id pulsar.NodeID
|
||||
copy(id[:], leaves[i].ValidatorID[:])
|
||||
if id == node {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
t.Fatalf("node %x not in validator set", node[:8])
|
||||
}
|
||||
proof, err := vset.InclusionProof(idx)
|
||||
if err != nil {
|
||||
t.Fatalf("inclusion proof: %v", err)
|
||||
}
|
||||
return quasar.QuorumSignerRecord{
|
||||
ValidatorID: node,
|
||||
PublicKey: pub.Bytes(),
|
||||
VotingWeight: weight,
|
||||
Scheme: quasar.QuorumSchemeMLDSA65,
|
||||
ParamSetID: mldsa65ParamByte,
|
||||
KeyVersion: 0,
|
||||
MerklePath: proof,
|
||||
Signature: sig,
|
||||
}
|
||||
}
|
||||
|
||||
// wrapRawWQC wraps a hand-built WeightedQuorumCert into a ConsensusCert with a
|
||||
// correct control-plane header, so the verifier reaches the leg's predicate.
|
||||
func wrapRawWQC(t *testing.T, pos certPosition, vset *quasar.WeightedValidatorSet, wqc *quasar.WeightedQuorumCert) *quasar.ConsensusCert {
|
||||
t.Helper()
|
||||
b, err := wqc.MarshalBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("marshal wqc: %v", err)
|
||||
}
|
||||
p := config.StrictPQProfile
|
||||
return &quasar.ConsensusCert{
|
||||
Version: controlPlaneCertVersion,
|
||||
Profile: byte(p.ProfileID),
|
||||
ChainID: pos.ChainID,
|
||||
Epoch: pos.Epoch,
|
||||
Height: pos.Height,
|
||||
Round: pos.Round,
|
||||
BlockHash: pos.BlockHash,
|
||||
ValidatorSetRoot: vset.Root(),
|
||||
PolicyID: controlPlanePolicyID,
|
||||
RequiredLegsRoot: quasar.HashRequiredLegs(controlPlanePolicy{quorumWeight: 4}.RequiredLegs()),
|
||||
AggregateWeight: wqc.AggregateWeight,
|
||||
Evidence: []quasar.LegEvidence{{
|
||||
Leg: quasar.LegSpec{Kind: quasar.LegPulsarMLDSA, ParamSetID: mldsa65ParamByte},
|
||||
Mode: quasar.EvidenceWeightedSigSet,
|
||||
Payload: b,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// rawWQC builds a WeightedQuorumCert over the given records at a claimed
|
||||
// threshold, straight from the shipped builder.
|
||||
func rawWQC(t *testing.T, pos certPosition, vset *quasar.WeightedValidatorSet, threshold uint64, records []quasar.QuorumSignerRecord) *quasar.WeightedQuorumCert {
|
||||
t.Helper()
|
||||
wqc, err := quasar.BuildWeightedQuorumCert(quasar.QuorumCertParams{
|
||||
ChainID: pos.ChainID,
|
||||
Epoch: pos.Epoch,
|
||||
Height: pos.Height,
|
||||
Round: pos.Round,
|
||||
ValueHash: pos.BlockHash,
|
||||
QCType: controlPlaneQCType,
|
||||
ValidatorSetRoot: vset.Root(),
|
||||
QuorumThreshold: threshold,
|
||||
}, records)
|
||||
if err != nil {
|
||||
t.Fatalf("build raw wqc: %v", err)
|
||||
}
|
||||
return wqc
|
||||
}
|
||||
|
||||
// TestControlPlaneCert_UnsafeFloorRejected — the two-threshold self-guard (RED
|
||||
// finding 1): the wallet-custody t=3 can neither compose nor verify a cert for
|
||||
// N=5 (the BFT floor is 4). This is the exact trap inc-1 fell into (cluster.go
|
||||
// wiring the Pulsar threshold = the quorum), now impossible in the cert core.
|
||||
func TestControlPlaneCert_UnsafeFloorRejected(t *testing.T) {
|
||||
keys, privs, order := certTestPods(t, 5)
|
||||
pos := certTestPosition()
|
||||
|
||||
// Compose with t=3 (the wallet-custody threshold) must be refused.
|
||||
sigs3 := signQuorum(t, pos, keys, privs, order, 3, 3)
|
||||
if _, err := ComposeControlPlaneCert(pos, keys, sigs3, 3); !errors.Is(err, ErrUnsafeCertFloor) {
|
||||
t.Fatalf("compose t=3 for N=5: want ErrUnsafeCertFloor, got %v", err)
|
||||
}
|
||||
|
||||
// A genuine 4-of-5 cert cannot be VERIFIED with a mis-wired floor of 3.
|
||||
sigs4 := signQuorum(t, pos, keys, privs, order, 4, 4)
|
||||
cert, err := ComposeControlPlaneCert(pos, keys, sigs4, 4)
|
||||
if err != nil {
|
||||
t.Fatalf("compose: %v", err)
|
||||
}
|
||||
if err := VerifyControlPlaneCert(pos, keys, 3, cert); !errors.Is(err, ErrUnsafeCertFloor) {
|
||||
t.Fatalf("verify at floor 3: want ErrUnsafeCertFloor, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestControlPlaneCert_RawSubQuorumRejected — a hand-built, internally-consistent
|
||||
// 3-of-5 cert (3 valid signatures over the threshold-3 message) is rejected by
|
||||
// the verifier's mandatory BFT floor: 3 < policy floor 4.
|
||||
func TestControlPlaneCert_RawSubQuorumRejected(t *testing.T) {
|
||||
keys, privs, order := certTestPods(t, 5)
|
||||
pos := certTestPosition()
|
||||
vset, err := buildValidatorSet(pos.Epoch, keys)
|
||||
if err != nil {
|
||||
t.Fatalf("vset: %v", err)
|
||||
}
|
||||
msg, err := CertSigningMessage(pos, keys, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("msg: %v", err)
|
||||
}
|
||||
var records []quasar.QuorumSignerRecord
|
||||
for i := 0; i < 3; i++ {
|
||||
node := order[i]
|
||||
sig, err := privs[node].SignCtxDeterministic(msg, certContext)
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
records = append(records, certRecordFor(t, vset, node, keys[node], 1, sig))
|
||||
}
|
||||
cert := wrapRawWQC(t, pos, vset, rawWQC(t, pos, vset, 3, records))
|
||||
if err := VerifyControlPlaneCert(pos, keys, 4, cert); !errors.Is(err, quasar.ErrQCThresholdBelowFloor) {
|
||||
t.Fatalf("raw sub-quorum cert: want ErrQCThresholdBelowFloor, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestControlPlaneCert_RawAttackerKeyStuffed — a record for a legitimate
|
||||
// validator id carrying an ATTACKER public key + attacker signature is rejected
|
||||
// at the weighted-Merkle inclusion gate (the reconstructed leaf is not in the
|
||||
// committed tree), even though the signature is valid under the attacker key.
|
||||
func TestControlPlaneCert_RawAttackerKeyStuffed(t *testing.T) {
|
||||
keys, privs, order := certTestPods(t, 5)
|
||||
pos := certTestPosition()
|
||||
vset, _ := buildValidatorSet(pos.Epoch, keys)
|
||||
msg, _ := CertSigningMessage(pos, keys, 4)
|
||||
|
||||
var records []quasar.QuorumSignerRecord
|
||||
for i := 0; i < 3; i++ {
|
||||
node := order[i]
|
||||
sig, _ := privs[node].SignCtxDeterministic(msg, certContext)
|
||||
records = append(records, certRecordFor(t, vset, node, keys[node], 1, sig))
|
||||
}
|
||||
attacker, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
|
||||
if err != nil {
|
||||
t.Fatalf("attacker keygen: %v", err)
|
||||
}
|
||||
badNode := order[3] // a real validator id, but an attacker key stuffed in
|
||||
badSig, _ := attacker.SignCtxDeterministic(msg, certContext)
|
||||
records = append(records, certRecordFor(t, vset, badNode, attacker.PublicKey, 1, badSig))
|
||||
|
||||
cert := wrapRawWQC(t, pos, vset, rawWQC(t, pos, vset, 4, records))
|
||||
if err := VerifyControlPlaneCert(pos, keys, 4, cert); !errors.Is(err, quasar.ErrQCMerkleInclusion) {
|
||||
t.Fatalf("attacker-key stuffing: want ErrQCMerkleInclusion, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestControlPlaneCert_RawWeightInflated — a cert whose inner AggregateWeight is
|
||||
// inflated above the true sum of signer weights is rejected by the weight
|
||||
// recomputation gate.
|
||||
func TestControlPlaneCert_RawWeightInflated(t *testing.T) {
|
||||
keys, privs, order := certTestPods(t, 5)
|
||||
pos := certTestPosition()
|
||||
vset, _ := buildValidatorSet(pos.Epoch, keys)
|
||||
msg, _ := CertSigningMessage(pos, keys, 4)
|
||||
|
||||
var records []quasar.QuorumSignerRecord
|
||||
for i := 0; i < 4; i++ {
|
||||
node := order[i]
|
||||
sig, _ := privs[node].SignCtxDeterministic(msg, certContext)
|
||||
records = append(records, certRecordFor(t, vset, node, keys[node], 1, sig))
|
||||
}
|
||||
wqc := rawWQC(t, pos, vset, 4, records)
|
||||
wqc.AggregateWeight += 100 // lie about the total signer weight
|
||||
|
||||
cert := wrapRawWQC(t, pos, vset, wqc)
|
||||
if err := VerifyControlPlaneCert(pos, keys, 4, cert); !errors.Is(err, quasar.ErrQCAggregateWeight) {
|
||||
t.Fatalf("weight inflation: want ErrQCAggregateWeight, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
package controlplane
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
@@ -13,6 +14,12 @@ import (
|
||||
pulsar "github.com/luxfi/pulsar/pkg/pulsar"
|
||||
)
|
||||
|
||||
// rekeyContext is the FIPS 204 §5.2 domain-separation context for a key-rotation
|
||||
// authorization signature. DISTINCT from popContext and certContext so a
|
||||
// rotation authorization can never be cross-protocol-confused with a leg PoP or
|
||||
// a cert signature.
|
||||
var rekeyContext = []byte("hanzo/controlplane/rekey/v1")
|
||||
|
||||
// Share is a sealed per-pod signing share. Exactly one is issued per pod.
|
||||
//
|
||||
// The Index is the pod's 1-based party index. secret is the stub z-share source
|
||||
@@ -94,6 +101,20 @@ var (
|
||||
// node with no verifiable identity could neither authenticate its own legs
|
||||
// nor have them attributed, so it must never enter the registry.
|
||||
errMissingIdentityKey = errors.New("controlplane: share has no ML-DSA identity key")
|
||||
// ErrIdentityKeyConflict is the FIRST-WRITER-WINS guard (RED R1): a node
|
||||
// already bound to one ML-DSA identity key may NOT be re-registered under a
|
||||
// DIFFERENT key. A silent overwrite would be a rogue-key swap — an attacker
|
||||
// re-registering a live validator with its own key would then have its forged
|
||||
// legs and cert signatures accepted, since the whole cert's rogue-key
|
||||
// resistance rests on idVerifier being the trusted key source. Idempotent
|
||||
// re-registration of the IDENTICAL key is allowed; a sanctioned rotation goes
|
||||
// through Rekey (self-authorized under the current key).
|
||||
ErrIdentityKeyConflict = errors.New("controlplane: node already bound to a different identity key (first-writer-wins; rotate via an authorized Rekey)")
|
||||
// ErrRekeyUnknownNode rejects a rotation of a node with no registered key.
|
||||
ErrRekeyUnknownNode = errors.New("controlplane: rekey of a node with no registered identity key")
|
||||
// ErrRekeyUnauthorized rejects a rotation whose authorization signature does
|
||||
// not verify under the node's CURRENT registered key.
|
||||
ErrRekeyUnauthorized = errors.New("controlplane: rekey authorization does not verify under the current identity key")
|
||||
)
|
||||
|
||||
// ValidatorRegistry pins the bijection party-index <-> node and holds each
|
||||
@@ -129,12 +150,59 @@ func (r *ValidatorRegistry) Register(s Share) error {
|
||||
if idx, ok := r.byNode[s.Node]; ok && idx != s.Index {
|
||||
return fmt.Errorf("%w: node holds index %d, tried %d", ErrNodeHasShare, idx, s.Index)
|
||||
}
|
||||
// First-writer-wins on the identity key (RED R1): a node already bound to a
|
||||
// key may not be silently re-bound to a DIFFERENT one. Idempotent
|
||||
// re-registration of the identical key is fine (crash-restart / re-sync).
|
||||
if cur, ok := r.idVerifier[s.Node]; ok && !pubKeyEqual(cur, s.idKey.PublicKey) {
|
||||
return fmt.Errorf("%w: node %x", ErrIdentityKeyConflict, s.Node[:8])
|
||||
}
|
||||
r.byIndex[s.Index] = s.Node
|
||||
r.byNode[s.Node] = s.Index
|
||||
r.idVerifier[s.Node] = s.idKey.PublicKey
|
||||
return nil
|
||||
}
|
||||
|
||||
// rekeyTBS is the to-be-signed preimage authorizing a key rotation: bound to the
|
||||
// node and the NEW public key. Only the holder of the node's CURRENT secret key
|
||||
// can produce a signature over it, so a rotation is self-authorized.
|
||||
func rekeyTBS(node pulsar.NodeID, newPub *mldsa.PublicKey) []byte {
|
||||
h := sha256.New()
|
||||
h.Write([]byte("cp-rekey-tbs-v1"))
|
||||
h.Write(node[:])
|
||||
h.Write(newPub.Bytes())
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
// Rekey rotates a bound node's identity key to newPub, authorized by authSig — a
|
||||
// signature under the node's CURRENT registered key over rekeyTBS(node, newPub).
|
||||
// This is the ONLY sanctioned way to change a bound key (Register is
|
||||
// first-writer-wins); it is the registry action a consensus-ordered
|
||||
// OpRekeyValidator applies. Self-authorized: an attacker without the current
|
||||
// secret key cannot forge the authorization, so it cannot rotate a validator's
|
||||
// key. Refuses an unknown node, a nil key, or an unauthorized signature.
|
||||
func (r *ValidatorRegistry) Rekey(node pulsar.NodeID, newPub *mldsa.PublicKey, authSig []byte) error {
|
||||
if newPub == nil {
|
||||
return errMissingIdentityKey
|
||||
}
|
||||
cur, ok := r.idVerifier[node]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %x", ErrRekeyUnknownNode, node[:8])
|
||||
}
|
||||
if !cur.VerifySignatureCtx(rekeyTBS(node, newPub), authSig, rekeyContext) {
|
||||
return ErrRekeyUnauthorized
|
||||
}
|
||||
r.idVerifier[node] = newPub
|
||||
return nil
|
||||
}
|
||||
|
||||
// pubKeyEqual reports whether two ML-DSA public keys are byte-identical.
|
||||
func pubKeyEqual(a, b *mldsa.PublicKey) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == b
|
||||
}
|
||||
return bytes.Equal(a.Bytes(), b.Bytes())
|
||||
}
|
||||
|
||||
// Size is the number of registered voters.
|
||||
func (r *ValidatorRegistry) Size() int { return len(r.byNode) }
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
//go:build controlplane
|
||||
|
||||
package controlplane
|
||||
|
||||
// custody_r1_test.go — RED finding R1: the registry is the trusted key source
|
||||
// for the whole cert, so key binding must be first-writer-wins with a
|
||||
// self-authorized rotation path. These tests lock that.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/crypto/mldsa"
|
||||
)
|
||||
|
||||
func r1Key(t *testing.T) *mldsa.PrivateKey {
|
||||
t.Helper()
|
||||
sk, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
|
||||
if err != nil {
|
||||
t.Fatalf("keygen: %v", err)
|
||||
}
|
||||
return sk
|
||||
}
|
||||
|
||||
// TestRegistry_FirstWriterWins_RefusesKeySwap — a node already bound to a key
|
||||
// cannot be re-registered under a DIFFERENT key (the rogue-key swap). The
|
||||
// original key is unchanged.
|
||||
func TestRegistry_FirstWriterWins_RefusesKeySwap(t *testing.T) {
|
||||
reg := NewValidatorRegistry()
|
||||
node := NodeIDFromName("cloud-0")
|
||||
sk1, sk2 := r1Key(t), r1Key(t)
|
||||
|
||||
if err := reg.Register(Share{Index: 1, Node: node, idKey: sk1}); err != nil {
|
||||
t.Fatalf("first register: %v", err)
|
||||
}
|
||||
if err := reg.Register(Share{Index: 1, Node: node, idKey: sk2}); !errors.Is(err, ErrIdentityKeyConflict) {
|
||||
t.Fatalf("key swap: want ErrIdentityKeyConflict, got %v", err)
|
||||
}
|
||||
if pub, ok := reg.idVerifier[node]; !ok || !pubKeyEqual(pub, sk1.PublicKey) {
|
||||
t.Fatal("the swap attempt overwrote the original key")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_IdempotentSameKey — re-registering the IDENTICAL key is allowed
|
||||
// (crash-restart / re-sync must not fail).
|
||||
func TestRegistry_IdempotentSameKey(t *testing.T) {
|
||||
reg := NewValidatorRegistry()
|
||||
node := NodeIDFromName("cloud-0")
|
||||
sk := r1Key(t)
|
||||
if err := reg.Register(Share{Index: 1, Node: node, idKey: sk}); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
if err := reg.Register(Share{Index: 1, Node: node, idKey: sk}); err != nil {
|
||||
t.Fatalf("idempotent re-register of the same key must succeed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_Rekey_SelfAuthorized — the sanctioned rotation: a signature under
|
||||
// the CURRENT key authorizes the new key, and the registry updates.
|
||||
func TestRegistry_Rekey_SelfAuthorized(t *testing.T) {
|
||||
reg := NewValidatorRegistry()
|
||||
node := NodeIDFromName("cloud-0")
|
||||
old, newk := r1Key(t), r1Key(t)
|
||||
if err := reg.Register(Share{Index: 1, Node: node, idKey: old}); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
authSig, err := old.SignCtxDeterministic(rekeyTBS(node, newk.PublicKey), rekeyContext)
|
||||
if err != nil {
|
||||
t.Fatalf("authorize: %v", err)
|
||||
}
|
||||
if err := reg.Rekey(node, newk.PublicKey, authSig); err != nil {
|
||||
t.Fatalf("authorized rekey: %v", err)
|
||||
}
|
||||
if !pubKeyEqual(reg.idVerifier[node], newk.PublicKey) {
|
||||
t.Fatal("rekey did not install the new key")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_Rekey_UnauthorizedRefused — an attacker without the current
|
||||
// secret key cannot rotate: a rekey authorized under any OTHER key is refused
|
||||
// and the bound key is untouched.
|
||||
func TestRegistry_Rekey_UnauthorizedRefused(t *testing.T) {
|
||||
reg := NewValidatorRegistry()
|
||||
node := NodeIDFromName("cloud-0")
|
||||
old, newk, attacker := r1Key(t), r1Key(t), r1Key(t)
|
||||
if err := reg.Register(Share{Index: 1, Node: node, idKey: old}); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
badSig, _ := attacker.SignCtxDeterministic(rekeyTBS(node, newk.PublicKey), rekeyContext)
|
||||
if err := reg.Rekey(node, newk.PublicKey, badSig); !errors.Is(err, ErrRekeyUnauthorized) {
|
||||
t.Fatalf("unauthorized rekey: want ErrRekeyUnauthorized, got %v", err)
|
||||
}
|
||||
if !pubKeyEqual(reg.idVerifier[node], old.PublicKey) {
|
||||
t.Fatal("unauthorized rekey mutated the key")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_Rekey_UnknownNode — rotating a node that was never registered is
|
||||
// refused (no current key to authorize against).
|
||||
func TestRegistry_Rekey_UnknownNode(t *testing.T) {
|
||||
reg := NewValidatorRegistry()
|
||||
node := NodeIDFromName("ghost")
|
||||
newk := r1Key(t)
|
||||
sig, _ := newk.SignCtxDeterministic(rekeyTBS(node, newk.PublicKey), rekeyContext)
|
||||
if err := reg.Rekey(node, newk.PublicKey, sig); !errors.Is(err, ErrRekeyUnknownNode) {
|
||||
t.Fatalf("rekey unknown node: want ErrRekeyUnknownNode, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
// actions.go — the two GitOps write actions.
|
||||
//
|
||||
// POST /v1/gitops/{name}/rollback — pin the CR image tag to a prior clean semver.
|
||||
// POST /v1/deploy/{name}/rollback — pin the CR image tag to a prior clean semver.
|
||||
// It REUSES the P1 release seam (cloud.OnServiceRelease → clients/paas
|
||||
// releaseService), so the clean-semver gate + idempotent spec.image patch live
|
||||
// in exactly ONE place; the operator reconciles the rollout.
|
||||
// POST /v1/gitops/{name}/sync — request an operator reconcile now by touching the
|
||||
// POST /v1/deploy/{name}/sync — request an operator reconcile now by touching the
|
||||
// CR (an annotation bump the operator's watch observes). Today the CR is the
|
||||
// desired source, so sync = nudge-reconcile; when git.hanzo.ai is the source it
|
||||
// becomes apply-desired-from-git, same endpoint.
|
||||
package gitops
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -1,9 +1,9 @@
|
||||
// applications.go — GET /v1/gitops/applications: the fleet list. Each operator
|
||||
// applications.go — GET /v1/deploy/applications: the fleet list. Each operator
|
||||
// Service CR is one Application row: its declared image version, the running
|
||||
// version observed from the live Deployment, the reconciled health, and the sync
|
||||
// verdict (declared == running ⇒ Synced, else OutOfSync). The console renders this
|
||||
// as the ArgoCD application list.
|
||||
package gitops
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package gitops mounts the native GitOps control plane at /v1/gitops — the
|
||||
// Package gitops mounts the native GitOps control plane at /v1/deploy — the
|
||||
// ArgoCD-grade deploy dashboard for the operator-managed fleet, made native to
|
||||
// the cloud binary and parallel to /v1/git (the native git server).
|
||||
//
|
||||
@@ -7,16 +7,16 @@
|
||||
// Deployment + Service + Ingress (+ HPA/PDB/Pods). This plane OBSERVES that
|
||||
// reconciliation the way ArgoCD observes a synced Application —
|
||||
//
|
||||
// GET /v1/gitops/applications — the fleet list: name, declared version,
|
||||
// GET /v1/deploy/applications — the fleet list: name, declared version,
|
||||
// health, sync, per app.
|
||||
// GET /v1/gitops/{name}/tree — the owned-resource tree (ownerRef edges)
|
||||
// GET /v1/deploy/{name}/tree — the owned-resource tree (ownerRef edges)
|
||||
// with per-node health + sync.
|
||||
// GET /v1/gitops/{name}/resource/{ref} — one node's live manifest + a
|
||||
// GET /v1/deploy/{name}/resource/{ref} — one node's live manifest + a
|
||||
// desired-vs-live diff.
|
||||
// GET /v1/gitops/{name}/logs — the app's current pod logs.
|
||||
// POST /v1/gitops/{name}/rollback — pin the CR image tag to a prior semver
|
||||
// GET /v1/deploy/{name}/logs — the app's current pod logs.
|
||||
// POST /v1/deploy/{name}/rollback — pin the CR image tag to a prior semver
|
||||
// (the operator reconciles the rollout).
|
||||
// POST /v1/gitops/{name}/sync — request an operator reconcile now.
|
||||
// POST /v1/deploy/{name}/sync — request an operator reconcile now.
|
||||
//
|
||||
// SECURITY — every route is SUPERADMIN ONLY, fail-closed, on the SAME predicate
|
||||
// the rest of cloud uses (c.IsAdmin()): the plane reads and mutates SYSTEM Service
|
||||
@@ -32,8 +32,8 @@
|
||||
// (github.com/hanzoai/git) and this engine syncs that repo → cluster with
|
||||
// self-heal. The desired-vs-live diff below is already structured for that: it
|
||||
// reads a desired source that is "cluster last-applied" now and becomes the
|
||||
// git.hanzo.ai manifest later, with no shape change. See gitopsDesiredTODO.
|
||||
package gitops
|
||||
// git.hanzo.ai manifest later, with no shape change. See deployDesiredTODO.
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -121,13 +121,13 @@ func scanOrder() []string { return []string{"hanzo", "hanzo-testnet", "hanzo-dev
|
||||
// CR metadata.name satisfies this) — the injection guard for the CR a route reads.
|
||||
var appNameRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
|
||||
|
||||
const userAgent = "hanzo-cloud-gitops"
|
||||
const userAgent = "hanzo-cloud-deploy"
|
||||
|
||||
// gitopsDesiredTODO documents the desired-state source seam: "last-applied" (the
|
||||
// deployDesiredTODO documents the desired-state source seam: "last-applied" (the
|
||||
// kubectl last-applied-configuration annotation on the live object) today; the
|
||||
// git.hanzo.ai manifest repo once RegisterPushBuilder commits CR changes there.
|
||||
// The diff shape does not change when the source flips.
|
||||
const gitopsDesiredTODO = "last-applied"
|
||||
const deployDesiredTODO = "last-applied"
|
||||
|
||||
// state is gitops's own data; shared deps live in the embedded cloud.Base.
|
||||
type state struct {
|
||||
@@ -136,9 +136,9 @@ type state struct {
|
||||
initErr string
|
||||
}
|
||||
|
||||
// Mount wires /v1/gitops/* onto app. Every handler gates on c.IsAdmin() first.
|
||||
// Mount wires /v1/deploy/* onto app. Every handler gates on c.IsAdmin() first.
|
||||
func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
return cloud.Mount(app, deps, "gitops", build, routes)
|
||||
return cloud.Mount(app, deps, "deploy", build, routes)
|
||||
}
|
||||
|
||||
// build resolves the in-process k8s clients (fail-closed: when no kubeconfig
|
||||
@@ -148,24 +148,24 @@ func build(b cloud.Base) (state, error) {
|
||||
dyn, cs, err := newClients()
|
||||
if err != nil {
|
||||
st.initErr = err.Error()
|
||||
b.Log.Warn("kubernetes client unavailable; /v1/gitops endpoints will fail closed", "err", err)
|
||||
b.Log.Warn("kubernetes client unavailable; /v1/deploy endpoints will fail closed", "err", err)
|
||||
} else {
|
||||
st.dyn, st.clientset = dyn, cs
|
||||
}
|
||||
b.Log.Info("gitops control plane mounted", "prefix", "/v1/gitops", "k8s", st.dyn != nil, "brand", b.Brand, "env", b.Env)
|
||||
b.Log.Info("deploy control plane mounted", "prefix", "/v1/deploy", "k8s", st.dyn != nil, "brand", b.Brand, "env", b.Env)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// routes registers the /v1/gitops/* surface. Every observing/mutating route is
|
||||
// routes registers the /v1/deploy/* surface. Every observing/mutating route is
|
||||
// SuperAdmin-gated; the health probe is public (real k8s reachability).
|
||||
func routes(app *zip.App, s *cloud.Service[state]) {
|
||||
app.Get("/v1/gitops/applications", guard(s, cloud.Handle(s, listApplications)))
|
||||
app.Get("/v1/gitops/health", cloud.Handle(s, health))
|
||||
app.Get("/v1/gitops/:name/tree", guard(s, cloud.Handle(s, appTree)))
|
||||
app.Get("/v1/gitops/:name/resource/:ref", guard(s, cloud.Handle(s, appResource)))
|
||||
app.Get("/v1/gitops/:name/logs", guard(s, cloud.Handle(s, appLogs)))
|
||||
app.Post("/v1/gitops/:name/rollback", guard(s, cloud.Handle(s, rollback)))
|
||||
app.Post("/v1/gitops/:name/sync", guard(s, cloud.Handle(s, sync)))
|
||||
app.Get("/v1/deploy/applications", guard(s, cloud.Handle(s, listApplications)))
|
||||
app.Get("/v1/deploy/health", cloud.Handle(s, health))
|
||||
app.Get("/v1/deploy/:name/tree", guard(s, cloud.Handle(s, appTree)))
|
||||
app.Get("/v1/deploy/:name/resource/:ref", guard(s, cloud.Handle(s, appResource)))
|
||||
app.Get("/v1/deploy/:name/logs", guard(s, cloud.Handle(s, appLogs)))
|
||||
app.Post("/v1/deploy/:name/rollback", guard(s, cloud.Handle(s, rollback)))
|
||||
app.Post("/v1/deploy/:name/sync", guard(s, cloud.Handle(s, sync)))
|
||||
}
|
||||
|
||||
// guard wraps a handler with the SuperAdmin gate (fail-closed: a non-SuperAdmin is
|
||||
@@ -183,7 +183,7 @@ func guard(s *cloud.Service[state], h zip.Handler) zip.Handler {
|
||||
// served. 200 only when both hold; 503 + the real reason otherwise. Not
|
||||
// admin-gated — liveness must be probe-able without a JWT.
|
||||
func health(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
res := map[string]any{"service": "gitops", "status": "ok"}
|
||||
res := map[string]any{"service": "deploy", "status": "ok"}
|
||||
if s.State.dyn == nil {
|
||||
res["status"], res["k8s"], res["error"] = "degraded", false, s.State.initErr
|
||||
return c.JSON(http.StatusServiceUnavailable, res)
|
||||
@@ -215,7 +215,7 @@ func health(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
// ready fails closed when no cluster client resolved (503 + the real reason).
|
||||
func ready(s *cloud.Service[state]) error {
|
||||
if s.State.dyn == nil {
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "gitops: kubernetes client not configured: %s", s.State.initErr)
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "deploy: kubernetes client not configured: %s", s.State.initErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package gitops
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -187,8 +187,8 @@ func TestComputeDiff(t *testing.T) {
|
||||
"spec": map[string]any{"replicas": int64(2), "selector": map[string]any{"matchLabels": map[string]any{"app.kubernetes.io/instance": "iam"}}, "template": map[string]any{"spec": map[string]any{"containers": []any{map[string]any{"name": "app", "image": "ghcr.io/hanzoai/iam:v1"}}}}}}
|
||||
db, _ := json.Marshal(desired)
|
||||
_ = unstructured.SetNestedField(live.Object, map[string]any{lastAppliedAnnotation: string(db)}, "metadata", "annotations")
|
||||
if src, mod, _ := computeDiff(live); src != gitopsDesiredTODO || mod {
|
||||
t.Errorf("identical-desired diff = (%q,%v), want (%q,false)", src, mod, gitopsDesiredTODO)
|
||||
if src, mod, _ := computeDiff(live); src != deployDesiredTODO || mod {
|
||||
t.Errorf("identical-desired diff = (%q,%v), want (%q,false)", src, mod, deployDesiredTODO)
|
||||
}
|
||||
// Annotation with a different image → modified.
|
||||
desired["spec"].(map[string]any)["template"].(map[string]any)["spec"].(map[string]any)["containers"].([]any)[0].(map[string]any)["image"] = "ghcr.io/hanzoai/iam:v2"
|
||||
@@ -6,7 +6,7 @@
|
||||
// P2b swaps the internals for github.com/argoproj/gitops-engine pkg/health
|
||||
// (health.GetResourceHealth) for exact ArgoCD parity; the CODES emitted here are
|
||||
// already those strings, so the wire contract the console consumes does not change.
|
||||
package gitops
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
@@ -1,4 +1,4 @@
|
||||
// logs.go — GET /v1/gitops/{name}/logs: the app's current pod logs, streamed from
|
||||
// logs.go — GET /v1/deploy/{name}/logs: the app's current pod logs, streamed from
|
||||
// the newest running pod via the typed CoreV1 GetLogs subresource. The operator
|
||||
// labels the workload it renders for an App CR with
|
||||
// app.kubernetes.io/instance=<name>, so that selects the app's pods; the
|
||||
@@ -6,7 +6,7 @@
|
||||
// selects a container; ?tail= bounds the lines. Never fabricates output — an
|
||||
// unreachable cluster or absent pod yields an honest 200 with an empty tail + the
|
||||
// reason, not invented logs.
|
||||
package gitops
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,4 +1,4 @@
|
||||
// resource.go — GET /v1/gitops/{name}/resource/{ref}: one tree node's live
|
||||
// resource.go — GET /v1/deploy/{name}/resource/{ref}: one tree node's live
|
||||
// manifest plus a desired-vs-live diff.
|
||||
//
|
||||
// {ref} is the canonical "group:kind:namespace:name" token the tree emits on each
|
||||
@@ -13,7 +13,7 @@
|
||||
// (RegisterPushBuilder → commit → engine sync), desiredSource becomes "git" with
|
||||
// the SAME diff shape. P2b replaces the field-strip diff with gitops-engine
|
||||
// pkg/diff (three-way) for exact ArgoCD parity.
|
||||
package gitops
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -136,7 +136,7 @@ func computeDiff(live *unstructured.Unstructured) (source string, modified bool,
|
||||
return "none", false, nil
|
||||
}
|
||||
modified = !jsonEqual(normalizeForDiff(live.Object), normalizeForDiff(d))
|
||||
return gitopsDesiredTODO, modified, d
|
||||
return deployDesiredTODO, modified, d
|
||||
}
|
||||
|
||||
// normalizeForDiff strips server-set / volatile fields so a diff reflects only
|
||||
@@ -1,4 +1,4 @@
|
||||
// tree.go — GET /v1/gitops/{name}/tree: the owned-resource tree for one
|
||||
// tree.go — GET /v1/deploy/{name}/tree: the owned-resource tree for one
|
||||
// Application, the ArgoCD ApplicationTree shape (a FLAT node list with parentRefs
|
||||
// edges; the console renders the DAG). The root is the Service CR; depth-1 nodes
|
||||
// are the operator-owned Deployment/Service/Ingress/HPA/PDB/ConfigMap; depth-2 are
|
||||
@@ -9,7 +9,7 @@
|
||||
// P2b swaps buildTree's cluster walk for github.com/argoproj/gitops-engine
|
||||
// pkg/cache (ClusterCache.GetManagedLiveObjs / hierarchy) for a watch-backed tree;
|
||||
// the Node shape the console consumes does not change.
|
||||
package gitops
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,418 +0,0 @@
|
||||
// Package featureflags is cloud's runtime evaluation seam over the Hanzo Insights
|
||||
// feature-flag engine (hanzoai/insights `rust/feature-flags`, the PostHog-compatible
|
||||
// /flags + /decide evaluator). It is NOT a second flag store: Insights OWNS flag
|
||||
// definitions, targeting, rollout %, and the change/activity log; cloud only EVALUATES
|
||||
// flags at runtime and surfaces the platform switches to the admin cockpit.
|
||||
//
|
||||
// ONE flag engine, EVERY switch. The PLATFORM launch switches (waitlist, public
|
||||
// signup, subsystem activation, gateway limits, network ids) are Insights feature
|
||||
// flags; this package NAMES them (the registry below — key + category + the env var
|
||||
// that supplies the fallback default) and reads their live value. Env is only the
|
||||
// FALLBACK default; the Insights flag overrides. A subsystem calls Bool/Int/String and
|
||||
// gets the hot value — a flip in the Insights flag UI takes effect within one cache TTL
|
||||
// (default 15s) with NO redeploy.
|
||||
//
|
||||
// FAIL-SAFE. When Insights is not configured (INSIGHTS_FLAGS_URL / INSIGHTS_PROJECT_
|
||||
// TOKEN unset) OR unreachable, evaluation degrades to the env fallback -> literal
|
||||
// default — exactly today's behavior, zero regression. No secret is read here; the
|
||||
// project token is injected from KMS into the process env by the deployment (the same
|
||||
// WAITLIST_URL env convention clients/base's waitlist plugin reads), never hardcoded.
|
||||
package featureflags
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// Type is the switch kind the cockpit renders (and how the value is decoded).
|
||||
type Type string
|
||||
|
||||
const (
|
||||
TypeBool Type = "bool"
|
||||
TypeInt Type = "int"
|
||||
TypeString Type = "string"
|
||||
)
|
||||
|
||||
// Def is ONE platform switch: an Insights feature-flag key qualified by the metadata
|
||||
// the cockpit shows and the env var that provides the fallback default. This table is
|
||||
// the ONE place the platform switches are named; Insights evaluates them.
|
||||
type Def struct {
|
||||
Key string // the Insights feature-flag key (snake_case)
|
||||
Category string // Launch | Signup | Subsystems | Gateway | Network
|
||||
Label string
|
||||
Desc string
|
||||
Type Type
|
||||
Env string // env var supplying the fallback default (may be "")
|
||||
Default string // literal fallback when neither Insights nor env has a value
|
||||
ReadOnly bool // surfaced read-only (boot-time activation, network ids)
|
||||
}
|
||||
|
||||
// ── registry (open + extensible) ────────────────────────────────────────────────
|
||||
|
||||
var (
|
||||
regMu sync.RWMutex
|
||||
defs []Def
|
||||
index = map[string]int{}
|
||||
)
|
||||
|
||||
// Register adds a switch to the platform-flag registry. Any subsystem may call it (in
|
||||
// its init) to surface its own flag in the cockpit — the registry is open, not
|
||||
// hardcoded. A duplicate key REPLACES the prior def (last registration wins).
|
||||
func Register(d Def) {
|
||||
regMu.Lock()
|
||||
defer regMu.Unlock()
|
||||
if i, ok := index[d.Key]; ok {
|
||||
defs[i] = d
|
||||
return
|
||||
}
|
||||
index[d.Key] = len(defs)
|
||||
defs = append(defs, d)
|
||||
}
|
||||
|
||||
// Defs returns a snapshot of the registered switches in registration order.
|
||||
func Defs() []Def {
|
||||
regMu.RLock()
|
||||
defer regMu.RUnlock()
|
||||
out := make([]Def, len(defs))
|
||||
copy(out, defs)
|
||||
return out
|
||||
}
|
||||
|
||||
func lookupDef(key string) (Def, bool) {
|
||||
regMu.RLock()
|
||||
defer regMu.RUnlock()
|
||||
if i, ok := index[key]; ok {
|
||||
return defs[i], true
|
||||
}
|
||||
return Def{}, false
|
||||
}
|
||||
|
||||
// ── evaluation client ──────────────────────────────────────────────────────────
|
||||
|
||||
// snapshot is one cached read of the Insights /flags response.
|
||||
type snapshot struct {
|
||||
flags map[string]json.RawMessage // featureFlags[key] (bool | variant string)
|
||||
payloads map[string]json.RawMessage // featureFlagPayloads[key] (arbitrary JSON)
|
||||
at time.Time
|
||||
ok bool
|
||||
}
|
||||
|
||||
// Client evaluates Insights feature flags over the PostHog-compatible /flags endpoint,
|
||||
// caching the whole response for one TTL (the hot-apply bound). Reads are lock-guarded
|
||||
// and degrade to env/default when Insights is unconfigured or unreachable.
|
||||
type Client struct {
|
||||
base string
|
||||
token string
|
||||
distinctID string
|
||||
hc *http.Client
|
||||
ttl time.Duration
|
||||
|
||||
mu sync.RWMutex
|
||||
snap snapshot
|
||||
}
|
||||
|
||||
var mounted *Client
|
||||
|
||||
func (c *Client) configured() bool { return c != nil && c.base != "" && c.token != "" }
|
||||
|
||||
// resolve returns a switch's live string value and its source. Insights wins when it
|
||||
// has a value; else the env fallback; else the literal default. Nil-safe.
|
||||
func (c *Client) resolve(def Def) (value string, source string) {
|
||||
if c.configured() {
|
||||
fv, pv, present := c.lookup(def.Key)
|
||||
if present {
|
||||
switch def.Type {
|
||||
case TypeBool:
|
||||
if b, ok := asBool(fv); ok {
|
||||
return strconv.FormatBool(b), "insights"
|
||||
}
|
||||
case TypeInt:
|
||||
if n, ok := asInt(pv); ok {
|
||||
return strconv.Itoa(n), "insights"
|
||||
}
|
||||
if n, ok := asInt(fv); ok {
|
||||
return strconv.Itoa(n), "insights"
|
||||
}
|
||||
case TypeString:
|
||||
if s, ok := asString(pv); ok && s != "" {
|
||||
return s, "insights"
|
||||
}
|
||||
if s, ok := asString(fv); ok && s != "" {
|
||||
return s, "insights"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if def.Env != "" {
|
||||
if ev := strings.TrimSpace(os.Getenv(def.Env)); ev != "" {
|
||||
return ev, "env"
|
||||
}
|
||||
}
|
||||
return def.Default, "default"
|
||||
}
|
||||
|
||||
func (c *Client) lookup(key string) (fv, pv json.RawMessage, present bool) {
|
||||
c.ensureFresh()
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
var inFlags, inPayloads bool
|
||||
fv, inFlags = c.snap.flags[key]
|
||||
pv, inPayloads = c.snap.payloads[key]
|
||||
return fv, pv, inFlags || inPayloads
|
||||
}
|
||||
|
||||
// ensureFresh refreshes the cached snapshot when older than the TTL. On a fetch error
|
||||
// the previous snapshot is kept (graceful degradation) and the clock is stamped so a
|
||||
// persistent failure is retried at most once per TTL, never on every read.
|
||||
func (c *Client) ensureFresh() {
|
||||
c.mu.RLock()
|
||||
fresh := c.snap.ok && time.Since(c.snap.at) < c.ttl
|
||||
c.mu.RUnlock()
|
||||
if fresh {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.snap.ok && time.Since(c.snap.at) < c.ttl {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||
defer cancel()
|
||||
snap, err := c.fetch(ctx)
|
||||
if err != nil {
|
||||
c.snap.at = time.Now() // keep last values; bound retry to one per TTL
|
||||
return
|
||||
}
|
||||
c.snap = snap
|
||||
}
|
||||
|
||||
func (c *Client) fetch(ctx context.Context) (snapshot, error) {
|
||||
body, _ := json.Marshal(map[string]string{"token": c.token, "distinct_id": c.distinctID})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/flags", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return snapshot{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return snapshot{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return snapshot{}, fmt.Errorf("insights /flags: status %d", resp.StatusCode)
|
||||
}
|
||||
var out struct {
|
||||
FeatureFlags map[string]json.RawMessage `json:"featureFlags"`
|
||||
FeatureFlagPayloads map[string]json.RawMessage `json:"featureFlagPayloads"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&out); err != nil {
|
||||
return snapshot{}, err
|
||||
}
|
||||
if out.FeatureFlags == nil {
|
||||
out.FeatureFlags = map[string]json.RawMessage{}
|
||||
}
|
||||
if out.FeatureFlagPayloads == nil {
|
||||
out.FeatureFlagPayloads = map[string]json.RawMessage{}
|
||||
}
|
||||
return snapshot{flags: out.FeatureFlags, payloads: out.FeatureFlagPayloads, at: time.Now(), ok: true}, nil
|
||||
}
|
||||
|
||||
// ── value parsers (tolerant of PostHog bool/variant/payload shapes) ─────────────
|
||||
|
||||
func asBool(raw json.RawMessage) (bool, bool) {
|
||||
t := strings.TrimSpace(string(raw))
|
||||
if t == "true" {
|
||||
return true, true
|
||||
}
|
||||
if t == "false" {
|
||||
return false, true
|
||||
}
|
||||
if t == "" || t == "null" {
|
||||
return false, false
|
||||
}
|
||||
var s string
|
||||
if json.Unmarshal(raw, &s) == nil {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "true", "1", "on", "yes":
|
||||
return true, true
|
||||
case "false", "0", "off", "no":
|
||||
return false, true
|
||||
case "":
|
||||
return false, false
|
||||
default:
|
||||
return true, true // enabled with a variant
|
||||
}
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
func asInt(raw json.RawMessage) (int, bool) {
|
||||
t := strings.TrimSpace(string(raw))
|
||||
if t == "" || t == "null" {
|
||||
return 0, false
|
||||
}
|
||||
var n json.Number
|
||||
if json.Unmarshal(raw, &n) == nil {
|
||||
if i, err := n.Int64(); err == nil {
|
||||
return int(i), true
|
||||
}
|
||||
if f, err := n.Float64(); err == nil {
|
||||
return int(f), true
|
||||
}
|
||||
}
|
||||
var s string
|
||||
if json.Unmarshal(raw, &s) == nil {
|
||||
if i, err := strconv.Atoi(strings.TrimSpace(s)); err == nil {
|
||||
return i, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func asString(raw json.RawMessage) (string, bool) {
|
||||
t := strings.TrimSpace(string(raw))
|
||||
if t == "" || t == "null" {
|
||||
return "", false
|
||||
}
|
||||
var s string
|
||||
if json.Unmarshal(raw, &s) == nil {
|
||||
return s, true
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
|
||||
// ── typed live accessors (the in-process evaluation seam) ───────────────────────
|
||||
|
||||
// Bool returns the live boolean value of a registered switch (Insights -> env -> default).
|
||||
func Bool(key string) bool {
|
||||
def, ok := lookupDef(key)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
v, _ := mounted.resolve(def)
|
||||
b, _ := strconv.ParseBool(strings.TrimSpace(v))
|
||||
return b
|
||||
}
|
||||
|
||||
// Int returns the live integer value of a registered switch.
|
||||
func Int(key string) int {
|
||||
def, ok := lookupDef(key)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
v, _ := mounted.resolve(def)
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(v))
|
||||
return n
|
||||
}
|
||||
|
||||
// String returns the live string value of a registered switch.
|
||||
func String(key string) string {
|
||||
def, ok := lookupDef(key)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
v, _ := mounted.resolve(def)
|
||||
return v
|
||||
}
|
||||
|
||||
// ── admin control-plane board ──────────────────────────────────────────────────
|
||||
|
||||
// SwitchView is one platform switch as the admin cockpit renders it: the live value +
|
||||
// where it came from (insights | env | default).
|
||||
type SwitchView struct {
|
||||
Key string `json:"key"`
|
||||
Category string `json:"category"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value"`
|
||||
Source string `json:"source"`
|
||||
Env string `json:"env,omitempty"`
|
||||
ReadOnly bool `json:"readOnly"`
|
||||
}
|
||||
|
||||
// BoardView is the full control-plane read board: the engine status + a deep-link to
|
||||
// the Insights flag manager (the ONE place a switch is edited) and its activity log
|
||||
// (the native change audit), plus every switch's live value.
|
||||
type BoardView struct {
|
||||
Engine string `json:"engine"`
|
||||
Configured bool `json:"configured"`
|
||||
ManageURL string `json:"manageUrl"`
|
||||
AuditURL string `json:"auditUrl"`
|
||||
Switches []SwitchView `json:"switches"`
|
||||
}
|
||||
|
||||
// Board evaluates every registered switch live and returns the cockpit read board.
|
||||
func Board() BoardView {
|
||||
appURL := strings.TrimRight(strings.TrimSpace(os.Getenv("INSIGHTS_APP_URL")), "/")
|
||||
proj := firstNonEmpty(os.Getenv("INSIGHTS_PROJECT_ID"), "1")
|
||||
var manage, audit string
|
||||
if appURL != "" {
|
||||
manage = fmt.Sprintf("%s/project/%s/feature_flags", appURL, proj)
|
||||
audit = fmt.Sprintf("%s/project/%s/activity?scope=FeatureFlag", appURL, proj)
|
||||
}
|
||||
list := Defs()
|
||||
sw := make([]SwitchView, 0, len(list))
|
||||
for _, d := range list {
|
||||
val, src := mounted.resolve(d)
|
||||
sw = append(sw, SwitchView{
|
||||
Key: d.Key, Category: d.Category, Label: d.Label, Description: d.Desc,
|
||||
Type: string(d.Type), Value: val, Source: src, Env: d.Env, ReadOnly: d.ReadOnly,
|
||||
})
|
||||
}
|
||||
return BoardView{Engine: "insights", Configured: mounted.configured(), ManageURL: manage, AuditURL: audit, Switches: sw}
|
||||
}
|
||||
|
||||
// ── lifecycle ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Mount builds the evaluation client from env (Insights base + KMS-injected project
|
||||
// token) and installs it as the process-wide seam. It serves NO HTTP routes — it is the
|
||||
// in-process read plane that subsystems and clients/admin consume. Disabled config is a
|
||||
// no-op (env fallback only), never an error.
|
||||
func Mount(_ *zip.App, deps cloud.Deps) error {
|
||||
if deps.Logger == nil {
|
||||
return fmt.Errorf("featureflags.Mount: nil deps.Logger")
|
||||
}
|
||||
log := deps.Logger.New("subsystem", "featureflags")
|
||||
c := &Client{
|
||||
base: strings.TrimRight(strings.TrimSpace(os.Getenv("INSIGHTS_FLAGS_URL")), "/"),
|
||||
token: strings.TrimSpace(os.Getenv("INSIGHTS_PROJECT_TOKEN")),
|
||||
distinctID: firstNonEmpty(os.Getenv("INSIGHTS_FLAGS_DISTINCT_ID"), "hanzo-platform:"+firstNonEmpty(deps.Brand, "hanzo")),
|
||||
hc: &http.Client{Timeout: 4 * time.Second},
|
||||
ttl: ttlFromEnv(),
|
||||
}
|
||||
mounted = c
|
||||
log.Info("featureflags evaluation seam ready", "engine", "insights", "configured", c.configured(), "ttlSeconds", int(c.ttl.Seconds()), "switches", len(Defs()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func ttlFromEnv() time.Duration {
|
||||
if v := strings.TrimSpace(os.Getenv("INSIGHTS_FLAGS_TTL_SECONDS")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
return time.Duration(n) * time.Second
|
||||
}
|
||||
}
|
||||
return 15 * time.Second
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
package featureflags
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// newTestClient points a Client at a fake Insights /flags server and installs it as the
|
||||
// process seam, restoring the prior seam on cleanup.
|
||||
func newTestClient(t *testing.T, base string) *Client {
|
||||
t.Helper()
|
||||
prev := mounted
|
||||
c := &Client{base: base, token: "phc_test", distinctID: "test", hc: &http.Client{Timeout: 2 * time.Second}, ttl: time.Minute}
|
||||
mounted = c
|
||||
t.Cleanup(func() { mounted = prev })
|
||||
return c
|
||||
}
|
||||
|
||||
// flagServer returns an httptest server serving a controllable /flags response.
|
||||
func flagServer(t *testing.T, body func() map[string]any) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(body())
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestEnvFallbackAndDefault(t *testing.T) {
|
||||
// No Insights configured -> env fallback, then literal default.
|
||||
prev := mounted
|
||||
mounted = &Client{} // not configured
|
||||
t.Cleanup(func() { mounted = prev })
|
||||
|
||||
// waitlist_open default is "true" (no env set).
|
||||
t.Setenv("WAITLIST_OPEN", "")
|
||||
if !Bool("waitlist_open") {
|
||||
t.Fatalf("waitlist_open default should be true")
|
||||
}
|
||||
// env override beats the literal default.
|
||||
t.Setenv("WAITLIST_OPEN", "false")
|
||||
if Bool("waitlist_open") {
|
||||
t.Fatalf("waitlist_open env=false should win")
|
||||
}
|
||||
// int default.
|
||||
if Int("waitlist_access_capacity") != 0 {
|
||||
t.Fatalf("capacity default should be 0")
|
||||
}
|
||||
t.Setenv("WAITLIST_ACCESS_CAPACITY", "250")
|
||||
if Int("waitlist_access_capacity") != 250 {
|
||||
t.Fatalf("capacity env should be 250, got %d", Int("waitlist_access_capacity"))
|
||||
}
|
||||
// network id read-only default.
|
||||
if Int("network_id_localnet") != 1337 {
|
||||
t.Fatalf("localnet id default should be 1337")
|
||||
}
|
||||
// unknown key is safe.
|
||||
if Bool("nope") || Int("nope") != 0 || String("nope") != "" {
|
||||
t.Fatalf("unknown key must be zero-valued")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateFromInsights(t *testing.T) {
|
||||
srv := flagServer(t, func() map[string]any {
|
||||
return map[string]any{
|
||||
"featureFlags": map[string]any{"waitlist_open": true, "public_signup": false},
|
||||
"featureFlagPayloads": map[string]any{"waitlist_access_capacity": 500},
|
||||
}
|
||||
})
|
||||
newTestClient(t, srv.URL)
|
||||
|
||||
if !Bool("waitlist_open") {
|
||||
t.Fatalf("insights waitlist_open should be true")
|
||||
}
|
||||
if Bool("public_signup") {
|
||||
t.Fatalf("insights public_signup should be false")
|
||||
}
|
||||
if got := Int("waitlist_access_capacity"); got != 500 {
|
||||
t.Fatalf("insights payload capacity should be 500, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsightsOverridesEnv(t *testing.T) {
|
||||
srv := flagServer(t, func() map[string]any {
|
||||
return map[string]any{"featureFlags": map[string]any{"waitlist_open": false}}
|
||||
})
|
||||
newTestClient(t, srv.URL)
|
||||
t.Setenv("WAITLIST_OPEN", "true") // env says open, insights says closed -> insights wins
|
||||
if Bool("waitlist_open") {
|
||||
t.Fatalf("insights (false) must override env (true)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHotApplyAfterTTL(t *testing.T) {
|
||||
open := true
|
||||
srv := flagServer(t, func() map[string]any {
|
||||
return map[string]any{"featureFlags": map[string]any{"waitlist_open": open}}
|
||||
})
|
||||
c := newTestClient(t, srv.URL)
|
||||
|
||||
if !Bool("waitlist_open") {
|
||||
t.Fatalf("first read should be true")
|
||||
}
|
||||
// Flip the flag at the source, then expire the cache -> next read reflects it (hot-apply).
|
||||
open = false
|
||||
c.mu.Lock()
|
||||
c.snap.at = time.Now().Add(-time.Hour)
|
||||
c.mu.Unlock()
|
||||
if Bool("waitlist_open") {
|
||||
t.Fatalf("after TTL expiry the flip must be visible (hot-apply)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGracefulDegradeOnError(t *testing.T) {
|
||||
// Unreachable base -> fetch fails -> env/default, never a panic.
|
||||
prev := mounted
|
||||
mounted = &Client{base: "http://127.0.0.1:0", token: "phc_test", hc: &http.Client{Timeout: 200 * time.Millisecond}, ttl: time.Minute}
|
||||
t.Cleanup(func() { mounted = prev })
|
||||
t.Setenv("WAITLIST_OPEN", "false")
|
||||
if Bool("waitlist_open") {
|
||||
t.Fatalf("unreachable insights must fall back to env=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoard(t *testing.T) {
|
||||
srv := flagServer(t, func() map[string]any {
|
||||
return map[string]any{"featureFlags": map[string]any{"waitlist_open": true}}
|
||||
})
|
||||
newTestClient(t, srv.URL)
|
||||
t.Setenv("INSIGHTS_APP_URL", "https://insights.hanzo.ai/")
|
||||
t.Setenv("INSIGHTS_PROJECT_ID", "7")
|
||||
|
||||
b := Board()
|
||||
if b.Engine != "insights" || !b.Configured {
|
||||
t.Fatalf("board engine/configured wrong: %+v", b)
|
||||
}
|
||||
if b.ManageURL != "https://insights.hanzo.ai/project/7/feature_flags" {
|
||||
t.Fatalf("manage url wrong: %s", b.ManageURL)
|
||||
}
|
||||
var open, netID *SwitchView
|
||||
for i := range b.Switches {
|
||||
switch b.Switches[i].Key {
|
||||
case "waitlist_open":
|
||||
open = &b.Switches[i]
|
||||
case "network_id_mainnet":
|
||||
netID = &b.Switches[i]
|
||||
}
|
||||
}
|
||||
if open == nil || open.Source != "insights" || open.Value != "true" {
|
||||
t.Fatalf("waitlist_open switch view wrong: %+v", open)
|
||||
}
|
||||
if netID == nil || !netID.ReadOnly || netID.Value != "1" {
|
||||
t.Fatalf("network_id_mainnet should be read-only default 1: %+v", netID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsers(t *testing.T) {
|
||||
if b, ok := asBool(json.RawMessage("true")); !b || !ok {
|
||||
t.Fatal("asBool true")
|
||||
}
|
||||
if b, ok := asBool(json.RawMessage(`"on"`)); !b || !ok {
|
||||
t.Fatal("asBool variant on")
|
||||
}
|
||||
if _, ok := asBool(json.RawMessage("")); ok {
|
||||
t.Fatal("asBool empty not present")
|
||||
}
|
||||
if n, ok := asInt(json.RawMessage("500")); n != 500 || !ok {
|
||||
t.Fatal("asInt number")
|
||||
}
|
||||
if n, ok := asInt(json.RawMessage(`"42"`)); n != 42 || !ok {
|
||||
t.Fatal("asInt string number")
|
||||
}
|
||||
if s, ok := asString(json.RawMessage(`"hi"`)); s != "hi" || !ok {
|
||||
t.Fatal("asString")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package featuregate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// approvalStatusPending is the ONE value that gates a user. This mirrors IAM's
|
||||
// object.ApprovalPending (hanzoai/iam object/user.go) and User.IsApproved() —
|
||||
// approval is FAIL-OPEN: a user is approved unless properties.approvalStatus is
|
||||
// EXACTLY "pending" (absent / "approved" / "rejected" all read approved via
|
||||
// IsApproved). Only "pending" holds a user on the waitlist. Keeping the literal
|
||||
// here (not importing IAM) keeps featuregate self-contained.
|
||||
const approvalStatusPending = "pending"
|
||||
|
||||
// approvedHeader is the FORWARD-PERFECT path: once IAM carries approvalStatus in
|
||||
// the token and the gateway mints it as a validated header (the same trust model
|
||||
// as X-User-IsAdmin), the enforcement points read approval for FREE with no IAM
|
||||
// round-trip. Until then the resolver falls back to an IAM get-account lookup.
|
||||
// Values: "true" (approved) / "false" (pending). Any other value → fall through.
|
||||
const approvedHeader = "X-User-Approved"
|
||||
|
||||
// accountLookup fetches a caller's approvalStatus by replaying the caller's own
|
||||
// credentials to IAM get-account. Injected so the resolver is unit-testable
|
||||
// without a live IAM. It returns (status, ok): ok=false on any IAM error, which
|
||||
// the resolver treats FAIL-OPEN (approved) — the documented guard behavior
|
||||
// (availability over a hard gate when IAM is unreachable).
|
||||
type accountLookup func(ctx context.Context, cookie, auth string) (status string, ok bool)
|
||||
|
||||
// Approvals resolves whether the current caller is off the waitlist. It is the ONE
|
||||
// approval predicate the native middleware uses, DRY with the @file waitlist-guard
|
||||
// (both read properties.approvalStatus == "pending"). Resolution order:
|
||||
//
|
||||
// 1. global admin (c.IsAdmin()) → approved (admins are never gated)
|
||||
// 2. validated header X-User-Approved → its bit (forward-perfect, no lookup)
|
||||
// 3. IAM get-account (caller's creds) → approved unless approvalStatus=="pending"
|
||||
// — cached per user for ttl; FAIL-OPEN on any IAM error.
|
||||
type Approvals struct {
|
||||
lookup accountLookup
|
||||
ttl time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
cache map[string]approvalEntry
|
||||
}
|
||||
|
||||
type approvalEntry struct {
|
||||
approved bool
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// NewApprovals builds a resolver. iamBase is the in-cluster IAM base
|
||||
// (e.g. http://iam.hanzo.svc.cluster.local:8000); ttl bounds the per-user cache.
|
||||
// A zero iamBase yields a resolver whose lookup always fails-open (approved) —
|
||||
// safe for a deployment where approval is enforced elsewhere (the guard).
|
||||
func NewApprovals(iamBase string, ttl time.Duration) *Approvals {
|
||||
if ttl <= 0 {
|
||||
ttl = 30 * time.Second
|
||||
}
|
||||
return &Approvals{
|
||||
lookup: httpAccountLookup(strings.TrimRight(iamBase, "/")),
|
||||
ttl: ttl,
|
||||
cache: map[string]approvalEntry{},
|
||||
}
|
||||
}
|
||||
|
||||
// newApprovalsWithLookup is the test seam: a resolver over an injected lookup.
|
||||
func newApprovalsWithLookup(lookup accountLookup, ttl time.Duration) *Approvals {
|
||||
if ttl <= 0 {
|
||||
ttl = 30 * time.Second
|
||||
}
|
||||
return &Approvals{lookup: lookup, ttl: ttl, cache: map[string]approvalEntry{}}
|
||||
}
|
||||
|
||||
// Approved reports whether the caller is off the waitlist.
|
||||
func (a *Approvals) Approved(c *zip.Ctx) bool {
|
||||
// (1) Global admins are ALWAYS approved.
|
||||
if c.IsAdmin() {
|
||||
return true
|
||||
}
|
||||
// (2) Forward-perfect validated header — no IAM round-trip when present.
|
||||
switch strings.ToLower(strings.TrimSpace(c.Header(approvedHeader))) {
|
||||
case "true", "1", "approved":
|
||||
return true
|
||||
case "false", "0", "pending":
|
||||
return false
|
||||
}
|
||||
// (3) IAM get-account lookup, cached per user, fail-open on error.
|
||||
user := strings.TrimSpace(c.User())
|
||||
if user == "" {
|
||||
// No validated principal — an unauthenticated caller. The middleware
|
||||
// resolves login separately; treat as not-approved so an anonymous
|
||||
// request to a gated host is bounced (never allowed through as approved).
|
||||
return false
|
||||
}
|
||||
if a.lookup == nil {
|
||||
return true // no lookup wired → fail-open (approval enforced elsewhere)
|
||||
}
|
||||
if e, ok := a.get(user); ok {
|
||||
return e.approved
|
||||
}
|
||||
status, ok := a.lookup(c.Context(),
|
||||
c.Header("Cookie"), c.Header("Authorization"))
|
||||
if !ok {
|
||||
// IAM unreachable → FAIL-OPEN (approved). Do NOT cache a fail-open so the
|
||||
// next request re-probes and a recovered IAM re-gates promptly.
|
||||
return true
|
||||
}
|
||||
approved := strings.TrimSpace(strings.ToLower(status)) != approvalStatusPending
|
||||
a.put(user, approved)
|
||||
return approved
|
||||
}
|
||||
|
||||
func (a *Approvals) get(user string) (approvalEntry, bool) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
e, ok := a.cache[user]
|
||||
if !ok || time.Since(e.at) > a.ttl {
|
||||
return approvalEntry{}, false
|
||||
}
|
||||
return e, true
|
||||
}
|
||||
|
||||
func (a *Approvals) put(user string, approved bool) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.cache[user] = approvalEntry{approved: approved, at: time.Now()}
|
||||
}
|
||||
|
||||
// httpAccountLookup builds the real IAM get-account lookup. It replays the
|
||||
// caller's Cookie / Authorization to IAM and reads data.properties.approvalStatus
|
||||
// (the field GetAccount returns via GetMaskedUser). Bounded read + timeout mirror
|
||||
// the guard's iamGet. Any non-200 / decode error → ok=false (fail-open upstream).
|
||||
func httpAccountLookup(iamBase string) accountLookup {
|
||||
if iamBase == "" {
|
||||
return func(context.Context, string, string) (string, bool) { return "", false }
|
||||
}
|
||||
url := iamBase + "/v1/iam/get-account"
|
||||
return func(ctx context.Context, cookie, auth string) (string, bool) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if cookie != "" {
|
||||
req.Header.Set("Cookie", cookie)
|
||||
}
|
||||
if auth != "" {
|
||||
req.Header.Set("Authorization", auth)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", false
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return approvalStatusFromAccount(body)
|
||||
}
|
||||
}
|
||||
|
||||
// approvalStatusFromAccount extracts properties.approvalStatus from an IAM
|
||||
// get-account response. The user object is at the top level or under `data`
|
||||
// (the casibase { status, data } envelope). Returns ("", false) on an error
|
||||
// envelope or a missing user (fail-open upstream). An ABSENT approvalStatus is
|
||||
// returned as "" (ok=true) — which the resolver reads as approved (fail-open,
|
||||
// matching IsApproved()).
|
||||
func approvalStatusFromAccount(body []byte) (string, bool) {
|
||||
type acct struct {
|
||||
Owner string `json:"owner"`
|
||||
Properties map[string]string `json:"properties"`
|
||||
}
|
||||
var top struct {
|
||||
Status string `json:"status"`
|
||||
acct
|
||||
Data acct `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &top); err != nil {
|
||||
return "", false
|
||||
}
|
||||
if top.Status == "error" {
|
||||
return "", false
|
||||
}
|
||||
a := top.acct
|
||||
if a.Owner == "" && top.Data.Owner != "" {
|
||||
a = top.Data
|
||||
}
|
||||
if a.Owner == "" {
|
||||
return "", false
|
||||
}
|
||||
if a.Properties == nil {
|
||||
return "", true // no properties → approved (fail-open)
|
||||
}
|
||||
return a.Properties["approvalStatus"], true
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package featuregate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
luxlog "github.com/luxfi/log"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// ctxFor builds a zip.Ctx carrying the given identity headers by driving a
|
||||
// throwaway app whose one handler captures the ctx.
|
||||
func ctxWith(t *testing.T, headers map[string]string, fn func(c *zip.Ctx)) {
|
||||
t.Helper()
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
app.Get("/probe", func(c *zip.Ctx) error {
|
||||
fn(c)
|
||||
return c.NoContent(204)
|
||||
})
|
||||
hr := httptest.NewRequest("GET", "http://x/probe", nil)
|
||||
for k, v := range headers {
|
||||
hr.Header.Set(k, v)
|
||||
}
|
||||
resp, err := app.Fiber().Test(hr)
|
||||
if err != nil {
|
||||
t.Fatalf("probe: %v", err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestApprovals_AdminAlwaysApproved(t *testing.T) {
|
||||
a := newApprovalsWithLookup(func(context.Context, string, string) (string, bool) {
|
||||
t.Fatal("admin must not trigger an IAM lookup")
|
||||
return "", false
|
||||
}, time.Minute)
|
||||
ctxWith(t, map[string]string{"X-User-IsAdmin": "true", "X-User-Id": "u"}, func(c *zip.Ctx) {
|
||||
if !a.Approved(c) {
|
||||
t.Fatal("admin should be approved")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApprovals_ForwardHeaderWins(t *testing.T) {
|
||||
a := newApprovalsWithLookup(func(context.Context, string, string) (string, bool) {
|
||||
t.Fatal("header path must not trigger an IAM lookup")
|
||||
return "", false
|
||||
}, time.Minute)
|
||||
ctxWith(t, map[string]string{"X-User-Id": "u", "X-User-Approved": "true"}, func(c *zip.Ctx) {
|
||||
if !a.Approved(c) {
|
||||
t.Fatal("X-User-Approved=true should be approved")
|
||||
}
|
||||
})
|
||||
ctxWith(t, map[string]string{"X-User-Id": "u", "X-User-Approved": "false"}, func(c *zip.Ctx) {
|
||||
if a.Approved(c) {
|
||||
t.Fatal("X-User-Approved=false should NOT be approved")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApprovals_IAMLookup_PendingGates(t *testing.T) {
|
||||
calls := 0
|
||||
a := newApprovalsWithLookup(func(context.Context, string, string) (string, bool) {
|
||||
calls++
|
||||
return "pending", true
|
||||
}, time.Minute)
|
||||
ctxWith(t, map[string]string{"X-User-Id": "u", "X-Org-Id": "acme"}, func(c *zip.Ctx) {
|
||||
if a.Approved(c) {
|
||||
t.Fatal("approvalStatus=pending should NOT be approved")
|
||||
}
|
||||
})
|
||||
// Second call hits the cache (no second lookup).
|
||||
ctxWith(t, map[string]string{"X-User-Id": "u", "X-Org-Id": "acme"}, func(c *zip.Ctx) {
|
||||
if a.Approved(c) {
|
||||
t.Fatal("cached pending should NOT be approved")
|
||||
}
|
||||
})
|
||||
if calls != 1 {
|
||||
t.Fatalf("IAM lookups = %d, want 1 (cached)", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovals_IAMLookup_ApprovedAndAbsentPass(t *testing.T) {
|
||||
for _, status := range []string{"approved", "", "rejected"} {
|
||||
a := newApprovalsWithLookup(func(context.Context, string, string) (string, bool) {
|
||||
return status, true
|
||||
}, time.Minute)
|
||||
ctxWith(t, map[string]string{"X-User-Id": "u", "X-Org-Id": "acme"}, func(c *zip.Ctx) {
|
||||
if !a.Approved(c) {
|
||||
t.Fatalf("approvalStatus=%q should be approved (fail-open, only 'pending' gates)", status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovals_FailOpenOnIAMError(t *testing.T) {
|
||||
a := newApprovalsWithLookup(func(context.Context, string, string) (string, bool) {
|
||||
return "", false // IAM unreachable
|
||||
}, time.Minute)
|
||||
ctxWith(t, map[string]string{"X-User-Id": "u", "X-Org-Id": "acme"}, func(c *zip.Ctx) {
|
||||
if !a.Approved(c) {
|
||||
t.Fatal("IAM unreachable should FAIL-OPEN (approved) for availability")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApprovals_UnauthenticatedNotApproved(t *testing.T) {
|
||||
a := newApprovalsWithLookup(func(context.Context, string, string) (string, bool) {
|
||||
t.Fatal("no lookup for an unauthenticated caller")
|
||||
return "", false
|
||||
}, time.Minute)
|
||||
ctxWith(t, map[string]string{}, func(c *zip.Ctx) {
|
||||
if a.Approved(c) {
|
||||
t.Fatal("an unauthenticated caller is not approved")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApprovalStatusFromAccount(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantStatus string
|
||||
wantOK bool
|
||||
}{
|
||||
{"top-level pending", `{"owner":"acme","properties":{"approvalStatus":"pending"}}`, "pending", true},
|
||||
{"data-wrapped approved", `{"status":"ok","data":{"owner":"acme","properties":{"approvalStatus":"approved"}}}`, "approved", true},
|
||||
{"no properties", `{"owner":"acme"}`, "", true},
|
||||
{"error envelope", `{"status":"error","msg":"nope"}`, "", false},
|
||||
{"no owner", `{"properties":{"approvalStatus":"pending"}}`, "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, ok := approvalStatusFromAccount([]byte(tc.body))
|
||||
if got != tc.wantStatus || ok != tc.wantOK {
|
||||
t.Fatalf("= (%q,%v), want (%q,%v)", got, ok, tc.wantStatus, tc.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package featuregate is the launch-control ENFORCEMENT for Hanzo's hosted services:
|
||||
// the native middleware (Enforce) + the per-user approval predicate (Approvals, reused
|
||||
// from IAM). It is a CONSUMER of the ONE policy engine — the per-service waitlist MODE
|
||||
// and the host→service registry live in clients/flags (a service's mode IS the
|
||||
// switch waitlist.<svc>, evaluated through the native engine); the admin board is the
|
||||
// /v1/admin/services lens and the guard's runtime mode read is /v1/featuregate/mode,
|
||||
// both served there. This package owns only enforcement, decomplected into two axes:
|
||||
//
|
||||
// - PER-SERVICE waitlist mode on|off — the flags switch waitlist.<svc>,
|
||||
// resolved for a request host via flags.WaitlistModeForHost (the decide).
|
||||
// - PER-USER approvalStatus pending|approved — owned by IAM (approval.go), REUSED.
|
||||
//
|
||||
// THE RULE, applied at ONE native enforcement point (Enforce):
|
||||
//
|
||||
// if waitlistMode[host] AND NOT user.approved → bounce to the waitlist
|
||||
// if approved OR mode=off → allow
|
||||
// unauthenticated → login first
|
||||
package featuregate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/flags"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// Enforce is the NATIVE, forward-perfect enforcement point for the waitlist — the
|
||||
// single in-binary middleware that reads the registry (in-process, no HTTP hop)
|
||||
// and the caller's approval, and applies THE RULE for every request whose Host is
|
||||
// a governed service. As product hosts fold into the one-binary cloud, this is the
|
||||
// ONE enforcement point (the @file waitlist-guard is the interim gate for hosts
|
||||
// not yet cloud-fronted; both read the SAME registry so a toggle governs both).
|
||||
//
|
||||
// THE RULE (per request, on a governed host in waitlist mode):
|
||||
//
|
||||
// carries a Hanzo API key (hk-/sk-/…) → allow (paid inference; possession-gated)
|
||||
// exempt path (health/iam/waitlist) → allow
|
||||
// unauthenticated → 302 waitlist (browser) / 401 (API)
|
||||
// waitlist mode OFF (or host un-governed) → allow (c.Next)
|
||||
// waitlist mode ON AND approved → allow (c.Next)
|
||||
// waitlist mode ON AND NOT approved → 302 waitlist (browser) / 403 (API)
|
||||
//
|
||||
// INTEGRATION POINT — wire in serve.go RIGHT AFTER SanitizeIdentity:
|
||||
//
|
||||
// app.Use(IdentityMiddleware(cfg)) // establishes the validated principal
|
||||
// app.Use(featuregate.Enforce(featuregate.EnforceConfig{ WaitlistURL: … })) // ← here
|
||||
//
|
||||
// It reads the sanitized X-User-Id / X-User-IsAdmin / X-User-Approved that
|
||||
// IdentityMiddleware minted, so it MUST run after it and (like BillingGate) before
|
||||
// the subsystem handlers. It is deliberately NOT wired here — the unified-binary
|
||||
// agent owns serve.go's boot chain; this package exposes Enforce so the one-line
|
||||
// app.Use lands without a merge collision. The decide (flags.WaitlistModeForHost) is
|
||||
// resolved PER REQUEST and fail-opens until the flags engine has mounted, so Enforce
|
||||
// can be constructed before Mount runs.
|
||||
//
|
||||
// WHY NATIVE IS CANONICAL (in-cluster-bypass). The @file edge guard only gates
|
||||
// traffic arriving THROUGH the ingress — a pod reaching another service's pod
|
||||
// directly in-cluster bypasses it (a cluster-wide baseline NetworkPolicy + Cilium
|
||||
// broad-allow union means additive netpols can't seal that). For a waitlist (threat
|
||||
// model = external users) edge-only is acceptable, but this native middleware is
|
||||
// FORWARD-PERFECT: when the app IS cloud, the gate is IN the request path, so
|
||||
// reaching the pod directly STILL hits it — there is no edge to go around. That is
|
||||
// the reason the native middleware is the canonical enforcement and the @file guard
|
||||
// is purely interim. It is also STATELESS (it reads the sanitized X-User-* headers,
|
||||
// sets no cookie), so the multi-apex cookie-domain concern the @file guard must
|
||||
// handle does not exist here at all.
|
||||
//
|
||||
// Paths that must NEVER be gated (health, the waitlist page's own API, auth
|
||||
// callbacks) are skipped via ExemptPrefixes so enforcement can't lock the platform
|
||||
// out of its own recovery/observability surface.
|
||||
type EnforceConfig struct {
|
||||
// WaitlistURL is where an unapproved / unauthenticated browser is bounced
|
||||
// (per-brand, e.g. https://waitlist.hanzo.ai). Empty → API-style 403/401 for
|
||||
// everyone (no redirect target), so enforcement still holds.
|
||||
WaitlistURL string
|
||||
|
||||
// Approvals resolves whether the caller is off the waitlist. When nil, Enforce
|
||||
// builds one from IAMBase.
|
||||
Approvals *Approvals
|
||||
|
||||
// IAMBase is the in-cluster IAM base used to build Approvals when it is nil.
|
||||
IAMBase string
|
||||
|
||||
// ExemptPrefixes are request-path prefixes never gated (health/metrics/auth).
|
||||
// A sensible default set is used when empty.
|
||||
ExemptPrefixes []string
|
||||
|
||||
// Gate is THE decide: it resolves whether a request host is in waitlist mode,
|
||||
// via the ONE policy engine. When nil it is flags.WaitlistModeForHost —
|
||||
// host→service→waitlist.<svc>. Injected only in tests. Fail-open by contract:
|
||||
// known=false (unmounted / registry error / un-governed host) → not gated.
|
||||
Gate func(ctx context.Context, host string) (mode bool, service string, known bool)
|
||||
}
|
||||
|
||||
// defaultExemptPrefixes are the paths enforcement must never touch — HIP-0106
|
||||
// health, the auth/OIDC handshake, and the waitlist join API itself (so a gated
|
||||
// user can still submit the waitlist form).
|
||||
var defaultExemptPrefixes = []string{
|
||||
"/v1/featuregate/", // the mode read + the health route
|
||||
"/v1/iam/", // auth / OIDC / approval-status / get-account handshake
|
||||
"/v1/waitlist", // the waitlist join API (a gated user must reach it)
|
||||
"/health",
|
||||
"/healthz",
|
||||
"/__guard/", // the @file guard's own callback surface (defense in depth)
|
||||
}
|
||||
|
||||
// Enforce builds the native enforcement middleware. It is a no-op passthrough when the
|
||||
// decide reports the host is not governed (gate known=false — the flags registry not
|
||||
// mounted yet, a store error, or an un-governed host), so a request before boot
|
||||
// completes is never wrongly gated.
|
||||
func Enforce(cfg EnforceConfig) zip.Handler {
|
||||
approvals := cfg.Approvals
|
||||
if approvals == nil {
|
||||
approvals = NewApprovals(cfg.IAMBase, 0)
|
||||
}
|
||||
gate := cfg.Gate
|
||||
if gate == nil {
|
||||
gate = flags.WaitlistModeForHost // the ONE decide: host→service→waitlist.<svc>
|
||||
}
|
||||
exempt := cfg.ExemptPrefixes
|
||||
if len(exempt) == 0 {
|
||||
exempt = defaultExemptPrefixes
|
||||
}
|
||||
waitlistURL := strings.TrimRight(strings.TrimSpace(cfg.WaitlistURL), "/")
|
||||
|
||||
return func(c *zip.Ctx) error {
|
||||
path := c.Path()
|
||||
for _, p := range exempt {
|
||||
if strings.HasPrefix(path, p) {
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// MONEY-CRITICAL EXEMPTION: a request bearing a Hanzo API KEY (hk-/sk-/pk-/…)
|
||||
// is NEVER waitlist-gated. Paid inference on api.hanzo.ai authenticates by KEY
|
||||
// POSSESSION + is metered downstream in the `ai` subsystem — SanitizeIdentity
|
||||
// does NOT mint a session principal for an API key (auth_identity.go isAPIKey →
|
||||
// validatedPrincipal returns nil), so an API-key request arrives here with an
|
||||
// empty c.User(); without this exemption THE RULE would misclassify it as
|
||||
// "unauthenticated API → 401" and break every paid inference call. The gate is
|
||||
// redundant anyway: an API key is minted ONLY in the (gated) console, so an
|
||||
// unapproved user can never obtain one. Session/JWT access to a gated host is
|
||||
// still gated normally — only key possession is exempt.
|
||||
if carriesAPIKey(c) {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
mode, _, known := gate(c.Context(), c.Fiber().Hostname())
|
||||
if !known || !mode {
|
||||
// Un-governed host, mode OFF, or a registry read error (the decide folds
|
||||
// all three into known=false) → allow. A governed host is opened by
|
||||
// flipping its waitlist.<svc> switch OFF; an unknown host is not ours to
|
||||
// gate at the shared cloud edge — the @file guard is the belt-and-braces
|
||||
// gate for the hosts that must stay closed.
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// Governed host in waitlist mode. Approved (incl. admins) pass; everyone
|
||||
// else is bounced.
|
||||
if approvals.Approved(c) {
|
||||
return c.Next()
|
||||
}
|
||||
return bounce(c, waitlistURL)
|
||||
}
|
||||
}
|
||||
|
||||
// bounce renders the not-approved verdict: a browser navigation is redirected to
|
||||
// the waitlist (302); an API client gets a 403 (401 when there is no principal at
|
||||
// all). Content-negotiated so a fetch/XHR never eats an opaque HTML redirect.
|
||||
func bounce(c *zip.Ctx, waitlistURL string) error {
|
||||
if isAPIClient(c) || waitlistURL == "" {
|
||||
if strings.TrimSpace(c.User()) == "" {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]any{
|
||||
"error": "authentication required — sign in to continue",
|
||||
})
|
||||
}
|
||||
return c.JSON(http.StatusForbidden, map[string]any{
|
||||
"error": "account pending approval — join the waitlist",
|
||||
})
|
||||
}
|
||||
c.SetHeader("Location", waitlistURL)
|
||||
return c.NoContent(http.StatusFound)
|
||||
}
|
||||
|
||||
// apiKeyPrefixes are the Hanzo API-key prefixes. This MIRRORS cloud
|
||||
// auth_identity.go isAPIKey (the ONE authority) — kept local so featuregate stays
|
||||
// self-contained (no cloud-internal import) while agreeing on the exact contract:
|
||||
// a token with one of these prefixes is a possession-gated API key, not a session
|
||||
// principal. If cloud adds a prefix there, add it here.
|
||||
var apiKeyPrefixes = []string{"hk-", "sk-", "pk-", "fw_", "hz_"}
|
||||
|
||||
// carriesAPIKey reports whether the request authenticates with a Hanzo API key —
|
||||
// in the Authorization header (Bearer or Basic-username) or the common api-key /
|
||||
// x-api-key headers. GENEROUS by design: over-exempting only skips the redundant
|
||||
// waitlist gate (key validation + billing still run downstream); under-exempting
|
||||
// would break paid inference. So any recognized key signal exempts the request.
|
||||
func carriesAPIKey(c *zip.Ctx) bool {
|
||||
auth := strings.TrimSpace(c.Header("Authorization"))
|
||||
if tok, ok := cut(auth, "Bearer "); ok && hasAPIKeyPrefix(tok) {
|
||||
return true
|
||||
}
|
||||
// Basic auth carries the key as the username (key:) — OpenAI-compat clients do
|
||||
// this. The raw base64 is not decoded here; instead accept the header-form keys
|
||||
// callers commonly send, which is where inference SDKs put the key.
|
||||
if hasAPIKeyPrefix(strings.TrimSpace(c.Header("api-key"))) ||
|
||||
hasAPIKeyPrefix(strings.TrimSpace(c.Header("x-api-key"))) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasAPIKeyPrefix(tok string) bool {
|
||||
for _, p := range apiKeyPrefixes {
|
||||
if strings.HasPrefix(tok, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// cut splits s on the first occurrence of prefix at the START (case-insensitive on
|
||||
// the scheme word), returning the remainder.
|
||||
func cut(s, prefix string) (string, bool) {
|
||||
if len(s) >= len(prefix) && strings.EqualFold(s[:len(prefix)], prefix) {
|
||||
return strings.TrimSpace(s[len(prefix):]), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// isAPIClient reports whether the caller is a non-browser API client, so we fail
|
||||
// closed with a status code instead of an interactive redirect. A Bearer/Basic
|
||||
// Authorization OR an Accept without text/html is an API call; a browser
|
||||
// navigation sends Accept: text/html.
|
||||
func isAPIClient(c *zip.Ctx) bool {
|
||||
if auth := strings.TrimSpace(c.Header("Authorization")); auth != "" {
|
||||
return true
|
||||
}
|
||||
accept := c.Header("Accept")
|
||||
if accept != "" && !strings.Contains(accept, "text/html") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package featuregate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
luxlog "github.com/luxfi/log"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// testGate is the injected decide (the flags engine's WaitlistModeForHost seam):
|
||||
// hanzo.chat is gated, api.hanzo.ai is open, everything else is un-governed. This is
|
||||
// exactly what flags.WaitlistModeForHost returns for the equivalent registry, without
|
||||
// standing up the native flag engine (cgo) in a middleware unit test.
|
||||
func testGate(_ context.Context, host string) (mode bool, service string, known bool) {
|
||||
switch host {
|
||||
case "hanzo.chat":
|
||||
return true, "chat", true // gated
|
||||
case "api.hanzo.ai":
|
||||
return false, "api", true // open
|
||||
default:
|
||||
return false, "", false // un-governed
|
||||
}
|
||||
}
|
||||
|
||||
// gateApp mounts Enforce over the injected decide and a catch-all "ok" handler. The
|
||||
// injected approval status decides whether the caller is off the waitlist.
|
||||
func gateApp(t *testing.T, approvalStatus string) *zip.App {
|
||||
t.Helper()
|
||||
approvals := newApprovalsWithLookup(func(context.Context, string, string) (string, bool) {
|
||||
return approvalStatus, true
|
||||
}, time.Minute)
|
||||
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
app.Use(Enforce(EnforceConfig{WaitlistURL: "https://waitlist.hanzo.ai", Approvals: approvals, Gate: testGate}))
|
||||
app.Get("/*", func(c *zip.Ctx) error { return c.String(200, "ok") })
|
||||
return app
|
||||
}
|
||||
|
||||
type greq struct {
|
||||
host, path, user, org, accept string
|
||||
admin, approvedHdr, setApprov bool
|
||||
authorization string // raw Authorization header (e.g. "Bearer hk-…")
|
||||
apiKeyHeader string // raw api-key header value
|
||||
}
|
||||
|
||||
func drive(t *testing.T, app *zip.App, r greq) (int, string) {
|
||||
t.Helper()
|
||||
hr := httptest.NewRequest("GET", "http://"+r.host+r.path, nil)
|
||||
hr.Host = r.host
|
||||
if r.user != "" {
|
||||
hr.Header.Set("X-User-Id", r.user)
|
||||
}
|
||||
if r.org != "" {
|
||||
hr.Header.Set("X-Org-Id", r.org)
|
||||
}
|
||||
if r.admin {
|
||||
hr.Header.Set("X-User-IsAdmin", "true")
|
||||
}
|
||||
if r.setApprov {
|
||||
if r.approvedHdr {
|
||||
hr.Header.Set("X-User-Approved", "true")
|
||||
} else {
|
||||
hr.Header.Set("X-User-Approved", "false")
|
||||
}
|
||||
}
|
||||
if r.accept != "" {
|
||||
hr.Header.Set("Accept", r.accept)
|
||||
}
|
||||
if r.authorization != "" {
|
||||
hr.Header.Set("Authorization", r.authorization)
|
||||
}
|
||||
if r.apiKeyHeader != "" {
|
||||
hr.Header.Set("api-key", r.apiKeyHeader)
|
||||
}
|
||||
resp, err := app.Fiber().Test(hr)
|
||||
if err != nil {
|
||||
t.Fatalf("drive: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return resp.StatusCode, resp.Header.Get("Location")
|
||||
}
|
||||
|
||||
const html = "text/html,application/xhtml+xml"
|
||||
|
||||
// THE RULE — the acceptance matrix.
|
||||
|
||||
func TestRule_PendingUser_BouncedFromGatedHost(t *testing.T) {
|
||||
app := gateApp(t, "pending")
|
||||
// Browser → 302 to the waitlist.
|
||||
code, loc := drive(t, app, greq{host: "hanzo.chat", path: "/dashboard", user: "u", org: "acme", accept: html})
|
||||
if code != 302 || loc != "https://waitlist.hanzo.ai" {
|
||||
t.Fatalf("pending browser on gated host = %d loc=%q, want 302 → waitlist", code, loc)
|
||||
}
|
||||
// API (JSON accept) → 403.
|
||||
code, _ = drive(t, app, greq{host: "hanzo.chat", path: "/dashboard", user: "u", org: "acme", accept: "application/json"})
|
||||
if code != 403 {
|
||||
t.Fatalf("pending API on gated host = %d, want 403", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRule_ApprovedUser_ThroughGatedHost(t *testing.T) {
|
||||
app := gateApp(t, "approved")
|
||||
code, _ := drive(t, app, greq{host: "hanzo.chat", path: "/dashboard", user: "u", org: "acme", accept: html})
|
||||
if code != 200 {
|
||||
t.Fatalf("approved user on gated host = %d, want 200 (through)", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRule_ModeOffService_OpenToPendingUser(t *testing.T) {
|
||||
app := gateApp(t, "pending")
|
||||
// api.hanzo.ai is mode OFF → even a pending user passes.
|
||||
code, _ := drive(t, app, greq{host: "api.hanzo.ai", path: "/v1/chat/completions", user: "u", org: "acme", accept: "application/json"})
|
||||
if code != 200 {
|
||||
t.Fatalf("pending user on OPEN service = %d, want 200", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRule_Admin_ThroughGatedHost(t *testing.T) {
|
||||
app := gateApp(t, "pending") // status irrelevant — admin short-circuits
|
||||
code, _ := drive(t, app, greq{host: "hanzo.chat", path: "/dashboard", user: "z", org: "admin", admin: true, accept: html})
|
||||
if code != 200 {
|
||||
t.Fatalf("admin on gated host = %d, want 200", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRule_UnauthenticatedBrowser_BouncedToWaitlist(t *testing.T) {
|
||||
app := gateApp(t, "pending")
|
||||
code, loc := drive(t, app, greq{host: "hanzo.chat", path: "/dashboard", accept: html})
|
||||
if code != 302 || loc != "https://waitlist.hanzo.ai" {
|
||||
t.Fatalf("anon browser = %d loc=%q, want 302 → waitlist", code, loc)
|
||||
}
|
||||
// Anon API → 401 (authenticate first).
|
||||
code, _ = drive(t, app, greq{host: "hanzo.chat", path: "/dashboard", accept: "application/json"})
|
||||
if code != 401 {
|
||||
t.Fatalf("anon API = %d, want 401", code)
|
||||
}
|
||||
}
|
||||
|
||||
// MONEY-CRITICAL: a paid inference request with a Hanzo API key MUST flow through
|
||||
// Enforce even on a waitlist-ON host — it is possession-gated + billed downstream,
|
||||
// never waitlist-gated. Without the exemption THE RULE would 401 it and break
|
||||
// inference cluster-wide.
|
||||
func TestRule_APIKeyInference_NeverGated(t *testing.T) {
|
||||
app := gateApp(t, "pending")
|
||||
for _, key := range []string{"hk-43f50b6b", "sk-hz-abc", "pk-hz-obs", "fw_live_x", "hz_secret"} {
|
||||
// The exact paid-inference shape: Bearer key, JSON accept, NO session/user, on a
|
||||
// GATED host — the exemption, not mode, must carry it through.
|
||||
for _, p := range []string{"/v1/chat/completions", "/v1/models", "/v1/embeddings"} {
|
||||
code, _ := drive(t, app, greq{
|
||||
host: "hanzo.chat", path: p, accept: "application/json",
|
||||
authorization: "Bearer " + key,
|
||||
})
|
||||
if code != 200 {
|
||||
t.Fatalf("API key %q on %s = %d, want 200 (paid inference must NOT be waitlist-gated)", key, p, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The api-key / x-api-key header form is exempt too.
|
||||
code, _ := drive(t, app, greq{host: "hanzo.chat", path: "/v1/chat/completions", accept: "application/json", apiKeyHeader: "hk-headerform"})
|
||||
if code != 200 {
|
||||
t.Fatalf("api-key header inference = %d, want 200", code)
|
||||
}
|
||||
// A NON-key Bearer (a JWT-shaped token) from a pending user IS still gated — only
|
||||
// key possession is exempt, not arbitrary bearers.
|
||||
code, _ = drive(t, app, greq{host: "hanzo.chat", path: "/dashboard", user: "u", org: "acme", accept: "application/json", authorization: "Bearer eyJhbGciOi.jwt.sig"})
|
||||
if code != 403 {
|
||||
t.Fatalf("pending JWT bearer on gated host = %d, want 403 (only API keys are exempt)", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRule_UngovernedHost_PassesThrough(t *testing.T) {
|
||||
app := gateApp(t, "pending")
|
||||
code, _ := drive(t, app, greq{host: "example.com", path: "/whatever", user: "u", org: "acme", accept: html})
|
||||
if code != 200 {
|
||||
t.Fatalf("un-governed host = %d, want 200 (not ours to gate)", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRule_ExemptPaths_NeverGated(t *testing.T) {
|
||||
app := gateApp(t, "pending")
|
||||
for _, p := range []string{"/health", "/v1/iam/get-account", "/v1/waitlist/join", "/v1/featuregate/mode"} {
|
||||
code, _ := drive(t, app, greq{host: "hanzo.chat", path: p, user: "u", org: "acme", accept: html})
|
||||
if code != 200 {
|
||||
t.Fatalf("exempt path %q = %d, want 200 (never gated)", p, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRule_ForwardHeaderApproved_ThroughWithoutLookup(t *testing.T) {
|
||||
// Injected lookup returns pending, but the validated X-User-Approved header
|
||||
// (the forward-perfect path) says approved → the user passes with no lookup.
|
||||
app := gateApp(t, "pending")
|
||||
code, _ := drive(t, app, greq{host: "hanzo.chat", path: "/dashboard", user: "u", org: "acme",
|
||||
accept: html, setApprov: true, approvedHdr: true})
|
||||
if code != 200 {
|
||||
t.Fatalf("X-User-Approved=true on gated host = %d, want 200", code)
|
||||
}
|
||||
}
|
||||
|
||||
// The DEFAULT gate (nil Gate → flags.WaitlistModeForHost) fail-opens before the flags
|
||||
// engine has mounted: with no engine, WaitlistModeForHost returns known=false for every
|
||||
// host, so Enforce never gates pre-boot.
|
||||
func TestEnforce_DefaultGate_FailsOpenPreBoot(t *testing.T) {
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
app.Use(Enforce(EnforceConfig{WaitlistURL: "https://waitlist.hanzo.ai",
|
||||
Approvals: newApprovalsWithLookup(func(context.Context, string, string) (string, bool) { return "pending", true }, time.Minute)}))
|
||||
app.Get("/*", func(c *zip.Ctx) error { return c.String(200, "ok") })
|
||||
code, _ := drive(t, app, greq{host: "hanzo.chat", path: "/dashboard", user: "u", org: "acme", accept: html})
|
||||
if code != 200 {
|
||||
t.Fatalf("default gate pre-boot = %d, want 200 (never gate before flags mounts)", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package finance
|
||||
|
||||
import "context"
|
||||
|
||||
// UsageRow is one recorded usage debit — the READ twin of RecordUsage. The SAME
|
||||
// wallet→revenue posting a metered call wrote is read back here, so the usage a
|
||||
// customer SEES is exactly what drained their wallet. Cents is the debit magnitude
|
||||
// (USD minor units); Model is the metered-unit label the debit carried (Entry.Memo);
|
||||
// CreatedAt is unix seconds.
|
||||
type UsageRow struct {
|
||||
ID string `json:"id"`
|
||||
Cents int64 `json:"cents"`
|
||||
Model string `json:"model"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
// ListUsage returns org's recorded usage debits, most-recent-first, up to limit
|
||||
// (limit <= 0 lists all). It reads the org's OWN finance file — the file IS the
|
||||
// tenant boundary, so this can only ever return the caller's org's usage — and keeps
|
||||
// ONLY the usage-debit entries (a deposit/grant is not usage).
|
||||
//
|
||||
// It is the CO-RESIDENT read the customer billing surface uses INSTEAD of the S2S
|
||||
// HTTP hop: co-resident, commerce's own /v1/billing/usage route is not compiled into
|
||||
// this binary, so proxying that path self-dispatches straight back into the customer
|
||||
// handler. Reading the ledger here is the same move balance() already makes, so the
|
||||
// usage view can never self-answer "sign in to view billing".
|
||||
func (f *ledgerFinance) ListUsage(ctx context.Context, org string, limit int) ([]UsageRow, error) {
|
||||
store, err := f.storeFor(org, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := store.Entries(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]UsageRow, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.Kind != kindUsage {
|
||||
continue // deposits/grants are credits, not usage
|
||||
}
|
||||
rows = append(rows, UsageRow{
|
||||
ID: e.ID,
|
||||
Cents: e.Amount.Cents(),
|
||||
Model: e.Memo,
|
||||
CreatedAt: e.CreatedAt,
|
||||
})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user