Compare commits

...
Author SHA1 Message Date
antje 139bd734f0 deps: luxfi/consensus v1.36.2 -> v1.36.3 — the v1.36.2 tag was re-pushed
Cold builds fail sumdb verification against the moved tag (downloaded
eKzasq4O... vs sealed IbeWQF1w...). v1.36.3 is the immutable successor;
never re-tag a published version.
2026-07-17 01:26:35 -07:00
antje 6e24fcfd28 mirror: skip hidden files — AppleDouble forks pass the extension check
._foo.png is a mac resource fork, not a render; 700+ of them poisoned a
library within an hour of the mirror going live.
2026-07-17 01:15:16 -07:00
antje 9e7931e81c render: poll window matches the dispatch cap; engine recycles after each render
The 10m local history poll undercut the 4h startToCloseTimeout the dispatcher
grants — live renders (observed 8-70m) were marked failed while still sampling;
only the mirror later delivered them. renderWindow now matches the cap.

The engine leaks ~58GB per render. The handler signals a recycle after each
COMPLETED render (never on timeout — the engine may still be sampling and the
mirror rescues late finishes); the supervisor restarts on the signal.
2026-07-17 01:09:39 -07:00
494a6582c4 bench(finance): BenchmarkListUsage — the co-resident usage-read path (BUG 2) (#325)
The finance domain had no benchmark; this measures the read that replaced the
commerceinproc self-dispatch — finance.ListUsage over a per-org SQLite ledger,
at 100/1000/5000 seeded debits. Backs the reproducibility claim in the
hanzo-unified-tenant-cloud paper (1.25/10.5/61 ms). Also surfaces a real N+1:
store.Entries fetches postings per row the usage view never uses — a
postings-free read would cut this ~10x (follow-up).

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-17 01:06:20 -07:00
zandhanzo-dev b0254454c9 Money names its unit: Int -> Atto
There were two words for one idea. money.Amount.Minor() returns an integer at the
CURRENCY's decimals — money.USD declares 2, so cents. cloudmoney.Amount.Int()
returns an integer at 18 decimals — atto. Both are "the backing integer", neither
name says which, and they differ by 10^16.

That is not a style complaint. It is how

    Amount: cloudmoney.FromInt(u.Charge.Minor()), // exact 18-dp USD, no floor

got written, reviewed, and shipped. It type-checked (both sides *big.Int), it read
as "make an Amount from the integer", and it billed a $17.376 zen call as
$0.0000000000000017 until v1.801.44. The package documented the trap in prose —
"Take the decimal, never Amount.Minor()" — because a comment was the only place
the unit existed. Prose is not a type.

So name the unit, not the Go type: FromInt -> FromAtto, Int() -> Atto(),
IntString() -> AttoString(). FromCents/Cents already did this and were never
confused with anything. Now the units are visible at the call site, and the
mistake reads as one: FromAtto(x.Cents()) is obviously wrong where
FromInt(x.Minor()) was obviously fine. It no longer type-checks either —
FromAtto takes *big.Int, Cents() returns int64 — so the pairing that cost us the
money is now two independent kinds of error instead of zero.

Values are untouched: Atto/AttoString return exactly what Int/IntString did, so
the treasury ledger hash and every stored 18-decimal string are byte-identical.
No migration, no data change — only the names, and the compiler found every one
of them (45 sites; a regex could not have, because .Int() also belongs to big.Int
and decimal).

Zero regressions: the failing set is identical to origin/main.
2026-07-17 00:52:06 -07:00
zeekayandClaude Opus 4.8 c93ddf92bf build(deps): adopt stable luxfi/keys v1.4.1 (unbreak force-moved v1.4.0 checksum)
luxfi/keys v1.4.0 was force-moved on the remote (tree 5153d639→80a3745a),
producing a go.sum checksum mismatch that broke `go mod tidy`/`go build` and
the hanzoai/cloud image build in CI. Every keys tag v1.1.0..v1.4.0 was
force-moved with content changes; only v1.4.1 is byte-stable (local==remote
tree 17551acd). Adopt v1.4.1 as the single stable version. Its go.mod floors
the unified luxfi stack, so the transitive set moves forward (geth 1.17.12→
1.20.1, consensus 1.35.32→1.36.2, crypto/database/warp/zap/…), all within v1.

go.mod/go.sum only; diff vs origin/main is luxfi/* exclusively. Embedded
iam2 v0.14.0, apps.go, and concurrent commerce/agent work untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:50:13 -07:00
74478bb9a2 build: decomplect the cloud image — cloud compiles ONLY Go (#321)
The console SPA, agent-skills catalog, and native flags staticlib are each now
built by their OWN CI as a versioned immutable image and PULLED into the cloud
build, instead of rebuilding node+python+rust from scratch every release. The
console stage (cold npm install + full Next static export, cache-busted every
build) was the ~20-min long pole; it is now a registry pull.

- Dockerfile: console/skills/flagslib stages -> FROM ${CONSOLE_IMAGE}/
  ${SKILLS_IMAGE}/${FLAGS_IMAGE} prebuilt pulls; COPY sources updated
  (/dist, /catalog, /libhanzo_flags.a). Mirror golang+alpine bases, GOPRIVATE,
  and every RED gate (SQLCipher proof, modernc guard, cek frozen-format) are
  unchanged. Pins are ghcr.io so both buildx lanes pull directly; mirrored to
  registry.hanzo.ai (S3) for GET-flow consumers. release.yml still owns the
  cloud image + v* tags (it resolves CONSOLE_IMAGE to a fresh console-embed
  digest, as CONSOLE_CACHEBUST did).
- native/flags/Dockerfile: the cloud-flags artifact (rust -> scratch /libhanzo_flags.a).
- hanzo.yml: images: cloud-flags (distinct artifact, never a v* tag) + zccache
  RUSTC_WRAPPER on the native-flags gate (no-op unless the runner carries it).

Companion artifact publishers: hanzoai/console#(console-embed),
hanzoai/openapi#(agent-skills).

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-17 00:49:57 -07:00
hanzo-dev f76e714d37 refactor(flags): extract the Policy primitive — fold featuregate's waitlist into the flags engine
featureflags -> flags (Hanzo's Unleash-analog / hanzoai/flags): ONE runtime
decision engine, (Principal, context) -> verdict, evaluated in-process, hot.

Fold the launch-control waitlist off its DUPLICATE SQLite mode store
(clients/featuregate) onto the one engine:

  - a service's waitlist mode IS the platform switch waitlist.<svc>,
    evaluated through the same native evaluator as every other flag
  - the host->service registry folds into clients/flags (waitlist_store.go,
    mode column dropped — the mode is the switch)
  - featuregate.Enforce stays but as a CONSUMER of flags.WaitlistModeForHost
    (the decide), via an injectable Gate seam
  - /v1/featuregate/mode -> served by flags (the decide), same wire shape
  - /v1/admin/services -> an admin lens like /v1/admin/flags
  - per-user approval still reuses IAM (featuregate/approval.go, unchanged)
  - featuregate dropped from apps Wire() — it exposes only Enforce now

The flags package doc names the aspirational end-state: authz (access policy)
and entitlements (product-access policy) are the SAME (Principal,context)->
verdict shape and could COMPOSE this one engine. NOT touched here — flagged only.

Tests (green): featuregate RULE acceptance matrix + approvals; folded-registry
store (Seed/ServiceForHost/List/Upsert); flags env/board/parsers;
apps TestWireOrderMatchesFrozen. Native-engine + cek store tests need
CGO+libsqlcipher+libhanzo_flags (CI), unchanged.
2026-07-17 00:48:30 -07:00
hanzo-dev 53cd7c585d billing bridge: ask commerce what it gates, stop keeping a copy
The bridge's mint guard kept its own list of commerce's mint routes under the
comment 'kept in lockstep with api/billing/handlers.go'. A comment cannot hold
two lists together, and it hadn't: 10 paths here against the 16 commerce gates.
Six money-mint routes were outside this guard entirely.

Commerce now DECLARES its gated surface — middleware.Mint records what it gates,
MintRoutes() exports it (commerce v1.49.0) — so the guard reads that declaration
instead of copying it. A mint route added in commerce is covered here with nobody
remembering anything, which is the only version of this that survives contact
with a busy repo.

Registration is what populates the registry, so the guard registers commerce's
billing routes before reading it. The request-level assertion is unchanged and is
the part that matters: an ordinary org user's call must never REACH commerce,
because arriving at all means arriving with the admin service token that
satisfies MayMintMoney.

Proof it bites: allowlisting 'deposit' fails with the escalation itself —
'POST /v1/billing/deposit reached commerce carrying Bearer svc-tok'.

16/16 gated routes refused. All six bridge tests pass.
2026-07-17 00:41:23 -07:00
antjeandGitHub 8479418021 gpu: mirror local studio renders to the library (independent of claims) (#324)
Every 30s (its own ticker in the connect select loop), scan --studio-dir/output
recursively for image files new or changed since the last scan and POST each to
<studio-url>/v1/library/upload with the worker's bearer, tagged ?node=<identity>
and ?subpath=<subfolder>. So EVERY render lands in studio.hanzo.ai — including
ones produced OUTSIDE the job path: a graph hand-run on the node, or a render
that finished after its activity was reaped (the stranded-late-render class). The
mirror's independence from claims/activities is the point.

State is a tiny in-memory map[relpath]size that skips unchanged files; the studio
endpoint dedupes byte-identical uploads, so a re-scan after 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 spam). New --studio-url flag (default
https://studio.hanzo.ai, HANZO_STUDIO_UPLOAD_URL honored) so the mirror works
WITHOUT jobs. No new deps. Test: cli/gpu_mirror_test.go (new/changed uploaded,
unchanged skipped, node+subpath+bearer carried).
2026-07-17 00:20:24 -07:00
antje 309c830abb agent: v0.1.2 — pass a completion billing refusal (402) through, not a 502
Bump github.com/hanzoai/agent v0.1.1 -> v0.1.2 and have the in-process completer
return the agent typed hz.UpstreamError{Status,Body} on a non-2xx completion. The
round then passes a caller-facing 4xx (402 insufficient_balance, 429, 403) through
verbatim so a no-credit user sees "add credits", not an opaque "agent: completion"
502. Proven live: POST /v1/agent with a real hk- key ran the round end-to-end and
the completion returned 402. Dropped the now-unused clip() helper. Simplified
commerce_errorscope_test to the real invariant (typed 403 never flattened to 5xx)
now that commerce v1.48.10 honors status itself; the scope stays as the boundary.
2026-07-17 00:01:18 -07:00
antje f4ab7e2bd3 deps: commerce v1.48.5 -> v1.48.10 — free $0 tiers stay self-serve (paid-tier gate keys on price, not includedCreditUsd) 2026-07-16 22:38:59 -07:00
zandhanzo-dev 94873e3e0c MountAll's doc outlived Typed
It still said the MountFunc takes the app as `any` and that in-repo subsystems
recover it via Typed. Neither is true: MountFunc names *zip.App and Typed is
deleted. The app is handed to each mount as itself.
2026-07-16 22:24:17 -07:00
antje 0e377c52ad feat(sync,git): Gitea-native sync provider + webhook reject parity; fix LoadConfig flag panic
CHANGE 1 — the /v1/sync git provider drives GITEA (the one git store), not the
retired cloud embedded store:
- clients/sync/gitea.go: a Gitea REST + go-git client (GIT_ADMIN_TOKEN, GITEA_URL).
  Inbound = fast-forward-only go-git fetch(source) -> push(Gitea), so a diverged ref
  is a conflict, never overwritten (split-brain guard, now on Gitea). Outbound = a
  Gitea push-mirror (sync_on_commit) so Gitea itself propagates every commit. Fails
  closed when GIT_ADMIN_TOKEN is unset.
- git_provider.go Reconcile and sync_api.go patch/delete now compose those Gitea
  primitives; the cloud embedded seams (InboundGitSync/ImportGitRepo/EnsureGitMirror)
  are retired from the sync path. resolve() decision, loop guard, cursor idempotency,
  and hop limit are unchanged.

CHANGE 3 — webhook reject parity + a pre-existing root test panic:
- Wrap /v1/git/webhook and the slack(events,commands)/discord/teams/telegram inbound
  webhooks in cloud.Terminal so a bad-signature 401 / malformed 400 survives the
  commerce /v1 500-flatten (uniform reject codes, matching /v1/sync and
  /v1/connector/github/webhook).
- config.go LoadConfig: guard the process-global flag registration with a sync.Once,
  so a re-entrant LoadConfig (many test callers in one binary) no longer panics
  "flag redefined: enable".

CHANGE 2 (retire the embedded git server) is NOT done: it is still a live dependency.
cloneURL resolves to api.hanzo.ai/v1/git (cloud's OWN embedded server) and the
coding-agent orchestrator clones from uploadPack, pushes to receivePack, and reads the
store via VerifyRef. Deferred — migrate coding to Gitea first.

Tests (CGO_ENABLED=0): clients/sync + clients/integrations green; new
clients/sync/gitea_test.go proves reconcile acts on Gitea and the fast-forward guard.
2026-07-16 22:20:41 -07:00
hanzo-dev 66e4999a44 Merge: Mount takes *zip.App — delete the Typed shim and its 85 wrappers 2026-07-16 21:57:01 -07:00
antje 1cf505ebd1 edge: scope commerce error envelope to its own routes (unblock release smoke — 14 endpoints 500→4xx)
commercemid.ErrorHandlerJSON() was installed as a /v1 GROUP middleware, but fiber
matches group middleware by PREFIX, not by the handle a route registered on. So on
the shared /v1 it wrapped EVERY subsystem mounted after commerce (projects, agents,
wallets, functions, integrations, marketplace, team, s3, analytics, knowledge,
automations, deploy, billing) and flattened their typed zip.HTTPError (403 "X-Org-Id
required", 400, …) into a blanket 500 — the store envelope always renders 500. The
authenticated release smoke (#322) correctly fails on 5xx, so this blocked every
release since it landed.

Fix stays cloud-side (no commerce dep bump, no money-path churn): commerceErrorScope
guards the envelope by commercePrefixes, so it stays on commerce and every other
subsystem renders its own status via zip default. Pre-commerce subsystems (kms,
o11y) already did; this makes the post-commerce ones match. Verified with the REAL
commerce middleware: /v1/projects 403 (was 500), /v1/store/current keeps the 500
envelope. Regression test added.
2026-07-16 21:50:00 -07:00
zandhanzo-dev 25fcaa4351 Mount takes *zip.App: delete Typed and the 85 wrappers
MountFunc took `app any` and cloud.Typed asserted it back to *zip.App on every
mount — a runtime check doing the type system's job, with a failure branch that
could not fire because the only value ever passed is a *zip.App. Every subsystem
paid for it: 85 call sites read cloud.Typed(x.Mount) instead of x.Mount.

The reason given was circular. cloud said `any` was load-bearing because an
external module (licensing) exposed func(any, Deps) error; licensing said it used
`any` to avoid an import cycle in pkg/cloud. Each pointed at the other, and the
cycle cannot exist: this package already imports zip (build.go), and zip does not
import cloud. The `any` was justified by nothing.

So name the type. MountFunc is func(*zip.App, Deps) error — what every subsystem
already exported and what licensing's own doc claimed all along. Typed is deleted,
the 85 wrappers are gone, and the three in-repo mounts that hand-rolled the same
assertion (mountZen, mountMetrics, MountO11y) just take the app.

The compiler immediately found what the `any` had been hiding: four mounts still
shaped func(any, ...), one of them across a module boundary. That is the point —
a signature drift is now a build failure instead of a runtime error nobody would
see until a subsystem mounted.

Also here, because the same rip surfaced them:

  - iam: v1.31.27-0.20260716191958-4400762928a2 -> v1.31.28, and the replace
    pinning a second pseudo-version is dropped. The required pseudo-version named
    a commit that no longer exists (it was rebased away), so `go mod tidy` could
    not resolve it; v1.31.28 is a real tag and a strict superset of what the
    replace pointed at. A version, not a coordinate, and no replace.
  - licensing -> v0.1.5, which is where the typed Mount ships.
  - cloud.OrgConfig is aliased next to LicenseEntitlement. Both are named by
    CommerceClient's methods, but only one was exported, so the exported
    interface could not be implemented from outside without reaching into
    cloud/types — an omission, not a boundary.
  - build_registration_test.go tested Typed and nothing else. "Recovers the
    *zip.App" proved an adapter passed through its argument; "fails closed on a
    wrong type" cannot be compiled now. What is left is the assertion that a
    subsystem signature IS a MountFunc — which the build checks.

No regressions: the failing set is byte-identical to origin/main (11 pre-existing
TestAudit_*).
2026-07-16 21:47:19 -07:00
zeekay f49d283282 build(iam2): bump embedded iam2 v0.1.1 → v0.14.0 (parity-complete surface)
The CLOUD_IAM_IMPL=iam2 identity fold (clients/iam2, identitySpec) was pinned
to iam2 v0.1.1 — an early cut missing the whole console-parity surface. Bump
to v0.14.0 so the embed actually serves what hanzo.id needs:

  - RFC/IETF surface (HIP-0111): OAuth2 code+PKCE/refresh/client_credentials/
    password, RFC 8693 token-exchange, 7662 introspection, 7009 revocation,
    8414 AS-metadata, OIDC UserInfo, SCIM 2.0 Users
  - Casdoor verb-alias compat (transitional cutover bridge) for every verb the
    live console/gateway hard-code: users/orgs/apps/providers/roles/projects
  - operator bootstrap upsert (IAM CR reconciliation), TOTP MFA enrollment,
    organization-scoped projects (ScopeSwitcher)

No wiring change — main's identitySpec + co-mingle iam2server.Mount(app, db)
compile unchanged against the v0.14.0 API. zip already at v1.8.3.

Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:43:03 -07:00
antje f20e7679ef feat(connector,sync): /v1/connector/github/webhook namespace + reject paths survive the commerce /v1 500-flatten
CHANGE A — first-party vs external route naming. Rename /v1/github-webhook ->
/v1/connector/github/webhook, opening the external-platform namespace
/v1/connector/<provider>/webhook (github now; gitlab/others are sibling literal
routes later, each with its own signature scheme). /v1/git/webhook (first-party
Hanzo Git) and /v1/sync (the bridge) are unchanged. No live consumer breaks:
the GitHub App isn't created yet.

CHANGE B — 500 -> real 4xx on the reject paths. mountCommerce registers
commerce's ErrorHandlerJSON on app.Group("/v1"); that filter rewrites ANY error
a downstream /v1 handler PROPAGATES into a hardcoded HTTP 500. Every /v1
subsystem mounted after commerce is wrapped the same way -- git, sync AND
integrations alike. git does NOT escape it: its isolated tests read 401 only
because they don't co-mount commerce (its bad-sig path is never exercised in
prod, so the flatten went unseen). Add cloud.Terminal, which writes a returned
*zip.HTTPError in-band and returns nil, so the filter's c.Next() sees nil and
has nothing to flatten. Wrap /v1/sync (all verbs) and the connector webhook.
sync no-principal is now 401 (was 403) -- an authentication failure, matching
the webhook's bad-sig 401. The commerce filter is left untouched.

Tests reproduce the /v1 flatten filter and assert: sync unauth -> 401, connector
bad-sig -> 401, malformed body -> 400; connector resolves at the new path and
the old /v1/github-webhook 404s.
2026-07-16 21:32:46 -07:00
antje c93fd2c4dd edge: /v1/agent self-meters (fix 503 balance_unavailable on the agent orchestrator)
The global BillingGate priced /v1/agent/* at a flat 1c via a dead legacy
bot-reverse-proxy rule in DefaultPrice, so every agent request was gated: the
unauthenticated preset/conversation reads hit the balance check, failed closed,
and 503d before the orchestrator handler ever ran. The route was mounted and
winning precedence the whole time — the gate short-circuited ahead of it.

/v1/agent is self-metered: the reads are free and POST /v1/agent bills through
the /v1/chat/completions it runs in-process (gated + metered downstream), so the
edge must price it 0 or double-bill. Drop the legacy branch (and its now-unused
cloudEdgePriceCents const); price /v1/agent and /v1/agent/* at 0. Tests updated.
2026-07-16 21:06:09 -07:00
hanzo-dev 2f4095e68a Merge feat/openapi-spec: /v1/openapi.json generated from the live router
The spec IS the router, not a description of it: apps.Wire() -> MountAll ->
app.Fiber().GetRoutes(). 983 operations / 692 paths / 109 products, served beside
/zap so ZAP and OpenAPI are two projections of one route table rather than two
sources that can disagree.

A drift test proves the bijection and was proved to fire; it already caught
/v1/pricing-policy and /v1/pricing/policy collapsing onto one operationId.

# Conflicts:
#	serve.go
2026-07-16 20:42:42 -07:00
6233b3c805 fix(billing,smoke): usage reads the co-resident ledger (not a self-dispatch) + a real authenticated release smoke (#322)
A live authenticated smoke surfaced two production bugs; this fixes both and
adds the durable smoke that now guards every release.

BUG — /v1/billing/usage 500 for a valid caller. usage() proxied
"/v1/billing/usage" through commerceinproc, which re-dispatches BY PATH.
Commerce's own billing routes are behind //go:build cloud and never compiled
here, so the ONLY registration of that path is usage() itself — the S2S hop
re-entered the handler, which self-answered "sign in to view billing". This is
the SAME defect balance() was already fixed for. usage() now reads the usage
ledger DIRECTLY from cloud's own finance ledger (finance.ListUsage → the
wallet→revenue debits RecordUsage wrote), off the self-dispatching hop;
split-deploy falls back to the commerce S2S read, unchanged.

BUG — the balance gate 402'd read-only GETs (fixed in hanzoai/ai, pinned via
the go.mod bump). A $0-balance org could not VIEW its own resources. Fixed in
the ai module's BalanceGateFilter — reads never spend, so GET/HEAD/OPTIONS are
exempt; only writes/metered POSTs gate on balance.

SMOKE — cmd/smoke: a durable, authenticated per-subsystem prober. One
side-effect-free read per core subsystem; a read that 402s (balance gate) or
5xx-es (crash) fails the release. Baked into the image (Dockerfile) and wired
into release.yml as the functional gate after the boot check, so a release can
never ship with chat/billing/projects/kms/... down. The smoke token is
KMS/secret-sourced (never hardcoded); absent → the anonymous matrix still gates
public/authed and catches every 402-on-read / 5xx.

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-16 20:35:56 -07:00
hanzo-dev 52acb303c2 cli: multi-identity credential store with active-pointer switch
Store every logged-in identity in ~/.hanzo/identities.json keyed by a stable
<owner>/<name> key (admin/z, hanzo/z), with an Active pointer; mirror the active
identity into credentials.json so every legacy single-file reader is unchanged.
A second login as a different owner for the same email (privilege separation:
hanzo-admin-guard vs hanzo-console) is stored beside the first, not over it.

New: auth list (whoami --all), auth switch <owner|owner/name> (alias use),
logout [<owner>]. Token refresh writes through the store (SaveActive) so the
active identity stays fresh after rotation. All files stay mode 0600.

One credential store, active-pointer switch, backward compatible.
2026-07-16 20:20:53 -07:00
hanzo-dev 951703f486 dedup: fold hanzoai/o11y module wildcard into the one o11y subsystem
apps.Wire() had TWO {Name:"o11y"} entries: the in-repo read plane
(o11y.MountO11y) and the external hanzoai/o11y module wildcard
(cloud.Typed(o11ymod.Mount)). Fold the module wildcard in as the TERMINAL
sub-mount inside o11y.MountO11y (registered after every specific /v1/o11y/*
route, so Fiber in-order match still gives them precedence), delete the 2nd
Wire entry and the now-unused o11ymod import -> ONE o11y spec.

/v1/o11y/health is preserved exactly: the merged entry keeps OwnsHealth=false,
so the generic always-ok route is registered before MountAll (ahead of the
wildcard) — byte-identical to when the module co-entry, also OwnsHealth=false,
triggered it. Frozen wire order updated (two co-owned rows -> one); the
no-duplicate test no longer needs an o11y exemption.
2026-07-16 20:17:18 -07:00
hanzo-dev 7022064723 dedup: delete dead clients/session
Zero importers, no cmd/session, absent from apps.Wire(); its /v1/code/sessions
surface was never mounted (dark). Removes session.go, store.go, session_test.go.
2026-07-16 20:10:06 -07:00
hanzo-dev 8dabc5c469 fix(cloud): serve /v1/iam/* — the org-scoped edge to Hanzo IAM
The one-binary console (console.hanzo.ai — cloud serves console's static export)
reads org members + projects at /v1/iam/*, but cloud 404'd those (IAM isn't
folded in-process yet) and the old /org/iam BFF proxy is pruned from the static
export. So the browser got the SPA shell (HTTP 200 HTML), which the client
surfaced as "Request failed (HTTP 200)" — the Platform page died on "Could not
load".

iam_edge.go adds an org-scoped reverse edge at /v1/iam/* → the standalone IAM,
mounted when IAM is NOT folded in-process (else that subsystem owns the path — no
double-mount). The org is PINNED to the caller's validated, server-minted
X-Org-Id (never a raw client header) — load-bearing, since IAM's own authz is
permissive on the org-keyed routes, so without the pin one tenant could read
another's projects. A super admin may cross; a tenant may not; writes require an
org admin and must carry the caller's own org. Shares the ONE IAM identity
(iamHost/iamCred) with the API-key resolver (DRY).

Verified: 7 gate tests (pin, cross-tenant refuse, super-cross, allow-list, 401,
write-gate, own predicate) + go build clean.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-16 20:07:12 -07:00
zandGitHub ac65cf4ec3 chore(deps): bump hanzoai/tasks → v1.51.1 (fix api.hanzo.ai concurrent-map-writes crash)
Fixes the live recurring tasksd concurrent-map-writes fatal (hanzoai/tasks#18, v1.51.1). Build green; the failing 'Test' check is the pre-existing repo-wide LoadConfig flag-redefine panic (fails on main + all branches), unrelated to this go.mod-only bump.
2026-07-16 20:02:07 -07:00
hanzo-dev 5e06e29de5 Merge: tenant apps are App CRs — the last writer of the Service kind
clients/platform was the only thing still minting Service CRs; every other
declarer is already App. A role-less App dispatches to the operator's service
profile, the same reconcile the Service kind ran, so a tenant workload carries
over verbatim.

Existing tenant CRs still resolve (App first, Service second). A redeploy patches
the kind it IS rather than minting a twin — two CRs on one name is the
commerce-admin ownerRef flap, not a migration. Teardown deletes BOTH kinds:
either alone rebuilds the app the tenant just deleted, still billing.

113 tests pass, 7 new.
2026-07-16 19:40:04 -07:00
hanzo-dev 095e1cce4e platform: tenant apps are App CRs — the last writer of the Service kind
clients/platform was the only thing still minting Service CRs. Every other
declarer is already App: universe git has zero hanzo.ai/v1 kind:Service, the
operator calls App "the sole workload reconciler for the collapsed fleet", and 69
of the 80 live CRs are Apps. A tenant app was the exception for no reason — a
role-less App dispatches to the operator's service profile (controllers/app.rs
`classify("") => Dispatch::Service`), which is the same reconcile the Service kind
ran, so an App carries a tenant workload verbatim.

Nothing mints a new Service CR after this. What already exists still resolves:
reads, patches, scales and deletes walk crGVRs() — App first, Service second — so
the 3 live tenant CRs written before this keep working untouched.

A redeploy of a pre-collapse app patches the kind it IS rather than declaring an
App twin. Two CRs claiming one name is not a migration, it is the commerce-admin
flap: both kinds materialize the same children through the same materializer under
one field manager, so the Deployment's ownerRef just flips between them.

Teardown deletes BOTH kinds. Either kind alone re-materializes the Deployment, so
removing only the one that resolves first would rebuild the app the tenant just
deleted — running, and still billing, minutes after a successful delete.

Sequencing (deploy order matters): operator v0.7.7 carries the Claim guard that
makes App the deterministic owner when both kinds claim a name. This is safe
before it — the no-twin rule means a colliding pair is never created here — but
the guard is what makes an existing collision safe to clean up.

REMOVABLE once no Service CR remains in any tenant namespace: drop servicesGVR
from crGVRs() and the delete-both, and this file is App-only.

Tests: 113 pass; 7 new (kind is App, no twin on legacy redeploy, delete removes
both kinds, idempotent delete, resolution order, absence honest). The 10 tests
that asserted a Service CR is written now assert the kind we write.
TestMigrateOverLegacyPlatformApps fails identically on pristine main (sqlcipher
codec, environmental).
2026-07-16 19:39:56 -07:00
antje bffe597ff0 apps: frozen wire list — add sync entry (main added /v1/sync to Wire; frozen drifted, breaking TestWireOrderMatchesFrozen); fix agent route comment 2026-07-16 19:38:00 -07:00
hanzo-dev 601d5660b7 Merge: paas reads the kind the fleet runs on, and does not fight the git declarer
The PaaS control plane serves platform.hanzo.ai and read services only: 69 App CRs
run in the scanned namespaces against 7 Service CRs, so the SUPERADMIN drift board
rendered 7 rows for a 69-app fleet — 1 in production — and /v1/paas/health probed
the Service CRD, found it served, and reported ok over a blind board.

Reads now walk App first, Service second, deduped by name. Writes refuse a
git-declared App (Hanzo CD syncs it with selfHeal, so a patch is reverted on the
next sync) and name the file to commit to; a Service CR still patches.

35 tests pass, 0 fail.
2026-07-16 19:30:16 -07:00
hanzo-devandz 8bf2dda4d1 feat(shard): in-binary org→owner shard router for horizontal writer scale
Lifts the unified binary off replicas:1 on DigitalOcean (RWO-only block
storage, no RWX) WITHOUT any shared volume. Each org is pinned by rendezvous
hashing (ha.Owner over the static CLOUD_PEERS ring) to exactly one owner pod;
a request whose org this pod does not own is transparently forwarded to the
owner, so every per-org SQLite store (KMS, finance, and every org-keyed store),
audit append, per-org rate ceiling, and prepaid billing debit runs on ONE pod.

THE INVARIANT — no two pods ever write one tenant's SQLite file — is upheld by
two independent guarantees that compose: per-pod RWO PVC (different physical
files per pod) + org→owner routing (all of an org's writes on one pod).

- shardrouter.go: the middleware. Runs immediately after SanitizeIdentity so it
  keys on the VALIDATED, server-minted X-Org-Id (never a client header), hashes
  the SAME injective SanitizeOrg slug the on-disk path uses (routing key ≡ file
  key), and forwards via a fasthttp streaming proxy (SSE/chat pass through — a
  streaming chat still carries a per-org billing debit, so it routes too) with
  dial-only retry across an owner's roll gap and a 421 loop-guard on divergence.
  Static identical membership rules out dual-owner and routing loops.
- config.go: CLOUD_PEERS (id@addr ring) + POD_NAME (self). Sharding auto-enables
  only when >1 peer; Validate fails closed if self is not in the ring or if
  embedded iam (non-shardable process-local sessions) is co-enabled.
- serve.go: wires the middleware after IdentityMiddleware; shard-aware boot log.
- audit_serve.go: per-shard audit is automatic on the per-pod PVC; stamp the
  shard id on the AU-9 checkpoint stream so the tail-truncation monitor tracks
  N heads. writerpin.SingleWriter is correct PER SHARD (each pod sole writer of
  its orgs); the writer lease is pod-local under per-pod PVC and stays off.

No-op (byte-identical to today) when CLOUD_PEERS names ≤1 pod. N pinned at 3;
rebalance-on-N-change (a tenant-file move) is the documented follow-up.

Tests prove: exactly one owner per org (deterministic, total, evenly
distributed); all pods agree (no dual-writer); owned served locally, unowned
forwarded to the owner (local chain never runs, hop header set, response
streamed); org-less served locally; loop-guard 421; boot-gate fail-closed.
2026-07-16 19:28:27 -07:00
hanzo-dev db226b321b Merge: bill zen in the credit unit — FromInt(Minor()) understated every debit by 10^16 2026-07-16 19:26:12 -07:00
hanzo-dev f24596453e Bill zen in the credit unit, not the currency's minor unit
zen prices every SKU as an exact 18-dp value tagged money.USD. money.USD
declares 2 decimals, so Amount.Minor() rescales that value to CENTS, while
cloudmoney.FromInt reads its argument as 18-dp. Composing the two divided
every zen debit by 10^16: a $17.376 charge debited $0.000000000000001738,
and any charge under half a cent folded to a zero that metering.Record drops
before it reaches the ledger — no debit row, call served free.

The spend gate read the same composition. FromInt(est.Minor()).Cents() is
always 0, and AuthorizeVerdict only compares balance against size when
AmountCents > 0, so the size check was dead code and any org with a positive
balance could draw a request of any size. The dust debits never accumulated,
so the cap could not trip either.

Route the seam through one conversion. credit() carries the exact decimal
across and changes only the minor-unit convention — no rescale, no rounding,
no factor — because the decimal is the value and a currency's Decimals is a
rendering convention. cloudmoney.FromDecimal is the typed counterpart of
ParseUSD that makes this expressible without a string round-trip. nano()
takes the typed Amount rather than a bare *big.Int, so a cents integer can no
longer reach the warehouse fold.

The tests asserted Amount.Int() against Charge.Minor() — the same integer on
both sides — so they proved a round-trip and were blind to the unit. They now
assert against known dollar values built through a different constructor: a
$17.376 charge debits $17.376. Reintroducing Minor()-into-FromInt fails all
of them, including a gate test that admits an over-cap request.
2026-07-16 19:24:29 -07:00
hanzo-dev 185cca68f2 Forward only the billing paths the console reads
The /v1/billing/* bridge attaches the commerce service token, and that token
satisfies commerce's MayMintMoney. The subpath was checked for traversal but
not against a set, so POST /v1/billing/deposit forwarded a mint for any
signed-in user — pinned to their own subject, which aims the mint rather than
stopping it. Commerce closed this on its direct path the day after the bridge
reopened it, and wider: the bridge needs only a validated principal, never an
admin bit.

The forwardable set is now a per-method table, consulted before the token is
attached, 404 on anything else. Per-method because payouts is a GET read and a
POST mint on one path — a method-blind set hands the mint to every reader.

Nothing reachable today: commerce's mint is not compiled into this binary and
commerce.hanzo.svc resolves here. But devnet runs a real mint-capable commerce
and points cloud-api at it under a second name for the same thing; unifying
those names would arm it. The gate should exist before that cleanup does.
2026-07-16 18:54:20 -07:00
blue db4801d787 Gate the billing bridge on an allowlist: the service token is authorization
/v1/billing/* forwarded any subpath to commerce carrying the admin
COMMERCE_SERVICE_TOKEN, validated only against path traversal. Forwarding IS
authorization there: commerce gates money-mint on
MayMintMoney = IsServiceToken || IsSuperAdmin, so every forwarded path ran with
platform authority. Commerce 403s an org admin who POSTs /v1/billing/deposit
directly; through this bridge the same person was handed the platform's own
credential, and the subject-pinning aimed the credit at their own account.
Pinning is an IDOR control, not an authority control.

Add billingForwardable: a per-method allowlist of the endpoints the console
actually calls, enforced before the token is attached. An unlisted path 404s.
Allowlist, not denylist — a mint route commerce adds tomorrow is unreachable
with no change here. GET and POST are separate sets because `payouts` is a read
AND a mint-gated write; one method-blind set would hand the mint to every reader.
The POST set holds nothing that creates balance from a client-named amount.

Not exploitable in the current topology: commerce is co-resident in cloud, whose
build never compiles mount.go (//go:build cloud), so no api.Route billing bundle
is reachable and the forward loops back to this bridge. It is one COMMERCE_URL
away from live — devnet already runs a standalone commerce.

Tests: an ordinary org user's deposit/credit/refund/credit-grants/husd/allotment
now never reach commerce; the console's 12 real calls still forward; the store
bridge still cannot tunnel into billing.
2026-07-16 18:36:07 -07:00
hanzo-dev 337c159db0 Embed zen v1.4.0 so the prompt-cache fix reaches production
zen v1.4.0 bills the prompt cache: cache_read was priced in the catalog,
published in /v1/models, and never charged. cloud pinned v1.3.11, so the
fix could not reach api.hanzo.ai, where the traffic lands.

The module graph cannot move: zen's own go.mod is byte-identical between
v1.3.11 and v1.4.0 (same sha256), and cloud holds exactly one zen edge.

NOTE: this is necessary but NOT sufficient. hmoney.Minor() returns cents
while cloudmoney.FromInt expects atto, so apps/zen.go understates every
debit by 1e16 and the spend gate reads AmountCents 0. Correct pricing is
still zeroed downstream. Tracked separately; that fix is what makes cache
billing real.
2026-07-16 18:21:12 -07:00
hanzo-dev 20e7591f53 openapi: serve GET /v1/openapi.json generated from the live router
The route table gets a third projection. /zap replays the /v1 handlers, the
console renders them, and GET /v1/openapi.json now describes them — all read
from the ONE router after MountAll, so none can drift and none holds a second
copy. There is no checked-in spec file and no second registry.

openapi.Live(app) reads app.Fiber().GetRoutes(true) (fiber's own filter drops
Use() middleware); every other function is pure over that []Route. Each
operation is tagged with its product — the first path segment after /v1/ —
so a CLI can build `hanzo <product> <resource> <verb>` with no judgment.

Reading the LIVE router is the only total source: POST /v1/kms/auth/login is
registered as Group("/v1/kms/auth").Post("/login") and no grep can find it,
and the route set is a function of deployment config, so the document varies
per deployment — correctly. Unauthenticated: it grants no capability, every
route it names stays auth-gated, and `hanzo --help` must build its tree
before login.

The drift guard (cmd/cloud/openapi_test.go) is a bijection over the fully
mounted apps.Wire(): 983 operations / 692 paths / 109 products, every live
route present, no operation invented. Shown to fail on a broken translation
(353 routes reported missing) before being restored.

Honest boundaries, asserted rather than papered over:
  - No schemas. The router holds func(*zip.Ctx) error; the request type is a
    local inside the handler (var req secretPutRequest; json.Unmarshal(...)),
    unreachable by reflection. cloud.Handle[S] is generic over the SERVICE,
    not the payload. The path to schemas is zip's typed ops, which today
    number zero — which is why zip's own generator emits nothing here.
  - No responses block. OpenAPI 3.1 makes it optional; fabricating 200/ok on
    ~900 routes would assert what nothing knows.
  - HEAD/CONNECT excluded — forced, not taste. CONNECT has no OpenAPI field;
    fiber auto-generates HEAD in startupProcess(), so including it would make
    the document depend on lifecycle stage.
  - Catch-alls are opaque: POST /v1/billing/deposit is not a route here.

Corrects LLM.md, which the code contradicted: Wire() does exist (apps.go:188),
MountAll does not sort, and byte-identical patterns MERGE rather than panic.
A high handler count is not a collision — app.Post(path, mw1, mw2, mw3, h) is
one registration with four handlers (apps/commerce.go:151), and 34 live routes
are that shape, so the generator never reads the count.
2026-07-16 18:14:23 -07:00
antje 9d5d51936a cloud: mount hanzoai/agent /v1/agent (no-shim); delete dead clients/chat
clients/agent adapter injects the ai completion (in-process, billed) + tools.Default()
into github.com/hanzoai/agent; POST /v1/agent live path. Dispatch resolves the caller
via the ONE canonical tools.PrincipalFrom — no reconstructed principal. Builds green
on the v1.801.35 base. DEPLOY-BLOCKED: go.mod uses a local replace for hanzoai/agent
(CI needs a fetchable release + the local module-cache shallow-clone bug resolved).
2026-07-16 18:00:50 -07:00
hanzo-dev c5cefc39a6 Bill the prompt cache in production: zen v1.3.11 -> v1.4.0
cache_read was priced in the catalog and published in /v1/models, but never
charged: on an openai upstream every cached input token billed at the full
`in` rate, and on an anthropic one it left the bill entirely. zen v1.4.0
normalizes both dialects into one tally over three disjoint classes
(fresh + cached + cacheWrite == the whole prompt) and derives once per
response. cloud embeds zen via apps/zen.go, so the fix is inert at
api.hanzo.ai until this pin moves.

zen v1.4.0's go.mod is byte-identical to v1.3.11's, so the require edit and
the two go.sum lines are the whole change: no transitive pin moves, and
nothing else in the graph names zen.
2026-07-16 17:49:16 -07:00
hanzo-dev 7f6b3a4cfe Merge: fix the zen margin test's decimal + money types (unblocks CI on main) 2026-07-16 17:48:36 -07:00
z e538ae0b7f apps: price the zen test with the decimal money actually uses
The zen margin test never compiled, so CI has been red on main since it landed:
it parsed prices with shopspring/decimal and handed the result to hmoney.New,
which takes hanzoai/decimal. Two identically-named types, one of them wrong.

    vet: cannot use d (struct type "github.com/shopspring/decimal".Decimal)
         as "github.com/hanzoai/decimal".Decimal value in argument to hmoney.New

Money has exactly one decimal. A second one that merely LOOKS like it is how a
price silently becomes a different number, which is why the compiler is right to
refuse. Parse with hanzoai/decimal (decimal.Parse — the same call zen itself
prices with); this was the only file in cloud importing shopspring.

Read the debit through Amount.Int(), not Amount.Minor(). The test held two money
types at once: zen's hanzoai/money.Amount (Charge/Cost) has Minor(), but
meterUsage returns cloud's clients/money.Amount, whose accessor is Int() — it
wraps Minor() and returns the identical *big.Int, so the assertions are unchanged.

The tests themselves are worth keeping: they prove the debit is the retail Charge
and never the upstream COGS, that a 3x-margin tier collects 3x, and that a
sub-cent call does not floor to zero. They just could not run.

Not run locally: ./apps links a prebuilt Rust staticlib (libhanzo_flags.a) that
is built in CI, not here. vet — the step CI actually failed — passes, and the
package builds.
2026-07-16 17:48:26 -07:00
hanzo-dev 2593b1b18e GOPRIVATE names the namespace that is actually private
GOPRIVATE named zap-proto/*, which is public -- all 55 repos, all 6 modules
cloud needs served anonymously from the public proxy. The namespace that is
private went unnamed: github.com/hanzoai/* (467 private repos incl. ai,
account, commerce, orm, xorm, beego, csqlite). It resolved only by falling
through GOPROXY's direct fallback, and passed checksums only because go.sum
already pins everything.

containment.yml then set GOSUMDB=off to compensate. GOSUMDB does not scope to
a namespace: that disabled checksum verification for every module in the
build, public ones included, in the image that handles payments -- breaking
the invariant the Dockerfile three files away states and honors.

Public modules keep proxy and sumdb immutability; private ones go direct and
authenticated. Verified under the CI and Dockerfile env with -mod=readonly and
sumdb on: build 0, go mod verify all verified.
2026-07-16 17:33:16 -07:00
hanzo-devandantje 40ca6a48a8 feat(sync): universal /v1/sync engine — GitHub/GitLab ⇆ native Hanzo Git
One reconcile loop, one place a sync happens. clients/sync owns the Sync
record (source/target/direction/trigger/cursor), a per-org sqlite `sync`
table, /v1/sync CRUD + /v1/sync/:id/run, and a kind→Provider registry with a
single Reconcile(sync,event) contract. Git is the first provider, composing the
existing git object-plane seams (InboundGitSync inbound, ImportGitRepo reconcile,
EnsureGitMirror outbound) — no second copy of any git op.

Triggers resolve to Syncs and enqueue the engine (cloud.Sync), never sync
directly: the GitHub App webhook now serves the path it actually fires at
(/v1/github-webhook, was a prod 404 at /v1/integrations/github/webhook) and hands
the verified push to the engine; the Gitea push webhook gains a loop guard
(skip pusher == GIT_SYNC_ACTOR). Loops break on the engine cursor (identical
fingerprints are a no-op) + actor guard; chained propagation (a sync's target is
another's source) is bounded by a hop limit.

Seams (root cloud): SyncFunc + RegisterSync/Sync, GitMirrorController +
EnsureGitMirror. Fail-closed on unmounted engine / missing secret. Tests
(CGO_ENABLED=0): engine loop-guard/idempotency/chain/hop-limit, git resolve,
CRUD+patch+run, webhook signature/isolation/enqueue, gitea loop guard.
2026-07-16 17:26:06 -07:00
hanzo-dev 2cc52957b4 build: GOPRIVATE names the namespace that is actually private
GOPRIVATE listed github.com/zap-proto/*. Every zap-proto repo is public — all 55
of them — and every zap-proto module this build needs is served by the public
proxy anonymously. It was never the reason anything resolved direct.

The namespace that IS private went unnamed: github.com/hanzoai/* — ai, account,
commerce, orm, xorm, beego, csqlite and ~30 more. Those only ever built by
falling through GOPROXY's `direct` fallback after the proxy 404'd them, and only
kept passing the checksum step because go.sum already pins them, so no sumdb
lookup happens. It worked by accident, one added dependency away from failing.

containment.yml compensated for that unnamed namespace with GOSUMDB=off, which
does not scope to hanzoai — it disables checksum verification for EVERY module in
the build, the public majority included. The Dockerfile's comment reasoned the
same way inverted ("hanzoai/* and luxfi/* are PUBLIC ... only zap-proto/* is
exempt"); hanzoai/* is largely private and luxfi/* (all 37 deps here) is public.

Naming github.com/hanzoai/* is what the off switch was standing in for. GOPRIVATE
implies GONOPROXY+GONOSUMDB for exactly that namespace, so the private modules go
direct+authenticated and skip the sumdb that cannot see them, while zap-proto and
luxfi keep the public proxy + checksum db — the immutable hashes that make a
force-moved tag unable to break or poison the build. GONOSUMDB and GOSUMDB=off
are dropped: scoped by GOPRIVATE, they are redundant, and blanket-off is a
supply-chain regression in a money image.

hanzoiam/* is not listed: a74b7de reverted the scim/saml/ldap embed, so nothing
in go.mod requires it. It goes back when the modules resolve, named as private.

Verified under the exact CI/Dockerfile env (GOPRIVATE=github.com/hanzoai/* only,
GOSUMDB=sum.golang.org, GOFLAGS=-mod=readonly): build exit 0, vet exit 0,
`go mod verify` = all modules verified, and `go mod download` resolves both a
public module (zap-proto/zip) and a private one (hanzoai/ai).
2026-07-16 17:21:31 -07:00
antje 28aaa39d56 deps: ai v1.816.0 -> v1.817.0 — global auto-routing switch is the '*' settings row, not ROUTER_ENABLED env (config-as-Base)
Runtime routing policy now sources from the GlobalDefaultOwner OrgSettings
row (admin.hanzo.ai-editable SQLite), env demoted to deprecated fallback.
Per-org override unchanged.
2026-07-16 17:16:33 -07:00
hanzo-dev dfe30a3883 paas: read the kind the fleet runs on, and don't fight the git declarer
The PaaS control plane serves platform.hanzo.ai and read `services` only. The
fleet collapsed onto `kind: App` and this reader never followed: 69 App CRs run
across the scanned namespaces against 7 Service CRs, so the SUPERADMIN drift
board rendered 7 rows for a 69-app fleet — 1 row in `hanzo`, production, and that
row is a duplicate CR. It was not an error anyone could see. The board looked
plausible and was blind to 68 of 69 services; every read of an App-declared
service 404'd; and /v1/paas/health probed the Service CRD, found it served, and
reported ok — status theater over a blind board. clients/deploy already solved
this with an App-first/Service-second read order; this gives the same order to
the reader that needed it.

Both kinds are listed and deduped by name: one workload is one row even when an
App CR and a Service CR both claim the name, because a Deployment has one
controller ownerRef and the operator's Claim guard gives it to the App.

The write path is the harder half. Hanzo CD syncs 68 of the 69 App CRs from
universe `infra/k8s/operator/crs/` with selfHeal on, so patching an App CR here
is reverted on the next sync — the deploy would report success and silently roll
back. That is worse than refusing, so deploy and release now resolve which kind
holds the workload and refuse a git-declared one, naming the file to commit to.
A Service CR has no git declarer (cloud and kubectl write them directly), so it
still patches exactly as before — the tenant plane and the untransitioned CRs are
untouched. release.go's "no ArgoCD" claim was true when written and is not now.

This does not restore an admin deploy button for the git-declared fleet. Under
GitOps that button belongs in git, and ImageUpdate already names that seam
(registry→git→cluster). Whether /v1/paas/deploy should commit to universe or be
retired is a CTO call, not one to make silently inside a read fix.

Tests: 35 pass, 0 fail, including the pre-existing release suite. The fleet-sees-
App-CRs case is seeded to the shape of the real `hanzo` namespace.
2026-07-16 17:12:40 -07:00
antje a74b7de5c4 revert(iam2): back out scim/saml/ldap embed — hanzoiam repos do not resolve
Reverts e4b3c88f. go.mod pinned github.com/hanzoiam/{ldap,saml,scim}, but none
of the three repos exist: https, ssh, and API all return not found, and ldap
was never fetched by any proxy or cache. Cold machines — including release
runners — fail go mod download, so main could neither build nor release.
Local builds passed only on module caches warmed 21:36-21:48 UTC today.

iam2 restored to v0.1.1, the state prod v1.801.38 ships. Re-land the embed
unchanged once the hanzoiam repos exist and resolve from a cold GOMODCACHE.
2026-07-16 17:01:45 -07:00
hanzo-dev f662973eb2 Merge: one name for the account, one for the org — and the last copy of the payer rule deleted 2026-07-16 16:38:08 -07:00
z e72d77b21b billing: one name for the account, one name for the org
Two functions were called Payer and returned different things: account.Payer
returns the ACCOUNT that pays, principal.Payer returned the ORG whose ledger
holds it. Those are different values on the same request — a person in the shared
signup org pays from account "hanzo/alice" held in ledger "hanzo" — and one name
for both is how the gate came to key the pool while the debit spent the person.
Rename it to what it returns: principal.HomeOrg. An org names a ledger; an
account names a wallet within it.

Fix the last copy of the rule with it. clients/metering is cloud's vendored
metering client, and its IdentityFromGatewayHeaders still hardcoded `user := org`
under the same false premise, while its doc claimed cloud "mirrors" the module
"exactly so every product keys the SAME ledger entry" — a promise two independent
copies cannot keep. Both now call hanzoai/account.Payer, so they agree by
construction and the comment is true for a reason.

Its test asserted the divergence in words — "want hanzo (per-org billing key, not
org/sub)" — which is how a premise outlives the code that disproved it. It now
asserts equality with the rule.
2026-07-16 16:37:43 -07:00
hanzo-devandz b2c5b4f81e build(cloud): mirror base images to ghcr.io/hanzoai/mirror/* (kill public.ecr.aws 429s)
public.ecr.aws (ECR Public) rate-limits anonymous pulls with HTTP 429 on shared
CI runners; a 429 on any base pull aborts the release (a release died pulling
python:3.12-alpine). Repoint all five FROMs to 1:1 linux/amd64 mirrors in our
own GHCR namespace, pinned by digest for immutability:

  node    ghcr.io/hanzoai/mirror/node:24-alpine@sha256:0cb0e7c3195bce740b6c8d8b27432c92360e3b7f1528087f2c50640b177950c6
  python  ghcr.io/hanzoai/mirror/python:3.12-alpine@sha256:aa679aa4eed6eb56c1dc6ad3f1b98b7d2d788fd961596779d188fdedad97fb38
  rust    ghcr.io/hanzoai/mirror/rust:1-alpine3.22@sha256:b348cb409ac0a73de15065997a360063cf87465574a15e3e4469862cb8996f02
  golang  ghcr.io/hanzoai/mirror/golang:1.26-alpine3.22@sha256:47d47cb5cc3c7dac409dcb6c3a98a6263571218046cd02d709527feef804a77c
  alpine  ghcr.io/hanzoai/mirror/alpine:3.22@sha256:7c8cb692ae09657cbc4a3f3cbd0e8d5a2690ba38386aaaf252dbb060bf5eb2e6

The four the task named plus alpine:3.22 (the final-stage base — same registry,
same 429 exposure) so no FROM still hits public.ecr.aws. release.yml already
logs the build into ghcr.io (GH_PAT, docker/login-action) before building, so
buildx resolves these private mirrors today with no new plumbing. Only the five
FROM lines + one rationale comment change; nothing else in the Dockerfile.

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw
2026-07-16 15:48:48 -07:00
hanzo-dev 6c77cfccab feat(ai): bump ai v1.814.0 → v1.816.0 — router self-probe, flywheel trainer, casibase metering
Ships the full router flywheel into the cloud binary: the self-probe
(continuous tagged auto traffic → reward ledger), the fit→gate→auto-deploy
→publish trainer, the routing-latency guard (~0.68us heuristic), and
casibase-chat usage/o11y metering. Also carries the zen warehouse+span
wiring already on main.

Migrates the billing-subject callers (balance.go, account/billing.go +
tests) from the ai/object.Payer that v1.816.0 REMOVED to the extracted
github.com/hanzoai/account.Payer — the same rule in its new home (the
concurrent decomplect). Same replace directive for the force-pushed iam
pseudo-version account v0.2.0 pins.

Claude-Session: https://claude.ai/code/session_018PmFAHZvbBSTsuWyebwMra
2026-07-16 15:32:35 -07:00
hanzo-dev 34800f6991 ci(cloud): GOPRIVATE += github.com/hanzoiam/* (enterprise IAM modules)
The iam2 embed pulls github.com/hanzoiam/{scim,saml} (+ ldap under -tags iam2_ldap).
hanzoiam is a distinct org from hanzoai, so hanzoai/* did NOT cover it — the test
phase would route these private modules through the public proxy/sumdb and 404.
Git auth is already handled by the reusable CI's GH_PAT insteadOf (covers any
github.com private repo the PAT reads).
2026-07-16 15:04:30 -07:00
zeekay e4b3c88f82 feat(iam2): wire SCIM + SAML into the cloud embed; LDAP GPL-isolated behind -tags iam2_ldap
Last code step of the iam2 migration. clients/iam2 blank-imports the Apache-2.0
enterprise features so their init() self-registers into iam2's feature registry;
iam2server.Mount -> feature.MountAll auto-mounts them under CLOUD_IAM_IMPL=iam2.
ONE mechanism (database/sql driver pattern) — no feature.Register call in cloud,
which (feature.Register appends with no dedup) would double-mount and collide routes.

  _ github.com/hanzoiam/scim   SCIM 2.0 provisioning  (/scim/*)
  _ github.com/hanzoiam/saml   SAML IdP + SP SSO      (/v1/iam/saml/*, /v1/iam/acs, /v1/iam/get-saml-login)

LDAP is GPL-isolated: hanzoiam/ldap links goldap (GPL-2.0), so it is opt-in only via
clients/iam2/ldap_enabled.go (//go:build iam2_ldap). The DEFAULT cloud binary stays
copyleft-free — proven: `go list -deps ./clients/iam2/` has no goldap; only under
-tags iam2_ldap does it pull github.com/lor00x/goldap + hanzoai/ldapserver.

deps: hanzoai/iam2 v0.1.1->v0.1.4; + hanzoiam/{scim,saml,ldap}; go mod tidy.
2026-07-16 14:59:47 -07:00
hanzo-devandz 6a47436062 feat(kms): one-time legacy ZapDB -> per-org SQLite migration (deploy-safe cutover)
Without this, deploying the per-org store over live data boots an EMPTY store and
orphans every secret — cloud KMS is the authoritative source the kms-operator syncs
out to every service, so that is a cluster-wide outage. New() now runs a one-time,
writer-only, keyed, FATAL-on-error migration BEFORE serving: it opens the legacy
{DataDir}/kms ZapDB (encrypted at rest with the master key), streams every SEALED
secret (kms/secrets/ prefix; the JSON value carries the full coordinate + AES-GCM
ciphertext + ML-KEM wrapped DEK) verbatim into its per-org SQLite file via store.put
— NEVER unsealing, so no plaintext is exposed and the AAD path-binding survives —
then archives the legacy dir to {DataDir}/kms.migrated so the OS lock is released for
good. Idempotent (prior .migrated marker or absent store = no-op; put upserts).

Tests: migrate roundtrip (opens to same plaintext), no-legacy-store no-op, and the
cross-org relocation defense still holds after migration. Full kms suite green (2
pre-existing admin-edge fails only).
2026-07-16 14:55:16 -07:00
hanzo-devandz b31c642959 feat(kms): per-org SQLite store — replaces OS-locked embedded ZapDB, lifts replicas=1
The embedded luxfi/kms ZapDB (a Badger fork) held an exclusive OS lock on ONE dir
for ALL orgs — the hard reason cloud ran replicas=1 behind a cross-process writer
lease. This replaces it with per-org encrypted SQLite ({DataDir}/orgs/{org}/kms.db
via cloud.OrgDB->cek, per-db DEK), which has no exclusive-opener lock, so distinct
tenants never contend and different pods can serve different tenants.

Crypto stays in the client (Seal/Open AES-256-GCM envelope, AAD-bound to the FULL
/orgs/{org} path): plaintext never reaches the file, and a record physically moved
into another org's file still fails to Open (cross-org swap defense preserved).
Adds cloud.PlatformDB for the reserved non-tenant (_platform) partition.

Verified: 54 PASS / 2 FAIL in clients/kms; the 2 fails (admin-edge dualmount/authz)
are PRE-EXISTING — proven failing identically on origin/main. concurrent_open_probe
proves per-org SQLite has no exclusive lock. Build + finance (pure-Go) green.

Branch only — NOT for main/deploy until red review passes.
2026-07-16 14:55:16 -07:00
zandhanzo-dev 2ac7f835e2 billing: the edge gate keys the account the debit spends
The gate keyed `user := home` — the org pool, always — on the premise that
prepaid billing is per-org. That premise is false. A person in the shared signup
org holds their OWN account: its members are strangers, not a team, and a shared
org is not a shared wallet. That is what IAM's signed billing_account claim states
and what ai's meter debits.

So the gate authorized against a balance nobody drained. Fund the pool and a
signup-org person still 402s, because their usage comes out of their own account;
fund the person and an empty pool blocks them anyway. Two layers, two answers, one
request.

Resolve through hanzoai/account.Payer — the same function ai debits with, on the
same credential — so the gate and the debit cannot name different accounts. The
premise is removed rather than restated.

The masquerade split is preserved by construction, not by care: the account is
resolved WITHIN the home org, so Account.Org IS the home org and a SuperAdmin
acting in another org still bills their own ledger. A claim naming a foreign
ledger is refused, so it cannot redirect a debit into the org being acted on.

Tests assert against the rule rather than a constant, so they cannot drift the way
the premise did: the signup-org person keys their own account, a real org pools,
the claim wins for a person and a project, and every case is checked equal to what
the debit computes.
2026-07-16 14:42:34 -07:00
hanzo-dev c563d8a4a6 merge: iam2 embed subsystem — CLOUD_IAM_IMPL selects beego (default) or iam2
clients/iam2 mounts the clean-room zip+orm IAM at /v1/iam when CLOUD_IAM_IMPL=iam2,
else beego, byte-for-byte unchanged. Flag OFF by default → inert. iam2 v0.1.1 seam.
2026-07-16 14:38:03 -07:00
hanzo-dev 724d011417 feat(iam2): select /v1/iam impl via CLOUD_IAM_IMPL (default beego, unchanged)
apps.Wire's identity slot now calls identitySpec(): CLOUD_IAM_IMPL=iam2 mounts the clean-room iam2 (zip+orm), anything else — including unset, the production default — keeps the legacy beego Casdoor embed byte-for-byte. The two impls own the SAME absolute prefixes (/v1/iam/*, /login/oauth/*) and cannot co-mount, so exactly one occupies the slot per boot and mount order is preserved. Off by default => completely inert until a canary flips the flag; selection (this) stays orthogonal to activation (cfg.Enabled).
2026-07-16 14:32:15 -07:00
hanzo-dev 53e22f0875 feat(iam2): clean-room IAM v2 embed subsystem (zip+orm, beego-free)
clients/iam2 folds the beego-free Hanzo IAM v2 into the unified cloud binary as an in-process identity plane — the either/or twin of clients/iam. Matches the cloud.Typed contract func(*zip.App, cloud.Deps) error: cloud hands subsystems a cloud.Deps (not an orm.DB), so Mount opens its OWN embedded SQLite ({DataDir}/iam2/iam.db, mirroring the beego embed's {DataDir}/iam layout), seeds config new-only+idempotent from the SAME init_data.json the beego iam uses (non-fatal, honest degrade), then iam2server.Mount registers the whole surface at the canonical absolute paths.

Fail-closed like clients/iam: a store-open or mount failure serves 503 on the identity prefixes while every co-resident subsystem stays up; iam2server.Mount's only panic path (a registered enterprise feature) is recovered in safeMount so it never crashes the shared binary. A TODO marks where the parallel-lane hanzoiam/{scim,saml,ldap} feature.Register lines land.

Pins github.com/hanzoai/iam2 v0.1.1 (seam held stable across the parallel internals refactor); transitive MVS bumps are all patch-level within v1.x (zip 1.8.3, orm promoted to direct, luxfi/crypto 1.20.1, argon2id 1.0.0, pgx 5.9.2). Inert until wired — see the apps.Wire gating follow-up.
2026-07-16 14:32:06 -07:00
hanzo-dev 7b63b49324 fix(ci): bound the SECOND version scan too (compute step) — same unbounded --paginate
The compute-next-version step had the same full-registry --paginate as the tag
step (fixed in f0abd21). It ran first, so it could hang before the build. Bound
it to one page too. Both version scans are now O(1 page), not O(registry).
2026-07-16 14:28:00 -07:00
hanzo-dev 0494a6d3c7 feat(zen): warehouse + gen_ai span emission with exact margin — ai v1.814.0
zen's commerce Meter now also calls ai's TraceServedUsage (recordTrace
WITHOUT recordUsage — the commerce debit stays the ONE billing source,
never doubled), carrying zen's exact per-tier retail (Charge) and upstream
COGS (Cost) folded atto→nano, so zen* traffic in the unified binary lands
in hanzo.cloud_usage + the o11y span plane with TRUE margin instead of
being warehouse-blind. Rides the ai v1.813.1→v1.814.0 bump; balance.go
(+ its drift-guard test) migrated to the renamed Payer/PayerOf API —
same subjects, one rule.

Claude-Session: https://claude.ai/code/session_018PmFAHZvbBSTsuWyebwMra
2026-07-16 14:26:17 -07:00
zandGitHub 27cc091884 Merge pull request #315 from hanzoai/cloud-zen-family-events
feat(zen): embedded-zen meter writes the family RoutingEvent (last link) + ai v1.813.5/zen v1.3.11
2026-07-16 14:08:50 -07:00
hanzo-dev 64b0d1f8a1 feat(zen): embedded-zen meter writes the family RoutingEvent (the last link)
The embedded zen mount serves the zen catalog in-process and never reaches ai's
pipeToFamily, so zen* calls produced ZERO routing events — starving stats, world,
spark retrain, and /v1/feedback joins. Wire cloud's zen Meter to ALSO write the
RoutingEvent through the ONE shared writer object.RecordFamilyRouting (source="family",
served arm = zen.Usage.Upstream, join key = zen.Usage.ResponseID — the client-visible
response id, new in zen v1.3.11 — tokens + retail cost), fire-and-forget beside the
existing debit. Bumps zen v1.3.7 → v1.3.11 (Usage.ResponseID); ai already v1.813.6
carries object.RecordFamilyRouting.

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw
2026-07-16 14:08:28 -07:00
antje 54b8bae797 git: Gitea push-webhook ingest (POST /v1/git/webhook)
The external Hanzo Git server (Gitea fork, git.hanzo.ai) POSTs push events
here so a push landing on it drives the SAME push-to-deploy core the embedded
smart-HTTP receive-pack path drives: fireBranchBuild -> cloud.OnGitPush deploy
trigger + EmitLifecycle. One code path, no duplication.

HMAC auth: X-Gitea-Signature is hex HMAC-SHA256 of the raw body, verified
constant-time against GIT_WEBHOOK_SECRET (KMS-synced hanzo/prod:/git/webhook-secret).
Fail-closed 401 on unset secret or mismatch. Only X-Gitea-Event: push acts;
others 204. Zero-SHA / non-branch refs are no-ops.
2026-07-16 12:05:08 -07:00
zandGitHub 5fb8f50e0a Merge decomplect/account-payer: migrate to ai.Payer — unbreaks main, ends the self-serve 402
main pinned ai v1.813.6 (which deletes billing_subject.go for the Payer/Account
refactor) without migrating the call sites, so main did not compile:
  clients/billing/balance.go:59: undefined: aiobject.BillingSubject

This migrates the call sites to the ONE rule: Payer(Credential)->Account
(owner = Person|Org|Project). Ends the live 402 where a self-serve signup's top-up
minted to the shared org pool 'hanzo' while the gate debited 'hanzo/alice' ($0) --
customer paid, locked out, money in a pool their org-mates could spend.

Green: go build -tags 'libsqlite3 sqlite_fts5' ./clients/... rc=0;
go test ./clients/billing ./clients/account rc=0. go.mod/go.sum identical to main.
2026-07-16 11:51:27 -07:00
hanzo-dev bd520de13f fix(billing): migrate off deleted BillingSubject → ai.Payer/PayerOf — unbreaks main
main pins ai v1.813.6, which contains the Payer/Account refactor (billing_subject.go
and its PERSONAL_BILLING_ORGS/ORG_BILLING_ORGS lying default are deleted). But the
call sites were never migrated, so main does not compile:
  clients/billing/balance.go:59: undefined: aiobject.BillingSubject
  clients/billing/balance.go:61: undefined: aiobject.BillingSubjectFromUserKey

Migrates balance.go + finance.go + billing.go to the ONE rule —
Payer(Credential{Owner,Name}).Subject() / PayerOf(org,key).Subject() — and DRYs the
duplicate subject resolvers. This is the cloud half of the fix that ends the live
self-serve 402 (top-up minted to the shared org pool 'hanzo' while the gate debited
'hanzo/alice' = $0).

go.mod/go.sum untouched vs main. Green: go build -tags 'libsqlite3 sqlite_fts5'
./clients/... rc=0; go test ./clients/billing ./clients/account rc=0.
2026-07-16 11:50:51 -07:00
hanzo-dev 65e782a855 refactor(billing): route top-up + console subject through ai.Payer, one rule
The console top-up (clients/account/billing.go) and the finance read
(clients/billing/finance.go) each re-implemented the billing subject as "always
the org" — the reverted lineage's rule. Against ai's gate, which bills a signup
person per-person, that is the split: money minted to subject "hanzo" (the pool)
while the gate debited "hanzo/alice" ($0) → the paid-up member 402'd.

Delete both twins; resolve the subject through the ONE rule, ai/object.Payer,
keyed on the IAM username (X-User-Name) the gate also keys on. Top-up credits and
console reads now land on the SAME account the gate debits — they cannot drift
because there is one function. The killed PERSONAL_BILLING_ORGS / ORG_BILLING_ORGS
env is inert here too (test proves hostile values change nothing).

Requires ai v1.809.5, which must be tagged FROM ai main after decomplect/account-payer
merges — NOT off a branch. The prior one-rule fix was tagged off an unmerged branch
(v1.806.8/.9), main never got it, and every later tag resurrected the allowlists;
that is why this bug is live. go.sum refreshes via `go mod tidy` once the tag exists.
Verified locally via a replace to the ai branch: clients build + twin tests green.
2026-07-16 11:47:10 -07:00
antje 74d94d1f3e ci(release): retire notify-universe — Hanzo CD owns git→cluster sync
The repository_dispatch deploy hub is gone (universe image-update.yml
removed in universe d07cf945; the dispatches were silently suppressed by
the flagged sender account regardless). Deploys are declared-tag bumps in
universe crs/, synced by Hanzo CD (ArgoCD, ns hanzo-cd) and reconciled by
the operator. [skip ci]
2026-07-16 11:36:10 -07:00
antjeandGitHub ce6c4dc374 deps: hanzoai/ai v1.813.1 -> v1.813.6 (balance-exempt routing config + enso family helper) (#314)
ai#102: /v1/get-routing-defaults + org-settings CRUD + routing-ledger
export are configuration metadata, never wallet-gated — unblocks reading
org routing defaults for $0-balance orgs and the operator platform flip.
Edge auto-routing billing tests green.
2026-07-16 11:28:33 -07:00
antje 2134876826 platform: allow the self-hosted fleet registry in the native build lane
registry.hanzo.ai/{hanzoai,luxfi,zooai}/ join the /v1/runner push allowlist —
Wave 0 of the native CI/CD migration. Until now only release.yml's crane
mirror could reach the fleet registry; the native BuildKit lane was
ghcr-only by policy.
2026-07-16 11:13:07 -07:00
hanzo-dev f0abd2116e fix(ci): version-assignment scanned the WHOLE registry — livelocked releases
The "atomic free-version assignment" step paginated every container version
(`gh api --paginate .../versions`) to find the max release number. As the
registry accumulated tags this grew unbounded and hung the step for 30+ min,
livelocking every cloud release. Container versions are created newest-first and
version tags are monotonic, so the max is always on the newest page — query one
bounded page (?per_page=100) instead of the full history. Fast + correct.
2026-07-16 11:11:39 -07:00
hanzo-dev ebf267908c refactor(ledger): drop redundant "core" — the ledger adapter is apps.ledger
Values, not places: the finance-backed credit-ledger adapter is qualified by its
namespace (apps.ledger), not a braided ledgercoreCredit compound. Rename the type
ledgercoreCredit → ledger, the file commerce_ledger.go → ledger.go, and scrub
"ledgercore" from prose (the ledger IS the core — "core" adds nothing). No behavior
change; admin/core tests green, changed packages build.
2026-07-16 10:18:47 -07:00
antje 4a64ef568b feat(chat): clients/chat — one /v1/chat tool-calling orchestrator
POST /v1/chat runs one LLM tool-calling round that lets a model manage a system
via tools. Composes existing cloud pieces, reinventing nothing:
- LLM routing + per-org reserve/settle billing: the ai subsystem's
  /v1/chat/completions, invoked in-process (Fiber Test) — the only path that
  both returns tool_calls AND carries the billing gate.
- tool plane (clients/tools): the org's registered MCP/registry tools are
  offered to the model and dispatched server-side (activation + price gated).
- capabilities: graph (advisory node-ops -> ops the client applies) and create
  (server-executed, tools = the org's registered MCP render services).
Returns {reply, actions, ops}. chat mounts before ai so /v1/chat resolves here
(the ai /v1/chat alias is shadowed); ai keeps /v1/chat/completions.
2026-07-16 10:02:29 -07:00
hanzo-devandantje 73632723f6 feat(ai): bump ai subsystem v1.813.0 → v1.813.1
Carries the PAID-Enso revert + the family learning loop: per-family-call RoutingEvents
+ shadow A/B (records what the learned engine would have picked), /v1/feedback signal
contract (up/down/regenerate/switch/abandon/accept/revert/rating/dismiss) with the
online reward forward to the engine's /route/observe, ROUTER_ADMIN_TOKEN service-auth
on the training-data exports, and Zen/Enso provider branding. Lights up /v1/router/stats
+ world.hanzo.ai (shadow-vs-served agreement).

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw
2026-07-16 09:53:56 -07:00
hanzo-dev f5d57e35f1 feat(ai): bump ai subsystem v1.812.0 → v1.813.0 — router policy, rewards, margin, DO backfill
Ships the per-org router policy (get/update-router-policy + the org > '*' >
conf fold on every auto route), the routing-reward ledger, the nano margin
ledger (costNano/billedNano/marginNano + unpriced flagging), and the
gaps-only DigitalOcean usage backfill (POST /v1/admin/usage/backfill-do,
dry-run default). The release image also re-embeds console@main
(CONSOLE_REF=main), picking up the console Router page (v8.4.137+).

Claude-Session: https://claude.ai/code/session_018PmFAHZvbBSTsuWyebwMra
2026-07-16 09:28:20 -07:00
hanzo-dev 8f61d57963 feat(credit): ONE ledger seam — commerce credit + admin grant mint into finance.Current()
The last mile of "one way to grant credit": cloud implements commerce's
creditledger.CreditLedger (v1.48.5) over the native finance ledger and injects it
at mountCommerce (EmbedConfig.Ledger). Now commerce's POST /v1/billing/credit AND
the admin.hanzo.ai grant (/v1/admin/customers/:org/credit) both mint into the SAME
per-org finance wallet the ai prepaid gate reads — a granted credit is immediately
spendable, no split ledger. The admin path drops its parallel finance.Deposit for
the one creditledger.Credit call (idempotency key rides through; commerce HTTP
deposit remains the split-deploy fallback).

- apps/commerce_ledger.go: ledgercoreCredit adapter (compile-time asserted against
  commerce's exported interface); org-pool wallet (Subject==Org); fails closed with
  no co-resident finance.
- apps/commerce.go: inject Ledger: ledgercoreCredit{} at mountCommerce.
- clients/admin/core/grant.go: grantDeposit prefers creditledger.Get() (the one
  ledger) over its own finance.Deposit.

Verified: changed pkgs build green on commerce v1.48.5 + ai v1.812.0; admin/core
grant tests pass. Ships with the ai free-tier removal (v1.811.0+) already on main.
2026-07-16 09:14:59 -07:00
antje 5bfe2842a1 ci(release): pass GIT_AUTH_TOKEN build secret — Dockerfile renamed the id from gh_token (46081689) but the workflow still passed gh_token, so the private console clone ran credentialless and the release failed 2026-07-16 01:50:00 -07:00
antje 4608168991 build: consume the standard GIT_AUTH_TOKEN build secret (was gh_token)
One secret id everywhere: BuildKit's gitsource convention (GIT_AUTH_TOKEN,
which the fabric's buildFrontendCmd already attaches) is also what the
Dockerfile's console-embed and go-mod fetch stages mount. gh_token was a
second name for the same credential.
2026-07-16 01:19:03 -07:00
antje a74c95abc2 deps: hanzoai/ai v1.812.0 + commerce v1.48.5 — reward ledger, router policy, connections import reach prod
ai v1.809.4 → v1.812.0: RoutingEvent reward ledger (/v1/add-routing-reward,
/v1/export-routing-rewards), per-org router policy + observability, Enso
public arms + retrain-status, connected-provider usage import (OpenAI/
Anthropic), own-brand god-view gate (TokenIsOwnBrand), Bearer-aware
get-cloud-usages, billing nano margin ledger, unpriced-model flagging.

commerce v1.48.2 → v1.48.5: mint-gated org-keyed credit primitive +
injected-ledger seam; fixes the phantom luxfi/cevm test import that broke
go mod tidy for every consumer (this repo included).
2026-07-16 01:16:12 -07:00
antje b7e4843cd7 feat(fabric): GIT_AUTH_TOKEN build secret — private-repo fetches for the build Jobs
The buildkit Job now surfaces the console-git-token Secret (optional) as the
GIT_AUTH_TOKEN env, and buildctl attaches it as the same-named build secret;
BuildKit's gitsource presents it as the HTTPS credential for the git context.
Fixes the fabric's inability to build PRIVATE repos (github.com/hanzoai/cloud
itself: 'could not read Username' — the rel_HvInGKU failure). Public repos and
Secret-less clusters fetch anonymously exactly as before.
2026-07-16 01:15:07 -07:00
antje 9b97ca9164 feat(git): public repos — anonymous read for the build fabric
A repo gains a visibility bit: PATCH /v1/git/repos/:name {"public":true}
(or create-time "public"). Public grants ANONYMOUS upload-pack/info-refs
only — receive-pack and the whole control plane stay org-authed, and a
private or missing repo answers the same uniform 404 so anonymous probing
cannot enumerate.

This is what lets the credential-less buildkit build Job (launchDirectBuild
git context) fetch from the embedded git server — the same reason public
GitHub repos build with no env. Private-repo builds remain a later
GIT_AUTH_TOKEN feature.

resolvePackRepo grows the allowPublic branch (fetch-side only): anonymous
callers address org-level repos by the orgRE-validated :org path segment;
the path-vs-identity guard for authenticated callers is unchanged.
2026-07-16 01:05:30 -07:00
antjeandGitHub 4b255e8e9a test(platform): end-to-end git push -> buildFromPush enqueue proof (#311)
Wire a REAL smart-HTTP git push through the actual production seam
(cloud.RegisterPushBuilder <-> cloud.OnGitPush) into the real platform
push builder, and assert the matching app's build is enqueued (a
building deployment for the pushed commit). The two halves were covered
in isolation (clients/git TestPushFiresBuildTrigger; platform
TestBuildFromPush_LaunchesMatchingApp) but nothing connected a live push
to the real builder end to end. Pure-Go dev build (CGO_ENABLED=0).
2026-07-16 00:45:07 -07:00
hanzo-dev 41be25c713 Merge land/slack-on-bridge: Slack chat path onto the generalized bridge — one chat code path 2026-07-16 00:15:57 -07:00
hanzo-dev 301706dd54 refactor(integrations): Slack chat path onto the generalized bridge — one chat code path 2026-07-16 00:13:59 -07:00
zandGitHub 746b42209f Merge feat/bots-control-plane: decomplect the bot plane, proxy instead of duplicate
Deletes clients/bot — a place-name that had collected three concerns (a mount, a
transport, and two unrelated domain wire protocols). One value per module:
  run       -> clients/bots      /v1/bots (no store)
  dispatch  -> clients/coding    /v1/coding-tasks
  transport -> clients/runtime   address, identity, framing, cleartext policy
  machine   -> clients/visor     /v1/compute/bots (moved off /v1/bots)
  session   -> clients/agents    the one registry (reverted to main)

Fixes a live route collision: visor's listBots silently won GET /v1/bots (the
router merges byte-identical patterns, first wins), so cloud served machine rows
as runs and orgs' real runs were invisible.

POST /v1/bots/run now returns 501 instead of charging $1.00 for a bot that never
booted -- the runtime has no launch endpoint. Stop fails closed: a bare 404 is
502, only a structured error body means already-stopped.

Net -613 lines. runtime never imports bots/coding; the reverse is a compile-error
cycle, so the one-way dependency is structurally enforced.
2026-07-15 23:27:48 -07:00
zandGitHub 58d39546b1 Merge fix/billing-balance-collision: read prepaid balance from the finance ledger, not a self-call
Cloud's /v1/billing/balance proxy was the only handler registered (commerce's
GetBalance is behind //go:build cloud, never compiled in) so it called itself,
depth 2. Reads the finance ledger via the existing finance.Current() seam and
single-sources the subject from aiobject.BillingSubject so console and gate
cannot drift.
2026-07-15 23:27:02 -07:00
zandGitHub a7fcf77247 Merge pull request #310 from hanzoai/bump/ai-v1.809.4
chore(ai): bump embedded ai v1.809.3 -> v1.809.4 (enso comp cold-cache fix)
2026-07-15 23:07:00 -07:00
hanzo-dev e324fdb236 chore(ai): bump embedded hanzoai/ai v1.809.3 -> v1.809.4 (enso comp cold-cache fix)
Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw
2026-07-15 23:06:41 -07:00
hanzo-dev d678ba2f6b bots: proxy the runtime instead of copying its state; name the transport
Red found the substrate was wrong: cloud kept its own registry of runs. It minted
an id the runtime had never heard of, so /v1/bots listed runs that did not exist,
stop closed records for runs that were never started, and run charged $1.00 for a
bot that never booted — the runtime has no launch operation at all, and nothing
in run ever contacted it. The registry that exists is the runtime's own tenant
store; it is the only thing that knows whether a sandbox is alive.

So cloud owns policy and the runtime owns the run:
  - GET /v1/bots and POST /v1/bots/:runId/stop proxy the runtime, gated by the
    validated principal and org. The org is cloud's, never the client's, and the
    runtime keys every run under tenants/{org}/ — so a foreign run resolves under
    the caller's org, where it does not exist, and 404s.
  - POST /v1/bots/run returns 501. There is no launch operation to call, so the
    honest answer is that it is not implemented. It no longer charges.
  - The agents session plane is reverted verbatim: no surface column, no Agent
    filter, no SessionOpen, no in-process stop. It never should have carried a
    second copy of the runtime's state.

Absence is only meaningful from a callee that could have said otherwise, so the
transport separates ErrNotFound (the operation ANSWERED absent) from ErrNotServed
(no such operation). A runtime without the stop route reports absent for every
run; treating that as "already stopped" made a stop that cannot fail. It is 502.

Decomplect the transport: clients/bot was named for the host it dials and braided
three concerns — an ops face plus two unrelated domain protocols. It is now
clients/runtime, a domain-free transport (address, identity, framing, cleartext
policy, /v1/bot/* ops face) that must not import bots/coding. Each domain owns its
own wire stub (bots/wire.go, coding/task.go), so the ZAP swap per HIP-0106/0120 is
a seam swap. Wire spec bot -> runtime; cmd/bot -> cmd/runtime.

The duplicate-route guard was a no-op: the router MERGES byte-identical patterns
into one route with chained handlers, so counting entries never saw the collision
it guarded. It now asserts one handler per route, and a test proves it fires on
the original bug. Fiber's Test() defaults to a 1s wall-clock deadline, which made
the isolation guards flake under load; they now pass an explicit timeout.

Route table unchanged: /v1/bots (runs), /v1/compute/bots (machines), /v1/bot/*
(runtime ops).
2026-07-15 22:52:24 -07:00
hanzo-dev c24b601825 feat(git): JSON read/browse surface for the console repo-browser
The console repo-browser (hanzoai/console products/git) needs machine-readable
refs/tree/blob/commits/readme, but git only served those as HTML (ui.go) + the
repo-CRUD control plane. Add the JSON twin, reusing ONE set of go-git read
helpers so the HTML + JSON surfaces can never drift:

  GET /v1/git/repos/:name/refs                   → { branches, tags, default }
  GET /v1/git/repos/:name/tree?ref&path          → { entries:[{name,path,type,size,mode}] }
  GET /v1/git/repos/:name/blob?ref&path          → { path,size,encoding,content,binary,truncated }
  GET /v1/git/repos/:name/commits?ref&path&limit → { commits:[{sha,shortSha,message,author*,date}] }
  GET /v1/git/repos/:name/readme?ref             → { path, content, encoding }

- browse.go: the handlers + DTOs (mirror the console GitApi normalizers verbatim).
  ref+path ride as ?ref=&path= query params (the UI's own convention) so a slashed
  branch is unambiguous. Reuses org()/findRepo()/openGit()/resolveRef()/
  cleanTreePath(). Org-scoped (X-Org-Id); a repo outside the caller's org 404s.
  Distinct trailing segments — never shadow the :org/:repo smart-HTTP routes.
- ui.go: findReadme → readmeAt (returns filename+content); the ONE readme scan now
  backs both the HTML repo home and the JSON /readme (DRY, no duplication).
- browse_test.go: seeds a nested tree + README + binary file, asserts every endpoint
  + org isolation + honest 404/403 + empty-repo refs.

CGO_ENABLED=0 go test ./clients/git/ green (full package); go vet + build clean.
Stays v1.x.x (Go module). Ships with console@main on the next cloud release.
2026-07-15 22:23:09 -07:00
hanzo-dev 3d2b7c87b5 fix(billing): read the prepaid balance from the finance ledger, not a self-call
GET /v1/billing/balance and /v1/finance/balance proxied to commerce at the SAME
path they are registered on. commerceinproc publishes the shared zip app and
re-dispatches by path, and commerce's own /v1/billing/* routes are never
registered in this binary (api.Route runs only from commerce's mount.go, which
is //go:build cloud; cloud ships -tags "libsqlite3 sqlite_fts5"). So the proxy
re-entered itself, hit its own sign-in gate with no principal, and reported
"billing upstream status 500".

Co-resident, the prepaid wallet is cloud's own finance ledger: wireFinance points
the ai gate's balance read at it, the edge meter debits it (metering.fetchAvailable
already resolves finance.Current() first), and an admin grant credits it
(core.grantDeposit already prefers it). Read it directly through that same seam.
The commerce S2S read stays as the split-deploy fallback.

The subject comes from ai/object.BillingSubject — the function the ai prepaid gate
itself resolves — instead of a re-implemented copy. cloud and ai each keeping their
own copy of that rule is what let them drift: the console scoped to the org while
the gate scoped to "org/user", so the view showed a funded org while the gate
refused the member.

Fail posture unchanged: a balance that cannot be read is UNKNOWN and surfaces as
502 — never rendered as a zero balance. The sign-in gate and org scoping are
untouched; the org still comes from the validated principal only.

Tests: self-dispatch pinned at depth 2 (the seam's real mechanics, which the
existing SetHandler-stub test cannot see); router semantics probed (byte-identical
patterns MERGE and silently shadow; equal-specificity param-name conflicts PANIC at
registration; most-specific wins over registration order); balance regression,
gate-subject parity, unreadable-is-not-zero, sign-in gate, and cross-org isolation.
2026-07-15 22:16:46 -07:00
antje 7dd7a9b4dd feat(insights): /v1/insights — the unified native surface on the ONE analytics engine
A wire adapter, not a second pipeline: POST /v1/insights/e accepts PostHog-
shaped payloads (single or {batch}) from @hanzo/insights and any compatible
SDK, maps $-properties onto the native CaptureEvent, and rides the SAME
capture path (tenant gate -> normalize -> scrub -> hanzo.events). GET
/v1/insights/events is the console's tenant-scoped recent-events read; GET
/v1/insights/health reports the surface. Flags deliberately stay at /v1/flags.

Scale path stays stateless: accept on any replica, pooled batch INSERT sink;
the queue-buffered (mq/pubsub -> Datastore consumer) upgrade swaps the exec
behind buildEventsInsert with no handler changes.
2026-07-15 21:35:30 -07:00
zandGitHub 8d719c9821 Merge pull request #309 from hanzoai/bump/ai-v1.809.3
chore(ai): bump embedded ai v1.809.2 -> v1.809.3 (comped preview balance fix)
2026-07-15 21:30:55 -07:00
hanzo-dev f58f0b7c2a chore(ai): bump embedded hanzoai/ai v1.809.2 -> v1.809.3 (comped preview bypasses enforceBalanceGate)
Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw
2026-07-15 21:30:09 -07:00
hanzo-dev 94ec6ecbb9 Merge land/hybrid-connectors: injective tenant S3 key (close cross-tenant collision) + orgPathSafe guard 2026-07-15 21:21:56 -07:00
hanzo-dev f7fd44618c fix(projects): injective tenant S3 key via verbatim principal.Org (close cross-tenant collision) + orgPathSafe traversal guard 2026-07-15 21:21:25 -07:00
hanzo-dev 3ddbe91e63 bots: state the list cap explicitly
The published contract has no pagination, so the list is capped either way; set
it at the store maximum here rather than inherit the store's 100-row default.
2026-07-15 20:52:36 -07:00
hanzo-dev 8360315345 bots: give the three "bot" values one namespace each; make the run control plane native
GET /v1/bots was registered twice: clients/visor (bot machines) and clients/bots
(bot runs). The router resolves byte-identical patterns by first-registration
without panicking, and visor mounts first, so visor's machine list answered the
console's run list and clients/bots.list was unreachable. The console normalized
machine rows into run rows, yielding one blank-runId row per kind=bot machine
with a dead sessionUrl, and hid the org's real runs.

Name the values apart, one home and one route namespace each:
  - bot run     -> clients/bots  /v1/bots            (unchanged; the console + CLI contract)
  - bot machine -> clients/visor /v1/compute/bots    (moved; a machine is compute)
  - bot runtime -> clients/bot   /v1/bot/*           (passthrough for runtime-owned ops)

Make the run control plane native. clients/bots holds no store: a run is recorded
on the agents session plane under agent label "bot", so the run id is the session
id and one registry serves every kind of agent work. list reads it org-scoped;
stop resolves (org, runId) against that record and drives the runtime only after
ownership is proven, so a run of another tenant is a 404 the runtime never hears
about. An unreachable runtime is a 502 with the record left live.

Two seams (Runs, Runtime) are injected in adapters.go, the only file in
clients/bots importing agents/bot, so handlers unit-test against fakes.

agents: sessions carry a surface (the modality a session runs on, matching the
runtime's own origin.surface) via the established additive-column migration;
SessionFilter gains Agent so a product face reads only its own sessions;
OpenSession takes SessionOpen; StopSession is the single-session twin of
StopSessions and shares its one write path (stopOne).

Contract change: a run id is now the session id, not bot_<hex>. No bot_ id was
durable anywhere -- the old run handler minted an id and stored it nowhere -- so
there is nothing to migrate. Ids stay opaque to clients.
2026-07-15 20:38:54 -07:00
antje 6781eed94f cli/gpu: heartbeat the claimed activity + fleet presence DURING a render (goroutine in claimAndRun) — a >120s render no longer drops the machine offline mid-render; fixes BYO-GPU fix/compose/render hitting the deadline + going offline 2026-07-15 20:21:31 -07:00
zandGitHub cd082ca1cb Merge pull request #308 from hanzoai/bump/ai-v1.809.2
chore(ai): bump embedded hanzoai/ai v1.809.1 -> v1.809.2 (Enso limited-preview gating)
2026-07-15 19:19:07 -07:00
hanzo-dev 6c7a89f8d9 chore(ai): bump embedded hanzoai/ai v1.809.1 -> v1.809.2 (Enso limited-preview gating)
Lands the Enso limited-preview gating into the live api.hanzo.ai binary
(cloud embeds ai in-process via ai.Mount). v1.809.2 adds ai's Enso family
routing (ENSO_URL -> enso pod) + waitlist gating: ModelAccess visibility in
/v1/models, 403 request-access for ungated SKUs, comped bypass of the balance
gate for granted callers, POST /v1/models/:model/access, org `hanzo` seed.

Independent of the concurrent zen v1.3.0 -> v1.3.7 bump (#307), which this
branch is based on top of.

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw
2026-07-15 19:16:00 -07:00
hanzo-dev b5b1a09952 Merge land/chat-connectors: generalized ChatBridge + Discord/Teams/Telegram adapters for hanzo.chat 2026-07-15 19:11:41 -07:00
hanzo-dev 767e089410 land(chat): generalized ChatBridge + Discord/Teams/Telegram adapters for hanzo.chat 2026-07-15 19:09:52 -07:00
zandGitHub 5cbf1ed90e Merge pull request #307 from hanzoai/fix/zen-bump-clean
fix(zen): bump embedded hanzoai/zen v1.3.0 → v1.3.7 (1M ladder fix)
2026-07-15 19:04:13 -07:00
hanzo-dev 9d8f86d191 fix(zen): bump embedded hanzoai/zen v1.3.0 -> v1.3.7 (1M ladder need() fix)
The cloud binary serves the zen* family IN-PROCESS via zen.Mount, so the
embedded module version — not the zen pod image — is what serves zen5. v1.3.0
sized the ladder rung by the byte estimate alone: a ~230K-token prompt with a
32K max_tokens budget estimated ~258K (under glm-5.2's 262144 cap), stayed on
glm-5.2, which then saw prompt+output = 262145 and 400'd. v1.3.7 sizes the rung
by need() = messages + tool schema + max_tokens, overflowing a >262144 total to
the 1M deepseek-v4-pro rung. Brings the Enso family + gating in-binary too.

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw
2026-07-15 19:03:37 -07:00
antje f49a6ac2b2 fix(image): runtime needs libgcc — the flags staticlib references _Unwind_*
The hanzo-flags Rust staticlib compiles with unwinding (panic-guarded FFI);
its _Unwind_* references resolve from libgcc_s, which the alpine runtime did
not carry — /cloud failed relocation at exec ('Error relocating /cloud:
_Unwind_GetIP: symbol not found') and smoke red-gated the release. Add libgcc
to the runtime apk set.
2026-07-15 18:47:52 -07:00
hanzo-dev 92004893be Merge land/agent-deploy-sites: agent AI-brief site build+deploy to <slug>.hanzo.app, metered per deploy 2026-07-15 18:41:36 -07:00
hanzo-dev 3d294ce07c land(sites): agent AI-brief site build+deploy to <slug>.hanzo.app, metered per deploy 2026-07-15 18:40:05 -07:00
antje 1b552d890c feat(flags): native flag engine — stateless Rust FFI + SQLite per project, /v1/flags
native/flags: hanzo-flags, a stateless Rust staticlib with PostHog-compatible
evaluation — the exact Insights rollout hash (sha1 first-15-hex / LONG_SCALE,
pinned by test vectors), the full vendored property-operator set (exact/regex/
semver/date/relative-date...), condition groups (variant-override groups first),
multivariate cumulative selection, payloads. Pure (defs JSON, ctx JSON) ->
response JSON behind a panic-guarded C ABI: hanzo_flags_evaluate/_free.
65 tests green.

clients/featureflags becomes the NATIVE engine (no external evaluator, no KV,
no network): definitions in per-(org, project) SQLite via cloud.OrgDB
(encrypted at rest via cek), hot in-memory platform snapshot (TTL 15s),
evaluation over cgo. The INSIGHTS_FLAGS_URL HTTP proxy is gone. Bool/Int/
String and the admin Board keep their shapes; sources are flags -> env ->
default (first cockpit write creates the definition — env fallback intact
until then, zero regression). engine_stub (!cgo) degrades loudly, fail-safe.

/v1/flags (org-scoped via principal, project via X-Project-Id): POST /v1/flags
(+/decide alias) evaluate; GET/PUT/DELETE /v1/flags/defs[/:key]; GET
/v1/flags/activity; GET /v1/flags/health. PUT /v1/admin/flags/:key (SuperAdmin)
is the cockpit write path through SetPlatformSwitch — flips apply immediately
in-pod, peers converge within one TTL.

Build: Dockerfile flagslib stage (rust:1-alpine musl staticlib, --locked) copied
to the exact ${SRCDIR}-relative cgo link path; make native; hanzo.yml
native-flags step + clients/featureflags in the hermetic unit gate.
go test ./clients/featureflags ./apps green (FFI live).
2026-07-15 18:38:01 -07:00
zeekayandClaude Opus 4.8 23d9c4809a fix(console): white-label the embedded console <title> by request Host
The console SPA shipped in the unified cloud binary is a STATIC export of
hanzoai/console whose <title> is baked to the default (Hanzo) brand at build
time. The embed cannot read the request Host, so every host — including
console.lux.cloud and console.zoo.cloud — served "Hanzo Cloud Console" in the
browser tab, a white-label violation. (The standalone Next.js app is host-aware
via generateMetadata, but it is retired from this serving path: cloud serves
the console in-process from the go:embed static export.)

Rewrite the SPA shell's <title> at the serving layer (serveIndex/indexFor) to
the request host's brand, reusing the existing brands registry (BrandForHost):
console.lux.cloud -> "Lux Cloud Console", console.hanzo.ai -> "Hanzo Cloud
Console" (unchanged), console.zoo.cloud -> "Zoo Cloud Console". Matches
hanzoai/console's own `${brandName} Console` output — one source of truth. The
Hanzo/default host returns the embedded bytes unchanged (no regression); a shell
with no <title> is never altered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:25:22 -07:00
hanzo-dev 9c2eb2cf64 Merge land/featuregate: per-service waitlist-mode control plane 2026-07-15 18:10:27 -07:00
antje 0414fe2975 deps: bump ai v1.808.1 -> v1.809.1
Brings live: get-cloud-usages Bearer + balance-exempt (usage panel works at
$0), the /v1/ai/connections usage-import endpoint. Console changes ride the
same release via CONSOLE_REF=main embed. Build green.
2026-07-15 17:38:07 -07:00
hanzo-dev 6529cf7d3e land(featuregate): per-service waitlist-mode control plane
Global SQLite registry {service, hosts, waitlistMode} + admin board/toggle
(/v1/admin/services*, /v1/featuregate/mode) + native Enforce middleware and
IAM-backed per-user approval resolver. Ported from recover/featuregate; dropped
the init()/cloud.RegisterWithShutdown self-registration in favor of the explicit
apps.Wire() MountSpec (order after admin, before tasks) + frozen wire_test row.

Distinct from clients/featureflags (a global read-only Insights waitlist_open
switch); this is the per-host launch lever with in-binary enforcement.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-15 13:20:25 -07:00
z 33072f27b3 platform: guard IAM object-store against a nil engine + commerce v1.48.2
Two co-residence fixes surfaced by the live founder-journey e2e:

1. /v1/platform/projects (authed) returned 500 'runtime error: invalid memory
   address' — iamobj.GetProjects/AddProject deref the package-global ormer.Engine,
   nil until the co-resident IAM subsystem initializes it. A nil store must fail
   cleanly: the iamStore() guard converts a nil-deref into a typed 503. (The store
   is initialized once iam co-mounts — v1.801.21 embedded-SQLite isolation.)
2. Bump commerce v1.48.1 -> v1.48.2: ErrorHandlerJSON preserves a downstream
   typed HTTP status, so a co-resident subsystem's 401/403/404 (e.g. platform's
   'X-Org-Id required') is no longer flattened to 500 by commerce's /v1 mount.

Regression tests: nil-store -> 503, success + real-error passthrough.
2026-07-15 13:11:54 -07:00
hanzo-dev ea3ccdbccd controlplane seam (c): R1 — first-writer-wins registry + self-authorized Rekey (RED R1)
The ValidatorRegistry is the trusted key source for the whole cert, so key binding must be rogue-key-resistant BEFORE the driver sources idVerifier into the cert verifier. Register is now first-writer-wins: a node already bound to one ML-DSA key may not be silently re-bound to a DIFFERENT key (ErrIdentityKeyConflict); idempotent re-register of the identical key stays allowed. The ONLY sanctioned rotation is Rekey(node, newPub, authSig) — self-authorized by a signature under the node's CURRENT key over rekeyTBS under a DISTINCT rekey context (not popContext/certContext); an attacker without the current secret key cannot rotate. This is the registry action a consensus-ordered OpRekeyValidator applies (op plumbing lands with the RSM rewire).

5 R1 tests green; existing OnePodOneShare + full suite unaffected.
2026-07-15 12:41:21 -07:00
hanzo-dev 8417cba676 controlplane seam (c): two-threshold self-guard + raw-craft verify tests (RED findings 1,3)
Finding 1: guardBFTFloor(len(keys), quorumWeight) fails closed unless the floor is the byzantine-safe BFT quorum (2n/3+1) for the validator-set size — so a mis-wired wallet-custody t (=3 for n=5) can neither compose nor verify a cert even if a future driver passes it. Called at the top of ComposeControlPlaneCert and VerifyControlPlaneCert. This is the exact two-threshold trap inc-1 hit (cluster.go wiring Pulsar threshold = quorum), now impossible in the cert core.

Finding 3: three raw-craft verify-side tests drive hand-built malicious certs (bypassing the honest composer) straight at VerifyControlPlaneCert — sub-quorum threshold-lie (ErrQCThresholdBelowFloor), attacker-key stuffing (ErrQCMerkleInclusion), weight inflation (ErrQCAggregateWeight) — plus a self-guard test (t=3 refused). 14 cert tests green.

Flag still false; ceremony untouched; existing byzantine suite green.
2026-07-15 12:41:21 -07:00
hanzo-dev 493d4ef01a controlplane inc-2 seam (c): real independent-sig ConsensusCert crypto core
The shipped Gen-3 weighted-quorum cert the design chose over the blocked threshold-Pulsar path. Each pod signs the canonical quorum message INDEPENDENTLY with its seam-a ML-DSA-65 key under a DISTINCT cert context (RED R4: certContext != popContext); the cert is a quasar.ConsensusCert carrying one EvidenceWeightedSigSet leg (a WeightedQuorumCert of N independent FIPS-204 sigs + weighted-Merkle quorum). Verification is quasar.VerifyConsensusCert under a control-plane ConsensusCertPolicy that requires the weighted-sig-set PQ leg — the shipped, audited verifier; NEVER the structural QuasarCert.Verify. No DKG, no threshold aggregate, no unshipped luxfi/pulsar core: soundness rests only on stock FIPS-204 verify + the weighted-validator-set Merkle commitment. Composes against luxfi/consensus v1.35.32 (BuildWeightedValidatorSet / BuildWeightedQuorumCert / VerifyConsensusCert).

VerifyControlPlaneCert binds the cert to the caller's expected position before the cryptographic verify (VerifyConsensusCert pins validator-set+policy but not the caller's height/round/block).

10 standalone crypto tests green: real cert verifies under policy; below-quorum / missing-leg / forged-sig / rogue-signer / wrong-position / wrong-validator-set all REJECTED with exact typed errors; R4 proven (a popContext sig is rejected as a cert sig); deterministic composition. Self-contained (does not touch the ceremony); existing suite stays green. Flag NOT yet flipped — ceremony rewiring + the ProductionBCCSigningReady() flip + stub deletion follow.
2026-07-15 12:41:21 -07:00
hanzo-dev fb5a9024db deploy: rename the CD surface gitops -> /v1/deploy
The ArgoCD fork now lives at hanzoai/deploy (hanzoai/gitops redirects to it), so
the cloud CD control plane takes the matching single-word name: clients/gitops ->
clients/deploy, /v1/gitops/* -> /v1/deploy/*, and the subsystem name (Wire entry,
enablement key, health surface) is 'deploy'.

Same plane, same ArgoCD-grade fleet view over our own App CRs — applications,
resource tree, live manifest + diff, logs, sync, rollback.
2026-07-15 12:30:57 -07:00
antjeandGitHub fbf347d851 cloud(cmd): generate standalone connectorruntime binary
Completes the per-app-binary invariant: connectorruntime (HIP-0126) is in apps.Wire() but its generated cmd stub was dropped by a stale reconciliation merge. go generate ./apps restores it. cicd green.
2026-07-15 11:18:51 -07:00
hanzo-dev 3322f7377f Merge fix/iam-coresidence-db-isolation: isolate embedded IAM SQLite (unblock iam+ai) + CLOUD_ENABLE_STAGED
# Conflicts:
#	clients/iam/iam.go
2026-07-15 10:40:00 -07:00
hanzo-dev a389074817 Merge chore/regen-cmd-link: generate cmd/link standalone binary 2026-07-15 10:33:42 -07:00
antje 83d7e65e8f cloud(ci): fix test gate path ./subsystems/ -> ./apps/ (main was red)
The subsystems/ -> apps/ rename merge (345264d2) missed hanzo.yml's test
gate, which still ran `go test ./subsystems/` — a directory that no longer
exists — so every main CI run failed "./subsystems [setup failed]". Point
it at ./apps/ (the renamed composition root). Unbreaks the release gate.
2026-07-15 10:21:30 -07:00
hanzo-dev cb579a8a79 Merge origin/main (agents oversize-target fix) into branch integration 2026-07-15 10:19:28 -07:00
hanzo-dev 3662f6982d Merge fix/402-credential-ordering: prefer fresh login JWT over stored hk- key in hanzo code 2026-07-15 10:18:18 -07:00
hanzo-dev 8813dd68c5 Merge feat/connector-runtime-land: native in-process connector runtime (goja) 2026-07-15 10:17:08 -07:00
hanzo-dev a5f94b0ef2 fix(agents): reject an oversize target GPU array at the handler
Red LOW-2: body.Spec.GPUs was only truncated post-decode by Sanitize
(cap 32). register + PATCH now return 400 for a GPU array larger than the
cap instead of silently truncating, so an absurd payload is refused up
front and the client learns the bound. The 4MiB body limit already caps
the transient decode allocation; a normal-sized list still registers.
2026-07-15 10:14:47 -07:00
antje 935974d230 cloud(cmd): generate missing standalone link binary (go generate ./apps)
The link app (unified AI login-manager registry, /v1/links) landed in
apps.Wire() without its generated cmd stub. Regenerating reconciles the
per-app-binary invariant: every Wire() app builds standalone via
apps.ServeSingle AND mounts into the unified binary. Idempotent.
2026-07-15 10:02:13 -07:00
hanzo-dev 4d42d6719e Merge origin/main (agents run-target) into apps/ rename integration 2026-07-15 09:49:42 -07:00
hanzo-dev c093a93d34 feat(agents): carry machine capability + live metrics on a run-target
A linked computer now reports what it IS (os/arch/cpus/memory/gpus, "spec")
and what it is DOING now (loadavg/memory/gpu-util, "metrics") so mission-
control can show which machine an agent runs on and whether it can take more
work — without copying the fact onto every session.

- targetspec.go: Spec/Metrics/GPU value types, JSON column codecs, and a
  total Sanitize that bounds strings/counts/sizes and coerces floats finite,
  so a hostile client can neither bloat the row nor smuggle a NaN/Inf.
- agent_targets gains spec/metrics/metrics_at (crashloop-safe addColumns,
  PRAGMA-guarded, no new index); register + PATCH accept them; a metrics
  PATCH is a heartbeat and the server owns the staleness clock (a client
  can't forge At).
- register upserts by (org, host) so re-linking the same machine refreshes
  one target instead of piling up duplicates.

Tests: sanitize bounds, store round-trip, GetTargetByHost org-scope, HTTP
capability+heartbeat, upsert-by-host. Existing target tests stay green.
2026-07-15 09:45:44 -07:00
hanzo-dev 68eebac230 fix(apps): add link mount to frozen wire-order fixture (main was red)
The feat/link-registry merge (669fee5) added the `link` mount to Wire() —
{Name:"link", after agents} — but never updated the frozen mount-order fixture,
so TestWireOrderMatchesFrozen failed on main (84 specs vs 83 frozen). This merge
inherited that break; adding the missing frozen entry (link, no health, has
shutdown) at its Wire() position makes apps green again.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-15 09:43:55 -07:00
hanzo-dev 345264d2f5 Merge feat/per-app-cmd-binaries: subsystems/ -> apps/ rename + per-app cmd binaries
# Conflicts:
#	apps/wire_seams.go
2026-07-15 09:39:39 -07:00
hanzo-dev 888d265469 fix(link): scope login-out session stop to the revoking user
A link revoke tore down live sessions by matching only {org, host,
provider, account}. Those fields come from a link row the caller sets at
upsert, so any org member could stop another member's sessions by
registering a wildcard link (e.g. provider-only) and revoking it.

Make Actor mandatory in agents.SessionMatch and AND it into the query.
The link adapter derives it from the revoking user's subject
(agents.BillingActor), the single place that binds a stop to the caller's
own sessions, so a stop/count can only ever reach that user's sessions.
A match with no actor stops nothing (fail-closed).

Tests: agents.TestStopSessions_ActorScoped (wildcard, host-forge,
no-actor fail-closed, count scope, own-account teardown) and
link.TestRevokeCannotStopCoTenantSessions (full stack through the real
adapter, co-tenant survives). TestRevokeStopsSessions stays green.
2026-07-15 09:31:20 -07:00
hanzo-dev e4a80c7de6 Merge branch 'feat/link-registry' into _mergetmp 2026-07-15 03:32:21 -07:00
hanzo-dev 669fee5cba feat(link): unified AI login-manager registry (/v1/links)
The org+user-scoped registry of which provider accounts (Claude Max, ChatGPT
Plus, a Hanzo/api key) a developer has signed into, on which machines, with each
account's latest usage snapshot — the cross-machine view console renders and the
source the redundancy route policy reads.

- clients/link: the Link atom (no secret — metadata + usage snapshot only),
  per-org SQLite store (org+subject leading-bound, upsert-on-identity, revoke),
  the /v1/links surface, and a pure RoutePolicy (Plan) that orders a user's linked
  accounts for redundancy (two Claude Max, then the metered API backstop) carrying
  the billing mode per candidate. The store holds no metering client — a
  subscription's usage is metered for visibility only and never charges commerce.
- clients/agents: a session now carries the linked account it ran under
  (Provider/Account tag), and StopSessions/CountActiveSessions expose the
  in-process action a link revoke takes to stop the sessions under a revoked
  account/device. Backward-compatible session migration (addColumns).
- subsystems: mount link after agents so a revoke can stop its sessions.

Org+user fail-closed isolation, subscription-vs-api-key billing distinction,
and revoke-stops-sessions are all tested (build/vet/gofmt clean; race+CGO green).
2026-07-15 03:31:37 -07:00
hanzo-dev 0b69af3e62 chore(cloud): bump hanzoai/ai v1.808.0 → v1.808.1 (relay reasoning normalization)
Pulls the DeepSeek <think></think> strip into api.hanzo.ai: reasoning-inlining
upstreams (zen5-pro/zen5-flash → deepseek-*) no longer leak the </think> template
token into the visible answer via the Anthropic-translation path that `hanzo code`
uses. Also carries the hk-key 402 tenant-gate fix. Builds clean (server + hanzo CLI).

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-15 03:09:00 -07:00
zeekay 78d299e6b4 release: mirror via crane — IAM token realm rejects buildx's multi-scope request (crane single-scope proven E2E) 2026-07-15 02:59:17 -07:00
hanzo-dev f766c8aec1 feat(cloud): per-app standalone cmd binaries via apps.ServeSingle + generator
Each app now builds as its own standalone binary AND still mounts into the
unified cloud binary — one source of truth (apps.Wire()). Two pieces:

1. apps.ServeSingle(name) — the ONE way to run a single app standalone: validate
   the name against Wire(), then cloud.Serve(Wire(), []string{name}). It is the
   path cmd/hanzo's 'hanzo <name>' already uses; promoting it to apps (which
   already imports cloud, so no cycle) lets every cmd/<app> stub reuse it instead
   of re-implementing the dispatch. Adding an app in Wire() is still the one edit.

2. cmd/gen-app-cmds — a tiny generator (go:generate directive in apps/apps.go)
   that parses Wire()'s {Name: "..."} literals and writes one cmd/<app>/main.go
   stub per app (apps.ServeSingle("<app>")). Generated, not hand-maintained; a
   re-run is idempotent (writes only on content change). 80 unique apps today.

Verify: go build ./apps/... green; apps.ServeSingle added; go generate ./apps is
idempotent; go test ./apps/... green (TestWireOrderMatchesFrozen at 84 specs); a
sample of generated cmd stubs (kms/account/agents/platform/storage/audit/
analytics/ads/ingress/billing/o11y) build standalone green. The full ./cmd/...
build is heavy (80 binaries x the cloud tree) and needs CI parallelism; the
stubs themselves are sound.
2026-07-15 02:46:42 -07:00
zeekay 0a41f07085 Merge remote-tracking branch 'origin/main' into ci-finish 2026-07-15 02:43:03 -07:00
zeekay bb3eb01f5e ci: mirror uses direct REGISTRY_USER/PASSWORD secret (Free plan hides org KMS secrets from private repos); test gate skips the cek env-gated test 2026-07-15 02:42:50 -07:00
hanzo-dev cabbfa297a refactor(cloud): rename subsystems/ -> apps/ (the composition root)
The cloud binary mounts ~84 'subsystems' into one unified binary; they read as
apps, so the package follows. Purely internal: dir subsystems/ -> apps/, package
'subsystems' -> 'apps', the one file subsystems.go -> apps.go. Three import sites
updated (cmd/cloud, cmd/hanzo). Broken path/filename references in comments
(subsystems.Wire, subsystems/commerce.go, subsystems.go) -> apps.*; the package
doc + LLM.md/docs paths follow.

No external breakage: the package is same-module (never in go.mod), no external
module imports it. The frozen-order test TestWireOrderMatchesFrozen compares
MountSpec.Name strings, not the package name — passes unchanged at 84 specs.

Standalone via 'hanzo <name>' and per-app cmd binaries are unchanged by this
rename (D2 adds the cmd binaries on top).

Verify: go build ./... green; go vet ./apps/... ./cmd/... clean; go test ./apps/...
green; TestWireOrderMatchesFrozen green. cmd/cloud TestMountAllAndServeHealth
fails identically on clean main (needs CLOUD_KMS_MASTER_KEY_REF), not this change.
2026-07-15 02:31:45 -07:00
hanzo-dev 0964d4f4d8 fix(agents): build session target/host indexes after addColumns
migrateSessions created ix_sessions_org_target and ix_sessions_org_host in
the table DDL, which runs before addColumns adds target/host to a
pre-existing table. On a fresh DB the CREATE TABLE carries the columns so
the indexes build; booting over the prior release's agent_sessions table
the CREATE TABLE IF NOT EXISTS no-ops and the index references a
not-yet-added column ("no such column: target"), failing the release
migration smoke. Build the two indexes after addColumns so the columns
exist first on both the fresh and upgrade paths.

TestMigrateOverLegacySessionsTable locks it over the pre-target schema.
2026-07-15 02:24:57 -07:00
hanzo-dev bb9bcbf307 fix(cloud/code): prefer the live login JWT over the hk- key to unblock 402
hanzo code 402s ('a billable tenant is required') on a deployment without
IAM_MINT_CLIENT_*: codeToken() preferred the hk- API key, but an hk- key only
mints a billing principal when the server can resolve it (iamKeys.resolve is a
no-op without the mint credential) — so the request arrives anonymous and zen's
/v1/messages billing gate refuses it. The /v1/models catalog call is loosely
gated, so the launcher banner still printed, masking the cause.

Reorder codeToken to prefer a FRESH hanzo login JWT, which carries the caller's
owner/project/sub claims verbatim and mints a billing principal on EVERY
deployment, then fall back to the hk- key chain (still works on mint-credentialed
servers). HANZO_API_KEY stays the deliberate operator override at the top.

freshAccessToken() is a new expiry-guarded accessor (decomplected from
accessToken, which whoami keeps expiry-agnostic): an EXPIRED jwt must not win —
it would 401 a session a valid hk- key would serve — so it falls through to the
key. No expiry record (a raw HANZO_TOKEN) is trusted as-is.

Pinned by TestCodeTokenPrecedence (fresh jwt beats hk-, expired jwt falls
through, HANZO_API_KEY overrides all). Structural hanzoai/jwt extraction is a
follow-up PR.
2026-07-15 02:19:47 -07:00
hanzo-dev 9347a5671f git: make the mounted service pointer atomic (fix -race)
The lifecycle reactors (notify / mirror-out / index-on-push) read the package
'mounted' var in DETACHED EmitLifecycle goroutines while Mount/Shutdown write it;
a reactor goroutine outliving a Shutdown (or a test Mount<->Shutdown cycle) tore
against the write. Convert 'mounted' to atomic.Pointer[cloud.Service[state]] —
Store on Mount/Shutdown, Load in every reader (reactors, export CloneURL/VerifyRef,
index-on-push, the GitHub importer) + the tests. Behavior unchanged (production
sets mounted once); the data race the -race detector flagged is gone.
2026-07-15 02:19:15 -07:00
hanzo-dev a4cb8e9305 feat(cloud/connectorruntime): native in-process connector exec (HIP-0126)
Lands clients/connectorruntime — the native replacement for the standalone
ActivePieces Node engine: an ActivePieces JS connector action runs in goja
in-process (no auto pod, no in-cluster HTTP hop, no shared X-Piece-Run-Secret).
Same {action,auth,props} -> {ok,output,error} contract; org-gated via
principal.Org; the caller's credential travels in the request auth.

POST /v1/automations/connectors/:id/run  run one connector action in-process

Wired into the composition root (subsystems.Wire) the one way main does it —
an explicit MountSpec after automations (it pairs with the catalogue) — not the
old self-registering cloud.Register init (removed; main is explicit-Wire).
frozen order sequence updated to 84 specs.

Re-derived from the cloud-cr worktree's uncommitted work against current main:
the package's principal.Tenant -> principal.Org, the cloud.Register init -> the
explicit Wire() entry, and the go.mod esbuild dep flipped to direct. The
kb/sync_piece.go refactor that originally accompanied this is NOT carried —
clients/kb was removed on main since the branch forked, so there is nothing to
refactor; connectorruntime stands on its own (imports only cloud + principal).

Build green; go test ./clients/connectorruntime/... green (registry + runtime);
TestWireOrderMatchesFrozen green at 84 specs.
2026-07-15 02:13:38 -07:00
hanzo-dev c24364985a Merge feat/ai-obs-attribution: o11y trace attribution + annotation queues
Trace attribution, per-project metrics, sessions, and annotation-queue routes
on the o11y plane (clients/o11y annotation store + queues, clients/eval
attribution/telemetry, principal scope). Genuine new feature (not on main),
rebased clean onto current main; builds and race tests green.
2026-07-15 02:07:08 -07:00
hanzo-dev 3f31ac709d Merge feat/github-app-sync: GitHub App bidirectional repo mirror + sync
Install the Hanzo GitHub App -> list org repos -> import into git.hanzo.ai ->
bidirectional sync (outbound mirror already exists; inbound = HMAC-verified
webhook, fast-forward-ONLY, never force-overwrites native). Inert until the App
creds land in KMS.
2026-07-15 02:07:05 -07:00
hanzo-dev 663571f690 Merge fix/affiliates-topup: accrual tops up the open period (month-to-date converges); link clicks off the money-DB write path 2026-07-15 02:05:26 -07:00
hanzo-dev c29d96a70c fix(affiliates): accrual tops up the open period so month-to-date spend converges
The monthly accrual latched once on the FIRST sweep's PARTIAL month-to-date spend
and no-oped every later sweep, so an affiliate whose dashboard swept early in the
month froze its share near zero (underpaid, though platform-safe). Store.Accrue now
inserts the period row on first sweep and, for the still-open period, TOPS IT UP
toward the current (higher) month-to-date reading in one transaction — adding only
the positive delta, so accrued_cents converges to the month-end value, never
decreases, and never overshoots. The per-event money invariant is untouched: each
row keeps margin + share from ONE reading (share <= margin) and the level-schedule
cap keeps sum(share) <= margin at every step.

Public link clicks move off the money-DB write path: clickLink folds pings into an
in-memory coalescing buffer (bounded), flushed batched on the next links read and on
shutdown, so a click flood can never contend with the accrual/payout writes.

Tests: TestAccrualConverges + TestAccrualConvergesAtMaxRate prove the share tracks a
growing month-to-date and sum(share) <= margin at every intermediate sweep; existing
invariant/idempotency/isolation/links tests stay green (26 pass, -race).
2026-07-15 02:05:08 -07:00
hanzo-dev f36c99a109 code: index on push via durable hanzoai/tasks workflow
A push to a repo's default branch enqueues a durable IndexRepoWorkflow on
the embedded tasks engine (workflow id keyed by commit = idempotent per
push); a worker reads the tip tree from the object plane and folds its
text files into the org's code index, retried on failure. Fail-soft:
before the engine is wired the reactor indexes inline on its detached
lifecycle goroutine (the ai-ingest contract), so push-index is always live.

git and code never import each other — the reactor is git's third
lifecycle subscriber, the index reached through the SetIndexer func seam
wired once at the composition root.
2026-07-15 02:04:02 -07:00
hanzo-dev cabe44f5df feat(o11y): trace attribution, per-project metrics, sessions + annotation-queue routes
eval telemetry (Trace): add ProjectID, SessionID, APIKeyHash (SHA-256 ref, never
plaintext), and StartTime/EndTime latency; DDL columns + additive migrations +
write/read paths. A run stamps its project (principal.ProjectScope), groups its
item-traces under the run as one session, times the model call, and records a
non-reversible ref of the caller credential. Trace list narrows by the caller's
project (default == whole org).

evals/metrics: thread the server-minted project scope into MetricsFilter,
usageWhere (AND project = ?) and the latency span filter. The default-project
(whole-org) board queries the ledger today; a named-project board is honest-empty
until cloud_usage carries a project column (ai write path) — activation is one
guard flip, the query plumbing is project-aware and tested.

principal.ProjectScope: the ONE helper for "default project == "" == whole org";
eval + o11y both read it.

o11y: explicit org-gated GET /v1/o11y/sessions pinning the runtime /api/sessions
route; native annotation-queues surface (SQLite metastore, org+project scoped) at
/v1/o11y/annotation-queues* — list/create/detail/update/delete, items add/list/
complete — returning the console {data,meta} envelope.

Tests: trace attribution (latency, session grouping, hashed-not-plaintext key),
per-project + cross-org trace isolation, usageWhere project predicate, annotation
queue lifecycle + org/project isolation + validation + principal gate.
2026-07-15 02:03:55 -07:00
hanzo-dev d5c3688eef GitHub App: bidirectional repo mirror + sync (git.hanzo.ai)
integrations (github.go/github_app.go/github_webhook.go): App install ->
installation-token mint (ghinstallation), list granted repos, background import,
inbound push webhook (HMAC-verified, fast-forward-only). Inert until
GITHUB_APP_{ID,PRIVATE_KEY,WEBHOOK_SECRET,SLUG} land.

git (github_import.go + cloud.GitImporter seam in git_import.go): import =
fast-forward mirror-in per branch; inbound = fast-forward-ONLY advance where
native is canonical, so a divergence is a recorded conflict and never a
force-overwrite; loop-prevention via the Origin stamp; per-repo status. Outbound
mirror uses the org installation token for github.com targets, shared-token
fallback otherwise (no regression).
2026-07-15 02:02:49 -07:00
hanzo-dev 7f2584cd4e code: add tree + file endpoints (zread contract over the index)
GET /v1/code/tree?repo=  → get_repo_structure: the repo's files + per-file
  symbol counts, ordered, per-org isolated.
GET /v1/code/file?repo=&path= → read_file: the INDEXED content (the symbol
  chunks the search tiers hold), for fast context. NOT byte-verbatim — inter-
  symbol lines a parser did not chunk are absent; the S3-backed git object
  plane (clients/git) is the source of record for exact bytes, history, and
  blame. Comments say so; a follow-up routes byte-fidelity read_file/blame at
  the git plane.
Store.tree + Store.fileContent read the existing files/symbols/chunks tiers —
no schema change. Test covers tree structure, indexed-file read, 404 on an
unindexed path, and per-org isolation (org B sees an empty tree).
2026-07-15 02:01:18 -07:00
hanzo-dev 4a3bd68917 Merge feat/hanzo-agent-keystone: @hanzo Slack coding agent (ships INERT)
The coding-agent code lands fail-closed and INERT: no sandbox runs until an
operator provisions docker and sets HANZO_CODING_* env. The coding sandbox
stays DISABLED until the default-deny-egress + container resource-limit
hardening lands and red re-clears. Do NOT provision docker / set HANZO_CODING_*
until then.

Native coding tasks from Slack over the shared object plane: clients/coding
dispatcher, in-process agents session bus, tracker agent PR, bot NDJSON client,
git export seam, slack coding trigger.
2026-07-15 01:57:17 -07:00
hanzo-dev 6ac6ece3bf Merge feat/mission-control-sessions: agent session execution-context + targets
Mission-control backend over /v1/agents/sessions: sessions carry host/cwd/
repo/target and a compact last-event; new /v1/agents/targets registry
(register/list/detail/patch/delete) with live session-load, org fail-closed,
in the same agents.db. Composes with the compute fleet at the view layer.
2026-07-15 01:49:51 -07:00
hanzo-dev b81a0fbdb7 agents: session execution-context + targets (mission-control)
Sessions gain host/cwd/repo/target so mission-control can show where a
session runs and map sessions to a machine; the list projection carries a
compact last-event preview. Add /v1/agents/targets — register/list/detail/
patch/delete a dispatch destination (laptop|cloud|gpu|cluster|machine) with
a live session-load rollup — in the same agents.db, one tenancy column, org
fail-closed. A session's target resolves same-org at register/patch (#48).

Composes with the compute fleet (/v1/fleet/workers, /v1/clusters) at the
view layer rather than duplicating it.
2026-07-15 01:48:51 -07:00
zeekay c94dba9794 release: fix step splice — buildx keeps its with: block 2026-07-14 23:38:51 -07:00
zeekay 14034b7b41 release: dual-host — mirror release tags to registry.hanzo.ai
Server-side copies alongside the ghcr retag; the cluster deploys from OUR
registry, ghcr stays the public identity. Best-effort by contract — a mirror
hiccup never blocks the tag-receipt.
2026-07-14 23:38:27 -07:00
zeekay f269f3156a Merge remote-tracking branch 'origin/main' into unfork-commerce 2026-07-14 23:19:49 -07:00
zeekay c6b31666d1 ci: canonical hanzo.yml test gate + hanzoai/ci caller on the arc pool
release.yml keeps sole ownership of the image + v* tags (tag-is-a-receipt);
this adds the vet/unit gate on every push/PR, on our own runners.
2026-07-14 23:19:38 -07:00
hanzo-dev 986dc9d58e fix(cli/code): give Claude Code its true 1M context on zen models
Claude Code sizes a model's context window only from ids it recognizes.
An unknown gateway id like `zen5-pro` gets a hardcoded 128K budget and is
rejected client-side above it ("maximum context length is 131072") — even
though api.hanzo.ai and the DeepSeek-V4 upstream serve the full 1M window
fine (verified live: 140K-token prompts return 200).

Bridge each CC tier through a recognized carrier id (claude-opus-4-8[1m]
etc.) that unlocks the 1M budget, then rewrite it back to the served zen
alias via settings.json modelOverrides before the request leaves the
client — the carrier never reaches the server, so real claude-* routes are
untouched and responses stay branded/served as zen. One source of truth
(zenTiers) drives the wire env, picker branding, and overrides. The fast
tier stays direct (never needs >128K; SMALL_FAST_MODEL isn't override-
rewritten, so a carrier there would leak raw).

Verified end-to-end with Claude Code v2.1.210: the wire model reaching
api.hanzo.ai is zen5 / zen5-pro carrying context-1m-2025-08-07, with zero
raw claude-* leaks.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 23:15:24 -07:00
zeekay 8477d02e37 merge main: /v1/store owner carried onto the NATIVE mount
The store surface registers natively (api/store.Route on a group-scoped chain
mirroring the standalone /v1 bundle) instead of re-adding the gin AdaptNetHTTP
prefix; commercePrefixes keeps /v1/store pinned so store reads can never fall
through to the AI /v1/* balance gate again.
2026-07-14 23:03:43 -07:00
zeekay f601ae8885 commerce v1.48.1 NATIVE co-residence — routes on the shared zip app, AdaptNetHTTP gone from the commerce path
- subsystems/commerce.go: commerce.Embed(App: app) — the SharedApp contract;
  commerce registers /v1/commerce/* + /_/commerce/* directly on cloud's router
  (one specificity space, no second engine, no net/http adaptation)
- live wire paths registered natively with commerce's own gates:
  /v1/billing/webhooks/:provider (HMAC-is-auth) and
  /v1/billing/auto-recharge/run-all (service-token + PlatformOnly) —
  commercePrefixes contract test pinned and passing
- commerceinproc.SetApp: S2S byte-stream enters the shared app's pipeline;
  RoundTrip normalizes client-style RequestURI at the ONE seam;
  entitlements + BalanceCents stay pure direct-Go (commerceclient)
- zip v1.8.2 (union tag: v1.8 features + the v1.7.4 chain-order fix + v1.7.5
  empty-leaf normalization — v1.8.1 was missing both)
- metering dispatch e2e ported to the zip harness and green
2026-07-14 23:01:39 -07:00
hanzo-dev 21bf5c8e43 fix(commerce): own /v1/store/* so store reads reach commerce, not the AI /v1/* balance gate
The bare storefront surface api.Route(Group("/v1")) registers on the commerce
gin engine — GET /v1/store/current (the org-scoped default store the admin
dashboard AND the content storefront edge resolve), the /v1/store/:id/listing
upsert the publish edge writes, and the public /v1/store/:store/listing reads
karma.style serves at runtime — was dropped from commercePrefixes by the unfork.
Unowned, /v1/store/* fell through to the bare /v1/* AI catch-all, whose prepaid
LLM balance gate 402'd every store read for an org funded in commerce but $0 in
the ai ledger (org karma: $999.99 credit, GET /v1/store/current -> 402
insufficient_balance, blocking store provisioning + the storefront image fan-out).

Restore the /v1/store prefix so /v1/store/* routes to the commerce handler, which
resolves the org from the gateway X-Org-Id (TokenRequired -> ensureIAMOrg) and
lazily provisions the org's store — standalone-commerced parity. A store-metadata
read never requires an LLM balance. Per-route permission masks are unchanged
(money paths stay publishedRequired); gin still 404s an unknown /v1/store/* path.

Guarded by TestStoreSurfaceRoutedToCommerceNotAIGate (store read resolves on
commerce, never 402 at the catch-all) + the extended commercePrefixes pin test.
2026-07-14 22:55:03 -07:00
hanzo-dev f9ec50bbc3 feat(social): optional media[] URLs on posts
Add an optional media field (JSON array of URLs) to the social Post store,
additive and idempotent exactly like the marketing scheduled_at fix:

- store: media TEXT NOT NULL DEFAULT '[]' column + addColumn upgrade for
  pre-existing prod DBs (migrate-on-open, the only upgrade path for the
  encrypted single-file store); JSON encode/decode helpers; Post.Media []string
  always serialized as [] never null.
- api: normMedia bounds each URL to maxField and the list to maxMedia (10),
  the one sanitization seam on create + update (mirrors content/channel).
- tests: media round-trip in TestPostCRUD (create sets, update replaces) and
  TestMigrateAddsMediaColumnToOldPosts — old-schema DB gains the column and a
  media write no longer 500s; legacy rows default to [].
2026-07-14 22:51:48 -07:00
hanzo-dev f36732c507 Merge feat/affiliate-profit-share-dashboard: OSS-dev affiliate profit-share + dashboard
Margin-based accrual (share = rate x Hanzo's margin, share <= margin invariant),
derived share ledger that never mutates the cost-of-record, shareable links with
click/signup/conversion, per-period earnings, opt-in privacy leaderboard, and the
SuperAdmin set-rate. Tenant-isolated, fail-closed.
2026-07-14 22:31:52 -07:00
hanzo-dev 6cb081127f affiliates: profit-share (margin-based accrual) + dashboard (links, earnings, leaderboard)
Switch the affiliate accrual from revenue-share (spend x rate) to PROFIT-share
(margin x rate), where margin = a referred org's gross spend x the platform
gross-margin fraction (AFFILIATE_MARGIN_BPS, default 40%). The share is a rate
OF Hanzo's margin, never the customer's bill, so a payout can never exceed the
margin earned. The L1 rate is capped (maxL1RateBps=9300) so the whole L1+L2+L3
schedule stays <= 100% of the margin: total share <= margin, always.

The derived share ledger (affiliate_accruals) now records the margin base
alongside the gross spend and the share; it never mutates the cost-of-record
(commerce/cloud_usage) - it is a pure projection keyed by referrer.

Dashboard surface (all org-scoped, fail-closed):
- GET  /v1/affiliates/me/earnings   per-period + per-direct-referral aggregate
- GET/POST /v1/affiliates/me/links  shareable links + click/signup/conversion
- POST /v1/affiliates/me/handle     opt-in leaderboard display name
- POST /v1/affiliates/click         public click ping (vanity counter)
- GET  /v1/affiliates/leaderboard   opt-in handles + aggregate + your own rank
- POST /v1/admin/affiliates/:id/rate SuperAdmin set L1 rate (capped)

Tests: margin invariant (share <= margin per level + summed; charge unchanged),
set-rate cap, links lifecycle, cross-affiliate isolation (earnings + links),
leaderboard privacy (no org identity leaks, own rank always visible).
2026-07-14 22:31:21 -07:00
hanzo-dev 19371b7a8f Merge fix/prod-headers-followups: brand-only Server fallback (zip v1.8.1) + trailing-dot brand resolution 2026-07-14 21:56:26 -07:00
hanzo-dev 3f8afeb5e9 fix(headers): brand-only Server fallback + trailing-dot brand resolution
Address Red's LOW-1/LOW-3 on the production-header posture:
- serve.go sets zip.Config.ServerHeader=cfg.Brand so the responses the
  ProductionHeaders middleware can't reach — the fasthttp transport's OWN
  pre-routing errors (431/400) — read Server: <brand> instead of the framework
  default. Requires zip>=v1.8.1 (transport now propagates ServerHeader). Repin
  v1.8.0->v1.8.1; go.mod diff is only the zip line.
- BrandForHostOK strips a trailing FQDN dot so api.lux.network. resolves to lux
  (fails safe to neutral before, never a wrong brand — brand-fidelity fix).
2026-07-14 21:56:15 -07:00
hanzo-dev 73ffedd136 deps(commerce): v1.47.2 -> v1.47.3 — tier gate counts granted credits as spendable (fixes 402 for grant-funded orgs)
Claude-Session: https://claude.ai/code/session_01Gq8suw7uuodAMPDRpo6iAB
2026-07-14 21:47:20 -07:00
hanzo-dev 72ffc555b7 Merge feat/prod-response-headers: inherit zip ProductionHeaders posture (brand-by-host Server, X-Api-Version, HSTS/nosniff) 2026-07-14 21:07:29 -07:00
hanzo-dev 16c6d300da feat: inherit zip ProductionHeaders — brand-by-host Server, X-Api-Version, HSTS/nosniff
Wire the shared production response-header posture (zip v1.8.0) into the edge,
right after RequestID so it covers every response — success, error, 404, and
the public-site static bytes:

- Server is the white-label brand of the request Host via BrandForHostOK (cloud's
  own registry), so a lux/zoo caller is never served "hanzo" and no response
  leaks the framework name. An unmatched Host falls back to this deployment's own
  brand (cfg.Brand), never a framework or hardcoded single brand.
- X-Api-Version carries the build version (Config.Version <- CLOUD_VERSION env,
  else the link-time cloud.Version default) under a brand-neutral key.
- HSTS + nosniff are the always-safe security floor; the console SPA keeps its
  own framing rules (no X-Frame-Options/CSP forced here).

Bumps zip v1.6.0 -> v1.8.0 for middleware.ProductionHeaders. X-Request-Id stays
owned by middleware.RequestID.
2026-07-14 21:07:18 -07:00
hanzo-dev 9db9c53d89 cli: auto-wire Hanzo MCP into hanzo code claude (port Rust resolve_mcp)
The Go hanzo code wired zero MCP — subagents got a bare model, no Hanzo tools.
Port the Rust CLI's resolve_mcp: resolve hanzo-mcp (installed → PATH, else uvx
hanzo-mcp), write an --mcp-config stdio document scoped to the cwd into the
isolated config dir, and pass --strict-mcp-config so the Hanzo server is the sole
MCP source (a repo .mcp.json is ignored — it can ship a bearer-exfiltrating stdio
server). A missing hanzo-mcp warns and continues (MCP is an enhancement, never a
blocker). So hanzo code claude now starts with the Hanzo tool lattice — code
search over the cloud index, web search, vision, fs/exec/git. Test covers the
opt-in, the config shape, and the not-found warn path.
2026-07-14 20:12:25 -07:00
hanzo-dev 2fa922250b code+knowledge: default embed model to zen-embedding SKU (fix semantic tier)
Both indexers defaulted CLOUD_EMBED_MODEL to bge-m3 (the raw upstream), which
the gateway rejects (400) — only the zen-embedding SKU is served. Result:
index-time embeds failed silently, semantic code + KB search returned nothing
(vectors:0, degraded:true). Default to the served SKU. Pairs with the zen
bge-m3->zen-embedding alias so both the name and the SKU resolve.
2026-07-14 20:04:19 -07:00
hanzo-dev 64b233dfe1 integrations: GitLab OAuth provider (login + repo sync, KMS-env creds)
Adds the gitlab provider on the same OAuth registry as slack/google/github.
Reads GITLAB_CLIENT_ID/GITLAB_CLIENT_SECRET from ENV (KMS-synced, never in code);
Configured() is false until both are present so it ships INERT and fails closed
(honest 503 / failure redirect, never a fake OK). Callback = the app's
/v1/integrations/gitlab/callback; the generic dispatcher seals the access+refresh
tokens into the org KMS namespace.

Least-privilege by construction: requests only openid/profile/email/read_api/
read_repository/write_repository — the token receives the intersection of
requested + app-allowed, so we never request api/sudo/admin_mode/k8s_proxy/
*_runner/*_registry even if the app was provisioned with them (tested).
GITLAB_URL supports self-hosted GitLab. 5 tests: registration, least-privilege
authorize URL, exchange seals both tokens + resolves account, missing-secret
fails honestly, error body surfaced.
2026-07-14 18:19:52 -07:00
hanzo-dev 3cdcf062c0 deps(commerce): v1.47.1 -> v1.47.2 — org-scoped /v1/store/current + lazy store provisioning
commerce v1.47.2 fixes GET /v1/store/current returning the phantom shared
"default" store: it now resolves the caller org's namespace and lazily,
idempotently provisions the org-scoped store (store.EnsureDefault) on first
authenticated hit — the store id the content storefront edge needs to publish
Listing.headerImage. Round-trip + cross-tenant tests ship in the module.

Claude-Session: https://claude.ai/code/session_01Gq8suw7uuodAMPDRpo6iAB
2026-07-14 17:59:51 -07:00
hanzo-dev 54657f5da1 fix(marketing): additive ALTER so old prod campaigns tables gain scheduled_at
POST/GET /v1/marketing/campaigns 500'd on prod with "table
marketing_campaigns has no column named scheduled_at": migrateCampaigns()
uses CREATE TABLE IF NOT EXISTS, which NEVER alters an existing table, so a
prod DB created before scheduled_at was added to the DDL was frozen at its
original schema and every campaign write (INSERT/UPDATE name it) 500'd. The
store is an encrypted single-file SQLite only the binary can open, so a
hand-patch is impossible — the upgrade MUST happen in migrate-on-open.

- migrateCampaigns: after the CREATE, run an idempotent additive column
  upgrade — ALTER TABLE marketing_campaigns ADD COLUMN scheduled_at
  INTEGER NOT NULL DEFAULT 0 — swallowing SQLite's "duplicate column name"
  (the only error) so it's a no-op on a fresh DB.
- addColumn helper mirrors clients/social/store.go exactly (the ONE way we
  do additive migrations), keyed by an extensible {table,col,def} list.

Tests (store_migrate_test.go, CGO_ENABLED=0 pure-Go cek): a from-OLD-schema DB
(marketing_campaigns without scheduled_at + a legacy row) opens, migrate ADDs
the column, a scheduled_at write succeeds, and the legacy row survives with
scheduled_at defaulted to 0; plus a fresh-DB idempotency re-open. Without the
ALTER the old-schema test reproduces the exact prod 500.

Claude-Session: https://claude.ai/code/session_01Gq8suw7uuodAMPDRpo6iAB
2026-07-14 17:56:36 -07:00
hanzo-dev ca78743549 fix(commerce): restore /v1/billing/auto-recharge prefix dropped by the unfork — pin test moves beside the canonical list
The unfork (6a071d2) rebuilt commercePrefixes in subsystems/commerce.go from a
pre-fix snapshot, dropping /v1/billing/auto-recharge (landed as #274/6dc3c6b)
and deleting its pin test with the old clients/commerce tree. Without the
prefix the durable cron's quarter-hour billing-autorecharge poke lands on the
account-bridge /v1/billing/* session gate and 403s — verified live before
6dc3c6b shipped in v1.801.1 (poke 200, 311 orgs swept, 23:45:05Z).

Same one-line-family fix, new canonical location; TestCommercePrefixesPinned
now lives beside the list it pins so a future rewrite can't silently regress
the wire path again.

Claude-Session: https://claude.ai/code/session_01XptqW83ZLpqyGBENc1wAQz
2026-07-14 17:51:13 -07:00
hanzo-dev 4e88b76672 fix(zen): resolve upstream provider keys env-first, then KMS
zenKeyResolver read the embedded KMS store ONLY. The operator injects the
provider keys (DO_AI_API_KEY, ANTHROPIC_API_KEY) as env from the KMS-synced
K8s secret cloud-api-llm-keys, but that value is not seeded into the embedded
ZapDB KMS store — so GetSecret missed, the resolver returned an empty key, and
zen's upstream call to DO GenAI answered 401 'Unable to authenticate you'.
Every zen chat failed while ai (which reads the key from env) worked.

Read env first, then KMS — the same order ai uses (object/kms.go). One key
source of truth shared by both zen and ai. Empty on both still returns '' so
the call fails fast, never silent free usage.
2026-07-14 17:24:48 -07:00
zeekay 6a071d2a52 unfork(commerce): import hanzoai/commerce v1.47.1 — delete the 1,397-file inlined fork
One canonical commerce repo, one-way dependency (cloud → commerce), no more
monorepo-split force-pushes to keep two trees in sync.

- clients/commerce/ (fork, no go.mod) DELETED; cloud imports the module
- subsystems/commerce.go: the ONE adapter — narrows cloud.Deps, boots
  commerce.Embed, mounts the gin handler at the commerce prefixes, wires the
  two in-process seams; luxfi/log imported plainly as log
- consumer bridges move cloud-side (they read cloud seams, not commerce):
  clients/metering    ← fork metering (finance-coupled billing-gate client)
  clients/commerceclient ← in-process entitlement client + BalanceCents
  (separate from commerceinproc: the entitlement client imports clients/plan,
  which imports cloud — commerceinproc must stay stdlib-only for build.go)
- commerce API path: api/api flattened to api (module v1.47.1)
- middleware precedence regression tests moved INTO the module beside the
  accesstoken fix they pin; the in-proc dispatch e2e stays in clients/metering
- subsystems/wire_test: freeze gitops (main had 83 specs vs 82 frozen)

Test surface green: subsystems, commerceclient (real embedded-ledger money
tests), commerceinproc, metering, catalogsync, bots, admin/finance, ml; root
package fails ONLY the 11 pre-existing env-gated tests (cek master key),
identical to origin/main.
2026-07-14 17:08:40 -07:00
hanzo-dev 14e9c32c4f cli: map Claude Code Fable tier to zen5-max (top SKU)
Fable is Claude Code's top model tier; it was pinned to zen5-pro, the
same as Opus. Point it at zen5-max (the largest zen5 SKU: Qwen3.5-397B ->
1M overflow) so the CC tier ladder is monotonic: Haiku->zen5-flash,
Sonnet->zen5, Opus->zen5-pro, Fable->zen5-max.
2026-07-14 16:20:45 -07:00
hanzo-dev 12f60dfed3 deps: o11y v1.5.28 + otel-collector v1.2.0 — evict koanf v1 monolith
Bumps the embedded o11y (v1.5.26->v1.5.28) and otel-collector
(v0.144.13->v1.2.0) to their koanf-v2 releases and drops the obsolete
otel-collector v0.144.10=>v0.144.13 replace. Removes the ambiguous
github.com/knadh/koanf/maps import (bundled v1.5.0 monolith vs split
module) that broke 'go build ./cmd/cloud'. Cloud builds green.
2026-07-14 16:20:45 -07:00
hanzo-dev 7e6a580cab P2: /v1/gitops deploy dashboard API (clients/gitops)
The ArgoCD-grade GitOps control plane over the operator App CRs, native to the
cloud binary and parallel to /v1/git. SuperAdmin-only, fail-closed, Secrets never
surfaced. The console dashboard consumes these shapes:

  GET  /v1/gitops/applications        list: name, role, version(declared),
                                      runningVersion, health, sync, phase, endpoints
  GET  /v1/gitops/{name}/tree         flat node list (ArgoCD ApplicationTree) with
                                      ownerRef parentRefs + per-node health
  GET  /v1/gitops/{name}/resource/{ref} live manifest + desired-vs-live diff
                                      (ref = group:kind:namespace:name from a node)
  GET  /v1/gitops/{name}/logs         newest app pod logs (tail/container bounded)
  POST /v1/gitops/{name}/rollback     pin CR image tag to a prior semver — REUSES
                                      the P1 release seam (cloud.OnServiceRelease)
  POST /v1/gitops/{name}/sync         request an operator reconcile now

- health.go: per-resource health in the ArgoCD vocabulary (Healthy/Progressing/
  Degraded/Suspended/Missing), pure — P2b swaps to gitops-engine pkg/health.
- CR kind-collapse compat shim: reads BOTH apps.hanzo.ai (kind App, forward) and
  services.hanzo.ai (kind Service, live) — App wins the dedupe; removable
  post-cutover. spec.role surfaced.
- Wired into subsystems.Wire after paas (so the release seam is registered before a
  rollback delegates to it).

TODO seam (follow-on, noted): true GitOps on git.hanzo.ai — RegisterPushBuilder
commits the CR change to the manifest repo and the engine syncs repo→cluster;
desiredSource flips last-applied → git with no shape change.

Tests: health matrix, sync, ref-parse, membership, diff, observe, App-first/Service
-fallback resolution, list dedupe, tree ownerRef+selector. go build + test green.
2026-07-14 16:20:45 -07:00
hanzo-dev e76f1b3185 ci(release): make the migration-smoke shared volume writable (fix baseline INFRA fail)
The two-boot migration smoke shares a docker named volume across boots, but a fresh
named volume is root:root 0755 while the cloud image runs non-root — so the baseline
(v1.799.19) could not create cek's <db>.cek.lock under /data and aborted before
"listening" ("cek: open lock ... permission denied"), failing the gate on infra, not a
regression. The single-boot smoke only passed because --tmpfs is world-writable.

chmod the shared volume 0777 via a root helper before the baseline boot, and again
between boots so the candidate can read the baseline's files even if runtime UIDs differ.
2026-07-14 16:20:09 -07:00
hanzo-dev 6dc3c6bb1e fix(commerce): mount /v1/billing/auto-recharge as a commerce prefix — unbreak the durable-cron sweep poke
The durable platform cron (clients/cron) fires cron-billing-autorecharge every
15m as a poke: POST cloud.hanzo.svc:8000/v1/billing/auto-recharge/run-all with
the COMMERCE_SERVICE_TOKEN bearer. That path was not a commerce prefix, so it
fell through to the account-bridge /v1/billing/* catch-all, whose session gate
403s a service token ("sign in to view billing"). Live fires have been failing
on exactly that — verified in-pod: the poke returns 403 on v1.799.13.

Add /v1/billing/auto-recharge to commercePrefixes alongside /v1/billing/webhooks
— same class of route (token/signature IS the auth, no session possible).
commerce mounts at Wire order 100, ahead of the bridge, so the poke reaches gin
where commerce's own TokenRequired service-token branch + PlatformOnly gate
authenticate it. No other /v1/billing/* route changes owner.

TestAutoRechargePrefixMounted pins both prefixes so a future edit can't silently
re-break the sweep. Landed directly on main: the identical change merged four
times (#274/#275/#277/#280) and was each time force-pushed off main or closed +
branch-deleted; a PR is not a durable landing surface here.

Claude-Session: https://claude.ai/code/session_01XptqW83ZLpqyGBENc1wAQz
2026-07-14 16:17:24 -07:00
hanzo-dev ab58eb4de2 Merge fix/release-migration-smoke: index-after-ADD-COLUMN store migrations + regression harness 2026-07-14 16:04:56 -07:00
hanzo-dev cef39aacfe fix(stores): create indexes over ALTER-added columns after the ADD COLUMN pass
tracker.migrate() indexed issues(org, repo) and issues(org, kind) in the base
DDL, but repo/kind are ALTER-added. On a legacy tracker.db (CREATE TABLE IF NOT
EXISTS no-ops) those indexes fail "no such column", migrate() fails, mount
fails, and the pod crashloops on deploy — the same class already fixed in
wallets and affiliates. Move both indexes after the ALTER pass.

Prevent recurrence with a shared regression harness (internal/migratetest):
each store contributes a legacy-DDL case that seeds its pre-migration schema and
asserts migrate() succeeds and is idempotent, proving migration-correctness as a
pure test over schema epochs instead of at prod boot. Cases added for tracker
(with a scoped-insert probe) plus the other ALTER+index stores — agents, social,
platform, provisioning, projects — which audit clean and are now locked.
2026-07-14 16:04:49 -07:00
hanzo-dev 5fe4b68a33 ci(release): migration smoke — boot candidate over the prior release's on-disk schema
The plain smoke boots on a fresh /data, so every migrate() takes its CREATE-TABLE
path and no forward-migration runs — an index over a not-yet-ADDed column is valid
on a fresh store yet crashes on a pre-existing one (affiliates referrer_org in
v1.800.1, wallets project/agent before it), which is how a boot-crash reached prod
and took api.hanzo.ai down while every smoke stayed green.

Reproduce the real upgrade path: boot the prior released image (default
ghcr.io/hanzoai/cloud:v1.799.19, override via SMOKE_MIGRATION_BASELINE) to lay its
cek-encrypted on-disk schema into a persistent volume under one shared throwaway
master key, then boot the candidate over the same volume and require "listening". A
migrate() that assumes a fresh store fails the gate before any image is pushed.
2026-07-14 15:46:42 -07:00
hanzo-dev 3f349107e4 P1: native release seam — RegisterServiceReleaser patches services.hanzo.ai CR
Close push→build→image→CR: a proven, clean-semver image rolls live by patching
the matching operator hanzo.ai/v1 Service CR's spec.image directly, so the
operator reconciles the Deployment. This is the in-cluster, direct-CR replacement
for universe's image-update.yml GitOps hop (repository_dispatch → PR → ArgoCD),
with the same determinism (clean-semver only; resolve CR by metadata.name) and no
git round-trip.

- build.go: RegisterServiceReleaser / OnServiceRelease / ServiceReleaserRegistered
  inversion seam (mirrors RegisterPushBuilder), so a build-completion path rolls a
  proven image with no cloud⇄paas import cycle.
- clients/paas/release.go: releaseService — resolve CR by name (main-first),
  clean-semver gate (IsSemverTag), idempotent merge-patch of spec.image; registered
  at Mount as the releaser impl (paas owns the first-party Service CR plane).
- clients/platform/release.go: rolloutRelease — native CR patch primary, universe
  image-update dispatch kept as an additive GitOps mirror during cutover.

Tests: split/gate, patch, idempotent, reject-floating, unknown-service, main-first,
fail-closed, seam dispatch/no-op. go build + go test green (paas, platform, root).
2026-07-14 15:45:02 -07:00
hanzo-dev e49eada0f2 Merge: fix affiliates migrate index order (cloud boot crash) 2026-07-14 15:22:16 -07:00
zeekay 874a6f63b6 fix(affiliates): create referrer_org index AFTER ADD COLUMN (boot crash)
On a store whose affiliate_referrals table predates referrer_org, CREATE TABLE
IF NOT EXISTS is a no-op, so 'CREATE INDEX ... ON affiliate_referrals(referrer_org)'
in the same DDL batch failed with 'no such column: referrer_org' BEFORE the
ALTER ... ADD COLUMN pass ran — crashing cloud on boot (v1.800.1 CrashLoopBackOff,
api.hanzo.ai down). Move the index creation after ADD COLUMN so it is valid on
both a fresh store and a migrated one. Regression test seeds the old schema and
asserts migrate succeeds + backfills (it fails with the exact prod error when the
index is moved back into the DDL batch).
2026-07-14 15:22:16 -07:00
hanzo-dev 81b6fc8df8 Merge: hanzo code claude self-identifies as a Hanzo Zen model (--append-system-prompt) 2026-07-14 15:21:18 -07:00
hanzo-dev 9fb946ff96 feat(code): claude agent appends the Hanzo Zen identity to its system prompt
`hanzo code claude` already pins the model to a zen5 alias (default zen5, the
GLM-5.2-class tier) and forces it on argv so a persisted /model selection
("best") cannot override it — but Claude Code's base system prompt still tells
the model it is Claude, so a Hanzo-served model self-identifies as Claude when
asked. Append the Hanzo Zen identity via --append-system-prompt (an APPEND, not
--system-prompt: CC keeps its harness prompt for tool-use/safety/coding) so the
served model says it is a Hanzo Zen model. The identity is not a permission
bypass, so it is applied in --safe too (unlike --dangerously-skip-permissions).

codex/dev (OpenAI wire) are unchanged — the append is Anthropic-only.
2026-07-14 15:19:53 -07:00
hanzo-dev efd9f71cf6 Merge: bare hanzo + configurable default coding tool 2026-07-14 15:04:48 -07:00
zeekay 5b2d742a84 feat(cli): bare hanzo + configurable default coding tool
- `hanzo` (no args): log in if needed, then drop into the configured agent on
  a Hanzo cloud model — one word, billed to your account, all zen-native.
- `hanzo code` (no agent): runs the default agent instead of showing help.
- Default agent resolves HANZO_CODE_TOOL, then config `code_tool`, else dev.
- `hanzo config set code_tool claude|codex|dev` + `code_model` — git-style k/v,
  persisted to ~/.hanzo/config.
A named agent (`hanzo code claude`) still dispatches to its subcommand.
2026-07-14 15:04:48 -07:00
hanzo-dev 6a017c1f37 wallets: migrate columns before indexing them (fix deploy crashloop)
migrate() created ix_wallets_scope/ix_wallets_finance over project/agent/
finance_account inside the base DDL, before the idempotent ALTER TABLE that
forward-adds those columns. On a wallets table created before scoping (the prod
shape), CREATE TABLE IF NOT EXISTS no-ops, so the index build hit 'no such
column: project' -> migrate fails -> mount fails -> pod crashloop on any newer
image. Order by dependency: base tables, then ALTER-add every post-original
column (project/agent/chain/finance_account), then the indexes over them.

Regression test opens a legacy-schema wallets.db and asserts clean, idempotent
migrate + a fully-scoped insert.
2026-07-14 14:54:01 -07:00
hanzo-dev 3f24e1a81b Merge: resolve API keys to a principal at the identity edge (fixes zen 402) 2026-07-14 14:25:50 -07:00
zeekay 96b9915b07 feat(auth): resolve API keys to a principal at the identity edge
The identity boundary validated JWTs only; an opaque API key (hk-/sk-/pk-)
yielded no principal, so a subsystem that gates on the minted identity (zen's
billing gate) refused a key request as anonymous — the zen 402 'a billable
tenant is required' for a funded hk- key.

keyResolver turns a key into the SAME idClaims a JWT yields (via IAM's
authenticated get-user?accessKey, the confidential hanzo-console client), so the
ONE minting path serves both credentials and key auth and session auth can never
disagree on who a request is. An unresolved key stays anonymous — a bad key
never grants trust; an unconfigured resolver keeps keys anonymous rather than
mis-resolved. Brief generic TTL cache keeps the hot path off the network.
2026-07-14 14:25:50 -07:00
hanzo-dev 2fb181206a fix(coding): terminal ops outlive run ctx; cloud→bot POST refuses cleartext
Red LOW-6: a timed-out coding run must still close the session and mirror
its terminal result. Run terminal-side ops (fail/CloseSession/mirror/CreatePR)
under context.WithoutCancel so an expired run ctx cannot strand a session in
'running'.

Red MEDIUM-3: the cloud→bot coding POST carries the org hk- git credential and
the shared gateway bearer. Fail closed on a cleartext http:// target; a plaintext
in-cluster hop is allowed ONLY when the operator asserts mesh mTLS via
BOT_GATEWAY_ALLOW_PLAINTEXT=1. Error never echoes the credential.
2026-07-14 13:59:57 -07:00
zeekay 3c476cbd5d deps: bump hanzoai/zen v1.2.0→v1.3.0 (cost-basis + margin pricing) 2026-07-14 13:32:08 -07:00
blue 9bed31d068 style: gofmt coding orchestrator + tracker agentpr + slack_coding test 2026-07-14 13:22:13 -07:00
blue ce416c4798 feat(coding): @hanzo agent keystone — native coding tasks from Slack
Turn @hanzo from a chatbot into an engineer. A Slack message
`@hanzo code: <repo> <task>` branches off the chat-only reply into a
durable coding run: register a live agent session, dispatch to the
bot-gateway sandbox, mirror progress into the session live, verify the
pushed branch landed in native /v1/git, open a native PR work item, and
report the branch + PR back in-thread. Non-code mentions keep the
existing chat path unchanged.

- clients/coding: transport-agnostic orchestrator (Dispatcher over
  interface seams; unit-tested with fakes, org-isolated, no credential
  leak into session events/PR body). Cannot import git (cycle via
  integrations) so CloneURL/VerifyRef are injected at the composition root.
- clients/agents/inproc: in-process session API (Open/Log/Close) — the
  twin of the /v1/agents/sessions control plane, same store + live bus.
- clients/tracker/agentpr: in-process Kind:pr Source:agent work item,
  org-scoped, get-or-create repo board (KEY-N).
- clients/bot/coding: in-process NDJSON client for POST /v1/coding-tasks;
  credential travels in the body only, never argv/URL/logs.
- clients/git/export: CloneURL + VerifyRef seams (org-scoped ref check).
- clients/integrations/slack_coding: the code: trigger, credential fetch
  from KMS (fail-closed), ack, detached bounded run, result Block Kit card.
- subsystems/wire_seams: compose the Dispatcher (git seams + adapters)
  and inject into the Slack surface.

Tenant isolation fail-closed: org is the only tenant key on every seam;
sandbox pointed only at the caller org's clone URL with an IAM-scoped
credential; cross-org repo targeting is refused by git's path-vs-identity
guard. Tests: CGO_ENABLED=0 go test green across all touched packages.
2026-07-14 12:45:37 -07:00
zeekay 67b7cfcfdc deps: bump hanzoai/zen v1.1.0→v1.2.0 (coherent lineup + thinking effort)
Pulls in the reworked zen family (one SKU per capability, every upstream
verified on DO) and the hanzoai/thinking depth fold (effort → each upstream's
native reasoning shape). Adds hanzoai/thinking v0.1.0 as a direct require.
2026-07-14 12:40:54 -07:00
hanzo-dev 71c9611461 Merge: hanzo code claude defaults to zen5 (GLM-5.2) 2026-07-14 12:39:26 -07:00
zeekay dac8f15f6b feat(cli): hanzo code claude defaults to zen5 (GLM-5.2)
zen5 is the flagship GLM-5.2-class alias (1M ctx, tool-capable — the glm-5.2
upstream returns tool_use/stop_reason:tool_use, verified live). Matches the
intent to launch Claude Code on GLM-5.2 through api.hanzo.ai. Was zen5-pro
(DeepSeek). The four CC tier slots stay pinned to served zen5 aliases so the
classifier, subagents, and /compact never hit an unserved claude-* id.
2026-07-14 12:38:19 -07:00
hanzo-dev 4c0972a965 git lifecycle: escape branch name in Slack blocks (Red residual LOW)
ev.Branch was an un-escaped mrkdwn sink: git refnames allow < > & !, and the
receive-pack fire path (branchTips → for-each-ref) applies no branchRE, so a
hostile branch (e.g. x<!channel>y) flowed verbatim into the summary and the
*Branch* field of every subscribed channel. slackEscape it in both places, and
defensively escape the org/repo display text too (a deploy event's repo derives
from an unconstrained RepoURL, though it is subscription-gated to a valid name).

TestNotifyEscapesMrkdwn now also pushes a hostile branch through smart-HTTP with
the real git CLI (go-git rejects such a refspec) and asserts it is neutralized in
both the summary and the Branch field.
2026-07-14 12:06:41 -07:00
hanzo-dev 3fc3446bce git lifecycle: address Red review (fix-then-ship)
HIGH-1 mirror-out no longer starves the shared pack plane: dedicated mirrorSem
(separate from packSem), per-push context.WithTimeout, git http.lowSpeed abort,
and cmd.WaitDelay so a stalled downstream's network-helper child can't wedge the
slot past the deadline.

MED-2/MED-3 full (org,project,repo) identity: subscriptions + mirror targets key
on project too (DDL + every list/delete + the notify + mirror fire paths); deploy
emitters thread project (platform a.ProjectID normalized, projects org-level);
repo delete cascade-deletes its subscriptions + mirrors in one tx (no
exfil-on-recreate via an orphaned target).

MED-1 decouple allowlists: outbound mirror TARGET set = {github.com, gitlab.com}
only; the local git host is rejected as a target (no internal SSRF /
privileged-cred presentation). Inbound-fetch credential gate unchanged.

MED-4 Slack mrkdwn escaping of user-derived text (commit subject, pusher, deploy
detail) so a crafted commit subject can't inject <!channel>/disguised links.

LOW-1 keep the shared bot token least-privilege (no chat:write.public — it would
also arm the @hanzo assistant to post uninvited); notifications require the bot
be invited. INFO: reject non-deliverable build.started at subscribe time; DRY
repoFromURL into cloud.RepoFromCloneURL.

Re-verified: new tests for project-scoped routing, delete-cascade, mrkdwn escape,
and stalled-downstream isolation; suites green under -race; gofmt+vet clean;
go.mod untouched.
2026-07-14 11:49:03 -07:00
683e0e0045 test(subsystems): base gained Shutdown via per-org embed (#298) — sync frozen wire order (#300)
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-14 11:23:45 -07:00
863f61e9a2 analytics: capture (write) plane — POST /v1/analytics + /v1/tracker → hanzo.events (#296)
* analytics: add capture (write) plane — POST /v1/analytics + /v1/tracker → hanzo.events

The analytics subsystem served only read lenses over hanzo.events; nothing
wrote the table, so the web/commerce lenses were permanently honest-empty. This
adds the symmetric ingest: products POST batches to cloud (the ONE native front
door) and cloud writes org-scoped rows into the datastore warehouse the read
side already queries.

- POST /v1/analytics, /v1/analytics/batch, /v1/tracker (beacon alias) — all
  tenant-gated in-handler; tenant_id is always principal.Org, never client input.
- Writes ride ai/object.DatastoreExec (the SAME pooled client the reads use).
- The writer owns the hanzo.events DDL (EnsureEventsTable, idempotent/latched).
- Privacy scrub: credential/PII-shaped property keys dropped, email values
  redacted, before any row is built.
- Pure core (normalizeEvent/scrubProps/buildEventsInsert) unit-tested; HTTP
  contract tests cover no-principal 403, forged-org 403, oversized 400,
  datastore-down 503; a build-tagged live test proves the full round trip
  against a real datastore.

* analytics: accept anonymous capture, attributed to the brand-public org

Marketing sites emit anonymous pageviews (no session). captureTenant now falls
back — when there is no validated principal — to the PUBLIC brand org derived
SERVER-SIDE from the request Host via the white-label registry (BrandForHostOK),
never a client-claimed org. A forged X-Org-Id is still ignored, and an
unrecognized Host is refused (anonymous events are never dumped into a default
org). Gated by CLOUD_ANALYTICS_PUBLIC_CAPTURE (default on, matching the existing
public insights-capture posture). Verified live: an anonymous pageview to
Host hanzo.ai lands under tenant_id=hanzo.

---------

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-14 11:21:54 -07:00
478f784b34 marketing: grow /v1/marketing into the full GTM engine (#299)
Fold the remaining GTM subsystems into clients/marketing (native Go, per-org
SQLite, twin of clients/crm), so nothing Python is load-bearing in the mount
path:

- Email drip sequences on the embedded hanzoai/tasks engine (drip.go): a
  per-minute durable schedule sweeps due enrollments; each (enrollment, step)
  is claimed once so a step delivers at most once across restarts/redelivery,
  then the walk advances or completes. Mirrors clients/cron (engine owns time,
  SQLite owns the schedule) — no Redis, no bespoke ticker.
- The ONE send seam (suppress.go): every marketing delivery funnels through
  state.deliver, which enforces the per-org suppression/opt-out list and then
  hands off to the platform notify rail. Expose notify.Send so the existing
  sender is reachable in-process — one sender, not a second. Plus a signed
  public one-click unsubscribe.
- Audiences (audiences.go): cohort filters evaluated live against the org's
  hanzo.events analytics via the ai/object datastore, tenant_id-scoped and
  honest-empty when the warehouse is not wired.
- Promo codes (promos.go): the First-1,000 90%-off launch promo (discounts.md)
  realized as a non-cash wallet credit through the finance ledger, with the
  hard 1,000 cap, one-per-org, one-per-instrument and team-seat-cap guards.
- Content calendar (calendar.go): scheduled posts as documents published by a
  task-executed hook; social publish returns an honest 501 until a connector
  is wired (clients/social's push is fail-closed, the automations connector
  registry is package-private).
- Campaign scheduling (scheduled_at + the scheduled state).

Real tests: drip scheduling + per-step idempotence + tenant isolation,
suppression enforcement at the gate, promo eligibility math + abuse guards.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-14 11:21:24 -07:00
hanzo-dev ae4a9369a2 git lifecycle event stream + Slack-notify and outbound-mirror subscribers
Generalize the single-registrant git push→deploy hook into a many-subscriber
lifecycle stream without regressing push→deploy. RegisterPushBuilder/OnGitPush
stay exactly as-is (deploy is the subscriber-of-record); alongside them a new
RegisterLifecycleSubscriber/EmitLifecycle fans ONE LifecycleEvent out to N
reactors, best-effort, detached, on a cancel-immune context, panic-contained.

Emit points: PushLanded at the one branch-build funnel (covers HTTP/SSH/push,
now carrying before/after tips + pusher); BuildStarted/DeployLive/DeployFailed
at the platform (startGitBuild / applyLive / failDeploymentCtx) and projects
(deployGit / deployArtifact / completeDeployment) transitions.

Subscriber 1 — Slack notify: per-org repo→channel subscriptions
(/v1/git/repos/:name/subscriptions) stored in the existing per-org git.db,
delivered as Block Kit via the ONE integrations chat.postMessage path
(integrations.NotifySlack/PostSlackBlocks; the automations connector now shares
it). Adds chat:write.public so a freshly-subscribed public channel works
without a manual invite.

Subscriber 2 — outbound mirror: per-repo downstream targets
(/v1/git/repos/:name/mirrors) force-push ONLY the advanced branch to
allowlisted hosts (github.com/gitlab.com/git.hanzo.ai), token via env-only
http.extraHeader under the pack-slot semaphore. LifecycleEvent.Origin is the
loop-prevention seam for a future inbound sync.

All routes org-scoped + fail-closed; tenant isolation, injection, allowlist,
and loop-prevention covered by tests.
2026-07-14 11:17:52 -07:00
zandGitHub e622032d79 Merge pull request #297 from hanzoai/feat/company-formation
Hanzo Company — incorporation + fundraising state machine (/v1/company)
2026-07-14 11:17:14 -07:00
b6274fbef7 feat(base): per-org multi-tenant Base hosting at /v1/base/* (kill superbase wrapper) (#298)
Upgrade the in-process Base embed from the single-instance waitlist-only fold
(#193/#211/#248) into two orthogonal lanes on the ONE engine:

  - LANE 1 (unchanged surface): the platform waitlist app → public /v1/waitlist/*.
  - LANE 2 (new): managed Base hosting → ONE Base app PER ORG, opened lazily and
    LRU-pooled, each on its own SQLite under {DataDir}/base/{TenantSegment}/ (the
    HIP-0302 'SQLite per tenant' model the gojabase leaves use). Served
    authenticated under /v1/base/*, the org resolved from the VALIDATED cloud
    principal (principal.Org) — physical per-org isolation, the console Bases
    manager's backend and the in-binary replacement for the superbase pod.

Base serves under BASE_API_PREFIX=/v1/base so its collections API mounts natively
(self-URLs included) without colliding with cloud's other /v1 routes; the waitlist
plugin binds a FIXED /v1/waitlist regardless. Per-org apps validate bearers against
Hanzo IAM's JWKS as their exclusive auth source (apis.StoreKey{JWKSURL,
ExternalAuthOnly}) — ONE IAM, no second auth path. The cloud binary owns the ZAP
transport, so embedded apps set ZAP_DISABLED. Shutdown releases the platform app +
every pooled per-org app.

Reuses gojabase.TenantSegment (the ONE injective, traversal-safe org->path encoder).
Test: subsystem boots in the harness, a collection record round-trips per-org
isolated over the real HTTP path (acme's record invisible to globex; distinct
on-disk dirs).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-14 11:16:33 -07:00
hanzo-dev 57443cecee captable: harden the http_test fiber timeout (pre-existing flake); wire company after guide/x402 in the frozen order 2026-07-14 11:12:53 -07:00
hanzo-dev f10800c332 company: make genesis idempotent (no double share issuance) + propagate save error 2026-07-14 11:10:51 -07:00
hanzo-dev ae3edfbf2a feat(company): Hanzo Company — incorporation + fundraising state machine
Adds clients/company (/v1/company): one formation state machine per org
(structure → founders+KYC → $999 → documents → esign → on-chain equity
genesis → company), with a SKIP path for already-incorporated orgs that
imports corporate docs → data room and a cap-table sheet → captable.

- machine.go: pure transition table + per-edge guards; the payment gate,
  KYC gate, and skip path are unit-tested with no I/O.
- providers.go: narrow seams for billing/kyc/docs/esign/captable/anchor/
  filing/upgrade. Billing wires the shared ResourceMeter ($999). KYC and
  state filing are honest stubs (no fabricated verification/filing).
- genesis.go: KMS-signed Hanzo-L1 equity-genesis anchor mirroring
  clients/treasury; computes the root always, commits on-chain when wired,
  honest pending otherwise.
- adapters.go + captable.facade + dataroom.Ingest: new in-proc facades so
  company writes the cap table and data room without an HTTP hop.
- google provider completed in clients/integrations (OAuth + KMS token
  custody); the automations google_sheets/google_drive connectors are
  consolidated to one `google` connector sharing that token; company import
  reads Drive/Sheets through it.
- wired into subsystems.Wire() after referrals; docs/company-dogfood.md
  walks Hanzo/Lux/Zoo through the import path.
2026-07-14 11:10:51 -07:00
3f62dbfeb9 feat(guide): Business AI Guide — /v1/guide launch checklist + agent (#282)
clients/guide + /v1/guide/*: a per-org checklist engine over a
machine-readable curriculum (Step: id/title/why/how/done/dependencies/
signal/tool). Per-org progress on cloud.OrgStore; next-step + dependency
gating are pure functions; auto-detect reconciles a step to done when its
signal maps to real org state (acted = agent action ledger; analytics =
shared warehouse events). Business AI 'do it for me' drafts with deps.AI
then executes the step's bound MCP tool via automations.InvokeTool AS THE
CALLER — attributable, metered, audited, never exceeding the caller's
authorization. Built-in default.yaml (7 steps: positioning→landing→
analytics→waitlist→email→referral→launch) seeds it before marketing's
checklist.yaml; an org-custom PUT replaces it cleanly.

clients/automations: decomplect tool dispatch (dispatchTool) from its two
doors — the HTTP MCP handler and the new in-process InvokeTool/ToolExists
seam — so both share one dispatch, org-scoped credential, concurrency
bound, meter and audit.

Tests (pure-Go, CGO_ENABLED=0): validation/cycle-detection, next-step +
dependency gating, auto-detect reconcile (present/absent/error/terminal),
the acted detector end-to-end, the agent (tool success/failure/assisted/
unknown-tool), and the HTTP surface (403 gate, transitions, 409 gating,
curriculum replace/revert, per-org isolation, do-delegation).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-14 11:05:32 -07:00
36c56e7e51 feat(growth): multi-level referral upline + OSS-author royalties in one accrual walk (#295)
Extend the existing affiliate accrual into a depth-capped multi-level upline and
fold OSS-author royalties into the SAME per-org spend walk, over one attribution
spine.

Affiliates — multi-level upline (clients/affiliates):
- The referredBy graph is the existing affiliate_referrals edge made walkable
  (referred_org -> referrer_org, denormalized). referredBy is set-once/immutable
  (UNIQUE referred_org) with cycle detection at set time (wouldCycleOrg climbs the
  proposed referrer's upline and refuses a loop).
- Per-level schedule L1 20% / L2 5% / L3 2%, depth cap 3. L1 uses the affiliate's
  own negotiable rate (default 20%, preserving prior single-level behavior); L2/L3
  are platform constants. accrual rows carry the level for analytics.
- The admin sweep is source-centric: fold over every referred org, read spend ONCE,
  walk the upline and accrue to each ancestor's approved affiliate, latched
  at-most-once per (affiliate, source, period). The per-affiliate dashboard read
  walks the downline (same latch key, never double-accrues).
- User-level referredBy graph (user_referrals): set-once + cycle-checked, mirroring
  the org edge; recorded from the referee's user to the affiliate's owner user.
- Payout no-overdraw guarantee unchanged (pending-guard + treasury reserve backing).

Authors — OSS royalties (clients/authors):
- Default author share 25% (was 5%).
- GitLab provider alongside GitHub: host-aware repo canonicalization (github.com +
  gitlab.com), provider-dispatched forge seam (linkedAccount/repoAdmin/fetchFile).
- Append-only, on-chain-ready royalty ledger (author_ledger) with a nullable
  compute_proof column, written per accrual in the same transaction as the balance
  move. compute_proof stays NULL — the hanzod attestation is a follow-up, not faked.
- AccrueForOrg seam: the affiliate sweep drives author royalty for each source org
  with the spend it already read — one accrual walk. Nil-safe when authors is
  unmounted (mirrors treasury.Reserve).

Surfaces:
- GET /v1/affiliates/me — my code, link, downline by level (L1/L2/L3 with rates +
  counts), accrued/pending/paid, payouts.
- GET /v1/admin/referrals — unified SuperAdmin cross-tenant analytics (top referrers,
  conversion, accrual liability by level). The one-time-bonus board moves to
  GET /v1/admin/referrals/bonuses (referrals) so the two compose without colliding.

Tests: 3-level walk math + depth cap, cycle rejection (org + user), set-once
immutability (org + user), no-overdraw payout, GitLab verify + ledger row with NULL
compute-proof, both surfaces. Deterministic fiber test timeout (30s ceiling; the 1s
default flaked under machine load).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-14 10:53:48 -07:00
5aa185f4bb kb: wikilink graph — extraction, /v1/kb/graph, and vault import (#294)
Complete the native knowledge graph on the kb subsystem.

Wikilink edges: the kb-page after_save hook now extracts [[Page Title]]
references from the page body (the same flattened Lexical text the vector
indexer embeds) and reconciles them into kb-link edge documents — a Link
(source) + Data (target_title) reference, no parallel store. Index and link
maintenance run as one combined page hook so a vector outage never skips link
extraction. Targets resolve to a page by value at read time, so a rename or
trash of a target needs no edge rewrite; a page's own trash removes its
outgoing edges.

GET /v1/kb/graph: the org's knowledge as nodes (kb-page/kb-memory/kb-source,
plus connector and dangling-link endpoints) and edges (parent tree, wikilinks,
connector provenance), org/project scoped, shaped for a force-directed
renderer.

POST /v1/kb/import: an Obsidian-importer-equivalent that ingests an Obsidian
vault zip, Notion export zip (markdown/HTML), Evernote .enex, or Roam JSON as
a kb-page tree with links preserved. Each format is a pure normalizer package
(obsidian/notion/roam/evernote over vault + lexical), mapping to pages filed
through the same framework.Ingest path a connector sync uses — the after_save
hook then extracts their wikilinks, one link path for authored and imported
pages alike.

framework: add the in-process Delete (twin of the HTTP delete, runs on_trash)
used by edge reconciliation.

Tests: table-driven wikilink extraction; per-format normalizer tests with real
fixture files; end-to-end graph, import, and full edge lifecycle (extract,
reconcile-on-edit, source-trash cleanup, target-trash dangling) over the real
framework store.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-14 10:53:02 -07:00
bc3f8d1dc0 feat(wallets,x402): scoped custody {org,project,agent,account} + native x402 pay-per-use (#293)
Wallets custody scoping (clients/wallets)
- One Scope type {org, project, agent, account} is the ONE key both the KMS
  secret ref (keyRef) and the store lookup derive from. Org stays the hard
  isolation boundary; project/agent/account are optional narrowings within it.
- keyRef derives from the full scope, injection-safe (narrowings validated to a
  slash-free segment). An org-only wallet keeps its exact legacy ref, so scoping
  is additive, not a migration.
- listWalletsByScope is the one scope-filtered read path (org bound, narrowings
  filter within); create + list handlers thread the scope. Store gains project +
  agent columns (forward ALTER, dup-column tolerant).

Native x402 pay-per-use subsystem (clients/x402, /v1/x402)
- challenge (402 + PaymentRequirements) -> client signs ERC-3009 -> proof ->
  Verify (EIP-712 secp256k1 recovery via luxfi/crypto, the same primitive wallets
  signs with) -> settle -> serve. Enforce is a zip middleware a priced route
  group applies.
- Idempotent + replay-safe: settlement id is deterministic in (from, nonce); a
  spent nonce reused for different terms is a replay (402), a re-submitted
  authorization is an idempotent retry (settled + metered once). Two independent
  guards: the PK-atomic store claim and the ledger's own RequestID/Ref idempotency.
- Settlement wires into the metering spine: the payer's org is debited through
  metering so paid usage appears in billing/usage like any metered spend, and the
  recipient wallet's ledger is credited. Ledger settlement is LIVE; on-chain
  broadcast of the authorization is a seam (not wired).
- Marketplace seam: a Registry (Publish) maps resource -> Terms (price + recipient
  wallet ref); x402 resolves the recipient via wallets.ResolvePaymentTarget and
  enforces. The registry itself is another subsystem's work.

Removes the dead, unreferenced clients/commerce/payment/x402 (gin + btcec) so the
binary has exactly one x402.

Tests: scope derivation + injection + scoped seal/sign + scope lookup isolation;
EIP-712 verify round-trip/tamper/term-binding; challenge->verify->serve, nonce
replay rejection, settle-once on retry, free passthrough, payer-required, and
end-to-end ledger settlement (payer debited once, recipient credited once).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-14 10:51:18 -07:00
5d56acfb88 feat(tools): unified tool plane + marketplace (registry, activation, external-MCP, full-cloud-control, x402 seam) (#292)
* tools: unified tool-plane registry, activation, external-MCP + builtin sources

ONE registry where a tool is {name, source, schema, per-(org,project) activation,
optional price}. Sources register a Provider (List+Dispatch) into it; the registry
enforces the one policy: precedence dedup, activation gate (403), x402 Charger seam
(fail-closed for priced tools), then dispatch. Ships two package-owned sources:
external MCP servers (KMS-sealed auth, SSRF-guarded) and full-cloud-control builtin
(every /v1 route, dispatched in-process replaying the caller credential). Real
tests: precedence, activation 403 + cross-org isolation, priced fail-closed,
external-MCP dispatch, builtin route dispatch, SSRF guard.

* tools+marketplace: register sources, x402 price seam, wire subsystems

Sources register a Provider into the tool plane from their OWN Mount (no source
duplicates its listing logic): connectors (automations), functions, agents, and
skills (discovery-only). Adds the registry Pricer seam + public activation
pass-throughs (Activate/Deactivate/Activated/Exists) so marketplace install IS the
tool activation write and a published listing's price reaches per-call dispatch
enforcement.

clients/marketplace: /v1/marketplace listing/discovery/install over the plane;
monetized listings declare price + recipient wallet and settle through the tools
Charger (x402) seam — proven end-to-end (publish→install→dispatch charges the
seller). Wires tools + marketplace into subsystems.Wire() (frozen-order updated).

Tests: marketplace install==activation + cross-org isolation, phantom 422, publish
validation, monetized dispatch settlement, discovery overlay.

---------

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-14 10:48:38 -07:00
hanzo-dev ef1b674bee feat(git): repo maintenance — bitmap + commit-graph for fast clones
A repo populated by fetch/receive-pack accumulates objects via index-pack,
which writes a pack but no reachability bitmap and no commit-graph — so every
upload-pack walks the whole object graph to build the clone pack (O(objects)
per clone). Add maintenance on the git-exec seam: repack -adb --write-bitmap-index
+ commit-graph write --reachable --changed-paths, under one pack slot with the
same memory bounds as every pack op (a multi-GB gc can't OOM the pod).

- POST /v1/git/repos/:name/gc — on-demand repack, org-scoped, 404 fail-closed.
- Post-push autoMaintain: fire-and-forget git gc --auto after every receive-pack
  (HTTP + SSH), slot-yielding (skips when clones are busy) so repos self-maintain
  as pushes accumulate packs, no scheduler.

Tests: runMaintenance writes bitmap + commit-graph and the repo stays clonable;
the /gc endpoint repacks (200 maintained) + 404s unknown/cross-tenant repos.
2026-07-14 08:50:26 -07:00
antje 728cb0a283 deps: bump ai v1.807.0 -> v1.808.0
Bearer-auth get-cloud-usages + brand-scoped god-view (one UsagePanel across
app/chat/billing). Blue+RED reviewed; build+tests green.
2026-07-14 08:32:16 -07:00
hanzo-dev bc94982a4c fix(git): read the framework-decoded pack request body, don't re-inflate
Fiber's Body() transparently inflates a Content-Encoding: gzip request per
its header (BodyGunzipWithLimit + SetBodyRaw), so c.Body() is already the
decoded pkt-line stream. packRequestBody inflated it a second time and
returned 400 "invalid gzip request body" for every compressed clone/fetch.
git gzips the ref-negotiation once a repo carries enough refs, so this broke
clone for all but trivially small repos — the small-repo round-trip tests
sent the request plain and never exercised the compressed path.

Read c.Body() verbatim (the framework owns Content-Encoding). Add a gzipped
upload-pack regression test through the live Fiber server that fails on the
double-decode and passes on the fix.
2026-07-14 08:16:46 -07:00
hanzo-dev 68a5546f52 deps(ai): bump hanzoai/ai v1.806.15 -> v1.807.0
Ships the zen consolidation: ai discovers the Zen family from the zen service
(GET $ZEN_URL/v1/models) and fronts it, holding no zen routing/pricing/identity
of its own. Pulls the corrected per-upstream reasoning vocabulary and the
money/decimal billing types transitively.
2026-07-14 07:43:32 -07:00
hanzo-dev c9a982a6ca feat(git): streaming git-CLI object plane — bounded memory, protocol v2 (red-reviewed GO) 2026-07-14 07:08:45 -07:00
hanzo-dev db4e6a18a7 chore(deps): fold released hanzoai/ai v1.806.15 (#290,#291) into main 2026-07-14 07:08:45 -07:00
hanzo-dev 60ba58ed55 git: harden the streaming object plane (red review deltas)
Address the red-team findings on feat/git-streaming-object-plane:

HIGH-1 mirror credential exfiltration: only attach GIT_MIRROR_TOKEN to hosts
on an allowlist (GIT_MIRROR_ALLOW_HOSTS, default {github.com, git.hanzo.ai});
any other https source fetches anonymously so a tenant-supplied URL can't
capture the shared token. Set http.followRedirects=false on the mirror fetch
so the token can't ride a cross-host redirect.

MED-2 pack RAM: add -c pack.threads=1 -c pack.windowMemory=64m
-c pack.deltaCacheSize=64m -c core.bigFileThreshold=16m to every pack/fetch
subprocess, and a concurrency semaphore (GIT_PACK_MAX_CONCURRENCY, default 2)
around all pack ops so concurrent large clones/mirrors can't multiply RAM in
the shared cgroup.

MED-3 org traversal: gate the org identifier through orgRE (alnum-led, no
'/'/'\\', no leading '.') in org(), so a SuperAdmin X-Org-Id switch to
"../../etc" is rejected at the git boundary before it reaches absRepoPath.

MED-4 mirror SSRF: resolve the source host and refuse loopback / private /
link-local / metadata (IMDS) / unspecified / multicast targets;
GIT_MIRROR_ALLOW_PRIVATE_HOSTS allowlists internal hosts for tests /
deliberate internal mirrors. Generic rejection message (no probe oracle).

MED-5 pack-to-disk DoS: -c receive.maxInputSize (GIT_RECEIVE_MAX_INPUT_SIZE,
default 2g) on receive-pack so a gzip-amplified or runaway push can't fill
the pod disk.

MED-6 disconnect reaping: gitPackStream.Close now closes the read pipe and
Kills the process before Wait, and runPackSSH Kills on a channel-copy error,
so an abandoned clone can't leave git blocked on a full pipe (leaked
proc/goroutine/FDs).

LOW-7 strip URL userinfo in mirrorSource (credentials via env only, never a
ps-visible argv). LOW-8 close std pipes on cmd.Start failure.

Tests: org-traversal rejection, disconnect-reaping (Close returns promptly +
process reaped + slot released), mirror credential host allowlist, and mirror
SSRF + userinfo strip; existing suites stay green.
2026-07-14 06:54:11 -07:00
hanzo-dev d30112bad7 git: back the object plane with the streaming git CLI for bounded memory
The heavy git paths buffered whole packs in RAM via go-git's pure-Go
server transport and FetchContext: a clone serialized the entire outgoing
packfile into a bytes.Buffer, a mirror indexed the whole incoming pack in
memory, and receive-pack read the full push body. Mirroring a multi-GB
repo OOM-killed the 1 Gi cloud pod.

Route the object plane through the streaming git CLI instead — the way
gitea/GitLab serve smart-HTTP — so packs stream to and from disk with
memory bounded by an OS pipe:

- clone/fetch serve: `git upload-pack --stateless-rpc`, git stdout streamed
  straight to the HTTP response (SendStream); no pack buffer.
- push receive: `git receive-pack --stateless-rpc`, request body -> git
  stdin -> index-pack to disk; only the small report-status is buffered.
- info/refs: `git <svc> --stateless-rpc --advertise-refs` + pkt-line header.
- mirror-in: `git fetch --prune --tags +refs/*:refs/*` against the on-disk
  bare repo; ls-remote --symref resolves the source default branch for HEAD.
- SSH: plain `git upload-pack`/`git receive-pack` over the channel.

One git-exec seam (gitexec.go) builds every subprocess with a hardened,
minimal env (GIT_CONFIG_NOSYSTEM, GIT_CONFIG_GLOBAL=/dev/null, no inherited
secrets, GIT_TERMINAL_PROMPT=0, GIT_NO_REPLACE_OBJECTS) and arg slices only.
Protocol v2 is forwarded from the client Git-Protocol header / SSH env
(validated). Mirror source credentials are injected only via env git-config
http.extraHeader (GIT_MIRROR_TOKEN), never argv or logs, under
GIT_ALLOW_PROTOCOL=http:https. Tenant isolation stays on the validated
absolute bare-repo path (storage.absRepoPath); the handler org/path guards
are unchanged.

Push-to-deploy is preserved via a branch-tip diff (before/after
for-each-ref) that fires cloud.OnGitPush for every advanced branch, and
metering (recordUsage) runs after every push. The go-git server transport
is removed; go-git remains only for bounded init/ref-read/object-building.

Runtime: add git to the alpine stage (upload-pack/receive-pack/http-backend/
git-remote-https).

Tests: real git-CLI clone+push round-trip, a 48 MiB clone proving the pack
streams with ~120 KB server heap growth, mirror from an external
git-http-backend source, and the existing tenant-isolation + push-to-deploy
suites, all green.
2026-07-14 06:12:40 -07:00
5c83e09cc7 fix(deps): correct hanzoai/ai v1.806.15 go.sum hash (#291)
The v1.806.15 tag was re-pointed to the current fix commit; a stale local
module cache wrote the previous tag content's hash into go.sum in #290, so
cloud CI's fresh download failed verification (checksum mismatch / SECURITY
ERROR). Re-fetched the module clean so go.sum records the actual v1.806.15
content hash. No code change; go.mod pin unchanged.

Verified: CGO_ENABLED=1 go build -tags "libsqlite3 sqlite_fts5" ./cmd/cloud → 0.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-13 23:37:21 -07:00
dc22bdfa66 fix(deps): bump hanzoai/ai v1.806.13 -> v1.806.15 (#290)
Carries the hanzo-code-claude corrections (ai 68be82cf, first in v1.806.14):
per-upstream reasoning-effort vocabulary (GLM/DeepSeek max|high, not OpenAI
low|medium|high) and round-tripping assistant thinking as reasoning_content
so DeepSeek/Kimi tool-call loops no longer 400/stall. v1.806.15 also carries
ai's luxfi/geth proxy-resolution CI fix and the native Responses work.

Prod (cloud v1.799.16) embedded ai v1.806.13, which predates these fixes.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-13 23:28:17 -07:00
zandGitHub f09ac084d7 Merge pull request #285 from hanzoai/fix/cloud-gomod-cache-v4
fix(ci): retry Zen resolution after publishing tag
2026-07-13 17:07:34 -07:00
hanzo-dev 1b302ebf57 fix(ci): retry Zen resolution after publishing tag 2026-07-13 17:07:28 -07:00
zandGitHub 9b6e53d03b Merge pull request #284 from hanzoai/fix/cloud-gomod-cache-v3
fix(ci): cold-bust poisoned Zen module cache
2026-07-13 17:04:49 -07:00
hanzo-dev 4dc06a9a30 fix(ci): cold-bust poisoned Zen module cache 2026-07-13 17:04:25 -07:00
zandGitHub b003a202b8 Merge pull request #283 from hanzoai/fix/code-full-auto-tool-default
fix(code): full-auto defaults on tool-capable model
2026-07-13 17:02:32 -07:00
hanzo-dev 3ec43678de fix(code): default agents to full-auto tool-capable model 2026-07-13 17:02:10 -07:00
hanzo-dev 4e3877cb8f git: serve smart-HTTP at the git-host root so git clone https://git.hanzo.ai/<org>/<repo>.git works
Add host-guarded root-level /:org/:repo/{info/refs,git-upload-pack,git-receive-pack}
reusing the existing handlers. onGitHost gates on the request Host == git host
(defaultSSHHost(Domain)); on api/console hosts the routes fall through (c.Next())
so a bare /:org/:repo never shadows another surface. Advertised clone URL stays
/v1/git until cutover. Test: TestRootSmartHTTP_HostGuard (git host → handler runs;
other host → 404).
2026-07-13 15:41:24 -07:00
hanzo-dev 23c2ba42d8 cloud: mount zen co-resident + 18-dp-native metering
zen mounts as a /v1-scoped Claim middleware BEFORE ai's /v1/* catch-all
(Wire position 100, ahead of ai at 150). Claim routes every request whose
model is a zen SKU to zen's serving layer in-process and c.Next()s the rest
to ai, so zen owns the zen family (identity, tools, 1M ladder, codec) and ai
owns every other model + /v1/models. The frozen wire sequence is updated.

Billing for zen* moves from ai's in-handler metering (skipped — zen claims
before ai's beego catch-all) to zen's own Gate+Meter wired to the same
commerce metering client the edge gate uses — ONE billing source for zen*,
never double-billed, never free. Granularity is org/project/user mirroring
the edge gate: home org (principal.BillingOrg) pays, project
(principal.ValidatedProject) scopes spend caps, user is attributed. An admin
acting in another org bills their own home org.

metering.Usage now carries a typed money.Amount (native 18-dp USD — the
finance ledger's own precision) as the canonical debit; Record passes it
straight to finance with NO flooring to cents or micros, so an exact
per-token cost from zen debits exactly. Legacy AmountCents/AmountMicros
reconstruct the same Amount for older callers. See hip-00NN.

Bumps hanzoai/zen v1.0.0 -> v1.1.0 (additive API: TenantResolver, ctx-aware
Meter/Gate, Tenant.BillingOrg/Project, Usage.RequestID).
2026-07-13 15:39:38 -07:00
hanzo-dev 1f25b417e5 fix(code): disable zen5-ultra (403s), Fable tier falls through to zen5-pro; bump ai v1.806.12->v1.806.13
zen5-ultra routes to anthropic-claude-opus-4.8, which 403s on this account.
Drop the ultra backend chain from the Fable tier so it resolves to zen5-pro
(the working heavy-reasoning tier) instead. Picks up ai v1.806.13:
upstream-429 surfacing (typed apiError, status reaches client) + the
Anthropic thinking-budget -> upstream reasoning-vocabulary adapter
(glm max|high, openai low|med|high) so deep CC thinking reaches the deep
tier and invalid vocab no longer gets GLM-5.2 429'd. See hip-00NN.
2026-07-13 14:47:11 -07:00
hanzo-dev c7c5c96a79 chore(deps): bump hanzoai/money v0.2.0 -> v0.2.1 (honest 18-dp decimal floor) 2026-07-13 12:46:06 -07:00
antje c99ea4afd4 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	go.mod
2026-07-13 12:34:25 -07:00
hanzo-dev 9fedec0c33 merge: git-native feature + de-gitea → main
# Conflicts:
#	go.mod
#	go.sum
2026-07-13 12:22:40 -07:00
hanzo-dev d317c8f4dd chore: de-gitea — scrub branding/docs, keep legal+provider 2026-07-13 11:54:03 -07:00
antje 6ed3b15685 Merge commit '615eceec' 2026-07-13 11:19:05 -07:00
antje 3909afd435 merge: restore force-push-clobbered lineage — CLI device login + gpu --studio-dir, atto-USD money, datastore debrand, deps
origin/main was rewritten (tracker/git work force-pushed over ~13 shipped
commits). This merge reunites both histories; go.mod resolved as the
highest-version union + go mod tidy.
2026-07-13 11:19:05 -07:00
antje 2fa5262532 refactor(money): delegate exact-money machinery to shared hanzoai/money + hanzoai/decimal
The big.Int 18-decimal fixed-point implementation lives ONCE in the shared
packages; cloud/clients/money becomes the thin policy layer that pins the
credit unit to 18-decimal USD. Finance/treasury/admin call sites unchanged
in behavior — money, ledger, sqlstore, finance, admin-core tests green.
2026-07-13 11:13:59 -07:00
hanzo-dev 697dcf677a tracker: narrow Kind to {issue,pr,epic}; contract draws the three-plane boundary
Correct the taxonomy after confirming the real architecture: helpdesk tickets,
CMS content, ERP docs and knowledge entries are framework.DocType records (Hanzo
Base), and CRM Company/Contact/Opportunity is a bespoke relational store — a
DIFFERENT plane from the tracker. The tracker Issue is the engineering/project
work-item primitive, not the universal record.

- kinds narrowed to issue|pr|epic (drop deal/ticket/doc = domain records; drop
  task = the async plane, hanzoai/tasks). sources kept (legit work-item origins).
- contract.go rewritten to THREE orthogonal planes — tracker Issue (work items),
  framework.DocType (domain records), hanzoai/tasks (async execution) — and the
  thin one-directional seams (ExtRef links, Issue<->task), never embedding.
- Issue/IssueFilter/field docs + test seed aligned to the narrowed set.
2026-07-13 10:44:51 -07:00
hanzo-dev 3d860256d1 tracker(contract): the Hanzo work-item contract — ONE Issue, every surface a filter
Pin the alignment law as in-repo design source of truth (doc-file, like the
git package doc and tasks/CONTRACT.md): there is ONE work-item primitive — the
tracker Issue — and no second issue table. Every product surface (hanzo.team
board, git Issues/PRs tabs, CRM pipeline, helpdesk queue, CMS task list, an
agent's work) is a FILTER over the one table, never a parallel store.

Documents: the four immutable discriminators (Kind/Source/Repo/ExtRef) and
their orthogonality; the surface->filter mapping; the two tenancy roots (IAM
project = physical file; tracker Project = KEY-N team within it; git repo bound
by the Repo discriminator, a third thing); and the tasks seam — tracking
(intent+state) is not execution (durable async = hanzoai/tasks), composing in
exactly two directions (Issue->enqueue task, task->patch Issue board state),
never inverted.
2026-07-13 10:38:58 -07:00
hanzo-dev d4613a1a97 tracker: ONE polymorphic issue primitive — git/CRM/helpdesk/CMS/team all compose
Extend the tracker Issue with four immutable discriminators (Kind, Source,
Repo, ExtRef) so a project task, a git issue, a pull request, an epic, a CRM
deal and a helpdesk ticket are ALL the same row. Every product surface becomes
a FILTER over this one table — never a second tracker:

  - hanzo.team board  = ListIssues(Filter{})            (or {Status})
  - git repo Issues    = Filter{Repo, Kind:"issue"}
  - git repo PRs       = Filter{Repo, Kind:"pr"}
  - CRM pipeline       = Filter{Kind:"deal"}
  - helpdesk queue     = Filter{Kind:"ticket"}

store.go: additive kind/source/repo/ext_ref columns (DEFAULTed so existing
rows read as native team issues; idempotent ALTER for pre-spine DBs) +
org_repo / org_kind indexes; ListIssues takes IssueFilter (status+kind+repo+
source, each an optional bound predicate).

tracker.go: closed kinds/sources sets, normKind/normSource, issueFilter() from
?status=&kind=&repo=&source=; create accepts + validates the discriminators;
billing category stays the constant "issue" row, decoupled from work-item Kind.

Discriminators are identity — set once at Create, immutable on Update — so a
row never migrates between surfaces. Tests: TestIssuePolymorphicSpine proves
the filter slices + defaults + cross-repo source view.
2026-07-13 10:32:00 -07:00
hanzo-dev b8d6d9ee0c git(ui): native Hanzo Git web UI — repo list, browse, blob, commits
The embedded git host gains its browser surface (ui.go + ui_templates.go),
server-rendered in the ONE cloud binary — the native replacement for the
standalone Gitea web app, so git.hanzo.ai can retire it. Routes at /git/*:
org repo list, repo home (branches/HEAD/tree/clone/README), tree browse, file
view (binary-aware), commit log. Reads the SAME org-scoped store + go-git object
storage the API uses; every page scoped to the IAM-validated X-Org-Id with the
path-vs-identity guard (no cross-tenant read). html/template auto-escaping is the
XSS boundary. Build+vet clean; all clients/git tests green.
2026-07-13 10:01:43 -07:00
hanzo-dev 4f00303e73 git(env): drop CLOUD_ prefix on embedded git SSH env — GIT_SSH_{HOST,ADDR,HOST_KEY}
The embedded Hanzo Git subsystem's env is now bare GIT_* (was CLOUD_GIT_*). Not
set in any prod deployment (code defaults), so the rename is behavior-neutral;
takes effect on next build. Cleaner + gitea-free naming.
2026-07-13 09:06:07 -07:00
hanzo-dev a272085354 fix(ci): bustable BuildKit cache ids — a poisoned module cache wedged the release
The release has been failing on:
  go: github.com/hanzoai/otel-collector@v0.144.10: unknown revision v0.144.10

That tag EXISTS on GitHub and resolves fine from a clean cache (verified:
go get github.com/hanzoai/otel-collector@v0.144.10 -> exit 0). The failure is
the BuildKit cache mount, not the pin: with no explicit id, BuildKit keys the
cache by target path alone, so a negative lookup recorded while the tag did not
yet exist is remembered FOREVER and there is no way to evict it.

Give every cache mount an explicit, bumpable id (cloud-gomod-v2 /
cloud-gobuild-v2). Bumping the suffix forces a cold cache. This unwedges the
release without touching the (correct) otel-collector pin, and gives us the
lever we were missing the next time a phantom pin poisons the cache.
2026-07-13 09:00:34 -07:00
hanzo-dev bc4e73f46a git: rebrand OUR embedded git = Hanzo Git; default provider = git (git.hanzo.ai)
Per directive: only OUR internal embedded git is rebranded (Hanzo Git), and the
default source provider is our own git.hanzo.ai ("git"). External providers
github/gitlab/bitbucket/gitea.com stay available as sources — gitea.com is fine
as an external source, we just never brand OUR host as Gitea.
2026-07-13 08:43:15 -07:00
hanzo-dev da909ee96b fix(edge): raise BodyLimit to 16 MiB — the 4 MiB default capped the context window
The context window is a BODY-SIZE fact, not only a model fact: a chat request
carries its whole prompt in the request body. zip/fiber defaults BodyLimit to
4 MiB (zip.go: 'if cfg.BodyLimit == 0 { cfg.BodyLimit = 4 << 20 }') and cloud
never set it — so a 1M-token prompt (~4.3 MB of JSON) was refused by fasthttp
BEFORE any handler ran, with the opaque 400 'Error when parsing request' that
reads like a malformed payload rather than a size cap.

Effect: the 1M-context routes were unreachable in practice. Measured on prod
(v1.799.10, direct to pod:8000, bypassing ingress+gateway):
  1.5 MB body -> 350,152 prompt tokens -> 200 (glm-5.2 correctly auto-rolled)
  2.0 MB body -> 466,148 prompt tokens -> 200
  4.0 MB+     -> 400 'Error when parsing request'
So the cascade logic was right all along; the transport silently capped it at
~900k tokens. 16 MiB gives a 1M-token prompt ~3.7x headroom. The edge is
authenticated + rate-limited and fasthttp streams, so this is not a memory
vector. Env GATEWAY_BODY_LIMIT, mirroring the GATEWAY_READ_BUFFER_SIZE
precedent (same class of bug one layer up: the 4 KiB header default).

Tests pin the invariant so it cannot silently regress to the framework default.
2026-07-13 08:26:57 -07:00
hanzo-dev 4d7f61327d chore(deps): re-pin ai v1.806.12 + iam v1.31.25 (de-mattn) — mattn/go-sqlite3 now 0 in go.sum AND build graph 2026-07-13 00:28:26 -07:00
hanzo-dev e8309cbcaa chore(deps): de-mattn — beego/v2 v2.4.2 + xorm v1.4.1 (sqlite v0.3.0 already) + drop the mattn/go-sqlite3 replace (go list -deps mattn=0) 2026-07-12 23:45:47 -07:00
antje 615eceec9c merge: karma storefront loop — published Asset → listing image; product.created → ecom render; Campaign→product integrity
# Conflicts:
#	build.go
#	go.mod
#	go.sum
2026-07-12 23:33:27 -07:00
hanzo-dev aab2e3298b content: reverse storefront loop (product.created → ecom render) + Campaign→product integrity
Closes the Studio→CMS→Commerce loop the other direction. The forward edge
(storefront.go) publishes a rendered Asset onto the product image; this adds the
REVERSE — a new catalog product triggers its ecom render — plus the join-key
integrity gate. Decomplected over the COMMERCE event stream so commerce and content
never import each other.

Reverse loop (B):
- events: SubjectProductCreated/Updated ("commerce.product.*", on the COMMERCE
  stream) + PublishProductCreated/Updated on the existing events.Publisher (nil-safe
  no-op when NATS is absent, mirroring the order publishers).
- commerce product REST: publishProductEvents middleware fires the catalog event after
  a successful create/replace, reading the process Publisher off the gin context
  exactly like the order publishers — a total no-op when unwired.
- clients/catalogsync: in-process consumer subsystem on the COMMERCE stream mapping
  product.created → content.EnsureCatalogAsset (design == slug). Inert until
  CLOUD_COMMERCE_NATS_URL names the NATS carrying the events.
- content.EnsureCatalogAsset: product slug → ONE ecom Asset draft, idempotent (skip if
  a non-archived ecom asset already exists) and quiet (errNotConfigured / lane not
  installed = skip, never an error loop).

Integrity (C):
- Storefront gains ProductExists (reusing the same commerce S2S seam, X-Org-Id-pinned);
  enforceCatalogRefs before_save on Campaign.product fails closed on a dangling handle,
  validates only on set/change, and skips when commerce is unconfigured or erroring.
  Campaign-only on purpose: Asset.design is authored render-first (before the product
  exists), so gating it would reject the loop's first step.

Wired after content in subsystems.Wire() (frozen order test updated). Tests cover the
EnsureCatalogAsset idempotency/skip paths, the Campaign dangling-handle rejection, the
ProductExists S2S wire, the catalog event envelope + no-op, the REST middleware
classification, and the consumer dispatch ACK/NAK semantics.
2026-07-12 23:13:07 -07:00
antjeandGitHub 77b1e1a4f1 refactor(datastore): cloud on hanzo-ds/go — kill datastore-go/v2 (#279)
swap the 3 remaining driver call sites (audit_mirror, commerce/db/
datastore, o11y/event_ingest) hanzoai/datastore-go/v2 -> hanzo-ds/go,
and take o11y v1.5.26 (fully driver-clean). cloud graph now has zero
hanzoai/datastore-* — the driver's one home is hanzo-ds/go.
2026-07-12 23:02:12 -07:00
hanzo-dev 3d93b17e79 deps: bump ai v1.806.9->v1.806.10 + fix phantom o11y v1.5.23->v1.5.25
- ai v1.806.10: gofumpt-clean CI (unblocks ai release pipeline) +
  deepseek-r1-distill context parity + swarm datastore standardization
  (hanzo-ds/go v1.0.1, datastore-go v2.47.2).
- o11y v1.5.23 was a PHANTOM pin (tag never published; graph jumps
  1.5.21 -> 1.5.25). Cloud only built via warm CI module cache; a fresh
  resolve failed 'unknown revision v1.5.23'. Repin to the real latest
  v1.5.25. Verified: go build ./cmd/cloud = exit 0.
2026-07-12 22:58:00 -07:00
antje 427e781149 feat(cli): hanzo login defaults to the RFC 8628 device flow — QR + link, approve from any device
The ONE interactive login: the CLI mints a device+user code from IAM
(hanzo.id /v1/iam/oauth/device, client hanzo-app — the first-party client
seeded with device_code), renders the verification link as text and a
terminal QR, and polls /v1/iam/oauth/token until approved. No password
ever touches the terminal; works headless (GPU boxes, ssh). Same endpoints
and client as hanzo-dev's live device flow, so one server-side config
serves both. --username/--password-stdin keep the password grant for
automation; --token unchanged.
2026-07-12 22:56:18 -07:00
antje dec8130df5 fix(cli): gpu connect detects Apple Silicon — report chip + unified memory
detectGPUs only probed nvidia-smi, so a Metal/MPS box registered as
CPU-only in the fleet. On darwin/arm64 report the Apple chip as one GPU
with hw.memsize unified memory, MiB-formatted like nvidia-smi.
2026-07-12 22:51:45 -07:00
antje ae4b6eb06b feat(cli): hanzo gpu connect --studio-dir supervises the local render backend
The gpu-jobs claim loop renders on the LOCAL studio server (127.0.0.1:8188);
naming a checkout makes connect own that server's lifecycle too — launch from
the venv, health-probe /system_stats, one grace re-check, free the port and
relaunch on death or hang. Replaces the GB10 watchdog script and hand-rolled
systemd units: the hanzo CLI is the one way a BYO box joins the fleet, render
backend included. --daemon bakes the flag into hanzo-gpu.service.
2026-07-12 22:23:42 -07:00
antjeandGitHub 99f6209a80 chore(o11y): consume Datastore() accessor — o11y v1.5.23 (#276)
renames the store call site DatastoreDB() -> Datastore() (redundant DB
dropped) and bumps hanzoai/o11y v1.5.19 -> v1.5.23, which also completes
the clickhouse->datastore debrand indirects and pins a valid module zip
(v1.5.21/.22 zips were case-collision-invalid; .23 verified downloadable).
CGO_ENABLED=0 go build ./... green.
2026-07-12 22:04:40 -07:00
antje a9337fcbcd feat(finance): exact 18-decimal (atto-USD) money — kill cents flooring
The double-entry ledger stored money as int64 cents, so every sub-cent AI
charge was floored to 0 (free AI) and every call skimmed the fractional
cent on rounding. Money is now EXACT to 18 decimals end to end.

- clients/money: new immutable big.Int Amount in atto-USD (1e-18) — the
  EVM/ERC-20 uint256 unit, so the off-chain ledger and an on-chain credit
  token are the SAME value with no boundary rounding. No float, no deps.
- ledger core + SQLite adapter: Posting/Entry/Balance int64 → money.Amount;
  amounts stored as TEXT (atto overflows SQLite INTEGER past ~$9.20) with an
  O(1) running-balance column and a one-time cents→atto ×1e16 rebuild
  migration (existing prefunds carry over exactly on first open). Anchor
  ComputeRoot hashes exact atto.
- finance wallet / types seam / Formance adapter / admin grant+deposit /
  metering / treasury threaded through. Treasury reserve-fund facade stays
  int64-cents (revenue-share is cents-granular).
- ai debit path (v1.806.6) emits exact decimal-USD; wireFinance parses it to
  atto. Balance READ stays coarse cents (gates a >0 threshold only). Grant
  response returns balanceAtto so a sub-cent debit is visible.

Pin ai v1.806.3 → v1.806.6 (exact nano-USD billing; datastore-go/v2 registers
driver 'datastore', no clash with cloud's hanzo-ds/go 'clickhouse').
2026-07-12 21:59:09 -07:00
hanzo-dev 7c7cb8f06c content: Storefront edge — published catalog Asset → storefront product image
A published content.Asset (kind in ecom/product/lifestyle, design==product slug)
materializes its S3 URL into the org's Hanzo Commerce store Listing headerImage —
the runtime display layer karma.style already reads (GET /v1/store/:store/listing).
Replaces the build-time studio->S3->library.json->sync-*->site pipeline with ONE
publish-edge side effect, decomplected exactly like the social Distributor.

- storefront.go: Storefront seam + commerce S2S impl (Bearer COMMERCE_SERVICE_TOKEN
  + X-Org-Id over clients/commerceinproc; store via GET /v1/store/current; upsert
  PUT /v1/store/:id/listing/:design). Fail-closed (not_configured) with no token.
- content.go: wire sf edge at Mount; fire StorefrontPublish on the published edge;
  TransitionResult.Storefront.
- Tenant-scoped by IAM org; assets referenced by S3 URL; no sync scripts.

Tests (CGO_ENABLED=0): gate, url resolution, transition side-effect, fail-closed,
and the real commerce S2S wire incl. X-Org-Id tenant pinning.
2026-07-12 21:53:29 -07:00
hanzo-dev fc87e6c0d0 deps: iam v1.31.24 (tenancy: Org.Parent + recursive authz; signup/signin IP capture); datastore-go v2.47.0
datastore-go was pinned at v2.47.1 — a version the module proxy cannot resolve (the /v2 module path does not exist at that tag), so cloud did not build from a clean cache. v2.47.0 resolves.

Claude-Session: https://claude.ai/code/session_01RFrWpXc1BsqfrFYMbyDusJ
2026-07-12 21:44:10 -07:00
z 47b0a36b8b chore(deps): bump hanzoai/ai v1.806.8 -> v1.806.9 (>256k glm-5.2 auto-rolls to DS4-Pro) 2026-07-12 20:21:22 -07:00
hanzo-dev 7991397320 cloud: clickhouse->datastore debrand (zero clickhouse .go, datastore:// DSN, drop legacy CLICKHOUSE env) + pin o11y v1.5.21 (restore /v1/sentry, one datastore driver) 2026-07-12 20:08:39 -07:00
z e977047a2f chore(deps): bump hanzoai/ai v1.806.7 -> v1.806.8 (ONE-rule billing subject) 2026-07-12 20:03:12 -07:00
zandhanzo-dev a932834b64 refactor(billing): mirror ai ONE rule — billing subject is always the org
billingSubject(org,name) = org (lowercased), always. Deletes the personalBillingOrgs
and orgBillingOrgs allowlist parsers (PERSONAL_BILLING_ORGS / ORG_BILLING_ORGS). Keeps
the console billing view in lockstep with ai/object.BillingSubject so the view and the
gateway gate scope to the SAME subject. Tests prove the killed envs are ignored.

Needs go.mod bump github.com/hanzoai/ai -> v1.806.8 (run in a module-resolving env).
2026-07-12 20:01:25 -07:00
zeekay a6ae479c32 revert: otel-collector v0.144.10 — v0.144.13 forced mock v0.14.3 whose renamed API breaks o11y v1.5.17 (cascade pending) 2026-07-12 19:46:48 -07:00
zeekay c73cc1ce4f deps: otel-collector v0.144.13 + hanzo-ds/mock v0.14.3 — chproto-free datastore stack 2026-07-12 19:44:41 -07:00
zeekay a6f1fa59d4 debrand: datastore-go v2.47.2 (0 clickhouse/CH identifiers) + chproto->dsproto 2026-07-12 19:43:38 -07:00
zeekay 5ab750387a deps: datastore-go v2.47.1 — cloud go.mod/go.sum now 0 clickhouse + 0 signoz
datastore-go's transport moved to hanzo-ds/native, so the ClickHouse/ch-go
indirect is pruned from cloud entirely. No cloud package imports a ClickHouse
or signoz module.
2026-07-12 19:31:48 -07:00
hanzo-dev 300eda4bd9 deps: adopt otel-collector v0.144.10 (hanzo-ds datastore drivers) — drop signoz + -hanzo.0 replaces
- otel-collector v0.144.8 (+ replace => v0.144.8-hanzo.0) -> clean require v0.144.10
- dropped: replace SigNoz/signoz-otel-collector => hanzoai/signoz-otel-collector
  and the // indirect SigNoz/signoz-otel-collector require
- cloud now pulls hanzo-ds/{go,native,mock} transitively; 0 signoz in go.mod/go.sum;
  no cloud package imports a ClickHouse driver directly (build green)
Residual: one ClickHouse/ch-go // indirect graph line from a transitive dep's
go.mod (not compiled in) — clears when that dep drops it.
2026-07-12 19:19:54 -07:00
hanzo-dev 39889ae44d deps: bump hanzoai/ai v1.806.6 -> v1.806.7
Measured DO context windows + config-only resolver (the old table refused
deepseek-v4-pro at 131072) + oversized-prompt reroute to the 1M model.
This is what lifts hanzo code claude to the full 1M context.
2026-07-12 18:44:29 -07:00
hanzo-dev fcc506b038 debrand(o11y): drop signoz — query plane reads o11y_traces.distributed_o11y_index_v3
The o11y read plane was renamed signoz_* -> o11y_* (databases, tables, query
identifiers) at the source; the ClickHouse cutover migration
(o11y/deploy/clickhouse/migrations/0001_rename_signoz_to_o11y.sql) renames the
physical objects data-preservingly. Cloud's eval telemetry queried the OLD
physical name directly, so it would break post-cutover AND leaked the brand:

  eval/metrics.go: spanTable signoz_traces.distributed_signoz_index_v3
                        ->   o11y_traces.distributed_o11y_index_v3

Plus every prose/table-name reference across the in-repo o11y read plane and
commerce OTel bootstrap: signoz_traces/signoz_logs -> o11y_traces/o11y_logs,
SigNoz-fork provenance comments -> o11y/upstream. No source brand leaks remain.

DEPLOY: apply 0001_rename_signoz_to_o11y.sql during the collector cutover window
so prod ClickHouse objects match the new identifiers before this image serves.

Residual: go.mod keeps a REDIRECTED github.com/SigNoz/signoz-otel-collector key
(replace -> our fork; no SigNoz code fetched) — pulled transitively by
hanzoai/otel-collector's own go.mod; purge belongs to that fork's rename.
2026-07-12 17:43:34 -07:00
hanzo-dev 2e488527f2 feat(billing): adopt ai v1.806.6 — thread exact USD debit through the usage recorder
ai v1.806.6's UsageEvent replaced Cents(int64) with USD (exact decimal string,
atto-precise) so a sub-cent AI call bills precisely upstream. Adapt cloud's recorder:
- types.UsageInput gains USD (supersedes Cents when set).
- build.go SetUsageRecorder forwards u.USD.
- finance.RecordUsage rounds USD -> cents at the ledger boundary (usdToCents,
  round-half-up via math/big, no float). The local finance ledger is cents-denominated,
  so sub-cent floors to 0 exactly as the prior int64-Cents contract did — the atto path
  is commerce, not this store. Money-safety locked by TestUsdToCents.

Also carries ai v1.806.6's glm-5.2 1M context window (already in v1.806.5). Build ./...
green, finance tests pass.
2026-07-12 17:17:02 -07:00
hanzo-dev 78373130da fix(code): default hanzo code to glm-5.2, not the reserved word best
Claude Code treats `best` (like opus/sonnet/haiku) as a reserved model
alias and rewrites it to a claude-* id. api.hanzo.ai does not serve those
claude-* ids (a request 403s — see anthropicWire), so `hanzo code claude`
on the default `best` died at session start. Default to glm-5.2: the
stable GLM-5.2-class 1M-ctx frontier, a concrete catalog id CC passes
through unchanged; the backend still cascades on rate-limit / down.
2026-07-12 17:05:02 -07:00
hanzo-dev 7f4b166b49 fix(billing): bill the SUBJECT's wallet — a personal account gets a personal balance
The ledger already scoped correctly (org = which books, subject = which wallet in them), but both hooks passed the org for BOTH, collapsing every member onto the tenant's pool wallet. Since every signup lives in 'hanzo', a brand-new $0 account read HANZO's balance and sailed through the gate: we were enforcing our own wallet, not theirs. Now the gate reads, and usage debits, the subject ai already resolves — a person => their own wallet (personal plan), an org-owned application/service key => the org's account. That is the product: sign up as yourself with personal billing, then stand up an org whose users are your customers (Organization.Parent + AdministersOrg in hanzoai/iam). The invariant that must never break: the gate READ and the usage DEBIT key on the SAME wallet, or spend outruns the balance that admitted it — both use subject, keep them together. Also unpins o11y v1.5.16, whose tag was re-pointed upstream so its hash no longer matches go.sum (build fails verification); v1.5.17 is the unpoisoned tag.

Claude-Session: https://claude.ai/code/session_01RFrWpXc1BsqfrFYMbyDusJ
2026-07-12 16:52:20 -07:00
hanzo-dev a24d6324b8 refactor(team): rip the last Huly refs — zero Huly anywhere
- tracker.go: prose 'Huly/Svelte' -> 'prior Svelte'
- model.json: Slack-mapping wire attrs hulyChannel/hulyChannelClass ->
  teamChannel/teamChannelClass (seed-model data, NOT Go-referenced; valid JSON).
The clients/team/*.go debrand already landed on main. This closes it out.

NOTE: teamChannel wire IDs are new-workspace seed data; the deployed front
(front:v0.7.391) still speaks hulyChannel for Slack channel mapping, so a lockstep
front rebuild is needed for Slack mapping on NEW workspaces — flagged, not silent.
2026-07-12 16:50:11 -07:00
hanzo-dev 1ec565c4a5 fix(billing): bill the SUBJECT's wallet — a personal account gets a personal balance
The ledger already scoped correctly (org = which books, subject = which wallet in them), but both hooks passed the org for BOTH, collapsing every member onto the tenant's pool wallet. Since every signup lives in 'hanzo', a brand-new $0 account read HANZO's balance and sailed through the gate: we were enforcing our own wallet, not theirs. Now the gate reads, and usage debits, the subject ai already resolves — a person => their own wallet (personal plan), an org-owned application/service key => the org's account. That is the product: sign up as yourself with personal billing, then stand up an org whose users are your customers (Organization.Parent + AdministersOrg in hanzoai/iam). The invariant that must never break: the gate READ and the usage DEBIT key on the SAME wallet, or spend outruns the balance that admitted it — both use subject, keep them together. Also unpins o11y v1.5.16, whose tag was re-pointed upstream so its hash no longer matches go.sum (build fails verification); v1.5.17 is the unpoisoned tag.

Claude-Session: https://claude.ai/code/session_01RFrWpXc1BsqfrFYMbyDusJ
2026-07-12 16:48:12 -07:00
hanzo-dev 2e775ed572 chore(deps): bump ai v1.806.4 -> v1.806.5 (GLM-5.x 1M context window)
Deployed glm-5.2 was capped at 16384 tokens (the context_length_util.go
fallback) — long Claude Code sessions 402'd. ai v1.806.5 sets glm-5.x to a
1M window with 131072 modern fallback (commit d248ff98).
2026-07-12 16:30:39 -07:00
hanzo-dev 2cd359fdb2 cli: pin CC tier slots to fixed zen5 aliases
The four Claude Code tier slots (Haiku/Sonnet/Opus/Fable) are a fixed
zen5-* capability contract, decoupled from the resolved main model id.
Previously OPUS tracked ANTHROPIC_MODEL, coupling the tier to the main
choice; now OPUS=zen5-pro and FABLE=zen5-ultra are stable.

Decomplects which-tier from which-model: the tier->alias map is fixed
in the client; the alias->upstream map lives in models.yaml. Swap an
upstream (GLM -> Qwen 3.6 -> a future frontier) and every SDK/CLI/CC
integration keeps working unchanged. zen5-ultra carries its own backend
fallback chain (zen5-ultra -> zen5-pro -> zen5 -> zen5-flash) so the
Fable tier degrades gracefully if the premium upstream is unavailable.
2026-07-12 16:18:08 -07:00
hanzo-dev cde95fae20 fix(code): pin all Claude Code model slots to zen5 aliases
claude code's subagents, permission classifier, and /compact default to
built-in claude-* model ids (claude-haiku-*, claude-opus-*, claude-sonnet-*).
api.hanzo.ai does not serve those ids — a request 403s, which:
  - kills the classifier ('auto mode cannot determine safety of Bash')
  - kills every subagent (the 403 that dead-ended session cff690fc)
  - leaves /compact to run on a non-1M model -> 262145 > 262144 -> unresumable

anthropicWire now pins every CC tier slot to a served zen5 alias (the
Hanzo-standard mapping of CC tiers onto top OSS models resold via DO GenAI):
  ANTHROPIC_MODEL             = <model> (default best -> zen5/glm-5.2)
  ANTHROPIC_SMALL_FAST_MODEL  = zen5-flash  (DeepSeek-4 Flash, classifier)
  ANTHROPIC_DEFAULT_HAIKU     = zen5-flash
  ANTHROPIC_DEFAULT_SONNET    = zen5       (GLM-5.2)
  ANTHROPIC_DEFAULT_OPUS      = <model>
  ANTHROPIC_DEFAULT_FABLE     = zen5-pro  (DeepSeek-V4 Pro)

Live-proven: zen5/zen5-flash/zen5-pro/zen5-ultra all return 200 on the account;
only the raw claude-* ids 403. tests: TestAnthropicWirePinsZen5Tiers +
TestAnthropicWireExplicitModel lock the mapping and forbid raw claude-*.
2026-07-12 16:18:08 -07:00
hanzo-dev 720f58d599 refactor(team): drop Huly branding — no brand prefix, no vendor name
Debrand the team subsystem (our fork is Hanzo Team, not Huly):
- prose/comments: Huly -> Team / the platform
- hulyName() -> personName() (internal Person.name formatter)
- HULY_MODEL_VERSION -> MODEL_VERSION (no brand prefix at all, per the naming rule)
- TestHulyName -> TestPersonName

model.json WIRE class IDs (slack:class:SlackChannelMapping_hulyChannel, attrs
hulyChannel/hulyChannelClass) are deliberately UNTOUCHED: the deployed front
(ghcr.io/hanzoai/front:v0.7.391) speaks them, so renaming needs a lockstep front
rebuild — tracked separately, not a silent prod break.

Build + team tests green.
2026-07-12 16:18:08 -07:00
hanzo-dev 4a3ef817e6 feat(billing): honor ORG_BILLING_ORGS in console billing subject + bump ai
Bump hanzoai/ai to the ORG_BILLING_ORGS build (v1.806.3 + the allowlist), so the
gateway's BillingSubject promotes an allowlisted org (e.g. hanzo) to ONE shared
pool. Mirror the same scoped override in the console billing BFF (billingSubject)
so the console view scopes to the SAME subject the gate reads. Default empty =
zero behavior change.

Also refresh the stale hanzoai/o11y v1.5.16 go.mod checksum in go.sum (metadata
only; the zip/code hash was already correct — the readonly build passes), which
a moved tag had left inconsistent and which blocked every cloud build.
2026-07-12 16:18:08 -07:00
hanzo-dev 04a8bc55ab feat(cli): hanzo code defaults to the virtual best model
Change defaultCodeModel glm-5.2 -> best so `hanzo code claude` (no model arg)
auto-routes to the best-available coding model by quality and cascades on
rate-limit / out-of-credit / down (server-side, controllers/failover.go).
Explicit overrides still work (`hanzo code claude glm5.2`); the fuzzy resolver
validates `best` against /v1/models, where it is a real listed catalog entry.
2026-07-12 16:17:39 -07:00
zeekayandClaude Opus 4.8 57c96c251f datastore: cloud source → hanzoai/datastore-go/v2 (registers "datastore", not "clickhouse")
Completes the datastore standardization for cloud's own source: audit_mirror,
commerce/db, o11y/event_ingest now import github.com/hanzoai/datastore-go/v2
(the rebranded fork registering the UNIQUE sql driver name "datastore") instead
of the interim github.com/hanzo-ds/go (which registers "clickhouse" and collides
with upstream). Bumps ai v1.806.3 → v1.806.4 (its object/datastore.go likewise on
datastore-go/v2). cmd/cloud now links ZERO upstream ClickHouse/clickhouse-go/v2.
Remaining hanzo-ds/go is transitive via o11y/pkg/datastoremetrics — needs an o11y
release built off datastore-go/v2 (v1.5.16 tag currently resolves to the interim
hanzo-ds/go variant).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 11:21:51 -07:00
antjeandGitHub 1594706f5a fix(commerce): service-token S2S auth beats the IAM scope gate (completions 500 → clean 402/allow) (#273)
An in-process metering/billing dispatch (cloud → commerce via buildMeteringClient)
carries the verified COMMERCE_SERVICE_TOKEN *and* an X-Org-Id header (to select the
tenant namespace). The X-Org-Id makes IAMTokenRequired stamp iam_authenticated=true
with ZERO permissions (an S2S call carries no X-User-Permissions), so on an
Admin-masked billing route (/v1/billing/{balance,tier,usage}) TokenRequired's IAM
branch ran first, hasScope(0, Admin)=false → 403 "IAM principal lacks required
permission scope". The service token was never consulted; the balance read / usage
debit failed and the completions edge rendered that internal failure as a client 500.

Fix: in TokenRequired, check the service-token branch BEFORE the IAM branch. A request
bearing the KMS-sourced service token is the trusted platform S2S caller and must
authenticate as the service, never be subjected to the per-user IAM scope gate — even
when it also carries X-Org-Id. The two branches' logic is byte-identical; only the
order changed.

- Does NOT loosen the gate for external callers: a real IAM user never holds the
  service token, so they still hit the IAM branch and are masked-gated exactly as
  before (TestTokenRequired_UserIAMScopeGateUnchanged, IAMBranchEnforcesMasks).
- Prepaid gate stays fail-closed: funded → allowed, unfunded → clean 402
  insufficient_balance (TestInProcMeteringDispatch_ServiceTokenAuthPath drives the
  real chain over commerceinproc with finance NOT co-resident).
- The service token is KMS/env-sourced and never logged.

The 500-render itself lives in the hanzoai/ai dep (it serves /v1/chat/completions and
turns the internal balance/debit failure into a 500); once the cloud-side dispatch no
longer 403s, that failure no longer occurs.
2026-07-12 11:19:48 -07:00
antje 480e7eb51c chore(deps): ai v1.806.3 (fast-500 nil-CruSession guard) + o11y v1.5.16 (datastore debrand)
ai v1.806.3: nil-CruSession guard on both session funnels + auth-before-autoroute
(routers/base.go; fixes fast 500 on unauthenticated session read).
o11y v1.5.16: datastore debrand (DatastoreDB()). luxfi pins unchanged.
2026-07-12 10:40:56 -07:00
antje f1ed51ea06 Merge remote-tracking branch 'origin/chore/audit-datastore-env' into chore/datastore-roll 2026-07-12 10:37:08 -07:00
antje c4350b2b0f fix(finance): bill the org billing ACCOUNT (pool) for both read + debit, not the per-user subject
The ai gate read the account pool but debited the per-user wallet (read-subject != debit-subject),
so a funded account gated but never depleted. Key BOTH the balance read and the usage debit on the org
(its default-account pool wallet) in the wireFinance hook, so a funded account gates AND meters
consistently. Fund accounts, not users.
2026-07-12 10:18:38 -07:00
66761a3600 feat(cron): ONE durable platform cron on the tasks engine — retire every ticker and k8s CronJob (#268)
* feat(cron): ONE durable platform cron on the tasks engine — retire every ticker and k8s CronJob

clients/cron replaces the k8s CronJob fleet AND the commerce sweep ticker
with durable schedules on the shared embedded hanzoai/tasks engine
(v1.50.0 — the release whose sweeper actually fires on the sharded store).
The engine owns time; runs are durable workflows visible in the Tasks
console (/_/tasks, tasks.hanzo.ai) under the CRON_ORG shard (default
hanzo), namespace default, queue cloud-cron.

Entries are DATA in universe git: a ConfigMap labeled
cron.hanzo.ai/enabled="true" carries schedule + EITHER job.yaml (a
batchv1 Job manifest run to completion with Forbid concurrency, entry
label, TTL self-reap) OR poke.json ({url,method,bearerEnv,timeout} —
bearer resolved from THIS process's KMS-synced env, never stored).
A reconcile workflow — itself a durable schedule (*/5) plus one boot
pass — diffs ConfigMaps against cron-prefixed schedules: upserts are
drift-gated (rewriting resets the fire anchor), deletes never touch
foreign ids. Fire-time activities re-read their ConfigMap, so payload
edits apply next tick.

