Compare commits
360
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 | ||
|
|
0a41f07085 | ||
|
|
bb3eb01f5e | ||
|
|
cabbfa297a | ||
|
|
0964d4f4d8 | ||
|
|
bb9bcbf307 | ||
|
|
9347a5671f | ||
|
|
a4cb8e9305 | ||
|
|
c24364985a | ||
|
|
3f31ac709d | ||
|
|
663571f690 | ||
|
|
c29d96a70c | ||
|
|
f36c99a109 | ||
|
|
cabe44f5df | ||
|
|
d5c3688eef | ||
|
|
7f2584cd4e | ||
|
|
4a3bd68917 | ||
|
|
6ac6ece3bf | ||
|
|
b81a0fbdb7 | ||
|
|
c94dba9794 | ||
|
|
14034b7b41 | ||
|
|
f269f3156a | ||
|
|
c6b31666d1 | ||
|
|
986dc9d58e | ||
|
|
8477d02e37 | ||
|
|
f601ae8885 | ||
|
|
21bf5c8e43 | ||
|
|
f9ec50bbc3 | ||
|
|
f36732c507 | ||
|
|
6cb081127f | ||
|
|
19371b7a8f | ||
|
|
3f8afeb5e9 | ||
|
|
73ffedd136 | ||
|
|
72ffc555b7 | ||
|
|
16c6d300da | ||
|
|
9db9c53d89 | ||
|
|
2fa922250b | ||
|
|
64b233dfe1 | ||
|
|
3cdcf062c0 | ||
|
|
54657f5da1 | ||
|
|
ca78743549 | ||
|
|
4e88b76672 | ||
|
|
6a071d2a52 | ||
|
|
14e9c32c4f | ||
|
|
12f60dfed3 | ||
|
|
7e6a580cab | ||
|
|
e76f1b3185 | ||
|
|
6dc3c6bb1e | ||
|
|
ab58eb4de2 | ||
|
|
cef39aacfe | ||
|
|
5fe4b68a33 | ||
|
|
3f349107e4 | ||
|
|
e49eada0f2 | ||
|
|
874a6f63b6 | ||
|
|
81b6fc8df8 | ||
|
|
9fb946ff96 | ||
|
|
efd9f71cf6 | ||
|
|
5b2d742a84 | ||
|
|
6a017c1f37 | ||
|
|
3f24e1a81b | ||
|
|
96b9915b07 | ||
|
|
2fb181206a | ||
|
|
3c476cbd5d | ||
|
|
9bed31d068 | ||
|
|
ce416c4798 | ||
|
|
67b7cfcfdc | ||
|
|
71c9611461 | ||
|
|
dac8f15f6b | ||
|
|
4c0972a965 | ||
|
|
3fc3446bce | ||
|
|
683e0e0045 | ||
|
|
863f61e9a2 | ||
|
|
478f784b34 | ||
|
|
ae4a9369a2 | ||
|
|
e622032d79 | ||
|
|
b6274fbef7 | ||
|
|
57443cecee | ||
|
|
f10800c332 | ||
|
|
ae3edfbf2a | ||
|
|
3f62dbfeb9 | ||
|
|
36c56e7e51 | ||
|
|
5aa185f4bb | ||
|
|
bc3f8d1dc0 | ||
|
|
5d56acfb88 | ||
|
|
ef1b674bee | ||
|
|
728cb0a283 | ||
|
|
bc94982a4c | ||
|
|
68a5546f52 | ||
|
|
c9a982a6ca | ||
|
|
db4e6a18a7 | ||
|
|
60ba58ed55 | ||
|
|
d30112bad7 | ||
|
|
5c83e09cc7 | ||
|
|
dc22bdfa66 | ||
|
|
f09ac084d7 | ||
|
|
1b302ebf57 | ||
|
|
9b6e53d03b | ||
|
|
4dc06a9a30 | ||
|
|
b003a202b8 | ||
|
|
3ec43678de | ||
|
|
4e3877cb8f | ||
|
|
23c2ba42d8 | ||
|
|
1f25b417e5 | ||
|
|
c7c5c96a79 | ||
|
|
c99ea4afd4 | ||
|
|
9fedec0c33 | ||
|
|
d317c8f4dd | ||
|
|
6ed3b15685 | ||
|
|
3909afd435 | ||
|
|
2fa5262532 | ||
|
|
697dcf677a | ||
|
|
3d860256d1 | ||
|
|
d4613a1a97 | ||
|
|
b8d6d9ee0c | ||
|
|
4f00303e73 | ||
|
|
a272085354 | ||
|
|
bc4e73f46a | ||
|
|
da909ee96b | ||
|
|
4d7f61327d | ||
|
|
e8309cbcaa | ||
|
|
615eceec9c | ||
|
|
aab2e3298b | ||
|
|
77b1e1a4f1 | ||
|
|
3d93b17e79 | ||
|
|
427e781149 | ||
|
|
dec8130df5 | ||
|
|
ae4b6eb06b | ||
|
|
99f6209a80 | ||
|
|
a9337fcbcd | ||
|
|
7c7cb8f06c | ||
|
|
fc87e6c0d0 | ||
|
|
47b0a36b8b | ||
|
|
7991397320 | ||
|
|
e977047a2f | ||
|
|
a932834b64 | ||
|
|
a6ae479c32 | ||
|
|
c73cc1ce4f | ||
|
|
a6f1fa59d4 | ||
|
|
5ab750387a | ||
|
|
300eda4bd9 | ||
|
|
39889ae44d | ||
|
|
fcc506b038 | ||
|
|
2e488527f2 | ||
|
|
78373130da | ||
|
|
7f4b166b49 | ||
|
|
a24d6324b8 | ||
|
|
1ec565c4a5 | ||
|
|
2e775ed572 | ||
|
|
2cd359fdb2 | ||
|
|
cde95fae20 | ||
|
|
720f58d599 | ||
|
|
4a3ef817e6 | ||
|
|
04a8bc55ab | ||
|
|
57c96c251f | ||
|
|
1594706f5a | ||
|
|
480e7eb51c | ||
|
|
f1ed51ea06 | ||
|
|
c4350b2b0f | ||
|
|
66761a3600 | ||
|
|
85af41bb35 | ||
|
|
557bebe577 | ||
|
|
c824ae56b1 | ||
|
|
4b6c0bbaf6 | ||
|
|
8546343592 | ||
|
|
9e6832ca33 | ||
|
|
d5c4f491f2 | ||
|
|
f7555233ad | ||
|
|
ce177d2042 | ||
|
|
33a9027b34 | ||
|
|
cd800e8d66 | ||
|
|
77a30d1f99 | ||
|
|
82375164c0 | ||
|
|
06cad7ce9d | ||
|
|
68978ed174 | ||
|
|
88610b6b8b | ||
|
|
87a950b377 | ||
|
|
e11dfe19d1 | ||
|
|
33de017fd2 | ||
|
|
6ddf02a7a4 | ||
|
|
e3b1fa604d | ||
|
|
7f0666a260 | ||
|
|
e3c544de2b | ||
|
|
1c3206dcbf | ||
|
|
a75cfa2f2c | ||
|
|
1d1b2fd396 | ||
|
|
ece1560d2b | ||
|
|
5267caa5a6 | ||
|
|
c0a0ec73e8 | ||
|
|
cca930891e | ||
|
|
33ce0f29f0 | ||
|
|
cd888836a6 | ||
|
|
e23416cdb1 | ||
|
|
7b8134b882 | ||
|
|
9aeb3312e7 | ||
|
|
13a77b7f91 | ||
|
|
0f1b2a5fe5 | ||
|
|
9bd1c0ec3b | ||
|
|
ed95fef1c7 | ||
|
|
47dd91c6d2 | ||
|
|
bd0709b723 | ||
|
|
5efe43770a | ||
|
|
55feb39624 | ||
|
|
526455b098 | ||
|
|
4fbbbccd18 | ||
|
|
04bda86558 | ||
|
|
1abb1676a5 | ||
|
|
456a60be22 | ||
|
|
14c89a2565 | ||
|
|
e2c21e6d9a | ||
|
|
40c8b59d85 | ||
|
|
2370bd3acf | ||
|
|
850d4c0dad | ||
|
|
c39d2a13bc | ||
|
|
4e20e0234c | ||
|
|
14d5fab57f | ||
|
|
6f3c6ffb3d | ||
|
|
8636760c2f | ||
|
|
7c125ba373 | ||
|
|
d741a0848f | ||
|
|
35bb240e5c | ||
|
|
f1ceb4e889 | ||
|
|
f0d9f8cefb | ||
|
|
798a85fddf |
@@ -0,0 +1,12 @@
|
||||
# ~7-line canonical caller — all real config lives in /hanzo.yml.
|
||||
# Test gate on OUR arc pool; release.yml owns the image + v* tags.
|
||||
name: CI/CD
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
jobs:
|
||||
cicd:
|
||||
uses: hanzoai/ci/.github/workflows/build.yml@v1
|
||||
secrets: inherit
|
||||
@@ -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"
|
||||
|
||||
|
||||
+296
-45
@@ -67,8 +67,12 @@ jobs:
|
||||
# ARC ephemeral runners match jobs targeting the scale-set NAME as a label.
|
||||
runs-on: [hanzo-build-linux-amd64]
|
||||
outputs:
|
||||
version: ${{ steps.ver.outputs.version }}
|
||||
version_v: ${{ steps.ver.outputs.version_v }}
|
||||
# The FINAL assigned version comes from the Tag step (atomic free-version
|
||||
# assignment), NOT the compute step — under concurrency the compute value may
|
||||
# have been superseded. notify-universe must roll the version that was actually
|
||||
# tagged + whose image was actually retagged.
|
||||
version: ${{ steps.tag.outputs.version }}
|
||||
version_v: ${{ steps.tag.outputs.version_v }}
|
||||
steps:
|
||||
- name: Checkout (full history + all tags — the version floor is read from tags)
|
||||
uses: actions/checkout@v4
|
||||
@@ -92,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
|
||||
|
||||
@@ -106,12 +110,12 @@ jobs:
|
||||
major="${max%%.*}"; rest="${max#*.}"; minor="${rest%%.*}"; patch="${rest##*.}"
|
||||
version="${major}.${minor}.$((patch + 1))"
|
||||
|
||||
# Refuse to proceed if the number we intend to mint already exists as a
|
||||
# git tag (a concurrent run beat us — the serialized lane makes this a
|
||||
# can't-happen, but fail loud rather than clobber).
|
||||
# This version is a STARTING HINT only. It labels the smoke-built image and
|
||||
# seeds the OCI metadata; the FINAL version is assigned atomically in the Tag
|
||||
# step (which recomputes + retries on collision), so a taken number here is
|
||||
# NOT fatal — the Tag step finds the next free one. Just note it and proceed.
|
||||
if git rev-parse -q --verify "refs/tags/v${version}" >/dev/null; then
|
||||
echo "::error::computed v${version} already exists as a git tag — aborting to avoid collision"
|
||||
exit 1
|
||||
echo "note: hint v${version} already tagged — the Tag step will assign the next free version"
|
||||
fi
|
||||
|
||||
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||
@@ -140,6 +144,45 @@ jobs:
|
||||
driver: docker-container
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Mirror credential (registry.hanzo.ai)
|
||||
# Dual-host: pull the KMS deploy kubeconfig (same Universal Auth flow
|
||||
# the hanzoai/ci reusable uses), read the cluster-synced
|
||||
# registry-credentials dockerconfig, and log in. Best-effort: absent
|
||||
# creds → GHCR-only release, never a blocked tag.
|
||||
env:
|
||||
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
|
||||
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}
|
||||
KMS_ENDPOINT: ${{ vars.KMS_ENDPOINT || 'https://kms.hanzo.ai' }}
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
# Direct credential first (repo/org secret — works on private repos,
|
||||
# where the Free plan hides org KMS secrets); KMS kubeconfig fallback.
|
||||
if [ -n "${REGISTRY_USER:-}" ] && [ -n "${REGISTRY_PASSWORD:-}" ]; then
|
||||
echo "$REGISTRY_PASSWORD" | docker login registry.hanzo.ai -u "$REGISTRY_USER" --password-stdin
|
||||
echo "MIRROR_OK=1" >> "$GITHUB_ENV"; exit 0
|
||||
fi
|
||||
[ -z "${KMS_CLIENT_ID:-}" ] && { echo "no KMS creds — mirror skipped"; exit 0; }
|
||||
TOKEN=$(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')
|
||||
[ -z "$TOKEN" ] && { echo "KMS login failed — mirror skipped"; exit 0; }
|
||||
KC=$(curl -sf "$KMS_ENDPOINT/v1/kms/orgs/hanzo/secrets/deploy/KUBECONFIG?env=prod" -H "Authorization: Bearer $TOKEN" | jq -r '.secret.value // empty')
|
||||
[ -z "$KC" ] && { echo "no KUBECONFIG in KMS — mirror skipped"; exit 0; }
|
||||
echo "$KC" | base64 -d > "$RUNNER_TEMP/kubeconfig"
|
||||
command -v kubectl >/dev/null 2>&1 || {
|
||||
KVER=$(curl -fsSL https://dl.k8s.io/release/stable.txt)
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
curl -fsSL "https://dl.k8s.io/release/${KVER}/bin/linux/amd64/kubectl" -o "$HOME/.local/bin/kubectl" && chmod +x "$HOME/.local/bin/kubectl"
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
}
|
||||
CFG=$(KUBECONFIG="$RUNNER_TEMP/kubeconfig" kubectl -n hanzo get secret registry-credentials -o jsonpath='{.data.\.dockerconfigjson}' 2>/dev/null | base64 -d || true)
|
||||
[ -z "$CFG" ] && { echo "registry-credentials unreadable — mirror skipped"; exit 0; }
|
||||
UP=$(echo "$CFG" | jq -r '.auths["registry.hanzo.ai"].auth // empty' | base64 -d)
|
||||
[ -z "$UP" ] && { echo "no registry auth — mirror skipped"; exit 0; }
|
||||
echo "::add-mask::${UP#*:}"
|
||||
echo "${UP#*:}" | docker login registry.hanzo.ai -u "${UP%%:*}" --password-stdin
|
||||
echo "MIRROR_OK=1" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Log in to ghcr.io (GH_PAT — writes the cloud package despite its ai-repo linkage)
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -178,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: |
|
||||
@@ -250,6 +293,158 @@ 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
|
||||
# exactly why a DDL valid on a fresh store but broken on a pre-existing one (an
|
||||
# index over a not-yet-ADDed column: affiliates referrer_org in v1.800.1, wallets
|
||||
# project/agent before it) sailed through CI and took api.hanzo.ai down. This
|
||||
# step reproduces the REAL prod upgrade path: boot the PRIOR released image to lay
|
||||
# its on-disk (cek-encrypted) SQLite schema into a persistent volume, then boot
|
||||
# the candidate over that SAME volume and require it to still reach "listening".
|
||||
# A migrate() that assumes a fresh store dies here, before any image is pushed.
|
||||
- name: Migration smoke — candidate MUST boot over the PRIOR release's on-disk schema
|
||||
env:
|
||||
# The image whose on-disk schema a prod upgrade migrates FROM — the tag the
|
||||
# fleet runs today. Bump to the last-DEPLOYED tag as releases roll (override
|
||||
# without a code change via the SMOKE_MIGRATION_BASELINE repo/org variable).
|
||||
BASELINE: ${{ vars.SMOKE_MIGRATION_BASELINE }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BASELINE="${BASELINE:-ghcr.io/hanzoai/cloud:v1.799.19}"
|
||||
CANDIDATE=cloud:smoke
|
||||
VOL="cloudmig-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
B1=""; B2=""
|
||||
cleanup() { docker rm -f "$B1" "$B2" >/dev/null 2>&1 || true; docker volume rm "$VOL" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT
|
||||
docker volume create "$VOL" >/dev/null
|
||||
|
||||
# ONE throwaway 32-byte master key for BOTH boots: cek seals each per-db DEK
|
||||
# under it on the baseline boot and unwraps it on the candidate boot. A
|
||||
# mismatched key fails closed (never opens), so sharing it is what puts the
|
||||
# MIGRATE path — not a decrypt error — under test.
|
||||
KEY="$(head -c 32 /dev/urandom | base64 | tr -d '\n')"
|
||||
|
||||
boot() { # $1=image $2=name -> prints container id
|
||||
docker run -d --name "$2" \
|
||||
-v "$VOL":/data \
|
||||
-e CLOUD_DATA_DIR=/data \
|
||||
-e CLOUD_ENV=smoke \
|
||||
-e CLOUD_KMS_MASTER_KEY_REF="$KEY" \
|
||||
"$1"
|
||||
}
|
||||
wait_listen() { # $1=container -> 0 if "listening", 1 if it died / timed out
|
||||
for _ in $(seq 1 90); do
|
||||
lg="$(docker logs "$1" 2>&1 || true)"
|
||||
printf '%s' "$lg" | grep -q '"message":"listening"' && return 0
|
||||
[ "$(docker inspect -f '{{.State.Running}}' "$1" 2>/dev/null || echo false)" != "true" ] && return 1
|
||||
sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
# A fresh docker named volume is root:root 0755, but the cloud image runs
|
||||
# NON-ROOT, so it cannot create cek's <db>.cek.lock under /data (the single-boot
|
||||
# smoke only worked because --tmpfs is world-writable). Make the shared volume
|
||||
# writable for BOTH boots, re-opening it between them so the candidate can read
|
||||
# the baseline's files even if their runtime UIDs differ.
|
||||
chmod_vol() { docker run --rm --user 0 -v "$VOL":/data --entrypoint sh "$CANDIDATE" -c 'chmod -R 0777 /data'; }
|
||||
|
||||
# Boot 1 — the prior release writes its real schema into the volume. It must
|
||||
# reach "listening" (proof every subsystem migrated + its DB is on disk); if
|
||||
# the pinned baseline can't boot in this env the gate is blind, so fail loud.
|
||||
echo "migration baseline: $BASELINE"
|
||||
pulled=0; for _ in 1 2 3; do if docker pull "$BASELINE"; then pulled=1; break; fi; sleep 5; done
|
||||
[ "$pulled" = 1 ] || { echo "MIGRATION SMOKE INFRA FAIL: cannot pull baseline $BASELINE"; exit 1; }
|
||||
chmod_vol
|
||||
B1="$(boot "$BASELINE" cloudmig_base)"
|
||||
if ! wait_listen "$B1"; then
|
||||
echo "::group::baseline boot logs"; docker logs "$B1" 2>&1 || true; echo "::endgroup::"
|
||||
echo "MIGRATION SMOKE INFRA FAIL: baseline $BASELINE did not reach \"listening\" — cannot stage the prior schema (inspect/bump SMOKE_MIGRATION_BASELINE)"
|
||||
exit 1
|
||||
fi
|
||||
docker stop "$B1" >/dev/null
|
||||
chmod_vol
|
||||
|
||||
# Boot 2 — the candidate migrates that on-disk schema IN PLACE. This is the gate.
|
||||
B2="$(boot "$CANDIDATE" cloudmig_cand)"
|
||||
listening=0; wait_listen "$B2" && listening=1
|
||||
logs="$(docker logs "$B2" 2>&1 || true)"
|
||||
echo "::group::candidate migration boot logs"; printf '%s\n' "$logs"; echo "::endgroup::"
|
||||
|
||||
# 'no such column'/'no such table' is the exact index-before-ADD-COLUMN crash;
|
||||
# 'panic' catches any generic Mount failure. The load-bearing check is the
|
||||
# "listening" assertion below — a migrate() crash exits before it.
|
||||
if printf '%s' "$logs" | grep -Eiq 'panic|no such column|no such table'; then
|
||||
echo "MIGRATION SMOKE FAIL: candidate logged a DDL/migration error over the prior schema (the v1.800.1-class regression)"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$listening" -ne 1 ]; then
|
||||
echo "MIGRATION SMOKE FAIL: candidate did NOT reach \"listening\" over the $BASELINE schema — a subsystem's migrate() crashes on a pre-existing store"
|
||||
exit 1
|
||||
fi
|
||||
echo "MIGRATION SMOKE PASS: candidate booted to \"listening\" over the $BASELINE on-disk schema"
|
||||
|
||||
- name: Push (cache hit from the smoke build — publishes the tested image)
|
||||
id: push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -257,10 +452,13 @@ jobs:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
# Push ONLY the immutable, per-commit sha- tag (always unique — never races)
|
||||
# and the floating latest. The v<X.Y.Z> version is NOT pushed here: the
|
||||
# compute-step version may be claimed by a concurrent release between compute
|
||||
# and now, and pushing it would clobber that release's :vX image (mutable tag
|
||||
# corruption). The version is assigned + the proven sha-image retagged to it
|
||||
# ATOMICALLY in the Tag step below, so :vX exists iff its git tag exists.
|
||||
tags: |
|
||||
ghcr.io/hanzoai/cloud:${{ steps.ver.outputs.version_v }}
|
||||
ghcr.io/hanzoai/cloud:${{ steps.ver.outputs.version }}
|
||||
ghcr.io/hanzoai/cloud:${{ steps.ver.outputs.major_minor }}
|
||||
ghcr.io/hanzoai/cloud:sha-${{ steps.ver.outputs.sha_short }}
|
||||
ghcr.io/hanzoai/cloud:latest
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -269,41 +467,94 @@ jobs:
|
||||
build-args: |
|
||||
CONSOLE_CACHEBUST=${{ steps.ver.outputs.cachebust }}
|
||||
secrets: |
|
||||
gh_token=${{ secrets.GH_PAT }}
|
||||
GIT_AUTH_TOKEN=${{ secrets.GH_PAT }}
|
||||
|
||||
# THE RECEIPT: reached only because build + smoke + push all succeeded. If
|
||||
# any of them failed the job already stopped and no tag was minted.
|
||||
- name: Tag the proven image (git tag = receipt for a pushed, smoke-passed image)
|
||||
# THE RECEIPT + ATOMIC VERSION ASSIGNMENT (race-safe). Reached only because
|
||||
# build + smoke + push all succeeded, so a proven image exists under the unique
|
||||
# sha- tag. Here we assign the next FREE v<X.Y.Z> and retag that proven image to
|
||||
# it — atomically, with the git-tag push as the serialization point:
|
||||
# * Recompute the next version FRESH (compute-step's value may have been claimed
|
||||
# by a concurrent release in the build window).
|
||||
# * If that version's git tag already exists, bump and retry.
|
||||
# * Retag the proven sha-image → :vX (+ :X.Y.Z + :X.Y) via imagetools (metadata
|
||||
# only, NO rebuild — byte-identical to the smoke-passed image).
|
||||
# * Push the git tag; the FIRST pusher of vX wins, a loser deletes its local tag
|
||||
# and recomputes. So concurrent releases each grab a distinct free number and
|
||||
# the invariant "git tag vX ⇔ image :vX pushed+smoke-passed" holds under race.
|
||||
- name: Tag the proven image (atomic free-version assignment — race-safe)
|
||||
id: tag
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
V="${{ steps.ver.outputs.version_v }}"
|
||||
git config user.name "hanzo-dev"
|
||||
git config user.email "dev@hanzo.ai"
|
||||
git tag -a "$V" -m "release $V — image ghcr.io/hanzoai/cloud:$V pushed and smoke-passed (${GITHUB_SHA})"
|
||||
git push "https://x-access-token:${GH_PAT}@github.com/${GITHUB_REPOSITORY}.git" "$V"
|
||||
echo "Tagged $V → ghcr.io/hanzoai/cloud:$V"
|
||||
SHA_IMG="ghcr.io/hanzoai/cloud:sha-${{ steps.ver.outputs.sha_short }}"
|
||||
PUSH_URL="https://x-access-token:${GH_PAT}@github.com/${GITHUB_REPOSITORY}.git"
|
||||
for attempt in $(seq 1 8); do
|
||||
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 \
|
||||
'/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" \
|
||||
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)"
|
||||
major="${max%%.*}"; rest="${max#*.}"; minor="${rest%%.*}"; patch="${rest##*.}"
|
||||
VER="${major}.${minor}.$((patch + 1))"; V="v${VER}"
|
||||
if git rev-parse -q --verify "refs/tags/$V" >/dev/null; then
|
||||
echo " $V already tagged — recomputing (attempt $attempt)"; sleep 3; continue
|
||||
fi
|
||||
docker buildx imagetools create \
|
||||
-t "ghcr.io/hanzoai/cloud:${V}" \
|
||||
-t "ghcr.io/hanzoai/cloud:${VER}" \
|
||||
-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. 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
|
||||
crane copy "$SHA_IMG" "registry.hanzo.ai/hanzoai/cloud:${MT}" \
|
||||
|| echo "::warning::mirror registry.hanzo.ai/hanzoai/cloud:${MT} failed"
|
||||
done
|
||||
fi
|
||||
git tag -a "$V" -m "release $V — image ghcr.io/hanzoai/cloud:$V (retagged from sha-${{ steps.ver.outputs.sha_short }}, smoke-passed ${GITHUB_SHA})"
|
||||
if git push "$PUSH_URL" "$V" 2>/dev/null; then
|
||||
echo "Tagged $V → ghcr.io/hanzoai/cloud:$V"
|
||||
echo "version=${VER}" >> "$GITHUB_OUTPUT"
|
||||
echo "version_v=${V}" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo " push of $V lost the race — recomputing (attempt $attempt)"
|
||||
git tag -d "$V" >/dev/null 2>&1 || true
|
||||
sleep 3
|
||||
done
|
||||
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
|
||||
|
||||
+110
-137
@@ -2,123 +2,59 @@
|
||||
#
|
||||
# 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
|
||||
RUN apk add --no-cache ca-certificates tzdata git gcc musl-dev sqlcipher-dev pkgconfig binutils
|
||||
# ── 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
|
||||
# is frozen by pinning sqlcipher-dev to an EXACT version. A repo bump then fails
|
||||
# the build LOUDLY (never a silent prod brick); on such a failure, bump the pin
|
||||
# AND confirm cek's frozen-fixture test still opens (format unchanged)
|
||||
# before shipping. A MAJOR bump (4.x → 5.x) changes the default format and would
|
||||
# orphan existing encrypted stores — migrate/rewrap them first.
|
||||
RUN apk add --no-cache ca-certificates tzdata git gcc musl-dev sqlcipher-dev=4.6.1-r0 pkgconfig binutils
|
||||
RUN addgroup -g 65532 -S nonroot && adduser -u 65532 -S nonroot -G nonroot
|
||||
# mattn/go-sqlite3's `libsqlite3` tag hard-codes `-lsqlite3`, but alpine's
|
||||
# sqlcipher-dev ships ONLY libsqlcipher (no libsqlite3.so). Symlink so the link
|
||||
@@ -130,38 +66,51 @@ 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 ./
|
||||
RUN --mount=type=secret,id=gh_token \
|
||||
--mount=type=cache,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/"; \
|
||||
# The cache mounts carry an EXPLICIT id so they can be busted. Without one,
|
||||
# BuildKit keys the cache by target path alone, and a poisoned entry is immortal:
|
||||
# a module resolved while its tag did not yet exist is remembered as "unknown
|
||||
# revision" forever, so `go mod download` keeps failing on a tag that now exists
|
||||
# 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=GIT_AUTH_TOKEN \
|
||||
--mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
|
||||
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,target=/go/pkg/mod,sharing=locked \
|
||||
--mount=type=cache,target=/root/.cache/go-build,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 \
|
||||
MODERNC="$(CGO_ENABLED=1 go list -tags "libsqlite3 sqlite_fts5" -deps ./cmd/cloud 2>/dev/null | grep -c 'modernc.org/sqlite' || true)"; \
|
||||
[ "$MODERNC" = "0" ] || { echo "SQLITE-GATE FAIL: cmd/cloud links modernc.org/sqlite ($MODERNC pkgs) under CGO=1 — double-registers \"sqlite\" with hanzoai/sqlite(mattn) and panics at init."; exit 1; }
|
||||
# RED gate — ENCRYPTION PROOF + the cek.go GOLDEN-VECTOR KAT, under the SAME CGO +
|
||||
@@ -170,20 +119,34 @@ RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
||||
# image). TestUnwrapGoldenFixture asserts a FROZEN pre-luxfi-swap 61-byte DEK
|
||||
# sidecar still decrypts under the shipped luxfi/crypto-AEAD code — existing
|
||||
# encrypted stores stay readable, or NO image.
|
||||
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
||||
--mount=type=cache,target=/root/.cache/go-build,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 \
|
||||
SQLITE_REQUIRE_CODEC=1 CGO_ENABLED=1 go test -count=1 -tags "libsqlite3 sqlite_fts5" \
|
||||
-run 'TestEncryptionProof|TestUnwrapGoldenFixture|TestWrapUnwrapRoundTripPinsLayout' \
|
||||
github.com/hanzoai/sqlite
|
||||
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
||||
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
|
||||
# RED gate — cek FROZEN-FORMAT guard, run INSIDE the image under the pinned Alpine
|
||||
# libsqlcipher: opens the committed encrypted fixture and reads its canary row. A
|
||||
# sqlcipher-dev pin/base bump that changes the on-disk format fails the IMAGE build
|
||||
# HERE (not only Go CI) → a silent prod brick of existing stores becomes a red build.
|
||||
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 \
|
||||
SQLITE_REQUIRE_CODEC=1 CGO_ENABLED=1 go test -count=1 -run TestFrozenFixtureOpens \
|
||||
-tags "libsqlite3 sqlite_fts5" ./cek
|
||||
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"
|
||||
@@ -191,7 +154,16 @@ LABEL org.opencontainers.image.revision="${REVISION}" \
|
||||
# a plaintext libsqlite3 — the binary's -lsqlite3 DT_NEEDED would then bind to
|
||||
# plaintext sqlite and silently no-op PRAGMA key. sqlcipher-libs ships
|
||||
# libsqlcipher.so.0; alias libsqlite3.so.0 to it so sqlite3_* binds there.
|
||||
RUN apk add --no-cache ca-certificates tzdata sqlcipher-libs \
|
||||
#
|
||||
# `git` backs the git object plane (clients/git): the heavy paths — clone/fetch
|
||||
# serve, push receive, mirror-in — shell out to the streaming git CLI
|
||||
# (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.
|
||||
# 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
|
||||
@@ -200,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,102 @@ 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`
|
||||
decomplects tool dispatch from its front doors: `dispatchTool` is the ONE core
|
||||
(resolve `<connector>_<action>` → run with a Token bound to the VALIDATED org),
|
||||
and TWO doors share it — the HTTP JSON-RPC handler (`POST /v1/automations/mcp`)
|
||||
and the exported `automations.InvokeTool(ctx, org, tool, args)`. A sibling
|
||||
subsystem that must ACT AS a caller (the Business AI guide's "do it for me")
|
||||
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
|
||||
injectable auto-detect registry (`detect.go` — `acted` reads the agent action
|
||||
ledger, `analytics` probes the shared warehouse) + the agent (`agent.go` — drafts
|
||||
with `deps.AI`, executes the step's bound tool via `automations.InvokeTool`). The
|
||||
curriculum is a machine-readable contract (embedded `default.yaml`; org-custom via
|
||||
PUT replaces it) so `hanzoai/marketing` can author the full `checklist.yaml`
|
||||
against the same `Step`/`Curriculum` shape.
|
||||
|
||||
## Identity vocabulary is IAM-native
|
||||
|
||||
Identity is expressed ONLY in IAM-native nouns: **org, user, project, billing
|
||||
@@ -58,3 +163,24 @@ HIP-0026); never read a raw request header for scope.
|
||||
behind a `// NAMING(gated)` note in `clients/platform/k8s.go`. The surrounding
|
||||
identity vocabulary is org-native regardless; only the on-cluster string waits on
|
||||
an infrastructure migration.
|
||||
|
||||
## Hanzo Company (`clients/company`, `/v1/company`)
|
||||
|
||||
The Stripe-Atlas-class incorporation + fundraising product: ONE formation state
|
||||
machine per org. `machine.go` is the PURE core — a `transitions` table with a guard
|
||||
per edge, `Advance(f, to)` the only mutator — so transitions, the payment gate, and
|
||||
the skip path are unit-testable with no I/O. The HTTP surface is decomplected: ACTION
|
||||
endpoints populate data (structure/founders/kyc/payment/documents/esign/genesis/
|
||||
import), and ONE `POST /v1/company/advance {to}` runs the guarded transition.
|
||||
|
||||
Every external dependency is a narrow provider interface (`providers.go`) so the
|
||||
machine composes them identically in prod and tests: billing → the shared
|
||||
`ResourceMeter` ($999 one-time fee); documents → `dataroom.Ingest` (new in-proc
|
||||
facade); cap table → `captable.*` (new in-proc facades: SetIncorporation /
|
||||
AddStakeholders / EnsureShareClass / IssueShares / RecordRound); equity genesis →
|
||||
a KMS-signed Hanzo-L1 anchor mirroring `clients/treasury` (honest pending when
|
||||
unwired); KYC + state filing → honest stubs (no fabricated verification/filing).
|
||||
Import path (already-incorporated orgs): Google Drive → data room, a Google Sheet →
|
||||
captable, via the `google` OAuth provider now completed in `clients/integrations`
|
||||
(token custodied in KMS; the automations `google` connector shares the same token).
|
||||
Runbook: `docs/company-dogfood.md`.
|
||||
|
||||
@@ -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
|
||||
|
||||
+431
@@ -0,0 +1,431 @@
|
||||
// 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
|
||||
// is no init()-registry and no order-int: adding, removing, or reordering a
|
||||
// subsystem is a one-line edit to Wire(), read top-to-bottom. cmd/cloud and
|
||||
// cmd/hanzo both call Wire() and thread the slice into cloud.Serve — the set is
|
||||
// defined ONCE, here.
|
||||
//
|
||||
// (This package must NOT live in package cloud: the subsystems import cloud for
|
||||
// Deps + Typed, so a root-package bundle would form an import cycle. As a sibling
|
||||
// subpackage it composes them without one.)
|
||||
//
|
||||
// HIP-0106: the unified cloud binary is the APPLICATION layer plus the embedded KMS
|
||||
// secrets plane and the embedded IAM identity plane ("one Go binary embeds IAM +
|
||||
// KMS + o11y"). The edge/infra tier (mcp, gateway, ingress-edge) runs as its own
|
||||
// deployments for blast-radius isolation; several application folds (iam, base,
|
||||
// commerce, captable, dataroom, sign, ingress) are STAGED — linked here but mounted
|
||||
// only when the operator names them in CLOUD_ENABLE.
|
||||
//
|
||||
// Ordering provenance: order-int ascending; ties in the exact order the
|
||||
// pre-refactor init()-registry mounted them, captured empirically from origin/main
|
||||
// @c504d2b (68 self-registering specs) and frozen by TestWireOrderMatchesFrozen
|
||||
// (wire_test.go). ai (@150) and the hanzoai/o11y module wildcard (@70) are NOT in
|
||||
// that dump: on their wave-2 tags (ai v1.805.2, o11y v1.5.12) they no longer
|
||||
// self-register, so origin/main currently DROPS them (a latent regression this
|
||||
// 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.
|
||||
//
|
||||
//go:generate go run ../cmd/gen-app-cmds
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
// External subsystem modules. As of the atomic wave-2 bump they NO LONGER
|
||||
// self-register (no cloud.Register in their init) — the composition root wires
|
||||
// each one explicitly below, so removing an entry here is the ONLY way to drop it.
|
||||
"github.com/hanzoai/ai"
|
||||
"github.com/hanzoai/authz"
|
||||
"github.com/hanzoai/licensing"
|
||||
"github.com/hanzoai/metrics"
|
||||
|
||||
// In-repo subsystem packages (clients/*). Each exports a Mount (and, where it
|
||||
// owns process-lifetime resources, a Shutdown); Wire references them directly.
|
||||
"github.com/hanzoai/cloud/clients/account"
|
||||
"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"
|
||||
"github.com/hanzoai/cloud/clients/auditlog"
|
||||
"github.com/hanzoai/cloud/clients/authors"
|
||||
"github.com/hanzoai/cloud/clients/automations"
|
||||
"github.com/hanzoai/cloud/clients/base"
|
||||
"github.com/hanzoai/cloud/clients/billing"
|
||||
"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/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/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"
|
||||
"github.com/hanzoai/cloud/clients/notify"
|
||||
"github.com/hanzoai/cloud/clients/o11y"
|
||||
"github.com/hanzoai/cloud/clients/paas"
|
||||
"github.com/hanzoai/cloud/clients/plan"
|
||||
"github.com/hanzoai/cloud/clients/platform"
|
||||
"github.com/hanzoai/cloud/clients/plugin"
|
||||
"github.com/hanzoai/cloud/clients/pricing"
|
||||
"github.com/hanzoai/cloud/clients/product"
|
||||
"github.com/hanzoai/cloud/clients/projects"
|
||||
"github.com/hanzoai/cloud/clients/prompts"
|
||||
"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"
|
||||
"github.com/hanzoai/cloud/clients/tools"
|
||||
"github.com/hanzoai/cloud/clients/tracker"
|
||||
"github.com/hanzoai/cloud/clients/treasury"
|
||||
"github.com/hanzoai/cloud/clients/usage"
|
||||
"github.com/hanzoai/cloud/clients/visor"
|
||||
"github.com/hanzoai/cloud/clients/wallets"
|
||||
"github.com/hanzoai/cloud/clients/websearch"
|
||||
"github.com/hanzoai/cloud/clients/world"
|
||||
"github.com/hanzoai/cloud/clients/x402"
|
||||
"github.com/hanzoai/cloud/clients/zt"
|
||||
|
||||
// Framework CONTENT modules — NOT mount subsystems (they carry no HTTP surface
|
||||
// and are absent from Wire()). Each registers its DocType fixtures and, for erp,
|
||||
// its ledger-posting lifecycle hooks into the clients/framework DocType engine
|
||||
// from a package init() (framework.RegisterModule) — the idiomatic
|
||||
// register-into-a-registry pattern (cf. database/sql drivers). The framework
|
||||
// engine is mounted (always-on, /v1/framework/*) but its module registry is
|
||||
// populated ONLY by these blank imports. Dropping one silently strips that
|
||||
// lane's DocTypes and hooks — for erp, the immutable ledger postings — from the
|
||||
// binary with NO mount change and NO failing mount test. #248 dropped them;
|
||||
// TestFrameworkContentModulesLinked now guards against a recurrence. Keep.
|
||||
_ "github.com/hanzoai/cloud/clients/cms"
|
||||
_ "github.com/hanzoai/cloud/clients/erp"
|
||||
_ "github.com/hanzoai/cloud/clients/help"
|
||||
)
|
||||
|
||||
// init wires the cross-subsystem func seams — the composition root is the one place
|
||||
// that may import two leaf subsystems at once, so a connection neither can express
|
||||
// alone lives here. git's push→index reactor calls the code index without git
|
||||
// importing code: the adapter converts git's IndexedFile to code's File and drops the
|
||||
// result (the reactor only needs success/failure). Stored once at load; invoked on
|
||||
// push, long after mount, so there is no ordering dependency. Same
|
||||
// register-into-a-registry idiom the framework content modules use above.
|
||||
func init() {
|
||||
git.SetIndexer(func(ctx context.Context, org, billingOrg, project, repo string, files []git.IndexedFile) error {
|
||||
in := make([]code.File, len(files))
|
||||
for i, f := range files {
|
||||
in[i] = code.File{Path: f.Path, Content: f.Content}
|
||||
}
|
||||
_, err := code.IndexFiles(ctx, org, billingOrg, project, repo, in)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// 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).
|
||||
// Enablement is a separate axis: cloud.Serve mounts only the specs cfg.Enabled(name)
|
||||
// admits, so a STAGED subsystem is linked but inert until named.
|
||||
func Wire() []cloud.MountSpec {
|
||||
return []cloud.MountSpec{
|
||||
// embedded NATS :4222 + JetStream.
|
||||
{Name: "pubsub", Mount: pubsub.Mount, Shutdown: pubsub.Shutdown},
|
||||
// embedded Kafka adaptor :9092.
|
||||
{Name: "kafka", Mount: kafka.Mount, Shutdown: kafka.Shutdown},
|
||||
// /.well-known/agent-skills/* — before IAM's /.well-known/* wildcard (50).
|
||||
{Name: "agentskills", Mount: agentskills.Mount},
|
||||
// Insights feature-flag evaluation seam (no routes; a hot value plane).
|
||||
{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: 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: 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: 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.
|
||||
// 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: 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: mountCommerce},
|
||||
{Name: "licensing", Mount: licensing.Mount},
|
||||
{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: storage.Mount, OwnsHealth: true},
|
||||
// Provisioning control plane: /v1/sql,/v1/vector,/v1/datastore,/v1/kv,/v1/search,/v1/s3,/v1/docdb.
|
||||
{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: 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: 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/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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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
|
||||
// to zen's serving layer in-process (identity, tools, 1M ladder, codec) and
|
||||
// c.Next()s everything else to ai. zen owns the zen family; ai owns every
|
||||
// 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: ai.Mount},
|
||||
// Runtime wasm/proxy plugins — mounts dead last.
|
||||
{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(a *zip.App, deps cloud.Deps) error {
|
||||
return metrics.Mount(a, metrics.Deps{Logger: deps.Logger, DataDir: deps.DataDir, Brand: deps.Brand})
|
||||
}
|
||||
|
||||
// ctxShutdown adapts a subsystem's zero-arg Shutdown() error to the
|
||||
// cloud.ShutdownFunc(ctx) signature. Several subsystems expose the simpler form
|
||||
// (their teardown ignores the deadline); this bridges the impedance mismatch in ONE
|
||||
// place so the Wire entries stay declarative — no inline closures.
|
||||
func ctxShutdown(f func() error) cloud.ShutdownFunc {
|
||||
return func(context.Context) error { return f() }
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// Copyright © 2026 Hanzo AI. MIT License.
|
||||
|
||||
// commerce.go mounts the hanzoai/commerce MODULE into the unified cloud binary
|
||||
// (HIP-0106) via the NATIVE co-residence contract: commerce registers its routes
|
||||
// directly on the HOST's zip app (EmbedConfig.App) — one router, one specificity
|
||||
// space, zero handler adaptation. This adapter narrows cloud.Deps, boots the
|
||||
// embed, and wires the in-process seams. Direction is one-way: cloud → commerce.
|
||||
//
|
||||
// PCI SCOPE. Commerce is a LIGHT ROUTER, NOT in PCI-DSS scope: tokens + intent IDs
|
||||
// only, NEVER a PAN. PAN-touching paths call the out-of-process Payments / Vault
|
||||
// (ZAP-RPC); when those clients are absent the payment handlers fail closed while
|
||||
// tenant config + admin stay served — mountCommerce warns loudly at startup.
|
||||
//
|
||||
// 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 apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/commerceclient"
|
||||
"github.com/hanzoai/cloud/clients/commerceinproc"
|
||||
"github.com/hanzoai/commerce"
|
||||
commercebilling "github.com/hanzoai/commerce/api/billing"
|
||||
commercestore "github.com/hanzoai/commerce/api/store"
|
||||
commercemid "github.com/hanzoai/commerce/middleware"
|
||||
"github.com/hanzoai/commerce/middleware/iammiddleware"
|
||||
log "github.com/luxfi/log"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// In-process CommerceClient factory — pickCommerceClient calls it when the
|
||||
// commerce subsystem is enabled. Registered HERE (not called directly from
|
||||
// package cloud) because commerceclient's entitlement client imports
|
||||
// clients/plan, which imports cloud: the hook keeps the package graph acyclic.
|
||||
cloud.RegisterCommerceClientFactory(func(cfg *cloud.Config, _ log.Logger) cloud.CommerceClient {
|
||||
return commerceclient.InProcessClient(cfg.Brand)
|
||||
})
|
||||
}
|
||||
|
||||
// commercePrefixes is every root path the commerce surface owns on the shared
|
||||
// app. Under the native SharedApp contract most of these are registered by
|
||||
// commerce's own setupRoutes; the list is the fail-closed 503 set AND the wire
|
||||
// contract commerce_prefix_test pins — the route families a session gate or the
|
||||
// AI /v1/* catch-all must never swallow:
|
||||
var commercePrefixes = []string{
|
||||
"/v1/commerce", // public checkout + tenant + catalog + deposits
|
||||
"/_/commerce", // tenant-admin surface
|
||||
// The BARE store surface: GET /v1/store/current (the org-scoped default
|
||||
// store the admin dashboard AND the content storefront edge resolve), the
|
||||
// per-listing upsert /v1/store/:id/listing/:slug the publish edge writes,
|
||||
// and the public storefront reads karma.style serves at runtime. Without
|
||||
// this owner, /v1/store/* fell through to the bare /v1/* AI catch-all —
|
||||
// whose prepaid BALANCE gate 402'd every store read (a store-metadata read
|
||||
// must never require an LLM balance).
|
||||
"/v1/store",
|
||||
// Payment-provider webhook receiver (POST /v1/billing/webhooks/:provider —
|
||||
// Square et al). The provider's HMAC over the registered notification URL +
|
||||
// body IS the auth; a bearer gate is impossible for provider callbacks.
|
||||
"/v1/billing/webhooks",
|
||||
// The platform auto-recharge sweep (PlatformOnly, POST .../run-all). The
|
||||
// durable cron's poke carries the COMMERCE_SERVICE_TOKEN bearer; without
|
||||
// this owner it lands on the account-bridge /v1/billing/* catch-all, whose
|
||||
// session gate 403s a service token. (Landed 5x before the unfork — #274 —
|
||||
// and the pin test lives beside THIS list so it can't silently regress.)
|
||||
"/v1/billing/auto-recharge",
|
||||
}
|
||||
|
||||
// mountCommerce boots commerce ON the shared zip app (native co-residence).
|
||||
// commerce's own setupRoutes registers /v1/commerce/* and /_/commerce/*
|
||||
// directly; the standalone-only surfaces (bare /healthz, legacy /admin SPA,
|
||||
// checkout SPA root catch-all, Listen) are skipped by the SharedApp contract.
|
||||
// This adapter registers the remaining wire-contract families with commerce's
|
||||
// own gate chains (see commercePrefixes).
|
||||
func mountCommerce(app *zip.App, deps cloud.Deps) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("commerce: nil zip.App")
|
||||
}
|
||||
if deps.Logger == nil {
|
||||
return fmt.Errorf("commerce: nil deps.Logger")
|
||||
}
|
||||
lg := deps.Logger.New("subsystem", "commerce")
|
||||
if deps.Payments == nil {
|
||||
lg.Warn("commerce: deps.Payments is nil — payment intent paths will fail; tenant config + admin still served")
|
||||
}
|
||||
if deps.Vault == nil {
|
||||
lg.Warn("commerce: deps.Vault is nil — vault charge paths unavailable; tenant config + admin still served")
|
||||
}
|
||||
|
||||
// Native zip health endpoint — registered FIRST so probes answer even when
|
||||
// the embed fails below.
|
||||
app.Get("/_/commerce/healthz", func(c *zip.Ctx) error {
|
||||
return c.JSON(http.StatusOK, map[string]string{"status": "ok", "service": "commerce"})
|
||||
})
|
||||
|
||||
// commerce persists its per-org SQLite + `base` tree under <DataDir>/commerce,
|
||||
// NEVER at DataDir directly: cloud already owns DataDir/orgs and DataDir/base,
|
||||
// and commerce also writes orgs/ + base/ — sharing the root would collide two
|
||||
// apps on the same SQLite files and corrupt them.
|
||||
dataDir := "/var/lib/cloud/commerce"
|
||||
if deps.DataDir != "" {
|
||||
dataDir = filepath.Join(deps.DataDir, "commerce")
|
||||
}
|
||||
|
||||
embedded, err := commerce.Embed(context.Background(), commerce.EmbedConfig{
|
||||
DataDir: dataDir,
|
||||
// RequireIdentity stays gateway-owned: the gateway in front of the cloud
|
||||
// binary is the trust boundary per HIP-0026.
|
||||
RequireIdentity: false,
|
||||
// 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)
|
||||
mountCommerceFailClosed(app)
|
||||
return nil
|
||||
}
|
||||
|
||||
// The BARE /v1/store surface (see commercePrefixes). Group-scoped chain
|
||||
// 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(), commerceErrorScope())
|
||||
// Unconditional, exactly like the standalone bundle: IAMTokenRequired
|
||||
// no-ops gracefully when IAM is not initialized.
|
||||
storeV1.Use(iammiddleware.IAMTokenRequired())
|
||||
commercestore.Route(storeV1, commercemid.TokenRequired())
|
||||
|
||||
// Provider webhook intake at the LIVE registered path. Chain mirrors the
|
||||
// commerce-standalone posture: gated request context, then the sessionless
|
||||
// HMAC-verified handler.
|
||||
app.Post("/v1/billing/webhooks/:provider", commercemid.RequestContext(), commercebilling.HandleProviderWebhook)
|
||||
|
||||
// Durable-cron auto-recharge poke (COMMERCE_SERVICE_TOKEN bearer) at its
|
||||
// live path — the bridge's session gate would 403 the poke. Same gate
|
||||
// chain the commerce route table uses: TokenRequired authenticates the
|
||||
// service token, PlatformOnly authorizes the mint.
|
||||
app.Post("/v1/billing/auto-recharge/run-all",
|
||||
commercemid.RequestContext(),
|
||||
commercemid.TokenRequired(),
|
||||
commercemid.PlatformOnly(),
|
||||
commercebilling.RunAutoRechargeAllOrgs,
|
||||
)
|
||||
|
||||
// In-process seams:
|
||||
// - commerceinproc routes the S2S billing byte-stream into the co-resident
|
||||
// app (the metering debit path) instead of a socket to a standalone pod.
|
||||
// - commerceclient reads the Embedded's datastore DIRECTLY (entitlements +
|
||||
// BalanceCents) — no HTTP shape at all.
|
||||
commerceinproc.SetApp(app)
|
||||
commerceclient.PublishEmbedded(embedded)
|
||||
|
||||
lg.Info("commerce embedded natively (hanzoai/commerce module on the shared zip app)",
|
||||
"data_dir", dataDir,
|
||||
"brand", deps.Brand,
|
||||
"env", deps.Env,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
c.SetHeader("Content-Type", "application/json")
|
||||
return c.Bytes(http.StatusServiceUnavailable, []byte(`{"error":"commerce unavailable","code":503}`))
|
||||
}
|
||||
for _, p := range commercePrefixes {
|
||||
app.All(p+"/*", failed)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Copyright © 2026 Hanzo AI. MIT License.
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
luxlog "github.com/luxfi/log"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// TestCommercePrefixesPinned pins the wire paths that MUST reach the commerce gin
|
||||
// handler — the ones a missing prefix silently regresses because they otherwise
|
||||
// fall through to another owner (the account-bridge /v1/billing/* catch-all, or
|
||||
// the bare /v1/* AI catch-all) that answers with the wrong contract:
|
||||
//
|
||||
// - /v1/billing/webhooks provider HMAC is the auth (Square et al); the
|
||||
// session-gated bridge would 403 it.
|
||||
// - /v1/billing/auto-recharge the durable cron's billing-autorecharge poke
|
||||
// (COMMERCE_SERVICE_TOKEN bearer); without the prefix the poke 403s at the
|
||||
// bridge ("sign in to view billing") — exactly how the first live fires
|
||||
// failed, and how the commerce unfork regressed it once already.
|
||||
// - /v1/store the bare storefront surface (GET /v1/store/current +
|
||||
// the listing upsert/reads). Dropped by the unfork, it matched no owner and fell
|
||||
// to the /v1/* AI balance gate, which 402'd every store read for a commerce-funded
|
||||
// org (the karma /v1/store/current outage). It is metadata, never LLM inference.
|
||||
func TestCommercePrefixesPinned(t *testing.T) {
|
||||
want := map[string]bool{
|
||||
"/v1/billing/auto-recharge": false,
|
||||
"/v1/billing/webhooks": false,
|
||||
"/v1/store": false,
|
||||
}
|
||||
for _, p := range commercePrefixes {
|
||||
if _, ok := want[p]; ok {
|
||||
want[p] = true
|
||||
}
|
||||
}
|
||||
for p, ok := range want {
|
||||
if !ok {
|
||||
t.Errorf("commercePrefixes missing %q — the route falls through to the wrong owner", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStoreSurfaceRoutedToCommerceNotAIGate is the regression guard for the karma
|
||||
// GET /v1/store/current → 402 outage. Because "/v1/store" is a commercePrefix, a
|
||||
// store read is mounted on the commerce handler AHEAD of the bare /v1/* AI catch-all
|
||||
// (Wire order: commerce@191 < ai@321; Fiber matches first-registered), so it resolves
|
||||
// on commerce and NEVER reaches the LLM prepaid-balance gate that denied every store
|
||||
// read for an org funded in commerce but $0 in the ai ledger.
|
||||
func TestStoreSurfaceRoutedToCommerceNotAIGate(t *testing.T) {
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
|
||||
// Mirror mountCommerce EXACTLY: app.All(prefix+"/*", handler) for each commerce
|
||||
// prefix. The stub stands in for the embedded commerce gin handler that serves
|
||||
// getCurrent (200 with the org's store).
|
||||
commerce := zip.AdaptNetHTTP(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"store":{"id":"karma-store"}}`)
|
||||
}))
|
||||
for _, p := range commercePrefixes {
|
||||
app.All(p+"/*", commerce)
|
||||
}
|
||||
|
||||
// The bare /v1/* AI catch-all, mounted LAST like the real Wire, gates every
|
||||
// non-exempt /v1/* path on the caller's LLM prepaid balance → 402 for a
|
||||
// commerce-funded-but-ai-$0 org. This is the exact gate that produced the outage.
|
||||
app.Get("/v1/*", func(c *zip.Ctx) error {
|
||||
return c.JSON(http.StatusPaymentRequired, map[string]any{
|
||||
"error": map[string]string{"code": "insufficient_balance"},
|
||||
})
|
||||
})
|
||||
|
||||
// The store read must resolve on the commerce handler (200), never 402 at the AI gate.
|
||||
code, body := doReq(t, app, http.MethodGet, "/v1/store/current")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("GET /v1/store/current hit the AI balance gate (got %d, body %s) — /v1/store must be a commercePrefix so it reaches commerce, not the /v1/* catch-all", code, body)
|
||||
}
|
||||
|
||||
// And a sibling store path (the listing upsert the publish edge writes) is owned too.
|
||||
if code, _ := doReq(t, app, http.MethodPut, "/v1/store/karma-store/listing/valentina"); code == http.StatusPaymentRequired {
|
||||
t.Fatalf("PUT /v1/store/:id/listing/:slug fell through to the AI balance gate (402) — the whole store surface must be commerce-owned")
|
||||
}
|
||||
}
|
||||
|
||||
// doReq drives one request through the mounted app and returns (status, body).
|
||||
func doReq(t *testing.T, app *zip.App, method, path string) (int, []byte) {
|
||||
t.Helper()
|
||||
resp, err := app.Fiber().Test(httptest.NewRequest(method, path, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("Test %s %s: %v", method, path, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, b
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package apps
|
||||
|
||||
import (
|
||||
"github.com/hanzoai/cloud/clients/coding"
|
||||
"github.com/hanzoai/cloud/clients/git"
|
||||
"github.com/hanzoai/cloud/clients/integrations"
|
||||
)
|
||||
|
||||
// wire_seams.go wires cross-subsystem in-process seams that cannot be a MountSpec
|
||||
// because they compose functions ACROSS packages that must not import each other.
|
||||
//
|
||||
// The coding orchestrator (clients/coding) needs git's CloneURL + VerifyRef, but
|
||||
// clients/git imports clients/integrations (Slack-notify), and integrations calls
|
||||
// coding — so coding -> git would cycle (integrations -> coding -> git ->
|
||||
// integrations). The composition root is the ONE place that imports all three, so
|
||||
// it assembles the coding Dispatcher here (git seams + agents/tracker/bot adapters)
|
||||
// and injects it into the Slack trigger surface. init() runs once at load, before
|
||||
// cloud.Serve; the git functions are plain reads that resolve their state at call
|
||||
// time, so no mount ordering is required. The mirror-failure logger is nil (those
|
||||
// failures are non-fatal and dropped).
|
||||
func init() {
|
||||
integrations.SetCodingDispatcher(coding.NewDispatcher(git.CloneURL, git.VerifyRef, nil))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
// Copyright 2026 Hanzo AI Inc. All Rights Reserved.
|
||||
|
||||
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"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
hmoney "github.com/hanzoai/money"
|
||||
"github.com/hanzoai/zen"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// mountZen mounts zen co-resident in the unified cloud binary. zen is the ONE
|
||||
// serving layer for the zen model family; it owns identity, routing, the 1M
|
||||
// context ladder, vision, tools, and the Anthropic↔OpenAI codec. ai stays the
|
||||
// auth+billing+discovery seam and the /v1/models authority; it no longer carries
|
||||
// a parallel zen table or identity prompts. (See hip-00NN.)
|
||||
//
|
||||
// zen mounts as a MIDDLEWARE, not a route owner: Claim is scoped to /v1/* and
|
||||
// routes every request whose model is a zen SKU to zen's pipeline in-process,
|
||||
// calling c.Next() for everything else so ai's /v1/* catch-all serves non-zen
|
||||
// models. ONE mount mechanism; the host owns the routes, zen owns the family.
|
||||
//
|
||||
// Billing. cloud's edge middleware (serve.go) runs IdentityMiddleware,
|
||||
// AuditTrail, ScopeRateLimit, and BillingGate app-wide BEFORE MountAll, so a
|
||||
// zen-claimed request is already authenticated, audited, and rate-limited at the
|
||||
// edge. But the edge BillingGate prices bare /v1/messages, /v1/chat/completions,
|
||||
// and /v1/embeddings at 0 (they are not under /v1/ai/ in selfMeteredPrefixes), and
|
||||
// ai's OWN in-handler metering — which used to bill zen* — is SKIPPED because
|
||||
// zen's Claim runs before ai's beego catch-all. zen therefore bills zen* itself,
|
||||
// through the same commerce metering client the edge gate uses, so there is ONE
|
||||
// billing source for zen* (never double-billed, never free): zen's Gate
|
||||
// authorizes the estimate before the upstream call, zen's Meter records the
|
||||
// exact served cost after. zen's Meter/Gate are wired here; ai's edge gate stays
|
||||
// 0 for these paths.
|
||||
//
|
||||
// Billing granularity is org / project / user, mirroring the edge gate's
|
||||
// identityFromCtx exactly:
|
||||
// - the HOME org (principal.BillingOrg) is the balance key — who PAYS. An admin
|
||||
// acting in another org bills the admin's home org, never the org acted on.
|
||||
// - the project (principal.ValidatedProject) scopes spend caps; a project may
|
||||
// carry its own billing account, resolved server-side by commerce from the
|
||||
// org's project binding (the X-Billing-Account-Id header only attributes,
|
||||
// never redirects spend). Empty is the org-wide default.
|
||||
// - the user (c.User) is the actor for the audit trail.
|
||||
//
|
||||
// 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(a *zip.App, deps cloud.Deps) error {
|
||||
z, err := zen.New(zen.Config{
|
||||
Logger: deps.Logger,
|
||||
Key: zenKeyResolver(deps.KMS),
|
||||
Tenant: cloudTenantResolver,
|
||||
Gate: commerceGate(deps.Metering),
|
||||
Meter: commerceMeter(deps.Metering),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("zen: %w", err)
|
||||
}
|
||||
// The one mount: Claim scoped to /v1, ahead of ai's catch-all.
|
||||
a.Group("/v1", z.Claim())
|
||||
return nil
|
||||
}
|
||||
|
||||
// cloudTenantResolver is the multi-tenant billing-identity resolver: it keys
|
||||
// the balance on the validated HOME org (who pays), resolves the project scope
|
||||
// from a validated claim, and attributes the user. It mirrors the edge gate's
|
||||
// identityFromCtx so a zen* debit lands on the SAME ledger axes the edge gate
|
||||
// would have used — home-org balance, project+service scope, user actor — and a
|
||||
// masquerading admin bills their own home org, never the org acted on. A request
|
||||
// with no validated principal resolves to an empty Tenant, which zen's Valid()
|
||||
// gate refuses (no free, anonymous usage).
|
||||
func cloudTenantResolver(c *zip.Ctx) zen.Tenant {
|
||||
home, _ := principal.BillingOrg(c)
|
||||
project, _ := principal.ValidatedProject(c)
|
||||
return zen.Tenant{
|
||||
Org: c.Org(),
|
||||
User: c.User(),
|
||||
BillingOrg: home,
|
||||
Project: project,
|
||||
}
|
||||
}
|
||||
|
||||
// commerceGate is zen's pre-serve authorization backed by cloud commerce. It
|
||||
// asks the metering client whether the home org can cover the request's
|
||||
// estimated cost (priced at the tier that WILL serve, so an overflow is gated
|
||||
// against its real cost). The balance key is the home org (User); the project
|
||||
// scopes the spend cap. The estimate is exact 18-dp atto-USD from zen, folded to
|
||||
// whole cents for the balance check (a sub-cent estimate gates as "any positive
|
||||
// balance", the same contract as the edge gate's AmountCents). An unconfigured
|
||||
// metering client (nil) admits everything — zen's own tenant gate still refuses
|
||||
// anonymous traffic, and the edge BillingGate + ScopeRateLimit already ran. A
|
||||
// denied verdict returns the metering reason so zen surfaces it as its 402.
|
||||
func commerceGate(m *metering.Client) zen.Gate {
|
||||
if m == nil || !m.Enabled() {
|
||||
return nil
|
||||
}
|
||||
return func(ctx context.Context, t zen.Tenant, model string, est hmoney.Amount) error {
|
||||
if t.BillingOrg == "" {
|
||||
return fmt.Errorf("a billable tenant is required (no anonymous usage)")
|
||||
}
|
||||
// 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,
|
||||
AmountCents: cents,
|
||||
Project: t.Project,
|
||||
Service: zenService,
|
||||
})
|
||||
if err != nil {
|
||||
// Balance unknown -> fail-closed (mirrors the edge gate's 503).
|
||||
return fmt.Errorf("billing unavailable")
|
||||
}
|
||||
if !v.Allow {
|
||||
if v.Reason == "spend_cap" {
|
||||
return fmt.Errorf("spend cap reached for this scope")
|
||||
}
|
||||
return fmt.Errorf("insufficient balance")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// commerceMeter is zen's post-serve usage recorder backed by cloud commerce.
|
||||
// It debits the home org for the EXACT served cost as a typed money.Amount
|
||||
// (native 18-dp USD — the same precision the co-resident finance ledger holds),
|
||||
// so an exact per-token cost is never floored to cents or micros. Attributed to
|
||||
// the requested zen SKU with real token counts. The debit is detached (background
|
||||
// context) so a client disconnect cannot cancel it and recording never blocks the
|
||||
// reply — the same contract as the edge gate's post-request record. An
|
||||
// unconfigured client is a no-op (zen's logMeter still ran as the default, so the
|
||||
// audit trail is never empty).
|
||||
func commerceMeter(m *metering.Client) zen.Meter {
|
||||
if m == nil || !m.Enabled() {
|
||||
return nil
|
||||
}
|
||||
return commerceMeterImpl{m}
|
||||
}
|
||||
|
||||
type commerceMeterImpl struct{ m *metering.Client }
|
||||
|
||||
func (g commerceMeterImpl) Record(ctx context.Context, u zen.Usage) {
|
||||
if u.Tenant.BillingOrg == "" {
|
||||
return // never debit an unattributable request
|
||||
}
|
||||
// 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,
|
||||
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
|
||||
// the same LLM product ai does, so it shares ai's service label — a per-scope
|
||||
// spend cap on "ai" binds both surfaces, and Observe reconciles them together.
|
||||
const zenService = "ai"
|
||||
|
||||
// zenProvider is the metering provider label that marks a debit as zen-served
|
||||
// (the LLM family zen owns) for internal cost reconciliation vs the raw upstream.
|
||||
const zenProvider = "zen"
|
||||
|
||||
// zenKeyResolver is zen's upstream-credential resolver. zen's catalog names each
|
||||
// provider's key by an env-var convention (DO_AI_API_KEY, ANTHROPIC_API_KEY, …);
|
||||
// the resolver turns that name into a concrete secret. It reads the ENVIRONMENT
|
||||
// FIRST, then falls back to KMS — the SAME order ai uses (object/kms.go: "the prod
|
||||
// hot path resolves DO_AI_API_KEY from the env before any live KMS"). The operator
|
||||
// injects these provider keys as env from the KMS-synced K8s secret
|
||||
// (cloud-api-llm-keys), so the env is the live value; the embedded KMS store is a
|
||||
// fallback that is not always seeded with the provider keys. Reading KMS-only made
|
||||
// zen send an EMPTY key whenever the store lacked it, and the upstream (DO GenAI)
|
||||
// answered 401 "Unable to authenticate you" — surfacing to the caller as a failed
|
||||
// chat while ai (which reads env) worked. Env-first fixes that with one source of
|
||||
// truth shared across both zen and ai. An empty result on both still returns "" so
|
||||
// the upstream call fails fast (never silent free usage).
|
||||
func zenKeyResolver(kms cloud.KMSClient) func(context.Context, string) string {
|
||||
return func(ctx context.Context, envName string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(envName)); v != "" {
|
||||
return v
|
||||
}
|
||||
if kms == nil {
|
||||
return ""
|
||||
}
|
||||
b, err := kms.GetSecret(ctx, envName)
|
||||
if err != nil || len(b) == 0 {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
// admin.hanzo.ai forward-auth guard is the confidential client `hanzo-admin-guard`,
|
||||
// so IAM mints its access tokens with aud=hanzo-admin-guard (each app's aud is its
|
||||
// client_id). The guard forwards that bearer to cloud-api /v1/admin/*; the identity
|
||||
// sanitizer only grants global-admin (owner==adminOrg) to a VALIDATED principal, and
|
||||
// sanitizer only grants SuperAdmin (owner==adminOrg) to a VALIDATED principal, and
|
||||
// validation enforces this audience allowlist. If hanzo-admin-guard is not accepted
|
||||
// the token resolves anonymous and the SuperAdmin gate reads false -> 403, even
|
||||
// though the token's owner IS admin. Pin the client_id into the baked default so the
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ func openTemp(t *testing.T) (*Recorder, string) {
|
||||
return rec, path
|
||||
}
|
||||
|
||||
// sampleRecord is a representative security event (a global-admin org deletion).
|
||||
// sampleRecord is a representative security event (a SuperAdmin org deletion).
|
||||
func sampleRecord(action string) Record {
|
||||
return Record{
|
||||
Time: time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC),
|
||||
|
||||
+3
-3
@@ -54,9 +54,9 @@ type Resource struct {
|
||||
}
|
||||
|
||||
// AuthContext records HOW the actor authenticated and what authority they held
|
||||
// at decision time — the AC-* evidence (was this a global admin? by what
|
||||
// at decision time — the AC-* evidence (was this a SuperAdmin? by what
|
||||
// credential?). Method is "jwt" | "api-key" | "none". IsAdmin is the VALIDATED
|
||||
// global-admin bit (owner == AdminOrg), never a raw X-User-IsAdmin.
|
||||
// SuperAdmin bit (owner == AdminOrg), never a raw X-User-IsAdmin.
|
||||
type AuthContext struct {
|
||||
Method string `json:"method"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
@@ -65,7 +65,7 @@ type AuthContext struct {
|
||||
// Outcome is the result of the action: whether it was allowed and what
|
||||
// happened. Result is "success" | "deny" | "error". Status is the HTTP status.
|
||||
// Reason is a short, non-sensitive explanation for a deny/error (e.g.
|
||||
// "global admin required", "insufficient_balance") — never a secret, never a
|
||||
// "SuperAdmin required", "insufficient_balance") — never a secret, never a
|
||||
// raw upstream error body.
|
||||
type Outcome struct {
|
||||
Result string `json:"result"`
|
||||
|
||||
+7
-6
@@ -2,7 +2,7 @@ package audit
|
||||
|
||||
// The append-only sink + the serialized Recorder that owns the hash-chain head.
|
||||
//
|
||||
// WHY SQLITE IS THE PRIMARY, DURABLE STORE (not ClickHouse). The chain is only
|
||||
// WHY SQLITE IS THE PRIMARY, DURABLE STORE (not datastore). The chain is only
|
||||
// tamper-EVIDENT if records are appended in a strict, gapless total order and
|
||||
// each record's PrevHash is the immediately-preceding record's Hash. That demands
|
||||
// a single serializing writer with a synchronous, read-your-write head. cloud's
|
||||
@@ -14,8 +14,8 @@ package audit
|
||||
// app issues no UPDATE/DELETE, and the hash-chain detects any out-of-band edit to
|
||||
// the file. That is the compliance-grade primary control.
|
||||
//
|
||||
// THE CLICKHOUSE MIRROR IS A PROJECTION, NOT THE SOURCE OF TRUTH. The datastore
|
||||
// (ClickHouse MergeTree — insert-only, mutation-rejected at parse time) is the
|
||||
// THE DATASTORE MIRROR IS A PROJECTION, NOT THE SOURCE OF TRUTH. The datastore
|
||||
// (datastore MergeTree — insert-only, mutation-rejected at parse time) is the
|
||||
// fleet-wide OLAP mirror for long-retention, cross-deployment query. It is
|
||||
// best-effort and asynchronous: a mirror outage must never block or fail an
|
||||
// audited request, and the local chain remains the authority the verifier walks.
|
||||
@@ -39,12 +39,13 @@ import (
|
||||
// mattn+SQLCipher, encrypted at rest; !cgo → pure-Go modernc). Importing
|
||||
// modernc directly instead would double-register "sqlite" under CGO and
|
||||
// panic at init. Blank import registers the driver.
|
||||
"github.com/hanzoai/cloud/cek"
|
||||
_ "github.com/hanzoai/sqlite"
|
||||
)
|
||||
|
||||
// Mirror is the optional OLAP projection sink (the datastore/ClickHouse). It is
|
||||
// Mirror is the optional OLAP projection sink (the datastore/datastore). It is
|
||||
// deliberately a tiny interface, not a concrete client, so the Recorder has no
|
||||
// compile-time dependency on ClickHouse and tests can supply a fake. Append is
|
||||
// compile-time dependency on datastore and tests can supply a fake. Append is
|
||||
// called best-effort, asynchronously, off the request path.
|
||||
type Mirror interface {
|
||||
// Append writes one sealed record to the projection. A returned error is
|
||||
@@ -111,7 +112,7 @@ type CheckpointFunc func(cp Checkpoint)
|
||||
// the file lock — the same single-writer discipline pricing/provisioning use,
|
||||
// here doubling as the chain's serialization guarantee.
|
||||
func Open(path string, mirror Mirror) (*Recorder, error) {
|
||||
db, err := sql.Open("sqlite", path)
|
||||
db, err := cek.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("audit: open sqlite %q: %w", path, err)
|
||||
}
|
||||
|
||||
+1
-1
@@ -208,7 +208,7 @@ func actorFromCtx(c *zip.Ctx) audit.Actor {
|
||||
|
||||
// authFromCtx records HOW the caller authenticated and the VALIDATED admin bit.
|
||||
// IsAdmin comes from c.IsAdmin() (the sanitized X-User-IsAdmin, true only for a
|
||||
// verified global admin), never a raw header. Method is inferred from the
|
||||
// verified SuperAdmin), never a raw header. Method is inferred from the
|
||||
// presence/shape of a credential: an Authorization/X-Authorization bearer or a
|
||||
// session cookie ⇒ "jwt" (or "api-key" for an opaque hk-/sk- token); none ⇒
|
||||
// "none".
|
||||
|
||||
@@ -52,7 +52,7 @@ func newAuditApp(t *testing.T) (*zip.App, *audit.Recorder) {
|
||||
// security event and MUST be audited even though it is a GET.
|
||||
app.Get("/v1/admin/orgs", func(c *zip.Ctx) error {
|
||||
if !c.IsAdmin() {
|
||||
return zip.ErrForbidden("global admin required")
|
||||
return zip.ErrForbidden("SuperAdmin required")
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]string{"ok": "true"})
|
||||
})
|
||||
@@ -65,7 +65,7 @@ func newAuditApp(t *testing.T) (*zip.App, *audit.Recorder) {
|
||||
return app, rec
|
||||
}
|
||||
|
||||
// asAdmin sets the sanitized identity headers a VALIDATED global admin would
|
||||
// asAdmin sets the sanitized identity headers a VALIDATED SuperAdmin would
|
||||
// carry after SanitizeIdentity (X-User-IsAdmin=true, org=admin).
|
||||
func asAdmin(req *http.Request) {
|
||||
req.Header.Set("X-User-Id", "z@hanzo.ai")
|
||||
|
||||
+37
-24
@@ -1,15 +1,15 @@
|
||||
package cloud
|
||||
|
||||
// The datastore (ClickHouse) OLAP mirror — a best-effort projection of the audit
|
||||
// The datastore OLAP mirror — a best-effort projection of the audit
|
||||
// trail for fleet-wide, long-retention, cross-deployment query. It implements
|
||||
// audit.Mirror.
|
||||
//
|
||||
// The datastore is the natural OLAP audit sink: the table is a MergeTree, which
|
||||
// is INSERT-ONLY by engine — ClickHouse rejects UPDATE/DELETE against it at parse
|
||||
// is INSERT-ONLY by engine — datastore rejects UPDATE/DELETE against it at parse
|
||||
// time ("MergeTree does not support mutations"), so the mirror is append-only at
|
||||
// the storage layer, matching the local chain's discipline. We create the table
|
||||
// idempotently on first connect (CREATE TABLE IF NOT EXISTS) and insert via the
|
||||
// canonical clickhouse-go PrepareBatch → Append → Send idiom (the same the
|
||||
// canonical datastore-go PrepareBatch → Append → Send idiom (the same the
|
||||
// provisioning subsystem uses; the driver is already in cloud's module graph, so
|
||||
// this adds no dependency).
|
||||
//
|
||||
@@ -26,14 +26,14 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
clickhouse "github.com/ClickHouse/clickhouse-go/v2"
|
||||
datastore "github.com/hanzo-ds/go"
|
||||
"github.com/hanzoai/cloud/audit"
|
||||
luxlog "github.com/luxfi/log"
|
||||
)
|
||||
|
||||
// clickhouseMirror writes audit records to a ClickHouse MergeTree table.
|
||||
type clickhouseMirror struct {
|
||||
conn clickhouse.Conn
|
||||
// datastoreMirror writes audit records to a datastore MergeTree table.
|
||||
type datastoreMirror struct {
|
||||
conn datastore.Conn
|
||||
table string
|
||||
log luxlog.Logger
|
||||
}
|
||||
@@ -44,25 +44,38 @@ type clickhouseMirror struct {
|
||||
//
|
||||
// Config (all from env / KMS-injected secrets, never hard-coded):
|
||||
//
|
||||
// CLOUD_AUDIT_CLICKHOUSE_ADDR host:9000 of the datastore native port
|
||||
// CLOUD_AUDIT_CLICKHOUSE_DB database (default "hanzo")
|
||||
// CLOUD_AUDIT_CLICKHOUSE_TABLE table (default "audit_log")
|
||||
// CLOUD_AUDIT_CLICKHOUSE_USER user
|
||||
// CLOUD_AUDIT_CLICKHOUSE_PASSWORD password (KMS-backed secret)
|
||||
// CLOUD_AUDIT_DATASTORE_ADDR host:9000 of the datastore native port
|
||||
// CLOUD_AUDIT_DATASTORE_DB database (default "hanzo")
|
||||
// CLOUD_AUDIT_DATASTORE_TABLE table (default "audit_log")
|
||||
// CLOUD_AUDIT_DATASTORE_USER user
|
||||
// CLOUD_AUDIT_DATASTORE_PASSWORD password (KMS-backed secret)
|
||||
//
|
||||
// auditEnv reads CLOUD_AUDIT_DATASTORE_<suffix>; auditEnvOr adds a default when unset.
|
||||
func auditEnv(suffix string) string {
|
||||
return os.Getenv("CLOUD_AUDIT_DATASTORE_" + suffix)
|
||||
}
|
||||
|
||||
func auditEnvOr(suffix, def string) string {
|
||||
if v := auditEnv(suffix); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func newAuditMirror(log luxlog.Logger) (audit.Mirror, error) {
|
||||
addr := strings.TrimSpace(os.Getenv("CLOUD_AUDIT_CLICKHOUSE_ADDR"))
|
||||
addr := strings.TrimSpace(auditEnv("ADDR"))
|
||||
if addr == "" {
|
||||
return nil, nil // no datastore configured — local chain only.
|
||||
}
|
||||
db := getenv("CLOUD_AUDIT_CLICKHOUSE_DB", "hanzo")
|
||||
table := getenv("CLOUD_AUDIT_CLICKHOUSE_TABLE", "audit_log")
|
||||
db := auditEnvOr("DB", "hanzo")
|
||||
table := auditEnvOr("TABLE", "audit_log")
|
||||
|
||||
conn, err := clickhouse.Open(&clickhouse.Options{
|
||||
conn, err := datastore.Open(&datastore.Options{
|
||||
Addr: []string{addr},
|
||||
Auth: clickhouse.Auth{
|
||||
Auth: datastore.Auth{
|
||||
Database: db,
|
||||
Username: os.Getenv("CLOUD_AUDIT_CLICKHOUSE_USER"),
|
||||
Password: os.Getenv("CLOUD_AUDIT_CLICKHOUSE_PASSWORD"),
|
||||
Username: auditEnv("USER"),
|
||||
Password: auditEnv("PASSWORD"),
|
||||
},
|
||||
DialTimeout: 5 * time.Second,
|
||||
})
|
||||
@@ -77,7 +90,7 @@ func newAuditMirror(log luxlog.Logger) (audit.Mirror, error) {
|
||||
}
|
||||
|
||||
qualified := db + "." + table
|
||||
m := &clickhouseMirror{conn: conn, table: qualified, log: log}
|
||||
m := &datastoreMirror{conn: conn, table: qualified, log: log}
|
||||
if err := m.ensureTable(ctx); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
@@ -92,7 +105,7 @@ func newAuditMirror(log luxlog.Logger) (audit.Mirror, error) {
|
||||
// = insert-only (mutations rejected at parse time). Partitioned by month and
|
||||
// ordered for the (org, time) query pattern; seq + hash are carried so the OLAP
|
||||
// copy is cross-checkable against the local chain.
|
||||
func (m *clickhouseMirror) ensureTable(ctx context.Context) error {
|
||||
func (m *datastoreMirror) ensureTable(ctx context.Context) error {
|
||||
ddl := fmt.Sprintf(`
|
||||
CREATE TABLE IF NOT EXISTS %s (
|
||||
seq UInt64,
|
||||
@@ -130,7 +143,7 @@ ORDER BY (actor_org, ts, seq)`, m.table)
|
||||
// still payload-bearing) diffs out of the fleet warehouse minimizes the blast
|
||||
// radius of a warehouse compromise. The full record (with diffs) lives only in
|
||||
// the local, access-controlled chain.
|
||||
func (m *clickhouseMirror) Append(ctx context.Context, r audit.Record) error {
|
||||
func (m *datastoreMirror) Append(ctx context.Context, r audit.Record) error {
|
||||
batch, err := m.conn.PrepareBatch(ctx, "INSERT INTO "+m.table+` (
|
||||
seq, ts, actor_org, actor_sub, actor_email, action, res_type, res_id,
|
||||
auth_method, is_admin, result, status, reason, source_ip, user_agent,
|
||||
@@ -159,7 +172,7 @@ func (m *clickhouseMirror) Append(ctx context.Context, r audit.Record) error {
|
||||
// series and alerts on any regression. Best-effort; a failure is dropped by the
|
||||
// Recorder (the structured log carries the same digest). Implements
|
||||
// audit.CheckpointSink.
|
||||
func (m *clickhouseMirror) Checkpoint(ctx context.Context, cp audit.Checkpoint) error {
|
||||
func (m *datastoreMirror) Checkpoint(ctx context.Context, cp audit.Checkpoint) error {
|
||||
if err := m.ensureCheckpointTable(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -177,7 +190,7 @@ func (m *clickhouseMirror) Checkpoint(ctx context.Context, cp audit.Checkpoint)
|
||||
// ensureCheckpointTable creates the append-only checkpoint digest table. A plain
|
||||
// MergeTree ordered by time — the monitor reads the latest rows and checks that
|
||||
// count never decreases.
|
||||
func (m *clickhouseMirror) ensureCheckpointTable(ctx context.Context) error {
|
||||
func (m *datastoreMirror) ensureCheckpointTable(ctx context.Context) error {
|
||||
ddl := fmt.Sprintf(`
|
||||
CREATE TABLE IF NOT EXISTS %s_checkpoints (
|
||||
ts DateTime64(3, 'UTC'),
|
||||
|
||||
+16
-3
@@ -15,6 +15,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud/audit"
|
||||
@@ -22,7 +23,7 @@ import (
|
||||
)
|
||||
|
||||
// buildAuditRecorder constructs the audit Recorder from cfg: the append-only
|
||||
// SQLite chain at {DataDir}/audit.db plus a best-effort ClickHouse OLAP mirror
|
||||
// SQLite chain at {DataDir}/audit.db plus a best-effort datastore OLAP mirror
|
||||
// when a datastore is configured. Returns (nil, nil) only when the trail is
|
||||
// explicitly disabled — the caller then wires a no-op middleware.
|
||||
func buildAuditRecorder(cfg *Config, logger luxlog.Logger) (*audit.Recorder, error) {
|
||||
@@ -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
|
||||
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
// Copyright 2026 Hanzo AI Inc. All Rights Reserved.
|
||||
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The identity boundary (SanitizeIdentity) validates a JWT and mints the identity
|
||||
// headers every subsystem trusts. An opaque API key (hk-/sk-/pk-/fw_/hz_) is not a
|
||||
// JWT, so it yielded no principal — and a subsystem that gates on the minted
|
||||
// identity (zen's billing gate) refused an API-key request as anonymous, though the
|
||||
// key is a first-class credential. keyResolver closes that gap: it turns a key into
|
||||
// the SAME idClaims a JWT yields, so ONE minting path serves both credentials and
|
||||
// key auth and session auth can never disagree on who a request is.
|
||||
|
||||
// keyResolver turns an opaque API key into the principal it authenticates, or nil
|
||||
// for an unknown key or an unconfigured resolver (the request stays anonymous — a
|
||||
// bad key never grants trust).
|
||||
type keyResolver interface {
|
||||
resolve(ctx context.Context, key string) *idClaims
|
||||
}
|
||||
|
||||
// iamKeys resolves an `hk-` key against IAM's get-user?accessKey endpoint,
|
||||
// authenticating as the confidential `hanzo-console` client (the credential
|
||||
// clients/account already uses). The resolved user is exactly what a JWT for that
|
||||
// user carries, so SanitizeIdentity mints identical headers for a key and a session.
|
||||
// A brief cache keeps the hot auth path off the network; it caches misses too, so a
|
||||
// bad key cannot hammer IAM.
|
||||
type iamKeys struct {
|
||||
base string
|
||||
auth string // client_secret_basic, or "" when unconfigured
|
||||
http *http.Client
|
||||
cache cache[string, *idClaims]
|
||||
}
|
||||
|
||||
// newIAMKeys reads the same IAM env clients/account does. With no confidential
|
||||
// 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 {
|
||||
return &iamKeys{
|
||||
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 != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (k *iamKeys) resolve(ctx context.Context, key string) *idClaims {
|
||||
if k.auth == "" || k.base == "" || key == "" {
|
||||
return nil
|
||||
}
|
||||
if c, ok := k.cache.get(key); ok {
|
||||
return c // may be a cached nil — a valid "anonymous" answer
|
||||
}
|
||||
c := k.lookup(ctx, key)
|
||||
k.cache.put(key, c)
|
||||
return c
|
||||
}
|
||||
|
||||
// lookup performs the authenticated get-user?accessKey call and maps the user row
|
||||
// to idClaims. Any failure (unreachable, denied, unknown key) yields nil. Name is
|
||||
// both the username IAM's owner/name lookups parse and the id fallback: a key has
|
||||
// no UUID subject, so userID() falls through to name — the gateway's historical
|
||||
// X-User-Id==name behavior the owner/name path expects.
|
||||
func (k *iamKeys) lookup(ctx context.Context, key string) *idClaims {
|
||||
u := k.base + "/v1/iam/get-user?" + url.Values{"accessKey": {key}}.Encode()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
req.Header.Set("Authorization", k.auth)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := k.http.Do(req)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var env struct {
|
||||
Status string `json:"status"`
|
||||
Data *struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if json.Unmarshal(raw, &env) != nil || env.Status != "ok" || env.Data == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(env.Data.Owner) == "" {
|
||||
return nil
|
||||
}
|
||||
return &idClaims{
|
||||
Owner: strings.TrimSpace(env.Data.Owner),
|
||||
Name: strings.TrimSpace(env.Data.Name),
|
||||
PreferredUsername: strings.TrimSpace(env.Data.Name),
|
||||
Email: strings.TrimSpace(env.Data.Email),
|
||||
IsAdmin: env.Data.IsAdmin,
|
||||
}
|
||||
}
|
||||
|
||||
// cache is a tiny concurrency-safe TTL map — one generic type for every
|
||||
// resolve-once-reuse-briefly lookup, so no bespoke cache is hand-rolled per caller.
|
||||
type cache[K comparable, V any] struct {
|
||||
ttl time.Duration
|
||||
mu sync.Mutex
|
||||
m map[K]entry[V]
|
||||
}
|
||||
|
||||
type entry[V any] struct {
|
||||
v V
|
||||
exp time.Time
|
||||
}
|
||||
|
||||
func newCache[K comparable, V any](ttl time.Duration) cache[K, V] {
|
||||
return cache[K, V]{ttl: ttl, m: make(map[K]entry[V])}
|
||||
}
|
||||
|
||||
func (c *cache[K, V]) get(k K) (V, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
e, ok := c.m[k]
|
||||
if !ok || time.Now().After(e.exp) {
|
||||
var zero V
|
||||
return zero, false
|
||||
}
|
||||
return e.v, true
|
||||
}
|
||||
|
||||
func (c *cache[K, V]) put(k K, v V) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.m[k] = entry[V]{v: v, exp: time.Now().Add(c.ttl)}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Copyright 2026 Hanzo AI Inc. All Rights Reserved.
|
||||
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// iamKeys.lookup maps an IAM get-user?accessKey row to the SAME idClaims a JWT
|
||||
// yields, so the one minting path serves a key and a session identically.
|
||||
func TestIAMKeysLookup(t *testing.T) {
|
||||
var gotKey, gotAuth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotKey = r.URL.Query().Get("accessKey")
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":"ok","data":{"owner":"hanzo","name":"z","email":"z@hanzo.ai","isAdmin":true}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
k := &iamKeys{base: srv.URL, auth: "Basic test", http: srv.Client(), cache: newCache[string, *idClaims](time.Minute)}
|
||||
c := k.resolve(context.Background(), "hk-abc123")
|
||||
if c == nil {
|
||||
t.Fatal("resolve returned nil for a valid key")
|
||||
}
|
||||
if c.Owner != "hanzo" || c.Name != "z" || c.Email != "z@hanzo.ai" || !c.IsAdmin {
|
||||
t.Fatalf("claims = %+v, want owner=hanzo name=z email=z@hanzo.ai isAdmin=true", c)
|
||||
}
|
||||
// The key is passed as accessKey and the confidential credential is sent.
|
||||
if gotKey != "hk-abc123" {
|
||||
t.Errorf("IAM got accessKey=%q, want hk-abc123", gotKey)
|
||||
}
|
||||
if gotAuth != "Basic test" {
|
||||
t.Errorf("IAM got auth=%q, want the confidential Basic credential", gotAuth)
|
||||
}
|
||||
// userID falls through to name (a key has no UUID subject) — the owner/name
|
||||
// path IAM's privileged lookups expect.
|
||||
if c.userID() != "z" || c.username() != "z" {
|
||||
t.Errorf("userID=%q username=%q, want both z", c.userID(), c.username())
|
||||
}
|
||||
}
|
||||
|
||||
// An unconfigured resolver (no confidential credential) resolves nothing, so an
|
||||
// API key stays anonymous rather than mis-resolved.
|
||||
func TestIAMKeysUnconfigured(t *testing.T) {
|
||||
k := &iamKeys{base: "http://iam", auth: "", cache: newCache[string, *idClaims](time.Minute)}
|
||||
if c := k.resolve(context.Background(), "hk-abc"); c != nil {
|
||||
t.Fatalf("unconfigured resolver returned %+v, want nil", c)
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown key (IAM status != ok) resolves to nil — a bad key never grants trust.
|
||||
func TestIAMKeysUnknown(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"status":"error","msg":"Unauthorized operation"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
k := &iamKeys{base: srv.URL, auth: "Basic test", http: srv.Client(), cache: newCache[string, *idClaims](time.Minute)}
|
||||
if c := k.resolve(context.Background(), "hk-bad"); c != nil {
|
||||
t.Fatalf("unknown key resolved to %+v, want nil", c)
|
||||
}
|
||||
}
|
||||
|
||||
// The cache serves a resolved key without a second IAM call (and caches a miss too,
|
||||
// so a bad key cannot hammer IAM).
|
||||
func TestIAMKeysCache(t *testing.T) {
|
||||
var calls int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
_, _ = w.Write([]byte(`{"status":"ok","data":{"owner":"hanzo","name":"z"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
k := &iamKeys{base: srv.URL, auth: "Basic test", http: srv.Client(), cache: newCache[string, *idClaims](time.Minute)}
|
||||
for i := 0; i < 3; i++ {
|
||||
if k.resolve(context.Background(), "hk-x") == nil {
|
||||
t.Fatal("resolve nil")
|
||||
}
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("IAM called %d times, want 1 (cached)", calls)
|
||||
}
|
||||
}
|
||||
+20
-1
@@ -31,6 +31,8 @@ import (
|
||||
|
||||
gojose "github.com/go-jose/go-jose/v4"
|
||||
"github.com/go-jose/go-jose/v4/jwt"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
)
|
||||
|
||||
// idClaims is the subset of Hanzo IAM JWT claims the identity sanitizer needs.
|
||||
@@ -39,12 +41,27 @@ type idClaims struct {
|
||||
jwt.Claims
|
||||
|
||||
Owner string `json:"owner"` // org slug (the org)
|
||||
Project string `json:"project"` // org SUB-SCOPE within owner (empty ⟹ default project)
|
||||
Name string `json:"name"` // display name (id fallback)
|
||||
PreferredUsername string `json:"preferred_username"` // id fallback
|
||||
Email string `json:"email"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
}
|
||||
|
||||
// mintedProject returns the project id to stamp into X-Project-Id, or "" when the
|
||||
// header must be OMITTED. The project rides in the validated JWT `project` claim,
|
||||
// scoped to the caller's org exactly like `owner` — trusted, not forgeable. The
|
||||
// default project (absent claim, or the literal principal.DefaultProject) mints
|
||||
// nothing, so X-Project-Id is present iff a non-default project is in scope. This
|
||||
// mirrors the edge (iamauth.Claims.MintedProject) byte-for-byte, so the in-binary
|
||||
// path binds the same header the gateway would.
|
||||
func (c *idClaims) mintedProject() string {
|
||||
if principal.IsDefaultProject(c.Project) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(c.Project)
|
||||
}
|
||||
|
||||
// userID resolves the canonical user id: sub, then preferred_username, then
|
||||
// name. IAM may leave sub empty. This is the STABLE identifier (a UUID when IAM
|
||||
// sets sub) stamped as X-User-Id and consumed as the attribution key everywhere.
|
||||
@@ -94,6 +111,7 @@ type identityValidator struct {
|
||||
issuers []string
|
||||
audiences []string
|
||||
cache *jwksCache
|
||||
keys keyResolver // resolves an opaque API key to a principal; nil ⟹ keys stay anonymous
|
||||
}
|
||||
|
||||
// newIdentityValidator builds a validator whose trusted-issuer set is the primary
|
||||
@@ -105,6 +123,7 @@ func newIdentityValidator(issuer, jwksURL string, audiences []string, ttl time.D
|
||||
issuers: trustedIssuers(issuer),
|
||||
audiences: audiences,
|
||||
cache: newJWKSCache(jwksURL, ttl),
|
||||
keys: newIAMKeys(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +160,7 @@ func kmsMachineAudience(owner string) string {
|
||||
// isKMSMachinePrincipal reports whether a validated token is a per-org KMS-sync
|
||||
// machine identity: its audience set contains the owner-bound machine audience
|
||||
// (<owner>-platform-kms). Such a principal is a client_credentials machine identity
|
||||
// scoped to exactly one org. SanitizeIdentity uses this to DENY it global-admin
|
||||
// scoped to exactly one org. SanitizeIdentity uses this to DENY it SuperAdmin
|
||||
// authority even if it somehow carries isAdmin=true and owner==adminOrg, so V6's
|
||||
// audience widening can never be leveraged (via an admin-org machine token) into a
|
||||
// cross-org read. Its org-scoped data access is unaffected — this gates ONLY the
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package cloud
|
||||
|
||||
import "testing"
|
||||
|
||||
// The context window is a BODY-SIZE fact, not just a model fact.
|
||||
//
|
||||
// A chat request carries its whole prompt in the request body, so the edge's
|
||||
// BodyLimit is a hard ceiling on the context window no matter what the model
|
||||
// catalog advertises. The zip/fiber framework default is 4 MiB; a 1M-token
|
||||
// prompt serializes to roughly 4.3 MB of JSON (measured on prod: 2 MB of body
|
||||
// carried 466,148 prompt tokens, ~4.5 bytes/token). Left at the default, every
|
||||
// 1M-context route — deepseek-v4-pro, and anything glm-5.2 overflows into once
|
||||
// a prompt passes its 262,144 cap — was unreachable: fasthttp rejected the body
|
||||
// before any handler ran and answered the opaque 400 "Error when parsing
|
||||
// request", which reads like a malformed payload rather than a size cap.
|
||||
//
|
||||
// This pins the ceiling above a real 1M-token prompt so the regression cannot
|
||||
// come back silently.
|
||||
|
||||
// oneMillionTokenBody is a conservative byte estimate for a 1M-token prompt.
|
||||
// Measured ~4.5 bytes/token; 4.3 keeps the floor conservative.
|
||||
const oneMillionTokenBody = 1_000_000 * 43 / 10 // ~4.3 MB
|
||||
|
||||
// zipFrameworkDefault is the zip/fiber BodyLimit applied when Config.BodyLimit
|
||||
// is left at 0 (zip.go: `if cfg.BodyLimit == 0 { cfg.BodyLimit = 4 << 20 }`) —
|
||||
// the exact cap that made 1M context unreachable.
|
||||
const zipFrameworkDefault = 4 << 20
|
||||
|
||||
// LoadConfig registers process flags, so it may only be called ONCE per test
|
||||
// binary (a second call panics with "flag redefined"). Both invariants are
|
||||
// therefore asserted against a single load, and the env-override path is
|
||||
// exercised through the getenvInt helper that LoadConfig itself uses.
|
||||
func TestBodyLimit_FitsAMillionTokenPrompt(t *testing.T) {
|
||||
cfg := LoadConfig()
|
||||
|
||||
if cfg.BodyLimit == 0 || cfg.BodyLimit == zipFrameworkDefault {
|
||||
t.Fatalf("BodyLimit = %d — this is the zip/fiber 4 MiB default, the exact cap "+
|
||||
"that made 1M context unreachable", cfg.BodyLimit)
|
||||
}
|
||||
|
||||
if cfg.BodyLimit <= oneMillionTokenBody {
|
||||
t.Fatalf("BodyLimit = %d bytes, which cannot hold a 1M-token prompt (~%d bytes); "+
|
||||
"the 1M-context models would 400 with fasthttp's opaque "+
|
||||
"%q", cfg.BodyLimit, oneMillionTokenBody, "Error when parsing request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBodyLimit_EnvOverride(t *testing.T) {
|
||||
t.Setenv("GATEWAY_BODY_LIMIT", "33554432") // 32 MiB
|
||||
|
||||
if got := getenvInt("GATEWAY_BODY_LIMIT", 16<<20); got != 32<<20 {
|
||||
t.Fatalf("getenvInt(GATEWAY_BODY_LIMIT) = %d, want %d — the limit must stay tunable", got, 32<<20)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBodyLimit_DefaultsWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv("GATEWAY_BODY_LIMIT", "")
|
||||
|
||||
if got := getenvInt("GATEWAY_BODY_LIMIT", 16<<20); got != 16<<20 {
|
||||
t.Fatalf("getenvInt fallback = %d, want the 16 MiB default", got)
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ type BrandInfo struct {
|
||||
// https://hanzo.id/v1/iam/.well-known/jwks (iam.hanzo.ai is a routing alias, not
|
||||
// the issuer), and the cloud CLI already defaults to hanzo.id. Pinning
|
||||
// iam.hanzo.ai here would fail the issuer check on every real token, anonymizing
|
||||
// every principal — global admin would 403 platform-wide (fail-secure, but
|
||||
// every principal — SuperAdmin would 403 platform-wide (fail-secure, but
|
||||
// broken). lux/zoo/pars already correctly point at their own .id issuers.
|
||||
var brands = map[string]BrandInfo{
|
||||
"hanzo": {ID: "hanzo", IAMIssuer: "https://hanzo.id", Domain: "hanzo.ai", AltDomains: []string{"hanzo.cloud", "hanzo.app"}},
|
||||
@@ -86,6 +86,10 @@ func BrandForHostOK(host string) (string, bool) {
|
||||
if i := strings.IndexByte(host, ':'); i >= 0 {
|
||||
host = host[:i]
|
||||
}
|
||||
// A fully-qualified Host may carry a trailing root dot ("api.lux.network.");
|
||||
// strip it so the suffix match still resolves the brand instead of failing to
|
||||
// neutral.
|
||||
host = strings.TrimSuffix(host, ".")
|
||||
best, bestLen := "", -1
|
||||
for id, b := range brands {
|
||||
for _, d := range append([]string{b.Domain}, b.AltDomains...) {
|
||||
@@ -109,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
|
||||
|
||||
@@ -6,14 +6,18 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/commerce/metering"
|
||||
aiobject "github.com/hanzoai/ai/object"
|
||||
"github.com/hanzoai/cloud/clients/commerceinproc"
|
||||
"github.com/hanzoai/cloud/clients/metering"
|
||||
luxlog "github.com/luxfi/log"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/cloud/clients"
|
||||
"github.com/hanzoai/cloud/clients/finance"
|
||||
"github.com/hanzoai/cloud/clients/gatewaypolicy"
|
||||
"github.com/hanzoai/cloud/clients/money"
|
||||
"github.com/hanzoai/cloud/clients/s3admin"
|
||||
"github.com/hanzoai/cloud/types"
|
||||
)
|
||||
|
||||
// BuildDeps constructs the Deps used by every subsystem's Mount(app, deps).
|
||||
@@ -61,6 +65,7 @@ func BuildDeps(cfg *Config) Deps {
|
||||
deps := Deps{
|
||||
Logger: logger,
|
||||
Brand: cfg.Brand,
|
||||
Version: cfg.Version,
|
||||
Env: cfg.Env,
|
||||
Domain: cfg.Domain,
|
||||
IAMIssuer: cfg.IAMIssuer,
|
||||
@@ -86,6 +91,7 @@ func BuildDeps(cfg *Config) Deps {
|
||||
// pass-through and a dev deployment is never blocked.
|
||||
deps.Metering = buildMeteringClient(cfg, logger)
|
||||
deps.AI = meteredAIClient(pickAIClient(cfg, logger), deps)
|
||||
wireFinance(cfg, logger)
|
||||
deps.O11y = pick(cfg, logger, "o11y", "O11y", cfg.O11yZAPAddr, clients.O11yRPCAt, clients.DisabledO11y)
|
||||
deps.VFS = pickVFSClient(cfg, logger)
|
||||
deps.MQ = pick(cfg, logger, "mq", "MQ", cfg.MQZAPAddr, clients.MQRPCAt, clients.DisabledMQ)
|
||||
@@ -178,6 +184,60 @@ func boolStr(b bool, t, f string) string {
|
||||
return f
|
||||
}
|
||||
|
||||
// wireFinance constructs the ONE in-process finance ledger (per-org SQLite
|
||||
// double-entry prepaid wallet), publishes it for every money consumer to resolve by
|
||||
// the narrow finance.Client, and installs the embedded ai router's balance-read +
|
||||
// usage-debit hooks so the PREPAID gate dispatches DIRECTLY to it — a typed in-proc
|
||||
// call, no HTTP, no socket. There is NO exempt path (hanzoai/ai >= v1.805.8): every
|
||||
// principal is gated on a positive prepaid balance, fail-closed. MUST run before
|
||||
// ai.Mount (the ai gate reads the hook per request; the hook must be installed first)
|
||||
// — which BuildDeps guarantees (deps are built before MountAll).
|
||||
func wireFinance(cfg *Config, log luxlog.Logger) {
|
||||
if !cfg.Enabled("commerce") {
|
||||
return // money layer not co-resident (split-deploy); ai falls back to HTTP.
|
||||
}
|
||||
fin := finance.New(cfg.DataDir)
|
||||
finance.Publish(fin)
|
||||
// Money is billed to the SUBJECT's wallet, inside the org's ledger.
|
||||
//
|
||||
// org = which ledger (the tenant's books)
|
||||
// subject = which wallet in it (ai resolves it: a person => "org/name", an
|
||||
// org-owned application/service key => the org's own account)
|
||||
//
|
||||
// So a personal account has a PERSONAL balance and a personal plan, and an org
|
||||
// pays for what its applications and service keys spend — which is the product:
|
||||
// sign up as yourself, then stand up an org whose users are your customers.
|
||||
//
|
||||
// Keying both hooks on the org collapsed every member onto the tenant's pool
|
||||
// wallet: every new signup lives in "hanzo", so a brand-new $0 account read
|
||||
// HANZO's balance and sailed through the gate — we enforced our own wallet.
|
||||
//
|
||||
// The invariant that must never break: the gate READ and the usage DEBIT key on
|
||||
// the SAME wallet, or spend can outrun the balance that admitted it. Both use
|
||||
// subject; keep them together. The gate reads a coarse cents balance (a >0
|
||||
// threshold only); the DEBIT is 18-decimal-exact.
|
||||
aiobject.SetBalanceReader(func(ctx context.Context, subject, namespace, currency string) (int64, error) {
|
||||
bal, err := fin.Balance(ctx, namespace, subject, currency, false)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return bal.Cents(), nil
|
||||
})
|
||||
// The DEBIT is exact: the ai module emits the cost as a decimal-USD string, parsed
|
||||
// here to 18-decimal USD (1e-18) so a sub-cent call bills precisely and is never floored.
|
||||
aiobject.SetUsageRecorder(func(ctx context.Context, u aiobject.UsageEvent) error {
|
||||
amt, err := money.ParseUSD(u.USD)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fin.RecordUsage(ctx, types.UsageInput{
|
||||
Org: u.Namespace, Subject: u.Subject, Amount: amt,
|
||||
Currency: u.Currency, Model: u.Model, Provider: u.Provider, RequestID: u.RequestID,
|
||||
})
|
||||
})
|
||||
log.Info("finance ledger wired (per-subject wallet in the org ledger, 18-decimal-exact, fail-closed)", "dataDir", cfg.DataDir)
|
||||
}
|
||||
|
||||
// pick resolves one inter-subsystem client under the HIP-0106 wiring rule shared
|
||||
// by every co-resident-capable dependency: enabled in THIS process → zero value
|
||||
// (nil) so the subsystem's own Mount installs the in-process client; not enabled
|
||||
@@ -289,25 +349,177 @@ func OnGitPush(ctx context.Context, ev GitPushEvent) error {
|
||||
return pushBuilder(ctx, ev)
|
||||
}
|
||||
|
||||
// ---- first-party service release (push→build→image→CR rollout) ----
|
||||
|
||||
// ServiceReleaseEvent describes a proven, clean-semver image ready to roll live on
|
||||
// an operator-managed first-party service. It is the payload of the release seam
|
||||
// that closes push→build→image→CR: after a build produces the image, the CR for
|
||||
// this service is patched to it and the operator reconciles the Deployment.
|
||||
//
|
||||
// - Service is the target CR metadata.name (the repo/service name ⇒ CR name,
|
||||
// mirroring universe's image-update.yml convention).
|
||||
// - Image is the full registry ref (repository:tag); the tag MUST be clean
|
||||
// semver (vX.Y.Z) — the releaser refuses every mutable/sha/suffixed form.
|
||||
// - SHA is the source commit for provenance (optional; logged, never gated on).
|
||||
type ServiceReleaseEvent struct {
|
||||
Service string
|
||||
Image string
|
||||
SHA string
|
||||
}
|
||||
|
||||
// serviceReleaser is the registered first-party CR-rollout seam. clients/paas
|
||||
// (the owner of the hanzo.ai/v1 Service CR control plane) installs it in Mount; a
|
||||
// proven build calls OnServiceRelease, which patches spec.image on the matching
|
||||
// CR. The inversion keeps package cloud from importing clients/paas (which imports
|
||||
// cloud) — the same idiom as pushBuilder / kmsClientFactory. Exactly one
|
||||
// registration.
|
||||
var serviceReleaser func(ctx context.Context, ev ServiceReleaseEvent) error
|
||||
|
||||
// RegisterServiceReleaser installs the first-party CR-rollout hook. clients/paas
|
||||
// calls this from its Mount when co-resident; it is the ONE inversion point that
|
||||
// lets a build-completion path roll a proven image onto its operator Service CR
|
||||
// with no cloud⇄paas import cycle.
|
||||
func RegisterServiceReleaser(f func(ctx context.Context, ev ServiceReleaseEvent) error) {
|
||||
serviceReleaser = f
|
||||
}
|
||||
|
||||
// ServiceReleaserRegistered reports whether a first-party CR-rollout hook is
|
||||
// installed (the paas control plane is co-resident). A caller uses it to know
|
||||
// whether OnServiceRelease actually patches a CR or is a no-op, so it can be
|
||||
// honest about which rollout path took effect.
|
||||
func ServiceReleaserRegistered() bool { return serviceReleaser != nil }
|
||||
|
||||
// OnServiceRelease rolls a proven image live by patching the matching hanzo.ai/v1
|
||||
// Service CR's spec.image (the operator then reconciles the Deployment). It is a
|
||||
// no-op when no releaser is registered (a binary without the paas control plane
|
||||
// co-resident). The releaser enforces the clean-semver gate and CR-name
|
||||
// resolution; this is only the dispatch seam.
|
||||
func OnServiceRelease(ctx context.Context, ev ServiceReleaseEvent) error {
|
||||
if serviceReleaser == nil {
|
||||
return nil
|
||||
}
|
||||
return serviceReleaser(ctx, ev)
|
||||
}
|
||||
|
||||
// ---- git lifecycle event stream ----
|
||||
//
|
||||
// One event, many subscribers. push-to-deploy (OnGitPush) is the deploy
|
||||
// subscriber-of-record and stays exactly as it is; this seam generalizes the SAME
|
||||
// inversion to N reactors (mirror-out, Slack-notify, …) so git/platform EMIT a
|
||||
// lifecycle fact and never import the subscribers. It is deliberately SEPARATE
|
||||
// from OnGitPush — the deploy path is single-registrant and synchronous, this
|
||||
// stream is many-registrant and best-effort — so adding a reactor can never
|
||||
// perturb push→deploy.
|
||||
|
||||
// LifecycleKind classifies a git lifecycle event. The value IS the wire name a
|
||||
// subscription filters on.
|
||||
type LifecycleKind string
|
||||
|
||||
const (
|
||||
LifecyclePushLanded LifecycleKind = "push.landed"
|
||||
LifecycleBuildStarted LifecycleKind = "build.started"
|
||||
LifecycleDeployLive LifecycleKind = "deploy.live"
|
||||
LifecycleDeployFailed LifecycleKind = "deploy.failed"
|
||||
)
|
||||
|
||||
// LifecycleEvent is one git lifecycle fact fanned out to every registered
|
||||
// subscriber. A plain data value — values, not places:
|
||||
// - Org/Project/Repo the tenant + repo the fact happened in (the routing key).
|
||||
// - Branch/Before/After the ref that moved and its old→new tip (a push).
|
||||
// - Pusher who pushed (best-effort; "" for a client-less push).
|
||||
// - DeployID/Detail the deployment id + a human one-liner (a deploy transition).
|
||||
// - Origin "" for a native push; the source host when the refs
|
||||
// arrived via an inbound mirror sync — the loop-prevention seam that lets the
|
||||
// outbound mirror subscriber suppress a re-mirror of refs it just pulled in.
|
||||
type LifecycleEvent struct {
|
||||
Kind LifecycleKind
|
||||
Org string
|
||||
Project string
|
||||
Repo string
|
||||
Branch string
|
||||
Before string
|
||||
After string
|
||||
Pusher string
|
||||
DeployID string
|
||||
Detail string
|
||||
Origin string
|
||||
}
|
||||
|
||||
// RepoFromCloneURL extracts the repo name from a git clone URL (last path segment,
|
||||
// ".git" stripped) — the repo component of the (org,project,repo) routing key that
|
||||
// a deploy emitter derives from an app/project's linked RepoURL. The ONE place this
|
||||
// derivation lives, shared by the platform + projects deploy paths (no per-package
|
||||
// copy).
|
||||
func RepoFromCloneURL(u string) string {
|
||||
u = strings.TrimSuffix(strings.TrimSpace(u), "/")
|
||||
u = strings.TrimSuffix(u, ".git")
|
||||
if i := strings.LastIndexByte(u, '/'); i >= 0 {
|
||||
return u[i+1:]
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// lifecycleSubscribers is the fan-out list. Registration happens at Mount
|
||||
// (single-threaded, before any request is served), so a plain slice is correct:
|
||||
// EmitLifecycle only ever ranges it after every subsystem's Mount has run.
|
||||
var lifecycleSubscribers []func(ctx context.Context, ev LifecycleEvent)
|
||||
|
||||
// RegisterLifecycleSubscriber adds a git-lifecycle reactor. Every subsystem that
|
||||
// reacts to a push/deploy (mirror-out, Slack-notify) registers ONE here at Mount;
|
||||
// git/platform EMIT via EmitLifecycle. The inversion keeps the emitters from
|
||||
// importing the subscribers — the same pattern as RegisterPushBuilder, but
|
||||
// many-registrant.
|
||||
func RegisterLifecycleSubscriber(fn func(ctx context.Context, ev LifecycleEvent)) {
|
||||
if fn == nil {
|
||||
return
|
||||
}
|
||||
lifecycleSubscribers = append(lifecycleSubscribers, fn)
|
||||
}
|
||||
|
||||
// ResetLifecycleSubscribers clears the registry. TEST-ONLY seam (a test mounts and
|
||||
// unmounts repeatedly); production registers once at Mount and never resets.
|
||||
func ResetLifecycleSubscribers() { lifecycleSubscribers = nil }
|
||||
|
||||
// EmitLifecycle fans one event out to every registered subscriber, best-effort and
|
||||
// NON-BLOCKING: each subscriber runs in its own goroutine on a cancel-immune
|
||||
// context (the fact already happened — a request cancel must not abort the
|
||||
// notify/mirror), so a slow reactor (a mirror push) can never delay the git/deploy
|
||||
// path or another reactor. A panicking subscriber is contained so one bad reactor
|
||||
// can neither crash the shared multi-tenant process nor starve the others; each
|
||||
// subscriber logs its own errors.
|
||||
func EmitLifecycle(ctx context.Context, ev LifecycleEvent) {
|
||||
subs := lifecycleSubscribers
|
||||
if len(subs) == 0 {
|
||||
return
|
||||
}
|
||||
bg := context.WithoutCancel(ctx)
|
||||
for _, fn := range subs {
|
||||
go func(fn func(context.Context, LifecycleEvent)) {
|
||||
defer func() { _ = recover() }()
|
||||
fn(bg, ev)
|
||||
}(fn)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 clients/commerce registers in init() — a direct Go call
|
||||
// that reads the embedded commerce datastore + the @hanzo/plans vocabulary, no
|
||||
// network hop (the HIP-0106 co-resident default). cloud never imports clients/commerce,
|
||||
// so the commerce library, its /v1/commerce subsystem, and this client share ONE
|
||||
// package with no cloud⇄commerce cycle — the same inversion KMS uses. Absent the
|
||||
// registration (clients/commerce not linked) it fails closed rather than pretending.
|
||||
// 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
|
||||
// (clients/commerceinproc) imports clients/plan, which imports cloud — a direct
|
||||
// call here would be a package cycle. Absent the registration it fails closed
|
||||
// rather than pretending.
|
||||
//
|
||||
// NETWORK PATH PRESERVED: absent co-residency the ZAP-RPC + disabled fallbacks apply
|
||||
// (out-of-process commerce, or not wired), so the remote proxy seam
|
||||
// (CLOUD_COMMERCE_ZAP_ADDR) is unchanged — this fold does NOT force the in-process
|
||||
// cutover; the live default still selects the network client when commerce is not
|
||||
// enabled in this process.
|
||||
// (CLOUD_COMMERCE_ZAP_ADDR) is unchanged — the live default still selects the
|
||||
// network client when commerce is not enabled in this process.
|
||||
func pickCommerceClient(cfg *Config, log luxlog.Logger) CommerceClient {
|
||||
if cfg.Enabled("commerce") {
|
||||
if commerceClientFactory == nil {
|
||||
log.Error("deps.Commerce: commerce enabled but no client factory registered (clients/commerce not linked); failing closed")
|
||||
log.Error("deps.Commerce: commerce enabled but no client factory registered (subsystems not linked); failing closed")
|
||||
return clients.DisabledCommerce()
|
||||
}
|
||||
log.Info("deps.Commerce → in-process (embedded commerce)", "brand", cfg.Brand)
|
||||
@@ -320,17 +532,15 @@ func pickCommerceClient(cfg *Config, log luxlog.Logger) CommerceClient {
|
||||
return clients.DisabledCommerce()
|
||||
}
|
||||
|
||||
// commerceClientFactory constructs the embedded in-process Commerce client from
|
||||
// cloud Config. clients/commerce registers it in init(); pickCommerceClient calls it
|
||||
// so cloud depends on the CommerceClient interface + this hook, never the concrete
|
||||
// commerce package — the same inversion KMS + the subsystem Registry use. Exactly
|
||||
// one registration.
|
||||
// commerceClientFactory constructs the embedded in-process Commerce client.
|
||||
// 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.
|
||||
// clients/commerce calls this from its init(); it is the ONE inversion point that
|
||||
// lets the commerce library and its /v1/commerce subsystem share one package with no
|
||||
// cloud⇄commerce cycle.
|
||||
// apps/commerce.go calls this from its init(); exactly one registration.
|
||||
func RegisterCommerceClientFactory(f func(cfg *Config, log luxlog.Logger) CommerceClient) {
|
||||
commerceClientFactory = f
|
||||
}
|
||||
@@ -446,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
|
||||
@@ -478,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 {
|
||||
@@ -493,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 }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestOnServiceReleaseNoop proves the dispatch seam is a safe no-op when no
|
||||
// releaser is registered (a binary without the paas control plane co-resident) —
|
||||
// mirroring OnGitPush's contract.
|
||||
func TestOnServiceReleaseNoop(t *testing.T) {
|
||||
RegisterServiceReleaser(nil)
|
||||
if ServiceReleaserRegistered() {
|
||||
t.Fatal("ServiceReleaserRegistered() = true with no releaser registered")
|
||||
}
|
||||
if err := OnServiceRelease(context.Background(), ServiceReleaseEvent{Service: "cloud", Image: "ghcr.io/hanzoai/cloud:v1.0.0"}); err != nil {
|
||||
t.Fatalf("OnServiceRelease with no releaser = %v, want nil no-op", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOnServiceReleaseDispatch proves a registered releaser receives the exact
|
||||
// event and its error propagates — the one inversion point paas installs.
|
||||
func TestOnServiceReleaseDispatch(t *testing.T) {
|
||||
var got ServiceReleaseEvent
|
||||
sentinel := errors.New("boom")
|
||||
RegisterServiceReleaser(func(_ context.Context, ev ServiceReleaseEvent) error {
|
||||
got = ev
|
||||
return sentinel
|
||||
})
|
||||
t.Cleanup(func() { RegisterServiceReleaser(nil) })
|
||||
|
||||
if !ServiceReleaserRegistered() {
|
||||
t.Fatal("ServiceReleaserRegistered() = false after registration")
|
||||
}
|
||||
want := ServiceReleaseEvent{Service: "hanzo-app", Image: "ghcr.io/hanzoai/hanzo-app:v1.42.15", SHA: "abc1234"}
|
||||
if err := OnServiceRelease(context.Background(), want); !errors.Is(err, sentinel) {
|
||||
t.Fatalf("OnServiceRelease error = %v, want sentinel", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("releaser received %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
+830
@@ -0,0 +1,830 @@
|
||||
// Package cek is cloud's ONE encryption-at-rest gate for its per-subsystem
|
||||
// SQLite stores. Every store opens its database through cek.Open — the single
|
||||
// seam where a plaintext file is transparently migrated to SQLCipher and a keyed
|
||||
// *sql.DB is returned — so "encrypted at rest" is a property of the open path,
|
||||
// not something each of the ~30 stores must remember to do.
|
||||
//
|
||||
// THREAT MODEL. The DO block volume under /var/lib/cloud is provider-encrypted,
|
||||
// so the residual exposure is a COPIED PV snapshot/backup or an in-cluster
|
||||
// exec/PV read seeing plaintext customer PII (crm), the ledger (treasury), org
|
||||
// wallet maps (wallets), the audit log, team/entitlements. cek removes that
|
||||
// exposure: each file's pages are SQLCipher-encrypted under a per-database key
|
||||
// that never leaves the process in the clear and is itself wrapped by the
|
||||
// KMS-injected master key; the pre-migration plaintext copy is shredded once the
|
||||
// encrypted store is proven readable. A lifted file is useless without the key.
|
||||
//
|
||||
// SCOPE / NON-GOALS. cek provides CONFIDENTIALITY at rest against the read-only
|
||||
// exposure above (no master key ⇒ no plaintext). It is NOT integrity,
|
||||
// authenticity, or anti-rollback against a PV-WRITE (node-compromise) adversary
|
||||
// who can modify the volume: the per-file id lives in the (unauthenticated) .dek
|
||||
// sidecar, so such an adversary could swap two of OUR OWN {db,.dek} pairs or
|
||||
// replay an old snapshot. That is outside the stated model and is deliberately
|
||||
// NOT defended here (a logical-id+epoch binding would add complexity for an
|
||||
// out-of-model threat); revisit only if tenant-isolation-under-node-compromise
|
||||
// is scoped in.
|
||||
//
|
||||
// ENVELOPE (the primitives live in github.com/hanzoai/sqlite/cek.go and are
|
||||
// reused verbatim — one crypto implementation, KAT-gated there):
|
||||
//
|
||||
// - Each database has its OWN random 256-bit DEK (the SQLCipher page key),
|
||||
// minted once at first touch and NEVER changed, so ciphertext pages are
|
||||
// never rewritten.
|
||||
// - Each database also gets a random 128-bit FILE ID, stored in the clear at
|
||||
// the head of its <db>.dek sidecar. The KEK is derived from that id, NOT the
|
||||
// file path: KEK = HKDF-SHA256(masterKey, lp("global") || lp(hex(fileID))).
|
||||
// The id is intrinsic to the file and travels with the sidecar, so moving
|
||||
// the data dir or changing CLOUD_DATA_DIR can never change the KEK and brick
|
||||
// a store. RFC-5869 HKDF via x/crypto/hkdf — NOT luxfi/crypto/kdf (a QZMQ
|
||||
// KeySchedule, not generic HKDF; using it would brick every store).
|
||||
// - The DEK is wrapped AES-256-GCM under the KEK, bound to the same id as AAD.
|
||||
// Sidecar = fileID(16) || wrapped-DEK. The raw DEK is never written.
|
||||
// - Master-key ROTATION rewraps only the sidecar: the DEK and fileID are
|
||||
// unchanged, so no page is rewritten and no file can be bricked.
|
||||
//
|
||||
// FAIL-SECURE. On an encryption-CAPABLE build (production is CGO + libsqlcipher)
|
||||
// a missing master key is FATAL — the data plane refuses to open unencrypted,
|
||||
// the same posture the KMS store takes. A key set on a NON-encrypting build is
|
||||
// likewise fatal. An encrypted file whose sidecar is missing is refused. A
|
||||
// migration whose encrypted copy does not reproduce the source's schema AND
|
||||
// per-table content (a rowid-independent multiset hash, not just a row count)
|
||||
// leaves the plaintext untouched and errors — the caller (MountAll) fails
|
||||
// closed, so cloud never serves a half-migrated data plane.
|
||||
package cek
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"hash"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
sqlitedrv "github.com/hanzoai/sqlite"
|
||||
)
|
||||
|
||||
// masterKeyEnv is the ONE variable that supplies the 32-byte KMS master key
|
||||
// (base64), matching the operator Deployment and clients/kms. No second gate.
|
||||
const masterKeyEnv = "CLOUD_KMS_MASTER_KEY_REF"
|
||||
|
||||
const (
|
||||
dekSuffix = ".dek" // sidecar: fileID(16) || wrapped-DEK
|
||||
lockSuffix = ".cek.lock" // per-db flock guarding first-touch + migration
|
||||
tmpSuffix = ".cek.tmp" // in-progress encrypted target (same volume → atomic rename)
|
||||
plainBakSuffix = ".plain.bak" // transient pre-migration plaintext; SHREDDED after verified open
|
||||
|
||||
sqliteMagic = "SQLite format 3\x00" // 16-byte header of an UNENCRYPTED db
|
||||
headerLen = 16
|
||||
fileIDLen = 16 // random per-file KEK-derivation id, stored in the sidecar head
|
||||
)
|
||||
|
||||
// principalType domain-separates cloud's platform databases from IAM's org/user
|
||||
// stores in the shared HKDF namespace.
|
||||
const principalType = sqlitedrv.PrincipalGlobal
|
||||
|
||||
var (
|
||||
masterOnce sync.Once
|
||||
masterKey []byte
|
||||
masterErr error
|
||||
masterOverride []byte
|
||||
)
|
||||
|
||||
// SetMasterKey injects the 32-byte master key explicitly (cloud's boot resolves
|
||||
// it once from cfg and hands it here), taking precedence over the environment.
|
||||
// Call before the first Open. A wrong-length key is ignored so the env path can
|
||||
// still apply.
|
||||
func SetMasterKey(k []byte) {
|
||||
if len(k) == 32 {
|
||||
masterOverride = append([]byte(nil), k...)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveMaster resolves the process master key exactly once:
|
||||
//
|
||||
// (key, nil) — 32-byte key AND an encryption-capable build → encrypt.
|
||||
// (nil, nil) — no key AND a non-encrypting (pure-Go) build → dev/CI plaintext.
|
||||
// (nil, error) — key malformed; OR key set on a non-encrypting build; OR NO key
|
||||
// on an encryption-capable build. The last is the production
|
||||
// fail-closed: a capable binary never silently ships plaintext.
|
||||
func resolveMaster() ([]byte, error) {
|
||||
masterOnce.Do(func() {
|
||||
raw := masterOverride
|
||||
if len(raw) == 0 {
|
||||
b64 := strings.TrimSpace(os.Getenv(masterKeyEnv))
|
||||
if b64 == "" {
|
||||
if sqlitedrv.EncryptionAvailable() {
|
||||
masterErr = fmt.Errorf("cek: %s is required on an encryption-capable build; "+
|
||||
"refusing to open the data plane unencrypted (set the KMS master key, "+
|
||||
"or run a pure-Go dev build)", masterKeyEnv)
|
||||
}
|
||||
return // pure-Go dev/CI: plaintext is expected (no codec linked)
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
masterErr = fmt.Errorf("cek: %s is not valid base64: %w", masterKeyEnv, err)
|
||||
return
|
||||
}
|
||||
raw = decoded
|
||||
}
|
||||
if len(raw) != 32 {
|
||||
masterErr = fmt.Errorf("cek: master key must decode to 32 bytes, got %d", len(raw))
|
||||
return
|
||||
}
|
||||
if !sqlitedrv.EncryptionAvailable() {
|
||||
masterErr = fmt.Errorf("cek: %s is set but this build cannot encrypt (pure-Go sqlite); "+
|
||||
"rebuild CGO_ENABLED=1 linked against libsqlcipher, or unset it for a dev build", masterKeyEnv)
|
||||
return
|
||||
}
|
||||
masterKey = raw
|
||||
})
|
||||
return masterKey, masterErr
|
||||
}
|
||||
|
||||
// Encrypting reports whether cek will encrypt at rest (a valid master key is
|
||||
// configured on an encryption-capable build). cloud calls this once at boot for
|
||||
// the posture log; a false result on a capable build means resolveMaster errored
|
||||
// and the first store Open will fail closed.
|
||||
func Encrypting() bool {
|
||||
k, err := resolveMaster()
|
||||
return err == nil && len(k) == 32
|
||||
}
|
||||
|
||||
// Open returns a *sql.DB for the SQLite database at path, encrypted at rest when
|
||||
// a master key is configured. It is the single drop-in replacement for
|
||||
// sql.Open("sqlite", path) across every cloud store.
|
||||
func Open(path string) (*sql.DB, error) {
|
||||
master, err := resolveMaster()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if master == nil {
|
||||
// Only reachable on a non-encrypting dev/CI build (a capable build with no
|
||||
// key already errored above). Preserve the prior bare-path behavior.
|
||||
return sql.Open("sqlite", path)
|
||||
}
|
||||
return openEncrypted(path, master)
|
||||
}
|
||||
|
||||
func openEncrypted(path string, master []byte) (*sql.DB, error) {
|
||||
unlock, err := flock(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
if err := recoverInterrupted(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var db *sql.DB
|
||||
switch classify(path) {
|
||||
case stateFresh:
|
||||
db, err = createFresh(path, master)
|
||||
case stateEncrypted:
|
||||
db, err = openExisting(path, master)
|
||||
default: // statePlaintext
|
||||
db, err = migrateThenOpen(path, master)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// No plaintext replica may survive a successful keyed open: shred any backup
|
||||
// left by this (or a crashed prior) migration. THIS is the security objective.
|
||||
shredPlainBak(path)
|
||||
return db, nil
|
||||
}
|
||||
|
||||
type fileState int
|
||||
|
||||
const (
|
||||
stateFresh fileState = iota // absent or too small to be a real db → mint
|
||||
statePlaintext // "SQLite format 3\0" header → migrate
|
||||
stateEncrypted // db-sized, non-magic header → SQLCipher, open via sidecar
|
||||
)
|
||||
|
||||
func classify(path string) fileState {
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil || fi.Size() < headerLen {
|
||||
return stateFresh
|
||||
}
|
||||
if isPlaintextHeader(path) {
|
||||
return statePlaintext
|
||||
}
|
||||
return stateEncrypted
|
||||
}
|
||||
|
||||
func isPlaintextHeader(path string) bool {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
var hdr [headerLen]byte
|
||||
if _, err := f.ReadAt(hdr[:], 0); err != nil {
|
||||
return false
|
||||
}
|
||||
return string(hdr[:]) == sqliteMagic
|
||||
}
|
||||
|
||||
// ── sidecar: fileID(16) || wrapped-DEK ───────────────────────────────────────
|
||||
|
||||
// mintSidecar generates a fresh fileID + DEK, wraps the DEK under the id-derived
|
||||
// KEK, and returns the DEK and the sidecar bytes to persist.
|
||||
func mintSidecar(master []byte) (dek, sidecar []byte, err error) {
|
||||
fileID := make([]byte, fileIDLen)
|
||||
if _, err = rand.Read(fileID); err != nil {
|
||||
return nil, nil, fmt.Errorf("cek: generate file id: %w", err)
|
||||
}
|
||||
kek, aad, err := deriveFor(master, fileID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer zero(kek)
|
||||
if dek, err = sqlitedrv.NewDEK(); err != nil {
|
||||
return nil, nil, fmt.Errorf("cek: new DEK: %w", err)
|
||||
}
|
||||
wrapped, err := sqlitedrv.WrapDEK(kek, dek, aad)
|
||||
if err != nil {
|
||||
zero(dek)
|
||||
return nil, nil, fmt.Errorf("cek: wrap DEK: %w", err)
|
||||
}
|
||||
sidecar = append(append(make([]byte, 0, fileIDLen+len(wrapped)), fileID...), wrapped...)
|
||||
return dek, sidecar, nil
|
||||
}
|
||||
|
||||
// unwrapSidecar reads the fileID from the sidecar head and unwraps the DEK under
|
||||
// the id-derived KEK. A wrong master key, tampered blob, or truncated sidecar
|
||||
// fails the GCM tag and errors — never a partial/garbage key.
|
||||
func unwrapSidecar(master, sidecar []byte) ([]byte, error) {
|
||||
if len(sidecar) <= fileIDLen {
|
||||
return nil, fmt.Errorf("cek: sidecar too short (%d bytes)", len(sidecar))
|
||||
}
|
||||
fileID, wrapped := sidecar[:fileIDLen], sidecar[fileIDLen:]
|
||||
kek, aad, err := deriveFor(master, fileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer zero(kek)
|
||||
dek, err := sqlitedrv.UnwrapDEK(kek, wrapped, aad)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cek: unwrap DEK (wrong master key or corrupt sidecar): %w", err)
|
||||
}
|
||||
return dek, nil
|
||||
}
|
||||
|
||||
// deriveFor derives the KEK and the wrap-AAD for a file id. Both bind to
|
||||
// hex(fileID) so the id — not any path or config value — is the sole identity.
|
||||
func deriveFor(master, fileID []byte) (kek, aad []byte, err error) {
|
||||
id := hex.EncodeToString(fileID)
|
||||
kek, err = sqlitedrv.DeriveKey(master, principalType, id)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("cek: derive KEK: %w", err)
|
||||
}
|
||||
return kek, sqlitedrv.PrincipalAAD(principalType, id), nil
|
||||
}
|
||||
|
||||
// ── open paths ───────────────────────────────────────────────────────────────
|
||||
|
||||
func createFresh(path string, master []byte) (*sql.DB, error) {
|
||||
dekPath := path + dekSuffix
|
||||
if fileExists(dekPath) {
|
||||
return openExisting(path, master) // a concurrent first-touch won the lock
|
||||
}
|
||||
dek, sidecar, err := mintSidecar(master)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer zero(dek)
|
||||
if err := writeFileAtomic(dekPath, sidecar, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := openKeyed(path, dek)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cek: create encrypted %q: %w", path, err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func openExisting(path string, master []byte) (*sql.DB, error) {
|
||||
dekPath := path + dekSuffix
|
||||
sidecar, err := os.ReadFile(dekPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cek: read sidecar %q (encrypted db, refusing to open blind): %w", dekPath, err)
|
||||
}
|
||||
dek, err := unwrapSidecar(master, sidecar)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer zero(dek)
|
||||
db, err := openKeyed(path, dek)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cek: open encrypted %q: %w", path, err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// migrateThenOpen converts an existing PLAINTEXT database to SQLCipher without
|
||||
// losing a row, then opens it. Crash-safe (plaintext is the source of truth
|
||||
// until an atomic rename commits) and fail-secure (the swap happens only after
|
||||
// the encrypted copy reproduces the source schema + per-table content hash +
|
||||
// integrity_check, re-opened via the exact keyed path the app uses).
|
||||
func migrateThenOpen(path string, master []byte) (*sql.DB, error) {
|
||||
dekPath := path + dekSuffix
|
||||
// Plaintext header ⇒ an earlier attempt did not commit: discard any stale
|
||||
// sidecar/tmp and redo from the plaintext source of truth.
|
||||
_ = os.Remove(dekPath)
|
||||
tmp := path + tmpSuffix
|
||||
removeDBFiles(tmp)
|
||||
|
||||
dek, sidecar, err := mintSidecar(master)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer zero(dek)
|
||||
|
||||
srcInv, err := exportPlaintext(path, tmp, dek)
|
||||
if err != nil {
|
||||
removeDBFiles(tmp)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if isPlaintextHeader(tmp) {
|
||||
removeDBFiles(tmp)
|
||||
return nil, fmt.Errorf("cek: migration of %q produced a plaintext file (SQLCipher not linked?)", path)
|
||||
}
|
||||
if err := verifyParity(tmp, dek, srcInv); err != nil {
|
||||
removeDBFiles(tmp)
|
||||
return nil, fmt.Errorf("cek: migration parity check failed for %q (plaintext left intact): %w", path, err)
|
||||
}
|
||||
|
||||
// Commit. Order for crash-safety:
|
||||
// 1. sidecar (the encrypted db's key),
|
||||
// 2. plaintext → <db>.plain.bak,
|
||||
// 3. delete the now-orphaned plaintext -wal/-shm (WAL was checkpoint-folded
|
||||
// into the main file, so .plain.bak is complete; removing them now closes
|
||||
// the window where a stale plaintext WAL sits beside the encrypted db),
|
||||
// 4. atomic rename tmp → path.
|
||||
// A crash between any two steps is resolved by recoverInterrupted on reboot.
|
||||
if err := writeFileAtomic(dekPath, sidecar, 0o600); err != nil {
|
||||
removeDBFiles(tmp)
|
||||
return nil, err
|
||||
}
|
||||
if err := os.Rename(path, path+plainBakSuffix); err != nil {
|
||||
removeDBFiles(tmp)
|
||||
_ = os.Remove(dekPath)
|
||||
return nil, fmt.Errorf("cek: preserve plaintext backup for %q: %w", path, err)
|
||||
}
|
||||
_ = os.Remove(path + "-wal")
|
||||
_ = os.Remove(path + "-shm")
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
_ = os.Rename(path+plainBakSuffix, path) // roll back
|
||||
_ = os.Remove(dekPath)
|
||||
return nil, fmt.Errorf("cek: swap encrypted %q into place: %w", path, err)
|
||||
}
|
||||
syncDir(filepath.Dir(path))
|
||||
|
||||
db, err := openKeyed(path, dek)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cek: open migrated %q: %w", path, err)
|
||||
}
|
||||
return db, nil // openEncrypted's tail shreds .plain.bak after this succeeds
|
||||
}
|
||||
|
||||
// exportPlaintext opens the plaintext source, folds its WAL into the main file
|
||||
// (crm.db/audit.db carry MB of live WAL), records the source inventory for the
|
||||
// parity check, and copies every page into a fresh SQLCipher database at tmp via
|
||||
// SQLCipher's own sqlcipher_export with the format compat pinned. The source is
|
||||
// only read.
|
||||
func exportPlaintext(path, tmp string, dek []byte) (inventory, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
src, err := sql.Open("sqlite", sqlitedrv.DSN(path, nil))
|
||||
if err != nil {
|
||||
return inventory{}, fmt.Errorf("cek: open plaintext %q: %w", path, err)
|
||||
}
|
||||
defer func() { _ = src.Close() }()
|
||||
src.SetMaxOpenConns(1) // ATTACH + sqlcipher_export must run on ONE connection
|
||||
|
||||
conn, err := src.Conn(ctx)
|
||||
if err != nil {
|
||||
return inventory{}, fmt.Errorf("cek: acquire conn for %q: %w", path, err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
if _, err := conn.ExecContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
|
||||
return inventory{}, fmt.Errorf("cek: checkpoint %q: %w", path, err)
|
||||
}
|
||||
srcInv, err := readInventory(ctx, conn)
|
||||
if err != nil {
|
||||
return inventory{}, fmt.Errorf("cek: inventory source %q: %w", path, err)
|
||||
}
|
||||
|
||||
// The DEK is crypto/rand hex — no injection surface; the path is a bound
|
||||
// parameter. The reopen-via-openKeyed parity check downstream is the
|
||||
// authoritative guard that the copy opens under the app's exact keyed path.
|
||||
// The target is created at the runtime's SQLCipher format (the frozen
|
||||
// libsqlcipher default), which is exactly what openKeyed reopens it under.
|
||||
attach := fmt.Sprintf(`ATTACH DATABASE ? AS enc KEY "x'%x'"`, dek)
|
||||
if _, err := conn.ExecContext(ctx, attach, tmp); err != nil {
|
||||
return inventory{}, fmt.Errorf("cek: attach encrypted target: %w", err)
|
||||
}
|
||||
if _, err := conn.ExecContext(ctx, "SELECT sqlcipher_export('enc')"); err != nil {
|
||||
_, _ = conn.ExecContext(ctx, "DETACH DATABASE enc")
|
||||
return inventory{}, fmt.Errorf("cek: sqlcipher_export: %w", err)
|
||||
}
|
||||
if _, err := conn.ExecContext(ctx, "DETACH DATABASE enc"); err != nil {
|
||||
return inventory{}, fmt.Errorf("cek: detach encrypted target: %w", err)
|
||||
}
|
||||
return srcInv, nil
|
||||
}
|
||||
|
||||
// verifyParity re-opens the encrypted copy exactly as the running app will
|
||||
// (openKeyed) and asserts: integrity_check ok, identical schema fingerprint, and
|
||||
// — per table — identical row count AND identical rowid-independent content hash.
|
||||
// The content hash (a commutative multiset sum of per-row hashes over the USER
|
||||
// columns) is what backs the zero-loss claim: it catches any value change, NULL
|
||||
// coercion, or row add/drop that count + schema + integrity_check miss, while
|
||||
// correctly ignoring the benign implicit-rowid renumbering sqlcipher_export does.
|
||||
// It runs BEFORE the atomic swap, so any discrepancy leaves the plaintext intact.
|
||||
func verifyParity(tmp string, dek []byte, src inventory) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
db, err := openKeyed(tmp, dek)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reopen encrypted copy: %w", err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
db.SetMaxOpenConns(1)
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
return fmt.Errorf("ping encrypted copy (key/compat mismatch?): %w", err)
|
||||
}
|
||||
|
||||
var ic string
|
||||
if err := db.QueryRowContext(ctx, "PRAGMA integrity_check").Scan(&ic); err != nil {
|
||||
return fmt.Errorf("integrity_check: %w", err)
|
||||
}
|
||||
if ic != "ok" {
|
||||
return fmt.Errorf("integrity_check returned %q", ic)
|
||||
}
|
||||
|
||||
conn, err := db.Conn(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("acquire verify conn: %w", err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
dst, err := readInventory(ctx, conn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inventory encrypted copy: %w", err)
|
||||
}
|
||||
if dst.schema != src.schema {
|
||||
return fmt.Errorf("schema fingerprint mismatch")
|
||||
}
|
||||
if len(dst.tables) != len(src.tables) {
|
||||
return fmt.Errorf("table set mismatch: src %d tables, dst %d", len(src.tables), len(dst.tables))
|
||||
}
|
||||
for tbl, s := range src.tables {
|
||||
d, ok := dst.tables[tbl]
|
||||
if !ok {
|
||||
return fmt.Errorf("table %q missing in encrypted copy", tbl)
|
||||
}
|
||||
if d.count != s.count {
|
||||
return fmt.Errorf("row-count mismatch in %q: src %d, dst %d", tbl, s.count, d.count)
|
||||
}
|
||||
if d.content != s.content {
|
||||
return fmt.Errorf("content-hash mismatch in %q (row values differ despite equal count)", tbl)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// inventory is the parity fingerprint: a schema hash plus, per table, a row count
|
||||
// and a content hash.
|
||||
type inventory struct {
|
||||
schema [32]byte
|
||||
tables map[string]tableStat
|
||||
}
|
||||
|
||||
type tableStat struct {
|
||||
count int64
|
||||
content [32]byte // commutative multiset sum of per-row hashes over user columns
|
||||
}
|
||||
|
||||
func readInventory(ctx context.Context, conn *sql.Conn) (inventory, error) {
|
||||
rows, err := conn.QueryContext(ctx,
|
||||
`SELECT type,name,COALESCE(tbl_name,''),COALESCE(sql,'') FROM sqlite_master `+
|
||||
`WHERE name NOT LIKE 'sqlite_%' ORDER BY type,name`)
|
||||
if err != nil {
|
||||
return inventory{}, err
|
||||
}
|
||||
var schemaLines, tables []string
|
||||
for rows.Next() {
|
||||
var typ, name, tbl, ddl string
|
||||
if err := rows.Scan(&typ, &name, &tbl, &ddl); err != nil {
|
||||
_ = rows.Close()
|
||||
return inventory{}, err
|
||||
}
|
||||
schemaLines = append(schemaLines, typ+"\x1f"+name+"\x1f"+tbl+"\x1f"+ddl)
|
||||
if typ == "table" {
|
||||
tables = append(tables, name)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return inventory{}, err
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
sort.Strings(schemaLines)
|
||||
h := sha256.New()
|
||||
for _, l := range schemaLines {
|
||||
writeLP(h, []byte(l))
|
||||
}
|
||||
inv := inventory{tables: make(map[string]tableStat, len(tables))}
|
||||
copy(inv.schema[:], h.Sum(nil))
|
||||
|
||||
for _, tbl := range tables {
|
||||
st, err := tableContent(ctx, conn, tbl)
|
||||
if err != nil {
|
||||
return inventory{}, fmt.Errorf("content %q: %w", tbl, err)
|
||||
}
|
||||
inv.tables[tbl] = st
|
||||
}
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
// tableContent streams every row of a table and folds a per-row hash (over the
|
||||
// user columns, faithfully distinguishing NULL / "" / 0 / storage class) into a
|
||||
// commutative 256-bit sum. Order-independent (survives rowid-driven scan-order
|
||||
// differences) and duplicate-safe (addition, not XOR). O(1) memory per table.
|
||||
func tableContent(ctx context.Context, conn *sql.Conn, tbl string) (tableStat, error) {
|
||||
q := `SELECT * FROM "` + strings.ReplaceAll(tbl, `"`, `""`) + `"`
|
||||
rows, err := conn.QueryContext(ctx, q)
|
||||
if err != nil {
|
||||
return tableStat{}, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
cols, err := rows.Columns()
|
||||
if err != nil {
|
||||
return tableStat{}, err
|
||||
}
|
||||
var st tableStat
|
||||
scan := make([]any, len(cols))
|
||||
ptrs := make([]any, len(cols))
|
||||
for i := range scan {
|
||||
ptrs[i] = &scan[i]
|
||||
}
|
||||
for rows.Next() {
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
return tableStat{}, err
|
||||
}
|
||||
rh := sha256.New()
|
||||
for _, v := range scan {
|
||||
encodeValue(rh, v)
|
||||
}
|
||||
var row [32]byte
|
||||
copy(row[:], rh.Sum(nil))
|
||||
addInto(&st.content, row)
|
||||
st.count++
|
||||
}
|
||||
return st, rows.Err()
|
||||
}
|
||||
|
||||
// encodeValue writes a type-tagged, length-prefixed, NULL-sentinel encoding of a
|
||||
// scanned SQLite value so two logically-equal rows hash identically and a NULL
|
||||
// never collides with "" or 0.
|
||||
func encodeValue(h hash.Hash, v any) {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
h.Write([]byte{0})
|
||||
case int64:
|
||||
var b [8]byte
|
||||
binary.BigEndian.PutUint64(b[:], uint64(x))
|
||||
h.Write([]byte{1})
|
||||
h.Write(b[:])
|
||||
case float64:
|
||||
var b [8]byte
|
||||
binary.BigEndian.PutUint64(b[:], math.Float64bits(x))
|
||||
h.Write([]byte{2})
|
||||
h.Write(b[:])
|
||||
case bool:
|
||||
h.Write([]byte{3})
|
||||
if x {
|
||||
h.Write([]byte{1})
|
||||
} else {
|
||||
h.Write([]byte{0})
|
||||
}
|
||||
case []byte:
|
||||
h.Write([]byte{4})
|
||||
writeLP(h, x)
|
||||
case string:
|
||||
h.Write([]byte{5})
|
||||
writeLP(h, []byte(x))
|
||||
case time.Time:
|
||||
h.Write([]byte{6})
|
||||
var b [8]byte
|
||||
binary.BigEndian.PutUint64(b[:], uint64(x.UnixNano()))
|
||||
h.Write(b[:])
|
||||
default:
|
||||
h.Write([]byte{9})
|
||||
writeLP(h, []byte(fmt.Sprintf("%v", x)))
|
||||
}
|
||||
}
|
||||
|
||||
// writeLP writes an 8-byte big-endian length prefix then the bytes, so
|
||||
// concatenations are unambiguous.
|
||||
func writeLP(h hash.Hash, b []byte) {
|
||||
var n [8]byte
|
||||
binary.BigEndian.PutUint64(n[:], uint64(len(b)))
|
||||
h.Write(n[:])
|
||||
h.Write(b)
|
||||
}
|
||||
|
||||
// addInto computes acc = (acc + row) mod 2^256, big-endian — the commutative,
|
||||
// duplicate-safe multiset combiner.
|
||||
func addInto(acc *[32]byte, row [32]byte) {
|
||||
var carry uint16
|
||||
for i := 31; i >= 0; i-- {
|
||||
s := uint16(acc[i]) + uint16(row[i]) + carry
|
||||
acc[i] = byte(s)
|
||||
carry = s >> 8
|
||||
}
|
||||
}
|
||||
|
||||
// recoverInterrupted resumes a migration that crashed between the commit steps,
|
||||
// keying only on files (no config). It also scrubs any stale plaintext -wal/-shm
|
||||
// so a half-committed state can never leave one beside an encrypted db.
|
||||
func recoverInterrupted(path string) error {
|
||||
dekPath := path + dekSuffix
|
||||
tmp := path + tmpSuffix
|
||||
if fileExists(path) {
|
||||
// If the live file is already encrypted, a leftover tmp is a dead
|
||||
// migration attempt — discard it. (A plaintext live file is handled by
|
||||
// migrateThenOpen, which clears tmp itself.)
|
||||
if !isPlaintextHeader(path) {
|
||||
removeDBFiles(tmp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case fileExists(tmp) && fileExists(dekPath):
|
||||
// Crashed after the sidecar+backup were written but before/during the swap
|
||||
// rename: finish it. The sidecar matches tmp.
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return fmt.Errorf("cek: resume migration swap for %q: %w", path, err)
|
||||
}
|
||||
_ = os.Remove(path + "-wal")
|
||||
_ = os.Remove(path + "-shm")
|
||||
syncDir(filepath.Dir(path))
|
||||
case fileExists(path + plainBakSuffix):
|
||||
// Swap never happened; restore the plaintext original for a clean redo.
|
||||
if err := os.Rename(path+plainBakSuffix, path); err != nil {
|
||||
return fmt.Errorf("cek: restore plaintext backup for %q: %w", path, err)
|
||||
}
|
||||
removeDBFiles(tmp)
|
||||
_ = os.Remove(dekPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// openKeyed opens a keyed SQLCipher database via the driver's ONE blessed keyed
|
||||
// open (the key rides the URI so it is applied at sqlite3_open_v2, before mattn's
|
||||
// pragma battery touches the header — a post-open PRAGMA key would be too late).
|
||||
//
|
||||
// CROSS-VERSION SAFETY. SQLCipher's on-disk format is fixed by the libsqlcipher
|
||||
// major the arcd image links; that version is FROZEN in the Dockerfile (see the
|
||||
// runbook) so an image rebuild cannot silently change the default cipher format
|
||||
// and orphan a store. An at-open `cipher_compatibility` pin is NOT usable here
|
||||
// (the URI param is silently ignored, and a post-open pragma runs after mattn has
|
||||
// already read the header) — the version freeze is the control. If a mismatch
|
||||
// ever occurs it FAILS CLOSED: openExisting returns "file is not a database" and
|
||||
// MountAll aborts — never a silent downgrade or corruption.
|
||||
func openKeyed(path string, dek []byte) (*sql.DB, error) {
|
||||
db, err := sqlitedrv.OpenDB(path, dek)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cek: open keyed %q: %w", path, err) // no dsn (holds key)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// ── shred + boring helpers ───────────────────────────────────────────────────
|
||||
|
||||
// shredPlainBak overwrites and removes the transient pre-migration plaintext
|
||||
// backup once the encrypted store is proven readable. Overwrite-then-remove is
|
||||
// defense-in-depth (best-effort on a copy-on-write/journalled fs; the block
|
||||
// volume is provider-encrypted regardless). Idempotent no-op when absent.
|
||||
func shredPlainBak(path string) {
|
||||
bak := path + plainBakSuffix
|
||||
fi, err := os.Stat(bak)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if f, err := os.OpenFile(bak, os.O_WRONLY, 0); err == nil {
|
||||
overwrite(f, fi.Size())
|
||||
_ = f.Sync()
|
||||
_ = f.Close()
|
||||
}
|
||||
_ = os.Remove(bak)
|
||||
}
|
||||
|
||||
func overwrite(f *os.File, size int64) {
|
||||
const chunk = 1 << 20
|
||||
buf := make([]byte, chunk)
|
||||
for remaining := size; remaining > 0; {
|
||||
n := int64(chunk)
|
||||
if remaining < n {
|
||||
n = remaining
|
||||
}
|
||||
if _, err := rand.Read(buf[:n]); err != nil {
|
||||
return
|
||||
}
|
||||
if _, err := f.Write(buf[:n]); err != nil {
|
||||
return
|
||||
}
|
||||
remaining -= n
|
||||
}
|
||||
}
|
||||
|
||||
func fileExists(p string) bool {
|
||||
_, err := os.Stat(p)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func removeDBFiles(base string) {
|
||||
for _, s := range []string{"", "-wal", "-shm"} {
|
||||
_ = os.Remove(base + s)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFileAtomic(dst string, data []byte, perm os.FileMode) error {
|
||||
tmp := dst + ".tmp"
|
||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, perm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cek: create %q: %w", tmp, err)
|
||||
}
|
||||
if _, err := f.Write(data); err != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("cek: write %q: %w", tmp, err)
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("cek: sync %q: %w", tmp, err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("cek: close %q: %w", tmp, err)
|
||||
}
|
||||
if err := os.Rename(tmp, dst); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("cek: rename %q→%q: %w", tmp, dst, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncDir(dir string) {
|
||||
if d, err := os.Open(dir); err == nil {
|
||||
_ = d.Sync()
|
||||
_ = d.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// flock takes an exclusive advisory lock on <path>.cek.lock, serializing
|
||||
// first-touch and migration for the SAME database across processes/goroutines
|
||||
// (different databases never contend). Returns an unlock func.
|
||||
func flock(path string) (func(), error) {
|
||||
lockPath := path + lockSuffix
|
||||
if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil {
|
||||
return nil, fmt.Errorf("cek: create lock dir: %w", err)
|
||||
}
|
||||
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cek: open lock %q: %w", lockPath, err)
|
||||
}
|
||||
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
|
||||
_ = f.Close()
|
||||
return nil, fmt.Errorf("cek: acquire lock %q: %w", lockPath, err)
|
||||
}
|
||||
return func() {
|
||||
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
||||
_ = f.Close()
|
||||
}, nil
|
||||
}
|
||||
|
||||
func zero(b []byte) {
|
||||
for i := range b {
|
||||
b[i] = 0
|
||||
}
|
||||
}
|
||||
+439
@@ -0,0 +1,439 @@
|
||||
package cek
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
sqlitedrv "github.com/hanzoai/sqlite"
|
||||
)
|
||||
|
||||
// resetMaster clears the once-resolved master so a test can choose a posture.
|
||||
// White-box tests only; production never resets.
|
||||
func resetMaster(key []byte) {
|
||||
masterOnce = sync.Once{}
|
||||
masterKey = nil
|
||||
masterErr = nil
|
||||
masterOverride = nil
|
||||
if key != nil {
|
||||
SetMasterKey(key)
|
||||
}
|
||||
}
|
||||
|
||||
func testMaster(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
k := make([]byte, 32)
|
||||
for i := range k {
|
||||
k[i] = byte(i*7 + 1)
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// requireCipher skips when the build cannot produce REAL ciphertext (a mis-linked
|
||||
// cgo build silently writes plaintext), probing rather than trusting the flag.
|
||||
// With SQLITE_REQUIRE_CODEC=1 (the Docker image build sets it) a would-be skip
|
||||
// becomes a FAILURE, so the in-image gate is airtight standalone.
|
||||
func requireCipher(t *testing.T) {
|
||||
t.Helper()
|
||||
skipOrFail := func(msg string) {
|
||||
if os.Getenv("SQLITE_REQUIRE_CODEC") == "1" {
|
||||
t.Fatal(msg)
|
||||
}
|
||||
t.Skip(msg)
|
||||
}
|
||||
if !sqlitedrv.EncryptionAvailable() {
|
||||
skipOrFail("sqlite build cannot encrypt (pure-Go); run with CGO + libsqlcipher")
|
||||
return
|
||||
}
|
||||
probe := filepath.Join(t.TempDir(), "probe.db")
|
||||
dek, _ := sqlitedrv.NewDEK()
|
||||
db, err := openKeyed(probe, dek)
|
||||
if err != nil {
|
||||
t.Fatalf("probe open: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`CREATE TABLE t(x)`); err != nil {
|
||||
t.Fatalf("probe ddl: %v", err)
|
||||
}
|
||||
_ = db.Close()
|
||||
if isPlaintextHeader(probe) {
|
||||
skipOrFail("cgo build is NOT linked against libsqlcipher (keyed db is plaintext)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// makePlaintextDB writes a genuine UNENCRYPTED SQLite db with known rows in WAL
|
||||
// mode (exercising exportPlaintext's WAL fold), returning per-table row counts.
|
||||
func makePlaintextDB(t *testing.T, path string, companies, contacts int) map[string]int64 {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatalf("open plaintext: %v", err)
|
||||
}
|
||||
for _, p := range []string{"PRAGMA journal_mode=WAL", "PRAGMA wal_autocheckpoint=0"} {
|
||||
if _, err := db.Exec(p); err != nil {
|
||||
t.Fatalf("pragma %q: %v", p, err)
|
||||
}
|
||||
}
|
||||
if _, err := db.Exec(`
|
||||
CREATE TABLE crm_companies(id TEXT PRIMARY KEY, org TEXT, name TEXT, arr INTEGER);
|
||||
CREATE INDEX ix_co_org ON crm_companies(org);
|
||||
CREATE TABLE crm_contacts(id TEXT PRIMARY KEY, org TEXT, email TEXT, note TEXT);`); err != nil {
|
||||
t.Fatalf("ddl: %v", err)
|
||||
}
|
||||
for i := 0; i < companies; i++ {
|
||||
if _, err := db.Exec(`INSERT INTO crm_companies VALUES(?,?,?,?)`,
|
||||
fmt.Sprintf("co-%d", i), "maxpower", fmt.Sprintf("Co %d", i), int64(i*1000)); err != nil {
|
||||
t.Fatalf("insert company: %v", err)
|
||||
}
|
||||
}
|
||||
for i := 0; i < contacts; i++ {
|
||||
// note is NULL on evens to exercise NULL fidelity in the content hash.
|
||||
if i%2 == 0 {
|
||||
_, err = db.Exec(`INSERT INTO crm_contacts(id,org,email) VALUES(?,?,?)`,
|
||||
fmt.Sprintf("ct-%d", i), "maxpower", fmt.Sprintf("u%d@x.io", i))
|
||||
} else {
|
||||
_, err = db.Exec(`INSERT INTO crm_contacts VALUES(?,?,?,?)`,
|
||||
fmt.Sprintf("ct-%d", i), "maxpower", fmt.Sprintf("u%d@x.io", i), "vip")
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("insert contact: %v", err)
|
||||
}
|
||||
}
|
||||
if !isPlaintextHeader(path) {
|
||||
t.Fatalf("precondition: freshly created db is not plaintext")
|
||||
}
|
||||
_ = db.Close()
|
||||
return map[string]int64{"crm_companies": int64(companies), "crm_contacts": int64(contacts)}
|
||||
}
|
||||
|
||||
func rowCount(t *testing.T, db *sql.DB, table string) int64 {
|
||||
t.Helper()
|
||||
var n int64
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&n); err != nil {
|
||||
t.Fatalf("count %s: %v", table, err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func firstBytes(t *testing.T, path string, n int) []byte {
|
||||
t.Helper()
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", path, err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
b := make([]byte, n)
|
||||
if _, err := f.ReadAt(b, 0); err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// TestMigratePlaintextToCipher is the keystone: plaintext → SQLCipher with a
|
||||
// ciphertext header, exact row parity, an unwrappable sidecar, readable data,
|
||||
// and — per RED HIGH — NO surviving plaintext backup (shredded after verify).
|
||||
func TestMigratePlaintextToCipher(t *testing.T) {
|
||||
requireCipher(t)
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "crm.db")
|
||||
want := makePlaintextDB(t, path, 37, 91)
|
||||
|
||||
resetMaster(testMaster(t))
|
||||
db, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("cek.Open (migrate): %v", err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
if isPlaintextHeader(path) {
|
||||
t.Fatalf("FAIL: %s still plaintext after migration", path)
|
||||
}
|
||||
t.Logf("migrated header (hex): %s", hex.EncodeToString(firstBytes(t, path, 16)))
|
||||
|
||||
for tbl, n := range want {
|
||||
if got := rowCount(t, db, tbl); got != n {
|
||||
t.Errorf("row-count mismatch %s: want %d got %d", tbl, n, got)
|
||||
}
|
||||
}
|
||||
|
||||
// sidecar unwraps under the id-derived KEK (path plays no role).
|
||||
sidecar, err := os.ReadFile(path + dekSuffix)
|
||||
if err != nil {
|
||||
t.Fatalf("read sidecar: %v", err)
|
||||
}
|
||||
if dek, err := unwrapSidecar(testMaster(t), sidecar); err != nil || len(dek) != 32 {
|
||||
t.Fatalf("unwrap sidecar: dek=%d err=%v", len(dek), err)
|
||||
}
|
||||
|
||||
// RED HIGH: the plaintext backup must be GONE (shredded), not lingering.
|
||||
if fileExists(path + plainBakSuffix) {
|
||||
t.Errorf("SECURITY: plaintext backup %s survived migration (must be shredded)", path+plainBakSuffix)
|
||||
}
|
||||
|
||||
var name string
|
||||
if err := db.QueryRow(`SELECT name FROM crm_companies WHERE id='co-5'`).Scan(&name); err != nil || name != "Co 5" {
|
||||
t.Errorf("content check: got %q err %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenIdempotent: a second Open of an already-migrated db is a no-op.
|
||||
func TestOpenIdempotent(t *testing.T) {
|
||||
requireCipher(t)
|
||||
path := filepath.Join(t.TempDir(), "treasury.db")
|
||||
want := makePlaintextDB(t, path, 5, 5)
|
||||
|
||||
resetMaster(testMaster(t))
|
||||
db1, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("first open: %v", err)
|
||||
}
|
||||
_ = db1.Close()
|
||||
hdr1 := firstBytes(t, path, 16)
|
||||
|
||||
db2, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("second open: %v", err)
|
||||
}
|
||||
defer func() { _ = db2.Close() }()
|
||||
if isPlaintextHeader(path) {
|
||||
t.Fatalf("db became plaintext on reopen")
|
||||
}
|
||||
for tbl, n := range want {
|
||||
if got := rowCount(t, db2, tbl); got != n {
|
||||
t.Errorf("reopen row-count %s: want %d got %d", tbl, n, got)
|
||||
}
|
||||
}
|
||||
if string(hdr1) != string(firstBytes(t, path, 16)) {
|
||||
t.Errorf("header changed on reopen (pages rewritten?)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFreshCreateEncrypted: a brand-new db is born encrypted.
|
||||
func TestFreshCreateEncrypted(t *testing.T) {
|
||||
requireCipher(t)
|
||||
path := filepath.Join(t.TempDir(), "wallets.db")
|
||||
resetMaster(testMaster(t))
|
||||
db, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open fresh: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`CREATE TABLE w(addr TEXT, org TEXT)`); err != nil {
|
||||
t.Fatalf("ddl: %v", err)
|
||||
}
|
||||
_ = db.Close()
|
||||
if isPlaintextHeader(path) {
|
||||
t.Fatalf("fresh db is plaintext")
|
||||
}
|
||||
if !fileExists(path + dekSuffix) {
|
||||
t.Fatalf("fresh db has no sidecar")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrongMasterFailsClosed: a sidecar minted under one master cannot open under
|
||||
// another — a lifted file + wrong key yields an error, never plaintext.
|
||||
func TestWrongMasterFailsClosed(t *testing.T) {
|
||||
requireCipher(t)
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
makePlaintextDB(t, path, 3, 3)
|
||||
|
||||
resetMaster(testMaster(t))
|
||||
db, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
_ = db.Close()
|
||||
|
||||
other := make([]byte, 32)
|
||||
for i := range other {
|
||||
other[i] = 0xAB
|
||||
}
|
||||
resetMaster(other)
|
||||
if _, err := Open(path); err == nil {
|
||||
t.Fatalf("SECURITY: opened encrypted db under the WRONG master key")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataDirMoveNoBrick (RED MED #2, PoC): the KEK binds to the sidecar fileID,
|
||||
// NOT CLOUD_DATA_DIR, so changing/unsetting the data dir — or moving the file to
|
||||
// a different path — must NOT brick the store.
|
||||
func TestDataDirMoveNoBrick(t *testing.T) {
|
||||
requireCipher(t)
|
||||
dir1 := t.TempDir()
|
||||
pathA := filepath.Join(dir1, "crm.db")
|
||||
want := makePlaintextDB(t, pathA, 8, 8)
|
||||
|
||||
t.Setenv("CLOUD_DATA_DIR", dir1)
|
||||
resetMaster(testMaster(t))
|
||||
db, err := Open(pathA)
|
||||
if err != nil {
|
||||
t.Fatalf("migrate under dir1: %v", err)
|
||||
}
|
||||
_ = db.Close()
|
||||
|
||||
// Simulate a data-dir change AND a physical move to a different filename.
|
||||
dir2 := t.TempDir()
|
||||
pathB := filepath.Join(dir2, "moved.db")
|
||||
copyFile(t, pathA, pathB)
|
||||
copyFile(t, pathA+dekSuffix, pathB+dekSuffix)
|
||||
t.Setenv("CLOUD_DATA_DIR", "/some/other/root") // the old brick trigger
|
||||
|
||||
db2, err := Open(pathB) // KEK from fileID → must still open
|
||||
if err != nil {
|
||||
t.Fatalf("AVAILABILITY: data-dir change bricked the store: %v", err)
|
||||
}
|
||||
defer func() { _ = db2.Close() }()
|
||||
for tbl, n := range want {
|
||||
if got := rowCount(t, db2, tbl); got != n {
|
||||
t.Errorf("moved db row-count %s: want %d got %d", tbl, n, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMissingKeyFatalOnCapableBuild (RED MED #3): an encryption-capable build
|
||||
// with NO master key must FAIL CLOSED, never silently open plaintext. On a
|
||||
// pure-Go build the same absence is dev-mode plaintext.
|
||||
func TestMissingKeyFatalOnCapableBuild(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "settings.db")
|
||||
resetMaster(nil)
|
||||
os.Unsetenv(masterKeyEnv)
|
||||
|
||||
_, err := Open(path)
|
||||
if sqlitedrv.EncryptionAvailable() {
|
||||
if err == nil {
|
||||
t.Fatalf("SECURITY: capable build opened the data plane with NO master key (silent plaintext)")
|
||||
}
|
||||
if fileExists(path) {
|
||||
t.Fatalf("SECURITY: a db file was created despite the fatal missing-key error")
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("pure-Go dev build should allow plaintext when no key: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestContentHashCatchesMutation (RED MED #4): the content hash catches a value
|
||||
// change that row-count + schema + integrity_check all miss, AND does NOT
|
||||
// false-positive on the benign implicit-rowid renumbering sqlcipher_export does.
|
||||
func TestContentHashCatchesMutation(t *testing.T) {
|
||||
requireCipher(t)
|
||||
a := filepath.Join(t.TempDir(), "a.db")
|
||||
b := filepath.Join(t.TempDir(), "b.db")
|
||||
makePlaintextDB(t, a, 10, 10)
|
||||
makePlaintextDB(t, b, 10, 10)
|
||||
if inventoryOf(t, a).tables["crm_contacts"].content != inventoryOf(t, b).tables["crm_contacts"].content {
|
||||
t.Fatalf("identical data produced different content hashes")
|
||||
}
|
||||
|
||||
// Mutate ONE value in b (same row count, same schema, integrity stays ok).
|
||||
base := inventoryOf(t, a).tables["crm_contacts"]
|
||||
mutate(t, b, `UPDATE crm_contacts SET email='HIJACKED' WHERE id='ct-3'`)
|
||||
after := inventoryOf(t, b).tables["crm_contacts"]
|
||||
if after.count != base.count {
|
||||
t.Fatalf("mutation changed the row count; test would not isolate content")
|
||||
}
|
||||
if base.content == after.content {
|
||||
t.Fatalf("SECURITY: content hash did NOT catch a value mutation (count-blind gate)")
|
||||
}
|
||||
|
||||
// Benign rowid renumber (delete+reinsert identical row, VACUUM) must NOT change it.
|
||||
c := filepath.Join(t.TempDir(), "c.db")
|
||||
makePlaintextDB(t, c, 10, 10)
|
||||
before := inventoryOf(t, c).tables["crm_contacts"].content
|
||||
mutate(t, c, `DELETE FROM crm_contacts WHERE id='ct-0'; INSERT INTO crm_contacts(id,org,email) VALUES('ct-0','maxpower','u0@x.io'); VACUUM`)
|
||||
if before != inventoryOf(t, c).tables["crm_contacts"].content {
|
||||
t.Errorf("content hash false-positived on a rowid renumber (re-inserted identical row)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCorruptSidecarFailsClosed (RED re-test #5, safety net): any undecryptable
|
||||
// state — a corrupted or missing .dek sidecar, the family a libsqlcipher format
|
||||
// mismatch would land in — FAILS CLOSED (errors), never silently opening
|
||||
// plaintext or a garbage db. (Cross-version format stability itself is enforced
|
||||
// by freezing the libsqlcipher version in the build; an at-open cipher_compat
|
||||
// pin is infeasible with mattn's URI-key requirement — see openKeyed.)
|
||||
func TestCorruptSidecarFailsClosed(t *testing.T) {
|
||||
requireCipher(t)
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
makePlaintextDB(t, path, 4, 4)
|
||||
|
||||
resetMaster(testMaster(t))
|
||||
db, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
_ = db.Close()
|
||||
|
||||
// Flip a byte in the wrapped-DEK region of the sidecar → GCM tag fails.
|
||||
sc, err := os.ReadFile(path + dekSuffix)
|
||||
if err != nil {
|
||||
t.Fatalf("read sidecar: %v", err)
|
||||
}
|
||||
sc[len(sc)-1] ^= 0xFF
|
||||
if err := os.WriteFile(path+dekSuffix, sc, 0o600); err != nil {
|
||||
t.Fatalf("write sidecar: %v", err)
|
||||
}
|
||||
resetMaster(testMaster(t))
|
||||
if _, err := Open(path); err == nil {
|
||||
t.Fatalf("SECURITY: opened an encrypted db with a corrupted DEK sidecar")
|
||||
}
|
||||
|
||||
// A missing sidecar over an encrypted db must also refuse (never open blind).
|
||||
if err := os.Remove(path + dekSuffix); err != nil {
|
||||
t.Fatalf("rm sidecar: %v", err)
|
||||
}
|
||||
resetMaster(testMaster(t))
|
||||
if _, err := Open(path); err == nil {
|
||||
t.Fatalf("SECURITY: opened an encrypted db with NO DEK sidecar")
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func inventoryOf(t *testing.T, path string) inventory {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", sqlitedrv.DSN(path, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("open for inventory: %v", err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
db.SetMaxOpenConns(1)
|
||||
conn, err := db.Conn(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("conn: %v", err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
inv, err := readInventory(context.Background(), conn)
|
||||
if err != nil {
|
||||
t.Fatalf("readInventory: %v", err)
|
||||
}
|
||||
return inv
|
||||
}
|
||||
|
||||
func mutate(t *testing.T, path, sqlText string) {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", sqlitedrv.DSN(path, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("open mutate: %v", err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
if _, err := db.Exec(sqlText); err != nil {
|
||||
t.Fatalf("mutate %q: %v", sqlText, err)
|
||||
}
|
||||
}
|
||||
|
||||
func copyFile(t *testing.T, src, dst string) {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", src, err)
|
||||
}
|
||||
if err := os.WriteFile(dst, b, 0o600); err != nil {
|
||||
t.Fatalf("write %s: %v", dst, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package cek
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The frozen fixture pins the SQLCipher on-disk FORMAT the data plane is written
|
||||
// with. A committed encrypted store (created under the build's frozen
|
||||
// libsqlcipher) is opened by TestFrozenFixtureOpens; if a future libsqlcipher
|
||||
// upgrade changes the default cipher format, opening a store written under the
|
||||
// old format FAILS — turning a silent prod brick into a red CI. (Build-time
|
||||
// SQLITE_REQUIRE_CODEC only proves a FRESH db encrypts; it cannot detect a format
|
||||
// change against pre-existing stores — the fixture is what does.)
|
||||
|
||||
const frozenCanary = "cek-format-canary-v1"
|
||||
|
||||
// frozenMaster is a FIXED, obviously-fake test key (0x00..0x1f) used only to wrap
|
||||
// the committed fixture's DEK. Not a secret; never used in production.
|
||||
func frozenMaster() []byte {
|
||||
k := make([]byte, 32)
|
||||
for i := range k {
|
||||
k[i] = byte(i)
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
const frozenDir = "testdata/frozen"
|
||||
|
||||
// TestGenerateFrozenFixture (re)writes the committed fixture. Gated on
|
||||
// CEK_GEN_FIXTURE=1 and run by hand ONLY when the format is intentionally rev'd;
|
||||
// the committed bytes are otherwise frozen.
|
||||
func TestGenerateFrozenFixture(t *testing.T) {
|
||||
if os.Getenv("CEK_GEN_FIXTURE") != "1" {
|
||||
t.Skip("set CEK_GEN_FIXTURE=1 to regenerate the frozen fixture")
|
||||
}
|
||||
requireCipher(t)
|
||||
if err := os.MkdirAll(frozenDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dbPath := filepath.Join(frozenDir, "store.db")
|
||||
for _, s := range []string{"", dekSuffix, "-wal", "-shm"} {
|
||||
_ = os.Remove(dbPath + s)
|
||||
}
|
||||
dek, sidecar, err := mintSidecar(frozenMaster())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(dbPath+dekSuffix, sidecar, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := openKeyed(dbPath, dek)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := h.Exec(`CREATE TABLE frozen(id INTEGER PRIMARY KEY, note TEXT)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := h.Exec(`INSERT INTO frozen VALUES(1,?)`, frozenCanary); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = h.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`) // fold into the main .db
|
||||
_ = h.Close()
|
||||
_ = os.Remove(dbPath + "-wal")
|
||||
_ = os.Remove(dbPath + "-shm")
|
||||
if isPlaintextHeader(dbPath) {
|
||||
t.Fatal("generated fixture is plaintext")
|
||||
}
|
||||
t.Logf("wrote frozen fixture %s (+%s)", dbPath, dekSuffix)
|
||||
}
|
||||
|
||||
// TestFrozenFixtureOpens opens the COMMITTED encrypted fixture (copied to temp so
|
||||
// the committed bytes are never mutated) and reads its canary row. A libsqlcipher
|
||||
// format change makes this fail — a red CI instead of a silent prod brick.
|
||||
func TestFrozenFixtureOpens(t *testing.T) {
|
||||
requireCipher(t)
|
||||
src := filepath.Join(frozenDir, "store.db")
|
||||
if !fileExists(src) {
|
||||
t.Fatal("frozen fixture missing — regenerate with CEK_GEN_FIXTURE=1 and commit testdata/frozen/")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
dst := filepath.Join(dir, "store.db")
|
||||
copyFile(t, src, dst)
|
||||
copyFile(t, src+dekSuffix, dst+dekSuffix)
|
||||
|
||||
resetMaster(frozenMaster())
|
||||
db, err := Open(dst)
|
||||
if err != nil {
|
||||
t.Fatalf("FORMAT DRIFT: frozen fixture failed to open (libsqlcipher format changed?): %v", err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
var note string
|
||||
if err := db.QueryRow(`SELECT note FROM frozen WHERE id=1`).Scan(¬e); err != nil {
|
||||
t.Fatalf("read canary: %v", err)
|
||||
}
|
||||
if note != frozenCanary {
|
||||
t.Fatalf("canary mismatch: got %q want %q", note, frozenCanary)
|
||||
}
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
+143
-26
@@ -160,23 +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)
|
||||
default:
|
||||
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 {
|
||||
@@ -188,10 +186,18 @@ func runLogin(env *Env, lf *loginFlags, cmd *cobra.Command) error {
|
||||
return err
|
||||
}
|
||||
creds = credsFromToken(tr)
|
||||
default:
|
||||
// The ONE interactive way: RFC 8628 device flow — link + QR + code,
|
||||
// approve from any signed-in browser or phone. Headless-safe.
|
||||
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
|
||||
}
|
||||
@@ -199,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
|
||||
}
|
||||
|
||||
@@ -215,10 +230,13 @@ func newLoginCmd(envOf func() *Env, _ *globalFlags) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "login",
|
||||
Short: "Authenticate against Hanzo IAM and store a token",
|
||||
Long: "Authenticate against Hanzo IAM (hanzo.id) via the password grant and store\n" +
|
||||
"the token in ~/.hanzo/credentials.json (mode 0600). Use --token to store an\n" +
|
||||
"externally-minted token instead, and --platform-token to store the platform\n" +
|
||||
"control-plane service token needed by apps/deploy/clusters.",
|
||||
Long: "Authenticate against Hanzo IAM (hanzo.id) and store the token in\n" +
|
||||
"~/.hanzo/credentials.json (mode 0600). Default is the device flow: scan the\n" +
|
||||
"QR (or open the link) from any signed-in device and approve — no password\n" +
|
||||
"touches this terminal, works over ssh/headless. --username/--password-stdin\n" +
|
||||
"keep the password grant for automation; --token stores an externally-minted\n" +
|
||||
"token; --platform-token stores the platform control-plane service token\n" +
|
||||
"needed by apps/deploy/clusters.",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error { return runLogin(envOf(), lf, cmd) },
|
||||
}
|
||||
@@ -238,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`")
|
||||
@@ -289,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
|
||||
}
|
||||
|
||||
@@ -318,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()
|
||||
@@ -333,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())
|
||||
}
|
||||
}
|
||||
|
||||
+209
-2
@@ -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",
|
||||
@@ -65,6 +65,7 @@ var controlCommands = map[string]string{
|
||||
"security": "scan files for hardcoded secrets (local guardrail; no server/auth)",
|
||||
"gpu": "connect this machine's GPU to the Hanzo cloud fleet (connect/status/disconnect)",
|
||||
"engine": "run a local hanzo-engine (OpenAI + Anthropic model server)",
|
||||
"code": "launch a coding agent (claude, codex, dev) on a Hanzo cloud model",
|
||||
"runner": "run this machine as a JIT CI runner for your org (GitHub Actions)",
|
||||
"run": "launch a workload on Hanzo compute (container or function)",
|
||||
"agent": "invoke a managed Hanzo agent to run a task (headless)",
|
||||
@@ -102,6 +103,9 @@ type Config struct {
|
||||
PlatformURL string `json:"platform_url,omitempty"`
|
||||
CloudURL string `json:"cloud_url,omitempty"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
APIKey string `json:"apiKey,omitempty"` // hk-… key; what `hanzo code` hands the agents
|
||||
CodeTool string `json:"code_tool,omitempty"` // default agent for bare `hanzo` / `hanzo code`: dev|claude|codex
|
||||
CodeModel string `json:"code_model,omitempty"` // default model for `hanzo code` (else defaultCodeModel)
|
||||
}
|
||||
|
||||
// Credentials holds secret material, ~/.hanzo/credentials.json, mode 0600.
|
||||
@@ -232,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.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -290,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
|
||||
@@ -368,6 +552,17 @@ func newRootCmd() *cobra.Command {
|
||||
env.out = cmd.OutOrStdout()
|
||||
return nil
|
||||
},
|
||||
// Bare `hanzo` gets you coding: log in if needed, then drop into the
|
||||
// configured agent (code_tool, else dev) on a Hanzo cloud model — one word,
|
||||
// billed to your account. `hanzo <cmd>` still runs that command.
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if codeToken(env) == "" {
|
||||
if err := runLogin(env, &loginFlags{}, cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return runCode(env, defaultAgent(env), nil)
|
||||
},
|
||||
}
|
||||
|
||||
pf := root.PersistentFlags()
|
||||
@@ -398,6 +593,7 @@ func newRootCmd() *cobra.Command {
|
||||
newSecurityCmd(envOf),
|
||||
newGPUCmd(envOf, &f),
|
||||
newEngineCmd(envOf, &f),
|
||||
newCodeCmd(envOf, &f),
|
||||
newRunnerCmd(envOf, &f),
|
||||
newRunCmd(envOf, &f),
|
||||
newAgentCmd(envOf, &f),
|
||||
@@ -430,7 +626,7 @@ func newConfigCmd() *cobra.Command {
|
||||
PersistentPreRunE: func(*cobra.Command, []string) error { return nil },
|
||||
}
|
||||
|
||||
configKeys := []string{"org", "output", "iam_issuer", "platform_url", "cloud_url", "client_id"}
|
||||
configKeys := []string{"org", "output", "iam_issuer", "platform_url", "cloud_url", "client_id", "code_tool", "code_model"}
|
||||
|
||||
get := &cobra.Command{
|
||||
Use: "get <key>",
|
||||
@@ -518,6 +714,10 @@ func (c *Config) field(key string) (string, error) {
|
||||
return c.CloudURL, nil
|
||||
case "client_id":
|
||||
return c.ClientID, nil
|
||||
case "code_tool":
|
||||
return c.CodeTool, nil
|
||||
case "code_model":
|
||||
return c.CodeModel, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown config key %q", key)
|
||||
}
|
||||
@@ -541,6 +741,13 @@ func (c *Config) setField(key, val string) error {
|
||||
c.CloudURL = val
|
||||
case "client_id":
|
||||
c.ClientID = val
|
||||
case "code_tool":
|
||||
if _, ok := codeAgents[val]; !ok {
|
||||
return fmt.Errorf("code_tool must be one of: claude, codex, dev")
|
||||
}
|
||||
c.CodeTool = val
|
||||
case "code_model":
|
||||
c.CodeModel = val
|
||||
default:
|
||||
return fmt.Errorf("unknown config key %q", key)
|
||||
}
|
||||
|
||||
+638
@@ -0,0 +1,638 @@
|
||||
package cli
|
||||
|
||||
// code.go — `hanzo code <agent> [model]`: launch a coding agent on the Hanzo AI
|
||||
// cloud. ONE way to start ANY agent on ANY Hanzo model: the endpoint, the
|
||||
// credential and the model are injected for you; nothing to export, nothing to
|
||||
// remember.
|
||||
//
|
||||
// hanzo code claude # Claude Code on the default model
|
||||
// hanzo code codex deepseek-v4-pro
|
||||
// hanzo code dev glm5.2 # ids are resolved fuzzily: glm5.2 -> glm-5.2
|
||||
// hanzo code ls # what can I run?
|
||||
//
|
||||
// Two wire protocols cover all three agents: Claude Code speaks Anthropic,
|
||||
// Codex and @hanzo/dev (a Codex fork) speak OpenAI — so an agent is just a
|
||||
// binary + a wire + the flag that turns approvals off. Agents run full-auto by
|
||||
// default (--safe restores prompting).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// defaultCodeModel is the model `hanzo code <agent>` runs when no model is
|
||||
// named. It must be a catalog-served id with working tool calls: coding agents
|
||||
// cannot operate on a text-only model even when its SSE transport is healthy.
|
||||
// zen5 is the flagship GLM-5.2-class capability alias (1M ctx, tool-capable) —
|
||||
// the frontier coding tier. Do not use the virtual `best`: Claude Code treats
|
||||
// it (like `opus`/`sonnet`/`haiku`) as a reserved alias and rewrites it to a
|
||||
// claude-* id that api.hanzo.ai does not serve. Override per invocation with an
|
||||
// explicit id: `hanzo code claude zen5-pro`.
|
||||
const defaultCodeModel = "zen5"
|
||||
|
||||
// A zenTier bridges a model id Claude Code recognizes (the carrier) to the zen
|
||||
// alias api.hanzo.ai serves. CC sizes a model's context window and features only
|
||||
// from ids it knows; an unknown id like "zen5-pro" gets a 128K budget and is
|
||||
// rejected client-side above it — the "max context 131072" the server never
|
||||
// actually imposes. Nothing lets us declare a custom model's window, so the
|
||||
// carrier (a known 1M id, [1m] where needed) unlocks the budget and modelOverrides
|
||||
// rewrites it back to zen before the request leaves the client — the carrier never
|
||||
// reaches the server. Requires Claude Code v2.1.200+.
|
||||
type zenTier struct {
|
||||
zen string // served zen alias — the wire model api.hanzo.ai receives
|
||||
carrier string // recognized id CC budgets from; "" pins zen directly (fast tiers, never >128K)
|
||||
env string // ANTHROPIC_DEFAULT_*_MODEL slot ("" = selectable, no slot)
|
||||
name string // /model picker label (via _NAME, gateway-effective)
|
||||
desc string // /model picker description (via _DESCRIPTION)
|
||||
}
|
||||
|
||||
// slotID is the tier's wire value: its carrier, or the zen id when direct.
|
||||
func (t zenTier) slotID() string {
|
||||
if t.carrier != "" {
|
||||
return t.carrier
|
||||
}
|
||||
return t.zen
|
||||
}
|
||||
|
||||
// zenTiers is the ONE source of truth for the CC-tier ⇆ zen mapping — the wire
|
||||
// env, picker branding, and modelOverrides all derive from it. Every zen id must
|
||||
// be one api.hanzo.ai serves (TestZenTiersServeReal); zen5-mini/max/ultra are
|
||||
// listed but not served, so fable maps to zen5-pro until zen5-max returns.
|
||||
var zenTiers = []zenTier{
|
||||
{zen: "zen5-flash", env: "ANTHROPIC_DEFAULT_HAIKU_MODEL", name: "Zen5 Flash", desc: "Hanzo Zen5 Flash — fast, cheap tier"},
|
||||
{zen: "zen5", carrier: "claude-sonnet-4-6[1m]", env: "ANTHROPIC_DEFAULT_SONNET_MODEL", name: "Zen5", desc: "Hanzo Zen5 — frontier tier (1M context)"},
|
||||
{zen: "zen5-pro", carrier: "claude-opus-4-8[1m]", env: "ANTHROPIC_DEFAULT_OPUS_MODEL", name: "Zen5 Pro", desc: "Hanzo Zen5 Pro — DeepSeek-V4 class (1M context)"},
|
||||
{zen: "zen5-pro", carrier: "claude-fable-5[1m]", env: "ANTHROPIC_DEFAULT_FABLE_MODEL", name: "Zen5 Pro (max effort)", desc: "Hanzo Zen5 Pro, top tier (1M context)"},
|
||||
{zen: "zen5-coder", carrier: "claude-sonnet-5", name: "Zen5 Coder", desc: "Hanzo Zen5 Coder — code-specialized (1M context)"},
|
||||
}
|
||||
|
||||
// zenCarrier maps a resolved zen alias to the carrier CC budgets from; an unknown
|
||||
// id passes through with CC's default budget.
|
||||
func zenCarrier(model string) string {
|
||||
for _, t := range zenTiers {
|
||||
if t.zen == model {
|
||||
return t.slotID()
|
||||
}
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
// stripModelSuffix drops the "[1m]" suffix so a carrier matches its override key.
|
||||
func stripModelSuffix(id string) string {
|
||||
if i := strings.IndexByte(id, '['); i >= 0 {
|
||||
return id[:i]
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// claudeModelOverrides is the carrier→zen map for settings.json: CC budgets from
|
||||
// the carrier key and sends the zen value on the wire. Direct tiers need no entry.
|
||||
func claudeModelOverrides() map[string]string {
|
||||
m := make(map[string]string, len(zenTiers))
|
||||
for _, t := range zenTiers {
|
||||
if t.carrier != "" {
|
||||
m[stripModelSuffix(t.carrier)] = t.zen
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// wire builds the env that points an agent's SDK at the Hanzo cloud.
|
||||
type wire func(base, token, model string) map[string]string
|
||||
|
||||
// anthropicWire points Claude Code at the Hanzo cloud and pins each CC tier slot
|
||||
// to its carrier (see zenTier), so subagents, the classifier, and /compact get
|
||||
// the right context budget while modelOverrides rewrites carriers back to zen ids
|
||||
// on the wire. `model` arrives already mapped to its carrier (runCode).
|
||||
func anthropicWire(base, token, model string) map[string]string {
|
||||
env := map[string]string{
|
||||
"ANTHROPIC_BASE_URL": base,
|
||||
"ANTHROPIC_AUTH_TOKEN": token,
|
||||
"ANTHROPIC_MODEL": model,
|
||||
}
|
||||
for _, t := range zenTiers {
|
||||
if t.env == "" {
|
||||
continue
|
||||
}
|
||||
env[t.env] = t.slotID()
|
||||
env[t.env+"_NAME"] = t.name
|
||||
env[t.env+"_DESCRIPTION"] = t.desc
|
||||
// SMALL_FAST_MODEL is deprecated AND not rewritten by modelOverrides, so
|
||||
// it must hold a served zen id directly — mirror the (direct) haiku slot.
|
||||
if t.env == "ANTHROPIC_DEFAULT_HAIKU_MODEL" {
|
||||
env["ANTHROPIC_SMALL_FAST_MODEL"] = t.slotID()
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func openaiWire(base, token, _ string) map[string]string {
|
||||
return map[string]string{
|
||||
"OPENAI_BASE_URL": strings.TrimSuffix(base, "/") + "/v1",
|
||||
"OPENAI_API_KEY": token,
|
||||
}
|
||||
}
|
||||
|
||||
type codeAgent struct {
|
||||
bin string // executable to exec
|
||||
wire wire // how it finds the cloud
|
||||
fullAuto []string // flags that bypass approval prompts
|
||||
modelArg []string // how the model is passed on argv (empty: via env)
|
||||
carrier func(model string) string // maps the resolved model to a client-recognized id (claude: zen→carrier); nil = pass through
|
||||
provider func(base string) []string // agents that need the endpoint declared, not just env'd
|
||||
clear []string // env that would shadow the wire (a stale key in the shell)
|
||||
configHome string // env var that relocates the agent's config dir to ~/.hanzo ("" = share the user's own install)
|
||||
seed func(dir string) error // one-time defaults for the isolated config dir
|
||||
appendSystem []string // --append-system-prompt + text; ALWAYS applied (identity, not a permission bypass — present in --safe too)
|
||||
mcp bool // auto-wire the Hanzo MCP server (code/vector/web/vision tools) as an stdio server scoped to the cwd
|
||||
install string // hint when the binary is missing
|
||||
}
|
||||
|
||||
// codex and @hanzo/dev share a lineage (dev is a Codex fork), hence a wire.
|
||||
// They also ignore OPENAI_BASE_URL and talk to chatgpt.com unless a provider is
|
||||
// declared, so declare Hanzo as the provider and select it.
|
||||
func codexLike(bin, install string) codeAgent {
|
||||
return codeAgent{
|
||||
bin: bin,
|
||||
wire: openaiWire,
|
||||
fullAuto: []string{"--dangerously-bypass-approvals-and-sandbox"},
|
||||
modelArg: []string{"-m"},
|
||||
provider: func(base string) []string {
|
||||
return []string{
|
||||
"-c", "model_provider=hanzo",
|
||||
"-c", `model_providers.hanzo.name="Hanzo"`,
|
||||
"-c", fmt.Sprintf(`model_providers.hanzo.base_url="%s/v1"`, strings.TrimSuffix(base, "/")),
|
||||
"-c", `model_providers.hanzo.env_key="OPENAI_API_KEY"`,
|
||||
"-c", `model_providers.hanzo.wire_api="responses"`,
|
||||
}
|
||||
},
|
||||
install: install,
|
||||
}
|
||||
}
|
||||
|
||||
// zenIdentityPrompt is appended to Claude Code's base system prompt so a model
|
||||
// served through the Hanzo cloud self-identifies as a Hanzo Zen model. It is an
|
||||
// APPEND, not a replace: Claude Code keeps its base prompt (tool-use, safety,
|
||||
// coding conventions); only the model's identity is Hanzo Zen. The model is
|
||||
// served as a zen5 alias via api.hanzo.ai. Passed as --append-system-prompt,
|
||||
// present in --safe too (identity is not a permission bypass).
|
||||
const zenIdentityPrompt = "You are running through the Hanzo AI cloud as a Hanzo Zen model (the `zen5` capability tier, served via api.hanzo.ai). When asked what model or assistant you are, identify as a Hanzo Zen model. You are operating inside the Claude Code harness; keep its tool-use, safety, and coding conventions — only your identity is Hanzo Zen."
|
||||
|
||||
var codeAgents = map[string]codeAgent{
|
||||
"claude": {
|
||||
bin: "claude",
|
||||
wire: anthropicWire,
|
||||
fullAuto: []string{"--dangerously-skip-permissions"},
|
||||
// --model forces the session model on argv. Claude Code persists the
|
||||
// user's last /model selection (e.g. the reserved word "best"), and that
|
||||
// persisted choice OVERRIDES ANTHROPIC_MODEL — so the env var alone cannot
|
||||
// pin the model. --model is the per-session override that beats it. CC
|
||||
// budgets the context window from this (carrier) id, then rewrites it to
|
||||
// the zen alias via modelOverrides before the request leaves the client —
|
||||
// so api.hanzo.ai serves the zen id regardless of what /model last held.
|
||||
modelArg: []string{"--model"},
|
||||
// zen→carrier: hand CC a model it recognizes so it grants the full (1M)
|
||||
// context budget; claudeSettings' modelOverrides maps it back to zen.
|
||||
carrier: zenCarrier,
|
||||
// Stamp the identity: append the Hanzo Zen identity to CC's base prompt so
|
||||
// the served model says it is a Hanzo Zen model when asked. An append (not
|
||||
// --system-prompt) keeps CC's harness prompt intact; applied in --safe too.
|
||||
appendSystem: []string{"--append-system-prompt", zenIdentityPrompt},
|
||||
clear: []string{"ANTHROPIC_API_KEY"}, // outranks AUTH_TOKEN: a stale one silently wins
|
||||
// Its own config home under ~/.hanzo, not the user's ~/.claude. Claude
|
||||
// Code and `hanzo code claude` are independent products: sharing one
|
||||
// mutable config braids them — the user's saved /model (e.g. "fable")
|
||||
// leaks in as the session identity, and hanzo's zen5 picks leak back out.
|
||||
// A separate home decomplects them; the injected zen5 slots then show
|
||||
// cleanly in /model instead of a stale saved value.
|
||||
configHome: "CLAUDE_CONFIG_DIR",
|
||||
seed: seedClaudeConfig,
|
||||
// Auto-wire the Hanzo MCP server so `hanzo code claude` starts with the
|
||||
// Hanzo tool lattice (code search over the cloud index, web search, vision,
|
||||
// fs/exec/git) instead of a bare model. Resolved + injected in runCode.
|
||||
mcp: true,
|
||||
install: "npm i -g @anthropic-ai/claude-code",
|
||||
},
|
||||
"codex": codexLike("codex", "npm i -g @openai/codex"),
|
||||
"dev": codexLike("dev", "npm i -g @hanzo/dev"),
|
||||
}
|
||||
|
||||
// defaultAgent is the agent `hanzo code` (no agent named) and bare `hanzo` launch:
|
||||
// HANZO_CODE_TOOL, else the config `code_tool`, else dev — the Hanzo agent. An
|
||||
// unknown value falls back to dev rather than failing, so a stale preference never
|
||||
// blocks the default flow.
|
||||
func defaultAgent(env *Env) codeAgent {
|
||||
name := firstNonEmpty(os.Getenv("HANZO_CODE_TOOL"), env.cfg.CodeTool)
|
||||
if a, ok := codeAgents[name]; ok {
|
||||
return a
|
||||
}
|
||||
return codeAgents["dev"]
|
||||
}
|
||||
|
||||
func newCodeCmd(envOf func() *Env, _ *globalFlags) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "code [agent] [model] [-- args]",
|
||||
Short: "Launch a coding agent (dev, claude, codex) on a Hanzo cloud model",
|
||||
Long: "Run @hanzo/dev, Claude Code, or Codex against api.hanzo.ai with the endpoint,\n" +
|
||||
"credential and model injected — no env vars to remember. `hanzo code` alone runs\n" +
|
||||
"dev (the Hanzo agent); name an agent to pick another. Model ids resolve fuzzily\n" +
|
||||
"(glm5.2 -> glm-5.2) and agents run full-auto unless you pass --safe.",
|
||||
Example: " hanzo code # dev, the default agent\n" +
|
||||
" hanzo code claude\n" +
|
||||
" hanzo code codex deepseek-v4-pro\n" +
|
||||
" hanzo code dev glm5.2 -- --resume\n" +
|
||||
" hanzo code ls",
|
||||
// Bare `hanzo code` (or `hanzo code <model>/-- args` with no agent name) runs
|
||||
// the configured default agent (code_tool, else dev). A recognized agent name
|
||||
// (or `ls`) dispatches to its subcommand; anything else is the agent's args, so
|
||||
// `hanzo code glm5.2` works too.
|
||||
DisableFlagParsing: true,
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
if len(args) > 0 {
|
||||
if _, isAgent := codeAgents[args[0]]; isAgent || args[0] == "ls" {
|
||||
sub, _, err := c.Find(args)
|
||||
if err == nil && sub != c {
|
||||
sub.SetArgs(args[1:])
|
||||
return sub.Execute()
|
||||
}
|
||||
}
|
||||
}
|
||||
return runCode(envOf(), defaultAgent(envOf()), args)
|
||||
},
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(codeAgents))
|
||||
for name := range codeAgents {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
for _, name := range names {
|
||||
agent := codeAgents[name]
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: name + " [model] [-- args]",
|
||||
Short: "Launch " + name + " on a Hanzo model (default: " + defaultCodeModel + ")",
|
||||
DisableFlagParsing: true, // agent flags (--resume, -c …) pass straight through
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
return runCode(envOf(), agent, args)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "ls",
|
||||
Aliases: []string{"models", "list"},
|
||||
Short: "List the models this account can run",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
env := envOf()
|
||||
models, err := catalog(env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, m := range models {
|
||||
fmt.Fprintln(env.out, m)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runCode(env *Env, agent codeAgent, args []string) error {
|
||||
bin, err := exec.LookPath(agent.bin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s is not installed — try: %s", agent.bin, agent.install)
|
||||
}
|
||||
token := codeToken(env)
|
||||
if token == "" {
|
||||
return fmt.Errorf("no Hanzo credential — run `hanzo login`, or set HANZO_API_KEY")
|
||||
}
|
||||
base := strings.TrimSuffix(firstNonEmpty(env.CloudURL, "https://api.hanzo.ai"), "/")
|
||||
|
||||
// First non-flag arg is the model; --safe is ours; the rest is the agent's.
|
||||
model, safe, rest := "", false, make([]string, 0, len(args))
|
||||
for _, a := range args {
|
||||
switch {
|
||||
case a == "--": // the separator is ours; the agent must not see it
|
||||
case a == "--safe" || a == "--ask":
|
||||
safe = true
|
||||
case model == "" && !strings.HasPrefix(a, "-") && len(rest) == 0:
|
||||
model = a
|
||||
default:
|
||||
rest = append(rest, a)
|
||||
}
|
||||
}
|
||||
if model == "" {
|
||||
model = defaultCodeModel
|
||||
}
|
||||
if model, err = resolveModel(env, model); err != nil {
|
||||
return err
|
||||
}
|
||||
// Resolve the served zen id first (above), THEN hand the agent its carrier:
|
||||
// the id its client recognizes for context/feature budgeting. The zen id is
|
||||
// what api.hanzo.ai serves; the carrier is a client-side concern only.
|
||||
served := model
|
||||
if agent.carrier != nil {
|
||||
model = agent.carrier(model)
|
||||
}
|
||||
|
||||
for _, k := range agent.clear {
|
||||
if err := os.Unsetenv(k); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// The isolated config dir is resolved BEFORE argv so the MCP config file can be
|
||||
// written into it (and cleaned with the rest of the session state).
|
||||
var configDir string
|
||||
if agent.configHome != "" {
|
||||
dir, err := hanzoDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if agent.seed != nil {
|
||||
if err := agent.seed(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := os.Setenv(agent.configHome, dir); err != nil {
|
||||
return err
|
||||
}
|
||||
configDir = dir
|
||||
}
|
||||
// Auto-wire the Hanzo MCP server (code search over the cloud index, web search,
|
||||
// vision, fs/exec/git) so the agent starts with the tool lattice, not a bare
|
||||
// model. Appended to the agent's own args; a missing server warns, never blocks.
|
||||
if agent.mcp && configDir != "" {
|
||||
cwd, _ := os.Getwd()
|
||||
if flags, warn := mcpArgs(configDir, cwd); warn != "" {
|
||||
fmt.Fprintf(env.out, "hanzo mcp: %s\n", warn)
|
||||
} else {
|
||||
rest = append(rest, flags...)
|
||||
}
|
||||
}
|
||||
|
||||
argv := codeArgv(agent, base, model, safe, rest)
|
||||
|
||||
for k, v := range agent.wire(base, token, model) {
|
||||
if err := os.Setenv(k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if served != model {
|
||||
fmt.Fprintf(env.out, "%s → %s (as %s) on %s\n", agent.bin, served, model, base)
|
||||
} else {
|
||||
fmt.Fprintf(env.out, "%s → %s on %s\n", agent.bin, model, base)
|
||||
}
|
||||
return execEngine(bin, argv) // exec: signals + exit code flow straight through
|
||||
}
|
||||
|
||||
// codeArgv builds the final agent command line. Permission bypass is the
|
||||
// launcher default for every agent; --safe is the single explicit opt-out.
|
||||
func codeArgv(agent codeAgent, base, model string, safe bool, rest []string) []string {
|
||||
argv := []string{agent.bin}
|
||||
if !safe {
|
||||
argv = append(argv, agent.fullAuto...)
|
||||
}
|
||||
if agent.provider != nil {
|
||||
argv = append(argv, agent.provider(base)...)
|
||||
}
|
||||
if len(agent.modelArg) > 0 { // claude takes the model via env, codex/dev on argv
|
||||
argv = append(argv, agent.modelArg...)
|
||||
argv = append(argv, model)
|
||||
}
|
||||
argv = append(argv, agent.appendSystem...) // identity — applies in safe AND full-auto
|
||||
argv = append(argv, rest...)
|
||||
return argv
|
||||
}
|
||||
|
||||
// mcpArgs resolves the Hanzo MCP server and returns the flags that attach it to a
|
||||
// claude session, scoped to cwd. It ports the Rust CLI's resolve_mcp: prefer an
|
||||
// installed `hanzo-mcp` on PATH, else `uvx hanzo-mcp` (ephemeral), else nothing —
|
||||
// MCP is an enhancement, so a missing server never blocks the session. The Hanzo
|
||||
// server is layered via --mcp-config, and --strict-mcp-config makes it the SOLE
|
||||
// MCP source: a repo's own .mcp.json is NOT loaded (it can carry a hostile stdio
|
||||
// server that would inherit the session's bearer). The config file is written into
|
||||
// the isolated config dir so it is cleaned with the rest of the hanzo session state.
|
||||
//
|
||||
// Returns the argv flags to append (possibly empty) and a warning to surface when
|
||||
// no server could be resolved, so the caller can tell the user tools are absent.
|
||||
func mcpArgs(configDir, cwd string) (flags []string, warn string) {
|
||||
prog, args := resolveHanzoMCP(cwd)
|
||||
if prog == "" {
|
||||
return nil, "hanzo-mcp not found (install: `uv tool install hanzo-mcp`); launching without Hanzo tools"
|
||||
}
|
||||
cfg := mcpConfigJSON(prog, args)
|
||||
path := filepath.Join(configDir, "mcp.json")
|
||||
if err := os.WriteFile(path, []byte(cfg), 0o600); err != nil {
|
||||
return nil, "could not write MCP config; launching without Hanzo tools"
|
||||
}
|
||||
// --strict-mcp-config: the Hanzo server is the ONLY MCP source (a repo
|
||||
// .mcp.json is ignored — it could ship a bearer-exfiltrating stdio server).
|
||||
return []string{"--mcp-config", path, "--strict-mcp-config"}, ""
|
||||
}
|
||||
|
||||
// resolveHanzoMCP finds how to launch hanzo-mcp as an stdio server scoped to cwd:
|
||||
// an installed console script first, else uv's ephemeral runner. Empty program =
|
||||
// neither is on PATH.
|
||||
func resolveHanzoMCP(cwd string) (prog string, args []string) {
|
||||
if p, err := exec.LookPath("hanzo-mcp"); err == nil {
|
||||
return p, []string{"--project-dir", cwd}
|
||||
}
|
||||
if p, err := exec.LookPath("uvx"); err == nil {
|
||||
return p, []string{"hanzo-mcp", "--project-dir", cwd}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// mcpConfigJSON is the --mcp-config document adding Hanzo's stdio server. Claude
|
||||
// requires an explicit "type". Marshaled (not fmt'd) so the cwd/program are
|
||||
// correctly escaped.
|
||||
func mcpConfigJSON(prog string, args []string) string {
|
||||
doc := map[string]any{
|
||||
"mcpServers": map[string]any{
|
||||
"hanzo": map[string]any{
|
||||
"type": "stdio",
|
||||
"command": prog,
|
||||
"args": args,
|
||||
"env": map[string]string{},
|
||||
},
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(doc)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// seedClaudeConfig writes first-run defaults into the isolated config dir so
|
||||
// `hanzo code claude` starts clean: auto-approve, high effort, onboarding done,
|
||||
// and NO pinned model (the zen5 slots come from the injected env, not saved
|
||||
// state). It never overwrites — the user's own later edits in this dir persist.
|
||||
func seedClaudeConfig(dir string) error {
|
||||
if err := upsertClaudeSettings(filepath.Join(dir, "settings.json")); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeIfAbsent(filepath.Join(dir, ".claude.json"), "{\"hasCompletedOnboarding\":true}\n")
|
||||
}
|
||||
|
||||
// claudeSettingsBase is the first-run settings.json for `hanzo code claude`:
|
||||
// sensible agent defaults and no pinned model, so the env-injected carrier slots
|
||||
// win. effortLevel is max — the deepest reasoning tier — matching the operator's
|
||||
// /effort max session setting; zen folds it into the upstream's reasoning budget
|
||||
// (anthropicThinkingBudget → normalizeReasoning) so it reaches the model. Applied
|
||||
// only when settings.json does not yet exist — later user edits to these persist.
|
||||
var claudeSettingsBase = map[string]any{
|
||||
"includeCoAuthoredBy": false,
|
||||
"permissions": map[string]any{"defaultMode": "auto"},
|
||||
"skipAutoPermissionPrompt": true,
|
||||
"skipDangerousModePermissionPrompt": true,
|
||||
"effortLevel": "max",
|
||||
"theme": "dark",
|
||||
"enableWorkflows": true,
|
||||
}
|
||||
|
||||
// upsertClaudeSettings writes settings.json, (re)applying the operator-owned
|
||||
// modelOverrides (carrier→zen) on EVERY launch while preserving the user's own
|
||||
// edits to every other key. modelOverrides is policy, not preference: it must
|
||||
// track the current zenTiers so Claude Code's context-budgeting carriers keep
|
||||
// mapping to the served zen ids — so, unlike the base defaults, it is not
|
||||
// write-once. A first run (or an unreadable/corrupt file) starts from the base.
|
||||
func upsertClaudeSettings(path string) error {
|
||||
settings := map[string]any{}
|
||||
if b, err := os.ReadFile(path); err != nil || json.Unmarshal(b, &settings) != nil || len(settings) == 0 {
|
||||
settings = map[string]any{}
|
||||
for k, v := range claudeSettingsBase {
|
||||
settings[k] = v
|
||||
}
|
||||
}
|
||||
overrides := make(map[string]any, len(zenTiers))
|
||||
for carrier, zen := range claudeModelOverrides() {
|
||||
overrides[carrier] = zen
|
||||
}
|
||||
settings["modelOverrides"] = overrides
|
||||
b, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, append(b, '\n'), 0o600)
|
||||
}
|
||||
|
||||
// writeIfAbsent creates path with content only when it does not already exist,
|
||||
// so seeding a config dir never clobbers a user's later edits.
|
||||
func writeIfAbsent(path, content string) error {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return nil
|
||||
}
|
||||
return os.WriteFile(path, []byte(content), 0o600)
|
||||
}
|
||||
|
||||
// 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.freshAccessToken(), env.cfg.APIKey, storedAPIKey())
|
||||
}
|
||||
|
||||
// storedAPIKey reads (never writes) the hk- key that hanzo-mcp and the rest of
|
||||
// the toolchain share, so one `hanzo login`/key serves every tool.
|
||||
func storedAPIKey() string {
|
||||
dir, err := hanzoDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var cfg struct {
|
||||
APIKey string `json:"apiKey"`
|
||||
}
|
||||
if err := loadJSON(filepath.Join(dir, "config.json"), &cfg); err != nil {
|
||||
return ""
|
||||
}
|
||||
return cfg.APIKey
|
||||
}
|
||||
|
||||
// resolveModel turns what you typed into an id the cloud serves. Exact ids pass
|
||||
// through; otherwise ids are compared with punctuation and case removed, so
|
||||
// "glm5.2" finds "glm-5.2". An unknown id lists the near misses rather than
|
||||
// failing deep inside the agent.
|
||||
func resolveModel(env *Env, want string) (string, error) {
|
||||
models, err := catalog(env)
|
||||
if err != nil {
|
||||
// Never hand an agent a model we could not confirm: it would fail deep
|
||||
// inside the session with an opaque error. Say so here instead.
|
||||
return "", fmt.Errorf("cannot read the model catalog: %w", err)
|
||||
}
|
||||
fold := func(s string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
return unicode.ToLower(r)
|
||||
}
|
||||
return -1
|
||||
}, s)
|
||||
}
|
||||
target := fold(want)
|
||||
var near []string
|
||||
for _, m := range models {
|
||||
switch f := fold(m); {
|
||||
case m == want, f == target:
|
||||
return m, nil
|
||||
case strings.Contains(f, target), strings.Contains(target, f):
|
||||
near = append(near, m)
|
||||
}
|
||||
}
|
||||
if len(near) == 1 {
|
||||
return near[0], nil
|
||||
}
|
||||
if len(near) > 1 {
|
||||
return "", fmt.Errorf("%q is ambiguous — did you mean: %s", want, strings.Join(near, ", "))
|
||||
}
|
||||
return "", fmt.Errorf("no model %q — run `hanzo code ls` to see what you can run", want)
|
||||
}
|
||||
|
||||
func catalog(env *Env) ([]string, error) {
|
||||
base := strings.TrimSuffix(firstNonEmpty(env.CloudURL, "https://api.hanzo.ai"), "/")
|
||||
req, err := http.NewRequest(http.MethodGet, base+"/v1/models", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+codeToken(env))
|
||||
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s/v1/models: %s", base, resp.Status)
|
||||
}
|
||||
var body struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]string, 0, len(body.Data))
|
||||
for _, m := range body.Data {
|
||||
ids = append(ids, m.ID)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
return ids, nil
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
// Copyright 2023-2025 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 cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCodeAgentsBypassPermissionsByDefault(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
flag string
|
||||
}{
|
||||
{name: "claude", flag: "--dangerously-skip-permissions"},
|
||||
{name: "codex", flag: "--dangerously-bypass-approvals-and-sandbox"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
agent := codeAgents[tt.name]
|
||||
argv := codeArgv(agent, "https://api.hanzo.ai", defaultCodeModel, false, nil)
|
||||
if !slices.Contains(argv, tt.flag) {
|
||||
t.Fatalf("default argv %q does not contain permission bypass %q", argv, tt.flag)
|
||||
}
|
||||
|
||||
safeArgv := codeArgv(agent, "https://api.hanzo.ai", defaultCodeModel, true, nil)
|
||||
if slices.Contains(safeArgv, tt.flag) {
|
||||
t.Fatalf("--safe argv %q still contains permission bypass %q", safeArgv, tt.flag)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// user's "pop open with GLM-5.2" intent maps to. It must NOT be the virtual
|
||||
// `best` (a reserved word Claude Code rewrites to an unserved claude-* id).
|
||||
if defaultCodeModel != "zen5" {
|
||||
t.Fatalf("coding-agent default %q is not the flagship zen5 alias", defaultCodeModel)
|
||||
}
|
||||
if defaultCodeModel == "best" {
|
||||
t.Fatal("default must be a concrete served id, never the reserved word best")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnthropicWirePinsCarrierTiers locks in the 1M-context fix: every CC tier
|
||||
// slot is pinned to a Claude-Code-recognized CARRIER id (so CC grants the model
|
||||
// its true, up-to-1M context budget instead of the 128K fallback it applies to
|
||||
// ids it does not know), and settings.json modelOverrides rewrites each carrier
|
||||
// back to a served zen alias before the request leaves the client — so the wire
|
||||
// model that reaches api.hanzo.ai is always zen, never the carrier. The old
|
||||
// deadlock (a raw claude-* id 403ing on the Hanzo account) cannot recur because
|
||||
// the carrier never reaches the server; the override guarantees it (asserted in
|
||||
// TestCarrierTiersAreOverridden).
|
||||
func TestAnthropicWirePinsCarrierTiers(t *testing.T) {
|
||||
env := anthropicWire("https://api.hanzo.ai", "hk-test", "claude-opus-4-8[1m]")
|
||||
|
||||
want := map[string]string{
|
||||
"ANTHROPIC_BASE_URL": "https://api.hanzo.ai",
|
||||
"ANTHROPIC_AUTH_TOKEN": "hk-test",
|
||||
"ANTHROPIC_MODEL": "claude-opus-4-8[1m]",
|
||||
"ANTHROPIC_SMALL_FAST_MODEL": "zen5-flash", // deprecated var: no override applies, so a direct served zen id
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "zen5-flash", // fast tier is carrier-less (never needs >128K)
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4-6[1m]",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-opus-4-8[1m]",
|
||||
"ANTHROPIC_DEFAULT_FABLE_MODEL": "claude-fable-5[1m]",
|
||||
}
|
||||
for k, v := range want {
|
||||
if got := env[k]; got != v {
|
||||
t.Errorf("%s: want %q, got %q", k, v, got)
|
||||
}
|
||||
}
|
||||
// Each tier slot carries a Hanzo-branded display name so the picker never
|
||||
// shows the underlying carrier (Opus/Sonnet/Haiku) — only the Zen brand.
|
||||
if env["ANTHROPIC_DEFAULT_OPUS_MODEL_NAME"] != "Zen5 Pro" {
|
||||
t.Errorf("OPUS tier must display the Zen brand, got %q", env["ANTHROPIC_DEFAULT_OPUS_MODEL_NAME"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestCarrierTiersAreOverridden is the safety net that replaces the old
|
||||
// "no claude-* in the wire" rule: a claude-* carrier in the env is SAFE only
|
||||
// because modelOverrides maps it to a served zen id. Every claude-* value the
|
||||
// wire emits must have a modelOverrides entry (keyed by its suffix-stripped id)
|
||||
// pointing at a zen alias — otherwise CC would send the raw claude-* id to
|
||||
// api.hanzo.ai and reintroduce the 403.
|
||||
func TestCarrierTiersAreOverridden(t *testing.T) {
|
||||
env := anthropicWire("https://api.hanzo.ai", "hk-test", "claude-opus-4-8[1m]")
|
||||
overrides := claudeModelOverrides()
|
||||
for k, v := range env {
|
||||
if len(v) < 6 || v[:6] != "claude" {
|
||||
continue // only carrier ids need an override
|
||||
}
|
||||
zen, ok := overrides[stripModelSuffix(v)]
|
||||
if !ok {
|
||||
t.Errorf("%s=%q is a claude-* carrier with NO modelOverrides entry — it would reach api.hanzo.ai and 403", k, v)
|
||||
continue
|
||||
}
|
||||
if len(zen) < 3 || zen[:3] != "zen" {
|
||||
t.Errorf("%s=%q overrides to %q, which is not a zen alias", k, v, zen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestZenCarrierRoundTrips checks the two-way mapping the fix depends on: every
|
||||
// tier's zen id maps to a carrier, and that carrier (suffix-stripped) maps back
|
||||
// to a served zen id via modelOverrides. An unknown id passes through unchanged.
|
||||
func TestZenCarrierRoundTrips(t *testing.T) {
|
||||
overrides := claudeModelOverrides()
|
||||
for _, tier := range zenTiers {
|
||||
carrier := zenCarrier(tier.zen)
|
||||
if tier.carrier == "" {
|
||||
// Direct tier: zenCarrier returns the served zen id itself, no override.
|
||||
if carrier != tier.zen {
|
||||
t.Errorf("direct tier %q must map to itself, got %q", tier.zen, carrier)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if carrier == tier.zen {
|
||||
t.Errorf("zenCarrier(%q) did not map to a carrier", tier.zen)
|
||||
}
|
||||
if got := overrides[stripModelSuffix(carrier)]; got != tier.zen {
|
||||
t.Errorf("carrier %q for %q overrides to %q, want %q", carrier, tier.zen, got, tier.zen)
|
||||
}
|
||||
}
|
||||
if got := zenCarrier("some-raw-upstream"); got != "some-raw-upstream" {
|
||||
t.Errorf("unknown id must pass through unchanged, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaudeArgvForcesModel locks in the fix for "everything shows up as best":
|
||||
// the claude agent must pass --model <resolved> on argv so a persisted /model
|
||||
// selection (the reserved word "best") cannot override the zen5 model the
|
||||
// launcher chose. ANTHROPIC_MODEL alone is not enough — /model beats it.
|
||||
func TestClaudeArgvForcesModel(t *testing.T) {
|
||||
agent := codeAgents["claude"]
|
||||
argv := codeArgv(agent, "https://api.hanzo.ai", "zen5-pro", false, nil)
|
||||
i := slices.Index(argv, "--model")
|
||||
if i < 0 {
|
||||
t.Fatalf("claude argv %q does not force --model (a persisted /model selection would win)", argv)
|
||||
}
|
||||
if i+1 >= len(argv) || argv[i+1] != "zen5-pro" {
|
||||
t.Fatalf("claude argv %q: --model must be followed by the resolved id zen5-pro", argv)
|
||||
}
|
||||
// --model is forced in --safe mode too (model pinning is independent of
|
||||
// the permission mode).
|
||||
safeArgv := codeArgv(agent, "https://api.hanzo.ai", "zen5-pro", true, nil)
|
||||
if i := slices.Index(safeArgv, "--model"); i < 0 || safeArgv[i+1] != "zen5-pro" {
|
||||
t.Fatalf("--safe argv %q must still force --model zen5-pro", safeArgv)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaudeAppendsZenIdentityInAllModes locks in the identity fix: the claude
|
||||
// agent appends --append-system-prompt <zenIdentityPrompt> so a Hanzo-served
|
||||
// model self-identifies as a Hanzo Zen model. Identity is not a permission
|
||||
// bypass, so it is present in --safe too (unlike --dangerously-skip-permissions).
|
||||
// codex/dev (OpenAI wire) do not carry the Anthropic-only append.
|
||||
func TestClaudeAppendsZenIdentityInAllModes(t *testing.T) {
|
||||
agent := codeAgents["claude"]
|
||||
|
||||
check := func(argv []string) {
|
||||
t.Helper()
|
||||
i := slices.Index(argv, "--append-system-prompt")
|
||||
if i < 0 || i+1 >= len(argv) || argv[i+1] != zenIdentityPrompt {
|
||||
t.Fatalf("argv %q missing --append-system-prompt <zenIdentityPrompt>", argv)
|
||||
}
|
||||
}
|
||||
|
||||
// full-auto (default)
|
||||
check(codeArgv(agent, "https://api.hanzo.ai", defaultCodeModel, false, nil))
|
||||
|
||||
// --safe keeps the identity (identity != permission bypass) but drops the bypass
|
||||
safeArgv := codeArgv(agent, "https://api.hanzo.ai", defaultCodeModel, true, nil)
|
||||
check(safeArgv)
|
||||
if slices.Contains(safeArgv, "--dangerously-skip-permissions") {
|
||||
t.Fatalf("--safe must not carry the permission bypass: %v", safeArgv)
|
||||
}
|
||||
|
||||
// codex/dev (OpenAI wire) do not carry the Anthropic-only identity append
|
||||
for _, name := range []string{"codex", "dev"} {
|
||||
argv := codeArgv(codeAgents[name], "https://api.hanzo.ai", defaultCodeModel, false, nil)
|
||||
if slices.Contains(argv, "--append-system-prompt") {
|
||||
t.Fatalf("%s must not carry the claude-only identity append: %v", name, argv)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaudeAutoWiresMCP locks in the fix for "hanzo code wires no tools": the
|
||||
// claude agent must opt into MCP auto-wiring, and the resolver must produce an
|
||||
// stdio server config that is layered STRICTLY (repo .mcp.json ignored — it could
|
||||
// exfiltrate the session bearer).
|
||||
func TestClaudeAutoWiresMCP(t *testing.T) {
|
||||
if !codeAgents["claude"].mcp {
|
||||
t.Fatal("claude agent must set mcp:true so `hanzo code claude` starts with the Hanzo tool lattice")
|
||||
}
|
||||
// codex/dev are wired additively by their own provider config, not this seam.
|
||||
for _, name := range []string{"codex", "dev"} {
|
||||
if codeAgents[name].mcp {
|
||||
t.Fatalf("%s must not use the claude MCP seam (it attaches Hanzo additively via -c)", name)
|
||||
}
|
||||
}
|
||||
|
||||
// The --mcp-config document is a valid single-server stdio config.
|
||||
cfg := mcpConfigJSON("/usr/bin/hanzo-mcp", []string{"--project-dir", "/repo"})
|
||||
var doc struct {
|
||||
MCPServers map[string]struct {
|
||||
Type string `json:"type"`
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
} `json:"mcpServers"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(cfg), &doc); err != nil {
|
||||
t.Fatalf("mcpConfigJSON is not valid JSON: %v", err)
|
||||
}
|
||||
h, ok := doc.MCPServers["hanzo"]
|
||||
if !ok || h.Type != "stdio" || h.Command != "/usr/bin/hanzo-mcp" {
|
||||
t.Fatalf("mcpConfigJSON: want one stdio server 'hanzo' → /usr/bin/hanzo-mcp, got %+v", doc.MCPServers)
|
||||
}
|
||||
if !slices.Contains(h.Args, "--project-dir") || !slices.Contains(h.Args, "/repo") {
|
||||
t.Fatalf("mcpConfigJSON: server must be scoped to the project dir, got args %v", h.Args)
|
||||
}
|
||||
|
||||
// mcpArgs writes the config into the isolated dir and returns the strict flags.
|
||||
dir := t.TempDir()
|
||||
t.Setenv("PATH", "/usr/bin/hanzo-mcp-not-here") // force the not-found path deterministically
|
||||
flags, warn := mcpArgs(dir, "/repo")
|
||||
if warn == "" || len(flags) != 0 {
|
||||
t.Fatalf("with no hanzo-mcp on PATH, mcpArgs must warn and inject nothing, got flags=%v warn=%q", flags, warn)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaudeAgentAppliesCarrier locks in the runCode wiring: the claude agent
|
||||
// carries the zen→carrier map (so the resolved zen model is handed to CC as a
|
||||
// recognized 1M id), while codex/dev have no carrier (they speak OpenAI directly
|
||||
// and must NOT rewrite the model).
|
||||
func TestClaudeAgentAppliesCarrier(t *testing.T) {
|
||||
if codeAgents["claude"].carrier == nil {
|
||||
t.Fatal("claude agent must set a carrier so CC budgets the full context window")
|
||||
}
|
||||
if got := codeAgents["claude"].carrier("zen5-pro"); got != "claude-opus-4-8[1m]" {
|
||||
t.Fatalf("claude carrier: zen5-pro must map to the opus carrier, got %q", got)
|
||||
}
|
||||
for _, name := range []string{"codex", "dev"} {
|
||||
if codeAgents[name].carrier != nil {
|
||||
t.Fatalf("%s speaks OpenAI directly and must not remap the model", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// servedZenIDs is the set of zen aliases api.hanzo.ai actually serves, confirmed
|
||||
// live (2026-07). zen5-mini/zen5-max/zen5-ultra are catalog-listed but 404 or
|
||||
// time out, so no tier may target them. Adding a tier forces confirming its id
|
||||
// serves and listing it here — the guard below fails otherwise.
|
||||
var servedZenIDs = map[string]bool{
|
||||
"zen5-flash": true,
|
||||
"zen5": true,
|
||||
"zen5-pro": true,
|
||||
"zen5-coder": true,
|
||||
}
|
||||
|
||||
// TestZenTiersServeReal is the invariant a 1M-carrier is useless without: every
|
||||
// tier's zen wire id must be one api.hanzo.ai serves. A carrier that budgets 1M
|
||||
// but rewrites to a 404 id just fails later, opaquely.
|
||||
func TestZenTiersServeReal(t *testing.T) {
|
||||
for _, tier := range zenTiers {
|
||||
if !servedZenIDs[tier.zen] {
|
||||
t.Errorf("tier %q → zen id %q is not in the confirmed-served set; a carrier to an unserved id 404s", tier.carrier, tier.zen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpsertClaudeSettings checks the seed: a fresh dir gets base defaults plus
|
||||
// the carrier→zen modelOverrides, and a re-seed REFRESHES modelOverrides (policy)
|
||||
// while PRESERVING a user's own edits to other keys (preference).
|
||||
func TestUpsertClaudeSettings(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "settings.json")
|
||||
|
||||
if err := upsertClaudeSettings(path); err != nil {
|
||||
t.Fatalf("first seed failed: %v", err)
|
||||
}
|
||||
read := func() map[string]any {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read settings: %v", err)
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
t.Fatalf("settings.json is not valid JSON: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
s := read()
|
||||
if s["effortLevel"] != "max" {
|
||||
t.Errorf("base defaults missing: effortLevel = %v", s["effortLevel"])
|
||||
}
|
||||
ov, ok := s["modelOverrides"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("modelOverrides missing or wrong type: %T", s["modelOverrides"])
|
||||
}
|
||||
if ov["claude-opus-4-8"] != "zen5-pro" {
|
||||
t.Errorf("modelOverrides[claude-opus-4-8] = %v, want zen5-pro", ov["claude-opus-4-8"])
|
||||
}
|
||||
|
||||
// User edits a preference and adds a key; re-seed must keep both.
|
||||
s["effortLevel"] = "low"
|
||||
s["userKey"] = "keepme"
|
||||
b, _ := json.Marshal(s)
|
||||
if err := os.WriteFile(path, b, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := upsertClaudeSettings(path); err != nil {
|
||||
t.Fatalf("re-seed failed: %v", err)
|
||||
}
|
||||
s2 := read()
|
||||
if s2["effortLevel"] != "low" {
|
||||
t.Errorf("re-seed clobbered user edit: effortLevel = %v, want low", s2["effortLevel"])
|
||||
}
|
||||
if s2["userKey"] != "keepme" {
|
||||
t.Errorf("re-seed dropped user key: userKey = %v", s2["userKey"])
|
||||
}
|
||||
ov2, _ := s2["modelOverrides"].(map[string]any)
|
||||
if ov2["claude-opus-4-8"] != "zen5-pro" {
|
||||
t.Errorf("re-seed lost modelOverrides: %v", ov2)
|
||||
}
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
// device.go — RFC 8628 Device Authorization Grant for `hanzo login`: the ONE
|
||||
// way any machine signs in. The CLI asks IAM for a device+user code, shows the
|
||||
// verification link as text AND a terminal QR (scan with a phone), and polls
|
||||
// the token endpoint until the user approves in any browser session. No
|
||||
// password ever touches this terminal; works headless (GPU boxes, ssh, CI).
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mdp/qrterminal/v3"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// deviceAuthResp is the RFC 8628 device authorization response.
|
||||
type deviceAuthResp struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
UserCode string `json:"user_code"`
|
||||
VerificationURI string `json:"verification_uri"`
|
||||
VerificationURIComplete string `json:"verification_uri_complete"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
Interval int64 `json:"interval"`
|
||||
Error string `json:"error"`
|
||||
ErrorDesc string `json:"error_description"`
|
||||
}
|
||||
|
||||
const deviceGrantType = "urn:ietf:params:oauth:grant-type:device_code"
|
||||
|
||||
// defaultDeviceClientID is the first-party client the device grant runs as.
|
||||
// hanzo-app is the one Hanzo client IAM seeds with device_code enabled
|
||||
// (iam cmd/iam/cli/init_apps.go brandGrantTypes); hanzo-console stays the
|
||||
// password-grant client. hanzo-dev's live device flow uses the same pair of
|
||||
// endpoints and this client, so the two CLIs share one server-side config.
|
||||
const defaultDeviceClientID = "hanzo-app"
|
||||
|
||||
// rawForm posts a form to an oauth endpoint and returns the decoded token
|
||||
// response WITHOUT mapping OAuth errors to Go errors — the device poll must
|
||||
// inspect authorization_pending / slow_down itself.
|
||||
func (c *iamClient) rawForm(ctx context.Context, endpoint string, form url.Values) (*tokenResp, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.issuer+endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "hanzo-cli/"+Version)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
var tr tokenResp
|
||||
if err := json.Unmarshal(body, &tr); err != nil {
|
||||
return nil, fmt.Errorf("iam %s: HTTP %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return &tr, nil
|
||||
}
|
||||
|
||||
// deviceAuth starts the device flow: mints a device_code + user_code pair.
|
||||
// Query-param shape matches hanzo-dev's verified client (oidc_device_auth.rs).
|
||||
func (c *iamClient) deviceAuth(ctx context.Context, scope string) (*deviceAuthResp, error) {
|
||||
q := url.Values{"client_id": {c.clientID}, "scope": {scope}, "response_type": {"device_code"}}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.issuer+"/v1/iam/oauth/device?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "hanzo-cli/"+Version)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
var da deviceAuthResp
|
||||
if err := json.Unmarshal(body, &da); err != nil {
|
||||
return nil, fmt.Errorf("iam device auth: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
if da.Error != "" {
|
||||
return nil, fmt.Errorf("iam device auth: %s: %s", da.Error, da.ErrorDesc)
|
||||
}
|
||||
if da.DeviceCode == "" || da.UserCode == "" {
|
||||
return nil, fmt.Errorf("iam device auth: HTTP %d: no device_code in response", resp.StatusCode)
|
||||
}
|
||||
return &da, nil
|
||||
}
|
||||
|
||||
// pollDeviceToken polls the token endpoint until the user approves, the code
|
||||
// expires, or ctx ends. authorization_pending keeps polling; slow_down backs
|
||||
// off per RFC 8628 §3.5.
|
||||
func (c *iamClient) pollDeviceToken(ctx context.Context, da *deviceAuthResp) (*tokenResp, error) {
|
||||
interval := time.Duration(max64(da.Interval, 1)) * time.Second
|
||||
deadline := time.Now().Add(time.Duration(da.ExpiresIn) * time.Second)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(interval):
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return nil, fmt.Errorf("device code expired before approval — run `hanzo login` again")
|
||||
}
|
||||
tr, err := c.rawForm(ctx, "/v1/iam/oauth/token", url.Values{
|
||||
"grant_type": {deviceGrantType},
|
||||
"client_id": {c.clientID},
|
||||
"device_code": {da.DeviceCode},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch tr.Error {
|
||||
case "":
|
||||
if tr.AccessToken == "" {
|
||||
return nil, fmt.Errorf("iam device token: empty access_token")
|
||||
}
|
||||
return tr, nil
|
||||
case "authorization_pending":
|
||||
// approved not yet — keep waiting
|
||||
case "slow_down":
|
||||
interval += 5 * time.Second
|
||||
case "expired_token":
|
||||
return nil, fmt.Errorf("device code expired before approval — run `hanzo login` again")
|
||||
default:
|
||||
return nil, fmt.Errorf("iam device token: %s: %s", tr.Error, tr.ErrorDesc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runDeviceLogin drives the interactive device sign-in and returns credentials.
|
||||
func runDeviceLogin(cmd *cobra.Command, env *Env, scope string) (*Credentials, error) {
|
||||
out := cmd.OutOrStdout()
|
||||
// An explicit --client-id/HANZO_CLIENT_ID override wins; otherwise the
|
||||
// device grant runs as hanzo-app (see defaultDeviceClientID).
|
||||
clientID := env.ClientID
|
||||
if clientID == "" || clientID == defaultClientID {
|
||||
clientID = defaultDeviceClientID
|
||||
}
|
||||
iam := newIAMClient(env.IAMIssuer, clientID)
|
||||
da, err := iam.deviceAuth(cmd.Context(), scope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
link := firstNonEmpty(da.VerificationURIComplete, da.VerificationURI)
|
||||
fmt.Fprintf(out, "\nSign in on any device:\n\n")
|
||||
printQR(out, link)
|
||||
fmt.Fprintf(out, "\n %s\n", link)
|
||||
if da.VerificationURI != "" && da.UserCode != "" {
|
||||
fmt.Fprintf(out, " or open %s and enter code %s\n", da.VerificationURI, da.UserCode)
|
||||
}
|
||||
fmt.Fprintf(out, "\nWaiting for approval…\n")
|
||||
tr, err := iam.pollDeviceToken(cmd.Context(), da)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return credsFromToken(tr), nil
|
||||
}
|
||||
|
||||
// printQR renders a scannable QR of the verification link using half blocks
|
||||
// (compact enough for a default 80×24 terminal).
|
||||
func printQR(w io.Writer, link string) {
|
||||
qrterminal.GenerateWithConfig(link, qrterminal.Config{
|
||||
Level: qrterminal.L,
|
||||
Writer: w,
|
||||
HalfBlocks: true,
|
||||
QuietZone: 1,
|
||||
})
|
||||
}
|
||||
|
||||
func max64(a, b int64) int64 {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeviceAuth(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/iam/oauth/device" {
|
||||
t.Fatalf("path = %s", r.URL.Path)
|
||||
}
|
||||
if got := r.URL.Query().Get("client_id"); got != "hanzo-app" {
|
||||
t.Fatalf("client_id = %q", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("response_type"); got != "device_code" {
|
||||
t.Fatalf("response_type = %q", got)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"device_code": "dc-1",
|
||||
"user_code": "WDJB-MJHT",
|
||||
"verification_uri": srv0 + "/login/oauth/device",
|
||||
"verification_uri_complete": srv0 + "/login/oauth/device/WDJB-MJHT",
|
||||
"expires_in": 900,
|
||||
"interval": 1,
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
srv0 = srv.URL
|
||||
|
||||
iam := newIAMClient(srv.URL, "hanzo-app")
|
||||
da, err := iam.deviceAuth(context.Background(), "openid profile email")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if da.DeviceCode != "dc-1" || da.UserCode != "WDJB-MJHT" {
|
||||
t.Fatalf("unexpected device auth: %+v", da)
|
||||
}
|
||||
if !strings.HasSuffix(da.VerificationURIComplete, "/WDJB-MJHT") {
|
||||
t.Fatalf("verification_uri_complete = %q", da.VerificationURIComplete)
|
||||
}
|
||||
}
|
||||
|
||||
// srv0 lets the handler reference its own server URL in the response body.
|
||||
var srv0 string
|
||||
|
||||
func TestPollDeviceTokenPendingThenIssued(t *testing.T) {
|
||||
polls := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/iam/oauth/token" {
|
||||
t.Fatalf("path = %s", r.URL.Path)
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
if got := r.Form.Get("grant_type"); got != deviceGrantType {
|
||||
t.Fatalf("grant_type = %q", got)
|
||||
}
|
||||
if got := r.Form.Get("device_code"); got != "dc-1" {
|
||||
t.Fatalf("device_code = %q", got)
|
||||
}
|
||||
polls++
|
||||
if polls < 3 {
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": "authorization_pending"})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "tok-1", "token_type": "Bearer", "expires_in": 3600})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
iam := newIAMClient(srv.URL, "hanzo-console")
|
||||
tr, err := iam.pollDeviceToken(context.Background(), &deviceAuthResp{DeviceCode: "dc-1", ExpiresIn: 30, Interval: 0})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tr.AccessToken != "tok-1" || polls != 3 {
|
||||
t.Fatalf("token = %q polls = %d", tr.AccessToken, polls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollDeviceTokenExpired(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": "expired_token"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
iam := newIAMClient(srv.URL, "hanzo-console")
|
||||
_, err := iam.pollDeviceToken(context.Background(), &deviceAuthResp{DeviceCode: "dc-1", ExpiresIn: 30, Interval: 0})
|
||||
if err == nil || !strings.Contains(err.Error(), "expired") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollDeviceTokenDenied(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": "access_denied", "error_description": "user refused"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
iam := newIAMClient(srv.URL, "hanzo-console")
|
||||
_, err := iam.pollDeviceToken(context.Background(), &deviceAuthResp{DeviceCode: "dc-1", ExpiresIn: 30, Interval: 0})
|
||||
if err == nil || !strings.Contains(err.Error(), "access_denied") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
+212
-4
@@ -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
|
||||
@@ -94,6 +98,8 @@ func newGPUCmd(envOf func() *Env, _ *globalFlags) *cobra.Command {
|
||||
var engineURL string
|
||||
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",
|
||||
@@ -105,6 +111,8 @@ func newGPUCmd(envOf func() *Env, _ *globalFlags) *cobra.Command {
|
||||
engineURL: engineURL,
|
||||
engineEndpoint: engineEndpoint,
|
||||
registerProvider: registerProvider,
|
||||
studioDir: studioDir,
|
||||
studioURL: studioURL,
|
||||
}
|
||||
if daemon {
|
||||
return installDaemon(cmd, opts)
|
||||
@@ -118,6 +126,8 @@ func newGPUCmd(envOf func() *Env, _ *globalFlags) *cobra.Command {
|
||||
connect.Flags().StringVar(&engineURL, "engine-url", defaultEngineURL, "local URL where hanzo-engine is probed (GET /v1/models)")
|
||||
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",
|
||||
@@ -310,9 +320,13 @@ func sanitizeID(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// detectGPUs queries nvidia-smi for the machine's accelerators, degrading
|
||||
// gracefully to an empty list (CPU-only) when nvidia-smi is absent or errors.
|
||||
// detectGPUs queries nvidia-smi for the machine's accelerators; on Apple
|
||||
// Silicon it reports the chip's integrated GPU with its unified memory.
|
||||
// Degrades gracefully to an empty list (CPU-only) when neither is present.
|
||||
func detectGPUs() []gpuInfo {
|
||||
if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" {
|
||||
return detectAppleGPU()
|
||||
}
|
||||
out, err := exec.Command("nvidia-smi", "--query-gpu=name,memory.total", "--format=csv,noheader").Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
@@ -330,6 +344,27 @@ func detectGPUs() []gpuInfo {
|
||||
return gpus
|
||||
}
|
||||
|
||||
// detectAppleGPU reports the Apple Silicon chip as one GPU with the machine's
|
||||
// unified memory (Metal/MPS shares it all), MiB-formatted like nvidia-smi.
|
||||
func detectAppleGPU() []gpuInfo {
|
||||
brand, err := exec.Command("sysctl", "-n", "machdep.cpu.brand_string").Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
name := strings.TrimSpace(string(brand))
|
||||
if !strings.HasPrefix(name, "Apple") {
|
||||
return nil
|
||||
}
|
||||
info := gpuInfo{Name: name + " (Metal)"}
|
||||
if mem, err := exec.Command("sysctl", "-n", "hw.memsize").Output(); err == nil {
|
||||
var b int64
|
||||
if _, err := fmt.Sscan(strings.TrimSpace(string(mem)), &b); err == nil && b > 0 {
|
||||
info.MemoryTotal = fmt.Sprintf("%d MiB", b/(1024*1024))
|
||||
}
|
||||
}
|
||||
return []gpuInfo{info}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// connect.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -341,6 +376,8 @@ type connectOpts struct {
|
||||
engineURL string // local URL to probe hanzo-engine
|
||||
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 {
|
||||
@@ -369,6 +406,12 @@ func runConnect(cmd *cobra.Command, env *Env, opts connectOpts) error {
|
||||
return fmt.Errorf("register: %w", err)
|
||||
}
|
||||
fmt.Fprintf(out, "connected %q to %s (org %s) — %s\n", w.hostname, w.baseURL, orgOf(env), describeGPUs(w.gpus))
|
||||
|
||||
// studio.render backend: the claim loop drives the LOCAL studio server, so
|
||||
// when a checkout is named we own its lifecycle too — no separate watchdog.
|
||||
if opts.studioDir != "" {
|
||||
go superviseStudio(ctx, opts.studioDir, out)
|
||||
}
|
||||
fmt.Fprintf(out, "claiming %s jobs; heartbeating every %s. Ctrl-C to stop (the machine goes offline after ~90s; `hanzo gpu disconnect` removes it).\n", w.jobsNS, heartbeatEvery)
|
||||
|
||||
if w.serveEngine {
|
||||
@@ -387,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)
|
||||
@@ -414,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -507,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)
|
||||
@@ -675,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.
|
||||
@@ -766,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():
|
||||
@@ -790,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
|
||||
}
|
||||
}
|
||||
@@ -904,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 {
|
||||
@@ -1154,6 +1359,9 @@ func installDaemon(cmd *cobra.Command, opts connectOpts) error {
|
||||
args += " --register-provider"
|
||||
}
|
||||
}
|
||||
if opts.studioDir != "" {
|
||||
args += " --studio-dir " + opts.studioDir
|
||||
}
|
||||
unit := fmt.Sprintf(`[Unit]
|
||||
Description=Hanzo GPU worker (bring-your-own compute)
|
||||
After=network-online.target
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
// studio.go — local Hanzo Studio render-backend supervision for `hanzo gpu
|
||||
// connect --studio-dir <checkout>`. The gpu-jobs claim loop renders on the
|
||||
// LOCAL studio server (127.0.0.1:8188); this keeps that server alive so the
|
||||
// box needs no separate watchdog script or hand-rolled systemd unit — the
|
||||
// hanzo CLI is the one way a BYO box joins the fleet, render backend included.
|
||||
//
|
||||
// Semantics (ported from the GB10 watchdog it replaces): health-probe
|
||||
// /system_stats; on failure, one grace re-check, then free the port (a stale
|
||||
// main.py holding :8188 makes the new one crash on EADDRINUSE) and relaunch
|
||||
// from the checkout's venv. Emits a line only on restart events.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
studioAddr = "127.0.0.1:8188"
|
||||
studioHealthURL = "http://" + studioAddr + "/system_stats"
|
||||
studioProbeEvery = 45 * time.Second
|
||||
studioGraceWait = 8 * time.Second
|
||||
studioStartWindow = 180 * time.Second
|
||||
)
|
||||
|
||||
func studioHealthy(ctx context.Context) bool {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, studioHealthURL, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode == http.StatusOK
|
||||
}
|
||||
|
||||
// studioPython picks the checkout's venv interpreter, falling back to python3.
|
||||
func studioPython(dir string) string {
|
||||
venv := filepath.Join(dir, ".venv", "bin", "python")
|
||||
if _, err := os.Stat(venv); err == nil {
|
||||
return venv
|
||||
}
|
||||
return "python3"
|
||||
}
|
||||
|
||||
// launchStudio starts the render backend from dir, logging to
|
||||
// <dir>/studio-local.log. The process gets its own group so a restart can
|
||||
// kill stragglers (torch dataloader workers and the like) in one signal.
|
||||
func launchStudio(dir string) (*exec.Cmd, error) {
|
||||
logf, err := os.OpenFile(filepath.Join(dir, "studio-local.log"),
|
||||
os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cmd := exec.Command(studioPython(dir), "main.py",
|
||||
"--listen", "0.0.0.0", "--port", "8188",
|
||||
"--normalvram", "--disable-auto-launch",
|
||||
"--output-directory", filepath.Join(dir, "output"))
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(),
|
||||
"STUDIO_PERSIST_QUEUE=1",
|
||||
"PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True",
|
||||
)
|
||||
cmd.Stdout = logf
|
||||
cmd.Stderr = logf
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
if err := cmd.Start(); err != nil {
|
||||
logf.Close()
|
||||
return nil, err
|
||||
}
|
||||
logf.Close() // the child holds its own descriptor
|
||||
// Shield the render backend from the OOM killer ahead of pack/model RAM
|
||||
// spikes. Negative adj needs privilege; best-effort.
|
||||
_ = os.WriteFile("/proc/"+strconv.Itoa(cmd.Process.Pid)+"/oom_score_adj",
|
||||
[]byte("-700"), 0o644)
|
||||
go func() { _ = cmd.Wait() }() // reap; the probe loop decides on restarts
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// stopStudio kills the supervised process group (if any) and any foreign
|
||||
// main.py still holding the port, then waits for the listener to clear.
|
||||
func stopStudio(cmd *exec.Cmd) {
|
||||
if cmd != nil && cmd.Process != nil {
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM)
|
||||
}
|
||||
_ = exec.Command("pkill", "-f", "main.py --listen").Run()
|
||||
for i := 0; i < 15; i++ {
|
||||
conn, err := net.DialTimeout("tcp", studioAddr, time.Second)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
var cmd *exec.Cmd
|
||||
restart := func(reason string) {
|
||||
stopStudio(cmd)
|
||||
c, err := launchStudio(dir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(out, "studio: launch failed (%s): %v\n", reason, err)
|
||||
return
|
||||
}
|
||||
cmd = c
|
||||
deadline := time.Now().Add(studioStartWindow)
|
||||
for time.Now().Before(deadline) && ctx.Err() == nil {
|
||||
if studioHealthy(ctx) {
|
||||
fmt.Fprintf(out, "studio: serving on %s (pid %d, %s)\n", studioAddr, c.Process.Pid, reason)
|
||||
return
|
||||
}
|
||||
time.Sleep(4 * time.Second)
|
||||
}
|
||||
fmt.Fprintf(out, "studio: started pid %d (%s) but %s not healthy yet\n", c.Process.Pid, reason, studioAddr)
|
||||
}
|
||||
|
||||
if !studioHealthy(ctx) {
|
||||
restart("boot")
|
||||
} else {
|
||||
fmt.Fprintf(out, "studio: already serving on %s\n", studioAddr)
|
||||
}
|
||||
|
||||
tick := time.NewTicker(studioProbeEvery)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if cmd != nil && cmd.Process != nil {
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM)
|
||||
}
|
||||
return
|
||||
case <-studioRecycle:
|
||||
restart("recycle")
|
||||
case <-tick.C:
|
||||
if studioHealthy(ctx) {
|
||||
continue
|
||||
}
|
||||
// Grace re-check: it may be momentarily busy mid-render.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
continue
|
||||
case <-time.After(studioGraceWait):
|
||||
}
|
||||
if !studioHealthy(ctx) {
|
||||
restart("unresponsive")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ import (
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// adminOrg is THE global-admin / IAM-system org that owns every customer org row —
|
||||
// adminOrg is THE SuperAdmin / IAM-system org that owns every customer org row —
|
||||
// standardized as "admin" across the whole stack (IAM, commerce, ai, gateway all
|
||||
// gate cross-tenant on owner=="admin"). A created customer org is owned by it.
|
||||
const adminOrg = "admin"
|
||||
@@ -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,
|
||||
@@ -327,7 +331,7 @@ type onboardResp struct {
|
||||
// - FIRST-RUN (no owner): create + MOVE the user in as admin, so their next JWT
|
||||
// carries the new owner and the cloud scopes everything to it.
|
||||
// - ADDITIONAL (owner set): create the org but do NOT move the user — a move
|
||||
// changes their IAM owner (stripping a global admin's status + orphaning their
|
||||
// changes their IAM owner (stripping a SuperAdmin's status + orphaning their
|
||||
// current org). They reach the new org via the OrgSwitcher, which re-scopes
|
||||
// X-Org-Id without touching IAM membership. A personal-org request from someone
|
||||
// who already has an org is meaningless → 409.
|
||||
|
||||
+126
-32
@@ -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,36 +161,6 @@ func isSubjectKey(k string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// personalBillingOrgs — orgs whose members bill per-USER (the shared catch-all).
|
||||
// Mirrors commerce's PERSONAL_BILLING_ORGS and billing-scope.ts personalBillingOrgs:
|
||||
// default "hanzo"; PERSONAL_BILLING_ORGS or HANZO_DEFAULT_ORG overrides (comma list).
|
||||
func personalBillingOrgs() map[string]bool {
|
||||
raw := getenv("PERSONAL_BILLING_ORGS", getenv("HANZO_DEFAULT_ORG", "hanzo"))
|
||||
out := map[string]bool{}
|
||||
for _, p := range strings.Split(raw, ",") {
|
||||
if p = strings.ToLower(strings.TrimSpace(p)); p != "" {
|
||||
out[p] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// billingSubject — the commerce billing subject for an org+user, the SAME subject the
|
||||
// gateway debits: a member of a personal-billing org bills per-user as "<org>/<name>";
|
||||
// a dedicated org bills per-org as "<org>". Mirrors billing-scope.ts billingSubject.
|
||||
func billingSubject(org, name string) string {
|
||||
o := strings.ToLower(strings.TrimSpace(org))
|
||||
if o == "" {
|
||||
return ""
|
||||
}
|
||||
if personalBillingOrgs()[o] {
|
||||
if n := strings.ToLower(strings.TrimSpace(name)); n != "" {
|
||||
return o + "/" + n
|
||||
}
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -184,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,17 +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) {
|
||||
t.Setenv("PERSONAL_BILLING_ORGS", "hanzo")
|
||||
cases := []struct{ org, name, want string }{
|
||||
{"acme", "alice", "acme"}, // dedicated org bills per-ORG
|
||||
{"hanzo", "Dave", "hanzo/dave"}, // personal-billing org bills per-USER (lowercased)
|
||||
{"hanzo", "", "hanzo"}, // personal org, no name → org
|
||||
{"", "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: 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") // 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/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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,7 +160,6 @@ func TestBilling_ScopesQueryToCallerAndForwards(t *testing.T) {
|
||||
f := &fakeBilling{}
|
||||
t.Setenv("COMMERCE_URL", f.server(t).URL)
|
||||
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-tok")
|
||||
t.Setenv("PERSONAL_BILLING_ORGS", "hanzo") // acme is a DEDICATED org → subject "acme"
|
||||
app := mountApp(t, "http://iam.invalid", "", "")
|
||||
|
||||
// alice/acme with a FORGED ?userId=victim & ?org=othercorp: the handler must pin
|
||||
@@ -165,7 +192,6 @@ func TestBilling_ScopesWriteBodyToCaller(t *testing.T) {
|
||||
f := &fakeBilling{}
|
||||
t.Setenv("COMMERCE_URL", f.server(t).URL)
|
||||
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-tok")
|
||||
t.Setenv("PERSONAL_BILLING_ORGS", "hanzo")
|
||||
app := mountApp(t, "http://iam.invalid", "", "")
|
||||
|
||||
// a POST with a forged userId in the body must be overwritten to acme.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@
|
||||
// TWO real jobs (why it is a handler, not a vanishing proxy):
|
||||
//
|
||||
// - ENTITLEMENT (server-authoritative). cms/erp/help are each a SINGLE shared
|
||||
// per-BRAND instance, so only a member of the owning brand org — or a global
|
||||
// admin — may frame them; a customer org gets the honest provision panel, never
|
||||
// per-BRAND instance, so only a member of the owning brand org — or a
|
||||
// SuperAdmin — may frame them; a customer org gets the honest provision panel, never
|
||||
// a cross-tenant frame. The caller's org is the VALIDATED X-Org-Id (never a
|
||||
// browser claim); the owning org is the deployment brand.
|
||||
//
|
||||
@@ -103,7 +103,7 @@ func embedStatus(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
embedURL := origin + landing
|
||||
|
||||
// SERVER-SIDE entitlement gate: a brand-owned app frames only for a member of the
|
||||
// owning brand org (cr.owner == deps.Brand) or a global admin. A non-entitled
|
||||
// owning brand org (cr.owner == deps.Brand) or a SuperAdmin. A non-entitled
|
||||
// caller NEVER receives the embed URL and we don't even probe — the module shows
|
||||
// the provision panel. This is the authoritative gate (the client check only
|
||||
// avoids a flash).
|
||||
|
||||
@@ -70,10 +70,10 @@ func TestEmbedStatus_BrandMemberEntitled_Reachable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbedStatus_GlobalAdminEntitled(t *testing.T) {
|
||||
func TestEmbedStatus_SuperAdminEntitled(t *testing.T) {
|
||||
stubProbe(t, false)
|
||||
app := mountBrand(t, "hanzo")
|
||||
// A global admin from a DIFFERENT org is still entitled (isGlobalAdmin bypass);
|
||||
// A SuperAdmin from a DIFFERENT org is still entitled (isSuperAdmin bypass);
|
||||
// the probe says down → not-provisioned but entitled with the embed URL.
|
||||
code, body := callH(t, app, http.MethodGet, "/v1/embed-status?app=erp",
|
||||
map[string]string{"X-User-Id": "root", "X-Org-Id": "acme", "X-User-IsAdmin": "true"}, "")
|
||||
|
||||
+210
-404
@@ -1,40 +1,22 @@
|
||||
// Package admin mounts the god-mode admin surface (/v1/admin/*) the Hanzo
|
||||
// Admin Console (admin.hanzo.ai, apps/operator) calls, per the api.ts contract.
|
||||
// Package admin mounts the god-mode admin surface (/v1/admin/*) the Hanzo Admin Console
|
||||
// (admin.hanzo.ai, apps/operator) calls, per the api.ts contract.
|
||||
//
|
||||
// It is an AGGREGATOR, not a new store: identity (orgs/users/roles/applications/
|
||||
// audit/me) is read from IAM, the money panels (spend/tokens/credits) from
|
||||
// commerce, and System Health from o11y — every one a real upstream, none fused
|
||||
// into this binary (see subsystems.go). The facade fans out over HTTP exactly
|
||||
// like o11ysvc / productsvc: it holds no business logic, it shapes the reads into
|
||||
// the /v1 envelope { status, msg, data, data2 } the operator's transport
|
||||
// decodes (get<T> reads data; getList<T> reads data + data2 total).
|
||||
// It is an AGGREGATOR, not a new store: identity (orgs/users/roles/applications/audit/me)
|
||||
// is read from IAM, the money panels (spend/tokens/credits) from commerce, and System
|
||||
// Health from o11y — every one a real upstream. The facade fans out over HTTP, shaping
|
||||
// the reads into the /v1 envelope { status, msg, data, data2 } the operator's transport
|
||||
// decodes.
|
||||
//
|
||||
// SECURITY — TWO tiers off ONE identity predicate, both fail-closed. The cockpit is a
|
||||
// single pane for a SuperAdmin (owner == AdminOrg — c.IsAdmin(), the SANITIZED
|
||||
// X-User-IsAdmin, true ONLY for a JWT-validated principal whose org IS the admin org,
|
||||
// matching the gateway's admin-guard) AND for an org admin (any other validated admin
|
||||
// caller). The predicate is enforced in ONE place — resolveScope/scopedOrgs (scope.go):
|
||||
// The subsystem is decomposed into a shared kernel (clients/admin/core) plus one package
|
||||
// per handler domain (audit/customer/revenue/finance). This file is the Mount: it builds
|
||||
// the ONE core.State from Deps, then registers each domain's routes alongside the
|
||||
// top-level reads (me/overview/orgs/users/usage/roles/applications/products/compute/o11y/
|
||||
// analytics/bases + the flags/waitlist control plane).
|
||||
//
|
||||
// - PLATFORM routes (roles/applications/audit/products/finance/compute/o11y/revenue +
|
||||
// the launch/release/flags/access control plane) are SuperAdmin ONLY (guard).
|
||||
// No principal → 403; an org admin → 403; a forged X-User-IsAdmin never survives
|
||||
// ingress (SanitizeIdentity strips it).
|
||||
// - ORG-SCOPED routes (me/overview/orgs/users/usage/analytics/bases) are guardScoped:
|
||||
// a SuperAdmin sees EVERY tenant; any other validated admin caller is HARD-limited to
|
||||
// their OWN org subtree. The cross-tenant boundary — the escalation line — cannot be
|
||||
// crossed by a non-super caller for ANY input, because their org is the sanitized,
|
||||
// un-forgeable c.Org() and every read folds over scopedOrgs.
|
||||
//
|
||||
// admin adds no service credential to the IAM fan-out — it replays the caller's own
|
||||
// cookie/bearer, so it can never read more than the caller already could, and IAM
|
||||
// re-checks authority on every call (a non-super caller replaying to a cross-tenant IAM
|
||||
// read is refused by IAM too — defense in depth).
|
||||
//
|
||||
// Panels with no in-binary feed yet (the Usage & Costs timeseries + per-product
|
||||
// breakdown live in insights/datastore; the product/workload registry + infra
|
||||
// tiles live in platform.hanzo.ai / the operator inventory) return the real,
|
||||
// honest empty state — never a fabricated number. The operator UI renders those
|
||||
// as an em-dash / empty table by design.
|
||||
// SECURITY — TWO tiers off ONE identity predicate, both fail-closed. PLATFORM routes are
|
||||
// SuperAdmin ONLY (core.Guard). ORG-SCOPED routes (me/overview/orgs/users/usage/analytics/
|
||||
// bases) are core.GuardScoped: a SuperAdmin sees EVERY tenant; any other validated admin
|
||||
// caller is HARD-limited to their OWN org subtree by core.ResolveScope/ScopedOrgs.
|
||||
package admin
|
||||
|
||||
import (
|
||||
@@ -48,33 +30,26 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/audit"
|
||||
"github.com/hanzoai/cloud/clients/admin/audit"
|
||||
"github.com/hanzoai/cloud/clients/admin/commerce"
|
||||
"github.com/hanzoai/cloud/clients/admin/core"
|
||||
"github.com/hanzoai/cloud/clients/admin/customer"
|
||||
"github.com/hanzoai/cloud/clients/admin/digitalocean"
|
||||
"github.com/hanzoai/cloud/clients/admin/finance"
|
||||
"github.com/hanzoai/cloud/clients/admin/health"
|
||||
"github.com/hanzoai/cloud/clients/admin/iam"
|
||||
"github.com/hanzoai/cloud/clients/admin/revenue"
|
||||
"github.com/hanzoai/cloud/clients/commerceinproc"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// state is admin's own data: the resolved upstream clients + the admin org for
|
||||
// this deployment. admin holds NO Base shared deps (it fans out over HTTP replaying
|
||||
// the caller's own creds); the embedded cloud.Base carries only the mount-time
|
||||
// logger (s.Log), used for the mount line.
|
||||
type state struct {
|
||||
iam *iamClient
|
||||
commerce *commerceClient
|
||||
health *healthClient
|
||||
do *doClient
|
||||
adminOrg string
|
||||
// auditStore is cloud's OWN tamper-evident audit store (nil when unconfigured,
|
||||
// in which case /v1/admin/audit falls back to the IAM get-records proxy). Serve
|
||||
// builds it and hands it over via deps.Audit. See audit.go.
|
||||
auditStore *audit.Recorder
|
||||
}
|
||||
|
||||
// Mount registers the /v1/admin/* surface on app. Every handler gates on
|
||||
// c.IsAdmin() first (global-admin only), then aggregates real upstream data.
|
||||
// Mount registers the /v1/admin/* surface on app. Every handler gates on c.IsAdmin()
|
||||
// first (via core.Guard/GuardScoped), then aggregates real upstream data.
|
||||
//
|
||||
// Complex flavour: the state is built from Deps fields NOT on cloud.Base
|
||||
// (deps.Audit, deps.IAMIssuer), so it constructs the cloud.Service value directly
|
||||
// (cloud.NewBase + &cloud.Service[state]{…}) rather than via cloud.Mount.
|
||||
// The state is built from Deps fields NOT on cloud.Base (deps.Audit, deps.IAMIssuer), so
|
||||
// it constructs the cloud.Service value directly (cloud.NewBase + &cloud.Service[core.State]{…})
|
||||
// rather than via cloud.Mount.
|
||||
func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("admin.Mount: nil zip.App")
|
||||
@@ -83,15 +58,15 @@ func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
return fmt.Errorf("admin.Mount: nil deps.Logger")
|
||||
}
|
||||
b := cloud.NewBase(deps, "admin")
|
||||
s := &cloud.Service[state]{
|
||||
s := &cloud.Service[core.State]{
|
||||
Base: b,
|
||||
State: state{
|
||||
iam: newIAMClient(iamBase(deps)),
|
||||
commerce: newCommerceClient(commerceinproc.BaseURL(os.Getenv("CLOUD_COMMERCE_HTTP_URL")), os.Getenv("COMMERCE_SERVICE_TOKEN")),
|
||||
health: newHealthClient(o11yHealthURL()),
|
||||
do: newDOClient(doTokenFromEnv()),
|
||||
adminOrg: adminOrgOf(deps),
|
||||
auditStore: deps.Audit,
|
||||
State: core.State{
|
||||
IAM: iam.New(iamBase(deps)),
|
||||
Commerce: commerce.New(commerceinproc.BaseURL(os.Getenv("CLOUD_COMMERCE_HTTP_URL")), os.Getenv("COMMERCE_SERVICE_TOKEN")),
|
||||
Health: health.New(o11yHealthURL()),
|
||||
DO: digitalocean.New(doTokenFromEnv()),
|
||||
AdminOrg: adminOrgOf(deps),
|
||||
AuditStore: deps.Audit,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -99,189 +74,97 @@ func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
|
||||
b.Log.Info("admin surface mounted",
|
||||
"prefix", "/v1/admin",
|
||||
"iam", s.State.iam.configured(),
|
||||
"commerce", s.State.commerce.configured(),
|
||||
"digitalocean", s.State.do.configured(),
|
||||
"adminOrg", s.State.adminOrg,
|
||||
"iam", s.State.IAM.Ready(),
|
||||
"commerce", s.State.Commerce.Ready(),
|
||||
"digitalocean", s.State.DO.Ready(),
|
||||
"adminOrg", s.State.AdminOrg,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// routes registers the /v1/admin/* surface on app, threading the ONE service value
|
||||
// through the two-tier gate: org-scoped panels behind guardScoped (a SuperAdmin OR
|
||||
// a validated admin caller pinned to an org — the HANDLER then scopes the data via
|
||||
// scopedOrgs / resolveScope, so a non-super caller is HARD-limited to their own org
|
||||
// subtree), the platform control plane behind guard (SuperAdmin only).
|
||||
func routes(app *zip.App, s *cloud.Service[state]) {
|
||||
// Org-scoped panels — guardScoped. Same panels, both tiers, scoped by the ONE
|
||||
// predicate; cross-tenant reads are impossible for a non-super caller.
|
||||
app.Get("/v1/admin/me", guardScoped(s, me))
|
||||
app.Get("/v1/admin/overview", guardScoped(s, overview))
|
||||
app.Get("/v1/admin/orgs", guardScoped(s, orgs))
|
||||
app.Get("/v1/admin/users", guardScoped(s, users))
|
||||
app.Get("/v1/admin/usage", guardScoped(s, usage))
|
||||
// Platform reads — SuperAdmin only (cross-tenant by nature): roles/apps catalog,
|
||||
// the fleet audit trail, workload registry, SaaS profitability, compute fleet,
|
||||
// system health.
|
||||
app.Get("/v1/admin/roles", guard(s, roles))
|
||||
app.Get("/v1/admin/applications", guard(s, applications))
|
||||
app.Get("/v1/admin/audit", guard(s, auditRecords))
|
||||
app.Get("/v1/admin/audit/verify", guard(s, auditVerify))
|
||||
app.Get("/v1/admin/products", guard(s, products))
|
||||
app.Get("/v1/admin/finance", guard(s, finance))
|
||||
app.Get("/v1/admin/compute", guard(s, compute))
|
||||
app.Get("/v1/admin/o11y", guard(s, o11y))
|
||||
app.Post("/v1/admin/sync", guard(s, syncNow))
|
||||
|
||||
// Customer management — the operator cockpit. List (static) precedes the :org
|
||||
// param route; the write actions are POST (distinct method), so none collide.
|
||||
app.Get("/v1/admin/customers", guard(s, customers))
|
||||
app.Get("/v1/admin/customers/:org", guard(s, customerDetail))
|
||||
app.Post("/v1/admin/customers/:org/credit", guard(s, grantCredit))
|
||||
app.Get("/v1/admin/grants", guard(s, grants))
|
||||
app.Post("/v1/admin/grants", guard(s, issueGrant))
|
||||
app.Post("/v1/admin/customers/:org/suspend", guard(s, suspendCustomer))
|
||||
app.Post("/v1/admin/customers/:org/reactivate", guard(s, reactivateCustomer))
|
||||
|
||||
// Fleet revenue aggregate — SuperAdmin only (cross-tenant profitability).
|
||||
app.Get("/v1/admin/revenue", guard(s, revenue))
|
||||
// Product analytics — org-scoped (SuperAdmin: all-orgs SaaS analytics; org admin:
|
||||
// their own org's usage/active/spend).
|
||||
app.Get("/v1/admin/analytics", guardScoped(s, analytics))
|
||||
// through the two-tier gate: org-scoped panels behind core.GuardScoped, the platform
|
||||
// control plane behind core.Guard. Each carved-out domain (audit/customer/revenue/finance)
|
||||
// owns its own route registration.
|
||||
func routes(app *zip.App, s *cloud.Service[core.State]) {
|
||||
// Org-scoped panels — GuardScoped. Cross-tenant reads are impossible for a non-super
|
||||
// caller.
|
||||
app.Get("/v1/admin/me", core.GuardScoped(s, me))
|
||||
app.Get("/v1/admin/overview", core.GuardScoped(s, overview))
|
||||
app.Get("/v1/admin/orgs", core.GuardScoped(s, orgs))
|
||||
app.Get("/v1/admin/users", core.GuardScoped(s, users))
|
||||
app.Get("/v1/admin/usage", core.GuardScoped(s, usage))
|
||||
// Platform reads — SuperAdmin only (cross-tenant by nature).
|
||||
app.Get("/v1/admin/roles", core.Guard(s, roles))
|
||||
app.Get("/v1/admin/applications", core.Guard(s, applications))
|
||||
app.Get("/v1/admin/products", core.Guard(s, products))
|
||||
app.Get("/v1/admin/compute", core.Guard(s, compute))
|
||||
app.Get("/v1/admin/o11y", core.Guard(s, o11y))
|
||||
app.Post("/v1/admin/sync", core.Guard(s, syncNow))
|
||||
|
||||
// Product analytics — org-scoped (SuperAdmin: all-orgs; org admin: their own org).
|
||||
app.Get("/v1/admin/analytics", core.GuardScoped(s, analytics))
|
||||
// Bases — the tenant Base-instance panel, org-scoped (bases.go).
|
||||
app.Get("/v1/admin/bases", guardScoped(s, bases))
|
||||
app.Get("/v1/admin/bases", core.GuardScoped(s, bases))
|
||||
|
||||
// ── Platform control plane — SuperAdmin ONLY (launch/release/flags + access). ──
|
||||
// Flipping public_signup / waitlist_open / rollout %, and granting waitlist
|
||||
// access, are PLATFORM sudo; an org admin never sees or touches them (guard,
|
||||
// super-only, like every mutating fleet action). flags.go + waitlist.go.
|
||||
app.Get("/v1/admin/flags", guard(s, flags))
|
||||
app.Get("/v1/admin/waitlist", guard(s, waitlist))
|
||||
app.Post("/v1/admin/waitlist/boost", guard(s, waitlistBoost))
|
||||
}
|
||||
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))
|
||||
|
||||
// guard wraps a handler with the global-admin gate. Fail-closed: any request
|
||||
// whose validated identity is not a global admin (X-User-IsAdmin != "true",
|
||||
// which SanitizeIdentity sets only for owner == AdminOrg) is refused 403 before
|
||||
// the handler — no upstream is touched, no data leaks.
|
||||
func guard(s *cloud.Service[state], h func(*cloud.Service[state], *zip.Ctx) error) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
if !c.IsAdmin() {
|
||||
return zip.ErrForbidden("global admin required")
|
||||
}
|
||||
return h(s, c)
|
||||
}
|
||||
}
|
||||
|
||||
// guardScoped is the gate for the ORG-SCOPED panels (me/overview/orgs/users/usage/
|
||||
// analytics/bases). It admits a SuperAdmin (c.IsAdmin()) OR any VALIDATED admin caller
|
||||
// pinned to an org, and the handler then scopes every read to resolveScope(c) — so a
|
||||
// non-super caller passes the gate but the DATA layer hard-limits them to their own org
|
||||
// subtree. Cross-tenant reads are impossible for a non-super caller regardless of input.
|
||||
//
|
||||
// The non-super admission requires c.User() (X-User-Id) non-empty, which SanitizeIdentity
|
||||
// sets ONLY for a validated principal — so an anonymous caller who forged X-Org-Id (the
|
||||
// documented Phase-1 residual restores a client X-Org-Id for the data path) is REFUSED
|
||||
// here: no validated principal, no X-User-Id, no admission. A validated non-super
|
||||
// principal's X-Org-Id is PINNED by the boundary to their own owner, never client-chosen.
|
||||
//
|
||||
// The org-ADMIN-vs-member distinction (should a non-admin org member reach the cockpit?)
|
||||
// is enforced at the console BFF getAdminGate, which reads IAM's isAdmin claim; cloud
|
||||
// cannot see that claim without a trusted org-admin header from SanitizeIdentity (a
|
||||
// follow-up trust-boundary change). The cross-tenant boundary — the escalation line — is
|
||||
// fully enforced HERE regardless, because a non-super caller can only ever read their own
|
||||
// org's data.
|
||||
func guardScoped(s *cloud.Service[state], h func(*cloud.Service[state], *zip.Ctx) error) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
if c.IsAdmin() {
|
||||
return h(s, c)
|
||||
}
|
||||
if strings.TrimSpace(c.User()) != "" && strings.TrimSpace(c.Org()) != "" {
|
||||
return h(s, c)
|
||||
}
|
||||
return zip.ErrForbidden("admin required")
|
||||
}
|
||||
}
|
||||
|
||||
// callerCreds captures the caller's replayed authorization context for the IAM
|
||||
// fan-out: the raw Cookie header (session model) and the Authorization bearer.
|
||||
func callerCreds(c *zip.Ctx) creds {
|
||||
return creds{
|
||||
cookie: string(c.Fiber().Request().Header.Peek("Cookie")),
|
||||
auth: c.Header("Authorization"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── /v1 envelope writers ────────────────────────────────────────────────
|
||||
|
||||
// ok writes a { status:"ok", data } envelope (the get<T> shape).
|
||||
func ok(c *zip.Ctx, data any) error {
|
||||
return c.JSON(200, map[string]any{"status": "ok", "msg": "", "data": data})
|
||||
}
|
||||
|
||||
// okList writes a { status:"ok", data:[...], data2:total } envelope (getList<T>).
|
||||
func okList(c *zip.Ctx, rows any, total int) error {
|
||||
return c.JSON(200, map[string]any{"status": "ok", "msg": "", "data": rows, "data2": total})
|
||||
}
|
||||
|
||||
// okRaw writes a { status:"ok", data:<raw>, data2:total } envelope, forwarding an
|
||||
// IAM payload verbatim so its exact wire shape (Role, Application, Record, User)
|
||||
// reaches the operator field-for-field.
|
||||
func okRaw(c *zip.Ctx, rows json.RawMessage, total int) error {
|
||||
if len(rows) == 0 {
|
||||
rows = json.RawMessage("[]")
|
||||
}
|
||||
return c.JSON(200, map[string]any{"status": "ok", "msg": "", "data": rows, "data2": total})
|
||||
}
|
||||
|
||||
// fail writes a { status:"error", msg } envelope. The operator's transport maps
|
||||
// a non-ok envelope to a surfaced error (never a fabricated value).
|
||||
func fail(c *zip.Ctx, msg string) error {
|
||||
return c.JSON(200, map[string]any{"status": "error", "msg": msg, "data": nil})
|
||||
// ── Carved-out domains own their routes (audit/customer/revenue/finance). ──
|
||||
audit.Routes(app, s)
|
||||
customer.Routes(app, s)
|
||||
revenue.Routes(app, s)
|
||||
finance.Routes(app, s)
|
||||
}
|
||||
|
||||
// ── /v1/admin/me — operator identity (AdminMe) ───────────────────────────────
|
||||
|
||||
// me answers with the validated operator identity. The gate already proved this
|
||||
// is a global admin, so the fields come from the sanitized identity headers —
|
||||
// authoritative and never client-forgeable.
|
||||
func me(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
sc := resolveScope(s, c)
|
||||
owner := strings.TrimSpace(c.Org())
|
||||
if owner == "" && sc.super {
|
||||
owner = s.State.adminOrg
|
||||
// me answers with the validated operator identity. The gate already proved this is an
|
||||
// admin, so the fields come from the sanitized identity headers — authoritative and never
|
||||
// client-forgeable.
|
||||
func me(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
sc := core.ResolveScope(s, c)
|
||||
owner, _ := principal.Org(c)
|
||||
if owner == "" && sc.Super {
|
||||
owner = s.State.AdminOrg
|
||||
}
|
||||
name := strings.TrimSpace(c.User())
|
||||
// IsGlobalAdmin reflects the REAL scope: true only for a SuperAdmin (owner ==
|
||||
// admin org). An org admin gets false + their own org, so the cockpit renders the
|
||||
// scoped (own-subtree) view and hides the platform-sudo panels.
|
||||
return ok(c, adminMe{
|
||||
Owner: owner,
|
||||
Name: name,
|
||||
Email: strings.TrimSpace(c.UserEmail()),
|
||||
DisplayName: name,
|
||||
IsSuperAdmin: sc.super,
|
||||
IsGlobalAdmin: sc.super, // DEPRECATED alias of isSuperAdmin; kept populated for back-compat
|
||||
return core.OK(c, adminMe{
|
||||
Owner: owner,
|
||||
Name: name,
|
||||
Email: strings.TrimSpace(c.UserEmail()),
|
||||
DisplayName: name,
|
||||
IsSuperAdmin: sc.Super,
|
||||
})
|
||||
}
|
||||
|
||||
// ── /v1/admin/orgs — tenant directory (OrgRow[]) ─────────────────────────────
|
||||
|
||||
func orgs(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
func orgs(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
cr := callerCreds(c)
|
||||
orgs, err := scopedOrgs(s, ctx, c, cr)
|
||||
cr := core.CallerCreds(c)
|
||||
orgs, err := core.ScopedOrgs(s, ctx, c, cr)
|
||||
if err != nil {
|
||||
return fail(c, err.Error())
|
||||
return core.Fail(c, err.Error())
|
||||
}
|
||||
rows := make([]orgRow, 0, len(orgs))
|
||||
for _, o := range orgs {
|
||||
users := orgUserCount(s, ctx, cr, o.Name)
|
||||
spend, credits := orgMoney(s, ctx, o.Name)
|
||||
// orgs is a per-ROW panel (OrgRow[] via OKList; it carries NO sources[] channel):
|
||||
// a failed read degrades THAT org's row to an honest zero, never a fleet total that
|
||||
// falsely reads healthy. The aggregate-freshness signal lives on /overview.
|
||||
spend, credits, _ := core.OrgMoney(s, ctx, o.Name)
|
||||
rows = append(rows, orgRow{
|
||||
Org: o.Name,
|
||||
Display: display(o.DisplayName, o.Name),
|
||||
Display: core.Display(o.DisplayName, o.Name),
|
||||
Users: users,
|
||||
Products: 0, // workload registry feed pending (platform apps table)
|
||||
SpendCents: spend,
|
||||
@@ -291,21 +174,21 @@ func orgs(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
})
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].Org < rows[j].Org })
|
||||
return okList(c, rows, len(rows))
|
||||
return core.OKList(c, rows, len(rows))
|
||||
}
|
||||
|
||||
// ── /v1/admin/users — cross-org directory (OperatorUser[]) ───────────────────
|
||||
|
||||
func users(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
func users(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
cr := callerCreds(c)
|
||||
sc := resolveScope(s, c)
|
||||
cr := core.CallerCreds(c)
|
||||
sc := core.ResolveScope(s, c)
|
||||
q := url.Values{}
|
||||
if !sc.super {
|
||||
// A scoped caller lists ONLY their own org's users — the client ?org= is
|
||||
// ignored, the owner hard-pinned to the sanitized org subtree.
|
||||
if len(sc.orgs) > 0 {
|
||||
q.Set("owner", sc.orgs[0])
|
||||
if !sc.Super {
|
||||
// A scoped caller lists ONLY their own org's users — the client ?org= is ignored,
|
||||
// the owner hard-pinned to the sanitized org subtree.
|
||||
if len(sc.Orgs) > 0 {
|
||||
q.Set("owner", sc.Orgs[0])
|
||||
}
|
||||
} else if owner := strings.TrimSpace(c.Query("org")); owner != "" {
|
||||
q.Set("owner", owner)
|
||||
@@ -321,57 +204,56 @@ func users(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
q.Set("field", "name")
|
||||
q.Set("value", term)
|
||||
}
|
||||
res, err := s.State.iam.getList(ctx, cr, "/v1/iam/get-users", q)
|
||||
res, err := s.State.IAM.Users(ctx, cr, q)
|
||||
if err != nil {
|
||||
return fail(c, err.Error())
|
||||
return core.Fail(c, err.Error())
|
||||
}
|
||||
var raw []iamUser
|
||||
if len(res.rows) > 0 {
|
||||
if err := json.Unmarshal(res.rows, &raw); err != nil {
|
||||
return fail(c, "users decode: "+err.Error())
|
||||
var raw []iam.User
|
||||
if len(res.Rows) > 0 {
|
||||
if err := json.Unmarshal(res.Rows, &raw); err != nil {
|
||||
return core.Fail(c, "users decode: "+err.Error())
|
||||
}
|
||||
}
|
||||
rows := make([]operatorUser, 0, len(raw))
|
||||
for _, u := range raw {
|
||||
rows = append(rows, operatorUser{
|
||||
Owner: u.Owner,
|
||||
Name: u.Name,
|
||||
Email: u.Email,
|
||||
DisplayName: u.DisplayName,
|
||||
IsAdmin: u.IsAdmin,
|
||||
IsSuperAdmin: u.Owner == s.State.adminOrg,
|
||||
IsGlobalAdmin: u.Owner == s.State.adminOrg, // back-compat alias; same fact
|
||||
Tag: u.Tag,
|
||||
Created: u.CreatedTime,
|
||||
LastSignin: u.LastSigninTime,
|
||||
Forbidden: u.IsForbidden,
|
||||
Owner: u.Owner,
|
||||
Name: u.Name,
|
||||
Email: u.Email,
|
||||
DisplayName: u.DisplayName,
|
||||
IsAdmin: u.IsAdmin,
|
||||
IsSuperAdmin: u.Owner == s.State.AdminOrg,
|
||||
Tag: u.Tag,
|
||||
Created: u.CreatedTime,
|
||||
LastSignin: u.LastSigninTime,
|
||||
Forbidden: u.IsForbidden,
|
||||
})
|
||||
}
|
||||
total := res.total
|
||||
total := res.Total
|
||||
if total < len(rows) {
|
||||
total = len(rows)
|
||||
}
|
||||
return okList(c, rows, total)
|
||||
return core.OKList(c, rows, total)
|
||||
}
|
||||
|
||||
// ── /v1/admin/roles and /applications — verbatim IAM passthrough ─────────────
|
||||
|
||||
func roles(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
func roles(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
return iamPassthrough(s, c, "/v1/iam/get-roles")
|
||||
}
|
||||
|
||||
func applications(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
func applications(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
return iamPassthrough(s, c, "/v1/iam/get-applications")
|
||||
}
|
||||
|
||||
// iamPassthrough forwards a paginated IAM read verbatim (the operator decodes
|
||||
// Role / Application as the raw IAM wire shape). `owner` defaults to the admin
|
||||
// org, which owns the platform applications.
|
||||
func iamPassthrough(s *cloud.Service[state], c *zip.Ctx, path string) error {
|
||||
// iamPassthrough forwards a paginated IAM read verbatim (the operator decodes Role /
|
||||
// Application as the raw IAM wire shape). `owner` defaults to the admin org, which owns
|
||||
// the platform applications.
|
||||
func iamPassthrough(s *cloud.Service[core.State], c *zip.Ctx, path string) error {
|
||||
q := url.Values{}
|
||||
owner := strings.TrimSpace(c.Query("owner"))
|
||||
if owner == "" {
|
||||
owner = s.State.adminOrg
|
||||
owner = s.State.AdminOrg
|
||||
}
|
||||
q.Set("owner", owner)
|
||||
if p := strings.TrimSpace(c.Query("p")); p != "" {
|
||||
@@ -380,75 +262,52 @@ func iamPassthrough(s *cloud.Service[state], c *zip.Ctx, path string) error {
|
||||
if ps := strings.TrimSpace(c.Query("pageSize")); ps != "" {
|
||||
q.Set("pageSize", ps)
|
||||
}
|
||||
res, err := s.State.iam.getList(c.Context(), callerCreds(c), path, q)
|
||||
res, err := s.State.IAM.List(c.Context(), core.CallerCreds(c), path, q)
|
||||
if err != nil {
|
||||
return fail(c, err.Error())
|
||||
return core.Fail(c, err.Error())
|
||||
}
|
||||
return okRaw(c, res.rows, res.total)
|
||||
}
|
||||
|
||||
// ── /v1/admin/audit — records directory (AuditRow[]) ─────────────────────────
|
||||
//
|
||||
// The handler lives in audit.go (it reads cloud's OWN tamper-evident store).
|
||||
// iamAuditQuery builds the IAM get-records query for the federated fallback
|
||||
// auditFromIAM uses when no local store is configured.
|
||||
|
||||
func iamAuditQuery(c *zip.Ctx) url.Values {
|
||||
q := url.Values{}
|
||||
if org := strings.TrimSpace(c.Query("org")); org != "" {
|
||||
q.Set("organizationName", org)
|
||||
}
|
||||
q.Set("p", "1")
|
||||
ps := strings.TrimSpace(c.Query("pageSize"))
|
||||
if ps == "" {
|
||||
ps = "100"
|
||||
}
|
||||
q.Set("pageSize", ps)
|
||||
q.Set("sortField", "createdTime")
|
||||
q.Set("sortOrder", "descend")
|
||||
return q
|
||||
return core.OKRaw(c, res.Rows, res.Total)
|
||||
}
|
||||
|
||||
// ── /v1/admin/usage — fleet usage roll-up (UsageData) ────────────────────────
|
||||
|
||||
// usage returns the real fleet money totals from commerce. The daily series and
|
||||
// the per-product breakdown are NOT derivable from the commerce billing API
|
||||
// (they live in insights/datastore, owned separately); admin returns the
|
||||
// honest empty series/byProduct rather than fabricating a trend — the operator
|
||||
// renders that as an empty chart, never a fake line.
|
||||
func usage(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
// usage returns the real fleet money totals from commerce. The daily series and the
|
||||
// per-product breakdown are NOT derivable from the commerce billing API (they live in
|
||||
// insights/datastore); admin returns the honest empty series/byProduct rather than
|
||||
// fabricating a trend.
|
||||
func usage(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
cr := callerCreds(c)
|
||||
sc := resolveScope(s, c)
|
||||
cr := core.CallerCreds(c)
|
||||
sc := core.ResolveScope(s, c)
|
||||
org := strings.TrimSpace(c.Query("org"))
|
||||
if !sc.super {
|
||||
// A scoped caller reads ONLY their own org's usage — the client ?org= is
|
||||
// ignored, the org hard-pinned to the sanitized subtree.
|
||||
if !sc.Super {
|
||||
// A scoped caller reads ONLY their own org's usage — the client ?org= is ignored,
|
||||
// the org hard-pinned to the sanitized subtree.
|
||||
org = ""
|
||||
if len(sc.orgs) > 0 {
|
||||
org = sc.orgs[0]
|
||||
if len(sc.Orgs) > 0 {
|
||||
org = sc.Orgs[0]
|
||||
}
|
||||
}
|
||||
|
||||
var spend int64
|
||||
switch {
|
||||
case org != "":
|
||||
if r, err := s.State.commerce.usageRollup(ctx, org, orgSubject(org)); err == nil {
|
||||
spend = r.ConsumedCents
|
||||
if sp, err := s.State.Commerce.Spend(ctx, org); err == nil {
|
||||
spend = int64(sp.Consumed)
|
||||
}
|
||||
case sc.super:
|
||||
case sc.Super:
|
||||
// Fleet: sum month-to-date consumption across every org.
|
||||
orgs, err := listOrgs(s, ctx, cr)
|
||||
orgs, err := core.ListOrgs(s, ctx, cr)
|
||||
if err == nil {
|
||||
for _, o := range orgs {
|
||||
if r, e := s.State.commerce.usageRollup(ctx, o.Name, orgSubject(o.Name)); e == nil {
|
||||
spend += r.ConsumedCents
|
||||
if sp, e := s.State.Commerce.Spend(ctx, o.Name); e == nil {
|
||||
spend += int64(sp.Consumed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ok(c, usageData{
|
||||
return core.OK(c, usageData{
|
||||
Totals: usageTotals{SpendCents: spend, Tokens: 0, Requests: 0},
|
||||
Series: []usagePoint{},
|
||||
ByProduct: []usageByProduct{},
|
||||
@@ -457,64 +316,67 @@ func usage(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
|
||||
// ── /v1/admin/products — workload registry (ProductRow[]) ────────────────────
|
||||
|
||||
// products is the workload/drift registry (declared vs running tag, health).
|
||||
// That inventory is the platform.hanzo.ai apps table / operator reconcile state,
|
||||
// NOT an in-binary source. admin exposes the gated endpoint and returns the
|
||||
// real empty registry until that feed is wired — it never fabricates workload
|
||||
// rows. The operator renders an empty table, not fake products.
|
||||
func products(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
return okList(c, []productRow{}, 0)
|
||||
// products is the workload/drift registry. That inventory is the platform.hanzo.ai apps
|
||||
// table / operator reconcile state, NOT an in-binary source. admin exposes the gated
|
||||
// endpoint and returns the real empty registry until that feed is wired.
|
||||
func products(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
return core.OKList(c, []productRow{}, 0)
|
||||
}
|
||||
|
||||
// ── /v1/admin/overview — Platform Overview tiles (OverviewData) ───────────────
|
||||
|
||||
func overview(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
func overview(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
cr := callerCreds(c)
|
||||
cr := core.CallerCreds(c)
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
var sources []sourceStatus
|
||||
var sources []core.SourceStatus
|
||||
orgCount, userCount, spend, credits := 0, 0, int64(0), int64(0)
|
||||
|
||||
orgs, orgErr := scopedOrgs(s, ctx, c, cr)
|
||||
sources = append(sources, srcOf("iam", orgErr, len(orgs), now))
|
||||
orgs, orgErr := core.ScopedOrgs(s, ctx, c, cr)
|
||||
sources = append(sources, core.SrcOf("iam", orgErr, len(orgs), now))
|
||||
commercePartial := false
|
||||
if orgErr == nil {
|
||||
orgCount = len(orgs)
|
||||
for _, o := range orgs {
|
||||
userCount += orgUserCount(s, ctx, cr, o.Name)
|
||||
sp, cr2 := orgMoney(s, ctx, o.Name)
|
||||
sp, cr2, ok := core.OrgMoney(s, ctx, o.Name)
|
||||
spend += sp
|
||||
credits += cr2
|
||||
if !ok {
|
||||
// This org's money did not read — the fleet spend/credits totals are now
|
||||
// an UNDERCOUNT, so the commerce source must report degraded, not healthy.
|
||||
commercePartial = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Commerce freshness: probe one org's rollup so the tile reflects a real read.
|
||||
commerceRows := 0
|
||||
// Commerce freshness derives from the SAME per-org reads the totals fold — NOT a
|
||||
// single probe org (which could read healthy while commerce was down for every other
|
||||
// org, masking an undercount). Not-configured when unwired; degraded/partial (the ONE
|
||||
// core.ErrPartialRevenue sentinel revenue/finance use) when ANY per-org read failed.
|
||||
var commerceErr error
|
||||
if s.State.commerce.configured() {
|
||||
probe := s.State.adminOrg
|
||||
if len(orgs) > 0 {
|
||||
probe = orgs[0].Name
|
||||
}
|
||||
if _, err := s.State.commerce.usageRollup(ctx, probe, orgSubject(probe)); err != nil {
|
||||
commerceErr = err
|
||||
} else {
|
||||
commerceRows = 1
|
||||
}
|
||||
} else {
|
||||
commerceRows := 0
|
||||
switch {
|
||||
case !s.State.Commerce.Ready():
|
||||
commerceErr = fmt.Errorf("commerce endpoint not configured")
|
||||
case commercePartial:
|
||||
commerceErr = core.ErrPartialRevenue
|
||||
commerceRows = orgCount
|
||||
default:
|
||||
commerceRows = orgCount
|
||||
}
|
||||
sources = append(sources, srcOf("commerce", commerceErr, commerceRows, now))
|
||||
sources = append(sources, core.SrcOf("commerce", commerceErr, commerceRows, now))
|
||||
|
||||
// o11y System Health.
|
||||
o11yRows := 0
|
||||
oOK, oErr := s.State.health.ok(ctx)
|
||||
oOK, oErr := s.State.Health.Up(ctx)
|
||||
if oOK {
|
||||
o11yRows = 1
|
||||
}
|
||||
sources = append(sources, srcOf("o11y", oErr, o11yRows, now))
|
||||
sources = append(sources, core.SrcOf("o11y", oErr, o11yRows, now))
|
||||
|
||||
return ok(c, overviewData{
|
||||
return core.OK(c, overviewData{
|
||||
Orgs: orgCount,
|
||||
Users: userCount,
|
||||
Products: 0, // workload registry feed pending (platform apps table)
|
||||
@@ -530,95 +392,33 @@ func overview(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
|
||||
// ── /v1/admin/sync — refresh trigger ─────────────────────────────────────────
|
||||
|
||||
// sync answers the operator's "Sync now" button. admin aggregates LIVE on
|
||||
// every read (there is no cached fleet snapshot in-binary), so there is no batch
|
||||
// job to kick — the button simply re-reads. We acknowledge honestly with
|
||||
// { started: true } so the UI re-fetches the (freshly-computed) overview.
|
||||
func syncNow(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
return ok(c, map[string]bool{"started": true})
|
||||
// syncNow answers the operator's "Sync now" button. admin aggregates LIVE on every read,
|
||||
// so there is no batch job to kick — the button simply re-reads. We acknowledge honestly
|
||||
// with { started: true }.
|
||||
func syncNow(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
return core.OK(c, map[string]bool{"started": true})
|
||||
}
|
||||
|
||||
// ── aggregation helpers ──────────────────────────────────────────────────────
|
||||
|
||||
// listOrgs reads the org directory (owner = admin org) as the typed shape the
|
||||
// overview/orgs/usage aggregators fold over.
|
||||
func listOrgs(s *cloud.Service[state], ctx context.Context, cr creds) ([]iamOrg, error) {
|
||||
q := url.Values{}
|
||||
q.Set("owner", s.State.adminOrg)
|
||||
res, err := s.State.iam.getList(ctx, cr, "/v1/iam/get-organizations", q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var orgs []iamOrg
|
||||
if len(res.rows) > 0 {
|
||||
if err := json.Unmarshal(res.rows, &orgs); err != nil {
|
||||
return nil, fmt.Errorf("orgs decode: %w", err)
|
||||
}
|
||||
}
|
||||
return orgs, nil
|
||||
}
|
||||
|
||||
// orgUserCount returns the member count for one org from the IAM list total
|
||||
// (data2). Best-effort: an error yields 0 rather than failing the whole row.
|
||||
func orgUserCount(s *cloud.Service[state], ctx context.Context, cr creds, org string) int {
|
||||
// orgUserCount returns the member count for one org from the IAM list total (data2).
|
||||
// Best-effort: an error yields 0 rather than failing the whole row.
|
||||
func orgUserCount(s *cloud.Service[core.State], ctx context.Context, cr iam.Creds, org string) int {
|
||||
q := url.Values{}
|
||||
q.Set("owner", org)
|
||||
q.Set("p", "1")
|
||||
q.Set("pageSize", "1")
|
||||
res, err := s.State.iam.getList(ctx, cr, "/v1/iam/get-users", q)
|
||||
res, err := s.State.IAM.Users(ctx, cr, q)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return res.total
|
||||
}
|
||||
|
||||
// orgMoney returns (spendCents, creditsCents) for one org from commerce.
|
||||
// Best-effort: unreachable/unconfigured commerce yields zeros.
|
||||
func orgMoney(s *cloud.Service[state], ctx context.Context, org string) (int64, int64) {
|
||||
subj := orgSubject(org)
|
||||
var spend, credits int64
|
||||
if r, err := s.State.commerce.usageRollup(ctx, org, subj); err == nil {
|
||||
spend = r.ConsumedCents
|
||||
}
|
||||
if c, err := s.State.commerce.creditsCents(ctx, org, subj); err == nil {
|
||||
credits = c
|
||||
}
|
||||
return spend, credits
|
||||
}
|
||||
|
||||
// orgSubject is the billing subject commerce keys an org's wallet on. Commerce's
|
||||
// per-org billing store (the 2026-07 durability rework, commerce >=1.46.8)
|
||||
// namespaces by the TRUSTED X-Org-Id header (set by commerceClient.get from this
|
||||
// same org) and keys the org wallet under the BARE org slug as the `user` subject —
|
||||
// NOT "org/user". The prior "org/org" subject (with the wrong X-IAM-Org-Id header)
|
||||
// resolved to an EMPTY wallet, so every per-org money panel read $0 while real
|
||||
// balances existed (lux $10,000, maxpower $20,498). Verified live against commerce
|
||||
// /v1/billing/{balance,usage-rollup}: user=<org> + X-Org-Id=<org> returns the real
|
||||
// wallet; user="org/org" or a missing/other org header returns $0.
|
||||
func orgSubject(org string) string { return org }
|
||||
|
||||
// srcOf builds a SourceStatus freshness row for the overview.
|
||||
func srcOf(name string, err error, rows int, at string) sourceStatus {
|
||||
s := sourceStatus{Name: name, OK: err == nil, Rows: rows, At: at}
|
||||
if err != nil {
|
||||
s.Error = err.Error()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func display(displayName, fallback string) string {
|
||||
if strings.TrimSpace(displayName) != "" {
|
||||
return displayName
|
||||
}
|
||||
return fallback
|
||||
return res.Total
|
||||
}
|
||||
|
||||
// ── config resolution ────────────────────────────────────────────────────────
|
||||
|
||||
// iamBase resolves the IAM management HTTP base. CLOUD_IAM_HTTP_URL wins (the
|
||||
// in-cluster Service, e.g. http://iam.hanzo.svc.cluster.local:8000); otherwise
|
||||
// the public issuer (deps.IAMIssuer, e.g. https://hanzo.id) which also serves
|
||||
// /v1/iam/*. Empty only when neither is set (endpoint reports not-configured).
|
||||
// iamBase resolves the IAM management HTTP base. CLOUD_IAM_HTTP_URL wins (the in-cluster
|
||||
// Service); otherwise the public issuer (deps.IAMIssuer) which also serves /v1/iam/*.
|
||||
func iamBase(deps cloud.Deps) string {
|
||||
if v := strings.TrimSpace(os.Getenv("CLOUD_IAM_HTTP_URL")); v != "" {
|
||||
return v
|
||||
@@ -635,11 +435,17 @@ func o11yHealthURL() string {
|
||||
return "http://o11y.hanzo.svc.cluster.local:80/v1/o11y/health"
|
||||
}
|
||||
|
||||
// adminOrgOf resolves the admin org slug (IAM's IsGlobalAdmin owner). IAM_ADMIN_ORG
|
||||
// mirrors config.go's default; "admin" is the fleet-wide default.
|
||||
// adminOrgOf resolves the admin org slug (IAM's IsSuperAdmin owner). IAM_ADMIN_ORG mirrors
|
||||
// config.go's default; "admin" is the fleet-wide default.
|
||||
func adminOrgOf(_ cloud.Deps) string {
|
||||
if v := strings.TrimSpace(os.Getenv("IAM_ADMIN_ORG")); v != "" {
|
||||
return v
|
||||
}
|
||||
return "admin"
|
||||
}
|
||||
|
||||
// doTokenFromEnv reads the DigitalOcean token from the environment. Sourced from a
|
||||
// KMSSecret on the cloud deployment (DO_API_TOKEN) — never hard-coded.
|
||||
func doTokenFromEnv() string {
|
||||
return strings.TrimSpace(os.Getenv("DO_API_TOKEN"))
|
||||
}
|
||||
|
||||
+97
-67
@@ -10,9 +10,14 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
fiber "github.com/zap-proto/fiber/v3"
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/admin/commerce"
|
||||
"github.com/hanzoai/cloud/clients/admin/core"
|
||||
"github.com/hanzoai/cloud/clients/admin/digitalocean"
|
||||
"github.com/hanzoai/cloud/clients/admin/health"
|
||||
"github.com/hanzoai/cloud/clients/admin/iam"
|
||||
luxlog "github.com/luxfi/log"
|
||||
fiber "github.com/zap-proto/fiber/v3"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
@@ -28,42 +33,21 @@ func mount(t *testing.T, iamURL, commerceURL, healthURL string) func(method, pat
|
||||
// AND the raw fiber app (so tests that need a request BODY can drive it directly —
|
||||
// the returned `do` sends a nil body). The handlers read s.* live at request time,
|
||||
// so an override before issuing a request takes effect.
|
||||
func mountSvc(t *testing.T, iamURL, commerceURL, healthURL string) (func(method, path string, hdr map[string]string) (*http.Response, []byte), *cloud.Service[state], *fiber.App) {
|
||||
func mountSvc(t *testing.T, iamURL, commerceURL, healthURL string) (func(method, path string, hdr map[string]string) (*http.Response, []byte), *cloud.Service[core.State], *fiber.App) {
|
||||
t.Helper()
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
s := &cloud.Service[state]{State: state{
|
||||
iam: newIAMClient(iamURL),
|
||||
commerce: newCommerceClient(commerceURL, "test-token"),
|
||||
health: newHealthClient(healthURL),
|
||||
do: newDOClient(""), // no token → honest not-configured unless a test overrides s.State.do
|
||||
adminOrg: "admin",
|
||||
s := &cloud.Service[core.State]{State: core.State{
|
||||
IAM: iam.New(iamURL),
|
||||
Commerce: commerce.New(commerceURL, "test-token"),
|
||||
Health: health.New(healthURL),
|
||||
DO: digitalocean.New(""), // no token → honest not-configured unless a test overrides s.State.DO
|
||||
AdminOrg: "admin",
|
||||
}}
|
||||
// Mirror the REAL Mount (admin.go): org-scoped panels behind guardScoped, the
|
||||
// platform control plane behind guard (super-only), so the harness stays
|
||||
// authoritative for the two-tier gate.
|
||||
app.Get("/v1/admin/me", guardScoped(s, me))
|
||||
app.Get("/v1/admin/overview", guardScoped(s, overview))
|
||||
app.Get("/v1/admin/orgs", guardScoped(s, orgs))
|
||||
app.Get("/v1/admin/users", guardScoped(s, users))
|
||||
app.Get("/v1/admin/usage", guardScoped(s, usage))
|
||||
app.Get("/v1/admin/analytics", guardScoped(s, analytics))
|
||||
app.Get("/v1/admin/bases", guardScoped(s, bases))
|
||||
app.Get("/v1/admin/roles", guard(s, roles))
|
||||
app.Get("/v1/admin/applications", guard(s, applications))
|
||||
app.Get("/v1/admin/audit", guard(s, auditRecords))
|
||||
app.Get("/v1/admin/audit/verify", guard(s, auditVerify))
|
||||
app.Get("/v1/admin/products", guard(s, products))
|
||||
app.Get("/v1/admin/finance", guard(s, finance))
|
||||
app.Post("/v1/admin/sync", guard(s, syncNow))
|
||||
app.Get("/v1/admin/customers", guard(s, customers))
|
||||
app.Get("/v1/admin/customers/:org", guard(s, customerDetail))
|
||||
app.Post("/v1/admin/customers/:org/credit", guard(s, grantCredit))
|
||||
app.Post("/v1/admin/customers/:org/suspend", guard(s, suspendCustomer))
|
||||
app.Post("/v1/admin/customers/:org/reactivate", guard(s, reactivateCustomer))
|
||||
app.Get("/v1/admin/revenue", guard(s, revenue))
|
||||
app.Get("/v1/admin/flags", guard(s, flags))
|
||||
app.Get("/v1/admin/waitlist", guard(s, waitlist))
|
||||
app.Post("/v1/admin/waitlist/boost", guard(s, waitlistBoost))
|
||||
// Mirror the REAL Mount EXACTLY by registering the same routes() the subsystem uses
|
||||
// (org-scoped panels behind GuardScoped, the platform control plane behind Guard,
|
||||
// each domain owning its own routes), so the harness stays authoritative for the
|
||||
// two-tier gate + every surface.
|
||||
routes(app, s)
|
||||
fa := app.Fiber()
|
||||
|
||||
return func(method, path string, hdr map[string]string) (*http.Response, []byte) {
|
||||
@@ -122,7 +106,7 @@ var platformAdminRoutes = []adminRoute{
|
||||
var adminRoutes = append(append([]adminRoute{}, scopedAdminRoutes...), platformAdminRoutes...)
|
||||
|
||||
// TestGate_DeniesEveryRoute proves the non-negotiable: EVERY /v1/admin/* route is
|
||||
// global-admin only, fail-closed. An anonymous caller and a tenant-admin (whose
|
||||
// SuperAdmin only, fail-closed. An anonymous caller and a tenant-admin (whose
|
||||
// identity carries an org but NOT the sanitizer-minted X-User-IsAdmin) are BOTH
|
||||
// denied 403 on every route — no upstream is even reached. admin mirrors the
|
||||
// gateway's admin-guard: SanitizeIdentity sets X-User-IsAdmin only for a
|
||||
@@ -152,10 +136,12 @@ func TestGate_DeniesEveryRoute(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A VALIDATED non-super org admin (X-User-Id + pinned X-Org-Id, NO X-User-IsAdmin)
|
||||
// is denied on every PLATFORM route (super-only). The org-scoped routes admit them
|
||||
// but hard-scope the data — proven in scope_test.go.
|
||||
orgAdmin := map[string]string{"X-Org-Id": "acme", "X-User-Id": "acme/bob", "X-User-Email": "bob@acme.test"}
|
||||
// A VALIDATED org admin (X-User-Id + pinned X-Org-Id + the sanitizer-minted
|
||||
// X-User-IsOrgAdmin, but NO GLOBAL X-User-IsAdmin) is denied on every PLATFORM route
|
||||
// (super-only). The org-scoped routes admit them but hard-scope the data — proven in
|
||||
// scope_test.go. (A validated NON-admin member, lacking the org-admin bit, is refused
|
||||
// on the scoped panels too — TestScope_MemberWithoutOrgAdminDenied.)
|
||||
orgAdmin := map[string]string{"X-Org-Id": "acme", "X-User-Id": "acme/bob", "X-User-Email": "bob@acme.test", "X-User-IsOrgAdmin": "true"}
|
||||
for _, r := range platformAdminRoutes {
|
||||
resp, body := do(r.method, r.path, orgAdmin)
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
@@ -164,15 +150,15 @@ func TestGate_DeniesEveryRoute(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGate_AllowsGlobalAdmin proves the flip side: a validated global admin
|
||||
// TestGate_AllowsSuperAdmin proves the flip side: a validated SuperAdmin
|
||||
// (X-User-IsAdmin=true, minted only for owner==AdminOrg) is admitted — the gate
|
||||
// is not vacuously closed. Reaches /v1/admin/me, which needs no upstream.
|
||||
func TestGate_AllowsGlobalAdmin(t *testing.T) {
|
||||
func TestGate_AllowsSuperAdmin(t *testing.T) {
|
||||
do := mount(t, "http://127.0.0.1:0", "http://127.0.0.1:0", "http://127.0.0.1:0")
|
||||
admin := map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "admin", "X-User-Id": "admin/z", "X-User-Email": "z@hanzo.ai"}
|
||||
resp, body := do("GET", "/v1/admin/me", admin)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("global-admin GET /v1/admin/me: got %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
t.Fatalf("SuperAdmin GET /v1/admin/me: got %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var env struct {
|
||||
Status string `json:"status"`
|
||||
@@ -184,17 +170,13 @@ func TestGate_AllowsGlobalAdmin(t *testing.T) {
|
||||
if env.Status != "ok" {
|
||||
t.Fatalf("me status = %q, want ok", env.Status)
|
||||
}
|
||||
if env.Data.Owner != "admin" || env.Data.Email != "z@hanzo.ai" || !env.Data.IsGlobalAdmin {
|
||||
if env.Data.Owner != "admin" || env.Data.Email != "z@hanzo.ai" || !env.Data.IsSuperAdmin {
|
||||
t.Errorf("me identity wrong: %+v", env.Data)
|
||||
}
|
||||
// SuperAdmin canonicalization: the new isSuperAdmin key MUST be present and
|
||||
// equal to the deprecated isGlobalAdmin alias — the console may read either
|
||||
// during the rename migration and must see the same truth.
|
||||
// SuperAdmin canonicalization: the isSuperAdmin key MUST be present and true
|
||||
// for a platform SuperAdmin.
|
||||
if !env.Data.IsSuperAdmin {
|
||||
t.Errorf("me: isSuperAdmin must be true for a global admin: %+v", env.Data)
|
||||
}
|
||||
if env.Data.IsSuperAdmin != env.Data.IsGlobalAdmin {
|
||||
t.Errorf("me: isSuperAdmin (%v) must equal back-compat isGlobalAdmin (%v)", env.Data.IsSuperAdmin, env.Data.IsGlobalAdmin)
|
||||
t.Errorf("me: isSuperAdmin must be true for a SuperAdmin: %+v", env.Data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,11 +274,9 @@ func newFakeCommerce() *fakeCommerce {
|
||||
// revenue bug (commerce.go had X-IAM-Org-Id; admin.go orgSubject had "org/org", so
|
||||
// every real balance read $0). /v1/admin/orgs must surface acme's real $50.00.
|
||||
func TestCommerce_ReconcilesWithXOrgIdBareSlug(t *testing.T) {
|
||||
// orgSubject MUST be the bare slug (not "org/org").
|
||||
if got := orgSubject("acme"); got != "acme" {
|
||||
t.Fatalf("orgSubject(\"acme\") = %q, want \"acme\" (bare slug; \"acme/acme\" reads an empty commerce wallet)", got)
|
||||
}
|
||||
|
||||
// The billing subject is the bare org slug for BOTH the X-Org-Id header and the
|
||||
// `user` param — commerce.Client bakes that in (one subject, no "org/org"). This
|
||||
// test proves it end to end: /v1/admin/orgs must surface acme's real $50.00.
|
||||
iam := newFakeIAM()
|
||||
defer iam.server.Close()
|
||||
commerce := newFakeCommerce()
|
||||
@@ -384,7 +364,7 @@ func TestOrgs_RealAggregation(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestUsers_MapsIAMToOperatorUser verifies the cross-org directory mapping,
|
||||
// including the derived isGlobalAdmin (owner == adminOrg) and the data2 total.
|
||||
// including the derived isSuperAdmin (owner == adminOrg) and the data2 total.
|
||||
func TestUsers_MapsIAMToOperatorUser(t *testing.T) {
|
||||
iam := newFakeIAM()
|
||||
defer iam.server.Close()
|
||||
@@ -409,17 +389,9 @@ func TestUsers_MapsIAMToOperatorUser(t *testing.T) {
|
||||
if u.Name != "alice" || u.Email != "alice@hanzo.ai" || !u.IsAdmin || u.LastSignin == "" {
|
||||
t.Errorf("user mapping wrong: %+v", u)
|
||||
}
|
||||
// owner "hanzo" != adminOrg "admin" → not a global admin.
|
||||
if u.IsGlobalAdmin {
|
||||
t.Errorf("user owner=hanzo must not be flagged global admin")
|
||||
}
|
||||
// SuperAdmin canonicalization: the new key mirrors the same derivation, so a
|
||||
// non-admin-org user is NOT a super admin under either key, and they agree.
|
||||
// owner "hanzo" != adminOrg "admin" → not a SuperAdmin.
|
||||
if u.IsSuperAdmin {
|
||||
t.Errorf("user owner=hanzo must not be flagged super admin")
|
||||
}
|
||||
if u.IsSuperAdmin != u.IsGlobalAdmin {
|
||||
t.Errorf("user: isSuperAdmin (%v) must equal back-compat isGlobalAdmin (%v)", u.IsSuperAdmin, u.IsGlobalAdmin)
|
||||
t.Errorf("user owner=hanzo must not be flagged SuperAdmin")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,7 +492,7 @@ func TestOverview_RealTilesAndSources(t *testing.T) {
|
||||
t.Error("overview lastSync must be set")
|
||||
}
|
||||
// Source freshness: iam ok, commerce ok, o11y not-ok (unconfigured).
|
||||
src := map[string]sourceStatus{}
|
||||
src := map[string]core.SourceStatus{}
|
||||
for _, s := range d.Sources {
|
||||
src[s.Name] = s
|
||||
}
|
||||
@@ -535,6 +507,64 @@ func TestOverview_RealTilesAndSources(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestOverview_CommercePartialOnPerOrgError proves the decomplected freshness rule: the
|
||||
// commerce source is DEGRADED when ANY per-org money read fails — the fleet total is then
|
||||
// an undercount and must NOT read healthy. Commerce succeeds for hanzo but 500s for acme;
|
||||
// the overview folds acme's failure into a not-ok commerce source (the SAME partial
|
||||
// pattern revenue/finance use) instead of the old single-probe that masked it.
|
||||
func TestOverview_CommercePartialOnPerOrgError(t *testing.T) {
|
||||
iam := newFakeIAM()
|
||||
defer iam.server.Close()
|
||||
commerce := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.Header.Get("X-Org-Id") == "acme" {
|
||||
w.WriteHeader(500) // commerce down for THIS org only
|
||||
io.WriteString(w, `{"status":"error","msg":"commerce down for acme"}`)
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/usage-rollup"):
|
||||
io.WriteString(w, `{"consumedCents":1500,"overageCents":0}`)
|
||||
case strings.HasSuffix(r.URL.Path, "/balance"):
|
||||
io.WriteString(w, `{"available":5000,"balance":5000}`)
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
defer commerce.Close()
|
||||
|
||||
do := mount(t, iam.server.URL, commerce.URL, "")
|
||||
admin := map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "admin"}
|
||||
resp, body := do("GET", "/v1/admin/overview", admin)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("overview: %d (%s)", resp.StatusCode, body)
|
||||
}
|
||||
var env struct {
|
||||
Data overviewData `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
src := map[string]core.SourceStatus{}
|
||||
for _, s := range env.Data.Sources {
|
||||
src[s.Name] = s
|
||||
}
|
||||
c, ok := src["commerce"]
|
||||
if !ok {
|
||||
t.Fatal("overview must report a commerce source")
|
||||
}
|
||||
if c.OK {
|
||||
t.Errorf("commerce source must be DEGRADED when a per-org read failed (undercount masked as healthy), got %+v", c)
|
||||
}
|
||||
if c.Error == "" {
|
||||
t.Errorf("degraded commerce source must carry an error: %+v", c)
|
||||
}
|
||||
// The healthy org still contributes — an honest PARTIAL total, never a hard panel fail.
|
||||
if env.Data.SpendCents30d != 1500 {
|
||||
t.Errorf("spend = %d, want 1500 (only hanzo read; acme failed)", env.Data.SpendCents30d)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUsage_RealTotalsHonestEmptySeries proves the usage roll-up returns the REAL
|
||||
// fleet spend from commerce but an HONEST empty series/byProduct — the timeseries
|
||||
// feed lives in insights/datastore, and admin must never fabricate a trend.
|
||||
|
||||
+94
-304
@@ -1,23 +1,15 @@
|
||||
package admin
|
||||
|
||||
// Native SaaS business ANALYTICS (/v1/admin/analytics) — cohort retention, growth,
|
||||
// churn, active-customers (DAU/WAU/MAU), revenue (MRR/ARPU) and usage over time,
|
||||
// derived from REAL fleet data: IAM org `createdTime` (the signup cohort — always
|
||||
// available) + the commerce transaction ledger (usage = `withdraw` rows, the true
|
||||
// customer-activity signal). Global-admin only (s.guard), like every admin route.
|
||||
// churn, active-customers (DAU/WAU/MAU), revenue (MRR/ARPU) and usage over time, derived
|
||||
// from REAL fleet data: IAM org `createdTime` (the signup cohort) + the commerce
|
||||
// transaction ledger (usage = `withdraw` rows). SuperAdmin/org-scoped via GuardScoped.
|
||||
//
|
||||
// HONEST BY CONSTRUCTION. There is NO fabricated curve anywhere. Growth/cohorts
|
||||
// come from real signup timestamps; retention/active/churn/usage come from real
|
||||
// consumption events. A metric that cannot yet be computed (LTV needs observed
|
||||
// churn; NRR needs MRR history commerce does not expose point-in-time) returns a
|
||||
// null / honest-empty series — never an invented trend — and the `computed` map
|
||||
// flags exactly which metrics are backed by data, so the console renders honest
|
||||
// states and a reviewer can verify no number was made up.
|
||||
//
|
||||
// The heavy read (every org's ledger) is bounded + fanned out concurrently. Admin
|
||||
// is low-QPS; at fleet scale this belongs in the insights/datastore OLAP mirror
|
||||
// (same note as the usage series), but the billing ledger is the correct SOURCE
|
||||
// OF TRUTH for real per-customer activity today.
|
||||
// The fleet activity model + the continuous spend series live in clients/admin/core
|
||||
// (core.FleetActivity / core.SpendSeries / core.CustActivity / core.SeriesPoint) because
|
||||
// the revenue board reuses them — one implementation, DRY. This file holds only the
|
||||
// analytics-specific derivation (growth/retention/churn/active/LTV) that folds over that
|
||||
// shared model.
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -27,21 +19,16 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/admin/core"
|
||||
"github.com/hanzoai/cloud/clients/admin/iam"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// ── wire shapes (operator contract) ──────────────────────────────────────────
|
||||
|
||||
// seriesPoint is one bucketed point (count OR cents, per the series). T is the
|
||||
// bucket key (RFC3339 date / "2006-01" month).
|
||||
type seriesPoint struct {
|
||||
T string `json:"t"`
|
||||
Value int64 `json:"value"`
|
||||
}
|
||||
|
||||
// retentionCohort is one row of the retention triangle: a signup cohort, its size,
|
||||
// and the % of it still ACTIVE at each subsequent period (values[0] = the signup
|
||||
// period itself). Percentages are 0..100.
|
||||
// retentionCohort is one row of the retention triangle: a signup cohort, its size, and
|
||||
// the % of it still ACTIVE at each subsequent period (values[0] = the signup period
|
||||
// itself). Percentages are 0..100.
|
||||
type retentionCohort struct {
|
||||
Cohort string `json:"cohort"`
|
||||
Size int `json:"size"`
|
||||
@@ -62,40 +49,39 @@ type analyticsData struct {
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
|
||||
// Growth — from IAM createdTime (always real).
|
||||
Signups []seriesPoint `json:"signups"`
|
||||
CumulativeCustomers []seriesPoint `json:"cumulativeCustomers"`
|
||||
TotalCustomers int `json:"totalCustomers"`
|
||||
NewCustomers int `json:"newCustomers"`
|
||||
GrowthRatePct float64 `json:"growthRatePct"`
|
||||
Signups []core.SeriesPoint `json:"signups"`
|
||||
CumulativeCustomers []core.SeriesPoint `json:"cumulativeCustomers"`
|
||||
TotalCustomers int `json:"totalCustomers"`
|
||||
NewCustomers int `json:"newCustomers"`
|
||||
GrowthRatePct float64 `json:"growthRatePct"`
|
||||
|
||||
// Active customers — from the usage ledger.
|
||||
ActiveCustomers []seriesPoint `json:"activeCustomers"`
|
||||
DAU int `json:"dau"`
|
||||
WAU int `json:"wau"`
|
||||
MAU int `json:"mau"`
|
||||
ActiveCustomers []core.SeriesPoint `json:"activeCustomers"`
|
||||
DAU int `json:"dau"`
|
||||
WAU int `json:"wau"`
|
||||
MAU int `json:"mau"`
|
||||
|
||||
// Retention triangle — signup cohort × active period.
|
||||
Retention retentionGrid `json:"retention"`
|
||||
|
||||
// Churn — logo churn (count) + rate.
|
||||
Churn []seriesPoint `json:"churn"`
|
||||
ChurnRatePct float64 `json:"churnRatePct"`
|
||||
Churn []core.SeriesPoint `json:"churn"`
|
||||
ChurnRatePct float64 `json:"churnRatePct"`
|
||||
|
||||
// Revenue analytics.
|
||||
MRRCents int64 `json:"mrrCents"`
|
||||
Revenue []seriesPoint `json:"revenue"`
|
||||
ARPUCents int64 `json:"arpuCents"`
|
||||
LTVCents *int64 `json:"ltvCents"` // null until churn is observed
|
||||
NRRPct *float64 `json:"nrrPct"` // null — needs MRR history
|
||||
MRRCents int64 `json:"mrrCents"`
|
||||
Revenue []core.SeriesPoint `json:"revenue"`
|
||||
ARPUCents int64 `json:"arpuCents"`
|
||||
LTVCents *int64 `json:"ltvCents"` // null until churn is observed
|
||||
NRRPct *float64 `json:"nrrPct"` // null — needs MRR history
|
||||
|
||||
// Usage analytics.
|
||||
Usage []seriesPoint `json:"usage"`
|
||||
TopCustomers []analyticsSlice `json:"topCustomers"`
|
||||
Usage []core.SeriesPoint `json:"usage"`
|
||||
TopCustomers []analyticsSlice `json:"topCustomers"`
|
||||
|
||||
// Transparency: which metrics are backed by real data vs honest-empty. A
|
||||
// reviewer/console reads this to know nothing was fabricated.
|
||||
Computed map[string]bool `json:"computed"`
|
||||
Sources []sourceStatus `json:"sources"`
|
||||
// Transparency: which metrics are backed by real data vs honest-empty.
|
||||
Computed map[string]bool `json:"computed"`
|
||||
Sources []core.SourceStatus `json:"sources"`
|
||||
}
|
||||
|
||||
// analyticsSlice is a labelled magnitude (top customers by usage cents).
|
||||
@@ -105,75 +91,35 @@ type analyticsSlice struct {
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
// ── the activity model the pure math folds over ──────────────────────────────
|
||||
|
||||
// txnPoint is one dated usage event (a commerce `withdraw`, in cents).
|
||||
type txnPoint struct {
|
||||
T time.Time
|
||||
Cents int64
|
||||
}
|
||||
|
||||
// custActivity is one customer's real analytics input: when they signed up (IAM
|
||||
// createdTime) and their consumption events (commerce withdraws). Deposits are
|
||||
// NOT activity (a credit grant is not the customer using the product), so only
|
||||
// withdraws feed active/retention/churn/usage — the honest "used it" signal.
|
||||
type custActivity struct {
|
||||
Org string
|
||||
Display string
|
||||
Created time.Time
|
||||
HasCreated bool
|
||||
Usage []txnPoint
|
||||
SpendCents int64
|
||||
}
|
||||
|
||||
func (ca custActivity) activeIn(bucket string, interval string) bool {
|
||||
for _, p := range ca.Usage {
|
||||
if bucketKeyOf(p.T, interval) == bucket {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (ca custActivity) activeSince(cut time.Time) bool {
|
||||
for _, p := range ca.Usage {
|
||||
if !p.T.Before(cut) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── handler ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func analytics(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
func analytics(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
cr := callerCreds(c)
|
||||
cr := core.CallerCreds(c)
|
||||
now := time.Now().UTC()
|
||||
rangeStr := normalizeRange(c.Query("range"))
|
||||
since, interval, _ := rangeWindow(rangeStr, now)
|
||||
|
||||
var sources []sourceStatus
|
||||
var sources []core.SourceStatus
|
||||
|
||||
// Scoped fan-in: a SuperAdmin gets every org (all-orgs SaaS analytics); an org
|
||||
// admin gets ONLY their own subtree (their org's usage/active/spend), never
|
||||
// another tenant's — the ONE tenant-scope predicate (scope.go).
|
||||
orgs, err := scopedOrgs(s, ctx, c, cr)
|
||||
// Scoped fan-in: a SuperAdmin gets every org (all-orgs SaaS analytics); an org admin
|
||||
// gets ONLY their own subtree — the ONE tenant-scope predicate (core.ScopedOrgs).
|
||||
orgs, err := core.ScopedOrgs(s, ctx, c, cr)
|
||||
if err != nil {
|
||||
return fail(c, err.Error())
|
||||
return core.Fail(c, err.Error())
|
||||
}
|
||||
sources = append(sources, srcOf("iam", nil, len(orgs), now.Format(time.RFC3339)))
|
||||
sources = append(sources, core.SrcOf("iam", nil, len(orgs), now.Format(time.RFC3339)))
|
||||
|
||||
acts, ledgerOK := fleetActivity(s, ctx, orgs)
|
||||
acts, ledgerOK := core.FleetActivity(s, ctx, orgs)
|
||||
ledgerRows := 0
|
||||
for _, a := range acts {
|
||||
ledgerRows += len(a.Usage)
|
||||
}
|
||||
var ledgerErr error
|
||||
if !ledgerOK {
|
||||
ledgerErr = errPartialRevenue // partial ledger read — mark degraded
|
||||
ledgerErr = core.ErrPartialRevenue // partial ledger read — mark degraded
|
||||
}
|
||||
sources = append(sources, srcOf("commerce-ledger", ledgerErr, ledgerRows, now.Format(time.RFC3339)))
|
||||
sources = append(sources, core.SrcOf("commerce-ledger", ledgerErr, ledgerRows, now.Format(time.RFC3339)))
|
||||
|
||||
// MRR from subscriptions (point-in-time), fanned out like the money reads.
|
||||
mrr := fleetMRR(s, ctx, orgs)
|
||||
@@ -189,13 +135,13 @@ func analytics(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
})
|
||||
data.GeneratedAt = now.Format(time.RFC3339)
|
||||
data.Sources = sources
|
||||
return ok(c, data)
|
||||
return core.OK(c, data)
|
||||
}
|
||||
|
||||
// analyticsInput is everything computeAnalytics needs — no I/O, so the whole SaaS
|
||||
// analytics derivation is unit-testable without a network.
|
||||
type analyticsInput struct {
|
||||
acts []custActivity
|
||||
acts []core.CustActivity
|
||||
mrrCents int64
|
||||
now time.Time
|
||||
since time.Time
|
||||
@@ -206,19 +152,19 @@ type analyticsInput struct {
|
||||
|
||||
// computeAnalytics is the PURE derivation of every analytics metric from the real
|
||||
// activity model. Growth is always computed (signup timestamps); the ledger-backed
|
||||
// metrics compute from real usage when present and degrade to honest empty/zero
|
||||
// when the fleet has no usage yet — never a fabricated curve. `computed` flags each.
|
||||
// metrics compute from real usage when present and degrade to honest empty/zero when the
|
||||
// fleet has no usage yet — never a fabricated curve. `computed` flags each.
|
||||
func computeAnalytics(in analyticsInput) analyticsData {
|
||||
buckets := enumerateBuckets(in.since, in.now, in.interval)
|
||||
buckets := core.EnumerateBuckets(in.since, in.now, in.interval)
|
||||
|
||||
// ── Growth (IAM createdTime — always real) ──
|
||||
signups := make([]seriesPoint, len(buckets))
|
||||
signups := make([]core.SeriesPoint, len(buckets))
|
||||
newCount := 0
|
||||
total := 0
|
||||
for i, b := range buckets {
|
||||
signups[i] = seriesPoint{T: b}
|
||||
signups[i] = core.SeriesPoint{T: b}
|
||||
}
|
||||
idx := indexOf(buckets)
|
||||
idx := core.IndexOf(buckets)
|
||||
for _, a := range in.acts {
|
||||
if !a.HasCreated {
|
||||
continue
|
||||
@@ -227,12 +173,12 @@ func computeAnalytics(in analyticsInput) analyticsData {
|
||||
if !a.Created.Before(in.since) {
|
||||
newCount++
|
||||
}
|
||||
if i, ok := idx[bucketKeyOf(a.Created, in.interval)]; ok {
|
||||
if i, ok := idx[core.BucketKeyOf(a.Created, in.interval)]; ok {
|
||||
signups[i].Value++
|
||||
}
|
||||
}
|
||||
// Cumulative customers across the SAME buckets (all-time count at each bucket end).
|
||||
cumulative := make([]seriesPoint, len(buckets))
|
||||
cumulative := make([]core.SeriesPoint, len(buckets))
|
||||
for i, b := range buckets {
|
||||
end := bucketEnd(b, in.interval)
|
||||
n := 0
|
||||
@@ -241,15 +187,15 @@ func computeAnalytics(in analyticsInput) analyticsData {
|
||||
n++
|
||||
}
|
||||
}
|
||||
cumulative[i] = seriesPoint{T: b, Value: int64(n)}
|
||||
cumulative[i] = core.SeriesPoint{T: b, Value: int64(n)}
|
||||
}
|
||||
growthRate := priorWindowGrowth(in.acts, in.since, in.now)
|
||||
|
||||
// ── Active customers + usage (ledger-backed) ──
|
||||
usage := spendSeries(in.acts, in.since, in.now, in.interval)
|
||||
active := make([]seriesPoint, len(buckets))
|
||||
usage := core.SpendSeries(in.acts, in.since, in.now, in.interval)
|
||||
active := make([]core.SeriesPoint, len(buckets))
|
||||
for i, b := range buckets {
|
||||
active[i] = seriesPoint{T: b}
|
||||
active[i] = core.SeriesPoint{T: b}
|
||||
}
|
||||
for _, a := range in.acts {
|
||||
// active in a bucket = at least one usage event in it
|
||||
@@ -258,7 +204,7 @@ func computeAnalytics(in analyticsInput) analyticsData {
|
||||
if p.T.Before(in.since) || p.T.After(in.now) {
|
||||
continue
|
||||
}
|
||||
seen[bucketKeyOf(p.T, in.interval)] = true
|
||||
seen[core.BucketKeyOf(p.T, in.interval)] = true
|
||||
}
|
||||
for b := range seen {
|
||||
if i, ok := idx[b]; ok {
|
||||
@@ -289,8 +235,7 @@ func computeAnalytics(in analyticsInput) analyticsData {
|
||||
}
|
||||
var ltv *int64
|
||||
if churnRate > 0 && arpu > 0 {
|
||||
// LTV ≈ ARPU / monthly churn rate — computed ONLY when real churn is
|
||||
// observed, else honest null (LTV needs churn to mean anything).
|
||||
// LTV ≈ ARPU / monthly churn rate — computed ONLY when real churn is observed.
|
||||
v := int64(float64(arpu) / (churnRate / 100.0))
|
||||
ltv = &v
|
||||
}
|
||||
@@ -300,7 +245,7 @@ func computeAnalytics(in analyticsInput) analyticsData {
|
||||
|
||||
// Revenue series = realized usage revenue per bucket (same as usage cents for a
|
||||
// pay-as-you-go fleet; distinct field so the console can theme it as revenue).
|
||||
revenue := make([]seriesPoint, len(usage))
|
||||
revenue := make([]core.SeriesPoint, len(usage))
|
||||
copy(revenue, usage)
|
||||
|
||||
return analyticsData{
|
||||
@@ -340,18 +285,18 @@ func computeAnalytics(in analyticsInput) analyticsData {
|
||||
}
|
||||
}
|
||||
|
||||
// computeRetention builds the cohort × period retention triangle from real signup
|
||||
// months and usage months. retention[c][k] = fraction of cohort c ACTIVE in month
|
||||
// c+k. Cohorts are capped to the last `maxCohorts` months (the classic triangle);
|
||||
// a cohort with no signups is omitted. Values are 0..100.
|
||||
func computeRetention(acts []custActivity, now time.Time, maxCohorts int) retentionGrid {
|
||||
// computeRetention builds the cohort × period retention triangle from real signup months
|
||||
// and usage months. retention[c][k] = fraction of cohort c ACTIVE in month c+k. Cohorts
|
||||
// are capped to the last `maxCohorts` months; a cohort with no signups is omitted. Values
|
||||
// are 0..100.
|
||||
func computeRetention(acts []core.CustActivity, now time.Time, maxCohorts int) retentionGrid {
|
||||
// Group customers by signup month.
|
||||
byCohort := map[string][]custActivity{}
|
||||
byCohort := map[string][]core.CustActivity{}
|
||||
for _, a := range acts {
|
||||
if !a.HasCreated {
|
||||
continue
|
||||
}
|
||||
k := monthKey(a.Created)
|
||||
k := core.MonthKey(a.Created)
|
||||
byCohort[k] = append(byCohort[k], a)
|
||||
}
|
||||
|
||||
@@ -365,7 +310,7 @@ func computeRetention(acts []custActivity, now time.Time, maxCohorts int) retent
|
||||
cohorts = cohorts[len(cohorts)-maxCohorts:]
|
||||
}
|
||||
|
||||
nowMonth := monthKey(now)
|
||||
nowMonth := core.MonthKey(now)
|
||||
grid := retentionGrid{Interval: "month"}
|
||||
maxPeriods := 0
|
||||
for _, cohort := range cohorts {
|
||||
@@ -379,7 +324,7 @@ func computeRetention(acts []custActivity, now time.Time, maxCohorts int) retent
|
||||
month := addMonths(cohort, k)
|
||||
activeN := 0
|
||||
for _, m := range members {
|
||||
if m.activeIn(month, "month") {
|
||||
if m.ActiveIn(month, "month") {
|
||||
activeN++
|
||||
}
|
||||
}
|
||||
@@ -396,17 +341,16 @@ func computeRetention(acts []custActivity, now time.Time, maxCohorts int) retent
|
||||
return grid
|
||||
}
|
||||
|
||||
// computeChurn derives monthly LOGO churn: a customer counts as churned in month M
|
||||
// if they were active in M-1 but NOT in M. The rate is the average monthly churn
|
||||
// over the observed window (churned / active-at-start). Returns honest zeros when
|
||||
// there is no usage history.
|
||||
func computeChurn(acts []custActivity, now time.Time, months int) ([]seriesPoint, float64) {
|
||||
// computeChurn derives monthly LOGO churn: a customer counts as churned in month M if
|
||||
// they were active in M-1 but NOT in M. The rate is the average monthly churn over the
|
||||
// observed window. Returns honest zeros when there is no usage history.
|
||||
func computeChurn(acts []core.CustActivity, now time.Time, months int) ([]core.SeriesPoint, float64) {
|
||||
// Build the last `months` month keys ending at now.
|
||||
keys := lastMonths(now, months)
|
||||
series := make([]seriesPoint, len(keys))
|
||||
series := make([]core.SeriesPoint, len(keys))
|
||||
var churnedTotal, baseTotal int
|
||||
for i, m := range keys {
|
||||
series[i] = seriesPoint{T: m}
|
||||
series[i] = core.SeriesPoint{T: m}
|
||||
if i == 0 {
|
||||
continue // no prior month to compare
|
||||
}
|
||||
@@ -414,10 +358,10 @@ func computeChurn(acts []custActivity, now time.Time, months int) ([]seriesPoint
|
||||
churned := 0
|
||||
base := 0
|
||||
for _, a := range acts {
|
||||
wasActive := a.activeIn(prev, "month")
|
||||
wasActive := a.ActiveIn(prev, "month")
|
||||
if wasActive {
|
||||
base++
|
||||
if !a.activeIn(m, "month") {
|
||||
if !a.ActiveIn(m, "month") {
|
||||
churned++
|
||||
}
|
||||
}
|
||||
@@ -433,31 +377,8 @@ func computeChurn(acts []custActivity, now time.Time, months int) ([]seriesPoint
|
||||
return series, rate
|
||||
}
|
||||
|
||||
// spendSeries buckets fleet usage cents into a continuous series over since..now.
|
||||
// Shared by the analytics usage/revenue trend and the revenue board's spend trend
|
||||
// (one implementation, DRY). A bucket with no usage is an honest 0, not a gap.
|
||||
func spendSeries(acts []custActivity, since, now time.Time, interval string) []seriesPoint {
|
||||
buckets := enumerateBuckets(since, now, interval)
|
||||
idx := indexOf(buckets)
|
||||
out := make([]seriesPoint, len(buckets))
|
||||
for i, b := range buckets {
|
||||
out[i] = seriesPoint{T: b}
|
||||
}
|
||||
for _, a := range acts {
|
||||
for _, p := range a.Usage {
|
||||
if p.T.Before(since) || p.T.After(now) {
|
||||
continue
|
||||
}
|
||||
if i, ok := idx[bucketKeyOf(p.T, interval)]; ok {
|
||||
out[i].Value += p.Cents
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// topCustomersByUsage returns the top-N customers by total usage cents (desc).
|
||||
func topCustomersByUsage(acts []custActivity, n int) []analyticsSlice {
|
||||
func topCustomersByUsage(acts []core.CustActivity, n int) []analyticsSlice {
|
||||
rows := make([]analyticsSlice, 0, len(acts))
|
||||
for _, a := range acts {
|
||||
if a.SpendCents <= 0 {
|
||||
@@ -472,9 +393,9 @@ func topCustomersByUsage(acts []custActivity, n int) []analyticsSlice {
|
||||
return rows
|
||||
}
|
||||
|
||||
// priorWindowGrowth is the signup growth vs the immediately-preceding window: the
|
||||
// % change in new signups this window vs last. 0 when the prior window had none.
|
||||
func priorWindowGrowth(acts []custActivity, since, now time.Time) float64 {
|
||||
// priorWindowGrowth is the signup growth vs the immediately-preceding window: the %
|
||||
// change in new signups this window vs last. 0 when the prior window had none.
|
||||
func priorWindowGrowth(acts []core.CustActivity, since, now time.Time) float64 {
|
||||
window := now.Sub(since)
|
||||
priorStart := since.Add(-window)
|
||||
cur, prev := 0, 0
|
||||
@@ -495,83 +416,29 @@ func priorWindowGrowth(acts []custActivity, since, now time.Time) float64 {
|
||||
}
|
||||
|
||||
// activeWithin counts customers with at least one usage event since `cut`.
|
||||
func activeWithin(acts []custActivity, cut time.Time) int {
|
||||
func activeWithin(acts []core.CustActivity, cut time.Time) int {
|
||||
n := 0
|
||||
for _, a := range acts {
|
||||
if a.activeSince(cut) {
|
||||
if a.ActiveSince(cut) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ── fleet readers (I/O; concurrent, bounded) ─────────────────────────────────
|
||||
|
||||
// fleetActivity reads every org's signup time (already on the org row) + usage
|
||||
// ledger, folded into the pure activity model. Returns (acts, ok) where ok is
|
||||
// false if ANY org's ledger read failed (the caller marks the source degraded and
|
||||
// flags the ledger-backed metrics as not-fully-computed). Fanned out concurrently
|
||||
// with a bound, like the customer list.
|
||||
func fleetActivity(s *cloud.Service[state], ctx context.Context, orgs []iamOrg) ([]custActivity, bool) {
|
||||
acts := make([]custActivity, len(orgs))
|
||||
oks := make([]bool, len(orgs))
|
||||
sem := make(chan struct{}, maxCustomerConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
for i, o := range orgs {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(i int, o iamOrg) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
ca := custActivity{Org: o.Name, Display: display(o.DisplayName, o.Name)}
|
||||
if t, err := time.Parse(time.RFC3339, o.CreatedTime); err == nil {
|
||||
ca.Created = t.UTC()
|
||||
ca.HasCreated = true
|
||||
}
|
||||
rows, err := s.State.commerce.transactions(ctx, o.Name, orgSubject(o.Name), 2000)
|
||||
oks[i] = err == nil
|
||||
for _, r := range rows {
|
||||
if strings.ToLower(r.Type) != "withdraw" {
|
||||
continue // only consumption is "activity"; deposits are credits
|
||||
}
|
||||
t, perr := parseTxnTime(r.CreatedAt)
|
||||
if perr != nil {
|
||||
continue
|
||||
}
|
||||
amt := r.Amount
|
||||
if amt < 0 {
|
||||
amt = -amt
|
||||
}
|
||||
ca.Usage = append(ca.Usage, txnPoint{T: t, Cents: amt})
|
||||
ca.SpendCents += amt
|
||||
}
|
||||
acts[i] = ca
|
||||
}(i, o)
|
||||
}
|
||||
wg.Wait()
|
||||
allOK := true
|
||||
for _, ok := range oks {
|
||||
if !ok {
|
||||
allOK = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return acts, allOK
|
||||
}
|
||||
|
||||
// fleetMRR sums each org's active-subscription MRR concurrently.
|
||||
func fleetMRR(s *cloud.Service[state], ctx context.Context, orgs []iamOrg) int64 {
|
||||
func fleetMRR(s *cloud.Service[core.State], ctx context.Context, orgs []iam.Org) int64 {
|
||||
vals := make([]int64, len(orgs))
|
||||
sem := make(chan struct{}, maxCustomerConcurrency)
|
||||
sem := make(chan struct{}, core.MaxCustomerConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
for i, o := range orgs {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(i int, o iamOrg) {
|
||||
go func(i int, o iam.Org) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
if sum, err := s.State.commerce.subscriptionSummary(ctx, o.Name, orgSubject(o.Name)); err == nil {
|
||||
vals[i] = sum.MRR
|
||||
if pl, err := s.State.Commerce.Plan(ctx, o.Name); err == nil {
|
||||
vals[i] = int64(pl.MRR)
|
||||
}
|
||||
}(i, o)
|
||||
}
|
||||
@@ -583,36 +450,10 @@ func fleetMRR(s *cloud.Service[state], ctx context.Context, orgs []iamOrg) int64
|
||||
return total
|
||||
}
|
||||
|
||||
// ── pure time-bucket helpers ─────────────────────────────────────────────────
|
||||
// ── analytics-specific month arithmetic (the shared bucket keys live in core) ──
|
||||
|
||||
func monthKey(t time.Time) string { return t.UTC().Format("2006-01") }
|
||||
func dayKey(t time.Time) string { return t.UTC().Format("2006-01-02") }
|
||||
|
||||
// weekKey buckets to the ISO week's Monday (a stable weekly key).
|
||||
func weekKey(t time.Time) string {
|
||||
u := t.UTC()
|
||||
// back up to Monday
|
||||
wd := int(u.Weekday())
|
||||
if wd == 0 {
|
||||
wd = 7
|
||||
}
|
||||
monday := u.AddDate(0, 0, -(wd - 1))
|
||||
return monday.Format("2006-01-02")
|
||||
}
|
||||
|
||||
func bucketKeyOf(t time.Time, interval string) string {
|
||||
switch interval {
|
||||
case "month":
|
||||
return monthKey(t)
|
||||
case "week":
|
||||
return weekKey(t)
|
||||
default:
|
||||
return dayKey(t)
|
||||
}
|
||||
}
|
||||
|
||||
// bucketEnd returns the inclusive end instant of a bucket key (for the cumulative
|
||||
// count). A day/week/month key advances one unit; the end is one nanosecond before.
|
||||
// bucketEnd returns the inclusive end instant of a bucket key (for the cumulative count).
|
||||
// A day/week/month key advances one unit; the end is one nanosecond before.
|
||||
func bucketEnd(key, interval string) time.Time {
|
||||
switch interval {
|
||||
case "month":
|
||||
@@ -631,48 +472,6 @@ func bucketEnd(key, interval string) time.Time {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
// enumerateBuckets lists every bucket key from since..now inclusive so a series has
|
||||
// a continuous axis (a zero-usage bucket is an honest 0, not a gap).
|
||||
func enumerateBuckets(since, now time.Time, interval string) []string {
|
||||
if since.After(now) {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
step := func(t time.Time) time.Time {
|
||||
switch interval {
|
||||
case "month":
|
||||
return t.AddDate(0, 1, 0)
|
||||
case "week":
|
||||
return t.AddDate(0, 0, 7)
|
||||
default:
|
||||
return t.AddDate(0, 0, 1)
|
||||
}
|
||||
}
|
||||
// cap iterations so a bad range can never spin unbounded
|
||||
for t, n := since, 0; !t.After(now) && n < 800; t, n = step(t), n+1 {
|
||||
k := bucketKeyOf(t, interval)
|
||||
if !seen[k] {
|
||||
seen[k] = true
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
// ensure the final bucket (now) is present
|
||||
last := bucketKeyOf(now, interval)
|
||||
if !seen[last] {
|
||||
out = append(out, last)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func indexOf(buckets []string) map[string]int {
|
||||
m := make(map[string]int, len(buckets))
|
||||
for i, b := range buckets {
|
||||
m[b] = i
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// addMonths adds k months to a "2006-01" key.
|
||||
func addMonths(month string, k int) string {
|
||||
t, err := time.Parse("2006-01", month)
|
||||
@@ -696,7 +495,7 @@ func monthsBetween(a, b string) int {
|
||||
func lastMonths(now time.Time, n int) []string {
|
||||
out := make([]string, 0, n)
|
||||
for i := n - 1; i >= 0; i-- {
|
||||
out = append(out, monthKey(now.AddDate(0, -i, 0)))
|
||||
out = append(out, core.MonthKey(now.AddDate(0, -i, 0)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -708,15 +507,6 @@ func pct(part, whole int) float64 {
|
||||
return (float64(part) / float64(whole)) * 100
|
||||
}
|
||||
|
||||
// parseTxnTime accepts the commerce ledger's RFC3339 forms.
|
||||
func parseTxnTime(s string) (time.Time, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
return t.UTC(), nil
|
||||
}
|
||||
return time.Parse("2006-01-02T15:04:05Z", s)
|
||||
}
|
||||
|
||||
// normalizeRange clamps the range param to the supported set (default 30d).
|
||||
func normalizeRange(r string) string {
|
||||
switch strings.TrimSpace(r) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/admin/core"
|
||||
)
|
||||
|
||||
// mkTime is a test helper for an RFC3339-ish instant.
|
||||
@@ -21,14 +23,14 @@ func mkTime(s string) time.Time {
|
||||
// alpha: signup 2024-05-10; usage 2024-05-20 (100c), 2024-06-05 (200c) [cohort 05, active 05+06]
|
||||
// beta : signup 2024-05-25; usage 2024-05-28 (50c) [cohort 05, active 05 only]
|
||||
// gamma: signup 2024-06-15; usage 2024-07-01 (400c) [cohort 06, active 07 only]
|
||||
func fleetFixture() []custActivity {
|
||||
return []custActivity{
|
||||
func fleetFixture() []core.CustActivity {
|
||||
return []core.CustActivity{
|
||||
{Org: "alpha", Display: "Alpha", Created: mkTime("2024-05-10"), HasCreated: true,
|
||||
Usage: []txnPoint{{T: mkTime("2024-05-20"), Cents: 100}, {T: mkTime("2024-06-05"), Cents: 200}}, SpendCents: 300},
|
||||
Usage: []core.TxnPoint{{T: mkTime("2024-05-20"), Cents: 100}, {T: mkTime("2024-06-05"), Cents: 200}}, SpendCents: 300},
|
||||
{Org: "beta", Display: "Beta", Created: mkTime("2024-05-25"), HasCreated: true,
|
||||
Usage: []txnPoint{{T: mkTime("2024-05-28"), Cents: 50}}, SpendCents: 50},
|
||||
Usage: []core.TxnPoint{{T: mkTime("2024-05-28"), Cents: 50}}, SpendCents: 50},
|
||||
{Org: "gamma", Display: "Gamma", Created: mkTime("2024-06-15"), HasCreated: true,
|
||||
Usage: []txnPoint{{T: mkTime("2024-07-01"), Cents: 400}}, SpendCents: 400},
|
||||
Usage: []core.TxnPoint{{T: mkTime("2024-07-01"), Cents: 400}}, SpendCents: 400},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,10 +217,10 @@ func TestComputeAnalytics_HonestEmptyNoLedger(t *testing.T) {
|
||||
func TestSpendSeries_ContinuousHonestBuckets(t *testing.T) {
|
||||
now := mkTime("2024-07-05")
|
||||
since := mkTime("2024-07-01")
|
||||
acts := []custActivity{
|
||||
{Usage: []txnPoint{{T: mkTime("2024-07-01"), Cents: 100}, {T: mkTime("2024-07-03"), Cents: 300}}},
|
||||
acts := []core.CustActivity{
|
||||
{Usage: []core.TxnPoint{{T: mkTime("2024-07-01"), Cents: 100}, {T: mkTime("2024-07-03"), Cents: 300}}},
|
||||
}
|
||||
series := spendSeries(acts, since, now, "day")
|
||||
series := core.SpendSeries(acts, since, now, "day")
|
||||
// 5 daily buckets 07-01..07-05.
|
||||
if len(series) != 5 {
|
||||
t.Fatalf("series buckets = %d, want 5 (%+v)", len(series), series)
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
package admin
|
||||
|
||||
// The /v1/admin/audit query surface, wired to cloud's REAL tamper-evident audit
|
||||
// store (the audit.Recorder Serve builds and hands over via deps.Audit).
|
||||
//
|
||||
// This REPLACES the previous behavior — proxying IAM get-records — as the primary
|
||||
// source: cloud now keeps its OWN append-only, hash-chained trail of every
|
||||
// security-relevant request against this binary, and that is what a compliance
|
||||
// auditor queries here. IAM's own login/session records remain available in IAM;
|
||||
// they are a DIFFERENT trail (IAM's request surface), and admin still federates
|
||||
// them as a fallback when cloud's local store is not configured, so no capability
|
||||
// is lost.
|
||||
//
|
||||
// SECURITY. Both handlers are registered behind the SAME s.guard as every other
|
||||
// /v1/admin/* route (global-admin only, fail-closed). They are READ-ONLY (Query
|
||||
// and Verify issue SELECT only), so exposing them cannot weaken the append-only
|
||||
// property. The verify endpoint returns integrity STATUS, never a way to mutate.
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/audit"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// audit answers GET /v1/admin/audit from cloud's local tamper-evident store when
|
||||
// configured, else falls back to the IAM get-records proxy (federated view).
|
||||
// Filters: org, sub, action, resource, result, since, until, pageSize, p (page).
|
||||
// The response is the /v1 list envelope { data:[rows], data2:total } the
|
||||
// operator decodes, with the current chain integrity summary attached.
|
||||
func auditRecords(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
// No local store configured → preserve the legacy federated IAM view so the
|
||||
// endpoint never regresses to empty.
|
||||
if s.State.auditStore == nil {
|
||||
return auditFromIAM(s, c)
|
||||
}
|
||||
|
||||
f := auditFilterFromQuery(c)
|
||||
rows, total, err := s.State.auditStore.Query(c.Context(), f)
|
||||
if err != nil {
|
||||
return fail(c, err.Error())
|
||||
}
|
||||
|
||||
out := make([]audit.Wire, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, r.ToWire())
|
||||
}
|
||||
|
||||
// Attach the live integrity summary so the console can badge the trail as
|
||||
// verified. Best-effort: a verify error must not fail the listing.
|
||||
integrity, ivErr := s.State.auditStore.Verify(c.Context())
|
||||
var integrityPayload any
|
||||
if ivErr == nil {
|
||||
integrityPayload = integrity
|
||||
}
|
||||
|
||||
return c.JSON(200, map[string]any{
|
||||
"status": "ok",
|
||||
"msg": "",
|
||||
"data": out,
|
||||
"data2": total,
|
||||
"integrity": integrityPayload,
|
||||
})
|
||||
}
|
||||
|
||||
// auditVerify answers GET /v1/admin/audit/verify — the tamper-evidence check. It
|
||||
// walks the whole hash chain and returns the integrity result (ok, count, head,
|
||||
// and the seq where the chain first breaks if tampered). Global-admin gated like
|
||||
// every admin route.
|
||||
func auditVerify(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
if s.State.auditStore == nil {
|
||||
return fail(c, "audit store not configured")
|
||||
}
|
||||
integrity, err := s.State.auditStore.Verify(c.Context())
|
||||
if err != nil {
|
||||
return fail(c, err.Error())
|
||||
}
|
||||
return ok(c, integrity)
|
||||
}
|
||||
|
||||
// auditFilterFromQuery builds an audit.Filter from the request query params. Time
|
||||
// bounds accept RFC3339. pageSize (default 100, cap 1000) and p (1-based page)
|
||||
// drive Limit/Offset. Unknown/blank params are simply not applied.
|
||||
func auditFilterFromQuery(c *zip.Ctx) audit.Filter {
|
||||
f := audit.Filter{
|
||||
Org: strings.TrimSpace(c.Query("org")),
|
||||
Sub: strings.TrimSpace(c.Query("sub")),
|
||||
Action: strings.TrimSpace(c.Query("action")),
|
||||
Resource: strings.TrimSpace(c.Query("resource")),
|
||||
ResourceID: strings.TrimSpace(c.Query("resourceId")),
|
||||
Result: strings.TrimSpace(c.Query("result")),
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("since")); v != "" {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
f.Since = t
|
||||
}
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("until")); v != "" {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
f.Until = t
|
||||
}
|
||||
}
|
||||
pageSize := 100
|
||||
if v := strings.TrimSpace(c.Query("pageSize")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
pageSize = n
|
||||
}
|
||||
}
|
||||
f.Limit = pageSize
|
||||
if v := strings.TrimSpace(c.Query("p")); v != "" {
|
||||
if page, err := strconv.Atoi(v); err == nil && page > 1 {
|
||||
f.Offset = (page - 1) * pageSize
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// auditFromIAM is the legacy federated view: when cloud has no local audit store,
|
||||
// forward the IAM get-records read verbatim (the prior behavior), so the endpoint
|
||||
// still surfaces IAM's own audit trail rather than an empty list.
|
||||
func auditFromIAM(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
q := iamAuditQuery(c)
|
||||
res, err := s.State.iam.getList(c.Context(), callerCreds(c), "/v1/iam/get-records", q)
|
||||
if err != nil {
|
||||
return fail(c, err.Error())
|
||||
}
|
||||
return okRaw(c, res.rows, res.total)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Package audit is the /v1/admin/audit query surface, wired to cloud's REAL
|
||||
// tamper-evident audit store (the audit.Recorder Serve builds and hands over via
|
||||
// deps.Audit).
|
||||
//
|
||||
// cloud keeps its OWN append-only, hash-chained trail of every security-relevant
|
||||
// request against this binary, and that is what a compliance auditor queries here. IAM's
|
||||
// own login/session records remain a DIFFERENT trail; admin still federates them as a
|
||||
// fallback when cloud's local store is not configured, so no capability is lost.
|
||||
//
|
||||
// SECURITY. Both handlers are registered behind core.Guard (SuperAdmin only,
|
||||
// fail-closed). They are READ-ONLY (Query and Verify issue SELECT only), so exposing
|
||||
// them cannot weaken the append-only property.
|
||||
package audit
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
auditstore "github.com/hanzoai/cloud/audit"
|
||||
"github.com/hanzoai/cloud/clients/admin/core"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// Routes registers the /v1/admin/audit* surface (SuperAdmin only).
|
||||
func Routes(app *zip.App, s *cloud.Service[core.State]) {
|
||||
app.Get("/v1/admin/audit", core.Guard(s, Records))
|
||||
app.Get("/v1/admin/audit/verify", core.Guard(s, Verify))
|
||||
}
|
||||
|
||||
// Records answers GET /v1/admin/audit from cloud's local tamper-evident store when
|
||||
// configured, else falls back to the IAM get-records proxy (federated view). Filters:
|
||||
// org, sub, action, resource, result, since, until, pageSize, p (page). The response is
|
||||
// the /v1 list envelope { data:[rows], data2:total } with the current chain integrity
|
||||
// summary attached.
|
||||
func Records(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
// No local store configured → preserve the legacy federated IAM view so the endpoint
|
||||
// never regresses to empty.
|
||||
if s.State.AuditStore == nil {
|
||||
return fromIAM(s, c)
|
||||
}
|
||||
|
||||
f := auditFilterFromQuery(c)
|
||||
rows, total, err := s.State.AuditStore.Query(c.Context(), f)
|
||||
if err != nil {
|
||||
return core.Fail(c, err.Error())
|
||||
}
|
||||
|
||||
out := make([]auditstore.Wire, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, r.ToWire())
|
||||
}
|
||||
|
||||
// Attach the live integrity summary so the console can badge the trail as verified.
|
||||
// Best-effort: a verify error must not fail the listing.
|
||||
integrity, ivErr := s.State.AuditStore.Verify(c.Context())
|
||||
var integrityPayload any
|
||||
if ivErr == nil {
|
||||
integrityPayload = integrity
|
||||
}
|
||||
|
||||
return c.JSON(200, map[string]any{
|
||||
"status": "ok",
|
||||
"msg": "",
|
||||
"data": out,
|
||||
"data2": total,
|
||||
"integrity": integrityPayload,
|
||||
})
|
||||
}
|
||||
|
||||
// Verify answers GET /v1/admin/audit/verify — the tamper-evidence check. It walks the
|
||||
// whole hash chain and returns the integrity result (ok, count, head, and the seq where
|
||||
// the chain first breaks if tampered).
|
||||
func Verify(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
if s.State.AuditStore == nil {
|
||||
return core.Fail(c, "audit store not configured")
|
||||
}
|
||||
integrity, err := s.State.AuditStore.Verify(c.Context())
|
||||
if err != nil {
|
||||
return core.Fail(c, err.Error())
|
||||
}
|
||||
return core.OK(c, integrity)
|
||||
}
|
||||
|
||||
// auditFilterFromQuery builds an audit.Filter from the request query params. Time bounds
|
||||
// accept RFC3339. pageSize (default 100) and p (1-based page) drive Limit/Offset.
|
||||
// Unknown/blank params are simply not applied.
|
||||
func auditFilterFromQuery(c *zip.Ctx) auditstore.Filter {
|
||||
f := auditstore.Filter{
|
||||
Org: strings.TrimSpace(c.Query("org")),
|
||||
Sub: strings.TrimSpace(c.Query("sub")),
|
||||
Action: strings.TrimSpace(c.Query("action")),
|
||||
Resource: strings.TrimSpace(c.Query("resource")),
|
||||
ResourceID: strings.TrimSpace(c.Query("resourceId")),
|
||||
Result: strings.TrimSpace(c.Query("result")),
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("since")); v != "" {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
f.Since = t
|
||||
}
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("until")); v != "" {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
f.Until = t
|
||||
}
|
||||
}
|
||||
pageSize := 100
|
||||
if v := strings.TrimSpace(c.Query("pageSize")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
pageSize = n
|
||||
}
|
||||
}
|
||||
f.Limit = pageSize
|
||||
if v := strings.TrimSpace(c.Query("p")); v != "" {
|
||||
if page, err := strconv.Atoi(v); err == nil && page > 1 {
|
||||
f.Offset = (page - 1) * pageSize
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// fromIAM is the legacy federated view: when cloud has no local audit store, forward the
|
||||
// IAM get-records read verbatim (the prior behavior), so the endpoint still surfaces
|
||||
// IAM's own audit trail rather than an empty list.
|
||||
func fromIAM(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
q := iamAuditQuery(c)
|
||||
res, err := s.State.IAM.List(c.Context(), core.CallerCreds(c), "/v1/iam/get-records", q)
|
||||
if err != nil {
|
||||
return core.Fail(c, err.Error())
|
||||
}
|
||||
return core.OKRaw(c, res.Rows, res.Total)
|
||||
}
|
||||
|
||||
// iamAuditQuery builds the IAM get-records query for the federated fallback.
|
||||
func iamAuditQuery(c *zip.Ctx) url.Values {
|
||||
q := url.Values{}
|
||||
if org := strings.TrimSpace(c.Query("org")); org != "" {
|
||||
q.Set("organizationName", org)
|
||||
}
|
||||
q.Set("p", "1")
|
||||
ps := strings.TrimSpace(c.Query("pageSize"))
|
||||
if ps == "" {
|
||||
ps = "100"
|
||||
}
|
||||
q.Set("pageSize", ps)
|
||||
q.Set("sortField", "createdTime")
|
||||
q.Set("sortOrder", "descend")
|
||||
return q
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package admin
|
||||
package audit
|
||||
|
||||
// Tests for the store-backed /v1/admin/audit + /v1/admin/audit/verify surface.
|
||||
// They wire admin against a REAL audit.Recorder (on-disk SQLite) seeded with
|
||||
// records, drive requests through the whole zip app, and assert the query
|
||||
// results, the integrity summary, and the global-admin gate.
|
||||
// Tests for the store-backed /v1/admin/audit + /v1/admin/audit/verify surface. They wire
|
||||
// the audit domain against a REAL audit.Recorder (on-disk SQLite) seeded with records,
|
||||
// drive requests through the whole zip app, and assert the query results, the integrity
|
||||
// summary, and the SuperAdmin gate.
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -16,29 +16,28 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/audit"
|
||||
auditstore "github.com/hanzoai/cloud/audit"
|
||||
"github.com/hanzoai/cloud/clients/admin/core"
|
||||
luxlog "github.com/luxfi/log"
|
||||
fiber "github.com/zap-proto/fiber/v3"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// mountWithStore builds a zip app with admin's audit routes wired to a real audit
|
||||
// store, and returns the store + a request helper. Only the audit routes are
|
||||
// mounted here (the rest are covered by mount()); this keeps the store-backed
|
||||
// tests focused.
|
||||
func mountWithStore(t *testing.T) (*audit.Recorder, func(method, path string, hdr map[string]string) (*http.Response, []byte)) {
|
||||
// mountWithStore builds a zip app with the audit routes wired to a real audit store, and
|
||||
// returns the store + a request helper. Only the audit routes are mounted here.
|
||||
func mountWithStore(t *testing.T) (*auditstore.Recorder, func(method, path string, hdr map[string]string) (*http.Response, []byte)) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
rec, err := audit.Open(path, nil)
|
||||
rec, err := auditstore.Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("audit.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = rec.Close() })
|
||||
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
s := &cloud.Service[state]{State: state{adminOrg: "admin", auditStore: rec}}
|
||||
app.Get("/v1/admin/audit", guard(s, auditRecords))
|
||||
app.Get("/v1/admin/audit/verify", guard(s, auditVerify))
|
||||
s := &cloud.Service[core.State]{State: core.State{AdminOrg: "admin", AuditStore: rec}}
|
||||
app.Get("/v1/admin/audit", core.Guard(s, Records))
|
||||
app.Get("/v1/admin/audit/verify", core.Guard(s, Verify))
|
||||
fa := app.Fiber()
|
||||
|
||||
do := func(method, p string, hdr map[string]string) (*http.Response, []byte) {
|
||||
@@ -57,17 +56,17 @@ func mountWithStore(t *testing.T) (*audit.Recorder, func(method, path string, hd
|
||||
return rec, do
|
||||
}
|
||||
|
||||
func seedAudit(t *testing.T, rec *audit.Recorder, n int) {
|
||||
func seedAudit(t *testing.T, rec *auditstore.Recorder, n int) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
for i := 0; i < n; i++ {
|
||||
_, err := rec.Append(ctx, audit.Record{
|
||||
_, err := rec.Append(ctx, auditstore.Record{
|
||||
Time: time.Now().UTC(),
|
||||
Actor: audit.Actor{Org: "admin", Sub: "z@hanzo.ai"},
|
||||
Actor: auditstore.Actor{Org: "admin", Sub: "z@hanzo.ai"},
|
||||
Action: "DELETE /v1/admin/orgs",
|
||||
Resource: audit.Resource{Type: "org", ID: "acme"},
|
||||
Auth: audit.AuthContext{Method: "jwt", IsAdmin: true},
|
||||
Outcome: audit.Outcome{Result: "success", Status: 200},
|
||||
Resource: auditstore.Resource{Type: "org", ID: "acme"},
|
||||
Auth: auditstore.AuthContext{Method: "jwt", IsAdmin: true},
|
||||
Outcome: auditstore.Outcome{Result: "success", Status: 200},
|
||||
Method: "DELETE",
|
||||
Path: "/v1/admin/orgs/acme",
|
||||
})
|
||||
@@ -77,15 +76,15 @@ func seedAudit(t *testing.T, rec *audit.Recorder, n int) {
|
||||
}
|
||||
}
|
||||
|
||||
var globalAdmin = map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "admin", "X-User-Id": "z@hanzo.ai"}
|
||||
var superAdmin = map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "admin", "X-User-Id": "z@hanzo.ai"}
|
||||
|
||||
// TestAdminAudit_ReturnsRealRecords proves GET /v1/admin/audit returns the
|
||||
// store's records (newest-first) with an accurate total and an integrity summary.
|
||||
// TestAdminAudit_ReturnsRealRecords proves GET /v1/admin/audit returns the store's
|
||||
// records (newest-first) with an accurate total and an integrity summary.
|
||||
func TestAdminAudit_ReturnsRealRecords(t *testing.T) {
|
||||
rec, do := mountWithStore(t)
|
||||
seedAudit(t, rec, 5)
|
||||
|
||||
resp, body := do("GET", "/v1/admin/audit", globalAdmin)
|
||||
resp, body := do("GET", "/v1/admin/audit", superAdmin)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("audit: got %d (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
@@ -124,10 +123,10 @@ func TestAdminAudit_Filters(t *testing.T) {
|
||||
rec, do := mountWithStore(t)
|
||||
ctx := context.Background()
|
||||
// One deny among successes.
|
||||
_, _ = rec.Append(ctx, audit.Record{Action: "POST /v1/admin/roles", Actor: audit.Actor{Org: "admin"}, Outcome: audit.Outcome{Result: "deny", Status: 403}})
|
||||
_, _ = rec.Append(ctx, auditstore.Record{Action: "POST /v1/admin/roles", Actor: auditstore.Actor{Org: "admin"}, Outcome: auditstore.Outcome{Result: "deny", Status: 403}})
|
||||
seedAudit(t, rec, 3)
|
||||
|
||||
resp, body := do("GET", "/v1/admin/audit?result=deny", globalAdmin)
|
||||
resp, body := do("GET", "/v1/admin/audit?result=deny", superAdmin)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("got %d (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
@@ -144,13 +143,13 @@ func TestAdminAudit_Filters(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminAudit_VerifyEndpoint proves GET /v1/admin/audit/verify returns the
|
||||
// integrity result for the chain.
|
||||
// TestAdminAudit_VerifyEndpoint proves GET /v1/admin/audit/verify returns the integrity
|
||||
// result for the chain.
|
||||
func TestAdminAudit_VerifyEndpoint(t *testing.T) {
|
||||
rec, do := mountWithStore(t)
|
||||
seedAudit(t, rec, 8)
|
||||
|
||||
resp, body := do("GET", "/v1/admin/audit/verify", globalAdmin)
|
||||
resp, body := do("GET", "/v1/admin/audit/verify", superAdmin)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("verify: got %d (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
@@ -173,12 +172,9 @@ func TestAdminAudit_VerifyEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminAudit_DeniedWithoutGlobalAdmin proves BOTH audit endpoints fail-closed
|
||||
// 403 for a non-global-admin, and — critically — the store is NEVER read on a
|
||||
// denied request (the gate runs before the handler, so no records leak to an
|
||||
// unauthorized caller). We assert non-leakage by seeding records and confirming
|
||||
// the denied response body contains none of them.
|
||||
func TestAdminAudit_DeniedWithoutGlobalAdmin(t *testing.T) {
|
||||
// TestAdminAudit_DeniedWithoutSuperAdmin proves BOTH audit endpoints fail-closed 403 for
|
||||
// a non-SuperAdmin, and — critically — the store is NEVER read on a denied request.
|
||||
func TestAdminAudit_DeniedWithoutSuperAdmin(t *testing.T) {
|
||||
rec, do := mountWithStore(t)
|
||||
seedAudit(t, rec, 3)
|
||||
|
||||
@@ -204,17 +200,14 @@ func TestAdminAudit_DeniedWithoutGlobalAdmin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminAudit_FallsBackToIAMWhenNoStore proves that when no local store is
|
||||
// configured (auditStore == nil), /v1/admin/audit still serves the federated IAM
|
||||
// view rather than erroring — preserving the prior capability. Covered by the
|
||||
// existing TestAudit_MapsRecords (IAM proxy path); here we assert the nil-store
|
||||
// verify endpoint reports "not configured" rather than panicking.
|
||||
// TestAdminAudit_VerifyWithoutStore proves the nil-store verify endpoint reports "not
|
||||
// configured" rather than panicking.
|
||||
func TestAdminAudit_VerifyWithoutStore(t *testing.T) {
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
s := &cloud.Service[state]{State: state{adminOrg: "admin"}} // no auditStore
|
||||
app.Get("/v1/admin/audit/verify", guard(s, auditVerify))
|
||||
s := &cloud.Service[core.State]{State: core.State{AdminOrg: "admin"}} // no auditStore
|
||||
app.Get("/v1/admin/audit/verify", core.Guard(s, Verify))
|
||||
req := httptest.NewRequest("GET", "/v1/admin/audit/verify", nil)
|
||||
for k, v := range globalAdmin {
|
||||
for k, v := range superAdmin {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := app.Fiber().Test(req, fiber.TestConfig{Timeout: 30 * time.Second})
|
||||
+10
-9
@@ -30,6 +30,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/admin/core"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
@@ -62,7 +63,7 @@ func baseAdminConfig() (base, token string, ok bool) {
|
||||
// baseProxy issues a server-authed GET to the Base admin surface and returns its raw JSON
|
||||
// body + status. Bounded read; Bearer token only when configured; never forwards a client
|
||||
// header.
|
||||
func baseProxy(s *cloud.Service[state], ctx context.Context, target, token string) (json.RawMessage, int, error) {
|
||||
func baseProxy(s *cloud.Service[core.State], ctx context.Context, target, token string) (json.RawMessage, int, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("base request: %w", err)
|
||||
@@ -85,8 +86,8 @@ func baseProxy(s *cloud.Service[state], ctx context.Context, target, token strin
|
||||
|
||||
// bases answers GET /v1/admin/bases — the scoped Base-instance list. Honest empty when the
|
||||
// engine is unconfigured; scope-filtered for a non-super caller.
|
||||
func bases(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
sc := resolveScope(s, c)
|
||||
func bases(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
sc := core.ResolveScope(s, c)
|
||||
base, token, ok := baseAdminConfig()
|
||||
if !ok {
|
||||
return c.JSON(200, map[string]any{
|
||||
@@ -97,8 +98,8 @@ func bases(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
})
|
||||
}
|
||||
q := url.Values{}
|
||||
if !sc.super && len(sc.orgs) > 0 {
|
||||
q.Set("org", sc.orgs[0]) // defense 1: server-side narrowing to the caller's org
|
||||
if !sc.Super && len(sc.Orgs) > 0 {
|
||||
q.Set("org", sc.Orgs[0]) // defense 1: server-side narrowing to the caller's org
|
||||
}
|
||||
target := base + "/v1/base/instances"
|
||||
if enc := q.Encode(); enc != "" {
|
||||
@@ -106,20 +107,20 @@ func bases(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
}
|
||||
raw, code, err := baseProxy(s, c.Context(), target, token)
|
||||
if err != nil {
|
||||
return fail(c, err.Error())
|
||||
return core.Fail(c, err.Error())
|
||||
}
|
||||
if code/100 != 2 {
|
||||
return fail(c, fmt.Sprintf("base engine returned http %d", code))
|
||||
return core.Fail(c, fmt.Sprintf("base engine returned http %d", code))
|
||||
}
|
||||
// Defense 2: re-check every row against the resolved scope. A scoped caller NEVER
|
||||
// sees a row outside their subtree even if the upstream ignored ?org=.
|
||||
out := make([]baseInstance, 0)
|
||||
for _, r := range decodeInstances(raw) {
|
||||
if sc.scopedToOrg(r.Org) {
|
||||
if sc.ScopedToOrg(r.Org) {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return okList(c, out, len(out))
|
||||
return core.OKList(c, out, len(out))
|
||||
}
|
||||
|
||||
// decodeInstances tolerates BOTH a bare JSON array and a { data: [...] } envelope (the two
|
||||
|
||||
+104
-16
@@ -15,6 +15,11 @@ import (
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/audit"
|
||||
fiber "github.com/zap-proto/fiber/v3"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/admin/commerce"
|
||||
"github.com/hanzoai/cloud/clients/admin/core"
|
||||
"github.com/hanzoai/cloud/clients/admin/customer"
|
||||
"github.com/hanzoai/cloud/clients/admin/revenue"
|
||||
)
|
||||
|
||||
// ── rich stateful fakes for the customer-management surfaces ──────────────────
|
||||
@@ -24,7 +29,7 @@ import (
|
||||
type cockpitFakes struct {
|
||||
iam *httptest.Server
|
||||
commerce *httptest.Server
|
||||
service *cloud.Service[state]
|
||||
service *cloud.Service[core.State]
|
||||
do func(method, path string, hdr map[string]string, body string) (*http.Response, []byte)
|
||||
|
||||
mu sync.Mutex
|
||||
@@ -38,9 +43,10 @@ type depositCapture struct {
|
||||
org string
|
||||
user string
|
||||
amount int64
|
||||
idem string // X-Idempotency-Key commerce received (empty when none forwarded)
|
||||
}
|
||||
|
||||
// adminHdr is a validated global-admin identity (what SanitizeIdentity mints for
|
||||
// adminHdr is a validated SuperAdmin identity (what SanitizeIdentity mints for
|
||||
// owner==AdminOrg) plus a replayable credential.
|
||||
func adminHdr() map[string]string {
|
||||
return map[string]string{
|
||||
@@ -64,14 +70,14 @@ func newCockpitFakes(t *testing.T) *cockpitFakes {
|
||||
// Signup + usage dates relative to now so analytics windows include them.
|
||||
acmeCreated := now.AddDate(0, 0, -45).Format(time.RFC3339)
|
||||
globexCreated := now.AddDate(0, 0, -20).Format(time.RFC3339)
|
||||
usage := map[string][]txn{
|
||||
usage := map[string][]commerce.Entry{
|
||||
"acme": {
|
||||
{ID: "t1", Type: "withdraw", Amount: 100, Currency: "usd", CreatedAt: now.AddDate(0, 0, -40).Format(time.RFC3339)},
|
||||
{ID: "t2", Type: "withdraw", Amount: 200, Currency: "usd", CreatedAt: now.AddDate(0, 0, -5).Format(time.RFC3339)},
|
||||
{ID: "t3", Type: "deposit", Amount: 20000, Currency: "usd", CreatedAt: now.AddDate(0, 0, -46).Format(time.RFC3339)},
|
||||
{ID: "t1", Kind: "withdraw", Amount: 100, Currency: "usd", At: now.AddDate(0, 0, -40).Format(time.RFC3339)},
|
||||
{ID: "t2", Kind: "withdraw", Amount: 200, Currency: "usd", At: now.AddDate(0, 0, -5).Format(time.RFC3339)},
|
||||
{ID: "t3", Kind: "deposit", Amount: 20000, Currency: "usd", At: now.AddDate(0, 0, -46).Format(time.RFC3339)},
|
||||
},
|
||||
"globex": {
|
||||
{ID: "t4", Type: "withdraw", Amount: 400, Currency: "usd", CreatedAt: now.AddDate(0, 0, -3).Format(time.RFC3339)},
|
||||
{ID: "t4", Kind: "withdraw", Amount: 400, Currency: "usd", At: now.AddDate(0, 0, -3).Format(time.RFC3339)},
|
||||
},
|
||||
}
|
||||
// users per org (owner/name): forbidden read live from f.forbidden.
|
||||
@@ -170,7 +176,7 @@ func newCockpitFakes(t *testing.T) *cockpitFakes {
|
||||
_ = json.Unmarshal(body, &req)
|
||||
f.mu.Lock()
|
||||
f.balances[org] += req.Amount
|
||||
f.deposits = append(f.deposits, depositCapture{org: org, user: req.User, amount: req.Amount})
|
||||
f.deposits = append(f.deposits, depositCapture{org: org, user: req.User, amount: req.Amount, idem: r.Header.Get("X-Idempotency-Key")})
|
||||
f.mu.Unlock()
|
||||
w.WriteHeader(201)
|
||||
fmt.Fprintf(w, `{"transactionId":"dep-%d","user":%q,"amount":%d,"currency":%q,"type":"deposit"}`, req.Amount, req.User, req.Amount, req.Currency)
|
||||
@@ -233,8 +239,8 @@ func TestCustomers_ListRealFleet(t *testing.T) {
|
||||
t.Fatalf("customers: %d (%s)", resp.StatusCode, body)
|
||||
}
|
||||
var env struct {
|
||||
Data []customerRow `json:"data"`
|
||||
Data2 int `json:"data2"`
|
||||
Data []customer.CustomerRow `json:"data"`
|
||||
Data2 int `json:"data2"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
@@ -266,7 +272,7 @@ func TestCustomerDetail_RealAndNoSecretLeak(t *testing.T) {
|
||||
t.Fatalf("SECRET LEAK: the access key value appears in the customer detail response")
|
||||
}
|
||||
var env struct {
|
||||
Data customerDetailData `json:"data"`
|
||||
Data customer.CustomerDetailData `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
@@ -283,7 +289,7 @@ func TestCustomerDetail_RealAndNoSecretLeak(t *testing.T) {
|
||||
t.Fatalf("want 2 users, got %d", len(d.Users))
|
||||
}
|
||||
// The users carry hasApiKey (presence) but NO key value field exists in the type.
|
||||
var anna *customerUser
|
||||
var anna *customer.CustomerUser
|
||||
for i := range d.Users {
|
||||
if d.Users[i].Name == "anna" {
|
||||
anna = &d.Users[i]
|
||||
@@ -307,7 +313,7 @@ func TestGrantCredit_DepositLandsAndAudited(t *testing.T) {
|
||||
t.Fatalf("audit open: %v", err)
|
||||
}
|
||||
defer rec.Close()
|
||||
f.service.State.auditStore = rec
|
||||
f.service.State.AuditStore = rec
|
||||
|
||||
resp, body := f.do("POST", "/v1/admin/customers/acme/credit", adminHdr(), `{"amountCents":5000,"reason":"support comp"}`)
|
||||
if resp.StatusCode != 200 {
|
||||
@@ -383,6 +389,88 @@ func TestGrantCredit_Validation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantCredit_NilAuditStoreFailsClosed proves the SOC2 durability guarantee: a
|
||||
// credit grant is REFUSED (503) — with NO money moved — on a deployment that has no
|
||||
// durable audit store to record it into. newCockpitFakes attaches no AuditStore, so a
|
||||
// valid grant must fail closed rather than silently move money with no cloud-side record.
|
||||
func TestGrantCredit_NilAuditStoreFailsClosed(t *testing.T) {
|
||||
f := newCockpitFakes(t)
|
||||
if f.service.State.AuditStore != nil {
|
||||
t.Fatal("precondition: cockpit fakes must start with a nil AuditStore")
|
||||
}
|
||||
|
||||
resp, body := f.do("POST", "/v1/admin/customers/acme/credit", adminHdr(), `{"amountCents":5000,"reason":"support comp"}`)
|
||||
if resp.StatusCode != 503 {
|
||||
t.Fatalf("nil-audit-store grant: status %d, want 503 (fail-closed) — body=%s", resp.StatusCode, body)
|
||||
}
|
||||
if !strings.Contains(string(body), `"error"`) {
|
||||
t.Errorf("expected error envelope, got %s", body)
|
||||
}
|
||||
// The money must NOT have moved — no deposit reached commerce.
|
||||
f.mu.Lock()
|
||||
n := len(f.deposits)
|
||||
bal := f.balances["acme"]
|
||||
f.mu.Unlock()
|
||||
if n != 0 {
|
||||
t.Errorf("unaudited grant must NOT deposit, but %d landed", n)
|
||||
}
|
||||
if bal != 20000 {
|
||||
t.Errorf("acme balance = %d after refused grant, want unchanged 20000", bal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantCredit_IdempotencyKeyForwarded proves the double-credit guard: when the
|
||||
// operator supplies an Idempotency-Key, cloud forwards a DETERMINISTIC X-Idempotency-Key
|
||||
// to commerce (so a commit-then-timeout retry dedupes there), the SAME nonce yields the
|
||||
// SAME key (a retry lands nothing new), a DIFFERENT nonce yields a DIFFERENT key, and NO
|
||||
// nonce forwards no key (the additive default).
|
||||
func TestGrantCredit_IdempotencyKeyForwarded(t *testing.T) {
|
||||
f := newCockpitFakes(t)
|
||||
rec, err := audit.Open(":memory:", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("audit open: %v", err)
|
||||
}
|
||||
defer rec.Close()
|
||||
f.service.State.AuditStore = rec
|
||||
|
||||
grant := func(hdr map[string]string) string {
|
||||
resp, body := f.do("POST", "/v1/admin/customers/acme/credit", hdr, `{"amountCents":5000,"reason":"support comp"}`)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("credit: %d (%s)", resp.StatusCode, body)
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if len(f.deposits) == 0 {
|
||||
t.Fatal("no deposit captured")
|
||||
}
|
||||
return f.deposits[len(f.deposits)-1].idem
|
||||
}
|
||||
|
||||
withKey := func(k string) map[string]string {
|
||||
h := adminHdr()
|
||||
h["Idempotency-Key"] = k
|
||||
return h
|
||||
}
|
||||
|
||||
// A supplied nonce is forwarded as a non-empty, deterministic X-Idempotency-Key.
|
||||
k1 := grant(withKey("op-nonce-1"))
|
||||
if k1 == "" {
|
||||
t.Fatal("Idempotency-Key supplied but commerce received no X-Idempotency-Key")
|
||||
}
|
||||
// The SAME nonce (a retry) → the SAME key, so commerce dedupes and lands nothing new.
|
||||
if k1b := grant(withKey("op-nonce-1")); k1b != k1 {
|
||||
t.Errorf("same nonce must yield same key: %q vs %q", k1, k1b)
|
||||
}
|
||||
// A DIFFERENT nonce → a DIFFERENT key (a genuinely new grant is never dropped).
|
||||
if k2 := grant(withKey("op-nonce-2")); k2 == k1 {
|
||||
t.Errorf("distinct nonce must yield distinct key, both = %q", k1)
|
||||
}
|
||||
// No nonce → no key forwarded (additive default preserved).
|
||||
if k0 := grant(adminHdr()); k0 != "" {
|
||||
t.Errorf("no nonce must forward no key, got %q", k0)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSuspendReactivate_ForbidsUsersAndAudits proves suspend flips IAM isForbidden
|
||||
// on every org user (the real access lever) and is audited, and reactivate reverses
|
||||
// it — the customer's status reflects the change on a re-list.
|
||||
@@ -390,7 +478,7 @@ func TestSuspendReactivate_ForbidsUsersAndAudits(t *testing.T) {
|
||||
f := newCockpitFakes(t)
|
||||
rec, _ := audit.Open(":memory:", nil)
|
||||
defer rec.Close()
|
||||
f.service.State.auditStore = rec
|
||||
f.service.State.AuditStore = rec
|
||||
|
||||
// Suspend acme.
|
||||
resp, body := f.do("POST", "/v1/admin/customers/acme/suspend", adminHdr(), "")
|
||||
@@ -407,7 +495,7 @@ func TestSuspendReactivate_ForbidsUsersAndAudits(t *testing.T) {
|
||||
// A re-list shows acme suspended (all users forbidden).
|
||||
_, lb := f.do("GET", "/v1/admin/customers", adminHdr(), "")
|
||||
var env struct {
|
||||
Data []customerRow `json:"data"`
|
||||
Data []customer.CustomerRow `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(lb, &env)
|
||||
for _, c := range env.Data {
|
||||
@@ -440,7 +528,7 @@ func TestRevenue_RealAggregate(t *testing.T) {
|
||||
t.Fatalf("revenue: %d (%s)", resp.StatusCode, body)
|
||||
}
|
||||
var env struct {
|
||||
Data revenueData `json:"data"`
|
||||
Data revenue.RevenueData `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
|
||||
@@ -1,442 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/commerceinproc"
|
||||
)
|
||||
|
||||
// commerceClient reads the commerce billing S2S surface (/v1/billing/*, /v1/costs)
|
||||
// for the money panels (spend, tokens, credits, COGS). Commerce runs as its own
|
||||
// deployment; these are HTTP calls authenticated with the admin-scoped
|
||||
// COMMERCE_SERVICE_TOKEN (a KMS-sourced secret already on the cloud env — never
|
||||
// hard-coded here). PER-ORG reads (balance/usage-rollup/subscriptions) resolve the
|
||||
// org's billing namespace from the TRUSTED X-Org-Id header — commerce's EdgeAuth
|
||||
// trusts it ONLY when the bearer is the service token — and key the wallet under the
|
||||
// bare org slug (`user`). The fleet-wide /v1/costs god-view is org-INDEPENDENT
|
||||
// (DigitalOcean + provider vendor bills) and sends NO org, so commerce falls back to
|
||||
// its own service namespace (COMMERCE_SERVICE_ORG) there. (An earlier revision sent
|
||||
// X-IAM-Org-Id, which commerce does NOT read — every per-org money panel read $0.)
|
||||
type commerceClient struct {
|
||||
base string // e.g. http://commerce.hanzo.svc.cluster.local:8001
|
||||
token string // admin S2S bearer (secret; never logged)
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func newCommerceClient(base, token string) *commerceClient {
|
||||
return &commerceClient{
|
||||
base: strings.TrimRight(strings.TrimSpace(base), "/"),
|
||||
token: strings.TrimSpace(token),
|
||||
http: commerceinproc.Client(15 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *commerceClient) configured() bool { return c != nil && c.base != "" }
|
||||
|
||||
// rollup is the org-scoped billing view commerce serves at /v1/billing/usage-rollup.
|
||||
// Cents are the canonical unit; consumedCents is the org's month-to-date spend.
|
||||
type rollup struct {
|
||||
ConsumedCents int64 `json:"consumedCents"`
|
||||
OverageCents int64 `json:"overageCents"`
|
||||
Balance struct {
|
||||
BalanceCents int64 `json:"balanceCents"`
|
||||
AvailableCents int64 `json:"availableCents"`
|
||||
} `json:"balance"`
|
||||
}
|
||||
|
||||
// usageRollup fetches the current-month rollup for one billing subject (an IAM
|
||||
// "org/user" identity) in org `org`. commerce keys usage per user; the operator
|
||||
// aggregates across an org's users when a full breakdown is needed. Returns a
|
||||
// zero rollup (not an error) when commerce is not configured so a partial deploy
|
||||
// degrades to honest zeros rather than a 5xx.
|
||||
func (c *commerceClient) usageRollup(ctx context.Context, org, user string) (rollup, error) {
|
||||
var out rollup
|
||||
if !c.configured() {
|
||||
return out, nil
|
||||
}
|
||||
q := url.Values{"user": {user}}
|
||||
body, err := c.get(ctx, "/v1/billing/usage-rollup", q, org)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return out, fmt.Errorf("commerce rollup decode: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// balanceAll is the org's prepaid credit balance across currencies (cents).
|
||||
// Sourced from /v1/billing/balance/all for the "Credits" tile.
|
||||
type balanceAll struct {
|
||||
Balances map[string]struct {
|
||||
Available int64 `json:"available"`
|
||||
Balance int64 `json:"balance"`
|
||||
} `json:"balances"`
|
||||
}
|
||||
|
||||
// creditsCents returns the org's available credit balance in USD cents. Zero
|
||||
// (not an error) when commerce is unconfigured.
|
||||
func (c *commerceClient) creditsCents(ctx context.Context, org, user string) (int64, error) {
|
||||
if !c.configured() {
|
||||
return 0, nil
|
||||
}
|
||||
q := url.Values{"user": {user}, "currency": {"usd"}}
|
||||
body, err := c.get(ctx, "/v1/billing/balance", q, org)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var b struct {
|
||||
Available int64 `json:"available"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &b); err != nil {
|
||||
return 0, fmt.Errorf("commerce balance decode: %w", err)
|
||||
}
|
||||
return b.Available, nil
|
||||
}
|
||||
|
||||
// subscriptionsWire is the /v1/billing/subscriptions list shape the MRR + plan
|
||||
// readers fold over. Only the fields we need are decoded (status + plan
|
||||
// name/price/interval); commerce emits plan.price in the currency's minor unit
|
||||
// (cents) per its wire.
|
||||
type subscriptionsWire struct {
|
||||
Subscriptions []struct {
|
||||
Status string `json:"status"`
|
||||
Plan struct {
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
Price int64 `json:"price"`
|
||||
Currency string `json:"currency"`
|
||||
Interval string `json:"interval"`
|
||||
} `json:"plan"`
|
||||
} `json:"subscriptions"`
|
||||
}
|
||||
|
||||
// subSummary is the plan + MRR view of an org's subscriptions in ONE read: the
|
||||
// active plan name (the customer's tier), the normalized monthly recurring cents,
|
||||
// and whether any subscription is active. "pay-as-you-go" is the honest default
|
||||
// for a metered customer with no active subscription (not a fabricated tier).
|
||||
type subSummary struct {
|
||||
Plan string // active plan name, else "pay-as-you-go"
|
||||
MRR int64 // monthly-normalized recurring cents from active subs
|
||||
Active bool // any active/trialing subscription present
|
||||
}
|
||||
|
||||
// subscriptionSummary reads /v1/billing/subscriptions ONCE and derives both the
|
||||
// plan tier and the MRR contribution, so the customer list/detail and the revenue
|
||||
// board share a single upstream read (DRY). Only "active"/"trialing" subscriptions
|
||||
// count; canceled/past-due do not. An honest zero/"pay-as-you-go" (not an error)
|
||||
// when commerce is unconfigured, so a partial deploy degrades to honest values.
|
||||
func (c *commerceClient) subscriptionSummary(ctx context.Context, org, user string) (subSummary, error) {
|
||||
sum := subSummary{Plan: "pay-as-you-go"}
|
||||
if !c.configured() {
|
||||
return sum, nil
|
||||
}
|
||||
q := url.Values{"user": {user}}
|
||||
body, err := c.get(ctx, "/v1/billing/subscriptions", q, org)
|
||||
if err != nil {
|
||||
return sum, err
|
||||
}
|
||||
var w subscriptionsWire
|
||||
if err := json.Unmarshal(body, &w); err != nil {
|
||||
return sum, fmt.Errorf("commerce subscriptions decode: %w", err)
|
||||
}
|
||||
for _, s := range w.Subscriptions {
|
||||
switch strings.ToLower(strings.TrimSpace(s.Status)) {
|
||||
case "active", "trialing":
|
||||
sum.MRR += monthlyNormalizedCents(s.Plan.Price, s.Plan.Interval)
|
||||
sum.Active = true
|
||||
if name := strings.TrimSpace(s.Plan.Name); name != "" && sum.Plan == "pay-as-you-go" {
|
||||
sum.Plan = name
|
||||
}
|
||||
}
|
||||
}
|
||||
return sum, nil
|
||||
}
|
||||
|
||||
// mrrCents returns the monthly-recurring-revenue contribution of org `org`'s
|
||||
// ACTIVE subscriptions (see subscriptionSummary). Kept as the narrow reader the
|
||||
// finance/revenue folds call; it delegates so there is ONE subscriptions decode.
|
||||
func (c *commerceClient) mrrCents(ctx context.Context, org, user string) (int64, error) {
|
||||
sum, err := c.subscriptionSummary(ctx, org, user)
|
||||
return sum.MRR, err
|
||||
}
|
||||
|
||||
// monthlyNormalizedCents normalizes a plan price to a monthly figure by its
|
||||
// billing interval so annual and monthly plans are comparable in one MRR sum.
|
||||
func monthlyNormalizedCents(priceCents int64, interval string) int64 {
|
||||
switch strings.ToLower(strings.TrimSpace(interval)) {
|
||||
case "year", "yearly", "annual", "annually":
|
||||
return priceCents / 12
|
||||
case "week", "weekly":
|
||||
return priceCents * 52 / 12
|
||||
case "day", "daily":
|
||||
return priceCents * 365 / 12
|
||||
default: // month/monthly and anything unrecognized → treat as monthly
|
||||
return priceCents
|
||||
}
|
||||
}
|
||||
|
||||
// vendorCost mirrors commerce's api/costs.VendorCost — one line of what WE pay a
|
||||
// vendor for a service in a period (COGS, USD cents). Decoded verbatim from
|
||||
// GET /v1/costs so the finance board renders the per-vendor breakdown without
|
||||
// re-deriving any cost cloud-side.
|
||||
type vendorCost struct {
|
||||
Vendor string `json:"vendor"`
|
||||
Service string `json:"service"`
|
||||
AmountCents int64 `json:"amountCents"`
|
||||
Source string `json:"source"` // "actual" | "estimated"
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// costReport is the GET /v1/costs response: every vendor COGS line for a period
|
||||
// plus the total. TotalCents is the platform's whole COGS (DigitalOcean compute +
|
||||
// the LLM providers we resell) — the single figure the finance margin math folds.
|
||||
type costReport struct {
|
||||
Period string `json:"period"`
|
||||
Vendors []vendorCost `json:"vendors"`
|
||||
TotalCents int64 `json:"totalCents"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
// costs reads commerce's vendor-COGS god-view (GET /v1/costs) for a period — the
|
||||
// SINGLE source of truth for what we pay every vendor. It authenticates with the
|
||||
// admin S2S service token (COMMERCE_SERVICE_TOKEN, no IAM user identity), which
|
||||
// commerce's requireCostsAdmin admits on its M2M path (Admin bit + empty Subject).
|
||||
//
|
||||
// This is a PLATFORM god-view, deliberately NOT per-org, so NO org selector is
|
||||
// sent: the DigitalOcean compute and OpenAI COGS lines are read from the vendor
|
||||
// billing APIs (global, org-independent) and the metered LLM estimates come from
|
||||
// commerce's own service namespace (COMMERCE_SERVICE_ORG) — which commerce resolves
|
||||
// from its service-token config, never from a request header. Returns a zero report
|
||||
// (not an error) when commerce is unconfigured so a partial deploy degrades to
|
||||
// honest zeros rather than a 5xx.
|
||||
func (c *commerceClient) costs(ctx context.Context, period string) (costReport, error) {
|
||||
var out costReport
|
||||
if !c.configured() {
|
||||
return out, nil
|
||||
}
|
||||
q := url.Values{}
|
||||
if period != "" {
|
||||
q.Set("period", period)
|
||||
}
|
||||
// Empty org: /v1/costs is fleet-wide; commerce uses COMMERCE_SERVICE_ORG.
|
||||
body, err := c.get(ctx, "/v1/costs", q, "")
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return out, fmt.Errorf("commerce costs decode: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// depositResult is the /v1/billing/deposit 201 response — the transaction id of
|
||||
// the credit that landed. The operator surfaces it as the receipt of a grant.
|
||||
type depositResult struct {
|
||||
TransactionID string `json:"transactionId"`
|
||||
User string `json:"user"`
|
||||
Amount int64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
// deposit grants credit to an org's wallet by creating a commerce Deposit
|
||||
// transaction (POST /v1/billing/deposit). This is the ONE money-in primitive the
|
||||
// admin credit action uses (refunds/comps/support) — it is symmetric with the
|
||||
// balance READ: the same X-Org-Id=<org> namespace + `user`=<org> subject the
|
||||
// creditsCents/usageRollup reads resolve, so a grant lands exactly where the
|
||||
// balance panel reads it. Authenticated with the admin S2S COMMERCE_SERVICE_TOKEN
|
||||
// (commerce's /billing admin group), which is the same credential the reads use.
|
||||
// Commerce's EdgeAuth additionally pins the body `user` to the X-Org-Id subject,
|
||||
// so the grant can never be mis-targeted to another org's wallet. amountCents must
|
||||
// be positive (a grant, never a silent debit) — the handler validates + caps it.
|
||||
func (c *commerceClient) deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (depositResult, error) {
|
||||
var out depositResult
|
||||
if !c.configured() {
|
||||
return out, errUnconfigured
|
||||
}
|
||||
if currency == "" {
|
||||
currency = "usd"
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"user": user,
|
||||
"currency": currency,
|
||||
"amount": amountCents,
|
||||
"notes": notes,
|
||||
"tags": tags,
|
||||
})
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
respBody, err := c.post(ctx, "/v1/billing/deposit", org, body)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &out); err != nil {
|
||||
return out, fmt.Errorf("commerce deposit decode: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// txn is one commerce ledger row (GET /v1/billing/transactions). Cents is the
|
||||
// canonical unit; Type is "deposit" (credit) or "withdraw" (usage/consumption).
|
||||
// CreatedAt is the RFC3339 event time the analytics fold buckets on.
|
||||
type txn struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Amount int64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
Tags string `json:"tags,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// transactions reads an org's ledger (GET /v1/billing/transactions) for one
|
||||
// billing subject. The rows carry a real event timestamp + type, so the analytics
|
||||
// aggregator can derive signup-cohort retention, active-customer windows, churn,
|
||||
// and usage-over-time from actual consumption events — NOT a fabricated series.
|
||||
// `limit` bounds the read (the endpoint sorts newest-first). Returns an empty
|
||||
// slice (not an error) when commerce is unconfigured so a partial deploy degrades
|
||||
// to an honest empty history rather than a 5xx.
|
||||
func (c *commerceClient) transactions(ctx context.Context, org, user string, limit int) ([]txn, error) {
|
||||
if !c.configured() {
|
||||
return nil, nil
|
||||
}
|
||||
q := url.Values{"user": {user}}
|
||||
if limit > 0 {
|
||||
q.Set("limit", fmt.Sprintf("%d", limit))
|
||||
}
|
||||
body, err := c.get(ctx, "/v1/billing/transactions", q, org)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Commerce serves the ledger WRAPPED as { count, transactions:[...] } (verified
|
||||
// live). Decode that shape; tolerate a bare array too so a contract change in
|
||||
// either direction degrades gracefully rather than silently reading zero rows
|
||||
// (which would make the analytics honest-empty despite real usage).
|
||||
var wrap struct {
|
||||
Transactions []txn `json:"transactions"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &wrap); err == nil && wrap.Transactions != nil {
|
||||
return wrap.Transactions, nil
|
||||
}
|
||||
var rows []txn
|
||||
if err := json.Unmarshal(body, &rows); err != nil {
|
||||
return nil, fmt.Errorf("commerce transactions decode: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// post performs one admin-authenticated commerce POST (JSON body) and returns the
|
||||
// raw response. It carries the SAME trust context as get: the admin S2S service
|
||||
// token as the bearer and X-Org-Id=<org> as the per-org namespace selector that
|
||||
// commerce's EdgeAuth trusts only after verifying the service token. A non-2xx is
|
||||
// an error (the caller surfaces it honestly + records the failed attempt in the
|
||||
// audit trail — a grant that did not land is never reported as success).
|
||||
func (c *commerceClient) post(ctx context.Context, path, org string, body []byte) ([]byte, error) {
|
||||
u := c.base + path
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
if org != "" {
|
||||
req.Header.Set("X-Org-Id", org)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("commerce unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("commerce status %d", resp.StatusCode)
|
||||
}
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
// get performs one admin-authenticated commerce GET and returns the raw body.
|
||||
func (c *commerceClient) get(ctx context.Context, path string, q url.Values, org string) ([]byte, error) {
|
||||
u := c.base + path
|
||||
if enc := q.Encode(); enc != "" {
|
||||
u += "?" + enc
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
if org != "" {
|
||||
// Commerce's EdgeAuth (middleware/edgeauth.go) trusts X-Org-Id ONLY after it
|
||||
// verifies the bearer is the COMMERCE_SERVICE_TOKEN, then resolves the per-org
|
||||
// billing namespace from it. This is the service-to-service org selector.
|
||||
// X-IAM-Org-Id is NOT read by commerce — it silently resolved to the default
|
||||
// (COMMERCE_SERVICE_ORG) namespace, so every real org's balance/spend read $0.
|
||||
req.Header.Set("X-Org-Id", org)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("commerce unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("commerce status %d", resp.StatusCode)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// healthClient probes an upstream's /v1/o11y/health (or any health path) so the
|
||||
// overview can report System Health honestly. A non-2xx or unreachable upstream
|
||||
// is reported as not-ok — never masked.
|
||||
type healthClient struct {
|
||||
url string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func newHealthClient(u string) *healthClient {
|
||||
return &healthClient{url: strings.TrimSpace(u), http: &http.Client{Timeout: 8 * time.Second}}
|
||||
}
|
||||
|
||||
func (h *healthClient) configured() bool { return h != nil && h.url != "" }
|
||||
|
||||
// ok reports whether the o11y health endpoint answers 2xx.
|
||||
func (h *healthClient) ok(ctx context.Context) (bool, error) {
|
||||
if !h.configured() {
|
||||
return false, fmt.Errorf("o11y health not configured")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.url, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
resp, err := h.http.Do(req)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("o11y unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return false, fmt.Errorf("o11y health %d", resp.StatusCode)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// Package commerce is the admin cockpit's typed reader for the commerce billing
|
||||
// plane. It models the domain, not the endpoints: a billing subject (an org's
|
||||
// slug) has orthogonal, independently-readable facets —
|
||||
//
|
||||
// Spend — what it consumed this month
|
||||
// Credits — what prepaid balance it holds
|
||||
// Plan — its subscription tier + monthly-recurring revenue
|
||||
// Ledger — its transaction history
|
||||
//
|
||||
// plus one fleet god-view (Costs, our vendor COGS) and one write (Deposit, the
|
||||
// grant-credit primitive). Each read is total: an unwired or unreachable commerce
|
||||
// degrades to an honest zero, never a fabricated number.
|
||||
//
|
||||
// Commerce runs as its own deployment; these are HTTP calls authenticated with the
|
||||
// admin-scoped COMMERCE_SERVICE_TOKEN (a KMS-sourced secret already on the cloud
|
||||
// env — never hard-coded). A per-subject read resolves the org's billing namespace
|
||||
// from the TRUSTED X-Org-Id header (commerce's EdgeAuth trusts it only when the
|
||||
// bearer is the service token) AND keys the wallet under the bare slug — one value,
|
||||
// the subject, is both. The fleet Costs god-view is org-independent and sends no
|
||||
// subject.
|
||||
package commerce
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/admin/money"
|
||||
)
|
||||
|
||||
// errUnconfigured marks a write (Deposit) attempted against an unwired commerce.
|
||||
var errUnconfigured = errors.New("commerce not configured")
|
||||
|
||||
// Client reads the commerce billing plane.
|
||||
type Client struct {
|
||||
base string // e.g. http://commerce.hanzo.svc.cluster.local:8001
|
||||
token string // admin S2S bearer (secret; never logged)
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// New builds a commerce client for base + admin S2S token.
|
||||
func New(base, token string) *Client {
|
||||
return &Client{
|
||||
base: strings.TrimRight(strings.TrimSpace(base), "/"),
|
||||
token: strings.TrimSpace(token),
|
||||
http: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Ready reports whether a commerce endpoint is wired on this deployment.
|
||||
func (c *Client) Ready() bool { return c != nil && c.base != "" }
|
||||
|
||||
// Spend is a subject's month-to-date consumption.
|
||||
type Spend struct {
|
||||
Consumed money.Cents `json:"consumedCents"`
|
||||
Overage money.Cents `json:"overageCents"`
|
||||
}
|
||||
|
||||
// Spend reads a subject's month-to-date consumption (GET /v1/billing/usage-rollup).
|
||||
// Zero (not an error) when commerce is unwired, so a partial deploy degrades to
|
||||
// honest zeros.
|
||||
func (c *Client) Spend(ctx context.Context, subject string) (Spend, error) {
|
||||
var out Spend
|
||||
if !c.Ready() {
|
||||
return out, nil
|
||||
}
|
||||
body, err := c.get(ctx, "/v1/billing/usage-rollup", url.Values{"user": {subject}}, subject)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return out, fmt.Errorf("commerce spend decode: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Credits reads a subject's available prepaid credit (GET /v1/billing/balance).
|
||||
// Zero (not an error) when commerce is unwired.
|
||||
func (c *Client) Credits(ctx context.Context, subject string) (money.Cents, error) {
|
||||
if !c.Ready() {
|
||||
return 0, nil
|
||||
}
|
||||
q := url.Values{"user": {subject}, "currency": {"usd"}}
|
||||
body, err := c.get(ctx, "/v1/billing/balance", q, subject)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var b struct {
|
||||
Available money.Cents `json:"available"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &b); err != nil {
|
||||
return 0, fmt.Errorf("commerce credits decode: %w", err)
|
||||
}
|
||||
return b.Available, nil
|
||||
}
|
||||
|
||||
// Plan is a subject's subscription: the active tier, the monthly-normalized
|
||||
// recurring revenue, and whether any subscription is active. Name is
|
||||
// "pay-as-you-go" for a metered subject with no active subscription (the honest
|
||||
// default, never a fabricated tier).
|
||||
type Plan struct {
|
||||
Name string
|
||||
MRR money.Cents
|
||||
Active bool
|
||||
}
|
||||
|
||||
// subscriptionsWire is the /v1/billing/subscriptions list shape Plan folds over.
|
||||
type subscriptionsWire struct {
|
||||
Subscriptions []struct {
|
||||
Status string `json:"status"`
|
||||
Plan struct {
|
||||
Name string `json:"name"`
|
||||
Price money.Cents `json:"price"`
|
||||
Interval string `json:"interval"`
|
||||
} `json:"plan"`
|
||||
} `json:"subscriptions"`
|
||||
}
|
||||
|
||||
// Plan reads a subject's subscription tier + MRR in ONE decode (GET
|
||||
// /v1/billing/subscriptions), so the customer + revenue surfaces share a single
|
||||
// upstream read. Only "active"/"trialing" subscriptions count. Honest
|
||||
// zero/"pay-as-you-go" (not an error) when commerce is unwired.
|
||||
func (c *Client) Plan(ctx context.Context, subject string) (Plan, error) {
|
||||
out := Plan{Name: "pay-as-you-go"}
|
||||
if !c.Ready() {
|
||||
return out, nil
|
||||
}
|
||||
body, err := c.get(ctx, "/v1/billing/subscriptions", url.Values{"user": {subject}}, subject)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
var w subscriptionsWire
|
||||
if err := json.Unmarshal(body, &w); err != nil {
|
||||
return out, fmt.Errorf("commerce plan decode: %w", err)
|
||||
}
|
||||
for _, s := range w.Subscriptions {
|
||||
switch strings.ToLower(strings.TrimSpace(s.Status)) {
|
||||
case "active", "trialing":
|
||||
out.MRR += monthlyNormalized(s.Plan.Price, s.Plan.Interval)
|
||||
out.Active = true
|
||||
if name := strings.TrimSpace(s.Plan.Name); name != "" && out.Name == "pay-as-you-go" {
|
||||
out.Name = name
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// monthlyNormalized normalizes a plan price to a monthly figure by its billing
|
||||
// interval so annual and monthly plans are comparable in one MRR sum.
|
||||
func monthlyNormalized(price money.Cents, interval string) money.Cents {
|
||||
switch strings.ToLower(strings.TrimSpace(interval)) {
|
||||
case "year", "yearly", "annual", "annually":
|
||||
return price / 12
|
||||
case "week", "weekly":
|
||||
return price * 52 / 12
|
||||
case "day", "daily":
|
||||
return price * 365 / 12
|
||||
default: // month/monthly and anything unrecognized → treat as monthly
|
||||
return price
|
||||
}
|
||||
}
|
||||
|
||||
// Entry is one ledger row. Kind is "deposit" (credit) or "withdraw" (usage). At is
|
||||
// the RFC3339 event time analytics buckets on.
|
||||
type Entry struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"type"`
|
||||
Amount money.Cents `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
Tags string `json:"tags,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
At string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// Ledger reads a subject's transaction history (GET /v1/billing/transactions),
|
||||
// newest-first, bounded by limit. Empty (not an error) when commerce is unwired.
|
||||
func (c *Client) Ledger(ctx context.Context, subject string, limit int) ([]Entry, error) {
|
||||
if !c.Ready() {
|
||||
return nil, nil
|
||||
}
|
||||
q := url.Values{"user": {subject}}
|
||||
if limit > 0 {
|
||||
q.Set("limit", fmt.Sprintf("%d", limit))
|
||||
}
|
||||
body, err := c.get(ctx, "/v1/billing/transactions", q, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Commerce serves the ledger WRAPPED as { count, transactions:[...] }; tolerate a
|
||||
// bare array too so a contract change in either direction degrades gracefully.
|
||||
var wrap struct {
|
||||
Transactions []Entry `json:"transactions"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &wrap); err == nil && wrap.Transactions != nil {
|
||||
return wrap.Transactions, nil
|
||||
}
|
||||
var rows []Entry
|
||||
if err := json.Unmarshal(body, &rows); err != nil {
|
||||
return nil, fmt.Errorf("commerce ledger decode: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// Vendor is one line of what WE pay a vendor for a service in a period (COGS).
|
||||
type Vendor struct {
|
||||
Name string `json:"vendor"`
|
||||
Service string `json:"service"`
|
||||
Amount money.Cents `json:"amountCents"`
|
||||
Source string `json:"source"` // "actual" | "estimated"
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// Costs is the fleet COGS god-view: every vendor line for a period plus the total.
|
||||
type Costs struct {
|
||||
Period string `json:"period"`
|
||||
Vendors []Vendor `json:"vendors"`
|
||||
Total money.Cents `json:"totalCents"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
// Costs reads commerce's vendor-COGS god-view (GET /v1/costs) for a period — the
|
||||
// SINGLE source of truth for what we pay every vendor. It authenticates with the
|
||||
// admin S2S service token (no IAM user identity) and is org-INDEPENDENT, so it
|
||||
// sends no subject. Zero (not an error) when commerce is unwired.
|
||||
func (c *Client) Costs(ctx context.Context, period string) (Costs, error) {
|
||||
var out Costs
|
||||
if !c.Ready() {
|
||||
return out, nil
|
||||
}
|
||||
q := url.Values{}
|
||||
if period != "" {
|
||||
q.Set("period", period)
|
||||
}
|
||||
body, err := c.get(ctx, "/v1/costs", q, "")
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return out, fmt.Errorf("commerce costs decode: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Receipt is the result of a Deposit — the transaction id of the credit that landed.
|
||||
type Receipt struct {
|
||||
TxID string `json:"transactionId"`
|
||||
Amount money.Cents `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
// Deposit grants credit to a subject's wallet (POST /v1/billing/deposit) — the ONE
|
||||
// money-in primitive. Symmetric with Credits: the same X-Org-Id namespace + `user`
|
||||
// subject the reads resolve. amount must be positive (the handler validates + caps).
|
||||
//
|
||||
// idempotencyKey, when non-empty, is sent as X-Idempotency-Key so commerce dedupes a
|
||||
// retried deposit AT MOST ONCE (a completed key REPLAYS the stored receipt, an in-flight
|
||||
// key 409s; scoped billing-deposit:<subject>). This closes the commit-then-timeout double
|
||||
// -credit: cloud's 15s client can time out AFTER commerce committed, and a retry carrying
|
||||
// the SAME key lands nothing new. An EMPTY key preserves the additive default (distinct
|
||||
// deposits to the same subject are legitimately cumulative) — commerce never dedupes by
|
||||
// amount. See commerce api/billing/deposit.go.
|
||||
func (c *Client) Deposit(ctx context.Context, subject string, amount money.Cents, currency, notes, tags, idempotencyKey string) (Receipt, error) {
|
||||
var out Receipt
|
||||
if !c.Ready() {
|
||||
return out, errUnconfigured
|
||||
}
|
||||
if currency == "" {
|
||||
currency = "usd"
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"user": subject,
|
||||
"currency": currency,
|
||||
"amount": amount,
|
||||
"notes": notes,
|
||||
"tags": tags,
|
||||
})
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
respBody, err := c.post(ctx, "/v1/billing/deposit", subject, body, idempotencyKey)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &out); err != nil {
|
||||
return out, fmt.Errorf("commerce deposit decode: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// post performs one admin-authenticated commerce POST (JSON body) and returns the
|
||||
// raw response. The admin S2S service token is the bearer and X-Org-Id=<subject>
|
||||
// the per-org namespace selector commerce's EdgeAuth trusts only after verifying
|
||||
// the service token. idempotencyKey, when non-empty, is sent as X-Idempotency-Key so a
|
||||
// retried write dedupes at commerce. A non-2xx is an error the caller surfaces + audits
|
||||
// honestly.
|
||||
func (c *Client) post(ctx context.Context, path, subject string, body []byte, idempotencyKey string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
if subject != "" {
|
||||
req.Header.Set("X-Org-Id", subject)
|
||||
}
|
||||
if idempotencyKey != "" {
|
||||
req.Header.Set("X-Idempotency-Key", idempotencyKey)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("commerce unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("commerce status %d", resp.StatusCode)
|
||||
}
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
// get performs one admin-authenticated commerce GET and returns the raw body.
|
||||
func (c *Client) get(ctx context.Context, path string, q url.Values, subject string) ([]byte, error) {
|
||||
u := c.base + path
|
||||
if enc := q.Encode(); enc != "" {
|
||||
u += "?" + enc
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
if subject != "" {
|
||||
// Commerce's EdgeAuth trusts X-Org-Id ONLY after it verifies the bearer is the
|
||||
// COMMERCE_SERVICE_TOKEN, then resolves the per-org billing namespace from it.
|
||||
req.Header.Set("X-Org-Id", subject)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("commerce unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("commerce status %d", resp.StatusCode)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package commerce
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestMonthlyNormalizedCents proves annual/monthly normalization for MRR.
|
||||
func TestMonthlyNormalizedCents(t *testing.T) {
|
||||
if got := monthlyNormalized(12_000, "year"); got != 1_000 {
|
||||
t.Errorf("yearly $120 → monthly = %d, want 1000", got)
|
||||
}
|
||||
if got := monthlyNormalized(2_000, "month"); got != 2_000 {
|
||||
t.Errorf("monthly must pass through, got %d", got)
|
||||
}
|
||||
if got := monthlyNormalized(2_000, ""); got != 2_000 {
|
||||
t.Errorf("unknown interval must be treated as monthly, got %d", got)
|
||||
}
|
||||
}
|
||||
+12
-11
@@ -18,14 +18,14 @@ package admin
|
||||
// operator's Bots and Machines boards (admin.hanzo.ai) group into an
|
||||
// org → app → project tree. It aggregates the operator-owned usage table
|
||||
// hanzo.compute_usage(org, app, project, kind, event, machine_id, size,
|
||||
// price_cents, ts) — the same warehouse (`datastore`, ClickHouse) the analytics
|
||||
// price_cents, ts) — the same warehouse (`datastore`, datastore) the analytics
|
||||
// subsystem reads, over the SAME shared client (aiobject.DatastoreQuery), no
|
||||
// second connection. `kind` is an OPEN LowCardinality spectrum (bot | machine |
|
||||
// cluster | nodepool | container | function | …) — a bot is a machine running the
|
||||
// @hanzo/bot agent, a machine is raw compute visor opens — and each console lens
|
||||
// reuses this one endpoint with a different `?kind=` (Bots=bot, Machines=machine).
|
||||
//
|
||||
// GLOBAL-ADMIN ONLY (the s.guard wrap in admin.go), all-orgs by default; this is
|
||||
// SUPERADMIN ONLY (the s.guard wrap in admin.go), all-orgs by default; this is
|
||||
// an AGGREGATOR — admin holds no compute state, it only reads. Honest by
|
||||
// construction, exactly like the analytics events lens: no datastore connected, or
|
||||
// the events table not provisioned yet (the emitter is still being wired) → the
|
||||
@@ -39,6 +39,7 @@ import (
|
||||
|
||||
aiobject "github.com/hanzoai/ai/object"
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/admin/core"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
@@ -72,13 +73,13 @@ type computeLeaf struct {
|
||||
}
|
||||
|
||||
// compute answers GET /v1/admin/compute. ?kind=<kind> and ?org= narrow the
|
||||
// aggregate; ?range=24h|7d|30d bounds it (default 30d). Global-admin only.
|
||||
func compute(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
// aggregate; ?range=24h|7d|30d bounds it (default 30d). SuperAdmin only.
|
||||
func compute(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
// Honest-empty when the warehouse is not connected or the usage table is not
|
||||
// provisioned yet (the visor/commerce emitter is still being wired).
|
||||
if !aiobject.DatastoreEnabled() || !computeTableExists(ctx) {
|
||||
return okList(c, []computeLeaf{}, 0)
|
||||
return core.OKList(c, []computeLeaf{}, 0)
|
||||
}
|
||||
|
||||
// `kind` is an OPEN LowCardinality spectrum (bot | machine | cluster | nodepool |
|
||||
@@ -89,10 +90,10 @@ func compute(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
sql, args := buildComputeQuery(c.Query("range"), kind, strings.TrimSpace(c.Query("org")))
|
||||
rows, err := aiobject.DatastoreQuery(ctx, sql, args...)
|
||||
if err != nil {
|
||||
return fail(c, "compute query: "+err.Error())
|
||||
return core.Fail(c, "compute query: "+err.Error())
|
||||
}
|
||||
leaves := computeLeavesFromRows(rows)
|
||||
return okList(c, leaves, len(leaves))
|
||||
return core.OKList(c, leaves, len(leaves))
|
||||
}
|
||||
|
||||
// buildComputeQuery assembles the two-level roll-up (pure, so it is unit-tested).
|
||||
@@ -168,7 +169,7 @@ func computeSince(rangeLabel string) time.Time {
|
||||
}
|
||||
}
|
||||
|
||||
// terminalComputeSQL renders the terminal-event set as a ClickHouse string list.
|
||||
// terminalComputeSQL renders the terminal-event set as a datastore string list.
|
||||
func terminalComputeSQL() string {
|
||||
quoted := make([]string, len(terminalComputeEvents))
|
||||
for i, e := range terminalComputeEvents {
|
||||
@@ -177,12 +178,12 @@ func terminalComputeSQL() string {
|
||||
return strings.Join(quoted, ",")
|
||||
}
|
||||
|
||||
// chTS formats a time as a ClickHouse DateTime literal (UTC), bound as a string arg.
|
||||
// chTS formats a time as a datastore DateTime literal (UTC), bound as a string arg.
|
||||
func chTS(t time.Time) string { return t.UTC().Format("2006-01-02 15:04:05") }
|
||||
|
||||
// ── map[string]any coercers (the DatastoreQuery row shape) ───────────────────
|
||||
//
|
||||
// The ClickHouse driver decodes each column to its native Go type (uint64 for
|
||||
// The datastore driver decodes each column to its native Go type (uint64 for
|
||||
// count()/sum(UInt*), time.Time for DateTime, string for String); these accept
|
||||
// those natives so a driver/transport change can't crash a read.
|
||||
|
||||
@@ -220,7 +221,7 @@ func chStr(v any) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// chTime coerces a ClickHouse DateTime (time.Time) to an RFC3339 UTC string.
|
||||
// chTime coerces a datastore DateTime (time.Time) to an RFC3339 UTC string.
|
||||
func chTime(v any) string {
|
||||
switch t := v.(type) {
|
||||
case time.Time:
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package core
|
||||
|
||||
// The fleet ACTIVITY + TIME-SERIES model, shared by the analytics board and the revenue
|
||||
// board (one implementation, DRY): real per-customer signup + usage folded into a pure
|
||||
// activity value, and the continuous bucketed spend series both boards render.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/admin/iam"
|
||||
)
|
||||
|
||||
// SeriesPoint is one bucketed point (count OR cents, per the series). T is the bucket
|
||||
// key (RFC3339 date / "2006-01" month).
|
||||
type SeriesPoint struct {
|
||||
T string `json:"t"`
|
||||
Value int64 `json:"value"`
|
||||
}
|
||||
|
||||
// TxnPoint is one dated usage event (a commerce `withdraw`, in cents).
|
||||
type TxnPoint struct {
|
||||
T time.Time
|
||||
Cents int64
|
||||
}
|
||||
|
||||
// CustActivity is one customer's real analytics input: when they signed up (IAM
|
||||
// createdTime) and their consumption events (commerce withdraws). Deposits are NOT
|
||||
// activity (a credit grant is not the customer using the product), so only withdraws
|
||||
// feed active/retention/churn/usage — the honest "used it" signal.
|
||||
type CustActivity struct {
|
||||
Org string
|
||||
Display string
|
||||
Created time.Time
|
||||
HasCreated bool
|
||||
Usage []TxnPoint
|
||||
SpendCents int64
|
||||
}
|
||||
|
||||
// ActiveIn reports whether the customer had a usage event in the given bucket.
|
||||
func (ca CustActivity) ActiveIn(bucket string, interval string) bool {
|
||||
for _, p := range ca.Usage {
|
||||
if BucketKeyOf(p.T, interval) == bucket {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ActiveSince reports whether the customer had a usage event at or after cut.
|
||||
func (ca CustActivity) ActiveSince(cut time.Time) bool {
|
||||
for _, p := range ca.Usage {
|
||||
if !p.T.Before(cut) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// FleetActivity reads every org's signup time (already on the org row) + usage ledger,
|
||||
// folded into the pure activity model. Returns (acts, ok) where ok is false if ANY org's
|
||||
// ledger read failed (the caller marks the source degraded and flags the ledger-backed
|
||||
// metrics as not-fully-computed). Fanned out concurrently with a bound.
|
||||
func FleetActivity(s *cloud.Service[State], ctx context.Context, orgs []iam.Org) ([]CustActivity, bool) {
|
||||
acts := make([]CustActivity, len(orgs))
|
||||
oks := make([]bool, len(orgs))
|
||||
sem := make(chan struct{}, MaxCustomerConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
for i, o := range orgs {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(i int, o iam.Org) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
ca := CustActivity{Org: o.Name, Display: Display(o.DisplayName, o.Name)}
|
||||
if t, err := time.Parse(time.RFC3339, o.CreatedTime); err == nil {
|
||||
ca.Created = t.UTC()
|
||||
ca.HasCreated = true
|
||||
}
|
||||
rows, err := s.State.Commerce.Ledger(ctx, o.Name, 2000)
|
||||
oks[i] = err == nil
|
||||
for _, r := range rows {
|
||||
if strings.ToLower(r.Kind) != "withdraw" {
|
||||
continue // only consumption is "activity"; deposits are credits
|
||||
}
|
||||
t, perr := ParseTxnTime(r.At)
|
||||
if perr != nil {
|
||||
continue
|
||||
}
|
||||
amt := int64(r.Amount)
|
||||
if amt < 0 {
|
||||
amt = -amt
|
||||
}
|
||||
ca.Usage = append(ca.Usage, TxnPoint{T: t, Cents: amt})
|
||||
ca.SpendCents += amt
|
||||
}
|
||||
acts[i] = ca
|
||||
}(i, o)
|
||||
}
|
||||
wg.Wait()
|
||||
allOK := true
|
||||
for _, ok := range oks {
|
||||
if !ok {
|
||||
allOK = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return acts, allOK
|
||||
}
|
||||
|
||||
// SpendSeries buckets fleet usage cents into a continuous series over since..now. Shared
|
||||
// by the analytics usage/revenue trend and the revenue board's spend trend (one
|
||||
// implementation, DRY). A bucket with no usage is an honest 0, not a gap.
|
||||
func SpendSeries(acts []CustActivity, since, now time.Time, interval string) []SeriesPoint {
|
||||
buckets := EnumerateBuckets(since, now, interval)
|
||||
idx := IndexOf(buckets)
|
||||
out := make([]SeriesPoint, len(buckets))
|
||||
for i, b := range buckets {
|
||||
out[i] = SeriesPoint{T: b}
|
||||
}
|
||||
for _, a := range acts {
|
||||
for _, p := range a.Usage {
|
||||
if p.T.Before(since) || p.T.After(now) {
|
||||
continue
|
||||
}
|
||||
if i, ok := idx[BucketKeyOf(p.T, interval)]; ok {
|
||||
out[i].Value += p.Cents
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── pure time-bucket helpers ─────────────────────────────────────────────────
|
||||
|
||||
func MonthKey(t time.Time) string { return t.UTC().Format("2006-01") }
|
||||
func DayKey(t time.Time) string { return t.UTC().Format("2006-01-02") }
|
||||
|
||||
// WeekKey buckets to the ISO week's Monday (a stable weekly key).
|
||||
func WeekKey(t time.Time) string {
|
||||
u := t.UTC()
|
||||
// back up to Monday
|
||||
wd := int(u.Weekday())
|
||||
if wd == 0 {
|
||||
wd = 7
|
||||
}
|
||||
monday := u.AddDate(0, 0, -(wd - 1))
|
||||
return monday.Format("2006-01-02")
|
||||
}
|
||||
|
||||
func BucketKeyOf(t time.Time, interval string) string {
|
||||
switch interval {
|
||||
case "month":
|
||||
return MonthKey(t)
|
||||
case "week":
|
||||
return WeekKey(t)
|
||||
default:
|
||||
return DayKey(t)
|
||||
}
|
||||
}
|
||||
|
||||
// EnumerateBuckets lists every bucket key from since..now inclusive so a series has a
|
||||
// continuous axis (a zero-usage bucket is an honest 0, not a gap).
|
||||
func EnumerateBuckets(since, now time.Time, interval string) []string {
|
||||
if since.After(now) {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
step := func(t time.Time) time.Time {
|
||||
switch interval {
|
||||
case "month":
|
||||
return t.AddDate(0, 1, 0)
|
||||
case "week":
|
||||
return t.AddDate(0, 0, 7)
|
||||
default:
|
||||
return t.AddDate(0, 0, 1)
|
||||
}
|
||||
}
|
||||
// cap iterations so a bad range can never spin unbounded
|
||||
for t, n := since, 0; !t.After(now) && n < 800; t, n = step(t), n+1 {
|
||||
k := BucketKeyOf(t, interval)
|
||||
if !seen[k] {
|
||||
seen[k] = true
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
// ensure the final bucket (now) is present
|
||||
last := BucketKeyOf(now, interval)
|
||||
if !seen[last] {
|
||||
out = append(out, last)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// IndexOf maps each bucket key to its position for O(1) fold-in.
|
||||
func IndexOf(buckets []string) map[string]int {
|
||||
m := make(map[string]int, len(buckets))
|
||||
for i, b := range buckets {
|
||||
m[b] = i
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ParseTxnTime accepts the commerce ledger's RFC3339 forms.
|
||||
func ParseTxnTime(s string) (time.Time, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
return t.UTC(), nil
|
||||
}
|
||||
return time.Parse("2006-01-02T15:04:05Z", s)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// The /v1 envelope writers { status, msg, data, data2 } the operator's transport
|
||||
// decodes (get<T> reads data; getList<T> reads data + data2 total).
|
||||
|
||||
// OK writes a { status:"ok", data } envelope (the get<T> shape).
|
||||
func OK(c *zip.Ctx, data any) error {
|
||||
return c.JSON(200, map[string]any{"status": "ok", "msg": "", "data": data})
|
||||
}
|
||||
|
||||
// OKList writes a { status:"ok", data:[...], data2:total } envelope (getList<T>).
|
||||
func OKList(c *zip.Ctx, rows any, total int) error {
|
||||
return c.JSON(200, map[string]any{"status": "ok", "msg": "", "data": rows, "data2": total})
|
||||
}
|
||||
|
||||
// OKRaw writes a { status:"ok", data:<raw>, data2:total } envelope, forwarding an
|
||||
// IAM payload verbatim so its exact wire shape (Role, Application, Record, User)
|
||||
// reaches the operator field-for-field.
|
||||
func OKRaw(c *zip.Ctx, rows json.RawMessage, total int) error {
|
||||
if len(rows) == 0 {
|
||||
rows = json.RawMessage("[]")
|
||||
}
|
||||
return c.JSON(200, map[string]any{"status": "ok", "msg": "", "data": rows, "data2": total})
|
||||
}
|
||||
|
||||
// Fail writes a { status:"error", msg } envelope. The operator's transport maps a
|
||||
// non-ok envelope to a surfaced error (never a fabricated value).
|
||||
func Fail(c *zip.Ctx, msg string) error {
|
||||
return c.JSON(200, map[string]any{"status": "error", "msg": msg, "data": nil})
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/admin/iam"
|
||||
)
|
||||
|
||||
// MaxCustomerConcurrency bounds the per-org enrichment fan-out so a large fleet does
|
||||
// not open one upstream connection per org at once. Admin is low-QPS; 8 keeps latency
|
||||
// low without hammering IAM/commerce.
|
||||
const MaxCustomerConcurrency = 8
|
||||
|
||||
// ListOrgs reads the org directory (owner = admin org) as the typed shape the
|
||||
// overview/orgs/usage/customer/revenue/finance aggregators fold over.
|
||||
func ListOrgs(s *cloud.Service[State], ctx context.Context, cr iam.Creds) ([]iam.Org, error) {
|
||||
q := url.Values{}
|
||||
q.Set("owner", s.State.AdminOrg)
|
||||
res, err := s.State.IAM.Orgs(ctx, cr, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var orgs []iam.Org
|
||||
if len(res.Rows) > 0 {
|
||||
if err := json.Unmarshal(res.Rows, &orgs); err != nil {
|
||||
return nil, fmt.Errorf("orgs decode: %w", err)
|
||||
}
|
||||
}
|
||||
return orgs, nil
|
||||
}
|
||||
|
||||
// OrgMoney returns (spendCents, creditsCents, ok) for one org from commerce. ok is
|
||||
// false when the spend OR credits read FAILED — so a fleet aggregator can fold the
|
||||
// per-org failure into a PARTIAL/degraded source rather than presenting the resulting
|
||||
// undercount as authoritative (the SAME (row, ok) contract revenue.revenueOf uses). An
|
||||
// unwired commerce is NOT a failure: Spend/Credits return (0, nil) when unconfigured, so
|
||||
// ok stays true and the caller distinguishes "not configured" via Commerce.Ready().
|
||||
func OrgMoney(s *cloud.Service[State], ctx context.Context, org string) (spend, credits int64, ok bool) {
|
||||
ok = true
|
||||
if sp, err := s.State.Commerce.Spend(ctx, org); err == nil {
|
||||
spend = int64(sp.Consumed)
|
||||
} else {
|
||||
ok = false
|
||||
}
|
||||
if c, err := s.State.Commerce.Credits(ctx, org); err == nil {
|
||||
credits = int64(c)
|
||||
} else {
|
||||
ok = false
|
||||
}
|
||||
return spend, credits, ok
|
||||
}
|
||||
|
||||
// FindOrg returns the IAM org by slug (nil, nil when it does not exist) so a management
|
||||
// action can validate its target before acting — never credit or suspend an org that
|
||||
// isn't real.
|
||||
func FindOrg(s *cloud.Service[State], ctx context.Context, cr iam.Creds, org string) (*iam.Org, error) {
|
||||
orgs, err := ListOrgs(s, ctx, cr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range orgs {
|
||||
if orgs[i].Name == org {
|
||||
return &orgs[i], nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Display returns displayName when non-blank, else the fallback.
|
||||
func Display(displayName, fallback string) string {
|
||||
if strings.TrimSpace(displayName) != "" {
|
||||
return displayName
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/admin/iam"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// Handler is the admin handler shape: a free function over the shared kernel. Every
|
||||
// domain handler is `func h(s *cloud.Service[core.State], c *zip.Ctx) error`.
|
||||
type Handler = func(*cloud.Service[State], *zip.Ctx) error
|
||||
|
||||
// Guard wraps a handler with the SuperAdmin gate. Fail-closed: any request whose
|
||||
// validated identity is not a SuperAdmin (X-User-IsAdmin != "true", which
|
||||
// SanitizeIdentity sets only for owner == AdminOrg) is refused 403 before the handler
|
||||
// — no upstream is touched, no data leaks.
|
||||
func Guard(s *cloud.Service[State], h Handler) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
if !c.IsAdmin() {
|
||||
return zip.ErrForbidden("SuperAdmin required")
|
||||
}
|
||||
return h(s, c)
|
||||
}
|
||||
}
|
||||
|
||||
// GuardScoped is the gate for the ORG-SCOPED panels (me/overview/orgs/users/usage/
|
||||
// analytics/bases). It admits a SuperAdmin (principal.IsSuperAdmin) OR an ORG admin
|
||||
// (an admin of their OWN org — principal.IsOrgAdmin) pinned to a validated org, and the
|
||||
// handler then scopes every read to ResolveScope(c) — so a non-super caller passes the
|
||||
// gate but the DATA layer hard-limits them to their own org subtree. Cross-tenant reads
|
||||
// are impossible for a non-super caller regardless of input.
|
||||
//
|
||||
// The non-super admission requires BOTH the sanitizer-minted X-User-IsOrgAdmin
|
||||
// (principal.IsOrgAdmin — the "admin of my own org" bit, unforgeable because the boundary
|
||||
// strips it on ingress) AND a validated principal pinned to its own org (principal.Org,
|
||||
// the ONE org accessor: validated X-User-Id + non-empty in-bounds X-Org-Id). So a
|
||||
// validated but NON-admin MEMBER of an org is
|
||||
// REFUSED here — the same denial an anonymous caller who forged X-Org-Id gets — closing
|
||||
// the same-tenant over-visibility gap where any org member could read their org's admin
|
||||
// panels. A validated non-super principal's X-Org-Id is PINNED by the boundary to their
|
||||
// own owner, never client-chosen.
|
||||
func GuardScoped(s *cloud.Service[State], h Handler) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
if principal.IsSuperAdmin(c) {
|
||||
return h(s, c) // SuperAdmin: cross-tenant, admitted regardless of org pin.
|
||||
}
|
||||
if _, ok := principal.Org(c); ok && principal.IsOrgAdmin(c) {
|
||||
return h(s, c)
|
||||
}
|
||||
return zip.ErrForbidden("admin required")
|
||||
}
|
||||
}
|
||||
|
||||
// CallerCreds captures the caller's replayed authorization context for the IAM
|
||||
// fan-out: the raw Cookie header (session model) and the Authorization bearer.
|
||||
func CallerCreds(c *zip.Ctx) iam.Creds {
|
||||
return iam.Creds{
|
||||
Cookie: string(c.Fiber().Request().Header.Peek("Cookie")),
|
||||
Auth: c.Header("Authorization"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package core
|
||||
|
||||
// The ONE credit-write path + the ONE tamper-evident audit emit. ApplyGrant is the
|
||||
// single core shared by POST /v1/admin/customers/:org/credit (org from the path) and
|
||||
// POST /v1/admin/grants (org from the body): validate the amount + target org, deposit
|
||||
// into the org's commerce ledger (trial vs prepaid by source), and record the audit
|
||||
// row. One path, one way to grant.
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/audit"
|
||||
"github.com/hanzoai/cloud/clients/admin/money"
|
||||
"github.com/hanzoai/cloud/clients/finance"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
"github.com/hanzoai/commerce/billing/creditledger"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// CreditRequest is the grant body. AmountCents is the credit to add (positive only — a
|
||||
// grant, never a silent debit). Reason is the operator's justification, recorded in the
|
||||
// audit trail's before/after (refund / comp / support).
|
||||
type CreditRequest struct {
|
||||
AmountCents int64 `json:"amountCents"`
|
||||
Currency string `json:"currency"`
|
||||
Reason string `json:"reason"`
|
||||
// Source splits the grant into the commerce ledger's two money buckets:
|
||||
// - "trial" (default) — a non-cash promo/comp credit: spendable on non-premium
|
||||
// metered usage only, NEVER refundable cash and NEVER paid out.
|
||||
// - "prepaid" — real money added to the customer's cash balance. Refundable,
|
||||
// GPU-eligible.
|
||||
// Unknown/empty → trial (fail-closed to non-cash).
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// maxGrantCents caps a single grant at $100,000 — a guardrail against a fat-finger
|
||||
// operator credit, not a policy limit. The cap keeps a typo from minting a fortune.
|
||||
const maxGrantCents int64 = 100 * 100 * 1000
|
||||
|
||||
// grantTag maps a grant source to the commerce deposit Tags that billing/bucket
|
||||
// DepositKind classifies into Credit (trial) vs Prepaid (real money). Default
|
||||
// (empty/unknown/"trial") is the non-cash Credit bucket — a staff comp is never
|
||||
// silently minted as payout-able real money.
|
||||
func grantTag(source string) (tag, normalized string) {
|
||||
if strings.ToLower(strings.TrimSpace(source)) == "prepaid" {
|
||||
return "admin-grant", "prepaid" // DepositKind: bare → Prepaid (real money)
|
||||
}
|
||||
return "grant:admin", "trial" // DepositKind: grant:* → Credit (non-cash trial)
|
||||
}
|
||||
|
||||
// grantNote composes the deposit note from the operator's reason (bounded), so the
|
||||
// commerce ledger row itself carries the justification alongside the audit trail.
|
||||
func grantNote(c *zip.Ctx, reason string) string {
|
||||
r := strings.TrimSpace(reason)
|
||||
if len(r) > 200 {
|
||||
r = r[:200]
|
||||
}
|
||||
by := strings.TrimSpace(c.UserEmail())
|
||||
if by == "" {
|
||||
by = strings.TrimSpace(c.User())
|
||||
}
|
||||
if r == "" {
|
||||
r = "operator credit"
|
||||
}
|
||||
if by != "" {
|
||||
return fmt.Sprintf("Admin grant by %s: %s", by, r)
|
||||
}
|
||||
return "Admin grant: " + r
|
||||
}
|
||||
|
||||
// grantIdempotencyKey derives the DETERMINISTIC commerce idempotency key for a grant from
|
||||
// its (org, amount, currency, source) BOUND to the operator-supplied Idempotency-Key nonce
|
||||
// — so a retried grant (a commit-then-timeout re-submit carrying the SAME nonce) dedupes at
|
||||
// commerce (X-Idempotency-Key, at-most-once), while two DISTINCT grants — even same org +
|
||||
// amount — never collide. Binding the amount/currency/source into the hash means a nonce
|
||||
// accidentally reused for a DIFFERENT grant still lands (a different key), so dedup can
|
||||
// never silently DROP a legitimate distinct grant.
|
||||
//
|
||||
// Empty when the operator supplied no nonce: without a stable per-attempt id there is no
|
||||
// value that is both retry-stable AND grant-unique, so we do NOT fabricate one (a content
|
||||
// -only hash would wrongly dedupe two legitimate identical comps). The deposit is then
|
||||
// additive — the pre-existing behavior. Effective end-to-end once the operator console
|
||||
// sends an Idempotency-Key per grant attempt (reused verbatim on retry); commerce already
|
||||
// enforces the dedup (api/billing/deposit.go).
|
||||
func grantIdempotencyKey(c *zip.Ctx, org, currency, source string, amountCents int64) string {
|
||||
nonce := strings.TrimSpace(c.Header("Idempotency-Key"))
|
||||
if nonce == "" {
|
||||
nonce = strings.TrimSpace(c.Header("X-Idempotency-Key"))
|
||||
}
|
||||
if nonce == "" {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256([]byte(strings.Join([]string{
|
||||
org, strconv.FormatInt(amountCents, 10), currency, source, nonce,
|
||||
}, "|")))
|
||||
return "grant-" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// ApplyGrant validates the amount + target org, deposits into the org's commerce ledger
|
||||
// (trial vs prepaid by source), and records the tamper-evident audit row. One path, one
|
||||
// way to grant.
|
||||
func ApplyGrant(s *cloud.Service[State], c *zip.Ctx, org string, req CreditRequest) error {
|
||||
ctx := c.Context()
|
||||
cr := CallerCreds(c)
|
||||
if req.AmountCents <= 0 {
|
||||
return Fail(c, "amountCents must be positive")
|
||||
}
|
||||
if req.AmountCents > maxGrantCents {
|
||||
return Fail(c, fmt.Sprintf("amountCents exceeds the %d-cent per-grant cap", maxGrantCents))
|
||||
}
|
||||
currency := strings.ToLower(strings.TrimSpace(req.Currency))
|
||||
if currency == "" {
|
||||
currency = "usd"
|
||||
}
|
||||
|
||||
// Validate the target is a REAL org (never mint an orphan wallet on a typo).
|
||||
o, err := FindOrg(s, ctx, cr, org)
|
||||
if err != nil {
|
||||
return Fail(c, err.Error())
|
||||
}
|
||||
if o == nil {
|
||||
return c.JSON(404, map[string]any{"status": "error", "msg": "customer not found", "data": nil})
|
||||
}
|
||||
|
||||
// FAIL-CLOSED durability (SOC2 AU-2/AU-5): a credit grant moves REAL money and MUST
|
||||
// leave a durable, tamper-evident record. If this deployment has no audit store to
|
||||
// record into, REFUSE the grant BEFORE any money moves — never an unaudited money
|
||||
// move. A nil store is not a production state: cloud requires a persistent data dir
|
||||
// for the trail (audit_serve.go), and nil arises only from the explicit
|
||||
// CLOUD_AUDIT_DISABLED dev opt-out, on which moving money is not a supported op.
|
||||
if s.State.AuditStore == nil {
|
||||
return c.JSON(503, map[string]any{"status": "error", "msg": "grant refused: no durable audit store is configured on this deployment; a credit grant must be recorded before money moves", "data": nil})
|
||||
}
|
||||
|
||||
tag, source := grantTag(req.Source)
|
||||
notes := grantNote(c, req.Reason)
|
||||
|
||||
// ONE credit money-move: the co-resident native finance wallet is preferred (the ai
|
||||
// prepaid gate + the edge meter read/debit THAT wallet, so a grant MUST land there),
|
||||
// with the commerce HTTP deposit as the split-deploy fallback. Both return the
|
||||
// pre-balance (recorded even on failure), the entry/transaction id, and the post-balance,
|
||||
// so the audit + response below are one shape regardless of which path moved the money.
|
||||
before, txID, after, afterExact, derr := grantDeposit(s, c, org, currency, notes, tag, source, req.AmountCents)
|
||||
if derr != nil {
|
||||
// The grant did not land — record the FAILED attempt (accountability), then
|
||||
// surface the error. Never report a grant that failed as success.
|
||||
EmitAudit(s, c, "admin.customer.credit", "credit", org,
|
||||
map[string]any{"balanceCents": before},
|
||||
map[string]any{"amountCents": req.AmountCents, "currency": currency, "reason": req.Reason, "source": source, "error": derr.Error()},
|
||||
audit.Outcome{Result: "error", Status: 200, Reason: "grant failed"})
|
||||
return Fail(c, "grant failed: "+derr.Error())
|
||||
}
|
||||
|
||||
EmitAudit(s, c, "admin.customer.credit", "credit", org,
|
||||
map[string]any{"balanceCents": before},
|
||||
map[string]any{"balanceCents": after, "grantedCents": req.AmountCents, "currency": currency, "reason": req.Reason, "source": source, "transactionId": txID},
|
||||
audit.Outcome{Result: "success", Status: 200})
|
||||
|
||||
return OK(c, map[string]any{
|
||||
"org": org,
|
||||
"grantedCents": req.AmountCents,
|
||||
"currency": currency,
|
||||
"source": source,
|
||||
"balanceCents": after,
|
||||
"balanceExact": afterExact, // EXACT 18-decimal balance — a sub-cent debit is visible here
|
||||
"transactionId": txID,
|
||||
})
|
||||
}
|
||||
|
||||
// grantDeposit performs the ONE credit money-move for a grant. It prefers the co-resident
|
||||
// native finance wallet — the ai prepaid gate and the edge meter read/debit THAT wallet,
|
||||
// so an admin grant must credit it (subject == the org slug, the org-pool wallet) — and
|
||||
// falls back to the commerce HTTP deposit only when no finance ledger is co-resident (a
|
||||
// split deploy). It returns the pre-balance (so ApplyGrant can audit even a FAILED
|
||||
// attempt), the entry/transaction id, and the post-balance, so the audit + response are
|
||||
// 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()
|
||||
// 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, 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 cerr != nil {
|
||||
return before, "", before, "", cerr
|
||||
}
|
||||
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 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)
|
||||
res, derr := s.State.Commerce.Deposit(ctx, org, money.Cents(amountCents), currency, notes, tag, idem)
|
||||
if derr != nil {
|
||||
return int64(beforeC), "", int64(beforeC), "", derr
|
||||
}
|
||||
afterC, _ := s.State.Commerce.Credits(ctx, org)
|
||||
return int64(beforeC), res.TxID, int64(afterC), "", nil
|
||||
}
|
||||
|
||||
// EmitAudit writes ONE compliance record for a management action to cloud's
|
||||
// tamper-evident trail: who (the validated SuperAdmin from the sanitized identity —
|
||||
// the gate already proved it), what (action + resource), the redacted before/after, and
|
||||
// the outcome. This is the "before/after on a config-affecting change" the request-level
|
||||
// middleware record cannot carry (it never reads bodies). Best-effort: a failure here is
|
||||
// logged loud, never silent, and never double-fails the response. A nil store
|
||||
// (unconfigured deployment) is a no-op, like the middleware.
|
||||
func EmitAudit(s *cloud.Service[State], c *zip.Ctx, action, resType, resID string, before, after any, outcome audit.Outcome) {
|
||||
if s.State.AuditStore == nil {
|
||||
return
|
||||
}
|
||||
org, _ := principal.Org(c)
|
||||
rec := audit.Record{
|
||||
Actor: audit.Actor{Org: org, Sub: strings.TrimSpace(c.User()), Email: strings.TrimSpace(c.UserEmail())},
|
||||
Action: action,
|
||||
Resource: audit.Resource{Type: resType, ID: resID},
|
||||
Auth: audit.AuthContext{Method: "jwt", IsAdmin: c.IsAdmin()},
|
||||
Outcome: outcome,
|
||||
UserAgent: c.Header("User-Agent"),
|
||||
RequestID: c.RequestID(),
|
||||
Method: c.Method(),
|
||||
Path: c.Path(),
|
||||
Before: audit.Redact(mustJSON(before)),
|
||||
After: audit.Redact(mustJSON(after)),
|
||||
}
|
||||
if _, err := s.State.AuditStore.Append(c.Context(), rec); err != nil {
|
||||
c.Log().Error("admin: audit emit failed (request-level record still applies)",
|
||||
"action", action, "resource", resType, "id", resID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// mustJSON marshals v to raw JSON for the audit before/after, returning an empty object
|
||||
// on the (unexpected) marshal error rather than panicking — a metadata diff must never
|
||||
// crash a money/access action.
|
||||
func mustJSON(v any) json.RawMessage {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return json.RawMessage("{}")
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
package admin
|
||||
package core
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestGrantTag pins the money-bucket mapping: a staff comp defaults to the
|
||||
// non-cash TRIAL (Credit) bucket, and ONLY an explicit "prepaid" mints real
|
||||
// money. A tagging slip must never silently create payout-able cash.
|
||||
// TestGrantTag pins the money-bucket mapping: a staff comp defaults to the non-cash
|
||||
// TRIAL (Credit) bucket, and ONLY an explicit "prepaid" mints real money. A tagging slip
|
||||
// must never silently create payout-able cash.
|
||||
func TestGrantTag(t *testing.T) {
|
||||
cases := []struct {
|
||||
source string
|
||||
@@ -0,0 +1,99 @@
|
||||
package core
|
||||
|
||||
// The TENANT-SCOPE predicate — the ONE rule the whole cockpit obeys so admin.hanzo.ai
|
||||
// is a single pane for BOTH tiers off ONE identity primitive:
|
||||
//
|
||||
// owner == the admin org (SuperAdmin, c.IsAdmin()) ⇒ CROSS-TENANT: every org.
|
||||
// any other validated admin caller ⇒ OWN SUBTREE: their org
|
||||
// (+ the sub-orgs they own).
|
||||
//
|
||||
// Decomplected into exactly one place (ResolveScope + ScopedOrgs + Descendants) so no
|
||||
// handler re-derives it and the escalation line — a non-super caller reaching ANOTHER
|
||||
// tenant — cannot be crossed by any single panel.
|
||||
//
|
||||
// RECURSION SEAM (honest gap). The subtree is TODAY the singleton {org}: IAM's
|
||||
// Organization has NO parent-org / hierarchy field yet, so no tenant subtree exists to
|
||||
// walk. `Descendants` is the ONE function that becomes a parent-index BFS once IAM adds
|
||||
// the ParentOrg link — every scoped read composes over it, so recursion lands there and
|
||||
// nowhere else, with zero change to the callers.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/admin/iam"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// TenantScope is a request's resolved visibility window. Super and Orgs are the two
|
||||
// mutually exclusive views: a SuperAdmin sees all tenants (Orgs ignored); anyone else
|
||||
// sees exactly Orgs (their own subtree).
|
||||
type TenantScope struct {
|
||||
Super bool
|
||||
Orgs []string
|
||||
}
|
||||
|
||||
// ScopedToOrg reports whether the scope admits reads for org o. Super admits every org;
|
||||
// a scoped caller admits only orgs in their subtree. Used by panels that filter an
|
||||
// upstream list (e.g. bases) rather than fanning out per-org.
|
||||
func (t TenantScope) ScopedToOrg(o string) bool {
|
||||
if t.Super {
|
||||
return true
|
||||
}
|
||||
o = strings.TrimSpace(o)
|
||||
for _, s := range t.Orgs {
|
||||
if s == o {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ResolveScope derives the request's tenant window from the SANITIZED identity only —
|
||||
// never a client-forgeable field. A SuperAdmin (c.IsAdmin(), owner == admin org) is
|
||||
// cross-tenant; any other caller is pinned to the subtree of their own (sanitized) org.
|
||||
func ResolveScope(s *cloud.Service[State], c *zip.Ctx) TenantScope {
|
||||
if c.IsAdmin() {
|
||||
return TenantScope{Super: true}
|
||||
}
|
||||
org, ok := principal.Org(c)
|
||||
if !ok {
|
||||
return TenantScope{} // no validated principal/org ⇒ empty window ⇒ sees nothing
|
||||
}
|
||||
return TenantScope{Orgs: Descendants(s, org)}
|
||||
}
|
||||
|
||||
// Descendants returns org + every sub-org it owns — the subtree the caller administers.
|
||||
// See the RECURSION SEAM note above: today the singleton {org}; the ONE place a future
|
||||
// IAM parent-org index is walked.
|
||||
func Descendants(s *cloud.Service[State], org string) []string {
|
||||
org = strings.TrimSpace(org)
|
||||
if org == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{org}
|
||||
}
|
||||
|
||||
// ScopedOrgs is the ONE fan-in the org-scoped read panels (overview, orgs, usage,
|
||||
// analytics) fold over — enforcing the two-scope predicate in a single place. A
|
||||
// SuperAdmin gets EVERY org (the cross-tenant list); any other caller gets ONLY their
|
||||
// own subtree, each row read from IAM so the display name / createdTime are the REAL
|
||||
// values. An org row that can't be read best-effort degrades to a name-only row rather
|
||||
// than failing the panel — the scope is unaffected.
|
||||
func ScopedOrgs(s *cloud.Service[State], ctx context.Context, c *zip.Ctx, cr iam.Creds) ([]iam.Org, error) {
|
||||
sc := ResolveScope(s, c)
|
||||
if sc.Super {
|
||||
return ListOrgs(s, ctx, cr)
|
||||
}
|
||||
rows := make([]iam.Org, 0, len(sc.Orgs))
|
||||
for _, name := range sc.Orgs {
|
||||
row := iam.Org{Owner: s.State.AdminOrg, Name: name, DisplayName: name}
|
||||
if full, err := s.State.IAM.Org(ctx, cr, s.State.AdminOrg+"/"+name); err == nil && full.Name != "" {
|
||||
row = full
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package core
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrPartialRevenue marks a revenue read that succeeded at the org-list level but had
|
||||
// one or more per-org failures — the fleet total is real but PARTIAL. SrcOf reports it
|
||||
// as a not-ok source so the console shows a degraded state rather than presenting an
|
||||
// under-count as authoritative.
|
||||
var ErrPartialRevenue = errors.New("partial: one or more org revenue reads failed")
|
||||
|
||||
// SourceStatus is the freshness of one upstream the aggregator pulls from
|
||||
// (overview.sources[] / revenue.sources[] / finance.sources[] / analytics.sources[]).
|
||||
type SourceStatus struct {
|
||||
Name string `json:"name"`
|
||||
OK bool `json:"ok"`
|
||||
Rows int `json:"rows"`
|
||||
Error string `json:"error"`
|
||||
At string `json:"at"`
|
||||
}
|
||||
|
||||
// SrcOf builds a SourceStatus freshness row for an aggregator.
|
||||
func SrcOf(name string, err error, rows int, at string) SourceStatus {
|
||||
s := SourceStatus{Name: name, OK: err == nil, Rows: rows, At: at}
|
||||
if err != nil {
|
||||
s.Error = err.Error()
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Package core is the shared kernel of the admin subsystem: the resolved upstream
|
||||
// clients (State) plus the one-copy business primitives every admin domain composes
|
||||
// — the two-tier gate, the /v1 envelope writers, the tenant-scope predicate, the IAM
|
||||
// fan-in, the single credit-grant path, the tamper-evident audit emit, and the fleet
|
||||
// activity/time-series model. Each primitive lives EXACTLY once here; the domain
|
||||
// packages (audit/customer/revenue/finance) and the top-level admin Mount import it,
|
||||
// never duplicating a helper — there is one path to grant, one read, one scope rule.
|
||||
package core
|
||||
|
||||
import (
|
||||
"github.com/hanzoai/cloud/audit"
|
||||
"github.com/hanzoai/cloud/clients/admin/commerce"
|
||||
"github.com/hanzoai/cloud/clients/admin/digitalocean"
|
||||
"github.com/hanzoai/cloud/clients/admin/health"
|
||||
"github.com/hanzoai/cloud/clients/admin/iam"
|
||||
)
|
||||
|
||||
// State is admin's own data: the resolved upstream clients + the admin org for this
|
||||
// deployment. admin holds NO Base shared deps (it fans out over HTTP replaying the
|
||||
// caller's own creds); the embedded cloud.Base carries only the mount-time logger.
|
||||
//
|
||||
// AuditStore is cloud's OWN tamper-evident audit store (nil when unconfigured, in
|
||||
// which case /v1/admin/audit falls back to the IAM get-records proxy). Serve builds
|
||||
// it and hands it over via deps.Audit.
|
||||
type State struct {
|
||||
IAM *iam.Client
|
||||
Commerce *commerce.Client
|
||||
Health *health.Client
|
||||
DO *digitalocean.Client
|
||||
AdminOrg string
|
||||
AuditStore *audit.Recorder
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
// Package customer is the CUSTOMER management surface (/v1/admin/customers*) — the
|
||||
// operator cockpit's core: the live fleet customer list (incl. new self-serve signups),
|
||||
// one-customer detail, and the audited management ACTIONS (grant credit, suspend,
|
||||
// reactivate).
|
||||
//
|
||||
// It aggregates the SAME real upstreams the rest of admin reads — IAM for the org
|
||||
// directory + user/owner/status, commerce for balance/spend/plan/ledger — and adds the
|
||||
// two write levers an operator needs:
|
||||
//
|
||||
// - GRANT CREDIT is a real commerce Deposit landing in the org's own wallet, via the
|
||||
// ONE core credit-write path (core.ApplyGrant).
|
||||
// - SUSPEND / REACTIVATE flips IAM `isForbidden` on the org's users — IAM refuses a
|
||||
// forbidden user at login AND at token issuance, so a suspended customer cannot sign
|
||||
// in or mint a fresh token. Fully reversible.
|
||||
//
|
||||
// SECURITY. Every route is mounted behind core.Guard (SuperAdmin only, fail-closed).
|
||||
// The write actions REPLAY THE CALLER'S OWN SuperAdmin credential to IAM, and each is
|
||||
// recorded to cloud's tamper-evident audit trail with a redacted BEFORE/AFTER.
|
||||
package customer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/audit"
|
||||
"github.com/hanzoai/cloud/clients/admin/core"
|
||||
"github.com/hanzoai/cloud/clients/admin/iam"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// ── wire shapes (operator contract) ──────────────────────────────────────────
|
||||
|
||||
// CustomerRow is one row in GET /v1/admin/customers — a fleet customer at a glance.
|
||||
type CustomerRow struct {
|
||||
Org string `json:"org"`
|
||||
Display string `json:"display"`
|
||||
OwnerEmail string `json:"ownerEmail"`
|
||||
Plan string `json:"plan"`
|
||||
Status string `json:"status"` // "active" | "suspended"
|
||||
Users int `json:"users"`
|
||||
BalanceCents int64 `json:"balanceCents"`
|
||||
SpendCents int64 `json:"spendCents"`
|
||||
MRRCents int64 `json:"mrrCents"`
|
||||
Created string `json:"created"`
|
||||
LastActive string `json:"lastActive"`
|
||||
}
|
||||
|
||||
// CustomerUser is one member in the customer detail (no secrets — the AccessKey PRESENCE
|
||||
// is surfaced as hasApiKey, never the key itself).
|
||||
type CustomerUser struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Forbidden bool `json:"forbidden"`
|
||||
HasAPIKey bool `json:"hasApiKey"`
|
||||
LastSignin string `json:"lastSignin"`
|
||||
Created string `json:"created"`
|
||||
}
|
||||
|
||||
// CustomerTxn is one ledger row in the detail's top-up/usage history.
|
||||
type CustomerTxn struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // "deposit" (credit) | "withdraw" (usage)
|
||||
Cents int64 `json:"cents"`
|
||||
Currency string `json:"currency"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
// CustomerDetailData is the GET /v1/admin/customers/:org payload.
|
||||
type CustomerDetailData struct {
|
||||
Org string `json:"org"`
|
||||
Display string `json:"display"`
|
||||
OwnerEmail string `json:"ownerEmail"`
|
||||
Plan string `json:"plan"`
|
||||
Status string `json:"status"`
|
||||
Created string `json:"created"`
|
||||
BalanceCents int64 `json:"balanceCents"`
|
||||
SpendCents int64 `json:"spendCents"`
|
||||
MRRCents int64 `json:"mrrCents"`
|
||||
APIKeys int `json:"apiKeys"`
|
||||
Users []CustomerUser `json:"users"`
|
||||
Transactions []CustomerTxn `json:"transactions"`
|
||||
}
|
||||
|
||||
// ── GET /v1/admin/customers — the fleet customer list ────────────────────────
|
||||
|
||||
// Customers answers GET /v1/admin/customers.
|
||||
func Customers(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
cr := core.CallerCreds(c)
|
||||
orgs, err := core.ListOrgs(s, ctx, cr)
|
||||
if err != nil {
|
||||
return core.Fail(c, err.Error())
|
||||
}
|
||||
|
||||
rows := make([]CustomerRow, len(orgs))
|
||||
sem := make(chan struct{}, core.MaxCustomerConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
for i, o := range orgs {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(i int, o iam.Org) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
rows[i] = enrichCustomer(s, ctx, cr, o)
|
||||
}(i, o)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].Org < rows[j].Org })
|
||||
return core.OKList(c, rows, len(rows))
|
||||
}
|
||||
|
||||
// enrichCustomer folds one org's real IAM + commerce reads into a customer row. Each read
|
||||
// is best-effort: an upstream miss degrades that field to its honest zero/empty (never a
|
||||
// fabricated value), so one flaky org never fails the fleet.
|
||||
func enrichCustomer(s *cloud.Service[core.State], ctx context.Context, cr iam.Creds, o iam.Org) CustomerRow {
|
||||
users, _ := orgUsers(s, ctx, cr, o.Name)
|
||||
spend, credits, _ := core.OrgMoney(s, ctx, o.Name)
|
||||
plan, _ := s.State.Commerce.Plan(ctx, o.Name)
|
||||
|
||||
return CustomerRow{
|
||||
Org: o.Name,
|
||||
Display: core.Display(o.DisplayName, o.Name),
|
||||
OwnerEmail: ownerEmail(users),
|
||||
Plan: plan.Name,
|
||||
Status: statusOf(users),
|
||||
Users: len(users),
|
||||
BalanceCents: credits,
|
||||
SpendCents: spend,
|
||||
MRRCents: int64(plan.MRR),
|
||||
Created: o.CreatedTime,
|
||||
LastActive: lastActiveOf(users),
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /v1/admin/customers/:org — one customer's detail ─────────────────────
|
||||
|
||||
// CustomerDetail answers GET /v1/admin/customers/:org.
|
||||
func CustomerDetail(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
cr := core.CallerCreds(c)
|
||||
org := customerOrgParam(c)
|
||||
if org == "" {
|
||||
return core.Fail(c, "org is required")
|
||||
}
|
||||
|
||||
o, err := core.FindOrg(s, ctx, cr, org)
|
||||
if err != nil {
|
||||
return core.Fail(c, err.Error())
|
||||
}
|
||||
if o == nil {
|
||||
return c.JSON(404, map[string]any{"status": "error", "msg": "customer not found", "data": nil})
|
||||
}
|
||||
|
||||
users, _ := orgUsers(s, ctx, cr, org)
|
||||
spend, credits, _ := core.OrgMoney(s, ctx, org)
|
||||
plan, _ := s.State.Commerce.Plan(ctx, org)
|
||||
ledgerEntries, _ := s.State.Commerce.Ledger(ctx, org, 50)
|
||||
|
||||
rows := make([]CustomerUser, 0, len(users))
|
||||
apiKeys := 0
|
||||
for _, u := range users {
|
||||
hasKey := strings.TrimSpace(u.AccessKey) != ""
|
||||
if hasKey {
|
||||
apiKeys++
|
||||
}
|
||||
rows = append(rows, CustomerUser{
|
||||
Name: u.Name,
|
||||
Email: u.Email,
|
||||
IsAdmin: u.IsAdmin,
|
||||
Forbidden: u.IsForbidden,
|
||||
HasAPIKey: hasKey,
|
||||
LastSignin: u.LastSigninTime,
|
||||
Created: u.CreatedTime,
|
||||
})
|
||||
}
|
||||
|
||||
ledger := make([]CustomerTxn, 0, len(ledgerEntries))
|
||||
for _, e := range ledgerEntries {
|
||||
ledger = append(ledger, CustomerTxn{
|
||||
ID: e.ID,
|
||||
Type: e.Kind,
|
||||
Cents: int64(e.Amount),
|
||||
Currency: e.Currency,
|
||||
Notes: e.Notes,
|
||||
Time: e.At,
|
||||
})
|
||||
}
|
||||
|
||||
return core.OK(c, CustomerDetailData{
|
||||
Org: org,
|
||||
Display: core.Display(o.DisplayName, org),
|
||||
OwnerEmail: ownerEmail(users),
|
||||
Plan: plan.Name,
|
||||
Status: statusOf(users),
|
||||
Created: o.CreatedTime,
|
||||
BalanceCents: credits,
|
||||
SpendCents: spend,
|
||||
MRRCents: int64(plan.MRR),
|
||||
APIKeys: apiKeys,
|
||||
Users: rows,
|
||||
Transactions: ledger,
|
||||
})
|
||||
}
|
||||
|
||||
// ── POST /v1/admin/customers/:org/credit — grant credit ──────────────────────
|
||||
|
||||
// GrantCredit answers POST /v1/admin/customers/:org/credit — a staff credit grant to the
|
||||
// path org, funneled through the ONE core credit-write path (core.ApplyGrant).
|
||||
func GrantCredit(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
org := customerOrgParam(c)
|
||||
if org == "" {
|
||||
return core.Fail(c, "org is required")
|
||||
}
|
||||
var req core.CreditRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return core.Fail(c, "invalid request body")
|
||||
}
|
||||
return core.ApplyGrant(s, c, org, req)
|
||||
}
|
||||
|
||||
// ── POST /v1/admin/customers/:org/{suspend,reactivate} — access control ──────
|
||||
|
||||
// SuspendCustomer forbids every member of the org (cuts login + token issuance).
|
||||
func SuspendCustomer(s *cloud.Service[core.State], c *zip.Ctx) error { return setForbidden(s, c, true) }
|
||||
|
||||
// ReactivateCustomer restores access for every member of the org.
|
||||
func ReactivateCustomer(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
return setForbidden(s, c, false)
|
||||
}
|
||||
|
||||
// setForbidden flips IAM `isForbidden` on every member of the org — suspend
|
||||
// (forbidden=true) cuts login + token issuance; reactivate restores it. Each user's FULL
|
||||
// object is read, the one field flipped, and written back, replaying the caller's
|
||||
// SuperAdmin credential so IAM authorizes it. Best-effort per user with an aggregated
|
||||
// result: a partial failure is reported honestly (affected vs failed), never masked as a
|
||||
// clean success. The action is recorded with a redacted before/after user tally.
|
||||
func setForbidden(s *cloud.Service[core.State], c *zip.Ctx, forbidden bool) error {
|
||||
ctx := c.Context()
|
||||
cr := core.CallerCreds(c)
|
||||
org := customerOrgParam(c)
|
||||
if org == "" {
|
||||
return core.Fail(c, "org is required")
|
||||
}
|
||||
|
||||
o, err := core.FindOrg(s, ctx, cr, org)
|
||||
if err != nil {
|
||||
return core.Fail(c, err.Error())
|
||||
}
|
||||
if o == nil {
|
||||
return c.JSON(404, map[string]any{"status": "error", "msg": "customer not found", "data": nil})
|
||||
}
|
||||
|
||||
users, err := orgUsers(s, ctx, cr, org)
|
||||
if err != nil {
|
||||
return core.Fail(c, err.Error())
|
||||
}
|
||||
|
||||
beforeForbidden := 0
|
||||
for _, u := range users {
|
||||
if u.IsForbidden {
|
||||
beforeForbidden++
|
||||
}
|
||||
}
|
||||
|
||||
var affected, failed []string
|
||||
for _, u := range users {
|
||||
id := u.Owner + "/" + u.Name
|
||||
full, gerr := s.State.IAM.User(ctx, cr, id)
|
||||
if gerr != nil {
|
||||
failed = append(failed, u.Name)
|
||||
continue
|
||||
}
|
||||
full["isForbidden"] = forbidden
|
||||
if uerr := s.State.IAM.SetUser(ctx, cr, id, full); uerr != nil {
|
||||
failed = append(failed, u.Name)
|
||||
continue
|
||||
}
|
||||
affected = append(affected, u.Name)
|
||||
}
|
||||
|
||||
action := "admin.customer.suspend"
|
||||
if !forbidden {
|
||||
action = "admin.customer.reactivate"
|
||||
}
|
||||
result := "success"
|
||||
reason := ""
|
||||
if len(failed) > 0 {
|
||||
result = "error"
|
||||
reason = fmt.Sprintf("%d user(s) not updated", len(failed))
|
||||
}
|
||||
core.EmitAudit(s, c, action, "customer", org,
|
||||
map[string]any{"suspended": beforeForbidden == len(users) && len(users) > 0, "forbiddenUsers": beforeForbidden, "totalUsers": len(users)},
|
||||
map[string]any{"suspended": forbidden, "affected": affected, "failed": failed},
|
||||
audit.Outcome{Result: result, Status: 200, Reason: reason})
|
||||
|
||||
return core.OK(c, map[string]any{
|
||||
"org": org,
|
||||
"suspended": forbidden,
|
||||
"affected": affected,
|
||||
"failed": failed,
|
||||
})
|
||||
}
|
||||
|
||||
// ── aggregation + derivation helpers ─────────────────────────────────────────
|
||||
|
||||
// orgUsers reads an org's members (a bounded page) as the typed subset the customer
|
||||
// surface folds over. It is the ONE IAM read that yields the user count, the owner email,
|
||||
// the suspend status, and the API-key presence — so a customer row costs a single
|
||||
// get-users call, not four.
|
||||
func orgUsers(s *cloud.Service[core.State], ctx context.Context, cr iam.Creds, org string) ([]iam.User, error) {
|
||||
q := url.Values{}
|
||||
q.Set("owner", org)
|
||||
q.Set("p", "1")
|
||||
q.Set("pageSize", "200")
|
||||
res, err := s.State.IAM.Users(ctx, cr, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw []iam.User
|
||||
if len(res.Rows) > 0 {
|
||||
if err := json.Unmarshal(res.Rows, &raw); err != nil {
|
||||
return nil, fmt.Errorf("users decode: %w", err)
|
||||
}
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// ownerEmail picks the org's admin user's email (the account owner), falling back to the
|
||||
// first user with an email. Empty when no user carries one.
|
||||
func ownerEmail(users []iam.User) string {
|
||||
for _, u := range users {
|
||||
if u.IsAdmin && strings.TrimSpace(u.Email) != "" {
|
||||
return u.Email
|
||||
}
|
||||
}
|
||||
for _, u := range users {
|
||||
if strings.TrimSpace(u.Email) != "" {
|
||||
return u.Email
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// statusOf derives the suspend status: an org is "suspended" only when it has at least
|
||||
// one user and EVERY user is forbidden (a partial forbid is still "active"). Honest by
|
||||
// construction.
|
||||
func statusOf(users []iam.User) string {
|
||||
if len(users) == 0 {
|
||||
return "active"
|
||||
}
|
||||
for _, u := range users {
|
||||
if !u.IsForbidden {
|
||||
return "active"
|
||||
}
|
||||
}
|
||||
return "suspended"
|
||||
}
|
||||
|
||||
// lastActiveOf returns the most recent user sign-in across the org (RFC3339), the best
|
||||
// "last active" signal available from IAM. Empty when no user has signed in.
|
||||
func lastActiveOf(users []iam.User) string {
|
||||
last := ""
|
||||
for _, u := range users {
|
||||
if u.LastSigninTime > last {
|
||||
last = u.LastSigninTime
|
||||
}
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
// customerOrgParam reads + trims the :org path param.
|
||||
func customerOrgParam(c *zip.Ctx) string { return strings.TrimSpace(c.Param("org")) }
|
||||
@@ -1,4 +1,4 @@
|
||||
package admin
|
||||
package customer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -7,26 +7,23 @@ import (
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/audit"
|
||||
"github.com/hanzoai/cloud/clients/admin/core"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// The GRANTS surface (/v1/admin/grants) — the operator cockpit's credit-grant
|
||||
// ledger. A grant is a staff-issued credit (a comp/refund/promo) written to a
|
||||
// customer org's commerce ledger by POST /v1/admin/customers/:org/credit (or
|
||||
// POST /v1/admin/grants). Every grant is recorded in cloud's tamper-evident audit
|
||||
// store as action "admin.customer.credit" (resource type "credit", resource id =
|
||||
// the target org), so THIS view is a projection of that trail — the ONE source of
|
||||
// truth for "who granted what to whom, when, and from which bucket". Global-admin
|
||||
// only (mounted behind s.guard, like every /v1/admin/* route).
|
||||
// The GRANTS surface (/v1/admin/grants) — the operator cockpit's credit-grant ledger. A
|
||||
// grant is a staff-issued credit (a comp/refund/promo) written to a customer org's
|
||||
// commerce ledger by POST /v1/admin/customers/:org/credit (or POST /v1/admin/grants).
|
||||
// Every grant is recorded in cloud's tamper-evident audit store as action
|
||||
// "admin.customer.credit", so THIS view is a projection of that trail — the ONE source of
|
||||
// truth for "who granted what to whom, when, and from which bucket". SuperAdmin only.
|
||||
//
|
||||
// A grant's `source` splits it into the two commerce money buckets:
|
||||
// - trial — a non-cash promo/comp credit (never refundable cash, never paid
|
||||
// out, non-premium usage only).
|
||||
// - trial — a non-cash promo/comp credit (never refundable cash, never paid out).
|
||||
// - prepaid — real money added to the customer's cash balance.
|
||||
// The staff-issue default is trial (we never silently mint payout-able money).
|
||||
|
||||
// grantRow is one row in GET /v1/admin/grants.
|
||||
type grantRow struct {
|
||||
// GrantRow is one row in GET /v1/admin/grants.
|
||||
type GrantRow struct {
|
||||
Org string `json:"org"`
|
||||
AmountCents int64 `json:"amountCents"`
|
||||
Currency string `json:"currency"`
|
||||
@@ -38,7 +35,7 @@ type grantRow struct {
|
||||
Result string `json:"result"` // success | error
|
||||
}
|
||||
|
||||
// grantAfter is the audit record's After payload emitted by applyGrant. Success
|
||||
// grantAfter is the audit record's After payload emitted by core.ApplyGrant. Success
|
||||
// carries grantedCents+transactionId; a failed attempt carries amountCents+error.
|
||||
type grantAfter struct {
|
||||
GrantedCents int64 `json:"grantedCents"`
|
||||
@@ -49,19 +46,15 @@ type grantAfter struct {
|
||||
TransactionID string `json:"transactionId"`
|
||||
}
|
||||
|
||||
// grants answers GET /v1/admin/grants — the credit-grant ledger across ALL orgs,
|
||||
// newest first, projected from the audit trail. Filters: ?org, ?result
|
||||
// (success|error), ?limit. Honest empty when no local audit store is configured
|
||||
// (the grants view is audit-backed; without the store there is no grant history
|
||||
// to read — never a fabricated list).
|
||||
//
|
||||
// GET /v1/admin/grants
|
||||
func grants(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
if s.State.auditStore == nil {
|
||||
// Grants answers GET /v1/admin/grants — the credit-grant ledger across ALL orgs, newest
|
||||
// first, projected from the audit trail. Filters: ?org, ?result (success|error), ?limit.
|
||||
// Honest empty when no local audit store is configured.
|
||||
func Grants(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
if s.State.AuditStore == nil {
|
||||
return c.JSON(200, map[string]any{
|
||||
"status": "ok",
|
||||
"msg": "grant history is unavailable (no local audit store configured on this deployment)",
|
||||
"data": []grantRow{},
|
||||
"data": []GrantRow{},
|
||||
"data2": 0,
|
||||
})
|
||||
}
|
||||
@@ -81,12 +74,12 @@ func grants(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
Limit: limit,
|
||||
}
|
||||
|
||||
rows, total, err := s.State.auditStore.Query(c.Context(), f)
|
||||
rows, total, err := s.State.AuditStore.Query(c.Context(), f)
|
||||
if err != nil {
|
||||
return fail(c, err.Error())
|
||||
return core.Fail(c, err.Error())
|
||||
}
|
||||
|
||||
out := make([]grantRow, 0, len(rows))
|
||||
out := make([]GrantRow, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
var a grantAfter
|
||||
if len(r.After) > 0 {
|
||||
@@ -108,7 +101,7 @@ func grants(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
if actor == "" {
|
||||
actor = r.Actor.Sub
|
||||
}
|
||||
out = append(out, grantRow{
|
||||
out = append(out, GrantRow{
|
||||
Org: r.Resource.ID, // the TARGET org the credit landed on
|
||||
AmountCents: amount,
|
||||
Currency: currency,
|
||||
@@ -129,8 +122,8 @@ func grants(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// issueGrantRequest is the POST /v1/admin/grants body: the credit fields plus the
|
||||
// target org (which the per-customer route carries in its path instead).
|
||||
// issueGrantRequest is the POST /v1/admin/grants body: the credit fields plus the target
|
||||
// org (which the per-customer route carries in its path instead).
|
||||
type issueGrantRequest struct {
|
||||
Org string `json:"org"`
|
||||
AmountCents int64 `json:"amountCents"`
|
||||
@@ -139,22 +132,19 @@ type issueGrantRequest struct {
|
||||
Source string `json:"source"` // "trial" (default) | "prepaid"
|
||||
}
|
||||
|
||||
// issueGrant answers POST /v1/admin/grants — issue a credit grant to any org from
|
||||
// the operator Grants view (org in the body). It funnels through the SAME
|
||||
// applyGrant core POST /v1/admin/customers/:org/credit uses, so there is exactly
|
||||
// ONE credit-write path (validate + deposit trial/prepaid + audit).
|
||||
//
|
||||
// POST /v1/admin/grants { org, amountCents, currency?, reason?, source? }
|
||||
func issueGrant(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
// IssueGrant answers POST /v1/admin/grants — issue a credit grant to any org from the
|
||||
// operator Grants view (org in the body). It funnels through the SAME core.ApplyGrant
|
||||
// POST /v1/admin/customers/:org/credit uses, so there is exactly ONE credit-write path.
|
||||
func IssueGrant(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
var body issueGrantRequest
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return fail(c, "invalid request body")
|
||||
return core.Fail(c, "invalid request body")
|
||||
}
|
||||
org := strings.TrimSpace(body.Org)
|
||||
if org == "" {
|
||||
return fail(c, "org is required")
|
||||
return core.Fail(c, "org is required")
|
||||
}
|
||||
return applyGrant(s, c, org, creditRequest{
|
||||
return core.ApplyGrant(s, c, org, core.CreditRequest{
|
||||
AmountCents: body.AmountCents,
|
||||
Currency: body.Currency,
|
||||
Reason: body.Reason,
|
||||
@@ -0,0 +1,20 @@
|
||||
package customer
|
||||
|
||||
import (
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/admin/core"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// Routes registers the customer-management surface (SuperAdmin only). List (static)
|
||||
// precedes the :org param route; the write actions are POST (distinct method), so none
|
||||
// collide. The grants ledger + the org-in-body issue-grant share the ONE credit path.
|
||||
func Routes(app *zip.App, s *cloud.Service[core.State]) {
|
||||
app.Get("/v1/admin/customers", core.Guard(s, Customers))
|
||||
app.Get("/v1/admin/customers/:org", core.Guard(s, CustomerDetail))
|
||||
app.Post("/v1/admin/customers/:org/credit", core.Guard(s, GrantCredit))
|
||||
app.Get("/v1/admin/grants", core.Guard(s, Grants))
|
||||
app.Post("/v1/admin/grants", core.Guard(s, IssueGrant))
|
||||
app.Post("/v1/admin/customers/:org/suspend", core.Guard(s, SuspendCustomer))
|
||||
app.Post("/v1/admin/customers/:org/reactivate", core.Guard(s, ReactivateCustomer))
|
||||
}
|
||||
@@ -1,557 +0,0 @@
|
||||
package admin
|
||||
|
||||
// The CUSTOMER management surface (/v1/admin/customers*) — the operator cockpit's
|
||||
// core: the live fleet customer list (incl. new self-serve signups), one-customer
|
||||
// detail, and the audited management ACTIONS (grant credit, suspend, reactivate).
|
||||
//
|
||||
// It aggregates the SAME real upstreams the rest of admin reads — IAM for the org
|
||||
// directory + user/owner/status, commerce for balance/spend/plan/ledger — and adds
|
||||
// the two write levers an operator needs to run the paid cloud:
|
||||
//
|
||||
// - GRANT CREDIT is a real commerce Deposit (refunds/comps/support) landing in
|
||||
// the org's own wallet, symmetric with the balance read.
|
||||
// - SUSPEND / REACTIVATE flips IAM `isForbidden` on the org's users. That is the
|
||||
// platform's REAL access lever: IAM refuses a forbidden user at login AND at
|
||||
// token issuance (object/check.go + object/token_oauth.go), so a suspended
|
||||
// customer cannot sign in or mint a fresh token — no new enforcement path is
|
||||
// invented, and it is fully reversible.
|
||||
//
|
||||
// SECURITY. Every route is mounted behind s.guard (global-admin only, fail-closed)
|
||||
// exactly like the read surface. The write actions REPLAY THE CALLER'S OWN global-
|
||||
// admin credential to IAM (no service credential added — IAM re-checks
|
||||
// IsGlobalAdmin, so admin can never mutate a boundary the caller couldn't already
|
||||
// cross), and each is recorded to cloud's tamper-evident audit trail with a
|
||||
// redacted BEFORE/AFTER (the AU "before/after on config-affecting change"), on top
|
||||
// of the uniform request record the audit middleware already writes for every
|
||||
// /v1/admin/* mutation. No customer card data is ever read or exposed here.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/audit"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// ── wire shapes (operator contract) ──────────────────────────────────────────
|
||||
|
||||
// customerRow is one row in GET /v1/admin/customers — a fleet customer at a glance.
|
||||
type customerRow struct {
|
||||
Org string `json:"org"`
|
||||
Display string `json:"display"`
|
||||
OwnerEmail string `json:"ownerEmail"`
|
||||
Plan string `json:"plan"`
|
||||
Status string `json:"status"` // "active" | "suspended"
|
||||
Users int `json:"users"`
|
||||
BalanceCents int64 `json:"balanceCents"`
|
||||
SpendCents int64 `json:"spendCents"`
|
||||
MRRCents int64 `json:"mrrCents"`
|
||||
Created string `json:"created"`
|
||||
LastActive string `json:"lastActive"`
|
||||
}
|
||||
|
||||
// customerUser is one member in the customer detail (no secrets — the AccessKey
|
||||
// PRESENCE is surfaced as hasApiKey, never the key itself).
|
||||
type customerUser struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Forbidden bool `json:"forbidden"`
|
||||
HasAPIKey bool `json:"hasApiKey"`
|
||||
LastSignin string `json:"lastSignin"`
|
||||
Created string `json:"created"`
|
||||
}
|
||||
|
||||
// customerTxn is one ledger row in the detail's top-up/usage history.
|
||||
type customerTxn struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // "deposit" (credit) | "withdraw" (usage)
|
||||
Cents int64 `json:"cents"`
|
||||
Currency string `json:"currency"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
// customerDetailData is the GET /v1/admin/customers/:org payload.
|
||||
type customerDetailData struct {
|
||||
Org string `json:"org"`
|
||||
Display string `json:"display"`
|
||||
OwnerEmail string `json:"ownerEmail"`
|
||||
Plan string `json:"plan"`
|
||||
Status string `json:"status"`
|
||||
Created string `json:"created"`
|
||||
BalanceCents int64 `json:"balanceCents"`
|
||||
SpendCents int64 `json:"spendCents"`
|
||||
MRRCents int64 `json:"mrrCents"`
|
||||
APIKeys int `json:"apiKeys"`
|
||||
Users []customerUser `json:"users"`
|
||||
Transactions []customerTxn `json:"transactions"`
|
||||
}
|
||||
|
||||
// ── GET /v1/admin/customers — the fleet customer list ────────────────────────
|
||||
|
||||
// maxCustomerConcurrency bounds the per-org enrichment fan-out so a large fleet
|
||||
// does not open one upstream connection per org at once. Admin is low-QPS; 8 keeps
|
||||
// latency low without hammering IAM/commerce.
|
||||
const maxCustomerConcurrency = 8
|
||||
|
||||
func customers(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
cr := callerCreds(c)
|
||||
orgs, err := listOrgs(s, ctx, cr)
|
||||
if err != nil {
|
||||
return fail(c, err.Error())
|
||||
}
|
||||
|
||||
rows := make([]customerRow, len(orgs))
|
||||
sem := make(chan struct{}, maxCustomerConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
for i, o := range orgs {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(i int, o iamOrg) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
rows[i] = enrichCustomer(s, ctx, cr, o)
|
||||
}(i, o)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].Org < rows[j].Org })
|
||||
return okList(c, rows, len(rows))
|
||||
}
|
||||
|
||||
// enrichCustomer folds one org's real IAM + commerce reads into a customer row.
|
||||
// Each read is best-effort: an upstream miss degrades that field to its honest
|
||||
// zero/empty (never a fabricated value), so one flaky org never fails the fleet.
|
||||
func enrichCustomer(s *cloud.Service[state], ctx context.Context, cr creds, o iamOrg) customerRow {
|
||||
subj := orgSubject(o.Name)
|
||||
users, _ := orgUsers(s, ctx, cr, o.Name)
|
||||
spend, credits := orgMoney(s, ctx, o.Name)
|
||||
sub, _ := s.State.commerce.subscriptionSummary(ctx, o.Name, subj)
|
||||
|
||||
return customerRow{
|
||||
Org: o.Name,
|
||||
Display: display(o.DisplayName, o.Name),
|
||||
OwnerEmail: ownerEmail(users),
|
||||
Plan: sub.Plan,
|
||||
Status: statusOf(users),
|
||||
Users: len(users),
|
||||
BalanceCents: credits,
|
||||
SpendCents: spend,
|
||||
MRRCents: sub.MRR,
|
||||
Created: o.CreatedTime,
|
||||
LastActive: lastActiveOf(users),
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /v1/admin/customers/:org — one customer's detail ─────────────────────
|
||||
|
||||
func customerDetail(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
cr := callerCreds(c)
|
||||
org := customerOrgParam(c)
|
||||
if org == "" {
|
||||
return fail(c, "org is required")
|
||||
}
|
||||
|
||||
o, err := findOrg(s, ctx, cr, org)
|
||||
if err != nil {
|
||||
return fail(c, err.Error())
|
||||
}
|
||||
if o == nil {
|
||||
return c.JSON(404, map[string]any{"status": "error", "msg": "customer not found", "data": nil})
|
||||
}
|
||||
|
||||
subj := orgSubject(org)
|
||||
users, _ := orgUsers(s, ctx, cr, org)
|
||||
spend, credits := orgMoney(s, ctx, org)
|
||||
sub, _ := s.State.commerce.subscriptionSummary(ctx, org, subj)
|
||||
txns, _ := s.State.commerce.transactions(ctx, org, subj, 50)
|
||||
|
||||
rows := make([]customerUser, 0, len(users))
|
||||
apiKeys := 0
|
||||
for _, u := range users {
|
||||
hasKey := strings.TrimSpace(u.AccessKey) != ""
|
||||
if hasKey {
|
||||
apiKeys++
|
||||
}
|
||||
rows = append(rows, customerUser{
|
||||
Name: u.Name,
|
||||
Email: u.Email,
|
||||
IsAdmin: u.IsAdmin,
|
||||
Forbidden: u.IsForbidden,
|
||||
HasAPIKey: hasKey,
|
||||
LastSignin: u.LastSigninTime,
|
||||
Created: u.CreatedTime,
|
||||
})
|
||||
}
|
||||
|
||||
ledger := make([]customerTxn, 0, len(txns))
|
||||
for _, t := range txns {
|
||||
ledger = append(ledger, customerTxn{
|
||||
ID: t.ID,
|
||||
Type: t.Type,
|
||||
Cents: t.Amount,
|
||||
Currency: t.Currency,
|
||||
Notes: t.Notes,
|
||||
Time: t.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
return ok(c, customerDetailData{
|
||||
Org: org,
|
||||
Display: display(o.DisplayName, org),
|
||||
OwnerEmail: ownerEmail(users),
|
||||
Plan: sub.Plan,
|
||||
Status: statusOf(users),
|
||||
Created: o.CreatedTime,
|
||||
BalanceCents: credits,
|
||||
SpendCents: spend,
|
||||
MRRCents: sub.MRR,
|
||||
APIKeys: apiKeys,
|
||||
Users: rows,
|
||||
Transactions: ledger,
|
||||
})
|
||||
}
|
||||
|
||||
// ── POST /v1/admin/customers/:org/credit — grant credit ──────────────────────
|
||||
|
||||
// creditRequest is the grant body. AmountCents is the credit to add (positive
|
||||
// only — a grant, never a silent debit). Reason is the operator's justification,
|
||||
// recorded in the audit trail's before/after (refund / comp / support).
|
||||
type creditRequest struct {
|
||||
AmountCents int64 `json:"amountCents"`
|
||||
Currency string `json:"currency"`
|
||||
Reason string `json:"reason"`
|
||||
// Source splits the grant into the commerce ledger's two money buckets:
|
||||
// - "trial" (default) — a non-cash promo/comp credit (billing/bucket
|
||||
// Credit): spendable on non-premium metered usage only, NEVER
|
||||
// refundable cash and NEVER paid out. A staff comp is a trial
|
||||
// grant by default (we don't mint payout-able money on a comp).
|
||||
// - "prepaid" — real money added to the customer's cash balance (e.g. a
|
||||
// manual settlement of a wire). Refundable, GPU-eligible.
|
||||
// Mapped to the commerce deposit Tags DepositKind reads (grant:* → Credit,
|
||||
// bare admin-grant → Prepaid). Unknown/empty → trial (fail-closed to non-cash).
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// grantTag maps a grant source to the commerce deposit Tags that billing/bucket
|
||||
// DepositKind classifies into Credit (trial) vs Prepaid (real money). Default
|
||||
// (empty/unknown/"trial") is the non-cash Credit bucket — a staff comp is never
|
||||
// silently minted as payout-able real money.
|
||||
func grantTag(source string) (tag, normalized string) {
|
||||
if strings.ToLower(strings.TrimSpace(source)) == "prepaid" {
|
||||
return "admin-grant", "prepaid" // DepositKind: bare → Prepaid (real money)
|
||||
}
|
||||
return "grant:admin", "trial" // DepositKind: grant:* → Credit (non-cash trial)
|
||||
}
|
||||
|
||||
// maxGrantCents caps a single grant at $100,000 — a guardrail against a fat-finger
|
||||
// operator credit, not a policy limit. A larger comp is deliberate + should be
|
||||
// deliberate (two grants), and the cap keeps a typo from minting a fortune.
|
||||
const maxGrantCents int64 = 100 * 100 * 1000
|
||||
|
||||
func grantCredit(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org := customerOrgParam(c)
|
||||
if org == "" {
|
||||
return fail(c, "org is required")
|
||||
}
|
||||
var req creditRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return fail(c, "invalid request body")
|
||||
}
|
||||
return applyGrant(s, c, org, req)
|
||||
}
|
||||
|
||||
// applyGrant is the ONE credit-write core shared by POST /v1/admin/customers/:org/credit
|
||||
// (org from the path) and POST /v1/admin/grants (org from the body): validate the
|
||||
// amount + target org, deposit into the org's commerce ledger (trial vs prepaid by
|
||||
// source), and record the tamper-evident audit row. One path, one way to grant.
|
||||
func applyGrant(s *cloud.Service[state], c *zip.Ctx, org string, req creditRequest) error {
|
||||
ctx := c.Context()
|
||||
cr := callerCreds(c)
|
||||
if req.AmountCents <= 0 {
|
||||
return fail(c, "amountCents must be positive")
|
||||
}
|
||||
if req.AmountCents > maxGrantCents {
|
||||
return fail(c, fmt.Sprintf("amountCents exceeds the %d-cent per-grant cap", maxGrantCents))
|
||||
}
|
||||
currency := strings.ToLower(strings.TrimSpace(req.Currency))
|
||||
if currency == "" {
|
||||
currency = "usd"
|
||||
}
|
||||
|
||||
// Validate the target is a REAL org (never mint an orphan wallet on a typo).
|
||||
o, err := findOrg(s, ctx, cr, org)
|
||||
if err != nil {
|
||||
return fail(c, err.Error())
|
||||
}
|
||||
if o == nil {
|
||||
return c.JSON(404, map[string]any{"status": "error", "msg": "customer not found", "data": nil})
|
||||
}
|
||||
|
||||
subj := orgSubject(org)
|
||||
before, _ := s.State.commerce.creditsCents(ctx, org, subj)
|
||||
|
||||
tag, source := grantTag(req.Source)
|
||||
notes := grantNote(c, req.Reason)
|
||||
res, derr := s.State.commerce.deposit(ctx, org, subj, req.AmountCents, currency, notes, tag)
|
||||
if derr != nil {
|
||||
// The grant did not land — record the FAILED attempt (accountability), then
|
||||
// surface the error. Never report a grant that failed as success.
|
||||
emitAudit(s, c, "admin.customer.credit", "credit", org,
|
||||
map[string]any{"balanceCents": before},
|
||||
map[string]any{"amountCents": req.AmountCents, "currency": currency, "reason": req.Reason, "source": source, "error": derr.Error()},
|
||||
audit.Outcome{Result: "error", Status: 200, Reason: "grant failed"})
|
||||
return fail(c, "grant failed: "+derr.Error())
|
||||
}
|
||||
|
||||
after, _ := s.State.commerce.creditsCents(ctx, org, subj)
|
||||
emitAudit(s, c, "admin.customer.credit", "credit", org,
|
||||
map[string]any{"balanceCents": before},
|
||||
map[string]any{"balanceCents": after, "grantedCents": req.AmountCents, "currency": currency, "reason": req.Reason, "source": source, "transactionId": res.TransactionID},
|
||||
audit.Outcome{Result: "success", Status: 200})
|
||||
|
||||
return ok(c, map[string]any{
|
||||
"org": org,
|
||||
"grantedCents": req.AmountCents,
|
||||
"currency": currency,
|
||||
"source": source,
|
||||
"balanceCents": after,
|
||||
"transactionId": res.TransactionID,
|
||||
})
|
||||
}
|
||||
|
||||
// ── POST /v1/admin/customers/:org/{suspend,reactivate} — access control ──────
|
||||
|
||||
func suspendCustomer(s *cloud.Service[state], c *zip.Ctx) error { return setForbidden(s, c, true) }
|
||||
func reactivateCustomer(s *cloud.Service[state], c *zip.Ctx) error { return setForbidden(s, c, false) }
|
||||
|
||||
// setForbidden flips IAM `isForbidden` on every member of the org — suspend
|
||||
// (forbidden=true) cuts login + token issuance; reactivate restores it. Each
|
||||
// user's FULL object is read, the one field flipped, and written back (update-user
|
||||
// replaces the row), replaying the caller's global-admin credential so IAM
|
||||
// authorizes it. Best-effort per user with an aggregated result: a partial failure
|
||||
// is reported honestly (affected vs failed), never masked as a clean success. The
|
||||
// action is recorded with a redacted before/after user tally.
|
||||
func setForbidden(s *cloud.Service[state], c *zip.Ctx, forbidden bool) error {
|
||||
ctx := c.Context()
|
||||
cr := callerCreds(c)
|
||||
org := customerOrgParam(c)
|
||||
if org == "" {
|
||||
return fail(c, "org is required")
|
||||
}
|
||||
|
||||
o, err := findOrg(s, ctx, cr, org)
|
||||
if err != nil {
|
||||
return fail(c, err.Error())
|
||||
}
|
||||
if o == nil {
|
||||
return c.JSON(404, map[string]any{"status": "error", "msg": "customer not found", "data": nil})
|
||||
}
|
||||
|
||||
users, err := orgUsers(s, ctx, cr, org)
|
||||
if err != nil {
|
||||
return fail(c, err.Error())
|
||||
}
|
||||
|
||||
beforeForbidden := 0
|
||||
for _, u := range users {
|
||||
if u.IsForbidden {
|
||||
beforeForbidden++
|
||||
}
|
||||
}
|
||||
|
||||
var affected, failed []string
|
||||
for _, u := range users {
|
||||
id := u.Owner + "/" + u.Name
|
||||
full, gerr := s.State.iam.getUserRaw(ctx, cr, id)
|
||||
if gerr != nil {
|
||||
failed = append(failed, u.Name)
|
||||
continue
|
||||
}
|
||||
full["isForbidden"] = forbidden
|
||||
if uerr := s.State.iam.updateUserRaw(ctx, cr, id, full); uerr != nil {
|
||||
failed = append(failed, u.Name)
|
||||
continue
|
||||
}
|
||||
affected = append(affected, u.Name)
|
||||
}
|
||||
|
||||
action := "admin.customer.suspend"
|
||||
if !forbidden {
|
||||
action = "admin.customer.reactivate"
|
||||
}
|
||||
result := "success"
|
||||
reason := ""
|
||||
if len(failed) > 0 {
|
||||
result = "error"
|
||||
reason = fmt.Sprintf("%d user(s) not updated", len(failed))
|
||||
}
|
||||
emitAudit(s, c, action, "customer", org,
|
||||
map[string]any{"suspended": beforeForbidden == len(users) && len(users) > 0, "forbiddenUsers": beforeForbidden, "totalUsers": len(users)},
|
||||
map[string]any{"suspended": forbidden, "affected": affected, "failed": failed},
|
||||
audit.Outcome{Result: result, Status: 200, Reason: reason})
|
||||
|
||||
return ok(c, map[string]any{
|
||||
"org": org,
|
||||
"suspended": forbidden,
|
||||
"affected": affected,
|
||||
"failed": failed,
|
||||
})
|
||||
}
|
||||
|
||||
// ── aggregation + derivation helpers ─────────────────────────────────────────
|
||||
|
||||
// orgUsers reads an org's members (a bounded page) as the typed subset the
|
||||
// customer surface folds over. It is the ONE IAM read that yields the user count,
|
||||
// the owner email, the suspend status, and the API-key presence — so a customer
|
||||
// row costs a single get-users call, not four.
|
||||
func orgUsers(s *cloud.Service[state], ctx context.Context, cr creds, org string) ([]iamUser, error) {
|
||||
q := url.Values{}
|
||||
q.Set("owner", org)
|
||||
q.Set("p", "1")
|
||||
q.Set("pageSize", "200")
|
||||
res, err := s.State.iam.getList(ctx, cr, "/v1/iam/get-users", q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw []iamUser
|
||||
if len(res.rows) > 0 {
|
||||
if err := json.Unmarshal(res.rows, &raw); err != nil {
|
||||
return nil, fmt.Errorf("users decode: %w", err)
|
||||
}
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// findOrg returns the IAM org by slug (nil, nil when it does not exist) so a
|
||||
// management action can validate its target before acting — never credit or
|
||||
// suspend an org that isn't real.
|
||||
func findOrg(s *cloud.Service[state], ctx context.Context, cr creds, org string) (*iamOrg, error) {
|
||||
orgs, err := listOrgs(s, ctx, cr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range orgs {
|
||||
if orgs[i].Name == org {
|
||||
return &orgs[i], nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ownerEmail picks the org's admin user's email (the account owner), falling back
|
||||
// to the first user with an email. Empty when no user carries one.
|
||||
func ownerEmail(users []iamUser) string {
|
||||
for _, u := range users {
|
||||
if u.IsAdmin && strings.TrimSpace(u.Email) != "" {
|
||||
return u.Email
|
||||
}
|
||||
}
|
||||
for _, u := range users {
|
||||
if strings.TrimSpace(u.Email) != "" {
|
||||
return u.Email
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// statusOf derives the suspend status: an org is "suspended" only when it has at
|
||||
// least one user and EVERY user is forbidden (a partial forbid is still "active" —
|
||||
// the operator sees the per-user state in the detail). Honest by construction.
|
||||
func statusOf(users []iamUser) string {
|
||||
if len(users) == 0 {
|
||||
return "active"
|
||||
}
|
||||
for _, u := range users {
|
||||
if !u.IsForbidden {
|
||||
return "active"
|
||||
}
|
||||
}
|
||||
return "suspended"
|
||||
}
|
||||
|
||||
// lastActiveOf returns the most recent user sign-in across the org (RFC3339), the
|
||||
// best "last active" signal available from IAM. Empty when no user has signed in.
|
||||
func lastActiveOf(users []iamUser) string {
|
||||
last := ""
|
||||
for _, u := range users {
|
||||
if u.LastSigninTime > last {
|
||||
last = u.LastSigninTime
|
||||
}
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
// customerOrgParam reads + trims the :org path param.
|
||||
func customerOrgParam(c *zip.Ctx) string { return strings.TrimSpace(c.Param("org")) }
|
||||
|
||||
// grantNote composes the deposit note from the operator's reason (bounded), so the
|
||||
// commerce ledger row itself carries the justification alongside the audit trail.
|
||||
func grantNote(c *zip.Ctx, reason string) string {
|
||||
r := strings.TrimSpace(reason)
|
||||
if len(r) > 200 {
|
||||
r = r[:200]
|
||||
}
|
||||
by := strings.TrimSpace(c.UserEmail())
|
||||
if by == "" {
|
||||
by = strings.TrimSpace(c.User())
|
||||
}
|
||||
if r == "" {
|
||||
r = "operator credit"
|
||||
}
|
||||
if by != "" {
|
||||
return fmt.Sprintf("Admin grant by %s: %s", by, r)
|
||||
}
|
||||
return "Admin grant: " + r
|
||||
}
|
||||
|
||||
// emitAudit writes ONE compliance record for a management action to cloud's
|
||||
// tamper-evident trail: who (the validated global admin from the sanitized
|
||||
// identity — the gate already proved it), what (action + resource), the redacted
|
||||
// before/after, and the outcome. This is the "before/after on a config-affecting
|
||||
// change" the request-level middleware record cannot carry (it never reads bodies).
|
||||
// Best-effort: the audit MIDDLEWARE is the AU-5 fail-closed authority for the
|
||||
// request; a failure here is logged loud, never silent, and never double-fails the
|
||||
// response. A nil store (unconfigured deployment) is a no-op, like the middleware.
|
||||
func emitAudit(s *cloud.Service[state], c *zip.Ctx, action, resType, resID string, before, after any, outcome audit.Outcome) {
|
||||
if s.State.auditStore == nil {
|
||||
return
|
||||
}
|
||||
rec := audit.Record{
|
||||
Actor: audit.Actor{Org: strings.TrimSpace(c.Org()), Sub: strings.TrimSpace(c.User()), Email: strings.TrimSpace(c.UserEmail())},
|
||||
Action: action,
|
||||
Resource: audit.Resource{Type: resType, ID: resID},
|
||||
Auth: audit.AuthContext{Method: "jwt", IsAdmin: c.IsAdmin()},
|
||||
Outcome: outcome,
|
||||
UserAgent: c.Header("User-Agent"),
|
||||
RequestID: c.RequestID(),
|
||||
Method: c.Method(),
|
||||
Path: c.Path(),
|
||||
Before: audit.Redact(mustJSON(before)),
|
||||
After: audit.Redact(mustJSON(after)),
|
||||
}
|
||||
if _, err := s.State.auditStore.Append(c.Context(), rec); err != nil {
|
||||
c.Log().Error("admin: audit emit failed (request-level record still applies)",
|
||||
"action", action, "resource", resType, "id", resID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// mustJSON marshals v to raw JSON for the audit before/after, returning an empty
|
||||
// object on the (unexpected) marshal error rather than panicking — a metadata
|
||||
// diff must never crash a money/access action.
|
||||
func mustJSON(v any) json.RawMessage {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return json.RawMessage("{}")
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// doClient reads DigitalOcean's billing API for the finance dashboard's cost
|
||||
// side. DO is our PRIMARY venue (a ~$40k promotional credit); this client turns
|
||||
// the customer balance + billing history into the cents the finance aggregator
|
||||
// folds into gross margin and runway.
|
||||
//
|
||||
// Auth is a single personal-access token, DO_API_TOKEN, sourced from a KMSSecret
|
||||
// on the cloud env — NEVER hard-coded (kms.hanzo.ai is the only secret store).
|
||||
// When the token is unset the client is UNCONFIGURED and every read reports the
|
||||
// honest not-configured state; the finance endpoint then returns
|
||||
// cost.digitalocean = {configured:false} rather than a fabricated number.
|
||||
//
|
||||
// DIGITALOCEAN SIGN CONVENTION (authoritative, from DO's public OpenAPI spec):
|
||||
// GET /v2/customers/my/balance returns three DECIMAL-DOLLAR STRINGS —
|
||||
// - account_balance: most-recent billing balance, accounts-receivable sign.
|
||||
// POSITIVE = the customer OWES DO; NEGATIVE = the customer
|
||||
// holds CREDIT (DO owes us). Our promo credit shows as a
|
||||
// NEGATIVE account_balance, so credit-remaining = -account_balance.
|
||||
// - month_to_date_usage: spend in the current billing period (positive dollars).
|
||||
// - month_to_date_balance = account_balance + month_to_date_usage.
|
||||
//
|
||||
// We convert dollars→cents once at the edge and work in int64 cents everywhere after.
|
||||
type doClient struct {
|
||||
base string // DO API base; https://api.digitalocean.com in prod
|
||||
token string // DO_API_TOKEN (secret; never logged)
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// doAPIBase is DigitalOcean's public API host. Overridable in tests via
|
||||
// newDOClientWithBase so a fake server can stand in.
|
||||
const doAPIBase = "https://api.digitalocean.com"
|
||||
|
||||
func newDOClient(token string) *doClient {
|
||||
return newDOClientWithBase(doAPIBase, token)
|
||||
}
|
||||
|
||||
func newDOClientWithBase(base, token string) *doClient {
|
||||
return &doClient{
|
||||
base: strings.TrimRight(strings.TrimSpace(base), "/"),
|
||||
token: strings.TrimSpace(token),
|
||||
http: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// configured reports whether a DO token is present. Unconfigured → the finance
|
||||
// endpoint returns cost.digitalocean = {configured:false}, never a fake balance.
|
||||
func (c *doClient) configured() bool { return c != nil && c.token != "" }
|
||||
|
||||
// doBalance is the decoded /v2/customers/my/balance response. Dollars are parsed
|
||||
// into cents at decode time so no float dollars leak past this boundary.
|
||||
type doBalance struct {
|
||||
// AccountBalanceCents mirrors DO's account_balance (accounts-receivable sign:
|
||||
// positive = owed to DO, negative = credit we hold).
|
||||
AccountBalanceCents int64
|
||||
MonthToDateBalanceCents int64
|
||||
MonthToDateUsageCents int64
|
||||
GeneratedAt string
|
||||
}
|
||||
|
||||
// doBalanceWire is the raw DO JSON (all money fields are decimal-dollar strings).
|
||||
type doBalanceWire struct {
|
||||
MonthToDateBalance string `json:"month_to_date_balance"`
|
||||
AccountBalance string `json:"account_balance"`
|
||||
MonthToDateUsage string `json:"month_to_date_usage"`
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
}
|
||||
|
||||
// balance fetches the customer balance and converts every dollar string to cents.
|
||||
func (c *doClient) balance(ctx context.Context) (doBalance, error) {
|
||||
var out doBalance
|
||||
if !c.configured() {
|
||||
return out, fmt.Errorf("DO_API_TOKEN not configured")
|
||||
}
|
||||
body, err := c.get(ctx, "/v2/customers/my/balance")
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
var w doBalanceWire
|
||||
if err := json.Unmarshal(body, &w); err != nil {
|
||||
return out, fmt.Errorf("do balance decode: %w", err)
|
||||
}
|
||||
out = doBalance{
|
||||
AccountBalanceCents: dollarsToCents(w.AccountBalance),
|
||||
MonthToDateBalanceCents: dollarsToCents(w.MonthToDateBalance),
|
||||
MonthToDateUsageCents: dollarsToCents(w.MonthToDateUsage),
|
||||
GeneratedAt: strings.TrimSpace(w.GeneratedAt),
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// doHistoryEntry is one row of the billing history (used to build the burn-down
|
||||
// timeseries). amount is a decimal-dollar string in DO's wire.
|
||||
type doHistoryEntry struct {
|
||||
Description string `json:"description"`
|
||||
AmountCents int64 `json:"-"`
|
||||
Amount string `json:"amount"`
|
||||
Date string `json:"date"`
|
||||
Type string `json:"type"`
|
||||
InvoiceID string `json:"invoice_id"`
|
||||
}
|
||||
|
||||
// history fetches recent billing history (Invoice/Credit/Payment entries). Used
|
||||
// only to render the credit burn-down series; a failure here is non-fatal (the
|
||||
// finance endpoint still returns the balance-derived tiles with an empty series).
|
||||
func (c *doClient) history(ctx context.Context, perPage int) ([]doHistoryEntry, error) {
|
||||
if !c.configured() {
|
||||
return nil, fmt.Errorf("DO_API_TOKEN not configured")
|
||||
}
|
||||
if perPage <= 0 {
|
||||
perPage = 50
|
||||
}
|
||||
body, err := c.get(ctx, "/v2/customers/my/billing_history?per_page="+strconv.Itoa(perPage))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var w struct {
|
||||
BillingHistory []doHistoryEntry `json:"billing_history"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &w); err != nil {
|
||||
return nil, fmt.Errorf("do billing_history decode: %w", err)
|
||||
}
|
||||
for i := range w.BillingHistory {
|
||||
w.BillingHistory[i].AmountCents = dollarsToCents(w.BillingHistory[i].Amount)
|
||||
}
|
||||
return w.BillingHistory, nil
|
||||
}
|
||||
|
||||
// get performs one token-authenticated DO GET and returns the raw body.
|
||||
func (c *doClient) get(ctx context.Context, path string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("digitalocean unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("digitalocean status %d", resp.StatusCode)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// dollarsToCents parses a DO decimal-dollar string ("23.44", "-40000.00") into
|
||||
// integer cents, rounding to the nearest cent. A blank/invalid string is 0 —
|
||||
// DO always sends a value, so this only guards against a malformed field, and a
|
||||
// zero there is the honest fallback (never a fabricated amount).
|
||||
func dollarsToCents(s string) int64 {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int64(math.Round(f * 100))
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// Package do reads DigitalOcean's billing API for the finance dashboard's cost
|
||||
// side. DO is our PRIMARY venue (a large promotional credit); this client turns
|
||||
// the customer balance + billing history into money.Cents the finance aggregator
|
||||
// folds into gross margin and runway.
|
||||
//
|
||||
// Auth is a single personal-access token, DO_API_TOKEN, sourced from a KMSSecret on
|
||||
// the cloud env — NEVER hard-coded. When the token is unset the client is not Ready
|
||||
// and every read reports the honest not-configured state.
|
||||
//
|
||||
// SIGN CONVENTION (from DO's public OpenAPI spec): GET /v2/customers/my/balance
|
||||
// returns decimal-dollar strings; account_balance carries the accounts-receivable
|
||||
// sign — POSITIVE = we OWE DO, NEGATIVE = we hold CREDIT. Our promo credit shows as
|
||||
// a negative account balance, so credit-remaining = -Account. Dollars are converted
|
||||
// to cents once at this edge; everything downstream is money.Cents.
|
||||
package digitalocean
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/admin/money"
|
||||
)
|
||||
|
||||
// apiBase is DigitalOcean's public API host. Overridable in tests via NewWithBase.
|
||||
const apiBase = "https://api.digitalocean.com"
|
||||
|
||||
// Client reads DigitalOcean billing with a personal-access token.
|
||||
type Client struct {
|
||||
base string
|
||||
token string // DO_API_TOKEN (secret; never logged)
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// New builds a DO client against the public API.
|
||||
func New(token string) *Client { return NewWithBase(apiBase, token) }
|
||||
|
||||
// NewWithBase builds a DO client against base (a test may point it at a stub).
|
||||
func NewWithBase(base, token string) *Client {
|
||||
return &Client{
|
||||
base: strings.TrimRight(strings.TrimSpace(base), "/"),
|
||||
token: strings.TrimSpace(token),
|
||||
http: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Ready reports whether a DO token is present.
|
||||
func (c *Client) Ready() bool { return c != nil && c.token != "" }
|
||||
|
||||
// Balance is the decoded /v2/customers/my/balance, in cents. Account carries DO's
|
||||
// accounts-receivable sign (positive = owed to DO, negative = credit we hold).
|
||||
type Balance struct {
|
||||
Account money.Cents
|
||||
MonthToDate money.Cents
|
||||
Usage money.Cents
|
||||
At string
|
||||
}
|
||||
|
||||
// balanceWire is the raw DO JSON (all money fields are decimal-dollar strings).
|
||||
type balanceWire struct {
|
||||
MonthToDateBalance string `json:"month_to_date_balance"`
|
||||
AccountBalance string `json:"account_balance"`
|
||||
MonthToDateUsage string `json:"month_to_date_usage"`
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
}
|
||||
|
||||
// Balance fetches the customer balance, converting every dollar string to cents.
|
||||
func (c *Client) Balance(ctx context.Context) (Balance, error) {
|
||||
var out Balance
|
||||
if !c.Ready() {
|
||||
return out, fmt.Errorf("DO_API_TOKEN not configured")
|
||||
}
|
||||
body, err := c.get(ctx, "/v2/customers/my/balance")
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
var w balanceWire
|
||||
if err := json.Unmarshal(body, &w); err != nil {
|
||||
return out, fmt.Errorf("do balance decode: %w", err)
|
||||
}
|
||||
return Balance{
|
||||
Account: dollarsToCents(w.AccountBalance),
|
||||
MonthToDate: dollarsToCents(w.MonthToDateBalance),
|
||||
Usage: dollarsToCents(w.MonthToDateUsage),
|
||||
At: strings.TrimSpace(w.GeneratedAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Entry is one billing-history row (used to build the credit burn-down series).
|
||||
type Entry struct {
|
||||
Description string
|
||||
Amount money.Cents
|
||||
Date string
|
||||
Kind string
|
||||
InvoiceID string
|
||||
}
|
||||
|
||||
// entryWire is the raw DO history row (amount is a decimal-dollar string).
|
||||
type entryWire struct {
|
||||
Description string `json:"description"`
|
||||
Amount string `json:"amount"`
|
||||
Date string `json:"date"`
|
||||
Type string `json:"type"`
|
||||
InvoiceID string `json:"invoice_id"`
|
||||
}
|
||||
|
||||
// History fetches recent billing history. Used only for the burn-down series; a
|
||||
// failure is non-fatal to the caller (it renders the balance tiles with no series).
|
||||
func (c *Client) History(ctx context.Context, perPage int) ([]Entry, error) {
|
||||
if !c.Ready() {
|
||||
return nil, fmt.Errorf("DO_API_TOKEN not configured")
|
||||
}
|
||||
if perPage <= 0 {
|
||||
perPage = 50
|
||||
}
|
||||
body, err := c.get(ctx, "/v2/customers/my/billing_history?per_page="+strconv.Itoa(perPage))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var w struct {
|
||||
BillingHistory []entryWire `json:"billing_history"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &w); err != nil {
|
||||
return nil, fmt.Errorf("do billing_history decode: %w", err)
|
||||
}
|
||||
out := make([]Entry, len(w.BillingHistory))
|
||||
for i, e := range w.BillingHistory {
|
||||
out[i] = Entry{
|
||||
Description: e.Description,
|
||||
Amount: dollarsToCents(e.Amount),
|
||||
Date: e.Date,
|
||||
Kind: e.Type,
|
||||
InvoiceID: e.InvoiceID,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// get performs one token-authenticated DO GET and returns the raw body.
|
||||
func (c *Client) get(ctx context.Context, path string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("digitalocean unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("digitalocean status %d", resp.StatusCode)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// dollarsToCents parses a DO decimal-dollar string ("23.44", "-40000.00") into
|
||||
// integer cents, rounding to the nearest cent. A blank/invalid string is 0 — DO
|
||||
// always sends a value, so this only guards a malformed field, and zero there is
|
||||
// the honest fallback (never a fabricated amount).
|
||||
func dollarsToCents(s string) money.Cents {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return money.Cents(math.Round(f * 100))
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package digitalocean
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDollarsToCents(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want int64
|
||||
}{
|
||||
{"23.44", 2344},
|
||||
{"-40000.00", -4_000_000}, // promo credit held (negative account_balance)
|
||||
{"12.23", 1223},
|
||||
{"0", 0},
|
||||
{"", 0},
|
||||
{" 5.5 ", 550},
|
||||
{"garbage", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := int64(dollarsToCents(c.in)); got != c.want {
|
||||
t.Errorf("dollarsToCents(%q) = %d, want %d", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// errUnconfigured marks an upstream that is not wired on this deployment (no DO
|
||||
// token / no commerce URL). srcOf reports it as a not-ok source so the console
|
||||
// shows the honest not-configured state rather than a fabricated read.
|
||||
var errUnconfigured = errors.New("not configured")
|
||||
|
||||
// errPartialRevenue marks a revenue read that succeeded at the org-list level but
|
||||
// had one or more per-org failures — the fleet total is real but PARTIAL. srcOf
|
||||
// reports it as a not-ok source so the console shows a degraded state rather than
|
||||
// presenting an under-count as authoritative.
|
||||
var errPartialRevenue = errors.New("partial: one or more org revenue reads failed")
|
||||
|
||||
// ── /v1/admin/finance — SaaS business/finance dashboard (FinanceData) ─────────
|
||||
//
|
||||
// The profitability panel the Hanzo Admin Console renders on admin.hanzo.ai: what
|
||||
// we pay every vendor (COGS), what we earn, the resulting gross margin, how fast
|
||||
// we're burning the DigitalOcean promo credit, and the runway that credit + burn
|
||||
// imply. It is GLOBAL-ADMIN ONLY (s.guard) — financial data is Hanzo-internal and
|
||||
// must never reach a customer or tenant-admin.
|
||||
//
|
||||
// Like the rest of admin it FABRICATES NOTHING and it OWNS NO cost logic. COGS is
|
||||
// the SINGLE source of truth in commerce (GET /v1/costs: DigitalOcean compute +
|
||||
// the LLM providers we resell) — cloud CONSUMES it, never re-reads a vendor's
|
||||
// billing API to derive a cost, so the margin uses the whole multi-vendor COGS.
|
||||
// Revenue + MRR come from commerce billing (honest zeros when unreachable). The
|
||||
// one direct vendor read that remains is the DigitalOcean promo-CREDIT balance +
|
||||
// burn-down history — an ORTHOGONAL treasury view (how long the credit lasts), NOT
|
||||
// a COGS: commerce tracks what we SPEND with DO (the compute line), not our prepaid
|
||||
// credit balance, so it can't provide it. The derived margin/runway math is a pure
|
||||
// function (computeFinance) with a unit test proving the numbers and every
|
||||
// unconfigured path.
|
||||
|
||||
// financeData is the full /v1/admin/finance aggregate (FinanceData).
|
||||
type financeData struct {
|
||||
Cost financeCost `json:"cost"`
|
||||
Revenue financeRevenue `json:"revenue"`
|
||||
Derived financeDerived `json:"derived"`
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
Sources []sourceStatus `json:"sources"`
|
||||
}
|
||||
|
||||
// financeCost is the platform COGS view — what WE pay our vendors. Its authority
|
||||
// is commerce GET /v1/costs (the SINGLE vendor-COGS source of truth): TotalCents is
|
||||
// the whole-platform COGS the margin math folds, and Vendors is the per-vendor
|
||||
// breakdown (DigitalOcean compute + each LLM provider we resell) the console
|
||||
// renders as a donut. Configured is false (and every number 0) when commerce
|
||||
// /v1/costs is unreachable — the console then shows the honest not-configured state.
|
||||
//
|
||||
// DigitalOcean here is an ORTHOGONAL treasury view (promo-credit remaining + the
|
||||
// burn-down series), NOT part of COGS: its month-to-date spend is NO LONGER the
|
||||
// margin cost (TotalCents is) — it feeds only the runway projection. Commerce owns
|
||||
// the DO compute COGS line; this is our prepaid-credit balance, which commerce
|
||||
// does not track, so it stays a direct DO account read.
|
||||
type financeCost struct {
|
||||
Configured bool `json:"configured"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Period string `json:"period"`
|
||||
TotalCents int64 `json:"totalCents"`
|
||||
Vendors []vendorCost `json:"vendors"`
|
||||
|
||||
DigitalOcean doCost `json:"digitalocean"`
|
||||
}
|
||||
|
||||
// doCost is the DigitalOcean credit + spend view. When Configured is false every
|
||||
// number is zero and the console renders the honest "connect DO_API_TOKEN" state.
|
||||
type doCost struct {
|
||||
Configured bool `json:"configured"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreditRemainingCents int64 `json:"creditRemainingCents"`
|
||||
MonthToDateSpendCents int64 `json:"monthToDateSpendCents"`
|
||||
AvgDailyBurnCents int64 `json:"avgDailyBurnCents"`
|
||||
AccountBalanceCents int64 `json:"accountBalanceCents"`
|
||||
GeneratedAt string `json:"generatedAt,omitempty"`
|
||||
History []doHistoryPoint `json:"history"`
|
||||
}
|
||||
|
||||
// doHistoryPoint is one credit burn-down series point (usage charge over time).
|
||||
type doHistoryPoint struct {
|
||||
Date string `json:"date"`
|
||||
AmountCents int64 `json:"amountCents"`
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// financeRevenue is the commerce revenue view (all money in USD cents).
|
||||
type financeRevenue struct {
|
||||
Configured bool `json:"configured"`
|
||||
TotalRevenueCents int64 `json:"totalRevenueCents"`
|
||||
MRRCents int64 `json:"mrrCents"`
|
||||
CreditsConsumedCents int64 `json:"creditsConsumedCents"`
|
||||
}
|
||||
|
||||
// financeDerived is the pure profitability math. Runway is a pointer so it can be
|
||||
// null (no honest runway when burn is zero or DO is unconfigured).
|
||||
type financeDerived struct {
|
||||
GrossMarginCents int64 `json:"grossMarginCents"`
|
||||
GrossMarginPct float64 `json:"grossMarginPct"`
|
||||
RunwayDays *float64 `json:"runwayDays"`
|
||||
Profitable bool `json:"profitable"`
|
||||
}
|
||||
|
||||
// financeInput is the raw material computeFinance folds into financeData. The
|
||||
// handler fills cost from the commerce COGS read (+ the DO-credit treasury view)
|
||||
// and revenue from commerce billing; the pure function does the math so the
|
||||
// derivation is unit-testable in isolation.
|
||||
type financeInput struct {
|
||||
cost financeCost
|
||||
revenue financeRevenue
|
||||
generatedAt string
|
||||
sources []sourceStatus
|
||||
}
|
||||
|
||||
// computeFinance is the PURE derivation: given the multi-vendor COGS view and the
|
||||
// commerce revenue view, it computes gross margin, margin %, runway, and
|
||||
// profitability. No I/O, no clock, no globals — everything it needs is in
|
||||
// financeInput, which is exactly why the finance math can be tested without any
|
||||
// network.
|
||||
//
|
||||
// grossMarginCents = revenue - COGS(total, all vendors)
|
||||
// grossMarginPct = grossMargin / revenue * 100 (0 when revenue is 0)
|
||||
// runwayDays = DO creditRemaining / DO avgDailyBurn (nil when burn 0 or DO off)
|
||||
// profitable = revenue > COGS
|
||||
func computeFinance(in financeInput) financeData {
|
||||
cost := in.cost.TotalCents
|
||||
rev := in.revenue.TotalRevenueCents
|
||||
|
||||
margin := rev - cost
|
||||
var marginPct float64
|
||||
if rev > 0 {
|
||||
marginPct = (float64(margin) / float64(rev)) * 100
|
||||
}
|
||||
|
||||
// Runway is the DO promo-credit treasury projection (orthogonal to COGS): how
|
||||
// many days the remaining credit lasts at the current DO burn. Nil when DO is
|
||||
// off or burn is 0 — never a fabricated infinity.
|
||||
do := in.cost.DigitalOcean
|
||||
var runway *float64
|
||||
if do.Configured && do.AvgDailyBurnCents > 0 {
|
||||
d := float64(do.CreditRemainingCents) / float64(do.AvgDailyBurnCents)
|
||||
runway = &d
|
||||
}
|
||||
|
||||
return financeData{
|
||||
Cost: in.cost,
|
||||
Revenue: in.revenue,
|
||||
Derived: financeDerived{
|
||||
GrossMarginCents: margin,
|
||||
GrossMarginPct: marginPct,
|
||||
RunwayDays: runway,
|
||||
Profitable: rev > cost,
|
||||
},
|
||||
GeneratedAt: in.generatedAt,
|
||||
Sources: in.sources,
|
||||
}
|
||||
}
|
||||
|
||||
// finance answers GET /v1/admin/finance. It reads the multi-vendor COGS from
|
||||
// commerce /v1/costs, the DO promo-credit/burn-down treasury view, and the fleet
|
||||
// commerce revenue, then hands them to computeFinance. Global-admin only (mounted
|
||||
// under s.guard); no principal / tenant-admin / forged header → 403 before this
|
||||
// handler ever runs.
|
||||
func finance(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
cr := callerCreds(c)
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
period := time.Now().UTC().Format("2006-01")
|
||||
|
||||
var sources []sourceStatus
|
||||
|
||||
// ── COGS: commerce /v1/costs (the single vendor-COGS source of truth) ──
|
||||
// cloud CONSUMES the multi-vendor breakdown (DigitalOcean compute + the LLM
|
||||
// providers we resell) — it does NOT re-derive any vendor cost. TotalCents is
|
||||
// the margin cost. Honest not-configured when commerce is unreachable.
|
||||
cost := financeCost{Period: period}
|
||||
if s.State.commerce.configured() {
|
||||
report, err := s.State.commerce.costs(ctx, period)
|
||||
if err != nil {
|
||||
cost.Error = err.Error()
|
||||
sources = append(sources, srcOf("commerce-costs", err, 0, now))
|
||||
} else {
|
||||
cost.Configured = true
|
||||
cost.TotalCents = report.TotalCents
|
||||
cost.Vendors = report.Vendors
|
||||
if report.Period != "" {
|
||||
cost.Period = report.Period
|
||||
}
|
||||
sources = append(sources, srcOf("commerce-costs", nil, len(report.Vendors), now))
|
||||
}
|
||||
} else {
|
||||
cost.Error = "commerce /v1/costs not configured"
|
||||
sources = append(sources, srcOf("commerce-costs", errUnconfigured, 0, now))
|
||||
}
|
||||
if cost.Vendors == nil {
|
||||
cost.Vendors = []vendorCost{}
|
||||
}
|
||||
|
||||
// ── DigitalOcean promo-credit / runway (orthogonal treasury view) ──
|
||||
// The one direct vendor read that remains: our DO prepaid-credit balance +
|
||||
// burn-down history, which commerce does not track. Its MTD spend feeds ONLY
|
||||
// the runway projection — it is NOT the margin cost (that is cost.TotalCents).
|
||||
do := doCost{Configured: s.State.do.configured()}
|
||||
if !s.State.do.configured() {
|
||||
do.Error = "DO_API_TOKEN not configured"
|
||||
sources = append(sources, srcOf("digitalocean", errUnconfigured, 0, now))
|
||||
} else {
|
||||
bal, err := s.State.do.balance(ctx)
|
||||
if err != nil {
|
||||
do.Error = err.Error()
|
||||
sources = append(sources, srcOf("digitalocean", err, 0, now))
|
||||
} else {
|
||||
// creditRemaining = -account_balance clamped at 0 (negative account
|
||||
// balance = credit we hold; a positive balance means we owe DO → 0 credit).
|
||||
credit := -bal.AccountBalanceCents
|
||||
if credit < 0 {
|
||||
credit = 0
|
||||
}
|
||||
do.CreditRemainingCents = credit
|
||||
do.MonthToDateSpendCents = bal.MonthToDateUsageCents
|
||||
do.AccountBalanceCents = bal.AccountBalanceCents
|
||||
do.GeneratedAt = bal.GeneratedAt
|
||||
do.AvgDailyBurnCents = avgDailyBurnCents(bal.MonthToDateUsageCents, time.Now().UTC())
|
||||
do.History = doHistory(s, ctx)
|
||||
sources = append(sources, srcOf("digitalocean", nil, 1, now))
|
||||
}
|
||||
}
|
||||
if do.History == nil {
|
||||
do.History = []doHistoryPoint{}
|
||||
}
|
||||
cost.DigitalOcean = do
|
||||
|
||||
// ── Revenue: commerce (fleet-wide) ────────────────────────────────────
|
||||
// Configured means the revenue source was actually READ, not merely wired: on a
|
||||
// transient IAM/commerce failure it stays FALSE so computeFinance and the console
|
||||
// never fabricate a negative margin / red "burning" alarm from a fake zero.
|
||||
rev := financeRevenue{}
|
||||
if !s.State.commerce.configured() {
|
||||
sources = append(sources, srcOf("commerce", errUnconfigured, 0, now))
|
||||
} else if orgs, orgErr := listOrgs(s, ctx, cr); orgErr != nil {
|
||||
// The revenue source is unreadable → honest not-configured, never a zero
|
||||
// that would flip the margin negative on an upstream hiccup.
|
||||
sources = append(sources, srcOf("commerce", orgErr, 0, now))
|
||||
} else {
|
||||
var totalRev, mrr int64
|
||||
partial := false
|
||||
for _, o := range orgs {
|
||||
subj := orgSubject(o.Name)
|
||||
if r, e := s.State.commerce.usageRollup(ctx, o.Name, subj); e == nil {
|
||||
totalRev += r.ConsumedCents
|
||||
} else {
|
||||
partial = true
|
||||
}
|
||||
if m, e := s.State.commerce.mrrCents(ctx, o.Name, subj); e == nil {
|
||||
mrr += m
|
||||
} else {
|
||||
partial = true
|
||||
}
|
||||
}
|
||||
// Realized revenue = what customers consumed (metered spend). Credits
|
||||
// consumed mirrors that same figure at the fleet level.
|
||||
rev.Configured = true
|
||||
rev.TotalRevenueCents = totalRev
|
||||
rev.CreditsConsumedCents = totalRev
|
||||
rev.MRRCents = mrr
|
||||
// A per-org read failure means the fleet total is PARTIAL — mark the source
|
||||
// not-ok so the console shows a degraded state, never presents an under-count
|
||||
// as authoritative.
|
||||
if partial {
|
||||
sources = append(sources, srcOf("commerce", errPartialRevenue, len(orgs), now))
|
||||
} else {
|
||||
sources = append(sources, srcOf("commerce", nil, len(orgs), now))
|
||||
}
|
||||
}
|
||||
|
||||
return ok(c, computeFinance(financeInput{
|
||||
cost: cost,
|
||||
revenue: rev,
|
||||
generatedAt: now,
|
||||
sources: sources,
|
||||
}))
|
||||
}
|
||||
|
||||
// doHistory reads DO billing history into the burn-down series (best-effort:
|
||||
// a failure yields an empty series, never a fabricated trend). Only usage-side
|
||||
// entries (Invoice/charges) shape the burn-down; the series stays honest-empty
|
||||
// when history is unavailable.
|
||||
func doHistory(s *cloud.Service[state], ctx context.Context) []doHistoryPoint {
|
||||
entries, err := s.State.do.history(ctx, 60)
|
||||
if err != nil {
|
||||
return []doHistoryPoint{}
|
||||
}
|
||||
pts := make([]doHistoryPoint, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
pts = append(pts, doHistoryPoint{
|
||||
Date: e.Date,
|
||||
AmountCents: e.AmountCents,
|
||||
Type: e.Type,
|
||||
Description: e.Description,
|
||||
})
|
||||
}
|
||||
return pts
|
||||
}
|
||||
|
||||
// avgDailyBurnCents derives the average daily DO burn from month-to-date usage:
|
||||
// month-to-date spend divided by the number of elapsed days in the current month
|
||||
// (at least 1, so day 1 doesn't divide by zero). This is the honest run-rate the
|
||||
// runway projection uses — a real read (MTD usage) over real elapsed time, never
|
||||
// an invented rate.
|
||||
func avgDailyBurnCents(monthToDateSpendCents int64, now time.Time) int64 {
|
||||
day := now.Day()
|
||||
if day < 1 {
|
||||
day = 1
|
||||
}
|
||||
return monthToDateSpendCents / int64(day)
|
||||
}
|
||||
|
||||
// doTokenFromEnv reads the DigitalOcean token from the environment. Sourced from
|
||||
// a KMSSecret on the cloud deployment (DO_API_TOKEN) — never hard-coded.
|
||||
func doTokenFromEnv() string {
|
||||
return strings.TrimSpace(os.Getenv("DO_API_TOKEN"))
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package finance
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/admin/core"
|
||||
"github.com/hanzoai/cloud/clients/commerceclient"
|
||||
ledger "github.com/hanzoai/cloud/clients/finance"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// Backfill answers POST /v1/admin/finance/backfill?org=<org> — the ONE-TIME cutover that
|
||||
// carries an org's CURRENT commerce prepaid balance into the native finance wallet. It
|
||||
// reads the pre-migration source of truth (the org's commerce balance for the org-pool
|
||||
// subject == the org slug) and deposits it into finance under the FIXED ref
|
||||
// "backfill:<org>", so re-running the cutover credits the wallet AT MOST ONCE. SuperAdmin
|
||||
// only (core.Guard). Returns { org, migratedCents, entryId }; entryId is "" when the
|
||||
// balance was non-positive (nothing to carry).
|
||||
func Backfill(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
org := strings.TrimSpace(c.Query("org"))
|
||||
if org == "" {
|
||||
return core.Fail(c, "org is required")
|
||||
}
|
||||
ctx := c.Context()
|
||||
|
||||
// Pre-migration source of truth: the org's current commerce prepaid balance for the
|
||||
// org-pool subject (== the org slug), read DIRECTLY from the co-resident embedded
|
||||
// commerce ledger. The admin commerce HTTP client dials an unroutable in-proc address
|
||||
// and reads $0, which would migrate nothing; the native read returns the real figure,
|
||||
// or an ERROR when commerce is not co-resident (never a phantom zero the cutover would
|
||||
// silently carry as "nothing to migrate").
|
||||
balanceCents, err := commerceclient.BalanceCents(ctx, org, org, "usd", false)
|
||||
if err != nil {
|
||||
return core.Fail(c, "read commerce balance: "+err.Error())
|
||||
}
|
||||
|
||||
entryID, err := ledger.MigrateOrg(ctx, org, balanceCents)
|
||||
if err != nil {
|
||||
return core.Fail(c, "finance backfill: "+err.Error())
|
||||
}
|
||||
return core.OK(c, map[string]any{
|
||||
"org": org,
|
||||
"migratedCents": balanceCents,
|
||||
"entryId": entryID,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright © 2026 Hanzo AI. MIT License.
|
||||
|
||||
package finance
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/admin/core"
|
||||
ledger "github.com/hanzoai/cloud/clients/finance"
|
||||
"github.com/hanzoai/cloud/clients/money"
|
||||
"github.com/hanzoai/cloud/types"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// Deposit answers POST /v1/admin/finance/deposit — a SuperAdmin credit into an ARBITRARY
|
||||
// subject's native prepaid wallet. Where the credit-grant + the backfill fund the org POOL
|
||||
// (subject == the org slug), this funds a SPECIFIC wallet: an org pool ("hanzo") or a human
|
||||
// ("hanzo/z" → wallet:z in orgs/hanzo/finance.db). It posts a balanced double-entry credit
|
||||
// on the ONE finance ledger and is additive (no idempotency ref, so distinct grants stack).
|
||||
// SuperAdmin only (core.Guard).
|
||||
//
|
||||
// Params arrive as a JSON body OR query: org, subject, cents (>0), notes?, currency? (usd).
|
||||
// 503 when no finance ledger is co-resident on this deployment; 400 on a missing/invalid
|
||||
// arg or non-positive cents.
|
||||
func Deposit(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
fin := ledger.Current()
|
||||
if fin == nil {
|
||||
return zip.Errorf(503, "finance ledger not co-resident on this deployment")
|
||||
}
|
||||
|
||||
// Body is optional — params may arrive as query instead — so a non-JSON/empty body is
|
||||
// not an error; the query values below fill or override it.
|
||||
var body struct {
|
||||
Org string `json:"org"`
|
||||
Subject string `json:"subject"`
|
||||
Cents int64 `json:"cents"`
|
||||
Notes string `json:"notes"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
_ = c.Bind(&body)
|
||||
|
||||
org := pick(c.Query("org"), body.Org)
|
||||
subject := pick(c.Query("subject"), body.Subject)
|
||||
notes := pick(c.Query("notes"), body.Notes)
|
||||
currency := strings.ToLower(pick(c.Query("currency"), body.Currency))
|
||||
if currency == "" {
|
||||
currency = "usd"
|
||||
}
|
||||
|
||||
cents := body.Cents
|
||||
if q := strings.TrimSpace(c.Query("cents")); q != "" {
|
||||
n, err := strconv.ParseInt(q, 10, 64)
|
||||
if err != nil {
|
||||
return zip.ErrBadRequest("cents must be an integer")
|
||||
}
|
||||
cents = n
|
||||
}
|
||||
|
||||
if org == "" || subject == "" {
|
||||
return zip.ErrBadRequest("org and subject are required")
|
||||
}
|
||||
if cents <= 0 {
|
||||
return zip.ErrBadRequest("cents must be positive")
|
||||
}
|
||||
|
||||
entryID, err := fin.Deposit(c.Context(), types.DepositInput{
|
||||
Org: org,
|
||||
Subject: subject,
|
||||
Amount: money.FromCents(cents),
|
||||
Currency: currency,
|
||||
Notes: notes,
|
||||
})
|
||||
if err != nil {
|
||||
return core.Fail(c, "finance deposit: "+err.Error())
|
||||
}
|
||||
return core.OK(c, map[string]any{
|
||||
"org": org,
|
||||
"subject": subject,
|
||||
"cents": cents,
|
||||
"entryId": entryID,
|
||||
})
|
||||
}
|
||||
|
||||
// pick returns the first non-empty, trimmed value — a query param preferred over the body,
|
||||
// so either channel funds a wallet with one handler.
|
||||
func pick(query, body string) string {
|
||||
if q := strings.TrimSpace(query); q != "" {
|
||||
return q
|
||||
}
|
||||
return strings.TrimSpace(body)
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
// Package finance is the SaaS business/finance dashboard (/v1/admin/finance) — the
|
||||
// profitability panel: what we pay every vendor (COGS), what we earn, the gross margin,
|
||||
// how fast we're burning the DigitalOcean promo credit, and the runway that credit + burn
|
||||
// imply. SUPERADMIN ONLY (core.Guard).
|
||||
//
|
||||
// It FABRICATES NOTHING and OWNS NO cost logic. COGS is the SINGLE source of truth in
|
||||
// commerce (GET /v1/costs) — cloud CONSUMES it. Revenue + MRR come from commerce billing.
|
||||
// The one direct vendor read that remains is the DigitalOcean promo-CREDIT balance +
|
||||
// burn-down history — an ORTHOGONAL treasury view. The derived margin/runway math is a
|
||||
// pure function (ComputeFinance) with a unit test proving the numbers.
|
||||
package finance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/admin/commerce"
|
||||
"github.com/hanzoai/cloud/clients/admin/core"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// errUnconfigured marks an upstream that is not wired on this deployment (no DO token /
|
||||
// no commerce URL). core.SrcOf reports it as a not-ok source so the console shows the
|
||||
// honest not-configured state rather than a fabricated read.
|
||||
var errUnconfigured = errors.New("not configured")
|
||||
|
||||
// Routes registers the finance dashboard (SuperAdmin only).
|
||||
func Routes(app *zip.App, s *cloud.Service[core.State]) {
|
||||
app.Get("/v1/admin/finance", core.Guard(s, Finance))
|
||||
// One-time commerce→finance balance cutover (SuperAdmin only). Idempotent per org.
|
||||
app.Post("/v1/admin/finance/backfill", core.Guard(s, Backfill))
|
||||
// Fund an ARBITRARY subject's native wallet — an org pool or a human ("hanzo/z").
|
||||
// SuperAdmin only; additive (grants stack).
|
||||
app.Post("/v1/admin/finance/deposit", core.Guard(s, Deposit))
|
||||
// Per-provider upstream credit ledger + usage funding split (multi-provider
|
||||
// credit-management). Same SuperAdmin guard, same cloud_usage warehouse.
|
||||
app.Get("/v1/admin/providers/credit", core.Guard(s, ProvidersCredit))
|
||||
app.Get("/v1/admin/usage/funding", core.Guard(s, UsageFunding))
|
||||
}
|
||||
|
||||
// FinanceData is the full /v1/admin/finance aggregate.
|
||||
type FinanceData struct {
|
||||
Cost FinanceCost `json:"cost"`
|
||||
Revenue FinanceRevenue `json:"revenue"`
|
||||
Derived FinanceDerived `json:"derived"`
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
Sources []core.SourceStatus `json:"sources"`
|
||||
}
|
||||
|
||||
// FinanceCost is the platform COGS view — what WE pay our vendors. Its authority is
|
||||
// commerce GET /v1/costs: TotalCents is the whole-platform COGS the margin math folds,
|
||||
// and Vendors is the per-vendor breakdown. Configured is false (and every number 0) when
|
||||
// commerce /v1/costs is unreachable.
|
||||
//
|
||||
// DigitalOcean here is an ORTHOGONAL treasury view (promo-credit remaining + burn-down),
|
||||
// NOT part of COGS: it feeds only the runway projection.
|
||||
type FinanceCost struct {
|
||||
Configured bool `json:"configured"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Period string `json:"period"`
|
||||
TotalCents int64 `json:"totalCents"`
|
||||
Vendors []commerce.Vendor `json:"vendors"`
|
||||
|
||||
DigitalOcean DoCost `json:"digitalocean"`
|
||||
}
|
||||
|
||||
// DoCost is the DigitalOcean credit + spend view. When Configured is false every number
|
||||
// is zero and the console renders the honest "connect DO_API_TOKEN" state.
|
||||
type DoCost struct {
|
||||
Configured bool `json:"configured"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreditRemainingCents int64 `json:"creditRemainingCents"`
|
||||
MonthToDateSpendCents int64 `json:"monthToDateSpendCents"`
|
||||
AvgDailyBurnCents int64 `json:"avgDailyBurnCents"`
|
||||
AccountBalanceCents int64 `json:"accountBalanceCents"`
|
||||
GeneratedAt string `json:"generatedAt,omitempty"`
|
||||
History []DoHistoryPoint `json:"history"`
|
||||
}
|
||||
|
||||
// DoHistoryPoint is one credit burn-down series point (usage charge over time).
|
||||
type DoHistoryPoint struct {
|
||||
Date string `json:"date"`
|
||||
AmountCents int64 `json:"amountCents"`
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// FinanceRevenue is the commerce revenue view (all money in USD cents).
|
||||
type FinanceRevenue struct {
|
||||
Configured bool `json:"configured"`
|
||||
TotalRevenueCents int64 `json:"totalRevenueCents"`
|
||||
MRRCents int64 `json:"mrrCents"`
|
||||
CreditsConsumedCents int64 `json:"creditsConsumedCents"`
|
||||
}
|
||||
|
||||
// FinanceDerived is the pure profitability math. Runway is a pointer so it can be null
|
||||
// (no honest runway when burn is zero or DO is unconfigured).
|
||||
type FinanceDerived struct {
|
||||
GrossMarginCents int64 `json:"grossMarginCents"`
|
||||
GrossMarginPct float64 `json:"grossMarginPct"`
|
||||
RunwayDays *float64 `json:"runwayDays"`
|
||||
Profitable bool `json:"profitable"`
|
||||
}
|
||||
|
||||
// FinanceInput is the raw material ComputeFinance folds into FinanceData. The handler
|
||||
// fills Cost from the commerce COGS read (+ the DO-credit treasury view) and Revenue from
|
||||
// commerce billing; the pure function does the math so the derivation is unit-testable in
|
||||
// isolation.
|
||||
type FinanceInput struct {
|
||||
Cost FinanceCost
|
||||
Revenue FinanceRevenue
|
||||
GeneratedAt string
|
||||
Sources []core.SourceStatus
|
||||
}
|
||||
|
||||
// ComputeFinance is the PURE derivation: given the multi-vendor COGS view and the commerce
|
||||
// revenue view, it computes gross margin, margin %, runway, and profitability. No I/O.
|
||||
//
|
||||
// grossMarginCents = revenue - COGS(total, all vendors)
|
||||
// grossMarginPct = grossMargin / revenue * 100 (0 when revenue is 0)
|
||||
// runwayDays = DO creditRemaining / DO avgDailyBurn (nil when burn 0 or DO off)
|
||||
// profitable = revenue > COGS
|
||||
func ComputeFinance(in FinanceInput) FinanceData {
|
||||
cost := in.Cost.TotalCents
|
||||
rev := in.Revenue.TotalRevenueCents
|
||||
|
||||
margin := rev - cost
|
||||
var marginPct float64
|
||||
if rev > 0 {
|
||||
marginPct = (float64(margin) / float64(rev)) * 100
|
||||
}
|
||||
|
||||
// Runway is the DO promo-credit treasury projection (orthogonal to COGS). Nil when DO
|
||||
// is off or burn is 0 — never a fabricated infinity.
|
||||
do := in.Cost.DigitalOcean
|
||||
var runway *float64
|
||||
if do.Configured && do.AvgDailyBurnCents > 0 {
|
||||
d := float64(do.CreditRemainingCents) / float64(do.AvgDailyBurnCents)
|
||||
runway = &d
|
||||
}
|
||||
|
||||
return FinanceData{
|
||||
Cost: in.Cost,
|
||||
Revenue: in.Revenue,
|
||||
Derived: FinanceDerived{
|
||||
GrossMarginCents: margin,
|
||||
GrossMarginPct: marginPct,
|
||||
RunwayDays: runway,
|
||||
Profitable: rev > cost,
|
||||
},
|
||||
GeneratedAt: in.GeneratedAt,
|
||||
Sources: in.Sources,
|
||||
}
|
||||
}
|
||||
|
||||
// Finance answers GET /v1/admin/finance. It reads the multi-vendor COGS from commerce
|
||||
// /v1/costs, the DO promo-credit/burn-down treasury view, and the fleet commerce revenue,
|
||||
// then hands them to ComputeFinance. SuperAdmin only.
|
||||
func Finance(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
cr := core.CallerCreds(c)
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
period := time.Now().UTC().Format("2006-01")
|
||||
|
||||
var sources []core.SourceStatus
|
||||
|
||||
// ── COGS: commerce /v1/costs (the single vendor-COGS source of truth) ──
|
||||
cost := FinanceCost{Period: period}
|
||||
if s.State.Commerce.Ready() {
|
||||
report, err := s.State.Commerce.Costs(ctx, period)
|
||||
if err != nil {
|
||||
cost.Error = err.Error()
|
||||
sources = append(sources, core.SrcOf("commerce-costs", err, 0, now))
|
||||
} else {
|
||||
cost.Configured = true
|
||||
cost.TotalCents = int64(report.Total)
|
||||
cost.Vendors = report.Vendors
|
||||
if report.Period != "" {
|
||||
cost.Period = report.Period
|
||||
}
|
||||
sources = append(sources, core.SrcOf("commerce-costs", nil, len(report.Vendors), now))
|
||||
}
|
||||
} else {
|
||||
cost.Error = "commerce /v1/costs not configured"
|
||||
sources = append(sources, core.SrcOf("commerce-costs", errUnconfigured, 0, now))
|
||||
}
|
||||
if cost.Vendors == nil {
|
||||
cost.Vendors = []commerce.Vendor{}
|
||||
}
|
||||
|
||||
// ── DigitalOcean promo-credit / runway (orthogonal treasury view) ──
|
||||
do := DoCost{Configured: s.State.DO.Ready()}
|
||||
if !s.State.DO.Ready() {
|
||||
do.Error = "DO_API_TOKEN not configured"
|
||||
sources = append(sources, core.SrcOf("digitalocean", errUnconfigured, 0, now))
|
||||
} else {
|
||||
bal, err := s.State.DO.Balance(ctx)
|
||||
if err != nil {
|
||||
do.Error = err.Error()
|
||||
sources = append(sources, core.SrcOf("digitalocean", err, 0, now))
|
||||
} else {
|
||||
// creditRemaining = -account_balance clamped at 0 (negative account balance =
|
||||
// credit we hold; a positive balance means we owe DO → 0 credit).
|
||||
credit := -int64(bal.Account)
|
||||
if credit < 0 {
|
||||
credit = 0
|
||||
}
|
||||
do.CreditRemainingCents = credit
|
||||
do.MonthToDateSpendCents = int64(bal.Usage)
|
||||
do.AccountBalanceCents = int64(bal.Account)
|
||||
do.GeneratedAt = bal.At
|
||||
do.AvgDailyBurnCents = AvgDailyBurnCents(int64(bal.Usage), time.Now().UTC())
|
||||
do.History = doHistory(s, ctx)
|
||||
sources = append(sources, core.SrcOf("digitalocean", nil, 1, now))
|
||||
}
|
||||
}
|
||||
if do.History == nil {
|
||||
do.History = []DoHistoryPoint{}
|
||||
}
|
||||
cost.DigitalOcean = do
|
||||
|
||||
// ── Revenue: commerce (fleet-wide) ────────────────────────────────────
|
||||
rev := FinanceRevenue{}
|
||||
if !s.State.Commerce.Ready() {
|
||||
sources = append(sources, core.SrcOf("commerce", errUnconfigured, 0, now))
|
||||
} else if orgs, orgErr := core.ListOrgs(s, ctx, cr); orgErr != nil {
|
||||
// The revenue source is unreadable → honest not-configured, never a zero that
|
||||
// would flip the margin negative on an upstream hiccup.
|
||||
sources = append(sources, core.SrcOf("commerce", orgErr, 0, now))
|
||||
} else {
|
||||
var totalRev, mrr int64
|
||||
partial := false
|
||||
for _, o := range orgs {
|
||||
if sp, e := s.State.Commerce.Spend(ctx, o.Name); e == nil {
|
||||
totalRev += int64(sp.Consumed)
|
||||
} else {
|
||||
partial = true
|
||||
}
|
||||
if pl, e := s.State.Commerce.Plan(ctx, o.Name); e == nil {
|
||||
mrr += int64(pl.MRR)
|
||||
} else {
|
||||
partial = true
|
||||
}
|
||||
}
|
||||
rev.Configured = true
|
||||
rev.TotalRevenueCents = totalRev
|
||||
rev.CreditsConsumedCents = totalRev
|
||||
rev.MRRCents = mrr
|
||||
if partial {
|
||||
sources = append(sources, core.SrcOf("commerce", core.ErrPartialRevenue, len(orgs), now))
|
||||
} else {
|
||||
sources = append(sources, core.SrcOf("commerce", nil, len(orgs), now))
|
||||
}
|
||||
}
|
||||
|
||||
return core.OK(c, ComputeFinance(FinanceInput{
|
||||
Cost: cost,
|
||||
Revenue: rev,
|
||||
GeneratedAt: now,
|
||||
Sources: sources,
|
||||
}))
|
||||
}
|
||||
|
||||
// doHistory reads DO billing history into the burn-down series (best-effort: a failure
|
||||
// yields an empty series, never a fabricated trend).
|
||||
func doHistory(s *cloud.Service[core.State], ctx context.Context) []DoHistoryPoint {
|
||||
entries, err := s.State.DO.History(ctx, 60)
|
||||
if err != nil {
|
||||
return []DoHistoryPoint{}
|
||||
}
|
||||
pts := make([]DoHistoryPoint, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
pts = append(pts, DoHistoryPoint{
|
||||
Date: e.Date,
|
||||
AmountCents: int64(e.Amount),
|
||||
Type: e.Kind,
|
||||
Description: e.Description,
|
||||
})
|
||||
}
|
||||
return pts
|
||||
}
|
||||
|
||||
// AvgDailyBurnCents derives the average daily DO burn from month-to-date usage:
|
||||
// month-to-date spend divided by the number of elapsed days in the current month (at
|
||||
// least 1, so day 1 doesn't divide by zero).
|
||||
func AvgDailyBurnCents(monthToDateSpendCents int64, now time.Time) int64 {
|
||||
day := now.Day()
|
||||
if day < 1 {
|
||||
day = 1
|
||||
}
|
||||
return monthToDateSpendCents / int64(day)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package finance
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestComputeFinance_Math is the PURE derivation proof: given a fixed multi-vendor COGS
|
||||
// view and commerce revenue view, gross margin, margin %, runway, and profitability are
|
||||
// exactly the arithmetic the dashboard promises — no I/O. The margin cost is the COGS
|
||||
// total (all vendors); runway is the DO-credit projection.
|
||||
func TestComputeFinance_Math(t *testing.T) {
|
||||
// COGS: $30k total across vendors. DO treasury: $40k credit, $10k left, burning
|
||||
// $1k/day. Revenue: $35k realized.
|
||||
in := FinanceInput{
|
||||
Cost: FinanceCost{
|
||||
Configured: true,
|
||||
TotalCents: 3_000_000, // $30,000 COGS across all vendors (the margin cost)
|
||||
DigitalOcean: DoCost{
|
||||
Configured: true,
|
||||
CreditRemainingCents: 1_000_000, // $10,000 promo credit remaining
|
||||
AvgDailyBurnCents: 100_000, // $1,000/day DO burn (runway input)
|
||||
},
|
||||
},
|
||||
Revenue: FinanceRevenue{
|
||||
Configured: true,
|
||||
TotalRevenueCents: 3_500_000, // $35,000 revenue
|
||||
MRRCents: 500_000, // $5,000 MRR
|
||||
},
|
||||
}
|
||||
got := ComputeFinance(in)
|
||||
|
||||
// margin = 35,000 - 30,000 = $5,000
|
||||
if got.Derived.GrossMarginCents != 500_000 {
|
||||
t.Errorf("grossMarginCents = %d, want 500000 ($5,000)", got.Derived.GrossMarginCents)
|
||||
}
|
||||
// marginPct = 5,000 / 35,000 * 100 = 14.2857…%
|
||||
if math.Abs(got.Derived.GrossMarginPct-14.285714) > 0.0001 {
|
||||
t.Errorf("grossMarginPct = %f, want ≈14.2857", got.Derived.GrossMarginPct)
|
||||
}
|
||||
// runway = 10,000 / 1,000 = 10 days
|
||||
if got.Derived.RunwayDays == nil || math.Abs(*got.Derived.RunwayDays-10) > 1e-9 {
|
||||
t.Errorf("runwayDays = %v, want 10", got.Derived.RunwayDays)
|
||||
}
|
||||
// revenue (35k) > cost (30k) → profitable this month.
|
||||
if !got.Derived.Profitable {
|
||||
t.Error("profitable must be true when revenue > cost")
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputeFinance_BurningFasterThanEarning proves the red state: cost exceeds revenue →
|
||||
// negative margin, not profitable, runway still finite.
|
||||
func TestComputeFinance_BurningFasterThanEarning(t *testing.T) {
|
||||
in := FinanceInput{
|
||||
Cost: FinanceCost{
|
||||
Configured: true,
|
||||
TotalCents: 4_000_000, // $40,000 COGS (the margin cost)
|
||||
DigitalOcean: DoCost{
|
||||
Configured: true,
|
||||
CreditRemainingCents: 2_000_000, // $20,000 left
|
||||
AvgDailyBurnCents: 200_000, // $2,000/day
|
||||
},
|
||||
},
|
||||
Revenue: FinanceRevenue{Configured: true, TotalRevenueCents: 1_000_000}, // $10,000
|
||||
}
|
||||
got := ComputeFinance(in)
|
||||
if got.Derived.GrossMarginCents != -3_000_000 { // 10k - 40k = -30k
|
||||
t.Errorf("grossMarginCents = %d, want -3000000", got.Derived.GrossMarginCents)
|
||||
}
|
||||
if got.Derived.Profitable {
|
||||
t.Error("must NOT be profitable when cost > revenue")
|
||||
}
|
||||
// runway = 20,000 / 2,000 = 10 days
|
||||
if got.Derived.RunwayDays == nil || math.Abs(*got.Derived.RunwayDays-10) > 1e-9 {
|
||||
t.Errorf("runwayDays = %v, want 10", got.Derived.RunwayDays)
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputeFinance_HonestUnconfigured proves the DO-off path: no fabricated credit/burn,
|
||||
// runway is NULL (not zero), margin is just revenue (cost 0), and margin % is 0 when
|
||||
// revenue is 0.
|
||||
func TestComputeFinance_HonestUnconfigured(t *testing.T) {
|
||||
in := FinanceInput{
|
||||
Cost: FinanceCost{Configured: false, DigitalOcean: DoCost{Configured: false}}, // commerce + DO both off
|
||||
Revenue: FinanceRevenue{Configured: false},
|
||||
}
|
||||
got := ComputeFinance(in)
|
||||
if got.Cost.DigitalOcean.Configured {
|
||||
t.Error("DO must report configured:false when the token is unset")
|
||||
}
|
||||
if got.Cost.DigitalOcean.CreditRemainingCents != 0 || got.Cost.DigitalOcean.AvgDailyBurnCents != 0 {
|
||||
t.Error("unconfigured DO must not fabricate credit/burn")
|
||||
}
|
||||
// runway is null (nil) — no honest runway without a burn rate.
|
||||
if got.Derived.RunwayDays != nil {
|
||||
t.Errorf("runwayDays must be nil when DO is unconfigured, got %v", *got.Derived.RunwayDays)
|
||||
}
|
||||
if got.Derived.GrossMarginPct != 0 {
|
||||
t.Errorf("grossMarginPct must be 0 when revenue is 0, got %f", got.Derived.GrossMarginPct)
|
||||
}
|
||||
// revenue 0 is not > cost 0 → not profitable.
|
||||
if got.Derived.Profitable {
|
||||
t.Error("zero revenue and zero cost is not profitable")
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputeFinance_ZeroBurnNullRunway proves runway is null when DO is configured but
|
||||
// burn is zero (no division by zero, no fabricated infinity).
|
||||
func TestComputeFinance_ZeroBurnNullRunway(t *testing.T) {
|
||||
in := FinanceInput{
|
||||
Cost: FinanceCost{Configured: true, TotalCents: 0, DigitalOcean: DoCost{Configured: true, CreditRemainingCents: 4_000_000, AvgDailyBurnCents: 0}},
|
||||
Revenue: FinanceRevenue{Configured: true, TotalRevenueCents: 100_000},
|
||||
}
|
||||
got := ComputeFinance(in)
|
||||
if got.Derived.RunwayDays != nil {
|
||||
t.Errorf("runwayDays must be nil when burn is 0, got %v", *got.Derived.RunwayDays)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAvgDailyBurn_ElapsedDays proves the run-rate is MTD spend over elapsed days (≥1), a
|
||||
// real read over real time — never an invented rate.
|
||||
func TestAvgDailyBurn_ElapsedDays(t *testing.T) {
|
||||
// $3,000 MTD on the 10th → $300/day.
|
||||
got := AvgDailyBurnCents(300_000, time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC))
|
||||
if got != 30_000 {
|
||||
t.Errorf("avgDailyBurn = %d, want 30000 ($300/day)", got)
|
||||
}
|
||||
// Day 1 must not divide by zero.
|
||||
if AvgDailyBurnCents(50_000, time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)) != 50_000 {
|
||||
t.Error("day-1 burn must be the full MTD spend (divide by 1)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// Per-provider UPSTREAM credit ledger + usage funding split for admin.hanzo.ai.
|
||||
//
|
||||
// TWO-LEDGER MODEL (do not conflate):
|
||||
// - UPSTREAM (this file): what WE spend at each provider — provider promo credit
|
||||
// (grant) burning down to paid. DigitalOcean's $26k GenAI credit is the first
|
||||
// real row; DO's live remaining/burn/runway come from the DO billing API
|
||||
// (reused from finance.go), every provider's burn from the ONE cloud_usage
|
||||
// ledger. Grants are fixed contractual numbers seeded here (not a live vendor
|
||||
// read); move to KMS/config when there is more than one.
|
||||
// - DOWNSTREAM (clients/commerce): what we bill OUR customers (credit/prepaid/card).
|
||||
// Orthogonal — never mixed with the upstream provider credits above.
|
||||
//
|
||||
// Two SuperAdmin endpoints (the console renders them; this is the authoritative
|
||||
// contract). Both reuse the admin auth guard + the cloud_usage warehouse — no new
|
||||
// datastore, no duplicate reads.
|
||||
package finance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
aiobject "github.com/hanzoai/ai/object"
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/admin/core"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// usageTable is the ONE metered-LLM warehouse ledger (same table clients/usage +
|
||||
// clients/analytics read; the `provider`, `model`, `cost_cents`, `total_tokens`
|
||||
// columns are theirs — DRY, never a second store).
|
||||
const usageTable = "hanzo.cloud_usage"
|
||||
|
||||
// providerGrantsCents seeds KNOWN upstream promo-credit grants, in cents. 0 / absent
|
||||
// => no grant => paid-only. DO $26,000 is the first real row; OpenAI/Anthropic/
|
||||
// Cloudflare/Nebius/Telnyx land here (with their grant amounts) as the user drops keys.
|
||||
var providerGrantsCents = map[string]int64{
|
||||
"do-ai": 2_600_000, // DigitalOcean $26,000 GenAI credit
|
||||
}
|
||||
|
||||
// ProviderCredit is one provider's upstream credit ledger row.
|
||||
type ProviderCredit struct {
|
||||
Provider string `json:"provider"`
|
||||
GrantCents int64 `json:"grant_cents"`
|
||||
BurnCents int64 `json:"burn_cents"`
|
||||
RemainingCents int64 `json:"remaining_cents"`
|
||||
RunwayDays *float64 `json:"runway_days"` // nil when burn is 0 / unknown (never a fabricated infinity)
|
||||
HasCredit bool `json:"has_credit"`
|
||||
IsPaidOnly bool `json:"is_paid_only"`
|
||||
}
|
||||
|
||||
// computeProviderCredits builds the per-provider ledger: grant (seed) + burn
|
||||
// (cloud_usage) + remaining, with DO reconciled against its authoritative billing API
|
||||
// (real remaining/burn/runway). Shared by both endpoints so the funding classifier
|
||||
// and the ledger read never diverge.
|
||||
func computeProviderCredits(ctx context.Context, s *cloud.Service[core.State]) []ProviderCredit {
|
||||
now := time.Now().UTC()
|
||||
burn := providerBurnCents(ctx)
|
||||
|
||||
names := map[string]struct{}{}
|
||||
for p := range providerGrantsCents {
|
||||
names[p] = struct{}{}
|
||||
}
|
||||
for p := range burn {
|
||||
if p != "" {
|
||||
names[p] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]ProviderCredit, 0, len(names))
|
||||
for p := range names {
|
||||
grant := providerGrantsCents[p]
|
||||
row := ProviderCredit{
|
||||
Provider: p,
|
||||
GrantCents: grant,
|
||||
BurnCents: burn[p],
|
||||
HasCredit: grant > 0,
|
||||
IsPaidOnly: grant == 0,
|
||||
}
|
||||
|
||||
// DO: authoritative live read (creditRemaining = -account_balance clamped ≥0,
|
||||
// mirroring finance.go). Consumed = grant - remaining; runway = remaining / burn.
|
||||
if p == "do-ai" && s.State.DO.Ready() {
|
||||
if bal, err := s.State.DO.Balance(ctx); err == nil {
|
||||
credit := -int64(bal.Account)
|
||||
if credit < 0 {
|
||||
credit = 0
|
||||
}
|
||||
row.RemainingCents = credit
|
||||
consumed := grant - credit
|
||||
if consumed < 0 {
|
||||
consumed = 0
|
||||
}
|
||||
row.BurnCents = consumed
|
||||
if adb := AvgDailyBurnCents(int64(bal.Usage), now); adb > 0 {
|
||||
rw := float64(credit) / float64(adb)
|
||||
row.RunwayDays = &rw
|
||||
}
|
||||
out = append(out, row)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Others (or DO unconfigured): remaining = grant - warehouse burn (≥0). Per-
|
||||
// provider runway needs a burn-rate we don't yet derive for non-DO providers.
|
||||
rem := grant - row.BurnCents
|
||||
if rem < 0 {
|
||||
rem = 0
|
||||
}
|
||||
row.RemainingCents = rem
|
||||
out = append(out, row)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Provider < out[j].Provider })
|
||||
return out
|
||||
}
|
||||
|
||||
// providerBurnCents sums cost_cents per provider over ALL time from cloud_usage,
|
||||
// platform-wide (admin view — NOT org-scoped; this is our upstream spend, not a
|
||||
// customer's usage). Honest-empty ({}) on any datastore blip — never 5xxs.
|
||||
func providerBurnCents(ctx context.Context) map[string]int64 {
|
||||
burn := map[string]int64{}
|
||||
if !aiobject.DatastoreEnabled() {
|
||||
return burn
|
||||
}
|
||||
if err := aiobject.EnsureCloudUsageTable(ctx); err != nil {
|
||||
return burn
|
||||
}
|
||||
rows, err := aiobject.DatastoreQuery(ctx,
|
||||
"SELECT provider, sum(cost_cents) AS burn FROM "+usageTable+" GROUP BY provider")
|
||||
if err != nil {
|
||||
return burn
|
||||
}
|
||||
for _, r := range rows {
|
||||
if p := aStr(r["provider"]); p != "" {
|
||||
burn[p] = aI64(r["burn"])
|
||||
}
|
||||
}
|
||||
return burn
|
||||
}
|
||||
|
||||
// ProvidersCredit serves GET /v1/admin/providers/credit — the per-provider upstream
|
||||
// credit ledger. SuperAdmin-guarded (see Routes).
|
||||
func ProvidersCredit(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
return core.OK(c, computeProviderCredits(c.Context(), s))
|
||||
}
|
||||
|
||||
// UsageFundingRow is one (provider, model) usage roll-up tagged by funding class.
|
||||
type UsageFundingRow struct {
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
Funding string `json:"funding"` // credit | paid | paid_only | byo
|
||||
Tokens int64 `json:"tokens"`
|
||||
CostCents int64 `json:"cost_cents"`
|
||||
Requests int64 `json:"requests"`
|
||||
}
|
||||
|
||||
// fundingClass classifies a provider's usage at the PROVIDER level from the ledger:
|
||||
// grant remaining => credit, grant exhausted => paid, no grant => paid_only. The
|
||||
// precise PER-CALL split (and the `byo` class) lands when the ai metering write stamps
|
||||
// a `funding` column on cloud_usage — then UsageFunding GROUP BYs that column directly.
|
||||
func fundingClass(pc ProviderCredit) string {
|
||||
switch {
|
||||
case !pc.HasCredit:
|
||||
return "paid_only"
|
||||
case pc.RemainingCents > 0:
|
||||
return "credit"
|
||||
default:
|
||||
return "paid"
|
||||
}
|
||||
}
|
||||
|
||||
// UsageFunding serves GET /v1/admin/usage/funding?from&to — the per-provider/model
|
||||
// usage split by funding class over the window (default last 30d). SuperAdmin-guarded.
|
||||
func UsageFunding(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
start, end, _, werr := aiobject.ResolveCloudUsageWindow("", c.Query("from"), c.Query("to"), time.Now().UTC())
|
||||
if werr != nil {
|
||||
end = time.Now().UTC()
|
||||
start = end.AddDate(0, 0, -30)
|
||||
}
|
||||
|
||||
cls := map[string]string{}
|
||||
for _, pc := range computeProviderCredits(ctx, s) {
|
||||
cls[pc.Provider] = fundingClass(pc)
|
||||
}
|
||||
|
||||
out := []UsageFundingRow{}
|
||||
if aiobject.DatastoreEnabled() {
|
||||
if err := aiobject.EnsureCloudUsageTable(ctx); err == nil {
|
||||
rows, qerr := aiobject.DatastoreQuery(ctx,
|
||||
"SELECT provider, model, count() AS requests, sum(total_tokens) AS tokens, "+
|
||||
"sum(cost_cents) AS cost_cents FROM "+usageTable+
|
||||
" WHERE timestamp >= ? AND timestamp < ? GROUP BY provider, model ORDER BY cost_cents DESC",
|
||||
tsLit(start), tsLit(end))
|
||||
if qerr == nil {
|
||||
for _, r := range rows {
|
||||
prov := aStr(r["provider"])
|
||||
fund := cls[prov]
|
||||
if fund == "" {
|
||||
fund = "paid_only" // usage from a provider with no grant row
|
||||
}
|
||||
out = append(out, UsageFundingRow{
|
||||
Provider: prov,
|
||||
Model: aStr(r["model"]),
|
||||
Funding: fund,
|
||||
Tokens: aI64(r["tokens"]),
|
||||
CostCents: aI64(r["cost_cents"]),
|
||||
Requests: aI64(r["requests"]),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return core.OK(c, out)
|
||||
}
|
||||
|
||||
// ── trivial warehouse-cell coercers (the usage package's equivalents are unexported) ──
|
||||
|
||||
func aStr(v any) string { s, _ := v.(string); return s }
|
||||
|
||||
func aI64(v any) int64 {
|
||||
switch n := v.(type) {
|
||||
case int64:
|
||||
return n
|
||||
case int:
|
||||
return int64(n)
|
||||
case uint64:
|
||||
return int64(n)
|
||||
case float64:
|
||||
return int64(n)
|
||||
case string:
|
||||
i, _ := strconv.ParseInt(n, 10, 64)
|
||||
return i
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func tsLit(t time.Time) string { return t.UTC().Format("2006-01-02 15:04:05") }
|
||||
@@ -0,0 +1,59 @@
|
||||
package finance
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestFundingClass locks the provider-level funding classification the
|
||||
// /v1/admin/usage/funding endpoint derives from the credit ledger.
|
||||
func TestFundingClass(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
pc ProviderCredit
|
||||
want string
|
||||
}{
|
||||
{"grant with remaining -> credit", ProviderCredit{HasCredit: true, RemainingCents: 2_500_000}, "credit"},
|
||||
{"grant exhausted -> paid", ProviderCredit{HasCredit: true, RemainingCents: 0}, "paid"},
|
||||
{"no grant -> paid_only", ProviderCredit{HasCredit: false, RemainingCents: 0}, "paid_only"},
|
||||
{"no grant, stray remaining -> paid_only", ProviderCredit{HasCredit: false, RemainingCents: 100}, "paid_only"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := fundingClass(tc.pc); got != tc.want {
|
||||
t.Errorf("fundingClass(%+v) = %q, want %q", tc.pc, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAI64 covers the datastore-cell coercion across the driver/JSON transports
|
||||
// (sum()/count() come back as uint64, float64, or decimal-string depending on path).
|
||||
func TestAI64(t *testing.T) {
|
||||
cases := []struct {
|
||||
in any
|
||||
want int64
|
||||
}{
|
||||
{int64(42), 42},
|
||||
{int(42), 42},
|
||||
{uint64(42), 42},
|
||||
{float64(42), 42},
|
||||
{"42", 42},
|
||||
{"2600000", 2_600_000},
|
||||
{nil, 0},
|
||||
{"not-a-number", 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := aI64(tc.in); got != tc.want {
|
||||
t.Errorf("aI64(%#v) = %d, want %d", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestProviderGrantsSeeded asserts DO's $26k grant is the seeded real row (the
|
||||
// live-verifiable acceptance) and is treated as credit-bearing.
|
||||
func TestProviderGrantsSeeded(t *testing.T) {
|
||||
if providerGrantsCents["do-ai"] != 2_600_000 {
|
||||
t.Fatalf("do-ai grant = %d cents, want 2_600_000 ($26k)", providerGrantsCents["do-ai"])
|
||||
}
|
||||
if fundingClass(ProviderCredit{HasCredit: true, RemainingCents: providerGrantsCents["do-ai"]}) != "credit" {
|
||||
t.Error("seeded DO grant with full remaining must classify as credit")
|
||||
}
|
||||
}
|
||||
+15
-170
@@ -3,174 +3,19 @@ package admin
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/admin/core"
|
||||
"github.com/hanzoai/cloud/clients/admin/digitalocean"
|
||||
"github.com/hanzoai/cloud/clients/admin/finance"
|
||||
)
|
||||
|
||||
// TestComputeFinance_Math is the PURE derivation proof: given a fixed multi-vendor
|
||||
// COGS view and commerce revenue view, gross margin, margin %, runway, and
|
||||
// profitability are exactly the arithmetic the dashboard promises — no I/O. The
|
||||
// margin cost is the COGS total (all vendors); runway is the DO-credit projection.
|
||||
func TestComputeFinance_Math(t *testing.T) {
|
||||
// COGS: $30k total across vendors. DO treasury: $40k credit, $10k left, burning
|
||||
// $1k/day. Revenue: $35k realized.
|
||||
in := financeInput{
|
||||
cost: financeCost{
|
||||
Configured: true,
|
||||
TotalCents: 3_000_000, // $30,000 COGS across all vendors (the margin cost)
|
||||
DigitalOcean: doCost{
|
||||
Configured: true,
|
||||
CreditRemainingCents: 1_000_000, // $10,000 promo credit remaining
|
||||
AvgDailyBurnCents: 100_000, // $1,000/day DO burn (runway input)
|
||||
},
|
||||
},
|
||||
revenue: financeRevenue{
|
||||
Configured: true,
|
||||
TotalRevenueCents: 3_500_000, // $35,000 revenue
|
||||
MRRCents: 500_000, // $5,000 MRR
|
||||
},
|
||||
}
|
||||
got := computeFinance(in)
|
||||
|
||||
// margin = 35,000 - 30,000 = $5,000
|
||||
if got.Derived.GrossMarginCents != 500_000 {
|
||||
t.Errorf("grossMarginCents = %d, want 500000 ($5,000)", got.Derived.GrossMarginCents)
|
||||
}
|
||||
// marginPct = 5,000 / 35,000 * 100 = 14.2857…%
|
||||
if math.Abs(got.Derived.GrossMarginPct-14.285714) > 0.0001 {
|
||||
t.Errorf("grossMarginPct = %f, want ≈14.2857", got.Derived.GrossMarginPct)
|
||||
}
|
||||
// runway = 10,000 / 1,000 = 10 days
|
||||
if got.Derived.RunwayDays == nil || math.Abs(*got.Derived.RunwayDays-10) > 1e-9 {
|
||||
t.Errorf("runwayDays = %v, want 10", got.Derived.RunwayDays)
|
||||
}
|
||||
// revenue (35k) > cost (30k) → profitable this month.
|
||||
if !got.Derived.Profitable {
|
||||
t.Error("profitable must be true when revenue > cost")
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputeFinance_BurningFasterThanEarning proves the red state: cost exceeds
|
||||
// revenue → negative margin, not profitable, runway still finite.
|
||||
func TestComputeFinance_BurningFasterThanEarning(t *testing.T) {
|
||||
in := financeInput{
|
||||
cost: financeCost{
|
||||
Configured: true,
|
||||
TotalCents: 4_000_000, // $40,000 COGS (the margin cost)
|
||||
DigitalOcean: doCost{
|
||||
Configured: true,
|
||||
CreditRemainingCents: 2_000_000, // $20,000 left
|
||||
AvgDailyBurnCents: 200_000, // $2,000/day
|
||||
},
|
||||
},
|
||||
revenue: financeRevenue{Configured: true, TotalRevenueCents: 1_000_000}, // $10,000
|
||||
}
|
||||
got := computeFinance(in)
|
||||
if got.Derived.GrossMarginCents != -3_000_000 { // 10k - 40k = -30k
|
||||
t.Errorf("grossMarginCents = %d, want -3000000", got.Derived.GrossMarginCents)
|
||||
}
|
||||
if got.Derived.Profitable {
|
||||
t.Error("must NOT be profitable when cost > revenue")
|
||||
}
|
||||
// runway = 20,000 / 2,000 = 10 days
|
||||
if got.Derived.RunwayDays == nil || math.Abs(*got.Derived.RunwayDays-10) > 1e-9 {
|
||||
t.Errorf("runwayDays = %v, want 10", got.Derived.RunwayDays)
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputeFinance_HonestUnconfigured proves the DO-off path: no fabricated
|
||||
// credit/burn, runway is NULL (not zero), margin is just revenue (cost 0), and
|
||||
// margin % is 0 when revenue is 0.
|
||||
func TestComputeFinance_HonestUnconfigured(t *testing.T) {
|
||||
in := financeInput{
|
||||
cost: financeCost{Configured: false, DigitalOcean: doCost{Configured: false}}, // commerce + DO both off
|
||||
revenue: financeRevenue{Configured: false},
|
||||
}
|
||||
got := computeFinance(in)
|
||||
if got.Cost.DigitalOcean.Configured {
|
||||
t.Error("DO must report configured:false when the token is unset")
|
||||
}
|
||||
if got.Cost.DigitalOcean.CreditRemainingCents != 0 || got.Cost.DigitalOcean.AvgDailyBurnCents != 0 {
|
||||
t.Error("unconfigured DO must not fabricate credit/burn")
|
||||
}
|
||||
// runway is null (nil) — no honest runway without a burn rate.
|
||||
if got.Derived.RunwayDays != nil {
|
||||
t.Errorf("runwayDays must be nil when DO is unconfigured, got %v", *got.Derived.RunwayDays)
|
||||
}
|
||||
if got.Derived.GrossMarginPct != 0 {
|
||||
t.Errorf("grossMarginPct must be 0 when revenue is 0, got %f", got.Derived.GrossMarginPct)
|
||||
}
|
||||
// revenue 0 is not > cost 0 → not profitable.
|
||||
if got.Derived.Profitable {
|
||||
t.Error("zero revenue and zero cost is not profitable")
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputeFinance_ZeroBurnNullRunway proves runway is null when DO is
|
||||
// configured but burn is zero (no division by zero, no fabricated infinity).
|
||||
func TestComputeFinance_ZeroBurnNullRunway(t *testing.T) {
|
||||
in := financeInput{
|
||||
cost: financeCost{Configured: true, TotalCents: 0, DigitalOcean: doCost{Configured: true, CreditRemainingCents: 4_000_000, AvgDailyBurnCents: 0}},
|
||||
revenue: financeRevenue{Configured: true, TotalRevenueCents: 100_000},
|
||||
}
|
||||
got := computeFinance(in)
|
||||
if got.Derived.RunwayDays != nil {
|
||||
t.Errorf("runwayDays must be nil when burn is 0, got %v", *got.Derived.RunwayDays)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAvgDailyBurn_ElapsedDays proves the run-rate is MTD spend over elapsed
|
||||
// days (≥1), a real read over real time — never an invented rate.
|
||||
func TestAvgDailyBurn_ElapsedDays(t *testing.T) {
|
||||
// $3,000 MTD on the 10th → $300/day.
|
||||
got := avgDailyBurnCents(300_000, time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC))
|
||||
if got != 30_000 {
|
||||
t.Errorf("avgDailyBurn = %d, want 30000 ($300/day)", got)
|
||||
}
|
||||
// Day 1 must not divide by zero.
|
||||
if avgDailyBurnCents(50_000, time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)) != 50_000 {
|
||||
t.Error("day-1 burn must be the full MTD spend (divide by 1)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDollarsToCents proves the DO decimal-dollar → cents parsing, including the
|
||||
// negative (credit) case and the blank fallback.
|
||||
func TestDollarsToCents(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want int64
|
||||
}{
|
||||
{"23.44", 2344},
|
||||
{"-40000.00", -4_000_000}, // promo credit held (negative account_balance)
|
||||
{"12.23", 1223},
|
||||
{"0", 0},
|
||||
{"", 0},
|
||||
{" 5.5 ", 550},
|
||||
{"garbage", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := dollarsToCents(c.in); got != c.want {
|
||||
t.Errorf("dollarsToCents(%q) = %d, want %d", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMonthlyNormalizedCents proves annual/monthly normalization for MRR.
|
||||
func TestMonthlyNormalizedCents(t *testing.T) {
|
||||
if got := monthlyNormalizedCents(12_000, "year"); got != 1_000 {
|
||||
t.Errorf("yearly $120 → monthly = %d, want 1000", got)
|
||||
}
|
||||
if got := monthlyNormalizedCents(2_000, "month"); got != 2_000 {
|
||||
t.Errorf("monthly must pass through, got %d", got)
|
||||
}
|
||||
if got := monthlyNormalizedCents(2_000, ""); got != 2_000 {
|
||||
t.Errorf("unknown interval must be treated as monthly, got %d", got)
|
||||
}
|
||||
}
|
||||
// The finance PURE-math derivation tests (ComputeFinance / AvgDailyBurnCents) live with
|
||||
// the handler in clients/admin/finance. These are the INTEGRATION tests that drive GET
|
||||
// /v1/admin/finance through the shared admin mount harness (mountSvc + fake IAM/commerce/DO).
|
||||
|
||||
// newFakeDO serves the DO billing API with fixed decimal-dollar strings so the
|
||||
// finance aggregation is deterministic. account_balance is NEGATIVE (credit held).
|
||||
@@ -205,7 +50,7 @@ func TestFinance_RealAggregation(t *testing.T) {
|
||||
defer do.Close()
|
||||
|
||||
doReq, s, _ := mountSvc(t, iam.server.URL, commerce.URL, "")
|
||||
s.State.do = newDOClientWithBase(do.URL, "test-do-token") // configured DO client
|
||||
s.State.DO = digitalocean.NewWithBase(do.URL, "test-do-token") // configured DO client
|
||||
admin := map[string]string{
|
||||
"X-User-IsAdmin": "true", "X-Org-Id": "admin",
|
||||
"Authorization": "Bearer operator-jwt", "Cookie": "iam_access_token=operator-jwt",
|
||||
@@ -216,7 +61,7 @@ func TestFinance_RealAggregation(t *testing.T) {
|
||||
}
|
||||
var env struct {
|
||||
Status string `json:"status"`
|
||||
Data financeData `json:"data"`
|
||||
Data finance.FinanceData `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
@@ -237,7 +82,7 @@ func TestFinance_RealAggregation(t *testing.T) {
|
||||
if len(d.Cost.Vendors) != 2 {
|
||||
t.Fatalf("cost.vendors must carry 2 lines (DO + OpenAI), got %d", len(d.Cost.Vendors))
|
||||
}
|
||||
if d.Cost.Vendors[0].Vendor == "" || d.Cost.Vendors[0].AmountCents == 0 {
|
||||
if d.Cost.Vendors[0].Name == "" || d.Cost.Vendors[0].Amount == 0 {
|
||||
t.Errorf("vendor line must carry a vendor + amount, got %+v", d.Cost.Vendors[0])
|
||||
}
|
||||
|
||||
@@ -282,7 +127,7 @@ func TestFinance_RealAggregation(t *testing.T) {
|
||||
t.Error("runwayDays must be present when DO burn > 0")
|
||||
}
|
||||
// Every source reported (digitalocean + commerce both ok).
|
||||
src := map[string]sourceStatus{}
|
||||
src := map[string]core.SourceStatus{}
|
||||
for _, x := range d.Sources {
|
||||
src[x.Name] = x
|
||||
}
|
||||
@@ -314,7 +159,7 @@ func TestFinance_HonestUnconfiguredDO(t *testing.T) {
|
||||
t.Fatalf("finance: got %d (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var env struct {
|
||||
Data financeData `json:"data"`
|
||||
Data finance.FinanceData `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
@@ -347,7 +192,7 @@ func TestFinance_HonestUnconfiguredDO(t *testing.T) {
|
||||
t.Errorf("commerce revenue must still be real with DO off, got %d", d.Revenue.TotalRevenueCents)
|
||||
}
|
||||
// The digitalocean source must be present and NOT ok (honest not-configured).
|
||||
var doSrc *sourceStatus
|
||||
var doSrc *core.SourceStatus
|
||||
for i := range d.Sources {
|
||||
if d.Sources[i].Name == "digitalocean" {
|
||||
doSrc = &d.Sources[i]
|
||||
@@ -375,7 +220,7 @@ func TestFinance_RevenueSourceDown_NoFabrication(t *testing.T) {
|
||||
t.Fatalf("finance: got %d (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var env struct {
|
||||
Data financeData `json:"data"`
|
||||
Data finance.FinanceData `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
@@ -393,7 +238,7 @@ func TestFinance_RevenueSourceDown_NoFabrication(t *testing.T) {
|
||||
t.Errorf("COGS must remain configured when the revenue source is down: %+v", d.Cost)
|
||||
}
|
||||
// The commerce (revenue) source is present and NOT ok — honest degraded state.
|
||||
var revSrc *sourceStatus
|
||||
var revSrc *core.SourceStatus
|
||||
for i := range d.Sources {
|
||||
if d.Sources[i].Name == "commerce" {
|
||||
revSrc = &d.Sources[i]
|
||||
|
||||
+37
-14
@@ -2,25 +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). Global-admin 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/featureflags"
|
||||
"github.com/hanzoai/cloud/clients/admin/core"
|
||||
"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[state], c *zip.Ctx) error {
|
||||
return 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,48 @@
|
||||
// Package health probes an upstream's health endpoint (e.g. o11y's
|
||||
// /v1/o11y/health) so the admin overview can report System Health honestly. A
|
||||
// non-2xx or unreachable upstream reads as down — never masked.
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client probes one health URL.
|
||||
type Client struct {
|
||||
url string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// New builds a health probe for url.
|
||||
func New(u string) *Client {
|
||||
return &Client{url: strings.TrimSpace(u), http: &http.Client{Timeout: 8 * time.Second}}
|
||||
}
|
||||
|
||||
// Ready reports whether a health URL is wired.
|
||||
func (c *Client) Ready() bool { return c != nil && c.url != "" }
|
||||
|
||||
// Up reports whether the health endpoint answers 2xx.
|
||||
func (c *Client) Up(ctx context.Context) (bool, error) {
|
||||
if !c.Ready() {
|
||||
return false, fmt.Errorf("health endpoint not configured")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.url, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("health unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return false, fmt.Errorf("health status %d", resp.StatusCode)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -1,4 +1,18 @@
|
||||
package admin
|
||||
// Package iam is the admin cockpit's typed reader for the Hanzo IAM management
|
||||
// surface (/v1/iam/get-*). IAM runs as its own deployment (not fused into this
|
||||
// binary), so these are HTTP calls, not Go method dispatch. Every call REPLAYS
|
||||
// THE CALLER'S OWN credential (session cookie + Authorization), so IAM authorizes
|
||||
// the read as the same principal the gateway already validated as a SuperAdmin.
|
||||
// admin adds NO service credential of its own here: it never widens what the
|
||||
// caller could read directly, and IAM's own IsSuperAdmin gate stays the second
|
||||
// line of defense.
|
||||
//
|
||||
// The reads split two orthogonal ways: TYPED domain reads the cockpit folds into
|
||||
// its own rows — Orgs/Users (paginated lists), Org/User (one row), SetUser (the
|
||||
// one write) — and a generic verbatim List the cockpit forwards field-for-field
|
||||
// (roles, applications, audit records). An unwired IAM (no base) is not Ready and
|
||||
// every read reports the honest not-configured error.
|
||||
package iam
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -13,34 +27,141 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// iamClient reads the IAM management surface (/v1/iam/get-*) on behalf of a
|
||||
// verified global-admin caller. IAM runs as its own deployment (not fused into
|
||||
// this binary — see subsystems.go), so these are HTTP calls, not Go method
|
||||
// dispatch. Every call REPLAYS THE CALLER'S OWN credential (session cookie +
|
||||
// Authorization), so IAM authorizes the read as the same principal the gateway
|
||||
// already validated as a global admin. admin adds NO service credential of
|
||||
// its own here: it never widens what the caller could read directly, and IAM's
|
||||
// own IsGlobalAdmin gate stays the second line of defense.
|
||||
type iamClient struct {
|
||||
// Client reads the IAM management surface (/v1/iam/get-*) on behalf of a verified
|
||||
// SuperAdmin caller.
|
||||
type Client struct {
|
||||
base string // e.g. http://iam.hanzo.svc.cluster.local:8000
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func newIAMClient(base string) *iamClient {
|
||||
return &iamClient{
|
||||
// New builds an IAM client for base (empty base → not Ready).
|
||||
func New(base string) *Client {
|
||||
return &Client{
|
||||
base: strings.TrimRight(strings.TrimSpace(base), "/"),
|
||||
http: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *iamClient) configured() bool { return c != nil && c.base != "" }
|
||||
// Ready reports whether an IAM endpoint is wired on this deployment.
|
||||
func (c *Client) Ready() bool { return c != nil && c.base != "" }
|
||||
|
||||
// creds is the caller's replayed authorization context: the raw Cookie header
|
||||
// Creds is the caller's replayed authorization context: the raw Cookie header
|
||||
// and Authorization bearer captured off the inbound request. IAM authenticates
|
||||
// exactly as it does for the browser (credentials: 'include').
|
||||
type creds struct {
|
||||
cookie string
|
||||
auth string
|
||||
type Creds struct {
|
||||
Cookie string
|
||||
Auth string
|
||||
}
|
||||
|
||||
// Org is the IAM Organization subset the aggregators fold over.
|
||||
type Org struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
CreatedTime string `json:"createdTime"`
|
||||
}
|
||||
|
||||
// User is the IAM User subset mapped into OperatorUser. AccessKey is decoded
|
||||
// ONLY to derive API-key PRESENCE (hasApiKey) for the customer detail — its VALUE
|
||||
// is never surfaced in any admin response (the hk- key is a credential, not a
|
||||
// display field), so no secret leaves this binary.
|
||||
type User struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Tag string `json:"tag"`
|
||||
CreatedTime string `json:"createdTime"`
|
||||
LastSigninTime string `json:"lastSigninTime"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
IsForbidden bool `json:"isForbidden"`
|
||||
AccessKey string `json:"accessKey"`
|
||||
}
|
||||
|
||||
// List is a decoded paginated read: the raw rows and the backend total.
|
||||
type List struct {
|
||||
Rows json.RawMessage
|
||||
Total int
|
||||
}
|
||||
|
||||
// List calls an IAM get-* endpoint and returns the raw data array + data2 total —
|
||||
// the verbatim-forward primitive (roles, applications, audit records reach the
|
||||
// operator field-for-field). A non-ok envelope is an error (surfaced honestly to
|
||||
// the operator).
|
||||
func (c *Client) List(ctx context.Context, cr Creds, path string, q url.Values) (List, error) {
|
||||
env, err := c.get(ctx, cr, path, q)
|
||||
if err != nil {
|
||||
return List{}, err
|
||||
}
|
||||
total := envTotal(env.Data2, env.Data)
|
||||
return List{Rows: env.Data, Total: total}, nil
|
||||
}
|
||||
|
||||
// Orgs lists organizations (GET /v1/iam/get-organizations).
|
||||
func (c *Client) Orgs(ctx context.Context, cr Creds, q url.Values) (List, error) {
|
||||
return c.List(ctx, cr, "/v1/iam/get-organizations", q)
|
||||
}
|
||||
|
||||
// Users lists users (GET /v1/iam/get-users).
|
||||
func (c *Client) Users(ctx context.Context, cr Creds, q url.Values) (List, error) {
|
||||
return c.List(ctx, cr, "/v1/iam/get-users", q)
|
||||
}
|
||||
|
||||
// Org fetches ONE organization row (GET /v1/iam/get-organization?id=owner/name)
|
||||
// as the typed Org subset the scoped read panels fold over. Replays the caller's
|
||||
// own credential, so IAM authorizes the read as the same validated principal — a
|
||||
// non-super caller can only ever read their OWN org this way (the second line of the
|
||||
// tenant-scope defense). Best-effort by design: the scoped-orgs fan-in tolerates an
|
||||
// error and falls back to a name-only row.
|
||||
func (c *Client) Org(ctx context.Context, cr Creds, id string) (Org, error) {
|
||||
q := url.Values{"id": {id}}
|
||||
env, err := c.get(ctx, cr, "/v1/iam/get-organization", q)
|
||||
if err != nil {
|
||||
return Org{}, err
|
||||
}
|
||||
var org Org
|
||||
if err := json.Unmarshal(env.Data, &org); err != nil {
|
||||
return Org{}, fmt.Errorf("iam get-organization decode: %w", err)
|
||||
}
|
||||
return org, nil
|
||||
}
|
||||
|
||||
// User fetches ONE user as its FULL wire object (GET /v1/iam/get-user?id=
|
||||
// owner/name), preserving every field. The suspend/reactivate action reads the
|
||||
// whole object, flips isForbidden, and writes it back — update-user REPLACES the
|
||||
// row, so operating on the full object (not a typed subset) is what keeps every
|
||||
// other field intact. Replays the caller's own credential, so IAM authorizes the
|
||||
// read as the same validated SuperAdmin.
|
||||
func (c *Client) User(ctx context.Context, cr Creds, id string) (map[string]any, error) {
|
||||
q := url.Values{"id": {id}}
|
||||
env, err := c.get(ctx, cr, "/v1/iam/get-user", q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var user map[string]any
|
||||
if err := json.Unmarshal(env.Data, &user); err != nil {
|
||||
return nil, fmt.Errorf("iam get-user decode: %w", err)
|
||||
}
|
||||
if user == nil {
|
||||
return nil, fmt.Errorf("iam get-user %q: empty", id)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// SetUser writes a full user object back (POST /v1/iam/update-user?id=owner/name).
|
||||
// The caller's replayed credential is a VALIDATED SuperAdmin, whom IAM's
|
||||
// CheckPermissionForUpdateUser admits to set privileged fields (isForbidden) on any
|
||||
// user — a tenant/org-admin is refused by IAM itself, so this can never be abused to
|
||||
// suspend across a boundary the caller couldn't already cross. admin adds no service
|
||||
// credential of its own; IAM re-checks IsSuperAdmin.
|
||||
func (c *Client) SetUser(ctx context.Context, cr Creds, id string, user map[string]any) error {
|
||||
q := url.Values{"id": {id}}
|
||||
body, err := json.Marshal(user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.post(ctx, cr, "/v1/iam/update-user", q, body)
|
||||
return err
|
||||
}
|
||||
|
||||
// envelope is the uniform /v1 response shape every /v1/iam handler returns.
|
||||
@@ -52,26 +173,9 @@ type envelope struct {
|
||||
Data2 json.RawMessage `json:"data2"`
|
||||
}
|
||||
|
||||
// listResult is a decoded paginated read: the raw rows and the backend total.
|
||||
type listResult struct {
|
||||
rows json.RawMessage
|
||||
total int
|
||||
}
|
||||
|
||||
// getList calls an IAM get-* endpoint and returns the raw data array + data2
|
||||
// total. A non-ok envelope is an error (surfaced honestly to the operator).
|
||||
func (c *iamClient) getList(ctx context.Context, cr creds, path string, q url.Values) (listResult, error) {
|
||||
env, err := c.get(ctx, cr, path, q)
|
||||
if err != nil {
|
||||
return listResult{}, err
|
||||
}
|
||||
total := envTotal(env.Data2, env.Data)
|
||||
return listResult{rows: env.Data, total: total}, nil
|
||||
}
|
||||
|
||||
// get performs one authenticated GET and decodes the /v1 envelope.
|
||||
func (c *iamClient) get(ctx context.Context, cr creds, path string, q url.Values) (envelope, error) {
|
||||
if !c.configured() {
|
||||
func (c *Client) get(ctx context.Context, cr Creds, path string, q url.Values) (envelope, error) {
|
||||
if !c.Ready() {
|
||||
return envelope{}, fmt.Errorf("iam endpoint not configured")
|
||||
}
|
||||
u := c.base + path
|
||||
@@ -83,11 +187,11 @@ func (c *iamClient) get(ctx context.Context, cr creds, path string, q url.Values
|
||||
return envelope{}, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if cr.cookie != "" {
|
||||
req.Header.Set("Cookie", cr.cookie)
|
||||
if cr.Cookie != "" {
|
||||
req.Header.Set("Cookie", cr.Cookie)
|
||||
}
|
||||
if cr.auth != "" {
|
||||
req.Header.Set("Authorization", cr.auth)
|
||||
if cr.Auth != "" {
|
||||
req.Header.Set("Authorization", cr.Auth)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
@@ -115,68 +219,11 @@ func (c *iamClient) get(ctx context.Context, cr creds, path string, q url.Values
|
||||
return env, nil
|
||||
}
|
||||
|
||||
// getUserRaw fetches ONE user as its FULL wire object (GET /v1/iam/get-user?id=
|
||||
// owner/name), preserving every field. The suspend/reactivate action reads the
|
||||
// whole object, flips isForbidden, and writes it back — update-user REPLACES the
|
||||
// row, so operating on the full object (not a typed subset) is what keeps every
|
||||
// other field intact. Replays the caller's own credential, so IAM authorizes the
|
||||
// read as the same validated global admin.
|
||||
func (c *iamClient) getUserRaw(ctx context.Context, cr creds, id string) (map[string]any, error) {
|
||||
q := url.Values{"id": {id}}
|
||||
env, err := c.get(ctx, cr, "/v1/iam/get-user", q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var user map[string]any
|
||||
if err := json.Unmarshal(env.Data, &user); err != nil {
|
||||
return nil, fmt.Errorf("iam get-user decode: %w", err)
|
||||
}
|
||||
if user == nil {
|
||||
return nil, fmt.Errorf("iam get-user %q: empty", id)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// getOrg fetches ONE organization row (GET /v1/iam/get-organization?id=owner/name)
|
||||
// as the typed iamOrg subset the scoped read panels fold over. Replays the caller's
|
||||
// own credential, so IAM authorizes the read as the same validated principal — a
|
||||
// non-super caller can only ever read their OWN org this way (the second line of the
|
||||
// tenant-scope defense). Best-effort by design: the scoped-orgs fan-in tolerates an
|
||||
// error and falls back to a name-only row.
|
||||
func (c *iamClient) getOrg(ctx context.Context, cr creds, id string) (iamOrg, error) {
|
||||
q := url.Values{"id": {id}}
|
||||
env, err := c.get(ctx, cr, "/v1/iam/get-organization", q)
|
||||
if err != nil {
|
||||
return iamOrg{}, err
|
||||
}
|
||||
var org iamOrg
|
||||
if err := json.Unmarshal(env.Data, &org); err != nil {
|
||||
return iamOrg{}, fmt.Errorf("iam get-organization decode: %w", err)
|
||||
}
|
||||
return org, nil
|
||||
}
|
||||
|
||||
// updateUserRaw writes a full user object back (POST /v1/iam/update-user?id=
|
||||
// owner/name). The caller's replayed credential is a VALIDATED global admin, whom
|
||||
// IAM's CheckPermissionForUpdateUser admits to set privileged fields (isForbidden)
|
||||
// on any user — a tenant/org-admin is refused by IAM itself, so this can never be
|
||||
// abused to suspend across a boundary the caller couldn't already cross. admin
|
||||
// adds no service credential of its own; IAM re-checks IsGlobalAdmin.
|
||||
func (c *iamClient) updateUserRaw(ctx context.Context, cr creds, id string, user map[string]any) error {
|
||||
q := url.Values{"id": {id}}
|
||||
body, err := json.Marshal(user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.post(ctx, cr, "/v1/iam/update-user", q, body)
|
||||
return err
|
||||
}
|
||||
|
||||
// post performs one authenticated POST (JSON body) replaying the caller's cookie +
|
||||
// bearer, and decodes the /v1 envelope. A non-ok envelope (or an IAM 401/403) is
|
||||
// an error the mutation surfaces honestly + records as a failed audited attempt.
|
||||
func (c *iamClient) post(ctx context.Context, cr creds, path string, q url.Values, body []byte) (envelope, error) {
|
||||
if !c.configured() {
|
||||
func (c *Client) post(ctx context.Context, cr Creds, path string, q url.Values, body []byte) (envelope, error) {
|
||||
if !c.Ready() {
|
||||
return envelope{}, fmt.Errorf("iam endpoint not configured")
|
||||
}
|
||||
u := c.base + path
|
||||
@@ -189,11 +236,11 @@ func (c *iamClient) post(ctx context.Context, cr creds, path string, q url.Value
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if cr.cookie != "" {
|
||||
req.Header.Set("Cookie", cr.cookie)
|
||||
if cr.Cookie != "" {
|
||||
req.Header.Set("Cookie", cr.Cookie)
|
||||
}
|
||||
if cr.auth != "" {
|
||||
req.Header.Set("Authorization", cr.auth)
|
||||
if cr.Auth != "" {
|
||||
req.Header.Set("Authorization", cr.Auth)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
@@ -0,0 +1,9 @@
|
||||
// Package money is the admin cockpit's one billing unit: USD cents as a typed
|
||||
// value, so no field or method anywhere has to spell "Cents" again and a dollar
|
||||
// amount can never be silently passed where cents are meant. The underlying type
|
||||
// is int64, so arithmetic is ordinary integer math and the JSON wire is the raw
|
||||
// integer — unchanged.
|
||||
package money
|
||||
|
||||
// Cents is an amount of US-dollar cents.
|
||||
type Cents int64
|
||||
@@ -17,7 +17,7 @@ package admin
|
||||
// o11y — GET /v1/admin/o11y, the GLOBAL fleet-wide observability read that powers
|
||||
// the operator's o11y board on admin.hanzo.ai. It is the un-org-scoped twin of the
|
||||
// per-org console o11y: the same signals, aggregated across EVERY tenant, over the
|
||||
// ONE hanzoai/datastore (ClickHouse) — the same warehouse + shared client
|
||||
// ONE hanzoai/datastore (datastore) — the same warehouse + shared client
|
||||
// (aiobject.DatastoreQuery) the analytics/compute lenses already use, no second
|
||||
// connection.
|
||||
//
|
||||
@@ -28,7 +28,7 @@ package admin
|
||||
// - Logs → o11y_logs.distributed_logs_v2 : fleet log volume + volume-over-time
|
||||
// - LLM gens → langfuse.observations : generations + cost (fleet-wide; honest-empty today)
|
||||
//
|
||||
// GLOBAL-ADMIN ONLY (the s.guard wrap in admin.go): the gateway strips a client
|
||||
// SUPERADMIN ONLY (the s.guard wrap in admin.go): the gateway strips a client
|
||||
// X-Org-Id and re-mints from the JWT owner, and this handler applies NO org filter,
|
||||
// so it is the ONE place a fleet operator crosses tenants — a non-admin bearer is
|
||||
// refused 403 before a single row is read. Fail-closed.
|
||||
@@ -47,6 +47,7 @@ import (
|
||||
|
||||
aiobject "github.com/hanzoai/ai/object"
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/admin/core"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
@@ -144,10 +145,10 @@ type o11yLLM struct {
|
||||
}
|
||||
|
||||
// o11y answers GET /v1/admin/o11y. ?range=24h|7d|30d bounds the window (default 30d).
|
||||
// GLOBAL-ADMIN ONLY (s.guard). Every signal degrades independently: a table that is
|
||||
// SUPERADMIN ONLY (s.guard). Every signal degrades independently: a table that is
|
||||
// absent or errors contributes its zero-value, never a failure — the fleet board
|
||||
// always renders what the datastore actually holds.
|
||||
func o11y(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
func o11y(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
rangeLabel := o11yRange(c.Query("range"))
|
||||
since := computeSince(rangeLabel)
|
||||
@@ -165,7 +166,7 @@ func o11y(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
// Honest-empty when the warehouse is not connected: the board renders its zero
|
||||
// state, never a fabricated fleet.
|
||||
if !aiobject.DatastoreEnabled() {
|
||||
return ok(c, payload)
|
||||
return core.OK(c, payload)
|
||||
}
|
||||
|
||||
sinceTS := chTS(since) // DateTime literal — cloud_usage.timestamp, traces.timestamp
|
||||
@@ -210,7 +211,7 @@ func o11y(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
payload.LLM = o11yLLM{Generations: chInt64(r["gens"]), CostUsd: chFloat64(r["cost"])}
|
||||
}
|
||||
|
||||
return ok(c, payload)
|
||||
return core.OK(c, payload)
|
||||
}
|
||||
|
||||
// ── pure SQL builders (static SQL + one positional time bound; unit-tested) ──
|
||||
@@ -371,7 +372,7 @@ func o11yRange(v string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// o11yBucket maps the range to a fixed ClickHouse interval clause (a server-side
|
||||
// o11yBucket maps the range to a fixed datastore interval clause (a server-side
|
||||
// CONSTANT — never user input — so it is safe to render into the SQL). ~24-30
|
||||
// buckets across the window keeps the charts legible.
|
||||
func o11yBucket(rangeLabel string) string {
|
||||
@@ -394,7 +395,7 @@ func firstRowOr(rows []map[string]any) map[string]any {
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
// chFloat64 coerces a ClickHouse numeric cell to float64 (the round()/quantile()
|
||||
// chFloat64 coerces a datastore numeric cell to float64 (the round()/quantile()
|
||||
// columns land as float64; a Decimal serialized to string is parsed). The twin of
|
||||
// chInt64 for the latency/error-rate/cost fields. Non-numeric → 0 (honest zero).
|
||||
func chFloat64(v any) float64 {
|
||||
|
||||
@@ -95,7 +95,7 @@ func TestO11yTop_LimitAndOrder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFillUsageTotals reads the ClickHouse row into the KPI band across the
|
||||
// TestFillUsageTotals reads the datastore row into the KPI band across the
|
||||
// numeric variants the driver returns (uint64/int64/float64), honest zeros on
|
||||
// an empty row.
|
||||
func TestFillUsageTotals(t *testing.T) {
|
||||
@@ -134,7 +134,7 @@ func TestFillTraceTotals(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTopParsers map ClickHouse rows into the leaderboard view-models and preserve
|
||||
// TestTopParsers map datastore rows into the leaderboard view-models and preserve
|
||||
// order (the SQL already ORDER BYs; the parser must not reorder or drop rows).
|
||||
func TestTopParsers(t *testing.T) {
|
||||
orgs := topOrgsFromRows([]map[string]any{
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
package admin
|
||||
|
||||
// Fleet REVENUE aggregate (/v1/admin/revenue) — the operator's money board: total
|
||||
// prepaid balances held, total realized spend, MRR, a per-customer revenue table,
|
||||
// ARPU, and a real spend trend. Global-admin only (s.guard).
|
||||
//
|
||||
// This is ORTHOGONAL to /v1/admin/finance: finance is the COGS/margin god-view
|
||||
// (what WE pay vendors, gross margin, DO-credit runway); revenue is the CUSTOMER
|
||||
// money view (what each customer holds/spends/subscribes). Both read commerce, but
|
||||
// answer different questions — one is "are we profitable", the other is "who are
|
||||
// our paying customers and what do they pay". Every number is a real commerce read;
|
||||
// an unreachable org degrades to honest zero (never a fabricated figure), and a
|
||||
// partial fleet read marks its source degraded rather than presenting an undercount
|
||||
// as authoritative.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// revenueCustomer is one row of the per-customer revenue table.
|
||||
type revenueCustomer struct {
|
||||
Org string `json:"org"`
|
||||
Display string `json:"display"`
|
||||
Plan string `json:"plan"`
|
||||
BalanceCents int64 `json:"balanceCents"`
|
||||
SpendCents int64 `json:"spendCents"`
|
||||
MRRCents int64 `json:"mrrCents"`
|
||||
}
|
||||
|
||||
// revenueData is the whole GET /v1/admin/revenue payload.
|
||||
type revenueData struct {
|
||||
TotalBalancesCents int64 `json:"totalBalancesCents"`
|
||||
TotalSpendCents int64 `json:"totalSpendCents"`
|
||||
MRRCents int64 `json:"mrrCents"`
|
||||
Customers int `json:"customers"`
|
||||
PayingCustomers int `json:"payingCustomers"`
|
||||
ARPUCents int64 `json:"arpuCents"`
|
||||
PerCustomer []revenueCustomer `json:"perCustomer"`
|
||||
SpendTrend []seriesPoint `json:"spendTrend"`
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
Sources []sourceStatus `json:"sources"`
|
||||
}
|
||||
|
||||
func revenue(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
cr := callerCreds(c)
|
||||
now := time.Now().UTC()
|
||||
|
||||
orgs, err := listOrgs(s, ctx, cr)
|
||||
if err != nil {
|
||||
return fail(c, err.Error())
|
||||
}
|
||||
|
||||
// Per-org money, fanned out concurrently (balance + spend + plan/MRR).
|
||||
rows := make([]revenueCustomer, len(orgs))
|
||||
oks := make([]bool, len(orgs))
|
||||
sem := make(chan struct{}, maxCustomerConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
for i, o := range orgs {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(i int, o iamOrg) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
rows[i], oks[i] = revenueOf(s, ctx, o)
|
||||
}(i, o)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var totalBal, totalSpend, mrr int64
|
||||
paying := 0
|
||||
partial := false
|
||||
for i, r := range rows {
|
||||
totalBal += r.BalanceCents
|
||||
totalSpend += r.SpendCents
|
||||
mrr += r.MRRCents
|
||||
if r.SpendCents > 0 || r.MRRCents > 0 {
|
||||
paying++
|
||||
}
|
||||
if !oks[i] {
|
||||
partial = true
|
||||
}
|
||||
}
|
||||
arpu := int64(0)
|
||||
if paying > 0 {
|
||||
arpu = totalSpend / int64(paying)
|
||||
}
|
||||
|
||||
// Real 30-day spend trend from the usage ledger (honest empty when no usage).
|
||||
acts, ledgerOK := fleetActivity(s, ctx, orgs)
|
||||
trend := spendSeries(acts, now.AddDate(0, 0, -30), now, "day")
|
||||
|
||||
// Highest-revenue customers first.
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i].SpendCents != rows[j].SpendCents {
|
||||
return rows[i].SpendCents > rows[j].SpendCents
|
||||
}
|
||||
return rows[i].BalanceCents > rows[j].BalanceCents
|
||||
})
|
||||
|
||||
nowStr := now.Format(time.RFC3339)
|
||||
sources := []sourceStatus{srcOf("iam", nil, len(orgs), nowStr)}
|
||||
if partial {
|
||||
sources = append(sources, srcOf("commerce", errPartialRevenue, len(orgs), nowStr))
|
||||
} else {
|
||||
sources = append(sources, srcOf("commerce", nil, len(orgs), nowStr))
|
||||
}
|
||||
if !ledgerOK {
|
||||
sources = append(sources, srcOf("commerce-ledger", errPartialRevenue, 0, nowStr))
|
||||
}
|
||||
|
||||
return ok(c, revenueData{
|
||||
TotalBalancesCents: totalBal,
|
||||
TotalSpendCents: totalSpend,
|
||||
MRRCents: mrr,
|
||||
Customers: len(orgs),
|
||||
PayingCustomers: paying,
|
||||
ARPUCents: arpu,
|
||||
PerCustomer: rows,
|
||||
SpendTrend: trend,
|
||||
GeneratedAt: nowStr,
|
||||
Sources: sources,
|
||||
})
|
||||
}
|
||||
|
||||
// revenueOf reads one org's money view (balance + spend + plan/MRR). Returns
|
||||
// (row, ok): ok is false when the spend OR balance read failed, so the caller can
|
||||
// mark the fleet total PARTIAL rather than presenting an undercount as complete.
|
||||
func revenueOf(s *cloud.Service[state], ctx context.Context, o iamOrg) (revenueCustomer, bool) {
|
||||
subj := orgSubject(o.Name)
|
||||
row := revenueCustomer{Org: o.Name, Display: display(o.DisplayName, o.Name), Plan: "pay-as-you-go"}
|
||||
ok := true
|
||||
|
||||
if r, err := s.State.commerce.usageRollup(ctx, o.Name, subj); err == nil {
|
||||
row.SpendCents = r.ConsumedCents
|
||||
} else {
|
||||
ok = false
|
||||
}
|
||||
if credits, err := s.State.commerce.creditsCents(ctx, o.Name, subj); err == nil {
|
||||
row.BalanceCents = credits
|
||||
} else {
|
||||
ok = false
|
||||
}
|
||||
if sub, err := s.State.commerce.subscriptionSummary(ctx, o.Name, subj); err == nil {
|
||||
row.MRRCents = sub.MRR
|
||||
row.Plan = sub.Plan
|
||||
}
|
||||
return row, ok
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// Package revenue is the fleet REVENUE aggregate (/v1/admin/revenue) — the operator's
|
||||
// money board: total prepaid balances held, total realized spend, MRR, a per-customer
|
||||
// revenue table, ARPU, and a real spend trend. SuperAdmin only (core.Guard).
|
||||
//
|
||||
// This is ORTHOGONAL to /v1/admin/finance: finance is the COGS/margin god-view (what WE
|
||||
// pay vendors); revenue is the CUSTOMER money view (what each customer holds/spends/
|
||||
// subscribes). Every number is a real commerce read; an unreachable org degrades to
|
||||
// honest zero, and a partial fleet read marks its source degraded.
|
||||
package revenue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/admin/core"
|
||||
"github.com/hanzoai/cloud/clients/admin/iam"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// Routes registers the fleet revenue board (SuperAdmin only, cross-tenant profitability).
|
||||
func Routes(app *zip.App, s *cloud.Service[core.State]) {
|
||||
app.Get("/v1/admin/revenue", core.Guard(s, Revenue))
|
||||
}
|
||||
|
||||
// RevenueCustomer is one row of the per-customer revenue table.
|
||||
type RevenueCustomer struct {
|
||||
Org string `json:"org"`
|
||||
Display string `json:"display"`
|
||||
Plan string `json:"plan"`
|
||||
BalanceCents int64 `json:"balanceCents"`
|
||||
SpendCents int64 `json:"spendCents"`
|
||||
MRRCents int64 `json:"mrrCents"`
|
||||
}
|
||||
|
||||
// RevenueData is the whole GET /v1/admin/revenue payload.
|
||||
type RevenueData struct {
|
||||
TotalBalancesCents int64 `json:"totalBalancesCents"`
|
||||
TotalSpendCents int64 `json:"totalSpendCents"`
|
||||
MRRCents int64 `json:"mrrCents"`
|
||||
Customers int `json:"customers"`
|
||||
PayingCustomers int `json:"payingCustomers"`
|
||||
ARPUCents int64 `json:"arpuCents"`
|
||||
PerCustomer []RevenueCustomer `json:"perCustomer"`
|
||||
SpendTrend []core.SeriesPoint `json:"spendTrend"`
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
Sources []core.SourceStatus `json:"sources"`
|
||||
}
|
||||
|
||||
// Revenue answers GET /v1/admin/revenue.
|
||||
func Revenue(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
cr := core.CallerCreds(c)
|
||||
now := time.Now().UTC()
|
||||
|
||||
orgs, err := core.ListOrgs(s, ctx, cr)
|
||||
if err != nil {
|
||||
return core.Fail(c, err.Error())
|
||||
}
|
||||
|
||||
// Per-org money, fanned out concurrently (balance + spend + plan/MRR).
|
||||
rows := make([]RevenueCustomer, len(orgs))
|
||||
oks := make([]bool, len(orgs))
|
||||
sem := make(chan struct{}, core.MaxCustomerConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
for i, o := range orgs {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(i int, o iam.Org) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
rows[i], oks[i] = revenueOf(s, ctx, o)
|
||||
}(i, o)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var totalBal, totalSpend, mrr int64
|
||||
paying := 0
|
||||
partial := false
|
||||
for i, r := range rows {
|
||||
totalBal += r.BalanceCents
|
||||
totalSpend += r.SpendCents
|
||||
mrr += r.MRRCents
|
||||
if r.SpendCents > 0 || r.MRRCents > 0 {
|
||||
paying++
|
||||
}
|
||||
if !oks[i] {
|
||||
partial = true
|
||||
}
|
||||
}
|
||||
arpu := int64(0)
|
||||
if paying > 0 {
|
||||
arpu = totalSpend / int64(paying)
|
||||
}
|
||||
|
||||
// Real 30-day spend trend from the usage ledger (honest empty when no usage).
|
||||
acts, ledgerOK := core.FleetActivity(s, ctx, orgs)
|
||||
trend := core.SpendSeries(acts, now.AddDate(0, 0, -30), now, "day")
|
||||
|
||||
// Highest-revenue customers first.
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i].SpendCents != rows[j].SpendCents {
|
||||
return rows[i].SpendCents > rows[j].SpendCents
|
||||
}
|
||||
return rows[i].BalanceCents > rows[j].BalanceCents
|
||||
})
|
||||
|
||||
nowStr := now.Format(time.RFC3339)
|
||||
sources := []core.SourceStatus{core.SrcOf("iam", nil, len(orgs), nowStr)}
|
||||
if partial {
|
||||
sources = append(sources, core.SrcOf("commerce", core.ErrPartialRevenue, len(orgs), nowStr))
|
||||
} else {
|
||||
sources = append(sources, core.SrcOf("commerce", nil, len(orgs), nowStr))
|
||||
}
|
||||
if !ledgerOK {
|
||||
sources = append(sources, core.SrcOf("commerce-ledger", core.ErrPartialRevenue, 0, nowStr))
|
||||
}
|
||||
|
||||
return core.OK(c, RevenueData{
|
||||
TotalBalancesCents: totalBal,
|
||||
TotalSpendCents: totalSpend,
|
||||
MRRCents: mrr,
|
||||
Customers: len(orgs),
|
||||
PayingCustomers: paying,
|
||||
ARPUCents: arpu,
|
||||
PerCustomer: rows,
|
||||
SpendTrend: trend,
|
||||
GeneratedAt: nowStr,
|
||||
Sources: sources,
|
||||
})
|
||||
}
|
||||
|
||||
// revenueOf reads one org's money view (balance + spend + plan/MRR). Returns (row, ok):
|
||||
// ok is false when the spend OR balance read failed, so the caller can mark the fleet
|
||||
// total PARTIAL rather than presenting an undercount as complete.
|
||||
func revenueOf(s *cloud.Service[core.State], ctx context.Context, o iam.Org) (RevenueCustomer, bool) {
|
||||
row := RevenueCustomer{Org: o.Name, Display: core.Display(o.DisplayName, o.Name), Plan: "pay-as-you-go"}
|
||||
ok := true
|
||||
|
||||
if sp, err := s.State.Commerce.Spend(ctx, o.Name); err == nil {
|
||||
row.SpendCents = int64(sp.Consumed)
|
||||
} else {
|
||||
ok = false
|
||||
}
|
||||
if credits, err := s.State.Commerce.Credits(ctx, o.Name); err == nil {
|
||||
row.BalanceCents = int64(credits)
|
||||
} else {
|
||||
ok = false
|
||||
}
|
||||
if pl, err := s.State.Commerce.Plan(ctx, o.Name); err == nil {
|
||||
row.MRRCents = int64(pl.MRR)
|
||||
row.Plan = pl.Name
|
||||
}
|
||||
return row, ok
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user