Compare commits

...
Author SHA1 Message Date
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
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
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
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
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
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 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
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
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 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
383 changed files with 48128 additions and 3556 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
+1
View File
@@ -34,4 +34,5 @@ Thumbs.db
.shots/
.claude/
.worktrees/
native/flags/target
+74
View File
@@ -6,6 +6,44 @@ 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 |
| 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).
## Framework doctrine
One way to do everything. Composable, orthogonal, DRY. A new subsystem is a
@@ -184,3 +222,39 @@ 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.
+12 -1
View File
@@ -24,7 +24,7 @@ OPENAPI_DIR ?= ../openapi
# forces the fork to modernc too so the whole binary registers "sqlite" once.
CGO_ENABLED ?= 0
.PHONY: help 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 hanzo run smoke test test-cgo vet tidy docker docker-push clean
help: ## Show this help.
@awk 'BEGIN{FS=":.*##";printf "\nUsage: make <target>\n\nTargets:\n"} /^[a-zA-Z_-]+:.*##/{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
@@ -40,6 +40,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
+36 -18
View File
@@ -51,6 +51,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"
@@ -65,15 +66,17 @@ import (
"github.com/hanzoai/cloud/clients/bots"
"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/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/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"
@@ -236,7 +239,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).
@@ -250,6 +256,11 @@ func Wire() []cloud.MountSpec {
{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
@@ -314,10 +325,16 @@ func Wire() []cloud.MountSpec {
{Name: "graph", Mount: graph.Mount},
{Name: "security", Mount: security.Mount, Shutdown: ctxShutdown(security.Shutdown), OwnsHealth: true},
{Name: "integrations", Mount: integrations.Mount, Shutdown: integrations.Shutdown},
// Per-org Cloudflare asset plane /v1/integrations/cloudflare/{pages,workers,r2,kv,d1}/*.
// Mounts AFTER integrations because it reads the org's Cloudflare token through
// the integrations custody seam (integrations.TokenFor) — one token, one
// custody boundary. Stateless: no store, no shutdown.
{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},
@@ -336,23 +353,24 @@ func Wire() []cloud.MountSpec {
{Name: "evals", Mount: eval.Mount},
{Name: "treasury", Mount: treasury.Mount, Shutdown: ctxShutdown(treasury.Shutdown)},
{Name: "admin", Mount: admin.Mount},
// Launch-control (per-service waitlist mode) folded into the flags engine: the
// mode IS the switch waitlist.<svc>, the board is the /v1/admin/services lens,
// and /v1/featuregate/mode is served by flags. featuregate is no longer a mounted
// subsystem — it exposes only the native Enforce middleware (wired in serve.go),
// a consumer of flags.WaitlistModeForHost.
// 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
+55
View File
@@ -21,15 +21,19 @@ import (
"net/http"
"path/filepath"
"strings"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/commerceclient"
"github.com/hanzoai/cloud/clients/commerceinproc"
financeclient "github.com/hanzoai/cloud/clients/finance"
"github.com/hanzoai/commerce"
commercebilling "github.com/hanzoai/commerce/api/billing"
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"
)
@@ -164,6 +168,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 +232,41 @@ 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)
}
+89 -86
View File
@@ -23,92 +23,95 @@ 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
{"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
{"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
{"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
{"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)
{"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
+48
View File
@@ -0,0 +1,48 @@
// 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_AcceptsHanzoTeam pins the hanzo.team OIDC client. IAM mints
// team's access tokens with aud=hanzo-team (each app's aud is its client_id);
// the team OAuth callback sets that token as the hanzo_iam_token cookie, and
// the usage/wallet page (/v1/team/billing/ui/) reads /v1/billing/balance +
// /v1/usage/summary same-origin on it. If hanzo-team is not accepted the
// cookie resolves anonymous and every wallet read 401s — and the callback's
// own validator (NewTokenValidator shares this allowlist) refuses the login.
func TestJWTAudiences_AcceptsHanzoTeam(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-team") {
t.Fatalf("defaultJWTAudiences must include hanzo-team (the hanzo.team client_id); got %v", defaultJWTAudiences)
}
if !has(jwtAudiencesFromEnv(), "hanzo-team") {
t.Fatalf("resolved JWT audiences must include hanzo-team; 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
+36 -15
View File
@@ -42,6 +42,7 @@ type idClaims struct {
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"`
@@ -62,6 +63,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.
@@ -123,7 +138,7 @@ func newIdentityValidator(issuer, jwksURL string, audiences []string, ttl time.D
issuers: trustedIssuers(issuer),
audiences: audiences,
cache: newJWKSCache(jwksURL, ttl),
keys: newIAMKeys(),
keys: sharedKeys(), // ONE resolver+cache, shared with OrgForKey (analytics capture)
}
}
@@ -197,6 +212,16 @@ func (v *identityValidator) validate(raw string) (*idClaims, error) {
return nil, err
}
// Fail SECURE on a misconfigured (empty) trust set: an empty issuer OR audience
// allowlist must REJECT every token, never silently disable that axis. In
// production both are always resolved non-empty (BrandIssuers + the baked
// audience defaults, unioned in config.go so they are "never empty"), so this
// fires ONLY on an operator misconfiguration (CLOUD_JWT_AUDIENCES="" emptying the
// resolved set, or an empty issuer set) — and then it denies, it never admits (I2).
if len(v.issuers) == 0 || len(v.audiences) == 0 {
return nil, fmt.Errorf("identity validator misconfigured: empty issuer or audience allowlist")
}
// Reject a missing issuer: an empty issuer must never pass the set check.
if claims.Issuer == "" {
return nil, fmt.Errorf("missing issuer")
@@ -221,14 +246,13 @@ func (v *identityValidator) validate(raw string) (*idClaims, error) {
// 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)
// The audience allowlist is guaranteed non-empty (checked above), so the
// audience axis is ALWAYS enforced — never silently skipped.
auds := v.audiences
if mach := kmsMachineAudience(claims.Owner); mach != "" {
auds = append(append(make([]string, 0, len(v.audiences)+1), v.audiences...), mach)
}
expected := jwt.Expected{AnyAudience: jwt.Audience(auds)}
if err := claims.Claims.ValidateWithLeeway(expected, 2*time.Minute); err != nil {
return nil, fmt.Errorf("claims: %w", err)
}
@@ -425,14 +449,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
+39 -3
View File
@@ -1,10 +1,45 @@
package cloud
import (
"crypto/rand"
"crypto/rsa"
"os"
"testing"
"time"
)
// TestValidate_FailSecureOnEmptyTrustSet proves I2: a validator whose resolved
// issuer OR audience allowlist is empty REJECTS an otherwise-valid, correctly
// signed token — the axis is never silently disabled. Production always resolves
// non-empty sets; this guards the misconfiguration path (CLOUD_JWT_AUDIENCES=""
// or 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, []string{"hanzo-console"}, 0).validate(tok); err != nil {
t.Fatalf("baseline valid token must be accepted, got %v", err)
}
// Empty audience set → deny.
if _, err := newIdentityValidator(testIssuer, jwks.URL, nil, 0).validate(tok); err == nil {
t.Error("empty audience allowlist must REJECT (fail-secure), not accept")
}
// Empty issuer set → deny (construct directly; trustedIssuers never yields empty
// with a primary, so bypass it to exercise the guard).
vEmptyIss := &identityValidator{issuers: nil, audiences: []string{"hanzo-console"}, cache: newJWKSCache(jwks.URL, 0), keys: newIAMKeys()}
if _, err := vEmptyIss.validate(tok); err == nil {
t.Error("empty issuer allowlist 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 +75,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 +85,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")
}
}
+42 -8
View File
@@ -14,7 +14,7 @@ import (
"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"
@@ -90,6 +90,7 @@ 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)
wireTierReader(deps.Metering, logger)
deps.AI = meteredAIClient(pickAIClient(cfg, logger), deps)
wireFinance(cfg, logger)
deps.O11y = pick(cfg, logger, "o11y", "O11y", cfg.O11yZAPAddr, clients.O11yRPCAt, clients.DisabledO11y)
@@ -107,7 +108,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 +118,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 +158,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 +176,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 +196,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 +
+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)
}
}
}
+15 -8
View File
@@ -474,11 +474,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 +489,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,7 +596,6 @@ func newRootCmd() *cobra.Command {
newDeployCmd(envOf, &f),
newClustersCmd(envOf, &f),
newBuildCmd(envOf, &f),
newK8sCmd(envOf, &f),
newConfigCmd(),
newSecurityCmd(envOf),
newGPUCmd(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{})
+64 -22
View File
@@ -147,6 +147,7 @@ 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
@@ -163,10 +164,11 @@ type codeAgent struct {
// 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"},
bin: bin,
wire: openaiWire,
fullAuto: []string{"--dangerously-bypass-approvals-and-sandbox"},
continueArgs: []string{"resume", "--last"},
modelArg: []string{"-m"},
provider: func(base string) []string {
return []string{
"-c", "model_provider=hanzo",
@@ -174,6 +176,12 @@ func codexLike(bin, install string) codeAgent {
"-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"`,
// api.hanzo.ai exposes the standard OpenAI /v1/models shape,
// not Codex's private remote model-catalog schema. Skip that
// optional refresh and supply the coding model's metadata here.
"-c", `features.remote_models=false`,
"-c", `model_context_window=262144`,
"-c", `model_auto_compact_token_limit=235929`,
}
},
install: install,
@@ -190,9 +198,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 +255,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 +332,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 +385,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 +400,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 {
+77 -10
View File
@@ -18,6 +18,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"slices"
"testing"
"time"
@@ -48,6 +49,72 @@ 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) {
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=262144`,
`model_auto_compact_token_limit=235929`,
} {
if !slices.Contains(argv, want) {
t.Errorf("Codex argv %q does not contain %q", argv, want)
}
}
}
// 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 +127,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 +148,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 }
+142 -10
View File
@@ -39,6 +39,7 @@ import (
"regexp"
"runtime"
"slices"
"strconv"
"strings"
"syscall"
"time"
@@ -54,6 +55,10 @@ const (
heartbeatEvery = 30 * time.Second
claimPoll = 2 * time.Second
claimLeaseSecs = 120
// renderWindow matches the dispatch cap (studio gpu_dispatch sets
// startToCloseTimeout 14400s). The old 10m local poll undercut it and
// marked live renders failed while they kept sampling (observed 8-70m).
renderWindow = 4 * time.Hour
// localComfyUI is the studio render backend the studio.render handler drives.
localComfyUI = "http://127.0.0.1:8188"
// defaultStudioUploadURL is where finished render outputs are POSTed so they
@@ -155,6 +160,8 @@ type worker struct {
hostname string
jobsNS string
gpus []gpuInfo
arch string // CPU arch (`uname -m`), detected once at newWorker
memory int64 // total system RAM in bytes, detected once at newWorker
handlers map[string]jobHandler
// studioUploadURL is the org studio base that receives finished render outputs
@@ -194,8 +201,16 @@ type gpuInfo struct {
// additive (omitempty): an older cloud that does not read them still renders the
// GPU; a newer one advertises the engine endpoint on GET /v1/fleet/workers.
type registration struct {
Hostname string `json:"hostname"`
Os string `json:"os"`
Hostname string `json:"hostname"`
Os string `json:"os"`
// Arch/CPUs/Memory are THIS host's static CPU spec, in the SAME convention the
// fleet already uses for code-linked run-targets: Arch is `uname -m`
// (aarch64 | x86_64 | arm64), Memory is total system RAM in BYTES. Matching the
// existing convention matters — evo-2 and spark appear on the board as BOTH a
// run-target and a gpu-connect worker, so both rows must show the SAME arch.
Arch string `json:"arch,omitempty"`
CPUs int `json:"cpus,omitempty"`
Memory int64 `json:"memory,omitempty"`
Version string `json:"version"`
JobQueue string `json:"jobQueue"`
GPUs []gpuInfo `json:"gpus"`
@@ -288,6 +303,8 @@ func newWorker(env *Env, jobsNS string) (*worker, error) {
hostname: host,
jobsNS: firstNonEmpty(jobsNS, defaultJobsNS),
gpus: detectGPUs(),
arch: detectArch(),
memory: detectMemTotal(),
studioUploadURL: firstNonEmpty(os.Getenv("HANZO_STUDIO_UPLOAD_URL"), defaultStudioUploadURL),
}
policy, err := loadSharePolicy()
@@ -361,6 +378,66 @@ func detectAppleGPU() []gpuInfo {
return []gpuInfo{info}
}
// detectArch reports this machine's CPU architecture in the SAME convention the
// fleet already uses for code-linked nodes — `uname -m` (aarch64 | x86_64 on Linux,
// arm64 | x86_64 on Darwin) — so a machine that shows up as both a run-target and a
// gpu-connect worker carries ONE arch string on the board. Falls back to the
// compiled runtime.GOARCH only if uname is unavailable; "" is never forced.
func detectArch() string {
if out, err := exec.Command("uname", "-m").Output(); err == nil {
if a := strings.TrimSpace(string(out)); a != "" {
return a
}
}
return runtime.GOARCH
}
// detectMemTotal returns this machine's total physical RAM in bytes, or 0 when it
// cannot be read (reported as "unknown" via omitempty — never faked). Linux reads
// /proc/meminfo's MemTotal (covers evo-2's Strix Halo and spark's GB10, both Linux);
// Darwin reads sysctl hw.memsize. This is the SAME total a code-linked box reports
// as Spec.Memory, so the fleet board describes both kinds of node identically.
func detectMemTotal() int64 {
if runtime.GOOS == "darwin" {
out, err := exec.Command("sysctl", "-n", "hw.memsize").Output()
if err != nil {
return 0
}
var b int64
if _, err := fmt.Sscan(strings.TrimSpace(string(out)), &b); err == nil && b > 0 {
return b
}
return 0
}
b, err := os.ReadFile("/proc/meminfo")
if err != nil {
return 0
}
return parseMemTotalKB(b)
}
// parseMemTotalKB extracts MemTotal from /proc/meminfo content (reported in kB) and
// returns it in bytes, or 0 when the line is absent or malformed.
func parseMemTotalKB(meminfo []byte) int64 {
sc := bufio.NewScanner(bytes.NewReader(meminfo))
for sc.Scan() {
line := sc.Text()
if !strings.HasPrefix(line, "MemTotal:") {
continue
}
f := strings.Fields(line) // "MemTotal:" <kb> "kB"
if len(f) < 2 {
return 0
}
kb, err := strconv.ParseInt(f[1], 10, 64)
if err != nil || kb <= 0 {
return 0
}
return kb * 1024
}
return 0
}
// ---------------------------------------------------------------------------
// connect.
// ---------------------------------------------------------------------------
@@ -506,6 +583,9 @@ func (w *worker) buildRegistration() registration {
return registration{
Hostname: w.hostname,
Os: runtime.GOOS,
Arch: w.arch,
CPUs: runtime.NumCPU(),
Memory: w.memory,
Version: Version,
JobQueue: w.jobsNS,
GPUs: w.gpus,
@@ -820,6 +900,14 @@ func (w *worker) studioRenderHandler(ctx context.Context, input json.RawMessage)
return nil, fmt.Errorf("studio.render: input needs a `prompt` graph")
}
cl := &http.Client{Timeout: 60 * time.Second}
// The claim-to-submit window: hold off the supervisor's recycle, and wait out
// one if it is already mid-flight — a claimed job must never die on staging
// because the engine happened to be restarting.
staging.Add(1)
defer staging.Add(-1)
if err := waitEngine(ctx, cl); err != nil {
return nil, fmt.Errorf("studio.render: %w", err)
}
// Materialize any uploaded inputs (they live in orgs/{org}/input on the cloud
// pod, which this worker cannot read) into the LOCAL studio input dir via its
// own /upload/image, so LoadImage resolves them before we render.
@@ -848,7 +936,7 @@ func (w *worker) studioRenderHandler(ctx context.Context, input json.RawMessage)
return nil, fmt.Errorf("studio.render: no prompt_id in /prompt response")
}
// Poll history until the prompt shows up (completed).
deadline := time.Now().Add(10 * time.Minute)
deadline := time.Now().Add(renderWindow)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
@@ -872,6 +960,11 @@ func (w *worker) studioRenderHandler(ctx context.Context, input json.RawMessage)
if uerr != nil {
return nil, fmt.Errorf("studio.render: prompt %s rendered but gallery upload failed: %w", pr.PromptID, uerr)
}
// The engine leaks ~58GB per render; recycling after each completed
// render caps it at one render's worth. Boot (~20s) is noise next to
// 8-70m renders. Never recycle on the timeout path — the engine may
// still be sampling and the mirror rescues late finishes.
requestStudioRecycle()
return map[string]any{"promptId": pr.PromptID, "outputs": outputs, "gallery": gallery}, nil
}
}
@@ -1014,6 +1107,12 @@ func (w *worker) mirrorRenders(ctx context.Context, out io.Writer, dir, base str
if werr != nil || info == nil || info.IsDir() || !isImageFile(p) {
return nil
}
// Hidden files and AppleDouble forks (`._*`, `.DS_Store`) ride along with
// mac scp and are not renders — `._foo.png` passes the extension check
// but is a 4KB resource fork that poisons the library.
if strings.HasPrefix(filepath.Base(p), ".") {
return nil
}
rel, rerr := filepath.Rel(dir, p)
if rerr != nil {
return nil
@@ -1148,14 +1247,47 @@ func (w *worker) materializeInputs(ctx context.Context, cl *http.Client, inputs
return nil
}
// collectOutputs pulls the output image/file names out of a ComfyUI history entry.
// waitEngine blocks until the local engine answers its /queue — up to 90s, which
// outlasts any supervisor recycle (engine restart is seconds, model reload longer).
func waitEngine(ctx context.Context, cl *http.Client) error {
deadline := time.Now().Add(90 * time.Second)
for {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, localComfyUI+"/queue", nil)
if err != nil {
return err
}
resp, err := cl.Do(req)
if err == nil {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<10))
resp.Body.Close()
if resp.StatusCode/100 == 2 {
return nil
}
}
if time.Now().After(deadline) {
return fmt.Errorf("engine not up: %v", err)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(3 * time.Second):
}
}
}
// collectOutputs pulls the output file names out of a ComfyUI history entry. Savers
// publish under different keys — SaveImage/SaveVideo under "images", SaveGLB under
// "3d" — so every saver's outputs are gathered, not just images (a 3D mesh would
// otherwise never travel back to the library).
func collectOutputs(entry json.RawMessage) []string {
type namedFile struct {
Filename string `json:"filename"`
Subfolder string `json:"subfolder"`
}
var e struct {
Outputs map[string]struct {
Images []struct {
Filename string `json:"filename"`
Subfolder string `json:"subfolder"`
} `json:"images"`
Images []namedFile `json:"images"`
ThreeD []namedFile `json:"3d"`
} `json:"outputs"`
}
if err := json.Unmarshal(entry, &e); err != nil {
@@ -1163,8 +1295,8 @@ func collectOutputs(entry json.RawMessage) []string {
}
var files []string
for _, node := range e.Outputs {
for _, img := range node.Images {
files = append(files, filepath.Join(img.Subfolder, img.Filename))
for _, f := range append(append([]namedFile{}, node.Images...), node.ThreeD...) {
files = append(files, filepath.Join(f.Subfolder, f.Filename))
}
}
return files
+88
View File
@@ -0,0 +1,88 @@
package cli
// gpu_spec_test.go — the host static-spec a `hanzo gpu connect` 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 gpu-connect 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)
}
}
+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)
}
}
+69 -6
View File
@@ -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"
)
@@ -107,6 +109,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 +182,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 +195,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")
}
}
}
+61 -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,21 @@ 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
}
+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")
}
}
+39 -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,56 @@ 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("/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
+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()
+54
View File
@@ -295,6 +295,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 +344,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))
}
+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
+7 -6
View File
@@ -106,13 +106,14 @@ 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))
}
// ---- shared helpers (mirror clients/crm) ----
+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"
)
+8 -7
View File
@@ -276,6 +276,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 +285,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,
+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
}
+179
View File
@@ -0,0 +1,179 @@
package agents
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
)
// routing.go is the machine-identity + liveness plane for routed runs (#48 half
// B). A run dispatched to a target is executed by an EXTERNAL machine (`hanzo
// code --serve`) that claims it over HTTP. Two properties make that safe:
//
// - MACHINE IDENTITY. A target carries a claim key — a high-entropy capability
// minted server-side, returned to the daemon ONCE, and stored only as a
// SHA-256 hash (never plaintext, like every other secret). A claim/report
// must present the key; cloud verifies it in constant time, scoped to
// (org, target). Possession of the key IS being that machine, so one machine
// can never claim another's runs even within the same org.
//
// - LIVENESS. Dispatch routes ONLY to a target with a live runner. A serve
// daemon proves liveness by polling Claim, which stamps serving_at; the gate
// rejects a target whose last poll is older than servingTTL. A run to a dead
// or absent runner fails closed at dispatch (never silently runs elsewhere),
// and one that dies mid-flight is re-queued/timed-out by the durable owner.
//
// The claim key lives in its own table so the live (A) target CRUD is untouched.
const (
// servingTTL bounds how stale a target's last claim poll may be and still be
// considered "a runner is listening". The serve daemon re-polls right after a
// 25s long-poll returns empty, so a healthy runner stamps well inside this.
servingTTL = 90 * time.Second
claimKeyPrefix = "tgtk_"
claimKeyBytes = 32 // 256-bit capability
maxClaimKey = 128
)
var (
errNoClaimKey = errors.New("agents: target has no claim key")
errClaimKeyBad = errors.New("agents: claim key mismatch")
errTargetNotLive = errors.New("agents: target has no live runner")
errTargetNotReady = errors.New("agents: target is not online")
)
// migrateClaimKeys creates the per-target claim-key + serving-liveness table in
// the SAME agents.db (one store, one tenancy column). Idempotent.
func (s *Store) migrateClaimKeys() error {
const ddl = `
CREATE TABLE IF NOT EXISTS agent_target_claim_keys (
org TEXT NOT NULL,
target_id TEXT NOT NULL,
key_hash TEXT NOT NULL,
serving_at INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY (org, target_id)
);`
if _, err := s.db.Exec(ddl); err != nil {
return fmt.Errorf("migrate claim keys: %w", err)
}
return nil
}
// UpsertClaimKeyHash stores (or rotates) a target's claim-key hash. serving_at is
// reset to 0 on a fresh mint — the daemon proves liveness by its first poll.
func (s *Store) UpsertClaimKeyHash(ctx context.Context, org, targetID, hash string, now int64) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO agent_target_claim_keys (org,target_id,key_hash,serving_at,updated_at)
VALUES (?,?,?,0,?)
ON CONFLICT(org,target_id) DO UPDATE SET key_hash=excluded.key_hash, serving_at=0, updated_at=excluded.updated_at`,
org, targetID, hash, now)
if err != nil {
return fmt.Errorf("upsert claim key: %w", err)
}
return nil
}
// ClaimKeyHash returns a target's stored hash + last serving stamp, or
// errNoClaimKey when none was ever minted.
func (s *Store) ClaimKeyHash(ctx context.Context, org, targetID string) (hash string, servingAt int64, err error) {
row := s.db.QueryRowContext(ctx,
`SELECT key_hash, serving_at FROM agent_target_claim_keys WHERE org=? AND target_id=?`, org, targetID)
err = row.Scan(&hash, &servingAt)
if errors.Is(err, sql.ErrNoRows) {
return "", 0, errNoClaimKey
}
if err != nil {
return "", 0, fmt.Errorf("get claim key: %w", err)
}
return hash, servingAt, nil
}
// StampServing records that a target's runner polled at now (its liveness
// heartbeat). Best-effort by the caller; a missing row is a no-op.
func (s *Store) StampServing(ctx context.Context, org, targetID string, now int64) error {
_, err := s.db.ExecContext(ctx,
`UPDATE agent_target_claim_keys SET serving_at=? WHERE org=? AND target_id=?`, now, org, targetID)
return err
}
// hashClaimKey is the at-rest form: SHA-256 hex of a high-entropy token. A random
// 256-bit key needs no password KDF; SHA-256 gives a fixed-size, constant-time-
// comparable digest and the plaintext is never stored.
func hashClaimKey(key string) string {
sum := sha256.Sum256([]byte(key))
return hex.EncodeToString(sum[:])
}
// newClaimKey mints a fresh capability token.
func newClaimKey() (string, error) {
b := make([]byte, claimKeyBytes)
if _, err := rand.Read(b); err != nil {
return "", err
}
return claimKeyPrefix + hex.EncodeToString(b), nil
}
// verifyClaimKey checks a presented key against the target's stored hash in
// constant time. Fail-closed: no key on file, or an empty presented key, is a
// mismatch — never an accidental pass.
func (s *Store) verifyClaimKey(ctx context.Context, org, targetID, presented string) error {
presented = strings.TrimSpace(presented)
if presented == "" || len(presented) > maxClaimKey {
return errClaimKeyBad
}
stored, _, err := s.ClaimKeyHash(ctx, org, targetID)
if err != nil {
return err // errNoClaimKey or a real DB error
}
if subtle.ConstantTimeCompare([]byte(stored), []byte(hashClaimKey(presented))) != 1 {
return errClaimKeyBad
}
return nil
}
// TargetDispatchable is the DRY liveness gate, used at dispatch (fail closed
// before enqueue) AND re-checked at claim. A run is dispatchable only to a target
// that (a) exists in this org, (b) is online, and (c) has a live runner — a claim
// poll within servingTTL. Any failure is an explicit error the dispatcher renders
// honestly; it NEVER falls back to running elsewhere.
func (s *Store) TargetDispatchable(ctx context.Context, org, targetID string) error {
t, err := s.GetTarget(ctx, org, targetID)
if err != nil {
return err // errTargetNotFound or a real DB error
}
if t.Status != TargetOnline {
return errTargetNotReady
}
_, servingAt, err := s.ClaimKeyHash(ctx, org, targetID)
if err != nil {
return errTargetNotLive // no claim key => no runner ever attached
}
if servingAt <= 0 || time.Now().Unix()-servingAt > int64(servingTTL/time.Second) {
return errTargetNotLive
}
return nil
}
// TargetDispatchable is the exported gate the coding dispatcher injects (it never
// imports the store directly). Returns nil when a run may be routed to (org,
// targetID), else a descriptive error.
func TargetDispatchable(ctx context.Context, org, targetID string) error {
if mounted == nil {
return fmt.Errorf("agents: not mounted")
}
org = strings.TrimSpace(org)
targetID = strings.TrimSpace(targetID)
if org == "" || targetID == "" {
return fmt.Errorf("agents: org and target required")
}
return mounted.State.store.TargetDispatchable(ctx, org, targetID)
}
+216
View File
@@ -0,0 +1,216 @@
package agents
import (
"context"
"net/http"
"strings"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/principal"
"github.com/zap-proto/zip"
)
// routing_http.go is the machine-facing surface a `hanzo code --serve` daemon
// uses to CLAIM and complete routed runs. Every route is BOTH org-scoped (the
// gateway-minted X-Org-Id, exactly like the rest of the targets plane) AND
// machine-authenticated (the target claim key in X-Target-Key): a caller must
// prove it is acting in the target's org and that it holds that specific
// machine's capability. A run offered to target X is never reachable from a claim
// for target Y, and a claim for another org's target 404s at the org boundary.
//
// POST /v1/agents/targets/:id/claim-key mint/rotate this target's claim key -> {claimKey}
// POST /v1/agents/targets/:id/claim long-poll for the next routed run (X-Target-Key)
// POST /v1/agents/targets/:id/runs/:runId/report report a routed run's terminal result (X-Target-Key)
// claimLongPoll bounds one Claim wait; on expiry the daemon gets 204 and re-polls
// immediately, which also refreshes its serving liveness. A var (not a const) so a
// test can shrink the empty-poll window without waiting the full window.
var claimLongPoll = 25 * time.Second
const (
// claimKeyHeader carries the machine capability. Distinct from Authorization
// (which carries the org bearer): org identity and machine identity are two
// independent proofs, both required.
claimKeyHeader = "X-Target-Key"
maxReportField = 64 << 10
)
// mountRouting registers the route-work machine surface. Called from mountTargets
// AFTER the target CRUD routes so the extra-segment paths are unambiguous.
func mountRouting(s *cloud.Service[state], app *zip.App) {
assertSingleReplica(s.Log)
g := app.Group("/v1/agents")
g.Post("/targets/:id/claim-key", cloud.Handle(s, mintClaimKey))
g.Post("/targets/:id/claim", cloud.Handle(s, claimRoutedRun))
g.Post("/targets/:id/runs/:runId/report", cloud.Handle(s, reportRoutedRun))
}
// caller is the VALIDATED principal id (X-User-Id) — the machine-owner identity for
// route-work. tenant() already required a validated principal, so on any handler that
// resolved an org this is non-empty.
func caller(c *zip.Ctx) string { return strings.TrimSpace(c.User()) }
// ownsTarget reports whether the caller may MANAGE this target's route-work plane —
// mint/rotate the claim key, claim, report, patch, delete. A machine belongs to the
// principal that registered it (least privilege, AC-6): its owner may manage it, and
// an org admin (self-service org management, the admin-org model's isAdmin) may manage
// any of the org's targets. An UNOWNED (pre-migration) row is admin-only until its
// owner re-registers — register binds the owner. Fail-closed: an empty caller or an
// empty owner never satisfies the ownership arm, so a non-validated request or a
// pre-migration row is never owner-managed.
func ownsTarget(c *zip.Ctx, t Target) bool {
if principal.IsOrgAdmin(c) || principal.IsSuperAdmin(c) {
return true
}
u := caller(c)
return t.Owner != "" && u != "" && u == t.Owner
}
// authorizeTargetManage resolves the (org,id) target and gates it on ownsTarget,
// collapsing every failure — cross-org, unknown, or not-owned — to the SAME
// errTargetNotFound so the machine surface never distinguishes them (no oracle). The
// resolved target is returned for the caller to use (avoids a second read).
func authorizeTargetManage(s *cloud.Service[state], c *zip.Ctx, org, id string) (Target, error) {
t, err := s.State.store.GetTarget(c.Context(), org, id)
if err != nil {
return Target{}, errTargetNotFound // unknown / cross-org -> no oracle
}
if !ownsTarget(c, t) {
return Target{}, errTargetNotFound
}
return t, nil
}
// mintClaimKey (re)mints the target's claim key and returns it ONCE. Only the
// SHA-256 hash is stored. Org-scoped: only a caller in the target's org can mint,
// and the key is bound to (org, target). Rotating supersedes any prior daemon.
func mintClaimKey(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
id := idParam(c)
// The target must exist in this org AND the caller must OWN it (or be an org
// admin) before it can (re)mint a capability — minting rotates the key, so an
// un-scoped mint would let any org member strand a victim's daemon and steal its
// runs. Every failure collapses to the same not-found (no oracle).
if _, err := authorizeTargetManage(s, c, org, id); err != nil {
return zip.ErrNotFound("target not found")
}
key, err := newClaimKey()
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "rng: %v", err)
}
if err := s.State.store.UpsertClaimKeyHash(c.Context(), org, id, hashClaimKey(key), time.Now().Unix()); err != nil {
return zip.Errorf(http.StatusInternalServerError, "persist: %v", err)
}
return c.JSON(http.StatusOK, map[string]any{"targetId": id, "claimKey": key})
}
// claimRoutedRun authenticates the machine, refreshes its serving liveness, and
// long-polls the rendezvous for the next run addressed to THIS (org, target).
// 200 + the run on a claim; 204 when the poll window elapses with no work.
func claimRoutedRun(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
id := idParam(c)
// TWO proofs, both required, both fail-closed to the SAME 403 (no oracle): the
// caller must OWN this machine (or be an org admin) AND hold its claim key. The
// ownership gate is defense in depth — with mint owner-scoped an attacker cannot
// obtain a valid key for a victim's machine, but a claim still refuses a
// non-owner outright rather than resting solely on the capability.
if _, err := authorizeTargetManage(s, c, org, id); err != nil {
return claimAuthError(errTargetNotFound)
}
if err := s.State.store.verifyClaimKey(c.Context(), org, id, c.Header(claimKeyHeader)); err != nil {
return claimAuthError(err)
}
// The poll itself is the runner's liveness proof — stamp it so the dispatch
// gate (TargetDispatchable) sees a live runner. Best-effort.
_ = s.State.store.StampServing(c.Context(), org, id, time.Now().Unix())
ctx, cancel := context.WithTimeout(c.Context(), claimLongPoll)
defer cancel()
run, got := routedMailbox.Claim(ctx, org, id)
if !got {
return c.NoContent(http.StatusNoContent)
}
return c.JSON(http.StatusOK, routedRunView(run))
}
type reportReq struct {
OK bool `json:"ok"`
Changed bool `json:"changed"`
Branch string `json:"branch"`
CommitSha string `json:"commitSha"`
Diffstat string `json:"diffstat"`
Error string `json:"error"`
}
// reportRoutedRun completes a claimed run: it delivers the terminal result to the
// run's durable owner (the RoutedRunWorkflow activity), which lets the workflow
// finish. Scoped to (org, target, runId) AND claim-key-authenticated, so a
// machine can only ever report a run it legitimately holds. Idempotent: a report
// for an unknown/already-finished run is a clean no-op (the session terminal was
// already set by the machine's own stream).
func reportRoutedRun(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
id := idParam(c)
runID := strings.TrimSpace(c.Param("runId"))
// Same two proofs as claim: own the machine (or org admin) AND hold its key, so a
// non-owner can neither fabricate a report nor complete a victim's run. No oracle.
if _, err := authorizeTargetManage(s, c, org, id); err != nil {
return claimAuthError(errTargetNotFound)
}
if err := s.State.store.verifyClaimKey(c.Context(), org, id, c.Header(claimKeyHeader)); err != nil {
return claimAuthError(err)
}
var body reportReq
if err := c.Bind(&body); err != nil {
return err
}
res := RoutedResult{
OK: body.OK, Changed: body.Changed,
Branch: clampStr(body.Branch, maxRepo),
CommitSha: clampStr(body.CommitSha, 128),
Diffstat: clampStr(body.Diffstat, maxReportField),
Error: clampStr(body.Error, maxReportField),
}
delivered := routedMailbox.Report(org, id, runID, res)
return c.JSON(http.StatusOK, map[string]any{"delivered": delivered})
}
// claimAuthError maps the claim-key verdict onto a fail-closed HTTP status. A
// missing target row, a missing/mismatched key, and an unknown org all collapse
// to 403 so the surface never distinguishes "wrong key" from "no such target" —
// an unauthorized caller learns nothing about what exists.
func claimAuthError(err error) error {
switch err {
case errNoClaimKey, errClaimKeyBad, errTargetNotFound:
return zip.ErrForbidden("target claim rejected")
default:
return zip.Errorf(http.StatusInternalServerError, "claim auth: %v", err)
}
}
// routedRunView is the non-secret run spec handed to the machine. It carries no
// credential by design — the machine authenticates git + model routing with its
// own already-held credentials.
func routedRunView(run RoutedRun) map[string]any {
return map[string]any{
"sessionId": run.SessionID,
"repo": run.Repo,
"project": run.Project,
"base": run.Base,
"branch": run.Branch,
"prompt": run.Prompt,
"cloneUrl": run.CloneURL,
"timeoutSeconds": run.TimeoutSeconds,
}
}
+415
View File
@@ -0,0 +1,415 @@
package agents
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http/httptest"
"testing"
"time"
"github.com/zap-proto/zip"
)
// doKey is a keyless-body request with a machine claim key (X-Target-Key) attached.
func doKey(t *testing.T, app *zip.App, method, path, org, key string) (int, []byte) {
t.Helper()
req := httptest.NewRequest(method, path, nil)
if org != "" {
req.Header.Set("X-Org-Id", org)
req.Header.Set("X-User-Id", "u-"+org)
}
if key != "" {
req.Header.Set(claimKeyHeader, key)
}
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("Test %s %s: %v", method, path, err)
}
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, b
}
// registerAndMint registers a target for org and mints its claim key, returning
// (targetID, claimKey).
func registerAndMint(t *testing.T, app *zip.App, org, host string) (string, string) {
t.Helper()
code, body := do(t, app, "POST", "/v1/agents/targets", org, map[string]any{"label": host, "host": host})
if code != 201 && code != 200 {
t.Fatalf("register target: %d %s", code, body)
}
var tv struct {
ID string `json:"id"`
}
_ = json.Unmarshal(body, &tv)
code, body = doKey(t, app, "POST", "/v1/agents/targets/"+tv.ID+"/claim-key", org, "")
if code != 200 {
t.Fatalf("mint claim key: %d %s", code, body)
}
var kv struct {
ClaimKey string `json:"claimKey"`
}
_ = json.Unmarshal(body, &kv)
if kv.ClaimKey == "" {
t.Fatal("claim key empty")
}
return tv.ID, kv.ClaimKey
}
// A claim without the machine's key, or with the WRONG key, is refused — org
// membership alone is not enough to claim a machine's runs.
func TestClaim_RequiresMachineKey(t *testing.T) {
app := mountApp(t, &fakeAI{content: "x"})
old := claimLongPoll
claimLongPoll = 150 * time.Millisecond
defer func() { claimLongPoll = old }()
id, key := registerAndMint(t, app, "acme", "evo")
// No key => 403.
if code, _ := doKey(t, app, "POST", "/v1/agents/targets/"+id+"/claim", "acme", ""); code != 403 {
t.Fatalf("claim with no key must be 403, got %d", code)
}
// Wrong key => 403.
if code, _ := doKey(t, app, "POST", "/v1/agents/targets/"+id+"/claim", "acme", "tgtk_wrong"); code != 403 {
t.Fatalf("claim with wrong key must be 403, got %d", code)
}
// Right key, no work => 204 (never 200, never another tenant's run).
if code, _ := doKey(t, app, "POST", "/v1/agents/targets/"+id+"/claim", "acme", key); code != 204 {
t.Fatalf("claim with right key + no work must be 204, got %d", code)
}
}
// THE machine boundary: a key minted for target A cannot claim target B, and a
// different org cannot claim at all — even with a real key for its own target.
func TestClaim_CrossMachineAndCrossOrgDenied(t *testing.T) {
app := mountApp(t, &fakeAI{content: "x"})
old := claimLongPoll
claimLongPoll = 150 * time.Millisecond
defer func() { claimLongPoll = old }()
idA, keyA := registerAndMint(t, app, "acme", "evoA")
idB, _ := registerAndMint(t, app, "acme", "evoB")
// A's key against B => 403 (constant-time mismatch on B's stored hash).
if code, _ := doKey(t, app, "POST", "/v1/agents/targets/"+idB+"/claim", "acme", keyA); code != 403 {
t.Fatalf("A's key claiming B must be 403, got %d", code)
}
// Offer a run for acme/idA, then a DIFFERENT org cannot claim idA at all (its
// org scope resolves no such target => 403), and the run is never handed out.
OfferRoutedRun(RoutedRun{Org: "acme", TargetID: idA, SessionID: "sess_a", Repo: "api"})
if code, _ := doKey(t, app, "POST", "/v1/agents/targets/"+idA+"/claim", "evil", keyA); code != 403 {
t.Fatalf("another org claiming acme's target must be 403, got %d", code)
}
// acme WITH A's key claims its own run.
code, body := doKey(t, app, "POST", "/v1/agents/targets/"+idA+"/claim", "acme", keyA)
if code != 200 {
t.Fatalf("acme must claim its own run, got %d %s", code, body)
}
var rv struct {
SessionID string `json:"sessionId"`
}
_ = json.Unmarshal(body, &rv)
if rv.SessionID != "sess_a" {
t.Fatalf("claimed wrong run: %s", body)
}
}
// The end-to-end machine round trip: offer -> claim -> report reaches the durable
// owner awaiting the result.
func TestClaimReport_RoundTrip(t *testing.T) {
app := mountApp(t, &fakeAI{content: "x"})
id, key := registerAndMint(t, app, "acme", "evo")
off := OfferRoutedRun(RoutedRun{Org: "acme", TargetID: id, SessionID: "sess_rt", Repo: "api", Branch: "agent/rt"})
code, body := doKey(t, app, "POST", "/v1/agents/targets/"+id+"/claim", "acme", key)
if code != 200 {
t.Fatalf("claim: %d %s", code, body)
}
got := make(chan RoutedResult, 1)
go func() { res, _ := off.Await(context.Background()); got <- res }()
code, _ = doKeyBody(t, app, "POST", "/v1/agents/targets/"+id+"/runs/sess_rt/report", "acme", key,
map[string]any{"ok": true, "changed": true, "commitSha": "cafe"})
if code != 200 {
t.Fatalf("report: %d", code)
}
select {
case res := <-got:
if !res.OK || res.CommitSha != "cafe" {
t.Fatalf("report did not reach the owner: %+v", res)
}
case <-time.After(2 * time.Second):
t.Fatal("owner never received the report")
}
}
// doKeyBody is doKey with a JSON body.
func doKeyBody(t *testing.T, app *zip.App, method, path, org, key string, body any) (int, []byte) {
t.Helper()
b, _ := json.Marshal(body)
req := httptest.NewRequest(method, path, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
if org != "" {
req.Header.Set("X-Org-Id", org)
req.Header.Set("X-User-Id", "u-"+org)
}
if key != "" {
req.Header.Set(claimKeyHeader, key)
}
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("Test %s %s: %v", method, path, err)
}
defer func() { _ = resp.Body.Close() }()
out, _ := io.ReadAll(resp.Body)
return resp.StatusCode, out
}
// ---- store-level liveness gate ----
// TargetDispatchable is the fail-closed gate: online + a live runner (a recent
// claim poll). Offline, no key, or a stale poll all reject.
func TestTargetDispatchable_LivenessGate(t *testing.T) {
s := testSessionStore(t)
ctx := context.Background()
now := time.Now().Unix()
tgt := Target{ID: "t1", Org: "acme", Label: "evo", Kind: TargetMachine, Status: TargetOnline, Host: "evo", CreatedAt: now, UpdatedAt: now}
if err := s.CreateTarget(ctx, tgt); err != nil {
t.Fatal(err)
}
// No claim key yet => not live => not dispatchable.
if err := s.TargetDispatchable(ctx, "acme", "t1"); err != errTargetNotLive {
t.Fatalf("no runner => not dispatchable, got %v", err)
}
// Mint + a fresh serving stamp => dispatchable.
if err := s.UpsertClaimKeyHash(ctx, "acme", "t1", hashClaimKey("k"), now); err != nil {
t.Fatal(err)
}
if err := s.StampServing(ctx, "acme", "t1", now); err != nil {
t.Fatal(err)
}
if err := s.TargetDispatchable(ctx, "acme", "t1"); err != nil {
t.Fatalf("online + fresh runner => dispatchable, got %v", err)
}
// A stale serving stamp => not dispatchable (dead runner).
if err := s.StampServing(ctx, "acme", "t1", now-int64(servingTTL/time.Second)-5); err != nil {
t.Fatal(err)
}
if err := s.TargetDispatchable(ctx, "acme", "t1"); err != errTargetNotLive {
t.Fatalf("stale runner => not dispatchable, got %v", err)
}
// Fresh again but OFFLINE => not dispatchable.
_ = s.StampServing(ctx, "acme", "t1", time.Now().Unix())
tgt.Status = TargetOffline
tgt.UpdatedAt = time.Now().Unix()
if err := s.UpdateTarget(ctx, tgt); err != nil {
t.Fatal(err)
}
if err := s.TargetDispatchable(ctx, "acme", "t1"); err != errTargetNotReady {
t.Fatalf("offline => not dispatchable, got %v", err)
}
// Unknown target / cross-org => fail closed.
if err := s.TargetDispatchable(ctx, "acme", "nope"); err != errTargetNotFound {
t.Fatalf("unknown target => not found, got %v", err)
}
if err := s.TargetDispatchable(ctx, "evil", "t1"); err != errTargetNotFound {
t.Fatalf("cross-org => not found, got %v", err)
}
}
// The claim key is stored ONLY as a hash; verify is constant-time + fail closed.
func TestClaimKey_HashedAtRestAndVerified(t *testing.T) {
s := testSessionStore(t)
ctx := context.Background()
now := time.Now().Unix()
_ = s.CreateTarget(ctx, Target{ID: "t1", Org: "acme", Status: TargetOnline, CreatedAt: now, UpdatedAt: now})
key, _ := newClaimKey()
if err := s.UpsertClaimKeyHash(ctx, "acme", "t1", hashClaimKey(key), now); err != nil {
t.Fatal(err)
}
// The stored value is a hash, never the plaintext.
stored, _, _ := s.ClaimKeyHash(ctx, "acme", "t1")
if stored == key || stored != hashClaimKey(key) {
t.Fatalf("claim key must be stored as a hash, not plaintext")
}
if err := s.verifyClaimKey(ctx, "acme", "t1", key); err != nil {
t.Fatalf("correct key must verify: %v", err)
}
if err := s.verifyClaimKey(ctx, "acme", "t1", "tgtk_wrong"); err != errClaimKeyBad {
t.Fatalf("wrong key must fail: %v", err)
}
if err := s.verifyClaimKey(ctx, "acme", "t1", ""); err != errClaimKeyBad {
t.Fatalf("empty key must fail: %v", err)
}
// Cross-org verify resolves no key => fail closed.
if err := s.verifyClaimKey(ctx, "evil", "t1", key); err != errNoClaimKey {
t.Fatalf("cross-org verify must fail closed, got %v", err)
}
}
// ---- M2: owner/machine scoping of the claim-key plane ----
// reqAs sends a request with an EXPLICIT principal (X-User-Id) + optional org-admin
// bit and claim key + JSON body, so a test can prove owner/admin scoping distinct
// from org scoping.
func reqAs(t *testing.T, app *zip.App, method, path, org, user string, admin bool, key string, body any) (int, []byte) {
t.Helper()
var r io.Reader
if body != nil {
b, _ := json.Marshal(body)
r = bytes.NewReader(b)
}
req := httptest.NewRequest(method, path, r)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if org != "" {
req.Header.Set("X-Org-Id", org)
}
if user != "" {
req.Header.Set("X-User-Id", user)
}
if admin {
req.Header.Set("X-User-IsOrgAdmin", "true")
}
if key != "" {
req.Header.Set(claimKeyHeader, key)
}
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("Test %s %s: %v", method, path, err)
}
defer func() { _ = resp.Body.Close() }()
out, _ := io.ReadAll(resp.Body)
return resp.StatusCode, out
}
// registerAs registers a target in org OWNED by the given principal, returning its id.
func registerAs(t *testing.T, app *zip.App, org, user, host string) string {
t.Helper()
code, body := reqAs(t, app, "POST", "/v1/agents/targets", org, user, false, "", map[string]any{"label": host, "host": host})
if code != 201 && code != 200 {
t.Fatalf("register target: %d %s", code, body)
}
var tv struct {
ID string `json:"id"`
}
_ = json.Unmarshal(body, &tv)
if tv.ID == "" {
t.Fatalf("register returned no id: %s", body)
}
return tv.ID
}
// A machine belongs to the principal that registered it: a DIFFERENT member of the
// SAME org can neither mint/rotate its claim key, nor patch, nor delete it — only its
// owner or an org admin can. Every refusal collapses to not-found (no oracle).
func TestClaimKeyPlane_OwnerScoped(t *testing.T) {
app := mountApp(t, &fakeAI{content: "x"})
id := registerAs(t, app, "acme", "alice", "evo")
// A non-owner member of the same org is DENIED on every management verb.
if code, _ := reqAs(t, app, "POST", "/v1/agents/targets/"+id+"/claim-key", "acme", "mallory", false, "", nil); code != 404 {
t.Fatalf("non-owner mint must be denied (no oracle -> 404), got %d", code)
}
if code, _ := reqAs(t, app, "PATCH", "/v1/agents/targets/"+id, "acme", "mallory", false, "", map[string]any{"status": TargetOffline}); code != 404 {
t.Fatalf("non-owner patch must be denied, got %d", code)
}
if code, _ := reqAs(t, app, "DELETE", "/v1/agents/targets/"+id, "acme", "mallory", false, "", nil); code != 404 {
t.Fatalf("non-owner delete must be denied, got %d", code)
}
// The OWNER can mint.
code, body := reqAs(t, app, "POST", "/v1/agents/targets/"+id+"/claim-key", "acme", "alice", false, "", nil)
if code != 200 {
t.Fatalf("owner mint must succeed, got %d %s", code, body)
}
// An ORG ADMIN (self-service org management) can manage any of the org's machines.
if code, _ := reqAs(t, app, "POST", "/v1/agents/targets/"+id+"/claim-key", "acme", "boss", true, "", nil); code != 200 {
t.Fatalf("org admin mint must succeed, got %d", code)
}
if code, _ := reqAs(t, app, "PATCH", "/v1/agents/targets/"+id, "acme", "boss", true, "", map[string]any{"status": TargetOnline}); code != 200 {
t.Fatalf("org admin patch must succeed, got %d", code)
}
}
// A non-owner cannot CLAIM a victim's runs or REPORT on them, even if the claim-key
// authorization were somehow satisfied — the ownership gate refuses first.
func TestClaimReport_OwnerScoped(t *testing.T) {
app := mountApp(t, &fakeAI{content: "x"})
old := claimLongPoll
claimLongPoll = 150 * time.Millisecond
defer func() { claimLongPoll = old }()
id := registerAs(t, app, "acme", "alice", "evo")
// Alice mints her machine's key.
code, body := reqAs(t, app, "POST", "/v1/agents/targets/"+id+"/claim-key", "acme", "alice", false, "", nil)
if code != 200 {
t.Fatalf("owner mint: %d %s", code, body)
}
var kv struct {
ClaimKey string `json:"claimKey"`
}
_ = json.Unmarshal(body, &kv)
// Mallory (same org, not the owner) with the RIGHT key is still refused: she does
// not own the machine. (In practice she cannot obtain the key, since mint is
// owner-scoped — this is the defense-in-depth arm.)
if code, _ := reqAs(t, app, "POST", "/v1/agents/targets/"+id+"/claim", "acme", "mallory", false, kv.ClaimKey, nil); code != 403 {
t.Fatalf("non-owner claim must be 403, got %d", code)
}
if code, _ := reqAs(t, app, "POST", "/v1/agents/targets/"+id+"/runs/sess_x/report", "acme", "mallory", false, kv.ClaimKey, map[string]any{"ok": true}); code != 403 {
t.Fatalf("non-owner report must be 403, got %d", code)
}
// The owner with her key claims (no work -> 204) and reports fine.
if code, _ := reqAs(t, app, "POST", "/v1/agents/targets/"+id+"/claim", "acme", "alice", false, kv.ClaimKey, nil); code != 204 {
t.Fatalf("owner claim (no work) must be 204, got %d", code)
}
}
// A pre-migration UNOWNED target (owner=”) is admin-only, and its owner heals it by
// re-registering (register binds the owner). Proven at the store + handler seam.
func TestUnownedTarget_AdminOnly_ThenBoundByRegister(t *testing.T) {
app := mountApp(t, &fakeAI{content: "x"})
ctx := context.Background()
now := time.Now().Unix()
// Seed an unowned row directly, as an upgraded pre-owner DB would carry.
if err := mounted.State.store.CreateTarget(ctx, Target{ID: "tgt_legacy", Org: "acme", Owner: "", Label: "old", Kind: TargetMachine, Status: TargetOnline, Host: "old", CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
// A plain member cannot mint on an unowned row.
if code, _ := reqAs(t, app, "POST", "/v1/agents/targets/tgt_legacy/claim-key", "acme", "alice", false, "", nil); code != 404 {
t.Fatalf("unowned row must be member-denied, got %d", code)
}
// An org admin can.
if code, _ := reqAs(t, app, "POST", "/v1/agents/targets/tgt_legacy/claim-key", "acme", "boss", true, "", nil); code != 200 {
t.Fatalf("unowned row must be admin-manageable, got %d", code)
}
// The owner heals it by re-registering the SAME host — register ADOPTS the
// unowned row and BINDS the owner (no duplicate).
code, body := reqAs(t, app, "POST", "/v1/agents/targets", "acme", "alice", false, "", map[string]any{"label": "old", "host": "old"})
if code != 200 && code != 201 {
t.Fatalf("re-register: %d %s", code, body)
}
var tv struct {
ID string `json:"id"`
}
_ = json.Unmarshal(body, &tv)
if tv.ID != "tgt_legacy" {
t.Fatalf("re-register must adopt the unowned row (same id), got %q", tv.ID)
}
// Now alice (the bound owner) can mint.
if code, _ := reqAs(t, app, "POST", "/v1/agents/targets/tgt_legacy/claim-key", "acme", "alice", false, "", nil); code != 200 {
t.Fatalf("after binding, the owner must be able to mint, got %d", code)
}
}
+9 -9
View File
@@ -48,9 +48,9 @@ func (c *countingAI) Embed(_ context.Context, _ *types.EmbedRequest) ([][]float3
return nil, nil
}
// schedSvc builds a Service + scheduler with NO billing (gate allows) and the given
// schedService builds a Service + scheduler with NO billing (gate allows) and the given
// AI, seeded with the supplied agents. Returns the scheduler for direct tick().
func schedSvc(t *testing.T, ai types.AIClient, seed ...Agent) *scheduler {
func schedService(t *testing.T, ai types.AIClient, seed ...Agent) *scheduler {
t.Helper()
s := &cloud.Service[state]{Base: cloud.Base{Log: luxlog.New("test")}, State: state{store: testStore(t), ai: ai}}
for _, a := range seed {
@@ -83,7 +83,7 @@ func waitFor(cond func() bool) bool {
// run; a tick at a non-matching minute launches none.
func TestSchedulerFiresDueAgent(t *testing.T) {
ai := &countingAI{}
sc := schedSvc(t, ai, longRunning("acme", "cron", "*/5 * * * *"))
sc := schedService(t, ai, longRunning("acme", "cron", "*/5 * * * *"))
ctx := context.Background()
sc.tick(ctx, at(t, "2026-07-01 12:36")) // 36 not multiple of 5 -> no fire
@@ -102,7 +102,7 @@ func TestSchedulerFiresDueAgent(t *testing.T) {
// like an HTTP run — the scheduler shares runAgent.
func TestSchedulerRecordsRun(t *testing.T) {
ai := &countingAI{}
sc := schedSvc(t, ai, longRunning("acme", "cron", "* * * * *"))
sc := schedService(t, ai, longRunning("acme", "cron", "* * * * *"))
ctx := context.Background()
sc.tick(ctx, at(t, "2026-07-01 12:00"))
if !waitFor(func() bool {
@@ -119,7 +119,7 @@ func TestSchedulerRecordsRun(t *testing.T) {
// tick fires; the immediately-following matching tick is skipped (backoff=1).
func TestSchedulerBackoffOnFailure(t *testing.T) {
ai := &countingAI{fail: true}
sc := schedSvc(t, ai, longRunning("acme", "cron", "* * * * *"))
sc := schedService(t, ai, longRunning("acme", "cron", "* * * * *"))
ctx := context.Background()
sc.tick(ctx, at(t, "2026-07-01 12:00"))
@@ -152,7 +152,7 @@ func TestSchedulerBackoffOnFailure(t *testing.T) {
// second matching tick while it is in flight does NOT start a second run.
func TestSchedulerConcurrencyCap(t *testing.T) {
ai := &countingAI{block: make(chan struct{})}
sc := schedSvc(t, ai, longRunning("acme", "cron", "* * * * *"))
sc := schedService(t, ai, longRunning("acme", "cron", "* * * * *"))
ctx := context.Background()
sc.tick(ctx, at(t, "2026-07-01 12:00")) // starts run #1, which blocks
@@ -257,7 +257,7 @@ func TestSchedulerGatesUnfundedRun(t *testing.T) {
// path that lets Shutdown close the store safely.
func TestSchedulerStopDrainsCleanly(t *testing.T) {
ai := &countingAI{}
sc := schedSvc(t, ai, longRunning("acme", "cron", "* * * * *"))
sc := schedService(t, ai, longRunning("acme", "cron", "* * * * *"))
sc.start()
// Fire one run via a direct tick, then stop — stop must return after drain.
sc.tick(context.Background(), at(t, "2026-07-01 12:00"))
@@ -286,7 +286,7 @@ func TestSchedulerStopDrainsCleanly(t *testing.T) {
// rather than waiting the full runTimeout.
func TestSchedulerStopHonorsDeadline(t *testing.T) {
ai := &countingAI{block: make(chan struct{})}
sc := schedSvc(t, ai, longRunning("acme", "cron", "* * * * *"))
sc := schedService(t, ai, longRunning("acme", "cron", "* * * * *"))
sc.start()
sc.tick(context.Background(), at(t, "2026-07-01 12:00"))
if !waitFor(func() bool { return ai.count() == 1 }) {
@@ -310,7 +310,7 @@ func TestSchedulerStopHonorsDeadline(t *testing.T) {
func TestSchedulerOnlyLongRunning(t *testing.T) {
ai := &countingAI{}
one := mk("acme", "one") // one-shot default, no schedule
sc := schedSvc(t, ai, one)
sc := schedService(t, ai, one)
sc.tick(context.Background(), at(t, "2026-07-01 12:00"))
time.Sleep(20 * time.Millisecond)
if ai.count() != 0 {
+12 -11
View File
@@ -182,17 +182,18 @@ func toEventView(e Event) eventView {
// :name would otherwise capture "sessions"). Within the block, the static
// /stream route precedes the /:id param for the same reason.
func mountSessions(s *cloud.Service[state], app *zip.App) {
app.Post("/v1/agents/sessions", cloud.Handle(s, registerSession))
app.Get("/v1/agents/sessions", cloud.Handle(s, listSessions))
app.Get("/v1/agents/sessions/stream", cloud.Handle(s, sessionsStream))
app.Get("/v1/agents/sessions/:id", cloud.Handle(s, getSession))
app.Patch("/v1/agents/sessions/:id", cloud.Handle(s, patchSession))
app.Get("/v1/agents/sessions/:id/tree", cloud.Handle(s, sessionTree))
app.Post("/v1/agents/sessions/:id/events", cloud.Handle(s, appendSessionEvent))
app.Post("/v1/agents/sessions/:id/pause", cloud.Handle(s, pauseSession))
app.Post("/v1/agents/sessions/:id/resume", cloud.Handle(s, resumeSession))
app.Post("/v1/agents/sessions/:id/stop", cloud.Handle(s, stopSession))
app.Post("/v1/agents/sessions/:id/message", cloud.Handle(s, messageSession))
g := app.Group("/v1/agents")
g.Post("/sessions", cloud.Handle(s, registerSession))
g.Get("/sessions", cloud.Handle(s, listSessions))
g.Get("/sessions/stream", cloud.Handle(s, sessionsStream))
g.Get("/sessions/:id", cloud.Handle(s, getSession))
g.Patch("/sessions/:id", cloud.Handle(s, patchSession))
g.Get("/sessions/:id/tree", cloud.Handle(s, sessionTree))
g.Post("/sessions/:id/events", cloud.Handle(s, appendSessionEvent))
g.Post("/sessions/:id/pause", cloud.Handle(s, pauseSession))
g.Post("/sessions/:id/resume", cloud.Handle(s, resumeSession))
g.Post("/sessions/:id/stop", cloud.Handle(s, stopSession))
g.Post("/sessions/:id/message", cloud.Handle(s, messageSession))
}
func idParam(c *zip.Ctx) string { return strings.TrimSpace(c.Param("id")) }
+4
View File
@@ -180,6 +180,10 @@ CREATE INDEX IF NOT EXISTS ix_runs_org_agent_created ON agent_runs(org, agent_na
if err := s.migrateTargets(); err != nil {
return err
}
// Per-target claim keys + serving liveness (the #48 route-work machine plane).
if err := s.migrateClaimKeys(); err != nil {
return err
}
return nil
}
+265 -24
View File
@@ -10,6 +10,8 @@ import (
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/principal"
"github.com/hanzoai/cloud/clients/samples"
"github.com/zap-proto/zip"
)
@@ -17,10 +19,11 @@ import (
// box, a GPU host, or a whole cluster. It is the #48 link-a-compute seam over the
// SAME agents.db (one store, one tenancy column) as sessions/events — NOT a rival
// device registry. It composes with the compute fleet rather than duplicating it: a
// session records the target id it runs on (agent_sessions.target), and the mission-
// control devices view unions these registered targets with the org's BYO workers
// (GET /v1/fleet/workers) and BYO clusters (GET /v1/clusters) at the view layer — the
// console's established pattern for folding compute sources.
// session records the target id it runs on (agent_sessions.target), and the org's
// unified board (GET /v1/fleet, clients/visor/board.go) unions these registered
// targets with its BYO workers (GET /v1/fleet/workers), BYO clusters and Visor
// machines — reading this registry through the in-process seam below rather than
// copying it.
//
// POST /v1/agents/targets register a target -> Target
// GET /v1/agents/targets list the org's targets (+ live session load)
@@ -30,6 +33,12 @@ import (
//
// Every route is org-scoped through principal.Org (tenant), fail-closed — a tenant
// can never see or mutate another org's targets, exactly like sessions.
//
// A write carrying `metrics` IS a heartbeat, and a heartbeat is two facts, not one:
// the LAST sample (kept on the row, rendered by the views here) and one point in a
// utilization SERIES (appended to clients/samples). The row answers "is this machine
// alive and what is it doing now"; the series answers "how hot has it been". The
// append is best-effort and detached — see recordSample.
// Target kinds — the closed vocabulary of dispatch destinations.
const (
@@ -78,6 +87,7 @@ var errTargetNotFound = errors.New("agents: target not found")
type Target struct {
ID string
Org string
Owner string // the VALIDATED principal (c.User()) that registered this machine; "" for a pre-migration row
Label string
Kind string // laptop | cloud | gpu | cluster | machine
Status string // online | offline | draining
@@ -103,6 +113,7 @@ func (s *Store) migrateTargets() error {
CREATE TABLE IF NOT EXISTS agent_targets (
id TEXT PRIMARY KEY,
org TEXT NOT NULL,
owner TEXT NOT NULL DEFAULT '',
label TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL DEFAULT 'machine',
status TEXT NOT NULL DEFAULT 'online',
@@ -120,9 +131,12 @@ CREATE INDEX IF NOT EXISTS ix_targets_org_created ON agent_targets(org, created_
return fmt.Errorf("migrate targets: %w", err)
}
// Forward, idempotent upgrade for target rows created before the capability +
// metrics columns existed. PRAGMA-guarded, so re-running on an upgraded DB is a
// no-op — the DDL above covers fresh installs, this covers pre-existing ones.
// metrics + owner columns existed. PRAGMA-guarded, so re-running on an upgraded
// DB is a no-op — the DDL above covers fresh installs, this covers pre-existing
// ones. A pre-owner row backfills owner='' (unowned) and is admin-only until its
// owner re-registers (register binds the owner) — see registerTarget.
if err := s.addColumns("agent_targets", map[string]string{
"owner": "TEXT NOT NULL DEFAULT ''",
"spec": "TEXT NOT NULL DEFAULT ''",
"metrics": "TEXT NOT NULL DEFAULT ''",
"metrics_at": "INTEGER NOT NULL DEFAULT 0",
@@ -132,12 +146,12 @@ CREATE INDEX IF NOT EXISTS ix_targets_org_created ON agent_targets(org, created_
return nil
}
const targetCols = `id,org,label,kind,status,capacity,host,spec,metrics,metrics_at,created_at,updated_at`
const targetCols = `id,org,owner,label,kind,status,capacity,host,spec,metrics,metrics_at,created_at,updated_at`
func scanTarget(sc interface{ Scan(...any) error }) (Target, error) {
var t Target
var spec, metrics string
err := sc.Scan(&t.ID, &t.Org, &t.Label, &t.Kind, &t.Status, &t.Capacity, &t.Host,
err := sc.Scan(&t.ID, &t.Org, &t.Owner, &t.Label, &t.Kind, &t.Status, &t.Capacity, &t.Host,
&spec, &metrics, &t.MetricsAt, &t.CreatedAt, &t.UpdatedAt)
if err != nil {
return t, err
@@ -150,8 +164,8 @@ func scanTarget(sc interface{ Scan(...any) error }) (Target, error) {
// CreateTarget inserts one target. The id is caller-generated (genID("tgt")).
func (s *Store) CreateTarget(ctx context.Context, t Target) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO agent_targets (`+targetCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
t.ID, t.Org, t.Label, t.Kind, t.Status, t.Capacity, t.Host,
`INSERT INTO agent_targets (`+targetCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
t.ID, t.Org, t.Owner, t.Label, t.Kind, t.Status, t.Capacity, t.Host,
encodeSpec(t.Spec), encodeMetrics(t.Metrics), t.MetricsAt, t.CreatedAt, t.UpdatedAt)
if err != nil {
return fmt.Errorf("insert target: %w", err)
@@ -194,12 +208,14 @@ func (s *Store) ListTargets(ctx context.Context, org string) ([]Target, error) {
}
// UpdateTarget persists mutable fields for an existing (org,id) target. Scoped by org
// so a cross-tenant id can never mutate another's target.
// so a cross-tenant id can never mutate another's target. owner is persisted too so a
// relink can BIND a previously-unowned row (registerTarget) and a patch preserves the
// owner it read; no client-facing patch field sets owner, so it never moves by mutation.
func (s *Store) UpdateTarget(ctx context.Context, t Target) error {
res, err := s.db.ExecContext(ctx,
`UPDATE agent_targets SET label=?, kind=?, status=?, capacity=?, host=?, spec=?, metrics=?, metrics_at=?, updated_at=?
`UPDATE agent_targets SET owner=?, label=?, kind=?, status=?, capacity=?, host=?, spec=?, metrics=?, metrics_at=?, updated_at=?
WHERE org=? AND id=?`,
t.Label, t.Kind, t.Status, t.Capacity, t.Host,
t.Owner, t.Label, t.Kind, t.Status, t.Capacity, t.Host,
encodeSpec(t.Spec), encodeMetrics(t.Metrics), t.MetricsAt, t.UpdatedAt, t.Org, t.ID)
if err != nil {
return fmt.Errorf("update target: %w", err)
@@ -211,6 +227,32 @@ func (s *Store) UpdateTarget(ctx context.Context, t Target) error {
return nil
}
// GetLinkableTargetByHost returns the target for (org,host) that the caller `owner`
// may re-link — its OWN row, else an UNOWNED (pre-migration) row it may adopt —
// preferring the exact-owner match, newest first. A row owned by a DIFFERENT
// principal is NEVER returned, so a re-link can never clobber another member's
// machine: the caller gets its own row or (falling through in registerTarget) a fresh
// one. errTargetNotFound when nothing linkable exists.
func (s *Store) GetLinkableTargetByHost(ctx context.Context, org, host, owner string) (Target, error) {
host = strings.TrimSpace(host)
if host == "" {
return Target{}, errTargetNotFound
}
row := s.db.QueryRowContext(ctx,
`SELECT `+targetCols+` FROM agent_targets
WHERE org=? AND host=? AND (owner=? OR owner='')
ORDER BY CASE WHEN owner=? THEN 0 ELSE 1 END, created_at DESC, id ASC LIMIT 1`,
org, host, owner, owner)
t, err := scanTarget(row)
if errors.Is(err, sql.ErrNoRows) {
return Target{}, errTargetNotFound
}
if err != nil {
return Target{}, fmt.Errorf("get linkable target by host: %w", err)
}
return t, nil
}
// GetTargetByHost returns an org's target reporting the given host, or
// errTargetNotFound. It is how a re-link of the SAME machine finds its existing target
// (idempotent register) instead of creating a duplicate. Org-scoped: a host string can
@@ -233,6 +275,90 @@ func (s *Store) GetTargetByHost(ctx context.Context, org, host string) (Target,
return t, nil
}
// ---- the in-process seam (org-scoped, fail-closed) ----
//
// TargetsForOrg / LoadOn are the exported twins of the list + detail reads above:
// the ONE way another in-process subsystem (the /v1/fleet board in clients/visor)
// reads this registry WITHOUT an HTTP hop back through the gateway — the same
// shape ListForOrg gives the agent registry. They are two ORTHOGONAL values on
// purpose: a target is what the machine IS, its load is what is running on it, and
// a caller that only needs the inventory does not pay for the rollups.
//
// ISOLATION: org is the ONLY tenant key and is threaded verbatim into the
// org-scoped store methods, so a caller for org A can never enumerate or resolve
// org B's targets. The caller MUST pass an org it already validated server-side
// (principal.Org), never a raw client header.
// TargetsForOrg returns the org's registered run-targets from the in-process
// store, newest first. Fails closed when the subsystem is not mounted or the org
// is empty/oversized.
func TargetsForOrg(ctx context.Context, org string) ([]Target, error) {
if mounted == nil || mounted.State.store == nil {
return nil, fmt.Errorf("agents: not mounted")
}
org = strings.TrimSpace(org)
if org == "" || len(org) > principal.MaxOrgLen {
return nil, fmt.Errorf("agents: invalid org")
}
return mounted.State.store.ListTargets(ctx, org)
}
// ResolveTarget resolves a human's target REFERENCE — a target id or its friendly
// label (the hostname the CLI registers) — to the org's target, org-scoped and
// fail-closed. It is the ONE way a trigger surface (the Slack `code: <repo> on
// <target>` grammar, a console picker) turns "on evo" into a target id without
// leaking another tenant's inventory: an id or label that resolves to no target in
// THIS org returns errTargetNotFound, never another org's machine.
//
// Precedence: an exact id match wins (ids are unambiguous), else an exact,
// case-folded label match (newest first, so a re-registered machine's live row is
// preferred). A reference that matches neither is not found — the caller renders an
// honest error and NEVER falls back to a local run.
func ResolveTarget(ctx context.Context, org, ref string) (Target, error) {
if mounted == nil || mounted.State.store == nil {
return Target{}, fmt.Errorf("agents: not mounted")
}
org = strings.TrimSpace(org)
ref = strings.TrimSpace(ref)
if org == "" || len(org) > principal.MaxOrgLen {
return Target{}, fmt.Errorf("agents: invalid org")
}
if ref == "" || len(ref) > maxTargetID {
return Target{}, errTargetNotFound
}
// An id is exact and unambiguous — try it first.
if t, err := mounted.State.store.GetTarget(ctx, org, ref); err == nil {
return t, nil
} else if err != errTargetNotFound {
return Target{}, err
}
// Else an exact, case-folded label match within this org.
rows, err := mounted.State.store.ListTargets(ctx, org)
if err != nil {
return Target{}, err
}
for _, t := range rows { // ListTargets is newest-first: the live row wins a label tie
if strings.EqualFold(strings.TrimSpace(t.Label), ref) {
return t, nil
}
}
return Target{}, errTargetNotFound
}
// LoadOn returns the live session load on one of the org's targets — the same
// (target id OR host) mapping the HTTP views use, so the board and /v1/agents/
// targets can never disagree about what is running where.
func LoadOn(ctx context.Context, org, id, host string) (TargetLoad, error) {
if mounted == nil || mounted.State.store == nil {
return TargetLoad{}, fmt.Errorf("agents: not mounted")
}
org = strings.TrimSpace(org)
if org == "" || len(org) > principal.MaxOrgLen {
return TargetLoad{}, fmt.Errorf("agents: invalid org")
}
return mounted.State.store.SessionLoad(ctx, org, id, host)
}
// DeleteTarget removes an org's target. Sessions keep their recorded target id (a
// historical fact); a detached target simply stops appearing in the registry.
func (s *Store) DeleteTarget(ctx context.Context, org, id string) (bool, error) {
@@ -262,6 +388,83 @@ func (s *Store) SessionLoad(ctx context.Context, org, id, host string) (TargetLo
return TargetLoad{Sessions: total, Running: running}, nil
}
// ---- the fleet time series ----
//
// A heartbeat is the ONE moment this process learns what a linked machine is
// doing, so it is also where the fleet's utilization series is fed. The target row
// keeps the LAST sample (the snapshot the views render, unchanged); clients/samples
// keeps every sample over time. Two different questions — "is it alive now" and
// "how hot has it been" — so two homes, one write.
// sampleTimeout bounds the warehouse write. Generous (the insert is one small row
// in-cluster) but finite, so a wedged datastore can never hold the goroutine open.
const sampleTimeout = 5 * time.Second
// sampleOf projects a target's server-stamped heartbeat into a fleet sample. PURE
// (no clock, no I/O, no store) so the whole projection is unit-testable and the
// caller decides when it runs.
//
// cost_cents is 0: an agent run-target is the operator's OWN machine (a laptop, a
// dialed-in box) — the fleet meters its utilization, it does not resell it. A
// priced source (visor/cloud) fills that column from its own resale price.
func sampleOf(t Target) samples.Sample {
var model string
if len(t.Spec.GPUs) > 0 {
// The representative accelerator: the count already rides in GPUs, so the
// first card's model names the row. A heterogeneous host is rare enough
// that naming its first card beats inventing a summary string here.
model = t.Spec.GPUs[0].Model
if model == "" {
model = t.Spec.GPUs[0].Vendor
}
}
return samples.Sample{
Org: t.Org,
Source: samples.SourceAgent,
Unit: t.ID,
Host: t.Host,
Kind: t.Kind,
At: time.Unix(t.MetricsAt, 0).UTC(),
CPUs: t.Spec.CPUs,
Memory: t.Spec.Memory,
MemUsed: t.Metrics.MemUsed,
MemFree: t.Metrics.MemFree,
Load1: t.Metrics.Load1,
Load5: t.Metrics.Load5,
Load15: t.Metrics.Load15,
GPUUtil: t.Metrics.GPUUtil,
GPUs: len(t.Spec.GPUs),
GPUModel: model,
}
}
// recordSample appends a heartbeat to the fleet series. Best-effort and DETACHED
// on purpose — the warehouse is never in the heartbeat's critical path:
//
// - it runs on its own bounded context, so neither a slow datastore nor the
// client hanging up mid-request can stall or cancel the write;
// - it never touches the response, so the /v1/agents/targets contract is
// byte-identical whether the warehouse is present, absent or on fire;
// - a failure is logged, never surfaced — a dropped sample must not cost a
// machine its heartbeat.
//
// This is the shape the billing warehouse write already uses (`go zapWriteUsage`):
// the seam is synchronous, the CALLER owns the concurrency.
func recordSample(s *cloud.Service[state], t Target) {
if t.MetricsAt == 0 {
return // no heartbeat in this write — nothing to append
}
sample := sampleOf(t) // project on the caller's goroutine: t must not escape mutably
go func() {
ctx, cancel := context.WithTimeout(context.Background(), sampleTimeout)
defer cancel()
if err := samples.Record(ctx, sample); err != nil {
s.Log.Warn("fleet sample write failed", "org", sample.Org, "unit", sample.Unit, "err", err)
}
}()
}
// ---- HTTP shapes (the published contract) ----
type targetView struct {
@@ -306,11 +509,16 @@ func toTargetView(t Target, load TargetLoad) targetView {
// /v1/agents/:ref wildcard (Fiber matches in registration order) so "targets" is not
// captured as a ref. The static /v1/agents/targets precedes /v1/agents/targets/:id.
func mountTargets(s *cloud.Service[state], app *zip.App) {
app.Post("/v1/agents/targets", cloud.Handle(s, registerTarget))
app.Get("/v1/agents/targets", cloud.Handle(s, listTargets))
app.Get("/v1/agents/targets/:id", cloud.Handle(s, getTarget))
app.Patch("/v1/agents/targets/:id", cloud.Handle(s, patchTarget))
app.Delete("/v1/agents/targets/:id", cloud.Handle(s, deleteTarget))
g := app.Group("/v1/agents")
g.Post("/targets", cloud.Handle(s, registerTarget))
g.Get("/targets", cloud.Handle(s, listTargets))
g.Get("/targets/:id", cloud.Handle(s, getTarget))
g.Patch("/targets/:id", cloud.Handle(s, patchTarget))
g.Delete("/targets/:id", cloud.Handle(s, deleteTarget))
// The #48 route-work machine surface (claim-key, claim long-poll, report)
// lives on the same target routes; register after the CRUD so the
// extra-segment paths are unambiguous.
mountRouting(s, app)
}
// ---- register ----
@@ -374,18 +582,28 @@ func registerTarget(s *cloud.Service[state], c *zip.Ctx) error {
metricsAt = now // the server owns the staleness clock; a client can't forge it
}
// Idempotent re-link: the SAME machine (org+host) refreshes its existing target
// rather than piling up duplicates, so mission-control shows one row per machine
// with live spec/metrics. Only an explicit host keys this — an anonymous target
// (no host) always creates.
// The registering principal OWNS this machine (least privilege): only it (or an
// org admin) may later mint the claim key, claim runs, report, patch, or delete
// it. tenant() already required a validated principal, so this is non-empty.
owner := caller(c)
// Idempotent re-link: the SAME machine (org+host+owner) refreshes its existing
// target rather than piling up duplicates, so mission-control shows one row per
// machine with live spec/metrics. It resolves ONLY the caller's own row (or an
// UNOWNED pre-migration row, which it ADOPTS by binding owner) — a row owned by a
// different member is never touched, so a re-link can never hijack another's
// machine; the caller falls through to create its own. Only an explicit host keys
// this — an anonymous target (no host) always creates.
if host != "" {
if existing, err := s.State.store.GetTargetByHost(c.Context(), org, host); err == nil {
if existing, err := s.State.store.GetLinkableTargetByHost(c.Context(), org, host, owner); err == nil {
existing.Owner = owner // bind an adopted unowned row; no-op if already ours
existing.Label, existing.Kind, existing.Status, existing.Capacity = label, kind, status, capacity
existing.Spec, existing.Metrics, existing.MetricsAt = spec, metrics, metricsAt
existing.UpdatedAt = now
if err := s.State.store.UpdateTarget(c.Context(), existing); err != nil {
return zip.Errorf(http.StatusInternalServerError, "persist: %v", err)
}
recordSample(s, existing) // a re-link carrying metrics IS a heartbeat
load, _ := s.State.store.SessionLoad(c.Context(), org, existing.ID, existing.Host)
return c.JSON(http.StatusOK, toTargetView(existing, load))
}
@@ -396,13 +614,14 @@ func registerTarget(s *cloud.Service[state], c *zip.Ctx) error {
return zip.Errorf(http.StatusInternalServerError, "rng: %v", err)
}
t := Target{
ID: id, Org: org, Label: label, Kind: kind, Status: status,
ID: id, Org: org, Owner: owner, Label: label, Kind: kind, Status: status,
Capacity: capacity, Host: host, Spec: spec, Metrics: metrics, MetricsAt: metricsAt,
CreatedAt: now, UpdatedAt: now,
}
if err := s.State.store.CreateTarget(c.Context(), t); err != nil {
return zip.Errorf(http.StatusInternalServerError, "persist: %v", err)
}
recordSample(s, t) // a registration carrying metrics is the target's first sample
return c.JSON(http.StatusCreated, toTargetView(t, TargetLoad{}))
}
@@ -472,6 +691,12 @@ func patchTarget(s *cloud.Service[state], c *zip.Ctx) error {
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "get: %v", err)
}
// Only the machine's owner (or an org admin) may mutate it — a member cannot
// reconfigure/drain another member's machine. Fail-closed to the SAME not-found
// an unknown id gives, so a probe learns nothing about what exists.
if !ownsTarget(c, t) {
return zip.ErrNotFound("target not found")
}
var body patchTargetReq
if err := c.Bind(&body); err != nil {
return err
@@ -538,6 +763,9 @@ func patchTarget(s *cloud.Service[state], c *zip.Ctx) error {
}
return zip.Errorf(http.StatusInternalServerError, "update: %v", err)
}
if body.Metrics != nil {
recordSample(s, t) // THE heartbeat: append it to the fleet series too
}
load, _ := s.State.store.SessionLoad(c.Context(), org, t.ID, t.Host)
return c.JSON(http.StatusOK, toTargetView(t, load))
}
@@ -550,6 +778,19 @@ func deleteTarget(s *cloud.Service[state], c *zip.Ctx) error {
return zip.ErrForbidden("X-Org-Id required")
}
id := idParam(c)
// Resolve + ownership-gate before deleting: only the machine's owner (or an org
// admin) may deregister it. A cross-org id, an unknown id, and a non-owned id all
// collapse to the same not-found — no oracle.
t, err := s.State.store.GetTarget(c.Context(), org, id)
if err == errTargetNotFound {
return zip.ErrNotFound("target not found")
}
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "get: %v", err)
}
if !ownsTarget(c, t) {
return zip.ErrNotFound("target not found")
}
deleted, err := s.State.store.DeleteTarget(c.Context(), org, id)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "delete: %v", err)
+227
View File
@@ -0,0 +1,227 @@
package agents
import (
"encoding/json"
"net/http"
"testing"
"time"
"github.com/hanzoai/cloud/clients/samples"
)
// targetsample_test.go covers the FIRST emitter: a run-target heartbeat also
// appends to the fleet series (clients/samples).
//
// The datastore is absent under test, so samples.Record is a proven no-op (its own
// package tests that). What MUST be proven here is everything this side owns:
// the projection is faithful, the vocabularies agree, and the HTTP contract is
// untouched whether or not the warehouse exists.
// ---- the projection (pure) ----
// A heartbeat projects onto a sample with no loss and no invention.
func TestSampleOfProjectsTheHeartbeat(t *testing.T) {
at := time.Now().Unix()
tg := Target{
ID: "tgt-1", Org: "acme", Kind: TargetGPU, Host: "box.local", Label: "Box",
Spec: Spec{OS: "linux", Arch: "arm64", CPUs: 20, Memory: 128 << 30,
GPUs: []GPU{{Vendor: "nvidia", Model: "GB10", Memory: 96 << 30}}},
Metrics: Metrics{Load1: 2.5, Load5: 2, Load15: 1.5,
MemUsed: 64 << 30, MemFree: 64 << 30, GPUUtil: 0.75},
MetricsAt: at,
}
s := sampleOf(tg)
if s.Org != "acme" || s.Unit != "tgt-1" || s.Host != "box.local" {
t.Fatalf("identity did not project: %+v", s)
}
if s.Source != samples.SourceAgent {
t.Fatalf("source want %q, got %q", samples.SourceAgent, s.Source)
}
if s.Kind != TargetGPU {
t.Fatalf("kind want %q, got %q", TargetGPU, s.Kind)
}
if !s.At.Equal(time.Unix(at, 0).UTC()) {
t.Fatalf("at must be the SERVER-stamped heartbeat clock, got %v", s.At)
}
if s.CPUs != 20 || s.Memory != 128<<30 {
t.Fatalf("spec did not project: %+v", s)
}
if s.MemUsed != 64<<30 || s.MemFree != 64<<30 || s.Load1 != 2.5 || s.Load5 != 2 || s.Load15 != 1.5 {
t.Fatalf("metrics did not project: %+v", s)
}
if s.GPUUtil != 0.75 || s.GPUs != 1 || s.GPUModel != "GB10" {
t.Fatalf("gpu did not project: %+v", s)
}
// An agent's own machine is metered, never resold.
if s.CostCents != 0 {
t.Fatalf("an agent sample must be unpriced, got %d", s.CostCents)
}
// The projection must be acceptable to the plane it feeds.
if err := samples.Record(t.Context(), s); err != nil {
t.Fatalf("a projected sample must be recordable: %v", err)
}
}
// The accelerator count comes from the spec, and the row is named by the first
// card's model — falling back to its vendor when the model is unknown.
func TestSampleOfGPUSummary(t *testing.T) {
cases := []struct {
name string
gpus []GPU
wantN int
wantModel string
}{
{"none", nil, 0, ""},
{"model", []GPU{{Vendor: "nvidia", Model: "GB10"}}, 1, "GB10"},
{"vendor fallback", []GPU{{Vendor: "amd"}}, 1, "amd"},
{"multi is counted, first names it", []GPU{{Model: "GB10"}, {Model: "GB10"}}, 2, "GB10"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s := sampleOf(Target{ID: "t", Org: "o", Kind: TargetGPU, Spec: Spec{GPUs: tc.gpus}, MetricsAt: 1})
if s.GPUs != tc.wantN || s.GPUModel != tc.wantModel {
t.Fatalf("want (%d, %q), got (%d, %q)", tc.wantN, tc.wantModel, s.GPUs, s.GPUModel)
}
})
}
}
// THE cross-package contract: every kind a target can be must be a kind the fleet
// series accepts, or heartbeats would silently stop being recorded. This fails the
// day someone adds a target kind without teaching the series about it.
func TestEveryTargetKindIsAFleetKind(t *testing.T) {
fleet := map[string]bool{
samples.KindLaptop: true, samples.KindCloud: true, samples.KindGPU: true,
samples.KindCluster: true, samples.KindMachine: true, samples.KindWorker: true,
}
for _, k := range []string{TargetLaptop, TargetCloud, TargetGPU, TargetCluster, TargetMachine} {
if !fleet[k] {
t.Fatalf("target kind %q is not a fleet sample kind — its heartbeats would be dropped", k)
}
// Proven end to end: a sample carrying this kind validates.
s := sampleOf(Target{ID: "t", Org: "o", Kind: k, MetricsAt: 1})
if err := samples.Record(t.Context(), s); err != nil {
t.Fatalf("kind %q must be recordable: %v", k, err)
}
}
}
// A write with no heartbeat in it appends nothing — recordSample is a no-op when
// the server never stamped a metrics clock.
func TestRecordSampleSkipsWhenNoHeartbeat(t *testing.T) {
mountApp(t, nil) // sets the `mounted` singleton recordSample logs through
// No panic, no goroutine, no write: MetricsAt == 0 means "no sample here".
recordSample(mounted, Target{ID: "tgt-1", Org: "acme", Kind: TargetGPU, MetricsAt: 0})
}
// ---- (c) the HTTP contract is untouched by the series ----
// The heartbeat still 200s with no warehouse, and still returns the snapshot on
// the row exactly as before — the series is strictly additive.
func TestHeartbeatStill200sWithoutDatastore(t *testing.T) {
app := mountApp(t, nil)
code, body := do(t, app, http.MethodPost, "/v1/agents/targets", "acme", map[string]any{
"label": "Box", "kind": TargetGPU, "host": "box.local",
"spec": map[string]any{"os": "linux", "cpus": 20, "gpus": []map[string]any{{"vendor": "nvidia", "model": "GB10"}}},
"metrics": map[string]any{"load1": 2.5, "gpuUtil": 0.75, "memUsed": 100},
})
if code != http.StatusCreated {
t.Fatalf("register want 201 without a datastore, got %d (%s)", code, body)
}
var created targetView
if err := json.Unmarshal(body, &created); err != nil {
t.Fatalf("shape: %v (%s)", err, body)
}
if created.Metrics == nil || created.Metrics.GPUUtil != 0.75 {
t.Fatalf("the snapshot on the row must be unchanged: %+v", created.Metrics)
}
if created.MetricsAt == "" {
t.Fatal("the server must still stamp the heartbeat clock")
}
// The heartbeat itself.
code, body = do(t, app, http.MethodPatch, "/v1/agents/targets/"+created.ID, "acme", map[string]any{
"metrics": map[string]any{"load1": 4, "gpuUtil": 0.9, "memUsed": 200},
})
if code != http.StatusOK {
t.Fatalf("heartbeat want 200 without a datastore, got %d (%s)", code, body)
}
var beat targetView
if err := json.Unmarshal(body, &beat); err != nil {
t.Fatalf("shape: %v (%s)", err, body)
}
if beat.Metrics == nil || beat.Metrics.GPUUtil != 0.9 || beat.Metrics.Load1 != 4 {
t.Fatalf("the heartbeat must still refresh the row snapshot: %+v", beat.Metrics)
}
// A re-link (same org+host) is idempotent and still carries a heartbeat.
code, body = do(t, app, http.MethodPost, "/v1/agents/targets", "acme", map[string]any{
"label": "Box", "kind": TargetGPU, "host": "box.local",
"metrics": map[string]any{"load1": 1},
})
if code != http.StatusOK {
t.Fatalf("re-link want 200 (idempotent), got %d (%s)", code, body)
}
var relinked targetView
if err := json.Unmarshal(body, &relinked); err != nil {
t.Fatalf("shape: %v (%s)", err, body)
}
if relinked.ID != created.ID {
t.Fatalf("a re-link must refresh the SAME target: %s != %s", relinked.ID, created.ID)
}
}
// ---- the in-process seam ----
// TargetsForOrg / LoadOn are org-keyed and fail closed — the board reads through
// them, so a cross-tenant id must never resolve.
func TestInProcessSeamIsOrgScopedAndFailsClosed(t *testing.T) {
app := mountApp(t, nil)
code, body := do(t, app, http.MethodPost, "/v1/agents/targets", "acme", map[string]any{
"label": "Secret", "kind": TargetGPU, "host": "secret.local",
})
if code != http.StatusCreated {
t.Fatalf("register: %d (%s)", code, body)
}
var created targetView
_ = json.Unmarshal(body, &created)
// The owner sees it.
own, err := TargetsForOrg(t.Context(), "acme")
if err != nil {
t.Fatalf("TargetsForOrg(acme): %v", err)
}
if len(own) != 1 || own[0].ID != created.ID {
t.Fatalf("the owner must see its target, got %+v", own)
}
// Another tenant sees nothing — the same id is unreachable.
other, err := TargetsForOrg(t.Context(), "other")
if err != nil {
t.Fatalf("TargetsForOrg(other): %v", err)
}
if len(other) != 0 {
t.Fatalf("CROSS-TENANT LEAK: org 'other' enumerated %+v", other)
}
// A blank/oversized org fails closed on both.
for _, bad := range []string{"", " "} {
if _, err := TargetsForOrg(t.Context(), bad); err == nil {
t.Fatalf("TargetsForOrg(%q) must fail closed", bad)
}
if _, err := LoadOn(t.Context(), bad, created.ID, ""); err == nil {
t.Fatalf("LoadOn(%q) must fail closed", bad)
}
}
// LoadOn is org-keyed too: the foreign tenant resolves no load for the id.
load, err := LoadOn(t.Context(), "other", created.ID, "secret.local")
if err != nil {
t.Fatalf("LoadOn(other): %v", err)
}
if load.Sessions != 0 || load.Running != 0 {
t.Fatalf("CROSS-TENANT LEAK: foreign load %+v", load)
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"strings"
"time"
openai "github.com/sashabaranov/go-openai"
openai "github.com/hanzoai/go-openai"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
+11 -5
View File
@@ -100,15 +100,21 @@ func routes(app *zip.App, s *cloud.Service[state]) {
app.Get("/v1/analytics/timeseries", cloud.Handle(s, timeseries))
app.Get("/v1/analytics/top", cloud.Handle(s, top))
// Capture (WRITE) side — the ingest that fills hanzo.events (capture.go). All
// POST, all tenant-gated in-handler. /v1/tracker is the page-unload beacon
// alias (bare route; never collides with the /v1/tracker/projects* issue tracker).
// Capture (WRITE) side — the ingest that fills hanzo.events. POST /v1/event
// (event.go) is the ONE canonical front door: body Event | [Event], org
// resolved IAM-only and fail-closed, into the ONE write core (ingestEvents).
app.Post("/v1/event", cloud.Handle(s, eventIngest))
// DEPRECATED ingest aliases — thin wire adapters that normalize onto the SAME
// write core (log a one-shot deprecation, keep working). /v1/analytics{,/batch}
// and /v1/tracker speak the Segment/beacon CaptureBatch wire; /v1/tracker is a
// bare route (never collides with the /v1/tracker/projects* issue tracker).
app.Post("/v1/analytics", cloud.Handle(s, capture))
app.Post("/v1/analytics/batch", cloud.Handle(s, capture))
app.Post("/v1/tracker", cloud.Handle(s, capture))
// /v1/insights — the unified native surface (insights.go): PostHog-wire
// ingest + console reads over the SAME engine. Flags live at /v1/flags.
// /v1/insights — console reads over the SAME engine + the DEPRECATED PostHog-
// wire ingest adapter (/v1/insights/e → the ONE write core). Flags live at /v1/flags.
app.Get("/v1/insights/health", cloud.Handle(s, insightsHealth))
app.Post("/v1/insights/e", cloud.Handle(s, insightsIngest))
app.Get("/v1/insights/events", cloud.Handle(s, insightsEvents))
+143 -29
View File
@@ -52,6 +52,7 @@ import (
"os"
"regexp"
"strings"
"sync"
"sync/atomic"
"time"
@@ -498,19 +499,72 @@ func publicCaptureEnabled() bool {
}
}
// captureTenant resolves the tenant a batch is attributed to. A VALIDATED
// principal always wins (authenticated product traffic → its own org). Otherwise,
// for anonymous marketing traffic, the tenant is the PUBLIC brand org derived
// SERVER-SIDE from the request Host via the white-label registry — never a
// client-claimed org, so the isolation invariant holds: a caller with no bearer
// can only ever write into the brand-public partition of the Host it actually
// reached, and a forged X-Org-Id is ignored exactly as on the read path. An
// unrecognized Host is refused (we never dump anonymous events into a default
// org). Returns ("", false) when the caller must be answered 403.
// resolveKeyOrg maps a presented project/API key to its org through the ONE IAM
// key seam (cloud.OrgForKey). It is a package var ONLY so a test can substitute a
// resolver without standing up IAM; production is always cloud.OrgForKey.
var resolveKeyOrg = cloud.OrgForKey
// projectKey returns the project/API key a keyed SDK presents OUT-OF-BAND of the
// Authorization header — the transports SanitizeIdentity does NOT mint identity
// from, so they never reach tenant() as a principal. In priority order: the
// ?api_key= query, the x-api-key / api-key headers, and the PostHog-wire body
// field `api_key` (posthog-js and the insights-go batch envelope put it there).
// "" when none is present.
//
// The body is PEEKED via c.Body() — fasthttp buffers the full body, so the later
// c.Bind in the handler re-reads the same bytes; peeking does not consume it. Only
// the api_key field is decoded (a bad/unrelated JSON body simply yields "").
func projectKey(c *zip.Ctx) string {
if k := trim(c.Query("api_key")); k != "" {
return k
}
if k := trim(c.Header("x-api-key")); k != "" {
return k
}
if k := trim(c.Header("api-key")); k != "" {
return k
}
if body := c.Body(); len(body) > 0 {
var probe struct {
APIKey string `json:"api_key"`
}
if json.Unmarshal(body, &probe) == nil {
if k := trim(probe.APIKey); k != "" {
return k
}
}
}
return ""
}
// captureTenant resolves the tenant a batch is attributed to, in strict trust
// order:
//
// 1. A VALIDATED principal always wins (authenticated product traffic → its own
// org; this also covers a Hanzo key sent as a bearer, which SanitizeIdentity
// has already resolved to a principal upstream).
// 2. Otherwise, if the caller PRESENTS a project key out-of-band (posthog-js /
// insights-go: api_key in the body/query/x-api-key), resolve it to its org
// through the ONE IAM key seam. This FAILS CLOSED: a presented-but-unresolvable
// key is refused (→ 403), NEVER falling through to the brand-host fallback —
// attributing a keyed request to the wrong (brand-public) partition would be a
// cross-tenant write.
// 3. Only for TRULY anonymous traffic (no principal, no key) is the tenant the
// PUBLIC brand org derived SERVER-SIDE from the request Host via the
// white-label registry — never a client-claimed org. An unrecognized Host is
// refused (we never dump anonymous events into a default org).
//
// Returns ("", false) when the caller must be answered 403.
func captureTenant(c *zip.Ctx) (string, bool) {
if org, ok := tenant(c); ok {
return org, true
}
if key := projectKey(c); key != "" {
if org, ok := resolveKeyOrg(c.Context(), key); ok {
return org, true
}
return "", false // presented key that does not resolve → fail CLOSED
}
if !publicCaptureEnabled() {
return "", false
}
@@ -520,36 +574,75 @@ func captureTenant(c *zip.Ctx) (string, bool) {
return "", false
}
// capture ingests one batch into hanzo.events, tenant-scoped. Shared by
// /v1/analytics, /v1/analytics/batch, and /v1/tracker.
func capture(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := captureTenant(c)
if !ok {
return zip.ErrForbidden("valid bearer or a recognized brand host required")
// ── ONE write core ───────────────────────────────────────────────────────────
// event source tags — the ingest adapter each row arrived through. Stamped into
// properties.$source by ingestEvents so the ONE hanzo.events table stays honest
// about origin (canonical vs. deprecated wire) WITHOUT a second table or a schema
// migration: the read lenses are unchanged and $source is queryable in the
// properties JSON, which is exactly the migration signal for the alias sunset.
const (
sourceEvent = "event" // canonical POST /v1/event (native Event wire)
sourcePostHog = "posthog" // POST /v1/insights/e (PostHog wire adapter, deprecated)
sourceCapture = "capture" // POST /v1/analytics{,/batch}, /v1/tracker (Segment/beacon, deprecated)
)
// withSource returns a copy of p carrying $source=source (the ingest adapter), so
// normalizeEvent's scrub+store path records origin as a property. nil-safe; never
// mutates the caller's map (the adapters share their event structs).
func withSource(p map[string]any, source string) map[string]any {
if source == "" {
return p
}
var batch CaptureBatch
if err := c.Bind(&batch); err != nil {
return zip.ErrBadRequest("malformed capture batch")
out := make(map[string]any, len(p)+1)
for k, v := range p {
out[k] = v
}
evs := batch.events()
out["$source"] = source
return out
}
// deprecatedOnce records one deprecation log per alias path per process, so a
// high-volume ingest alias signals its sunset exactly once instead of flooding.
var deprecatedOnce sync.Map
// deprecated logs (once per path) that a superseded ingest alias was hit, pointing
// callers at the canonical front door. It NEVER changes behavior — the alias keeps
// working — it only records the migration signal (also visible as $source in the
// warehouse).
func deprecated(s *cloud.Service[state], c *zip.Ctx, canonical string) {
p := c.Path()
if _, seen := deprecatedOnce.LoadOrStore(p, struct{}{}); seen {
return
}
s.Log.Warn("deprecated analytics ingest endpoint; migrate to the canonical event front door",
"path", p, "canonical", canonical)
}
// ingestEvents is the ONE write core: normalize → scrub → batch INSERT into the
// ONE hanzo.events table. org is the SERVER-resolved tenant (never client input);
// source tags the ingest adapter. Every front door — the canonical /v1/event and
// the deprecated PostHog / Segment / beacon adapters — funnels here, so there is
// exactly one write path. Returns the honest accepted/dropped receipt; the errors
// it returns are already HTTP-shaped (zip) for the handler to pass straight up.
func ingestEvents(ctx context.Context, org, source string, evs []CaptureEvent) (CaptureResult, error) {
if len(evs) == 0 {
return c.JSON(http.StatusOK, CaptureResult{})
return CaptureResult{}, nil
}
if len(evs) > maxBatch {
return zip.ErrBadRequest("batch too large")
return CaptureResult{}, zip.ErrBadRequest("batch too large")
}
if err := requireDatastore(); err != nil {
return err
return CaptureResult{}, err
}
ctx := c.Context()
if err := EnsureEventsTable(ctx); err != nil {
return zip.Errorf(http.StatusServiceUnavailable, "analytics warehouse unavailable: %v", err)
return CaptureResult{}, zip.Errorf(http.StatusServiceUnavailable, "analytics warehouse unavailable: %v", err)
}
now := time.Now().UTC()
rows := make([]eventRow, 0, len(evs))
dropped := 0
for _, e := range evs {
e.Properties = withSource(e.Properties, source)
row, ok := normalizeEvent(org, now, e)
if !ok {
dropped++
@@ -558,12 +651,33 @@ func capture(s *cloud.Service[state], c *zip.Ctx) error {
rows = append(rows, row)
}
if len(rows) == 0 {
return c.JSON(http.StatusOK, CaptureResult{Dropped: dropped})
return CaptureResult{Dropped: dropped}, nil
}
stmt, args := buildEventsInsert(rows)
if err := aiobject.DatastoreExec(ctx, stmt, args...); err != nil {
return warehouseErr("capture", err)
return CaptureResult{}, warehouseErr("capture", err)
}
return c.JSON(http.StatusOK, CaptureResult{Accepted: len(rows), Dropped: dropped})
return CaptureResult{Accepted: len(rows), Dropped: dropped}, nil
}
// capture ingests a Segment/beacon batch into hanzo.events, tenant-scoped. It is
// the DEPRECATED wire adapter behind /v1/analytics, /v1/analytics/batch, and
// /v1/tracker: a thin CaptureBatch decoder over the ONE write core (ingestEvents).
// New callers post the canonical Event to /v1/event; this alias keeps working and
// keeps captureTenant's brand-host path for anonymous marketing traffic.
func capture(s *cloud.Service[state], c *zip.Ctx) error {
deprecated(s, c, "/v1/event")
org, ok := captureTenant(c)
if !ok {
return zip.ErrForbidden("valid bearer or a recognized brand host required")
}
var batch CaptureBatch
if err := c.Bind(&batch); err != nil {
return zip.ErrBadRequest("malformed capture batch")
}
res, err := ingestEvents(c.Context(), org, sourceCapture, batch.events())
if err != nil {
return err
}
return c.JSON(http.StatusOK, res)
}
+120
View File
@@ -0,0 +1,120 @@
// 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.
// See the License for the specific language governing permissions and
// limitations under the License.
package analytics
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/zap-proto/zip"
)
// These tests cover the project-API-key → org resolution added to captureTenant so
// keyed, bearer-less SDK traffic (posthog-js / insights-go batch) maps to a tenant.
// They drive the REAL /v1/insights/e handler through the injectable resolveKeyOrg
// seam, so no IAM is needed. The observable proxy for "resolved to a tenant" is
// "passed the tenant gate" — i.e. NOT 403; without a datastore the handler then
// returns 503, so any non-403 status means captureTenant admitted the request.
// stubResolver swaps resolveKeyOrg for the test and records the key it was handed,
// so a test asserts BOTH that projectKey extracted the right key AND that
// captureTenant honored the resolution. Restored via t.Cleanup.
func stubResolver(t *testing.T, fn func(key string) (string, bool)) *string {
t.Helper()
var got string
orig := resolveKeyOrg
resolveKeyOrg = func(_ context.Context, key string) (string, bool) {
got = key
return fn(key)
}
t.Cleanup(func() { resolveKeyOrg = orig })
return &got
}
// postKeyed issues POST path (with optional ?query) to the mounted app, setting an
// optional Host and headers and a raw JSON body — no middleware, mirroring the
// SanitizeIdentity-minted-header harness the other analytics tests use.
func postKeyed(t *testing.T, app *zip.App, path, host, body string, hdr map[string]string) int {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if host != "" {
req.Host = host
}
for k, v := range hdr {
req.Header.Set(k, v)
}
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("Test POST %s: %v", path, err)
}
defer func() { _ = resp.Body.Close() }()
_, _ = io.Copy(io.Discard, resp.Body)
return resp.StatusCode
}
// TestCaptureTenant_KeyExtractionReachesResolver: a project key presented in the
// body, the ?api_key= query, or the x-api-key header is extracted and handed to the
// resolver, and a resolved key passes the tenant gate (never 403).
func TestCaptureTenant_KeyExtractionReachesResolver(t *testing.T) {
app := mountApp(t)
cases := []struct {
name, path, body string
hdr map[string]string
wantKey string
}{
{"body", "/v1/insights/e", `{"api_key":"hk-body","event":"e","distinct_id":"d"}`, nil, "hk-body"},
{"query", "/v1/insights/e?api_key=hk-query", `{"event":"e","distinct_id":"d"}`, nil, "hk-query"},
{"x-api-key", "/v1/insights/e", `{"event":"e","distinct_id":"d"}`, map[string]string{"x-api-key": "hk-hdr"}, "hk-hdr"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := stubResolver(t, func(string) (string, bool) { return "acme", true })
code := postKeyed(t, app, tc.path, "", tc.body, tc.hdr)
if *got != tc.wantKey {
t.Fatalf("resolver handed key %q, want %q", *got, tc.wantKey)
}
if code == http.StatusForbidden {
t.Fatalf("a resolved key must pass the tenant gate, got 403")
}
})
}
}
// TestCaptureTenant_UnresolvableKeyFailsClosed: a PRESENTED key that does not
// resolve is refused (403) EVEN on a recognized brand host — it must never fall
// through to the brand-public partition (that would be a cross-tenant write).
func TestCaptureTenant_UnresolvableKeyFailsClosed(t *testing.T) {
app := mountApp(t)
stubResolver(t, func(string) (string, bool) { return "", false }) // nothing resolves
code := postKeyed(t, app, "/v1/insights/e", "hanzo.ai",
`{"api_key":"hk-bad","event":"e","distinct_id":"d"}`, nil)
if code != http.StatusForbidden {
t.Fatalf("presented-but-unresolvable key on a brand host must 403 (fail closed), got %d", code)
}
}
// TestCaptureTenant_AnonBrandHostFallsBack: with NO key presented, anonymous
// traffic on a recognized brand host still resolves to the brand org (not 403), and
// the key resolver is never consulted — the key path only triggers on a real key.
func TestCaptureTenant_AnonBrandHostFallsBack(t *testing.T) {
app := mountApp(t)
stubResolver(t, func(key string) (string, bool) {
t.Fatalf("resolver consulted for a keyless request (key=%q)", key)
return "", false
})
code := postKeyed(t, app, "/v1/insights/e", "hanzo.ai",
`{"event":"e","distinct_id":"d"}`, nil)
if code == http.StatusForbidden {
t.Fatalf("anonymous capture on a recognized brand host must pass the tenant gate, got 403")
}
}
+139
View File
@@ -0,0 +1,139 @@
// 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.
// event.go — the ONE canonical event-ingestion front door.
//
// POST /v1/event body: Event | [Event] -> {accepted, dropped}
//
// A JSON object is one event; a JSON array IS the batch (there is deliberately no
// /v1/event/batch). Every other ingest surface (the PostHog wire at
// /v1/insights/e, the Segment/beacon wire at /v1/analytics{,/batch} and
// /v1/tracker) is a thin DEPRECATED adapter that normalizes its own wire shape
// onto CaptureEvent and funnels through the SAME write core (ingestEvents) into
// the SAME hanzo.events table. One write path, many adapters.
//
// AUTH — IAM ONLY, FAIL-CLOSED: the tenant is resolved SERVER-SIDE from a
// validated bearer principal (its owner org) or, for a keyed bearer-less SDK, an
// access key resolved through the ONE IAM key seam (cloud.OrgForKey). There is NO
// brand-host fallback on this endpoint: an unauthenticated or unresolvable caller
// is refused (403), so the canonical door never writes an event into a tenant IAM
// did not vouch for. The org is NEVER read from the body.
package analytics
import (
"encoding/json"
"net/http"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
)
// Event is the canonical analytics event — the entire ingest contract in four
// fields. Only these are first-class; everything else a caller wants to record
// travels in Properties (the scrubber runs over it downstream, same as every
// event). The tenant is NOT a field: it is resolved server-side from IAM, so a
// caller can only ever write into its OWN org's partition.
type Event struct {
Event string `json:"event"` // event name (required; empty ⇒ dropped as unroutable)
DistinctID string `json:"distinctId"` // the person/visitor id the caller owns
Time string `json:"time"` // optional RFC3339; clamped to server-now on skew/absent
Properties map[string]any `json:"properties"` // everything non-core
}
// toCapture adapts the canonical Event onto the internal CaptureEvent the write
// core consumes. Type is left empty (canonicalType ⇒ "event"); no $-property is
// promoted to a column here — /v1/event stays a strict four-field contract, and
// every non-core field the caller sent stays in Properties.
func (e Event) toCapture() CaptureEvent {
return CaptureEvent{
Event: e.Event,
DistinctID: e.DistinctID,
Timestamp: e.Time,
Properties: e.Properties,
}
}
// eventTenant resolves the tenant for POST /v1/event — IAM ONLY, FAIL-CLOSED. A
// validated bearer principal wins (its owner org); otherwise a presented access
// key is resolved to its org through the ONE IAM key seam (resolveKeyOrg →
// cloud.OrgForKey). There is NO brand-host fallback: an unauthenticated or
// unresolvable caller returns ("", false) → 403. (The deprecated aliases keep
// captureTenant's brand-host path for anonymous marketing traffic; the canonical
// endpoint is deliberately stricter — IAM is the only tenant authority here.)
func eventTenant(c *zip.Ctx) (string, bool) {
if org, ok := tenant(c); ok {
return org, true
}
if key := projectKey(c); key != "" {
if org, ok := resolveKeyOrg(c.Context(), key); ok {
return org, true
}
}
return "", false
}
// decodeEvents decodes a request body as Event | []Event. The first non-space
// byte decides: '[' ⇒ the array batch, anything else ⇒ a single Event. An empty
// body yields no events (an honest empty receipt, not an error). Pure over the
// raw bytes (the handler passes c.Body() — fasthttp-buffered, the same bytes
// projectKey peeked) so the decode is driven directly by tests.
func decodeEvents(body []byte) ([]Event, error) {
i := 0
for i < len(body) {
if b := body[i]; b == ' ' || b == '\t' || b == '\r' || b == '\n' {
i++
continue
}
break
}
if i >= len(body) {
return nil, nil
}
if body[i] == '[' {
var evs []Event
if err := json.Unmarshal(body, &evs); err != nil {
return nil, err
}
return evs, nil
}
var e Event
if err := json.Unmarshal(body, &e); err != nil {
return nil, err
}
return []Event{e}, nil
}
// eventIngest answers POST /v1/event — the ONE canonical ingestion front door.
// Org is IAM-derived and fail-closed (eventTenant); the body is Event | [Event];
// every event flows through the ONE write core (ingestEvents) into the ONE
// hanzo.events table, tagged source=event.
func eventIngest(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := eventTenant(c)
if !ok {
return zip.ErrForbidden("valid bearer or a resolvable access key required")
}
evs, err := decodeEvents(c.Body())
if err != nil {
return zip.ErrBadRequest("malformed event payload")
}
caps := make([]CaptureEvent, len(evs))
for i, e := range evs {
caps[i] = e.toCapture()
}
res, err := ingestEvents(c.Context(), org, sourceEvent, caps)
if err != nil {
return err
}
return c.JSON(http.StatusOK, res)
}
+240
View File
@@ -0,0 +1,240 @@
// 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.
// See the License for the specific language governing permissions and
// limitations under the License.
package analytics
import (
"net/http"
"testing"
"time"
)
// ── decodeEvents: Event | []Event (single + batch) ──────────────────────────
func TestDecodeEvents_Single(t *testing.T) {
evs, err := decodeEvents([]byte(`{"event":"signup","distinctId":"u1","properties":{"plan":"pro"}}`))
if err != nil {
t.Fatalf("decode single: %v", err)
}
if len(evs) != 1 {
t.Fatalf("single want 1 event, got %d", len(evs))
}
if evs[0].Event != "signup" || evs[0].DistinctID != "u1" {
t.Fatalf("single decoded = %+v", evs[0])
}
if evs[0].Properties["plan"] != "pro" {
t.Fatalf("single properties = %v", evs[0].Properties)
}
}
func TestDecodeEvents_Batch(t *testing.T) {
evs, err := decodeEvents([]byte(`[{"event":"a","distinctId":"d"},{"event":"b","distinctId":"d"}]`))
if err != nil {
t.Fatalf("decode batch: %v", err)
}
if len(evs) != 2 || evs[0].Event != "a" || evs[1].Event != "b" {
t.Fatalf("batch decoded = %+v", evs)
}
}
func TestDecodeEvents_BatchLeadingWhitespace(t *testing.T) {
// The array is detected past leading whitespace, not only at byte 0.
evs, err := decodeEvents([]byte(" \n\t [{\"event\":\"a\"}]"))
if err != nil {
t.Fatalf("decode ws-batch: %v", err)
}
if len(evs) != 1 || evs[0].Event != "a" {
t.Fatalf("ws-batch decoded = %+v", evs)
}
}
func TestDecodeEvents_EmptyIsNoEvents(t *testing.T) {
// Empty / whitespace-only body ⇒ zero events, NOT an error (honest empty receipt).
for _, b := range []string{"", " ", "\n\t"} {
evs, err := decodeEvents([]byte(b))
if err != nil || len(evs) != 0 {
t.Fatalf("empty %q ⇒ evs=%v err=%v", b, evs, err)
}
}
}
func TestDecodeEvents_Malformed(t *testing.T) {
for _, b := range []string{`{"event":`, `[{"event":"a"},`, `not json`} {
if _, err := decodeEvents([]byte(b)); err == nil {
t.Fatalf("malformed %q want error, got nil", b)
}
}
}
// ── adapter → Event/CaptureEvent normalization ──────────────────────────────
// TestEventToCapture: the canonical Event maps onto CaptureEvent with ONLY the
// four core fields promoted; Type is left empty (⇒ "event") and everything else
// stays in Properties (nothing is lifted to a column).
func TestEventToCapture(t *testing.T) {
e := Event{
Event: "purchase",
DistinctID: "u9",
Time: "2026-07-18T00:00:00Z",
Properties: map[string]any{"amount": 42, "$current_url": "https://x/y"},
}
ce := e.toCapture()
if ce.Event != "purchase" || ce.DistinctID != "u9" || ce.Timestamp != "2026-07-18T00:00:00Z" {
t.Fatalf("core fields = %+v", ce)
}
if ce.Type != "" {
t.Fatalf("Type must be empty (⇒ canonicalType event), got %q", ce.Type)
}
if ce.URL != "" {
t.Fatalf("no $-property is promoted to a column on the canonical wire; URL=%q", ce.URL)
}
// non-core stays in properties
if ce.Properties["amount"] != 42 || ce.Properties["$current_url"] != "https://x/y" {
t.Fatalf("properties passthrough = %v", ce.Properties)
}
}
// TestEventNormalizeThroughCore: a canonical Event, adapted and normalized, yields
// a row stamped with the SERVER org and the resolved event name.
func TestEventNormalizeThroughCore(t *testing.T) {
row, ok := normalizeEvent("acme", time.Now(), Event{Event: "signup", DistinctID: "u1"}.toCapture())
if !ok {
t.Fatal("want routable")
}
if row.tenant != "acme" || row.event != "signup" || row.eventType != "event" {
t.Fatalf("row = tenant %q event %q type %q", row.tenant, row.event, row.eventType)
}
}
// TestInsightsAdapterNormalization: the PostHog wire adapter lifts well-known
// $-properties to columns (that is its job) — the OTHER adapter feeding the ONE
// core, distinct from the canonical Event wire.
func TestInsightsAdapterNormalization(t *testing.T) {
ce := insightsEvent{
Event: "$pageview",
DistinctID: "d",
Properties: map[string]any{"$current_url": "https://x/y", "$session_id": "s1"},
}.toCapture()
if ce.Type != "pageview" {
t.Fatalf("posthog $pageview ⇒ type pageview, got %q", ce.Type)
}
if ce.URL != "https://x/y" || ce.SessionID != "s1" {
t.Fatalf("posthog $-props ⇒ columns: url=%q session=%q", ce.URL, ce.SessionID)
}
}
// TestCaptureBatchAdapter: the Segment/beacon adapter prefers `batch`, falling
// back to `events`.
func TestCaptureBatchAdapter(t *testing.T) {
if got := (CaptureBatch{Batch: []CaptureEvent{{Event: "a"}}, Events: []CaptureEvent{{Event: "b"}}}).events(); len(got) != 1 || got[0].Event != "a" {
t.Fatalf("batch preferred over events, got %+v", got)
}
if got := (CaptureBatch{Events: []CaptureEvent{{Event: "b"}}}).events(); len(got) != 1 || got[0].Event != "b" {
t.Fatalf("events fallback, got %+v", got)
}
}
// ── source tagging (the $source property, one-table origin discriminator) ────
func TestWithSource(t *testing.T) {
// stamps $source
got := withSource(nil, sourceEvent)
if got["$source"] != "event" {
t.Fatalf("withSource(nil,event) = %v", got)
}
// does not mutate the caller's map, and preserves existing keys
orig := map[string]any{"a": 1}
out := withSource(orig, sourcePostHog)
if out["a"] != 1 || out["$source"] != "posthog" {
t.Fatalf("withSource copy = %v", out)
}
if _, leaked := orig["$source"]; leaked {
t.Fatalf("withSource mutated the caller's map: %v", orig)
}
// empty source is a no-op passthrough (same map)
if got := withSource(orig, ""); got["$source"] != nil {
t.Fatalf("empty source must not stamp, got %v", got)
}
}
// TestSourceStampedIntoProperties: source flows through withSource → normalizeEvent
// → the stored properties JSON, so the ONE hanzo.events table carries origin
// WITHOUT a schema column.
func TestSourceStampedIntoProperties(t *testing.T) {
e := CaptureEvent{Event: "x", Properties: withSource(nil, sourceEvent)}
row, ok := normalizeEvent("acme", time.Now(), e)
if !ok {
t.Fatal("want routable")
}
props := decodeProps(t, row.properties)
if props["$source"] != "event" {
t.Fatalf("row.properties $source = %v (props=%v)", props["$source"], props)
}
}
// ── POST /v1/event: IAM-only, fail-closed auth ──────────────────────────────
//
// Observable proxy (mirrors capture_keyorg_test): a REFUSED request is 403; an
// ADMITTED one reaches requireDatastore and returns 503 (no datastore in tests).
// So "not 403" ⇒ the tenant gate admitted the request.
func TestEvent_NoPrincipalNoKeyForbidden(t *testing.T) {
app := mountApp(t)
if code, _ := doBody(t, app, http.MethodPost, "/v1/event", "", "", `{"event":"e","distinctId":"d"}`); code != http.StatusForbidden {
t.Fatalf("no-principal no-key /v1/event want 403, got %d", code)
}
}
func TestEvent_BearerPrincipalAdmitted(t *testing.T) {
app := mountApp(t)
// A validated principal (X-User/X-Org) is admitted → 503 (datastore down), not 403.
if code, _ := doBody(t, app, http.MethodPost, "/v1/event", "user-dave", "acme", `{"event":"signup","distinctId":"u1"}`); code != http.StatusServiceUnavailable {
t.Fatalf("bearer /v1/event want 503 (admitted, datastore down), got %d", code)
}
// A batch body is admitted the same way.
if code, _ := doBody(t, app, http.MethodPost, "/v1/event", "user-dave", "acme", `[{"event":"a","distinctId":"d"},{"event":"b","distinctId":"d"}]`); code != http.StatusServiceUnavailable {
t.Fatalf("bearer /v1/event batch want 503, got %d", code)
}
}
func TestEvent_ResolvedKeyAdmitted(t *testing.T) {
app := mountApp(t)
got := stubResolver(t, func(string) (string, bool) { return "acme", true })
code := postKeyed(t, app, "/v1/event", "", `{"api_key":"hk-k","event":"e","distinctId":"d"}`, nil)
if *got != "hk-k" {
t.Fatalf("resolver handed key %q, want hk-k", *got)
}
if code == http.StatusForbidden {
t.Fatalf("a resolved access key must pass the /v1/event gate, got 403")
}
}
func TestEvent_UnresolvableKeyFailsClosedEvenOnBrandHost(t *testing.T) {
app := mountApp(t)
stubResolver(t, func(string) (string, bool) { return "", false })
code := postKeyed(t, app, "/v1/event", "hanzo.ai", `{"api_key":"hk-bad","event":"e","distinctId":"d"}`, nil)
if code != http.StatusForbidden {
t.Fatalf("presented-but-unresolvable key on /v1/event must 403 (fail closed), got %d", code)
}
}
// TestEvent_NoBrandHostFallback is THE distinguishing invariant: anonymous traffic
// on a recognized brand host is ADMITTED by the deprecated /v1/analytics alias
// (brand-public partition) but REFUSED by the canonical /v1/event — IAM is the
// only tenant authority on the canonical door.
func TestEvent_NoBrandHostFallback(t *testing.T) {
app := mountApp(t)
body := `{"event":"e","distinctId":"d"}`
if code, _ := doHost(t, app, "/v1/event", "", "", "hanzo.ai", body); code != http.StatusForbidden {
t.Fatalf("anonymous brand-host /v1/event must 403 (no brand fallback), got %d", code)
}
// Contrast: the deprecated alias still admits the same anonymous brand-host
// traffic (503 = admitted, datastore down), proving the difference is by design.
if code, _ := doHost(t, app, "/v1/analytics", "", "", "hanzo.ai", `{"batch":[{"type":"pageview"}]}`); code != http.StatusServiceUnavailable {
t.Fatalf("deprecated alias still brand-admits (want 503), got %d", code)
}
}
+34 -32
View File
@@ -33,7 +33,11 @@ import (
)
// insightsEvent is the PostHog wire shape (subset that matters for ingest).
// UUID is the top-level per-event id PostHog SDKs mint for idempotency; the rest
// of the identity/attribution the SDKs carry rides inside Properties (mapped in
// toCapture).
type insightsEvent struct {
UUID string `json:"uuid"`
Event string `json:"event"`
DistinctID string `json:"distinct_id"`
Timestamp string `json:"timestamp"`
@@ -65,6 +69,11 @@ func (e insightsEvent) toCapture() CaptureEvent {
typ = "pageview"
}
return CaptureEvent{
// Idempotency id: PostHog SDKs carry a top-level event `uuid`; some send it
// as an `$insert_id` property instead. Preserve it as the client MessageID so
// a retried batch (insights-go retries with backoff) keeps a STABLE row id
// rather than the server minting a fresh one per attempt.
MessageID: firstNonEmptyStr(strings.TrimSpace(e.UUID), strings.TrimSpace(str("$insert_id"))),
Type: typ,
Event: e.Event,
Timestamp: e.Timestamp,
@@ -73,6 +82,19 @@ func (e insightsEvent) toCapture() CaptureEvent {
URL: str("$current_url"),
Path: str("$pathname"),
Referrer: str("$referrer"),
// UTM attribution: PostHog SDKs put campaign params in BARE `utm_*`
// properties (not $-prefixed — confirmed against the SDK/ingest source).
// hanzo.events has first-class utm_* columns and the native capture path
// maps CaptureEvent.UTM into them (capture.go), so surfacing them here is
// what lets the web/commerce lens attribute traffic to a campaign. They were
// previously dropped on the PostHog-wire front door.
UTM: UTM{
Source: str("utm_source"),
Medium: str("utm_medium"),
Campaign: str("utm_campaign"),
Term: str("utm_term"),
Content: str("utm_content"),
},
Product: str("product"),
Library: str("$lib"),
LibraryVer: str("$lib_version"),
@@ -80,9 +102,13 @@ func (e insightsEvent) toCapture() CaptureEvent {
}
}
// insightsIngest answers POST /v1/insights/e — the PostHog-compatible front
// door. Same tenant gate, same normalize/scrub, same warehouse as /v1/analytics.
// insightsIngest answers POST /v1/insights/e — the DEPRECATED PostHog-wire
// adapter. It normalizes the PostHog single/batch shape onto CaptureEvent and
// funnels through the ONE write core (ingestEvents, source=posthog); it keeps
// captureTenant's brand-host path so anonymous PostHog-wire traffic is unbroken.
// New callers post the canonical Event to /v1/event.
func insightsIngest(s *cloud.Service[state], c *zip.Ctx) error {
deprecated(s, c, "/v1/event")
org, ok := captureTenant(c)
if !ok {
return zip.ErrForbidden("valid bearer or a recognized brand host required")
@@ -95,39 +121,15 @@ func insightsIngest(s *cloud.Service[state], c *zip.Ctx) error {
if len(events) == 0 && body.Event != "" {
events = []insightsEvent{body.insightsEvent}
}
if len(events) == 0 {
return c.JSON(http.StatusOK, CaptureResult{})
caps := make([]CaptureEvent, len(events))
for i, e := range events {
caps[i] = e.toCapture()
}
if len(events) > maxBatch {
return zip.ErrBadRequest("batch too large")
}
if err := requireDatastore(); err != nil {
res, err := ingestEvents(c.Context(), org, sourcePostHog, caps)
if err != nil {
return err
}
ctx := c.Context()
if err := EnsureEventsTable(ctx); err != nil {
return zip.Errorf(http.StatusServiceUnavailable, "analytics warehouse unavailable: %v", err)
}
now := time.Now().UTC()
rows := make([]eventRow, 0, len(events))
dropped := 0
for _, e := range events {
row, ok := normalizeEvent(org, now, e.toCapture())
if !ok {
dropped++
continue
}
rows = append(rows, row)
}
if len(rows) == 0 {
return c.JSON(http.StatusOK, CaptureResult{Dropped: dropped})
}
stmt, args := buildEventsInsert(rows)
if err := aiobject.DatastoreExec(ctx, stmt, args...); err != nil {
return zip.Errorf(http.StatusServiceUnavailable, "analytics warehouse write failed: %v", err)
}
return c.JSON(http.StatusOK, CaptureResult{Accepted: len(rows), Dropped: dropped})
return c.JSON(http.StatusOK, res)
}
// insightsEvents answers GET /v1/insights/events — the console's recent-events
+97
View File
@@ -0,0 +1,97 @@
// 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.
// See the License for the specific language governing permissions and
// limitations under the License.
package analytics
import (
"testing"
"time"
)
// TestToCapture_PreservesUTMAttribution proves the PostHog-wire adapter carries the
// BARE utm_* campaign params (what PostHog SDKs emit) through to the native
// CaptureEvent — and thence, via normalizeEvent, into the hanzo.events utm_*
// columns the INSERT binds. Regression guard: these were previously dropped, so
// every campaign-attributed pageview lost its source/medium/campaign on the
// /v1/insights/e front door and the web/commerce lens could never attribute it.
func TestToCapture_PreservesUTMAttribution(t *testing.T) {
e := insightsEvent{
Event: "$pageview",
DistinctID: "visitor-1",
Properties: map[string]any{
"utm_source": "newsletter",
"utm_medium": "email",
"utm_campaign": "launch",
"utm_term": "analytics",
"utm_content": "hero-cta",
"$current_url": "https://hanzo.ai/insights",
},
}
cap := e.toCapture()
if cap.UTM.Source != "newsletter" || cap.UTM.Medium != "email" ||
cap.UTM.Campaign != "launch" || cap.UTM.Term != "analytics" || cap.UTM.Content != "hero-cta" {
t.Fatalf("UTM not mapped from PostHog wire: %+v", cap.UTM)
}
// End-to-end through the normalizer into the positional row the INSERT binds.
row, ok := normalizeEvent("acme", time.Now(), cap)
if !ok {
t.Fatal("want ok")
}
if row.utmSource != "newsletter" || row.utmMedium != "email" ||
row.utmCampaign != "launch" || row.utmTerm != "analytics" || row.utmContent != "hero-cta" {
t.Fatalf("UTM lost before the events row: src=%q med=%q camp=%q term=%q content=%q",
row.utmSource, row.utmMedium, row.utmCampaign, row.utmTerm, row.utmContent)
}
}
// TestToCapture_IdempotencyID proves the client event id (PostHog top-level `uuid`,
// or the `$insert_id` property fallback) is preserved as the stable row id, so a
// retried batch does not mint a fresh id per attempt — while an absent id still
// falls back to a server-minted one (existing behavior unchanged).
func TestToCapture_IdempotencyID(t *testing.T) {
// top-level uuid wins
row, _ := normalizeEvent("acme", time.Now(),
insightsEvent{Event: "signup", DistinctID: "u1", UUID: "evt-abc"}.toCapture())
if row.id != "evt-abc" {
t.Fatalf("top-level uuid not preserved as row id, got %q", row.id)
}
// $insert_id property fallback when no top-level uuid
row2, _ := normalizeEvent("acme", time.Now(),
insightsEvent{Event: "signup", DistinctID: "u1", Properties: map[string]any{"$insert_id": "ins-9"}}.toCapture())
if row2.id != "ins-9" {
t.Fatalf("$insert_id fallback not preserved, got %q", row2.id)
}
// absent → server still mints a non-empty id
row3, _ := normalizeEvent("acme", time.Now(),
insightsEvent{Event: "signup", DistinctID: "u1"}.toCapture())
if row3.id == "" {
t.Fatal("server must still mint an id when the client sends none")
}
}
// TestToCapture_MapsCoreFields guards that the pre-existing $-property mappings
// still hold alongside the new UTM/idempotency mappings (no regression).
func TestToCapture_MapsCoreFields(t *testing.T) {
cap := insightsEvent{
Event: "$pageview",
DistinctID: "v1",
Properties: map[string]any{
"$session_id": "s1",
"$current_url": "https://hanzo.ai/x",
"$pathname": "/x",
"$referrer": "https://news.ycombinator.com/",
"$lib": "insights-go",
"$lib_version": "1.2.3",
"product": "console",
},
}.toCapture()
if cap.Type != "pageview" || cap.SessionID != "s1" || cap.URL != "https://hanzo.ai/x" ||
cap.Path != "/x" || cap.Referrer != "https://news.ycombinator.com/" ||
cap.Library != "insights-go" || cap.LibraryVer != "1.2.3" || cap.Product != "console" {
t.Fatalf("core PostHog-wire mapping regressed: %+v", cap)
}
}
+26 -134
View File
@@ -1,152 +1,44 @@
package authors
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 author royalty loop needs: read a deploying
// 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.
// org's metered spend (the royalty accrual base) and grant a promo credit to a wallet
// (a payout made in credits, ledger tag grant:author). 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/affiliates/commerce.go EXACTLY: the same
// COMMERCE_SERVICE_TOKEN S2S path, the same X-Org-Id=<org> namespace + bare org
// `user` subject — so an author payout-in-credits lands in precisely the wallet the
// balance panel reads, indistinguishable from an admin/affiliate grant except by its
// ledger tag (grant:author, → 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 author payout-in-credits still lands in precisely the wallet the
// balance panel reads, indistinguishable from an admin grant except by its
// grant:author tag.
type commerce interface {
configured() bool
// deposit grants amountCents to org's wallet (Credit/trial bucket via the
// grant:author 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 deploying org's month-to-date metered consumption — the royalty
// accrual base (spend × the author's share).
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("authors: 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
// affiliates.httpCommerce.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)} }
+28 -17
View File
@@ -43,6 +43,7 @@ import (
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/audit"
"github.com/hanzoai/cloud/clients/connectorruntime"
"github.com/hanzoai/cloud/clients/principal"
"github.com/hanzoai/cloud/clients/tools"
"github.com/zap-proto/zip"
@@ -138,35 +139,45 @@ func Mount(app *zip.App, deps cloud.Deps) error {
tools.Register(connectorToolProvider{})
b.Log.Info("automations mounted", "connectors", catalog.ConnectorCount, "runtime", len(registry), "brand", deps.Brand)
// Native single-connector execution (HIP-0126): POST /v1/automations/connectors/:id/run,
// the in-process goja runner paired with the connector catalogue above. It mounts one
// route DISTINCT from every automations route (no /v1/automations/* wildcard here, so no
// shadow), and was a separate Wire entry purely for that one route — fold it in as a
// terminal sub-mount so connector catalogue + execution are ONE automations subsystem.
if err := connectorruntime.Mount(app, deps); err != nil {
return err
}
return nil
}
// routes registers the automations surface: the connector catalog, flow CRUD +
// versioning + lifecycle, run history, and the MCP endpoint.
func routes(app *zip.App, s *cloud.Service[state]) {
app.Get("/v1/automations/connectors", cloud.Handle(s, connectors))
g := app.Group("/v1/automations")
g.Get("/connectors", cloud.Handle(s, connectors))
// Back-compat alias: the pre-rename /pieces path stays valid (same handler, same
// body) so live clients pinned to it keep working. "pieces" is the retired
// ActivePieces term; "connectors" is the ONE Hanzo name (HIP-0126).
app.Get("/v1/automations/pieces", cloud.Handle(s, connectors))
g.Get("/pieces", cloud.Handle(s, connectors))
app.Get("/v1/automations/flows", cloud.Handle(s, listFlows))
app.Post("/v1/automations/flows", cloud.Handle(s, createFlow))
app.Get("/v1/automations/flows/:id", cloud.Handle(s, getFlow))
app.Patch("/v1/automations/flows/:id", cloud.Handle(s, updateFlow))
app.Delete("/v1/automations/flows/:id", cloud.Handle(s, deleteFlow))
app.Get("/v1/automations/flows/:id/versions", cloud.Handle(s, listVersions))
app.Post("/v1/automations/flows/:id/versions", cloud.Handle(s, createVersion))
app.Post("/v1/automations/flows/:id/operations", cloud.Handle(s, applyOperation))
app.Post("/v1/automations/flows/:id/run", cloud.Handle(s, runFlow))
app.Post("/v1/automations/flows/:id/enable", cloud.Handle(s, enableFlow))
app.Post("/v1/automations/flows/:id/disable", cloud.Handle(s, disableFlow))
g.Get("/flows", cloud.Handle(s, listFlows))
g.Post("/flows", cloud.Handle(s, createFlow))
g.Get("/flows/:id", cloud.Handle(s, getFlow))
g.Patch("/flows/:id", cloud.Handle(s, updateFlow))
g.Delete("/flows/:id", cloud.Handle(s, deleteFlow))
g.Get("/flows/:id/versions", cloud.Handle(s, listVersions))
g.Post("/flows/:id/versions", cloud.Handle(s, createVersion))
g.Post("/flows/:id/operations", cloud.Handle(s, applyOperation))
g.Post("/flows/:id/run", cloud.Handle(s, runFlow))
g.Post("/flows/:id/enable", cloud.Handle(s, enableFlow))
g.Post("/flows/:id/disable", cloud.Handle(s, disableFlow))
app.Get("/v1/automations/runs", cloud.Handle(s, listRuns))
app.Get("/v1/automations/runs/:id", cloud.Handle(s, getRun))
app.Post("/v1/automations/runs/:id/resume", cloud.Handle(s, resumeRun))
g.Get("/runs", cloud.Handle(s, listRuns))
g.Get("/runs/:id", cloud.Handle(s, getRun))
g.Post("/runs/:id/resume", cloud.Handle(s, resumeRun))
app.Post("/v1/automations/mcp", cloud.Handle(s, mcp))
g.Post("/mcp", cloud.Handle(s, mcp))
}
// Shutdown closes the store. Idempotent — safe when nothing is mounted.
+33 -1
View File
@@ -15,7 +15,7 @@
// LANE 2 — managed Base hosting (what superbase/PocketHost provided). ONE Base
// app PER ORG, opened lazily and pooled, each on its OWN SQLite under
// {DataDir}/base/{orgSegment}/ — the same "prod = SQLite per tenant" model
// (HIP-0302) the gojabase leaves (captable/sign/dataroom) use, so an org's
// (HIP-0302) the NewBase leaves (captable/sign/dataroom) use, so an org's
// collections/records are PHYSICALLY isolated. Served AUTHENTICATED under
// /v1/base/*, the org resolved from the VALIDATED cloud principal (never a
// client header). This is the console Bases manager's backend.
@@ -61,6 +61,7 @@ import (
"github.com/hanzoai/base/plugins/waitlist"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/principal"
"github.com/hanzoai/cloud/clients/sites"
"github.com/zap-proto/zip"
)
@@ -158,12 +159,43 @@ func Mount(app *zip.App, deps cloud.Deps) error {
app.All("/v1/base/*", func(c *zip.Ctx) error { return serveOrg(p, log, c) })
mounted = &subsystem{pool: p, platform: platformApp}
// Host-as-project-ref (HIP-0014, gated by CLOUD_BASE_PUBLIC_HOST, default OFF):
// let a published site host serve /v1/base, /v1/realtime and /_/ scoped to the
// org its SUBDOMAIN resolves to — so an anon page can reach its own Base, authz
// by Base's collection rules. The org comes from the resolved site, never the
// caller. Absent the flag, site hosts serve only static files (unchanged).
if publicHostEnabled() {
sites.SetBaseHostHandler(func(org string, c *zip.Ctx) error {
h, release, err := p.acquire(org)
if err != nil {
log.Error("base: open org app failed", "err", err)
return zip.Errorf(http.StatusInternalServerError, "base unavailable")
}
defer release()
return zip.AdaptNetHTTP(h)(c)
})
log.Info("base public-host routing enabled", "flag", publicHostEnv)
}
log.Info("base app embedded",
"waitlist", "/v1/waitlist/*", "hosting", "/v1/base/*",
"prefix", os.Getenv("BASE_API_PREFIX"), "brand", deps.Brand, "env", deps.Env)
return nil
}
// publicHostEnv gates host-as-project-ref Base routing (default OFF).
const publicHostEnv = "CLOUD_BASE_PUBLIC_HOST"
func publicHostEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(publicHostEnv))) {
case "1", "true", "yes":
return true
default:
return false
}
}
// serveOrg resolves the caller's org from the validated principal, acquires that
// org's pooled Base app (pinned for the request so eviction can't close it
// mid-flight), and serves the request through the org's own Base mux.
+3 -3
View File
@@ -18,7 +18,7 @@ import (
baseapp "github.com/hanzoai/base"
"github.com/hanzoai/base/core"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/gojabase"
"github.com/hanzoai/cloud/clients/goja"
luxlog "github.com/luxfi/log"
fiber "github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip"
@@ -170,8 +170,8 @@ func TestPerOrgIsolatedCRUD(t *testing.T) {
}
// The isolation is physical: distinct on-disk data dirs per org segment.
acmeDir := filepath.Join(dataDir, "base", gojabase.TenantSegment("acme"))
globexDir := filepath.Join(dataDir, "base", gojabase.TenantSegment("globex"))
acmeDir := filepath.Join(dataDir, "base", goja.TenantSegment("acme"))
globexDir := filepath.Join(dataDir, "base", goja.TenantSegment("globex"))
if acmeDir == globexDir {
t.Fatalf("orgs share a data dir: %s", acmeDir)
}
+4 -4
View File
@@ -17,7 +17,7 @@ import (
baseapp "github.com/hanzoai/base"
"github.com/hanzoai/base/apis"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/gojabase"
"github.com/hanzoai/cloud/clients/goja"
)
// Pool sizing (env-overridable). A full Base app is heavier than a bare *sql.DB
@@ -35,7 +35,7 @@ const (
// ({DataDir}/base/{TenantSegment}/), the "prod = SQLite per tenant" rule
// (HIP-0302). Apps open lazily on first request, migrate once, and are pooled
// (LRU-capped, idle-evicted). Concurrent opens of the same org are
// single-flighted under mu. The org→segment encoding is gojabase.TenantSegment —
// single-flighted under mu. The org→segment encoding is goja.TenantSegment —
// the ONE injective, traversal-safe tenant→path encoder, shared so an org maps
// to exactly one physical identity everywhere in the binary.
type pool struct {
@@ -85,7 +85,7 @@ func (p *pool) acquire(org string) (http.Handler, func(), error) {
if strings.TrimSpace(org) == "" {
return nil, nil, fmt.Errorf("base: empty org")
}
seg := gojabase.TenantSegment(org)
seg := goja.TenantSegment(org)
p.mu.Lock()
defer p.mu.Unlock()
@@ -111,7 +111,7 @@ func (p *pool) acquire(org string) (http.Handler, func(), error) {
// for in-process callers that drive the engine's Go API directly (collection
// provisioning, seeding) rather than the HTTP path.
func (p *pool) appFor(org string) (*baseapp.Base, error) {
seg := gojabase.TenantSegment(org)
seg := goja.TenantSegment(org)
p.mu.Lock()
defer p.mu.Unlock()
if e, ok := p.m[seg]; ok {
+9 -1
View File
@@ -6,6 +6,7 @@ import (
"github.com/hanzoai/account"
"github.com/hanzoai/cloud/clients/finance"
"github.com/hanzoai/cloud/clients/principal"
"github.com/zap-proto/zip"
)
@@ -57,7 +58,14 @@ import (
// production tokens carry one. Called out for review rather than papered over.
func subjectFor(c *zip.Ctx, org string) string {
if name := strings.TrimSpace(c.Header("X-User-Name")); name != "" {
return account.Payer(account.Credential{Owner: org, Name: name}).Subject()
// Hand Payer the account the credential NAMES (the validated `billing_account`
// claim, minted into X-Billing-Account-Id). The ai gate reads the same claim,
// so this view and that gate resolve one wallet. Reading it here is what keeps
// them from drifting the way the org-vs-"org/user" split once did — except
// that split was two rules, and this would be one rule fed two different
// credentials, which reads the same to a user: a funded balance the gate
// refuses. Absent ⟹ Payer's legacy rule, exactly today's answer.
return account.Payer(account.Credential{Owner: org, Name: name, Account: principal.BillingAccount(c)}).Subject()
}
return account.PayerOf(org, strings.TrimSpace(c.User())).Subject()
}
+3 -2
View File
@@ -134,9 +134,10 @@ func Mount(app *zip.App, deps cloud.Deps) error {
// routes registers the bots surface. The static /run literal and the :runId param
// are resolved by specificity, so /v1/bots/run can never bind as a run id.
func routes(app *zip.App, s *cloud.Service[state]) {
app.Post("/v1/bots/run", cloud.Handle(s, run))
g := app.Group("/v1/bots")
g.Post("/run", cloud.Handle(s, run))
app.Get("/v1/bots", cloud.Handle(s, list))
app.Post("/v1/bots/:runId/stop", cloud.Handle(s, stop))
g.Post("/:runId/stop", cloud.Handle(s, stop))
}
// run reports that launching is not implemented.
+39 -38
View File
@@ -8,12 +8,12 @@
// business LOGIC (ported to a self-contained goja bundle in github.com/hanzoai/
// captable) and gives it PERSISTENCE over per-tenant Base/SQLite. The bundle
// carries logic; the Go host carries storage. The seam between them is the
// REUSABLE clients/gojabase binding (the RW-Base goja host), which esign (#100)
// REUSABLE clients/goja binding (the RW-Base goja host), which esign (#100)
// and dataroom (#101) reuse unchanged — this leaf is just:
//
// captable bundle (github.com/hanzoai/captable.Bundle) + the per-tenant Schema
// │
// clients/gojabase.New(...) ← injects __db/__newId/__now,
// clients/goja.NewBase(...) ← injects __db/__newId/__now,
// │ one SQLite file per tenant,
// /v1/captable/* zip routes one transaction per request
//
@@ -37,7 +37,7 @@ import (
hcaptable "github.com/hanzoai/captable"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/gojabase"
"github.com/hanzoai/cloud/clients/goja"
"github.com/hanzoai/cloud/clients/principal"
"github.com/zap-proto/zip"
)
@@ -48,7 +48,7 @@ const maxBody = 1 << 20 // 1 MiB
// state is captable's own data; shared deps live in the embedded cloud.Base.
type state struct {
host *gojabase.Host
host *goja.BaseHost
}
// mounted is the active service so Shutdown can release the per-tenant stores.
@@ -72,7 +72,7 @@ func Mount(app *zip.App, deps cloud.Deps) error {
if err != nil {
return fmt.Errorf("captable.Mount: load bundle: %w", err)
}
host, err := gojabase.New(gojabase.Config{
host, err := goja.NewBase(goja.BaseConfig{
Name: "captable",
Bundle: bundle,
Schema: schema,
@@ -80,7 +80,7 @@ func Mount(app *zip.App, deps cloud.Deps) error {
OnOpen: seedCompany,
})
if err != nil {
return fmt.Errorf("captable.Mount: gojabase host: %w", err)
return fmt.Errorf("captable.Mount: goja NewBase host: %w", err)
}
s := &cloud.Service[state]{Base: cloud.NewBase(deps, "captable"), State: state{host: host}}
mounted = s
@@ -98,48 +98,49 @@ func Mount(app *zip.App, deps cloud.Deps) error {
// routes wires the /v1/captable/* route table → bundle route names. GET reads
// carry no body; mutations do.
func routes(app *zip.App, s *cloud.Service[state]) {
g := app.Group("/v1/captable")
// company
app.Get("/v1/captable/company", route(s, "company.get", nil, false))
app.Put("/v1/captable/company", route(s, "company.update", nil, true))
g.Get("/company", route(s, "company.get", nil, false))
g.Put("/company", route(s, "company.update", nil, true))
// stakeholders
app.Get("/v1/captable/stakeholders", route(s, "stakeholders.list", nil, false))
app.Post("/v1/captable/stakeholders", route(s, "stakeholders.add", nil, true))
app.Patch("/v1/captable/stakeholders/:id", routeID(s, "stakeholders.update", true))
app.Delete("/v1/captable/stakeholders/:id", routeID(s, "stakeholders.delete", false))
g.Get("/stakeholders", route(s, "stakeholders.list", nil, false))
g.Post("/stakeholders", route(s, "stakeholders.add", nil, true))
g.Patch("/stakeholders/:id", routeID(s, "stakeholders.update", true))
g.Delete("/stakeholders/:id", routeID(s, "stakeholders.delete", false))
// share classes
app.Get("/v1/captable/share-classes", route(s, "shareClasses.list", nil, false))
app.Post("/v1/captable/share-classes", route(s, "shareClasses.create", nil, true))
app.Patch("/v1/captable/share-classes/:id", routeID(s, "shareClasses.update", true))
g.Get("/share-classes", route(s, "shareClasses.list", nil, false))
g.Post("/share-classes", route(s, "shareClasses.create", nil, true))
g.Patch("/share-classes/:id", routeID(s, "shareClasses.update", true))
// equity plans
app.Get("/v1/captable/equity-plans", route(s, "equityPlans.list", nil, false))
app.Post("/v1/captable/equity-plans", route(s, "equityPlans.create", nil, true))
g.Get("/equity-plans", route(s, "equityPlans.list", nil, false))
g.Post("/equity-plans", route(s, "equityPlans.create", nil, true))
// shares (issuance + transfer). /shares/transfer registers before /shares/:id
// (different methods anyway) so it can never be shadowed.
app.Get("/v1/captable/shares", route(s, "shares.list", nil, false))
app.Post("/v1/captable/shares", route(s, "shares.add", nil, true))
app.Post("/v1/captable/shares/transfer", route(s, "shares.transfer", nil, true))
app.Delete("/v1/captable/shares/:id", routeID(s, "shares.delete", false))
g.Get("/shares", route(s, "shares.list", nil, false))
g.Post("/shares", route(s, "shares.add", nil, true))
g.Post("/shares/transfer", route(s, "shares.transfer", nil, true))
g.Delete("/shares/:id", routeID(s, "shares.delete", false))
// options
app.Get("/v1/captable/options", route(s, "options.list", nil, false))
app.Post("/v1/captable/options", route(s, "options.add", nil, true))
app.Delete("/v1/captable/options/:id", routeID(s, "options.delete", false))
g.Get("/options", route(s, "options.list", nil, false))
g.Post("/options", route(s, "options.add", nil, true))
g.Delete("/options/:id", routeID(s, "options.delete", false))
// SAFEs
app.Get("/v1/captable/safes", route(s, "safes.list", nil, false))
app.Post("/v1/captable/safes", route(s, "safes.create", nil, true))
app.Delete("/v1/captable/safes/:id", routeID(s, "safes.delete", false))
g.Get("/safes", route(s, "safes.list", nil, false))
g.Post("/safes", route(s, "safes.create", nil, true))
g.Delete("/safes/:id", routeID(s, "safes.delete", false))
// convertible notes
app.Get("/v1/captable/convertibles", route(s, "convertibles.list", nil, false))
app.Post("/v1/captable/convertibles", route(s, "convertibles.create", nil, true))
app.Delete("/v1/captable/convertibles/:id", routeID(s, "convertibles.delete", false))
g.Get("/convertibles", route(s, "convertibles.list", nil, false))
g.Post("/convertibles", route(s, "convertibles.create", nil, true))
g.Delete("/convertibles/:id", routeID(s, "convertibles.delete", false))
// rounds + investments
app.Get("/v1/captable/rounds", route(s, "rounds.list", nil, false))
app.Post("/v1/captable/rounds", route(s, "rounds.create", nil, true))
app.Get("/v1/captable/rounds/:id", routeID(s, "rounds.get", false))
app.Post("/v1/captable/rounds/:id/close", routeID(s, "rounds.close", true))
app.Post("/v1/captable/rounds/:id/investments", routeID(s, "rounds.investments.add", true))
app.Get("/v1/captable/investments", route(s, "rounds.investments.list", nil, false))
g.Get("/rounds", route(s, "rounds.list", nil, false))
g.Post("/rounds", route(s, "rounds.create", nil, true))
g.Get("/rounds/:id", routeID(s, "rounds.get", false))
g.Post("/rounds/:id/close", routeID(s, "rounds.close", true))
g.Post("/rounds/:id/investments", routeID(s, "rounds.investments.add", true))
g.Get("/investments", route(s, "rounds.investments.list", nil, false))
// computed cap table
app.Get("/v1/captable/summary", route(s, "captable", nil, false))
g.Get("/summary", route(s, "captable", nil, false))
}
// route builds a zip handler that dispatches a fixed bundle route. readBody
@@ -176,7 +177,7 @@ func dispatch(s *cloud.Service[state], c *zip.Ctx, route string, params map[stri
}
}
}
resp, err := s.State.host.Dispatch(c.Context(), org, gojabase.Request{
resp, err := s.State.host.Dispatch(c.Context(), org, goja.BaseRequest{
Route: route,
Params: params,
Body: body,
+7 -7
View File
@@ -6,22 +6,22 @@ import (
"testing"
hcaptable "github.com/hanzoai/captable"
"github.com/hanzoai/cloud/clients/gojabase"
"github.com/hanzoai/cloud/clients/goja"
)
// TestFullLifecycle drives the REAL embedded captable bundle against a REAL
// per-tenant SQLite through gojabase — the end-to-end proof that the bundle's SQL
// per-tenant SQLite through NewBase — the end-to-end proof that the bundle's SQL
// matches the Go host schema and that the whole cap-table fold round-trips
// through Base: company → stakeholders → share class → equity plan → share
// issuance → options → SAFE → priced round + investment (dilution) → transfer →
// computed cap table. Any column/route drift fails here, not in production.
func newHost(t *testing.T) *gojabase.Host {
func newHost(t *testing.T) *goja.BaseHost {
t.Helper()
bundle, err := hcaptable.Bundle()
if err != nil {
t.Fatal(err)
}
h, err := gojabase.New(gojabase.Config{
h, err := goja.NewBase(goja.BaseConfig{
Name: "captable",
Bundle: bundle,
Schema: schema,
@@ -36,9 +36,9 @@ func newHost(t *testing.T) *gojabase.Host {
}
// do dispatches a route and returns (status, decoded body).
func do(t *testing.T, h *gojabase.Host, org, route string, params map[string]string, body any) (int, any) {
func do(t *testing.T, h *goja.BaseHost, org, route string, params map[string]string, body any) (int, any) {
t.Helper()
resp, err := h.Dispatch(context.Background(), org, gojabase.Request{Route: route, Params: params, Body: body})
resp, err := h.Dispatch(context.Background(), org, goja.BaseRequest{Route: route, Params: params, Body: body})
if err != nil {
t.Fatalf("dispatch %s: %v", route, err)
}
@@ -300,7 +300,7 @@ func TestDilutiveOptionsExcludeTerminal(t *testing.T) {
}
// firstShareID returns the founder's original certificate share id.
func firstShareID(t *testing.T, h *gojabase.Host, org string) string {
func firstShareID(t *testing.T, h *goja.BaseHost, org string) string {
t.Helper()
_, body := do(t, h, org, "shares.list", nil, nil)
data := body.(map[string]any)["data"].([]any)
+4 -4
View File
@@ -7,7 +7,7 @@ import (
"fmt"
"time"
"github.com/hanzoai/cloud/clients/gojabase"
"github.com/hanzoai/cloud/clients/goja"
)
// facade.go is the in-process cap-table seam: it lets a sibling subsystem (Hanzo
@@ -74,7 +74,7 @@ type RoundInput struct {
// response. The body is round-tripped through JSON to a generic value so the goja
// bundle sees the SAME wire shape (lower-case json keys) the HTTP path produces —
// passing a typed Go struct straight to goja would expose Go field names instead.
func facadeDispatch(ctx context.Context, org, route string, params map[string]string, body any) (*gojabase.Response, error) {
func facadeDispatch(ctx context.Context, org, route string, params map[string]string, body any) (*goja.Response, error) {
if mounted == nil || mounted.State.host == nil {
return nil, ErrNotMounted
}
@@ -85,7 +85,7 @@ func facadeDispatch(ctx context.Context, org, route string, params map[string]st
if err != nil {
return nil, err
}
return mounted.State.host.Dispatch(ctx, org, gojabase.Request{Route: route, Params: params, Body: wire})
return mounted.State.host.Dispatch(ctx, org, goja.BaseRequest{Route: route, Params: params, Body: wire})
}
// toWire normalizes a typed value to a generic JSON value (map[string]any /
@@ -107,7 +107,7 @@ func toWire(body any) (any, error) {
// okBody checks the response is 2xx and returns the body bytes, else a descriptive
// error carrying the bundle's own message.
func okBody(resp *gojabase.Response, route string) ([]byte, error) {
func okBody(resp *goja.Response, route string) ([]byte, error) {
if resp.Status/100 != 2 {
return nil, fmt.Errorf("captable %s: status %d: %s", route, resp.Status, string(resp.Body))
}
+1 -1
View File
@@ -19,7 +19,7 @@ import (
// mountApp builds a bare zip.App (no SanitizeIdentity middleware, so X-Org-Id +
// X-User-Id are trusted verbatim — the standard cloud leaf test harness) and
// mounts the captable leaf on it. This exercises the REAL HTTP path: routing →
// body decode → principal gate → gojabase dispatch → per-tenant Base → response,
// body decode → principal gate → NewBase dispatch → per-tenant Base → response,
// the same path the live binary serves under CLOUD_ENABLE=captable.
func mountApp(t *testing.T) *zip.App {
t.Helper()
+2 -2
View File
@@ -9,7 +9,7 @@ import (
// schema is the per-tenant SQLite DDL — the Go host owns migrations; the goja
// bundle only issues SQL against these tables. Column names MUST match the SQL in
// the captable bundle (github.com/hanzoai/captable goja/src/routes/*). Idempotent
// (IF NOT EXISTS), so it runs on every tenant DB open via gojabase.
// (IF NOT EXISTS), so it runs on every tenant DB open via NewBase.
//
// This is the Prisma data model (prisma/schema.prisma) translated to SQLite:
// DateTime → TEXT (ISO strings stored verbatim; the bundle never parses them),
@@ -217,7 +217,7 @@ CREATE INDEX IF NOT EXISTS ix_investment_company ON investment(company_id);
CREATE INDEX IF NOT EXISTS ix_investment_round ON investment(round_id);
`
// seedCompany is the gojabase OnOpen hook: it ensures the tenant's cap-table
// seedCompany is the NewBase OnOpen hook: it ensures the tenant's cap-table
// company row exists (id == the validated tenant), so the bundle's companyId
// always resolves. The name defaults to the tenant and is renamed via
// PUT /v1/captable/company. INSERT OR IGNORE makes it idempotent across reopens.
+72
View File
@@ -0,0 +1,72 @@
// Package channels is the /v1/channels transport plane: the portable chat
// envelope, per-org access policy (pairing / allowlist / open), a durable
// inbox, and outbound send across the connected chat transports (Discord,
// Slack, Teams, Telegram). Identity and token custody stay in
// clients/integrations — channels consumes its ingress seam
// (integrations.RegisterIngress) and its send doors, so the dependency points
// one way: channels → integrations, never back.
package channels
import (
"context"
"fmt"
"os"
"path/filepath"
"sync/atomic"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/integrations"
"github.com/zap-proto/zip"
)
// state is the subsystem's mounted state: the ONE channels store.
type state struct {
store *store
}
// mounted is the active service, read by ingest on emit goroutines and written
// once at Mount/Shutdown — an atomic.Pointer (clients/sync pattern) so a
// detached event reads it race-free. nil ⇒ unmounted; ingest drops.
var mounted atomic.Pointer[cloud.Service[state]]
// Mount wires /v1/channels/* onto app and registers the ingress consumer.
func Mount(app *zip.App, deps cloud.Deps) error {
if app == nil {
return fmt.Errorf("channels.Mount: nil zip.App")
}
if deps.Logger == nil {
return fmt.Errorf("channels.Mount: nil deps.Logger")
}
if deps.DataDir == "" {
return fmt.Errorf("channels.Mount: empty DataDir")
}
if err := os.MkdirAll(deps.DataDir, 0o755); err != nil {
return fmt.Errorf("channels.Mount: data dir: %w", err)
}
st, err := openStore(filepath.Join(deps.DataDir, "channels.db"))
if err != nil {
return fmt.Errorf("channels.Mount: open store: %w", err)
}
b := cloud.NewBase(deps, "channels")
s := &cloud.Service[state]{Base: b, State: state{store: st}}
// Publish state BEFORE registering the ingress consumer so the first
// emitted event finds a mounted service.
mounted.Store(s)
routes(app, s)
integrations.RegisterIngress(ingest)
b.Log.Info("channels mounted", "transports", len(transports))
return nil
}
// Shutdown unpublishes the service, then closes the store. Idempotent. The
// context is unused; the signature matches integrations.Shutdown so apps.go
// wires it directly. Unpublish-first stops new ingest events from adopting a
// store that is about to close.
func Shutdown(_ context.Context) error {
s := mounted.Load()
if s == nil {
return nil
}
mounted.Store(nil)
return s.State.store.Close()
}
+65
View File
@@ -0,0 +1,65 @@
package channels
import (
"context"
"errors"
"strings"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/integrations"
)
// discord.go is the Discord transport: envelope normalization from the
// ingress seam and egress through integrations.SendDiscord (token custody
// stays in integrations).
// errNoRoute rejects egress to a room the org has no inbound-learned route
// for. routes.go maps it to 409 — the send needs a prior allowed inbound
// message from that room.
var errNoRoute = errors.New("channels: no reply route for this room")
// discordDoor is the send door; tests spy it, prod never repoints.
var discordDoor = integrations.SendDiscord
// DM:false is honest: the interactions ingress is guild-scoped only.
var discordTransport = transport{
id: "discord",
caps: capabilities{Group: true},
normalize: discordNormalize,
send: discordEgress,
}
// discordNormalize maps a Discord Inbound (ExternalID = guild id, DedupeKey =
// interaction id) into the envelope. The ingress is guild slash commands
// only, so every room is a group.
func discordNormalize(ev integrations.IngressEvent) (Message, bool) {
in := ev.In
return Message{
Channel: "discord",
Account: strings.ToLower(in.ExternalID),
Sender: Sender{ExternalID: in.User, Org: ev.Org},
Room: Room{ID: in.Channel, Kind: RoomGroup},
Text: in.Text,
Idempotency: in.DedupeKey,
}, true
}
// discordEgress sends via the shared bot after the tenancy gate: a
// channel_route row exists only after an ALLOWED inbound interaction in that
// channel, so route presence IS the org's verified send capability
// (reply_root is "" for discord; presence is the datum).
func discordEgress(ctx context.Context, s *cloud.Service[state], org string, m Message) (Delivery, error) {
_, ok, err := s.State.store.routeFor(ctx, org, "discord", m.Room.ID)
if err != nil {
return Delivery{}, err
}
if !ok {
return Delivery{}, errNoRoute
}
id, err := discordDoor(ctx, m.Room.ID, m.ReplyTo, renderText(m))
if err != nil {
return Delivery{}, err
}
return Delivery{MessageID: id, Timestamp: time.Now().Unix()}, nil
}
+268
View File
@@ -0,0 +1,268 @@
package channels
import (
"fmt"
"strings"
)
// envelope.go is the portable chat envelope — the ONE message shape every
// transport normalizes into and renders out of (the OpenClaw contract port).
// Every union is closed and kind-tagged; nothing here infers meaning from
// string shape.
// RoomKind classifies where a message lives.
type RoomKind string
const (
RoomDM RoomKind = "dm"
RoomGroup RoomKind = "group"
RoomThread RoomKind = "thread"
)
func (k RoomKind) valid() bool {
switch k {
case RoomDM, RoomGroup, RoomThread:
return true
}
return false
}
// ActionKind tags the closed Action union.
type ActionKind string
const (
ActionCommand ActionKind = "command"
ActionURL ActionKind = "url"
ActionSelect ActionKind = "select"
ActionApproval ActionKind = "approval"
)
// AttachmentKind is the closed attachment media class.
type AttachmentKind string
const (
AttachmentImage AttachmentKind = "image"
AttachmentAudio AttachmentKind = "audio"
AttachmentVideo AttachmentKind = "video"
AttachmentFile AttachmentKind = "file"
)
// Sender identifies who sent an inbound message. UserID is the bound Hanzo
// subject (integrations.LinkedSubject) and may be empty when the platform user
// has not linked. Org is filled on ingress and ignored on egress.
type Sender struct {
ExternalID string `json:"externalId"`
Display string `json:"display,omitempty"`
UserID string `json:"userId,omitempty"`
Org string `json:"org,omitempty"`
}
// Room is the conversation a message lives in.
type Room struct {
ID string `json:"id"`
Kind RoomKind `json:"kind,omitempty"`
}
// Attachment is a URL-addressed media item.
type Attachment struct {
Kind AttachmentKind `json:"kind"`
URL string `json:"url"`
MIME string `json:"mime,omitempty"`
}
func (a Attachment) validate() error {
switch a.Kind {
case AttachmentImage, AttachmentAudio, AttachmentVideo, AttachmentFile:
default:
return fmt.Errorf("attachment: unknown kind %q", a.Kind)
}
if a.URL == "" {
return fmt.Errorf("attachment %s: url required", a.Kind)
}
return nil
}
// SelectOption is one choice of a select action.
type SelectOption struct {
Label string `json:"label"`
Value string `json:"value"`
}
// Approval names the approval request an approval action refers to.
type Approval struct {
ID string `json:"id"`
}
// Action is the kind-tagged closed union (command | url | select | approval);
// exactly the fields of its Kind are set — validate enforces per-kind
// exclusivity so a channel never has to guess from string shape.
type Action struct {
Kind ActionKind `json:"kind"`
Label string `json:"label,omitempty"`
Command string `json:"command,omitempty"`
URL string `json:"url,omitempty"`
Options []SelectOption `json:"options,omitempty"`
Approval *Approval `json:"approval,omitempty"`
}
func (a Action) validate() error {
switch a.Kind {
case ActionCommand:
if a.Command == "" {
return fmt.Errorf("action command: command required")
}
if a.URL != "" || len(a.Options) > 0 || a.Approval != nil {
return fmt.Errorf("action command: only command may be set")
}
case ActionURL:
if a.URL == "" {
return fmt.Errorf("action url: url required")
}
if a.Command != "" || len(a.Options) > 0 || a.Approval != nil {
return fmt.Errorf("action url: only url may be set")
}
case ActionSelect:
if len(a.Options) == 0 {
return fmt.Errorf("action select: at least one option required")
}
for _, o := range a.Options {
if o.Label == "" || o.Value == "" {
return fmt.Errorf("action select: option label and value required")
}
}
if a.Command != "" || a.URL != "" || a.Approval != nil {
return fmt.Errorf("action select: only options may be set")
}
case ActionApproval:
if a.Approval == nil || a.Approval.ID == "" {
return fmt.Errorf("action approval: approval id required")
}
if a.Command != "" || a.URL != "" || len(a.Options) > 0 {
return fmt.Errorf("action approval: only approval may be set")
}
default:
return fmt.Errorf("action: unknown kind %q", a.Kind)
}
return nil
}
// Message is the normalized envelope. Account is informational — the lowercased
// external id of the org's connected platform account; the policy key is
// (org, channel) only.
type Message struct {
Channel string `json:"channel"`
Account string `json:"account,omitempty"`
Sender Sender `json:"sender"`
Room Room `json:"room"`
Text string `json:"text,omitempty"`
Attachments []Attachment `json:"attachments,omitempty"`
Actions []Action `json:"actions,omitempty"`
ReplyTo string `json:"replyTo,omitempty"`
Idempotency string `json:"idempotency,omitempty"`
}
func (m *Message) validate() error {
if m.Channel == "" {
return fmt.Errorf("message: channel required")
}
if m.Room.ID == "" {
return fmt.Errorf("message: room id required")
}
if !m.Room.Kind.valid() {
return fmt.Errorf("message: unknown room kind %q", m.Room.Kind)
}
if err := validateContent(m.Text, m.Attachments, m.Actions); err != nil {
return err
}
m.Account = strings.ToLower(m.Account)
return nil
}
// SendRequest is the narrow body of POST /v1/channels/:channel/send — the
// envelope's outbound projection. Identity fields (Sender, Account, Channel)
// are not decodable here: the route path names the channel and the caller's
// authenticated org supplies the tenant.
type SendRequest struct {
Room Room `json:"room"`
Text string `json:"text,omitempty"`
Attachments []Attachment `json:"attachments,omitempty"`
Actions []Action `json:"actions,omitempty"`
ReplyTo string `json:"replyTo,omitempty"`
Idempotency string `json:"idempotency,omitempty"`
}
func (r SendRequest) validate() error {
// Room.Kind may be empty on egress: the transport doors address a room by
// id alone; kind is an ingress classification.
if r.Room.ID == "" {
return fmt.Errorf("send: room id required")
}
if r.Room.Kind != "" && !r.Room.Kind.valid() {
return fmt.Errorf("send: unknown room kind %q", r.Room.Kind)
}
return validateContent(r.Text, r.Attachments, r.Actions)
}
// validateContent is the shared content rule: something to say, and every
// attachment/action well-formed.
func validateContent(text string, attachments []Attachment, actions []Action) error {
if text == "" && len(attachments) == 0 {
return fmt.Errorf("text or attachments required")
}
for _, a := range attachments {
if err := a.validate(); err != nil {
return err
}
}
for _, a := range actions {
if err := a.validate(); err != nil {
return err
}
}
return nil
}
// Delivery is a transport's send receipt. Timestamp is Unix seconds.
type Delivery struct {
MessageID string `json:"messageId"`
Timestamp int64 `json:"timestamp"`
}
// renderText is the ONE deterministic downgrade renderer: all four transports
// advertise media:false / actions:false this pass, so attachments and actions
// flatten to one line each after the text. Native rendering is a named
// follow-up. Called only on validated messages (an approval action carries a
// non-nil Approval).
func renderText(m Message) string {
var b strings.Builder
b.WriteString(m.Text)
for _, a := range m.Attachments {
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString(string(a.Kind) + ": " + a.URL)
if a.MIME != "" {
b.WriteString(" (" + a.MIME + ")")
}
}
for _, a := range m.Actions {
if b.Len() > 0 {
b.WriteString("\n")
}
switch a.Kind {
case ActionCommand:
b.WriteString("[" + a.Label + "] " + a.Command)
case ActionURL:
b.WriteString("[" + a.Label + "] " + a.URL)
case ActionSelect:
labels := make([]string, 0, len(a.Options))
for _, o := range a.Options {
labels = append(labels, o.Label)
}
b.WriteString("[" + a.Label + "] " + strings.Join(labels, " | "))
case ActionApproval:
b.WriteString("[" + a.Label + "] approval requested: " + a.Approval.ID)
}
}
return b.String()
}
+283
View File
@@ -0,0 +1,283 @@
package channels
import (
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/hanzoai/cloud/clients/integrations"
)
// envelope_test.go proves the portable envelope's closure: per-transport
// normalization into ONE shape, kind-tagged unions with no string sniffing,
// the narrow egress projection (C2-6), and the deterministic downgrade
// renderer. Pure — no store, no HTTP.
func TestNormalize(t *testing.T) {
ev := func(provider, externalID, user, channel, thread, text, key string) integrations.IngressEvent {
return integrations.IngressEvent{Org: "acme", In: integrations.Inbound{
Provider: provider, ExternalID: externalID, User: user,
Channel: channel, ThreadID: thread, Text: text, DedupeKey: key,
}}
}
cases := []struct {
name string
norm func(integrations.IngressEvent) (Message, bool)
ev integrations.IngressEvent
ok bool
want Message
}{
{
name: "telegram positive chat id is a DM; ThreadID is the reply target",
norm: telegramNormalize,
ev: ev("telegram", "HanzoBot", "42", "777", "55", "hi", "u-1"),
ok: true,
want: Message{Channel: "telegram", Account: "hanzobot", Sender: Sender{ExternalID: "42", Org: "acme"},
Room: Room{ID: "777", Kind: RoomDM}, Text: "hi", ReplyTo: "55", Idempotency: "u-1"},
},
{
name: "telegram negative chat id is a group",
norm: telegramNormalize,
ev: ev("telegram", "HanzoBot", "42", "-1001234", "", "hi", "u-2"),
ok: true,
want: Message{Channel: "telegram", Account: "hanzobot", Sender: Sender{ExternalID: "42", Org: "acme"},
Room: Room{ID: "-1001234", Kind: RoomGroup}, Text: "hi", Idempotency: "u-2"},
},
{
name: "telegram unparseable chat id drops",
norm: telegramNormalize,
ev: ev("telegram", "HanzoBot", "42", "abc", "", "hi", "u-3"),
ok: false,
},
{
name: "telegram zero chat id drops",
norm: telegramNormalize,
ev: ev("telegram", "HanzoBot", "42", "0", "", "hi", "u-4"),
ok: false,
},
{
name: "slack D-conversation is a DM",
norm: slackNormalize,
ev: ev("slack", "T024ABC", "u1", "D024BE91L", "", "hello", "e-1"),
ok: true,
want: Message{Channel: "slack", Account: "t024abc", Sender: Sender{ExternalID: "u1", Org: "acme"},
Room: Room{ID: "D024BE91L", Kind: RoomDM}, Text: "hello", Idempotency: "e-1"},
},
{
name: "slack threaded channel event is a thread replying under thread_ts",
norm: slackNormalize,
ev: ev("slack", "T024ABC", "u1", "C024BE91L", "1712.0001", "hello", "e-2"),
ok: true,
want: Message{Channel: "slack", Account: "t024abc", Sender: Sender{ExternalID: "u1", Org: "acme"},
Room: Room{ID: "C024BE91L", Kind: RoomThread}, Text: "hello", ReplyTo: "1712.0001", Idempotency: "e-2"},
},
{
name: "slack bare channel event is a group",
norm: slackNormalize,
ev: ev("slack", "T024ABC", "u1", "C024BE91L", "", "hello", "e-3"),
ok: true,
want: Message{Channel: "slack", Account: "t024abc", Sender: Sender{ExternalID: "u1", Org: "acme"},
Room: Room{ID: "C024BE91L", Kind: RoomGroup}, Text: "hello", Idempotency: "e-3"},
},
{
name: "teams 19: conversation is a group (Bot Framework thread id contract)",
norm: teamsNormalize,
ev: ev("teams", "Tenant-1", "u7", "19:abc@thread.tacv2", "", "hey", "a-1"),
ok: true,
want: Message{Channel: "teams", Account: "tenant-1", Sender: Sender{ExternalID: "u7", Org: "acme"},
Room: Room{ID: "19:abc@thread.tacv2", Kind: RoomGroup}, Text: "hey", Idempotency: "a-1"},
},
{
name: "teams a: conversation is personal",
norm: teamsNormalize,
ev: ev("teams", "Tenant-1", "u7", "a:1a2b3c", "", "hey", "a-2"),
ok: true,
want: Message{Channel: "teams", Account: "tenant-1", Sender: Sender{ExternalID: "u7", Org: "acme"},
Room: Room{ID: "a:1a2b3c", Kind: RoomDM}, Text: "hey", Idempotency: "a-2"},
},
{
// C1-F6 fail-safe: an unknown conversation shape classifies DM — the
// strictest direction, since dmPolicy defaults to pairing.
name: "teams unknown conversation shape falls back to DM",
norm: teamsNormalize,
ev: ev("teams", "Tenant-1", "u7", "48:whatever", "", "hey", "a-3"),
ok: true,
want: Message{Channel: "teams", Account: "tenant-1", Sender: Sender{ExternalID: "u7", Org: "acme"},
Room: Room{ID: "48:whatever", Kind: RoomDM}, Text: "hey", Idempotency: "a-3"},
},
{
name: "discord guild interaction is always a group",
norm: discordNormalize,
ev: ev("discord", "GUILD9", "u5", "555", "", "ping", "i-1"),
ok: true,
want: Message{Channel: "discord", Account: "guild9", Sender: Sender{ExternalID: "u5", Org: "acme"},
Room: Room{ID: "555", Kind: RoomGroup}, Text: "ping", Idempotency: "i-1"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, ok := tc.norm(tc.ev)
if ok != tc.ok {
t.Fatalf("ok = %v, want %v", ok, tc.ok)
}
if !ok {
return
}
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("message = %+v,\nwant %+v", got, tc.want)
}
})
}
}
func TestActionValidateClosedSet(t *testing.T) {
opts := []SelectOption{{Label: "One", Value: "a"}}
cases := []struct {
name string
a Action
ok bool
}{
{"command", Action{Kind: ActionCommand, Label: "Deploy", Command: "/deploy"}, true},
{"url", Action{Kind: ActionURL, Label: "Docs", URL: "https://docs.example"}, true},
{"select", Action{Kind: ActionSelect, Label: "Pick", Options: opts}, true},
{"approval", Action{Kind: ActionApproval, Label: "Approve", Approval: &Approval{ID: "ap-1"}}, true},
// No sniffing: a /command-looking value inside a url action stays a url
// action — kinds are declared, never inferred from string shape.
{"url that looks like a command", Action{Kind: ActionURL, URL: "/deploy"}, true},
{"unknown kind", Action{Kind: "menu"}, false},
{"command empty", Action{Kind: ActionCommand}, false},
{"command with url set", Action{Kind: ActionCommand, Command: "/x", URL: "https://x"}, false},
{"url empty", Action{Kind: ActionURL}, false},
{"select empty options", Action{Kind: ActionSelect}, false},
{"select blank option", Action{Kind: ActionSelect, Options: []SelectOption{{Label: "", Value: "a"}}}, false},
{"select with command set", Action{Kind: ActionSelect, Options: opts, Command: "/x"}, false},
{"approval nil", Action{Kind: ActionApproval}, false},
{"approval empty id", Action{Kind: ActionApproval, Approval: &Approval{}}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.a.validate()
if (err == nil) != tc.ok {
t.Fatalf("validate = %v, want ok=%v", err, tc.ok)
}
})
}
}
func TestAttachmentValidate(t *testing.T) {
cases := []struct {
name string
a Attachment
ok bool
}{
{"image", Attachment{Kind: AttachmentImage, URL: "https://cdn.example/a.png", MIME: "image/png"}, true},
{"file", Attachment{Kind: AttachmentFile, URL: "https://cdn.example/f.pdf"}, true},
{"empty url", Attachment{Kind: AttachmentImage}, false},
{"unknown kind", Attachment{Kind: "gif", URL: "https://x"}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.a.validate()
if (err == nil) != tc.ok {
t.Fatalf("validate = %v, want ok=%v", err, tc.ok)
}
})
}
}
func TestSendRequestValidate(t *testing.T) {
att := []Attachment{{Kind: AttachmentFile, URL: "https://cdn.example/f.pdf"}}
cases := []struct {
name string
r SendRequest
ok bool
}{
// Room.Kind is optional on egress — doors address rooms by id alone.
{"kind optional on egress", SendRequest{Room: Room{ID: "r"}, Text: "x"}, true},
{"attachments alone suffice", SendRequest{Room: Room{ID: "r"}, Attachments: att}, true},
{"room id required", SendRequest{Text: "x"}, false},
{"bad kind rejected", SendRequest{Room: Room{ID: "r", Kind: "castle"}, Text: "x"}, false},
{"content required", SendRequest{Room: Room{ID: "r"}}, false},
{"invalid action rejected", SendRequest{Room: Room{ID: "r"}, Text: "x", Actions: []Action{{Kind: "menu"}}}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.r.validate()
if (err == nil) != tc.ok {
t.Fatalf("validate = %v, want ok=%v", err, tc.ok)
}
})
}
}
func TestMessageValidate(t *testing.T) {
m := Message{Channel: "slack", Account: "MiXeD", Room: Room{ID: "r", Kind: RoomDM}, Text: "x"}
if err := m.validate(); err != nil {
t.Fatalf("validate: %v", err)
}
if m.Account != "mixed" {
t.Fatalf("account = %q, want lowercased", m.Account)
}
bad := []Message{
{Room: Room{ID: "r", Kind: RoomDM}, Text: "x"}, // channel required
{Channel: "slack", Room: Room{Kind: RoomDM}, Text: "x"}, // room id required
{Channel: "slack", Room: Room{ID: "r", Kind: "weird"}, Text: "x"}, // closed room kinds
{Channel: "slack", Room: Room{ID: "r"}, Text: "x"}, // kind required on the full envelope
{Channel: "slack", Room: Room{ID: "r", Kind: RoomDM}}, // content required
}
for i := range bad {
if err := bad[i].validate(); err == nil {
t.Fatalf("message %d must not validate: %+v", i, bad[i])
}
}
}
func TestRenderTextDeterministic(t *testing.T) {
m := Message{
Channel: "slack",
Room: Room{ID: "C1", Kind: RoomGroup},
Text: "body",
Attachments: []Attachment{
{Kind: AttachmentImage, URL: "https://cdn.example/a.png", MIME: "image/png"},
},
Actions: []Action{
{Kind: ActionCommand, Label: "Deploy", Command: "/deploy"},
{Kind: ActionURL, Label: "Docs", URL: "https://docs.example"},
{Kind: ActionSelect, Label: "Pick", Options: []SelectOption{{Label: "One", Value: "a"}, {Label: "Two", Value: "b"}}},
{Kind: ActionApproval, Label: "Approve", Approval: &Approval{ID: "ap-1"}},
},
}
a, b := renderText(m), renderText(m)
if a != b {
t.Fatalf("renderText not deterministic:\n%q\n%q", a, b)
}
if !strings.HasPrefix(a, "body") {
t.Fatalf("rendered = %q, want the text first", a)
}
for _, once := range []string{
"https://cdn.example/a.png", "(image/png)",
"[Deploy] /deploy", "[Docs] https://docs.example",
"[Pick] One | Two", "[Approve] approval requested: ap-1",
} {
if n := strings.Count(a, once); n != 1 {
t.Fatalf("%q rendered %d times, want exactly once in %q", once, n, a)
}
}
}
// TestSendRequestNarrowDecode proves C2-6: identity fields (sender, account,
// channel) are not decodable on the egress body — the struct simply has no
// such fields, so nothing a caller sends can alias them. The route layer
// additionally rejects unknown keys loudly (send_test.go).
func TestSendRequestNarrowDecode(t *testing.T) {
raw := []byte(`{"room":{"id":"r1"},"text":"hi","sender":{"externalId":"evil"},"account":"spoof","channel":"slack"}`)
var r SendRequest
if err := json.Unmarshal(raw, &r); err != nil {
t.Fatalf("unmarshal: %v", err)
}
want := SendRequest{Room: Room{ID: "r1"}, Text: "hi"}
if !reflect.DeepEqual(r, want) {
t.Fatalf("decoded = %+v, want only the outbound projection %+v", r, want)
}
}
+127
View File
@@ -0,0 +1,127 @@
package channels
import (
"context"
"sync/atomic"
"time"
"github.com/hanzoai/cloud/clients/integrations"
)
// ingest is the registered integrations ingress consumer (channels.Mount):
// normalize -> identity -> gate -> route -> inbox | pairing. It runs on a
// detached per-event goroutine with a bounded context
// (integrations.emitIngress), so nothing here can delay a webhook.
// gcEverySec bounds opportunistic retention GC to once per 10 min across all
// ingest goroutines.
const gcEverySec = 600
var lastGC atomic.Int64
func ingest(ctx context.Context, ev integrations.IngressEvent) {
s := mounted.Load()
if s == nil {
return
}
st := s.State.store
tr, ok := transportFor(ev.In.Provider)
if !ok {
return
}
m, ok := tr.normalize(ev)
if !ok {
return
}
// Identity is best-effort: an unlinked user or KMS-down leaves UserID empty
// and never blocks ingest.
if subj, found, err := integrations.LinkedSubject(ev.Org, ev.In.Provider, ev.In.User); err == nil && found {
m.Sender.UserID = subj
}
now := time.Now().Unix()
// Gate BEFORE any write (C1-F4): a channel_route row is a send capability,
// so a blocked sender must not mint one.
var v verdict
var err error
if m.Room.Kind == RoomDM {
v, err = dmGate(ctx, st, ev.Org, m.Channel, m.Sender.ExternalID, true)
} else {
// A thread is a group surface: RoomThread deliberately gates under the
// group policy.
v, err = groupGate(ctx, st, ev.Org, m.Channel, m.Sender.ExternalID)
}
if err != nil {
// Fail closed: an unreadable policy drops the event. No sender ids in
// logs — reason codes only.
s.Log.Warn("channels: gate error, inbound dropped", "channel", m.Channel, "err", err)
return
}
if v.Allow || v.Pair {
// Route capture on allow AND pair — the pairing reply below must be able
// to ride the teams door. Upserted for all four transports; only discord
// (row presence = egress capability, ReplyRoot "") and teams (the
// JWT-verified serviceURL) read it — slack/telegram bind egress via
// per-org token / OrgForExternalID instead.
if rerr := st.upsertRoute(ctx, ev.Org, m.Channel, m.Room.ID, ev.ReplyRoot, now); rerr != nil {
s.Log.Warn("channels: route upsert", "channel", m.Channel, "err", rerr)
}
}
switch {
case v.Allow:
// ACCEPTED TRADEOFF (C1-F3): under groupPolicy=open any group member
// inserts inbox rows; event-key dedupe, 8 KiB truncation, 30-day GC, and
// single-conn SQLite serialization bound the damage. A per-org ingest
// limiter is the named follow-up alongside agent delivery.
// Agent delivery is NOT built this pass: this insert is the seam a
// future channels.RegisterDelivery consumer will observe.
if ierr := st.insertInbox(ctx, inboxRow{
Org: ev.Org,
Channel: m.Channel,
Account: m.Account,
RoomID: m.Room.ID,
RoomKind: m.Room.Kind,
Sender: m.Sender.ExternalID,
SenderUser: m.Sender.UserID,
Text: m.Text,
ReplyTo: m.ReplyTo,
EventKey: m.Idempotency,
CreatedAt: now,
}); ierr != nil {
s.Log.Warn("channels: inbox insert", "channel", m.Channel, "err", ierr)
}
case v.Pair:
code, created, perr := upsertPairing(ctx, st, ev.Org, m.Channel, m.Sender.ExternalID, now)
if perr != nil {
s.Log.Warn("channels: pairing", "channel", m.Channel, "err", perr)
} else if created {
// Reply only when a request was minted (at most one per TTL per
// sender; a full pending cap mints nothing). Ordering invariant: the
// route upserted above is what lets this send pass the discord/teams
// binding checks, and a slack/telegram chat is org-bound by the very
// event that arrived — no binding special case needed. The pairing
// message is never stored in the inbox and the code is never logged.
if _, serr := tr.send(ctx, s, ev.Org, Message{
Channel: m.Channel,
Account: m.Account,
Room: m.Room,
ReplyTo: m.ReplyTo,
Text: pairingText(code),
}); serr != nil {
s.Log.Warn("channels: pairing reply", "channel", m.Channel, "err", serr)
}
}
default:
// Blocked: closed reason code only — never sender ids.
s.Log.Debug("channels: inbound blocked", "channel", m.Channel, "reason", string(v.Reason))
}
// Opportunistic retention GC, at most once per gcEverySec across goroutines.
if last := lastGC.Load(); now-last > gcEverySec && lastGC.CompareAndSwap(last, now) {
if gerr := st.gc(ctx, now); gerr != nil {
s.Log.Warn("channels: gc", "err", gerr)
}
}
}
func pairingText(code string) string {
return "Pairing code: " + code + " — an org admin can approve it in the Hanzo console (expires in 1 hour)."
}

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