The commerce auto-recharge sweeper (clients/commerce/sweep.go) is
deleted; its 15m POST /v1/billing/auto-recharge/run-all becomes the
cron-billing-autorecharge poke entry — same request, same token, one
cron system.

Tests (-race): TestPokeEndToEnd + TestJobEndToEnd drive the FULL durable
path against a real embedded engine (org-shard schedule → trigger →
loopback worker → activity → completed run in the org shard — the exact
console-visibility + dispatch-routing the design stands on);
TestReconcileConverges pins upsert/delete/foreign-id/anchor semantics;
TestParseEntry pins the ConfigMap contract. commerce + subsystems suites
green.

Claude-Session: https://claude.ai/code/session_016yg7GPhYdWCh9vpp4HEwLZ

* feat(tasks): UI at /tasks (no /_/) + org/project/user-native gate (tasks v1.51.0)

The Tasks console moves to console.hanzo.ai/tasks + tasks.hanzo.ai/tasks —
the /_/ prefix is gone. Subsystem routes mount before the console SPA
catch-all, so /tasks wins the path and every other console route keeps the
SPA fallback. The gate now threads the FULL validated identity — org,
project (X-Project-Id, minted by identity middleware from the validated
project claim), user, email — into the engine via tasks v1.51.0's
WithIdentity; convention: project ↔ tasks namespace inside the org shard.
ZAP plane unchanged (loopback ungated + IAM-gated cluster listener).

