Compare commits

...
Author SHA1 Message Date
antje 9f9f5c7e75 books: land the AI bookkeeper on /v1/books (native double-entry, per-org SQLite)
A native 'books' domain in the cloud one-binary — the ERPNext Accounts SEMANTICS
ported to Go, NO Postgres/Formance/ERPNext-Python. Reads commerce
/v1/billing/transactions (sole posting source, read-only) and books the
double-entry twin; GAAP rev-rec (Customer Wallet = deferred-revenue liability,
recognized at usage); accrual P&L + Balance Sheet + export; AI Ask brain (SaaS
metrics: MRR/ARR/burn/runway) + clarifying questions; bank engine with PDF
(rsc.io/pdf + foot-check reconciliation), OFX/CSV, Plaid, Teller + reconciliation.
Exact int64 cents, per-org Base/SQLite, ~35 tests green (built blue/red/cto).
Registered in apps.go beside treasury; adds rsc.io/pdf direct dep.
2026-07-24 02:04:15 -07:00
antje bee8d71d6a merge: analytics destination adapters (Umami + PostHog + GA4/Meta ecommerce)
Fan POST /v1/analytics out to analytics.hanzo.ai (Umami /api/send) and
insights.hanzo.ai (PostHog /v1/e); ecommerce -> GA4/Meta conversion mapping.
Fail-soft fanout; adapters inert until per-site KMS keys are set (additive).
2026-07-24 01:51:26 -07:00
antje 4d71c12a6b fix(destinations): PostHog → /v1/e, Umami visitor id → id (red must-fix)
Two silent-drop contract bugs from adversarial (red) review of the Umami +
PostHog analytics sinks — both fail soft, so a paid product ingested zero.

- posthog [critical]: Send POSTed to <host>/batch, but Hanzo Insights
  capture-rs registers ONLY /v1/e|/v1/s|/v1/ai (rust/capture/src/router.rs;
  legacy /batch REMOVED, Django APPEND_SLASH=False) → every event 404'd and
  was dropped (postJSON logs, never wedges). Repoint to /v1/e; the
  {api_key,batch:[…]} body is unchanged — capture-rs's untagged
  RawRequest::Batch variant accepts it verbatim.

- umami [medium]: umamiPayload.DistinctID serialized as `distinctId`, a field
  absent from the fork's /api/send zod schema (src/app/api/send/route.ts) →
  silently stripped, so the forwarded visitor id never keyed the session
  (stitching fell back to IP+UA). Rename the JSON tag to `id`, the schema's
  real session key: sessionId = id ? uuid(website,id) : uuid(website,ip,ua,salt).

Tests now assert the real WIRE contract (a struct-field assert cannot catch a
JSON-tag bug — the source of the false confidence red flagged):
TestPostHogSendEndToEnd → /v1/e; TestUmamiBuild + TestUmamiSendEndToEnd → the
visitor id marshals under `id`, never `distinctId`.

go build + go vet + go test ./clients/destinations/... green.
2026-07-24 01:44:56 -07:00
antje 70dff81db5 refactor(share): login-first provisioning, fresh deterministic email, env base
Three hardening fixes from live testing against the zrok controller:
- LOGIN-FIRST: token(org, create) tries login before create — an existing
  account (the common path) costs ONE login and never touches admin; create
  only on a real miss. Slims the controller interface to token()+overview()
  (org-centric — the controller owns all credential derivation, the handler
  just passes org; DRY).
- FRESH EMAIL: zrok SOFT-deletes accounts (a deleted email stays in the unique
  index, un-recreatable forever), so a plain share-<org>@ that was ever touched
  is burned. accountEmail now carries an HMAC freshness suffix
  (share-<org>-<hash8>@hanzo.ai) — deterministic, unguessable, and distinct
  from any prior scheme, so no burn can strand an org.
- ENV BASE: ZROK_API_BASE (default /api/v2) makes the /api/v2 -> /v1 cutover a
  CR edit, not a rebuild — flip it the moment the /v1 zrok image deploys.
Builds + unit tests green.
2026-07-24 01:24:27 -07:00
antje 7d5123dd3d chore(commerce): ship store access billing 2026-07-24 01:10:51 -07:00
antje 1dccca6b2f fix(share): per-org account email uses a hyphen, not plus
share-<org>@hanzo.ai. A plus local part trips some validators, and zrok
soft-deletes accounts (its unique email index then blocks a recreate of
the same address) — a hyphenated, stable identity sidesteps both. Verified
live against the controller: create 201 + login 200 for share-hanzo.
2026-07-24 00:57:25 -07:00
antje ea06f77e53 feat(destinations): add Umami + PostHog sinks and native ecommerce mapping
Fan the /v1 analytics event stream out to two first-party analytics sinks
alongside the existing ad platforms, and map schema.org/GA4 ecommerce actions
onto GA4 and Meta's native conversion schemas.

- umami.go: POST /api/send (Hanzo Analytics fork). Credential-less public
  beacon keyed by the non-secret website id; forwards the end-user UA+IP so
  Umami attributes session/geo. Pageviews sent without a name (Umami's rule).
- posthog.go: POST /batch (Hanzo Insights fork). Project api_key is the KMS
  secret, carried in the body (never URL/log); distinct_id keyed, empty dropped.
- fanout.go: resolveSecret treats a Secret-less, Fallback-less destination as
  credential-less (public ingest) instead of failing closed.
- translate.go: map product_viewed/product_added/begin_checkout/purchase (+
  schema.org aliases) onto the commerce standard events; lift ecommerce line
  items (items/products array, or a first-class product id) into Conversion.Items.
- ga4.go: render GA4 items[] + purchase transaction_id.
- meta.go: render Meta content_ids/contents/content_type/num_items + order_id.
- destination.go: normalized Item type + Conversion.Items/Referrer.
- send.go: shared non-PII first-party analytics data helper.

Secrets stay in KMS; no credential in any endpoint, error, or log line.
Tests: adapter payload + ecommerce mapping (mock HTTP), translate vocabulary +
item lift, credential-less resolution. go test ./clients/destinations/... green.
2026-07-24 00:52:49 -07:00
antje 6f371409c7 cloud auth: IAM-native trust — drop the per-app audience allowlist
Trust becomes exactly what IAM asserts: a valid SIGNATURE from a trusted ISSUER
(the brand set) plus EXPIRY. The audience (a minting app's client_id) is now
INFORMATIONAL, not an access gate — cloud no longer keeps a hand-maintained mirror
of IAM's app registry. That mirror was the lone non-IAM-native gate and it drifted:
every new first-party app (most recently hanzo-commerce, breaking the commerce-admin
AI assistant) silently 401'd until someone edited GATEWAY_ALLOWED_AUDIENCES. A new
first-party app now 'just works' with zero cloud change.

Removed: identityValidator.audiences, Config.JWTAudiences, jwtAudiencesFromEnv,
defaultJWTAudiences, BrandAudiences, unionStrings. validate() enforces issuer + expiry
via jwt.Expected{} (empty AnyAudience skips ONLY the audience match; go-jose still
checks exp/nbf against time.Now). Fail-secure on an empty ISSUER set is preserved.

PRESERVED (unchanged): owner-claim org scoping on every guard; SuperAdmin =
owner==adminOrg AND !isKMSMachinePrincipal; the KMS-machine SuperAdmin-denial
(isKMSMachinePrincipal reads claims.Audience directly, not the removed allowlist);
OrgHasUnsafeRune. Tests reframed to the new invariant and all green, incl. the KMS
red adversarial suite (multi-value aud, admin-slip, owner-bound machine-aud, trim/
unsafe owner). One new test proves any aud from a trusted issuer validates while a
wrong issuer / expired token still rejects.

The user-facing behavior change: a real admin (owner==adminOrg, isAdmin) is now
SuperAdmin from ANY first-party app, not only allowlisted ones — owner is the
authority, which is correct for an internal IAM where every app is first-party.
2026-07-24 00:32:47 -07:00
antje e6499ac02a fix(share): zrok API is /api/v2 + application/zrok.v1+json media type
The controller's go-swagger API mounts at basePath /api/v2 and consumes/
produces ONLY application/zrok.v1+json (application/json → 415/500, and a
v1 path → the SPA's 202 HTML). ensureAccount/login/overview now hit the
right base with the right content type. Verified: /api/v2/login returns
the account token 200.
2026-07-24 00:29:37 -07:00
zeekayandClaude Fable 5 1eb2d288f0 build(deps): bump hanzoai/commerce v1.49.14 → v1.49.15 (billing webhooks live: X-Webhook-* delivery, lifecycle emission, bounded retry)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 00:18:45 -07:00
antje 438ff9641b feat(share): /v1/share/* — ngrok-native public sharing in the one cloud binary
Provisions a per-org zrok account from the caller's validated IAM identity
so the Rust CLI's `hanzo share <port>` publishes a local service to a public
https://<token>.share.hanzo.ai URL with zero manual setup.

- POST /v1/share/enable: stateless, deterministic per-org account (email
  share+<org>@hanzo.ai, password HMAC(secret,org)) — ensure-account + login
  reconstruct the same credential every time; the zrok controller IS the
  store, no local persistence. Returns {accountToken, controller, namespace,
  urlTemplate} for the tunnel client. Idempotent.
- GET /v1/share: the org's active shares (CLI + console Shares view), honest-
  empty when the controller is unreachable.
- Fail-closed 503 until ZROK_ADMIN_TOKEN; org from principal (never a client
  field) so a caller can only ever provision/read its OWN org. .Group routes,
  mockable controller client, unit-tested.
2026-07-24 00:10:33 -07:00
zeekayandClaude Opus 4.8 83bd636f0f git: public explore/landing — OSS-first, no sign-in to browse
git.hanzo.ai (served by cloud's embedded git forge, clients/git) returned a raw
403 'sign in to view Hanzo Git' for signed-out visitors. Most Hanzo projects are
OSS, so the default face is now open, GitHub-style:

- uiExplore + /explore: lists every PUBLIC repo across ALL orgs, searchable (?q=),
  no auth. Cross-org via {DataDir}/orgs enumeration (per-org stores have no global
  index), capped at maxExploreScan.
- uiHome: signed out -> public explore/landing; signed in -> your org's repos.
- uiRepoAccess: repo/tree/blob/commits views serve PUBLIC repos anonymously;
  private repos stay org-authed and answer the SAME 404 (no existence leak),
  mirroring smart-HTTP's resolvePackRepo(allowPublic).
- Store.ListPublic: per-org public-repo query.
- state.dataDir threaded through Mount for the org enumeration.

Anonymous clone of public repos already worked (smart_http); this opens the
browser surface to match. Updated TestRootUI_HostGuard to the new public-landing
contract (/ -> 200 explore, missing repo -> native 404).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:17:57 -07:00
hanzo-dev 5589d05bfd guide: fold the full Zen of Hanzo genome into a version-aware seed
Seed the DB-backed blueprint with the complete corpus — the 64-principle
spine plus 888 modern + 114 heritage strategies (1002 total), each filed
under a spine principle and tagged by era. base.yaml is the assembled
genome, kept byte-synced with the GitOps origin.

Schema: Blueprint.Principles (the 64 archetypes); Strategy gains
principle/source/era/blog. Validate enforces referential integrity — every
strategy's principle resolves to a spine slug, no duplicate id across the
1002 — plus unique principle slugs.

Version-aware seed (SeedOrUpgrade): a monotonic seedVersion is stamped on
the seed row (source="seed"); an admin write flips it to source="admin".
On Mount a brand with no row is seeded, an UNEDITED seed at an older
generation is upgraded in place, and a brand ever admin-edited is never
touched again. A self-healing backfill marks legacy version>=2 rows as
admin.

Fix two review LOWs:
- authoringBlueprint returns a clone of the embedded fixture in its
  pre-seed fallbacks, so an in-place PATCH can't corrupt the shared value.
- putBlueprint / listBlueprintVersions fetch the write key via
  LatestResolved without parsing, so a corrupt stored row is still
  replaceable by a valid PUT.

Tests: assembled seed parses+validates; /v1/guide/strategies surfaces the
888; a v2 seed upgrades an unedited v1 row and never clobbers an admin
edit; legacy backfill; the two LOW PoCs. Frozen wire green.
2026-07-23 13:28:33 -07:00
hanzo-dev 124a2a2ffb guide: DB-backed blueprint — seeded fixture, 3-tier resolution, SuperAdmin authoring, strategies corpus
Make the whole Guide™ playbook DB-backed and SuperAdmin-editable — nothing static.

Schema: extend the engine into a full Blueprint (Section/Strategy/Template + an
Enabled lever on every item, default-on). The engine stays pure — Blueprint.Curriculum()
projects the ENABLED journey (disabled section drops its steps; disabled step drops and
its dependents lose the edge), so reconcile/Next/Available/Counts are unchanged. Parse is
fail-closed; Validate enforces DAG-acyclic + no-dangling over the authored graph AND the
enabled projection.

Seed: embed base.yaml (12 sections · 67 steps · 114 strategies · 6 templates, a
byte-identical synced copy of the universe fixture) as the blueprint; supersede
default.yaml. On Mount, seed-if-absent into a shared versioned store (guide_blueprint,
brand-keyed) — a redeploy never clobbers admin edits.

Resolve (three tiers): org override (per-org) → brand blueprint (shared DB, seeded,
SuperAdmin-authored) → embedded fixture (fail-safe). A disabled/unparseable tier is
skipped.

Author: /v1/guide/blueprint/* gated on IsSuperAdmin (owner==admin) — a normal org member
or per-org admin gets 403. GET/PUT the whole blueprint, GET versions (PITR), PATCH an
item (edit + the enable/disable lever). Every write is validated fail-closed and appended
as a new version; edits take effect on the next resolve.

Filter: GET /v1/guide/strategies?category=&stage=&workload= — the ENABLED corpus,
org-scoped, joined to the observe layer (stage:* is a monotone readiness floor,
research==formed; has:* maps to module:*/connected:*/analytics/revenue/deployed/
funnel:signups; extend standardConnectors so the join is real).

Green: go build ./clients/guide/... ./apps/...; go vet; go test ./clients/guide/... (50
tests incl SuperAdmin-gate 403, idempotent-seed no-clobber, org>brand>fixture resolution,
disabled section/step drop, corpus filter). apps TestWireOrderMatchesFrozen green (routes
added to the existing guide subsystem).
2026-07-23 12:21:56 -07:00
hanzo-dev 7038607250 guide: real-time growth-observe layer — signals, stage classifier, /v1/guide/profile
Add the Guide's OBSERVE layer so the Business AI grounds on an org's REAL
platform truth, org-scoped and honest-degrading.

Growth signals extend the Detector vocabulary (detect.go stays THE coupling
point): module:<name>, connected:<provider>, funnel:<stage>, deployed, revenue,
customers:>=<N>. Each reads a sibling subsystem through an injected Signals seam
bound at the composition root (apps/wire_seams.go) — guide imports none of them,
the coding-dispatcher injected-function pattern. A nil seam field honest-degrades
its signal to not-present; the vocabulary is the contract, a read fills in when a
provably org-scoped seam lands. lookupDetector resolves parameterized <kind>:<param>
signals so a curriculum step can auto-detect on any of them.

classifyStage folds a SignalSet into a first-principles growth stage
(formed->launched->activated->scaling) — pure, total, exhaustively tested; the
ladder is data, driven by OUTCOMES (presence, engagement, money), not setup.

GET /v1/guide/profile exposes {stage, signals, keyMetrics}, org-scoped on the
validated principal, READ-ONLY, recomputed each request (real-time by pull) via the
existing reconcile path + the growth probes.

Bound reads (provably org-scoped, nil-safe): framework.ModuleInstalled (new,
module-granularity sibling of Installed), integrations.Connected (new, boolean,
never the token), the in-package analytics funnel. deployed/revenue/customers stay
unbound seams (deploy is cluster-scoped; commerce/crm have no clean per-org
in-process read) — honest-degrading until a provably org-scoped read exists.

Tenant isolation: every signal read keys on the caller's own org; TestHTTPProfile
CrossTenantIsolation proves org A's profile never reflects org B's signals and each
request's probes read only the caller's org. framework/integrations seam reads have
their own org-scoping tests.
2026-07-23 10:13:15 -07:00
hanzo-dev 83b59c509b research: gate HA object-store durability behind CLOUD_RESEARCH_DURABLE opt-in
The unified store's durability plane auto-activated whenever S3_ADMIN_* was
configured (prod sets it), so deploying it would begin fencing real tenant
data on the object store's conditional-PUT (If-Match) atomicity before that
atomicity is validated against the deployed SeaweedFS version (Red's takeover-
fence staging gate H2). Gate it behind CLOUD_RESEARCH_DURABLE (off by default):
the store runs local-only until the flag is set, and the shard router already
pins each org to one writer (ha.Owner) — so this is NOT the rolling-deploy
outage, and the DDL-drift-proof + evidence-preserving record layer is fully
active. The object-store snapshot/fence turns on deliberately after the gate.
2026-07-23 09:41:46 -07:00
hanzo-dev 803db8811c test(framework): guard stampFixture clone — fail if DocField/DocPerm gain a non-scalar field (red LOW-1: shallow-clone would corrupt the always-on registry cross-org) 2026-07-23 07:40:15 -07:00
hanzo-dev 75395adfa4 guide: always-on standard modules + AI suggest + per-brand journeys
Complete the stranded Guide feature so a fresh org's journey works with no
per-org module install.

framework: MarkAlwaysOn/AlwaysOnModules mark a registered module always-on —
its DocType fixtures resolve for every org via GetDocType/ListDocTypes and the
Installed predicate, with no per-org install row. This defaults only the SCHEMA
on; every document row stays physically org-scoped. A per-org stored DocType
still overrides the fixture. always_on_isolation_test.go proves org A's records
are unreadable by org B even though the DocType resolves for both.

content: marketing is always-on (doctypes.go init), so content_generate
completes for a fresh org — the fix for "content: marketing module not installed
for org". Drop the now-unreachable not-installed guard in EnsureCatalogAsset (a
fresh org without a studio still quietly skips at Generate).

guide: builtin-2 journey rooted at "Form your company", then positioning →
launch steps and the daily agentic growth loop; positioning gates on company.
Add /v1/guide/suggest + /v1/guide/chat — read-only AI "what to do next", grounded
in the org's real progress + funnel, never running an action. Resolve the
white-label brand journey (brands/<brand>.yaml; zoo ships zoo-1) at Mount.

cek: TestMain seeds an ephemeral master key so the encrypted-at-rest stores open
on an encryption-capable test build (framework, content, guide) — mirrors
compliance/integrations/flags/venue.
2026-07-23 07:37:19 -07:00
antje 7a236a2c3a agents: CLI-facing control drain for locally-started sessions
The dashboard already POSTs pause/resume/stop/message, recorded as durable
KindControl events. A cloud-dispatched routed run gets them forwarded to the
tasks engine, but a LOCALLY-started `hanzo code` session is not task-backed —
so the running surface must pull them itself.

Add GET /v1/agents/sessions/:id/control?after=<seq>: an owner-scoped, cursor-
driven drain of a session's control commands (ListControlAfter filters to
control, oldest first). Read-only, org is the only tenant key, foreign id 404s.
Test covers filter/order/cursor/tenant-isolation.
2026-07-23 07:01:31 -07:00
hanzo-dev 4941e0839e Merge branch 'blue/research-orm' into blue/research-unified
Unify the research record layer (hanzoai/orm typed records, DDL-drift-proof
+ migrateLegacy evidence carry-forward) onto the HA file-durability layer
(ha-elected single-writer + fenced ship-before-ack). Reconcile the three
files both branches touch:
- store.go: ORM's typed-orm rewrite — supersedes the raw-SQL ALTER-migrate
  path (schema migration is structurally impossible under orm).
- migrate_test.go: ORM's legacy-DATA migration test (ALTER-convergence is
  obsolete under orm).
- research.go: both — HA's durability wiring (WithDurable + shipDurable
  ship-before-ack in every write handler) plus ORM's postGrant scope comment.

deps: orm v0.6.1 -> v0.6.7 (CreateIfAbsent typed-record ingest the orm store
needs) and commerce v1.49.13 -> v1.49.14 (DatastoreAdapter satisfies orm
v0.6.7's orm.DB CreateIfAbsent, keeping ./apps/ building).
2026-07-23 04:38:14 -07:00
hanzo-dev ac31716ce4 Merge branch 'blue/cloud-ha-sqlite' into blue/research-unified 2026-07-23 04:13:09 -07:00
hanzo-dev 0b1ec329ff test(durable): gate the encrypted takeover proof on a real cek round-trip (R1)
TestRecordShipsSoTakeoverKeepsIt reopens the shipped store through cek. On a CGO
build whose SQLite lacks SQLCipher, PRAGMA key is silently ignored so cek writes a
plaintext file it then cannot migrate on reopen (sqlcipher_export absent) — and the
suite TestMain still injects a dev key because sqlitedrv.EncryptionAvailable() reports
a false-positive yes, so the test hard-failed under the default CGO build.

cekCanReopen probes the actual round-trip: it holds under pure-Go (plaintext) and
real libsqlcipher (encrypted, exercising the .dek sidecar cross-pod restore), and
skips only on the broken-capability build. go test ./... stays green everywhere;
ship-before-return is proven under CGO_ENABLED=0 and by TestRecordOnNonOwnerFailsClosed.
2026-07-23 04:01:16 -07:00
hanzo-dev 362515eead durable: loud gate when HA is disabled on multi-replica (L2) + document M3/M4/M5
Red L2: durability disabled now routes through disabledDurability(), which logs at
ERROR on a MULTI-REPLICA deployment (>1 CLOUD_PEERS) — per-org stores then survive
only via shard routing + per-pod RWO PVC, so a lost PVC loses data — and at INFO for
single-replica/dev where local-only is expected. The encryption-capable-build-without-
cipher case is treated as a misconfig (fail closed + loud), never a silent plaintext
ship or a silent drop back to the non-HA outage.

Red M3: documented degraded-open recovery — a degraded pod stays read-only for the
cached store's life (in-place re-acquire would CarryForward-restore under the live
handle = stale reads); recovery is a fresh open (pod restart / shard reroute), with
quiesce-close-reopen as the future enhancement.

Red M4/M5: documented the SeaweedFS operational requirements the fence rests on —
object versioning + no-expiry lifecycle on the org-db prefix (lease round is the
system of record; a dropped/rolled-back lease can un-fence a zombie), and RWO
per-writer PVCs for DataDir, never RWX.
2026-07-23 03:40:32 -07:00
hanzo-dev c6a121f096 perf(research): one-time marker so a restart skips the legacy re-scan
The legacy migration was idempotent but re-read the raw-SQL tables on every open. A
completion marker (a kindMeta record under a distinct id from the seq clock, written
in the SAME tx as the migration so it commits iff the migration commits) short-circuits
every later open. Greenfield stores mark done immediately. Test asserts the marker is
set post-migration and that a re-open stays correct (no duplication).
2026-07-23 03:39:04 -07:00
hanzo-dev 1b54ba50d5 test(durable): H2 staging gate — SeaweedFS If-Match/If-None-Match atomicity
The single-writer fence rests on the gateway evaluating If-None-Match:* (create-only)
and If-Match (version-conditioned) preconditions ATOMICALLY server-side — the one
property the in-process fakes cannot prove. This env-gated integration test races 12
concurrent writers against the REAL S3ConditionalStore and asserts EXACTLY ONE wins
each precondition; two winners = split-brain possible, do not ship durability against
that gateway. Skipped in unit runs; run in staging with CLOUD_DURABLE_IT=1 + S3_ADMIN_*
before durability fences real tenant data (or on a SeaweedFS version bump).
2026-07-23 03:38:20 -07:00
hanzo-dev 4d5083e7b6 durable: never hold the store lock across object-store I/O + timeouts + overflow guard
Red M1: forPath held the store-wide c.mu through openDurable→Hydrate (Acquire +
CarryForward round-trips, untimed) — a hung SeaweedFS froze the whole subsystem, a
cache-hit For() included. Now the durable open runs with c.mu RELEASED, deduped by
an in-flight record so concurrent For() for one org opens exactly once (cek cannot
open a file twice); every object-store round-trip (hydrate, Sync, close) is bounded
by durableOpTimeout (30s) so a slow store degrades to bounded latency, never a
deadlock. The local-only path is unchanged (disk I/O under c.mu as before).

Red L3: CloseAll ships each final state with c.mu released and time-bounded (was a
background-ctx ship under the lock) — Durable.Close takes a ctx now.

Red L1: unframe checked m+sl > len(b), which a max-uint64 length wraps past, then
panics the slice. Now checked as sl > len(b)-m (subtraction, no wrap) → fails closed.

Tests: TestDurableForDedupsConcurrentOpens (8 concurrent For → one store), the L1
oversized-length frame fails closed, all durable + research + root OrgStore green.
2026-07-23 03:35:24 -07:00
hanzo-dev a182ed755d fix(research): carry pre-orm raw-SQL evidence forward on open (was orphaning it)
CRITICAL (red): the orm store read only _entities, so an existing per-org file whose
evidence lived in the old raw-SQL experiment/attempt/artifact tables read as EMPTY —
every logged run silently vanished the instant orm shipped (data intact in-file, but
all reads returned 0). migrateLegacy now runs in openStore: gated on old-table
existence (greenfield skips), idempotent via CreateIfAbsent, fail-secure (a migration
error fails the open rather than serving empty over real data).

- Each version keeps its OLD seq value → canonical/supersession preserved exactly (a
  corrected run stays canonical); the append clock is advanced past every migrated seq
  so a later ingest still supersedes (proven with a ts=0 correction across the boundary).
- Rows read by COLUMN NAME (SELECT *) → a table left by any older schema (even the
  outage-era one missing provenance columns) migrates without a 'no such column'.
- content_hash/revision/status/visibility/consent/provenance + artifact blobs verbatim.
- Regression TestLegacyDataMigration: seed old tables incl a correction pair → open →
  assert pre-migration truth (corrected canonical, retained intact, artifact bytes),
  a later ingest supersedes, an unrelated ingest doesn't flip it, re-open is a no-op.

Also (red): setArtifactVisibility now does its read-modify-write in a transaction like
setGrant (LOW); a scale note at the loaders documents the in-memory-fold tradeoff +
indexed-Filter escalation (MEDIUM, org-bounded); postGrant's client-supplied project
is confirmed intentional (org is the tenant boundary, project an org-internal target
label) with a clarifying comment (LOW).
2026-07-23 03:33:45 -07:00
hanzo-dev 174e7bcd17 durable: fix live-path lost-write — Record ships (H1), restore dir-order, checkpoint result (M2)
Red H1: research.Record (in-process A/B evidence, experiments.go:425) committed
locally but NEVER shipped — an acked write lost on takeover, and unfenced on a
non-owner. Record now Syncs before returning and propagates a not-acked ship as an
error (mirrors the HTTP shipDurable), so a takeover keeps it and a non-owner fails
closed instead of persisting a stale divergent local copy.

Writing the H1 takeover regression surfaced a real restore bug: restore() wrote the
.dek key sidecar BEFORE RestoreFile created the parent dir, so a fresh successor
(orgs/<slug>/ absent) failed the sidecar write → hydrate degraded → EMPTY store =
the lost write. RestoreFile (which MkdirAll's) now runs first, then the sidecar.
The flat-tempdir unit test masked it; added TestDurableRestoreIntoFreshNestedDir.

Red M2: wal_checkpoint(TRUNCATE) result was discarded — busy!=0 ships a partial
snapshot (committed frames still in WAL) as acked = silent lost write. Now
QueryRow'd; fail closed on busy!=0.

Tests: TestRecordShipsSoTakeoverKeepsIt (takeover keeps the evidence, real cek
key-sidecar cross-pod restore), TestRecordOnNonOwnerFailsClosed, and the nested-dir
restore regression — all green.
2026-07-23 03:30:20 -07:00
hanzo-dev b0222e0c28 compliance,legal: close the 2 red LOWs — role-gate decideAccreditation; catch {{index . "k"}}/{{$.k}} undeclared-field refs 2026-07-23 03:10:46 -07:00
hanzo-dev 8293e9de23 Merge remote-tracking branch 'origin/main' into blue/cloud-adopt-provision 2026-07-23 03:04:14 -07:00
hanzo-dev af16ada769 test(apps): add destinations frozen row — wire golden was latent-red on main (Wire mounted it, frozen omitted it) 2026-07-23 03:02:04 -07:00
hanzo-dev d7e579ff79 test(research): concurrent-ingest seq monotonicity under -race
24 concurrent ingests of distinct content must all land (no lost write, no dup) and
the server-assigned append clock (seq) must be unique + gapless in [1,N] — the
concurrency proof for the per-store seq that replaced SQL AUTOINCREMENT, backed by
OrgDB single-writer + orm writeMu + the ingest tx.
2026-07-23 02:59:34 -07:00
hanzo-dev ec764e44f9 company/compliance/idv/legal: decomplect the KYC/verification decision from the callback
A KYC/verification terminal status is now reached by three orthogonal paths, never a
client-asserted status:

- provider reconcile (pull): company kyc/refresh + compliance verifications/:id/refresh
  consult the wired provider for the settled status; Manual stays pending.
- provider webhook (push): compliance verifications/webhook authenticates by HMAC
  signature (idv.Webhook, KMS-sealed secret, disabled by default) and reconciles the
  referenced check from the provider API, so the request body cannot dictate a status.
- reviewer decision: company kyc/decision (a platform reviewer) and compliance
  verifications/:id/decision (an org admin or platform reviewer) are role-gated and
  attributed (DecidedBy = the acting user), and produce a DISTINCT reviewer_confirmed,
  never a provider_verified.

guardKYCVerified and the compliance decision accept only an attributed provider pass or
reviewer_confirmed. A founder/check records who decided; an unattributed "verified"
fails closed. startKYC clamps a provider's inquiry-time status to pending.

legal: the counsel-review notice is coupled to the category — formation and equity
templates always carry it, forced on override and emitted at render — and an override
body may reference only declared fields.

compliance createAccreditation records only an asserted state; a provider_verified
state routes through the attributed decision endpoint.

Remove the committed cek key sidecars and gitignore the pattern; tests seal their
stores under t.TempDir().
2026-07-23 02:57:21 -07:00
hanzo-dev 6cb979bbb9 feat(compliance,legal): corporate back-office — KYC/KYB orchestration + legal template engine
Hanzo Compliance (/v1/compliance): org-scoped KYC/KYB verification through a
provider-agnostic seam, accreditation-state tracking, and a compliance-scoped read
of the shared tamper-evident audit plane (SOC 2 posture). Subject PII is sealed at
rest (cek) and referenced by opaque id everywhere else — never in logs, audit, or
URLs. No path yields a verified status on create; a terminal decision comes only
from the provider (refresh) or an authenticated, audited callback.

Hanzo Legal (/v1/legal): a versioned, org-overridable standardized template library
plus a pure, deterministic merge-field generation engine, a sealed document store,
and e-sign + filing seams. The counsel-review notice is a non-droppable invariant on
formation and securities documents.

clients/idv: the ONE identity/business verification seam — honest Manual default,
config-driven Persona/Onfido/Stripe adapter with a KMS-sealed key and a strict,
fail-closed status classifier. Consumed by BOTH compliance onboarding KYB and company
formation KYC; company now resolves a real provider from config (fail-closed),
replacing the manual-only default.

Boundary invariant, enforced in the data model and on the wire: platform tooling with
licensed providers and professionals in the loop — provider-reported or tracked states
only, never a platform assertion of "compliant" or "legally valid".
2026-07-23 02:57:21 -07:00
hanzo-dev f822b772a4 test(research): give App.Test the repo-standard 30s timeout, not the 1s default
The research HTTP tests used the bare app.Fiber().Test(req) whose default client
timeout (1s) is too tight for a cold cek-encrypted per-org SQLite open, so they
flaked as i/o timeouts. Match the rest of the repo (base/exec/world tests):
fiber.TestConfig{Timeout: 30 * time.Second}. Assertions unchanged.
2026-07-23 02:56:49 -07:00
hanzo-dev e0e16cb319 cloud: wire the per-org store through the HA-durable path (research)
OrgStore gains an opt-in Durability (WithDurable): when set, forPath hydrates each
org's SQLite from the object store BEFORE opening (elected owner CarryForward-seals
to its lease round; non-owner refreshes read-only), binds the handle, and Sync ships
it fenced after a write. A degraded hydrate never blocks the open — the store always
opens (reads local, writes fail closed) so a second replica can never break it. With
no option it is byte-identical to the local-only cache the other 12 subsystems use.

BuildDeps constructs the deployment Durability (buildDurability): the SeaweedFS S3
If-Match ConditionalStore (same s3admin identity as deps.VFS), membership over
CLOUD_PEERS (the SAME set the shard router elects on), and the per-org envelope
Cipher rooted at the KMS master. No object store ⇒ nil ⇒ local-only. An
encryption-capable build with no cipher is refused (never ship plaintext snapshots).

research wires WithDurable(b.Durable) and calls Sync after each write commits
(ship-before-ack): a not-acked ship (deposed/degraded) returns 503 so the client
retries on the org's current owner — no acknowledged write is lost on failover. On a
local deployment the ship is a successful no-op. Scoped to research as the proof; the
seam (WithDurable) is the one other subsystems adopt next.

durable_test.go adds the cek-encrypting-build coverage: the .dek key sidecar ships
with the database bytes and is restored on hydrate, plus the (sidecar,db) frame
round-trip.
2026-07-23 02:56:25 -07:00
hanzo-dev 5b503ab749 deps: bump hanzoai/vfs v0.6.4 → v0.6.6 (modernc out of the graph)
v0.6.4's replica/sqlite.go blank-imports modernc.org/sqlite directly, which
registers the "sqlite" database/sql driver a SECOND time alongside cek's
hanzoai/sqlite — a double-register panic at init the moment a binary links both
(which wiring internal/org's durable path into cloud is the first to do). v0.6.6
(469995c + ffd32ca) routes replica through the ONE hanzoai/sqlite driver, so a
single registration stands. Patch bump, FencedStore API unchanged.
2026-07-23 02:56:07 -07:00
hanzo-dev 51a35fdcc4 refactor(research): migrate store from raw SQL to hanzoai/orm
Kills the 'no such column' DDL-drift bug class permanently: records are typed Go
values stored as JSON in orm's fixed _entities table, so adding a field is a struct
change with ZERO DDL. orm layers over the per-org *sql.DB cloud.OrgDB already opens
(cek-encrypted, single-writer, WAL) via orm.AdaptSQLite — orm manages the records,
the caller owns the file — so encryption at rest and the HA durability plane are
preserved and the openStore(*sql.DB) seam is unchanged.

Behaviors preserved over the orm model:
- dedup/idempotency: CreateIfAbsent keyed by <project>:<id>:<content_hash>
- versioned/canonical: a per-store monotone seq stamped inside the ingest tx
  (never client ts); canonical = latest-appended non-retracted per stable id
- queries (counts, list, totals, projects, artifacts) reimplemented as in-memory
  folds over orm Query (per-org stores are small)
- evidence model intact: ingest forces private/non-trainable/non-publishable,
  faulted runs retained, artifact sha256 server-derived, provenance first-class

Artifact bytes split into a distinct blob keyspace so the diary feed never loads
blobs, and because orm's _entities id is a global PRIMARY KEY (kind-prefixed ids
keep each kind in its own keyspace). Regression test proves a new provenance field
needs no migration; the 11 evidence-semantics tests pass against the orm store.
2026-07-23 02:53:29 -07:00
hanzo-dev 4f7fcfe9a4 org: Durable — the reusable single-writer + hydrate + fenced-ship gate
Packages the handoff_test.go discipline into one value a per-org SQLite store
wires at open/write/close. Composes the three lanes: ha election (CASFencer
lease = WHO writes), replica.FencedStore (CarryForward-on-takeover + round-fenced
ship = HOW it ships, no acknowledged write lost, deposed writer rejected as
ErrStaleRound), and the org envelope Cipher (durable object at rest).

Snapshot is a raw file copy — checkpoint the WAL on the store's sole connection,
read the actual local file bytes, and ship them framed with the cek .dek key
sidecar. Backend-agnostic: identical whether the local file is cek-encrypted
(production) or plaintext (dev), so an existing on-disk store needs no migration
and cek stays the local at-rest gate.

Hydrate never makes a store unopenable (degrades read-only, writes fail closed) —
the outage was a second replica breaking the store.

durable_test.go proves it over one object store: two replicas contend → one
writes, the other defers, both open, no split-brain; a rolling takeover hydrates
the shipped snapshot with no lost write; a deposed writer is fenced; an
unreachable store degrades read-only, never 'unavailable'.
2026-07-23 02:36:15 -07:00
hanzo-dev 02b090f414 scrub(s3): rename minio→s3/SeaweedFS across object-store code
The object store is the SeaweedFS S3 gateway reached via github.com/hanzoai/s3-go
(package name minio, aliased s3). Rename the import alias minio→s3 and every
minio.X reference to s3.X; MinioConditionalStore→S3ConditionalStore,
NewMinioConditionalStore→NewS3ConditionalStore; fix comments/docs that named the
old MinIO fork. Upstream transitive module names (github.com/minio/*) in
go.mod/go.sum are external deps of hanzoai/s3-go and are left as-is.

No behavior change: pure rename + comments.
2026-07-23 02:19:31 -07:00
hanzo-dev 6c08ad7339 test(metering): fix pre-existing TestRecord_DebitsFinanceInProcess (2 causes)
Pre-existing failure on clean main, unrelated to the pre-pay work — two causes,
both surfaced now that the metering suite runs:

1) Encryption gate: on an encryption-capable (cgo) build, cek refuses to open
   the finance store without CLOUD_KMS_MASTER_KEY_REF. The root package's
   TestMain supplies a throwaway dev key; the clients/metering test package had
   no TestMain, so the finance-in-process test couldn't open a store. Add the
   same dev-key TestMain (mirrors the root; only when the build can encrypt and
   no key is provided — CI's real key still wins).

2) Stale ceil-based assertions vs the exact 18-decimal ledger. Since the
   Money Int->Atto migration, finance debits the EXACT sub-cent amount: a 1.5c
   micros debit lands as 1.5c, leaving 98.5c (/usr/bin/zsh.985) — not a ceiled 98c. The
   RecordResult still reports the ceiled 2c (Cents() rounds up for whole-cent
   contexts), which is unchanged. Assert the exact ledger balance (money.Cmp
   against ParseUSD) instead of bal.Cents(), which ceils 98.5->99. The anti-leak
   invariant holds: the sub-cent debit is recorded exactly, never dropped to 0.

Full metering suite green.
2026-07-23 02:09:39 -07:00
hanzo-dev ab21d87764 test(metering): pre-pay lifecycle — zero balance refused, funded served+debited
The CTO pre-pay acceptance scenario end to end: a freshly provisioned org
starts at a ZERO balance, so a metered request is REFUSED (402, no free floor,
nothing recorded); after a pre-pay deposit lands, the same request is SERVED
and DEBITS the balance; when the balance is exhausted, it is refused again. The
gate (Client.Authorize: funded := available > 0) already enforced this; this is
the explicit acceptance test for the pre-pay model. Stub balance made
thread-safe (setAvailable). Green under -race.
2026-07-23 01:59:22 -07:00
hanzo-dev e16fd7a237 merge main into CDP merge (concurrent advance) 2026-07-23 01:42:14 -07:00
hanzo-dev bc6d0a046b research: idempotent migrate — converge old-schema stores, fix outage
The store returned 500 'research store unavailable' because migrate() ran
CREATE TABLE IF NOT EXISTS (a no-op on an existing table) then CREATE INDEX
ix_exp_git ON experiment(git_sha) — which failed 'no such column: git_sha'
on any org store created before the provenance columns landed. CREATE TABLE
IF NOT EXISTS never adds a column to an existing table.

Fix: after creating the tables, ALTER TABLE ADD COLUMN every current non-key
column (tolerating 'duplicate column name' when already present), THEN the
indexes. migrate() now converges any older schema to the current column set
and is idempotent across re-opens. Adding a column = add it to the CREATE
plus the ensure list. Regression tests reproduce the outage (old table
without git_sha) + idempotent re-open + fresh-db all-duplicates.
2026-07-23 01:41:43 -07:00
hanzo-dev 072b4e1fde research: idempotent migrate — converge old-schema stores, fix outage
The store returned 500 'research store unavailable' because migrate() ran
CREATE TABLE IF NOT EXISTS (a no-op on an existing table) then CREATE INDEX
ix_exp_git ON experiment(git_sha) — which failed 'no such column: git_sha'
on any org store created before the provenance columns landed. CREATE TABLE
IF NOT EXISTS never adds a column to an existing table.

Fix: after creating the tables, ALTER TABLE ADD COLUMN every current non-key
column (tolerating 'duplicate column name' when already present), THEN the
indexes. migrate() now converges any older schema to the current column set
and is idempotent across re-opens. Adding a column = add it to the CREATE
plus the ensure list. Regression tests reproduce the outage (old table
without git_sha) + idempotent re-open + fresh-db all-duplicates.
2026-07-23 01:41:03 -07:00
hanzo-dev 09be0200fb merge(cloud): CDP destinations (GA4/Meta/TikTok/Reddit/LinkedIn/X) + AI GTM
Server-side fan-out behind /v1/event: clients/destinations (6 adapters, per-org
KMS-custodied secrets, org-isolated store, bounded fail-soft fan-out), the RAW
pre-scrub forward sink in clients/analytics, and the AI-GTM analytics lens +
destinations_connect tool in clients/guide. Additive; no go.mod change.
2026-07-23 01:40:29 -07:00
hanzo-dev 4d25ca740d fix(help): ingress-owned edge limit, gated categories, opaque ticket ref, capped intake
Red-review fixes on the /v1/help public plane:

- Drop the app per-IP rate limiter. Behind hanzoai/ingress the socket peer IS the
  ingress, so a per-IP limiter keys every customer to ONE shared bucket (a global
  throttle + trivial DoS) and X-Forwarded-For is client-settable (a fresh value per
  request evades the limit and grows the bucket map without bound). The ingress owns
  the per-client edge limit; the plane bounds each request instead.

- GET /v1/help/categories now returns only sections that front a Published + public
  article, so an internal (agent-only) category name or description never leaks.

- Intake returns an opaque random public_ref in place of the monotonic ticket name,
  so an anonymous submitter cannot read the org's ticket volume. The sequential name
  stays internal to the agent plane.

- Cap the whole intake body at 64 KiB before parsing.

Tests: clients/help green (adds no-shared-bucket-throttle, category-gating, oversized-
body, opaque-ref assertions).
2026-07-23 01:38:36 -07:00
blueandhanzo-dev 7a8a1c35e0 feat(help): native /v1/help support product on the framework engine
Complete the Hanzo Support model as DocType fixtures and add the thin
/v1/help public plane — the native-Go replacement for the Frappe Helpdesk
(Vue + Python Frappe on Werkzeug), on Base in the one cloud binary.

Model (clients/help/help.go): add hd-article + hd-article-category (the KB)
and hd-communication (the ticket conversation thread); add a source field to
hd-ticket for inbound-connector provenance. Agents author and triage all of
it on the generic role-gated /v1/framework/hd-* surface — no new agent code.

Public plane (clients/help/subsystem.go): the anonymous face the secure-by-
default engine cannot serve — GET /v1/help/articles + /articles/:slug (only
status=Published AND is_public=1, re-checked on direct fetch), /categories,
and POST /v1/help/tickets (rate-limited customer intake creating the ticket
plus its opening conversation message). The served org is fixed server-side
(CLOUD_HELP_PUBLIC_ORG, else the deployment brand), never client-chosen, and
fails closed when unset. Storage delegates entirely to the framework
in-process API — one engine, no duplicated CRUD.

Wire help as a mount subsystem (apps.go + frozen wire order), alongside
knowledge, the other framework lane with a companion subsystem.
2026-07-23 01:38:36 -07:00
hanzo-dev cccf8f60dd feat(fleet): GB10-class unified SoC inventory — machine RAM, sm arch, CUDA/driver
nvidia-smi reports memory.total as [N/A] on a Grace-Blackwell SoC (no
dedicated VRAM counter) so the board showed nothing for spark. A unified
NVIDIA SoC now reports the machine RAM snapped to hardware capacity
(snap bound widened to 8 GiB — GB10 firmware reserves ~6.3 GiB), its sm
arch from compute_cap (12.1 -> sm_121), and the host CUDA toolkit +
driver versions ride the registration, mirroring the AMD rocm/hip pair.
2026-07-23 01:27:09 -07:00
hanzo-dev c0e06e5cbd fix(fleet): unified APU reports the machine RAM, snapped to hardware capacity
An APU board figure now matches the Apple convention: the unified pool is
the MACHINE memory (128 GiB Strix Halo says 128 GiB), not the GTT tunable.
Kernel-visible MemTotal snaps up to the next 16 GiB DIMM capacity only when
the gap is a plausible firmware reservation (<= 6 GiB); a real carve-out
stays honest. evo: 118 GiB GTT / 124.4 GiB visible -> 131072 MiB.
2026-07-23 01:19:23 -07:00
antje 9a51bffbcb refactor(admin): rename /v1/admin/storage → /v1/admin/block-storage
Free /v1/admin/storage for the operator's S3 object-buckets view (a distinct
storage concern); the DO block-volume + datastore-fill fleet is /block-storage.
No consumers yet, so the rename is clean. Handler storage→blockStorage, file
storage.go→block_storage.go (history preserved). Pairs with the operator SPA
Block Storage page + console repoint.
2026-07-22 23:44:21 -07:00
hanzo-dev b80bc9c37a feat(functions): target=fleet — run a function on the org GPU fleet
A python function created with target=fleet invokes as an fn.run job on
the org gpu-jobs queue through the ONE embedded tasks engine (new
View.StartActivity/DescribeActivity seam, tasks v1.51.4): same queue,
same claim loop, same result a direct tasks-API submit gets. invoke
blocks bounded by TimeoutSec (900s ceiling, matching the sandbox);
longer jobs belong on the tasks API. Job outcome maps onto the sandbox
execResult contract so billing gate + metering + invocation recording
are identical across executors; fail-closed when the engine is not
ready. Also: functions suite gains the sibling TestMain encryption
harness (suite now runs on an encryption-capable build without CI env).
2026-07-22 23:37:53 -07:00
hanzo-dev 5540308d26 feat(destinations): fan the /v1/event stream out to ad + analytics platforms
Translate the canonical /v1/event stream to each connected platform's
conversion schema and forward it server-side. GA4 (Measurement Protocol)
and Meta (Pixel + Conversions API) work end-to-end; X, LinkedIn, TikTok,
and Reddit are scaffolded against the same Destination interface.

- clients/destinations: the Destination interface + per-platform adapters
  + translator (canonical EVENTS -> normalized conversion) + per-org
  registry (Base/SQLite, API secrets KMS-sealed) + the analytics fan-out
  consumer. A destination may reuse an integrations OAuth token
  (Meta CAPI <- meta_ads) or seal its own; PII match keys are SHA-256
  hashed before send.
- clients/analytics: a one-way fan-out sink after the write core, handed
  the RAW (pre-scrub) batch so a Conversions-API forwarder can hash the
  match keys the warehouse drops; detached + fail-soft, no-op when unset.
- clients/guide: read the analytics funnel (GET /v1/guide/analytics + an
  overview fold) and expose destinations_connect as an MCP tool.

Tests: translator mapping, GA4 + Meta send end-to-end (mock + PII
hashing), store tenant isolation, fan-out, and the connect seam.
2026-07-22 23:11:51 -07:00
hanzo-dev ddffb21a3b benchmark: keep fugu as arena DATA (a benchmarked/routable model), not marketing
Restore the fugu-ultra published_claim rows + the test fixture — fugu is just
another model we verify in the arena and can route to, like grok/gpt/opus. The
comments stay neutral (no 'disprove the rival' narration). The rule is only: no
fugu on the Enso MARKETING pages (hanzo.ai/enso — already clean). Reverses the
over-broad purge in 8b3aec0.
2026-07-22 23:07:28 -07:00
hanzo-dev 8b3aec0e95 benchmark: purge competitor from the public /v1/benchmark arena
Remove the 4 sakana/fugu-ultra published_claim rows + scrub the two comments that
named the competitor (house rule: no competitor names in source). The public
leaderboard (unauth GET /v1/benchmark/leaderboard) no longer exposes it. Legit
arena models (grok/gpt/opus/fable etc., provider-reported) are unchanged. Removed
the fugu-ultra test fixture (594 attempts remain, test still >=500 green).
2026-07-22 23:03:12 -07:00
antje e0466a63bf feat(admin): GET /v1/admin/storage — DO block-storage fleet + datastore fill
The data source for admin.hanzo.ai's realtime Block Storage board, so we can
watch the analytics datastore fill and scale DO storage before it runs out.
SuperAdmin only (the s.guard wrap). Two REAL sources, each degrading independently:

- Fleet inventory (count · total capacity · monthly cost · per-volume region +
  attachment) from the DigitalOcean API — a new Volumes() on the existing
  DO_API_TOKEN client (paginated). DO gives capacity + attachment but NOT fill %,
  so a volume's used/pct stay ABSENT (the console renders "—", never a fabricated
  number).
- The analytics datastore's own fill from ClickHouse system.disks (the 200Gi PVC)
  over the SAME shared aiobject.DatastoreQuery the analytics + compute lenses use —
  no second connection. This is THE number the operator scales on. Near-full
  volumes raise an alert (warn ≥ 80%, critical ≥ 90%).

Honest by construction: DO unconfigured → empty fleet; datastore not connected →
no datastore card. admin creates no table and holds no storage state — it only reads.

Pure buildStorageSnapshot / alertLevel / datastoreFillFromRow are unit-tested
(fleet totals, cost, absent per-volume fill, the datastore card + alert, the
system.disks bytes→GiB→pct math, zero-capacity → nil). Pairs with console 8.4.151.
2026-07-22 22:36:12 -07:00
hanzo-dev 7e63dbad36 merge(main): integrate main (experiments+catalog) into campaign-gtm 2026-07-22 22:20:48 -07:00
hanzo-dev f77706ea52 merge(main): integrate latest main into connectors-catalog-100 2026-07-22 22:18:54 -07:00
hanzo-dev d5c05c8e51 fix(connectors): pin salesforce instance_url to SF hosts (reject IP/IMDS/port), invert recaptcha to allowlist, twitter confidential-client guard (red) 2026-07-22 22:18:29 -07:00
hanzo-dev 48c8e965f0 feat(fleet): fn.run — the functions-runner lane on a linked node
A linked node with uv claims fn.run jobs from the org gpu-jobs queue: an
inline Python script executed in an ephemeral uv run env on the node own
GPU stack, result = output tail + exit code + duration; nonzero exit
fails the activity with the traceback. Poison-loop guard becomes
per-lane, so a render-less node (evo) still claims its own lanes and
declines renders back to the queue. E2E-proven: submit via tasks API ->
evo claim -> uv run (rocm-smi visible) -> result GET.
2026-07-22 22:14:04 -07:00
hanzo-dev 898bc0aa7f feat(campaign): compose the merged experiments primitive for creative A/B
Rebased onto origin/main (experiments primitive landed @541f1b4). Campaign A/B now
composes clients/experiments instead of a nil-seam placeholder:

- experiment.go: AssignFunc composes experiments.Assign (bucketing); AnalyzeFunc
  composes experiments.Analyze (pull-model — it reads the metric from analytics
  itself, no duplicate measurement). Both nil-safe: single-creative honest default.
- metrics.go: GET /v1/campaign/:id/metrics embeds the A/B analysis (abTest) when a
  campaign runs >1 creative and an experiment is wired.
- apps/wire_seams.go: SetExperiment wires Assign+Analyze at the composition root
  (campaign-linked experiments live in the org's default project).

Tests: assigned variant flows to the executor plan (utm_content); single-creative
never assigns; assign-error fails soft to the default creative.
2026-07-22 22:00:10 -07:00
hanzo-dev 725f2f808e feat(campaign): /v1/campaign GTM orchestration + paid channel consumes connectors
Top-level go-to-market plane: a Campaign is a VALUE that spans channels; a
Channel is an orthogonal EXECUTOR it fans out to. Each channel consumes the
connector plane via integrations.TokenFor — the campaign object never touches a
credential.

- clients/campaign: Campaign model + store (per-org SQLite, cek at rest), the
  Channel interface + injected-func registry, /v1/campaign surface (CRUD +
  launch/pause fan-out + channels add/remove + metrics), experiment seam for
  creative A/B (nil-safe).
- clients/ads/provider.go: the ad-network execution edge (LaunchPaid/PaidSpend/
  PausePaid) — resolves the org's ad token via TokenFor, Meta executed for real,
  fail-closed on a disabled connector. Standalone POST /v1/ads/campaigns/:id/launch.
- clients/analytics/campaign.go: CampaignMetrics — the ONE campaign-metrics query
  over hanzo.events, org + utm_campaign bound positionally (tenancy invariant).
- apps/wire_seams.go: paid channel wired at the composition root (campaign never
  imports ads, ads never imports campaign).

Tests: fan-out, connector-consumes-token (httptest Meta), tenant isolation,
connector-disabled-blocks-send, spend fan-in, analytics tenancy predicate.
2026-07-22 21:54:30 -07:00
blueandhanzo-dev 1c9df53bb1 connectors: complete third-party catalog to 100% of the historical list
Add 20 connectors on the established declarative planes (no plane changes):

Key connectors (userScope, keyVerify) — CRM/support, analytics, commerce, content:
  zendesk, pipedrive, intercom, reamaze, optimizely, amplitude, mixpanel,
  posthog, coinbase_commerce, shipstation, shipwire, contentful, netlify.
Bespoke body-inspecting Verify (200-with-error-in-body) over a shared, redirect-
refusing transport (verifypost.go): authorizenet, recaptcha.
Org OAuth (AdminOnly, reuse the callback/state plane): salesforce (instance_url
  custody + SSRF guard), adroll, twitter (S256 PKCE derived from the client secret
  so no shared Authorize/Exchange contract changes), google_bigquery, google_cloud
  (reuse google.go plumbing, read-only scopes).

One shared subdomain-origin normalizer (zendesk/reamaze); shopify keeps its own.
Least-privilege scopes throughout; fail-closed + token-free verified per connector.
Real httptest tests: wiring/placement, KMS seal, fail-closed, tenant isolation,
token-free, least-privilege scope, PKCE challenge!=verifier, instance_url SSRF.
2026-07-22 21:53:26 -07:00
hanzo-dev 541f1b4cf8 merge(main): integrate latest main into feat/experiments 2026-07-22 21:40:22 -07:00
hanzo-dev c47252ee73 fix(experiments): gate winner-promotion on org admin (red finding — a prod flag write) 2026-07-22 21:39:39 -07:00
hanzo-dev ba98244759 feat(fleet): render an AMD APU as the processor it is
A unified-memory APU now reports the cpuinfo marketing name, normalized
from BIOS shouting: AMD RYZEN AI MAX+ 395 w/ Radeon 8060S ->
"AMD Ryzen AI Max+ 395 w/ Radeon 8060S (gfx1151)". Model numbers keep
their casing; discrete cards and non-Ryzen hosts keep their names.
2026-07-22 20:54:20 -07:00
hanzo-dev 9343cbf63f feat(account): remove trial funding — usage is pre-paid, org starts at zero
Business model: no trial credits. Onboarding no longer grants any starter
credit — onboardFirstRun drops the commerce GrantCredit call, the account
state drops the commerce client, and provision's result drops trialGranted.
The first-run path still drives the ONE atomic IAM provision (org + admin
move + hashed org-scoped credential); the org just starts at a zero balance.

Removed payout.Client.GrantCredit (its only caller was this funding path —
no dead code); the additive Deposit + the referral/affiliate/author programs
are untouched.

Test: first-run drives provision ONCE (owner/name from get-user, resolved
slug), no funding call; a retry converges.
2026-07-22 20:48:37 -07:00
hanzo-dev e2684eafad feat(experiments): unified A/B EXPERIMENT primitive composing flags+analytics+research
/v1/experiments is the VALUE that composes three existing planes, never a fourth
engine. ASSIGNMENT = flags (deterministic subject->variant via engineEvaluate; no
second bucketing, no assignment store). MEASUREMENT = analytics (one org-scoped
hanzo.events query; the eventsWhere isolation invariant). EVIDENCE = research
(per-variant samples as kind=ab rows; two-proportion z significance is a pure
function over them). Lifecycle: create writes the multivariate flag def; assign is
a flags evaluation; analyze folds analytics outcomes per variant by joining each
subject to its flags assignment; decide promotes a winner by rewriting the flag
rollout to 100%.

Seams added (additive, one-way): flags.Assign/PutDef/GetDef, analytics.Outcomes,
research.Record/List. clients/campaign composes experiments.Assign/Analyze to run a
creative A/B without reinventing assignment or evidence.

Variant KIND is orthogonal: variant.payload can be a feature config, ad-creative
id, email subject, or model id.
2026-07-22 20:42:07 -07:00
hanzo-dev db33e46737 feat(fleet): honest AMD APU inventory — unified memory, family name, arch + ROCm/HIP
- An APU whose VRAM is a token carve-out now reports its GTT unified pool
  (Strix Halo: 1 GiB VRAM carve-out vs ~118 GiB usable) with unified=true;
  discrete cards keep dedicated VRAM. Fixes evo showing 1 GB.
- gfx family naming: gfx1151 -> AMD Strix Halo (also Strix Point, Phoenix),
  so the board says what the silicon is, not just the iGPU series.
- Inventory records the native gfx arch per GPU and the host ROCm + HIP
  versions (/opt/rocm/.info/version, hipconfig), surfaced on /v1/fleet/workers.
- docs: bring-your-gpu.md converged on hanzo link (gpu connect is gone).
2026-07-22 20:40:35 -07:00
hanzo-dev 3d1b9828b5 feat(account): onboard drives ONE atomic IAM provision + funds the trial
The production signup path (POST /v1/iam/onboard) did create-organization THEN
move-user as two non-atomic S2S calls — a fault between them orphaned the org
(the exact bug IAM's provision() fixed), and it minted no credential and
granted no trial. First-run onboarding now calls the ONE atomic
POST /v1/iam/admin/provision (service-token) instead: org + admin move +
hashed credential + trial claim converge, and a mid-flight retry resumes on
the founder's own org rather than orphaning it.

On the one-time trial grant (the once-per-verified-identity claim IAM
returns), it funds the identity's CANONICAL org via commerce
POST /v1/billing/credit with a per-identity idempotency key
(starter:<verified-email>) — so the per-org dedup is a per-identity dedup, the
anti-farm gate (one trial per identity, not per org). Funding is best-effort +
idempotent: a hiccup never fails an onboarding that already provisioned.

The additional-org flow (caller already has a home org) is unchanged
(create-without-move). A legacy fallback keeps the old pair when the service
token is unset, so a partial deploy still onboards.

Test: first-run drives provision ONCE (owner/name from get-user, resolved
slug), funds with the per-identity key + bounded 500c to the canonical org,
and a retry grants no second trial → never double-funds.
2026-07-22 20:33:20 -07:00
hanzo-dev c11058ed05 Merge remote-tracking branch 'origin/main' into blue/research
# Conflicts:
#	apps/apps.go
#	apps/wire_test.go
#	clients/research/datastore.go
#	clients/research/research.go
#	clients/research/research_test.go
#	clients/research/ssrf.go
#	clients/research/store.go
2026-07-22 20:32:44 -07:00
hanzo-dev 68b1a17903 feat(payout): idempotent GrantCredit (POST /v1/billing/credit) for on-signup funding
Deposit (POST /v1/billing/deposit) is additive — a retried onboard would
double-fund. GrantCredit posts the idempotent /v1/billing/credit: commerce
dedups on idempotencyKey, so the same key credits at most once. The starter
grant uses a per-identity key against the identity's canonical org, so the
per-org dedup is a per-identity dedup — the trial-abuse gate (one trial per
verified identity, not per org).

A *Client method only (not the shared Commerce interface), so the authors/
affiliates seams are unaffected.

Tests: posts /v1/billing/credit with the per-identity idempotencyKey +
bounded 500c + starter-credit tag + org namespace; unconfigured is an honest
error.
2026-07-22 20:26:04 -07:00
hanzo-dev 2c88a30872 research: N2 — supersession is seq-authoritative, not client ts
Canonical ordering used client ts first. But the backfill importer stamps a wall-clock ts
(~1.7e9) while the SDK live path sends none (ts=0), so an SDK correction/retraction (ts=0)
of a backfilled experiment (ts=big) sorted BEFORE it and NEVER superseded — the stale
value stayed canonical while the producer saw added=1 (the exact 91.4→69.7 stale-correction
case in the intended backfill+live-SDK workflow). canonicalExpWhere/AttWhere now order by
the server's monotonic append clock seq (latest-APPEND-wins, un-forgeable by a client); ts
is display-only (measured-at). datastore.go notes the deferred canonical OLAP read must be
seq-authoritative too (never argMax(ts)).

Cross-tool PoC (TestSupersessionIsSeqNotClientTS): ts=big backfill → ts=0 SDK correction
wins (69.7) AND ts=0 SDK retraction withdraws (canonical=0); attempt correction likewise;
all 17 tests green.
2026-07-22 20:23:18 -07:00
hanzo-dev 12e3b857ca feat(integrations): Guide marketing/ad connectors — meta/google-ads/analytics/microsoft/tiktok/reddit/linkedin/warpcast/whatsapp (red GO, least-privilege) 2026-07-22 19:59:43 -07:00
hanzo-dev c30eb383db fix(integrations): drop write-capable business_management from meta_ads scopes
Red MEDIUM: meta_ads is documented read-only, but metaScopes carried
business_management — read+WRITE to Business Manager assets (users, ad
accounts, system users). Ad-account enumeration uses /me/adaccounts under
ads_read, so no read path needs it; the custodied ~60-day token no longer
carries business-asset write authority.

Harden the forbidden-scope guard to reject business_management and any
*_management / rw_* grant (not just ads_management), so a write scope cannot
regress. Add two tests: the short-token fallback (fb_exchange_token leg fails →
seal the short token with its real ~now+3600 expiry, no refresh) and a
cross-provider state-confusion negative (a meta_ads state replayed at
google_ads/callback is rejected as invalid state).
2026-07-22 19:49:58 -07:00
hanzo-dev 30175e876a research: M1/M2 content-address artifacts server-side; M3 close SSRF denylist gaps
M1: the artifact sha256 was client-asserted + never verified (poisonable, hash-addressed
unearned). Now the caller submits the BYTES (base64 content); the SERVER hashes them and
that sha256 is the identity + ref (sha256:<hash>). A client-asserted sha256 must MATCH
(else 422); content is required; poisoning needs a preimage. Bytes are stored + retrievable
by hash (GET /v1/research/artifacts/:sha256, org-scoped). First-writer-wins resolved.

M2: Artifact.ref was an ungated file:// twin of the SSRF-gated endpoint. The server now
DERIVES ref = sha256:<hash> and ignores any client ref — no file:///etc/passwd can be
stored or served back.

M3: ssrfSafe comment corrected to best-effort INGEST HYGIENE (not the dial-time control);
added the ranges the stdlib predicates miss — CGNAT 100.64/10 (Alibaba IMDS
100.100.100.200), IANA protocol 192.0.0/24 (Oracle IMDS 192.0.0.192), 0/8, TEST-NETs,
198.18/15, 240/4, NAT64, v6 doc; named dialTimeGuardTODO as the greppable pin required
before any endpoint dialer ships.

17 tests pass incl content-address poisoning-rejected + blob round-trip + new SSRF ranges.
2026-07-22 19:48:50 -07:00
hanzo-dev 756f995d81 research: H1 — honor retraction (was a silent no-op)
revision is now part of content identity (hashExp/hashAtt) so a retraction of an
otherwise-identical run is a DISTINCT retained version, not an INSERT-OR-IGNORE no-op.
Canonical redefined: THE single latest version by (ts,seq); if that latest is retracted
the id has NO canonical (withdrawn) — the NOT EXISTS no longer filters revision, so a
newest-retraction wins and no earlier version resurfaces. Also canonicalize hashed JSON
(sorted-key, whitespace-free) so a re-serialization never mints a spurious version (L3).

Red PoC: retract → withdrawn from canonical (0), retained keeps both (2); a later
correction restores canonical. 16 tests pass.
2026-07-22 19:41:24 -07:00
hanzo-dev 4ab24f8884 feat(integrations): Guide marketing/ad/analytics OAuth connectors
Add nine org-scoped connectors for the Business AI Guide's Account-Linking
step, on the same registry + KMS-sealed custody as google/github/cloudflare:

  meta_ads          OAuth  Advertising  Facebook/Instagram Ads + Pages (long-lived token)
  google_ads        OAuth  Advertising  reuses google.go plumbing, adwords scope
  google_analytics  OAuth  Analytics    reuses google.go plumbing, analytics.readonly
  microsoft_ads     OAuth  Advertising  Microsoft Advertising (Bing), refresh token
  tiktok_ads        OAuth  Advertising  TikTok for Business, durable token
  reddit_ads        OAuth  Advertising  Basic-auth client, refresh token
  linkedin_ads      OAuth  Advertising  read + reporting, optional refresh
  warpcast          apikey Social       Farcaster via Neynar key (keyVerify)
  whatsapp          apikey Messaging    WhatsApp Cloud API token + phone id (keyVerify)

Each is one declarative provider file over the existing Provider contract;
oauth_http.go is the shared bounded, token-free transport the form/JSON
exchanges reuse. Scopes are least-privilege (read/reporting; write scopes
added when a write path is wired). All AdminOnly — linking a client's
marketing account is an org-admin action. Tokens are read by the ads /
analytics / marketing readers via integrations.TokenFor(org, provider, name).

Tests: authorize/exchange against httptest stand-ins, KMS seal-before-row,
verify-before-store fail-closed, tenant isolation, org-admin gate, and
token-free errors/responses per provider.
2026-07-22 19:31:20 -07:00
hanzo-dev 7cc3d501fe feat(venue): /v1/cloud DO/AWS/GCP/Azure account connectors → fleet fold (red GO all 4; SafeRESTConfig gate) 2026-07-22 19:28:45 -07:00
hanzo-dev b47a01e4e1 research: wire /v1/research + R&D Ops Board into the cloud binary
The evidence plane (HIP-0512) was built + committed but orphaned — absent from
apps.Wire(), so it 404'd in prod and the board showed only its seed snapshot.
Wire it as a non-staged subsystem (mounts under the production mount-all default),
the arena's sibling right after benchmark, and mount its embedded R&D Ops Board at
/research (same-origin with the plane it reads).

- apps.go: import + {Name:"research", Mount, Shutdown} after benchmark
- wire_test.go: frozen sequence updated (research, non-staged, has Shutdown)
- research.go: mount the embedded board UI at /research (like tasks/ui)
- clients/research/ui: the board embed + serving test

go build ./cmd/cloud links (617MB); apps wire/staged guards + full research
suite green. Goes live at api.hanzo.ai/v1/research + /research on image deploy.
2026-07-22 19:25:34 -07:00
hanzo-dev 0bee60a961 fix(venue,fleet): close Red's 3 HIGH + 1 MED in the discovery/fold + keyless-verify path
HIGH-2 (exec-kubeconfig RCE) + HIGH-3 (SSRF guarded the wrong field): one hardened
fold entrypoint. fleet.SafeRESTConfig — which dynFromKubeconfig/Register now funnel
through (so it also closes the shared visor.attachCluster BYO path) — REJECTS a
kubeconfig that carries an exec or auth-provider credential plugin (client-go would
run a local binary with the pod's full env) and SSRF-guards restCfg.Host, the ACTUAL
dial target the node-list hits (https-only; no loopback/private/link-local/IMDS/
unspecified/multicast). The guard now covers the kubeconfig SERVER, not the
discovery-reported endpoint (which diverges for DO/Azure). Bypass env
FLEET_ALLOW_PRIVATE_HOSTS for loopback test apiservers only.

HIGH-1 (GCP credentialJson SSRF/LFI/exfil): validateGoogleCredential fail-closes the
customer credentials JSON BEFORE google.CredentialsFromJSON — only a service_account
key is accepted (token_uri pinned to a google host); an external_account (WIF) config,
whose credential_source.url/file and token_url are attacker-chosen (pod SSRF /
arbitrary file read / token exfiltration, firing during verify), is rejected. Keyless
WIF becomes a Hanzo-owned config (follow-on), never customer-authored.

MED-4 (AWS region SSRF): a region is validated against ^[a-z]{2}-[a-z]+-\d+$ before
it is interpolated into the STS/EKS host, so "evil.com/" can never redirect the
assumed-role token off-account.

Venue's wrong-field endpoint pre-guard is removed (fleet is the one gate). Tests:
fleet SafeRESTConfig (exec/auth-provider/non-routable/non-https reject, public allow,
bypass); venue exec-kubeconfig-not-folded, server-diverges-from-endpoint-guarded,
hostile-external_account-rejected, validateGoogleCredential, validRegion. Fold-consumer
suites (visor/ml/fleet) green; cmd/cloud links. (Harness: fiber v3 Test timeout raised
off its 1s default so CI-box load can't flake the fold's TLS dial.)
2026-07-22 19:19:57 -07:00
hanzo-dev c9702cb422 docs(LLM): add /v1/cloud venue plane to the Open Cloud planes map 2026-07-22 19:19:57 -07:00
hanzo-dev 7d47001c55 feat(venue): add Azure (AKS) as the 4th provider — AAD + ARM REST, keyless WIF
Same discovery interface as DO/AWS/GCP, folding into the ONE fleet. An org
registers an Azure AD app (tenant + client): a client_secret drives the
service-principal flow; its absence selects KEYLESS Workload Identity Federation
(Hanzo presents its own federated OIDC token as a client assertion — no customer
secret stored). Discovery is plain ARM REST (net/http, no azure-sdk-for-go): GET
managedClusters per subscription, POST listClusterUserCredential for each
kubeconfig, fold. Tests cover both the SP and keyless-WIF paths (httptest AAD +
ARM stubs) plus token-free errors and keyless-seal.
2026-07-22 19:19:57 -07:00
hanzo-dev e9dfb16b5e feat(base): forward /v1/collections/* to the managed Base orchestrator
The go:embed console (served BY the cloud binary) drives the Base product's
data plane at /v1/collections/* but has no Next BFF (pruned by build:embed), so
the calls hit cloud's native /v1 router — which had no such route → 404, and the
whole Base product (Bases manager + Records) went dark on console.hanzo.ai.

Add a principal-gated reverse proxy (clients/base/collections.go, the clients/o11y
pattern) that forwards /v1/collections[/*] 1:1 to the managed Base (base.hanzo.ai,
in-cluster Service; overridable via CLOUD_BASE_ORCHESTRATOR_URL/HOST) — the
in-binary replacement for the retired console BFF. cloud can't mint a hanzo.id JWT
(JWKS-only), so it forwards the caller's OWN Bearer; the managed Base validates it
and scopes tenants by the token subject. allowCollections reproduces the BFF's
least-privilege allowBaseSurface (collections data plane only; no Base admin tunnel).
Always on, before the embed gate. Tests: allow-list, director (Host+path+bearer),
principal gate (403 fail-closed).
2026-07-22 19:16:20 -07:00
hanzo-dev 7b7c53dcb4 research: COALESCE empty-store totals (SUM over zero rows is NULL) + regression test 2026-07-22 18:24:29 -07:00
hanzo-dev 6ba7b03d2e feat(venue): /v1/cloud connect-a-cloud-account plane — discover org clusters, fold into the ONE fleet
An org links labeled DigitalOcean / AWS / GCP accounts (verified live, KMS-sealed,
keyless where possible); venue discovers each account's native Kubernetes clusters
and folds them into clients/fleet.Register — the same registry visor surfaces at
/v1/clusters and ml federates onto. No second cluster registry.

- DigitalOcean: PAT -> GET /v2/account, list DOKS clusters, pull each kubeconfig (godo).
- AWS: cross-account STS AssumeRole pinned by external id (confused-deputy), eks
  List/DescribeCluster, k8s-aws-v1 presigned token. Hand-rolled REST + owned SigV4
  (pinned by the canonical get-vanilla vector); no aws-sdk-go-v2 service trees.
- GCP: Workload Identity Federation or SA key via oauth2/google, container REST
  clusters.list; no google container SDK / gRPC.

Multi-credential per org (labeled, /orgs/{org}/cloud/{provider}/{label}), org-scoped
and tenant-isolated, admin-gated mutations, SSRF guard on discovered endpoints,
per-new-cluster billing meter through the shared compute fee. One discovery
interface, thin providers behind it. Zero new module deps (godo + oauth2 present).
2026-07-22 18:24:22 -07:00
hanzo-dev 1b8d6f1d50 merge(main): integrate parallel main into the 3 drive-home plane merges 2026-07-22 18:24:06 -07:00
zeekayandClaude Opus 4.8 6c9de45895 feat(analytics): behavior read lenses on /v1/analytics/top (pages/referrers/sources)
Extend the ONE breakdown endpoint with three org-scoped behavior lenses over
hanzo.events — answering WHERE people go, WHAT they look at, and WHERE they come
from — as ranked {key, pageviews, visitors, pct} lists alongside models/products:

- topPages       GROUP BY path (WHERE event='$pageview')
- topReferrers   GROUP BY referrer_domain, self/empty referrer bucketed "(direct)"
- topSources     GROUP BY utm_source, empty utm bucketed "(none)"

Read-side only: no ingest/write-core/schema change. Each lens rides eventsWhere
so the org is bound POSITIONALLY (never interpolated) — same tenant boundary as
every other query. pct is share of the TRUE in-window pageview total via
`sum(pageviews) OVER ()`, so a top-N list honestly shows the long tail. Every
lens degrades to honest-empty (available:false, empty items, Debug log) when the
events table is absent/errored — never a 500 (mirrors the overview web lens).

visitors=uniqExact(distinct_id), pageviews=count of $pageview — identical to the
web overview lens.

Tests: breakdownSQL binds org positionally + filters $pageview + buckets
direct/none + window-fn total; buildBreakdown pct is share of in-window total
(not returned-rows sum); honest-empty on datastore error. gofmt+vet clean, no
go.sum drift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 18:23:27 -07:00
hanzo-dev 6355a05279 feat(automations): /v1/automations IFTTT trigger plane on the one engine (red GO: rate-budget bounded) 2026-07-22 18:22:47 -07:00
hanzo-dev 78f832a681 feat(cloudflare): /v1/cloudflare asset plane (Pages/Workers/Workers-AI/R2/KV/D1), Workers-AI on the unified spine (red GO)
# Conflicts:
#	apps/wire_test.go
2026-07-22 18:22:33 -07:00
hanzo-dev f0525629ac feat(integrations): GitHub Pages on the App token (red-SHIP: grant-cached, token-safe, 403-split) 2026-07-22 18:19:38 -07:00
hanzo-dev ba93b55717 research: diary artifacts — POST/GET /v1/research/artifacts (hash-addressed, private-by-default)
Adds the research-diary artifact record (raw-artifact retention class): a
dashboard-snapshot (PNG of the canonical board) or ai-report (generated page),
addressed by sha256 content hash — a re-POST of the same bytes is a no-op (the
hash-addressed idempotency). The bytes live at ref (blob store); this stores the
verifiable manifest with the same provenance (project, git_sha/branch/dirty,
lib_versions) as a run.

  POST /v1/research/artifacts        record one artifact (idempotent by sha256)
  GET  /v1/research/artifacts?run=&project=&since=   diary feed, newest-first

Private by default; public only via the separate visibility grant (POST
/v1/research/grants with sha256), the same rule as runs; no implicit training/commons
rights. Rolls up to hanzo.research_artifact (append-only, keyed by sha256). 14 tests
pass.
2026-07-22 18:12:12 -07:00
hanzo-dev 0ebddafaab research: versioned append-only model + first-class provenance + consent (CTO legal model)
Reshapes the AttemptStore per the updated ToS/DPA/Research-Supplemental terms:

- VERSIONED, not write-once. A correction APPENDS a new version under the same
  stable id; the prior is RETAINED (superseded), never mutated. Version identity is
  content_hash over the measurement AND its provenance, so an idempotent re-ingest is
  a no-op while a corrected number or a run on a new commit/lib version is a new
  retained version. revision ∈ {original,corrected,retracted}; canonical/superseded
  are derived (latest non-retracted per stable id).
- RETAINED vs CANONICAL exposed as distinct counts everywhere (ingest response,
  /totals, /projects). retained is the truth; canonical is the deduped view, so dedup
  never reads as loss. faulted/failed runs are retained (negative results are evidence).
- PROVENANCE first-class + queryable: project (column), git_sha/git_branch (indexed),
  git_dirty, lib_versions (structured JSON) — the longitudinal 'which lib version
  regressed X' record.
- PRIVATE by default; visibility (private/org/public) + trainable + publishable are
  each a SEPARATE authorized grant (POST /v1/research/grants), never implied by upload.
- Retention classes separable (nonpersonal metadata/provenance permanent; raw
  artifacts own retention; secrets never stored). Honest durability label: versioned ·
  append-only; cloud mirroring rolling out — NOT yet immutable/replicated/recovery-tested.

12 tests pass (pure-Go): idempotent-by-content, correction-appends-version,
provenance-distinct-versions, faulted-retained, private-by-default+grant,
projects/totals canonical+retained, SSRF, HTTP round-trip+provenance+tenant-isolation,
grant-separate-from-upload.
2026-07-22 18:01:14 -07:00
hanzo-dev b048bfcdf1 chore(deps): bump hanzoai/ai v1.831.0 (12-model BYOK catalog) + refreeze wire order (benchmark) 2026-07-22 17:49:49 -07:00
hanzo-dev ef454a24cf research: /v1/research evidence plane — per-org SQLite source of truth + hanzoai/datastore roll-up (HIP-0512)
Adds the Hanzo Research surface (HIP-0512 §"Hanzo Research"): every experiment
across every product accrues as immutable, queryable evidence under one
discriminator (kind ∈ benchmark|kernel-perf|training|ablation|policy-eval).

Two planes: each org's transactional SQLite (per HIP-0302, physical file
isolation) is the durable source of truth; it rolls up best-effort into
hanzoai/datastore (ClickHouse) via the account-usage warehouse pattern for the
cross-project OLAP surface. A datastore outage degrades to rolled_up:false, never
a failed ingest.

Ingest is idempotent by stable id — attempt by (project,benchmark,item,model),
experiment by (project,<kind>:<subject>:<task>) with a ts-guarded upsert for
latest-run-canonical — so a corpus imports exactly once however many times it is
replayed (the no-data-loss guarantee) and an out-of-order replay of an older run
cannot regress a newer number.

Surface:
  POST /v1/research/experiments   ingest a batch → SQLite → roll up
  GET  /v1/research/experiments   list, latest-run-canonical (?project= ?kind=)
  GET  /v1/research/projects      every project + real totals (ops board)
  GET  /v1/research/totals        headline aggregate + per-kind (?project=)

Org is the physical tenant boundary (validated principal, never a client field);
project is a first-class column so the org's ops board aggregates across its
projects in one query. BYO endpoint is SSRF-gated at ingest (loopback/private/
link-local/metadata denylist, https required). status is a stored run-state field
(default complete); the live leasing state machine is the durable-execution
increment.

Tests (9, pure-Go): idempotent double-ingest, latest-run-canonical + no-regress,
attempt immutability, project/totals aggregates, SSRF denylist, HTTP round-trip +
tenant isolation + fail-soft roll-up.
2026-07-22 17:49:49 -07:00
hanzo-dev d0529648b9 fix(cloudflare): floor the per-call BYO inference fee (red F-1)
A non-text Workers AI modality (whisper audio, vision/classification image
bytes) yields no prompt text, so aiPromptText → "" → EstTokens → 0 → the
token-denominated BYO fee → 0. ResourceMeter.Gate short-circuits on a
0-cent cost BEFORE calling commerce Authorize, so a frozen / broke /
over-cap org got ungated, unbilled Hanzo-proxied inference — no balance
check, no debit row.

Root cause: a token-denominated fee cannot price a non-text modality. Fix:
floor the per-CALL fee. BYOInferenceFeeMicros now returns max(token fee,
BYOFloorMicros) with a non-zero default floor ($0.0001), so:
  - the gate reservation is ALWAYS >= 1 cent → the gate ALWAYS runs → a
    frozen/broke/over-cap org is REFUSED, never proxied;
  - every /ai/run leaves a debit row >= the floor → no silent proxied call.
This closes F-1 and the F-2 unbilled-call gap (same root cause) together.

Also reorder aiRun: the balance/freeze gate now runs BEFORE account
resolution, so a refused org makes ZERO Cloudflare contact (no discovery,
no run). The floor is one shared, generic knob in the inference-billing
spine (CLOUD_AI_BYO_FLOOR_UUSD) — not a Cloudflare-specific path.

Tests: a broke org + a non-text model is refused 402 with zero CF contact
and zero debits; a funded text run whose model reports no usage still bills
the floor. BYO fee/floor math unit-tested with the floor isolated.
2026-07-22 17:47:21 -07:00
blueandhanzo-dev 48a14de490 fix(automations): bound the trigger run-start surface (RED HIGH-1/MED-1/MED-2/LOW-1)
Deliver was the only run-start path with no bound. Add three orthogonal per-org
bounds in the ONE shared startRun, plus the loop/redelivery hardening:

HIGH-1 amplification (self-trigger loop / fan-out / hook-hammer):
- concurrency: orgRunLimiter now guards EVERY run-start (moved into startRun; runFlow
  parity), full → 429.
- durable rate budget: CountRunsSince (persisted rows, survives restart) caps run-starts
  per rolling minute (CLOUD_AUTOMATIONS_RUNS_PER_MIN, default 300), enforced BEFORE the
  insert → a fan-out/loop hits the ceiling and stops.
- causation depth: TriggerEvent.Depth → FlowRunInput.Depth; Deliver refuses at
  maxCausationDepth so an in-platform cycle terminates. /hooks reads X-Causation-Depth;
  the seam carries depth.

MED-1: the github push webhook skips a bot/App-authored push (isBotActor "[bot]") — our
own outbound mirror pushes AS the App bot, so "on push → our push → push" cannot loop.
MED-2: engine readiness is checked BEFORE the run-row insert, and a transient start
failure DELETEs the row (not FAILED) — a not-ready/crash no longer burns the DedupeKey,
so redelivery retries instead of dropping the event.
LOW-1: /hooks with no X-Idempotency-Key content-hashes the body, so a hammer of identical
POSTs collapses to one run.

Tests (cgo + pure-Go, race-clean): TestDeliverLoopBounded (in-platform cycle terminates),
TestDeliverRateCapped (durable budget), TestDeliverEngineNotReadyRetryable (no key burn),
TestInboundHookContentHashDedupe, TestBotActorGuard.
2026-07-22 17:46:14 -07:00
blueandhanzo-dev a77a938df9 refactor(automations): decomplect trigger-arrival + unify run-start (one way)
Polish toward one-way + compose, no behavior change:

- armTrigger: the ONE trigger-SOURCE arrival plane (cron schedule for POLLING,
  routing-index subscription for WEBHOOK/APP_WEBHOOK, nothing for MANUAL),
  extracted out of setEnabled so status-flip and trigger-arrival are separate
  concerns. The action chain is untouched here — any trigger source pairs with any
  actions (a product, not a switch).
- startRun: the ONE way a firing turns (rule, run id, event) into a durable run —
  persist-gate then dispatch, threading the event payload. Shared by the manual
  /run and event Deliver paths; a cron tick still starts through the engine
  schedule. Deliver collapses to pure match + dispatch; runFlow to a lean handler.

Tests green (cgo + pure-Go), race-clean.
2026-07-22 17:46:14 -07:00
blueandhanzo-dev 133e4a73c7 feat(automations): compose inbound webhooks into the trigger engine (github seam)
Wires the existing verified provider webhooks into the automations Deliver plane
via a composition-root seam, so a real external event fires subscribed flows —
without integrations importing automations (which imports it for credential
custody).

- integrations.SetAutomationTrigger: a primitive-typed seam (nil-safe no-op when
  automations is disabled), set once at the apps composition root to
  automations.Deliver.
- github push webhook now also fires github/push to subscribed flows for the
  signature-verified installation org (best-effort; ev.After is the dedupe key).
- stripe/channels reach the SAME Deliver with a one-line fireTrigger call.

Test: the seam passes the verified event verbatim, fail-closes on empty org, and
no-ops unwired.
2026-07-22 17:46:14 -07:00
blueandhanzo-dev 87f9e08c9a feat(automations): inbound event triggers — fire flows on webhooks/messages/cron
Adds the IFTTT inbound plane to the ONE automations engine: an external event
(provider webhook, inbound message, scheduler tick) starts every enabled flow
whose WEBHOOK/APP_WEBHOOK trigger matches (source, event), threading the event
payload into the run as {{trigger.*}}.

- trigger.go: Deliver(ctx, org, TriggerEvent) — org-scoped match over the routing
  index, per-org durable start via the shared engine (runStarter -> executeFlow),
  at-most-once by DedupeKey (CreateRunIfAbsent gate), fail-closed on a missing org.
- store: automations_triggers routing index (org, provider, event -> flow+version),
  maintained by setEnabled — the inbound analog of the POLLING cron schedule.
- engine: seed outputs["trigger"] so actions read the firing event.
- POST /v1/automations/hooks/:source/:event — authenticated inbound sink.
- each run emits ONE o11y event beside the exactly-once meter + audit.
- main_test.go: cek dev-key TestMain (mirrors clients/sync) so the suite runs under
  cgo and pure-Go alike.

Tests: trigger->action fire + payload threading (real embedded engine), tenant
isolation, idempotency, fail-closed, HTTP org gate, enable/disable lifecycle.
2026-07-22 17:46:14 -07:00
hanzo-dev 0e076a61b4 fix(integrations): address Red review on GitHub Pages (grant cache, 403 disambiguation, secret scrub)
LOW-1: cache the installation grant set per-installation-id (45s TTL) so a
status-polling console no longer re-enumerates up to 100 upstream repo pages
on every Pages request. Keyed strictly by installation id (never org name) and
TTL-bounded, so a reconnect gets a fresh key and a revoked grant cannot outlive
the TTL — the cache can never widen cross-tenant scope.

LOW-2: pagesErr now distinguishes a rate-limit 403/429 (x-ratelimit-remaining:0,
Retry-After, or a rate-limit body) — surfaced as 429 with the retry hint — from a
genuine permission 403 that keeps the actionable re-authorize message.

INFO-1: truncateBody redacts credential-shaped substrings (gh*_ / github_pat_ /
Bearer <value>) from any surfaced provider error body — defense in depth.

Adopts Red's github_pages_adversarial_test.go (two-installation cross-tenant +
injection/cname/token/confused-deputy matrices) and adds fast-follow tests for
the grant-cache keying/collapse, the 403/429 split, and the secret scrub. Full
integrations suite green under -race.
2026-07-22 17:42:03 -07:00
hanzo-dev 7d59226a3b feat(integrations): GitHub Pages management on the App installation token
Five org-authed routes, siblings of the repo list/import, addressing one
repo as a resource on the same short-lived installation token:

  GET    /v1/integrations/github/repos/:repo/pages        status + live URL + custom domain
  POST   /v1/integrations/github/repos/:repo/pages        enable/configure (branch source or Actions)
  PUT    /v1/integrations/github/repos/:repo/pages        set/clear custom domain, HTTPS, source
  DELETE /v1/integrations/github/repos/:repo/pages        disable
  POST   /v1/integrations/github/repos/:repo/pages/builds request a build

Org comes from the validated principal; the repo name is intersected with
the installation's granted set and the owner is taken from GitHub's own
full_name, so a caller can neither address an ungranted repo nor inject an
owner into the GitHub API path. The token rides only the Authorization
header. Fail-closed: unconfigured 503, unconnected 409, ungranted 404.
2026-07-22 17:42:03 -07:00
hanzo-dev 3f7254468e test(apps): refreeze wire golden — add benchmark subsystem
The benchmark subsystem was added to Wire() (native /v1/benchmark arena)
without updating the frozen mount-order golden, so TestWireOrderMatchesFrozen
fails on main (93 Wire specs vs 92 frozen). Add the missing entry in its
Wire() position (after evals). Mechanical refreeze, independent of the
Cloudflare asset plane in the parent commit.
2026-07-22 17:41:07 -07:00
hanzo-dev 10d31b6ff5 feat(cloudflare): first-class /v1/cloudflare/* asset plane
Consolidate the per-org Cloudflare asset plane under the first-class
/v1/cloudflare/* prefix (sibling of /v1/dns, /v1/domain): connecting the
provider stays on the integrations plane, managing resources moves here.

Surface + core-manage each resource against the CF v4 API with the org's
own KMS-sealed token (fail-closed, tenant-isolated on the validated
principal):
- Zones + Analytics (read): list/get zones, zone traffic dashboard
- Pages, Workers (handlers unchanged, prefix relocated)
- Workers AI: POST /ai/run/{model} metered inference
- R2, KV (+ key value get/put/delete), D1 (+ query): wired for real
  (were Phase-2 501 stubs)

Thin hand-rolled REST over the CF v4 API on net/http, decomplected to one
seam: client.send is the ONE authed request/read primitive (Bearer-only,
bounded, token-free); envelope unwrap (exec/cfDo/cfUpload), verbatim relay
(pass), raw value (getRaw) and the AI run (runAI) all compose over it, so
the request/auth/error dance is written once. acctClient/acctWrite are the
ONE auth+account preamble every account-scoped handler shares — each
resource handler is a thin, orthogonal body over those two seams.

Workers AI meters through the ONE unified usage/billing spine
(cloud.AIMeterProvider "ai") and emits the ONE gen_ai o11y span
(clients.StartGenAISpan), at the thin BYO fee since the org's own token
already paid Cloudflare for the compute. New shared, generic (not
CF-specific) inference-billing surface in metered_ai.go: BYOFeeBps /
BYOInferenceFeeMicros / MicrosToGateCents + exported AIMeterProvider and
EstTokens. The gen_ai span constructor is extracted from aihttp.go so
chat, embed and workers-ai share one.

Least-privilege connector scopes grown to match the now-wired surfaces
(R2/KV/D1/Workers-AI/Analytics). Model, key and id path segments are
validated (anchored charset + no-traversal) so no caller value can
smuggle path structure or a host into an upstream URL.

Tests (httptest CF stub + fake commerce): tenant isolation, the model
SSRF guard, the org-admin mutation gate, raw KV value relay, D1 query,
and the Workers AI debit landing on the unified "ai" spine.
2026-07-22 17:41:07 -07:00
hanzo-dev df1e2908ee research: mount /v1/research in the composition root + freeze wire order
Registers the research subsystem in apps.Wire() (after benchmark, before
treasury) with its Shutdown, and freezes its position — and benchmark's, which
closes a pre-existing Wire/frozen gap on main — in TestWireOrderMatchesFrozen.
2026-07-22 17:35:37 -07:00
zeekayandClaude Opus 4.8 120a74b4b0 refactor(analytics): unify event ingest onto ONE canonical door /v1/event
Make POST /v1/event the single canonical ingest door: one handler
implementation, one write core (ingestEvents), serving every wire shape
and every auth context. The other five routes become thin aliases/shims
delegating to it.

- decodeIngest: ONE wire-tolerant decoder accepting a bare Event object,
  a bare [Event] array, AND the {batch:[…]} | {events:[…]} envelope the
  Segment/beacon/publishable paths speak — all yielding the SAME
  []CaptureEvent the write core consumes.
- eventTenant: auth is the orthogonal pluggable concern on the one door —
  (1) IAM bearer, (2) write-only publishable key (pk_, folded in from the
  old /v1/ingest), (3) out-of-band IAM access key; fail-closed 403 with NO
  brand-host fallback on the canonical door.
- ingestBody: the ONE ingest core (tolerant decode → foldException → the
  ONE write core). eventIngest, the site-host carve (eventWithOrg), and the
  /v1/ingest alias all funnel through it; auth is the only per-door
  difference.
- Site-host carve repointed to /v1/event (eventWithOrg forces org from the
  resolved Site); isAnalyticsPath adds /v1/event so a published-site beacon
  POSTing <host>/v1/event lands host-forced. The org-forced-from-host
  adversarial invariant holds for /v1/event too.
- /v1/ingest is now a thin DEPRECATED alias of eventHandle (one-shot
  deprecation log, $source=ingest tag); /v1/ingest/keys minting unchanged.
- /v1/analytics{,/batch}, /v1/tracker, /v1/insights/e kept as thin
  foreign-protocol shims (external Segment/PostHog compat only) funnelling
  through the SAME write core; captureWithOrg now delegates to ingestBody.

Write core and hanzo.events schema unchanged. Tests prove the three wire
shapes land identically, pk_/bearer/host auth contexts resolve the same
tenant into a byte-identical warehouse row, and the exact app beacon
{batch:[ev]} body is accepted on /v1/event via the site-host carve.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 17:35:24 -07:00
hanzo-dev 8724f6231e research: /v1/research evidence plane — per-org SQLite source of truth + hanzoai/datastore roll-up (HIP-0512)
Adds the Hanzo Research surface (HIP-0512 §"Hanzo Research"): every experiment
across every product accrues as immutable, queryable evidence under one
discriminator (kind ∈ benchmark|kernel-perf|training|ablation|policy-eval).

Two planes: each org's transactional SQLite (per HIP-0302, physical file
isolation) is the durable source of truth; it rolls up best-effort into
hanzoai/datastore (ClickHouse) via the account-usage warehouse pattern for the
cross-project OLAP surface. A datastore outage degrades to rolled_up:false, never
a failed ingest.

Ingest is idempotent by stable id — attempt by (project,benchmark,item,model),
experiment by (project,<kind>:<subject>:<task>) with a ts-guarded upsert for
latest-run-canonical — so a corpus imports exactly once however many times it is
replayed (the no-data-loss guarantee) and an out-of-order replay of an older run
cannot regress a newer number.

Surface:
  POST /v1/research/experiments   ingest a batch → SQLite → roll up
  GET  /v1/research/experiments   list, latest-run-canonical (?project= ?kind=)
  GET  /v1/research/projects      every project + real totals (ops board)
  GET  /v1/research/totals        headline aggregate + per-kind (?project=)

Org is the physical tenant boundary (validated principal, never a client field);
project is a first-class column so the org's ops board aggregates across its
projects in one query. BYO endpoint is SSRF-gated at ingest (loopback/private/
link-local/metadata denylist, https required). status is a stored run-state field
(default complete); the live leasing state machine is the durable-execution
increment.

Tests (9, pure-Go): idempotent double-ingest, latest-run-canonical + no-regress,
attempt immutability, project/totals aggregates, SSRF denylist, HTTP round-trip +
tenant isolation + fail-soft roll-up.
2026-07-22 17:28:09 -07:00
antje 4672e8b89d feat(commerce): re-expose plan authority CRUD at /v1/plans/* + commerce v1.49.13
Re-ship increment 3a (the subscription/DNS plan CHARGE authority). Restores the
mount reverted in 43f5656d7 after the v1.49.12 seed bug, now on the RED-approved
fix commerce v1.49.13 (fix/plans-seed-roundtrip):
  - models/plan Metadata_ datastore:",noindex" (envelope round-trips; team.minSeats
    no longer served null)
  - bundle expansion builds a LOCAL zero-price childSnapshotPlan — never writes a
    $0 partial row into the plan authority (fixes world-pro/world-team $0 under-charge)
  - corrective boot seed force-corrects unmanaged partial rows to the embed while
    leaving admin edits (Managed) authoritative

Mount mirrors the 2b catalog mount: planapi.AdminRoute(storeV1, commercebilling.
SeedRows) on the /v1 bundle + own /v1/plans in commercePrefixes so it reaches
commerce (requireSuperAdmin-gated), not the AI /v1/* 402 catch-all. PUBLIC read
stays GET /v1/billing/plans. Money-path suites green (TestProdPath_SeedReadbackAllFields,
TestProdPath_CorrectsPreexistingBadRows); cloud prefix tests green.
2026-07-22 17:16:03 -07:00
zeekayandClaude Opus 4.8 0c153f9c36 feat(analytics): route /v1/analytics beacon ingest on published site hosts
Published sites POST analytics beacons to <site-host>/v1/analytics (and
/v1/insights/e), but that hit the static site server and 405'd — while
/v1/base already routed via the sites base-carve. Mirror the base carve
exactly for analytics ingest, so a page on yadota.hanzo.app (or a bound
custom domain) can send its own beacons into hanzo.events.

sites: add analyticsHostHandler + SetAnalyticsHostHandler + isAnalyticsPath
(prefix /v1/analytics OR ==/v1/insights/e). In Middleware, for both the slug
host and the custom-domain branch, carve the ingest POST to the handler with
org = resolved Site.Org (server-supplied, host-derived — never the caller/
body), gated on method POST so the authenticated GET read lenses
(/v1/analytics/overview|timeseries|top on api.hanzo.ai) are never hijacked.
The base carve stays first and independent.

analytics: extract captureWithOrg / insightsWithOrg — the Segment/beacon and
PostHog decode+ingest cores with an EXPLICIT org — so the deprecated aliases
(org via captureTenant) and the site-host carve (org forced from the Site)
share the ONE write core (ingestEvents), no copy. build() installs the carve
via sites.SetAnalyticsHostHandler, gated by the same already-existing flag the
anonymous ingest path uses (CLOUD_ANALYTICS_PUBLIC_CAPTURE, default ON); off
⇒ a site host 405s a beacon POST, unchanged.

Tests: sites carve routing (slug + custom host force Site.Org, not body/header
claim; GET serves static; /v1/base still routes to base; self host Continues;
non-live 405; no-handler 405; isAnalyticsPath). analytics end-to-end through
Mount+sites.Middleware (forced site org, 503-not-403 discriminator; empty
batch 200; custom domain; GET not hijacked; non-site host uses the normal
gate; disabled when public capture off).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 17:02:10 -07:00
hanzo-dev 6fbdfe88d8 feat(link): --mirror flag — explicit off-switch for the render mirror sweep
The render mirror sweeps every local studio output into the org library
with the WORKER token, so on a box whose login org differs from the
content org it misroutes files; until now the only disable was pointing
--studio-url at a dead port. --mirror=false is the honest switch; default
true keeps current behavior. Ported from feat/per-gpu-queue 9468354
(superseded branch — the gpu command family there became link on main).
2026-07-22 16:53:17 -07:00
antje 8ec6d62d16 chore(deps): bump hanzoai/iam v1.32.2 → v1.33.0
Clean-room IAM cutover-parity release. v1.33.0 closes the gaps cloud's
consumers need for a casdoor→clean-room identity cutover:
- orgs membership claim on tokens (multi-org tenancy the validator reads)
- casdoor membership verb aliases (clients/team invite consumes)
- get-user?accessKey= key resolution (hk-/pk-/sk-, CapKeyResolve-gated)
- Docker Registry v2 token + JWKS endpoints
All exported surface cloud imports (pkg/model, pkg/store, server) unchanged
→ compile-safe. NO deploy, NO CLOUD_IAM_IMPL flip (retired); casdoor remains
the live authority until the staged shadow-parity cutover.
2026-07-22 16:14:28 -07:00
hanzo-dev fc8337ebb5 sites: drop reserved labels from the first-party allowlist (RED F-2)
Belt-and-suspenders on the brand apex: New() now filters IsReserved labels out of the
first-party allowlist (logging the drop), so an operator setting CLOUD_SITES_FIRSTPARTY=
login (or api/wallet/console/…) can never turn an auth-sensitive host into a publishable
site. Default cd,flow,gallery are clean and unaffected. TestFirstPartyDropsReserved pins it
(cd/flow/gallery serve; api/login/wallet dropped). Closes RED's last actionable finding —
F-1 (org-pin) already fixed in ba9330c; host-parsing + Base-path RED-confirmed sound.
2026-07-22 15:28:36 -07:00
hanzo-dev ba9330c089 sites: org-PIN first-party hosts (RED #4 ship-blocker) — never shadow-able
RED review of the first-party allowlist found the real risk: siteSlug returns a bare
slug and serve() resolved it via ResolveUniqueLiveSlug (the unique-LIVE-slug-across-ALL-
orgs fallback). So cd.hanzo.ai → Resolve('cd') → a CUSTOMER's project named 'cd' could be
served on our internal brand host — and via the OAuth-redirect path in reserved.go that is
account takeover. The allowlist gates WHICH labels serve; it did NOT pin WHOSE project.

Fix: first-party resolution is PINNED to the owning org (hanzo). siteSlug now returns a
firstParty flag; serve()/resolveLivePinned route a first-party host through the new
Resolver.ResolveOrg → store.ResolveOrgLiveSlug (WHERE org=? AND slug=? AND status='live'),
which can NEVER return another org's project. Multi-tenant hanzo.app is unchanged (still
unique-across-orgs). Fail-closed: no owning org (CLOUD_SITES_FIRSTPARTY_ORG, default hanzo)
⇒ first-party disabled entirely.

TestFirstPartyOrgPinned proves it: a first-party host calls ResolveOrg(hanzo,slug) and NEVER
the unique-slug Resolve; a multi-tenant host does the opposite. tsc/build green.
2026-07-22 15:25:06 -07:00
hanzo-dev 33db8b7c2a build(cloud): build ONLY the cloud binary — retire the Go hanzo CLI target
The shipped hanzo is the Rust CLI (~/work/hanzo/cli, curl hanzo.sh); the Go
cmd/hanzo was only ever a local 'make hanzo' control-plane build (CI/release
already build only cloud). Remove the make target so cloud builds exactly the
one stateless unified-API binary. cmd/hanzo + cli/ stay as reference for the
still-to-port client-side tools (GPU fleet worker link, runner, engine,
security); the code wrapper + zen-tier 1M mechanism are now in the Rust CLI.

Claude-Session: https://claude.ai/code/session_01QSN1woYbvENByMbGUqQ9Me
2026-07-22 15:24:39 -07:00
hanzo-dev ce5696c182 sites: first-party apex (hanzo.ai) — OPT-IN allowlist for OUR internal sites
Converge cd/flow/gallery.hanzo.ai off the legacy s3://cdn staticFiles plane onto the
Projects PaaS plane (clients/sites → hanzo-sites/hanzo/<slug>). The site router served
only <slug>.hanzo.app (user sites); hanzo.ai is a SelfDomain it never resolved.

hanzo.ai is INTERNAL-ONLY (users get <slug>.hanzo.app), so it uses the OPPOSITE security
model to the multi-tenant apex: sites are OPT-IN via an explicit allowlist
(CLOUD_SITES_FIRSTPARTY=cd,flow,gallery on CLOUD_SITES_FIRSTPARTY_APEX=hanzo.ai). ONLY an
allow-listed label serves; EVERY other hanzo.ai host (api/console/iam/kms/world/chat/…,
listed in reserved.go or not) falls through PROTECTED by default — so a first-come project
can never shadow a real internal host (the reserved.go OAuth account-takeover). No
denylist-completeness burden on the brand's own domain. hanzo.app (user sites) unchanged.

Tests: TestSiteSlugFirstParty pins the boundary (allow-listed serve; api/iam/kms/world/
unlisted all protected; dotted key can't match; hanzo.app unaffected). tsc/build green.
2026-07-22 15:14:21 -07:00
hanzo-dev eef518ecdb benchmark: AttemptStore seam + runner + presets + proof tests on real data
Architecture per CTO directive: read/leaderboard/compare/worker go through the
AttemptStore interface — fileStore is LOCAL-DEV (DataDir JSONL); the cloud backend
(relational + object store, stateless API) swaps in behind the same interface, no
handler change. NEVER pod-local writes in prod.

- store.go: AttemptStore interface + fileStore (append-only, idempotent by item×model)
- runner.go: execution worker (OpenAI-compatible call → extract → score → Append,
  cache-before-spend via Has); items from the evaluator plane
- presets.go: POST/GET /v1/benchmark/presets — design-your-own router blend (enso-<name>)
- benchmark_test.go: PROVES on real attempts — fable-5 measured 81.3 vs published 94.6
  (gap +13.3), grok vs gpt-5.6-sol paired n=198 net+5 McNemar p=0.30, exact-McNemar unit

Fugu Ultra GPQA claim 95.5 vs our harness 94.4% (answered) is the flagship
replicate-or-disprove. Builds clean; unified binary links.
2026-07-22 14:36:10 -07:00
hanzo-dev 6122473c55 benchmark: native /v1/benchmark arena — replicate-or-disprove any provider
Sibling to /v1/eval, mounted into the unified cloud binary (apps.go). The benchmark
ARENA: run the top-14 canonical public benchmarks against any model/endpoint under ONE
standardized harness, and replicate-or-disprove a published claim. Provenance-first,
never blended: published_claim (vendor-reported) vs hanzo-measured (our harness) are
separate planes — the gap is the signal (Fugu claims GPQA 95.5; our harness measures
~92-95 by fault accounting; fable-5 claims 94.6, we measure 81.3).

Routes: GET /catalog (top-14, native flags), GET /leaderboard?benchmark= (per-model
measured ∥ published, coverage-aware), GET /compare?a=&b= (paired common-set +
rescue/damage + exact McNemar — the only valid arm-vs-arm test), POST /runs (BYO model
or endpoint; cache-before-spend; execution worker follow-on). Builds clean; unified
binary links.
2026-07-22 14:36:10 -07:00
antje 43f5656d78 Revert "feat(commerce): expose plan authority CRUD at /v1/plans/* + commerce v1.49.12"
This reverts commit f05b9a6441.
2026-07-22 14:27:28 -07:00
zeekayandClaude Opus 4.8 6a7333e5d8 feat(projects): wire analytics + Base data space ON by default for new projects
Every new project now gets analytics collection and a Base data space
(form/forum/data submissions) wired by default — no opt-in — through the ONE
create path.

- createProject applies setProjectDefaults(analytics ON unless the caller passes
  analytics:false; space_id = "<org>/<slug>") then best-effort provisions the
  Base space. /v1/sites (ensureProject) routes through the same helpers, so
  defaults live in exactly one place.
- Fail-soft: a Base provisioning error (incl. embed disabled) is logged and
  swallowed — it never fails project creation, mirroring the CF edge-purge policy.
- clients/base.EnsureSpace: idempotent, in-process (no HTTP) provisioner that
  opens the org's per-org Base app and ensures the public-create "submissions"
  collection exists.
- store: additive analytics/space_id columns (migration matches the existing
  cache_control/last_purge_at ALTER pattern); analytics defaults ON for existing
  rows too. Exposed on the project view as analytics/space.

Tests: analytics default-ON, analytics:false opt-out, space provisioned on
create, and provisioning failure is fail-soft (create still 201, persists).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 14:09:12 -07:00
antje c3938fed27 fix(team): flatten model HTML in bot replies — a bot answering '<p>yes.</p>' now renders 'yes.', not the literal tag (double-escape bug on hanzo.team DMs/channels) 2026-07-22 14:06:26 -07:00
antje f05b9a6441 feat(commerce): expose plan authority CRUD at /v1/plans/* + commerce v1.49.12
Increment 3a — the subscription/DNS plan CHARGE authority. Mirrors the 2b catalog
mount: register commerce's plan SuperAdmin CRUD (api/plan -> planApi.AdminRoute)
on cloud's /v1 bundle group, injecting the embed seed source
(commercebilling.SeedRows — the SAME @hanzo/plans embed the boot seed +
resolveSubscriptionPlan read), and own /v1/plans in commercePrefixes so it reaches
commerce, not the AI /v1/* 402 catch-all.

commerce v1.49.12 (feat/plans-sot cherry-picked onto v1.49.11): models/plan
authority + runPlansSeed (env COMMERCE_PLANS_SEED, idempotent/count-gated) +
resolveSubscriptionPlan reads the editable authority with embed fallback. The mint
gates score the IMMUTABLE embed (paidTier/IncludedMonthlyCents), NOT the DB price —
an admin edit never moves a charge gate. Seed == embed (TestSeededPricesEqualEmbed),
so first boot changes NO charge. Each handler requireSuperAdmin-gated (anon -> 403).
2026-07-22 13:55:06 -07:00
zeekayandClaude Opus 4.8 c829daeb45 feat(projects): dedicated POST /v1/projects/:slug/purge + emit Cache-Tag; DRY the edge purge
- Add POST /v1/projects/:slug/purge (mirrored at /v1/platform/sites/:slug/purge):
  resolve (org,slug), flush the edge cache-tag site-<org>-<slug>, stamp
  LastPurgeAt, persist, return 200 Project. Never touches the S3 origin. Org-scoped
  exactly like deploy (403 no org, 404 unknown slug). CF-unconfigured/failing purge
  is non-fatal (stamps + 200), matching deploy's behavior.
- DRY: extract purgeTag (the ONE cf.PurgeTags + cache-tag derivation + failure
  policy) and purgeEdge (purge + stamp). deploy(onPublish), setDomains, del, and the
  new purge handler all route through purgeTag — no copy-pasted CF call.
- Cache-Tag on served responses already emitted in sites.streamSite (unchanged);
  tightened its comment + CacheTag doc to state the emit/purge pairing invariant.
- Scrub stale svc suffix from prose (6→0): projectsvc→projects, tasksvc→tasks,
  cloud-mlsvc comment reworded; zapsvc test fixture → zap. msvc (third-party) left.
- Enforce one vocabulary in touched comments (project / deployment / S3 origin /
  edge cache-tag / purge); no behavior change.
- Tests: purge_test.go — stamps LastPurgeAt, CF-unconfigured→200, 403/404 scope,
  S3 origin untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 13:48:20 -07:00
hanzo-dev 2fb9c2e095 feat(integrations): ecommerce + AI-startup key connectors
One key-verify mechanism (keyVerify) plus the payment, model, messaging, and
SaaS connectors an online store or AI startup needs, on the user-scoped
/v1/connectors plane. Each provider is declarative data — origin, path,
credential placement, offline checks — over a single fail-closed, token-free
verify; a new connector is a registration, not another hand-rolled HTTP dance.

Providers: stripe, paypal, square, shopify; gemini, groq, mistral, cohere,
together, replicate, huggingface, openrouter, xai, fireworks, deepseek,
pinecone; sendgrid, resend, postmark, mailchimp, klaviyo, twilio; hubspot,
notion, linear, airtable.

A customer key seals to KMS only on a 2xx from the provider's cheapest
authenticated read; non-2xx and transport errors store nothing and never echo
the credential. authClient no longer follows redirects, so a 3xx fails closed
and custom credential headers are not forwarded across a hop.
2026-07-22 13:26:14 -07:00
hanzo-dev 1f1c5569d5 merge(main): final catch-up before connectors ship 2026-07-22 09:07:13 -07:00
hanzo-dev 0a1ebbc0b8 merge(main): catch up before shipping connectors 2026-07-22 08:42:21 -07:00
a36b8d1ba7 build(deps): bump hanzoai/ai v1.829.7 → v1.830.0 (free-tier flash cap #143) (#351)
Pulls the free-tier auto-routing flash cap (ai#111): a CONFIDENT free-tier org's
`auto` is confined to the flash pool (blended-price ceiling), so a non-paying
caller is never routed to a premium model. Enso stays the default; paid/trial,
explicit model, and X-Max-Cost still win. Single-commit jump; cloud builds clean
against v1.830.0 (CGO_ENABLED=0).

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-22 08:39:46 -07:00
zandGitHub 6caf1826d7 sync: freshness reconcile scheduler + gitToken graceful fallback (git.hanzo.ai native cutover)
Periodic reconcile scheduler (clients/sync) that keeps every poll sync current (env-gated CLOUD_SYNC_RECONCILE_INTERVAL, leader-safe on the Recreate singleton writer, bounded by the shared reconcileSem), plus OrgStore.Each cross-org sweep primitive and store.ListAll. gitToken now falls back App-token -> GIT_MIRROR_TOKEN -> anonymous instead of hard-failing, so github reconciles (public immediately, private once GIT_MIRROR_TOKEN is set) stop freezing. Tests: scheduler_test, orgdb_test TestOrgStoreEach, git_provider_test TestGitTokenFallback; go vet ./... clean.
2026-07-22 03:52:39 -07:00
antje b5167139d0 feat(commerce): expose SuperAdmin catalog CMS at /v1/catalog/* in the embed
admin.hanzo.ai's editor (console v8.4.154) CRUDs /v1/catalog/entries, but
commerce's setupRoutes wires only /v1/commerce/* — the /v1/catalog CMS
(api.Route -> catalogApi.AdminRoute, standalone-only) 404'd in the co-resident
binary. mountCommerce is the adapter for exactly these setupRoutes-skipped
families: mount catalogApi.AdminRoute on the existing /v1 bundle group (storeV1:
AddHost+RequestContext+errorScope+IAMTokenRequired — the same chain the
standalone /v1 bundle uses), and own /v1/catalog in commercePrefixes so it
reaches commerce, not the AI /v1/* balance catch-all.

No commerce module change: catalogApi.AdminRoute already ships in v1.49.9. Each
handler is requireSuperAdmin-gated (anon -> 401/403, cross-tenant data — never
org-scoped). Editor unchanged; closes the admin-edit half of the loop.
2026-07-22 03:30:36 -07:00
hanzo-dev 3337ddcdd5 deploy: bump embedded commerce v1.49.10 → v1.49.11 (billing-reads 502 class fix)
Picks up the #146 sibling fix: the co-resident billing READS (ListInvoices,
ListBillingSubscriptions, ListPayouts, GetPaymentConfig, DownloadInvoicePDF) no longer
nil-deref-panic (→502) on the embed path when no org is in Locals — they resolve the org
via nil-safe GetOrganizationOK. Compile-verified clean:
go build ./apps/... ./clients/account/... ./clients/commerceclient/...
2026-07-22 03:29:52 -07:00
hanzo-dev 00dc99d834 deploy: bump embedded commerce v1.49.9 → v1.49.10 (spend-alerts 502 fix)
Picks up the #146 fix: GET /v1/billing/spend-alerts (the console Budgets page) no
longer panics (→ 502) on the co-resident embed path when no org is in Locals.
commerce ListSpendAlerts + its CRUD siblings + billingSubject now resolve the org
via the nil-safe GetOrganizationOK. Compile-verified clean:
go build ./apps/... ./clients/account/... ./clients/commerceclient/...
2026-07-22 03:09:37 -07:00
hanzo-dev 41b992e7fd Merge remote-tracking branch 'origin/main' into consolidate/held-work 2026-07-22 03:03:17 -07:00
hanzo-dev 8b1ba262aa merge(connectors): OAuth/apikey connector plane (anthropic, openai, copilot, device, refresh)
# Conflicts:
#	clients/integrations/integrations.go
2026-07-22 02:56:27 -07:00
antje 2898ae3670 deploy: bump embedded commerce v1.49.8 → v1.49.9
Increment-1 catalog SoT: commerce Bootstrap now seeds the 17 infra-tier
catalogentry rows (11 cloud + 3 gpu + 3 datastore) via runInfraCatalogSeed
(count-gated, idempotent, non-fatal) and projects an infra brand scope +
Metadata at GET /v1/commerce/catalog?brand=infra. Purely additive; billing/
orders/Stripe untouched.
2026-07-22 02:52:44 -07:00
hanzo-dev 6fd497221d merge(deps): commerce v1.49.3 bump + flags/metering test follow-through
# Conflicts:
#	go.mod
#	go.sum
#	hanzo.yml
2026-07-22 02:46:33 -07:00
antje 6d93966ad8 fix(team): run the entitlement/guest-cap gate at sendInvite too
The team.guests cap ran only at selectWorkspace (the guest's later login),
never at sendInvite — the actual guest add. A workspace admin could invite
guests past the plan cap and the add itself was ungated. Call the SAME
entitle seam at the add point, OBSERVE-MODE consistent with the login gate:
it logs the over-cap denial and admits today, and the 402 arms the day
enforcement returns. Commerce/plans errors admit inside entitle, so a
licensing outage never bricks an invite. Tests prove the gate runs at the
add point (commerce + plan cap consulted, genuine over-cap rank) yet admits,
and that an infra error still records the invite.
2026-07-22 01:53:55 -07:00
antje a8570e0bd5 fix(team/collab): close seedYLog TOCTOU — route the seed through the hub
createContent seeded a brand-new doc's live update log with a bare
Get(miss)-then-Put. In the gap between the miss and the Put, a concurrent
first edit on the live WS lane could land and then be overwritten by the
seed (lost update), or the seed could overwrite a log that just went live.
Seed THROUGH the collab hub (seedIfAbsent): it reuses the room's flushMu/mu,
so the empty-check and the set are atomic under the room lock and the seed
and the field's live editor serialize on ONE room. The seed applies only
when the log is still empty — never clobbers a live edit. Deterministic +
concurrent (-race) tests prove the live edit always survives.
2026-07-22 01:48:48 -07:00
antje e4538bcc89 fix(team): converge concurrent logins to one personal workspace
EnsureWorkspace was check-then-insert (WorkspacesOf -> INSERT) with no
uniqueness on the personal-workspace identity, so two concurrent logins for
the same (org, account) could each see 'none' and mint a duplicate personal
workspace (reproduced: 32 concurrent logins -> 6 rows). Add a partial-unique
index on (owner_org, owner) — EnsureWorkspace is the sole creator and always
writes owner=account — with a dedup that converges any pre-existing duplicates
to the earliest row, and make the create an idempotent upsert (ON CONFLICT DO
NOTHING then adopt the winner). Concurrent EnsureWorkspace now yields exactly
one row. Heal logic extracted to adoptExisting, reused on both paths.
2026-07-22 01:44:56 -07:00
antje 082cec000c fix(team): surface a real seat-read error instead of a false 0 members
Seats swallowed the row Scan error, so a genuine DB failure reported (0, 0)
— indistinguishable from an org with no members, under-counting billed
seats. Propagate the error (the aggregate COUNT always returns one row, so
any Scan error is a real failure); the wallet's /billing/plan read now
502s honestly and retries instead of rendering a truthful-looking 0.
2026-07-22 01:40:23 -07:00
antje 599e063871 fix(auth): console identity from the ONE validated principal, not casibase
DECOMPLECTION. The operator SPA (admin.hanzo.ai) authenticates via /v1/signin (a
cloud PKCE session → the X-User-* principal the gateway/middleware mints) but read
its IDENTITY from /v1/get-account, which the embedded IAM (casibase) answers from ITS
OWN session cookie. A PKCE session is not a casibase session, so get-account returned
owner:"hanzo" (anonymous) or "Unauthorized operation" — and the SPA SuperAdmin gate
(owner==admin && isAdmin), reading that, bounced the operator UI to login even though
the SAME session got 200 from every /v1/admin/* route. Identity source and auth source
disagreed: two session models for one surface.

Now identity IS the principal. AccountFromPrincipal (registered after IdentityMiddleware,
before MountAll's casibase mount) answers /v1/get-account from X-User-Owner/Name/Email/
IsAdmin when a VALIDATED principal is present (owner = HOME org, so a SuperAdmin
org-switched into a tenant stays one); with no principal it falls through to casibase
unchanged (anonymous sign-in + legacy casibase-session callers untouched). One truth,
additive, fail-open. Fixes the operator console UI being unusable via browser login.
2026-07-22 01:36:35 -07:00
antje 062e0d4374 fix(finance): never show held funds as spendable in the balance projection
The commerce S2S fallback set available = balance whenever the reported
available was 0, so a fully-held wallet (balance == holds, available 0)
rendered its entire balance as spendable — money the prepaid gate would
refuse. Derive available as balance NET OF holds when commerce reports only
a balance, clamped at 0 (holds beyond balance never go negative). Extracted
as spendableCents with a table test; a fully-held org proves the handler.
2026-07-22 01:36:03 -07:00
antje f88405f6a6 fix(team): cap workspace-invite org grant at member — no org-admin escalation
A workspace invite passed the body role straight into the org-level IAM
grant, so a single-workspace admin could make an invitee an org IAM admin/
owner (trusted elsewhere via IsAdmin). Cap the org grant at member; the rich
role stays workspace-scoped on the local roster row. Test proves the cap.
2026-07-22 01:01:50 -07:00
antje 35dbdbc589 fix(team/collab): close room-resurrection race (panic) + stop lost updates on flush
Two defects in the single-replica collab hub (collabws.go):

1) BLOCKING race → 'panic: close of closed channel' + registry corruption. join
   registered its peer in rm.peers AFTER releasing h.mu, so in that gap a concurrent
   leave of the last old peer would GC the room (delete h.rooms[key] + close(rm.stop),
   ending the flusher). The resurrected room then panicked on its next leave
   (close of an already-closed channel) and could evict a live room (split-brain).
   This is the ordinary 'reconnect by the sole editor' shape. Fix: register the peer
   while STILL holding h.mu, before the slow VFS load — a concurrent leave can no
   longer see the room as empty. Lock order stays h->rm. A failed first-open now
   undoes the join via leave() so it leaks neither the peer nor the room+flusher.

2) Silent lost updates on flush. flush cleared rm.dirty BEFORE the Put and discarded
   the Put error, so a transient VFS failure dropped the buffered window (dirty already
   false => neither the ticker nor last-leave retried). Fix: clear dirty only on a
   SUCCESSFUL Put, re-arm it on failure, and serialize the persist (flushMu) so two
   overlapping flushes cannot reorder their Puts.

TDD: TestCollabHubConcurrentJoinLeaveNoPanicNoLeak hammers join+leave on one doc from
500 goroutines under -race (reproduces the panic + a room leak without the fix, clean
with it). TestCollabFlushRetriesAfterPutError proves a failed Put keeps the room dirty
and nothing is persisted, then a later Put persists and clears dirty.
2026-07-22 00:53:51 -07:00
antje 5c8b5c9a08 fix(finance): surface a real balance-read error instead of rendering it as $0
ledgerFinance.Balance discarded store.Balance's error (bal, _ := ...), so a real
read failure — a DB Scan error or a corrupt/garbled stored-balance ParseInt failure —
returned (zero, nil), indistinguishable from a genuine zero. That defeated the
documented 'a balance that cannot be read is unknown, and unknown is not broke'
invariant every caller relies on: /v1/billing/balance and /v1/finance/balance showed
a FUNDED customer $0.00, and the AI prepaid gate (metering.fetchAvailable) refused a
funded org's request. Propagate the error; the callers' guards already render 503 on
a non-nil read error. Direction was already safe (zero only under-reports), so this is
correctness + observability, not an over-charge.

TDD: TestBalanceReadErrorSurfaces closes the store's DB out from under the cached
handle and asserts Balance now returns an error, not (0,nil). Fails before, passes after.
2026-07-22 00:53:51 -07:00
hanzo-dev 46c0d0e11f merge(main): bring the integration up to current main before shipping 2026-07-22 00:10:59 -07:00
hanzo-dev 86b0b79ecc fix(cli/code): size Codex context window to the served model, not a flat 262144
The codexLike provider hardcoded '-c model_context_window=262144' (+auto_compact
235929) for EVERY model, so enso / zen5 — which the gateway serves at 1M
(ai flagshipWindow pin, live) — were capped at 256K in the 'hanzo code' Codex
wrapper. That surfaced to the user as 'maximum context exceeded' at 262144 even
after switching to zen5-pro.

Make the window model-aware: codeContextWindow(model) → 1M for the enso/zen5
flagship tiers, 131072 for flash; auto-compact at 90%. Threads the served model
into provider(base, model). Only codexLike sets provider (codex has no carrier,
so its model IS the served zen id). Tests updated + a sizing test added.

Claude-Session: https://claude.ai/code/session_01QSN1woYbvENByMbGUqQ9Me
2026-07-21 23:58:19 -07:00
antje 12d0081e3b fix(analytics): stop /v1/analytics 500 — replace panicking sync.Map with mutex+map
The deprecated-alias 'log once per path' used a package sync.Map (Go's HashTrieMap),
which entered a 'ran out of hash bits while inserting' panic state under the hot
ingest path — so the Recover middleware turned EVERY POST /v1/analytics (and /batch,
/tracker) into a 500. Events stopped landing in hanzo.events once the map degraded.
The alias set is tiny (3 paths); swap the sync.Map for a mutex-guarded map — no trie
state to corrupt, cannot panic. Behavior unchanged (still logs each alias once).
go build ./... green.
2026-07-21 23:29:10 -07:00
antje 1256cda741 fix(agents): retry + model-failover on transient upstream overload so bot replies stop dropping
The agent-run path made ONE completion call; when the default agent model
(deepseek-v4-flash) returned a transient upstream 429 'Platform overloaded'
(~1 in 3 under load), the run recorded an error and the bot reply was dropped.

- types: add ErrUpstreamBusy sentinel — the shared vocabulary for a transient,
  safely-retryable upstream failure (429/5xx/empty-choices/'overloaded').
- aihttp: classify + tag transient chat-completion failures with ErrUpstreamBusy
  (errors.Is-detectable); permanent errors (400/auth/unserved model) stay
  untagged and fail fast. Message preserved; no control-flow change for
  non-agent callers (interactive chat untouched).
- agents/executeRun: bounded retry (3 attempts, equal-jittered backoff, ctx-aware)
  on ErrUpstreamBusy, then ONE failover to the reliable model (CLOUD_AI_FALLBACK_MODEL,
  default 'best') if the agent's own model stays throttled. Retrying a completion
  is side-effect-free, so metering still debits EXACTLY once on the eventual
  success (runAgent meters only r.Status==ok) and never on failed attempts.
- Bill the model ACTUALLY used (r.Model) — a failover run bills 'best', not the
  throttled model it started on.
- Scoped to the autonomous agent/bot run path ONLY; interactive user-facing
  chat/completions behavior is unchanged.

TDD: 429-twice-then-200 -> one ok run + one debit; persistent 429 + failover
exhausted -> clean error run + no debit; failover-success bills the used model;
transient-classifier unit test (429/503/500/empty-choices busy, 400 not).
2026-07-21 23:17:25 -07:00
hanzo-dev cf5d807cf0 fix(sites): complete the coalesce+ceiling WIP — add sync/strconv imports + fixed-window takeToken() 2026-07-21 22:39:55 -07:00
hanzo-dev 3c4e05632c merge(site-releases): consolidate onto main 2026-07-21 22:38:35 -07:00
hanzo-dev 93e23b541d merge(channels): consolidate onto main
# Conflicts:
#	apps/apps.go
#	apps/wire_test.go
#	clients/channels/routes.go
2026-07-21 22:38:22 -07:00
hanzo-dev 1d8de1dda7 merge(cloudflare-connector): consolidate onto main
# Conflicts:
#	clients/integrations/cloudflare.go
#	clients/integrations/cloudflare_test.go
#	clients/integrations/integrations.go
2026-07-21 22:37:57 -07:00
hanzo-dev 56dd542879 merge(account-usage): consolidate onto main
# Conflicts:
#	clients/link/http.go
#	clients/link/store.go
2026-07-21 22:30:47 -07:00
hanzo-dev 78a8aa3310 merge(link-router): consolidate onto main
# Conflicts:
#	clients/link/http.go
2026-07-21 22:30:05 -07:00
hanzo-dev e06eeb18da merge(leaderboard): consolidate onto main
# Conflicts:
#	apps/apps.go
2026-07-21 22:16:01 -07:00
hanzo-dev 7f2deef78c merge(cd-projection-clusters-projects-stream): consolidate onto main
# Conflicts:
#	clients/deploy/dashboard.go
#	clients/deploy/dashboard_endpoints_test.go
#	clients/deploy/deploy_test.go
#	clients/deploy/projection.go
#	clients/deploy/stream.go
2026-07-21 22:12:33 -07:00
hanzo-dev cf24f0cbfc merge(admin-billing-credit): consolidate onto main 2026-07-21 22:09:28 -07:00
hanzo-dev df250b65db merge(admin-credit-grant): consolidate onto main
# Conflicts:
#	clients/admin/commerce/commerce.go
2026-07-21 22:09:12 -07:00
hanzo-dev 8bcfb8eff3 merge(analytics-capture-v2): consolidate onto main
# Conflicts:
#	clients/analytics/analytics.go
#	clients/analytics/capture.go
2026-07-21 22:07:28 -07:00
hanzo-dev 7f3c381570 merge(zen-upstream-key-env-fallback): consolidate onto main
# Conflicts:
#	apps/zen.go
#	apps/zen_key_test.go
2026-07-21 22:03:03 -07:00
hanzo-dev 78a9c1f02e merge(agents-typed-ops): consolidate onto main 2026-07-21 21:55:48 -07:00
hanzo-dev abcbbb155b feat(gpu): HANZO_STUDIO_VRAM env overrides studio vram mode
Default stays --normalvram (safe for small BYO GPUs); big-memory boxes (GB10
128G unified) can set HANZO_STUDIO_VRAM=--highvram so the render backend keeps
models GPU-resident. Additive + opt-in; other workers unchanged.
2026-07-21 21:44:51 -07:00
48d3b35e67 refactor(iam): drop Casdoor iam-v1 entirely — cloud embeds the clean hanzoai/iam (#349)
cloud's last tie to the retired Casdoor/Beego fork (hanzoai/iam-v1) is gone:

- clients/iam: embeds the clean hanzoai/iam (zip-native + hanzoai/orm) via
  iamserver.Mount over its own SQLite store; fail-closed 503 on boot failure.
  Removes the dual-impl identitySpec (CLOUD_IAM_IMPL=iam2 branch) + clients/iam2.
- clients/platform + clients/deploy: read the IAM-owned Project resource via
  iam/pkg/store over the embedded DB() instead of iam-v1's object-store ormer.
- cmd/hanzo: retires the `hanzo iam` Casdoor daemon subcommand.
- go.mod: the last transitive iam-v1 edge was hanzoai/ai/object; bump ai
  v1.829.3 -> v1.829.4 (Casdoor-free cut). go mod tidy drops iam-v1 entirely.

Remaining iam-v1 references are accurate "retired/gone" doc comments only.
Tests green: clients/iam, clients/platform, clients/deploy. Wire frozen-order
golden consistent (iam2->iam collapse).

Co-authored-by: zeekay <ai@hanzo.ai>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 20:19:11 -07:00
antje 3fef8bccce fix(webui): serve each route's OWN exported shell — ends the OAuth login loop
The embedded console served index.html for EVERY non-asset path, so a deep
load of /auth/callback hydrated '/' instead; AuthGate discarded the ?code and
bounced to /signin — sign-in could never complete on the embedded console.
Now an extensionless path tries the static-export route shell (<route>.html)
first, with the index treatment (no-cache + white-label title rewrite, one
shared brandTitle); direct .html requests are also no-cache.
2026-07-21 19:40:14 -07:00
30a566846b chore(deps): bump hanzoai/ai → v1.829.7 (admin.* signin redeems as admin-console; admin-login P0) (#350)
Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-21 18:50:56 -07:00
hanzo-dev ff5cefcddd chore(deps): bump hanzoai/ai v1.829.5 → v1.829.6
Router-config routes (/v1/router/{policy,defaults,ledger,rewards,artifact-meta}
and /v1/org/settings) now serve over api.hanzo.ai. The v1.829.5 refactor moved them
to ZAP-native handlers and deleted their beego routes, but :8000 is the beego
web.Router — the ZAP registry backs a separate transport — so they 404'd in prod
(console Router->Policy, chat/app routing defaults). v1.829.6 adds RouterConfigBridge
(one beego adapter dispatching in-process through the SAME gateway registry, so the
ZAP handler stays the sole impl) and restores their isBalanceExempt entries.
2026-07-21 18:18:26 -07:00
hanzo-dev aafa89064d merge: detect AMD GPUs in node inventory (evo gfx1151 was invisible) 2026-07-21 18:11:28 -07:00
hanzo-dev 893e7d7759 link: detect AMD GPUs in the node inventory (rocm-smi / kfd topology / vulkaninfo)
detectGPUs now reports AMD accelerators as first-class resources alongside NVIDIA
and Apple Metal — discrete Radeon cards and gfx APUs alike (evo's gfx1151 Radeon
8060S on the RYZEN AI MAX+ 395). Resolution order: rocm-smi --showproductname (name
+ gfx target), then the kfd topology under /sys (GPU nodes by simd_count, gfx from
gfx_target_version), then a vulkaninfo summary; VRAM filled from amdgpu sysfs
mem_info_vram_total. Pure parsers are unit-tested against evo's real rocm-smi CSV
and kfd properties; the gfx_target_version decode (110501 → gfx1151) is covered.
2026-07-21 18:06:33 -07:00
hanzo-dev b6f15cd27c merge: hanzo link|unlink|status — bring a machine into the fleet as a node
The unified Go binary is hanzo dev — the unified Hanzo Go binary

Usage:
  hanzo <command> [flags]

Control commands (gcloud/doctl-style):
  agent        invoke a managed Hanzo agent to run a task (headless)
  apps         list/get the platform apps board (declared/running/drift)
  auth         manage authentication + stored identities (login, logout, whoami, list, switch, token)
  bot          launch a computer-using agent (booted desktop or terminal)
  build        enqueue a platform-native build (runner fabric)
  clusters     provision/list/select dedicated DOKS clusters
  code         launch a coding agent (claude, codex, dev) on a Hanzo cloud model
  config       view/edit ~/.hanzo/config preferences
  deploy       drive a platform redeploy (rolling restart, zero-downtime)
  engine       run a local hanzo-engine (OpenAI + Anthropic model server)
  k8s          deploy-target helpers (current target)
  link         bring this machine into the Hanzo cloud fleet as a node (fabric + compute worker)
  login        authenticate against Hanzo IAM (hanzo.id) and store a token
  logout       remove stored credentials
  run          launch a workload on Hanzo compute (container or function)
  runner       run this machine as a JIT CI runner for your org (GitHub Actions)
  security     scan files for hardcoded secrets (local guardrail; no server/auth)
  status       show the org's fleet — every node with each of its GPUs
  unlink       take this machine out of the fleet (deregister + stop hanzod)
  whoami       show the current identity from the stored token

Service subcommands (server mode):
  account      serve the account subsystem standalone
  account-bridge serve the account-bridge subsystem standalone
  admin        serve the admin subsystem standalone
  admission    serve the admission subsystem standalone
  ads          serve the ads subsystem standalone
  affiliates   serve the affiliates subsystem standalone
  agent        serve the agent subsystem standalone
  agents       serve the agents subsystem standalone
  agentskills  serve the agentskills subsystem standalone
  ai           serve the ai subsystem standalone
  analytics    serve the analytics subsystem standalone
  audit        serve the audit subsystem standalone
  authors      serve the authors subsystem standalone
  authz        serve the authz subsystem standalone
  automations  serve the automations subsystem standalone
  base         serve the base subsystem standalone
  billing      serve the billing subsystem standalone
  bots         serve the bots subsystem standalone
  captable     serve the captable subsystem standalone
  catalogsync  serve the catalogsync subsystem standalone
  channels     serve the channels subsystem standalone
  cloud        serve the full unified surface (all enabled subsystems, one listener)
  cloudflare   serve the cloudflare subsystem standalone
  code         serve the code subsystem standalone
  commerce     serve the commerce subsystem standalone
  company      serve the company subsystem standalone
  content      serve the content subsystem standalone
  crm          serve the crm subsystem standalone
  dataroom     serve the dataroom subsystem standalone
  datastore    datastore-fork analytics DB — not a Go serve target (see help text)
  deploy       serve the deploy subsystem standalone
  dns          serve the dns subsystem standalone
  do           serve the do subsystem standalone
  domain       serve the domain subsystem standalone
  entitlements serve the entitlements subsystem standalone
  evals        serve the evals subsystem standalone
  exec         serve the exec subsystem standalone
  flags        serve the flags subsystem standalone
  framework    serve the framework subsystem standalone
  functions    serve the functions subsystem standalone
  gateway      serve the gateway subsystem standalone
  git          serve the git subsystem standalone
  graph        serve the graph subsystem standalone
  guide        serve the guide subsystem standalone
  iam          serve standalone Hanzo IAM (full Beego server: login UI, OAuth2/OIDC, LDAP/RADIUS)
  ingress      serve the ingress subsystem standalone
  integrations serve the integrations subsystem standalone
  kafka        serve the kafka subsystem standalone
  kms          serve the kms subsystem standalone
  knowledge    serve the knowledge subsystem standalone
  licensing    serve the licensing subsystem standalone
  link         serve the link subsystem standalone
  marketing    serve the marketing subsystem standalone
  marketplace  serve the marketplace subsystem standalone
  metrics      serve the metrics subsystem standalone
  ml           serve the ml subsystem standalone
  notify       serve the notify subsystem standalone
  o11y         serve the o11y subsystem standalone
  paas         serve the paas subsystem standalone
  plan         serve the plan subsystem standalone
  platform     serve the platform subsystem standalone
  plugins      serve the plugins subsystem standalone
  pricing      serve the pricing subsystem standalone
  product      serve the product subsystem standalone
  projects     serve the projects subsystem standalone
  prompts      serve the prompts subsystem standalone
  provisioning serve the provisioning subsystem standalone
  pubsub       serve the pubsub subsystem standalone
  referrals    serve the referrals subsystem standalone
  rollingcap   serve the rollingcap subsystem standalone
  runtime      serve the runtime subsystem standalone
  sbom         serve the sbom subsystem standalone
  security     serve the security subsystem standalone
  settings     serve the settings subsystem standalone
  sign         serve the sign subsystem standalone
  social       serve the social subsystem standalone
  storage      serve the storage subsystem standalone
  sync         serve the sync subsystem standalone
  tasks        serve the tasks subsystem standalone
  team         serve the team subsystem standalone
  templates    serve the templates subsystem standalone
  tools        serve the tools subsystem standalone
  tracker      serve the tracker subsystem standalone
  treasury     serve the treasury subsystem standalone
  usage        serve the usage subsystem standalone
  validators   serve the validators subsystem standalone
  visor        serve the visor subsystem standalone
  wallets      serve the wallets subsystem standalone
  websearch    serve the websearch subsystem standalone
  world        serve the world subsystem standalone
  x402         serve the x402 subsystem standalone
  zen          serve the zen subsystem standalone
  zero-trust   serve the zero-trust subsystem standalone

Meta:
  help         show this message
  version      print version and exit

Flags are per-subcommand (e.g. `hanzo cloud --enable=iam,kms --brand=hanzo`,
`hanzo kms --listen=:8443`). Run a subcommand to see its config via env/flags. (HIP-0106): link composes fabric join (hanzo dev — the unified Hanzo Go binary

Usage:
  hanzo <command> [flags]

Control commands (gcloud/doctl-style):
  agent        invoke a managed Hanzo agent to run a task (headless)
  apps         list/get the platform apps board (declared/running/drift)
  auth         manage authentication + stored identities (login, logout, whoami, list, switch, token)
  bot          launch a computer-using agent (booted desktop or terminal)
  build        enqueue a platform-native build (runner fabric)
  clusters     provision/list/select dedicated DOKS clusters
  code         launch a coding agent (claude, codex, dev) on a Hanzo cloud model
  config       view/edit ~/.hanzo/config preferences
  deploy       drive a platform redeploy (rolling restart, zero-downtime)
  engine       run a local hanzo-engine (OpenAI + Anthropic model server)
  k8s          deploy-target helpers (current target)
  link         bring this machine into the Hanzo cloud fleet as a node (fabric + compute worker)
  login        authenticate against Hanzo IAM (hanzo.id) and store a token
  logout       remove stored credentials
  run          launch a workload on Hanzo compute (container or function)
  runner       run this machine as a JIT CI runner for your org (GitHub Actions)
  security     scan files for hardcoded secrets (local guardrail; no server/auth)
  status       show the org's fleet — every node with each of its GPUs
  unlink       take this machine out of the fleet (deregister + stop hanzod)
  whoami       show the current identity from the stored token

Service subcommands (server mode):
  account      serve the account subsystem standalone
  account-bridge serve the account-bridge subsystem standalone
  admin        serve the admin subsystem standalone
  admission    serve the admission subsystem standalone
  ads          serve the ads subsystem standalone
  affiliates   serve the affiliates subsystem standalone
  agent        serve the agent subsystem standalone
  agents       serve the agents subsystem standalone
  agentskills  serve the agentskills subsystem standalone
  ai           serve the ai subsystem standalone
  analytics    serve the analytics subsystem standalone
  audit        serve the audit subsystem standalone
  authors      serve the authors subsystem standalone
  authz        serve the authz subsystem standalone
  automations  serve the automations subsystem standalone
  base         serve the base subsystem standalone
  billing      serve the billing subsystem standalone
  bots         serve the bots subsystem standalone
  captable     serve the captable subsystem standalone
  catalogsync  serve the catalogsync subsystem standalone
  channels     serve the channels subsystem standalone
  cloud        serve the full unified surface (all enabled subsystems, one listener)
  cloudflare   serve the cloudflare subsystem standalone
  code         serve the code subsystem standalone
  commerce     serve the commerce subsystem standalone
  company      serve the company subsystem standalone
  content      serve the content subsystem standalone
  crm          serve the crm subsystem standalone
  dataroom     serve the dataroom subsystem standalone
  datastore    datastore-fork analytics DB — not a Go serve target (see help text)
  deploy       serve the deploy subsystem standalone
  dns          serve the dns subsystem standalone
  do           serve the do subsystem standalone
  domain       serve the domain subsystem standalone
  entitlements serve the entitlements subsystem standalone
  evals        serve the evals subsystem standalone
  exec         serve the exec subsystem standalone
  flags        serve the flags subsystem standalone
  framework    serve the framework subsystem standalone
  functions    serve the functions subsystem standalone
  gateway      serve the gateway subsystem standalone
  git          serve the git subsystem standalone
  graph        serve the graph subsystem standalone
  guide        serve the guide subsystem standalone
  iam          serve standalone Hanzo IAM (full Beego server: login UI, OAuth2/OIDC, LDAP/RADIUS)
  ingress      serve the ingress subsystem standalone
  integrations serve the integrations subsystem standalone
  kafka        serve the kafka subsystem standalone
  kms          serve the kms subsystem standalone
  knowledge    serve the knowledge subsystem standalone
  licensing    serve the licensing subsystem standalone
  link         serve the link subsystem standalone
  marketing    serve the marketing subsystem standalone
  marketplace  serve the marketplace subsystem standalone
  metrics      serve the metrics subsystem standalone
  ml           serve the ml subsystem standalone
  notify       serve the notify subsystem standalone
  o11y         serve the o11y subsystem standalone
  paas         serve the paas subsystem standalone
  plan         serve the plan subsystem standalone
  platform     serve the platform subsystem standalone
  plugins      serve the plugins subsystem standalone
  pricing      serve the pricing subsystem standalone
  product      serve the product subsystem standalone
  projects     serve the projects subsystem standalone
  prompts      serve the prompts subsystem standalone
  provisioning serve the provisioning subsystem standalone
  pubsub       serve the pubsub subsystem standalone
  referrals    serve the referrals subsystem standalone
  rollingcap   serve the rollingcap subsystem standalone
  runtime      serve the runtime subsystem standalone
  sbom         serve the sbom subsystem standalone
  security     serve the security subsystem standalone
  settings     serve the settings subsystem standalone
  sign         serve the sign subsystem standalone
  social       serve the social subsystem standalone
  storage      serve the storage subsystem standalone
  sync         serve the sync subsystem standalone
  tasks        serve the tasks subsystem standalone
  team         serve the team subsystem standalone
  templates    serve the templates subsystem standalone
  tools        serve the tools subsystem standalone
  tracker      serve the tracker subsystem standalone
  treasury     serve the treasury subsystem standalone
  usage        serve the usage subsystem standalone
  validators   serve the validators subsystem standalone
  visor        serve the visor subsystem standalone
  wallets      serve the wallets subsystem standalone
  websearch    serve the websearch subsystem standalone
  world        serve the world subsystem standalone
  x402         serve the x402 subsystem standalone
  zen          serve the zen subsystem standalone
  zero-trust   serve the zero-trust subsystem standalone

Meta:
  help         show this message
  version      print version and exit

Flags are per-subcommand (e.g. `hanzo cloud --enable=iam,kms --brand=hanzo`,
`hanzo kms --listen=:8443`). Run a subcommand to see its config via env/flags., delegated to the Rust CLI installed as hanzo code claude · /home/z/work/hanzo/cloud · start
  model routing: on → api.hanzo.ai (prompts + code go here; usage metered to your org)
  session stream: on → https://hanzo.bot/sessions/sess_4729b3a0a623c55d0d3b7b08fe8ebe35
resume: hanzo --resume 4729b3a0a623c55d0d3b7b08fe8ebe35) with compute-worker
registration (CPU cores+model, memory, each GPU), heartbeats, claims gpu-jobs.
unlink is idempotent; status renders the fleet with each GPU distinct. hanzo dev — the unified Hanzo Go binary

Usage:
  hanzo <command> [flags]

Control commands (gcloud/doctl-style):
  agent        invoke a managed Hanzo agent to run a task (headless)
  apps         list/get the platform apps board (declared/running/drift)
  auth         manage authentication + stored identities (login, logout, whoami, list, switch, token)
  bot          launch a computer-using agent (booted desktop or terminal)
  build        enqueue a platform-native build (runner fabric)
  clusters     provision/list/select dedicated DOKS clusters
  code         launch a coding agent (claude, codex, dev) on a Hanzo cloud model
  config       view/edit ~/.hanzo/config preferences
  deploy       drive a platform redeploy (rolling restart, zero-downtime)
  engine       run a local hanzo-engine (OpenAI + Anthropic model server)
  k8s          deploy-target helpers (current target)
  link         bring this machine into the Hanzo cloud fleet as a node (fabric + compute worker)
  login        authenticate against Hanzo IAM (hanzo.id) and store a token
  logout       remove stored credentials
  run          launch a workload on Hanzo compute (container or function)
  runner       run this machine as a JIT CI runner for your org (GitHub Actions)
  security     scan files for hardcoded secrets (local guardrail; no server/auth)
  status       show the org's fleet — every node with each of its GPUs
  unlink       take this machine out of the fleet (deregister + stop hanzod)
  whoami       show the current identity from the stored token

Service subcommands (server mode):
  account      serve the account subsystem standalone
  account-bridge serve the account-bridge subsystem standalone
  admin        serve the admin subsystem standalone
  admission    serve the admission subsystem standalone
  ads          serve the ads subsystem standalone
  affiliates   serve the affiliates subsystem standalone
  agent        serve the agent subsystem standalone
  agents       serve the agents subsystem standalone
  agentskills  serve the agentskills subsystem standalone
  ai           serve the ai subsystem standalone
  analytics    serve the analytics subsystem standalone
  audit        serve the audit subsystem standalone
  authors      serve the authors subsystem standalone
  authz        serve the authz subsystem standalone
  automations  serve the automations subsystem standalone
  base         serve the base subsystem standalone
  billing      serve the billing subsystem standalone
  bots         serve the bots subsystem standalone
  captable     serve the captable subsystem standalone
  catalogsync  serve the catalogsync subsystem standalone
  channels     serve the channels subsystem standalone
  cloud        serve the full unified surface (all enabled subsystems, one listener)
  cloudflare   serve the cloudflare subsystem standalone
  code         serve the code subsystem standalone
  commerce     serve the commerce subsystem standalone
  company      serve the company subsystem standalone
  content      serve the content subsystem standalone
  crm          serve the crm subsystem standalone
  dataroom     serve the dataroom subsystem standalone
  datastore    datastore-fork analytics DB — not a Go serve target (see help text)
  deploy       serve the deploy subsystem standalone
  dns          serve the dns subsystem standalone
  do           serve the do subsystem standalone
  domain       serve the domain subsystem standalone
  entitlements serve the entitlements subsystem standalone
  evals        serve the evals subsystem standalone
  exec         serve the exec subsystem standalone
  flags        serve the flags subsystem standalone
  framework    serve the framework subsystem standalone
  functions    serve the functions subsystem standalone
  gateway      serve the gateway subsystem standalone
  git          serve the git subsystem standalone
  graph        serve the graph subsystem standalone
  guide        serve the guide subsystem standalone
  iam          serve standalone Hanzo IAM (full Beego server: login UI, OAuth2/OIDC, LDAP/RADIUS)
  ingress      serve the ingress subsystem standalone
  integrations serve the integrations subsystem standalone
  kafka        serve the kafka subsystem standalone
  kms          serve the kms subsystem standalone
  knowledge    serve the knowledge subsystem standalone
  licensing    serve the licensing subsystem standalone
  link         serve the link subsystem standalone
  marketing    serve the marketing subsystem standalone
  marketplace  serve the marketplace subsystem standalone
  metrics      serve the metrics subsystem standalone
  ml           serve the ml subsystem standalone
  notify       serve the notify subsystem standalone
  o11y         serve the o11y subsystem standalone
  paas         serve the paas subsystem standalone
  plan         serve the plan subsystem standalone
  platform     serve the platform subsystem standalone
  plugins      serve the plugins subsystem standalone
  pricing      serve the pricing subsystem standalone
  product      serve the product subsystem standalone
  projects     serve the projects subsystem standalone
  prompts      serve the prompts subsystem standalone
  provisioning serve the provisioning subsystem standalone
  pubsub       serve the pubsub subsystem standalone
  referrals    serve the referrals subsystem standalone
  rollingcap   serve the rollingcap subsystem standalone
  runtime      serve the runtime subsystem standalone
  sbom         serve the sbom subsystem standalone
  security     serve the security subsystem standalone
  settings     serve the settings subsystem standalone
  sign         serve the sign subsystem standalone
  social       serve the social subsystem standalone
  storage      serve the storage subsystem standalone
  sync         serve the sync subsystem standalone
  tasks        serve the tasks subsystem standalone
  team         serve the team subsystem standalone
  templates    serve the templates subsystem standalone
  tools        serve the tools subsystem standalone
  tracker      serve the tracker subsystem standalone
  treasury     serve the treasury subsystem standalone
  usage        serve the usage subsystem standalone
  validators   serve the validators subsystem standalone
  visor        serve the visor subsystem standalone
  wallets      serve the wallets subsystem standalone
  websearch    serve the websearch subsystem standalone
  world        serve the world subsystem standalone
  x402         serve the x402 subsystem standalone
  zen          serve the zen subsystem standalone
  zero-trust   serve the zero-trust subsystem standalone

Meta:
  help         show this message
  version      print version and exit

Flags are per-subcommand (e.g. `hanzo cloud --enable=iam,kms --brand=hanzo`,
`hanzo kms --listen=:8443`). Run a subcommand to see its config via env/flags. is a
superset — non-Go verbs pass through to hanzo-node so nothing breaks.
2026-07-21 17:56:10 -07:00
hanzo-dev 31e87b7f1e link: hanzo is a superset — delegate non-Go verbs to the Rust CLI (hanzo-node)
The Go unified binary takes the `hanzo` name (HIP-0106). fabricCLI now resolves the
Rust fabric/dev CLI as `hanzo-node` (then a self-guarded `hanzo`), and cmd/hanzo
delegates any verb that is neither a Go control verb nor a served subsystem —
node, dev, wallet, network, … — to it via cli.Passthrough. So one `hanzo` name
serves both: link/unlink/status + the whole Go surface native, and `hanzo node up`
(the fabric that link composes) plus the Rust dev verbs handed through unchanged.
2026-07-21 17:22:39 -07:00
hanzo-dev 28c4f699d0 Merge remote-tracking branch 'origin/main' into feat/hanzo-link 2026-07-21 17:22:26 -07:00
hanzo-dev 3649758225 Merge remote-tracking branch 'origin/main' into feat/hanzo-link
# Conflicts:
#	cli/gpu.go
2026-07-21 16:58:53 -07:00
hanzo-dev e06813a5ed link: RED-review fixes — unlink idempotency + comment sweep
unlink is now idempotent: runDisconnect treats an already-terminal (409) or absent
(404) fleet row as the desired end state (no-op with a clear notice), and unlink
runs stopFabric unconditionally so a deregister error never leaves hanzod running —
the deregister error is reported, not short-circuited. Tests cover the 409/404
idempotent path and the 500 error-surfacing path.

Sweeps the remaining `gpu connect` prose in comments to `link` (engine, runner,
visor fleet/board/visor, and the gpu/fleet spec + engine test headers).
2026-07-21 16:56:58 -07:00
hanzo-dev f09d98a1a0 chore(deps): bump hanzoai/ai v1.829.3 → v1.829.5 (RESTful ZAP-native router routes; beego split-brain killed) 2026-07-21 16:54:53 -07:00
antje 707b6332ad fix(team): heal the authenticating caller's own member row (real Seats:0 fix)
The live wallet Seats:0 for maxpower was NOT the orgs claim (Dave's claim already
lists maxpower/admin). Dave OWNS a maxpower workspace, but a team-go migration left
his own member row is_bot=1/active=0, so Seats (which filters active=1 AND is_bot=0)
excluded him while getUserWorkspaces still listed the workspace (no flag filter).
EnsureWorkspace early-returned on the existing workspace without ever correcting
the row, so every re-login kept 0.

A user who just authenticated through IAM is by definition an active, non-bot member
of their own workspace: EnsureWorkspace now forces the caller's OWN row (never
anyone else's) to active=1/is_bot=0 on the existing-workspace path. Idempotent.
TestEnsureWorkspaceHealsMigratedMember reproduces Dave's exact shape red→green.
2026-07-21 16:26:18 -07:00
antje 524ffb7cdb fix(team): home org always ensured a seat; createContent seeds the ydoc log
Two live hanzo.team defects:

1) Wallet Seats:0 for the caller's own org. orgsClaim dropped the HOME org (the
   org the wallet, Seats, and every account-store surface scope to via extra.org)
   whenever the IAM orgs claim was non-empty but did not itself list home — the
   fallback only fired for an EMPTY claim. So establishSession never ensured a
   home-org workspace, and Seats(home) returned 0 for an org that has the caller
   as a member. Home is now unconditionally in the set, so its workspace (hence a
   seat) is ensured at every login. Reproduced in TestOrgsClaimAlwaysIncludesHome.

2) New-Issue dialog description dropped on create. createContent stored only the
   markup SNAPSHOT blob; the collaborative editor replays the Y.js update log the
   WS lane serves (ydoc-<id>-<field>), a different blob, so the description showed
   empty. createContent now also seeds that log from the front-supplied Y.js
   update (never clobbering an existing/live log; scoped to createContent).
   TestCollabCreateContentSeedsYLog covers it.
2026-07-21 16:05:22 -07:00
hanzo-dev 4bd4feabe4 link: hanzo link|unlink|status — bring a machine into the fleet as a node
link composes the two node memberships under one verb: it starts hanzod via the
canonical `hanzo node up` (best-effort; --no-fabric skips it) and runs the
compute-worker loop that registers this host's CPU (cores + model), memory, and
each GPU as its own resource, then heartbeats and claims jobs from the org queue.
unlink deregisters the node and stops hanzod; status renders the fleet with each
GPU shown distinctly and this box highlighted. Works on a CPU-only node.

Replaces the gpu connect|status|disconnect surface (one way, no alias). Adds a
CPU model field to the advertised inventory — CLI registration and the visor
fleet record in lockstep. The worker machinery (register/heartbeat/claim, studio,
engine advertise) is unchanged; only the command layer and inventory grow.
2026-07-21 16:02:16 -07:00
antje b7b61933bf fix(admin): commerce cost god-view uses the in-process transport (fixes commerce.inproc DNS fail)
The admin cockpit's commerce reader (/v1/admin/finance COGS, /v1/admin/usage,
costs) built a PLAIN http.Client but its base is commerceinproc.BaseURL() — which
returns the 'http://commerce.inproc' placeholder when commerce is co-resident. A
plain client DNS-resolves that host → 'lookup commerce.inproc: no such host', so
the admin finance/cost god-view silently errored ('commerce unreachable'). Swap to
commerceinproc.Client() — the self-routing transport metering already uses:
in-process dispatch for the placeholder host, plain HTTP for a split-deploy URL.
Only this admin client hit it (the others use real env URLs).
2026-07-21 15:52:30 -07:00
antje a952bed0b7 feat(billing): default rolling-cap fallback — protect pay-as-you-go too
The per-tier rolling cap only governed SEEDED subscription tiers (free/pro/…).
Pay-as-you-go / empty / unknown tiers fell through to uncapped — a burst-spend
hole for the entire non-subscription user base (every current org is
pay-as-you-go). Add ai_rolling_cap_cents_default: when a caller's tier has no
specific cap, the reader falls back to it, so EVERY caller gets a rolling ceiling
once set. Default 0 = opt-in (no behavior change until an admin sets it in the
cockpit); tier-specific caps still win. Fail-open on tier/sum error unchanged.
Test covers the fallback (pay-as-you-go over default → deny; tier-specific beats
default; empty+no-default still admits).
2026-07-21 15:23:02 -07:00
hanzo-dev 2f3535d5be test(apps): refreeze wire golden — add validators (91st subsystem)
A parallel merge (feat(validators): NFT-gated node provisioning) added the
validators subsystem to Wire() at position 45 (after ads) without updating the
frozen golden, so TestWireOrderMatchesFrozen fails 91 vs 90 — the failing test
behind main's red CI once the go.sum compile error is fixed. Refroze to match
Wire() order + flags (ownsHealth=false, hasShutdown=true).
2026-07-21 15:17:23 -07:00
hanzo-dev 807bad3f4c fix(deps): go mod tidy — complete go.sum, unblock main CI
Main CI/CD has been red for 8+ commits: go.sum was missing transitive
entries (mongo-driver/bson via golang-set, btcd/chainhash/v2 via btcec,
hanzos3/go-sdk via zapdb, go-json-experiment/json + luxfi/filesystem via
luxfi/node) so go vet/test/build all fail before any test runs. A parallel
dep bump landed without tidy. Pure require-list + go.sum reconcile; versions
unchanged (luxfi/node stays v1.36.15).
2026-07-21 14:52:15 -07:00
zeekayandClaude Opus 4.8 fc3de9ede2 fix(validators): register /v1/validators collection root flat (avoid trailing-slash 404)
Group("/v1/validators").Post("") registers "/v1/validators/", which the
portal's bare POST /v1/validators would miss. Register the list+provision
collection root via app.Get/app.Post like clients/wallets et al.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 14:24:55 -07:00
3a417701c8 feat(fleet): per-GPU render queue — targeted lanes, visibility, cancel, usage (#346)
* test(apps): refreeze wire golden — add rollingcap subsystem

Wire() mounts 90 subsystems but the frozen sequence listed 89: the rollingcap
gate (mounted after billing) was added to Wire() without refreezing the golden,
so TestWireOrderMatchesFrozen has been red on main. Add the missing entry at its
Wire() position — the same maintenance the earlier dns+cloudflare refreeze did.
Pre-existing drift, unrelated to the per-GPU queue work; folded in so cloud CI's
go-unit gate (which runs ./apps/) is green again.

* feat(fleet): per-GPU render queue — targeted lanes, visibility, cancel, usage

Make all BYO-GPU render work flow through the org's gpu-jobs queue, visible and
manageable per GPU, and close the hidden direct-submit hole.

cli/gpu.go
- Two-lane claim: the worker claims its OWN lane ("gpu:<identity>") FIRST, then
  the shared "gpu-jobs" lane — targeting is the taskQueue VALUE within the one
  gpu-jobs namespace, so a job pinned to spark is never starved and no worker
  steals another GPU's targeted job.
- Render submit moves from the open POST /prompt to the gated
  POST /v1/worker/execute with X-Worker-Token (KMS STUDIO_WORKER_TOKEN).
- Worker reports live GPU utilization (nvidia-smi) each heartbeat via
  POST /v1/fleet/samples.

cli/studio.go
- Launch the local ComfyUI with --listen 127.0.0.1 --worker-mode (was 0.0.0.0,
  unauthenticated) — the worker dials loopback, so binding wider only exposed an
  open /prompt; worker-mode gates the submit seam.

clients/visor
- GET /v1/fleet/jobs?gpu=&status= — the org's queue, each row tagged with the
  GPU it targets (''=shared lane) + the claiming worker; ?gpu=X matches target OR
  claimant; status normalized to queued|running|completed|failed|canceled; the
  full ComfyUI graph is omitted (cheap SaveImage label instead).
- POST /v1/fleet/jobs/:id/cancel {run,reason} — org-scoped cancel via the tasks
  CancelActivityForOrg wrapper (tasks v1.51.2).
- POST /v1/fleet/samples — BYO util ingest into the existing samples warehouse
  the board already overlays; fleetUnit gains Queued/Running per-GPU depth.

Reuses ActivitiesForOrg (one engine, one tenant key), fail-soft by source.
TDD: claim precedence, gpuTarget, status normalize, filter, per-node counts,
sample build, route tenancy — ./cli/ + ./clients/visor/ added to the CI go-unit
gate so they run every push.

* fix(gpu): detach + bound the util sampler so a hung nvidia-smi can't wedge the worker

sampleGPUs shelled nvidia-smi with no timeout and reportSample ran SYNCHRONOUSLY in
the worker's select loop (heartbeat tick + render-progress tick). Under GPU/driver
pressure nvidia-smi can hang, blocking the whole loop → no heartbeats, no claims →
the machine flaps offline and stops rendering mid-run.

- sampleGPUs takes a ctx and runs the probe via exec.CommandContext under a 5s cap
  (self-cancels instead of hanging); the probe is an injectable package var.
- reportSample detaches probe+POST onto its own goroutine under a 20s budget and
  returns immediately, so the select loop is never blocked. At most one report in
  flight per ticker site (interval >> budget); self-cancels on worker shutdown.

Test: a hung sampler (blocks until its bounded ctx fires) — reportSample still
returns to the caller at once, proving claim/heartbeat can't wedge (-race clean).

* fix(fleet): adversarial batch — pagination, render preflight, filter/stall/token hardening

F1 (MAJOR): the queue + fleet reads no longer truncate at 100 rows. gpuJobs and
byoWorkers cursor-walk the org's namespace to completion via the new paginated
tasks read (ActivitiesPageForOrg, tasks v1.51.3); gpuJobs then recency-sorts and
bounds terminal history (all live jobs kept + last 50 terminal). Past ~100 lifetime
renders a busy org no longer hides live jobs or drops online workers.

F2 (MAJOR): worker render preflight. A node advertises studioCap + claims render
lanes ONLY when it can serve — STUDIO_WORKER_TOKEN present AND a studio reachable
(or launched via --studio-dir). Otherwise it heartbeats as present but claims
nothing (no poison loop of 403→FAILED→reclaim), with a loud one-time operator
warning. Re-evaluated each heartbeat so a studio dying/recovering flips claiming.

F3: ?gpu= filter is case-insensitive (node ids are lower-case).
F4: a running job past its lease surfaces as 'stalled' (worker died, not yet reaped)
    instead of 'running' forever.
F5: every loopback studio call (execute/history/view/upload/queue) sends
    X-Worker-Token, robust to the worker-mode gate widening scope.
F7: corrected the '/prompt' → '/v1/worker/execute' error string.

Tests: >100-row ordering/bound + stall + case-insensitive filter + cancel err→HTTP
(404/409) mapping; terminal complete/fail hit the exact ns+wf+run path (catches a
routing regression a 200-everything stub would miss); SharePolicy.reject fallback;
not-ready node claims nothing; studioCap gating. tasks v1.51.3 adds
ActivitiesPageForOrg with a >100 pagination test.

---------

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-21 14:13:55 -07:00
antje 3a464b01c4 security: reserve stg slug — close shared-client redirect takeover vector
The shared hanzo-app OAuth client trusts https://stg.hanzo.app/callback, but `stg` was not a reserved subdomain — an attacker could first-come-claim stg.hanzo.app, run authorize(client_id=hanzo-app, redirect=stg.hanzo.app/callback) (IAM exact-matches), and harvest a logged-in user code minted with aud=hanzo-app that api.hanzo.ai trusts → account takeover. stg.hanzo.app is 404/unbound today, so reserving closes it safely.

Adds `stg` to sites.baseReserved + reserved-superset test (reserved ⊇ {www,stg}) + storage-layer BindHost(stg)-rejected test. Cherry-picked ONLY the isolated clients/sites files from 5ac8f89; the larger per-app IAM client changes (appauth.go/projects.go/iam) stay OUT of main pending red re-review.

Verified: go build ./... ok; CGO=0 go test ./clients/sites/... ok; BindHost(stg)→errReservedHost, stg does not resolve.
2026-07-21 13:48:33 -07:00
zeekayandClaude Opus 4.8 c90196fa14 feat(validators): POST /v1/validators — NFT-gated node provisioning + owner-gated registration
Phase-1 of GDA/SDM validator onboarding on lux.cloud. New /v1/validators/*
subsystem: a caller proves wallet control (EIP-191 personal_sign challenge,
address recovered server-side) AND on-chain ownership of a Validator-tier
GenesisNFT on Ethereum mainnet (ownerOf against 0x31e0F919C67ceDd2Bc3E294340Dc900735810311,
reusing the luxfi/geth read path), then the endpoint:
  - generates a luxd staking identity (TLS+BLS+ML-DSA-65 -> strict-PQ NodeID)
    via luxfi/node/staking — byte-identical to genesis/cmd/venuekeygen — and
    SEALS it into KMS (never plaintext; fail-closed);
  - persists the org -> tokenId -> slot entitlement (per-org SQLite);
  - writes a NEW-node LuxNetwork CR (group node.lux.cloud, ns lux-validators)
    + KMSSecret sync — three hard guards make it structurally incapable of
    touching the live hand-managed luxd StatefulSets;
  - ENQUEUES an owner-gated registration (pending_owner_approval, NEVER
    auto-submitted to any P-Chain).

Adds github.com/luxfi/node v1.36.15 (requires cloud's exact pinned
crypto/ids/geth — zero version skew). 13 tests pass incl. a live ETH-mainnet
ownerOf read; go vet clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 13:38:14 -07:00
hanzo-dev 4264ddeb19 cap+router RED fixes: spend-cap writes require org-admin + bump ai v1.829.3 / commerce v1.49.8
- F2-1: gate the co-resident spend-alert CRUD WRITES (POST/PATCH/DELETE) to an ORG ADMIN
  / SuperAdmin / trusted S2S token (requireSpendCapAdmin) — commerce's user group admitted
  any authenticated member, so a compromised member key could DELETE the org's cap
  (unbounded spend) or POST a 1c enforce cap (org-wide 402 DoS). Reads (list/authorize)
  stay member/S2S-open. Exports accountclient.IsServiceToken for the gate.
- bump github.com/hanzoai/ai v1.829.0 → v1.829.3 (router allowlist HARD floor + no
  unowned-OrgSettings clobber across all writers: beego/ZAP/trainer/generic-setter).
- bump github.com/hanzoai/commerce v1.49.7 → v1.49.8 (spend-cap fails OPEN on unknown
  spend, not closed — no 402-storm on a finance-read blip).
2026-07-21 12:53:44 -07:00
69cf880b2f sync: repoint the git provider from Gitea to the native /v1/git plane (#347)
The universal sync engine's git provider drove an EXTERNAL Gitea store
(giteaFromEnv → gitea.mirrorIn / ensurePushMirror). Repoint it to the native git
object-plane seams already registered by clients/git at Mount, so the ONE git
store IS the in-binary /v1/git plane and no byte transits an external git host:

  - inbound (source push)  → cloud.InboundGitSync  (fast-forward-only advance;
                             a diverged native ref is a Conflict, native preserved)
  - reconcile pull/both    → cloud.ImportGitRepo   (ff mirror every branch in;
                             MirrorURL=source registers the native→source push-back)
  - reconcile push-only    → cloud.EnsureGitMirror (declare the outbound target;
                             the native mirror_out lifecycle does the pushing)

sync_api.reconcileOutboundMirror likewise moves onto cloud.EnsureGitMirror — the
ONE outbound-target registrar — so a sync's mirror target is never split across
two stores. gitea.go + gitea_test.go (the entire external-Gitea client) are now
dead and removed: forwards-only, no dead code, DRY.

This also FIXES the provider being inert in prod: giteaFromEnv fails closed
without GITEA_TOKEN/URL (unset on cloud), so no git sync could reconcile; the
native seams are in-process and need no external config. resolve() (the pure
decision core) is unchanged — TestGitResolve green; build + vet clean. The
KMS-gated store tests (TestSyncValidation) fail identically on origin/main
(CLOUD_KMS_MASTER_KEY_REF test-env requirement), orthogonal to this change.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-21 11:51:59 -07:00
1d309c9474 git: serve native git UI at git.hanzo.ai root (host-routed, GitHub-style URLs) (#345)
The native /v1/git UI (ui.go) was reachable only under /git/* on every host, so
git.hanzo.ai/ and git.hanzo.ai/<org>/<repo> fell through to the console SPA
catch-all (webui.go app.All("/*")) — the console shadowed the git host root.

Extend the existing onGitHost host-routing (already guarding the root smart-HTTP
/:org/:repo/* clone paths) to the UI: register the SAME handlers at the root
("/", "/:org/:repo", tree/blob/commits) gated to the git host. On api/console
they fall through (c.Next()) to the console catch-all, so a bare /:org/:repo
never shadows it there; on git.hanzo.ai they serve the native browser.

URLs are now canonical per host — one and only one way: base "" on the git host
(git.hanzo.ai/<org>/<repo>, matching the clone URL) and "/git" where the console
embeds the browser. Thread that base through render/templates/href-builders; the
UI clone box shows the clean git-host form (git.hanzo.ai/<org>/<repo>.git).

Non-destructive: additive routes behind a host guard; smart-HTTP clone routing
(distinct /info/refs|/git-*-pack tail) and console/api hosts are unchanged.
TestRootUI_HostGuard covers git-host serve + api-host fall-through.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-21 11:44:11 -07:00
hanzo-dev db2e9284be chore(deps): bump github.com/hanzoai/ai v1.829.0 → v1.829.2 (per-org router config surface)
Picks up the per-org enabled-models allowlist + savings-vs-quality dial: OrgSettings
RouterEnabledModels/RouterQualityBias, the /v1/get-router-policy + /v1/update-router-policy
carry them (GET also returns the servable-model catalog), and resolveAutoModel enforces
the allowlist (both heuristic + engine paths) and the dial (cost-budget narrow + SLO
tighten). Opt-in: an org that sets neither routes exactly as before.
2026-07-21 11:40:42 -07:00
hanzo-dev 0a9c727609 docs(projects/CONTRACT): converge the two site planes into one — static crs/ retires into Projects
Decision + per-site migration runbook: every first-party static site (cd/flow/
gallery/yadota) becomes a Project served by the ONE host-router (clients/sites),
bundle moved to the canonical <bucket>/<org>/<slug> layout (no external-prefix
special case — resolver keeps one code path). <slug>.hanzo.ai becomes a bound host
routed through cloud, mirroring the *.hanzo.app wildcard edge. Steps 1-2 additive
(no live-routing change); the cutover (route *.hanzo.ai via cloud, delete the
staticFiles Middleware+IngressRoute) is a reviewed per-host flip, cd.hanzo.ai LAST.
End state: static-sites.yaml holds zero first-party sites — one router, one S3
layout, one store; sites sourced from hanzo-apps.
2026-07-21 10:59:00 -07:00
a63189c83a refactor(iam): drop cloud's DIRECT iam-v1 dep — use the clean v2 iam OrgRef (#344)
'iam-v1 is dead; use the new clean iam for all things.' cloud imported the dead
Casdoor fork github.com/hanzoai/iam-v1 in 6 files for exactly ONE type: OrgRef
{Org,Role} (a JWT-claim membership ref). The v2 clean-room iam (github.com/
hanzoai/iam) now EXPORTS it at pkg/model.OrgRef (= schema.OrgRef, byte-identical
JSON) as of v1.32.1. Repoint all 6 (auth_identity, token_validator,
clients/team/{invite,account}, + 2 tests) to model.OrgRef and bump iam→v1.32.1.

cloud's own code now has ZERO direct iam-v1 imports. iam-v1 remains ONLY as a
TRANSITIVE dep via hanzoai/ai/object, which still couples to the full Casdoor
IAM API (Claims/GetUser/GetOrganization/MFA…) — a major separate migration
(ai's domain), not an OrgRef swap. Builds clean; model.OrgRef resolves.

Co-authored-by: zeekay <ai@hanzo.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 10:55:54 -07:00
hanzo-dev f3843d34b5 cap: mount spend-alerts CRUD writes co-resident — customers could not set a cap
The GET list + GET /authorize are served co-resident, but POST/PATCH/DELETE
/v1/billing/spend-alerts were NOT — so a customer creating/editing/removing a usage
cap fell through the account bridge's /v1/billing/* wildcard (billingForwardable
includes POST spend-alerts), forwarded to COMMERCE_URL (= this binary), and
self-dispatched into the SAME 502 loop authorize hit. Net: self-service cap
management was impossible in the unified binary (every write 502'd).

Register CreateSpendAlert / UpdateSpendAlert / DeleteSpendAlert co-resident with the
exact chain commerce's own route table gates them (api/billing/handlers.go:322-325,
user group userRequired = TokenRequired) + the global RequestContext: an IAM JWT OR
the COMMERCE_SERVICE_TOKEN, org from the gateway-pinned X-Org-Id. Org-scoped by
namespace (a caller writes only their OWN org's caps; a foreign :id misses in their
namespace), so no PinBillingSubject — spend-alerts are org-level. Specific routes
shadow the bridge wildcard (order 100 < 122). Completes the self-service cap CRUD:
list + authorize (already co-resident) + create/edit/delete (this).
2026-07-21 10:00:45 -07:00
807cf30294 feat(agents): seed the built-in crew @dev @des @vi on org first-touch (re-land) (#342)
Re-land of the crew seed (a parallel force-push to cloud main dropped it).
personalities.go: dev/des/vi personas + idempotent SeedPersonalities(ctx,org)
(one registry, UNIQUE(org,name); no-op without a model). account.go OAuth
callback seeds per-org after EnsureWorkspace (best-effort, never blocks login).
Native TestSeedPersonalities green. TEAM_AGENTS_ENABLED=1 already live +
AIDefaultModel=deepseek-v4-flash → the crew materializes and answers @-mentions.

Co-authored-by: zeekay <ai@hanzo.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:51:39 -07:00
zeekayandClaude Opus 4.8 e930c39fd6 feat(agents): seed the built-in crew — @dev @des @vi — on org first-touch
The named personas from the old hanzo.ai site, brought into Hanzo Team as
ordinary rows in the ONE agents registry: nothing special-cased downstream —
they list, project into the Team roster as bot members (bots.go), and answer
@-mentions through the SAME agents.RunOnBehalf path every agent uses.

- clients/agents/personalities.go: the canonical crew (dev=builder, des=designer,
  vi=visionary) + SeedPersonalities(ctx, org) — idempotent via the registry's
  UNIQUE(org,name); no-ops without a default model (never a half-seeded org) or
  an unmounted subsystem (safe to call best-effort on the login path).
- clients/team/account.go: the OAuth callback seeds the crew per-org right after
  EnsureWorkspace — a new org gets its default office AND its default crew
  together. Best-effort: a seed hiccup NEVER blocks login.

Native test green: TestSeedPersonalities — creates the crew, ListForOrg returns
the @dev/@des/@vi handles with model+prompt, re-seed is a 0-create no-op (no
dup), no-model is a clean no-op. To make them TALK, the Chunter responder flips
on with TEAM_AGENTS_ENABLED=1 (the deploy env) — the pipeline is already built.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:46:22 -07:00
hanzo-dev 2c1dd75101 fix(commerce): serve spend-alerts/authorize co-resident, breaking the 502 self-dispatch loop
The request-edge metering gate (clients/metering scopeAuthorize) reads the per-scope
spend-cap verdict at GET /v1/billing/spend-alerts/authorize over commerceinproc. With no
co-resident handler it fell through to the account bridge's /v1/billing/* wildcard, which
re-forwarded it to COMMERCE_URL (the public api.hanzo.ai edge = this binary) BY PATH through
the same transport, re-entering the wildcard until the depth-8 guard refused -> 502. The live
pod logged ~135x/30m of this (org "maxpower"). The cap is a policy overlay so the gate FAILS
OPEN (outer status 200, no traffic blocked), but every authorize call burned 8 full-app
dispatches and the spend cap never actually evaluated (always fail-open) — a silent policy hole.

Register commerce's own AuthorizeSpendCap co-resident in mountCommerce (order 100, ahead of the
bridge at 122) so the specific route shadows the wildcard and the gate hits the real handler at
depth 1 — the same co-resident move already made for plans/spend-alerts/invoices/etc. Service-
token chain (RequestContext + TokenRequired), mirroring commerce's OWN gate on this route, not
the IAM/PinBillingSubject console chain (a service token is not an IAM JWT, so IAMTokenRequired
would leave GetOrganization unset and AuthorizeSpendCap would 500).

Regression test in clients/commerceinproc/selfdispatch_test.go pins both arrangements: without
the specific route the wildcard self-loops to the depth-8 refusal (the prod 502 signature); with
it the handler serves once at depth 1 and the wildcard never fires.
2026-07-21 09:41:12 -07:00
hanzo-dev a9e6e028b4 feat(deploy): project static-plane SITES into the fleet list — CD dashboard shows ALL
GET /v1/deploy/applications listed only App CRs (the ~72 pod-backed services), so
every static-plane SITE — cd.hanzo.ai itself, flow, gallery, yadota, … — was
invisible on the CD dashboard. A site has no App CR / Deployment: it is a
`staticFiles` Middleware (S3 origin `s3://cdn/<slug>`) + an IngressRoute (its host),
served straight from S3 with zero pods.

clients/deploy/sites.go: listSiteApplications enumerates the staticFiles Middlewares
per namespace and joins each to its IngressRoute host, projecting one Application row
per site with Role:"site", Repository=the S3 origin, Endpoints=[https://<host>], and
— since a static site is served from exactly its declared prefix — always Synced
(Version==RunningVersion=="static"). Health = routed (Healthy) vs defined-but-unrouted
(Missing). Best-effort (mirrors runningVersions): a missing-CRD/RBAC list error logs
and yields nothing, so the services half of the board always renders.

deploy.go: middlewaresGVR + ingressRoutesGVR (hanzo.ai/v1alpha1). applications.go:
fold site rows into the per-namespace scan + the summary. deploy_test.go:
TestListSiteApplications (a staticFiles Middleware + IngressRoute → one role:"site"
row; an unrouted Middleware → Missing, no endpoints).

Aligns with "delivery is the cloud deploy engine": the CD dashboard now renders the
WHOLE delivery surface — every service AND every site.

Claude-Session: https://claude.ai/code/session_81b00bd9
2026-07-21 09:40:12 -07:00
zeekayandClaude 399b893b09 fix(deps): bump hanzoai/ai → v1.829.0 (iam-v1 repoint) so cloud graph drops old iam root
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 09:39:14 -07:00
zeekayandClaude c59dcb2a85 refactor(iam): unify on hanzoai/iam@v1.32.0 (former iam2) + retire fork as iam-v1
Clean-room rewrite is now github.com/hanzoai/iam@v1.32.0 (continues the version line
so MVS selects it over the fork's v1.31.x); cloud embeds it via server.Mount. The
fork's object/iamserver/root usages repoint to github.com/hanzoai/iam-v1@v1.31.37.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 09:30:35 -07:00
antje 4055226f6c fix(ai): split completions (M2M) from embeddings (pk-) credential — stop bot replies 403ing on the read-only publishable key
deps.AI (chat completions, a WRITE endpoint: agents/guide/crm/content/
sitegen/code-ask) and deps.Embed (embeddings, READ-ONLY: code-index + KB)
were ONE shared client authenticated by CLOUD_AI_API_KEY — a read-only
publishable (pk-) key from secret cloud-ai-embed-key. The gateway 403s a
pk- key on any write endpoint ('Publishable keys can only access read-only
endpoints ... use a secret key'), so bot replies intermittently failed.

Split the credential by concern:
- pickCompletionsClient (deps.AI) REFUSES any pk- key (publishableKey guard)
  and authenticates with the binary's IAM M2M identity (IAM_CLIENT_ID/SECRET,
  already deployed) — the durable no-static-key path. A secret sk-/hk- static
  key is still honored as an operator override.
- pickEmbedClient (deps.Embed) keeps the pk- key UNCHANGED (correct
  least-privilege for a read-only call); falls back to the M2M resolution when
  no static embed key is set.

Metering unchanged: both clients wrap the ONE meteredAIClient path. No CR/KMS
change needed — the M2M identity is already in the cloud CR.

Tests (pick_test.go): completions refuse pk- and present the M2M bearer;
completions honor an sk- key; pk--only + no M2M fails closed (never sends the
pk- key to the write endpoint); embeddings present the pk- key; embed falls
back to M2M with no key; BuildDeps splits both credentials end to end.
2026-07-21 09:06:13 -07:00
antje 8408c141eb team(wallet): seats count every verified org + honest Free plan display
- establishSession EnsureWorkspace's the FULL verified membership set, not just
  the home org, so a non-home org's wallet counts the caller as a seat instead
  of "0 Members in your org". The multi-org lane unioned getUserWorkspaces across
  every org but left the seat/member projection seeded only for the home org; one
  membership set now drives the token, the workspace union, AND the seat count.
- wallet page: an empty commerce plan renders "Free" + an Upgrade CTA (the login
  gate admits a no-subscription org on the effective Free tier) instead of a bare
  "—" that read as a data failure. Never fabricates a tier.
- wallet header reflows at narrow widths: the Top up button holds its intrinsic
  size (flexShrink 0) under a wrapping row, so it stays whole at 390px instead of
  clipping to "Top".
- test: Seats counts distinct active non-bot members (owner + guest; bot and
  inactive excluded; tenant-scoped).
2026-07-21 09:01:09 -07:00
antje 605f565138 analytics: publishable-key direct ingest (pk_) — fastest capture path, no Kafka hop
Adds a write-only publishable key and a direct-to-ClickHouse ingest ALONGSIDE
the existing /v1/event + Kafka-tier pipeline (nothing removed).

- pk_<b64url(org)>.<b64url(hmac)> — org sealed under HMAC-SHA256(CLOUD_INGEST_KEY_SECRET,
  org). Ingest-only BY CONSTRUCTION: the pk_ underscore prefix is outside
  isAPIKey's set, so SanitizeIdentity/OrgForKey refuse it — it can never become a
  bearer principal, so it can never read. Verify is one HMAC compute (no IAM/DB
  hop) — the lowest-latency path. Fails closed when the secret is unset.
- POST /v1/ingest — {batch:[WireEvent]} authed by pk_, org stamped from the SIGNED
  key (never the body), funneled through the ONE write core (ingestEvents) into
  hanzo.events, tagged source=ingest.
- POST /v1/ingest/keys — an org owner (validated principal) mints a pk_ for its
  OWN org.
- GET /v1/errors — type:'error' read lens (validated principal; reads never accept
  the write-only key). error is now first-class in canonicalType/resolveEventName
  ('error'/$error); the WireEvent error object folds into properties.$exception.

Tests: mint↔verify round trip, fail-closed matrix (forged org, wrong secret,
malformed), exception folding, canonicalType.
2026-07-20 23:45:24 -07:00
antje 4a2e098705 build(deps): @hanzo/plans v1.4.3 → v1.4.4 (goja bundle NAMESPACES synced)
Picks up the sites.*/base.* namespace grouping in the embedded plans bundle so
/v1/plans/vocab matches the canonical entitlements vocabulary. Subscription caps
(ai.rolling_cap_usd/_window_hours, sites/base included) already flowed via the raw
__PLANS_DATA__ injection; this closes the last display-path drift.
2026-07-20 23:25:44 -07:00
hanzo-dev 4500f609ed fix(billing): serve the console's billing READS co-resident, breaking the 502 self-dispatch loop
commerce's api.Route() billing bundle is never compiled into the cloud binary, so
GET /v1/billing/{invoices,subscriptions,spend-alerts,payouts,payment-config} had no
handler here and fell through to the account bridge's /v1/billing/* wildcard. The
bridge forwards to COMMERCE_URL, which defaults to the public api.hanzo.ai edge —
i.e. THIS binary — re-entering the same bridge in an unbounded self-dispatch loop
that surfaces as a 502. In prod there is no separate commerce backend to point
COMMERCE_URL at (the in-cluster commerce Service selects the cloud pods), so
co-residence is the only way to break the loop.

Register commerce's own read handlers on the shared app (order 100, shadowing the
bridge wildcard at 122), behind RequestContext + IAMTokenRequired (org namespace from
the gateway-validated X-Org-Id) + a new PinBillingSubject middleware that carries the
SAME subject-pinning the bridge applies (reusing resolveCaller/scopedBillingSearch/
account.Payer) so a co-resident read can never widen past the caller and an
unvalidated caller is refused before the handler runs. This is the same co-resident
move already made for plans/usage/balance.

Bump the embedded hanzoai/commerce dep to v1.49.7 (catalog SOT + public/admin catalog API).
2026-07-20 18:35:25 -07:00
hanzo-dev d6f98175df merge: serve GET /v1/billing/plans in-process (break the 502 self-dispatch loop) 2026-07-20 17:54:00 -07:00
hanzo-dev 5527827b46 fix(commerce): serve GET /v1/billing/plans in-process, breaking the 502 self-dispatch loop
commerce's legacy api.Route() billing bundle (ListPlans, invoices, subscriptions)
is NOT registered by the co-resident embed — setupRoutes wires only /v1/commerce/*
— so /v1/billing/plans had no handler in the cloud binary. The account bridge's
/v1/billing/* wildcard (order 122) then forwarded the read back to commerce at
COMMERCE_URL, which defaults to the public api.hanzo.ai edge, re-entering the same
bridge in an unbounded self-dispatch loop that surfaced as
'commerce unreachable: Get https://api.hanzo.ai/v1/billing/plans' -> 502.

Register commerce's static ListPlans on the shared app in mountCommerce (order 100,
ahead of the bridge) so the specific route shadows the wildcard and plans serve
in-process — the same co-resident move billing.go already makes for usage/balance.
2026-07-20 17:31:49 -07:00
antje 96232be94e team: collab room flusher — burst-then-idle edits persist on the debounce clock
append() only flushed when the NEXT append found the debounce due, so a
typing burst followed by idle sat dirty in memory until the last peer
left. The per-room flusher ticks the same debounce; GC closes it.
2026-07-20 17:12:55 -07:00
hanzo-devandantje 096de3d8b1 build(deps): commerce v1.49.6 — RED residual fixes (dunning, re-subscribe, books integrity) 2026-07-20 17:12:33 -07:00
antje 0d8c0df95f team: collab WS keepalive — server pings keep throttled tabs off the 1006 path
Backgrounded tabs throttle the provider's awareness renewals; without
server pings the idle read deadline fires an abrupt close the provider
surfaces as 1006 — the 'cannot connect to collaboration service' banner.
The browser's network stack auto-pongs even when throttled; each pong
extends the read deadline.
2026-07-20 17:09:33 -07:00
antje 3b09f22fb1 team: live collaborative editing — hocuspocus WS lane at /collaborator
The front's @hocuspocus/provider (2.15) speaks its protocol at the bare
/collaborator path with the doc id IN-BAND, which no ingress rewrite can
bridge to the collab relay's /v1/collab/<id> mux — so the live lane joins
the snapshot RPC lane in clients/team: collabws.go serves the hocuspocus
wire (Auth in-band with the SAME HS256 session token + workspace pin +
membership gate as collab.go, SyncStep1 -> replay + empty-diff Step2 +
server Step1, SyncStatus acks, awareness echo keepalive) over zip/wsx,
persisting the Y.js update log per doc on deps.VFS under the tenant-scoped
blob key. The server never parses update payloads: Y.js updates are
commutative + idempotent, so log replay converges; a lone peer's
full-state SyncStep2 (the reply to the server's empty-SV Step1) replaces
the log — compaction without a server-side CRDT. Rooms are in-process
(cloud pins replicas=1, single writer).
2026-07-20 17:07:35 -07:00
antje be2243afc8 team: Slack-model multi-org — orgs claim → workspace union + explicit select + invite
Bumps iam to v1.31.34 and carries the verified `orgs` membership-set claim end
to end, so a user's team workspaces union across every org they belong to.

- cloud.VerifiedIdentity gains Orgs []iam.OrgRef, copied from the verified
  claims (idClaims parses the signed `orgs`); empty on legacy tokens.
- establishSession folds the full membership set into the session token
  (extra.orgs), fallback [{owner, admin}] for a legacy token; extra.user carries
  the IAM id for a mid-session refresh. Still fails closed on empty owner. The
  short workspace token (rides the transactor URL) stays minimal (extra.org only).
- getUserWorkspaces unions WorkspacesOf across every session org, each
  WorkspaceInfo tagged with its owning org for the client switcher.
- selectWorkspace resolves an EXPLICIT (org∈session, slug) — clean BadRequest on
  absent, WorkspaceAmbiguous on a slug in two orgs, never a silent default.
  getWorkspaceInfo resolves the token's workspace claim, killing the wss[0]
  default the same way. Single-workspace fast path unchanged (front selects by
  URL). Cross-tenant isolation preserved (every lookup owner_org-scoped).
- Invite plane (clients/team/invite.go): sendInvite resolves the invitee in IAM
  (get-user), POSTs add-membership as the confidential hanzo-team app
  (client_secret_basic, CapMembershipAdmin), and writes the local member row;
  owner/admin only. getMemberships is the mid-session refresh (live get-memberships,
  session-set fallback). AddMember upserts the roster row idempotently.

Tests: table tests for the orgs round-trip (+legacy fallback), the workspace
union, cross-org select, no-default/ambiguous refusals, getWorkspaceInfo
no-default, guest-cap unaffected, invite writes membership+row (mock IAM) +
admin gate, refresh live/fallback, and VerifiedIdentity.Orgs claim flow.
2026-07-20 16:48:25 -07:00
antje eff0ca6b79 team: collaborator RPC plane + chat notify-context projection
The Team front was losing two core lanes against the native backend:

- Issue/doc rich-text creation dead-ended: the front's collaborator-client
  POSTs createContent/updateContent/getContent to /collaborator/rpc/:documentId,
  which only had the Y.js WS relay behind it — every RPC 404'd, so
  createMarkup threw and tracker issues/documents could not be created.
  collab.go now serves that contract on deps.VFS (same tenant-scoped blob
  keys as files.go): snapshots at makeCollabJsonId ids, membership-gated,
  no-oracle 404s. Ingress path-splits /collaborator/rpc → cloud (universe).

- Channels/DMs vanished from the chat navigator on reload: the nav lists
  notification:class:DocNotifyContext per {user} (upstream server triggers
  materialize them; we never did). seed.go's trigger now projects contexts
  from chunter Channel/DirectMessage membership — create on member add,
  remove on leave, lastUpdateTimestamp bump per message (heals pre-existing
  channels on first message) — and mirrors every write as a derived tx that
  tx() broadcasts so live sessions refresh.

Tests: collab RPC round-trip + tenancy red bars; channel→context projection
(create/touch/leave). chat_test's local clChannel const moved to seed.go.
2026-07-20 16:18:33 -07:00
antje 17924d81b2 build(deps): plans v1.4.3 — team-max/enterprise license the team product 2026-07-20 16:04:57 -07:00
antje 55f168324d team: native login — IAM password RPC, provider_hint federation, platform severities
The hanzo.team login page goes native: the SPA form now authenticates
straight against Hanzo IAM (there are no local accounts) and the social
buttons land directly in the provider OAuth flow.

- account RPC "login": server-side IAM password grant — the SAME two-step
  the platform e2e auth helper locks (POST /v1/iam/login responseType=code,
  then the confidential code exchange) — followed by the EXACT session
  establishment the OAuth callback runs (now ONE shared establishSession:
  userinfo → verified owner claim → workspace ensure → HS256 token). The
  password rides only in the body of the one IAM login call, is never
  logged or persisted, and bad credentials answer a clean 401 with the
  platform status the form already translates.
- /auth/google and /auth/github: same authorize hop as /auth/openid (the
  one registered callback) carrying provider_hint=provider-google/github,
  so hanzo.id auto-federates straight into the provider (console-proven,
  id >= 0.2.6); explicit ?provider_hint= passes through verbatim.
- /providers now surfaces Google + GitHub + Hanzo so the SPA renders the
  three buttons with zero client wire changes.
- Status.Severity is the platform's STRING enum ("ERROR"), not an int the
  SPA compares against nothing — error styling and retry now behave.

Tests: password login mints a verifying session (mock IAM), bad creds 401
with no password in logs or response, provider_hint mapping, providers
surface. TestPersistenceCRUD remains the known pre-existing host red.
2026-07-20 16:04:12 -07:00
antje d0f2a64ca9 build(deps): commerce v1.49.5 — card-on-file self-serve subscribe 2026-07-20 16:03:47 -07:00
antje 6bdb64d13a feat(billing): wire the rolling-window AI-spend cap (admin-configurable)
Installs the ai gate's per-tier rolling AI-spend cap — the Anthropic-style burst
limit that resets continuously (usage older than the window drops out of the
trailing sum; no reset job). Composes three co-resident globals and owns no state:

  aiobject.TierReader()  — the caller's commerce plan tier
  finance.Current()      — the ledger's windowed usage sum (SumUsageSince)
  flags.Int(key)         — the admin-editable per-tier caps

The two knobs (window hours + per-tier cap cents) are platform switches, so
admin.hanzo.ai renders and edits them LIVE via the existing /v1/admin/flags
cockpit — zero bespoke admin UI. Seed defaults mirror @hanzo/plans subscription.json
(developer $0.75 / pro $2.50 / plus $12 / max $25 per 3h window).

- clients/rollingcap: the new subsystem (own package — it imports clients/flags,
  which imports root cloud, so the wiring lives above that edge). Registers the
  switches (init) + installs the reader (Mount, no routes). FAILS OPEN on any
  tier/finance error — a commerce blip must never 429 a paying caller.
- types.FinanceClient: expose SumUsageSince (the trailing-window source) on the
  interface (was only on the concrete ledger); billing/marketing test mocks updated.
- apps.go: mount rollingcap after commerce/plan (its globals are wired by then).
- go.mod: hanzoai/ai v1.827.1 → v1.828.1 (the SetRollingCapReader hook).

Inert until deployed on the unified binary; the hook is nil elsewhere. Tests cover
the decision table (over/under-cap, window-off, unknown/uncapped tier, fail-open on
tier+sum errors) + Mount no-op when globals unwired + seed coverage.
2026-07-20 15:53:17 -07:00
antje 8f398960bd fix(team): entitle gate observes, never blocks — the 402 bricked all logins
No org has subscription rows yet and no self-serve checkout exists, so the
definitive-no 402 on selectWorkspace locked every user out (live 2026-07-20,
front rendered it as NoLoaderForStrings). Log the denial, admit, and bring
enforcement back with the card-on-file subscribe path.
2026-07-20 15:09:40 -07:00
zeekayandClaude Opus 4.8 b5292c5639 build: force GOWORK=off so cloud builds standalone, not via the parent go.work
Root cause of the "broken module graph" (make test / go build ./... failing with
oxy invalid-version, ugorji/koanf ambiguous imports, k8s.io/kubernetes staging
referencing removed API groups): `~/work/hanzo/go.work` auto-shadows this tree but
does NOT list ./cloud. In that workspace mode Go drops cloud's own go.mod
directives — the oxy replace, the ugorji monolith exclude, and the k8s.io/*
staging pins — so the graph that those directives keep consistent falls apart.
cloud is a standalone deploy unit (own go.mod/Dockerfile/binary) and must not join
that workspace (merging its k8s/otel tree with o11y's reintroduces the koanf split
ambiguity; the parent workspace is independently red on koanf).

Fix: the Makefile forces GOWORK=off for all go targets — exactly how CI and the
Dockerfile build (fresh checkout, no parent go.work). No go.mod change was needed;
the existing directives are correct for module mode. A committed go.work was
rejected: it would flip the Dockerfile into workspace mode after its -mod=readonly
`go mod download` step.

Proof (GOWORK=off, == what make now runs): `go build ./...` exit 0, `go vet ./...`
exit 0, `go mod tidy` stable (no go.mod change). `make build` exit 0. `go test
./...` runs (was fully blocked before): 128 ok / 103 no-test / 10 fail, every
failure runtime not module-graph — encrypted-OrgDB tests that need CGO+libsqlcipher
(the Dockerfile's -tags libsqlite3 stage), a bundle-embed test needing make
deploy-ui, and pre-existing behavior tests (metering/zt/o11y). See LLM.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 14:59:19 -07:00
antje 134ff852ef build(deps): commerce v1.49.4 — per-seat billing release 2026-07-20 13:55:46 -07:00
antje fbe07f3e7c team: usage/wallet page — @hanzo/ui@8 static embed at /v1/team/billing/ui/ + org-scoped plan read
- clients/team/wallet: small Vite/React page on @hanzo/ui@8 (balance
  three-bucket split, current-period usage, plan + seats, top-up link to
  billing.hanzo.ai), mobile-first monochrome; committed dist go:embed'd
  (the console/tasks one-binary precedent).
- clients/team/billing.go: session-gated serve of the embed + GET
  /v1/team/billing/plan (seats/guests from the org's member rows, plan +
  team.guests cap through the same commerce/plans seams entitle uses);
  orgPrincipal is the ONE token→tenant resolution (files plane rebased on it).
- money reads stay on cloud's own /v1/billing/balance + /v1/usage/summary:
  the hanzo_iam_token cookie the team callback sets is now a validated
  principal (aud hanzo-team appended to defaultJWTAudiences, forwards-only),
  so the org is pinned server-side from the verified claim — no second
  auth mechanism.
- tests: billing 401 unauth (through real Mount), embedded shell + bundle
  served authed, plan org-scoped across two tenants, audience pin.
2026-07-20 13:48:15 -07:00
zeekayandClaude Opus 4.8 222f91b898 refactor(routes): group clients/team under app.Group("/v1/team")
Finishes the one convertible subsystem the group sweep skipped: team's routes
were spread across 5 register funcs whose receiver was named `g` (colliding
with the group var). Resolved by passing the group as a zip.Router param
(register(r zip.Router, ...)) instead of *zip.App — one `tg := app.Group(
"/v1/team")` in Mount, threaded to acct/bridge/files.register + the two inline
transactor routes, all rewritten to relative paths. (Note *zip.App does NOT
satisfy zip.Router — App.Fiber() returns *fiber.App vs the interface's
fiber.Router — so the two test harnesses now pass app.Group("/v1/team") too.)

Route table preserved (13 team routes byte-identical); team test suite green
(exercises the real /v1/team/* paths end-to-end); combined build +
TestWireOrderMatchesFrozen pass. deploy stays flat by design — it already DRYs
via const dashPrefix and its loginPath/callbackPath vars are reused for
redirects (scope.go), so grouping would risk redirect paths for no real gain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 13:12:48 -07:00
antje f5a7ccdfc7 refactor(cloud): drop svc from stale comment prose (finish the suffix cleanup)
Follow-up to 256b848e: the doc comments still named packages by their old
svc-suffix (iamsvc.Mount, pricingsvc, provisioningsvc, mlsvc, evalsvc, plansvc,
productsvc, syncsvc, gatewaysvc) — stale references to symbols that are now bare.
Corrected to the real names across config/middleware_identity/eval/gateway/git/
ml/pricing/projects. Comment-only; no code change.

Deliberately kept: cloud-mlsvc at clients/ml/ml.go (a real ClusterRoleBinding
name in ml-rbac.yaml, correctly referenced) and the "zapsvc" test-fixture repo
strings (test data, not an identifier).

Also gofmt'd clients/pricing/admin_http_test.go (pre-existing import-order drift).
2026-07-20 13:01:31 -07:00
antje fdb7a31dd0 harden(team): bounded tokens, verified tenant, WS origin gate, OAuth state, billing gate
- token: every session token carries exp (30d; workspace 12h); Decode enforces
  exp/nbf with 60s skew; pre-rollout no-exp tokens honored until a fixed
  legacy cutoff (constant, no env). The "secret" fallback literal is GONE —
  empty secret is a hard ErrNoSecret and the TEAM_DEV_INSECURE hatch is dead.
- account: OAuth state is a random nonce bound to a short-lived cookie
  (navigateUrl rides in the cookie), verified one-shot on callback; the tenant
  comes ONLY from the RS256/JWKS-verified IAM token owner (cloud.NewTokenValidator)
  — fail closed, no default org.
- transactor: WS upgrade enforces an Origin allow-list (same host, team
  surfaces, *.hanzo.ai, absent Origin for non-browser); serves the front's
  /api/v1/statistics poll target (own-workspace sessions only).
- entitle: selectWorkspace requires the org's 'team' license — definitive no
  → 402 + upgradeUrl billing.hanzo.ai; guest role capped by the plan's
  team.guests entitlement (join order); infra errors ALWAYS admit so the gate
  can never brick login mid-rollout.
2026-07-20 12:57:56 -07:00
antje 256b848ece refactor(cloud): drop the svc suffix — bare package names + spelled-out test helpers
One name per thing, no compound-word cruft. The `svc` suffix was never a real
package (zero `package *svc`) — only import aliases and abbreviated test helpers.

- Import aliases → bare package names: plansvc→plan (commerceclient),
  captablesvc→captable + dataroomsvc→dataroom (company/adapters). No stutter, no
  alias where the bare name is unambiguous.
- Test helpers spelled out: fakeSvc→fakeService, testSvc→testService,
  newSvc→newService — across admin/agents/deploy/domain/functions/ingress/
  integrations/ml/platform/provisioning/storage/wallets tests, callers updated
  in-package.
- Stale `// Package …svc` doc-comment prose corrected to the real package name
  (exec/iam/plugin/pricing/product/provisioning/sync/tasks).

Naming only — no logic change. go build + test-compile green on all 21 packages.

Note: the clients/team package (filesSvc/fsvc rename) is excluded here — it has
concurrent in-progress work; its svc cleanup lands with that change.
2026-07-20 12:56:12 -07:00
antje 643926bc76 plans v1.4.1: hanzo.team commercial model — $20/$100/$200 ladder + $25/user team
Bump github.com/hanzoai/plans v1.4.0 -> v1.4.1 (catalog: pro repriced $20
on hanzo_pro_20, new plus $100, max $200, team $25/user per-seat minSeats 2,
team.guests entitlement, team namespace).

Pin the contract in clients/plan tests: TestPlans_Ladder freezes the
subscription ladder prices + stripe lookup keys + team per-seat/minSeats;
TestLicenseEntitlement_TeamProduct freezes the hanzo.team entitlement gate —
licensing.product:team emitted for pro, plus, max AND team, engine on max,
never on developer. Vocab namespaces 9 -> 10 (team).

Smoke-booted: /v1/plans/subscriptions serves the new ladder,
/v1/plans/entitlements/team carries licensing.product:team.
2026-07-20 10:51:13 -07:00
hanzo-dev 5019d7a384 feat(base): host-as-project-ref — serve /v1/base + /v1/realtime + /_/ on the app host
A published site host now serves its org own Base data plane (HIP-0014). The
sites middleware, on a /v1/base|/v1/realtime|/_/ path, calls an injected per-org
Base handler with the org the SUBDOMAIN resolves to (Site.Org) — never the
caller — so an anon page reaches its own Base, authz by Base collection rules.
One seam (sites.SetBaseHostHandler, mirroring SetResolver; no import cycle),
gated by CLOUD_BASE_PUBLIC_HOST (default OFF): absent the flag a site host serves
only static files, unchanged. This is what makes maxpower.hanzo.app/_/ (admin) +
the public contact form + anon realtime chat work — the token supersedes the
key/host for signed-in users (org from IAM), keys/host are the tokenless path.
2026-07-20 09:00:38 -07:00
hanzo-dev 39b6b12823 wip(sites): MEDIUM-1 — coalesce+ceiling the shared Cloudflare purge (INCOMPLETE)
Partial fix for red MEDIUM-1 (unbounded shared purge = cross-tenant blast
radius). Per-tag coalescing + process-wide per-minute ceiling in the Purger.
NOT finished: MEDIUM-2 (release retention GC), LOW-1 (reject rel=="."),
LOW-2 (empty dest ETag = fail). Do not merge until complete + red re-review.
2026-07-19 23:22:19 -07:00
antje efbdab87f6 sites: bare <slug>.hanzo.app is the ONE servable host — publish binds + advertises it
The org-scoped two-label design (<slug>.<org>.hanzo.app) was never servable: a
k8s wildcard Ingress host and a Let's Encrypt wildcard cert each match exactly
ONE label, so the two-label host neither routes nor gets TLS. Publish still
stamped it as liveUrl and bound it, so every 'Visit' link and the console/app
cards pointed at a dead host, and the sites edge served nothing.

One host, one way:
- siteURL → https://<slug>.<apex> (bare); siteHost → bare <slug> (the global
  first-come binding key — matches TestSiteHostBindingIsFirstComeAndTenantSafe,
  which already asserted bare-host first-come). A second org publishing the same
  slug is refused the subdomain and serves at its S3 URL only.
- siteSlug parses ONLY the bare host; a dotted key falls through to the API
  pipeline. unique-live-slug resolve (added earlier) keeps pre-binding publishes
  servable with no backfill.
- tests updated to the bare-host contract throughout.
2026-07-19 22:08:55 -07:00
hanzo-dev ebf96d9851 feat(sites): publish by server-side promote into immutable releases
Static sites had no way to put content at a site's prefix through the API.
Add one: a release plane on the existing site engine.

A release is an immutable prefix whose id is a digest of the object manifest
it was promoted from; the site record holds a pointer to the release it
serves, and siteResolver resolves through it. Publishing copies server-side
within the object store, so no bytes traverse the API and no client holds an
S3 credential. Rollback is the same pointer flip aimed at an older release.

Isolation: the source is a path relative to the caller's own org space. The
org segment comes from the validated principal (the one org rule this package
already uses for site prefixes) and the bucket never comes from the request,
so a caller has no syntax for naming another tenant's data. safeRel roots and
cleans both the source and every object key.

Atomicity: the release row is written only after every object lands, and
activation is one statement whose WHERE requires that row in the same tenant,
so a partially-copied release cannot be pointed at. ActivateRelease is the
sole writer of the pointer on the activate path.

Releases live in <org>/.releases/<slug>/<id>/, a sibling of the mutable
serving prefix, so a full-artifact deploy reclaims the pointer without
destroying retained releases. Caps reuse the artifact budget.

POST   /v1/sites/:slug/publish
POST   /v1/sites/:slug/releases
GET    /v1/sites/:slug/releases
POST   /v1/sites/:slug/releases/:release/activate

Mirrored under /v1/platform/sites. Inert for existing sites: an empty pointer
serves the legacy prefix.
2026-07-19 21:48:52 -07:00
zeekayandClaude Opus 4.8 78a8197e49 refactor(routes): group single-prefix subsystems under app.Group("/v1/<x>")
45 subsystems converted from flat full-path registration to the idiomatic zip
app.Group("/v1/<prefix>") + relative-path pattern — DRY the prefix, one and
only one way. Route-preserving: proved Group(p).<M>("/rel") == flat
app.<M>("p/rel") byte-for-byte; bare-prefix root routes kept FLAT (Group(p).
Get("") would add a trailing slash). Multi-prefix / dynamic-path / cross-
function-collision subsystems deliberately left flat.

Every converted subsystem gated on route-table preservation + go build + go vet;
combined ./clients/... + ./apps/... compiles clean; TestWireOrderMatchesFrozen
passes (Wire()/composition root untouched — grouping is inside each Mount).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:13:40 -07:00
antje 83519a957e merge: shared admin core/warehouse ClickHouse helpers (data-platform-warehouse-helpers)
feat/data-platform-warehouse-helpers: clients/admin/core/warehouse.go + invoices/
metrics/subscriptions refactored onto it (aimetrics portion already landed via
#10). Conflict resolved keeping main's CreateCreditGrant, dropping the SaaS-metrics
helpers moved to warehouse.go. Tests: clients/admin (all subpkgs) ok, apps ok.
2026-07-19 20:45:00 -07:00
hanzo-devandantje 7b19236966 admin(data-platform): shared core/warehouse ClickHouse helpers + invoices/metrics/subscriptions using them + test coverage + aimetrics 2026-07-19 20:43:08 -07:00
antje 2e3dc7d818 merge: fleet gpu-connect CPU arch/cores/RAM reporting (fleet-byo-cpu-spec)
feat/fleet-byo-cpu-spec: gpu-connect nodes report CPU arch + cores + RAM on
/v1/fleet (cli/gpu.go, clients/visor board+fleet). Redundant #331 flags-test
tweak dropped in favor of main's. Tests: visor ok, cli ok, apps ok (CGO-off);
flags green under CGO-on (its one CGO-off failure is pre-existing, matches main).
2026-07-19 20:40:58 -07:00
hanzo-devandantje 906f89a2b6 feat(fleet): gpu-connect nodes report CPU arch + cores + RAM on /v1/fleet
BYO nodes that dial in via `hanzo gpu connect` reported only their GPUs, so the
/v1/fleet board (and the world Fleet panel) showed NO CPU arch or system memory
for them — unlike code-linked run-targets, whose Spec already carries
arch/cpus/memory. evo-2 (Strix Halo, x86_64) and spark (GB10, aarch64), both
128 GB, appear on the board as BOTH a run-target AND a gpu-connect worker; the
BYO rows surfaced blank arch/memory.

Add the host's static CPU spec to the fleet presence record, read from the real
machine (never hardcoded), in the SAME convention the fleet already uses for
code-linked nodes so a machine shows ONE arch string across both rows:
  - reporter (cli/gpu.go): registration gains arch, cpus, memory. detectArch is
    `uname -m` (aarch64 | x86_64 | arm64) to match the existing fleet convention
    (NOT runtime.GOARCH's arm64/amd64). detectMemTotal reads /proc/meminfo
    MemTotal on Linux (evo-2, spark) / sysctl hw.memsize on Darwin; cpus =
    runtime.NumCPU. 0/"" when unknown, never faked.
  - decoder (clients/visor/fleet.go): fleetRegistration + byoWorker mirror the
    three fields (lockstep with the CLI) and byoWorkers populates them.
  - board (clients/visor/board.go): workerUnits -> byoUnit fills
    fleetSpec.Arch/CPUs/Memory (the fields agentUnits already sets), so a
    gpu-connect node and a code-linked node describe themselves identically.

Verified on a real GB10 (spark-class): detectArch=aarch64 (uname -m), nproc 20,
/proc/meminfo 127600528 kB -> 130662940672 bytes — byte-identical to how the SAME
box already reports as a code-linked run-target (arch=aarch64 cpus=20
memory=130662940672).

Tests: parseMemTotalKB, detectMemTotal (real host >0), detectArch (uname -m
convention, not GOARCH), buildRegistration host spec; fleetRegistration decode +
byoUnit projection + unknown-spec omitted.
2026-07-19 20:37:43 -07:00
antje 84cea2889a merge: admin AI-metrics read view (aimetrics-router-verify)
feat/aimetrics-router-verify: clients/admin/aimetrics.go — /v1/admin/aimetrics
read view. Tests: clients/admin (+subpkgs) ok, apps ok.
2026-07-19 20:37:05 -07:00
hanzo-devandantje fa6cc69a65 admin(aimetrics): AI-metrics read view (clients/admin/aimetrics) + test 2026-07-19 20:36:47 -07:00
antje 0063793f12 merge: thin audited admin credit-grant relay (admin-credit-grant)
feat/admin-credit-grant: clients/admin/creditgrant.go — audited relay to the
commerce credit-grant. Tests: clients/admin (+subpkgs) ok, apps ok.
2026-07-19 20:35:35 -07:00
hanzo-devandantje 198a025cf9 feat(admin): thin audited credit-grant relay at POST /v1/admin/credit-grants
The one admin mint surface. SuperAdmin-only (core.Guard); forwards verbatim to
commerce's already-mint-gated POST /v1/billing/credit-grants (middleware.Mint →
PlatformOnly) via COMMERCE_SERVICE_TOKEN, scoped to the target org, and writes one
tamper-evident audit record. Commerce stays the sole credit-grant ledger — no
in-process mint. NOT deployed; for red review (mint surface).

Assisted-by: neo:claude-opus-4-8
2026-07-19 20:35:15 -07:00
antje 7b204ee0de merge: unified inbound channel ingest plane (channels)
feat/channels: clients/channels/{slack,teams,telegram,store}.go + integrations
ingress + cmd/channels — envelope, pairing, policy, per-platform adapters.
Tests: clients/channels ok, clients/integrations ok, apps ok.
2026-07-19 20:34:57 -07:00
hanzo-devandantje 81515ef108 feat(channels): unified inbound channel ingest plane (envelope, pairing, policy, per-platform adapters)
Preserve divergent channel-ingest work: clients/channels package (envelope
normalization, pairing, delivery policy, Slack/Discord/Teams/Telegram
adapters, store), cmd/channels entrypoint, integration event-emit hooks,
and apps wiring.
2026-07-19 20:30:04 -07:00
antje 320af40b0b sites: serve bare <slug>.hanzo.app again — unique-live-slug resolve
The org-scoped host redesign (<slug>.<org>.hanzo.app) left the edge unservable:
a k8s Ingress host and a Let's Encrypt wildcard each match exactly ONE label, so
the two-label shape never routes nor gets TLS, while the one-label product URL
every surface advertises (palette, share copy, publish toast) was rejected by
siteSlug and fell through to the console pipeline. Net: no published site
resolved at all.

Fix, preserving the org-scoped design:
- siteSlug accepts a bare non-reserved <slug>.<apex> label again (org-scoped
  two-label parsing unchanged, ready for per-org certs later)
- siteResolver falls back for bare keys: explicit site_hosts binding first,
  else ResolveUniqueLiveSlug — serve iff EXACTLY ONE live project owns the
  slug across orgs; ambiguous or draft ⇒ honest 404. Deterministic,
  hijack-safe (reserved labels rejected at the host boundary), and
  migration-free for publishes that predate host binding.
- tests: bare-host parse cases + unique/ambiguous/draft resolve proofs
2026-07-19 20:01:41 -07:00
antje 277ea80a4f chore(gitignore): ignore local .worktrees/ container
The .worktrees/ directory holds local git worktrees (dev infra), never part of
the tree — mirrors the existing .claude/ rule so a working checkout stays clean.
2026-07-19 19:54:27 -07:00
antje cd42e5f902 merge: kmsreseal dual-face auth + owner-claim assertion
feat/kms-reseal-migration: split reseal tokenFunc into src/dst faces (CR app-name
credential vs per-org <org>-platform-kms), assert minted-token owner==target org
(refuse admin), flag empty source folders as seeding-wedge risk. Tests: apps ok;
cmd/kmsreseal ok (full suite green under CGO; new auth/owner tests green under CGO-off).
2026-07-19 19:52:29 -07:00
hanzo-devandantje c0672a1fa6 feat(kmsreseal): dual-face auth + owner-claim assertion for the reseal migration
The reseal migration reads from the standalone KMS and writes into cloud KMS —
two faces with DIFFERENT identities. Split the single tokenFunc into srcAuth/dstAuth:
src uses the CR app-name credentialsRef (the standalone accepts it); dst uses the
per-org <org>-platform-kms credential (cloud accepts it dynamically, admin-denied,
no static audience widening).

Defense-in-depth (LOW-1): decodeJWTOwner reads the minted token owner/isAdmin claims
locally and asserts owner == target org (refusing admin tokens) before any read/write,
so a misscoped credential fails its target instead of acting on the wrong org. An
empty source folder is flagged as a seeding-wedge risk instead of silently skipped.

Tests (auth_test.go): decodeJWTOwner, owner-mismatch + admin-refusal gates,
dual-face token brokering.
2026-07-19 19:50:16 -07:00
antje 675d17f29c merge: CD per-app detail endpoints (syncwindows, revision metadata, resource-tree SSE), tenant-scoped
feat/cd-detail-endpoints: serve the three per-app endpoints the ArgoCD SPA
detail view calls, scoped by the same resolveScope/findNamespace path as
dashApp. Tests green: clients/deploy, apps.
2026-07-19 19:48:39 -07:00
hanzo-devandantje 46f42f43e6 deploy: serve the three per-app CD detail endpoints, tenant-scoped
The ArgoCD SPA's application-detail view calls three per-app endpoints the
projection did not serve, spamming "404 page not found" toasts. Add them,
scoped by the same resolveScope/findNamespace path as dashApp — a SuperAdmin
sees the whole fleet, a validated org member sees only its own apps, a
cross-tenant name is a clean 404 (no oracle), an unvalidated caller fails
closed:

- GET /applications/:name/syncwindows -> the permissive-empty
  ApplicationSyncWindowState (no sync windows run; canSync true).
- GET /applications/:name/revisions/:revision/metadata -> honest minimal
  RevisionMetadata (message = the revision, HEAD resolves to the declared
  image tag; date = the CR creation time; author empty). Image-based deploys
  carry no git commit and the manifest repo is not the app's source, so no
  author is fabricated and it never 404s.
- GET /stream/applications/:name/resource-tree -> the live ApplicationTree
  as SSE (data: {"result": tree}), the scope gate before any emission,
  emitted once then refreshed on the keep-alive interval, honoring ctx cancel.
2026-07-19 19:48:16 -07:00
antje 214b5d2925 merge: Hanzo Domains registrar (name.com) + session store + routed-dispatch reach 2026-07-19 18:31:14 -07:00
hanzo-devandantje 42f2ed8f52 feat(cloud): domain registrar (name.com) + session store — routed-dispatch reach + CD promote job
clients/domain: registrar layer — name.com client, pricing, register,
per-org store, /v1 mount. clients/session: session store backing routed
runs. Agents: mailbox + routing reach the dispatch targets; release.yml
gains the declared-tag promote job (universe CR bump, Hanzo CD syncs).
2026-07-19 18:31:12 -07:00
hanzo-devandantje 70f8d29447 coding: verify + PR + close the session when a routed run completes
A routed run's machine pushes with its own credential and streams into the
session, but cloud still owns the completion — the integrity gate, the PR row, and
the session's terminal state (the machine never closes the session, so it was
staying "running" forever). Give a routed run the SAME cloud-side completion the
local keystone path runs after a sandbox push.

- completeChanged: the shared terminal for a run that reported changes — VerifyRef
  the pushed branch LANDED (fail-closed to a session error + no PR if absent), file
  the native PR, mirror done, close the session done. The local path (Run) now calls
  it too, so the two paths cannot drift.
- finalizeRouted: maps a machine's terminal report onto that completion — reported
  failure closes the session error (no PR), no-changes closes done (no PR), a changed
  push runs completeChanged. No secret crosses; cloud only reads the ref it can see.
- DeliverRoutedRunActivity runs the completion once, after a real report, on a
  cancel-immune bounded context, so a completed run is never re-executed by a retry.
  The completion seam is injected at the composition root (NewDispatcher), the same
  injected-seam shape index_on_push uses, so the free-function activity reaches the
  dispatcher's git/tracker/session seams without a global Dispatcher.
- RoutedRun carries Actor + AgentRef (cloud-side only, never sent to the machine) so
  the completion attributes the session close and files the PR with the right
  assignee.

Also document the mailbox's single-replica dependency at its definition (accepted,
inherited from cloud's KMS-lock replicas:1) with a future replica-aware note.

Tests: routed changed+verify -> PR filed + session done; verify fails -> no PR +
session error; no changes -> done no PR; reported error -> error no PR (verify never
runs); NewDispatcher wires the seam; the durable type bridge preserves attribution.
2026-07-19 18:17:08 -07:00
hanzo-dev 01378dea23 chore(deps): bump hanzoai/ai v1.827.0 -> v1.827.1 (NULL-safe OrgSettings scan) 2026-07-19 13:19:34 -07:00
hanzo-dev 694bc4f716 deploy: debrand the projection instance label argocd.argoproj.io -> hanzo.ai
The CD projection synthesized an argocd.argoproj.io/instance label on every app
(visible on every card). It is Hanzo-native CD, not ArgoCD — the App CRs carry
hanzo.ai/* labels. Emit hanzo.ai/instance instead; env + org labels unchanged.
(The argoproj.io/v1alpha1 response SHAPE stays until the @hanzo/gui FE that reads
@hanzo/ui/cd native types replaces the ArgoCD SPA.)
2026-07-19 12:44:19 -07:00
hanzo-dev 184945862f deploy: tenant-scope the CD projection to IAM orgs and projects
Resolve each /v1/deploy read request's scope from the validated identity —
the same boundary clients/platform.tenant uses (validated principal +
injective provisioning.SanitizeOrg + the c.IsAdmin SuperAdmin predicate).
A SuperAdmin sees the whole fleet; a validated org member sees only its own
org's apps (hanzo.ai/org label, tenant-<org> namespace); anyone else is
refused. Scoped reads: applications list/detail/resource-tree, clusters,
projects, and the SSE stream. sync/rollback + the argocd bootstrap stay
SuperAdmin-only.

projectApp reads app.kubernetes.io/part-of into spec.project (default when
absent) and surfaces hanzo.ai/org. The projects endpoint reflects the
IAM-owned (org,name) Project resource in-process — org-scoped for a normal
org, all orgs for a SuperAdmin — with a synthesized default so every app's
spec.project resolves. IAM stays the single source; no CD-side project row.
2026-07-19 12:44:19 -07:00
hanzo-dev b3e058490c chore(deps): bump hanzoai/ai v1.826.7 -> v1.827.0
Integrates the last two router branches now on ai main:
- per-org RoutingPolicy on the hot path (decomplected per-org routing)
- context_window surfaced in /v1/models

(do-ai premium routes + judge/MFJP + mean-field + RouterCostCeiling slider
+ the judge-panel deadlock fix already shipped via v1.826.7, already live.)
clients/... compiles clean against v1.827.0.
2026-07-19 12:32:04 -07:00
hanzo-dev 7c301185ee deploy(stream): guard typed-nil watch object + recover on watch goroutines
A malformed watch event carrying a typed-nil *unstructured.Unstructured would
nil-deref on GetName() in forwardWatch. The read plane installs no panic
recovery around detached goroutines, so that crash would take down the whole
process. Guard the typed-nil, and recover at the spawn site so no future
malformed event can crash the plane. Adds a regression test.

Red review: SHIP (this closes the sole LOW finding).
2026-07-19 10:15:48 -07:00
hanzo-dev 40ca519c51 feat(deploy): project /clusters, /projects, /stream/applications for the CD dashboard
The ArgoCD-UI-compatible surface returned nothing at three endpoints the
applications view calls, so the SPA error-toasted on load:

  GET /v1/deploy/clusters            -> 404
  GET /v1/deploy/projects            -> 404
  GET /v1/deploy/stream/applications -> 404

Add all three as read-only projections over the SAME App-CR source dashAppList
reads (listAppCRs + runningVersions + projectApp: one source, one projection),
SuperAdmin-gated by guard(), safe on cloud-reader (no writer/commerce imports):

- /clusters -> ClusterList of the destinations the fleet reconciles into,
  deduped, always including the in-cluster destination, with a per-cluster
  application count. argoCluster has no config field, so a cluster credential
  cannot be surfaced by construction.
- /projects -> AppProjectList: prefers real argoproj.io/v1alpha1 AppProject CRs
  when that CRD is served (reshaped to only the intended spec fields), otherwise
  synthesizes one permissive project per distinct App-CR project name (default
  always present).
- /stream/applications -> the applications watch as SSE: one ADDED event per
  current App CR, then live ADDED/MODIFIED/DELETED from a per-namespace watch,
  held open with keep-alives. Every watch + goroutine is bound to the request and
  torn down on disconnect; degrades to keep-alive only if the watch verb is not
  granted; fails closed (503) with no cluster client.

Tests (go test -race green): cluster dedupe + always-in-cluster + never-emits-
credentials; project distinct/default + synth-permissive + real-CR-only-intended-
fields; stream ADDED-per-app + zero-app-no-panic + honors-ctx-cancel + SSE-headers;
all three routes 403 without SuperAdmin.
2026-07-19 10:15:48 -07:00
zandGitHub 0f86fd4a5b fix(commerce): stop the in-process self-dispatch recursion that crash-loops the writer (#341)
scopeRateLimiter reads its own rules via a co-resident commerce self-dispatch (GET /v1/billing/spend-alerts) on every authed request; the rule cache fills only after the fetch returns, so the self-dispatch re-enters scopeRateLimiter with a cold cache → unbounded in-process recursion → writer stack-overflow (single request) / OOM (concurrent). Dump-attributed (goroutine 4423, 21,823 setRequestCancel) and real-binary A/B verified on current main+fix (GET returns, POST 402s, 40-concurrent peaks 228 goroutines, 0 pileup). Exempt the commerce config surface from its own gate + an on-path depth backstop.
2026-07-19 09:56:11 -07:00
zeekayandClaude Fable 5 5ac8e7a1a5 harden(team): Chunter responder OFF by default + bounded/lazy (anti-storm)
Post-mortem containment for the v1.801.104 writer crash. To be unambiguous on
root cause: the fatal was the commerce co-resident dispatch reentrancy
(stack: apps.mountCommerce.IAMTokenRequired.func5 → commerce@v1.49.3
iammiddleware.go:157 → unbounded net/http.setRequestCancel goroutines), the bug
PR #341 fixes, introduced by 169beab (enso per-tier gate) in the .99→.104 range —
NOT this responder. The responder makes ZERO outbound calls at boot (it fires only
from session.tx, the live client-WS write path; never from reconcile/replay). Any
build of current main still crashes until #341 lands, independent of this change.

That said, an unbounded per-message responder IS a foot-gun, so this makes it
safe-by-default and bounded regardless:

- OFF by default: Mount wires the LLM seam ONLY when TEAM_AGENTS_ENABLED=1. A nil
  runAgent makes maybeAgentReply return at the top → NO outbound model call can
  fire. An un/mis-configured binary is provably inert.
- Fresh-only: a message created before this process booted (>60s grace) is a
  replay/backfill and is NEVER answered — kills the "replayed backlog fans out into
  thousands of HTTP calls" failure mode.
- Single-flight per (workspace, space, bot): a burst to one conversation collapses
  to one turn; duplicates dropped, not queued.
- Hard concurrency cap: a global semaphore (TEAM_AGENTS_MAX_CONCURRENCY, default 4,
  clamped 1..64) bounds in-flight turns; over the cap, DROP.
- Circuit breaker per agent: after 3 consecutive failures skip the agent for 60s —
  the backoff that turns a publishable-key 403 storm into a quiet trickle. No
  retries, ever.

TDD (all -race green): TestNoReplyToBacklogAtBoot boots against a 500-message
backlog and asserts ZERO runner calls (then one fresh post IS answered);
TestConcurrencyCapBounded (cap=2, 8 msgs → exactly 2 in-flight, rest dropped);
TestSingleFlightPerConversation (5 msgs, 1 conversation → 1 turn);
TestCircuitBreakerBacksOff (persistent failure → runner called exactly threshold
times, circuit opens). Existing responder + roster tests unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 09:47:44 -07:00
hanzo-dev 6625b5d41b feat(deploy): project /clusters, /projects, /stream/applications for the CD dashboard
The ArgoCD-UI-compatible surface returned nothing at three endpoints the
applications view calls, so the SPA error-toasted on load:

  GET /v1/deploy/clusters            -> 404
  GET /v1/deploy/projects            -> 404
  GET /v1/deploy/stream/applications -> 404

Add all three as read-only projections over the SAME App-CR source dashAppList
reads (listAppCRs + runningVersions + projectApp: one source, one projection),
SuperAdmin-gated by guard(), safe on cloud-reader (no writer/commerce imports):

- /clusters -> ClusterList of the destinations the fleet reconciles into,
  deduped, always including the in-cluster destination, with a per-cluster
  application count. argoCluster has no config field, so a cluster credential
  cannot be surfaced by construction.
- /projects -> AppProjectList: prefers real argoproj.io/v1alpha1 AppProject CRs
  when that CRD is served (reshaped to only the intended spec fields), otherwise
  synthesizes one permissive project per distinct App-CR project name (default
  always present).
- /stream/applications -> the applications watch as SSE: one ADDED event per
  current App CR, then live ADDED/MODIFIED/DELETED from a per-namespace watch,
  held open with keep-alives. Every watch + goroutine is bound to the request and
  torn down on disconnect; degrades to keep-alive only if the watch verb is not
  granted; fails closed (503) with no cluster client.

Tests (go test -race green): cluster dedupe + always-in-cluster + never-emits-
credentials; project distinct/default + synth-permissive + real-CR-only-intended-
fields; stream ADDED-per-app + zero-app-no-panic + honors-ctx-cancel + SSE-headers;
all three routes 403 without SuperAdmin.
2026-07-19 09:25:40 -07:00
zeekayandClaude Fable 5 3baee40745 feat(team): Chunter agent responder — org agents become talkable in chat
Bots-as-members (bots.go/roster reconcile) already projects each org agent as a
workspace Employee, but a message to a bot did nothing — the AI was present and
mute. This adds the WRITE/response half: when a human posts a Chunter ChatMessage
addressed to an active bot member — a DirectMessage whose participants include the
bot, or a channel message that @-mentions it — the transactor runs that agent
through agents.RunOnBehalf (the ONE billed/metered/recorded in-process run path)
and posts the model's answer back into the SAME conversation as that bot, via the
SAME applyTx + hub.broadcast write the SPA and roster projection use.

- chat.go: parseChatMessage, replyTargets (DM-member OR @mention addressing),
  maybeAgentReply (cheap gate → agents list → per-bot async turn), replyAsBot
  (recovered + 90s-bounded goroutine; never blocks the WS loop). plainText/
  htmlMarkup bridge stored markup ↔ LLM text. Loop guard: a bot-authored message
  never triggers a reply.
- transactor.go: transServer gains runAgent (the LLM seam) + log; session.tx fires
  maybeAgentReply on the client write path only (roster/sync call applyTx directly,
  so a projection can never trigger a reply).
- bots.go: agentReplyRunner adapts agents.RunOnBehalf (error-status run → post
  nothing, never an empty bubble).
- team.go Mount wires runAgent=agentReplyRunner. Responder is off (nil runner) when
  unwired, so the path is fully additive.

TDD: 16 tests — parse/ignore, plainText/htmlMarkup, DM + mention addressing, the
full DM reply loop with a fake runner (asserts on-behalf-of user, agent id, plain
prompt, reply authored by the bot in the same conversation), loop-guard, and
disabled-when-no-runner. All clients/team tests green (CGO_ENABLED=0), go vet clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 09:06:50 -07:00
hanzo-dev 2cb2e8e286 chore(deps): bump ai v1.826.6 -> v1.826.7 (judge self-call pileup fix; unblocks commerce v1.49.3) 2026-07-19 07:16:48 -07:00
hanzo-dev 6dc2b3a355 feat(deploy): OAuth sign-in for the CD dashboard (PKCE, verify-before-mint)
cd.hanzo.ai rendered but every API call 403'd with no way to sign in: the IAM
session cookie is host-scoped to hanzo.id, so no other host's session can ever
authorize cd, and clients/deploy had no login route.

Adds GET /v1/deploy/login + /callback + POST /logout against the admin-console
app (org=admin; hanzo-cloud's org is hanzo, which is why admin users were never
found). Public PKCE client, no secret. The callback verifies the exchanged token
through cloud's own JWKS validator and decides SuperAdmin on VERIFIED claims
before minting; the unverified decode is gone. session/userinfo becomes a public
bootstrap so the SPA can discover it is signed out. guard() is unchanged.

DEPLOY_PUBLIC_URL is now REQUIRED (login/callback 503 without it).

blue built, red cleared: gate stronger than before, zero regressions.
2026-07-19 04:06:40 -07:00
hanzo-dev 6134408bad deploy: verify the token before minting the session; make sign-in reachable
Red review of the sign-in round trip found no security defect, and four ways it
would fail in practice. All four are the same shape: correct in the happy path,
unhelpful or unreachable in the real one.

MINT ONLY WHAT THIS DEPLOYMENT WILL ACCEPT. The callback now runs the exchanged
token through cloud's OWN validator before writing the cookie, and decides the
admin-org question on VERIFIED claims. The audience allowlist is env-overridable
and jwtAudiencesFromEnv REPLACES the baked default, so a deployment whose
CLOUD_JWT_AUDIENCES / GATEWAY_ALLOWED_AUDIENCES omits this console's client_id
minted a cookie the boundary refused on the next request — 403, document-bounce
to sign-in, IAM session still live, instant code, mint, 403, forever. It now
fails once, with the reason and the knob to turn. The unverified claim decode is
gone with it.

That validator is exported from cloud (NewTokenValidator) rather than rebuilt
here: SanitizeIdentity validates on the way in, a subsystem minting a session
needs the same verdict a moment earlier, and two copies of it could drift into
exactly the mint-then-refuse loop above. jwksURLFor is now the one derivation
both share.

SIGN-IN HAS TO BE REACHABLE FROM WHERE THE USER IS. The dashboard is an XHR
client, so guard()'s document bounce never fires for it — it got a 403 and dead
-ended with no route to sign-in. /v1/deploy/session/userinfo is now the one
public bootstrap route: {loggedIn:false} plus the sign-in URL for an anonymous
caller, the real identity for a SuperAdmin. It discloses no identity, no cluster
state, no configuration, and gates nothing; every route that returns fleet data
or mutates a CR stays guarded.

The OAuth origin is now configuration, not the Host header. Deriving a redirect
from caller-controlled input is only ever saved by the registry's exact-match
check — a second lock covering for a broken first one. With no DEPLOY_PUBLIC_URL
sign-in fails closed naming the knob.

Logout is POST: as a GET any site could sign a SuperAdmin out by navigation,
which a SameSite=Lax cookie still rides. The session lifetime comes from the
verified expiry, clamped — an already-expired token no longer becomes an
eight-hour cookie, and a bogus far-future exp no longer becomes a decade-long
one. Both cookies take the __Host- prefix, which the browser only honours for
Secure, Path=/, Domain-less cookies, so a sibling *.hanzo.ai host cannot shadow
them.
2026-07-19 03:45:48 -07:00
hanzo-dev 36fc7c3b74 deploy: sign in to the cd console with IAM, so its SuperAdmin gate is reachable
Every /v1/deploy route gates on c.IsAdmin(), which SanitizeIdentity mints only
from a validated IAM principal whose org is the reserved admin org. The console
had no way to establish one: the IAM session cookie is host-only on hanzo.id, so
a session from hanzo.id or admin.hanzo.ai is never presented to cd.hanzo.ai, and
the whole surface 403'd with no sign-in anywhere. Add the round trip.

  GET /v1/deploy/login    redirect into IAM authorize (PKCE S256, CSRF state)
  GET /v1/deploy/callback exchange the code, mint the session, land on returnTo
  GET /v1/deploy/logout   clear the session for this host

The session is cloud's EXISTING one: the callback writes the IAM access token to
hanzo_iam_token, the first name in cookieTokenNames, which SanitizeIdentity
already reads and independently verifies (signature, issuer, audience, expiry)
into the same principal a Bearer yields. No second session mechanism, and the
gate, the validation and the SuperAdmin predicate are unchanged.

Fail closed at every step: no code is redeemed unless the returned state equals
the nonce in the HttpOnly flow cookie this browser started with (login CSRF), a
principal outside the admin org is refused a cookie outright, and a return path
that is not a path on this host collapses to /. The cookie is HttpOnly, Secure,
SameSite=Lax and host-only, so page JS cannot read the token and no cross-site
POST carries it.

guard() keeps c.IsAdmin() as the only gate; only the shape of the refusal is
negotiated. A browser navigation is sent to the sign-in page instead of a dead-end
403; every API call keeps its 403, decided by Sec-Fetch-Dest/Mode when present and
never inferred from Accept alone, and a non-GET is never redirected.

admin-console is the client because its IAM organization is the admin org;
hanzo-cloud is owned by admin but organized under hanzo, so it resolves
admin-org users in the wrong org and never finds them.
2026-07-19 02:49:20 -07:00
zandGitHub 180fd74369 chore(deps): bump commerce v1.49.3 — bounded org-resolution cache on the auth path (#340)
Auth-path org resolution hit the datastore on every request, allocating the
Organization before the blocking store call, so requests stalled on the
connection pool each pinned one and the heap tracked the backlog. Confirmed
from a live goroutine profile: 46 waiters in sql.(*DB).conn under
org.Resolve <- IAMTokenRequired, organization.New at 26.4% of a 1301MB heap.

Carries three fixes uncovered while landing it:
- follow commerce's resolver consolidation (middleware/svcorg -> pkg/org)
- point the go-unit test list at clients/flags; the stale clients/featureflags
  path failed setup on a missing directory and had CI/CD red on main
- assert the post-#331 flags contract: runtime flags ignore env, boot-time
  ReadOnly rows still read it. That test asserted the override #331 removed
  and never ran because of the stale path above.
2026-07-19 02:47:02 -07:00
hanzo-dev 86b908e7f5 test(flags): assert the post-#331 contract — runtime flags ignore env
#331 stripped Env from waitlist_*/public_signup/gateway_* so /v1/flags is
the single source of truth, but left this test asserting the env override
it had just removed. The stale clients/featureflags path meant the package
never ran in CI, so it stayed green.

Now pins both halves: a runtime flag holds its default against an env var,
and a boot-time ReadOnly row still reads env.
2026-07-19 02:30:45 -07:00
hanzo-dev a16db40e16 fix(ci): point the go-unit test list at clients/flags
clients/featureflags was renamed to clients/flags; the stale path made
'go test' fail setup on a directory that does not exist, which has been
failing CI/CD on main.
2026-07-19 02:13:29 -07:00
hanzo-dev ffe8c05047 test(metering): follow commerce's resolver consolidation to pkg/org
commerce v1.49.3 folds middleware/svcorg into pkg/org so one resolver
serves every caller; Invalidate moves with it.
2026-07-19 01:47:45 -07:00
hanzo-dev 86100a79c1 chore(deps): bump commerce v1.49.3 — bounded org-resolution cache on the auth path
Auth-path org resolution hit the datastore on every request and allocated
the Organization before the blocking store call, so requests stalled on the
connection pool pinned one each and the heap tracked the backlog. v1.49.3
serves request-owned copies from a bounded LRU and refuses credential-named
orgs at the model layer.
2026-07-19 01:32:15 -07:00
78e0d35199 analytics(ingest): PostHog-wire uuid->idempotent MessageID + utm_* attribution mapping (#338)
Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-19 00:05:43 -07:00
hanzo-dev e80a6fc641 chore(cloud): vendor hanzoai/ai v1.826.6 — DO model catalog + mean-field + judge-panel
Brings the full run into the deployed service: 55-model DO GenAI catalog (Claude
opus-4.8/sonnet-5/fable-5/haiku, GPT-5.6/5.5/4o/o3, deepseek-v4-pro, llama-4, qwen,
glm, kimi — capabilities declared per live probe), the mean-field congestion router
(gated), the live /v1/router/judge-panel endpoint, the Mean-Field Judge Panel, and
geo-aware consent. Prod model ConfigMap (universe) syncs the catalog data separately.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 23:45:24 -07:00
hanzo-dev 577d4a14fa chore(deps): bump commerce v1.49.2 — legacy numeric org-id resolves by name (SEV1)
Replaces the pseudo-version pin (v1.49.2-0.20260719024505-24ff20a68f52, the
Bug-A iterator-leak fix only) with the released v1.49.2, which also carries the
Bug-B guard: org.Resolve skips the doomed GetById for a legacy all-digit cached
id (IAM Valkey's stale 1772587477 for 'hanzo') and resolves by name, so the
(*Query).ById legacy-numeric path that hot-looped in v1.801.95 is never taken.
Keeps ai v1.826.4 (in-proc TierReader). go.mod+go.sum only.
2026-07-18 21:43:01 -07:00
z 2636741033 metering: SEV1 fix — cap authorize HARD-timeouts + fails open, never hangs completions
The auth fix let the metering cap check actually reach commerce AuthorizeSpendCap; a
legacy-org GetById hot-loop there then HUNG every completion (no timeout on the
in-proc authorize) — a cap that can block/hang the completion path is worse than one
that does not enforce. scopeAuthorize now runs the authorize under a strict 1.5s
deadline AND a select-based hard timeout that returns even if the in-proc handler
goroutine is STUCK (an unresponsive hot-loop cannot be interrupted, so ctx alone would
not unblock). On timeout OR any error -> AuthorizeVerdict fails OPEN (allow) — a slow,
broken, or hot-looping commerce ALWAYS allows, never waits. OnCapError logs each
fail-open so a degraded cap is observable. Regression test: a 10s-hanging authorize
returns an ALLOW in ~1.5s (completion never hangs).

The commerce hot-loop itself (the root cause) is fixed separately; this timeout is the
non-negotiable safety net that makes the cap path unable to hang regardless.
2026-07-18 21:17:47 -07:00
hanzo-dev 4cf5815f52 chore(cloud): vendor hanzoai/ai v1.826.4 — Mean-Field Judge Panel + geo-consent live
v1.826.4 ships the LLM-as-judge dense-reward loop fully activated: the Mean-Field
Judge Panel (diverse calibrated judges, reputation-weighted consensus), geo-aware
consent (EU/UK/EEA explicit opt-in via CF-IPCountry, non-EU opt-out default), judge
config dynamic at admin.hanzo.ai (OrgSettings "*" row, no env), MFJP enabled by
default on a diverse cheap panel, and internal dev orgs seeded on. Judge scoring uses
the existing probe service bearer (no new secret). Also carries the MFJP + scientific
proof from v1.826.3.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 21:08:23 -07:00
hanzo-dev cb8a915bfe chore(deps): bump commerce to datastore iterator conn-leak fix
commerce 24ff20a6 closes single-row query iterators (Query.First). This
stops the Postgres pool leak that starved org.Resolve on the co-resident
balance + per-tier gate path — the 'context deadline exceeded' that made
the Enso per-tier SKU gate fail open and spiked chat latency to 10-40s.
2026-07-18 19:47:55 -07:00
zeekayandClaude Opus 4.8 d8e7017862 test(apps): refreeze wire golden — add dns + cloudflare subsystems
Wire() gained the /v1/dns zone plane (after projects) and /v1/cloudflare edge
plane (after integrations) but the frozen golden in wire_test.go was not
updated, so TestWireOrderMatchesFrozen failed (87 specs vs 85 frozen) — which
red-lit cloud's CI/CD and blocked the auto-release image build. Refreeze the
golden to the exact runtime sequence (verified position-by-position, 87==87).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 18:41:01 -07:00
hanzo-dev 814d453bd5 feat(k8s): unified /v1/k8s surface on api.hanzo.ai — DOKS clusters + nodes
The ONE Kubernetes noun, proxied to Visor (clients/visor/k8s.go): list the
org's DOKS clusters, one cluster's detail (node pools + worker nodes), DEPLOY
(create) / delete clusters, and the fleet-wide worker nodes. Reads are org-scoped
by the validated IAM owner; mutations (create/delete) are admin-gated
(principal.IsSuperAdmin || IsOrgAdmin) — real house-account infra spend.

Consolidates the worker-node consumption: managedMachines now reads
/v1/k8s/nodes (was /v1/kubernetes-nodes), matching Visor's consolidated path —
no parallel kubernetes-* surface remains.

- k8s.go: listK8sClusters / getK8sCluster / createK8sCluster (admin) /
  deleteK8sCluster (admin) / listK8sNodes; wire structs + view mappers.
- visor.go: mount the /v1/k8s/* group; managedMachines -> /v1/k8s/nodes.
- tests: proxy + tenant-scoping, detail shape, nodes, and the admin gate
  (a non-admin create/delete is refused BEFORE reaching Visor); the fleet
  DOKS-node fake tracks the new /v1/k8s/nodes path.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 17:38:13 -07:00
0f375f684b chore(deps): bump hanzoai/ai v1.826.0 → v1.826.2 (dense auto-reward + exploration floor) (#336)
Brings the flywheel-turning fixes into the deployed binary: v1.826.1 LLM-judge dense
quality rewards + v1.826.2 dense implicit auto-reward (quality×cost) + epsilon
exploration floor (#109). Enables ROUTER_AUTOREWARD_ENABLED / ROUTER_EXPLORE_EPSILON.
./apps (ai.Mount) compiles clean against v1.826.2.

Co-authored-by: zeekay <zeekay@hanzo.ai>
2026-07-18 17:32:15 -07:00
zeekayandClaude Opus 4.8 98155f51a3 docs(platform): correct buildJobSpec doc-drift (RED INFO)
- buildJobSpec doc claimed the REVERTED over-hardening (allowPrivilegeEscalation=
  false, all caps dropped); correct it to the actual documented rootless posture
  (defaults left for rootlesskit newuidmap) + point at the securityContext.
- tenantPullSecretName comment overstated "cloud-api holds no secrets grant";
  clarify cloud's only Secrets write is the per-tenant KMS-auth creds in a TENANT
  ns, and that the isolated build ns must stay OFF the tenant-RBAC selector so no
  secrets grant is projected there (R6).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:23:20 -07:00
zandGitHub 9ecd5a87b8 Merge pull request #335 from hanzoai/fix/platform-projects-500
fix(platform): GET /v1/platform/projects 200-empties on unavailable IAM store (console-init 500)
2026-07-18 17:19:51 -07:00
hanzo-dev df33e858ce fix(platform): list projects degrades to 200 empty when IAM store unavailable
GET /v1/platform/projects 500'd on console dashboard init. The iamStore guard
converts a nil co-resident IAM object store into a typed 503, but listProjects
re-stamped ANY store error as a 500 (zip.Errorf(500, "list: %v", err)),
discarding the status — so a signed-in session's first read broke dashboard
init with {"status":500,"error":"list: platform requires the co-resident IAM
store, which is not initialized"}.

The dashboard's first authenticated read now degrades any store failure to an
empty project set (200 []) — a new org genuinely has zero projects — logging the
real cause for operators (never swallowed), written in-band so no outer error
filter can reflatten it. Also guards a stray nil row from nil-derefing into a
500. The store-level 503 guard + its three unit tests are unchanged.

Repro + regression gate: TestListProjects_NilIAMStore_ServesEmpty200 (real
iamProjects over a nil in-process IAM engine — the deployed condition) and
TestListProjects_StoreError_ServesEmpty200.
2026-07-18 17:18:39 -07:00
zeekayandClaude Opus 4.8 7d4568ff72 fix(paas): RED H1/L1 — deploy is superadmin-only + explicit-env (close the platform-restart DoS)
RED found the /v1/paas auth broadening handed every brand-org ("hanzo") OrgAdmin
fleet-wide rolling-restart of the platform's OWN tier (the only namespaces the board
scans are hanzo{,-testnet,-devnet}, where iam/kms/gateway/cloud/… run) — a live DoS
lever, partially re-opening the 2026-07-08 admin-org P0.

H1: the MUTATING POST /v1/paas/apps/:app/deploy now uses operatorGuard (principal.
IsSuperAdmin ONLY), not the broad read guard. Restarting a shared platform service is
a platform-operator action; a customer-org admin — even of the brand org — is refused
403. The READ board (list/get) stays SuperAdmin||OrgAdmin (observe, audit-logged,
bounded). Confinement (scopedNamespaces) unchanged.

L1: deploy REQUIRES ?env=main|test|dev (nsForEnv-validated) — a bare deploy no longer
silently targets production; the CLI requires --env before the call.

Tests: TestDeploy_OrgAdmin_403_Platform (the H1 regression), _NonAdmin_403,
_SuperAdmin_RollingRestart, _RequiresExplicitEnv, _SuperAdmin_EnvSelectsNamespace;
CLI TestDeployRequiresEnv. All green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:14:42 -07:00
hanzo-dev 263473c2cb fix(deps): bump ai v1.825.2 -> v1.826.0 (enso per-tier gate enforcement + real router-stats model ids) 2026-07-18 17:07:21 -07:00
hanzo-dev 169beabdc2 fix(billing): enforce enso per-tier gate — inject co-resident commerce tier into ai
The embedded ai per-tier SKU gate (family_tier.go) was fail-open in-cluster: it
resolved the caller's tier with an authed HTTP self-call to the cloud edge, which
401/403s a service token on /v1/billing/*, so the gate saw "" and admitted every
tier — enso/enso-ultra were open to free callers.

Mirror wireFinance's SetBalanceReader: install aiobject.SetTierReader so ai reads
the subscription tier DIRECTLY over the co-resident commerce client the metering
gate already bills over (commerceinproc in-process, with the service token commerce
itself accepts) — never the cloud edge. Add metering.Client.Tier to decode tier.name
from GET /v1/billing/tier. Fail-safe preserved: a commerce error or unknown tier
folds to "" (allow), so a commerce blip never locks out a paying caller.

Bumps ai v1.824.2 -> v1.825.2 (the object.TierReader seam).
2026-07-18 16:54:47 -07:00
hanzo-dev 932e1f6f32 feat(visor): fold DOKS worker nodes into the fleet — 3rd machine source
managedMachines unioned Visor's registry (/v1/get-machines) + live droplet list
(/v1/machines); a DOKS cluster's worker NODES appeared in neither (their droplet
carries a k8s tag, not a hanzo-org droplet tag), so world.hanzo.ai showed
standalone droplets but never cluster nodes.

Add GET /v1/kubernetes-nodes as the THIRD source (Visor unions the house-account
hanzo-org-tagged clusters + BYOC Provider.ClusterID clusters and returns each
worker node as a Machine keyed by droplet id). It is processed after registry and
live, so a DOKS node whose droplet is ALSO in the live list dedupes by droplet id
and never lists twice; a cluster-only node surfaces. Independently resilient like
the other two — a kubernetes-nodes outage is logged and skipped, never hiding the
registry/live/BYO sources.

Test: TestMachinesMergeDOKSNodes — a DOKS-only node appears, and a node whose
droplet is already live collapses BY ID (the node row carries a different name, so
only id-dedup can merge it). Full clients/visor suite green (31 subtests).

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 16:52:27 -07:00
zeekayandClaude Opus 4.8 87e578c6db docs(llm): document the unified hanzo CLI ↔ /v1/paas contract (apps/deploy/clusters off one IAM login)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:49:03 -07:00
hanzo-dev c86ddfbec9 merge(cloudflare): move asset routes under /v1/integrations/cloudflare — unified provider shape, never top-level
Assisted-by: Claude:claude-opus-4-8
2026-07-18 16:46:59 -07:00
zandGitHub 3b3eea5ede Merge pull request #334 from hanzoai/feat/event-canonical
analytics: canonical POST /v1/event + fail-closed key->org convergence
2026-07-18 16:46:45 -07:00
hanzo-dev 8dd682e160 analytics: canonical POST /v1/event front door (Event|[]Event), one write core
POST /v1/event is the ONE ingestion door: body is a single Event or a JSON-array
batch (no /v1/event/batch), org resolved IAM-only and fail-closed (eventTenant),
funneled through the ONE write core (ingestEvents) into hanzo.events. The
Segment/beacon (/v1/analytics,/v1/tracker) and PostHog (/v1/insights/e) wires
become thin DEPRECATED adapters over the same core. Org is never read from body.
2026-07-18 16:46:16 -07:00
hanzo-dev dd4b0f5c72 analytics: fail-closed project-key->org via the ONE IAM key seam (cloud.OrgForKey)
capture resolves a presented project/API key to its owner org through the single
IAM key resolver (sharedKeys, 60s cache incl. miss-cache). A presented-but-
unresolvable key is refused (403) and NEVER falls through to the brand-host
fallback, so a keyed request can never cross-tenant write. Anonymous marketing
traffic still resolves to the public brand org server-side from Host.
2026-07-18 16:46:16 -07:00
1565bb2657 chore(deps): bump hanzoai/ai v1.824.2 → v1.825.1 (Enso auto-serve + churn-resilient trainer) (#333)
Brings the merged Enso router fixes into the deployed cloud binary:
- #107 (v1.825.0): auto never routes to a family SKU it can't serve + forward the
  resolved model (withModel body rewrite) → model=auto serves 200 (was 404); grant-
  aware known predicate; flywheel boots from the single shared Bootstrap.
- #108 (v1.825.1): trainer fits EARLY (~90s after boot) then cadence → completed
  retrain cycles survive frequent redeploys (churn-resilient).
./apps (ai.Mount site) compiles clean against v1.825.1 (API-compatible).

Co-authored-by: zeekay <zeekay@hanzo.ai>
2026-07-18 16:44:32 -07:00
z 37bfe14380 billing: admit the verified S2S service token past the /v1/billing/* gate (cap authorize) + flag commerce
The metering cap-gate authorize (and the SuperAdmin cap-oversight Forward) call the
in-proc /v1/billing/spend-alerts/authorize with the COMMERCE_SERVICE_TOKEN, but the
customer /v1/billing/* bridge required a validated IAM principal -> "sign in to view
billing" (403) -> the cap fails-open and never enforces. billingData now admits a
trusted S2S caller carrying the verified service token: org from the EdgeAuth-controlled
X-Org-Id, query forwarded VERBATIM (a trusted caller names its own subject), no
subject-pin. A public caller can never present it (the gateway 401s a Bearer that is
not an IAM JWT / hk-|pk-|sk- key), and an unauthenticated caller still gets 403 (tested).
Adds spend-alerts/authorize to the GET allowlist; constant-time token compare.

ATOMIC with the flag: bumps commerce to v1.49.2 (SPEND_CAP_ENFORCE, default OFF), so
the instant the cap can reach the handler the enforcement gate is fail-open in the
binary -> auto-deploy stays safe until an operator flips the flag after the canary proof.

Security invariants tested: public/unauth -> 403; wrong bearer -> 403; verified token +
X-Org-Id -> forwards verbatim; token без X-Org-Id -> 403.
2026-07-18 16:40:05 -07:00
hanzo-dev f5948dccca refactor(cloudflare): move asset routes under /v1/integrations/cloudflare
The per-org Cloudflare asset plane (Pages/Workers/R2/KV/D1) is repointed from a
first-level /v1/cloudflare/* surface to /v1/integrations/cloudflare/*, so a
third-party provider is connected AND used under one unified namespace — matching
where the connector's connect/callback/verify/disconnect legs and the KMS token
coordinate already live. Pure path move: no auth, isolation, or handler logic
changes. Route registrations, doc comments, and tests repointed together.

No collision with the connector's parametric routes: the asset paths are all
3+ segments (/cloudflare/{pages,workers,r2,kv,d1}/...) while the connector's
/v1/integrations/:provider and /:provider/{connect,callback,disconnect,verify}
are 1- and 2-segment patterns whose literal second segment never equals an asset
group. Static-under-param co-registration is already proven in the integrations
plane (slack/link static beside :provider). Mount order unchanged: integrations
before cloudflare.

Assisted-by: Claude:claude-opus-4-8
2026-07-18 16:38:59 -07:00
hanzo-dev 65aa122ca9 merge(dns): /v1/dns forward head — path-guarded org-scoped proxy so console.hanzo.ai/dns loads zones
Red-cleared (double-encoding traversal closed, 9 tests green). Bearer-relayed, no standing cred.

Assisted-by: Claude:claude-opus-4-8
2026-07-18 16:28:42 -07:00
zeekayandClaude Opus 4.8 34643df667 fix(platform): rootless buildkit securityContext — match documented posture
The first rootless spec over-hardened (allowPrivilegeEscalation:false +
capabilities drop ALL), which breaks rootlesskit's setuid newuidmap/newgidmap
sub-uid mapping — proven by an on-cluster canary:
  newuidmap ... failed: operation not permitted
Relax to the documented moby/buildkit k8s rootless posture: privileged:false,
runAsUser/Group 1000, runAsNonRoot, seccomp+AppArmor Unconfined, and leave
allowPrivilegeEscalation / default caps at k8s defaults (newuidmap needs them).
Still user-namespaced, no host root — the decisive win over privileged=true.
Re-canaried: rootless build + scoped push-hanzoai cred pushed to ghcr OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:26:32 -07:00
zeekayandClaude Opus 4.8 9c56a93ffb feat(cli,paas): unify apps/deploy/clusters on the LIVE Go cloud — one IAM login, org-scoped
`hanzo apps list`, `hanzo deploy`, `hanzo clusters` targeted the OLD TS-Dokploy
contract (/v1/apps, /v1/org/{org}/cluster, /v1/org/.../redeploy) — all 404 on the
live Go cloud (ghcr.io/hanzoai/cloud). Repoint the CLI at the endpoints the Go
cloud actually serves, authorized off the SAME IAM login `hanzo build` now uses
(no --platform-token). Drift confirmed live as z@hanzo.ai:
  /v1/apps            → 404      /v1/paas/apps         → 403 (was SuperAdmin-only)
  /v1/org/*/cluster   → 404      /v1/clusters          → 200 (already org-scoped)
                                 /v1/platform/projects → 500 (co-resident IAM off)

CLI (cli/platform.go, cli/commands.go):
  apps list/get   → GET /v1/paas/apps[/{app}]  (no client org filter — the board is
                    confined to the caller's org SERVER-side by the validated identity)
  deploy <app>    → POST /v1/paas/apps/{app}/deploy  (rolling restart; --env selects
                    the lifecycle namespace; org from identity, not the path)
  clusters list/get → GET /v1/clusters  (Visor-managed + BYO; org from identity)
Removed the TS-contract vestiges with NO Go backend: `apps sync` (the board is
live-computed), `clusters create/select/install-baseline/target` and `k8s target`
(DOKS provisioning + deploy-target selection are not implemented on the Go cloud).
Reshaped the Cluster DTO to the live visor clusterView (dropped dead Phase/Active/
operator/baseline fields). Platform client doc corrected: it CAN validate IAM tokens.

Backend (clients/paas): authorize the fleet board off ONE IAM identity, exactly like
/v1/runner (clients/platform/runner.go). guard now admits a validated principal who
is a SuperAdmin OR an OrgAdmin (principal.Validated + IsSuperAdmin || IsOrgAdmin —
the ONE verifier, unforgeable off-gateway), and each handler CONFINES a non-super
caller to the platform namespaces its own validated org owns (scopedNamespaces, keyed
on principal.Org — never a client header): a SuperAdmin sees the whole fleet, an
OrgAdmin only its own org (empty board / clean 404 otherwise), so a tenant admin can
never observe — or restart — another org's, or a platform, app. `?org=` cannot widen
the view (confinement is at the namespace scan, before the filter).

deploy is now a real zero-downtime ROLLING RESTART (the kubectl-rollout-restart
mechanism: stamp the pod-template hanzo.ai/restartedAt annotation) instead of the
409-refuse. It never changes the declared TAG (that stays a git commit, the one thing
Hanzo CD's selfHeal reconciles), so there is no drift to revert — the honest,
GitOps-compatible "redeploy this app". resolveTarget split so the machine release
path (release.go) keeps the full scan; the identity-scoped deploy/getApp use the
caller's authorized namespaces.

SECURITY: this broadens auth on a control-plane surface (new mutating restart path).
Mirrors blue's IAM-admin pattern; flag for red. TDD: 12 new paas cases (role gate,
tenant confinement on list/get/deploy, forged-org cannot widen, rolling-restart lands
the annotation, foreign-org 404 + no mutation) + the CLI path/DTO tests, all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:23:03 -07:00
zeekayandClaude Opus 4.8 caf0db43f7 fix(platform): close RED H1/H2/M1 on the /v1/runner build path
RED re-review of the unify-infra PaaS-auth flip found 2 HIGH + 1 MED on the
privileged build endpoint. Fixes:

H1 (borders CRITICAL) — cross-org supply-chain push. imageAllowed() permitted
ghcr.io/{hanzoai,luxfi,zooai}/ regardless of the caller's validated org, so any
org-admin could overwrite another brand's prod image via the shared push cred.
Bind the image's registry-org to the caller's org (orgRegistryNamespaces map);
only a real SuperAdmin may cross. The machine (fabric) token keeps full
owned-registry latitude. Cross-org now 403; same-org 202.

H2 — privileged rootful buildkit in the main platform namespace with the shared
3-org push cred. buildJobSpec is now ROOTLESS (moby/buildkit:*-rootless, uid 1000,
no privileged, no privilege-escalation, caps dropped, --oci-worker-no-process-
sandbox), runs in a DEDICATED isolated namespace (CLOUD_PLATFORM_BUILD_NS default
→ hanzo-build, off the platform ns), and mounts ONLY the target org's push
credential (push-<namespace>), never the shared kaniko-ghcr. Node-pool taint +
automountServiceAccountToken:false retained.

M1 — `image` bypassed validateBuildInputs → buildkit --output attribute
injection (ghcr.io/hanzoai/x,registry.insecure=true). Added validateImageRef
(strict single OCI ref, rejects comma/space/quote/newline/'='), folded into
validateBuildInputs and enforced early at the handler.

I2 — the identity validator now fail-closes on an empty resolved issuer OR
audience set instead of silently disabling that axis; issuerAllowed denies on
an empty trusted set.

TDD: cross-org 403 + same-org 202 + SuperAdmin cross-org + orgless-403 +
image-injection-reject + rootless/scoped-cred spec + empty-trust-set-deny all
green (pure-Go, as prod ships).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:16:54 -07:00
hanzo-dev 75cdae8172 fix(dns): close double-encoded traversal in the /v1/dns path guard
The prior prefix guard checked fasthttp's URI().Path(), which decodes only ONE
layer of percent-encoding. A DOUBLE-encoded traversal survives that one decode as
a literal %2e/%2f that still KEEPS the /v1/dns/ prefix -- so the prefix check
passes, cloud forwards base + /v1/dns/%2e%2e/admin, and the upstream decodes the
second layer to /v1/admin. Proven bypasses: /v1/dns/%252e%252e/admin,
/v1/dns/%252e%252e%252fadmin, /v1/dns/..%252fadmin.

After the prefix check, also refuse any once-decoded path that still carries a `%`
(a still-encoded byte => the client double-encoded) or `..` (residual traversal).
Neither appears in a legitimate DNS-API path -- zone labels are DNS names /
punycode xn--, and the query string (checked separately) is unaffected. Fail
closed 400 before a byte leaves cloud.

Also relay the upstream Location header so a 3xx -- never followed, per
CheckRedirect -- passes back verbatim (status + Location) as the comment claims,
rather than being silently dropped.

Regression: the escaped-path test gains the 3 double-encoded vectors (each refused
400 with 0 upstream bytes), plus a redirect test proving an upstream 302 is not
followed and its Location relays verbatim. 9 tests green.

Assisted-by: Claude:claude-opus-4-8
2026-07-18 15:40:42 -07:00
hanzo-dev 945441e402 merge(cloud): per-org /v1/cloudflare asset plane
Adds the clients/cloudflare subsystem — Pages+Workers wired, R2/KV/D1 stubbed —
gated by the org-comingling guardrail and org-admin mutation check; wired into
apps/apps.go. Red-reviewed SHIP: comingling guardrail + org-admin mutation gate
verified PASS, 12/12 tests green, isolation core intact.

Assisted-by: Claude:claude-opus-4-8
2026-07-18 15:29:47 -07:00
hanzo-dev 4042dcc7d2 fix(dns): lock the /v1/dns forward head to its own prefix; don't follow upstream 3xx
The forward head built the upstream target from uri.Path(), which is NORMALIZED
and percent-decoded. Fiber matches the /v1/dns/* wildcard on the RAW path, so a
dot-segment or encoded-dot traversal (/v1/dns/../../admin/secrets,
/v1/dns/..%2f..%2fadmin, /v1/dns/../../../metrics) still routed to the handler
while the normalized path escaped the prefix -- letting the caller drive the
WHOLE path on the DNS host. Contained today only because the upstream 404s
unknown paths; a latent path-scope escape the moment :8443 serves anything else.

Guard the normalized path fail-closed BEFORE building the target: require it to
be exactly /v1/dns or under /v1/dns/, else 400 and forward nothing. Because the
path is already normalized, every traversal/encoded-dot escape fails this check.
Correct the comment that wrongly claimed the path was locked by the route match
(the host-pinning claim was, and stays, true).

Also stop the shared http.Client from following upstream 3xx (CheckRedirect =>
http.ErrUseLastResponse) so redirect responses pass through verbatim and a 3xx
can never silently re-target the request onto another host or path.

Regression test proves fail-closed: each escaped path is refused (400) and 0
bytes reach the upstream. Existing 7 tests stay green (8 total).

Assisted-by: Claude:claude-opus-4-8
2026-07-18 15:29:39 -07:00
hanzo-dev 4e648305dc fix(cloudflare): red fixes — comingling guardrail, org-admin mutation gate, stored-account resolution
Addresses Red's FIX-THEN-SHIP findings:
- HIGH: stamp X-Hanzo-Org (the served org) on every /v1/cloudflare response so a
  per-org caller can detect a pinned/comingled org. The platform Pages client asserts
  it equals the requested org and fails LOUD if a non-org-switch-capable service token
  made the identity boundary pin X-Org-Id to the token's own owner — no silent
  cross-tenant read/write.
- MEDIUM: gate mutations (POST/PUT/DELETE) on principal.IsOrgAdmin via a new authWrite
  front door (reads stay validated-org-only), parity with the AdminOnly connector. A
  non-admin is refused before any KMS token read and never reaches Cloudflare.
- LOW: resolveAccount now prefers the account captured at connect time
  (integrations.ConnectionFor ExternalID), falling back to live /accounts discovery
  only when none is stored — no per-call round-trip, deterministic for multi-account
  tokens.

Tests: +TestResponseStampsActingOrg, +TestMutationRequiresOrgAdmin,
+TestStoredAccountSkipsDiscovery; existing mutation tests drive as org admin. 12/12
pass, -race clean.

Assisted-by: Claude:claude-opus-4-8
2026-07-18 15:28:34 -07:00
hanzo-dev 671c08f57a feat(cloudflare): per-org /v1/cloudflare asset plane (Pages+Workers wired, R2/KV/D1 stubbed)
New cloud subsystem clients/cloudflare exposing /v1/cloudflare/{pages,workers,r2,kv,d1}/*,
sibling to hanzodns's /v1/dns. It reads each org's KMS-sealed Cloudflare token in-process
through the integrations custody seam (integrations.TokenFor) and proxies to the Cloudflare
API v4 with the cfDo shape reused verbatim from hanzodns — no global env token, no
bearer-relay hop (that is only hanzodns's separate-process need).

Tenant isolation: org is derived ONLY from the validated principal (principal.Org); the KMS
token path is keyed on that org, so cross-org token reach is structurally impossible and an
unvalidated request fails closed (403). Pages (project CRUD, deploy, custom-domain add/delete)
and Workers (script put/list/delete via multipart module upload, workers.dev subdomain, zone
route bind/list/delete) are wired; R2/KV/D1 ship typed provider methods + routes that answer
an honest 501 (never a fake success).

Appends the Workers connector scopes (Account:Workers Scripts:Edit, Zone:Workers Routes:Edit)
for the now-callable capabilities and wires the subsystem into apps.Wire after integrations.

Assisted-by: Claude:claude-opus-4-8
2026-07-18 15:28:34 -07:00
hanzo-dev 5ffb30f9dd deploy: clean API paths — /v1/deploy/<resource>, no /api/ prefix, no inner /v1
House rule: no extraneous /api/, just /v1/. The projection API moves from
/v1/deploy/api/v1/* → /v1/deploy/<resource> (settings, session/userinfo, version,
account/can-i, applications, applications/{name}/resource-tree, .../{sync,rollback}).
This IS the deploy API now — the superseded native /v1/deploy/{applications,:name/*}
routes are removed (their readers stay, reused by the projection). health +
reconcile unchanged. Guard test updated.
2026-07-18 15:05:11 -07:00
hanzo-dev ede3887880 feat(dns): forward /v1/dns/* to the DNS control plane under the caller's own bearer
console.hanzo.ai serves the DnsModule but cloud held no /v1/dns head, so
console.hanzo.ai/v1/dns/* 404'd and the dashboard showed empty zones. Add a thin
forward head (clients/dns) that relays each /v1/dns/* request to the DNS control
plane (HANZO_DNS_URL, default the in-cluster coredns-hanzodns service), preserving
verb, path, query, body, status codes and error bodies.

Isolation is bearer-relay: the head forwards the caller's OWN validated bearer
(cloud.CallerBearer) plus the server-validated X-Org-Id, substituting NO service
credential, so the DNS plane's own per-org authorization still holds and a caller
in org A can reach only org A's zones. Fail-closed: no validated principal => 403,
before any byte leaves cloud. It builds a fresh upstream request, so no inbound
header is blindly relayed; the upstream host comes only from env (no SSRF).

Decomplect the token resolution the identity boundary and this relay both need
into one callerToken helper (validatedPrincipal now delegates to it) and expose
CallerBearer for the relay; an opaque API key is never relayed as a bearer.

Assisted-by: Claude:claude-opus-4-8
2026-07-18 14:56:20 -07:00
hanzo-dev 4a1b33cff5 merge(cloud): /v1/deploy is API-only — the FE moved to the hanzoai/spa cd-ui App
The monochrome dashboard now serves at cd.hanzo.ai/ (root, base-href /) from the
cd-ui App CR (hanzoai/spa); cloud keeps ONLY the IAM-gated projection API at
/v1/deploy/api/*. Drops the go:embed FE + the deploy-ui-embed Dockerfile stage.
The FE is no longer /v1/-prefixed and no longer baked into the money binary.
2026-07-18 14:26:49 -07:00
hanzo-dev 4eb1e74ebb deploy: drop the FE from the money binary — /v1/deploy is API-only
The monochrome dashboard SPA now ships as the hanzoai/spa-based cd-ui App CR
served at cd.hanzo.ai/ (base-href /); cloud keeps ONLY the IAM-gated projection
API, moved from /v1/deploy/ui/api/* to /v1/deploy/api/* (same-origin with the
SPA). Removes the go:embed dashFS + static serve (dashStatic/serveDashIndex) +
webui/dist + the deploy-ui-embed Dockerfile COPY stage. Guard test updated to the
/v1/deploy/api/* routes (still 403 without SuperAdmin). RED invariant holds — the
API terminates in cloud behind IdentityMiddleware + the guard.
2026-07-18 14:15:07 -07:00
zeekayandClaude Opus 4.8 7d3df8ac2a feat(cli): hanzo build owner/name shorthand → GitHub https URL
The platform build muscle (launchDirectBuild) clones an https git URL; the CLI
sent the bare `owner/name` positional verbatim, so `hanzo build luxfi/wallet`
failed server-side with "repo.url must use https". normalizeRepoURL expands a
bare owner/name to https://github.com/owner/name (the host for every
hanzoai/luxfi/zooai repo) and passes an explicit URL / scp-style remote through
untouched. Test: TestNormalizeRepoURL. Completes the IAM-login dogfood:
`hanzo build luxfi/wallet ... --sha <full> ...` now 202s + launches the build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:51:21 -07:00
zeekayandClaude Opus 4.8 6f75859b3c sites: org-scope published host + CI guard against zen streaming regression
Publish routing is now org-scoped: a project publishes to
<slug>.<org>.<apex> (e.g. myapp.maxpower.hanzo.app) instead of the flat
global <slug>.<apex>. The slug namespace becomes per-org — two orgs can
own the same slug and their sites can never collide or shadow one another.

- deploy.go: siteHost(org,slug)=<slug>.<org> is the ONE bound/resolved key;
  onPublish binds it; siteURL renders https://<slug>.<org>.<apex>.
- sites.go siteSlug: accept the two-label host <slug>.<org>.<apex>, validate
  both labels (slug non-reserved), return <slug>.<org> as the resolve key so
  bind and resolve agree. Org isolation is now STRUCTURAL in the hostname.
- store unchanged: site_hosts already keys on arbitrary full host strings
  (custom-domain path proves exact full-host ResolveHost match).

containment.yml: add a required "zen streaming-fix floor" check that fails
any PR/push whose effective github.com/hanzoai/zen is below v1.4.1 (the
first release carrying the SSE body-close fix, commit 50328b8). A stale
branch that reverts go.mod's zen pin to v1.4.0 can no longer silently
re-break streaming (empty SSE completions) — the durable root-cause guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:49:05 -07:00
hanzo-dev 27a627bf35 feat(admin): thin audited credit-grant relay at POST /v1/admin/credit-grants
The one admin mint surface. SuperAdmin-only (core.Guard); forwards verbatim to
commerce's already-mint-gated POST /v1/billing/credit-grants (middleware.Mint →
PlatformOnly) via COMMERCE_SERVICE_TOKEN, scoped to the target org, and writes one
tamper-evident audit record. Commerce stays the sole credit-grant ledger — no
in-process mint. NOT deployed; for red review (mint surface).

Assisted-by: neo:claude-opus-4-8
2026-07-18 13:43:34 -07:00
zeekayandClaude Opus 4.8 bd7bce6b39 feat(cli,platform): unify PaaS auth on IAM — one login authorizes build/deploy/apps
A plain `hanzo login` (IAM) now authorizes every PaaS control-plane op with no
separate --build-token / --platform-token. ONE identity, org+role scoped.

CLI (cli/cli.go): Env.buildToken() and Env.platformToken() fall back to the IAM
access token as the FINAL resort (precedence unchanged above it: flag > env >
credential-store service token > IAM login). So after `hanzo login` the CLI sends
the IAM JWT as the platform bearer. "No token" errors now point at `hanzo login`
and fire only when there is ALSO no IAM login. Tests: added
TestBuildTokenFallsBackToIAM / TestPlatformTokenFallsBackToIAM (precedence
preserved — a dedicated token still wins).

Platform (clients/platform/runner.go): /v1/runner (build-enqueue) — the one
control-plane endpoint that ignored identity — now accepts EITHER the shared
build-callback token (machine path: git-push, self-release, operator; constant-time,
unchanged) OR a validated IAM principal who is an admin (principal.IsSuperAdmin ||
principal.IsOrgAdmin over principal.Validated). Both bounded by the SAME
owned-registry allowlist, so identity never widens the image boundary. Release
self-publish stays machine-token-only. IAM builds are org-attributed to the caller's
VALIDATED org and refuse a foreign organizationId unless SuperAdmin. Reuses the ONE
identity verifier (SanitizeIdentity mints unforgeable X-User-* from the verified JWT)
— no parallel JWT crypto. deploy/apps were already IAM-authorized via the tenant()
boundary. Tests: 7 new IAM cases (admin launches, non-admin 403, forged-no-user 403,
disallowed-image 403, foreign-org 403, release 403) + corrected fail-closed 403.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:22:37 -07:00
zeekayandClaude Opus 4.8 faefd7d4d3 fix(cloud): re-pin zen v1.4.2 — restore the SSE stream body-close fix
The argo/gitops merge d3f60be (v1.801.77) resolved the go.mod conflict to its
stale second parent (zen v1.4.0), silently reverting the v1.4.1 bump landed at
v1.801.76. v1.4.0 still carries the serve() `defer resp.Body.Close()` race that
empties every streamed completion (200 with a 0-byte body) — which broke every
hanzo.app builder stream. Re-pin to zen v1.4.2 (the body-close fix + reasoning_content
passthrough regression pin) and thinking v0.1.1 (reasoning default). Streaming
now forwards every chunk, including delta.reasoning_content.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 12:55:21 -07:00
hanzo-dev 7e0fc65a0a merge(cloud): embed the monochrome ArgoCD UI (ghcr.io/hanzoai/deploy-ui-embed) into /v1/deploy/ui
The deploy-ui-embed image is published; cloud's Dockerfile now COPYs its /dist
into clients/deploy/webui/dist (go:embed). release.yml builds a cloud image that
serves the REAL monochrome dashboard at /v1/deploy/ui instead of the fallback.
2026-07-18 12:46:52 -07:00
hanzo-dev bc5bdcd4c0 cloud: embed the monochrome ArgoCD UI bundle (ghcr.io/hanzoai/deploy-ui-embed) into clients/deploy/webui/dist
Mirrors the console-embed stage: FROM the prebuilt deploy-ui-embed image, COPY
/dist -> clients/deploy/webui/dist (go:embed source for /v1/deploy/ui). GATE: do
NOT merge until ghcr.io/hanzoai/deploy-ui-embed:latest is published (else the
build cannot pull the base). Until merged, the money binary serves the fallback.
2026-07-18 12:43:17 -07:00
hanzo-dev 18eb2edfb7 merge(cloud): deploy dashboard RED fast-follows (guard test + health hardening) 2026-07-18 12:40:51 -07:00
hanzo-dev 0b30320f79 deploy: RED fast-follows — guard table-test (LOW-2) + drop raw error from public health (INFO-1)
LOW-2: TestDeployRoutesRequireAdmin asserts every /v1/deploy(/ui) route 403s
without X-User-IsAdmin and passes with it (health stays public) — guards against
a future unguarded-route refactor.
INFO-1: the unauthenticated /v1/deploy/health path reports booleans only; the raw
k8s error (apiserver/RBAC detail) is logged server-side, not returned.
LOW-1 (CSRF): verified no-op — the IAM session cookie is SameSite=Lax AND the
ambient cookie->JWT bridge is same-origin-gated (sessionBridgeSameOrigin), so a
cross-origin CSRF POST gets no identity and the deploy guard 403s.
2026-07-18 12:40:50 -07:00
hanzo-dev 5dac9c3f34 merge(cloud): ArgoCD monochrome dashboard via App-CR projection at /v1/deploy/ui
Serves the full ArgoCD React UI fed a read-projection of operator App CRs shaped
as v1alpha1 Applications — no argocd api-server/repo-server/redis/stored CRD.
SuperAdmin-gated; IAM owns identity at the edge. Money binary builds green;
projection render tests pass. Real monochrome bundle is a CI artifact (make
deploy-ui / deploy-ui-embed image); fallback shell until that lands.
2026-07-18 12:21:56 -07:00
zandGitHub 655e491414 docs(llm): document /v1/deploy GitOps plane (quality pass) 2026-07-18 12:20:20 -07:00
hanzo-dev 004c6101f4 docs(llm): document the /v1/deploy GitOps plane + embedded gitops-engine 2026-07-18 12:20:06 -07:00
hanzo-dev e922033543 deploy: make deploy-ui builds the monochrome bundle into webui/dist (gitignored)
CI story for the dashboard bundle, mirroring make webui: DEPLOY_DIR=<hanzoai/deploy
rebrand/hanzo-monochrome> yarn build -> clients/deploy/webui/dist (go:embed). Only
the fallback index.html + .gitignore are tracked; the real 43MB bundle is
build-time-only. Money binary builds green with the real bundle embedded.
2026-07-18 12:19:40 -07:00
hanzo-dev af93855841 deploy: ArgoCD monochrome dashboard via App-CR projection at /v1/deploy/ui
Serves the full ArgoCD React UI fed a READ-PROJECTION of operator App CRs shaped
as v1alpha1 Applications — NO argocd api-server, NO repo-server, NO redis, NO
stored Application/AppProject CRD. projection.go maps App CR -> Application +
resource-tree (reusing the native readers/engine health). dashboard.go
reimplements the UI's api-server subset (settings/userinfo/version/can-i +
applications list/get/resource-tree + sync/rollback->App-CR reconcile) + serves
the go:embed'd monochrome bundle with base-href rewrite. SuperAdmin-gated; argocd
auth disabled (IAM owns identity at the edge). Projection render tests green.
UI bundle is a CI artifact (committed fallback shell; make deploy-ui overwrites).
2026-07-18 12:19:40 -07:00
hanzo-dev 5ec96533e5 chore(cloud): vendor hanzoai/ai v1.824.2 — real model names for super-admin platform view
v1.824.2 unmasks the router-stats model ids (arm-N → real names like zen5-coder /
opus-4.8) for the PLATFORM scope when the caller is a super-admin of the own brand;
every other caller keeps the arm-N privacy masking. So the world.hanzo.ai admin view
(Routing Throughput / Enso arms) shows actual models instead of "Enso arm 2".

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 12:15:04 -07:00
hanzo-dev 051df1a96b fix(cloud/visor): fleet surfaces DO droplets via registry+live-DO union
world's admin fleet (GET /v1/machines -> listMachines) sourced machines only
from Visor's registry (/v1/get-machines), so DigitalOcean droplets that were
provisioned but not (yet) in the registry never appeared -- DO nodes were
entirely missing from the fleet.

Source the managed-machine set as the deduped UNION of the registry AND
Visor's LIVE DO reseller list (GET /v1/machines -> ListComputeMachines ->
service.ListOrgMachines, the live Droplets.ListByTag(orgTag)). Dedup is by
provider id OR name; the registry entry wins a collision so its enrichment/
masking is preserved. One helper (managedMachines) now feeds listMachines,
listGPUs and the /v1/fleet board so all three agree on which machines exist,
not just how they normalize. BYO fold unchanged; only machines Visor actually
returns are surfaced (nothing fabricated).

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 12:13:48 -07:00
z 7add862ca6 billing: metering client honors METERING_TEST (safe test-mode canary/staging)
buildMeteringClient ignored the documented METERING_TEST env, so the metering client
was ALWAYS live (c.test=false) — a staging/canary could not route debits to the
sandbox books, and the usage-cap smoke would have moved real money. Now METERING_TEST=true
sets Config.Test, so fin.RecordUsage writes the TEST finance books and the cap read
(org.TestMode via SQUARE_ENVIRONMENT=sandbox) sees the SAME test books. Unset in prod
= live, unchanged.
2026-07-18 12:13:31 -07:00
z 684e447943 cap: enforce + alert on the FINANCE ledger (where the unified binary records usage)
The spend cap read commerce's transaction store, which the co-resident cloud binary
leaves EMPTY (usage is recorded via fin.RecordUsage on the finance ledger) — so in
prod the cap summed 0 and never enforced, and the alert never fired. This wires the
cap onto the ledger prod actually writes, ORG-WIDE (the finance Entry carries no
scope; per-scope is a follow-up):

- sqlstore.SumByKindSince: additive read-only aggregate (kind + created_at index,
  18-decimal TEXT folded in Go) — no Entry schema change.
- finance.SumUsageSince: the org's metered usage (cents) since a cutoff, deposits
  excluded, sandbox books for a test org.
- finance.SetUsageHook: dependency-inverted post-debit seam (finance never imports
  commerce) the cap alert fires through.
- apps/commerce.go: SetPeriodSpendReader(financePeriodSpend) so AuthorizeSpendCap
  reads finance spend since the UTC month start, and SetUsageHook(fireCapAlert) so a
  finance debit fires the org's spend-alerts on the same crossing.

Composes the commerce policy/CRUD/promo/admin/ancestor-fix (commerce
v1.49.1->v1.49.2 injection seam) — a targeted host re-wire, not a redo.
2026-07-18 12:07:10 -07:00
hanzo-dev 4e0f4c87ca deps: commerce v1.49.0->v1.49.1 — real subscription tier derivation
Completes the Enso per-tier gate: ai v1.824.1 already enforces min_tier at the
family pipe + auto-router; this bumps the co-resident commerce so /v1/billing/tier
returns the caller's REAL plan (was stubbed always-Free). Fail-open on uncertainty.
2026-07-18 11:43:21 -07:00
zandGitHub d3f60be7e6 merge(cloud): embed argo gitops-engine under /v1/deploy — reconcile + RED HIGH-1 prune fuse (inert: DEPLOY_ENGINE_ENABLED off) 2026-07-18 11:27:44 -07:00
hanzo-dev 4f9c09aa30 chore(deploy): go mod tidy after rebase onto main (union: main deps + gitops-engine v0.7.2 + k8s 0.35.3 staging) 2026-07-18 11:27:25 -07:00
hanzo-dev 3e34b7bd54 chore(cloud): vendor hanzoai/ai v1.824.1 — Enso flywheel boots from Mount
v1.824.1 boots StartRouterTrainer + StartRouterProbe from ai.Mount, so the flywheel
runs in the deployed (embedded-in-cloud) service, not just the standalone aid binary.
Both still self-gate on their env flags; universe sets ROUTER_TRAIN_ENABLED=1 to turn
training on. Also carries the retrain-timeline fix (retrains now count).

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 11:27:15 -07:00
hanzo-dev 3f279cbb19 deploy: prune-safety fuse (RED HIGH-1) on the engine reconcile
Five guards before any deletion: (i) refuse an empty desired set; (ii) dry-run
sizes the prune set + a count/ratio fuse (DEPLOY_ENGINE_PRUNE_MAX default 10,
_RATIO default 0.20) refuses a mass prune; (iii) WithPruneConfirmed gates prune
on the fuse passing; (iv) PVC + KMSSecret are excluded from prune entirely (data
anchors, irreversible); (v) parseManifestDir walks recursively so a nested
manifest is never silently dropped (which prune would read as a deletion).
prune stays off by default (DEPLOY_ENGINE_PRUNE).
2026-07-18 11:26:02 -07:00
hanzo-dev 54b81309be deploy: pin gitops-engine to hanzoai/deploy/gitops-engine v0.7.2 (no replace)
Drops the filesystem replace => ../deploy/gitops-engine. The fork's engine module
was renamed to its real repo path (github.com/hanzoai/deploy/gitops-engine, tag
gitops-engine/v0.7.2) so cloud requires it as a normal pinned version — CI builds
the money binary with NO sibling checkout, NO argoproj alias. tidy + scoped build
green over SSH.
2026-07-18 11:26:02 -07:00
hanzo-dev 268e79369e deploy: embed argo gitops-engine in-process under /v1/deploy (reconcile half) 2026-07-18 11:26:02 -07:00
hanzo-dev f977650c30 ci(release): auto-promote the proven tag into universe crs/cloud.yaml
Every merge to main builds + smoke-tests + tags a proven image, but nothing
recorded that tag as the desired state Hanzo CD deploys, so api.hanzo.ai sat on
a stale pin (v1.801.71) while proven images (…72-…75) never rolled. The old
image-update.yml deploy hub was deleted in the Hanzo CD cutover; a direct CR
patch is reverted by ArgoCD selfHeal.

Add a promote job that, after the tag receipt, bumps spec.image.tag in
hanzoai/universe crs/cloud.yaml and commits deploy(cloud): <tag> — the SAME
yq-bump the hanzoai/ci reusable does for every other service. The universe-crs
ArgoCD Application (automated sync + selfHeal) then reconciles it to the cluster.
No hand-dispatch, no hand-edit.
2026-07-18 11:02:47 -07:00
bb021e49a7 fix(cloud): zen v1.4.1 (SSE body-close race) + thinking v0.1.1 (reasoning default)
Fixes empty streaming completions for zen5* (hanzo.app builder P0):
- zen v1.4.1 stops serve() from closing the upstream body before fiber's lazy
  SendStreamWriter drains it (every SSE completion was truncated to a 200 + empty body).
- thinking v0.1.1 makes glm-5.2/deepseek Off send reasoning_effort:none, so zen5-coder
  streams the answer immediately instead of a long silent content:null reasoning preamble.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 10:49:51 -07:00
z bca49760b1 admin+metering: SuperAdmin usage-cap + promo control plane; fix net/http spend_cap→402
Adds the /v1/admin control plane admin.hanzo.ai drives, twinning /v1/admin/flags:
  - /v1/admin/promos (GET/PUT, core.Guard SuperAdmin) → commerce /v1/platform/promo:
    configure the admin-controlled plan promo (percentOff/start/end/plans/active).
  - /v1/admin/spend-caps (GET/POST/PATCH/DELETE, core.GuardScoped) → commerce
    /v1/billing/spend-alerts with X-Org-Id: oversee/override ANY org’s usage caps
    (SuperAdmin via ?org=; a scoped admin hard-pinned to their own — the escalation
    line). Reuses the customer’s OWN spend-alert rows, no parallel model.
commerce.Forward is the ONE service-token seam these ride, relaying commerce’s own
status so a 400/403/404 surfaces honestly instead of masking as success.

Fixes clients/metering/middleware.go defaultOnDenied: a FUNDED caller over a
per-scope spend cap now maps to a DISTINCT 402 spend_cap_exceeded (errors.Is), not
the 503 it fell through to — parity with the zip-native denyVerdict, so any product
on the net/http middleware surfaces the same honest verdict.
2026-07-18 09:56:58 -07:00
hanzo-dev f470bcab93 build(deps): bump embedded luxfi/kms v1.11.8 -> v1.12.4
Brings the ACTIVE Hanzo KMS custody plane (api.hanzo.ai/v1/kms/*, served by the
cloud-embedded luxfi/kms per HIP-0106) to the latest v1.x. keys (v1.4.1) and
crypto (v1.20.2) already latest; luxfi/mpc stays out of the graph (threshold
signing is a wire-coupled external daemon, not a linked module). v1.12.4 verifies
against the public sumdb; clients/kms + cmd/cloud build green.
2026-07-18 09:52:56 -07:00
z d31cd1cde6 fix(cloud/fleet): never surface a GPU slug's VRAM as system RAM
toMachineView's memGB fallback read the integer before "gb" out of any
size slug. On a DO GPU droplet the slug's gb is VRAM (gpu-h100x8-640gb ->
640 GB VRAM), not system RAM, so a GPU node missing its upstream memSize
would render 640 GB of "system memory" -- a misleading number.

Guard the fallback with the GPU check already needed for v.GPU: reuse the
single gpuSpecOf(slug) call (spec, isGpu) and apply the slug's gb figure
only when !isGpu. Real m.MemSize still takes precedence for every provider,
GPU nodes included, so a GPU machine that reports its true RAM is
unaffected -- only the VRAM-as-RAM fallback is suppressed.

Table tests: a gpu-h100x8-640gb slug with empty MemSize yields Mem=="" (not
"640 GB") while still resolving GPU=="H100", and the same slug with a real
MemSize=="1920gb" reports "1920 GB".

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 09:43:47 -07:00
z 2527b4957e fix(cloud/fleet): map system memory + parse DO size-slug vCPU/RAM
The fleet view (world.hanzo.ai cloud variant) renders machines from
cloud's /v1/machines -> listMachines -> toMachineView. Two honest-data
gaps left system RAM and DigitalOcean vCPU counts blank:

1. System memory was never surfaced: machineView had no memory field and
   toMachineView never read the upstream memSize, so every provider's RAM
   column rendered empty.
2. DO vCPU was dropped: toMachineView filled vcpu only when CpuSize parsed
   as a bare integer, but DigitalOcean reports size SLUGS (s-4vcpu-8gb),
   so strconv.Atoi failed and vCPU showed nothing.

Fix:
- Add MemSize to visorMachine (upstream already sends it; it was simply
  unmapped) and Mem to machineView.
- parseSizeSlug pulls the integer before "vcpu" and the integer before
  "gb" out of a size slug (s-4vcpu-8gb -> 4,8; g-8vcpu-32gb -> 8,32).
- normalizeMem renders "N GB" only for trustworthy inputs (explicit
  gb/gib, explicit mb converted with rounding, a bare integer as MB when
  >=1024 else GB) and returns "" for anything ambiguous -- never a
  fabricated number.
- toMachineView keeps the Atoi(CpuSize) path and falls back to the slug's
  vcpu; sets Mem from normalizeMem(MemSize) and falls back to the slug's
  gb figure. The GPU-spec logic is unchanged.

Table tests cover the slug parser, the mem rounding, and the mapper
precedence (explicit values win, honest omission when neither yields one).

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 09:37:45 -07:00
zandGitHub 22a268cd29 Merge feat/code-normalized-continue into main 2026-07-18 09:30:51 -07:00
antje 6012f52b6a gpu worker: collect SaveGLB outputs — 3D meshes travel back to the library
collectOutputs read only the 'images' key, but SaveGLB publishes under '3d', so a
generated .glb never mirrored to the org library. Gather every saver's outputs
(images + 3d), so the studio 3D lane's mesh lands like an image or video render.
2026-07-18 09:27:56 -07:00
hanzo-dev cf663ec015 feat(code): normalize continue across harnesses 2026-07-18 09:26:22 -07:00
hanzo-dev 5e75f25b45 integrations: Cloudflare OAuth connect path alongside apikey (same KMS coordinate)
The cloudflare provider now offers a browser OAuth path in addition to the
shipped apikey path. /connect dispatches by request: a "token" key in the body
seals via apikey (verify-before-store, unchanged); its absence starts the
Authorization Code flow (confidential client, client_secret, no PKCE — the
framework's OAuth pattern) and the exchanged access token is sealed to the SAME
KMS coordinate (/orgs/{org}/integrations/cloudflare/api_token), so the DNS
provider layer is auth-method-agnostic.

Framework: connect dispatch is now capability-based (Verify and/or Authorize)
rather than Kind-only; Mount validates RedirectPath for any OAuth-capable
provider; bodyHasCredential picks the path. The OAuth leg gates on its own app
creds (Creds().ClientID) so a missing Cloudflare OAuth app degrades to an honest
503 without breaking the always-available apikey path.

Requires a registered Cloudflare OAuth app: CLOUDFLARE_OAUTH_CLIENT_ID/SECRET in
env, redirect https://api.hanzo.ai/v1/integrations/cloudflare/callback.
2026-07-18 09:14:07 -07:00
zeekayandClaude Opus 4.8 ca9c4682dd rebuild(cloud): embed console-embed@sha-bd8a816 (casibase auth /v1/* fix + console per-project resources) + activate MCP builtin tool-plane (#292)
No Go change — this rebuild re-resolves the freshly-republished console-embed:latest
(console main bd8a81651) into the go:embed console served at console.hanzo.ai, and
ships builtin.go's auto '/v1 route → MCP tool' plane at /v1/tools/mcp (already in main,
newer than the deployed v1.801.69).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 08:57:15 -07:00
hanzo-dev ceba27fbbf feat(connectors): OAuth/apikey connector plane (Anthropic, OpenAI, Copilot, device flow, refresh)
Preserve divergent connector work: verify-before-store connector types
(Anthropic, OpenAI, Copilot), device-authorization flow, token refresh,
connector registry/store, and integration/store/link plumbing.
2026-07-18 01:26:55 -07:00
hanzo-dev 4cdd2f2558 feat(channels): unified inbound channel ingest plane (envelope, pairing, policy, per-platform adapters)
Preserve divergent channel-ingest work: clients/channels package (envelope
normalization, pairing, delivery policy, Slack/Discord/Teams/Telegram
adapters, store), cmd/channels entrypoint, integration event-emit hooks,
and apps wiring.
2026-07-18 01:26:09 -07:00
hanzo-dev 8eabff4d44 fix(kms): unshadow the bare secrets-list route (/secrets/+ not /secrets/*)
The value routes registered the optional-greedy wildcard `/secrets/*`, which
fiber also matches with an empty tail — so the bare `GET .../secrets` list path
was answered by getSecret (400 "secret name is required") and listSecrets was
unreachable. Switch the getSecret/deleteSecret value routes to the required-
greedy `+` (one-or-more), so `/secrets` falls through to the exact list route
while `/secrets/<path>/<name>` still reads/deletes. reqWildcard reads the `+`
param.

Regression test list_route_test.go asserts the bare list path returns 200 with
a secrets array (was 400) and that value reads still work.
2026-07-18 01:19:23 -07:00
hanzo-dev 6cde53fb05 integrations: Cloudflare apikey connector (verify-before-seal, org-admin gated)
Register Cloudflare as an apikey-kind connector on the /v1/integrations plane.
A customer-supplied scoped API token is verified live against Cloudflare's
GET /user/tokens/verify (must be status:active) before it is sealed into the
org's KMS namespace (/orgs/{org}/integrations/cloudflare/api_token); the
connection row holds only non-secret account metadata. connect/verify/disconnect
are org-admin gated from the validated principal (principal.IsOrgAdmin).

Extends the connector framework with the apikey credential seam shared by future
customer-credential providers: Provider.Kind/AdminOnly/Verify, VerifyInput,
connectByCredential (verify-before-seal, fail-closed), and the verify route.

Serves POST /v1/integrations/cloudflare/{connect,verify,disconnect} and
GET /v1/integrations.
2026-07-18 00:07:53 -07:00
z 9560169625 Merge feat/route-work-to-target: route coding run to chosen target machine 2026-07-17 23:32:54 -07:00
hanzo-dev 4fe61262a9 coding: verify + PR + close the session when a routed run completes
A routed run's machine pushes with its own credential and streams into the
session, but cloud still owns the completion — the integrity gate, the PR row, and
the session's terminal state (the machine never closes the session, so it was
staying "running" forever). Give a routed run the SAME cloud-side completion the
local keystone path runs after a sandbox push.

- completeChanged: the shared terminal for a run that reported changes — VerifyRef
  the pushed branch LANDED (fail-closed to a session error + no PR if absent), file
  the native PR, mirror done, close the session done. The local path (Run) now calls
  it too, so the two paths cannot drift.
- finalizeRouted: maps a machine's terminal report onto that completion — reported
  failure closes the session error (no PR), no-changes closes done (no PR), a changed
  push runs completeChanged. No secret crosses; cloud only reads the ref it can see.
- DeliverRoutedRunActivity runs the completion once, after a real report, on a
  cancel-immune bounded context, so a completed run is never re-executed by a retry.
  The completion seam is injected at the composition root (NewDispatcher), the same
  injected-seam shape index_on_push uses, so the free-function activity reaches the
  dispatcher's git/tracker/session seams without a global Dispatcher.
- RoutedRun carries Actor + AgentRef (cloud-side only, never sent to the machine) so
  the completion attributes the session close and files the PR with the right
  assignee.

Also document the mailbox's single-replica dependency at its definition (accepted,
inherited from cloud's KMS-lock replicas:1) with a future replica-aware note.

Tests: routed changed+verify -> PR filed + session done; verify fails -> no PR +
session error; no changes -> done no PR; reported error -> error no PR (verify never
runs); NewDispatcher wires the seam; the durable type bridge preserves attribution.
2026-07-17 23:13:07 -07:00
z 4ee67c1797 Merge feat/kms-reseal-migration: CR-driven KMS re-seal migration tool (#79) 2026-07-17 23:10:37 -07:00
2bb35ac291 auth: accept admin-console audience in the cloud JWT allowlist (#332)
The cloud already trusts hanzo-admin-guard (the admin surface) but not admin-console
(the admin console's own OIDC client), so a SuperAdmin token minted via admin-console
was rejected on /v1/admin with 'invalid audience' — forcing an awkward hanzo-admin-guard
detour. Add admin-console so the admin console's tokens work directly, matching
GATEWAY_ALLOWED_AUDIENCES which already lists it.

Co-authored-by: zeekay <z@hanzo.ai>
2026-07-17 23:03:19 -07:00
hanzo-dev 0782431509 docs: open cloud planes blueprint (HIP-0129)
Plane map with honest tiers in LLM.md: /v1/connectors custody (in flight), /v1/channels transport (planned, branch reserved), shipped planes named by package. Spec home HIP-0129; roadmap P1-P15 lives there.
2026-07-17 22:49:44 -07:00
zeekayandz e2929b82b0 gateway: nest clients/gatewaypolicy → clients/gateway/edge (kill the compound)
"gatewaypolicy" is a compound (gateway+policy) and read as a second gateway
package. It is the ONE Gateway concern with clients/gateway — the per-org edge
policy STORE (OrgRPM ceiling + CORS + cache) that the /v1/gateway/config plane and
the package-cloud edge middleware both read.

They are two packages only to break a Go import cycle: clients/gateway imports root
cloud (cloud.Deps), and middleware_edge.go IS package cloud — so the store must be a
LEAF both can import. A flat merge cycles. Fix per the no-compound law: nest the leaf
UNDER gateway as clients/gateway/edge (edge.Policy/Store/New). One gateway namespace;
/v1/gateway/config surface unchanged; edge-middleware logic unchanged.

NOT redundant with the external hanzoai/gateway (KrakenD): that does coarse per-route
edge rate-limit + auth at ingress; this is per-AUTHENTICATED-org RPM (needs the decoded
token org), an app-level ceiling the edge proxy cannot compute. Different layer.

Pure rename (49/49, no logic change); full cmd/cloud binary links; gateway + gateway/edge
tests pass; gofmt/vet clean.
2026-07-17 22:47:51 -07:00
d19f1d9066 flags: runtime flags resolve from /v1/flags only — drop redundant env gates (#331)
waitlist_* / public_signup / gateway_* are runtime flags; strip their Env: fallbacks so
they resolve from the /v1/flags DB engine → Default (single source of truth, flipped live,
no redeploy). Boot-time ReadOnly rows (subsystem_*, network_id_*) keep Env — that IS their
boot mechanism. Nothing read these env vars outside the flags engine (verified).

Co-authored-by: zeekay <z@hanzo.ai>
2026-07-17 22:46:51 -07:00
hanzo-dev 7241bc952e agents/integrations: reach routed dispatch from the Slack trigger
Wire the load-bearing trigger so a coding run can be dispatched to a chosen
linked machine end-to-end. Extend the Slack coding grammar with an optional
routing prefix — code: <repo> on <machine> <task> — resolve <machine> org-scoped
(id or friendly label) and set Req.TargetID. An unknown or foreign machine is an
honest error, never a silent local fallback; an untargeted request is byte-
unchanged (repo <task>).

- agents.ResolveTarget: the ONE org-scoped id-or-label resolver, fail-closed, so a
  trigger surface turns 'on evo' into a target id without leaking another tenant's
  inventory.
- slack_coding: parseCoding yields (repo, target, task); a routed run skips the KMS
  agent-credential fetch (the machine authenticates with its own credential); the
  ack + result card report a routed run as queued-on-<machine>, followed live in
  mission-control, not a premature branch-pushed verdict.

Tests: ResolveTarget id/label precedence + cross-org not-found + unmounted fail-
closed; parseCoding on-prefix grammar (routing only when 'on' is the token after
the repo; 'on-call'/'only' untouched); routed result card is queued not done.
2026-07-17 22:42:59 -07:00
z 8a50e0d335 Merge fix/consensus-bump: luxfi/consensus v1.36.9 (unbreak force-moved checksum) 2026-07-17 22:33:25 -07:00
zeekayandClaude Opus 4.8 1484745a01 build(deps): bump ai → v1.822.3 — drop the last WqyJh audio-fork edges
cloud transitively pulled github.com/WqyJh/{go-cosyvoice,go-openai-realtime}
through ai v1.822.2 (the go-openai-fork release, which predated ai's TTS
switch to the hanzo-owned forks). ai v1.822.3 wires ai/tts onto
github.com/hanzoai/go-cosyvoice + go-openai-realtime, so tidy drops both WqyJh
edges from cloud's graph. cloud now pulls ZERO third-party OpenAI-lineage:
WqyJh 0, sashabaranov 0, ClickHouse 0. Single datastore sql registrant
(hanzo-ds/go). Builds clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 22:29:42 -07:00
hanzo-blue 9dc3038051 docs(kmsreseal): runbook SCOPE = exact per-host dry-run counts (#79) 2026-07-17 22:26:48 -07:00
hanzo-blue a06b5fb1a2 docs(kmsreseal): operator LLM.md — subcommands, dry-run findings, gated cutover (#79)
4-host map, G1 delta (78-union), G5 boot-cycle verdict (safe: direct master key),
seeding wedge-risk prerequisite. Dry-run only; cutover CTO-gated.
2026-07-17 22:25:33 -07:00
z 675809dcdf feat(admin): fleet-aggregate billing endpoints (metrics + invoices + subscriptions)
admin.hanzo.ai's SaaS-metrics/Invoices/Subscriptions pages were placeholders
awaiting cross-org /v1/admin/* endpoints. Add them as super-admin (core.Guard)
fleet-aggregate readers over the existing commerce billing engine:

  GET /v1/admin/metrics        — SaaS god-view: MRR/ARR/net-new/churn/active-subs/
                                 paying-customers/plan-mix/top-customers/recent.
                                 Single S2S proxy — commerce /v1/metrics/saas is
                                 already a cross-org aggregate (same gate finance
                                 Costs uses: RequirePlatformAdmin→IsServiceToken).
  GET /v1/admin/invoices       — cross-org invoice list; fan core.ListOrgs out
  GET /v1/admin/subscriptions  — cross-org subscription list; per-tenant reads
                                 merged (identical to revenue.go's fan-out).

Honest degradation: a failed per-org read contributes no rows, never fabricated.
go build ./clients/admin/... green, gofmt clean. Pairs with admin operator UI
(feat/admin-billing-fleet-ui).
2026-07-17 22:23:47 -07:00
zeekayandClaude Opus 4.8 f43883d34e refactor(go-openai): import the hanzoai/go-openai fork directly, drop the replace
cloud/clients{,/agent} used sashabaranov/go-openai only via a 'replace =>
hanzoai/go-openai' — which does not propagate to cloud's own consumers, so
gateway/iam/etc. each had to copy it. Now the fork declares its own module
path (github.com/hanzoai/go-openai v1.41.0): require it directly. Bumps the
lockstep fork adopters — ai v1.822.2, agent v0.1.3 — so the hz.Mount Completer
boundary shares ONE openai type set (was a hanzoai-vs-sashabaranov type
mismatch). No replace anywhere; upstream sashabaranov remains only as an
indirect dep of the go-cosyvoice TTS chain (owned next). cmd/cloud keeps its
single datastore sql registrant (hanzo-ds/go). Builds clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 22:11:47 -07:00
zandGitHub 74fe777aeb Merge: cloud S3 client minio-go → hanzoai/s3-go (the one house S3 client)
7 files repoint minio-go v7 → hanzoai/s3-go (a byte-identical fork, package
minio, zero call-site churn); minio-go demoted to indirect. Red-verified:
presign + conditional-CAS byte-identical to the prior v7.0.100 dep, drop-in.
Deploy-gate: live SeaweedFS CAS smoke (If-Match/If-None-Match/412).
2026-07-17 22:05:03 -07:00
hanzo-blue 9963b3d055 feat(kmsreseal): CR-driven KMS re-seal migration tool (#79)
Embeds the fleet KMS into cloud by re-sealing the ~125 KMSSecret-referenced
secrets from the legacy standalone (unsealed at rest) into cloud's embedded
/v1/kms (AES-256-GCM sealed per secret). Driven by the KMSSecret CRs — the
authoritative (org,path,env,key) manifest — not a raw store copy.

- inventory: pure CR parse/validate/dedup; handles explicit keys, folder-sync
  (empty keys[]), the env-default divergence (cloud refuses empty env), and
  malformed CRs (fail-loud, never silently dropped). Reuses cloud/clients/kms
  ValidSegment/ValidSubpath so a coordinate the store would reject is never built.
- reseal: per-CR org-bound auth (owner==projectSlug), GET standalone -> POST cloud
  (cloud seals). Idempotent upserts, re-runnable. Plaintext transits memory only,
  wiped after write; results carry coordinates + status, never values.
- verify: read-only SHA-256 hash-compare standalone-vs-cloud + org-isolation matrix
  (cross-org 403, no-principal 403) against the real cloud guard.
- preflight: cloud /v1/kms reachability + JWT-validation probes + offline G1 delta.
- runbook: the ordered, rollback-safe cutover (standalone stays read-only).

Tests: round-trip against cloud's REAL embedded /v1/kms in-process (real seal,
real guard) via a zip app.Fiber().Test transport adapter; seal-proof (no plaintext
on disk); wrong-org refusal; folder-sync via LIST; hash-mismatch detection;
isolation matrix. go test ./cmd/kmsreseal green.
2026-07-17 22:03:16 -07:00
zeekay e8fa9d6f18 policy: kill /v1/featuregate/mode alias + rename featuregate → admission
featuregate READ like a synonym for flags — the source of the "isn't this the
same thing?" confusion. It is not: flags is the Policy decide-ENGINE (/v1/flags);
this package is the request-ADMISSION gate that composes it one-way (host→service
registry + waitlist.<svc> mode read + Enforce middleware + IAM approval check).
Renamed to `admission` — the precise systems term for policy-gating requests
(k8s-style admission control) — which also dodges the gate/gateway/gatewaypolicy
name cluster. flags stays THE engine; admission is a thin one-way consumer.

Also kills the /v1/featuregate/mode compat alias entirely (route + Enforce exempt
entry + test): one route, /v1/flags/waitlist. No shim, no adaptor, no backwards
compat — per the one-and-only-one-way law.

flags engine surface unchanged. Build/vet green; admission tests + apps frozen-Wire
order test pass (admission holds featuregate's slot). Deeper Policy collapse
(authz/entitlements/gatewaypolicy → one engine) is a separate staged HIP-0127 pass.
2026-07-17 21:26:28 -07:00
hanzo-dev 18289d0eb6 agents/coding: route a coding run to a chosen target machine
When a coding run carries a targetId (a registered /v1/agents/targets
machine), enqueue it as a durable task addressed to that target on the ONE
embedded tasks engine instead of running it in the cloud sandbox. No target
keeps the local sandbox path byte-unchanged.

- mailbox: an in-process rendezvous between the durable RoutedRunWorkflow and
  the external machine that claims a run over HTTP; tenant + machine isolation
  is a property of the (org,target) key, not a check a caller can skip.
- routing: per-target claim key (a capability, stored only as a SHA-256 hash,
  constant-time verified) is the machine identity; a fail-closed liveness gate
  (online + a live runner) is the dispatch admission.
- claim/report HTTP surface (org bearer + X-Target-Key) lets a machine claim
  and complete only runs addressed to it.
- coding.Dispatcher gains a routed branch: open the session on the target,
  enqueue the durable RoutedRunWorkflow (no secret in the payload — the machine
  authenticates with its own credential), return queued; fail closed on an
  unavailable target or a failed enqueue, never fall back to local.

Tests: dispatch-to-target, no-target-local-unchanged, dead-target-fail-closed,
cross-machine/cross-org claim denied, mailbox isolation + claim race.
2026-07-17 21:26:04 -07:00
hanzo-dev fac1cf9aa3 Route the S3 object plane through the hanzoai/s3-go client
Swap the S3 client in the 7 direct importers from github.com/minio/minio-go/v7
to github.com/hanzoai/s3-go (package minio; a minio-go v7.0.98 fork). Drop-in:
same package name and same New/Client/Options, {Get,Put,List,MakeBucket,
RemoveObject,RemoveObjects}Options, ObjectInfo/Object/ErrorResponse surface and
credentials.NewStaticV4; conditional-CAS (SetMatchETag/SetMatchETagExcept) and
presign paths unchanged.

minio-go leaves the direct requires and stays indirect (luxfi/zapdb via
clients/kms). Run go mod tidy after the s3-go v1.0.0 tag is published to
populate go.sum.
2026-07-17 21:24:02 -07:00
antje f7ded021ec gpu worker: a claimed job survives the engine recycle window
The supervisor recycles at queue-idle, but a freshly CLAIMED job is invisible
to the engine queue until its graph is submitted — so recycles fired over the
claim-to-submit window and staging failed on a dead engine, consuming the job
(observed twice in prod, seconds apart). Two invariants close it: a staging
latch the supervisor honors before recycling, and waitEngine() so a job
claimed while a recycle is already mid-flight waits out the restart instead
of dying on connection-refused.
2026-07-17 20:28:12 -07:00
z 7136fff969 feat(link): server-side failover router over linked accounts + per-account usage
Add the execution half of route.go's redundancy seam: a Router that routes a
signed-in caller's inference through one of their OWN linked provider accounts,
resolves that account's KMS-sealed credential, and cycles to the next in-org
account on a live 429 — never falling back to a platform key or crossing the
tenant boundary. Meters each served call per (org, provider, profile) and
exposes the per-account breakdown at /v1/billing/usage/accounts (+ the canonical
/v1/links/usage/accounts).

- resolver.go: the credential-fetch contract consumed (Resolver seam) + a
  KMS-backed impl keyed at orgs/<org>/providers/<provider>/<profile>; Credential
  redacts under every fmt verb, resolves only within one org's namespace, and
  never falls back to a platform key.
- select.go: the non-secret account selector (openclaw Model@provider:profile,
  X-Provider-Account header, session pin) — carries no org/subject.
- router.go: in-org candidate selection (Links.ListLinked only), cycle-on-429,
  fail-secure, per-account cooldown; PolicyPlan|MostRemaining|RoundRobin.
- routed.go + meter.go: a summing per-account usage counter beside the Links +
  the meter that bills api-key accounts via commerce and leaves subscriptions
  plan-paid (BillingMode), so a call is metered once per meaning.
- carrier.go + wire.go: a process-local credential carrier (never serialized) +
  the Deps composition root and the AIClient upstream adapter. Inert until called.

Tests prove routing through a linked account, in-org cycling on 429, the
cross-org isolation boundary (against the real store), fail-secure with no
platform-key fallback, and that a credential never reaches a log or error.
2026-07-17 19:19:33 -07:00
zeekay dbc4966aeb build(iam2): bump v0.15.4 → v0.16.0 (argon2id SOTA password hashing)
Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 17:35:27 -07:00
z d38984f03f refactor(usage): unify account-usage onto the ONE /v1/usage surface
The account-usage plane (7456318) wrongly opened a SECOND usage surface inside
clients/link (/v1/links/usage). Move it into clients/usage so usage owns ALL
usage and link owns links and nothing usage — one surface, orthogonal, one window.

Moves (package link -> usage): sample.go (the Sample value + Sanitize), datastore.go
(the hanzo.account_usage warehouse series + reads, now behind a `warehouse` type that
holds only the DDL latch over aiobject's shared datastore — no handle, so usage keeps
NO Shutdown), and the record/samples handlers (account.go). Reconciled with the usage
subsystem: cloudUsageTable -> the existing llmTable, dsTime -> the existing tsLiteral,
duplicate aString -> dsString.

Route table (was /v1/links/usage*):
  POST /v1/usage           record account-usage samples (the collector)
  GET  /v1/usage/samples   one provider account's own lane dash (time series)
  GET  /v1/usage/summary   THE one summary — merged (see below)
  GET  /v1/usage/analytics{,/access}  unchanged

Summary collision resolved by MERGE, not two endpoints: the account-usage global view
folds into the existing /v1/usage/summary as a labelled `accounts` block beside spend +
LLM, over ONE window (aiobject.ResolveCloudUsageWindow drives both). Nothing dropped —
the caller's own linked-account rows AND the org Hanzo-routed rows both ride the one
summary, each side reporting its own availability, never summed.

Decomplected the Link-refresh: reportUsage braided a warehouse write with a Link
upsert, and since POST /v1/links already sets an account's usage snapshot, the sample
-> snapshot path was a SECOND way to do that. record now records usage only; the link
registry stays link's own concern. Drops the 3 Link-registry tests (they exercised
/v1/links, unreachable in a usage-only mount) and the Link half of 2 more; the warehouse
+ value coverage moves intact. No back-compat alias (the route was hours old).

Wire guard unchanged: link keeps its Shutdown (SQLite store), usage keeps none.
2026-07-17 16:53:17 -07:00
hanzo-dev 0aa853f300 fix(iam-edge): forward the public sign-in surface before the tenant gate
console.hanzo.ai is served one-binary off cloud, so its /v1/iam/* calls hit the
iam_edge — which required a validated org for EVERY route. That 401'd
'sign in to continue' on the sign-in routes themselves (get-app-login, login,
oauth token exchange), a chicken-and-egg that bricked console login (the
'unknown iam route' / 'sign in to continue' users saw). Forward the
unauthenticated-by-design sign-in surface (login-page config, credential submit,
signin/signup, captcha/verification aids, the OAuth token endpoint, OIDC
discovery) straight to IAM BEFORE the org gate. Tenant CRUD + org metadata stay
fully gated — no tenant-data route is opened. Test: TestIamEdgePublic.
2026-07-17 16:50:29 -07:00
hanzo-dev 62068b2d1e feat(integrations): Cloudflare apikey connector (verify-before-store, KMS custody, org-admin gate) 2026-07-17 16:14:21 -07:00
hanzo-dev 610dcc166d wip(fleet-samples plane (clients/samples + /v1/fleet board)): rescued from agent that hit the session limit
Committed as-is to preserve the work (the building agent died mid-verify).
Not yet built/tested green; NOT merged to main. Resume from here.
2026-07-17 16:04:56 -07:00
zeekay 26a1910bca chore: trigger release build for iam2 v0.15.4 (federation fix)
d411200 (iam2 v0.15.1→v0.15.4 bump) did not trigger a release run; nudge
the push-triggered release so the federation-security-fixed image ships.
2026-07-17 15:21:10 -07:00
zeekay d411200359 build(iam2): bump v0.15.1 → v0.15.4 (federation SuperAdmin-mint CRITICAL fix)
v0.15.4 closes the red-team CRITICAL in the federation broker: authorize
Application.Organization on write + reserved-org guard in federation
link/provision (was: social login could mint a SuperAdmin / take over a
cross-tenant account) + SSRF IP filter. Required before the hanzo.id social
cutover. Build pipeline healthy (consensus v1.36.3).

Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:03:48 -07:00
hanzo-dev 071174a414 Merge: surface the iam2 canary in /v1/flags 2026-07-17 12:39:04 -07:00
z bc98da0308 flags: surface the iam2 canary in /v1/flags
A read-only subsystem_iam2_active switch on the platform panel, mirroring
subsystem_iam_active, makes the clean-room iam2 selection visible in the /v1/flags
cockpit. The selector stays ONE thing — CLOUD_IAM_IMPL=iam2 at boot, applied on
the next reconcile — this switch reflects it, it does not add a second control.
Its description names the gate: the IAM cutover parity suite
(universe e2e/50-iam-cutover-parity) must be green against the iam2 shadow before
the canary is flipped.
2026-07-17 12:38:47 -07:00
hanzo-dev 7456318242 feat(account-usage): clients/link usage plane — samples, datastore series, /v1/links/usage
The account-usage plane over clients/link: a Sample value (one metering lane of
one provider account at one instant), a ReplacingMergeTree warehouse projection
(hanzo.account_usage + a dedup-preserving daily rollup MV) read back with explicit
read-time argMax dedup, and the /v1/links/usage surface — report samples, a
per-provider dash, and a global summary that sets a user's own linked-account plan
usage beside the org's Hanzo-routed cost of record, every row labelled by
source/scope/confidence and never summed together.

A windowless sample (a valid window class with no meter-reported duration or
reset) keys its class's nominal bucket, never the zero instant: every ranged read
filters window_start into [from,to) and the TTL drops epoch rows on arrival, so a
zero-keyed row would be written-but-never-read and would silently drop out of the
summary. Re-polls of a windowless counter collapse onto that one nominal instance
(ReplacingMergeTree by ts), so it is one row per lane, never summed across polls —
reconciling the two window-instance tests the rescued WIP left in contradiction.
2026-07-17 12:31:12 -07:00
hanzo-dev d9a20e2798 fix(identity): mint X-Billing-Account-Id from the claim, never from the client
The header was captured from client input and re-injected verbatim for any
validated principal. That was defensible while it was a mere attribution hint
no debit ever read — the comment said as much. It is not one anymore:
ai/object.Payer now resolves the PAYING account from it, so forwarding the
client's copy would let a caller name its own payer, which is the whole thing
the claim exists to prevent. A signup-org member could have sent
`X-Billing-Account-Id: org:hanzo` and pointed their spend at the shared pool.

It is now minted from the validated `billing_account` claim
(idClaims.mintedBillingAccount), mirroring iamauth.Claims.MintedBillingAccount
byte-for-byte, so the in-binary path binds what the gateway would and both
resolve one payer. The raw client copy is deleted on ingress and not restored.

The console read and the top-up now hand Payer that same claim, so the balance a
member SEES, the account a top-up FUNDS, and the account the ai gate DEBITS are
one wallet. Feeding Payer a different credential per call site is the modern
shape of the old org-vs-"org/user" split: a funded balance the gate refuses.

Tests drive real signed tokens through the boundary: the claim reaches the
header for person/org/project, a forged copy never survives (even on a token
that carries no claim, where a restored copy would be the only value present),
and an anonymous caller carries no payer at all.
2026-07-17 12:27:34 -07:00
z 36231f57e2 refactor(flags,featuregate): decomplect the waitlist host-gate out of the flag engine
flags is now the PURE (Principal,context)->verdict engine: Register/Bool/Int/
String/Board/SetPlatformSwitch/Defs + /v1/flags/* + native evaluator + the
platform-switch seed. ZERO host->service / ModeForHost / waitlist.<svc> /
mode-route knowledge.

The complete launch waitlist-gate feature moves to clients/featuregate, which
COMPOSES flags one-way (flags.Bool/Register/SetPlatformSwitch/Def/Defs; flags
never imports featuregate):
- flags/waitlist_store.go -> featuregate/registry.go (host->service map)
- flags/waitlist.go -> featuregate/waitlist.go (mode decide + admin funcs +
  seed + waitlist.<svc> Def registration + Mount/Shutdown + the mode route)
- flags/waitlist_store_test.go -> featuregate/registry_test.go
- the registry OrgStore handle (was flags.Client.registry) is now featuregate
  package state, opened in featuregate.Mount, closed in Shutdown
- Enforce default gate is now the LOCAL WaitlistModeForHost
- /v1/flags/waitlist AND /v1/featuregate/mode compat alias served by
  featuregate (route name unchanged)
- apps.Wire re-adds featuregate after admin (after flags); wire_test frozen row
- admin/services.go swaps the flags import to featuregate for the board funcs
2026-07-17 12:06:06 -07:00
zeekay 4343cdc684 fix(flags): keep /v1/featuregate/mode as a TEMPORARY compat alias for /v1/flags/waitlist
The namespace collapse (75d6f36) renamed the live waitlist-mode read to
/v1/flags/waitlist and made /v1/featuregate/mode a 404 — correct per the one-namespace
Policy primitive, but a BREAKING change to a public route whose external callers cannot
be fully enumerated from the monorepo (a deployed frontend could still call the old
path). Per the hard "never goes down for any customer" constraint, ship the collapse
WITHOUT the break: /v1/flags/waitlist is canonical; /v1/featuregate/mode is a temporary
alias to the same handler; both exempt from the Enforce gate.

Delete the alias (this route + its exempt entry in featuregate/middleware.go) once every
caller is confirmed on /v1/flags/waitlist — a one-line follow-up, gated on the owner.

Verified: gofmt clean, go build/vet green, exempt-path test asserts BOTH routes ungated.
2026-07-17 11:24:57 -07:00
hanzo-dev 187473bd92 Merge rip/services-kind: read one workload kind (App), drop the Service shim
services.hanzo.ai is dead (0 Service CRs cluster-wide; the fleet is 100% App).
clients/paas, clients/deploy, and clients/platform drop the two-kind read shim
and read apps.hanzo.ai only. The paas deploy endpoint (and release seam) now
always refuse a git-declared App with 409, naming the universe git path to
commit the tag to.
2026-07-17 11:01:28 -07:00
hanzo-dev 9659d1a56f paas/deploy/platform: read one workload kind (App), drop the Service shim
The services.hanzo.ai kind is dead: zero Service CRs exist cluster-wide and
the whole fleet is apps.hanzo.ai (kind App). These three cluster-facing planes
carried a two-kind read shim (App first, Service fallback) that is no longer
reachable, so strip it and read one kind — App.

- clients/paas: drop servicesGVR + crGVRs(); listApps/getApp/observeFleet read
  appsGVR directly (no cross-kind dedup). The deploy endpoint now always refuses
  (409): every App CR in the platform namespaces is git-declared and reconciled
  by Hanzo CD with selfHeal, so a patch here is reverted — the response names the
  git path to commit the tag to. releaseService refuses on the same grounds.
- clients/deploy: drop servicesCRGVR + appCRGVRs() and the "hanzo.ai/Service"
  registry entry; health/getAppCR/listAppCRs read appsCRGVR directly. coreSvcGVR
  (the core/v1 Service child object) is unchanged.
- clients/platform: drop servicesGVR + crGVRs(); resolveCR/getCR/deleteService
  read and delete appsGVR only. Tenant apps are still written and patched as App
  CRs in tenant-<org>.

Tests updated to the one-kind reality. Builds/vets/gofmt clean; go.mod untouched.
2026-07-17 11:00:25 -07:00
zeekay 75d6f36639 flags: move the waitlist mode read /v1/featuregate/mode -> /v1/flags/waitlist (one namespace)
The guard's public waitlist-mode read now lives under /v1/flags (the flags engine
owns it) — there is NO /v1/featuregate HTTP endpoint. The route, the Enforce
exempt prefix, and the doc/prose comments move; the featuregate Go PACKAGE (native
Enforce middleware) is NOT renamed, and the /v1/admin/services board is unchanged.

- clients/flags/routes.go            GET /v1/featuregate/mode -> GET /v1/flags/waitlist
- clients/flags/waitlist.go          doc comments repointed
- clients/featuregate/middleware.go  defaultExemptPrefixes /v1/featuregate/ -> /v1/flags/waitlist
- clients/featuregate/middleware_test.go  exempt-path assertion updated
- apps/apps.go                       stale prose comment repointed

Verified green: go build ./clients/flags/... ./clients/featuregate/... ./apps/...,
go vet, and CGO_ENABLED=0 go test ./clients/featuregate/...
2026-07-17 10:57:25 -07:00
hanzo-dev dcd107b336 Merge: real semver only — ai v1.821.1 / iam v1.31.28 / luxfi from sumdb (kill pseudo-versions + force-moved-tag poison) 2026-07-17 10:48:06 -07:00
zandhanzo-dev 04a4d68118 Real semver across the board: ai v1.821.1, iam v1.31.28, luxfi from sumdb
Three coordinate-hygiene fixes so the pipeline resolves deterministically:
  - ai v1.820.0 -> v1.821.1. v1.820.0 pinned iam at an orphaned pseudo-version
    (a commit rebased out of existence); v1.821.1 pins the real iam tag v1.31.28.
  - iam -> v1.31.28, the real published tag; the pseudo-version and its replace
    are gone.
  - luxfi go.sum re-recorded from the immutable sum.golang.org via go mod tidy,
    so consensus/vm can no longer carry the hashes a force-moved git tag served.

Nothing but coordinates changed; ai v1.821.1 is v1.820.0's tree with one dep line
repinned, so the compiled result is identical to the shipped v1.801.49. Real
public semver only: no pseudo-versions, no replaces, no force-moved tags.
2026-07-17 10:47:19 -07:00
zeekay a773c9225a fix(release): fail-closed container-tag floor so an orphaned tag is never reused
The v1.801.50 tag collision: a run pushed :v1.801.50 then was cancelled after
imagetools-create but before its git tag (orphaned container tag). The Tag steps
container-tag floor (cont_max) was fail-OPEN — `gh api ... 2>/dev/null || true`
yields "" on any API error — so a later run did NOT see :v1.801.50, recomputed
the same number, and REASSIGNED :v1.801.50 to a different image: an ambiguous
mutable prod tag (silent flip on any fresh-node reschedule).

Fail-CLOSED: if the container-tag lookup ERRORS (vs legitimately empty), retry the
whole attempt instead of proceeding on a git-only floor that cannot see the orphan.
A version that already has a pushed image is now never reused.

NOT reordering git-tag before imagetools-create (the other candidate fix): that
reintroduces the phantom "tag exists, image does not" this workflow was built to
prevent. Pairs with the crane-mirror timeout (ed4d372) that stops the hang→cancel
which orphans tags in the first place. Compute-step cont_max left as-is (hint only).

[skip ci]
2026-07-17 10:29:03 -07:00
antjeandGitHub 50bbf3a64d supervisor: recycle only at queue-idle; busy is not dead (#329)
Recycling on each completed render killed long renders mid-sample when short
jobs shared the engine (observed: every direct render died within ~6 minutes
while probe jobs cycled). The recycle now defers until the queue is empty.
Health: an engine that answers /queue with work in it is alive however slowly
it answers /system_stats; restarts require three consecutive silent probes
with an idle or unreadable queue.
2026-07-17 02:48:12 -07:00
hanzo-dev 7685705165 fix(release): mirror LOGIN is best-effort too — a registry blip must not fail the release
The 'Mirror credential' step fail-safed only on a missing KMS token, not on the
docker-login to registry.hanzo.ai itself. A transient 502 from the mirror registry
(ingress blip; the registry was healthy 6m before and after) killed the whole
serialized release — no image, no tag — even though ghcr (the PRIMARY) was fine.
Both login paths now skip the mirror (MIRROR_OK unset) on failure and continue.
Complements ed4d372 (the crane-copy timeout): the mirror is now best-effort end to end.

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw
2026-07-17 02:26:28 -07:00
z ed4d37235f fix(release): bound the registry.hanzo.ai crane mirror with a timeout
An unbounded `crane copy` to registry.hanzo.ai can HANG (not just fail) — the
best-effort mirror once livelocked the Tag step and held the entire serialized
release lane (concurrency: release-cloud, cancel-in-progress:false), so no queued
release could run. A best-effort mirror must never be able to block the git-tag
receipt that follows it. `timeout 120` makes it truly best-effort.

[skip ci]
2026-07-17 02:06:48 -07:00
zeekay 0655cdb8cd build(iam2): bump v0.14.0 → v0.15.1 (federation + signing-key generation)
v0.15.0 adds the OIDC/OAuth2 social-federation broker (Google/GitHub);
v0.15.1 mints signing keys for keyless reserved-org certs so the embedded
iam2 publishes a JWKS and can sign tokens (shadow-canary finding). Carries
the full parity + RFC surface into the cloud image for the hanzo.id cutover.

Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 01:35:15 -07:00
hanzo-dev 7ff5862f42 build(deps): adopt luxfi/consensus v1.36.9 (unbreak force-moved v1.36.2 checksum)
luxfi/consensus v1.36.2 was force-repushed with different go.mod content, so
cloud's committed go.sum no longer matches and 'go mod download' aborts with a
SECURITY ERROR — breaking EVERY release. Same recurring luxfi force-move pattern
as c93ddf9 (keys). Bump to latest stable v1.36.9; clients/controlplane (only
importer) compiles clean, go mod verify passes.

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw
2026-07-17 01:27:58 -07:00
antjeandGitHub fceb34c1d2 render: poll window matches the dispatch cap; engine recycles after each render (#326)
* 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.

* 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.

* 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:40 -07:00
hanzo-dev d5e12b3df1 feat(ai): bump ai v1.818.0 → v1.820.0 — router live-by-default + record-all + self-export/delete
Ships to prod: router.enabled=true (model=auto routes for every org by default),
per-request RoutingEvent recording for auto AND explicit models (up/down feedback
works on all models), per-org + global fit-gate-deploy-publish training, and the
self-scoped routing-data export/delete (data ownership). Pairs with the universe
CR ROUTER_ENDPOINT removal (heuristic 300ns is the live path).

Claude-Session: https://claude.ai/code/session_018PmFAHZvbBSTsuWyebwMra
2026-07-17 01:25:40 -07:00
hanzo-dev 36ba00f540 dedup: extract clients/payout from the 3 byte-mirror commerce.go copies
referrals/affiliates/authors each carried a byte-identical commerce.go (their own
doc-comments said so): the same commerce interface, httpCommerce, newCommerceClient,
deposit(), spendCents(), errUnconfigured — the S2S COMMERCE_SERVICE_TOKEN money-in
path (POST /v1/billing/deposit) + usage-rollup, triplicated.

Extract ONE clients/payout (attributed-credit -> commerce via commerceinproc): the
exported Commerce/Client/NewClient/ErrUnconfigured. Each program keeps a THIN
adapter — its own lowercase commerce interface + a commerceSeam that delegates to
payout.Client — so the program store/handler code AND their fakeCommerce test doubles
are untouched, and each program still names its own grant tag (grant:referral /
grant:affiliate / grant:author). ~330 duplicated lines collapse to one binding.

Zero behaviour change: identical HTTP contract, headers (X-Org-Id, Bearer), body,
fail-soft (ErrUnconfigured on deposit / 0 on spend when unwired), and errors.Is
sentinel. Adds payout unit tests (httptest) that give the extracted HTTP path REAL
coverage the fakes never did — ok clients/payout 0.010s.
2026-07-17 01:19:36 -07:00
hanzo-dev db53daea72 dedup: fold clients/gojabase into clients/goja (the Base binding is an option)
gojabase was the read-WRITE-Base sibling of goja: it wrapped a goja.Host and
added per-tenant Base/SQLite persistence, but duplicated the Host/Config/Request/
Response/New surface. Fold it into the ONE goja package as the Base-binding
CONSTRUCTOR — the persistence layer is now opted into via NewBase (vs New for a
read-only catalog bundle):

  goja.New   / goja.Host   / goja.Config   / goja.Request    read-only engine (plans/pricing)
  goja.NewBase / goja.BaseHost / goja.BaseConfig / goja.BaseRequest   + per-tenant Base

Moves gojabase.go -> clients/goja/base.go (renamed types, no goja. self-import),
store.go -> basestore.go, and both test files, all into package goja (zero
identifier collisions, coverage preserved). Repoints every importer —
dataroom/captable/sign (RW) to goja.Base*; plan/pricing already used goja and are
unchanged; base uses goja.TenantSegment. clients/gojabase deleted.

Behaviour is byte-identical: the engine, the per-request transaction commit-on-
<400, the injective TenantSegment, and the __db/__blob/__newId/__now host globals
are unchanged; only the package + exported names moved. No routes (both are
libraries). The gojabase[...] error prefix is kept as the RW-layer diagnostic label.
2026-07-17 01:19:36 -07:00
hanzo-dev 919d96f3f8 dedup: fold connectorruntime into the one automations subsystem
clients/connectorruntime mounts exactly ONE route —
POST /v1/automations/connectors/:id/run — the in-process goja runner paired with
automations own GET /v1/automations/connectors catalogue. It was a separate Wire
entry solely for that route. Fold connectorruntime.Mount in as a terminal
sub-mount of automations.Mount and drop its Wire entry + import -> ONE
automations subsystem.

The route is DISTINCT from every automations route and automations mounts no
/v1/automations/* wildcard, so there is no shadow; the runner still resolves the
shared engine lazily. clients/connectorruntime stays a focused package
(composition); its internal bundlecmd tool is untouched. Frozen wire row
removed.
2026-07-17 01:19:36 -07:00
hanzo-dev d846f17bf8 dedup: fold platform cron into the one tasks subsystem
clients/cron mounts NO routes — its Mount only launches a background starter
that registers durable schedules on the SAME shared engine (cloud.EmbeddedTasks)
that clients/tasks fronts. It was a separate Wire entry purely to get its
goroutine launched. Fold it in as a terminal sub-mount of tasks.Mount and drop
the cron Wire entry + import -> ONE tasks subsystem.

clients/cron stays a focused package (composition, not code-dumping): tasks
imports and invokes it. No routes change (cron never had any); the scheduler
still waits for the post-MountAll engine, so timing is unchanged. Frozen wire
row removed.
2026-07-17 01:19:36 -07:00
hanzo-dev 1648c08839 dedup: normalize the plan subsystem enable id "plans" -> "plan"
clients/plan.Mount was wired under the name "plans" while its package, and now
its generated standalone cmd, are "plan" — one subsystem, two names. Normalize
the Wire enable id (and cmd/plans -> cmd/plan, ServeSingle arg) to "plan".

Product routes are unchanged: the subsystem still serves /v1/plans/* (plural),
including its OwnsHealth /v1/plans/health probe — only the enable id / binary
name changes. No route drop; mount-all default still enables it (empty Enable =
all on). Updated the frozen wire row and the two cmd/cloud enable-id references;
TestMountAllAndServeHealth now maps plan to its real /v1/plans/health path
(enable id no longer equals route prefix for plan, as is already true for
account/runtime/agent).
2026-07-17 01:19:36 -07:00
385237bdaa fix(release): resolve prebuilt artifact digests + unbreak cloud-flags publish (#327)
cloud#321 landed the Go-only Dockerfile (FROM cloud-flags:latest) but the
release.yml integration wasn't in it, and the reusable could not publish
cloud-flags at all — so every release since has FAILED at
'FROM cloud-flags:latest: not found'. Two fixes:

1. native/flags/Dockerfile base ghcr.io/hanzoai/mirror/rust -> public.ecr.aws
   (digest-identical). The hanzoai/ci reusable builds cloud-flags with the repo
   GITHUB_TOKEN, which 403s pulling the cross-repo-linked private mirror package;
   a public base is GITHUB_TOKEN-pullable, so cloud-flags finally publishes.

2. release.yml resolves console-embed/agent-skills/cloud-flags :latest to
   IMMUTABLE digests at release time (crane) and passes them as CONSOLE_IMAGE/
   SKILLS_IMAGE/FLAGS_IMAGE build-args to BOTH the smoke build and the push build,
   replacing CONSOLE_CACHEBUST. Reproducible (pinned, not floating :latest) AND
   fresh (a console/skills/flags change is a new digest). A MISSING artifact FAILS
   the release BEFORE build/smoke/tag — the receipt invariant holds, never a
   phantom tag on an image that could not embed the real console.

Preserves #322's functional + migration smoke gates (different sections).

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

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-17 01:18:50 -07:00
z cfce8b10d0 probe: pin what zip typed ops can bind, before migrating 792 routes onto them
zip.Get[In, Out] is advertised as ONE op projected into three surfaces (REST ·
OpenAPI · MCP), and cloud's raw routes were slated to migrate onto it starting
with clients/agents. Measured first, against the route shapes cloud actually
has.

The registry works: registering one typed op populates /.well-known/openapi.json
and the /mcp tool surface, both of which are absent today only because cloud
registers zero typed ops (installOpenAPIRoutes/installMCP early-return on
len(a.ops)==0).

The binding does not. registerTyped's fiber handler passes c.Body() — nil for a
GET — into op.invoke and nothing else, and fiber's DefaultCtx.Context() returns
context.Background(). So a typed handler sees no path param, no query param, and
no header. 16 of clients/agents' 25 routes carry a path param and would receive a
zero In; the ?live/?host/?status/?agent filters would vanish; and the org, which
every agents handler reads via principal.Org(c) -> tenant(c) -> 403, is
unreachable. Migrating as-is would answer the wrong session for every :id route
and drop the authz gate on all of them.

MCP is the sharp edge: tool arguments arrive as the body, so MCP is the ONE
projection that DOES fill In, and mcpCall runs op.invoke with no identity at all.
A migrated org-scoped op would answer an anonymous caller, and the only way to
give it an org would be an org field in the typed In — caller-supplied, i.e. a
cross-tenant read. Both horns are unacceptable, so clients/agents does not
migrate at this zip version.

The org half needs no framework change: fiber's SetContext (already used by
TracingMiddleware) is honored by the ctx registerTyped hands to op.invoke, so
middleware can carry the validated org to a typed handler off the wire, over REST
and MCP alike, without an In field. TestPrincipalBridgeCarriesOrg proves it on
stock zip. Only URL binding is missing.

These are characterization tests: they pin the gap as the current contract and
fail with "UNBLOCKED: invert this test" the moment zip binds a URL, so the
migration restarts on a failing build rather than on someone remembering.
2026-07-16 15:14:25 -07:00
hanzo-dev a4f68617a0 wip(account-usage plane (clients/link usage + datastore)): rescued from agent that hit the session limit
Committed as-is to preserve the work (the building agent died mid-verify).
Not yet built/tested green; NOT merged to main. Resume from here.
2026-07-15 14:46:43 -07:00
hanzo-dev e3e87f8b69 leaderboard: gamified usage analytics — leaderboards + activity graph (#43)
New /v1/usage/leaderboard + /v1/usage/activity + opt-in surface over a derived
datastore rollup (SummingMergeTree MV of hanzo.cloud_usage). Ranks top AI users
(personal/org) and orgs (global); per-day contribution heatmap + timeline.

- rollup.go: usage_rollup_daily target + incremental MV (type-exact projection,
  cannot fail a valid ledger insert) + deploy-gated run-once backfill.
- sql.go: injection-safe builders — org bound positionally, metric from a closed
  allowlist, limit a clamped int; org is the leading predicate.
- view.go: opt-in privacy — self/opted-in/admin named, else Anonymous; cross-org
  detail structurally impossible (org-bound reads).
- store.go: opt-in preference store (private by default), Base/SQLite via cek.
- board.go/activity.go/optin.go/backfill.go: handlers, fail-closed on principal.
- 39 tests (incl -race): builder injection-safety, tenant isolation, cross-tenant
  bleed, naming policy, opt-in default-private, authz resolvers, rollup lifecycle.
2026-07-14 22:28:37 -07:00
hanzo-dev 0df009b329 fix(zen): resolve upstream provider keys with env fallback (fix DO 401)
zenKeyResolver read the co-resident KMS store ONLY. The upstream provider
keys (DO_AI_API_KEY, ANTHROPIC_API_KEY) are provisioned as env, injected
from the KMS-synced cloud-api-llm-keys secret — they are NOT sealed in the
embedded KMS store. So GetSecret missed, the resolver returned an empty key,
and zen's call to DO GenAI answered 401 'Unable to authenticate you'. Every
zen chat failed at the upstream while ai (which reads the key from env) worked.

Try the sealed KMS value first (so completing sealed-store provisioning later
needs no code change), then fall back to env. Absent from both still returns
'' so the call fails fast — never silent free usage. Tests cover env-fallback,
sealed-precedence, and absent-everywhere.
2026-07-14 17:29:07 -07:00
hanzo-dev 02fab4bc40 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.
2026-07-14 10:52:17 -07:00
hanzo-dev 4aff04accd 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.
2026-07-13 16:09:17 -07:00
815 changed files with 151671 additions and 7055 deletions
+22
View File
@@ -111,6 +111,28 @@ jobs:
echo "GOPROXY=https://proxy.golang.org,direct"
} >> "$GITHUB_ENV"
- name: zen streaming-fix floor — go.mod must pin github.com/hanzoai/zen >= v1.4.1
# The SSE body-close fix (zen commit 50328b8, first released in zen v1.4.1)
# is what makes streaming completions return a body instead of an empty
# stream. A stale-branch merge that reverts go.mod's zen pin below the floor
# silently re-breaks streaming, and `next build`'s ignoreBuildErrors hides
# the runtime break — so no image may be cut on a regressed pin. This is the
# durable root-cause guard: it reads the EFFECTIVE module version (post-MVS,
# exactly what the build links) and fails the PR/push below the floor.
run: |
set -euo pipefail
FLOOR="v1.4.1"
V="$(go list -m -f '{{.Version}}' github.com/hanzoai/zen)"
echo "effective github.com/hanzoai/zen = ${V} (floor ${FLOOR})"
# semver-correct compare: the lowest of {V, FLOOR} under `sort -V` must be
# the FLOOR, i.e. V >= FLOOR. (sort -V orders v1.4.2 above v1.4.10 too.)
low="$(printf '%s\n%s\n' "$V" "$FLOOR" | sort -V | head -1)"
if [ "$low" != "$FLOOR" ]; then
echo "::error::github.com/hanzoai/zen is pinned at ${V}, below the streaming-fix floor ${FLOOR} — this re-breaks SSE streaming (empty completions). Re-pin zen to >= ${FLOOR} in go.mod before merging."
exit 1
fi
echo "OK: zen ${V} is at or above the streaming-fix floor ${FLOOR}"
- name: positive proof — clients/controlplane is unreachable from the default build
run: |
set -euo pipefail
+161 -37
View File
@@ -124,20 +124,6 @@ jobs:
echo "sha_short=$(git rev-parse --short "$GITHUB_SHA")" >> "$GITHUB_OUTPUT"
echo "Next release: v${version} (git_max='${git_max:-none}' container_max='${cont_max:-none}')"
# Console-embed cachebust. The console clone+build layer is keyed on this;
# prefer hanzoai/console main HEAD so a CONSOLE-ONLY change re-embeds without
# needing a cloud commit (cloud-sha alone froze the embed between cloud pushes).
# git ls-remote must CLEAR the extraheader actions/checkout installs (it carries
# THIS repo's GITHUB_TOKEN, which 404s the cross-repo console lookup); gh is not
# on the runner. If resolution yields nothing, fall back to the cloud sha — still
# unique per cloud commit, so the embed is never frozen. Either way THIS build
# busts (new value) and re-clones console main fresh.
console_head="$(git -c 'http.https://github.com/.extraheader=' ls-remote \
"https://x-access-token:${GH_PAT}@github.com/hanzoai/console.git" refs/heads/main 2>/dev/null | cut -f1 || true)"
cachebust="${console_head:-$GITHUB_SHA}"
echo "cachebust=${cachebust}" >> "$GITHUB_OUTPUT"
echo "console cachebust: ${cachebust} (console_head='${console_head:-none}')"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
@@ -160,8 +146,12 @@ jobs:
# 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
if echo "$REGISTRY_PASSWORD" | docker login registry.hanzo.ai -u "$REGISTRY_USER" --password-stdin; then
echo "MIRROR_OK=1" >> "$GITHUB_ENV"
else
echo "mirror login failed (registry.hanzo.ai unreachable) — mirror skipped, release continues"
fi
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')
@@ -180,8 +170,11 @@ jobs:
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"
if echo "${UP#*:}" | docker login registry.hanzo.ai -u "${UP%%:*}" --password-stdin; then
echo "MIRROR_OK=1" >> "$GITHUB_ENV"
else
echo "mirror login failed (registry.hanzo.ai unreachable) — mirror skipped, release continues"
fi
- name: Log in to ghcr.io (GH_PAT — writes the cloud package despite its ai-repo linkage)
uses: docker/login-action@v3
@@ -190,6 +183,39 @@ jobs:
username: hanzo-dev
password: ${{ secrets.GH_PAT }}
- name: Resolve decomplection artifact digests (the Go-only build's prebuilt inputs)
id: artifacts
run: |
set -euo pipefail
# cloud compiles ONLY Go; it pulls three prebuilt artifacts (console SPA,
# agent-skills catalog, native flags staticlib). Resolve each published
# :latest to an IMMUTABLE digest so THIS release is reproducible (pinned,
# not floating :latest) AND a console/skills/flags change is picked up —
# its CI republished :latest, so this resolves to the NEW digest. A MISSING
# artifact FAILS the release HERE, before build/smoke/push/tag: the receipt
# invariant means we never tag an image that couldn't embed the real console.
command -v crane >/dev/null 2>&1 || {
mkdir -p "$HOME/.local/bin"
curl -fsSL "https://github.com/google/go-containerregistry/releases/latest/download/go-containerregistry_Linux_x86_64.tar.gz" \
| tar -xz -C "$HOME/.local/bin" crane
}
export PATH="$HOME/.local/bin:$PATH"
resolve() {
local repo="$1" d
d="$(crane digest "ghcr.io/hanzoai/${repo}:latest" 2>/dev/null || true)"
[ -n "$d" ] || { echo "::error::decomplection artifact ghcr.io/hanzoai/${repo}:latest is not published — refusing to cut a release that would embed a stale/placeholder ${repo}"; return 1; }
printf 'ghcr.io/hanzoai/%s@%s' "$repo" "$d"
}
CONSOLE_IMAGE="$(resolve console-embed)" || exit 1
SKILLS_IMAGE="$(resolve agent-skills)" || exit 1
FLAGS_IMAGE="$(resolve cloud-flags)" || exit 1
{
echo "console_image=${CONSOLE_IMAGE}"
echo "skills_image=${SKILLS_IMAGE}"
echo "flags_image=${FLAGS_IMAGE}"
} >> "$GITHUB_OUTPUT"
echo "resolved: console=${CONSOLE_IMAGE} skills=${SKILLS_IMAGE} flags=${FLAGS_IMAGE}"
- name: OCI labels
id: meta
uses: docker/metadata-action@v5
@@ -216,11 +242,13 @@ jobs:
load: true
tags: cloud:smoke
labels: ${{ steps.meta.outputs.labels }}
# Bust the console clone+build layer every release (the cloud commit sha is
# unique per push) so the embed re-fetches console main HEAD fresh — never the
# frozen snapshot the persistent BuildKit cache would otherwise serve forever.
# cloud compiles ONLY Go: pull the three prebuilt artifacts pinned to the
# digests resolved above (reproducible, and fresh — a console/skills/flags
# change is a new digest). No node/python/rust toolchain in this build.
build-args: |
CONSOLE_CACHEBUST=${{ steps.ver.outputs.cachebust }}
CONSOLE_IMAGE=${{ steps.artifacts.outputs.console_image }}
SKILLS_IMAGE=${{ steps.artifacts.outputs.skills_image }}
FLAGS_IMAGE=${{ steps.artifacts.outputs.flags_image }}
# GIT_AUTH_TOKEN: BuildKit secret the Dockerfile consumes to fetch private
# cross-org Go modules (hanzoai/*, luxfi/*) over authenticated git.
secrets: |
@@ -462,10 +490,12 @@ jobs:
ghcr.io/hanzoai/cloud:sha-${{ steps.ver.outputs.sha_short }}
ghcr.io/hanzoai/cloud:latest
labels: ${{ steps.meta.outputs.labels }}
# SAME cachebust as the smoke build → every layer is a cache hit from step 1
# and the pushed image is byte-identical to the one the smoke test proved.
# SAME artifact digests as the smoke build → every layer is a cache hit from
# step 1 and the pushed image is byte-identical to the one smoke proved.
build-args: |
CONSOLE_CACHEBUST=${{ steps.ver.outputs.cachebust }}
CONSOLE_IMAGE=${{ steps.artifacts.outputs.console_image }}
SKILLS_IMAGE=${{ steps.artifacts.outputs.skills_image }}
FLAGS_IMAGE=${{ steps.artifacts.outputs.flags_image }}
secrets: |
GIT_AUTH_TOKEN=${{ secrets.GH_PAT }}
@@ -499,12 +529,25 @@ jobs:
# 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.
# Fail-CLOSED. An ORPHANED container tag — image pushed by a run that
# died or was cancelled after imagetools-create but before its git tag —
# MUST raise the floor, or a later run reassigns that same number to a
# different image (an ambiguous mutable prod tag; the v1.801.50 flip). A
# git-only floor can't see the orphan, so if the container-tag lookup
# ERRORS (vs legitimately returning no tags) we retry the whole attempt
# rather than silently proceeding — a version with a pushed image is never
# reused. (Reordering git-tag before imagetools-create is the WRONG fix: it
# reintroduces the phantom "tag ⇔ no image" this workflow exists to prevent.)
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)"
if cont_raw="$(GH_TOKEN="$GH_PAT" gh api \
'/orgs/hanzoai/packages/container/cloud/versions?per_page=100' \
--jq '.[].metadata.container.tags[]?' 2>/dev/null)"; then
cont_max="$(printf '%s\n' "$cont_raw" \
| sed 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true)"
else
echo " container-tag lookup failed — retry so an orphaned tag can't be reused (attempt $attempt)"; sleep 3; continue
fi
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)"
@@ -534,8 +577,12 @@ jobs:
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"
# Bounded: registry.hanzo.ai can *hang* (not just fail), and this
# is best-effort — an unbounded crane copy once livelocked the whole
# tag step and held the serialized release lane. timeout makes the
# mirror truly best-effort so the git-tag receipt below always runs.
timeout 120 crane copy "$SHA_IMG" "registry.hanzo.ai/hanzoai/cloud:${MT}" \
|| echo "::warning::mirror registry.hanzo.ai/hanzoai/cloud:${MT} failed or timed out"
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})"
@@ -552,9 +599,86 @@ jobs:
echo "::error::could not acquire a free version tag after 8 attempts"
exit 1
# 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.
# ── Promote: the declared-tag bump that makes the release DEPLOY ─────────────
# The tag minted above is the receipt for a pushed, smoke-passed image; THIS job
# records it as the desired state Hanzo CD reconciles. The universe-crs ArgoCD
# Application (ns hanzo-cd, `automated` sync + selfHeal) syncs
# infra/k8s/operator/crs/*.yaml → cluster and the operator rolls the Deployment,
# so a tag bump committed here reaches api.hanzo.ai with NO hand-dispatch and NO
# hand-edit of the CR.
#
# This is the SAME yq-bump → `deploy(<svc>): <tag>` universe commit the hanzoai/ci
# reusable (build.yml deploy step) does for every other service. cloud owns it
# HERE because its image is built by this workflow, not the ci reusable — its
# hanzo.yml carries no main `images:` entry and `# NO deploy`, so the shared
# deploy step never bumps cloud's CR. A direct in-cluster CR patch is NOT enough:
# ArgoCD selfHeal reverts any live edit not also recorded in git within ~45s.
# The retired notify-universe repository_dispatch had no receiver after the
# image-update.yml deploy hub was deleted in the Hanzo CD cutover; the git commit
# IS the sanctioned path now.
promote:
needs: build-amd64
# Only a real release promotes: build+smoke+push+tag all succeeded, so a
# proven v* image exists. A failure earlier leaves version_v empty → skipped.
if: ${{ needs.build-amd64.outputs.version_v != '' }}
runs-on: [hanzo-build-linux-amd64]
steps:
- name: Record the proven tag in universe crs/cloud.yaml (Hanzo CD rolls it)
env:
# GH_PAT already pushes this repo's git tags above (contents:write on the
# hanzoai org), so it writes hanzoai/universe too — the SAME token the ci
# reusable falls back to for the universe deploy commit.
GH_PAT: ${{ secrets.GH_PAT }}
VERSION_V: ${{ needs.build-amd64.outputs.version_v }}
run: |
set -euo pipefail
[ -n "${GH_PAT:-}" ] || { echo "::error::no GH_PAT — cannot record the declared-tag bump in universe"; exit 1; }
# Bare arc runners ship no yq — provision the static binary (sudo-free,
# same pattern the ci reusable and this workflow's kubectl/crane fetches use).
if ! command -v yq >/dev/null 2>&1; then
mkdir -p "$HOME/.local/bin"; export PATH="$HOME/.local/bin:$PATH"
curl -fsSL https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 \
-o "$HOME/.local/bin/yq" && chmod +x "$HOME/.local/bin/yq"
fi
git clone -q --depth 1 \
"https://x-access-token:${GH_PAT}@github.com/hanzoai/universe.git" \
"$RUNNER_TEMP/universe"
CR="$RUNNER_TEMP/universe/infra/k8s/operator/crs/cloud.yaml"
[ -f "$CR" ] || { echo "::error::crs/cloud.yaml not found in universe"; exit 1; }
CUR="$(yq -r '.spec.image.tag // ""' "$CR")"
echo "cloud CR: ${CUR:-<empty>} → ${VERSION_V}"
# Monotonic guard: never roll the CR BACKWARD. Release runs finish under a
# serialized lane but a slow older run must never overwrite a newer promote.
# Skip iff the CR already holds a semver >= the version we just cut.
CURN="${CUR#v}"; NEWN="${VERSION_V#v}"
if printf '%s' "$CURN" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
top="$(printf '%s\n%s\n' "$CURN" "$NEWN" | sort -V | tail -1)"
if [ "$top" = "$CURN" ] && [ "$CURN" != "$NEWN" ]; then
echo "::notice::cloud CR already at v${CURN} (≥ ${VERSION_V}) — not rolling back"; exit 0
fi
fi
yq -i ".spec.image.tag = \"${VERSION_V}\"" "$CR"
if git -C "$RUNNER_TEMP/universe" diff --quiet; then
echo "::notice::crs/cloud.yaml already at ${VERSION_V} — nothing to record"; exit 0
fi
git -C "$RUNNER_TEMP/universe" -c user.name=hanzo-ci -c user.email=dev@hanzo.ai \
commit -qam "deploy(cloud): ${VERSION_V} (${GITHUB_REPOSITORY}@$(echo "${GITHUB_SHA}" | cut -c1-7))"
# Rebase-safe push: universe main advances on every service's deploy, so a
# concurrent commit must not make cloud's promote lose the whole roll. Retry
# a few times, rebasing between attempts.
for attempt in $(seq 1 5); do
if git -C "$RUNNER_TEMP/universe" push -q origin HEAD:main; then
echo "recorded deploy(cloud): ${VERSION_V} — Hanzo CD (universe-crs) will roll it to api.hanzo.ai"
exit 0
fi
echo " universe push lost the race — rebasing (attempt ${attempt})"
git -C "$RUNNER_TEMP/universe" pull -q --rebase origin main || true
sleep 3
done
echo "::error::could not record the cloud tag bump in universe after 5 attempts"; exit 1
+9
View File
@@ -13,6 +13,14 @@
coverage.txt
coverage.html
# cek encryption sidecars are key material — never commit one from a source tree.
# Tests seal their stores under t.TempDir(); a sidecar in a package dir (e.g. from a
# test that opened ":memory:") is a mistake. The cek testdata fixtures are the one
# intentional exception.
*.dek
*.cek.lock
!cek/testdata/**
# Environment files
.env
.env.*
@@ -34,4 +42,5 @@ Thumbs.db
.shots/
.claude/
.worktrees/
native/flags/target
+173
View File
@@ -6,6 +6,87 @@ artifact serves `api.hanzo.ai`, `api.lux.cloud`, `api.zoo.cloud`, `api.osage.clo
and every white-label reseller. Brand, enabled subsystems, and org scope are
deployment configuration.
## Open Cloud planes
Spec home: HIP-0129 `hip-0129-open-cloud-planes` (hips repo). This section is a
map, not the spec. One noun, one owner, one route family. No plane reads another
plane's store; imports flow custody-ward only (channels -> integrations, never
reverse).
| Route | Noun | Owner | Tier |
| --- | --- | --- | --- |
| `/v1/connectors` | Custody: per-user BYO external accounts | `clients/integrations` (extends; user scope new) | In flight (branch `feat/connectors`) |
| `/v1/channels` | Transport: portable message envelope, DM pairing, send + inbox | `clients/channels` (new) | Planned (branch `feat/channels` reserved; no transport code yet) |
| `/v1/sync` | Data: bidirectional sync engine | `clients/sync` | Shipped |
| `/v1/automations` | Workflows: flows/runs, goja piece runtime | `clients/automations` | Shipped |
| `/v1/compute/bots` | Hosting: `@hanzo/bot` Node containers | `clients/bots` | Shipped |
| `/v1/tasks` | Durable engine | `clients/tasks` | Shipped |
| `/v1/gpus` + fleet | BYO GPU presence | `clients/fleet` + `clients/visor` | Shipped |
| `/v1/cloud` | Cloud accounts: link DO/AWS/GCP/Azure, discover native k8s clusters, fold into the fleet | `clients/venue` (new) | In flight (branch `feat/cloud-account-connectors`; blue-held for red) |
| IAM | Identity: users, orgs, roles | IAM | Shipped |
| KMS | Secret custody: sealed secrets | `clients/kms` | Shipped |
Custody invariants: secrets sealed in KMS at
`/orgs/{org}/users/{user}/connectors/{provider}/{label}`, never in SQLite rows;
verify before store. Refresh is single-flight with rotation resealing; the CLI
does local browser PKCE and posts the bundle to
`POST /v1/connectors/:provider/credential`; cloud owns device-code flows.
Transport invariants: typed actions (`command|url|select|approval`), no raw
string sniffing; pairing codes 8 chars, 1h TTL, max 3 pending per account,
owner bootstrap on first approval.
Container boundary is permanent for native-module, host-filesystem, loop-state,
and vendor-Node work (agent loop, exec/PTY, harnesses, browser, voice, codecs,
Node-bound channels, plugin SDK/loader). The Node plugin SDK is never ported to
Go; cloud extensibility is connectors/automations/tools.
Port roadmap (P1-P15) lives in HIP-0129; do not restate it here. Every claim
carries its tier: Shipped (on main, named package/route), In flight (named
pre-main branch), Planned (backlog id or named reservation).
## Build & module graph — standalone module, NOT a go.work member
`cloud` is a self-contained deploy unit: its own `go.mod`, `Dockerfile`, binary.
It is intentionally NOT listed in the parent `~/work/hanzo/go.work` workspace —
that workspace deliberately excludes the heavy modules, and merging cloud's
k8s/otel dependency tree with `o11y`'s reintroduces `koanf`/`ugorji`
monolith-vs-split import ambiguities (the parent workspace is itself red on the
koanf split; that is not cloud's bug to fix).
The catch: `go` auto-discovers that parent `go.work` whenever you run a bare
`go build ./...` / `go test ./...` from inside this tree, which puts the build in
workspace mode and SILENTLY DROPS cloud's own `go.mod` directives. Those
directives are load-bearing and each fixes exactly one graph hazard:
- `replace github.com/vulcand/oxy/v2 => github.com/traefik/oxy/v2 <pseudo>` — the
bare require is a placeholder (`v2.0.0-00010101000000-000000000000`); without
the replace it resolves to an invalid version.
- `exclude github.com/ugorji/go <old-monolith>` — drops the pre-split monolith so
`github.com/ugorji/go/codec` (pulled by gin) is unambiguous.
- the `k8s.io/*` staging replace block pins every staging module to the `v0.35.3`
line. `k8s.io/kubernetes` is a GRAPH-ONLY transitive require of
`hanzoai/deploy/gitops-engine` (clients/deploy uses its `pkg/utils/kube`); NO
cloud package imports `k8s.io/kubernetes`, so its staging tree never compiles —
do not "drop k8s.io/kubernetes", the pins keep the graph consistent and it is
never built. koanf resolves to the split modules; the `koanf v1.5.0` monolith
require is a harmless graph leaf, never imported.
So: build cloud in module mode, never workspace mode. `make build`/`test`/`vet`/
`tidy` force `GOWORK=off` (matches CI and the Dockerfile, which check out cloud
alone with no parent go.work). For a bare `go` command from this tree, prefix
`GOWORK=off`. `GOWORK=off go build ./...` and `go vet ./...` are green; `go mod
tidy` is stable. Do NOT commit a `go.work` here — it would flip the Dockerfile
(`COPY go.mod go.sum``go mod download``COPY . .`) into workspace mode after
its `-mod=readonly` download step.
Test modes: `make test` is pure-Go (`CGO_ENABLED=0`). Encrypted-at-rest OrgDB
tests (`cek`, `CLOUD_KMS_MASTER_KEY_REF` set) REQUIRE `CGO_ENABLED=1` +
libsqlcipher (`cek/cek.go` refuses to encrypt in pure-Go); those run only in the
Dockerfile's dedicated `-tags libsqlite3` CGO stage, and fail under `make test`
by design (clients/git, kms, flags, x402, cmd/kmsreseal, finance). Bundle-embed
tests (clients/tasks/ui) need `make deploy-ui` first (real bundle is gitignored).
## Framework doctrine
One way to do everything. Composable, orthogonal, DRY. A new subsystem is a
@@ -146,6 +227,23 @@ package under `clients/<name>` that obeys these seams — nothing more.
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.
- **The EXPERIMENT is a composition, not a fourth engine (`clients/experiments`,
`/v1/experiments`).** A/B testing is ONE value whatever the variant KIND (feature
flag, ad creative, email subject, model id): the primitive owns only the
experiment registry (definition + decision); it COMPOSES three planes it never
duplicates. ASSIGNMENT = `flags.Assign(org,project,key,subject,props)`
subject→variant is a deterministic `engineEvaluate` (sha1 rollout hash), no second
bucketing, no assignment store; create writes a multivariate flag def
(`flags.PutDef`) and decide rewrites its weights to 100% for the winner
(`flags.GetDef`+`PutDef`). MEASUREMENT = `analytics.Outcomes(...)` — one
org-scoped `hanzo.events` query (the `eventsWhere` isolation invariant), no second
event store; the analyze fold joins each subject's analytics outcome to its flags
variant by `distinct_id`. EVIDENCE = `research.Record`/`research.List` — per-variant
samples land as immutable `kind:"ab"` rows; significance (two-proportion z-test,
`math.Erfc`, no dep) is a PURE function over them. `clients/campaign` runs a
creative A/B by composing `experiments.Assign`/`experiments.Analyze` — it never
reinvents assignment or evidence. Add a new variant KIND by putting a payload on
the variant; the primitive does not care what it is.
## Identity vocabulary is IAM-native
@@ -184,3 +282,78 @@ Import path (already-incorporated orgs): Google Drive → data room, a Google Sh
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`.
## Deploy plane (`clients/deploy`, `/v1/deploy`)
Native ArgoCD-grade GitOps console over the operator-managed fleet, parallel to
`/v1/git`: each `hanzo.ai/v1` App CR IS the Application, and the plane OBSERVES the
operator's reconcile — `GET /v1/deploy/applications` (fleet list), `/{name}/tree`
(ownerRef resource tree + per-node health/sync), `/{name}/resource/{ref}` (live
manifest + desired-vs-live diff), `/{name}/logs`; `POST /{name}/rollback` pins the CR
image to a prior semver and `/{name}/sync` requests a reconcile. SUPERADMIN-only on
`c.IsAdmin()`, fail-closed; Secret nodes are never surfaced. `engine.go` embeds the argo
`gitops-engine` (`hanzoai/deploy/gitops-engine` v0.7.2, no replace) in-process for the
reconcile half behind `DEPLOY_ENGINE_ENABLED` (default off), with a prune-safety fuse.
## The `hanzo` CLI targets THIS binary — one contract, one IAM login
The `hanzo` CLI (`cli/`) is the same unified binary; its control-plane verbs speak the
routes THIS process serves, authorized off a plain `hanzo login` (the IAM access token is
the final bearer fallback — no `--platform-token`). The ONE contract, no TS-Dokploy drift:
- `hanzo apps list|get``GET /v1/paas/apps[/{app}]` (`clients/paas` fleet drift board)
- `hanzo deploy <app>``POST /v1/paas/apps/{app}/deploy` — a zero-downtime ROLLING
RESTART (stamps the Deployment pod-template `hanzo.ai/restartedAt` annotation; never
changes the declared TAG — that stays a git commit CD reconciles). `--env` picks the ns.
- `hanzo clusters list|get``GET /v1/clusters` (`clients/visor`, tenant-scoped)
- `hanzo build``POST /v1/runner` (native buildkit fabric)
`/v1/paas/*` auth mirrors `/v1/runner` (`clients/platform/runner.go`): the `guard` admits a
validated principal who is SuperAdmin OR OrgAdmin, then each handler CONFINES a non-super
caller to the platform namespaces its own validated org owns (`scopedNamespaces`, keyed on
`principal.Org` — a tenant admin can never observe/restart another org's, or a platform,
app; `?org=` cannot widen it). The rolling restart needs `patch` on `apps/deployments`
(ClusterRole/cloud, universe `infra/k8s/cloud/rbac.yaml`). There is NO `/v1/apps`,
`/v1/org/{org}/cluster`, or `/v1/platform/projects` CLI path — the first two never existed
here (TS-Dokploy contract, 404), and `/v1/platform/*` needs a co-resident IAM store this
deployment does not fold in (IAM runs as a separate svc) so it 500s; the live apps backend
is `/v1/paas`, whose board reads k8s directly with no IAM-store dependency.
## GTM: `/v1/campaign` orchestration → channels → connectors → analytics
The go-to-market stack decomplects a campaign from its execution. A **Campaign is a
VALUE** (`clients/campaign`: `{name, audience, content[], schedule, budget, channels[],
status}`) that SPANS channels; a **Channel is an EXECUTOR** (`channel.go`, the
`Channel` interface) it fans out to. The three channels are orthogonal and each
CONSUMES the connector plane via `integrations.TokenFor` — the campaign object never
touches a credential:
- **paid → `/v1/ads`** — `ads.LaunchPaid/PaidSpend/PausePaid` (`clients/ads/provider.go`)
resolve the org's ad token (`meta_ads`/`google_ads`/… via `TokenFor(org, <id>,
"access_token")`) and run the campaign on the provider. Meta is executed for real;
fail-closed when the org has not connected (424). This is the ONLY place `/v1/ads`
touches the connector plane.
- **organic → `/v1/publish`** (rename of `clients/social`) and **email → `/v1/marketing`**
are DESIGNED follow-ons: register their executors the same way in `apps/wire_seams.go`
(`campaign.RegisterChannel(campaign.NewChannel(kind, launch, spend, pause))`). Until
wired, a fan-out records that channel "unavailable" (honest), never fabricated.
Channels are injected at the composition root (`apps/wire_seams.go`), the SAME
injected-function decoupling the coding dispatcher uses — `campaign` never imports
`ads`, `ads` never imports `campaign`. Fan-out (`launch.go` `fanOut`) is best-effort
per channel; the org (the ONLY tenant key) is passed verbatim to every executor, so a
campaign can only ever resolve its OWN org's token.
**Metrics = the ONE analytics plane, not a second store.** `GET /v1/campaign/:id/metrics`
reads the funnel from `analytics.CampaignMetrics(org, campaignID, variant, start, end)`
(`clients/analytics/campaign.go`) — an org+`utm_campaign`(+`utm_content`)-scoped query
over `hanzo.events`, org and campaign bound POSITIONALLY (same tenancy invariant as
every analytics query) — joined with each channel connector's reported spend
(`Channel.Spend`). Derived KPIs: CTR/CVR/CAC/ROAS. Honest-empty when the warehouse is
absent.
**Creative A/B composes the experiment primitive** (`experiment.go`), it does not
reinvent it: a creative A/B is an experiment whose variant = a creative (tagged
`utm_content`) and whose metric = the analytics read. The `AssignFunc`/`EvidenceFunc`
seams are wired at the root to the flags-assignment + evidence primitive; nil-safe
until it lands (single-creative honest default).
+31 -4
View File
@@ -4,6 +4,18 @@
GO ?= go
BIN ?= cloud
PKG ?= ./cmd/cloud
# cloud is a STANDALONE Go module — a self-contained deploy unit (its own go.mod,
# Dockerfile, binary). It is intentionally NOT a member of the parent
# ~/work/hanzo/go.work workspace (that workspace deliberately excludes the heavy
# modules; adding cloud would merge its k8s/otel graph with o11y's and reintroduce
# koanf/ugorji import ambiguities). But `go` auto-discovers that parent go.work
# whenever a dev builds from inside this tree, which shadows cloud's own
# replace/exclude directives (oxy pin, ugorji monolith exclude, k8s staging pins)
# and breaks `go build ./...`. Force module mode so make targets build EXACTLY
# what CI/Docker build (fresh checkout, no parent go.work). Overridable via
# `make GOWORK=... <target>` for the rare cross-module case.
export GOWORK := off
DOCKER_IMAGE ?= ghcr.io/hanzoai/cloud
DOCKER_TAG ?= dev
LDFLAGS ?= -s -w
@@ -24,7 +36,7 @@ OPENAPI_DIR ?= ../openapi
# forces the fork to modernc too so the whole binary registers "sqlite" once.
CGO_ENABLED ?= 0
.PHONY: help native webui agentskills build build-standalone hanzo run smoke test test-cgo vet tidy docker docker-push clean
.PHONY: help native webui deploy-ui agentskills build build-standalone 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)
@@ -40,6 +52,17 @@ webui: ## Build the real console static bundle into webui/dist (go:embed source)
cp -r "$(CONSOLE_DIR)/out/." webui/dist/
@echo ">> embedded real console bundle into webui/dist (index.html $$(wc -c < webui/dist/index.html) bytes)"
deploy-ui: ## Build the monochrome ArgoCD dashboard bundle into clients/deploy/webui/dist (go:embed source). DEPLOY_DIR=<path to hanzoai/deploy>.
@command -v yarn >/dev/null 2>&1 || { echo "yarn is required to build the deploy dashboard bundle"; exit 1; }
@test -f "$(DEPLOY_DIR)/ui/package.json" || { echo "deploy checkout not found at $(DEPLOY_DIR) — set DEPLOY_DIR=<path to hanzoai/deploy on rebrand/hanzo-monochrome>"; exit 1; }
@test -d "$(DEPLOY_DIR)/ui/node_modules" || (cd "$(DEPLOY_DIR)/ui" && yarn install --frozen-lockfile)
cd "$(DEPLOY_DIR)/ui" && NODE_OPTIONS=--max-old-space-size=8192 yarn build
# Overlay the fresh bundle, keeping only the tracked fallback (.gitignore +
# index.html shell); the real 43MB bundle is build-time-only (gitignored).
find clients/deploy/webui/dist -mindepth 1 -maxdepth 1 ! -name .gitignore -exec rm -rf {} +
cp -r "$(DEPLOY_DIR)/ui/dist/app/." clients/deploy/webui/dist/
@echo ">> embedded monochrome ArgoCD bundle into clients/deploy/webui/dist (index.html $$(wc -c < clients/deploy/webui/dist/index.html) bytes)"
agentskills: ## Regenerate the FULL agent-skills catalog into clients/agentskills/catalog (go:embed source) from the openapi SOT. OPENAPI_DIR=<path to openapi>.
@test -f "$(OPENAPI_DIR)/skills.py" || { echo "openapi checkout not found at $(OPENAPI_DIR) — set OPENAPI_DIR=<path> or clone hanzoai/openapi"; exit 1; }
# skills.py rewrites the whole catalog dir; the .gitignore keeps only the tiny
@@ -53,9 +76,13 @@ build: ## Build the unified cloud binary into ./bin/cloud (embeds whatever webui
build-standalone: webui build ## Build the REAL 1-binary console: console build:embed → webui/dist → go build.
hanzo: ## Build the hanzo control-plane CLI into ./bin/hanzo (pure Go, same mode as cmd/cloud — registers the ONE "sqlite" driver exactly once; a plain CGO_ENABLED=1 `go build ./cmd/hanzo` links the fork's mattn backend alongside the embedded modernc importers and panics, see header).
@mkdir -p bin
CGO_ENABLED=$(CGO_ENABLED) $(GO) build -ldflags="$(LDFLAGS)" -o bin/hanzo ./cmd/hanzo
# NOTE: cloud builds ONLY the `cloud` binary — the stateless unified API. The Go
# `hanzo` CLI (cmd/hanzo + cli/) is RETIRED: the shipped `hanzo` is the Rust CLI
# (~/work/hanzo/cli, `curl hanzo.sh`), which talks to this API over HTTP via its
# OpenAPI-generated command surface. The `code` wrapper (incl. the zen-tier 1M
# mechanism) now lives in the Rust CLI. cmd/hanzo + cli/ remain only as the
# reference for the still-to-port client-side tools (GPU fleet worker `link`,
# `runner`, `engine`, `security`) and are no longer built here.
run: build ## Run with iam,base,kms,gateway,o11y enabled (matches README quickstart).
./bin/$(BIN) --enable=iam,base,kms,gateway,o11y --brand=hanzo --domain=api.hanzo.ai
+64
View File
@@ -0,0 +1,64 @@
// Copyright 2026 Hanzo AI Inc. All Rights Reserved.
package cloud
import (
"net/http"
"github.com/hanzoai/cloud/clients/principal"
"github.com/zap-proto/zip"
)
// AccountFromPrincipal decomplects the console's IDENTITY onto the ONE truth every
// /v1/admin/* call already authorizes on — the validated cloud principal — instead of
// the embedded casibase account model.
//
// THE COMPLECTION IT REMOVES. The operator SPA authenticates via /v1/signin (a cloud
// PKCE session → the X-User-* principal the middleware mints) but read its IDENTITY
// from /v1/get-account, which the embedded IAM (casibase) answers from ITS OWN session
// cookie. A PKCE session is not a casibase session, so get-account returned
// owner:"hanzo" (anonymous) or "Unauthorized operation" — and the SPA's SuperAdmin
// gate (owner == "admin" && isAdmin), reading that, bounced the operator UI to login
// even though the SAME session got 200 from every /v1/admin/* route. Two session
// models for one surface; the identity source and the authorization source disagreed.
//
// THE DECOMPLECTION. Identity is now the principal: when a VALIDATED principal is
// present (X-User-Id is set ONLY by IdentityMiddleware from a real credential, never a
// raw client header — see middleware_identity.go), /v1/get-account reflects it. owner
// is the HOME org (principal.Owner) so a SuperAdmin org-switched into a tenant stays a
// SuperAdmin; isAdmin is the validated bit. With NO principal it falls through
// (c.Next()) to the casibase account surface unchanged — the anonymous sign-in page
// and any legacy casibase-session caller are untouched. One truth, additive, fail-open
// to the old path. MUST be registered AFTER IdentityMiddleware (needs the minted
// headers) and BEFORE MountAll (so it precedes the casibase /v1/get-account handler).
func AccountFromPrincipal() zip.Handler {
return func(c *zip.Ctx) error {
if c.Method() != http.MethodGet || c.Path() != "/v1/get-account" {
return c.Next()
}
user := c.User() // X-User-Id — minted only from a validated credential
if user == "" {
return c.Next() // no principal → the casibase account surface (unchanged)
}
owner := principal.Owner(c) // HOME org (a SuperAdmin stays one when org-switched)
if owner == "" {
owner = c.Org()
}
name := c.Header("X-User-Name")
if name == "" {
name = user
}
return c.JSON(http.StatusOK, map[string]any{
"status": "ok",
"msg": "",
"data": map[string]any{
"owner": owner,
"name": name,
"displayName": name,
"email": c.Header("X-User-Email"),
"isAdmin": c.IsAdmin(),
"type": "normal-user",
},
})
}
}
+138 -56
View File
@@ -34,7 +34,6 @@ package apps
import (
"context"
"fmt"
"os"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
@@ -51,6 +50,7 @@ import (
// 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/admission"
"github.com/hanzoai/cloud/clients/ads"
"github.com/hanzoai/cloud/clients/affiliates"
"github.com/hanzoai/cloud/clients/agent"
@@ -61,22 +61,30 @@ import (
"github.com/hanzoai/cloud/clients/authors"
"github.com/hanzoai/cloud/clients/automations"
"github.com/hanzoai/cloud/clients/base"
"github.com/hanzoai/cloud/clients/benchmark"
"github.com/hanzoai/cloud/clients/billing"
"github.com/hanzoai/cloud/clients/books"
"github.com/hanzoai/cloud/clients/bots"
"github.com/hanzoai/cloud/clients/campaign"
"github.com/hanzoai/cloud/clients/captable"
"github.com/hanzoai/cloud/clients/catalogsync"
"github.com/hanzoai/cloud/clients/channels"
"github.com/hanzoai/cloud/clients/cloudflare"
"github.com/hanzoai/cloud/clients/code"
"github.com/hanzoai/cloud/clients/company"
"github.com/hanzoai/cloud/clients/connectorruntime"
"github.com/hanzoai/cloud/clients/compliance"
"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/destinations"
"github.com/hanzoai/cloud/clients/dns"
"github.com/hanzoai/cloud/clients/do"
"github.com/hanzoai/cloud/clients/domain"
"github.com/hanzoai/cloud/clients/entitlements"
"github.com/hanzoai/cloud/clients/eval"
"github.com/hanzoai/cloud/clients/exec"
"github.com/hanzoai/cloud/clients/experiments"
"github.com/hanzoai/cloud/clients/flags"
"github.com/hanzoai/cloud/clients/framework"
"github.com/hanzoai/cloud/clients/functions"
@@ -84,13 +92,15 @@ import (
"github.com/hanzoai/cloud/clients/git"
"github.com/hanzoai/cloud/clients/graph"
"github.com/hanzoai/cloud/clients/guide"
"github.com/hanzoai/cloud/clients/help"
"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/leaderboard"
"github.com/hanzoai/cloud/clients/legal"
"github.com/hanzoai/cloud/clients/link"
"github.com/hanzoai/cloud/clients/marketing"
"github.com/hanzoai/cloud/clients/marketplace"
@@ -108,10 +118,13 @@ import (
"github.com/hanzoai/cloud/clients/provisioning"
"github.com/hanzoai/cloud/clients/pubsub"
"github.com/hanzoai/cloud/clients/referrals"
"github.com/hanzoai/cloud/clients/research"
"github.com/hanzoai/cloud/clients/rollingcap"
"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/share"
"github.com/hanzoai/cloud/clients/sign"
"github.com/hanzoai/cloud/clients/social"
"github.com/hanzoai/cloud/clients/storage"
@@ -123,6 +136,8 @@ import (
"github.com/hanzoai/cloud/clients/tracker"
"github.com/hanzoai/cloud/clients/treasury"
"github.com/hanzoai/cloud/clients/usage"
"github.com/hanzoai/cloud/clients/validators"
"github.com/hanzoai/cloud/clients/venue"
"github.com/hanzoai/cloud/clients/visor"
"github.com/hanzoai/cloud/clients/wallets"
"github.com/hanzoai/cloud/clients/websearch"
@@ -130,19 +145,20 @@ import (
"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.
// Framework CONTENT modules — pure fixture lanes that 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. (help is a framework lane too but ALSO mounts
// a thin /v1/help public plane, so it is a real import + a Wire() spec below,
// alongside knowledge — the other lane with a companion subsystem.)
_ "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
@@ -163,22 +179,6 @@ func init() {
})
}
// identitySpec selects the ONE identity backend that owns /v1/iam/* (+ /login/oauth/*)
// for this boot. CLOUD_IAM_IMPL=iam2 picks the clean-room iam2 (zip+orm, beego-free);
// anything else — including unset, the production default — keeps the legacy beego
// Casdoor embed, byte-for-byte today's behavior. The two impls register the SAME
// absolute prefixes and therefore cannot co-mount, so selection (this func) stays
// separate from activation (cfg.Enabled): exactly one spec occupies the identity slot
// in Wire, preserving mount order either way. os.Getenv (not the unexported
// cloud.getenv, which is unreachable from package apps) is the read — CLOUD_IAM_IMPL is
// the deliberate, off-by-default opt-in that keeps iam2 inert until a canary flips it.
func identitySpec() cloud.MountSpec {
if os.Getenv("CLOUD_IAM_IMPL") == "iam2" {
return cloud.MountSpec{Name: "iam2", Mount: iam2.Mount}
}
return cloud.MountSpec{Name: "iam", Mount: iam.Mount}
}
// Wire returns every linked subsystem as a cloud.MountSpec, in mount order. The
// slice position IS the order: cloud.MountAll iterates it as-given, registering each
// subsystem's teardown as a zip shutdown hook so teardown runs in reverse (LIFO).
@@ -210,15 +210,12 @@ func Wire() []cloud.MountSpec {
// /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 IAM identity plane (/v1/iam/*, /login/oauth/*) — the identity authority,
// mounts before its dependents. The ONE implementation: the clean-room iam-v2
// (zip-native + hanzoai/orm, beego-free); the retired Casdoor iam-v1 embed is GONE.
// STAGED: the operator adds "iam" to --enable only after IAM config + the fold are
// verified (login/authorize/token/jwks + the operator SSO chain).
{Name: "iam", Mount: iam.Mount},
// 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},
@@ -236,7 +233,10 @@ func Wire() []cloud.MountSpec {
// CommerceClient is wired directly in pickCommerceClient).
{Name: "commerce", Mount: mountCommerce},
{Name: "licensing", Mount: licensing.Mount},
{Name: "plans", Mount: plan.Mount, OwnsHealth: true},
// clients/plan.Mount. Enable id normalized "plans" -> "plan" to match the
// package + generated cmd/plan (one subsystem, one name). Its product routes
// stay /v1/plans/* (incl. the OwnsHealth /v1/plans/health probe) — unchanged.
{Name: "plan", 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).
@@ -244,12 +244,22 @@ func Wire() []cloud.MountSpec {
// 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},
// Rolling AI-spend cap: installs the ai gate's per-tier trailing-window cap
// reader (registers no routes; its admin-editable knobs are platform switches
// surfaced in the /v1/admin/flags cockpit). After commerce/plan so the tier +
// finance globals it composes are wired.
{Name: "rollingcap", Mount: rollingcap.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},
// The /v1/dns forward head: relays the console DNS dashboard to the DNS
// control plane under the caller's own validated bearer (clients/dns).
{Name: "dns", Mount: dns.Mount},
// The registrar: search/price/register domains (name.com) per org.
{Name: "domain", Mount: domain.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
@@ -271,6 +281,13 @@ func Wire() []cloud.MountSpec {
{Name: "templates", Mount: templates.Mount},
{Name: "framework", Mount: framework.Mount, Shutdown: ctxShutdown(framework.Shutdown)},
{Name: "knowledge", Mount: knowledge.Mount},
// Hanzo Support PUBLIC plane /v1/help/* (help center KB read + customer ticket
// intake) — the anonymous face the secure-by-default framework surface can't
// serve. A framework lane like knowledge: its DocType fixtures register via
// init() (help.Module), and this mounts the thin public subsystem. Owns no store
// (delegates to the framework in-process API), so no Shutdown. After framework
// (whose in-process API it calls at request time); before the AI /v1/* catch-all.
{Name: "help", Mount: help.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.
@@ -284,6 +301,11 @@ func Wire() []cloud.MountSpec {
{Name: "catalogsync", Mount: catalogsync.Mount, Shutdown: catalogsync.Shutdown},
{Name: "ml", Mount: ml.Mount, OwnsHealth: true},
{Name: "usage", Mount: usage.Mount},
// Gamified usage analytics: /v1/usage/leaderboard + /v1/usage/activity + the
// per-day contribution graph, over the datastore rollup (#43). Co-owns /v1/usage/*
// with usage (a distinct concern — who leads + your activity graph) at its own
// exact paths; owns the opt-in SQLite store. Before the ai /v1/* catch-all.
{Name: "leaderboard", Mount: leaderboard.Mount, Shutdown: leaderboard.Shutdown},
{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
@@ -293,6 +315,18 @@ func Wire() []cloud.MountSpec {
// 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)},
// Top-level GTM orchestration /v1/campaign/* — the capability layer that fans a
// campaign VALUE out to its channels (paid→ads, organic→publish, email→marketing),
// each CONSUMING the connector plane via integrations.TokenFor. Metrics read from
// the ONE analytics plane (never a second store); creative A/B composes the
// experiment seam. The paid channel executor is wired in wire_seams.go. Owns a DB
// handle, so its Shutdown closes it cleanly on SIGTERM.
{Name: "campaign", Mount: campaign.Mount, Shutdown: ctxShutdown(campaign.Shutdown)},
// GDA/SDM validator onboarding /v1/validators/* — wallet-sig + ETH-mainnet
// GenesisNFT ownerOf → seal luxd staking identity into KMS → write a NEW-node
// LuxNetwork CR (node.lux.cloud, never the live luxd) → enqueue an owner-gated
// registration (never auto-submitted to any P-Chain). Owns a DB handle.
{Name: "validators", Mount: validators.Mount, Shutdown: ctxShutdown(validators.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
@@ -305,19 +339,43 @@ func Wire() []cloud.MountSpec {
// DB handles, so its Shutdown closes them on SIGTERM.
{Name: "sync", Mount: sync.Mount, Shutdown: ctxShutdown(sync.Shutdown)},
{Name: "visor", Mount: visor.Mount},
// Connect-a-cloud-account plane /v1/cloud/*: an org links its DigitalOcean /
// AWS / GCP / Azure accounts (labeled, KMS-sealed, keyless where possible),
// Hanzo DISCOVERS each account's native Kubernetes clusters and FOLDS them into
// the ONE fleet (clients/fleet.Register) — so they surface in visor's
// /v1/clusters and run work like any BYO/managed cluster. No second registry.
{Name: "venue", Mount: venue.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},
// ngrok-native public sharing: /v1/share/* provisions a per-org zrok
// account so `hanzo share <port>` publishes a local service to a public
// https://<token>.share.hanzo.ai URL. Fail-closed until ZROK_ADMIN_TOKEN.
{Name: "share", Mount: share.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},
// Marketing destinations /v1/destinations/* — the native fan-out that
// TRANSLATES the canonical /v1/event stream to each connected ad/analytics
// platform (GA4 Measurement Protocol, Meta Conversions API, X/LinkedIn/TikTok/
// Reddit) and forwards it server-side. Mounts AFTER analytics (whose fan-out
// sink it installs) and integrations (a destination may reuse an OAuth
// connection's token via integrations.TokenFor). Owns a DB handle → Shutdown.
{Name: "destinations", Mount: destinations.Mount, Shutdown: ctxShutdown(destinations.Shutdown)},
// First-class per-org Cloudflare asset plane /v1/cloudflare/{zones,pages,workers,
// ai,r2,kv,d1}/* (sibling of /v1/dns, /v1/domain). Mounts AFTER integrations
// because it reads the org's Cloudflare token through the integrations custody
// seam (integrations.TokenFor) — one token, one custody boundary. Connecting the
// provider stays on the integrations plane; this plane only MANAGES resources.
{Name: "cloudflare", Mount: cloudflare.Mount},
{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: "channels", Mount: channels.Mount, Shutdown: channels.Shutdown},
{Name: "gateway", Mount: gateway.Mount},
{Name: "entitlements", Mount: entitlements.Mount, Shutdown: entitlements.Shutdown},
{Name: "exec", Mount: exec.Mount},
@@ -334,25 +392,41 @@ func Wire() []cloud.MountSpec {
{Name: "sign", Mount: sign.Mount, Shutdown: sign.Shutdown, OwnsHealth: true},
{Name: "product", Mount: product.Mount},
{Name: "evals", Mount: eval.Mount},
{Name: "benchmark", Mount: benchmark.Mount},
// The R&D EVIDENCE plane (HIP-0512) + its R&D Ops Board UI at /research —
// the arena's sibling: benchmark measures, research is the versioned diary
// every product's runs accrue into. Non-staged: mounts under the default.
{Name: "research", Mount: research.Mount, Shutdown: ctxShutdown(research.Shutdown)},
// The unified EXPERIMENT primitive (/v1/experiments): A/B testing as ONE value
// whatever the variant kind (feature | ad creative | email | model). It is a
// COMPOSITION — assignment via flags, measurement via analytics, evidence via
// research — so it mounts AFTER all three. It owns only the experiment registry
// store → Shutdown. clients/campaign composes it (experiments.Assign/Analyze).
{Name: "experiments", Mount: experiments.Mount, Shutdown: ctxShutdown(experiments.Shutdown)},
// The revenue BOOKS spine (/v1/books): a native double-entry general ledger that
// reads commerce transactions (the sole posting source) and books the accounting twin —
// plus bank import (PDF/OFX/CSV/Plaid/Teller), reconciliation, and the AI Ask brain.
{Name: "books", Mount: books.Mount, Shutdown: ctxShutdown(books.Shutdown)},
{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.
// Launch-control gate (per-service waitlist): the COMPLETE feature — host→service
// registry + brand seed + the waitlist.<svc> switch registration + the
// /v1/flags/waitlist (and /v1/admission/mode compat) mode read + the Enforce
// middleware — COMPOSING the flags engine one-way (flags.Bool/Register/
// SetPlatformSwitch; flags never imports admission). Mounts AFTER flags so the
// engine's platform-switch plane is installed first; the admin board is the
// /v1/admin/services lens over it. Owns the registry store handle → Shutdown.
{Name: "admission", Mount: admission.Mount, Shutdown: ctxShutdown(admission.Shutdown)},
// Tasks: the durable workflow/UI surface AND platform cron (durable schedules
// on the same shared engine, replacing every k8s CronJob). cron was a separate
// Wire entry; it mounts no routes and only registers schedules, so it is folded
// in as a sub-mount of tasks.Mount — ONE tasks subsystem.
{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},
// Automations: the connector catalogue + flow engine AND native single-connector
// execution (POST /v1/automations/connectors/:id/run, HIP-0126). The connector
// runner mounts no other routes, so it is folded in as a sub-mount of
// automations.Mount (was a separate "connectorruntime" entry) — ONE subsystem.
{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
@@ -373,6 +447,14 @@ func Wire() []cloud.MountSpec {
// 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},
// The corporate back-office surfaces — orthogonal to company/captable/billing:
// Hanzo Compliance (/v1/compliance) orchestrates KYC/KYB verification providers
// + tracks accreditation state + surfaces the SOC 2 audit posture; Hanzo Legal
// (/v1/legal) is the versioned template + generation engine + e-sign/filing seams.
// Both are TOOLING with providers/professionals in the loop, never advice or
// certification. They mount before the /v1/* AI catch-all so their routes resolve.
{Name: "compliance", Mount: compliance.Mount, Shutdown: ctxShutdown(compliance.Shutdown), OwnsHealth: true},
{Name: "legal", Mount: legal.Mount, Shutdown: ctxShutdown(legal.Shutdown), OwnsHealth: true},
// 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
+318
View File
@@ -21,15 +21,23 @@ import (
"net/http"
"path/filepath"
"strings"
"time"
"github.com/hanzoai/cloud"
accountclient "github.com/hanzoai/cloud/clients/account"
"github.com/hanzoai/cloud/clients/commerceclient"
"github.com/hanzoai/cloud/clients/commerceinproc"
financeclient "github.com/hanzoai/cloud/clients/finance"
"github.com/hanzoai/cloud/clients/principal"
"github.com/hanzoai/commerce"
commercebilling "github.com/hanzoai/commerce/api/billing"
catalogapi "github.com/hanzoai/commerce/api/catalog"
planapi "github.com/hanzoai/commerce/api/plan"
commercestore "github.com/hanzoai/commerce/api/store"
commercedatastore "github.com/hanzoai/commerce/datastore"
commercemid "github.com/hanzoai/commerce/middleware"
"github.com/hanzoai/commerce/middleware/iammiddleware"
commercensctx "github.com/hanzoai/commerce/util/nscontext"
log "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
@@ -60,6 +68,21 @@ var commercePrefixes = []string{
// whose prepaid BALANCE gate 402'd every store read (a store-metadata read
// must never require an LLM balance).
"/v1/store",
// Platform-admin catalog CMS: GET/POST/PUT/DELETE /v1/catalog/entries +
// POST /v1/catalog/seed — the SuperAdmin CRUD admin.hanzo.ai's editor drives.
// commerce's setupRoutes wires only the PUBLIC read (/v1/commerce/catalog);
// the CRUD lives on the standalone /v1 bundle (api.Route → catalogApi.AdminRoute),
// which the co-resident embed skips, so mountCommerce mounts it below on the
// same /v1 gate chain. Own the prefix here so it reaches commerce (each handler
// is requireSuperAdmin-gated) instead of the AI /v1/* balance catch-all.
"/v1/catalog",
// Platform-admin subscription/DNS plan authority CMS: GET/POST/PUT/DELETE
// /v1/plans/entries + POST /v1/plans/seed (increment 3a) — the SuperAdmin CRUD
// the console plan editor drives. The PUBLIC read stays GET /v1/billing/plans;
// this CRUD rides the /v1 bundle (api.Route → planApi.AdminRoute), which the
// embed skips, so mountCommerce mounts it below. Own the prefix so it reaches
// commerce (each handler requireSuperAdmin-gated), not the AI /v1/* 402 gate.
"/v1/plans",
// 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.
@@ -140,6 +163,26 @@ func mountCommerce(app *zip.App, deps cloud.Deps) error {
storeV1.Use(iammiddleware.IAMTokenRequired())
commercestore.Route(storeV1, commercemid.TokenRequired())
// Platform-admin catalog CMS on the SAME /v1 bundle: GET/POST/PUT/DELETE
// /v1/catalog/entries + POST /v1/catalog/seed. setupRoutes wires only the
// public read (/v1/commerce/catalog); the CRUD rides the standalone /v1 bundle
// (api.Route → catalogApi.AdminRoute), which the co-resident embed skips — so
// register it here, exactly as the standalone does. storeV1's IAMTokenRequired
// populates the claims each handler's requireSuperAdmin reads (anon → 403, a
// platform admin edits); it is cross-tenant data, never org-scoped.
catalogapi.AdminRoute(storeV1)
// Platform-admin subscription/DNS plan authority CRUD on the SAME /v1 bundle:
// GET/POST/PUT/DELETE /v1/plans/entries + POST /v1/plans/seed (increment 3a).
// Mirrors the catalog mount: the standalone wires it on the /v1 bundle
// (api.Route → planApi.AdminRoute), which the co-resident embed skips. The
// embed seed SOURCE is injected here (the composition root) — commercebilling.
// SeedRows, the SAME @hanzo/plans embed the boot seed + resolveSubscriptionPlan
// read — so api/plan never imports api/billing. Each handler is
// requireSuperAdmin-gated (anon → 403); prices are admin-editable but the mint
// gates score the IMMUTABLE embed, so an edit never moves a charge gate.
planapi.AdminRoute(storeV1, commercebilling.SeedRows)
// Provider webhook intake at the LIVE registered path. Chain mirrors the
// commerce-standalone posture: gated request context, then the sessionless
// HMAC-verified handler.
@@ -156,6 +199,214 @@ func mountCommerce(app *zip.App, deps cloud.Deps) error {
commercebilling.RunAutoRechargeAllOrgs,
)
// GET /v1/billing/plans — the public tier catalog the console renders. commerce's
// legacy api.Route() billing bundle (ListPlans, invoices, subscriptions, …) is NOT
// registered by the co-resident embed: setupRoutes wires only /v1/commerce/*, so
// /v1/billing/plans has NO handler in this binary. The account bridge's
// /v1/billing/* wildcard (order 122) then forwards the read BACK to commerce at
// COMMERCE_URL — which defaults to the public api.hanzo.ai edge — re-entering the
// same bridge in an unbounded self-dispatch loop that surfaces as a 502
// ("commerce unreachable: Get https://api.hanzo.ai/v1/billing/plans"). Registering
// the static ListPlans handler HERE (order 100, ahead of the bridge) shadows that
// wildcard and serves plans in-process — the same co-resident move billing.go makes
// for usage/balance. RequestContext supplies the namespaced context promo.Active reads.
app.Get("/v1/billing/plans", commercemid.RequestContext(), commercebilling.ListPlans)
// The rest of the console's billing READS, served co-resident for the SAME reason
// plans is: commerce's api.Route() billing bundle is never compiled here, so without
// these registrations every one of them falls through to the account bridge's
// /v1/billing/* wildcard, which forwards to COMMERCE_URL — the public api.hanzo.ai
// edge, which is THIS binary — and self-dispatches into a 502 loop. In prod there is
// no separate commerce backend to point COMMERCE_URL at (the in-cluster `commerce`
// Service selects the cloud pods), so co-residence is the only way to break the loop.
//
// Chain: RequestContext (gated context) → IAMTokenRequired (resolves the org from the
// gateway-validated X-Org-Id into Locals("organization"), the namespace commerce's
// GetOrganization reads) → PinBillingSubject (pins the caller's OWN billing subject
// into the query — the SAME isolation the bridge applies, so a read can never widen
// past the caller; fail-closed for an unvalidated caller) → the commerce handler. The
// specific paths shadow the bridge wildcard (order 100 < 122). payment-config is
// org-scoped, not subject-scoped, but PinBillingSubject is still the auth gate that
// keeps an unvalidated caller from reaching GetOrganization; its pinned (unused)
// subject params are ignored by that handler.
billingRead := []struct {
path string
h zip.Handler
}{
{"/v1/billing/invoices", commercebilling.ListInvoices},
{"/v1/billing/invoices/:id/pdf", commercebilling.DownloadInvoicePDF},
{"/v1/billing/subscriptions", commercebilling.ListBillingSubscriptions},
{"/v1/billing/spend-alerts", commercebilling.ListSpendAlerts},
{"/v1/billing/payouts", commercebilling.ListPayouts},
{"/v1/billing/payment-config", commercebilling.GetPaymentConfig},
}
for _, r := range billingRead {
app.Get(r.path,
commercemid.RequestContext(),
iammiddleware.IAMTokenRequired(),
accountclient.PinBillingSubject(),
r.h,
)
}
// GET /v1/billing/spend-alerts/authorize — the per-request per-scope spend-CAP
// VERDICT the request-edge metering gate consumes (clients/metering scopeAuthorize,
// pathLimitsAuthorize). It is a SERVICE-token S2S read (COMMERCE_SERVICE_TOKEN +
// X-Org-Id), NOT a browser/IAM read — so it needs its OWN registration, distinct from
// the console billingRead block above: without a co-resident handler this authorize
// fell through to the account bridge's /v1/billing/* wildcard (order 122), which — being
// service-token-forwardable (billing.go billingForwardable) — re-forwarded it to
// COMMERCE_URL (the public api.hanzo.ai edge = THIS binary) over commerceinproc's
// self-routing transport, re-entering the same wildcard until the depth-8 guard refused
// → 502 → the gate fails OPEN (the cap is a policy overlay, so no traffic was blocked,
// but ~135 502s/30m spammed the money path and each burned 8 full-app dispatches). The
// plain GET /v1/billing/spend-alerts (registered above) already broke this loop for the
// CRUD read; this closes the /authorize sibling the metering gate hits on every call.
//
// Chain mirrors commerce's OWN gate on this route (api/billing/handlers.go: the `billing`
// group's userRequired = TokenRequired) plus the RequestContext the standalone supplies
// globally — NOT the IAM/PinBillingSubject console chain: a raw service token is not an
// IAM JWT, so IAMTokenRequired would leave GetOrganization unset and AuthorizeSpendCap
// would 500. TokenRequired authenticates the service token AND resolves the tenant from
// the gateway-pinned X-Org-Id into Locals("organization"), which AuthorizeSpendCap reads.
// The specific route shadows the bridge wildcard (order 100 < 122). No PlatformOnly:
// authorize is a per-org cap read, not a cross-org mint (unlike auto-recharge/run-all).
app.Get("/v1/billing/spend-alerts/authorize",
commercemid.RequestContext(),
commercemid.TokenRequired(),
commercebilling.AuthorizeSpendCap,
)
// Self-service spend-cap CRUD WRITES — the customer half of the cap: a customer
// (or the admin S2S) CREATES / EDITS / REMOVES their own usage caps. These are the
// write siblings of the co-resident GET /v1/billing/spend-alerts list; without their
// own registration they too fell through the account bridge's /v1/billing/* wildcard
// (billingForwardable includes POST spend-alerts) into the SAME 502 self-dispatch loop
// authorize hit — so a customer could not set a cap AT ALL in the unified binary
// (POST/PATCH/DELETE all 502'd). Same chain commerce's own route table gates them with
// (api/billing/handlers.go:322-325, the `user` group's userRequired = TokenRequired) +
// the global RequestContext — an IAM JWT OR the COMMERCE_SERVICE_TOKEN, org resolved
// from the gateway-pinned X-Org-Id into Locals("organization"). Org-scoped by that
// namespace (a caller only ever writes their OWN org's caps; a foreign :id is a
// not-found miss in the caller's namespace), so no PinBillingSubject — spend-alerts are
// org-level, not billing-subject-level. Shadow the bridge wildcard (order 100 < 122).
// A spend cap is a FINANCIAL SAFETY control, so its writes are gated to an ORG ADMIN
// (or SuperAdmin, or the trusted S2S service token for the SuperAdmin cap-oversight
// Forward) — never any authenticated member. commerce's own `user` group admits any
// member, which would let a compromised member key DELETE the org's cap (→ unbounded
// spend) or POST a 1¢ enforce cap (→ org-wide 402 DoS). requireSpendCapAdmin closes that.
app.Post("/v1/billing/spend-alerts",
commercemid.RequestContext(),
commercemid.TokenRequired(),
requireSpendCapAdmin(),
commercebilling.CreateSpendAlert,
)
app.Patch("/v1/billing/spend-alerts/:id",
commercemid.RequestContext(),
commercemid.TokenRequired(),
requireSpendCapAdmin(),
commercebilling.UpdateSpendAlert,
)
app.Delete("/v1/billing/spend-alerts/:id",
commercemid.RequestContext(),
commercemid.TokenRequired(),
requireSpendCapAdmin(),
commercebilling.DeleteSpendAlert,
)
// POST /v1/billing/topup/token — the INLINE Square card top-up (the console's
// "Billing → Credits → add credits": the Square Web Payments SDK tokenizes the card
// IN THE BROWSER → a single-use nonce → this endpoint charges it and credits the
// caller's balance). commerce's api.Route() billing bundle is NOT compiled into the
// co-resident embed, so — exactly like plans/invoices/spend-alerts above — without
// this registration the POST fell through to the account bridge's /v1/billing/*
// wildcard (order 122). That wildcard is service-token-forwardable for topup/token
// (billing.go billingForwardable), so billingData re-forwarded it to COMMERCE_URL
// (default the public api.hanzo.ai edge = THIS binary) over commerceinproc's
// self-routing transport, re-entering the SAME wildcard until the depth-8 guard
// refused → the "commerceinproc: in-process dispatch depth 8 exceeded" 502 that broke
// top-up outright. Registering commerce's real TopupWithToken co-resident here
// (order 100 < 122) shadows the wildcard and serves the charge in-process at depth 1
// — no HTTP hop, no self-dispatch. topup/token STAYS in billingForwardable as the
// split-deploy fallback (a standalone commerce still serves it); co-residence just
// wins first.
//
// Chain — the browser money-WRITE posture, byte-for-byte what the bridge applied:
// RequireCSRF — the ambient-cookie anti-CSRF gate the bridge's requireCSRF
// wrapped POST /v1/billing/* with (a Bearer/gateway caller is
// not CSRF-able; an ambient-cookie write needs the token).
// RequestContext — the gated request context commerce's handler + ledger read.
// IAMTokenRequired — resolves the org from the gateway-validated X-Org-Id into
// Locals("organization"), which TopupWithToken.GetOrganization
// + topupDestination read as the org billing key.
// PinBillingSubject — pins ?user= to the caller's OWN account.Payer subject (the
// SAME rule the ai spend-gate debits and billingData pins), so
// the credit lands on the caller's subject (person=org/name) and
// can never be widened; fail-closed for an unvalidated caller —
// the IDOR boundary stays exactly where billingData put it.
// The card PAN never touches this binary: TopupWithToken charges the Square nonce only,
// and the settled charge itself is the mint authority (mintauth.WithAuthorized).
app.Post("/v1/billing/topup/token",
accountclient.RequireCSRF(),
commercemid.RequestContext(),
iammiddleware.IAMTokenRequired(),
accountclient.PinBillingSubject(),
commercebilling.TopupWithToken,
)
// The remaining console billing WRITES that share topup/token's self-dispatch loop
// class — each is a POST the console makes (billingForwardable in billing.go), each had
// NO co-resident handler, so each fell through to the account bridge's /v1/billing/*
// wildcard (order 122) and re-entered it over commerceinproc until the depth-8 guard
// refused (the same "in-process dispatch depth 8 exceeded" 502 that broke top-up). Each
// commerce handler exists in the vendored module (v1.49.13); registering them co-resident
// (order 100 < 122) shadows the wildcard and serves the write in-process at depth 1. They
// STAY in billingForwardable as the split-deploy fallback (same precedent as topup/token
// + spend-alerts). Chain matches the bridge's write posture byte-for-byte:
//
// - RequireCSRF — the ambient-cookie anti-CSRF gate the bridge wrapped POST
// /v1/billing/* with (Bearer/gateway callers are not CSRF-able).
// - RequestContext — the gated request context commerce's handlers read.
// - IAMTokenRequired — resolves the org from the gateway-validated X-Org-Id into
// Locals("organization") — the namespace GetOrganization reads.
// - PinBillingSubject — pins the caller's OWN account.Payer subject into BOTH query and
// body AND fail-closes an unvalidated caller. It is the auth gate
// on every one, and the IDOR control on the subject-scoped one.
//
// payment-methods (save a card-on-file / vault a Square nonce) is SUBJECT-scoped: commerce's
// CreatePaymentMethod reads `customerId` from the BODY, so PinBillingSubject's body-pin is
// load-bearing here — a member can only vault a card for their OWN subject, exactly the
// boundary billingData's scopedBillingBody enforced. The Square nonce goes to Square; the
// PAN never touches this binary.
app.Post("/v1/billing/payment-methods",
accountclient.RequireCSRF(),
commercemid.RequestContext(),
iammiddleware.IAMTokenRequired(),
accountclient.PinBillingSubject(),
commercebilling.CreatePaymentMethod,
)
// subscriptions/:id/{cancel,reactivate} are org-NAMESPACE-scoped: commerce's handlers
// resolve the subscription by `:id` WITHIN the caller's org namespace (a foreign org's id
// is a 404 miss), so tenancy is the namespace IAMTokenRequired resolves and PinBillingSubject
// is the fail-closed-anon auth gate — its pinned subject params are ignored by these
// handlers (the SAME role it plays for the org-scoped payment-config read). The bridge's
// subject-pin was likewise a no-op for these, so nothing is dropped.
app.Post("/v1/billing/subscriptions/:id/cancel",
accountclient.RequireCSRF(),
commercemid.RequestContext(),
iammiddleware.IAMTokenRequired(),
accountclient.PinBillingSubject(),
commercebilling.CancelBillingSubscription,
)
app.Post("/v1/billing/subscriptions/:id/reactivate",
accountclient.RequireCSRF(),
commercemid.RequestContext(),
iammiddleware.IAMTokenRequired(),
accountclient.PinBillingSubject(),
commercebilling.ReactivateBillingSubscription,
)
// 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.
@@ -164,6 +415,19 @@ func mountCommerce(app *zip.App, deps cloud.Deps) error {
commerceinproc.SetApp(app)
commerceclient.PublishEmbedded(embedded)
// Usage-cap enforcement on the FINANCE path. The unified binary records usage in
// the finance ledger (fin.RecordUsage), NOT commerce's transaction store — which
// it leaves empty — so the cap must read spend from, and fire alerts on, the
// finance ledger. Two seams, both org-wide (the finance Entry carries no scope;
// per-scope caps are a follow-up):
// - SetPeriodSpendReader: AuthorizeSpendCap's scopeSpentCents reads the org's
// finance period spend instead of the empty commerce transaction ledger, so
// a real LLM request increments the cap's `spent` and trips the 402.
// - SetUsageHook: after each finance debit, fire the org's spend-alerts on the
// SAME crossing (the alert half), reading the same finance spend + debouncing.
commercebilling.SetPeriodSpendReader(financePeriodSpend)
financeclient.SetUsageHook(fireCapAlert)
lg.Info("commerce embedded natively (hanzoai/commerce module on the shared zip app)",
"data_dir", dataDir,
"brand", deps.Brand,
@@ -215,3 +479,57 @@ func mountCommerceFailClosed(app *zip.App) {
app.All(p+"/*", failed)
}
}
// financePeriodSpend is the usage-cap's period-spend source (injected into commerce
// via SetPeriodSpendReader). It returns the org's finance-ledger usage in cents since
// the start of the CURRENT UTC month — the window the cap resets on (mirrors
// commerce periodStartUTC). Org-wide: the finance Entry carries no project/service,
// so scope args are ignored and the org total is returned (what the covering
// org-wide spend-alert row binds on). A finance impl without the sum capability, or a
// split deploy (no co-resident finance), reports 0 — the cap can never over-count.
func financePeriodSpend(ctx context.Context, org string, test bool, _, _ string) (int64, error) {
fin := financeclient.Current()
if fin == nil {
return 0, nil
}
summer, ok := fin.(interface {
SumUsageSince(context.Context, string, bool, int64) (int64, error)
})
if !ok {
return 0, nil
}
n := time.Now().UTC()
since := time.Date(n.Year(), n.Month(), 1, 0, 0, 0, 0, time.UTC).Unix()
return summer.SumUsageSince(ctx, org, test, since)
}
// fireCapAlert fires the org's spend-alerts after a finance usage debit — the alert
// half of the cap on the finance path (wired via finance.SetUsageHook). It resolves
// the org's commerce datastore (where the spend-alert rows live) and calls the
// exported commerce trigger, which reads the org's period spend via financePeriodSpend
// and stamps/debounces. Detached + best-effort; never blocks the money path. Runs in
// its own goroutine (the hook is invoked with `go`), so a background context is right.
func fireCapAlert(org string, test bool, project, service string) {
if strings.TrimSpace(org) == "" {
return
}
ctx := commercensctx.WithNamespace(context.Background(), org)
db := commercedatastore.New(ctx)
commercebilling.FireSpendAlerts(ctx, db, org, test, project, service, nil)
}
// requireSpendCapAdmin gates a spend-alert WRITE to a validated ORG ADMIN or platform
// SuperAdmin (the unforgeable SanitizeIdentity-minted X-User-IsOrgAdmin / isAdmin bits),
// OR the trusted in-proc S2S service token (the SuperAdmin cap-oversight Forward + internal
// automation). A validated non-admin MEMBER is REFUSED (403): a spend cap is a financial
// safety boundary — a member must not be able to delete the org's cap (→ unbounded spend)
// or set a punitive 1¢ cap (→ org-wide 402 DoS). The read paths (list/authorize) stay
// member/S2S-open; only the mutations require admin.
func requireSpendCapAdmin() zip.Handler {
return func(c *zip.Ctx) error {
if principal.IsSuperAdmin(c) || principal.IsOrgAdmin(c) || accountclient.IsServiceToken(c) {
return c.Next()
}
return zip.ErrForbidden("org admin required to change spend caps")
}
}
+21
View File
@@ -32,6 +32,8 @@ func TestCommercePrefixesPinned(t *testing.T) {
"/v1/billing/auto-recharge": false,
"/v1/billing/webhooks": false,
"/v1/store": false,
"/v1/catalog": false,
"/v1/plans": false,
}
for _, p := range commercePrefixes {
if _, ok := want[p]; ok {
@@ -84,6 +86,25 @@ func TestStoreSurfaceRoutedToCommerceNotAIGate(t *testing.T) {
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")
}
// The platform-admin catalog CMS (admin.hanzo.ai's editor) must reach commerce —
// where each handler is requireSuperAdmin-gated (anon → 401/403) — never the AI
// /v1/* balance gate, which would 402 the editor's list/edit instead.
if code, _ := doReq(t, app, http.MethodGet, "/v1/catalog/entries"); code == http.StatusPaymentRequired {
t.Fatalf("GET /v1/catalog/entries fell through to the AI balance gate (402) — /v1/catalog must be a commercePrefix so the SuperAdmin CMS reaches commerce")
}
if code, _ := doReq(t, app, http.MethodPut, "/v1/catalog/entries/cloud-starter"); code == http.StatusPaymentRequired {
t.Fatalf("PUT /v1/catalog/entries/:slug fell through to the AI balance gate (402) — the whole catalog CMS must be commerce-owned")
}
// The platform-admin plan authority CMS (increment 3a) must also reach commerce —
// requireSuperAdmin-gated (anon → 401/403) — never the AI /v1/* 402 gate.
if code, _ := doReq(t, app, http.MethodGet, "/v1/plans/entries"); code == http.StatusPaymentRequired {
t.Fatalf("GET /v1/plans/entries fell through to the AI balance gate (402) — /v1/plans must be a commercePrefix so the plan authority CMS reaches commerce")
}
if code, _ := doReq(t, app, http.MethodPut, "/v1/plans/entries/pro"); code == http.StatusPaymentRequired {
t.Fatalf("PUT /v1/plans/entries/:slug fell through to the AI balance gate (402) — the whole plan authority CMS must be commerce-owned")
}
}
// doReq drives one request through the mounted app and returns (status, body).
+9 -7
View File
@@ -7,13 +7,15 @@ import (
)
// TestFrameworkContentModulesLinked guards the framework CONTENT modules
// (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 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.
// (cms/erp/help) against silent removal. cms/erp are pure fixture lanes reached
// ONLY via blank imports in apps.go; help is a real import + a Wire() spec (it also
// mounts the /v1/help public plane) but still contributes its DocTypes the SAME way,
// from a package init() (framework.RegisterModule). #248 dropped the blank 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 — for help, dropping the import
// would also drop its Wire() spec and fail TestWireOrderMatchesFrozen, but this keeps
// the fixture-linkage guard uniform across all three lanes.
func TestFrameworkContentModulesLinked(t *testing.T) {
got := make(map[string]bool)
for _, m := range framework.RegisteredModules() {
+99
View File
@@ -1,9 +1,20 @@
package apps
import (
"context"
"encoding/json"
"time"
"github.com/hanzoai/cloud/clients/ads"
"github.com/hanzoai/cloud/clients/automations"
"github.com/hanzoai/cloud/clients/campaign"
"github.com/hanzoai/cloud/clients/coding"
"github.com/hanzoai/cloud/clients/experiments"
"github.com/hanzoai/cloud/clients/framework"
"github.com/hanzoai/cloud/clients/git"
"github.com/hanzoai/cloud/clients/guide"
"github.com/hanzoai/cloud/clients/integrations"
"github.com/hanzoai/cloud/clients/principal"
)
// wire_seams.go wires cross-subsystem in-process seams that cannot be a MountSpec
@@ -20,4 +31,92 @@ import (
// failures are non-fatal and dropped).
func init() {
integrations.SetCodingDispatcher(coding.NewDispatcher(git.CloneURL, git.VerifyRef, nil))
// Guide growth-OBSERVE seam: the /v1/guide/profile observe layer reads the org's
// real platform truth through injected, org-scoped, honest-degrading probes.
// clients/guide imports NONE of these subsystems (decomplected), so the
// composition root — the ONE place that imports them all — binds the reads, the
// same injected-function pattern the coding dispatcher above uses. Each probe is
// PROVABLY org-scoped: framework.ModuleInstalled keys GetDocType on the org;
// integrations.Connected keys store.Get on the org and never surfaces the token.
// HasDeployment/RevenueCents/RecordCount are LEFT NIL: deploy is cluster/admin-
// scoped (not per-org) and commerce-revenue-of-record + the crm record count have
// no clean per-org in-process read yet — binding a read whose org-scoping we
// cannot guarantee would be the bug. Until a provably-org-scoped read lands their
// signals honest-degrade to not-present (the vocabulary is the contract; a nil
// seam can never be a spurious true).
guide.BindSignals(guide.Signals{
ModuleInstalled: framework.ModuleInstalled,
ConnectorPresent: integrations.Connected,
})
// Inbound-event seam: a verified provider webhook (or chat channel) in
// clients/integrations fires the automations engine's Deliver here — the ONE place
// that imports both, so integrations never has to import automations (which imports
// it, for credential custody). A primitive-typed adapter keeps the seam free of the
// automations types.
integrations.SetAutomationTrigger(func(ctx context.Context, org, source, name, dedupeKey string, depth int, payload map[string]any) (int, error) {
return automations.Deliver(ctx, org, automations.TriggerEvent{
Source: source, Name: name, DedupeKey: dedupeKey, Depth: depth, Payload: payload,
})
})
// GTM PAID channel: the /v1/campaign orchestrator fans out to executors that
// satisfy campaign.Channel; the composition root is the ONE place that imports
// both campaign and the ad plane, so it adapts ads' connector-consuming
// execution funcs (provider.go — each resolves the org's ad token through
// integrations.TokenFor and fails closed) onto the primitive-typed channel
// seam. campaign never imports ads and ads never imports campaign — the same
// injected-function decoupling the coding dispatcher above uses.
campaign.RegisterChannel(campaign.NewChannel(campaign.KindPaid,
func(ctx context.Context, org string, p campaign.Plan) (campaign.Ref, error) {
r, err := ads.LaunchPaid(ctx, org, ads.PaidPlan{
Platform: p.Platform, Account: p.Account, Name: p.Name,
Objective: p.Objective, BudgetCents: p.BudgetCents, ScheduleAt: p.ScheduleAt,
})
return campaign.Ref{Platform: r.Platform, Account: r.Account, ExternalID: r.ExternalID, Status: r.Status, Detail: r.Detail}, err
},
func(ctx context.Context, org string, ref campaign.Ref) (int64, error) {
return ads.PaidSpend(ctx, org, ads.PaidRef{Platform: ref.Platform, Account: ref.Account, ExternalID: ref.ExternalID})
},
func(ctx context.Context, org string, ref campaign.Ref) error {
return ads.PausePaid(ctx, org, ads.PaidRef{Platform: ref.Platform, Account: ref.Account, ExternalID: ref.ExternalID})
},
))
// GTM creative A/B composes the merged EXPERIMENT primitive (clients/experiments)
// — campaign never reinvents bucketing or measurement. Assign resolves the
// subject's variant (creative) from the experiment's flag; Analyze is pull-model
// (it reads the metric from analytics itself), returned as opaque JSON so
// campaign stays decoupled from the analysis type. Campaign-linked experiments
// live in the org's default project. Both are nil-safe upstream: an org that
// never created a "campaign:<id>" experiment runs a single creative (Assign
// errors → "" → Content[0]).
campaign.SetExperiment(
func(ctx context.Context, org, experimentID, subject string) (string, error) {
a, err := experiments.Assign(ctx, org, principal.DefaultProject, experimentID, subject, nil)
if err != nil {
return "", err
}
return a.Variant, nil
},
func(ctx context.Context, org, experimentID string, start, end time.Time) (json.RawMessage, error) {
an, err := experiments.Analyze(ctx, org, principal.DefaultProject, experimentID, start, end, 0.05)
if err != nil {
return nil, err
}
return json.Marshal(an)
},
)
// GTM ORGANIC + EMAIL channels wire HERE the same way once their executors land
// (designed follow-ons — the publish rename + marketing email-connector wiring):
//
// campaign.RegisterChannel(campaign.NewChannel(campaign.KindOrganic,
// publish.Syndicate, publish.NoSpend, publish.Unpublish)) // social connectors
// campaign.RegisterChannel(campaign.NewChannel(campaign.KindEmail,
// marketing.Broadcast, marketing.NoSpend, marketing.Halt)) // email connectors
//
// Until wired, those channels record "unavailable" on a fan-out (honest) and a
// campaign runs a single creative — never a fabricated launch or variant.
}
+101 -86
View File
@@ -23,92 +23,107 @@ var frozen = []struct {
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
{"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
{"plan", true, false}, // was order 111; enable id normalized plans->plan (routes stay /v1/plans/*)
{"pricing", true, false}, // was order 112
{"storage", true, false}, // was order 118
{"provisioning", false, false}, // was order 120
{"billing", false, false}, // was order 121
{"rollingcap", false, false}, // rolling spend-cap gate (after billing); golden drifted — refrozen
{"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
{"dns", false, false}, // new: /v1/dns zone plane (after projects)
{"domain", false, false}, // new: Hanzo Domains registrar (/v1/domain), after dns
{"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
{"help", false, false}, // new: Hanzo Support public plane /v1/help (after knowledge; framework lane with a companion subsystem, no store → no Shutdown)
{"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
{"leaderboard", false, true}, // new: gamified usage analytics (after usage), owns opt-in SQLite (Shutdown)
{"crm", false, false}, // was order 131
{"marketing", false, true}, // new: marketing domain fold (after crm)
{"ads", false, true}, // new: ads domain fold (after crm)
{"campaign", false, true}, // new: /v1/campaign GTM orchestration (after ads); fans out to channels
{"validators", false, true}, // new: NFT-gated node provisioning (after ads); golden refrozen
{"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
{"venue", false, false}, // new: /v1/cloud connect-a-cloud-account plane (after visor); folds discovered clusters into the fleet
{"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
{"destinations", false, true}, // new: /v1/destinations CDP fan-out (after integrations, before cloudflare)
{"cloudflare", false, false}, // new: /v1/cloudflare edge plane (after integrations)
{"sbom", true, false}, // was order 137
{"team", false, true}, // was order 138
{"settings", false, true}, // was order 138
{"notify", true, false}, // was order 139
{"channels", false, true}, // new: /v1/channels transport plane (after notify; must mount after integrations so RegisterIngress installs before webhooks emit)
{"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
{"benchmark", false, false}, // benchmark plane (after evals, before treasury)
{"research", false, true}, // R&D evidence plane + /research board (HIP-0512), arena sibling after benchmark; Shutdown closes per-org stores
{"experiments", false, true}, // unified A/B EXPERIMENT primitive: composes flags(assign)+analytics(measure)+research(evidence); Shutdown closes the registry stores
{"treasury", false, true}, // was order 146
{"admin", false, false}, // was order 146
{"admission", false, true}, // launch-control gate: composes flags (registry+seed+mode route+Enforce); Shutdown closes the registry store
{"tasks", false, false}, // was order 147; platform cron folded in as a sub-mount of tasks.Mount (was a separate entry)
{"automations", false, true}, // was order 148; connectorruntime (POST /v1/automations/connectors/:id/run) folded in as a sub-mount of automations.Mount
{"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)
{"compliance", true, true}, // new: Hanzo Compliance — KYC/KYB + accreditation + audit posture (after company)
{"legal", true, true}, // new: Hanzo Legal — template + generation engine + e-sign/filing (after compliance)
{"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
+64
View File
@@ -0,0 +1,64 @@
// Copyright 2026 Hanzo AI Inc. All Rights Reserved.
package apps
import (
"context"
"errors"
"testing"
)
// stubKMS is a KMSClient whose GetSecret returns a sealed value when present or a
// not-found error otherwise — mirroring the co-resident store that, in production,
// holds no upstream provider keys (they are provisioned as KMS-injected env).
type stubKMS struct{ sealed map[string]string }
func (s stubKMS) GetSecret(_ context.Context, ref string) ([]byte, error) {
if v, ok := s.sealed[ref]; ok {
return []byte(v), nil
}
return nil, errors.New("secret not found")
}
func (stubKMS) PutSecret(context.Context, string, []byte) error { return nil }
func (stubKMS) Sign(context.Context, string, []byte) ([]byte, error) { return nil, nil }
// TestZenKeyResolver_EnvFallback pins the production wiring: the upstream provider
// key is provisioned as env (from the cloud-api-llm-keys secret), NOT sealed in the
// co-resident KMS store, so a KMS miss must resolve to the env value rather than
// returning "" (which would send an empty bearer upstream → provider 401).
func TestZenKeyResolver_EnvFallback(t *testing.T) {
const env = "DO_AI_API_KEY"
t.Setenv(env, "env-provisioned-key")
// KMS store has no upstream keys (the real deployment state) — resolve from env.
if got := zenKeyResolver(stubKMS{})(context.Background(), env); got != "env-provisioned-key" {
t.Fatalf("KMS-miss: got %q, want env value", got)
}
// A nil KMS client (KMS disabled) — still resolve from env.
if got := zenKeyResolver(nil)(context.Background(), env); got != "env-provisioned-key" {
t.Fatalf("nil-KMS: got %q, want env value", got)
}
}
// TestZenKeyResolver_EnvTakesPrecedence pins the resolution ORDER: env is read
// FIRST, then KMS — the same order ai uses on the prod hot path. The operator
// injects provider keys as env from the KMS-synced secret, so the env is the live
// value; the co-resident store is the fallback. A key present in BOTH surfaces
// resolves to the env value.
func TestZenKeyResolver_EnvTakesPrecedence(t *testing.T) {
const env = "ANTHROPIC_API_KEY"
t.Setenv(env, "env-key")
got := zenKeyResolver(stubKMS{sealed: map[string]string{env: "sealed-key"}})(context.Background(), env)
if got != "env-key" {
t.Fatalf("got %q, want env-key (env precedence)", got)
}
}
// TestZenKeyResolver_AbsentEverywhere keeps the fail-fast contract: absent from both
// surfaces resolves to "" so zen refuses rather than serving for free.
func TestZenKeyResolver_AbsentEverywhere(t *testing.T) {
if got := zenKeyResolver(stubKMS{})(context.Background(), "MISSING_KEY_XYZ"); got != "" {
t.Fatalf("got %q, want empty", got)
}
}
-50
View File
@@ -1,50 +0,0 @@
// Copyright 2026 The Hanzo Authors. 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 cloud
import (
"os"
"testing"
)
// TestJWTAudiences_AcceptsAdminGuard is the operator-cockpit keystone. The
// 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 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
// forwarded bearer validates.
func TestJWTAudiences_AcceptsAdminGuard(t *testing.T) {
os.Unsetenv("CLOUD_JWT_AUDIENCES")
os.Unsetenv("GATEWAY_ALLOWED_AUDIENCES")
has := func(list []string, v string) bool {
for _, s := range list {
if s == v {
return true
}
}
return false
}
if !has(defaultJWTAudiences, "hanzo-admin-guard") {
t.Fatalf("defaultJWTAudiences must include hanzo-admin-guard (the admin-cockpit guard client_id); got %v", defaultJWTAudiences)
}
if !has(jwtAudiencesFromEnv(), "hanzo-admin-guard") {
t.Fatalf("resolved JWT audiences must include hanzo-admin-guard; got %v", jwtAudiencesFromEnv())
}
}
-47
View File
@@ -1,47 +0,0 @@
// Copyright 2026 The Hanzo Authors. 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 cloud
import (
"os"
"testing"
)
// TestJWTAudiences_AcceptsHanzoWorld pins the world.hanzo.ai OIDC client. IAM
// mints world's access tokens with aud=hanzo-world (each app's aud is its
// client_id). Those bearers hit cloud-api; the identity sanitizer only trusts a
// principal whose aud is in this allowlist. If hanzo-world is not accepted the
// token resolves anonymous and the analyst's api.hanzo.ai calls 401. Pin the
// client_id into the baked default so the forwarded bearer validates.
func TestJWTAudiences_AcceptsHanzoWorld(t *testing.T) {
os.Unsetenv("CLOUD_JWT_AUDIENCES")
os.Unsetenv("GATEWAY_ALLOWED_AUDIENCES")
has := func(list []string, v string) bool {
for _, s := range list {
if s == v {
return true
}
}
return false
}
if !has(defaultJWTAudiences, "hanzo-world") {
t.Fatalf("defaultJWTAudiences must include hanzo-world (the world.hanzo.ai client_id); got %v", defaultJWTAudiences)
}
if !has(jwtAudiencesFromEnv(), "hanzo-world") {
t.Fatalf("resolved JWT audiences must include hanzo-world; got %v", jwtAudiencesFromEnv())
}
}
+50 -4
View File
@@ -37,10 +37,10 @@ type keyResolver interface {
// 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]
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
@@ -55,6 +55,52 @@ func newIAMKeys() *iamKeys {
}
}
// sharedKeys memoizes ONE API-key resolver (and its 60s cache) for the whole
// binary. The identity boundary (SanitizeIdentity, via newIdentityValidator) and
// any subsystem that must resolve a key OUT-OF-BAND of the Authorization header
// (analytics capture: a project key posted in the SDK body/query) both go through
// this ONE seam, so a key resolves to the SAME org either way and IAM sees one
// warm cache — never a second, drifting resolver.
var (
sharedKeysOnce sync.Once
sharedKeysInst *iamKeys
)
func sharedKeys() *iamKeys {
sharedKeysOnce.Do(func() { sharedKeysInst = newIAMKeys() })
return sharedKeysInst
}
// maxKeyOrgLen bounds a resolved org key the same way principal.MaxOrgLen does: the
// org becomes a warehouse partition key, so an over-long value (malformed / hostile)
// is refused rather than stored.
const maxKeyOrgLen = 128
// OrgForKey resolves an opaque Hanzo API key (hk-/sk-/pk-/fw_/hz_) to the org it
// belongs to — the SAME owner org SanitizeIdentity mints when that key arrives as a
// bearer — through the ONE IAM key seam (get-user?accessKey). It is the exported
// door a keyed, bearer-less SDK path uses to attribute a project key to a tenant.
//
// FAILS CLOSED: ("", false) for a non-key-shaped string, an unknown/unresolvable
// key, an unconfigured resolver, or an out-of-bounds org — never a fabricated or
// default tenant, so a bad key can never be written into another org's partition.
// The isAPIKey prefix gate keeps garbage strings off the IAM network path.
func OrgForKey(ctx context.Context, key string) (string, bool) {
key = strings.TrimSpace(key)
if !isAPIKey(key) {
return "", false
}
claims := sharedKeys().resolve(ctx, key)
if claims == nil {
return "", false
}
owner := strings.TrimSpace(claims.Owner)
if owner == "" || len(owner) > maxKeyOrgLen {
return "", false
}
return owner, true
}
// 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
+72 -58
View File
@@ -33,6 +33,7 @@ import (
"github.com/go-jose/go-jose/v4/jwt"
"github.com/hanzoai/cloud/clients/principal"
model "github.com/hanzoai/iam/pkg/model"
)
// idClaims is the subset of Hanzo IAM JWT claims the identity sanitizer needs.
@@ -40,12 +41,14 @@ import (
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"`
Owner string `json:"owner"` // org slug (the org)
Project string `json:"project"` // org SUB-SCOPE within owner (empty ⟹ default project)
BillingAccount string `json:"billing_account"` // WHO PAYS, stated by IAM (empty ⟹ pre-claim token)
Name string `json:"name"` // display name (id fallback)
PreferredUsername string `json:"preferred_username"` // id fallback
Email string `json:"email"`
IsAdmin bool `json:"isAdmin"`
Orgs []model.OrgRef `json:"orgs"` // membership SET (home first); empty on legacy tokens
}
// mintedProject returns the project id to stamp into X-Project-Id, or "" when the
@@ -62,6 +65,20 @@ func (c *idClaims) mintedProject() string {
return strings.TrimSpace(c.Project)
}
// mintedBillingAccount returns the funding account to stamp into
// X-Billing-Account-Id, or "" when the header must be OMITTED (a token minted
// before IAM shipped the claim, or one IAM could not attribute).
//
// WHO PAYS IS NOT A CLIENT'S TO NAME. This rides the validated `billing_account`
// claim — IAM's signed statement, resolved at the identity boundary from the real
// grant context — exactly like `owner` and `project`. It mirrors the edge
// (iamauth.Claims.MintedBillingAccount) byte-for-byte, so the in-binary path binds
// the same header the gateway would, and ai/object.Payer reads the same payer on
// both. The raw client copy is deleted on ingress and NEVER restored.
func (c *idClaims) mintedBillingAccount() string {
return strings.TrimSpace(c.BillingAccount)
}
// 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.
@@ -108,43 +125,41 @@ var jwtSigAlgs = []gojose.SignatureAlgorithm{
// (cert-hanzo/cert-lux/cert-zoo/...), keyed by the token kid, so a single
// jwksURL verifies all brands. Only the issuer-string comparison had to widen.
type identityValidator struct {
issuers []string
audiences []string
cache *jwksCache
keys keyResolver // resolves an opaque API key to a principal; nil ⟹ keys stay anonymous
issuers []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
// issuer UNIONED with every white-label brand issuer (BrandIssuers) plus any
// WHITELABEL_ISSUERS override. ttl<=0 uses the 15m JWKS default. The union is
// fail-secure: it only ADDS the known-good brand issuers, never an arbitrary one.
func newIdentityValidator(issuer, jwksURL string, audiences []string, ttl time.Duration) *identityValidator {
//
// Trust is IAM-native: signature (JWKS) + issuer (this set) + expiry. There is NO
// per-app audience allowlist — the `aud` (a minting app's client_id) is IAM's to
// assign, not cloud's to mirror, so a new first-party app needs zero cloud change.
func newIdentityValidator(issuer, jwksURL string, ttl time.Duration) *identityValidator {
return &identityValidator{
issuers: trustedIssuers(issuer),
audiences: audiences,
cache: newJWKSCache(jwksURL, ttl),
keys: newIAMKeys(),
issuers: trustedIssuers(issuer),
cache: newJWKSCache(jwksURL, ttl),
keys: sharedKeys(), // ONE resolver+cache, shared with OrgForKey (analytics capture)
}
}
// kmsMachineAudSuffix is the fixed suffix of a per-org PaaS-KMS sync machine
// identity's audience. Each org's KMS sync authenticates as a dedicated,
// NON-shared IAM application named "<org>-platform-kms" (Organization=<org>,
// client_credentials grant), so IAM stamps the token's aud == the app's own
// clientId == "<org>-platform-kms" (a non-shared app's audience is its clientId,
// object/token_jwt.go tokenAudience) and owner == <org>
// (object/token_oauth.go GetClientCredentialsToken sets owner = app.Organization).
// identity's audience. Each org's KMS sync authenticates as a dedicated, NON-shared
// IAM application named "<org>-platform-kms" (Organization=<org>, client_credentials
// grant), so IAM stamps the token's aud == the app's own clientId == "<org>-platform-kms"
// (object/token_jwt.go tokenAudience) and owner == <org> (object/token_oauth.go
// GetClientCredentialsToken sets owner = app.Organization).
//
// That audience is, by construction, absent from CLOUD_JWT_AUDIENCES — it is
// per-org, not a fixed app — which is EXACTLY why the sync stayed pending: the
// machine token failed the audience check below, SanitizeIdentity treated it as
// anonymous, and the /v1/kms org-scope guard 403'd it before the store. The fix is
// to accept this one audience, but ONLY when it equals the token's OWN owner claim
// plus this suffix, so it certifies "the KMS sync identity for its own org" and
// grants nothing wider. Org-scoping is still enforced downstream by owner at the guard
// (owner == :org); this only lets a legitimately-minted, owner-scoped machine token
// clear validation. A per-org application means a per-org clientSecret — never
// a shared platform-wide reader, which would be a cross-org hole.
// Validation no longer consults the audience at all (trust is signature + issuer +
// expiry), so a machine token clears validate() like any other. This suffix survives
// for the OPPOSITE reason: to RECOGNISE a machine principal (isKMSMachinePrincipal) so
// SanitizeIdentity can DENY it SuperAdmin even when it carries owner==adminOrg — a
// client_credentials machine identity must never wield platform-admin. The match is
// bound to the token's OWN owner claim (<owner>-platform-kms), so it certifies "the
// KMS sync identity for its own org" and grants nothing wider.
const kmsMachineAudSuffix = "-platform-kms"
// kmsMachineAudience returns the audience an org org's PaaS-KMS sync identity
@@ -197,6 +212,15 @@ func (v *identityValidator) validate(raw string) (*idClaims, error) {
return nil, err
}
// Fail SECURE on a misconfigured (empty) trust set: with no trusted issuer every
// token must be REJECTED, never silently admitted. In production the set is always
// non-empty (the primary issuer + BrandIssuers, unioned in config.go so it is
// "never empty"), so this fires ONLY on an operator misconfiguration — and then it
// denies, it never admits (I2).
if len(v.issuers) == 0 {
return nil, fmt.Errorf("identity validator misconfigured: empty issuer set")
}
// Reject a missing issuer: an empty issuer must never pass the set check.
if claims.Issuer == "" {
return nil, fmt.Errorf("missing issuer")
@@ -207,29 +231,22 @@ func (v *identityValidator) validate(raw string) (*idClaims, error) {
if claims.Expiry == nil {
return nil, fmt.Errorf("missing expiry")
}
// Issuer must be one of the trusted brand issuers. go-jose's jwt.Expected
// checks a SINGLE issuer, so the issuer is validated here against the set and
// left out of Expected (audience + expiry stay with Expected).
// Issuer must be one of the trusted brand issuers. go-jose's jwt.Expected checks a
// SINGLE issuer, so the issuer is validated here against the set and left out of
// Expected (only expiry/not-before stay with Expected).
if !issuerAllowed(claims.Issuer, v.issuers) {
return nil, fmt.Errorf("untrusted issuer %q", claims.Issuer)
}
// Audience: the static allowlist (CLOUD_JWT_AUDIENCES / brand app client_ids)
// PLUS the per-org PaaS-KMS sync machine audience bound to THIS token's own
// owner (<owner>-platform-kms). The machine audience is added only when the
// allowlist is active (non-empty — always so in production) and only for the
// token's own org, so accepting it never widens org-scoping: the /v1/kms guard still
// gates on owner == :org. Without this, a real client_credentials machine token
// (aud == its per-org clientId, never in the allowlist) fails here and the
// sync silently stays pending — the activation blocker.
expected := jwt.Expected{}
if len(v.audiences) > 0 {
auds := v.audiences
if mach := kmsMachineAudience(claims.Owner); mach != "" {
auds = append(append(make([]string, 0, len(v.audiences)+1), v.audiences...), mach)
}
expected.AnyAudience = jwt.Audience(auds)
}
if err := claims.Claims.ValidateWithLeeway(expected, 2*time.Minute); err != nil {
// Audience is NOT an access gate. A valid signature from a trusted issuer (both
// checked above) proves IAM minted this token for one of ITS OWN registered apps;
// the `aud` merely names which app. Cloud does not keep a per-app allowlist to
// mirror IAM's registry — that mirror drifted and silently 401'd every new
// first-party app until hand-edited. Org scope is the `owner` claim, enforced by
// every downstream guard; SuperAdmin is owner==adminOrg AND !isKMSMachinePrincipal
// (SanitizeIdentity). Expiry + not-before are STILL enforced here: Expected{} with a
// zero Time validates against time.Now(); an empty AnyAudience skips ONLY the
// audience match (go-jose/v4 jwt/validation.go).
if err := claims.Claims.ValidateWithLeeway(jwt.Expected{}, 2*time.Minute); err != nil {
return nil, fmt.Errorf("claims: %w", err)
}
return &claims, nil
@@ -425,14 +442,11 @@ func trustedIssuers(primary string) []string {
return out
}
// issuerAllowed reports whether iss is one of the trusted issuers. An empty set
// (no primary, no brands — never the case in production) skips the check, matching
// the prior "empty issuer disables the check" behavior; a non-empty set is
// fail-secure (a token whose iss is not in the set is rejected).
// issuerAllowed reports whether iss is one of the trusted issuers. It is
// fail-secure in BOTH directions: an empty trusted set matches NOTHING (deny), so
// a misconfiguration that empties the issuer allowlist rejects every token instead
// of silently disabling the check (I2); a non-empty set rejects any iss not in it.
func issuerAllowed(iss string, trusted []string) bool {
if len(trusted) == 0 {
return true
}
for _, t := range trusted {
if iss == t {
return true
+52
View File
@@ -0,0 +1,52 @@
package cloud
import (
"crypto/rand"
"crypto/rsa"
"testing"
"time"
)
// TestValidate_AudienceIsNotAGate proves the IAM-native trust model: a token signed
// by a trusted issuer validates REGARDLESS of its `aud` (the minting app's client_id).
// Cloud keeps no per-app audience allowlist mirroring IAM's registry — so a brand-new
// first-party app works with zero cloud change, and the specific app tokens the old
// per-app tests pinned (admin-guard, world, team, commerce) are accepted by the SAME
// rule as everything else. Trust is signature + issuer + expiry; org scope is the
// owner claim, enforced downstream. Replaces the three audience_*_test.go files that
// asserted a static allowlist which no longer exists.
func TestValidate_AudienceIsNotAGate(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("genkey: %v", err)
}
jwks := jwksServer(t, &key.PublicKey)
v := newIdentityValidator(testIssuer, jwks.URL, 0)
future := time.Now().Add(time.Hour)
// Every audience — the apps the deleted per-app tests pinned AND a never-registered
// one — validates identically, because aud is not an access gate.
for _, aud := range []string{
"hanzo-admin-guard", // admin.hanzo.ai forward-auth cockpit
"hanzo-world", // world.hanzo.ai analyst tokens
"hanzo-team", // hanzo.team wallet page
"hanzo-commerce", // commerce.hanzo.ai admin AI assistant
"a-brand-new-first-party-app-never-listed-anywhere",
} {
tok := signWith(t, key, tokenClaims(aud, "acme", "", false, future))
id, err := v.validate(tok)
if err != nil {
t.Fatalf("aud=%q from a trusted issuer must validate (no allowlist), got %v", aud, err)
}
if id.Owner != "acme" {
t.Errorf("aud=%q: owner must be carried through, got %q", aud, id.Owner)
}
}
// Expiry is STILL enforced (dropping the aud gate must not disable time checks):
// a token expired beyond the 2m leeway is rejected whatever its aud.
expired := signWith(t, key, tokenClaims("hanzo-commerce", "acme", "", false, time.Now().Add(-time.Hour)))
if _, err := v.validate(expired); err == nil {
t.Error("expired token must be REJECTED even though audience is no longer gated")
}
}
+49 -35
View File
@@ -1,15 +1,15 @@
package cloud
// V6 (the activation blocker) — the identity validator must accept a per-org
// PaaS-KMS sync machine token: a client_credentials JWT whose aud is the org's
// own IAM application clientId "<owner>-platform-kms" (a per-org value, NEVER in
// CLOUD_JWT_AUDIENCES) — but ONLY when that audience is bound to the token's OWN
// owner claim. Before the fix the machine token failed the audience check,
// SanitizeIdentity resolved anonymous, and the /v1/kms guard 403'd it, so the sync
// silently stayed pending. These are white-box unit tests of validate() itself;
// the end-to-end proof through SanitizeIdentity + the real guard lives in
// clients/kms (v6_aud_e2e_test.go). Reuses the jwksServer/signWith/tokenClaims
// helpers from middleware_identity_test.go (same package).
// The per-org PaaS-KMS sync identity authenticates as its own IAM application
// "<owner>-platform-kms" (client_credentials), so its token carries owner=<org> and
// aud=<owner>-platform-kms. Validation no longer gates on the audience at all (trust
// is signature + issuer + expiry), so a machine token clears validate() like any
// other. The owner-bound machine aud survives only to IDENTIFY such a principal
// (isKMSMachinePrincipal) so SanitizeIdentity can DENY it SuperAdmin even in the admin
// org — a client_credentials machine identity must never wield platform-admin. These
// are white-box unit tests of that identification; the end-to-end proof through
// SanitizeIdentity + the real guard lives in clients/kms (v6_aud_e2e_test.go). Reuses
// the jwksServer/signWith/tokenClaims helpers from middleware_identity_test.go.
import (
"crypto/rand"
@@ -18,19 +18,16 @@ import (
"time"
)
func TestIdentityValidator_KMSMachineAudience(t *testing.T) {
func TestIdentityValidator_KMSMachinePrincipal(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("genkey: %v", err)
}
jwks := jwksServer(t, &key.PublicKey)
// The static allowlist deliberately contains NO *-platform-kms audience, so any
// acceptance below can come ONLY from the owner-bound machine-aud rule, not the
// allowlist — this is what makes it a fix and not a config workaround.
v := newIdentityValidator(testIssuer, jwks.URL, []string{"hanzo-console"}, 0)
v := newIdentityValidator(testIssuer, jwks.URL, 0)
future := time.Now().Add(time.Hour)
t.Run("machine token for its own org is accepted", func(t *testing.T) {
t.Run("own-org machine token validates and is recognised as a machine principal", func(t *testing.T) {
c, err := v.validate(signWith(t, key, tokenClaims("maxpower-platform-kms", "maxpower", "", false, future)))
if err != nil {
t.Fatalf("machine token rejected: %v", err)
@@ -38,34 +35,51 @@ func TestIdentityValidator_KMSMachineAudience(t *testing.T) {
if c.Owner != "maxpower" {
t.Fatalf("owner=%q, want maxpower", c.Owner)
}
})
t.Run("machine aud for a DIFFERENT org is rejected (owner-bound)", func(t *testing.T) {
// owner=maxpower but aud=acme-platform-kms: the accepted machine aud is bound
// to the token's OWN owner (maxpower-platform-kms), so this must fail — it is
// not a blanket "*-platform-kms" wildcard.
if _, err := v.validate(signWith(t, key, tokenClaims("acme-platform-kms", "maxpower", "", false, future))); err == nil {
t.Fatal("cross-org machine audience must be rejected (owner-bound)")
if !isKMSMachinePrincipal(c) {
t.Fatal("aud==<owner>-platform-kms must be recognised as a machine principal")
}
})
t.Run("arbitrary audience still rejected (fix is scoped, not a disable)", func(t *testing.T) {
if _, err := v.validate(signWith(t, key, tokenClaims("some-random-app", "maxpower", "", false, future))); err == nil {
t.Fatal("an arbitrary audience must still be rejected")
t.Run("admin-org machine token is recognised so SuperAdmin is denied", func(t *testing.T) {
c, err := v.validate(signWith(t, key, tokenClaims("admin-platform-kms", "admin", "", true, future)))
if err != nil {
t.Fatalf("admin machine token rejected: %v", err)
}
if !isKMSMachinePrincipal(c) {
t.Fatal("admin-org machine token must be recognised (SanitizeIdentity denies it SuperAdmin)")
}
})
t.Run("machine aud with empty owner is rejected (fail closed)", func(t *testing.T) {
// aud="-platform-kms" with owner="": kmsMachineAudience("")=="" so no machine
// audience is granted and the bare suffix is not in the allowlist.
if _, err := v.validate(signWith(t, key, tokenClaims("-platform-kms", "", "", false, future))); err == nil {
t.Fatal("machine aud with empty owner must be rejected")
t.Run("machine aud bound to a DIFFERENT org is not this owner's machine principal", func(t *testing.T) {
// owner=maxpower, aud=acme-platform-kms: the machine-principal match is bound to
// the token's OWN owner (maxpower-platform-kms), not a "*-platform-kms" wildcard.
// It validates (aud is not gated) and is owner-scoped to maxpower downstream.
c, err := v.validate(signWith(t, key, tokenClaims("acme-platform-kms", "maxpower", "", false, future)))
if err != nil {
t.Fatalf("token rejected: %v", err)
}
if isKMSMachinePrincipal(c) {
t.Fatal("a cross-org machine aud must not count as this owner's machine principal")
}
})
t.Run("normal static-allowlist token still accepted (regression)", func(t *testing.T) {
if _, err := v.validate(signWith(t, key, tokenClaims("hanzo-console", "maxpower", "", false, future))); err != nil {
t.Fatalf("static-allowlist token rejected: %v", err)
t.Run("ordinary app token is not a machine principal", func(t *testing.T) {
c, err := v.validate(signWith(t, key, tokenClaims("hanzo-console", "maxpower", "", false, future)))
if err != nil {
t.Fatalf("token rejected: %v", err)
}
if isKMSMachinePrincipal(c) {
t.Fatal("an ordinary app token is not a machine principal")
}
})
t.Run("empty-owner token is never a machine principal (fail closed)", func(t *testing.T) {
c, err := v.validate(signWith(t, key, tokenClaims("-platform-kms", "", "", false, future)))
if err != nil {
t.Fatalf("token rejected: %v", err)
}
if isKMSMachinePrincipal(c) {
t.Fatal(`empty-owner token must never be a machine principal (kmsMachineAudience("")=="")`)
}
})
+35 -65
View File
@@ -1,10 +1,40 @@
package cloud
import (
"crypto/rand"
"crypto/rsa"
"os"
"testing"
"time"
)
// TestValidate_FailSecureOnEmptyTrustSet proves I2: a validator whose resolved
// issuer set is empty REJECTS an otherwise-valid, correctly signed token — the axis
// is never silently disabled. Production always resolves a non-empty set; this
// guards the misconfiguration path (an empty issuer set), which must fail closed,
// not open.
func TestValidate_FailSecureOnEmptyTrustSet(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("genkey: %v", err)
}
jwks := jwksServer(t, &key.PublicKey)
future := time.Now().Add(time.Hour)
tok := signWith(t, key, tokenClaims("hanzo-console", "acme", "", false, future))
// Sanity: a properly configured validator accepts the token.
if _, err := newIdentityValidator(testIssuer, jwks.URL, 0).validate(tok); err != nil {
t.Fatalf("baseline valid token must be accepted, got %v", err)
}
// Empty issuer set → deny (construct directly; trustedIssuers never yields empty
// with a primary, so bypass it to exercise the guard).
vEmptyIss := &identityValidator{issuers: nil, cache: newJWKSCache(jwks.URL, 0), keys: newIAMKeys()}
if _, err := vEmptyIss.validate(tok); err == nil {
t.Error("empty issuer set must REJECT (fail-secure), not accept")
}
}
// TestTrustedIssuers_WhiteLabel proves the in-binary validator's trusted-issuer
// set is the primary issuer UNIONED with every white-label brand issuer plus the
// WHITELABEL_ISSUERS override, deduped, primary-first.
@@ -40,7 +70,8 @@ func TestTrustedIssuers_WhiteLabel(t *testing.T) {
}
// TestIssuerAllowed proves the set membership check: brand issuers pass, an
// outsider is rejected, and an empty set (never in prod) skips the check.
// outsider is rejected, and an empty set is fail-secure — it matches NOTHING (I2),
// so a misconfiguration that empties the allowlist denies every token.
func TestIssuerAllowed(t *testing.T) {
set := []string{"https://hanzo.id", "https://lux.id"}
if !issuerAllowed("https://lux.id", set) {
@@ -49,8 +80,8 @@ func TestIssuerAllowed(t *testing.T) {
if issuerAllowed("https://attacker.id", set) {
t.Error("attacker.id must be rejected")
}
if !issuerAllowed("anything", nil) {
t.Error("empty set must skip the check (matches prior empty-issuer behavior)")
if issuerAllowed("anything", nil) {
t.Error("empty set must DENY (fail-secure), never skip the check")
}
}
@@ -76,7 +107,7 @@ func TestBrandIssuers(t *testing.T) {
// full brand set, so a lux token would pass the issuer gate on the hanzo binary.
func TestNewIdentityValidator_MultiIssuer(t *testing.T) {
os.Unsetenv("WHITELABEL_ISSUERS")
v := newIdentityValidator("https://hanzo.id", "http://iam.hanzo.svc/v1/iam/.well-known/jwks", []string{"hanzo-cloud", "lux-cloud"}, 0)
v := newIdentityValidator("https://hanzo.id", "http://iam.hanzo.svc/v1/iam/.well-known/jwks", 0)
if !issuerAllowed("https://lux.id", v.issuers) {
t.Fatalf("validator must trust the lux issuer, set=%v", v.issuers)
}
@@ -87,64 +118,3 @@ func TestNewIdentityValidator_MultiIssuer(t *testing.T) {
t.Fatalf("validator must reject an untrusted issuer, set=%v", v.issuers)
}
}
// TestBrandAudiences proves every brand's cloud audience (<brand>-cloud) is derived
// from the brands registry — one source of truth, mirroring BrandIssuers.
func TestBrandAudiences(t *testing.T) {
got := BrandAudiences()
for _, want := range []string{"hanzo-cloud", "lux-cloud", "zoo-cloud", "pars-cloud", "bootnode-cloud"} {
found := false
for _, g := range got {
if g == want {
found = true
break
}
}
if !found {
t.Errorf("BrandAudiences()=%v missing %q", got, want)
}
}
}
// TestJWTAudiencesFromEnv_BrandUnion proves the resolved audience allowlist ALWAYS
// includes every brand's <brand>-cloud aud (so a lux token validates), whether the
// list comes from the baked default or a hanzo-only env override — and that an
// env-supplied entry is not duplicated.
func TestJWTAudiencesFromEnv_BrandUnion(t *testing.T) {
has := func(list []string, v string) bool {
for _, s := range list {
if s == v {
return true
}
}
return false
}
// Baked default path (no env).
os.Unsetenv("CLOUD_JWT_AUDIENCES")
os.Unsetenv("GATEWAY_ALLOWED_AUDIENCES")
def := jwtAudiencesFromEnv()
for _, want := range []string{"hanzo-cloud", "lux-cloud", "zoo-cloud", "pars-cloud"} {
if !has(def, want) {
t.Errorf("baked audiences %v missing brand aud %q", def, want)
}
}
// A legacy hanzo-only env override must STILL accept lux-cloud (brand union),
// with no duplicate of the env-supplied hanzo-cloud.
os.Setenv("GATEWAY_ALLOWED_AUDIENCES", "hanzo-app,hanzo-console,hanzo-cloud")
defer os.Unsetenv("GATEWAY_ALLOWED_AUDIENCES")
got := jwtAudiencesFromEnv()
if !has(got, "lux-cloud") {
t.Fatalf("hanzo-only env override must still accept lux-cloud, got %v", got)
}
n := 0
for _, s := range got {
if s == "hanzo-cloud" {
n++
}
}
if n != 1 {
t.Fatalf("hanzo-cloud must appear exactly once (no duplicate), got %d in %v", n, got)
}
}
-16
View File
@@ -137,19 +137,3 @@ func BrandIssuers() []string {
}
return out
}
// BrandAudiences returns the OAuth `aud` (== IAM client_id == app name) of every
// white-label brand's cloud login app: `<brand>-cloud` (hanzo-cloud, lux-cloud,
// zoo-cloud, pars-cloud, bootnode-cloud). A brand's session token carries
// aud=<brand>-cloud (HIP-0111: client_id == app == aud), so the audience allowlist
// must include each to accept a lux/zoo/pars token on the ONE binary. Derived from
// the same `brands` registry as BrandIssuers — one source of truth, no hand-listing.
func BrandAudiences() []string {
out := make([]string, 0, len(brands))
for id := range brands {
if id != "" {
out = append(out, id+"-cloud")
}
}
return out
}
+256 -35
View File
@@ -2,19 +2,24 @@ package cloud
import (
"context"
"encoding/base64"
"fmt"
"os"
"strings"
"time"
aiobject "github.com/hanzoai/ai/object"
"github.com/hanzoai/cloud/cek"
"github.com/hanzoai/cloud/clients/commerceinproc"
"github.com/hanzoai/cloud/clients/metering"
"github.com/hanzoai/cloud/internal/org"
s3 "github.com/hanzoai/s3-go"
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/gateway/edge"
"github.com/hanzoai/cloud/clients/money"
"github.com/hanzoai/cloud/clients/s3admin"
"github.com/hanzoai/cloud/types"
@@ -63,14 +68,15 @@ func BuildDeps(cfg *Config) Deps {
)
deps := Deps{
Logger: logger,
Brand: cfg.Brand,
Version: cfg.Version,
Env: cfg.Env,
Domain: cfg.Domain,
IAMIssuer: cfg.IAMIssuer,
DataDir: cfg.DataDir,
AIDefaultModel: cfg.AIDefaultModel,
Logger: logger,
Brand: cfg.Brand,
Version: cfg.Version,
Env: cfg.Env,
Domain: cfg.Domain,
IAMIssuer: cfg.IAMIssuer,
DataDir: cfg.DataDir,
AIDefaultModel: cfg.AIDefaultModel,
AIFallbackModel: cfg.AIFallbackModel,
}
// For each subsystem: enabled → leave nil (Mount fills it); not
@@ -90,11 +96,19 @@ func BuildDeps(cfg *Config) Deps {
// commerce URL yields a !Enabled() client, so the wrap is a transparent
// pass-through and a dev deployment is never blocked.
deps.Metering = buildMeteringClient(cfg, logger)
deps.AI = meteredAIClient(pickAIClient(cfg, logger), deps)
wireTierReader(deps.Metering, logger)
// AI (completions, WRITE) and Embed (embeddings, READ-ONLY) are DISTINCT
// credentials by concern: completions never ride the read-only publishable
// (pk-) key — the gateway 403s a pk- key on any write endpoint — so deps.AI
// resolves to the M2M identity, while deps.Embed keeps the pk- key (correct
// least-privilege for a read-only call). Both meter through the ONE commerce path.
deps.AI = meteredAIClient(pickCompletionsClient(cfg, logger), deps)
deps.Embed = meteredAIClient(pickEmbedClient(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)
deps.Durable = buildDurability(cfg, logger)
// Payments and Vault never co-resident. Disabled stub when no
// endpoint, otherwise RPC.
@@ -107,7 +121,7 @@ func BuildDeps(cfg *Config) Deps {
// working *Store (static-only if the SQLite file can't open), so the edge
// middleware is never left without a policy source — a store-open error is
// logged, not fatal.
gp, err := gatewaypolicy.New(cfg.DataDir, cfg.AdminOrg, staticEdgePolicy(cfg))
gp, err := edge.New(cfg.DataDir, cfg.AdminOrg, staticEdgePolicy(cfg))
if err != nil {
logger.Warn("gateway policy store degraded to static-only", "err", err)
}
@@ -117,10 +131,10 @@ func BuildDeps(cfg *Config) Deps {
}
// staticEdgePolicy projects the static env/flag edge config into the boot-default
// policy the gatewaypolicy.Store layers runtime overrides on top of. A disabled
// policy the edge.Store layers runtime overrides on top of. A disabled
// per-IP limiter (CLOUD_EDGE_RATELIMIT=false) maps to PerIPRPM 0 (a live no-op).
func staticEdgePolicy(cfg *Config) gatewaypolicy.Policy {
p := gatewaypolicy.Policy{
func staticEdgePolicy(cfg *Config) edge.Policy {
p := edge.Policy{
CORSOrigins: cfg.CORSOrigins,
WindowSec: cfg.EdgeRateWindowSec,
}
@@ -157,9 +171,15 @@ func buildMeteringClient(cfg *Config, log luxlog.Logger) *metering.Client {
httpClient = commerceinproc.Client(0) // in-process dispatch; no network timeout
}
m, err := metering.New(metering.Config{
BaseURL: base,
Token: cfg.CommerceServiceToken,
Org: cfg.Brand, // X-Org-Id default for S2S; per-request org overrides.
BaseURL: base,
Token: cfg.CommerceServiceToken,
Org: cfg.Brand, // X-Org-Id default for S2S; per-request org overrides.
// Honor the documented METERING_TEST env: when "true", route every debit to
// commerce's TEST/sandbox books (fin.RecordUsage in.Test=true) so a staging /
// canary deployment records NO real money — and the usage-cap read (org.TestMode
// via SQUARE_ENVIRONMENT=sandbox) sees the SAME test books. Unset in prod → live,
// unchanged. Without this the flag was silently ignored (always live).
Test: strings.EqualFold(strings.TrimSpace(os.Getenv(metering.EnvTest)), "true"),
FailOpen: cfg.BillingFailOpen,
HTTPClient: httpClient, // nil off the co-resident path → metering builds its own
})
@@ -169,6 +189,11 @@ func buildMeteringClient(cfg *Config, log luxlog.Logger) *metering.Client {
log.Error("billing: invalid commerce URL, gate disabled", "err", err)
m, _ = metering.New(metering.Config{})
}
// Observe every cap-check fail-open (timeout / slow / broken commerce) — a cap that
// silently allows must never be silent. The completion still proceeds (fail-open).
metering.OnCapError = func(err error) {
log.Warn("spend-cap check failed open (allowing completion) — commerce authorize slow/unavailable", "err", err)
}
if m.Enabled() {
log.Info("billing gate enabled", "commerce", boolStr(inProcess, "in-process", "http:"+base), "fail_open", cfg.BillingFailOpen)
} else {
@@ -184,6 +209,28 @@ func boolStr(b bool, t, f string) string {
return f
}
// wireTierReader installs the embedded ai module's per-tier SKU gate reader so it
// resolves the caller's commerce subscription tier through the SAME co-resident
// commerce client the metering gate bills over — in-process (commerceinproc) when
// commerce is folded in, S2S HTTP with the service token otherwise — NEVER an authed
// self-call to the cloud edge. That self-call is the toothless-gate bug: the edge
// 401/403s a service call to /v1/billing/*, so the ai module's own HTTP lookup always
// returned "" in-cluster and every tier-gated SKU failed OPEN. This mirrors
// wireFinance's SetBalanceReader: cloud owns the co-resident read, ai stays
// transport-agnostic. Fail-safe is preserved — Client.Tier folds a commerce error or
// an unknown plan to "", which the gate treats as ALLOW, so a commerce blip never
// locks out a paying caller. No-op when commerce is unreachable (metering !Enabled),
// leaving ai's standalone HTTP fallback in place.
func wireTierReader(m *metering.Client, log luxlog.Logger) {
if m == nil || !m.Enabled() {
return
}
aiobject.SetTierReader(func(ctx context.Context, subject, namespace string) (string, error) {
return m.Tier(ctx, subject, namespace)
})
log.Info("ai per-tier SKU gate wired to co-resident commerce (in-process tier read, fail-safe)")
}
// 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 +
@@ -545,44 +592,76 @@ func RegisterCommerceClientFactory(f func(cfg *Config, log luxlog.Logger) Commer
commerceClientFactory = f
}
// pickAIClient resolves deps.AI — the client the agents subsystem runs chat
// completions through. Unlike the co-resident subsystems, there is NO in-process
// "ai" mount that fills a nil deps.AI: inference is an external gateway, so this
// must return a concrete client, never nil. (A nil deps.AI was the live bug —
// the default all-enabled config returned nil here and nothing ever filled it,
// so every /v1/agents/:name/run 503'd "inference is not configured".)
// publishableKey reports whether apiKey is a read-only PUBLISHABLE key (pk-). The
// Hanzo gateway 403s a publishable key on any WRITE endpoint — "Publishable keys
// can only access read-only endpoints … use a secret key (sk-)". So the COMPLETIONS
// resolver must refuse it (it would only 403 chat), while the EMBED resolver accepts
// it (embeddings ARE read-only). pk- is the IAM key family's read-only member
// (hk-/sk-/pk-/fw_/hz_; clients/admission). This ONE predicate is the split's crux:
// it kept the intermittent-403 bug — a pk- embed key riding the shared completions
// client — from ever recurring, wherever the key comes from.
func publishableKey(apiKey string) bool {
return strings.HasPrefix(strings.TrimSpace(apiKey), "pk-")
}
// pickCompletionsClient resolves deps.AI — the client the agents run path (and
// guide/crm/content/sitegen/code /ask) execute CHAT COMPLETIONS through. There is
// NO in-process "ai" mount that fills a nil deps.AI: inference is an external
// gateway, so this returns a concrete client, never nil.
//
// Completions are a WRITE endpoint: a read-only publishable (pk-) key 403s them. So
// this resolver NEVER rides a pk- key — that is the embed credential (pickEmbedClient).
// Preference order:
// 1. Static-key HTTP gateway when a base URL AND a static key are configured —
// an operator override / pre-provisioned key. The key is a KMS-injected
// secret; only the base URL and default model are ever logged.
// 1. Static SECRET-key HTTP gateway when a base URL AND a completions-capable
// (non-pk-) static key are configured — an explicit operator override (sk-/hk-).
// A pk- key here is REFUSED (it would only 403 chat) and the resolver falls
// through to M2M — THE fix for the intermittent publishable-key 403 on bot replies.
// 2. M2M HTTP gateway when a base URL AND the binary's IAM identity are present
// (the durable Hanzo default): the client mints+refreshes a client-
// credentials token from IAM_CLIENT_ID/SECRET — no static key to rotate. The
// secret is never logged.
// (the durable Hanzo default): the client mints+refreshes a client-credentials
// token from IAM_CLIENT_ID/SECRET — no static key to rotate. Secret never logged.
// 3. ZAP RPC when an addr is configured (split-deploy of a future ai subsystem).
// 4. Fail-closed stub otherwise — a run records an honest error, never fakes one.
func pickAIClient(cfg *Config, log luxlog.Logger) AIClient {
if cfg.AIBaseURL != "" && cfg.AIAPIKey != "" {
log.Info("deps.AI → HTTP gateway (static key)", "base_url", cfg.AIBaseURL, "default_model", cfg.AIDefaultModel)
func pickCompletionsClient(cfg *Config, log luxlog.Logger) AIClient {
if cfg.AIBaseURL != "" && cfg.AIAPIKey != "" && !publishableKey(cfg.AIAPIKey) {
log.Info("deps.AI (completions) → HTTP gateway (static secret key)", "base_url", cfg.AIBaseURL, "default_model", cfg.AIDefaultModel)
return clients.AIHTTPAt(cfg.AIBaseURL, cfg.AIAPIKey, cfg.AIDefaultModel)
}
if cfg.AIAPIKey != "" && publishableKey(cfg.AIAPIKey) {
log.Info("deps.AI (completions) → refusing read-only publishable (pk-) key for chat; using M2M", "base_url", cfg.AIBaseURL)
}
if cfg.AIBaseURL != "" && cfg.AIAuthClientID != "" && cfg.AIAuthClientSecret != "" {
tokenURL := aiM2MTokenURL(cfg)
if tokenURL != "" {
log.Info("deps.AI → HTTP gateway (IAM M2M)", "base_url", cfg.AIBaseURL,
log.Info("deps.AI (completions) → HTTP gateway (IAM M2M)", "base_url", cfg.AIBaseURL,
"token_url", tokenURL, "client_id", cfg.AIAuthClientID, "default_model", cfg.AIDefaultModel)
return clients.AIHTTPM2M(cfg.AIBaseURL, tokenURL, cfg.AIAuthClientID, cfg.AIAuthClientSecret, cfg.AIDefaultModel)
}
}
if cfg.AIZAPAddr != "" {
log.Info("deps.AI → ZAP RPC", "addr", cfg.AIZAPAddr)
log.Info("deps.AI (completions) → ZAP RPC", "addr", cfg.AIZAPAddr)
return clients.AIRPCAt(cfg.AIZAPAddr)
}
log.Info("deps.AI → disabled (no CLOUD_AI_API_KEY, no IAM M2M identity, no gateway configured)")
log.Info("deps.AI (completions) → disabled (no secret key, no IAM M2M identity, no gateway configured)")
return clients.DisabledAI()
}
// pickEmbedClient resolves deps.Embed — the client code-index + KB knowledge run
// EMBEDDINGS through. Embeddings are a READ-ONLY endpoint, so the read-only
// publishable (pk-) key (CLOUD_AI_API_KEY ← cloud-ai-embed-key) is the CORRECT
// least-privilege credential here, and is used UNCHANGED — this path is deliberately
// not rewired. Preference order:
// 1. Static-key HTTP gateway when a base URL AND a static key are configured. The
// key (pk- or sk-) is a KMS-injected secret; only base URL + model are logged.
// 2. Otherwise share the completions resolution (M2M / ZAP / fail-closed) so a
// deploy with no dedicated embed key still indexes — no regression.
func pickEmbedClient(cfg *Config, log luxlog.Logger) AIClient {
if cfg.AIBaseURL != "" && cfg.AIAPIKey != "" {
log.Info("deps.Embed → HTTP gateway (static embed key)", "base_url", cfg.AIBaseURL, "default_model", cfg.AIDefaultModel)
return clients.AIHTTPAt(cfg.AIBaseURL, cfg.AIAPIKey, cfg.AIDefaultModel)
}
return pickCompletionsClient(cfg, log)
}
// aiM2MTokenURL resolves IAM's client_credentials endpoint the agent runner mints
// its M2M inference token at. It MUST be reachable FROM INSIDE THE CLUSTER: the
// runner runs in-cluster and the public issuer host (https://hanzo.id) is fronted
@@ -640,6 +719,148 @@ func pickVFSClient(cfg *Config, log luxlog.Logger) VFSClient {
return clients.DisabledVFS()
}
// durableBucket holds every org's HA-SQLite snapshot (and its writer lease). One
// bucket, keys laid out orgs/<slug>[/…]/<subsystem>.db per HIP-0302 — the durable
// twin of the on-disk DataDir layout.
//
// OPERATIONAL REQUIREMENTS the fence depends on (enforce in the SeaweedFS deployment,
// not in code):
//
// - Object versioning + a no-expiry / no-lifecycle-deletion policy on this prefix.
// The writer lease (orgs/<slug>/.owner) is the round's system of record; if the
// gateway silently drops or rolls back that object, a monotone round can reset and
// un-fence a zombie writer (Red M4). A local high-water-round floor per pod is the
// future in-process defense; the object lifecycle is the operational one.
// - RWO, per-writer PVCs for DataDir — NEVER an RWX shared volume (Red M5). Two pods
// on one DataDir corrupt SQLite regardless of this fence; single-writer here is the
// durable-copy fence, and per-pod RWO is the local-file guarantee the shard router
// already relies on (see shardrouter.go).
const durableBucket = "org-db"
// buildDurability constructs the deployment's HA-durability factory, or nil when the
// deployment has no object store to be durable against (dev/single-node — every
// OrgStore then stays local-only). It composes the SeaweedFS S3 If-Match
// ConditionalStore (the SAME s3admin identity deps.VFS uses), the writer membership
// over CLOUD_PEERS (the SAME set the shard router elects on, so the store-layer owner
// and the routed owner agree), and the per-org envelope Cipher rooted at the KMS
// master. Any construction failure fails SAFE to nil (local-only) rather than crash
// the boot; an encryption-capable build with no usable cipher is REFUSED — a build
// that promises encryption never ships plaintext snapshots to the object store.
func buildDurability(cfg *Config, log luxlog.Logger) *Durability {
// A multi-replica deployment REQUIRES the durable plane: with >1 writer, a per-org
// store that is not hydrate-on-open + fenced is the outage this exists to fix.
// disabledDurability logs at the severity the replica count warrants, so a
// misconfigured prod deployment is never SILENTLY non-durable (Red L2).
multiReplica := len(parsePeers(cfg.ShardPeers)) > 1
// Explicit opt-in: the object-store fence rests on the deployed SeaweedFS enforcing
// conditional-PUT (If-Match) atomically, which must be validated against the deployed
// version before it fences real tenant data (the takeover-fence staging gate). Until
// CLOUD_RESEARCH_DURABLE is set the store runs local-only — the shard router still
// pins each org to one writer, so this is not the rolling-deploy outage; only the
// cross-restart object-store snapshot waits for the opt-in.
if !cfg.ResearchDurable {
disabledDurability(log, multiReplica, "CLOUD_RESEARCH_DURABLE not set — HA object-store durability is opt-in pending the SeaweedFS conditional-PUT atomicity gate")
return nil
}
admin := s3admin.New()
if !admin.Configured() {
disabledDurability(log, multiReplica, "no S3 admin creds (S3_ADMIN_* unset)")
return nil
}
client, err := admin.Client()
if err != nil {
disabledDurability(log, multiReplica, fmt.Sprintf("S3 client construction failed: %v", err))
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
if err := ensureDurableBucket(ctx, admin, client); err != nil {
// Non-fatal: the bucket likely already exists; a later ship/hydrate retries.
log.Warn("durability bucket ensure failed (continuing)", "bucket", durableBucket, "err", err)
}
cancel()
// Membership: CLOUD_PEERS (the shard router's set). A single-pod deployment with
// no peers is its own sole writer — still hydrate-on-open + fenced ship across a
// rolling restart.
self := firstNonEmptyStr(strings.TrimSpace(cfg.ShardSelf), hostnameOr("cloud-0"))
peers := parsePeers(cfg.ShardPeers)
if len(peers) == 0 {
peers = []org.Member{{ID: self, Addr: self}}
}
members := org.NewMembership(self, org.StaticSource(peers...), 5*time.Second)
_ = members.Start(context.Background()) // static source: the initial refresh populates Members()
cipher := durableCipher(cfg, log)
if cipher == nil && cek.Encrypting() {
// The master that satisfied cek must decode here too, so this is a genuine
// misconfig, not a dev path: never ship plaintext snapshots AND never silently
// drop durability — fail closed and log LOUDLY for the replica count.
disabledDurability(log, multiReplica, "encryption-capable build but no durable cipher (would ship plaintext snapshots)")
return nil
}
log.Info("durability enabled", "bucket", durableBucket, "self", self, "peers", len(peers), "encrypted", cipher != nil)
return org.NewDurability(org.NewS3ConditionalStore(client, durableBucket), members, cipher)
}
// disabledDurability records that the durable plane is OFF, at ERROR when the
// deployment is multi-replica (per-org stores then survive only via shard routing +
// per-pod RWO PVC; a lost/rescheduled PVC loses committed data — the operator MUST
// configure S3) and at INFO when single-replica/dev (local-only is the expected
// posture). One place, so no disabled path is silent on a deployment that needs HA.
func disabledDurability(log luxlog.Logger, multiReplica bool, why string) {
if multiReplica {
log.Error("DURABILITY DISABLED on a MULTI-REPLICA deployment — per-org stores survive only via shard routing + per-pod RWO PVC; a lost/rescheduled PVC loses committed data. Configure S3_ADMIN_* to enable the durable plane.", "reason", why)
return
}
log.Info("durability disabled — single-replica/dev, per-org stores stay local-only", "reason", why)
}
// ensureDurableBucket creates the durable bucket if absent (idempotent).
func ensureDurableBucket(ctx context.Context, admin s3admin.Admin, client *s3.Client) error {
ok, err := client.BucketExists(ctx, durableBucket)
if err != nil {
return err
}
if ok {
return nil
}
return client.MakeBucket(ctx, durableBucket, s3.MakeBucketOptions{Region: admin.Region()})
}
// durableCipher builds the per-org envelope Cipher from the base64 KMS master
// (CLOUD_KMS_MASTER_KEY_REF — the SAME key cek derives from). nil when no valid
// 32-byte master is configured (pure-Go dev → plaintext durable object, matching the
// plaintext local file).
func durableCipher(cfg *Config, log luxlog.Logger) *org.Cipher {
ref := strings.TrimSpace(cfg.KMSMasterKeyRef)
if ref == "" {
return nil
}
master, err := base64.StdEncoding.DecodeString(ref)
if err != nil {
log.Warn("durable cipher: KMS master key ref is not valid base64", "err", err)
return nil
}
c, err := org.NewCipher(master)
if err != nil {
log.Warn("durable cipher: invalid KMS master key", "err", err)
return nil
}
return c
}
// hostnameOr returns the OS hostname, or def when unavailable — a stable self id for
// a single-pod deployment that sets no CLOUD_POD_NAME.
func hostnameOr(def string) string {
if h, err := os.Hostname(); err == nil && h != "" {
return h
}
return def
}
func pickPaymentsClient(cfg *Config, log luxlog.Logger) PaymentsClient {
if cfg.PaymentsZAPAddr != "" {
log.Info("deps.Payments → ZAP RPC", "addr", cfg.PaymentsZAPAddr)
+51
View File
@@ -0,0 +1,51 @@
package cloud
import (
"io"
"net/http"
"net/http/httptest"
"testing"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// CallerBearer relays the caller's OWN validated JWT bearer and nothing else: a JWT
// passes through unchanged, an opaque API key is not relayable, and no credential
// yields "". This is the token a downstream org-scoped service (the DNS forward
// head) re-validates to enforce tenant isolation across the hop.
func TestCallerBearer(t *testing.T) {
app := zip.New(zip.Config{Logger: luxlog.New("test")})
app.Get("/probe", func(c *zip.Ctx) error { return c.Bytes(200, []byte(CallerBearer(c))) })
probe := func(setup func(*http.Request)) string {
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
if setup != nil {
setup(req)
}
res, err := app.Fiber().Test(req)
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(res.Body)
_ = res.Body.Close()
return string(b)
}
cases := []struct {
name string
setup func(*http.Request)
want string
}{
{"jwt bearer relayed unchanged", func(r *http.Request) { r.Header.Set("Authorization", "Bearer jwt.header.sig") }, "jwt.header.sig"},
{"X-Authorization fallback", func(r *http.Request) { r.Header.Set("X-Authorization", "Bearer x.y.z") }, "x.y.z"},
{"opaque hk- api key is NOT relayable", func(r *http.Request) { r.Header.Set("Authorization", "Bearer hk-secret") }, ""},
{"opaque sk- api key is NOT relayable", func(r *http.Request) { r.Header.Set("Authorization", "Bearer sk-secret") }, ""},
{"no credential yields empty", nil, ""},
}
for _, c := range cases {
if got := probe(c.setup); got != c.want {
t.Errorf("%s: CallerBearer = %q, want %q", c.name, got, c.want)
}
}
}
+21 -10
View File
@@ -63,7 +63,9 @@ var controlCommands = map[string]string{
"k8s": "deploy-target helpers (current target)",
"config": "view/edit ~/.hanzo/config preferences",
"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)",
"link": "bring this machine into the Hanzo cloud fleet as a node (fabric + compute worker)",
"unlink": "take this machine out of the fleet (deregister + stop hanzod)",
"status": "show the org's fleet — every node with each of its GPUs",
"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)",
@@ -474,11 +476,14 @@ func (e *Env) freshAccessToken() string {
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
// (in precedence) the bound --platform-token flag, the environment, then the
// credential store. Never hardcoded.
// platformToken resolves the bearer the platform control plane authenticates
// apps/clusters/redeploy with. ONE identity authorizes everything: after a plain
// `hanzo login` the IAM access token is the FINAL fallback, so no separate
// --platform-token is needed — the platform verifies the IAM JWT (signature,
// issuer, expiry) and org-scopes the caller. A dedicated service token still
// wins when present (flag > env > credential store > IAM login), so purpose-minted
// machine tokens keep their precedence and internal automation is unchanged.
// Never hardcoded.
func (e *Env) platformToken(flagVal string) string {
return firstNonEmpty(
flagVal,
@@ -486,17 +491,22 @@ func (e *Env) platformToken(flagVal string) string {
os.Getenv("PLATFORM_SERVICE_TOKEN"),
os.Getenv("PAAS_SERVICE_TOKEN"),
e.creds.PlatformToken,
e.accessToken(), // IAM login is the one identity that authorizes control-plane ops
)
}
// buildToken resolves the platform build-enqueue token (a distinct credential
// from the service token — see /v1/runner).
// buildToken resolves the bearer `hanzo build` sends to the platform build
// enqueue (/v1/runner). Same unify-infra contract as platformToken: a dedicated
// build token wins when present, but a plain IAM login is the FINAL fallback, so
// `hanzo build` works off the one identity with no separate --build-token — the
// platform verifies the IAM JWT and authorizes the build by org + role.
func (e *Env) buildToken(flagVal string) string {
return firstNonEmpty(
flagVal,
os.Getenv("HANZO_BUILD_TOKEN"),
os.Getenv("PLATFORM_BUILD_CALLBACK_TOKEN"),
e.creds.BuildToken,
e.accessToken(), // IAM login is the one identity that authorizes builds
)
}
@@ -588,10 +598,11 @@ func newRootCmd() *cobra.Command {
newDeployCmd(envOf, &f),
newClustersCmd(envOf, &f),
newBuildCmd(envOf, &f),
newK8sCmd(envOf, &f),
newConfigCmd(),
newSecurityCmd(envOf),
newGPUCmd(envOf, &f),
newLinkCmd(envOf, &f),
newUnlinkCmd(envOf, &f),
newStatusCmd(envOf, &f),
newEngineCmd(envOf, &f),
newCodeCmd(envOf, &f),
newRunnerCmd(envOf, &f),
+54
View File
@@ -141,6 +141,29 @@ func TestPlatformTokenPrecedence(t *testing.T) {
}
}
// TestPlatformTokenFallsBackToIAM is the UNIFY-INFRA contract for the control
// plane: after a plain `hanzo login`, the IAM access token is the FINAL fallback
// so `hanzo apps`/`hanzo deploy` authorize off the one identity. An explicit
// platform service token (creds/env/flag) still wins.
func TestPlatformTokenFallsBackToIAM(t *testing.T) {
sandbox(t)
// Only an IAM login: no platform token anywhere ⇒ the IAM access token is sent.
e := resolve(&Config{}, &Credentials{AccessToken: "iam-jwt"}, globalFlags{})
if got := e.platformToken(""); got != "iam-jwt" {
t.Fatalf("IAM access token should be the final platform-token fallback: %q", got)
}
// A dedicated platform service token still beats the IAM token.
e = resolve(&Config{}, &Credentials{AccessToken: "iam-jwt", PlatformToken: "svc"}, globalFlags{})
if got := e.platformToken(""); got != "svc" {
t.Fatalf("dedicated platform token must beat the IAM fallback: %q", got)
}
// No login at all ⇒ empty (caller surfaces "run `hanzo login`").
e = resolve(&Config{}, &Credentials{}, globalFlags{})
if got := e.platformToken(""); got != "" {
t.Fatalf("no token and no login should resolve empty: %q", got)
}
}
func TestBuildTokenPrecedence(t *testing.T) {
sandbox(t)
e := resolve(&Config{}, &Credentials{BuildToken: "creds"}, globalFlags{})
@@ -156,6 +179,37 @@ func TestBuildTokenPrecedence(t *testing.T) {
}
}
// TestBuildTokenFallsBackToIAM is the UNIFY-INFRA contract: after a plain
// `hanzo login` (no --build-token), the IAM access token is the FINAL fallback,
// so `hanzo build` authorizes off the one identity. An explicit build token
// (creds/env/flag) still wins — the IAM token is the LAST resort, never an
// override of a purpose-minted machine token.
func TestBuildTokenFallsBackToIAM(t *testing.T) {
sandbox(t)
// Only an IAM login: no build token anywhere ⇒ the IAM access token is sent.
e := resolve(&Config{}, &Credentials{AccessToken: "iam-jwt"}, globalFlags{})
if got := e.buildToken(""); got != "iam-jwt" {
t.Fatalf("IAM access token should be the final build-token fallback: %q", got)
}
// A dedicated build token still beats the IAM token (precedence preserved).
e = resolve(&Config{}, &Credentials{AccessToken: "iam-jwt", BuildToken: "creds"}, globalFlags{})
if got := e.buildToken(""); got != "creds" {
t.Fatalf("dedicated build token must beat the IAM fallback: %q", got)
}
// HANZO_TOKEN (the env form of the IAM token) is also honored via accessToken().
e = resolve(&Config{}, &Credentials{}, globalFlags{})
t.Setenv("HANZO_TOKEN", "iam-env")
if got := e.buildToken(""); got != "iam-env" {
t.Fatalf("HANZO_TOKEN should back the build-token fallback: %q", got)
}
// No login at all ⇒ empty, so the caller can surface "run `hanzo login`".
t.Setenv("HANZO_TOKEN", "")
e = resolve(&Config{}, &Credentials{}, globalFlags{})
if got := e.buildToken(""); got != "" {
t.Fatalf("no token and no login should resolve empty: %q", got)
}
}
func TestAccessTokenFromEnvOverCreds(t *testing.T) {
sandbox(t)
e := resolve(&Config{}, &Credentials{AccessToken: "creds"}, globalFlags{})
+84 -25
View File
@@ -147,9 +147,10 @@ type codeAgent struct {
bin string // executable to exec
wire wire // how it finds the cloud
fullAuto []string // flags that bypass approval prompts
continueArgs []string // harness-native form of Hanzo -c/--continue
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
provider func(base, model 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
@@ -158,22 +159,46 @@ type codeAgent struct {
install string // hint when the binary is missing
}
// codeContextWindow is the input context (tokens) the served coding model
// budgets from. The enso and zen5 flagship tiers serve 1M; the flash tiers
// serve 131072. Codex is told this so it sizes context to the real window
// instead of a flat 256K cap — the cause of "maximum context exceeded" at
// 262144 even on a 1M-capable model. This wrapper only launches Hanzo coding
// models (default zen5), so non-flash defaults to the 1M flagship window.
func codeContextWindow(model string) int {
if strings.Contains(strings.ToLower(model), "flash") {
return 131072
}
return 1000000
}
// 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 {
bin: bin,
wire: openaiWire,
fullAuto: []string{"--dangerously-bypass-approvals-and-sandbox"},
continueArgs: []string{"resume", "--last"},
modelArg: []string{"-m"},
provider: func(base, model string) []string {
// api.hanzo.ai exposes the standard OpenAI /v1/models shape, not
// Codex's private remote model-catalog schema, so skip that refresh
// and supply the model's window here — sized to the SERVED model
// (enso / zen5 flagship = 1M, flash tiers = 131072) so Codex budgets
// the real context instead of a flat 256K cap (the "maximum context
// exceeded at 262144" bug). Auto-compact at 90% leaves headroom.
win := codeContextWindow(model)
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"`,
"-c", `features.remote_models=false`,
"-c", fmt.Sprintf("model_context_window=%d", win),
"-c", fmt.Sprintf("model_auto_compact_token_limit=%d", win*9/10),
}
},
install: install,
@@ -190,9 +215,10 @@ const zenIdentityPrompt = "You are running through the Hanzo AI cloud as a Hanzo
var codeAgents = map[string]codeAgent{
"claude": {
bin: "claude",
wire: anthropicWire,
fullAuto: []string{"--dangerously-skip-permissions"},
bin: "claude",
wire: anthropicWire,
fullAuto: []string{"--dangerously-skip-permissions"},
continueArgs: []string{"--continue"},
// --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
@@ -246,9 +272,12 @@ func newCodeCmd(envOf func() *Env, _ *globalFlags) *cobra.Command {
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.",
"(glm5.2 -> glm-5.2), -c resumes either harness, and agents run full-auto unless\n" +
"you pass --safe. Unknown options pass through; -- forces verbatim passthrough.",
Example: " hanzo code # dev, the default agent\n" +
" hanzo code claude\n" +
" hanzo code claude -c\n" +
" hanzo code codex -c\n" +
" hanzo code codex deepseek-v4-pro\n" +
" hanzo code dev glm5.2 -- --resume\n" +
" hanzo code ls",
@@ -320,19 +349,10 @@ func runCode(env *Env, agent codeAgent, args []string) error {
}
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)
}
}
// First non-flag arg before -- is the model; --safe and --continue are ours.
// Unknown options pass through unchanged. Everything after -- belongs to the
// agent, including positional subcommands and raw Codex -c config overrides.
model, safe, continueLast, rest := splitCodeArgs(args)
if model == "" {
model = defaultCodeModel
}
@@ -382,7 +402,7 @@ func runCode(env *Env, agent codeAgent, args []string) error {
}
}
argv := codeArgv(agent, base, model, safe, rest)
argv := codeArgv(agent, base, model, safe, codeAgentRest(agent, continueLast, rest))
for k, v := range agent.wire(base, token, model) {
if err := os.Setenv(k, v); err != nil {
@@ -397,6 +417,45 @@ func runCode(env *Env, agent codeAgent, args []string) error {
return execEngine(bin, argv) // exec: signals + exit code flow straight through
}
// splitCodeArgs pulls the launcher-owned tokens (the model, --safe, -c/--continue)
// out of the raw args; everything else is the agent's. The `--` separator is ours
// and switches on verbatim passthrough — every token after it goes to the agent
// untouched, including positional subcommands (`codex exec`) and raw Codex -c
// config overrides that would otherwise look like our --continue.
func splitCodeArgs(args []string) (model string, safe, continueLast bool, rest []string) {
rest = make([]string, 0, len(args))
passthrough := false
for _, a := range args {
switch {
case passthrough:
rest = append(rest, a)
case a == "--": // the separator is ours; the agent must not see it
passthrough = true
case a == "--safe" || a == "--ask":
safe = true
case a == "-c" || a == "--continue":
continueLast = true
case model == "" && !strings.HasPrefix(a, "-") && len(rest) == 0:
model = a
default:
rest = append(rest, a)
}
}
return model, safe, continueLast, rest
}
// codeAgentRest prepends the agent's harness-native resume tokens when -c/--continue
// was given, so one Hanzo flag resumes the last session on either harness (`--continue`
// for Claude Code, `resume --last` for Codex/dev).
func codeAgentRest(agent codeAgent, continueLast bool, rest []string) []string {
if !continueLast {
return rest
}
args := make([]string, 0, len(agent.continueArgs)+len(rest))
args = append(args, agent.continueArgs...)
return append(args, rest...)
}
// 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 {
@@ -405,7 +464,7 @@ func codeArgv(agent codeAgent, base, model string, safe bool, rest []string) []s
argv = append(argv, agent.fullAuto...)
}
if agent.provider != nil {
argv = append(argv, agent.provider(base)...)
argv = append(argv, agent.provider(base, model)...)
}
if len(agent.modelArg) > 0 { // claude takes the model via env, codex/dev on argv
argv = append(argv, agent.modelArg...)
+100 -10
View File
@@ -18,6 +18,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"slices"
"testing"
"time"
@@ -48,6 +49,95 @@ func TestCodeAgentsBypassPermissionsByDefault(t *testing.T) {
}
}
func TestCodeArgsSeparatorPreservesAgentSubcommand(t *testing.T) {
model, safe, continueLast, rest := splitCodeArgs([]string{"--safe", "--", "exec", "--ephemeral", "do it"})
if model != "" || !safe || continueLast {
t.Fatalf("model=%q safe=%v continue=%v, want default model and safe mode", model, safe, continueLast)
}
if want := []string{"exec", "--ephemeral", "do it"}; !reflect.DeepEqual(rest, want) {
t.Fatalf("agent args = %q, want %q", rest, want)
}
}
func TestCodeArgsExplicitModelBeforeSeparator(t *testing.T) {
model, safe, continueLast, rest := splitCodeArgs([]string{"zen5-max", "--", "exec"})
if model != "zen5-max" || safe || continueLast || !reflect.DeepEqual(rest, []string{"exec"}) {
t.Fatalf("model=%q safe=%v continue=%v rest=%q", model, safe, continueLast, rest)
}
}
func TestCodeContinueIsNormalizedForBothHarnesses(t *testing.T) {
for _, tt := range []struct {
name string
want []string
}{
{name: "claude", want: []string{"--continue"}},
{name: "codex", want: []string{"resume", "--last"}},
} {
t.Run(tt.name, func(t *testing.T) {
model, safe, continueLast, rest := splitCodeArgs([]string{"-c"})
if model != "" || safe || !continueLast || len(rest) != 0 {
t.Fatalf("model=%q safe=%v continue=%v rest=%q", model, safe, continueLast, rest)
}
if got := codeAgentRest(codeAgents[tt.name], continueLast, rest); !reflect.DeepEqual(got, tt.want) {
t.Fatalf("normalized continue args = %q, want %q", got, tt.want)
}
})
}
}
func TestCodeUnknownOptionsAndPostSeparatorArgsPassThrough(t *testing.T) {
unknown := []string{"--mystery", "value", "--other=1"}
model, safe, continueLast, rest := splitCodeArgs(unknown)
if model != "" || safe || continueLast || !reflect.DeepEqual(rest, unknown) {
t.Fatalf("unknown options changed: model=%q safe=%v continue=%v rest=%q", model, safe, continueLast, rest)
}
_, _, continueLast, rest = splitCodeArgs([]string{"--", "-c", "model=x"})
if continueLast || !reflect.DeepEqual(rest, []string{"-c", "model=x"}) {
t.Fatalf("post-separator Codex config must pass verbatim: continue=%v rest=%q", continueLast, rest)
}
}
func TestCodexProviderUsesNativeResponsesMetadata(t *testing.T) {
// defaultCodeModel is zen5 — a 1M flagship tier — so the window must be 1M,
// NOT the old flat 262144 cap that surfaced as "maximum context exceeded"
// even on a 1M-capable model. Auto-compact is 90% of the window.
argv := codeArgv(codeAgents["codex"], "https://api.hanzo.ai", defaultCodeModel, false, nil)
for _, want := range []string{
`model_provider=hanzo`,
`model_providers.hanzo.base_url="https://api.hanzo.ai/v1"`,
`model_providers.hanzo.wire_api="responses"`,
`features.remote_models=false`,
`model_context_window=1000000`,
`model_auto_compact_token_limit=900000`,
} {
if !slices.Contains(argv, want) {
t.Errorf("Codex argv %q does not contain %q", argv, want)
}
}
// A flash tier budgets its smaller real window, not 1M.
flash := codeArgv(codeAgents["codex"], "https://api.hanzo.ai", "zen5-flash", false, nil)
if !slices.Contains(flash, `model_context_window=131072`) {
t.Errorf("zen5-flash argv %q must budget 131072, not the flagship 1M", flash)
}
}
// TestCodeContextWindowSizing pins the model→window map: every non-flash tier
// gets the 1M flagship window; only flash tiers drop to 131072.
func TestCodeContextWindowSizing(t *testing.T) {
for _, m := range []string{"zen5", "zen5-pro", "zen5-coder", "enso", "enso-ultra"} {
if w := codeContextWindow(m); w != 1000000 {
t.Errorf("codeContextWindow(%q) = %d, want 1000000", m, w)
}
}
for _, m := range []string{"zen5-flash", "enso-flash"} {
if w := codeContextWindow(m); w != 131072 {
t.Errorf("codeContextWindow(%q) = %d, want 131072", m, w)
}
}
}
// 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_*).
@@ -60,10 +150,10 @@ func TestCodeTokenPrecedence(t *testing.T) {
freshExpiry := time.Now().Add(1 * time.Hour).Unix()
cases := []struct {
name string
envKey string // HANZO_API_KEY override
creds Credentials
want string
name string
envKey string // HANZO_API_KEY override
creds Credentials
want string
}{
{
name: "fresh JWT beats hk- key",
@@ -81,16 +171,16 @@ func TestCodeTokenPrecedence(t *testing.T) {
want: "hk-stored",
},
{
name: "HANZO_API_KEY overrides everything (deliberate operator override)",
name: "HANZO_API_KEY overrides everything (deliberate operator override)",
envKey: "hk-explicit",
creds: Credentials{AccessToken: "jwt-live", Expiry: freshExpiry},
want: "hk-explicit",
creds: Credentials{AccessToken: "jwt-live", Expiry: freshExpiry},
want: "hk-explicit",
},
{
name: "HANZO_API_KEY overrides even an expired JWT",
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",
creds: Credentials{AccessToken: "jwt-dead", Expiry: time.Now().Add(-1 * time.Hour).Unix()},
want: "hk-explicit",
},
}
+103 -222
View File
@@ -3,6 +3,7 @@ package cli
import (
"fmt"
"io"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
@@ -15,12 +16,12 @@ func (e *Env) platform(gf *globalFlags) *Platform {
return newPlatform(e.PlatformURL, e.platformToken(gf.platformToken))
}
// deref renders a *string for a table cell, "-" when nil/empty.
func deref(p *string) string {
if p == nil || *p == "" {
// dashIfEmpty renders a string cell, "-" when empty.
func dashIfEmpty(s string) string {
if s == "" {
return "-"
}
return *p
return s
}
// yesno renders a bool for a table cell.
@@ -37,7 +38,8 @@ func newTab(w io.Writer) *tabwriter.Writer {
}
// ---------------------------------------------------------------------------
// apps — the observe surface.
// apps — the fleet drift board (GET /v1/paas/apps). Org-confined server-side by
// the IAM identity: a superadmin sees the whole fleet, an org-admin only its own.
// ---------------------------------------------------------------------------
func newAppsCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
@@ -56,7 +58,6 @@ func newAppsCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
RunE: func(cmd *cobra.Command, _ []string) error {
e := envOf()
res, err := e.platform(gf).Apps(cmd.Context(), AppsQuery{
Org: e.Org, // empty == all (single-tenant default)
Env: envFilter,
Health: healthFilter,
Drift: driftOnly,
@@ -69,8 +70,8 @@ func newAppsCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
fmt.Fprintln(tw, "ORG\tAPP\tENV\tDECLARED\tRUNNING\tHEALTH\tDRIFT")
for _, a := range res.Apps {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
a.Org, a.App, a.Env, deref(a.DeclaredTag), deref(a.RunningTag),
deref(a.Health), driftSeverity(a.Drift))
a.Org, a.App, a.Env, dashIfEmpty(a.DeclaredTag), dashIfEmpty(a.RunningTag),
dashIfEmpty(a.Health), driftSeverity(a.Drift))
}
tw.Flush()
fmt.Fprintf(w, "\n%d apps (ok=%d yellow=%d red=%d)\n",
@@ -79,17 +80,17 @@ func newAppsCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
})
},
}
list.Flags().StringVar(&envFilter, "env", "", "filter by env: dev|test|main")
list.Flags().StringVar(&envFilter, "env", "", "filter by env: main|test|dev")
list.Flags().StringVar(&healthFilter, "health", "", "filter by health: green|yellow|red")
list.Flags().BoolVar(&driftOnly, "drift", false, "only rows that are drifting")
get := &cobra.Command{
Use: "get <org/app/env>",
Short: "Get one app row by its <org>/<app>/<env> id",
Use: "get <app>",
Short: "Get one app row by its CR name (production by default)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
e := envOf()
a, err := e.platform(gf).App(cmd.Context(), args[0], e.Org)
a, err := e.platform(gf).App(cmd.Context(), args[0])
if err != nil {
return err
}
@@ -101,109 +102,93 @@ func newAppsCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
fmt.Fprintf(tw, "env:\t%s\n", a.Env)
fmt.Fprintf(tw, "repo:\t%s\n", a.Repo)
fmt.Fprintf(tw, "registry:\t%s\n", a.Registry)
fmt.Fprintf(tw, "declared:\t%s\n", deref(a.DeclaredTag))
fmt.Fprintf(tw, "running:\t%s\n", deref(a.RunningTag))
fmt.Fprintf(tw, "latest:\t%s\n", deref(a.LatestTag))
fmt.Fprintf(tw, "health:\t%s\n", deref(a.Health))
fmt.Fprintf(tw, "declared:\t%s\n", dashIfEmpty(a.DeclaredTag))
fmt.Fprintf(tw, "running:\t%s\n", dashIfEmpty(a.RunningTag))
fmt.Fprintf(tw, "health:\t%s\n", dashIfEmpty(a.Health))
fmt.Fprintf(tw, "phase:\t%s\n", dashIfEmpty(a.Phase))
fmt.Fprintf(tw, "drift:\t%s\n", driftSeverity(a.Drift))
fmt.Fprintf(tw, "cluster:\t%s\n", deref(a.Cluster))
fmt.Fprintf(tw, "namespace:\t%s\n", deref(a.Namespace))
fmt.Fprintf(tw, "updated:\t%s\n", a.UpdatedAt)
fmt.Fprintf(tw, "cluster:\t%s\n", dashIfEmpty(a.Cluster))
fmt.Fprintf(tw, "namespace:\t%s\n", dashIfEmpty(a.Namespace))
if len(a.Endpoints) > 0 {
fmt.Fprintf(tw, "endpoints:\t%s\n", strings.Join(a.Endpoints, ", "))
}
tw.Flush()
})
},
}
sync := &cobra.Command{
Use: "sync",
Short: "Trigger an inventory refresh of the apps board",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
e := envOf()
if err := e.platform(gf).SyncApps(cmd.Context()); err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout(), "apps sync triggered")
return nil
},
}
cmd.AddCommand(list, get, sync)
cmd.AddCommand(list, get)
return cmd
}
// ---------------------------------------------------------------------------
// deploy — the drive surface (rolling restart, zero-downtime).
// deploy — POST /v1/paas/apps/{app}/deploy: a zero-downtime rolling restart.
// ---------------------------------------------------------------------------
func newDeployCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
var project, environment string
var environment string
cmd := &cobra.Command{
Use: "deploy <container>",
Short: "Redeploy a container (rolling restart, zero-downtime)",
Long: "Drive a platform redeploy: a rolling restart of the container's k8s\n" +
"Deployment (re-pulls the image, recreates pods, zero downtime). Coordinates\n" +
"are exact — org (--org/config), project (--project), env (--env) and the\n" +
"container id (positional). This is the canonical PaaS-driven deploy.",
Use: "deploy <app>",
Short: "Redeploy an app (rolling restart, zero-downtime) — requires --env",
Long: "Drive a platform redeploy: a rolling restart of the app's k8s Deployment\n" +
"(re-pulls the declared image, recreates pods, zero downtime). The app is the\n" +
"operator App CR name; the org comes from your IAM identity. --env is REQUIRED\n" +
"(main|test|dev) — deploy never silently targets production. Restarting a shared\n" +
"platform service is a platform-operator action, so this needs a superadmin\n" +
"identity. A TAG change is still a git commit — this restarts what is declared.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(environment) == "" {
return fmt.Errorf("--env is required (main|test|dev) — deploy will not default to production")
}
e := envOf()
org, err := e.requireOrg()
res, err := e.platform(gf).Redeploy(cmd.Context(), args[0], environment)
if err != nil {
return err
}
if project == "" || environment == "" {
return fmt.Errorf("--project and --env are required (the container's project/environment ids)")
}
container := args[0]
if err := e.platform(gf).Redeploy(cmd.Context(), org, project, environment, container); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "redeployed %s (org=%s project=%s env=%s)\n", container, org, project, environment)
return nil
return e.emit(res, func(w io.Writer) {
fmt.Fprintf(w, "restarted %s (namespace=%s env=%s at %s)\n",
res.App, res.Namespace, dashIfEmpty(res.Env), res.RestartedAt)
})
},
}
cmd.Flags().StringVar(&project, "project", "", "project id")
cmd.Flags().StringVar(&environment, "env", "", "environment id")
cmd.Flags().StringVar(&environment, "env", "", "lifecycle env: main|test|dev (REQUIRED)")
return cmd
}
// ---------------------------------------------------------------------------
// clusters — dedicated DOKS cluster lifecycle.
// clusters — GET /v1/clusters: the org's compute fleet (Visor-managed + BYO),
// tenant-scoped server-side by the IAM identity.
// ---------------------------------------------------------------------------
func newClustersCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "clusters",
Aliases: []string{"cluster"},
Short: "Provision/list/select dedicated DOKS clusters",
Short: "List the org's clusters (managed + BYO)",
}
list := &cobra.Command{
Use: "list",
Short: "List the org's dedicated clusters",
Short: "List the org's clusters",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
e := envOf()
org, err := e.requireOrg()
if err != nil {
return err
}
cs, err := e.platform(gf).Clusters(cmd.Context(), org)
cs, err := e.platform(gf).Clusters(cmd.Context())
if err != nil {
return err
}
return e.emit(cs, func(w io.Writer) {
tw := newTab(w)
fmt.Fprintln(tw, "NAME\tID\tREGION\tSTATUS\tPHASE\tACTIVE\tOPERATOR\tBASELINE")
fmt.Fprintln(tw, "NAME\tID\tREGION\tSTATUS\tKIND\tNODES\tSIZE\tGPUS")
for _, c := range cs {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
c.Name, c.DoksClusterID, c.Region, c.Status, c.Phase,
yesno(c.Active), yesno(c.OperatorInstalled), yesno(c.BaselineInstalled))
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%d\t%s\t%s\n",
c.Name, dashIfEmpty(c.ID()), dashIfEmpty(c.Region), dashIfEmpty(c.Status),
dashIfEmpty(c.Kind), c.NodeCount, dashIfEmpty(c.NodeSize), gpuCell(c))
}
tw.Flush()
if len(cs) == 0 {
fmt.Fprintln(w, "(no dedicated clusters)")
fmt.Fprintln(w, "(no clusters)")
}
})
},
@@ -215,151 +200,51 @@ func newClustersCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
e := envOf()
org, err := e.requireOrg()
if err != nil {
return err
}
cs, err := e.platform(gf).Clusters(cmd.Context(), org)
cs, err := e.platform(gf).Clusters(cmd.Context())
if err != nil {
return err
}
for _, c := range cs {
if c.DoksClusterID == args[0] || c.Name == args[0] {
if c.ID() == args[0] || c.Name == args[0] {
return e.emit(c, func(w io.Writer) { printCluster(w, c) })
}
}
return fmt.Errorf("cluster %q not found in org %s", args[0], org)
return fmt.Errorf("cluster %q not found", args[0])
},
}
var region, nodeSize string
var ha bool
var nodeCount int
create := &cobra.Command{
Use: "create",
Short: "Provision a new dedicated DOKS cluster for the org",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
e := envOf()
org, err := e.requireOrg()
if err != nil {
return err
}
c, err := e.platform(gf).ProvisionCluster(cmd.Context(), org, ProvisionReq{
Region: region, HA: ha, NodeSize: nodeSize, NodeCount: nodeCount,
})
if err != nil {
return err
}
return e.emit(c, func(w io.Writer) {
fmt.Fprintf(w, "provisioning cluster %s (%s)\n", c.Name, c.DoksClusterID)
printCluster(w, *c)
})
},
}
create.Flags().StringVar(&region, "region", "", "DO region (default sfo3)")
create.Flags().BoolVar(&ha, "ha", false, "highly-available control plane")
create.Flags().StringVar(&nodeSize, "node-size", "", "node size slug (e.g. s-2vcpu-4gb)")
create.Flags().IntVar(&nodeCount, "node-count", 0, "node count")
var shared bool
selectCmd := &cobra.Command{
Use: "select <cluster-id>",
Short: "Set the org's active deploy target (or --shared to revert)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
e := envOf()
org, err := e.requireOrg()
if err != nil {
return err
}
var clusterID *string
switch {
case shared:
clusterID = nil
case len(args) == 1:
clusterID = &args[0]
default:
return fmt.Errorf("give a cluster id, or --shared to revert to the shared cluster")
}
t, err := e.platform(gf).SelectTarget(cmd.Context(), org, clusterID)
if err != nil {
return err
}
return e.emit(t, func(w io.Writer) { printTarget(w, t) })
},
}
selectCmd.Flags().BoolVar(&shared, "shared", false, "revert to the shared cluster")
installBaseline := &cobra.Command{
Use: "install-baseline <cluster-id>",
Short: "Install the hanzo-operator + per-tenant baseline on a cluster",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
e := envOf()
org, err := e.requireOrg()
if err != nil {
return err
}
if err := e.platform(gf).InstallBaseline(cmd.Context(), org, args[0]); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "baseline install requested for %s\n", args[0])
return nil
},
}
target := &cobra.Command{
Use: "target",
Short: "Show the org's current resolved deploy target",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
e := envOf()
org, err := e.requireOrg()
if err != nil {
return err
}
t, err := e.platform(gf).Target(cmd.Context(), org)
if err != nil {
return err
}
return e.emit(t, func(w io.Writer) { printTarget(w, t) })
},
}
cmd.AddCommand(list, get, create, selectCmd, installBaseline, target)
cmd.AddCommand(list, get)
return cmd
}
// gpuCell renders the live GPU inventory of a cluster ("-" when none).
func gpuCell(c Cluster) string {
var parts []string
if c.NvidiaGPU > 0 {
parts = append(parts, fmt.Sprintf("%d nvidia", c.NvidiaGPU))
}
if c.AmdGPU > 0 {
parts = append(parts, fmt.Sprintf("%d amd", c.AmdGPU))
}
if len(parts) == 0 {
return "-"
}
return strings.Join(parts, "+")
}
func printCluster(w io.Writer, c Cluster) {
tw := newTab(w)
fmt.Fprintf(tw, "id:\t%s\n", c.DoksClusterID)
fmt.Fprintf(tw, "id:\t%s\n", dashIfEmpty(c.ID()))
fmt.Fprintf(tw, "name:\t%s\n", c.Name)
fmt.Fprintf(tw, "region:\t%s\n", c.Region)
fmt.Fprintf(tw, "status:\t%s\n", c.Status)
fmt.Fprintf(tw, "phase:\t%s\n", c.Phase)
fmt.Fprintf(tw, "active:\t%s\n", yesno(c.Active))
fmt.Fprintf(tw, "operatorInstalled:\t%s\n", yesno(c.OperatorInstalled))
fmt.Fprintf(tw, "baselineInstalled:\t%s\n", yesno(c.BaselineInstalled))
fmt.Fprintf(tw, "endpoint:\t%s\n", deref(c.Endpoint))
fmt.Fprintf(tw, "k8sVersion:\t%s\n", deref(c.K8sVersion))
fmt.Fprintf(tw, "created:\t%s\n", c.CreatedAt)
if c.BaselineError != nil && *c.BaselineError != "" {
fmt.Fprintf(tw, "baselineError:\t%s\n", *c.BaselineError)
}
tw.Flush()
}
func printTarget(w io.Writer, t *Target) {
tw := newTab(w)
kind := "shared"
if t.Dedicated {
kind = "dedicated"
}
fmt.Fprintf(tw, "cluster:\t%s\n", t.Cluster)
fmt.Fprintf(tw, "kind:\t%s\n", kind)
for ns, env := range t.Namespaces {
fmt.Fprintf(tw, "namespace:\t%s -> %s\n", ns, env)
fmt.Fprintf(tw, "region:\t%s\n", dashIfEmpty(c.Region))
fmt.Fprintf(tw, "status:\t%s\n", dashIfEmpty(c.Status))
fmt.Fprintf(tw, "kind:\t%s\n", dashIfEmpty(c.Kind))
fmt.Fprintf(tw, "nodeCount:\t%d\n", c.NodeCount)
fmt.Fprintf(tw, "nodeSize:\t%s\n", dashIfEmpty(c.NodeSize))
fmt.Fprintf(tw, "gpus:\t%s\n", gpuCell(c))
fmt.Fprintf(tw, "created:\t%s\n", dashIfEmpty(c.CreatedAt))
for _, np := range c.NodePools {
fmt.Fprintf(tw, "pool:\t%s (%s x%d, autoscale=%s)\n", np.Name, np.Size, np.Count, yesno(np.AutoScale))
}
tw.Flush()
}
@@ -387,6 +272,10 @@ func newBuildCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
if br.Repo == "" || br.SHA == "" || br.Image == "" {
return fmt.Errorf("--repo (or positional), --sha and --image are required")
}
// The platform build muscle clones an https git URL; accept the
// idiomatic `owner/name` shorthand and expand it to GitHub (the host
// for every hanzoai/luxfi/zooai repo). A full URL passes through.
br.Repo = normalizeRepoURL(br.Repo)
if br.OrganizationID == "" {
br.OrganizationID = e.Org // optional; server defaults to DEFAULT_BUILD_ORG_ID
}
@@ -419,32 +308,24 @@ func newBuildCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
return cmd
}
// ---------------------------------------------------------------------------
// k8s — deploy-target helpers.
// ---------------------------------------------------------------------------
func newK8sCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "k8s",
Short: "Kubernetes deploy-target helpers",
// normalizeRepoURL expands the idiomatic `owner/name` shorthand to a full GitHub
// https URL (the platform build muscle clones https), and leaves an explicit URL
// (http/https/git/ssh scheme, or a scp-style git@host:owner/name) untouched. Only
// a bare single-segment `owner/name` — two path parts, no scheme, no host — is
// expanded; anything else is the caller's explicit choice and passes through.
func normalizeRepoURL(repo string) string {
r := strings.TrimSpace(repo)
if r == "" {
return r
}
target := &cobra.Command{
Use: "target",
Short: "Show the org's current resolved deploy target (cluster + namespaces)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
e := envOf()
org, err := e.requireOrg()
if err != nil {
return err
}
t, err := e.platform(gf).Target(cmd.Context(), org)
if err != nil {
return err
}
return e.emit(t, func(w io.Writer) { printTarget(w, t) })
},
// Already a URL or scp-style remote → leave as-is.
if strings.Contains(r, "://") || strings.Contains(r, "@") {
return r
}
cmd.AddCommand(target)
return cmd
// Bare owner/name (exactly two non-empty segments, no host dot in the first).
parts := strings.Split(strings.Trim(r, "/"), "/")
if len(parts) == 2 && parts[0] != "" && parts[1] != "" && !strings.Contains(parts[0], ".") {
return "https://github.com/" + parts[0] + "/" + parts[1]
}
return r
}
+109 -32
View File
@@ -8,6 +8,24 @@ import (
"testing"
)
func TestNormalizeRepoURL(t *testing.T) {
cases := map[string]string{
"luxfi/wallet": "https://github.com/luxfi/wallet",
"hanzoai/cloud": "https://github.com/hanzoai/cloud",
"https://github.com/luxfi/wallet": "https://github.com/luxfi/wallet", // full URL untouched
"git@github.com:luxfi/wallet.git": "git@github.com:luxfi/wallet.git", // scp-style untouched
"https://gitlab.com/org/repo": "https://gitlab.com/org/repo", // non-github URL untouched
"owner/name/extra": "owner/name/extra", // not a bare owner/name
"single": "single", // not two segments
"": "", // empty
}
for in, want := range cases {
if got := normalizeRepoURL(in); got != want {
t.Errorf("normalizeRepoURL(%q) = %q, want %q", in, got, want)
}
}
}
// withPlatform points the CLI at an httptest platform via env (HANZO_PLATFORM_URL
// + HANZO_PLATFORM_TOKEN), the same resolution path the real binary uses.
func withPlatform(t *testing.T, h http.HandlerFunc) string {
@@ -20,11 +38,15 @@ func withPlatform(t *testing.T, h http.HandlerFunc) string {
return srv.URL
}
// apps list hits the LIVE board path /v1/paas/apps and renders the fleet table.
func TestAppsListCommandTable(t *testing.T) {
withPlatform(t, func(w http.ResponseWriter, _ *http.Request) {
withPlatform(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/paas/apps" {
t.Errorf("apps path = %s, want /v1/paas/apps", r.URL.Path)
}
_ = json.NewEncoder(w).Encode(AppsList{
Apps: []AppView{
{Org: "hanzoai", App: "iam", Env: "main", DeclaredTag: strptr("v1.2.3"), RunningTag: strptr("v1.2.3"), Health: strptr("green"), Drift: json.RawMessage(`{"severity":"ok"}`)},
{Org: "hanzoai", App: "iam", Env: "main", DeclaredTag: "v1.2.3", RunningTag: "v1.2.3", Health: "green", Drift: json.RawMessage(`{"severity":"ok"}`)},
},
Summary: struct {
Total int `json:"total"`
@@ -43,6 +65,20 @@ func TestAppsListCommandTable(t *testing.T) {
}
}
// apps list honors --env/--health/--drift as server query params (the board filters).
func TestAppsListCommandFilters(t *testing.T) {
withPlatform(t, func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if q.Get("env") != "main" || q.Get("health") != "red" || q.Get("drift") != "1" {
t.Errorf("filters not forwarded: %s", r.URL.RawQuery)
}
_ = json.NewEncoder(w).Encode(AppsList{})
})
if _, err := runRoot(t, "", "apps", "list", "--env", "main", "--health", "red", "--drift"); err != nil {
t.Fatalf("apps list filters: %v", err)
}
}
func TestAppsListCommandJSON(t *testing.T) {
withPlatform(t, func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(AppsList{Apps: []AppView{{Org: "hanzoai", App: "iam", Env: "main"}}})
@@ -60,69 +96,112 @@ func TestAppsListCommandJSON(t *testing.T) {
}
}
// apps get hits /v1/paas/apps/{app}.
func TestAppsGetCommand(t *testing.T) {
withPlatform(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/paas/apps/iam" {
t.Errorf("path = %s, want /v1/paas/apps/iam", r.URL.Path)
}
_ = json.NewEncoder(w).Encode(AppView{ID: "hanzoai/iam/main", Org: "hanzoai", App: "iam", Env: "main", DeclaredTag: "v1.2.3", Health: "green", Phase: "Running"})
})
out, err := runRoot(t, "", "apps", "get", "iam")
if err != nil {
t.Fatalf("apps get: %v", err)
}
for _, want := range []string{"hanzoai/iam/main", "Running", "v1.2.3"} {
if !strings.Contains(out, want) {
t.Fatalf("apps get missing %q in:\n%s", want, out)
}
}
}
// deploy hits /v1/paas/apps/{app}/deploy — a rolling restart, org from identity.
func TestDeployCommand(t *testing.T) {
withPlatform(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/org/acme/project/p1/env/e1/container/app-x/redeploy" {
t.Errorf("redeploy path = %s", r.URL.Path)
if r.URL.Path != "/v1/paas/apps/app-x/deploy" || r.URL.Query().Get("env") != "main" {
t.Errorf("redeploy request = %s?%s", r.URL.Path, r.URL.RawQuery)
}
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true})
w.WriteHeader(202)
_ = json.NewEncoder(w).Encode(DeployResult{OK: true, App: "app-x", Namespace: "hanzo", Env: "main", RestartedAt: "2026-07-18T12:00:00Z"})
})
out, err := runRoot(t, "", "deploy", "app-x", "--org", "acme", "--project", "p1", "--env", "e1")
out, err := runRoot(t, "", "deploy", "app-x", "--env", "main")
if err != nil {
t.Fatalf("deploy: %v", err)
}
if !strings.Contains(out, "redeployed app-x") {
if !strings.Contains(out, "restarted app-x") || !strings.Contains(out, "namespace=hanzo") {
t.Fatalf("deploy output: %q", out)
}
}
func TestDeployRequiresProjectEnv(t *testing.T) {
withPlatform(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })
if _, err := runRoot(t, "", "deploy", "app-x", "--org", "acme"); err == nil {
t.Fatalf("deploy must require --project/--env")
// deploy REQUIRES --env — a bare deploy errors CLI-side, never silently prod.
func TestDeployRequiresEnv(t *testing.T) {
withPlatform(t, func(w http.ResponseWriter, _ *http.Request) {
t.Error("deploy without --env must not reach the server")
w.WriteHeader(202)
})
if _, err := runRoot(t, "", "deploy", "app-x"); err == nil {
t.Fatalf("deploy must require --env")
}
}
func TestDeployRequiresOrg(t *testing.T) {
withPlatform(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })
if _, err := runRoot(t, "", "deploy", "app-x", "--project", "p1", "--env", "e1"); err == nil {
t.Fatalf("deploy must require an org")
// deploy --env selects the lifecycle namespace via the ?env query param.
func TestDeployCommandEnv(t *testing.T) {
withPlatform(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/paas/apps/chat/deploy" || r.URL.Query().Get("env") != "test" {
t.Errorf("deploy env request = %s?%s", r.URL.Path, r.URL.RawQuery)
}
w.WriteHeader(202)
_ = json.NewEncoder(w).Encode(DeployResult{OK: true, App: "chat", Namespace: "hanzo-testnet", Env: "test", RestartedAt: "2026-07-18T12:00:00Z"})
})
if _, err := runRoot(t, "", "deploy", "chat", "--env", "test"); err != nil {
t.Fatalf("deploy --env: %v", err)
}
}
// A non-ok deploy response is surfaced as an error.
func TestDeployNotOK(t *testing.T) {
withPlatform(t, func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(DeployResult{OK: false})
})
if _, err := runRoot(t, "", "deploy", "app-x", "--env", "main"); err == nil {
t.Fatalf("deploy must error when the server does not report ok")
}
}
// clusters list hits the LIVE /v1/clusters (org from identity, not the path).
func TestClustersListCommand(t *testing.T) {
withPlatform(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/org/acme/cluster" {
t.Errorf("path = %s", r.URL.Path)
if r.URL.Path != "/v1/clusters" {
t.Errorf("path = %s, want /v1/clusters", r.URL.Path)
}
_ = json.NewEncoder(w).Encode(map[string]any{"clusters": []Cluster{
{DoksClusterID: "c1", Name: "hanzo-acme", Region: "sfo3", Status: "running", Phase: "ready", Active: true, OperatorInstalled: true, BaselineInstalled: true},
{DoksClusterID: "c1", Name: "hanzo-acme", Region: "sfo3", Status: "running", Kind: "managed", NodeCount: 3, NodeSize: "s-2vcpu-4gb", NvidiaGPU: 2},
}})
})
out, err := runRoot(t, "", "clusters", "list", "--org", "acme")
out, err := runRoot(t, "", "clusters", "list")
if err != nil {
t.Fatalf("clusters list: %v", err)
}
for _, want := range []string{"NAME", "hanzo-acme", "c1", "ready", "yes"} {
for _, want := range []string{"NAME", "hanzo-acme", "c1", "managed", "2 nvidia"} {
if !strings.Contains(out, want) {
t.Fatalf("clusters list missing %q in:\n%s", want, out)
}
}
}
func TestK8sTargetCommand(t *testing.T) {
withPlatform(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/org/acme/cluster/select" {
t.Errorf("path = %s", r.URL.Path)
}
_ = json.NewEncoder(w).Encode(map[string]any{"target": Target{Cluster: "hanzo-k8s", Dedicated: false, Namespaces: map[string]string{"hanzo": "main"}}})
// clusters get filters the live list client-side by id or name.
func TestClustersGetCommand(t *testing.T) {
withPlatform(t, func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"clusters": []Cluster{
{DoksClusterID: "c1", Name: "hanzo-acme", Region: "sfo3", Status: "running", Kind: "byo", NodeCount: 1},
}})
})
out, err := runRoot(t, "", "k8s", "target", "--org", "acme")
out, err := runRoot(t, "", "clusters", "get", "c1")
if err != nil {
t.Fatalf("k8s target: %v", err)
t.Fatalf("clusters get: %v", err)
}
if !strings.Contains(out, "hanzo-k8s") || !strings.Contains(out, "shared") {
t.Fatalf("k8s target output: %q", out)
if !strings.Contains(out, "hanzo-acme") || !strings.Contains(out, "byo") {
t.Fatalf("clusters get output: %q", out)
}
}
@@ -167,5 +246,3 @@ func TestConfigSetGetCommand(t *testing.T) {
t.Fatalf("config get = %q", out)
}
}
func strptr(s string) *string { return &s }
+1 -1
View File
@@ -8,7 +8,7 @@ package cli
// cosign-signed binary from the latest github.com/hanzoai/engine release), so the
// CLI never re-implements platform detection or verification. `serve` launches the
// installed binary (`hanzoai --port P run -m MODEL`); `status` probes it, reusing
// the same /v1/models probe `hanzo gpu connect --serve-engine` advertises with.
// the same /v1/models probe `hanzo link --serve-engine` advertises with.
import (
"context"
+1099 -122
View File
File diff suppressed because it is too large Load Diff
+157
View File
@@ -0,0 +1,157 @@
package cli
import (
"os"
"path/filepath"
"testing"
)
// TestParseRocmSmiCSV — the AMD GPU inventory names a card from its marketing
// series + gfx target, exactly as `rocm-smi --showproductname --csv` reports on
// evo's gfx1151 Radeon 8060S. This is the primary AMD detection path.
func TestParseRocmSmiCSV(t *testing.T) {
// Real evo output (header + one card row).
csv := []byte("device,Card Series,Card Model,Card Vendor,Card SKU,Subsystem ID,Device Rev,Node ID,GUID,GFX Version\n" +
"card0,Radeon 8060S Graphics,0x1586,Advanced Micro Devices Inc. [AMD/ATI],STRXLGEN,-0x7fe3,0xc1,1,49819,gfx1151\n")
gpus := parseRocmSmiCSV(csv)
if len(gpus) != 1 {
t.Fatalf("want 1 AMD GPU, got %d (%+v)", len(gpus), gpus)
}
if got, want := gpus[0].Name, "AMD Strix Halo \u00b7 Radeon 8060S Graphics (gfx1151)"; got != want {
t.Errorf("name = %q, want %q", got, want)
}
}
// TestGfxNameDecodesTargetVersion — the kfd fallback decodes gfx_target_version.
func TestGfxNameDecodesTargetVersion(t *testing.T) {
for _, c := range []struct {
v int
want string
}{
{110501, "gfx1151"}, // evo Radeon 8060S / Strix Halo
{90012, "gfx9012"}, // sanity: MI-class encoding shape
{100300, "gfx1030"}, // RDNA2
} {
if got := gfxName(c.v); got != c.want {
t.Errorf("gfxName(%d) = %q, want %q", c.v, got, c.want)
}
}
}
// TestParseKfdTopology — the driver-only fallback (no rocm-smi) still finds the GPU
// from /sys/class/kfd: node 0 is the CPU (simd_count 0, skipped), node 1 is the
// gfx1151 GPU. Built against a fixture tree mirroring evo's real properties files.
func TestParseKfdTopology(t *testing.T) {
root := t.TempDir()
writeNode(t, root, "0", "simd_count 0\ngfx_target_version 0\n")
writeNode(t, root, "1", "cpu_cores_count 0\nsimd_count 80\ngfx_target_version 110501\n")
gpus := parseKfdTopology(root)
if len(gpus) != 1 {
t.Fatalf("want 1 GPU node (CPU node 0 skipped), got %d (%+v)", len(gpus), gpus)
}
if got, want := gpus[0].Name, "AMD Strix Halo (gfx1151)"; got != want {
t.Errorf("name = %q, want %q", got, want)
}
if got, want := gpus[0].Arch, "gfx1151"; got != want {
t.Errorf("arch = %q, want %q", got, want)
}
}
// TestParseVulkaninfoSummary — the last resort keeps only AMD/Radeon devices so it
// never double-counts a card another vendor path already reported.
func TestParseVulkaninfoSummary(t *testing.T) {
out := []byte("GPU0:\n\tdeviceName = AMD Radeon Graphics (RADV GFX1151)\n" +
"GPU1:\n\tdeviceName = llvmpipe (LLVM 18.1.0, 256 bits)\n")
gpus := parseVulkaninfoSummary(out)
if len(gpus) != 1 {
t.Fatalf("want 1 AMD device (llvmpipe filtered), got %d (%+v)", len(gpus), gpus)
}
if got := gpus[0].Name; got != "AMD Radeon Graphics (RADV GFX1151)" {
t.Errorf("name = %q", got)
}
}
func writeNode(t *testing.T, root, id, props string) {
t.Helper()
dir := filepath.Join(root, id)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "properties"), []byte(props), 0o644); err != nil {
t.Fatal(err)
}
}
// TestPickAmdMemUnified — an APU whose VRAM is a token carve-out reports the
// machine's unified RAM snapped to hardware capacity (evo: 1 GiB VRAM, 118 GiB
// GTT, 124.4 GiB kernel-visible of 128 GiB physical → 128 GiB); a discrete card
// keeps its VRAM.
func TestPickAmdMemUnified(t *testing.T) {
if miB, unified := pickAmdMem(1024, 120832, 127411); !unified || miB != 131072 {
t.Errorf("APU: got (%d, %v), want (131072, true)", miB, unified)
}
if miB, unified := pickAmdMem(24576, 8192, 127411); unified || miB != 24576 {
t.Errorf("discrete: got (%d, %v), want (24576, false)", miB, unified)
}
}
// TestSnapUnified — a small firmware gap snaps up to the DIMM capacity; a big
// carve-out is real lost capacity and stays as-is; exact multiples stand.
func TestSnapUnified(t *testing.T) {
for _, c := range []struct{ in, want int64 }{
{127411, 131072}, // evo: 124.4 GiB visible of 128 GiB physical
{124610, 131072}, // spark GB10: 121.7 GiB visible of 128 GiB physical (~6.3 GiB firmware)
{131072, 131072}, // exact 128 GiB
{119194, 119194}, // ~116 GiB visible (16 GiB BIOS carve) — 11.6 GiB gap, keep honest
{63488, 65536}, // 62 GiB visible of 64 GiB
} {
if got := snapUnified(c.in); got != c.want {
t.Errorf("snapUnified(%d) = %d, want %d", c.in, got, c.want)
}
}
}
// TestAmdAPUName — the board renders the APU as the processor the owner bought,
// normalized from the BIOS shouting; non-Ryzen hosts opt out.
func TestAmdAPUName(t *testing.T) {
if got, want := amdAPUName("AMD RYZEN AI MAX+ 395 w/ Radeon 8060S"), "AMD Ryzen AI Max+ 395 w/ Radeon 8060S"; got != want {
t.Errorf("amdAPUName = %q, want %q", got, want)
}
if got := amdAPUName("Intel(R) Core(TM) i9-14900K"); got != "" {
t.Errorf("non-Ryzen host must opt out, got %q", got)
}
}
// TestApuProcessorNames — only unified-memory cards are renamed; a discrete
// card on the same host keeps its own identity.
func TestApuProcessorNames(t *testing.T) {
gpus := []gpuInfo{
{Name: "AMD Strix Halo \u00b7 Radeon 8060S Graphics (gfx1151)", Arch: "gfx1151", Unified: true},
{Name: "Radeon RX 7900 XTX (gfx1100)", Arch: "gfx1100"},
}
out := apuProcessorNames(gpus, "AMD RYZEN AI MAX+ 395 w/ Radeon 8060S")
if got, want := out[0].Name, "AMD Ryzen AI Max+ 395 w/ Radeon 8060S (gfx1151)"; got != want {
t.Errorf("APU name = %q, want %q", got, want)
}
if got, want := out[1].Name, "Radeon RX 7900 XTX (gfx1100)"; got != want {
t.Errorf("discrete name = %q, want %q", got, want)
}
}
// TestParseNvidiaSmiCSV — a GB10-class unified SoC ("[N/A]" VRAM) reports the
// machine RAM snapped to capacity + its sm arch; a discrete card keeps its VRAM.
func TestParseNvidiaSmiCSV(t *testing.T) {
out := []byte("NVIDIA GB10, [N/A], 12.1\n")
gpus := parseNvidiaSmiCSV(out, 124610)
if len(gpus) != 1 {
t.Fatalf("want 1 GPU, got %d", len(gpus))
}
g := gpus[0]
if g.Name != "NVIDIA GB10" || g.MemoryTotal != "131072 MiB" || !g.Unified || g.Arch != "sm_121" {
t.Errorf("GB10 = %+v", g)
}
disc := parseNvidiaSmiCSV([]byte("NVIDIA GeForce RTX 4090, 24564 MiB, 8.9\n"), 124610)
if d := disc[0]; d.MemoryTotal != "24564 MiB" || d.Unified || d.Arch != "sm_89" {
t.Errorf("discrete = %+v", d)
}
}
+2 -1
View File
@@ -109,6 +109,7 @@ func TestBuildRegistrationCarriesEngine(t *testing.T) {
jobsNS: "gpu-jobs",
gpus: []gpuInfo{{Name: "NVIDIA GB10", MemoryTotal: "122880 MiB"}},
serveEngine: true,
studioReady: true,
engineURL: srv.URL,
engineAdvURL: "http://node.example:1234",
}
@@ -136,7 +137,7 @@ func TestBuildRegistrationCarriesEngine(t *testing.T) {
}
func TestCapabilitiesWithoutEngine(t *testing.T) {
w := &worker{serveEngine: false}
w := &worker{serveEngine: false, studioReady: true}
caps := w.capabilities()
if len(caps) != 1 || caps[0] != studioCap {
t.Fatalf("capabilities = %v, want just [%q] when not serving an engine", caps, studioCap)
+75
View File
@@ -0,0 +1,75 @@
package cli
import (
"encoding/json"
"os/exec"
"strings"
"testing"
)
// TestFnValidate — payload bounds: script required, flag-injection via
// requirements refused, timeout defaulted and capped.
func TestFnValidate(t *testing.T) {
if _, err := fnValidate(json.RawMessage(`{}`)); err == nil {
t.Error("empty script must be refused")
}
if _, err := fnValidate(json.RawMessage(`{"script":"print(1)","requirements":["--index-url=evil"]}`)); err == nil {
t.Error("flag-shaped requirement must be refused")
}
in, err := fnValidate(json.RawMessage(`{"script":"print(1)"}`))
if err != nil || in.TimeoutSeconds != 3600 {
t.Errorf("default timeout: got %d, err %v", in.TimeoutSeconds, err)
}
in, _ = fnValidate(json.RawMessage(`{"script":"print(1)","timeoutSeconds":999999}`))
if in.TimeoutSeconds != 21600 {
t.Errorf("timeout cap: got %d, want 21600", in.TimeoutSeconds)
}
}
// TestTailBuffer — a chatty training loop keeps only the LAST bytes, flagged.
func TestTailBuffer(t *testing.T) {
tb := &tailBuffer{cap: 8}
tb.Write([]byte("0123456789"))
if string(tb.buf) != "23456789" || !tb.truncated {
t.Errorf("tail = %q truncated=%v", tb.buf, tb.truncated)
}
tb2 := &tailBuffer{cap: 8}
tb2.Write([]byte("abc"))
if string(tb2.buf) != "abc" || tb2.truncated {
t.Errorf("small write mangled: %q %v", tb2.buf, tb2.truncated)
}
}
// TestHasNonRenderLane — echo and studio.render alone keep the render-only
// poison guard; a registered fn.run lane lifts it.
func TestHasNonRenderLane(t *testing.T) {
w := &worker{handlers: map[string]jobHandler{"echo": echoHandler, studioCap: nil}}
if w.hasNonRenderLane() {
t.Error("render-only worker must report no extra lane")
}
w.handlers[fnCap] = fnRunHandler
if !w.hasNonRenderLane() {
t.Error("fn.run lane must lift the guard")
}
}
// TestFnRunSmoke — the real handler end-to-end against the local uv: a script
// that prints and exits 0. Skipped where uv is absent.
func TestFnRunSmoke(t *testing.T) {
if !uvPresent() {
t.Skip("uv not installed")
}
res, err := fnRunHandler(t.Context(), json.RawMessage(`{"script":"print(2+2)","timeoutSeconds":120}`))
if err != nil {
t.Fatalf("fn.run: %v", err)
}
m := res.(map[string]any)
if !strings.Contains(m["output"].(string), "4") {
t.Errorf("output = %q, want it to contain 4", m["output"])
}
}
func uvPresent() bool {
_, err := exec.LookPath("uv")
return err == nil
}
+291
View File
@@ -0,0 +1,291 @@
package cli
// gpu_queue_test.go — the per-GPU claim contract: a job pinned to THIS machine's
// lane ("gpu:<identity>") is claimed BEFORE the shared any-GPU lane ("gpu-jobs"),
// both within the gpu-jobs namespace. A stub cloud records the taskQueue of every
// claim so the ORDER is the assertion.
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
// stubJobsCloud records the taskQueue of every claim and serves the queued job (if
// any) on the matching lane, once. A lane with no job (or already drained) answers
// 204; complete/fail/heartbeat answer 200.
func stubJobsCloud(t *testing.T, claims *[]string, jobsByLane map[string]*claimedActivity) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/activities/claim") {
var body struct {
TaskQueue string `json:"taskQueue"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
*claims = append(*claims, body.TaskQueue)
if job := jobsByLane[body.TaskQueue]; job != nil {
jobsByLane[body.TaskQueue] = nil // deliver once
_ = json.NewEncoder(w).Encode(job)
return
}
w.WriteHeader(http.StatusNoContent)
return
}
w.WriteHeader(http.StatusOK) // complete / fail / heartbeat
}))
}
// echoJob is a GPU-free job the worker's echo handler runs to completion instantly,
// so a claim test never needs a real render backend.
func echoJob(id string) *claimedActivity {
a := &claimedActivity{Input: json.RawMessage(`{}`)}
a.Execution.WorkflowId, a.Execution.RunId = id, id
a.Type.Name = "echo"
return a
}
func testWorker(t *testing.T, url string) *worker {
t.Helper()
t.Setenv("HANZO_TOKEN", "test-token") // ensureToken honors this; no `hanzo login`
return &worker{
env: &Env{CloudURL: url},
http: &http.Client{Timeout: 5 * time.Second},
baseURL: url,
identity: "spark",
hostname: "spark",
jobsNS: "gpu-jobs",
handlers: map[string]jobHandler{"echo": echoHandler},
studioReady: true, // a render-capable node (preflight passed)
}
}
func TestGPUQueueLaneName(t *testing.T) {
w := &worker{identity: "spark"}
if got := w.gpuQueue(); got != "gpu:spark" {
t.Fatalf("gpuQueue() = %q, want gpu:spark", got)
}
}
// A job pinned to THIS GPU's lane is claimed first — the shared lane is not even
// polled that cycle.
func TestClaimPrefersTargetedLane(t *testing.T) {
var claims []string
srv := stubJobsCloud(t, &claims, map[string]*claimedActivity{"gpu:spark": echoJob("j1")})
defer srv.Close()
if err := testWorker(t, srv.URL).claimAndRun(context.Background(), io.Discard); err != nil {
t.Fatalf("claimAndRun: %v", err)
}
if len(claims) != 1 || claims[0] != "gpu:spark" {
t.Fatalf("claims = %v, want exactly [gpu:spark] (targeted first; shared not polled)", claims)
}
}
// With its own lane empty, the worker falls through to the shared any-GPU lane.
func TestClaimFallsBackToSharedLane(t *testing.T) {
var claims []string
srv := stubJobsCloud(t, &claims, map[string]*claimedActivity{"gpu-jobs": echoJob("j2")})
defer srv.Close()
if err := testWorker(t, srv.URL).claimAndRun(context.Background(), io.Discard); err != nil {
t.Fatalf("claimAndRun: %v", err)
}
if len(claims) != 2 || claims[0] != "gpu:spark" || claims[1] != "gpu-jobs" {
t.Fatalf("claims = %v, want [gpu:spark gpu-jobs] (targeted, then shared)", claims)
}
}
// Both lanes empty polls targeted THEN shared and runs nothing.
func TestClaimBothLanesEmpty(t *testing.T) {
var claims []string
srv := stubJobsCloud(t, &claims, map[string]*claimedActivity{})
defer srv.Close()
if err := testWorker(t, srv.URL).claimAndRun(context.Background(), io.Discard); err != nil {
t.Fatalf("claimAndRun: %v", err)
}
if len(claims) != 2 || claims[0] != "gpu:spark" || claims[1] != "gpu-jobs" {
t.Fatalf("claims = %v, want [gpu:spark gpu-jobs]", claims)
}
}
// The render submit seam is the gated worker-mode execute path, not the open /prompt
// — the shared contract with the studio's --worker-mode gate.
func TestWorkerExecuteSeamIsGated(t *testing.T) {
if localWorkerExecute != "http://127.0.0.1:8188/v1/worker/execute" {
t.Fatalf("localWorkerExecute = %q, want the gated /v1/worker/execute seam", localWorkerExecute)
}
}
// A HUNG nvidia-smi (blocks until its bounded context fires) must NOT block the
// caller: reportSample detaches the probe+POST onto a goroutine and returns at once,
// so the worker's select loop keeps heartbeating and claiming. Guards the
// worker-wedge regression (a synchronous probe that stalls the loop under GPU/driver
// pressure → the machine flaps offline mid-render).
func TestReportSampleNeverBlocksLoop(t *testing.T) {
orig := nvidiaSmi
defer func() { nvidiaSmi = orig }()
probing := make(chan struct{})
release := make(chan struct{})
nvidiaSmi = func(ctx context.Context) ([]byte, error) {
close(probing) // entered the probe (the nvidiaSmi var read already happened)
select {
case <-release: // the test lets us finish
case <-ctx.Done(): // or the bounded probe timeout fires
}
return nil, ctx.Err()
}
t.Setenv("HANZO_TOKEN", "t")
w := &worker{identity: "spark", hostname: "spark", http: &http.Client{Timeout: time.Second}, env: &Env{}, baseURL: "http://127.0.0.1:0"}
done := make(chan struct{})
go func() { w.reportSample(context.Background()); close(done) }()
select {
case <-done: // returned immediately — the select loop is never wedged
case <-time.After(500 * time.Millisecond):
t.Fatal("reportSample blocked the caller — a hung sampler would wedge the worker loop")
}
select {
case <-probing: // the probe really ran, on the detached goroutine (off the critical path)
case <-time.After(2 * time.Second):
t.Fatal("probe never started")
}
close(release)
}
// A node that can't serve renders (preflight failed) claims NOTHING — it must never
// pull a render job onto a box that will only refuse it on the gated seam (poison
// loop). It still heartbeats presence; it just stays idle.
func TestNotStudioReadyClaimsNothing(t *testing.T) {
var claims []string
srv := stubJobsCloud(t, &claims, map[string]*claimedActivity{"gpu:spark": echoJob("j1")})
defer srv.Close()
w := testWorker(t, srv.URL)
w.studioReady = false
if err := w.claimAndRun(context.Background(), io.Discard); err != nil {
t.Fatalf("claimAndRun: %v", err)
}
if len(claims) != 0 {
t.Fatalf("a not-ready node claimed %v; want zero claims", claims)
}
}
// The terminal report must hit the RIGHT activity — namespace gpu-jobs, the CLAIMED
// workflow+run ids, the correct verb. A stub that 200s every path lets an ns/id
// routing regression pass, so assert the exact paths for both complete and fail.
func TestTerminalReportsHitCorrectActivityPath(t *testing.T) {
t.Setenv("HANZO_TOKEN", "t")
var mu sync.Mutex
terminal := map[string]string{}
served := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/activities/claim"):
var body struct {
TaskQueue string `json:"taskQueue"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
if body.TaskQueue != "gpu:spark" {
w.WriteHeader(http.StatusNoContent)
return
}
mu.Lock()
n := served
served++
mu.Unlock()
switch n {
case 0:
_ = json.NewEncoder(w).Encode(echoJob("wf1")) // echo handler → complete
case 1:
j := echoJob("wf2")
j.Type.Name = "nope" // no handler → fail
_ = json.NewEncoder(w).Encode(j)
default:
w.WriteHeader(http.StatusNoContent)
}
case strings.HasSuffix(r.URL.Path, "/complete"):
mu.Lock()
terminal["complete"] = r.URL.Path
mu.Unlock()
w.WriteHeader(http.StatusOK)
case strings.HasSuffix(r.URL.Path, "/fail"):
mu.Lock()
terminal["fail"] = r.URL.Path
mu.Unlock()
w.WriteHeader(http.StatusOK)
default:
w.WriteHeader(http.StatusOK)
}
}))
defer srv.Close()
w := testWorker(t, srv.URL)
if err := w.claimAndRun(context.Background(), io.Discard); err != nil {
t.Fatalf("run1 (echo→complete): %v", err)
}
if err := w.claimAndRun(context.Background(), io.Discard); err != nil {
t.Fatalf("run2 (unknown→fail): %v", err)
}
if got := terminal["complete"]; got != "/v1/tasks/namespaces/gpu-jobs/activities/wf1/wf1/complete" {
t.Fatalf("complete path = %q, want the claimed activity's exact ns+ids", got)
}
if got := terminal["fail"]; got != "/v1/tasks/namespaces/gpu-jobs/activities/wf2/wf2/fail" {
t.Fatalf("fail path = %q, want the claimed activity's exact ns+ids", got)
}
}
// SharePolicy.reject's fallback: an ABSENT or unparseable input field skips its gate
// (permissive), never a hard error, so a policy only ever narrows on fields it can read.
func TestSharePolicyRejectFallback(t *testing.T) {
var nilp *SharePolicy
if r := nilp.reject("studio.render", nil); r != "" {
t.Fatalf("nil policy must allow everything: %q", r)
}
p := &SharePolicy{AllowedJobTypes: []string{"studio.render"}}
if p.reject("echo", nil) == "" {
t.Fatal("a disallowed job type must be rejected")
}
if r := p.reject("studio.render", nil); r != "" {
t.Fatalf("an allowed job type must pass: %q", r)
}
p2 := &SharePolicy{AllowedOrgs: []string{"acme"}}
if r := p2.reject("studio.render", json.RawMessage(`{}`)); r != "" {
t.Fatalf("absent org must SKIP the org gate (fallback), not reject: %q", r)
}
if r := p2.reject("studio.render", json.RawMessage(`{"org":"acme"}`)); r != "" {
t.Fatalf("a matching org must pass: %q", r)
}
if p2.reject("studio.render", json.RawMessage(`{"org":"other"}`)) == "" {
t.Fatal("a non-allowed org must be rejected")
}
if r := p2.reject("studio.render", json.RawMessage(`not json`)); r != "" {
t.Fatalf("unparseable input must skip input gates, not reject: %q", r)
}
}
// studioCap is advertised ONLY when the node can actually render: a missing worker
// token is never ready (the gated seam would 403), and the block reason is explicit.
func TestStudioReadyGatesCapability(t *testing.T) {
t.Setenv("STUDIO_WORKER_TOKEN", "")
w := &worker{launchesStudio: true, http: &http.Client{}}
w.refreshStudioReady(context.Background())
if w.studioReady {
t.Fatal("no token must never be studio-ready")
}
if contains(w.capabilities(), studioCap) {
t.Fatalf("studioCap advertised without a token: %v", w.capabilities())
}
if w.studioBlockReason() == "" {
t.Fatal("a not-ready node must explain why it won't render")
}
// Token present + we launch the studio ⇒ ready ⇒ studioCap advertised.
t.Setenv("STUDIO_WORKER_TOKEN", "tok")
if changed := w.refreshStudioReady(context.Background()); !changed {
t.Fatal("adding the token should flip readiness")
}
if !w.studioReady || !contains(w.capabilities(), studioCap) {
t.Fatalf("token + launchesStudio must be ready + advertise studioCap: ready=%v caps=%v", w.studioReady, w.capabilities())
}
}
+88
View File
@@ -0,0 +1,88 @@
package cli
// gpu_spec_test.go — the host static-spec a `hanzo link` node reports so
// GET /v1/fleet can show its CPU arch, core count and total RAM (the fields a
// code-linked box already carries). Real telemetry only: arch is `uname -m`, cores
// are runtime.NumCPU, RAM is parsed from the OS — never a hardcoded machine.
import (
"os/exec"
"runtime"
"strings"
"testing"
)
func TestParseMemTotalKB(t *testing.T) {
// A real /proc/meminfo head from a 128 GiB box. MemTotal is in kB; we report bytes.
meminfo := []byte("MemTotal: 131923980 kB\nMemFree: 1048576 kB\nMemAvailable: 120000000 kB\n")
if got, want := parseMemTotalKB(meminfo), int64(131923980)*1024; got != want {
t.Fatalf("parseMemTotalKB = %d, want %d bytes", got, want)
}
// Absent / malformed input is reported as 0 (unknown), never a guess.
for name, in := range map[string]string{
"empty": "",
"no-memtotal": "MemFree: 100 kB\n",
"malformed": "MemTotal: notanumber kB\n",
"no-value": "MemTotal:\n",
} {
if got := parseMemTotalKB([]byte(in)); got != 0 {
t.Fatalf("%s: parseMemTotalKB = %d, want 0", name, got)
}
}
}
// detectMemTotal reads the real host, so on Linux/macOS CI it must return a positive
// byte count — proof the reporter reads actual RAM rather than shipping 0.
func TestDetectMemTotalIsReal(t *testing.T) {
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
t.Skipf("no MemTotal source on %s", runtime.GOOS)
}
got := detectMemTotal()
if got <= 0 {
t.Fatalf("detectMemTotal = %d, want the host's real RAM (>0)", got)
}
// Evidence: what THIS host actually reports (never hardcoded). On the GB10 spark
// box this prints aarch64 + ~128 GiB read from /proc/meminfo.
t.Logf("real host spec: arch=%s cpus=%d memory=%d bytes (%.1f GiB)",
detectArch(), runtime.NumCPU(), got, float64(got)/(1<<30))
}
// detectArch must match the fleet's `uname -m` convention (aarch64 | x86_64 | arm64),
// NOT runtime.GOARCH (arm64 | amd64) — so a machine that appears as both a run-target
// and a linked worker shows ONE arch string on the board. On Linux uname -m is
// aarch64/x86_64; assert the real host agrees and is never GOARCH's amd64.
func TestDetectArchMatchesUnameConvention(t *testing.T) {
got := detectArch()
if got == "" {
t.Fatal("detectArch returned empty; must fall back to runtime.GOARCH")
}
if out, err := exec.Command("uname", "-m").Output(); err == nil {
if want := strings.TrimSpace(string(out)); want != "" && got != want {
t.Fatalf("detectArch = %q, want `uname -m` %q (fleet convention)", got, want)
}
}
// Guard the regression this test exists for: on Linux amd64 the value must be
// x86_64, never GOARCH's "amd64".
if runtime.GOOS == "linux" && runtime.GOARCH == "amd64" && got == "amd64" {
t.Fatal("arch is GOARCH 'amd64'; the fleet convention is 'x86_64'")
}
t.Logf("detectArch=%q (GOARCH=%q)", got, runtime.GOARCH)
}
// buildRegistration must carry this host's detected arch (uname -m), cores (NumCPU)
// and RAM — so spark reports aarch64 and evo-2 reports x86_64, both ~128 GB, matching
// how the same machines already report as code-linked run-targets.
func TestBuildRegistrationCarriesHostSpec(t *testing.T) {
const mem = int64(137438953472) // 128 GiB
w := &worker{hostname: "spark", jobsNS: "gpu-jobs", arch: "aarch64", memory: mem}
reg := w.buildRegistration()
if reg.Arch != "aarch64" {
t.Fatalf("Arch = %q, want the worker's detected arch %q", reg.Arch, "aarch64")
}
if reg.CPUs != runtime.NumCPU() {
t.Fatalf("CPUs = %d, want runtime.NumCPU %d", reg.CPUs, runtime.NumCPU())
}
if reg.Memory != mem {
t.Fatalf("Memory = %d, want the detected total %d", reg.Memory, mem)
}
}
+200
View File
@@ -0,0 +1,200 @@
package cli
// link.go — `hanzo link | unlink | status`: bring THIS machine into the Hanzo
// cloud fleet AS A NODE, take it back out, and view the fleet.
//
// A node is two orthogonal memberships, composed under one verb:
//
// 1. The FABRIC — hanzod on hanzo.network. `link` starts it by invoking the
// canonical fabric verb `hanzo node up` (the Rust node CLI: resolve a hanzod
// binary, spawn it detached, record its pid). link does NOT reimplement hanzod
// supervision — there is exactly one way to start hanzod, and this composes it.
// Best-effort: a node with no hanzod still joins the compute fleet (CPU-only
// boxes and dev machines link fine); `--no-fabric` skips it outright.
//
// 2. The COMPUTE fleet — this machine's inventory (CPU cores + model, memory, and
// each GPU as its own resource) registered as a heartbeating presence in the
// org's `fleet` namespace, running the outbound worker loop that claims jobs
// from `gpu-jobs`. That machinery lives in gpu.go (runConnect); `link` runs it.
//
// `unlink` reverses both: it deregisters the worker (drops the fleet row), then
// stops the fabric (`hanzo node stop`). `status` is the fleet view — every machine
// with each of its GPUs shown distinctly, this box highlighted.
//
// One identity: the IAM token `hanzo login` mints authorizes every cloud call; the
// server derives the tenant from the token. No secrets on the box.
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"time"
"github.com/spf13/cobra"
)
// ---------------------------------------------------------------------------
// link / unlink / status — the node-level command surface.
// ---------------------------------------------------------------------------
func newLinkCmd(envOf func() *Env, _ *globalFlags) *cobra.Command {
var opts connectOpts
var daemon bool
var noFabric bool
cmd := &cobra.Command{
Use: "link",
Short: "Bring this machine into the Hanzo cloud fleet as a node",
Long: "Link this machine into the Hanzo cloud as a node: join the fabric (start\n" +
"hanzod on hanzo.network) and register as a compute worker — advertising this\n" +
"host's CPU (cores + model), memory, and each GPU as its own resource, then\n" +
"heartbeating and claiming jobs from your org's queue. The node shows up in the\n" +
"console (Machines + GPUs) and on `hanzo status`. Works on a CPU-only box.\n" +
"Authentication reuses the `hanzo login` token; the org is taken from its claims.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if daemon {
return installDaemon(cmd, opts)
}
// 1. Fabric: start hanzod via the canonical `hanzo node up` (best-effort).
startFabric(cmd, noFabric)
// 2. Compute fleet: register inventory, heartbeat, claim jobs (foreground).
return runConnect(cmd, envOf(), opts)
},
}
f := cmd.Flags()
f.StringVar(&opts.jobsNS, "jobs-namespace", defaultJobsNS, "tasks namespace to claim jobs from")
f.BoolVar(&noFabric, "no-fabric", false, "join the compute fleet only; do not start hanzod")
f.BoolVar(&daemon, "daemon", false, "install a systemd --user unit (Restart=always) instead of running in the foreground")
f.BoolVar(&opts.serveEngine, "serve-engine", false, "also advertise a hanzo-engine model server (OpenAI + Anthropic) running on this node")
f.StringVar(&opts.engineURL, "engine-url", defaultEngineURL, "local URL where hanzo-engine is probed (GET /v1/models)")
f.StringVar(&opts.engineEndpoint, "engine-endpoint", "", "public URL to advertise for gateway routing (defaults to --engine-url; a node behind NAT needs a reachable URL/tunnel)")
f.BoolVar(&opts.registerProvider, "register-provider", false, "auto-register the engine endpoint as an org model provider (POST /v1/add-provider)")
f.StringVar(&opts.studioDir, "studio-dir", os.Getenv("HANZO_STUDIO_DIR"), "local Hanzo Studio checkout; when set, link launches and supervises the render backend on 127.0.0.1:8188")
f.StringVar(&opts.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)")
f.BoolVar(&opts.mirror, "mirror", true, "sweep local renders into the org studio library; --mirror=false serves jobs only")
return cmd
}
func newUnlinkCmd(envOf func() *Env, _ *globalFlags) *cobra.Command {
return &cobra.Command{
Use: "unlink",
Short: "Take this machine out of the fleet (deregister + stop hanzod)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
// Reverse of link, both best-effort: drop the compute-fleet row, then
// ALWAYS stop the fabric so a deregister error never leaves hanzod
// running. The deregister error is reported, not short-circuited.
derr := runDisconnect(cmd, envOf())
stopFabric(cmd)
return derr
},
}
}
func newStatusCmd(envOf func() *Env, _ *globalFlags) *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Show the org's fleet — every machine with each of its GPUs (this box highlighted)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error { return runFleetStatus(cmd, envOf()) },
}
}
// ---------------------------------------------------------------------------
// Fabric — compose the canonical `hanzo node up` / `node stop`.
// ---------------------------------------------------------------------------
// fabricCLI resolves the Hanzo node CLI that owns hanzod supervision — the Rust
// fabric/dev CLI with `node up/stop`. In the canonical layout the Go unified binary
// takes the `hanzo` name and the Rust CLI is installed alongside it as `hanzo-node`,
// so `link` composes `node up` without shelling into itself (this binary has no
// `node`). Resolution order: HANZO_FABRIC_CLI, then `hanzo-node`, then a
// self-guarded `hanzo` (for boxes where the Rust CLI still holds the `hanzo` name).
// Returns "" when none is resolvable.
func fabricCLI() string {
if p := os.Getenv("HANZO_FABRIC_CLI"); p != "" {
return p
}
if p, err := exec.LookPath("hanzo-node"); err == nil {
return p
}
p, err := exec.LookPath("hanzo")
if err != nil {
return ""
}
if self, err := os.Executable(); err == nil {
if sp, _ := os.Readlink(p); sp == self || p == self {
return ""
}
}
return p
}
// Passthrough delegates a verb this binary does not own — node, dev, wallet,
// network, … — to the Rust fabric/dev CLI (resolved by fabricCLI, installed as
// `hanzo-node`), so the single `hanzo` name is a SUPERSET: Go verbs served natively,
// everything else handed through unchanged. `hanzo node up` (the fabric `link`
// itself composes) works for users too. It runs the delegate to completion with
// inherited stdio and exits with its code; it returns false only when no fabric CLI
// is resolvable, so the caller can report unknown-subcommand.
func Passthrough(args []string) bool {
bin := fabricCLI()
if bin == "" {
return false
}
c := exec.Command(bin, args...)
c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr
if err := c.Run(); err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
os.Exit(ee.ExitCode())
}
fmt.Fprintf(os.Stderr, "hanzo: delegating %v to %s: %v\n", args, bin, err)
os.Exit(1)
}
os.Exit(0)
return true
}
// startFabric starts hanzod by invoking `hanzo node up` — the one canonical way to
// join the fabric. Best-effort by design: a missing CLI or a missing hanzod prints
// a clear note and the node still joins the compute fleet. `--no-fabric` skips it.
func startFabric(cmd *cobra.Command, skip bool) {
out := cmd.OutOrStdout()
if skip {
fmt.Fprintln(out, "fabric: skipped (--no-fabric); joining the compute fleet only")
return
}
bin := fabricCLI()
if bin == "" {
fmt.Fprintln(out, "fabric: hanzo node CLI not found — joining the compute fleet only")
fmt.Fprintln(out, " (install the `hanzo` node CLI or set HANZO_FABRIC_CLI to start hanzod)")
return
}
ctx, cancel := context.WithTimeout(cmd.Context(), 60*time.Second)
defer cancel()
c := exec.CommandContext(ctx, bin, "node", "up")
c.Stdout, c.Stderr = out, cmd.ErrOrStderr()
if err := c.Run(); err != nil {
fmt.Fprintf(out, "fabric: `%s node up` did not start hanzod (%v) — joining the compute fleet only\n", bin, err)
}
}
// stopFabric stops the hanzod this box started, via the canonical `hanzo node stop`.
// Best-effort — nothing to stop is not an error.
func stopFabric(cmd *cobra.Command) {
out := cmd.OutOrStdout()
bin := fabricCLI()
if bin == "" {
return
}
ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
defer cancel()
c := exec.CommandContext(ctx, bin, "node", "stop")
c.Stdout, c.Stderr = out, cmd.ErrOrStderr()
_ = c.Run()
}
+58
View File
@@ -0,0 +1,58 @@
package cli
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/spf13/cobra"
)
// TestUnlinkIdempotent — a repeat `hanzo unlink` is a no-op. The deregister POST
// hitting an already-terminal (409) or absent (404) fleet row is the desired end
// state, so runDisconnect returns nil (not an error) and says so, letting `unlink`
// be run twice safely.
func TestUnlinkIdempotent(t *testing.T) {
for _, code := range []int{http.StatusConflict, http.StatusNotFound} {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(code)
_, _ = w.Write([]byte(`{"error":"activity terminal"}`))
}))
t.Setenv("HANZO_TOKEN", "test-token") // ensureToken honors this; no login needed
var out bytes.Buffer
cmd := &cobra.Command{}
cmd.SetContext(context.Background())
cmd.SetOut(&out)
if err := runDisconnect(cmd, &Env{CloudURL: srv.URL}); err != nil {
t.Errorf("HTTP %d: runDisconnect should be idempotent (nil), got %v", code, err)
}
if !strings.Contains(out.String(), "already unlinked") {
t.Errorf("HTTP %d: want 'already unlinked' notice, got %q", code, out.String())
}
srv.Close()
}
}
// TestUnlinkDeregisterErrorSurfaces — a non-terminal deregister failure (e.g. 500)
// is a real error and must be returned, not swallowed as idempotent success.
func TestUnlinkDeregisterErrorSurfaces(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"boom"}`))
}))
defer srv.Close()
t.Setenv("HANZO_TOKEN", "test-token")
cmd := &cobra.Command{}
cmd.SetContext(context.Background())
cmd.SetOut(&bytes.Buffer{})
if err := runDisconnect(cmd, &Env{CloudURL: srv.URL}); err == nil {
t.Fatal("a 500 deregister must surface an error, got nil")
}
}
+110 -132
View File
@@ -12,11 +12,13 @@ import (
"time"
)
// Platform is a thin client over the platform.hanzo.ai /v1 control plane. That
// surface is machine-to-machine (service-token, "No OIDC" — it cannot validate
// IAM user tokens), so the token here is the platform service token, resolved
// from flag/env/credential store by the caller; the build endpoint takes its
// own token per call.
// Platform is a thin client over the LIVE Hanzo Cloud control plane
// (platform.hanzo.ai / api.hanzo.ai → svc `cloud`, the Go binary). Every route it
// calls is served by that one binary and authorized off ONE IAM identity: after
// `hanzo login` the CLI sends the IAM access token as the bearer, and the cloud's
// identity middleware (SanitizeIdentity) validates the JWT and org-scopes the
// caller — no separate platform/service token. A purpose-minted machine token
// still works (flag > env > credential store > IAM login) for automation.
type Platform struct {
baseURL string
token string
@@ -45,8 +47,8 @@ func (e *apiError) Error() string {
msg = http.StatusText(e.status)
}
hint := ""
if e.status == http.StatusUnauthorized {
hint = " (set the platform service token: --platform-token, HANZO_PLATFORM_TOKEN, or `hanzo login --platform-token`)"
if e.status == http.StatusUnauthorized || e.status == http.StatusForbidden {
hint = " (run `hanzo login` — your IAM identity authorizes the platform; admin ops need an org-admin or superadmin identity)"
}
return fmt.Sprintf("platform %s: HTTP %d: %s%s", e.path, e.status, msg, hint)
}
@@ -55,7 +57,7 @@ func (e *apiError) Error() string {
// into out (when non-nil) and mapping a non-2xx into an *apiError.
func (p *Platform) do(ctx context.Context, method, path, token string, body, out any) error {
if token == "" {
return fmt.Errorf("no platform token: pass --platform-token, set HANZO_PLATFORM_TOKEN, or run `hanzo login --platform-token <tok>`")
return fmt.Errorf("not authenticated: run `hanzo login` (an IAM login now authorizes the platform; a --platform-token / HANZO_PLATFORM_TOKEN still works for machine automation)")
}
var rdr io.Reader
if body != nil {
@@ -94,8 +96,8 @@ func (p *Platform) do(ctx context.Context, method, path, token string, body, out
return nil
}
// serverMessage pulls the `{ "message": … }` field platform errors use, falling
// back to the raw (truncated) body.
// serverMessage pulls the `{ "message": … }` / `{ "error": … }` field the cloud's
// errors use, falling back to the raw (truncated) body.
func serverMessage(raw []byte) string {
var e struct {
Message string `json:"message"`
@@ -117,33 +119,35 @@ func serverMessage(raw []byte) string {
}
// ---------------------------------------------------------------------------
// Apps board — GET /v1/apps, GET /v1/apps/{id}, POST /v1/apps/sync.
// Apps board — GET /v1/paas/apps, GET /v1/paas/apps/{app}. The live Go cloud's
// fleet drift board (clients/paas): the operator App CRs across the platform
// namespaces, declared/running/latest tags + health + the drift verdict. It is
// org-confined server-side (a SuperAdmin sees the fleet; an OrgAdmin only its own
// org), so the CLI sends NO org filter — identity scopes the view.
// ---------------------------------------------------------------------------
// AppView mirrors the platform apps-lifecycle DTO. Nullable columns are *string
// so JSON null round-trips; Drift is kept raw so --json is byte-faithful and
// the drift schema can evolve without a client bump.
// AppView mirrors clients/paas.AppView (the LIVE board DTO). Tags/health are plain
// strings ("" == unknown, rendered "-"); Drift is kept raw so --json is
// byte-faithful and the drift schema can evolve without a client bump.
type AppView struct {
ID string `json:"id"`
Org string `json:"org"`
App string `json:"app"`
Env string `json:"env"`
Repo string `json:"repo"`
Registry string `json:"registry"`
DeclaredTag *string `json:"declaredTag"`
RunningTag *string `json:"runningTag"`
LatestTag *string `json:"latestTag"`
ReleaseURL *string `json:"releaseUrl"`
ReleaseAssets int `json:"releaseAssets"`
Health *string `json:"health"`
Cluster *string `json:"cluster"`
Namespace *string `json:"namespace"`
LastObserved *string `json:"lastObserved"`
UpdatedAt string `json:"updatedAt"`
Drift json.RawMessage `json:"drift"`
ID string `json:"id"` // <org>/<app>/<env>, e.g. hanzoai/iam/main
Org string `json:"org"` // image namespace, e.g. hanzoai
App string `json:"app"`
Env string `json:"env"`
Repo string `json:"repo"`
Registry string `json:"registry"`
DeclaredTag string `json:"declaredTag"`
RunningTag string `json:"runningTag"`
LatestTag string `json:"latestTag"`
Health string `json:"health"`
Phase string `json:"phase"`
Cluster string `json:"cluster"`
Namespace string `json:"namespace"`
Endpoints []string `json:"endpoints"`
Drift json.RawMessage `json:"drift"`
}
// AppsList is the /v1/apps envelope: ordered rows + a drift summary.
// AppsList is the /v1/paas/apps envelope: ordered rows + a drift summary.
type AppsList struct {
Apps []AppView `json:"apps"`
Summary struct {
@@ -152,9 +156,10 @@ type AppsList struct {
} `json:"summary"`
}
// AppsQuery are the optional /v1/apps filters.
// AppsQuery are the optional /v1/paas/apps filters (server-honored). Env/Health/
// Drift narrow the board; there is deliberately no org filter — the board is
// confined to the caller's org by the validated identity, never a client value.
type AppsQuery struct {
Org string
Env string
Health string
Drift bool
@@ -162,9 +167,6 @@ type AppsQuery struct {
func (p *Platform) Apps(ctx context.Context, q AppsQuery) (*AppsList, error) {
v := url.Values{}
if q.Org != "" {
v.Set("org", q.Org)
}
if q.Env != "" {
v.Set("env", q.Env)
}
@@ -174,7 +176,7 @@ func (p *Platform) Apps(ctx context.Context, q AppsQuery) (*AppsList, error) {
if q.Drift {
v.Set("drift", "1")
}
path := "/v1/apps"
path := "/v1/paas/apps"
if len(v) > 0 {
path += "?" + v.Encode()
}
@@ -182,17 +184,11 @@ func (p *Platform) Apps(ctx context.Context, q AppsQuery) (*AppsList, error) {
return out, p.do(ctx, http.MethodGet, path, p.token, nil, out)
}
func (p *Platform) App(ctx context.Context, id, org string) (*AppView, error) {
path := "/v1/apps/" + id
if org != "" {
path += "?org=" + url.QueryEscape(org)
}
// App gets one app row by its <app> CR name (production by default; the server
// scans the caller's authorized namespaces main→test→dev).
func (p *Platform) App(ctx context.Context, app string) (*AppView, error) {
out := &AppView{}
return out, p.do(ctx, http.MethodGet, path, p.token, nil, out)
}
func (p *Platform) SyncApps(ctx context.Context) error {
return p.do(ctx, http.MethodPost, "/v1/apps/sync", p.token, nil, nil)
return out, p.do(ctx, http.MethodGet, "/v1/paas/apps/"+url.PathEscape(app), p.token, nil, out)
}
// driftSeverity extracts the severity string from the raw drift object.
@@ -207,110 +203,92 @@ func driftSeverity(raw json.RawMessage) string {
}
// ---------------------------------------------------------------------------
// Dedicated clusters — /v1/org/{org}/cluster[ /select | /{id}/install-baseline ].
// Clusters — GET /v1/clusters. The live Go cloud's compute fleet (clients/visor):
// Visor-managed node pools + the org's BYO clusters, tenant-scoped server-side by
// the validated org (?owner is the caller's IAM org). No org in the path.
// ---------------------------------------------------------------------------
// Cluster mirrors a doks_cluster record. `status` is DigitalOcean state; `phase`
// is the platform provisioning lifecycle — orthogonal (a DO-running cluster is
// not a usable target until phase=ready).
// NodePool mirrors clients/visor.nodePoolView.
type NodePool struct {
PoolID string `json:"poolId"`
Name string `json:"name"`
Size string `json:"size"`
Count int `json:"count"`
MinNodes int `json:"minNodes"`
MaxNodes int `json:"maxNodes"`
AutoScale bool `json:"autoScale"`
}
// Cluster mirrors clients/visor.clusterView — the LIVE cluster DTO. `kind` is
// "managed" (Visor-provisioned) or "byo" (attached kubeconfig).
type Cluster struct {
DoksClusterID string `json:"doksClusterId"`
Name string `json:"name"`
DoClusterID *string `json:"doClusterId"`
Region string `json:"region"`
Status string `json:"status"`
Endpoint *string `json:"endpoint"`
K8sVersion *string `json:"k8sVersion"`
HA bool `json:"ha"`
Phase string `json:"phase"`
OperatorInstalled bool `json:"operatorInstalled"`
BaselineInstalled bool `json:"baselineInstalled"`
Active bool `json:"active"`
BaselineError *string `json:"baselineError"`
OrganizationID string `json:"organizationId"`
CreatedAt string `json:"createdAt"`
Tags []string `json:"tags"`
MaintenancePolicy json.RawMessage `json:"maintenancePolicy,omitempty"`
DoksClusterID string `json:"doksClusterId"`
DoClusterID string `json:"doClusterId"`
Name string `json:"name"`
Region string `json:"region"`
Status string `json:"status"`
NodePools []NodePool `json:"nodePools"`
NodeSize string `json:"nodeSize"`
NodeCount int `json:"nodeCount"`
CreatedAt string `json:"createdAt"`
Kind string `json:"kind"`
NvidiaGPU int `json:"nvidiaGpu"`
AmdGPU int `json:"amdGpu"`
}
// ProvisionReq is the dedicated-cluster provisioning body (org forced by path).
type ProvisionReq struct {
Region string `json:"region,omitempty"`
HA bool `json:"ha,omitempty"`
NodeSize string `json:"nodeSize,omitempty"`
NodeCount int `json:"nodeCount,omitempty"`
// ID is the stable cluster identifier for display/lookup: the DOKS id when managed,
// else the name (a BYO cluster keys on its attached name).
func (c Cluster) ID() string {
if c.DoksClusterID != "" {
return c.DoksClusterID
}
return c.Name
}
// Target is the redacted ClusterTargetView — the kubeconfig is never present.
type Target struct {
Cluster string `json:"cluster"`
Namespaces map[string]string `json:"namespaces"`
Dedicated bool `json:"dedicated"`
}
func (p *Platform) Clusters(ctx context.Context, org string) ([]Cluster, error) {
func (p *Platform) Clusters(ctx context.Context) ([]Cluster, error) {
var out struct {
Clusters []Cluster `json:"clusters"`
}
err := p.do(ctx, http.MethodGet, "/v1/org/"+url.PathEscape(org)+"/cluster", p.token, nil, &out)
err := p.do(ctx, http.MethodGet, "/v1/clusters", p.token, nil, &out)
return out.Clusters, err
}
func (p *Platform) ProvisionCluster(ctx context.Context, org string, req ProvisionReq) (*Cluster, error) {
var out struct {
Cluster Cluster `json:"cluster"`
}
err := p.do(ctx, http.MethodPost, "/v1/org/"+url.PathEscape(org)+"/cluster", p.token, req, &out)
return &out.Cluster, err
}
func (p *Platform) Target(ctx context.Context, org string) (*Target, error) {
var out struct {
Target Target `json:"target"`
}
err := p.do(ctx, http.MethodGet, "/v1/org/"+url.PathEscape(org)+"/cluster/select", p.token, nil, &out)
return &out.Target, err
}
// SelectTarget activates a dedicated cluster as the org's deploy target, or
// reverts to the shared cluster when clusterID is nil.
func (p *Platform) SelectTarget(ctx context.Context, org string, clusterID *string) (*Target, error) {
var out struct {
Target Target `json:"target"`
}
body := map[string]any{"doksClusterId": clusterID}
err := p.do(ctx, http.MethodPost, "/v1/org/"+url.PathEscape(org)+"/cluster/select", p.token, body, &out)
return &out.Target, err
}
func (p *Platform) InstallBaseline(ctx context.Context, org, clusterID string) error {
path := "/v1/org/" + url.PathEscape(org) + "/cluster/" + url.PathEscape(clusterID) + "/install-baseline"
return p.do(ctx, http.MethodPost, path, p.token, nil, nil)
}
// ---------------------------------------------------------------------------
// Deploy — POST …/container/{id}/redeploy (rolling restart, zero-downtime).
// Deploy — POST /v1/paas/apps/{app}/deploy: a zero-downtime ROLLING RESTART of the
// app's Deployment (re-pulls the declared image, recreates pods). Org-confined
// server-side; an optional env selects the lifecycle namespace (main|test|dev).
// ---------------------------------------------------------------------------
// Redeploy triggers a rolling restart of the container's k8s Deployment. The
// coordinates are exact (the platform validates org+project+env+container scope).
func (p *Platform) Redeploy(ctx context.Context, org, project, env, container string) error {
path := fmt.Sprintf("/v1/org/%s/project/%s/env/%s/container/%s/redeploy",
url.PathEscape(org), url.PathEscape(project), url.PathEscape(env), url.PathEscape(container))
var out struct {
OK bool `json:"ok"`
// Redeploy triggers a rolling restart of the named app. env is optional
// (main|test|dev); empty targets production (the first match, main→test→dev).
func (p *Platform) Redeploy(ctx context.Context, app, env string) (*DeployResult, error) {
path := "/v1/paas/apps/" + url.PathEscape(app) + "/deploy"
if env != "" {
path += "?env=" + url.QueryEscape(env)
}
if err := p.do(ctx, http.MethodPost, path, p.token, nil, &out); err != nil {
return err
out := &DeployResult{}
if err := p.do(ctx, http.MethodPost, path, p.token, nil, out); err != nil {
return nil, err
}
if !out.OK {
return fmt.Errorf("redeploy did not report ok")
return nil, fmt.Errorf("redeploy did not report ok")
}
return nil
return out, nil
}
// DeployResult is the /deploy acceptance (202): the restarted app + its namespace.
type DeployResult struct {
OK bool `json:"ok"`
App string `json:"app"`
Namespace string `json:"namespace"`
Env string `json:"env"`
RestartedAt string `json:"restartedAt"`
}
// ---------------------------------------------------------------------------
// Build — POST /v1/runner (platform-native CI, no GitHub builders).
// Build — POST /v1/runner (platform-native CI, no GitHub builders). Authorized off
// the IAM login exactly like the surfaces above (or a dedicated build token for
// machine automation). Unchanged wire contract.
// ---------------------------------------------------------------------------
// BuildReq is the direct-enqueue body. Repo/SHA/Image are required.
@@ -337,11 +315,11 @@ type BuildJob struct {
Target string `json:"target"`
}
// EnqueueBuild enqueues a native build. It authenticates with the dedicated
// build-callback token, not the service token.
// EnqueueBuild enqueues a native build. buildToken is resolved by the caller (IAM
// login is the final fallback; a dedicated build token wins when present).
func (p *Platform) EnqueueBuild(ctx context.Context, req BuildReq, buildToken string) (*BuildJob, error) {
if buildToken == "" {
return nil, fmt.Errorf("no build token: set HANZO_BUILD_TOKEN / PLATFORM_BUILD_CALLBACK_TOKEN or `hanzo login --build-token <tok>`")
return nil, fmt.Errorf("not authenticated: run `hanzo login` (an IAM login now authorizes builds; HANZO_BUILD_TOKEN / --build-token still works for machine automation)")
}
out := &BuildJob{}
return out, p.do(ctx, http.MethodPost, "/v1/runner", buildToken, req, out)
+47 -91
View File
@@ -18,17 +18,22 @@ func platformStub(t *testing.T, token string, h http.HandlerFunc) (*Platform, fu
return newPlatform(srv.URL, token), srv.Close
}
// Apps hits the LIVE board /v1/paas/apps with the IAM bearer; it sends NO org
// filter (the board is org-confined server-side by the validated identity).
func TestPlatformAuthHeaderAndApps(t *testing.T) {
p, done := platformStub(t, "svc-tok", func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer svc-tok" {
t.Errorf("auth header = %q", got)
}
if r.URL.Path != "/v1/apps" {
t.Errorf("path = %s", r.URL.Path)
if r.URL.Path != "/v1/paas/apps" {
t.Errorf("path = %s, want /v1/paas/apps", r.URL.Path)
}
if r.URL.Query().Get("env") != "main" || r.URL.Query().Get("drift") != "1" {
t.Errorf("query = %s", r.URL.RawQuery)
}
if r.URL.Query().Has("org") {
t.Errorf("client must NOT send an org filter (identity confines the board): %s", r.URL.RawQuery)
}
_ = json.NewEncoder(w).Encode(AppsList{
Apps: []AppView{{ID: "hanzoai/iam/main", Org: "hanzoai", App: "iam", Env: "main", Drift: json.RawMessage(`{"severity":"red"}`)}},
})
@@ -47,126 +52,75 @@ func TestPlatformAuthHeaderAndApps(t *testing.T) {
}
}
// App hits /v1/paas/apps/{app}; no org query (identity scopes it).
func TestPlatformApp(t *testing.T) {
p, done := platformStub(t, "t", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/apps/hanzoai/iam/main" {
t.Errorf("path = %s", r.URL.Path)
if r.URL.Path != "/v1/paas/apps/iam" {
t.Errorf("path = %s, want /v1/paas/apps/iam", r.URL.Path)
}
if r.URL.Query().Get("org") != "hanzoai" {
t.Errorf("org query = %s", r.URL.RawQuery)
if r.URL.RawQuery != "" {
t.Errorf("app get must carry no query, got %s", r.URL.RawQuery)
}
_ = json.NewEncoder(w).Encode(AppView{ID: "hanzoai/iam/main", App: "iam"})
_ = json.NewEncoder(w).Encode(AppView{ID: "hanzoai/iam/main", App: "iam", Phase: "Running"})
})
defer done()
a, err := p.App(context.Background(), "hanzoai/iam/main", "hanzoai")
a, err := p.App(context.Background(), "iam")
if err != nil || a.App != "iam" {
t.Fatalf("App: %v %+v", err, a)
}
}
func TestPlatformSyncApps(t *testing.T) {
// Clusters hits the LIVE /v1/clusters (org from identity, not the path).
func TestPlatformClusters(t *testing.T) {
p, done := platformStub(t, "t", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/v1/apps/sync" {
t.Errorf("sync = %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(200)
})
defer done()
if err := p.SyncApps(context.Background()); err != nil {
t.Fatalf("SyncApps: %v", err)
}
}
func TestPlatformClustersAndProvision(t *testing.T) {
p, done := platformStub(t, "t", func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/v1/org/acme/cluster":
_ = json.NewEncoder(w).Encode(map[string]any{"clusters": []Cluster{{DoksClusterID: "c1", Name: "hanzo-acme", Region: "sfo3", Status: "running", Phase: "ready", Active: true}}})
case r.Method == http.MethodPost && r.URL.Path == "/v1/org/acme/cluster":
body, _ := io.ReadAll(r.Body)
var req ProvisionReq
_ = json.Unmarshal(body, &req)
if req.Region != "sfo3" || !req.HA {
t.Errorf("provision body = %+v", req)
}
w.WriteHeader(201)
_ = json.NewEncoder(w).Encode(map[string]any{"cluster": Cluster{DoksClusterID: "c2", Name: "new", Phase: "requested"}})
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
if r.Method != http.MethodGet || r.URL.Path != "/v1/clusters" {
t.Errorf("clusters = %s %s, want GET /v1/clusters", r.Method, r.URL.Path)
}
_ = json.NewEncoder(w).Encode(map[string]any{"clusters": []Cluster{
{DoksClusterID: "c1", Name: "hanzo-acme", Region: "sfo3", Status: "running", Kind: "managed", NodeCount: 3},
}})
})
defer done()
cs, err := p.Clusters(context.Background(), "acme")
if err != nil || len(cs) != 1 || cs[0].DoksClusterID != "c1" {
cs, err := p.Clusters(context.Background())
if err != nil || len(cs) != 1 || cs[0].ID() != "c1" || cs[0].Kind != "managed" {
t.Fatalf("Clusters: %v %+v", err, cs)
}
c, err := p.ProvisionCluster(context.Background(), "acme", ProvisionReq{Region: "sfo3", HA: true})
if err != nil || c.DoksClusterID != "c2" {
t.Fatalf("ProvisionCluster: %v %+v", err, c)
}
}
func TestPlatformTargetAndSelect(t *testing.T) {
p, done := platformStub(t, "t", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/org/acme/cluster/select" {
t.Errorf("path = %s", r.URL.Path)
}
if r.Method == http.MethodPost {
body, _ := io.ReadAll(r.Body)
var m map[string]any
_ = json.Unmarshal(body, &m)
if m["doksClusterId"] != "c1" {
t.Errorf("select body = %v", m)
}
}
_ = json.NewEncoder(w).Encode(map[string]any{"target": Target{Cluster: "hanzo-acme", Dedicated: true, Namespaces: map[string]string{"acme": "main"}}})
})
defer done()
tg, err := p.Target(context.Background(), "acme")
if err != nil || tg.Cluster != "hanzo-acme" || !tg.Dedicated {
t.Fatalf("Target: %v %+v", err, tg)
}
id := "c1"
if _, err := p.SelectTarget(context.Background(), "acme", &id); err != nil {
t.Fatalf("SelectTarget: %v", err)
}
}
func TestPlatformInstallBaseline(t *testing.T) {
p, done := platformStub(t, "t", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/v1/org/acme/cluster/c1/install-baseline" {
t.Errorf("install-baseline = %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(200)
})
defer done()
if err := p.InstallBaseline(context.Background(), "acme", "c1"); err != nil {
t.Fatalf("InstallBaseline: %v", err)
// A BYO cluster with no DOKS id keys on its name via ID().
func TestClusterIDFallsBackToName(t *testing.T) {
c := Cluster{Name: "byo-1", Kind: "byo"}
if c.ID() != "byo-1" {
t.Fatalf("ID() = %q, want byo-1", c.ID())
}
}
// Redeploy hits /v1/paas/apps/{app}/deploy (rolling restart), org from identity.
func TestPlatformRedeploy(t *testing.T) {
p, done := platformStub(t, "t", func(w http.ResponseWriter, r *http.Request) {
want := "/v1/org/acme/project/p1/env/e1/container/app-x/redeploy"
if r.Method != http.MethodPost || r.URL.Path != want {
t.Errorf("redeploy path = %s %s", r.Method, r.URL.Path)
if r.Method != http.MethodPost || r.URL.Path != "/v1/paas/apps/app-x/deploy" {
t.Errorf("redeploy = %s %s, want POST /v1/paas/apps/app-x/deploy", r.Method, r.URL.Path)
}
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true})
if r.URL.Query().Get("env") != "test" {
t.Errorf("env query = %s", r.URL.RawQuery)
}
w.WriteHeader(202)
_ = json.NewEncoder(w).Encode(DeployResult{OK: true, App: "app-x", Namespace: "hanzo-testnet", Env: "test", RestartedAt: "2026-07-18T00:00:00Z"})
})
defer done()
if err := p.Redeploy(context.Background(), "acme", "p1", "e1", "app-x"); err != nil {
t.Fatalf("Redeploy: %v", err)
res, err := p.Redeploy(context.Background(), "app-x", "test")
if err != nil || res.Namespace != "hanzo-testnet" {
t.Fatalf("Redeploy: %v %+v", err, res)
}
}
func TestPlatformRedeployNotOK(t *testing.T) {
p, done := platformStub(t, "t", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": false})
_ = json.NewEncoder(w).Encode(DeployResult{OK: false})
})
defer done()
if err := p.Redeploy(context.Background(), "o", "p", "e", "c"); err == nil {
if _, err := p.Redeploy(context.Background(), "c", ""); err == nil {
t.Fatalf("expected error when ok=false")
}
}
@@ -207,14 +161,16 @@ func TestPlatformError401Hint(t *testing.T) {
})
defer done()
_, err := p.Apps(context.Background(), AppsQuery{})
if err == nil || !strings.Contains(err.Error(), "HTTP 401") || !strings.Contains(err.Error(), "platform service token") {
t.Fatalf("401 error should carry a token hint, got %v", err)
if err == nil || !strings.Contains(err.Error(), "HTTP 401") || !strings.Contains(err.Error(), "hanzo login") {
t.Fatalf("401 error should point at `hanzo login`, got %v", err)
}
}
func TestPlatformNoTokenError(t *testing.T) {
p := newPlatform("https://platform.hanzo.ai", "")
if _, err := p.Apps(context.Background(), AppsQuery{}); err == nil || !strings.Contains(err.Error(), "no platform token") {
t.Fatalf("expected no-token error, got %v", err)
// After unify-infra, the "no credential" error points at `hanzo login` — the one
// identity that authorizes the platform — not a separate platform token.
if _, err := p.Apps(context.Background(), AppsQuery{}); err == nil || !strings.Contains(err.Error(), "hanzo login") {
t.Fatalf("expected a `hanzo login` hint, got %v", err)
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ package cli
// org for queued workflow jobs and spawns ephemeral, auto-exiting actions-runner
// subprocesses tagged with this box's labels (GPU / vulkan aware). Outbound only —
// nothing listens for inbound. Completes the trifecta: `engine` serves models,
// `gpu connect` shares compute, `runner` claims CI — one binary, one org login.
// `link` shares compute, `runner` claims CI — one binary, one org login.
import (
"os/signal"
+86 -10
View File
@@ -1,5 +1,5 @@
// studio.go — local Hanzo Studio render-backend supervision for `hanzo gpu
// connect --studio-dir <checkout>`. The gpu-jobs claim loop renders on the
// studio.go — local Hanzo Studio render-backend supervision for `hanzo link
// --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.
@@ -12,6 +12,7 @@ package cli
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
@@ -20,6 +21,7 @@ import (
"os/exec"
"path/filepath"
"strconv"
"sync/atomic"
"syscall"
"time"
)
@@ -65,9 +67,22 @@ func launchStudio(dir string) (*exec.Cmd, error) {
if err != nil {
return nil, err
}
// --listen 127.0.0.1 (loopback only) + --worker-mode: the render backend serves
// the fleet worker on this box and nothing else. The worker dials loopback
// (localComfyUI) so binding wider bought nothing but an open, unauthenticated
// /prompt — the hidden-run hole. --worker-mode makes the studio gate its submit
// seam (/v1/worker/execute + X-Worker-Token) so only the worker can start a render.
// VRAM mode: default --normalvram (safe for smaller BYO GPUs); override with
// HANZO_STUDIO_VRAM (e.g. "--highvram") on big-memory boxes (GB10 128G unified) so
// the Qwen text-encoder stays resident on-GPU instead of non-deterministically
// offloading to CPU — offload makes renders CPU-bound and ~8x slower.
vramMode := os.Getenv("HANZO_STUDIO_VRAM")
if vramMode == "" {
vramMode = "--normalvram"
}
cmd := exec.Command(studioPython(dir), "main.py",
"--listen", "0.0.0.0", "--port", "8188",
"--normalvram", "--disable-auto-launch",
"--listen", "127.0.0.1", "--port", "8188", "--worker-mode",
vramMode, "--disable-auto-launch",
"--output-directory", filepath.Join(dir, "output"))
cmd.Dir = dir
cmd.Env = append(os.Environ(),
@@ -107,6 +122,48 @@ func stopStudio(cmd *exec.Cmd) {
}
}
// studioRecycle carries at most one pending recycle request; the render
// handler signals it after each completed render (see gpu.go).
var studioRecycle = make(chan struct{}, 1)
// staging guards the claim-to-submit window: a claimed job is real work the
// engine queue cannot see yet, so the supervisor must never recycle over it
// (observed: jobs claimed during a recycle failed staging on a dead engine
// and were consumed).
var staging atomic.Int32
func requestStudioRecycle() {
select {
case studioRecycle <- struct{}{}:
default:
}
}
// studioBusy reports whether the engine holds queued or running prompts.
// A generous timeout: a saturated GB10 answers slowly mid-render — slow is
// alive, and killing a live render costs 8-70 minutes of GPU work.
func studioBusy(ctx context.Context) (busy, ok bool) {
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+studioAddr+"/queue", nil)
if err != nil {
return false, false
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return false, false
}
defer resp.Body.Close()
var q struct {
Running []json.RawMessage `json:"queue_running"`
Pending []json.RawMessage `json:"queue_pending"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 32<<20)).Decode(&q); err != nil {
return false, false
}
return len(q.Running)+len(q.Pending) > 0, true
}
// 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) {
@@ -138,6 +195,12 @@ func superviseStudio(ctx context.Context, dir string, out io.Writer) {
tick := time.NewTicker(studioProbeEvery)
defer tick.Stop()
// recyclePending defers the post-render recycle until the queue is EMPTY:
// short jobs complete while a long render is mid-sample, and recycling on
// their completion killed the live render (observed: every direct render
// died within ~6 minutes while probe jobs cycled).
recyclePending := false
unhealthy := 0
for {
select {
case <-ctx.Done():
@@ -145,19 +208,32 @@ func superviseStudio(ctx context.Context, dir string, out io.Writer) {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM)
}
return
case <-studioRecycle:
recyclePending = true
case <-tick.C:
busy, ok := studioBusy(ctx)
if recyclePending && ok && !busy && staging.Load() == 0 {
recyclePending = false
unhealthy = 0
restart("recycle")
continue
}
if studioHealthy(ctx) {
unhealthy = 0
continue
}
// Grace re-check: it may be momentarily busy mid-render.
select {
case <-ctx.Done():
if ok && busy {
// Alive-busy: slow health under render load is not death.
unhealthy = 0
continue
case <-time.After(studioGraceWait):
}
if !studioHealthy(ctx) {
restart("unresponsive")
// Sustained silence with an idle or unreadable queue = actually dead.
unhealthy++
if unhealthy < 3 {
continue
}
unhealthy = 0
restart("unresponsive")
}
}
}
+48 -7
View File
@@ -50,6 +50,7 @@
package account
import (
"context"
"encoding/base64"
"errors"
"fmt"
@@ -367,18 +368,58 @@ func onboard(s *cloud.Service[state], c *zip.Ctx) error {
return herr
}
// Create the org (cloning the caller's current org for password/locale
// compatibility), then — first-run only — move the zero-org user in as admin.
// ADDITIONAL org (caller already has a home): create it WITHOUT moving them —
// they reach it via the OrgSwitcher (a move would strip their SuperAdmin / orphan
// their current org).
if additional {
org := buildOrg(s, c, slug, displayName, body.Personal, cr.owner)
if err := s.State.iam.createOrganization(c.Context(), org); err != nil {
return zip.Errorf(http.StatusBadGateway, "could not create the organization: %v", err)
}
return c.JSON(http.StatusOK, onboardResp{Org: slug, DisplayName: displayName, Additional: true})
}
// FIRST-RUN: drive the ONE atomic IAM provision (org + admin move + hashed
// org-scoped credential), replacing the create-org + move-user pair — a mid-flight
// retry now converges on the founder's own org instead of orphaning it. The org
// starts at a zero balance (usage is pre-paid). Prefer it whenever the
// service-token path is wired; fall back to the legacy pair only when it is not,
// so a partial deploy still onboards.
if s.State.iam.provisionReady() {
resp, err := onboardFirstRun(c.Context(), s.State.iam, cr.id, slug, displayName, body.Personal)
if err != nil {
return err
}
return c.JSON(http.StatusOK, resp)
}
// Legacy fallback (service token unset): create then move — the non-atomic pair.
org := buildOrg(s, c, slug, displayName, body.Personal, cr.owner)
if err := s.State.iam.createOrganization(c.Context(), org); err != nil {
return zip.Errorf(http.StatusBadGateway, "could not create the organization: %v", err)
}
if !additional {
if err := s.State.iam.moveUserToOrg(c.Context(), cr.id, slug); err != nil {
return zip.Errorf(http.StatusBadGateway, "org created but could not assign you to it: %v", err)
}
if err := s.State.iam.moveUserToOrg(c.Context(), cr.id, slug); err != nil {
return zip.Errorf(http.StatusBadGateway, "org created but could not assign you to it: %v", err)
}
return c.JSON(http.StatusOK, onboardResp{Org: slug, DisplayName: displayName, Additional: additional})
return c.JSON(http.StatusOK, onboardResp{Org: slug, DisplayName: displayName, Additional: false})
}
// onboardFirstRun drives the ONE atomic IAM provision for a zero-org caller (create
// org + move them in as admin + mint the hashed org-scoped credential), replacing
// the create-org + move-user pair so a mid-flight retry converges on the founder's
// own org instead of orphaning it. The org starts at a ZERO balance — usage is
// pre-paid, so there is no signup grant. Split out so the provisioning glue is
// unit-tested against mock IAM without the CSRF/routing/principal shell.
func onboardFirstRun(ctx context.Context, iam *iamClient, callerID, slug, displayName string, personal bool) (onboardResp, error) {
row, err := iam.getUserRow(ctx, callerID)
if err != nil {
return onboardResp{}, zip.Errorf(http.StatusBadGateway, "could not resolve the user: %v", err)
}
res, err := iam.provision(ctx, row.Owner, row.Name, slug, personal)
if err != nil {
return onboardResp{}, zip.Errorf(http.StatusBadGateway, "could not provision the organization: %v", err)
}
return onboardResp{Org: res.Org, DisplayName: displayName, Additional: false}, nil
}
// resolveOnboardName derives the base slug + display name from the request, or a
+67 -11
View File
@@ -30,6 +30,7 @@ package account
import (
"bytes"
"crypto/subtle"
"encoding/json"
"net/http"
"net/url"
@@ -39,6 +40,7 @@ import (
"github.com/hanzoai/account"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/principal"
"github.com/zap-proto/zip"
)
@@ -107,6 +109,7 @@ var billingForwardable = map[string][]string{
"subscriptions",
"payment-methods",
"spend-alerts",
"spend-alerts/authorize", // the S2S cap-verdict read (metering gate); 2 segments need their own entry
"payment-config",
"plans",
"payouts",
@@ -241,9 +244,26 @@ func commerceCreds() (base, token string) {
func billingData(s *cloud.Service[state], c *zip.Ctx) error {
// IDOR boundary: the subject is the VALIDATED caller's own org/user, never a client
// value. requireOwner=true — billing is always org-scoped (a zero-org user has none).
// Auth. A browser caller is the VALIDATED principal (customer path — subject-pinned
// below). An IN-PROC S2S caller carries the verified COMMERCE_SERVICE_TOKEN (the
// metering cap-gate's authorize + the SuperAdmin cap-oversight Forward). The gateway
// 401s a public Bearer that is not an IAM JWT / hk-|pk-|sk- key (the 64-hex service
// token fails JWT parse at the edge), so an EXTERNAL client can NEVER present it here —
// an unauthenticated caller still hits the 403 below. On the S2S path the caller
// legitimately names its own subject, so its query is forwarded as-is (no pin), scoped
// only by the EdgeAuth-controlled X-Org-Id.
cr, ok := resolveCaller(c, true)
s2s := false
owner := cr.owner
if !ok {
return zip.ErrForbidden("sign in to view billing")
if !s2sBillingCall(c) {
return zip.ErrForbidden("sign in to view billing")
}
owner = strings.TrimSpace(c.Org()) // trusted X-Org-Id (never a client value on a public call)
if owner == "" {
return zip.ErrForbidden("sign in to view billing")
}
s2s = true
}
method := c.Method()
@@ -277,20 +297,32 @@ func billingData(s *cloud.Service[state], c *zip.Ctx) error {
// 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. 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()
// subject comes from the ONE rule (ai/object.Payer), fed the account the
// credential NAMES (the validated `billing_account` claim) — the same claim the
// ai gate reads, so a top-up credits the SAME account the gate debits. Feeding
// Payer a different credential here than the gate gets is the modern shape of
// the old split: money landing in an account the gate never reads.
inQuery, _ := url.ParseQuery(string(c.Fiber().Request().URI().QueryString()))
q := scopedBillingSearch(inQuery, subject)
var q url.Values
var body []byte
if method == http.MethodPost {
body = scopedBillingBody(c.Body(), subject)
if s2s {
// Trusted S2S caller: forward its query/body VERBATIM — it legitimately names the
// subject (e.g. the metering gate's ?user=<org>&amount=). Scoped by X-Org-Id.
q = inQuery
if method == http.MethodPost {
body = c.Body()
}
} else {
// Browser customer: pin EVERY subject key to the caller's OWN account so commerce's
// per-tenant isolation can never be crossed from the client.
subject := account.Payer(account.Credential{Owner: cr.owner, Name: cr.username, Account: principal.BillingAccount(c)}).Subject()
q = scopedBillingSearch(inQuery, subject)
if method == http.MethodPost {
body = scopedBillingBody(c.Body(), subject)
}
}
raw, status, err := commerceDo(c.Context(), base, token, method, "/v1/billing/"+sub, q, cr.owner, body)
raw, status, err := commerceDo(c.Context(), base, token, method, "/v1/billing/"+sub, q, owner, body)
if err != nil {
return zip.Errorf(http.StatusBadGateway, "billing upstream unreachable: %v", err)
}
@@ -300,3 +332,27 @@ func billingData(s *cloud.Service[state], c *zip.Ctx) error {
c.SetHeader("Cache-Control", "no-store, must-revalidate")
return c.Bytes(status, raw)
}
// s2sBillingCall reports whether the request carries the verified COMMERCE_SERVICE_TOKEN
// as its Bearer — a trusted IN-PROC service-to-service caller (the metering cap-gate's
// authorize, the SuperAdmin cap-oversight Forward). It is the SAME secret this bridge
// already forwards WITH, so admitting a caller who already holds it grants no authority it
// could not otherwise wield. Safety rests on the edge: the gateway 401s a public Bearer
// that is not an IAM JWT / hk-|pk-|sk- API key (the 64-hex service token is a JWT
// candidate that fails to parse), so an EXTERNAL client can never reach this handler
// holding it — only in-proc commerceinproc dispatch does. Constant-time compare; the token
// is never logged.
func s2sBillingCall(c *zip.Ctx) bool {
_, token := commerceCreds()
if token == "" {
return false
}
bearer := strings.TrimSpace(strings.TrimPrefix(c.Header("Authorization"), "Bearer "))
return bearer != "" && subtle.ConstantTimeCompare([]byte(bearer), []byte(token)) == 1
}
// IsServiceToken is the exported view of s2sBillingCall — whether the request is a trusted
// in-proc S2S caller bearing the verified COMMERCE_SERVICE_TOKEN. Used by co-resident route
// gates (e.g. the spend-alert admin gate) that must admit the metering cap-gate and the
// SuperAdmin cap-oversight Forward alongside org admins, while refusing a plain member.
func IsServiceToken(c *zip.Ctx) bool { return s2sBillingCall(c) }
+89
View File
@@ -0,0 +1,89 @@
// billing_coresident.go — PinBillingSubject, the subject-pinning middleware that lets
// commerce's OWN billing READ handlers serve co-resident in the unified cloud binary.
//
// WHY IT EXISTS. Co-resident, commerce is EMBEDDED (apps/commerce.go mountCommerce →
// commerce.Embed on the shared zip app), and in prod there is NO standalone commerce
// backend — the in-cluster `commerce` Service selects the cloud pods themselves. So the
// /v1/billing/* bridge (billing.go), which forwards to COMMERCE_URL, has nowhere to send
// a read but back into cloud: the default base (the public api.hanzo.ai edge) re-enters
// the same bridge in an unbounded self-dispatch loop that surfaces as a 502
// ("billing upstream unreachable: Get https://api.hanzo.ai/v1/billing/<path>"). The fix
// is to serve those reads co-resident from the embedded commerce — the same co-resident
// move mountCommerce already makes for GET /v1/billing/plans — so the specific route
// shadows the bridge wildcard (order 100 < 122) and never leaves the process.
//
// WHAT IT GUARANTEES. commerce's read handlers scope to the org by NAMESPACE (from the
// gateway-validated X-Org-Id via iammiddleware) but filter finer scope (the billing
// subject) only from a query param — an UNPINNED ListInvoices returns every user's rows
// in the org namespace. The /v1/billing/* bridge is what pins that subject today; this
// middleware carries the SAME pin onto the co-resident route so the isolation is
// byte-for-byte the shipped behavior. It is the ONE subject rule (account.Payer, the same
// function the ai spend-gate and the top-up resolve), fed the account the credential NAMES
// — so a read scopes to exactly the account the gate debits, never wider.
package account
import (
"net/url"
"github.com/hanzoai/account"
"github.com/hanzoai/cloud/clients/principal"
"github.com/zap-proto/zip"
)
// PinBillingSubject pins every billing subject key in the request query to the VALIDATED
// caller's own subject (dropping ?org), so a co-resident commerce read handler downstream
// can only ever return the caller's OWN rows. It is the co-resident twin of billingData's
// subject-pinning, reusing the SAME resolveCaller → account.Payer rule and the SAME
// scopedBillingSearch, so the two paths scope identically.
//
// Three cases, mirroring billingData exactly:
// - Browser customer (a validated principal with an org): OVERWRITE the subject keys
// with the caller's own subject and drop ?org. The client cannot widen scope.
// - Trusted in-proc S2S (the verified COMMERCE_SERVICE_TOKEN bearer, carrying its own
// X-Org-Id): pass the query through VERBATIM — it legitimately names its own subject,
// scoped by the EdgeAuth-controlled org.
// - Neither: refuse. A bearer-less request with a forged X-Org-Id has no validated
// principal and is fail-closed here, before the read handler runs.
//
// The pin rewrites the request URI's query string AND (on a write) the JSON body in place;
// fasthttp's SetQueryString resets the parsed-args cache and SetBody replaces the body
// bytes, so the handler's later c.Query() / c.Bind() read the pinned values. Pinning the
// body is what keeps a co-resident WRITE handler that reads its subject from the body
// (commerce's CreatePaymentMethod reads customerId from the JSON body) IDOR-safe — query-only
// pinning would leave a client-named customerId/userId in the body untouched.
func PinBillingSubject() zip.Handler {
return func(c *zip.Ctx) error {
inQuery, _ := url.ParseQuery(string(c.Fiber().Request().URI().QueryString()))
cr, ok := resolveCaller(c, true)
if !ok {
// Not a validated customer — admit ONLY a trusted in-proc S2S caller that
// names its own org (same admission billingData makes), leaving its query
// untouched. Everything else is refused before the read runs.
if s2sBillingCall(c) && c.Org() != "" {
return c.Next()
}
return zip.ErrForbidden("sign in to view billing")
}
subject := account.Payer(account.Credential{
Owner: cr.owner,
Name: cr.username,
Account: principal.BillingAccount(c),
}).Subject()
// Pin the subject on BOTH the query AND the write body — the SAME two-helper
// scoping billingData applies (scopedBillingSearch + scopedBillingBody). A
// co-resident WRITE handler that reads its subject from the body (commerce's
// CreatePaymentMethod → customerId) is only IDOR-safe if the body is pinned too.
// scopedBillingBody overwrites the subject keys and preserves every other field
// (card, type, sourceId, …); a non-JSON / empty body is returned unchanged, so a
// GET read carries no body and is unaffected.
c.Fiber().Request().URI().SetQueryString(scopedBillingSearch(inQuery, subject).Encode())
if len(c.Body()) > 0 {
c.Fiber().Request().SetBody(scopedBillingBody(c.Body(), subject))
}
return c.Next()
}
}
+150
View File
@@ -0,0 +1,150 @@
package account
import (
"encoding/json"
"net/http"
"testing"
"github.com/hanzoai/cloud"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// billing_coresident_test.go — proves PinBillingSubject carries the SAME tenant scoping
// onto a co-resident commerce read handler that billingData applies on the bridge: the
// caller's own subject is pinned into the query (dropping ?org), an unvalidated caller is
// refused before the handler runs, and a trusted in-proc S2S caller passes through
// verbatim. This is what lets commerce's ListInvoices/ListBillingSubscriptions/... serve
// in-process without leaking another subject's rows.
// echoQuery is the downstream stand-in for a commerce read handler: it reports exactly the
// query it observes AFTER the pin, so a test can assert the subject the handler would filter on.
func echoQuery(c *zip.Ctx) error {
return c.JSON(200, map[string]any{
"user": c.Query("user"),
"userId": c.Query("userId"),
"customerId": c.Query("customerId"),
"org": c.Query("org"),
"status": c.Query("status"),
})
}
// echoBody is the downstream stand-in for a commerce WRITE handler (e.g. CreatePaymentMethod)
// that reads its subject from the JSON BODY: it reports the body it observes AFTER the pin, so
// a test can assert the subject the handler would persist and that non-subject fields survive.
func echoBody(c *zip.Ctx) error {
var got map[string]any
if len(c.Body()) > 0 {
_ = json.Unmarshal(c.Body(), &got)
}
return c.JSON(200, got)
}
func pinApp(t *testing.T) *zip.App {
t.Helper()
app := zip.New(zip.Config{Logger: luxlog.New("test")})
// MountAccount installs the identity middleware PinBillingSubject relies on; mounting
// it keeps the probe on the same trust plane as the real co-resident registration.
if err := MountAccount(app, cloud.Deps{Logger: luxlog.New("test"), Brand: "hanzo"}); err != nil {
t.Fatalf("MountAccount: %v", err)
}
app.Get("/probe", PinBillingSubject(), echoQuery)
app.Post("/probe", PinBillingSubject(), echoBody)
return app
}
// TestPinBillingSubject_PinsCallerAndDropsOrg — a validated customer's forged subject
// keys are overwritten with its OWN subject and ?org is dropped, exactly like the bridge.
func TestPinBillingSubject_PinsCallerAndDropsOrg(t *testing.T) {
app := pinApp(t)
code, body := callH(t, app, http.MethodGet,
"/probe?userId=victim&customerId=victim&user=victim&org=othercorp&status=open", alice, "")
if code != http.StatusOK {
t.Fatalf("want 200, got %d (%s)", code, body)
}
var got map[string]string
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("bad body: %s", body)
}
for _, k := range billingSubjectKeys {
if got[k] != "acme" { // alice/acme resolves to the org subject "acme"
t.Fatalf("handler must see %s=acme (caller's own subject), got %q", k, got[k])
}
}
if got["org"] != "" {
t.Fatalf("org must be dropped, handler saw org=%q", got["org"])
}
if got["status"] != "open" {
t.Fatalf("non-subject filter must survive, got status=%q", got["status"])
}
}
// TestPinBillingSubject_PinsBodyForWrites — a POST whose JSON body names a FOREIGN subject
// (customerId/userId/user) has every subject key overwritten with the caller's OWN subject
// before the handler binds it, while non-subject fields survive. This is the boundary
// commerce's CreatePaymentMethod (which reads customerId from the body) relies on to be
// IDOR-safe co-resident — byte-identical to billingData's scopedBillingBody on the bridge.
func TestPinBillingSubject_PinsBodyForWrites(t *testing.T) {
app := pinApp(t)
code, body := callH(t, app, http.MethodPost, "/probe", alice,
`{"customerId":"victim","userId":"victim","user":"victim","card":{"last4":"4242"}}`)
if code != http.StatusOK {
t.Fatalf("want 200, got %d (%s)", code, body)
}
var got map[string]any
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("bad body: %s", body)
}
for _, k := range billingSubjectKeys {
if got[k] != "acme" { // alice/acme resolves to the org subject "acme"
t.Fatalf("handler must see body %s=acme (caller's own subject), got %v", k, got[k])
}
}
card, ok := got["card"].(map[string]any)
if !ok || card["last4"] != "4242" {
t.Fatalf("non-subject body field must survive the pin, got card=%v", got["card"])
}
}
// TestPinBillingSubject_RefusesUnvalidated — a forged X-Org-Id with NO validated
// X-User-Id (and no service token) is refused before the read handler runs: no
// cross-tenant billing read is possible.
func TestPinBillingSubject_RefusesUnvalidated(t *testing.T) {
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-tok")
app := pinApp(t)
code, _ := callH(t, app, http.MethodGet, "/probe?userId=victim",
map[string]string{"X-Org-Id": "victim"}, "")
if code != http.StatusForbidden {
t.Fatalf("unvalidated caller: want 403, got %d", code)
}
}
// TestPinBillingSubject_S2SForwardsVerbatim — the trusted in-proc S2S caller (verified
// COMMERCE_SERVICE_TOKEN + its own X-Org-Id, no validated user) is admitted and its query
// is left UNTOUCHED, so it can name its own subject (the cap-gate's authorize read).
func TestPinBillingSubject_S2SForwardsVerbatim(t *testing.T) {
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-tok")
app := pinApp(t)
code, body := callH(t, app, http.MethodGet, "/probe?user=acme&status=open",
map[string]string{"Authorization": "Bearer svc-tok", "X-Org-Id": "acme"}, "")
if code != http.StatusOK {
t.Fatalf("S2S: want 200, got %d (%s)", code, body)
}
var got map[string]string
_ = json.Unmarshal(body, &got)
if got["user"] != "acme" || got["status"] != "open" {
t.Fatalf("S2S query must pass through verbatim, got %+v", got)
}
}
// TestPinBillingSubject_S2SNoOrgRefused — the service token with NO X-Org-Id has no org
// to scope the privileged read and is refused (matches billingData's s2s org requirement).
func TestPinBillingSubject_S2SNoOrgRefused(t *testing.T) {
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-tok")
app := pinApp(t)
code, _ := callH(t, app, http.MethodGet, "/probe",
map[string]string{"Authorization": "Bearer svc-tok"}, "")
if code != http.StatusForbidden {
t.Fatalf("S2S without X-Org-Id: want 403, got %d", code)
}
}
+62
View File
@@ -232,3 +232,65 @@ func TestBilling_RejectsTraversalSegment(t *testing.T) {
t.Fatalf("a traversal must never reach commerce, but upstream saw %q", f.path)
}
}
// ── S2S service-token admission (the auth fix; 4 security invariants) ─────────
// Invariant #4 — THE SECURITY GATE: a public/unauthenticated caller (no validated
// principal AND not the service token) STILL gets 403 on the spend-alert routes, incl.
// a WRONG bearer. The fix must NEVER open billing to the world.
func TestBilling_S2S_PublicStill403(t *testing.T) {
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-tok")
app := mountApp(t, "http://iam.invalid", "", "")
for _, path := range []string{
"/v1/billing/spend-alerts",
"/v1/billing/spend-alerts/authorize?user=acme&amount=1",
} {
// forged X-Org-Id, no validated principal, no service token
if code, body := callH(t, app, http.MethodGet, path, map[string]string{"X-Org-Id": "victim"}, ""); code != http.StatusForbidden {
t.Fatalf("public caller to %s: want 403, got %d (%s)", path, code, body)
}
}
// a WRONG bearer is still just a public caller → 403
if code, _ := callH(t, app, http.MethodGet, "/v1/billing/spend-alerts/authorize?user=acme&amount=1",
map[string]string{"X-Org-Id": "acme", "Authorization": "Bearer not-the-token"}, ""); code != http.StatusForbidden {
t.Fatalf("wrong bearer: want 403")
}
}
// The trusted in-proc S2S caller (verified COMMERCE_SERVICE_TOKEN + X-Org-Id) is admitted
// and its authorize query is forwarded to commerce VERBATIM (a trusted caller names its
// own subject), scoped by X-Org-Id — this is what lets the cap gate reach AuthorizeSpendCap.
func TestBilling_S2S_ServiceTokenForwardsVerbatim(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, http.MethodGet,
"/v1/billing/spend-alerts/authorize?user=acme&amount=100&project=P",
map[string]string{"Authorization": "Bearer svc-tok", "X-Org-Id": "acme"}, "")
if code != http.StatusOK {
t.Fatalf("S2S authorize: want 200, got %d (%s)", code, body)
}
if f.path != "/v1/billing/spend-alerts/authorize" {
t.Fatalf("forwarded path = %q", f.path)
}
// VERBATIM: the S2S caller's ?user/?amount/?project reach commerce un-pinned.
if f.query.Get("user") != "acme" || f.query.Get("amount") != "100" || f.query.Get("project") != "P" {
t.Fatalf("S2S query must forward verbatim, got %v", f.query)
}
if f.org != "acme" || f.auth != "Bearer svc-tok" {
t.Fatalf("S2S must send X-Org-Id=acme + service token, got org=%q auth=%q", f.org, f.auth)
}
}
// S2S with the verified token but NO X-Org-Id → 403 (no org to scope the privileged
// forward to; never fall back to a client value).
func TestBilling_S2S_NoOrg403(t *testing.T) {
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-tok")
app := mountApp(t, "http://iam.invalid", "", "")
if code, _ := callH(t, app, http.MethodGet, "/v1/billing/spend-alerts/authorize?user=acme&amount=1",
map[string]string{"Authorization": "Bearer svc-tok"}, ""); code != http.StatusForbidden {
t.Fatalf("S2S without X-Org-Id: want 403")
}
}
+17
View File
@@ -173,6 +173,23 @@ func requireCSRF(s *cloud.Service[state], next zip.Handler) zip.Handler {
}
}
// RequireCSRF exposes the ambient-cookie anti-CSRF gate as a STANDALONE middleware for a
// co-resident money-WRITE route registered OUTSIDE this package — specifically
// apps/commerce.go's POST /v1/billing/topup/token, which shadows the account-bridge's
// POST /v1/billing/* wildcard (order 100 < 122) that would otherwise have wrapped the
// write in requireCSRF. Moving the route co-resident to break the commerceinproc
// self-dispatch loop must NOT silently drop that anti-CSRF gate, so the identical
// enforcement rides along as its own handler. It binds to the SAME process-wide key
// (sharedCSRFKey) the GET /v1/csrf issuer and the bridge verifier use, so a token minted
// at /v1/csrf verifies here byte-identically. Enforces ONLY on the ambient-cookie path (a
// Bearer/gateway/API caller is not CSRF-able); on success it c.Next()s into the rest of
// the chain. The minimal Service carries only the shared key — requireCSRF/verifyCSRF
// read nothing else off it.
func RequireCSRF() zip.Handler {
s := &cloud.Service[state]{State: state{csrfKey: sharedCSRFKey(nil)}}
return requireCSRF(s, func(c *zip.Ctx) error { return c.Next() })
}
// issueCSRFToken serves GET /v1/csrf: for a VALIDATED caller, a fresh token
// bound to their identity. no-store so it is never cached by a shared proxy. This is
// the same-origin endpoint the embedded SPA reads (its response body is unreadable to
+82
View File
@@ -44,6 +44,7 @@ const iamMaxBody = 4 << 20
// iamClient is the confidential-client caller. clientID/clientSecret authenticate
// as the `hanzo-console` app; an empty pair means "not configured" (handlers 501).
type iamClient struct {
serviceToken string // IAM_SERVICE_TOKEN — the Bearer for the admin provision endpoint
base string
clientID string
clientSecret string
@@ -56,10 +57,91 @@ func newIAMClient() *iamClient {
base: base,
clientID: strings.TrimSpace(os.Getenv("IAM_MINT_CLIENT_ID")),
clientSecret: strings.TrimSpace(os.Getenv("IAM_MINT_CLIENT_SECRET")),
serviceToken: strings.TrimSpace(os.Getenv("IAM_SERVICE_TOKEN")),
http: &http.Client{Timeout: 15 * time.Second},
}
}
// provisionResult is the /v1/iam/admin/provision response: the converged org and its
// hashed credential (accessSecret shown ONCE on first mint). The org starts at a zero
// balance — usage is pre-paid, no signup grant.
type provisionResult struct {
Org string `json:"org"`
AccessKey string `json:"accessKey"`
AccessSecret string `json:"accessSecret"`
Error string `json:"error"`
}
// provisionReady reports whether the service-token provisioning path is wired.
func (c *iamClient) provisionReady() bool { return c != nil && c.serviceToken != "" }
// provision drives the ONE atomic IAM onboarding op: create the org, move the named
// user in as its admin, and mint its hashed org-scoped credential — the service-token
// endpoint that replaces the create-org + move-user pair, so there is no orphan
// between two writes and a mid-flight retry converges. orgSlug is the caller's
// already-resolved slug (IAM honors it verbatim). The org starts at a zero balance.
func (c *iamClient) provision(ctx context.Context, owner, name, orgSlug string, personal bool) (provisionResult, error) {
if !c.provisionReady() {
return provisionResult{}, errNotConfigured
}
body, err := json.Marshal(map[string]any{
"owner": owner, "name": name, "orgSlug": orgSlug, "personal": personal,
})
if err != nil {
return provisionResult{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/v1/iam/admin/provision", strings.NewReader(string(body)))
if err != nil {
return provisionResult{}, err
}
req.Header.Set("Authorization", "Bearer "+c.serviceToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return provisionResult{}, fmt.Errorf("iam unreachable: %w", err)
}
defer func() { _ = resp.Body.Close() }()
raw, err := io.ReadAll(io.LimitReader(resp.Body, iamMaxBody))
if err != nil {
return provisionResult{}, err
}
var out provisionResult
if err := json.Unmarshal(raw, &out); err != nil {
return provisionResult{}, fmt.Errorf("iam provision non-json response (%d)", resp.StatusCode)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 || out.Error != "" {
msg := out.Error
if msg == "" {
msg = fmt.Sprintf("iam status %d", resp.StatusCode)
}
return provisionResult{}, fmt.Errorf("iam provision: %s", msg)
}
return out, nil
}
// userRow is the subset of an IAM user the onboarding path reads to resolve the
// caller's authoritative (owner, name) — a zero-org caller's owner is not on its
// token, so provision needs it from the row.
type userRow struct {
Owner string `json:"owner"`
Name string `json:"name"`
}
// getUserRow resolves the user by the caller's id (the same read the move did) into
// its authoritative (owner, name).
func (c *iamClient) getUserRow(ctx context.Context, id string) (userRow, error) {
raw, err := c.getUser(ctx, id)
if err != nil {
return userRow{}, err
}
var row userRow
if err := json.Unmarshal(raw, &row); err != nil {
return userRow{}, fmt.Errorf("iam get-user: decode: %w", err)
}
return row, nil
}
// configured reports whether the confidential client is wired. Handlers 501 when
// false — the deployment simply lacks the `hanzo-console` credential (the honest
// "not configured on this deployment" state, never a fabricated result).
+64
View File
@@ -0,0 +1,64 @@
package account
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
)
// TestOnboardFirstRun_ProvisionsOnce proves the production signup path drives ONE
// atomic IAM provision (not the old create-org + move-user pair): it resolves the
// zero-org caller's authoritative (owner, name) and provisions the org + hashed
// credential in a single call. No trial credit is granted — the org starts at a zero
// balance and usage is pre-paid.
func TestOnboardFirstRun_ProvisionsOnce(t *testing.T) {
var provisionCalls int
var provBody map[string]any
iamSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/iam/get-user":
_ = json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"data": map[string]any{"owner": "landing", "name": "dave"},
})
case "/v1/iam/admin/provision":
provisionCalls++
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &provBody)
_ = json.NewEncoder(w).Encode(map[string]any{
"org": "dave", "accessKey": "hk-x", "accessSecret": "sk-x",
})
default:
http.NotFound(w, r)
}
}))
defer iamSrv.Close()
iam := &iamClient{base: iamSrv.URL, clientID: "c", clientSecret: "s", serviceToken: "svc", http: &http.Client{}}
resp, err := onboardFirstRun(context.Background(), iam, "dave", "dave", "Dave", true)
if err != nil {
t.Fatalf("onboardFirstRun: %v", err)
}
if resp.Org != "dave" || resp.Additional {
t.Fatalf("resp = %+v, want org=dave additional=false", resp)
}
if provisionCalls != 1 {
t.Fatalf("provision calls = %d, want 1 (ONE atomic op, not create+move)", provisionCalls)
}
if provBody["owner"] != "landing" || provBody["name"] != "dave" || provBody["orgSlug"] != "dave" {
t.Fatalf("provision body = %v, want owner=landing name=dave orgSlug=dave", provBody)
}
// Retry converges: provision is idempotent (same org), no orphan.
if _, err := onboardFirstRun(context.Background(), iam, "dave", "dave", "Dave", true); err != nil {
t.Fatalf("retry: %v", err)
}
if provisionCalls != 2 {
t.Fatalf("provision calls = %d, want 2 (retried, converges)", provisionCalls)
}
}
+40 -22
View File
@@ -38,7 +38,10 @@ import (
"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/invoices"
"github.com/hanzoai/cloud/clients/admin/metrics"
"github.com/hanzoai/cloud/clients/admin/revenue"
"github.com/hanzoai/cloud/clients/admin/subscriptions"
"github.com/hanzoai/cloud/clients/commerceinproc"
"github.com/hanzoai/cloud/clients/principal"
"github.com/zap-proto/zip"
@@ -87,42 +90,57 @@ func Mount(app *zip.App, deps cloud.Deps) error {
// 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]) {
g := app.Group("/v1/admin")
// 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))
g.Get("/me", core.GuardScoped(s, me))
g.Get("/overview", core.GuardScoped(s, overview))
g.Get("/orgs", core.GuardScoped(s, orgs))
g.Get("/users", core.GuardScoped(s, users))
g.Get("/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))
g.Get("/roles", core.Guard(s, roles))
g.Get("/applications", core.Guard(s, applications))
g.Get("/products", core.Guard(s, products))
g.Get("/compute", core.Guard(s, compute))
g.Get("/block-storage", core.Guard(s, blockStorage))
g.Get("/o11y", core.Guard(s, o11y))
g.Get("/aimetrics", core.Guard(s, aimetrics))
g.Post("/sync", core.Guard(s, syncNow))
// Credit grants — the ONE admin mint surface (SuperAdmin only). Thin, audited
// relay to commerce's mint-gated POST /v1/billing/credit-grants; commerce is the
// sole ledger. See creditgrant.go.
g.Post("/credit-grants", core.Guard(s, createCreditGrant))
// Product analytics — org-scoped (SuperAdmin: all-orgs; org admin: their own org).
app.Get("/v1/admin/analytics", core.GuardScoped(s, analytics))
g.Get("/analytics", core.GuardScoped(s, analytics))
// Bases — the tenant Base-instance panel, org-scoped (bases.go).
app.Get("/v1/admin/bases", core.GuardScoped(s, bases))
g.Get("/bases", core.GuardScoped(s, bases))
// ── Platform control plane — SuperAdmin ONLY (launch/release/flags + access). ──
app.Get("/v1/admin/flags", core.Guard(s, flagsBoard))
app.Put("/v1/admin/flags/:key", core.Guard(s, setFlag))
g.Get("/flags", core.Guard(s, flagsBoard))
g.Put("/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))
// of /v1/admin/flags), reading the registry + decide the admission gate owns.
g.Get("/services", core.Guard(s, services))
g.Post("/services", core.Guard(s, upsertService))
g.Post("/services/:service/mode", core.Guard(s, setServiceMode))
g.Get("/waitlist", core.Guard(s, waitlist))
g.Post("/waitlist/boost", core.Guard(s, waitlistBoost))
// ── Carved-out domains own their routes (audit/customer/revenue/finance). ──
// Usage-cap + promo control plane (promos platform-only; spend-caps org-scoped).
limitRoutes(app, s)
// ── Carved-out domains own their routes (audit/customer/revenue/finance +
// the billing fleet views metrics/invoices/subscriptions). ──
audit.Routes(app, s)
customer.Routes(app, s)
revenue.Routes(app, s)
finance.Routes(app, s)
metrics.Routes(app, s)
invoices.Routes(app, s)
subscriptions.Routes(app, s)
}
// ── /v1/admin/me — operator identity (AdminMe) ───────────────────────────────
+3 -3
View File
@@ -24,16 +24,16 @@ import (
// mount builds a zip app with admin mounted against the given upstream bases,
// and returns a `do` helper that issues test requests through the whole app.
func mount(t *testing.T, iamURL, commerceURL, healthURL string) func(method, path string, hdr map[string]string) (*http.Response, []byte) {
do, _, _ := mountSvc(t, iamURL, commerceURL, healthURL)
do, _, _ := mountService(t, iamURL, commerceURL, healthURL)
return do
}
// mountSvc is mount but also returns the underlying cloud.Service[state] (so finance tests can swap
// mountService is mount but also returns the underlying cloud.Service[state] (so finance tests can swap
// in a fake DigitalOcean client, and the cockpit tests can attach an audit store)
// 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[core.State], *fiber.App) {
func mountService(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[core.State]{State: core.State{
+392
View File
@@ -0,0 +1,392 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package admin
// aimetrics — GET /v1/admin/aimetrics, the GLOBAL fleet-wide AI / training / eval
// read that powers the operator's AI-metrics board on admin.hanzo.ai. It is the
// AI-and-eval-focused companion to o11y (o11y.go): where o11y answers "how is the
// FLEET behaving" (RED metrics, logs, usage), this answers "how are the MODELS and
// EVALS doing" — LLM generations, per-model spend, and eval-run quality/progress —
// over the SAME ONE datastore (Datastore), the SAME shared client
// (aiobject.DatastoreQuery), no second connection.
//
// Signals, each from its canonical table in the one datastore:
// - LLM generations → langfuse.observations : generations, cost (USD), latency
// (fleet-wide; honest-empty until the
// Langfuse ingest lands rows)
// - Per-model usage → hanzo.cloud_usage : requests, tokens, cost per model
// (the live usage ledger the ai gateway
// writes — populated today)
// - Eval runs → hanzo.eval_traces : traces, runs, datasets, models under
// test, per-trace latency
// - Eval progress → hanzo.eval_scores : score count, avg score, per-score-name
// distribution, recent-run averages, and
// the avg-score-over-time TREND — the
// training/eval progress signal
//
// The eval_traces / eval_scores tables are OWNED and written by the eval telemetry
// store (clients/eval/telemetry.go) — the SAME warehouse, same db ("hanzo"), same
// shared aiobject client. admin only READS them here. There is deliberately no
// "training_progress" table: the router's per-request training events live in the ai
// OLTP Postgres (object.RoutingEvent), NOT the OLAP warehouse, so the honest
// warehouse-side progress signal is the eval-score trend, not a routing table.
//
// SUPERADMIN ONLY (the core.Guard wrap in admin.go), all-orgs, no org filter — the
// one place a fleet operator crosses tenants for AI/eval metrics; a non-admin bearer
// is refused 403 before a single row is read. Fail-closed.
//
// Honest by construction, exactly like o11y/compute: no datastore connected → the
// real empty aggregate, never a fabricated fleet; and every signal degrades
// INDEPENDENTLY — a table that is absent or a column that differs contributes its
// zero-value (the enclosing `if err == nil`), never a failure, so the board always
// renders what the datastore actually holds. admin READS only; it owns and creates
// NO table. Money from cloud_usage is USD cents, from langfuse is USD; latency is
// milliseconds; time bounds are POSITIONAL parameters (never interpolated), and the
// bucket interval is a server-side constant — injection-safe.
import (
"strconv"
"time"
aiobject "github.com/hanzoai/ai/object"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/admin/core"
"github.com/zap-proto/zip"
)
// Fully-qualified datastore tables. admin only READS these — the ai gateway owns
// hanzo.cloud_usage, Langfuse owns langfuse.observations, and the eval telemetry
// store (clients/eval) owns hanzo.eval_traces / hanzo.eval_scores.
const (
aimUsageTable = "hanzo.cloud_usage"
aimLangfuseObs = "langfuse.observations"
aimEvalTraces = "hanzo.eval_traces"
aimEvalScores = "hanzo.eval_scores"
aimTopN = 12
)
// aiMetrics is the whole AI-metrics board payload.
type aiMetrics struct {
Range string `json:"range"`
Start string `json:"start"`
End string `json:"end"`
Langfuse aimLangfuse `json:"langfuse"`
Usage aimUsage `json:"usage"`
Evals aimEvals `json:"evals"`
TopModels []aimModelStat `json:"topModels"` // cloud_usage per-model (populated today)
LangfuseModels []aimLfModelStat `json:"langfuseModels"` // langfuse per-model (honest-empty today)
ScoreNames []aimScoreStat `json:"scoreNames"` // eval_scores per score-name
EvalRuns []aimRunStat `json:"evalRuns"` // recent eval runs (progress)
ScoreSeries []aimScorePoint `json:"scoreSeries"` // avg eval score over time (progress trend)
}
// aimLangfuse is the fleet-wide Langfuse generation rollup (honest-empty today).
// Cost is USD (Langfuse's native unit); latency is milliseconds (end_time-start_time).
type aimLangfuse struct {
Generations int64 `json:"generations"`
CostUsd float64 `json:"costUsd"`
LatencyMsAvg float64 `json:"latencyMsAvg"`
LatencyMsP95 float64 `json:"latencyMsP95"`
}
// aimUsage is the fleet LLM-usage KPI band from the live cloud_usage ledger.
type aimUsage struct {
Requests int64 `json:"requests"`
Tokens int64 `json:"tokens"`
PromptTokens int64 `json:"promptTokens"`
CompletionTokens int64 `json:"completionTokens"`
CostCents int64 `json:"costCents"`
Models int64 `json:"models"`
}
// aimEvals is the fleet eval KPI band: the trace half (eval_traces) and the score
// half (eval_scores). LatencyMsAvg is the mean model-under-test call window.
type aimEvals struct {
Runs int64 `json:"runs"`
Traces int64 `json:"traces"`
Datasets int64 `json:"datasets"`
Models int64 `json:"models"`
LatencyMsAvg float64 `json:"latencyMsAvg"`
Scores int64 `json:"scores"`
ScoreNames int64 `json:"scoreNames"`
AvgScore float64 `json:"avgScore"`
}
// aimModelStat is one row of the per-model usage leaderboard (cloud_usage).
type aimModelStat struct {
Model string `json:"model"`
Requests int64 `json:"requests"`
Tokens int64 `json:"tokens"`
CostCents int64 `json:"costCents"`
}
// aimLfModelStat is one row of the per-model Langfuse leaderboard (honest-empty today).
type aimLfModelStat struct {
Model string `json:"model"`
Generations int64 `json:"generations"`
CostUsd float64 `json:"costUsd"`
}
// aimScoreStat is one row of the per-score-name eval leaderboard (eval_scores).
type aimScoreStat struct {
Name string `json:"name"`
Count int64 `json:"count"`
AvgValue float64 `json:"avgValue"`
MinValue float64 `json:"minValue"`
MaxValue float64 `json:"maxValue"`
}
// aimRunStat is one recent eval run: its dataset, how many scores it recorded, its
// mean score, and when it last ran — the run-level eval-progress row.
type aimRunStat struct {
RunName string `json:"runName"`
Dataset string `json:"dataset"`
Scores int64 `json:"scores"`
AvgValue float64 `json:"avgValue"`
LastTs string `json:"lastTs"`
}
// aimScorePoint is one bucket of the avg-eval-score-over-time trend.
type aimScorePoint struct {
Ts string `json:"ts"`
AvgValue float64 `json:"avgValue"`
Count int64 `json:"count"`
}
// aimetrics answers GET /v1/admin/aimetrics. ?range=24h|7d|30d bounds the window
// (default 30d). SUPERADMIN ONLY (core.Guard). Every signal degrades independently:
// a table that is absent or errors contributes its zero-value, never a failure — the
// board always renders what the datastore actually holds.
func aimetrics(s *cloud.Service[core.State], c *zip.Ctx) error {
ctx := c.Context()
rangeLabel := o11yRange(c.Query("range"))
since := computeSince(rangeLabel)
payload := aiMetrics{
Range: rangeLabel,
Start: since.Format(time.RFC3339),
End: time.Now().UTC().Format(time.RFC3339),
TopModels: []aimModelStat{},
LangfuseModels: []aimLfModelStat{},
ScoreNames: []aimScoreStat{},
EvalRuns: []aimRunStat{},
ScoreSeries: []aimScorePoint{},
}
// Honest-empty when the warehouse is not connected: the board renders its zero
// state, never a fabricated fleet.
if !aiobject.DatastoreEnabled() {
return core.OK(c, payload)
}
sinceTS := chTS(since) // DateTime literal — cloud_usage.timestamp, langfuse.start_time, eval_*.ts
interval := o11yBucket(rangeLabel)
// ── Langfuse generations (fleet) — honest-empty until ingest lands rows ──
if rows, err := aiobject.DatastoreQuery(ctx, aimLangfuseTotalsSQL(), sinceTS); err == nil {
r := firstRowOr(rows)
payload.Langfuse.Generations = chInt64(r["gens"])
payload.Langfuse.CostUsd = chFloat64(r["cost"])
}
// Langfuse latency (separate query so a Nullable end_time / column mismatch never
// zeroes the proven generations+cost number above).
if rows, err := aiobject.DatastoreQuery(ctx, aimLangfuseLatencySQL(), sinceTS); err == nil {
r := firstRowOr(rows)
payload.Langfuse.LatencyMsAvg = chFloat64(r["lat_avg"])
payload.Langfuse.LatencyMsP95 = chFloat64(r["lat_p95"])
}
// Langfuse per-model.
if rows, err := aiobject.DatastoreQuery(ctx, aimLangfuseModelsSQL(), sinceTS); err == nil {
payload.LangfuseModels = lfModelsFromRows(rows)
}
// ── Per-model usage (fleet) from the live cloud_usage ledger ──
if rows, err := aiobject.DatastoreQuery(ctx, aimUsageTotalsSQL(), sinceTS); err == nil {
fillAimUsage(&payload.Usage, firstRowOr(rows))
}
if rows, err := aiobject.DatastoreQuery(ctx, aimTopModelsSQL(), sinceTS); err == nil {
payload.TopModels = aimModelsFromRows(rows)
}
// ── Evals (fleet): traces + scores + progress ──
if rows, err := aiobject.DatastoreQuery(ctx, aimEvalTracesSQL(), sinceTS); err == nil {
fillAimEvalTraces(&payload.Evals, firstRowOr(rows))
}
if rows, err := aiobject.DatastoreQuery(ctx, aimEvalScoresSQL(), sinceTS); err == nil {
fillAimEvalScores(&payload.Evals, firstRowOr(rows))
}
if rows, err := aiobject.DatastoreQuery(ctx, aimScoreNamesSQL(), sinceTS); err == nil {
payload.ScoreNames = scoreNamesFromRows(rows)
}
if rows, err := aiobject.DatastoreQuery(ctx, aimEvalRunsSQL(), sinceTS); err == nil {
payload.EvalRuns = evalRunsFromRows(rows)
}
if rows, err := aiobject.DatastoreQuery(ctx, aimScoreSeriesSQL(interval), sinceTS); err == nil {
payload.ScoreSeries = scoreSeriesFromRows(rows)
}
return core.OK(c, payload)
}
// ── pure SQL builders (static SQL + one positional time bound; unit-tested) ──
func aimLangfuseTotalsSQL() string {
return "SELECT count() AS gens, toFloat64(sum(total_cost)) AS cost FROM " + aimLangfuseObs +
" WHERE type = 'GENERATION' AND start_time >= ?"
}
func aimLangfuseLatencySQL() string {
lat := "(toUnixTimestamp64Milli(end_time) - toUnixTimestamp64Milli(start_time))"
return "SELECT round(avg(" + lat + "), 2) AS lat_avg, round(quantile(0.95)(" + lat + "), 2) AS lat_p95 " +
"FROM " + aimLangfuseObs + " WHERE type = 'GENERATION' AND start_time >= ? AND end_time > start_time"
}
func aimLangfuseModelsSQL() string {
return "SELECT provided_model_name AS model, count() AS gens, toFloat64(sum(total_cost)) AS cost " +
"FROM " + aimLangfuseObs + " WHERE type = 'GENERATION' AND start_time >= ? AND provided_model_name != '' " +
"GROUP BY model ORDER BY gens DESC LIMIT " + strconv.Itoa(aimTopN)
}
func aimUsageTotalsSQL() string {
return "SELECT count() AS requests, sum(total_tokens) AS tokens, " +
"sum(prompt_tokens) AS prompt_tokens, sum(completion_tokens) AS completion_tokens, " +
"sum(cost_cents) AS cost_cents, uniqExact(model) AS models " +
"FROM " + aimUsageTable + " WHERE timestamp >= ?"
}
func aimTopModelsSQL() string {
return "SELECT model, count() AS requests, sum(total_tokens) AS tokens, " +
"sum(cost_cents) AS cost_cents FROM " + aimUsageTable +
" WHERE timestamp >= ? AND model != '' GROUP BY model ORDER BY requests DESC LIMIT " + strconv.Itoa(aimTopN)
}
func aimEvalTracesSQL() string {
lat := "(toUnixTimestamp64Milli(end_time) - toUnixTimestamp64Milli(start_time))"
return "SELECT count() AS traces, uniqExact(run_name) AS runs, uniqExact(dataset) AS datasets, " +
"uniqExact(model) AS models, round(avgIf(" + lat + ", end_time > start_time), 2) AS lat_avg " +
"FROM " + aimEvalTraces + " WHERE ts >= ?"
}
func aimEvalScoresSQL() string {
return "SELECT count() AS scores, round(avg(value), 4) AS avg_value, uniqExact(name) AS score_names " +
"FROM " + aimEvalScores + " WHERE ts >= ?"
}
func aimScoreNamesSQL() string {
return "SELECT name, count() AS n, round(avg(value), 4) AS avg_value, " +
"round(min(value), 4) AS min_value, round(max(value), 4) AS max_value " +
"FROM " + aimEvalScores + " WHERE ts >= ? AND name != '' GROUP BY name ORDER BY n DESC LIMIT " + strconv.Itoa(aimTopN)
}
func aimEvalRunsSQL() string {
return "SELECT run_name, any(dataset) AS dataset, count() AS scores, round(avg(value), 4) AS avg_value, " +
"max(ts) AS last_ts FROM " + aimEvalScores + " WHERE ts >= ? AND run_name != '' " +
"GROUP BY run_name ORDER BY last_ts DESC LIMIT " + strconv.Itoa(aimTopN)
}
func aimScoreSeriesSQL(interval string) string {
return "SELECT toStartOfInterval(ts, INTERVAL " + interval + ") AS ts, " +
"round(avg(value), 4) AS avg_value, count() AS n FROM " + aimEvalScores +
" WHERE ts >= ? GROUP BY ts ORDER BY ts"
}
// ── pure row parsers (unit-tested) ──
func fillAimUsage(u *aimUsage, r map[string]any) {
u.Requests = chInt64(r["requests"])
u.Tokens = chInt64(r["tokens"])
u.PromptTokens = chInt64(r["prompt_tokens"])
u.CompletionTokens = chInt64(r["completion_tokens"])
u.CostCents = chInt64(r["cost_cents"])
u.Models = chInt64(r["models"])
}
func fillAimEvalTraces(e *aimEvals, r map[string]any) {
e.Traces = chInt64(r["traces"])
e.Runs = chInt64(r["runs"])
e.Datasets = chInt64(r["datasets"])
e.Models = chInt64(r["models"])
e.LatencyMsAvg = chFloat64(r["lat_avg"])
}
func fillAimEvalScores(e *aimEvals, r map[string]any) {
e.Scores = chInt64(r["scores"])
e.AvgScore = chFloat64(r["avg_value"])
e.ScoreNames = chInt64(r["score_names"])
}
func aimModelsFromRows(rows []map[string]any) []aimModelStat {
out := make([]aimModelStat, 0, len(rows))
for _, r := range rows {
out = append(out, aimModelStat{
Model: chStr(r["model"]),
Requests: chInt64(r["requests"]),
Tokens: chInt64(r["tokens"]),
CostCents: chInt64(r["cost_cents"]),
})
}
return out
}
func lfModelsFromRows(rows []map[string]any) []aimLfModelStat {
out := make([]aimLfModelStat, 0, len(rows))
for _, r := range rows {
out = append(out, aimLfModelStat{
Model: chStr(r["model"]),
Generations: chInt64(r["gens"]),
CostUsd: chFloat64(r["cost"]),
})
}
return out
}
func scoreNamesFromRows(rows []map[string]any) []aimScoreStat {
out := make([]aimScoreStat, 0, len(rows))
for _, r := range rows {
out = append(out, aimScoreStat{
Name: chStr(r["name"]),
Count: chInt64(r["n"]),
AvgValue: chFloat64(r["avg_value"]),
MinValue: chFloat64(r["min_value"]),
MaxValue: chFloat64(r["max_value"]),
})
}
return out
}
func evalRunsFromRows(rows []map[string]any) []aimRunStat {
out := make([]aimRunStat, 0, len(rows))
for _, r := range rows {
out = append(out, aimRunStat{
RunName: chStr(r["run_name"]),
Dataset: chStr(r["dataset"]),
Scores: chInt64(r["scores"]),
AvgValue: chFloat64(r["avg_value"]),
LastTs: chTime(r["last_ts"]),
})
}
return out
}
func scoreSeriesFromRows(rows []map[string]any) []aimScorePoint {
out := make([]aimScorePoint, 0, len(rows))
for _, r := range rows {
out = append(out, aimScorePoint{
Ts: chTime(r["ts"]),
AvgValue: chFloat64(r["avg_value"]),
Count: chInt64(r["n"]),
})
}
return out
}
+171
View File
@@ -0,0 +1,171 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package admin
import (
"strings"
"testing"
)
// TestAimSQL_ReadsCanonicalTables proves every AI-metrics query reads the ONE
// datastore's canonical table, binds the time bound as a POSITIONAL param (one
// `?`), and never interpolates user input. The bucket interval is the only rendered
// value in the series query and it is a server-side constant.
func TestAimSQL_ReadsCanonicalTables(t *testing.T) {
cases := []struct {
name, sql, table string
wantQMarks int
}{
{"langfuseTotals", aimLangfuseTotalsSQL(), "langfuse.observations", 1},
{"langfuseLatency", aimLangfuseLatencySQL(), "langfuse.observations", 1},
{"langfuseModels", aimLangfuseModelsSQL(), "langfuse.observations", 1},
{"usageTotals", aimUsageTotalsSQL(), "hanzo.cloud_usage", 1},
{"topModels", aimTopModelsSQL(), "hanzo.cloud_usage", 1},
{"evalTraces", aimEvalTracesSQL(), "hanzo.eval_traces", 1},
{"evalScores", aimEvalScoresSQL(), "hanzo.eval_scores", 1},
{"scoreNames", aimScoreNamesSQL(), "hanzo.eval_scores", 1},
{"evalRuns", aimEvalRunsSQL(), "hanzo.eval_scores", 1},
{"scoreSeries", aimScoreSeriesSQL("1 DAY"), "hanzo.eval_scores", 1},
}
for _, c := range cases {
if !strings.Contains(c.sql, "FROM "+c.table) {
t.Errorf("%s must read %s; got %q", c.name, c.table, c.sql)
}
if n := strings.Count(c.sql, "?"); n != c.wantQMarks {
t.Errorf("%s: %d bind params, want %d (time bound only) — no interpolation; got %q", c.name, n, c.wantQMarks, c.sql)
}
}
}
// TestAimLangfuseScopedToGeneration proves the Langfuse lens is scoped to
// generations only (not spans/events), matching the o11y LLM lens.
func TestAimLangfuseScopedToGeneration(t *testing.T) {
for _, sql := range []string{aimLangfuseTotalsSQL(), aimLangfuseLatencySQL(), aimLangfuseModelsSQL()} {
if !strings.Contains(sql, "type = 'GENERATION'") {
t.Errorf("langfuse lens must scope to GENERATION observations; got %q", sql)
}
}
}
// TestAimTop_LimitAndOrder proves the leaderboards bound + order the result.
func TestAimTop_LimitAndOrder(t *testing.T) {
if !strings.Contains(aimTopModelsSQL(), "ORDER BY requests DESC LIMIT 12") {
t.Errorf("topModels must order by requests desc, limit %d", aimTopN)
}
if !strings.Contains(aimScoreNamesSQL(), "GROUP BY name ORDER BY n DESC LIMIT 12") {
t.Errorf("scoreNames must group+order+limit %d", aimTopN)
}
if !strings.Contains(aimEvalRunsSQL(), "ORDER BY last_ts DESC LIMIT 12") {
t.Errorf("evalRuns must order by last_ts desc, limit %d", aimTopN)
}
}
// TestAimScoreSeries_IntervalBound proves the (constant) bucket interval is
// rendered into the score-trend series query and grouped/ordered by the bucket.
func TestAimScoreSeries_IntervalBound(t *testing.T) {
for _, iv := range []string{"1 HOUR", "6 HOUR", "1 DAY"} {
s := aimScoreSeriesSQL(iv)
if !strings.Contains(s, "INTERVAL "+iv) || !strings.Contains(s, "GROUP BY ts ORDER BY ts") {
t.Errorf("score series must bucket by INTERVAL %s; got %q", iv, s)
}
}
}
// TestAimEvalLatencyGuarded proves the latency expressions guard end_time>start_time
// so a zero/default end_time never contributes a garbage (negative) latency.
func TestAimEvalLatencyGuarded(t *testing.T) {
if !strings.Contains(aimEvalTracesSQL(), "end_time > start_time") {
t.Errorf("eval traces latency must guard end_time>start_time; got %q", aimEvalTracesSQL())
}
if !strings.Contains(aimLangfuseLatencySQL(), "end_time > start_time") {
t.Errorf("langfuse latency must guard end_time>start_time; got %q", aimLangfuseLatencySQL())
}
}
// TestFillAimUsage reads a cloud_usage row into the KPI band across the numeric
// variants the driver returns (uint64/int64/float64), honest zeros on an empty row.
func TestFillAimUsage(t *testing.T) {
var empty aimUsage
fillAimUsage(&empty, map[string]any{})
if empty.Requests != 0 || empty.Tokens != 0 || empty.Models != 0 {
t.Fatalf("empty row must yield honest zeros; got %+v", empty)
}
var got aimUsage
fillAimUsage(&got, map[string]any{
"requests": uint64(274), "tokens": uint64(102597), "prompt_tokens": uint64(60000),
"completion_tokens": uint64(42597), "cost_cents": uint64(216), "models": uint64(42),
})
if got.Requests != 274 || got.Tokens != 102597 || got.CostCents != 216 || got.Models != 42 {
t.Fatalf("usage totals mis-parsed: %+v", got)
}
}
// TestFillAimEvals maps both eval halves (traces + scores) into the KPI band,
// including the float latency/score columns (round()/avg() land as float64; a
// Decimal-as-string is parsed).
func TestFillAimEvals(t *testing.T) {
var e aimEvals
fillAimEvalTraces(&e, map[string]any{
"traces": uint64(1280), "runs": uint64(16), "datasets": uint64(4),
"models": uint64(6), "lat_avg": float64(842.5),
})
fillAimEvalScores(&e, map[string]any{
"scores": uint64(1280), "avg_value": "0.8125", "score_names": uint64(3),
})
if e.Traces != 1280 || e.Runs != 16 || e.Datasets != 4 || e.Models != 6 || e.LatencyMsAvg != 842.5 {
t.Fatalf("eval traces mis-parsed: %+v", e)
}
if e.Scores != 1280 || e.ScoreNames != 3 || e.AvgScore != 0.8125 { // string→float64 path
t.Fatalf("eval scores mis-parsed: %+v", e)
}
}
// TestAimParsers map datastore rows into the view-models and preserve order (the
// SQL already ORDER BYs; a parser must not reorder or drop rows), with empty input
// yielding an empty (non-nil) slice rather than a panic.
func TestAimParsers(t *testing.T) {
models := aimModelsFromRows([]map[string]any{
{"model": "glm-5.2", "requests": uint64(154), "tokens": uint64(38966), "cost_cents": uint64(114)},
{"model": "deepseek-v4-flash", "requests": uint64(118), "tokens": uint64(61550), "cost_cents": uint64(101)},
})
if len(models) != 2 || models[0].Model != "glm-5.2" || models[1].Model != "deepseek-v4-flash" || models[0].Requests != 154 {
t.Fatalf("top models mis-parsed/reordered: %+v", models)
}
lf := lfModelsFromRows([]map[string]any{
{"model": "gpt-4o", "gens": uint64(42), "cost": float64(1.25)},
})
if len(lf) != 1 || lf[0].Model != "gpt-4o" || lf[0].Generations != 42 || lf[0].CostUsd != 1.25 {
t.Fatalf("langfuse models mis-parsed: %+v", lf)
}
names := scoreNamesFromRows([]map[string]any{
{"name": "accuracy", "n": uint64(320), "avg_value": float64(0.82), "min_value": float64(0), "max_value": float64(1)},
})
if len(names) != 1 || names[0].Name != "accuracy" || names[0].Count != 320 || names[0].AvgValue != 0.82 || names[0].MaxValue != 1 {
t.Fatalf("score names mis-parsed: %+v", names)
}
runs := evalRunsFromRows([]map[string]any{
{"run_name": "nightly-2026-07", "dataset": "gsm8k", "scores": uint64(200), "avg_value": float64(0.9), "last_ts": nil},
})
if len(runs) != 1 || runs[0].RunName != "nightly-2026-07" || runs[0].Dataset != "gsm8k" || runs[0].Scores != 200 || runs[0].AvgValue != 0.9 {
t.Fatalf("eval runs mis-parsed: %+v", runs)
}
// Empty input → empty (non-nil) slices, never a panic.
if got := scoreSeriesFromRows(nil); got == nil || len(got) != 0 {
t.Errorf("nil rows must yield empty slice, got %v", got)
}
if got := aimModelsFromRows(nil); got == nil || len(got) != 0 {
t.Errorf("nil rows must yield empty slice, got %v", got)
}
}
+3 -2
View File
@@ -26,8 +26,9 @@ import (
// 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))
g := app.Group("/v1/admin")
g.Get("/audit", core.Guard(s, Records))
g.Get("/audit/verify", core.Guard(s, Verify))
}
// Records answers GET /v1/admin/audit from cloud's local tamper-evident store when
+197
View File
@@ -0,0 +1,197 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package admin
// blockStorage — GET /v1/admin/block-storage, the realtime DO block-storage fleet the
// operator's Block Storage board (admin.hanzo.ai) watches to scale DO before it runs out.
// (Named `block-storage`, not `storage`, so the operator's separate S3 object-buckets
// view keeps /v1/admin/storage — two distinct storage concerns, two endpoints.)
// Two REAL sources, honest by construction:
// - The FLEET inventory (count · total capacity · monthly cost · per-volume region +
// attachment) from the DigitalOcean API (the same DO_API_TOKEN client the finance
// dashboard already uses). DO exposes capacity + attachment but NOT fill %, so a
// volume's used/pct stay ABSENT (the console renders an honest "—", never a
// fabricated number).
// - The analytics DATASTORE's own fill from ClickHouse `system.disks` (the 200Gi PVC
// the datastore fork mounts) — total/free space over the SAME shared client
// (aiobject.DatastoreQuery) the analytics + compute lenses read, no second
// connection. This is THE number the operator scales on.
//
// SUPERADMIN ONLY (the s.guard wrap in admin.go): a cross-tenant infra read, all-orgs.
// admin holds NO storage state — it only reads DO + the datastore. DO unconfigured →
// empty fleet; datastore not connected → no datastore card. Never a fabricated fleet.
import (
"context"
aiobject "github.com/hanzoai/ai/object"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/admin/core"
"github.com/hanzoai/cloud/clients/admin/digitalocean"
"github.com/zap-proto/zip"
)
// doBlockUsdPerGiB is DO block storage's list price ($0.10/GiB/mo) — the fleet cost
// line for the ops budget when DO doesn't itemize per-volume spend.
const doBlockUsdPerGiB = 0.10
// bytesPerGiB converts system.disks bytes (UInt64) → GiB.
const bytesPerGiB = 1024 * 1024 * 1024
// storageVolume mirrors the console StorageVolume. UsedGiB/Pct are POINTERS so an
// absent fill serializes as JSON null (→ the console's honest "—"), distinct from a
// real 0%.
type storageVolume struct {
ID string `json:"id"`
Name string `json:"name"`
Region string `json:"region"`
SizeGiB int `json:"sizeGiB"`
UsedGiB *float64 `json:"usedGiB"`
Pct *float64 `json:"pct"`
Attached bool `json:"attached"`
Service string `json:"service"`
}
// storageFleet is the roll-up: real count/capacity/cost; fleet fill absent (DO gives
// no per-volume fill, so there is no honest fleet-wide used total to report).
type storageFleet struct {
Count int `json:"count"`
TotalGiB int `json:"totalGiB"`
UsedGiB *float64 `json:"usedGiB"`
Pct *float64 `json:"pct"`
MonthlyUsd int `json:"monthlyUsd"`
}
// datastoreVolume is the analytics backend's own volume, fill REAL from system.disks.
type datastoreVolume struct {
Name string `json:"name"`
Mount string `json:"mount"`
SizeGiB int `json:"sizeGiB"`
UsedGiB float64 `json:"usedGiB"`
Pct float64 `json:"pct"`
}
// storageAlert flags a near-full volume (only the datastore carries a real fill today,
// so alerts are datastore-derived until a per-volume filesystem source is wired).
type storageAlert struct {
Volume string `json:"volume"`
Pct float64 `json:"pct"`
Level string `json:"level"`
}
// storageSnapshot is the whole board payload the console normalizes.
type storageSnapshot struct {
Fleet storageFleet `json:"fleet"`
Datastore *datastoreVolume `json:"datastore"`
Volumes []storageVolume `json:"volumes"`
Alerts []storageAlert `json:"alerts"`
}
// blockStorage answers GET /v1/admin/block-storage. SuperAdmin only. Each source
// degrades independently — a DO outage still returns the real datastore fill, and v.v.
func blockStorage(s *cloud.Service[core.State], c *zip.Ctx) error {
ctx := c.Context()
vols, _ := s.State.DO.Volumes(ctx) // honest empty on not-configured / unreachable
fill := datastoreFill(ctx) // nil unless system.disks answered
return core.OK(c, buildStorageSnapshot(vols, fill))
}
// buildStorageSnapshot assembles the board payload (PURE — unit-tested). It folds the
// DO inventory into the fleet totals + per-volume rows (fill absent), attaches the real
// datastore card, and derives a near-full alert from the datastore fill. No fabrication:
// a volume's fill is left nil (DO gives none), and the datastore card is present only
// when system.disks actually answered.
func buildStorageSnapshot(vols []digitalocean.Volume, fill *datastoreVolume) storageSnapshot {
out := make([]storageVolume, 0, len(vols))
totalGiB := 0
for _, v := range vols {
out = append(out, storageVolume{
ID: v.ID,
Name: v.Name,
Region: v.Region,
SizeGiB: v.SizeGiB,
Attached: len(v.DropletIDs) > 0,
})
totalGiB += v.SizeGiB
}
alerts := make([]storageAlert, 0, 1)
if fill != nil {
if lvl := alertLevel(fill.Pct); lvl != "" {
alerts = append(alerts, storageAlert{Volume: fill.Name, Pct: fill.Pct, Level: lvl})
}
}
return storageSnapshot{
Fleet: storageFleet{
Count: len(vols),
TotalGiB: totalGiB,
MonthlyUsd: int(float64(totalGiB)*doBlockUsdPerGiB + 0.5),
},
Datastore: fill,
Volumes: out,
Alerts: alerts,
}
}
// alertLevel thresholds a fill %: critical ≥ 90, warn ≥ 80, else none (PURE, tested).
func alertLevel(pct float64) string {
switch {
case pct >= 90:
return "critical"
case pct >= 80:
return "warn"
default:
return ""
}
}
// datastoreFill reads the analytics datastore's own volume usage from ClickHouse
// `system.disks` (the largest disk by capacity is the data volume — the 200Gi PVC).
// Returns nil when the datastore isn't connected or the query fails (honest — the
// console shows no datastore card, never a fabricated fill).
func datastoreFill(ctx context.Context) *datastoreVolume {
if !aiobject.DatastoreEnabled() {
return nil
}
rows, err := aiobject.DatastoreQuery(ctx,
"SELECT name, path, total_space, free_space FROM system.disks ORDER BY total_space DESC LIMIT 1")
if err != nil || len(rows) == 0 {
return nil
}
return datastoreFillFromRow(rows[0])
}
// datastoreFillFromRow maps a system.disks row → the datastore card (PURE, tested).
// nil when the disk reports no capacity (an unusable read, not a fabricated 0%).
func datastoreFillFromRow(r map[string]any) *datastoreVolume {
total := float64(chInt64(r["total_space"]))
if total <= 0 {
return nil
}
free := float64(chInt64(r["free_space"]))
used := total - free
if used < 0 {
used = 0
}
return &datastoreVolume{
Name: chStr(r["name"]),
Mount: chStr(r["path"]),
SizeGiB: int(total / bytesPerGiB),
UsedGiB: round1(used / bytesPerGiB),
Pct: round1(used / total * 100),
}
}
// round1 rounds to one decimal (used/pct read cleanly; not a scientific quantity).
func round1(x float64) float64 { return float64(int(x*10+0.5)) / 10 }
+130
View File
@@ -0,0 +1,130 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package admin
import (
"testing"
"github.com/hanzoai/cloud/clients/admin/digitalocean"
)
// The DO inventory folds into fleet totals + rows, and per-volume fill stays ABSENT
// (DO exposes no fill) so the console renders an honest "—", never a fabricated 0%.
func TestBuildStorageSnapshotFleet(t *testing.T) {
vols := []digitalocean.Volume{
{ID: "a", Name: "pvc-a", Region: "sfo3", SizeGiB: 200, DropletIDs: []int{1}},
{ID: "b", Name: "pvc-b", Region: "nyc1", SizeGiB: 100, DropletIDs: nil},
{ID: "c", Name: "pvc-c", Region: "sfo3", SizeGiB: 50, DropletIDs: []int{2, 3}},
}
snap := buildStorageSnapshot(vols, nil)
if snap.Fleet.Count != 3 {
t.Fatalf("count = %d, want 3", snap.Fleet.Count)
}
if snap.Fleet.TotalGiB != 350 {
t.Fatalf("totalGiB = %d, want 350", snap.Fleet.TotalGiB)
}
// 350 GiB * $0.10 = $35.
if snap.Fleet.MonthlyUsd != 35 {
t.Fatalf("monthlyUsd = %d, want 35", snap.Fleet.MonthlyUsd)
}
if snap.Fleet.UsedGiB != nil || snap.Fleet.Pct != nil {
t.Fatalf("fleet fill must be absent (DO gives none); got used=%v pct=%v", snap.Fleet.UsedGiB, snap.Fleet.Pct)
}
if len(snap.Volumes) != 3 {
t.Fatalf("volumes = %d, want 3", len(snap.Volumes))
}
// Attachment derives from droplet_ids; fill is absent per row.
if !snap.Volumes[0].Attached || snap.Volumes[1].Attached || !snap.Volumes[2].Attached {
t.Fatalf("attachment wrong: %+v", snap.Volumes)
}
for _, v := range snap.Volumes {
if v.UsedGiB != nil || v.Pct != nil {
t.Fatalf("volume %s fill must be absent, got used=%v pct=%v", v.ID, v.UsedGiB, v.Pct)
}
}
if snap.Datastore != nil {
t.Fatalf("datastore must be nil when no fill was read; got %+v", snap.Datastore)
}
if len(snap.Alerts) != 0 {
t.Fatalf("no alerts without a datastore fill; got %v", snap.Alerts)
}
}
// A near-full datastore raises exactly one alert; a healthy one raises none.
func TestBuildStorageSnapshotDatastoreAlert(t *testing.T) {
full := &datastoreVolume{Name: "default", Mount: "/var/lib/hanzo-datastore", SizeGiB: 196, UsedGiB: 178, Pct: 91}
snap := buildStorageSnapshot(nil, full)
if snap.Datastore == nil || snap.Datastore.Pct != 91 {
t.Fatalf("datastore card missing/wrong: %+v", snap.Datastore)
}
if len(snap.Alerts) != 1 || snap.Alerts[0].Level != "critical" || snap.Alerts[0].Volume != "default" {
t.Fatalf("expected one critical alert, got %+v", snap.Alerts)
}
healthy := &datastoreVolume{Name: "default", SizeGiB: 196, UsedGiB: 13, Pct: 7}
if a := buildStorageSnapshot(nil, healthy).Alerts; len(a) != 0 {
t.Fatalf("a 7%%-full datastore must raise no alert, got %+v", a)
}
}
func TestAlertLevel(t *testing.T) {
cases := []struct {
pct float64
want string
}{
{0, ""}, {7, ""}, {79.9, ""}, {80, "warn"}, {89.9, "warn"}, {90, "critical"}, {99, "critical"},
}
for _, c := range cases {
if got := alertLevel(c.pct); got != c.want {
t.Fatalf("alertLevel(%v) = %q, want %q", c.pct, got, c.want)
}
}
}
// system.disks bytes → GiB + pct, matching the live datastore (13.5G used of ~196G ≈ 7%).
func TestDatastoreFillFromRow(t *testing.T) {
// total ≈ 196.6 GiB, free ≈ 183.1 GiB → used ≈ 13.5 GiB → ~6.9%.
gib := float64(bytesPerGiB) // a variable → runtime math (a float→int const conversion won't compile)
total := int64(196.6 * gib)
used := int64(13.5 * gib)
row := map[string]any{
"name": "default",
"path": "/var/lib/hanzo-datastore",
"total_space": uint64(total),
"free_space": uint64(total - used),
}
d := datastoreFillFromRow(row)
if d == nil {
t.Fatal("expected a datastore fill, got nil")
}
if d.Name != "default" || d.Mount != "/var/lib/hanzo-datastore" {
t.Fatalf("name/mount wrong: %+v", d)
}
if d.SizeGiB != 196 {
t.Fatalf("sizeGiB = %d, want 196", d.SizeGiB)
}
if d.UsedGiB < 13.4 || d.UsedGiB > 13.6 {
t.Fatalf("usedGiB = %v, want ~13.5", d.UsedGiB)
}
if d.Pct < 6.5 || d.Pct > 7.5 {
t.Fatalf("pct = %v, want ~7", d.Pct)
}
// A disk that reports no capacity is an unusable read → nil (never a fake 0%).
if datastoreFillFromRow(map[string]any{"total_space": uint64(0)}) != nil {
t.Fatal("zero-capacity disk must yield nil, not a fabricated 0%")
}
}
+1 -1
View File
@@ -203,7 +203,7 @@ func newCockpitFakes(t *testing.T) *cockpitFakes {
}
}))
_, s, fa := mountSvc(t, f.iam.URL, f.commerce.URL, "")
_, s, fa := mountService(t, f.iam.URL, f.commerce.URL, "")
f.service = s
f.do = func(method, path string, hdr map[string]string, body string) (*http.Response, []byte) {
t.Helper()
+62 -2
View File
@@ -33,6 +33,7 @@ import (
"time"
"github.com/hanzoai/cloud/clients/admin/money"
"github.com/hanzoai/cloud/clients/commerceinproc"
)
// errUnconfigured marks a write (Deposit) attempted against an unwired commerce.
@@ -45,12 +46,17 @@ type Client struct {
http *http.Client
}
// New builds a commerce client for base + admin S2S token.
// New builds a commerce client for base + admin S2S token. The HTTP client uses the
// commerceinproc self-routing transport: when commerce is CO-RESIDENT (base is the
// commerce.inproc placeholder) it dispatches in-process — a plain http.Client would
// instead DNS-resolve "commerce.inproc" and fail "no such host", silently breaking the
// admin cost/finance god-view. For a split-deploy (a real commerce URL) it falls
// through to plain HTTP unchanged.
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},
http: commerceinproc.Client(15 * time.Second),
}
}
@@ -295,6 +301,18 @@ func (c *Client) Deposit(ctx context.Context, subject string, amount money.Cents
return out, nil
}
// CreateCreditGrant forwards a credit-grant request verbatim to commerce's
// mint-gated POST /v1/billing/credit-grants (CreateCreditGrant), authenticated
// by the admin service token, with subject as the target-org namespace selector.
// Commerce is the sole credit-grant ledger; this relays its contract untouched
// (the raw response is returned to the caller) so the admin surface stays thin.
func (c *Client) CreateCreditGrant(ctx context.Context, subject string, body []byte, idempotencyKey string) ([]byte, error) {
if !c.Ready() {
return nil, errUnconfigured
}
return c.post(ctx, "/v1/billing/credit-grants", subject, body, idempotencyKey)
}
// 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
@@ -332,6 +350,48 @@ func (c *Client) post(ctx context.Context, path, subject string, body []byte, id
return respBody, nil
}
// Forward proxies an admin-authenticated request to commerce VERBATIM and returns
// the raw body + status. It is the ONE seam a SuperAdmin surface drives commerce's
// own endpoints through — the platform plan-promo config (/v1/platform/promo) and a
// per-org spend-alert override (/v1/billing/spend-alerts) — without a typed method
// per shape. subject is the X-Org-Id namespace selector (the target org for a cap
// override, or the admin org for platform config); body is nil for GET/DELETE. The
// status is returned so the caller surfaces commerce's OWN verdict (400 validation,
// 403, 404) instead of flattening every non-2xx into one code.
func (c *Client) Forward(ctx context.Context, method, path, subject string, body []byte) ([]byte, int, error) {
if !c.Ready() {
return nil, 0, errUnconfigured
}
var rdr io.Reader
if body != nil {
rdr = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, c.base+path, rdr)
if err != nil {
return nil, 0, err
}
req.Header.Set("Accept", "application/json")
if body != nil {
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)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("commerce unreachable: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, resp.StatusCode, err
}
return raw, resp.StatusCode, 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
+220
View File
@@ -0,0 +1,220 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package core
// warehouse — the ONE-copy datastore-read kernel the billing FLEET views
// (metrics/invoices/subscriptions) compose. They read commerce.events — the
// single warehouse table the commerce analytics collector lands every
// customer-activity event in (subscription/invoice/usage lifecycle) — over the
// SAME shared client (aiobject.DatastoreQuery) the o11y/compute/analytics lenses
// already use, no second connection. This mirrors compute.go's row-coercers and
// EXISTS-TABLE probe, hoisted here so the three sibling domains share ONE copy
// instead of each re-deriving it (DRY; the admin-package o11y/compute keep their
// own private copies as the read template).
//
// Every read is honest by construction: no datastore connected, or the events
// table not provisioned (the emitter is still being wired) → the real empty
// aggregate, NEVER a fabricated fleet. admin READS only; it owns and creates NO
// table (the collector owns commerce.events). Time bounds are POSITIONAL
// parameters (never interpolated) so the reads are injection-safe; money is USD
// cents; timestamps are RFC3339.
import (
"context"
"strconv"
"strings"
"time"
aiobject "github.com/hanzoai/ai/object"
)
// BillingEventsTable is the collector-owned warehouse table the commerce
// customer-activity emitters land in (events/client.go → analytics-collector →
// commerce.events). admin only READS it (never creates it — the collector owns
// its writes), exactly as o11y reads hanzo.cloud_usage.
const BillingEventsTable = "commerce.events"
// Canonical customer-activity event names — the CONTRACT with the commerce
// emitters (events/client.go). These are server-side constants (never user
// input), so rendering them into an IN (...) list is injection-safe.
const (
EvSubscriptionCreated = "subscription_created"
EvSubscriptionRenewed = "subscription_renewed"
EvSubscriptionPlanChanged = "subscription_plan_changed"
EvSubscriptionCanceled = "subscription_canceled"
EvInvoiceFinalized = "invoice_finalized"
EvInvoicePaid = "invoice_paid"
EvInvoiceVoid = "invoice_void"
EvAPIUsageDebit = "api_usage_debit"
)
// SubscriptionEvents / InvoiceEvents are the lifecycle sets each fleet view
// folds over (latest-event-wins per entity). Closed server-side constants.
var (
SubscriptionEvents = []string{EvSubscriptionCreated, EvSubscriptionRenewed, EvSubscriptionPlanChanged, EvSubscriptionCanceled}
InvoiceEvents = []string{EvInvoiceFinalized, EvInvoicePaid, EvInvoiceVoid}
)
// WarehouseReady reports whether the shared datastore ledger is connected, the
// gate every fleet read checks first (honest-empty when false).
func WarehouseReady() bool { return aiobject.DatastoreEnabled() }
// BillingEventsReady reports whether the warehouse is connected AND the
// collector's commerce.events table is provisioned — the two-part gate every
// billing fleet view opens with, so an unwired collector degrades to an honest
// empty aggregate rather than an error.
func BillingEventsReady(ctx context.Context) bool {
return aiobject.DatastoreEnabled() && CHTableExists(ctx, BillingEventsTable)
}
// CHTableExists probes the datastore for a table's presence. The name is a
// package constant (never user input), so EXISTS TABLE is safe. Any error →
// false (honest "not available yet"), mirroring compute.computeTableExists.
func CHTableExists(ctx context.Context, qualified string) bool {
rows, err := aiobject.DatastoreQuery(ctx, "EXISTS TABLE "+qualified)
if err != nil || len(rows) == 0 {
return false
}
for _, v := range rows[0] {
return CHInt64(v) == 1
}
return false
}
// SQLInList renders a set of server-side-constant strings as a datastore string
// list ('a','b',…) for an IN (...) clause. ONLY for closed constant sets (the
// event-name enums above) — never for user input; positional args carry all
// caller-derived values.
func SQLInList(vals []string) string {
quoted := make([]string, len(vals))
for i, v := range vals {
quoted[i] = "'" + v + "'"
}
return strings.Join(quoted, ",")
}
// WarehouseSince maps the ?range enum (24h|7d|30d, default 30d) to a lower time
// bound, mirroring compute.computeSince so the fleet views share ONE window
// grammar.
func WarehouseSince(rangeLabel string) time.Time {
now := time.Now().UTC()
switch strings.TrimSpace(rangeLabel) {
case "24h":
return now.Add(-24 * time.Hour)
case "7d":
return now.Add(-7 * 24 * time.Hour)
default:
return now.Add(-30 * 24 * time.Hour)
}
}
// CHTimeLit formats a time as a datastore DateTime literal (UTC), bound as a
// POSITIONAL string arg (never interpolated).
func CHTimeLit(t time.Time) string { return t.UTC().Format("2006-01-02 15:04:05") }
// CHFirstRow returns the first row or an empty map (never nil), so a parser
// reads honest zeros from an empty result instead of panicking.
func CHFirstRow(rows []map[string]any) map[string]any {
if len(rows) == 0 {
return map[string]any{}
}
return rows[0]
}
// ── map[string]any coercers (the DatastoreQuery row shape) ───────────────────
//
// The datastore driver decodes each column to its native Go type (uint64 for
// count()/sum(UInt*), float64 for round()/JSON numerics, time.Time for DateTime,
// string for String); these accept those natives so a driver/transport change
// can't crash a read. Twins of the admin-package compute.go coercers.
func CHInt64(v any) int64 {
switch n := v.(type) {
case int:
return int64(n)
case int64:
return n
case int32:
return int64(n)
case uint:
return int64(n)
case uint64:
return int64(n)
case uint32:
return int64(n)
case uint16:
return int64(n)
case uint8:
return int64(n)
case float64:
return int64(n)
case float32:
return int64(n)
case string:
f, err := strconv.ParseFloat(strings.TrimSpace(n), 64)
if err != nil {
return 0
}
return int64(f)
default:
return 0
}
}
func CHFloat64(v any) float64 {
switch n := v.(type) {
case float64:
return n
case float32:
return float64(n)
case int:
return float64(n)
case int64:
return float64(n)
case int32:
return float64(n)
case uint64:
return float64(n)
case uint32:
return float64(n)
case string:
f, err := strconv.ParseFloat(strings.TrimSpace(n), 64)
if err != nil {
return 0
}
return f
default:
return 0
}
}
func CHStr(v any) string {
if s, ok := v.(string); ok {
return s
}
return ""
}
// CHTime coerces a datastore DateTime (time.Time) to an RFC3339 UTC string.
func CHTime(v any) string {
switch t := v.(type) {
case time.Time:
return t.UTC().Format(time.RFC3339)
case string:
return t
default:
return ""
}
}
+62
View File
@@ -0,0 +1,62 @@
package admin
import (
"encoding/json"
"strings"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/audit"
"github.com/hanzoai/cloud/clients/admin/core"
"github.com/zap-proto/zip"
)
// createCreditGrant is the admin mint surface: POST /v1/admin/credit-grants.
//
// SuperAdmin ONLY (wired through core.Guard). It does NOT mint in-process — it
// forwards the request VERBATIM to commerce's already-mint-gated
// POST /v1/billing/credit-grants (middleware.Mint → PlatformOnly), authenticated
// by COMMERCE_SERVICE_TOKEN and scoped to the target org, and writes ONE
// tamper-evident compliance record. Commerce stays the single credit-grant ledger;
// this is a thin, audited relay so there is exactly one place credit is minted.
//
// The body is commerce's own CreateCreditGrant contract; the only field this layer
// reads is the target org (`org`, or `user` as the org-pool alias) to select the
// per-org namespace commerce's EdgeAuth trusts after verifying the service token.
func createCreditGrant(s *cloud.Service[core.State], c *zip.Ctx) error {
if !s.State.Commerce.Ready() {
return core.Fail(c, "commerce is not configured on this deployment")
}
var req map[string]any
if err := c.Bind(&req); err != nil {
return core.Fail(c, "invalid request body")
}
org, _ := req["org"].(string)
if strings.TrimSpace(org) == "" {
org, _ = req["user"].(string)
}
org = strings.TrimSpace(org)
if org == "" {
return core.Fail(c, "org is required")
}
idempotencyKey, _ := req["idempotencyKey"].(string)
body, err := json.Marshal(req)
if err != nil {
return core.Fail(c, "invalid request body")
}
raw, err := s.State.Commerce.CreateCreditGrant(c.Context(), org, body, idempotencyKey)
if err != nil {
core.EmitAudit(s, c, "admin.customer.credit-grant", "credit-grant", org,
req, map[string]any{"error": err.Error()},
audit.Outcome{Result: "error", Status: 502, Reason: "credit-grant failed"})
return core.Fail(c, "credit-grant failed: "+err.Error())
}
core.EmitAudit(s, c, "admin.customer.credit-grant", "credit-grant", org,
nil, json.RawMessage(raw),
audit.Outcome{Result: "success", Status: 200})
return core.OK(c, json.RawMessage(raw))
}
+8 -7
View File
@@ -10,11 +10,12 @@ import (
// 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))
g := app.Group("/v1/admin")
g.Get("/customers", core.Guard(s, Customers))
g.Get("/customers/:org", core.Guard(s, CustomerDetail))
g.Post("/customers/:org/credit", core.Guard(s, GrantCredit))
g.Get("/grants", core.Guard(s, Grants))
g.Post("/grants", core.Guard(s, IssueGrant))
g.Post("/customers/:org/suspend", core.Guard(s, SuspendCustomer))
g.Post("/customers/:org/reactivate", core.Guard(s, ReactivateCustomer))
}
+63
View File
@@ -142,6 +142,69 @@ func (c *Client) History(ctx context.Context, perPage int) ([]Entry, error) {
return out, nil
}
// Volume is one DO block-storage volume: capacity + attachment + region. DO's API
// gives capacity and which droplets a volume is attached to, but NOT fill % — the
// caller enriches fill only where a filesystem source (the datastore's own
// system.disks) reports it, and renders an honest "—" everywhere else.
type Volume struct {
ID string
Name string
Region string
SizeGiB int
DropletIDs []int
}
// volumeWire is the raw DO /v2/volumes row.
type volumeWire struct {
ID string `json:"id"`
Name string `json:"name"`
SizeGigabytes int `json:"size_gigabytes"`
Region struct {
Slug string `json:"slug"`
} `json:"region"`
DropletIDs []int `json:"droplet_ids"`
}
// Volumes lists ALL block-storage volumes across the account, following DO's
// page-number pagination (200/page, a hard 25-page cap so a runaway can never loop).
// The fleet inventory (count, capacity, monthly cost) is real; per-volume fill is NOT
// exposed by DO and stays absent (honest) until a filesystem source reports it.
func (c *Client) Volumes(ctx context.Context) ([]Volume, error) {
if !c.Ready() {
return nil, fmt.Errorf("DO_API_TOKEN not configured")
}
var out []Volume
for page := 1; page <= 25; page++ {
body, err := c.get(ctx, "/v2/volumes?per_page=200&page="+strconv.Itoa(page))
if err != nil {
return nil, err
}
var w struct {
Volumes []volumeWire `json:"volumes"`
Meta struct {
Total int `json:"total"`
} `json:"meta"`
}
if err := json.Unmarshal(body, &w); err != nil {
return nil, fmt.Errorf("do volumes decode: %w", err)
}
for _, v := range w.Volumes {
out = append(out, Volume{
ID: v.ID,
Name: v.Name,
Region: v.Region.Slug,
SizeGiB: v.SizeGigabytes,
DropletIDs: v.DropletIDs,
})
}
// Stop on the last (short) page, or once we've collected the reported total.
if len(w.Volumes) < 200 || (w.Meta.Total > 0 && len(out) >= w.Meta.Total) {
break
}
}
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)
+6 -5
View File
@@ -28,16 +28,17 @@ 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))
g := app.Group("/v1/admin")
g.Get("/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))
g.Post("/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))
g.Post("/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))
g.Get("/providers/credit", core.Guard(s, ProvidersCredit))
g.Get("/usage/funding", core.Guard(s, UsageFunding))
}
// FinanceData is the full /v1/admin/finance aggregate.
+4 -4
View File
@@ -15,7 +15,7 @@ import (
// 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).
// /v1/admin/finance through the shared admin mount harness (mountService + 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).
@@ -49,7 +49,7 @@ func TestFinance_RealAggregation(t *testing.T) {
do := newFakeDO()
defer do.Close()
doReq, s, _ := mountSvc(t, iam.server.URL, commerce.URL, "")
doReq, s, _ := mountService(t, iam.server.URL, commerce.URL, "")
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",
@@ -151,7 +151,7 @@ func TestFinance_HonestUnconfiguredDO(t *testing.T) {
commerce := newFakeCommerceFinance()
defer commerce.Close()
doReq, _, _ := mountSvc(t, iam.server.URL, commerce.URL, "") // s.do already has empty token → unconfigured
doReq, _, _ := mountService(t, iam.server.URL, commerce.URL, "") // s.do already has empty token → unconfigured
admin := map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "admin"}
resp, body := doReq("GET", "/v1/admin/finance", admin)
@@ -212,7 +212,7 @@ func TestFinance_RevenueSourceDown_NoFabrication(t *testing.T) {
defer commerce.Close()
// IAM points nowhere reachable → listOrgs errors; commerce /v1/costs still 200s.
doReq, _, _ := mountSvc(t, "http://127.0.0.1:0", commerce.URL, "")
doReq, _, _ := mountService(t, "http://127.0.0.1:0", commerce.URL, "")
admin := map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "admin"}
resp, body := doReq("GET", "/v1/admin/finance", admin)
+152
View File
@@ -0,0 +1,152 @@
// Package invoices is the fleet INVOICE view (/v1/admin/invoices) — every issued
// invoice across every tenant: number, org, amount, status, issue + due date, plus the
// id a future detail view fetches /v1/billing/invoices/:id with. SuperAdmin only
// (core.Guard).
//
// It reads the ONE shared warehouse (commerce.events) — the table the commerce
// analytics collector lands every invoice-lifecycle event in — over the SAME client
// (aiobject.DatastoreQuery) the o11y/compute lenses use, with ZERO per-org fan-out:
// one GROUP BY resolves each invoice's LATEST lifecycle state (argMax by timestamp),
// so the whole fleet is one query, not N per-org commerce reads. Honest by
// construction: no datastore connected or the collector's table not provisioned yet →
// the real empty list, never a fabricated row. Optional ?org= scopes to one tenant,
// ?status= filters the LATEST status, ?limit= caps the list.
package invoices
import (
"sort"
"strconv"
"strings"
aiobject "github.com/hanzoai/ai/object"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/admin/core"
"github.com/zap-proto/zip"
)
// defaultLimit caps the fleet invoice list when the caller sends none.
const defaultLimit = 500
// InvoiceRow is one row of GET /v1/admin/invoices — an issued invoice at a glance,
// tagged with its owning org. Money is USD cents; timestamps are RFC3339 strings.
type InvoiceRow struct {
ID string `json:"id"`
Number string `json:"number"`
Org string `json:"org"`
Display string `json:"display"`
Status string `json:"status"`
AmountCents int64 `json:"amountCents"`
Currency string `json:"currency"`
Issued string `json:"issued"`
Due string `json:"due"`
}
// Invoices answers GET /v1/admin/invoices.
//
// GET /v1/admin/invoices?org=&status=&limit=
func Invoices(s *cloud.Service[core.State], c *zip.Ctx) error {
ctx := c.Context()
status := strings.ToLower(strings.TrimSpace(c.Query("status")))
wantOrg := strings.TrimSpace(c.Query("org"))
limit := parseLimit(c.Query("limit"))
// Honest-empty when the warehouse is not connected or the collector's events
// table is not provisioned yet (the emitter is still being wired).
if !core.BillingEventsReady(ctx) {
return core.OKList(c, []InvoiceRow{}, 0)
}
rows, err := aiobject.DatastoreQuery(ctx, invoicesSQL())
if err != nil {
return core.Fail(c, "invoices query: "+err.Error())
}
all := invoiceRowsFromRows(rows)
// Filter (latest status / org) then newest issued first, cap to limit.
out := make([]InvoiceRow, 0, len(all))
for _, r := range all {
if wantOrg != "" && r.Org != wantOrg {
continue
}
if status != "" && strings.ToLower(r.Status) != status {
continue
}
out = append(out, r)
}
sort.Slice(out, func(i, j int) bool { return out[i].Issued > out[j].Issued })
total := len(out)
if len(out) > limit {
out = out[:limit]
}
return core.OKList(c, out, total)
}
// invoicesSQL resolves each invoice's LATEST lifecycle state from commerce.events
// (argMax by timestamp). Static SQL over a closed event-name set (SQLInList of
// server constants) — no user input is interpolated, so it is injection-safe.
func invoicesSQL() string {
return "SELECT JSONExtractString(properties, 'invoice_id') AS id, " +
"argMax(JSONExtractString(properties, 'number'), timestamp) AS number, " +
"argMax(organization_id, timestamp) AS org, " +
"argMax(JSONExtractString(properties, 'status'), timestamp) AS status, " +
"argMax(JSONExtractInt(properties, 'amount_cents'), timestamp) AS amount_cents, " +
"argMax(JSONExtractString(properties, 'currency'), timestamp) AS currency, " +
"argMax(JSONExtractString(properties, 'issued'), timestamp) AS issued, " +
"argMax(JSONExtractString(properties, 'due'), timestamp) AS due, " +
"argMax(event, timestamp) AS last_event " +
"FROM " + core.BillingEventsTable + " " +
"WHERE event IN (" + core.SQLInList(core.InvoiceEvents) + ") " +
"AND JSONExtractString(properties, 'invoice_id') != '' " +
"GROUP BY id"
}
// invoiceRowsFromRows maps the datastore rows onto []InvoiceRow (pure). Display is
// the org slug — the warehouse holds no friendly name and admin does no per-org IAM
// fan-out here (honest, not fabricated). Status folds the lifecycle from the latest
// event so a paid/voided invoice reads correctly regardless of the status snapshot.
func invoiceRowsFromRows(rows []map[string]any) []InvoiceRow {
out := make([]InvoiceRow, 0, len(rows))
for _, r := range rows {
org := core.CHStr(r["org"])
out = append(out, InvoiceRow{
ID: core.CHStr(r["id"]),
Number: core.CHStr(r["number"]),
Org: org,
Display: org,
Status: foldInvoiceStatus(core.CHStr(r["last_event"]), core.CHStr(r["status"])),
AmountCents: core.CHInt64(r["amount_cents"]),
Currency: core.CHStr(r["currency"]),
Issued: core.CHStr(r["issued"]),
Due: core.CHStr(r["due"]),
})
}
return out
}
// foldInvoiceStatus resolves the effective status from the latest lifecycle event
// (paid / void terminal), falling back to the last-emitted status snapshot (open
// for a finalized invoice) when the event is a finalize.
func foldInvoiceStatus(lastEvent, snapshot string) string {
switch lastEvent {
case core.EvInvoicePaid:
return "paid"
case core.EvInvoiceVoid:
return "void"
}
if s := strings.TrimSpace(snapshot); s != "" {
return s
}
return "open"
}
// parseLimit clamps the fleet-list cap to [1,5000], defaulting to defaultLimit.
func parseLimit(s string) int {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n <= 0 {
return defaultLimit
}
if n > 5000 {
return 5000
}
return n
}
+77
View File
@@ -0,0 +1,77 @@
package invoices
import (
"strings"
"testing"
"github.com/hanzoai/cloud/clients/admin/core"
)
// TestInvoiceRowsFromRows proves the warehouse-row → InvoiceRow mapping (JSON-shape
// contract): amount coerced from driver ints, status folded from the latest event,
// display honestly the org slug (no fan-out).
func TestInvoiceRowsFromRows(t *testing.T) {
rows := []map[string]any{
{
"id": "inv_1", "number": "INV-0042", "org": "acme",
"status": "open", "amount_cents": int64(4900), "currency": "usd",
"issued": "2026-07-01T00:00:00Z", "due": "2026-07-15T00:00:00Z",
"last_event": core.EvInvoicePaid,
},
{
"id": "inv_2", "number": "INV-0043", "org": "beta",
"status": "open", "amount_cents": uint64(1200), "currency": "usd",
"issued": "2026-07-02T00:00:00Z", "due": "",
"last_event": core.EvInvoiceVoid,
},
}
out := invoiceRowsFromRows(rows)
if len(out) != 2 {
t.Fatalf("got %d rows, want 2", len(out))
}
if out[0].ID != "inv_1" || out[0].Number != "INV-0042" || out[0].Org != "acme" || out[0].Display != "acme" {
t.Fatalf("row0 identity wrong: %+v", out[0])
}
if out[0].AmountCents != 4900 || out[0].Currency != "usd" {
t.Fatalf("row0 amount/currency wrong: %+v", out[0])
}
if out[0].Status != "paid" {
t.Fatalf("row0 status = %q, want paid (paid event folds)", out[0].Status)
}
if out[0].Issued != "2026-07-01T00:00:00Z" || out[0].Due != "2026-07-15T00:00:00Z" {
t.Fatalf("row0 dates wrong: %+v", out[0])
}
if out[1].Status != "void" {
t.Fatalf("row1 status = %q, want void", out[1].Status)
}
}
func TestFoldInvoiceStatus(t *testing.T) {
if got := foldInvoiceStatus(core.EvInvoicePaid, "open"); got != "paid" {
t.Fatalf("paid fold = %q", got)
}
if got := foldInvoiceStatus(core.EvInvoiceVoid, "open"); got != "void" {
t.Fatalf("void fold = %q", got)
}
if got := foldInvoiceStatus(core.EvInvoiceFinalized, "open"); got != "open" {
t.Fatalf("finalized snapshot = %q", got)
}
if got := foldInvoiceStatus(core.EvInvoiceFinalized, ""); got != "open" {
t.Fatalf("finalized default = %q", got)
}
}
func TestInvoicesSQLInjectionSafe(t *testing.T) {
sql := invoicesSQL()
if !strings.Contains(sql, core.BillingEventsTable) {
t.Fatalf("query must read %s: %q", core.BillingEventsTable, sql)
}
for _, ev := range core.InvoiceEvents {
if !strings.Contains(sql, "'"+ev+"'") {
t.Fatalf("query missing event %q", ev)
}
}
if strings.Contains(sql, "?") {
t.Fatalf("invoices state query takes no positional args: %q", sql)
}
}
+13
View File
@@ -0,0 +1,13 @@
package invoices
import (
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/admin/core"
"github.com/zap-proto/zip"
)
// Routes registers the fleet invoice view (SuperAdmin only, cross-tenant).
func Routes(app *zip.App, s *cloud.Service[core.State]) {
g := app.Group("/v1/admin")
g.Get("/invoices", core.Guard(s, Invoices))
}
+144
View File
@@ -0,0 +1,144 @@
package admin
import (
"encoding/json"
"net/http"
"net/url"
"strings"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/admin/core"
"github.com/zap-proto/zip"
)
// The SuperAdmin usage-cap + promo control plane, twinning /v1/admin/flags. It owns
// no store: it FORWARDS to commerce (the billing source of truth) over the ONE
// service-token seam —
//
// promos → commerce /v1/platform/promo (the admin-configured plan promo)
// spend-caps → commerce /v1/billing/spend-alerts (a per-org usage cap override)
//
// so admin.hanzo.ai configures the 50%-off promo and oversees/overrides any org's
// caps without a parallel model. Promo routes are platform-only (core.Guard); cap
// routes are org-scoped (core.GuardScoped) so a SuperAdmin targets any org via ?org=
// while a lesser admin is hard-pinned to their own.
// limitRoutes registers the promo + cap control plane. Called from routes().
func limitRoutes(app *zip.App, s *cloud.Service[core.State]) {
g := app.Group("/v1/admin")
// Platform plan promo — SuperAdmin only.
g.Get("/promos", core.Guard(s, getPromo))
g.Put("/promos", core.Guard(s, putPromo))
// Per-org usage-cap oversight/override — SuperAdmin (any org via ?org=) or an org
// admin (own org only). Reuses the customer's OWN self-service spend-alert CRUD,
// so a platform override and a customer edit are the same rows.
g.Get("/spend-caps", core.GuardScoped(s, listSpendCaps))
g.Post("/spend-caps", core.GuardScoped(s, createSpendCap))
g.Patch("/spend-caps/:id", core.GuardScoped(s, updateSpendCap))
g.Delete("/spend-caps/:id", core.GuardScoped(s, deleteSpendCap))
}
// getPromo returns the current platform plan promo. X-Org-Id is the admin org —
// commerce stores the singleton in the reserved platform namespace regardless, and
// the service token is what passes commerce's RequirePlatformAdmin.
func getPromo(s *cloud.Service[core.State], c *zip.Ctx) error {
raw, status, err := s.State.Commerce.Forward(c.Context(), http.MethodGet, "/v1/platform/promo", s.State.AdminOrg, nil)
return relay(c, raw, status, err)
}
// putPromo upserts the platform plan promo from the SuperAdmin's {percentOff,start,
// end,plans,active} body — the ONE place the 50%-off offer is configured.
func putPromo(s *cloud.Service[core.State], c *zip.Ctx) error {
raw, status, err := s.State.Commerce.Forward(c.Context(), http.MethodPut, "/v1/platform/promo", s.State.AdminOrg, c.Body())
return relay(c, raw, status, err)
}
// listSpendCaps returns a target org's usage caps (spend-alerts + derived period
// spend/over/warn/resetsAt). The org is the SuperAdmin's ?org= or, for a scoped
// admin, their own — never a client-widened scope.
func listSpendCaps(s *cloud.Service[core.State], c *zip.Ctx) error {
org, ok := targetOrg(s, c)
if !ok {
return core.Fail(c, "org required")
}
raw, status, err := s.State.Commerce.Forward(c.Context(), http.MethodGet, "/v1/billing/spend-alerts", org, nil)
return relay(c, raw, status, err)
}
// createSpendCap sets a cap on a target org (platform override of a customer budget).
func createSpendCap(s *cloud.Service[core.State], c *zip.Ctx) error {
org, ok := targetOrg(s, c)
if !ok {
return core.Fail(c, "org required")
}
raw, status, err := s.State.Commerce.Forward(c.Context(), http.MethodPost, "/v1/billing/spend-alerts", org, c.Body())
return relay(c, raw, status, err)
}
// updateSpendCap edits a target org's cap by id (raise/lower the ceiling, flip enforce).
func updateSpendCap(s *cloud.Service[core.State], c *zip.Ctx) error {
org, ok := targetOrg(s, c)
if !ok {
return core.Fail(c, "org required")
}
id := strings.TrimSpace(c.Param("id"))
if id == "" {
return core.Fail(c, "cap id required")
}
raw, status, err := s.State.Commerce.Forward(c.Context(), http.MethodPatch, "/v1/billing/spend-alerts/"+url.PathEscape(id), org, c.Body())
return relay(c, raw, status, err)
}
// deleteSpendCap removes a target org's cap by id.
func deleteSpendCap(s *cloud.Service[core.State], c *zip.Ctx) error {
org, ok := targetOrg(s, c)
if !ok {
return core.Fail(c, "org required")
}
id := strings.TrimSpace(c.Param("id"))
if id == "" {
return core.Fail(c, "cap id required")
}
raw, status, err := s.State.Commerce.Forward(c.Context(), http.MethodDelete, "/v1/billing/spend-alerts/"+url.PathEscape(id), org, nil)
return relay(c, raw, status, err)
}
// targetOrg resolves which org a cap operation acts on: a SuperAdmin names it with
// ?org=; a scoped admin is hard-pinned to their own subtree (?org= ignored). Empty
// (false) when unresolvable, so the handler fails closed rather than acting on a
// guessed tenant.
func targetOrg(s *cloud.Service[core.State], c *zip.Ctx) (string, bool) {
sc := core.ResolveScope(s, c)
if sc.Super {
if org := strings.TrimSpace(c.Query("org")); org != "" {
return org, true
}
return "", false
}
if len(sc.Orgs) > 0 && strings.TrimSpace(sc.Orgs[0]) != "" {
return sc.Orgs[0], true
}
return "", false
}
// relay surfaces commerce's OWN verdict in the /v1 envelope: a 2xx passes the raw
// JSON through as data (so the console decodes the exact SpendAlert/Promo shape), a
// non-2xx becomes an honest failure carrying commerce's status + message rather than
// masking a 400 validation as success.
func relay(c *zip.Ctx, raw []byte, status int, err error) error {
if err != nil {
return core.Fail(c, err.Error())
}
if status < 200 || status >= 300 {
msg := strings.TrimSpace(string(raw))
if msg == "" {
msg = http.StatusText(status)
}
return core.Fail(c, msg)
}
if len(raw) == 0 {
return core.OK(c, map[string]any{"ok": true})
}
return core.OKRaw(c, json.RawMessage(raw), 0)
}
+120
View File
@@ -0,0 +1,120 @@
package admin
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
// recCommerce records the X-Org-Id + method + path of the last forwarded request so a
// test can prove the /v1/admin control plane targets the RIGHT tenant namespace, and
// serves the promo + spend-alert shapes verbatim.
type recCommerce struct {
server *httptest.Server
mu sync.Mutex
lastOrg string
lastMethod string
lastPath string
}
func newRecCommerce() *recCommerce {
f := &recCommerce{}
f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
f.lastOrg = r.Header.Get("X-Org-Id")
f.lastMethod = r.Method
f.lastPath = r.URL.Path
f.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "/platform/promo"):
io.WriteString(w, `{"percentOff":50,"plans":["pro"],"active":true}`)
case strings.HasSuffix(r.URL.Path, "/spend-alerts"):
io.WriteString(w, `[{"id":"a1","threshold":10000,"enforce":true,"period":"2026-07","resetsAt":"2026-08-01T00:00:00Z"}]`)
default:
io.WriteString(w, `{}`)
}
}))
return f
}
func (f *recCommerce) seen() (string, string, string) {
f.mu.Lock()
defer f.mu.Unlock()
return f.lastMethod, f.lastPath, f.lastOrg
}
func envStatus(t *testing.T, body []byte) string {
t.Helper()
var e struct {
Status string `json:"status"`
}
_ = json.Unmarshal(body, &e)
return e.Status
}
// The promo control plane is SuperAdmin-only (core.Guard) and forwards to commerce's
// platform-promo endpoint.
func TestLimits_Promo_SuperOnly(t *testing.T) {
iam := newScopeIAM()
defer iam.server.Close()
com := newRecCommerce()
defer com.server.Close()
do := mount(t, iam.server.URL, com.server.URL, "")
// SuperAdmin GET → 200 ok, forwarded to /v1/platform/promo.
resp, body := do("GET", "/v1/admin/promos", superHdr)
if resp.StatusCode != http.StatusOK || envStatus(t, body) != "ok" {
t.Fatalf("super GET promos = %d %s", resp.StatusCode, body)
}
if m, p, _ := com.seen(); m != "GET" || !strings.HasSuffix(p, "/platform/promo") {
t.Fatalf("forwarded %s %s, want GET .../platform/promo", m, p)
}
// SuperAdmin PUT → forwarded as PUT.
if resp, _ := do("PUT", "/v1/admin/promos", superHdr); resp.StatusCode != http.StatusOK {
t.Fatalf("super PUT promos = %d", resp.StatusCode)
}
if m, _, _ := com.seen(); m != "PUT" {
t.Fatalf("promo PUT forwarded as %s, want PUT", m)
}
// A non-super org admin is REFUSED at the platform gate (403), never reaching commerce.
if resp, _ := do("GET", "/v1/admin/promos", orgAdminHdr); resp.StatusCode != http.StatusForbidden {
t.Fatalf("org-admin GET promos = %d, want 403 (platform-only)", resp.StatusCode)
}
}
// Cap oversight is org-scoped: a SuperAdmin targets any org via ?org=; a scoped admin
// is hard-pinned to their OWN org (a client ?org= is ignored — the escalation line).
func TestLimits_SpendCaps_OrgScoped(t *testing.T) {
iam := newScopeIAM()
defer iam.server.Close()
com := newRecCommerce()
defer com.server.Close()
do := mount(t, iam.server.URL, com.server.URL, "")
// SuperAdmin with ?org=maxpower → forwards X-Org-Id=maxpower.
resp, body := do("GET", "/v1/admin/spend-caps?org=maxpower", superHdr)
if resp.StatusCode != http.StatusOK || envStatus(t, body) != "ok" {
t.Fatalf("super spend-caps = %d %s", resp.StatusCode, body)
}
if _, p, org := com.seen(); org != "maxpower" || !strings.HasSuffix(p, "/spend-alerts") {
t.Fatalf("forwarded org=%q path=%q, want maxpower .../spend-alerts", org, p)
}
// SuperAdmin WITHOUT ?org → org required (honest error, no guessed tenant).
if _, body := do("GET", "/v1/admin/spend-caps", superHdr); envStatus(t, body) != "error" {
t.Fatalf("super spend-caps without org must be an error envelope, got %s", body)
}
// A scoped org admin naming a FOREIGN ?org=hanzo is hard-pinned to their OWN org.
do("GET", "/v1/admin/spend-caps?org=hanzo", orgAdminHdr)
if _, _, org := com.seen(); org != "maxpower" {
t.Fatalf("scoped admin forwarded org=%q, want maxpower (client ?org= must be ignored)", org)
}
}
+488
View File
@@ -0,0 +1,488 @@
// Package metrics is the fleet SaaS-operations god-view (/v1/admin/metrics) — the
// operator's business dashboard: MRR/ARR, net-new vs churned MRR, the plan/category
// mix, the top customers, and the recent subscription movements. SuperAdmin only
// (core.Guard).
//
// It reads the ONE shared warehouse (commerce.events) — the table the commerce
// analytics collector lands every subscription/invoice/usage-lifecycle event in —
// over the SAME client (aiobject.DatastoreQuery) the o11y/compute lenses use, with
// ZERO per-org fan-out. Each panel is ONE aggregate query that folds the whole fleet
// (subscription state = latest-event-wins via argMax; new/churn/usage = windowed),
// exactly the way o11y.go composes independent per-signal reads. An unconnected
// warehouse — or the collector's events table not provisioned yet — degrades to an
// honest empty snapshot (real zeros, `[]` not null) with a not-ok source, never a
// fabricated number. Money is USD cents end to end; time bounds are POSITIONAL args.
package metrics
import (
"context"
"errors"
"sort"
"strconv"
"strings"
"time"
aiobject "github.com/hanzoai/ai/object"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/admin/core"
"github.com/hanzoai/cloud/clients/admin/money"
"github.com/zap-proto/zip"
)
// errUnconfigured marks the warehouse not connected on this deployment — core.SrcOf
// reports it as a not-ok source so the console renders the honest not-configured state.
var errUnconfigured = errors.New("billing warehouse not connected")
// defaultLimit caps the top-customers list; recentLimit caps the movement feed.
const (
defaultLimit = 20
recentLimit = 20
)
// ── response shapes (byte-identical to the operator contract in api.ts) ──────
// These were formerly modeled on the commerce S2S client; they now live here (the
// one consumer) since the read is a direct warehouse aggregate. Money is money.Cents
// (int64 underlying → plain-integer JSON, unchanged on the wire).
// SaaSMetrics is the whole-business SaaS-operations aggregate.
type SaaSMetrics struct {
AsOf string `json:"asOf"`
Currency string `json:"currency"`
Window string `json:"window"`
Revenue SaaSRevenue `json:"revenue"`
Subs SaaSSubs `json:"subscriptions"`
Usage SaaSUsage `json:"usage"`
Customers []SaaSCustomer `json:"customers"`
Orgs int `json:"orgs"`
Gaps []string `json:"gaps"`
}
// SaaSRevenue is the recurring-revenue headline (run-rate MRR/ARR + windowed movement).
type SaaSRevenue struct {
MRRCents money.Cents `json:"mrrCents"`
ARRCents money.Cents `json:"arrCents"`
ActiveSubscriptions int `json:"activeSubscriptions"`
PayingCustomers int `json:"payingCustomers"`
Trials int `json:"trials"`
NewMRRCents money.Cents `json:"newMrrCents"`
ChurnedMRRCents money.Cents `json:"churnedMrrCents"`
NetNewMRRCents money.Cents `json:"netNewMrrCents"`
ByCategory []SaaSCategory `json:"byCategory"`
}
// SaaSCategory is one plan-category bucket of run-rate MRR (the plan mix).
type SaaSCategory struct {
Category string `json:"category"`
MRRCents money.Cents `json:"mrrCents"`
Subscriptions int `json:"subscriptions"`
}
// SaaSSubs is the subscription-operations panel (per-plan mix, trials, new/canceled,
// recent movements).
type SaaSSubs struct {
ByPlan []SaaSPlan `json:"byPlan"`
TrialsActive int `json:"trialsActive"`
New int `json:"new"`
Canceled int `json:"canceled"`
Recent []SaaSEvent `json:"recent"`
}
// SaaSPlan is one plan's active/trialing counts, seats, and MRR contribution.
type SaaSPlan struct {
Plan string `json:"plan"`
Name string `json:"name"`
Category string `json:"category"`
Active int `json:"active"`
Trialing int `json:"trialing"`
Seats int `json:"seats"`
MRRCents money.Cents `json:"mrrCents"`
}
// SaaSEvent is one recent subscription movement ("created" or "canceled").
type SaaSEvent struct {
At string `json:"at"`
Org string `json:"org"`
Type string `json:"type"`
Plan string `json:"plan"`
Category string `json:"category"`
MRRDeltaCents money.Cents `json:"mrrDeltaCents"`
}
// SaaSUsage is the metered / pay-as-you-go revenue headline for the window.
type SaaSUsage struct {
Instrumented bool `json:"instrumented"`
WindowUsageCents money.Cents `json:"windowUsageCents"`
Requests int64 `json:"requests"`
}
// SaaSCustomer is one top customer by MRR + windowed usage.
type SaaSCustomer struct {
Org string `json:"org"`
Plan string `json:"plan"`
Category string `json:"category"`
Status string `json:"status"`
MRRCents money.Cents `json:"mrrCents"`
UsageCents money.Cents `json:"usageCents"`
Seats int `json:"seats"`
Since string `json:"since,omitempty"`
}
// MetricsData is the GET /v1/admin/metrics payload: the SaaS snapshot, flat, plus the
// admin read time and the upstream freshness strip every god-view carries.
type MetricsData struct {
SaaSMetrics
GeneratedAt string `json:"generatedAt"`
Sources []core.SourceStatus `json:"sources"`
}
// Metrics answers GET /v1/admin/metrics by aggregating commerce.events directly
// (fleet-wide, no per-org fan-out). SuperAdmin only.
//
// GET /v1/admin/metrics?window=30d&limit=20
func Metrics(s *cloud.Service[core.State], c *zip.Ctx) error {
ctx := c.Context()
now := time.Now().UTC().Format(time.RFC3339)
window := normalizeWindow(c.Query("window"))
limit := parseLimit(c.Query("limit"))
// Honest not-configured snapshot when the warehouse/collector table is absent.
if !core.BillingEventsReady(ctx) {
return core.OK(c, empty(now, window, core.SrcOf("billing-warehouse", errUnconfigured, 0, now)))
}
sinceTS := core.CHTimeLit(core.WarehouseSince(window))
m := SaaSMetrics{AsOf: now, Currency: "usd", Window: window}
// Revenue headline + plan-mix (run-rate, latest-event-wins over active subs).
if rows, err := aiobject.DatastoreQuery(ctx, headlineSQL()); err == nil {
fillHeadline(&m.Revenue, core.CHFirstRow(rows))
}
if rows, err := aiobject.DatastoreQuery(ctx, byCategorySQL()); err == nil {
m.Revenue.ByCategory = byCategoryFromRows(rows)
}
if rows, err := aiobject.DatastoreQuery(ctx, byPlanSQL()); err == nil {
m.Subs.ByPlan = byPlanFromRows(rows)
}
m.Subs.TrialsActive = m.Revenue.Trials
// Windowed movement: new vs churned MRR + counts.
if rows, err := aiobject.DatastoreQuery(ctx, movementSQL(), sinceTS); err == nil {
r := core.CHFirstRow(rows)
m.Revenue.NewMRRCents = money.Cents(core.CHInt64(r["new_mrr"]))
m.Revenue.ChurnedMRRCents = money.Cents(core.CHInt64(r["churned_mrr"]))
m.Revenue.NetNewMRRCents = m.Revenue.NewMRRCents - m.Revenue.ChurnedMRRCents
m.Subs.New = int(core.CHInt64(r["new_count"]))
m.Subs.Canceled = int(core.CHInt64(r["canceled_count"]))
}
// Recent movements feed.
if rows, err := aiobject.DatastoreQuery(ctx, recentSQL(), sinceTS); err == nil {
m.Subs.Recent = recentFromRows(rows)
}
// Metered usage headline (window).
if rows, err := aiobject.DatastoreQuery(ctx, usageSQL(), sinceTS); err == nil {
r := core.CHFirstRow(rows)
m.Usage.Requests = core.CHInt64(r["requests"])
m.Usage.WindowUsageCents = money.Cents(core.CHInt64(r["usage_cents"]))
m.Usage.Instrumented = m.Usage.Requests > 0
}
// Fleet org count (any billing activity).
if rows, err := aiobject.DatastoreQuery(ctx, orgCountSQL()); err == nil {
m.Orgs = int(core.CHInt64(core.CHFirstRow(rows)["orgs"]))
}
// Top customers by MRR + windowed usage (two reads merged, no fan-out).
m.Customers = topCustomers(ctx, sinceTS, limit)
m.Gaps = gapsFor(m)
return core.OK(c, MetricsData{
SaaSMetrics: normalize(m),
GeneratedAt: now,
Sources: []core.SourceStatus{core.SrcOf("billing-warehouse", nil, m.Orgs, now)},
})
}
// ── active-subscription state subquery (latest-event-wins, non-canceled) ─────
// activeSubs is the fleet's current subscription state: one row per subscription,
// its LATEST lifecycle values (argMax by timestamp), keeping only non-canceled
// subs (HAVING on the latest event). Static SQL over a closed event-name set — no
// user input interpolated. Reused by every run-rate panel so the definition of
// "active" lives in ONE place.
func activeSubs() string {
return "(SELECT " +
"argMax(organization_id, timestamp) AS org, " +
"argMax(JSONExtractString(properties, 'plan'), timestamp) AS plan, " +
"argMax(JSONExtractString(properties, 'plan_name'), timestamp) AS plan_name, " +
"argMax(JSONExtractString(properties, 'category'), timestamp) AS category, " +
"argMax(JSONExtractString(properties, 'status'), timestamp) AS status, " +
"argMax(JSONExtractInt(properties, 'mrr_cents'), timestamp) AS mrr_cents, " +
"argMax(JSONExtractInt(properties, 'seats'), timestamp) AS seats, " +
"min(timestamp) AS first_ts " +
"FROM " + core.BillingEventsTable + " " +
"WHERE event IN (" + core.SQLInList(core.SubscriptionEvents) + ") " +
"AND JSONExtractString(properties, 'subscription_id') != '' " +
"GROUP BY JSONExtractString(properties, 'subscription_id') " +
"HAVING argMax(event, timestamp) != '" + core.EvSubscriptionCanceled + "')"
}
// ── pure SQL builders (static SQL + at most one positional time bound) ────────
// headlineSQL: run-rate MRR (paying, non-trial), active-sub count, paying-customer
// count, and trial count — one pass over the active-subs state.
func headlineSQL() string {
return "SELECT sumIf(mrr_cents, status != 'trialing') AS mrr, " +
"count() AS active_subs, " +
"uniqExactIf(org, status != 'trialing' AND mrr_cents > 0) AS paying, " +
"countIf(status = 'trialing') AS trials FROM " + activeSubs()
}
func byCategorySQL() string {
return "SELECT category, sumIf(mrr_cents, status != 'trialing') AS mrr, count() AS subs " +
"FROM " + activeSubs() + " GROUP BY category ORDER BY mrr DESC"
}
func byPlanSQL() string {
return "SELECT plan, any(plan_name) AS name, any(category) AS category, " +
"countIf(status = 'active') AS active, countIf(status = 'trialing') AS trialing, " +
"sum(seats) AS seats, sumIf(mrr_cents, status != 'trialing') AS mrr " +
"FROM " + activeSubs() + " GROUP BY plan ORDER BY mrr DESC"
}
// movementSQL: windowed new vs churned MRR + counts (one positional since bound).
func movementSQL() string {
return "SELECT " +
"sumIf(JSONExtractInt(properties, 'mrr_cents'), event = '" + core.EvSubscriptionCreated + "') AS new_mrr, " +
"countIf(event = '" + core.EvSubscriptionCreated + "') AS new_count, " +
"sumIf(JSONExtractInt(properties, 'mrr_cents'), event = '" + core.EvSubscriptionCanceled + "') AS churned_mrr, " +
"countIf(event = '" + core.EvSubscriptionCanceled + "') AS canceled_count " +
"FROM " + core.BillingEventsTable + " " +
"WHERE event IN ('" + core.EvSubscriptionCreated + "','" + core.EvSubscriptionCanceled + "') AND timestamp >= ?"
}
func recentSQL() string {
return "SELECT timestamp AS at, organization_id AS org, event AS type, " +
"JSONExtractString(properties, 'plan_name') AS plan, " +
"JSONExtractString(properties, 'category') AS category, " +
"JSONExtractInt(properties, 'mrr_cents') AS mrr_delta " +
"FROM " + core.BillingEventsTable + " " +
"WHERE event IN ('" + core.EvSubscriptionCreated + "','" + core.EvSubscriptionCanceled + "') AND timestamp >= ? " +
"ORDER BY at DESC LIMIT " + strconv.Itoa(recentLimit)
}
func usageSQL() string {
return "SELECT count() AS requests, sum(JSONExtractInt(properties, 'amount_cents')) AS usage_cents " +
"FROM " + core.BillingEventsTable + " WHERE event = '" + core.EvAPIUsageDebit + "' AND timestamp >= ?"
}
func orgCountSQL() string {
return "SELECT uniqExact(organization_id) AS orgs FROM " + core.BillingEventsTable +
" WHERE event IN (" + core.SQLInList(allBillingEvents()) + ")"
}
func perOrgSubsSQL() string {
return "SELECT org, sumIf(mrr_cents, status != 'trialing') AS mrr, sum(seats) AS seats, " +
"argMax(plan_name, mrr_cents) AS plan, argMax(category, mrr_cents) AS category, " +
"argMax(status, mrr_cents) AS status, min(first_ts) AS since " +
"FROM " + activeSubs() + " GROUP BY org"
}
func perOrgUsageSQL() string {
return "SELECT organization_id AS org, sum(JSONExtractInt(properties, 'amount_cents')) AS usage_cents " +
"FROM " + core.BillingEventsTable + " WHERE event = '" + core.EvAPIUsageDebit + "' AND timestamp >= ? GROUP BY org"
}
// allBillingEvents is the union of every customer-activity event the fleet counts
// an org as "active" on (subscription + invoice + usage).
func allBillingEvents() []string {
out := append([]string{}, core.SubscriptionEvents...)
out = append(out, core.InvoiceEvents...)
return append(out, core.EvAPIUsageDebit)
}
// ── pure row parsers ─────────────────────────────────────────────────────────
func fillHeadline(r *SaaSRevenue, row map[string]any) {
r.MRRCents = money.Cents(core.CHInt64(row["mrr"]))
r.ARRCents = r.MRRCents * 12
r.ActiveSubscriptions = int(core.CHInt64(row["active_subs"]))
r.PayingCustomers = int(core.CHInt64(row["paying"]))
r.Trials = int(core.CHInt64(row["trials"]))
}
func byCategoryFromRows(rows []map[string]any) []SaaSCategory {
out := make([]SaaSCategory, 0, len(rows))
for _, r := range rows {
out = append(out, SaaSCategory{
Category: core.CHStr(r["category"]),
MRRCents: money.Cents(core.CHInt64(r["mrr"])),
Subscriptions: int(core.CHInt64(r["subs"])),
})
}
return out
}
func byPlanFromRows(rows []map[string]any) []SaaSPlan {
out := make([]SaaSPlan, 0, len(rows))
for _, r := range rows {
out = append(out, SaaSPlan{
Plan: core.CHStr(r["plan"]),
Name: core.CHStr(r["name"]),
Category: core.CHStr(r["category"]),
Active: int(core.CHInt64(r["active"])),
Trialing: int(core.CHInt64(r["trialing"])),
Seats: int(core.CHInt64(r["seats"])),
MRRCents: money.Cents(core.CHInt64(r["mrr"])),
})
}
return out
}
func recentFromRows(rows []map[string]any) []SaaSEvent {
out := make([]SaaSEvent, 0, len(rows))
for _, r := range rows {
typ := "created"
delta := money.Cents(core.CHInt64(r["mrr_delta"]))
if core.CHStr(r["type"]) == core.EvSubscriptionCanceled {
typ = "canceled"
delta = -delta // churn reduces run-rate MRR
}
out = append(out, SaaSEvent{
At: core.CHTime(r["at"]),
Org: core.CHStr(r["org"]),
Type: typ,
Plan: core.CHStr(r["plan"]),
Category: core.CHStr(r["category"]),
MRRDeltaCents: delta,
})
}
return out
}
// topCustomers folds per-org subscription state + per-org windowed usage into the
// top-N customers by MRR (then usage). Two reads merged in Go by org — a union, so
// a pay-as-you-go org with usage but no subscription still appears.
func topCustomers(ctx context.Context, sinceTS string, limit int) []SaaSCustomer {
byOrg := map[string]*SaaSCustomer{}
if rows, err := aiobject.DatastoreQuery(ctx, perOrgSubsSQL()); err == nil {
for _, r := range rows {
org := core.CHStr(r["org"])
if org == "" {
continue
}
byOrg[org] = &SaaSCustomer{
Org: org,
Plan: core.CHStr(r["plan"]),
Category: core.CHStr(r["category"]),
Status: core.CHStr(r["status"]),
MRRCents: money.Cents(core.CHInt64(r["mrr"])),
Seats: int(core.CHInt64(r["seats"])),
Since: core.CHTime(r["since"]),
}
}
}
if rows, err := aiobject.DatastoreQuery(ctx, perOrgUsageSQL(), sinceTS); err == nil {
for _, r := range rows {
org := core.CHStr(r["org"])
if org == "" {
continue
}
usage := money.Cents(core.CHInt64(r["usage_cents"]))
if cust, ok := byOrg[org]; ok {
cust.UsageCents = usage
continue
}
byOrg[org] = &SaaSCustomer{Org: org, Plan: "pay-as-you-go", Status: "active", UsageCents: usage}
}
}
out := make([]SaaSCustomer, 0, len(byOrg))
for _, c := range byOrg {
out = append(out, *c)
}
sortCustomers(out)
if len(out) > limit {
out = out[:limit]
}
return out
}
// ── small pure helpers ───────────────────────────────────────────────────────
// sortCustomers ranks by MRR desc, ties broken by windowed usage desc.
func sortCustomers(cs []SaaSCustomer) {
sort.SliceStable(cs, func(i, j int) bool { return lessCustomer(cs[i], cs[j]) })
}
func lessCustomer(a, b SaaSCustomer) bool {
if a.MRRCents != b.MRRCents {
return a.MRRCents > b.MRRCents
}
return a.UsageCents > b.UsageCents
}
// gapsFor lists honest not-yet-observed signals so the console can badge a partial
// snapshot without fabricating data.
func gapsFor(m SaaSMetrics) []string {
gaps := []string{}
if !m.Usage.Instrumented {
gaps = append(gaps, "api-usage debits not yet observed")
}
if m.Revenue.ActiveSubscriptions == 0 {
gaps = append(gaps, "no active subscriptions observed")
}
return gaps
}
// empty is the honest not-connected snapshot: real zeros + empty slices (never
// null, never fabricated) plus the not-ok source.
func empty(now, window string, src core.SourceStatus) MetricsData {
return MetricsData{
SaaSMetrics: normalize(SaaSMetrics{AsOf: now, Currency: "usd", Window: window}),
GeneratedAt: now,
Sources: []core.SourceStatus{src},
}
}
// normalize replaces nil slices with empty ones so the JSON is honest arrays (`[]`,
// not null) and the console never has to guard a missing collection.
func normalize(m SaaSMetrics) SaaSMetrics {
if m.Revenue.ByCategory == nil {
m.Revenue.ByCategory = []SaaSCategory{}
}
if m.Subs.ByPlan == nil {
m.Subs.ByPlan = []SaaSPlan{}
}
if m.Subs.Recent == nil {
m.Subs.Recent = []SaaSEvent{}
}
if m.Customers == nil {
m.Customers = []SaaSCustomer{}
}
if m.Gaps == nil {
m.Gaps = []string{}
}
return m
}
// normalizeWindow clamps ?window to the supported set (default 30d) — mirrors the
// warehouse window grammar (core.WarehouseSince).
func normalizeWindow(v string) string {
switch strings.TrimSpace(v) {
case "24h":
return "24h"
case "7d":
return "7d"
default:
return "30d"
}
}
// parseLimit clamps the top-N cap to [1,200], defaulting to defaultLimit.
func parseLimit(s string) int {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n <= 0 {
return defaultLimit
}
if n > 200 {
return 200
}
return n
}
+121
View File
@@ -0,0 +1,121 @@
package metrics
import (
"strings"
"testing"
"github.com/hanzoai/cloud/clients/admin/core"
)
// TestFillHeadline proves the run-rate headline coercion (driver ints) + the
// ARR = 12×MRR derivation.
func TestFillHeadline(t *testing.T) {
var rev SaaSRevenue
fillHeadline(&rev, map[string]any{
"mrr": int64(4900), "active_subs": uint64(3), "paying": uint64(2), "trials": uint64(1),
})
if rev.MRRCents != 4900 || rev.ARRCents != 4900*12 {
t.Fatalf("mrr/arr wrong: %+v", rev)
}
if rev.ActiveSubscriptions != 3 || rev.PayingCustomers != 2 || rev.Trials != 1 {
t.Fatalf("counts wrong: %+v", rev)
}
}
func TestByCategoryAndPlanFromRows(t *testing.T) {
cats := byCategoryFromRows([]map[string]any{
{"category": "cloud", "mrr": int64(9800), "subs": uint64(2)},
})
if len(cats) != 1 || cats[0].Category != "cloud" || cats[0].MRRCents != 9800 || cats[0].Subscriptions != 2 {
t.Fatalf("category row wrong: %+v", cats)
}
plans := byPlanFromRows([]map[string]any{
{"plan": "pro", "name": "Pro", "category": "cloud", "active": uint64(2), "trialing": uint64(1), "seats": uint64(5), "mrr": int64(9800)},
})
if len(plans) != 1 {
t.Fatalf("want 1 plan, got %d", len(plans))
}
p := plans[0]
if p.Plan != "pro" || p.Name != "Pro" || p.Category != "cloud" || p.Active != 2 || p.Trialing != 1 || p.Seats != 5 || p.MRRCents != 9800 {
t.Fatalf("plan row wrong: %+v", p)
}
}
// TestRecentFromRows proves the movement feed maps event→type and NEGATES churn MRR.
func TestRecentFromRows(t *testing.T) {
rows := []map[string]any{
{"at": "2026-07-10T00:00:00Z", "org": "acme", "type": core.EvSubscriptionCreated, "plan": "Pro", "category": "cloud", "mrr_delta": int64(4900)},
{"at": "2026-07-09T00:00:00Z", "org": "beta", "type": core.EvSubscriptionCanceled, "plan": "Team", "category": "cloud", "mrr_delta": int64(3000)},
}
out := recentFromRows(rows)
if len(out) != 2 {
t.Fatalf("want 2, got %d", len(out))
}
if out[0].Type != "created" || out[0].MRRDeltaCents != 4900 {
t.Fatalf("created row wrong: %+v", out[0])
}
if out[1].Type != "canceled" || out[1].MRRDeltaCents != -3000 {
t.Fatalf("canceled row must negate mrr: %+v", out[1])
}
}
func TestSortCustomers(t *testing.T) {
cs := []SaaSCustomer{
{Org: "a", MRRCents: 100, UsageCents: 0},
{Org: "b", MRRCents: 500, UsageCents: 0},
{Org: "c", MRRCents: 500, UsageCents: 999}, // ties on MRR → usage breaks
}
sortCustomers(cs)
if cs[0].Org != "c" || cs[1].Org != "b" || cs[2].Org != "a" {
t.Fatalf("order wrong: %s,%s,%s", cs[0].Org, cs[1].Org, cs[2].Org)
}
}
// TestStateQueriesNoPositionalArgs: the run-rate (state) queries are fully static.
func TestStateQueriesNoPositionalArgs(t *testing.T) {
for name, sql := range map[string]string{
"headline": headlineSQL(), "byCategory": byCategorySQL(), "byPlan": byPlanSQL(),
"orgCount": orgCountSQL(), "perOrgSubs": perOrgSubsSQL(),
} {
if !strings.Contains(sql, core.BillingEventsTable) {
t.Fatalf("%s must read %s", name, core.BillingEventsTable)
}
if strings.Contains(sql, "?") {
t.Fatalf("%s (run-rate) must take no positional args: %q", name, sql)
}
}
}
// TestWindowedQueriesOnePositionalArg: the windowed queries bind exactly ONE time
// arg (injection-safe — the since bound is never interpolated).
func TestWindowedQueriesOnePositionalArg(t *testing.T) {
for name, sql := range map[string]string{
"movement": movementSQL(), "recent": recentSQL(), "usage": usageSQL(), "perOrgUsage": perOrgUsageSQL(),
} {
if n := strings.Count(sql, "?"); n != 1 {
t.Fatalf("%s must bind exactly ONE positional time arg, got %d: %q", name, n, sql)
}
if !strings.Contains(sql, "timestamp >= ?") {
t.Fatalf("%s time bound must be positional: %q", name, sql)
}
}
}
func TestNormalizeAndEmpty(t *testing.T) {
m := normalize(SaaSMetrics{})
if m.Revenue.ByCategory == nil || m.Subs.ByPlan == nil || m.Subs.Recent == nil || m.Customers == nil || m.Gaps == nil {
t.Fatal("normalize must replace nil slices with empty (honest [] not null)")
}
e := empty("now", "30d", core.SrcOf("billing-warehouse", errUnconfigured, 0, "now"))
if e.Currency != "usd" || e.Window != "30d" || len(e.Sources) != 1 || e.Sources[0].OK {
t.Fatalf("empty snapshot wrong: %+v", e)
}
}
func TestNormalizeWindow(t *testing.T) {
for in, want := range map[string]string{"24h": "24h", "7d": "7d", "30d": "30d", "": "30d", "90d": "30d"} {
if got := normalizeWindow(in); got != want {
t.Fatalf("normalizeWindow(%q) = %q, want %q", in, got, want)
}
}
}
+14
View File
@@ -0,0 +1,14 @@
package metrics
import (
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/admin/core"
"github.com/zap-proto/zip"
)
// Routes registers the SaaS-metrics god-view (SuperAdmin only, cross-tenant business
// aggregate).
func Routes(app *zip.App, s *cloud.Service[core.State]) {
g := app.Group("/v1/admin")
g.Get("/metrics", core.Guard(s, Metrics))
}
+2 -1
View File
@@ -22,7 +22,8 @@ import (
// 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))
g := app.Group("/v1/admin")
g.Get("/revenue", core.Guard(s, Revenue))
}
// RevenueCustomer is one row of the per-customer revenue table.
+15 -15
View File
@@ -1,16 +1,16 @@
package admin
// The /v1/admin/services board — the launch-control LENS on the ONE flag engine, twin
// The /v1/admin/services board — the launch-control LENS over the waitlist gate, twin
// of /v1/admin/flags. Every hosted service (studio/chat/console/app/api/team + runtime
// onboards) with its LIVE waitlist mode — the switch waitlist.<svc> evaluated through
// clients/flags. This is the "remove the waitlist one service at a time" toggle.
// SuperAdmin only (core.Guard), like every platform /v1/admin/*.
// onboards) with its LIVE waitlist mode — the switch waitlist.<svc>, evaluated through
// clients/admission (which composes the flag engine one-way). This is the "remove the
// waitlist one service at a time" toggle. SuperAdmin only (core.Guard), like every
// platform /v1/admin/*.
//
// Formerly clients/featuregate owned its OWN SQLite mode store + this control plane;
// both folded onto the flag engine so the platform has ONE decision plane. featuregate
// now owns only the native Enforce middleware — a consumer of flags.WaitlistModeForHost.
// Per-user approval (the second, orthogonal axis) stays IAM's, reached via the existing
// admin IAM proxy — not re-served here.
// The registry + mode decide + these admin control funcs live in clients/admission,
// the complete launch-gate feature; flags is the pure engine underneath. Per-user
// approval (the second, orthogonal axis) stays IAM's, reached via the existing admin IAM
// proxy — not re-served here.
import (
"errors"
@@ -19,13 +19,13 @@ import (
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/admin/core"
"github.com/hanzoai/cloud/clients/flags"
"github.com/hanzoai/cloud/clients/admission"
"github.com/zap-proto/zip"
)
// services answers GET /v1/admin/services — the launch board (every service + live mode).
func services(s *cloud.Service[core.State], c *zip.Ctx) error {
rows, err := flags.ListWaitlistServices(c.Context())
rows, err := admission.ListWaitlistServices(c.Context())
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "list services: %v", err)
}
@@ -35,14 +35,14 @@ func services(s *cloud.Service[core.State], c *zip.Ctx) error {
// upsertService answers POST /v1/admin/services — onboard or edit a hosted service so a
// new host is governed WITHOUT a redeploy. A re-register PRESERVES the live switch.
func upsertService(s *cloud.Service[core.State], c *zip.Ctx) error {
var in flags.ServiceInput
var in admission.ServiceInput
if err := c.Bind(&in); err != nil {
return err
}
if strings.TrimSpace(in.Service) == "" {
return zip.ErrBadRequest("service slug is required")
}
view, err := flags.UpsertWaitlistService(c.Context(), in, c.UserEmail())
view, err := admission.UpsertWaitlistService(c.Context(), in, c.UserEmail())
if err != nil {
return zip.ErrBadRequest(err.Error())
}
@@ -62,9 +62,9 @@ func setServiceMode(s *cloud.Service[core.State], c *zip.Ctx) error {
if err := c.Bind(&body); err != nil {
return err
}
view, err := flags.SetWaitlistMode(c.Context(), service, body.WaitlistMode, c.UserEmail())
view, err := admission.SetWaitlistMode(c.Context(), service, body.WaitlistMode, c.UserEmail())
if err != nil {
if errors.Is(err, flags.ErrServiceNotFound) {
if errors.Is(err, admission.ErrServiceNotFound) {
return zip.ErrNotFound("service not found: " + service)
}
return zip.Errorf(http.StatusInternalServerError, "set mode: %v", err)
+13
View File
@@ -0,0 +1,13 @@
package subscriptions
import (
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/admin/core"
"github.com/zap-proto/zip"
)
// Routes registers the fleet subscription view (SuperAdmin only, cross-tenant).
func Routes(app *zip.App, s *cloud.Service[core.State]) {
g := app.Group("/v1/admin")
g.Get("/subscriptions", core.Guard(s, Subscriptions))
}
@@ -0,0 +1,156 @@
// Package subscriptions is the fleet SUBSCRIPTION view (/v1/admin/subscriptions) —
// every tenant's plan subscription: customer/org, plan, status, monthly-normalized
// MRR, and the current-period start/renews. SuperAdmin only (core.Guard).
//
// It reads the ONE shared warehouse (commerce.events) — the table the commerce
// analytics collector lands every subscription-lifecycle event in — over the SAME
// client (aiobject.DatastoreQuery) the o11y/compute lenses use, with ZERO per-org
// fan-out: one GROUP BY resolves each subscription's LATEST lifecycle state
// (argMax by timestamp), so the whole fleet is one query, not N per-org commerce
// reads. Honest by construction: no datastore connected or the collector's table
// not provisioned yet → the real empty list, never a fabricated tenant. The MRR is
// the monthly-normalized figure the emitter already computed (cents). Optional
// ?org= scopes to one tenant, ?status= filters the LATEST status, ?limit= caps.
package subscriptions
import (
"sort"
"strconv"
"strings"
aiobject "github.com/hanzoai/ai/object"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/admin/core"
"github.com/zap-proto/zip"
)
// defaultLimit caps the fleet subscription list when the caller sends none.
const defaultLimit = 500
// SubscriptionRow is one row of GET /v1/admin/subscriptions — a tenant's subscription at
// a glance, tagged with its owning org. MRR is USD cents; timestamps are RFC3339 strings.
type SubscriptionRow struct {
ID string `json:"id"`
Org string `json:"org"`
Display string `json:"display"`
User string `json:"user"`
Plan string `json:"plan"`
Status string `json:"status"`
MRRCents int64 `json:"mrrCents"`
Started string `json:"started"`
Renews string `json:"renews"`
}
// Subscriptions answers GET /v1/admin/subscriptions.
//
// GET /v1/admin/subscriptions?org=&status=&limit=
func Subscriptions(s *cloud.Service[core.State], c *zip.Ctx) error {
ctx := c.Context()
status := strings.ToLower(strings.TrimSpace(c.Query("status")))
wantOrg := strings.TrimSpace(c.Query("org"))
limit := parseLimit(c.Query("limit"))
// Honest-empty when the warehouse is not connected or the collector's events
// table is not provisioned yet (the emitter is still being wired).
if !core.BillingEventsReady(ctx) {
return core.OKList(c, []SubscriptionRow{}, 0)
}
rows, err := aiobject.DatastoreQuery(ctx, subscriptionsSQL())
if err != nil {
return core.Fail(c, "subscriptions query: "+err.Error())
}
all := subscriptionRowsFromRows(rows)
// Filter (latest status / org) then sort highest-MRR first, cap to limit.
out := make([]SubscriptionRow, 0, len(all))
for _, r := range all {
if wantOrg != "" && r.Org != wantOrg {
continue
}
if status != "" && strings.ToLower(r.Status) != status {
continue
}
out = append(out, r)
}
sort.Slice(out, func(i, j int) bool {
if out[i].MRRCents != out[j].MRRCents {
return out[i].MRRCents > out[j].MRRCents
}
return out[i].Started > out[j].Started
})
total := len(out)
if len(out) > limit {
out = out[:limit]
}
return core.OKList(c, out, total)
}
// subscriptionsSQL resolves each subscription's LATEST lifecycle state from
// commerce.events (argMax by timestamp). Static SQL over a closed event-name set
// (SQLInList of server constants) — no user input is interpolated, so it is
// injection-safe. The emitted properties carry the plan/status/mrr/period fields.
func subscriptionsSQL() string {
return "SELECT JSONExtractString(properties, 'subscription_id') AS id, " +
"argMax(organization_id, timestamp) AS org, " +
"argMax(distinct_id, timestamp) AS user, " +
"argMax(JSONExtractString(properties, 'plan_name'), timestamp) AS plan, " +
"argMax(JSONExtractString(properties, 'status'), timestamp) AS status, " +
"argMax(JSONExtractInt(properties, 'mrr_cents'), timestamp) AS mrr_cents, " +
"argMax(event, timestamp) AS last_event, " +
"min(timestamp) AS started, " +
"argMax(JSONExtractString(properties, 'period_end'), timestamp) AS renews " +
"FROM " + core.BillingEventsTable + " " +
"WHERE event IN (" + core.SQLInList(core.SubscriptionEvents) + ") " +
"AND JSONExtractString(properties, 'subscription_id') != '' " +
"GROUP BY id"
}
// subscriptionRowsFromRows maps the datastore rows onto []SubscriptionRow (pure).
// Display is the org slug — the warehouse holds no friendly name and admin does
// no per-org IAM fan-out here (honest, not fabricated). The final status folds
// the lifecycle: a subscription whose LATEST event is a cancel reads "canceled"
// regardless of the last-emitted status snapshot.
func subscriptionRowsFromRows(rows []map[string]any) []SubscriptionRow {
out := make([]SubscriptionRow, 0, len(rows))
for _, r := range rows {
org := core.CHStr(r["org"])
out = append(out, SubscriptionRow{
ID: core.CHStr(r["id"]),
Org: org,
Display: org,
User: core.CHStr(r["user"]),
Plan: core.CHStr(r["plan"]),
Status: foldStatus(core.CHStr(r["last_event"]), core.CHStr(r["status"])),
MRRCents: core.CHInt64(r["mrr_cents"]),
Started: core.CHTime(r["started"]),
Renews: core.CHStr(r["renews"]),
})
}
return out
}
// foldStatus resolves the effective status: a subscription whose latest event is
// a cancel is "canceled"; otherwise the last-emitted status snapshot (falling
// back to "active" when the emitter sent none).
func foldStatus(lastEvent, snapshot string) string {
if lastEvent == core.EvSubscriptionCanceled {
return "canceled"
}
if s := strings.TrimSpace(snapshot); s != "" {
return s
}
return "active"
}
// parseLimit clamps the fleet-list cap to [1,5000], defaulting to defaultLimit.
func parseLimit(s string) int {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n <= 0 {
return defaultLimit
}
if n > 5000 {
return 5000
}
return n
}
@@ -0,0 +1,91 @@
package subscriptions
import (
"strings"
"testing"
"time"
"github.com/hanzoai/cloud/clients/admin/core"
)
// TestSubscriptionRowsFromRows proves the warehouse-row → SubscriptionRow mapping
// (the JSON-shape contract) coerces the datastore driver's native types and folds
// the lifecycle status; display honestly mirrors the org slug (no fan-out).
func TestSubscriptionRowsFromRows(t *testing.T) {
started := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
rows := []map[string]any{
{ // active (mrr as driver int64), latest event renewed
"id": "sub_1", "org": "acme", "user": "hanzo/alice",
"plan": "Pro", "status": "active", "mrr_cents": int64(4900),
"last_event": core.EvSubscriptionRenewed, "started": started,
"renews": "2026-08-01T00:00:00Z",
},
{ // canceled wins over a stale "active" snapshot
"id": "sub_2", "org": "beta", "user": "hanzo/bob",
"plan": "Team", "status": "active", "mrr_cents": uint64(0),
"last_event": core.EvSubscriptionCanceled, "started": started,
"renews": "",
},
}
out := subscriptionRowsFromRows(rows)
if len(out) != 2 {
t.Fatalf("got %d rows, want 2", len(out))
}
r0 := out[0]
if r0.ID != "sub_1" || r0.Org != "acme" || r0.Display != "acme" || r0.User != "hanzo/alice" {
t.Fatalf("row0 identity wrong: %+v", r0)
}
if r0.Plan != "Pro" || r0.Status != "active" || r0.MRRCents != 4900 {
t.Fatalf("row0 plan/status/mrr wrong: %+v", r0)
}
if r0.Started != "2026-07-01T12:00:00Z" {
t.Fatalf("row0 started = %q", r0.Started)
}
if r0.Renews != "2026-08-01T00:00:00Z" {
t.Fatalf("row0 renews = %q", r0.Renews)
}
if out[1].Status != "canceled" {
t.Fatalf("row1 status = %q, want canceled (latest-event folds)", out[1].Status)
}
}
func TestFoldStatus(t *testing.T) {
if got := foldStatus(core.EvSubscriptionCanceled, "active"); got != "canceled" {
t.Fatalf("cancel fold = %q", got)
}
if got := foldStatus(core.EvSubscriptionRenewed, "trialing"); got != "trialing" {
t.Fatalf("snapshot passthrough = %q", got)
}
if got := foldStatus(core.EvSubscriptionCreated, ""); got != "active" {
t.Fatalf("empty-snapshot default = %q", got)
}
}
// TestSubscriptionsSQLInjectionSafe asserts the query is fully static over the
// closed event-name set — the warehouse table, no user-derived interpolation.
func TestSubscriptionsSQLInjectionSafe(t *testing.T) {
sql := subscriptionsSQL()
if !strings.Contains(sql, core.BillingEventsTable) {
t.Fatalf("query must read %s: %q", core.BillingEventsTable, sql)
}
for _, ev := range core.SubscriptionEvents {
if !strings.Contains(sql, "'"+ev+"'") {
t.Fatalf("query missing event %q", ev)
}
}
if strings.Contains(sql, "?") {
t.Fatalf("subscriptions state query takes no positional args: %q", sql)
}
}
func TestParseLimitBounds(t *testing.T) {
if parseLimit("") != defaultLimit || parseLimit("0") != defaultLimit || parseLimit("x") != defaultLimit {
t.Fatal("bad/empty limit must default")
}
if parseLimit("10") != 10 {
t.Fatal("valid limit must pass through")
}
if parseLimit("999999") != 5000 {
t.Fatal("limit must clamp to 5000")
}
}
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package featuregate
package admission
import (
"context"
@@ -31,7 +31,7 @@ import (
// approval is FAIL-OPEN: a user is approved unless properties.approvalStatus is
// EXACTLY "pending" (absent / "approved" / "rejected" all read approved via
// IsApproved). Only "pending" holds a user on the waitlist. Keeping the literal
// here (not importing IAM) keeps featuregate self-contained.
// here (not importing IAM) keeps admission self-contained.
const approvalStatusPending = "pending"
// approvedHeader is the FORWARD-PERFECT path: once IAM carries approvalStatus in
@@ -1,7 +1,7 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
// Licensed under the Apache License, Version 2.0.
package featuregate
package admission
import (
"context"
@@ -12,16 +12,24 @@
// See the License for the specific language governing permissions and
// limitations under the License.
// Package featuregate is the launch-control ENFORCEMENT for Hanzo's hosted services:
// the native middleware (Enforce) + the per-user approval predicate (Approvals, reused
// from IAM). It is a CONSUMER of the ONE policy engine — the per-service waitlist MODE
// and the host→service registry live in clients/flags (a service's mode IS the
// switch waitlist.<svc>, evaluated through the native engine); the admin board is the
// /v1/admin/services lens and the guard's runtime mode read is /v1/featuregate/mode,
// both served there. This package owns only enforcement, decomplected into two axes:
// Package admission is the launch-control GATE for Hanzo's hosted services — the
// COMPLETE waitlist feature, COMPOSING the ONE flag engine (clients/flags) one-way. It
// owns:
//
// - PER-SERVICE waitlist mode on|off — the flags switch waitlist.<svc>,
// resolved for a request host via flags.WaitlistModeForHost (the decide).
// - the host→service registry (registry.go) + the brand seed (waitlist.go),
// - the per-service MODE decide WaitlistModeForHost — a service's mode IS the switch
// waitlist.<svc>, evaluated through the flag engine (flags.Bool),
// - the admin control funcs (List/Set/Upsert) the /v1/admin/services board calls,
// - the guard's public mode read /v1/flags/waitlist, Mount,
// - the native enforcement middleware (Enforce, this file),
// - the per-user approval predicate (Approvals, reused from IAM — approval.go).
//
// flags NEVER imports admission; admission imports flags. The engine is the pure
// (Principal, context) -> verdict primitive; this package is its first composed tenant.
// Enforcement is decomplected into two orthogonal axes:
//
// - PER-SERVICE waitlist mode on|off — the switch waitlist.<svc>, resolved for a
// request host via WaitlistModeForHost (the decide, waitlist.go).
// - PER-USER approvalStatus pending|approved — owned by IAM (approval.go), REUSED.
//
// THE RULE, applied at ONE native enforcement point (Enforce):
@@ -29,14 +37,13 @@
// if waitlistMode[host] AND NOT user.approved → bounce to the waitlist
// if approved OR mode=off → allow
// unauthenticated → login first
package featuregate
package admission
import (
"context"
"net/http"
"strings"
"github.com/hanzoai/cloud/clients/flags"
"github.com/zap-proto/zip"
)
@@ -59,13 +66,13 @@ import (
// INTEGRATION POINT — wire in serve.go RIGHT AFTER SanitizeIdentity:
//
// app.Use(IdentityMiddleware(cfg)) // establishes the validated principal
// app.Use(featuregate.Enforce(featuregate.EnforceConfig{ WaitlistURL: … })) // ← here
// app.Use(admission.Enforce(admission.EnforceConfig{ WaitlistURL: … })) // ← here
//
// It reads the sanitized X-User-Id / X-User-IsAdmin / X-User-Approved that
// IdentityMiddleware minted, so it MUST run after it and (like BillingGate) before
// the subsystem handlers. It is deliberately NOT wired here — the unified-binary
// agent owns serve.go's boot chain; this package exposes Enforce so the one-line
// app.Use lands without a merge collision. The decide (flags.WaitlistModeForHost) is
// app.Use lands without a merge collision. The decide (WaitlistModeForHost) is
// resolved PER REQUEST and fail-opens until the flags engine has mounted, so Enforce
// can be constructed before Mount runs.
//
@@ -102,7 +109,7 @@ type EnforceConfig struct {
ExemptPrefixes []string
// Gate is THE decide: it resolves whether a request host is in waitlist mode,
// via the ONE policy engine. When nil it is flags.WaitlistModeForHost —
// via the ONE policy engine. When nil it is WaitlistModeForHost —
// host→service→waitlist.<svc>. Injected only in tests. Fail-open by contract:
// known=false (unmounted / registry error / un-governed host) → not gated.
Gate func(ctx context.Context, host string) (mode bool, service string, known bool)
@@ -112,9 +119,9 @@ type EnforceConfig struct {
// health, the auth/OIDC handshake, and the waitlist join API itself (so a gated
// user can still submit the waitlist form).
var defaultExemptPrefixes = []string{
"/v1/featuregate/", // the mode read + the health route
"/v1/iam/", // auth / OIDC / approval-status / get-account handshake
"/v1/waitlist", // the waitlist join API (a gated user must reach it)
"/v1/flags/waitlist", // the guard's public mode read (flags engine)
"/v1/iam/", // auth / OIDC / approval-status / get-account handshake
"/v1/waitlist", // the waitlist join API (a gated user must reach it)
"/health",
"/healthz",
"/__guard/", // the @file guard's own callback surface (defense in depth)
@@ -131,7 +138,7 @@ func Enforce(cfg EnforceConfig) zip.Handler {
}
gate := cfg.Gate
if gate == nil {
gate = flags.WaitlistModeForHost // the ONE decide: host→service→waitlist.<svc>
gate = WaitlistModeForHost // the ONE decide: host→service→waitlist.<svc>
}
exempt := cfg.ExemptPrefixes
if len(exempt) == 0 {
@@ -199,7 +206,7 @@ func bounce(c *zip.Ctx, waitlistURL string) error {
}
// apiKeyPrefixes are the Hanzo API-key prefixes. This MIRRORS cloud
// auth_identity.go isAPIKey (the ONE authority) — kept local so featuregate stays
// auth_identity.go isAPIKey (the ONE authority) — kept local so admission stays
// self-contained (no cloud-internal import) while agreeing on the exact contract:
// a token with one of these prefixes is a possession-gated API key, not a session
// principal. If cloud adds a prefix there, add it here.
@@ -1,7 +1,7 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
// Licensed under the Apache License, Version 2.0.
package featuregate
package admission
import (
"context"
@@ -16,7 +16,7 @@ import (
// testGate is the injected decide (the flags engine's WaitlistModeForHost seam):
// hanzo.chat is gated, api.hanzo.ai is open, everything else is un-governed. This is
// exactly what flags.WaitlistModeForHost returns for the equivalent registry, without
// exactly what WaitlistModeForHost returns for the equivalent registry, without
// standing up the native flag engine (cgo) in a middleware unit test.
func testGate(_ context.Context, host string) (mode bool, service string, known bool) {
switch host {
@@ -186,7 +186,7 @@ func TestRule_UngovernedHost_PassesThrough(t *testing.T) {
func TestRule_ExemptPaths_NeverGated(t *testing.T) {
app := gateApp(t, "pending")
for _, p := range []string{"/health", "/v1/iam/get-account", "/v1/waitlist/join", "/v1/featuregate/mode"} {
for _, p := range []string{"/health", "/v1/iam/get-account", "/v1/waitlist/join", "/v1/flags/waitlist"} {
code, _ := drive(t, app, greq{host: "hanzo.chat", path: p, user: "u", org: "acme", accept: html})
if code != 200 {
t.Fatalf("exempt path %q = %d, want 200 (never gated)", p, code)
@@ -205,7 +205,7 @@ func TestRule_ForwardHeaderApproved_ThroughWithoutLookup(t *testing.T) {
}
}
// The DEFAULT gate (nil Gate → flags.WaitlistModeForHost) fail-opens before the flags
// The DEFAULT gate (nil Gate → WaitlistModeForHost) fail-opens before the flag
// engine has mounted: with no engine, WaitlistModeForHost returns known=false for every
// host, so Enforce never gates pre-boot.
func TestEnforce_DefaultGate_FailsOpenPreBoot(t *testing.T) {
@@ -1,11 +1,11 @@
package flags
package admission
// The waitlist REGISTRY — the host→service map + service metadata folded in from
// the former clients/featuregate SQLite store. It is deliberately MODE-FREE: a
// service's waitlist mode is NOT a column here, it is the platform switch
// waitlist.<svc> evaluated through the ONE native engine (waitlist.go). This store
// answers only "which service owns this host, and what is its display metadata" —
// the config the decide needs, with the decision itself owned by the flag engine.
// The launch-registry — the host→service map + service display metadata. It is
// deliberately MODE-FREE: a service's waitlist mode is NOT a column here, it is the
// platform switch waitlist.<svc> evaluated through the ONE flag engine (clients/flags,
// composed one-way from waitlist.go). This store answers only "which service owns this
// host, and what is its display metadata" — the config the decide needs, with the
// decision itself owned by the flag engine.
//
// It rides the SAME per-(org,project) OrgDB machinery as the flag defs (opened via
// cloud.OrgStore, encrypted at rest via cek); the registry is PLATFORM-global, so it
@@ -21,7 +21,7 @@ import (
)
// ErrServiceNotFound is returned when a service slug is not in the registry.
var ErrServiceNotFound = errors.New("flags: waitlist service not found")
var ErrServiceNotFound = errors.New("admission: waitlist service not found")
// ServiceRow is one hosted service in the registry (host→service + metadata). The
// waitlist MODE is intentionally absent — it is the platform switch waitlist.<svc>,
@@ -45,7 +45,7 @@ type waitlistStore struct {
}
// openWaitlistStore migrates the registry schema over an already-opened (pragma'd,
// cek-wrapped) OrgDB handle — the same open contract as openStore for flag defs.
// cek-wrapped) OrgDB handle — the same open contract as flags' openStore for flag defs.
func openWaitlistStore(db *sql.DB) (*waitlistStore, error) {
const schema = `
CREATE TABLE IF NOT EXISTS wl_services (
@@ -64,7 +64,7 @@ CREATE TABLE IF NOT EXISTS wl_hosts (
CREATE INDEX IF NOT EXISTS ix_wl_hosts_service ON wl_hosts(service);
`
if _, err := db.Exec(schema); err != nil {
return nil, fmt.Errorf("flags: waitlist migrate: %w", err)
return nil, fmt.Errorf("admission: waitlist migrate: %w", err)
}
return &waitlistStore{db: db}, nil
}
@@ -210,7 +210,7 @@ func (s *waitlistStore) Get(ctx context.Context, service string) (ServiceRow, er
func (s *waitlistStore) Upsert(ctx context.Context, in ServiceRow, by string, now int64) (ServiceRow, error) {
svc := strings.ToLower(strings.TrimSpace(in.Service))
if svc == "" {
return ServiceRow{}, fmt.Errorf("flags: waitlist service slug required")
return ServiceRow{}, fmt.Errorf("admission: waitlist service slug required")
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
@@ -1,9 +1,9 @@
package flags
package admission
// Registry coverage for the folded host→service store. It drives the store over a raw
// sqlite handle (the same driver OrgDB uses), so it exercises the fold WITHOUT the cek
// Registry coverage for the host→service store. It drives the store over a raw sqlite
// handle (the same driver OrgDB uses), so it exercises the registry WITHOUT the cek
// at-rest layer — runnable under CGO=0. The MODE is out of scope here by design (it is
// the waitlist.<svc> switch, evaluated by the native engine, covered separately).
// the waitlist.<svc> switch, evaluated by the flag engine, covered separately).
import (
"context"
@@ -1,19 +1,23 @@
package flags
package admission
// The waitlist LENS on the ONE flag engine — the launch-control plane folded in from
// the former clients/featuregate. Decomplected into the two orthogonal axes it always
// was, now with a single decision plane:
// The launch-control gate — the COMPLETE waitlist feature, COMPOSING the ONE flag
// engine (clients/flags) one-way. Decomplected into the two orthogonal axes it always
// was, with a single decision plane:
//
// - MODE (per service): waitlist.<svc> IS a platform switch, evaluated through the
// SAME native engine as every other platform flag. There is no second mode store.
// - HOST MAP + metadata: the registry (waitlist_store.go) resolves a request host
// to the service whose switch governs it, and carries display metadata.
// flag engine (flags.Bool / flags.SetPlatformSwitch / flags.Register). There is no
// second mode store.
// - HOST MAP + metadata: the registry (registry.go) resolves a request host to the
// service whose switch governs it, and carries display metadata.
//
// The decide is WaitlistModeForHost(host) → (mode, service, known): resolve host→svc,
// then read waitlist.<svc>. featuregate.Enforce is now a CONSUMER of this decide, and
// /v1/featuregate/mode + the /v1/admin/services board read it too. Per-user approval
// (pending|approved) stays IAM's (featuregate/approval.go) — the second, orthogonal
// axis, unchanged.
// then read waitlist.<svc>. Enforce (middleware.go) consumes this decide; the admin
// board (/v1/admin/services) and the guard's runtime mode read (/v1/flags/waitlist,
// served here) read it too. Per-user approval (pending|approved) is the second,
// orthogonal axis — IAM's, in approval.go.
//
// flags NEVER imports this package; this package imports flags. That one-way arrow is
// the whole point of the decomplection: the engine is pure, the feature composes it.
import (
"context"
@@ -25,10 +29,29 @@ import (
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/flags"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// The reserved platform tenant the launch registry rides in — the SAME reserved
// (org, project) the flag engine uses for its platform switches, so the registry and
// the waitlist.<svc> switches co-locate. One waitlist.db for the deployment.
const (
platformOrg = "platform"
platformProject = "platform"
)
// registryState is admission's process-wide launch state: the platform-tenant
// host→service registry store + the deployment brand it was seeded for. Installed by
// Mount, torn down by Shutdown.
type registryState struct {
store *cloud.OrgStore[*waitlistStore]
brand string
}
var mounted *registryState
// SeedService is one row of the launch registry (a hosted service + its hosts). Mode
// is intentionally absent — the launch posture (gated) is waitlistDef's Default "true".
type SeedService struct {
@@ -60,17 +83,17 @@ func waitlistKey(svc string) string { return "waitlist." + strings.ToLower(strin
// waitlistDef is the platform switch for one service's mode. Default "true" = the
// launch posture (gated until an admin opens it), so a deployment with no stored flag
// behaves exactly as the old featuregate seed (waitlistMode ON).
func waitlistDef(svc, display string) Def {
// behaves exactly as the old admission seed (waitlistMode ON).
func waitlistDef(svc, display string) flags.Def {
if strings.TrimSpace(display) == "" {
display = svc
}
return Def{
return flags.Def{
Key: waitlistKey(svc),
Category: "Launch",
Label: "Waitlist · " + display,
Desc: "Waitlist mode for " + display + ": ON gates the service to APPROVED users; OFF opens it.",
Type: TypeBool,
Type: flags.TypeBool,
Default: "true",
}
}
@@ -78,9 +101,13 @@ func waitlistDef(svc, display string) Def {
// ensureWaitlistDef registers a service's switch if it is not already registered
// (Mount registers the seed set with nicer labels; this covers runtime onboards).
func ensureWaitlistDef(svc, display string) {
if _, ok := lookupDef(waitlistKey(svc)); !ok {
Register(waitlistDef(svc, display))
key := waitlistKey(svc)
for _, d := range flags.Defs() {
if d.Key == key {
return
}
}
flags.Register(waitlistDef(svc, display))
}
// boolDef is the minimal PostHog flag definition for a boolean switch value.
@@ -92,26 +119,24 @@ func boolDef(on bool) json.RawMessage {
}
// requireRegistry resolves the platform-tenant registry store, or an error when the
// engine is not mounted (writes need it; the decide fail-opens instead).
// gate is not mounted (writes need it; the decide fail-opens instead).
func requireRegistry() (*waitlistStore, error) {
c := mounted
if c == nil || c.registry == nil {
return nil, fmt.Errorf("flags: waitlist registry not mounted")
if mounted == nil || mounted.store == nil {
return nil, fmt.Errorf("admission: waitlist registry not mounted")
}
return c.registry.For(platformOrg, platformProject)
return mounted.store.For(platformOrg, platformProject)
}
// WaitlistModeForHost is THE decide the Enforce consumer, /v1/featuregate/mode, and
// WaitlistModeForHost is THE decide the Enforce consumer, /v1/flags/waitlist, and
// the admin board call: resolve host→service, then read the waitlist.<svc> switch
// through the engine. FAIL-OPEN by construction — an unmounted registry, a store
// through the flag engine. FAIL-OPEN by construction — an unmounted registry, a store
// error, or an un-governed host all return known=false, so a request is NEVER gated
// pre-boot or on a registry fault (availability over a hard gate, matching the guard).
func WaitlistModeForHost(ctx context.Context, host string) (mode bool, service string, known bool) {
c := mounted
if c == nil || c.registry == nil {
if mounted == nil || mounted.store == nil {
return false, "", false
}
st, err := c.registry.For(platformOrg, platformProject)
st, err := mounted.store.For(platformOrg, platformProject)
if err != nil {
return false, "", false
}
@@ -119,7 +144,7 @@ func WaitlistModeForHost(ctx context.Context, host string) (mode bool, service s
if err != nil || !known {
return false, "", false
}
return Bool(waitlistKey(svc)), svc, true
return flags.Bool(waitlistKey(svc)), svc, true
}
// ListWaitlistServices returns the admin board: every registered service with its LIVE
@@ -135,19 +160,19 @@ func ListWaitlistServices(ctx context.Context) ([]ServiceView, error) {
}
out := make([]ServiceView, 0, len(rows))
for _, r := range rows {
out = append(out, ServiceView{ServiceRow: r, WaitlistMode: Bool(waitlistKey(r.Service))})
out = append(out, ServiceView{ServiceRow: r, WaitlistMode: flags.Bool(waitlistKey(r.Service))})
}
return out, nil
}
// SetWaitlistMode flips one service's waitlist switch — the launch lever — and returns
// the updated view. It is the ONE write path (through SetPlatformSwitch, audited in the
// flag activity log); the flip is hot (this pod applies immediately, peers converge
// within the eval TTL). ErrServiceNotFound when the slug is unknown.
// the updated view. It is the ONE write path (through flags.SetPlatformSwitch, audited
// in the flag activity log); the flip is hot (this pod applies immediately, peers
// converge within the eval TTL). ErrServiceNotFound when the slug is unknown.
func SetWaitlistMode(ctx context.Context, service string, mode bool, actor string) (ServiceView, error) {
service = strings.ToLower(strings.TrimSpace(service))
if service == "" {
return ServiceView{}, fmt.Errorf("flags: service is required")
return ServiceView{}, fmt.Errorf("admission: service is required")
}
st, err := requireRegistry()
if err != nil {
@@ -158,10 +183,10 @@ func SetWaitlistMode(ctx context.Context, service string, mode bool, actor strin
return ServiceView{}, err
}
ensureWaitlistDef(service, row.DisplayName)
if err := SetPlatformSwitch(waitlistKey(service), boolDef(mode), actor); err != nil {
if err := flags.SetPlatformSwitch(waitlistKey(service), boolDef(mode), actor); err != nil {
return ServiceView{}, err
}
return ServiceView{ServiceRow: row, WaitlistMode: Bool(waitlistKey(service))}, nil
return ServiceView{ServiceRow: row, WaitlistMode: flags.Bool(waitlistKey(service))}, nil
}
// UpsertWaitlistService onboards or edits a hosted service so a new host is governed
@@ -170,7 +195,7 @@ func SetWaitlistMode(ctx context.Context, service string, mode bool, actor strin
func UpsertWaitlistService(ctx context.Context, in ServiceInput, actor string) (ServiceView, error) {
svc := strings.ToLower(strings.TrimSpace(in.Service))
if svc == "" {
return ServiceView{}, fmt.Errorf("flags: service slug is required")
return ServiceView{}, fmt.Errorf("admission: service slug is required")
}
st, err := requireRegistry()
if err != nil {
@@ -192,43 +217,44 @@ func UpsertWaitlistService(ctx context.Context, in ServiceInput, actor string) (
}
ensureWaitlistDef(svc, row.DisplayName)
if isNew {
if err := SetPlatformSwitch(waitlistKey(svc), boolDef(in.WaitlistMode), actor); err != nil {
if err := flags.SetPlatformSwitch(waitlistKey(svc), boolDef(in.WaitlistMode), actor); err != nil {
return ServiceView{}, err
}
}
return ServiceView{ServiceRow: row, WaitlistMode: Bool(waitlistKey(svc))}, nil
return ServiceView{ServiceRow: row, WaitlistMode: flags.Bool(waitlistKey(svc))}, nil
}
// mountWaitlist seeds the registry and registers a waitlist.<svc> switch per known
// service. Best-effort + fail-safe: a registry error (e.g. cek master key not yet
// injected) degrades to the in-memory seed switches — the decide then fail-opens,
// exactly the flag engine's own boot posture. Called from Mount.
func mountWaitlist(c *Client, brand string, log luxlog.Logger) {
// seedRegistry seeds the registry and registers a waitlist.<svc> switch per known
// service, COMPOSING the flag engine (flags.Register). Best-effort + fail-safe: a
// registry error (e.g. cek master key not yet injected) degrades to the in-memory seed
// switches — the decide then fail-opens, exactly the flag engine's own boot posture.
// Returns the number of seeded services (for the mount log). Called from Mount.
func seedRegistry(brand string, log luxlog.Logger) int {
seed := seedWaitlist(brand)
for _, sv := range seed { // in-memory switches — always succeeds
Register(waitlistDef(sv.Service, sv.DisplayName))
flags.Register(waitlistDef(sv.Service, sv.DisplayName))
}
st, err := c.registry.For(platformOrg, platformProject)
st, err := mounted.store.For(platformOrg, platformProject)
if err != nil {
log.Warn("waitlist registry unavailable — modes degrade to seed defaults", "err", err)
return
return len(seed)
}
if _, err := st.Seed(context.Background(), seed, time.Now().Unix()); err != nil {
log.Warn("waitlist registry seed failed", "err", err)
return
return len(seed)
}
if rows, err := st.List(context.Background()); err == nil {
for _, r := range rows { // register any persisted onboard beyond the seed
ensureWaitlistDef(r.Service, r.DisplayName)
}
}
return len(seed)
}
// waitlistModeRoute answers GET /v1/featuregate/mode?host=<h> — the runtime lookup the
// waitlistModeRoute answers GET /v1/flags/waitlist?host=<h> — the runtime lookup the
// @file waitlist-guard caches. Public (in-cluster) read: it returns ONLY the boolean
// mode for the ONE queried host, never an enumeration. Same wire shape as the former
// featuregate route, so the interim guard ports 1:1.
func waitlistModeRoute(_ *cloud.Service[state], c *zip.Ctx) error {
// mode for the ONE queried host, never an enumeration.
func waitlistModeRoute(c *zip.Ctx) error {
host := strings.TrimSpace(c.Query("host"))
if host == "" {
host = c.Fiber().Hostname()
@@ -242,7 +268,44 @@ func waitlistModeRoute(_ *cloud.Service[state], c *zip.Ctx) error {
})
}
// ── brand seed (moved verbatim from the former featuregate/seed.go) ──────────────
// ── lifecycle ────────────────────────────────────────────────────────────────
// Mount installs the launch-control gate: it opens the platform-tenant host→service
// registry, seeds it for the deployment brand, registers a waitlist.<svc> switch per
// service in the flag engine (flags.Register), and serves the guard's public mode read
// at /v1/flags/waitlist. Fail-safe: a registry error (e.g. cek master key not yet
// injected) degrades to the in-memory seed switches — WaitlistModeForHost then
// fail-opens. Mounts AFTER flags so the engine's platform-switch plane is installed first.
func Mount(app *zip.App, deps cloud.Deps) error {
if deps.Logger == nil {
return fmt.Errorf("admission.Mount: nil deps.Logger")
}
if deps.DataDir == "" {
return fmt.Errorf("admission.Mount: empty deps.DataDir")
}
log := deps.Logger.New("subsystem", "admission")
mounted = &registryState{
store: cloud.NewOrgStore[*waitlistStore](deps.DataDir, "waitlist", openWaitlistStore),
brand: deps.Brand,
}
n := seedRegistry(deps.Brand, log)
// The guard's public runtime mode read (host→service→waitlist.<svc>), one namespace
// under /v1/flags. Exempt from the Enforce gate (see defaultExemptPrefixes) so a
// gated user can still resolve mode.
app.Get("/v1/flags/waitlist", waitlistModeRoute)
log.Info("admission gate ready", "services", n)
return nil
}
// Shutdown closes the launch registry's per-org store handles.
func Shutdown() error {
if mounted == nil || mounted.store == nil {
return nil
}
return mounted.store.CloseAll()
}
// ── brand seed (moved verbatim from the former flags/waitlist.go) ────────────────
// seedWaitlist returns the launch registry for a brand. White-labeled so a Lux/Zoo/Pars
// deployment governs its OWN hosts. New hosted services onboard at runtime via
+68 -8
View File
@@ -32,6 +32,7 @@ package ads
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"net/http"
"os"
@@ -106,13 +107,15 @@ func Mount(app *zip.App, deps cloud.Deps) error {
// routes registers the ads surface: the campaign CRUD + the summary roll-up.
func routes(app *zip.App, s *cloud.Service[state]) {
app.Get("/v1/ads/summary", cloud.Handle(s, summary))
g := app.Group("/v1/ads")
g.Get("/summary", cloud.Handle(s, summary))
app.Get("/v1/ads/campaigns", cloud.Handle(s, listCampaigns))
app.Post("/v1/ads/campaigns", cloud.Handle(s, createCampaign))
app.Get("/v1/ads/campaigns/:id", cloud.Handle(s, getCampaign))
app.Put("/v1/ads/campaigns/:id", cloud.Handle(s, updateCampaign))
app.Delete("/v1/ads/campaigns/:id", cloud.Handle(s, deleteCampaign))
g.Get("/campaigns", cloud.Handle(s, listCampaigns))
g.Post("/campaigns", cloud.Handle(s, createCampaign))
g.Get("/campaigns/:id", cloud.Handle(s, getCampaign))
g.Put("/campaigns/:id", cloud.Handle(s, updateCampaign))
g.Delete("/campaigns/:id", cloud.Handle(s, deleteCampaign))
g.Post("/campaigns/:id/launch", cloud.Handle(s, launchCampaignHandler))
}
// ---- shared helpers (mirror clients/crm) ----
@@ -223,7 +226,7 @@ func createCampaign(s *cloud.Service[state], c *zip.Ctx) error {
}
now := time.Now().Unix()
camp := Campaign{
ID: id, Org: org, Name: name, Platform: platform, Status: status,
ID: id, Org: org, Name: name, Platform: platform, Account: clip(body.Account), Status: status,
Objective: clip(body.Objective), Budget: nonNeg(body.Budget), Spend: nonNeg(body.Spend),
CreatedAt: now, UpdatedAt: now,
}
@@ -281,7 +284,7 @@ func updateCampaign(s *cloud.Service[state], c *zip.Ctx) error {
return zip.ErrBadRequest("status must be one of draft, active, paused, completed")
}
camp := Campaign{
ID: idParam(c), Org: org, Name: name, Platform: platform, Status: status,
ID: idParam(c), Org: org, Name: name, Platform: platform, Account: clip(body.Account), Status: status,
Objective: clip(body.Objective), Budget: nonNeg(body.Budget), Spend: nonNeg(body.Spend),
UpdatedAt: time.Now().Unix(),
}
@@ -307,6 +310,63 @@ func deleteCampaign(s *cloud.Service[state], c *zip.Ctx) error {
return c.NoContent(http.StatusNoContent)
}
// ---- launch (consumes the connector plane) ----
// launchCampaignHandler runs a stored ad campaign on its provider using the ORG'S
// connected ad-account token. It is the standalone proof that /v1/ads consumes the
// connector plane: no token is held here — LaunchPaid (provider.go) resolves it
// from KMS through integrations.TokenFor and FAILS CLOSED when the org has not
// connected the platform (424), so a launch can never spend on a connection the
// org did not make. On success the provider campaign id is recorded (MarkLaunched)
// and the campaign goes active. An optional body {account} sets/overrides the
// target ad account when the stored campaign has none.
func launchCampaignHandler(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
camp, err := s.State.store.GetCampaign(c.Context(), org, idParam(c))
if err != nil {
return mapErr(err, "campaign not found")
}
var body struct {
Account string `json:"account"`
}
_ = c.Bind(&body)
account := clip(body.Account)
if account == "" {
account = camp.Account
}
ref, lerr := LaunchPaid(c.Context(), org, PaidPlan{
Platform: camp.Platform, Account: account, Name: camp.Name,
Objective: camp.Objective, BudgetCents: camp.Budget,
})
if lerr != nil {
return mapProviderErr(lerr)
}
saved, err := s.State.store.MarkLaunched(c.Context(), org, camp.ID, ref.Account, ref.ExternalID, time.Now().Unix())
if err != nil {
return mapErr(err, "campaign not found")
}
return c.JSON(http.StatusOK, saved)
}
// mapProviderErr renders a provider-execution error as the honest HTTP status: a
// missing/rejected connection is 424 (connect the account first), an unwired
// platform is 501, a transient edge failure is 502.
func mapProviderErr(err error) error {
switch {
case errors.Is(err, errNotConnected):
return zip.Errorf(http.StatusFailedDependency, "connect your ad account for this platform first")
case errors.Is(err, errUnsupportedPlatform):
return zip.Errorf(http.StatusNotImplemented, "%v", err)
case errors.Is(err, errUpstream):
return zip.Errorf(http.StatusBadGateway, "%v", err)
default:
return zip.Errorf(http.StatusBadRequest, "%v", err)
}
}
// ---- summary ----
func summary(s *cloud.Service[state], c *zip.Ctx) error {
+115
View File
@@ -0,0 +1,115 @@
package ads
import (
"context"
"errors"
"net/http"
"testing"
"github.com/hanzoai/cloud/clients/campaign"
)
// campaign_paid_test.go is the END-TO-END proof of the paid GTM channel: the SAME
// adapter apps/wire_seams.go registers (campaign.Plan → ads.PaidPlan → LaunchPaid)
// driven through the campaign.Channel interface, so the whole chain
// campaign → ads → integrations.TokenFor → provider is exercised against an
// httptest Meta stub. It lives in package ads because only this package can point
// the connector-custody seam (tokenFor) and the Meta base at test doubles;
// importing clients/campaign here is acyclic (campaign never imports ads).
// paidChannelForTest builds the exact paid-channel adapter the composition root
// wires, so the test exercises the real seam shape, not a bespoke one.
func paidChannelForTest() campaign.Channel {
return campaign.NewChannel(campaign.KindPaid,
func(ctx context.Context, org string, p campaign.Plan) (campaign.Ref, error) {
r, err := LaunchPaid(ctx, org, PaidPlan{
Platform: p.Platform, Account: p.Account, Name: p.Name,
Objective: p.Objective, BudgetCents: p.BudgetCents, ScheduleAt: p.ScheduleAt,
})
return campaign.Ref{Platform: r.Platform, Account: r.Account, ExternalID: r.ExternalID, Status: r.Status, Detail: r.Detail}, err
},
func(ctx context.Context, org string, ref campaign.Ref) (int64, error) {
return PaidSpend(ctx, org, PaidRef{Platform: ref.Platform, Account: ref.Account, ExternalID: ref.ExternalID})
},
func(ctx context.Context, org string, ref campaign.Ref) error {
return PausePaid(ctx, org, PaidRef{Platform: ref.Platform, Account: ref.Account, ExternalID: ref.ExternalID})
},
)
}
// TestCampaignPaidChannel_EndToEnd: a campaign's paid channel launches an ad
// campaign on Meta using the ORG'S connected token, then reads spend — the full
// capability→connector→provider chain.
func TestCampaignPaidChannel_EndToEnd(t *testing.T) {
stubToken(t, func(_ context.Context, org, provider, _ string) ([]byte, error) {
if provider != "meta_ads" {
t.Fatalf("paid meta channel must consume meta_ads, got %q", provider)
}
return []byte("tok-" + org), nil
})
var gotAuth string
stubMeta(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/act_123/campaigns" {
gotAuth = r.Header.Get("Authorization")
_, _ = w.Write([]byte(`{"id":"120210000000009"}`))
return
}
if r.URL.Path == "/120210000000009/insights" {
_, _ = w.Write([]byte(`{"data":[{"spend":"7.50"}]}`))
return
}
http.NotFound(w, r)
})
paid := paidChannelForTest()
if paid.Kind() != campaign.KindPaid {
t.Fatalf("channel kind want paid, got %q", paid.Kind())
}
ref, err := paid.Launch(context.Background(), "acme", campaign.Plan{
CampaignID: "cmp_1", Platform: "meta", Account: "123", Name: "GTM Launch",
})
if err != nil {
t.Fatalf("paid Launch: %v", err)
}
if ref.ExternalID != "120210000000009" || ref.Status != "live" {
t.Fatalf("ref: %+v", ref)
}
if gotAuth != "Bearer tok-acme" {
t.Fatalf("Meta must be called with acme's connector token, got %q", gotAuth)
}
// Spend read composes the same token door → provider insights.
cents, err := paid.Spend(context.Background(), "acme", ref)
if err != nil {
t.Fatalf("paid Spend: %v", err)
}
if cents != 750 {
t.Fatalf("spend want 750 cents, got %d", cents)
}
}
// TestCampaignPaidChannel_ConnectorDisabledBlocksSend: a campaign whose org has
// not connected the ad account cannot launch — the channel fails closed and the
// provider is never called (no spend on an unmade connection).
func TestCampaignPaidChannel_ConnectorDisabledBlocksSend(t *testing.T) {
stubToken(t, func(_ context.Context, _, _, _ string) ([]byte, error) {
return nil, errors.New("integrations: meta_ads not connected for org")
})
hit := false
stubMeta(t, func(w http.ResponseWriter, _ *http.Request) {
hit = true
_, _ = w.Write([]byte(`{"id":"nope"}`))
})
paid := paidChannelForTest()
_, err := paid.Launch(context.Background(), "acme", campaign.Plan{
CampaignID: "cmp_1", Platform: "meta", Account: "123", Name: "GTM",
})
if !errors.Is(err, errNotConnected) {
t.Fatalf("want errNotConnected, got %v", err)
}
if hit {
t.Fatalf("provider must NOT be called when the org's connector is disabled")
}
}
+320
View File
@@ -0,0 +1,320 @@
package ads
// provider.go is the ad-network EXECUTION edge: the ONE place /v1/ads consumes the
// connector plane. An ad campaign runs on a provider (Meta/Google/…) using the
// ORG'S OWN connector token — resolved at call time from KMS through the
// integrations.TokenFor custody seam, never held in this process, never in a
// manifest. This closes the gap the ads store left open: a stored Campaign is now
// LAUNCHABLE against the real provider, and it is what the /v1/campaign paid
// channel fans out to (apps/wire_seams.go adapts LaunchPaid/PaidSpend/PausePaid
// onto campaign.Channel).
//
// FAIL-CLOSED CUSTODY. Every operation resolves the org's token FIRST; any reason
// it cannot be produced — the org never connected the ad account, the integrations
// plane is unmounted, KMS is down — is errNotConnected, and NO provider call is
// made. The token rides the Authorization header only (never the URL, never a log,
// never argv), exactly the meta.go connector discipline.
//
// LEAST SURPRISE ON SPEND. A launch creates the campaign OBJECT on the provider;
// delivery (and therefore spend) does not begin until the ad-set/ad legs are wired
// (the documented ads follow-up), so a launch cannot silently burn budget. Spend is
// READ back from the provider's insights (PaidSpend) — the connector's reported
// number the /v1/campaign metrics plane joins with the analytics funnel.
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/hanzoai/cloud/clients/integrations"
)
const (
// accessTokenSecret is the KMS secret name every ad connector custodies its
// long-lived token under (meta.go/google_marketing.go: "access_token").
accessTokenSecret = "access_token"
// launchTimeout bounds a single provider call so a slow ad edge never wedges a
// launch or a metrics read.
launchTimeout = 20 * time.Second
)
// platformConnector maps an ads Platform to its integrations connector id. The
// paid channel consumes the connector via TokenFor(org, <id>, "access_token").
// A platform with no entry has no connector wired → errUnsupportedPlatform (the
// token is never even sought), so the map is the ONE source of "which ad networks
// this deployment can run".
var platformConnector = map[string]string{
"meta": "meta_ads",
"google": "google_ads",
"tiktok": "tiktok_ads",
"reddit": "reddit_ads",
"linkedin": "linkedin_ads",
"microsoft": "microsoft_ads",
}
var (
// errNotConnected — the org has no usable connection for the platform (no
// token, or the provider rejected it). Fail-closed: no spend, no fabrication.
errNotConnected = errors.New("ads: ad account not connected for org")
// errUnsupportedPlatform — a valid platform whose provider execution is not yet
// wired (the connector may be connected; only the create/read impl is missing).
errUnsupportedPlatform = errors.New("ads: paid execution not wired for platform")
// errUpstream — the ad platform edge failed transiently (network / non-auth non-2xx).
errUpstream = errors.New("ads: ad platform edge unavailable")
)
// tokenFor is the connector-custody seam. It defaults to integrations.TokenFor
// (the ONE KMS-backed token door) and is a package var ONLY so a test can exercise
// the provider path — the same reason meta.go's endpoints are package vars. It is
// never reassigned in production.
var tokenFor = integrations.TokenFor
// metaAdsBase is the Meta Graph API base. A package var so a test points create /
// insights / pause at an httptest server; never mutated in production.
var metaAdsBase = "https://graph.facebook.com/v21.0"
// adHTTP is the ONE bounded client for provider calls.
var adHTTP = &http.Client{Timeout: launchTimeout}
// PaidPlan is the standalone contract for launching one ad campaign — the campaign
// paid channel adapts campaign.Plan onto it (apps/wire_seams.go). BudgetCents is
// the org's own (connector-paid) budget; ScheduleAt an optional start time.
type PaidPlan struct {
Platform string
Account string // provider ad-account ref (Meta: act_<id> or <id>)
Name string
Objective string // provider objective enum; defaulted per-provider when empty
BudgetCents int64
ScheduleAt int64
}
// PaidRef is a launched ad campaign's durable handle: the provider-side id plus the
// platform/account needed to read spend or pause it.
type PaidRef struct {
Platform string
Account string
ExternalID string
Status string
Detail string
}
// adToken resolves (connectorID, token) for a platform, fail-closed. An unmapped
// platform never reaches KMS. An empty/absent token is errNotConnected.
func adToken(ctx context.Context, org, platform string) (string, string, error) {
connectorID, ok := platformConnector[strings.ToLower(strings.TrimSpace(platform))]
if !ok {
return "", "", fmt.Errorf("%w: %s", errUnsupportedPlatform, platform)
}
tok, err := tokenFor(ctx, org, connectorID, accessTokenSecret)
if err != nil || len(strings.TrimSpace(string(tok))) == 0 {
return connectorID, "", errNotConnected
}
return connectorID, strings.TrimSpace(string(tok)), nil
}
// LaunchPaid creates the ad campaign on its provider using the org's connector
// token. Fail-closed: it resolves the token BEFORE any provider call. Meta is
// executed for real; other connected platforms return errUnsupportedPlatform
// (the connector is verified, only the provider impl is the remaining gap).
func LaunchPaid(ctx context.Context, org string, p PaidPlan) (PaidRef, error) {
platform := strings.ToLower(strings.TrimSpace(p.Platform))
connectorID, token, err := adToken(ctx, org, platform)
if err != nil {
return PaidRef{}, err
}
switch platform {
case "meta":
return metaCreateCampaign(ctx, token, p)
default:
return PaidRef{}, fmt.Errorf("%w: %s (connector %s connected)", errUnsupportedPlatform, platform, connectorID)
}
}
// PaidSpend reads the provider-reported spend (minor units) for a launched ad
// campaign. Fail-closed on the token; honest 0 when the platform is unsupported.
func PaidSpend(ctx context.Context, org string, ref PaidRef) (int64, error) {
platform := strings.ToLower(strings.TrimSpace(ref.Platform))
_, token, err := adToken(ctx, org, platform)
if err != nil {
return 0, err
}
switch platform {
case "meta":
return metaCampaignSpend(ctx, token, ref.ExternalID)
default:
return 0, fmt.Errorf("%w: %s", errUnsupportedPlatform, platform)
}
}
// PausePaid pauses a launched ad campaign on its provider. Fail-closed on the token.
func PausePaid(ctx context.Context, org string, ref PaidRef) error {
platform := strings.ToLower(strings.TrimSpace(ref.Platform))
_, token, err := adToken(ctx, org, platform)
if err != nil {
return err
}
switch platform {
case "meta":
return metaPauseCampaign(ctx, token, ref.ExternalID)
default:
return fmt.Errorf("%w: %s", errUnsupportedPlatform, platform)
}
}
// ── Meta (Facebook/Instagram) ad campaign execution ─────────────────────────
// metaErr is Meta's Graph API error envelope.
type metaErr struct {
Message string `json:"message"`
Type string `json:"type"`
Code int `json:"code"`
}
// metaAcct normalizes a Meta ad-account ref to the act_<id> form the campaigns
// edge expects. Empty stays empty (caller rejects).
func metaAcct(account string) string {
a := strings.TrimSpace(account)
if a == "" {
return ""
}
if strings.HasPrefix(a, "act_") {
return a
}
return "act_" + a
}
// metaCreateCampaign POSTs a campaign to /act_<id>/campaigns. Objective defaults to
// OUTCOME_TRAFFIC; special_ad_categories is the required empty set; the object is
// created ACTIVE but does not deliver (no ad sets) so no spend starts on launch.
func metaCreateCampaign(ctx context.Context, token string, p PaidPlan) (PaidRef, error) {
account := metaAcct(p.Account)
if account == "" {
return PaidRef{}, fmt.Errorf("meta: ad account (account) is required")
}
objective := strings.TrimSpace(p.Objective)
if objective == "" {
objective = "OUTCOME_TRAFFIC"
}
form := url.Values{
"name": {strings.TrimSpace(p.Name)},
"objective": {objective},
"status": {"ACTIVE"},
"special_ad_categories": {"[]"},
}
var out struct {
ID string `json:"id"`
Error *metaErr `json:"error"`
}
if err := metaPost(ctx, metaAdsBase+"/"+account+"/campaigns", token, form, &out); err != nil {
return PaidRef{}, err
}
if out.Error != nil {
return PaidRef{}, fmt.Errorf("meta create campaign: %s", out.Error.Message)
}
if strings.TrimSpace(out.ID) == "" {
return PaidRef{}, fmt.Errorf("meta create campaign returned no id")
}
return PaidRef{Platform: "meta", Account: account, ExternalID: out.ID, Status: "live"}, nil
}
// metaCampaignSpend reads spend from /{campaignID}/insights?fields=spend. Meta
// reports spend in the account currency's major unit as a string; it is converted
// to minor units (cents). No insights row (campaign hasn't spent) → 0.
func metaCampaignSpend(ctx context.Context, token, campaignID string) (int64, error) {
if strings.TrimSpace(campaignID) == "" {
return 0, nil
}
var out struct {
Data []struct {
Spend string `json:"spend"`
} `json:"data"`
Error *metaErr `json:"error"`
}
if err := metaGet(ctx, metaAdsBase+"/"+campaignID+"/insights?fields=spend", token, &out); err != nil {
return 0, err
}
if out.Error != nil {
return 0, fmt.Errorf("meta insights: %s", out.Error.Message)
}
if len(out.Data) == 0 {
return 0, nil
}
dollars, err := strconv.ParseFloat(strings.TrimSpace(out.Data[0].Spend), 64)
if err != nil || dollars < 0 {
return 0, nil
}
return int64(dollars*100 + 0.5), nil
}
// metaPauseCampaign POSTs status=PAUSED to /{campaignID}.
func metaPauseCampaign(ctx context.Context, token, campaignID string) error {
if strings.TrimSpace(campaignID) == "" {
return fmt.Errorf("meta: campaign id required")
}
var out struct {
Success bool `json:"success"`
Error *metaErr `json:"error"`
}
if err := metaPost(ctx, metaAdsBase+"/"+campaignID, token, url.Values{"status": {"PAUSED"}}, &out); err != nil {
return err
}
if out.Error != nil {
return fmt.Errorf("meta pause: %s", out.Error.Message)
}
return nil
}
// ── bounded HTTP (token on the Authorization header only) ────────────────────
func metaPost(ctx context.Context, endpoint, token string, form url.Values, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return fmt.Errorf("%w: %v", errUpstream, err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return adDo(req, out)
}
func metaGet(ctx context.Context, endpoint, token string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return fmt.Errorf("%w: %v", errUpstream, err)
}
req.Header.Set("Authorization", "Bearer "+token)
return adDo(req, out)
}
// adDo runs a bounded provider request and decodes the JSON body. A 401/403 is
// fail-closed as errNotConnected (the org's token is invalid/revoked — the
// connection is not usable); any other non-2xx or transport error is errUpstream.
// The body is bounded so a hostile edge cannot amplify memory.
func adDo(req *http.Request, out any) error {
resp, err := adHTTP.Do(req)
if err != nil {
return fmt.Errorf("%w: %v", errUpstream, err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return errNotConnected
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("%w: %d", errUpstream, resp.StatusCode)
}
if out == nil {
return nil
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("%w: decode: %v", errUpstream, err)
}
return nil
}
+210
View File
@@ -0,0 +1,210 @@
package ads
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// stubToken overrides the connector-custody seam so the provider path is
// exercised without standing up KMS + integrations. Restored on cleanup.
func stubToken(t *testing.T, fn func(ctx context.Context, org, provider, name string) ([]byte, error)) {
t.Helper()
prev := tokenFor
tokenFor = fn
t.Cleanup(func() { tokenFor = prev })
}
// stubMeta points the Meta base at an httptest server (restored on cleanup) and
// returns the server so the test can inspect what the provider received.
func stubMeta(t *testing.T, h http.HandlerFunc) *httptest.Server {
t.Helper()
srv := httptest.NewServer(h)
prev := metaAdsBase
metaAdsBase = srv.URL
t.Cleanup(func() { metaAdsBase = prev; srv.Close() })
return srv
}
// TestLaunchPaid_MetaCreatesViaConnectorToken is the proof that /v1/ads consumes
// the connector plane: LaunchPaid resolves the ORG'S token via the TokenFor seam
// and creates the campaign on Meta with that token on the Authorization header.
func TestLaunchPaid_MetaCreatesViaConnectorToken(t *testing.T) {
var (
gotAuth, gotPath, gotBody string
gotTokenOrg, gotProvider string
)
stubToken(t, func(_ context.Context, org, provider, name string) ([]byte, error) {
gotTokenOrg, gotProvider = org, provider
if name != accessTokenSecret {
t.Fatalf("token secret name want %q, got %q", accessTokenSecret, name)
}
return []byte("T0K3N-acme"), nil
})
stubMeta(t, func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
gotPath = r.URL.Path
_ = r.ParseForm()
gotBody = r.Form.Encode()
_, _ = w.Write([]byte(`{"id":"120210000000001"}`))
})
ref, err := LaunchPaid(context.Background(), "acme", PaidPlan{
Platform: "meta", Account: "123", Name: "Spring Launch", Objective: "OUTCOME_TRAFFIC",
})
if err != nil {
t.Fatalf("LaunchPaid: %v", err)
}
if ref.ExternalID != "120210000000001" || ref.Account != "act_123" || ref.Status != "live" {
t.Fatalf("ref: %+v", ref)
}
if gotTokenOrg != "acme" || gotProvider != "meta_ads" {
t.Fatalf("TokenFor called with (%q,%q), want (acme, meta_ads)", gotTokenOrg, gotProvider)
}
if gotAuth != "Bearer T0K3N-acme" {
t.Fatalf("Authorization header want the org token, got %q", gotAuth)
}
if gotPath != "/act_123/campaigns" {
t.Fatalf("create path want /act_123/campaigns, got %q", gotPath)
}
for _, want := range []string{"name=Spring", "objective=OUTCOME_TRAFFIC", "status=ACTIVE", "special_ad_categories="} {
if !strings.Contains(gotBody, want) {
t.Fatalf("create body missing %q: %q", want, gotBody)
}
}
}
// TestLaunchPaid_ConnectorDisabledNoProviderCall: when the org has not connected
// the ad account (TokenFor fails), LaunchPaid fails closed and NEVER calls the
// provider — no spend, no fabrication.
func TestLaunchPaid_ConnectorDisabledNoProviderCall(t *testing.T) {
stubToken(t, func(_ context.Context, _, _, _ string) ([]byte, error) {
return nil, errors.New("integrations: meta_ads not connected for org")
})
hit := false
stubMeta(t, func(w http.ResponseWriter, r *http.Request) {
hit = true
_, _ = w.Write([]byte(`{"id":"should-not-happen"}`))
})
_, err := LaunchPaid(context.Background(), "acme", PaidPlan{Platform: "meta", Account: "123", Name: "X"})
if !errors.Is(err, errNotConnected) {
t.Fatalf("want errNotConnected, got %v", err)
}
if hit {
t.Fatalf("provider must NOT be called when the connector is disabled")
}
}
// TestLaunchPaid_TenantIsolationTokenPerOrg: each org's launch carries ITS OWN
// org's token to the provider — org A can never spend on org B's connection.
func TestLaunchPaid_TenantIsolationTokenPerOrg(t *testing.T) {
stubToken(t, func(_ context.Context, org, _, _ string) ([]byte, error) {
return []byte("tok-" + org), nil // each org has a distinct token
})
var seen []string
stubMeta(t, func(w http.ResponseWriter, r *http.Request) {
seen = append(seen, r.Header.Get("Authorization"))
_, _ = w.Write([]byte(`{"id":"1"}`))
})
if _, err := LaunchPaid(context.Background(), "acme", PaidPlan{Platform: "meta", Account: "1", Name: "A"}); err != nil {
t.Fatalf("acme launch: %v", err)
}
if _, err := LaunchPaid(context.Background(), "maxpower", PaidPlan{Platform: "meta", Account: "2", Name: "B"}); err != nil {
t.Fatalf("maxpower launch: %v", err)
}
if len(seen) != 2 || seen[0] != "Bearer tok-acme" || seen[1] != "Bearer tok-maxpower" {
t.Fatalf("each launch must carry its own org token, got %v", seen)
}
}
// TestLaunchPaid_AuthFailureIsNotConnected: a token the provider rejects (401) is
// fail-closed as errNotConnected — the connection is not usable.
func TestLaunchPaid_AuthFailureIsNotConnected(t *testing.T) {
stubToken(t, func(_ context.Context, _, _, _ string) ([]byte, error) { return []byte("stale"), nil })
stubMeta(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":{"message":"invalid token"}}`))
})
_, err := LaunchPaid(context.Background(), "acme", PaidPlan{Platform: "meta", Account: "1", Name: "X"})
if !errors.Is(err, errNotConnected) {
t.Fatalf("401 want errNotConnected, got %v", err)
}
}
// TestLaunchPaid_UnsupportedPlatformAfterToken: a mapped-but-unwired platform
// resolves the connector token (proving consumption) then honestly reports the
// execution gap — never a fabricated launch.
func TestLaunchPaid_UnsupportedPlatformAfterToken(t *testing.T) {
tokenSought := false
stubToken(t, func(_ context.Context, _, provider, _ string) ([]byte, error) {
tokenSought = true
if provider != "google_ads" {
t.Fatalf("google platform must resolve google_ads, got %q", provider)
}
return []byte("g-token"), nil
})
_, err := LaunchPaid(context.Background(), "acme", PaidPlan{Platform: "google", Account: "1", Name: "X"})
if !errors.Is(err, errUnsupportedPlatform) {
t.Fatalf("want errUnsupportedPlatform, got %v", err)
}
if !tokenSought {
t.Fatalf("a mapped platform must resolve its connector token before reporting the gap")
}
}
// TestLaunchPaid_UnmappedPlatformNeverSeeksToken: a platform with no connector is
// rejected BEFORE any KMS/token lookup.
func TestLaunchPaid_UnmappedPlatformNeverSeeksToken(t *testing.T) {
stubToken(t, func(_ context.Context, _, _, _ string) ([]byte, error) {
t.Fatalf("token must not be sought for an unmapped platform")
return nil, nil
})
_, err := LaunchPaid(context.Background(), "acme", PaidPlan{Platform: "snapchat", Account: "1", Name: "X"})
if !errors.Is(err, errUnsupportedPlatform) {
t.Fatalf("want errUnsupportedPlatform, got %v", err)
}
}
// TestPaidSpend_MetaInsightsCents: spend is read from Meta insights and converted
// to minor units (cents), through the org's connector token.
func TestPaidSpend_MetaInsightsCents(t *testing.T) {
stubToken(t, func(_ context.Context, _, _, _ string) ([]byte, error) { return []byte("tok"), nil })
var gotPath string
stubMeta(t, func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
_, _ = w.Write([]byte(`{"data":[{"spend":"12.34"}]}`))
})
cents, err := PaidSpend(context.Background(), "acme", PaidRef{Platform: "meta", ExternalID: "120210000000001"})
if err != nil {
t.Fatalf("PaidSpend: %v", err)
}
if cents != 1234 {
t.Fatalf("spend want 1234 cents, got %d", cents)
}
if gotPath != "/120210000000001/insights" {
t.Fatalf("insights path want /120210000000001/insights, got %q", gotPath)
}
}
// TestPausePaid_MetaSetsPaused: pause posts status=PAUSED to the provider through
// the org's connector token.
func TestPausePaid_MetaSetsPaused(t *testing.T) {
stubToken(t, func(_ context.Context, _, _, _ string) ([]byte, error) { return []byte("tok"), nil })
var gotStatus string
stubMeta(t, func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
gotStatus = r.Form.Get("status")
_, _ = w.Write([]byte(`{"success":true}`))
})
if err := PausePaid(context.Background(), "acme", PaidRef{Platform: "meta", ExternalID: "120210000000001"}); err != nil {
t.Fatalf("PausePaid: %v", err)
}
if gotStatus != "PAUSED" {
t.Fatalf("pause status want PAUSED, got %q", gotStatus)
}
}
+82 -17
View File
@@ -54,7 +54,9 @@ func openStore(path string) (*Store, error) {
// migrate creates the campaigns table. Idempotent (IF NOT EXISTS). The table
// leads its lookup indexes with `org` so tenant isolation is a physical
// property, not just a WHERE clause.
// property, not just a WHERE clause. account + external_id link a stored campaign
// to its launched provider execution (provider.go); they are added idempotently
// so a DB created before the connector-execution edge existed gains them cleanly.
func (s *Store) migrate() error {
const ddl = `
CREATE TABLE IF NOT EXISTS ads_campaigns (
@@ -62,6 +64,8 @@ CREATE TABLE IF NOT EXISTS ads_campaigns (
org TEXT NOT NULL,
name TEXT NOT NULL,
platform TEXT NOT NULL DEFAULT 'meta',
account TEXT NOT NULL DEFAULT '',
external_id TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'draft',
objective TEXT NOT NULL DEFAULT '',
budget INTEGER NOT NULL DEFAULT 0,
@@ -76,9 +80,48 @@ CREATE INDEX IF NOT EXISTS ix_ads_campaigns_org_platform ON ads_campaigns(org, p
if _, err := s.db.Exec(ddl); err != nil {
return fmt.Errorf("ads migrate: %w", err)
}
// Idempotent column adds for DBs created before account/external_id existed.
// The column names are package constants (never user input) → safe to inline.
for col, spec := range map[string]string{
"account": "TEXT NOT NULL DEFAULT ''",
"external_id": "TEXT NOT NULL DEFAULT ''",
} {
if err := s.addColumnIfMissing("ads_campaigns", col, spec); err != nil {
return fmt.Errorf("ads migrate column %s: %w", col, err)
}
}
return nil
}
// addColumnIfMissing ALTERs table to add col (with spec) only when absent. table/
// col/spec are package constants, so the interpolation is injection-safe. Makes
// the schema migration forward-only and idempotent across process restarts.
func (s *Store) addColumnIfMissing(table, col, spec string) error {
rows, err := s.db.Query(`PRAGMA table_info(` + table + `)`)
if err != nil {
return err
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var (
cid, notnull, pk int
name, ctype string
dflt sql.NullString
)
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
return err
}
if name == col {
return nil // already present
}
}
if err := rows.Err(); err != nil {
return err
}
_, err = s.db.Exec(`ALTER TABLE ` + table + ` ADD COLUMN ` + col + ` ` + spec)
return err
}
// Close closes the underlying database. Idempotent-safe via sql.DB.
func (s *Store) Close() error { return s.db.Close() }
@@ -89,31 +132,33 @@ func (s *Store) Close() error { return s.db.Close() }
// (draft/active/paused/completed) — both validated at the write layer against
// the fixed vocabularies in ads.go.
type Campaign struct {
ID string `json:"id"`
Org string `json:"-"`
Name string `json:"name"`
Platform string `json:"platform"`
Status string `json:"status"`
Objective string `json:"objective"`
Budget int64 `json:"budget"`
Spend int64 `json:"spend"`
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt"`
ID string `json:"id"`
Org string `json:"-"`
Name string `json:"name"`
Platform string `json:"platform"`
Account string `json:"account,omitempty"` // provider ad-account ref (Meta act_<id>)
ExternalID string `json:"externalId,omitempty"` // provider campaign id after a launch
Status string `json:"status"`
Objective string `json:"objective"`
Budget int64 `json:"budget"`
Spend int64 `json:"spend"`
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt"`
}
const campaignCols = `id,org,name,platform,status,objective,budget,spend,created_at,updated_at`
const campaignCols = `id,org,name,platform,account,external_id,status,objective,budget,spend,created_at,updated_at`
func scanCampaign(sc interface{ Scan(...any) error }) (Campaign, error) {
var c Campaign
err := sc.Scan(&c.ID, &c.Org, &c.Name, &c.Platform, &c.Status, &c.Objective,
err := sc.Scan(&c.ID, &c.Org, &c.Name, &c.Platform, &c.Account, &c.ExternalID, &c.Status, &c.Objective,
&c.Budget, &c.Spend, &c.CreatedAt, &c.UpdatedAt)
return c, err
}
func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (Campaign, error) {
if _, err := s.db.ExecContext(ctx,
`INSERT INTO ads_campaigns (`+campaignCols+`) VALUES (?,?,?,?,?,?,?,?,?,?)`,
c.ID, c.Org, c.Name, c.Platform, c.Status, c.Objective, c.Budget, c.Spend,
`INSERT INTO ads_campaigns (`+campaignCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
c.ID, c.Org, c.Name, c.Platform, c.Account, c.ExternalID, c.Status, c.Objective, c.Budget, c.Spend,
c.CreatedAt, c.UpdatedAt); err != nil {
return Campaign{}, fmt.Errorf("insert campaign: %w", err)
}
@@ -161,10 +206,13 @@ func (s *Store) ListCampaigns(ctx context.Context, org, status string, limit int
return out, rows.Err()
}
// UpdateCampaign edits the user-owned fields. external_id is deliberately NOT in
// the SET list: it is launch-owned (MarkLaunched sets it), so editing a campaign
// never clobbers the link to its live provider execution.
func (s *Store) UpdateCampaign(ctx context.Context, c Campaign) (Campaign, error) {
res, err := s.db.ExecContext(ctx,
`UPDATE ads_campaigns SET name=?,platform=?,status=?,objective=?,budget=?,spend=?,updated_at=? WHERE org=? AND id=?`,
c.Name, c.Platform, c.Status, c.Objective, c.Budget, c.Spend, c.UpdatedAt, c.Org, c.ID)
`UPDATE ads_campaigns SET name=?,platform=?,account=?,status=?,objective=?,budget=?,spend=?,updated_at=? WHERE org=? AND id=?`,
c.Name, c.Platform, c.Account, c.Status, c.Objective, c.Budget, c.Spend, c.UpdatedAt, c.Org, c.ID)
if err != nil {
return Campaign{}, fmt.Errorf("update campaign: %w", err)
}
@@ -174,6 +222,23 @@ func (s *Store) UpdateCampaign(ctx context.Context, c Campaign) (Campaign, error
return s.GetCampaign(ctx, c.Org, c.ID)
}
// MarkLaunched records the provider execution on a stored campaign: its account +
// external id, and status=active. Org-scoped — a cross-tenant id affects zero
// rows (errNotFound), never a foreign mutation. This is the ONLY writer of
// external_id, so the launch link is never clobbered by a user edit.
func (s *Store) MarkLaunched(ctx context.Context, org, id, account, externalID string, updatedAt int64) (Campaign, error) {
res, err := s.db.ExecContext(ctx,
`UPDATE ads_campaigns SET account=?,external_id=?,status='active',updated_at=? WHERE org=? AND id=?`,
account, externalID, updatedAt, org, id)
if err != nil {
return Campaign{}, fmt.Errorf("mark launched: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return Campaign{}, errNotFound
}
return s.GetCampaign(ctx, org, id)
}
func (s *Store) DeleteCampaign(ctx context.Context, org, id string) (bool, error) {
res, err := s.db.ExecContext(ctx, `DELETE FROM ads_campaigns WHERE org=? AND id=?`, org, id)
if err != nil {
+27 -136
View File
@@ -1,153 +1,44 @@
package affiliates
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/hanzoai/cloud/clients/commerceinproc"
"github.com/hanzoai/cloud/clients/payout"
)
// commerce is the narrow money seam the affiliate loop needs: read a referred
// org's metered spend (the accrual base) and grant a promo credit to a wallet (a
// payout made in credits). It is an INTERFACE so the store/handler logic is
// testable with a fake ledger the HTTP impl below is the ONE production binding.
// commerce is the narrow money seam the affiliate loop needs: read a referred org's
// metered spend (the commission accrual base) and grant a promo credit to a wallet
// (a payout made in credits, ledger tag grant:affiliate). It is an INTERFACE so the
// store/handler logic is testable with a fake ledger; the production binding is
// clients/payout, reached through the thin adapter below.
//
// This mirrors clients/referrals/commerce.go EXACTLY (which itself mirrors
// clients/admin/commerce.go): the same COMMERCE_SERVICE_TOKEN S2S path, the same
// X-Org-Id=<org> namespace + bare org `user` subject that admin.grantCredit uses —
// so an affiliate payout-in-credits lands in precisely the wallet the balance
// panel reads, indistinguishable from an admin grant except by its ledger tag
// (grant:affiliate vs grant:referral / grant:admin, all → the commerce Credit/trial
// bucket per DepositKind's grant:* rule).
// The S2S impl (COMMERCE_SERVICE_TOKEN path, X-Org-Id=<org> namespace, bare-org
// `user` subject) was three byte-identical commerce.go copies; it now lives ONCE in
// clients/payout. An affiliate payout-in-credits still lands in precisely the wallet
// the balance panel reads, indistinguishable from an admin grant except by its
// grant:affiliate tag.
type commerce interface {
configured() bool
// deposit grants amountCents to org's wallet (Credit/trial bucket via the
// grant:affiliate tag) and returns the ledger transaction id.
deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (txnID string, err error)
// spendCents is a referred org's month-to-date metered consumption — the
// commission accrual base (spend × the affiliate's rate).
spendCents(ctx context.Context, org, user string) (int64, error)
}
// errUnconfigured is returned by a deposit against an unwired commerce so the
// caller records an honest failure rather than reporting a phantom payout.
var errUnconfigured = errors.New("affiliates: commerce endpoint not configured")
// errUnconfigured is the shared sentinel a deposit against an unwired commerce
// returns, so the caller records an honest failure rather than a phantom payout.
var errUnconfigured = payout.ErrUnconfigured
// httpCommerce is the production commerce binding (COMMERCE_SERVICE_TOKEN S2S).
type httpCommerce struct {
base string
token string
http *http.Client
// commerceSeam adapts the shared payout.Client onto this program's lowercase seam
// (Go package-scoped interface methods cannot cross packages). Zero logic — pure
// delegation; the money path lives in clients/payout.
type commerceSeam struct{ c *payout.Client }
func (s commerceSeam) configured() bool { return s.c.Configured() }
func (s commerceSeam) deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (string, error) {
return s.c.Deposit(ctx, org, user, amountCents, currency, notes, tags)
}
func (s commerceSeam) spendCents(ctx context.Context, org, user string) (int64, error) {
return s.c.SpendCents(ctx, org, user)
}
func newCommerceClient(base, token string) *httpCommerce {
return &httpCommerce{
base: strings.TrimRight(strings.TrimSpace(base), "/"),
token: strings.TrimSpace(token),
http: commerceinproc.Client(15 * time.Second),
}
}
func (c *httpCommerce) configured() bool { return c != nil && c.base != "" && c.token != "" }
// deposit posts POST /v1/billing/deposit — the ONE money-in primitive (identical
// to admin.commerceClient.deposit). Commerce's EdgeAuth pins the body `user` to
// the X-Org-Id subject, so a payout can never be mis-targeted to another wallet.
func (c *httpCommerce) deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (string, error) {
if !c.configured() {
return "", 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 "", err
}
raw, err := c.do(ctx, http.MethodPost, "/v1/billing/deposit", nil, org, body)
if err != nil {
return "", err
}
var out struct {
TransactionID string `json:"transactionId"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return "", fmt.Errorf("commerce deposit decode: %w", err)
}
return out.TransactionID, nil
}
// spendCents reads GET /v1/billing/usage-rollup and returns consumedCents. Zero
// (not an error) when commerce is unconfigured so a partial deploy degrades to
// "no spend to accrue yet" rather than a 5xx.
func (c *httpCommerce) spendCents(ctx context.Context, org, user string) (int64, error) {
if !c.configured() {
return 0, nil
}
q := url.Values{"user": {user}}
raw, err := c.do(ctx, http.MethodGet, "/v1/billing/usage-rollup", q, org, nil)
if err != nil {
return 0, err
}
var out struct {
ConsumedCents int64 `json:"consumedCents"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return 0, fmt.Errorf("commerce rollup decode: %w", err)
}
return out.ConsumedCents, nil
}
// do performs one admin-S2S commerce request. X-Org-Id=<org> is the per-org
// namespace selector commerce's EdgeAuth trusts only behind the service token.
func (c *httpCommerce) do(ctx context.Context, method, path string, q url.Values, org string, body []byte) ([]byte, error) {
u := c.base + path
if enc := q.Encode(); enc != "" {
u += "?" + enc
}
var r io.Reader
if body != nil {
r = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, u, r)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
if body != nil {
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 func() { _ = resp.Body.Close() }()
out, 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 out, nil
}
// newCommerceClient builds the production binding, delegating to clients/payout.
func newCommerceClient(base, token string) commerce { return commerceSeam{payout.NewClient(base, token)} }
+1 -1
View File
@@ -22,7 +22,7 @@ import (
hz "github.com/hanzoai/agent"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/tools"
openai "github.com/sashabaranov/go-openai"
openai "github.com/hanzoai/go-openai"
fiber "github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip"
)
+128 -23
View File
@@ -28,7 +28,9 @@ import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
mrand "math/rand/v2"
"net/http"
"os"
"path/filepath"
@@ -107,6 +109,13 @@ type state struct {
// per subsystem. Empty only on a deployment that configured no default, in
// which case create still requires an explicit model.
defaultModel string
// failoverModel is the reliable model a run falls over to when the agent's own
// model stays throttled (429/overloaded) after bounded retries
// (deps.AIFallbackModel, default "best"). It makes an autonomous bot reply
// still land when the throttled default flash model is overloaded. Empty
// disables failover (retry-only). Only the run path reads it — interactive
// chat is untouched.
failoverModel string
// bill is the shared per-org gate+meter (reuses deps.Metering, the ONE
// commerce client — the same object ml/provisioning use). Nil/!Enabled()
// makes Gate allow and Meter a no-op, so an unconfigured deployment runs
@@ -262,11 +271,12 @@ func Mount(app *zip.App, deps cloud.Deps) error {
s := &cloud.Service[state]{
Base: cloud.NewBase(deps, "agents"),
State: state{
store: store,
ai: deps.AI,
defaultModel: strings.TrimSpace(deps.AIDefaultModel),
bill: cloud.NewResourceMeter(deps, meterKind),
bus: newBus(),
store: store,
ai: deps.AI,
defaultModel: strings.TrimSpace(deps.AIDefaultModel),
failoverModel: strings.TrimSpace(deps.AIFallbackModel),
bill: cloud.NewResourceMeter(deps, meterKind),
bus: newBus(),
// TASKS PLUG-IN POINT: durable execution rides hanzoai/tasks, not a
// bespoke engine. Default is record-only; wiring client.Dial(TASKS_URL)
// from github.com/hanzoai/tasks/pkg/sdk/client here makes control forward
@@ -276,6 +286,7 @@ func Mount(app *zip.App, deps cloud.Deps) error {
}
mounted = s
g := app.Group("/v1/agents")
app.Get("/v1/agents", cloud.Handle(s, list))
app.Post("/v1/agents", cloud.Handle(s, create))
// The static org-wide surfaces are listed before the :ref wildcard for reading
@@ -284,18 +295,18 @@ func Mount(app *zip.App, deps cloud.Deps) error {
// a ref). Registration order decides nothing here — it only decides which
// handler silently wins when two patterns are byte-identical, which is a
// collision, not a precedence.
app.Get("/v1/agents/metrics", cloud.Handle(s, metrics))
app.Get("/v1/agents/activity", cloud.Handle(s, activity))
g.Get("/metrics", cloud.Handle(s, metrics))
g.Get("/activity", cloud.Handle(s, activity))
// Live agent-session control plane: /v1/agents/sessions[/...].
mountSessions(s, app)
// Agent targets: /v1/agents/targets[/...] — the #48 dispatch destinations a
// session runs on.
mountTargets(s, app)
app.Get("/v1/agents/:ref", cloud.Handle(s, get))
app.Patch("/v1/agents/:ref", cloud.Handle(s, update))
app.Delete("/v1/agents/:ref", cloud.Handle(s, del))
app.Post("/v1/agents/:ref/run", cloud.Handle(s, run))
app.Get("/v1/agents/:ref/runs", cloud.Handle(s, runs))
g.Get("/:ref", cloud.Handle(s, get))
g.Patch("/:ref", cloud.Handle(s, update))
g.Delete("/:ref", cloud.Handle(s, del))
g.Post("/:ref/run", cloud.Handle(s, run))
g.Get("/:ref/runs", cloud.Handle(s, runs))
// Long-running scheduler: invokes each long-running agent's run on its cron
// cadence through the SAME runAgent path as the HTTP handler (one run path,
@@ -665,11 +676,12 @@ func runAgent(s *cloud.Service[state], ctx context.Context, a Agent, input, acto
return Run{}, err
}
r := executeRun(ctx, s.State.ai, a.Org, a, input)
r := executeRun(ctx, s.State.ai, a.Org, a, input, s.State.failoverModel)
span.SetAttributes(
attribute.String("hanzo.agent.run_id", r.ID),
attribute.String("hanzo.agent.run_status", r.Status),
attribute.Int64("hanzo.agent.duration_ms", r.DurationMs),
attribute.String("gen_ai.response.model", r.Model),
)
if r.Status == "error" {
span.SetStatus(codes.Error, r.Error)
@@ -685,12 +697,14 @@ func runAgent(s *cloud.Service[state], ctx context.Context, a Agent, input, acto
openRunSession(s, ctx, a, r, actor)
// Bill only a successful run (mirrors the edge gate: failed work is not
// charged). Rich attribution: product=agent (Provider), the agent's model,
// and the actor for the audit trail. Fire-and-forget on a background context.
// charged). Rich attribution: product=agent (Provider), the model ACTUALLY
// used (r.Model — a failover run bills the reliable model it fell over to, not
// the throttled one it started on), and the actor for the audit trail.
// Fire-and-forget on a background context.
if r.Status == "ok" {
s.State.bill.MeterUsage(a.Org, meterKind, metering.Usage{
AmountCents: fee,
Model: a.Model,
Model: r.Model,
Actor: actor,
RequestID: requestID,
ClientIP: clientIP,
@@ -699,11 +713,31 @@ func runAgent(s *cloud.Service[state], ctx context.Context, a Agent, input, acto
return r, nil
}
// executeRun composes the agent's instructions with the caller input, runs one
// real chat completion through the AI client, and returns the resulting Run —
// status "ok" with output, or "error" with the upstream failure. Pure of HTTP
// and persistence so it is directly testable; the caller records + responds.
func executeRun(ctx context.Context, ai types.AIClient, org string, a Agent, input string) Run {
// maxAttempts bounds retries of a SINGLE model's completion on a transient
// upstream failure (429 / 5xx / empty-choices / "Platform overloaded"). Retrying
// a completion is side-effect-free — nothing bills until it succeeds — so a
// bounded retry with jittered backoff turns an intermittent gateway 429 into a
// delivered reply instead of a dropped one.
const maxAttempts = 3
// retryBaseDelay / retryMaxDelay bound the exponential, equal-jittered backoff
// between attempts. Small by design: a gateway overload clears in well under a
// second, and a run must not stall a bot conversation.
const (
retryBaseDelay = 150 * time.Millisecond
retryMaxDelay = 2 * time.Second
)
// executeRun composes the agent's instructions with the caller input and runs
// one chat completion through the AI client — with a bounded retry on transient
// upstream overload and, if the agent's own model stays throttled, ONE failover
// to the deployment's reliable model (fallback) so an autonomous bot reply still
// lands. It returns the resulting Run — status "ok" with output and Model set to
// the model that ACTUALLY answered (so metering bills that model), or "error"
// with the final upstream failure. Pure of HTTP and persistence so it is directly
// testable; the caller records + responds. This reliability policy is the agent
// runner's ALONE — the interactive user-facing chat path is untouched.
func executeRun(ctx context.Context, ai types.AIClient, org string, a Agent, input, fallback string) Run {
// Child step span; the AI client opens its own GenAI span nested under this.
ctx, span := agentTracer.Start(ctx, "agent.step", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
@@ -717,11 +751,11 @@ func executeRun(ctx context.Context, ai types.AIClient, org string, a Agent, inp
prompt += in
}
start := time.Now()
resp, aiErr := ai.ChatCompletion(ctx, &types.ChatRequest{Model: a.Model, Prompt: prompt, Org: org})
resp, used, aiErr := completeWithFailover(ctx, ai, org, prompt, a.Model, fallback)
dur := time.Since(start).Milliseconds()
id, _ := genID("run")
r := Run{
ID: id, Org: org, AgentName: a.Name, Model: a.Model, Input: input,
ID: id, Org: org, AgentName: a.Name, Model: used, Input: input,
DurationMs: dur, CreatedAt: time.Now().Unix(),
}
if aiErr != nil {
@@ -738,6 +772,77 @@ func executeRun(ctx context.Context, ai types.AIClient, org string, a Agent, inp
return r
}
// completeWithFailover runs the completion on the agent's model with a bounded
// retry (completeWithRetry), then — only if that model is STILL throttled after
// its retries — fails over ONCE to fallback, a reliable model. It returns the
// response, the model that actually produced it (for honest metering), and the
// final error. A non-transient failure on either model returns immediately (the
// next model would fail identically). ONE ordered mechanism, no config sprawl.
func completeWithFailover(ctx context.Context, ai types.AIClient, org, prompt, model, fallback string) (*types.ChatResponse, string, error) {
models := []string{model}
if f := strings.TrimSpace(fallback); f != "" && f != model {
models = append(models, f)
}
var lastErr error
for _, m := range models {
resp, err := completeWithRetry(ctx, ai, org, prompt, m)
if err == nil {
return resp, m, nil
}
lastErr = err
// Escalate to the next model ONLY on a transient overload; a hard error
// (bad request, auth, unserved model) fails fast — failover cannot help.
if !errors.Is(err, types.ErrUpstreamBusy) {
return nil, m, err
}
}
return nil, models[len(models)-1], lastErr
}
// completeWithRetry calls the completion up to maxAttempts times, retrying ONLY a
// transient upstream overload (types.ErrUpstreamBusy) with jittered backoff and
// respecting context cancellation. A non-transient error returns immediately.
func completeWithRetry(ctx context.Context, ai types.AIClient, org, prompt, model string) (*types.ChatResponse, error) {
var lastErr error
for attempt := 0; attempt < maxAttempts; attempt++ {
resp, err := ai.ChatCompletion(ctx, &types.ChatRequest{Model: model, Prompt: prompt, Org: org})
if err == nil {
return resp, nil
}
lastErr = err
if !errors.Is(err, types.ErrUpstreamBusy) {
return nil, err // permanent — do not burn retries repeating it
}
if attempt == maxAttempts-1 {
break
}
if err := sleepBackoff(ctx, attempt); err != nil {
return nil, err // context cancelled/expired mid-backoff
}
}
return nil, lastErr
}
// sleepBackoff waits an exponential, equal-jittered delay before the next
// attempt, or returns the context error if the caller's deadline fires first.
func sleepBackoff(ctx context.Context, attempt int) error {
d := retryBaseDelay << attempt
if d > retryMaxDelay {
d = retryMaxDelay
}
// Equal jitter: half fixed, half random in [0, d/2) — spreads retries without
// ever collapsing the delay to ~0 (guarantees forward progress under load).
wait := d/2 + time.Duration(mrand.Int64N(int64(d/2)+1))
t := time.NewTimer(wait)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}
func runs(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
+2 -2
View File
@@ -157,7 +157,7 @@ func TestExecuteRunOK(t *testing.T) {
ai := &fakeAI{content: "hi there"}
a := mk("maxpower", "greeter")
a.Instructions = "You are a greeter."
r := executeRun(context.Background(), ai, "maxpower", a, "say hi")
r := executeRun(context.Background(), ai, "maxpower", a, "say hi", "")
if r.Status != "ok" {
t.Fatalf("want ok, got %q err=%q", r.Status, r.Error)
@@ -178,7 +178,7 @@ func TestExecuteRunOK(t *testing.T) {
func TestExecuteRunRecordsError(t *testing.T) {
ai := &fakeAI{err: errors.New("model unavailable")}
r := executeRun(context.Background(), ai, "maxpower", mk("maxpower", "x"), "in")
r := executeRun(context.Background(), ai, "maxpower", mk("maxpower", "x"), "in", "")
if r.Status != "error" {
t.Fatalf("want error status, got %q", r.Status)
}
+5 -2
View File
@@ -13,8 +13,8 @@ import (
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/types"
"github.com/hanzoai/cloud/clients/metering"
"github.com/hanzoai/cloud/types"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
@@ -88,7 +88,10 @@ func mountBilled(t *testing.T, commerceURL string, ai types.AIClient) *zip.App {
t.Fatalf("metering.New: %v", err)
}
app := zip.New(zip.Config{Logger: luxlog.New("test")})
deps := cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir(), AI: ai, Metering: m}
// AIFallbackModel="best" arms the agent runner's failover so the retry/failover
// tests exercise the real escalation path; it never fires for a run whose model
// answers (or fails non-transiently), so the other billed tests are unaffected.
deps := cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir(), AI: ai, Metering: m, AIFallbackModel: "best"}
if err := Mount(app, deps); err != nil {
t.Fatalf("Mount: %v", err)
}
+38
View File
@@ -71,6 +71,44 @@ func OpenSession(ctx context.Context, org, actor, agent, title string) (string,
return id, nil
}
// OpenSessionOn is OpenSession with the run's dispatch TARGET recorded, so
// mission-control shows a routed run on the machine it was sent to (session.target
// == the target id) exactly as a locally-linked run shows its host. The target is
// re-resolved org-scoped and MUST belong to this org — a session can never claim
// to run on another tenant's machine (the same fail-closed rule sessionContext
// enforces on the HTTP register path). An empty target falls back to OpenSession.
func OpenSessionOn(ctx context.Context, org, actor, agent, title, target string) (string, error) {
target = strings.TrimSpace(target)
if target == "" {
return OpenSession(ctx, org, actor, agent, title)
}
if mounted == nil {
return "", fmt.Errorf("agents: not mounted")
}
org = strings.TrimSpace(org)
if org == "" {
return "", fmt.Errorf("agents: org required")
}
if _, err := mounted.State.store.GetTarget(ctx, org, target); err != nil {
if err == errTargetNotFound {
return "", fmt.Errorf("agents: target not found in this org")
}
return "", fmt.Errorf("agents: resolve target: %w", err)
}
id, err := OpenSession(ctx, org, actor, agent, title)
if err != nil {
return "", err
}
// Stamp the target onto the freshly-opened row (org-scoped update); a failure
// here is non-fatal — the session is live, it simply lacks its machine tag.
if x, gerr := mounted.State.store.GetSession(ctx, org, id); gerr == nil {
x.Target = target
x.UpdatedAt = time.Now().Unix()
_ = mounted.State.store.UpdateSession(ctx, x)
}
return id, nil
}
// LogSessionEvent appends one ordered event (message|tool-call|spawn|log|status|
// control) to an org's session and fans it out live. The (org, id) pair is
// re-resolved so a caller can only write to a session THIS org owns; kind is
+47
View File
@@ -114,3 +114,50 @@ func TestInproc_NotMounted_FailsClosed(t *testing.T) {
t.Fatal("unmounted OpenSession must fail closed")
}
}
// ResolveTarget turns a human's reference (id or friendly label) into the org's
// target, org-scoped and fail-closed: an id wins, else an exact case-folded label,
// and a reference matching neither — or another org's machine — is not found.
func TestResolveTarget_IdThenLabel_OrgScoped(t *testing.T) {
mountInproc(t)
ctx := context.Background()
now := int64(1000)
acme := Target{ID: "tgt_acme1", Org: "acme", Label: "evo", Kind: TargetGPU, Status: TargetOnline, Host: "evo", CreatedAt: now, UpdatedAt: now}
evil := Target{ID: "tgt_evil1", Org: "evil", Label: "evo", Kind: TargetGPU, Status: TargetOnline, Host: "evo", CreatedAt: now, UpdatedAt: now}
if err := mounted.State.store.CreateTarget(ctx, acme); err != nil {
t.Fatal(err)
}
if err := mounted.State.store.CreateTarget(ctx, evil); err != nil {
t.Fatal(err)
}
// By id.
if got, err := ResolveTarget(ctx, "acme", "tgt_acme1"); err != nil || got.ID != "tgt_acme1" {
t.Fatalf("resolve by id: %+v %v", got, err)
}
// By label (case-folded), scoped to the caller's org — never evil's same-labelled box.
if got, err := ResolveTarget(ctx, "acme", "EVO"); err != nil || got.ID != "tgt_acme1" {
t.Fatalf("resolve by label must find acme's own, got %+v %v", got, err)
}
// Another org's id is not found (no cross-tenant leak).
if _, err := ResolveTarget(ctx, "acme", "tgt_evil1"); err != errTargetNotFound {
t.Fatalf("cross-org id must be not-found, got %v", err)
}
// An unknown reference is not found — the caller renders an honest error.
if _, err := ResolveTarget(ctx, "acme", "nope"); err != errTargetNotFound {
t.Fatalf("unknown ref must be not-found, got %v", err)
}
// Empty ref is not found (never resolves to "some" machine).
if _, err := ResolveTarget(ctx, "acme", ""); err != errTargetNotFound {
t.Fatalf("empty ref must be not-found, got %v", err)
}
}
func TestResolveTarget_NotMounted_FailsClosed(t *testing.T) {
prev := mounted
mounted = nil
t.Cleanup(func() { mounted = prev })
if _, err := ResolveTarget(context.Background(), "acme", "evo"); err == nil {
t.Fatal("unmounted ResolveTarget must fail closed")
}
}
+316
View File
@@ -0,0 +1,316 @@
package agents
import (
"context"
"os"
"strconv"
"strings"
"sync"
luxlog "github.com/luxfi/log"
)
// mailbox.go is the LIVE hand-off between a routed run's durable owner (the
// coding RoutedRunWorkflow, running on the embedded tasks engine) and the
// external machine that claims and executes it over HTTP. It is the rendezvous
// ONLY — never the durable queue. The tasks engine is the queue of record: it
// survives a cloud restart, times a never-claimed run out, and retries. On every
// (re)start of the delivery activity the run is (re-)Offered here, so a machine
// that long-polls Claim always finds work the engine still owns; a cloud restart
// simply re-populates the mailbox from durable history.
//
// ISOLATION IS STRUCTURAL. Every offer is filed under the key (org, target), and
// Claim/Report only ever touch that one key's slot. A run offered for (orgB,
// targetY) is unreachable from a Claim or Report for (orgA, targetX) — the tenant
// + machine boundary is a property of the map key, not a check a caller can skip.
// RoutedRun is the NON-SECRET spec of one coding run dispatched to a target. It
// carries no credential by design: the executing machine authenticates git +
// model routing with its OWN already-held credentials (the same ones `hanzo code`
// uses), so no secret ever enters the durable store or crosses to the machine in
// the claim response. Everything here is safe to persist in the tasks engine.
type RoutedRun struct {
Org string `json:"org"`
TargetID string `json:"targetId"`
SessionID string `json:"sessionId"` // the live session opened at dispatch; the machine streams into it
Repo string `json:"repo"`
Project string `json:"project,omitempty"`
Base string `json:"base,omitempty"`
Branch string `json:"branch"`
Prompt string `json:"prompt"`
CloneURL string `json:"cloneUrl"`
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
// Actor + AgentRef are CLOUD-SIDE attribution for the completion path (session
// close + PR assignee). They are NOT part of routedRunView, so they never cross
// to the executing machine — the machine needs neither.
Actor string `json:"actor,omitempty"`
AgentRef string `json:"agentRef,omitempty"`
}
// RoutedResult is a routed run's terminal outcome, reported by the machine and
// returned to the durable activity so the workflow completes.
type RoutedResult struct {
OK bool `json:"ok"`
Changed bool `json:"changed"`
Branch string `json:"branch,omitempty"`
CommitSha string `json:"commitSha,omitempty"`
Diffstat string `json:"diffstat,omitempty"`
Error string `json:"error,omitempty"`
}
// offer is one run waiting to be claimed, plus the channel its durable owner
// blocks on for the terminal result. result is buffered(1) so Report never blocks
// even if the owner is between selects; closed fires when the offer is finished
// (reported OR abandoned) so a waiter always unblocks.
type offer struct {
mb *mailbox
key string // (org,target)
rk string // (org,target,sessionID)
run RoutedRun
result chan RoutedResult
closed chan struct{}
once sync.Once
}
// Await blocks until the machine reports this run's result, the offer is
// abandoned, or ctx (the activity's StartToClose budget) fires. It is the
// durable owner's half of the rendezvous.
func (o *offer) Await(ctx context.Context) (RoutedResult, bool) {
select {
case res := <-o.result:
return res, true
case <-o.closed:
// Abandoned or reported-then-closed: drain a delivered result if one raced in.
select {
case res := <-o.result:
return res, true
default:
return RoutedResult{}, false
}
case <-ctx.Done():
return RoutedResult{}, false
}
}
// Close removes the offer from the mailbox (if still present) and unblocks any
// waiter. Idempotent — the durable owner defers it so a timed-out or crashed
// delivery never leaks a queued or claimed offer.
func (o *offer) Close() { o.mb.discard(o) }
// mailbox is the process-wide rendezvous. queues holds each key's FIFO of
// unclaimed offers; byRun indexes every live offer by (org,target,sessionID) for
// Report + re-offer dedupe; signal is a per-key broadcast channel (closed and
// recreated on Offer) that Claim waits on.
type mailbox struct {
mu sync.Mutex
queues map[string][]*offer
byRun map[string]*offer
signal map[string]chan struct{}
// inflight is the per-org set of live routed sessions (offered, not yet finished),
// the gauge the per-org admission cap reads. A SET keyed by session id (not a bare
// counter) so a re-offer after a restart re-adds idempotently and a superseded
// offer never double-counts or wrongly decrements the still-live session.
inflight map[string]map[string]struct{}
}
func newMailbox() *mailbox {
return &mailbox{
queues: map[string][]*offer{},
byRun: map[string]*offer{},
signal: map[string]chan struct{}{},
inflight: map[string]map[string]struct{}{},
}
}
func (m *mailbox) inflightAddLocked(org, sess string) {
s := m.inflight[org]
if s == nil {
s = map[string]struct{}{}
m.inflight[org] = s
}
s[sess] = struct{}{}
}
func (m *mailbox) inflightRemoveLocked(org, sess string) {
if s := m.inflight[org]; s != nil {
delete(s, sess)
if len(s) == 0 {
delete(m.inflight, org)
}
}
}
// InFlight returns how many routed runs an org has live (offered, not yet finished).
func (m *mailbox) InFlight(org string) int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.inflight[org])
}
// routedMailbox is the ONE process-wide rendezvous, shared by the coding
// delivery activity (Offer/Await) and the machine-facing HTTP surface
// (Claim/Report). One mailbox, one way.
//
// SINGLE-REPLICA DEPENDENCY (accepted, inherited). This rendezvous is IN-PROCESS: the
// durable delivery activity (Offer/Await, on whichever replica's tasks worker polls
// the agent-routed queue) and the external machine's POST /claim (ingress load-
// balanced to any replica) must land on the SAME process, because the mailbox is a
// package global, not a shared broker. cloud already runs HARD single-replica —
// Recreate, replicas:1 — because the embedded Badger KMS holds an exclusive file lock
// and the audit sequence is an in-memory counter (infra/k8s/operator/crs/cloud.yaml),
// so route-work INHERITS that guarantee for free and needs no broker. assertSingleReplica
// logs the assumption at mount and warns loudly if a multi-replica signal is present.
//
// IF cloud is ever made multi-replica (the KMS lock lifted): this rendezvous MUST
// become replica-aware — either a sticky route that pins a target's /claim to the
// replica whose worker owns its delivery, or a shared broker (the embedded NATS/
// JetStream already in-process, keyed by (org,target)) so Offer and Claim meet
// regardless of which replica each hits. Until then, single-replica is the contract.
var routedMailbox = newMailbox()
// assertSingleReplica records the single-replica assumption the process-global
// rendezvous depends on, and warns LOUDLY if a multi-replica signal is detectable
// (CLOUD_REPLICAS > 1). It does not fail mount — cloud's replicas:1 is enforced by the
// deployment (the KMS lock), so this is a defensive breadcrumb for the day that
// changes, not a runtime gate. Called once from mountRouting.
func assertSingleReplica(log luxlog.Logger) {
if log == nil {
return
}
replicas := 1
if v := strings.TrimSpace(os.Getenv("CLOUD_REPLICAS")); v != "" {
if n, err := strconv.Atoi(v); err == nil {
replicas = n
}
}
if replicas > 1 {
log.Warn("route-work: the routed-run rendezvous is process-global and REQUIRES cloud to run single-replica, but CLOUD_REPLICAS>1 — routed /claim will silently fail on a replica that does not own the delivery. Make the rendezvous replica-aware (sticky target route or shared broker) before scaling out.",
"replicas", replicas)
return
}
log.Info("route-work: routed-run rendezvous is in-process; assumes cloud single-replica (inherited from the KMS exclusive lock)")
}
func mbKey(org, target string) string { return org + "\x00" + target }
func runKey(org, target, sess string) string { return org + "\x00" + target + "\x00" + sess }
// Offer files run for its (org,target) and returns the handle its durable owner
// awaits. A re-offer of the same (org,target,sessionID) — the workflow retrying
// or replaying after a restart — supersedes the stale prior offer (removing it
// from the queue and unblocking its dead waiter) so a machine never claims a run
// whose owner has already moved on.
func (m *mailbox) Offer(run RoutedRun) *offer {
key := mbKey(run.Org, run.TargetID)
rk := runKey(run.Org, run.TargetID, run.SessionID)
o := &offer{mb: m, key: key, rk: rk, run: run, result: make(chan RoutedResult, 1), closed: make(chan struct{})}
m.mu.Lock()
if prev := m.byRun[rk]; prev != nil {
m.removeFromQueueLocked(key, prev)
prev.finish()
}
m.byRun[rk] = o
m.queues[key] = append(m.queues[key], o)
m.broadcastLocked(key)
m.mu.Unlock()
return o
}
// Claim blocks until an unclaimed run exists for (org,target) or ctx fires,
// returning the oldest. The claimed offer leaves the queue but stays in byRun,
// awaiting Report. Only this key's queue is ever read, so a claim can never
// surface another tenant's or another machine's run.
func (m *mailbox) Claim(ctx context.Context, org, target string) (RoutedRun, bool) {
key := mbKey(org, target)
for {
m.mu.Lock()
if q := m.queues[key]; len(q) > 0 {
o := q[0]
m.queues[key] = q[1:]
m.mu.Unlock()
return o.run, true
}
sig := m.signalLocked(key)
m.mu.Unlock()
select {
case <-sig:
// a new offer (or a superseding one) arrived — re-check
case <-ctx.Done():
return RoutedRun{}, false
}
}
}
// Report delivers a terminal result to the run's durable owner. Scoped to
// (org,target,sessionID): a report can only ever complete a run that exact key
// owns, so one machine can never report on behalf of another. Returns false when
// no live offer matches (already reported, abandoned, or never existed).
func (m *mailbox) Report(org, target, sess string, res RoutedResult) bool {
rk := runKey(org, target, sess)
m.mu.Lock()
o := m.byRun[rk]
if o == nil {
m.mu.Unlock()
return false
}
delete(m.byRun, rk)
m.removeFromQueueLocked(mbKey(org, target), o)
m.mu.Unlock()
o.deliver(res)
return true
}
// discard drops an offer the owner is done with (ctx timeout / crash / normal
// close) so neither the queue nor byRun retains it.
func (m *mailbox) discard(o *offer) {
m.mu.Lock()
if m.byRun[o.rk] == o {
delete(m.byRun, o.rk)
}
m.removeFromQueueLocked(o.key, o)
m.mu.Unlock()
o.finish()
}
func (m *mailbox) removeFromQueueLocked(key string, o *offer) {
q := m.queues[key]
for i, e := range q {
if e == o {
m.queues[key] = append(q[:i:i], q[i+1:]...)
return
}
}
}
// broadcastLocked wakes every Claim waiting on key by closing its signal channel;
// a fresh channel replaces it for the next wait.
func (m *mailbox) broadcastLocked(key string) {
if ch, ok := m.signal[key]; ok {
close(ch)
delete(m.signal, key)
}
}
func (m *mailbox) signalLocked(key string) chan struct{} {
ch, ok := m.signal[key]
if !ok {
ch = make(chan struct{})
m.signal[key] = ch
}
return ch
}
func (o *offer) deliver(res RoutedResult) {
o.once.Do(func() {
o.result <- res // buffered(1) — never blocks
close(o.closed)
})
}
func (o *offer) finish() {
o.once.Do(func() { close(o.closed) })
}
// OfferRoutedRun is the exported seam the coding delivery activity uses to place
// a run into the live rendezvous. Kept here (agents owns targets + sessions) so
// the machine-facing HTTP surface and the durable activity share ONE mailbox.
func OfferRoutedRun(run RoutedRun) *offer { return routedMailbox.Offer(run) }
+166
View File
@@ -0,0 +1,166 @@
package agents
import (
"context"
"sync"
"testing"
"time"
)
func mkRun(org, target, sess string) RoutedRun {
return RoutedRun{Org: org, TargetID: target, SessionID: sess, Repo: "api", Branch: "agent/" + sess}
}
// A claimed run comes back to exactly one claimer, then its report reaches the
// offerer that is awaiting it.
func TestMailbox_OfferClaimReport(t *testing.T) {
m := newMailbox()
off := m.Offer(mkRun("acme", "tgt_1", "sess_1"))
got, ok := m.Claim(context.Background(), "acme", "tgt_1")
if !ok || got.SessionID != "sess_1" {
t.Fatalf("claim wrong: ok=%v run=%+v", ok, got)
}
done := make(chan RoutedResult, 1)
go func() {
res, _ := off.Await(context.Background())
done <- res
}()
if !m.Report("acme", "tgt_1", "sess_1", RoutedResult{OK: true, CommitSha: "abc"}) {
t.Fatal("report should deliver to the awaiting offer")
}
select {
case res := <-done:
if !res.OK || res.CommitSha != "abc" {
t.Fatalf("await got wrong result: %+v", res)
}
case <-time.After(2 * time.Second):
t.Fatal("await never received the reported result")
}
}
// THE tenant + machine boundary: a claim for (org,target) can NEVER surface a run
// offered for a different org OR a different target — it is a property of the key.
func TestMailbox_CrossTenantAndCrossMachineIsolation(t *testing.T) {
m := newMailbox()
m.Offer(mkRun("orgB", "tgt_Y", "sess_foreign_org"))
m.Offer(mkRun("acme", "tgt_Y", "sess_foreign_machine"))
m.Offer(mkRun("acme", "tgt_X", "sess_mine"))
// A claim for (acme, tgt_X) gets ONLY acme/tgt_X's run.
got, ok := m.Claim(context.Background(), "acme", "tgt_X")
if !ok || got.SessionID != "sess_mine" {
t.Fatalf("claim leaked across a boundary: ok=%v run=%+v", ok, got)
}
// And that queue is now empty — no foreign run fell through.
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
if _, ok := m.Claim(ctx, "acme", "tgt_X"); ok {
t.Fatal("a foreign run must never be claimable as acme/tgt_X")
}
// A Report can only complete a run under its exact key: reporting the foreign
// machine's session under tgt_X does nothing.
if m.Report("acme", "tgt_X", "sess_foreign_machine", RoutedResult{OK: true}) {
t.Fatal("report crossed the machine boundary")
}
if m.Report("acme", "tgt_Y", "sess_foreign_org", RoutedResult{OK: true}) {
t.Fatal("report crossed the org boundary")
}
}
// Two racing claimers, one run: exactly one wins.
func TestMailbox_NoDoubleClaim(t *testing.T) {
m := newMailbox()
m.Offer(mkRun("acme", "tgt_1", "sess_1"))
var wins int
var mu sync.Mutex
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
if _, ok := m.Claim(ctx, "acme", "tgt_1"); ok {
mu.Lock()
wins++
mu.Unlock()
}
}()
}
wg.Wait()
if wins != 1 {
t.Fatalf("exactly one claimer must win, got %d", wins)
}
}
// A claim with no work times out on ctx and reports no run — fail closed, never hang.
func TestMailbox_ClaimTimesOut(t *testing.T) {
m := newMailbox()
ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond)
defer cancel()
if _, ok := m.Claim(ctx, "acme", "tgt_empty"); ok {
t.Fatal("an empty mailbox must not yield a run")
}
}
// The durable owner's Await unblocks (fail-closed) when its budget ctx fires with
// no report — the machine never claimed, or claimed and died.
func TestMailbox_AwaitFailsClosedOnDeadline(t *testing.T) {
m := newMailbox()
off := m.Offer(mkRun("acme", "tgt_1", "sess_1"))
ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond)
defer cancel()
if _, ok := off.Await(ctx); ok {
t.Fatal("await must fail closed when the deadline fires without a report")
}
}
// A re-offer of the same run (workflow retry / cloud restart) supersedes the stale
// offer: the old waiter unblocks abandoned, and the fresh run is claimable.
func TestMailbox_ReOfferSupersedes(t *testing.T) {
m := newMailbox()
old := m.Offer(mkRun("acme", "tgt_1", "sess_1"))
// re-offer BEFORE anyone claims the first
fresh := m.Offer(mkRun("acme", "tgt_1", "sess_1"))
// old is abandoned
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if _, ok := old.Await(ctx); ok {
t.Fatal("the superseded offer must not complete")
}
// exactly one claimable run remains, and reporting reaches the fresh offer
got, ok := m.Claim(context.Background(), "acme", "tgt_1")
if !ok || got.SessionID != "sess_1" {
t.Fatalf("fresh run not claimable: %+v", got)
}
if _, ok := m.Claim(ctxShort(), "acme", "tgt_1"); ok {
t.Fatal("the stale offer must not have left a duplicate in the queue")
}
done := make(chan struct{})
go func() { fresh.Await(context.Background()); close(done) }()
if !m.Report("acme", "tgt_1", "sess_1", RoutedResult{OK: true}) {
t.Fatal("report must reach the fresh offer")
}
<-done
}
// Report for an unknown/already-finished run is a clean false.
func TestMailbox_ReportUnknownIsNoOp(t *testing.T) {
m := newMailbox()
if m.Report("acme", "tgt_1", "nope", RoutedResult{OK: true}) {
t.Fatal("report for an unknown run must be a no-op")
}
}
func ctxShort() context.Context {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Millisecond)
_ = cancel
return ctx
}
+119
View File
@@ -0,0 +1,119 @@
// personalities.go seeds an org's BUILT-IN agents — the named personas a human
// @-mentions in Hanzo Team (@dev to build, @des to design, @vi for vision). They
// are ordinary rows in the ONE agent registry (this package's store): nothing
// about them is special-cased downstream — they list, project into Team as bot
// members (bots.go), and answer through the SAME agents.RunOnBehalf path every
// other agent uses. The only thing this file adds is a one-time, idempotent
// create so a fresh org has its crew without anyone POSTing them by hand.
//
// One and only one seed: keyed by the registry's UNIQUE(org,name), a re-seed is a
// no-op (errConflict is swallowed). The Name is the @-handle (dev/des/vi), so the
// Team mention resolves to the persona; Description is the human-facing title.
package agents
import (
"context"
"errors"
"strings"
"time"
)
// persona is one built-in agent definition. Name is the lowercase @-handle;
// Description is the display title; Instructions is the system prompt that gives
// the persona its voice and remit.
type persona struct {
Name string
Description string
Instructions string
}
// personalities is the canonical built-in crew. Adding one here is the ONE way a
// new default persona ships — no per-org config, no duplicate definition. The old
// hanzo.ai site's voices, brought into Team.
var personalities = []persona{
{
Name: "dev",
Description: "Dev — the builder",
Instructions: "You are Dev, Hanzo's builder. You ship. When a human @-mentions you " +
"you write the code, wire the change, and report what you did in plain terms — " +
"file paths, commands, results. You prize the smallest correct change, one and " +
"only one way to do a thing, and no ceremony. You never hand-wave: if you built " +
"it you say so with proof; if you're blocked you name the blocker. Terse, exact, " +
"and always moving toward a finished, working solution.",
},
{
Name: "des",
Description: "Des — the designer",
Instructions: "You are Des, Hanzo's designer. You own how it looks and feels — layout, " +
"type, color, motion, the whole experience. When a human @-mentions you, you " +
"think in systems, not one-off screens: a token, a component, a consistent rule " +
"that reads as one product in light and dark. You give concrete, buildable design " +
"direction (spacing, hierarchy, states), not vague taste. Elegant, accessible, and " +
"opinionated — you make the obvious thing beautiful.",
},
{
Name: "vi",
Description: "Vi — the visionary",
Instructions: "You are Vi, Hanzo's visionary lead. You hold the big picture and the long " +
"arc — where the product is going, why it matters, and what to do next to get there. " +
"When a human @-mentions you, you connect the dots across the org, cut through noise " +
"to the one thing that matters, and rally the crew around it. You think in bets and " +
"outcomes, name the strategy plainly, and turn a sprawling ask into a sharp, " +
"sequenced plan. Inspiring, decisive, and grounded in what actually ships.",
},
}
// SeedPersonalities ensures the built-in crew exists for org. Idempotent: an
// already-present persona (UNIQUE org+name) is left untouched, so it is safe to
// call on every org first-touch (a new Team workspace, say). Returns the number
// newly created.
//
// It needs a model to attach — the deployment's configured default. With no
// default model, seeding is a NO-OP (0, nil): an org gets its crew the moment the
// binary has a model to run them on, never a half-created persona that can't run.
// A subsystem that is not mounted also no-ops rather than erroring, so a caller on
// the login path can call it best-effort without ever blocking a human.
func SeedPersonalities(ctx context.Context, org string) (int, error) {
if mounted == nil || mounted.State.store == nil {
return 0, nil
}
org = strings.TrimSpace(org)
if org == "" {
return 0, nil
}
model := strings.TrimSpace(mounted.State.defaultModel)
if model == "" {
return 0, nil
}
created := 0
now := time.Now().Unix()
for _, p := range personalities {
id, err := genID("agent")
if err != nil {
return created, err
}
a := Agent{
ID: id,
Org: org,
Name: p.Name,
Model: model,
Instructions: p.Instructions,
Description: p.Description,
Status: "ready",
CreatedAt: now,
UpdatedAt: now,
}
err = mounted.State.store.Create(ctx, a)
switch {
case err == nil:
created++
case errors.Is(err, errConflict):
// Already seeded — the one-way idempotent no-op.
default:
return created, err
}
}
return created, nil
}

Some files were not shown because too many files have changed in this diff Show More