The embedded SPA bundle is rebuilt with base /tasks/ in a follow-up sync
commit (hanzoai/admin builds it; clients/tasks/ui/dist is the embed).

Claude-Session: https://claude.ai/code/session_016yg7GPhYdWCh9vpp4HEwLZ

* ci: retrigger (Actions dropped the push event)

* chore(tasks/ui): sync SPA rebuilt with base /tasks/ (hanzoai/admin@448b189)

Claude-Session: https://claude.ai/code/session_016yg7GPhYdWCh9vpp4HEwLZ

---------

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-12 09:53:06 -07:00
antje 85af41bb35 chore(audit): dual-read CLOUD_AUDIT_DATASTORE_* with legacy CLOUD_AUDIT_CLICKHOUSE_* fallback
Prefer the datastore-named env keys, fall back to the clickhouse-named legacy
keys so a rollout mid-flight (new binary / old manifest, or vice versa) never
drops the audit OLAP mirror connection. Code-only; no roll.
2026-07-12 09:48:18 -07:00
antje 557bebe577 fix(deps): ai v1.805.11 (datastore fork) — kill clickhouse double-registration; native commerce read + per-subject finance deposit
ai v1.805.10 pulled upstream ClickHouse/clickhouse-go via ai/object, which double-registered
the clickhouse driver against cloud's hanzo-ds/go fork → boot panic (CI smoke caught it). ai v1.805.11
merges the clickhouse→hanzo-ds purge, so the upstream driver is no longer imported (verified: 0 in the
build, binary boots clean). Also: commerce.BalanceCents (native in-proc balance read — backfill + cockpit
read real balances, no commerce.inproc) and POST /v1/admin/finance/deposit (fund a SPECIFIC wallet:
hanzo/z, not just the pool).
2026-07-12 09:47:50 -07:00
hanzo-dev c824ae56b1 chore(deps): bump hanzoai/ai v1.805.10 → v1.805.13 (Anthropic /v1/messages tools translation) 2026-07-12 09:28:30 -07:00
hanzo-dev 4b6c0bbaf6 merge: hanzo code CLI launcher (claude/codex/dev on any Hanzo cloud model) 2026-07-12 09:25:20 -07:00
hanzo-dev 8546343592 merge: LOW-1 — ml/provisioning balance Gate keys on home org (principal.Payer), matching the debit (Red fast-follow) 2026-07-12 09:08:47 -07:00
hanzo-dev 9e6832ca33 fix(billing): ml + provisioning pre-create GATE keys on home org (principal.Payer), matching the paired debit
LOW-1 fast-follow (Red). In these two create-handlers the DEBIT correctly keyed on the
HOME org (principal.Payer(c)) but the pre-create balance GATE still keyed on the
EFFECTIVE org — the two swapped only the meter, not the gate (their Gate signature had
gained a projectValidated arg, so the class swap missed them). Effect: a SuperAdmin
masquerading into a victim org was balance-gated on the VICTIM's funds while the debit
landed on admin's ledger (money already correct; an availability/consistency nit).

Fix: swap the Gate org arg -> principal.Payer(c) in both handlers, restoring the
invariant the other ~8 resource meters hold — gate + debit BOTH key on home; data scope
(namespace/project) stays effective.

Test: TestResourceMeter_GateKeysOnPayerForMasqueradingAdmin resolves principal.Payer(c)
from a masquerade ctx (X-User-Owner=admin, X-Org-Id=victim -> admin) and asserts the
recCommerce balance check keyed on admin (home), never victim. go build ./...=0 (links),
gofmt clean, root ResourceMeter suite + clients/ml green (provisioning store tests are the
pre-existing CLOUD_KMS_MASTER_KEY_REF env gate, orthogonal).
2026-07-12 09:07:58 -07:00
zeekayandClaude Opus 4.8 d5c4f491f2 git: re-integrate SSH + ZAP + client-less REST push onto the generics framework
Ports the prior Git-over-SSH + ZAP transport + client-less REST push work
(commit 95ba376, written against the old *svc-receiver service) onto the
current cloud.Service[state] generics framework on main. A port, not a
rewrite: the DRY architecture is preserved — ONE control-plane core, ONE
git pack path, three thin transports (REST, ZAP, SSH) over them.

Handler pattern adapted: every `*svc` method became a free function taking
`s *cloud.Service[state]` (matching git.go's existing create/list/get/del),
`cloud.TenantStore` → `cloud.NewOrgStore`, methods → `cloud.Handle(s, fn)`
route registration, `s.log`→`s.Log`, `s.stores/storage/keys/ssh`→`s.State.*`,
`tenant(c)`→`org(c)` (principal.Org). Package-scoped helpers (storeFor,
provision, recordUsage, refState, cloneURL, sshURL, firePushBuilds, session)
are the current framework's free-function forms.

New files:
- core.go   coreCreate/List/Get/Delete/Usage — the ONE transport-agnostic
            control-plane impl; REST + ZAP both call it.
- pack.go   serve{Upload,Receive}Pack + ssh{Upload,Receive}Pack — the ONE git
            pack code path both smart-HTTP and SSH drive (io.Reader/Writer,
            transport-agnostic); readerOnly guards the SSH channel write side.
- ssh.go    golang.org/x/crypto/ssh server. Host key from CLOUD_GIT_SSH_HOST_KEY
            (KMS env) or on-disk ed25519 generated+persisted 0600.
            PublicKeyCallback resolves key→(org,user) by SHA256 fingerprint,
            fail CLOSED. Session accepts only git-upload-pack/git-receive-pack,
            enforces path-org == key-org. Listen CLOUD_GIT_SSH_ADDR (:2222).
- keystore.go  fingerprint-indexed global SSH public-key registry (public keys
               only — nothing to hash).
- keys.go   POST/GET/DELETE /v1/git/keys.
- push.go   POST /v1/git/repos/:name/push — build tree+commit from posted
            utf-8/base64 files, advance ref, create-on-first-push (composes the
            ONE provision), fire the SAME firePushBuilds hook receive-pack fires,
            return commit + cloneUrl + sshUrl.
- zap.go    git/zap/{createRepo,listRepos,getRepo,deleteRepo,usage} envelope
            adapters over the SAME core funcs, on the shared /zap plane. No
            second ZAP server, no gRPC.

Modified:
- git.go       repoView/toView gain sshUrl; state holds sshHost/ssh/keys; Mount
               opens the keystore + starts the SSH listener; routes() wires
               push+keys+ZAP; Shutdown stops SSH + closes the keystore. REST
               control-plane handlers are now thin adapters over core.go.
- smart_http.go  uploadPack/receivePack drive the shared pack.go path via
                 resolvePackRepo; firePushBuilds + session helpers retained.

Tests (go test ./clients/git/... — 16 pass): SSH key register accept/reject +
cross-org reject + end-to-end SSH clone+push over an in-process listener; a
zapface WS round-trip proving the ZAP path hits the SAME core as REST; REST
push create/update/build-hook, first-push provisioning, validation, and
cross-org isolation. tenant_isolation_test + git_test bind the SSH listener to
an ephemeral loopback port so tests never collide on :2222.

CGO_ENABLED=0 GOWORK=off go test ./clients/git/... passes; go build clean;
gofmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 09:05:56 -07:00
antje f7555233ad feat(finance): route edge meter + admin grant onto the finance ledger, + commerce→finance backfill
metering.Client fetchAvailable/Record now resolve finance.Current() first (micros→cents
ceil so metered_ai's AmountMicros debit is never dropped), commerce HTTP only when finance is
not co-resident. admin ApplyGrant credits the finance wallet (fixes the broken commerce.inproc
grant path). New DepositInput.Ref makes deposits idempotent; finance.MigrateOrg + SuperAdmin
POST /v1/admin/finance/backfill?org= carry each org's commerce balance into its finance wallet
exactly once at cutover. All money — balance, usage, deposit — now flows through the ONE per-org
double-entry ledger.

Verified: build + go vet clean; finance/metering/admin/core tests pass (micros fold, ref
idempotency, backfill exactly-once, zero-HTTP-when-co-resident all locked by tests).
2026-07-12 09:02:36 -07:00
antje ce177d2042 feat(finance): per-org double-entry prepaid wallet ledger (finance.Client) + ai native hooks
New clients/finance: ONE lightweight SQLite file per customer (orgs/<org>/finance.db),
double-entry wallet on the treasury/ledger core — deposit=funding→wallet, usage=wallet→revenue,
balanced within the org file, idempotent. Orthogonal money interface types.FinanceClient
(finance.Client, mirrors commerce.Client); NOT bolted onto the Deps bag and NO pick/RPC/disabled
cruft — a narrow published seam (finance.Publish/Current) every money consumer resolves.

BuildDeps.wireFinance constructs+publishes it and installs the ai router's typed
BalanceReader/UsageRecorder hooks (ai v1.805.10) so the prepaid gate reads balance + debits usage
by a DIRECT in-proc call, no HTTP. Rob-Pike small interfaces between systems, not a god-struct.

Remaining before cutover: edge meter + admin grant onto finance.Current(), backfill commerce
balances into <org>:wallet, then build/deploy/prefund/drop BALANCE_EXEMPT_USERS.
2026-07-12 09:01:38 -07:00
antjeandGitHub 33a9027b34 chore(deps): pin all hanzoai/luxfi deps to clean v1 OSS tags (#269)
Drop pseudo-versions and +incompatible for our own modules; everything now
resolves to a published v1 semver tag (v1 only, per policy):

  goa            v0.0.0-pseudo        -> v1.0.0   (first tag)
  sign           v0.0.0-pseudo        -> v1.0.0   (v1 path; 36 bogus v2 tags dropped)
  captable       v0.0.0-pseudo        -> v1.0.0   (first tag)
  pubsub-go      v1.0.1-0.pseudo      -> v1.53.0  (existing clean tag; HEAD==v1.53.0)
  gochimp3       v0.0.0-pseudo        -> v1.0.0   (module path fixed: Elandiro -> hanzoai)
  goauthorizenet v0.0.0-pseudo        -> v1.0.0   (go.mod added)
  sendgrid-go    v3.4.2-...+incompat  -> v1.3.0   (go.mod added; v1 path)
  luxfi/keys     v1.2.2 (force-moved) -> v1.4.0   (clean current tag)

CGO_ENABLED=0 go build ./... green.
2026-07-12 00:58:01 -07:00
cd800e8d66 fix(release): race-safe version assignment — unjam the cloud release lane (#271)
Root cause of the jammed lane (failed releases + phantom tags like v1.786.221):
the push step published the racy v<X.Y.Z> tag BEFORE it was confirmed free, and
the tag step hard-failed when a concurrent release had claimed that number
(computed 220, already tagged → 'rejected, already exists'). Result: a pushed
image with no matching git tag, mutable :vX clobbering, and dead releases.

Fix — decouple the immutable artifact from the versioned release:
- Push step publishes ONLY the unique sha-<sha7> (+ floating latest); never the
  racy version tag.
- Tag step assigns the next FREE v<X.Y.Z> ATOMICALLY: recompute fresh, retag the
  proven sha-image → :vX/:X.Y.Z/:X.Y via imagetools (metadata-only, byte-identical),
  git-tag it, and RETRY on collision (the git-tag push is the serialization point —
  a loser recomputes). Concurrent releases each grab a distinct free number.
- Compute-step collision is now a non-fatal hint (Tag step owns assignment).
- notify-universe rolls the Tag step's FINAL version, not the compute guess.

Preserves the invariant: git tag vX ⇔ image :vX pushed + smoke-passed. Logic
unit-tested locally (free-find + collision-skip). YAML valid.

Co-authored-by: zeekay <ai@hanzo.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 23:58:40 -07:00
hanzo-dev 77a30d1f99 merge: admin-org predicate (owner==admin) + home-org billing (debit/gate on X-User-Owner, data scope on X-Org-Id) — admin masquerade bills the admin ledger 2026-07-11 23:41:30 -07:00
hanzo-dev 82375164c0 billing: debit+gate on HOME org (X-User-Owner); embedded-commerce sudo = owner==admin
THE billing fix (B): split 'who pays' (HOME org, X-User-Owner) from 'whose data'
(EFFECTIVE org, X-Org-Id), which the old code conflated onto one org. A platform
SuperAdmin masquerading into another org now spends from the admin ledger; data
scope stays on the acted-on org.

- clients/principal: Owner(c) (X-User-Owner, bounded+cloned), BillingOrg(c) (home w/
  effective fallback, Validated-gated), Payer(c) (bare-string for the in-handler meters).
- middleware_identity: mint X-User-Owner=home for every validated principal (before the
  admin org-switch) + strip it on ingress (authorityHeaders) so a client can't forge who pays.
- middleware_billing: identityFromCtx keys User+Org (balance check AND debit) on BillingOrg.
- 11 resource-meter live sites (visor/s3/security/platform-run/functions/ml/provisioning/
  bots/tracker/automations): Gate+Meter+MeterUsage bill principal.Payer(c) (home); Org stays
  effective for the namespace. Background/reconcile paths (a.Org/r.Org/b.Org, meterRun) and
  the studio_render 'org' param bill the RESOURCE's own org — FLAGGED (see Red handoff).
- metered_ai + types: ChatRequest/EmbedRequest gain BillingOrg; meteredAI gate+record key on
  billedOrg(BillingOrg,Org); threaded principal.Payer(c) through the code engine (Synthesize/
  Embed) so internal RAG bills home too. Inner AI call keeps req.Org (data scope).

Part A (embedded commerce, clients/commerce mirror of the commerce repo): drop the spoofable
IsSuperAdmin boolean; auth.IAMClaims.IsSuperAdmin() gates homeOrg()=="admin" (HomeOrg from
X-User-Owner ?: Owner); iammiddleware reads X-User-Owner->HomeOrg (Owner stays effective);
EdgeAuth strips+mints X-User-Owner before the ?org override, stops minting X-User-IsSuperAdmin;
all .SuperAdmin() call sites -> .IsSuperAdmin(). Org-scoped IsAdmin untouched.

Tests (green): TestIdentityFromCtx_AdminMasqueradeBillsHomeOrg (owner=admin+effective=victim ->
debit on admin, data on victim), TestBilledOrg, TestMeteredAI_AdminMasqueradeBillsHomeOrg
(end-to-end debit user=admin), vendored TestIAMClaims_IsSuperAdmin masquerade+anti-escalation,
EdgeAuth strips forged X-User-Owner. go build ./...=0, go vet=0.

Rollout: pre-gateway (no X-User-Owner) billing falls back to effective (home==effective for a
normal caller; masquerade fails CLOSED). Deploy gateway (mints X-User-Owner) first.
2026-07-11 23:36:25 -07:00
hanzo-dev 06cad7ce9d cli(code): fail loudly on unresolved models; declare the Hanzo provider for codex
Three defects found by testing a real completion instead of --version:
  - an unreachable catalog silently passed the typed id straight through, so an
    agent booted on a model that does not exist and failed opaquely mid-session;
    resolution is now strict.
  - a stale ANTHROPIC_API_KEY in the shell outranks ANTHROPIC_AUTH_TOKEN and
    silently wins; it is cleared for the claude wire.
  - codex ignores OPENAI_BASE_URL and talks to chatgpt.com unless a provider is
    declared; declare Hanzo and select it (wire_api=responses, per upstream).
2026-07-11 23:34:33 -07:00
hanzo-dev 68978ed174 docs(identity): last 'global admin' prose -> SuperAdmin (zero repo-wide) 2026-07-11 23:22:37 -07:00
hanzo-dev 88610b6b8b refactor(identity): ONE fact per predicate — SuperAdmin = admin-org membership, OrgAdmin = own-org admin
Cloud was the odd one out: it minted SuperAdmin from TWO signals
(claims.IsAdmin && owner == adminOrg) while IAM's canonical User.IsSuperAdmin() is
just user.Owner == conf.AdminOrg, and IsOrgAdmin folded SuperAdmin into itself. Two
predicates that each meant one-and-a-half things.

Decomplected — two orthogonal facts, one predicate each:
  IsSuperAdmin = owner == adminOrg          (platform sudo; the SAME equality IAM uses)
  IsOrgAdmin   = the IAM isAdmin bit         (admin of one's OWN org; implies nothing about super)
A gate admitting either now writes IsSuperAdmin(c) || IsOrgAdmin(c) explicitly, so the
superset is visible AT the gate, not hidden inside a predicate. GuardScoped already did.

The admin org holds ONLY SuperAdmins (provisioned in, never promoted), so membership IS
the fact — the isAdmin bit is the orthogonal org scope, never a super gate. The KMS
machine-principal exclusion STAYS (a real guard: an admin-org machine token must never
be super). Test renamed + strengthened to lock the one predicate. Build ./... clean,
identity/admin/principal suites all green, KMS-machine exclusion tests pass.
2026-07-11 23:21:17 -07:00
87a950b377 security: golang-jwt/jwt v3 → v4 (no-patch high; v3 line abandoned) (#270)
The 4 commerce auth/token files imported the vulnerable golang-jwt/jwt v3.2.2
(dependabot high, no v3 patch — fix is off-v3). v4.5.2 (already present) keeps
StandardClaims/Valid()/int64 dates → API-compatible, zero code ripple vs the
v5 RegisteredClaims rewrite. Dropped the orphan v3 require. Full build + commerce
auth tests green. (dgrijalva/jwt-go remains transitive-only = unreachable.)

Co-authored-by: zeekay <ai@hanzo.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 23:19:39 -07:00
antjeandGitHub e11dfe19d1 chore(deps): bump embedded iam to v1.31.22 (#266)
Advances the in-process IAM embed (clients/iam, HIP-0106) from the
v1.31.20 pre-release pin (31a798fb, #117) to the published v1.31.22 tag
(387c4a60). Brings the project claim mint + default-project seed (#120)
into cloud's embedded IAM so console admin surfaces match standalone
hanzo.id, already on v1.31.22.

API-compatible: iamserver.InitEmbed and object.Project CRUD unchanged
(iam go.mod hash identical across versions), no clients/iam adaptation.
CGO_ENABLED=0 go build ./... green; clients/iam + clients/platform tests pass.
2026-07-11 22:51:42 -07:00
33de017fd2 cloud: bump zip v1.6.0 + licensing v0.1.4 — off the deleted App.Mount, one-way surface (#267)
zip v1.6.0 deleted the deprecated/redundant surface (App.Mount/Route/ModuleFn/
UseFiber). cloud is off App.Mount (#257); licensing v0.1.4 migrated its one
App.Mount call. Build 0, framework tests green.

Co-authored-by: zeekay <ai@hanzo.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:51:03 -07:00
hanzo-dev 6ddf02a7a4 cli: hanzo code — launch claude/codex/dev on any Hanzo cloud model
One way to start any coding agent on any Hanzo model: endpoint, credential and
model are injected, so nothing is exported by hand. Two wires cover all three
agents (claude speaks Anthropic; codex and @hanzo/dev, a codex fork, speak
OpenAI), model ids resolve fuzzily (glm5.2 -> glm-5.2), and agents run full-auto
unless --safe. Config gains apiKey; the key the rest of the toolchain already
keeps in ~/.hanzo/config.json is read (never written) so one key serves every tool.
2026-07-11 22:36:34 -07:00
zandantje e3b1fa604d chore(datastore): purge clickhouse-go/datastore-go → hanzo-ds + debrand (Group A, v3)
Rebased on current origin/main (e3c544de, +4: #262/#258/#261/prepaid-wiring).
- ai v1.805.9 (= v1.805.7 hardened prepaid gate + purge) — NOT the money-regressing v1.805.8
- o11y v1.5.15, otel-collector v0.144.8-hanzo.0
- datastore clients → hanzo-ds/go; ClickhouseDB()→DatastoreDB(); clickhouse→datastore rip
- go.mod clickhouse-free; go list -deps ./cmd/cloud clickhouse=0; 0 BALANCE_EXEMPT in linked ai
- boots clean fail-closed (single sql.Register); luxfi pins as live; wire/DSN/env contracts preserved
2026-07-11 22:25:32 -07:00
7f0666a260 docs(projects,tracker): mark the two Project structs as deliberately distinct (#264)
WS4 audit flagged projects.Project and tracker.Project as duplicate types.
They are not: projects.Project is the Slug-keyed deployable site (one global
projects.db, org column); tracker.Project is a KEY-prefixed Linear-style issue
team living in a per-(org,project) tracker.db WITHIN one of those sites. Only
the universal storage-row skeleton (id/org/name/description/timestamps) is
shared; the natural keys carry different semantics (DNS label vs issue-ID
prefix) and the field sets are not subsets. Record the distinction on each type
so the finding is not re-litigated. Comment-only; no API or behavior change.

Co-authored-by: Hanzo <dev@hanzo.ai>
2026-07-11 22:13:06 -07:00
e3c544de2b feat(billing): thread validated-project bool through ResourceMeter.Gate (#262)
principal.ValidatedProject(c) now flows through ResourceMeter.Gate into
metering.AuthInput.ProjectValidated, so resource-creation caps harden
consistently with the edge BillingGate. Removes the hardcoded
ProjectValidated:false at resource_billing.go.

- Gate signature gains projectValidated bool (adjacent to project, mirroring
  principal.ValidatedProject's return + AuthInput's field order).
- Every *zip.Ctx caller passes principal.ValidatedProject(c); the three
  no-principal/client-body paths (metered_ai LLM decorator, background agent
  run, content studio in.Project) pass false — unvalidated stays soft, never
  fabricated true.
- Meter/Record path unchanged: Usage carries no ProjectValidated; cap
  enforcement is Gate-only, so threading it there would be dead code.

Stays SOFT in prod today: ValidatedProject is true only for a validated NAMED
project, and IAM seeds none yet, so no org has a project claim. No behavior
change now; named-project caps auto-harden per-org as IAM seeds them.

Co-authored-by: Hanzo <dev@hanzo.ai>
2026-07-11 21:44:51 -07:00
1c3206dcbf feat(platform): collapse buildType surface to pack|dockerfile (one way) (#261)
The build path (buildFrontendCmd) already chose its BuildKit frontend on
dockerfile presence alone: an explicit Dockerfile → dockerfile.v0, otherwise
hanzoai/pack via gateway.v0. buildType never gated the build — it was stored
metadata whose closed set still advertised the retired nixpacks/buildpacks/
static strategies and defaulted git apps to nixpacks.

Collapse it to the one true surface:
- closed set is {pack, dockerfile}; git source defaults to pack, not nixpacks
- image source always yields buildType image (no build), forced by source, so a
  client can no longer stamp a git strategy on a prebuilt-image app
- Goa contract (design.go + generated openapi3.json/yaml) enum, description and
  examples track the same {pack, dockerfile, image} set

Adds TestBuildTypeSurface pinning the default (pack), the dockerfile escape
hatch, rejection of every retired strategy, and image-source forcing.

Co-authored-by: Hanzo <dev@hanzo.ai>
2026-07-11 21:44:48 -07:00
a75cfa2f2c refactor(identity): collapse hand-rolled org/project reads onto principal (#258)
Six identity reads re-derived org/project inline instead of going through
principal — the ONE accessor. Route them all through it so the trust
decision lives in exactly one place and cannot drift.

- analytics tenant(): delegate to principal.Org — fixes a retained-buffer
  bug (org keyed the cloud_usage ledger past request end as a zero-copy
  fasthttp view; principal.Org clones). Gate unchanged.
- git/security projectScope(): read via principal.Project; absent header
  and literal "default" both map to the un-suffixed default scope via
  principal.IsDefaultProject, keeping today's (org,project) key shape.
- admin core.ResolveScope / core.GuardScoped / me / core.EmitAudit: read
  org via principal.Org (validated principal + org), composed with the new
  principal.IsOrgAdmin predicate. Admin-org bucket kept local.

Co-authored-by: Hanzo <dev@hanzo.ai>
2026-07-11 21:44:08 -07:00
hanzo-dev 1d1b2fd396 feat(billing): prepaid AI gate — wire ai v1.805.7 to in-process commerce, no exempt
Pin hanzoai/ai v1.805.7 (exempt concept removed: no BALANCE_EXEMPT_USERS/KEYS, no
fail-open; balance-unverifiable now DENIES). BuildDeps sets aiobject.CommerceTransport
= commerceinproc.Transport() and self-configures the ai module's commerceEndpoint to the
in-process placeholder when commerce is co-resident, so the embedded ai router's balance
read + usage debit hit the raw commerce gin (service-token, no socket) — the SAME one
ledger the metering client bills — not the customer proxy that 401s a service token.
Every external inference is now prepaid-gated with zero exempt principals.

hanzo/z was a normal customer, never admin; exempting it leaked money. Closed.
2026-07-11 21:37:00 -07:00
ece1560d2b feat(platform): delegate project lifecycle to IAM (one source of truth) (#260)
Projects are now owned by Hanzo IAM (hanzo.id) as the org-scoped
(owner,name) resource. Platform REFERENCES that store instead of owning
one: apps still live under a project, but create/list/get/delete/exists
of the bare project delegate to IAM in-process.

- New ProjectStore port (projects.go) with an in-process iamProjects
  adapter over github.com/hanzoai/iam/object — no HTTP hop, and IAM's
  canonical *object.Project is used verbatim (no platform-local clone).
- Delete platform's project ownership: the Project struct, the
  platform_projects table, and Create/Get/GetByID/List/Update/Delete
  project store methods. DeleteProject is replaced by DeleteProjectApps
  (cascade of the app tree only; the project row is IAM's).
- Apps key on the IAM project NAME (platform_apps.project_id holds it);
  the internal proj_ id indirection is gone, so reconcile/preview/domains
  use app.ProjectID directly and drop their project lookups.
- run.go get-or-creates the default project via the ProjectStore, using
  principal.DefaultProject as the single source of truth for "default".
- tenant() gates on principal.Validated(c) (canonical), not a raw
  c.User()=="" whitespace-blind check.
- Tests: fake ProjectStore + TestProjectLifecycleDelegatesToIAM proving
  create/delete route through IAM and delete cascades platform's apps.

Co-authored-by: Hanzo <dev@hanzo.ai>
2026-07-11 21:01:24 -07:00
5267caa5a6 feat(identity): bind X-Project-Id from the validated project claim; harden per-project caps (#259)
SanitizeIdentity now mints X-Project-Id from the validated JWT `project` claim
(idClaims.mintedProject), exactly like X-Org-Id from `owner` — the raw client
X-Project-Id is never a source. Still checked non-foreign to the effective org, so
a global admin viewing another org drops their own-org project. Mirrors the edge
(iamauth.Claims.MintedProject), so the in-binary path binds the same header.

principal.ValidatedProject flips CONDITIONALLY: (project, true) only for a
validated, NON-default project claim — the signal a project-scoped spend cap uses
to HARD-enforce. The default project stays soft (IAM does not seed default
projects yet; a blanket true would wrongly hard-402 every org). Named project caps
auto-harden one org at a time as projects are seeded.

Tests: claim-sourced project binding + forged/override client X-Project-Id ignored
+ cross-org claim refused on admin org-switch + X-Org-Id forgery still blocked;
ValidatedProject claim-backed→(project,true), absent/default→soft, unvalidated→soft.

Follow-up (WS3, out of scope): resource_billing.go Gate hardcodes
ProjectValidated:false — resource-creation caps stay soft until it threads
principal.ValidatedProject.

Co-authored-by: Hanzo <dev@hanzo.ai>
2026-07-11 21:00:51 -07:00
hanzo-dev c0a0ec73e8 refactor(auth): rename GlobalAdmin -> SuperAdmin (SOC2/FedRAMP term)
Eliminate "global admin" terminology across the cloud repo in favor of the
standard SuperAdmin term. Pure naming refactor; behavior preserved.

Identifiers (commerce auth + middleware + root):
- (*auth.IAMClaims).GlobalAdmin() -> SuperAdmin() + all call sites
- IAMClaims.IsGlobalAdmin field -> IsSuperAdmin (JSON tag isGlobalAdmin
  -> isSuperAdmin; SuperAdmin() still honors owner=="admin", the canonical
  predicate, so behavior is preserved)
- edgeauth isGlobalAdmin() helper -> isSuperAdmin(); requireGlobalAdmin ->
  requireSuperAdmin; test-local globalAdmin vars -> superAdmin
- const HeaderUserIsGlobalAdmin -> HeaderUserIsSuperAdmin, value
  "X-User-IsGlobalAdmin" -> "X-User-IsSuperAdmin" (commerce-internal header:
  minted+stripped in edgeauth, read in iammiddleware; no external sender)
- auth/globaladmin_test.go -> auth/superadmin_test.go

clients/admin: the deprecated isGlobalAdmin JSON alias (sibling of the
canonical isSuperAdmin, same value) is REMOVED rather than renamed — a rename
would collide with the existing IsSuperAdmin field, and the alias was a
transitional back-compat scaffold for this very migration. Tautological
alias-equality assertions dropped; isSuperAdmin checks retained.

Prose: "global admin" / "global-admin" / "GLOBAL-ADMIN" / "GlobalAdmin" in
comments and docs (LLM.md, k8s manifests) rewritten to SuperAdmin, case-aware.

Left untouched: cloud gateway header X-User-IsAdmin; principal.IsSuperAdmin /
IsOrgAdmin; svcorg's DefaultNamespace (global) kind.

build ./... clean; vet clean; SuperAdmin behavior-locking tests pass
(commerce auth/edgeauth/iammiddleware/catalog/costs/checkout, admin
scope/money, principal, root identity). gofmt clean. Zero GlobalAdmin /
global-admin remaining in .go/.md/.yaml.
2026-07-11 18:20:58 -07:00
hanzo-dev cca930891e feat(admin): per-provider credit ledger + usage funding endpoints
Two SuperAdmin endpoints for multi-provider credit-management (the console renders
them; contract is authoritative):
  GET /v1/admin/providers/credit  -> [{provider, grant_cents, burn_cents,
      remaining_cents, runway_days, has_credit, is_paid_only}]
  GET /v1/admin/usage/funding?from&to -> [{provider, model, funding, tokens,
      cost_cents, requests}], funding in {credit,paid,paid_only,byo}

DRY: reuses the admin auth guard (core.Guard), finance.go's DO billing read (real
k grant, live remaining/burn/runway), and the ONE cloud_usage warehouse (no new
store). DO is the seeded real row; others land as keys arrive. Funding is
provider-level-derived in v1; the per-call split lands when the ai metering write
stamps a funding column (next commit). Envelope = core.OK {status,msg,data:[...]}.
Two-ledger model kept distinct: UPSTREAM provider credits here vs DOWNSTREAM Hanzo
customer billing (commerce). Pure logic TDD'd.
2026-07-11 17:24:20 -07:00
hanzo-dev 33ce0f29f0 refactor(principal): IsSuperAdmin + IsOrgAdmin — two named predicates, not bare c.IsAdmin()
Per the SuperAdmin convention (SOC2/FedRAMP; standard term SuperAdmin, NEVER
'global admin'): name the two admin scopes explicitly.
- principal.IsSuperAdmin(c) = c.IsAdmin() — platform sudo; SuperAdmin ⟺ owner==admin org
  (X-User-IsAdmin minted only for that identity).
- principal.IsOrgAdmin(c) = IsSuperAdmin(c) || X-User-IsOrgAdmin — admin of one's own org.
GuardScoped now reads IsSuperAdmin (fast-path) + IsOrgAdmin (scoped) instead of bare
c.IsAdmin()/OrgAdmin. Pure rename — no behavior change, identity suite green.
2026-07-11 17:22:13 -07:00
hanzo-dev cd888836a6 fix(deps): bump hanzoai/ai v1.805.5 -> v1.805.6 (failover credit-cascade)
v1.805.6 makes credit/quota exhaustion + agreement gates (402/403/insufficient_quota)
cascade to the next provider in the fallback chain — Phase 2 of multi-provider
credit-management + fixes the do-ai->anthropic opus fallback.
2026-07-11 17:15:52 -07:00
hanzo-dev e23416cdb1 admin: close same-tenant over-visibility gap in GuardScoped
GuardScoped admitted ANY validated org member to their own org's admin
panels (/v1/admin/{overview,orgs,users,usage,analytics,me,bases}), not
just org-admins: SanitizeIdentity discarded the JWT org-level isAdmin bit
for non-admin-org principals (it only minted the GLOBAL X-User-IsAdmin
when owner==adminOrg), so GuardScoped's fallback (validated User+Org) let
a plain member through.

Mint a new, unforgeable X-User-IsOrgAdmin signal and require it:

- middleware_identity.go: add X-User-IsOrgAdmin to authorityHeaders
  (stripped on every ingress request, so a client can never forge it),
  and mint it for any validated principal with claims.IsAdmin &&
  !isKMSMachinePrincipal — covering both a global admin and an org-admin,
  owner-scoped and machine-excluded.
- clients/principal: add OrgAdmin(c) = IsAdmin() OR X-User-IsOrgAdmin.
- clients/admin/core.GuardScoped: require principal.OrgAdmin(c) in
  addition to validated User+Org; a validated non-admin member now gets
  the same 403 as an unvalidated caller. Guard (global-only) untouched.

Tests: orgAdminHdr now carries the bit; add TestScope_MemberWithoutOrg
AdminDenied (member without the bit is 403 on every scoped panel) and
TestSanitizeIdentity_OrgAdminHeader (org-admin gets the bit not global,
member/machine get neither, forged bit stripped on bearer + anon paths).
2026-07-11 17:11:53 -07:00
hanzo-dev 7b8134b882 Merge branch 'moneysafe' 2026-07-11 16:58:15 -07:00
hanzo-dev 9aeb3312e7 admin: fail-closed grant durability + deposit idempotency (no unaudited/double money)
Finding 3 (grant durability — ARCHITECT: FAIL-CLOSED). EmitAudit is a silent
no-op when State.AuditStore == nil, so on a no-audit-store deployment a
SuperAdmin grant moved REAL money with NO durable cloud-side before/after record.

Decision: FAIL-CLOSED. Grants are a real-money op and the audit store is always
present in any real deployment — audit_serve.go REQUIRES a persistent data dir
for the trail (hard boot error otherwise), and a nil store arises ONLY from the
explicit CLOUD_AUDIT_DISABLED dev opt-out. So ApplyGrant now refuses the grant
with 503 BEFORE any money moves when AuditStore == nil: no unaudited money move.
The check sits after org validation and before the deposit, so the existing
validation errors (amount/cap/unknown-org) are unaffected.

Residual (documented, not regressed): a post-deposit Append failure (store
present but the write errors) keeps the money-moved success response — the money
DID land, and failing it would both misreport the grant and, absent idempotency,
invite a double-credit retry. It stays a loud log, backstopped by the
request-level AuditTrail middleware which independently records the request and
fails the request CLOSED on its own write error (serve.go / audit_middleware.go).

Finding 4 (deposit double-credit on retry — WIRED). commerce.Deposit posted
/v1/billing/deposit with no idempotency key, so a commit-then-timeout (cloud's
15s client) could drive an operator double-credit on retry. The commerce backend
DOES enforce idempotency: POST /v1/billing/deposit reads X-Idempotency-Key,
scoped billing-deposit:<subject> — a completed key REPLAYS the receipt, an
in-flight key 409s, an absent key is legitimately additive (never dedup by
amount). Verified in ~/work/hanzo/commerce/api/billing/deposit.go.

Fix: thread a DETERMINISTIC key from the grant. Deposit/post gained an
idempotencyKey arg sent as X-Idempotency-Key. ApplyGrant derives it as
sha256(org|amountCents|currency|source|nonce) where nonce is the operator-supplied
Idempotency-Key — so a retried grant carrying the SAME nonce dedupes at commerce
while two DISTINCT grants (even same org+amount) never collide, and a nonce reused
for a DIFFERENT amount still lands (dedup can never silently DROP a real grant).
No nonce => no key => additive default preserved; we do NOT fabricate a
content-only key (it would wrongly dedupe two legitimate identical comps).
Residual (cross-service, frontend): effective end-to-end once the operator
console sends an Idempotency-Key per grant attempt, reused verbatim on retry.

Finding 2 (GuardScoped over-visibility — NEEDS CROSS-SERVICE, not fixed here).
GuardScoped admits any validated principal with non-empty c.User()+c.Org().
SanitizeIdentity (middleware_identity.go) mints X-User-Id for EVERY validated org
member and mints the admin signal X-User-IsAdmin ONLY for a GLOBAL admin
(owner==adminOrg) — it DISCARDS the JWT org-level isAdmin bit for non-admin-org
principals. So a regular member's sanitized identity is byte-identical to an
org-admin's, and GuardScoped admits regular members to their own org's admin
panels (same-tenant over-visibility of financials + user directory; NOT
cross-tenant — ResolveScope pins to c.Org()). The finding is REAL, but the
prescribed fix (require org-admin) CANNOT be done in clients/admin/*: the
org-admin signal is not minted, and middleware_identity.go is read-only in this
scope. Requiring the only admin signal we have (c.IsAdmin(), GLOBAL) would break
the deliberate, test-locked org-scoped tier (scope_test.go admits org-admins).
Correct cross-service fix: in SanitizeIdentity's `case owner != ""` branch, mint
X-User-IsOrgAdmin=true when claims.IsAdmin (owner safe, non-KMS), add it to
authorityHeaders (stripped on ingress so unforgeable), then GuardScoped requires
c.IsAdmin() || X-User-IsOrgAdmin=="true". Left for the middleware owner.

Tests: TestGrantCredit_NilAuditStoreFailsClosed (503, no deposit, balance
unchanged); TestGrantCredit_IdempotencyKeyForwarded (same nonce=>same key,
distinct nonce=>distinct key, no nonce=>no key). Existing money must-pass tests
(TestGrantCredit_DepositLandsAndAudited, TestSuspendReactivate_*, TestAdminAudit_*)
still pass unchanged.
2026-07-11 16:54:33 -07:00
hanzo-dev 13a77b7f91 admin: decomplect partial-commerce reporting on /overview (one partial pattern)
Finding 1 (undercount masked as healthy). core.OrgMoney swallowed the
Commerce.Spend/Credits errors to a silent zero, and /overview derived the
"commerce" source freshness from a SINGLE probe org — so if commerce was down
for 40 of 41 orgs the fleet spend/credits totals were an undercount while the
source still read "healthy". revenue.go / finance.go already report this
correctly via a `partial` flag + the core.ErrPartialRevenue sentinel.

Fix (decomplect to ONE partial pattern, not a second one):
- OrgMoney now returns (spend, credits int64, ok bool); ok is false when the
  spend OR credits read failed — 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 Ready() still distinguishes not-configured.
- overview folds ANY per-org OrgMoney failure into the commerce source as
  degraded, reusing core.ErrPartialRevenue, and derives freshness from the SAME
  per-org reads the totals fold instead of a single probe org.

orgs is a per-ROW panel (OrgRow[] via OKList; NO sources[] channel): a failed
read degrades THAT org's row to an honest zero — there is no aggregate total to
mislabel, so no change beyond the signature. The customer per-row/detail call
sites are best-effort and unchanged in behavior.

Test: TestOverview_CommercePartialOnPerOrgError — commerce 500s for one org of
two; the commerce source reports not-ok with an error while the healthy org's
spend still contributes (honest partial total, not a hard panel fail).
2026-07-11 16:54:05 -07:00
hanzo-dev 0f1b2a5fe5 test(graph): align unreachable-upstream tests + doc to the code's honest-empty-200 (not 502) 2026-07-11 16:36:30 -07:00
hanzo-dev 9bd1c0ec3b chore(kms): delete dead clients/kms/replication — a lone test with no production source
The subpackage contained only replication_test.go referencing an undefined Producer
(no NewProducer/BackupOnce/etc. anywhere), so it never compiled and failed go vet.
Zero importers. Pike: the best code is no code.
2026-07-11 16:33:39 -07:00
hanzo-dev ed95fef1c7 style(admin): gofmt clients/admin/revenue/revenue.go (new split file) 2026-07-11 16:19:17 -07:00
hanzo-dev 47dd91c6d2 Merge branch 'splitA' into splitmerge 2026-07-11 15:37:47 -07:00
hanzo-dev bd0709b723 admin: rewire Mount onto core + domains, retire in-package handlers
Rewrite the top-level admin package as the Mount + aggregator only. state ->
core.State (exported fields), and every top-level handler + helper is retyped to
*cloud.Service[core.State] and calls core.* for the shared kernel:
  - routes() registers the org-scoped panels (me/overview/orgs/users/usage/
    analytics/bases) behind core.GuardScoped and the platform reads
    (roles/applications/products/compute/o11y/sync + flags/waitlist) behind
    core.Guard, then delegates to audit/customer/revenue/finance Routes().
  - analytics.go keeps only the analytics-specific derivation (growth/retention/
    churn/active/LTV) folding over the core activity model + spend series.
  - o11y/compute/bases/waitlist/flags/types retyped to core.State + core.*.

Delete the now-moved audit.go/customers.go/grants.go/revenue.go/finance.go/
scope.go (their handlers live in the domain packages; their shared helpers in
core). doTokenFromEnv moves to admin config; iamAuditQuery moves to the audit
package.

Move tests with their code: audit store tests -> audit package; grantTag test ->
core; finance pure-math tests -> finance package. The full-mount integration
tests (admin/scope/cockpit/finance-aggregation) stay in the admin package and
now drive the real routes() so the harness mirrors Mount exactly. Behavior,
routes, tenant scoping and every assertion unchanged.
2026-07-11 15:35:31 -07:00
hanzo-dev 5efe43770a admin: extract shared core kernel + carve audit/customer/revenue/finance domains
Introduce clients/admin/core as the subsystem's shared kernel — the State
struct (upstream clients + adminOrg + audit store) and the one-copy business
primitives every admin surface composes: the two-tier gate (Guard/GuardScoped),
the /v1 envelope writers (OK/OKList/OKRaw/Fail), CallerCreds, the tenant-scope
predicate (TenantScope/ResolveScope/ScopedOrgs/Descendants), the IAM fan-in
(ListOrgs/OrgMoney/FindOrg/Display/SrcOf/SourceStatus), the ONE credit-write path
(ApplyGrant + EmitAudit + grantTag/grantNote/CreditRequest) and the fleet
activity/time-series model shared by analytics and revenue
(CustActivity/TxnPoint/SeriesPoint/FleetActivity/SpendSeries + bucket helpers).

Carve one package per handler domain over that kernel:
  - audit    -> /v1/admin/audit{,/verify} (store-backed + IAM fallback)
  - customer -> /v1/admin/customers* + /v1/admin/grants (list/detail/credit/
                suspend/reactivate/grants ledger)
  - revenue  -> /v1/admin/revenue
  - finance  -> /v1/admin/finance (+ the pure ComputeFinance derivation)

Each domain imports core for the kernel and shared logic; no business logic is
duplicated. Routes are registered per-domain via <domain>.Routes(app, s).
2026-07-11 15:35:03 -07:00
hanzo-dev 55feb39624 feat(bots): GET /v1/bots + POST /v1/bots/:runId/stop — org-scoped list+stop over the bot-gateway
Completes the bots surface (launch -> launch+list+stop). Both are thin,
org-scoped proxies onto the in-cluster bot-gateway (BOT_GATEWAY_URL, the same
server-side knob clients/bot uses), carrying the caller's validated tenant
context (X-Org-Id pinned to principal.Org, never a request param).

- GET /v1/bots normalizes the gateway's session rows into
  {runId,task,surface,status,sessionUrl,startedAt}, deriving sessionUrl here
  (the one place a session URL is built). Honest-empty {"bots":[]} on an
  unconfigured/unreachable gateway, a non-2xx, or an undecodable body -- never 5xx.
- POST /v1/bots/:runId/stop returns {runId,status:stopped}; a run the caller's
  org does not own is 404; an unreachable gateway is a clean 502.

Both require a validated principal so org-scoping can't ride a forged X-Org-Id.
Hermetic list+stop tests against a fake gateway assert normalization, sessionUrl
derivation, caller-org scoping, honest-empty, and 404/502 paths.
2026-07-11 15:15:56 -07:00
hanzo-dev 526455b098 refactor(admin): extract iam upstream client into clients/admin/iam (finishes the upstream-client layer) 2026-07-11 14:53:10 -07:00
hanzo-dev 4fbbbccd18 fix(deps): bump hanzoai/ai v1.805.4 -> v1.805.5 (enforceBalanceGate fail-open)
v1.805.5 adds the controller-side balance-gate fail-open (BALANCE_GATE_FAIL_OPEN_ON_ERROR)
on top of v1.805.4's nil-guard, so a broken/misconfigured Commerce billing backend
degrades to allowed-but-ungated instead of 500-ing every authenticated chat for
non-exempt users. Together they let the CR drop the commerce.hanzo.svc:8001 bridge
and keep commerceEndpoint unset per the in-process design.
2026-07-11 13:21:45 -07:00
hanzo-dev 04bda86558 build(cloud): bump hanzoai/o11y v1.5.12 -> v1.5.13 — Hanzo Sentry /v1/sentry backend routes live 2026-07-11 12:39:20 -07:00
hanzo-dev 1abb1676a5 merge: Hanzo Sentry /v1/sentry cloud edge — mount + DSN-ingest gate exemption (Red-GO) 2026-07-11 12:37:25 -07:00
hanzo-dev 456a60be22 fix(deps): bump hanzoai/ai v1.805.3 -> v1.805.4 (nil balanceGate 500)
ai v1.805.4 guards the nil balanceGate deref in resolveBillingKey that returned a
bare HTTP 500 on EVERY authenticated /v1 request (models, chat/completions,
messages) whenever commerceEndpoint is unconfigured (balance enforcement disabled).
RateLimitFilter calls resolveBillingKey before BalanceGateFilter's own nil guard,
so the embedded AI subsystem crashed every authed request while anonymous requests
(no token) 401'd correctly. Fail-open: a disabled billing subsystem resolves no
billing subject and never crashes a request.
2026-07-11 12:27:38 -07:00
hanzo-dev 14c89a2565 feat(o11y): expose /v1/sentry edge mount + DSN-ingest gate exemption
Cloud edge for Hanzo Sentry (the /v1/sentry product face served by the embedded
o11y runtime):

- mountSentry registers the /v1/sentry/* wildcard, forwarding to the SAME gated
  runtime handler the /v1/o11y wildcard uses (one runtime, two path families; no
  path rewrite — the Sentry routes are literal /v1/sentry/... in the runtime).
- gate() now exempts the DSN-authenticated Sentry ingest writes (isSentryIngestPath:
  POST /v1/sentry/{project}/envelope|store/, tight method+prefix+suffix match) from
  the principal gate — the runtime authenticates the DSN key, not a Hanzo session —
  while EVERY Sentry read/write API stays principal-gated (no cross-tenant leak).

Uses only the existing o11y runtime-handler API, so it builds against the current
pinned o11y (v1.5.12) and is inert (404) until the o11y dep is bumped to a build
that carries the /v1/sentry routes.

FOLLOW-ONS (coordinated separately):
- Bump the hanzoai/o11y dep to the tag containing the /v1/sentry routes to activate.
- Gateway needs a byte-identical isErrorIngestPath sibling for POST
  /v1/sentry/{project}/envelope|store/ so the tokenless DSN ingest is not 403'd at
  the edge (do NOT touch ~/work/hanzo/gateway here).

Test: TestGateExemptsSentryIngestButGatesReads (ingest exempt, reads/writes gated).
2026-07-11 11:58:40 -07:00
hanzo-dev e2c21e6d9a fix(social): honest at-rest comment — token NOT yet encrypted (RED HIGH)
The Account.Token comment claimed the store is SQLCipher-encrypted and 'keeps
it encrypted at rest'. False: openStore uses cek.Open, which today runs the
no-key plaintext fallback (real WithRawKey-from-KMS lands with the connect
flow). Token column is empty today, so no plaintext secret ships.
2026-07-11 11:36:16 -07:00
hanzo-dev 40c8b59d85 merge(social): publish edge + scheduler parity (fail-closed provider seam) 2026-07-11 11:35:11 -07:00
hanzo-dev 2370bd3acf refactor(admin): complete first-principles upstream clients — money.Cents + digitalocean
Finishes the batch-F rework (the extraction landed in 850d4c0; this is the second,
first-principles half that the earlier cherry-pick stopped short of):

- clients/admin/money — type Cents int64; the unit lives in the type, so
  ConsumedCents/MRRCents/CreditsCents collapse to Consumed/MRR/Credits. int64 casts
  only at the operator-contract boundary (wire unchanged, JSON tags kept).
- clients/admin/digitalocean — do.go extracted from the inline client; Client.{Ready,
  Balance,History}. Named digitalocean (not do) so it never collides with the local do var.
- clients/admin/commerce — reworked to the money.Cents unit + collapsed the always-equal
  (org,user) into one subject; dropped the MRRCents + duplicate-rollup shims.

Applied cleanly (0 conflicts) atop the extraction + tenant->org main. Build ./... green,
vet clean, admin+commerce+digitalocean tests pass (pure-Go cek gate).
2026-07-11 11:17:10 -07:00
hanzo-dev 850d4c0dad refactor(admin): first-principles upstream clients — money.Cents + commerce/health/digitalocean/health packages
Extracts admin's inline upstream reader clients into self-contained, package-namespaced
units and gives billing a single value type. Decomplect + package-as-namespace, per the
Hickey/DRY bar:

- clients/admin/money — type Cents int64; the unit lives in the type (ConsumedCents/
  MRRCents/… collapse to Consumed/MRR). int64 casts only at the operator-contract edge.
- clients/admin/commerce — Client.{Ready,Spend,Credits,Plan,Ledger,Costs,Deposit}
  (Deposit = the one write). Collapses the always-equal (org,user) into one subject;
  the bare-slug X-Org-Id+user invariant is baked into the client. Drops MRRCents +
  duplicate rollup-balance shims.
- clients/admin/digitalocean — Client.{Ready,Balance,History}
- clients/admin/health — Client.{Ready,Up}

Wire unchanged (JSON tags kept, DTOs stay int64 cents). Integrated onto current main:
preserves the commerceinproc.BaseURL(...) in-process routing and the tenant->org
vocabulary. Build ./... green, vet clean, admin+commerce+integrations tests pass
(pure-Go, cek gate). Completes the batch-F rework the user authorized.
2026-07-11 11:12:08 -07:00
hanzo-dev c39d2a13bc feat(social): publish edge + in-process scheduler (fail-closed provider seam)
Fold the live social stack's publish + schedule onto the native /v1/social domain:

- Publish edge (publish.go): the ONE publishPost path — claim (at-most-once across
  the HTTP handler and a scheduler tick), fan out to the channel's connected accounts
  through the injectable Publisher seam, record the honest outcome (published + external
  id, or failed + reason) on the post. POST /v1/social/posts/:id/publish + on-create
  fanout (scheduled-for-now-or-earlier) both call it.
- Scheduler (scheduler.go): an in-process periodic sweep (the native equivalent of the
  live stack's Temporal timer + hourly missing-post poller) that advances every org's
  scheduled -> published when the time arrives; idempotent, per-org, clean shutdown.
  Mirrors clients/commerce/sweep.go.
- Provider seam: fail-closed default (notConfiguredPublisher) that reports EXACTLY which
  OAuth-app credentials are missing (the live orchestrator's env var names) and NEVER
  fakes success. No Hanzo deployment carries provider creds today, so this is prod's
  honest state. GET /v1/social/providers reports per-network publish-readiness.
- Store: token on accounts (SQLCipher-encrypted at rest; live stack stores it plaintext),
  external_id/account_id/error on posts, ClaimForPublish/MarkPublished/MarkFailed,
  DueScheduled (the ONE deliberate cross-org system read), RecoverStuckPublishing.

Tenant isolation preserved: every publish is org-scoped; the scheduler's cross-org sweep
only reads (org,id) to dispatch into the org-scoped path. 12 tests (9 new) prove publish
success/not-configured/no-account, idempotency, per-org isolation, the scheduler sweep,
crash recovery, and live capabilities. go build -tags 'cloud cloud_mount' ./... green;
frozen wire-order guard green.
2026-07-11 11:08:49 -07:00
hanzo-dev 4e20e0234c cek: complete plaintext-store coverage on live-prod + flatten to top-level package
Rebased blue's RED-reviewed encrypt-at-rest onto the CURRENT live-prod commit
(v1.786.185). The release train added stores blue's base never saw, all opening
PLAINTEXT — closed every one through the same cek.Open seam:
  - tenantdb.go   (the SOLE per-tenant opener → code/git/functions/tracker)
  - gojabase, gatewaypolicy (gateway.db), dataroom (link_index.db)
Result: ZERO plaintext sql.Open("sqlite") left in the cloud data plane
(commerce self-encrypts under its own KMS key; cek internals excepted).

Flattened internal/cek → top-level one-word package cek. Trimmed the AI-slop
import comments to one line.

Verified CGO=1+SQLCipher: go build ./... green, cek tests green, and blue's
cek.Open shipping path pre-proven on ALL 47 real prod DBs (ciphertext + plaintext
shredded + exact row-count parity, 0 fail).
2026-07-11 10:56:53 -07:00
blueandhanzo-dev 14d5fab57f ci(cek): run frozen-format guard inside the Docker image build
The frozen-fixture test ran in Go CI but not inside the image under the pinned
Alpine libsqlcipher, so a sqlcipher-dev pin/base bump that changes the on-disk
format could go green in CI while bricking prod. Add the frozen-format gate to
the Docker RED gate (beside TestEncryptionProof), and make requireCipher honor
SQLITE_REQUIRE_CODEC=1 (a would-be skip becomes a FAILURE) so the in-image gate
is airtight: any format change now fails the IMAGE build, not just Go CI.

Verified: SQLITE_REQUIRE_CODEC=1 go test -run TestFrozenFixtureOpens ./internal/cek = ok.
2026-07-11 10:52:44 -07:00
blueandhanzo-dev 6f3c6ffb3d fix(cek): frozen-format CI fixture + exact sqlcipher pin + confidentiality-only scope doc
RED re-review closers:
1. Cross-version brick guard (vector b): the version-freeze was soft (unpinned
   apk sqlcipher-dev resolves from the live Alpine repo). Now:
   - Dockerfile pins sqlcipher-dev=4.6.1-r0 → a repo bump fails the build LOUDLY
     (never a silent prod brick).
   - Commit a FROZEN encrypted fixture (internal/cek/testdata/frozen/store.db
     + .dek, written under cipher_compatibility 4) + TestFrozenFixtureOpens that
     copies it to temp, opens via cek, and reads a known canary row. A future
     libsqlcipher format change makes the FROZEN store fail to open → red CI,
     which build-time SQLITE_REQUIRE_CODEC (fresh-db only) cannot catch.
     TestGenerateFrozenFixture (CEK_GEN_FIXTURE=1) regenerates it on an
     intentional format rev.
2. Scope doc (vector c): cek package doc now states it provides CONFIDENTIALITY
   at rest, NOT integrity/authenticity/anti-rollback vs a PV-write
   (node-compromise) adversary — out of the stated read-only model. The
   logical-id+epoch binding is deliberately NOT added (out-of-model complexity).

9/9 cek tests green on real SQLCipher (cgo) + nocgo; gofmt/vet clean. Migration
still deferred; live cutover gated on the user's supervised go.
2026-07-11 10:52:44 -07:00
blueandhanzo-dev 8636760c2f fix(cek): address RED review — shred plaintext, fileID KEK, fatal-missing-key, content-parity
RED review fixes on the encrypt-at-rest codec:
1. [HIGH] Shred the pre-migration <db>.plain.bak after the verified keyed reopen
   (overwrite+remove, single call site in openEncrypted). Nothing reaped it
   before, so every migrated store left a COMPLETE plaintext replica on the
   volume forever — the exact threat the codec exists to kill. Test asserts the
   backup is gone post-migration.
2. [MED] KEK no longer binds to the CLOUD_DATA_DIR-relative path (brittle: a
   dir change bricked the plane). A random per-file id is stored in the sidecar
   head (fileID(16) || wrapped-DEK) and the KEK derives from it — intrinsic to
   the file, config-independent. Test moves a store to a new dir + wrong
   CLOUD_DATA_DIR and it still opens.
3. [MED] Missing key on an encryption-capable build is now FATAL (fail-closed,
   same posture as KMS), not a silent plaintext downgrade. Encrypting() is wired
   into the boot log (serve.go). No new gate/env — keyed off the existing
   CLOUD_KMS_MASTER_KEY_REF + build capability.
4. [MED] Parity gate gains a rowid-independent per-table content hash (commutative
   multiset sum of per-row hashes over user columns), catching value mutations
   that count+schema+integrity_check miss while ignoring benign rowid renumbering.
   Dropped the inaccurate 'byte-faithful' wording. Cross-version cipher stability
   is enforced by FREEZING libsqlcipher in the build (an at-open cipher_compat pin
   is infeasible with mattn's URI-key requirement — the URI param is ignored and a
   post-open pragma runs after mattn reads the header; proven); any mismatch fails
   closed (test: corrupt/missing sidecar refuses).
5. [LOW] recoverInterrupted scrubs stale -wal/-shm; migration removes them before
   the swap so a plaintext WAL never sits beside an encrypted db. No statement
   logging on keyed conns; the key never rides a logged DSN/error.

8/8 tests green on real SQLCipher 3.53.1 (cgo) + nocgo fatal-key path.
2026-07-11 10:52:44 -07:00
blueandhanzo-dev 7c125ba373 feat(cek): encrypt-at-rest for all cloud SQLite stores (plaintext→SQLCipher, zero-loss migrate-on-open)
The CGO+libsqlcipher cloud binary shipped encryption DORMANT: all 29 stores
opened via bare sql.Open("sqlite", path), so /var/lib/cloud/*.db (crm PII,
treasury ledger, wallets, audit, team/entitlements) were plaintext despite
CLOUD_KMS_MASTER_KEY_REF being present. The SQLCipher primitives were linked
(hanzoai/sqlite cek.go) but never USED.

internal/cek.Open is the ONE encryption-at-rest seam every store now routes
through. When the master key is configured it transparently, under a per-file
flock: mints a per-DB random DEK (SQLCipher page key), wraps it AES-256-GCM
under KEK=HKDF-SHA256(master, principal) in a <db>.dek sidecar, and — for an
existing PLAINTEXT file — migrates it to SQLCipher via sqlcipher_export, then
verifies schema + per-table row-count parity + integrity_check by re-opening
the encrypted copy EXACTLY as the app will, BEFORE an atomic swap. Fail-secure:
key set on a non-encrypting build is a hard error; unverifiable migration leaves
plaintext intact; wrong master fails closed; crash mid-swap is recovered.

Proven with real SQLCipher (TDD): plaintext→ciphertext header, row parity,
.dek unwrap, .plain.bak preserved, idempotent reopen, wrong-key rejected,
dev-mode gated, nocgo fail-closed.

Migration is deferred to a maintenance-window cutover (this image built via
arcd) — NEVER encrypt against the current binary, which cannot open SQLCipher.

Rewires 29 open-sites; base/o11y external-module DBs are a follow-on.
2026-07-11 10:51:13 -07:00
d741a0848f cloud: migrate off deprecated app.Mount → All+AdaptNetHTTP (one mount path); tidy go.sum (#257)
zip #5 deprecated (*App).Mount(prefix, h): it is exactly
  app.All(prefix+"/*", zip.AdaptNetHTTP(h))
kept only as a behaviour-identical alias. Move every foreign-http.Handler
mount onto the explicit primitive so there is ONE way to put a route on the
app, and so the cloud money binary keeps building once zip deletes App.Mount.

Migrated all THREE route-mount call sites (the task scoped two; commerce is a
third — its exclusion note referred to the commerce.Mount *function*, not the
app.Mount(p, handler) inside it):
  - clients/plugin/plugin.go:125   app.Mount(p.Prefix, h) → app.All(p.Prefix+"/*", zip.AdaptNetHTTP(h))
  - clients/iam/iam.go:159         app.Mount(p, handler)  → app.All(p+"/*", zip.AdaptNetHTTP(handler))
  - clients/commerce/mount.go:147  app.Mount(p, handler)  → app.All(p+"/*", zip.AdaptNetHTTP(handler))
Behaviour-identical (Mount IS this composition). Also updated the four doc
comments that named the deprecated zip.App.Mount so no dangling reference to a
soon-deleted method remains. grep-confirmed ZERO app.Mount( route-mounts left.

go.sum: removed the two inert zap-proto/zip v1.3.0 hash lines (nothing in the
module graph requires v1.3.0 — orphan cruft a working `go mod tidy` would drop).
Full `go mod tidy` is blocked by a PRE-EXISTING, unrelated force-moved tag on
luxfi/keys@v1.2.2 (server serves h1:nuD+y5…; committed go.sum records
h1:XH5mRm…), so the two dead lines were removed surgically instead — luxfi/keys
and every other entry left byte-identical to origin/main. No GONOSUMCHECK hack.

Co-authored-by: zeekay <ai@hanzo.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 10:50:09 -07:00
hanzo-dev 35bb240e5c fix(commerce): derive org from IAM on the token path — no commerce-owned org
Per the one-authority directive: commerce must not require its OWN Organization
record; IAM is the sole org/user/auth authority. The service-token path already
auto-projects the org from X-Org-Id via the cached GetOrCreate resolver, but the
IAM-principal path c.Next()'d without resolving the org — it depended on
iammiddleware running upstream, and when it hadn't, GetOrganization MustGet-
panicked (500) / the org was absent, so an IAM org with no pre-existing commerce
row could not view or create billing. ensureIAMOrg now resolves the validated
X-Org-Id through the SAME cached GetOrCreate resolver on the IAM path too
(idempotent), so any IAM org "just works" and a thin billing record is
auto-projected on first use — commerce derives the org from IAM, never its own
table. This is also the root cause behind the "hanzo has no commerce org record"
wall on the live 2-org proof.
2026-07-11 10:45:33 -07:00
hanzo-dev f1ceb4e889 fix(o11y): exempt DSN-authed error-ingest from the principal gate
The Sentry error-ingest wire endpoints (POST /v1/o11y/api/<project>/envelope|store/)
authenticate with a DSN public key downstream in the o11y handler, not a Hanzo
principal. cloud's o11y gate() 403'd them for lacking X-User-Id, so the tokenless
ingest that the gateway allowlists (isErrorIngestPath) was blocked one layer deeper
— error-tracking could never ingest end-to-end.

Add the cloud-side counterpart: gate() now also exempts isErrorIngestPath
(byte-for-byte the gateway's matcher — method + /v1/o11y/api/ prefix + envelope|store
suffix, never a bare prefix). Reads under /v1/o11y/api/vN/... and the Issues
list/detail/update stay principal-gated; the exempted ingest still fails closed on a
bad/absent DSN key (401/503). Tested: TestGateExemptsErrorIngestButGatesReads.
2026-07-10 23:58:25 -07:00
hanzo-dev f0d9f8cefb feat(config): additive CLOUD_ENABLE_STAGED lever — activate staged subsystems without an allowlist
Enabled() staged path is now orthogonal to the Enable allowlist: a staged
subsystem (iam/ingress) mounts when named in EITHER Enable (strict allowlist) OR
the new EnableStaged (additive). CLOUD_ENABLE_STAGED=iam + empty CLOUD_ENABLE =
all-non-staged prod default PLUS iam — the faithful iam-fold canary/cutover shape
with NO hand-enumerated allowlist that silently drops a newly-added subsystem.

Proven: TestEnabled_StagedActivatesAdditively (iam on, non-staged default intact,
unnamed staged sibling stays off).
2026-07-08 12:01:06 -07:00
hanzo-dev 798a85fddf fix(iam): isolate embedded IAM's SQLite via IAM_DATABASE_URL — unblock iam+ai co-residence
Both the iam and ai casdoor-derived forks resolve their SQLite handle from the
SAME env key (dataSourceName) + the one beego web.AppConfig global. A deployment
sets dataSourceName for ai; with iam enabled, IAM's bootstrap resolved that same
value and xorm-opened ai's DB (auto-migrating casdoor tables into it) -> the
documented boot crash that pinned every post-embed release and kept iam staged.

IAM's conf already honors an IAM-scoped IAM_DATABASE_URL above the shared
dataSourceName; pin it to IAM's own iam.db under DataDir so the two forks get
independent stores, order-independent, NO fork edit. Operator override respected.

Unit-proven: TestIsolateDatabase (iam-owned DSN, != ai dataSourceName, respects override).
2026-07-08 11:55:47 -07:00
2468 changed files with 91203 additions and 238580 deletions
+12
View File
@@ -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
+14 -4
View File
@@ -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
View File
@@ -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.
+1
View File
@@ -34,3 +34,4 @@ Thumbs.db
.shots/
.claude/
native/flags/target
+110 -137
View File
@@ -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"]
+140 -14
View File
@@ -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`.
+4 -1
View File
@@ -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
View File
@@ -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() }
}
+217
View File
@@ -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)
}
}
+82
View File
@@ -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)
}
}
}
+99
View File
@@ -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
+81
View File
@@ -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
}
+23
View File
@@ -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))
}
+152
View File
@@ -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
View File
@@ -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)
}
}
+305
View File
@@ -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)
}
}
}
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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".
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)}
}
+86
View File
@@ -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
View File
@@ -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
+62
View File
@@ -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)
}
}
+16 -1
View File
@@ -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
+243 -47
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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 }
}
+43
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
}
+100
View File
@@ -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(&note); err != nil {
t.Fatalf("read canary: %v", err)
}
if note != frozenCanary {
t.Fatalf("canary mismatch: got %q want %q", note, frozenCanary)
}
}
BIN
View File
Binary file not shown.
Binary file not shown.
+143 -26
View File
@@ -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
}
+175
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+409
View File
@@ -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
View File
@@ -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
}
+107
View File
@@ -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
View File
@@ -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(&registerProvider, "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
+92
View File
@@ -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
View File
@@ -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")
}
}
}
}
+6 -2
View File
@@ -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
View File
@@ -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)
+35 -9
View File
@@ -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.
+257
View File
@@ -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)
}
}
+3 -3
View File
@@ -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).
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
+10 -8
View File
@@ -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)
-131
View File
@@ -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)
}
+151
View File
@@ -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
View File
@@ -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
View File
@@ -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)
-442
View File
@@ -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
}
+367
View File
@@ -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
}
+16
View File
@@ -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
View File
@@ -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:
+215
View File
@@ -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)
}
+36
View File
@@ -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})
}
+80
View File
@@ -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
}
+62
View File
@@ -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"),
}
}
+269
View File
@@ -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
+99
View File
@@ -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
}
+28
View File
@@ -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
}
+32
View File
@@ -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
}
+381
View File
@@ -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,
+20
View File
@@ -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))
}
-557
View File
@@ -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
}
-178
View File
@@ -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))
}
+182
View File
@@ -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))
}
+23
View File
@@ -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)
}
}
}
-332
View File
@@ -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"))
}
+47
View File
@@ -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,
})
}
+93
View File
@@ -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)
}
+294
View File
@@ -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)
}
+133
View File
@@ -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)")
}
}
+239
View File
@@ -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") }
+59
View File
@@ -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
View File
@@ -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
View File
@@ -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())
}
+48
View File
@@ -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
}
+150 -103
View File
@@ -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 {
+9
View File
@@ -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
+9 -8
View File
@@ -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 {
+2 -2
View File
@@ -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{
-155
View File
@@ -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
}
+157
View File
@@ -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