Compare commits

...
Author SHA1 Message Date
hanzo-dev 71ec5f3496 cloud(cli): rename the native build endpoint /v1/arcd/enqueue -> /v1/runner
'arcd' is the client-side GitHub-Actions BYO product (github.com/arc-runner);
the platform's own native CI/compute pool is 'runner'. Rename the enqueue path
and de-brand the build command help/comments accordingly. Pairs with the
platform route move pages/api/v1/arcd/enqueue.ts -> pages/api/v1/runner.ts.
2026-07-09 20:19:10 -07:00
hanzo-dev 887341ac8a cloud(ci): BuildKit cache mounts on go build/test/mod — warm Go cache across builds
The 4 go invocations (mod download, sqlite double-register gate, encryption-proof
test, final build) had no cache mount, so every release recompiled the full CGO
graph (k8s + otel-collector + sqlcipher, CGO=1) from scratch on the ephemeral ARC
runner — the Build step was ~1316s/22min, dwarfing every other step. Mount
/go/pkg/mod + /root/.cache/go-build (sharing=locked) so the persistent ARC dind
BuildKit cache keeps the Go build+module cache warm. First build cold; subsequent
builds reuse compiled artifacts — target single-digit-minute rebuilds.
2026-07-09 20:03:43 -07:00
hanzo-devandGitHub 0235278e32 Merge pull request #214 from hanzoai/feat/runner-ship
cloud(runner): embed the arc JIT runner as `hanzo runner`
2026-07-09 19:26:41 -07:00
hanzo-dev cbcc97117b cloud(runner): embed the arc JIT runner as hanzo runner
Migrate arc-runner/arc (arc-archive) cmd/arcd host-role JIT GitHub-Actions
runner into hanzoai/cloud as package runner/, exposed as `hanzo runner` —
third verb of the one-binary trifecta: engine serves models, gpu connect shares
compute, runner claims CI. One org login, outbound-only, GPU/vulkan-aware.

- runner/: host-role JIT daemon (config, GitHub App auth, poll, JIT launcher,
  /v1 control surface, WSL labels). In-cluster controller role deferred.
- cli: wire `hanzo runner`; also register `engine` in controlCommands (was
  constructed but missing from the router — silently undispatchable).
- Security (red+cto reviewed): /v1 defaults 127.0.0.1 + localGuard (Host
  allowlist vs DNS-rebind, Origin vs CSRF); child env stripped of secrets;
  fork repos skipped by default (private/internal-org scoping is the real
  containment; documented honestly).
- deps: +go-github/v52, +ghinstallation/v2; luxfi/keys v1.2.0->v1.2.2 (v1.2.0
  tag mutated at origin — bump past it, verified under -mod=readonly, no bypass).
2026-07-09 19:25:05 -07:00
hanzo-dev f2ddb388b7 o11y: release chtraces exporter on failed Start (no leak on fail-soft path)
CreateTraces opens the ClickHouse conn + spawns the writer's ticker goroutine, so
a failed Start must Shutdown to release them rather than leak on the fail-soft
mount path.
2026-07-09 17:39:47 -07:00
97a619bdd3 test(kms): make dual-mount two-scope assertion load-robust (#212)
The validated-org-principal check asserted ==200, coupling this cross-package
gate test to the orgs/users/me handler's downstream success (IAM/datastore),
which flaked under full-suite parallel load. Assert the gate/shadow decision
only — admitted (not 403) and mounted (not 404) — since tenant-scoped data
correctness is proven in clients/admin/scope_test.go. Anonymous->403 and the
SuperAdmin-only platform routes are unchanged.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-09 17:32:13 -07:00
zeekayandClaude Opus 4.8 4d54be04b0 fix(embed): bust console clone layer with cloud sha (gh/ls-remote-free)
The prior two attempts (c97af12 sha-pin via git ls-remote, 4a7e533 via gh api)
both FAILED at version-compute: the ARC runner has no gh CLI, and git ls-remote
404s because actions/checkout installs a global http.extraheader carrying THIS
repo's GITHUB_TOKEN (scoped to hanzoai/cloud), overriding URL creds on the
cross-repo hanzoai/console lookup.

Bulletproof instead: no console-HEAD resolution at all. release.yml passes the
cloud commit sha as --build-arg CONSOLE_CACHEBUST (unique per push); the
Dockerfile references it in the proven `git clone --depth 1 --branch main`
RUN, so the layer cache key changes every build and re-clones console main HEAD
fresh. No gh, no ls-remote, no extraheader. Correctness over cache reuse — the
console stage rebuilds each release, but the embed is never the frozen snapshot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 17:30:51 -07:00
4c217c7efb fix(base+admin): base serves /v1/base/health (HealthOwner) even embed-off; dual-mount red test tracks the admin two-scope model (#211)
- clients/base: register /v1/base/health in Mount BEFORE the CLOUD_BASE_EMBED
  gate and mark cloud.HealthOwner — same always-on liveness pattern as
  clients/plan + clients/pricing. Fixes cmd/cloud TestMountAllAndServeHealth
  (base was the only listed subsystem not self-serving health).
- clients/kmssvc red_dualmount_test: #192 made the admin cockpit two-scope —
  orgs/users/me are org-scoped (guardScoped: a validated org principal is
  admitted + hard-scoped to its own org; anonymous still 403), while
  audit/roles/finance/flags/revenue stay SuperAdmin-only (guard: 403 for a
  non-admin principal). Assert both, plus kms's public /v1/kms/config never
  shadows either. No production code semantics changed — the stale test tracked
  the pre-two-scope admin-only contract.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-09 17:26:29 -07:00
zeekayandClaude Opus 4.8 4a7e533d5d fix(embed): resolve console HEAD via gh api, not git ls-remote
git ls-remote failed 'Repository not found' on the cross-repo hanzoai/console
lookup: actions/checkout installs a global git http.extraheader carrying THIS
repo's GITHUB_TOKEN (scoped to hanzoai/cloud only), which overrides the URL
creds and 404s. gh api honors GH_PAT (org read) and is unaffected. Unblocks the
console-embed-freshness fix (c97af12) — the release aborted at version-compute
before building.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 17:24:26 -07:00
hanzo-dev f28ff67f93 o11y: dogfood ZAP Router — cloud's own spans -> in-process ClickHouse sink, no socket
cloud already embeds the o11y trace write side (chtraces), so shipping its OWN
spans over the ZAP wire to a collector that then writes the same store is pure
waste. Route them through the ZAP locality-adaptive Router (luxfi/zap v1.2.1):
when sender and sink share this binary, the Cost-0 InProcessInterface wins and
the LIVE proto batch is handed to the sink by value — zero ZAP-wire serialize,
zero socket, no second collector hop.

- clients/o11y/tracesink.go: Router + Cost-0 InProcessInterface on Destination
  "hanzo.o11y.traces"; a chtraces exporter (the REAL o11y_index_v3 writer, reused
  as a consumer.Traces — its pdata->SpanV3 conversion is unexported) fed in
  process. Handler bridges SDK-exporter proto spans -> pdata via one in-memory
  OTLP round-trip. OPT-IN (O11Y_TRACES_ZAP_INPROCESS) + fail-soft: any error
  leaves cloud's spans on the wire; can never take cloud down. NewTraceExporter +
  routerTraceClient own the transport; cmd/cloud stays the composition root.
- cmd/cloud/telemetry.go: install the ONE tracer provider over the Router
  (in-process primary, ZAP wire fallback when the sink isn't registered). Enable
  when the in-process sink is on OR a wire endpoint is set. Composition-root
  single-provider invariant (ai's GenAI tracer inherits it) preserved.
- go.mod: github.com/luxfi/zap v1.2.0 -> v1.2.1 (adds Router/InProcessInterface).

TDD: span from cloud's provider reaches the in-process handler with no wire
client and no socket; proto->pdata round-trip preserves the span; router prefers
in-process, falls back to wire on ErrNoRoute, surfaces ErrNoRoute when neither.
2026-07-09 17:21:29 -07:00
6f12cfb617 refactor(kms): merge clients/kmssvc into clients/kms — one KMS package (#210)
The kmssvc dir was an artificial split: clients/kms is the KMS library
(embeds luxfi/kms + SecretStore + in-process client), clients/kmssvc was
the Fiber subsystem mounting /v1/kms/* — and it was ALSO package kms, dir-
named kmssvc only to dodge a dir-name collision. That svc suffix is a
workaround, not a concept.

Move every kmssvc file into clients/kms (kmssvc.go → mount.go; login.go,
env_required/kms/login_ratelimit/paas_sync/red_*/v6 tests). subsystems.go
imports clients/kms (order 10, /v1/kms/*); exactly one cloud.Register("kms").
Zero kmssvc refs remain. Full cloud build + kms package tests (incl red_*
adversarial) green. (Pre-existing clients/kms/replication test build failure
is unrelated — broken on main before this change.)

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-09 17:13:54 -07:00
hanzo-devandGitHub 2e68c5cb55 org+idem: fenced single-writer + exactly-once request layer (lift replicas:1 -> N safe) (#201)
Composes the ha lease-round (v0.1.1) and vfs/replica.FencedStore (v0.6.3) into
the cloud per-org substrate, and adds the request-layer exactly-once dedup, so
per-org SQLite is safe under a multi-replica Deployment (not just replicas:1).
Four orthogonal concerns, one home each:

  internal/org/fence.go   CASFencer: the INTERIM monotone round source. A per-org
                          writer lease {round,owner} over the object store's CAS
                          (a single linearizable register); takeover strictly
                          bumps the round, renewal keeps it. Implements ha.Fencer,
                          so the Lux BFT round drops in behind the same seam later.
                          HRW is only an optimization (cuts contention); safety
                          does not depend on a fresh/agreed membership view — a
                          split view costs liveness, never safety, because the
                          fence backstops it.
  internal/org/condstore.go  MinioConditionalStore: the concrete atomic-CAS store
                          (minio If-Match against the SeaweedFS gateway), promoted
                          from the orphaned internal/writefence. satisfies
                          replica.ConditionalStore.
  internal/idem/          exactly-once request execution: request-id PK written in
                          the SAME txn as the effect (atomic dedup+effect), shipped
                          in the per-org snapshot so a retry re-routed after a
                          rolling upgrade is deduped on the successor. 'fail if
                          already done' via ErrAlreadyApplied.
  internal/org/shared.go  re-export FencedStore/ConditionalStore/Lease/Fencer/
                          Round/ErrStaleRound so the org API stays one surface.

Deletes internal/writefence (was orphaned, zero importers): its fence primitive
is promoted to vfs/replica.FencedStore (the storage substrate's rightful home),
its minio store to condstore.go — one and one way, forward-only.

Safety (no data loss + no double-exec) rests on the composition, proven by
handoff_test.go against the four hazards: (a) partition minority cannot advance
the round -> cannot write; (b) rolling-upgrade handoff -> successor CarryForwards
the predecessor's last landed write + dedups; (c) duplicate request -> idem runs
once; (d) deposed writer -> refused by election, and if it still ships, fenced by
FencedStore. Ship-before-ack: a request is 'done' only once its fenced ship lands.

Interim round source = single linearizable register (object CAS) = crash-fault
tolerant. Roadmap: replace readLease/claim with the quasar PQ-BFT agreed round
(Byzantine-tolerant, deterministic finality, 3/5 quorum) — same ha.Fencer seam,
same FencedStore admission.

Cross-repo: pins ha@fence-lease + vfs@fenced-store (pseudo-versions); retag to
ha v0.1.1 + vfs v0.6.3 once those merge. go.sum touches only ha+vfs; the
pre-existing luxfi/keys@v1.2.0 re-tag mismatch (blocks `go mod tidy` on main
today) is unrelated and untouched.
2026-07-09 16:47:19 -07:00
zeekayandClaude Opus 4.8 c97af12f4f fix(embed): re-clone console per build — stop shipping a frozen embed
The console-clone+build layer was keyed only on static text ('git clone
--branch main'), so on the persistent ARC dind BuildKit cache EVERY cloud
build re-embedded the SAME stale console snapshot. New console work — the
native Tracker module, and everything since the cache was first warmed —
silently never shipped: console.hanzo.ai/tracker rendered an old surface
with zero /v1/tracker calls even on a freshly-deployed image.

Fix (values, not places): release.yml resolves hanzoai/console main HEAD
(git ls-remote) at build time and threads it through --build-arg CONSOLE_REF;
the Dockerfile fetches that exact ref (init+fetch+checkout, sha- or branch-
capable). A changed sha moves the layer cache key, so each build embeds the
live console commit — deterministically pinned, never frozen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 16:45:56 -07:00
a1551bf593 feat(admin): operator cockpit — two-scope model, flags/launch, waitlist/access, bases (#192)
admin.hanzo.ai as ONE cockpit for BOTH tiers off ONE identity predicate. scope.go decomplects the rule into a single place (resolveScope/scopedOrgs/descendants): owner==admin (c.IsAdmin, SuperAdmin) => cross-tenant, all orgs; any other validated admin => their OWN org, hard-scoped server-side. guardScoped admits a SuperAdmin OR a validated org-pinned caller and the handler scopes the data; the platform control plane (roles/audit/finance/revenue + launch/release/flags/access) stays s.guard (SuperAdmin only). me/overview/orgs/users/usage/analytics/bases are org-scoped; a non-super caller can never read another tenant for any input (their org is the sanitized, un-forgeable c.Org()).

Feature flags / launch / access via Hanzo Insights (one engine, not two): clients/featureflags is a hot-apply evaluation seam over Insights /flags (env = fallback default, 15s TTL, fail-safe degrade); /v1/admin/flags surfaces the launch switches (public_signup, waitlist_open, waitlist_access_capacity, ...) with deep-links to the Insights flag manager + activity log. /v1/admin/waitlist + /boost proxy the Base waitlist engine (server-authed, KMS secret, audited grant). /v1/admin/bases is the scoped tenant-Base panel seam (honest-empty until the Base engine is embedded).

Fix: waitlist.go shadowed the ok() envelope writer with a local bool (compile error) — renamed to configured. Tests: scope_test.go proves the two-scope invariant (super sees all; org-admin hard-pinned to own org; platform routes 403 an org-admin; users read pinned to own org); featureflags_test.go proves hot-apply + env fallback. go build ./... green; go test ./clients/admin/ + ./clients/featureflags/ green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-09 16:31:39 -07:00
hanzo-devandGitHub 952cb921ab feat(edge): embed the gateway CORS + per-IP rate-limit role in cloud (#181)
* feat(edge): embed the gateway CORS + per-IP rate-limit role in cloud

Fold the hanzoai/gateway edge role into cloud so it can serve api.hanzo.ai
directly, dropping the redundant KrakenD hop. cloud already validates the IAM
JWT + strips/re-mints identity headers (SanitizeIdentity), runs the per-tenant
ScopeRateLimit, and owns balance/spend-cap quota (BillingGate) — the gateway
duplicated the JWT/identity role and added only CORS + a per-IP flood cap.

Adds middleware_edge.go (package cloud), two orthogonal middlewares wired into
the serve.go chain AFTER Logger/sites and BEFORE identity:

- EdgeCORS: credentialed reflect-Origin CORS matching the gateway's policy
  (methods/headers/max-age). DEFAULT OFF (empty CLOUD_CORS_ORIGINS) so the
  shared Traefik ingress `cors-allow-all` stays the sole CORS authority on the
  recommended rollout — enabling both would double the ACAO header. Set
  CLOUD_CORS_ORIGINS only on a direct DO-LB->cloud edge. Handles + short-circuits
  the OPTIONS preflight (204) before any auth work.

- EdgeRateLimit: per-client-IP fixed-window flood cap (default 100/1s, gateway
  service-tier parity, strategy:ip) that runs BEFORE identity — the one gap
  ScopeRateLimit (keyed on the validated tenant) structurally can't see: an
  anonymous flood with no valid JWT. Keyed on the leftmost X-Forwarded-For;
  in-cluster direct callers (no XFF) are exempt, matching the standalone
  gateway's public-only scope. Opportunistic eviction keeps the bucket map
  bounded at edge IP cardinality (default ON, CLOUD_EDGE_RATELIMIT=false to
  disable). This preserves the gateway's protection rather than dropping it.

Config: CORSOrigins, EdgeRateEnabled/PerIP/WindowSec (config.go).
Tests: TestOriginMatcher, TestEdgeCORS_*, TestEdgeRateLimit_* (all green).
CGO_ENABLED=0 go build ./... + go test . green.

* feat(gateway): /v1/gateway runtime-mutable edge-policy plane

Make the embedded gateway role RUNTIME-CONFIGURABLE instead of baked into static
config: an operator/SuperAdmin retunes CORS, the per-IP flood cap, or a tenant's
rate ceiling via PUT /v1/gateway/config with NO redeploy — replacing the gateway's
image-baked KrakenD config.

clients/gatewaypolicy (leaf pkg, stdlib + hanzoai/sqlite only, no cloud import so
both the middleware and the HTTP subsystem share it cycle-free):
- Policy{CORSOrigins, PerIPRPM, WindowSec (platform), OrgRPM (per-org)} — every
  field is enforced by a consumer; no stored-but-ignored knob.
- Store: one encrypted per-tenant SQLite (gateway.db), org-keyed rows. The admin
  org row is the PLATFORM policy, layered over the static env/flag boot defaults.
  Cached resolvers Platform()/OrgRPM()/Effective() (5s TTL, fail-open to static),
  merge(base,over) makes a partial PUT additive. Fail-soft: a store-open error
  degrades to static-only (reads work, writes error) — the edge never goes down.

clients/gatewaysvc: the /v1/gateway subsystem (order 139) — GET/PUT config over
the SAME store, IAM-gated like clients/pricing/enablement.go:
- platform fields (CORS/per-IP) writable ONLY by SuperAdmin (c.IsAdmin()); routed
  to the platform row explicitly (PutPlatform) so an org-switched SuperAdmin still
  lands on it.
- per-org OrgRPM writable by the org admin (own org via principal.Tenant, never a
  raw header) or a SuperAdmin targeting ?org=<slug>.

Wiring: deps.GatewayPolicy (BuildDeps constructs it, layered over staticEdgePolicy;
serve.go closes it at shutdown). EdgeCORS/EdgeRateLimit now read the PLATFORM policy
LIVE (recompiling the CORS matcher only when the allowlist changes; the per-IP
limit/window per request). ScopeRateLimit gains the runtime per-org OrgRPM
override (most-restrictive-wins with the commerce-configured ceiling).

Tests: gatewaypolicy (static-only, platform layering, additive merge, per-org +
platform-default OrgRPM, persist-across-reopen); gatewaysvc (principal required,
org-self OrgRPM, org-admin platform 403, SuperAdmin platform, org-switched-still-
platform, empty-body 400). CGO_ENABLED=0 go build ./... + go test . green.
2026-07-09 16:25:43 -07:00
f842827e0c feat(base): embed base app + waitlist in-process (/v1/waitlist), retire standalone superbase (#193)
Mounts /v1/waitlist/* served in-process off the embedded hanzoai/base app over
the durable cloud PVC — the in-binary replacement for the standalone superbase
pod. STAGED + fail-closed: no-op unless CLOUD_BASE_EMBED=1. Registered as the
"base" subsystem (order 60) in clients/base.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-09 16:21:02 -07:00
40c187a75f fix(sbom): lazily create hanzo.sbom_component (datastore connects after Mount) (#207)
The datastore connects ASYNCHRONOUSLY: ai/object.InitDatastore flips
DatastoreEnabled true only AFTER Mount returns. Mount ran the CREATE TABLE
DDL only when DatastoreEnabled() was already true, so in prod it was skipped
and never retried -> GET /v1/sbom/{ref} 502 'Unknown table expression
identifier hanzo.sbom_component' while /v1/sbom/health reported datastore:true.

Add a lazy, idempotent ensureTable(ctx) guarded by a mutex+bool that latches
ONLY success (a transient failure retries; sync.Once would cache the failure).
It CREATE DATABASE IF NOT EXISTS hanzo then CREATE TABLE IF NOT EXISTS, and is
called from ingest and resolve right after requireDatastore() passes; on error
they return a retryable 503. Mount now routes its best-effort boot DDL through
the same ensureTable and is non-fatal (a Mount-time miss no longer aborts the
subsystem).

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-09 15:47:23 -07:00
f51ebb5550 fix(brand): map <brand>.cloud/.network hosts to the brand (white-label) (#209)
The live cloud console runs on <brand>.cloud hosts that route straight to the
cloud Service (console.lux.cloud, console.zoo.cloud, …). BrandForHostOK only
matched a brand's primary marketing Domain (lux.network, zoo.ngo), so a request
Host on lux.cloud/zoo.cloud fell through to the deployment brand — emitting Hanzo
branding on a Lux/Zoo surface (agent-skills catalogue + any Host-branded reply).
Add AltDomains per brand (lux→lux.cloud; zoo→zoo.network,zoo.cloud;
hanzo→hanzo.cloud,hanzo.app; pars→pars.ai) and match them in BrandForHostOK.
Base-URL/issuer scoping still uses the primary Domain.

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

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-09 15:39:16 -07:00
674c88cb5a feat(agentskills): serve /.well-known/agent-skills discovery, white-labeled by Host (#208)
New subsystem clients/agentskills serves the Agent Skills Discovery surface from
a catalog GENERATED by hanzoai/openapi's skills.py and embedded via go:embed:

  GET /.well-known/agent-skills/index.json        the brand's MASTER catalogue
  GET /.well-known/agent-skills/<skill>/SKILL.md   one skill document

WHITE-LABEL: the brand is decided per request from the Host (new BrandForHostOK,
mirroring platform.ts getWhiteLabelBrand) — api.hanzo.ai serves Hanzo,
api.lux.network serves Lux (lux.id), api.zoo.ngo serves Zoo — never Hanzo
branding on a Lux/Zoo surface. An unmatched Host degrades to the deployment brand
(CLOUD_BRAND), not blindly Hanzo. Order 8 registers these exact routes BEFORE
IAM's /.well-known/* wildcard (50) and the console catch-all, so they win Fiber's
first-match. Public, GET-only, no secrets.

The binary does not re-derive skills — it serves the embedded bytes, so the
sha256 digests in index.json match the served SKILL.md exactly. Only a tiny,
self-consistent `ai` fallback is committed (catalog/.gitignore); `make
agentskills` / the Dockerfile `skills` stage regenerate the FULL catalog (all 68
services × hanzo/lux/zoo) from the openapi SOT before `go build`, mirroring
webui/dist. End-to-end serve test drives the real router (index + SKILL.md +
white-label + digest + 404); cmd/cloud links clean.

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

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-09 15:16:36 -07:00
3652a2929b feat(sbom): global SBOM datastore + /v1/sbom ingest & resolve (#206)
Add clients/sbom: a self-contained subsystem riding the ONE shared
ClickHouse client (ai/object.Datastore*) — no second connection — that
ingests CycloneDX SBOMs from CI and serves them by image digest/ref.

The store (hanzo.sbom_component, ReplacingMergeTree) is GLOBAL/cross-tenant
by design: an SBOM belongs to a content-addressed image digest, not a
tenant, so any tenant deploying that image resolves the same component set.
Ingest is gated to the canonical cloud super-admin check (c.IsAdmin(),
owner==AdminOrg) which the build fleet carries; resolve exposes only an
image's immutable bill-of-materials (no tenant data).

  POST /v1/sbom        ingest (super-admin/CI): flatten document.components[]
  GET  /v1/sbom/{ref}  resolve by digest OR ref (FINAL dedupe, type,name order)
  GET  /v1/sbom/health liveness + datastore bool (not JWT-gated)

Registered id "sbom" order 137 with cloud.HealthOwner (binds before the ai
/v1/* catch-all at 150). Mirrors clients/analytics for structure, coercers,
and the honest-503 datastore gate.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-09 14:28:03 -07:00
36c083b568 chore(deps): pin hanzoai/ai → v1.804.0 (cookie self-heal + one super-admin rule) (#205)
Clean-semver pin bringing three ai releases into the cloud binary:
- v1.803.1 fix(account): cookie-session self-heal (#71) — a signed-in admin
  whose beego session already holds a guest u-<hash> is rebound to the
  canonical identity from hanzo_iam_token, so /v1/admin/* stops 403ing.
- v1.804.0 refactor(authz): ONE super-admin rule — membership in the `admin`
  org (owner == AdminOrg); drops the configurable globalAdminOrgs + built-in.
  Matches cloud's clients/admin (isSuperAdmin canonical, isGlobalAdmin alias)
  and the console isSuperAdminAccount gate.

Supersedes the pseudo-version pin (#197) and the intermediate v1.803.1 pin
(#198, closed). Verified: go build -tags "libsqlite3 sqlite_fts5" ./cmd/cloud.

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

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-09 13:46:20 -07:00
e761b4bb51 feat(platform,sites): pure-Go zip/tar.gz static-site upload + custom-domain serving (#204)
* feat(platform,sites): pure-Go zip/tar.gz static-site upload + custom-domain serving

Adds a self-service static-site deploy to the unified cloud binary's PaaS
surface and lets the site edge serve a customer's own domain from S3.

- projects: walkArtifact accepts a ZIP (archive/zip) as well as tar(.gz),
  sniffed by magic bytes; one deploy contract (index.html at root, same size
  and traversal guards), three container formats. A single wrapping top-level
  directory (a zip made from a project folder) is stripped so index.html lands
  at the root.
- projects: the deploy handler reads the artifact from a multipart file upload
  (a browser <input type=file>) OR the raw request body (a curl one-liner).
- sites: the host edge now serves a bound CUSTOM domain (a customer apex/host
  pointed at this edge) from that project's S3 prefix, resolved by the full
  host. Only external hosts (never one of our self domains) with a LIVE binding
  are served; every other host — our api/console hosts, or an unbound host
  routed here — Continues to the normal pipeline, so the API path pays no
  per-request lookup and a customer binding can never shadow a real Hanzo host.
- projects: POST/GET .../domains binds and lists a site's custom domains
  (admin-gated until DNS-ownership verification is wired here).
- surface: the static engine is exposed under /v1/platform/sites/* (the PaaS
  namespace) in addition to /v1/projects/*, so the one user flow is create a
  site -> upload a zip -> bind a domain -> live.

Pure Go, CGO-off. New unit tests cover the zip walker, format dispatch,
single-root strip, custom-domain routing (served/passthrough/self-host/not
-live), hostname validation, and host binding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(projects): authorize custom-domain binding by the platform-operator org

A custom-domain bind is authorized for a global admin OR the platform-operator
org (the deployment's brand org, env CLOUD_PLATFORM_OPERATOR_ORGS, default the
brand). The operator manages customer DNS until per-tenant DNS-ownership
verification is wired here. Safe because a bound domain is inert until its owner
points DNS at this edge — the real gate is DNS control.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 13:12:56 -07:00
696e858564 debrand: signoz → o11y (branding) + repoint collector module to hanzoai/otel-collector (#202)
* debrand: signoz -> o11y (branding) + repoint collector module

Drop "SigNoz"/"signoz" where it is BRANDING (comments, docs, prose) to o11y,
and repoint cloud's direct collector import to the renamed module.

- go.mod/go.sum: github.com/hanzoai/signoz-otel-collector v0.144.6 (direct)
  -> github.com/hanzoai/otel-collector v0.144.7 (direct). The old module stays
  as an // indirect dep because hanzoai/o11y v1.5.2 (separate repo, out of
  scope) still imports it; the fork also pulls upstream
  github.com/SigNoz/signoz-otel-collector v0.144.5 // indirect.
- clients/o11y/ingest.go: chlogs/chtraces imports -> otel-collector.
- Branding prose swapped to o11y in telemetry.go, subsystems.go, embed.go,
  logs.go, metricsread.go, scope.go, ingest_test.go, agents.go, LLM.md,
  docs/consolidation.md.

KEPT (not branding):
- ClickHouse schema read by cloud (written by the deployed collector):
  signoz_traces / signoz_logs / distributed_signoz_index_v3, severity_text
  columns. Renaming reads without migrating the live schema breaks them; the
  v0.144.6->.7 patch bump does not migrate table names.
- Upstream API in hanzoai/o11y/pkg/signoz: import path, alias, type SigNoz,
  and signoz.New / SigNoz.Start references (that repo is debranded separately).
- Upstream package names: signozclickhousemetrics.
- Honest attribution: "SigNoz's dd-sketch fork of ch-go".

Build: go build ./... = 0, go vet = 0, go test ./clients/o11y ./clients/admin
= ok. go mod tidy is blocked by a pre-existing luxfi/keys@v1.2.0 go.sum
checksum mismatch (identical on origin/main) -> CI-authoritative.

* o11y: read o11y_* ClickHouse tables + bump collector to v0.144.8

Direct ClickHouse table reads renamed signoz_* -> o11y_* to match the
o11y read plane and the data-preserving RENAME migration (hanzoai/o11y#28):

  o11y_traces.distributed_o11y_index_v3  (was signoz_traces.distributed_signoz_index_v3)
  o11y_logs.distributed_logs_v2          (was signoz_logs.distributed_logs_v2)

Files: clients/o11y/logs.go, clients/o11y/metricsread.go,
clients/o11y/ingest.go, clients/admin/o11y.go (+ o11y_test.go).

Bump github.com/hanzoai/otel-collector v0.144.7 -> v0.144.8 (writer side
now CREATEs/WRITEs the same o11y_* physical schema). Collector go.mod is
unchanged between the two tags (identical go.mod hash) — pure source
rename, so the module graph is unchanged; go mod tidy left to CI
(pre-existing luxfi/keys tidy block is CI-authoritative).

go build ./... = 0. clients/admin + clients/o11y tests green (SQL
assertions now match o11y_* target names). Lockstep deploy: collector
v0.144.8 -> o11y#28 RENAME migration -> o11y+cloud readers.

* cloud: embed o11y v1.5.4 — version-less /v1/o11y + o11y_ schema reads + debrand

Bumps hanzoai/o11y v1.5.2→v1.5.4 (version-less surface + o11y_ ClickHouse table
reads + the signoz→o11y debrand) and repoints embed.go to the renamed runtime
package pkg/signoz→pkg/o11y (type SigNoz→O11y). Pairs with otel-collector v0.144.8
(writes o11y_) + the lockstep cutover migration. go build ./... = 0.

---------

Co-authored-by: hanzo <z@hanzo.ai>
2026-07-09 13:12:34 -07:00
8f0db9093f deps: bump hanzoai/o11y v1.5.1 -> v1.5.2 (o11y /v1/o11y path normalizer) (#199)
Embeds hanzoai/o11y#26: the mount normalizes the /v1/o11y/<resource> public
contract onto the internal SigNoz /api/vN routes (kills the /api/ leak, fixes
the llmobs /v1/o11y/* 404). Pairs with the cloud CR O11Y_GLOBAL_EXTERNAL__URL=""
change (universe#461) — deploy together.

Co-authored-by: hanzo <z@hanzo.ai>
2026-07-09 08:57:35 -07:00
zeekayandClaude Opus 4.8 88f7de5d35 fix(identity): read hanzo_iam_token cookie — unbreak embedded-console org-scoped /v1
The ai (casibase) layer SETS the httpOnly hanzo_iam_token cookie (the IAM JWT) after
login (ai/controllers/account.go iamTokenCookieName), but cloud's own identity
middleware only read [iam_access_token, access_token, hanzo_token] — NOT
hanzo_iam_token. So the embedded console (browser holds ONLY that cookie, no
Authorization header) resolved to no validated principal → every org-scoped /v1
endpoint (agents, gpus, machines, platform, orgs, entitlements, …) 403'd
'X-Org-Id required', and modules rendered empty. Add hanzo_iam_token (first) to
cookieTokenNames so cloud reads the SAME cookie the ai layer sets → validates the
JWT → X-Org-Id from owner → org-scoped surfaces authorize. Verified: the JWT is
present in the browser (1533-char httpOnly hanzo_iam_token); only the name was wrong.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 02:28:51 -07:00
7000bccaec chore(deps): bump hanzoai/ai → e1c4cde0 (cookie-session self-heal, #71) (#197)
Pulls the get-account cookie-path fix (hanzoai/ai#81): a signed-in admin
whose beego session already holds an anonymous guest u-<hash> is now
self-healed from the hanzo_iam_token credential to its canonical identity,
so /v1/admin/* stops 403ing under the console cookie session. Verified:
cloud binary builds with -tags "libsqlite3 sqlite_fts5".

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

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-09 00:00:57 -07:00
fe2f9fc091 org: elect via hanzoai/ha; vfs/replica stays replication-only (#196)
internal/org/shared.go split its aliases along the real seam: election
(Member/Owner/IsOwner/Replicas) now re-exports github.com/hanzoai/ha; the
Replicator/Store/DB/DBPath stay github.com/hanzoai/vfs/replica. membership.go
and cipher.go are unchanged (the alias types line up: org.Member = ha.Member).
writefence doc updated to name ha as the election primitive.

One concern, one home: who-writes (ha) vs how-state-ships (vfs). No behavior
change; internal/org + writefence pass with -race.

NOTE: `go mod tidy` is blocked in this repo by a PRE-EXISTING, unrelated
luxfi/keys@v1.2.0 go.sum checksum mismatch; ha was added via `go get` +
marked direct by hand. Re-run tidy once that pin is fixed.

Co-authored-by: hanzo <z@hanzo.ai>
2026-07-08 23:11:34 -07:00
zandGitHub f84f5e2ead Merge pull request #195 from hanzoai/feat/ai-login-manager
feat(cloud): analytics.datastore entitlement gate for paid per-org usage analytics
2026-07-08 17:20:58 -07:00
hanzo-dev b5d8651f72 feat(cloud): analytics.datastore entitlement gate for paid per-org usage analytics 2026-07-08 17:01:45 -07:00
hanzo-dev ff60007ca2 test(cli): uploadOutputs takes active org 2026-07-08 16:38:10 -07:00
hanzo-dev 1153077622 Merge commit 'aef665f' into HEAD 2026-07-08 16:37:47 -07:00
hanzo-dev ac4d0771dc harden(build): -mod=readonly + GOSUMDB-on + digest-pinned bases (RED money-image gate)
Per RED's conditional GO on the encryption image:
- GOFLAGS -mod=mod -> -mod=readonly: the committed go.sum is the SOLE source
  of truth; any needed-hash drift FAILS the build instead of silently
  re-recording an unverified hash. Verified go.sum is complete + sumdb-
  consistent (go build/download clean with GOSUMDB ON).
- Drop GOSUMDB=off: a money image must not blanket-disable the checksum
  database. GONOSUMDB scoped to zap-proto/* only (first-party-direct).
- Digest-pin the three base images (node:24-alpine, golang:1.26-alpine3.22,
  alpine:3.22) @sha256 for a reproducible money image.

The ldd/readelf link proof stays belt-and-suspenders behind the ciphertext
proof (verified at build time on the musl image).
2026-07-08 16:16:07 -07:00
hanzo-dev 93d5eccd54 fix(console-sec): rate-limit keys on validated principal, not spoofable XFF (RED)
The limiter guards the OFF-GATEWAY path where nothing trusted stamps
X-Forwarded-For, so keying on the client-settable XFF let an attacker send a fresh
value per request and reset the 30/min bucket at will. These are all post-auth
money-write routes, so key on the un-spoofable VALIDATED principal (X-Org-Id/
X-User-Id, minted by SanitizeIdentity from a verified JWT); fall back to the socket
peer (L4 RemoteAddr) for an unauthenticated request (which the handler 403s anyway).
Test now rotates XFF during the flood (must NOT reset) and asserts a second principal
keeps its own bucket.
2026-07-08 16:14:38 -07:00
hanzo-dev f43062a3d2 feat(console-sec): CSRF token on money writes + per-IP rate limit (RED hardening)
Cloud-direct/off-gateway money path loses the edge WAF/limiter and the embed
session-bridge's Sec-Fetch-Site gate passes VACUOUSLY when Origin/Referer/SFS are
all absent (RED). Adds two positive controls to the console write surface:

CSRF (csrf.go) — GET /v1/console/csrf issues a token bound to the validated
principal (X-User-Id+X-Org-Id), MAC'd with keyed BLAKE3 (luxfi/crypto,
blake3.KeyedHash) under a server-only KMS key (CONSOLE_CSRF_KEY; ephemeral
per-process fallback). requireCSRF enforces X-CSRF-Token on the AMBIENT-cookie path
ONLY (no Authorization + a Cookie present) — Bearer/Basic/gateway/API callers are
immune to CSRF and skip it, so nothing non-browser breaks. A cross-site page cannot
read the same-origin token (SOP) nor set the custom header (no CORS preflight
granted), and the token is identity-bound so it can't be replayed as another user.

Rate limit (ratelimit.go) — per-IP token bucket (30/min) on mint/rotate/revoke key
+ wallet top-up; distinct from commerce's spend-cap, restores frequency protection
lost off-gateway. Keyed on XFF first-hop.

Wraps POST/DELETE keys, POST onboard, POST topup, POST billing, POST/PUT/PATCH/DELETE
commerce. Reads stay open. Tests: ambient-no-token 403, valid-token 200,
cross-identity replay 403, Bearer-skips-CSRF 200, rate-limit 429; existing suites
unchanged (no cookie ⇒ CSRF skipped). luxfi/crypto for the MAC (NOT stdlib/JWT).

Needs arcd build + RED review; CONSOLE_CSRF_KEY to be provisioned via KMS for
restart/multi-replica-stable tokens (coordinate ac742480).
2026-07-08 16:14:38 -07:00
hanzo-dev 5c5774a4f5 fix(cloud-direct): stamp X-User-Name; mint hk- keys by owner/username not owner/uuid
The in-binary direct-Bearer path (console SPA -> cloud, gateway bypassed) stamps
X-User-Id = the JWT subject (a UUID) via idClaims.userID(). The console key ops
built the IAM id as <owner>/<X-User-Id> = hanzo/<uuid>, but IAM's mint-user-keys /
get-user resolve only <owner>/<name> (hanzo/z) -> 'password or code is incorrect'
-> hk- mint 502 on the cloud-direct path. (The gateway path worked because the
gateway minted X-User-Id == username.)

Fix (narrow blast radius, per RED-preferred approach — does NOT reorder userID()):
- idClaims.username(): the IAM username (name claim, then preferred_username),
  NEVER the subject.
- SanitizeIdentity stamps X-User-Name from the validated username, DISTINCT from
  X-User-Id. X-User-Name is already an authorityHeader (stripped on ingress,
  re-injected only from validated claims -> forgery-proof).
- resolveCaller carries a distinct caller.username (X-User-Name, fallback to
  X-User-Id for the gateway path); new caller.keyID() = <owner>/<username> is used
  ONLY by the user-key ops. caller.id / caller.name are UNCHANGED, so the
  billing/topup/commerce subjects are byte-identical -> zero money-path impact.

Tests: TestKeys_DirectBearerPath_MintsByUsernameNotUUID (mint targets hanzo/z, not
the UUID), TestSanitizeIdentity_StampsUserName, TestSanitizeIdentity_UserNameForgeryStripped;
existing key + identity suites unchanged (gateway path falls back to owner/name).

Needs arcd/CI image build (no local docker) + RED review before deploy.
2026-07-08 16:14:38 -07:00
hanzo-dev 9a5f1f35d5 build: CGO=1 + libsqlcipher encrypted image (was CGO=0 PLAINTEXT) + KAT gates
The unified binary embeds IAM's per-org SQLCipher store and commerce's
per-tenant money DBs; the prior CGO_ENABLED=0 build shipped pure-Go
modernc — PLAINTEXT at rest. Rebuild CGO=1 against system libsqlcipher
(hanzoai/iam's proven recipe: libsqlite3 tag + libsqlcipher symlink +
-DSQLITE_HAS_CODEC), runtime base scratch -> alpine:3.22 + sqlcipher-libs
(CGO needs libc + the codec .so).

Baked-in RED gates (a failing gate = NO image):
- modernc double-registration guard: 0 modernc in the CGO=1 ./cmd/cloud
  graph (the one 'sqlite' driver is mattn/SQLCipher).
- TestEncryptionProof: real ciphertext-at-rest under SQLITE_REQUIRE_CODEC=1.
- cek.go golden-vector KAT (TestUnwrapGoldenFixture + round-trip): a frozen
  pre-luxfi-swap 61-byte DEK sidecar still decrypts under the shipped
  luxfi/crypto-AEAD code — existing encrypted stores stay readable.
- readelf/ldd link proof: the binary binds sqlite3_* to libsqlcipher, never
  a plaintext libsqlite3.

Console embed stage unchanged (same-origin console). RED must review before
the image ships.
2026-07-08 16:03:57 -07:00
hanzo-dev ac366b4ccd deps: converge SQLite onto one driver — bump sqlite/orm/base/commerce/o11y/replicate
Bump the six hanzo modules to their driver-converged releases so the CGO=1
unified binary has EXACTLY ONE database/sql 'sqlite' registration
(mattn/SQLCipher), ending the 'sql: Register called twice for driver
sqlite' panic:
  sqlite     v0.1.5   -> v0.2.3   (SetPersistWAL + OpenPragma primitives)
  orm        v0.5.2   -> v0.6.1
  base       v1.4.6   -> v1.5.7   (+ replicate v0.9.5, the last modernc leak)
  commerce   v1.42.29 -> v1.46.40 (+ go:embed plans fix)
  o11y       (pseudo) -> v1.5.1
  replicate  v0.8.0   -> v0.9.5

Retarget the mattn v2.0.3+incompatible replace v1.14.16 -> v1.14.47 (the
SetFileControlInt/SQLITE_FCNTL_PERSIST_WAL-capable version hanzoai/sqlite
v0.2.3 needs for SetPersistWAL).

Verified CGO=1: 0 modernc in the ./cmd/cloud dep graph; the 517MB binary
builds and boots (--help) with NO double-register panic.
2026-07-08 16:00:21 -07:00
hanzo-dev aef665f260 gpu connect: materialize uploaded inputs + route output to active org
studio.render now (1) writes any inputs shipped with the job into the
local studio input dir via its own /upload/image, so an uploaded photo
(which lives in orgs/{org}/input on the dispatching pod, unreadable here)
resolves for LoadImage before the render; and (2) forwards the job's
active org as the studio_active_org cookie on /upload/output, so the
finished render lands in that org's gallery even when the worker token's
home org differs (a@hanzo.ai home=hanzo, rendering for karma).
2026-07-08 15:50:46 -07:00
hanzo-devandGitHub 0f5270d619 feat(cloud): SuperAdmin canonicalization + per-org entitlements API (#194)
SuperAdmin: /v1/admin/me and /v1/admin/users now emit the canonical
`isSuperAdmin` key alongside the deprecated back-compat alias `isGlobalAdmin`
(both populated with the SAME fact — owner == AdminOrg). The console may read
either during the rename migration and sees the same truth. No DB change: the
signal was always a derived boolean, never a stored column.

Entitlements: new clients/entitlements subsystem (order 139) — the per-org
product-enablement plane the console's paid-product sidebar reads.

  GET  /v1/orgs/:org/entitlements  -> { "enabled": [...] }
  POST /v1/orgs/:org/entitlements  { add?, remove? } -> { "enabled": [...] }

Two authorities, never braided: ENABLEMENT (this store: durable per-tenant
SQLite, (org,product) key, settings-store discipline) vs ENTITLEMENT (commerce:
deps.Commerce.CheckEntitlement at write time). A non-super-admin may only enable
a product the org's plan already grants (402 otherwise); disabling is never
gated; a super admin bypasses the commerce gate and may target any :org. Org
scoping mirrors clients/kms: :org must equal the validated owner claim unless the
caller is a super admin; a bearer-less forge fails the principal gate (403).

Tests (TDD, all green): store tenant-isolation + all-or-nothing Apply;
forged-request 403; cross-org 403; malformed org/product 400 (commerce not
consulted); entitled enable 200; unentitled enable 402 (nothing persisted);
super-admin bypass 200 (commerce not consulted); nil-commerce member-add 503
(fail-closed); remove never gated; empty mutation 400. Plus admin_test asserts
isSuperAdmin present and equal to isGlobalAdmin on both /me and /users.
2026-07-08 15:12:41 -07:00
zandGitHub d8e719bd7a Merge pull request #191 from hanzoai/feat/world-pricing
feat(world): plan enforcement contract + GET /v1/world/limits
2026-07-08 13:55:41 -07:00
hanzo-dev f435b301ab chore: pin hanzoai/plans to released v1.4.0 2026-07-08 13:55:37 -07:00
hanzo-dev 0386536b2e feat(world): plan enforcement contract + GET /v1/world/limits
Bumps @hanzo/plans to the World-pricing catalog (world-enterprise tier +
world.model_api gate) and adds the single-sourced enforcement contract for
the /v1/world data plane.

- clients/plan: export Entitlements(ctx, id) — the one Go seam to read a
  plan's canonical entitlement block from the @hanzo/plans catalog (runs the
  bundle 'entitlements' route; no data duplication, no fromLegacy re-impl).
- clients/world/entitlement.go: WorldLimits + WorldLimitsFromEntitlements
  (pure) + ResolveWorldLimits(ctx, planID) — values sourced from world.*
  entitlements, never hardcoded. FreeWorldLimits is the fail-closed floor
  (catalog outage degrades to Free, never grants model/stream).
- GET /v1/world/limits?plan=<id>: machine-readable contract echo so agents/
  dashboard self-config against the live catalog instead of hardcoding tiers.
- Tests: contract mapping (all tiers), fail-closed on unmounted catalog, and
  end-to-end Entitlements against the real embedded bundle (world.model_api
  present on pro/enterprise, absent on free).

Per-request enforcement (org->plan resolution + rate limiter wiring) is the
documented follow-up owned with feat/world-model-engine; both gates resolve
through ResolveWorldLimits so policy stays single-sourced.
2026-07-08 13:49:29 -07:00
e8a02057a2 fix(wallets): retry Safe deploy on 'wallet not found' (same ring commit race, DRY) (#190)
Proxy capture of cloud->ring proved the Safe flow hits the ring's commit-after-
response read-after-write race TWICE, not once: createVault->createWallet ('vault
not found', already retried) AND createWallet->deploy ('wallet not found', which
502'd custody=safe). With ALL requests pinned to one node (via a debug proxy) the
deploy STILL 404'd, so it is a Postgres commit-visibility lag, not node affinity.

Extract doRetryNotFound(...notFound) (bounded 6x/250ms linear, ctx-aware, fail-fast
on any other error; do() only unmarshals on 2xx so out is safe across retries) and
use it for BOTH createWallet ('vault not found') and deploySafe ('wallet not
found'). go test ./clients/wallets/... green; cmd/cloud builds.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-08 13:42:44 -07:00
6fbc419c8c fix(kmssvc): require explicit env on secret writes (no silent default) (#189)
The embedded KMS write path POST /v1/kms/orgs/{org}/secrets defaulted a
missing env to "default" (envOr), committing the write to a bucket that
project/env/path readers (the kms-operator, cluster syncs) never resolve.
That split is what let an IAM z-password land in env=default while prod kept
serving the stale value. env is a first-class component of the storage key
(kms/secrets/{path}/{env}/{name}) and cannot be aliased, so a write with no
env now fails loud (400). GET/DELETE/LIST keep the envOr compat default (a
read/delete can't plant a value another reader trusts; legacy readers that
omit env must keep working). No PATCH route exists on this surface.

Regression tests: write without env -> 400 (and lands nowhere); write
env=prod is readable via the operator's project/env/path resolution (sha256
round-trip, values never printed) and is not visible in env=default. The
fail-closed-without-master-key test now sends a valid env so it still
exercises the 503 master-key gate rather than 400-ing on input.

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

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-08 12:04:34 -07:00
3123c009bb deps: bump hanzoai/ai -> #71 session-resolution fix (ai#79); iam stays v1.31.18 (#188)
Completes the #71 auth repair as a clean dep bump (the fix lives in ai + iam, not
the cloud tree):
- github.com/hanzoai/ai v1.802.0 -> v1.802.1-0.20260708185316-0321c35877f0
  (ai#79 0321c358: self-heal get-account identity — stop degrading real logins to
  u-<hash> guests; fail-closed 401). Pseudo-version pins the commit while the
  semantic-release patch tag mints (1 commit ahead of v1.802.0).
- github.com/hanzoai/iam v1.31.18 already pinned in main (iam#109 fail-closed
  guest-mint gate) — MVS keeps it over ai's older iam pin.
go mod tidy added the authentic gopsutil/v4 transitive hashes (iam util); go mod
verify OK; -mod=readonly CGO_ENABLED=0 go build ./cmd/cloud green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-08 11:58:25 -07:00
hanzo-dev 3771833d90 Merge fix/embed-session-principal-bridge: embed session→principal bridge + RED H2/H3 + iam v1.31.18 (H1)
Makes console.hanzo.ai (go:embed console) authenticate its money surfaces: the
first-party IAM session cookie → validated principal (sessionAccessToken → v.validate),
RED-hardened (H2 Secure cookie, H3 same-origin bridge gate), on iam v1.31.18 (H1
session-regeneration + iam-main security fixes). Pairs with console v8.4.122 which
addresses billing/commerce/keys at the canonical bare /v1 in embed mode.
2026-07-08 10:13:34 -07:00
hanzo-dev 5d18ca8264 deps(embed): pin hanzoai/iam v1.31.18 — H1 session-regen + iam-main security fixes ∪ InitEmbed
v1.31.18 is iam main (guard-leak stamp #108, guest-signin fail-closed #109, capauth
PermAttenuate #106) UNION the InitEmbed line UNION RED H1 (SessionRegenerateID on the
sign-in transition). The prior embed pin v1.31.17 had diverged off an old base and was
MISSING those iam-main security fixes, so pinning v1.31.17+H1 would have shipped the
money embed without them. v1.31.18 ships H1 + guard-leak/guest/capauth + InitEmbed
atomically into this binary's in-process IAM. Transitive indirect bumps (purego/
plan9stats/locafero/gopsutil-v4/viper/tidwall-match) are MVS-driven by iam v1.31.18.
2026-07-08 10:13:16 -07:00
hanzo-dev 00daba19e3 auth(embed): RED fixes H2+H3 on the session→principal bridge
H2 (HIGH) — pin the IAM session cookie Secure. clients/iamsvc/iamsvc.go derived
Secure from web.BConfig.Listen.EnableHTTPS, which is FALSE (the binary listens plain
:8000 behind the TLS-terminating ingress) → the session cookie shipped non-Secure.
The embed bridge turns that opaque sid into a money bearer (hk- mint, balance/top-up),
so a non-Secure cookie is capturable off any plaintext leg and replayable. Pinned
Secure: true (the deployed edge is always HTTPS).

H3 (MED-HIGH) — gate the ambient-cookie bridge to same-origin. billing.go/commerce.go
forward GET verbatim to commerce; a SameSite=Lax cookie still rides a top-level GET, so
a cross-site link could drive the victim's own money action if any commerce GET mutates.
validatedPrincipal now fires the session bridge ONLY for a same-origin request
(sessionBridgeSameOrigin: Sec-Fetch-Site same-origin|none, else Origin/Referer
host==Host) — refusing cross-site AND sibling-subdomain (same-site). Bearer/JWT-cookie
paths (non-ambient) are unaffected. +TestSessionBridgeSameOrigin (7 cases) green.

REMAINING for money: H1 (session-fixation — SessionRegenerateID on the IAM sign-in
transition) lands in hanzoai/iam (compiled into this binary); coordinating.
2026-07-08 09:55:08 -07:00
hanzo-dev b7cfda01fb auth(embed): bridge first-party IAM session cookie → validated principal
The go:embed console (console.hanzo.ai → cloud:8000) authenticates against the
in-process IAM, which sets an OPAQUE, httpOnly session cookie (cloud_session_id)
and stores the user's IAM-minted access-token JWT SERVER-SIDE against that session.
The console's Next BFF token-minting routes are stripped by the static export, so a
browser request to a cloud-native route (/v1/console/keys, /v1/billing/*) carries
only the session cookie — no bearer — and validatedPrincipal refused it, 401ing
every authenticated surface (API keys, billing, every product page = shell).

validatedPrincipal now resolves that session cookie to the server-stored access
token (sessionAccessToken via web.GlobalSessions) as a LAST RESORT (after Bearer/
Basic/JWT-cookie), then validates it through the SAME v.validate (sig/iss/aud/exp).
Identity is bound to the VALIDATED session: the client holds only an unguessable,
httpOnly sid; the session never asserts identity itself. No-op on gateway-fronted
binaries (a bearer is present) and on binaries with no IAM session manager
(web.GlobalSessions == nil) — tested. CSRF: cloud_session_id is SameSite=Lax, so a
cross-site request never carries it; and cookieTokenNames already establishes cloud's
JWT-cookie auth posture. This is the v8.4.5-flagged 'set the cookie the sanitizer
looks for' path, done cloud-side from the session store (no cross-repo IAM release).

RED review requested before it fronts money (session-fixation / CSRF surface).
2026-07-08 09:55:08 -07:00
25609e83ae feat(automations): waitlist points connectors — x/discord verify + award_points seam (#187)
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-08 09:21:54 -07:00
3bbc9c51c0 fix(console): default HANZO_CHAIN_ID to genesis-canonical 36963 (#186)
topupConfig defaulted to the placeholder 36900; align to the
genesis-canonical Hanzo mainnet chain id 36963 (lux/genesis, and the
rest of cloud clients/treasury+wallets already use 36963). Still
env-overridable via HANZO_CHAIN_ID.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-08 09:01:20 -07:00
hanzo-devandGitHub f4cfbbf4f2 refactor(cloud): one /v1/o11y owner (embedded runtime); split settings out of observe (#185)
The observability surface was mounted THREE ways over the same /v1/o11y/* paths:
clients/observe (order 44), clients/o11y's o11yscope (order 69), and the
hanzoai/o11y wildcard runtime (70/71). observe also served /v1/settings/:product,
which is console product config, not observability. Collapse to one and one way.

- ONE owner of /v1/o11y/{logs,metrics,status}: clients/o11y's o11yscope (order 69).
  observe's richer logic is folded IN so nothing is lost — the REAL per-org RED
  metrics + LLM usage (metricsread.go, was a stub in o11y) and the two-view logs
  (admin infra stdout / per-org request-from-traces). Tenant isolation preserved:
  org is principal.Tenant bound as a positional ClickHouse param, the product is
  shape-validated → alias-mapped (console slug → workload) → allowlisted
  (knownServices, SSRF/injection boundary). observe's productAlias merged into
  resolveService so no product loses data. Admin god-view gates on c.IsAdmin()
  (== owner=="admin" SuperAdmin after SanitizeIdentity), never a per-org isAdmin.

- /v1/settings/:product moved OUT of observe into clients/settings (it is NOT
  observability). Behavior/contract preserved verbatim from observe: {config,
  secretKeys} shape, KMS ref orgs/{org}/settings/{product}/{key}, (org,product)
  store isolation, secrets-to-KMS-or-fail-closed. Replaces the orphaned, divergent
  clients/settings stub with the live behavior and wires it in (order 138).

- /v1/query does not exist (no registrant, no consumer) — nothing to fold.
  /v1/observe/health was the auto-derived GET /v1/<id>/health for id "observe";
  it vanishes with the subsystem (o11yscope gets /v1/o11yscope/health; the runtime
  serves its own gate-exempt /v1/o11y/api/v*/health*). Both documented.

- DELETE clients/observe; drop its import; add clients/settings; fix the stale
  subsystems.go o11y comment ("reverse proxy to the dedicated o11y Deployment" →
  the embedded reality: scoped reads 69 + in-process runtime 71 + OTLP ingest 72).

Net -1037 LoC. cmd/cloud + cmd/hanzo build; clients/o11y + clients/settings tests
pass (20/20), covering tenant isolation, secrets-never-plaintext, product
validation, alias resolution, and route precedence over the wildcard proxy.
2026-07-08 08:58:18 -07:00
e98d614b61 refactor(cloud): canonical subsystem names — drop svc suffixes, one noun per capability (#184)
One canonical short-noun name per subsystem (registered name + Go package dir +
route). No public route breaks: renames that change a live /v1 prefix keep the
old route as a back-compat alias (mount both, same handlers).

Renamed (internal-only, route unchanged):
  usagesvc  -> usage        (register string)
  zt        -> zero-trust   (register string; pkg dir kept `zt`, routes /v1/networks|mesh|edge unaffected)
  auditlog  -> audit        (register string; route already /v1/audit)
  s3        -> storage       (dir+pkg+register; route /v1/s3 kept — route-safe)
  tasksvc   -> tasks         (dir+pkg; register already `tasks`)
  iamsvc    -> iam           (dir+pkg; register already `iam`)
  mpcseal   -> mpc           (internal lib dir+pkg; importers repointed, local alias kept)
  gojahost  -> goja          (internal lib dir+pkg; importers repointed)

Renamed with public route + back-compat alias:
  kb        -> knowledge     (dir+pkg+register; canonical /v1/knowledge added, /v1/kb alias kept; framework module id `kb` retained = data-model id)

Log "subsystem" labels aligned to canonical names. Subsystem test enable-lists
and stale clients/<old> path comments updated. stagedSubsystems already
canonical ({iam,ingress}).

Held (route collisions — CTO decision):
  ml -> models       COLLIDES /v1/models (OpenAI-compat catalog owns it) — kept `ml`
  websearch -> search COLLIDES /v1/search (provisioned search resource) — kept `websearch`
  kmssvc dir         register+route already canonical (`kms`, /v1/kms); dir kept
                     to avoid colliding with the `clients/kms` SecretStore core lib.

Not present in repo: gatewaysvc / gatewaypolicy (gateway is a separate deployment).

Build: CGO_ENABLED=0 go build ./... green; go vet green; tests green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-08 08:55:27 -07:00
hanzo-devandGitHub a98b0f101f refactor(cloud): decomplect subsystem registration — Typed adapter, OwnsHealth flag, generic pick[T], clean ids (#183)
One coherent change to the subsystem-registration layer. Concrete types over
`any` at the call sites, indirection deleted, generics only where they remove
real duplication.

CHANGE 1 — kill the per-subsystem `any`-unwrap boilerplate
  Every in-repo subsystem's init() hand-wrote the identical
    func(app any, deps cloud.Deps) error { a, ok := app.(*zip.App); if !ok {…}; return Mount(a, deps) }
  Add ONE adapter, cloud.Typed(func(*zip.App, Deps) error) MountFunc, that does
  the *zip.App recovery in a single place (fail-closed, never panics). All ~50
  subsystems collapse to `cloud.Register("x", n, cloud.Typed(Mount))` /
  `cloud.RegisterWithShutdown(..., cloud.Typed(Mount), Shutdown)`. Redundant
  shutdown wrappers dropped where Shutdown already matches ShutdownFunc; kept
  only where a no-arg Shutdown() needs signature adaptation.
  MountFunc's param STAYS `any` on purpose: the pinned external subsystem modules
  (hanzoai/ai, authz, base, commerce, metrics, o11y, licensing) register with
  `func(any,…)`, and a `func(any,…)` literal is not assignable to a
  `func(*zip.App,…)` parameter — retyping MountFunc would break those modules at
  compile time. The assertion is now central, not per-subsystem. MountAll takes
  the concrete *zip.App (threaded from Serve).

CHANGE 2 — OwnsHealth flag replaces the "<name>svc" health kludge
  Some subsystems serve their OWN fail-closed /v1/<name>/health; the generic
  always-ok liveness route in Serve would shadow it. The old fix encoded routing
  policy in the id ("kmssvc" parked the generic route at an unrouted path). Now
  Register/RegisterWithShutdown take `opts ...Option`; cloud.HealthOwner sets
  MountSpec.OwnsHealth, and Serve's generic-health loop skips a HealthOwner. The
  id is once again the clean route name. Invariant now uniform and checkable:
  a subsystem serves /v1/<name>/health  IFF  it registers cloud.HealthOwner.
  Migrated every health-owner to it: kms, paas, s3 (named in scope) plus
  analytics, console, platform, ml (same kludge) and notify, plans, pricing,
  security (had coincidental id==route; security's real probe reports a rule
  count the generic route was silently dropping). pickKMSClient gate + all
  tests + stale comments updated from the "kmssvc"/"s3svc"/… ids to kms/s3/….

CHANGE 3 — clean package renames (no collision)
  clients/paassvc → clients/paas, clients/projectsvc → clients/projects
  (package decls, filenames, the sole importer, error strings, userAgent, and
  doc refs repo-wide). clients/kmssvc + clients/tasksvc KEEP their package names
  — the `svc` disambiguates the subsystem from the same-named library it imports
  (clients/kms, hanzoai/tasks); their ids are already clean (kms via CHANGE 2,
  tasks).

CHANGE 4 — generic pick[T]
  The five identical co-resident-or-RPC-or-disabled resolvers (IAM, Base,
  Commerce, O11y, MQ) collapse into one
    pick[T](cfg, log, name, label, zapAddr, rpc func(string)T, disabled func()T) T.
  KMS/AI/VFS/Payments/Vault keep bespoke pickers — their construction genuinely
  differs (embedded store / gateway preference / S3-admin backend / never
  co-resident), so they are left alone.

Verified: CGO_ENABLED=0 go build ./cmd/hanzo/ and ./cmd/cloud/ both exit 0;
go vet clean on every changed package; `hanzo --help` lists kms/paas/projects/
s3/tasks svc-free; cloud root + renamed + health-owner package tests pass; new
build_registration_test.go covers Typed + HealthOwner. Net −199 lines.
2026-07-08 08:13:52 -07:00
hanzo-dev ca4338e6a7 Merge branch 'feat/control-plane-ceremony'
# Conflicts:
#	config.go
2026-07-08 07:59:17 -07:00
hanzo-dev 489efc212e chore(cloud): repin luxfi/consensus v1.35.30 -> v1.35.32 (DoS bound + 1-based fix)
Picks up the increment-2 crypto hygiene: the PartyID<=ValidatorSetSize DoS
bound on the quasar/pulsar Finalize path (Item7a) + the structural-Verify lock
(Item7b). v1.35.32 corrects a 1-based off-by-one in v1.35.31 that rejected the
Nth validator; verified the controlplane N=7 ceremony finalizes under -race.
LOW severity (ingestLeg bounds PartyID upstream) but the fix is now live-pinned.
2026-07-08 07:56:58 -07:00
hanzo-dev 62b7ca17f4 merge: two-plane epoch write-fence primitive (strict-> + atomic CAS) 2026-07-08 07:51:42 -07:00
hanzo-dev 6fd7faf622 merge: controlplane containment cage (CI guard + fail-closed assert + external-cert seam) 2026-07-08 07:51:41 -07:00
cc6d2d17bf feat(ingress): embedded runtime-configurable edge subsystem (/v1/ingress) (#182)
Add clients/ingress — an embedded edge plane in the cloud binary so the ONE
binary can BE the fleet edge: terminate TLS, run ACME, and reverse-proxy by Host
to upstreams, configured LIVE over /v1/ingress with no static routes.yaml and no
restart to change a route (hot-apply via an atomic engine snapshot swap).

Control plane (zip): /v1/ingress/{routes,services,middlewares,tls,status},
SuperAdmin-gated, per-tenant SQLite persistence, route Host globally unique;
every mutation reloads the engine.

Data plane (net/http): :80 (ACME HTTP-01 + router) and :443 (SNI TLS termination
via x/crypto/acme/autocert + router). Started only in edge role
(CLOUD_INGRESS_EDGE_ENABLED); app role keeps the listeners off — role = runtime
config, one binary.

Proxy: github.com/vulcand/oxy/v2 (Traefik lineage) weighted round-robin, the
Traefik router->service->middleware model. Middlewares: redirectScheme,
stripPrefix, addPrefix, headers.

STAGED subsystem (config.stagedSubsystems): linked but mounts ONLY when named in
CLOUD_ENABLE, so prod is untouched. Orthogonal to /v1/gateway (auth/rate-limit).

Build: CGO_ENABLED=0 go build ./... green; go test ./clients/ingress green (11 tests).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-08 07:45:37 -07:00
hanzo-dev 69cbc858f1 fix(writefence): injective mirror key + single-read Get (red pass)
Red-team findings on the write-fence primitive:
 - HIGH: mirrorKey(plugin,shard) = "writefence/"+plugin+"/"+shard was not
   injective — mirrorKey("kms/tenant-a","secrets") collided with
   mirrorKey("kms","tenant-a/secrets"), so a push framed as one shard could
   overwrite another's epoch/writer/payload. Real shard scopes carry '/'
   (vfs replica.DBPath yields "projects/site"), so it is reachable. Fixed with
   a %d:-length-prefixed key; TestMirrorKeyInjective_NoCrossShardAliasing locks
   it (was red's failing PoC, now green).
 - LOW: MinioConditionalStore.Get did StatObject+GetObject (two round trips);
   tightened to one GET whose obj.Stat() ETag is consistent with the read bytes
   — closes the window rather than relying on the CAS to absorb a stale version.
Core CAS/epoch soundness unchanged (red GO: N=64 same-epoch race → one winner,
retry-bounded, strict-> rejects epoch==recorded). Still shadow-only, unwired.
go test -race -count=200 green.
2026-07-08 07:26:02 -07:00
hanzo-dev 95f3b49aa2 harden(containment): grep also catches -tags negation form (red pass)
Red-pass finding: the check-#1 grep char class [A-Za-z0-9_, ] missed the
build-constraint negation form `-tags '!x,controlplane'` (the `!` broke the
match before reaching controlplane). Add `!` to the class so it is caught.

Verified the deeper guarantees hold (evasion-agnostic), so this is belt-only:
 - ZERO non-test importers of clients/controlplane (grep-confirmed).
 - The package has ZERO untagged files, so importing it into serve code fails
   the untagged `go build ./...` — check #3 catches ANY -tags syntax, incl.
   GOFLAGS=-tags=controlplane (verified: the pkg becomes buildable => check #3's
   'matched no packages' assertion fails => CI red).
Runtime asserts + external-cert selfComposedCert seam confirmed wired through
guarded constructors. No stub-crypto path reaches a serve binary.
2026-07-08 07:18:31 -07:00
hanzo-dev 1471801173 harden(controlplane): CI guard also catches the testing.Testing() spoof vector
Self-review finding: containment.go's runtime guard trusts testing.Testing(),
which is backed by a linker-set string var (testing.testBinary, set by `go
test` itself per cmd/go/internal/load/test.go). Confirmed locally that
`go build/run -ldflags="-X testing.testBinary=1"` spoofs it to true in a REAL
(non go-test) binary — verified with a throwaway program before writing this.

containment.yml's grep step now also fails the build on any reference to
`testing.testBinary` outside the Go toolchain itself, so a build path that
tried to ship that spoof gets caught the same way a `-tags controlplane`
build path does. Documented as a known residual in the workflow's header:
this is a mitigation (CI catches it), not a cryptographic close — that needs
increment-2's real signing, tracked in doc.go.

Also fixed the exclusion patterns to be grep-implementation-agnostic (some
recursive greps don't prefix paths with "./"), verified against a planted
violation for both checks.
2026-07-08 07:09:47 -07:00
hanzo-dev b252c65089 fix(writefence): strict-epoch, atomic-CAS write-fence for the (plugin,shard) mirror
Closes the same-epoch double-write on the HIP-0107 data-plane push path
(github.com/hanzoai/vfs/replica, wired in internal/org): today the only
admission checks are replica.IsOwner (a pure local computation over a
possibly-stale membership view) and the StatefulSet Recreate deployment
shape (role.Role) — both comment-only, non-atomic, and the underlying
Store/Backend.Put is an unconditional overwrite ("Overwriting is allowed").
A deposed/partitioned writer and a freshly-elected one can both push.

internal/writefence/fence.go adds Fence.Push: a single atomic
read-check-CAS that (1) rejects any candidateEpoch <= the epoch currently
recorded for the shard (strict >, closing the same-epoch case) and (2)
performs the epoch-advance and payload append in ONE conditional write
against the store's live version token, so two racing writers cannot both
land — the store is the sole arbiter, never an in-memory cache. Retries
once on a lost CAS race, re-checking strict monotonicity against the new
state, so a same-epoch racer's retry fails ErrStaleEpoch rather than
silently duplicating the admit.

EpochSource is the pluggable seam clients/controlplane's lease epoch drops
into once it graduates from shadow (Stage 1 today) — this package imports
nothing from controlplane. ConditionalStore models the S3 If-Match / GCS
generation-match primitive; store.go backs it for real with minio-go's
native SetMatchETag/SetMatchETagExcept (already vendored at v7.0.100, no
go.mod bump). fake_test.go models the same semantics in-process with a
barrier hook that deterministically reproduces the concurrent-CAS race.

Tests prove: strict-epoch rejection of a same-epoch retry (same and
different writer), the raw CAS rejecting a race loser, the full
concurrent-Push race resolving to exactly one winner, a legitimately
higher epoch being admitted, a stale lower epoch being rejected, and
per-shard scoping. Not yet wired into the live push path (that remains
gated by controlplane's shadow flag per HIP-0116); this is the fence
primitive plus a precise wiring recommendation for hanzoai/vfs's block
layer, which currently exposes no conditional-write capability to adopt.
2026-07-08 07:07:17 -07:00
hanzo-dev ee39b8bce6 harden(controlplane): CI+runtime containment cage + external-cert type seam
Stage-1 ceremony's crypto is stub/forgeable by design (doc.go); this closes
the drift risks doc.go's increment-2 worklist flagged:

- .github/workflows/containment.yml (PR-gated): greps every build/release
  surface in the repo for `-tags controlplane` and fails the build if found,
  plus a positive proof that `go build ./...` links clients/controlplane into
  no cmd/ main and that the package still matches zero packages with no tag.

- containment.go: mustHarnessOnly fail-closed panics the moment this
  package's stub crypto is touched (package-import-time for the
  PartialZVerifier registration, construction-time for NewSigner/
  NewStubComposer) unless ProductionBCCSigningReady() (hardcoded false) or
  testing.Testing() (the Go toolchain's own go-test signal, unspoofable by a
  real build) holds. Proven end-to-end via a real subprocess
  (TestContainment_NonHarnessProcessRefuses), not just in-process logic.

- selfComposedCert typed seam (driver.go/signer.go): CertComposer.Compose now
  returns an unexported wrapper only it can produce; verifyOwnCertStructure
  accepts only that type, never a bare *quasar.QuasarCert. An externally-
  received cert has no way to become one, so it cannot reach the structural
  check even by mistake. VerifyExternalCert is the sole seam for such a cert
  and fails closed (increment-2 crypto not implemented). Locked from a
  black-box vantage in external_cert_test.go.

Containment verified unchanged: `go build ./clients/controlplane/...` (no
tag) still matches zero packages; `go build ./...` still links no cmd/ main
to the package; full `-tags controlplane -race` suite green, no test weakened.
2026-07-08 07:05:39 -07:00
hanzo-dev c03f5c2380 analytics: bake console website-id into the console-embed build
The cloud-embedded console (console.hanzo.ai + team) is built from hanzoai/console
build:embed. hanzoai/console now ships <HanzoAnalytics/> (env-gated on
NEXT_PUBLIC_ANALYTICS_WEBSITE_ID). Default it to the console.hanzo.ai property
(7dce54ee-41f6-4751-96bf-fe005067c7c7, public per-site) in the console build stage
so the one native analytics tag renders on the next cloud build. GA4/Pixel off.
2026-07-08 06:41:24 -07:00
e7cd108e0f fix(anchor): EIP-2 low-S normalize the MPC signature (fixes 'invalid sender') (#180)
The luxfi/mpc threshold signer returns a NON-canonical r|s: s is frequently in the
upper half (s > N/2). luxfi/geth's tx validation (ValidateSignatureValues,
homestead=true) REJECTS high-S signatures, so the anchor's MPC-signed self-tx
failed on submit with 'invalid sender' (live: POST /v1/admin/treasury/anchor ->
status error, note 'submit: send tx: invalid sender'). recoverableSig now
canonicalizes s to N-s when it exceeds N/2 before searching the recovery id, so
the 65-byte r|s|v it hands tx.WithSignature is EIP-2-valid and recovers to the
treasury MPC wallet. Tests: TestRecoverableSig_LowSNormalization (forced high-S ->
low-S, still recovers). go test ./clients/wallets/... green; cmd/cloud builds.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-08 06:25:30 -07:00
hanzo-dev c25f7429e7 test(controlplane): re-red — lock double-write closure as full-ceremony invariant
Adversarially verify blue's class-A fixes hold under op COMPOSITION inside a
single block (which the original red suite exercised only as separate blocks or
single ops). Six hostile compositions — bare reassign, release+reassign,
release+assign, membership-remove+reassign, remove+release+assign, assign-steal
— are each refused end-to-end through the N=7 ceremony, and the live writer is
unchanged across every voter with its lease mirror consistent. Plus: the
authorized proven-dead handoff stays single-valued under redundant reassigns,
and assign+release of a fresh resource leaves no orphan writer (mirror desync
would be a second authority). GO: the double-write class is fully closed.
2026-07-08 06:09:40 -07:00
b472f8a609 fix(wallets): retry Safe createWallet on 'vault not found' (ring commit-after-response race) (#179)
The ring's :8081 commits a newly-created vault to its DB AFTER writing the
createVault 201 response, so cloud's back-to-back createVault->createWallet (fired
microseconds apart on one keep-alive connection) races the commit and read-misses
the just-created vault -> 404 'vault not found' -> custody=safe 502. A slower
client (curl, separate processes) never observes the gap, which is why manual
repro succeeded. Bounded retry (6x, linear 250ms backoff, ctx-aware) on exactly
that 404; every other error still fails fast. Idempotent per attempt (fresh body).
go build ./cmd/cloud green; go test ./clients/wallets/... green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-08 05:02:46 -07:00
ec1ac4d040 treasury: bind the reserve MPC wallet as the on-chain anchor signer (#63) (#178)
Wires the #162 BindAnchorSigner seam to a real quorum signer. New:
- wallets.TreasuryAnchorSigner(org,chain): resolves-or-provisions the org's stable
  KindTreasury wallet on the ring (reserved account 'treasury' / wallet
  'reserve-anchor', idempotent) and returns its EVM address + a sign closure.
- The closure produces an EVM-recoverable r‖s‖v signature: the ring returns a bare
  r‖s (64B, no recovery id) but tx.WithSignature needs 65B, so recoverableSig finds
  the v whose recovery yields the wallet address (fails closed otherwise).
- POST /v1/admin/treasury/bind-anchor (global-admin): calls TreasuryAnchorSigner +
  BindAnchorSigner, so subsequent /v1/admin/treasury/anchor commits the ledger root
  signed by the treasury MPC wallet, not the lone KMS key. Returns the bound address
  (fund it for gas on the Hanzo L1).

Tests: TestRecoverableSig (both parities recover to the signer) + _NoMatch (fail
closed). go build ./cmd/cloud green; go test ./clients/wallets/... green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-08 04:37:06 -07:00
zeekayandClaude Opus 4.8 5c937cf046 fix(o11y): restore the missing metrics-query companion — unbreak the cloud build
The o11y-scope landing added clients/o11y/{scope,status}.go referencing a metrics-
query layer (vmClient, newVMClient, promLabel, metricsQuery, queryMetrics,
metricsResult, metricPoint, usageRollup, boundRangeMinutes) whose source file was
never committed → `go build ./cmd/cloud` failed (undefined symbols), taking the
whole deploy plane down (no new cloud image buildable from main). Restore the file
to the surface's own honest-empty contract: newVMClient reads O11Y_VM_URL and an
unset/unreachable VM degrades every query to an honest-empty series (never a
fabricated point); status.go's VM up-inventory works when VM is wired. queryMetrics
returns the honest-empty RED series until the VM query_range wiring lands. Full
`go build ./cmd/cloud` now links; go test ./clients/o11y passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 04:31:12 -07:00
hanzo-devandGitHub 34f1e40458 chore(deps): hanzoai/ai v1.800.10-pre -> v1.802.0 (auto-routing defaults + decision collection) (#177)
Brings the embedded ai subsystem up to v1.802.0:
- #76 opt-in auto-routing (virtual auto/zen-router model, X-Routed-Model)
- #77 per-org enable/disable (OrgSettings precedence)
- #78 admin-settable defaults (reserved "*" row, /v1/get-routing-defaults)
  + RoutingEvent collection (no prompt text) + /v1/export-routing-ledger

Edge contract unchanged; auto_routing_billing_test green against the new
module (ok github.com/hanzoai/cloud). Pre-existing clients/o11y compile
break on main is untouched (fix lands separately).
2026-07-07 23:31:08 -07:00
hanzo-devandGitHub 762c60ec84 refactor(auto): decomplect — remove the /v1/auto reverse-proxy + clients/auto (#176)
Kill the second automation surface. /v1/auto was a per-org reverse proxy
(clients/auto + clients/auto/proxy) to the standalone hanzoai/auto engine
(auto.hanzo.svc) — a duplicate of the native, in-process /v1/automations
Connectors+Automations engine (clients/automations, cloud.EmbeddedTasks,
706-piece catalogue). One engine, one surface: /v1/automations is the ONE
native automation engine. The external engine + its console link-out are
retired (console + universe in paired PRs).

- Remove the order-140 blank import of clients/auto from subsystems.
- Delete clients/auto/ (auto.go + proxy/).

No functional loss: /v1/automations already serves flows/versions/runs/
pieces/MCP natively. clients/kb keeps its own AUTO_UPSTREAM piece-runner
coupling (a separate, pre-existing bridge to a never-implemented engine
endpoint) — reported for a follow-up, not touched here.

go build ./... green, go vet green, go test ./clients/automations + root ok.
2026-07-07 23:11:43 -07:00
b2c9300140 refactor(automations): rename connector catalogue pieces -> connectors (HIP-0126) (#174)
* refactor(automations): rename connector catalogue pieces -> connectors (HIP-0125)

The automations connector CATALOG surface drops the ActivePieces term "pieces" for the ONE Hanzo term "connectors":

- GET /v1/automations/pieces -> /v1/automations/connectors; /pieces kept as a
  byte-identical back-compat alias (same handler) so live clients never break.
- Catalog{PieceCount,Pieces} -> {ConnectorCount,Connectors}; PieceMetadata/
  PieceAuth/PieceAction/PieceTrigger -> Connector*; JSON tags pieceCount/pieces
  -> connectorCount/connectors; embedded catalog.json + OpenAPI updated to match.
- Test proves the /pieces alias mirrors /connectors byte-for-byte.

Deliberately UNCHANGED (persisted @xyflow builder wire contract; renaming would
break live clients + stored flows): the flow-step protocol PieceName/pieceName,
PIECE/PIECE_TRIGGER, corePiece. Aligning those is a staged migration (HIP-0125).

* chore(automations,git,framework): scrub AI-slop placeholder comments (Rob Pike pass)

Comment-only, zero behavior change. Removes agent-note narration and future-work hedges, keeps the real WHY:
- automations.go: drop "a separate agent later OVERWRITES this file" narration; keep the Catalog-is-the-wire-contract invariant.
- framework/naming.go: "value for now" -> "value derived from now" (it reads the now arg, not a hedge).
- git/git.go: drop TODO(billing) + "in the MVP" hedge; state the git.usage meter fact.
- git/storage.go: drop TODO(vfs)/MVP/follow-up narration; keep the WHY osfs (not vfs) is used (vfs.FS does not implement go-billy).
Kept as real WHY/invariants (not slop): connector_core.go loopback-test SSRF guard, connector_slack.go httptest override, affiliates/store.go  sentinel + PendingCents; types.go was already cleaned in the rename commit.

* docs(automations): point connector-rename references at HIP-0126 (0125 was taken)

---------

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-07 23:01:24 -07:00
acfa8d8597 feat(cloud): kill the /v1/auto ActivePieces reverse-proxy — one native engine (#175)
CTO decision: ONE automation engine = the native Go /v1/automations
(clients/automations on cloud.EmbeddedTasks). This removes the redundant
/v1/auto reverse-proxy subsystem (clients/auto), a per-org proxy to the
standalone ActivePieces Deployment (auto.hanzo.svc).

- delete clients/auto/ (auto.go + proxy/)
- drop the order-140 blank import from subsystems.go

Safe: no live caller of cloud/v1/auto — console link-outs to auto.hanzo.ai,
and clients/kb calls the engine directly via its own AUTO_UPSTREAM client
(untouched here). The native /v1/automations surface is unaffected.

NOTE (does NOT retire the ActivePieces Deployment): clients/kb/sync_piece.go
still executes connector pieces via the engine at /v1/auto/pieces/{piece}/run;
the native engine exposes the piece CATALOGUE but not piece EXECUTION yet, so
auto.hanzo.svc must stay until native reaches piece-run parity.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-07 22:51:02 -07:00
a2f519e95f chore: cut AI-slop comment narration (Rob-Pike pass) (#173)
Comment-only tightenings, zero behavior change:
- pubsub/o11y: drop the misleading "GC won't collect" narration on the
  package-level server/collector refs; state the real reason (shutdown
  reachability) or the actual invariant (metrics ref is a write-only keepalive).
- iamsvc: condense the 11-line InitEmbed block that verbatim-restated the
  package doc down to the fail-closed WHY that matters at the call site.

No code changed (git diff: comments only).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-07 22:50:59 -07:00
be8012b048 fix(wallets): Safe custody must create the MPC wallet via the ring PRODUCT API (#172)
Safe deploy (POST /v1/wallets/{id}/smart-wallet) resolves the owner wallet by its
db.Wallet PRIMARY KEY (orm.Get). The :9800 internal /keygen mints a threshold key
but persists NO db.Wallet row, so deploy 404'd 'wallet not found' (live: every
custody=safe create -> 502). The ring's Safe surface is VAULT-scoped: the only
create path that persists a db.Wallet AND returns its id is
POST /v1/vaults/{id}/wallets.

safeCustody.Provision now: createVault -> createWallet (vault-scoped, returns db
id + internal WalletID + EOA) -> deploySafe(dbId). KeyRef stays
<internalWalletId>|<smartWalletId> (owner-sign via :9800 uses the internal id;
propose via :8081 uses the smart-wallet id); the db id is only needed for the
one-time deploy. safeclient gains createVault + createWallet; the stub test now
emulates the vault/wallet-create routes. go test ./clients/wallets/... green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-07 22:11:00 -07:00
72cf87e4db feat(usage,audit): org-scoped usage summary + audit trail endpoints (#171)
Add two org-scoped cloud-api surfaces for the enterprise console:

- /v1/usage/summary (clients/usage): the org's unified footprint roll-up —
  spend by category over time + wallet (from the commerce ledger) plus LLM
  usage totals (from the warehouse). Composes existing sources server-side;
  each degrades independently to honest zeros with a source marker. Org from
  the validated bearer only (principal.Tenant); a forged X-Org-Id with no
  principal 401s and never reaches commerce.

- /v1/audit (clients/auditlog): the per-org twin of the admin god-view — an
  org admin reads ONLY their own org's events off the SAME tamper-evident,
  hash-chained store. Org PINNED server-side (a client ?org is ignored);
  filters time/actor/action/resource/resourceId/result + pagination.

- audit: extract the shared audit.Wire projection (used by both the admin and
  org routes, one JSON contract) and add a ResourceID filter to audit.Query.

Tests: usage (pure roll-up/categorization + HTTP scoping/honest-zeros),
auditlog (real in-memory recorder: scope isolation, filters, pagination,
401/501), audit (ToWire + ResourceID). CGO_ENABLED=0 go build + go test green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-07 22:06:00 -07:00
hanzo-dev 0153f9a665 fix(controlplane): close the release+assign double-write sibling
Reviewing my own displacement fix adversarially: red tested release+reassign,
but a standalone release of a LIVE holder was still admitted, and after it the
shard is unowned — so release(victim) at H then assign(attacker) at H+1 puts a
second live writer on the shard (same class, pure policy, survives real crypto).

Fix: a LIVE holder's lease is immutable — releasable only when the holder is
proven dead (ErrUnauthorizedRelease), symmetric with reassign. Closes the whole
double-write class, not just red's two tested paths. + TestPolicy_ReleaseLiveHolderRefused;
TestRSM_DeterministicConvergence now marks the holder proven-dead (out-of-band)
before releasing. Suite green.
2026-07-07 22:02:16 -07:00
5b5df8fc8a clients/world: GDELT + allowlisted-RSS news data plane (/v1/world) (#169)
* treasury: anchor signs through a quorum-gateable seam, not a lone key

anchor_evm.go held the signer's private key in-process and did types.SignTx.
Decouple WHERE the key lives from the tx builder via a txSigner seam:

- keySigner  — the existing local KMS-provisioned key (default; unchanged result,
  proven byte-identical to types.SignTx).
- mpcSigner  — delegates the 32-byte EVM signing hash to a quorum-gated custody
  backend (the reserve's 3-of-5 treasury MPC wallet), bound via BindAnchorSigner
  (the finance seam). The bound signer wins over any local key.

submit() now hashes the tx, delegates the hash to the resolved signer, and
applies the recoverable signature — agnostic to single-sig vs threshold. Fails
closed when neither signer is available (never fabricates a signature).

Test proves both paths recover to the correct sender and the quorum signer is
invoked exactly once; the live ring is a config swap.

* feat(gpu): BYO-GPU worker uploads render outputs to the org gallery

After studio.render completes on the local GPU, the worker fetches each finished
output from the local studio (/view) and POSTs it to the org studio's /upload/output
with the user's IAM bearer — landing it in orgs/{org}/output (S3-mirrored to the
gallery). No S3/rclone credentials ever touch the box; the session token is the only
credential. Upload target resolves from input.uploadUrl, then HANZO_STUDIO_UPLOAD_URL,
then studio.hanzo.ai. Proven end-to-end against studio 0.14.9 (aud hanzo-console).

* feat(gpu): per-machine share policy — advertised on the fleet record, enforced at claim

A linked GPU can be shared to specific orgs/projects/job-types/models with limits via
ONE policy object on the machine record (SharePolicy). It rides in the fleet
registration (input.policy) and is enforced ONCE, at claim: a job outside the policy
is failed back so an eligible worker takes it. nil/zero policy = fully permissive
(unchanged behaviour). Loaded from HANZO_GPU_POLICY (inline JSON) or
HANZO_GPU_POLICY_FILE. Unit-tested (reject matrix + loader).

Server-side multi-org queue fanout + metering-to-org+project remain follow-ups; the
worker enforces its own policy today (workers still claim their own org's queue).

* feat(world): GDELT + allowlisted-RSS news data plane (clients/world)

First vertical slice of the World news backend in the unified cloud binary:

  GET  /v1/world/news       merged, filtered, freshest-first feed  -> {items:[…]}
  GET  /v1/world/pipeline   per-(org,project) pipeline config
  PUT  /v1/world/pipeline   upsert feeds + keyword/region/source filters
  GET  /v1/world/stream     SSE live refresh (ZAP-native, org+project scoped)

- Ports world/api/{gdelt-doc,rss-proxy}.js: GDELT 2.0 Doc artlist + host-
  allowlisted RSS/Atom (~180-domain SSRF allowlist, enforced at PUT boundary,
  at fetch time, and on redirect targets).
- Org/project isolation on every path (principal.Tenant/Project); SQLite
  pipelines table PK(org,project); in-memory TTL feed cache (10m).
- RegisterWithShutdown order 142; one blank-import line in subsystems.go.
- Tests: httptest-stubbed upstreams (deterministic/offline) + live-verified.

---------

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-07 22:00:29 -07:00
hanzo-dev 2b46815e88 harden(controlplane): quorum-safety assertion, honest cert-verify, domain-sep commit
Follow-up rails from the red pass + the cryptographer audit (all on top of the
class-A fixes):
- checkQuorumSafety(N,quorum,f) asserts N>=3f+1, quorum>=2f+1, 2q>N+f at cluster
  construction (fail closed). The 2q>N+f margin at N=7 is exactly 1 and is the
  whole basis of the no-fork property, so a future sizing change can never
  silently break safety. + TestSafety_QuorumParametersAreByzantineSafe.
- verifyOwnCertStructure: renamed the driver's structural self-composed cert
  check away from 'independent triple-gate verification' and documented that an
  external cert must go through the cryptographic VerifyUnderPolicy (increment-2),
  never this structural path (red #8).
- commitZ: domain-separate the z-share commitment by session + party
  (H(cp-commit||sid||party||z)) so a commitment cannot be replayed across
  sessions/parties (red #4 hardening).
- doc.go: record the red->blue outcome (no-fork core held; 4 class-A closed), the
  CLASS-B caveat (stub secrets are public-seed-derivable -> safety suite meaningful
  only under real crypto), and the increment-2 security worklist (distributed DKG,
  authenticated handoff + KMS fence, RSM-level authz re-verify, external-cert
  crypto verify, CI guard against -tags controlplane releases).

Suite green under -tags controlplane -race; default build unaffected.
2026-07-07 21:58:34 -07:00
hanzo-dev 505539652a fix(controlplane): close red's class-A byzantine findings (blue->red->blue)
Red found a CRITICAL double-write + 3 more class-A breaks (pure orchestration/
policy, survive real crypto) with failing exploit tests. All closed; red's 4
class-A tests now pass without weakening them; class-B (stub-crypto-forgeable)
deferred to the real-crypto increment with explicit t.Skip TODOs.

#1/#2 CRITICAL double-write (policy.go, placement.go): displacement of a LIVE
  shard writer now requires proven-death by out-of-band evidence. A proposer-
  written same-block release authorizes nothing (it is not holder consent), and
  membership removal no longer manufactures proven-dead. Fail-closed increment-1
  posture; authenticated graceful handoff + KMS fence are increment-2.
#3 HIGH barrier forgeable (driver.go, custody.go, signer.go, transport.go):
  Round1 commitments are now proof-of-possession authenticated exactly as Round2
  legs, so one node cannot forge a quorum of spoofed commitments to defeat
  commit-before-reveal.
#4 MEDIUM apply fork gate (rsm.go): RSM.Apply re-checks ParentRoot == the
  applied-state commitment, so a block that does not extend local state can never
  mutate it (defense-in-depth for a future recovery/gossip path).

Corrected TestPolicy_ShardReassign_WithRelease (it asserted the vulnerable
same-block-release-authorizes-displacement behavior) to assert the fix. Updated
rsm_test blocks to extend state properly (the new parent-root gate). Suite green
under -tags controlplane; default build unaffected (package is tag-gated).
2026-07-07 21:54:35 -07:00
f1b5ca8d05 wallets: add Safe (Gnosis-Safe) smart-wallet custody over the luxfi/mpc ring (#62) (#168)
New KindSafe custody composes the ring's TWO planes without importing luxfi/mpc:
- :9800 internal threshold API (mpcclient) — keygen the owner MPC EOA + owner-sign
- :8081 product API (new safeclient) — CREATE2 Safe deploy + EIP-712 Safe-tx propose

safeclient.go mints a SHORT-LIVED HS256 ring JWT (iss=mpc.lux.network, aud=mpc-api,
role=admin, org-scoped) hand-rolled (crypto/hmac, no jwt dep) from the ring's
MPC_JWT_SECRET — resolved from cloud's in-process KMS via
CLOUD_WALLETS_MPC_JWT_SECRET_REF, NEVER a plaintext env value. The deploy route is
role-gated (owner|admin), so role=admin clears it.

safeCustody.Provision: keygen (owner EOA) -> deploy Safe(owners=[EOA], threshold=1)
on the wallet's EVM chain (per-wallet, default Hanzo L1 36963); KeyRef encodes both
ring handles (<mpcWalletId>|<smartWalletId>); Address = predicted Safe contract.
Sign: owner-approval signature via :9800 (uniform /v1/wallets/:id/sign). New route
POST /v1/wallets/:id/safe-tx composes the ring propose (EIP-712 MPC-sign) via a
safeProposer capability type-assert (no Kind switch). Fails closed
(ErrMPCNotConfigured) until CLOUD_WALLETS_MPC_API_ADDR + the JWT secret are wired.

Tests: TestSafeCustody drives a stub emulating both ring planes (asserts the minted
JWT is HS256-valid with correct iss/aud/role/org) + TestSafeCustody_FailClosed.
go test ./clients/wallets/... green; CGO_ENABLED=0 go build ./cmd/cloud green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-07 21:45:19 -07:00
77ebb17c3b cloud: register /v1/wallets subsystem + unblock main (Config.Role collision) (#167)
Two coupled changes so main builds green AND the MPC custody surface is live:

1. Fix the broken build on main. #160 (CLOUD_ROLE writer/reader HA split) and
   #163 (Stage-0 control-plane inert config) each added a `Role` field to the
   SAME Config struct on separate branches; the merge left Config.Role
   redeclared (role.Role vs string) + a duplicate struct-literal key, so
   `go build ./cmd/cloud` failed (release lane stuck at v1.786.124). Rename the
   inert #163 field to ControlPlaneRole (env ROLE, consumed by nothing yet). The
   HA Role (role.Role, CLOUD_ROLE, used by serve.go/build.go) is unchanged.

2. Register the wallets subsystem. clients/wallets (#151/#161) was never blank-
   imported into subsystems.go, so its init() never ran and /v1/wallets was
   unrouted (404) despite the code shipping. Add the order-127 blank import so
   the accounts/wallets/custody/keys/sign surface mounts — KMS custody always
   on; mpc/treasury fail closed until CLOUD_WALLETS_MPC_ADDR +
   CLOUD_WALLETS_MPC_API_KEY_REF are wired. This is the seam the treasury anchor
   (#162 BindAnchorSigner) binds through.

Verified: CGO_ENABLED=0 go build ./cmd/cloud green; wallets + config tests ok;
local boot logs 'wallets mounted' (defaultCustody=kms) then 'listening', no panic.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-07 21:03:30 -07:00
hanzo-dev 2a00823fdc feat(controlplane): Stage-1 increment-1 byzantine ceremony driver + harness
FULL-BFT byzantine ceremony driver for the cloud control plane, built
against published luxfi interfaces (consensus v1.35.30 protocol/quasar +
protocol/quasar/pulsar PulsarRoundSigner, pulsar v1.9.0 pkg/pulsar). Behind
the controlplane build tag, NOT wired into serve.

- Real PulsarRoundSigner drives Round1/Round2/Finalize (canonical
  non-grindable nonce, canonical signer set, z aggregation, ConsensusCert).
- Ceremony driver over an abstract Transport: proposer -> per-voter Round1
  commitment -> ALL-Round1 barrier -> Round2 share -> >=2/3 legs -> compose
  triple-PQ QuasarCert -> independent QuasarCert.Verify -> fail-secure apply.
- One-pod-one-share custody; policy gate refuses invariant-violating blocks
  (shard-writer reassign without lease-release/proven-dead predecessor).
- In-process N=7 harness: happy path, liveness (drop2 finalize / drop3 SAFE
  HALT), safety (equivocation, one-pod-two-shares, rushing, dup/rogue legs).

Stubbed for later increments (drop-in seams): ZAP transport, KMS share
custody, NonceMPC pool, DKG keygen, and pulsar's unshipped SignatureCore +
PartialZVerifier crypto cores + ComposePolaris cert composition.

Supersedes PR #163 classical pins: drops the bft promotion.
go build/vet/test green (CGO_ENABLED=0); race-clean.
2026-07-07 18:15:28 -07:00
hanzo-dev eb829a3102 fix(config): resolve Role field collision from concurrent merge
Two PRs landed on main that both added a Config.Role field — the #160
HA writer/reader role (role.Role, load-bearing in Serve) and the Stage-0
control-plane role (string). The text-merge compiled to a duplicate field
and broke the default build. Rename the inert control-plane field to
ControlPlaneRole (env ROLE unchanged); the HA Role keeps its name and all
cfg.Role.IsReader()/String() consumers are untouched.
2026-07-07 18:12:41 -07:00
hanzo-devandGitHub db5bd0f4c6 feat(cloud): CLOUD_ROLE writer/reader HA split + de-alias ZapDB + writer-pin seam (#160)
* refactor(kms): de-alias badger→zapdb (the embedded store IS ZapDB)

clients/kms/kms.go imported the store as `badger "github.com/luxfi/zapdb"`.
The store is luxfi/zapdb — the canonical Lux embedded KV, a hardened Badger
fork whose Go package is still literally `package badger`. The alias made call
sites read like raw dgraph-io/badger. Rename the alias to `zapdb` so every call
site is self-documenting; behaviour is byte-identical (same package, same API).

Confirms the invariant: `grep -rn dgraph-io/badger` across cloud = 0. There is
no raw Badger anywhere; the one embedded store is ZapDB.

* feat(cloud): CLOUD_ROLE writer/reader split + read-only KMS reader + writer-pin

Introduces an explicit HA role so read replicas can be added WITHOUT ever
risking a second writer opening the RWO stores. Default is byte-identical to
today: unset CLOUD_ROLE ⇒ Writer ⇒ the single pod that owns the RWO PVC.

- role: CLOUD_ROLE ∈ {writer(default), reader}. Serve fails CLOSED on an
  explicitly-invalid value (a wrong guess demotes the real writer or risks a
  second one). Pure, tested, imports nothing from cloud.
- kms: Config.ReadOnly opens the ZapDB store READ-ONLY with the lock guard
  BYPASSED — a reader serves secrets off a restored replica and NEVER takes the
  exclusive write lock (the mechanism proven safe by luxfi/zapdb's
  WithReadOnly + BypassLockGuard; zapdb-replicate uses the same to coexist with
  a live writer). Reader with no restored store / no key fails closed. Tested
  round-trip: writer writes → reader reopens read-only → reads back; reader
  writes rejected.
- writerpin: the single-writer election seam. SingleWriter (production-correct
  for StatefulSet replicas:1) is the default; ConsensusPin (Quasar leaderless
  election) is an HONEST stub that fails closed with ErrNotImplemented rather
  than fabricating a pin. Tested.
- wiring: Serve resolves+logs the role and the backing pin; pickKMSClient opens
  KMS read-only for readers. Writer path unchanged.

NOT YET wired (reported for Red/CTO): consensus election (writerpin gates no
store-open yet — k8s guarantees the single writer); reader gating of the
audit chain / durable tasks / per-tenant SQLite (still open writable) — the KMS
reader path is the completed slice. Data replication runs as sidecars at the
manifest layer (hanzoai/replicate for SQLite, luxfi/zapdb-replicate for ZapDB),
not via in-process import.

* feat(ha): fail-closed reader write-guard + prove in-process KMS backup

ReaderGuard: one boundary middleware rejects mutating verbs on a Reader
(405), gating EVERY store (KMS+audit+tasks+SQLite), not just KMS's
read-only open — a mis-routed write can no longer silently persist to a
reader's ephemeral dir and vanish on restart (H4). No-op on a Writer.

replication_test: real *zapdb.DB writer streams incremental age-encrypted
db.Backup blocks WHILE live; reader Restores into its OWN separate dir —
refutes the C1 'second-process open fails' path and proves the producer.
Fail-closed test: no recipient => no block (never plaintext to S3).

* test(ha): reader-guard verb matrix + replication edge cases

ReaderGuard: GET/HEAD/OPTIONS reach the store, POST/PUT/PATCH/DELETE all
405 without reaching it; Writer path (guard unmounted) serves every verb.
replication: wrong-identity restore fails closed; restore requires manifest
+ identity (unhydrated store never serves empty); repeated/no-op/overwrite
backups restore to the exact latest value (chain-correctness invariant).

* test(config): align IAM single-replica test with staged-subsystem contract

The 'empty list -> iam-enabled' subtest predates IAM becoming a STAGED
subsystem (stagedSubsystems["iam"]=true): the empty-Enable mount-all
default deliberately does NOT mount IAM (it corrupts the shared Beego
global and crashes `ai` with SQLITE_CANTOPEN). So empty list is
iam-DISABLED and >1 replica is allowed; the guard fires only when iam is
EXPLICITLY enabled. Code was correct; the test asserted the pre-staging
behavior. Pre-existing red on main, unrelated to the HA change.
2026-07-07 17:55:06 -07:00
hanzo-devandGitHub 14782628ad feat(cloud): Stage 0 — control-plane deps + inert config (#163)
Promote the luxfi consensus stack (consensus v1.25.15, bft v0.1.5,
p2p v1.21.1, validators v1.2.0) from indirect to direct requires, and add
four INERT control-plane config fields. Zero behavior change, reversible.
Deps + inert config only — no engine imported/started, no routes, no
serve.go/build.go behavior change.

v1.25.15 is the minimal clean tag: it already carries NewBFT (consensus.go:168)
+ engine/bft, its graph pulls validators v1.2.0 (Manager), it requires exactly
pulsar v1.1.1 (which stays v1.1.1 — zero drift), and it is the MVS-selected
version, so promotion is a no-op to the compiled graph. A lower tag would
downgrade the whole build's consensus (behavior change); a higher tag drifts
pulsar + consensus code.

The four are held direct by controlplane_deps.go: blank imports behind the
never-set //go:build controlplane_deps tag, so nothing links into the binary.
go mod tidy keeps them direct (it reads all build tags); deleting the file
reverts them to indirect. NodeID/Peers/Role/ControlPlaneQuorum parse in
LoadConfig (NODE_ID/PEERS/ROLE/CONTROL_PLANE_QUORUM) but no subsystem reads them.

Architecture direction (proposed, not shipped): the control plane is designed
to run Quasar (post-quantum BFT, protocol/quasar Submit->Finalized) under a
strict-PQ cert profile with a Pulsar RoundSigner threshold signer.

tidy also corrected pre-existing drift on main (nats-io/nats.go indirect->direct
via clients/kafka/interop_test.go; pruned 7 superseded go.sum lines) — verified
identical on pristine origin/main.
2026-07-07 17:55:03 -07:00
hanzo-devandGitHub 9106c15b71 fix(cloud): correct luxfi/precompile go.sum hash + ZAP-only telemetry (drop plaintext OTLP fallback) (#165)
Red review findings:
- go.sum: luxfi/precompile v0.5.37 zip hash disagreed with sum.golang.org
  (h1:Yh3dJ+... vs authoritative h1:2v0z...) → cold-cache CI SECURITY ERROR.
  Corrected to the sumdb-vouched hash.
- telemetry.go: remove the plaintext OTLP-HTTP fallback (newTraceExporter) that a
  stray/standard OTEL_EXPORTER_OTLP_ENDPOINT could use to silently downgrade
  tenant-carrying trace spans to cleartext. ONE wire now: ZAP. Dropped the
  otlptracehttp import (also severs its transitive grpc pull) and the dead
  otlpEndpoint parameter. OTLP stays only the collector's interop receiver.
2026-07-07 17:54:54 -07:00
hanzo-devandGitHub 36ef38d08d test(billing): prove auto-routing bills as the resolved model at the edge (#166)
The ai subsystem serves a virtual `auto`/`zen-router` model that resolves to a
concrete model id before pricing/billing, meters its own token cost keyed on the
SERVED model, and reports it via the X-Routed-Model header. The cloud edge prices
/v1/ai/* by PATH (0, self-metered), never by the request model, so `auto` bills
as whatever it resolved to — and the edge passes X-Routed-Model through untouched.

- auto_routing_billing_test.go: TestAutoRoutingBillsAsResolvedModel (edge does not
  double-bill /v1/ai/* + header pass-through) and TestDefaultPriceAiPathModelAgnostic.
- AUTH_BILLING_CONTRACT.md §4a: document the binding.

No code change needed — cloud already meters ai from the subsystem's own usage
record (which keys off the resolved request.Model), so the edge binds correctly.
2026-07-07 17:52:02 -07:00
2f5d164b7d zaptrace: encode OTLP via zap2pb; drop direct google.golang.org/protobuf (#164)
Route ExportTraceServiceRequest wire encoding through github.com/zap-proto/zap2pb
(the sanctioned ZAP<->protobuf boundary) instead of importing
google.golang.org/protobuf {proto,encoding/protowire} directly. Wire bytes are
byte-identical (repeated ResourceSpans under field 1); TestUploadTracesOverZAP
still decodes the spans over the real ZAP transport.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-07 17:28:07 -07:00
2642264ceb treasury: anchor signs through a quorum-gateable seam, not a lone key (#162)
anchor_evm.go held the signer's private key in-process and did types.SignTx.
Decouple WHERE the key lives from the tx builder via a txSigner seam:

- keySigner  — the existing local KMS-provisioned key (default; unchanged result,
  proven byte-identical to types.SignTx).
- mpcSigner  — delegates the 32-byte EVM signing hash to a quorum-gated custody
  backend (the reserve's 3-of-5 treasury MPC wallet), bound via BindAnchorSigner
  (the finance seam). The bound signer wins over any local key.

submit() now hashes the tx, delegates the hash to the resolved signer, and
applies the recoverable signature — agnostic to single-sig vs threshold. Fails
closed when neither signer is available (never fabricates a signature).

Test proves both paths recover to the correct sender and the quorum signer is
invoked exactly once; the live ring is a config swap.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-07 16:30:19 -07:00
8a5e5f97bd wallets: reconcile mpc custody to the ring's internal threshold API (#161)
The prior mpcclient targeted a DECIDED-but-nonexistent dashboard route tree
(/v1/wallets/{id}/sign, /v1/treasury/*) authed with a hand-minted HS256 JWT.
The deployed luxfi/mpc ring's real, working server-to-server custody surface is
the internal threshold API (cmd/mpcd/main.go, :9800): POST /keygen + POST /sign,
gated on the static MPC_INTERNAL_API_KEY bearer token — the exact contract the
ring's own /sign handler documents for a custody adapter.

Reconcile cloud to that contract:
- mpcclient.go: keygen + sign over the internal API; static bearer key (KMS),
  no JWT/dependency; deterministic idempotency key per (org,wallet,digest).
- custody.go: mpc + treasury provision via keygen, sign via /sign with the
  wallet's EVM chain id; Rotate preserves the address (ring-managed shares).
  Treasury quorum governance moves to the finance policy layer over this same
  primitive (no separate ring route).
- wallets.go: CLOUD_WALLETS_MPC_API_KEY_REF (KMS ref of the bearer key).
- test: stub emulates the internal /keygen+/sign contract.

Feature-flagged: unset CLOUD_WALLETS_MPC_ADDR ⇒ mpc/treasury fail closed.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-07 16:20:12 -07:00
a126b2f53f feat(billing): populate per-product usage axis + server-side ?product=/?groupBy=product (#159)
The console per-product Metrics dashboard groups usage on metadata.product /
metadata.agent, but commerce RecordUsage persists only provider/model (no product
field), so the breakdowns rendered honest-empty even though every non-LLM product
already meters+gates per-org via ResourceMeter (provider=<product>, default fee
$1.00, fail-closed 402 on zero balance).

clients/billing/usage.go is the ONE read-side adapter: usage() injects a canonical
metadata.product onto each ledger row (agent->agents, provisioning->kind,
token-metered->inference, else provider) from the SAME charged ledger, and honors
the previously-ignored ?product=<id> (server-side filter) and ?groupBy=product
(per-product spend rollup {product,requests,amountCents}). A row already carrying
metadata.product/agent wins, so it degrades to a no-op once the meter/commerce
persist them natively (forward-compatible).

No change to what is charged or gated; the balance floor stays enforced by default.
scopedBillingQuery is extracted so proxy() and usage() build the subject boundary
one way. AUTH_BILLING_CONTRACT.md documents coverage + the native-field checklist.

Tests: productOf table + enrich/filter/group units + handler-level ?product= /
?groupBy=product through the real route (33 billing tests green).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-07 16:11:54 -07:00
4c01e7727c feat(cloud): embed the REAL hanzoai/console bundle; fail-hard Dockerfile (#158)
The console image stage now FAILS the build when build:embed does not emit a
real static bundle (non-empty out/index.html + out/_next), instead of silently
degrading to the committed fallback shell. A broken console export can no longer
ship the placeholder to prod. Escape hatch: --build-arg ALLOW_PLACEHOLDER=1 for
a pure-Go dev image with no Node console.

hanzoai/console build:embed produces a real 7.7M static export (361KB index.html
+ 4.3M _next chunks); //go:embed bakes it into the ONE cloud binary. Also drops
the last console2 references (repo is hanzoai/console).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-07 15:52:57 -07:00
hanzo-dev 4718ce2ff9 Merge branch 'feat/addon-fast-follows' 2026-07-07 15:11:12 -07:00
hanzo-dev 90bd8fda19 cloud: rename console2→console (one canonical name)
The frontend repo is hanzoai/console (console2 was renamed away). Kill the
dead name across the build path + source so there is one name, one way:
- Dockerfile: clone hanzoai/console.git; ARG CONSOLE_REPO / CONSOLE_REF
- Makefile: CONSOLE_DIR; webui + build-standalone targets
- config.go: drop dead console2.hanzo.ai from the ZAP-WS origin allowlist
- comments across clients/* reference the console repo + its TS modules by
  their real name

No behavior change beyond dropping one unused CORS origin. Root pkg builds.
2026-07-07 15:11:09 -07:00
hanzo-devandGitHub 993e556a4c provisioning: Red low fast-follows (rollback orphan-key, kv-auth + envtest proofs, datastore tag) (#156)
Red review = SHIP; these close the 4 cloud-side low findings so the PR lands
with no known edges.

low-1 (rollback atomicity): createDedicated's inject-failure branch now calls
  removeAddonURL BEFORE tearing the backend down. injectAddonURL is not atomic —
  a strategic-merge PATCH can LAND server-side yet still return err (dropped
  response / post-commit timeout); scrubbing the maybe-written <KIND>_URL first
  means a committed-but-errored inject can't leave the instance pointing at a
  deleted backend (a dangling DSN is worse than Base). Proven by
  TestDedicated_InjectPartialWriteRollsBackOrphanKey (fake now models the
  write-then-error partial failure; asserts inject THEN remove ran, key gone).

low-2 (kv fail-open corner): TestDedicatedKV_RequirepassEnforced boots the REAL
  ghcr.io/hanzoai/kv image with the exact engine.args + mounted requirepass
  config and asserts an UNAUTHENTICATED PING is REJECTED, then that default:<pw>
  authenticates — locking down the one corner where, if the image ignored the
  positional config, the instance would boot unauthenticated. Raw RESP over TCP
  (zero new client deps); gated on CLOUD_KV_SMOKE_IMAGE + docker so the default
  suite stays green, real in CI.

low-3 (strategic-merge sibling preservation): TestPatchAddonSecret_RealAPIServer
  runs the ACTUAL k8sOrchestrator addon methods against a REAL kube-apiserver
  (controller-runtime envtest) — inject KV_URL then SQL_URL => BOTH survive in
  .data; RemoveAddonSecretKey drops one, keeps the other; idempotent on absent
  key/Secret. Replaces the fake orchestrator's assumption with a server-proven
  fact. Gated on KUBEBUILDER_ASSETS (skip without envtest binaries). Adds
  controller-runtime v0.23.3 as a TEST-ONLY dep — pinned to the release that
  keeps k8s.io at v0.35.3 (NO production client-go bump).

low-4 (datastore tag symmetry): dedicated datastore image tag floating ':26' ->
  env("CLOUD_DEDICATED_DATASTORE_TAG", "26.2.3.2"), symmetric with sql/kv/docdb.
  A floating ':26' resolves to whichever datastore lineage (bridge vs fork, distinct
  data dirs) last pushed under it — a per-org instance must boot a deterministic
  image.

go build ./... green; go test ./clients/provisioning/... green (envtest PASS
against a live apiserver, kv-smoke skips without docker).

(cherry picked from commit 04d841c4906b58fa06b1bc407b55c97e6661f169)
2026-07-07 14:50:18 -07:00
963263a2f4 feat(o11y): wire native datastore metrics ingest into the embedded runtime (#157)
* feat(o11y): wire native datastore metrics ingest into the embedded runtime

Bumps hanzoai/o11y to the native datastore metrics driver and starts an
in-process ZAP metric receiver (clients/o11y/metrics.go) that writes metrics to
the datastore over upstream ch-go via o11y/pkg/datastoremetrics — no histogram
fork. Reuses the embedded runtime.TelemetryStore.ClickhouseDB() connection, so
the query plane (read) and metrics (write) share one datastore conn.

Opt-in + fail-soft: gated on O11Y_METRICS_ZAP_LISTEN, a no-op until set, errors
logged and swallowed so metrics ingest can never take the query plane down. This
unblocks retiring the standalone signoz-otel-collector metrics path once verified
(verify-then-cutover). CGO_ENABLED=0 build + vet + existing o11y/observe tests green.

* chore: re-pin o11y@main (native datastore metrics driver merged)

---------

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-07 14:27:47 -07:00
hanzo-dev c4bf31d865 provisioning: Red low fast-follows (rollback orphan-key, kv-auth + envtest proofs, datastore tag)
Red review = SHIP; these close the 4 cloud-side low findings so the PR lands
with no known edges.

low-1 (rollback atomicity): createDedicated's inject-failure branch now calls
  removeAddonURL BEFORE tearing the backend down. injectAddonURL is not atomic —
  a strategic-merge PATCH can LAND server-side yet still return err (dropped
  response / post-commit timeout); scrubbing the maybe-written <KIND>_URL first
  means a committed-but-errored inject can't leave the instance pointing at a
  deleted backend (a dangling DSN is worse than Base). Proven by
  TestDedicated_InjectPartialWriteRollsBackOrphanKey (fake now models the
  write-then-error partial failure; asserts inject THEN remove ran, key gone).

low-2 (kv fail-open corner): TestDedicatedKV_RequirepassEnforced boots the REAL
  ghcr.io/hanzoai/kv image with the exact engine.args + mounted requirepass
  config and asserts an UNAUTHENTICATED PING is REJECTED, then that default:<pw>
  authenticates — locking down the one corner where, if the image ignored the
  positional config, the instance would boot unauthenticated. Raw RESP over TCP
  (zero new client deps); gated on CLOUD_KV_SMOKE_IMAGE + docker so the default
  suite stays green, real in CI.

low-3 (strategic-merge sibling preservation): TestPatchAddonSecret_RealAPIServer
  runs the ACTUAL k8sOrchestrator addon methods against a REAL kube-apiserver
  (controller-runtime envtest) — inject KV_URL then SQL_URL => BOTH survive in
  .data; RemoveAddonSecretKey drops one, keeps the other; idempotent on absent
  key/Secret. Replaces the fake orchestrator's assumption with a server-proven
  fact. Gated on KUBEBUILDER_ASSETS (skip without envtest binaries). Adds
  controller-runtime v0.23.3 as a TEST-ONLY dep — pinned to the release that
  keeps k8s.io at v0.35.3 (NO production client-go bump).

low-4 (datastore tag symmetry): dedicated datastore image tag floating ':26' ->
  env("CLOUD_DEDICATED_DATASTORE_TAG", "26.2.3.2"), symmetric with sql/kv/docdb.
  A floating ':26' resolves to whichever datastore lineage (bridge vs fork, distinct
  data dirs) last pushed under it — a per-org instance must boot a deterministic
  image.

go build ./... green; go test ./clients/provisioning/... green (envtest PASS
against a live apiserver, kv-smoke skips without docker).

(cherry picked from commit 04d841c4906b58fa06b1bc407b55c97e6661f169)
2026-07-07 14:22:13 -07:00
db991f2520 feat(cloud): embed native PubSub (clients/pubsub :4222) + Kafka adaptor (clients/kafka :9092) — Lux-consensus/no-ZK, disabled-by-default, interop-verified (#155)
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-07 14:15:02 -07:00
hanzo-devandGitHub 0af9d16e17 provisioning: uniform on-demand add-on instance binding + <KIND>_URL injection (#154)
Extend the dedicated-instance strategy so all four on-demand data add-ons —
Hanzo KV / SQL / DocDB / Datastore — route through ONE mechanism, and bind an
enabled add-on to an app instance by injecting its DSN as <KIND>_URL into the
instance's addons Secret (disabling reverts to Base).

- store: additive instance column (idempotent ALTER, threaded through Resource/
  cols/scan/Insert) + ListByInstance(org,instance).
- dedicated: add sql (Datastore type=postgresql, POSTGRES_* env, PGDATA subdir)
  and kv (type=valkey, per-instance requirepass via a MOUNTED config Secret since
  the kv-server binary reads no password from env; DSN user=default). Engine
  gains adminUser/env/args/secretMount so the CR builder stays one code path.
- addon_inject: injectAddonURL/removeAddonURL + orchestrator PatchAddonSecret
  (strategic-merge, create-if-absent, key-preserving) / RemoveAddonSecretKey
  (JSON-merge delete, idempotent). Reloader annotation + rev bump on the Secret.
- create: instance bind field (validated); inject AFTER the row Insert as part of
  the atomic provision (rollback on failure). drop: revert to Base BEFORE tearing
  the backend down.
- sql/kv move off the shared-logical registry (each org OWNS its instance); the
  orphaned shared postgres/redis provisioners + pgx/go-redis direct deps removed.

Tests: instance column round-trip + ListByInstance isolation; sql/kv DSN + CR
shape; inject merges (second add-on never clobbers the first); un-bound create
skips injection; drop removes URL before teardown; inject failure rolls back the
whole provision. go build/vet/test green.
2026-07-07 14:08:35 -07:00
hanzo-dev 39c72422dc deps: bump hanzoai/ai -> isglobaladmin in /get-account (8e65b8c3)
Pulls ai's additive isGlobalAdmin field on /get-account so console
recognizes global admins. Pure dependency bump: re-pins re-tagged
luxfi/* modules from source (GOPRIVATE, sumdb-bypassed) after the
documented content-hash drift, prunes cloud.google.com/go/compute and
stale hanzoai/iam v1.31.16 (ai dropped the GCP SDK and requires iam
v1.31.17). go build ./... green (CGO_ENABLED=0).
2026-07-07 12:57:45 -07:00
636159755d o11y: embed in-process OTLP ingest (traces+logs) into cloud (#153)
Fold the standalone otel-collector Deployment into the unified cloud binary:
an in-process OpenTelemetry Collector accepts OTLP (grpc :4317, http :4318) and
writes spans+logs into the same ClickHouse datastore cloud already reads for the
o11y query plane (signoz_traces / signoz_logs, cluster insights). Consumers point
at cloud.hanzo.svc instead of otel-collector.hanzo.svc.

Trimmed, driver-compatible pipeline (reuses the signoz clickhouse exporters that
compile against cloud upstream clickhouse-go v2.44.0):
  otlp -> memory_limiter, resource(namespace=hanzo, env), batch
       -> clickhousetraces (traces), clickhouselogsexporter (logs)

- OFF by default (CLOUD_OTLP_INGEST_ENABLED); fail-soft; ShutdownFunc flushes.
- DSN via env (envprovider), never on disk; metrics self-telemetry off so only
  :4317/:4318 bind (no :9090 class clash).
- telemetry.go: add OTLP-HTTP exporter path so cloud can loop back to the
  in-process ingest at localhost:4318 (ZAP stays default/canonical).

DEFERRED: metrics pipeline (signozclickhousemetrics) needs SigNoz dd-sketch
ch-go fork (chproto.DD/Store/IndexMapping) that will not compile against cloud
upstream ch-go; metrics ingest stays on the standalone collector. See
clients/o11y/LLM.md.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-07 12:12:26 -07:00
hanzo-dev df60569260 Merge branch 'feat/gpu-engine-serve'
# Conflicts:
#	Makefile
#	clients/treasury/anchor_evm.go
#	clients/treasury/ledger/sqlstore/sqlstore.go
#	clients/treasury/treasury.go
#	clients/treasury/treasury_test.go
#	config_iam_replicas_test.go
#	go.mod
#	go.sum
#	subsystems/subsystems.go
2026-07-07 10:43:36 -07:00
616dde3a9c feat(wallets): configurable KMS/MPC/treasury custody subsystem (/v1/wallets/*) (#151)
One custody seam over three orthogonal signing backends selected per-wallet by
Kind:

- KindKMS  single-sig custody IN-PROCESS via the embedded luxfi/kms client
  (deps.KMS). The fully-exercised spine: a real secp256k1 key is generated, its
  private bytes sealed under the KMS envelope, and every Sign recovers to the
  wallet address. No network hop.
- KindMPC / KindTreasury custody DELEGATE over HTTP to the deployed luxfi/mpc
  cluster via a thin typed REST client (the clients/mpcseal precedent). cloud
  never imports github.com/luxfi/mpc. Unconfigured -> fail closed
  (ErrMPCNotConfigured); a signature is never fabricated.

Config seam: KMS always available; mpc/treasury built only when
CLOUD_WALLETS_MPC_ADDR is set and the HS256 JWT secret resolves from a KMS ref
(never a plaintext env). Per-tenant SQLite (org column on every row, every query
filtered by org). Finance seam (WalletForLedgerAccount) is a pure lookup only.

Tests (incl -race): KMS single-sig end-to-end (sig recovers to address, sealed
at rest, rotate changes address), per-tenant isolation, custody seam selects
backend (fail-closed mpc/400 unknown), mpc path wired against a faithful stub.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-05 20:48:59 -07:00
9e7d00ae42 test(identity): make global-admin gate test config-agnostic on adminOrg (#150)
Reconciles TestGlobalAdminGate_RequiresAdminOrgAndIsAdmin with the task #51 decision
to pin IAM_ADMIN_ORG to the operator org (hanzo). The assertions are unchanged — the
gate is owner==adminOrg AND isAdmin — but the comment/labels no longer editorialize
that owner==admin is the only valid adminOrg. adminOrg is deployment config; the test
pins it to "admin" hermetically and proves the two invariants that hold for ANY
adminOrg: isAdmin is required (a non-admin in the admin org gets nothing) and owner is
required (an admin of any OTHER org gets nothing). Renamed the different-org case off
"hanzo" (which prod now pins AS the admin org) to a neutral "globex" so it reads
unambiguously.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-05 20:34:43 -07:00
f0a05475d1 feat(treasury): per-tenant Base storage — one SQLite file per tenant, IAM-selected (#149)
The finance ledger of record ran on a single process-wide {DataDir}/treasury.db.
Select the store per request from the validated IAM owner instead, so every
tenant's books live on their OWN Hanzo Base file and one tenant's writes can
never appear in another's read.

- sqlstore.Manager: opens+caches one *Store per tenant (mutex-guarded map). The
  house/reserve ledger is one fixed file ({DataDir}/treasury.db, preserved — no
  migration of live reserve capital); customer ledgers are {DataDir}/finance/{slug}.db.
- tenantSlug: injective (never folds acme/ACME), path-traversal-guarded, reserves
  the house slug. Verbatim stem for a DNS-ish org, else a sha256 slug. Consumes the
  treasury's canonical hanzoai/sqlite opener (Open) — ledgercore's per-tenant opener
  is a test-only helper that would double-register the sqlite driver.
- treasury.Mount binds the ledger of record to the HOUSE store; myAccounts reads the
  caller's OWN per-tenant file (house scope still honours the Formance/Postgres opt-in).
- StorageDriver(): one place decides the driver — sqlite (default, what prod runs) or
  postgres (opt-in via FORMANCE_LEDGER_URL). Postgres option preserved, never the default.

Tests: per-tenant isolation (A's write never in B's read; distinct files; cache
identity; traversal stays in-dir; no case-fold) + default-driver=sqlite + opt-in
preserved. go build ./cmd/cloud green; ./clients/treasury/... green (incl -race).

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-05 20:05:46 -07:00
a3290f447e fix(config): stage IAM off mount-all (unblock release smoke) + lock global-admin gate (task #51) (#148)
* fix(config): stage IAM off the mount-all default — unblock the release boot smoke

Every cloud release since the IAM embed (#142) has failed its boot smoke and
the fleet stayed pinned to a pre-embed image (v1.786.110), so the treasury +
finance merges (#143/#144/#145/#147) never shipped.

Root cause (from the failed release smoke logs): with CLOUD_ENABLE unset the
binary mounts every registered subsystem, so iamsvc.Mount now runs
iamserver.InitEmbed(). In the smoke/Docker env InitEmbed panics opening its own
SQLite (IAM_DATA_DIR=/data/iam absent on the tmpfs) and is recovered to a
fail-closed 503 — but IAM and the ai subsystem are sibling casibase/casdoor
forks linked against the SAME beego module, so InitEmbed's half-initialised
shared process-global (web.BConfig / xorm adapter) then makes ai's own
bootstrap fail identically:

  iam  ERROR iamserver.InitEmbed: bootstrap panicked: unable to open database file (14)
  ai   INFO  ai: initializing runtime
  cloud: mount: mount ai: ai: bootstrap: unable to open database file (14)  -> SMOKE FAIL

This would crash api.hanzo.ai in prod too (CLOUD_ENABLE is unset there), not
just the smoke.

Fix: make IAM a STAGED subsystem — excluded from the empty-Enable mount-all
default, mounted ONLY when named in CLOUD_ENABLE. This is exactly the HIP-0106
staged-rollout contract iamsvc already documents ('operator adds iam to
--enable only after the fold is verified'), now enforced in code. It restores
the pre-#142 mount-all set (iamsvc is the only subsystem #142 added to it), so
the boot smoke goes green again; hanzo.id keeps being served by the standalone
iam pod until an explicit, verified cutover. Local mount-all boot now reaches
'listening' with iam 'subsystem disabled' and ai mounted clean.

pickIAMClient already falls back to the remote/disabled IAM client when
Enabled("iam") is false (build.go), which is current prod behaviour, so no
deps.IAM regression. One activation mechanism (the enable-list), one place.

* test(identity): lock global-admin = owner==adminOrg AND isAdmin

The cloud admin surfaces (incl. /v1/admin/treasury/*) grant global admin only
to a validated principal whose org IS the admin org AND whose token carries
isAdmin. This locks that invariant end-to-end through the real JWKS-validated
SanitizeIdentity boundary, with the two cases that matter for the treasury flip:

  - a hanzo-org ADMIN (owner=hanzo, isAdmin=true)  -> NOT global admin
  - a NON-admin in the admin org (owner=admin, isAdmin=false) -> NOT global admin

The sole global admin z@hanzo.ai is global admin because IAM promotes @hanzo.ai
into the admin org (owner==adminOrg), NOT because it lives in 'hanzo'. This test
is the guard proving the boundary must NOT be widened to owner==hanzo (e.g.
IAM_ADMIN_ORG=hanzo), which would elevate every hanzo-org admin to see all
tenants' finances. The gate stays owner==adminOrg AND isAdmin; the fix for z is
that its token carries owner=admin, never a wider gate.

---------

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-05 19:58:06 -07:00
hanzo-dev e09d12da7c build(hanzo): pure-Go recipe for cmd/hanzo so the one sqlite driver registers once
The hanzo CLI (cmd/hanzo) had no build target, so a naive
`go build ./cmd/hanzo` used the machine default CGO_ENABLED=1 and panicked
at init: "sql: Register called twice for driver sqlite".

Root cause: with CGO on, github.com/hanzoai/sqlite (the canonical Hanzo
driver, imported by ~15 clients/*/store.go) compiles its mattn/SQLCipher
backend and registers "sqlite"; the embedded upstream deps that import
modernc.org/sqlite directly (base/core, o11y, commerce/db, orm/db) register
"sqlite" a second time -> panic.

Fix: build cmd/hanzo the same pure-Go way cmd/cloud and the Dockerfile
already ship (CGO_ENABLED=0). hanzoai/sqlite's !cgo backend IS modernc, so
the fork and every modernc importer resolve to a single registration. This
extends the existing CGO_ENABLED?=0 policy (see Makefile header) to the new
binary instead of adding a second way to build; no dependency is dropped and
hanzoai/sqlite stays canonical.

  make hanzo   # -> ./bin/hanzo, pure Go, one 'sqlite' registration
2026-07-05 19:52:05 -07:00
hanzo-dev d376f5eed4 chore(deps): mark luxfi/crypto & luxfi/geth direct
clients/treasury/anchor{,_evm}.go (Phase 2 ledger-root anchor) import
luxfi/geth and luxfi/crypto directly, so they are no longer indirect.
go mod tidy result; no version change.
2026-07-05 19:52:04 -07:00
3c967bad51 feat(treasury): delegate the native ledger to ledgercore — ONE double-entry engine (#145)
Collapse the treasury's separately-written double-entry SQL onto ledgercore
(github.com/hanzo-fi/ledger) — the SAME engine the ledger's own store uses — so
there is exactly one double-entry implementation across the stack (church of
Rich Hickey: one double-entry value, not three places).

- Reimplement clients/treasury/ledger/sqlstore to back the ledger.Store/ledger.Tx
  port with ledgercore instead of hand-rolled treasury_postings SQL. The
  accounting truth — every balance and the reserve overdraw guard — is now
  ledgercore's (postings -> moves -> balances + hash-chained log, idempotency-key
  dedup, WithTx atomic read-then-write). The adapter only maps the treasury's
  vocabulary (int64 cents, Kind/Program/Ref key, signed-Posting Entry) onto it.

- KEEP the port/adapter seam: Open()'s signature is unchanged, so treasury.go and
  the Formance-HTTP opt-in are untouched — native (ledgercore) stays the default
  backend. The engine (ledger.go) and the on-chain Root are UNCHANGED: each Entry
  is round-tripped verbatim (as ledgercore transaction metadata), so the Root is
  byte-identical to the previous store's, independent of ledgercore's own postings.

- Policy (revenue-share bps) stays in a small side table — it is Hanzo config, not
  double-entry accounting, so it does not belong in the shared engine.

- Pin bun to v1.2.9 (replace): ledger-fi floors v1.2.18, which removed
  schema.Formatter/NewFormatter/Append that hanzoai/o11y still uses; ledgercore's
  compiled closure uses no v1.2.18-only API, so v1.2.9 satisfies both. hanzo-fi/ledger
  is pinned to the PR-3 branch commit until it merges.

Tests (all green, incl. -race): overdraw guard, at-most-once payout, snapshot
reconcile, scope isolation, and Tx rollback all pass unchanged against the
ledgercore-backed store. The whole cloud module builds under -mod=readonly, and
the treasury test binary links NO modernc driver (so it does not reintroduce the
"sqlite registered twice" panic).

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-05 19:14:26 -07:00
4329e17f6f feat(treasury): activate Hanzo L1 (36963) anchor — EIP-1559 gas + deploy tool (#147)
The 36963 coreth fee market pins a 25 gwei min base fee, so a legacy tx priced
at base+1 strands the moment the base fee ticks up. anchor_evm.go now submits a
DynamicFeeTx (1 gwei tip floor, 2x-base-fee cap) — proven accepted on-chain as a
type-2 tx.

Adds clients/treasury/cmd/anchorctl: a one-shot in-cluster tool that provisions
the KMS-held signer (key -> KMS, only the address printed), funds it from a
genesis account, deploys contracts/TreasuryAnchor.sol, and can send anchor(bytes32).
Includes the compiled TreasuryAnchor.bin (solc 0.8.26, optimizer 200, cancun).

Deployed live: contract 0x53141dF42DF13Aad0512f2F08c3E3216EEFac5F2, owner = signer
0x703D4227d58d0b6A20BD721c940CED170470f634 (KMS ref hanzo/treasury-anchor/TREASURY_ANCHOR_SIGNER_KEY).

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-05 19:11:05 -07:00
0cdf505599 fix(test): canonical make test-race — one sqlite registration under -race (#48) (#146)
The cloud registers the "sqlite" driver exactly once in every build mode EXCEPT
a naive `go test -race`: -race forces CGO=1, which links the fork's mattn
"sqlite" (github.com/hanzoai/sqlite) ALONGSIDE the embedded deps that import
modernc directly (ai/base/commerce/o11y/orm), so both register "sqlite" and the
binary panics at init ("sql: Register called twice for driver sqlite") — the
pre-existing failure in clients/{graph,kmssvc,o11y}.

`make test` (CGO=0) and `make test-cgo` (-tags sqlite_purego) already avoid this
by resolving the whole binary to modernc's single registration. This adds the
missing peer for the race detector: `make test-race` runs
`CGO_ENABLED=1 go test -race -tags sqlite_purego ./...` — CGO on for the race
instrumentation, but the fork forced to its pure-Go backend so mattn never
registers and "sqlite" is registered exactly once. The ONE way to race-test the
cloud.

Proof: `go test -race ./clients/o11y/` panics; `go test -race -tags sqlite_purego
./clients/{graph,kmssvc,o11y}/` all pass.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-05 18:42:18 -07:00
0c61a8c31f feat(finance): per-org /v1/finance/* projecting the commerce + treasury planes (#144)
The finance.hanzo.ai + console Finance surfaces render real per-org data
instead of preview stubs. This adds no billing system — it PROJECTS the two
that already exist (the commerce customer wallet + the treasury reserve fund)
into the @hanzo/finance-ui contract (USD cents, optional-safe), scoped to the
validated IAM owner.

clients/billing/finance.go — six commerce-projected reads, reusing this
package's commerceProxy + per-org subject-pinning (one commerce read path):
  GET /v1/finance/balance          commerce balance (holds -> pendingCents)
  GET /v1/finance/credits          commerce deposit rows (grants, positive)
  GET /v1/finance/usage?range=     commerce withdraw rows -> series+lines+total
  GET /v1/finance/invoices         honest empty (no invoice ledger exists yet)
  GET /v1/finance/payment-methods  commerce portal, masked to brand+last4
  GET /v1/finance/ledger?range=    commerce ledger -> signed per-org postings

clients/treasury/treasury.go — GET /v1/finance/treasury reshaped from the
reserve Report into the TreasurySummary shape (reserve/committed/available +
honest Hanzo L1 anchor); the transparency policy rides along additively.

Tenant isolation: org A never sees org B (per-org subject pinned server-side,
client cannot widen scope); payment methods re-masked defensively so a PAN can
never leak. Honest empty/typed shapes where a data source does not exist yet.

Tests: go test -race ./clients/billing/... ./clients/treasury/... green;
go build ./cmd/cloud green.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-05 14:19:36 -07:00
4d7344053b feat(treasury): native reserve fund + backed payouts + Formance ledger-of-record + Hanzo L1 anchor (#143)
* feat(treasury): native double-entry reserve fund + backed-payout seam (#treasury)

The platform's OWN fund/reserve accounting, one layer ABOVE the per-org commerce
credit ledger. A store-agnostic, cloud-decoupled double-entry engine
(clients/treasury/ledger) — the SEED of the native hanzoai/finance central ledger
(the Go replacement for the Formance stack) — plus a Base/SQLite adapter
(ledger/sqlstore) and the cloud client (clients/treasury).

Core (clients/treasury/ledger): accounts + balanced journal entries (Σ postings==0,
refused otherwise), ONE shared fund:reserve pool with per-program payout sinks,
revenue-share policy (bps, one place), and the reserve GUARD — a fund debit that
would overdraw is refused, atomically, so growth-loop payouts are backed capital not
unbounded minting. Zero cloud/zip/SQLite imports; persistence is the Store/Tx port,
so it lifts to hanzoai/finance as a directory move.

Surface: GET /v1/treasury (org transparency), GET /v1/admin/treasury (report +
journal + anchor), POST /v1/admin/treasury/{policy,sweep,seed,anchor} (global-admin).
treasury.Reserve(program,ref,memo,cents) is the ONE seam the 3 loops call: backed →
proceed to credit; not backed → honestly pending; unmounted → passthrough
(backward-safe). Idempotent by ref (at-most-once fund debit). ledger.Root commits the
whole journal for the Hanzo L1 anchor (Phase 2 wires the KMS-signed submit).

Tests (-race, green): double-entry balances, revenue-share accrual + per-period
idempotency, reserve guard (backed→blocked), at-most-once, concurrent no-overdraw,
admin gate, Reserve passthrough+enforced, sqlstore round-trip + tx rollback.

* feat(finance): Formance ledger-of-record backend + backed payouts + scope-aware /v1/finance/*

Adopt Formance as the ledger of record behind a ledger.Backend PORT, without
reimplementing double-entry: two adapters satisfy the port — the native Base/SQLite
engine (offline/default, ships the reserve fund today) and clients/treasury/formance
(a real HTTP client to the Postgres-backed Formance Ledger v2 API: world→fund accrual,
fund→payout debit, 400 INSUFFICIENT_FUND→not-backed=the overdraw guard Formance
enforces, reference→idempotency). Select by FORMANCE_LEDGER_URL — a config flip. Root
computed via a SHARED hash so the L1 anchor is backend-agnostic.

Back the growth-loop payouts: referrals/affiliates/authors now DEBIT the reserve fund
via the ONE treasury.Reserve seam before crediting the recipient wallet — fund down,
wallet up, reconciled. Not backed → honestly pending (referrals) or 402 + VoidPayout
restores pending (affiliates/authors). Idempotent by ref (at-most-once). Unmounted →
passthrough (backward-safe; existing loop suites stay green).

Scope-aware /v1/finance/* — ONE engine, three tenancy surfaces (admin/console/finance
product): tenant derived from IAM, house/reserve locked to global-admin under
/v1/admin/finance/*, per-org callers see ONLY their own org:<tenant>:* accounts.
GET /v1/finance/accounts (per-org; admin ?scope=house|?org=<t>). Storage tiers doc'd:
authoritative OLTP ledger (native/Formance) + ClickHouse OLAP projection over the same
o11y event stream (audit mirror — no second metering pipeline).

Tests (-race, green): Formance adapter (accrual+idempotency, debit guard+replay,
snapshot) via a fake Formance server; scope isolation (per-org never sees house);
backed-payout enforced+blocked+at-most-once; VoidPayout restores pending.

* feat(treasury): Phase 2 — Hanzo L1 (36963) ledger-root anchor (contract + luxfi/geth submit + KMS signer)

Make the off-chain books tamper-evident on the LIVE Hanzo L1 (verified running:
network/chainId 36963, hanzod-0 producing blocks, EVM at network-36963).

- contracts/TreasuryAnchor.sol: minimal immutable witness — owner-gated anchor(bytes32)
  appends a timestamped root + emits Anchored; latest()/count for cheap verification.
  No upgradeability, no token — one job.
- anchor_evm.go: real luxfi/geth submitter — dial → chainID/nonce/gasPrice → sign a
  LegacyTx (anchor(bytes32) call when TREASURY_ANCHOR_CONTRACT set, else a 0-value
  self-tx carrying the root) with types.SignTx → send → await receipt → persist. The
  signer key is provisioned from KMS (KMSSecret → env TREASURY_ANCHOR_SIGNER_KEY,
  ref TREASURY_ANCHOR_SIGNER_KMS_REF) — NEVER plaintext in code/manifest.
- ledger.Root/ComputeRoot: deterministic SHA-256 hash-chain over the whole journal +
  reserve, shared by both backends so the anchor is backend-agnostic. A change to any
  historical posting changes the root.
- POST /v1/admin/treasury/anchor submits when wired; else returns the root that WOULD
  be committed + the EXACT remaining step. GET /v1/admin/treasury shows last anchored
  root/tx/block + synced flag. Persisted across restart (treasury_anchor.json).

Honest status: the on-chain submit is COMPLETE + compiling + config-gated but NOT
driven live this pass — the node's external JSON-RPC is unreachable from the build
env and needs an operator to: deploy TreasuryAnchor on 36963, provision the KMS
signer (fund it), set TREASURY_ANCHOR_{RPC_URL,CONTRACT,SIGNER_KEY}. In-cluster the
cloud binary reaches hanzod-rpc-internal:9630, so it's one deploy-config away.

Builds green (cmd/cloud links luxfi/geth); tests -race green; gofmt clean.

---------

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-05 13:38:29 -07:00
hanzo-dev 16b85808aa feat(cli): hanzo engine install|serve|status
Manage a local hanzo-engine (the `hanzoai` OpenAI + Anthropic model server)
from the canonical `hanzo` CLI:
- install: runs the canonical install.sh / install.ps1 (single source of truth
  for platform detection + signature verification — no re-implementation).
- serve:  launches the installed binary (`hanzoai --port P run -m MODEL`);
  syscall.Exec on Unix so signals + exit code flow through.
- status: probes the local engine, reusing the /v1/models probe that
  `hanzo gpu connect --serve-engine` advertises with.
Tests cover wiring, ready/unreachable status, and binary discovery.
2026-07-05 13:27:21 -07:00
hanzo-dev 69508f550e feat(treasury): Phase 2 — Hanzo L1 (36963) ledger-root anchor (contract + luxfi/geth submit + KMS signer)
Make the off-chain books tamper-evident on the LIVE Hanzo L1 (verified running:
network/chainId 36963, hanzod-0 producing blocks, EVM at network-36963).

- contracts/TreasuryAnchor.sol: minimal immutable witness — owner-gated anchor(bytes32)
  appends a timestamped root + emits Anchored; latest()/count for cheap verification.
  No upgradeability, no token — one job.
- anchor_evm.go: real luxfi/geth submitter — dial → chainID/nonce/gasPrice → sign a
  LegacyTx (anchor(bytes32) call when TREASURY_ANCHOR_CONTRACT set, else a 0-value
  self-tx carrying the root) with types.SignTx → send → await receipt → persist. The
  signer key is provisioned from KMS (KMSSecret → env TREASURY_ANCHOR_SIGNER_KEY,
  ref TREASURY_ANCHOR_SIGNER_KMS_REF) — NEVER plaintext in code/manifest.
- ledger.Root/ComputeRoot: deterministic SHA-256 hash-chain over the whole journal +
  reserve, shared by both backends so the anchor is backend-agnostic. A change to any
  historical posting changes the root.
- POST /v1/admin/treasury/anchor submits when wired; else returns the root that WOULD
  be committed + the EXACT remaining step. GET /v1/admin/treasury shows last anchored
  root/tx/block + synced flag. Persisted across restart (treasury_anchor.json).

Honest status: the on-chain submit is COMPLETE + compiling + config-gated but NOT
driven live this pass — the node's external JSON-RPC is unreachable from the build
env and needs an operator to: deploy TreasuryAnchor on 36963, provision the KMS
signer (fund it), set TREASURY_ANCHOR_{RPC_URL,CONTRACT,SIGNER_KEY}. In-cluster the
cloud binary reaches hanzod-rpc-internal:9630, so it's one deploy-config away.

Builds green (cmd/cloud links luxfi/geth); tests -race green; gofmt clean.
2026-07-05 13:21:41 -07:00
hanzo-dev a49ff2a427 feat(gpu): engine.serve — a connected GPU serves hanzo-engine models
`hanzo gpu connect --serve-engine` advertises a local hanzo-engine (the OpenAI +
Anthropic model server on :1234) on the org fleet, alongside the existing
studio.render worker. The worker probes GET {engine-url}/v1/models, publishes the
endpoint + model list in its presence record, and prints (or with --register-provider
POSTs) the /v1/add-provider call that routes api.hanzo.ai model traffic to this GPU as
an OpenAI-compatible (Type=Local) provider.

- cli/gpu.go: --serve-engine/--engine-url/--engine-endpoint/--register-provider;
  probeEngine, refreshEngine, engineAdvertisement, capabilities, provider hint;
  `hanzo gpu status` shows the engine endpoint.
- clients/visor/fleet.go: byoWorker + fleetRegistration carry capabilities + engine;
  GET /v1/fleet/workers advertises the endpoint (additive, omitempty).
- docs/bring-your-gpu.md: Connect (BYO) vs Deploy (cloud) -> engine.serve + studio.render.
- tests: probe/advertise/registration + full stub-cloud round-trip (no model needed).

One fleet, two job types: engine.serve (model serving) + studio.render (diffusion).
2026-07-05 13:12:30 -07:00
hanzo-dev 419984145b feat(finance): Formance ledger-of-record backend + backed payouts + scope-aware /v1/finance/*
Adopt Formance as the ledger of record behind a ledger.Backend PORT, without
reimplementing double-entry: two adapters satisfy the port — the native Base/SQLite
engine (offline/default, ships the reserve fund today) and clients/treasury/formance
(a real HTTP client to the Postgres-backed Formance Ledger v2 API: world→fund accrual,
fund→payout debit, 400 INSUFFICIENT_FUND→not-backed=the overdraw guard Formance
enforces, reference→idempotency). Select by FORMANCE_LEDGER_URL — a config flip. Root
computed via a SHARED hash so the L1 anchor is backend-agnostic.

Back the growth-loop payouts: referrals/affiliates/authors now DEBIT the reserve fund
via the ONE treasury.Reserve seam before crediting the recipient wallet — fund down,
wallet up, reconciled. Not backed → honestly pending (referrals) or 402 + VoidPayout
restores pending (affiliates/authors). Idempotent by ref (at-most-once). Unmounted →
passthrough (backward-safe; existing loop suites stay green).

Scope-aware /v1/finance/* — ONE engine, three tenancy surfaces (admin/console/finance
product): tenant derived from IAM, house/reserve locked to global-admin under
/v1/admin/finance/*, per-org callers see ONLY their own org:<tenant>:* accounts.
GET /v1/finance/accounts (per-org; admin ?scope=house|?org=<t>). Storage tiers doc'd:
authoritative OLTP ledger (native/Formance) + ClickHouse OLAP projection over the same
o11y event stream (audit mirror — no second metering pipeline).

Tests (-race, green): Formance adapter (accrual+idempotency, debit guard+replay,
snapshot) via a fake Formance server; scope isolation (per-org never sees house);
backed-payout enforced+blocked+at-most-once; VoidPayout restores pending.
2026-07-05 13:08:24 -07:00
hanzo-dev 312d421e34 feat(treasury): native double-entry reserve fund + backed-payout seam (#treasury)
The platform's OWN fund/reserve accounting, one layer ABOVE the per-org commerce
credit ledger. A store-agnostic, cloud-decoupled double-entry engine
(clients/treasury/ledger) — the SEED of the native hanzoai/finance central ledger
(the Go replacement for the Formance stack) — plus a Base/SQLite adapter
(ledger/sqlstore) and the cloud client (clients/treasury).

Core (clients/treasury/ledger): accounts + balanced journal entries (Σ postings==0,
refused otherwise), ONE shared fund:reserve pool with per-program payout sinks,
revenue-share policy (bps, one place), and the reserve GUARD — a fund debit that
would overdraw is refused, atomically, so growth-loop payouts are backed capital not
unbounded minting. Zero cloud/zip/SQLite imports; persistence is the Store/Tx port,
so it lifts to hanzoai/finance as a directory move.

Surface: GET /v1/treasury (org transparency), GET /v1/admin/treasury (report +
journal + anchor), POST /v1/admin/treasury/{policy,sweep,seed,anchor} (global-admin).
treasury.Reserve(program,ref,memo,cents) is the ONE seam the 3 loops call: backed →
proceed to credit; not backed → honestly pending; unmounted → passthrough
(backward-safe). Idempotent by ref (at-most-once fund debit). ledger.Root commits the
whole journal for the Hanzo L1 anchor (Phase 2 wires the KMS-signed submit).

Tests (-race, green): double-entry balances, revenue-share accrual + per-period
idempotency, reserve guard (backed→blocked), at-most-once, concurrent no-overdraw,
admin gate, Reserve passthrough+enforced, sqlstore round-trip + tx rollback.
2026-07-05 12:48:24 -07:00
0c0326f8d4 feat(iam): embed IAM in the unified cloud binary (last binary-consolidation piece) (#142)
* feat(iam): embed IAM in the unified cloud binary as an in-process subsystem

Folds Hanzo IAM -- the identity provider serving hanzo.id (login/authorize/
token/jwks/userinfo, /v1/iam/* admin, OAuth2/OIDC, LDAP/RADIUS) -- into the
unified hanzoai/cloud binary as the LAST binary-consolidation piece
(HIP-0106: "one Go binary embeds IAM + KMS + o11y").

clients/iamsvc wraps IAM's own Beego runtime: iamserver.Init() runs the full
bootstrap without binding a listener, and web.BeeApp.Handlers is mounted
verbatim on cloud's zip.App at every prefix IAM owns (/v1/iam/*,
/.well-known/*, /login/oauth/*, /_/iam/*, /cas/*, /scim/*). No auth logic is
reimplemented -- the same controllers answer, so OAuth/OIDC semantics
(authorize clientId org-resolution, JWT audiences, SuperAdmin owner=="admin",
argon2id password hashing) are preserved byte-for-byte. Registered at order 50
(identity authority, mounts before dependents).

- go.mod: pin hanzoai/iam v1.28.12 -> v1.31.16 (latest; carries the
  authorize-login org-resolution fixes #95/#96 the operator SSO chain needs).
- subsystems.go: blank-import clients/iamsvc; IAM no longer "NOT fused in".

Auth-critical middleware interactions verified: /v1/iam/* prices to 0 in
DefaultPrice (ungated -- the M2M /v1/iam/oauth/token mint is never charged);
SanitizeIdentity strips only forgeable X-User-*/X-Org-* headers, never the
Authorization bearer or iam_session_id cookie IAM's session/oauth logic reads.

Activation is STAGED via the enable-list gate: "iam" is NOT added to the live
--enable until IAM config is present in the cloud runtime and the fold is
verified (login/authorize/token/jwks + operator SSO chain). The standalone iam
pod keeps serving hanzo.id via ingress until then.

Build gate: CGO_ENABLED=0 go build ./... && go test . green. clients/iamsvc
tests prove registration (order 50) + full-path preservation through the mount.

* fix(iam): red-review — embed-mode bootstrap, fail-closed, single-replica guard

Addresses the red review of cloud#142 (mount mechanism approved; activation
blocked on standalone-only side effects in the wrapped entrypoint).

1. [HIGH] Embed-mode bootstrap. iamsvc now calls iamserver.InitEmbed (new in
   iam v1.31.17) instead of the standalone Init: skips StopOldInstance
   (lsof/SIGKILL — panics on distroless, kills a co-resident on shared netns),
   skips LDAP/RADIUS listeners (RADIUS binds unmanaged UDP with an empty shared
   secret), skips export/os.Exit, binds no listener. Standalone hanzo iam / iamd
   is byte-for-byte unchanged (Init delegates to the same shared bootstrap with
   every flag on). Also covers [MED] #3 — directory listeners never start
   in-process.

4. [MED] Fail-closed, not fail-loud. InitEmbed returns an error (recovers
   bootstrap panics); a broken/misconfigured IAM degrades THIS subsystem to a
   503 fail-closed on every IAM prefix (mountFailClosed) — every co-resident
   subsystem (KMS, o11y) stays up. Mirrors the KMS "no master key -> health-only"
   blast-radius isolation.

2. [HIGH] Single-replica enforcement. Embedded IAM uses Beego's process-local
   "memory" session store. Config.Validate now REFUSES to boot iam-enabled above
   CLOUD_REPLICAS=1 (a real runtime guard, not convention); the helm chart pins
   replicas=1 + injects CLOUD_REPLICAS whenever "iam" is in --enable.

5. [MED] Bump verified iam v1.31.16 -> v1.31.17. The slim-JWT change keeps every
   claim cloud reads (owner, isAdmin, email, name kept; aud is a registered
   claim, untouched) — IdentityMiddleware unaffected. authz v1.10.4 policy-API
   swap is IAM-internal (cloud builds green, no direct use). redirect_uri
   exact-match + AutoSignin CC-JWT normalization are version-skew CUTOVER gates:
   version-match the standalone pod + verify registered redirect_uris are exact
   before adding "iam" to the live --enable (runtime-data checklist, not code).

Tests (CGO_ENABLED=0):
- TestIAMEmbedBehindMiddlewareChain — unauth POST /v1/iam/oauth/token, /login,
  jwks + /login/oauth/authorize return 2xx through the REAL SanitizeIdentity +
  BillingGate chain (never 402/503); forged X-User-IsAdmin is stripped; a priced
  control path is denied at zero balance (proves the gate is engaged).
- TestDefaultPriceExemptsIAM — every IAM prefix prices to 0.
- TestValidateIAMSingleReplica — iam + replicas>1 refused; 1/unset/off ok.
- TestMountFailClosed503 — the fail-soft path serves 503 on every IAM prefix.

Build+test green; standalone hanzo iam still links; helm renders replicas=1 for
iam-enabled, replicaCount otherwise. Depends on iam v1.31.17
(hanzoai/iam#feat/iam-embed-entrypoint). STILL STAGED — the standalone iam pod
serves hanzo.id until red GREEN + runtime e2e.

---------

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-05 12:21:42 -07:00
hanzo-dev cdaee191f0 fix(iam): red-review — embed-mode bootstrap, fail-closed, single-replica guard
Addresses the red review of cloud#142 (mount mechanism approved; activation
blocked on standalone-only side effects in the wrapped entrypoint).

1. [HIGH] Embed-mode bootstrap. iamsvc now calls iamserver.InitEmbed (new in
   iam v1.31.17) instead of the standalone Init: skips StopOldInstance
   (lsof/SIGKILL — panics on distroless, kills a co-resident on shared netns),
   skips LDAP/RADIUS listeners (RADIUS binds unmanaged UDP with an empty shared
   secret), skips export/os.Exit, binds no listener. Standalone hanzo iam / iamd
   is byte-for-byte unchanged (Init delegates to the same shared bootstrap with
   every flag on). Also covers [MED] #3 — directory listeners never start
   in-process.

4. [MED] Fail-closed, not fail-loud. InitEmbed returns an error (recovers
   bootstrap panics); a broken/misconfigured IAM degrades THIS subsystem to a
   503 fail-closed on every IAM prefix (mountFailClosed) — every co-resident
   subsystem (KMS, o11y) stays up. Mirrors the KMS "no master key -> health-only"
   blast-radius isolation.

2. [HIGH] Single-replica enforcement. Embedded IAM uses Beego's process-local
   "memory" session store. Config.Validate now REFUSES to boot iam-enabled above
   CLOUD_REPLICAS=1 (a real runtime guard, not convention); the helm chart pins
   replicas=1 + injects CLOUD_REPLICAS whenever "iam" is in --enable.

5. [MED] Bump verified iam v1.31.16 -> v1.31.17. The slim-JWT change keeps every
   claim cloud reads (owner, isAdmin, email, name kept; aud is a registered
   claim, untouched) — IdentityMiddleware unaffected. authz v1.10.4 policy-API
   swap is IAM-internal (cloud builds green, no direct use). redirect_uri
   exact-match + AutoSignin CC-JWT normalization are version-skew CUTOVER gates:
   version-match the standalone pod + verify registered redirect_uris are exact
   before adding "iam" to the live --enable (runtime-data checklist, not code).

Tests (CGO_ENABLED=0):
- TestIAMEmbedBehindMiddlewareChain — unauth POST /v1/iam/oauth/token, /login,
  jwks + /login/oauth/authorize return 2xx through the REAL SanitizeIdentity +
  BillingGate chain (never 402/503); forged X-User-IsAdmin is stripped; a priced
  control path is denied at zero balance (proves the gate is engaged).
- TestDefaultPriceExemptsIAM — every IAM prefix prices to 0.
- TestValidateIAMSingleReplica — iam + replicas>1 refused; 1/unset/off ok.
- TestMountFailClosed503 — the fail-soft path serves 503 on every IAM prefix.

Build+test green; standalone hanzo iam still links; helm renders replicas=1 for
iam-enabled, replicaCount otherwise. Depends on iam v1.31.17
(hanzoai/iam#feat/iam-embed-entrypoint). STILL STAGED — the standalone iam pod
serves hanzo.id until red GREEN + runtime e2e.
2026-07-05 11:36:15 -07:00
hanzo-dev a4a1eb8b6c refactor(o11y): zaptrace ships OTLP over ZAP with no gRPC dep
The ZAP-native trace exporter marshaled its payload via the generated
otlp/collector/trace/v1.ExportTraceServiceRequest, whose sibling
trace_service_grpc.pb.go (no build tag) drags google.golang.org/grpc into the
graph — contradicting the exporter's own contract (ZAP wire, never gRPC).

Encode the ExportTraceServiceRequest envelope directly from the grpc-free trace
messages with protowire: it is a single 'repeated ResourceSpans resource_spans
= 1', so appending each ResourceSpans under field 1 is byte-identical to the
generated marshaler (proven by the existing round-trip test, which still decodes
with the canonical collector type). go list -deps ./zaptrace now shows no grpc.
Hanzo services speak ZAP/HTTP/WS, never gRPC.

Caveat: the cloud module still pulls google.golang.org/grpc transitively via
hanzoai/ai (sibling-owned), hanzoai/o11y (embedded SigNoz — intrinsically an
OTLP/gRPC collector) and hanzoai/base (GCS gRPC transport). Not removable by a
cloud-local change; tracked separately. go.sum: incidental tidy prune of stale
vfs/age checksums.
2026-07-05 11:19:32 -07:00
hanzo-dev 8ded07e5a1 feat(iam): embed IAM in the unified cloud binary as an in-process subsystem
Folds Hanzo IAM -- the identity provider serving hanzo.id (login/authorize/
token/jwks/userinfo, /v1/iam/* admin, OAuth2/OIDC, LDAP/RADIUS) -- into the
unified hanzoai/cloud binary as the LAST binary-consolidation piece
(HIP-0106: "one Go binary embeds IAM + KMS + o11y").

clients/iamsvc wraps IAM's own Beego runtime: iamserver.Init() runs the full
bootstrap without binding a listener, and web.BeeApp.Handlers is mounted
verbatim on cloud's zip.App at every prefix IAM owns (/v1/iam/*,
/.well-known/*, /login/oauth/*, /_/iam/*, /cas/*, /scim/*). No auth logic is
reimplemented -- the same controllers answer, so OAuth/OIDC semantics
(authorize clientId org-resolution, JWT audiences, SuperAdmin owner=="admin",
argon2id password hashing) are preserved byte-for-byte. Registered at order 50
(identity authority, mounts before dependents).

- go.mod: pin hanzoai/iam v1.28.12 -> v1.31.16 (latest; carries the
  authorize-login org-resolution fixes #95/#96 the operator SSO chain needs).
- subsystems.go: blank-import clients/iamsvc; IAM no longer "NOT fused in".

Auth-critical middleware interactions verified: /v1/iam/* prices to 0 in
DefaultPrice (ungated -- the M2M /v1/iam/oauth/token mint is never charged);
SanitizeIdentity strips only forgeable X-User-*/X-Org-* headers, never the
Authorization bearer or iam_session_id cookie IAM's session/oauth logic reads.

Activation is STAGED via the enable-list gate: "iam" is NOT added to the live
--enable until IAM config is present in the cloud runtime and the fold is
verified (login/authorize/token/jwks + operator SSO chain). The standalone iam
pod keeps serving hanzo.id via ingress until then.

Build gate: CGO_ENABLED=0 go build ./... && go test . green. clients/iamsvc
tests prove registration (order 50) + full-path preservation through the mount.
2026-07-05 11:02:49 -07:00
1723c22aaf feat(authors): native /v1/authors OSS-author deploy-royalty loop over the commerce ledger (#141)
The THIRD growth loop next to referrals (one-time credit) and affiliates
(partner commission): pays open-source AUTHORS a royalty on the metered platform
spend of orgs who DEPLOY their projects on Hanzo. Mirrors clients/affiliates
exactly — one SQLite store, server-side tenant isolation, one Mount (HIP-0106),
the SAME commerce ledger path (a credits payout is a grant, tag grant:author),
and an at-most-once accrual latch.

Flow: connect GitHub (IAM-linked account or supplied login) → verify repo
ownership (OAuth admin-check OR a hanzo.json verify-code file) → a deploy of a
verified author repo by ANY org is recorded (provenance) → sweep accrues 5% of
that org's month-to-date spend, at-most-once per (author, deploying-org, period),
self-deploys excluded → staff pay out as credits (real grant) or cash (record-only),
never exceeding pending.

Surface: GET /v1/authors, POST /v1/authors/{connect,repos/verify,deploys/record};
GET /v1/admin/authors, POST /v1/admin/authors/{sweep,:id/approve,:id/suspend,:id/payout}.

10 tests, all -race green: repo canonicalization, both verify methods, deploy
attribution + idempotency, spend×share accrual + at-most-once, lazy dashboard
sweep, credits-one-grant/cash-record-only/pending-guard payout, admin gate.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-05 06:11:55 -07:00
1828f4fc26 feat(affiliates): native /v1/affiliates partner-commission loop over the commerce ledger (#140)
Mirrors clients/referrals: one SQLite store, server-side tenant isolation, one
HIP-0106 Mount, admin surface global-admin-gated + enveloped for the console
proxy. Affiliates earn an ONGOING commission (default 20%) on the metered spend
of the customers they refer — the recurring, partner-revenue growth loop beside
referrals' one-time both-sides credit.

- apply (org) -> status applied; staff approve mints the code (vanity opt-in,
  uniqueness-enforced, else a derived slug) + sets the rate.
- attribute (?aff capture) records referred_org->affiliate (first-touch, one per
  referred org, self blocked; approved affiliates only).
- accrual sweep: commission = referred org spend this period x rate, latched
  at-most-once per (affiliate, referred_org, period) in one txn; also lazy on the
  affiliate's own dashboard read.
- payout: a credits method issues a commerce grant (tag grant:affiliate); cash
  methods are record-only; can never exceed pending (accrued - paid), reserved
  atomically before any grant.

Tests (go test -race, 9 green): apply->approve, vanity uniqueness (409),
accrual = spend x rate, idempotent-per-period sweep, payout-as-credits issues one
grant + cash record-only + pending guard, admin gate 403, attribution
self/unknown/first-touch, Mount.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-05 05:34:03 -07:00
81150fece9 feat(referrals): native /v1/referrals viral loop over the commerce ledger (#139)
* feat(referrals): native /v1/referrals viral loop over the commerce ledger

Per-org referral program mirroring clients/crm's structure (one SQLite store,
server-side tenant isolation, HIP-0106 Mount). Grants promo credit through the
SAME commerce deposit path as clients/admin.grantCredit (trial/Credit bucket,
tag grant:referral).

- Stable deterministic referral code per org (base32 of a hash of the org id) +
  a white-labeled ?ref link; persisted directory for O(1) reverse lookup.
- POST /v1/referrals/claim: record referrer<->referee (referee = validated
  caller), status signed_up. Self-referral blocked, one-per-referee idempotent
  (first-touch wins).
- Qualify signal = referee metered spend (honest 'actually used the product').
  On qualify, grant BOTH sides: referrer +$10, referee +$5. At-most-once via a
  credited_at latch — no sweep and no concurrent read can double-pay.
- Trigger: lazy on the referrer's GET /v1/referrals + POST /v1/admin/referrals/
  sweep (cron path). GET /v1/admin/referrals directory, both global-admin gated.
- Constants (bonus amounts + ledger tag) in one place. Commerce behind an
  interface for testable double-grant/idempotency proofs.

Tests: code derivation, self-ref block, idempotent claim, qualify->double-grant
with balances moving through the (fake) ledger, at-most-once idempotency, lazy
qualify on read, admin gate + directory, real Mount. All green (go test -race).

* feat(referrals): envelope the /v1/admin/referrals surface for the console admin proxy

---------

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-05 05:01:58 -07:00
hanzo-dev 11a3ae664b refactor(org): use the shared hanzoai/vfs/replica — kill the duplicate Replicator+election
cloud's internal/org was the ORIGIN of the HA-SQLite machinery; it's now promoted to the
shared hanzoai/vfs/replica lib that every service adopts. Delete the duplicated impls
(replica.go Replicator+Store+DB+DBPath, owner.go Member+Owner+IsOwner+Replicas+HRW) and
re-export them as aliases (shared.go). cloud-specific pieces stay: membership.go (live IAM
source), cipher.go (KMS envelope — already satisfies replica.Cipher), vfsstore.go (Store over
deps.VFS, now using the exported replica.Version). One and one way: ONE Replicator + election,
in vfs/replica, used by cloud AND visor. Builds + org tests green (vfs v0.6.2).
2026-07-05 00:43:11 -07:00
da0aaa677d fix(deps): bump hanzoai/ai v1.800.7 → v1.800.9 (zen context length → unblocks console chat) (#138)
Zen models were capped at the 4096 fallback in getContextLength, so every
console chat (grounded assistant ~4190-token system prompt) 402'd
'exceeds maximum token count: 4096'. v1.800.9 special-cases the zen* prefix
to 131072. Fixes the P0 console-chat gate.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-05 00:23:17 -07:00
hanzo-devandGitHub 557ec293e6 Merge pull request #137 from hanzoai/fix/o11y-embed-metrics-port-9090
fix(o11y embed): disable runtime :9090 self-metrics reader — crash-loop guard
2026-07-04 23:15:42 -07:00
hanzo-dev 3d448ebfb6 o11y embed: disable the runtime's :9090 self-metrics reader (crash-loop guard)
The embedded o11y runtime's OTel instrumentation defaults to a Prometheus pull
reader bound to 0.0.0.0:9090 (pkg/instrumentation) — the SAME port as cloud's
health listener (CLOUD_HEALTH_LISTEN=:9090). Activating the embed therefore made
the whole cloud process crash-loop with 'listen tcp :9090: bind: address already
in use' (verified on the canary), taking down all of api.hanzo.ai — a listener the
standalone o11y pod never contended for.

buildEmbeddedHandler now defaults O11Y_INSTRUMENTATION_METRICS_ENABLED=false (via
setenvDefault, operator-overridable to a free port) before construction. Cloud owns
process-level observability (exports its own OTel telemetry), so the embed serves
/v1/o11y in-process without a second metrics listener. Extracted the env defaults
into applyEmbedEnvDefaults + TDD (guard + operator-override).

Verified on the cloud-unified-canary: with this default the .104 embed goes Ready
and serves /v1/o11y in-process (health 200); without it the pod crash-loops on :9090.
CGO=0 go build/test green.
2026-07-04 23:15:22 -07:00
hanzo-devandGitHub bb5c0b9481 Merge pull request #136 from hanzoai/feat/o11y-embed-mainbased
o11y embed: shared community.NewServer (DRY) + health-exempt gate; o11y v1.5.0
2026-07-04 22:46:06 -07:00
hanzo-dev 358492a0aa o11y embed: use shared community.NewServer (DRY); exempt health from the gate
Refactor clients/o11y/embed.go onto o11y v1.5.0's shared builder community.NewServer
+ community.NewConfig — the EXACT construction the standalone o11y pod runs — so
the in-process runtime cannot drift from the pod's auth (pkg/identn/iamidentn,
Hanzo IAM gateway-header identity). Collapses the duplicated ~70-line signoz.New
factory list (drift risk) to one call. Enable signal now reads the flat operator
knob O11Y_DATASTORE_DSN (what the pod sets), falling back to the structured
O11Y_TELEMETRYSTORE_DATASTORE_DSN.

Exempt liveness/readiness paths from gate(): the runtime serves them without
identity (k8s probes pass that way), so gating them only breaks unauthenticated
health probes (admin System Health CLOUD_O11Y_HEALTH_URL, the external o11y.*
hosts) without protecting anything. Data routes stay gated (RED forge test still
403s /v1/o11y/api/v1/query_range).

go.mod: o11y v1.4.1 -> v1.5.0 (identical go.mod hash — no new transitive deps).
Build-gate: CGO_ENABLED=0 go build ./... = 0, go test . = ok, go test ./clients/o11y = ok.
2026-07-04 22:44:04 -07:00
zandGitHub d9b8087093 Merge pull request #135 from hanzoai/feat/o11y-embed-main-iamidentn
feat(o11y): embed the MAIN-based runtime (iamidentn) — auth matches the pod
2026-07-04 22:30:16 -07:00
hanzo-dev 53a5468cea feat(o11y): embed the MAIN-based runtime (iamidentn) — auth now matches the pod
The #133 embed pinned o11y v1.3.13, whose runtime authenticates via o11y-native
JWT (tokenizer.GetIdentity on the Authorization bearer). The live gateway-header
traffic the standalone o11y:0.2.0 pod serves — identity injected as X-Org-Id/
X-User-Id/X-User-Email by the gateway — would 401 against that. So activating the
v1.3.13 embed could not replace the pod.

This repoints the embed to the MAIN o11y line (v1.4.1), which resolves identity
through the IdentN resolver's iamidentn provider (default-enabled) from those
gateway session headers, with iamauthz (Hanzo IAM Casbin) for authorization —
the SAME auth model as the running pod. Gateway-header traffic authenticates
(200), not 401.

- clients/o11y/embed.go: build the runtime via pkg/signoz.New with the SAME
  provider factories the standalone cmd/community server uses (noop zeus,
  licensing, gateway, auditor, meterreporter; iamauthz; ClickHouse
  telemetrystore; sqlite sqlstore; IdentN to iamidentn), then app.NewServer to
  server.PublicHandler (new accessor, o11y v1.4.1). runtime.Start runs the
  registry background services (incl. the ruler/alert-rule-manager)
  non-blocking; we never call server.Start (cloud owns its HTTP listeners; OpAMP
  stays out-of-process). Gate/proxy-fallback structure (clients/o11y/o11y.go) is
  unchanged: still O11Y_TELEMETRYSTORE_DATASTORE_DSN-gated, still fail-soft to
  the reverse proxy.
- go.mod: hanzoai/o11y v1.3.13 to v1.4.1 (main line, iamidentn). Drop the stale
  replace prometheus/alertmanager to hanzoai/alertmanager v0.28.2 — it forced
  o11y's code onto the old fork whose api/v2 returns hanzoai/common types that
  clash with o11y v1.4.1's upstream prometheus/common structs. o11y v1.4.1 (and
  the pod) build against upstream prometheus/alertmanager v0.31.1; cloud has no
  direct alertmanager import, so it now matches.

Telemetry backend (ClickHouse datastore StatefulSet, cluster insights) is
untouched — the embedded runtime queries it over ClickHouse-native :9000.

Build-gate: CGO_ENABLED=0 go build ./... OK; go test ./clients/o11y/... OK; vet OK.
2026-07-04 22:22:49 -07:00
hanzo-devandGitHub a8d7fdd35d Merge pull request #134 from hanzoai/bump/tasks-v1.49.0
chore(deps): bump hanzoai/tasks v1.48.0 -> v1.49.0 (durable social primitives + gated auth)
2026-07-04 21:43:17 -07:00
hanzo-dev a21ebbbf6d chore(deps): bump hanzoai/tasks v1.48.0 -> v1.49.0
v1.49.0 adds the durable workflow primitives social-orchestrator needs to run
on cloud's embedded gated engine (ServeGated :9999): signal-to-running-workflow
re-dispatch, continueAsNew, startChild, typed search attributes, workflowId
conflict policy, and the signalWithStart wire fix. No cloud code change — the
embedded engine + gated listener pick up the fixes on rebuild.
2026-07-04 21:42:56 -07:00
hanzo-devandGitHub 555911ea07 feat(o11y): embed the o11y runtime in-process; retire the proxy path (#133)
Constructs the ONE hanzoai/o11y runtime IN-PROCESS (clients/o11y/embed.go) —
the SAME bootstrap the standalone cmd/server runs (o11y.New with its provider
factories -> app.NewServer -> server.PublicHandler) — and installs it via
o11y.SetHandler, so /v1/o11y/* is served by THIS binary against the ClickHouse
`datastore` (StatefulSet, cluster insights) instead of reverse-proxying a
standalone o11y Deployment. The standalone o11y pod can now retire; the
ClickHouse datastore stays as the telemetry backend.

- clients/o11y/embed.go: buildEmbeddedHandler wires telemetrystore (ClickHouse/
  datastore), sqlstore (sqlite under cloud's data root), querier, dashboards,
  alerts; starts the registry services + the alert rule manager (StartBackground).
  Enabled by O11Y_TELEMETRYSTORE_DATASTORE_DSN (the DSN is the one knob).
- o11y.go Register callback: prefer the in-process runtime; fall back to the
  reverse proxy when the embed is disabled (no DSN) or fails to init — fail-soft,
  zero downtime. Proxy handler + gate + tests retained for the fallback path.
- Bump hanzoai/o11y v1.3.12 -> v1.3.13 (adds Server.PublicHandler + StartBackground).
- Drop the stale `replace gorilla/mux => containous/mux`: it was a copied Traefik
  replace block; Traefik is not in the graph and nothing calls the containous API,
  but the fork lacks mux.MiddlewareFunc that o11y's otelmux needs. Standard
  gorilla/mux v1.8.1 satisfies every consumer.

Deferred (reported, not faked): OpAMP collector management (a second websocket
listener) is not started in-process — telemetry ingest continues on the existing
collector->datastore path. Build/test gate is CGO_ENABLED=0 (as prod ships): o11y
+ hanzoai/sqlite resolve to a single modernc sqlite driver registration.
2026-07-04 21:27:54 -07:00
zandGitHub 9dc7d80508 billing(#70): enforce per-scope spend caps + rate limits at the edge (metering v0.1.4)
Red SHIP; conflicts (only #45 clients/team) resolved to main; 22 money-path+regression tests green; go build clean. Edge calls standalone commerce /authorize; inert by default.
2026-07-04 20:53:45 -07:00
hanzo-dev c5a0a04a05 Merge remote-tracking branch 'origin/main' into feat/scope-spend-limits
# Conflicts:
#	clients/team/account.go
#	clients/team/account_store.go
#	clients/team/account_store_test.go
#	clients/team/account_test.go
#	clients/team/bots.go
#	clients/team/roster_test.go
#	clients/team/store.go
#	clients/team/team.go
#	clients/team/token/token.go
#	clients/team/token/token_test.go
#	clients/team/transactor.go
2026-07-04 20:47:26 -07:00
79a5c38807 chore: pin tasks v1.48.0 (was mutable pseudo-version) (#132)
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-04 20:43:20 -07:00
0e9e453fcb feat(durable): expose embedded tasks engine on a gated cluster ZAP listener (#131)
Consolidation: kill the standalone tasksd pod by running its consumers on cloud's
in-process embedded engine. After Embed wires the loopback (ungated, in-process
ai-ingest) listener, call emb.ServeGated(ctx, 9999, validator) to expose the SAME
engine cluster-wide under mandatory identity gating.

RequireIdentity: every request on :9999 must carry an IAM auth_token, validated
against {IAMIssuer}/v1/iam/.well-known/jwks (HIP-0111) and org-scoped to its owner --
the same trust anchor as the HTTP SanitizeIdentity boundary. The loopback dialer for
ai-ingest is untouched (127.0.0.1:19999, ungated, cloud's own trust boundary).

Fail-soft: a missing IAMIssuer or a bind failure logs and leaves the gated surface
down without disturbing ai-ingest. 9999 mirrors the port the retired tasksd exposed,
so a consumer repoint changes only the host (tasks.hanzo.svc -> cloud.hanzo.svc).

Depends on hanzoai/tasks#8 (ServeGated + identity over ZAP). Pinned here to that
branch's commit; repin to the tagged release once #8 merges. universe adds the :9999
Service port.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-04 20:42:28 -07:00
hanzo-dev 35652cb4da chore: pin tasks v1.48.0 (ServeGated) for the gated cluster ZAP listener 2026-07-04 20:42:24 -07:00
hanzo-dev 0ddfb72094 feat(durable): expose embedded tasks engine on a gated cluster ZAP listener
Consolidation: kill the standalone tasksd pod by running its consumers on cloud's
in-process embedded engine. After Embed wires the loopback (ungated, in-process
ai-ingest) listener, call emb.ServeGated(ctx, 9999, validator) to expose the SAME
engine cluster-wide under mandatory identity gating.

RequireIdentity: every request on :9999 must carry an IAM auth_token, validated
against {IAMIssuer}/v1/iam/.well-known/jwks (HIP-0111) and org-scoped to its owner --
the same trust anchor as the HTTP SanitizeIdentity boundary. The loopback dialer for
ai-ingest is untouched (127.0.0.1:19999, ungated, cloud's own trust boundary).

Fail-soft: a missing IAMIssuer or a bind failure logs and leaves the gated surface
down without disturbing ai-ingest. 9999 mirrors the port the retired tasksd exposed,
so a consumer repoint changes only the host (tasks.hanzo.svc -> cloud.hanzo.svc).

Depends on hanzoai/tasks#8 (ServeGated + identity over ZAP). Pinned here to that
branch's commit; repin to the tagged release once #8 merges. universe adds the :9999
Service port.
2026-07-04 20:35:02 -07:00
hanzo-dev 649c2c5b97 billing(#70): fix Red HIGH-2 project-spoof, MED-4 DenyResource, INFO-7
HIGH-2: principal.ValidatedProject(c) (project, validated) — returns false
today (X-Project-Id is a caller-chosen label, not claim-bound), so the edge
gate + resource meter pass ProjectValidated=false and commerce degrades
project-scoped hard caps to soft. ONE lever to harden when IAM mints a
project claim. MED-4: DenyResource renders ErrSpendCapExceeded -> 402
spend_cap_exceeded (was 503). INFO-7: canonicalService unifies the edge
service label with the resource provider (ml/visor->compute, agents->agent,
security->security.scan) so a cap binds on both surfaces. Bump metering
v0.1.3 -> v0.1.4. Tests green (incl DenyResource spend_cap).
2026-07-04 20:25:04 -07:00
hanzo-dev f32761ebbc fix(team/account): public-URL OAuth callback origin behind the gateway (#45)
Adds TEAM_PUBLIC_URL / PUBLIC_ORIGIN config: callbackOrigin() returns the
configured public origin (e.g. https://hanzo.team) for the OAuth redirect_uri
instead of the request Host, so cloud emits the registered public callback even
behind the gateway (where the request Host is the internal cluster service).
Unset = unchanged (falls back to originOf). Lets hanzo.team route through the
gateway UNIFORMLY like api.hanzo.ai — removes the need for the temporary
direct-to-cloud edge route.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-04 20:15:12 -07:00
hanzo-dev 59053acf41 billing: enforce per-scope spend caps + rate limits at the edge (#70)
BillingGate uses metering.AuthorizeVerdict (funds+cap, one round trip):
renders a distinct 402 spend_cap_exceeded (scope/cap/spent) and sets
X-Spend-Warn at the soft threshold; gates on the request price. New ONE
ScopeRateLimit middleware composes zip/middleware.RateLimit per-scope
(org/project/service), dynamic rpm from commerce (short-TTL cache,
fail-open), 429 + X-RateLimit-* — wired after identity, before billing.
ResourceMeter threads project + service(=provider) so resource creation
is scope-gated too. Scope always from the validated principal. Bump
metering v0.1.2 -> v0.1.3. Money-path tests green (402/warn/429/isolation).
2026-07-04 19:47:10 -07:00
hanzo-dev 5ee34fec4c feat(vfs): real in-process deps.VFS on SeaweedFS S3 — team files/avatars (#45)
pickVFSClient returns an S3-backed types.VFSClient (clients/s3vfs.go) when
S3_ADMIN_ACCESS_KEY/SECRET_KEY are set, else DisabledVFS (R-7 fail-closed
preserved). Reuses the SAME s3admin.Admin construction as clients/s3 (DRY, one
credential path). Put/Get/Delete over the shared 'team-blobs' bucket, per-tenant
key prefix (files.go builds team/blobs/<verified-org>/<ws>/<blobId>). S3 NoSuchKey
maps to types.ErrBlobNotFound (honest 404/idempotent-204); any other S3 error →
502 fail-closed (never a dishonest 404). Bucket create-if-absent self-heals a boot
blip. Red-reviewed SHIP. This is the repoint gate: hanzo.team avatars/attachments
now work off cloud.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-04 16:58:04 -07:00
hanzo-dev 873e2f488a feat(team): mount clients/team as native cloud subsystem — port from team-go (#45)
Ports hanzoai/team-go into the unified cloud binary as a zip-native subsystem
(order 138, /v1/team/*): account (IAM OAuth bridge + workspaces/members on SQLite),
transactor (Huly wire over wsx, serverVersion 0.6.0 preserved), bots-as-members
(in-process agents.ListForOrg → Employees, removal-reconcile), files (FrontStorage
contract, org+workspace-membership scoped, byte-derived content-type allow-list).

Security (Red-reviewed, all closed): fail-closed SERVER_SECRET degrade-health-only
(never crashes the binary/CI smoke-boot), token exp/nbf, seg() traversal guard,
setCookie verify, cross-tenant blob isolation, VFSClient.Delete fail-closed (deps.VFS
never nil, R-7). Supersedes the stale clients/team a parallel branch swept onto main.

Real deps.VFS wiring (avatars) follows in the next patch (.97) before the hanzo.team
front repoint, so nothing regresses. Migration + repoint + rip of the standalone
team-go Deployment are the remaining cutover steps.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-04 16:32:19 -07:00
7b49e15f51 admin: trial/prepaid grant source + /v1/admin/grants (list+issue) (#129)
- POST /v1/admin/customers/:org/credit gains `source` (trial|prepaid). A staff
  comp defaults to TRIAL (non-cash Credit bucket, grant:admin tag → billing/bucket
  DepositKind Credit); only explicit "prepaid" mints real money (admin-grant tag →
  Prepaid). Fail-closed: unknown→trial, so a comp never silently becomes payout-able
  cash. source recorded in the audit before/after + response.
- grantCredit refactored to a shared applyGrant core (ONE credit-write path).
- NEW GET /v1/admin/grants — the credit-grant ledger across all orgs, projected
  from the tamper-evident audit trail (action admin.customer.credit): org, amount,
  source, reason, staff actor, date, txid, result. Honest-empty without a local
  audit store.
- NEW POST /v1/admin/grants — issue a grant to any org from the operator Grants
  view (org in body), funneled through the SAME applyGrant core.
- Both global-admin gated (s.guard). grantTag unit-tested.

go build/vet/test ./clients/admin green.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-04 14:54:39 -07:00
hanzo-dev ff5512171e admin: trial/prepaid grant source + /v1/admin/grants (list+issue)
- POST /v1/admin/customers/:org/credit gains `source` (trial|prepaid). A staff
  comp defaults to TRIAL (non-cash Credit bucket, grant:admin tag → billing/bucket
  DepositKind Credit); only explicit "prepaid" mints real money (admin-grant tag →
  Prepaid). Fail-closed: unknown→trial, so a comp never silently becomes payout-able
  cash. source recorded in the audit before/after + response.
- grantCredit refactored to a shared applyGrant core (ONE credit-write path).
- NEW GET /v1/admin/grants — the credit-grant ledger across all orgs, projected
  from the tamper-evident audit trail (action admin.customer.credit): org, amount,
  source, reason, staff actor, date, txid, result. Honest-empty without a local
  audit store.
- NEW POST /v1/admin/grants — issue a grant to any org from the operator Grants
  view (org in body), funneled through the SAME applyGrant core.
- Both global-admin gated (s.guard). grantTag unit-tested.

go build/vet/test ./clients/admin green.
2026-07-04 14:32:44 -07:00
hanzo-devandGitHub ef71666dfa chore(kms): drop hanzoai/kms/sdk/go straggler, bump luxfi/kms v1.11.6→v1.11.8 (#128)
Align cloud to the target: KMS is the embedded luxfi/kms (clients/kms) alongside
embedded IAM; the last external hanzoai/kms dependency is removed.

- go.mod: bump github.com/luxfi/kms v1.11.6 → v1.11.8 (match the deployed image);
  remove github.com/hanzoai/kms/sdk/go v1.1.1; go mod tidy.
- clients/mpcseal (NEW): the minimal client-side-CEK sealing client for the
  SEPARATE luxfi/mpc node ring — a faithful, behavior-identical inline of the
  subset of the former hanzoai/kms/sdk/go that clients/fleet + clients/provisioning
  use (NewClient/Unlock/Set/Get/Delete + Argon2id→HKDF→AES-256-GCM). luxfi/kms has
  no drop-in equivalent (its pkg/ is server/ZAP/store, not a Vault client), so
  inlining the used subset is the minimal correct change that removes the external
  dep without altering the wire protocol or trust model. Drops the HPKE Wrap/Unwrap
  the callers never used.
- clients/{fleet,provisioning}: swap import path only; call sites untouched.
- clients/kmssvc/login.go + docs/consolidation.md: comment/table refs → luxfi.

Verified: go build ./... = 0, go vet = 0, gofmt clean, clients/provisioning tests
pass. go.mod + go.sum carry zero hanzoai/kms references.

Follow-up (separate, tested change): fold fleet/provisioning sealing into cloud's
embedded deps.KMS once types.KMSClient gains Delete + verified against the live MPC
ring — one KMS surface. Once the universe KMS-collapse PR merges, the deprecated
hanzoai/kms repo/package can be archived.
2026-07-04 14:25:58 -07:00
hanzo-dev 1b301149bb feat(principal,fleet,ml): make the data plane tenant+PROJECT aware
The gateway now mints X-Project-Id (an org SUB-SCOPE) alongside X-Org-Id.
Thread it through the keyed surfaces, backward-compatibly — the default
project ("default", or an absent header) resolves to today's exact keys,
so existing single-project tenants are byte-identical.

- principal.Project(c): the ONE read accessor, mirroring c.Org() (zero-copy
  header read, cloned on retain). Defaults to DefaultProject when the header
  is empty. principal.DefaultProject / IsDefaultProject own the default-scope
  semantics in one place (shared contract value with iamauth.DefaultProject).
- fleet: registry refs shard by project via the ONE scopeRef seam —
  "<org>/fleet/clusters" for the default project, "<org>/<project>/fleet/
  clusters" for a non-default one (index, sealed kubeconfig, cache key).
- ml: tenant namespace is "ml-<org>" for the default project and
  "ml-<org>-<project>" for a non-default one; both org and project are
  validated against strict DNS-label regexes (no lossy fold) and the composed
  label is length-checked against the 63-char ceiling, keeping the
  (org, project) -> namespace map injective. A hanzo.ai/project attribution
  label is stamped for non-default projects.
- visor BYO fleet + ml federation resolve project via principal.Project.

Billing stays keyed on the paying org (a project has no separate prepaid
balance); project is isolation + attribution, not a billing key.
2026-07-04 14:19:15 -07:00
hanzo-devandGitHub 8f7c72b56d fix(deps): repin luxfi/age v1.5.0 -> v1.5.1 (cold-cache checksum SECURITY ERROR) (#127)
luxfi/age v1.5.0 was upstream-retagged (transient files GC'd from the tag
tree), so the tag's zip content on the origin no longer matches the h1 hash
recorded in go.sum. Cold-cache builds (fresh CI, empty GOMODCACHE) fail with:

    verifying github.com/luxfi/age@v1.5.0: checksum mismatch
    SECURITY ERROR

v1.5.1 dereferences the same commit, is immutable, and is sum.golang.org
verified (h1:Gj8iHMMi0lGkKT/mlXV2HVBr2m3vt2v0eKVsTMTtAQM=). Surgical: age
require + go.sum only. go mod verify clean.
2026-07-04 14:15:49 -07:00
aa4b572f7e feat(crm): Startup Program applications — public intake, AI screen, pipeline (#125)
* feat(crm): startup-program applications resource (intake + AI screen + pipeline)

Public unauthenticated intake POST /v1/crm/applications (rate-limited + honeypot)
writes a dedicated crm_applications record (all fields in metadata JSON), a
best-effort CRM Company+Contact projection, and kicks off an AI screen via the
gateway (score / tier1 / suggested credits / summary / draft reply) that
auto-advances applied->screened. Staff GET/PATCH drive a stage machine
(applied->screened->qualified->credits-offered->onboarded, +rejected w/ reason).
Non-fatal if the LLM is unavailable.

* test(crm): startup applications — intake, honeypot, idempotency, AI screen, stage machine

10 tests: public intake creates application+CRM projection with all fields in
metadata; honeypot drop; validation; idempotent resubmit; end-to-end AI screen
with a fake gateway (score/tier1/credits/reply + auto-advance applied->screened);
non-fatal screen on gateway error; staff PATCH stage machine (advance/skip-block/
reject-requires-reason); pure canTransition + parseScreen + detectTier1.

---------

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-04 13:32:59 -07:00
hanzo-devandGitHub 7dd6ba60a9 chore(tasksvc): swap embedded Tasks UI to a fresh from-source build (#126)
PR #124 embedded the last-known-good admin-tasks build as a de-risking
fallback. This replaces it with a FRESH build of current admin-tasks HEAD,
built from source in a combined gui+admin workspace (hanzogui@7.3.x +
@hanzogui/admin@7.3.0 workspace-linked; @hanzogui@7.3.x is unpublished, so it
must build inside that workspace — see clients/tasksvc/ui/README.md).

Same contract (base=/_/tasks/, api=/v1/tasks); full component parity
(namespaces/workflows/schedules/batches/deployments/activities/nexus/history).
Embed tests still assert the real bundle (not the placeholder).
2026-07-04 13:29:39 -07:00
hanzo-dev 2c8b6a66c6 feat(fleet): unify BYO clusters into the ONE /v1/clusters surface (visor)
One and one way: BYO k8s / BYO-GPU / bare-metal attach now lives on the SAME fleet
surface as managed clusters (visor /v1/clusters), not a parallel /v1/ml/clusters.
- clients/fleet: the shared per-org BYO-cluster Registry — kubeconfig sealed in the
  org's KMS, validated by reaching the cluster (node + nvidia/amd GPU inventory),
  tenant-scoped by the ZAP-propagated X-Org-Id. ONE source of truth.
- visor: POST /v1/clusters (attach) + DELETE /v1/clusters/:id (detach), and BYO
  clusters MERGE into GET /v1/clusters beside managed ones. Nominal management fee
  (rides the compute-fee config — no bespoke env var; customer brings the compute).
- ml: deleted the parallel /v1/ml/clusters; dynForOrg federates ML serving onto the
  org's registered cluster via the shared registry (home client when none).
Builds + vet + tests green.
2026-07-04 13:16:34 -07:00
hanzo-devandGitHub 501cad7c57 feat(tasksvc): embed the real Tasks UI, retire tasks-ui pod (#124)
clients/tasksvc served /_/tasks from github.com/hanzoai/tasks/ui, whose
ui/dist is an empty 'No UI build present' placeholder — so tasks.hanzo.ai
still routed to the standalone tasks-ui pod (a Temporal-Web-UI fork).

cloud is the ONE process that serves tasks.hanzo.ai (durable.go's embedded
engine + /v1/tasks surface), so cloud now owns the UI embed too: a local
clients/tasksvc/ui package bakes the real admin-tasks SPA build (base=/_/tasks/,
api=/v1/tasks) into the binary via //go:embed. One binary, one origin, the
real UI — which lets the tasks-ui Deployment/Service/CR be retired.

Tests prove the embedded bundle is the real SPA (not the placeholder), the
SPA deep-link fallback, immutable asset caching, and GET-only.
2026-07-04 13:14:17 -07:00
hanzo-dev 583b04c19a test(observe): red-team route-precedence probe — scoped GET wins over the o11y proxy wildcard (#59)
Companion security test to the observe subsystem: proves a /v1/o11y/logs
request lands on the org-scoped handler (order 44), never falling through to
the unscoped hanzoai/o11y reverse-proxy wildcard (order 70) that would bypass
tenant scoping (attack #4).
2026-07-04 13:03:37 -07:00
hanzo-dev e4f749f738 fix(observe): coerce response_status_code (LowCardinality(String)) before numeric compare
Validated the handler SQL against the live signoz_traces schema: response_status_code
is LowCardinality(String), so a raw >= 500 raises NO_COMMON_TYPE and asInt64 on it
yields 0. Wrap with toInt32OrZero() in the RED errs count and the request-log status,
matching the verified live query (real per-org buckets returned).
2026-07-04 13:02:27 -07:00
hanzo-dev 8c655ca6a9 feat(observe): live per-org Settings/Status/Logs/Metrics for console products (#59)
New /v1/o11y/{logs,metrics,status} + /v1/settings/:product cloud subsystem
(order 44, wins over the hanzoai/o11y proxy wildcard) backing the console
product-detail tabs with REAL, org-scoped data — no stubs.

- Logs   : ClickHouse signoz_logs (admin: raw app stream) / signoz_traces
           org-tagged request stream (every other tenant), live-tail cursor.
- Metrics: per-org RED (rate/errors/p50/p95) from org-tagged spans
           (attributes_string['hanzo.org']) + per-org LLM usage (cloud_usage).
- Status : live in-cluster health probe (latency) + VictoriaMetrics up{service}.
- Settings: per-(org,product) SQLite CRUD; secret fields -> KMS, never SQLite.

Tenant isolation server-side: org = principal.Tenant (validated owner claim),
bound as a positional ClickHouse param / mandatory WHERE org=? — never a client
header/param/raw-query. Only IAM_ADMIN_ORG sees unattributed infra logs.
Reuses the shared ai/object datastore client (one conn, KMS creds).

Tests: 8/8 pass — store isolation, principal gate on every endpoint,
cross-tenant read denial, secrets-never-in-SQLite (fail closed w/o KMS),
product traversal/injection rejection, honest status down.
2026-07-04 13:02:27 -07:00
hanzo-dev 292ef535b5 fix(billing): restore per-item ledger attribution (Meter records kind as Usage.Model)
The fe3a5fd agents-metering refactor split MeterUsage out of Meter and
dropped the Model:kind write, so EVERY per-product debit (functions/invoke,
s3/op, provisioning, ml, tracker, automations, security) recorded an empty
model — losing per-item revenue attribution in the commerce ledger. Restore
the one-place mapping in Meter (all 8 resource callers flow through it).

Also fix two stale test doubles that read the retired X-IAM-Org-Id header;
commerce reads X-Org-Id only (same $0-revenue class as the admin.go fix), so
they saw an empty org. Full suite: 58 ok, 0 fail.
2026-07-04 12:58:32 -07:00
8350a5559e fix(serve): truthful comment — ZAP :9653 is plaintext TCP, needs mesh mTLS (red finding #3) (#123)
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-04 12:46:04 -07:00
hanzo-dev f18986ea7f feat(visor,cli): bring-your-own GPU fleet — console surfaces + hanzo gpu CLI
Register a machine's GPU into the cloud fleet with one command and see it on
the console's existing Machines + GPUs pages, tagged provider=byo.

- clients/visor: a BYO worker is a heartbeating presence activity in the org's
  `fleet` tasks namespace (cloud.EmbeddedTasks). fleet.go reads it and folds it
  into the SAME machineView/gpuView the console renders (provider=byo,
  location=on-prem, gpu model + VRAM, online/offline by heartbeat), plus a raw
  GET /v1/fleet/workers. /v1/machines and /v1/gpus union Visor's inventory with
  the BYO workers and degrade gracefully (BYO stays visible if Visor is down).
- cli: `hanzo gpu connect|status|disconnect` — reuses the `hanzo login`
  IAM token (org from its claims; token auto-refresh), detects GPUs via
  nvidia-smi, registers + heartbeats the fleet presence record, and runs an
  outbound worker loop claiming from `gpu-jobs` (pluggable handlers: echo,
  studio.render→local ComfyUI). --daemon installs a systemd --user unit.
- go.mod: hanzoai/tasks v1.46.0 → v1.47.0 (the claim + lease-reaper surface).

E2E (z@hanzo.ai): connect → GB10 'spark' shows provider=byo on
/v1/fleet/workers + /v1/machines + /v1/gpus → echo job claimed + completed.
2026-07-04 12:28:10 -07:00
hanzo-devandGitHub c0e25c2588 fix(bots): auto-create the bound agent on launch so a bot is messageable (#122)
launchBot bound an agent *name* but never created that agent, so
messageBot's in-process run (/v1/agents/:agent/run -> Resolve) 404'd
"agent not found" — a launched bot could not be messaged.

launchBot now create-if-absent's the bound agent via the SAME
POST /v1/agents the console uses (one create path, forwarding the
caller's validated identity -> org-scoped, IDOR-safe), BEFORE launching
the metered machine so a bad request (e.g. a non-catalog model) 400s
before anything is provisioned. Idempotent: an existing agent (409) is
reused. An omitted model takes the deployment default
(deps.AIDefaultModel, a valid catalog model) threaded from config — no
hardcoded model id.

Also add create/update-time model validation: a client-supplied model
outside the gateway's served catalog is a clean 400 (via the optional
types.ModelLister the real gateway client implements) instead of a
confusing run-time 502; fail-open when the catalog can't be enumerated.

Tests: agents model-validation + default (real store); httpAI.Models
against a fake gateway; visor launch->auto-create->message->resolve E2E
incl. the before/after 404->200 gap proof, idempotency, and bad-model
fail-fast (no machine provisioned).
2026-07-04 12:17:29 -07:00
hanzo-devandGitHub 2b435e4005 deps(rag): bump hanzoai/ai v1.800.6 -> v1.800.7 — force gateway embedder at resolution time (fixes RAG ingest hang, #72) (#121) 2026-07-04 11:46:26 -07:00
hanzo-devandGitHub 47421665b3 feat(notify): KMS-only provider creds, remove env fallback (#120)
notify's send surface now reads provider credentials EXCLUSIVELY from cloud's
embedded KMS (cloud.Deps.KMS) at the org-scoped, rotatable ref
orgs/<org>/notify/<svc>/<key> — the same /orgs/<org> namespace
clients/integrations uses, so a cred is seedable + rotatable via
POST /v1/kms/orgs/:org/secrets with no operator-injected env Secret and no
restart. The org is the VALIDATED principal's tenant, never a client header.

Removes the env-first fallback (envCreds/envFirst + the os import): no secret
is ever read from the environment, hard-coded, or logged. A missing key leaves
the value empty and constructProvider fails closed.

Tests rewritten to inject a fake KMS (no env), plus a per-org isolation test
and a regression that creds() ignores the legacy TWILIO_* env entirely.
2026-07-04 11:35:28 -07:00
hanzo-devandGitHub d3ece49517 deps(rag): bump hanzoai/ai v1.800.4 -> v1.800.6 — default embedder targets the Hanzo gateway, not api.openai.com (#119)
Pulls hanzoai/ai#71: the RAG default embedder (object/init.go seed) now points at
the Hanzo gateway (CLOUD_AI_BASE_URL / CLOUD_AI_API_KEY, model text-embedding-qwen3)
instead of an empty ProviderUrl that hit api.openai.com directly. A server-side
embed to api.openai.com from in-cluster crawled ~180-210s and then failed, so RAG
ingest (/v1/rag/embed) hung AND the Qdrant vector collection was never created
(writeDocsToVector sample embed timed out before ensureVectorCollection ran).
Gateway embeddings are <1s (proven live). The seed self-heals an existing
api.openai.com-direct default to the gateway on boot, so this deploy converges the
live default-embed provider with no manual console repoint.
2026-07-04 11:11:47 -07:00
z 8cf2f0683a fix(websearch): admit the validated console principal on /v1/websearch/search
The console (console2 WebSearch module) reaches search through the /cloud proxy
with a signed-in USER BEARER, not the shared X-API-Key. searchGuard required the
key on every call (F2 hardening), so the console got 503/401 — "backend not
initialized" — even though searxng+crawl are deployed and the upstream defaults
are correct.

Reconcile to the ONE-WAY gate the rest of the /v1 data plane uses: at the zip
layer, a request with a validated principal (principal.Validated — X-User-Id
minted by the identity middleware from a verified JWT) proxies straight to
SearXNG; a request with NO principal falls to the unchanged key-based searchGuard
(the hanzo.chat server path). A caller with neither is still refused, so F2 (no
open metasearch proxy) holds — proven by TestSearchNoPrincipalNoKeyRefused.

searchGuard (net/http) is untouched; its 503/401 tests stay green. New coverage:
TestSearchValidatedPrincipalBypassesKey (console bearer, key unset -> 200),
TestSearchNoPrincipalNoKeyRefused (anonymous, key unset -> 503).
2026-07-04 08:50:05 -07:00
zandGitHub 7ffe6dcf67 Merge pull request #117 from hanzoai/fix/agent-run-internal-egress
agent-runner mints M2M inference token from in-cluster IAM (fixes /v1/agents/:ref/run 502). Forward-integrated with main (#118 admin-guard audience). Reconciles live sha-b9639df onto a semver release.
2026-07-04 08:37:34 -07:00
0e960ecaae fix(identity): accept hanzo-admin-guard as a JWT audience (#118)
cloud-api SanitizeIdentity validates the forwarded IAM bearer against
defaultJWTAudiences before granting global-admin (owner==adminOrg). The
admin.hanzo.ai guard is client hanzo-admin-guard, so its tokens carry
aud=hanzo-admin-guard, which was missing from the allowlist -> the bearer
failed validation, resolved anonymous, and the SuperAdmin gate read false
-> 403, even though the token owner IS admin.

Append the guard client_id (forwards-only, mirrors gateway iamauth). Admin
authority still requires owner==adminOrg, so no widening. Pairs with
hanzoai/gateway audience fix.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-04 06:35:12 -07:00
hanzo-dev b9639dfe7e fix(cloud): agent-runner mints M2M inference token from in-cluster IAM (fixes /v1/agents/:ref/run 502)
pickAIClient built the M2M token URL from cfg.IAMIssuer (=https://hanzo.id), a
Cloudflare-fronted host. In-cluster the runner's server-side POST to
https://hanzo.id/v1/iam/oauth/token 403s with CF edge error 1006, so the
oauth2 client-credentials fetch fails and EVERY POST /v1/agents/:ref/run 502s:
  'cloud: chat completion: oauth2: cannot fetch token: 403 Forbidden / 1006'.

New aiM2MTokenURL resolves the token endpoint split-horizon, mirroring the KMS
login-broker (clients/kmssvc) exactly — one policy, no drift:
  1. CLOUD_AI_IAM_TOKEN_URL override
  2. in-cluster IAM_URL (already wired to http://iam.hanzo.svc for JWKS)
  3. public IAMIssuer fallback (single-process deploys)
IAMIssuer stays https://hanzo.id for JWT iss-validation (untouched). The chat
base URL is pointed in-cluster via CR env CLOUD_AI_BASE_URL (universe).

Proven in-cluster: mint token from iam.hanzo.svc + chat to gateway.hanzo.svc
both 200 (real completion). Unit test pins the 3-branch resolution order.
2026-07-04 05:12:39 -07:00
hanzo-dev 500b9e3afe deps(billing): bump hanzoai/ai v1.800.3 -> v1.800.4 — widget (hz_) keys bill owner org
Pulls in the ai fix that closes the hz_ widget-key free-inference hole: widget
keys now bill the OWNER ORG (object.WidgetKeyOwner), so reserveBudget +
recordUsage + the balance gate all engage instead of running free/unmetered.
Bounded to the restricted widget model set + token cap; fail-secure when a widget
key is unattributable.
2026-07-04 04:41:47 -07:00
hanzo-devandGitHub c68428e87d fix(kms): broker uses in-cluster IAM_URL for token exchange, not public issuer (CF 403s in-cluster loopback) (#116)
The per-tenant KMS secret-sync login broker derived its IAM token-exchange URL from the public issuer (hanzo.id), which Cloudflare 403s for in-cluster server-side POSTs → the sync could never authenticate. Prefer in-cluster IAM_URL (+ CLOUD_KMS_IAM_TOKEN_URL override), fall back to issuer. Unblocks PaaS per-tenant secret env (proven: git-built ai-demo app deployed on maxpower).
2026-07-04 04:05:44 -07:00
bfbedd1bba feat(notify): fold notifyd OTP send surface into the unified cloud binary (#115)
Mounts /v1/notify/{send,send/sms,send/email,health} natively in-process as the
cloud subsystem "notify" (order 139) — the native, in-process replacement for
the standalone notifyd (github.com/hanzoai/notify) Deployment.

notifyd's ONLY production consumer is Hanzo IAM's OTP send
(POST /v1/notify/send?sync=true, event=iam.otp_sent), and the live tenant's
template/provider/event tables are empty, so this folds exactly that contract
and nothing more. It reuses notifyd's OWN public provider packages
(service/{twilio,twilioemail,plivo,mail}) and wire types (pkg/types) — no
duplication of provider plumbing; only the internal-only cred->constructor glue
is mirrored.

Security: unlike the ClusterIP-internal notifyd (which trusted a raw X-Org-Id),
/v1/notify/send is reachable via the public gateway here, so it gates on a
VALIDATED principal and derives the org from principal.Tenant — the same
trust-boundary move clients/auto makes. Credentials come from env (the
KMS-synced notify-twilio Secret) and KMS via cloud.Deps.KMS; none is hard-coded
or logged. Ships a built-in iam.otp_sent template so the fold is strictly more
available than notifyd is today (whose empty store would 400 an OTP send).

Sync-only: the Temporal notify-send async plane is intentionally NOT folded;
async (no ?sync=true) returns 503, exactly as notifyd does without a worker.

Build-gated: go build ./... green; go test ./clients/notify/... green; gofmt/vet
clean. go.mod adds only hanzoai/notify + its provider transitive deps.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-04 03:48:05 -07:00
hanzo-dev 802e4181ef fix(zt): message-hygiene — 503 body names no internal env
The fail-closed 503 body no longer names ZT_CLIENT_ID/ZT_CLIENT_SECRET;
it now reads 'networking is not configured on this deployment' — a
customer-facing string the console renders as a clean 'not available yet'
state. Ops still see the env names in the Warn log at Mount. No behavior
change; the gate() fail-closed contract is identical.
2026-07-04 03:24:53 -07:00
zandGitHub dc4920b79a Merge pull request #114 from hanzoai/feat/slack-integrations-bridge
feat(integrations): @hanzo Slack agent bridge — events/commands/link on /v1/integrations/slack/*
2026-07-04 03:18:33 -07:00
hanzo-dev 7249f79445 fix(integrations): Red delta — recover async turns + shed before dedupe (M-2, M-1)
RED M-2 [MED] — contain panics in the async agent-turn goroutine. The dispatched
turn (handleSlack* → slackAgentReply → agents.RunOnBehalf, a large surface over
UNTRUSTED Slack input) ran UNRECOVERED — middleware.Recover() only wraps the sync
request goroutine — so a panic would crash the ENTIRE shared multi-tenant cloud
binary (every tenant, every subsystem). Introduced slackSpawn: runs an
already-slotted turn in a recovered goroutine (recover defer registered LAST so it
runs FIRST; the slot release still runs after it, so a panicking turn frees its
slot). Test TestSlackTurnPanicRecoveredAndSlotReleased.

RED M-1 [MED] — shed BEFORE burning the dedupe key. The old order was
mark-then-dispatch: MarkSlackEvent recorded the event_id, then a pool-full drop
silently 2xx-acked → Slack never retried and a later retry was deduped away, so
the @mention vanished. New order (events + slash): verify HMAC → resolve org →
TRY-ACQUIRE a pool slot; on shed record NOTHING and return a retriable 429 (Slack
re-delivers when a slot frees — the turn never ran, so no double-run and no burned
key); on acquire → MarkSlackEvent (release the slot on duplicate/error) → spawn.
The fast empty-2xx ack is kept for the normal path. Test
TestSlackShedReturnsNon2xxAndDoesNotRecord.

Also corrected the slack_dedupe.go replica note (we no longer "always 2xx-ack" —
a capacity shed returns a retriable non-2xx and records nothing, so it is not a
double-process).

RED M-3 [MED] deploy single-replica (Recreate + persistent CLOUD_DATA_DIR) — the
universe cloud manifest, owned by the deploy lane; the in-code single-writer
invariant comment is kept accurate here.

go build ./... = 0, go vet ./... = 0, go test ./clients/integrations/
./clients/agents/ -race green (25 tests).
2026-07-04 03:03:36 -07:00
hanzo-dev d49c14d1a0 fix(integrations): address Red review — wire routes, per-org pool, replica scope
RED H1 [HIGH] — wire the bridge into integrations.Mount (was only in a test
helper → the front-door didn't exist in prod). The 5 literal routes are
registered BEFORE the /:provider wildcards (registration-order precedence, same
discipline as clients/agents' static-before-:ref) and are PUBLIC at the JWT
layer: IdentityMiddleware only POPULATES a principal (never rejects) and
DefaultPrice returns 0 for /v1/integrations/* so BillingGate passes through —
reached exactly like /:provider/callback. Auth is HMAC (events/commands) /
signed __Host- cookie (link legs) INSIDE the handler. New test
TestSlackRoutePrecedence proves GET /slack/link hits slackLink (not :provider),
/v1/integrations/slack still resolves the provider view, and the webhook is
reachable with no principal.

RED M1 [MED] — per-org concurrency sub-limit. The agent-turn pool was one
process-global semaphore; one org bursting @hanzo could starve every tenant.
Replaced with orgLimiter (global cap + per-org cap, SLACK_AGENT_ORG_CONCURRENCY
default 8). The org is now resolved SYNC in the webhook path so the pool keys on
the RESOLVED tenant before a slot is taken. New test TestOrgLimiter.

RED M2 [MED] — corrected the false "holds across replicas" dedupe claim: the
table is per-process embedded SQLite (single-writer per HIP-0302), so the billed
webhook path MUST run single-replica (stated as the shipping invariant); a
shared SETNX store is the multi-replica follow-up.

RED L1 [LOW] — moved the dedupe-table DDL into the store's migrate() (store.go)
— fail-loud at Mount, one place — and removed the lazy first-use ensure whose
LoadOrStore-before-run could permanently disable the path on a transient DDL
error. slackBridgeReady now only inits the process pool + link seen-set.

Deferred (flagged for clients/integrations owner): L2 UNIQUE(provider,
external_id)+first-org-wins refusal on duplicate team connect; L3 purge
user:<slackUser>:refresh secrets on disconnect (currently inert after
disconnect, no leak).

go build ./... = 0, go vet ./... = 0, go test ./clients/integrations/
./clients/agents/ -race green (18 + 5 tests).
2026-07-04 02:41:14 -07:00
hanzo-dev 43228b20d6 feat(integrations): Slack agent bridge on the one-binary integrations plane (#45)
Port the hardened @hanzo Slack agent front-door from team-go/pkg/slack into
the unified Hanzo Cloud integrations plane, so Slack is ONE connector aligned
with the one-binary north star. It CONSUMES the existing Slack OAuth provider
(the per-org bot token it seals) and the framework seams
(OrgForExternalID / TokenFor / ConnectionFor); it adds no new custody path and
edits no existing file.

clients/agents:
- onbehalf.go: exported in-process RunOnBehalf(ctx, org, userSub, ref, input) —
  the clean in-process twin of the HTTP run handler (no gateway hop, no
  Cloudflare/IPv6 exposure). Resolves the agent org-scoped, runs it through the
  SAME runAgent -> executeRun -> meter path, bills billingActor(org, userSub)
  against org's ledger. Takes org+userSub DIRECTLY (caller pre-authenticated).

clients/integrations:
- slack_events.go: Slack Events webhook + slash command. HMAC-verified over the
  EXACT raw body with a 5-min replay window; url_verification challenge; routes
  @mention + DM to an on-behalf-of run; durable dedupe on event_id; fast empty
  ack + bounded async worker pool. Posts the reply into the thread with the
  org's bot token, or the link prompt EPHEMERALLY.
- slack_link.go: transplant-safe 3-leg per-user link (__Host- init/link cookies,
  leg1<->leg2 nonce continuity checked BEFORE any exchange, single-use). Binds
  Slack<->Hanzo via hanzo.id OIDC (hanzo-slack client) and seals the refresh
  token per (org, "slack", "user:<slackUser>:refresh").
- slack_verify.go: Slack signature verify + single-use link-state crypto
  (constant-time HMAC over s.stateKey; orthogonal to the OAuth-connect state).
- slack_dedupe.go: durable event-dedupe table as Store methods (no store.go edit).

PER-ORG ISOLATION (ship bar): an event's org comes ONLY from
OrgForExternalID(team_id) — never the payload; the reply uses THAT org's bot
token (TokenFor); the run is THAT org's agent (RunOnBehalf org-scoped). Tests
prove team A's event never resolves/tokens/runs as org B.

Mount wiring (5 routes) is handed to the clients/integrations owner — this
change adds NO Mount edit (clean separation); handlers are (s *svc) methods.

Tests (go test -race, green): HMAC reject (bad/missing/stale), dedupe
idempotency, per-org isolation (end-to-end bot-token capture proves the reply
used the connecting org's token), link transplant-rejected (no/mismatched init
cookie refused before exchange), RunOnBehalf bills the right actor.
2026-07-04 02:10:35 -07:00
hanzo-dev 95cb2ad064 fix(deps): align luxfi/age v1.5.0 go.sum hash with sum.golang.org
The recorded zip h1 for github.com/luxfi/age v1.5.0 (zC/Fw…) did not match
the immutable Go checksum transparency log (sum.golang.org), which records
G69Hb… — the same bits the module proxy and local cache serve. The stale
hash made the ENTIRE module unbuildable: every `go build` failed with a
checksum mismatch / SECURITY ERROR. The /go.mod hash already matched sumdb;
only the zip h1 was wrong. Aligning it to the transparency-log-verified
value unblocks the repo (`go mod verify` -> all modules verified). age is an
indirect dependency; no version bump.
2026-07-04 02:10:18 -07:00
0bef789868 feat(admin): GET /v1/admin/o11y — global fleet observability over the one datastore (#111)
Cross-org fleet o11y for admin.hanzo.ai (global-admin only, s.guard fail-closed):
fleet totals (requests/tokens/cost/errors/orgs/models from hanzo.cloud_usage;
latency p50/p95/p99 + error-rate + services from signoz_traces; log volume from
signoz_logs), usage + log-volume timeseries, and top-N orgs/models/services
leaderboards, plus the fleet Langfuse generation rollup. Un-org-scoped by design
— the one place a fleet operator crosses tenants; a non-admin bearer is refused
403 before a row is read. Reuses the shared aiobject.DatastoreQuery transport
(no second connection) and the compute/analytics honest-empty pattern; admin
reads only, owns no table. Time bounds are positional params, bucket interval a
server-side constant — injection-safe. Pure builders + parsers unit-tested.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-04 01:54:32 -07:00
zandGitHub 43153cfade Merge pull request #113 from hanzoai/feat/automations-phase1
feat(automations): Connectors+Automations engine — Phase 1 (HIP-0106 #51)
2026-07-04 01:53:55 -07:00
zandGitHub 2f42f5f69d Merge pull request #112 from hanzoai/fix/gosum-luxfi-age
fix(go.sum): realign luxfi/age@v1.5.0 to the proxy content hash (unblocks cloud releases)
2026-07-04 01:53:14 -07:00
hanzo-dev 72a53da165 fix(go.sum): realign luxfi/age@v1.5.0 to the proxy content hash
luxfi/age@v1.5.0 was force-retagged upstream: proxy.golang.org now serves
content hashing to G69HbSV… while go.sum pinned the stale zC/Fw… → every
cloud release fails at `go mod download` with a SECURITY ERROR (checksum
mismatch), blocking the whole pipeline. Dockerfile already sets GOSUMDB=off,
so the mismatch is against the committed go.sum, not the sumdb. Realign to the
exact hash CI computes from the proxy (same fix IAM shipped as fee44857).
2026-07-04 01:53:00 -07:00
hanzo-dev e93b6262bd fix(automations): RED fix set — exactly-once run bookkeeping + SSRF/caps/audit hardening
Addresses CTO's post-RED fix set (isolation boundary already approved airtight).

MED-1 (exactly-once metering/audit/persistence across ALL entrypoints): the
durable path is now the SINGLE owner of run bookkeeping. FlowRunWorkflow runs a
RecordRunStartActivity keyed on the workflow id (workflow.GetInfo — a scheduled
cron mints a fresh id per tick, so each tick is its own metered run despite the
schedule embedding one fixed FlowRunInput.RunID). Store CreateRunIfAbsent (row
idempotency) + ClaimMeter (atomic metered-flag 0->1) meter+audit only the winner,
so manual /run, MCP, and cron never double-bill. Manual /run no longer meters/
audits — it only CreateRunIfAbsent for immediate visibility. RecordRunEndActivity
records terminal status. Proof: TestScheduledRunMeteredExactlyOnce (a tick meters
once + shows in listRuns; two ticks = two distinct runs) + TestRunStartBookkeeping-
Idempotent (recordRunStart twice = one meter, one row).

MED-2 (honest SSRF blocklist): isPublicIP now rejects the IANA special-use ranges
Go's net helpers miss — 100.64/10 CGNAT (Alibaba metadata 100.100.100.200),
0/8, 192.0.0/24, 192.0.2/24, 192.88.99/24, 198.18/15, 198.51.100/24, 203.0.113/24,
240/4, 64:ff9b::/96 NAT64 — plus v4-mapped-v6 normalization. Comment no longer
overclaims a complete cloud-metadata blocklist. TestIsPublicIP covers each range +
public IPs still allowed.

MED-3 + LOW-4: step-count (<=256) + serialized-tree (<=512KB) caps at create /
version / operation time -> honest 422; resume payload bounded (<=64KB) -> 413.

LOW-2: per-org concurrency limiter (429) on run-starts + synchronous MCP tool calls
(bounds the core.delay goroutine lever). TestConcurrencyLimiter + TestFlowStepCap +
TestResumePayloadBounded.

LOW-1: MCP meters/audits AFTER Run, outcome derived from the real result — a failed
/ SSRF-blocked / not-connected call audits as error and is NOT billed. TestMCPAuditOutcome.

LOW-3: updateFlow validates publishedVersionId names an existing version OF THIS
FLOW in-org (else 422). TestUpdateFlowPublishedVersionValidated.

INF-1: register() panics at init on a <connector>_<action> tool-name collision so a
future connector can't silently make MCP dispatch ambiguous. TestToolNameCollisionPanics.

Tests: 25/25 green (CGO=0 build/vet/test; -race clean under cgo). Full module builds;
cmd/cloud links. catalog.json untouched.
2026-07-04 01:38:15 -07:00
hanzo-dev f3d2ece9f0 feat(integrations): Slack connect lights up on the public client_id alone
Authorize needs only SLACK_CLIENT_ID (a public value in every consent URL);
the SECRET is required only at the callback token exchange. Gate available/
connect on client_id so an org reaches Slack's Allow screen as soon as the
public id is set, while a deployment still missing SLACK_CLIENT_SECRET fails
the exchange with an honest ?error=slack (never a dead-end).
2026-07-04 01:31:54 -07:00
zeekayandClaude Fable 5 bc89be43ac fix(deps): bump hanzoai/ai v1.800.2 -> v1.800.3 for brand .cloud CORS fix
Pulls the cors_filter static-allowlist fix so console.lux.cloud (and
zoo/pars brand consoles) stop getting 403 "origin is not allowed" on
/v1/signin. Cleared stale sum.golang.org-poisoned go.sum entries for
re-tagged luxfi/{age,precompile,keys} (GOPRIVATE direct re-records the
current content hashes; matches the repo's GOSUMDB-off CI recipe).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 01:20:55 -07:00
hanzo-dev 47f58c0e11 feat(automations): merge full 701-piece catalog + /v1/automations OpenAPI
- catalog/catalog.json: 706 pieces (5 Tier-A executable connectors + 701
  ActivePieces catalogue entries); action/trigger props normalized string[]
  -> []PropSpec so PieceMetadata unmarshals at Mount (boot-parses green).
- docs/automations-openapi.yaml: OpenAPI 3.0.3, 13 paths all under /v1/automations.
- http_test: assert catalogue invariant (PieceCount==len, Tier-A present) not
  the seed-pinned count.
2026-07-04 01:09:57 -07:00
hanzo-dev 42c995c077 feat(automations): Phase-1 Connectors+Automations engine (HIP-0106 #51)
Native-Go /v1/automations/* subsystem in the unified cloud binary. Composes
three existing seams, never reinvents them:

- clients/integrations  — per-org connector creds via integrations.TokenFor
  (KMS-sealed, fail-closed); connectors never touch KMS directly.
- cloud.EmbeddedTasks    — the ONE shared in-process durable engine; a flow
  runs as a durable workflow in the OWNER's namespace (per-org lazy worker,
  mirroring ai/object/ingest_tasks.go).
- clients/principal      — the ONE tenant gate on every data handler.

Isolation is physical: ONE SQLite file, org column + org-led index on every
table; the durable activity's SOLE credential scope is FlowRunInput.Owner —
the VALIDATED org set at flow-start, never a client-supplied field.

Surface: pieces catalogue (go:embed), flows CRUD + versions + FlowOperation
apply, durable runs (start/list/get/resume via SignalWorkflow), enable/disable
(POLLING -> CreateSchedule), and a HIP-0300 MCP JSON-RPC tool surface
(/v1/automations/mcp) exposing every connector action as <connector>_<action>.

Connectors (Tier-A, self-registering): core (http_request SSRF-guarded via a
dialer Control hook, delay, code data-mapper, wait_for_approval signal
waitpoint), slack (send_message), github + google_sheets/drive (fail closed
until integrations custodies their tokens).

Metering + audit on flow-run start and MCP tool call. Order 148 (after
integrations 137, before ai's /v1/* catch-all 150).

Tests (16, all green with -race): store org-isolation, HTTP org-gating (403),
durable flow run reaching SUCCEEDED with threaded step outputs on an embedded
tasks engine, connector token isolation (in.Owner is the sole cred scope),
MCP tools/list + gated tools/call dispatch, pieces catalogue.
2026-07-04 01:03:06 -07:00
0b407430c5 feat(o11y): HTTP request + LLM/agent traces over ZAP (single provider) (#110)
* feat(o11y): emit an OTel SERVER span per /v1/* request over the ZAP wire

Cloud installed a ZAP tracer provider (cmd/cloud initTelemetry) but nothing in
the handler chain opened a span, so no request ever flowed through it — the o11y
Monitoring tab saw zero hanzo-cloud request traces (receiver="zap" span count
was flat-zero while logs streamed over ZAP).

TracingMiddleware (middleware_tracing.go) opens one SERVER span per /v1/*
request off the GLOBAL tracer (= the ZAP provider), records the OTel HTTP
semantic-convention attributes (method, route, status) + request_id/org, maps
error/5xx to an error span status, and writes the span context back onto the
request via SetContext so every downstream span (agent.run -> agent.step -> the
chat client span in clients/aihttp) parents under it: one trace tree per
request. Health/readiness/metrics + non-/v1 paths are skipped so probes never
flood the trace store. Wired right after RequestID in the canonical pipeline
(serve.go) so the whole authenticated chain nests under it.

c.Path()/c.Method()/headers are zero-copy views over the fasthttp request
buffer, which is recycled for the next request BEFORE the batch span processor
serializes the span asynchronously — so retained views corrupt (live: a
GET /v1/models span exported with http.route="/v1/chat/c..."). strings.Clone
pins our own copy for every retained attribute. Tests cover emission, attribute
mapping, error status, parent/child propagation, the skip set, and an env-gated
on-wire live test (CLOUD_ZAP_LIVE_ENDPOINT) that ships real spans to a ZAP
receiver — the async-export + ctx-reuse path the in-memory recorder can't model
(and the one that surfaced the corruption).

* feat(o11y): make cloud the single tracer-provider owner — one wire (ZAP)

The fused cloud binary set the ZAP provider first, then ai.Bootstrap (during
MountAll) called hanzoai/ai object.InitTelemetry which, seeing the CR's
OTEL_EXPORTER_OTLP_ENDPOINT, installed a SECOND, competing OTLP provider. OTel
global delegation is first-writer-wins for handles created before the first
SetTracerProvider (cloud's package-level tracers keep ZAP), but the ai GenAI
tracer is resolved lazily AFTER the second Set, so its spans stranded on
OTLP(:4318) while ZAP owned the rest — the split that left receiver="zap" span
count at zero for hanzo-cloud (verified live: spans arrived only via
receiver="otlp").

Composition-root fix: once cloud installs the ZAP provider, clear the
OTLP-exporter env (OTEL_EXPORTER_OTLP_ENDPOINT / _TRACES_ENDPOINT) so no embedded
subsystem installs a competing OTLP provider. Exactly one provider (ZAP), one
wire, deterministic regardless of CR env drift. In the fused binary OTLP is only
ever the collector's interop RECEIVER, never cloud's exporter; standalone
cmd/aid (no ZAP endpoint) is unaffected and keeps its OTLP path.

---------

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-04 00:56:42 -07:00
hanzo-dev 5998e664ff fix(kb): RED H1/M1 — inject X-Piece-Run-Secret on the auto piece-run call
The auto engine now gates /v1/auto/pieces/{piece}/run on a shared secret (it
trusts X-Org-Id absolutely, so the write+SSRF surface needs an in-band caller
proof). cloud is the ONLY legitimate caller — it resolves each org's real token
and pins the provider URL — so it presents the secret (from KMS, the same
PIECES_RUNNER_SECRET) as X-Piece-Run-Secret. pieceSync fails closed if the
secret is unset (a doomed call the engine would 403 anyway).
2026-07-04 00:05:48 -07:00
hanzo-dev b004b2327d feat(auto+kb): hybrid connector layer — /v1/auto proxy + activepieces long-tail
Mounts workflow automation + the ~280-app activepieces long tail in-platform,
per-org, through the ONE knowledge store. Go core + JS on-demand.

clients/auto: /v1/auto/* per-org REVERSE PROXY to the standalone Hanzo Auto
engine (one engine, one store — not re-embedded). The auto engine trusts
X-Org-Id absolutely, so this proxy IS the trust boundary: it GATES on a
validated principal (refuses the anon-forge X-Org-Id-with-no-credential path)
and re-stamps outbound identity from validated values only (strips every
smuggled authority alias). Pure gate+proxy in clients/auto/proxy (5 isolation
tests: anon-forge 403, per-org forward, smuggled-header strip, path preserved).

clients/kb: the first LONG-TAIL connector (notion). Identical OAuth lifecycle
(HMAC-org-bound state, KMS token path) but its PULL runs the activepieces JS
piece through the auto engine's on-demand runner (sync_piece.go) instead of
native Go — then files each record via the SAME framework.Ingest path. One
ingestion path; a JS-sourced doc lands in the same per-org store+index as a
Go-sourced one. clients/kb/notion is the pure record-shaper (6 tests).

ONE catalog: /v1/kb/connectors/catalog lists native Go + long-tail piece
connectors in one list, each badged kind native|piece (3 tests).

RED LOW-1: collection() + kmsRef() now route org through provisioning.SanitizeOrg
(the codebase's ONE normalizer) so the physical Qdrant namespace + KMS path are
injective in the owner ("a b" != "a_b") — defense in depth under the payload.org
filter. Injectivity tests + KB integration tests updated to derive the collection
through the helper (robust to the normalizer).

All tests green under CGO=0 (production config). Full binary boots; /v1/auto
mounted, anon-forge 403, catalog gated, spine (kb/framework health) 200.
2026-07-04 00:05:48 -07:00
zeekayandClaude Opus 4.8 c058c5dde7 fix(billing): payment-methods → commerce portal read; pin the full subject-key set
Address review: proxy the customer card list to commerce's admin-group PORTAL
endpoint (GET /v1/billing/portal/payment-methods), not the user-group
/payment-methods. PortalPaymentMethods 400s without a ?customerId=, so pinning
only ?user= would break it — generalize the proxy's subject pinning to the FULL
commerce edge-auth key set {user,userId,customerId} (now a shared
billingSubjectKeys var, identical to clients/console + commerce), pinned to the
caller's OWN org on every request. This leaves NO billing endpoint unfiltered
regardless of which param it reads (usage/balance/gpu-eligibility read user;
portal/payment-methods requires customerId) and is strictly more tenant-safe.
pinSubjectBody reuses the same var. The console keeps requesting the same-origin
/v1/billing/payment-methods (mounted here); the portal hop is server-side only.

Tests updated: widen-scope now asserts every subject key is pinned (org dropped);
payment-methods asserts the portal path + customerId pin. 14/14 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 23:37:00 -07:00
hanzo-dev 350fe79ffe chore(deps): bump hanzoai/ai → v1.800.2 (durable worker retention + fail-fast OpenAI embedding default) 2026-07-03 22:47:33 -07:00
zeekayandClaude Opus 4.8 72522412a1 feat(billing): proxy GPU launch-gate + payment-methods on customer /v1/billing
The customer billing proxy (clients/billing) exposed only usage+balance, so the
console GPU launch gate — card-on-file check + prepaid eligibility read + the
prepay-only charge — had no org-scoped cloud route and fell through to the
console pkg's /v1/billing/* wildcard (admin-shaped 403). Extend the SAME
commerce-proxy helper (no new HTTP/auth machinery) with the three enforcement
routes commerce 1.46.28 serves (api/billing/gpu_charge.go + portal):

  GET  /v1/billing/gpu-eligibility -> commerce GET  (read-only launch gate:
       {eligible,reason,prepaidAvailable,cardOnFile,...}; amountCents +
       minPrepaidCents + currency pass through)
  POST /v1/billing/gpu-charge      -> commerce POST (prepay-only, card-required
       debit; commerce enforces both gates + gpu-tagging server-side; status
       forwarded verbatim: 201 ok / 402 card_required|insufficient_prepaid)
  GET  /v1/billing/payment-methods -> commerce GET  (masked brand+last4 cards
       for the card-on-file check; type passes through)

All org-scoped to the caller's OWN org from the VALIDATED IAM owner claim
(principal.Tenant), identical to usage/balance — a client can never widen scope:
the GET subject is pinned to ?user=<org> (commerce's privileged payment-methods
branch filters CustomerId on it), and the POST body subject is pinned to the
{user,userId,customerId} set (mirrors clients/console + commerce edge-auth), so
a forged body can never charge another tenant. New commerceProxy.post + the
pinSubjectBody helper; no principal -> 401, unconfigured -> 501.

Tests: gpu-eligibility scope+passthrough+forged-subject overwrite;
payment-methods scope+type; gpu-charge body-subject pin + 402 verbatim + 401
no-principal + 501 unconfigured; pinSubjectBody unit. 14/14 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:40:11 -07:00
zeekayandClaude Opus 4.8 9c82576792 feat(billing): customer-facing org-scoped /v1/billing/{usage,balance}
On console.hanzo.ai the ingress routes /v1/* straight to cloud-api:8000 (the
console Next BFF is only at "/"), so the console's /v1/billing/usage +
/v1/billing/balance calls land on cloud-api — NOT the console's per-tenant
commerce proxy. cloud-api wired commerce billing ONLY under the admin-gated
aggregate (/v1/admin/*), so a normal org owner (davelorenzini/maxpower) hitting
/v1/billing/usage had no customer route and was denied -> 403 -> the "Access
required" wall on EVERY product overview + o11y usage panel.

Add a customer-facing, org-scoped billing READ surface (clients/billing):
GET /v1/billing/usage and /v1/billing/balance. Org = the VALIDATED IAM owner
claim (principal.Tenant — the trusted X-Org-Id the identity middleware minted
from the caller's verified session; never a client header), so a customer reads
ONLY their OWN org. Proxies commerce with COMMERCE_SERVICE_TOKEN + X-Org-Id=<org>
and the per-org billing subject pinned to user=<org> (admin.orgSubject /
metering identityFromCtx — verified live: user=<org> returns the real wallet);
returns commerce's raw body + status verbatim (the console parses the raw ledger).
Tenant isolation: no client-supplied subject/org query is ever forwarded, so
scope can never be widened. The all-orgs god view stays admin-only (clients/admin).

Tests: subject pinned to caller org, forged user/userId/customerId/org dropped,
start/end/currency pass through, no-principal -> 401 (commerce untouched),
unconfigured -> 501, commerce status forwarded verbatim.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:28:52 -07:00
zeekayandClaude Opus 4.8 3b0342ae43 fix(deps): bump hanzoai/ai → v1.800.0 (async Sora-style /v1/videos #68)
Pulls ai#68: async OpenAI Sora-style video API onto the cloud router.
POST /v1/videos/generations returns a video_<uuid> job immediately (was
sync ~104s → console /ai proxy 502); GET /v1/videos/{id} polls; GET
/v1/videos/{id}/content streams the MP4. Metering exactly-once (hold on
create, settle on completion, reaper releases abandoned), ownership-secured.

ai v1.800.0 go.mod is identical to v1.799.3 — no dependency-graph change;
only the hanzoai/ai hash lines move. Verified: cloud binary builds clean
(CGO_ENABLED=0) and the router now serves /v1/videos/generations,
/v1/videos/:id, /v1/videos/:id/content (spark-video backend).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:27:59 -07:00
117fc6e98b feat(tracker): native /v1/tracker issue tracker on SQLite (#109)
Durable replacement for the Huly/Svelte hanzo.team tracker whose upstream
each-block reactive-batching render race left issue lists rendering zero
rows. Native Go over one org-scoped SQLite store: rows return as plain JSON
and render deterministically (no Svelte reactivity in the path).

clients/tracker/store.go  — projects + issues on {DataDir}/tracker.db, the
  same modernc/SQLCipher driver + MaxOpenConns(1)+WAL pattern as projectsvc/
  crm. Per-project monotonic issue numbering allocated inside one tx (never
  races under the single-writer conn). Cascade delete in a tx. org column is
  the tenancy key; every query filters WHERE org=?.
clients/tracker/tracker.go — /v1/tracker/projects[/:key][/issues[/:num]] CRUD.
  org = principal.Tenant (validated IAM owner claim, HIP-0026), 403 otherwise.
  Status/priority closed sets; board/list via ?status=. Create wired to the
  shared per-org billing seam (free by default; ops prices via
  CLOUD_TRACKER_FEE_CENTS). Registered order 129, before the AI /v1/* catch-all.
subsystems/subsystems.go — one blank import links it into the binary.

Store CRUD/numbering/status-filter/cascade/tenant-isolation proven green on
real SQLite (clients/tracker/tracker_test.go).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:39:07 -07:00
hanzo-dev 1287a24255 chore(deps): bump hanzoai/ai → v1.799.3 (real default embedding provider — ingest produces real vectors); ships with the durable-enqueue default-namespace fix 2026-07-03 21:08:46 -07:00
hanzo-dev 3c5997411e fix(durable): enqueue ingest into the 'default' namespace, not a per-org one
ROOT CAUSE of durable ingest silently running inline (round-trip: github ingest blocked
25s+, no workflow in Tasks): the embedded engine only registers 'default' at boot and does
NOT lazily create namespaces on ExecuteWorkflow, so dialing Namespace:<org> made the worker
poll a non-existent namespace and BLOCK → EnqueueIngest hung → handler fell back to inline.
Fix: dial 'default' (always registered). Data isolation is unchanged — it's in the workflow
INPUT (IngestSource is owner-scoped), never the namespace. Now github/crawl enqueue a real
durable workflow that appears in Tasks (under default).
2026-07-03 20:36:16 -07:00
hanzo-dev 31fa4adff8 fix(deps): correct hanzoai/ai to v1.799.2 (v1.799.1 concurrent work + OpenAI-compat /v1/audio/speech) — supersedes the mis-numbered v1.796.6 downgrade 2026-07-03 18:13:45 -07:00
hanzo-dev c9a3f5a636 chore(deps): bump hanzoai/ai → v1.796.6 (OpenAI-compat /v1/audio/speech; audio+image+video all native) 2026-07-03 18:12:22 -07:00
hanzo-devandGitHub 35eb75084d fix(kms): close V6+V1 pre-activation blockers on the PaaS KMS→Secret sync (#108)
V6: accept the owner-bound per-tenant machine audience (<owner>-platform-kms) so a real client_credentials sync token clears SanitizeIdentity + the /v1/kms guard; decoupled from global-admin (isKMSMachinePrincipal). V1: per-IP rate limit + MaxConnsPerHost(h2-off) on the public login broker. Blue→Red→Blue→Red: Red SHIP (0 crit/high/medium). Activation runbook in the PR body + EnsureOrgIdentity doc.
2026-07-03 17:55:23 -07:00
hanzo-devandGitHub 7ed598bde6 feat(kb): Knowledge Base + unified AI memory + connectors on the DocType engine (#107)
Fourth app lane (after cms/erp/help): a Notion-like KB + agent memory + app
connectors as a 'kb' module on clients/framework — no new Base, no new database.

Fixtures (module kb): kb-page (wiki tree via a self-Link parent + Lexical
RichText body), kb-memory (agent memory: note/fact/observation), kb-source
(connector-ingested docs), kb-connector (connection metadata; OAuth token in KMS,
never in the doc/logs). All CRUD/permissions/tenant-isolation/install are the
framework's generic /v1/framework/* surface + the generic @hanzo/ui renderer.

Indexing (index.go, the ONE vector-write path): an after_save hook embeds every
knowledge write (page/memory/source) via the gateway and upserts it into the org's
OWN Qdrant collection (kb_<org>) with an org-pinned payload; on_trash removes it.
Human wiki + AI memory are ONE per-org knowledge store, indexed once. Fail-open at
index time (a vector outage never blocks a knowledge write); fail-honest at query.

Retrieval (subsystem.go): POST /v1/kb/search is the org-scoped RAG entry point —
collection AND payload filter both pinned to principal.Tenant, so a caller can only
retrieve its OWN knowledge. Degrades to an honest empty result when the index is
down.

Connectors (connectors.go, sync.go): per-org OAuth to GitHub/Slack/Google that
ingest external docs INTO the same store + index (via framework.Ingest → same
after_save hook — one ingestion path, never forked). OAuth state is HMAC-bound to
the validated org (defeats login-CSRF/mix-up); tokens live in KMS at a per-org path.
GitHub is end-to-end (repo READMEs + issues); Slack/Google share the OAuth
lifecycle + normalizer with an honest 'listing not yet implemented' depth marker.

framework.Ingest/UpdateData/FindByField/Search: the in-process create-with-hooks
API first-party producers (the connector sync) use so off-request writes run the
exact validate + lifecycle pipeline and stay physically org-scoped.

Tests (16, all green): engine-Validate on every fixture; page self-Link + Lexical
body; connector-has-no-token; per-org pointID/collection/kmsRef isolation; payload
org-pin; OAuth-state org-binding + tamper/cross-provider/wrong-key rejection;
Ingest validates+fires-hooks+org-scoped. Integration (real SQLite + mock
Qdrant/embeddings over the real HTTP surface): install → create kb-page in org A →
after_save indexes into kb_A → search as A retrieves it → search as B sees NOTHING
(no cross-org leak); forged-org search → 403; kb-memory lands in the same kb_A
namespace.
2026-07-03 17:15:08 -07:00
hanzo-dev f3b97e1588 harden(integrations): red-team defense-in-depth on the OAuth connector plane
Adversarial review of the connector framework (state HMAC, nonce custody,
per-org KMS token custody, console redirect). Core design held: state forgery,
nonce replay/race, cross-tenant KMS pathing, principal-forge, and the seam
fail-closed contract were already sound. Fixes are defense-in-depth + red-style
proving tests; the contract is unchanged (no /api/, no /v1/slack route).

Fixes
- Ingest sanitization (vector 7): provider-supplied NON-secret metadata
  (account label / external id / bot user id / scopes) is now stripped of C0
  control chars + DEL and length-bounded at the ONE framework ingest point in
  callback, before it is logged, stored, or reflected. Kills log-line/separator
  injection via a crafted Slack workspace name and bounds per-org row growth.
  Secret token VALUES bypass this and go straight to the KMS seal.
- Open-redirect hardening (vector 4): success/failRedirect fold into one
  query-escaping consoleRedirectURL builder (DRY + unit-testable). Confirms the
  Location host is always the env-fixed console origin; hostile provider detail
  can't break out of the query into host/scheme/path or inject CRLF.
- Request bounds (vector 10): callback rejects an oversized OAuth `code`
  (maxCodeLen, also covers the in-process ZAP plane); verify() rejects an
  oversized state token before any base64 work (maxStateLen).
- kmsDelete uses errors.Is(kms.ErrSecretNotFound) for wrap-safe idempotency.

Tests (all real, -race green; 39 pass)
- state: dot-injection/degenerate split, MAC-checked-before-parse,
  validly-signed-but-hostile-org rejected, overlong rejected.
- store: concurrent single-winner nonce consume (race proof).
- http: no-open-redirect property, end-to-end metadata sanitization,
  disconnect anonymous-forge -> 403 (secret+row survive), github scaffold
  callback fails closed at the Configured gate before any exchange,
  oversized-code rejected.
2026-07-03 17:12:25 -07:00
hanzo-dev 5ebb8216e7 test(integrations): real TDD suite for the connector framework
29 tests, all green under -race:
- state: sign/verify, tamper, expired, wrong-provider, wrong-key, malformed, key resolve
- store: org isolation, connected_at preservation, external-id resolve, nonce single-use, GC, idempotent delete
- slack: authorize URL params, exchange parse (httptest ok+error), revoke, registration shape
- integrations: list shape, connect unconfigured->503 / no-principal->403 / KMS-down->503 / invalid-org->400,
  callback happy path (real KMS-sealed token + connection row + 302 console), replay rejected, tampered state rejected,
  disconnect (KMS+row deleted, idempotent), org isolation, seam fail-closed when unmounted.
KMS custody uses a REAL clients/kms.Client with a 32-byte master key; no mocks in prod paths.
2026-07-03 17:12:25 -07:00
hanzo-dev cf2a38174f feat(integrations): generic OAuth connector framework (Slack ref, GitHub scaffold)
Provider-agnostic /v1/integrations plane: one registry, N providers.
Slack = full reference impl (OAuth v2 bot-token); GitHub = scaffold + #51 seam.
Per-org token custody in KMS (sealed); state-authed HMAC callback with
single-use nonce; org derived ONLY from signed state on the public callback.
Generic /v1/integrations/{provider}/callback — no /v1/slack/* (team-go owns it).
Wired at order 137 (after security 136, before AI 150).
2026-07-03 17:12:25 -07:00
hanzo-devandGitHub e255a4e69b fix(ci+sqlite): tag-after-push release + single "sqlite" driver in both cgo/nocgo (#105)
release.yml — invert the tag/build order so a git tag can NEVER exist without a
pushed, boot-verified image (the phantom v1.786.42/43 → ImagePullBackOff cause):

  main push → compute next version → build → SMOKE (boot to "listening") →
  push image → git tag (receipt) → notify universe

- Tag is minted only AFTER the push step succeeds; any build/smoke/push failure
  fails the run before the tag step → fail-run-no-tag.
- concurrency group `release-cloud` (cancel-in-progress:false) serializes runs so
  two main pushes can't collide on a number; the queued run re-reads tags and
  lands on the next patch → monotonic.
- next version = max(highest git tag, highest pushed ghcr container tag) + 1,
  folding in container tags so a pushed-but-untagged number is never reused.
- removed the `tags: v*` trigger (this workflow now OWNS tags — a hand-cut tag has
  no image behind it and won't build); notify-universe fires only on a successful
  build+tag, so universe is never told about a phantom.

sqlite — fix `panic: sql: Register called twice for driver sqlite` under
CGO_ENABLED=1 (blocks `go test ./...` + clean cgo rebuilds). #96 moved cloud's
stores to github.com/hanzoai/sqlite (mattn under cgo) while several embedded deps
still import modernc.org/sqlite directly (ai, tasks, base, commerce, o11y, orm) →
two packages register "sqlite" under cgo. Prod is CGO_ENABLED=0 (all one modernc
package, deduped) so prod never paniced; the panic is cgo-only.

- bump github.com/hanzoai/sqlite v0.1.4 → v0.1.5: adds the `sqlite_purego` opt-out
  build tag that forces the fork's pure-Go (modernc) backend under cgo. Default
  cgo path is unchanged (mattn/SQLCipher) so IAM/commerce encryption is untouched.
- bump github.com/hanzoai/ai → the commit that routes object/adapter.go + cmd
  tools through hanzoai/sqlite instead of modernc (never modernc directly).
- Makefile: CGO_ENABLED?=0 default (matches the shipped Dockerfile) so `make
  build`/`make test` register "sqlite" once and exactly mirror prod; new `test-cgo`
  target proves the cgo path via `-tags sqlite_purego`.

Verified: CGO_ENABLED=0 `go build/test ./...` and CGO_ENABLED=1 `-tags
sqlite_purego go build/test ./...` both pass with NO panic (the eval package that
panicked now passes in both modes). Pre-existing clients/s3 + clients/functions
billing-attribution test failures are unrelated (present on clean main, both
modes) and out of scope.
2026-07-03 17:05:47 -07:00
hanzo-devandGitHub 07a55382cf fix(console): close encoded-traversal gap on the /v1/billing money surface (#106)
billing.go's isSafeSegment left percent-escape (`%2f`/`%2e`) and matrix-param
(`;`) segments undecoded, so `/v1/billing/x/..%2fadmin` forwarded
`x/..%2fadmin` verbatim; the Go http client + commerce's own router
decode+normalize it downstream into a path that tunnels PAST /v1/billing into
another surface. commerce.go had already patched its call site with an inline
`%;` check — braiding the policy across call sites.

Harden the ONE shared segment guard instead: isSafeSegment now rejects empty,
`.`/`..`, slash, backslash, percent-escape, matrix-param, and any control char.
Both bridges (billing + commerce) get the complete guard from one place, and
commerce.go's call site drops the now-redundant inline check.

Regression test: `..`, `%2f`, `%2e%2e`, and `;` all 400 and never reach
upstream (proven to fail against the pre-fix guard).
2026-07-03 17:05:08 -07:00
hanzo-devandGitHub 8dc554f76c feat(console): per-tenant /v1/commerce/* store bridge for the static console (task #41) (#104)
The store twin of the just-merged /v1/billing/* bridge (#102). #81 namespaced
the console's commerce store calls to the canonical same-origin /v1/commerce/*
(SPA->server->/commerce proxy); the statically-exported console now terminates
every dynamic call at the unified cloud binary's /v1, so the binary must
reverse-proxy /v1/commerce/* to the commerce service. Without it the commerce
embed was incomplete.

clients/console/commerce.go serves GET|POST|PUT|PATCH|DELETE /v1/commerce/<path>
-> commerce's BARE store surface /v1/<path> (the console-side 'commerce'
namespace is stripped: the deployed commerce cmd/commerced mounts
api.Route(Group('/v1')), so products/orders/customers/... live at /v1/<kind>
while money lives at /v1/billing/*). Exactly the mapping console2's next.config
rewrite proved live (/v1/commerce/:path* -> /commerce/v1/:path* ->
commerce.svc/v1/:path*).

IDOR-safe: the org is the VALIDATED caller's own (resolveCaller ->
principal.Validated / c.Org()), never a client value; a bearer-less forged
X-Org-Id has no validated principal and is refused 403 before any commerce call.
Reuses the commerceDo(base,token) S2S transport billing.go/topup.go share
(admin COMMERCE_SERVICE_TOKEN + X-Org-Id, which commerce's EdgeAuth trusts only
behind the service token). Least privilege: a store-head allow-list (identical
to console2 proxy-allow.ts COMMERCE_HEADS) so the bridge can never tunnel to
/v1/billing (its own subject-scoped bridge), /v1/checkout, or tenant admin.

Hardened over a naive port: rejects percent-encoded path segments (%2f/%2e),
which the router leaves undecoded in the wildcard param but the Go http client +
commerce's router normalize downstream -- 'product/..%2fbilling' would otherwise
tunnel to /v1/billing past the allow-list (RED). Mirrors console2 pathIsClean.

/v1 only. CGO_ENABLED=0 go build ./... ok; go test ./clients/console/ ok.
2026-07-03 16:54:19 -07:00
hanzo-devandGitHub 7134985bae feat(platform): activate PaaS KMS→Secret sync — org-scoped coords + login broker + per-tenant identity (#42) (#103)
Companion to #89 (KMS-sealed PaaS secret env) + universe #321. The sync was
INERT for two structural reasons this closes, and the per-tenant scoping the
task requires is now enforced at cloud's ONE auth boundary — proven by test.

WHY IT WAS INERT (coordinate drift + wrong CR shape):
  - Seal path ≠ read path. cloud sealed at /platform/tenant-<org>/<app> but the
    kms-operator reads through cloud's org-scoped surface /v1/kms/orgs/<org>/
    secrets/... which folds to /orgs/<org>/... — a DIFFERENT record, never found.
  - The CR set projectSlug="platform" (a literal) and omitted secretsScope.keys,
    which the CRD REQUIRES (MinItems=1; luxfi/kms has no list endpoint). Either
    alone starves the sync.
  - hostAPI carried a /v1/kms suffix; the operator appends /v1/kms/... itself, so
    login + read URLs doubled the prefix.

THE FIX (secrets.go):
  - Seal at the org-scoped coordinate  orgs/<org>/platform/<app>/<KEY>  — the EXACT
    store path cloud's org-scoped read surface addresses. Seal and read are now one
    coordinate (proven: TestPaaSSecretSealReadAlignment).
  - CR carries projectSlug=<org>, secretsPath=platform/<app>, envSlug=default, and
    the explicit sorted key roster. hostAPI = KMS root.

PER-TENANT SCOPING (the NON-NEGOTIABLE) — enforced, not hoped:
  - The operator authenticates as a per-tenant IAM machine identity (owner=<org>)
    via the NEW /v1/kms/auth/login broker (kmssvc/login.go): it exchanges the
    caller's clientId/clientSecret at IAM's client_credentials endpoint and returns
    IAM's owner-scoped token verbatim. cloud is a relay, not an issuer.
  - cloud's org-scope guard admits /orgs/<org>/... ONLY when the VALIDATED owner ==
    that org (SanitizeIdentity derives owner from the token, ignoring client
    X-Org-Id). So tenant-A's credential can NEVER read tenant-B's path — 403 before
    the store is touched (proven: TestPaaSSecretCrossTenantDenied).
  - credsSecret is a PER-TENANT name in the tenant namespace — never a shared
    platform-wide reader (that would be a cross-tenant hole = NO-SHIP).

PROVISIONING (ensureTenantKMSAuth) — fail-closed, one privileged seam:
  - On app-create/deploy cloud ensures the tenant's owner=<org> credential is
    projected into tenant-<org> as the creds Secret the CR references, via an
    injected tenantKMSIdentity provider. nil provider (default) ⇒ honest "pending"
    (operator can't log in ⇒ reads nothing) — NEVER a shared or wrong-org identity.
  - FLAGGED: the concrete provider needs a scoped IAM admin credential cloud does
    not yet hold (clients/admin/iam.go replays the caller's cred, no service
    identity). Until wired/verified, sync stays safely pending. Flip ON = provision
    the per-tenant identity; the security invariant holds regardless of how it is
    minted (the guard is the enforcement point).

Tests (CGO_ENABLED=0 — prod/CI build mode; CGO test builds double-register sqlite,
a pre-existing module issue): alignment round-trip, cross-tenant 403 (+ A→A 200,
B→B 404, unauth 403), login broker happy/bad-cred/malformed, per-tenant provisioning
(fail-closed when unprovisioned, org-bound projection, no cross-tenant ask). Existing
kmssvc red-team guard vectors unchanged and passing.
2026-07-03 16:44:28 -07:00
7b99509335 feat(console): per-tenant /v1/billing/* bridge for the static console (task #41) (#102)
The BFF catch-all sweep's one real "server work -> Go handler" case. console2's
app/billing/v1/[...path]/route.ts injected the commerce SERVICE token and pinned the
caller's billing subject server-side (work a static export cannot do). Ported to
clients/console/billing.go: GET|POST /v1/billing/* forwards to commerce with the admin
COMMERCE_SERVICE_TOKEN, scoping every request to the VALIDATED caller's own subject
(billingSubject + scopedBillingSearch + scopedBillingBody, the Go port of console2's
billing-scope.ts), so a tenant can only read/act on its OWN ledger.

IDOR-safe: the subject is the validated principal (resolveCaller: principal.Validated /
c.Org() / c.User()), never a client userId/org. A forged X-Org-Id with no validated
X-User-Id is refused (403). Unset COMMERCE_SERVICE_TOKEN -> honest 501.

DRY: commerceDo (topup.go) refactored to take (base, token) so the wallet top-up AND
this bridge share one S2S transport. No behavior change to topup (its tests pass).

Tests (billing_test.go): billingSubject personal/dedicated, subject pin + org drop +
passthrough (query & write body), forged-value overwrite, 403 no-principal, 501 no-token,
and the end-to-end scoped forward to a fake commerce. go build ./clients/console/ = 0;
go test (CGO_ENABLED=0) ok. The default-CGO modernc-vs-CGO sqlite double-register that
panics the package is a pre-existing repo-wide issue (separate sqlite-one-driver lane).

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-03 16:28:32 -07:00
1c35acea85 fix(identity): spoof-proof X-Project-Id/X-App-Id at the SanitizeIdentity boundary (#101)
SanitizeIdentity minted an un-forgeable X-Org-Id but passed the org sub-scopes
X-Project-Id / X-App-Id through verbatim, so a caller could assert ANOTHER org's
project as a compute_usage attribution key or per-project sub-scope. Sanitize the
sub-scopes in the ONE trust boundary:

- Delete every X-Project-Id/X-App-Id on ingress (no raw client copy survives),
  then re-inject only for a validated principal, against the acted-as org.
- Refuse a cross-org X-Project-Id: a project REGISTERED to a DIFFERENT org than
  the validated org is dropped; the caller's own registered project and
  unregistered free-form within-org labels survive (projectIsForeign; fail-closed
  on a registry error).
- Drop both sub-scopes entirely on the anonymous path.
- X-App-Id is a caller label, not an isolation boundary (no cloud subsystem
  scopes access by it; the un-forgeable org bounds any mislabel) - forwarded on
  the validated path, dropped when anonymous.

Dependency-inverted like sites.SetResolver: projectsvc registers a
TenantScopeResolver at Mount; cloud never imports the project registries. The
visor proxy forwards the now-validated sub-scopes so compute attribution lands on
the caller's own project. X-Org-Id anti-forgery is unchanged.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 16:24:37 -07:00
hanzo-dev 14d12cbd54 chore(deps): bump hanzoai/ai → v1.796.5 (zen-video spark-video provider); integrated blue/cloud-ml-sa-auth (cloud-ml SA k8s auth)
release / build-amd64 (push) Failing after 6m17s
release / notify-universe (push) Skipped
2026-07-03 16:24:20 -07:00
hanzo-dev b4b8248a45 feat(framework): RichText fieldtype for the CMS WYSIWYG body
release / build-amd64 (push) Failing after 5m47s
release / notify-universe (push) Skipped
Add a RichText fieldtype to the DocType engine so a content field can hold a
Lexical EditorState JSON string (the console renders it with a native WYSIWYG).
The value is opaque text — validate.go coerces it verbatim (clipped to the scalar
bound), stored in the schemaless doc blob, round-tripping through create→get with
no shape enforcement (that's a UI concern). Minimal + fail-closed: the fieldtype
is added to the validated allow-set, so an unknown type is still rejected.

CMS: the seeded content body (Article/Page/Post) becomes RichText, and a
Data field is added for optional per-project scoping (the console's org→project
switcher filters content by it; empty = org-level). One CMS engine; project is a
filter.

Tests: TestDocTypeValidate accepts a RichText field; TestFieldTypeValidation
proves a Lexical JSON string round-trips verbatim through validation.
2026-07-03 16:18:14 -07:00
hanzo-blueandhanzo-dev 68c9e3579d ml(clients): authenticate as dedicated cloud-ml SA via HANZO_ML_TOKEN_FILE
The ML control plane (clients/ml, /v1/ml) ran under the pod SA (cloud-api),
braiding KServe/Kubeflow cluster reach onto the product-API identity. newDynamic
now re-scopes the in-cluster client to a mounted cloud-ml ServiceAccount token
when HANZO_ML_TOKEN_FILE is set (keeps in-cluster host+CA, swaps only identity),
fail-closed if the configured token is unreadable. Unset -> unchanged behaviour
(pod SA) for local/dev and pre-cutover. Pairs with universe ml-rbac.yaml
(cloud-mlsvc ClusterRoleBinding -> cloud-ml). go build + go vet clean.
2026-07-03 16:14:29 -07:00
hanzo-dev 4ac1698b75 docs(cloud): wave map — remaining Go services and their merge disposition
The execution queue for "all Hanzo Go services merge into the one cloud binary"
(HIP-0106): wave 0 already-merged (8 embedded modules + 37 native clients),
wave 1 tasks+visor (this build), wave 2+ mount queue (notify2, extract-svc,
playground, ...), and keep-standalone with reasons (iam/gateway/kms-MPC/registry
/s3/docdb/chain daemons). Waves are sequential through go.mod to avoid the
in-flight collision this wave hit.
2026-07-03 16:11:00 -07:00
hanzo-dev d2d654e63b feat(tasks): mount Tasks HTTP+UI surface on the ONE in-process engine
Consolidates the Tasks product surface into cloud — the follow-up durable.go
named ("consolidating that surface into cloud is the follow-up"). durable.go
already embeds the ONE tasks engine in-process (loopback ZAP :19999) for ai's
durable ingest; this mounts THAT SAME engine's HTTP handlers, so the Tasks
UI/API reads the same durable state as ingest. One engine, one binary, one way —
no second Embed.

- clients/tasksvc (order 147, before ai's /v1/* catch-all): adapts the shared
  engine's HTTP surface onto the zip mux at /v1/tasks/* + the embedded React UI
  at /_/tasks/*. The engine is created after MountAll, so the surface resolves
  cloud.EmbeddedTasks() lazily per request (503 fail-soft until wired).
- gate: settings/cluster/health stay open (no per-org data); the data surface
  (namespaces/workflows/mcp/events) refuses an unvalidated principal (403, never
  the unscoped store) and threads the gateway-validated org into the engine via
  tasks/pkg/auth.WithIdentity — per-(org,ns) shard isolation, matching the rest
  of the cloud data plane (clients/principal).
- durable.go: export EmbeddedTasks() — the single shared-engine accessor.
- bump hanzoai/tasks v1.43.0 → v1.46.0 (in-proc identity seam + one sqlite
  driver). No local replace directives.

Proof: /v1/tasks/cluster returns nodeId "cloud-tasks" (durable.go's engine),
settings 200, data routes 403 without a principal, /_/tasks UI 200.
2026-07-03 16:11:00 -07:00
hanzo-devandGitHub 28d504fd93 debrand: langfuse -> o11y/observability in our prose & comments (#100)
Drop the Langfuse brand from our own strings (code comments, docs,
config labels), mirroring the signoz->o11y product rename. Meaning preserved;
comments/docs/labels only, no functional change.

Intentionally KEPT (references to the external Langfuse product / upstream
dependency / integration contract, not our brand):
- LiteLLM success_callback/failure_callback ["langfuse"] + LANGFUSE_* env
  var names (the litellm langfuse-callback contract; renaming breaks emission)
- infra/k8s/langfuse/* (deploys upstream langfuse/langfuse:3 OSS image)
- o11y/langfuse-otlp-fanout.yaml + console-langfuse-keys (trace-fanout lane)
- console NOTICE (MIT attribution to Langfuse GmbH for clean-room UX)

Trace pipeline (ai emit -> collector -> backend -> console Observe) unchanged.
2026-07-03 16:06:15 -07:00
hanzo-dev a1f6aa8eb3 Merge native ERP + Helpdesk DocType lanes (feat/erp-help-framework-modules, RED-PASSED 9/9 race)
release / build-amd64 (push) Failing after 5m44s
release / notify-universe (push) Skipped
ERP (clients/erp): ERPNext-core DocType fixtures + native-Go GL/stock hooks —
idempotent deterministic-leg postings (exactly-once under concurrent submit),
on_cancel reversal, finite-guarded totals, double-entry submit gates.
Help (clients/help): Frappe Helpdesk-core fixtures, pure DocTypes, no hooks.
Both register on the framework engine at init; installed per-org via
/v1/framework/modules/{erp,help}/install. No new HTTP surface — ERP/Help ARE
documents on /v1/framework/*, drawn by the same generic DocType renderer as CMS.

Verified CGO=0 (production Dockerfile config): binary boots clean (no double
sqlite driver register), /v1/framework/health 200, /v1/models unshadowed (503
real AI handler), erp+help tests green under -race.
2026-07-03 15:51:13 -07:00
2e1b8083c1 feat(visor): /v1/bots surface + machine agent-binding proxies (CLOUD, mirrors machines) (#98)
release / build-amd64 (push) Failing after 6m13s
release / notify-universe (push) Skipped
Mounts /v1/bots in cloud as the sibling of /v1/machines — a Bot is an
Agent(cloud /v1/agents) + a kind=bot Machine(vm) + their AgentBinding,
composed as a thin proxy over the SAME Visor client the machines routes use:

  GET    /v1/bots                    list (vm /v1/machines?kind=bot + bindings join)
  POST   /v1/bots/launch             machine launch{kind:bot} THEN bind-agent
  GET    /v1/bots/:id                machine + its binding (404 if not a bot)
  DELETE /v1/bots/:id                unbind THEN terminate the machine
  POST   /v1/bots/:id/:action        message=agent run | stop|pause=unbind

Plus the machine agent-binding proxies cloud lacked (vm already serves them):

  POST   /v1/machines/:id/bind-agent
  GET    /v1/machines/:id/agent-binding
  DELETE /v1/machines/:id/agent-binding
  GET    /v1/agent-bindings

Every route org-gated by the validated principal (principal.Tenant), forwarded
to vm as ?owner=<org> — 403 without a valid IAM owner, exactly like machines.
No vm change: kind=bot launch + bind-agent are already live at visor:19000.
message runs the bot's bound agent via the ONE agent runner (/v1/agents/:agent/run).

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-03 15:47:45 -07:00
hanzo-dev fc8c6dc811 fix(durable): pin tasks engine DataDir to cloud data root (distroless has no /tmp)
release / build-amd64 (push) Failing after 6m16s
release / notify-universe (push) Skipped
Embed's default os.MkdirTemp("") resolves to /tmp, absent in the distroless cloud image
→ 'tasks.Embed: tempdir: stat /tmp: no such file or directory' → fail-soft to inline.
Pin DataDir to {deps.DataDir}/tasks (MkdirAll first) so the in-process engine actually
boots. Verified: without this the warn+inline-fallback fired cleanly in prod (v1.786.48).
2026-07-03 15:38:59 -07:00
hanzo-dev 8bdb16b33a feat(durable): embed the ONE tasks engine in-process — unified durable queue
release / build-amd64 (push) Failing after 7m11s
release / notify-universe (push) Skipped
cloud embeds hanzoai/tasks IN-PROCESS (loopback ZAP, durable.go) and injects a per-org
dialer into ai's ingest — there is no external tasks service to auth to, no per-org token
minting, no HTTP inner-cloud hop. Long ingests (github/crawl/s3) run as durable workflows
in the owner's namespace (CONTRACT §6); upload stays inline. Fail-soft: embed error →
ai dialer unset → inline fallback. Bumps ai → v1.796.4 (per-org ingest dialer). One engine,
one binary, one way. NOTE: embedded store is memdb today (survives worker crash via retry,
not process restart); console /tasksd still points at the cluster tasks Service for the UI
(consolidating that surface into cloud is the follow-up).
2026-07-03 15:21:08 -07:00
hanzo-dev f3f6af3850 fix(erp): address Red review — idempotent postings + cancel reversal + finite guard
Red verdict FIX-THEN-SHIP (0 critical; all findings within-tenant integrity —
isolation, forge-proofing, gates, ledger perms, console deletions all refuted/solid).

- HIGH (TOCTOU double-post): on_submit postings ran before the atomic docstatus flip
  with hash-named legs, so N concurrent submits over-posted the ledger 4-6x. Every
  GL/stock leg now has a DETERMINISTIC name (voucher-<kind>-<index>, via prompt
  autoname) and postLeg is idempotent (pre-read + re-check on create-conflict), so
  posting is exactly-once under any concurrency AND replayable after a partial
  failure — no engine change, uses the existing store API. Balances stay SUM(ledger).
- MED (cancel did not reverse): on_cancel hooks append reversing ledger rows (swap
  debit/credit; negate qty), sharing the SAME leg computation as submit.
- LOW (non-finite total -> 500): finite guards in the totals hooks -> clean 422.
- LOW (comment): tightened the ledger-immutability doc — manager bypass is
  within-tenant authority only (Red confirmed no cross-org escalation).

Regressions: 8 concurrent submits -> exactly 1x200 + GL posted once (2 legs,
race-clean); cancel -> net GL zero-sum + net stock zero; overflow qty*rate -> 422.
go test -race 9/9 (CGO=1) + CGO=0 + vet clean + full cmd/cloud binary.
2026-07-03 15:11:51 -07:00
4fec803eea eval: mount GET /v1/evals/observations reading hanzo.cloud_usage (#99)
Every AI call already writes the proven hanzo.cloud_usage ledger (model,
provider, tokens, cost_cents, org, user, status) via ai/object's zapWriteUsage
— the same recordTrace funnel that emits the OTel GenAI span. This mounts the
missing native route the console Observe > Observations surface calls, reading
that ledger as Langfuse-v3 GENERATION observations (org-scoped by the validated
principal, bound positional params, bounded LIMIT). No new emission path: one
recordTrace, fanned to o11y (span) + Langfuse (span) + this ledger (read).

- telemetry.go: Observation model + ObservationFilter; ListObservations on the
  Telemetry interface, dsTelemetry (cloud_usage query), memTelemetry (honest
  empty); asInt64 coercer (cloud_usage UInt32 tokens / UInt64 cost — asFloat
  only handles float types).
- eval.go: listObservations handler + observationView/toObservationView mapping
  to the console Observation shape; GET /v1/evals/observations route.
- observations_test.go: view mapping (success/error), asInt64 coercion, mem
  telemetry empty + org-required.

Requires a cloud rebuild + deploy-by-sha to go live (route is code, not env).

Co-authored-by: hanzo <a@hanzo.ai>
2026-07-03 15:06:29 -07:00
hanzo-dev 86ba641141 chore(deps): bump hanzoai/ai → v1.796.3 (routed /v1/docs/ingest + code-aware splitter + brand-neutral per-org store + transport-agnostic durable-ingest seam)
release / build-amd64 (push) Failing after 9m48s
release / notify-universe (push) Skipped
2026-07-03 15:01:54 -07:00
a8623146f0 feat(o11y): ZAP-native span export + GenAI spans on LLM/agent paths (#95)
- zaptrace: otlptrace.Client over the ZAP wire (github.com/zap-proto/http) —
  spans marshaled as OTLP protobuf, shipped over ZAP frames, NEVER OTLP-HTTP
  (:4318)/gRPC(:4317). Target = collector zapreceiver (:4319).
- cmd/cloud/telemetry.go: initTelemetry uses the ZAP exporter; enable via
  OTEL_EXPORTER_ZAP_ENDPOINT (default otel-collector.hanzo.svc:4319); keeps the
  no-op-when-unset posture.
- clients/aihttp.go ChatCompletion: OTel GenAI client span (gen_ai.system/
  operation.name/request.model + response.model + usage.{input,output}_tokens),
  RecordError on failure. Captures the previously-discarded resp.Usage.
- clients/agents/agents.go: runAgent opens a per-run root span (agent.run),
  executeRun a child agent.step span; the LLM client span nests under them —
  one trace per run: run -> step -> chat.

Test: zaptrace TestUploadTracesOverZAP green (span over the real ZAP wire).

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-03 14:56:48 -07:00
a1f163406e feat(provisioning): docdb dedicated engine = FerretDB on Hanzo SQL (SQLite) (#97)
release / build-amd64 (push) Failing after 5m43s
release / notify-universe (push) Skipped
Supersede the Base swap (67bb0bc). Hanzo Base has a REST/realtime document
API but NOT the MongoDB wire protocol, so it is not a drop-in docdb — a
customer's mongodb:// driver cannot speak to it. The managed "document
database" must accept existing MongoDB drivers unchanged.

The per-org dedicated docdb instance is now a FerretDB v1.24 instance
speaking the MongoDB wire protocol on :27017, backed by Hanzo SQL (SQLite,
pure-Go) — Mongo databases map to SQLite files under /state, collections to
tables, documents to JSON1 rows. ZERO raw mongod (no WiredTiger), ZERO
Postgres, ZERO go.mongodb.org driver in cloud (FerretDB is a deployed pod,
not a Go import). Per-instance SCRAM auth via FerretDB's SQLite-backend
new-auth (FERRETDB_TEST_ENABLE_NEW_AUTH + FERRETDB_SETUP_*); the returned
password is the instance admin credential, sealed in KMS.

engine fields:
- image ghcr.io/hanzoai/docdb-sqlite:1.24.0 — pinned FerretDB v1 with the
  SQLite backend handler, mirrored from upstream by hanzoai/docdb CI (v2 and
  the hanzoai/docdb Postgres/DocumentDB fork both dropped SQLite; v1 is the
  last line that carries it). Distinct package from the Postgres-backed
  ghcr.io/hanzoai/docdb that backs shared chat-docdb.
- fsGroup 1000: FerretDB is distroless and runs as UID:GID 1000 (no
  entrypoint can chown), so a fresh block PVC must be group-writable via the
  pod securityContext.fsGroup the operator stamps from spec.fsGroup — else
  the instance CrashLoops on "permission denied" writing /state.
- FERRETDB_STATE_DIR + FERRETDB_SQLITE_URL pin both process state and the
  SQLite files onto the mounted /state PVC (persist across restarts).

Verified end-to-end against the FerretDB v1.24 SQLite image with this exact
env: mongosh Insert/Find/Update/Delete over the wire protocol, and /state
held per-db admin.sqlite + events.sqlite with "SQLite format 3" magic — no
WiredTiger datadir, no Postgres PG_VERSION (both locally and in hanzoai/docdb
CI on the mirrored image).

TestDedicated_DocdbIsFerretOnSQL asserts the FerretDB image, SQLite backend
env, mongodb:// connString, per-instance SCRAM credential, fsGroup 1000, and
the absence of any Postgres/IAM/Base env. unavailableKinds stays empty.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 14:40:02 -07:00
hanzo-dev 6c10824fad feat(erp+help): native ERP + Helpdesk DocType lanes on the framework
Second and third app lanes on clients/framework, reusing the generic DocType
engine + install path + generic renderer (like clients/cms) — zero forked
engine, zero HTTP surface of their own, per-org on Base/SQLite.

- clients/erp: 20 ERPNext-core DocTypes (module "erp") — masters (item/
  warehouse/customer/supplier/account/department/employee), submittable
  transactions with child Tables (sales-order/-invoice/purchase-order/
  stock-entry/journal-entry/payment-entry), and two read-only hook-posted
  ledgers (gl-entry, stock-ledger-entry). Business logic as native-Go hooks:
  line/document totals (before_save), balanced GL on invoice/journal/payment
  submit, append-only stock ledger on stock-entry submit, and double-entry /
  non-empty submit gates. Posting is org-scoped via ev.Org + ev.Store.
- clients/help: 5 Helpdesk DocTypes (module "help") — hd-ticket (status
  workflow) + hd-agent/hd-team/hd-sla/hd-canned-response. Pure fixtures, no
  hooks — the purest DRY proof; self-contained (no cross-lane Link).
- SLUG names (erp-*/hd-*), series naming for transactions, field naming for
  masters (console slugifies on write), hash for ledgers — all reachable via
  the generic renderer; no collision with CMS (Author/Media/Page/...) or CRM.
- subsystems: blank-import erp + help so their init() registers the lanes.

Tests: fixtures-valid/model-spec/install-transact-roundtrip/submit-gates/
tenant-isolation/ledger-read-only — go test -race green (CGO=1 and CGO=0),
go vet clean, full cmd/cloud binary builds.
2026-07-03 14:39:26 -07:00
zandGitHub 6f52f27b57 Merge pull request #91 from hanzoai/platform/whitelabel-signin-cloud
feat(auth): cloud accepts every white-label brand's issuer + audience (lux/zoo/pars login)
2026-07-03 14:28:01 -07:00
hanzo-dev 64bc30243c feat(security): hanzo security scan CLI + decomplect engine into detect pkg
The pure detection engine moves to clients/security/detect (a stdlib-only
LEAF): one engine, two surfaces — the /v1/security HTTP subsystem and the
new local CLI both consume it, neither drags the other in. The subsystem
now calls detect.ScanContent/Rules/SeverityRank; behavior is unchanged.

hanzo security scan [path...] walks a tree, runs the engine, and exits
non-zero when a finding at/above --fail-on (default low; 'none' = report
only) is present — a pre-commit/CI/agent guardrail with no server, auth,
or network. Skips vendored/binary files; never prints a raw secret (masked
preview only). hanzo security rules lists the catalog. -o json supported.
Tests: 9 CLI (find+fail, clean-pass, fail-on threshold/none, json, vendor+
binary skip, bad flag, rules, control-verb), engine+subsystem unchanged.
2026-07-03 14:26:29 -07:00
e143df6a50 feat(auth): accept every white-label brand's cloud audience — one binary, all brands
Extends the white-label issuer-set validation (this branch) to the AUDIENCE half:
a lux/zoo/pars session token carries aud=<brand>-cloud (HIP-0111: client_id == app
== aud), so the audience allowlist must include each or the cloud-native identity
sanitizer 401s a valid lux token even after the issuer gate passes.

- brand.go BrandAudiences() derives <brand>-cloud for every registry brand
  (hanzo-cloud, lux-cloud, zoo-cloud, pars-cloud, bootnode-cloud) — one source of
  truth, mirroring BrandIssuers(); no hand-listed audience.
- config.go jwtAudiencesFromEnv() now ALWAYS unions BrandAudiences() into the
  resolved allowlist (baked like the brand issuers). A legacy hanzo-only
  GATEWAY_ALLOWED_AUDIENCES env override still accepts lux-cloud — the brand auds
  don't depend on getting the deploy env perfectly right. Fail-secure: only ADDS
  the known-good <brand>-cloud client_ids, never an arbitrary aud. unionStrings
  dedupes so an env-supplied entry is never duplicated.

Tests: BrandAudiences (registry-derived, covers every brand), jwtAudiencesFromEnv
brand-union (baked default AND a hanzo-only env override both accept lux-cloud, no
duplicate). Paired with hanzoai/ai#64 (the per-brand signin code exchange).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 14:26:24 -07:00
e7e379c96a wip(auth): white-label issuer-set validation — one binary validates all brand issuers
Widen identityValidator to a trusted issuer SET (primary UNION BrandIssuers) so one
cloud binary validates hanzo AND lux/zoo/pars tokens off the one shared IAM JWKS.
Fail-secure: only known-good brand issuers added. NOT built (phantom go-sqlite3
v2.0.3 dep blocks) / NOT gated / NOT deployed — needs: per-brand EXCHANGE client
verification, build-dep fix, hanzo-login no-regression gate, red review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 14:26:24 -07:00
dba5d73b0a fix(sqlite): every store imports the hanzoai/sqlite fork, never modernc directly (#96)
github.com/hanzoai/sqlite is the ONE Hanzo SQLite driver: it registers the
"sqlite" database/sql name under BOTH build tags (cgo → mattn+SQLCipher,
encrypted at rest; !cgo → pure-Go modernc, wrapped internally). Fourteen
cloud stores plus the pg→sqlite migration blank-imported modernc.org/sqlite
DIRECTLY, so a CGO_ENABLED=1 build registered "sqlite" twice — the fork's
mattn registration AND the direct modernc one — and panicked at init
("sql: Register called twice for driver sqlite"), taking down the whole
fused hanzo/cloud binary.

Swept every direct `_ "modernc.org/sqlite"` → `_ "github.com/hanzoai/sqlite"`
(sql.Open("sqlite", …) calls unchanged — same driver name). go.mod promotes
the fork to a direct require and demotes modernc to indirect (it survives
only as the fork's !cgo backend). Stale comments calling modernc the
"primary" driver corrected. Production already builds CGO_ENABLED=0 (one
modernc package, no collision); this makes the driver choice consistent and
unblocks a CGO_ENABLED=1 + libsqlcipher encrypted build.

NOTE: five UPSTREAM modules (base/core, ai/object, o11y sqlstore,
commerce/db, orm/db) still import modernc directly; a CGO_ENABLED=1 fused
build stays collision-prone until they adopt the fork too. Out of scope for
this repo; tracked as the cross-repo follow-up.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-03 14:22:50 -07:00
hanzo-dev 21575beebd chore(deps): bump hanzoai/ai → v1.796.2 (durable tasks ingest + routed /v1/docs/ingest + code-aware splitter + brand-neutral per-org store); tasks → v1.43.0
release / build-amd64 (push) Failing after 7m10s
release / notify-universe (push) Skipped
2026-07-03 14:12:56 -07:00
hanzo-dev bd752ba808 Merge CMS-on-Framework keystone (feat/cms-framework-module, RED-PASSED 1d23b905)
release / build-amd64 (push) Failing after 6m16s
release / notify-universe (push) Skipped
First business app lane native on the Hanzo Framework DocType engine:
generic module-install path (/v1/framework/modules[/:module[/install]]) +
the CMS content model as fixtures (Page/Post/Article/Media/Navigation/Author,
module cms). Additive — no change to the proven /v1/framework/* isolation.
RED verdict: SHIP (0 crit/high/med).
2026-07-03 13:16:00 -07:00
hanzo-dev b027680349 chore(deps): bump hanzoai/ai → v1.796.1 (unified ingest routed + code-aware splitter + brand-neutral per-org docs store)
release / build-amd64 (push) Failing after 7m8s
release / notify-universe (push) Skipped
2026-07-03 12:58:38 -07:00
e0f4338afa feat(security): native /v1/security/* — dependency-free secrets scanner (#94)
The first Semgrep-class capability shipped natively in the cloud binary,
per hanzoai/security POSTURE.md's plan of record. One subsystem, the
established clients/ pattern (self-registering Mount, org-scoped store
under DataDir, audit + metering wired), zero external tools.

- engine.go — the reusable detection core: pure (path,content)→findings,
  no I/O. Pattern rules (AWS/GCP/GitHub/Stripe/Slack/npm keys, private-key
  blocks, JWTs) + a Shannon-entropy-gated generic-assignment rule so
  `secret = "changeme"` is not flagged but a real high-entropy token is.
  THE INVARIANT: a finding never carries the raw secret — only a masked
  preview (4+4 ends, middle starred; short secrets fully starred) and the
  SHA-256 fingerprint (dedupe + rotation tracking). Persisting plaintext
  would make the findings DB the very thing we scan to prevent.
- store.go — per-tenant SQLite ({DataDir}/security.db), scans + findings
  tables, org column is the isolation boundary on every query; SaveScan is
  one transaction so a scan is never half-written. Mirrors clients/git.
- security.go — mounts /v1/security/{health,rules,scans,scans/:id,
  findings,findings/:id}. submitScan runs the engine, persists redacted
  findings, meters one unit, emits a tamper-evident audit record (the
  tally, never the secrets). Registered cloud.RegisterWithShutdown(
  "security", 136, …) + one blank-import line in subsystems.

Tests (14, no skips, no fakes): engine_test proves each rule fires, the
entropy gate, line mapping, dedupe, severity ordering, and that no field
ever echoes the raw secret; security_test proves the HTTP surface,
cross-tenant isolation (evil sees 0 of acme's scans/findings, 404 on id),
the no-principal 403, the severity filter, and that a clean scan persists
a real zero-findings record. go build ./... + go vet + -race all clean.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-03 12:50:51 -07:00
hanzo-dev 1d23b90509 feat(framework+cms): generic app-lane module fixtures + CMS content model
Framework: a generic, DRY module-install path so an app lane (CMS/ERP/Helpdesk)
declares its DocTypes as fixtures (framework.RegisterModule, sibling to
RegisterHook) and installs them per-org via the engine's own gate:
  GET  /v1/framework/modules                list registered lanes
  GET  /v1/framework/modules/:module        lane fixtures + which are installed in-org
  POST /v1/framework/modules/:module/install  ensure fixtures exist (managerOnly, idempotent)
'modules' reserved so the static routes are never shadowed by a document route.

CMS (clients/cms): the first lane — the content model as fixtures only, NO HTTP
surface of its own. Page/Post/Article (slug-named, status Draft/Published,
author Link), Media (Attach-backed DAM), Navigation (JSON menu), Author. A CMS
collection IS a framework DocType (module 'cms'); content IS documents;
publishing IS a status field. Registered at init; installed per-org.

Secure by default: install is managerOnly (owner seeded trust-on-first-use),
create-if-absent (never clobbers a customised DocType), stamps the module tag,
and every op stays per-org via principal.Tenant.

Tests: install/idempotency/unknown-404/tenant-isolation/forged-principal-403/
non-owner-403/module-tag; CMS fixture validity + content-model spec + a full
HTTP install->create Author->create Page(link)->publish->filter round-trip.
2026-07-03 12:44:06 -07:00
hanzo-dev d8806c1456 fix(framework): RED MEDIUM — decode URL-encoded path params (space-named DocTypes/docs/roles)
release / build-amd64 (push) Failing after 6m14s
release / notify-universe (push) Skipped
The router (zip over fasthttp) runs with Fiber's default UnescapePath:false, so
c.Param() returns path segments verbatim. A DocType/document/role name that is
legal per docTypeNameRe but contains a space ('Sales Invoice', 'System Manager')
arrives percent-encoded ('%20') and never matched its stored value: GET/PUT/
DELETE /v1/framework/:doctype/:name, submit/cancel, and revokeRole (:user/:role)
all 404'd, while create+list (no name in the path) worked — records could be made
yet be unreachable, and a granted System Manager could not be revoked.

Fix scoped to the ONE seam: pathParam() percent-decodes every framework path
param (getDocType/replaceDocType/deleteDocType, access(:doctype), docName,
revokeRole). NOT a global fiber.Config UnescapePath flip — that would change
segment splitting on the KMS secret-path, model-catalog, git and s3 wildcards
(c.Params("*")) that legitimately carry encoded slashes; the framework-local
decode is orthogonal and zero-blast-radius. Malformed escapes fall through to an
honest 404, never a panic.

Unblocks space-named DocTypes for the CMS/ERP/Help app lanes. Tests: red→green
round-trip (create -> GET/PUT/submit/cancel/DELETE by name) + space-named role
revoke; full framework suite green under -race.
2026-07-03 12:27:31 -07:00
67bb0bc639 feat(provisioning): docdb dedicated engine = Hanzo Base, not MongoDB/FerretDB (#93)
release / build-amd64 (push) Failing after 7m7s
release / notify-universe (push) Skipped
Reconciles task #52 (eliminate Mongo) onto main's dedicated-per-org instance
model. The managed "document database" (docdb) is now a dedicated per-org Hanzo
Base instance — JSON document collections on per-tenant SQLite with native
realtime (SSE /v1/realtime), IAM-native — NOT a per-org FerretDB/Mongo instance.

- dedicated.go: docdb engine swapped ghcr.io/hanzoai/docdb:0.1.0 (mongodb://,:27017,
  POSTGRES_*) -> ghcr.io/hanzoai/base (http://.../v1, :8090, /data), dsType "base".
  New engine fields: dataMount (emits spec.volumeMounts so the data PVC actually
  mounts — the operator does NOT auto-mount) and iamAuth (IAM-native: no per-
  resource password generated/sealed/returned; admin Secret carries IAM_URL/
  KMS_URL/IAM_CLIENT_* from the cloud binary's own IAM identity). baseInstanceEnv
  helper. createDedicated honors iamAuth (no pw path). datastoreCR emits
  volumeMounts. Runs on the operator's GENERIC Datastore controller — spec.type is
  free-form, image/ports/volumeMounts drive the StatefulSet verbatim, NO operator
  Rust change (verified). datastore engine stays ClickHouse (not Mongo).
- provisioning.go: header + sanitizeIdent comments de-Mongo'd.
- go.mod: mongo-driver demoted direct -> // indirect (zero Go imports of
  go.mongodb.org remain; it survives only as a transitive requirement).
- test: TestDedicated_DocdbIsBase asserts the docdb CR is the base image on :8090
  with /data mounted, connString http://.../v1 (no mongodb://), no credential
  (IAM-native), IAM env in the admin Secret. PASS.

Audit (unchanged): zero customer docdb data, so the swap is clean.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:56:46 -07:00
hanzo-dev 2df526d54d Merge remote-tracking branch 'origin/main' into feat/framework-doctype-engine
release / build-amd64 (push) Failing after 5m47s
release / notify-universe (push) Skipped
2026-07-03 11:20:55 -07:00
hanzo-devandGitHub 22f25bc5bd fix(enablement): RED MEDIUM — scope self-opt-in/view to the VALIDATED tenant (#92)
release / build-amd64 (push) Failing after 6m12s
release / notify-universe (push) Skipped
Red found the self-service enablement write path (POST /v1/enablement/optin|optout)
+ the view keyed on raw c.Org() instead of principal.Tenant(c). On the bearer-less
direct-to-pod path SanitizeIdentity restores a client X-Org-Id with no validated
principal, so an off-gateway caller could opt an org it does not own into/out of a
beta (cross-tenant enablement write; bounded — betaOrgs membership of already-beta
items only, no money/data/global-state, not reachable through the gateway).

Fix (DRY — pricing already imports principal, the READ catalog gate already uses it):
- enablementOpt: resolve subject via principal.Tenant(c), 401 on !ok (was raw c.Org()).
- enablementView: resolve via the existing trustedOrg(c) (validated-principal gate).
- Corrected the docstrings (subject is the VALIDATED tenant, not 'never client-supplied').

Red's two PoC attack tests (enablement_attack_test.go) now GREEN; full enablement
suite unchanged + green + -race. Closes the one MEDIUM from Red's cockpit review.
2026-07-03 10:10:42 -07:00
hanzo-dev 0fb7f1f65f fix(admin): decode commerce transactions {count,transactions} wrapper — analytics ledger was silently empty
release / build-amd64 (push) Failing after 5m44s
release / notify-universe (push) Skipped
Live-verified: /v1/billing/transactions returns {count, transactions:[...]}, not a bare array.
My client decoded a bare [] (test fake also returned bare — mock hid the bug), so the analytics
retention/churn/active/usage ledger read got ZERO rows despite real usage (maxpower: 268 txns).
Now decodes the wrapped shape (bare-array fallback for robustness); test fake mirrors the live shape.
2026-07-03 09:55:46 -07:00
hanzo-devandGitHub baf3afeaa5 feat(platform): KMS-sealed secret env vars (close the 501) (#89)
User app secret env is no longer refused — it is sealed into cloud's embedded
KMS and wired to the pod via an operator-materialized k8s Secret, never
plaintext, never logged.

The path a secret takes (secrets.go):
  1. SEAL   — createApp / PUT .../env seal every secret:true value into deps.KMS
              at a per-tenant/app coordinate (platform/<tenant-ns>/<app>/<KEY>);
              the persisted env_json value is blanked. Fails CLOSED if KMS is
              unavailable — a plaintext secret never lands in the DB as a fallback.
  2. DECLARE — on deploy (applyLive, the ONE shared choke point) cloud writes a
              canonical KMSSecret CR (secrets.lux.network/v1alpha1) into
              tenant-<org> declaring a managed Secret <app>-env sourced from that
              KMS scope. Best-effort: a missing CRD/RBAC degrades to an honest
              'pending' status, never a failed deploy.
  3. MOUNT  — the Service CR renders each secret env as valueFrom.secretKeyRef →
              that Secret (optional:true so the pod boots pre-sync); the hanzo
              operator (which already supports secretKeyRef) mounts it into the
              Deployment env. cloud is never in the plaintext path at runtime.

Also: PUT /v1/platform/projects/:p/apps/:a/env to set/rotate env post-create;
honest secretSync status (pending|syncing|ready|failed) from the KMSSecret CR
conditions on the app view; KMSSecret teardown on app/project delete.

Tests: seal blanks+seals+fails-closed; injective KMS refs (no cross-tenant
collision); canonical KMSSecret CR shape; apply/patch/delete; sync-status
mapping; and an end-to-end deploy asserting the Service CR carries secretKeyRef
(never plaintext) and the KMSSecret CR is authored. go build ./...=0, vet clean.
2026-07-03 09:45:30 -07:00
hanzo-dev 6c376a1917 feat(admin): operator cockpit — customers/revenue/analytics + enablement registry
release / build-amd64 (push) Failing after 6m14s
release / notify-universe (push) Skipped
/v1/admin/* (global-admin gated, reuse s.guard→c.IsAdmin owner==AdminOrg):
- GET  /v1/admin/customers          fleet customer list (balance/spend/plan/status), concurrent enrichment
- GET  /v1/admin/customers/:org     detail (balance/usage/keys-presence/txns/users) — no card data, no key values
- POST /v1/admin/customers/:org/credit      real commerce deposit, audited before/after
- POST /v1/admin/customers/:org/{suspend,reactivate}  IAM isForbidden flip (login+token enforced), audited
- GET  /v1/admin/revenue            fleet revenue aggregate + per-customer table + ARPU + real spend trend
- GET  /v1/admin/analytics          native SaaS analytics: cohort retention, growth, churn, DAU/WAU/MAU,
                                     revenue/ARPU, usage — real from IAM createdTime + commerce ledger,
                                     honest-empty + computed[] transparency map (no fabricated curves)

Enablement registry #30/#31 (extends the ONE pricing catalog overlay, DRY):
- Overlay gains explicit Beta flag → tri-state off|beta|ga; off is an ABSOLUTE kill switch
  (visibleTo ignores betaOrgs when !beta). Additive migration + backfill (no regression).
- GET/PUT /v1/admin/enablement       global-admin: list + set state (+grant orgs)
- GET  /v1/enablement                 caller effective view + available betas
- POST /v1/enablement/{optin,optout}  self-service, subject = SANITIZED caller org, refuses non-beta
  (cannot bypass off, cannot target another org, cannot change global state)

Clients: commerce deposit/transactions/subscriptionSummary; iam getUserRaw/updateUserRaw (caller-cred replay).
Tests: analytics math (retention/churn/growth/spend), cockpit handlers (credit deposit+audit, suspend+audit,
no-secret-leak, revenue), enablement (tri-state, opt-in-refuses-non-beta, admin-only, full flow, caller-scope).
All green + -race clean; cmd/cloud builds.
2026-07-03 09:40:29 -07:00
f2dc3806f5 feat(provisioning): TRUE multitenancy — dedicated per-tenant DB instances (#88)
datastore (ClickHouse) + docdb (FerretDB) were honest-gated (unavailableKinds)
because a SHARED backend can't scope a per-tenant role. Replace the gate with a
DEDICATED-instance strategy: each create launches the org's OWN instance via an
operator Datastore CR + admin Secret in tenant-<org> (derived from the VALIDATED
org, never a request field). Isolation is BY INSTANCE — a cross-tenant grant is
impossible, there being one tenant on the instance — which un-gates both kinds.

- dedicated.go: engine table (image/ports/admin-env/DSN per kind), instanceName
  (<prefix>-<orgHash10>-<name>, DNS-1123), the k8s orchestrator (ensure tenant
  ns + RBAC wait, apply/observe/delete the Datastore CR + admin Secret + reap the
  retained PVC) behind an interface a fake stands in for, createDedicated,
  reconcileDedicated (provisioning->ready off the operator's status.phase), and
  dropDedicated.
- Billing (first-class): a provision debit carrying the size dimension
  (Model=<kind>:<size>) lands on the CALLER's org via the ONE commerce meter, and
  a recurring GB-day footprint sweep charges every running instance's own org —
  the reserved hook, now unblocked by the instance's declared size. Drop removes
  the row, stopping the meter, and reaps the PVC so no storage leaks.
- unavailableKinds now empty (mechanism kept); shared datastore/docdb
  provisioners deleted (one way only); the 5 shared kinds untouched.
- Datastore CR (not DocDB) is used because only its controller writes
  status.phase, the readiness signal; type forced per engine.

Tests: hermetic dedicated suite (fake orch + mock commerce) proves CR/Secret
shape, two-org isolation, ready reconcile, drop+PVC reap, and per-org billing
attribution; a build-tagged livecluster test proves the whole path against the
real operator. go build ./cmd/cloud + go test ./clients/provisioning/... green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 09:37:36 -07:00
hanzo-devandGitHub 3ef49c9f1e feat(console): port waitlist/embed-status/topup routes to /v1/console/* (task #41) (#87)
Completes the console subsystem's standalone-route port for the True 1-binary
FE. The three remaining console2 Next server routes that do REAL server work
(not vanishing BFF proxies) now terminate natively in the unified binary, so
console2 can drop them from its static export:

  POST /v1/console/waitlist       waitlist.go — session-gated join to the Base
                                  waitlist plugin; the recorded email is BOUND to
                                  the gateway-verified X-User-Email (a signed-in
                                  user can't enroll a third party), honest 501
                                  when WAITLIST_URL is unset.
  GET  /v1/console/embed-status   embed.go — server-authoritative entitlement
                                  (owning brand org / global admin only) + a
                                  time-boxed reachability probe. SSRF-free: the
                                  target is <app>.<brand-domain> for the FIXED
                                  deployment brand (deps.Brand) — no client host
                                  in the target at all.
  POST /v1/console/topup/wallet   topup.go — verify an HUSD transfer on-chain
                                  (plain eth JSON-RPC, no EVM dep) and credit the
                                  VALIDATED caller's own org for the ON-CHAIN
                                  amount via the S2S commerce billing API. IDOR-
                                  safe (ignores any client userId); honest 501
                                  greenfield gate until HUSD is deployed.

All three resolve the caller from the VALIDATED principal only (same trust
boundary as keys/onboard); a forged X-User-Id/X-Org-Id is refused. docs is a
pure host->URL redirect with no server work, so it stays client-side in console2
(no handler here). Registered in the ONE routes() place; full unit coverage
(fake IAM/waitlist/RPC/commerce), go build ./... clean, binary boot-proven to
serve the console SPA at / with every /v1/console/* route resolving (403/501/503,
never 404).
2026-07-03 09:30:26 -07:00
zandGitHub b719a34c12 Merge pull request #86 from hanzoai/chore/zip-v1.2.1-431
release / build-amd64 (push) Failing after 6m18s
release / notify-universe (push) Skipped
chore(deps): zip v1.2.1 — activate the api.hanzo.ai 431 fix
2026-07-03 03:10:50 -07:00
hanzo-dev 077f8e5c62 chore(deps): zip v1.2.0->v1.2.1 — HTTP transport honors ReadBufferSize (activates the 431 fix)
v1.786.33 set zip.Config.ReadBufferSize=32768 (GATEWAY_READ_BUFFER_SIZE) but
zip v1.2.0's HTTP transport built a bare fasthttp.Server and dropped it — the
edge still 431'd at 4 KiB. zip v1.2.1 propagates the App Config onto the
transport's fasthttp.Server, so the 32 KiB header ceiling now takes effect on
cloud:8000 (the api.hanzo.ai/v1/* backend).
2026-07-03 03:10:42 -07:00
4267c48433 test(crm): lock /v1/crm/summary live-count immediacy (no lagging rollup) (#85)
The console E2E saw /v1/crm/summary miss a just-created record. Root cause was a
stale/eventually-consistent read; the current handler already counts LIVE
(s.Counts -> SELECT COUNT(*) per table on the same store the writes hit), so a
create/delete is reflected with ZERO lag — verified live (create company ->
summary companies +1 immediately).

Add TestSummaryReflectsCreateImmediately: create -> Counts shows +1, delete ->
Counts shows -1, all in one synchronous flow. This guards against any regression
to a materialized/async rollup. No production code change needed — the fix is the
regression lock.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 03:10:29 -07:00
18034e4641 fix(analytics): honest 503 on ClickHouse timeout; fix(provisioning): honest-gate datastore+docdb (no cross-tenant role) (#84)
Gap 2 (analytics 502 on ClickHouse i/o timeout): /v1/analytics/{overview,timeseries,top}
returned a raw 502 when a DatastoreQuery hit a connectivity failure even though
requireDatastore()'s not-connected path already 503s. warehouseErr() now maps a
transport/connectivity error -> 503 'warehouse unavailable' (retryable); a REACHABLE
warehouse that rejected the query (bad SQL/protocol) stays 502. Table-driven test.

Gap 3 (docdb/datastore provisioning) — SAFETY REWORK (replaces the earlier
readWriteAnyDatabase change, which was cluster-wide = a cross-tenant hole):
- datastore + docdb are honest-GATED (unavailableKinds -> 503 'not yet available',
  refused BEFORE billing or any backend write) because their backends cannot mint a
  per-tenant-SAFE credential:
    * datastore (ClickHouse): no grant-capable per-tenant admin (GRANT ALL -> Code 497);
      unblocking is backend-side (StatefulSet grant-capable admin).
    * docdb (FerretDB/DocumentDB): engine implements ONLY cluster-wide roles
      (clusterAdmin -> Postgres SUPERUSER, readWriteAnyDatabase) — no per-db role.
- docdbProvisioner.Create keeps requesting the CORRECT per-db 'readWrite' role (the
  tenant-safe target); when FerretDB supports it, drop the gate and it works as-is.
- Tests: gated kinds -> 503 with the provisioner never run; the 5 guaranteed kinds
  (sql/vector/kv/search/s3) are asserted NOT gated.

Bar met: 5/7 data kinds fully work; datastore+docdb show an honest 'coming soon',
never a security hole.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 03:10:25 -07:00
zandGitHub dae28913f9 Merge pull request #83 from hanzoai/fix/edge-read-buffer-431
release / build-amd64 (push) Failing after 6m17s
release / notify-universe (push) Skipped
fix(edge): raise fasthttp header buffer to 32 KiB (fix api.hanzo.ai 431 on multi-domain SSO)
2026-07-03 02:43:20 -07:00
hanzo-dev ecba82ffcf fix(edge): raise fasthttp header buffer to 32 KiB so multi-domain SSO sessions don't 431
The public HTTP edge (zip/fiber) uses fasthttp's default 4 KiB per-conn
read buffer, which caps total request-header size and returns 431 (Request
Header Fields Too Large) above it. Once an admin-guard Domain=.hanzo.ai SSO
cookie is set on every subdomain, a browser's request headers cross ~4 KiB
and every request to api.hanzo.ai/v1/* (gateway -> cloud passthrough) 431s.

Raise the edge ceiling to a sane 32 KiB (nginx large_client_header_buffers
parity) via zip.Config.ReadBufferSize, env GATEWAY_READ_BUFFER_SIZE (shared
with the gateway edge so both trust boundaries agree on ONE value; tunable
down if the per-conn memory budget demands). Internal zip services keep the
4 KiB framework default — only the browser-facing edge opts up.

Repro (pre-fix): POST cloud:8000/v1/agents with a 9 KiB Cookie -> 431
Server: fasthttp. Post-fix: same request -> 403 (auth), no 431.
2026-07-03 02:42:54 -07:00
zandGitHub 1b26dff113 Merge pull request #82 from hanzoai/fix/agents-id-name-resolve
release / build-amd64 (push) Failing after 5m45s
release / notify-universe (push) Skipped
fix(agents): make a created agent gettable AND runnable by the returned id
2026-07-03 01:53:45 -07:00
hanzo-dev 2232b1044f fix(agents): resolve /v1/agents/:ref by id OR name so created agents are runnable
create and list return an agent's public id (agent_<hex>), but get/run/update/
delete/runs resolved the URL path segment ONLY against the name column — so a
client that used the id create returned got 404 "agent not found". A created
agent was listed but neither gettable nor runnable by the identifier the API
handed back.

One-way fix: Store.Resolve(org, ref) matches either the public id or the
org-unique name (id wins on the astronomically-unlikely in-org collision),
org-scoped fail-closed so a cross-tenant ref is 404, never a leak. Every
path-addressed handler (get/update/delete/run/runs) resolves through it and keys
every downstream store op on the resolved a.Name. Route param :name -> :ref to
say what it accepts. The run path's validated-principal gate and single
product=agent debit are unchanged.

Tests (go test -race): Resolve by id and by name return the SAME agent; full
create -> get-by-returned-id -> run-by-returned-id all 200 the same agent with
real output; cross-org ref denied 404; a run addressed by the returned id meters
exactly once (product=agent).
2026-07-03 01:52:54 -07:00
zandGitHub 5e4f3deb41 Merge pull request #81 from hanzoai/feat/standalone-embed-real-console
feat(webui): standalone binary embeds the REAL console (build:embed pipeline)
2026-07-03 01:20:34 -07:00
hanzo-dev f198f7160d feat(webui): make the standalone binary embed the REAL console (build:embed)
The 1-binary console (HIP-0106) shipped as a 3-file STUB: nothing ran
console2's static export before `go build`/the image build, so `//go:embed
all:webui/dist` baked only the fallback shell.

- `make webui` (new): runs hanzoai/console2 `npm run build:embed` and overlays
  the static export into webui/dist so a plain `go build` embeds the full
  @hanzo/gui console. `make build-standalone` = webui → build. CONSOLE2_DIR
  points at a console2 checkout (default ../console2).
- Dockerfile console stage + webui.go + the stub shell: docs corrected — the
  pipeline is `build:embed` (a static export), not `next build`; the export now
  prerenders clean (console2 build-embed.mjs neutralizes the root layout's
  request-time headers() read), so the image embeds the real console instead of
  silently degrading to the shell. Bumped the export heap to 8192 for headroom.

Verified: build:embed → webui/dist (index.html 368 KB, /_next/static assets) →
CGO_ENABLED=0 go build ./cmd/cloud → the running binary serves the real console
at / (200, references /_next/, not the stub), the SPA shell for deep links
(/orgs), fingerprinted assets immutable-cached, and the /v1 API on the SAME
origin ({"service":"base","status":"ok"}); unmatched /v1/* is a real 404, not
HTML. webui_test.go (7 tests) green against the real bundle.
2026-07-03 01:16:17 -07:00
hanzo-devandGitHub 7ca9ee3460 chore(deps): bump ai v1.790.4→v1.790.5 — provider-admin API + path-normalization hardening (#80) 2026-07-03 01:08:29 -07:00
hanzo-devandGitHub 3b6be29974 chore(deps): bump hanzoai/ai v1.790.3→v1.790.4 — text-to-video (/v1/videos/generations) + canonical /v1/crawl (both red-cleared) (#79) 2026-07-03 00:44:54 -07:00
hanzo-dev 88d824ec65 feat(visor): /v1/compute/{regions,sizes} — compute catalog on the cloud path (org-gated DRY passthrough; closes console compute-catalog 404, drops /vm proxy need)
release / build-amd64 (push) Failing after 6m17s
release / notify-universe (push) Skipped
2026-07-03 00:31:43 -07:00
hanzo-dev 845a7dd370 Merge remote-tracking branch 'origin/main' into feat/framework-doctype-engine
release / build-amd64 (push) Failing after 5m45s
release / notify-universe (push) Skipped
2026-07-03 00:21:50 -07:00
hanzo-dev a0f650166b fix(framework): atomic owner-seed — 'exactly one' System Manager under concurrency (Red LOW)
Red measured 3-6 System Managers seeded when concurrent role-less members first
administered a fresh org: managerOnly did a check-then-insert (OrgHasRoles then
AssignRole) with a TOCTOU window. Fix: store.SeedOwnerIfUnowned is a SINGLE
conditional INSERT ... SELECT ... WHERE NOT EXISTS(SELECT 1 FROM fw_roles WHERE
org=?), so the unowned-check and the insert are one atomic statement — exactly
one concurrent first-caller's row lands. RowsAffected==1 => this caller is the
seeded owner; ==0 => re-resolve (a concurrent grant may have made them a
manager) else 403. No UNIQUE index (multiple SMs are legit later via AssignRole;
only the AUTO first-seed must be singular). Removed the now-dead OrgHasRoles.

Test: TestAtomicOwnerSeed — 8 concurrent role-less first-callers → exactly 1
seeded winner + exactly 1 System Manager row. 22 tests total, race-clean.
2026-07-03 00:21:42 -07:00
hanzo-devandGitHub 398a12d768 chore(deps): bump hanzoai/ai v1.790.2→v1.790.3 — Great-Audit security fixes (F1 unauth-admin RAG/scrape, F-sk zero-billing, F4 401s) (#78) 2026-07-03 00:06:18 -07:00
hanzo-devandGitHub 8d74132975 fix(security): gate /v1/websearch/search fail-closed (Great-Audit F2) (#77)
searchGuard treated the searxng X-API-Key as OPTIONAL — a MISSING key passed —
so GET /v1/websearch/search was an open proxy to the Hanzo-operated metasearch
instance (unauthenticated request-forgery + cost surface). Its scrape sibling
(scrapeHandler) already fails closed; this brings search to parity:
  - key unset         → 503 (surface not configured, never open-to-all)
  - X-API-Key missing → 401 (constant-time compare of "" vs want fails)
  - X-API-Key mismatch→ 401

Safe for the real caller: the LibreChat searxng client sends the configured
searxngApiKey (universe chat configmap wires searxngApiKey=${WEBSEARCH_API_KEY})
as X-API-Key, so only anonymous callers are turned away.

Tests: TestSearchMissingKeyRejected (was ...Allowed) → 401; new
TestSearchUnsetKeyFailsClosed → 503; TestSearchProxyRewritesToSearchPath and
TestMountRoutesThroughRouter now present the key. go build/vet/test green.
2026-07-03 00:04:28 -07:00
hanzo-dev 7a1e1bb559 Merge remote-tracking branch 'origin/main' into feat/framework-doctype-engine 2026-07-03 00:03:02 -07:00
hanzo-dev 7a996bfc47 fix(framework): secure-by-default perms + Single submit-immutability (Red LOW-1/LOW-2)
LOW-1 — Single submit-immutability: updateDocument/createDocument for a Single
now route through writeSingle, which enforces the SAME draft-only guard as the
non-Single path (a submitted/cancelled Single → 409, not a silent mutation) and
preserves a redacted Password across an unchanged update.

LOW-2 — secure-by-default permissions (no open-to-all footgun):
- permission.can() is now DEFAULT-CLOSED: removed the 'empty perms => open to
  every org member' branch. A permless doctype is manager-only; a role-less
  member is denied.
- DocType.normalize() seeds a System Manager perm at define time, so a stored
  doctype is never silently permless (explicit in UI + audit).
- Owner seeding moved from resolveAccess (any member is SM until a role exists)
  to managerOnly as trust-on-first-use: the FIRST validated principal to
  administer an org with no roles becomes its persisted System Manager (the
  owner) — exactly one member, deterministically, never cross-tenant.

Tests: +2 (TestSingleSubmitImmutability, TestPermlessDefaultClosed); 21 total
race-clean. go build ./... CGO=1 & =0 green, vet + gofmt clean. Fixed binary
boot-verified (framework health 200, gate 403, forge 403).
2026-07-02 23:49:37 -07:00
zeekayandClaude Opus 4.8 1226939913 fix(deps): luxfi/age@v1.5.0 go.sum = canonical sumdb hash (fixes container SECURITY ERROR)
release / build-amd64 (push) Failing after 7m10s
release / notify-universe (push) Skipped
The prior fix re-fetched luxfi/age with checksum-checking off, which recorded the
DIRECT-vcs hash. luxfi/age@v1.5.0 was force-re-tagged, so the direct tree hash
differs from the immutable proxy zip hash — the container build (GOPROXY=proxy +
GOSUMDB=sum.golang.org, GOPRIVATE dropped for luxfi/* on purpose) verifies against
the sumdb and hit "checksum mismatch / SECURITY ERROR" on `go mod download`.
Replaced the h1: zip hash with the canonical value from
sum.golang.org/lookup/github.com/luxfi/age@v1.5.0 (the /go.mod hash already
matched). Now go.sum == what the proxy+sumdb serve → container verification passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:38:46 -07:00
zeekayandClaude Opus 4.8 7ec61194c1 fix(docker): console2 build:embed failure degrades to fallback shell (non-fatal)
release / build-amd64 (push) Failing after 58s
release / notify-universe (push) Skipped
The console-embed stage's contract is "a missing static target is a degrade, not
an error" — but the guard only handled the target being ABSENT. When console2
exposes build:embed AND it CRASHES (currently: /signin Server-Components
prerender error kills `next build`), the `&&` chain failed the whole cloud image,
so a frontend prerender bug took down the entire Go backend build (release runs
for projectsvc S3 fix + kms refactors all failed here, not on Go).

Complete the stated contract: wrap build:embed so a build FAILURE also degrades to
the committed fallback shell (/out stays empty → Go embeds webui/dist/index.html).
The standalone console2 Deployment is the primary console; this embed is a
same-origin convenience and must never gate the backend image.

(console2 /signin static-export prerender crash tracked separately for the
console track — this makes cloud CI robust to it either way.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:24:43 -07:00
zeekayandClaude Opus 4.8 1897374b04 fix(deps): unblock cloud build — versioned replace for phantom go-sqlite3 + luxfi/age checksum
Two dep-rot issues blocking the cloud (Go backend) build:
1. A transitive dep requires the non-existent mattn/go-sqlite3 v2.0.3+incompatible.
   The unversioned replace didn't stop Go reading v2.0.3's go.mod during graph
   load. Fixed with a VERSIONED replace (v2.0.3+incompatible => v1.14.16, the last
   real go-sqlite3, drop-in package sqlite3). cloud's primary sqlite is
   modernc.org/sqlite (pure-Go); hanzoai/sqlite (encrypted, package `sqlite`) is a
   separate driver, adopting it is a real migration not this phantom fix.
2. luxfi/age@v1.5.0 go.sum checksum mismatch → removed stale lines + re-fetched.

go build ./internal/org/ (the sqlite consumer) now clean; module graph resolves.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:16:17 -07:00
hanzo-dev 3050896172 feat(framework): native-Go DocType engine — the foundation that retires Frappe
The Hanzo Framework: Frappe's DocType/metadata core rebuilt native in Go on
Base/SQLite, mounted at /v1/framework/* (subsystem order 129). ONE engine +
ONE generic UI renders every business app — CMS content-types, ERPNext
DocTypes, Helpdesk all become just DocTypes on this engine. No Frappe/Python
runtime dependency; the engine is pure Go.

- DocType registry: define/list/get/replace/delete metadata per-org
- Generic metadata-driven document CRUD with ?filters=/fields=/order_by=/limit=
- Fieldtypes: Data/Int/Float/Currency/Check/Date/Datetime/Text/SmallText/
  LongText/Select/Link/Table/Attach/JSON/Password (all validated)
- Naming: hash / field: / prompt / series patterns (INV-.YYYY.-.#####)
- Link relations (in-org ref check + fetch_from), Table child rows
- docstatus 0/1/2 with submit/cancel for submittable doctypes
- Per-org permissions (DocType perms by role) + per-org role store
- Go lifecycle hook interface (before_insert/before_save/after_save/
  on_submit/on_cancel/on_trash) — gpython/goja runner is a later add to the
  SAME interface
- Password fields: argon2id hash on write, redact on read (fail-secure)

Security: org derived ONCE via clients/principal.Tenant (validated principal
only; forged X-Org-Id refused 403). Every table + query is org-scoped. 19
tests (race-clean) prove cross-org isolation, forged-principal refusal,
permission enforcement, field-type validation, and the docstatus lifecycle.
Boot-verified locally (health 200, doctypes 403, forge refused).
2026-07-02 23:16:10 -07:00
hanzo-dev 212477e751 refactor(kms): filenames match packages — kms/kms.go + kmssvc/kmssvc.go 2026-07-02 23:03:41 -07:00
hanzo-dev e5630fe212 refactor(kms): drop the kmsembed compound — core is clients/kms, HTTP-mount subsystem is clients/kmssvc
The embedded luxfi/kms core (cloud/types-only leaf, built by build.go before the
app exists to break the import cycle) is now just 'kms'; the Fiber /v1/kms/* mount
subsystem (imports cloud) is 'kmssvc' (its existing internal name). One clean name
each, no unnecessary compound. build+vet+tests green.
2026-07-02 22:54:02 -07:00
zeekayandClaude Opus 4.8 7caca9b349 fix(projectsvc): SeaweedFS-compatible public-read bucket policy
publicReadPolicy used Principal {"AWS":["*"]} + array Resource, which
SeaweedFS's S3 policy engine rejects with 'Policy has invalid resource' —
aborting ensureBucket (SetBucketPolicy) BEFORE any files upload, so every
projectsvc deploy failed ('object storage'/'invalid resource') and no site
was ever served. Use scalar Principal "*" + scalar Resource, which SeaweedFS
accepts and is equally valid on AWS S3 / MinIO. Verified: mc anonymous
set-json with this exact shape succeeds against the s3.hanzo.ai SeaweedFS
gateway; a site uploaded to the now-public hanzo-sites bucket serves 200 at
https://s3.hanzo.ai/hanzo-sites/<org>/<slug>/index.html.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:52:59 -07:00
hanzo-devandGitHub 422f5f03b4 fix(websearch): decode Crawl4AI object-shaped markdown (scrape returned empty) (#72)
Found by testing the ACTUALLY-deployed hanzoai/crawl:0.0.1 (= Crawl4AI
0.8.6) against clients/websearch's crawl adapter: 0.8.x/0.9.x return the
/crawl result's `markdown` as an OBJECT
{raw_markdown, fit_markdown, markdown_with_citations, ...}, and signal the
batch with a boolean `success` (no `status`). crawlResult.Markdown was
typed `string`, so json.Decode errored on the object form → crawl()
returned an error → EVERY scrape returned {success:false} with empty
content. hanzo.chat Web Search's scrape half was therefore dead even once
crawl is running.

Fix: markdownField.UnmarshalJSON accepts either a bare string OR the
object (preferring the cleaned fit_markdown, raw_markdown fallback); the
crawlResponse envelope now also accepts boolean `success` alongside the
legacy `status`. Neither envelope field is required — Results[0].Success
is authoritative.

Tests (real 0.8.6 response shape):
  TestScrapeHandlesCrawl4AIObjectMarkdown — object markdown + bool success
    → success:true, returns fit_markdown (was: {success:false}).
  TestMarkdownFieldAcceptsBareString — bare-string form still works.
  All 12 clients/websearch tests pass; go build + go vet clean.

Contract verified live: crawl4ai 0.8.6 POST /crawl {urls:[...]} returns
synchronously (no task_id polling) with url/markdown/success/metadata —
matches the adapter otherwise.
2026-07-02 22:30:15 -07:00
zandGitHub 74557a7c1c Merge pull request #76 from hanzoai/feat/paas-domains
feat(platform): customer domains — self-serve subtree hosts + verified BYO custom domains
2026-07-02 22:01:40 -07:00
hanzo-devandGitHub 2b8111d2e1 feat(platform): live deployment logs — stream real build + app pod logs (#75)
deploymentLogs returned only the recorded timeline + a Job reference, ending
with a '(live BuildKit Job logs stream in phase 2)' placeholder. This closes
that phase-2 gap: it streams the ACTUAL pod logs from the cluster — the BuildKit
Job's pod while a git build runs, and the running app's pod once deployed — so
the console's per-deployment Logs pane shows real output, operator-consistent.

- logs.go: buildLogs (job-name=<jobName> pod in the build ns), appLogs
  (app.kubernetes.io/instance=<slug> pod in tenant-<org>), one podLogsBySelector
  path (newest pod, tail-bounded 400 lines, byte-capped 256 KiB keeping the tail,
  8s time-boxed). Every read is org-scoped and time-boxed.
- k8s.go: add a typed kubernetes.Interface clientset (from the SAME rest.Config)
  held ONLY for the Pods().GetLogs subresource the dynamic client cannot express;
  nil-safe — a construct failure leaves logs degrading to the timeline and never
  disables the CR control plane (all on dyn).
- deploy.go: deploymentLogs now appends real build + app logs and stamps a
   (build|app|none) so the console can label the pane. HONEST DEGRADE:
  an unreachable cluster / absent pod yields the recorded timeline + a stated
  'not available' note — source stays reflecting real streamed content, never a
  fabrication.
- 8 tests over a fake typed clientset: newest-pod selection, tenant-namespace
  scoping (acme never reads victim's pod), no-pod/no-clientset honest degrade,
  and the handler surfacing live build logs (source=build) vs degrading
  (source=none) — asserting the phase-2 placeholder is gone.
2026-07-02 21:52:39 -07:00
hanzo-devandGitHub e755af58d2 feat(console): native /v1/console/{keys,onboard} — port console2's standalone IAM server routes (#74)
The console can be a static export only once its two NON-proxy Next server
routes (app/keys, app/onboard) — which mint/revoke the user's hk- Cloud API
key and create the user's org as the confidential hanzo-console IAM client —
have a native home. Port them to a clients/console subsystem mounted at
/v1/console/* in the one binary (task #41, True 1-binary FE): the embedded SPA
calls /v1/console/* on its own origin, and the last stateful Node handlers go.

- clients/console/iam.go: confidential-client (client_secret_basic) IAM caller
  for mint/revoke/get the hk- key + create/read/update an org. Honest 501 when
  IAM_MINT_CLIENT_ID/SECRET are unset (mirrors identity.ts mintConfigured()).
- clients/console/console.go: /v1/console/{keys(GET/POST/DELETE),onboard(POST),
  health}. Every route requires a VALIDATED principal; the IAM id is DERIVED as
  <owner>/<name> from the gateway-minted X-User-Id/X-Org-Id, never a request
  value — a caller can only ever act on their OWN key/org (red-bar structural).
- clients/console/onboarding.go: faithful Go port of console2 onboarding.ts —
  pure slug + reserved-name policy (admin/built-in/app + hanzo/lux/zoo/pars).
- Registered as consolesvc (order 122) so /v1/consolesvc/health does not shadow
  the real fail-closed /v1/console/health probe.
- 16 tests: unauth 403 (forged X-Org-Id refused, IAM never touched), mint/get/
  revoke scoped to the derived id + show-once + no secret leak on GET, 501/502
  honesty, onboard first-run(create+move)/additional(create-only)/reserved 400/
  taken 409/personal auto-suffix, + pure-policy unit tests.
2026-07-02 21:52:11 -07:00
hanzo-dev e36c1c28fc feat(platform): customer domains — self-serve subtree hosts + verified BYO custom domains
Close the two PaaS domain gaps so a customer can put their app on their own
domain, operator-native, from the console.

- Seed a canonical default host <slug>.<org>.<sitesHost> on app create, so
  every app has a working HTTPS URL the moment it deploys (operator issues the
  cert). Never removable.
- New /v1/platform/.../domains surface (list/add/verify/remove). An org-subtree
  host is active immediately; a BYO custom host (yourco.com) is claimed PENDING
  and returns the exact DNS records to publish (TXT ownership token at
  _hanzo-challenge.<host> + CNAME to the app host).
- Verify resolves DNS: a matching TXT token proves control (DNS-01 model), then
  the host is rendered into the app's operator Service CR ingress via applyIngress
  (cert-manager TLS comes for free). Honest still-pending on not-yet, never fake.
- platform_domains table: host PRIMARY KEY = global uniqueness (one org per host,
  like site_hosts); pending→verified lifecycle. Cascade-deleted with app/project.
- validateOrgDomains extended: a non-subtree host renders ONLY when this org owns
  a VERIFIED claim; unverified/foreign/apex hosts still refused (RED hardening kept).
- ingressSpec extracted (one TLS shape shared by serviceCR + applyIngress);
  observeDomains surfaces operator status.endpoints/phase for honest live state.
- Tests: verified-custom accept + pending/foreign refuse; full add→verify→remove
  HTTP flow with fake DNS; global uniqueness (two orgs/two apps); apex refusal;
  default-host seeding; CR ingress render. go build + go test green.
2026-07-02 21:48:04 -07:00
hanzo-devandGitHub 880ceaa661 chore(deps): bump hanzoai/ai v1.790.1→v1.790.2 — full DO model lineup + file-scoped RAG + SD3.5 image (atop diffusion) (#73) 2026-07-02 21:26:05 -07:00
hanzo-dev 37263ecf77 fix(websearch): decode Crawl4AI object-shaped markdown (scrape returned empty)
Found by testing the ACTUALLY-deployed hanzoai/crawl:0.0.1 (= Crawl4AI
0.8.6) against clients/websearch's crawl adapter: 0.8.x/0.9.x return the
/crawl result's `markdown` as an OBJECT
{raw_markdown, fit_markdown, markdown_with_citations, ...}, and signal the
batch with a boolean `success` (no `status`). crawlResult.Markdown was
typed `string`, so json.Decode errored on the object form → crawl()
returned an error → EVERY scrape returned {success:false} with empty
content. hanzo.chat Web Search's scrape half was therefore dead even once
crawl is running.

Fix: markdownField.UnmarshalJSON accepts either a bare string OR the
object (preferring the cleaned fit_markdown, raw_markdown fallback); the
crawlResponse envelope now also accepts boolean `success` alongside the
legacy `status`. Neither envelope field is required — Results[0].Success
is authoritative.

Tests (real 0.8.6 response shape):
  TestScrapeHandlesCrawl4AIObjectMarkdown — object markdown + bool success
    → success:true, returns fit_markdown (was: {success:false}).
  TestMarkdownFieldAcceptsBareString — bare-string form still works.
  All 12 clients/websearch tests pass; go build + go vet clean.

Contract verified live: crawl4ai 0.8.6 POST /crawl {urls:[...]} returns
synchronously (no task_id polling) with url/markdown/success/metadata —
matches the adapter otherwise.
2026-07-02 21:21:25 -07:00
hanzo-devandGitHub 4f431320da fix(templates): resolve 38 broken screenshot preview URLs in catalog (#71)
Map catalog preview URLs to screenshots that actually exist in gallery.
All 69 templates now have resolvable preview images at gallery.hanzo.ai.
2026-07-02 21:17:21 -07:00
hanzo-dev cc8d8619ae feat(graph): chain-data cloud client — /v1/indexers + /v1/oracles
Front the Lux chain-data plane over HTTP so the console's Indexer and
Oracles pages read REAL chain state from api.hanzo.ai/v1/* instead of
rendering "not connected":

- GET /v1/indexers  -> luxfi/indexer explorer REST (/health + latest
  block): per-network chain/network/height/health. lag honestly omitted
  (the indexer REST exposes indexed height, not the chain HEAD).
- GET /v1/oracles   -> luxfi/graph GraphQL priceFeeds (O-Chain PriceFeed
  registry): real on-chain price feeds; honest-empty when none.

Principal-gated (403 without a validated IAM principal); brand-scoped
(each brand's cloud is wired to its own indexer/graph, a ledger is public
within a brand). Honest 502 on unreachable upstream, never a fabricated
row. Mirrors clients/visor + clients/zt structure; interface-seam tests
against a fake upstream. Registered order 135.
2026-07-02 20:27:25 -07:00
hanzo-devandGitHub 08c464aee8 feat(o11y): emit OTel traces via go.opentelemetry.io/otel (service.name=hanzo-cloud) (#67)
Env-gated on OTEL_EXPORTER_OTLP_ENDPOINT; non-fatal; clean no-op when unset (safe to ship before the collector is live). Installs the global tracer provider with a service.name resource so the console Monitoring tab filters this product. Mirrors ai/object/telemetry.go. Traces-only; metrics/logs are a tracked follow-up.
2026-07-02 20:18:47 -07:00
hanzo-devandGitHub 4f6e3c542a chore(deps): bump hanzoai/ai v1.789.1→v1.790.1 — wire /v1/images/generations diffusion (zen3-image → do-ai fal) (#70) 2026-07-02 19:46:27 -07:00
zandGitHub cbc42cc1d1 Merge pull request #64 from hanzoai/feat/sites-subdomain-router
feat(sites): wildcard-subdomain site router over S3 + Cloudflare purge-on-redeploy
2026-07-02 18:14:28 -07:00
hanzo-dev f205b3ab6d fix(sites): RED — unified reserved-list enforced at create+bind+serve, stream not buffer, 405
RED review fixes on the sites router:

1) [HIGH] ONE reserved-subdomain source (clients/sites/reserved.go:
   baseReserved baked-in + operator SetReservedExtra, never subtractable),
   consulted at THREE points that can no longer drift: serve (siteSlug),
   project-create (createProject -> 400), and host-bind (Store.BindHost ->
   errReservedHost). site_hosts can now NEVER physically hold a reserved host,
   so a reserved subdomain never resolves even if the ingress regex drifts —
   the serve gate is a backstop, not the sole guard. Widened the set to app/
   auth/payment/brand labels (console, sites, internal, gateway, login, secure,
   account, signin, auth, pay, wallet, admin, brand terms, ...).

2) [MED DoS] Serve now STREAMS objects (Fiber SendStream, Content-Length from
   info.Size, fasthttp closes the reader) instead of io.ReadAll-buffering up to
   64 MiB per request on the unauthenticated edge — removes the OOM vector.
   Same for the 404.html path.

3) [LOW] Non-GET/HEAD on a site host → 405 + Allow: GET, HEAD.

Tests: IsReserved, reserved-host-never-serves backstop, 405, BindHost-rejects-
reserved (even with a forced project row), create-rejects-reserved-slug via the
real handler. All green; no regressions.
2026-07-02 18:09:27 -07:00
hanzo-dev bbb4c162f4 fix(platform): harden cold-start deploy flywheel (RED L2+L1+I2)
Close the two LOW follow-ups on the cold-start tenant-RBAC fix, plus confirm
the fresh-org fail-closed status. All on top of v1.786.23 (already SHIP).

L2 — git reconciler no longer treats a transient tenant-RBAC delay as TERMINAL
and no longer head-of-line-blocks other orgs. reconcileBuild now does ONE
non-blocking readiness probe (ensureTenantReady: create-namespace-if-absent +
single SelfSubjectAccessReview) instead of the synchronous image path's ~45s
in-line waitForTenantRBAC. If the operator's RoleBinding has not landed, the
deployment stays 'building' and re-drives on the next 10s tick — never a
permanent fail (there is no client to retry a git build) and never a 45s stall
of the shared sequential reconciler. Only the elapsed build deadline fails it
honestly. errTenantProvisioning from applyLive is also caught as transient
(defense in depth). Namespace-create is decomplected into one shared
ensureNamespaceExists; ensureNamespace (sync, blocking) and ensureTenantReady
(async, probing) compose it.

L1 — image deploy path gains a per-org in-flight-deploy cap (inflightGate,
maxConcurrentDeploys, default 8 via CLOUD_PLATFORM_MAX_CONCURRENT_DEPLOYS),
mirroring the git build cap. deployImage acquires a slot before applyLive's
~45s RBAC wait and releases on any return; over-cap is a retryable 429 refused
BEFORE recording an attempt. Bounds request-goroutine pile-up on a wedged
operator; per-org (one org's saturation never throttles another); fail-closed.

I2 — confirmed the truly-fresh-org path fails closed on 503 (RBAC pending),
NOT a raw 502: the namespace IS created (the trigger for the operator's
RoleBinding), then the bounded RBAC wait yields errTenantProvisioning -> 503.

Tests (all -race green): reconciler stays 'building' then goes live on a later
tick once RBAC lands (not 'failed'); over-cap image deploy -> 429 with per-org
isolation + slot-release re-admit; fresh-org deploy -> 503 with namespace
created + no Service CR + honest 'error' deployment recorded.
2026-07-02 18:06:45 -07:00
3a6fa6006c fix(admin): reconcile fleet revenue with commerce — X-Org-Id + bare-slug subject (#65)
The /v1/admin money panels (finance/orgs/overview) read $0 for every org despite
real balances (lux $10,000, maxpower $20,498) because the commerce client used
the wrong org selector on BOTH axes:

- commerce.go get(): sent X-IAM-Org-Id, which commerce does NOT read. Commerce
  EdgeAuth resolves the per-org billing namespace from the TRUSTED X-Org-Id header
  (trusted only with the COMMERCE_SERVICE_TOKEN bearer). X-IAM-Org-Id silently
  fell back to the default (COMMERCE_SERVICE_ORG) namespace.
- admin.go orgSubject(): keyed the wallet subject as "org/org"; commerce keys the
  per-org wallet under the BARE org slug (user=<org>) within the X-Org-Id namespace
  (the 2026-07 commerce durability rework, commerce >=1.46.8).

Either alone zeroed the reconciliation; both were present. The prior comments
encoded the wrong model ("commerce resolves from COMMERCE_SERVICE_ORG, header
advisory") — corrected to the verified contract.

Verified LIVE against commerce /v1/billing/{balance,usage-rollup}:
  user=lux      + X-Org-Id: lux      -> $10,000.00 (1,000,000c)
  user=maxpower + X-Org-Id: maxpower -> $20,498.13 (2,049,813c)
  user=lux/lux  OR X-IAM-Org-Id      -> $0 (the bug)

The fleet-wide /v1/costs COGS god-view is org-independent and correctly sends no
org (unchanged).

Regression guard: TestCommerce_ReconcilesWithXOrgIdBareSlug — a contract-accurate
fake commerce that returns money ONLY for X-Org-Id + bare-slug user; proven
red->green (fails on org/org, passes on the fix). Full admin suite green.

Co-authored-by: blue <blue@hanzo.ai>
2026-07-02 18:05:16 -07:00
zandGitHub ae97bc1295 Merge pull request #66 from hanzoai/feat/agent-sessions
release / build-amd64 (push) Failing after 6m14s
release / notify-universe (push) Skipped
feat(agents): live agent-session control plane (/v1/agents/sessions)
2026-07-02 17:41:13 -07:00
hanzo-dev eb9fae6f38 agents/sessions: clone SSE root filter (fix Ctx-recycle data race) + prove event-seq under concurrency
Red review of the live agent-session control plane.

FIX (MEDIUM, systems lens): sessionsStream retained root := c.Query("root")
verbatim. c.Query is a zero-copy view into the fasthttp request buffer, and the
SendStreamWriter loop OUTLIVES the handler (runs after the Ctx is recycled), so
the long-lived root filter raced a reused buffer — within-org stream-filter
corruption / UB. tenant() already clones org for this exact reason; clone root
the same way. Not cross-tenant (org is cloned + bus-filtered); fixes the race
and honors the file's own 'never touch Ctx after return' invariant.

TEST (vector #4): add TestSessionEventSeqConcurrent — 64 parallel AppendEvents
to one session must yield seqs exactly {1..N}, no gaps (no lost write) no dupes
(no raced MAX+1). Proves the single-conn + UNIQUE(session_id,seq) guarantee
under -race instead of only asserting it.
2026-07-02 17:38:16 -07:00
hanzo-dev d3114dc43e feat(agents): live agent-session control plane (/v1/agents/sessions)
The canonical cloud registry every surface hangs off: live agent SESSIONS +
the subagent tree, streamed over ZAP, remote-controllable. This is the
view/control/stream layer; durable execution rides hanzoai/tasks, not a
bespoke scheduler.

Model + store (agents.db, same tenancy pattern as agents/runs):
- Session{id,agent,org,actor,status,parentSessionId,rootSessionId,title,
  startedAt,endedAt,taskWorkflowId,taskRunId,events[]}. Subagent tree =
  sessions linked by parentSessionId; the outer agent is the root, each
  spawned subagent a child, all sharing rootSessionId. Parent must exist
  in the SAME org (TOCTOU-checked in the write path) so a tree can never
  cross tenants. Per-session monotonic event Seq.

REST (org-scoped via principal.Tenant, fail-closed):
- POST /v1/agents/sessions            register (opt parentSessionId)
- GET  /v1/agents/sessions            list (filter root/parent/status)
- GET  /v1/agents/sessions/:id        detail + children + recent events
- GET  /v1/agents/sessions/:id/tree   full subagent-flow graph (1 query)
- PATCH /v1/agents/sessions/:id       status/title (terminal is monotonic)
- POST /v1/agents/sessions/:id/events append message/tool-call/spawn/log
- POST /v1/agents/sessions/:id/{pause,resume,stop,message}  control
  Routes register BEFORE /v1/agents/:name (Fiber matches in registration
  order); /stream precedes /:id for the same reason.

ZAP live stream:
- GET /v1/agents/sessions/stream (SSE) rides the ZAP machine transport
  natively (zip SendStreamWriter streams through ListenZAP — proven by
  zip stream_test). In-process bus is the single fan-out seam a direct
  ZAP push subscription attaches to. Org-filtered, non-blocking, laggard-
  drop; GET endpoints are the source of truth.

Durable execution = hanzoai/tasks (architecture alignment):
- Root session -> a tasks workflow; subagent -> child workflow (same
  rootSessionId). TaskController seam mirrors the tasks SDK Client
  (Signal/Cancel); control forwards to it when a session is task-backed,
  else records the command as a durable control event for stream-
  consuming surfaces. Default is the disabled (record-only) controller;
  the live client.Dial(TASKS_URL) plug-in point is marked in Mount.

Run integration (#5): the ONE runAgent path (HTTP + scheduler) opens a
root session per run (best-effort, never fails the run), so every run is
visible in the same registry.

Tests (real): store tree-linking + cross-tenant/dangling parent deny +
event seq/counts; HTTP tree assembly; cross-tenant read/tree/control/
append/parent deny; control authz (no validated principal -> 403) +
tasks forward (signal/cancel, forward-failure 502, record-only fallback);
event append/seq + status monotonicity; run-opens-session; bus fan-out/
org-filter/overrun/close. go build+vet+test clean; -race clean.
2026-07-02 17:38:16 -07:00
hanzo-dev a0536a35bb feat(sites): wildcard-subdomain site router over S3 + Cloudflare purge-on-redeploy
Add clients/sites: a HOST-routed public site server that turns
<slug>.hanzo.app into the static site a project deployed to OUR S3
(<org>/<slug>/ in CLOUD_PROJECTS_BUCKET). Installed as the FIRST middleware
in the compose root, ahead of identity/billing, so a published site is a
public artifact served straight from S3 — never a tenant API call.

Tenant isolation (RED-focus): the org + S3 prefix come ONLY from the store
lookup keyed by the validated subdomain slug, never from the request path or
a client header. Object keys are rooted-clean (path.Clean under '/') so no
../ or encoded traversal can escape the <org>/<slug>/ prefix into another
project or org. A globally-unique site_hosts binding table makes a bare
subdomain resolve deterministically to exactly one tenant (project slugs are
only org-unique); binding is first-come and cannot be hijacked.

Cache: one canonical policy (sites.CacheControlFor) applied both when writing
objects at deploy and when serving them — HTML public,max-age=60,s-maxage=86400;
content-hashed assets immutable 1y; middle TTL otherwise; per-project
cacheControl override on the document TTL. Cloudflare purge-by-cache-tag
(site-<org>-<slug>) on redeploy AND delete; creds from KMS/env
(CF_API_TOKEN/CF_ZONE_ID), honest no-op when unset. Cache state (TTL +
lastPurgeAt) exposed on the project API.

Tests: traversal/cross-tenant isolation proof, host-routing + reserved-host
exclusions, first-come/no-hijack subdomain binding, CF purge client.
2026-07-02 17:29:17 -07:00
hanzo-dev 435419bde9 feat(zt): tenant-scoped networking surface fronting Hanzo Zero Trust
Add clients/zt — a thin, org-scoped facade over the Hanzo Zero Trust
controller's OpenZiti Edge Management API (/edge/management/v1), backing
the console's Networks, Service Mesh and Edge pages (which render
"not connected" today).

Surface (all org-scoped by the validated principal):
  GET /v1/networks[/:id]  the org's ZT overlay, projected from its edge-routers
  GET /v1/mesh/services   ZT edge services
  GET /v1/edge/nodes      ZT edge-routers + real online/disabled/offline status

- client.go: one HTTP path — Ziti password-auth (KMS-injected
  ZT_CLIENT_ID/ZT_CLIENT_SECRET, zt-session header), cached session with
  re-auth-and-retry-once on 401, generic {data,meta} pager, honest error
  mapping, TLS trust via ZT_CA_PEM. Fails closed 503 when unconfigured.
- types.go: ZT wire structs + console view structs + PURE mapping.
  Tenant isolation is the "org-<org>" role attribute (the ONE tenancy
  convention ZT expresses natively); list/get filter to the caller's org.
- zt.go: routes/handlers, registered as subsystem "zt" (order 134).
- http_test.go: fake controller (interface seam) — asserts 200, tenant
  isolation, shape, health mapping, 401 re-auth retry, fail-closed 503.

Honest-empty over fabrication throughout: no org tag -> invisible; no
routers -> no network; no metrics -> omitted (UI renders em dash).
2026-07-02 17:28:44 -07:00
zandGitHub 769e0cc719 Merge pull request #63 from hanzoai/feat/visor-subsystem
release / build-amd64 (push) Failing after 7m9s
release / notify-universe (push) Skipped
feat(cloud): mount visor /v1/visor/* as a subsystem in the unified binary
2026-07-02 17:25:16 -07:00
hanzo-dev faa0fb365a chore(cloud): pin visor v1.108.5 for the mounted subsystem 2026-07-02 17:24:25 -07:00
zandGitHub 3f974f4810 Merge pull request #62 from hanzoai/feat/admin-compute-endpoint
feat(admin): /v1/admin/compute — cross-tenant compute analytics from the datastore
2026-07-02 17:22:58 -07:00
hanzo-dev ecae944468 feat(admin): /v1/admin/compute — cross-tenant compute analytics from the datastore
New global-admin read GET /v1/admin/compute powering the console Bots + Machines
operator boards. Aggregates hanzo.compute_usage(org, app, project, kind, event,
machine_id, size, price_cents, ts) grouped by (org, app, project, kind) over the
shared datastore client (aiobject.DatastoreQuery — the clients/analytics transport,
no second conn). `kind` is an OPEN LowCardinality spectrum (bot|machine|cluster|
nodepool|container|function|…) matched as a PLAIN STRING — ?kind= narrows to any
kind (Bots=bot, Machines=machine; future Clusters/Functions reuse this endpoint),
?org filters, ?range=24h|7d|30d bounds. Two-level roll-up: inner argMax(event,ts)
per machine -> outer counts machines, active (latest non-terminal), sum(price_cents),
max(ts). Honest-empty when the warehouse/table isn't wired yet (visor/commerce
emitter pending) — never a fabricated fleet. Global-admin only (s.guard); stays v1.x.x.
2026-07-02 17:15:02 -07:00
hanzo-dev 845696bd5a feat(visor): /v1/machines,/v1/gpus,/v1/clusters — compute unified into cloud via Visor
New clients/visor subsystem fronts Visor (the cloud OS at visor.hanzo.svc) and
serves the console's Machines/GPUs/Clusters pages as clean, tenant-scoped REST off
the unified cloud binary — replacing the god-mode /paas admin proxy that 501s.

Routes (every route org-scoped by the validated principal → Visor ?owner):
  GET/POST /v1/machines, GET/DELETE /v1/machines/:id   -> get-machines / machines/launch / delete-machine
  GET /v1/gpus (+ /v1/gpus/alerts)                     -> per-accelerator inventory derived from GPU machines
  GET /v1/clusters, node-pool create/scale/delete      -> get-node-pools / *-node-pool

View JSON mirrors the console normalizers exactly (visor.ts/compute.ts/platform.ts)
so the FE renders with no change. No fabrication: GPU rows are real accelerators of
real GPU machines, clusters are real node pools, and telemetry Visor lacks is
omitted (renders — not 0). Auth: KMS service credential (Basic) or forwarded bearer.

Tests: tenant scoping/isolation, machine/gpu/cluster shape, GPU slug derivation,
launch quote+real+delete. go build ./... + go vet + go test all green.
2026-07-02 17:08:17 -07:00
hanzo-dev f02ee08031 feat(platform): native console aggregates — /v1/{environments,pipelines,builds,releases}
The console Environments/Pipelines/Builds/Releases pages rendered "not
connected" because they call top-level REST that no cloud subsystem served.
Serve them natively from the platform control plane, DERIVED from the SAME
per-org project/app/deployment/build records (no new data model, no fabrication):

  - GET /v1/environments — distinct Application.Environment targets across the
    org's apps, each aggregating its apps (services), with a derived
    type/status. List-only: an environment is a scope on apps, not a record.
  - GET /v1/pipelines — one per app: its build/deploy config (repo|image) plus
    the status/timing of its latest deployment. List-only: a pipeline is an app.
  - GET /v1/builds — the REAL arcd BuildKit build records (platform_builds),
    joined to app repo + deployment commit. List-only: builds are triggered by
    the app deploy path (git source) — one trigger, not a duplicate here.
  - GET /v1/releases — deployments actually applied to the cluster
    (status deploying|live): a released image tag on an app/environment.

Every route is org-scoped through the same validated-principal gate (s.tenant →
requires c.User()); the response is the exact `{ "<plural>": [...] }` wrapper the
console FE normalizers read. Three org-wide store aggregates back them
(ListAllApplications / ListDeploymentsByOrg / ListBuildsByOrg), org the only
tenancy predicate. Real records or an honest empty — never fabricated history.

Tests: shape (200 + wrapper + derived fields), org isolation (second org sees
empty, no cross-tenant leak), forgeable-org refusal (no X-User-Id → 403).
2026-07-02 17:04:37 -07:00
hanzo-dev 40cc14ed5d feat(do): native DigitalOcean VPC + Load Balancer surface (/v1/vpcs, /v1/load-balancers)
Add clients/do: an org-scoped facade over digitalocean/godo's native VPCs
and LoadBalancers services, backing the console's VPC + Load Balancers pages
(which render 'not connected' today because nothing serves them).

- Routes: GET/POST /v1/vpcs, GET/DELETE /v1/vpcs/:id and the same for
  /v1/load-balancers. Real godo calls, honest empty/error states, never
  fabricated.
- Tenant isolation: DO is a single account, so a resource's physical DO name
  is 'o'<orgHash>-<friendly> via provisioning.BucketName (the SAME org-hash
  convention clients/s3 uses). List filters the account inventory to the
  caller's prefix; get/delete confirm prefix ownership before acting; a
  cross-tenant id reads 404 (existence-oracle guard).
- Fail-closed: absent DO_API_TOKEN every op is an honest 503.
- FE shape matches console2 VpcModule/LoadBalancerModule verbatim (vpcs[],
  loadBalancers[] with the exact field names).
- Registered as subsystem 'do' (order 123). godo v1.197.0 added to go.mod.
- Tests: per-org VPC + LB isolation, forge-path 403, fail-closed 503.
2026-07-02 17:02:11 -07:00
zandGitHub b26c4c4570 Merge pull request #61 from hanzoai/fix/agents-ai-inference
fix(agents+platform): real /v1/agents/:name/run inference + tolerate async tenant-RBAC on first deploy
2026-07-02 16:43:35 -07:00
hanzo-dev 1c21550559 chore(deps): realign luxfi/age + luxfi/pq go.sum zip hashes (force-re-tag drift)
Same class as c2a7534: upstream force-re-tagged luxfi/age v1.5.0 and
luxfi/pq v1.0.3 (content moved, /go.mod hashes unchanged), so the committed
zip h1: sums no longer match the served bits and `go build ./...` fails
verification. These deps entered the graph via hanzoai/commerce/metering
v0.1.2 (the agents scheduler/billing). Re-record the current zip hashes.
2026-07-02 15:48:48 -07:00
hanzo-dev faf1b35fff agents: bind /v1/agents/metrics + /v1/agents/activity (unshadow from :name)
The console2 Agents dashboard calls two org-wide routes that were never
reachable: the bare /v1/agents/:name wildcard captured "metrics"/"activity"
as an agent name (Fiber matches in registration order), so both 404'd and the
dashboard rendered a permanent "not connected" state.

- Register the two static routes BEFORE :name so they win the match.
- GET /v1/agents/metrics?range=24H|7D|30D -> a per-agent invocations-over-time
  histogram bucketed from REAL agent_runs rows; the Resource Usage rollup is
  all-null because this store meters no CPU/mem/storage/cost (honest em-dash,
  never a fabricated trend). Shape mirrors console2 normalizeMetrics exactly
  ({range,series:[{key,points:[{t,v}]}],resource:{...}}).
- GET /v1/agents/activity -> org-wide recent-activity feed: each recorded run
  is an invoked/failed event, each agent's own create/update timestamps are
  created/updated events; merged newest-first, capped 50. Shape mirrors
  normalizeActivity ({activity:[{id,kind,agent,message,at}]}).
- Store: add RunsSince(org,since,limit) — org-wide runs across all agents,
  tenancy on the org column; powers both surfaces.
- Tests prove the surfaces are not shadowed (200, not 404), reflect only real
  runs, and stay org-isolated.
2026-07-02 15:45:44 -07:00
hanzo-dev 8fcea20bff fix(agents): wire real inference so /v1/agents/:name/run executes
release / build-amd64 (push) Failing after 5m40s
release / notify-universe (push) Skipped
deps.AI was permanently nil under the default all-enabled config
(pickAIClient returned nil for cfg.Enabled("ai") and no in-process ai
subsystem ever filled it), so every agent run 503'd "inference is not
configured on this deployment" before the already-live per-org metering.

New AI client (clients/aihttp.go), two credential modes:
- AIHTTPAt: static-key OpenAI-compatible client (CLOUD_AI_API_KEY) — an
  operator/pre-provisioned-key override.
- AIHTTPM2M: durable default — mints+auto-refreshes an IAM client-
  credentials token from the binary's OWN identity (IAM_CLIENT_ID/SECRET)
  via x/oauth2/clientcredentials. No static key to rotate, no expiry cliff,
  no new secret to store. On Hanzo the identity resolves to
  admin/hanzo-cloud, which the gateway treats as balance-exempt, so cloud's
  per-org ResourceMeter stays the single revenue debit (no double-bill).

- build.go pickAIClient: static key -> M2M -> ZAP RPC -> fail-closed stub.
  Never returns nil (the live bug). Secret never logged.
- config.go: CLOUD_AI_BASE_URL (default https://api.hanzo.ai/v1),
  CLOUD_AI_API_KEY (optional), CLOUD_AI_DEFAULT_MODEL (default
  deepseek-v4-flash), AIAuthClientID/Secret from IAM_CLIENT_ID/SECRET.
- clients/aihttp_test.go: httptest OpenAI emulation — default-model
  substitution, content parse, 4xx/5xx mapping, empty-choices error, and
  M2M token mint+use+cache.

Composes with the live metering (Gate/MeterUsage) untouched. Model routing
is the gateway's job; empty model -> cheap default is the only cloud-side
fallback (no in-code model aliasing).
2026-07-02 15:34:54 -07:00
hanzo-dev 819cd22173 fix(admin/finance): RED — honest revenue.Configured + fleet-wide /v1/costs (no false margin)
Addresses Red MED-1/MED-2/INFO-3:
- MED-1: revenue.Configured now means the source was actually READ (listOrgs
  succeeded), not merely wired. A transient IAM failure → configured:false, never a
  fabricated zero that flips margin negative into a false 'burning' alarm. Per-org
  read failures mark the commerce source not-ok (partial), never presented as whole.
- MED-2: /v1/costs is a fleet-wide god-view — commerce resolves the namespace from
  COMMERCE_SERVICE_ORG, NOT from a request header (it never reads X-IAM-Org-Id).
  Dropped the no-op org arg from costs(); corrected the false 'resolves namespace
  from X-IAM-Org-Id' claims in commerceClient + orgSubject docs.
- Test: TestFinance_RevenueSourceDown_NoFabrication proves no fake revenue/margin
  when the IAM org list is unreadable while COGS still flows.
(INFO-3 false-green fix lands console-side in financeHealth.) 10 admin tests green.
2026-07-02 14:28:30 -07:00
hanzo-dev 3a42960859 feat(admin/finance): margin COGS from commerce /v1/costs — one vendor-COGS source
The finance board's cost side now CONSUMES commerce /v1/costs (the single
vendor-COGS source of truth: DigitalOcean compute + the LLM providers we resell)
instead of re-reading DigitalOcean's billing API to derive a DO-only cost. This
removes cloud's duplicate DO COGS read and gives the board the multi-vendor
per-vendor breakdown for free.

- commerceClient.costs() reads GET /v1/costs over the admin S2S service token
  (COMMERCE_SERVICE_TOKEN, no IAM user → commerce requireCostsAdmin M2M path).
- financeCost carries {configured,totalCents,vendors[],period}; margin cost is
  now the multi-vendor TotalCents, not DO month-to-date spend.
- DigitalOcean stays ONLY as the orthogonal promo-credit/runway treasury view
  (commerce does not track our prepaid credit); its MTD spend feeds runway alone.
- /v1/admin/finance shape is additive (cost.digitalocean preserved) and the
  global-admin guard is unchanged.
- tests: fake commerce now serves /v1/costs; margin = revenue - COGS; DO-off path
  proves COGS still flows from commerce (decoupled).
2026-07-02 13:51:46 -07:00
hanzo-dev 7ca2874f53 fix(platform): tolerate async tenant-RBAC on first deploy (cold-start race)
release / build-amd64 (push) Failing after 6m12s
release / notify-universe (push) Skipped
A brand-new tenant's namespace is created by ensureNamespace, but the operator's
tenant-RBAC controller projects cloud-api's `cloud-api-platform` RoleBinding
(get/create resourcequotas/limitranges/services.hanzo.ai in tenant-<org>)
ASYNCHRONOUSLY. The first-ever deploy raced ahead of that RoleBinding and failed
with `resourcequotas ... is forbidden`, self-healing only on a manual retry.

Gate ensureNamespace on a SelfSubjectAccessReview readiness poll
(waitForTenantRBAC): before touching the quota objects, ask the apiserver — as
cloud-api's OWN identity — "can I get resourcequotas in tenant-<org>?" and wait,
with bounded exponential back-off (~45s ceiling), for the operator's RoleBinding
to land. An already-onboarded tenant is confirmed by a single fast probe (no
sleep), so existing deploys are not slowed. On timeout, fail CLOSED with a
retryable errTenantProvisioning (deployErrStatus -> HTTP 503, honest
"provisioning, retry") — never a fabricated success. Creating a SSAR needs no
tenant RBAC (system:basic-user), so the probe is itself immune to the window it
closes. No RBAC or namespace derivation is loosened.

Tests (clients/platform/tenant_rbac_test.go): retry-until-RoleBinding-lands
succeeds end-to-end (quota+Service CR written); bounded timeout fails closed
(503, no quota/CR written); ctx-cancel aborts promptly; ready tenant resolves in
one probe (auto-glue fast path unchanged).
2026-07-02 13:39:42 -07:00
zeekay b32bce6f13 Merge remote-tracking branch 'origin/rip/api-to-v1' 2026-07-02 13:07:18 -07:00
hanzo-dev fe3a5fd553 feat(agents): /v1/agents per-org fail-closed metering + long-running scheduler
release / build-amd64 (push) Failing after 7m11s
release / notify-universe (push) Skipped
Lands the agent-backend metering feature onto the zip->zap-proto-migrated main
(cloud is already fully migrated on main; 8 subsystem pins + MountAll clean).

- /v1/agents/* self-meters a per-run fee to commerce: pre-authorize the org's
  prepaid credit balance fail-closed (402 insufficient_balance), debit on success,
  attributed to product "agent". Added to selfMeteredPrefixes so the edge gate
  never double-bills. Run path (money-moving) requires a VALIDATED principal
  (c.User() non-empty), refusing the no-bearer forge path; scheduled runs carry an
  unforgeable 'scheduler'-prefixed actor.
- ResourceMeter.Gate now forwards costCents as AuthInput.AmountCents so the gate
  enforces available >= fee (not merely > 0) — a 1-cent balance can no longer
  authorize a run that takes the ledger negative. MeterUsage generalizes the
  per-org debit (Actor/Model/token attribution) while Meter keeps its signature.
- Long-running agents: cron scheduler scans once a minute over a partial index
  (ix_agents_scheduled), bounded per-org cap (CLOUD_AGENT_MAX_LONG_RUNNING);
  scheduler.stop drains in-flight runs inside the SIGTERM budget via ShutdownAll.

Deps: commerce/metering v0.1.0 -> v0.1.2 (Actor field + AuthInput.AmountCents).
All other pins inherited from migrated main (ai v1.789.1, authz v1.10.3,
base v1.4.6, commerce v1.42.29, licensing v0.1.1, metrics v0.4.1, o11y v1.3.12,
vfs v0.4.4). hanzoai/zip stays out of the graph; zip == zap-proto/zip v1.2.0.

go mod verify clean; go build ./... EXIT 0; MountAll boot-smoke clean (no
want *zip.App); agents -race + root/ml/provisioning billing tests green.
2026-07-02 12:44:31 -07:00
zeekayandClaude Opus 4.8 c2a75347d7 chore(deps): realign luxfi/age + luxfi/pq go.sum zip hashes (force-re-tag drift)
Verified the /api/ -> /v1/ rip is COMPLETE on origin/main: zero owned /api/
route registrations, zero owned /api/ client strings. The rip landed in
cf58f8d (productsvc: drop residual /api/ prefix) plus the o11y and eval
cleanups. Every remaining /api/ reference is a non-owned external contract:

  - clients/o11y/o11y.go, o11y_test.go: doc comments ("no /api/, no rewrite")
    documenting the now-removed upstream rewrite.
  - clients/pricing/pricing.go: https://openrouter.ai/api/v1/models — a
    third-party vendor URL.
  - clients/platform/*_test.go: "apps/api/deploy" where `api` is a user's
    APP NAME inside /v1/platform/... paths (not an API prefix).
  - zapface/dispatch.go: comment already says "the /v1 convention".
  - clients/prompts/catalog.json: a prompt-catalog data blob describing a
    different project (prompts.chat), not this repo's routes.

The IAM /api/add-usage-record callout referenced in older cloud docs is a
Casdoor/casibase-lineage endpoint the LEGACY Node cloud-api used (documented
in hanzoai/commerce auth/iam_admin.go). The current Go cloud-api does NOT
call it: billing meters to commerce via hanzoai/commerce/metering. No
cross-service IAM usage-record dependency exists in this repo.

The only change here is a deps-hygiene fix so the build verifies clean:
luxfi force-re-tagged age@v1.5.0 and pq@v1.0.3, drifting their module-zip
h1: hashes vs the recorded go.sum (go.mod hashes unchanged). Realigned to
the upstream hashes at the SAME versions — no major/minor bump.

  CGO_ENABLED=0 GOWORK=off go build -mod=readonly ./...   -> exit 0
  go test ./clients/{o11y,eval,pricing,ml,admin} ./zapface . ./clients/platform -> all ok

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 12:33:09 -07:00
hanzo-dev b4322fa7ac fix(platform): serialize apply-CR→finalize-live per app (RED LOW-1)
applyService (operator Service CR write) was not jointly ordered with
FinalizeLive (live-pointer DB write): under concurrent same-app deploys an
OLDER deploy's CR write could land AFTER a NEWER one already went live, leaving
the live Service CR image lagging the recorded live version. deployImage had no
supersede gate at all.

Introduce applyLive — the ONE deploy mechanic shared by the image-source path
and the git build reconciler — running supersede-check → applyService →
FinalizeLive as one per-app-serialized critical section (appMutex, fixed-shard,
O(1) memory). An older deploy that loses the race is superseded and never writes
its CR. FinalizeLive's monotonic CAS still backstops DB monotonicity.

go vet + full clients/platform test suite green.
2026-07-02 12:00:17 -07:00
hanzo-dev 3e69c17ba5 fix(platform): HIGH-2 secrets-free cloud-api + MED-1 version-monotonic finalize (RED)
HIGH-2 — cloud-api touches NO K8s Secret:
- delete ensurePullSecret + secretsGVR + CLOUD_PLATFORM_PULL_DOCKERCONFIG;
  the per-tenant ghcr-pull Secret is provisioned by the OPERATOR's tenant-RBAC
  controller (from a KMS-synced source). serviceCR only REFERENCES it by name.
  cloud-api's ServiceAccount holds no secrets grant and issues no Secrets call.

MED-1 — monotonic live-version finalize (no build-time inversion):
- Store.FinalizeLive: ONE atomic conditional UPDATE that advances an app to live
  ONLY when its version >= the currently-live version (no read-then-write TOCTOU).
- reconcileBuild gates on buildSuperseded before applying the (older) CR, and
  records a late/older build 'superseded' (image still succeeded) instead of
  regressing the running workload. deployImage shares the same FinalizeLive.
- tests: TestFinalizeLiveIsMonotonic (store CAS) + TestBuildReconcilerVersionMonotonic
  (e2e inversion: newer-first-live, older-late-superseded, CR never downgraded).
2026-07-02 10:55:55 -07:00
hanzo-dev 646f2517cc feat(platform): git build→deploy watcher (close phase-2) + tenant GHCR pull secret
The git deploy path launched a BuildKit Job fire-and-forget and left the
deployment stuck 'building' — deploy.go documented the build watcher as
'phase 2'. Implement it as the ONE owner of the handoff:

- reconcile.go: a restart-safe reconciler (state in the store, not a
  goroutine) that scans 'building' deployments, checks each build Job, and on
  success applies the operator Service CR with the built image (the SAME
  applyService the image path uses) → deployment 'deploying', app 'live'. On
  failure/deadline it records the honest error. Started from Mount, stopped on
  Shutdown. Org-scoped: every write targets tenant-<row.Org>.
- store.ListBuildingDeployments: cross-org 'building' query (reconciler input).
- k8s.jobOutcome/jobResult: ONE Job terminal-state classifier shared by the
  concurrent-build cap and the reconciler.
- k8s.serviceCR imagePullSecrets + ensurePullSecret: the built image is PRIVATE
  (ghcr.io/hanzoai/tenant-<org>/*); provision the tenant GHCR pull secret from
  CLOUD_PLATFORM_PULL_DOCKERCONFIG (KMS-synced; no-op when unset) and reference
  it so the operator's pod can pull.

Tests: jobOutcome classifier + ListBuildingDeployments (oldest-first, cross-org,
building-only). Full platform suite green (RED authz/cmd-injection incl.).
2026-07-02 10:47:22 -07:00
hanzo-dev 8251286820 refactor(eval): route telemetry over the ONE shared datastore client
release / build-amd64 (push) Failing after 5m40s
release / notify-universe (push) Skipped
clients/eval/telemetry.go opened a SECOND direct clickhouse.Open with a
parallel CLOUD_EVALS_CLICKHOUSE_* cred namespace, bypassing the shared ZAP
datastore mesh that clients/analytics + ai/object already use. Consolidate:

- eval telemetry now routes every write/read over ai/object's shared client
  (aiobject.DatastoreExec / DatastoreQuery / DatastoreEnabled), the same peer
  the o11y ledger + /v1/analytics use. One connection, one pool, one
  retry/backoff, one KMS-injected cred namespace (DATASTORE_*).
- DELETE the CLOUD_EVALS_CLICKHOUSE_* namespace and the private clickhouse.Open.
- Ownership stays clean: eval owns only its two tables (hanzo.eval_traces,
  hanzo.eval_scores); ai/object owns hanzo.cloud_usage / hanzo.observations.
- No batch primitive needed — eval Records are single-row, mapping to the
  shared DatastoreExec INSERT ... VALUES (?) the o11y write path already uses.
- Async-connect aware: readiness gates per-op on DatastoreEnabled() (honest
  'unavailable' in the boot window), tables ensured idempotently, latched once.
- Reads bind org + narrowers positionally (?) — no interpolation; LIMIT always
  applied. Tenant isolation + score finiteness invariants unchanged.

provisioning's direct CH client is a distinct control-plane concern — untouched.

go build + go test ./clients/eval/... green.
2026-07-02 08:40:16 -07:00
hanzo-devandGitHub a52c00a0a7 ci(release): smoke-test the image before it can publish (#59)
A green go build/vet/test does not catch a binary that PANICS at startup.
v1.786.14/.15/.16 compiled clean but crashed at boot with

    cloud: mount metrics: metrics.Mount: app is *zip.App, want *zip.App

(a runtime type-assert from an incomplete hanzoai/zip -> zap-proto/zip
migration), published green, and CrashLooped in prod. The only gate that
catches this class is running the binary.

Restructure the single build-push into build(load) -> smoke -> push:

1. Build once to a local cloud:smoke tag (push:false, load:true), warming
   the BuildKit builder cache.
2. Boot that exact image with a minimal, prod-representative env (writable
   ephemeral /data + a throwaway 32-byte KMS master key so the KMS plane
   mounts on its normal ready path) and assert it reaches "listening" with
   NO startup-crash signature (metrics.Mount / mount metrics / panic /
   want *zip.App), else exit 1 BEFORE any push. Container always rm -f.
3. Re-run build with push:true and the real tags: identical context /
   platform / secrets, so every layer is a cache hit from step 1 and it
   only publishes the already-tested image.

Stays on the self-hosted arcd amd64 scale set + GH_PAT; notify-universe
unchanged.

Proven to DISCRIMINATE against the published images:
  ghcr.io/hanzoai/cloud:v1.786.18 (known-good) -> SMOKE PASS (exit 0)
  ghcr.io/hanzoai/cloud:v1.786.15 (known-bad)  -> SMOKE FAIL (exit 1)
2026-07-02 05:48:29 -07:00
hanzo-dev ce10cc3b79 merge(cloud): forward-integrate v1.786.18 F1 data-plane gate into the platform mount
release / build-amd64 (push) Successful in 6m13s
release / notify-universe (push) Failing after 1s
SECURITY: cloud origin/main (55b073e7) had DIVERGED from the LIVE image
v1.786.18 (faef12aa) at merge-base 862d4623 — v1.786.18 was tagged+deployed
from fix/cloud-1.786.18-zip-and-gates but NEVER merged back to main. It carries
the F1 forged-X-Org-Id cross-tenant gate that main LACKED:
  - clients/principal (validated-principal helper) — new package
  - bot + o11y reverse-proxy forge gates (bot.go, o11y.go + red_forge_test.go)
  - whole-data-plane principal gating across agents/crm/eval/functions/git/kms/
    ml/plan/pricing/projectsvc/prompts/provisioning/s3

Building .20 from main+platform ALONE would have REGRESSED this live fix
(reopened the forged-X-Org-Id hole — the ".17 insecure, do NOT deploy" hole).
This merge makes the artifact a true SUPERSET of the security floor.

Only conflict: clients/provisioning/provisioning.go import block — resolved to
keep BOTH the F1 principal.Validated(c) gate AND the platform sanitizeOrg
injectivity crit-fix (OrgHasUnsafeRune + raw-byte hash). go.mod/go.sum merged
clean (main and .18 converged on identical dep floors: ai 1.789.1, authz
1.10.3, base 1.4.6, commerce 1.42.29, o11y 1.3.12, vfs 0.4.4, zap-proto/zip).

Result = main (analytics/crm/templates/git + zip migration) + /v1/platform mount
+ v1.786.18 F1 gate. All 24 subsystems registered. go build ./... + go vet green.
Tests green together: platform (TestRED_CommandInjectionBlocked, cross-tenant,
injective), F1 (TestRed_BotProxyForwardsForgedOrgNoPrincipal,
TestRed_O11yProxyGatesForgedOrgNoPrincipal), provisioning, principal, root cloud.
2026-07-02 05:34:55 -07:00
hanzo-dev 90b666f5e5 merge(cloud): mount /v1/platform PaaS subsystem into main (blue/paas-v1platform)
release / build-amd64 (push) Successful in 7m14s
release / notify-universe (push) Failing after 1s
Merge RED-PASSED blue/paas-v1platform@86cb4c15 onto main@55b073e7, mounting the
per-org container-app PaaS control plane at /v1/platform (HIP-0106) — the deploy
engine behind one-click app deploys (ERP/Helpdesk ride on it).

Conflicts resolved (5 files, all combine-both-sides — no logic dropped):
  - subsystems/subsystems.go: KEEP every registration — platform (order 124) +
    analytics/crm/git/templates/prompts/agents/functions + all pre-existing.
  - clients/provisioning/provisioning.go: main's zip canonical import +
    branch's sanitizeOrg injectivity crit-fix (OrgHasUnsafeRune reject,
    raw-byte SHA-256, no TrimSpace) — both preserved.
  - middleware_identity.go: main's zap-proto/zip + doc; branch's OrgHasUnsafeRune
    (root cloud pkg) + SanitizeIdentity handler hardening — both preserved.
  - provisioning_test.go / middleware_identity_test.go: both test sets kept.

Integration: main migrated the repo hanzoai/zip -> zap-proto/zip; the branch
predated it, so the new clients/platform/{deploy,platform,http_test}.go were
rewritten to the canonical github.com/zap-proto/zip (incompatible zip.Ctx types
otherwise). go.mod unchanged from main (zap-proto/zip v1.2.0 direct); no new dep
(k8s.io/{apimachinery,client-go} v0.35.0 already present via ml/paassvc).

Crit-fixes preserved EXACTLY (RED-PASSED, proven green):
  argv build (TestRED_CommandInjectionBlocked), org-slug injective
  (TestSanitizeOrg{Injective,WhitespaceInjective}, TestBuildImageRefIsInjective),
  ResourceQuota (TestEnsureNamespaceAppliesQuota), no cross-tenant
  (TestHTTPCrossTenantIsolation, TestNamespaceIsDerivedFromOrgNotInput,
  TestServiceCRAlwaysPinnedToTenantNamespace).

go build ./... + go vet + go test (platform/provisioning/root cloud) all green.
2026-07-02 05:22:07 -07:00
hanzo-dev faef12aa38 fix(cloud): purge hanzoai/zip (forward zip-canonical-home) + complete F1 data-plane gates
release / build-amd64 (push) Successful in 6m11s
release / notify-universe (push) Failing after 2s
Startup crash on v1.786.15/.16: `cloud: mount metrics: metrics.Mount: app is
*zip.App, want *zip.App`. Cloud's core migrated to github.com/zap-proto/zip
(v1.786.13→.15) so app is *zap-proto/zip.App, but eight hanzo modules cloud
imports still pinned the OLD github.com/hanzoai/zip and registered subsystems
that type-assert app.(*hanzoai/zip.App). Distinct import paths = distinct Go
types, so MountAll's first such subsystem ("metrics", hanzoai/metrics@v0.4.0)
failed the assert at runtime (build/vet/test stayed green — the mismatch is
runtime-only). authz/base/o11y were the same latent break behind it.

Forward fix (the canonical-home migration was already released upstream; cloud
merely lagged): bump every lagging module to its migrated tag —
  ai v1.789.1-… → v1.789.1        authz v1.10.1 → v1.10.3
  base v1.4.1 → v1.4.6            commerce v1.42.27 → v1.42.29
  licensing v0.1.0 → v0.1.1       metrics v0.4.0 → v0.4.1
  o11y v1.3.7 → v1.3.12           vfs v0.4.1 → v0.4.4
authz pinned to v1.10.3 specifically: v1.10.2/v1.10.4 carry an unrelated
GetPolicy 2-value change that breaks the pinned hanzoai/iam; v1.10.3 has the zip
migration AND the iam-compatible 1-value signature. commerce pinned to v1.42.29
(v1.43.0 regressed back to hanzoai/zip). clients/analytics (from the merged .16
work) is cloud's own code and is migrated in-place. Result: hanzoai/zip is gone
from go.mod/go.sum and the whole module graph; the compiled binary mounts
metrics+o11y+all subsystems and reaches "listening" with no panic.

Also folds in the COMPLETE F1 close (RED found the gate was partial — two
reverse-proxy paths still forwarded a forged X-Org-Id):
- clients/bot: gate proxy() on principal.Validated before forwarding X-Org-Id to
  bot-gateway (RED PoC red_forge_test.go now passes: no-principal forge → 403).
- clients/o11y: wrap the installed reverse-proxy handler in gate() — refuse any
  request with no X-User-Id before it reaches the o11y runtime (forge twin test).
- clients/crm: extend the forge guard to WRITE+DELETE verbs (belt-and-suspenders).
- middleware_identity: refresh the stale FAIL-MODE comment — post-F1 the DATA
  plane also fails secure on a cold-cache JWKS failure (bounded by stale-on-error).

Base = F1 (fix/cloud-data-plane-principal-gate, bff2a688) + origin/main (analytics
.16, 862d4623). One healthy image: F1 + analytics + zip-fix + bot/o11y gates.
2026-07-02 04:37:38 -07:00
hanzo-dev 55b073e764 fix(analytics): import canonical github.com/zap-proto/zip (not hanzoai/zip)
Aligns clients/analytics with serve.go + every other in-tree clients/* subsystem,
which import github.com/zap-proto/zip. The original commit imported the OLD
github.com/hanzoai/zip, making analytics.Mount assert *hanzoai/zip.App while serve
passes *zap-proto/zip.App — a boot-time mount type mismatch. Removes the last in-tree
hanzoai/zip importer so once the migration lane re-releases the external subsystem
modules on zap-proto/zip, main boots with ONE zip.App type. (The DEPLOYED v1.786.17
is built off v1.786.13, which is all-hanzoai/zip, and is unaffected.)
2026-07-02 04:33:45 -07:00
hanzo-dev 261b847ed7 Merge remote-tracking branch 'origin/main' into fix/cloud-1.786.17-zip-and-gates 2026-07-02 04:18:41 -07:00
hanzo-dev bff2a688ad fix(cloud): gate the whole data plane on a validated principal (RED HIGH — live cross-tenant)
release / build-amd64 (push) Successful in 7m14s
release / notify-universe (push) Failing after 2s
RED found the cloud data plane trusted a bare X-Org-Id with no validated
principal. Off-gateway (direct api.cloud.hanzo.ai / in-cluster), a request with
`X-Org-Id: victim` and NO credential read/wrote/deleted another tenant's data:
CRM PII, KMS secrets, prompts, agents, evals, ML namespaces, projects, git repos.
SanitizeIdentity RESTORES a client X-Org-Id on the bearer-less "Phase-1 data"
path but leaves X-User-Id empty, so c.Org() alone is forgeable; c.User() (set
ONLY from a verified bearer/cookie) is the authentic signal.

ONE canonical gate — new clients/principal:
  - Validated(c): request carries a validated principal (X-User-Id set).
  - Tenant(c): verbatim org, gated + bounded + cloned; ("",false) => caller 403s.
The S3/eval fix (already shipped) is now the single source of truth, used
everywhere instead of six drifting hand-rolled copies.

Data-plane resolvers gated (were bare c.Org()):
  crm, prompts, agents, functions, git, eval -> principal.Tenant (verbatim)
  kms.guard (closes forged X-Org-Id==:org bypass), ml (per-org k8s ns),
  projectsvc, provisioning, s3 -> principal.Validated + own normalize/admin-bucket
  plan, pricing (public catalog) -> validated principal selects its overlay, else
  the public "hanzo" default (never a forged org's overlay)
  middleware_billing (no victim-ledger drain/probe), audit_middleware (aligned)

Breaks no real client: the console BFF always mints a user-bound bearer
(X-User-Id set); opaque-API-key callers hit /v1/ai/*, not these subsystems.

Tests (F4): clients/principal unit suite (forged-org-no-principal refused,
verbatim no-fold, empty/overlong refused, Validated only from X-User-Id); crm
forged-org 403 on every collection (PII); kms Vector8 forged X-Org-Id==:org with
no principal -> 403 (secrets). Existing per-subsystem tests send X-User-Id on the
legit path as the BFF does.

go build ./... green; go vet green; go test ./clients/... . green.
2026-07-02 03:34:45 -07:00
hanzo-dev 862d4623aa feat(analytics): native-Go /v1/analytics on datastore/ClickHouse, per-org
release / build-amd64 (push) Successful in 6m15s
release / notify-universe (push) Failing after 2s
Adds clients/analytics (order 132, registered as analyticssvc so the real
/v1/analytics/health owns the probe, not serve.go's generic liveness route) —
the backend for the console Native Analytics module (unified-analytics.md §5).

Two read lenses over the ONE hanzo warehouse, reusing the SAME clickhouse-go/v2
client the ai o11y ledger opens (ai/object DatastoreQuery/DatastoreEnabled/
EnsureCloudUsageTable/ResolveCloudUsageWindow) — no second CH client, DRY:
  - LLM lens (REAL): hanzo.cloud_usage — requests/tokens/spend/models/errorRate
  - web+commerce lens: hanzo.events — honest-empty until the collector emits

Surface (read-only, org-scoped, /v1):
  GET /v1/analytics/overview     per-org KPIs (llm real; web/commerce honest-empty)
  GET /v1/analytics/timeseries   requests/tokens/spend over hour|day buckets
  GET /v1/analytics/top          top models (real) + top products (honest-empty)
  GET /v1/analytics/health       datastore connectivity + lens-table availability

Tenant isolation is the security bar: tenant() requires a VALIDATED principal
(c.User(), set by SanitizeIdentity only for a verified bearer) AND a valid org
(c.Org(), the minted owner claim) — closing the Phase-1 no-bearer forged-X-Org-Id
data path exactly as clients/s3 does. Every query binds the org POSITIONALLY
(query.go llmWhere/eventsWhere), so a maxpower token can never read another org.
ClickHouse creds are KMS-injected env (DATASTORE_*), never hardcoded.

Tests: query-boundary isolation (org bound, never interpolated, incl SQLi slug),
honest-empty, real-number KPIs, errorRate, gap-filled series, top-models pct;
HTTP: no-principal->403, forged-org-no-bearer->403, datastore-down->honest 503,
bad-range->400, health owned-by-analytics honest 503 when down.
2026-07-02 03:32:27 -07:00
hanzo-dev c181c5f7f8 Merge feat/per-product-metering: per-product credit-drawdown for functions + s3
Integrates the fail-closed per-org credit-drawdown gate into cloud's last two
free data-plane subsystems (functions invoke, s3 op) via the ONE shared
cloud.ResourceMeter, plus the per-org billing-key fix (gate keys on the org slug).

HELD — NOT DEPLOYED. cloud origin/main is non-bootable until the zip migration
(#25 / o11y) lands; this ships with the cloud unfreeze. No dependency changes
(go.mod identical to main), so it adds no new build risk beyond the pre-existing
#25 o11y blocker. Verified: clients/functions 5/5, clients/s3 6/6, root billing
tests green; go build + vet clean on all changed packages.
2026-07-02 02:19:32 -07:00
hanzo-dev 8f73f92c93 fix(billing): key the credit-drawdown gate on the org slug, not {org}/{sub}
The prepaid credit balance is PER-ORG (one credit pool covers the whole org),
so the metering gate must query the ledger by the org slug. identityFromCtx keyed
User on "{org}/{sub}", which queries an empty per-user ledger and 402s a fully
funded org — a revenue-blocking false-decline for every real request. Key User
on the org slug (bare sub only when org is absent), mirroring
metering.IdentityFromGatewayHeaders so cloud and every product key the SAME
ledger entry. The {org}/{sub} actor identity belongs on the usage audit trail,
not the gate; metering v0.1.0 carries no Actor field, so it is omitted until the
module ships the User/Actor split.

Covered by resource_billing_test.go (per-org debit asserts user==org). Root +
functions + s3 packages: go build + vet clean, tests green.
2026-07-02 02:19:16 -07:00
hanzo-dev de09f7415e feat(billing): per-product credit-drawdown metering for functions + s3 (DRY ResourceMeter)
Wire fail-closed credit-drawdown metering into the two remaining cloud data-plane
subsystems that were free, reusing the ONE shared cloud.ResourceMeter (the same
Gate+Meter provisioning and ml already use). No free tier: pre-authorize the
org balance (insufficient -> 402, unreachable -> 503, nothing runs), then debit
on success. No second metering path.

- functions: POST /v1/functions/:name/invoke gates before sandbox compute and
  debits the caller org on a real execution (product "functions", unit
  "invoke", fee CLOUD_FUNCTION_FEE_CENTS, $1.00 default). A sandbox transport
  failure ran no billable compute -> not charged.
- s3: the data-plane guard is the ONE place it meters -- every guarded op gates
  before S3 is touched and debits on handler success (product "s3", unit
  "op", fee CLOUD_S3_FEE_CENTS). A handler error is not billed.
- resource_billing: Meter now records the billed unit as Usage.Model (kind), so
  provisioning kinds (sql/vector/kv/...), functions "invoke", s3 "op" and ml
  kinds all get per-item attribution in the ledger under their product label.
- middleware_billing: add /v1/functions/ and /v1/s3/ to selfMeteredPrefixes so
  the edge gate never double-bills the subsystem's own charge.

Tests (real commerce + sandbox doubles): functions 5/5, s3 guard 6/6 -- 402 on
empty balance with no compute run, debit-on-success to the CALLER org (never the
client default), no-bill-on-failure, free-fee ungated, unconfigured no-op,
tenant isolation. go build + vet clean; affected packages green.
2026-07-02 01:35:53 -07:00
zandGitHub 4e27f21f07 Merge pull request #58 from hanzoai/feat/saas-finance
release / build-amd64 (push) Failing after 12s
release / notify-universe (push) Skipped
feat(admin): SaaS finance dashboard — DO burn-down + revenue + margin/runway (cloud)
2026-07-02 01:00:34 -07:00
hanzo-dev 7df686438d feat(admin): GET /v1/admin/finance — SaaS profitability dashboard aggregate
Add a global-admin-only finance panel to the /v1/admin/* surface for
admin.hanzo.ai: DigitalOcean credit burn-down (our primary ~$40k credit
venue), month-to-date spend, revenue, MRR, gross margin, and runway.

- digitalocean.go: DO billing client (GET /v2/customers/my/balance +
  /billing_history). Authoritative DO sign convention (from DO's public
  OpenAPI spec): the three money fields are decimal-DOLLAR strings; a
  NEGATIVE account_balance = credit we hold, so creditRemaining =
  -account_balance (clamped at 0). dollars parsed to int64 cents at the
  edge. Token DO_API_TOKEN from env (KMSSecret); unset => {configured:false},
  never a fabricated balance.
- commerce.go: mrrCents reader — sums active/trialing subscriptions'
  monthly-normalized plan price (yearly => /12) for fleet MRR.
- finance.go: financeData shape + computeFinance, a PURE derivation
  (grossMargin = revenue - cost, marginPct, runwayDays = credit/burn or
  null, profitable). Handler fans out to DO + commerce, honest-empty on
  every unconfigured/unreachable path. Mounted under s.guard (global-admin
  only; no-principal/tenant-admin/forged => 403).
- Tests: computeFinance math (profitable + burning-faster + null-runway +
  unconfigured), DO sign/parse, MRR normalization, full-pipe aggregation
  with fake DO+commerce, honest-unconfigured-DO path; /v1/admin/finance
  added to the gate test so 403-for-non-admin is proven.

CGO_ENABLED=0 go build ./cmd/cloud: exit 0. go test ./clients/admin/: ok.
2026-07-02 00:49:14 -07:00
hanzo-dev 67f48bcc4d chore: zip v1.2.0 + zap-proto/http v0.2.0 — SSE streams over ZAP
Bump to the streaming transport so cloud's SSE endpoints (o11y traces, MCP
notifications, chunked bodies) push over ZAP as live streams, not buffered blobs.
Additive — existing buffered responses unchanged. Build + tests green.
2026-07-02 00:46:38 -07:00
zandGitHub 825ff9357c Merge pull request #57 from hanzoai/feat/v1-git
feat(git): native /v1/git layer (clone/push over go-git+VFS) + fix main build (fork.go zip import)
2026-07-01 23:59:47 -07:00
hanzo-dev e21e959456 fix(projectsvc): fork.go on zap-proto/zip — finish the zip migration
The template-fork merge (#56) left clients/projectsvc/fork.go +
fork_test.go importing github.com/hanzoai/zip while their package sibling
projectsvc.go already moved to github.com/zap-proto/zip (9872d1d), so
`go build ./cmd/cloud` failed with a Ctx/Handler type mismatch. One import
line each — one and one way, the whole package on zap-proto/zip.
2026-07-01 23:56:53 -07:00
hanzo-dev 20b684eafc feat(git): native S3-ready Git layer — /v1/git/* smart-HTTP clone/push
Adds clients/git: org+project-scoped Git hosting inside the unified cloud
binary — the "internal Gitea, native" foundation agents push code into.

Control plane (X-Org-Id tenancy, HIP-0026; optional X-Project-Id sub-scope):
  POST   /v1/git/repos        create a bare repo -> repoView (201)
  GET    /v1/git/repos        list the tenant's repos
  GET    /v1/git/repos/:name  repo detail (branches, HEAD, sizeBytes)
  DELETE /v1/git/repos/:name  delete + purge storage (204)
  GET    /v1/git/usage        per-repo + total bytes for the tenant

Smart-HTTP git protocol (real `git clone`/`git push` work natively):
  GET  /v1/git/:org/:repo/info/refs?service=git-upload-pack|git-receive-pack
  POST /v1/git/:org/:repo/git-upload-pack    (clone/fetch)
  POST /v1/git/:org/:repo/git-receive-pack   (push)

Storage: bare go-git repos on a go-billy filesystem; go-git's server
transport (plumbing/transport/server) reads/writes it for clone AND push.
MVP backs billy with osfs under {DataDir}/git/<org>/<project>/<repo>.git;
a documented TODO(vfs) seam swaps in hanzoai/vfs (S3/SeaweedFS) — vfs.FS
does not yet implement the go-billy surface go-git's dotgit requires.

Billing: every repo tracks sizeBytes, re-measured on create and after each
push; each measurement emits a meterable `git.usage org=.. repo=.. bytes=..`
log line. TODO(billing) seam for a commerce metering event.

Tenant isolation on every query (org empty -> 403; path :org must match the
authenticated tenant). Registered as subsystem "git" order 132.

go-git/v5 v5.19.1 + go-billy/v5 promoted indirect -> direct (no version bump).

Verified: CGO_ENABLED=0 go build ./cmd/cloud (exit 0); go test ./clients/git
(CRUD+isolation, info/refs advertisement, in-process go-git clone/commit/
push/re-clone round-trip, cross-tenant 403) all pass; real `git` CLI
clone/push/re-clone round-trip confirmed against a live server.
2026-07-01 23:56:53 -07:00
zandGitHub b213c5fa75 Merge pull request #56 from hanzoai/feat/template-fork
feat(fork): template → project fork (cloud)
2026-07-01 23:48:00 -07:00
hanzo-dev a2bd328142 chore: zip canonical home — hanzoai/zip -> zap-proto/zip@v1.1.0 + one-verb Listen
Move to the ZAP-family canonical framework (zap-proto/zip) and its final API:
app.Listen(cfg.ZAPListenAddr, "http://"+cfg.ListenAddr) — one verb, transport is
the address scheme (ZAP primary + HTTP extra from one call). Free MCP tool surface
rides along at /mcp over both transports. go.sum re-recorded vs the immutable proxy.
Whole tree builds; cloud/zapface/storagelock tests pass.
2026-07-01 23:45:54 -07:00
hanzo-dev 9279708dd5 feat(projectsvc): fork a gallery template into a real project
Add POST /v1/projects/fork — the ONE way to start a project from the Hanzo
starter-kit gallery in-console. The handler reads the ONE embedded templates
catalog (templates.Get; no catalog copy), maps the template's freeform
framework label to the projectsvc build-hint enum, and funnels through the SAME
createProject path POST /v1/projects uses, so slug validation, org scoping, ID
minting, and conflict handling are not duplicated.

- clients/templates: export List()/Get(slug) so projectsvc reads the catalog
  through one door; the HTTP GET handler now uses Get too (DRY).
- clients/projectsvc: extract create -> createProject (the shared internal
  create path); fork.go seeds a CreateProject from the template (name=title or
  override, slug=target or template slug, framework mapped, repo=gallery source)
  and calls createProject; org-scoped (X-Org-Id) exactly like the other routes.
- Framework mapping (mapFramework): Vite wins -> vite; Next.js -> next;
  React -> react; enum names pass through; bare HTML/* -> static.

Tests: end-to-end wire tests over the real route (template->project mapping,
org scoping/isolation, dup 409, missing-slug 400, unknown-template 404) plus a
mapFramework table pinned to the real gallery labels.
2026-07-01 23:41:50 -07:00
hanzo-dev 9872d1dbce feat: serve /v1 over ZAP — zip v0.5.0, real dual transport
zip v0.5.0 un-stubs the ZAP transport, so cloud now serves BOTH transports from
the ONE app: app.Serve(cfg.ZAPListenAddr, cfg.ListenAddr) binds ZAP (primary,
:9653 via CLOUD_ZAP_LISTEN — already in config) alongside HTTP (:8000). The log
already advertised the zap addr; now it is actually bound. Every /v1 route answers
identically over either transport (no RPC registry, routes ARE the ZAP surface).

- go.mod: hanzoai/zip v0.2.1 -> v0.5.0 (+ zap-proto/http v0.1.0 transitively).
- serve.go: app.Listen(http) -> app.Serve(zap, http).

Verified: go build ./... green (whole 54-file zip surface); go vet clean;
cloud + zapface + storagelock + admin tests pass. Prod ZAP port :9653 will be
open (was closed — the stub never bound).
2026-07-01 22:56:37 -07:00
hanzo-dev d68ce9f2b9 chore: strip casibase/casdoor — cloud is Hanzo-referential, one and one way
cloud is greenfield original (not a casibase fork); the residual casibase/casdoor
names in comments + one type + one error string were off-brand AND contradicted
that provenance (e.g. storagelock's 'casibase-derived cloud-api' lineage story).
De-branded to Hanzo-referential throughout — the {status,msg,data,data2} WIRE shape
is unchanged (console2 depends on it); it is simply OUR /v1 envelope now.

- zapface: casibaseEnvelope type -> envelope; all 'casibase /v1' -> '/v1'.
- storagelock: dropped the casibase-lineage narrative; 'casibase's XORM knob' ->
  'the storage driver knob'; classify string -> 'driverName=postgres'. SQLite is
  the only backend, full stop (no transitional-config language).
- subsystems: iam '(Casdoor)' -> '(Hanzo IAM)'.
- clients/admin: 'casibase envelope' -> '/v1 envelope' throughout.
- tests: de-branded; the integration test's simulated 'casdoor_session_id' cookie
  -> the REAL 'iam_access_token' contract (cookieTokenNames), so it's more faithful.

Verified: go build + go vet clean; storagelock/zapface/clients-admin tests pass.
2026-07-01 22:56:37 -07:00
hanzo-dev 86cb4c15f7 fix(platform,provisioning): close RED CRIT-2 residual — whitespace-collapse org injectivity
The org identifier was TrimSpace'd at both trust-boundary sites
(middleware_identity.go on claims.Owner + client X-Org-Id, and
provisioning.sanitizeOrg before hashing), so two DISTINCT IAM orgs differing
only by edge/internal/unicode whitespace ('acme' vs 'acme ' vs 'ac me' vs an
NBSP/ZWSP variant) collapsed onto ONE tenant-<slug> namespace / image ref /
bucket / DB — a cross-tenant fold (IAM org name is an unvalidated varchar, so a
fold-sibling is registrable and mints a valid token).

FIX — normalize+VALIDATE at the trust boundary, reject rather than fold:
- cloud.OrgHasUnsafeRune: refuse any org bearing a whitespace / control /
  zero-width-format (Cf) rune. fasthttp OWS-trims header values, so folding
  such an org could never round-trip through transport — rejection (fail
  secure) is the only injective option. Visible case/'.'/'-' still fold
  injectively via the org-slug hash.
- middleware_identity.go: owner is taken verbatim from the validated principal
  (no TrimSpace) and refused if unsafe -> request resolves org-less, every
  tenant() gate fails closed 403. Client X-Org-Id refused likewise.
- provisioning.sanitizeOrg: reject unsafe-rune inputs (defense-in-depth for
  non-header callers e.g. clients/s3) and hash the RAW bytes, never a trimmed
  copy. c.Org() is now the sole tenancy source and injective end-to-end.

Regression tests: {acme, 'acme ', 'ac me', NBSP/ZWSP/BOM/tab variants} ->
distinct-or-rejected, never colliding (provisioning + platform + middleware
JWT-owner path). go build ./... + go vet + affected suites green.
2026-07-01 21:40:57 -07:00
hanzo-devandGitHub df47c27b0d feat(crm): native-Go /v1/crm on Base — companies/contacts/opportunities, per-org (#55)
release / build-amd64 (push) Successful in 5m45s
release / notify-universe (push) Failing after 1s
First slice of the unified-backend-go program: a native-Go port of the Twenty
CRM core model (company/person/opportunity standard objects, composites
flattened to scalar columns) mounted at order 131 in the one cloud binary.

- Base/SQLite store ({DataDir}/crm.db), tenant isolation = org column on every
  query (c.Org() from the validated IAM owner claim, HIP-0026). Mirrors
  clients/prompts + clients/eval exactly (the ONE storage pattern).
- Full CRUD for all three entities + per-org summary counts; in-org referential
  integrity (a relation can never point across tenants; errBadRef -> 422).
- /v1/crm/{summary,companies,contacts,opportunities} — /v1 only, no /api.
- Tests: per-org isolation, CRUD round-trip, referential integrity,
  delete-clears-refs, list filters/counts, HTTP round-trip + validation. 7/7 pass.

No proxy to a NestJS backend; this is the thesis (business apps as native-Go
/v1 subsystems on Base) embodied as one working brick.
2026-07-01 20:14:44 -07:00
hanzo-dev a2de21679a fix(platform): close CRIT-1 cmd-injection, CRIT-2 org collision, MED-3 quotas (RED)
/v1/platform (PaaS) — RED do-not-ship findings. Tenancy core untouched.

CRIT-1 — OS command injection in the privileged BuildKit Job:
  launchBuildJob now emits buildctl as EXEC-FORM argv ([]string, no `sh -c`),
  so no shell parses any input. repo.url / dockerfile / git-ref are validated
  (validate.go): https-only URL to an allowlisted git host, no shell/flag
  metachars; safe relative dockerfile (no `..`); safe branch/tag/commit ref
  (no `#`, no metachars). Output image ref is forced server-side — a client
  cannot override --output/--opt. Validation runs at the build choke point AND
  early at createApp (400). red_cmdinj_poc_test.go flipped to a passing guard.

CRIT-2 — sanitizeOrg collision (non-injective) → cross-tenant takeover:
  deleted the lossy platform.sanitizeOrg; tenant()/tenantNamespace()/
  buildImageRef() now use the ONE injective provisioning.SanitizeOrg (DRY,
  reused — commit 4e77020c). Image ref made injective too: org+app are now
  separate '/'-joined path components (ghcr.io/hanzoai/tenant-<org>/<app>),
  neither slug can contain '/', so (a-b,c) vs (a,b-c) no longer collide.

MED-3 — quotas / replica bounds / shared-build DoS:
  clampReplicas caps replicas to [1,20] (env CLOUD_PLATFORM_MAX_REPLICAS) at
  createApp, applyService, and scaleService (fail-secure default). ensureNamespace
  applies a ResourceQuota + LimitRange per tenant namespace (idempotent).
  launchBuildJob caps concurrent builds per org (default 3, errTooManyBuilds→429).

Tests: cmd-injection blocked (5 vectors), org-slug + image-ref injectivity,
replica clamp (unit+HTTP), namespace quota/limitrange, concurrent-build cap.
go build ./... green; clients/platform + provisioning + s3 tests green.
2026-07-01 19:17:28 -07:00
hanzo-dev 68095cd1cf templates: read-only starter-kit gallery at /v1/templates (69 templates from hanzoai/gallery; browse + fork/deploy handoff)
release / build-amd64 (push) Successful in 6m18s
release / notify-universe (push) Failing after 2s
2026-07-01 19:13:52 -07:00
hanzo-dev 6897bc7b87 prompts: read-only starter catalog at /v1/prompts/catalog (107 prompts, browse+import; org store stays honestly empty) 2026-07-01 19:08:54 -07:00
hanzo-dev e404a5de98 fix(evals): bound /v1/evals/runs — per-org concurrency + wall-clock deadline (RED MED)
release / build-amd64 (push) Successful in 6m7s
release / notify-universe (push) Failing after 2s
A synchronous run drives up to maxRunItems paired LLM calls against the SHARED
in-process gateway. Two bounds stop one org from degrading every tenant:

- Per-org concurrency cap (maxConcurrentRunsPerOrg=4): a run acquires a per-org
  slot after passing validation; excess concurrent runs fail fast with 429 (never
  queued — queuing just relocates the exhaustion). Slot released on every return.
- Total wall-clock deadline (maxRunDuration=10m): the item loop runs under a
  context.WithTimeout; a run that exceeds it is cancelled, remaining items are
  recorded as honest errors, and the partial summary returns (Scored counts only
  real successes → 502 when nothing scored). A runaway can never pin a request +
  a gateway slot indefinitely.

Also (RED LOW, verified NOT present at 249a136e despite belief): getDataset now
sizes its collection via store.CountItems (SELECT COUNT(*)) instead of loading up
to maxListLimit full item bodies just to len() them (~96MB amplification on a
large dataset).

Tests: TestRunConcurrencyCap (semaphore fill/refuse/release), TestRunDeadline
Bounded (blocking runner + tiny deadline → cancels, 502, honest item errors, no
hang). go build ./clients/eval/ . green, go vet clean, all eval tests pass.
2026-07-01 18:58:41 -07:00
hanzo-dev e639e4853f fix(evals): HIGH — gate tenant() on validated principal + clone org key (RED)
Two cross-tenant fixes on the /v1/evals API layer (RED review):

1. Principal gate (HIGH): tenant() now requires a non-empty c.User() (X-User-Id,
   set ONLY by SanitizeIdentity from a verified token/session and stripped from
   client input). Its Phase-1 residual RESTORES a client X-Org-Id on the
   NO-principal path (bearer-less / opaque hk-/sk- key / invalid bearer), so
   without this gate a direct-to-pod request 'X-Org-Id: victim' with no auth read
   /wrote/deleted the victim org's datasets (PII golden outputs), scores, traces
   and runs. This is the same trust signal the audit layer uses (actorFromCtx).

2. Buffer-aliasing (correctness on the isolation key): c.Org() is a zero-copy
   view into the fasthttp request buffer, reused after the request ends. The org
   is our tenant KEY and is retained (telemetry events, run records); tenant()
   now strings.Clone()s it so a stored org can never silently mutate into another
   value once the buffer is recycled (was manifesting as run scores landing under
   a corrupted org id).

red_cap_test.go TestRed_ForgedHeaderCannotCrossTenant now sends a no-principal
X-Org-Id:victim request asserting 403; eval_test.go asserts tenant()='' without a
validated principal. go build ./clients/eval/ . green, go vet clean, all eval
tests pass.
2026-07-01 18:58:41 -07:00
hanzo-dev 1b584862fd feat(evals): native /v1/evals over Base+datastore, retire console proxy
Replace the Langfuse-fork proxy (crash-looping console P1012) with a native,
org-scoped evals system. Storage split per CTO directive:
- metastore (store.go): Base/SQLite, per-org config — datasets, dataset-items,
  evaluators, score-configs, dataset-run defs. Composite (org,id) keys so an id
  is never a cross-org global key (no existence oracle; two orgs may reuse ids).
- telemetry (telemetry.go): datastore/ClickHouse MergeTree, append-only traces +
  scores-as-events; every read binds org as a named param + LIMIT; behind an
  interface with an in-memory impl for tests. Reuses the Langfuse v3 CH shapes.
- runner (runner.go): pluggable EvalRunner (Complete + Judge); gateway runner is
  the spine (any model/judge, no token cap), DO can drop in as an adapter later.

Tenant isolation is c.Org() (validated bearer owner) ONLY — never client
X-Project-Id/X-Org-Id (the cross-tenant break the old proxy shipped). Score
integrity: NaN/Inf rejected, values validated against the org's score-config,
categorical labels checked against the allowed set. Content caps + name regex
guard injection/traversal/amplification.

24 tests pass: metastore + HTTP cross-tenant isolation, forged-header guard,
score-integrity, content caps, run orchestration over a stub runner.

/v1 only. TDD (go test green). gofmt+vet clean. go build ./... green.
2026-07-01 18:58:41 -07:00
hanzo-dev de4e705307 cloud(s3): drop CLOUD_ prefix on the shared S3 env family (S3_*)
One clean env family for the ONE shared S3 access path (clients/s3admin)
and the provisioning control plane. Rename across s3admin, clients/s3,
projectsvc, and provisioning — code, comments, log/error strings, tests:

  CLOUD_S3_ADMIN_ENDPOINT   -> S3_ADMIN_ENDPOINT
  CLOUD_S3_ADMIN_ACCESS_KEY -> S3_ADMIN_ACCESS_KEY
  CLOUD_S3_ADMIN_SECRET_KEY -> S3_ADMIN_SECRET_KEY
  CLOUD_S3_SECURE           -> S3_SECURE
  CLOUD_S3_REGION           -> S3_REGION
  CLOUD_S3_PUBLIC_ENDPOINT  -> S3_PUBLIC_ENDPOINT
  CLOUD_S3_PUBLIC_SECURE    -> S3_PUBLIC_SECURE

The cloud CR (universe 9e57d71d) already stamps BOTH the old and new
ACCESS_KEY/SECRET_KEY spellings from the s3-credentials secret, so this
image roll is drop-in: old names stay populated until the new image
lands, then the S3_* names take over. Everything else resolves from code
defaults (s3.hanzo.svc:9000 / us-east-1 / s3.hanzo.ai). go build + vet +
tests green across all four packages.
2026-07-01 18:40:51 -07:00
hanzo-dev 4e11bd4cd7 feat(org): per-org envelope encryption — SQLite ciphertext at rest, wired live
No TODO, no placeholder: the "full customer encryption" brick is real and wired
into the Replicator. Each org's SQLite snapshot is sealed with a distinct
AES-256-GCM key DERIVED from the KMS master via HKDF(master, label, orgID) — the
master never leaves the process, per-org keys are in-memory only. The SeaweedFS
object is ciphertext; orgs are cryptographically isolated (orgID bound as GCM
AAD, so a blob can't be replayed under another org); rotating the master re-keys
everything. Nonce is derived from (key, plaintext) so identical content seals
identically — the Replicator's version-skip keeps working under encryption.

Wired: NewReplicator(..., WithEncryption(cipher, orgID)) → Push seals, Pull opens.
Omit the option and the DB is stored in the clear (local dev only).

Pure Go stdlib (crypto/aes, crypto/cipher, crypto/hmac, crypto/sha256, crypto/subtle) —
package org stays dependency-free + testable. Tests: round-trip, wrong-master reject,
cross-org isolation, tamper detection, deterministic-per-content, master-rotation
rekey, and an end-to-end encrypted Replicator (stored bytes are ciphertext, reader
with the key restores plaintext, no plaintext leak without the key).
2026-07-01 17:57:50 -07:00
hanzo-dev 93580455df harden(platform): bind custom ingress domains to the caller's org (RED — domain hijack)
A custom domains[] entry was rendered straight into the operator Service CR
ingress.hosts, so a tenant could claim another org's host or a Hanzo apex
(api.hanzo.ai) and the operator would serve an Ingress for it. Require every
custom host to be under the caller's OWN '<org>.<sitesHost>' subtree (e.g.
maxpower may only claim *.maxpower.hanzo.app); anything else is refused 501
(verified arbitrary custom domains are phase-2 domain CRUD). Closes the
cross-tenant/apex domain-hijack vector reachable in the first slice. +2 tests
(unit + HTTP); 23 tests green.
2026-07-01 16:46:39 -07:00
hanzo-dev bba727bba8 fix(platform): require a validated principal in tenant() (RED HIGH)
/v1/platform mutates cluster state (operator Service CRs + BuildKit Jobs in
tenant-<org>) — more consequential than a data read — so trusting X-Org-Id alone
would let a direct-to-pod caller forge X-Org-Id:victim with NO bearer and
deploy/read into another tenant (SanitizeIdentity's documented Phase-1 residual).
tenant() now gates on c.User() (X-User-Id, set ONLY for a validated principal),
mirroring the Red-hardened clients/s3.tenant. Proven live: forged X-Org-Id with
no token -> 403 (was 200); real JWT -> 200; forged header + real token ->
validated owner wins. Every legitimate caller (gateway/console BFF) carries a
user-bound bearer, so no real client breaks.
2026-07-01 16:36:01 -07:00
hanzo-dev d7310bec48 fix(eval): tenant() must use the sanitized org, never client X-Project-Id (cross-tenant)
release / build-amd64 (push) Successful in 7m58s
release / notify-universe (push) Failing after 1s
RED MED-1 (cross-tenant eval-score read). eval.tenant() scopes the console API
key pair (console-pk-{org}/console-sk-{org} in KMS) and was PREFERRING the raw
`X-Project-Id` request header over the bearer-pinned org, then feeding it to
resolveKeys(). X-Project-Id is a project sub-scope WITHIN an org and is
DELIBERATELY excluded from SanitizeIdentity.authorityHeaders (client-controllable,
per middleware_identity.go). So a caller who set `X-Project-Id: victim-org` made
resolveKeys fetch ANOTHER org's console-pk/console-sk from KMS and read that org's
eval scores/datasets — a cross-tenant break. Reading a raw X-Org-Id header was
equally unsafe (SanitizeIdentity strips a client copy and re-mints c.Org() from
the token).

Fix: tenant() returns ONLY c.Org() — the org SanitizeIdentity pinned from the
validated bearer owner (HIP-0026) — the same authoritative selector
agents/prompts/provisioning use. X-Project-Id no longer influences KMS key
selection anywhere in the evals facade (it was used nowhere else). Per-PROJECT
key scoping, if ever needed, must derive the project from a membership check UNDER
c.Org(), never a raw sub-scope header (Phase-2; keys stay org-scoped today).

The console2 BFF (app/cloud) was the sole compensating control (it drops
X-Project-Id without forwardScope); this closes the defect AT THE SOURCE so
isolation no longer hangs on one omitted proxy header. New test
TestTenantIgnoresClientProjectID pins that a forged X-Project-Id never becomes the
tenant. go build ./clients/... + go test ./clients/eval/... green.
2026-07-01 16:24:12 -07:00
hanzo-dev a34d7673f9 feat(platform): native per-org /v1/platform PaaS subsystem (Dokploy port, Goa-designed)
Port the standalone Dokploy (platform.hanzo.ai) tRPC backend into the unified
cloud binary as clients/platform, mounted at /v1/platform (HIP-0106). Per-org,
IAM-validated, Base/SQLite store; the deploy path writes an operator hanzo.ai/v1
Service CR into the caller's OWN tenant-<org> namespace (derived from the
validated X-Org-Id, never a request input) and the operator reconciles it. Git
apps build via an in-cluster BuildKit Job (arcd model); image apps deploy
directly. Complements clients/paassvc (admin fleet board) and clients/projectsvc
(static sites) with the container-app PaaS.

Goa is the design-first contract (clients/platform/design, goa gen -> OpenAPI 3);
the runtime is native zip handlers (one router, behind SanitizeIdentity) — the
generated net/http server is deliberately NOT mounted so the identity trust
boundary is not routed through the fiber<->net/http adaptor.

- store.go: projects/applications/deployments/builds, org column tenancy
- k8s.go: tenant-<org> namespace derivation + Service CR apply/scale/delete + BuildKit Job
- platform.go: Mount + project/app CRUD + tenant() gate + health
- deploy.go: deploy/start/stop + deployment history/logs, fail-closed (no fabricated success)
- 20 tests: store CRUD, cross-tenant isolation (RED bar), fail-closed deploy,
  fake-cluster deploy-into-tenant-ns success, secret-env rejection

go build ./... green; go test ./clients/platform/ green.
2026-07-01 16:20:48 -07:00
hanzo-dev 4e77020c83 fix(s3,provisioning): close Red re-review findings — control-plane forge + injective org slug
release / build-amd64 (push) Successful in 7m8s
release / notify-universe (push) Failing after 5s
Red re-review (0 critical, 1 high, 1 med, 1 low): the s3 data-plane HIGH was
confirmed CLOSED, but Red found the same forge open on the provisioning control
plane (worse: destroy DB + credential exfil), proved my org-fold dispute WRONG
with a reachable cross-tenant collision, and asked to lock the fix's dependency.

- [HIGH] provisioning.tenant() now requires ctx.User() (provisioning.go:454) —
  same gate as the s3 fix. Without it, an in-cluster caller could forge
  'X-Org-Id: victim' with NO bearer and POST /v1/sql (allocate a DB in the
  victim's namespace + receive its connection string + password), DELETE
  /v1/sql/:name (destroy the victim's DB), or enumerate resources. SanitizeIdentity
  restores a forged X-Org-Id on the no-principal Phase-1 path but strips X-User-Id;
  gating on it refuses only the anonymous forge. Test:
  TestForgedOrgWithoutPrincipalRefused (forged org + no principal -> 403 across
  POST/DELETE/GET, provisioner never runs).
- [MED, dispute WITHDRAWN — Red was right] provisioning.sanitizeOrg is now
  INJECTIVE (provisioning.go:468): identity on a clean [a-z0-9-] slug, else the
  fold + '-'+16hex SHA-256(raw owner) — mirroring iam/object/orgdb.go:orgSlug.
  The old lossy fold collapsed 'Acme'/'acme' and 'team.a'/'team-a' onto one slug,
  and since the whole tenant->bucket/DB namespace hashes THAT slug, two distinct
  orgs shared one physical namespace (reachable: the IAM org name is a varchar
  with no shape validator, so a fold-sibling is registerable + mints a valid
  token). Tests: TestSanitizeOrgInjective (the exact collisions no longer collide,
  incl. derived orgHash) + updated TestSanitizeOrg.
- [LOW] locked the s3/provisioning fixes' cross-file dependency:
  TestSanitizeIdentity_AnonPathHasNoUserId asserts a client-forged X-User-Id does
  NOT survive the anon path (ctx.User()=="") while X-Org-Id does — so a future
  refactor that restored X-User-Id fails this test first.

All fold consumers (orgHash -> SQL/KV/CH/S3 physical namespaces) inherit the
injective slug through the ONE sanitizeOrg. go test green (provisioning + s3 +
s3admin + root identity); cmd/cloud builds; gofmt/vet clean; zero go.sum drift.
2026-07-01 15:40:11 -07:00
hanzo-dev 77c164ed38 fix(s3): address Red review — require validated principal, harden keys, shorten presign TTL
Red adversarial review (0 critical, 1 high, 3 med, 4 low). Fixes:

- [HIGH] tenant() now REQUIRES a validated principal (ctx.User()/X-User-Id).
  SanitizeIdentity restores the client's raw X-Org-Id on the no-principal
  'Phase-1 data path' but leaves X-User-Id empty; a pure data plane trusting
  X-Org-Id alone let an in-cluster caller (co-namespace pod) forge
  'X-Org-Id: victim' with NO bearer and get cross-tenant object CRUD. Gating on
  X-User-Id refuses ONLY that anonymous forge path — every legitimate caller
  reaches s3 through the console BFF /cloud proxy which mints a user bearer, so
  no real client breaks. Object storage never serves an unauthenticated
  principal. Test: TestForgedOrgWithoutPrincipalRefused (forged org + no
  principal -> 403 across the full route surface).
- [MED] presign TTL 15m -> 5m: bounds a minted capability's post-revocation
  lifetime (presigned URLs have no server-side revocation; the TTL IS the
  window). Documented the unwired-rate-limiter platform gap.
- [LOW] cleanKey rejects control bytes (\x00-\x1f) + backslash: a null byte
  serializes as %00 (C-string truncation risk for a downstream consumer) and
  '\' is a non-Go path separator. Tests extended.
- [LOW] friendlyBucket re-validates the recovered name against bucketNameRE:
  a prefixed-but-non-conforming bucket (only reachable out-of-band, never via
  createBucket) is treated as not-owned, so listBuckets never echoes an
  unaddressable name. Test added.
- Documented the ESCALATED residuals (not subsystem-fixable): single omnipotent
  SeaweedFS identity (isolation is app-layer only until STS/scoped creds), and
  the intentional S3-vs-KMS org-normalization divergence (S3 must fold to match
  provisioning's bucket naming; KMS keys secrets by exact owner).

Verdict was fix-then-ship; no critical, HIGH is bounded (not internet-reachable,
gateway strips X-Org-Id at the edge). go test ./clients/s3/... green (20 tests);
cmd/cloud builds; gofmt clean; zero go.sum drift.
2026-07-01 15:40:11 -07:00
hanzo-dev 15788fc876 feat(s3): native org-scoped /v1/s3 object-storage file manager
Adds the DATA plane over the shared SeaweedFS S3 gateway as /v1/s3/* on the
unified cloud binary (HIP-0106), the companion to clients/provisioning's s3
CONTROL plane. One console, one backend — no external s3.hanzo.ai UI.

- clients/s3admin: the ONE shared S3 access path. Both projectsvc/blob (deploy
  blob store) and the new s3 subsystem build their minio client here from the
  SAME CLOUD_S3_ADMIN_* creds (DRY — no second S3 client anywhere). Leaf pkg
  (minio-go only), so no import cycle. Separate public-host client mints
  presigned URLs a browser can follow.
- clients/projectsvc/blob.go: refactored to build its minio client via s3admin
  (was inline minio.New). Existing projectsvc tests unchanged + green.
- clients/s3: /v1/s3/{health,buckets,buckets/:bucket,buckets/:bucket/objects,
  buckets/:bucket/objects/*}. Org-scoped bucket-per-org via the EXACT
  provisioning.BucketName scheme (exported) = bucketName(physicalName(org,name)):
  org-hash prefixed AND '_'->'-' folded to a DNS-safe S3 name — so a bucket
  provisioned via POST /v1/s3 {name} is browsable here AND a bucket created here
  is a valid S3 name (the raw physicalName has underscores S3 rejects). Upload/
  download = presigned PUT/GET URLs (browser goes direct to S3; admin cred never
  leaves the server; object key path-clean-guarded; time-boxed 15m). Fail-closed
  503 without creds. Registered 's3svc' order 118 (< provisioning 120) so the
  static /v1/s3/buckets + /v1/s3/health win Fiber's first-match scan ahead of
  /v1/s3/:name and the generic-health route does not shadow the real probe.
- subsystems.go: one-line blank import.

Tests: go test ./clients/s3/... ./clients/s3admin/... green — fail-closed 503,
org 403, route-ordering (s3 owns /v1/s3/buckets + /health, not provisioning
:name), bucket-name + object-key traversal 400, cross-tenant physical-name
isolation, AND bucket-name consistency with provisioning + DNS-safety (no '_').
Zero go.sum drift (minio-go already a dep). cmd/cloud builds.
2026-07-01 15:40:11 -07:00
hanzo-dev fa02fe0360 chore(deps): bump ai -> 296a9e9b (ai ledger owns cloud_usage only; drop colliding observations write) 2026-07-01 15:17:04 -07:00
hanzo-dev ea2b562e53 chore(deps): bump ai -> a5a199e9 (o11y ledger reaches ClickHouse directly)
Pulls hanzoai/ai#a5a199e9: the cloud_usage/observations ledger now uses a direct
clickhouse-go/v2 client (object.InitDatastore in the shared Bootstrap) instead of
the dead ZAP 'datastore peer'. Fixes GET /v1/get-cloud-usages 'datastore peer not
connected'. Requires DATASTORE_ADDR/USER/PASSWORD env (wired on the cloud CR).
2026-07-01 14:58:40 -07:00
c10d343e9d fix(provisioning): return PUBLIC endpoints, never the internal .svc host (#53)
Tenants were shown the internal admin address (e.g. vector.hanzo.svc:6333) in
create/get/list responses + the connectionString — unusable from an app and a
leak. Add publicEndpoint(kind): HTTP kinds (vector/search/docdb/s3) → the unified
api.hanzo.ai gateway (/v1/<kind>/*); native-wire DBs → <kind>.hanzo.ai on the
native port (sql.hanzo.ai:5432, kv.hanzo.ai:6379, datastore.hanzo.ai:8123). So a
customer gets a real, routable endpoint for their app + hanzo.app. Per-kind
override via PUBLIC_<KIND>_HOST/_PORT. The DSN host:port is remapped too.

Co-authored-by: zeekay <z@zeekay.io>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:46:19 -07:00
hanzo-dev 02b7156b24 fix(prompts): self-heal from the legacy clients/prompt schema on shared prompts.db
release / build-amd64 (push) Successful in 5m47s
release / notify-universe (push) Failing after 2s
Live 1.786.5 returned 500 "no such column: id" on GET /v1/prompts. The removed
clients/prompt facade (shipped in 1.786.4) had created a `prompts` table in the
SAME {DataDir}/prompts.db with a versioned-rows layout that has NO `id` column.
clients/prompts' `CREATE TABLE IF NOT EXISTS prompts` no-ops on that existing
table, so every `SELECT id,... FROM prompts` failed against the persisted PVC.

migrate() now inspects the `prompts` table via PRAGMA table_info; when it exists
but lacks `id` (the legacy signature) it drops the legacy pair and rebuilds the
forward schema. The legacy rows carry no durable product data (the facade shipped
one release; the console Compute pages never wrote through it), so this is
forward-only and idempotent — once `id` exists it never fires again.

TDD: TestMigrateSelfHealsFromLegacyPromptSchema seeds the exact legacy schema +
a row, opens the store over the same file, and asserts List/Upsert succeed. Green.
2026-07-01 13:42:56 -07:00
hanzo-dev d378f8f2ad Merge blue/cloud-tenant-endpoints: per-org /v1/{prompts,agents,functions}
release / build-amd64 (push) Successful in 7m13s
release / notify-universe (push) Failing after 2s
Mounts the red-approved (3 rounds) per-org product control planes natively
in the unified cloud binary (the "all products in the cloud binary" thesis):

  - clients/prompts   order 126  /v1/prompts/*    versioned prompt library
  - clients/agents    order 127  /v1/agents/*     autonomous agents + runs
  - clients/functions order 128  /v1/functions/*  serverless fns + invoke

All three are org-scoped by the gateway-minted X-Org-Id (HIP-0026), fail
closed when it is absent, and persist to a per-tenant modernc.org/sqlite
store under CLOUD_DATA_DIR (no CGO). Each ships adversarial red_*_test.go
coverage (all green).

DRY reconciliation with main: main had independently added clients/prompt
(singular) also registering "prompts" for /v1/prompts. Two subsystems both
named "prompts" would double-append to cloud.Registry and double-mount the
same routes. blue's clients/prompts is the red-approved superset (adds
/v1/prompts/metrics — which console2's PromptsModule consumes — plus DELETE,
strict fail-closed X-Org-Id, and a reserved-name guard), so it becomes the
ONE owner of /v1/prompts/*; the earlier clients/prompt facade is removed.

Build green (CGO_ENABLED=0 go build ./...); prompts/agents/functions +
root + cmd/cloud tests all pass.
2026-07-01 13:16:58 -07:00
hanzo-dev 4510e307c1 feat(prompts): serve /v1/prompts from a native SQLite store (kill the console loop)
release / build-amd64 (push) Successful in 6m14s
release / notify-universe (push) Failing after 2s
The console (Langfuse image 3.159.55) 307-redirects /api/public/v2/prompts →
/v1/prompts, delegating prompts TO cloud. promptsvc used to proxy the other
way → redirect loop → 500. Port prompts NATIVE: an org-scoped, versioned
prompt registry in a single SQLite file (base pattern, modernc driver, WAL),
list/get/create. No proxy, no loop, all-SQLite by construction. Verified:
GET /v1/prompts → {data:[]}, POST creates v1, GET lists it, GET /:name
returns versions. Honest-empty when none.
2026-07-01 13:06:23 -07:00
hanzo-dev ff0a07c52a feat(kms): embed luxfi/kms in cloud (/v1/kms/*), fail-secure sealed store
HIP-0106 'all Go embeds in cloud': the KMS secrets plane runs in-process
in the cloud binary instead of the standalone Infisical fork.

- clients/kmsembed: cloud-free Client (implements types.KMSClient) over a
  luxfi/zapdb SecretStore. AES-256-GCM envelope (per-secret DEK sealed
  under a 32-byte master KEK); plaintext never touches disk. New() uses a
  fail-SECURE 3-way store-open (keyed→encrypted on-disk; no-key+no-store→
  ephemeral in-memory, no plaintext registry; no-key+existing-store→fail
  loud, never silently shadow encrypted data). Sign fails closed when no
  MPC backend is co-hosted. One place for key-shape validation.
- clients/kms: Fiber subsystem mounting /v1/kms/* (order 10), org-scoped
  CRUD via cloud's auth; /v1/kms/health + /v1/kms/config.
- build.go pickKMSClient: Enabled('kmssvc') → in-process kmsembed.New,
  fail-closed to DisabledKMS on error (never nil).
- config: CLOUD_KMS_MASTER_KEY_REF / CLOUD_KMS_MPC_ADDR / _VAULT_ID.

Reviewed blue+red: 40 tests (7 build + ~33 adversarial), gofmt/vet clean,
go.sum zero-diff. Red verdict: ship (fail-closed + confidentiality
invariants hold across the full key/store matrix).
2026-07-01 12:44:33 -07:00
hanzo-dev 7700b222f2 chore(deps): bump ai → v1.789.0 — unify iam hotfix into main
release / build-amd64 (push) Successful in 5m44s
release / notify-universe (push) Failing after 1s
Brings the released ai v1.789.0 onto main: it has BOTH the V1IamRewriteFilter
(the /v1/iam/* account-surface fix shipped off-main as the 1.785.35 hotfix) AND
the canonical X-Project-Id/X-Environment/X-User-Id header sweep. Resolves the
divergence — main is now the one lineage (prompts + bot + headers + org refactor
+ SeaweedFS replication + iam fix). Build + embed/identity tests green.
2026-07-01 12:24:46 -07:00
hanzo-dev db55bd428e refactor(org): tenant IS the organization — per-org writer pinning + SeaweedFS replication
Drop the confusing "tenant" concept: in Hanzo the ORGANIZATION is the tenant
boundary (identity, billing, per-org SQLite are all org-scoped). Renamed
internal/tenant → internal/org and made ownership explicitly PER-ORG: one replica
writes ALL of an org's databases (root + per-project + per-user), for locality +
intra-org consistency; the org moves as a unit on failover.

- owner.go: Owner/IsOwner/Replicas over Rendezvous (HRW) hashing of orgID — every
  replica computes the same writer-owner from the same membership, NO coordinator.
- membership.go: live replica set via a pluggable Source (StaticSource/CLOUD_REPLICAS
  now; K8s Endpoints / zapd gossip later); lock-free AmOwner hot-path.
- replica.go: Replicator Push (owner → SeaweedFS) / Pull (reader ← SeaweedFS,
  version-skip) + DBPath (orgs/<org>[/<scope>]/<service>.db, HIP-0302).
- vfsstore.go: object store bound to hanzoai/vfs (SeaweedFS) — NO minio, NO external
  S3 SDK. hanzoai/sqlite + hanzoai/base back the local DB handle (the DB interface).

Package is PURE stdlib (local vfsClient interface, zero cloud deps) so it builds +
tests without the dep tree. 16 tests pass: determinism, single-owner, even
distribution (±35%/50k), exact minimal-reshuffle, ordered failover, push/pull
round-trip, skip-unchanged, ownership handover, DBPath layout, vfs adapter. gofmt clean.
2026-07-01 11:07:23 -07:00
hanzo-dev f8b0832b40 feat(bot): mount /v1/bot/* → bot-gateway; name datastore/docdb by the primitive
release / build-amd64 (push) Successful in 7m42s
release / notify-universe (push) Failing after 2s
- botsvc (order 143): reverse-proxies /v1/bot/* to the in-cluster bot-gateway,
  stripping the /v1/bot prefix (the gateway serves bare paths: /v1/bot/health →
  bot-gateway /health) and forwarding the gateway-minted identity headers. The
  console2 Bot module's /v1/bot/health probe now resolves instead of 404.
  Verified e2e against the real bot-gateway: /v1/bot/health → 200
  {"service":"bot","status":"ok"}.
- provisioner: rename the datastore/docdb provisioners by the HANZO PRIMITIVE
  (datastoreProvisioner/newDatastore, docdbProvisioner/newDocdb) not the backing
  tech — the ClickHouse/MongoDB driver imports + wire-protocol schemes stay
  (functionally required), but the type names read as the primitive.

Note: /v1/s3, /v1/datastore, /v1/docdb are ALREADY mounted (provisioning loops
its 7 kinds); /v1/memory is served by the ai monolith. functions has no backend
— left honest (not fabricated).
2026-07-01 10:49:09 -07:00
hanzo-dev 6bd548c888 feat(prompts): mount /v1/prompts in the unified binary (console Langfuse facade)
The console2 Prompts module hit GET /v1/prompts → 404 "not routed on this
host" because no subsystem owned the route. Add promptsvc: a thin facade
(order 144, mirrors evalsvc) proxying list / get-by-name / create to the
console public prompts API (/api/public/v2/prompts) under the project-scoped
console key pair (HTTP Basic) — the same console + auth the eval facade
already composes. No prompt logic reimplemented; the console owns storage,
versioning, and labels.

Verified locally: the binary boots with "prompts surface mounted", GET
/v1/prompts routes to promptsvc (503 honest "no console API key" in the
keyless test env, not a 404), /v1/prompts/health → 200 (no route shadow).
In prod the console-keys secret is already wired (evalsvc), so prompts
resolve real data. eval tests + embed tests stay green.
2026-07-01 10:41:25 -07:00
hanzo-dev 384a4e29ed feat(tenant): rendezvous-hash owner — coordination-free per-tenant writer pinning
The load-bearing primitive of the horizontally-scalable OSS cloud (Hanzo V8).
Every replica of the unified binary computes the SAME writer-owner for a tenant's
per-tenant SQLite from the SAME membership set — via Rendezvous (HRW) hashing —
so there is NO election, NO lock service, NO discovery. This removes the whole
"who finds/owns/reaches service X" plumbing class for tenant state:

- Owner(tenant, members): deterministic, order-independent, exactly one owner.
- IsOwner(tenant, self, members): the per-write hot-path check.
- Replicas(tenant, members, n): owner + ordered failover successors (pre-warm S3).

HRW gives minimal reshuffle on membership change (only a departed replica's ~1/N
tenants migrate; the rest stay put) — cheap rolling deploys + scale-out. Pure Go
(crypto/sha256, no deps). 6 tests: determinism, single-owner, even distribution
(±35%/50k), minimal-reshuffle (exact), ordered failover. All pass; gofmt clean.

Wires into: owner holds the SQLite WAL (1 writer + N readers), streams WAL to
SeaweedFS/S3 (HIP-0107); non-owners read the S3 copy or forward strong writes.
Per-tenant envelope encryption (DEK wrapped by KMS master) makes the S3 file
ciphertext — tenants crypto-isolated. Membership feeds from K8s Endpoints / zapd.
2026-07-01 10:25:02 -07:00
hanzo-dev 2c22cffc83 chore(deps): bump ai → X-User-Id canonical identity header (drop X-IAM-*)
Pulls hanzoai/ai 56a55d1c so the unified binary's monolith reads the
canonical X-User-Id (cloud middleware_identity already injects it) instead
of the never-sent X-IAM-User-Id. Completes the X-IAM-* → canonical sweep
(org/project/env/user) in the compiled-in monolith. Builds + tests pass.
2026-07-01 10:16:18 -07:00
hanzo-dev 30fb36df44 chore(deps): bump ai → project/env canonical-header fix (X-Project-Id/X-Environment)
Pulls hanzoai/ai 7aab19aa into the unified binary so the monolith's
tenant-context filter reads the canonical X-Project-Id / X-Environment
(was X-IAM-*, never sent → project+env scoping was empty across ~200 /v1
routes) + the CORS allow-list accepts them. Pseudo-version pending the ai
v1.788.1 release tag; re-pin to the clean tag on CI cut. Full binary
builds, embed + identity tests pass.
2026-07-01 10:11:56 -07:00
hanzo-dev e1335439e3 fix(tenancy): read canonical X-Project-Id for project scope (was X-IAM-Project-Id)
evalsvc.tenant() read `X-IAM-Project-Id`, which nothing sends — console2
stamps the canonical `X-Project-Id` — so a selected project always fell
through to org-level and project selection scoped ZERO backend calls.
Switch the reader to `X-Project-Id` (one canonical header, per the
one-way rule); resolveKeys already accepts the project slug. Update the
identity-sanitizer comment to name the canonical sub-scope.

Build green, eval + identity tests pass.
2026-07-01 09:51:39 -07:00
hanzo-dev 7c6ad4f395 refactor(console): one console path, no build tags — delete dead clients/console
The repo carried TWO console embeds from parallel work: webui.go (wired in
Serve via mountConsole, no build tag — the working one) and clients/console
(a second take gated by //go:build cloud, imported by nobody after the earlier
build fix). The tag hid it from a normal build and the stale bundle doc comment
made it look like the whole binary needed -tags cloud. It never did.

- Delete clients/console/ — dead duplicate (unimported, tag-excluded, would
  double-mount "/"). Zero //go:build cloud tags remain in the repo.
- subsystems.go: correct the doc comment — subsystems register unconditionally;
  plain `go build ./cmd/cloud` (no tags) links and mounts the full set. Drop the
  now-stale clients/console note.

One console path (webui.go), builds normally. Verified: go build ./cmd/cloud
(no tags) → 427MB binary, go vet clean.
2026-07-01 09:50:02 -07:00
hanzo-dev b9efcf17bf fix(deps): restore 5 drifted luxfi go.sum h1 hashes (canonical sum.golang.org)
release / build-amd64 (push) Successful in 7m18s
release / notify-universe (push) Failing after 2s
The audit-trail commit 587952be rewrote 5 luxfi h1: module-zip hashes
(age, keys, pq, precompile, zap) to non-canonical values recorded under a
local GOPRIVATE/GONOSUMDB env, breaking 'go mod download' in the release
Docker build (checksum-DB SECURITY ERROR). go.mod is byte-identical to the
last-green build fbb76912, so restore its go.sum. All 5 now match
sum.golang.org. No code change.
2026-07-01 09:23:05 -07:00
hanzo-devandGitHub 587952bec7 feat(cloud): compliance-grade audit trail — tamper-evident, append-only, live (v1.785.34) (#51)
* fix(deps): reconcile 5 drifted luxfi go.sum hashes (age keys precompile pq zap)

These 5 luxfi modules were re-published under the same version tags (monorepo
re-tag); every local + CI module cache and the proxy agree on the new zip
hashes while the committed go.sum still pinned the old ones, so ALL builds fail
with a checksum-mismatch SECURITY ERROR. Reconcile go.sum to the hashes every
source agrees on (what go mod tidy would write). GONOSUMDB already trusts luxfi
(fetch direct). Pre-existing drift, orthogonal to the audit feature; needed to
build.

* feat(cloud): compliance-grade audit trail — tamper-evident, append-only, live

FedRAMP AU-* / SOC 2 CC-* audit control for the unified cloud binary. Every
security-relevant request against this binary is captured as a structured,
hash-chained record in an append-only store the app can only INSERT into, and
queryable through the global-admin-gated /v1/admin/audit surface.

WHAT

- audit/ package (record + chain + store + redact + query/verify), no route
  knowledge, pure security logic:
  * record.go — the AU-3 event model (actor/action/resource/auth/outcome/
    source-ip/ua/request-id/before-after) + the hash-chain math:
    hash = SHA256(canonical(record, hash+prevhash zeroed) || prevHash),
    genesis-anchored, DRY (add a field → covered by the hash automatically).
  * store.go — a single serialized Recorder (mutex + SQLite MaxOpenConns(1))
    owning the chain head; INSERT-only SQLite primary ({DataDir}/audit.db,
    zero-loss, synchronous) + optional best-effort ClickHouse mirror. Restart
    recovers the head so the chain continues (never forks).
  * query.go — filtered Query (parameterized; org/actor/action/resource/result/
    time) + Verify (walks the chain, recomputes every hash, reports the exact
    seq where a tamper/delete/reorder first breaks it).
  * redact.go — secret-key denylist (deny-by-key-name, recursive, fail-closed)
    for the before/after an explicit emit point supplies.

- audit_middleware.go (cloud pkg) — the ONE place every security-relevant
  request is recorded (decomplected: one predicate, every route). Sits AFTER
  SanitizeIdentity (validated, unforgeable actor/isAdmin) and BEFORE BillingGate
  (so billing 402/503 + admin 403 denials are audited too). Captures METADATA
  ONLY — never request/response bodies — so a secret in a body can't leak.
  Records mutations + all /v1/admin/* + all 401/403. Fails the request CLOSED
  (503) if the trail write fails (AU-5). Resolves the effective status from a
  returned *zip.HTTPError so error-returning denials are audited.

- audit_mirror.go — ClickHouse MergeTree OLAP mirror (insert-only by engine),
  best-effort projection for fleet retention/query. Driver already in go.mod.

- clients/admin/audit.go — rewires GET /v1/admin/audit to cloud's REAL store
  (was an IAM get-records proxy; kept as a federated fallback) + adds
  GET /v1/admin/audit/verify. Both behind the existing global-admin s.guard.

TESTS (all real, on-disk SQLite, no mocks)
- audit/: chain seals+links, verify passes clean, DETECTS field-tamper /
  deletion / reorder (out-of-band UPDATE/DELETE on a 2nd connection), restart
  continues the chain, concurrent appends stay gapless+verified (-race), redact
  strips secrets + fails closed, SQL-injection filter is inert.
- cloud/: middleware records a mutation with validated identity, audits a 403
  denial, SKIPS safe reads, audits admin reads, NEVER captures a secret-bearing
  body, no-op when unconfigured, fails closed on write error.
- clients/admin/: /v1/admin/audit returns real records + integrity summary,
  filters, verify endpoint, 403 without global-admin (no data leak), nil-store
  fallback.

THREAT MODEL
- Forge actor/admin: impossible at request level (SanitizeIdentity strips
  X-User-IsAdmin, actor from validated JWT).
- Forge the chain: an out-of-band edit re-hashes differently; keeping the chain
  valid requires recomputing the whole suffix — bounded by an externally-pinned
  head (Head()) for AU-9 (tail-truncation detection). Documented.
- Skip the middleware: mounted at the compose root before MountAll; the /zap
  plane replays through the same Fiber app (all middleware), so no bypass.
- Fail-closed is POST-RESPONSE: prevention is the AC layer (runs before the
  action); the trail is detection/accountability. Documented precisely.

Store is a compliance control: empty DataDir is a hard boot error unless
CLOUD_AUDIT_DISABLED=true (explicit opt-out). Secrets from env/KMS only; no
plaintext credential ever enters a record.

* harden(audit): scrub credential-shaped path segments + expand redaction denylist

Defense-in-depth from self-review before adversarial handoff:
- scrubCredentialSegments/scrubToken: a token that ever rides in a URL PATH
  (an hk-/sk-/pk-/fw_/hz_ key, reusing isAPIKey) is replaced with a marker in
  both Record.Path and resource.ID, so a secret in the path is never recorded
  verbatim. Normal identifiers (:name/:slug/:id/uuid/numeric) pass through.
  Proven by TestAudit_ScrubsCredentialInPath.
- Redaction denylist gains passphrase, privkey, social_security, phrase (covers
  seedPhrase/recoveryPhrase) — closing the key-name gaps found by enumerating
  real credential field names. TestRedact_StripsSecrets now asserts them.

* harden(audit): close raw-secret leak in path/resource-id/user-agent

Self-review PoC found a real residual leak beyond prefixed keys: a raw
high-entropy secret (64-hex, or a JWT) in the URL path — and a bearer/key in
the client User-Agent — were recorded verbatim (isAPIKey only matched
hk-/sk-/pk-/fw_/hz_ prefixes). Closed:
- scrubToken now also catches JWTs (eyJ + two dots) and long unbroken
  high-entropy alphanumeric runs (>=32, mixed, no separators) — a raw API
  key/hex secret. UUIDs (hyphens), slugs, names, emails, numeric ids pass
  through (TestScrubToken_NoFalsePositives).
- scrubFreeText scrubs credential-shaped words from the User-Agent (splits on
  space/=/;/,) and caps length at 512. Normal UA prefix preserved.
Proven: TestAudit_ScrubsCredentialInPath (prefixed+raw-hex+JWT),
TestAudit_ScrubsSecretInUserAgent. Query-string secrets already safe (c.Path()
excludes the query string).

* fix(audit): close audit-evasion via /health suffix on mutations (SECURITY)

Self-review PoC found a real evasion: isSecurityRelevant skipped ANY path
ending in /health, so a mutating POST /v1/admin/orgs/x/health (wildcard route
or an attacker-named segment) slipped past the audit trail entirely — the
worst class of bug for a compliance control (silent bypass).

Fix: check the unconditional security signals FIRST and without exception — a
401/403 denial, any /v1/admin/* call, and any POST/PUT/PATCH/DELETE are ALWAYS
audited whatever the path. Only after that is a safe read dropped (all safe
reads, incl. liveness probes, are request-log noise → not recorded). The
suffix-based /health exemption is gone; it can no longer suppress a mutation.
Proven by TestAudit_HealthSuffixCannotEvadeAudit (POST .../health IS audited;
GET /v1/kms/health is not) and the unchanged TestAudit_SkipsSafeReads.

* harden(audit): no false-attribution — anonymous request records no org/sub

Self-review: SanitizeIdentity's Phase-1 residual restores a client-supplied
X-Org-Id for the data path, so an UNAUTHENTICATED attacker sending
X-Org-Id: victim-org could stamp an audit event with a victim's org (false
attribution), even though X-User-Id/IsAdmin are correctly stripped.

Fix: actorFromCtx gates the recorded actor on a VALIDATED principal — a
non-empty c.User() (X-User-Id, which SanitizeIdentity sets only from a verified
JWT). With no validated sub (anonymous, or an invalid/garbage bearer that failed
validation), the actor is recorded EMPTY: the event stands as an honest
anonymous mutation identified by SourceIP, never mis-attributed to a claimed
org. With a validated sub, org/sub/email are authoritative.
Proven by TestAudit_AnonRequestNotAttributedToForgedOrg (runs the real
SanitizeIdentity ahead of AuditTrail).

* fix(audit): close 4 scrub-bypass classes from Red review (MEDIUM)

Red's adversarial review found the path/UA credential scrub (5dfdf0e4) had
4 bypass classes that let a secret reach the immutable trail:
  1. base64url with -/_  (looksLikeHighEntropyToken rejected any non-alnum)
  2. all-alpha opaque >=len  (required digits>0)
  3. percent-encoded prefix  (hk%2D… defeated the isAPIKey match)
  4. UA glued by :/()[]  (scrubFreeText split on too few delimiters)

Fixes:
- looksLikeHighEntropyToken now accepts the FULL base64url alphabet
  [A-Za-z0-9_-] (RFC 4648 §5), drops the digit requirement, exempts dotted
  values + canonical UUIDs, threshold lowered to 24 (128-bit base64 / 24-hex).
- scrubToken percent-decodes before every credential test (url.PathUnescape),
  so %2D/%5F can't hide structure.
- scrubFreeText tokenizes on a broad delimiter superset (= ; , : / \ ( ) [ ]
  { } " ' < > | & ?) and rebuilds in one pass preserving delimiters —
  replacing the fragile strings.ReplaceAll.

Proven by TestScrubToken_RedReviewBypassClasses (all 4 classes + UA glue) and
the expanded TestScrubToken_NoFalsePositives (uuids, model names like
claude-opus-4-20250514/text-embedding-3-large, slugs, normal UAs unchanged).

* feat(audit): operationalize AU-9 tail-truncation anchor (Red LOW #2)

Red: the Head() pin is inert unless operationalized — a hash chain can't detect
that the last K records were deleted (the surviving prefix self-verifies); only
an independent, durable head-digest series catches the count regression.

Adds a checkpoint emitter to the Recorder:
- StartCheckpoints(interval, logFn): a periodic goroutine emits the head digest
  {count, head, ts} to the append-only observability log (o11y) every interval
  (CLOUD_AUDIT_CHECKPOINT_INTERVAL, default 5m), plus a FINAL checkpoint on
  Close so the shutdown head is anchored.
- CheckpointSink: when the ClickHouse mirror implements it, the digest is ALSO
  persisted to an INDEPENDENT audit_log_checkpoints table (MergeTree) — so
  truncating the local SQLite chain cannot rewrite the anchor history.
- Detection (compare consecutive checkpoints, alert on count regression) lives
  in o11y where alert rules belong; the binary emits the tamper-evident anchor
  to an independent sink. /v1/admin/audit/verify already returns (count,head) as
  the pollable anchor too.

Proven: TestCheckpoint_EmitsHeadDigest (log + independent sink get count=7 head
on Close), TestCheckpoint_CountMonotonicDetectsTruncation (delete tail → prefix
still self-verifies, but count regresses 10→6 = the o11y alert signal).
Race-clean.

* harden(audit): close dotted-exemption + standard-b64 + nested-encoding scrub gaps

Self re-review (pre-empting Red's scoped re-review) found the round-1 scrub fix
still had gaps: the dotted-exemption let a raw secret bypass by appending '.x',
standard-base64 tokens (with +/) slipped, and nested percent-encoding (%252D)
survived a single decode.

Rewrote looksLikeHighEntropyToken to SCAN for the longest UNBROKEN base64-ish
run (>=24) anywhere in the value, over BOTH url-safe (-/_) and standard (+//)
alphabets — so '<rawsecret>.x' still trips (pre-dot run >= 24) and a standard-
base64 secret is caught. UUIDs stay exempt; real slugs/model-names/filenames
(report.pdf, text-embedding-3-large) have no 24-char run so they pass.
percentDecode now iterates (bounded x3) to normalize nested encodings.

All bypass classes closed (Red's 4 + dotted/standard/double-encoded), zero
false positives — TestScrubToken_RedReviewBypassClasses + NoFalsePositives
extended. The 20-char short-secret is a deliberate non-match (lowering below 24
would over-scrub legit hex-ish ids).

* fix(audit): address Red re-review — model-id over-scrub, UA glue, checkpoint (2 MED + 1 LOW)

Red re-review of the scrub/checkpoint code found 2 MEDIUM + 1 LOW:

MEDIUM 1 — model-id over-scrub (AU-3 regression): the round-3 run-scanner
counted '-' as a token char, so hyphenated model ids (claude-3-5-sonnet-
20241022, 26-char run) were redacted on audited routes (PATCH/DELETE
/v1/ml/models/:name, /v1/admin/catalog/models/*) — an auditor lost WHICH model
changed. Fix: isHighEntropyRunChar EXCLUDES '-' (kept +/_ for base64). Model
ids break into short runs (max ~9, far under 24); real secrets stay unbroken
>=24 runs — even a url-safe token using '-' as a separator has a >=24 run on one
side (AbCdEf-GhIjKl_MnOpQrStUvWxYz012345 -> 27). 6 model ids added to
TestScrubToken_NoFalsePositives.

MEDIUM 2 — UA free-text bypass: isFreeTextDelimiter omitted . @ # ~, so a
prefixed key glued by them (client@sk-live-KEY) stayed one token whose prefix
was no longer sk-/hk-. Fix: add . @ # ~ to the delimiter set; also flag a lone
eyJ-prefixed JWT header segment regardless of length (a JWT header is never a
legit id). Real UA dots are version separators (<24, safe). Fixed the stale
isFreeTextDelimiter docstring that referenced a nonexistent exemption. Proven by
4 glue-char probes in TestScrubToken_RedReviewBypassClasses.

LOW — checkpoint robustness: (a) StartCheckpoints now guards double-start with a
 flag (the field/WaitGroup write was -race-flagged on a 2nd call) and
the docstring is corrected; (b) the on-Close final checkpoint to the independent
sink is now SYNCHRONOUS with a bounded 5s ctx (was fire-and-forget — the AU-9
independent anchor could be stale exactly at shutdown when an attacker truncates).
Proven by TestCheckpoint_DoubleStartIsSafe (-race) + TestCheckpoint_CloseSyncsToSink.

34 tests green, race-clean, -tags cloud.

* harden(audit): structured-id exemption beats hyphen-exclusion (11%->0.09% token bypass)

The prior fix (exclude '-' from the entropy run to protect model ids) opened an
~11% bypass for 32-byte url-safe-base64 secrets whose '-' happened to break
every 24-run (measured over 10k random tokens). Excluding '-' was too blunt.

Better construction: INCLUDE '-' in the run alphabet again (so a base64url token
embedding '-' is caught by its run), but exempt STRUCTURED IDs up-front via
isStructuredID — a value with >=3 hyphen groups where every part is <=12 chars
(dictionary words / short numbers: claude-3-5-sonnet-20241022). A raw secret
does not decompose that way. Measured: 0 model over-scrub, 0.09% residual on
32-byte base64 tokens that randomly resemble an id AND are prefixless AND sit in
a URL path (real keys carry hk-/sk- prefixes caught by isAPIKey; JWTs by
looksLikeJWT). An interior-hyphen raw secret with LONG parts still redacts.

Proven: TestScrubToken_NoFalsePositives (12 model ids/slugs pass) +
TestScrubToken_RedReviewBypassClasses (interior-hyphen long-part secret redacts).
34 tests green, -tags cloud.

* fix(audit): lexical structured-id test closes Red MEDIUM + guard started race (LOW)

Red final re-review: isStructuredID was SHAPE-only (>=3 hyphen groups, parts
<=12) — attacker-satisfiable. A secret chunked to that shape
(AbCdEfGhIjKl-MnOpQrStUvWx-YzAbCdEfGhIj, or deadbeef-cafebabe-01234567-89abcdef)
was exempted; ~2.15% of random 128-bit tokens leaked by chance. Entropy-count
alone can't separate them (deepseek-r1-distill-qwen-32b has 24 non-hyphen chars,
same as a 128-bit secret).

Fix: isStructuredID now requires every group to be WORD-LIKE (isWordLikeGroup) —
lexical content, not shape. A group is rejected if it is dense MIXED-CASE (base64
chunk) or a long ALL-HEX-WITH-LETTERS run >=8 (hex chunk like deadbeef); an
all-digit version date (20241022) stays word-like. Measured: 0 model over-scrub,
0 crafted-attacker bypass, natural random-token leak 0.0004% (128-bit) / 0%
(192-bit+) — down from 2.15%. Real keys (hk-/sk- prefix) and JWTs are caught
regardless.

LOW: StartCheckpoints' started check-and-set now under r.mu (was -race-dirty on
a concurrent 2nd call; prod-unreachable but now clean).

Proven: TestScrubToken_RedReviewBypassClasses (4 chunked-secret classes redact) +
TestScrubToken_NoFalsePositives (17 model ids/slugs pass). 34 tests green, -race,
-tags cloud.

* harden(audit): two-stage detection + document single-case-chunk residual bound

Restructured looksLikeHighEntropyToken into two stages after tracing the
fundamental limit Red is probing:
- Stage 1 (UNCONDITIONAL): a >=24 UNBROKEN run over [A-Za-z0-9_+/] (hasHighEntropyRun,
  '-' and '.' are separators). Catches every raw secret WITHOUT internal separators
  (hex, base64) at 100% — the realistic 'client bug put a raw key in the URL' case.
  Never over-scrubs a hyphenated id (runs are short).
- Stage 2 (separated values): exempt a lexical structured-id (isStructuredID: >=3
  word-like hyphen groups); otherwise redact. Catches mixed-case/hex chunks.

ACCEPTED RESIDUAL BOUND (documented in code): a secret deliberately chunked into
>=3 single-case-ALPHABETIC groups <=12 chars is lexically indistinguishable from
a hyphenated model id (deepseek-r1-distill-qwen-32b carries the SAME 24-char
entropy budget) — NOT closable by any length/case/count rule without a word
dictionary (over-engineering for a defense-in-depth URL/UA backstop). It does not
widen exposure for any REAL credential: Hanzo keys are prefixed (isAPIKey, any
length), JWTs are eyJ-prefixed (looksLikeJWT), bodies are never read. The residual
is an adversary chunk-encoding their OWN secret into a URL to seed an admin-only
audit row — contrived, low-value. The realistic accidental leak (unbroken raw key)
is caught by stage 1.

Removed dead isHighEntropyRunChar. 34 tests green, -race, -tags cloud.

* feat(paassvc): native in-process PaaS deploy control plane (/v1/paas/*)

Port the standalone Dokploy platform's observe + deploy halves into the unified
cloud binary as clients/paassvc — the 'one and only one way to deploy' made
native. Follows the clients/ml pattern exactly: a self-contained dynamic k8s
client, cloud.Register'd from init() (order 128), global-admin-gated, fail-closed
when no cluster.

Surface (global-admin only; user-facing view lives in console2):
  GET  /v1/paas/apps             fleet drift board (declared/running/latest/drift+health)
  GET  /v1/paas/apps/:app        one service row by CR name (main->test->dev)
  POST /v1/paas/apps/:app/deploy deploy a tag by merge-patching Service CR .spec.image
  GET  /v1/paas/health           real k8s reachability + Service CRD probe

- drift.go: 1:1 port of apps-drift.ts (computeDrift/isSemverTag + 6 DriftKinds,
  identical severities). Pure, zero IO.
- paas.go: observeFleet lists hanzo.ai/v1 services across hanzo/-testnet/-devnet
  (inventory.ts DEFAULT_TARGETS), joins the live Deployment for the running tag
  (the operator Service CR status does NOT surface the running image — confirmed
  against the live CRD), health/phase/endpoints from the reconciled CR status.
  deploy merge-patches .spec.image (deploy-executor.ts parity) -> operator rolls it.
- Stateless: reads the cluster live (CRs are the source of truth); no apps-table
  copy, no cron readers (dropped vs the Node platform).

Tested green:
- 30+ unit cases incl. the 9 drift-contract cases ported verbatim; go test green,
  gofmt clean, go vet clean, full cloud binary builds (-tags cloud).
- Live-cluster probe (paasintegration tag, PAAS_IT-gated): observeFleet returned
  82 real rows matching kubectl; an idempotent same-image patch on pricing
  round-tripped through the operator with generation unchanged (6->6) = write
  path proven WITHOUT triggering a rollout, zero disturbance to live state.

Design + full port map: universe/docs/architecture/paas-in-cloud.md.
RBAC (cloud-paassvc -> cloud-api SA): universe infra/k8s/cloud/paassvc-rbac.yaml.

Additive: platform.hanzo.ai stays as the internal-admin console; this is the
native backend + (next) the console2 user UI. No forced retirement.

* polish(audit): close hex-chunk residual (hex rule 8->4) + correct residual doc

Red final review (SHIP verdict) flagged 2 non-blocking polish items:
1. Lower isWordLikeGroup hex rule from len>=8 to len>=4 — Red verified across 19
   real model ids that NONE has an all-hex-with-letters group of len>=4, so this
   closes the small-hex-chunk leak (md5/sha in 'xxxx-xxxx' display grouping:
   abcd-ef01-2345-6789-…) at 100% with ZERO model-id over-scrub. hexChunkMinLen=4.
2. Correct the residual doc: the accepted bound is now ONLY single-case-ALPHABETIC
   base32 chunks (lowercase/uppercase-only, no hex-letter runs >=4) — mixed-case
   base64 AND all hex-chunk sizes are now caught. The remaining case is genuinely
   unclosable without a word dictionary and exposes no real credential.

Proven: TestScrubToken_RedReviewBypassClasses now includes 4-char hex groups
(abcd-ef01-…) which redact; TestScrubToken_NoFalsePositives (19 model ids) still
pass. 36 tests green, -race, -tags cloud. Red re-review: none needed.
2026-07-01 07:22:39 -07:00
hanzo-devandGitHub fbb76912eb feat(paassvc): native in-process PaaS deploy control plane (/v1/paas/*) (#52)
Port the standalone Dokploy platform's observe + deploy halves into the unified
cloud binary as clients/paassvc — the 'one and only one way to deploy' made
native. Follows the clients/ml pattern exactly: a self-contained dynamic k8s
client, cloud.Register'd from init() (order 128), global-admin-gated, fail-closed
when no cluster.

Surface (global-admin only; user-facing view lives in console2):
  GET  /v1/paas/apps             fleet drift board (declared/running/latest/drift+health)
  GET  /v1/paas/apps/:app        one service row by CR name (main->test->dev)
  POST /v1/paas/apps/:app/deploy deploy a tag by merge-patching Service CR .spec.image
  GET  /v1/paas/health           real k8s reachability + Service CRD probe

- drift.go: 1:1 port of apps-drift.ts (computeDrift/isSemverTag + 6 DriftKinds,
  identical severities). Pure, zero IO.
- paas.go: observeFleet lists hanzo.ai/v1 services across hanzo/-testnet/-devnet
  (inventory.ts DEFAULT_TARGETS), joins the live Deployment for the running tag
  (the operator Service CR status does NOT surface the running image — confirmed
  against the live CRD), health/phase/endpoints from the reconciled CR status.
  deploy merge-patches .spec.image (deploy-executor.ts parity) -> operator rolls it.
- Stateless: reads the cluster live (CRs are the source of truth); no apps-table
  copy, no cron readers (dropped vs the Node platform).

Tested green:
- 30+ unit cases incl. the 9 drift-contract cases ported verbatim; go test green,
  gofmt clean, go vet clean, full cloud binary builds (-tags cloud).
- Live-cluster probe (paasintegration tag, PAAS_IT-gated): observeFleet returned
  82 real rows matching kubectl; an idempotent same-image patch on pricing
  round-tripped through the operator with generation unchanged (6->6) = write
  path proven WITHOUT triggering a rollout, zero disturbance to live state.

Design + full port map: universe/docs/architecture/paas-in-cloud.md.
RBAC (cloud-paassvc -> cloud-api SA): universe infra/k8s/cloud/paassvc-rbac.yaml.

Additive: platform.hanzo.ai stays as the internal-admin console; this is the
native backend + (next) the console2 user UI. No forced retirement.
2026-07-01 07:19:32 -07:00
hanzo-dev 3ed7e6d619 fix(cloud): address Red review — exact tenant keying + content caps
Red HIGH-1 (cross-tenant CRUD, PROVEN): the isolation key used a lossy
sanitizeOrg (lowercase/punct->'-'/32-char truncate) so distinct IAM owners
(acme/ACME/acme!/32-char-prefix) collapsed into one storage bucket. Fixed:
tenant() now keys on the EXACT validated org from SanitizeIdentity — never
normalized. Removed the magic 'admin' bucket (empty org -> 403, even for
admins). sanitizeOrg deleted from the key path (kept only as a cosmetic,
clearly-labeled namespace normalizer in functions).

Red MED-1 (46MB response amplification): prompt content now capped at 64KiB;
version history is bounded, metadata-only (no per-version content echo);
metrics uses a true COUNT.

Red INFO: >900s timeout now clamps to 900 (was reset to 30) in create+invoke.

Red's three adversarial tests INVERTED into regression guards that assert
isolation HOLDS: TestRed_OrgKeyExactIsolation (8 collision classes, all
isolated), TestRed_NoAdminBucketConfusion, TestRed_PromptContentCapped.
All suites green under -race; fused subsystems graph links.
2026-07-01 07:12:33 -07:00
hanzo-dev da80e34781 fix(build): main was broken — clients/console import excluded by //go:build cloud
Commit 7bd5c77 added clients/console (a second, //go:build cloud-gated take on
the go:embed console) and imported it unconditionally in the subsystems bundle.
The default build (go build ./cmd/cloud, no -tags) excludes those files, so the
whole binary failed: 'build constraints exclude all Go files in clients/console'
— which also fails the CI image build, blocking every deploy.

The working, wired console embed is webui.go's mountConsole (called from Serve
after all /v1 routes). clients/console is redundant with it and would double-mount
'/'. Drop the broken import so main builds; consolidating onto ONE console path
is a clean follow-up.

Verified: go build ./cmd/cloud → 426MB binary; booted it and the ONE process
serves console '/' (200 HTML), SPA fallback /gpus (200 HTML), /v1/metrics/health
(200), /v1/nope (503 non-HTML — decline-list holds), /healthz (200).
2026-07-01 06:40:13 -07:00
hanzo-dev d61d611d37 feat(cloud): mount per-org /v1/{prompts,agents,functions} product subsystems
Three native HIP-0106 subsystems, each org-scoped by the gateway-minted
X-Org-Id (SanitizeIdentity trust boundary), Base/SQLite in DataDir, secrets
by KMS reference only. Follows the projectsvc template; wired via one
additive block in subsystems/subsystems.go (orders 126/127/128).

- prompts   /v1/prompts/*    versioned prompt library (create=new version)
- agents    /v1/agents/*     agent defs + real run via deps.AI, recorded runs
- functions /v1/functions/*  serverless registry + invocations/metrics;
                             invoke delegates to the code-exec sandbox and
                             fails closed (503) when unconfigured — never
                             runs tenant code in-process, never fabricates.

Tenant isolation proven at the store AND HTTP layers (Fiber app.Test):
no-org -> 403, cross-org list empty, cross-org get/delete/run -> 404.
All suites green (CGO=1).
2026-07-01 06:39:31 -07:00
hanzo-dev 7bd5c7775f feat(console): go:embed the console2 SPA into cloud — the one-binary foundation
Hanzo V8: Open Edition. cloud/clients/console go:embeds dist/ (the console2 static
export) and mounts the SPA at "/" with SPA-fallback, order 990 — the last-resort
catch-all AFTER every /v1/* route (isAPIPath refuses to HTML-fallback /v1,/zap,/_,
/healthz so JSON clients get honest 404s). Registered in subsystems.go.

This is the seam that makes ONE Go binary the whole cloud — edge + gateway + every
subsystem + the frontend. Placeholder dist/index.html is overwritten by the
console2 static-export bundle at image-build time. Build verified: go build -tags
cloud ./clients/console/ clean.
2026-07-01 05:59:48 -07:00
hanzo-devandGitHub 467be6be19 feat(cloud): embed + serve the console UI from the ONE binary (go:embed) (#50)
One artifact, one origin: the same hanzoai/cloud binary now serves the
console (@hanzo/gui, from hanzoai/console2) at the web root AND the /v1 API
from one process — no separate console Service, no second origin. Flagship
OSS-cloud consolidation (HIP-0106).

Serve (webui.go)
- The console is compiled in via `//go:embed all:webui/dist` and mounted as
  the app's TERMINAL catch-all in Serve — LAST, after every /v1 subsystem
  route, the /zap plane, and the health contract. Fiber v3 matches in
  registration order, so real API routes always win; only paths that match
  nothing else reach the SPA.
- SPA fallback: `/` and any client-side route (`/orgs`, `/models`, …) serve
  index.html (Cache-Control: no-cache) so deep links / reloads work.
  Fingerprinted assets (assets/, _next/) are served immutable for a year,
  with brotli/gzip precompressed-sibling negotiation when the build emits
  .br/.gz. Served through a stdlib http.Handler (correct Content-Type,
  conditional GET) adapted onto zip via zip.AdaptNetHTTP.
- API precedence + namespace safety: an UNMATCHED path under an API/ops
  prefix (/v1/, /zap, /healthz, /readyz, /metrics) returns a real 404 — never
  the SPA shell — so clients calling a mistyped /v1/… never get HTML 200.
- Same-origin: the embedded console calls /v1 on its own host; the session
  cookie is first-party — no CORS, no second-origin token dance.
- Reuses the hanzoai/static plugin's SPAMode semantics; implemented in-binary
  because static.Handler is disk/S3-only today (its New() takes a Root/S3
  bucket, not an fs.FS) so it can't serve an embed.FS — teaching it fs.FS is
  the clean follow-up to collapse onto the shared plugin.

Build pipeline (Dockerfile)
- New `console` stage builds the console2 static bundle → /out; the Go build
  overlays it into webui/dist BEFORE `go build` so go:embed bakes it in.
- webui/dist/index.html is a committed fallback shell (a real same-origin /v1
  bootstrap) so `go build` always compiles and the binary always serves a UI
  even without the Node toolchain; the image build overwrites it with the real
  console. Built assets are .gitignore'd — generated at build time, never
  committed as source.

Tests (webui_test.go) — boot the app + assert, end-to-end via app.Fiber().Test:
GET / → shell; deep links → shell 200 (not 404); /v1/models → API (not SPA);
unmatched /v1/… → 404 (not HTML); assets served directly; HEAD; and path
traversal (../, %2e%2e) cannot escape the embed FS. 7/7 green.

Honest current state: console2 ships 15 Next server route handlers
(app/**/route.ts, KMS-token proxies) so it emits a Node server bundle, not a
static export — the image embeds the fallback shell until console2 exposes a
build:embed static target or those routes land here as native /v1 endpoints.
The Go embed/serve plumbing is complete and needs no change to light up the
full console the moment the static bundle exists.

Drive-by: brand_test.go asserted the pre-pin hanzo issuer (iam.hanzo.ai);
brand.go was pinned to hanzo.id in 991e7bef, so the test was stale — aligned
to the shipped behavior (brand.go unchanged). Root package: 34/34 green.
2026-07-01 05:53:35 -07:00
hanzo-dev 81d83748a1 fix(deps): re-record 3 drifted luxfi go.sum hashes (age@v1.5.0 +2) — canonical proxy; unblock release build 2026-06-30 22:23:20 -07:00
hanzo-dev e8e7bde84a refactor(o11y): forward path verbatim — no /api/ rewrite
The o11y fork now registers its routes at their exact public path (/v1/o11y/*),
so the reverse proxy forwards unchanged — removed rewritePath (/v1/o11y→/api) and
the TestRewritePath test. One and one way: the route IS the path on both sides.
Cloud o11y tests pass.
2026-06-30 21:35:19 -07:00
zeekay 416b7ee204 Merge branch 'feat/projects-store-and-deploy' 2026-06-30 20:18:54 -07:00
hanzo-devandGitHub b83bca18ac feat(cloud): /v1/exec (Code Interpreter → sandbox) + /v1/websearch (Hanzo search+crawl) (#49)
release / build-amd64 (push) Successful in 4m33s
release / notify-universe (push) Failing after 1s
* feat(cloud): /v1/exec (Code Interpreter → sandbox) + /v1/websearch (SearXNG+Firecrawl-compat over Hanzo search+crawl)

hanzo.chat's Run Code and Web Search agent tools speak fixed LibreChat
provider contracts. cloud-api is the single /v1 edge, so it owns those
surfaces and routes them to Hanzo's own infra — never an external SaaS.

- clients/exec (order 140): mounts /v1/exec, /v1/exec/*, /v1/upload,
  /v1/download/*, /v1/files/* — the @librechat/agents CodeExecutor contract
  (POST /exec {lang,code} X-API-Key -> {stdout,stderr,files}). Transparent
  reverse proxy to a SANDBOXED executor (CODE_EXEC_UPSTREAM). NO os/exec here;
  the executor is the isolation boundary. X-API-Key (CODE_EXEC_API_KEY, KMS)
  enforced constant-time, fail-closed.
- clients/websearch (order 141): mounts /v1/websearch/search (SearXNG JSON,
  proxied to a Hanzo-operated metasearch WEBSEARCH_UPSTREAM) and
  /v1/websearch/v1/scrape (Firecrawl shape, backed by Hanzo Crawl/Crawl4AI —
  {url}->{success,data:{markdown,metadata}}). WEBSEARCH_API_KEY (KMS).
- Both register before ai (150) so their specific paths win over ai's /v1/*
  catch-all. Mirrors the clients/o11y reverse-proxy pattern.

Tests: proxy verbatim-forward, path rewrite, auth fail-closed/reject,
crawl->firecrawl shape adaptation. All green.

* test(cloud): mount-through-Fiber integration tests for exec + websearch

Prove Mount() registers the overlapping static+wildcard routes (/v1/exec &
/v1/exec/*, /v1/websearch/*) on a real zip/Fiber router without panicking,
and that requests route end-to-end through the router to the guarded
handlers (proxy forward, firecrawl-shaped scrape, auth reject). Closes the
gap where direct-handler tests bypassed route registration.
2026-06-30 18:39:23 -07:00
hanzo-dev 6ada44df53 refactor(cloud): adminsvc → admin (drop svc, one word) — consistency with the svc-drop 2026-06-30 17:55:00 -07:00
hanzo-dev a85d64d286 refactor(cloud): drop the svc suffix — one word per subsystem client
o11ysvc→o11y, evalsvc→eval, mlsvc→ml, plansvc→plan, pluginsvc→plugin,
pricingsvc→pricing, productsvc→product, provisioningsvc→provisioning. The suffix
was stutter (svc = service). Package name == dir == the bare noun now.

o11y is `package o11y` importing `github.com/hanzoai/o11y` with a PLAIN import (no
alias): the import name is file-scoped and you never qualify your own package, so
`o11y.SetHandler` resolves to the upstream — the local `upstream()` URL func is
untouched. subsystems.go import paths + gojahost comment updated. Renamed packages
+ subsystems build clean.
2026-06-30 17:52:59 -07:00
hanzo-devandGitHub 68b50b3c73 feat(adminsvc): god-mode /v1/admin/* surface for admin.hanzo.ai console (#48)
release / build-amd64 (push) Successful in 4m35s
release / notify-universe (push) Failing after 4s
Aggregator facade mounting the /v1/admin/* surface the Hanzo Admin Console
(admin.hanzo.ai, apps/operator) calls, matching its api.ts contract
field-for-field. Fans out over HTTP to the real upstreams — IAM (orgs, users,
roles, applications, audit, me), commerce (spend, credits), o11y (health) —
exactly like the o11ysvc/productsvc read facades; holds no store of its own.

Every route is GLOBAL-ADMIN ONLY, fail-closed: the guard reuses c.IsAdmin(),
which after SanitizeIdentity is true only for a JWT-validated principal whose
org is the admin org (IAM's IsGlobalAdmin), matching the gateway's admin-guard.
Anonymous and tenant-admin callers are denied 403 on every route (regression
locked in TestGate_DeniesEveryRoute). The IAM fan-out replays the caller's own
cookie/bearer — no adminsvc service credential — so it never reads more than the
caller could, and IAM re-checks IsGlobalAdmin. Commerce uses the existing
KMS-synced COMMERCE_SERVICE_TOKEN; no secret is hard-coded or logged.

Panels with no in-binary feed yet return the honest empty state, never a
fabricated number: the usage timeseries + per-product breakdown (insights/
datastore) and the product/workload registry + infra tiles (platform apps
table). The operator renders these as empty/em-dash by design.

Endpoints: overview, orgs, users, roles, applications, audit, usage, products,
me, sync. Registered order 146; blank-imported in subsystems.go.

Tests: gate denial across all routes x anonymous/tenant-admin/tenant-user, gate
allow for global admin, real aggregation (orgs/users/overview/usage) against
mock IAM+commerce, credential-replay assertion, IAM-error-surfaced (not
fabricated), honest-empty series/products. go build ./... + go test green.
2026-06-30 17:48:43 -07:00
hanzo-dev ee8ce6b59d fix(storagelock): a db NAME is not a backend — reject only real Postgres
`dbName=hanzo_cloud` alongside driverName=sqlite crash-looped cloud-api (fail-closed
on a benign leftover). Decomplect: the guard's one job is "reject Postgres" =
driverName=postgres OR a postgres:// DSN. A database name selects nothing, so drop
`dbName` from forbiddenEnvs entirely (the Go binary never reads it). Also correct the
lineage label: the legacy cloud-api is casibase (Go) — it lives on as hanzoai/ai,
which mounts INTO this hanzoai/cloud orchestrator — NOT "Python/TS". Tests updated:
dbName is never a violation; driverName=postgres + postgres DSN still are. Forwards
perfection, no backwards-compat leftover.
2026-06-30 17:32:17 -07:00
hanzo-devandGitHub cf58f8d390 chore(productsvc): top-level /v1 — drop residual /api/ prefix (#47)
release / build-amd64 (push) Successful in 4m35s
release / notify-universe (push) Failing after 1s
Rename cloud-api's own public product routes from /api/<route> to
top-level /v1/<route>, per the openapi v1.0.0 lock-in (no /api/ prefix;
the subdomain is api.* so /api/ double-prefixes):

  /api/search-docs/indexes -> /v1/search-docs/indexes
  /api/search-docs/stats   -> /v1/search-docs/stats
  /api/vector/collections  -> /v1/vector/collections
  /api/vector/stats        -> /v1/vector/stats

These are the only /api/ paths cloud-api REGISTERS (serves). The remaining
/api/ literals are upstream calls cloud-api MAKES to other services that
genuinely serve /api/ — left untouched:
  - evalsvc: /api/public/* (Langfuse console API proxy targets)
  - o11ysvc: /v1/o11y/* -> /api/* runtime rewrite (destination)
  - pricingsvc: openrouter.ai/api/v1/models (external)

Hard cutover (no dual-serving, per no-backwards-compat). Coordinated with:
universe cloud-api-v1 AUTH_PUBLIC_PATHS, python-sdk + hanzo-docs RAG
clients, and the openapi cloud spec — all moving to /v1 together.
2026-06-30 17:06:27 -07:00
zeekayandClaude Opus 4.8 23f225e439 fix(deps): realign go.sum to current origin (luxfi force-re-tags) + integrate main (goa/pluginsvc); clear corrupted VCS cache
release / build-amd64 (push) Failing after 41s
release / notify-universe (push) Skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:38:16 -07:00
zeekay f18cd20de1 Merge remote-tracking branch 'origin/main' into feat/projects-store-and-deploy 2026-06-30 16:22:28 -07:00
hanzo-dev 5d84d5ecd2 Merge commit '6758ef50' into deploy/cloud-convergence
release / build-amd64 (push) Successful in 4m39s
release / notify-universe (push) Failing after 1s
2026-06-30 15:48:06 -07:00
hanzo-dev 991e7bef45 fix(auth): pin hanzo IAM issuer to hanzo.id + reject missing exp (red FIX1/INFO)
FIX1 (red, HIGH — the deploy landmine): brand.go defaulted the `hanzo` brand
IAMIssuer to https://iam.hanzo.ai, but the live .well-known/openid-configuration
on BOTH hanzo.id and iam.hanzo.ai reports issuer=https://hanzo.id +
jwks_uri=https://hanzo.id/v1/iam/.well-known/jwks (iam.hanzo.ai is a routing
alias, not the token issuer). With the baked default, SanitizeIdentity's issuer
check would fail on every real token -> every principal anonymized -> ALL global
admin gets 403 (fail-secure, no forgery opened, but admin broken platform-wide).
The cloud CLI already defaults to hanzo.id; lux/zoo/pars already point at their
own .id issuers. Pin hanzo -> https://hanzo.id so the correct config is
default-by-default. (JWKS derivation then yields the correct hanzo.id JWKS.)

INFO (red): go-jose ValidateWithLeeway only enforces exp when present
(`if c.Expiry != nil`), so a token with NO exp would never expire. Reject a
missing exp explicitly, exactly like a missing iss. +1 test (22 green).

No subsystem reads cfg.IAMIssuer except SanitizeIdentity + a log line, so the
brand default change is contained.
2026-06-30 15:39:08 -07:00
hanzo-dev 6758ef5087 feat(billing): per-org fail-closed gate+meter for non-LLM provisioning + compute
Non-LLM resources were free: anyone could provision sql/vector/kv/s3/datastore/
docdb/search (provisioningsvc) and ml models/train jobs/experiments (mlsvc, GPU)
for $0 — only LLM calls were metered. This adds the same per-org commerce gate
the LLM edge gate uses, in-handler, so every create is paid for.

ONE shared primitive (no copy-paste per kind), reusing Deps.Metering (the single
commerce client) — the in-handler analogue of BillingGate:

  cloud.ResourceMeter (resource_billing.go)
    Gate(ctx, org, kind, costCents)   pre-create balance gate, fail-CLOSED
    Meter(org, kind, amountCents, …)  post-success debit, per-org, async
    DenyResource(c, err)              402 insufficient_balance / 503 unavailable
    ResourceFeeCents(prefix, kind)    configurable flat fee, $1.00 default

Wired into BOTH create paths (provisioningsvc + mlsvc) via the shared type:
gate runs after request validation and BEFORE any backend/k8s object is created
(no free provisioning, even on a commerce outage); meter runs only after the
resource is persisted/created.

Multitenancy (the whole point): org is the caller's resolved slug from tenant(c)
— the SAME value that namespaces the resource, now JWT-derived by the #66
identity sanitizer (not a spoofable header). It is sent to commerce as BOTH the
user identity AND X-IAM-Org-Id, OVERRIDING the client default org, so the balance
checked and the ledger debited are always the caller's own — never a default,
never another tenant. Proven by tests asserting commerce sees X-IAM-Org-Id:<caller>
(not the client default "hanzo") on both the balance check and the debit.

Env-aware (3-env split): the gate fires in EVERY env; test/dev are sandbox-but-
billed against their own per-env commerce/Square (structural, not a code branch).
Env is threaded config→deps as an attribution label and is NEVER a billing
bypass — proven by a test that testnet/devnet still refuse at zero balance.

Cost model: real, configurable per-kind flat fee (CLOUD_PROVISION_FEE_CENTS[_KIND],
CLOUD_COMPUTE_FEE_CENTS[_KIND]); 0 makes a kind free and un-gated; invalid/negative
is ignored so a typo can't silently free a paid resource. Ongoing storage GB-month
and GPU-hour reuse the SAME Meter primitive with a usage-derived amount from a
future runtime watcher — no live-size source here, so no size is fabricated.

Tests: resource_billing_test.go (gate allow/refuse/free/fail-closed/fail-open,
caller-org-not-default for both balance and debit, tenant isolation, env-never-
bypasses, unconfigured/nil no-op, fee resolution, deny shapes) + per-subsystem
integration tests proving the gate is wired into the real create path (402 before
backend on zero balance, 201 + caller-org debit when funded, free-kind un-gated).
go build ./... clean, go test ./... green, gofmt + vet clean.
2026-06-30 15:20:05 -07:00
hanzo-dev 60c4bed9e9 fix(auth): close forgeable-admin trust boundary on cloud-api
zip.Ctx.Org()/IsAdmin() read X-Org-Id / X-User-IsAdmin verbatim, trusting the
gateway to be their sole minter. But cloud-api is reachable WITHOUT the gateway
in front (in-cluster cloud-api.hanzo.svc:8000, and historically the public
cloud-api.hanzo.ai), so a direct caller could forge `X-User-IsAdmin: true` and
pass every admin gate: the new /v1/admin/catalog writes, /v1/pricing/sync, and
the provisioningsvc/mlsvc literal "admin" tenant bucket.

Add SanitizeIdentity, an early middleware (before BillingGate + every subsystem)
that strips every client-supplied authority header and re-derives identity ONLY
from a validated IAM JWT. Validation is a tiny go-jose JWKS validator
(auth_identity.go) that mirrors gateway/v2/iamauth — deliberately NOT imported
to avoid a module cycle (gateway/v2 already imports hanzoai/cloud) and pulling
the gateway's KrakenD/gin/traefik tree for ~150 lines. Admin authority is
granted ONLY to a verified GLOBAL admin (owner == AdminOrg), so an org-admin
(IAM also sets isAdmin=true for org owners) can't escalate. Non-admins are
pinned to their own org; a verified global admin's org-switch is honored. One
middleware makes every existing c.IsAdmin()/c.Org() reader trustworthy with no
handler changes.

Phase-1 residual (documented in middleware_identity.go): with no validatable
bearer the client X-Org-Id is passed through for DATA scoping (the console
browser data path depends on it) — closing that is Phase-2; the ADMIN boundary
is closed on every path because X-User-IsAdmin is never restored from a header.
Fail-secure: a validator misconfig (issuer/JWKS) makes admin 403, never opens.

Tests (middleware_identity_test.go): 18 subtests — forged header grants nothing,
org-admin can't escalate or cross-tenant, global-admin org-switch honored,
expired/wrong-key/wrong-audience/api-key/missing-issuer all anonymous, cookie +
HTTP-Basic paths. go-jose promoted to a direct require (already in the graph).
2026-06-30 15:02:06 -07:00
hanzo-dev 4fd6088c0d fix(deps): canonical luxfi/zap@v0.8.11 go.sum hash (re-tagged module drifted local cache -> CI checksum mismatch)
release / build-amd64 (push) Successful in 4m43s
release / notify-universe (push) Failing after 5s
2026-06-30 14:50:54 -07:00
hanzo-dev fb17959764 Merge branch 'feat/catalog-enablement' 2026-06-30 14:45:50 -07:00
hanzo-dev 53d4418546 fix(pricingsvc): gate root /v1/pricing + fail-closed overlay + override caps
Red review of feat/catalog-enablement found two in-branch leaks; fixed:

FIX #2 (HIGH): the root GET /v1/pricing returned the WHOLE bundle blob
(hanzoModels, thirdPartyModels, providers, freeModels, families) un-gated
via the `fixed` passthrough — an un-gated second source for everything the
leaf routes hide. New GateRootData() (catalog.go) gates the root in place:
hanzoModels+thirdPartyModels via VisibleCatalog (hanzoModels tagged "Hanzo"
so a disabled Hanzo provider cascades), providers via VisibleProviders, and
the id-reference lists freeModels + families[].models kept only if the
referenced model survived (admins keep all). Route moved out of `fixed` to
app.Get("/v1/pricing", gatedRoot). Audited the rest of `fixed`
(subscriptions/blockchain/iam/base/paas/tools/gpu/policy/cloud/compute):
all draw from the plans catalog with ZERO model/provider identity keys —
no gating needed; summary stays gated (providers sub-dict) with counts as
aggregate stats.

FIX #3 (fail-closed): empty DataDir was a Warn + :memory: fallback — a
security control that silently fails OPEN (admin-hidden models re-expose on
pod restart). Now a hard boot error (prod sets CLOUD_DATA_DIR;
provisioningsvc already requires it, so the unified binary always has one).

FIX #5 (DoS guard): overrides now bounded at 64 KiB + depth 32
(checkOverride) — bounds the recursive merge under a forged-admin write.

Tests: TestGateRootData (root gated identically to leaves: disabled/beta/
admin across hanzoModels/thirdPartyModels/freeModels/families/providers,
summary counts untouched), TestCheckOverride (object|null, size+depth caps),
TestMount_EmptyDataDir_FailsClosed, + e2e GET /v1/pricing gating and an
over-deep override PATCH->400. go build ./... + go test ./... green, gofmt.

NOT fixed here (infra, tracked separately): forgeable X-User-IsAdmin via
direct-to-pod cloud-api route — pre-existing, shared by every cloud IsAdmin
route; needs gateway routing + NetworkPolicy restriction in universe/operator.
2026-06-30 14:34:25 -07:00
hanzo-dev 89d71b6f1f deps(ai): v1.785.14 -> v1.786.1 — cloud-repo image reaches feature-parity with prod
Decision (b): the cloud repo is the ONE authoritative builder of ghcr.io/hanzoai/cloud
(it assembles every subsystem incl. o11ysvc). But cloud pinned ai v1.785.14 while the
prod AI-built image (1.785.26) embeds ai code through the blue-money P0 security wave.
Bump ai to v1.786.1 — which contains ALL of it (balance ledger + overdraft gate, JWT
iss/aud validation, secret redaction, single-pod ledger invariant, aud env-keys,
redact allowlist, global-admin {admin,built-in}) — so the cloud-repo image is an
UPGRADE, never a regression below 1.785.26. luxfi/zap v0.8.8 -> v0.8.11 (tidy).
Build verified: go build ./cmd/cloud clean (453MB binary). Unblocks shipping o11ysvc.
2026-06-30 14:30:33 -07:00
hanzo-dev 278a134c09 fix(ci): ECR Public mirror for golang base — unblock release build (Docker Hub 429)
The last 5 release builds failed at `FROM golang:1.26-alpine` with
"toomanyrequests: unauthenticated pull rate limit" (429) from Docker Hub on the
shared runner, so no new cloud image has shipped — the deployed image predates the
o11ysvc mount (o11y /v1/o11y/* still 503) and the commerce mount. Switch the build
base to public.ecr.aws/docker/library/golang:1.26-alpine (immutable ECR Public
mirror, no rate limit) — the same fix already shipped in hanzoai/console2. Build
logic unchanged. Unblocks shipping o11ysvc → o11y live → retire old Langfuse console.
2026-06-30 14:12:28 -07:00
hanzo-dev 3d953f8504 feat(pricingsvc): catalog enablement overlay + admin API
Add the backend admin layer that makes "admin enables -> customer sees"
real for the model/provider catalog, without forking the static
@hanzo/pricing bundle (still the sole source of truth for catalog
content/shape).

One overlay store, one gate:
- catalog.go: SQLite/Base overlay (table catalog_overlay, PK (kind,id);
  default = enabled, so an empty store is a no-op). Pure gate
  VisibleCatalog/VisibleProviders applies {enabled,betaOrgs,overrides}
  onto the bundle output: visible iff own AND provider overlay admit the
  org (enabled || org in betaOrgs); overrides merge via RFC 7386. Admins
  see every entry, annotated under _overlay.
- admin.go: global-admin (c.IsAdmin) write surface — GET /v1/admin/catalog
  (full catalog + state), PATCH /v1/admin/catalog/models/* (slashed ids via
  greedy wildcard) and /providers/:name. Partial-update PATCH; override
  validated as JSON object|null.
- pricingsvc.go: gate wired into the catalog read path (models, free,
  featured, providers, summary, model/:name); non-catalog routes unchanged.
  Overlay opened at {DataDir}/catalog.db (in-memory fallback), closed in
  Shutdown.

Default behavior unchanged for live customers (all enabled). Tests:
pure-gate units (default-all-visible, disabled-hidden-except-beta,
override-merged deep, admin-sees-all, provider cascade), store round-trip,
and an end-to-end HTTP test (wildcard routing, IsAdmin 403, enable->see flow).

console2 admin UI is a separate agent's job; it consumes these endpoints.
2026-06-30 14:02:59 -07:00
hanzo-dev 4d0f642bbc fix(deps): base v1.3.2 -> v1.4.1 — unblock release Docker build
The committed go.sum pinned hanzoai/base@v1.3.2, whose tag was force-re-tagged
upstream (live content hash drifted from the recorded hash). The release
Dockerfile verifies modules against the committed go.sum with GOSUMDB=off, so
`go mod download` hit "checksum mismatch / SECURITY ERROR" and every release
build failed.

v1.4.1 is the latest base tag that (a) has a stable, immutable hash and (b)
still registers as a cloud subsystem (v1.4.2+ dropped cloud.Register and would
break subsystem assembly — 13 vs 14 subsystems, /v1/base/health 404). Pin v1.4.1:
fresh-cache go mod download is clean, registry assembles 14 subsystems, all
health endpoints 200, full suite green.
2026-06-28 23:15:53 -07:00
zeekay 5621c6eb6d feat(projects): /v1/projects org-scoped store + deploy pipeline
New projectsvc subsystem (HIP-0106) — the ONE org-scoped store of
buildable/deployable sites, shared by hanzo.app (builder) and
console.hanzo.ai (Projects module). Both read/write the same records
through the gateway (X-Org-Id from the IAM JWT); no second copy of state.

- CRUD: POST/GET/PATCH/DELETE /v1/projects (+ /:slug)
- Deploy: POST /v1/projects/:slug/deploy
  - artifact mode: tar(.gz) of built site -> OUR S3 (s3.hanzo.ai,
    CLOUD_PROJECTS_BUCKET) under <org>/<slug>/, public-read, live URL
  - git mode: queue + CI completion hook (/deployments/:id/complete)
- Deploy history: GET /v1/projects/:slug/deployments(/:id)
- SQLite store (modernc), versioned deployments, tenant isolation by org
- Reuses CLOUD_S3_ADMIN_* creds (one S3 path, like provisioningsvc)
- Path-traversal + size/file guards on artifacts; index.html required
- Published contract in CONTRACT.md for console2 to consume

Tests: store CRUD/isolation/ordering, deployment versioning, slugify,
provider detection, safeRel traversal guard, tar/tar.gz walker. All pass.
2026-06-28 23:07:51 -07:00
7d4c5b2bc6 build: drop GOPRIVATE for luxfi/hanzoai — use immutable public proxy (fix go.sum checksum mismatch) (#46)
The build routed luxfi/* + hanzoai/* DIRECT via git insteadOf, which re-fetches a
re-pointed tag's tree (luxfi/age@v1.5.0) whose hash differs from go.sum's proxy
hash → 'verifying github.com/luxfi/age@v1.5.0: checksum mismatch / SECURITY
ERROR'. luxfi/hanzoai are PUBLIC: resolve them via the IMMUTABLE public proxy +
the committed go.sum (which already pins the proxy hashes). Only zap-proto/*
stays first-party-direct. Matches the drop-GOPRIVATE fix in hanzoai/iam +
luxfi/kms.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-06-28 20:21:35 -07:00
hanzo-dev 5c2b81cb43 feat(cloud): pluginsvc — runtime plugin loader (goa wasm + ZAP-pluggable proxy)
cloud becomes a thin runtime host: alongside the compiled-in application
subsystems it mounts services from a runtime manifest (CLOUD_PLUGINS) with no
rebuild. Two plugin kinds, both reduced to app.Mount(prefix, http.Handler):

  wasm  — a polyglot module (Rust/WASM, Python, TypeScript) loaded in-process
          via github.com/hanzoai/goa (wazero/gpython/goja; pure Go, stays
          CGO_ENABLED=0). Drop a .wasm + manifest entry → mounted.
  proxy — a standalone server (e.g. the beego apps ai, vm) reached over a
          pluggable transport. The "zap" transport registers via
          pluginsvc.RegisterTransport; proxying defaults to HTTP until then.

Adding/updating a service = edit the manifest + drop a .wasm or redeploy the
standalone — the cloud binary is unchanged unless its own core changes.
Registered at order 900. Static binary preserved; package + full suite green.
2026-06-28 20:09:36 -07:00
z e7941223c7 docs(brand): add hero banner 2026-06-28 20:05:32 -07:00
z 8ca1cabfde chore(brand): dynamic hero banner 2026-06-28 20:05:31 -07:00
hanzo-dev e0bca8a552 test(cloud): scope registry assertion to the application-layer matrix (edge/infra subsystems run as own deployments) 2026-06-28 17:11:07 -07:00
hanzo-dev 512b971f0f refactor(arch): cloud = application layer only; drop edge/infra subsystem imports (gateway, iam, kms, mcp run as own deployments behind gateway/ingress). Scope registry test to app matrix. Blast-radius isolation, smaller binary. 2026-06-28 17:09:22 -07:00
hanzo-dev 1c918034e9 refactor(tenant): remove last X-Hanzo-Org; cloud -> ai v1.785.14. Single canonical X-Org-Id tenant header across the whole stack. 2026-06-28 16:59:27 -07:00
hanzo-dev 51c1259a7e build(deps): cloud -> ai v1.785.13 (tenant identity decomplected to X-Org-Id; X-Hanzo-Org kept as orthogonal service-token selector) 2026-06-28 16:47:23 -07:00
hanzo-dev a08d7db5e3 fix(cloud): full subsystem registry — force guarded beego/v2 v2.3.10 + explicitly import kms/iam/gateway/mcp; resolves ai(beego-v1)+iam(beego-v2) grace flag collision. TestRegistryAssemblesSubsystems green; cloud test suite fully passing. 2026-06-28 16:32:21 -07:00
hanzo-dev 7d269c1009 feat(cloud): storage-lockdown invariant (reject legacy PG env) + PG->SQLite migration tooling from zap-listener; superseded ZAP-listener/mount-adapter code dropped (zapface + registry are the one canonical way) 2026-06-28 16:17:10 -07:00
hanzo-dev 658dcd18be fix(deps): revert idv to v1.0.0 (v1.0.2 release is broken — missing provider/onyxplus.go) 2026-06-28 15:58:38 -07:00
hanzo-dev 446c188b24 build(deps): cloud -> ai v1.785.11 (fail-closed mount + P0 security: balance/overdraft, JWT iss/aud, redaction, authz, tenant) + idv v1.0.2 2026-06-28 15:57:18 -07:00
hanzo-dev 51b38c77b4 Merge remote-tracking branch 'origin/main' 2026-06-28 15:23:09 -07:00
hanzo-dev 95f7e807e1 fix(build): route luxfi via proxy + clean go.sum; ai v1.785.10 (fail-closed mount); drop removed amqp from tests
- Root cause of broken build: GOPROXY=direct fetched re-tagged git content + dropped //go:embed files (accel/crypto/geth). luxfi is public -> use proxy.
- ai@v1.785.10: Mount fails closed (503) when DB unconfigured, honoring BuildDeps three-mode contract.
- amqp removed from registry/health tests (subsystem retired).
- TestRegistryAssemblesSubsystems still red: ai(beego v1) vs iam(beego v2) grace flag collision blocks in-process coexistence - architectural follow-up.
2026-06-28 15:21:14 -07:00
zeekay 9a0e7d31b5 fix(deps): bump hanzoai/iamsdk/v2 v2.1.0 -> v2.1.2 (JWKS token verify)
release / build-amd64 (push) Failing after 30s
release / notify-universe (push) Skipped
v2.1.2 verifies JWTs via the published JWKS instead of parsing the configured
cert PEM. cloud-api's /v1/signin (the ai dep's code->session exchange) configured
an unparseable Certificate (IAM returns the cert NAME 'cert-built-in' to
global-admin callers, not a PEM) -> 'iamsdk: not valid PEM' -> every
hanzo-cloud/hanzo-console SPA login failed (console + admin). go.sum realigned
(first-party re-tag dep-rot). cmd/cloud builds clean.
2026-06-28 14:28:00 -07:00
hanzo-dev bf699cd26d chore(deps): bump luxfi/hanzoai deps to latest; fix re-tagged keys v1.2.2 + gateway v2.14.10; regenerate clean go.sum 2026-06-28 14:04:15 -07:00
hanzo-dev f1cd573a45 Merge PR #37: Hanzo branding + LICENSE attribution 2026-06-28 13:12:17 -07:00
hanzo-dev 5fed291ae9 Merge PR #45: fix(deps) bump hanzoai/ai -> 2e8fc6f15947 (401 on missing/invalid Bearer) 2026-06-28 13:12:17 -07:00
Blue e8542aa79c build(cloud): bump ai -> 2e8fc6f1 (invalid hk- key -> 401, nil-user fix); cloud:1.785.24 2026-06-28 04:29:32 -07:00
Blue 4fb24d37a0 build(cloud): bump ai -> 069b83ce (authenticate-before-parse; 401 not 200 on invalid key + bad body)
Pulls hanzoai/ai @069b83ce into cloud:1.785.23: residual 200-leak fix for
/v1/chat/completions, /v1/embeddings, /v1/rerank, /v1/messages — an invalid
credential with a malformed/incomplete body now returns 401 (was 200/400),
authenticating before the body is parsed. ai go.mod unchanged, so only the ai
require + its go.sum zip hash move.
2026-06-28 03:28:29 -07:00
hanzo-dev 9d7cd69632 fix(deps): revert erroneous base/pq go.sum realign — keep ONLY ai bump
The base/pq 'realign' in the prior commit adopted anomalous bits from a local
direct-fetch; the build container (and the working cloud:1.785.20 build) resolve
the ORIGINAL stable hashes via the module proxy/cache. Net go.sum change is now
exactly the hanzoai/ai bump (c08be563). luxfi re-tag churn
(fix/threshold-*-checksum-convergence) does NOT touch these pinned hashes.
2026-06-28 01:41:07 -07:00
hanzo-dev 1ff045fac9 fix(deps): bump hanzoai/ai -> c08be563 (401/402/400 auth status, not 200)
Pull in the ai auth-status fix: invalid/unknown hk- key -> 401, insufficient
balance -> 402, bad model -> 400 (was HTTP 200 with an error body on all of
them). Cloud-api is the single backend validating hk- keys for both
api.cloud.hanzo.ai and the gateway (api.hanzo.ai), which proxies the status.

Realign go.sum zip hashes for hanzoai/base v1.3.2 and luxfi/pq v1.0.3 to the
current origin (upstream re-tags; /go.mod hashes unchanged) so the build
resolves in a fresh container. Verified: CGO_ENABLED=0 go build ./cmd/cloud
links clean.
2026-06-28 01:28:44 -07:00
zeekay 413045f91c fix(deps): correct go.sum for re-tagged luxfi/pq + hanzoai/base
release / build-amd64 (push) Failing after 31s
release / notify-universe (push) Skipped
CI fetches via proxy.golang.org,direct; both tags were force-re-pushed so
the committed zip hashes no longer matched what the proxy serves:
  luxfi/pq    v1.0.3  pFlQm1... -> ksw1dm... (proxy commit 90d2223)
  hanzoai/base v1.3.2 BdTNDNe... -> 7GcHpg... (proxy commit 33d12949)
Minimal go mod tidy fix (2 lines); go.mod unchanged; cmd/cloud builds clean.
Unblocks the /v1/memory embed (hanzoai/ai fe516793).
2026-06-28 01:01:05 -07:00
zeekay 9da2a538ff deps: bump hanzoai/ai -> fe516793 (embeds /v1/memory) + realign luxfi go.sum (pq/base/tls re-tag dep-rot) 2026-06-28 00:16:20 -07:00
zeekay e92b824533 fix(o11y): proxy rewrites /v1/o11y/* -> /api/* for the runtime's controllers
release / build-amd64 (push) Failing after 30s
release / notify-universe (push) Skipped
The o11y runtime (SigNoz query server) serves its API under /api; the registered
handler owns the documented /v1/o11y/* -> /api/* rewrite. The proxy now strips the
public prefix and prepends /api so /v1/o11y/v3/query_range reaches /api/v3/query_range
(verbatim forwarding hit the SPA fallback instead of the API).
2026-06-27 23:27:34 -07:00
zeekay 9375fab728 feat(o11y): install runtime handler via reverse proxy to the o11y deployment
The o11y subsystem (hanzoai/o11y, order 70) mounts /v1/o11y/* but delegates to a
handler installed via o11y.SetHandler — never called in the unified cloud binary,
so the surface 503'd 'o11y runtime not initialized'. The heavy o11y runtime runs
as a dedicated Deployment; cloud now installs a reverse proxy to it (O11Y_UPSTREAM,
default o11y.hanzo.svc:80) so /v1/o11y/* serves real telemetry. Path preserved
verbatim; gateway-terminated identity forwarded.
2026-06-27 23:24:25 -07:00
hanzo-dev ef33b0b688 fix(deps): bump hanzoai/ai -> 254ea3b6 (401 on missing/invalid Bearer)
Pulls in ai fix: /v1/chat/completions, /v1/embeddings, /v1/rerank now
return HTTP 401 (not 200) on a missing/invalid Bearer token, matching
/v1/models. Valid-key completions + per-org billing unchanged.
2026-06-27 23:05:48 -07:00
zeekay 3a9d7c3ef2 chore(deps): bump hanzoai/kms/sdk/go v1.0.0 -> v1.1.1 (luxfi/constants dep-rot fix → unblocks mlsvc image build) 2026-06-27 22:04:23 -07:00
hanzo-dev 20e3fe5135 build: realign first-party go.sum hashes to current origin (upstream re-tags)
luxfi/* and hanzoai/* tags were re-pointed upstream (base v1.3.2, pq v1.0.3,
zap v0.8.8, et al.); the committed go.sum went stale and a clean image build
failed 'go mod download' verification. Re-record the current direct-fetch
hashes (proven non-first-party set untouched). No go.mod change.
2026-06-27 18:19:52 -07:00
hanzo-dev b5ea801da9 build: realign hanzoai/base v1.3.2 go.sum hash (upstream re-tag)
base@v1.3.2 was re-pointed after cloud's go.sum was recorded; the committed
zip hash (BdTNDNe3…) is the stale public-proxy first-seen content, while the
live tag (direct git, the path CI's GOPRIVATE takes) hashes to 7GcHpg…. CI
fetches base DIRECT and fast-fails the build at the checksum mismatch — the
last stale hash blocking a green main (pq was realigned in e4d68b36; this is
the same upstream-re-tag fix, mirroring 43a2ac57 for luxfi age/keys/zap).

go.mod /go.mod hash unchanged (graph-load verified it); only the module zip
hash needed realigning. Verified: clean-cache readonly linux/amd64 -mod=mod
direct build is green.
2026-06-27 18:14:42 -07:00
hanzo-dev e4d68b3638 build: realign luxfi/pq v1.0.3 go.sum hash (upstream re-tag)
The committed zip hash went stale after luxfi/pq@v1.0.3 was re-tagged
upstream; cloud's clean image build failed go mod download verification.
Update to the current origin hash.
2026-06-27 18:10:26 -07:00
hanzo-dev 3c1ade6b11 chore(deps): bump hanzoai/ai → f2cd2681 (per-user billing subject)
Pulls the per-user billing-subject fix into cloud-api: the gateway now keys the
balance gate + usage debit on object.BillingSubject(owner,name), so individuals
in the shared 'hanzo' org are billed independently (own balance, own $5) instead
of sharing+draining the single (hanzo,hanzo) balance.
2026-06-27 18:01:45 -07:00
zeekay 4f245a369f feat(mlsvc): tenant-scoped /v1/ml + /v1/train k8s bridge (kserve/trainer/katib)
New cloud subsystem (order 130) fronting the kubeflow forks via the k8s
dynamic client, scoped per-org by namespace (ml-<org>):

- /v1/ml/models           CRUD + PATCH + /predict (kserve InferenceService;
                          predict proxies to the model's v2 data plane /infer)
- /v1/train/jobs          CRUD (trainer TrainJob)
- /v1/train/experiments   CRUD + /trials (katib Experiment/Trial)
- /v1/ml/health, /v1/train/health  real probes: k8s reachability + CRD presence
  (200 ok / 503 degraded with the real reason; never status-theater)

Tenant boundary is the per-org Kubernetes namespace; the org->namespace map is
injective (strict slug regex, no lossy fold) so two tenants can never share a
namespace. User-supplied labels can't override the tenant org marker. The k8s
client is built in-process from the in-cluster service account with a KUBECONFIG
fallback (self-contained like provisioningsvc's backends, not on shared
cloud.Deps); it fails closed (503 / degraded health) when unconfigured.

Promotes k8s.io/apimachinery + client-go to direct requires. Registered via
blank import in subsystems.go. Unit tests cover the security-critical pure
helpers (tenant injectivity, name validation, label-override guard, GVRs).
2026-06-27 14:56:14 -07:00
hanzo-dev 8579b5acf2 chore(deps): bump hanzoai/ai -> 703fe6b5 (OpenAI stream role + usage-chunk gating)
Picks up the streaming fix: first delta carries role:assistant and the
empty-choices usage chunk is gated behind stream_options.include_usage —
resolves hanzo.chat 'reading role' no-reply (separate from the dbx fix).
2026-06-26 17:43:37 -07:00
hanzo-dev 220b81943a chore(deps): bump hanzoai/ai -> aa326e8a (Message []struct JSON columns)
Picks up JSONList[T] (sql.Scanner + driver.Valuer over JSON) for
Message.VectorScores/Suggestions/ToolCalls/SearchResults, fixing the dbx
'unsupported type []model.SearchResult, a slice of struct' 500 that killed
console2 sign-in (welcome-message insert) and every hanzo.chat AI message
save. No new deps; transitive hunyuan -> v1.3.48 (already required by ai main).
2026-06-26 17:25:00 -07:00
hanzo-dev 43a2ac572c fix(build): go.sum direct-live hashes for re-tagged luxfi age/keys/zap
luxfi re-tagged age v1.5.0, keys v1.1.0 and zap v0.8.8 in place. The public
module proxy serves each tag's first-seen (now stale) content, while the
Dockerfile fetches first-party DIRECT via GOPRIVATE — so `go mod download`
hit SECURITY ERROR (checksum mismatch) against the stale proxy zip hashes
committed in go.sum. Record the live DIRECT zip hashes for all three
(the /go.mod hashes are unchanged). Verified clean in the exact build env
(golang:1.26-alpine, GOPRIVATE=hanzoai/luxfi/zap-proto, GOPROXY=proxy,direct):
go mod download + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build ./cmd/cloud ./cmd/hanzo.
2026-06-26 15:44:14 -07:00
hanzo-dev 5026140f46 fix(build): GOPROXY=proxy,direct so public nested-tag modules resolve
The cloud merge pulled in tencentcloud-sdk-go monorepo modules whose tags are
nested paths (tencentcloud/hunyuan/v1.0.1074). GOPROXY=direct forced ALL modules
through direct VCS, which CANNOT resolve those nested tags ('unknown revision') —
go mod download failed in-build (worked locally only via cache). Route public
deps through the module proxy; first-party (hanzoai/luxfi/zap-proto) still go
direct+token via GOPRIVATE, so no private leak and re-pointed tags still resolve.
2026-06-26 15:16:51 -07:00
hanzo-dev dd01e62921 Merge feat/product-search-vector-endpoints into main (union)
Union merge — keep BOTH main's and the feature branch's work:

- subsystems/subsystems.go: register main's provisioningsvc (order 120)
  AND the feature's productsvc (order 145) alongside evalsvc/plansvc/pricingsvc.
- serve.go: collapse the two parallel :9090 listeners into ONE. Keep the
  feature's robust healthSrv/healthMux lifecycle (ReadHeaderTimeout, graceful
  Shutdown, fatal-on-bind) and FOLD main's HIP-0113 /metrics into healthMux,
  so the single ops port serves /healthz /readyz /health /metrics. Keep the
  feature's /zap zapface WebSocket plane. Drop the duplicate inline ops
  goroutine (and its now-unused io import) to avoid a double-bind CrashLoop.
- config.go: union HIP-0111 IAM-issuer-from-brand AND the ZAP web-origin allowlist.
- go.mod/go.sum: union both dep sets; take the feature's newer hanzoai/ai
  (2962d31c, /v1/embeddings + /v1/rerank) and main's newer luxfi
  (database v1.19.3, threshold v1.9.9, age v1.5.0). RESTORE the feature's
  'replace sashabaranov/go-openai => hanzoai/go-openai v1.40.0' that the merge
  dropped — ai's reasoning path needs Delta.ReasoningContent (fork-only).
  go.sum regenerated via go mod tidy honoring 02f56364 (first-party sumdb skip).

Build: go build ./... green (CGO=1; only luxfi/accel ld warnings). go vet clean.
2026-06-26 14:32:30 -07:00
hanzo-dev 73945fbd69 feat(evals): mount /v1/evals/* — LLM-observability facade (HIP-0106)
evalsvc is a thin facade: proxies /v1/evals/{datasets,dataset-items,evaluators,
scores} → console's public REST API (Langfuse v3 fork, owns the eval/observability
data model) and orchestrates POST /v1/evals/runs against the in-process model
gateway (run model over dataset → score → trace). No eval logic reimplemented —
evals ARE LLM observability; cloud unifies the surface, console owns the logic.
Registered at order 145, before the AI /v1/* catch-all.
2026-06-26 14:08:08 -07:00
hanzo-dev 80acaeec59 feat(cli): hanzo cloud-control CLI (login/apps/deploy/clusters/build/k8s)
Extend cmd/hanzo with a gcloud/doctl-class control plane (client mode),
selected by the first token alongside the existing server-mode subsystem
dispatch. Thin client over IAM (hanzo.id), the platform REST control plane
(platform.hanzo.ai/v1), and the cloud /v1 API — no parallel API.

- login/logout/whoami/auth: IAM password grant, token in ~/.hanzo (0600)
- apps list|get|sync: platform apps board (declared/running/latest/drift)
- deploy: rolling zero-downtime redeploy via the container redeploy surface
- clusters list|get|create|select|install-baseline|target: dedicated DOKS
- build: platform-native (arcd) build enqueue
- k8s target; config get|set|list|path
- global --org/--output/--platform-url/--iam-issuer/--platform-token
- secrets only via env/~/.hanzo, never hardcoded; platform kubeconfig never fetched
- stdout kept machine-readable (server-graph init chatter redirected to stderr)

44 unit tests (client + command wiring via httptest). Verified live:
login -> whoami -> apps list (79 apps) -> deploy pricing (gen 4->5, zero-downtime).
2026-06-26 13:47:01 -07:00
hanzo-dev 4c9089faf3 chore(deps): bump hanzoai/ai → 2962d31c (/v1/embeddings + /v1/rerank)
Pulls hanzoai/ai feat/embeddings-rerank, which adds the OpenAI-compatible
POST /v1/embeddings and Cohere/Jina-compatible POST /v1/rerank endpoints on the
same auth + provider-routing path as /v1/chat/completions. Embeddings reuse the
already-configured OpenAI Direct key (kms://OPENAI_API_KEY); rerank needs no new
key (bi-encoder cosine over the resolved embedding model, native Jina/Cohere
proxy when keyed).
2026-06-26 13:30:26 -07:00
6020f777ee feat(provisioning): /v1 control plane that creates logical resources in live shared backends (#44)
* feat(cloud): derive IAM issuer from brand, plumb into Deps (HIP-0111)

The unified cloud binary is one artifact serving every brand's API host
(api.hanzo.ai, api.lux.cloud, api.zoo.cloud, api.cloud.pars.network). Brand
is a per-deployment value, but the IAM issuer was hardcoded to iam.hanzo.ai
for every brand and Config.IAMIssuer was never plumbed into Deps — so a lux
or zoo deployment would validate JWTs against the wrong issuer (or not at all,
subsystems having no issuer to use).

White-label the token-validation issuer by brand:
- brand.go: PUBLIC brand→IAM registry (issuer + domain). hanzo→iam.hanzo.ai,
  lux→lux.id, zoo→zoo.id, pars→pars.id, bootnode→id.bootno.de. One source of
  truth; public values live in code, not KMS.
- config: when CLOUD_IAM_ISSUER/--iam-issuer is unset, derive it from the
  brand via the registry (no longer silently iam.hanzo.ai for all brands).
- deps + build: add Deps.IAMIssuer, set from cfg in BuildDeps, so subsystems
  validate against {issuer}/v1/iam/.well-known/jwks per HIP-0111.

Root package: go build + go vet + go test green (registry + issuer-derivation
tests). The fused -tags cloud binary build is blocked by a pre-existing
workspace go.sum mismatch (luxfi/age via go.work), orthogonal to this change.

* deps: converge luxfi/threshold -> v1.9.9, fix luxfi/age v1.5.0 re-tag hash

Both threshold and age were re-tagged upstream, leaving cloud/go.sum with
hashes that no longer match what the proxy serves. threshold@v1.9.4's
recorded sum broke `go work sync` and the fused -tags cloud build:
  verifying github.com/luxfi/threshold@v1.9.4/go.mod: checksum mismatch

Converge on the single workspace-wide versions:
- threshold v1.9.4 -> v1.9.9 (latest 1.9.x; matches base + mpc)
  go.mod require bumped; go.sum gains v1.9.9 zip+go.mod sums; drops the
  unused v1.9.4 zip sum; keeps v1.9.4/go.mod (consensus@v1.25.0 still
  requires it in MVS) with the correct post-retag hash.
- age v1.5.0: corrected the stale zip hash
  (zC/Fw/ptZwAXr9nqrxmrcf8752EIl1Lq9RECp9OmCO0= ->
   G69HbSV4R3vKEH9B0CulnRaMdSdf4RalMgP8xKmxHeI=) to match the re-tagged
  module; go.mod sum was already correct. Version unchanged (v1.5.0,
  consistent with base/kms/mpc).

All hashes authoritative (match proxy + mpc/base go.sum). No suppression
flags, no downgrade.

* feat(provisioning): control plane that creates logical resources in live shared backends

Adds clients/provisioningsvc, registered at order 120 and linked by one blank
import in subsystems/subsystems.go. It turns "create a database" into a real
logical resource inside the already-live shared product backends, scoped to the
gateway-minted org (X-Org-Id / c.Org()).

Surface (kind in databases|vector|datastore|kv|search|storage|docdb):
  POST   /v1/<kind>        {"name":"<slug>"} -> 201 {id,kind,name,status,host,
                            port,username,database,connectionString,password?}
  GET    /v1/<kind>        -> 200 [{id,name,kind,status,host,port,createdAt}]
  GET    /v1/<kind>/<name> -> 200 {id,name,kind,status,host,port,username,database}
  DELETE /v1/<kind>/<name> -> 204
  (GET /v1/provisioning/health is auto-registered by Serve.)

Backends (admin creds + in-cluster .svc defaults via env):
  databases -> Postgres   (pgx)            CREATE ROLE + CREATE DATABASE
  vector    -> Qdrant      (net/http)      PUT /collections/{name}
  datastore -> ClickHouse (clickhouse-go)  CREATE DATABASE + USER + GRANT
  kv        -> Redis       (go-redis)      ACL SETUSER (keyspace-scoped)
  search    -> Meilisearch (net/http)      POST /indexes
  storage   -> S3/MinIO    (minio-go)      MakeBucket
  docdb     -> MongoDB     (mongo-driver)  createCollection + createUser

Physical resources are namespaced org_<org>_<name> so tenants never collide;
the name is validated to a slug at the boundary and all SQL identifiers are
quoted — injection-safe.

Secrets: per-resource passwords (databases/kv/datastore/docdb) are sealed in
Hanzo KMS (github.com/hanzoai/kms/sdk/go, client-side encrypted); only a
secret_ref is persisted in SQLite. When KMS is unconfigured the service
degrades safely — the password is returned once in the create response and
nothing is written in plaintext. vector/search/storage have no per-resource
password (shared key auth out of band).

Metadata lives in ONE pure-Go SQLite DB ({DataDir}/provisioning.db, modernc),
UNIQUE(org,kind,name); multi-step writes run in a transaction.

Drivers were already indirect deps; importing them promotes them with no
version bumps. Tests cover the store (insert/get/list/delete, org isolation,
duplicate->conflict), name validation, org sanitization, identifier safety,
and token generation.

* fix(provisioning): close cross-tenant physical-name collision + native kind naming

BLOCKING SECURITY FIX. physicalName folded the org→name boundary by joining
hyphen-underscored org and name, so two distinct tenants could map to ONE
physical backend resource: physicalName("acme","my-db") ==
physicalName("acme-my","db") == "org_acme_my_db" (bucketName collided too).
On KV that is a cross-tenant credential takeover (idempotent ACL SETUSER
overwrites tenant A's user/keyspace); on SQL/datastore/S3 a cross-tenant DoS
and existence oracle. UNIQUE(org,kind,name) did not protect the physical layer.

- physicalName(org,name) = "o" + hex(sha256(org))[:16] + "_" + sanitizeIdent(name):
  a FIXED-WIDTH org hash makes the boundary unambiguous, so cross-org folds are
  cryptographically negligible. bucketName derives from the (now injective)
  physical via the '_'→'-' bijection, so one guard covers every backend. Both
  stay backend-valid (Postgres 63-char identifier limit, S3 3-63 char bucket).
- store: add global UNIQUE(physical_name) index + PhysicalExists pre-check; the
  create handler now FAILS CLOSED with 409 BEFORE touching a backend on any
  residual name-fold, never silently sharing a physical resource. Row maps
  physical_name -> (org,kind,name) so names stay traceable.
- tests: injectivity (physicalName + bucketName), handler org-gate (empty
  X-Org-Id -> 403 for non-admin), KMS safe-degrade (password returned once,
  nothing persisted in plaintext, secret_ref empty).

NATIVE NAMING (Hanzo brand: product name, never upstream OSS name):
kind "databases"->"sql", "storage"->"s3"; final set = sql, vector, datastore,
kv, search, s3, docdb. env CLOUD_STORAGE_*->CLOUD_S3_*. Wire connection schemes
(postgres://, redis://, mongodb://) unchanged — protocol, not branding.

Minor: package-level Shutdown closes the store (mirrors plansvc); comment that
trusting X-User-IsAdmin is acceptable (blast radius = literal "admin" bucket).

---------

Co-authored-by: zeekay <z@zeekay.io>
2026-06-26 13:15:36 -07:00
hanzo-dev 3dd0142243 chore(deps): bump hanzoai/ai → 41869ad8 (self-scoped /v1/update-preferences)
Picks up the account-backed user-preferences endpoint so console2 (and any
product) can persist cross-product, cross-device customizations onto the IAM
user account.
2026-06-26 11:15:07 -07:00
hanzo-dev 8512a9fde7 feat(productsvc): expose console Search/Vector panels on cloud-api
The console Search/Indexes and Vector panels are hardcoded to call
api.cloud.hanzo.ai/api/search-docs/* and /api/vector/* with a bearer
service key. cloud-api owns those paths now: productsvc proxies them to
the in-cluster Meilisearch (search.hanzo.svc) and Qdrant (vector.hanzo.svc)
and translates each upstream response into the exact JSON the console's
tRPC routers decode (SearchIndex/SearchStats, VectorCollection/VectorStats).

Read-only, shape-translating glue — no search/vector logic reimplemented.
Bearer key enforced with a constant-time compare (gateway bypasses these
paths via AUTH_PUBLIC_PATHS since the key is opaque, not a JWT). Endpoints
degrade to an honest empty body when the upstream is unreachable, matching
the console panels' graceful-empty contract.

Verified locally against the live search/vector services: 6 real indexes
(43,162 docs), 2 real Qdrant collections; wrong/absent key -> 401.
2026-06-25 15:18:51 -07:00
894cadd292 chore: bump luxfi/database v1.19.3 (#41)
Co-authored-by: zeekay <z@zeekay.io>
2026-06-25 15:15:47 -07:00
896494df48 deps: converge luxfi/threshold → v1.9.9, fix luxfi/age v1.5.0 re-tag hash (#43)
* feat(cloud): derive IAM issuer from brand, plumb into Deps (HIP-0111)

The unified cloud binary is one artifact serving every brand's API host
(api.hanzo.ai, api.lux.cloud, api.zoo.cloud, api.cloud.pars.network). Brand
is a per-deployment value, but the IAM issuer was hardcoded to iam.hanzo.ai
for every brand and Config.IAMIssuer was never plumbed into Deps — so a lux
or zoo deployment would validate JWTs against the wrong issuer (or not at all,
subsystems having no issuer to use).

White-label the token-validation issuer by brand:
- brand.go: PUBLIC brand→IAM registry (issuer + domain). hanzo→iam.hanzo.ai,
  lux→lux.id, zoo→zoo.id, pars→pars.id, bootnode→id.bootno.de. One source of
  truth; public values live in code, not KMS.
- config: when CLOUD_IAM_ISSUER/--iam-issuer is unset, derive it from the
  brand via the registry (no longer silently iam.hanzo.ai for all brands).
- deps + build: add Deps.IAMIssuer, set from cfg in BuildDeps, so subsystems
  validate against {issuer}/v1/iam/.well-known/jwks per HIP-0111.

Root package: go build + go vet + go test green (registry + issuer-derivation
tests). The fused -tags cloud binary build is blocked by a pre-existing
workspace go.sum mismatch (luxfi/age via go.work), orthogonal to this change.

* deps: converge luxfi/threshold -> v1.9.9, fix luxfi/age v1.5.0 re-tag hash

Both threshold and age were re-tagged upstream, leaving cloud/go.sum with
hashes that no longer match what the proxy serves. threshold@v1.9.4's
recorded sum broke `go work sync` and the fused -tags cloud build:
  verifying github.com/luxfi/threshold@v1.9.4/go.mod: checksum mismatch

Converge on the single workspace-wide versions:
- threshold v1.9.4 -> v1.9.9 (latest 1.9.x; matches base + mpc)
  go.mod require bumped; go.sum gains v1.9.9 zip+go.mod sums; drops the
  unused v1.9.4 zip sum; keeps v1.9.4/go.mod (consensus@v1.25.0 still
  requires it in MVS) with the correct post-retag hash.
- age v1.5.0: corrected the stale zip hash
  (zC/Fw/ptZwAXr9nqrxmrcf8752EIl1Lq9RECp9OmCO0= ->
   G69HbSV4R3vKEH9B0CulnRaMdSdf4RalMgP8xKmxHeI=) to match the re-tagged
  module; go.mod sum was already correct. Version unchanged (v1.5.0,
  consistent with base/kms/mpc).

All hashes authoritative (match proxy + mpc/base go.sum). No suppression
flags, no downgrade.

---------

Co-authored-by: zeekay <z@zeekay.io>
2026-06-25 14:49:15 -07:00
hanzo-devandGitHub 241f2758fe feat(cloud): derive IAM issuer from brand, plumb into Deps (HIP-0111) (#42)
The unified cloud binary is one artifact serving every brand's API host
(api.hanzo.ai, api.lux.cloud, api.zoo.cloud, api.cloud.pars.network). Brand
is a per-deployment value, but the IAM issuer was hardcoded to iam.hanzo.ai
for every brand and Config.IAMIssuer was never plumbed into Deps — so a lux
or zoo deployment would validate JWTs against the wrong issuer (or not at all,
subsystems having no issuer to use).

White-label the token-validation issuer by brand:
- brand.go: PUBLIC brand→IAM registry (issuer + domain). hanzo→iam.hanzo.ai,
  lux→lux.id, zoo→zoo.id, pars→pars.id, bootnode→id.bootno.de. One source of
  truth; public values live in code, not KMS.
- config: when CLOUD_IAM_ISSUER/--iam-issuer is unset, derive it from the
  brand via the registry (no longer silently iam.hanzo.ai for all brands).
- deps + build: add Deps.IAMIssuer, set from cfg in BuildDeps, so subsystems
  validate against {issuer}/v1/iam/.well-known/jwks per HIP-0111.

Root package: go build + go vet + go test green (registry + issuer-derivation
tests). The fused -tags cloud binary build is blocked by a pre-existing
workspace go.sum mismatch (luxfi/age via go.work), orthogonal to this change.
2026-06-25 14:42:24 -07:00
hanzo-dev b428adf832 fix(zapface): serve /zap via native Fiber WebSocket (zip/wsx), not net/http adaptor
The net/http adaptor path 404'd: fasthttp's synthetic ResponseWriter can't be
hijacked, so coder/websocket.Accept failed and Fiber returned 404 for /zap
(confirmed live: 'GET /zap status 404'). Switch to zip/wsx (fasthttp/websocket)
which upgrades natively. Handler now returns a zip.Handler that mints the auth
slot BEFORE upgrade (401 fail-closed), captures the cookie/bearer in the
per-connection closure, and runs the binary-ZAP read loop with ws.ReadMessage/
WriteMessage. serve.go mounts app.Get("/zap", ...). Adds fasthttp/websocket
to go.sum (zip/wsx dep). End-to-end WS integration test rewritten against a
real zip app + native upgrade — green.
2026-06-25 14:08:16 -07:00
hanzo-dev 525c3eb855 fix(serve): bind the :9090 health listener (/healthz, /readyz)
HealthListenAddr was declared but never bound — the operator's liveness
probe targets :9090/healthz and readiness :9090/readyz, so the pod failed
liveness and got SIGTERM'd in a ~90s CrashLoop (clean exit-0 'shutdown
requested'). Bind a stdlib health server on the health port serving
/healthz + /readyz (+ /health), separate from the :8000 API so the health
surface never shares failure modes with the API stack. Graceful-shutdown it
alongside the app.
2026-06-25 13:57:10 -07:00
hanzo-dev 0ffdcc43bf test(zapface): end-to-end WebSocket integration + unauth-reject
Drive a real ZAP binary frame through the full server path (WS upgrade ->
mintCap -> rpc.ParseRequest -> dispatch -> Fiber /v1/* mount -> casibase
envelope -> rpc.BuildResponse -> WS reply), asserting: real provider data
round-trips, the session cookie is replayed to the /v1 handler, query/body
mapping works, unknown method surfaces !ok, and an unauthenticated upgrade
fails closed with HTTP 401.
2026-06-25 13:46:53 -07:00
hanzo-dev e12f5020f2 feat(zapface): browser ZAP-over-WebSocket plane at /zap
Bind the scaffolded ZAP face: a WebSocket endpoint at /zap that speaks the
@zap-proto/web wire (github.com/zap-proto/go/rpc envelope + console2's inner
ZapRequest/ZapReply structs) and dispatches each call into the EXISTING /v1
casibase handlers in-process (adaptor.FiberApp) — one dispatch path, two
transports, zero duplicated business logic.

- zapface/wire.go: inner ZapRequest{method@0,payload@8}/ZapReply{ok@0,status@4,
  result@8,errorJson@16} codec + SuperJSON envelope ({"json":V}).
- zapface/dispatch.go: (method,input) -> /v1 HTTP replay; casibase
  {status,msg,data} -> ZapReply; get-* => GET+query, mutations => POST+body.
- zapface/server.go: coder/websocket upgrade, cookie/bearer auth slot
  (mintCap, fail-closed), per-frame rpc.ParseRequest -> dispatch ->
  rpc.BuildResponse.
- serve.go: mount app.All("/zap", ...) after MountAll.
- config.go: CLOUD_ZAP_WEB_ORIGINS allowlist.
- deps: promote coder/websocket + zap-proto/go@v1.3.0 (rpc pkg) direct.

Wire proven byte-exact vs the REAL @zap-proto runtime console2 ships, both
directions (TS buildRequest -> Go parse; Go reply -> TS parseResponse +
SuperJSON.parse). go test ./zapface green.
2026-06-25 13:29:13 -07:00
zeekay 20e675893d feat(serve): HIP-0113 ops listener (:9090 /healthz /readyz /metrics)
release / build-amd64 (push) Failing after 14m18s
release / notify-universe (push) Skipped
Decomplect health/ops from the product API: the ops endpoints move off the
app listener (:8000, /v1/*) onto cfg.HealthListenAddr (:9090), unauthenticated
and unversioned. Liveness (/healthz) and readiness (/readyz) are now distinct.
stdlib-only, zero new deps. Makes cloud the reference impl for HIP-0113 and
unbreaks the cloud-api probe (was /v1/health → 404 on the unified binary).
2026-06-25 12:42:17 -07:00
zeekay 02f5636432 fix(build): unpoison luxfi/age go.sum + first-party-scoped sumdb skip
release / build-amd64 (push) Failing after 24m30s
release / notify-universe (push) Skipped
luxfi/age v1.5.0 was re-pointed to a newer commit; sum.golang.org pins
the first-seen hash immutably, so a fresh build fetching our own module
hit `verifying github.com/luxfi/age@v1.5.0: checksum mismatch · SECURITY
ERROR`.

- go.sum: re-record age v1.5.0 zip h1: to live content (G69Hb… → zC/Fw…);
  /go.mod hash was unchanged.
- Dockerfile: add explicit GONOSUMDB scope (first-party only) and drop the
  fragile `rm -f go.sum && go mod download` self-heal — it masked the stale
  go.sum and re-recorded unverified hashes on any transient error. Correct
  committed go.sum + GOPROXY=direct is the one durable way.

Never global GONOSUMDB=* / GOINSECURE. Root cause is the upstream
force-re-tag practice, which must stop.
2026-06-25 00:58:58 -07:00
zeekay c2ce12b58a ci(deploy): notify universe with image-update on release
cloud had no universe dispatch → never auto-deployed. Add the same
image-update notify-universe job gateway/iam use. One contract.
2026-06-25 00:54:13 -07:00
bfc8e8ff5c build(hip-0106): unpoison module graph + tidy unified cloud binary (#40)
release / build-amd64 (push) Failing after 14m31s
* build: HIP-0106 unified binary builds pure-Go static

- commerce v1.42.5 -> v1.42.27 (pure-Go modernc SQLite + tracked embed catalogs)
- pin luxfi/kms v1.11.6 (past force-re-tagged v1.11.0)
- exclude legacy ugorji/go (gin msgpack ambiguity)
- regenerate go.sum clean (sumdb off; force-re-tag poisoning)
- Dockerfile: GOPRIVATE + GOSUMDB=off + GOPROXY=direct + gh_token secret

Produces CGO_ENABLED=0 static /cloud (206M); all subsystems link.
Follow-up: repin hanzoai/kms off the dead v1.0.x pseudo-version to v0.159.x.

* build: fresh-origin go.sum + Dockerfile self-heal (v0.2.1)

v0.2.0's go.sum was regenerated from a stale module cache, so a clean container
build mismatched the force-re-tagged hanzoai/kms/sdk/go. Regenerate from fresh
origin (matches Docker's fetch), add self-heal (rm go.sum + retry on poisoning)
+ GOFLAGS=-mod=mod. New tag (not a force-re-tag of v0.2.0 — that's the anti-pattern).

* feat(subsystems): unified cloud = app layer only; infra/edge run separately

Per CTO: the fused binary is the APPLICATION layer. Removed from subsystems.go:
- amqp (unused)
- iam → iam.hanzo.ai (Casdoor), kms → kms.hanzo.ai (luxfi/kms): isolated control plane
- mcp: own deployment
- gateway, ingress: the edge (route *to* this binary)

Keeps: ai, authz, base, commerce, licensing, metrics, o11y, vfs, plansvc, pricingsvc.
Binary 154M→79M; v0.3.0 ships uncompressed (no UPX). Validated: boots ready on
SQLite with the default set, infra/edge excluded.

* build(deps): bump zap-proto/go to v1.3.0 (indirect)

* build(hip-0106): unpoison module graph + tidy unified cloud binary

- re-record poisoned luxfi/* + hanzoai/* go.sum hashes (threshold/keys/kms
  and kms SDK force-re-tagged at same version; origin authoritative,
  GOSUMDB off + GONOSUMDB covers both orgs).
- replace mattn/go-sqlite3 v2.0.3+incompatible (deleted upstream tag) -> v1.14.16.
- go mod tidy drops the unimported direct require hanzoai/gateway
  v2.9.7+incompatible (gateway subsystem is a separate deploy, not yet
  wired into the unified binary's subsystems.go).
- cmd/cloud and cmd/hanzo build green for darwin + linux/amd64.

---------

Co-authored-by: zeekay <z@zeekay.io>
2026-06-24 19:21:21 -07:00
hanzo-dev da5e309179 deps(kms): refresh hanzoai/kms@v0.159.1 hash after brand-scrub history rewrite
kms history was rewritten to remove white-label brand leaks
(Liquidity/redacted); the v0.159.1 tag now points at a rewritten commit
with a new tree hash. go.mod content unchanged (only h1: tree hash
changes). cloud builds green (go build ./... exit 0). go mod verify: all
modules verified.
2026-06-24 18:05:23 -07:00
hanzo-dev 26f95bef0e deps: bump luxfi/kms -> v1.11.7 (clean tag after OSS brand-hygiene history rewrite) 2026-06-24 17:07:03 -07:00
hanzo-dev 6f37e71349 chore(deps): bump ai to d9c02eca (ratelimit tier ?user= fix)
Pulls hanzoai/ai#fix(ratelimit): tier lookup queries commerce by org slug
(?user=) instead of ?apiKey=, fixing the 400 that starved paid orgs of
their rate limits. No other dep changes (age/threshold lines reordered,
same immutable bits).
2026-06-24 04:39:22 -07:00
hanzo-dev bce0b5a859 build: pin luxfi/age+threshold go.sum to PROXY bits (Dockerfile is proxy-first)
release / build-amd64 (push) Failing after 26s
The prior commit refreshed these via local direct fetch (GOPRIVATE), recording
the GitHub-rewritten bits. The cloud Dockerfile fetches via proxy.golang.org
first (GONOPROXY=hanzoai only), so the build downloaded the proxy bits and
failed go.sum verification on threshold@v1.9.4/go.mod. Restored to the exact
proxy hashes from v1.785.13 (which built clean). Only the ai bump remains the
real go.mod/go.sum delta.
2026-06-23 22:07:54 -07:00
hanzo-dev e9c0e62f21 deps(ai): bump to 83876bf0 — hk- API-key resolution uses /v1/iam/get-user
release / build-amd64 (push) Failing after 29s
Pulls the ai fix where the controller hk- key lookup hit the legacy
/api/get-user (served as @hanzo/id SPA HTML, breaking API-key auth on
/v1/chat/completions). Refreshes go.sum for force-retagged luxfi/age@v1.5.0
and luxfi/threshold@v1.9.4 (upstream re-tag drift, GOPRIVATE — not caused by
this change). cloud (CGO_ENABLED=0) builds clean.
2026-06-23 22:00:55 -07:00
hanzo-dev 2a24345608 deps: bump ai -> 91659573 (complete per-org balance sweep)
release / build-amd64 (push) Failing after 26s
Folds in the zap-native + scraper per-org balance fixes so EVERY balance
check (gate, controller backstop, ZAP premium gate, zap balance query,
scraper preflight) reads the one per-org balance. Final image for the
per-org billing unification.
2026-06-23 21:33:13 -07:00
hanzo-dev d5b2e43e66 deps: bump ai -> e6402611 (per-org balance backstop)
release / build-amd64 (push) Failing after 1m8s
Completes the per-org billing unification: both the BalanceGateFilter AND the
resolveProviderForUser backstop now key by org slug + stamp X-Hanzo-Org, so the
single per-org credit is the balance every LLM call checks.
2026-06-23 21:27:09 -07:00
Hanzo 46b6f1d41d deps: bump ai -> ee423689 (zen→DO-AI routing + provider secret self-heal)
release / build-amd64 (push) Failing after 31s
Makes the LLM layer real:
- zen3/zen4/aliases re-pointed from dead Fireworks serverless to DO-AI
- do-ai provider key unified to kms://DO_AI_API_KEY (env-first resolution)
- provider re-seed self-heals ClientSecret/ProviderUrl/State on boot
2026-06-23 21:17:53 -07:00
hanzo-dev 0037bc76c7 build: resync luxfi go.sum to proxy/checksum-DB bits (fix re-tag drift)
release / build-amd64 (push) Failing after 32s
Several luxfi modules were force-rewritten upstream so go.sum captured the
rewritten direct-fetch bits, which conflict with the proxy's checksum-DB
artifacts: luxfi/age v1.5.0, luxfi/threshold v1.9.4, luxfi/zap v0.8.8.
Combined with the GOPROXY split (luxfi via proxy, hanzoai/zap-proto direct),
go.sum now pins the proxy/sumdb-authoritative hashes. luxfi stays in GONOSUMDB
so the few proxy-absent versions (e.g. luxfi/constants@v1.5.8 → 404) fall to
direct without a sumdb-lookup error while still pinned by go.sum.

Validated locally with the exact Dockerfile env: full 'go mod download' +
'CGO_ENABLED=0 go build ./cmd/cloud' succeed, 'go mod verify' = all modules
verified, binary embeds ai v1.785.9-...-44cd5f9a (per-org balance gate).
2026-06-23 21:12:47 -07:00
hanzo-dev 449c222834 build: split GONOPROXY/GONOSUMDB so luxfi/* resolves via proxy (fix age re-tag)
release / build-amd64 (push) Failing after 31s
GOPRIVATE forces BOTH direct-fetch and sumdb-bypass for every match, so
luxfi/age went direct to GitHub and hit the force-rewritten v1.5.0 tag
(h1:KEjq... != go.sum/sum.golang.org h1:G69H...), failing go mod download.
All luxfi/* modules we use are on the public proxy, so drop luxfi/* from
GONOPROXY (keep only hanzoai/* + zap-proto/*, whose just-pushed pseudo-
versions the proxy 404s). luxfi/* now resolves via proxy.golang.org =
immutable checksum-DB bits matching go.sum. Validated locally with the exact
Dockerfile env: luxfi/age proxy-clean, hanzoai/ai direct-clean.
2026-06-23 21:04:20 -07:00
hanzo-dev af0dba0709 build: proxy-first GOPROXY so force-rewritten upstream tags can't poison builds
release / build-amd64 (push) Failing after 35s
luxfi/age v1.5.0 was re-pushed on GitHub with content differing from the bits
sum.golang.org recorded (h1:G69H... original vs h1:KEjq... rewritten), so the
GOPRIVATE-forced direct fetch failed go.sum verification. Public luxfi/* are all
on the proxy; resolve through proxy.golang.org first (immutable, checksum-DB
artifacts) and fall back to direct only for repos the proxy 404s (private). Pins
public deps to verified bits; private resolution unchanged.
2026-06-23 20:58:16 -07:00
hanzo-dev 88229f300f deps: bump ai -> 44cd5f9a (per-org LLM balance gate)
release / build-amd64 (push) Failing after 33s
Unifies the LLM balance gate with commerce's per-org credit: the gate now
keys billing by org slug and stamps X-Hanzo-Org so a single per-org credit
(X-Org-Id=<org>) is the balance the gate checks and usage debits. Fixes
insufficient_balance on funded orgs (gate previously queried per-user in the
default 'hanzo' namespace).
2026-06-23 20:53:12 -07:00
hanzo-dev c870e8da01 deps: bump ai -> f3a36aa2 (iam SDK /v1/iam GetUrl + signout nil-guard)
release / build-amd64 (push) Failing after 39s
Fixes console2/cloud login end-to-end: the IAM SDK now loads the app cert from
/v1/iam/* so Signin's ParseJwtToken succeeds (was 'iamsdk: not valid PEM'),
establishing a real session that admin endpoints accept.
2026-06-22 01:01:45 -07:00
hanzo-dev 91bb757af0 build: drop re-added stale luxfi/threshold go.sum entry (re-tagged upstream)
release / build-amd64 (push) Failing after 32s
2026-06-21 20:55:49 -07:00
hanzo-dev 8817d2822c deps: bump hanzoai/dbx -> 6b6ceb7 (composite fields as JSON)
Fixes get-account 'unsupported type []model.SearchResult, a slice of struct'
and the matching scan errors — Message.SearchResults/VectorScores/Suggestions/
ToolCalls and all slice/map model fields now round-trip via JSON in the data
layer. Completes SQLite-native cloud-api login.
2026-06-21 20:54:03 -07:00
hanzo-dev 8a8bd51cb3 build: tolerate re-pushed private module tags (GOFLAGS=-mod=mod)
release / build-amd64 (push) Failing after 32s
luxfi/threshold@v1.9.4 is being re-tagged upstream, so its checksum drifts
from go.sum and 'go mod download' fails in CI. Record private-module hashes at
build time (-mod=mod; GOPRIVATE keeps them off the public sumdb) and drop the
stale threshold entry so it re-records cleanly.
2026-06-21 19:42:28 -07:00
hanzo-dev e103d951b0 deps: bump hanzoai/ai -> 152107f4 (dbx.Sync creates casibase schema on SQLite)
Unblocks the Base/SQLite cloud-api: the ai subsystem now creates its tables
from the Go structs on a fresh embedded SQLite store (no external migrations).
2026-06-21 19:37:14 -07:00
hanzo-dev 42aba75756 deps: bump hanzoai/ai -> v1.785.9-0...a98523d4 (StringList []string scan fix)
release / build-amd64 (push) Failing after 34s
Pins the merged ai main commit that adds StringList (sql.Scanner/Valuer over
JSON) for list columns — fixes the casibase data-layer panic
'unsupported Scan ... string into *[]string' that broke OAuth sign-in.
2026-06-21 19:15:26 -07:00
hanzo-dev 2aeba28467 deps: bump hanzoai/ai v1.785.7 -> v1.785.8 (CopyRequestBody for POST body parsing)
v1.785.8 sets beego CopyRequestBody in Bootstrap so the unified binary's AI
controllers can read POST bodies (json.Unmarshal of c.Ctx.Input.RequestBody);
without it /v1/chat/completions returned 'unexpected end of JSON input'. Completes
the unified-AI serve path: routing (bare /v1/*) + no-panic (session mgr) +
scratch-safe (memory sessions) + body parsing (CopyRequestBody).
2026-06-21 10:56:59 -07:00
hanzo-dev ad3a2fc688 deps: bump hanzoai/ai v1.785.6 -> v1.785.7 (memory session provider for scratch image)
v1.785.7 adds the scratch-safe memory session provider on top of the bare /v1/*
mount + session-manager build. Without it the unified binary 503'd on every
request (file session provider can't write in the read-only scratch root). With
all three fixes, /v1/chat/completions and the other OpenAI routes serve through
the unified binary.
2026-06-21 10:40:18 -07:00
hanzo-dev c771a04f0c fix(pricing): drop bare /v1/models alias so AI owns the OpenAI model list
In the unified binary the pricing subsystem (order 112) mounted a bare /v1/models
alias that shadowed the AI subsystem's (order 150) OpenAI-compatible /v1/models —
the {data:[{id,…}]} model list the api.hanzo.ai gateway forwards to cloud-api and
clients (cowork model picker) consume. Pricing's annotated catalog already lives
at /v1/pricing/models, so the bare alias only introduced a shape regression.
Remove it; pricing stays strictly under /v1/pricing/*. Now /v1/models, like the
other OpenAI routes, resolves to AI's beego handler via its /v1/* catch-all.
2026-06-21 10:32:25 -07:00
hanzo-dev 04b00a06a6 deps: bump hanzoai/ai v1.785.5 -> v1.785.6 (bare /v1/* mount + session manager)
v1.785.6 carries BOTH unified-binary fixes:
1. AI mounts casibase routes at bare /v1/* (not /v1/ai/*) so the api.hanzo.ai
   gateway, which forwards /v1/chat/completions etc. unchanged, resolves.
2. beego session manager built in Bootstrap so forwarded requests don't panic.

Together these make /v1/chat/completions, /v1/chat, /v1/models, /v1/messages
serve through the unified binary exactly as the gateway sends them.
2026-06-21 10:29:05 -07:00
hanzo-dev 670b526223 deps: bump hanzoai/ai v1.785.4 -> v1.785.5 (build beego session manager in Bootstrap)
ai v1.785.5 fixes the embedded /v1/ai/* HTTP 500: the unified binary never
calls beego.Run(), so beego.GlobalSessions was nil and every forwarded request
panicked in SessionStart. v1.785.5 builds the session manager in the shared
Bootstrap, so /v1/ai/chat/completions, /v1/ai/models and all nested routes
serve. Cloud binary builds green against it.
2026-06-21 10:14:16 -07:00
hanzo-dev 15d4859c07 ci(build): self-contained arcd build, GHCR login via GH_PAT
The ghcr.io/hanzoai/cloud package is linked to hanzoai/ai (cloud->ai rename),
so this repo GITHUB_TOKEN is denied write (permission_denied: write_package) via
the shared workflow. Build self-contained on the hanzo-build-linux-amd64 scale
set and log into GHCR with GH_PAT (admin:org+write:packages). gh_token still
feeds the Dockerfile private-module fetch. Dropped GHA cache (same denial +
artifact quota).
2026-06-21 09:31:43 -07:00
hanzo-dev 6653d1d23c deps: consume gateway/v2 v2.14.8 (proper /v2 module path)
gateways v2 tags were invalid Go modules (go.mod lacked the /v2 path), so
gateway v2.9.7+incompatible could not resolve on a clean fetch and cloud failed
to build. gateway v2.14.8 fixes the module path; import the /v2 path in the
subsystems bundle and pin v2.14.8. Build + go mod verify clean; binary boots
with no init panic.
2026-06-21 09:23:34 -07:00
hanzo-dev 7509d90a49 deps: pin gateway v2.9.6+incompatible (v2.9.7 added a go.mod without /v2 path)
gateway v2.9.7 introduced a go.mod still declaring module path
github.com/hanzoai/gateway at a v2 tag, which makes v2.9.7+incompatible an
invalid version (a module with a go.mod at major>=2 must use a /vN path).
v2.9.6 is the last go.mod-free v2 tag, so +incompatible is valid there; API is
identical for the subsystem blank-import. One patch down, no code change.
2026-06-21 09:10:37 -07:00
hanzo-dev f5e09f0f79 deps: refresh go.sum for force-pushed private tags (hanzoai/base v1.3.2, luxfi/threshold v1.9.4, ...)
Several private module tags were re-tagged after the committed go.sum was
generated, so a clean CI fetch failed go.sum verification (SECURITY ERROR:
checksum mismatch). Regenerated the affected private entries from current
remote content via go build -mod=mod (versions unchanged). Build is green and
boots without panic; go mod verify passes.
2026-06-21 09:03:59 -07:00
hanzo-dev b386c527d4 ci(build): route amd64 to ARC scale set hanzo-build-linux-amd64 by name
ARC ephemeral runners only match jobs targeting the scale-set name as a label.
The shared workflows default [self-hosted,linux,amd64] matches only classic
static runners (evo pool, offline) so the job sat queued with the listener
reporting assigned-job=0. gateways successful builds use runs-on:
hanzo-build-linux-amd64 (runner hanzo-build-linux-amd64-cvs28-runner-*); pass
that as runner-amd64.
2026-06-21 08:22:13 -07:00
hanzo-dev c60d398a54 ci(build): delegate to shared arcd docker-build.yml (self-hosted, billing-immune)
GitHub-hosted runners for this org are billing-frozen (jobs fail in ~5s:
"recent account payments have failed"), so the bespoke ubuntu-latest release
workflow can never start. Use the canonical hanzoai/.github docker-build.yml
reusable workflow, which runs on the self-hosted arcd pools and injects GH_PAT
as the gh_token BuildKit secret the Dockerfile needs for private cross-org Go
modules. amd64-only (cluster arch) to complete without the arm64 pool.
2026-06-21 08:09:19 -07:00
hanzo-dev de3f7c106d ci(build): authenticate private cross-org Go modules in image build
The unified binary pulls private hanzoai/* AND luxfi/* modules; the public
proxy 404s on them and the default GITHUB_TOKEN cannot read cross-org repos, so
the Docker build failed at go mod download.

- Dockerfile: split deps layer; GOPRIVATE + BuildKit gh_token secret +
  git insteadOf to fetch private modules over authenticated git (mirrors the
  proven hanzoai/ai Dockerfile). COPY --chmod=0755 the binary so the scratch
  image can never ship a non-executable /cloud (the 0644 CrashLoop class).
- release.yml: source the gh_token from the org GH_PAT (cross-org RO PAT that
  actually exists), not the never-configured HANZO_GH_RO_TOKEN.
2026-06-21 08:07:41 -07:00
hanzo-dev 2efdd3f46b deps: cut cloud-api onto unified binary — ai v1.785.4 (+#29 runtime init, /v1/billing path), beego v2.3.10 grace guard, go-openai fork
Fixes user-token AI on api.hanzo.ai:
- ai v1.785.4: AI runtime initializes in Mount() (#29) so /v1/ai/* serves real
  completions (no more 503 "ai runtime not initialized"); ai self-meters
  Commerce on the correct /v1/billing/* path (1.784.2 casibase used the dead
  /api/v1/billing/* -> 404 for real user JWTs, blocking the prepaid balance
  gate + usage auto-debit).
- beego v2.3.10: grace flag-registration guard so the unified binary (ai beego
  v1 + iam beego v2) does not panic ("flag redefined: graceful") at init.
- ai v1.785.4 also swaps deprecated denisenkom/go-mssqldb -> maintained
  microsoft/go-mssqldb (one mssql driver registration; no "sql.Register called
  twice" panic).
- replace sashabaranov/go-openai => hanzoai/go-openai v1.40.0 (ReasoningContent
  field) — mirrors ai own replace, which does not transit to this main module.

Verified: CGO_ENABLED=0 go build ./cmd/cloud produces a 316MB executable that
boots clean (base + full subsystem set) with no init panic; full boot stops
only on expected in-cluster config (IAM_KEYS_URL/IAM_AUDIENCE), supplied by the
cloud-api CR env.
2026-06-21 08:05:32 -07:00
Antje Worring 27df3a158e cloud: pin commerce/metering v0.1.0 (drop local replace) 2026-06-20 17:31:18 -07:00
Antje Worring 00fbd6d37b cloud: zip-native fail-closed billing gate (wraps commerce/metering) 2026-06-20 17:13:49 -07:00
z f7b364db90 deps: pin luxfi/kms v1.11.6 (published) — pkg/iam v1.18.5 referenced phantom v1.11.3 2026-06-19 01:15:08 -07:00
z f245d5a3da deps: pin goldap-free iam (pkg/iam v1.18.5, iam v1.19.6) — resell-clean (zero GPL-2.0) 2026-06-19 01:08:48 -07:00
antje 3b4ee86dce chore: add LICENSE (Apache-2.0) — Hanzo-native 2026-06-19 00:39:25 -07:00
hanzo-dev 72f9780311 chore: add Apache-2.0 LICENSE (Copyright 2026 Hanzo AI Inc) 2026-06-18 23:40:49 -07:00
d77e554b7e refactor(cmd): single subsystems bundle — define the mounted set once (DRY) (#19)
cmd/cloud and cmd/hanzo each blank-imported the same 16-subsystem list, so
adding/removing a subsystem meant editing two files (repeat-yourself). Move
the list into one package, github.com/hanzoai/cloud/subsystems; both
entrypoints blank-import only that. One source of truth for what's linked into
a Hanzo binary — dispatcher and full-surface binary mount an identical set by
construction.

(Bundle is a sibling subpackage, not the root cloud package: subsystems import
cloud for Deps+Register, so a root bundle would cycle.)

Verified: go build -tags 'cloud cloud_mount' . ./subsystems ./cmd/cloud
./cmd/hanzo green (-mod=readonly); hanzo --help still lists 18 subcommands
(16 subsystems + cloud + datastore); go test ./... green.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 09:29:28 -07:00
e075f7a438 fix(deps): commerce v1.39.1->v1.42.5 — mount commerce into the unified binary (#18)
cloud pinned commerce v1.39.1, which predates commerce's cloud-mount
integration: cloud.Register("commerce", 100, ...) in init() behind
//go:build cloud, added at v1.42.5. So commerce silently was NOT in the fused
surface or hanzo's subcommands. v1.42.5 (latest tag) registers correctly —
`hanzo --help` now lists commerce; the unified binary composes 16 subsystems.
iam stays v1.19.4 (no cascade). go build -tags 'cloud cloud_mount' + full
go test ./... green.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 09:21:19 -07:00
cea54a96b0 feat(cmd): land unified hanzo binary + extract shared cloud.Serve (DRY) (#17)
Adds cmd/hanzo — one binary dispatched by subcommand: `hanzo <svc>` serves one
subsystem, `hanzo cloud` the full fused surface, `hanzo iam` the standalone
Beego IdP (iamserver.Run), `hanzo datastore` documents the ClickHouse boundary.

DRY: the cloud-server body (compose root + HIP-0106 /v1/<name>/health contract
+ graceful shutdown) is extracted from cmd/cloud's main() into cloud.Serve(enable)
— the ONE shared place. cmd/cloud now calls cloud.Serve(nil), gaining graceful
shutdown + health endpoints (strict superset of its prior body, no regression).
cmd/hanzo dispatches through the same cloud.Serve.

Beego non-collision holds: iam registers routes inside iamserver.Init(), not
package init(); one Beego v2 path; visor (Beego v1) intentionally unlinked.
iam v1.19.4 moves indirect->direct (cmd/hanzo imports iam/iamserver).

Verified: go build -tags 'cloud cloud_mount' . ./cmd/cloud ./cmd/hanzo green
(-mod=readonly), go vet clean, hanzo --help lists 17 subcommands.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 05:47:13 -07:00
ae8c0cea98 fix(deps): iam v1.19.1→v1.19.4 + pkg/iam v1.18.1→v1.18.4 — fix cloud-api boot panic (#16)
iam v1.19.0–v1.19.3 panic at boot: routers/router.go registered
GET /v1/iam/run-authz-command → ApiController.RunAuthzCommand, a method never
added, so Beego panics at route registration when iamserver.Init() runs (this
hit cloud-api too). v1.19.4 (cut from iam main: dangling route removed +
initAdminUser seeds via conf.AdminOrg) fixes it. Transitive zap-proto/go
v0.3.0→v1.1.0 required by pkg/iam v1.18.4. go build -tags 'cloud cloud_mount'
./cmd/cloud green; -mod=readonly verified.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 05:36:42 -07:00
14bda07881 fix(go.sum): re-record re-tagged hanzoai/lux module checksums (unblocks build) (#15)
A chain of re-tagged modules produced checksum SECURITY ERRORs that
block 'go build ./...':
  - github.com/hanzoai/base@v1.3.2          (h1)
  - github.com/luxfi/threshold@v1.9.4       (h1)
  - github.com/hanzoai/kms/sdk/go@v1.0.0    (h1 + go.mod)
Re-record the current proxy hashes. go build ./... -> exit 0 (cmd/cloud links).

Co-authored-by: zooqueen <dev@hanzo.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 00:53:19 -07:00
Abhishek KrishnaandGitHub 4de40e2396 fix(go.sum): refresh stale hash for retagged luxfi/threshold (#7)
github.com/luxfi/threshold v1.9.4 was re-pushed at the same version
tag without updating go.sum in this repo. The recorded h1: digest no
longer matches the upstream zip on proxy.golang.org, so cold clones
fail at `go mod verify` / `go build`:

  verifying github.com/luxfi/threshold@v1.9.4: checksum mismatch
      downloaded: h1:H69e9QkvygDtWkni1FkD5ztLdiTasmpJXcuVzgebdxo=
      go.sum:     h1:/TsgIzo/e/DIx++J0+9eNuS7HkpSaXbVk+HvhlUOsmE=
  SECURITY ERROR

Regenerating go.sum (single line update) restores parity with
upstream and the build succeeds.

Reproduction (pre-fix):
  git clone https://github.com/hanzoai/cloud
  cd cloud && GOPRIVATE='github.com/hanzoai/*,github.com/luxfi/*' \
    go build ./cmd/cloud
  # verifying github.com/luxfi/threshold@v1.9.4: checksum mismatch

No code changes outside go.sum.
2026-06-16 20:28:51 -07:00
Abhishek KrishnaandGitHub 7f1e0d61df ci: add release workflow to publish ghcr.io/hanzoai/cloud (#8)
The README advertises `docker run -p 8080:8080 ghcr.io/hanzoai/cloud:latest`
but there's no release pipeline in the tree to produce that image.
This workflow publishes the Docker image on git-tag push (matching
v*), on default-branch push (as :latest and :sha-<short>), and on
manual dispatch.

Includes a Buildx secret pattern for fetching private upstream
modules (hanzoai/iam, hanzoai/commerce, hanzoai/gateway, ...) using
either the default GITHUB_TOKEN or an org-level HANZO_GH_RO_TOKEN
when cross-org private reads are required.
2026-06-16 20:28:47 -07:00
Abhishek KrishnaandGitHub 18738d56b2 chore: add helm/cloud minimal chart (#9)
Reference Helm chart that renders a single Deployment + Service +
optional PVC for the unified cloud binary. Mirrors the binary's
CLI flags via .Values.hanzo (brand, domain, dataDir, iamIssuer,
enable). Pod runs as nonroot UID 65532 matching the Dockerfile.

Not a substitute for luxfi/operator + Service CRD — this chart is
for users who want raw k8s manifests without installing the
operator first.
2026-06-16 20:28:44 -07:00
Abhishek KrishnaandGitHub 942feca4d7 chore: add deploy/compose.yml for single-node VPS deployment (#6)
Reference Docker Compose manifest for the unified cloud binary, with
matching .env.example and a deploy/README.md that documents required
vs optional environment variables (the binary refuses to mount IAM
without HANZO_IAM_ISSUER, so make that explicit).

No code or config changes outside deploy/.
2026-06-16 20:28:41 -07:00
Abhishek KrishnaandGitHub ad7828553e chore(scripts): add scripts/smoke-runtime.sh — boot + probe the real binary (#11)
`cmd/cloud-smoke` (existing) exercises the in-process mount path on a
mock zip.App with two health endpoints. It does not boot the actual
`cmd/cloud` binary or hit the HTTP surface customers will use.

This script closes that gap: it builds `./cmd/cloud`, boots it under
the same default-safe `--enable` list used in deployments (omits the
`iam` subsystem until the v1.19.2 boot panic is patched), waits for
the listener to bind, then probes the five endpoints whose expected
status is fixed by the HIP-0106 contract:

  /healthz                200   process health probe
  /v1/models              200   model catalog (no auth)
  /v1/plans               200   plansvc (goja-hosted)
  /v1/pricing             200   pricingsvc (goja-hosted)
  /v1/base/collections    401   base alive, auth-gated

If any probe regresses, the script dumps the tail of the boot log and
exits non-zero — making it usable as a CI gate and as a local "does my
clone actually serve?" check.

Env knobs (`PORT`, `LISTEN`, `BIN`, `DATA_DIR`, `ENABLE`,
`KEEP_RUNNING`, `BOOT_TIMEOUT`) let it drop into different
environments without a Makefile change. The `IAM_*` env vars default
to the production hanzo.id JWKS — required by `kms` for inbound JWT
validation even with `iam` disabled — and can be overridden per
deployment.

Pairs with the Makefile in #5: `make smoke` already exists for the
mount-time path; this is the runtime counterpart and can be wired as a
sibling target (`make smoke-runtime`) in a follow-up once #5 lands.
2026-06-16 20:28:37 -07:00
Abhishek KrishnaandGitHub 08a6ef73e7 chore: add Makefile with build/test/docker targets (#5)
Minimal developer ergonomics for the unified cloud binary. Targets
wrap go build / go test / docker build for the existing Dockerfile,
plus a `make run` shortcut that matches the README quickstart
(--enable=iam,base,kms,gateway,o11y).

No code changes outside the new Makefile.
2026-06-16 20:28:34 -07:00
Abhishek KrishnaandGitHub 4e17df928d chore: add .gitignore and .dockerignore (#10)
Repo currently has neither file. Two practical consequences:

1. `docker build .` copies the entire context including `.git/`, IDE
   metadata, OS detritus (`.DS_Store`), and any local `.env` — bloats
   the build context and risks baking secrets into image layers.
2. Without a `.gitignore`, the build output binary (`/cloud` per the
   `Dockerfile` final stage), local `.env` files, and editor leftovers
   are easy to commit by accident.

Both files cover the standard Go-project surface (binary at `/cloud`,
test outputs, coverage, env files), plus IDE/OS noise. The
`.dockerignore` additionally drops docs and tests so they don't enter
the runtime image — the binary is what ships, the README lives on
GitHub.

No behavior change; the binary the Dockerfile builds is bit-identical.
What changes is build-context size and the safety margin around
accidental commits.
2026-06-16 20:28:30 -07:00
223fc137f3 fix(auth): default CLOUD_IAM_ISSUER to https://iam.hanzo.ai (was .id typo) (#14)
IAM issues JWTs with iss=https://iam.hanzo.ai, but cloud-api defaulted the
expected issuer to https://iam.hanzo.id — so EVERY hanzo.id-login JWT was
rejected with 'invalid issuer claim (iss)' and the AI gateway's JWT auth path
was dead for all users (only hk-*/sk-* keys worked). One-char .id->.ai fix.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:55:57 -07:00
8ca298873c test(cloud): drop cmd/cloud-smoke, add real orchestrator integration test (#13)
The cloud-smoke command was a throwaway harness that hand-mounted fake health
routes and avoided the real subsystem matrix (citing build issues in cmd/cloud
that are now fixed — the full binary builds). Replaced with a proper go test in
package main that exercises the actual path:

- TestRegistryAssemblesSubsystems: every subsystem main.go imports self-registers
  via init() into cloud.Registry (proves the unified binary wires the matrix).
- TestMountAllAndServeHealth: BuildDeps -> MountAll -> serve; the self-contained
  subsystems (base, authz, amqp, metrics, plans, pricing) mount in-process and
  serve /v1/<name>/health = 200 via the real zip/fiber + jsonenc stack
  (app.Fiber().Test, no listener / external services).
- TestDepGatedSubsystemsFailClosed: ai, o11y mount and return >=500 from the
  disabled-dep stub — proving the BuildDeps three-mode contract end to end.

Discovered (not fixed here — separate subsystem bug): enabling iam panics with
"'RunAuthzCommand' method doesn't exist in the controller ApiController".

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 09:56:21 -07:00
b90e629308 fix(go.sum): refresh moved private-tag hashes so it builds clean (#12)
hanzoai/base@v1.3.2 and hanzoai/kms/sdk/go@v1.0.0 were re-tagged upstream, so
the recorded go.sum hashes no longer matched — `go build`/`go test` failed with
checksum mismatch (SECURITY ERROR) for anyone fetching fresh. Refreshed the
private-module hashes against the current tags.

Verified: go build ./... and go test ./... pass in default -mod=readonly mode.
No local replace directives (the 9 replaces are all published version pins for
the krakend/traefik gateway stack). cmd/cloud links to a single 272M binary.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 09:47:05 -07:00
Antje WorringandClaude Opus 4.8 7876d86241 feat(cloud): pin metrics v0.4.0 — ZAP MsgMetricBatch receiver + per-tenant + durable
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 14:15:56 -07:00
Antje WorringandClaude Opus 4.8 91970431f3 feat(cloud): pin metrics v0.3.0 — per-tenant observability isolation (X-Org-Id)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:44:48 -07:00
Antje WorringandClaude Opus 4.8 50add91a84 feat(cloud): pin metrics v0.2.0 — durable native metrics+logs+traces
The unified binary now serves the full native observability stack at
/v1/{metrics,logs,traces}/* — WAL-durable (survives restart, verified), zero
prometheus, zero Grafana. This is the working replacement for the Loki/Tempo/
SigNoz vendoring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 08:20:43 -07:00
Antje WorringandClaude Opus 4.8 ea1a699d9e feat(cloud): mount native hanzoai/metrics v0.1.0 (ZAP-native metrics store)
The prometheus-free replacement for the Grafana/Prometheus observability
backends. Registers at order 40, serves /v1/metrics/{health,batch,write,query};
ingests luxfi/metric.MetricBatch (the ZAP MsgMetricBatch wire shape). Verified
live: write+query and batch+query roundtrips return correct series. Binary stays
at zero prometheus. (Also refreshed the stale kms/sdk/go go.sum hash from the
wave's re-tag.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:24:44 -07:00
hanzo-devandClaude Opus 4.8 10ad830e10 build(deps): re-pin plans/pricing/licensing to release tags; drop local replace
Replace the dev pseudo-versions + the local `replace github.com/hanzoai/licensing
=> ../licensing` directive with the published release tags now that the three
subsystems are merged + tagged:

  - github.com/hanzoai/plans     v1.2.0  (was pseudo @147eced7)
  - github.com/hanzoai/pricing   v1.3.0  (was pseudo @0c4b4c12)
  - github.com/hanzoai/licensing v0.1.0  (was v0.0.0 + replace => ../licensing)

licensing@v0.1.0 requires github.com/hanzoai/cloud@v0.0.0-00010101...; that
self-reference resolves to this main module, so no replace is needed. go.mod/
go.sum are tidy and `go build -mod=readonly ./cmd/cloud` produces the 303M
unified binary (boots with --enable=plans,pricing,licensing; all health 200).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 19:58:14 -07:00
hanzo-devandClaude Opus 4.8 58bb1843a4 Merge feat/plans-pricing-goja-clean into feat/mount-licensing
Combine the licensing Mount (PR #3) with the plans+pricing goja mounts
(PR #4) onto one branch for the unified-binary re-pin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 19:54:22 -07:00
hanzo-devandClaude Opus 4.8 bb2600bd9b feat(plans+pricing): mount @hanzo/plans + @hanzo/pricing via base+goja
Mount the Node @hanzo/plans (data) and @hanzo/pricing (Express) services
INTO the unified cloud binary under /v1/plans/* and /v1/pricing/*, running
their JS inside the dop251/goja engine (the same engine base/plugins/gojavm
uses) — per HIP-0106. No service rewrite; Ship-of-Theseus to pure Go later.

New packages (the glue — clean module boundaries, no service source copied):
  - clients/gojahost: a reusable goja VM-pool host. Compiles a service repo's
    goja/bundle.js once, pre-warms a runtime pool (mirrors gojavm's pool +
    compile-once + per-runtime ensureLoaded discipline), injects the catalog
    JSON as globals, and dispatches handle({route,params,tenant}) -> {status,
    body} with ctx-cancel interrupt. Eager-loads one runtime so a bad bundle
    fails at Mount, not first request.
  - clients/plansvc: Mount(app, deps) for /v1/plans/*. Loads the @hanzo/plans
    bundle + embedded catalog, registers zip routes (subscriptions, cloud,
    blockchain, dns, gpu, regions, storage, tools, policy, schema, vocab,
    resolve/:id, entitlements/:id), threads X-Org-Id as the tenant for
    per-reseller (tenant_id,id) catalog scoping. The entitlements.mjs
    transforms (fromLegacy/toLicenseFeatures/resolvePlan) run in goja.
  - clients/pricingsvc: Mount(app, deps) for /v1/pricing/* + /v1/models.
    Express does NOT run in goja, so the Express transport is dropped; the
    server.mjs read handlers run in goja via the bundle. The sync.mjs markup
    (toMTok/processOpenRouterModel/...) also runs in goja via applyMarkup();
    the admin-gated POST /v1/pricing/sync does the live OpenRouter fetch in Go
    (net/http) and feeds raw JSON into the goja markup. _internal (provider
    costs/routing) is stripped from public responses.

Wiring: cmd/cloud/main.go blank-imports both wrappers; each init() calls
cloud.Register (plans order 111, pricing 112, after iam/commerce/licensing).
go.mod references hanzoai/plans + hanzoai/pricing as their own private Go
modules (the JS + data live there, embedded; nothing copied into cloud).

Tests: clients/{gojahost,plansvc,pricingsvc}/*_test.go exercise the real
embedded bundles (vocab, resolve+license_features, 404s, _internal strip,
exact markup math). Verified end-to-end: binary boots with --enable=plans,
pricing; all routes serve real data through goja over HTTP; X-Org-Id tenant
scoping confirmed (reseller override-wins, isolation holds).

Also corrects a stale go.sum entry for github.com/hanzoai/kms/sdk/go@v1.0.0
(the module was retagged; the recorded hash no longer matched the origin,
blocking any build that pulls base/iam/commerce -> kms). Updated to the
current origin hash.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 18:42:54 -07:00
hanzo-devandClaude Opus 4.8 5c6beb850b feat(cloud): mount licensing subsystem + commerce entitlement-copy
Wire the private hanzoai/licensing Go subsystem into the unified cloud
binary per HIP-0106, following the iam/commerce/ai Mount(app, deps)
pattern. Clean module boundary: licensing is imported as its own private
module (its mount.go self-registers via init(); cmd/cloud blank-imports
it). No subsystem source is copied into cloud — the signer/fingerprint
secret logic stays in the licensing module.

- cmd/cloud: blank-import github.com/hanzoai/licensing (order 110, after
  iam=50 and commerce=100, since the /v1/licensing/issue flow depends on
  both identity and entitlements).
- go.mod: require licensing + local replace (co-developed private module;
  production resolves via tag/pseudo-version, drop the replace).
- types.CommerceClient: add CheckEntitlement(ctx, orgID, productID) plus
  the LicenseEntitlement transport type. This is the entitlement flow that
  gates issuance: commerce answers "does this tenant own the licensed
  product?" and returns the plan's FLAT license-features per the
  @hanzo/plans toLicenseFeatures vocab contract; the licensing mount copies
  them verbatim into the signed token's `features` so the engine enforces
  exactly the plan that was bought.
- clients: implement CheckEntitlement on the disabled (fail-closed) and
  ZAP-RPC commerce stubs; in-process pass-through already satisfies it.

Tenant-scoped via orgID (X-Org-Id). Real KMS stays a licensing follow-up
(scaffold TODO); the Mount + entitlement-copy are the deliverable here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 18:23:19 -07:00
Antje WorringandClaude Opus 4.8 1e1e017ee4 fix(cloud): pin gateway v2.9.7 (clean, no local replaces)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 17:30:42 -07:00
Antje WorringandClaude Opus 4.8 991ec0e788 fix(cloud): correct gateway pin to v2.9.6+incompatible (prev go.mod was broken)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 16:46:14 -07:00
Antje WorringandClaude Opus 4.8 8b59797b59 fix(cloud): pin gateway v2.9.6 — ZERO prometheus in the unified binary
gateway dropped the legacy opencensus SaaS exporters (stackdriver was the last
prometheus source). Combined with o11y v1.3.7 + alertmanager/krakend-otel forks +
base v1.3.2 + kms Corona, the binary now links zero real prometheus packages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 16:45:19 -07:00
Antje WorringandClaude Opus 4.8 e67bd8aa6d fix(cloud): pin base v1.3.2 — network metrics on luxfi/metric (prometheus 6->1)
Real prometheus in the unified binary is now a single leaf package
(prometheus/prometheus/model/value via a gateway dep). Down from 16+.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 16:44:05 -07:00
Antje WorringandClaude Opus 4.8 ee879c028b fix(cloud): prometheus 16->6 pkgs — krakend-otel fork + gateway v2.9.5
replace krakend-otel => hanzoai/krakend-otel v0.13.1 (prom-free fork); pin
gateway v2.9.5 (opencensus prometheus exporter removed, counters -> luxfi/metric).
With the alertmanager fork + o11y v1.3.7 + iam v1.18.1, the only prometheus left
is the hanzoai/common+alertmanager fork shim core (6 pkgs) — needs those forks to
shed prometheus/common internally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 16:41:05 -07:00
Antje WorringandClaude Opus 4.8 e0680a7030 fix(cloud): pin iam/pkg/iam v1.18.1 — kill duplicate-beego graceful flag panic
pkg/iam v1.18.0 imported upstream github.com/beego/beego/v2 while the rest of the
binary uses the hanzoai/beego prom-free fork; both register a global 'graceful'
flag in init() -> panic at startup. v1.18.1 (already fixed on iam main, just
untagged) uses the fork only. The unified binary now boots and shows the
white-label -brand/-domain/-enable tenancy flags.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 16:11:47 -07:00
Antje WorringandClaude Opus 4.8 952a5112b3 fix(cloud): green the unified binary — o11y v1.3.7 + alertmanager fork replace
o11y's prometheus->hanzoai/alertmanager replace must be carried by the main
module (cloud), since a dependency's replace is ignored by consumers. With
o11y v1.3.7 (no-prometheus) + kms v0.159.1 (Corona), the full HIP-0106 binary
(11 subsystems on zip+ZAP, /v1 routing, luxfi/log+metric) compiles end-to-end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 16:06:39 -07:00
Antje WorringandClaude Opus 4.8 d60e476ea8 fix(deps): pin hanzoai/kms v0.159.1 (Corona signing fix)
Clears the kms SignWithRingtail blocker. cloud's remaining build failure is
hanzoai/o11y v1.3.6 (incomplete prometheus/common -> hanzoai/common fork).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:49:18 -07:00
Antje WorringandClaude Opus 4.8 66574461da fix(deps): drop 13 cross-repo local replaces; pin siblings to real published versions
Build still blocked separately on kms SignWithRingtail (luxfi/kms API gap) and
o11y type-mixing — tracked upstream; this lands the no-local-replace requirement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:11:00 -07:00
734 changed files with 164315 additions and 1371 deletions
+41
View File
@@ -0,0 +1,41 @@
# VCS
.git/
.gitignore
.gitattributes
# CI / repo metadata not needed inside the build
.github/
# Docs (image runs the binary; readers visit GitHub)
*.md
LICENSE
SECURITY.md
# Already-built binary at repo root (matches .gitignore)
/cloud
# Environment files (never bake secrets into images)
.env
.env.*
# IDE / editor
.vscode/
.idea/
*.swp
*.swo
# OS metadata
.DS_Store
Thumbs.db
# Tests stay out of the runtime image
*_test.go
# Local build outputs
/dist/
/build/
/bin/
# The Dockerfile itself doesn't need to be in the context it builds
Dockerfile
.dockerignore
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640" role="img" aria-label="cloud">
<rect width="1280" height="640" fill="#0A0A0A"/>
<svg x="96" y="215" width="210" height="210" viewBox="0 0 67 67"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#fff"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#fff"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#fff"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#fff"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#fff"/></svg>
<text x="378" y="276" font-family="Inter,system-ui,-apple-system,sans-serif" font-size="78" font-weight="800" letter-spacing="-2" fill="#ffffff">cloud</text>
<text x="378" y="322" font-family="Inter,system-ui,sans-serif" font-size="30" fill="#ffffff" opacity=".66">Hanzo Cloud — unified Go binary that imports every Hanzo-native…</text>
<rect x="378" y="338" width="806" height="3" rx="1.5" fill="#ffffff" opacity=".9"/>
<text x="378" y="390" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">github.com/hanzoai</text>
<text x="1184" y="390" text-anchor="end" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">hanzo.ai</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+123
View File
@@ -0,0 +1,123 @@
name: containment
# Guards the Stage-1 byzantine ceremony containment (clients/controlplane,
# build tag `controlplane`). Its increment-1 crypto is stub/forgeable BY
# DESIGN (SHA256-of-public-inputs commitments, symmetric-HMAC
# proof-of-possession, seed-derived threshold shares — see
# clients/controlplane/doc.go) and MUST NEVER reach a release/serve binary.
# Three independent checks; any one failing blocks the PR:
#
# 1. grep (tag) — no build/release invocation anywhere in the repo
# (Dockerfile, Makefile, shell scripts, any workflow) may pass a `-tags`
# value containing `controlplane` to go build/vet/run/install. The
# package's own `//go:build controlplane` tag declarations
# (clients/controlplane/*) are the thing being guarded, not a violation,
# and are excluded by path.
# 2. grep (spoof) — no build/release invocation may pass
# `-X testing.testBinary=1` (or any -ldflags containing it) to a REAL
# `go build`. That linker flag is what `go test` itself uses to make
# testing.Testing() report true (cmd/go/internal/load/test.go) — the
# runtime guard in containment.go trusts that signal, so this is the one
# concrete way to spoof it in a non-test binary. This grep is what turns
# "someone could type this" into "CI fails the PR that types it".
# 3. build — `go build ./...` (no tag — exactly what the Dockerfile
# and Makefile run) must not link clients/controlplane into any cmd/
# main, and `go build ./clients/controlplane/...` with no tag must match
# zero buildable packages (proves the tag still gates every file in it).
#
# Runtime belt-and-suspenders (defense in depth, not a substitute for the
# above): clients/controlplane/containment.go fail-closed panics if its stub
# crypto is ever constructed outside a go-test binary (testing.Testing()==
# false) — see TestContainment_NonHarnessProcessRefuses in
# containment_test.go. KNOWN RESIDUAL: testing.Testing() is a linker-set
# string var (testing.testBinary), not cryptographically bound to "actually
# is a test" — `-ldflags="-X testing.testBinary=1"` spoofs it in a real
# binary. Check #2 above is the mitigation: it fails the PR that would ship
# that flag. Closing the residual for real needs a signal `go build` cannot
# produce at all (increment-2, tracked in doc.go) rather than one merely
# absent by convention.
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
controlplane-containment:
runs-on: [hanzo-build-linux-amd64]
steps:
- uses: actions/checkout@v4
- name: grep — no build/release path may set -tags controlplane, or spoof testing.Testing()
run: |
set -euo pipefail
hits=0
# NOTE: exclusions are plain substring matches on the path (not
# anchored to a leading "./") so this is robust across grep
# implementations that format recursive-search paths differently.
# char class includes `!` so a build-constraint negation form
# (`-tags '!x,controlplane'`) cannot slip the grep. (Belt only: the
# positive-proof step below is the syntax-agnostic guarantee — the
# package has zero untagged files, so importing it into serve code
# fails the untagged `go build ./...` regardless of any -tags syntax.)
if grep -RnE -- '-tags[= ]*["'"'"']?[!A-Za-z0-9_, ]*\bcontrolplane\b' \
--exclude-dir=.git --exclude-dir=node_modules --exclude-dir=.claude --exclude-dir=vendor \
. 2>/dev/null \
| grep -v '\.git/' \
| grep -v 'clients/controlplane/' \
| grep -v '.github/workflows/containment.yml:'; then
echo "::error::found a build/release invocation passing -tags controlplane — clients/controlplane's stub crypto must never enter a release/serve binary (see clients/controlplane/doc.go)"
hits=1
fi
if grep -RnE -- 'testing\.testBinary' \
--exclude-dir=.git --exclude-dir=node_modules --exclude-dir=.claude --exclude-dir=vendor \
. 2>/dev/null \
| grep -v '.github/workflows/containment.yml:'; then
echo "::error::found a reference to testing.testBinary outside the Go toolchain itself — this is the linker var that spoofs testing.Testing() in a real (non go-test) binary; the containment.go runtime guard trusts that signal, so setting it anywhere in a real build path defeats it (see doc.go)"
hits=1
fi
if [ "$hits" -ne 0 ]; then exit 1; fi
echo "OK: no build/release path sets -tags controlplane or spoofs testing.Testing()"
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: go env for private modules (matches Dockerfile — zap-proto is direct+authenticated)
env:
GH_PAT: ${{ secrets.GH_PAT }}
run: |
git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "https://github.com/"
{
echo "GOPRIVATE=github.com/zap-proto/*"
echo "GONOSUMDB=github.com/zap-proto/*"
echo "GOSUMDB=off"
echo "GOPROXY=https://proxy.golang.org,direct"
} >> "$GITHUB_ENV"
- name: positive proof — clients/controlplane is unreachable from the default build
run: |
set -euo pipefail
go build ./...
for m in $(go list ./cmd/...); do
if go list -deps "$m" | grep -qx 'github.com/hanzoai/cloud/clients/controlplane'; then
echo "::error::$m links clients/controlplane into a real binary — containment breach"
exit 1
fi
done
out="$(go build ./clients/controlplane/... 2>&1 || true)"
if ! printf '%s' "$out" | grep -q 'matched no packages'; then
echo "::error::clients/controlplane built successfully WITHOUT -tags controlplane (containment breach): $out"
exit 1
fi
echo "OK: containment holds — clients/controlplane has zero buildable files by default and is linked into no cmd/ binary"
+295
View File
@@ -0,0 +1,295 @@
name: release
# Cuts a release of ghcr.io/hanzoai/cloud. The invariant this workflow exists to
# enforce:
#
# a git tag v<X.Y.Z> exists ⇔ an image ghcr.io/hanzoai/cloud:v<X.Y.Z>
# was pushed AND booted to "listening" in the smoke test.
#
# The tag is a RECEIPT for a proven image, minted only AFTER a successful push —
# never a trigger for a build that might fail. The prior design triggered builds
# FROM pushed tags, so a tag could exist with no image behind it (a failed or
# never-run build) — universe would then try to roll that tag and the pods went
# ImagePullBackOff (phantom v1.786.42/43). Here the order is inverted:
#
# main push → compute next version → build → SMOKE → push image → tag → notify
#
# so a push/smoke/build failure fails the run BEFORE the tag step and leaves no
# tag; universe is only ever notified of a version whose image is proven present.
#
# DO NOT push v* tags by hand anymore. This workflow OWNS them. A hand-cut tag has
# no image behind it (exactly the phantom this prevents) and won't build (there is
# no `tags:` trigger). Every merge to main IS the release; skip one with the usual
# `[skip ci]` in the commit/merge message (a docs-only change need not ship).
#
# concurrency: a single serialized lane (cancel-in-progress:false — a
# mid-flight push must finish, never be killed between "image pushed" and "tag
# created"). Two main pushes can therefore never compute the same next number:
# the queued run starts only after the running one tags, re-reads the tags, and
# lands on the next patch. Monotonic by construction.
#
# The next version is max(highest git tag, highest pushed container tag) + 1 (patch
# bump only — never a major/minor jump). Folding in the container tags means we
# never reuse a number that already has a pushed image, even if some earlier run
# pushed an image but died before tagging.
#
# ── Infra notes (unchanged, still true) ─────────────────────────────────────────
# Self-hosted arcd amd64 scale set — NEVER GitHub-hosted runners (this org's
# GitHub-hosted Actions are billing-frozen). GHCR login uses GH_PAT, not the repo
# GITHUB_TOKEN: the ghcr.io/hanzoai/cloud package is linked to a DIFFERENT repo
# (hanzoai/ai, from the cloud→ai module rename), so this repo's GITHUB_TOKEN is
# denied write to it (permission_denied: write_package). GH_PAT (admin:org +
# write:packages) writes any hanzoai package regardless of package-repo linkage,
# and is the BuildKit gh_token the Dockerfile uses to fetch private cross-org Go
# modules. amd64-only: the cluster is amd64; one platform completes on the live
# scale set without waiting on the arm64 pool.
on:
push:
branches: [main]
workflow_dispatch:
# Cut tags on the cloud repo (git tag push) → contents: write. packages: write
# to push the image; id-token for provenance.
permissions:
contents: write
packages: write
id-token: write
# One serialized release lane. Never cancel in-flight: a run killed between
# "image pushed" and "tag created" is exactly the drift we are preventing.
concurrency:
group: release-cloud
cancel-in-progress: false
jobs:
build-amd64:
# ARC ephemeral runners match jobs targeting the scale-set NAME as a label.
runs-on: [hanzo-build-linux-amd64]
outputs:
version: ${{ steps.ver.outputs.version }}
version_v: ${{ steps.ver.outputs.version_v }}
steps:
- name: Checkout (full history + all tags — the version floor is read from tags)
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Compute next version (monotonic patch bump over git + container tags)
id: ver
env:
GH_PAT: ${{ secrets.GH_PAT }}
run: |
set -euo pipefail
git fetch --tags --force --quiet
# Highest semver git tag (vX.Y.Z), normalised without the leading v.
git_max="$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' \
| sed 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true)"
# Best-effort: highest ALREADY-PUSHED container tag, so a number that has
# an image (even from a run that died before tagging) is never reused.
cont_max=""
if command -v gh >/dev/null 2>&1; then
cont_max="$(GH_TOKEN="$GH_PAT" gh api --paginate \
'/orgs/hanzoai/packages/container/cloud/versions' \
--jq '.[].metadata.container.tags[]' 2>/dev/null \
| sed 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true)"
fi
# Floor = highest of the two; fall back to 1.786.0 only if the repo has
# no tags at all (first release ever).
max="$(printf '%s\n%s\n%s\n' "1.786.0" "$git_max" "$cont_max" \
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)"
major="${max%%.*}"; rest="${max#*.}"; minor="${rest%%.*}"; patch="${rest##*.}"
version="${major}.${minor}.$((patch + 1))"
# Refuse to proceed if the number we intend to mint already exists as a
# git tag (a concurrent run beat us — the serialized lane makes this a
# can't-happen, but fail loud rather than clobber).
if git rev-parse -q --verify "refs/tags/v${version}" >/dev/null; then
echo "::error::computed v${version} already exists as a git tag — aborting to avoid collision"
exit 1
fi
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "version_v=v${version}" >> "$GITHUB_OUTPUT"
echo "major_minor=${major}.${minor}" >> "$GITHUB_OUTPUT"
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}')"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker-container
driver-opts: network=host
- name: Log in to ghcr.io (GH_PAT — writes the cloud package despite its ai-repo linkage)
uses: docker/login-action@v3
with:
registry: ghcr.io
username: hanzo-dev
password: ${{ secrets.GH_PAT }}
- name: OCI labels
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/hanzoai/cloud
tags: type=raw,value=${{ steps.ver.outputs.version_v }}
# ── Build → SMOKE → push → tag ───────────────────────────────────────────
# 1. Build once to a LOCAL tag (load into the daemon, do NOT push). Warms
# the BuildKit cache — the expensive console/npm + Go layers land here.
# 2. Boot that exact image and assert it reaches "listening" with no
# startup-crash signature (the gate).
# 3. Re-run build with push:true and the real tags: identical context /
# platform / secrets, so every layer is a cache hit from step 1 and the
# step only exports + pushes the already-tested image. Nothing that failed
# the smoke test can reach the registry.
# 4. Only after the push succeeds, mint + push the git tag (the receipt).
- name: Build (load locally for the smoke test)
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
push: false
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.
build-args: |
CONSOLE_CACHEBUST=${{ github.sha }}
# gh_token: BuildKit secret the Dockerfile consumes to fetch private
# cross-org Go modules (hanzoai/*, luxfi/*) over authenticated git.
secrets: |
gh_token=${{ secrets.GH_PAT }}
- name: Smoke test — the binary MUST boot to "listening" with no crash signature
run: |
set -euo pipefail
IMAGE=cloud:smoke
CID=""
cleanup() { [ -n "$CID" ] && docker rm -f "$CID" >/dev/null 2>&1 || true; }
trap cleanup EXIT
# Minimal, production-representative boot env:
# • a writable ephemeral /data root — the audit store, the embedded
# KMS secrets plane and every per-tenant SQLite open files under
# CLOUD_DATA_DIR; an unwritable dir would fail EVERY image before
# MountAll and the gate would stop discriminating good from bad; and
# • a throwaway 32-byte KMS master key so the KMS plane mounts on its
# normal ready path exactly as prod does (no real secret is used).
# The subsystem that crashed the incident (metrics, mount order 40)
# mounts AFTER kms (order 10), so the boot must get past kms for the
# gate to observe the panic — this env does exactly that.
KEY="$(head -c 32 /dev/urandom | base64 | tr -d '\n')"
CID="$(docker run -d \
--tmpfs /data:rw,size=64m \
-e CLOUD_DATA_DIR=/data \
-e CLOUD_ENV=smoke \
-e CLOUD_KMS_MASTER_KEY_REF="$KEY" \
"$IMAGE")"
# Poll up to 60s for boot to either finish ("listening" is logged once
# every subsystem has mounted and both transports are about to bind) or
# die (a Mount panic exits the process). A healthy boot is ~1-2s; the
# ceiling only guards a cold daemon.
listening=0
for _ in $(seq 1 60); do
logs="$(docker logs "$CID" 2>&1 || true)"
if printf '%s' "$logs" | grep -q '"message":"listening"'; then listening=1; break; fi
if [ "$(docker inspect -f '{{.State.Running}}' "$CID" 2>/dev/null || echo false)" != "true" ]; then break; fi
sleep 1
done
logs="$(docker logs "$CID" 2>&1 || true)"
echo "::group::cloud:smoke boot logs"
printf '%s\n' "$logs"
echo "::endgroup::"
# (1) No startup-crash signature. Catches the incident's Mount
# type-assert panic AND any generic Go panic — case-insensitive so a
# re-worded variant can't slip through — BEFORE a byte is pushed.
if printf '%s' "$logs" | grep -Eiq 'metrics\.Mount|mount metrics|panic|want \*zip\.App'; then
echo "SMOKE FAIL: startup-crash signature in boot logs (see above)"
exit 1
fi
# (2) Reached "listening" — proof that MountAll returned for every
# enabled subsystem (a failed Mount returns before this line).
if [ "$listening" -ne 1 ]; then
echo "SMOKE FAIL: binary never reached \"listening\" (a subsystem did not mount)"
exit 1
fi
# (3) Still alive — a server that logged "listening" then exited (e.g. a
# listener bind failure) is not a healthy image.
if [ "$(docker inspect -f '{{.State.Running}}' "$CID" 2>/dev/null || echo false)" != "true" ]; then
echo "SMOKE FAIL: process exited after \"listening\""
exit 1
fi
echo "SMOKE PASS: cloud:smoke booted to \"listening\" with no crash signature"
- name: Push (cache hit from the smoke build — publishes the tested image)
id: push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
push: true
tags: |
ghcr.io/hanzoai/cloud:${{ steps.ver.outputs.version_v }}
ghcr.io/hanzoai/cloud:${{ steps.ver.outputs.version }}
ghcr.io/hanzoai/cloud:${{ steps.ver.outputs.major_minor }}
ghcr.io/hanzoai/cloud:sha-${{ steps.ver.outputs.sha_short }}
ghcr.io/hanzoai/cloud:latest
labels: ${{ steps.meta.outputs.labels }}
# 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.
build-args: |
CONSOLE_CACHEBUST=${{ github.sha }}
secrets: |
gh_token=${{ secrets.GH_PAT }}
# THE RECEIPT: reached only because build + smoke + push all succeeded. If
# any of them failed the job already stopped and no tag was minted.
- name: Tag the proven image (git tag = receipt for a pushed, smoke-passed image)
env:
GH_PAT: ${{ secrets.GH_PAT }}
run: |
set -euo pipefail
V="${{ steps.ver.outputs.version_v }}"
git config user.name "hanzo-dev"
git config user.email "dev@hanzo.ai"
git tag -a "$V" -m "release $V — image ghcr.io/hanzoai/cloud:$V pushed and smoke-passed (${GITHUB_SHA})"
git push "https://x-access-token:${GH_PAT}@github.com/${GITHUB_REPOSITORY}.git" "$V"
echo "Tagged $V → ghcr.io/hanzoai/cloud:$V"
# Notify universe so the GitOps pipeline rolls the new image to prod — same
# image-update contract every service uses (gateway, iam, …). Runs ONLY after
# build-amd64 succeeds, i.e. only for a version whose image is proven pushed and
# tagged. A failed release never reaches here, so universe is never asked to
# deploy a phantom tag.
notify-universe:
needs: build-amd64
runs-on: [hanzo-build-linux-amd64]
steps:
- name: Repository dispatch (image-update)
uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.UNIVERSE_DISPATCH_TOKEN }}
repository: hanzoai/universe
event-type: image-update
client-payload: |
{
"service": "cloud",
"image": "ghcr.io/hanzoai/cloud:${{ needs.build-amd64.outputs.version_v }}",
"sha": "${{ github.sha }}",
"env": "all"
}
+36
View File
@@ -0,0 +1,36 @@
# Built binaries (Dockerfile output + `go build ./cmd/hanzo`)
/cloud
/hanzo
# Local build directories
/dist/
/build/
/bin/
# Go test + coverage artifacts
*.test
*.out
coverage.txt
coverage.html
# Environment files
.env
.env.*
!.env.example
# IDE / editor
.vscode/
.idea/
*.swp
*.swo
*~
# OS metadata
.DS_Store
Thumbs.db
# Logs
*.log
.shots/
.claude/
+208
View File
@@ -0,0 +1,208 @@
# Unified IAM Auth + Tenant Billing Contract
The ONE way every Hanzo product surface authenticates a user and bills their
usage. hanzo.chat, hanzo.app, studio.hanzo.ai, and console.hanzo.ai all
implement THIS contract against `hanzoai/cloud` (api.hanzo.ai). There is no
per-app billing, no shared API key, and no second way to do any of it.
## The chain, end to end
```
Browser (a surface)
│ 1. OIDC Authorization Code + PKCE, PUBLIC client (no client secret)
IAM (hanzo.id / lux.id / zoolabs.id / pars.id …)
│ 2. issues user tokens. owner claim = the user's org (the tenant).
Surface backend / SPA
│ 3. holds the user's IAM token server-side (session / httpOnly cookie).
│ UI's actively-selected (org, project) = the tenant context.
│ 4. EVERY call to cloud forwards THAT user's IAM bearer, unchanged:
│ Authorization: Bearer <user IAM token>
│ X-Project-Id: <active project> (optional; org comes from token)
cloud (api.hanzo.ai) — SanitizeIdentity → BillingGate
│ 5. validates the JWT (JWKS sig + issuer-set + audience + exp).
│ 6. derives (org, project): org is PINNED from the verified `owner`
│ claim (client-supplied X-Org-Id is stripped); project is the
│ claim-bound X-Project-Id (else soft-scoped).
│ 7. meters against the org's shared plan allowance; overflow →
│ pay-as-you-go on the org's linked billing account.
commerce (billing/pricing) — ONE ledger, keyed on (org, project)
```
One identity (the user's IAM token), one tenant key (org from the token's
`owner`, project from the active selection), one ledger. Every surface is a thin
client of this; none of them holds a shared key or bills anything itself.
## 1. Login — OIDC Authorization Code + PKCE, PUBLIC client
- **Public client, no client secret.** The token endpoint auth method is `none`;
security comes from PKCE (`code_challenge_method=S256`) + the signed `state`,
not a shared secret baked into a browser-delivered app. A public client cannot
leak a secret it does not have.
- Strategy registration MUST NOT be conditioned on a client secret. (The chat
login outage was exactly this: `configureOpenId` was gated on
`OPENID_CLIENT_SECRET`, so a secretless public client never registered the
`openid` passport strategy → "OpenID strategy not registered".)
- The `owner` claim is the tenant org. `sub` is the user. A surface reads the org
from `owner` (fallback `organization`), never from a client-set field.
- Reference implementation: `studio/middleware/iam_auth_middleware.py`
(`_authorize_redirect` builds the PKCE authorize URL; `handle_callback` adds a
`client_secret` to the token exchange ONLY if one is configured — public by
default).
## 2. Tenant context — active (org, project)
- A user belongs to one or more orgs; the token's `owner` is the home org, and
IAM may carry the full set (`organizations`/`orgs`/`groups`).
- The UI's actively-selected org + project is the tenant context for the session.
Studio carries the active org in the `studio_active_org` cookie and validates
it against the token's org set (`middleware/session.py: resolve_org`) — a user
can only ever select an org their token authorizes.
- cloud pins the billing org from the VERIFIED `owner` claim, so even a forged
active-org header cannot move spend to another tenant. The active project is
forwarded as `X-Project-Id` and is honored as a hard scope only when it is
claim-bound; otherwise it degrades to a soft scope (cannot hard-stop, cannot be
evaded). See `middleware_billing.go: identityFromCtx`.
## 3. Forwarding to cloud — the user's token, never a shared key
- EVERY request to cloud carries `Authorization: Bearer <the signed-in user's IAM
token>`. The token is held server-side (session / httpOnly cookie) and is never
exposed to the browser JS.
- The forwarded token MUST be principal-bound to the authenticated user (`sub`
equals the session principal) and unexpired. Fail secure: if no such token is
available, DENY (401 / "sign in") — never fall back to an ambient or service
credential, which would run as the wrong principal or drain a shared org.
- NO shared keys. NO per-app keys. NO per-user minted `hk-` keys for chat. The
IAM token IS the credential and the billing identity.
- Reference implementations:
- `chat/api/server/routes/agents/cloud.js` +
`chat/packages/api/src/endpoints/custom/tenantBearer.ts` — the ONE resolver
(`resolveTenantBearer`) both the agents path and the chat-completion path use.
- The chat-completion endpoints declare `apiKey: "{{LIBRECHAT_OPENID_TOKEN}}"`;
the custom-endpoint initializer substitutes the resolved session bearer at
request time (`chat/packages/api/src/endpoints/custom/initialize.ts`).
## 4. Billing — ONE path keyed on (org, project)
cloud is the single meter and gate. `SanitizeIdentity` (mirrored in-binary by
`auth_identity.go`) validates the token and exposes `c.Org()` / `c.User()` /
`ValidatedProject(c)`. `BillingGate` (`middleware_billing.go`) then:
- **Billing key = org.** Prepaid balance is per-org: one org credit covers the
whole org. `identityFromCtx` sets `User = org` (bare `sub` only when org is
absent). Keying per-user would 402 a fully funded org.
- **Scope axes = (project, service).** `project` is the caller's claim-bound
`X-Project-Id`; `service` is SERVER-derived from the route (`/v1/ai/*` → `ai`),
never a client field, so a caller cannot spoof another service's cap.
- **Shared plan, then PAYG.** `AuthorizeVerdict` checks the org's shared plan
allowance and per-scope spend caps in one round trip; overflow bills
pay-as-you-go against the org's linked billing account. Outcomes map to a frozen
HTTP contract:
- `200` allowed (with `X-Spend-Warn: <pct>` at a soft cap threshold),
- `402 insufficient_balance` — add credits at console.hanzo.ai,
- `402 spend_cap_exceeded` — raise the scope cap at console.hanzo.ai/limits,
- `503 balance_unavailable` — commerce unreachable, fail-closed.
- **Single charge.** `/v1/ai/*`, `/v1/agents/*`, and the other self-metering
subsystems record their own per-org usage; the edge gate returns price 0 for
them so nothing is billed twice (`DefaultPrice` / `selfMeteredPrefixes`).
## 4a. Per-product metering + the product/agent cost axes
Two metering seams share the ONE commerce ledger (`Deps.Metering`):
- **`BillingGate`** (`middleware_billing.go`) — the request EDGE, priced by PATH
(`DefaultPrice`). `/v1/ai/*` self-meters token spend upstream (gateway/ai), so it
is price-0 here to avoid double-billing.
**Auto-routing binds to the resolved model.** ai serves a virtual `auto`
(alias `zen-router`) model that it resolves to a concrete model id *before*
pricing/billing, then meters its own token cost keyed on the SERVED model and
reports it via the `X-Routed-Model` response header (echoed in the body
`model`). Because the edge prices `/v1/ai/*` by PATH (0), never by the request
model, `auto` bills as whatever it resolved to — the ai per-token meter is the
single source of the charge. The edge passes `X-Routed-Model` through untouched,
so the model reported to the client equals the model billed. Proven end-to-end:
`auto_routing_billing_test.go` (`TestAutoRoutingBillsAsResolvedModel`,
`TestDefaultPriceAiPathModelAgnostic`).
- **`ResourceMeter`** (`resource_billing.go`) — IN-HANDLER, priced per-org after the
caller's org is resolved. Every non-LLM product uses it: `Gate` (fail-closed
pre-auth, `available >= fee`, default fee $1.00 / `DefaultResourceFeeCents`) then
`Meter`/`MeterUsage` (debit-on-success, `provider = <product>`). Balance floor is
enforced BY DEFAULT — a zero/negative-balance priced call → **402** (proven:
`clients/{functions,s3,agents,ml,provisioning}/billing_test.go` `*RefusesUnfundedOrg`).
Metering+gating coverage (each meters its OWN org, debits on success):
| Product | provider label | fee knob | code |
|---|---|---|---|
| functions | `functions` | `CLOUD_FUNCTIONS_FEE_CENTS` | `clients/functions/invoke.go` |
| s3 | `s3` | `S3_*` | `clients/s3/s3.go` |
| agents | `agent` | `CLOUD_AGENT_FEE_CENTS` | `clients/agents/agents.go` |
| compute / GPU | `compute` | provision knobs | `clients/ml/ml.go`, `clients/visor/*` |
| provisioning (sql/kv/vector/docdb) | `provisioning` | `CLOUD_PROVISION_FEE_CENTS[_KIND]` | `clients/provisioning/*` |
| automations | `automations` | `CLOUD_AUTOMATIONS_FEE_CENTS` | `clients/automations/automations.go` |
| tracker | `tracker` | fee knob | `clients/tracker/tracker.go` |
| security | `security.scan` | — | `clients/security/security.go` |
**Product/agent read axes.** The console's per-product Metrics dashboard groups on
`metadata.product` (and `metadata.agent`). Commerce's `RecordUsage` persists the
metering SURFACE (`provider`) and billed UNIT (`model`) but has **no `product`
field** (its `usageRequest` drops `project`/`service`/`product`/`agent`). So the
customer read handler `clients/billing/usage.go` is the ONE read-side adapter:
`usage()` fetches the org-scoped ledger and, on 200, injects a canonical
`metadata.product` onto every row (`productOf`: `agent→agents`,
`provisioning→<kind>`, token-metered→`inference`, else `provider`) so the
breakdowns POPULATE from the SAME charged ledger. It also honors, server-side (was
silently ignored):
- `GET /v1/billing/usage?product=<id>` — filter to one product,
- `GET /v1/billing/usage?groupBy=product` — per-product rollup
`{product,requests,amountCents}`.
A row that already carries `metadata.product`/`agent` wins, so this degrades to a
no-op when the meter/commerce persist them natively (forward-compatible).
**Remaining checklist** (each is the same seam):
1. **Native `product`/`agent` fields** — add `Product`/`Agent` to
`commerce/metering.Usage` + `commerce` `usageRequest`/metadata, have each
`ResourceMeter` caller pass its product id, and drop the read-side `productOf`
derivation (decomplect: the meter KNOWS its product; record it, don't re-derive).
Cross-repo (commerce) — additive/backward-compatible.
2. **Agent-NAME axis** — needs (1): the agent run debit records `provider=agent` +
`model=<llm>` but not the agent name, so `metadata.agent` stays honest-empty
until commerce persists an `agent` field the agents meter sets to `a.Name`.
3. **compute split** — `ml` (predict) and `visor` (GPU) both meter `provider=compute`;
read-side can't split `inference` vs `gpus`. Needs (1) so each sets its product id.
4. **exec / containers** (`clients/exec`, Code Interpreter) — authed by a shared
service key (X-API-Key), NO per-org identity, so it can't meter per-org; its
compute is billed upstream at the chat/agent layer that invokes it.
5. **playground** — routes to `/v1/ai/*`, already metered as AI inference.
## 5. Secrets
- Per-tenant, KMS-managed only (`kms.hanzo.ai`, KMSSecret CRDs). No shared
service key stands in for a user. The only service tokens that exist are
narrow, per-tenant, and never used to impersonate a user for LLM spend.
- A surface's own OIDC registration is a PUBLIC client — there is no client
secret to store.
## Surface conformance (as of this contract)
| Surface | Login (PKCE public) | Forwards user token | Org from `owner` | Billed via cloud (org,project) |
|---|---|---|---|---|
| **studio.hanzo.ai** | ✅ reference | ✅ (validates locally) | ✅ | ⚠️ renders run on studio's own GPU workers and self-report to commerce keyed by org via a per-tenant commerce token — org-keyed, but not the forward-bearer-to-gateway path (studio does not call the cloud LLM gateway for its core renders) |
| **console.hanzo.ai** | ✅ | ✅ same-origin `/v1` through the gateway | ✅ | ✅ (it IS the canonical consumer) |
| **hanzo.chat** | ✅ (this change) | ✅ (this change: `resolveTenantBearer`) | ✅ | ✅ (this change: forwards bearer to `/v1/ai/*`) |
| **hanzo.app** | ❌ confidential client (`IAM_CLIENT_SECRET`, userinfo/introspect) | ✅ to its own backend; org from token `owner` | ✅ | ❌ builder AI runs on OpenRouter with an apiKey (`lib/llm/generation-api.ts`), NOT the cloud gateway — off the unified meter |
hanzo.app is the remaining gap: it needs the same treatment chat just got — switch
its IAM registration to a PKCE public client, and route its builder AI generation
through api.hanzo.ai forwarding the user's IAM bearer so usage meters against the
org plan instead of a shared OpenRouter key.
</content>
</invoke>
+196 -7
View File
@@ -1,11 +1,200 @@
FROM golang:1.26-alpine AS build
RUN apk add --no-cache ca-certificates tzdata
RUN addgroup -g 65532 -S nonroot && adduser -u 65532 -S nonroot -G nonroot
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /cloud ./cmd/cloud
# hanzoai/cloud — the ONE unified Hanzo Cloud binary (HIP-0106).
#
# This image is a SINGLE artifact that serves BOTH the /v1 API AND the console
# UI from one process: the console is compiled into the Go binary via
# //go:embed (see webui.go). The pipeline is:
#
# 1. console stage → build the hanzoai/console static bundle
# 2. (copied) → into webui/dist/ of the Go build context
# 3. build stage → `go build` bakes webui/dist into the binary (go:embed)
#
# so the final `/cloud` binary already carries the UI. No separate console
# Service, no second origin — the embedded console calls /v1 on its own host.
#
# ── console UI stage ─────────────────────────────────────────────────────────
# Builds the console SPA and emits a STATIC bundle at /out. console is fetched
# at a pinned ref (CONSOLE_REF) using the same gh_token BuildKit secret the Go
# build uses for private modules.
#
# console exposes `npm run build:embed` (scripts/build-embed.mjs): it prunes the
# Next server route handlers (BFF proxies — they collapse to the cloud /v1/* the
# SPA calls same-origin), wraps the client catch-all pages for output:'export',
# and neutralizes the root layout's request-time headers() read (the per-host
# <title>, resolved client-side in the embed) so the STATIC export prerenders
# clean — emitting out/. This stage runs it and copies out/ into /out, which the
# Go build drops into webui/dist so //go:embed bakes the FULL @hanzo/gui console
# into the ONE binary. This stage FAILS HARD: the prod image MUST carry the real
# console — a missing/broken build:embed is a build ERROR, never a silent degrade
# to the placeholder shell. The one escape hatch is --build-arg ALLOW_PLACEHOLDER=1
# (pure-Go dev image with no Node console), which is NEVER set for prod.
FROM public.ecr.aws/docker/library/node:24-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd AS console
ARG CONSOLE_REPO=https://github.com/hanzoai/console.git
ARG CONSOLE_REF=main
# CONSOLE_CACHEBUST busts this stage's BuildKit layer cache every build. WHY it must
# exist: the clone+build layer's cache key is derived from the RUN text + build args.
# With only a static `git clone --branch main`, the key NEVER changes, so on the
# persistent ARC dind BuildKit cache every cloud image re-embedded the SAME frozen
# console snapshot — new console work (the native Tracker, …) silently never shipped,
# even on a freshly-built+deployed image. release.yml feeds this the cloud commit sha
# (unique per push) so the clone RUN re-runs each build and re-fetches console
# ${CONSOLE_REF} (main HEAD) fresh. Correctness over cache reuse: the console stage
# rebuilds every time, but the embed is never stale.
ARG CONSOLE_CACHEBUST=none
RUN apk add --no-cache git
WORKDIR /console
# The static export prerenders every page (webpack compile + export prerender);
# give the heap headroom so a large @hanzo/gui build never OOMs into the stub.
ENV NEXT_TELEMETRY_DISABLED=1 NODE_OPTIONS=--max-old-space-size=8192
# Hanzo Analytics: the console's <HanzoAnalytics/> (env-gated) renders the one
# native analytics.hanzo.ai tag only when a website-id is baked in. Default to the
# console.hanzo.ai property (7dce54ee, public per-site) so console+team track on
# the next cloud build. GA4/Pixel stay off (unset). Public id, not a KMS secret.
ARG NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=7dce54ee-41f6-4751-96bf-fe005067c7c7
ENV NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=$NEXT_PUBLIC_ANALYTICS_WEBSITE_ID
RUN --mount=type=secret,id=gh_token \
if [ -s /run/secrets/gh_token ]; then \
git config --global url."https://x-access-token:$(cat /run/secrets/gh_token)@github.com/".insteadOf "https://github.com/"; \
fi && \
echo ">> embedding console ${CONSOLE_REF} (cachebust ${CONSOLE_CACHEBUST})" && \
git clone --depth 1 --branch "${CONSOLE_REF}" "${CONSOLE_REPO}" . && \
echo ">> console @ $(git rev-parse HEAD)" && \
npm install --no-audit --no-fund --fetch-retries=5 --fetch-retry-mintimeout=20000 --fetch-timeout=120000
# FAIL-HARD. build:embed MUST emit a REAL bundle — a non-empty out/index.html AND
# an out/_next/ chunk dir — and /out then carries it into the Go embed path. If the
# target is absent, the export fails, or the output is the placeholder shape, this
# is a build ERROR (exit 1): the prod image can NEVER silently ship the committed
# fallback shell. Escape hatch: --build-arg ALLOW_PLACEHOLDER=1 leaves /out empty
# (Go build keeps the committed shell) for a pure-Go dev image — NEVER set in prod.
ARG ALLOW_PLACEHOLDER=0
RUN mkdir -p /out; \
ok=0; \
if npm run 2>/dev/null | grep -q ' build:embed'; then \
if npm run build:embed && [ -s out/index.html ] && [ -d out/_next ]; then \
cp -r out/. /out/; \
echo ">> embedded REAL console static bundle: $(wc -c < out/index.html)-byte index.html, $(du -sh out/_next | cut -f1) _next/"; \
ok=1; \
else \
echo ">> console build:embed produced NO real bundle (missing/empty out/index.html or out/_next)"; \
fi; \
else \
echo ">> console exposes no build:embed target"; \
fi; \
if [ "$ok" != "1" ]; then \
if [ "$ALLOW_PLACEHOLDER" = "1" ]; then \
echo ">> ALLOW_PLACEHOLDER=1 — keeping committed fallback shell (DEV image only; NEVER prod)"; \
else \
echo ">> FATAL: refusing to ship the placeholder console. Fix the console build:embed, or pass --build-arg ALLOW_PLACEHOLDER=1 for a pure-Go dev image."; \
exit 1; \
fi; \
fi
FROM scratch
# ── Go build stage (CGO=1 + SQLCipher — REAL at-rest encryption) ─────────────
# The unified binary embeds IAM (clients/iam) whose per-org store is SQLCipher-
# encrypted (orgIsolation=sqlite), and commerce's per-tenant money DBs likewise.
# A CGO=0 modernc build SILENTLY SHIPS PLAINTEXT. So this builds CGO=1 against
# system libsqlcipher — hanzoai/iam's proven recipe: the `libsqlite3` tag + a
# libsqlcipher symlink + -DSQLITE_HAS_CODEC, with the modernc double-registration
# guard, TestEncryptionProof, and the cek.go golden-vector KAT baked in — so a
# build that fails to link REAL SQLCipher, or that would decrypt existing stores
# differently, produces NO image. alpine3.22 MATCHES the runtime base so the
# libsqlcipher soname the binary links is the SAME one present at runtime. ECR
# Public mirror avoids Docker Hub's 429 rate-limit on shared CI runners.
# ---- agent-skills stage: regenerate the FULL /.well-known/agent-skills catalog
# from the hanzoai/openapi SOT (skills.py) and carry it into the Go embed path
# BEFORE `go build`, the SAME way the console bundle is produced. The committed
# catalog is only the tiny `ai` fallback; prod must embed the full set. FAIL-HARD:
# if the clone/generation can't produce the master index, the image is not built.
FROM public.ecr.aws/docker/library/python:3.12-alpine AS skills
ARG OPENAPI_REPO=https://github.com/hanzoai/openapi.git
ARG OPENAPI_REF=main
RUN apk add --no-cache git && pip install --no-cache-dir pyyaml
WORKDIR /openapi
RUN --mount=type=secret,id=gh_token \
if [ -s /run/secrets/gh_token ]; then \
git config --global url."https://x-access-token:$(cat /run/secrets/gh_token)@github.com/".insteadOf "https://github.com/"; \
fi && \
git clone --depth 1 --branch "${OPENAPI_REF}" "${OPENAPI_REPO}" . && \
python3 skills.py --no-services --out /catalog && \
test -s /catalog/hanzo/index.json
FROM public.ecr.aws/docker/library/golang:1.26-alpine3.22@sha256:727cfc3c40be55cd1bc9a4a059406b28a059857e3be752aa9d09531e12c20c56 AS build
RUN apk add --no-cache ca-certificates tzdata git gcc musl-dev sqlcipher-dev pkgconfig binutils
RUN addgroup -g 65532 -S nonroot && adduser -u 65532 -S nonroot -G nonroot
# mattn/go-sqlite3's `libsqlite3` tag hard-codes `-lsqlite3`, but alpine's
# sqlcipher-dev ships ONLY libsqlcipher (no libsqlite3.so). Symlink so the link
# resolves -lsqlite3 to libsqlcipher — REAL encryption. Do NOT `apk add sqlite-dev`
# (a plaintext libsqlite3 would silently disable the codec; the gate below catches it).
RUN set -eux; \
SC="$(find /usr/lib /lib -name 'libsqlcipher.so*' 2>/dev/null | sort | head -1)"; \
test -n "$SC"; \
ln -sf "$SC" /usr/lib/libsqlite3.so; \
ln -sf "$SC" /usr/lib/libsqlite3.so.0
WORKDIR /src
# hanzoai/* and luxfi/* are PUBLIC and resolve via the IMMUTABLE public proxy +
# sumdb — go.sum pins those canonical hashes, so a force-re-pointed tag can never
# break the build. GOSUMDB stays ON (a money image must not blanket-disable the
# checksum database); only zap-proto/* is exempt (first-party-direct via GOPRIVATE,
# authenticated git over gh_token). -mod=readonly means the committed go.sum is the
# SOLE source of truth: any drift (a needed hash not present) FAILS the build
# instead of being silently re-recorded. CGO_CFLAGS/LDFLAGS enable the SQLCipher
# codec + URI keying.
ENV CGO_CFLAGS="-DSQLITE_HAS_CODEC -DSQLITE_USE_URI=1 -I/usr/include/sqlcipher" \
CGO_LDFLAGS="-lsqlcipher" \
GOPRIVATE=github.com/zap-proto/* \
GONOSUMDB=github.com/zap-proto/* \
GOPROXY=https://proxy.golang.org,direct \
GOFLAGS=-mod=readonly
COPY go.mod go.sum ./
RUN --mount=type=secret,id=gh_token \
--mount=type=cache,target=/go/pkg/mod,sharing=locked \
if [ -s /run/secrets/gh_token ]; then \
git config --global url."https://x-access-token:$(cat /run/secrets/gh_token)@github.com/".insteadOf "https://github.com/"; \
fi && \
go mod download
COPY . .
# Drop the console static bundle into the embed path BEFORE `go build`, so
# //go:embed all:webui/dist bakes it into the binary (same-origin console).
COPY --from=console /out/ /src/webui/dist/
# Overlay the FULL agent-skills catalog before `go build` so //go:embed all:catalog
# bakes the complete set (all services × brands), not the committed `ai` fallback.
COPY --from=skills /catalog/ /src/clients/agentskills/catalog/
# RED gate — modernc double-registration guard: 0 modernc under CGO=1, else the
# "sqlite" driver is registered twice (mattn + modernc) → panic at init.
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
MODERNC="$(CGO_ENABLED=1 go list -tags "libsqlite3 sqlite_fts5" -deps ./cmd/cloud 2>/dev/null | grep -c 'modernc.org/sqlite' || true)"; \
[ "$MODERNC" = "0" ] || { echo "SQLITE-GATE FAIL: cmd/cloud links modernc.org/sqlite ($MODERNC pkgs) under CGO=1 — double-registers \"sqlite\" with hanzoai/sqlite(mattn) and panics at init."; exit 1; }
# RED gate — ENCRYPTION PROOF + the cek.go GOLDEN-VECTOR KAT, under the SAME CGO +
# libsqlcipher build this image ships. TestEncryptionProof asserts real
# ciphertext-at-rest (SQLITE_REQUIRE_CODEC=1 makes a plaintext link FAIL → NO
# image). TestUnwrapGoldenFixture asserts a FROZEN pre-luxfi-swap 61-byte DEK
# sidecar still decrypts under the shipped luxfi/crypto-AEAD code — existing
# encrypted stores stay readable, or NO image.
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
SQLITE_REQUIRE_CODEC=1 CGO_ENABLED=1 go test -count=1 -tags "libsqlite3 sqlite_fts5" \
-run 'TestEncryptionProof|TestUnwrapGoldenFixture|TestWrapUnwrapRoundTripPinsLayout' \
github.com/hanzoai/sqlite
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
CGO_ENABLED=1 go build -tags "libsqlite3 sqlite_fts5" -ldflags="-s -w" -o /cloud ./cmd/cloud
# Prove the SHIPPED binary binds sqlite3_* to libsqlcipher, not a plaintext libsqlite3.
RUN readelf -d /cloud | grep -qE 'NEEDED.*(sqlcipher|sqlite3)' || { echo "FATAL: /cloud links no sqlite/sqlcipher .so"; exit 1; }; \
! ldd /cloud 2>/dev/null | grep -E 'libsqlite3' | grep -vq 'libsqlcipher' || { echo "FATAL: /cloud resolves a NON-sqlcipher libsqlite3 (plaintext risk)"; exit 1; }
# ── final image (alpine, NOT scratch — CGO needs libc + libsqlcipher) ─────────
FROM public.ecr.aws/docker/library/alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
ARG REVISION=unknown
LABEL org.opencontainers.image.revision="${REVISION}" \
org.opencontainers.image.source="https://github.com/hanzoai/cloud"
# Runtime needs libsqlcipher (the codec the binary links). It must NOT also carry
# a plaintext libsqlite3 — the binary's -lsqlite3 DT_NEEDED would then bind to
# plaintext sqlite and silently no-op PRAGMA key. sqlcipher-libs ships
# libsqlcipher.so.0; alias libsqlite3.so.0 to it so sqlite3_* binds there.
RUN apk add --no-cache ca-certificates tzdata sqlcipher-libs \
&& SC="$(find /usr/lib /lib -name 'libsqlcipher.so*' 2>/dev/null | sort | head -1)" \
&& test -n "$SC" \
&& ln -sf "$SC" /usr/lib/libsqlite3.so.0
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=build /etc/passwd /etc/passwd
+203
View File
@@ -0,0 +1,203 @@
Copyright (c) 2026 Hanzo AI Inc.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright (c) 2026 Hanzo AI Inc.
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.
+86
View File
@@ -0,0 +1,86 @@
# hanzoai/cloud — developer ergonomics for the unified Hanzo Cloud binary (HIP-0106).
# Targets are intentionally minimal; deploy artifacts (compose, helm) live in deploy/ and helm/.
GO ?= go
BIN ?= cloud
PKG ?= ./cmd/cloud
DOCKER_IMAGE ?= ghcr.io/hanzoai/cloud
DOCKER_TAG ?= dev
LDFLAGS ?= -s -w
# Path to a hanzoai/console checkout used to build the embedded console bundle.
CONSOLE_DIR ?= ../console
# Path to a hanzoai/openapi checkout — the SOT the agent-skills catalog is generated from.
OPENAPI_DIR ?= ../openapi
# The shipped binary is pure Go (Dockerfile: CGO_ENABLED=0 → scratch). Default all
# build/test targets to that mode so `make build`/`make test` exercise exactly
# what prod runs — and, critically, register the ONE "sqlite" driver exactly once:
# cloud's stores use github.com/hanzoai/sqlite (its !cgo backend IS modernc), and
# the embedded deps (ai/base/commerce/o11y/orm/tasks) that import modernc directly
# then resolve to the SAME package → a single registration. A plain CGO_ENABLED=1
# build instead links the fork's mattn backend ALONGSIDE those modernc importers
# and panics at init ("sql: Register called twice for driver sqlite"); `make
# test-cgo` proves the cgo path via the fork's `sqlite_purego` opt-out tag, which
# forces the fork to modernc too so the whole binary registers "sqlite" once.
CGO_ENABLED ?= 0
.PHONY: help webui agentskills build build-standalone hanzo run smoke test test-cgo vet tidy docker docker-push clean
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)
webui: ## Build the real console static bundle into webui/dist (go:embed source). CONSOLE_DIR=<path to console>.
@command -v npm >/dev/null 2>&1 || { echo "npm is required to build the console bundle"; exit 1; }
@test -f "$(CONSOLE_DIR)/package.json" || { echo "console checkout not found at $(CONSOLE_DIR) — set CONSOLE_DIR=<path>"; exit 1; }
@test -d "$(CONSOLE_DIR)/node_modules" || (cd "$(CONSOLE_DIR)" && npm install --no-audit --no-fund)
cd "$(CONSOLE_DIR)" && NEXT_TELEMETRY_DISABLED=1 NODE_OPTIONS=--max-old-space-size=8192 npm run build:embed
# Overlay the fresh static export onto webui/dist, keeping only the tracked
# fallbacks (.gitignore + assets/.gitkeep); the real bundle is build-time-only.
find webui/dist -mindepth 1 -maxdepth 1 ! -name .gitignore ! -name assets -exec rm -rf {} +
cp -r "$(CONSOLE_DIR)/out/." webui/dist/
@echo ">> embedded real console bundle into webui/dist (index.html $$(wc -c < 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
# `ai` fallback tracked, so the full set is embedded at build but never committed.
python3 "$(OPENAPI_DIR)/skills.py" --no-services --out clients/agentskills/catalog
@echo ">> embedded FULL agent-skills catalog ($$(jq -r .skill_count clients/agentskills/catalog/hanzo/index.json) skills/brand)"
build: ## Build the unified cloud binary into ./bin/cloud (embeds whatever webui/dist holds — run `webui` first for the real console).
@mkdir -p bin
CGO_ENABLED=$(CGO_ENABLED) $(GO) build -ldflags="$(LDFLAGS)" -o bin/$(BIN) $(PKG)
build-standalone: webui build ## Build the REAL 1-binary console: console build:embed → webui/dist → go build.
hanzo: ## Build the hanzo control-plane CLI into ./bin/hanzo (pure Go, same mode as cmd/cloud — registers the ONE "sqlite" driver exactly once; a plain CGO_ENABLED=1 `go build ./cmd/hanzo` links the fork's mattn backend alongside the embedded modernc importers and panics, see header).
@mkdir -p bin
CGO_ENABLED=$(CGO_ENABLED) $(GO) build -ldflags="$(LDFLAGS)" -o bin/hanzo ./cmd/hanzo
run: build ## Run with iam,base,kms,gateway,o11y enabled (matches README quickstart).
./bin/$(BIN) --enable=iam,base,kms,gateway,o11y --brand=hanzo --domain=api.hanzo.ai
smoke: ## Build and run cmd/cloud-smoke (mount-time integration check).
$(GO) run ./cmd/cloud-smoke
test: ## Run unit + integration tests (pure-Go, exactly as prod ships).
CGO_ENABLED=$(CGO_ENABLED) $(GO) test ./...
test-cgo: ## Prove the cgo build works too — forces the fork's pure-Go backend via -tags sqlite_purego so the embedded modernc importers don't double-register "sqlite".
CGO_ENABLED=1 $(GO) test -tags sqlite_purego ./...
vet: ## go vet across the module.
CGO_ENABLED=$(CGO_ENABLED) $(GO) vet ./...
tidy: ## go mod tidy + verify go.sum.
$(GO) mod tidy
$(GO) mod verify
docker: ## Build the Docker image (uses repo Dockerfile, scratch final stage).
docker build -t $(DOCKER_IMAGE):$(DOCKER_TAG) .
docker-push: docker ## Push the Docker image to ghcr.io. Requires docker login.
docker push $(DOCKER_IMAGE):$(DOCKER_TAG)
clean: ## Remove built artifacts.
rm -rf bin
+76
View File
@@ -1,3 +1,5 @@
<p align="center"><img src=".github/hero.svg" alt="cloud" width="880"></p>
# cloud
Unified Go control plane and binary for the Hanzo platform (HIP-0106).
@@ -15,6 +17,36 @@ docker run -p 8080:8080 ghcr.io/hanzoai/cloud:latest
`hanzoai/cloud` is one Go binary that mounts every Hanzo subsystem (iam, kms, base, gateway, ai, commerce, vfs, mq, dns, amqp, mcp, o11y, ...) into a single multi-tenant process. Same artifact serves `api.hanzo.ai`, `api.osage.cloud`, `api.lux.cloud`, `api.zoo.cloud`, and every white-label reseller. Brand, enabled subsystems, and tenant scope are deployment configuration.
## `hanzo` — cloud control CLI
The same binary is also a gcloud/doctl-class CLI. The first token selects the mode:
- `hanzo <subsystem>`**server mode**: serve a subsystem (`hanzo iam`, `hanzo cloud`, …).
- `hanzo <verb>`**client mode**: control the live estate. A thin client over
Hanzo IAM (`hanzo.id`), the platform control plane (`platform.hanzo.ai/v1`),
and the cloud `/v1` API — it invents no parallel API.
```bash
hanzo login # IAM password grant against hanzo.id → token in ~/.hanzo (0600)
hanzo whoami # identity from the stored token (--verify hits IAM userinfo)
hanzo apps list # platform apps board: declared/running/latest tag + drift + health
hanzo apps get <org>/<app>/<env> # one app row
hanzo deploy <container> --project <p> --env <e> # rolling, zero-downtime redeploy
hanzo clusters list|get|create|select|target # dedicated DOKS cluster lifecycle
hanzo build <repo> --sha <sha> --image <img> # platform-native (arcd/Kaniko) build, no GitHub builders
hanzo k8s target # the org's resolved deploy target (kubeconfig never returned)
hanzo config set <k> <v> # ~/.hanzo/config preferences
```
Global flags: `--org`, `-o/--output table|json`, `--platform-url`, `--iam-issuer`,
`--platform-token`. Tokens resolve from flag → env → `~/.hanzo` (never hardcoded):
the IAM user token is the identity; the platform control plane is service-token
authed (it cannot validate user tokens), so `apps`/`deploy`/`clusters` use
`--platform-token` / `HANZO_PLATFORM_TOKEN` / `PLATFORM_SERVICE_TOKEN`, and
`build` uses `HANZO_BUILD_TOKEN` / `PLATFORM_BUILD_CALLBACK_TOKEN`.
Install: `go install github.com/hanzoai/cloud/cmd/hanzo@latest`, or `brew install hanzoai/tap/hanzo`.
## Specs
Implements:
@@ -88,6 +120,50 @@ deployment configuration.
[hanzoai/zip](https://github.com/hanzoai/zip) — Sinatra-style Go web framework
built on Fiber v3. The ONE Go web framework. No `.Fast` escape hatch.
## Console UI — embedded in the ONE binary
The same `hanzoai/cloud` binary serves the [console](https://github.com/hanzoai/console)
(`@hanzo/gui`) UI at the web root AND the `/v1` API from one process — one
artifact, one origin, no separate console Service. The UI is compiled in via
`//go:embed` (see `webui.go`).
Pipeline (in the `Dockerfile`, before `go build`):
```
console stage → build console static bundle → /out
COPY --from=console /out/ → src/webui/dist/ (overlays the fallback shell)
build stage → go build → //go:embed all:webui/dist bakes it into /cloud
```
Serving (`webui.go`, registered LAST in `Serve` so it never shadows the API):
- `GET /` and any client-side route (`/orgs`, `/models`, …) → the SPA shell
(`index.html`) with `Cache-Control: no-cache`; fingerprinted assets under
`assets/`/`_next/` are served `immutable` for a year, with brotli/gzip
precompressed negotiation when the build emits `.br`/`.gz` siblings.
- `GET /v1/*` (and `/zap`, `/healthz`, …) → the API. Real subsystem routes are
registered before the console catch-all, so they always win; an **unmatched**
path under an API prefix returns a real 404 (JSON namespace), never HTML.
- Same-origin: the embedded console calls `/v1` on its own host, so the session
cookie is first-party — no second origin, no CORS.
`webui/dist/index.html` is a committed **fallback shell** (a real same-origin
`/v1` bootstrap) so `go build` always compiles and the binary always serves a UI
even without the Node toolchain. The image build overwrites `webui/dist` with the
real console bundle. See `webui_test.go` for the boot-and-assert tests
(`/` → shell, deep link → shell 200, `/v1/*` → API, unmatched `/v1` → 404).
Current state: `hanzoai/console` exposes `build:embed` (`scripts/build-embed.mjs`),
which stashes its Next server route handlers (BFF proxies that collapse to the
cloud `/v1/*` the SPA calls same-origin), wraps the client catch-all pages for
`output: 'export'`, neutralizes the root layout's request-time `headers()` read,
and emits a real static export at `out/` (a ~360 KB `index.html` + `_next/`
chunks). The image build (and `make webui`) run it and overlay `webui/dist`, so
`//go:embed` bakes the FULL `@hanzo/gui` console into the ONE binary. The
Dockerfile console stage FAILS HARD if that bundle is missing or degenerate —
the placeholder shell can never silently ship to prod (escape hatch:
`--build-arg ALLOW_PLACEHOLDER=1` for a pure-Go dev image).
## Status
Scaffold. The Mount(app, deps) integration for each subsystem lands per
+50
View File
@@ -0,0 +1,50 @@
// Copyright 2026 The Hanzo Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cloud
import (
"os"
"testing"
)
// TestJWTAudiences_AcceptsAdminGuard is the operator-cockpit keystone. The
// admin.hanzo.ai forward-auth guard is the confidential client `hanzo-admin-guard`,
// so IAM mints its access tokens with aud=hanzo-admin-guard (each app's aud is its
// client_id). The guard forwards that bearer to cloud-api /v1/admin/*; the identity
// sanitizer only grants global-admin (owner==adminOrg) to a VALIDATED principal, and
// validation enforces this audience allowlist. If hanzo-admin-guard is not accepted
// the token resolves anonymous and the SuperAdmin gate reads false -> 403, even
// though the token's owner IS admin. Pin the client_id into the baked default so the
// forwarded bearer validates.
func TestJWTAudiences_AcceptsAdminGuard(t *testing.T) {
os.Unsetenv("CLOUD_JWT_AUDIENCES")
os.Unsetenv("GATEWAY_ALLOWED_AUDIENCES")
has := func(list []string, v string) bool {
for _, s := range list {
if s == v {
return true
}
}
return false
}
if !has(defaultJWTAudiences, "hanzo-admin-guard") {
t.Fatalf("defaultJWTAudiences must include hanzo-admin-guard (the admin-cockpit guard client_id); got %v", defaultJWTAudiences)
}
if !has(jwtAudiencesFromEnv(), "hanzo-admin-guard") {
t.Fatalf("resolved JWT audiences must include hanzo-admin-guard; got %v", jwtAudiencesFromEnv())
}
}
+623
View File
@@ -0,0 +1,623 @@
package audit
// Tests for the tamper-evident audit chain. They exercise the REAL SQLite store
// (an on-disk temp db, not a mock) so the append-only write path, the hash-chain
// math, the verifier, redaction, and the filtered query are all proven
// end-to-end. The headline test is tamper-detection: a record edited directly in
// the database is DETECTED as breaking the chain.
import (
"context"
"database/sql"
"encoding/json"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// openTemp opens a Recorder backed by a fresh on-disk SQLite file (not :memory:,
// because tamper tests re-open the same file via a second connection to edit it
// out-of-band — exactly what an attacker with DB access would do).
func openTemp(t *testing.T) (*Recorder, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "audit.db")
rec, err := Open(path, nil)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { _ = rec.Close() })
return rec, path
}
// sampleRecord is a representative security event (a global-admin org deletion).
func sampleRecord(action string) Record {
return Record{
Time: time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC),
Actor: Actor{Org: "admin", Sub: "z@hanzo.ai", Email: "z@hanzo.ai"},
Action: action,
Resource: Resource{Type: "org", ID: "acme"},
Auth: AuthContext{Method: "jwt", IsAdmin: true},
Outcome: Outcome{Result: "success", Status: 200},
SourceIP: "203.0.113.7",
UserAgent: "console",
RequestID: "req-123",
Method: "DELETE",
Path: "/v1/admin/orgs/acme",
}
}
// TestChain_AppendSealsAndLinks proves each appended record gets a monotonic seq,
// links its PrevHash to the previous record's Hash, and starts from the genesis
// anchor.
func TestChain_AppendSealsAndLinks(t *testing.T) {
rec, _ := openTemp(t)
ctx := context.Background()
r0, err := rec.Append(ctx, sampleRecord("DELETE /v1/admin/orgs"))
if err != nil {
t.Fatalf("append 0: %v", err)
}
if r0.Seq != 0 {
t.Fatalf("first seq = %d, want 0", r0.Seq)
}
if r0.PrevHash != genesisPrevHash {
t.Fatalf("genesis prev = %q, want %q", r0.PrevHash, genesisPrevHash)
}
if r0.Hash == "" || r0.Hash == genesisPrevHash {
t.Fatalf("hash not computed: %q", r0.Hash)
}
r1, err := rec.Append(ctx, sampleRecord("POST /v1/admin/roles"))
if err != nil {
t.Fatalf("append 1: %v", err)
}
if r1.Seq != 1 {
t.Fatalf("second seq = %d, want 1", r1.Seq)
}
if r1.PrevHash != r0.Hash {
t.Fatalf("link broken: r1.prev=%q, r0.hash=%q", r1.PrevHash, r0.Hash)
}
if r1.Hash == r0.Hash {
t.Fatal("distinct records must have distinct hashes")
}
}
// TestVerify_PassesOnUntamperedChain proves a well-formed chain verifies OK.
func TestVerify_PassesOnUntamperedChain(t *testing.T) {
rec, _ := openTemp(t)
ctx := context.Background()
for i := 0; i < 25; i++ {
if _, err := rec.Append(ctx, sampleRecord("POST /v1/admin/sync")); err != nil {
t.Fatalf("append %d: %v", i, err)
}
}
integrity, err := rec.Verify(ctx)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if !integrity.OK {
t.Fatalf("chain not OK: broken at %d (%s)", integrity.BrokenAt, integrity.Reason)
}
if integrity.Count != 25 {
t.Fatalf("count = %d, want 25", integrity.Count)
}
if integrity.BrokenAt != -1 {
t.Fatalf("brokenAt = %d, want -1 on a good chain", integrity.BrokenAt)
}
// Head must equal the last record's hash.
count, head := rec.Head()
if count != 25 || head != integrity.HeadHash {
t.Fatalf("head mismatch: (%d,%q) vs verify (%d,%q)", count, head, integrity.Count, integrity.HeadHash)
}
}
// TestVerify_DetectsFieldTamper is the headline: an attacker with direct DB
// access edits a record's content (flips a denied outcome to success, or changes
// the actor). The stored hash no longer matches the recomputed hash, so Verify
// reports the exact seq where the chain breaks. THIS is the tamper-evidence
// property — an audit trail that can be silently forged is worse than none.
func TestVerify_DetectsFieldTamper(t *testing.T) {
rec, path := openTemp(t)
ctx := context.Background()
for i := 0; i < 10; i++ {
if _, err := rec.Append(ctx, sampleRecord("DELETE /v1/admin/orgs")); err != nil {
t.Fatalf("append %d: %v", i, err)
}
}
// Sanity: clean chain verifies.
if iv, _ := rec.Verify(ctx); !iv.OK {
t.Fatalf("precondition: clean chain should verify, broke at %d", iv.BrokenAt)
}
// Tamper OUT OF BAND — a second connection issues an UPDATE the application
// never would. This models an attacker who owns the file / a rogue DBA.
tamperOutOfBand(t, path, `UPDATE audit_log SET actor_sub='attacker', result='success' WHERE seq=4`)
iv, err := rec.Verify(ctx)
if err != nil {
t.Fatalf("Verify after tamper: %v", err)
}
if iv.OK {
t.Fatal("TAMPER NOT DETECTED — a modified record verified as OK; the chain is forgeable")
}
if iv.BrokenAt != 4 {
t.Fatalf("brokenAt = %d, want 4 (the edited record)", iv.BrokenAt)
}
if !strings.Contains(iv.Reason, "hash mismatch") {
t.Fatalf("reason = %q, want a hash-mismatch explanation", iv.Reason)
}
}
// TestVerify_DetectsDeletion proves deleting a record (or a contiguous run) breaks
// the chain: the record after the hole has a PrevHash that no longer matches the
// now-preceding record, and the seq sequence gaps. Either way Verify flags it.
func TestVerify_DetectsDeletion(t *testing.T) {
rec, path := openTemp(t)
ctx := context.Background()
for i := 0; i < 10; i++ {
if _, err := rec.Append(ctx, sampleRecord("POST /v1/admin/roles")); err != nil {
t.Fatalf("append %d: %v", i, err)
}
}
// Delete a MIDDLE record — the classic "cover your tracks" edit.
tamperOutOfBand(t, path, `DELETE FROM audit_log WHERE seq=5`)
iv, err := rec.Verify(ctx)
if err != nil {
t.Fatalf("Verify after delete: %v", err)
}
if iv.OK {
t.Fatal("DELETION NOT DETECTED — a removed record left the chain verifying OK")
}
// The break is observed at seq 6 (the record whose predecessor vanished): its
// seq no longer follows the running counter (5 is missing), so the gap check
// fires first at 6.
if iv.BrokenAt != 6 {
t.Fatalf("brokenAt = %d, want 6 (record after the hole)", iv.BrokenAt)
}
}
// TestVerify_DetectsReorder proves swapping two records' positions (an attacker
// trying to reorder events) breaks the prev-hash linkage.
func TestVerify_DetectsReorder(t *testing.T) {
rec, path := openTemp(t)
ctx := context.Background()
for i := 0; i < 6; i++ {
if _, err := rec.Append(ctx, sampleRecord("POST /v1/kms/secrets")); err != nil {
t.Fatalf("append %d: %v", i, err)
}
}
// Swap the hashes of seq 2 and seq 3 (content stays, linkage corrupts). Any
// out-of-band shuffle that doesn't recompute the WHOLE suffix is detectable.
tamperOutOfBand(t, path, `
UPDATE audit_log SET hash = (SELECT hash FROM audit_log WHERE seq=3) WHERE seq=2;`)
iv, _ := rec.Verify(ctx)
if iv.OK {
t.Fatal("REORDER/HASH-SWAP NOT DETECTED")
}
if iv.BrokenAt < 0 {
t.Fatalf("expected a break, got brokenAt=%d", iv.BrokenAt)
}
}
// TestChain_RestartContinues proves a re-opened store continues the SAME chain
// (recovers seq + head) rather than forking — so a pod restart cannot silently
// reset the trail.
func TestChain_RestartContinues(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.db")
ctx := context.Background()
rec1, err := Open(path, nil)
if err != nil {
t.Fatalf("open 1: %v", err)
}
var lastHash string
for i := 0; i < 5; i++ {
r, err := rec1.Append(ctx, sampleRecord("POST /v1/admin/sync"))
if err != nil {
t.Fatalf("append %d: %v", i, err)
}
lastHash = r.Hash
}
_ = rec1.Close()
rec2, err := Open(path, nil)
if err != nil {
t.Fatalf("open 2: %v", err)
}
defer func() { _ = rec2.Close() }()
count, head := rec2.Head()
if count != 5 {
t.Fatalf("recovered count = %d, want 5", count)
}
if head != lastHash {
t.Fatalf("recovered head = %q, want %q", head, lastHash)
}
// The next append must chain onto the recovered head at seq 5.
r5, err := rec2.Append(ctx, sampleRecord("DELETE /v1/admin/orgs"))
if err != nil {
t.Fatalf("append after restart: %v", err)
}
if r5.Seq != 5 || r5.PrevHash != lastHash {
t.Fatalf("chain did not continue: seq=%d prev=%q (want seq 5 prev %q)", r5.Seq, r5.PrevHash, lastHash)
}
// And the whole continued chain still verifies.
if iv, _ := rec2.Verify(ctx); !iv.OK {
t.Fatalf("continued chain broke at %d (%s)", iv.BrokenAt, iv.Reason)
}
}
// TestRedact_StripsSecrets proves the redactor removes credential-bearing fields
// (by key name, recursively) while keeping non-secret structure — so an explicit
// emit point's before/after can never carry a password/token/key.
func TestRedact_StripsSecrets(t *testing.T) {
in := json.RawMessage(`{
"name": "acme",
"password": "hunter2",
"apiKey": "sk-live-abc123",
"passphrase": "correct horse",
"wgPrivKey": "PRIVKEYBYTES",
"recoveryPhrase": "twelve words here",
"socialSecurityNumber": "078-05-1120",
"config": {
"clientSecret": "shh",
"endpoint": "https://api.example.com",
"nested": {"private_key": "-----BEGIN-----", "region": "sfo3"}
},
"tokens": ["t1", "t2"],
"roles": ["admin", "viewer"]
}`)
out := Redact(in)
s := string(out)
// Secrets gone (incl. the edge-case key names: passphrase, privkey, phrase, ssn).
for _, leak := range []string{"hunter2", "sk-live-abc123", "shh", "BEGIN",
"correct horse", "PRIVKEYBYTES", "twelve words here", "078-05-1120"} {
if strings.Contains(s, leak) {
t.Fatalf("secret leaked through redaction: %q still present in %s", leak, s)
}
}
// Non-secret structure preserved.
for _, keep := range []string{"acme", "https://api.example.com", "sfo3", "viewer"} {
if !strings.Contains(s, keep) {
t.Fatalf("redaction dropped a non-secret value %q: %s", keep, s)
}
}
// The redaction marker appears where secrets were.
if !strings.Contains(s, redactedMarker) {
t.Fatalf("no redaction marker in output: %s", s)
}
// "tokens" is a secret key → the whole array is redacted (not its elements).
var decoded map[string]any
if err := json.Unmarshal(out, &decoded); err != nil {
t.Fatalf("redacted output is not valid JSON: %v", err)
}
if decoded["tokens"] != redactedMarker {
t.Fatalf("secret-keyed array not redacted whole: %v", decoded["tokens"])
}
}
// TestRedact_FailsClosedOnBadJSON proves unparseable input is never echoed back.
func TestRedact_FailsClosedOnBadJSON(t *testing.T) {
out := Redact(json.RawMessage(`{not valid json, password=hunter2`))
if strings.Contains(string(out), "hunter2") {
t.Fatalf("bad JSON echoed a secret: %s", out)
}
if !strings.Contains(string(out), redactedMarker) {
t.Fatalf("bad JSON should redact to a marker, got %s", out)
}
}
// TestQuery_Filters proves the filtered read returns the right subset by actor,
// action, resource, and result, newest-first, with an accurate total.
func TestQuery_Filters(t *testing.T) {
rec, _ := openTemp(t)
ctx := context.Background()
mk := func(org, action, res, result string) Record {
r := sampleRecord(action)
r.Actor.Org = org
r.Resource.Type = res
r.Outcome.Result = result
return r
}
// A mixed set.
seed := []Record{
mk("admin", "DELETE /v1/admin/orgs", "org", "success"),
mk("acme", "POST /v1/base/records", "records", "success"),
mk("admin", "POST /v1/admin/roles", "roles", "deny"),
mk("admin", "DELETE /v1/admin/orgs", "org", "success"),
mk("acme", "POST /v1/kms/secrets", "secrets", "error"),
}
for i, r := range seed {
if _, err := rec.Append(ctx, r); err != nil {
t.Fatalf("seed %d: %v", i, err)
}
}
// Filter by org=admin → 3 rows.
rows, total, err := rec.Query(ctx, Filter{Org: "admin"})
if err != nil {
t.Fatalf("query org: %v", err)
}
if total != 3 || len(rows) != 3 {
t.Fatalf("org=admin: got %d rows, total %d, want 3/3", len(rows), total)
}
// Newest first: the last-appended admin row (seq 3) comes before seq 2, 0.
if rows[0].Seq < rows[len(rows)-1].Seq {
t.Fatalf("not newest-first: %d..%d", rows[0].Seq, rows[len(rows)-1].Seq)
}
// Filter by result=deny → 1 row (the 403-style role change).
denies, dtotal, err := rec.Query(ctx, Filter{Result: "deny"})
if err != nil {
t.Fatalf("query deny: %v", err)
}
if dtotal != 1 || len(denies) != 1 || denies[0].Action != "POST /v1/admin/roles" {
t.Fatalf("result=deny: got %d (%+v), want 1 role-change", dtotal, denies)
}
// Filter by resource=secrets → 1 row.
secs, stotal, err := rec.Query(ctx, Filter{Resource: "secrets"})
if err != nil {
t.Fatalf("query resource: %v", err)
}
if stotal != 1 || len(secs) != 1 {
t.Fatalf("resource=secrets: got %d, want 1", stotal)
}
}
// TestQuery_SQLInjectionInFilterIsInert proves a malicious filter value is a
// parameter, never SQL: it simply matches nothing and cannot drop the table.
func TestQuery_SQLInjectionInFilterIsInert(t *testing.T) {
rec, _ := openTemp(t)
ctx := context.Background()
for i := 0; i < 3; i++ {
if _, err := rec.Append(ctx, sampleRecord("POST /v1/admin/sync")); err != nil {
t.Fatalf("seed %d: %v", i, err)
}
}
inject := Filter{Org: "admin'; DROP TABLE audit_log;--"}
rows, total, err := rec.Query(ctx, inject)
if err != nil {
t.Fatalf("query should not error on injection attempt: %v", err)
}
if total != 0 || len(rows) != 0 {
t.Fatalf("injection value matched %d rows, want 0", total)
}
// The table survived — a normal query still returns the seeded rows.
if _, all, err := rec.Query(ctx, Filter{}); err != nil || all != 3 {
t.Fatalf("table damaged by injection attempt: all=%d err=%v", all, err)
}
}
// TestChain_ConcurrentAppendsStayGapless proves the serialized writer keeps the
// chain a true, gapless total order under CONCURRENT appends: many goroutines
// append at once, and the resulting chain must have every seq 0..N-1 exactly once
// AND verify. A race in the head/seq handoff would surface as a duplicate seq (a
// PRIMARY KEY error), a gap, or a broken link — all of which this catches.
func TestChain_ConcurrentAppendsStayGapless(t *testing.T) {
rec, _ := openTemp(t)
ctx := context.Background()
const goroutines, per = 16, 20
total := goroutines * per
errCh := make(chan error, total)
var wg sync.WaitGroup
for g := 0; g < goroutines; g++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < per; i++ {
if _, err := rec.Append(ctx, sampleRecord("POST /v1/admin/sync")); err != nil {
errCh <- err
}
}
}()
}
wg.Wait()
close(errCh)
for err := range errCh {
t.Fatalf("concurrent append failed (race in seq/head handoff?): %v", err)
}
// The chain must verify and contain exactly `total` gapless records.
iv, err := rec.Verify(ctx)
if err != nil {
t.Fatalf("verify: %v", err)
}
if !iv.OK {
t.Fatalf("concurrent chain broke at %d (%s)", iv.BrokenAt, iv.Reason)
}
if iv.Count != uint64(total) {
t.Fatalf("recorded %d records, want %d (a lost/duplicated append)", iv.Count, total)
}
}
// checkpointMirror is a Mirror that also captures checkpoints (implements
// CheckpointSink) so the test can assert the head digest reaches an independent
// sink.
type checkpointMirror struct {
mu sync.Mutex
cps []Checkpoint
}
func (m *checkpointMirror) Append(context.Context, Record) error { return nil }
func (m *checkpointMirror) Checkpoint(_ context.Context, cp Checkpoint) error {
m.mu.Lock()
defer m.mu.Unlock()
m.cps = append(m.cps, cp)
return nil
}
func (m *checkpointMirror) last() (Checkpoint, bool) {
m.mu.Lock()
defer m.mu.Unlock()
if len(m.cps) == 0 {
return Checkpoint{}, false
}
return m.cps[len(m.cps)-1], true
}
// TestCheckpoint_EmitsHeadDigest proves the AU-9 anchor: the periodic checkpoint
// emits the current (count, head) to the log function AND, when the mirror is a
// CheckpointSink, to the independent digest store — and a final checkpoint fires
// on Close. This is what an external monitor compares to detect tail-truncation.
func TestCheckpoint_EmitsHeadDigest(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.db")
mirror := &checkpointMirror{}
rec, err := Open(path, mirror)
if err != nil {
t.Fatalf("open: %v", err)
}
ctx := context.Background()
var logged []Checkpoint
var lmu sync.Mutex
// every=0 → no ticker; we drive checkpoints via Close (final) + a manual tick.
rec.StartCheckpoints(0, func(cp Checkpoint) {
lmu.Lock()
logged = append(logged, cp)
lmu.Unlock()
})
for i := 0; i < 7; i++ {
if _, err := rec.Append(ctx, sampleRecord("POST /v1/admin/sync")); err != nil {
t.Fatalf("append %d: %v", i, err)
}
}
// Close emits the FINAL checkpoint (count=7, head=chain head).
if err := rec.Close(); err != nil {
t.Fatalf("close: %v", err)
}
lmu.Lock()
n := len(logged)
var lastLogged Checkpoint
if n > 0 {
lastLogged = logged[n-1]
}
lmu.Unlock()
if n == 0 {
t.Fatal("no checkpoint logged (Close should emit a final head digest)")
}
if lastLogged.Count != 7 {
t.Errorf("final checkpoint count = %d, want 7", lastLogged.Count)
}
if lastLogged.Head == "" || lastLogged.Head == genesisPrevHash {
t.Errorf("final checkpoint head not set: %q", lastLogged.Head)
}
// The independent sink also received the final digest.
if cp, ok := mirror.last(); !ok || cp.Count != 7 {
t.Errorf("checkpoint sink final = %+v (ok=%v), want count 7", cp, ok)
}
}
// TestCheckpoint_DoubleStartIsSafe proves a second StartCheckpoints call is
// ignored (no re-arm, no field/WaitGroup race) — the Red-review robustness fix.
// Run under -race to catch a regression.
func TestCheckpoint_DoubleStartIsSafe(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.db")
rec, err := Open(path, nil)
if err != nil {
t.Fatalf("open: %v", err)
}
rec.StartCheckpoints(time.Hour, func(Checkpoint) {})
rec.StartCheckpoints(time.Hour, func(Checkpoint) {}) // second call must be a no-op.
// Append + close must not race or hang.
if _, err := rec.Append(context.Background(), sampleRecord("POST /v1/admin/sync")); err != nil {
t.Fatalf("append: %v", err)
}
if err := rec.Close(); err != nil {
t.Fatalf("close: %v", err)
}
}
// TestCheckpoint_CloseSyncsToSink proves the FINAL checkpoint on Close reaches the
// independent sink SYNCHRONOUSLY (the Red-review durability fix) — the sink has
// the final count before Close returns, not on a detached goroutine that might
// not run before process exit.
func TestCheckpoint_CloseSyncsToSink(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.db")
mirror := &checkpointMirror{}
rec, err := Open(path, mirror)
if err != nil {
t.Fatalf("open: %v", err)
}
rec.StartCheckpoints(0, func(Checkpoint) {}) // no ticker; only the on-close checkpoint.
for i := 0; i < 4; i++ {
if _, err := rec.Append(context.Background(), sampleRecord("POST /v1/admin/sync")); err != nil {
t.Fatalf("append %d: %v", i, err)
}
}
if err := rec.Close(); err != nil {
t.Fatalf("close: %v", err)
}
// Immediately after Close returns (no sleep), the sink MUST already have the
// final digest — proving the Close-path write was synchronous.
cp, ok := mirror.last()
if !ok || cp.Count != 4 {
t.Fatalf("sink final checkpoint = %+v (ok=%v), want count 4 synchronously on Close", cp, ok)
}
}
// TestCheckpoint_CountMonotonicDetectsTruncation demonstrates the DETECTION an
// external monitor performs: consecutive checkpoints have non-decreasing Count;
// after a tail truncation the head reported by Head() drops below a prior
// checkpoint — the signal the o11y alert fires on.
func TestCheckpoint_CountMonotonicDetectsTruncation(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.db")
rec, err := Open(path, nil)
if err != nil {
t.Fatalf("open: %v", err)
}
ctx := context.Background()
for i := 0; i < 10; i++ {
if _, err := rec.Append(ctx, sampleRecord("POST /v1/admin/sync")); err != nil {
t.Fatalf("append %d: %v", i, err)
}
}
before, _ := rec.Head() // the monitor's last pinned checkpoint count.
if before != 10 {
t.Fatalf("pre-truncation count = %d, want 10", before)
}
_ = rec.Close()
// Attacker truncates the tail (deletes the last 4 records) out of band.
tamperOutOfBand(t, path, `DELETE FROM audit_log WHERE seq >= 6`)
rec2, err := Open(path, nil)
if err != nil {
t.Fatalf("reopen: %v", err)
}
defer func() { _ = rec2.Close() }()
after, _ := rec2.Head()
// The internal chain still verifies (a truncated prefix is self-consistent)…
if iv, _ := rec2.Verify(ctx); !iv.OK {
t.Fatalf("truncated prefix should self-verify, broke at %d", iv.BrokenAt)
}
// …but the count REGRESSED vs the pinned checkpoint — the truncation signal.
if after >= before {
t.Fatalf("count did not regress after truncation: before=%d after=%d", before, after)
}
t.Logf("truncation detected by count regression: %d → %d (chain-internal verify is OK; external anchor catches it)", before, after)
}
// tamperOutOfBand opens the SAME sqlite file on a SEPARATE connection and runs a
// mutating statement the audit application itself never issues — modeling an
// attacker with direct database/file access. The Recorder's own connection is
// unaffected; Verify then re-reads and must catch the damage.
func tamperOutOfBand(t *testing.T, path, stmt string) {
t.Helper()
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatalf("tamper open: %v", err)
}
defer func() { _ = db.Close() }()
if _, err := db.Exec(stmt); err != nil {
t.Fatalf("tamper exec %q: %v", stmt, err)
}
}
+235
View File
@@ -0,0 +1,235 @@
package audit
// The read paths: filtered Query (for /v1/admin/audit) and Verify (the
// tamper-evidence walk for /v1/admin/audit/verify). Both are read-only — they
// issue SELECT only, never mutate — so exposing them can never weaken the
// append-only property.
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
)
// Filter narrows a Query. Zero-value fields are ignored (no constraint), so an
// empty Filter returns the most-recent Limit records. Time bounds are inclusive
// and compared against the RFC3339Nano ts column lexicographically (RFC3339 is
// order-preserving as text, so a string range is a correct time range).
type Filter struct {
Org string // actor_org exact match (tenant scope)
Sub string // actor_sub exact match (a specific user)
Action string // action exact match
Resource string // res_type exact match
ResourceID string // res_id exact match (a specific resource instance)
Result string // outcome result: success|deny|error
Since time.Time // ts >= Since (UTC)
Until time.Time // ts <= Until (UTC)
Limit int // max rows (default 100, cap 1000)
Offset int // pagination offset
}
// Query returns records matching f, newest first, and the total count matching
// the same predicate (ignoring Limit/Offset) for pagination. All predicates are
// parameterized — never string-interpolated — so a filter value can never inject
// SQL. Column names in the WHERE come from a fixed allowlist below, not caller
// input.
func (r *Recorder) Query(ctx context.Context, f Filter) (rows []Record, total int, err error) {
where, args := f.build()
limit := f.Limit
if limit <= 0 {
limit = 100
}
if limit > 1000 {
limit = 1000
}
offset := f.Offset
if offset < 0 {
offset = 0
}
countQ := `SELECT COUNT(*) FROM audit_log` + where
if err = r.db.QueryRowContext(ctx, countQ, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("audit: count: %w", err)
}
listQ := `SELECT ` + selectCols + ` FROM audit_log` + where +
` ORDER BY seq DESC LIMIT ? OFFSET ?`
listArgs := append(append([]any{}, args...), limit, offset)
rs, err := r.db.QueryContext(ctx, listQ, listArgs...)
if err != nil {
return nil, 0, fmt.Errorf("audit: query: %w", err)
}
defer func() { _ = rs.Close() }()
for rs.Next() {
rec, scanErr := scanRecord(rs)
if scanErr != nil {
return nil, 0, fmt.Errorf("audit: scan: %w", scanErr)
}
rows = append(rows, rec)
}
return rows, total, rs.Err()
}
// build assembles the parameterized WHERE clause from the non-zero filter
// fields. Each fragment uses a fixed column name and a ? placeholder, so no
// caller value ever reaches the SQL text.
func (f Filter) build() (string, []any) {
var conds []string
var args []any
add := func(frag string, val any) {
conds = append(conds, frag)
args = append(args, val)
}
if f.Org != "" {
add("actor_org = ?", f.Org)
}
if f.Sub != "" {
add("actor_sub = ?", f.Sub)
}
if f.Action != "" {
add("action = ?", f.Action)
}
if f.Resource != "" {
add("res_type = ?", f.Resource)
}
if f.ResourceID != "" {
add("res_id = ?", f.ResourceID)
}
if f.Result != "" {
add("result = ?", f.Result)
}
if !f.Since.IsZero() {
add("ts >= ?", f.Since.UTC().Format(time.RFC3339Nano))
}
if !f.Until.IsZero() {
add("ts <= ?", f.Until.UTC().Format(time.RFC3339Nano))
}
if len(conds) == 0 {
return "", nil
}
return " WHERE " + strings.Join(conds, " AND "), args
}
const selectCols = `seq, ts, actor_org, actor_sub, actor_email, action, res_type, res_id,
auth_method, is_admin, result, status, reason, source_ip, user_agent,
request_id, method, path, before, after, prev_hash, hash`
// scanRecord reconstructs a Record from a row of selectCols.
func scanRecord(sc interface{ Scan(...any) error }) (Record, error) {
var (
rec Record
ts string
isAdmin int
before, after string
)
if err := sc.Scan(
&rec.Seq, &ts, &rec.Actor.Org, &rec.Actor.Sub, &rec.Actor.Email,
&rec.Action, &rec.Resource.Type, &rec.Resource.ID,
&rec.Auth.Method, &isAdmin, &rec.Outcome.Result, &rec.Outcome.Status, &rec.Outcome.Reason,
&rec.SourceIP, &rec.UserAgent, &rec.RequestID, &rec.Method, &rec.Path,
&before, &after, &rec.PrevHash, &rec.Hash,
); err != nil {
return Record{}, err
}
if t, err := time.Parse(time.RFC3339Nano, ts); err == nil {
rec.Time = t
}
rec.Auth.IsAdmin = isAdmin != 0
if before != "" {
rec.Before = json.RawMessage(before)
}
if after != "" {
rec.After = json.RawMessage(after)
}
return rec, nil
}
// Integrity is the result of a Verify walk — the AU-9 evidence that the trail has
// not been tampered with.
type Integrity struct {
// OK is true iff every record's stored hash equals the recomputed hash AND the
// chain links are continuous (each PrevHash == the prior record's Hash, seqs
// gapless from 0).
OK bool `json:"ok"`
// Count is the number of records walked.
Count uint64 `json:"count"`
// HeadHash is the hash of the last record (or the genesis anchor for an empty
// chain). Pin this externally over time to detect tail-truncation.
HeadHash string `json:"headHash"`
// BrokenAt is the seq of the FIRST record that failed verification, or -1 when
// OK. Reason describes the break (recomputed-hash mismatch, prev-hash
// discontinuity, or a seq gap).
BrokenAt int64 `json:"brokenAt"`
Reason string `json:"reason,omitempty"`
}
// Verify walks the entire chain in seq order, recomputing each record's hash from
// its content + the running prev-hash and checking continuity. It is the
// tamper-detector: any modification (a changed field re-hashes differently), any
// deletion or reordering (a seq gap or a broken prev-hash link), or a forged row
// (its recomputed hash won't match unless the attacker also recomputed the entire
// suffix — which they cannot do without re-inserting every subsequent record) is
// reported with the exact seq where the chain first breaks.
//
// Complexity is O(n) over the records; for very large trails this streams row by
// row (no full materialization). At cloud's audit volume this is fine; if a trail
// grows past what an on-demand full walk should touch, verify a seq WINDOW
// (Verify is easily extended with a bound) or rely on the externally-pinned head.
func (r *Recorder) Verify(ctx context.Context) (Integrity, error) {
rs, err := r.db.QueryContext(ctx,
`SELECT `+selectCols+` FROM audit_log ORDER BY seq ASC`)
if err != nil {
return Integrity{}, fmt.Errorf("audit: verify query: %w", err)
}
defer func() { _ = rs.Close() }()
prevHash := genesisPrevHash
var expectSeq uint64
var count uint64
headHash := genesisPrevHash
for rs.Next() {
rec, scanErr := scanRecord(rs)
if scanErr != nil {
return Integrity{}, fmt.Errorf("audit: verify scan: %w", scanErr)
}
// Gapless, 0-based ordering.
if rec.Seq != expectSeq {
return Integrity{
OK: false, Count: count, HeadHash: headHash,
BrokenAt: int64(rec.Seq),
Reason: fmt.Sprintf("seq gap: expected %d, got %d", expectSeq, rec.Seq),
}, nil
}
// Link continuity: this record must chain to the previous record's hash.
if rec.PrevHash != prevHash {
return Integrity{
OK: false, Count: count, HeadHash: headHash,
BrokenAt: int64(rec.Seq),
Reason: "prev_hash discontinuity (a record was deleted, reordered, or altered)",
}, nil
}
// Content integrity: recompute the hash from the record's own fields.
want, hErr := computeHash(rec, rec.PrevHash)
if hErr != nil {
return Integrity{}, fmt.Errorf("audit: verify hash: %w", hErr)
}
if want != rec.Hash {
return Integrity{
OK: false, Count: count, HeadHash: headHash,
BrokenAt: int64(rec.Seq),
Reason: "hash mismatch (record content was modified after it was written)",
}, nil
}
prevHash = rec.Hash
headHash = rec.Hash
expectSeq = rec.Seq + 1
count++
}
if err := rs.Err(); err != nil {
return Integrity{}, fmt.Errorf("audit: verify rows: %w", err)
}
return Integrity{OK: true, Count: count, HeadHash: headHash, BrokenAt: -1}, nil
}
+176
View File
@@ -0,0 +1,176 @@
// Package audit is the unified cloud binary's compliance-grade audit trail —
// tamper-evident, append-only, and complete over the security-relevant request
// surface (FedRAMP AU-* / SOC 2 CC-* controls).
//
// THE CONTROL, IN ONE SENTENCE. Every security-relevant action against this
// binary is captured as a structured Record, hash-chained to its predecessor so
// any later deletion or modification is detectable, and written INLINE (never
// dropped) to an append-only store the application can only INSERT into.
//
// THREE PIECES, EACH IN ITS LANE (orthogonal, per the Zen of Hanzo):
// - record.go — the event model + the hash-chain math (what a record IS and
// how it links to the one before it). Pure, no I/O.
// - store.go — the append-only sink (SQLite primary, INSERT-only; a
// best-effort OLAP mirror) and the serialized Recorder that
// owns the chain head. All persistence.
// - redact.go — the secret-stripping allowlist/denylist for any structured
// before/after an explicit emit point supplies. No secret ever
// reaches a record.
//
// The HTTP middleware (Middleware, in the cloud package) and the query/verify
// endpoints (in clients/admin) are thin callers of this package. This package
// holds the security logic; it has zero knowledge of routes.
package audit
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"time"
)
// Actor identifies WHO performed the action. It is populated ONLY from a
// validated principal (the sanitized X-User-* headers SanitizeIdentity mints
// from a verified IAM JWT), never from a raw client header — so an actor can
// never be forged by the request that is being audited. A service principal
// (M2M / no user sub) records Org with an empty Sub.
type Actor struct {
// Org is the tenant (IAM `owner`). Empty for an unauthenticated request.
Org string `json:"org"`
// Sub is the user id (IAM `sub`/`preferred_username`). Empty for a service
// principal or an anonymous request.
Sub string `json:"sub"`
// Email is the validated user email, when present.
Email string `json:"email,omitempty"`
}
// Resource identifies WHAT was acted upon: a type (e.g. "org", "role",
// "secret", "provider-config", "credit") and its id. For a plain HTTP mutation
// with no finer resource semantics, Type is the route family and ID is empty —
// the Action verb + path already pin the object.
type Resource struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
}
// AuthContext records HOW the actor authenticated and what authority they held
// at decision time — the AC-* evidence (was this a global admin? by what
// credential?). Method is "jwt" | "api-key" | "none". IsAdmin is the VALIDATED
// global-admin bit (owner == AdminOrg), never a raw X-User-IsAdmin.
type AuthContext struct {
Method string `json:"method"`
IsAdmin bool `json:"isAdmin"`
}
// Outcome is the result of the action: whether it was allowed and what
// happened. Result is "success" | "deny" | "error". Status is the HTTP status.
// Reason is a short, non-sensitive explanation for a deny/error (e.g.
// "global admin required", "insufficient_balance") — never a secret, never a
// raw upstream error body.
type Outcome struct {
Result string `json:"result"`
Status int `json:"status"`
Reason string `json:"reason,omitempty"`
}
// Record is one audit event. The JSON tags ARE the on-disk and on-wire contract.
//
// Field order in the struct is deliberate but IRRELEVANT to the hash: the chain
// hashes the CANONICAL (sorted-key) JSON of the record with Hash/PrevHash zeroed
// (see canonicalBytes), so re-ordering fields or adding an omitempty field can
// never change an existing record's hash.
type Record struct {
// Seq is the strictly-increasing chain position (0-based). It is assigned by
// the Recorder under its lock, so it is a true total order with no gaps.
Seq uint64 `json:"seq"`
// Time is the UTC event timestamp (RFC3339Nano).
Time time.Time `json:"time"`
// Actor / Action / Resource / Auth / Outcome — the AU-3 "content of audit
// records" core: who, what, on what, how-authenticated, with what result.
Actor Actor `json:"actor"`
Action string `json:"action"`
Resource Resource `json:"resource"`
Auth AuthContext `json:"auth"`
Outcome Outcome `json:"outcome"`
// SourceIP + UserAgent — the AU-3 "source of the event" fields.
SourceIP string `json:"sourceIp,omitempty"`
UserAgent string `json:"userAgent,omitempty"`
// RequestID correlates the record to the request-line log and any downstream
// trace (the X-Request-Id the pipeline mints).
RequestID string `json:"requestId,omitempty"`
// Method + Path are the HTTP verb and route for a request-sourced event.
Method string `json:"method,omitempty"`
Path string `json:"path,omitempty"`
// Before / After capture a mutation's prior and resulting state for the
// AU-required "before/after" on config-affecting changes. They are populated
// ONLY by explicit emit points and ONLY after Redact has stripped secrets —
// the HTTP middleware never sets them (it never reads bodies), so a secret in
// a request body can never leak here. Raw JSON so any shape round-trips.
Before json.RawMessage `json:"before,omitempty"`
After json.RawMessage `json:"after,omitempty"`
// PrevHash is the hash of record Seq-1 (hex). For the genesis record (Seq 0)
// it is genesisPrevHash. Hash is this record's hash. Neither participates in
// its own hash computation (both are zeroed in canonicalBytes).
PrevHash string `json:"prevHash"`
Hash string `json:"hash"`
}
// genesisPrevHash is the PrevHash of the first record in a fresh chain: 32 zero
// bytes, hex-encoded. A non-empty, fixed anchor so the genesis record's hash is
// still a function of a known constant (not the empty string, which would be
// indistinguishable from "field omitted").
const genesisPrevHash = "0000000000000000000000000000000000000000000000000000000000000000"
// canonicalBytes returns the deterministic byte string a record hashes over: the
// record with Hash AND PrevHash zeroed, marshaled by encoding/json (which sorts
// struct fields in declaration order and, critically, is stable for a given
// struct — the SAME bytes on every machine and every run). Zeroing PrevHash here
// means the hash covers only the record's OWN content; the link to the previous
// record is added explicitly in computeHash by appending prevHash. This keeps
// the two concerns separable and the math obvious.
//
// We marshal a copy with the two hash fields cleared rather than a parallel
// struct so there is exactly ONE definition of a record's fields (DRY): add a
// field to Record and it is covered by the hash automatically.
func canonicalBytes(r Record) ([]byte, error) {
r.Hash = ""
r.PrevHash = ""
return json.Marshal(r)
}
// computeHash returns the hex SHA-256 of (canonical(record) || prevHash-bytes).
// The prevHash is folded in as its RAW hex string bytes — the exact value stored
// in the record's PrevHash field — so the verifier reproduces it byte-for-byte
// from stored data alone. Any change to the record's content OR to which record
// precedes it changes this output, which is the whole tamper-evidence property.
func computeHash(r Record, prevHash string) (string, error) {
body, err := canonicalBytes(r)
if err != nil {
return "", err
}
h := sha256.New()
h.Write(body)
h.Write([]byte(prevHash))
return hex.EncodeToString(h.Sum(nil)), nil
}
// seal finalizes a record into position seq linked to prevHash: it stamps Seq
// and PrevHash, computes Hash, and returns the sealed record ready to append.
// The Recorder calls this under its lock so seq/prevHash reflect the true head.
func seal(r Record, seq uint64, prevHash string) (Record, error) {
r.Seq = seq
r.PrevHash = prevHash
hash, err := computeHash(r, prevHash)
if err != nil {
return Record{}, err
}
r.Hash = hash
return r, nil
}
+140
View File
@@ -0,0 +1,140 @@
package audit
// Secret redaction for the before/after captured on a mutation.
//
// THE RULE. An audit record must NEVER contain a credential — no password,
// token, API key, private key, card number, or session secret. Two layers
// enforce this:
//
// 1. The HTTP middleware captures METADATA ONLY (actor/action/resource/outcome).
// It NEVER reads a request or response body, so a secret in a POST body can
// never reach a record through the automatic path. This is the primary
// guarantee: the code that can't see a secret can't leak one.
//
// 2. An EXPLICIT emit point that supplies structured before/after (e.g. a config
// change diff) runs it through Redact first. Redact walks the JSON and
// replaces the VALUE of any key whose name matches the secret denylist with a
// fixed marker, recursively. It is deny-by-key-name — the same allowlist
// PATTERN cloud already uses for user-secret redaction — chosen because a
// mutation diff has arbitrary shape and key-name matching is the robust,
// well-understood control (vs. trying to detect "secret-looking" values).
//
// Redact is conservative: on any structural surprise it returns the redaction
// marker rather than the input, so a parser edge case fails CLOSED (no raw
// passthrough).
import (
"encoding/json"
"strings"
)
// redactedMarker replaces every redacted value. A constant so tests and the
// query UI recognize it unambiguously.
const redactedMarker = "[REDACTED]"
// secretKeyParts are substrings that, when contained (case-insensitively) in a
// JSON object key, mark that key's value as secret. Kept as a small, auditable
// denylist of the credential-bearing field names that actually occur across the
// Hanzo surface (IAM, KMS, commerce, provider config). Matching is substring so
// "clientSecret", "api_key", "PRIVATE_KEY", "accessToken" all match.
var secretKeyParts = []string{
"password",
"passwd",
"secret",
"token",
"apikey",
"api_key",
"api-key",
"authorization",
"auth_token",
"private_key",
"privatekey",
"privkey", // privkey, wgPrivKey
"passphrase",
"client_secret",
"credential",
"session",
"cookie",
"card", // card_number, cardNumber
"cvv",
"cvc",
"pin",
"ssn",
"social_security", // socialSecurityNumber (lower-cased match covers camelCase)
"socialsecurity",
"otp",
"mnemonic",
"phrase", // seed_phrase, seedPhrase, recoveryPhrase
"access_key",
"secret_key",
"refresh_token",
"id_token",
"bearer",
"signing_key",
"encryption_key",
}
// isSecretKey reports whether a JSON key names a credential-bearing field.
func isSecretKey(key string) bool {
k := strings.ToLower(key)
for _, part := range secretKeyParts {
if strings.Contains(k, part) {
return true
}
}
return false
}
// Redact returns a copy of the JSON value with every secret-keyed value replaced
// by the redaction marker, recursively through objects and arrays. Non-JSON or
// empty input yields nil (nothing to record). On a JSON parse error the input is
// dropped (returns the marker as a JSON string) rather than passed through —
// fail closed.
//
// Use it at any explicit emit point that supplies before/after:
//
// audit.Emit(ctx, rec.WithChange(audit.Redact(before), audit.Redact(after)))
func Redact(raw json.RawMessage) json.RawMessage {
if len(raw) == 0 {
return nil
}
var v any
if err := json.Unmarshal(raw, &v); err != nil {
// Unparseable — never echo it back verbatim; record a marker instead.
b, _ := json.Marshal(redactedMarker)
return b
}
cleaned := redactValue("", v)
out, err := json.Marshal(cleaned)
if err != nil {
b, _ := json.Marshal(redactedMarker)
return b
}
return out
}
// redactValue walks a decoded JSON value. key is the object key under which v
// sits (empty at the root and for array elements); when key is a secret key, the
// ENTIRE value v is replaced (whether it is a scalar, object, or array — a secret
// nested object is redacted whole). Otherwise objects/arrays are recursed.
func redactValue(key string, v any) any {
if key != "" && isSecretKey(key) {
return redactedMarker
}
switch t := v.(type) {
case map[string]any:
out := make(map[string]any, len(t))
for k, val := range t {
out[k] = redactValue(k, val)
}
return out
case []any:
out := make([]any, len(t))
for i, val := range t {
out[i] = redactValue("", val) // array elements inherit no key
}
return out
default:
return v
}
}
+380
View File
@@ -0,0 +1,380 @@
package audit
// The append-only sink + the serialized Recorder that owns the hash-chain head.
//
// WHY SQLITE IS THE PRIMARY, DURABLE STORE (not ClickHouse). The chain is only
// tamper-EVIDENT if records are appended in a strict, gapless total order and
// each record's PrevHash is the immediately-preceding record's Hash. That demands
// a single serializing writer with a synchronous, read-your-write head. cloud's
// canonical store is embedded SQLite (one store per the storagelock lockdown;
// pricing/provisioning already persist to {DataDir}/*.db). A local SQLite table
// the application can only INSERT into gives us: (a) a real total order under one
// connection, (b) synchronous durability so NO record is ever lost on the request
// path (unlike a fire-and-forget mirror), and (c) an append-only surface — the
// app issues no UPDATE/DELETE, and the hash-chain detects any out-of-band edit to
// the file. That is the compliance-grade primary control.
//
// THE CLICKHOUSE MIRROR IS A PROJECTION, NOT THE SOURCE OF TRUTH. The datastore
// (ClickHouse MergeTree — insert-only, mutation-rejected at parse time) is the
// fleet-wide OLAP mirror for long-retention, cross-deployment query. It is
// best-effort and asynchronous: a mirror outage must never block or fail an
// audited request, and the local chain remains the authority the verifier walks.
// Losing a mirror row is a query-completeness issue, not an integrity one.
//
// FAIL MODE. Append is INLINE and its error is RETURNED to the middleware, which
// fails the request CLOSED (a security-relevant action that cannot be recorded is
// not permitted to silently succeed). This is the AU-5 "response to audit logging
// process failure": deny rather than act-unlogged.
import (
"context"
"database/sql"
"errors"
"fmt"
"sync"
"time"
// github.com/hanzoai/sqlite is the ONE Hanzo SQLite driver: it registers
// the "sqlite" database/sql name under both build tags (cgo →
// mattn+SQLCipher, encrypted at rest; !cgo → pure-Go modernc). Importing
// modernc directly instead would double-register "sqlite" under CGO and
// panic at init. Blank import registers the driver.
_ "github.com/hanzoai/sqlite"
)
// Mirror is the optional OLAP projection sink (the datastore/ClickHouse). It is
// deliberately a tiny interface, not a concrete client, so the Recorder has no
// compile-time dependency on ClickHouse and tests can supply a fake. Append is
// called best-effort, asynchronously, off the request path.
type Mirror interface {
// Append writes one sealed record to the projection. A returned error is
// logged and dropped by the Recorder — the mirror never gates a request.
Append(ctx context.Context, r Record) error
}
// Checkpoint is a periodic, tamper-EVIDENCE digest of the chain head: the record
// count and the head hash at a moment in time. It is the AU-9 anchor for
// TAIL-TRUNCATION detection — an internal chain walk cannot notice that the last
// K records were deleted (the surviving prefix still verifies), but a durable,
// INDEPENDENT series of head checkpoints can: Count is monotonic, so any decrease
// between two consecutive checkpoints is deletion, and an attacker cannot forge a
// higher count without appending records whose hashes the chain walk would reject.
type Checkpoint struct {
Time time.Time `json:"time"`
Count uint64 `json:"count"`
Head string `json:"head"`
}
// CheckpointSink is an optional capability a Mirror may implement to persist the
// head digest series to an INDEPENDENT store (so truncating the local SQLite
// cannot also rewrite the anchor history). A Mirror that does not implement it
// still gets its records; checkpoints then flow only to the structured log.
type CheckpointSink interface {
Checkpoint(ctx context.Context, cp Checkpoint) error
}
// Recorder is the single serialized writer that owns the audit chain head and
// the append-only store. Every Record flows through Append, which under one lock
// assigns the next Seq, links PrevHash to the current head, seals (hashes), and
// synchronously persists to SQLite before returning. Concurrency is serialized
// by mu AND by the single-connection SQLite pool, so the on-disk order equals
// the chain order with no gaps.
type Recorder struct {
db *sql.DB
mirror Mirror // nil when no OLAP mirror is configured.
mu sync.Mutex // guards nextSeq/headHash and serializes appends.
nextSeq uint64 // Seq to assign to the next record.
headHash string // Hash of the last-appended record (PrevHash for the next).
// Checkpoint emission (AU-9 tail-truncation anchor). logCheckpoint, when set,
// receives each head digest so it lands in the append-only observability log;
// stopCh/wg manage the periodic emitter goroutine's lifecycle; started guards
// against a second StartCheckpoints call (the field write + WaitGroup use are
// not safe to race). All three are set once, before any concurrent Append.
logCheckpoint func(cp Checkpoint)
stopCh chan struct{}
wg sync.WaitGroup
started bool
}
// CheckpointFunc receives a head digest for the structured (o11y) log. It is a
// plain func so the pure audit package stays free of any concrete logger type;
// the cloud wiring adapts luxlog to it.
type CheckpointFunc func(cp Checkpoint)
// Open opens (creating if needed) the append-only audit DB at path and recovers
// the chain head from it, so a restart continues the SAME chain rather than
// forking a new one. path may be ":memory:" for tests. mirror may be nil.
//
// the hanzoai/sqlite "sqlite" driver; MaxOpenConns(1) serializes every statement against
// the file lock — the same single-writer discipline pricing/provisioning use,
// here doubling as the chain's serialization guarantee.
func Open(path string, mirror Mirror) (*Recorder, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("audit: open sqlite %q: %w", path, err)
}
db.SetMaxOpenConns(1)
for _, pragma := range []string{
"PRAGMA busy_timeout=5000",
"PRAGMA journal_mode=WAL",
"PRAGMA synchronous=NORMAL",
} {
if _, err := db.Exec(pragma); err != nil {
_ = db.Close()
return nil, fmt.Errorf("audit: pragma %q: %w", pragma, err)
}
}
r := &Recorder{db: db, mirror: mirror}
if err := r.migrate(); err != nil {
_ = db.Close()
return nil, err
}
if err := r.recoverHead(); err != nil {
_ = db.Close()
return nil, err
}
return r, nil
}
// migrate creates the append-only audit table. It is INSERT-only by application
// discipline: this package issues no UPDATE or DELETE against it, and seq is the
// PRIMARY KEY so a replayed/duplicated seq is rejected by the engine. The hash
// columns make any out-of-band row edit detectable by Verify regardless of the
// storage layer's own guarantees.
func (r *Recorder) migrate() error {
const ddl = `
CREATE TABLE IF NOT EXISTS audit_log (
seq INTEGER PRIMARY KEY, -- chain position; gapless, assigned under lock
ts TEXT NOT NULL, -- RFC3339Nano UTC event time
actor_org TEXT NOT NULL DEFAULT '',
actor_sub TEXT NOT NULL DEFAULT '',
actor_email TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL,
res_type TEXT NOT NULL DEFAULT '',
res_id TEXT NOT NULL DEFAULT '',
auth_method TEXT NOT NULL DEFAULT '',
is_admin INTEGER NOT NULL DEFAULT 0,
result TEXT NOT NULL, -- success|deny|error
status INTEGER NOT NULL DEFAULT 0,
reason TEXT NOT NULL DEFAULT '',
source_ip TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
request_id TEXT NOT NULL DEFAULT '',
method TEXT NOT NULL DEFAULT '',
path TEXT NOT NULL DEFAULT '',
before TEXT NOT NULL DEFAULT '', -- redacted JSON (explicit emit only)
after TEXT NOT NULL DEFAULT '', -- redacted JSON (explicit emit only)
prev_hash TEXT NOT NULL,
hash TEXT NOT NULL
);
-- Query indexes for the /v1/admin/audit filters (actor/action/resource/time).
CREATE INDEX IF NOT EXISTS ix_audit_org_seq ON audit_log(actor_org, seq);
CREATE INDEX IF NOT EXISTS ix_audit_action_seq ON audit_log(action, seq);
CREATE INDEX IF NOT EXISTS ix_audit_result_seq ON audit_log(result, seq);
CREATE INDEX IF NOT EXISTS ix_audit_ts ON audit_log(ts);
`
if _, err := r.db.Exec(ddl); err != nil {
return fmt.Errorf("audit: migrate: %w", err)
}
return nil
}
// recoverHead loads the highest-seq record so a restarted process continues the
// existing chain (nextSeq = maxSeq+1, headHash = its hash). An empty table starts
// the genesis chain (nextSeq 0, headHash = genesisPrevHash).
func (r *Recorder) recoverHead() error {
var (
maxSeq sql.NullInt64
hash sql.NullString
)
row := r.db.QueryRow(`SELECT seq, hash FROM audit_log WHERE seq = (SELECT MAX(seq) FROM audit_log)`)
if err := row.Scan(&maxSeq, &hash); err != nil {
if errors.Is(err, sql.ErrNoRows) {
r.nextSeq = 0
r.headHash = genesisPrevHash
return nil
}
return fmt.Errorf("audit: recover head: %w", err)
}
if !maxSeq.Valid { // empty table (MAX over zero rows is NULL)
r.nextSeq = 0
r.headHash = genesisPrevHash
return nil
}
r.nextSeq = uint64(maxSeq.Int64) + 1
r.headHash = hash.String
return nil
}
// Append seals r into the next chain position and persists it. It fills Seq,
// PrevHash, and Hash (the caller sets everything else), advances the in-memory
// head only AFTER the durable INSERT succeeds, and mirrors best-effort. A
// persistence error is returned so the caller can fail the request CLOSED — the
// head is NOT advanced on failure, so the chain never gaps.
//
// The whole critical section (assign seq → seal → INSERT → advance head) holds
// mu, so two concurrent requests can never claim the same seq or race the head.
func (r *Recorder) Append(ctx context.Context, rec Record) (Record, error) {
if rec.Time.IsZero() {
rec.Time = time.Now().UTC()
} else {
rec.Time = rec.Time.UTC()
}
r.mu.Lock()
defer r.mu.Unlock()
sealed, err := seal(rec, r.nextSeq, r.headHash)
if err != nil {
return Record{}, fmt.Errorf("audit: seal: %w", err)
}
if err := r.insert(ctx, sealed); err != nil {
// Head not advanced; the next append reuses this seq. Fail closed upstream.
return Record{}, fmt.Errorf("audit: persist: %w", err)
}
// Durable — advance the chain head.
r.nextSeq = sealed.Seq + 1
r.headHash = sealed.Hash
// Best-effort OLAP mirror, detached so a slow/failed mirror never blocks the
// request or corrupts the reply. The local chain is already durable and is the
// authority; a lost mirror row is a query-completeness gap, not an integrity
// one. Copy by value: the record is immutable and safe to hand to a goroutine.
if r.mirror != nil {
m, out := r.mirror, sealed
go func() { _ = m.Append(context.Background(), out) }()
}
return sealed, nil
}
// insert writes one sealed record. INSERT-only — the sole write statement in this
// package. A duplicate seq (PRIMARY KEY) fails here, which is the desired
// invariant: the chain never overwrites a position.
func (r *Recorder) insert(ctx context.Context, rec Record) error {
_, err := r.db.ExecContext(ctx, `
INSERT INTO audit_log (
seq, ts, actor_org, actor_sub, actor_email, action, res_type, res_id,
auth_method, is_admin, result, status, reason, source_ip, user_agent,
request_id, method, path, before, after, prev_hash, hash
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
rec.Seq, rec.Time.Format(time.RFC3339Nano),
rec.Actor.Org, rec.Actor.Sub, rec.Actor.Email,
rec.Action, rec.Resource.Type, rec.Resource.ID,
rec.Auth.Method, boolToInt(rec.Auth.IsAdmin),
rec.Outcome.Result, rec.Outcome.Status, rec.Outcome.Reason,
rec.SourceIP, rec.UserAgent, rec.RequestID, rec.Method, rec.Path,
string(rec.Before), string(rec.After), rec.PrevHash, rec.Hash,
)
return err
}
// checkpointCloseTimeout bounds the final (synchronous) checkpoint write to the
// independent sink at shutdown, so Close cannot hang on an unreachable datastore.
const checkpointCloseTimeout = 5 * time.Second
// Close stops the periodic checkpoint emitter, emits a FINAL checkpoint
// SYNCHRONOUSLY (so the head at shutdown reaches both the o11y log and the
// independent digest store before the process exits — the AU-9 anchor must be
// current exactly when an attacker might trigger shutdown then truncate), and
// closes the underlying database.
func (r *Recorder) Close() error {
if r == nil || r.db == nil {
return nil
}
if r.stopCh != nil {
close(r.stopCh)
r.wg.Wait()
r.stopCh = nil
}
// Anchor the final head before the DB closes — independent of whether the
// periodic ticker was running (every<=0 still gets a shutdown checkpoint).
// Synchronous to the sink (bounded), so the independent store's last count is
// as fresh as the local chain at the moment of shutdown.
r.emitCheckpoint(true)
return r.db.Close()
}
// StartCheckpoints begins periodic head-digest emission every `every` (no ticker
// if every<=0; the on-Close checkpoint still fires). logFn, when non-nil,
// receives each digest for the append-only observability log (o11y), and a mirror
// implementing CheckpointSink also gets it persisted to an INDEPENDENT store —
// together the AU-9 anchor an external monitor compares to detect tail-truncation
// (count regression). MUST be called at most once, before any concurrent Append
// (a second call is ignored); the emitter stops on Close.
func (r *Recorder) StartCheckpoints(every time.Duration, logFn CheckpointFunc) {
// Guard the check-and-set under mu so a (mis)use that calls this concurrently
// is race-free, not just the single-call production path.
r.mu.Lock()
if r.started {
r.mu.Unlock()
return // already started — do not re-arm (avoids a field/WaitGroup race).
}
r.started = true
r.logCheckpoint = logFn
r.mu.Unlock()
if every <= 0 {
return
}
r.stopCh = make(chan struct{})
stop := r.stopCh
r.wg.Add(1)
go func() {
defer r.wg.Done()
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
r.emitCheckpoint(false)
}
}
}()
}
// emitCheckpoint snapshots the head and emits it to the log and (if the mirror is
// a CheckpointSink) the independent digest store. sync controls the sink write:
// on the periodic path (sync=false) it is detached so a slow sink never delays
// the ticker; on the Close path (sync=true) it BLOCKS on a bounded context so the
// final anchor is durable before shutdown. The log emission is always synchronous
// (it is the primary anchor and o11y ingests it append-only).
func (r *Recorder) emitCheckpoint(sync bool) {
count, head := r.Head()
cp := Checkpoint{Time: time.Now().UTC(), Count: count, Head: head}
if r.logCheckpoint != nil {
r.logCheckpoint(cp)
}
cs, ok := r.mirror.(CheckpointSink)
if !ok || cs == nil {
return
}
if sync {
ctx, cancel := context.WithTimeout(context.Background(), checkpointCloseTimeout)
defer cancel()
_ = cs.Checkpoint(ctx, cp)
return
}
go func() { _ = cs.Checkpoint(context.Background(), cp) }()
}
// Head returns the current chain head (count of records, and the head hash). A
// count of 0 means the genesis (empty) chain, headHash == genesisPrevHash. An
// external monitor can pin (count, headHash) over time to detect tail-truncation
// — which an internal chain walk alone cannot catch (a truncated prefix still
// verifies). This is the anchor point for AU-9 protection against deletion of the
// most-recent records.
func (r *Recorder) Head() (count uint64, headHash string) {
r.mu.Lock()
defer r.mu.Unlock()
return r.nextSeq, r.headHash
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
+60
View File
@@ -0,0 +1,60 @@
package audit
import "time"
// Wire is the JSON shape of one audit record on the operator/console contract. It
// is cloud's OWN record projection — richer than the IAM record it supersedes: it
// carries the outcome, the validated auth context, and the hash-chain linkage
// (Hash/PrevHash) so a console can show tamper-evidence per row. The JSON tags ARE
// the contract; both the admin god-view (/v1/admin/audit) and the org-scoped trail
// (/v1/audit) serialize this ONE shape so a single console adapter reads either.
type Wire struct {
Seq uint64 `json:"seq"`
Time string `json:"time"`
Org string `json:"org"`
Sub string `json:"sub"`
Email string `json:"email,omitempty"`
Action string `json:"action"`
Resource string `json:"resource"`
ResourceID string `json:"resourceId,omitempty"`
Method string `json:"method,omitempty"`
Path string `json:"path,omitempty"`
Result string `json:"result"`
Status int `json:"status"`
Reason string `json:"reason,omitempty"`
SourceIP string `json:"sourceIp,omitempty"`
UserAgent string `json:"userAgent,omitempty"`
RequestID string `json:"requestId,omitempty"`
IsAdmin bool `json:"isAdmin"`
Auth string `json:"authMethod,omitempty"`
Hash string `json:"hash"`
PrevHash string `json:"prevHash"`
}
// ToWire projects a stored Record onto the console wire shape. Time is emitted as
// RFC3339Nano (UTC) — the same nanosecond-precise, order-preserving format the ts
// column stores — so a client can sort/range on it verbatim.
func (r Record) ToWire() Wire {
return Wire{
Seq: r.Seq,
Time: r.Time.UTC().Format(time.RFC3339Nano),
Org: r.Actor.Org,
Sub: r.Actor.Sub,
Email: r.Actor.Email,
Action: r.Action,
Resource: r.Resource.Type,
ResourceID: r.Resource.ID,
Method: r.Method,
Path: r.Path,
Result: r.Outcome.Result,
Status: r.Outcome.Status,
Reason: r.Outcome.Reason,
SourceIP: r.SourceIP,
UserAgent: r.UserAgent,
RequestID: r.RequestID,
IsAdmin: r.Auth.IsAdmin,
Auth: r.Auth.Method,
Hash: r.Hash,
PrevHash: r.PrevHash,
}
}
+73
View File
@@ -0,0 +1,73 @@
package audit
import (
"context"
"testing"
"time"
)
func TestToWire_MapsEveryField(t *testing.T) {
r := Record{
Seq: 7,
Time: time.Date(2026, 7, 4, 12, 30, 0, 0, time.UTC),
Actor: Actor{Org: "maxpower", Sub: "dave", Email: "dave@maxpower.ai"},
Action: "machine.create",
Resource: Resource{Type: "machine", ID: "m-1"},
Auth: AuthContext{Method: "jwt", IsAdmin: true},
Outcome: Outcome{Result: "success", Status: 201, Reason: "ok"},
SourceIP: "1.2.3.4", UserAgent: "test-agent", RequestID: "req-1",
Method: "POST", Path: "/v1/machines",
PrevHash: "aa", Hash: "bb",
}
w := r.ToWire()
if w.Seq != 7 || w.Time != "2026-07-04T12:30:00Z" {
t.Fatalf("seq/time: %+v", w)
}
if w.Org != "maxpower" || w.Sub != "dave" || w.Email != "dave@maxpower.ai" {
t.Fatalf("actor: %+v", w)
}
if w.Action != "machine.create" || w.Resource != "machine" || w.ResourceID != "m-1" {
t.Fatalf("action/resource: %+v", w)
}
if w.Auth != "jwt" || !w.IsAdmin {
t.Fatalf("auth: %+v", w)
}
if w.Result != "success" || w.Status != 201 || w.Reason != "ok" {
t.Fatalf("outcome: %+v", w)
}
if w.Method != "POST" || w.Path != "/v1/machines" || w.SourceIP != "1.2.3.4" {
t.Fatalf("request: %+v", w)
}
if w.Hash != "bb" || w.PrevHash != "aa" {
t.Fatalf("chain: %+v", w)
}
}
func TestQuery_ResourceIDFilter(t *testing.T) {
rec, err := Open(":memory:", nil)
if err != nil {
t.Fatalf("Open: %v", err)
}
defer func() { _ = rec.Close() }()
ctx := context.Background()
for _, id := range []string{"m-1", "m-1", "m-2"} {
if _, err := rec.Append(ctx, Record{
Actor: Actor{Org: "o"}, Action: "machine.op",
Resource: Resource{Type: "machine", ID: id}, Outcome: Outcome{Result: "success"},
}); err != nil {
t.Fatalf("append: %v", err)
}
}
rows, total, err := rec.Query(ctx, Filter{ResourceID: "m-1"})
if err != nil {
t.Fatalf("Query: %v", err)
}
if total != 2 || len(rows) != 2 {
t.Fatalf("resourceId filter: want 2 rows on m-1, got %d rows total %d", len(rows), total)
}
for _, r := range rows {
if r.Resource.ID != "m-1" {
t.Fatalf("filter leaked %q", r.Resource.ID)
}
}
}
+677
View File
@@ -0,0 +1,677 @@
package cloud
// The audit middleware — the ONE place every security-relevant request is
// recorded to the tamper-evident trail (decomplected: one function, every route).
//
// PLACEMENT (why it sits exactly where serve.go puts it). The pipeline is
// Recover → RequestID → Logger → SanitizeIdentity → AuditTrail → BillingGate →
// subsystems. AuditTrail runs:
// - AFTER SanitizeIdentity, so the actor/isAdmin it records come from a
// VALIDATED IAM principal (the sanitized X-User-* headers), never a raw
// client header. The request being audited cannot forge its own actor.
// - BEFORE BillingGate and every subsystem, so it WRAPS the whole handler
// chain and observes the FINAL outcome — including a 402/503 billing denial
// and a 403 admin-guard denial (both security-relevant) — via the response
// status after Continue(), exactly like the Logger middleware reads it.
//
// WHAT IT CAPTURES: metadata only — actor, action (method+route family),
// resource, source ip, user agent, request id, auth context, and the outcome
// (result/status/reason). It NEVER reads the request or response BODY, so a
// secret in a POST body can never reach a record through this path. before/after
// diffs are the job of explicit emit points (audit.Recorder.Append with a
// redacted diff), not this middleware.
//
// WHAT IT RECORDS (the coverage predicate, auditable in one place — see
// isSecurityRelevant): every mutating request (POST/PUT/PATCH/DELETE), every
// /v1/admin/* request (read or write — admin reads are AC-relevant), and every
// auth-failure outcome (401/403) on ANY method (a denied GET is an access-control
// event). Safe, unauthenticated reads (a 200 GET on a public route) are NOT
// audited — that is request-log noise, not a security event, and auditing it
// would bury the signal and balloon the trail.
//
// FAIL MODE (AU-5): if the trail write fails on a request we decided to audit,
// the CLIENT gets a fail-closed 503 rather than a success it can rely on — the
// AU-5 "response to an audit logging process failure" is to interrupt, not to
// operate silently unlogged. A write to local SQLite is sub-millisecond, so this
// is a real integrity stance, not a latency tax. When no Recorder is configured
// the middleware is a no-op passthrough (an unconfigured deployment is never
// blocked), exactly like BillingGate.
//
// PRECISE SEMANTIC (do not over-read the 503). This is POST-RESPONSE audit: the
// handler has already run when the record is written, so a 503 here means "this
// event could not be RECORDED", NOT "the action did not execute". PREVENTION is
// the access-control layer's job and runs BEFORE the action — SanitizeIdentity
// (identity can't be forged) + the per-route admin guard both execute inside
// c.Next() ahead of any side effect. The audit trail's job is DETECTION and
// ACCOUNTABILITY (tamper-evident record of what happened), which it does. On a
// persistent audit-store outage every mutation returns 503 (loud, logged), so
// the system degrades to read-only rather than mutating unaudited — the intended
// compliance posture.
//
// PANIC BOUND. If a handler PANICS, the outermost middleware.Recover catches it
// and renders 500 with a full stack trace (loud, never silent); the panic unwinds
// PAST this middleware's post-c.Next() code, so a panicking request is not written
// to the trail. This is an accepted bound, not an evasion: an attacker cannot turn
// a panic into a SUCCESSFUL-but-unaudited mutation (a panic yields 500, not a
// completed action), and every panic is already captured by Recover's logging.
// Normal outcomes — including billing 402/503 and admin 403 denials, which return
// through c.Next() rather than panicking — are always audited.
import (
"errors"
"net/url"
"strings"
"github.com/hanzoai/cloud/audit"
"github.com/hanzoai/cloud/clients/principal"
"github.com/zap-proto/zip"
)
// AuditTrail returns the audit middleware bound to rec. A nil rec makes it a
// no-op passthrough so callers always Use() it unconditionally.
func AuditTrail(rec *audit.Recorder) zip.Handler {
if rec == nil {
return func(c *zip.Ctx) error { return c.Next() }
}
return func(c *zip.Ctx) error {
// Capture the pre-decision inputs BEFORE running the chain: the request
// context is recycled by Fiber after the handler returns, so identity and
// request fields must be read now (mirrors BillingGate capturing usage by
// value pre-Record).
method := c.Method()
path := c.Path()
err := c.Next()
// Resolve the EFFECTIVE status. A handler may set it on the response
// directly (c.Status(...).JSON(...)) OR return a *zip.HTTPError that the
// framework's error handler renders AFTER this middleware unwinds — in the
// latter case the response still reads 200 here, so the returned error is
// the authoritative source of a 401/403. Prefer the error's status when it
// carries one; this is what makes admin-guard denials (which return
// ErrForbidden) get audited as 403.
status := effectiveStatus(c.Fiber().Response().StatusCode(), err)
if !isSecurityRelevant(method, path, status) {
return err // not an audited event; pass the handler result through.
}
record := audit.Record{
Actor: actorFromCtx(c),
Action: method + " " + routeFamily(path),
Resource: resourceFromPath(path),
Auth: authFromCtx(c),
Outcome: outcomeOf(status, err),
SourceIP: ClientIP(c),
// User-Agent is client-controlled free text; a misconfigured/malicious
// client could embed a bearer token in it. Scrub credential-shaped runs
// (and cap length) so the UA can never carry a secret into the record.
UserAgent: scrubFreeText(c.Header("User-Agent")),
RequestID: c.RequestID(),
Method: method,
// Path is scrubbed of any credential-shaped segment: Hanzo routes use
// identifiers (:name/:slug/:id), not secrets, but a token that ever
// rides in the path (an hk-/sk-/pk-/fw_/hz_ key) must never be recorded
// verbatim. resourceFromPath applies the same scrub to the resource id.
Path: scrubCredentialSegments(path),
}
if _, aerr := rec.Append(c.Context(), record); aerr != nil {
// AU-5: could not record a security-relevant event — fail the request
// closed. Do NOT leak the audit error to the client; log it loud.
c.Log().Error("audit append failed — failing request closed",
"path", path, "method", method, "err", aerr)
return c.JSON(503, map[string]any{
"error": map[string]string{
"code": "audit_unavailable",
"message": "Request could not be securely recorded",
},
})
}
return err
}
}
// isSecurityRelevant is the coverage predicate — the ONE place that decides which
// requests enter the audit trail (AC/AU scope). Kept tiny and total so the
// coverage matrix is reviewable at a glance. Order matters and is deliberate:
// the security-relevant conditions are checked FIRST and are unconditional, so
// the health-probe exemption can NEVER be used to evade audit of a mutation or a
// denial (a POST/DELETE, or any 401/403, is always audited whatever the path).
// - any auth-failure outcome (401/403) on any method (a denied access attempt),
// - any /v1/admin/* request (admin reads are access-control-relevant),
// - any mutating request (POST/PUT/PATCH/DELETE).
//
// Only then, a genuine liveness probe (a GET to an exact health route) is
// exempted — it is neither a mutation, a denial, nor an admin call, so it is pure
// request-log noise. The exemption matches EXACT probe paths, never an arbitrary
// path that merely ends in "/health" (which a wildcard/attacker-named segment
// like POST /v1/admin/orgs/x/health could otherwise abuse to slip past audit).
func isSecurityRelevant(method, path string, status int) bool {
// Unconditional security signals — never suppressed by any path shape. A
// denial, an admin call, or a mutation is ALWAYS audited, whatever the path
// (so a wildcard/attacker-named "/health" tail cannot evade it).
if status == 401 || status == 403 {
return true
}
if strings.HasPrefix(path, "/v1/admin/") {
return true
}
if isMutation(method) {
return true
}
// Everything left is a safe read (non-mutating, non-admin, non-denied). None
// are audited — they are request-log noise, not security events. (Liveness
// probes fall here too; there is no separate case because the answer is the
// same: not recorded.)
return false
}
// isMutation reports whether a method changes state.
func isMutation(method string) bool {
switch method {
case "POST", "PUT", "PATCH", "DELETE":
return true
default:
return false
}
}
// actorFromCtx builds the Actor from the sanitized identity headers, gating
// ANTI-FORGERY of the recorded actor on a VALIDATED principal.
//
// The authoritative "this request carried a validated principal" signal is a
// non-empty X-User-Id (c.User()): SanitizeIdentity sets X-User-Id ONLY from a
// JWT it verified, and strips any client-supplied copy on ingress. A request
// with no principal — anonymous, OR one bearing an INVALID/garbage bearer that
// failed validation — has an empty c.User().
//
// In that unvalidated case the org header is NOT trustworthy: SanitizeIdentity's
// Phase-1 residual restores a client-supplied X-Org-Id for the data path, so an
// anonymous attacker could send X-Org-Id: victim-org and, if we recorded it,
// forge a FALSE ATTRIBUTION (an event stamped with a victim's org). So when there
// is no validated sub, the actor is left EMPTY — the record stands as an honest
// anonymous event identified by SourceIP, never mis-attributed to a claimed org.
//
// With a validated sub, org/sub/email all reflect the verified principal and are
// recorded authoritatively.
func actorFromCtx(c *zip.Ctx) audit.Actor {
if !principal.Validated(c) {
// No validated principal — do not trust the client-asserted org.
return audit.Actor{}
}
return audit.Actor{
Org: strings.TrimSpace(c.Org()),
Sub: strings.TrimSpace(c.User()),
Email: strings.TrimSpace(c.UserEmail()),
}
}
// authFromCtx records HOW the caller authenticated and the VALIDATED admin bit.
// IsAdmin comes from c.IsAdmin() (the sanitized X-User-IsAdmin, true only for a
// verified global admin), never a raw header. Method is inferred from the
// presence/shape of a credential: an Authorization/X-Authorization bearer or a
// session cookie ⇒ "jwt" (or "api-key" for an opaque hk-/sk- token); none ⇒
// "none".
func authFromCtx(c *zip.Ctx) audit.AuthContext {
return audit.AuthContext{
Method: authMethodOf(c),
IsAdmin: c.IsAdmin(),
}
}
// authMethodOf classifies the credential kind WITHOUT capturing it — it inspects
// only the token PREFIX (never stores the value). Order mirrors the sanitizer's
// extraction (bearer, then cookie).
func authMethodOf(c *zip.Ctx) string {
auth := c.Header("Authorization")
if auth == "" {
auth = c.Header("X-Authorization")
}
if tok := bearerFromAuth(auth); tok != "" {
if isAPIKey(tok) {
return "api-key"
}
return "jwt"
}
if basicFromAuth(auth) != "" {
return "basic"
}
for _, name := range cookieTokenNames {
if c.Fiber().Cookies(name) != "" {
return "jwt"
}
}
return "none"
}
// effectiveStatus reconciles the response status with a returned error. If the
// handler returned a *zip.HTTPError (e.g. ErrForbidden), its Status is
// authoritative — the framework renders it after this middleware unwinds, so the
// live response status does not yet reflect it. A non-HTTPError returned error
// with a still-2xx response means the framework will render a 500. Otherwise the
// response status stands.
func effectiveStatus(respStatus int, err error) int {
if err != nil {
var he *zip.HTTPError
if errors.As(err, &he) && he.Status != 0 {
return he.Status
}
// A non-HTTPError propagating up renders as 500, unless the handler already
// set an explicit error status on the response.
if respStatus < 400 {
return 500
}
}
return respStatus
}
// outcomeOf maps the final HTTP status + handler error to an audit Outcome.
// 2xx/3xx ⇒ success; 401/403 ⇒ deny; everything else (4xx/5xx) ⇒ error. The
// reason is a short, non-sensitive label derived from the status class — never a
// raw upstream error body (which could echo sensitive detail).
func outcomeOf(status int, err error) audit.Outcome {
switch {
case status == 401:
return audit.Outcome{Result: "deny", Status: status, Reason: "unauthenticated"}
case status == 403:
return audit.Outcome{Result: "deny", Status: status, Reason: "forbidden"}
case status >= 500:
return audit.Outcome{Result: "error", Status: status, Reason: "server_error"}
case status >= 400:
return audit.Outcome{Result: "error", Status: status, Reason: "client_error"}
default:
return audit.Outcome{Result: "success", Status: status}
}
}
// routeFamily reduces a concrete path to its stable route family for the action
// verb, dropping trailing high-cardinality id segments so "DELETE /v1/admin/
// orgs/acme" and "DELETE /v1/admin/orgs/globex" share the action "DELETE
// /v1/admin/orgs". The full concrete path is preserved separately in Record.Path.
func routeFamily(path string) string {
segs := strings.Split(strings.Trim(path, "/"), "/")
// Keep the leading "v1/<subsystem>/<noun>" and stop before an id-looking tail.
out := make([]string, 0, len(segs))
for _, s := range segs {
if looksLikeID(s) {
break
}
out = append(out, s)
}
if len(out) == 0 {
return "/" + strings.Join(segs, "/")
}
return "/" + strings.Join(out, "/")
}
// resourceFromPath derives the {type,id} resource from a /v1/<subsystem>/<type>/
// [<id>] path. Type is the noun after the subsystem; ID is the following segment
// when it looks like an identifier. Best-effort — the Action verb + Path are the
// authoritative locator; this is a convenience for filtering by resource type.
func resourceFromPath(path string) audit.Resource {
segs := strings.Split(strings.Trim(path, "/"), "/")
// segs: [v1, <subsystem>, <type>, <id?>, ...]
if len(segs) < 3 {
return audit.Resource{}
}
res := audit.Resource{Type: segs[2]}
if len(segs) >= 4 && looksLikeID(segs[3]) {
res.ID = scrubToken(segs[3])
}
return res
}
// scrubToken replaces a path segment that is a credential-shaped token with a
// fixed marker, so a secret that ever appears in a URL is never recorded
// verbatim. It catches, in increasing generality:
// - a known API-key prefix (hk-/sk-/pk-/fw_/hz_ — what isAPIKey recognizes),
// ALSO after percent-decoding, so hk%2DKEY can't slip the prefix check,
// - a JWT (three base64url parts split by '.', starting eyJ),
// - a long, high-entropy base64url/hex run (>=24) — a raw API key / access
// token / hex secret that carries no telltale prefix.
//
// A normal identifier passes through unchanged: a UUID (5 hyphen-split groups,
// each short), a slug, a numeric id, a dotted model name — none is a long
// unbroken high-entropy blob. See TestScrubToken_NoFalsePositives.
//
// RED-review hardening (finding: scrub bypass): the entropy test now (a) accepts
// the FULL base64url alphabet incl. '-' and '_' (RFC 4648 §5), (b) does NOT
// require a digit (an all-alpha opaque key is still a secret), (c) percent-
// decodes first so %2D/%5F can't hide structure, and (d) drops the threshold to
// 24 (short enough for a 128-bit base64 or a 24-hex key, long enough that no
// human-readable slug reaches it).
func scrubToken(seg string) string {
dec := percentDecode(seg)
if isAPIKey(seg) || isAPIKey(dec) ||
looksLikeJWT(seg) || looksLikeJWT(dec) ||
looksLikeHighEntropyToken(seg) || looksLikeHighEntropyToken(dec) {
return "[REDACTED-TOKEN]"
}
return seg
}
// percentDecode best-effort URL-decodes s so a percent-encoded credential
// (hk%2DKEY, sk%5Flive%5F…) is normalized before the credential tests run. On a
// malformed escape it returns s unchanged (the raw form is then tested as-is).
func percentDecode(s string) string {
// Decode repeatedly (bounded) so a NESTED encoding (%252D -> %2D -> -) is
// fully normalized before the credential tests run. Stop when a pass makes no
// change, on a malformed escape, or after a small cap (defeats a decode bomb).
for i := 0; i < 3 && strings.Contains(s, "%"); i++ {
dec, err := url.PathUnescape(s)
if err != nil || dec == s {
break
}
s = dec
}
return s
}
// looksLikeJWT reports whether s is a JSON Web Token OR a JWT header segment. A
// full JWT is three base64url parts split by '.', header starting "eyJ". But when
// free text (a UA) is tokenized on '.', a dotted JWT splits into parts; a real
// header part is >=24 chars (caught by the high-entropy run), yet to be safe we
// ALSO flag any lone segment starting with the canonical base64url header prefix
// "eyJ" (which decodes to '{"') regardless of length — a JWT header can never be
// a legitimate resource id, so redacting it has no false-positive cost.
func looksLikeJWT(s string) bool {
if !strings.HasPrefix(s, "eyJ") {
return false
}
// A full token (two dots) or a bare header segment — either way, redact.
return true
}
// highEntropyMinLen is the length at/above which an UNBROKEN run of base64/hex
// chars is treated as an opaque secret. 24 covers a 128-bit base64 token, a
// 24-nibble hex key, and short API keys, while every human-readable path
// segment/slug/model-name stays under it once split on its separators — no
// single run of a name like "text-embedding-3-large" reaches 24.
const highEntropyMinLen = 24
// looksLikeHighEntropyToken reports whether s CONTAINS an unbroken run of
// >= highEntropyMinLen base64/hex chars — the shape of a raw API key / access
// token / hex secret — UNLESS s is a structured human identifier.
//
// The run alphabet is [A-Za-z0-9_+/-] — the base64 alphabets (url-safe §5 '-”_'
// and standard §4 '+”/') and hex. '-' is INCLUDED so a url-safe-base64 token
// that embeds '-' is still caught by its run (excluding '-' left an ~11% bypass
// for 32-byte url-safe tokens whose '-' happened to break every 24-run — measured).
//
// TWO-STAGE DETECTION:
//
// 1. UNCONDITIONAL run scan — a >= highEntropyMinLen UNBROKEN run over
// [A-Za-z0-9_+/-] flags the value REGARDLESS of the structured-id exemption.
// This catches every raw secret WITHOUT internal separators (hex, base64) —
// the realistic "a client bug put a raw key in the URL" case — at 100%. A
// structured identifier never has a 24-char unbroken run, so this stage never
// over-scrubs one.
//
// 2. STRUCTURED-ID EXEMPTION for the rest (values with separators that DON'T have
// a 24-run): exempt a clearly hyphen-joined human id, judged by LEXICAL
// content — every group WORD-LIKE (single-case word or decimal number), which
// a mixed-case base64 chunk or a long hex-with-letters chunk is NOT (those are
// redacted). Shape alone is attacker-satisfiable (RED found a 3x12-chunked
// secret slipped a shape-only check); the lexical test rejects the common
// secret encodings.
//
// ACCEPTED RESIDUAL BOUND (documented, per RED review): the ONLY residual is a
// secret deliberately chunked into >=3 SINGLE-CASE-ALPHABETIC groups of <=12 chars
// with no hex-letter runs >= hexChunkMinLen (a lowercase- or uppercase-only base32
// alphabet, e.g. abcdefghijkl-mnopqrstuvwx-…). Such a value is lexically
// indistinguishable from a hyphenated model id (deepseek-r1-distill-qwen-32b
// carries the SAME 24-char entropy budget), so it passes stage 2. This is NOT
// closable by any length/case/count rule without a word dictionary
// (over-engineering for a defense-in-depth URL/UA backstop). Mixed-case base64
// chunks AND small-hex chunks (md5/sha display grouping) ARE now caught
// (isWordLikeGroup). The residual does not widen exposure for any REAL credential:
// Hanzo keys are hk-/sk-/pk-/fw_/hz_-prefixed (isAPIKey, caught at any
// length/shape), JWTs are eyJ-prefixed (looksLikeJWT), and request/response BODIES
// are never read. It is an adversary DELIBERATELY base32-chunking their OWN secret
// into a URL path to seed an admin-only audit row — contrived, low-value. The
// realistic accidental leak (an unbroken raw key) is caught by stage 1.
//
// A canonical UUID is exempt (its longest run is 12; the check documents intent).
func looksLikeHighEntropyToken(s string) bool {
if len(s) < highEntropyMinLen {
return false
}
// Stage 1 — unconditional: a long unbroken run is always a secret.
if hasHighEntropyRun(s) {
return true
}
// Stage 2 — separated values: redact unless it's a lexical structured id.
if isUUID(s) || isStructuredID(s) {
return false
}
// A >=24-length value with separators, not a UUID, not a structured id — e.g.
// "sk.live.LONGSECRET…" dotted, or a chunk pattern that is not word-like.
return true
}
// hasHighEntropyRun reports whether s contains an unbroken run of
// >= highEntropyMinLen high-entropy chars where the run alphabet EXCLUDES '-'
// (and '.'): a real separator breaks the run. This is stage 1 — it fires only on
// a genuinely UNBROKEN opaque blob (a raw hex/base64 key with no separators), so
// it never catches a hyphenated identifier (which stage 2 then classifies). A
// url-safe-base64 secret that embeds '-' still trips because the run on ONE side
// of the hyphen is >= 24 (verified: "AbCdEf-GhIjKl_MnOpQrStUvWxYz012345" -> 27).
func hasHighEntropyRun(s string) bool {
run := 0
for _, r := range s {
if isUnbrokenTokenChar(r) {
run++
if run >= highEntropyMinLen {
return true
}
} else {
run = 0
}
}
return false
}
// isUnbrokenTokenChar is the stage-1 run alphabet: base64/hex MINUS '-' (and the
// implicit exclusion of '.', space, etc.). '_' '+' '/' are kept — they appear
// inside opaque tokens and are not identifier separators.
func isUnbrokenTokenChar(r rune) bool {
return (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') ||
(r >= '0' && r <= '9') || r == '_' || r == '+' || r == '/'
}
// idPartMaxLen bounds a hyphen-group length in the structured-id exemption. 12
// covers the longest word in real model ids ("embedding", "20241022", "preview")
// while a raw secret's random hyphen groups routinely exceed it.
const idPartMaxLen = 12
// isStructuredID reports whether s is a hyphen-joined human identifier (a model
// name, slug): >= 3 hyphen groups where EVERY group is non-empty, <= idPartMaxLen
// chars, and WORD-LIKE. The word-like test is the anti-bypass core — a raw secret
// chunk cannot satisfy it — so the exemption is not attacker-satisfiable by shape.
func isStructuredID(s string) bool {
groups := strings.Split(s, "-")
if len(groups) < 3 {
return false
}
for _, g := range groups {
if g == "" || len(g) > idPartMaxLen || !isWordLikeGroup(g) {
return false
}
}
return true
}
// isWordLikeGroup reports whether a hyphen group looks like a model-id token (a
// dictionary word or a decimal number) rather than a random secret chunk. Two
// lexical signals reject a secret chunk:
// - MIXED CASE (both upper and lower letters) — base64 tokens are dense
// mixed-case; real model tokens are single-case ("sonnet", "Instruct", "3").
// - an all-hex-with-letters run (>= hexChunkMinLen chars, all [0-9a-fA-F], and
// not all-digits) — a hex secret chunk ("dead", "beef", "cafebabe"); a version
// date ("20241022", all digits) is NOT hex-with-letters, so it stays
// word-like. The threshold is 4: RED re-review verified that NO real model-id
// group is all-hex-with-letters of length >= 4, so 4 (vs the prior 8) closes
// the small-hex-chunk leak (md5/sha shown in "xxxx-xxxx" display grouping)
// with zero model-id over-scrub.
func isWordLikeGroup(g string) bool {
var hasUpper, hasLower, allHex, allDigit bool = false, false, true, true
for _, r := range g {
switch {
case r >= 'A' && r <= 'Z':
hasUpper = true
case r >= 'a' && r <= 'z':
hasLower = true
}
if r < '0' || r > '9' {
allDigit = false
}
isHex := (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')
if !isHex {
allHex = false
}
}
if hasUpper && hasLower {
return false // dense mixed-case → base64 secret chunk, not a word.
}
if allHex && !allDigit && len(g) >= hexChunkMinLen {
return false // hex-with-letters → hex secret chunk (not a version date).
}
return true
}
// hexChunkMinLen is the length at/above which an all-hex-with-letters group is
// treated as a secret chunk rather than a word. 4 is the tightest bound that does
// not over-scrub any real model-id group (RED-verified across 19 model ids) while
// catching hex secrets displayed in short groups (md5 "xxxx-xxxx", uuid-ish).
const hexChunkMinLen = 4
// isUUID reports whether s is a canonical 8-4-4-4-12 hex UUID (case-insensitive).
// Used to exempt uuids from the high-entropy secret test — a uuid is a legitimate
// resource id, not a credential.
func isUUID(s string) bool {
if len(s) != 36 {
return false
}
for i, r := range s {
switch i {
case 8, 13, 18, 23:
if r != '-' {
return false
}
default:
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
return false
}
}
}
return true
}
// maxUserAgentLen caps the recorded User-Agent so an oversized UA can neither
// bloat the trail nor smuggle a long payload. 512 chars covers every real UA.
const maxUserAgentLen = 512
// scrubFreeText scrubs credential-shaped words out of client-controlled free
// text (the User-Agent) and caps its length. It tokenizes on a broad delimiter
// superset — whitespace and the punctuation that commonly glues a token into a
// UA/header value (= ; , : / ( ) [ ] { } " ' < > | and backslash) — scrubs each
// token, and rebuilds the string preserving the exact delimiters between tokens.
//
// RED-review hardening (finding: UA tokenizer split on too few delimiters, and
// strings.ReplaceAll was substring-fragile): this walks the string in one pass
// (token-run, delimiter-run, …) and replaces each credential token IN PLACE, so a
// secret delimited by ':' '/' '(' etc. is caught and a token that is a substring
// of another is never mis-replaced. A normal UA ("Mozilla/5.0 (Macintosh …)") is
// unchanged because none of its words is credential-shaped.
func scrubFreeText(s string) string {
if s == "" {
return ""
}
if len(s) > maxUserAgentLen {
s = s[:maxUserAgentLen]
}
var b strings.Builder
b.Grow(len(s))
start := -1 // start index of the current token run, or -1 in a delimiter run.
flush := func(end int) {
if start >= 0 {
b.WriteString(scrubToken(s[start:end]))
start = -1
}
}
for i, r := range s {
if isFreeTextDelimiter(r) {
flush(i)
b.WriteRune(r)
} else if start < 0 {
start = i
}
}
flush(len(s))
return b.String()
}
// isFreeTextDelimiter reports whether r separates tokens in free text (a UA /
// header value). Deliberately broad so a credential can't hide behind an unusual
// separator.
//
// RED re-review (UA bypass persists): '.' '@' '#' '~' are INCLUDED — a prefixed
// key glued by one of them (client@sk-live-KEY, app.sk-live-KEY, build#hk-KEY)
// otherwise stayed one token whose PREFIX was no longer sk-/hk-, so isAPIKey
// missed it. Splitting on them exposes the bare key to scrubToken. Real UA dots
// are numeric version separators (<24, safe) and are split harmlessly. A JWT
// (eyJ.h.p.s) is handled up-front by scrubToken via looksLikeJWT before any
// tokenizer runs on a URL path segment; in free text a dotted JWT will split,
// but each ~40-char base64 part is itself a >=24 high-entropy run, so every part
// is still redacted.
func isFreeTextDelimiter(r rune) bool {
switch r {
case ' ', '\t', '\n', '\r', '=', ';', ',', ':', '/', '\\',
'(', ')', '[', ']', '{', '}', '"', '\'', '<', '>', '|', '&', '?',
'.', '@', '#', '~':
return true
}
return false
}
// scrubCredentialSegments applies scrubToken to every segment of a path, so the
// recorded Path can never carry a credential even if a future route embeds one.
// Route nouns/ids pass through unchanged (they are not isAPIKey-shaped).
func scrubCredentialSegments(path string) string {
if !strings.ContainsAny(path, "/") {
return scrubToken(path)
}
segs := strings.Split(path, "/")
changed := false
for i, s := range segs {
if scrubbed := scrubToken(s); scrubbed != s {
segs[i] = scrubbed
changed = true
}
}
if !changed {
return path
}
return strings.Join(segs, "/")
}
// looksLikeID heuristically flags a path segment as a high-cardinality id (a
// uuid, a long hex/opaque token, or a numeric id) vs a fixed route noun. Used to
// collapse route families; false positives only make the action slightly more
// specific, never leak anything.
func looksLikeID(s string) bool {
if s == "" {
return false
}
if len(s) >= 16 { // long opaque/uuid-ish segment
return true
}
allDigits := true
for _, r := range s {
if r < '0' || r > '9' {
allDigits = false
break
}
}
return allDigits && len(s) > 0
}
+579
View File
@@ -0,0 +1,579 @@
package cloud
// Integration tests for the audit middleware. They drive REAL requests through
// the zip/fiber stack (app.Fiber().Test) with the audit middleware in front of a
// handler, backed by a REAL on-disk audit store, then read the store back to
// assert what was (and was not) recorded. No mocks — the whole capture path runs.
//
// The middleware trusts SanitizeIdentity to have already validated identity, so
// these tests set the sanitized X-User-* headers directly (as SanitizeIdentity
// would after verifying a JWT) — that is the contract boundary under test here.
// The forgery/bypass properties of SanitizeIdentity itself are proven in
// middleware_identity_test.go; here we prove the middleware records the VALIDATED
// identity and the correct outcome for every security-relevant request.
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/hanzoai/cloud/audit"
"github.com/zap-proto/zip"
)
// newAuditApp wires a zip app with the audit middleware in front of a small set
// of routes covering the coverage matrix: a mutating POST, a safe GET, an
// admin-gated route that 403s, and a route that echoes a (secret-bearing) body
// so we can prove the body never reaches a record.
func newAuditApp(t *testing.T) (*zip.App, *audit.Recorder) {
t.Helper()
path := filepath.Join(t.TempDir(), "audit.db")
rec, err := audit.Open(path, nil)
if err != nil {
t.Fatalf("audit.Open: %v", err)
}
t.Cleanup(func() { _ = rec.Close() })
app := zip.New(zip.Config{})
app.Use(AuditTrail(rec))
// Mutating route — must be audited.
app.Post("/v1/kms/secrets", func(c *zip.Ctx) error {
return c.JSON(http.StatusCreated, map[string]string{"id": "sec_1"})
})
// Safe read — must NOT be audited (not a mutation, not admin, not a denial).
app.Get("/v1/pricing/models", func(c *zip.Ctx) error {
return c.JSON(http.StatusOK, map[string]string{"ok": "true"})
})
// Admin route that denies (as the real admin guard would) — the 403 is a
// security event and MUST be audited even though it is a GET.
app.Get("/v1/admin/orgs", func(c *zip.Ctx) error {
if !c.IsAdmin() {
return zip.ErrForbidden("global admin required")
}
return c.JSON(http.StatusOK, map[string]string{"ok": "true"})
})
// A mutation whose request body carries secrets — used to prove the body is
// never captured. The handler ignores the body; the point is what the
// middleware records (metadata only).
app.Post("/v1/iam/users", func(c *zip.Ctx) error {
return c.JSON(http.StatusOK, map[string]string{"ok": "true"})
})
return app, rec
}
// asAdmin sets the sanitized identity headers a VALIDATED global admin would
// carry after SanitizeIdentity (X-User-IsAdmin=true, org=admin).
func asAdmin(req *http.Request) {
req.Header.Set("X-User-Id", "z@hanzo.ai")
req.Header.Set("X-User-Email", "z@hanzo.ai")
req.Header.Set("X-Org-Id", "admin")
req.Header.Set("X-User-IsAdmin", "true")
req.Header.Set("Authorization", "Bearer eyJ.validated.jwt") // shape only; classifies as jwt
}
// asUser sets sanitized headers for a normal (non-admin) validated principal.
func asUser(req *http.Request) {
req.Header.Set("X-User-Id", "alice")
req.Header.Set("X-Org-Id", "acme")
req.Header.Set("Authorization", "Bearer eyJ.validated.jwt")
}
func mustTest(t *testing.T, app *zip.App, req *http.Request) *http.Response {
t.Helper()
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("Test %s %s: %v", req.Method, req.URL.Path, err)
}
return resp
}
// TestAudit_RecordsMutation proves a mutating request is captured with the
// correct validated actor, action, resource, outcome, and auth context — and the
// record is hash-chained (has a hash) and verifies.
func TestAudit_RecordsMutation(t *testing.T) {
app, rec := newAuditApp(t)
req := httptest.NewRequest(http.MethodPost, "/v1/kms/secrets", nil)
asUser(req)
req.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.1")
req.Header.Set("User-Agent", "test-agent/1.0")
resp := mustTest(t, app, req)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status = %d, want 201", resp.StatusCode)
}
rows, total, err := rec.Query(t.Context(), audit.Filter{})
if err != nil {
t.Fatalf("Query: %v", err)
}
if total != 1 || len(rows) != 1 {
t.Fatalf("recorded %d events, want exactly 1", total)
}
r := rows[0]
if r.Actor.Org != "acme" || r.Actor.Sub != "alice" {
t.Errorf("actor = %+v, want org=acme sub=alice (the VALIDATED identity)", r.Actor)
}
if r.Method != "POST" || r.Path != "/v1/kms/secrets" {
t.Errorf("method/path = %s %s, want POST /v1/kms/secrets", r.Method, r.Path)
}
if r.Resource.Type != "secrets" {
t.Errorf("resource type = %q, want secrets", r.Resource.Type)
}
if r.Outcome.Result != "success" || r.Outcome.Status != 201 {
t.Errorf("outcome = %+v, want success/201", r.Outcome)
}
if r.Auth.Method != "jwt" {
t.Errorf("auth method = %q, want jwt", r.Auth.Method)
}
if r.SourceIP != "203.0.113.9" {
t.Errorf("source ip = %q, want the left-most XFF entry", r.SourceIP)
}
if r.UserAgent != "test-agent/1.0" {
t.Errorf("user agent = %q, want test-agent/1.0", r.UserAgent)
}
if r.Hash == "" {
t.Error("record has no hash — not chained")
}
if iv, _ := rec.Verify(t.Context()); !iv.OK {
t.Errorf("chain broke after one record at %d (%s)", iv.BrokenAt, iv.Reason)
}
}
// TestAudit_RecordsDenial proves a 403 (access-control denial) is audited even on
// a GET, with outcome result="deny", and records the actor who was denied.
func TestAudit_RecordsDenial(t *testing.T) {
app, rec := newAuditApp(t)
// A NON-admin hits the admin route → 403.
req := httptest.NewRequest(http.MethodGet, "/v1/admin/orgs", nil)
asUser(req) // not admin
resp := mustTest(t, app, req)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status = %d, want 403", resp.StatusCode)
}
rows, total, err := rec.Query(t.Context(), audit.Filter{Result: "deny"})
if err != nil {
t.Fatalf("Query: %v", err)
}
if total != 1 {
t.Fatalf("recorded %d denials, want 1", total)
}
r := rows[0]
if r.Outcome.Result != "deny" || r.Outcome.Status != 403 {
t.Errorf("outcome = %+v, want deny/403", r.Outcome)
}
if r.Actor.Sub != "alice" {
t.Errorf("denied actor sub = %q, want alice", r.Actor.Sub)
}
if r.Auth.IsAdmin {
t.Error("denied non-admin recorded as admin")
}
}
// TestAudit_SkipsSafeReads proves an ordinary successful GET on a non-admin route
// is NOT audited — the trail captures security events, not read-log noise.
func TestAudit_SkipsSafeReads(t *testing.T) {
app, rec := newAuditApp(t)
req := httptest.NewRequest(http.MethodGet, "/v1/pricing/models", nil)
asUser(req)
resp := mustTest(t, app, req)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
_, total, err := rec.Query(t.Context(), audit.Filter{})
if err != nil {
t.Fatalf("Query: %v", err)
}
if total != 0 {
t.Fatalf("a safe GET was audited (%d rows) — trail should skip non-security reads", total)
}
}
// TestAudit_AdminReadIsAudited proves a SUCCESSFUL admin read is audited (admin
// access itself is an AC-relevant event), distinguishing it from a normal read.
func TestAudit_AdminReadIsAudited(t *testing.T) {
app, rec := newAuditApp(t)
req := httptest.NewRequest(http.MethodGet, "/v1/admin/orgs", nil)
asAdmin(req)
resp := mustTest(t, app, req)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
rows, total, err := rec.Query(t.Context(), audit.Filter{})
if err != nil {
t.Fatalf("Query: %v", err)
}
if total != 1 {
t.Fatalf("admin read recorded %d, want 1", total)
}
if !rows[0].Auth.IsAdmin || rows[0].Outcome.Result != "success" {
t.Errorf("admin read record = auth.isAdmin=%v outcome=%+v, want admin+success", rows[0].Auth.IsAdmin, rows[0].Outcome)
}
}
// TestAudit_NeverCapturesRequestBody is the secret-safety proof: a mutation whose
// body is FULL of credentials is audited, but the stored record contains NONE of
// the body — the middleware captures metadata only, so a secret in a body can
// never leak into the trail. This is the "reuse RedactUserSecrets" guarantee at
// its strongest: the code that could leak a secret never reads it.
func TestAudit_NeverCapturesRequestBody(t *testing.T) {
app, rec := newAuditApp(t)
secretBody := `{"username":"bob","password":"hunter2","apiKey":"sk-live-DEADBEEF","token":"ghp_SECRET"}`
req := httptest.NewRequest(http.MethodPost, "/v1/iam/users", strings.NewReader(secretBody))
req.Header.Set("Content-Type", "application/json")
asAdmin(req)
// Also stuff a secret into a header value that is NOT an identity header — it
// must not be captured either (we only record User-Agent + XFF, never arbitrary
// headers, and never Authorization's value).
req.Header.Set("Authorization", "Bearer eyJsuper.secret.token.value")
resp := mustTest(t, app, req)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
rows, total, err := rec.Query(t.Context(), audit.Filter{})
if err != nil {
t.Fatalf("Query: %v", err)
}
if total != 1 {
t.Fatalf("recorded %d, want 1", total)
}
// Serialize the WHOLE record and scan for any secret substring.
blob, _ := json.Marshal(rows[0])
for _, secret := range []string{"hunter2", "sk-live-DEADBEEF", "ghp_SECRET", "super.secret.token.value"} {
if strings.Contains(string(blob), secret) {
t.Fatalf("SECRET LEAKED into audit record: %q found in %s", secret, blob)
}
}
// But the metadata IS there: the auth method is classified without the token.
if rows[0].Auth.Method != "jwt" {
t.Errorf("auth method = %q, want jwt (classified from prefix, token not stored)", rows[0].Auth.Method)
}
if rows[0].Before != nil || rows[0].After != nil {
t.Errorf("middleware set before/after (%s / %s) — it must never read bodies", rows[0].Before, rows[0].After)
}
}
// TestAudit_ScrubsCredentialInPath proves a credential-shaped token that rides in
// the URL PATH (e.g. a KMS route where a caller wrongly puts an sk-/hk- key in the
// path) is never recorded verbatim in either Path or resource.ID — defense in
// depth beyond "bodies are never read". A normal identifier is untouched.
func TestAudit_ScrubsCredentialInPath(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.db")
rec, err := audit.Open(path, nil)
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = rec.Close() })
app := zip.New(zip.Config{})
app.Use(AuditTrail(rec))
app.Delete("/v1/kms/secrets/*", func(c *zip.Ctx) error {
return c.JSON(http.StatusOK, map[string]string{"ok": "true"})
})
// Three credential shapes smuggled into the path across three requests: a
// prefixed key, a raw high-entropy hex secret (NO telltale prefix), and a JWT.
paths := []struct{ path, secret string }{
{"/v1/kms/secrets/sk-live-SUPERSECRETKEY1234567890", "SUPERSECRETKEY"},
{"/v1/kms/secrets/deadbeefcafe0123456789abcdef0123456789abcdef0123", "deadbeefcafe0123"},
{"/v1/kms/secrets/eyJhbGciOiJIUzI1NiJ9.cGF5bG9hZA.c2ln", "eyJhbGciOiJIUzI1NiJ9"},
}
for _, tc := range paths {
req := httptest.NewRequest(http.MethodDelete, tc.path, nil)
asAdmin(req)
if resp := mustTest(t, app, req); resp.StatusCode != http.StatusOK {
t.Fatalf("%s: status = %d, want 200", tc.path, resp.StatusCode)
}
}
rows, total, err := rec.Query(t.Context(), audit.Filter{})
if err != nil {
t.Fatalf("Query: %v", err)
}
if total != len(paths) {
t.Fatalf("recorded %d, want %d", total, len(paths))
}
blob, _ := json.Marshal(rows)
for _, tc := range paths {
if strings.Contains(string(blob), tc.secret) {
t.Fatalf("credential in path leaked into record: %q present in %s", tc.secret, blob)
}
}
for _, r := range rows {
if !strings.Contains(r.Path, "[REDACTED-TOKEN]") {
t.Errorf("path token not scrubbed: %q", r.Path)
}
}
}
// TestAudit_ScrubsSecretInUserAgent proves a bearer/API-key embedded in the
// client-controlled User-Agent is scrubbed, and a normal UA is untouched.
func TestAudit_ScrubsSecretInUserAgent(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.db")
rec, err := audit.Open(path, nil)
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = rec.Close() })
app := zip.New(zip.Config{})
app.Use(AuditTrail(rec))
app.Post("/v1/kms/secrets", func(c *zip.Ctx) error { return c.JSON(http.StatusOK, map[string]string{"ok": "1"}) })
req := httptest.NewRequest(http.MethodPost, "/v1/kms/secrets", nil)
asAdmin(req)
req.Header.Set("User-Agent", "myclient/1.0 Bearer eyJhbGciOiJI.pay.sig key=sk-live-LEAKME99999")
mustTest(t, app, req)
rows, _, _ := rec.Query(t.Context(), audit.Filter{})
if len(rows) != 1 {
t.Fatalf("got %d rows, want 1", len(rows))
}
for _, secret := range []string{"eyJhbGciOiJI", "sk-live-LEAKME99999"} {
if strings.Contains(rows[0].UserAgent, secret) {
t.Errorf("UA secret leaked: %q in %q", secret, rows[0].UserAgent)
}
}
// The non-secret UA prefix survives (audit usefulness preserved).
if !strings.Contains(rows[0].UserAgent, "myclient/1.0") {
t.Errorf("UA over-scrubbed, lost the client name: %q", rows[0].UserAgent)
}
}
// TestScrubToken_NoFalsePositives proves legitimate identifiers are NEVER
// scrubbed — the guard fires only on genuinely secret-shaped segments, so the
// audit trail keeps its query precision for normal resource ids.
func TestScrubToken_NoFalsePositives(t *testing.T) {
for _, id := range []string{
"acme-corp", "gpt-4o-mini", "my_project_123", "user@example.com",
"550e8400-e29b-41d4-a716-446655440000", // uuid (hyphens)
"claude-opus-4-20250514", "text-embedding-3-large",
"my-cool-project-name", "feature-branch-xyz",
"deployment-2024-01-15", "report.pdf", "data.json",
// Red re-review round 2 — hyphenated model ids MUST pass (AU-3: an auditor
// must still see WHICH model a config change touched).
"claude-3-5-sonnet-20241022", "claude-3-5-haiku-20241022",
"claude-sonnet-4-20250514", "claude-3-7-sonnet-20250219",
"claude-3-5-sonnet-latest", "deepseek-r1-distill-qwen-32b",
"claude-3-opus-20240229", "stable-diffusion-xl-base",
"mixtral-8x7b-instruct", "llama-3-1-8b-instruct", "whisper-large-v3-turbo",
"v1", "models", "sync", "12345", "a", "",
} {
if got := scrubToken(id); got != id {
t.Errorf("false scrub: legit id %q → %q", id, got)
}
}
// And genuine secrets ARE scrubbed.
for _, sec := range []string{
"sk-live-abcdef", "hk-1234567890abcdef", "eyJhbG.payload.signature",
"deadbeefcafe0123456789abcdef0123456789abcdef0123", // 48-char raw hex
} {
if scrubToken(sec) == sec {
t.Errorf("missed secret: %q not scrubbed", sec)
}
}
}
// TestScrubToken_RedReviewBypassClasses is the regression for the 4 scrub-bypass
// classes Red found: base64url with -/_, all-alpha opaque >=len, percent-encoded
// prefixes, and delimiter-glued UA tokens. Each MUST now be redacted.
func TestScrubToken_RedReviewBypassClasses(t *testing.T) {
for _, sec := range []string{
"AbCdEf-GhIjKl_MnOpQrStUvWxYz012345", // base64url with - and _
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN", // all-alpha opaque, no digit
"hk%2DROTATEKEY0001SECRETKEY", // percent-encoded hk-
"sk%5Flive%5FBYPASS0001SECRETKEY", // percent-encoded sk_
// Red re-review round 2 — dotted-exemption + encoding + standard-base64:
"deadbeefcafe0123456789abcdef0123456789abcdef.x", // ".x" tail forces dotted exemption
"AbCdEfGhIjKlMnOpQrStUvWxYz012345.json", // secret with a filename-ish suffix
"hk%252DROTATEKEY0001SECRETKEY", // double percent-encoded hk-
"AbCdEfGhIjKlMnOpQrStUvWx0123456789", // 34-char opaque run (standard/url b64)
// Red re-review round 3 — interior-hyphen raw secret (NOT a structured id:
// only 1 hyphen, long parts) must still redact despite '-' in the run.
"aaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbbbbbbbbbb",
// Red final re-review — a secret CHUNKED to satisfy the structured-id shape
// (>=3 groups <=12 chars) must STILL redact: the lexical (word-like) test
// rejects mixed-case base64 chunks and hex chunks (>= hexChunkMinLen=4).
"AbCdEfGhIjKl-MnOpQrStUvWx-YzAbCdEfGhIj", // mixed-case base64 chunks
"A1b2C3d4-E5f6G7h8-I9j0K1l2", // 128-bit mixed-case chunks
"deadbeef-cafebabe-01234567-89abcdef", // all-hex chunks (8-char groups)
"abcdef01-23456789-abcdef01", // hex chunks
// Red final polish — SMALL hex chunks (md5/sha "xxxx-xxxx" display) now
// caught by the len>=4 hex rule.
"abcd-ef01-2345-6789-abcd-ef01", // 4-char hex groups
"dead-beef-cafe-babe-0123-4567", // 4-char hex groups
} {
if got := scrubToken(sec); got != "[REDACTED-TOKEN]" {
t.Errorf("Red bypass STILL OPEN: %q → %q (want redacted)", sec, got)
}
}
// UA with a secret glued by :/()[]= — must be scrubbed, client name kept.
ua := scrubFreeText("myapp/1.0 (token:sk-live-BYPASS0001) [key=hk-1234567890abcdef]")
for _, leak := range []string{"sk-live-BYPASS0001", "hk-1234567890abcdef"} {
if strings.Contains(ua, leak) {
t.Errorf("UA bypass: %q leaked in %q", leak, ua)
}
}
if !strings.Contains(ua, "myapp/1.0") {
t.Errorf("UA over-scrubbed, lost client name: %q", ua)
}
// Red re-review round 2 — a key glued by . @ # ~ must NOT survive.
for _, glued := range []string{
"client@sk-live-SECRETKEY00001", "app.sk-live-SECRETKEY00001",
"build#hk-SECRETKEY000000001", "v1~sk-live-SECRETKEY00001",
} {
if got := scrubFreeText(glued); strings.Contains(got, "SECRETKEY") {
t.Errorf("UA glue-char bypass: %q → %q (secret survives)", glued, got)
}
}
// Normal UAs must be byte-identical (no false scrub).
for _, ua := range []string{
"console", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
"curl/8.1.2", "Go-http-client/2.0",
} {
if got := scrubFreeText(ua); got != ua {
t.Errorf("UA false positive: %q → %q", ua, got)
}
}
}
// TestAudit_HealthSuffixCannotEvadeAudit proves a mutating request whose path
// ENDS in /health (an attacker-named wildcard segment) is STILL audited — the
// liveness exemption is exact and never suppresses a mutation or a denial. This
// closes an audit-evasion hole where POST /v1/admin/orgs/x/health would slip past.
func TestAudit_HealthSuffixCannotEvadeAudit(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.db")
rec, err := audit.Open(path, nil)
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = rec.Close() })
app := zip.New(zip.Config{})
app.Use(AuditTrail(rec))
app.Post("/v1/admin/orgs/*", func(c *zip.Ctx) error { return c.JSON(http.StatusOK, map[string]string{"ok": "1"}) })
app.Get("/v1/kms/health", func(c *zip.Ctx) error { return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) })
// (a) A mutating POST ending in /health MUST be audited.
req := httptest.NewRequest(http.MethodPost, "/v1/admin/orgs/evil/health", nil)
asAdmin(req)
mustTest(t, app, req)
_, total, _ := rec.Query(t.Context(), audit.Filter{})
if total != 1 {
t.Fatalf("EVASION: mutating POST ending /health audited %d times, want 1", total)
}
// (b) A genuine liveness GET /v1/kms/health MUST still be skipped.
req2 := httptest.NewRequest(http.MethodGet, "/v1/kms/health", nil)
mustTest(t, app, req2)
_, total2, _ := rec.Query(t.Context(), audit.Filter{})
if total2 != 1 {
t.Fatalf("liveness probe was audited (total went %d→%d) — should be exempt", total, total2)
}
}
// TestAudit_AnonRequestNotAttributedToForgedOrg proves an UNAUTHENTICATED
// attacker cannot forge a false attribution: sending X-Org-Id/X-User-Id/
// X-User-IsAdmin with no validated principal records an ANONYMOUS actor (empty
// org+sub, not admin), never the claimed victim org. Runs the REAL SanitizeIdentity
// (nil validator ⇒ strips authority, restores client X-Org-Id for the data path)
// ahead of AuditTrail, exactly as serve.go wires them.
func TestAudit_AnonRequestNotAttributedToForgedOrg(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.db")
rec, err := audit.Open(path, nil)
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = rec.Close() })
app := zip.New(zip.Config{})
app.Use(SanitizeIdentity(nil, "admin")) // trust boundary
app.Use(AuditTrail(rec))
app.Post("/v1/kms/secrets", func(c *zip.Ctx) error { return c.JSON(http.StatusOK, map[string]string{"ok": "1"}) })
req := httptest.NewRequest(http.MethodPost, "/v1/kms/secrets", nil)
// Anonymous attacker forging every identity header.
req.Header.Set("X-Org-Id", "victim-org")
req.Header.Set("X-User-Id", "victim-user")
req.Header.Set("X-User-IsAdmin", "true")
mustTest(t, app, req)
rows, _, _ := rec.Query(t.Context(), audit.Filter{})
if len(rows) != 1 {
t.Fatalf("got %d rows, want 1", len(rows))
}
r := rows[0]
if r.Auth.IsAdmin {
t.Error("forged X-User-IsAdmin survived into the record")
}
if r.Actor.Sub != "" {
t.Errorf("forged X-User-Id recorded as actor.Sub = %q", r.Actor.Sub)
}
if r.Actor.Org == "victim-org" {
t.Errorf("FALSE ATTRIBUTION: anonymous request stamped with claimed org %q", r.Actor.Org)
}
if r.Auth.Method != "none" {
t.Errorf("auth method = %q, want none (no valid credential)", r.Auth.Method)
}
// The event is still recorded (a mutation), honestly anonymous.
if r.Outcome.Result != "success" {
t.Errorf("outcome = %+v, want the anonymous mutation recorded", r.Outcome)
}
}
// TestAudit_NoopWhenUnconfigured proves a nil Recorder makes the middleware a
// pass-through (an unconfigured deployment is never blocked), exactly like
// BillingGate's nil-client behavior.
func TestAudit_NoopWhenUnconfigured(t *testing.T) {
app := zip.New(zip.Config{})
app.Use(AuditTrail(nil))
var ran bool
app.Post("/v1/kms/secrets", func(c *zip.Ctx) error {
ran = true
return c.JSON(http.StatusOK, map[string]string{"ok": "true"})
})
req := httptest.NewRequest(http.MethodPost, "/v1/kms/secrets", nil)
resp := mustTest(t, app, req)
if resp.StatusCode != http.StatusOK || !ran {
t.Fatalf("nil-recorder gate must pass through: status=%d ran=%v", resp.StatusCode, ran)
}
}
// TestAudit_FailsClosedOnWriteError proves that when the audit store cannot
// record a security-relevant event, the request is failed CLOSED (503) rather
// than allowed to succeed unlogged (AU-5). We force the failure by closing the
// store's DB before the request, so Append errors.
func TestAudit_FailsClosedOnWriteError(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.db")
rec, err := audit.Open(path, nil)
if err != nil {
t.Fatalf("open: %v", err)
}
// Close the underlying store so every subsequent Append fails.
_ = rec.Close()
app := zip.New(zip.Config{})
app.Use(AuditTrail(rec))
var ran bool
app.Post("/v1/kms/secrets", func(c *zip.Ctx) error {
ran = true
return c.JSON(http.StatusCreated, map[string]string{"id": "x"})
})
req := httptest.NewRequest(http.MethodPost, "/v1/kms/secrets", nil)
asAdmin(req)
resp := mustTest(t, app, req)
// The handler may have run (audit wraps AFTER the chain), but the response the
// CLIENT sees must be the fail-closed 503, not the handler's 201 — a
// security-relevant action that could not be recorded is not acknowledged as
// success.
if resp.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503 (fail-closed when audit write fails)", resp.StatusCode)
}
_ = ran
}
+199
View File
@@ -0,0 +1,199 @@
package cloud
// The datastore (ClickHouse) OLAP mirror — a best-effort projection of the audit
// trail for fleet-wide, long-retention, cross-deployment query. It implements
// audit.Mirror.
//
// The datastore is the natural OLAP audit sink: the table is a MergeTree, which
// is INSERT-ONLY by engine — ClickHouse rejects UPDATE/DELETE against it at parse
// time ("MergeTree does not support mutations"), so the mirror is append-only at
// the storage layer, matching the local chain's discipline. We create the table
// idempotently on first connect (CREATE TABLE IF NOT EXISTS) and insert via the
// canonical clickhouse-go PrepareBatch → Append → Send idiom (the same the
// provisioning subsystem uses; the driver is already in cloud's module graph, so
// this adds no dependency).
//
// This mirror is NEVER the integrity authority — the local SQLite hash-chain is.
// Its rows carry the same seq + hash so an operator CAN cross-check the OLAP copy
// against the chain, but a mirror gap is a query-completeness issue, not a
// tamper-evidence one. Every mirror error is logged and dropped by the Recorder;
// the request path never sees it.
import (
"context"
"fmt"
"os"
"strings"
"time"
clickhouse "github.com/ClickHouse/clickhouse-go/v2"
"github.com/hanzoai/cloud/audit"
luxlog "github.com/luxfi/log"
)
// clickhouseMirror writes audit records to a ClickHouse MergeTree table.
type clickhouseMirror struct {
conn clickhouse.Conn
table string
log luxlog.Logger
}
// newAuditMirror builds the OLAP mirror from operator config, or returns nil when
// no datastore is configured (mirroring is optional — the local chain is the
// authority). It connects lazily-validated (a Ping) and ensures the table exists.
//
// Config (all from env / KMS-injected secrets, never hard-coded):
//
// CLOUD_AUDIT_CLICKHOUSE_ADDR host:9000 of the datastore native port
// CLOUD_AUDIT_CLICKHOUSE_DB database (default "hanzo")
// CLOUD_AUDIT_CLICKHOUSE_TABLE table (default "audit_log")
// CLOUD_AUDIT_CLICKHOUSE_USER user
// CLOUD_AUDIT_CLICKHOUSE_PASSWORD password (KMS-backed secret)
func newAuditMirror(log luxlog.Logger) (audit.Mirror, error) {
addr := strings.TrimSpace(os.Getenv("CLOUD_AUDIT_CLICKHOUSE_ADDR"))
if addr == "" {
return nil, nil // no datastore configured — local chain only.
}
db := getenv("CLOUD_AUDIT_CLICKHOUSE_DB", "hanzo")
table := getenv("CLOUD_AUDIT_CLICKHOUSE_TABLE", "audit_log")
conn, err := clickhouse.Open(&clickhouse.Options{
Addr: []string{addr},
Auth: clickhouse.Auth{
Database: db,
Username: os.Getenv("CLOUD_AUDIT_CLICKHOUSE_USER"),
Password: os.Getenv("CLOUD_AUDIT_CLICKHOUSE_PASSWORD"),
},
DialTimeout: 5 * time.Second,
})
if err != nil {
return nil, fmt.Errorf("audit mirror: open: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := conn.Ping(ctx); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("audit mirror: ping %s: %w", addr, err)
}
qualified := db + "." + table
m := &clickhouseMirror{conn: conn, table: qualified, log: log}
if err := m.ensureTable(ctx); err != nil {
_ = conn.Close()
return nil, err
}
if log != nil {
log.Info("audit OLAP mirror connected", "addr", addr, "table", qualified)
}
return m, nil
}
// ensureTable creates the append-only audit table if it does not exist. MergeTree
// = insert-only (mutations rejected at parse time). Partitioned by month and
// ordered for the (org, time) query pattern; seq + hash are carried so the OLAP
// copy is cross-checkable against the local chain.
func (m *clickhouseMirror) ensureTable(ctx context.Context) error {
ddl := fmt.Sprintf(`
CREATE TABLE IF NOT EXISTS %s (
seq UInt64,
ts DateTime64(3, 'UTC'),
actor_org LowCardinality(String),
actor_sub String,
actor_email String,
action LowCardinality(String),
res_type LowCardinality(String),
res_id String,
auth_method LowCardinality(String),
is_admin UInt8,
result LowCardinality(String),
status UInt16,
reason String,
source_ip String,
user_agent String,
request_id String,
method LowCardinality(String),
path String,
prev_hash String,
hash String
) ENGINE = MergeTree
PARTITION BY toYYYYMM(ts)
ORDER BY (actor_org, ts, seq)`, m.table)
if err := m.conn.Exec(ctx, ddl); err != nil {
return fmt.Errorf("audit mirror: ensure table: %w", err)
}
return nil
}
// Append writes one record to the OLAP mirror via the canonical batch idiom. The
// before/after diffs are DELIBERATELY not mirrored — the OLAP copy is for
// query/analytics over the event stream, and keeping the (already-redacted but
// still payload-bearing) diffs out of the fleet warehouse minimizes the blast
// radius of a warehouse compromise. The full record (with diffs) lives only in
// the local, access-controlled chain.
func (m *clickhouseMirror) Append(ctx context.Context, r audit.Record) error {
batch, err := m.conn.PrepareBatch(ctx, "INSERT INTO "+m.table+` (
seq, ts, actor_org, actor_sub, actor_email, action, res_type, res_id,
auth_method, is_admin, result, status, reason, source_ip, user_agent,
request_id, method, path, prev_hash, hash)`)
if err != nil {
return fmt.Errorf("audit mirror: prepare: %w", err)
}
if err := batch.Append(
r.Seq, r.Time.UTC(), r.Actor.Org, r.Actor.Sub, r.Actor.Email,
r.Action, r.Resource.Type, r.Resource.ID,
r.Auth.Method, boolToUint8(r.Auth.IsAdmin),
r.Outcome.Result, uint16(r.Outcome.Status), r.Outcome.Reason,
r.SourceIP, r.UserAgent, r.RequestID, r.Method, r.Path,
r.PrevHash, r.Hash,
); err != nil {
_ = batch.Abort()
return fmt.Errorf("audit mirror: append: %w", err)
}
return batch.Send()
}
// Checkpoint persists a head-digest checkpoint to an INDEPENDENT digest table in
// the datastore — the AU-9 tail-truncation anchor. Because this lives in a store
// SEPARATE from the local SQLite chain, truncating the chain cannot also rewrite
// the checkpoint history: an external monitor querying this table sees the count
// series and alerts on any regression. Best-effort; a failure is dropped by the
// Recorder (the structured log carries the same digest). Implements
// audit.CheckpointSink.
func (m *clickhouseMirror) Checkpoint(ctx context.Context, cp audit.Checkpoint) error {
if err := m.ensureCheckpointTable(ctx); err != nil {
return err
}
batch, err := m.conn.PrepareBatch(ctx, "INSERT INTO "+m.table+"_checkpoints (ts, count, head)")
if err != nil {
return fmt.Errorf("audit mirror: checkpoint prepare: %w", err)
}
if err := batch.Append(cp.Time.UTC(), cp.Count, cp.Head); err != nil {
_ = batch.Abort()
return fmt.Errorf("audit mirror: checkpoint append: %w", err)
}
return batch.Send()
}
// ensureCheckpointTable creates the append-only checkpoint digest table. A plain
// MergeTree ordered by time — the monitor reads the latest rows and checks that
// count never decreases.
func (m *clickhouseMirror) ensureCheckpointTable(ctx context.Context) error {
ddl := fmt.Sprintf(`
CREATE TABLE IF NOT EXISTS %s_checkpoints (
ts DateTime64(3, 'UTC'),
count UInt64,
head String
) ENGINE = MergeTree
ORDER BY ts`, m.table)
if err := m.conn.Exec(ctx, ddl); err != nil {
return fmt.Errorf("audit mirror: ensure checkpoint table: %w", err)
}
return nil
}
func boolToUint8(b bool) uint8 {
if b {
return 1
}
return 0
}
+95
View File
@@ -0,0 +1,95 @@
package cloud
// Audit trail construction — the wiring Serve calls to stand up the Recorder.
//
// The audit store is a COMPLIANCE CONTROL, so its persistence is treated like the
// pricing catalog overlay's: a non-persistent (in-memory) audit trail would
// silently lose the record of every prior action on each restart — a fail-OPEN
// degradation of an integrity control. So an empty DataDir is a hard boot error
// in a normal run (prod always sets CLOUD_DATA_DIR; provisioning + pricing already
// require it, so the unified binary always has one). The trail can be turned OFF
// deliberately (CLOUD_AUDIT_DISABLED=true) for a minimal single-service dev run —
// an explicit opt-out, never a silent one.
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/hanzoai/cloud/audit"
luxlog "github.com/luxfi/log"
)
// buildAuditRecorder constructs the audit Recorder from cfg: the append-only
// SQLite chain at {DataDir}/audit.db plus a best-effort ClickHouse OLAP mirror
// when a datastore is configured. Returns (nil, nil) only when the trail is
// explicitly disabled — the caller then wires a no-op middleware.
func buildAuditRecorder(cfg *Config, logger luxlog.Logger) (*audit.Recorder, error) {
if getenvBool("CLOUD_AUDIT_DISABLED") {
if logger != nil {
logger.Warn("audit trail DISABLED by CLOUD_AUDIT_DISABLED — no tamper-evident record will be kept")
}
return nil, nil
}
if cfg.DataDir == "" {
return nil, fmt.Errorf("empty DataDir — the audit trail is a compliance control and requires a persistent data dir (set CLOUD_DATA_DIR); refusing to boot with a non-persistent trail that would lose all prior records on restart (or set CLOUD_AUDIT_DISABLED=true to opt out explicitly)")
}
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
return nil, fmt.Errorf("data dir: %w", err)
}
// OLAP mirror is optional and best-effort. A mirror that cannot be reached at
// boot must NOT stop the binary — the local chain is the authority — so a
// mirror construction error is logged and the trail runs local-only.
var mirror audit.Mirror
if m, err := newAuditMirror(logger); err != nil {
if logger != nil {
logger.Warn("audit OLAP mirror unavailable — running local-only (chain integrity unaffected)", "err", err)
}
} else {
mirror = m
}
dbPath := filepath.Join(cfg.DataDir, "audit.db")
rec, err := audit.Open(dbPath, mirror)
if err != nil {
return nil, fmt.Errorf("open audit store: %w", err)
}
// AU-9 tail-truncation anchor: emit a periodic head-digest checkpoint to the
// append-only observability log (and, when a mirror supports it, an
// independent digest store). An external o11y monitor compares consecutive
// checkpoints and alerts on a count regression — the only way to detect that
// the most-recent records were deleted (an internal chain walk cannot). The
// interval is CLOUD_AUDIT_CHECKPOINT_INTERVAL (default 5m; 0 disables).
interval := auditCheckpointInterval()
if logger != nil {
rec.StartCheckpoints(interval, func(cp audit.Checkpoint) {
logger.Info("audit_head_checkpoint",
"count", cp.Count, "head", cp.Head, "ts", cp.Time.Format(time.RFC3339Nano))
})
} else {
rec.StartCheckpoints(interval, nil)
}
if logger != nil {
count, head := rec.Head()
logger.Info("audit trail ready (tamper-evident, append-only)",
"store", dbPath, "records", count, "head", head,
"mirror", mirror != nil, "checkpoint_interval", interval.String())
}
return rec, nil
}
// auditCheckpointInterval resolves the head-digest checkpoint cadence.
// CLOUD_AUDIT_CHECKPOINT_INTERVAL is a Go duration (e.g. "5m", "1h"); default 5m;
// "0" disables periodic checkpoints (the on-close checkpoint still fires).
func auditCheckpointInterval() time.Duration {
if v := getenv("CLOUD_AUDIT_CHECKPOINT_INTERVAL", ""); v != "" {
if d, err := time.ParseDuration(v); err == nil {
return d
}
}
return 5 * time.Minute
}
+423
View File
@@ -0,0 +1,423 @@
package cloud
// In-binary IAM JWT validation — the trust anchor for SanitizeIdentity.
//
// This MIRRORS github.com/hanzoai/gateway/v2/iamauth, the canonical edge
// validator, but cloud deliberately does NOT import that package: iamauth lives
// in the heavyweight gateway module (KrakenD/gin/traefik) AND gateway/v2 already
// imports github.com/hanzoai/cloud, so importing it back would braid a module
// cycle and pull the gateway's whole dependency tree into cloud for ~150 lines
// of validation. The gateway remains the PRIMARY edge authority — in production
// it fronts cloud-api (universe routes.yaml). This validator is the in-binary
// defense-in-depth layer for the in-cluster / direct path, kept tiny and
// auditable on go-jose alone (already in cloud's module graph).
//
// What it enforces, exactly like iamauth.ValidateToken: signature against the
// IAM JWKS, issuer (strict), audience (allowlist, OR semantics), and expiry —
// always. A token missing the issuer is rejected.
import (
"context"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"sync"
"time"
gojose "github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
)
// idClaims is the subset of Hanzo IAM JWT claims the identity sanitizer needs.
// Shape mirrors iamauth.Claims so a token resolves identically at both layers.
type idClaims struct {
jwt.Claims
Owner string `json:"owner"` // org slug (the tenant)
Name string `json:"name"` // display name (id fallback)
PreferredUsername string `json:"preferred_username"` // id fallback
Email string `json:"email"`
IsAdmin bool `json:"isAdmin"`
}
// 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.
func (c *idClaims) userID() string {
if c.Subject != "" {
return c.Subject
}
if c.PreferredUsername != "" {
return c.PreferredUsername
}
return c.Name
}
// username resolves the IAM USERNAME — the `name` half of the `<owner>/<name>`
// key IAM's privileged ops (mint-user-keys, get-user) parse. Prefers the `name`
// claim (IAM's canonical username, e.g. "z"), then preferred_username. It NEVER
// returns the subject: sub is a UUID, and `<owner>/<uuid>` fails IAM's
// GetOwnerAndNameFromId user lookup ("password or code is incorrect"). This is
// the distinct-from-userID() value stamped as X-User-Name so the direct-Bearer
// path builds owner/name correctly — the gateway historically minted
// X-User-Id==name, which userID() (sub-first) breaks on the in-binary path.
func (c *idClaims) username() string {
if c.Name != "" {
return c.Name
}
return c.PreferredUsername
}
// jwtSigAlgs is the accepted signature-algorithm allowlist passed to
// jwt.ParseSigned (go-jose v4 requires it explicitly). RSA + ECDSA + PSS, the
// set IAM may sign with — never "none".
var jwtSigAlgs = []gojose.SignatureAlgorithm{
gojose.RS256, gojose.RS384, gojose.RS512,
gojose.ES256, gojose.ES384, gojose.ES512,
gojose.PS256, gojose.PS384, gojose.PS512,
}
// identityValidator validates an IAM JWT against a cached JWKS. Issuer (any of a
// trusted SET) + audience + expiry are always enforced.
//
// The issuer is a SET so ONE cloud binary validates every white-label brand's
// tokens (hanzo iss=hanzo.id AND lux iss=lux.id, ...). Signature verification is
// unaffected: the in-cluster IAM serves EVERY brand's signing cert in one JWKS
// (cert-hanzo/cert-lux/cert-zoo/...), keyed by the token kid, so a single
// jwksURL verifies all brands. Only the issuer-string comparison had to widen.
type identityValidator struct {
issuers []string
audiences []string
cache *jwksCache
}
// newIdentityValidator builds a validator whose trusted-issuer set is the primary
// issuer UNIONED with every white-label brand issuer (BrandIssuers) plus any
// WHITELABEL_ISSUERS override. ttl<=0 uses the 15m JWKS default. The union is
// fail-secure: it only ADDS the known-good brand issuers, never an arbitrary one.
func newIdentityValidator(issuer, jwksURL string, audiences []string, ttl time.Duration) *identityValidator {
return &identityValidator{
issuers: trustedIssuers(issuer),
audiences: audiences,
cache: newJWKSCache(jwksURL, ttl),
}
}
// kmsMachineAudSuffix is the fixed suffix of a per-tenant PaaS-KMS sync machine
// identity's audience. Each tenant's KMS sync authenticates as a dedicated,
// NON-shared IAM application named "<org>-platform-kms" (Organization=<org>,
// client_credentials grant), so IAM stamps the token's aud == the app's own
// clientId == "<org>-platform-kms" (a non-shared app's audience is its clientId,
// object/token_jwt.go tokenAudience) and owner == <org>
// (object/token_oauth.go GetClientCredentialsToken sets owner = app.Organization).
//
// That audience is, by construction, absent from CLOUD_JWT_AUDIENCES — it is
// per-tenant, not a fixed app — which is EXACTLY why the sync stayed pending: the
// machine token failed the audience check below, SanitizeIdentity treated it as
// anonymous, and the /v1/kms org-scope guard 403'd it before the store. The fix is
// to accept this one audience, but ONLY when it equals the token's OWN owner claim
// plus this suffix, so it certifies "the KMS sync identity for its own org" and
// grants nothing wider. Tenancy is still enforced downstream by owner at the guard
// (owner == :org); this only lets a legitimately-minted, owner-scoped machine token
// clear validation. A per-tenant application means a per-tenant clientSecret — never
// a shared platform-wide reader, which would be a cross-tenant hole.
const kmsMachineAudSuffix = "-platform-kms"
// kmsMachineAudience returns the audience a tenant org's PaaS-KMS sync identity
// carries: "<owner>-platform-kms". An empty owner yields empty — no machine
// audience is ever granted to an org-less token (fail closed).
func kmsMachineAudience(owner string) string {
if owner == "" {
return ""
}
return owner + kmsMachineAudSuffix
}
// isKMSMachinePrincipal reports whether a validated token is a per-tenant KMS-sync
// machine identity: its audience set contains the owner-bound machine audience
// (<owner>-platform-kms). Such a principal is a client_credentials machine identity
// scoped to exactly one org. SanitizeIdentity uses this to DENY it global-admin
// authority even if it somehow carries isAdmin=true and owner==adminOrg, so V6's
// audience widening can never be leveraged (via an admin-org machine token) into a
// cross-tenant read. Its org-scoped data access is unaffected — this gates ONLY the
// admin grant, keeping the machine path decoupled from admin inside cloud (rather
// than resting on the external invariant "IAM never stamps isAdmin=true on a
// machine-aud token", which cloud cannot see or enforce).
func isKMSMachinePrincipal(claims *idClaims) bool {
mach := kmsMachineAudience(claims.Owner)
if mach == "" {
return false
}
for _, a := range claims.Audience {
if a == mach {
return true
}
}
return false
}
// validate parses raw, verifies its signature against the JWKS, and enforces
// issuer/audience/expiry. Returns the claims on success, an error otherwise.
func (v *identityValidator) validate(raw string) (*idClaims, error) {
tok, err := jwt.ParseSigned(raw, jwtSigAlgs)
if err != nil {
return nil, fmt.Errorf("parse: %w", err)
}
keys, err := v.cache.get()
if err != nil {
return nil, fmt.Errorf("jwks: %w", err)
}
var claims idClaims
if err := verifyAgainstKeys(tok, keys, &claims); err != nil {
return nil, err
}
// Reject a missing issuer: an empty issuer must never pass the set check.
if claims.Issuer == "" {
return nil, fmt.Errorf("missing issuer")
}
// Reject a missing expiry: ValidateWithLeeway only enforces exp when present
// (it checks `if c.Expiry != nil`), so a token with NO exp would never expire.
// An IAM access token always carries exp; require it.
if claims.Expiry == nil {
return nil, fmt.Errorf("missing expiry")
}
// Issuer must be one of the trusted brand issuers. go-jose's jwt.Expected
// checks a SINGLE issuer, so the issuer is validated here against the set and
// left out of Expected (audience + expiry stay with Expected).
if !issuerAllowed(claims.Issuer, v.issuers) {
return nil, fmt.Errorf("untrusted issuer %q", claims.Issuer)
}
// Audience: the static allowlist (CLOUD_JWT_AUDIENCES / brand app client_ids)
// PLUS the per-tenant PaaS-KMS sync machine audience bound to THIS token's own
// owner (<owner>-platform-kms). The machine audience is added only when the
// allowlist is active (non-empty — always so in production) and only for the
// token's own org, so accepting it never widens tenancy: the /v1/kms guard still
// gates on owner == :org. Without this, a real client_credentials machine token
// (aud == its per-tenant clientId, never in the allowlist) fails here and the
// sync silently stays pending — the activation blocker.
expected := jwt.Expected{}
if len(v.audiences) > 0 {
auds := v.audiences
if mach := kmsMachineAudience(claims.Owner); mach != "" {
auds = append(append(make([]string, 0, len(v.audiences)+1), v.audiences...), mach)
}
expected.AnyAudience = jwt.Audience(auds)
}
if err := claims.Claims.ValidateWithLeeway(expected, 2*time.Minute); err != nil {
return nil, fmt.Errorf("claims: %w", err)
}
return &claims, nil
}
// verifyAgainstKeys tries the kid-matched key first, then any RSA signing key —
// mirrors iamauth's selection so a token verifies the same way at both layers.
func verifyAgainstKeys(tok *jwt.JSONWebToken, keys *gojose.JSONWebKeySet, claims *idClaims) error {
var lastErr error
for _, h := range tok.Headers {
if h.KeyID == "" {
continue
}
for _, k := range keys.Key(h.KeyID) {
if err := tok.Claims(k.Key, claims); err == nil {
return nil
} else {
lastErr = err
}
}
}
for _, k := range keys.Keys {
if k.Use != "sig" && k.Use != "" {
continue
}
if _, ok := k.Key.(*rsa.PublicKey); !ok {
continue
}
if err := tok.Claims(k.Key, claims); err == nil {
return nil
} else {
lastErr = err
}
}
if lastErr != nil {
return fmt.Errorf("no matching key: %w", lastErr)
}
return fmt.Errorf("no matching key in JWKS")
}
// ----------------------------------------------------------------------------
// JWKS cache (TTL refresh, stale-on-error) — mirrors iamauth.JWKSCache.
// ----------------------------------------------------------------------------
type jwksCache struct {
mu sync.RWMutex
keys *gojose.JSONWebKeySet
fetchedAt time.Time
ttl time.Duration
url string
client *http.Client
}
func newJWKSCache(url string, ttl time.Duration) *jwksCache {
if ttl <= 0 {
ttl = 15 * time.Minute
}
return &jwksCache{url: url, ttl: ttl, client: &http.Client{Timeout: 10 * time.Second}}
}
// get returns the cached key set, refreshing past TTL. On a fetch error with a
// previously-cached set, the stale set is returned rather than failing — a
// transient JWKS blip must not flap validation (and so admin auth) closed.
func (c *jwksCache) get() (*gojose.JSONWebKeySet, error) {
c.mu.RLock()
if c.keys != nil && time.Since(c.fetchedAt) < c.ttl {
k := c.keys
c.mu.RUnlock()
return k, nil
}
c.mu.RUnlock()
c.mu.Lock()
defer c.mu.Unlock()
if c.keys != nil && time.Since(c.fetchedAt) < c.ttl {
return c.keys, nil
}
keys, err := c.fetch()
if err != nil {
if c.keys != nil {
return c.keys, nil
}
return nil, err
}
c.keys = keys
c.fetchedAt = time.Now()
return keys, nil
}
func (c *jwksCache) fetch() (*gojose.JSONWebKeySet, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.url, nil)
if err != nil {
return nil, fmt.Errorf("request: %w", err)
}
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("read: %w", err)
}
var set gojose.JSONWebKeySet
if err := json.Unmarshal(body, &set); err != nil {
return nil, fmt.Errorf("parse: %w", err)
}
return &set, nil
}
// ----------------------------------------------------------------------------
// Token extraction — mirrors iamauth's Bearer / Basic / API-key helpers.
// ----------------------------------------------------------------------------
// isAPIKey reports whether tok is an opaque, backend-validated key (hk-/sk-/…)
// rather than a JWT, so the sanitizer skips JWT parsing for it.
func isAPIKey(tok string) bool {
return strings.HasPrefix(tok, "hk-") ||
strings.HasPrefix(tok, "sk-") ||
strings.HasPrefix(tok, "pk-") ||
strings.HasPrefix(tok, "fw_") ||
strings.HasPrefix(tok, "hz_")
}
// bearerFromAuth extracts the token from a "Bearer <token>" header value.
func bearerFromAuth(auth string) string {
if auth == "" {
return ""
}
parts := strings.SplitN(auth, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
return ""
}
return strings.TrimSpace(parts[1])
}
// basicFromAuth extracts the token from an HTTP Basic header value: the password
// field (the go/.netrc proxy idiom), falling back to the username when empty.
func basicFromAuth(auth string) string {
if auth == "" {
return ""
}
parts := strings.SplitN(auth, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Basic") {
return ""
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(parts[1]))
if err != nil {
return ""
}
user, pass, ok := strings.Cut(string(raw), ":")
if !ok {
return ""
}
if pass != "" {
return pass
}
return user
}
// trustedIssuers returns the full trusted-issuer set for the in-binary validator:
// the PRIMARY issuer (the deployment's own brand, cfg.IAMIssuer) UNIONED with every
// white-label brand issuer (BrandIssuers) and any WHITELABEL_ISSUERS override
// (comma-separated). Fail-secure: it only ADDS known-good issuers; a nil/empty
// result is impossible when a primary is set, so the issuer check is always
// enforced. Duplicates are removed; order is primary-first.
func trustedIssuers(primary string) []string {
out := make([]string, 0, 6)
add := func(v string) {
v = strings.TrimSpace(v)
if v == "" {
return
}
for _, e := range out {
if e == v {
return
}
}
out = append(out, v)
}
add(primary)
for _, iss := range BrandIssuers() {
add(iss)
}
for _, iss := range splitTrim(os.Getenv("WHITELABEL_ISSUERS")) {
add(iss)
}
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).
func issuerAllowed(iss string, trusted []string) bool {
if len(trusted) == 0 {
return true
}
for _, t := range trusted {
if iss == t {
return true
}
}
return false
}
+87
View File
@@ -0,0 +1,87 @@
package cloud
// V6 (the activation blocker) — the identity validator must accept a per-tenant
// PaaS-KMS sync machine token: a client_credentials JWT whose aud is the tenant's
// own IAM application clientId "<owner>-platform-kms" (a per-tenant value, NEVER in
// CLOUD_JWT_AUDIENCES) — but ONLY when that audience is bound to the token's OWN
// owner claim. Before the fix the machine token failed the audience check,
// SanitizeIdentity resolved anonymous, and the /v1/kms guard 403'd it, so the sync
// silently stayed pending. These are white-box unit tests of validate() itself;
// the end-to-end proof through SanitizeIdentity + the real guard lives in
// clients/kms (v6_aud_e2e_test.go). Reuses the jwksServer/signWith/tokenClaims
// helpers from middleware_identity_test.go (same package).
import (
"crypto/rand"
"crypto/rsa"
"testing"
"time"
)
func TestIdentityValidator_KMSMachineAudience(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("genkey: %v", err)
}
jwks := jwksServer(t, &key.PublicKey)
// The static allowlist deliberately contains NO *-platform-kms audience, so any
// acceptance below can come ONLY from the owner-bound machine-aud rule, not the
// allowlist — this is what makes it a fix and not a config workaround.
v := newIdentityValidator(testIssuer, jwks.URL, []string{"hanzo-console"}, 0)
future := time.Now().Add(time.Hour)
t.Run("machine token for its own org is accepted", func(t *testing.T) {
c, err := v.validate(signWith(t, key, tokenClaims("maxpower-platform-kms", "maxpower", "", false, future)))
if err != nil {
t.Fatalf("machine token rejected: %v", err)
}
if c.Owner != "maxpower" {
t.Fatalf("owner=%q, want maxpower", c.Owner)
}
})
t.Run("machine aud for a DIFFERENT org is rejected (owner-bound)", func(t *testing.T) {
// owner=maxpower but aud=acme-platform-kms: the accepted machine aud is bound
// to the token's OWN owner (maxpower-platform-kms), so this must fail — it is
// not a blanket "*-platform-kms" wildcard.
if _, err := v.validate(signWith(t, key, tokenClaims("acme-platform-kms", "maxpower", "", false, future))); err == nil {
t.Fatal("cross-org machine audience must be rejected (owner-bound)")
}
})
t.Run("arbitrary audience still rejected (fix is scoped, not a disable)", func(t *testing.T) {
if _, err := v.validate(signWith(t, key, tokenClaims("some-random-app", "maxpower", "", false, future))); err == nil {
t.Fatal("an arbitrary audience must still be rejected")
}
})
t.Run("machine aud with empty owner is rejected (fail closed)", func(t *testing.T) {
// aud="-platform-kms" with owner="": kmsMachineAudience("")=="" so no machine
// audience is granted and the bare suffix is not in the allowlist.
if _, err := v.validate(signWith(t, key, tokenClaims("-platform-kms", "", "", false, future))); err == nil {
t.Fatal("machine aud with empty owner must be rejected")
}
})
t.Run("normal static-allowlist token still accepted (regression)", func(t *testing.T) {
if _, err := v.validate(signWith(t, key, tokenClaims("hanzo-console", "maxpower", "", false, future))); err != nil {
t.Fatalf("static-allowlist token rejected: %v", err)
}
})
t.Run("machine token expiry still enforced", func(t *testing.T) {
if _, err := v.validate(signWith(t, key, tokenClaims("maxpower-platform-kms", "maxpower", "", false, time.Now().Add(-time.Hour)))); err == nil {
t.Fatal("expired machine token must be rejected")
}
})
}
// kmsMachineAudience is a pure helper; lock its contract directly.
func TestKMSMachineAudience(t *testing.T) {
if got := kmsMachineAudience("maxpower"); got != "maxpower-platform-kms" {
t.Fatalf("kmsMachineAudience(maxpower)=%q, want maxpower-platform-kms", got)
}
if got := kmsMachineAudience(""); got != "" {
t.Fatalf("kmsMachineAudience(\"\")=%q, want \"\" (no machine aud for an org-less token)", got)
}
}
+150
View File
@@ -0,0 +1,150 @@
package cloud
import (
"os"
"testing"
)
// 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.
func TestTrustedIssuers_WhiteLabel(t *testing.T) {
os.Unsetenv("WHITELABEL_ISSUERS")
got := trustedIssuers("https://hanzo.id")
want := map[string]bool{
"https://hanzo.id": true,
"https://lux.id": true,
"https://zoo.id": true, // per cloud brand.go registry
"https://pars.id": true,
"https://id.bootno.de": true, // bootnode brand also in the registry
}
set := map[string]bool{}
for _, g := range got {
set[g] = true
}
for w := range want {
if !set[w] {
t.Errorf("trusted set %v missing %q", got, w)
}
}
if got[0] != "https://hanzo.id" {
t.Errorf("primary issuer must be first, got %q", got[0])
}
// Override adds a brand without a rebuild.
t.Setenv("WHITELABEL_ISSUERS", "https://custom.id, https://another.id")
got2 := trustedIssuers("https://hanzo.id")
if !issuerAllowed("https://custom.id", got2) || !issuerAllowed("https://another.id", got2) {
t.Errorf("WHITELABEL_ISSUERS override must add issuers, got %v", got2)
}
}
// TestIssuerAllowed proves the set membership check: brand issuers pass, an
// outsider is rejected, and an empty set (never in prod) skips the check.
func TestIssuerAllowed(t *testing.T) {
set := []string{"https://hanzo.id", "https://lux.id"}
if !issuerAllowed("https://lux.id", set) {
t.Error("lux.id must be allowed")
}
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)")
}
}
// TestBrandIssuers proves the issuer list is derived from the brands registry and
// covers every configured brand (one source of truth).
func TestBrandIssuers(t *testing.T) {
got := BrandIssuers()
for _, want := range []string{"https://hanzo.id", "https://lux.id", "https://zoo.id", "https://pars.id", "https://id.bootno.de"} {
found := false
for _, g := range got {
if g == want {
found = true
break
}
}
if !found {
t.Errorf("BrandIssuers()=%v missing %q", got, want)
}
}
}
// TestNewIdentityValidator_MultiIssuer proves the constructed validator carries the
// full brand set, so a lux token would pass the issuer gate on the hanzo binary.
func TestNewIdentityValidator_MultiIssuer(t *testing.T) {
os.Unsetenv("WHITELABEL_ISSUERS")
v := newIdentityValidator("https://hanzo.id", "http://iam.hanzo.svc/v1/iam/.well-known/jwks", []string{"hanzo-cloud", "lux-cloud"}, 0)
if !issuerAllowed("https://lux.id", v.issuers) {
t.Fatalf("validator must trust the lux issuer, set=%v", v.issuers)
}
if !issuerAllowed("https://hanzo.id", v.issuers) {
t.Fatalf("validator must still trust hanzo (no regression), set=%v", v.issuers)
}
if issuerAllowed("https://evil.id", v.issuers) {
t.Fatalf("validator must reject an untrusted issuer, set=%v", v.issuers)
}
}
// TestBrandAudiences proves every brand's cloud audience (<brand>-cloud) is derived
// from the brands registry — one source of truth, mirroring BrandIssuers.
func TestBrandAudiences(t *testing.T) {
got := BrandAudiences()
for _, want := range []string{"hanzo-cloud", "lux-cloud", "zoo-cloud", "pars-cloud", "bootnode-cloud"} {
found := false
for _, g := range got {
if g == want {
found = true
break
}
}
if !found {
t.Errorf("BrandAudiences()=%v missing %q", got, want)
}
}
}
// TestJWTAudiencesFromEnv_BrandUnion proves the resolved audience allowlist ALWAYS
// includes every brand's <brand>-cloud aud (so a lux token validates), whether the
// list comes from the baked default or a hanzo-only env override — and that an
// env-supplied entry is not duplicated.
func TestJWTAudiencesFromEnv_BrandUnion(t *testing.T) {
has := func(list []string, v string) bool {
for _, s := range list {
if s == v {
return true
}
}
return false
}
// Baked default path (no env).
os.Unsetenv("CLOUD_JWT_AUDIENCES")
os.Unsetenv("GATEWAY_ALLOWED_AUDIENCES")
def := jwtAudiencesFromEnv()
for _, want := range []string{"hanzo-cloud", "lux-cloud", "zoo-cloud", "pars-cloud"} {
if !has(def, want) {
t.Errorf("baked audiences %v missing brand aud %q", def, want)
}
}
// A legacy hanzo-only env override must STILL accept lux-cloud (brand union),
// with no duplicate of the env-supplied hanzo-cloud.
os.Setenv("GATEWAY_ALLOWED_AUDIENCES", "hanzo-app,hanzo-console,hanzo-cloud")
defer os.Unsetenv("GATEWAY_ALLOWED_AUDIENCES")
got := jwtAudiencesFromEnv()
if !has(got, "lux-cloud") {
t.Fatalf("hanzo-only env override must still accept lux-cloud, got %v", got)
}
n := 0
for _, s := range got {
if s == "hanzo-cloud" {
n++
}
}
if n != 1 {
t.Fatalf("hanzo-cloud must appear exactly once (no duplicate), got %d in %v", n, got)
}
}
+102
View File
@@ -0,0 +1,102 @@
// Copyright 2023-2025 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cloud
// Auto-routing billing binding at the cloud edge.
//
// The ai subsystem serves a virtual `auto`/`zen-router` model: it resolves the
// request to a concrete model id BEFORE pricing/billing, meters its own LLM
// token cost to commerce keyed on the SERVED model, and reports that id via the
// `X-Routed-Model` response header (and the response body `model` field).
//
// The cloud edge must therefore do two things for an `auto` request, both
// verified here end-to-end through the real BillingGate + DefaultPrice:
// 1. NOT re-price it by the (virtual) request model — /v1/ai/* is self-metered,
// so the edge gate delegates all LLM billing to the ai subsystem. That
// subsystem bills the resolved model, so `auto` bills as what served it.
// 2. Pass the `X-Routed-Model` header through untouched, so the model the
// client sees reported is exactly the model that was billed.
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/zap-proto/zip"
)
// TestAutoRoutingBillsAsResolvedModel drives an `auto` chat request through the
// real edge gate (BillingGate + DefaultPrice) in front of a handler that
// simulates the ai subsystem after it resolved auto→zen4-coder.
func TestAutoRoutingBillsAsResolvedModel(t *testing.T) {
fc := &fakeCommerce{balanceBody: `{"available":5000}`}
srv := fc.server(t)
m := mustClient(t, srv.URL, false /* fail-closed */)
app := zip.New(zip.Config{})
// The genuine edge gate with the genuine price function.
app.Use(BillingGate(m, DefaultPrice))
// Stand-in for the mounted ai subsystem: auto already resolved to zen4-coder,
// which it reports on the header + body (and meters itself — not modeled here).
app.Post("/v1/ai/chat/completions", func(c *zip.Ctx) error {
c.SetHeader("X-Routed-Model", "zen4-coder")
return c.JSON(http.StatusOK, map[string]string{"model": "zen4-coder"})
})
req := httptest.NewRequest(http.MethodPost, "/v1/ai/chat/completions",
strings.NewReader(`{"model":"auto","messages":[{"role":"user","content":"refactor this"}]}`))
req.Header.Set("X-Org-Id", "hanzo")
req.Header.Set("X-User-Id", "alice")
req.Header.Set("Content-Type", "application/json")
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("Test request: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
// (1) The edge did NOT bill: /v1/ai/* is self-metered (DefaultPrice 0), so the
// gate short-circuits before Authorize/Record. Billing is delegated to the ai
// subsystem, which meters the RESOLVED model — never the virtual `auto`.
// (Give any erroneous async Record a moment to land before asserting zero.)
if fc.usages() != 0 {
t.Fatalf("edge recorded %d usage(s) for /v1/ai/*, want 0 (self-metered by ai)", fc.usages())
}
if waitFor(func() bool { return fc.usages() > 0 }, 50*time.Millisecond) {
t.Fatalf("edge double-billed an ai request (usages=%d): auto must be billed by the ai meter, not the edge", fc.usages())
}
// (2) Header pass-through: the resolved model the ai meter billed reaches the
// client, so what's reported == what's billed.
if got := resp.Header.Get("X-Routed-Model"); got != "zen4-coder" {
t.Errorf("X-Routed-Model = %q, want zen4-coder (must pass through the edge)", got)
}
}
// TestDefaultPriceAiPathModelAgnostic documents the binding at the pricing layer:
// the edge price for the ai chat path is 0 regardless of the request model, so
// `auto` and a concrete model are treated identically — the ai subsystem's own
// per-token meter (keyed on the resolved model) is the single source of the
// charge. Path-based, never request-model-based, pricing is what makes `auto`
// bill as the model that served it.
func TestDefaultPriceAiPathModelAgnostic(t *testing.T) {
if got := priceForPath(t, "/v1/ai/chat/completions"); got != 0 {
t.Errorf("DefaultPrice(/v1/ai/chat/completions) = %d, want 0 — ai self-meters the resolved model", got)
}
}
+140
View File
@@ -0,0 +1,140 @@
package cloud
import "strings"
// Brand white-label registry (HIP-0111).
//
// The cloud binary is one artifact serving every brand's API host
// (api.hanzo.ai, api.lux.cloud, api.zoo.cloud, api.cloud.pars.network, ...).
// Brand is a per-deployment value (CLOUD_BRAND / --brand). This registry maps a
// brand to its PUBLIC IAM facts — the canonical OIDC issuer the deployment must
// validate JWTs against. These are public (issuer host + brand domain), so they
// live in code, not in KMS.
//
// One source of truth: nothing else in the binary hardcodes a per-brand issuer.
// Config.IAMIssuer is derived from here when the operator does not pin one, so a
// lux deployment validates against lux.id, a zoo deployment against zoo.id, etc.,
// instead of silently defaulting every brand to iam.hanzo.ai.
// BrandInfo is the PUBLIC per-brand identity used for token validation + URL
// scoping. No secrets.
type BrandInfo struct {
// ID is the canonical brand key.
ID string
// IAMIssuer is the OIDC issuer (JWKS source) for this brand — the value the
// JWT `iss` claim must equal and whose /v1/iam/.well-known/jwks signs tokens.
IAMIssuer string
// Domain is the brand's primary marketing/site domain (for response scoping
// and base-URL derivation, e.g. api.<Domain>).
Domain string
// AltDomains are additional registrable domains that ALSO belong to this
// brand, used ONLY for hostname→brand white-label detection (BrandForHostOK).
// A brand's real serving surfaces span more than its marketing domain — the
// cloud console runs on <brand>.cloud hosts (console.lux.cloud,
// console.zoo.cloud), and a request Host there must brand as Lux/Zoo, never
// fall through to Hanzo. Base-URL/issuer scoping still uses the primary Domain.
AltDomains []string
}
// brands is the brand→IAM registry. Keys are the canonical brand IDs accepted
// by CLOUD_BRAND. Per HIP-0111 §Brands: hanzo→hanzo.id, lux→lux.id,
// zoo→zoo.id, pars→pars.id, bootnode→id.bootno.de.
//
// IAMIssuer MUST equal the `iss` IAM actually stamps AND host the signing JWKS.
// For hanzo the live .well-known/openid-configuration on BOTH hanzo.id and
// iam.hanzo.ai reports issuer=https://hanzo.id + jwks_uri=
// https://hanzo.id/v1/iam/.well-known/jwks (iam.hanzo.ai is a routing alias, not
// the issuer), and the cloud CLI already defaults to hanzo.id. Pinning
// iam.hanzo.ai here would fail the issuer check on every real token, anonymizing
// every principal — global admin would 403 platform-wide (fail-secure, but
// broken). lux/zoo/pars already correctly point at their own .id issuers.
var brands = map[string]BrandInfo{
"hanzo": {ID: "hanzo", IAMIssuer: "https://hanzo.id", Domain: "hanzo.ai", AltDomains: []string{"hanzo.cloud", "hanzo.app"}},
"lux": {ID: "lux", IAMIssuer: "https://lux.id", Domain: "lux.network", AltDomains: []string{"lux.cloud"}},
"zoo": {ID: "zoo", IAMIssuer: "https://zoo.id", Domain: "zoo.ngo", AltDomains: []string{"zoo.network", "zoo.cloud"}},
"pars": {ID: "pars", IAMIssuer: "https://pars.id", Domain: "pars.network", AltDomains: []string{"pars.ai"}},
"bootnode": {ID: "bootnode", IAMIssuer: "https://id.bootno.de", Domain: "bootno.de"},
}
// DefaultBrand is the fallback brand when CLOUD_BRAND is unknown.
const DefaultBrand = "hanzo"
// BrandFor returns the BrandInfo for id, falling back to the Hanzo brand for an
// unknown id. Lookup is case-insensitive.
func BrandFor(id string) BrandInfo {
if b, ok := brands[strings.ToLower(strings.TrimSpace(id))]; ok {
return b
}
return brands[DefaultBrand]
}
// IssuerForBrand returns the canonical OIDC issuer for a brand id.
func IssuerForBrand(id string) string {
return BrandFor(id).IAMIssuer
}
// BrandForHostOK resolves a request Host to a brand id from the same `brands`
// registry, mirroring the hostname→brand semantics of platform.ts's
// getWhiteLabelBrand: a Host at or under a brand's Domain (api.lux.network,
// lux.network) is that brand. The port is stripped and the compare is
// case-insensitive; the longest matching Domain wins so a nested brand domain is
// never shadowed by a shorter one. ok is false when NO brand domain matches, so
// the caller can choose its own fallback (the deployment brand) rather than
// silently emitting Hanzo branding on, say, a Zoo pod hit with an odd Host.
func BrandForHostOK(host string) (string, bool) {
host = strings.ToLower(strings.TrimSpace(host))
if i := strings.IndexByte(host, ':'); i >= 0 {
host = host[:i]
}
best, bestLen := "", -1
for id, b := range brands {
for _, d := range append([]string{b.Domain}, b.AltDomains...) {
d = strings.ToLower(d)
if d == "" {
continue
}
if (host == d || strings.HasSuffix(host, "."+d)) && len(d) > bestLen {
best, bestLen = id, len(d)
}
}
}
return best, best != ""
}
// BrandForHost is BrandForHostOK with the Hanzo default for an unmatched Host.
func BrandForHost(host string) string {
if b, ok := BrandForHostOK(host); ok {
return b
}
return DefaultBrand
}
// BrandIssuers returns the OIDC issuer of every configured white-label brand. The
// in-binary identity validator (auth_identity.go) trusts a token whose `iss` is
// any of these, so ONE cloud binary validates hanzo AND lux/zoo/pars tokens. One
// source of truth: derived from the same `brands` registry above.
func BrandIssuers() []string {
out := make([]string, 0, len(brands))
for _, b := range brands {
if b.IAMIssuer != "" {
out = append(out, b.IAMIssuer)
}
}
return out
}
// BrandAudiences returns the OAuth `aud` (== IAM client_id == app name) of every
// white-label brand's cloud login app: `<brand>-cloud` (hanzo-cloud, lux-cloud,
// zoo-cloud, pars-cloud, bootnode-cloud). A brand's session token carries
// aud=<brand>-cloud (HIP-0111: client_id == app == aud), so the audience allowlist
// must include each to accept a lux/zoo/pars token on the ONE binary. Derived from
// the same `brands` registry as BrandIssuers — one source of truth, no hand-listing.
func BrandAudiences() []string {
out := make([]string, 0, len(brands))
for id := range brands {
if id != "" {
out = append(out, id+"-cloud")
}
}
return out
}
+85
View File
@@ -0,0 +1,85 @@
package cloud
import (
"os"
"testing"
)
func TestBrandFor(t *testing.T) {
// hanzo → hanzo.id (NOT iam.hanzo.ai): brand.go was pinned to the real OIDC
// issuer in fddaeb14 ("pin hanzo IAM issuer to hanzo.id") — iam.hanzo.ai is a
// routing alias, and the live .well-known reports iss=https://hanzo.id, so a
// token would fail the issuer check against iam.hanzo.ai. This stale
// assertion predated that pin; aligned here (drive-by, brand.go unchanged).
cases := map[string]string{
"hanzo": "https://hanzo.id",
"lux": "https://lux.id",
"zoo": "https://zoo.id",
"pars": "https://pars.id",
"bootnode": "https://id.bootno.de",
"LUX": "https://lux.id", // case-insensitive
" zoo ": "https://zoo.id", // trimmed
"unknown": "https://hanzo.id", // falls back to hanzo
"": "https://hanzo.id", // empty → hanzo default
}
for brand, want := range cases {
if got := IssuerForBrand(brand); got != want {
t.Errorf("IssuerForBrand(%q) = %q, want %q", brand, got, want)
}
}
}
func TestBrandForHost(t *testing.T) {
match := map[string]string{
"api.hanzo.ai": "hanzo",
"hanzo.ai": "hanzo",
"api.lux.network": "lux",
"lux.network": "lux",
"API.LUX.NETWORK": "lux", // case-insensitive
"api.lux.network:443": "lux", // port stripped
"api.zoo.ngo": "zoo",
"chat.zoo.ngo": "zoo",
"api.pars.network": "pars",
// AltDomains: the real cloud console surfaces run on <brand>.cloud /
// <brand>.network — a Host there must brand as Lux/Zoo, never Hanzo.
"console.lux.cloud": "lux",
"console.zoo.cloud": "zoo",
"foo.zoo.network": "zoo",
"console.hanzo.cloud": "hanzo",
"app.hanzo.app": "hanzo",
}
for host, want := range match {
if got, ok := BrandForHostOK(host); !ok || got != want {
t.Errorf("BrandForHostOK(%q) = %q,%v; want %q,true", host, got, ok, want)
}
if got := BrandForHost(host); got != want {
t.Errorf("BrandForHost(%q) = %q; want %q", host, got, want)
}
}
// No brand domain matches → not ok, and BrandForHost defaults to hanzo. The
// caller (agentskills) uses the not-ok signal to fall back to the DEPLOYMENT
// brand instead of blindly emitting Hanzo on a non-hanzo pod.
for _, host := range []string{"example.com", "localhost", "", "10.0.0.1"} {
if _, ok := BrandForHostOK(host); ok {
t.Errorf("BrandForHostOK(%q) matched a brand, want no match", host)
}
if got := BrandForHost(host); got != DefaultBrand {
t.Errorf("BrandForHost(%q) = %q, want %q", host, got, DefaultBrand)
}
}
}
// TestLoadConfig_IssuerDerivedFromBrand asserts that when CLOUD_IAM_ISSUER is
// unset, the issuer is derived from CLOUD_BRAND — so a non-hanzo brand does not
// silently validate against iam.hanzo.ai.
func TestLoadConfig_IssuerDerivedFromBrand(t *testing.T) {
t.Setenv("CLOUD_BRAND", "lux")
os.Unsetenv("CLOUD_IAM_ISSUER")
cfg := LoadConfig()
if cfg.IAMIssuer != "https://lux.id" {
t.Fatalf("derived issuer = %q, want https://lux.id", cfg.IAMIssuer)
}
if cfg.Brand != "lux" {
t.Fatalf("brand = %q, want lux", cfg.Brand)
}
}
+307 -80
View File
@@ -1,11 +1,18 @@
package cloud
import (
"context"
"fmt"
"os"
"strings"
"github.com/hanzoai/commerce/metering"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
"github.com/hanzoai/cloud/clients"
"github.com/hanzoai/cloud/clients/gatewaypolicy"
"github.com/hanzoai/cloud/clients/s3admin"
)
// BuildDeps constructs the Deps used by every subsystem's Mount(app, deps).
@@ -33,7 +40,7 @@ import (
// JSON does not appear in any of these paths. Inter-subsystem calls
// are ZAP-typed Go values either via direct method dispatch (mode 1)
// or via ZAP RPC over the wire (mode 2). JSON happens only at the
// gateway/ingress edge, through the hanzoai/zip jsonenc helper.
// gateway/ingress edge, through the zip jsonenc helper.
//
// Payments and Vault are special: they are NEVER in-process per
// HIP-0106 solo-vault CDE. Their clients always resolve via
@@ -41,57 +48,155 @@ import (
// when no endpoint is configured.
func BuildDeps(cfg *Config) Deps {
logger := luxlog.New("cloud")
logger.Info("building deps",
logger.Info(
"building deps",
"brand", cfg.Brand,
"domain", cfg.Domain,
"iam_issuer", cfg.IAMIssuer,
"data_dir", cfg.DataDir,
"enabled", cfg.Enable,
)
deps := Deps{
Logger: logger,
Brand: cfg.Brand,
Domain: cfg.Domain,
DataDir: cfg.DataDir,
Logger: logger,
Brand: cfg.Brand,
Env: cfg.Env,
Domain: cfg.Domain,
IAMIssuer: cfg.IAMIssuer,
DataDir: cfg.DataDir,
AIDefaultModel: cfg.AIDefaultModel,
}
// For each subsystem: enabled → leave nil (Mount fills it); not
// enabled + endpoint → RPC client; not enabled + no endpoint →
// disabled stub.
deps.IAM = pickIAMClient(cfg, logger)
// disabled stub. The plain co-resident-or-RPC-or-disabled clients share
// ONE resolver (pick); KMS/AI/VFS keep bespoke pickers because their
// construction genuinely differs (embedded store / gateway preference /
// S3-admin backend). O11y's disabled stub is a no-op (telemetry going
// nowhere is normal), not fail-closed.
deps.IAM = pick(cfg, logger, "iam", "IAM", cfg.IAMZAPAddr, clients.IAMRPCAt, clients.DisabledIAM)
deps.KMS = pickKMSClient(cfg, logger)
deps.Base = pickBaseClient(cfg, logger)
deps.Commerce = pickCommerceClient(cfg, logger)
deps.Base = pick(cfg, logger, "base", "Base", cfg.BaseZAPAddr, clients.BaseRPCAt, clients.DisabledBase)
deps.Commerce = pick(cfg, logger, "commerce", "Commerce", cfg.CommerceZAPAddr, clients.CommerceRPCAt, clients.DisabledCommerce)
deps.AI = pickAIClient(cfg, logger)
deps.O11y = pickO11yClient(cfg, logger)
deps.O11y = pick(cfg, logger, "o11y", "O11y", cfg.O11yZAPAddr, clients.O11yRPCAt, clients.DisabledO11y)
deps.VFS = pickVFSClient(cfg, logger)
deps.MQ = pickMQClient(cfg, logger)
deps.MQ = pick(cfg, logger, "mq", "MQ", cfg.MQZAPAddr, clients.MQRPCAt, clients.DisabledMQ)
// Payments and Vault never co-resident. Disabled stub when no
// endpoint, otherwise RPC.
deps.Payments = pickPaymentsClient(cfg, logger)
deps.Vault = pickVaultClient(cfg, logger)
// Billing metering client for the request-edge gate. nil-safe: when no
// commerce URL is configured the resulting client is !Enabled() and the
// gate is a no-op.
deps.Metering = buildMeteringClient(cfg, logger)
// Runtime-mutable edge-policy store (/v1/gateway config plane), layered over
// the static env/flag defaults so an un-provisioned deployment behaves exactly
// as the static config until an operator PUTs an override. New always returns a
// 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))
if err != nil {
logger.Warn("gateway policy store degraded to static-only", "err", err)
}
deps.GatewayPolicy = gp
return deps
}
// pickIAMClient returns the canonical IAMClient for this process.
// nil = enabled here, Mount will fill it. RPC = remote endpoint
// configured. Disabled = not enabled, no endpoint.
func pickIAMClient(cfg *Config, log luxlog.Logger) IAMClient {
if cfg.Enabled("iam") {
return nil
// staticEdgePolicy projects the static env/flag edge config into the boot-default
// policy the gatewaypolicy.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{
CORSOrigins: cfg.CORSOrigins,
WindowSec: cfg.EdgeRateWindowSec,
}
if cfg.IAMZAPAddr != "" {
log.Info("deps.IAM → ZAP RPC", "addr", cfg.IAMZAPAddr)
return clients.IAMRPCAt(cfg.IAMZAPAddr)
if cfg.EdgeRateEnabled {
p.PerIPRPM = cfg.EdgeRatePerIP
}
return clients.DisabledIAM()
return p
}
// buildMeteringClient constructs the commerce metering client for BillingGate.
// An empty CommerceHTTPURL yields a not-Enabled() client (allow + no-op),
// matching the metering package's "not configured" mode, so an unconfigured
// deployment is never blocked. The token is a KMS-sourced secret supplied via
// config; it is never logged.
func buildMeteringClient(cfg *Config, log luxlog.Logger) *metering.Client {
m, err := metering.New(metering.Config{
BaseURL: cfg.CommerceHTTPURL,
Token: cfg.CommerceServiceToken,
Org: cfg.Brand, // X-Org-Id default for S2S; per-request org overrides.
FailOpen: cfg.BillingFailOpen,
})
if err != nil {
// Only an unparseable URL reaches here. Fall back to a not-configured
// client (no-op gate) rather than failing boot over billing wiring.
log.Error("billing: invalid commerce URL, gate disabled", "err", err)
m, _ = metering.New(metering.Config{})
}
if m.Enabled() {
log.Info("billing gate enabled", "commerce_url", cfg.CommerceHTTPURL, "fail_open", cfg.BillingFailOpen)
} else {
log.Info("billing gate disabled (no commerce URL)")
}
return m
}
// pick resolves one inter-subsystem client under the HIP-0106 wiring rule shared
// by every co-resident-capable dependency: enabled in THIS process → zero value
// (nil) so the subsystem's own Mount installs the in-process client; not enabled
// but a ZAP endpoint is configured → an RPC client at that endpoint; neither →
// the fail-closed/no-op disabled stub. name is the enable-list id; label is the
// deps.<X> log tag; rpc/disabled are the client's typed constructors. This is the
// ONE implementation of that rule — KMS/AI/VFS opt out with bespoke pickers only
// because their construction genuinely differs.
func pick[T any](cfg *Config, log luxlog.Logger, name, label, zapAddr string, rpc func(string) T, disabled func() T) T {
if cfg.Enabled(name) {
var zero T // enabled here → Mount fills deps.<label>
return zero
}
if zapAddr != "" {
log.Info("deps."+label+" → ZAP RPC", "addr", zapAddr)
return rpc(zapAddr)
}
return disabled()
}
// pickKMSClient resolves deps.KMS. When the kms subsystem is co-resident
// (Enabled("kms")) it returns the IN-PROCESS Client backed by the embedded
// luxfi/kms SecretStore under CLOUD_DATA_DIR — no external RPC. A store-open
// failure is NOT fatal to the whole binary: it falls back to the disabled stub
// (fail-closed) and logs, so a bad data dir degrades KMS rather than crashing
// every subsystem. Absent co-residency the legacy ZAP-RPC + disabled fallbacks
// apply (out-of-process KMS, or not wired).
//
// The subsystem id is "kms" (clients/kms registers it with cloud.HealthOwner so
// the generic liveness route never shadows its real /v1/kms/health, and registers
// the client factory this gate calls); this gate keys on the same id so "enabled"
// is one concept.
func pickKMSClient(cfg *Config, log luxlog.Logger) KMSClient {
if cfg.Enabled("kms") {
return nil
// The embedded-client constructor is registered by clients/kms in init()
// (RegisterKMSClientFactory). cloud never imports clients/kms, so the KMS
// library and its /v1/kms subsystem live in one package with no cloud⇄kms
// import cycle. Absent the registration (clients/kms not linked into this
// binary) KMS fails closed rather than pretending to host secrets.
if kmsClientFactory == nil {
log.Error("deps.KMS: kms enabled but no client factory registered (clients/kms not linked); failing closed")
return clients.DisabledKMS()
}
c, err := kmsClientFactory(cfg, log)
if err != nil {
log.Error("deps.KMS: embedded KMS unavailable, failing closed", "err", err)
return clients.DisabledKMS()
}
return c
}
if cfg.KMSZAPAddr != "" {
log.Info("deps.KMS → ZAP RPC", "addr", cfg.KMSZAPAddr)
@@ -100,74 +205,115 @@ func pickKMSClient(cfg *Config, log luxlog.Logger) KMSClient {
return clients.DisabledKMS()
}
func pickBaseClient(cfg *Config, log luxlog.Logger) BaseClient {
if cfg.Enabled("base") {
return nil
}
if cfg.BaseZAPAddr != "" {
log.Info("deps.Base → ZAP RPC", "addr", cfg.BaseZAPAddr)
return clients.BaseRPCAt(cfg.BaseZAPAddr)
}
return clients.DisabledBase()
}
// kmsClientFactory constructs the embedded in-process KMS client from cloud
// Config. clients/kms registers it in init(); pickKMSClient calls it so cloud
// depends on the KMSClient interface + this hook, never the concrete kms package
// — the same inversion the subsystem Registry already uses (cloud mounts every
// subsystem it never imports). Exactly one registration.
var kmsClientFactory func(cfg *Config, log luxlog.Logger) (KMSClient, error)
func pickCommerceClient(cfg *Config, log luxlog.Logger) CommerceClient {
if cfg.Enabled("commerce") {
return nil
}
if cfg.CommerceZAPAddr != "" {
log.Info("deps.Commerce → ZAP RPC", "addr", cfg.CommerceZAPAddr)
return clients.CommerceRPCAt(cfg.CommerceZAPAddr)
}
return clients.DisabledCommerce()
// RegisterKMSClientFactory installs the embedded-KMS constructor. clients/kms
// calls this from its init(); it is the ONE inversion point that lets the KMS
// library and its /v1/kms subsystem share one package with no cloud⇄kms cycle.
func RegisterKMSClientFactory(f func(cfg *Config, log luxlog.Logger) (KMSClient, error)) {
kmsClientFactory = f
}
// pickAIClient resolves deps.AI — the client the agents subsystem runs chat
// completions through. Unlike the co-resident subsystems, there is NO in-process
// "ai" mount that fills a nil deps.AI: inference is an external gateway, so this
// must return a concrete client, never nil. (A nil deps.AI was the live bug —
// the default all-enabled config returned nil here and nothing ever filled it,
// so every /v1/agents/:name/run 503'd "inference is not configured".)
//
// Preference order:
// 1. Static-key HTTP gateway when a base URL AND a static key are configured —
// an operator override / pre-provisioned key. The key is a KMS-injected
// secret; only the base URL and default model are ever logged.
// 2. M2M HTTP gateway when a base URL AND the binary's IAM identity are present
// (the durable Hanzo default): the client mints+refreshes a client-
// credentials token from IAM_CLIENT_ID/SECRET — no static key to rotate. The
// secret is never logged.
// 3. ZAP RPC when an addr is configured (split-deploy of a future ai subsystem).
// 4. Fail-closed stub otherwise — a run records an honest error, never fakes one.
func pickAIClient(cfg *Config, log luxlog.Logger) AIClient {
if cfg.Enabled("ai") {
return nil
if cfg.AIBaseURL != "" && cfg.AIAPIKey != "" {
log.Info("deps.AI → HTTP gateway (static key)", "base_url", cfg.AIBaseURL, "default_model", cfg.AIDefaultModel)
return clients.AIHTTPAt(cfg.AIBaseURL, cfg.AIAPIKey, cfg.AIDefaultModel)
}
if cfg.AIBaseURL != "" && cfg.AIAuthClientID != "" && cfg.AIAuthClientSecret != "" {
tokenURL := aiM2MTokenURL(cfg)
if tokenURL != "" {
log.Info("deps.AI → HTTP gateway (IAM M2M)", "base_url", cfg.AIBaseURL,
"token_url", tokenURL, "client_id", cfg.AIAuthClientID, "default_model", cfg.AIDefaultModel)
return clients.AIHTTPM2M(cfg.AIBaseURL, tokenURL, cfg.AIAuthClientID, cfg.AIAuthClientSecret, cfg.AIDefaultModel)
}
}
if cfg.AIZAPAddr != "" {
log.Info("deps.AI → ZAP RPC", "addr", cfg.AIZAPAddr)
return clients.AIRPCAt(cfg.AIZAPAddr)
}
log.Info("deps.AI → disabled (no CLOUD_AI_API_KEY, no IAM M2M identity, no gateway configured)")
return clients.DisabledAI()
}
func pickO11yClient(cfg *Config, log luxlog.Logger) O11yClient {
if cfg.Enabled("o11y") {
return nil
// aiM2MTokenURL resolves IAM's client_credentials endpoint the agent runner mints
// its M2M inference token at. It MUST be reachable FROM INSIDE THE CLUSTER: the
// runner runs in-cluster and the public issuer host (https://hanzo.id) is fronted
// by Cloudflare, which 403s a server-side (non-browser) loopback POST with edge
// error 1006 — so minting against the PUBLIC issuer URL fails and every
// POST /v1/agents/:ref/run 502s (root-caused 2026-07-04: in-cluster POST to
// https://hanzo.id/v1/iam/oauth/token → 403/1006, while http://iam.hanzo.svc/... → 200).
// This mirrors the KMS login-broker resolution (clients/kms) exactly — one
// split-horizon policy, no drift. Prefer, in order: an explicit override
// (CLOUD_AI_IAM_TOKEN_URL), the in-cluster IAM service base (IAM_URL — already
// wired to http://iam.hanzo.svc for JWKS), then the public issuer as a last resort
// (single-process / no split-horizon deploys). Returns "" only when no identity is
// resolvable, which keeps the M2M branch off (caller falls through to the stub).
func aiM2MTokenURL(cfg *Config) string {
if override := strings.TrimSpace(os.Getenv("CLOUD_AI_IAM_TOKEN_URL")); override != "" {
return override
}
if cfg.O11yZAPAddr != "" {
log.Info("deps.O11y → ZAP RPC", "addr", cfg.O11yZAPAddr)
return clients.O11yRPCAt(cfg.O11yZAPAddr)
if base := strings.TrimRight(strings.TrimSpace(os.Getenv("IAM_URL")), "/"); base != "" {
return base + "/v1/iam/oauth/token"
}
// O11y disabled-stub is no-op (not fail-closed) — telemetry
// going nowhere is a normal mode.
return clients.DisabledO11y()
if iss := strings.TrimRight(strings.TrimSpace(cfg.IAMIssuer), "/"); iss != "" {
return iss + "/v1/iam/oauth/token"
}
return ""
}
func pickVFSClient(cfg *Config, log luxlog.Logger) VFSClient {
if cfg.Enabled("vfs") {
return nil
}
// deps.VFS must NEVER be nil (R-7): files.go and any other VFS consumer call
// s.vfs.Put/Get/Delete unconditionally, so a nil here is a per-request 500
// (dishonest degradation) instead of a fail-closed 502. Unlike the
// nil-then-Mount-fills convention other subsystems use, nothing fills deps.VFS
// after MountAll (Mount receives deps by value), so we ALWAYS hand back a
// concrete client.
if cfg.VFSZAPAddr != "" {
log.Info("deps.VFS → ZAP RPC", "addr", cfg.VFSZAPAddr)
return clients.VFSRPCAt(cfg.VFSZAPAddr)
}
// Real blob backend (.97): the shared SeaweedFS S3 gateway — the canonical,
// key-based object store, reached with the SAME S3_ADMIN_* admin identity
// clients/s3 uses (s3admin, one construction). Present only when those creds
// are injected; a construction failure degrades to fail-closed rather than a
// nil deref. Team blobs (avatars/attachments) round-trip through this to the
// team-blobs bucket, org-scoped by the caller-built key prefix.
if admin := s3admin.New(); admin.Configured() {
v, err := clients.NewS3VFS(admin)
if err != nil {
log.Error("deps.VFS → S3 construction failed; falling back to fail-closed", "err", err)
return clients.DisabledVFS()
}
log.Info("deps.VFS → SeaweedFS S3", "bucket", clients.TeamBlobBucket)
return v
}
// No VFS endpoint and no S3 admin creds → fail-closed stub (R-7): Put/Get/Delete
// return a non-nil error → files answer 502, never a nil-deref 500.
return clients.DisabledVFS()
}
func pickMQClient(cfg *Config, log luxlog.Logger) MQClient {
if cfg.Enabled("mq") {
return nil
}
if cfg.MQZAPAddr != "" {
log.Info("deps.MQ → ZAP RPC", "addr", cfg.MQZAPAddr)
return clients.MQRPCAt(cfg.MQZAPAddr)
}
return clients.DisabledMQ()
}
func pickPaymentsClient(cfg *Config, log luxlog.Logger) PaymentsClient {
if cfg.PaymentsZAPAddr != "" {
log.Info("deps.Payments → ZAP RPC", "addr", cfg.PaymentsZAPAddr)
@@ -184,34 +330,115 @@ func pickVaultClient(cfg *Config, log luxlog.Logger) VaultClient {
return clients.DisabledVault()
}
// MountFunc is the canonical signature every subsystem exposes per
// HIP-0106. Each Hanzo Go service ships a top-level `Mount` symbol
// matching this signature; cmd/cloud/main.go imports the package and
// calls it.
type MountFunc func(app any, deps Deps) error // app is *zip.App; using any here to avoid an import cycle in pkg/cloud
// MountFunc is the registry's mount contract. The registry stores one function
// type, and the external subsystem modules (hanzoai/ai, authz, base, commerce,
// metrics, o11y, licensing) register their mount with `app any` — so `any` is
// the load-bearing wire type here, not a shortcut: retyping it to *zip.App would
// break those pinned modules at compile time (a func(any,…) literal is not
// assignable to a func(*zip.App,…) parameter). The concrete value is always a
// *zip.App; in-repo subsystems recover it via Typed instead of hand-writing the
// assertion (see Typed).
type MountFunc func(app any, deps Deps) error
// Typed adapts a strongly-typed subsystem Mount — func(*zip.App, Deps) error,
// the signature every in-repo subsystem already exports — into the registry's
// MountFunc. It performs the *zip.App recovery in ONE place, fail-closed with a
// clear error, so no subsystem repeats the `a, ok := app.(*zip.App)` boilerplate.
// The concrete value MountAll passes is always a *zip.App, so the assertion is
// total in practice; it stays as a defensive, self-documenting guard.
func Typed(mount func(*zip.App, Deps) error) MountFunc {
return func(app any, deps Deps) error {
a, ok := app.(*zip.App)
if !ok {
return fmt.Errorf("cloud.Mount: app is %T, want *zip.App", app)
}
return mount(a, deps)
}
}
// ShutdownFunc releases a subsystem's process-lifetime resources (background
// goroutines, open DB handles) on graceful shutdown. It must be idempotent and
// bounded — Serve calls it within the shutdown deadline. ctx carries that
// deadline so a slow teardown is cut off rather than hanging SIGTERM.
type ShutdownFunc func(ctx context.Context) error
// MountSpec describes one subsystem registered for mounting. The Order
// is used when ordering matters for inter-subsystem deps (e.g. iam
// before authz before commerce).
type MountSpec struct {
Name string
Order int
Mount MountFunc
Name string
Order int
Mount MountFunc
Shutdown ShutdownFunc // optional; nil means the subsystem has nothing to tear down.
// OwnsHealth marks a subsystem that serves its OWN GET /v1/<name>/health
// (a real, fail-closed probe). Serve's generic liveness loop skips these so
// its always-ok route never shadows the subsystem's real probe. Set via the
// HealthOwner option at registration.
OwnsHealth bool
}
// Option customizes a MountSpec at registration. It keeps Register's common
// path a two-liner while letting a subsystem opt into behavior (e.g. HealthOwner)
// without a wider signature — one registration entry point, extended by options.
type Option func(*MountSpec)
// HealthOwner declares that the subsystem serves its own /v1/<name>/health, so
// Serve's generic liveness route must not shadow it. This replaces the old
// "<name>svc" id kludge (which parked the generic route at an unrouted path):
// the id is now the clean route name and the health policy is an explicit flag.
func HealthOwner(s *MountSpec) { s.OwnsHealth = true }
// Registry is the in-process subsystem registry. Subsystems register via
// init() functions in their respective packages OR cmd/cloud/main.go can
// explicitly enumerate them. Either pattern works.
var Registry []MountSpec
// Register adds a subsystem to the in-process registry.
func Register(name string, order int, mount MountFunc) {
Registry = append(Registry, MountSpec{Name: name, Order: order, Mount: mount})
// Register adds a subsystem to the in-process registry. Trailing opts customize
// the spec (e.g. cloud.HealthOwner for a subsystem that serves its own health).
func Register(name string, order int, mount MountFunc, opts ...Option) {
spec := MountSpec{Name: name, Order: order, Mount: mount}
for _, opt := range opts {
opt(&spec)
}
Registry = append(Registry, spec)
}
// RegisterWithShutdown adds a subsystem that owns process-lifetime resources: a
// background worker (e.g. the agents scheduler) or a DB handle that must be
// flushed. shutdown is invoked by ShutdownAll on graceful stop. This is the ONE
// way a subsystem gets a teardown — Register stays the zero-teardown default.
// Trailing opts customize the spec, exactly as for Register.
func RegisterWithShutdown(name string, order int, mount MountFunc, shutdown ShutdownFunc, opts ...Option) {
spec := MountSpec{Name: name, Order: order, Mount: mount, Shutdown: shutdown}
for _, opt := range opts {
opt(&spec)
}
Registry = append(Registry, spec)
}
// ShutdownAll tears down every ENABLED subsystem that registered a ShutdownFunc,
// in REVERSE mount order (a dependency is torn down after its dependents), best
// effort: a failure is collected and the rest still run, so one stuck subsystem
// can't strand another's flush. Serve calls this inside the shutdown deadline.
func ShutdownAll(ctx context.Context, cfg *Config) error {
var firstErr error
for i := len(Registry) - 1; i >= 0; i-- {
spec := Registry[i]
if spec.Shutdown == nil || !cfg.Enabled(spec.Name) {
continue
}
if err := spec.Shutdown(ctx); err != nil && firstErr == nil {
firstErr = fmt.Errorf("shutdown %s: %w", spec.Name, err)
}
}
return firstErr
}
// MountAll iterates the registry in order and calls Mount() on each
// enabled subsystem.
func MountAll(app any, cfg *Config, deps Deps) error {
// enabled subsystem. app is the concrete *zip.App from Serve; the registry's
// MountFunc accepts it as `any` and in-repo subsystems recover it via Typed.
func MountAll(app *zip.App, cfg *Config, deps Deps) error {
// Sort registry by order — bubble sort, registry is tiny.
for i := 0; i < len(Registry); i++ {
for j := i + 1; j < len(Registry); j++ {
+62
View File
@@ -0,0 +1,62 @@
package cloud
import "testing"
// TestAIM2MTokenURL pins the split-horizon resolution order for the agent-runner
// M2M token endpoint (the GAP that 502'd every POST /v1/agents/:ref/run: the
// public issuer host is Cloudflare-fronted and 403s an in-cluster loopback with
// edge error 1006). The runner must mint its token from an IN-CLUSTER URL. Order:
// explicit override → in-cluster IAM_URL → public IAMIssuer fallback.
func TestAIM2MTokenURL(t *testing.T) {
const tokenPath = "/v1/iam/oauth/token"
cases := []struct {
name string
override string // CLOUD_AI_IAM_TOKEN_URL
iamURL string // IAM_URL
iamIssuer string // cfg.IAMIssuer
want string
}{
{
name: "explicit override wins over everything",
override: "http://iam.internal:1234/custom/token",
iamURL: "http://iam.hanzo.svc",
// even a public issuer present must not be chosen
iamIssuer: "https://hanzo.id",
want: "http://iam.internal:1234/custom/token",
},
{
name: "in-cluster IAM_URL preferred over public issuer",
iamURL: "http://iam.hanzo.svc",
iamIssuer: "https://hanzo.id",
want: "http://iam.hanzo.svc" + tokenPath,
},
{
name: "trailing slash on IAM_URL is trimmed",
iamURL: "http://iam.hanzo.svc/",
iamIssuer: "https://hanzo.id",
want: "http://iam.hanzo.svc" + tokenPath,
},
{
name: "falls back to public issuer only when no in-cluster URL",
iamIssuer: "https://hanzo.id",
want: "https://hanzo.id" + tokenPath,
},
{
name: "empty when no identity is resolvable (keeps M2M branch off)",
want: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// t.Setenv restores prior values + prevents parallel interference.
t.Setenv("CLOUD_AI_IAM_TOKEN_URL", tc.override)
t.Setenv("IAM_URL", tc.iamURL)
cfg := &Config{IAMIssuer: tc.iamIssuer}
if got := aiM2MTokenURL(cfg); got != tc.want {
t.Fatalf("aiM2MTokenURL() = %q, want %q", got, tc.want)
}
})
}
}
+86
View File
@@ -0,0 +1,86 @@
package cloud_test
import (
"strings"
"testing"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
)
// TestTyped_RecoversZipApp verifies cloud.Typed adapts a strongly-typed
// func(*zip.App, Deps) into the registry MountFunc: it hands the concrete
// *zip.App straight through to the wrapped mount.
func TestTyped_RecoversZipApp(t *testing.T) {
app := zip.New(zip.Config{})
var got *zip.App
mf := cloud.Typed(func(a *zip.App, _ cloud.Deps) error {
got = a
return nil
})
if err := mf(app, cloud.Deps{}); err != nil {
t.Fatalf("Typed mount returned error: %v", err)
}
if got != app {
t.Fatalf("Typed did not pass the concrete *zip.App through (got %p, want %p)", got, app)
}
}
// TestTyped_WrongTypeFailsClosed verifies cloud.Typed fails closed with a clear
// error — never a panic — when the registry passes a value that is not a
// *zip.App. This is the single, central replacement for the per-subsystem
// assertion boilerplate.
func TestTyped_WrongTypeFailsClosed(t *testing.T) {
called := false
mf := cloud.Typed(func(*zip.App, cloud.Deps) error {
called = true
return nil
})
err := mf("not-a-zip-app", cloud.Deps{})
if err == nil {
t.Fatal("Typed must return an error on a non-*zip.App value")
}
if called {
t.Fatal("Typed must NOT invoke the wrapped mount on a type mismatch")
}
if !strings.Contains(err.Error(), "*zip.App") {
t.Errorf("error should name the wanted type *zip.App, got: %v", err)
}
}
// TestHealthOwner_SetsFlag verifies the HealthOwner option sets OwnsHealth on the
// spec built by Register — the flag Serve reads to skip the generic liveness route
// for a subsystem that serves its own /v1/<name>/health. Asserted by finding the
// registered spec in the global Registry.
func TestHealthOwner_SetsFlag(t *testing.T) {
const name = "healthowner_probe_test"
cloud.Register(name, 999999, cloud.Typed(func(*zip.App, cloud.Deps) error { return nil }), cloud.HealthOwner)
spec := findSpec(t, name)
if !spec.OwnsHealth {
t.Fatal("HealthOwner option must set MountSpec.OwnsHealth")
}
}
// TestRegister_DefaultsNotHealthOwner verifies a plain Register (no options)
// leaves OwnsHealth false, so the generic liveness route stays the default.
func TestRegister_DefaultsNotHealthOwner(t *testing.T) {
const name = "plain_probe_test"
cloud.Register(name, 999998, cloud.Typed(func(*zip.App, cloud.Deps) error { return nil }))
spec := findSpec(t, name)
if spec.OwnsHealth {
t.Fatal("a plain Register must leave OwnsHealth false")
}
}
func findSpec(t *testing.T, name string) cloud.MountSpec {
t.Helper()
for _, s := range cloud.Registry {
if s.Name == name {
return s
}
}
t.Fatalf("spec %q not found in Registry", name)
return cloud.MountSpec{}
}
+39 -7
View File
@@ -6,25 +6,57 @@ import (
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients"
// Blank import registers the kms subsystem's client factory (init) into cloud,
// so BuildDeps can build the in-process deps.KMS below. cloud itself never
// imports clients/kms (no cloud⇄kms cycle); this external test can.
_ "github.com/hanzoai/cloud/clients/kms"
)
// TestBuildDeps_EnabledLeavesNil verifies that BuildDeps leaves an
// enabled subsystem's Client field nil — the subsystem Mount() is
// responsible for filling it in.
// TestBuildDeps_EnabledLeavesNil verifies that BuildDeps leaves an enabled
// Mount-fills-it subsystem's Client field nil — the subsystem Mount() installs
// it. KMS is the exception (see TestBuildDeps_KMSEnabledIsInProcess): it is
// constructed eagerly in BuildDeps because its store must exist before any
// dependent subsystem mounts.
func TestBuildDeps_EnabledLeavesNil(t *testing.T) {
cfg := &cloud.Config{
Brand: "hanzo",
Domain: "api.hanzo.ai",
DataDir: "/tmp",
Enable: []string{"iam", "kms", "base", "commerce", "ai", "o11y", "vfs", "mq"},
DataDir: t.TempDir(),
Enable: []string{"iam", "base", "commerce", "ai", "o11y", "vfs", "mq"},
}
deps := cloud.BuildDeps(cfg)
if deps.IAM != nil {
t.Errorf("deps.IAM: enabled subsystem must leave Client nil, got %T", deps.IAM)
}
if deps.KMS != nil {
t.Errorf("deps.KMS: enabled subsystem must leave Client nil, got %T", deps.KMS)
}
// TestBuildDeps_KMSEnabledIsInProcess verifies the HIP-0106 "embed KMS in cloud"
// contract: when the kms subsystem is enabled, deps.KMS is a live
// in-process client (never nil, never a disabled stub) so other subsystems get a
// working KMS via direct Go dispatch with no RPC. Absent a master key it still
// resolves (health-only, fail-closed) — the point is that deps.KMS is populated.
func TestBuildDeps_KMSEnabledIsInProcess(t *testing.T) {
cfg := &cloud.Config{
Brand: "hanzo",
Domain: "api.hanzo.ai",
DataDir: t.TempDir(),
Enable: []string{"kms"},
}
deps := cloud.BuildDeps(cfg)
if deps.KMS == nil {
t.Fatal("deps.KMS: enabled kms must give an in-process client, got nil")
}
// It must NOT be the fail-closed disabled stub — that stub returns IsDisabled
// errors; an in-process client (no master key) returns a master-key error.
_, err := deps.KMS.GetSecret(context.Background(), "any")
if err == nil {
t.Fatal("GetSecret with no master key must fail closed")
}
if clients.IsDisabled(err) {
t.Errorf("deps.KMS resolved to the DISABLED stub, want the in-process client: %v", err)
}
}
+381
View File
@@ -0,0 +1,381 @@
package cli
import (
"bufio"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"golang.org/x/term"
)
// iamClient is a thin client over the Hanzo IAM OAuth2 surface at
// {issuer}/v1/iam/oauth/*. It speaks only the standard token + userinfo
// endpoints; it holds no IAM business logic.
type iamClient struct {
issuer string
clientID string
http *http.Client
}
func newIAMClient(issuer, clientID string) *iamClient {
return &iamClient{
issuer: strings.TrimRight(issuer, "/"),
clientID: clientID,
http: &http.Client{Timeout: 30 * time.Second},
}
}
// tokenResp is the OAuth2 token endpoint response (success or RFC-6749 error).
type tokenResp struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
Scope string `json:"scope"`
Error string `json:"error"`
ErrorDesc string `json:"error_description"`
}
// postForm performs an x-www-form-urlencoded POST to an oauth endpoint and
// decodes the token response, surfacing OAuth errors as Go errors.
func (c *iamClient) postForm(ctx context.Context, endpoint string, form url.Values) (*tokenResp, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.issuer+endpoint, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "hanzo-cli/"+Version)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var tr tokenResp
if err := json.Unmarshal(body, &tr); err != nil {
return nil, fmt.Errorf("iam %s: HTTP %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
}
if tr.Error != "" {
return nil, fmt.Errorf("iam %s: %s: %s", endpoint, tr.Error, tr.ErrorDesc)
}
if tr.AccessToken == "" {
return nil, fmt.Errorf("iam %s: HTTP %d: no access_token in response", endpoint, resp.StatusCode)
}
return &tr, nil
}
// passwordGrant exchanges username+password for a token (the live IAM client
// supports password grant; device_code is hard-rejected server-side).
func (c *iamClient) passwordGrant(ctx context.Context, username, password, scope string) (*tokenResp, error) {
return c.postForm(ctx, "/v1/iam/oauth/access_token", url.Values{
"grant_type": {"password"},
"client_id": {c.clientID},
"username": {username},
"password": {password},
"scope": {scope},
})
}
// refreshGrant exchanges a refresh token for a fresh access token.
func (c *iamClient) refreshGrant(ctx context.Context, refreshToken string) (*tokenResp, error) {
return c.postForm(ctx, "/v1/iam/oauth/refresh_token", url.Values{
"grant_type": {"refresh_token"},
"client_id": {c.clientID},
"refresh_token": {refreshToken},
})
}
// decodeJWTClaims base64url-decodes a JWT's payload segment WITHOUT verifying
// the signature — used only to display the user's own token claims locally.
func decodeJWTClaims(token string) (map[string]any, error) {
parts := strings.Split(token, ".")
if len(parts) < 2 {
return nil, fmt.Errorf("not a JWT (need 3 dot-separated segments)")
}
payload, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "="))
if err != nil {
return nil, fmt.Errorf("decode JWT payload: %w", err)
}
var claims map[string]any
if err := json.Unmarshal(payload, &claims); err != nil {
return nil, fmt.Errorf("parse JWT claims: %w", err)
}
return claims, nil
}
// claimString reads a string claim, tolerating absence.
func claimString(claims map[string]any, key string) string {
if v, ok := claims[key].(string); ok {
return v
}
return ""
}
// credsFromToken builds a Credentials carrying the token plus the identity
// fields decoded from its claims. expiresIn (token endpoint) wins for expiry;
// otherwise the JWT `exp` claim is used.
func credsFromToken(tr *tokenResp) *Credentials {
c := &Credentials{
AccessToken: tr.AccessToken,
RefreshToken: tr.RefreshToken,
TokenType: firstNonEmpty(tr.TokenType, "Bearer"),
}
if claims, err := decodeJWTClaims(tr.AccessToken); err == nil {
c.Subject = firstNonEmpty(claimString(claims, "email"), claimString(claims, "sub"))
c.Owner = claimString(claims, "owner")
if exp, ok := claims["exp"].(float64); ok {
c.Expiry = int64(exp)
}
}
if tr.ExpiresIn > 0 {
c.Expiry = time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second).Unix()
}
return c
}
// ---------------------------------------------------------------------------
// Commands: login / logout / whoami, grouped under `auth`.
// ---------------------------------------------------------------------------
// loginFlags are shared by `hanzo login` and `hanzo auth login`.
type loginFlags struct {
username string
passwordStdin bool
token string
platformToken string
buildToken string
scope string
}
func runLogin(env *Env, lf *loginFlags, cmd *cobra.Command) error {
creds, err := LoadCredentials()
if err != nil {
return err
}
switch {
case lf.token != "":
// Paste an externally-minted token. Decode claims for identity.
tr := &tokenResp{AccessToken: lf.token, TokenType: "Bearer"}
creds = credsFromToken(tr)
default:
username := lf.username
if username == "" {
username, err = prompt(cmd, "Email: ")
if err != nil {
return err
}
}
password, err := readPassword(cmd, lf.passwordStdin)
if err != nil {
return err
}
iam := newIAMClient(env.IAMIssuer, env.ClientID)
tr, err := iam.passwordGrant(cmd.Context(), username, password, lf.scope)
if err != nil {
return err
}
creds = credsFromToken(tr)
}
// Optional machine-to-machine tokens for the platform control plane,
// stored alongside the identity so apps/deploy work post-login.
if lf.platformToken != "" {
creds.PlatformToken = lf.platformToken
}
if lf.buildToken != "" {
creds.BuildToken = lf.buildToken
}
if err := creds.Save(); err != nil {
return err
}
who := firstNonEmpty(creds.Subject, "(unknown)")
if creds.Owner != "" {
who += " @ " + creds.Owner
}
fmt.Fprintf(cmd.OutOrStdout(), "Logged in as %s (token expires %s)\n", who, shortTime(creds.Expiry))
return nil
}
func newLoginCmd(envOf func() *Env, _ *globalFlags) *cobra.Command {
lf := &loginFlags{}
cmd := &cobra.Command{
Use: "login",
Short: "Authenticate against Hanzo IAM and store a token",
Long: "Authenticate against Hanzo IAM (hanzo.id) via the password grant and store\n" +
"the token in ~/.hanzo/credentials.json (mode 0600). Use --token to store an\n" +
"externally-minted token instead, and --platform-token to store the platform\n" +
"control-plane service token needed by apps/deploy/clusters.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error { return runLogin(envOf(), lf, cmd) },
}
bindLoginFlags(cmd, lf)
return cmd
}
func bindLoginFlags(cmd *cobra.Command, lf *loginFlags) {
f := cmd.Flags()
f.StringVarP(&lf.username, "username", "u", "", "IAM username/email")
f.BoolVar(&lf.passwordStdin, "password-stdin", false, "read the password from stdin (for automation)")
f.StringVar(&lf.token, "token", "", "store this access token directly (skip the password grant)")
f.StringVar(&lf.platformToken, "platform-token", "", "also store the platform control-plane service token")
f.StringVar(&lf.buildToken, "build-token", "", "also store the platform build-enqueue token")
f.StringVar(&lf.scope, "scope", "openid profile email", "OAuth scope")
}
func newLogoutCmd() *cobra.Command {
return &cobra.Command{
Use: "logout",
Short: "Remove stored credentials",
Args: cobra.NoArgs,
PersistentPreRunE: func(*cobra.Command, []string) error { return nil },
RunE: func(cmd *cobra.Command, _ []string) error {
if err := DeleteCredentials(); err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout(), "Logged out.")
return nil
},
}
}
func newWhoamiCmd(envOf func() *Env) *cobra.Command {
var verify bool
cmd := &cobra.Command{
Use: "whoami",
Short: "Show the current identity from the stored token",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
env := envOf()
tok := env.accessToken()
if tok == "" {
return fmt.Errorf("not logged in: run `hanzo login`")
}
claims, err := decodeJWTClaims(tok)
if err != nil {
return err
}
if verify {
if err := verifyUserInfo(cmd.Context(), env, tok); err != nil {
return fmt.Errorf("token rejected by IAM: %w", err)
}
}
return env.emit(claims, func(w io.Writer) {
fmt.Fprintf(w, "email: %s\n", claimString(claims, "email"))
fmt.Fprintf(w, "name: %s\n", firstNonEmpty(claimString(claims, "displayName"), claimString(claims, "name")))
fmt.Fprintf(w, "org: %s\n", claimString(claims, "owner"))
fmt.Fprintf(w, "subject: %s\n", claimString(claims, "sub"))
fmt.Fprintf(w, "issuer: %s\n", claimString(claims, "iss"))
if exp, ok := claims["exp"].(float64); ok {
fmt.Fprintf(w, "expires: %s\n", shortTime(int64(exp)))
}
if verify {
fmt.Fprintln(w, "verified: yes (IAM userinfo accepted the token)")
}
})
},
}
cmd.Flags().BoolVar(&verify, "verify", false, "verify the token against the IAM userinfo endpoint")
return cmd
}
// verifyUserInfo calls the IAM userinfo endpoint with the bearer token; a 2xx
// means IAM accepts the token as live.
func verifyUserInfo(ctx context.Context, env *Env, token string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, env.IAMIssuer+"/v1/iam/oauth/userinfo", nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("User-Agent", "hanzo-cli/"+Version)
resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil
}
// newAuthCmd is the `auth` group: login/logout/whoami plus `token` (print the
// stored access token, for piping into other tools).
func newAuthCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "auth",
Short: "Manage authentication",
}
tokenCmd := &cobra.Command{
Use: "token",
Short: "Print the stored access token",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
tok := envOf().accessToken()
if tok == "" {
return fmt.Errorf("not logged in: run `hanzo login`")
}
fmt.Fprintln(cmd.OutOrStdout(), tok)
return nil
},
}
cmd.AddCommand(newLoginCmd(envOf, gf), newLogoutCmd(), newWhoamiCmd(envOf), tokenCmd)
return cmd
}
// ---------------------------------------------------------------------------
// Terminal helpers.
// ---------------------------------------------------------------------------
// prompt writes a prompt to stderr and reads a trimmed line from stdin.
func prompt(cmd *cobra.Command, label string) (string, error) {
fmt.Fprint(cmd.ErrOrStderr(), label)
r := bufio.NewReader(cmd.InOrStdin())
line, err := r.ReadString('\n')
if err != nil && err != io.EOF {
return "", err
}
return strings.TrimSpace(line), nil
}
// readPassword reads a password without echo from the terminal, or as a plain
// line from stdin when --password-stdin is set (automation) or stdin is not a
// terminal.
func readPassword(cmd *cobra.Command, fromStdin bool) (string, error) {
if fromStdin {
r := bufio.NewReader(cmd.InOrStdin())
line, err := r.ReadString('\n')
if err != nil && err != io.EOF {
return "", err
}
return strings.TrimRight(line, "\r\n"), nil
}
if f, ok := cmd.InOrStdin().(*os.File); ok && term.IsTerminal(int(f.Fd())) {
fmt.Fprint(cmd.ErrOrStderr(), "Password: ")
b, err := term.ReadPassword(int(f.Fd()))
fmt.Fprintln(cmd.ErrOrStderr())
return string(b), err
}
// Non-terminal stdin without --password-stdin: read a line so piped input
// still works, but nudge toward the explicit flag.
r := bufio.NewReader(cmd.InOrStdin())
line, err := r.ReadString('\n')
if err != nil && err != io.EOF {
return "", err
}
return strings.TrimRight(line, "\r\n"), nil
}
+210
View File
@@ -0,0 +1,210 @@
package cli
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// makeJWT builds an unsigned JWT (alg=none) carrying claims — enough to test
// the local, signature-free claim decode the CLI uses for display.
func makeJWT(claims map[string]any) string {
hdr := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
p, _ := json.Marshal(claims)
return hdr + "." + base64.RawURLEncoding.EncodeToString(p) + ".sig"
}
func TestDecodeJWTClaims(t *testing.T) {
tok := makeJWT(map[string]any{"email": "z@hanzo.ai", "owner": "hanzo", "sub": "abc", "exp": float64(1783110016)})
claims, err := decodeJWTClaims(tok)
if err != nil {
t.Fatalf("decode: %v", err)
}
if claimString(claims, "email") != "z@hanzo.ai" || claimString(claims, "owner") != "hanzo" {
t.Fatalf("claims wrong: %+v", claims)
}
if _, err := decodeJWTClaims("not-a-jwt"); err == nil {
t.Fatalf("expected error for non-JWT")
}
}
func TestCredsFromToken(t *testing.T) {
tok := makeJWT(map[string]any{"email": "z@hanzo.ai", "owner": "hanzo", "exp": float64(2000000000)})
// expires_in present → wins over exp.
c := credsFromToken(&tokenResp{AccessToken: tok, RefreshToken: "r", ExpiresIn: 3600})
if c.Subject != "z@hanzo.ai" || c.Owner != "hanzo" || c.RefreshToken != "r" {
t.Fatalf("identity not extracted: %+v", c)
}
if c.Expiry == 2000000000 {
t.Fatalf("expires_in should win over exp claim")
}
// No expires_in → falls back to exp claim.
c2 := credsFromToken(&tokenResp{AccessToken: tok})
if c2.Expiry != 2000000000 {
t.Fatalf("exp claim fallback failed: %d", c2.Expiry)
}
}
func TestPasswordGrant(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/iam/oauth/access_token" {
t.Errorf("path = %s", r.URL.Path)
}
_ = r.ParseForm()
if r.Form.Get("grant_type") != "password" || r.Form.Get("client_id") != "hanzo-console" ||
r.Form.Get("username") != "z@hanzo.ai" || r.Form.Get("password") != "pw" {
t.Errorf("bad form: %v", r.Form)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": makeJWT(map[string]any{"email": "z@hanzo.ai"}),
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "r",
})
}))
defer srv.Close()
c := newIAMClient(srv.URL, "hanzo-console")
tr, err := c.passwordGrant(context.Background(), "z@hanzo.ai", "pw", "openid")
if err != nil {
t.Fatalf("passwordGrant: %v", err)
}
if tr.AccessToken == "" || tr.RefreshToken != "r" {
t.Fatalf("token resp bad: %+v", tr)
}
}
func TestPasswordGrantError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(400)
_ = json.NewEncoder(w).Encode(map[string]any{"error": "invalid_grant", "error_description": "bad password"})
}))
defer srv.Close()
c := newIAMClient(srv.URL, "hanzo-console")
_, err := c.passwordGrant(context.Background(), "u", "p", "openid")
if err == nil || !strings.Contains(err.Error(), "invalid_grant") {
t.Fatalf("expected invalid_grant error, got %v", err)
}
}
func TestRefreshGrant(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
if r.URL.Path != "/v1/iam/oauth/refresh_token" || r.Form.Get("grant_type") != "refresh_token" || r.Form.Get("refresh_token") != "rt" {
t.Errorf("bad refresh request: %s %v", r.URL.Path, r.Form)
}
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": makeJWT(nil), "token_type": "Bearer"})
}))
defer srv.Close()
c := newIAMClient(srv.URL, "hanzo-console")
if _, err := c.refreshGrant(context.Background(), "rt"); err != nil {
t.Fatalf("refreshGrant: %v", err)
}
}
// runRoot executes the cobra root with args, returning stdout and any error.
// stderr is discarded; stdin is provided for password prompts.
func runRoot(t *testing.T, stdin string, args ...string) (string, error) {
t.Helper()
root := newRootCmd()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(new(bytes.Buffer))
root.SetIn(strings.NewReader(stdin))
root.SetArgs(args)
err := root.Execute()
return out.String(), err
}
func TestLoginCommandPasswordStdin(t *testing.T) {
sandbox(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": makeJWT(map[string]any{"email": "z@hanzo.ai", "owner": "hanzo"}),
"token_type": "Bearer", "expires_in": 3600,
})
}))
defer srv.Close()
out, err := runRoot(t, "pw\n", "login", "-u", "z@hanzo.ai", "--password-stdin", "--iam-issuer", srv.URL)
if err != nil {
t.Fatalf("login: %v", err)
}
if !strings.Contains(out, "Logged in as z@hanzo.ai @ hanzo") {
t.Fatalf("login output: %q", out)
}
creds, _ := LoadCredentials()
if creds.AccessToken == "" || creds.Subject != "z@hanzo.ai" {
t.Fatalf("credentials not persisted: %+v", creds)
}
}
func TestLoginTokenPasteAndPlatformToken(t *testing.T) {
sandbox(t)
tok := makeJWT(map[string]any{"email": "ops@hanzo.ai", "owner": "hanzo"})
out, err := runRoot(t, "", "login", "--token", tok, "--platform-token", "svc-123")
if err != nil {
t.Fatalf("login --token: %v", err)
}
if !strings.Contains(out, "ops@hanzo.ai") {
t.Fatalf("login output: %q", out)
}
creds, _ := LoadCredentials()
if creds.AccessToken != tok || creds.PlatformToken != "svc-123" {
t.Fatalf("creds not stored: %+v", creds)
}
}
func TestWhoamiCommand(t *testing.T) {
sandbox(t)
creds := credsFromToken(&tokenResp{AccessToken: makeJWT(map[string]any{
"email": "z@hanzo.ai", "name": "z", "owner": "hanzo", "sub": "u-1", "iss": "https://hanzo.id",
})})
if err := creds.Save(); err != nil {
t.Fatal(err)
}
out, err := runRoot(t, "", "whoami")
if err != nil {
t.Fatalf("whoami: %v", err)
}
for _, want := range []string{"z@hanzo.ai", "hanzo", "u-1", "https://hanzo.id"} {
if !strings.Contains(out, want) {
t.Fatalf("whoami missing %q in %q", want, out)
}
}
}
func TestWhoamiLoggedOut(t *testing.T) {
sandbox(t)
if _, err := runRoot(t, "", "whoami"); err == nil {
t.Fatalf("expected error when logged out")
}
}
func TestLogoutCommand(t *testing.T) {
sandbox(t)
(&Credentials{AccessToken: "x"}).Save()
if _, err := runRoot(t, "", "logout"); err != nil {
t.Fatalf("logout: %v", err)
}
if c, _ := LoadCredentials(); c.AccessToken != "" {
t.Fatalf("logout did not clear credentials")
}
}
func TestAuthTokenCommand(t *testing.T) {
sandbox(t)
(&Credentials{AccessToken: "the-token"}).Save()
out, err := runRoot(t, "", "auth", "token")
if err != nil {
t.Fatalf("auth token: %v", err)
}
if strings.TrimSpace(out) != "the-token" {
t.Fatalf("auth token output: %q", out)
}
}
+560
View File
@@ -0,0 +1,560 @@
// Package cli is the Hanzo cloud-control CLI — the gcloud/doctl-class client
// half of the `hanzo` binary.
//
// `hanzo <subsystem>` SERVES a subsystem (server mode, cmd/hanzo dispatch);
// `hanzo <verb>` CONTROLS the live estate (client mode, this package):
//
// hanzo login | auth identity against hanzo.id (IAM)
// hanzo apps list|get the platform apps board (declared/running/drift)
// hanzo deploy drive a platform redeploy (rolling, zero-downtime)
// hanzo clusters … provision/list/select dedicated DOKS clusters
// hanzo build enqueue a platform-native build (runner fabric)
// hanzo k8s … current deploy target helpers
// hanzo config … ~/.hanzo/config preferences
//
// It is a THIN client over surfaces that already exist — Hanzo IAM
// (hanzo.id /v1/iam/oauth/*), the platform REST control plane
// (platform.hanzo.ai /v1/*), and the cloud /v1 API. It invents no parallel
// API and holds no business logic; every command is one HTTP call shaped by
// resolved configuration. Secrets live only in ~/.hanzo (0600) or the
// environment — never in source, never logged.
package cli
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/spf13/cobra"
)
// Version is the binary version, set by cmd/hanzo from its -ldflags value so
// the CLI and the server report one string. Used in the User-Agent.
var Version = "dev"
// Default endpoints. Overridable per-field via config / env / flag.
const (
defaultIAMIssuer = "https://hanzo.id"
defaultPlatformURL = "https://platform.hanzo.ai"
defaultCloudURL = "https://api.hanzo.ai"
// hanzo-console is the only live IAM client that accepts the password
// grant today; a dedicated `hanzo-cli` client is a one-line IAM seed
// follow-up. Override with `--client-id` / HANZO_CLIENT_ID / config.
defaultClientID = "hanzo-console"
)
// controlCommands maps every client-mode verb to its one-line help. cmd/hanzo
// reads this both to ROUTE (a first token in here means client mode) and to
// list the commands in `hanzo help`, so the verb set is defined exactly once.
var controlCommands = map[string]string{
"login": "authenticate against Hanzo IAM (hanzo.id) and store a token",
"logout": "remove stored credentials",
"whoami": "show the current identity from the stored token",
"auth": "manage authentication (login, logout, whoami, token)",
"apps": "list/get the platform apps board (declared/running/drift)",
"deploy": "drive a platform redeploy (rolling restart, zero-downtime)",
"clusters": "provision/list/select dedicated DOKS clusters",
"build": "enqueue a platform-native build (runner fabric)",
"k8s": "deploy-target helpers (current target)",
"config": "view/edit ~/.hanzo/config preferences",
"security": "scan files for hardcoded secrets (local guardrail; no server/auth)",
"gpu": "connect this machine's GPU to the Hanzo cloud fleet (connect/status/disconnect)",
"engine": "run a local hanzo-engine (OpenAI + Anthropic model server)",
"runner": "run this machine as a JIT CI runner for your org (GitHub Actions)",
}
// IsControlVerb reports whether sub is a client-mode command (and therefore
// must be routed to this package, not the server dispatcher).
func IsControlVerb(sub string) bool {
_, ok := controlCommands[sub]
return ok
}
// ControlCommands returns the verb→description map for `hanzo help`.
func ControlCommands() map[string]string { return controlCommands }
// Execute runs the control CLI with args (already stripped of "hanzo"). It is
// the single entrypoint cmd/hanzo calls for client-mode verbs.
func Execute(args []string) error {
root := newRootCmd()
root.SetArgs(args)
return root.Execute()
}
// ---------------------------------------------------------------------------
// Config — non-secret preferences, ~/.hanzo/config (JSON).
// ---------------------------------------------------------------------------
// Config holds non-secret CLI preferences. Every field is optional; empty
// fields fall back to the built-in defaults at resolution time.
type Config struct {
Org string `json:"org,omitempty"`
Output string `json:"output,omitempty"` // "table" (default) | "json"
IAMIssuer string `json:"iam_issuer,omitempty"`
PlatformURL string `json:"platform_url,omitempty"`
CloudURL string `json:"cloud_url,omitempty"`
ClientID string `json:"client_id,omitempty"`
}
// Credentials holds secret material, ~/.hanzo/credentials.json, mode 0600.
// AccessToken/RefreshToken are the IAM user identity (from `hanzo login`);
// PlatformToken/BuildToken are the machine-to-machine tokens the platform
// REST control plane requires (it cannot validate IAM user tokens).
type Credentials struct {
AccessToken string `json:"access_token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
TokenType string `json:"token_type,omitempty"`
Expiry int64 `json:"expiry,omitempty"` // unix seconds
Subject string `json:"subject,omitempty"`
Owner string `json:"owner,omitempty"` // org slug from the token
PlatformToken string `json:"platform_token,omitempty"`
BuildToken string `json:"build_token,omitempty"`
}
// hanzoDir is ~/.hanzo, created 0700 if missing. Overridable with HANZO_HOME
// (used by tests to sandbox the credential store).
func hanzoDir() (string, error) {
if h := os.Getenv("HANZO_HOME"); h != "" {
return h, os.MkdirAll(h, 0o700)
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
dir := filepath.Join(home, ".hanzo")
return dir, os.MkdirAll(dir, 0o700)
}
func configPath() (string, error) {
dir, err := hanzoDir()
if err != nil {
return "", err
}
if p := os.Getenv("HANZO_CONFIG"); p != "" {
return p, nil
}
return filepath.Join(dir, "config"), nil
}
func credentialsPath() (string, error) {
dir, err := hanzoDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "credentials.json"), nil
}
// loadJSON reads a JSON file into v; a missing file is not an error (v is left
// at its zero value) so first-run with no config/credentials just works.
func loadJSON(path string, v any) error {
b, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
if len(b) == 0 {
return nil
}
return json.Unmarshal(b, v)
}
// writeJSON writes v as indented JSON at path with the given mode, via a
// temp-file rename so a crash mid-write never truncates the store.
func writeJSON(path string, v any, mode os.FileMode) error {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, append(b, '\n'), mode); err != nil {
return err
}
return os.Rename(tmp, path)
}
// LoadConfig reads ~/.hanzo/config (or HANZO_CONFIG).
func LoadConfig() (*Config, error) {
p, err := configPath()
if err != nil {
return nil, err
}
c := &Config{}
return c, loadJSON(p, c)
}
// Save persists the config (mode 0644 — non-secret).
func (c *Config) Save() error {
p, err := configPath()
if err != nil {
return err
}
return writeJSON(p, c, 0o644)
}
// LoadCredentials reads ~/.hanzo/credentials.json.
func LoadCredentials() (*Credentials, error) {
p, err := credentialsPath()
if err != nil {
return nil, err
}
c := &Credentials{}
return c, loadJSON(p, c)
}
// Save persists credentials with mode 0600 (owner read/write only).
func (c *Credentials) Save() error {
p, err := credentialsPath()
if err != nil {
return err
}
return writeJSON(p, c, 0o600)
}
// DeleteCredentials removes the credential store (used by logout).
func DeleteCredentials() error {
p, err := credentialsPath()
if err != nil {
return err
}
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// ---------------------------------------------------------------------------
// Env — the effective, resolved settings a command operates with.
// ---------------------------------------------------------------------------
// Env is the fully-resolved runtime context for a command: config + creds
// merged with environment and the global flags. Built once in the root's
// PersistentPreRunE and read by every subcommand.
type Env struct {
cfg *Config
creds *Credentials
Org string
Output string
IAMIssuer string
PlatformURL string
CloudURL string
ClientID string
out io.Writer
}
// flag values bound by the persistent flags (empty == unset, fall through).
type globalFlags struct {
org, output, platformURL, iamIssuer, cloudURL, clientID, platformToken string
}
// firstNonEmpty returns the first non-empty argument, or "".
func firstNonEmpty(vs ...string) string {
for _, v := range vs {
if v != "" {
return v
}
}
return ""
}
// resolve merges flags > env > config > built-in defaults into an Env. It is
// pure given its inputs (config/creds are loaded by the caller) so it is
// directly unit-testable.
func resolve(cfg *Config, creds *Credentials, f globalFlags) *Env {
e := &Env{cfg: cfg, creds: creds, out: os.Stdout}
e.Output = firstNonEmpty(f.output, os.Getenv("HANZO_OUTPUT"), cfg.Output, "table")
e.IAMIssuer = strings.TrimRight(firstNonEmpty(f.iamIssuer, os.Getenv("HANZO_IAM_ISSUER"), cfg.IAMIssuer, defaultIAMIssuer), "/")
e.PlatformURL = strings.TrimRight(firstNonEmpty(f.platformURL, os.Getenv("HANZO_PLATFORM_URL"), cfg.PlatformURL, defaultPlatformURL), "/")
e.CloudURL = strings.TrimRight(firstNonEmpty(f.cloudURL, os.Getenv("HANZO_CLOUD_URL"), cfg.CloudURL, defaultCloudURL), "/")
e.ClientID = firstNonEmpty(f.clientID, os.Getenv("HANZO_CLIENT_ID"), cfg.ClientID, defaultClientID)
// Org for platform calls is the platform organization id (a distinct
// namespace from the IAM token's `owner` slug), so it comes only from
// flag/env/config — never silently from the token.
e.Org = firstNonEmpty(f.org, os.Getenv("HANZO_ORG"), cfg.Org)
return e
}
// accessToken is the IAM user token (identity / cloud calls).
func (e *Env) accessToken() string {
return firstNonEmpty(os.Getenv("HANZO_TOKEN"), e.creds.AccessToken)
}
// 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.
func (e *Env) platformToken(flagVal string) string {
return firstNonEmpty(
flagVal,
os.Getenv("HANZO_PLATFORM_TOKEN"),
os.Getenv("PLATFORM_SERVICE_TOKEN"),
os.Getenv("PAAS_SERVICE_TOKEN"),
e.creds.PlatformToken,
)
}
// buildToken resolves the platform build-enqueue token (a distinct credential
// from the service token — see /v1/runner).
func (e *Env) buildToken(flagVal string) string {
return firstNonEmpty(
flagVal,
os.Getenv("HANZO_BUILD_TOKEN"),
os.Getenv("PLATFORM_BUILD_CALLBACK_TOKEN"),
e.creds.BuildToken,
)
}
// requireOrg returns the resolved org or a clear error telling the user how to
// set it.
func (e *Env) requireOrg() (string, error) {
if e.Org == "" {
return "", fmt.Errorf("no org set: pass --org, set HANZO_ORG, or run `hanzo config set org <org>`")
}
return e.Org, nil
}
// ---------------------------------------------------------------------------
// Output helpers — one place decides JSON vs human-readable tables.
// ---------------------------------------------------------------------------
// emit prints v as JSON when --output=json, otherwise calls table to render a
// human view. This is the single output branch for every command.
func (e *Env) emit(v any, table func(w io.Writer)) error {
if e.Output == "json" {
enc := json.NewEncoder(e.out)
enc.SetIndent("", " ")
return enc.Encode(v)
}
table(e.out)
return nil
}
// ---------------------------------------------------------------------------
// Root command + global flags.
// ---------------------------------------------------------------------------
func newRootCmd() *cobra.Command {
var f globalFlags
var env *Env
root := &cobra.Command{
Use: "hanzo",
Short: "Hanzo cloud control — manage the live Hanzo estate",
Long: "hanzo — gcloud/doctl-class control for the Hanzo platform (IAM, apps, deploys, clusters, builds).",
SilenceUsage: true,
SilenceErrors: false,
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := LoadConfig()
if err != nil {
return fmt.Errorf("load config: %w", err)
}
creds, err := LoadCredentials()
if err != nil {
return fmt.Errorf("load credentials: %w", err)
}
env = resolve(cfg, creds, f)
env.out = cmd.OutOrStdout()
return nil
},
}
pf := root.PersistentFlags()
pf.StringVar(&f.org, "org", "", "organization (overrides config / HANZO_ORG)")
pf.StringVarP(&f.output, "output", "o", "", "output format: table|json")
pf.StringVar(&f.platformURL, "platform-url", "", "platform base URL (default "+defaultPlatformURL+")")
pf.StringVar(&f.iamIssuer, "iam-issuer", "", "IAM issuer (default "+defaultIAMIssuer+")")
pf.StringVar(&f.cloudURL, "cloud-url", "", "cloud API base URL (default "+defaultCloudURL+")")
pf.StringVar(&f.clientID, "client-id", "", "IAM OAuth client id (default "+defaultClientID+")")
pf.StringVar(&f.platformToken, "platform-token", "", "platform control-plane service token (else env/credential store)")
// envOf returns the resolved Env for a command's RunE (always non-nil after
// PersistentPreRunE).
envOf := func() *Env { return env }
root.AddCommand(
newVersionCmd(),
newAuthCmd(envOf, &f),
newLoginCmd(envOf, &f),
newLogoutCmd(),
newWhoamiCmd(envOf),
newAppsCmd(envOf, &f),
newDeployCmd(envOf, &f),
newClustersCmd(envOf, &f),
newBuildCmd(envOf, &f),
newK8sCmd(envOf, &f),
newConfigCmd(),
newSecurityCmd(envOf),
newGPUCmd(envOf, &f),
newEngineCmd(envOf, &f),
newRunnerCmd(envOf, &f),
)
return root
}
func newVersionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print the hanzo version",
Args: cobra.NoArgs,
PersistentPreRunE: func(*cobra.Command, []string) error { return nil },
RunE: func(cmd *cobra.Command, _ []string) error {
fmt.Fprintf(cmd.OutOrStdout(), "hanzo %s\n", Version)
return nil
},
}
}
// ---------------------------------------------------------------------------
// config command — view/edit the non-secret preference file.
// ---------------------------------------------------------------------------
func newConfigCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "config",
Short: "View/edit ~/.hanzo/config preferences",
PersistentPreRunE: func(*cobra.Command, []string) error { return nil },
}
configKeys := []string{"org", "output", "iam_issuer", "platform_url", "cloud_url", "client_id"}
get := &cobra.Command{
Use: "get <key>",
Short: "Print one config value",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := LoadConfig()
if err != nil {
return err
}
v, err := cfg.field(args[0])
if err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout(), v)
return nil
},
}
set := &cobra.Command{
Use: "set <key> <value>",
Short: "Set one config value (keys: " + strings.Join(configKeys, ", ") + ")",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := LoadConfig()
if err != nil {
return err
}
if err := cfg.setField(args[0], args[1]); err != nil {
return err
}
if err := cfg.Save(); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "set %s = %s\n", args[0], args[1])
return nil
},
}
list := &cobra.Command{
Use: "list",
Short: "Print the full config",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := LoadConfig()
if err != nil {
return err
}
enc := json.NewEncoder(cmd.OutOrStdout())
enc.SetIndent("", " ")
return enc.Encode(cfg)
},
}
path := &cobra.Command{
Use: "path",
Short: "Print the config file path",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
p, err := configPath()
if err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout(), p)
return nil
},
}
cmd.AddCommand(get, set, list, path)
return cmd
}
// field returns the named config value as a string.
func (c *Config) field(key string) (string, error) {
switch key {
case "org":
return c.Org, nil
case "output":
return c.Output, nil
case "iam_issuer":
return c.IAMIssuer, nil
case "platform_url":
return c.PlatformURL, nil
case "cloud_url":
return c.CloudURL, nil
case "client_id":
return c.ClientID, nil
default:
return "", fmt.Errorf("unknown config key %q", key)
}
}
// setField sets the named config value.
func (c *Config) setField(key, val string) error {
switch key {
case "org":
c.Org = val
case "output":
if val != "table" && val != "json" {
return fmt.Errorf("output must be table|json")
}
c.Output = val
case "iam_issuer":
c.IAMIssuer = val
case "platform_url":
c.PlatformURL = val
case "cloud_url":
c.CloudURL = val
case "client_id":
c.ClientID = val
default:
return fmt.Errorf("unknown config key %q", key)
}
return nil
}
// shortTime renders a unix timestamp for human tables; "" for zero.
func shortTime(unix int64) string {
if unix == 0 {
return ""
}
return time.Unix(unix, 0).Format(time.RFC3339)
}
// sortedKeys returns the keys of m, sorted — for deterministic help output.
func sortedKeys(m map[string]string) []string {
ks := make([]string, 0, len(m))
for k := range m {
ks = append(ks, k)
}
sort.Strings(ks)
return ks
}
+240
View File
@@ -0,0 +1,240 @@
package cli
import (
"bytes"
"encoding/json"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
// TestMain restores stdout (quiet.go redirected it to stderr at init) so go
// test's own reporting stays on stdout.
func TestMain(m *testing.M) {
RestoreStdout()
os.Exit(m.Run())
}
// sandbox isolates the credential/config store in a temp dir and clears every
// env var resolve() consults, so tests are deterministic and never touch the
// developer's real ~/.hanzo.
func sandbox(t *testing.T) string {
t.Helper()
dir := t.TempDir()
t.Setenv("HANZO_HOME", dir)
for _, k := range []string{
"HANZO_CONFIG", "HANZO_OUTPUT", "HANZO_IAM_ISSUER", "HANZO_PLATFORM_URL",
"HANZO_CLOUD_URL", "HANZO_CLIENT_ID", "HANZO_ORG", "HANZO_TOKEN",
"HANZO_PLATFORM_TOKEN", "PLATFORM_SERVICE_TOKEN", "PAAS_SERVICE_TOKEN",
"HANZO_BUILD_TOKEN", "PLATFORM_BUILD_CALLBACK_TOKEN",
} {
t.Setenv(k, "")
}
return dir
}
func TestConfigRoundTrip(t *testing.T) {
sandbox(t)
in := &Config{Org: "acme", Output: "json", PlatformURL: "https://p.example", ClientID: "hanzo-console"}
if err := in.Save(); err != nil {
t.Fatalf("save: %v", err)
}
out, err := LoadConfig()
if err != nil {
t.Fatalf("load: %v", err)
}
if *out != *in {
t.Fatalf("round-trip mismatch: %+v != %+v", out, in)
}
}
func TestCredentialsRoundTripAndPerms(t *testing.T) {
dir := sandbox(t)
in := &Credentials{AccessToken: "tok", RefreshToken: "ref", TokenType: "Bearer", Subject: "z@hanzo.ai", Owner: "hanzo", PlatformToken: "pt"}
if err := in.Save(); err != nil {
t.Fatalf("save: %v", err)
}
fi, err := os.Stat(filepath.Join(dir, "credentials.json"))
if err != nil {
t.Fatalf("stat: %v", err)
}
if perm := fi.Mode().Perm(); perm != 0o600 {
t.Fatalf("credentials perm = %o, want 0600", perm)
}
out, err := LoadCredentials()
if err != nil {
t.Fatalf("load: %v", err)
}
if *out != *in {
t.Fatalf("round-trip mismatch: %+v != %+v", out, in)
}
if err := DeleteCredentials(); err != nil {
t.Fatalf("delete: %v", err)
}
if out, _ := LoadCredentials(); out.AccessToken != "" {
t.Fatalf("credentials not deleted")
}
}
func TestLoadMissingFilesIsZeroValue(t *testing.T) {
sandbox(t)
cfg, err := LoadConfig()
if err != nil || cfg.Org != "" {
t.Fatalf("missing config should be zero value, got %+v err %v", cfg, err)
}
creds, err := LoadCredentials()
if err != nil || creds.AccessToken != "" {
t.Fatalf("missing credentials should be zero value, got %+v err %v", creds, err)
}
}
func TestResolveDefaults(t *testing.T) {
sandbox(t)
e := resolve(&Config{}, &Credentials{}, globalFlags{})
if e.IAMIssuer != defaultIAMIssuer || e.PlatformURL != defaultPlatformURL ||
e.CloudURL != defaultCloudURL || e.ClientID != defaultClientID || e.Output != "table" {
t.Fatalf("defaults not applied: %+v", e)
}
}
func TestResolvePrecedenceFlagOverEnvOverConfig(t *testing.T) {
sandbox(t)
t.Setenv("HANZO_ORG", "env-org")
cfg := &Config{Org: "cfg-org", Output: "json"}
// Flag wins.
if e := resolve(cfg, &Credentials{}, globalFlags{org: "flag-org"}); e.Org != "flag-org" {
t.Fatalf("flag should win: %q", e.Org)
}
// Env beats config.
if e := resolve(cfg, &Credentials{}, globalFlags{}); e.Org != "env-org" {
t.Fatalf("env should beat config: %q", e.Org)
}
// Config used when no flag/env.
t.Setenv("HANZO_ORG", "")
if e := resolve(cfg, &Credentials{}, globalFlags{}); e.Org != "cfg-org" {
t.Fatalf("config should be used: %q", e.Org)
}
}
func TestPlatformTokenPrecedence(t *testing.T) {
sandbox(t)
e := resolve(&Config{}, &Credentials{PlatformToken: "from-creds"}, globalFlags{})
if got := e.platformToken(""); got != "from-creds" {
t.Fatalf("creds token: %q", got)
}
t.Setenv("PAAS_SERVICE_TOKEN", "from-paas")
if got := e.platformToken(""); got != "from-paas" {
t.Fatalf("PAAS env should beat creds: %q", got)
}
t.Setenv("PLATFORM_SERVICE_TOKEN", "from-platform")
if got := e.platformToken(""); got != "from-platform" {
t.Fatalf("PLATFORM env should beat PAAS: %q", got)
}
t.Setenv("HANZO_PLATFORM_TOKEN", "from-hanzo")
if got := e.platformToken(""); got != "from-hanzo" {
t.Fatalf("HANZO_PLATFORM_TOKEN should beat all envs: %q", got)
}
if got := e.platformToken("from-flag"); got != "from-flag" {
t.Fatalf("flag should beat everything: %q", got)
}
}
func TestBuildTokenPrecedence(t *testing.T) {
sandbox(t)
e := resolve(&Config{}, &Credentials{BuildToken: "creds"}, globalFlags{})
if got := e.buildToken(""); got != "creds" {
t.Fatalf("creds build token: %q", got)
}
t.Setenv("PLATFORM_BUILD_CALLBACK_TOKEN", "cb")
if got := e.buildToken(""); got != "cb" {
t.Fatalf("callback env: %q", got)
}
if got := e.buildToken("flag"); got != "flag" {
t.Fatalf("flag wins: %q", got)
}
}
func TestAccessTokenFromEnvOverCreds(t *testing.T) {
sandbox(t)
e := resolve(&Config{}, &Credentials{AccessToken: "creds"}, globalFlags{})
if got := e.accessToken(); got != "creds" {
t.Fatalf("creds token: %q", got)
}
t.Setenv("HANZO_TOKEN", "env")
if got := e.accessToken(); got != "env" {
t.Fatalf("env token should win: %q", got)
}
}
func TestRequireOrg(t *testing.T) {
sandbox(t)
e := resolve(&Config{}, &Credentials{}, globalFlags{})
if _, err := e.requireOrg(); err == nil {
t.Fatalf("expected error when org unset")
}
e = resolve(&Config{Org: "acme"}, &Credentials{}, globalFlags{})
if org, err := e.requireOrg(); err != nil || org != "acme" {
t.Fatalf("org=%q err=%v", org, err)
}
}
func TestConfigFieldGetSet(t *testing.T) {
c := &Config{}
if err := c.setField("org", "acme"); err != nil || c.Org != "acme" {
t.Fatalf("set org: %v", err)
}
if v, _ := c.field("org"); v != "acme" {
t.Fatalf("get org: %q", v)
}
if err := c.setField("output", "xml"); err == nil {
t.Fatalf("invalid output should error")
}
if err := c.setField("nope", "x"); err == nil {
t.Fatalf("unknown key should error")
}
if _, err := c.field("nope"); err == nil {
t.Fatalf("unknown key get should error")
}
}
func TestIsControlVerb(t *testing.T) {
for _, v := range []string{"login", "apps", "deploy", "clusters", "build", "k8s", "config", "auth", "whoami", "logout"} {
if !IsControlVerb(v) {
t.Errorf("%q should be a control verb", v)
}
}
for _, v := range []string{"iam", "kms", "cloud", "gateway", "datastore", "nope"} {
if IsControlVerb(v) {
t.Errorf("%q must NOT be a control verb (server mode)", v)
}
}
}
func TestEmitJSONvsTable(t *testing.T) {
// JSON branch: encodes the value, ignores the table func.
var jbuf bytes.Buffer
ej := &Env{Output: "json", out: &jbuf}
called := false
if err := ej.emit(map[string]string{"k": "v"}, func(_ io.Writer) { called = true }); err != nil {
t.Fatalf("emit json: %v", err)
}
if called {
t.Fatalf("table func must not run in json mode")
}
var got map[string]string
if err := json.Unmarshal(jbuf.Bytes(), &got); err != nil || got["k"] != "v" {
t.Fatalf("json output bad: %q (%v)", jbuf.String(), err)
}
// Table branch: runs the table func, does not emit JSON.
var tbuf bytes.Buffer
et := &Env{Output: "table", out: &tbuf}
if err := et.emit(map[string]string{"k": "v"}, func(w io.Writer) { _, _ = w.Write([]byte("ROW")) }); err != nil {
t.Fatalf("emit table: %v", err)
}
if !strings.Contains(tbuf.String(), "ROW") {
t.Fatalf("table output missing: %q", tbuf.String())
}
}
+450
View File
@@ -0,0 +1,450 @@
package cli
import (
"fmt"
"io"
"text/tabwriter"
"github.com/spf13/cobra"
)
// platform builds a platform REST client from the resolved env + the global
// --platform-token flag. The token may be empty here; the client surfaces a
// precise error on first use.
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 == "" {
return "-"
}
return *p
}
// yesno renders a bool for a table cell.
func yesno(b bool) string {
if b {
return "yes"
}
return "no"
}
// newTab returns a tabwriter writing to w with a 2-space gutter.
func newTab(w io.Writer) *tabwriter.Writer {
return tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
}
// ---------------------------------------------------------------------------
// apps — the observe surface.
// ---------------------------------------------------------------------------
func newAppsCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "apps",
Short: "List/get the platform apps board (declared/running/latest/drift)",
}
var envFilter, healthFilter string
var driftOnly bool
list := &cobra.Command{
Use: "list",
Short: "List apps with declared/running tags, health and drift",
Args: cobra.NoArgs,
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,
})
if err != nil {
return err
}
return e.emit(res, func(w io.Writer) {
tw := newTab(w)
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))
}
tw.Flush()
fmt.Fprintf(w, "\n%d apps (ok=%d yellow=%d red=%d)\n",
res.Summary.Total, res.Summary.ByDrift["ok"],
res.Summary.ByDrift["yellow"], res.Summary.ByDrift["red"])
})
},
}
list.Flags().StringVar(&envFilter, "env", "", "filter by env: dev|test|main")
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",
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)
if err != nil {
return err
}
return e.emit(a, func(w io.Writer) {
tw := newTab(w)
fmt.Fprintf(tw, "id:\t%s\n", a.ID)
fmt.Fprintf(tw, "org:\t%s\n", a.Org)
fmt.Fprintf(tw, "app:\t%s\n", a.App)
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, "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)
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)
return cmd
}
// ---------------------------------------------------------------------------
// deploy — the drive surface (rolling restart, zero-downtime).
// ---------------------------------------------------------------------------
func newDeployCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
var project, 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.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
e := envOf()
org, err := e.requireOrg()
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
},
}
cmd.Flags().StringVar(&project, "project", "", "project id")
cmd.Flags().StringVar(&environment, "env", "", "environment id")
return cmd
}
// ---------------------------------------------------------------------------
// clusters — dedicated DOKS cluster lifecycle.
// ---------------------------------------------------------------------------
func newClustersCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "clusters",
Aliases: []string{"cluster"},
Short: "Provision/list/select dedicated DOKS clusters",
}
list := &cobra.Command{
Use: "list",
Short: "List the org's dedicated 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)
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")
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))
}
tw.Flush()
if len(cs) == 0 {
fmt.Fprintln(w, "(no dedicated clusters)")
}
})
},
}
get := &cobra.Command{
Use: "get <cluster-id>",
Short: "Show one cluster",
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)
if err != nil {
return err
}
for _, c := range cs {
if c.DoksClusterID == 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)
},
}
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)
return cmd
}
func printCluster(w io.Writer, c Cluster) {
tw := newTab(w)
fmt.Fprintf(tw, "id:\t%s\n", c.DoksClusterID)
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)
}
tw.Flush()
}
// ---------------------------------------------------------------------------
// build — platform-native build (runner fabric) enqueue.
// ---------------------------------------------------------------------------
func newBuildCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
var br BuildReq
var buildToken string
cmd := &cobra.Command{
Use: "build <repo>",
Short: "Enqueue a platform-native build on the runner fabric (no GitHub builders)",
Long: "Enqueue a build on the platform's native runner fabric. Builds and pushes\n" +
"the named image at a SHA; on completion the platform patches the operator\n" +
"Service CR (build-job → deploy). Requires a live registered runner for the\n" +
"target pool (409 otherwise).",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
e := envOf()
if len(args) == 1 {
br.Repo = args[0]
}
if br.Repo == "" || br.SHA == "" || br.Image == "" {
return fmt.Errorf("--repo (or positional), --sha and --image are required")
}
if br.OrganizationID == "" {
br.OrganizationID = e.Org // optional; server defaults to DEFAULT_BUILD_ORG_ID
}
job, err := e.platform(gf).EnqueueBuild(cmd.Context(), br, e.buildToken(buildToken))
if err != nil {
return err
}
return e.emit(job, func(w io.Writer) {
tw := newTab(w)
fmt.Fprintf(tw, "buildJobId:\t%s\n", job.BuildJobID)
fmt.Fprintf(tw, "status:\t%s\n", job.Status)
fmt.Fprintf(tw, "runnerPool:\t%s\n", job.RunnerPool)
fmt.Fprintf(tw, "image:\t%s\n", job.Image)
fmt.Fprintf(tw, "target:\t%s\n", job.Target)
tw.Flush()
})
},
}
f := cmd.Flags()
f.StringVar(&br.Repo, "repo", "", "owner/name (e.g. hanzoai/pricing)")
f.StringVar(&br.SHA, "sha", "", "commit SHA to build")
f.StringVar(&br.Image, "image", "", "image to build+push (e.g. ghcr.io/hanzoai/pricing:<tag>)")
f.StringVar(&br.Branch, "branch", "", "branch (default main)")
f.StringVar(&br.Dockerfile, "dockerfile", "", "Dockerfile path")
f.StringVar(&br.Context, "context", "", "build context")
f.StringVar(&br.DockerTarget, "target", "", "Docker build stage (--target)")
f.StringVar(&br.OS, "os", "", "linux|darwin|windows (default linux)")
f.StringVar(&br.Arch, "arch", "", "amd64|arm64 (default amd64)")
f.StringVar(&buildToken, "build-token", "", "platform build-enqueue token (else env/credential store)")
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",
}
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) })
},
}
cmd.AddCommand(target)
return cmd
}
+171
View File
@@ -0,0 +1,171 @@
package cli
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// 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 {
t.Helper()
sandbox(t)
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
t.Setenv("HANZO_PLATFORM_URL", srv.URL)
t.Setenv("HANZO_PLATFORM_TOKEN", "svc-tok")
return srv.URL
}
func TestAppsListCommandTable(t *testing.T) {
withPlatform(t, func(w http.ResponseWriter, _ *http.Request) {
_ = 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"}`)},
},
Summary: struct {
Total int `json:"total"`
ByDrift map[string]int `json:"byDrift"`
}{Total: 1, ByDrift: map[string]int{"ok": 1}},
})
})
out, err := runRoot(t, "", "apps", "list")
if err != nil {
t.Fatalf("apps list: %v", err)
}
for _, want := range []string{"APP", "iam", "v1.2.3", "green", "ok", "1 apps"} {
if !strings.Contains(out, want) {
t.Fatalf("apps list table missing %q in:\n%s", want, out)
}
}
}
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"}}})
})
out, err := runRoot(t, "", "apps", "list", "-o", "json")
if err != nil {
t.Fatalf("apps list json: %v", err)
}
var res AppsList
if err := json.Unmarshal([]byte(out), &res); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, out)
}
if len(res.Apps) != 1 || res.Apps[0].App != "iam" {
t.Fatalf("json decode wrong: %+v", res.Apps)
}
}
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)
}
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true})
})
out, err := runRoot(t, "", "deploy", "app-x", "--org", "acme", "--project", "p1", "--env", "e1")
if err != nil {
t.Fatalf("deploy: %v", err)
}
if !strings.Contains(out, "redeployed app-x") {
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")
}
}
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")
}
}
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)
}
_ = 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},
}})
})
out, err := runRoot(t, "", "clusters", "list", "--org", "acme")
if err != nil {
t.Fatalf("clusters list: %v", err)
}
for _, want := range []string{"NAME", "hanzo-acme", "c1", "ready", "yes"} {
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"}}})
})
out, err := runRoot(t, "", "k8s", "target", "--org", "acme")
if err != nil {
t.Fatalf("k8s target: %v", err)
}
if !strings.Contains(out, "hanzo-k8s") || !strings.Contains(out, "shared") {
t.Fatalf("k8s target output: %q", out)
}
}
func TestBuildCommandValidation(t *testing.T) {
withPlatform(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(202) })
// Missing --sha/--image → validation error before any HTTP call.
if _, err := runRoot(t, "", "build", "hanzoai/pricing"); err == nil {
t.Fatalf("build must require --sha and --image")
}
}
func TestBuildCommand(t *testing.T) {
withPlatform(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/runner" {
t.Errorf("path = %s", r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer bt" {
t.Errorf("build auth = %q", got)
}
w.WriteHeader(202)
_ = json.NewEncoder(w).Encode(BuildJob{BuildJobID: "bj-9", Status: "queued", Image: "ghcr.io/hanzoai/pricing:t"})
})
out, err := runRoot(t, "", "build", "hanzoai/pricing", "--sha", "abc", "--image", "ghcr.io/hanzoai/pricing:t", "--build-token", "bt")
if err != nil {
t.Fatalf("build: %v", err)
}
if !strings.Contains(out, "bj-9") {
t.Fatalf("build output: %q", out)
}
}
func TestConfigSetGetCommand(t *testing.T) {
sandbox(t)
if _, err := runRoot(t, "", "config", "set", "org", "acme"); err != nil {
t.Fatalf("config set: %v", err)
}
out, err := runRoot(t, "", "config", "get", "org")
if err != nil {
t.Fatalf("config get: %v", err)
}
if strings.TrimSpace(out) != "acme" {
t.Fatalf("config get = %q", out)
}
}
func strptr(s string) *string { return &s }
+199
View File
@@ -0,0 +1,199 @@
package cli
// engine.go — `hanzo engine install|serve|status`: manage a local hanzo-engine
// (the `hanzoai` OpenAI + Anthropic model server) on THIS machine.
//
// One source of truth for install logic: `install` runs the SAME install.sh /
// install.ps1 the `curl … | sh` one-liner uses (it downloads the prebuilt,
// cosign-signed binary from the latest github.com/hanzoai/engine release), so the
// CLI never re-implements platform detection or verification. `serve` launches the
// installed binary (`hanzoai --port P run -m MODEL`); `status` probes it, reusing
// the same /v1/models probe `hanzo gpu connect --serve-engine` advertises with.
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
"github.com/spf13/cobra"
)
const (
installScriptSh = "https://raw.githubusercontent.com/hanzoai/engine/main/install.sh"
installScriptPS = "https://raw.githubusercontent.com/hanzoai/engine/main/install.ps1"
engineBinName = "hanzoai"
)
func newEngineCmd(envOf func() *Env, _ *globalFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "engine",
Short: "Run a local hanzo-engine (OpenAI + Anthropic model server)",
Long: "Install, serve, and inspect a local hanzo-engine on this machine.\n" +
"`install` downloads the prebuilt, signed `hanzoai` binary; `serve` runs it on\n" +
":1234; `status` shows whether it is up and which models it serves.",
}
// install ----------------------------------------------------------------
var version, dir string
install := &cobra.Command{
Use: "install",
Short: "Download + install the prebuilt hanzoai binary (runs the canonical install script)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return runEngineInstall(cmd, version, dir)
},
}
install.Flags().StringVar(&version, "engine-version", "", "install a specific release tag (default: latest)")
install.Flags().StringVar(&dir, "dir", "", "install directory (default: /usr/local/bin or ~/.local/bin)")
// serve ------------------------------------------------------------------
var model string
var port int
serve := &cobra.Command{
Use: "serve",
Short: "Serve a model with the local hanzoai binary (hanzoai --port P run -m MODEL)",
Long: "Launch the installed hanzoai server. Any args after `--` are passed through to\n" +
"the underlying `hanzoai … run` invocation (e.g. `-- --max-seqs 32`).",
Args: cobra.ArbitraryArgs,
RunE: func(cmd *cobra.Command, extra []string) error {
return runEngineServe(cmd, model, port, extra)
},
}
serve.Flags().StringVarP(&model, "model", "m", "Qwen/Qwen3-4B", "model to serve (HF repo id or local path)")
serve.Flags().IntVarP(&port, "port", "p", 1234, "port to serve the OpenAI + Anthropic API on")
// status -----------------------------------------------------------------
var url string
status := &cobra.Command{
Use: "status",
Short: "Show whether a local hanzo-engine is up and what it serves",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return runEngineStatus(cmd, envOf(), firstNonEmpty(url, defaultEngineURL))
},
}
status.Flags().StringVar(&url, "url", defaultEngineURL, "local engine URL to probe (GET /v1/models)")
cmd.AddCommand(install, serve, status)
return cmd
}
// runEngineInstall shells out to the canonical install script so there is exactly
// one implementation of platform detection + signature verification.
func runEngineInstall(cmd *cobra.Command, version, dir string) error {
env := os.Environ()
if version != "" {
env = append(env, "HANZOAI_VERSION="+version)
}
if dir != "" {
env = append(env, "HANZOAI_INSTALL_DIR="+dir)
}
var sh *exec.Cmd
if runtime.GOOS == "windows" {
sh = exec.CommandContext(cmd.Context(), "powershell", "-NoProfile", "-Command",
"irm "+installScriptPS+" | iex")
} else {
// curl … | sh — the exact one-liner documented in the README.
sh = exec.CommandContext(cmd.Context(), "sh", "-c",
"curl -fsSL "+installScriptSh+" | sh")
}
sh.Env = env
sh.Stdin, sh.Stdout, sh.Stderr = os.Stdin, cmd.OutOrStdout(), cmd.ErrOrStderr()
if err := sh.Run(); err != nil {
return fmt.Errorf("install script failed: %w", err)
}
return nil
}
// runEngineServe execs the installed hanzoai binary. On Unix it replaces this
// process (syscall.Exec) so signals + exit code flow straight through; on Windows
// it runs as a child with inherited stdio.
func runEngineServe(cmd *cobra.Command, model string, port int, extra []string) error {
bin, err := findEngineBinary()
if err != nil {
return err
}
args := []string{bin, "--port", fmt.Sprintf("%d", port), "run", "-m", model}
args = append(args, extra...)
fmt.Fprintf(cmd.ErrOrStderr(), "→ %s\n", exec.Command(bin, args[1:]...).String())
return execEngine(bin, args)
}
// runEngineStatus probes the local engine and prints (or JSON-emits) its state,
// reusing the same /v1/models probe the fleet advertisement uses.
func runEngineStatus(cmd *cobra.Command, env *Env, url string) error {
ctx, cancel := context.WithTimeout(cmd.Context(), 6*time.Second)
defer cancel()
adv := &engineAdvertisement{URL: url, APIs: []string{"openai", "anthropic"}}
if models, perr := probeEngine(ctx, url); perr != nil {
adv.Status = "unreachable"
} else {
adv.Status = "ready"
adv.Models = models
}
bin, _ := findEngineBinary()
return env.emit(map[string]any{"url": url, "status": adv.Status, "models": adv.Models, "binary": bin},
func(out io.Writer) {
fmt.Fprintf(out, "engine %s — %s\n", adv.URL, describeEngine(adv))
if len(adv.Models) > 0 {
for _, m := range adv.Models {
fmt.Fprintf(out, " model %s\n", m)
}
}
if bin != "" {
fmt.Fprintf(out, "binary %s\n", bin)
} else {
fmt.Fprintln(out, "binary not installed — run `hanzo engine install`")
}
if adv.Status != "ready" {
fmt.Fprintf(out, "hint start it with `hanzo engine serve -m Qwen/Qwen3-4B --port %s`\n",
portOf(url))
}
})
}
// findEngineBinary locates the installed hanzoai binary: PATH first, then the
// directories install.sh / install.ps1 write to.
func findEngineBinary() (string, error) {
name := engineBinName
if runtime.GOOS == "windows" {
name += ".exe"
}
if p, err := exec.LookPath(name); err == nil {
return p, nil
}
home, _ := os.UserHomeDir()
var dirs []string
if runtime.GOOS == "windows" {
if la := os.Getenv("LOCALAPPDATA"); la != "" {
dirs = append(dirs, filepath.Join(la, "Hanzo", "bin"))
}
} else {
dirs = append(dirs, "/usr/local/bin", filepath.Join(home, ".local", "bin"), filepath.Join(home, ".hanzo", "bin"))
}
for _, d := range dirs {
p := filepath.Join(d, name)
if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
return p, nil
}
}
return "", fmt.Errorf("hanzoai not found on PATH or in the default install dirs — run `hanzo engine install`")
}
func portOf(url string) string {
// best-effort: pull the ":<port>" tail for the hint
for i := len(url) - 1; i >= 0; i-- {
if url[i] == ':' {
return url[i+1:]
}
}
return "1234"
}
+14
View File
@@ -0,0 +1,14 @@
//go:build !windows
package cli
import (
"os"
"syscall"
)
// execEngine replaces the current process with hanzoai so signals (Ctrl-C) and
// the exit code flow straight through — `hanzo engine serve` becomes hanzoai.
func execEngine(bin string, args []string) error {
return syscall.Exec(bin, args, os.Environ())
}
+16
View File
@@ -0,0 +1,16 @@
//go:build windows
package cli
import (
"os"
"os/exec"
)
// execEngine runs hanzoai as a child with inherited stdio (Windows has no exec()).
// The child shares the console, so Ctrl-C reaches it; we return its exit error.
func execEngine(bin string, args []string) error {
c := exec.Command(bin, args[1:]...)
c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr
return c.Run()
}
+96
View File
@@ -0,0 +1,96 @@
package cli
import (
"bytes"
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/spf13/cobra"
)
// TestEngineCmdWiring asserts `hanzo engine` exposes install/serve/status with the
// documented flags — the contract install.sh and the docs promise.
func TestEngineCmdWiring(t *testing.T) {
env := &Env{Output: "table"}
cmd := newEngineCmd(func() *Env { return env }, &globalFlags{})
want := map[string]bool{"install": false, "serve": false, "status": false}
for _, sub := range cmd.Commands() {
if _, ok := want[sub.Name()]; ok {
want[sub.Name()] = true
}
}
for name, found := range want {
if !found {
t.Fatalf("engine subcommand %q missing", name)
}
}
serve, _, _ := cmd.Find([]string{"serve"})
if serve.Flags().Lookup("model") == nil || serve.Flags().Lookup("port") == nil {
t.Fatalf("serve must have --model and --port")
}
status, _, _ := cmd.Find([]string{"status"})
if status.Flags().Lookup("url") == nil {
t.Fatalf("status must have --url")
}
}
// TestEngineStatusReady probes a stub engine and reports it ready with its models.
func TestEngineStatusReady(t *testing.T) {
engine := stubEngine(t, "default", "zen-omni-30b")
defer engine.Close()
var buf bytes.Buffer
env := &Env{Output: "table", out: &buf}
cmd := &cobra.Command{}
cmd.SetContext(context.Background())
if err := runEngineStatus(cmd, env, engine.URL); err != nil {
t.Fatalf("status: %v", err)
}
out := buf.String()
if !strings.Contains(out, "ready") || !strings.Contains(out, "zen-omni-30b") {
t.Fatalf("status output missing ready/model:\n%s", out)
}
}
// TestEngineStatusUnreachable reports a down engine as unreachable (not an error).
func TestEngineStatusUnreachable(t *testing.T) {
var buf bytes.Buffer
env := &Env{Output: "table", out: &buf}
cmd := &cobra.Command{}
cmd.SetContext(context.Background())
// A port with nothing listening.
if err := runEngineStatus(cmd, env, "http://127.0.0.1:1"); err != nil {
t.Fatalf("status should not error on a down engine: %v", err)
}
if !strings.Contains(buf.String(), "unreachable") {
t.Fatalf("expected 'unreachable', got:\n%s", buf.String())
}
}
// TestFindEngineBinary finds hanzoai on PATH.
func TestFindEngineBinary(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("PATH exe probe differs on Windows")
}
dir := t.TempDir()
bin := filepath.Join(dir, engineBinName)
if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir)
got, err := findEngineBinary()
if err != nil {
t.Fatalf("findEngineBinary: %v", err)
}
if got != bin {
t.Fatalf("found %q, want %q", got, bin)
}
}
+1189
View File
File diff suppressed because it is too large Load Diff
+247
View File
@@ -0,0 +1,247 @@
package cli
// gpu_engine_test.go — the `--serve-engine` capability: probing a local hanzo-engine
// and assembling the fleet advertisement + provider registration. Uses an httptest
// stub for the engine so no model (or GPU) is needed.
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// stubEngine stands in for hanzo-engine's OpenAI-shaped GET /v1/models.
func stubEngine(t *testing.T, models ...string) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/v1/models", func(w http.ResponseWriter, _ *http.Request) {
data := make([]map[string]any, 0, len(models))
for _, m := range models {
data = append(data, map[string]any{"id": m, "object": "model", "owned_by": "local"})
}
_ = json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": data})
})
return httptest.NewServer(mux)
}
func TestProbeEngine(t *testing.T) {
srv := stubEngine(t, "default", "zen-omni-30b")
defer srv.Close()
got, err := probeEngine(context.Background(), srv.URL)
if err != nil {
t.Fatalf("probeEngine: %v", err)
}
if len(got) != 2 || got[0] != "default" || got[1] != "zen-omni-30b" {
t.Fatalf("models = %v, want [default zen-omni-30b]", got)
}
}
func TestProbeEngineDown(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "loading", http.StatusServiceUnavailable)
}))
defer srv.Close()
if _, err := probeEngine(context.Background(), srv.URL); err == nil {
t.Fatal("probeEngine: want error for a 503 engine, got nil")
}
}
func TestRefreshEngineAdvertises(t *testing.T) {
srv := stubEngine(t, "default")
defer srv.Close()
w := &worker{
identity: "gb10",
hostname: "gb10",
jobsNS: "gpu-jobs",
serveEngine: true,
engineURL: srv.URL,
engineAdvURL: "http://node.example:1234",
}
if changed := w.refreshEngine(context.Background()); !changed {
t.Fatal("first refreshEngine should report a change (nil -> ready)")
}
if w.engine == nil || w.engine.Status != "ready" {
t.Fatalf("engine = %+v, want status ready", w.engine)
}
if w.engine.URL != "http://node.example:1234" {
t.Fatalf("advertised URL = %q, want the endpoint, not the local probe URL", w.engine.URL)
}
if !contains(w.engine.APIs, "openai") || !contains(w.engine.APIs, "anthropic") {
t.Fatalf("APIs = %v, want both openai and anthropic (hanzo-engine serves both)", w.engine.APIs)
}
if len(w.engine.Models) != 1 || w.engine.Models[0] != "default" {
t.Fatalf("models = %v, want [default]", w.engine.Models)
}
// A second probe with an unchanged engine is a no-op (no needless re-register).
if changed := w.refreshEngine(context.Background()); changed {
t.Fatal("second refreshEngine with an unchanged engine should report no change")
}
}
func TestRefreshEngineUnreachable(t *testing.T) {
w := &worker{
identity: "gb10",
serveEngine: true,
engineURL: "http://127.0.0.1:0", // nothing listening
engineAdvURL: "http://127.0.0.1:0",
}
w.refreshEngine(context.Background())
if w.engine == nil || w.engine.Status != "unreachable" {
t.Fatalf("engine = %+v, want status unreachable", w.engine)
}
}
func TestBuildRegistrationCarriesEngine(t *testing.T) {
srv := stubEngine(t, "default")
defer srv.Close()
w := &worker{
identity: "gb10",
hostname: "gb10",
jobsNS: "gpu-jobs",
gpus: []gpuInfo{{Name: "NVIDIA GB10", MemoryTotal: "122880 MiB"}},
serveEngine: true,
engineURL: srv.URL,
engineAdvURL: "http://node.example:1234",
}
w.refreshEngine(context.Background())
reg := w.buildRegistration()
if !contains(reg.Capabilities, studioCap) || !contains(reg.Capabilities, engineCap) {
t.Fatalf("capabilities = %v, want both %q and %q", reg.Capabilities, studioCap, engineCap)
}
if reg.Engine == nil || reg.Engine.URL != "http://node.example:1234" {
t.Fatalf("registration engine = %+v, want the advertised endpoint", reg.Engine)
}
// The presence record's Input must carry the endpoint so GET /v1/fleet/workers
// (which decodes this exact JSON) can advertise it.
raw, err := json.Marshal(reg)
if err != nil {
t.Fatalf("marshal registration: %v", err)
}
for _, want := range []string{`"engine.serve"`, `"http://node.example:1234"`, `"openai"`, `"anthropic"`} {
if !strings.Contains(string(raw), want) {
t.Fatalf("registration JSON missing %s:\n%s", want, raw)
}
}
}
func TestCapabilitiesWithoutEngine(t *testing.T) {
w := &worker{serveEngine: false}
caps := w.capabilities()
if len(caps) != 1 || caps[0] != studioCap {
t.Fatalf("capabilities = %v, want just [%q] when not serving an engine", caps, studioCap)
}
}
func TestProviderBodyIsOpenAICompatible(t *testing.T) {
w := &worker{
identity: "gb10",
engine: &engineAdvertisement{URL: "http://node.example:1234", Status: "ready", Models: []string{"zen-omni-30b"}},
}
body := w.providerBody()
if body["type"] != "Local" {
t.Fatalf("type = %v, want Local (OpenAI-compatible; gateway auto-appends /v1)", body["type"])
}
if body["providerUrl"] != "http://node.example:1234" {
t.Fatalf("providerUrl = %v", body["providerUrl"])
}
if body["name"] != "gpu-gb10" {
t.Fatalf("name = %v, want gpu-gb10", body["name"])
}
if body["subType"] != "zen-omni-30b" {
t.Fatalf("subType = %v, want the served model", body["subType"])
}
}
// TestConnectServeEngineRoundTrip closes the loop end-to-end on this box, no model
// and no production: a stub hanzo-engine (GET /v1/models) + a stub cloud that stores
// the presence Input and serves it back on GET /v1/fleet/workers. It exercises the
// real chain — probe → build registration → POST the fleet activity → GET
// /v1/fleet/workers → the engine endpoint is advertised.
func TestConnectServeEngineRoundTrip(t *testing.T) {
engine := stubEngine(t, "default", "zen-omni-30b")
defer engine.Close()
var storedInput registration // what the CLI POSTed as the presence record's Input
cloud := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/fleet/activities"):
var body struct {
Input registration `json:"input"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
storedInput = body.Input
w.WriteHeader(http.StatusOK)
case r.Method == http.MethodGet && r.URL.Path == "/v1/fleet/workers":
// Fold the stored registration into the fleet worker shape, exactly as
// clients/visor/fleet.go byoWorkers does.
_ = json.NewEncoder(w).Encode(map[string]any{"workers": []fleetWorker{{
ID: "gb10", Hostname: storedInput.Hostname, Provider: "byo", Status: "online",
GPUs: storedInput.GPUs, Capabilities: storedInput.Capabilities, Engine: storedInput.Engine,
}}})
default: // namespace ensure + anything else
w.WriteHeader(http.StatusOK)
}
}))
defer cloud.Close()
t.Setenv("HANZO_TOKEN", "test-token") // ensureToken honors this; no `hanzo login` needed
w := &worker{
env: &Env{CloudURL: cloud.URL},
http: &http.Client{Timeout: 5 * time.Second},
baseURL: cloud.URL,
identity: "gb10",
hostname: "gb10",
jobsNS: "gpu-jobs",
gpus: []gpuInfo{{Name: "NVIDIA GB10", MemoryTotal: "122880 MiB"}},
handlers: map[string]jobHandler{},
serveEngine: true,
engineURL: engine.URL,
engineAdvURL: "http://node.example:1234",
}
ctx := context.Background()
w.refreshEngine(ctx)
if err := w.register(ctx); err != nil {
t.Fatalf("register: %v", err)
}
var resp struct {
Workers []fleetWorker `json:"workers"`
}
if _, err := w.call(ctx, http.MethodGet, "/v1/fleet/workers", nil, &resp); err != nil {
t.Fatalf("GET /v1/fleet/workers: %v", err)
}
if len(resp.Workers) != 1 {
t.Fatalf("workers = %d, want 1", len(resp.Workers))
}
fw := resp.Workers[0]
if !contains(fw.Capabilities, engineCap) {
t.Fatalf("fleet worker capabilities = %v, want engine.serve", fw.Capabilities)
}
if fw.Engine == nil || fw.Engine.URL != "http://node.example:1234" || fw.Engine.Status != "ready" {
t.Fatalf("fleet worker engine = %+v, want the advertised ready endpoint", fw.Engine)
}
if len(fw.Engine.Models) != 2 {
t.Fatalf("fleet worker engine models = %v, want 2", fw.Engine.Models)
}
}
func contains(xs []string, v string) bool {
for _, x := range xs {
if x == v {
return true
}
}
return false
}
+109
View File
@@ -0,0 +1,109 @@
package cli
import (
"encoding/json"
"testing"
)
func TestSharePolicyReject(t *testing.T) {
studioInput := json.RawMessage(`{"prompt":{},"org":"karma","project":"swimwear"}`)
engineInput := json.RawMessage(`{"model":"Qwen/Qwen3-4B","org":"hanzo"}`)
cases := []struct {
name string
policy *SharePolicy
jobType string
input json.RawMessage
allow bool // true == reject() returns ""
}{
{"nil policy is permissive", nil, "studio.render", studioInput, true},
{"empty policy is permissive", &SharePolicy{}, "studio.render", studioInput, true},
{
"job type allowed",
&SharePolicy{AllowedJobTypes: []string{"studio.render"}},
"studio.render", studioInput, true,
},
{
"job type not allowed",
&SharePolicy{AllowedJobTypes: []string{"engine.serve"}},
"studio.render", studioInput, false,
},
{
"org allowed",
&SharePolicy{AllowedOrgs: []string{"karma", "hanzo"}},
"studio.render", studioInput, true,
},
{
"org not allowed",
&SharePolicy{AllowedOrgs: []string{"hanzo"}},
"studio.render", studioInput, false,
},
{
"project not allowed",
&SharePolicy{AllowedProjects: []string{"lifestyle"}},
"studio.render", studioInput, false,
},
{
"model allowed",
&SharePolicy{AllowedModels: []string{"Qwen/Qwen3-4B"}},
"engine.serve", engineInput, true,
},
{
"model not allowed",
&SharePolicy{AllowedModels: []string{"meta/llama-3"}},
"engine.serve", engineInput, false,
},
{
"absent field skips its gate (input has no project)",
&SharePolicy{AllowedProjects: []string{"lifestyle"}},
"engine.serve", engineInput, true,
},
{
"combined: all gates pass",
&SharePolicy{
AllowedJobTypes: []string{"studio.render"},
AllowedOrgs: []string{"karma"},
AllowedProjects: []string{"swimwear"},
},
"studio.render", studioInput, true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
reason := tc.policy.reject(tc.jobType, tc.input)
if got := reason == ""; got != tc.allow {
t.Fatalf("reject()=%q; want allow=%v", reason, tc.allow)
}
})
}
}
func TestLoadSharePolicyInline(t *testing.T) {
t.Setenv("HANZO_GPU_POLICY", `{"allowedJobTypes":["studio.render"],"allowedOrgs":["karma"],"maxConcurrent":2}`)
p, err := loadSharePolicy()
if err != nil {
t.Fatalf("loadSharePolicy: %v", err)
}
if p == nil {
t.Fatal("expected a policy, got nil")
}
if len(p.AllowedJobTypes) != 1 || p.AllowedJobTypes[0] != "studio.render" {
t.Fatalf("AllowedJobTypes=%v", p.AllowedJobTypes)
}
if p.MaxConcurrent != 2 {
t.Fatalf("MaxConcurrent=%d", p.MaxConcurrent)
}
if p.reject("engine.serve", json.RawMessage(`{}`)) == "" {
t.Fatal("expected engine.serve to be rejected by studio.render-only policy")
}
}
func TestLoadSharePolicyUnset(t *testing.T) {
t.Setenv("HANZO_GPU_POLICY", "")
t.Setenv("HANZO_GPU_POLICY_FILE", "")
p, err := loadSharePolicy()
if err != nil || p != nil {
t.Fatalf("unset policy: got (%v, %v), want (nil, nil)", p, err)
}
}
+55
View File
@@ -0,0 +1,55 @@
package cli
import (
"context"
"net/http"
"os"
"testing"
"time"
)
// TestUploadOutputsIntegration exercises the real BYO-GPU result-upload path:
// fetchLocalOutput pulls a finished render from the LOCAL studio's /view and
// postGalleryOutput POSTs it to the org studio's /upload/output with the user's
// IAM token, landing it in orgs/{org}/output (the gallery, S3-mirrored).
//
// It is a live integration test, skipped unless the box is wired for it:
//
// HANZO_TOKEN=<iam bearer> \
// HANZO_UPLOAD_IT_FILE=<name of a file the local studio serves at /view?type=output> \
// HANZO_STUDIO_UPLOAD_URL=<org studio base, e.g. https://studio.hanzo.ai> \
// go test ./cli -run TestUploadOutputsIntegration -v
//
// The local studio is assumed at localComfyUI (127.0.0.1:8188).
func TestUploadOutputsIntegration(t *testing.T) {
tok := os.Getenv("HANZO_TOKEN")
file := os.Getenv("HANZO_UPLOAD_IT_FILE")
if tok == "" || file == "" {
t.Skip("set HANZO_TOKEN and HANZO_UPLOAD_IT_FILE to run the live upload integration test")
}
uploadURL := firstNonEmpty(os.Getenv("HANZO_STUDIO_UPLOAD_URL"), defaultStudioUploadURL)
w := &worker{
env: &Env{}, // ensureToken reads HANZO_TOKEN first, so no creds needed
http: &http.Client{Timeout: 60 * time.Second},
studioUploadURL: uploadURL,
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
subfolder := os.Getenv("HANZO_UPLOAD_IT_SUBFOLDER") // "" == top of output
out := file
if subfolder != "" {
out = subfolder + "/" + file
}
gallery, err := w.uploadOutputs(ctx, []string{out}, uploadURL, "karma")
if err != nil {
t.Fatalf("uploadOutputs(%q -> %s): %v", out, uploadURL, err)
}
if len(gallery) != 1 {
t.Fatalf("expected 1 gallery path, got %d: %v", len(gallery), gallery)
}
t.Logf("uploaded %q -> %s gallery path %q", out, uploadURL, gallery[0])
}
+348
View File
@@ -0,0 +1,348 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"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.
type Platform struct {
baseURL string
token string
http *http.Client
}
func newPlatform(baseURL, token string) *Platform {
return &Platform{
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
http: &http.Client{Timeout: 60 * time.Second},
}
}
// apiError carries the HTTP status + server message for a failed call so
// commands can give precise diagnostics (e.g. 401 → token problem).
type apiError struct {
status int
message string
path string
}
func (e *apiError) Error() string {
msg := e.message
if msg == "" {
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`)"
}
return fmt.Sprintf("platform %s: HTTP %d: %s%s", e.path, e.status, msg, hint)
}
// do performs one JSON request with the given bearer token, decoding a 2xx body
// 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>`")
}
var rdr io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, p.baseURL+path, rdr)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "hanzo-cli/"+Version)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := p.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if resp.StatusCode/100 != 2 {
return &apiError{status: resp.StatusCode, message: serverMessage(raw), path: path}
}
if out != nil && len(raw) > 0 {
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("platform %s: decode response: %w", path, err)
}
}
return nil
}
// serverMessage pulls the `{ "message": … }` field platform errors use, falling
// back to the raw (truncated) body.
func serverMessage(raw []byte) string {
var e struct {
Message string `json:"message"`
Error string `json:"error"`
}
if json.Unmarshal(raw, &e) == nil {
if e.Message != "" {
return e.Message
}
if e.Error != "" {
return e.Error
}
}
s := strings.TrimSpace(string(raw))
if len(s) > 240 {
s = s[:240] + "…"
}
return s
}
// ---------------------------------------------------------------------------
// Apps board — GET /v1/apps, GET /v1/apps/{id}, POST /v1/apps/sync.
// ---------------------------------------------------------------------------
// 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.
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"`
}
// AppsList is the /v1/apps envelope: ordered rows + a drift summary.
type AppsList struct {
Apps []AppView `json:"apps"`
Summary struct {
Total int `json:"total"`
ByDrift map[string]int `json:"byDrift"`
} `json:"summary"`
}
// AppsQuery are the optional /v1/apps filters.
type AppsQuery struct {
Org string
Env string
Health string
Drift bool
}
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)
}
if q.Health != "" {
v.Set("health", q.Health)
}
if q.Drift {
v.Set("drift", "1")
}
path := "/v1/apps"
if len(v) > 0 {
path += "?" + v.Encode()
}
out := &AppsList{}
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)
}
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)
}
// driftSeverity extracts the severity string from the raw drift object.
func driftSeverity(raw json.RawMessage) string {
var d struct {
Severity string `json:"severity"`
}
if json.Unmarshal(raw, &d) == nil && d.Severity != "" {
return d.Severity
}
return "-"
}
// ---------------------------------------------------------------------------
// Dedicated clusters — /v1/org/{org}/cluster[ /select | /{id}/install-baseline ].
// ---------------------------------------------------------------------------
// 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).
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"`
}
// 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"`
}
// 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) {
var out struct {
Clusters []Cluster `json:"clusters"`
}
err := p.do(ctx, http.MethodGet, "/v1/org/"+url.PathEscape(org)+"/cluster", 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).
// ---------------------------------------------------------------------------
// 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"`
}
if err := p.do(ctx, http.MethodPost, path, p.token, nil, &out); err != nil {
return err
}
if !out.OK {
return fmt.Errorf("redeploy did not report ok")
}
return nil
}
// ---------------------------------------------------------------------------
// Build — POST /v1/runner (platform-native CI, no GitHub builders).
// ---------------------------------------------------------------------------
// BuildReq is the direct-enqueue body. Repo/SHA/Image are required.
type BuildReq struct {
Repo string `json:"repo"`
SHA string `json:"sha"`
Image string `json:"image"`
Branch string `json:"branch,omitempty"`
Ref string `json:"ref,omitempty"`
Dockerfile string `json:"dockerfile,omitempty"`
Context string `json:"context,omitempty"`
DockerTarget string `json:"dockerTarget,omitempty"`
OS string `json:"os,omitempty"`
Arch string `json:"arch,omitempty"`
OrganizationID string `json:"organizationId,omitempty"`
}
// BuildJob is the enqueue acceptance (HTTP 202).
type BuildJob struct {
BuildJobID string `json:"buildJobId"`
Status string `json:"status"`
RunnerPool string `json:"runnerPool"`
Image string `json:"image"`
Target string `json:"target"`
}
// EnqueueBuild enqueues a native build. It authenticates with the dedicated
// build-callback token, not the service token.
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>`")
}
out := &BuildJob{}
return out, p.do(ctx, http.MethodPost, "/v1/runner", buildToken, req, out)
}
+220
View File
@@ -0,0 +1,220 @@
package cli
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// platformStub spins an httptest server whose handler is provided by the test,
// plus a client pointed at it with the given token.
func platformStub(t *testing.T, token string, h http.HandlerFunc) (*Platform, func()) {
t.Helper()
srv := httptest.NewServer(h)
return newPlatform(srv.URL, token), srv.Close
}
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.Query().Get("env") != "main" || r.URL.Query().Get("drift") != "1" {
t.Errorf("query = %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"}`)}},
})
})
defer done()
res, err := p.Apps(context.Background(), AppsQuery{Env: "main", Drift: true})
if err != nil {
t.Fatalf("Apps: %v", err)
}
if len(res.Apps) != 1 || res.Apps[0].App != "iam" {
t.Fatalf("apps wrong: %+v", res.Apps)
}
if driftSeverity(res.Apps[0].Drift) != "red" {
t.Fatalf("drift severity = %q", driftSeverity(res.Apps[0].Drift))
}
}
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.Query().Get("org") != "hanzoai" {
t.Errorf("org query = %s", r.URL.RawQuery)
}
_ = json.NewEncoder(w).Encode(AppView{ID: "hanzoai/iam/main", App: "iam"})
})
defer done()
a, err := p.App(context.Background(), "hanzoai/iam/main", "hanzoai")
if err != nil || a.App != "iam" {
t.Fatalf("App: %v %+v", err, a)
}
}
func TestPlatformSyncApps(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)
}
})
defer done()
cs, err := p.Clusters(context.Background(), "acme")
if err != nil || len(cs) != 1 || cs[0].DoksClusterID != "c1" {
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)
}
}
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)
}
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true})
})
defer done()
if err := p.Redeploy(context.Background(), "acme", "p1", "e1", "app-x"); err != nil {
t.Fatalf("Redeploy: %v", err)
}
}
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})
})
defer done()
if err := p.Redeploy(context.Background(), "o", "p", "e", "c"); err == nil {
t.Fatalf("expected error when ok=false")
}
}
func TestPlatformEnqueueBuild(t *testing.T) {
p, done := platformStub(t, "svc-tok", func(w http.ResponseWriter, r *http.Request) {
// The build endpoint must use the BUILD token, not the service token.
if got := r.Header.Get("Authorization"); got != "Bearer build-tok" {
t.Errorf("build auth header = %q (must use build token)", got)
}
if r.URL.Path != "/v1/runner" {
t.Errorf("path = %s", r.URL.Path)
}
body, _ := io.ReadAll(r.Body)
var req BuildReq
_ = json.Unmarshal(body, &req)
if req.Repo != "hanzoai/pricing" || req.SHA != "abc123" || req.Image == "" {
t.Errorf("build body = %+v", req)
}
w.WriteHeader(202)
_ = json.NewEncoder(w).Encode(BuildJob{BuildJobID: "bj-1", Status: "queued", RunnerPool: "runner-pool-32g", Image: req.Image})
})
defer done()
job, err := p.EnqueueBuild(context.Background(), BuildReq{Repo: "hanzoai/pricing", SHA: "abc123", Image: "ghcr.io/hanzoai/pricing:t"}, "build-tok")
if err != nil || job.BuildJobID != "bj-1" {
t.Fatalf("EnqueueBuild: %v %+v", err, job)
}
if _, err := p.EnqueueBuild(context.Background(), BuildReq{Repo: "r", SHA: "s", Image: "i"}, ""); err == nil {
t.Fatalf("expected error with empty build token")
}
}
func TestPlatformError401Hint(t *testing.T) {
p, done := platformStub(t, "bad", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(401)
_ = json.NewEncoder(w).Encode(map[string]string{"message": "Unauthorized"})
})
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)
}
}
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)
}
}
+24
View File
@@ -0,0 +1,24 @@
package cli
import "os"
// realStdout is the process's true stdout, captured at this package's init
// before the server-graph dependencies (iam/beego, kms) run their own init()
// functions — several of which emit warnings to stdout (e.g. the IAM registry
// signing-key loader). To keep the CLI's stdout machine-readable (so
// `hanzo apps list -o json | jq` is never corrupted by a dependency's startup
// chatter), this init redirects stdout to stderr for the duration of process
// initialization; RestoreStdout puts the real stdout back before any command
// writes a byte.
//
// This is best-effort: it only helps when this package initializes before the
// noisy dependency (cmd/hanzo imports cli first) AND that dependency reads the
// os.Stdout variable at log time rather than capturing it earlier. main always
// calls RestoreStdout, so correctness never depends on the redirect taking.
var realStdout = os.Stdout
func init() { os.Stdout = os.Stderr }
// RestoreStdout restores the real process stdout. cmd/hanzo calls this as its
// first statement so every command writes to the genuine stdout.
func RestoreStdout() { os.Stdout = realStdout }
+43
View File
@@ -0,0 +1,43 @@
package cli
// runner.go — `hanzo runner`: turn THIS machine into a JIT CI runner for your
// org's GitHub Actions. The host role of the arc daemon, migrated from
// arc-runner/arc (cmd/arcd) into cloud/runner: a GitHub App polls each configured
// org for queued workflow jobs and spawns ephemeral, auto-exiting actions-runner
// subprocesses tagged with this box's labels (GPU / vulkan aware). Outbound only —
// nothing listens for inbound. Completes the trifecta: `engine` serves models,
// `gpu connect` shares compute, `runner` claims CI — one binary, one org login.
import (
"os/signal"
"syscall"
"github.com/hanzoai/cloud/runner"
"github.com/spf13/cobra"
)
func newRunnerCmd(_ func() *Env, _ *globalFlags) *cobra.Command {
runner.Version = Version // propagate the cloud build version into the daemon
var configPath string
cmd := &cobra.Command{
Use: "runner",
Short: "Run this machine as a JIT CI runner for your org (GitHub Actions)",
Long: "Turn this box into an ephemeral, GPU-aware GitHub Actions runner for your\n" +
"org(s). A GitHub App polls for queued jobs and spawns auto-exiting runner\n" +
"subprocesses labelled with this machine's tags. Outbound-only; nothing\n" +
"listens for inbound. Config defaults to ~/.arcd/config.yaml (app_id, orgs,\n" +
"labels, runner_dir).",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := runner.LoadConfig(configPath)
if err != nil {
return err
}
ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
return runner.RunHost(ctx, cfg)
},
}
cmd.Flags().StringVar(&configPath, "config", "", "runner config.yaml (default: ~/.arcd/config.yaml)")
return cmd
}
+259
View File
@@ -0,0 +1,259 @@
package cli
import (
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"github.com/hanzoai/cloud/clients/security/detect"
"github.com/spf13/cobra"
)
// `hanzo security scan` is the LOCAL guardrail-at-generation: it walks a path,
// runs the SAME detect engine the /v1/security server surface uses (one engine,
// two surfaces — see clients/security/detect), and exits non-zero when a finding
// at or above the fail threshold is present. No server, no auth, no network — so
// it drops straight into a pre-commit hook, a CI step, or an agent's shell the
// moment code is written. It never prints a raw secret: findings carry the
// engine's masked preview only.
// skipDirs are never descended into — vendored / generated / VCS trees that
// would drown real findings in noise (and, for node_modules, minified blobs
// that trip entropy heuristics).
var skipDirs = map[string]bool{
".git": true, "node_modules": true, "vendor": true, "dist": true,
"build": true, "out": true, ".vscode-test": true, "target": true,
".next": true, "__pycache__": true, ".venv": true, "venv": true,
}
// maxScanFileBytes caps a single file read; a source file over this is almost
// certainly a data/blob artifact, not code a human wrote a secret into.
const maxScanFileBytes = 2 << 20 // 2 MiB
func newSecurityCmd(envOf func() *Env) *cobra.Command {
cmd := &cobra.Command{
Use: "security",
Short: "Code-security tools (local secret scanning)",
// No PersistentPreRunE override: the root's runs, resolving config/creds
// into env so envOf() (used for -o json) is non-nil. It is local-only —
// scan requires no login or network.
}
var failOn string
scan := &cobra.Command{
Use: "scan [path ...]",
Short: "Scan files/directories for hardcoded secrets (default: current dir)",
Long: "Walk each path and report hardcoded secrets using the native Hanzo\n" +
"detection engine (the same one behind /v1/security). Exits non-zero when a\n" +
"finding at or above --fail-on is present, so it gates a pre-commit hook or CI\n" +
"step. Secrets are never printed — only a masked preview.",
// We print our own findings + a clean "N secrets" error; no cobra usage
// dump on a policy failure.
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
paths := args
if len(paths) == 0 {
paths = []string{"."}
}
floor := strings.ToLower(strings.TrimSpace(failOn))
if floor == "" {
floor = "low"
}
if floor != "none" && detect.SeverityRank(floor) == 0 {
return fmt.Errorf("invalid --fail-on %q (want critical|high|medium|low|none)", failOn)
}
findings, scanned, err := scanPaths(paths)
if err != nil {
return err
}
e := envOf()
result := scanResult{
FilesScanned: scanned,
Findings: findings,
Summary: tally(findings),
}
if err := e.emit(result, func(w io.Writer) { renderScan(w, result) }); err != nil {
return err
}
// Policy gate: fail when any finding is at/above the floor.
if floor != "none" {
bad := 0
for _, f := range findings {
if detect.SeverityRank(f.Severity) >= detect.SeverityRank(floor) {
bad++
}
}
if bad > 0 {
return fmt.Errorf("%d secret(s) at or above %q severity", bad, floor)
}
}
return nil
},
}
scan.Flags().StringVar(&failOn, "fail-on", "low",
"minimum severity that fails the command: critical|high|medium|low|none")
rules := &cobra.Command{
Use: "rules",
Short: "List the detection rules the scanner applies",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
e := envOf()
rv := detect.Rules()
return e.emit(rv, func(w io.Writer) {
tw := newTab(w)
fmt.Fprintln(tw, "SEVERITY\tID\tNAME")
for _, r := range rv {
fmt.Fprintf(tw, "%s\t%s\t%s\n", r.Severity, r.ID, r.Name)
}
tw.Flush()
fmt.Fprintf(w, "\n%d rules\n", len(rv))
})
},
}
cmd.AddCommand(scan, rules)
return cmd
}
// scanFinding is a CLI finding: the engine finding with the file path it was
// found in (the engine echoes the path it was handed, which is what we want).
type scanResult struct {
FilesScanned int `json:"filesScanned"`
Findings []detect.Finding `json:"findings"`
Summary scanSummary `json:"summary"`
}
type scanSummary struct {
Total int `json:"total"`
Critical int `json:"critical"`
High int `json:"high"`
Medium int `json:"medium"`
Low int `json:"low"`
}
func tally(fs []detect.Finding) scanSummary {
var s scanSummary
for _, f := range fs {
s.Total++
switch f.Severity {
case detect.SeverityCritical:
s.Critical++
case detect.SeverityHigh:
s.High++
case detect.SeverityMedium:
s.Medium++
case detect.SeverityLow:
s.Low++
}
}
return s
}
// scanPaths walks each path and runs the engine over every readable text file,
// returning findings sorted worst-first (by severity, then path, then line).
func scanPaths(paths []string) ([]detect.Finding, int, error) {
var findings []detect.Finding
scanned := 0
for _, root := range paths {
info, err := os.Stat(root)
if err != nil {
return nil, 0, fmt.Errorf("stat %q: %w", root, err)
}
if !info.IsDir() {
fs, ok := scanOneFile(root)
if ok {
scanned++
findings = append(findings, fs...)
}
continue
}
walkErr := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
if err != nil {
return nil // unreadable entry — skip, don't abort the whole walk
}
if d.IsDir() {
if skipDirs[d.Name()] {
return filepath.SkipDir
}
return nil
}
if fs, ok := scanOneFile(p); ok {
scanned++
findings = append(findings, fs...)
}
return nil
})
if walkErr != nil {
return nil, 0, fmt.Errorf("walk %q: %w", root, walkErr)
}
}
sort.SliceStable(findings, func(i, j int) bool {
a, b := findings[i], findings[j]
if ra, rb := detect.SeverityRank(a.Severity), detect.SeverityRank(b.Severity); ra != rb {
return ra > rb
}
if a.Path != b.Path {
return a.Path < b.Path
}
return a.Line < b.Line
})
return findings, scanned, nil
}
// scanOneFile reads a file (bounded, text-only) and runs the engine. Returns
// ok=false for a file that was skipped (too big, binary, unreadable) so it is
// not counted as scanned.
func scanOneFile(path string) ([]detect.Finding, bool) {
info, err := os.Stat(path)
if err != nil || info.Size() > maxScanFileBytes {
return nil, false
}
b, err := os.ReadFile(path)
if err != nil {
return nil, false
}
if isBinary(b) {
return nil, false
}
return detect.ScanContent(path, string(b)), true
}
// isBinary reports whether b looks like a non-text blob — a NUL byte in the
// first 8 KiB is the same heuristic git uses. Skipping binaries avoids both
// false positives and wasted work on assets.
func isBinary(b []byte) bool {
n := len(b)
if n > 8192 {
n = 8192
}
for i := 0; i < n; i++ {
if b[i] == 0 {
return true
}
}
return false
}
func renderScan(w io.Writer, r scanResult) {
if len(r.Findings) == 0 {
fmt.Fprintf(w, "✓ no secrets found (%d files scanned)\n", r.FilesScanned)
return
}
tw := newTab(w)
fmt.Fprintln(tw, "SEVERITY\tRULE\tLOCATION\tPREVIEW")
for _, f := range r.Findings {
fmt.Fprintf(tw, "%s\t%s\t%s:%d\t%s\n", f.Severity, f.RuleID, f.Path, f.Line, f.Preview)
}
tw.Flush()
fmt.Fprintf(w, "\n%d finding(s) in %d files (critical=%d high=%d medium=%d low=%d)\n",
r.Summary.Total, r.FilesScanned, r.Summary.Critical, r.Summary.High,
r.Summary.Medium, r.Summary.Low)
}
+176
View File
@@ -0,0 +1,176 @@
package cli
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// runSecurity executes `security <args>` through the real root command with an
// isolated $HOME, capturing stdout. Returns output + the Execute error (the
// non-zero-exit signal).
func runSecurity(t *testing.T, args ...string) (string, error) {
t.Helper()
sandbox(t)
root := newRootCmd()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(append([]string{"security"}, args...))
err := root.Execute()
return out.String(), err
}
// TestSecurityScanFindsAndFails proves scan detects a planted secret, exits
// non-zero at the default fail threshold, and never prints the raw secret.
func TestSecurityScanFindsAndFails(t *testing.T) {
dir := t.TempDir()
secret := "AKIAIOSFODNN7EXAMPLE"
must(t, os.WriteFile(filepath.Join(dir, "config.py"),
[]byte("aws_key = \""+secret+"\"\nok = 1\n"), 0o644))
// a clean file that must NOT trip anything
must(t, os.WriteFile(filepath.Join(dir, "clean.go"),
[]byte("package main\nfunc main() {}\n"), 0o644))
out, err := runSecurity(t, "scan", dir)
if err == nil {
t.Fatalf("expected non-zero exit on a found secret; out:\n%s", out)
}
if !strings.Contains(err.Error(), "at or above") {
t.Fatalf("unexpected error: %v", err)
}
if strings.Contains(out, secret) {
t.Fatalf("output leaked the raw secret:\n%s", out)
}
if !strings.Contains(out, "aws-access-key-id") {
t.Fatalf("expected the aws rule id in output:\n%s", out)
}
}
// TestSecurityScanCleanPasses proves a clean tree exits zero with the ok line.
func TestSecurityScanCleanPasses(t *testing.T) {
dir := t.TempDir()
must(t, os.WriteFile(filepath.Join(dir, "app.go"),
[]byte("package main\nfunc main() { println(\"hi\") }\n"), 0o644))
out, err := runSecurity(t, "scan", dir)
if err != nil {
t.Fatalf("clean tree should exit zero, got %v\n%s", err, out)
}
if !strings.Contains(out, "no secrets found") {
t.Fatalf("expected the clean message:\n%s", out)
}
}
// TestSecurityScanFailOnNone proves --fail-on=none reports findings but exits
// zero (report-only mode).
func TestSecurityScanFailOnNone(t *testing.T) {
dir := t.TempDir()
must(t, os.WriteFile(filepath.Join(dir, "s.py"),
[]byte(`k = "AKIAIOSFODNN7EXAMPLE"`), 0o644))
out, err := runSecurity(t, "scan", "--fail-on", "none", dir)
if err != nil {
t.Fatalf("--fail-on=none should exit zero, got %v\n%s", err, out)
}
if !strings.Contains(out, "aws-access-key-id") {
t.Fatalf("report-only should still list the finding:\n%s", out)
}
}
// TestSecurityScanFailOnThreshold proves a medium finding does NOT fail when
// --fail-on=critical, but a critical one does.
func TestSecurityScanFailOnThreshold(t *testing.T) {
dir := t.TempDir()
// a jwt is medium severity
must(t, os.WriteFile(filepath.Join(dir, "t.txt"),
[]byte("tok = eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhYmMifQ.SGVsbG9TaWduYXR1cmU\n"), 0o644))
if out, err := runSecurity(t, "scan", "--fail-on", "critical", dir); err != nil {
t.Fatalf("medium finding must not fail at --fail-on=critical, got %v\n%s", err, out)
}
// now add a critical
must(t, os.WriteFile(filepath.Join(dir, "k.py"),
[]byte(`k = "AKIAIOSFODNN7EXAMPLE"`), 0o644))
if _, err := runSecurity(t, "scan", "--fail-on", "critical", dir); err == nil {
t.Fatal("a critical finding must fail at --fail-on=critical")
}
}
// TestSecurityScanJSON proves -o json emits a machine-readable result with the
// summary and no raw secret.
func TestSecurityScanJSON(t *testing.T) {
dir := t.TempDir()
secret := "AKIAIOSFODNN7EXAMPLE"
must(t, os.WriteFile(filepath.Join(dir, "s.py"), []byte(`k = "`+secret+`"`), 0o644))
out, _ := runSecurity(t, "scan", "-o", "json", "--fail-on", "none", dir)
var res scanResult
if err := json.Unmarshal([]byte(out), &res); err != nil {
t.Fatalf("json parse: %v\n%s", err, out)
}
if res.Summary.Critical < 1 || res.Summary.Total < 1 {
t.Fatalf("summary missing the critical: %+v", res.Summary)
}
if strings.Contains(out, secret) {
t.Fatalf("json leaked the secret:\n%s", out)
}
}
// TestSecurityScanSkipsVendorAndBinary proves the walker skips skipDirs and
// binary files (a secret inside node_modules or a NUL-laden blob is ignored).
func TestSecurityScanSkipsVendorAndBinary(t *testing.T) {
dir := t.TempDir()
nm := filepath.Join(dir, "node_modules")
must(t, os.MkdirAll(nm, 0o755))
must(t, os.WriteFile(filepath.Join(nm, "dep.js"),
[]byte(`k = "AKIAIOSFODNN7EXAMPLE"`), 0o644))
must(t, os.WriteFile(filepath.Join(dir, "blob.bin"),
append([]byte{0, 1, 2}, []byte(`AKIAIOSFODNN7EXAMPLE`)...), 0o644))
out, err := runSecurity(t, "scan", dir)
if err != nil {
t.Fatalf("vendored + binary secrets should be skipped → clean exit, got %v\n%s", err, out)
}
if !strings.Contains(out, "no secrets found") {
t.Fatalf("expected clean (skipped) result:\n%s", out)
}
}
// TestSecurityScanBadFailOn proves an invalid --fail-on is a clean error.
func TestSecurityScanBadFailOn(t *testing.T) {
dir := t.TempDir()
_, err := runSecurity(t, "scan", "--fail-on", "nope", dir)
if err == nil || !strings.Contains(err.Error(), "invalid --fail-on") {
t.Fatalf("want invalid --fail-on error, got %v", err)
}
}
// TestSecurityRules proves the rules subcommand lists the catalog.
func TestSecurityRules(t *testing.T) {
out, err := runSecurity(t, "rules")
if err != nil {
t.Fatalf("rules failed: %v", err)
}
if !strings.Contains(out, "aws-access-key-id") || !strings.Contains(out, "rules") {
t.Fatalf("rules output missing content:\n%s", out)
}
}
// TestSecurityIsControlVerb proves `security` routes to the CLI, not the server
// dispatcher.
func TestSecurityIsControlVerb(t *testing.T) {
if !IsControlVerb("security") {
t.Fatal("security must be a control verb")
}
}
func must(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
+636
View File
@@ -0,0 +1,636 @@
// Package admin mounts the god-mode admin surface (/v1/admin/*) the Hanzo
// Admin Console (admin.hanzo.ai, apps/operator) calls, per the api.ts contract.
//
// It is an AGGREGATOR, not a new store: identity (orgs/users/roles/applications/
// audit/me) is read from IAM, the money panels (spend/tokens/credits) from
// commerce, and System Health from o11y — every one a real upstream, none fused
// into this binary (see subsystems.go). The facade fans out over HTTP exactly
// like o11ysvc / productsvc: it holds no business logic, it shapes the reads into
// the /v1 envelope { status, msg, data, data2 } the operator's transport
// decodes (get<T> reads data; getList<T> reads data + data2 total).
//
// SECURITY — TWO tiers off ONE identity predicate, both fail-closed. The cockpit is a
// single pane for a SuperAdmin (owner == AdminOrg — c.IsAdmin(), the SANITIZED
// X-User-IsAdmin, true ONLY for a JWT-validated principal whose org IS the admin org,
// matching the gateway's admin-guard) AND for an org admin (any other validated admin
// caller). The predicate is enforced in ONE place — resolveScope/scopedOrgs (scope.go):
//
// - PLATFORM routes (roles/applications/audit/products/finance/compute/o11y/revenue +
// the launch/release/flags/access control plane) are SuperAdmin ONLY (s.guard).
// No principal → 403; an org admin → 403; a forged X-User-IsAdmin never survives
// ingress (SanitizeIdentity strips it).
// - ORG-SCOPED routes (me/overview/orgs/users/usage/analytics/bases) are s.guardScoped:
// a SuperAdmin sees EVERY tenant; any other validated admin caller is HARD-limited to
// their OWN org subtree. The cross-tenant boundary — the escalation line — cannot be
// crossed by a non-super caller for ANY input, because their org is the sanitized,
// un-forgeable c.Org() and every read folds over scopedOrgs.
//
// admin adds no service credential to the IAM fan-out — it replays the caller's own
// cookie/bearer, so it can never read more than the caller already could, and IAM
// re-checks authority on every call (a non-super caller replaying to a cross-tenant IAM
// read is refused by IAM too — defense in depth).
//
// Panels with no in-binary feed yet (the Usage & Costs timeseries + per-product
// breakdown live in insights/datastore; the product/workload registry + infra
// tiles live in platform.hanzo.ai / the operator inventory) return the real,
// honest empty state — never a fabricated number. The operator UI renders those
// as an em-dash / empty table by design.
package admin
import (
"context"
"encoding/json"
"fmt"
"net/url"
"os"
"sort"
"strings"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/audit"
"github.com/zap-proto/zip"
)
// svc holds the resolved upstream clients + the admin org for this deployment.
type svc struct {
iam *iamClient
commerce *commerceClient
health *healthClient
do *doClient
adminOrg string
// auditStore is cloud's OWN tamper-evident audit store (nil when unconfigured,
// in which case /v1/admin/audit falls back to the IAM get-records proxy). Serve
// builds it and hands it over via deps.Audit. See audit.go.
auditStore *audit.Recorder
}
// Mount registers the /v1/admin/* surface on app. Every handler gates on
// c.IsAdmin() first (global-admin only), then aggregates real upstream data.
func Mount(app *zip.App, deps cloud.Deps) error {
if app == nil {
return fmt.Errorf("admin.Mount: nil zip.App")
}
logger := deps.Logger
if logger == nil {
return fmt.Errorf("admin.Mount: nil deps.Logger")
}
logger = logger.New("subsystem", "admin")
s := &svc{
iam: newIAMClient(iamBase(deps)),
commerce: newCommerceClient(os.Getenv("CLOUD_COMMERCE_HTTP_URL"), os.Getenv("COMMERCE_SERVICE_TOKEN")),
health: newHealthClient(o11yHealthURL()),
do: newDOClient(doTokenFromEnv()),
adminOrg: adminOrgOf(deps),
auditStore: deps.Audit,
}
// Org-scoped panels — guardScoped: a SuperAdmin OR any validated admin caller
// pinned to an org. The HANDLER scopes the data (scopedOrgs / resolveScope) so a
// non-super caller is HARD-limited to their own org subtree — cross-tenant reads
// are impossible. Same panels, both tiers, scoped by the ONE predicate.
app.Get("/v1/admin/me", s.guardScoped(s.me))
app.Get("/v1/admin/overview", s.guardScoped(s.overview))
app.Get("/v1/admin/orgs", s.guardScoped(s.orgs))
app.Get("/v1/admin/users", s.guardScoped(s.users))
app.Get("/v1/admin/usage", s.guardScoped(s.usage))
// Platform reads — SuperAdmin only (cross-tenant by nature): roles/apps catalog,
// the fleet audit trail, workload registry, SaaS profitability, compute fleet,
// system health.
app.Get("/v1/admin/roles", s.guard(s.roles))
app.Get("/v1/admin/applications", s.guard(s.applications))
app.Get("/v1/admin/audit", s.guard(s.audit))
app.Get("/v1/admin/audit/verify", s.guard(s.auditVerify))
app.Get("/v1/admin/products", s.guard(s.products))
app.Get("/v1/admin/finance", s.guard(s.finance))
app.Get("/v1/admin/compute", s.guard(s.compute))
app.Get("/v1/admin/o11y", s.guard(s.o11y))
app.Post("/v1/admin/sync", s.guard(s.sync))
// Customer management — the operator cockpit. List (static) precedes the :org
// param route; the write actions are POST (distinct method), so none collide.
app.Get("/v1/admin/customers", s.guard(s.customers))
app.Get("/v1/admin/customers/:org", s.guard(s.customerDetail))
app.Post("/v1/admin/customers/:org/credit", s.guard(s.grantCredit))
app.Get("/v1/admin/grants", s.guard(s.grants))
app.Post("/v1/admin/grants", s.guard(s.issueGrant))
app.Post("/v1/admin/customers/:org/suspend", s.guard(s.suspendCustomer))
app.Post("/v1/admin/customers/:org/reactivate", s.guard(s.reactivateCustomer))
// Fleet revenue aggregate — SuperAdmin only (cross-tenant profitability).
app.Get("/v1/admin/revenue", s.guard(s.revenue))
// Product analytics — org-scoped (SuperAdmin: all-orgs SaaS analytics; org admin:
// their own org's usage/active/spend).
app.Get("/v1/admin/analytics", s.guardScoped(s.analytics))
// Bases — the tenant Base-instance panel, org-scoped (bases.go).
app.Get("/v1/admin/bases", s.guardScoped(s.bases))
// ── Platform control plane — SuperAdmin ONLY (launch/release/flags + access). ──
// Flipping public_signup / waitlist_open / rollout %, and granting waitlist
// access, are PLATFORM sudo; an org admin never sees or touches them (s.guard,
// super-only, like every mutating fleet action). flags.go + waitlist.go.
app.Get("/v1/admin/flags", s.guard(s.flags))
app.Get("/v1/admin/waitlist", s.guard(s.waitlist))
app.Post("/v1/admin/waitlist/boost", s.guard(s.waitlistBoost))
logger.Info("admin surface mounted",
"prefix", "/v1/admin",
"iam", s.iam.configured(),
"commerce", s.commerce.configured(),
"digitalocean", s.do.configured(),
"adminOrg", s.adminOrg,
)
return nil
}
// guard wraps a handler with the global-admin gate. Fail-closed: any request
// whose validated identity is not a global admin (X-User-IsAdmin != "true",
// which SanitizeIdentity sets only for owner == AdminOrg) is refused 403 before
// the handler — no upstream is touched, no data leaks.
func (s *svc) guard(h func(*zip.Ctx) error) zip.Handler {
return func(c *zip.Ctx) error {
if !c.IsAdmin() {
return zip.ErrForbidden("global admin required")
}
return h(c)
}
}
// guardScoped is the gate for the ORG-SCOPED panels (me/overview/orgs/users/usage/
// analytics/bases). It admits a SuperAdmin (c.IsAdmin()) OR any VALIDATED admin caller
// pinned to an org, and the handler then scopes every read to resolveScope(c) — so a
// non-super caller passes the gate but the DATA layer hard-limits them to their own org
// subtree. Cross-tenant reads are impossible for a non-super caller regardless of input.
//
// The non-super admission requires c.User() (X-User-Id) non-empty, which SanitizeIdentity
// sets ONLY for a validated principal — so an anonymous caller who forged X-Org-Id (the
// documented Phase-1 residual restores a client X-Org-Id for the data path) is REFUSED
// here: no validated principal, no X-User-Id, no admission. A validated non-super
// principal's X-Org-Id is PINNED by the boundary to their own owner, never client-chosen.
//
// The org-ADMIN-vs-member distinction (should a non-admin org member reach the cockpit?)
// is enforced at the console BFF getAdminGate, which reads IAM's isAdmin claim; cloud
// cannot see that claim without a trusted org-admin header from SanitizeIdentity (a
// follow-up trust-boundary change). The cross-tenant boundary — the escalation line — is
// fully enforced HERE regardless, because a non-super caller can only ever read their own
// org's data.
func (s *svc) guardScoped(h func(*zip.Ctx) error) zip.Handler {
return func(c *zip.Ctx) error {
if c.IsAdmin() {
return h(c)
}
if strings.TrimSpace(c.User()) != "" && strings.TrimSpace(c.Org()) != "" {
return h(c)
}
return zip.ErrForbidden("admin required")
}
}
// callerCreds captures the caller's replayed authorization context for the IAM
// fan-out: the raw Cookie header (session model) and the Authorization bearer.
func callerCreds(c *zip.Ctx) creds {
return creds{
cookie: string(c.Fiber().Request().Header.Peek("Cookie")),
auth: c.Header("Authorization"),
}
}
// ── /v1 envelope writers ────────────────────────────────────────────────
// ok writes a { status:"ok", data } envelope (the get<T> shape).
func ok(c *zip.Ctx, data any) error {
return c.JSON(200, map[string]any{"status": "ok", "msg": "", "data": data})
}
// okList writes a { status:"ok", data:[...], data2:total } envelope (getList<T>).
func okList(c *zip.Ctx, rows any, total int) error {
return c.JSON(200, map[string]any{"status": "ok", "msg": "", "data": rows, "data2": total})
}
// okRaw writes a { status:"ok", data:<raw>, data2:total } envelope, forwarding an
// IAM payload verbatim so its exact wire shape (Role, Application, Record, User)
// reaches the operator field-for-field.
func okRaw(c *zip.Ctx, rows json.RawMessage, total int) error {
if len(rows) == 0 {
rows = json.RawMessage("[]")
}
return c.JSON(200, map[string]any{"status": "ok", "msg": "", "data": rows, "data2": total})
}
// fail writes a { status:"error", msg } envelope. The operator's transport maps
// a non-ok envelope to a surfaced error (never a fabricated value).
func fail(c *zip.Ctx, msg string) error {
return c.JSON(200, map[string]any{"status": "error", "msg": msg, "data": nil})
}
// ── /v1/admin/me — operator identity (AdminMe) ───────────────────────────────
// me answers with the validated operator identity. The gate already proved this
// is a global admin, so the fields come from the sanitized identity headers —
// authoritative and never client-forgeable.
func (s *svc) me(c *zip.Ctx) error {
sc := s.resolveScope(c)
owner := strings.TrimSpace(c.Org())
if owner == "" && sc.super {
owner = s.adminOrg
}
name := strings.TrimSpace(c.User())
// IsGlobalAdmin reflects the REAL scope: true only for a SuperAdmin (owner ==
// admin org). An org admin gets false + their own org, so the cockpit renders the
// scoped (own-subtree) view and hides the platform-sudo panels.
return ok(c, adminMe{
Owner: owner,
Name: name,
Email: strings.TrimSpace(c.UserEmail()),
DisplayName: name,
IsSuperAdmin: sc.super,
IsGlobalAdmin: sc.super, // DEPRECATED alias of isSuperAdmin; kept populated for back-compat
})
}
// ── /v1/admin/orgs — tenant directory (OrgRow[]) ─────────────────────────────
func (s *svc) orgs(c *zip.Ctx) error {
ctx := c.Context()
cr := callerCreds(c)
orgs, err := s.scopedOrgs(ctx, c, cr)
if err != nil {
return fail(c, err.Error())
}
rows := make([]orgRow, 0, len(orgs))
for _, o := range orgs {
users := s.orgUserCount(ctx, cr, o.Name)
spend, credits := s.orgMoney(ctx, o.Name)
rows = append(rows, orgRow{
Org: o.Name,
Display: display(o.DisplayName, o.Name),
Users: users,
Products: 0, // workload registry feed pending (platform apps table)
SpendCents: spend,
CreditsCents: credits,
Tokens: 0, // fleet token counters pending (insights/datastore)
Created: o.CreatedTime,
})
}
sort.Slice(rows, func(i, j int) bool { return rows[i].Org < rows[j].Org })
return okList(c, rows, len(rows))
}
// ── /v1/admin/users — cross-org directory (OperatorUser[]) ───────────────────
func (s *svc) users(c *zip.Ctx) error {
ctx := c.Context()
cr := callerCreds(c)
sc := s.resolveScope(c)
q := url.Values{}
if !sc.super {
// A scoped caller lists ONLY their own org's users — the client ?org= is
// ignored, the owner hard-pinned to the sanitized org subtree.
if len(sc.orgs) > 0 {
q.Set("owner", sc.orgs[0])
}
} else if owner := strings.TrimSpace(c.Query("org")); owner != "" {
q.Set("owner", owner)
}
if p := strings.TrimSpace(c.Query("p")); p != "" {
q.Set("p", p)
}
if ps := strings.TrimSpace(c.Query("pageSize")); ps != "" {
q.Set("pageSize", ps)
}
if term := strings.TrimSpace(c.Query("q")); term != "" {
// IAM's list uses field/value contains-matching for the free-text filter.
q.Set("field", "name")
q.Set("value", term)
}
res, err := s.iam.getList(ctx, cr, "/v1/iam/get-users", q)
if err != nil {
return fail(c, err.Error())
}
var raw []iamUser
if len(res.rows) > 0 {
if err := json.Unmarshal(res.rows, &raw); err != nil {
return fail(c, "users decode: "+err.Error())
}
}
rows := make([]operatorUser, 0, len(raw))
for _, u := range raw {
rows = append(rows, operatorUser{
Owner: u.Owner,
Name: u.Name,
Email: u.Email,
DisplayName: u.DisplayName,
IsAdmin: u.IsAdmin,
IsSuperAdmin: u.Owner == s.adminOrg,
IsGlobalAdmin: u.Owner == s.adminOrg, // back-compat alias; same fact
Tag: u.Tag,
Created: u.CreatedTime,
LastSignin: u.LastSigninTime,
Forbidden: u.IsForbidden,
})
}
total := res.total
if total < len(rows) {
total = len(rows)
}
return okList(c, rows, total)
}
// ── /v1/admin/roles and /applications — verbatim IAM passthrough ─────────────
func (s *svc) roles(c *zip.Ctx) error {
return s.iamPassthrough(c, "/v1/iam/get-roles")
}
func (s *svc) applications(c *zip.Ctx) error {
return s.iamPassthrough(c, "/v1/iam/get-applications")
}
// iamPassthrough forwards a paginated IAM read verbatim (the operator decodes
// Role / Application as the raw IAM wire shape). `owner` defaults to the admin
// org, which owns the platform applications.
func (s *svc) iamPassthrough(c *zip.Ctx, path string) error {
q := url.Values{}
owner := strings.TrimSpace(c.Query("owner"))
if owner == "" {
owner = s.adminOrg
}
q.Set("owner", owner)
if p := strings.TrimSpace(c.Query("p")); p != "" {
q.Set("p", p)
}
if ps := strings.TrimSpace(c.Query("pageSize")); ps != "" {
q.Set("pageSize", ps)
}
res, err := s.iam.getList(c.Context(), callerCreds(c), path, q)
if err != nil {
return fail(c, err.Error())
}
return okRaw(c, res.rows, res.total)
}
// ── /v1/admin/audit — records directory (AuditRow[]) ─────────────────────────
//
// The handler lives in audit.go (it reads cloud's OWN tamper-evident store).
// iamAuditQuery builds the IAM get-records query for the federated fallback
// auditFromIAM uses when no local store is configured.
func iamAuditQuery(c *zip.Ctx) url.Values {
q := url.Values{}
if org := strings.TrimSpace(c.Query("org")); org != "" {
q.Set("organizationName", org)
}
q.Set("p", "1")
ps := strings.TrimSpace(c.Query("pageSize"))
if ps == "" {
ps = "100"
}
q.Set("pageSize", ps)
q.Set("sortField", "createdTime")
q.Set("sortOrder", "descend")
return q
}
// ── /v1/admin/usage — fleet usage roll-up (UsageData) ────────────────────────
// usage returns the real fleet money totals from commerce. The daily series and
// the per-product breakdown are NOT derivable from the commerce billing API
// (they live in insights/datastore, owned separately); admin returns the
// honest empty series/byProduct rather than fabricating a trend — the operator
// renders that as an empty chart, never a fake line.
func (s *svc) usage(c *zip.Ctx) error {
ctx := c.Context()
cr := callerCreds(c)
sc := s.resolveScope(c)
org := strings.TrimSpace(c.Query("org"))
if !sc.super {
// A scoped caller reads ONLY their own org's usage — the client ?org= is
// ignored, the org hard-pinned to the sanitized subtree.
org = ""
if len(sc.orgs) > 0 {
org = sc.orgs[0]
}
}
var spend int64
switch {
case org != "":
if r, err := s.commerce.usageRollup(ctx, org, orgSubject(org)); err == nil {
spend = r.ConsumedCents
}
case sc.super:
// Fleet: sum month-to-date consumption across every org.
orgs, err := s.listOrgs(ctx, cr)
if err == nil {
for _, o := range orgs {
if r, e := s.commerce.usageRollup(ctx, o.Name, orgSubject(o.Name)); e == nil {
spend += r.ConsumedCents
}
}
}
}
return ok(c, usageData{
Totals: usageTotals{SpendCents: spend, Tokens: 0, Requests: 0},
Series: []usagePoint{},
ByProduct: []usageByProduct{},
})
}
// ── /v1/admin/products — workload registry (ProductRow[]) ────────────────────
// products is the workload/drift registry (declared vs running tag, health).
// That inventory is the platform.hanzo.ai apps table / operator reconcile state,
// NOT an in-binary source. admin exposes the gated endpoint and returns the
// real empty registry until that feed is wired — it never fabricates workload
// rows. The operator renders an empty table, not fake products.
func (s *svc) products(c *zip.Ctx) error {
return okList(c, []productRow{}, 0)
}
// ── /v1/admin/overview — Platform Overview tiles (OverviewData) ───────────────
func (s *svc) overview(c *zip.Ctx) error {
ctx := c.Context()
cr := callerCreds(c)
now := time.Now().UTC().Format(time.RFC3339)
var sources []sourceStatus
orgCount, userCount, spend, credits := 0, 0, int64(0), int64(0)
orgs, orgErr := s.scopedOrgs(ctx, c, cr)
sources = append(sources, srcOf("iam", orgErr, len(orgs), now))
if orgErr == nil {
orgCount = len(orgs)
for _, o := range orgs {
userCount += s.orgUserCount(ctx, cr, o.Name)
sp, cr2 := s.orgMoney(ctx, o.Name)
spend += sp
credits += cr2
}
}
// Commerce freshness: probe one org's rollup so the tile reflects a real read.
commerceRows := 0
var commerceErr error
if s.commerce.configured() {
probe := s.adminOrg
if len(orgs) > 0 {
probe = orgs[0].Name
}
if _, err := s.commerce.usageRollup(ctx, probe, orgSubject(probe)); err != nil {
commerceErr = err
} else {
commerceRows = 1
}
} else {
commerceErr = fmt.Errorf("commerce endpoint not configured")
}
sources = append(sources, srcOf("commerce", commerceErr, commerceRows, now))
// o11y System Health.
o11yRows := 0
oOK, oErr := s.health.ok(ctx)
if oOK {
o11yRows = 1
}
sources = append(sources, srcOf("o11y", oErr, o11yRows, now))
return ok(c, overviewData{
Orgs: orgCount,
Users: userCount,
Products: 0, // workload registry feed pending (platform apps table)
ActiveProducts: 0,
Drift: 0,
SpendCents30d: spend,
Tokens30d: 0, // fleet token counters pending (insights/datastore)
CreditsCents: credits,
LastSync: now,
Sources: sources,
})
}
// ── /v1/admin/sync — refresh trigger ─────────────────────────────────────────
// sync answers the operator's "Sync now" button. admin aggregates LIVE on
// every read (there is no cached fleet snapshot in-binary), so there is no batch
// job to kick — the button simply re-reads. We acknowledge honestly with
// { started: true } so the UI re-fetches the (freshly-computed) overview.
func (s *svc) sync(c *zip.Ctx) error {
return ok(c, map[string]bool{"started": true})
}
// ── aggregation helpers ──────────────────────────────────────────────────────
// listOrgs reads the org directory (owner = admin org) as the typed shape the
// overview/orgs/usage aggregators fold over.
func (s *svc) listOrgs(ctx context.Context, cr creds) ([]iamOrg, error) {
q := url.Values{}
q.Set("owner", s.adminOrg)
res, err := s.iam.getList(ctx, cr, "/v1/iam/get-organizations", q)
if err != nil {
return nil, err
}
var orgs []iamOrg
if len(res.rows) > 0 {
if err := json.Unmarshal(res.rows, &orgs); err != nil {
return nil, fmt.Errorf("orgs decode: %w", err)
}
}
return orgs, nil
}
// orgUserCount returns the member count for one org from the IAM list total
// (data2). Best-effort: an error yields 0 rather than failing the whole row.
func (s *svc) orgUserCount(ctx context.Context, cr creds, org string) int {
q := url.Values{}
q.Set("owner", org)
q.Set("p", "1")
q.Set("pageSize", "1")
res, err := s.iam.getList(ctx, cr, "/v1/iam/get-users", q)
if err != nil {
return 0
}
return res.total
}
// orgMoney returns (spendCents, creditsCents) for one org from commerce.
// Best-effort: unreachable/unconfigured commerce yields zeros.
func (s *svc) orgMoney(ctx context.Context, org string) (int64, int64) {
subj := orgSubject(org)
var spend, credits int64
if r, err := s.commerce.usageRollup(ctx, org, subj); err == nil {
spend = r.ConsumedCents
}
if c, err := s.commerce.creditsCents(ctx, org, subj); err == nil {
credits = c
}
return spend, credits
}
// orgSubject is the billing subject commerce keys an org's wallet on. Commerce's
// per-org billing store (the 2026-07 durability rework, commerce >=1.46.8)
// namespaces by the TRUSTED X-Org-Id header (set by commerceClient.get from this
// same org) and keys the org wallet under the BARE org slug as the `user` subject —
// NOT "org/user". The prior "org/org" subject (with the wrong X-IAM-Org-Id header)
// resolved to an EMPTY wallet, so every per-org money panel read $0 while real
// balances existed (lux $10,000, maxpower $20,498). Verified live against commerce
// /v1/billing/{balance,usage-rollup}: user=<org> + X-Org-Id=<org> returns the real
// wallet; user="org/org" or a missing/other org header returns $0.
func orgSubject(org string) string { return org }
// srcOf builds a SourceStatus freshness row for the overview.
func srcOf(name string, err error, rows int, at string) sourceStatus {
s := sourceStatus{Name: name, OK: err == nil, Rows: rows, At: at}
if err != nil {
s.Error = err.Error()
}
return s
}
func display(displayName, fallback string) string {
if strings.TrimSpace(displayName) != "" {
return displayName
}
return fallback
}
// ── config resolution ────────────────────────────────────────────────────────
// iamBase resolves the IAM management HTTP base. CLOUD_IAM_HTTP_URL wins (the
// in-cluster Service, e.g. http://iam.hanzo.svc.cluster.local:8000); otherwise
// the public issuer (deps.IAMIssuer, e.g. https://hanzo.id) which also serves
// /v1/iam/*. Empty only when neither is set (endpoint reports not-configured).
func iamBase(deps cloud.Deps) string {
if v := strings.TrimSpace(os.Getenv("CLOUD_IAM_HTTP_URL")); v != "" {
return v
}
return strings.TrimSpace(deps.IAMIssuer)
}
// o11yHealthURL resolves the o11y health probe URL for the System Health source.
// CLOUD_O11Y_HEALTH_URL wins; else the in-cluster o11y Service default.
func o11yHealthURL() string {
if v := strings.TrimSpace(os.Getenv("CLOUD_O11Y_HEALTH_URL")); v != "" {
return v
}
return "http://o11y.hanzo.svc.cluster.local:80/v1/o11y/health"
}
// adminOrgOf resolves the admin org slug (IAM's IsGlobalAdmin owner). IAM_ADMIN_ORG
// mirrors config.go's default; "admin" is the fleet-wide default.
func adminOrgOf(_ cloud.Deps) string {
if v := strings.TrimSpace(os.Getenv("IAM_ADMIN_ORG")); v != "" {
return v
}
return "admin"
}
func init() {
// Order 146: after productsvc (145); the admin surface has no ordering
// dependency (it fans out over HTTP), placed adjacent to the other console
// read facades.
cloud.Register("admin", 146, cloud.Typed(Mount))
}
+640
View File
@@ -0,0 +1,640 @@
package admin
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
fiber "github.com/gofiber/fiber/v3"
"github.com/hanzoai/cloud"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// 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)
return do
}
// mountSvc is mount but also returns the underlying svc (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), *svc, *fiber.App) {
t.Helper()
app := zip.New(zip.Config{Logger: luxlog.New("test")})
s := &svc{
iam: newIAMClient(iamURL),
commerce: newCommerceClient(commerceURL, "test-token"),
health: newHealthClient(healthURL),
do: newDOClient(""), // no token → honest not-configured unless a test overrides s.do
adminOrg: "admin",
}
// Mirror the REAL Mount (admin.go): org-scoped panels behind guardScoped, the
// platform control plane behind guard (super-only), so the harness stays
// authoritative for the two-tier gate.
app.Get("/v1/admin/me", s.guardScoped(s.me))
app.Get("/v1/admin/overview", s.guardScoped(s.overview))
app.Get("/v1/admin/orgs", s.guardScoped(s.orgs))
app.Get("/v1/admin/users", s.guardScoped(s.users))
app.Get("/v1/admin/usage", s.guardScoped(s.usage))
app.Get("/v1/admin/analytics", s.guardScoped(s.analytics))
app.Get("/v1/admin/bases", s.guardScoped(s.bases))
app.Get("/v1/admin/roles", s.guard(s.roles))
app.Get("/v1/admin/applications", s.guard(s.applications))
app.Get("/v1/admin/audit", s.guard(s.audit))
app.Get("/v1/admin/audit/verify", s.guard(s.auditVerify))
app.Get("/v1/admin/products", s.guard(s.products))
app.Get("/v1/admin/finance", s.guard(s.finance))
app.Post("/v1/admin/sync", s.guard(s.sync))
app.Get("/v1/admin/customers", s.guard(s.customers))
app.Get("/v1/admin/customers/:org", s.guard(s.customerDetail))
app.Post("/v1/admin/customers/:org/credit", s.guard(s.grantCredit))
app.Post("/v1/admin/customers/:org/suspend", s.guard(s.suspendCustomer))
app.Post("/v1/admin/customers/:org/reactivate", s.guard(s.reactivateCustomer))
app.Get("/v1/admin/revenue", s.guard(s.revenue))
app.Get("/v1/admin/flags", s.guard(s.flags))
app.Get("/v1/admin/waitlist", s.guard(s.waitlist))
app.Post("/v1/admin/waitlist/boost", s.guard(s.waitlistBoost))
fa := app.Fiber()
return func(method, path string, hdr map[string]string) (*http.Response, []byte) {
t.Helper()
req := httptest.NewRequest(method, path, nil)
for k, v := range hdr {
req.Header.Set(k, v)
}
resp, err := fa.Test(req, fiber.TestConfig{Timeout: 30 * time.Second})
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
b, _ := io.ReadAll(resp.Body)
return resp, b
}, s, fa
}
type adminRoute struct{ method, path string }
// scopedAdminRoutes are the ORG-SCOPED panels (guardScoped): a SuperAdmin OR a validated
// org admin is admitted, and the handler scopes the data. A caller with NO validated
// principal (anonymous, or an org header but no X-User-Id) is still refused.
var scopedAdminRoutes = []adminRoute{
{"GET", "/v1/admin/me"},
{"GET", "/v1/admin/overview"},
{"GET", "/v1/admin/orgs"},
{"GET", "/v1/admin/users"},
{"GET", "/v1/admin/usage"},
{"GET", "/v1/admin/analytics"},
{"GET", "/v1/admin/bases"},
}
// platformAdminRoutes are SuperAdmin ONLY (s.guard) — the cross-tenant platform reads +
// the launch/release/flags/access control plane. A non-super caller is ALWAYS 403.
var platformAdminRoutes = []adminRoute{
{"GET", "/v1/admin/roles"},
{"GET", "/v1/admin/applications"},
{"GET", "/v1/admin/audit"},
{"GET", "/v1/admin/audit/verify"},
{"GET", "/v1/admin/products"},
{"GET", "/v1/admin/finance"},
{"POST", "/v1/admin/sync"},
{"GET", "/v1/admin/customers"},
{"GET", "/v1/admin/customers/acme"},
{"POST", "/v1/admin/customers/acme/credit"},
{"POST", "/v1/admin/customers/acme/suspend"},
{"POST", "/v1/admin/customers/acme/reactivate"},
{"GET", "/v1/admin/revenue"},
{"GET", "/v1/admin/flags"},
{"GET", "/v1/admin/waitlist"},
{"POST", "/v1/admin/waitlist/boost"},
}
// adminRoutes is the full surface (both tiers) — the fail-closed gate test denies an
// unauthenticated caller on EVERY one.
var adminRoutes = append(append([]adminRoute{}, scopedAdminRoutes...), platformAdminRoutes...)
// TestGate_DeniesEveryRoute proves the non-negotiable: EVERY /v1/admin/* route is
// global-admin only, fail-closed. An anonymous caller and a tenant-admin (whose
// identity carries an org but NOT the sanitizer-minted X-User-IsAdmin) are BOTH
// denied 403 on every route — no upstream is even reached. admin mirrors the
// gateway's admin-guard: SanitizeIdentity sets X-User-IsAdmin only for a
// validated principal whose owner == AdminOrg, so a forged header never survives
// ingress and the c.IsAdmin() read here is authoritative.
func TestGate_DeniesEveryRoute(t *testing.T) {
// Upstreams point nowhere reachable; the gate must reject BEFORE any call.
do := mount(t, "http://127.0.0.1:0", "http://127.0.0.1:0", "http://127.0.0.1:0")
// NO validated principal ⇒ denied on EVERY route (platform + scoped). guardScoped
// requires a sanitized X-User-Id, which an anonymous caller lacks — and a client
// that merely forges X-Org-Id (the documented Phase-1 residual) still has no
// X-User-Id, so it is refused here and can never reach a scoped read.
noPrincipal := []struct {
name string
hdr map[string]string
}{
{"anonymous", nil},
{"forged X-Org-Id, no validated user", map[string]string{"X-Org-Id": "victim"}},
}
for _, tc := range noPrincipal {
for _, r := range adminRoutes {
resp, body := do(r.method, r.path, tc.hdr)
if resp.StatusCode != http.StatusForbidden {
t.Errorf("%s %s [%s]: got %d, want 403 (body=%s)", r.method, r.path, tc.name, resp.StatusCode, body)
}
}
}
// A VALIDATED non-super org admin (X-User-Id + pinned X-Org-Id, NO X-User-IsAdmin)
// is denied on every PLATFORM route (super-only). The org-scoped routes admit them
// but hard-scope the data — proven in scope_test.go.
orgAdmin := map[string]string{"X-Org-Id": "acme", "X-User-Id": "acme/bob", "X-User-Email": "bob@acme.test"}
for _, r := range platformAdminRoutes {
resp, body := do(r.method, r.path, orgAdmin)
if resp.StatusCode != http.StatusForbidden {
t.Errorf("%s %s [org-admin on platform route]: got %d, want 403 (body=%s)", r.method, r.path, resp.StatusCode, body)
}
}
}
// TestGate_AllowsGlobalAdmin proves the flip side: a validated global admin
// (X-User-IsAdmin=true, minted only for owner==AdminOrg) is admitted — the gate
// is not vacuously closed. Reaches /v1/admin/me, which needs no upstream.
func TestGate_AllowsGlobalAdmin(t *testing.T) {
do := mount(t, "http://127.0.0.1:0", "http://127.0.0.1:0", "http://127.0.0.1:0")
admin := map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "admin", "X-User-Id": "admin/z", "X-User-Email": "z@hanzo.ai"}
resp, body := do("GET", "/v1/admin/me", admin)
if resp.StatusCode != http.StatusOK {
t.Fatalf("global-admin GET /v1/admin/me: got %d, want 200 (body=%s)", resp.StatusCode, body)
}
var env struct {
Status string `json:"status"`
Data adminMe `json:"data"`
}
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode me envelope: %v", err)
}
if env.Status != "ok" {
t.Fatalf("me status = %q, want ok", env.Status)
}
if env.Data.Owner != "admin" || env.Data.Email != "z@hanzo.ai" || !env.Data.IsGlobalAdmin {
t.Errorf("me identity wrong: %+v", env.Data)
}
// SuperAdmin canonicalization: the new isSuperAdmin key MUST be present and
// equal to the deprecated isGlobalAdmin alias — the console may read either
// during the rename migration and must see the same truth.
if !env.Data.IsSuperAdmin {
t.Errorf("me: isSuperAdmin must be true for a global admin: %+v", env.Data)
}
if env.Data.IsSuperAdmin != env.Data.IsGlobalAdmin {
t.Errorf("me: isSuperAdmin (%v) must equal back-compat isGlobalAdmin (%v)", env.Data.IsSuperAdmin, env.Data.IsGlobalAdmin)
}
}
// fakeIAM stands in for the IAM management surface. It records whether the
// caller's credential was replayed and returns /v1 envelopes.
type fakeIAM struct {
server *httptest.Server
gotAuth string
gotCook string
}
func newFakeIAM() *fakeIAM {
f := &fakeIAM{}
f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.gotAuth = r.Header.Get("Authorization")
f.gotCook = r.Header.Get("Cookie")
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "/get-organizations"):
io.WriteString(w, `{"status":"ok","msg":"","data":[
{"owner":"admin","name":"hanzo","displayName":"Hanzo","createdTime":"2020-01-01T00:00:00Z"},
{"owner":"admin","name":"acme","displayName":"Acme Inc","createdTime":"2021-02-02T00:00:00Z"}
],"data2":2}`)
case strings.HasSuffix(r.URL.Path, "/get-users"):
// A single-page count probe (pageSize=1) still reports data2 total.
io.WriteString(w, `{"status":"ok","msg":"","data":[
{"owner":"hanzo","name":"alice","email":"alice@hanzo.ai","displayName":"Alice","tag":"staff","createdTime":"2020-03-01T00:00:00Z","lastSigninTime":"2026-06-01T00:00:00Z","isAdmin":true,"isForbidden":false}
],"data2":7}`)
case strings.HasSuffix(r.URL.Path, "/get-roles"):
io.WriteString(w, `{"status":"ok","msg":"","data":[{"owner":"admin","name":"ops","displayName":"Ops"}],"data2":1}`)
case strings.HasSuffix(r.URL.Path, "/get-applications"):
io.WriteString(w, `{"status":"ok","msg":"","data":[{"owner":"admin","name":"hanzo-cloud","clientId":"cid"}],"data2":1}`)
case strings.HasSuffix(r.URL.Path, "/get-records"):
io.WriteString(w, `{"status":"ok","msg":"","data":[{"createdTime":"2026-06-29T00:00:00Z","organization":"hanzo","user":"alice","clientIp":"1.2.3.4","method":"POST","action":"login","requestUri":"/v1/iam/login"}],"data2":1}`)
default:
w.WriteHeader(404)
io.WriteString(w, `{"status":"error","msg":"not found"}`)
}
}))
return f
}
// fakeCommerce mimics the LIVE commerce billing contract (commerce >=1.46.8, the
// 2026-07 per-org durability rework): the per-org wallet is resolved from the
// TRUSTED X-Org-Id header (set only with the service-token bearer) and keyed under
// the BARE org slug as the `user` subject. A wrong header (X-IAM-Org-Id) or a wrong
// subject ("org/org") resolves to an EMPTY wallet — so this fake is a regression
// guard for the reconciliation bug that made every admin money panel read $0 while
// real balances existed (lux $10,000, maxpower $20,498). Verified against live
// commerce /v1/billing/{balance,usage-rollup}.
type fakeCommerce struct {
server *httptest.Server
balances map[string]int64 // org slug -> availableCents (credits)
spend map[string]int64 // org slug -> consumedCents (month-to-date)
sawIAMOrgHeader bool // true if the stale X-IAM-Org-Id header was ever sent
}
func newFakeCommerce() *fakeCommerce {
f := &fakeCommerce{
balances: map[string]int64{"acme": 5000, "hanzo": 5000},
spend: map[string]int64{"acme": 1500, "hanzo": 1500},
}
f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Header.Get("X-IAM-Org-Id") != "" {
f.sawIAMOrgHeader = true
}
// Live commerce trusts ONLY X-Org-Id (with the service-token bearer) for the
// org namespace and keys the wallet under the bare org slug. Anything else
// (missing X-Org-Id, or user != org) resolves to an empty wallet.
org := r.Header.Get("X-Org-Id")
user := r.URL.Query().Get("user")
bal, spend := int64(0), int64(0)
if org != "" && user == org {
bal, spend = f.balances[org], f.spend[org]
}
switch {
case strings.HasSuffix(r.URL.Path, "/usage-rollup"):
fmt.Fprintf(w, `{"consumedCents":%d,"overageCents":0,"balance":{"balanceCents":%d,"availableCents":%d}}`, spend, bal, bal)
case strings.HasSuffix(r.URL.Path, "/balance"):
fmt.Fprintf(w, `{"user":%q,"currency":"usd","balance":%d,"holds":0,"available":%d}`, user, bal, bal)
case strings.HasSuffix(r.URL.Path, "/subscriptions"):
io.WriteString(w, `{"subscriptions":[]}`)
default:
w.WriteHeader(404)
}
}))
return f
}
// TestCommerce_ReconcilesWithXOrgIdBareSlug pins the exact live-commerce contract
// the admin money aggregation depends on: the org selector is the TRUSTED X-Org-Id
// header and the wallet subject is the BARE org slug (user=<org>) — NOT
// X-IAM-Org-Id and NOT "org/org". This is the regression guard for the $0-fleet-
// revenue bug (commerce.go had X-IAM-Org-Id; admin.go orgSubject had "org/org", so
// every real balance read $0). /v1/admin/orgs must surface acme's real $50.00.
func TestCommerce_ReconcilesWithXOrgIdBareSlug(t *testing.T) {
// orgSubject MUST be the bare slug (not "org/org").
if got := orgSubject("acme"); got != "acme" {
t.Fatalf("orgSubject(\"acme\") = %q, want \"acme\" (bare slug; \"acme/acme\" reads an empty commerce wallet)", got)
}
iam := newFakeIAM()
defer iam.server.Close()
commerce := newFakeCommerce()
defer commerce.server.Close()
do := mount(t, iam.server.URL, commerce.server.URL, "")
admin := map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "admin"}
resp, body := do("GET", "/v1/admin/orgs", admin)
if resp.StatusCode != http.StatusOK {
t.Fatalf("orgs: got %d (body=%s)", resp.StatusCode, body)
}
var env struct {
Data []orgRow `json:"data"`
}
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode: %v", err)
}
// acme (sorted first) must show its REAL money, proving the header + subject key.
var acme *orgRow
for i := range env.Data {
if env.Data[i].Org == "acme" {
acme = &env.Data[i]
}
}
if acme == nil {
t.Fatalf("acme org missing from %+v", env.Data)
}
if acme.CreditsCents != 5000 || acme.SpendCents != 1500 {
t.Errorf("acme money = credits %d / spend %d, want 5000/1500 — the money did NOT reconcile (stale X-IAM-Org-Id or org/org subject reads $0)", acme.CreditsCents, acme.SpendCents)
}
// The stale header must NEVER be sent.
if commerce.sawIAMOrgHeader {
t.Error("admin sent the stale X-IAM-Org-Id header — commerce reads X-Org-Id only")
}
}
// TestOrgs_RealAggregation drives /v1/admin/orgs against fake IAM + commerce and
// verifies the envelope, the field mapping, the per-org user count (from IAM
// data2), the money (from commerce), and that the caller's credential is
// replayed to IAM (admin never forges a service credential for the fan-out).
func TestOrgs_RealAggregation(t *testing.T) {
iam := newFakeIAM()
defer iam.server.Close()
commerce := newFakeCommerce()
defer commerce.server.Close()
do := mount(t, iam.server.URL, commerce.server.URL, "")
admin := map[string]string{
"X-User-IsAdmin": "true", "X-Org-Id": "admin",
"Authorization": "Bearer operator-jwt", "Cookie": "iam_access_token=operator-jwt",
}
resp, body := do("GET", "/v1/admin/orgs", admin)
if resp.StatusCode != http.StatusOK {
t.Fatalf("orgs: got %d, want 200 (body=%s)", resp.StatusCode, body)
}
var env struct {
Status string `json:"status"`
Data []orgRow `json:"data"`
Data2 int `json:"data2"`
}
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode: %v", err)
}
if env.Status != "ok" || env.Data2 != 2 || len(env.Data) != 2 {
t.Fatalf("orgs envelope wrong: status=%q data2=%d rows=%d", env.Status, env.Data2, len(env.Data))
}
// Rows are sorted by org name: acme, hanzo.
acme := env.Data[0]
if acme.Org != "acme" || acme.Display != "Acme Inc" {
t.Errorf("org row[0] = %+v, want acme/Acme Inc", acme)
}
if acme.Users != 7 {
t.Errorf("org acme users = %d, want 7 (IAM data2)", acme.Users)
}
if acme.SpendCents != 1500 || acme.CreditsCents != 5000 {
t.Errorf("org acme money = spend %d credits %d, want 1500/5000", acme.SpendCents, acme.CreditsCents)
}
// The operator's own credential MUST have been replayed to IAM.
if iam.gotAuth != "Bearer operator-jwt" {
t.Errorf("IAM did not receive the caller's Authorization: got %q", iam.gotAuth)
}
if !strings.Contains(iam.gotCook, "operator-jwt") {
t.Errorf("IAM did not receive the caller's Cookie: got %q", iam.gotCook)
}
}
// TestUsers_MapsIAMToOperatorUser verifies the cross-org directory mapping,
// including the derived isGlobalAdmin (owner == adminOrg) and the data2 total.
func TestUsers_MapsIAMToOperatorUser(t *testing.T) {
iam := newFakeIAM()
defer iam.server.Close()
do := mount(t, iam.server.URL, "", "")
admin := map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "admin"}
resp, body := do("GET", "/v1/admin/users?org=hanzo", admin)
if resp.StatusCode != http.StatusOK {
t.Fatalf("users: got %d (body=%s)", resp.StatusCode, body)
}
var env struct {
Data []operatorUser `json:"data"`
Data2 int `json:"data2"`
}
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode: %v", err)
}
if env.Data2 != 7 || len(env.Data) != 1 {
t.Fatalf("users total=%d rows=%d, want 7/1", env.Data2, len(env.Data))
}
u := env.Data[0]
if u.Name != "alice" || u.Email != "alice@hanzo.ai" || !u.IsAdmin || u.LastSignin == "" {
t.Errorf("user mapping wrong: %+v", u)
}
// owner "hanzo" != adminOrg "admin" → not a global admin.
if u.IsGlobalAdmin {
t.Errorf("user owner=hanzo must not be flagged global admin")
}
// SuperAdmin canonicalization: the new key mirrors the same derivation, so a
// non-admin-org user is NOT a super admin under either key, and they agree.
if u.IsSuperAdmin {
t.Errorf("user owner=hanzo must not be flagged super admin")
}
if u.IsSuperAdmin != u.IsGlobalAdmin {
t.Errorf("user: isSuperAdmin (%v) must equal back-compat isGlobalAdmin (%v)", u.IsSuperAdmin, u.IsGlobalAdmin)
}
}
// TestRolesAndApplications_PassthroughShape verifies the verbatim IAM passthrough
// keeps the exact wire fields (clientId on Application, etc.) the operator decodes.
func TestRolesAndApplications_PassthroughShape(t *testing.T) {
iam := newFakeIAM()
defer iam.server.Close()
do := mount(t, iam.server.URL, "", "")
admin := map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "admin"}
_, appsBody := do("GET", "/v1/admin/applications", admin)
var appsEnv struct {
Data []struct {
Name string `json:"name"`
ClientId string `json:"clientId"`
} `json:"data"`
Data2 int `json:"data2"`
}
if err := json.Unmarshal(appsBody, &appsEnv); err != nil {
t.Fatalf("apps decode: %v", err)
}
if len(appsEnv.Data) != 1 || appsEnv.Data[0].ClientId != "cid" {
t.Errorf("applications passthrough lost clientId: %+v", appsEnv.Data)
}
_, rolesBody := do("GET", "/v1/admin/roles", admin)
if !strings.Contains(string(rolesBody), `"ops"`) {
t.Errorf("roles passthrough missing role name: %s", rolesBody)
}
}
// TestAudit_MapsRecords verifies the audit directory returns the IAM Record wire
// shape the operator's AuditRow decodes.
func TestAudit_MapsRecords(t *testing.T) {
iam := newFakeIAM()
defer iam.server.Close()
do := mount(t, iam.server.URL, "", "")
admin := map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "admin"}
resp, body := do("GET", "/v1/admin/audit", admin)
if resp.StatusCode != http.StatusOK {
t.Fatalf("audit: got %d (body=%s)", resp.StatusCode, body)
}
var env struct {
Data []struct {
CreatedTime string `json:"createdTime"`
Organization string `json:"organization"`
RequestUri string `json:"requestUri"`
} `json:"data"`
}
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode: %v", err)
}
if len(env.Data) != 1 || env.Data[0].Organization != "hanzo" || env.Data[0].RequestUri != "/v1/iam/login" {
t.Errorf("audit record shape wrong: %+v", env.Data)
}
}
// TestOverview_RealTilesAndSources verifies the Platform Overview: real org/user
// counts + money from the upstreams, and a per-source freshness row that reports
// the honest state of each feed (iam ok, commerce ok, o11y not-configured here).
func TestOverview_RealTilesAndSources(t *testing.T) {
iam := newFakeIAM()
defer iam.server.Close()
commerce := newFakeCommerce()
defer commerce.server.Close()
do := mount(t, iam.server.URL, commerce.server.URL, "") // no o11y health → source not-ok
admin := map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "admin"}
resp, body := do("GET", "/v1/admin/overview", admin)
if resp.StatusCode != http.StatusOK {
t.Fatalf("overview: got %d (body=%s)", resp.StatusCode, body)
}
var env struct {
Data overviewData `json:"data"`
}
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode: %v", err)
}
d := env.Data
if d.Orgs != 2 {
t.Errorf("overview orgs = %d, want 2", d.Orgs)
}
// 2 orgs × 7 users each (both count probes return data2=7).
if d.Users != 14 {
t.Errorf("overview users = %d, want 14", d.Users)
}
// 2 orgs × 1500 consumed cents.
if d.SpendCents30d != 3000 {
t.Errorf("overview spend = %d, want 3000", d.SpendCents30d)
}
if d.CreditsCents != 10000 {
t.Errorf("overview credits = %d, want 10000", d.CreditsCents)
}
if d.LastSync == "" {
t.Error("overview lastSync must be set")
}
// Source freshness: iam ok, commerce ok, o11y not-ok (unconfigured).
src := map[string]sourceStatus{}
for _, s := range d.Sources {
src[s.Name] = s
}
if !src["iam"].OK || src["iam"].Rows != 2 {
t.Errorf("iam source = %+v, want ok/2 rows", src["iam"])
}
if !src["commerce"].OK {
t.Errorf("commerce source = %+v, want ok", src["commerce"])
}
if src["o11y"].OK || src["o11y"].Error == "" {
t.Errorf("o11y source must be not-ok with an error when unconfigured: %+v", src["o11y"])
}
}
// TestUsage_RealTotalsHonestEmptySeries proves the usage roll-up returns the REAL
// fleet spend from commerce but an HONEST empty series/byProduct — the timeseries
// feed lives in insights/datastore, and admin must never fabricate a trend.
func TestUsage_RealTotalsHonestEmptySeries(t *testing.T) {
iam := newFakeIAM()
defer iam.server.Close()
commerce := newFakeCommerce()
defer commerce.server.Close()
do := mount(t, iam.server.URL, commerce.server.URL, "")
admin := map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "admin"}
resp, body := do("GET", "/v1/admin/usage", admin)
if resp.StatusCode != http.StatusOK {
t.Fatalf("usage: got %d (body=%s)", resp.StatusCode, body)
}
var env struct {
Data usageData `json:"data"`
}
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode: %v", err)
}
if env.Data.Totals.SpendCents != 3000 { // 2 orgs × 1500
t.Errorf("usage total spend = %d, want 3000", env.Data.Totals.SpendCents)
}
// Honest empty — NOT nil (the JSON must be [], which the operator renders as
// an empty chart), and NEVER a fabricated point.
if env.Data.Series == nil || len(env.Data.Series) != 0 {
t.Errorf("usage series must be an empty array (no fabricated trend), got %v", env.Data.Series)
}
if env.Data.ByProduct == nil || len(env.Data.ByProduct) != 0 {
t.Errorf("usage byProduct must be an empty array, got %v", env.Data.ByProduct)
}
}
// TestProductsAndSync_HonestShapes verifies products returns the real empty
// registry (no fabricated workloads) and sync acknowledges with {started:true}.
func TestProductsAndSync_HonestShapes(t *testing.T) {
do := mount(t, "", "", "")
admin := map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "admin"}
_, pBody := do("GET", "/v1/admin/products", admin)
var pEnv struct {
Data []productRow `json:"data"`
Data2 int `json:"data2"`
}
if err := json.Unmarshal(pBody, &pEnv); err != nil {
t.Fatalf("products decode: %v", err)
}
if pEnv.Data == nil || len(pEnv.Data) != 0 || pEnv.Data2 != 0 {
t.Errorf("products must be an empty registry (no fabricated rows): %+v", pEnv)
}
_, sBody := do("POST", "/v1/admin/sync", admin)
var sEnv struct {
Status string `json:"status"`
Data map[string]bool `json:"data"`
}
if err := json.Unmarshal(sBody, &sEnv); err != nil {
t.Fatalf("sync decode: %v", err)
}
if sEnv.Status != "ok" || !sEnv.Data["started"] {
t.Errorf("sync must ack {started:true}: %+v", sEnv)
}
}
// TestIAMError_SurfacedNotFabricated proves a failing upstream yields a real
// error envelope (status:error), NOT a stubbed/zero success — the operator shows
// the error state, honoring the api.ts "nothing here fabricates data" contract.
func TestIAMError_SurfacedNotFabricated(t *testing.T) {
// IAM that always 500s.
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
io.WriteString(w, `{"status":"error","msg":"iam boom"}`)
}))
defer bad.Close()
do := mount(t, bad.URL, "", "")
admin := map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "admin"}
_, body := do("GET", "/v1/admin/orgs", admin)
var env struct {
Status string `json:"status"`
Msg string `json:"msg"`
}
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode: %v", err)
}
if env.Status != "error" || env.Msg == "" {
t.Errorf("failing IAM must surface an error envelope, got %+v", env)
}
}
// TestMount_NilGuards keeps the Mount contract honest (nil app / nil logger).
func TestMount_NilGuards(t *testing.T) {
if err := Mount(nil, cloud.Deps{Logger: luxlog.New("test")}); err == nil {
t.Error("Mount(nil app) must error")
}
app := zip.New(zip.Config{Logger: luxlog.New("test")})
if err := Mount(app, cloud.Deps{}); err == nil {
t.Error("Mount(nil logger) must error")
}
}
+741
View File
@@ -0,0 +1,741 @@
package admin
// Native SaaS business ANALYTICS (/v1/admin/analytics) — cohort retention, growth,
// churn, active-customers (DAU/WAU/MAU), revenue (MRR/ARPU) and usage over time,
// derived from REAL fleet data: IAM org `createdTime` (the signup cohort — always
// available) + the commerce transaction ledger (usage = `withdraw` rows, the true
// customer-activity signal). Global-admin only (s.guard), like every admin route.
//
// HONEST BY CONSTRUCTION. There is NO fabricated curve anywhere. Growth/cohorts
// come from real signup timestamps; retention/active/churn/usage come from real
// consumption events. A metric that cannot yet be computed (LTV needs observed
// churn; NRR needs MRR history commerce does not expose point-in-time) returns a
// null / honest-empty series — never an invented trend — and the `computed` map
// flags exactly which metrics are backed by data, so the console renders honest
// states and a reviewer can verify no number was made up.
//
// The heavy read (every org's ledger) is bounded + fanned out concurrently. Admin
// is low-QPS; at fleet scale this belongs in the insights/datastore OLAP mirror
// (same note as the usage series), but the billing ledger is the correct SOURCE
// OF TRUTH for real per-customer activity today.
import (
"context"
"sort"
"strings"
"sync"
"time"
"github.com/zap-proto/zip"
)
// ── wire shapes (operator contract) ──────────────────────────────────────────
// seriesPoint is one bucketed point (count OR cents, per the series). T is the
// bucket key (RFC3339 date / "2006-01" month).
type seriesPoint struct {
T string `json:"t"`
Value int64 `json:"value"`
}
// retentionCohort is one row of the retention triangle: a signup cohort, its size,
// and the % of it still ACTIVE at each subsequent period (values[0] = the signup
// period itself). Percentages are 0..100.
type retentionCohort struct {
Cohort string `json:"cohort"`
Size int `json:"size"`
Values []float64 `json:"values"`
}
// retentionGrid is the classic cohort-retention heatmap (cohorts × periods).
type retentionGrid struct {
Interval string `json:"interval"` // "month"
Periods int `json:"periods"`
Cohorts []retentionCohort `json:"cohorts"`
}
// analyticsData is the whole GET /v1/admin/analytics payload.
type analyticsData struct {
Range string `json:"range"`
Interval string `json:"interval"`
GeneratedAt string `json:"generatedAt"`
// Growth — from IAM createdTime (always real).
Signups []seriesPoint `json:"signups"`
CumulativeCustomers []seriesPoint `json:"cumulativeCustomers"`
TotalCustomers int `json:"totalCustomers"`
NewCustomers int `json:"newCustomers"`
GrowthRatePct float64 `json:"growthRatePct"`
// Active customers — from the usage ledger.
ActiveCustomers []seriesPoint `json:"activeCustomers"`
DAU int `json:"dau"`
WAU int `json:"wau"`
MAU int `json:"mau"`
// Retention triangle — signup cohort × active period.
Retention retentionGrid `json:"retention"`
// Churn — logo churn (count) + rate.
Churn []seriesPoint `json:"churn"`
ChurnRatePct float64 `json:"churnRatePct"`
// Revenue analytics.
MRRCents int64 `json:"mrrCents"`
Revenue []seriesPoint `json:"revenue"`
ARPUCents int64 `json:"arpuCents"`
LTVCents *int64 `json:"ltvCents"` // null until churn is observed
NRRPct *float64 `json:"nrrPct"` // null — needs MRR history
// Usage analytics.
Usage []seriesPoint `json:"usage"`
TopCustomers []analyticsSlice `json:"topCustomers"`
// Transparency: which metrics are backed by real data vs honest-empty. A
// reviewer/console reads this to know nothing was fabricated.
Computed map[string]bool `json:"computed"`
Sources []sourceStatus `json:"sources"`
}
// analyticsSlice is a labelled magnitude (top customers by usage cents).
type analyticsSlice struct {
Label string `json:"label"`
Value int64 `json:"value"`
Hint string `json:"hint,omitempty"`
}
// ── the activity model the pure math folds over ──────────────────────────────
// txnPoint is one dated usage event (a commerce `withdraw`, in cents).
type txnPoint struct {
T time.Time
Cents int64
}
// custActivity is one customer's real analytics input: when they signed up (IAM
// createdTime) and their consumption events (commerce withdraws). Deposits are
// NOT activity (a credit grant is not the customer using the product), so only
// withdraws feed active/retention/churn/usage — the honest "used it" signal.
type custActivity struct {
Org string
Display string
Created time.Time
HasCreated bool
Usage []txnPoint
SpendCents int64
}
func (ca custActivity) activeIn(bucket string, interval string) bool {
for _, p := range ca.Usage {
if bucketKeyOf(p.T, interval) == bucket {
return true
}
}
return false
}
func (ca custActivity) activeSince(cut time.Time) bool {
for _, p := range ca.Usage {
if !p.T.Before(cut) {
return true
}
}
return false
}
// ── handler ──────────────────────────────────────────────────────────────────
func (s *svc) analytics(c *zip.Ctx) error {
ctx := c.Context()
cr := callerCreds(c)
now := time.Now().UTC()
rangeStr := normalizeRange(c.Query("range"))
since, interval, _ := rangeWindow(rangeStr, now)
var sources []sourceStatus
// Scoped fan-in: a SuperAdmin gets every org (all-orgs SaaS analytics); an org
// admin gets ONLY their own subtree (their org's usage/active/spend), never
// another tenant's — the ONE tenant-scope predicate (scope.go).
orgs, err := s.scopedOrgs(ctx, c, cr)
if err != nil {
return fail(c, err.Error())
}
sources = append(sources, srcOf("iam", nil, len(orgs), now.Format(time.RFC3339)))
acts, ledgerOK := s.fleetActivity(ctx, orgs)
ledgerRows := 0
for _, a := range acts {
ledgerRows += len(a.Usage)
}
var ledgerErr error
if !ledgerOK {
ledgerErr = errPartialRevenue // partial ledger read — mark degraded
}
sources = append(sources, srcOf("commerce-ledger", ledgerErr, ledgerRows, now.Format(time.RFC3339)))
// MRR from subscriptions (point-in-time), fanned out like the money reads.
mrr := s.fleetMRR(ctx, orgs)
data := computeAnalytics(analyticsInput{
acts: acts,
mrrCents: mrr,
now: now,
since: since,
interval: interval,
rangeStr: rangeStr,
ledgerOK: ledgerOK && ledgerRows > 0,
})
data.GeneratedAt = now.Format(time.RFC3339)
data.Sources = sources
return ok(c, data)
}
// analyticsInput is everything computeAnalytics needs — no I/O, so the whole SaaS
// analytics derivation is unit-testable without a network.
type analyticsInput struct {
acts []custActivity
mrrCents int64
now time.Time
since time.Time
interval string
rangeStr string
ledgerOK bool // the ledger read yielded real usage rows
}
// computeAnalytics is the PURE derivation of every analytics metric from the real
// activity model. Growth is always computed (signup timestamps); the ledger-backed
// metrics compute from real usage when present and degrade to honest empty/zero
// when the fleet has no usage yet — never a fabricated curve. `computed` flags each.
func computeAnalytics(in analyticsInput) analyticsData {
buckets := enumerateBuckets(in.since, in.now, in.interval)
// ── Growth (IAM createdTime — always real) ──
signups := make([]seriesPoint, len(buckets))
newCount := 0
total := 0
for i, b := range buckets {
signups[i] = seriesPoint{T: b}
}
idx := indexOf(buckets)
for _, a := range in.acts {
if !a.HasCreated {
continue
}
total++
if !a.Created.Before(in.since) {
newCount++
}
if i, ok := idx[bucketKeyOf(a.Created, in.interval)]; ok {
signups[i].Value++
}
}
// Cumulative customers across the SAME buckets (all-time count at each bucket end).
cumulative := make([]seriesPoint, len(buckets))
for i, b := range buckets {
end := bucketEnd(b, in.interval)
n := 0
for _, a := range in.acts {
if a.HasCreated && !a.Created.After(end) {
n++
}
}
cumulative[i] = seriesPoint{T: b, Value: int64(n)}
}
growthRate := priorWindowGrowth(in.acts, in.since, in.now)
// ── Active customers + usage (ledger-backed) ──
usage := spendSeries(in.acts, in.since, in.now, in.interval)
active := make([]seriesPoint, len(buckets))
for i, b := range buckets {
active[i] = seriesPoint{T: b}
}
for _, a := range in.acts {
// active in a bucket = at least one usage event in it
seen := map[string]bool{}
for _, p := range a.Usage {
if p.T.Before(in.since) || p.T.After(in.now) {
continue
}
seen[bucketKeyOf(p.T, in.interval)] = true
}
for b := range seen {
if i, ok := idx[b]; ok {
active[i].Value++
}
}
}
dau := activeWithin(in.acts, in.now.AddDate(0, 0, -1))
wau := activeWithin(in.acts, in.now.AddDate(0, 0, -7))
mau := activeWithin(in.acts, in.now.AddDate(0, 0, -30))
// ── Retention triangle (monthly cohorts × active month) ──
retention := computeRetention(in.acts, in.now, 12)
// ── Churn (monthly logo churn from active months) + rate ──
churn, churnRate := computeChurn(in.acts, in.now, 6)
// ── Revenue analytics ──
var totalSpend int64
for _, a := range in.acts {
totalSpend += a.SpendCents
}
arpu := int64(0)
if mau > 0 {
arpu = totalSpend / int64(mau)
} else if total > 0 {
arpu = totalSpend / int64(total)
}
var ltv *int64
if churnRate > 0 && arpu > 0 {
// LTV ≈ ARPU / monthly churn rate — computed ONLY when real churn is
// observed, else honest null (LTV needs churn to mean anything).
v := int64(float64(arpu) / (churnRate / 100.0))
ltv = &v
}
// ── Top customers by usage ──
top := topCustomersByUsage(in.acts, 10)
// Revenue series = realized usage revenue per bucket (same as usage cents for a
// pay-as-you-go fleet; distinct field so the console can theme it as revenue).
revenue := make([]seriesPoint, len(usage))
copy(revenue, usage)
return analyticsData{
Range: in.rangeStr,
Interval: in.interval,
Signups: signups,
CumulativeCustomers: cumulative,
TotalCustomers: total,
NewCustomers: newCount,
GrowthRatePct: growthRate,
ActiveCustomers: active,
DAU: dau,
WAU: wau,
MAU: mau,
Retention: retention,
Churn: churn,
ChurnRatePct: churnRate,
MRRCents: in.mrrCents,
Revenue: revenue,
ARPUCents: arpu,
LTVCents: ltv,
NRRPct: nil, // honest null — needs MRR history commerce doesn't expose
Usage: usage,
TopCustomers: top,
Computed: map[string]bool{
"growth": true, // signup timestamps are always present
"retention": in.ledgerOK,
"active": in.ledgerOK,
"churn": in.ledgerOK,
"usage": in.ledgerOK,
"revenue": in.ledgerOK,
"mrr": true,
"arpu": in.ledgerOK,
"ltv": ltv != nil,
"nrr": false,
},
}
}
// computeRetention builds the cohort × period retention triangle from real signup
// months and usage months. retention[c][k] = fraction of cohort c ACTIVE in month
// c+k. Cohorts are capped to the last `maxCohorts` months (the classic triangle);
// a cohort with no signups is omitted. Values are 0..100.
func computeRetention(acts []custActivity, now time.Time, maxCohorts int) retentionGrid {
// Group customers by signup month.
byCohort := map[string][]custActivity{}
for _, a := range acts {
if !a.HasCreated {
continue
}
k := monthKey(a.Created)
byCohort[k] = append(byCohort[k], a)
}
// Sorted cohort months, newest last, capped.
cohorts := make([]string, 0, len(byCohort))
for k := range byCohort {
cohorts = append(cohorts, k)
}
sort.Strings(cohorts)
if len(cohorts) > maxCohorts {
cohorts = cohorts[len(cohorts)-maxCohorts:]
}
nowMonth := monthKey(now)
grid := retentionGrid{Interval: "month"}
maxPeriods := 0
for _, cohort := range cohorts {
members := byCohort[cohort]
periods := monthsBetween(cohort, nowMonth) + 1
if periods < 1 {
periods = 1
}
row := retentionCohort{Cohort: cohort, Size: len(members), Values: make([]float64, periods)}
for k := 0; k < periods; k++ {
month := addMonths(cohort, k)
activeN := 0
for _, m := range members {
if m.activeIn(month, "month") {
activeN++
}
}
if len(members) > 0 {
row.Values[k] = pct(activeN, len(members))
}
}
if periods > maxPeriods {
maxPeriods = periods
}
grid.Cohorts = append(grid.Cohorts, row)
}
grid.Periods = maxPeriods
return grid
}
// computeChurn derives monthly LOGO churn: a customer counts as churned in month M
// if they were active in M-1 but NOT in M. The rate is the average monthly churn
// over the observed window (churned / active-at-start). Returns honest zeros when
// there is no usage history.
func computeChurn(acts []custActivity, now time.Time, months int) ([]seriesPoint, float64) {
// Build the last `months` month keys ending at now.
keys := lastMonths(now, months)
series := make([]seriesPoint, len(keys))
var churnedTotal, baseTotal int
for i, m := range keys {
series[i] = seriesPoint{T: m}
if i == 0 {
continue // no prior month to compare
}
prev := keys[i-1]
churned := 0
base := 0
for _, a := range acts {
wasActive := a.activeIn(prev, "month")
if wasActive {
base++
if !a.activeIn(m, "month") {
churned++
}
}
}
series[i].Value = int64(churned)
churnedTotal += churned
baseTotal += base
}
rate := 0.0
if baseTotal > 0 {
rate = pct(churnedTotal, baseTotal)
}
return series, rate
}
// spendSeries buckets fleet usage cents into a continuous series over since..now.
// Shared by the analytics usage/revenue trend and the revenue board's spend trend
// (one implementation, DRY). A bucket with no usage is an honest 0, not a gap.
func spendSeries(acts []custActivity, since, now time.Time, interval string) []seriesPoint {
buckets := enumerateBuckets(since, now, interval)
idx := indexOf(buckets)
out := make([]seriesPoint, len(buckets))
for i, b := range buckets {
out[i] = seriesPoint{T: b}
}
for _, a := range acts {
for _, p := range a.Usage {
if p.T.Before(since) || p.T.After(now) {
continue
}
if i, ok := idx[bucketKeyOf(p.T, interval)]; ok {
out[i].Value += p.Cents
}
}
}
return out
}
// topCustomersByUsage returns the top-N customers by total usage cents (desc).
func topCustomersByUsage(acts []custActivity, n int) []analyticsSlice {
rows := make([]analyticsSlice, 0, len(acts))
for _, a := range acts {
if a.SpendCents <= 0 {
continue
}
rows = append(rows, analyticsSlice{Label: a.Display, Value: a.SpendCents, Hint: a.Org})
}
sort.Slice(rows, func(i, j int) bool { return rows[i].Value > rows[j].Value })
if len(rows) > n {
rows = rows[:n]
}
return rows
}
// priorWindowGrowth is the signup growth vs the immediately-preceding window: the
// % change in new signups this window vs last. 0 when the prior window had none.
func priorWindowGrowth(acts []custActivity, since, now time.Time) float64 {
window := now.Sub(since)
priorStart := since.Add(-window)
cur, prev := 0, 0
for _, a := range acts {
if !a.HasCreated {
continue
}
if !a.Created.Before(since) && a.Created.Before(now) {
cur++
} else if !a.Created.Before(priorStart) && a.Created.Before(since) {
prev++
}
}
if prev == 0 {
return 0
}
return (float64(cur-prev) / float64(prev)) * 100
}
// activeWithin counts customers with at least one usage event since `cut`.
func activeWithin(acts []custActivity, cut time.Time) int {
n := 0
for _, a := range acts {
if a.activeSince(cut) {
n++
}
}
return n
}
// ── fleet readers (I/O; concurrent, bounded) ─────────────────────────────────
// fleetActivity reads every org's signup time (already on the org row) + usage
// ledger, folded into the pure activity model. Returns (acts, ok) where ok is
// false if ANY org's ledger read failed (the caller marks the source degraded and
// flags the ledger-backed metrics as not-fully-computed). Fanned out concurrently
// with a bound, like the customer list.
func (s *svc) fleetActivity(ctx context.Context, orgs []iamOrg) ([]custActivity, bool) {
acts := make([]custActivity, len(orgs))
oks := make([]bool, len(orgs))
sem := make(chan struct{}, maxCustomerConcurrency)
var wg sync.WaitGroup
for i, o := range orgs {
wg.Add(1)
sem <- struct{}{}
go func(i int, o iamOrg) {
defer wg.Done()
defer func() { <-sem }()
ca := custActivity{Org: o.Name, Display: display(o.DisplayName, o.Name)}
if t, err := time.Parse(time.RFC3339, o.CreatedTime); err == nil {
ca.Created = t.UTC()
ca.HasCreated = true
}
rows, err := s.commerce.transactions(ctx, o.Name, orgSubject(o.Name), 2000)
oks[i] = err == nil
for _, r := range rows {
if strings.ToLower(r.Type) != "withdraw" {
continue // only consumption is "activity"; deposits are credits
}
t, perr := parseTxnTime(r.CreatedAt)
if perr != nil {
continue
}
amt := r.Amount
if amt < 0 {
amt = -amt
}
ca.Usage = append(ca.Usage, txnPoint{T: t, Cents: amt})
ca.SpendCents += amt
}
acts[i] = ca
}(i, o)
}
wg.Wait()
allOK := true
for _, ok := range oks {
if !ok {
allOK = false
break
}
}
return acts, allOK
}
// fleetMRR sums each org's active-subscription MRR concurrently.
func (s *svc) fleetMRR(ctx context.Context, orgs []iamOrg) int64 {
vals := make([]int64, len(orgs))
sem := make(chan struct{}, maxCustomerConcurrency)
var wg sync.WaitGroup
for i, o := range orgs {
wg.Add(1)
sem <- struct{}{}
go func(i int, o iamOrg) {
defer wg.Done()
defer func() { <-sem }()
if sum, err := s.commerce.subscriptionSummary(ctx, o.Name, orgSubject(o.Name)); err == nil {
vals[i] = sum.MRR
}
}(i, o)
}
wg.Wait()
var total int64
for _, v := range vals {
total += v
}
return total
}
// ── pure time-bucket helpers ─────────────────────────────────────────────────
func monthKey(t time.Time) string { return t.UTC().Format("2006-01") }
func dayKey(t time.Time) string { return t.UTC().Format("2006-01-02") }
// weekKey buckets to the ISO week's Monday (a stable weekly key).
func weekKey(t time.Time) string {
u := t.UTC()
// back up to Monday
wd := int(u.Weekday())
if wd == 0 {
wd = 7
}
monday := u.AddDate(0, 0, -(wd - 1))
return monday.Format("2006-01-02")
}
func bucketKeyOf(t time.Time, interval string) string {
switch interval {
case "month":
return monthKey(t)
case "week":
return weekKey(t)
default:
return dayKey(t)
}
}
// bucketEnd returns the inclusive end instant of a bucket key (for the cumulative
// count). A day/week/month key advances one unit; the end is one nanosecond before.
func bucketEnd(key, interval string) time.Time {
switch interval {
case "month":
if t, err := time.Parse("2006-01", key); err == nil {
return t.AddDate(0, 1, 0).Add(-time.Nanosecond)
}
case "week":
if t, err := time.Parse("2006-01-02", key); err == nil {
return t.AddDate(0, 0, 7).Add(-time.Nanosecond)
}
default:
if t, err := time.Parse("2006-01-02", key); err == nil {
return t.AddDate(0, 0, 1).Add(-time.Nanosecond)
}
}
return time.Now().UTC()
}
// enumerateBuckets lists every bucket key from since..now inclusive so a series has
// a continuous axis (a zero-usage bucket is an honest 0, not a gap).
func enumerateBuckets(since, now time.Time, interval string) []string {
if since.After(now) {
return nil
}
var out []string
seen := map[string]bool{}
step := func(t time.Time) time.Time {
switch interval {
case "month":
return t.AddDate(0, 1, 0)
case "week":
return t.AddDate(0, 0, 7)
default:
return t.AddDate(0, 0, 1)
}
}
// cap iterations so a bad range can never spin unbounded
for t, n := since, 0; !t.After(now) && n < 800; t, n = step(t), n+1 {
k := bucketKeyOf(t, interval)
if !seen[k] {
seen[k] = true
out = append(out, k)
}
}
// ensure the final bucket (now) is present
last := bucketKeyOf(now, interval)
if !seen[last] {
out = append(out, last)
}
return out
}
func indexOf(buckets []string) map[string]int {
m := make(map[string]int, len(buckets))
for i, b := range buckets {
m[b] = i
}
return m
}
// addMonths adds k months to a "2006-01" key.
func addMonths(month string, k int) string {
t, err := time.Parse("2006-01", month)
if err != nil {
return month
}
return t.AddDate(0, k, 0).Format("2006-01")
}
// monthsBetween returns the whole-month distance from a..b ("2006-01" keys).
func monthsBetween(a, b string) int {
ta, ea := time.Parse("2006-01", a)
tb, eb := time.Parse("2006-01", b)
if ea != nil || eb != nil {
return 0
}
return int(tb.Year()-ta.Year())*12 + int(tb.Month()-ta.Month())
}
// lastMonths returns the last n month keys ending at `now` (oldest first).
func lastMonths(now time.Time, n int) []string {
out := make([]string, 0, n)
for i := n - 1; i >= 0; i-- {
out = append(out, monthKey(now.AddDate(0, -i, 0)))
}
return out
}
func pct(part, whole int) float64 {
if whole <= 0 {
return 0
}
return (float64(part) / float64(whole)) * 100
}
// parseTxnTime accepts the commerce ledger's RFC3339 forms.
func parseTxnTime(s string) (time.Time, error) {
s = strings.TrimSpace(s)
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t.UTC(), nil
}
return time.Parse("2006-01-02T15:04:05Z", s)
}
// normalizeRange clamps the range param to the supported set (default 30d).
func normalizeRange(r string) string {
switch strings.TrimSpace(r) {
case "7d", "30d", "90d", "all":
return strings.TrimSpace(r)
default:
return "30d"
}
}
// rangeWindow maps a range to (since, interval, approxBuckets).
func rangeWindow(rangeStr string, now time.Time) (time.Time, string, int) {
switch rangeStr {
case "7d":
return now.AddDate(0, 0, -7), "day", 7
case "90d":
return now.AddDate(0, 0, -90), "week", 13
case "all":
return now.AddDate(-2, 0, 0), "month", 24
default: // 30d
return now.AddDate(0, 0, -30), "day", 30
}
}
+259
View File
@@ -0,0 +1,259 @@
package admin
import (
"math"
"testing"
"time"
)
// mkTime is a test helper for an RFC3339-ish instant.
func mkTime(s string) time.Time {
t, err := time.Parse("2006-01-02", s)
if err != nil {
panic(err)
}
return t.UTC()
}
// fleetFixture builds a deterministic 3-customer fleet with known signup cohorts
// and usage events, so every analytics metric has a hand-computable expectation.
//
// alpha: signup 2024-05-10; usage 2024-05-20 (100c), 2024-06-05 (200c) [cohort 05, active 05+06]
// beta : signup 2024-05-25; usage 2024-05-28 (50c) [cohort 05, active 05 only]
// gamma: signup 2024-06-15; usage 2024-07-01 (400c) [cohort 06, active 07 only]
func fleetFixture() []custActivity {
return []custActivity{
{Org: "alpha", Display: "Alpha", Created: mkTime("2024-05-10"), HasCreated: true,
Usage: []txnPoint{{T: mkTime("2024-05-20"), Cents: 100}, {T: mkTime("2024-06-05"), Cents: 200}}, SpendCents: 300},
{Org: "beta", Display: "Beta", Created: mkTime("2024-05-25"), HasCreated: true,
Usage: []txnPoint{{T: mkTime("2024-05-28"), Cents: 50}}, SpendCents: 50},
{Org: "gamma", Display: "Gamma", Created: mkTime("2024-06-15"), HasCreated: true,
Usage: []txnPoint{{T: mkTime("2024-07-01"), Cents: 400}}, SpendCents: 400},
}
}
// TestComputeRetention_RealCohortTriangle is the headline: the cohort-retention
// heatmap is REAL math over signup cohorts × active months — never a fabricated
// curve. Every cell is hand-verified against the fixture.
func TestComputeRetention_RealCohortTriangle(t *testing.T) {
now := mkTime("2024-07-15")
grid := computeRetention(fleetFixture(), now, 12)
if grid.Interval != "month" {
t.Fatalf("retention interval = %q, want month", grid.Interval)
}
byCohort := map[string]retentionCohort{}
for _, c := range grid.Cohorts {
byCohort[c.Cohort] = c
}
// Cohort 2024-05 (alpha, beta): period0=100% (both active in 05),
// period1=50% (only alpha active in 06), period2=0% (neither active in 07).
c05, ok := byCohort["2024-05"]
if !ok {
t.Fatalf("missing cohort 2024-05 in %+v", grid.Cohorts)
}
if c05.Size != 2 {
t.Errorf("cohort 2024-05 size = %d, want 2", c05.Size)
}
wantC05 := []float64{100, 50, 0}
if len(c05.Values) != len(wantC05) {
t.Fatalf("cohort 2024-05 periods = %d, want %d (%v)", len(c05.Values), len(wantC05), c05.Values)
}
for k, want := range wantC05 {
if math.Abs(c05.Values[k]-want) > 0.01 {
t.Errorf("retention[2024-05][%d] = %.1f, want %.1f", k, c05.Values[k], want)
}
}
// Cohort 2024-06 (gamma): period0=0% (no usage in 06), period1=100% (active in 07).
c06 := byCohort["2024-06"]
wantC06 := []float64{0, 100}
if len(c06.Values) != len(wantC06) {
t.Fatalf("cohort 2024-06 periods = %d, want %d (%v)", len(c06.Values), len(wantC06), c06.Values)
}
for k, want := range wantC06 {
if math.Abs(c06.Values[k]-want) > 0.01 {
t.Errorf("retention[2024-06][%d] = %.1f, want %.1f", k, c06.Values[k], want)
}
}
}
// TestComputeChurn_RealLogoChurn proves monthly logo churn + rate are real.
//
// 06 vs 05: base {alpha,beta}=2, churned {beta}=1
// 07 vs 06: base {alpha}=1, churned {alpha}=1
// rate = churned(2) / base(3) = 66.67%
func TestComputeChurn_RealLogoChurn(t *testing.T) {
now := mkTime("2024-07-15")
series, rate := computeChurn(fleetFixture(), now, 6)
got := map[string]int64{}
for _, p := range series {
got[p.T] = p.Value
}
if got["2024-06"] != 1 {
t.Errorf("churn[2024-06] = %d, want 1 (beta churned)", got["2024-06"])
}
if got["2024-07"] != 1 {
t.Errorf("churn[2024-07] = %d, want 1 (alpha churned)", got["2024-07"])
}
if math.Abs(rate-66.666) > 0.1 {
t.Errorf("churn rate = %.2f, want ~66.67", rate)
}
}
// TestComputeAnalytics_RealFleet drives the whole pure derivation and asserts the
// growth, active-customer, ARPU, top-customer, LTV, and computed-flag outputs.
func TestComputeAnalytics_RealFleet(t *testing.T) {
now := mkTime("2024-07-15")
since, interval, _ := rangeWindow("30d", now) // since 2024-06-15, daily
d := computeAnalytics(analyticsInput{
acts: fleetFixture(), mrrCents: 0, now: now, since: since, interval: interval, rangeStr: "30d", ledgerOK: true,
})
if d.TotalCustomers != 3 {
t.Errorf("total customers = %d, want 3", d.TotalCustomers)
}
// New in the 30d window [06-15, 07-15]: gamma only (alpha/beta signed up in May).
if d.NewCustomers != 1 {
t.Errorf("new customers = %d, want 1 (gamma)", d.NewCustomers)
}
// MAU (30d): only gamma had usage in-window (07-01). DAU/WAU: none in last 1/7d.
if d.MAU != 1 {
t.Errorf("MAU = %d, want 1", d.MAU)
}
if d.DAU != 0 || d.WAU != 0 {
t.Errorf("DAU/WAU = %d/%d, want 0/0", d.DAU, d.WAU)
}
// ARPU = totalSpend(750) / MAU(1) = 750.
if d.ARPUCents != 750 {
t.Errorf("ARPU = %d, want 750", d.ARPUCents)
}
// Top customer by usage = gamma (400c) first.
if len(d.TopCustomers) != 3 || d.TopCustomers[0].Hint != "gamma" || d.TopCustomers[0].Value != 400 {
t.Errorf("top customers wrong: %+v", d.TopCustomers)
}
// LTV computed only because churn is observed (>0).
if d.LTVCents == nil {
t.Error("LTV must be computed when churn is observed")
}
// NRR is honest-null (no MRR history).
if d.NRRPct != nil {
t.Error("NRR must be honest-null (needs MRR history)")
}
// Computed transparency flags.
if !d.Computed["growth"] || !d.Computed["retention"] || !d.Computed["active"] || !d.Computed["churn"] {
t.Errorf("computed flags wrong for a real-ledger fleet: %+v", d.Computed)
}
if d.Computed["nrr"] {
t.Error("nrr computed flag must be false")
}
// The usage series is over a continuous daily axis (honest zeros, not gaps).
if len(d.Usage) == 0 {
t.Error("usage series must have buckets")
}
var usageTotal int64
for _, p := range d.Usage {
usageTotal += p.Value
}
if usageTotal != 400 { // only gamma's 07-01 400c falls in the 30d window
t.Errorf("in-window usage total = %d, want 400", usageTotal)
}
}
// TestComputeAnalytics_HonestEmptyNoLedger proves the no-fabrication contract: with
// real signups but NO usage ledger, growth is still computed but retention/active/
// churn/usage are honest-empty and flagged not-computed — NEVER an invented curve.
func TestComputeAnalytics_HonestEmptyNoLedger(t *testing.T) {
now := mkTime("2024-07-15")
// Same signups, but strip all usage (ledger empty / unreachable).
acts := fleetFixture()
for i := range acts {
acts[i].Usage = nil
acts[i].SpendCents = 0
}
since, interval, _ := rangeWindow("30d", now)
d := computeAnalytics(analyticsInput{acts: acts, now: now, since: since, interval: interval, rangeStr: "30d", ledgerOK: false})
// Growth is real regardless of the ledger.
if d.TotalCustomers != 3 || !d.Computed["growth"] {
t.Errorf("growth must still be computed from signups: total=%d computed=%v", d.TotalCustomers, d.Computed["growth"])
}
// Ledger-backed metrics are flagged NOT computed.
for _, k := range []string{"retention", "active", "churn", "usage", "revenue"} {
if d.Computed[k] {
t.Errorf("computed[%q] must be false with no ledger", k)
}
}
// And the actual values are honest zero — no fabricated activity.
if d.MAU != 0 || d.WAU != 0 || d.DAU != 0 {
t.Errorf("active must be 0 with no usage: dau=%d wau=%d mau=%d", d.DAU, d.WAU, d.MAU)
}
var usageTotal int64
for _, p := range d.Usage {
usageTotal += p.Value
}
if usageTotal != 0 {
t.Errorf("usage must be all-zero with no ledger, got %d", usageTotal)
}
if d.LTVCents != nil {
t.Error("LTV must be null with no churn observed")
}
// Retention cohorts still exist (from signups) but every cell is 0% (no activity).
for _, c := range d.Retention.Cohorts {
for k, v := range c.Values {
if v != 0 {
t.Errorf("retention[%s][%d] = %.1f, want 0 (no usage → no fabricated retention)", c.Cohort, k, v)
}
}
}
}
// TestSpendSeries_ContinuousHonestBuckets proves the shared spend series buckets
// real usage onto a continuous axis with honest zeros.
func TestSpendSeries_ContinuousHonestBuckets(t *testing.T) {
now := mkTime("2024-07-05")
since := mkTime("2024-07-01")
acts := []custActivity{
{Usage: []txnPoint{{T: mkTime("2024-07-01"), Cents: 100}, {T: mkTime("2024-07-03"), Cents: 300}}},
}
series := spendSeries(acts, since, now, "day")
// 5 daily buckets 07-01..07-05.
if len(series) != 5 {
t.Fatalf("series buckets = %d, want 5 (%+v)", len(series), series)
}
got := map[string]int64{}
for _, p := range series {
got[p.T] = p.Value
}
if got["2024-07-01"] != 100 || got["2024-07-03"] != 300 {
t.Errorf("spend buckets wrong: %+v", got)
}
// 07-02, 07-04, 07-05 are honest zeros (present, not missing).
if got["2024-07-02"] != 0 || got["2024-07-04"] != 0 {
t.Errorf("empty days must be honest 0, got %+v", got)
}
}
// TestBucketHelpers pins the month arithmetic the retention triangle relies on.
func TestBucketHelpers(t *testing.T) {
if addMonths("2024-05", 2) != "2024-07" {
t.Errorf("addMonths(2024-05,2) = %q, want 2024-07", addMonths("2024-05", 2))
}
if addMonths("2024-11", 3) != "2025-02" {
t.Errorf("addMonths(2024-11,3) = %q, want 2025-02", addMonths("2024-11", 3))
}
if monthsBetween("2024-05", "2024-07") != 2 {
t.Errorf("monthsBetween(05,07) = %d, want 2", monthsBetween("2024-05", "2024-07"))
}
if monthsBetween("2024-11", "2025-02") != 3 {
t.Errorf("monthsBetween cross-year = %d, want 3", monthsBetween("2024-11", "2025-02"))
}
if normalizeRange("bogus") != "30d" {
t.Errorf("normalizeRange must default to 30d")
}
if normalizeRange("90d") != "90d" {
t.Errorf("normalizeRange must keep 90d")
}
}
+130
View File
@@ -0,0 +1,130 @@
package admin
// The /v1/admin/audit query surface, wired to cloud's REAL tamper-evident audit
// store (the audit.Recorder Serve builds and hands over via deps.Audit).
//
// This REPLACES the previous behavior — proxying IAM get-records — as the primary
// source: cloud now keeps its OWN append-only, hash-chained trail of every
// security-relevant request against this binary, and that is what a compliance
// auditor queries here. IAM's own login/session records remain available in IAM;
// they are a DIFFERENT trail (IAM's request surface), and admin still federates
// them as a fallback when cloud's local store is not configured, so no capability
// is lost.
//
// SECURITY. Both handlers are registered behind the SAME s.guard as every other
// /v1/admin/* route (global-admin only, fail-closed). They are READ-ONLY (Query
// and Verify issue SELECT only), so exposing them cannot weaken the append-only
// property. The verify endpoint returns integrity STATUS, never a way to mutate.
import (
"strconv"
"strings"
"time"
"github.com/hanzoai/cloud/audit"
"github.com/zap-proto/zip"
)
// audit answers GET /v1/admin/audit from cloud's local tamper-evident store when
// configured, else falls back to the IAM get-records proxy (federated view).
// Filters: org, sub, action, resource, result, since, until, pageSize, p (page).
// The response is the /v1 list envelope { data:[rows], data2:total } the
// operator decodes, with the current chain integrity summary attached.
func (s *svc) audit(c *zip.Ctx) error {
// No local store configured → preserve the legacy federated IAM view so the
// endpoint never regresses to empty.
if s.auditStore == nil {
return s.auditFromIAM(c)
}
f := auditFilterFromQuery(c)
rows, total, err := s.auditStore.Query(c.Context(), f)
if err != nil {
return fail(c, err.Error())
}
out := make([]audit.Wire, 0, len(rows))
for _, r := range rows {
out = append(out, r.ToWire())
}
// Attach the live integrity summary so the console can badge the trail as
// verified. Best-effort: a verify error must not fail the listing.
integrity, ivErr := s.auditStore.Verify(c.Context())
var integrityPayload any
if ivErr == nil {
integrityPayload = integrity
}
return c.JSON(200, map[string]any{
"status": "ok",
"msg": "",
"data": out,
"data2": total,
"integrity": integrityPayload,
})
}
// auditVerify answers GET /v1/admin/audit/verify — the tamper-evidence check. It
// walks the whole hash chain and returns the integrity result (ok, count, head,
// and the seq where the chain first breaks if tampered). Global-admin gated like
// every admin route.
func (s *svc) auditVerify(c *zip.Ctx) error {
if s.auditStore == nil {
return fail(c, "audit store not configured")
}
integrity, err := s.auditStore.Verify(c.Context())
if err != nil {
return fail(c, err.Error())
}
return ok(c, integrity)
}
// auditFilterFromQuery builds an audit.Filter from the request query params. Time
// bounds accept RFC3339. pageSize (default 100, cap 1000) and p (1-based page)
// drive Limit/Offset. Unknown/blank params are simply not applied.
func auditFilterFromQuery(c *zip.Ctx) audit.Filter {
f := audit.Filter{
Org: strings.TrimSpace(c.Query("org")),
Sub: strings.TrimSpace(c.Query("sub")),
Action: strings.TrimSpace(c.Query("action")),
Resource: strings.TrimSpace(c.Query("resource")),
ResourceID: strings.TrimSpace(c.Query("resourceId")),
Result: strings.TrimSpace(c.Query("result")),
}
if v := strings.TrimSpace(c.Query("since")); v != "" {
if t, err := time.Parse(time.RFC3339, v); err == nil {
f.Since = t
}
}
if v := strings.TrimSpace(c.Query("until")); v != "" {
if t, err := time.Parse(time.RFC3339, v); err == nil {
f.Until = t
}
}
pageSize := 100
if v := strings.TrimSpace(c.Query("pageSize")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
pageSize = n
}
}
f.Limit = pageSize
if v := strings.TrimSpace(c.Query("p")); v != "" {
if page, err := strconv.Atoi(v); err == nil && page > 1 {
f.Offset = (page - 1) * pageSize
}
}
return f
}
// auditFromIAM is the legacy federated view: when cloud has no local audit store,
// forward the IAM get-records read verbatim (the prior behavior), so the endpoint
// still surfaces IAM's own audit trail rather than an empty list.
func (s *svc) auditFromIAM(c *zip.Ctx) error {
q := iamAuditQuery(c)
res, err := s.iam.getList(c.Context(), callerCreds(c), "/v1/iam/get-records", q)
if err != nil {
return fail(c, err.Error())
}
return okRaw(c, res.rows, res.total)
}
+238
View File
@@ -0,0 +1,238 @@
package admin
// Tests for the store-backed /v1/admin/audit + /v1/admin/audit/verify surface.
// They wire admin against a REAL audit.Recorder (on-disk SQLite) seeded with
// records, drive requests through the whole zip app, and assert the query
// results, the integrity summary, and the global-admin gate.
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
fiber "github.com/gofiber/fiber/v3"
"github.com/hanzoai/cloud/audit"
"github.com/zap-proto/zip"
luxlog "github.com/luxfi/log"
)
// mountWithStore builds a zip app with admin's audit routes wired to a real audit
// store, and returns the store + a request helper. Only the audit routes are
// mounted here (the rest are covered by mount()); this keeps the store-backed
// tests focused.
func mountWithStore(t *testing.T) (*audit.Recorder, func(method, path string, hdr map[string]string) (*http.Response, []byte)) {
t.Helper()
path := filepath.Join(t.TempDir(), "audit.db")
rec, err := audit.Open(path, nil)
if err != nil {
t.Fatalf("audit.Open: %v", err)
}
t.Cleanup(func() { _ = rec.Close() })
app := zip.New(zip.Config{Logger: luxlog.New("test")})
s := &svc{adminOrg: "admin", auditStore: rec}
app.Get("/v1/admin/audit", s.guard(s.audit))
app.Get("/v1/admin/audit/verify", s.guard(s.auditVerify))
fa := app.Fiber()
do := func(method, p string, hdr map[string]string) (*http.Response, []byte) {
t.Helper()
req := httptest.NewRequest(method, p, nil)
for k, v := range hdr {
req.Header.Set(k, v)
}
resp, err := fa.Test(req, fiber.TestConfig{Timeout: 30 * time.Second})
if err != nil {
t.Fatalf("%s %s: %v", method, p, err)
}
b, _ := io.ReadAll(resp.Body)
return resp, b
}
return rec, do
}
func seedAudit(t *testing.T, rec *audit.Recorder, n int) {
t.Helper()
ctx := context.Background()
for i := 0; i < n; i++ {
_, err := rec.Append(ctx, audit.Record{
Time: time.Now().UTC(),
Actor: audit.Actor{Org: "admin", Sub: "z@hanzo.ai"},
Action: "DELETE /v1/admin/orgs",
Resource: audit.Resource{Type: "org", ID: "acme"},
Auth: audit.AuthContext{Method: "jwt", IsAdmin: true},
Outcome: audit.Outcome{Result: "success", Status: 200},
Method: "DELETE",
Path: "/v1/admin/orgs/acme",
})
if err != nil {
t.Fatalf("seed %d: %v", i, err)
}
}
}
var globalAdmin = map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "admin", "X-User-Id": "z@hanzo.ai"}
// TestAdminAudit_ReturnsRealRecords proves GET /v1/admin/audit returns the
// store's records (newest-first) with an accurate total and an integrity summary.
func TestAdminAudit_ReturnsRealRecords(t *testing.T) {
rec, do := mountWithStore(t)
seedAudit(t, rec, 5)
resp, body := do("GET", "/v1/admin/audit", globalAdmin)
if resp.StatusCode != http.StatusOK {
t.Fatalf("audit: got %d (body=%s)", resp.StatusCode, body)
}
var env struct {
Data []struct {
Seq uint64 `json:"seq"`
Action string `json:"action"`
Hash string `json:"hash"`
Result string `json:"result"`
} `json:"data"`
Data2 int `json:"data2"`
Integrity struct {
OK bool `json:"ok"`
Count uint64 `json:"count"`
} `json:"integrity"`
}
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode: %v (body=%s)", err, body)
}
if env.Data2 != 5 || len(env.Data) != 5 {
t.Fatalf("got %d rows / total %d, want 5/5", len(env.Data), env.Data2)
}
if env.Data[0].Seq < env.Data[len(env.Data)-1].Seq {
t.Errorf("not newest-first: %d..%d", env.Data[0].Seq, env.Data[len(env.Data)-1].Seq)
}
if env.Data[0].Hash == "" {
t.Error("row has no hash — chain linkage not surfaced")
}
if !env.Integrity.OK || env.Integrity.Count != 5 {
t.Errorf("integrity summary = %+v, want ok/count=5", env.Integrity)
}
}
// TestAdminAudit_Filters proves the query filters (result) reach the store.
func TestAdminAudit_Filters(t *testing.T) {
rec, do := mountWithStore(t)
ctx := context.Background()
// One deny among successes.
_, _ = rec.Append(ctx, audit.Record{Action: "POST /v1/admin/roles", Actor: audit.Actor{Org: "admin"}, Outcome: audit.Outcome{Result: "deny", Status: 403}})
seedAudit(t, rec, 3)
resp, body := do("GET", "/v1/admin/audit?result=deny", globalAdmin)
if resp.StatusCode != http.StatusOK {
t.Fatalf("got %d (body=%s)", resp.StatusCode, body)
}
var env struct {
Data []map[string]any `json:"data"`
Data2 int `json:"data2"`
}
_ = json.Unmarshal(body, &env)
if env.Data2 != 1 || len(env.Data) != 1 {
t.Fatalf("result=deny returned %d/%d, want 1/1", len(env.Data), env.Data2)
}
if env.Data[0]["result"] != "deny" {
t.Errorf("filtered row result = %v, want deny", env.Data[0]["result"])
}
}
// TestAdminAudit_VerifyEndpoint proves GET /v1/admin/audit/verify returns the
// integrity result for the chain.
func TestAdminAudit_VerifyEndpoint(t *testing.T) {
rec, do := mountWithStore(t)
seedAudit(t, rec, 8)
resp, body := do("GET", "/v1/admin/audit/verify", globalAdmin)
if resp.StatusCode != http.StatusOK {
t.Fatalf("verify: got %d (body=%s)", resp.StatusCode, body)
}
var env struct {
Data struct {
OK bool `json:"ok"`
Count uint64 `json:"count"`
BrokenAt int64 `json:"brokenAt"`
HeadHash string `json:"headHash"`
} `json:"data"`
}
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode: %v (body=%s)", err, body)
}
if !env.Data.OK || env.Data.Count != 8 || env.Data.BrokenAt != -1 {
t.Errorf("verify result = %+v, want ok/count=8/brokenAt=-1", env.Data)
}
if env.Data.HeadHash == "" {
t.Error("verify returned no head hash")
}
}
// TestAdminAudit_DeniedWithoutGlobalAdmin proves BOTH audit endpoints fail-closed
// 403 for a non-global-admin, and — critically — the store is NEVER read on a
// denied request (the gate runs before the handler, so no records leak to an
// unauthorized caller). We assert non-leakage by seeding records and confirming
// the denied response body contains none of them.
func TestAdminAudit_DeniedWithoutGlobalAdmin(t *testing.T) {
rec, do := mountWithStore(t)
seedAudit(t, rec, 3)
cases := []struct {
name string
hdr map[string]string
}{
{"no identity", map[string]string{}},
{"tenant admin (org != adminOrg, no minted IsAdmin)", map[string]string{"X-Org-Id": "acme", "X-User-Id": "mallory"}},
{"forged-looking but non-admin", map[string]string{"X-Org-Id": "acme"}},
}
for _, ep := range []string{"/v1/admin/audit", "/v1/admin/audit/verify"} {
for _, tc := range cases {
resp, body := do("GET", ep, tc.hdr)
if resp.StatusCode != http.StatusForbidden {
t.Errorf("%s [%s]: got %d, want 403 (body=%s)", ep, tc.name, resp.StatusCode, body)
}
// No record content must appear in a denied response.
if len(body) > 0 && (contains(body, "DELETE /v1/admin/orgs") || contains(body, `"hash"`)) {
t.Errorf("%s [%s]: denied response leaked audit data: %s", ep, tc.name, body)
}
}
}
}
// TestAdminAudit_FallsBackToIAMWhenNoStore proves that when no local store is
// configured (auditStore == nil), /v1/admin/audit still serves the federated IAM
// view rather than erroring — preserving the prior capability. Covered by the
// existing TestAudit_MapsRecords (IAM proxy path); here we assert the nil-store
// verify endpoint reports "not configured" rather than panicking.
func TestAdminAudit_VerifyWithoutStore(t *testing.T) {
app := zip.New(zip.Config{Logger: luxlog.New("test")})
s := &svc{adminOrg: "admin"} // no auditStore
app.Get("/v1/admin/audit/verify", s.guard(s.auditVerify))
req := httptest.NewRequest("GET", "/v1/admin/audit/verify", nil)
for k, v := range globalAdmin {
req.Header.Set(k, v)
}
resp, err := app.Fiber().Test(req, fiber.TestConfig{Timeout: 30 * time.Second})
if err != nil {
t.Fatalf("verify: %v", err)
}
body, _ := io.ReadAll(resp.Body)
// A well-formed error envelope, not a 500/panic.
if resp.StatusCode != http.StatusOK || !contains(body, "not configured") {
t.Errorf("nil-store verify = %d %s, want an ok-envelope 'not configured' error", resp.StatusCode, body)
}
}
func contains(b []byte, sub string) bool {
s := string(b)
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
+138
View File
@@ -0,0 +1,138 @@
package admin
// The BASES panel (/v1/admin/bases) — the tenant Base-instance surface, scoped by the ONE
// tenant predicate: a SuperAdmin sees EVERY tenant's Base instance; any other admin caller
// sees ONLY their own subtree's. "Base" is Hanzo's multi-tenant app engine (hanzoai/base —
// a per-tenant DB store); an instance is one tenant's Base.
//
// SEAM (honest gap). The Base engine is being EMBEDDED into cloud (a /v1/base subsystem);
// until it lands, this panel proxies a server-authed Base admin surface at BASE_ADMIN_URL
// (secret from KMS via BASE_ADMIN_TOKEN — never a client claim, the SAME pattern
// waitlist.go uses) and returns the HONEST empty state when unconfigured — never
// fabricated instances. When /v1/base is embedded, point BASE_ADMIN_URL at the in-process
// handler; the scope filter below is unchanged.
//
// SCOPE SAFETY (defense in depth). A non-super caller's read is filtered to their subtree
// in TWO places: the upstream is asked for their org (?org=), AND every returned row is
// re-checked against the scope here — so a mis-filtering or unparseable upstream can NEVER
// leak another tenant's instance to a scoped caller (it degrades to empty, not to raw
// passthrough).
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/zap-proto/zip"
)
const (
baseAdminURLEnv = "BASE_ADMIN_URL"
baseAdminTokenEnv = "BASE_ADMIN_TOKEN"
)
var baseHTTP = &http.Client{Timeout: 15 * time.Second}
// baseInstance is one tenant's Base instance as the cockpit renders it. `Org` is the
// tenant slug the scope filter keys on — it MUST be present for a row to be visible to a
// scoped (non-super) caller.
type baseInstance struct {
Name string `json:"name"`
Org string `json:"org"`
URL string `json:"url"`
Status string `json:"status"`
Plan string `json:"plan"`
Region string `json:"region"`
Created string `json:"created"`
}
func baseAdminConfig() (base, token string, ok bool) {
base = strings.TrimRight(strings.TrimSpace(os.Getenv(baseAdminURLEnv)), "/")
token = strings.TrimSpace(os.Getenv(baseAdminTokenEnv))
return base, token, base != ""
}
// baseProxy issues a server-authed GET to the Base admin surface and returns its raw JSON
// body + status. Bounded read; Bearer token only when configured; never forwards a client
// header.
func (s *svc) baseProxy(ctx context.Context, target, token string) (json.RawMessage, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return nil, 0, fmt.Errorf("base request: %w", err)
}
req.Header.Set("Accept", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := baseHTTP.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("could not reach the Base engine: %w", err)
}
defer func() { _ = resp.Body.Close() }()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, resp.StatusCode, fmt.Errorf("base read: %w", err)
}
return json.RawMessage(raw), resp.StatusCode, nil
}
// bases answers GET /v1/admin/bases — the scoped Base-instance list. Honest empty when the
// engine is unconfigured; scope-filtered for a non-super caller.
func (s *svc) bases(c *zip.Ctx) error {
sc := s.resolveScope(c)
base, token, ok := baseAdminConfig()
if !ok {
return c.JSON(200, map[string]any{
"status": "ok",
"msg": "the Base engine is not yet embedded on this deployment",
"data": []baseInstance{},
"data2": 0,
})
}
q := url.Values{}
if !sc.super && len(sc.orgs) > 0 {
q.Set("org", sc.orgs[0]) // defense 1: server-side narrowing to the caller's org
}
target := base + "/v1/base/instances"
if enc := q.Encode(); enc != "" {
target += "?" + enc
}
raw, code, err := s.baseProxy(c.Context(), target, token)
if err != nil {
return fail(c, err.Error())
}
if code/100 != 2 {
return fail(c, fmt.Sprintf("base engine returned http %d", code))
}
// Defense 2: re-check every row against the resolved scope. A scoped caller NEVER
// sees a row outside their subtree even if the upstream ignored ?org=.
out := make([]baseInstance, 0)
for _, r := range decodeInstances(raw) {
if sc.scopedToOrg(r.Org) {
out = append(out, r)
}
}
return okList(c, out, len(out))
}
// decodeInstances tolerates BOTH a bare JSON array and a { data: [...] } envelope (the two
// shapes a Base admin surface might return), so the panel is robust to the engine's exact
// wire form.
func decodeInstances(raw json.RawMessage) []baseInstance {
body := raw
var env struct {
Data json.RawMessage `json:"data"`
}
if json.Unmarshal(raw, &env) == nil && len(env.Data) > 0 {
body = env.Data
}
var rows []baseInstance
_ = json.Unmarshal(body, &rows)
return rows
}
+500
View File
@@ -0,0 +1,500 @@
package admin
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
fiber "github.com/gofiber/fiber/v3"
"github.com/hanzoai/cloud/audit"
)
// ── rich stateful fakes for the customer-management surfaces ──────────────────
// cockpitFakes bundles a stateful IAM + commerce fake and the mounted `do` helper,
// exposing the recorded state (forbidden flips, deposits) tests assert on.
type cockpitFakes struct {
iam *httptest.Server
commerce *httptest.Server
svc *svc
do func(method, path string, hdr map[string]string, body string) (*http.Response, []byte)
mu sync.Mutex
forbidden map[string]bool // "owner/name" -> forbidden (mutated by update-user)
updateCalls []string // ids passed to update-user
deposits []depositCapture // deposits commerce received
balances map[string]int64 // org -> availableCents (mutated by deposit)
}
type depositCapture struct {
org string
user string
amount int64
}
// adminHdr is a validated global-admin identity (what SanitizeIdentity mints for
// owner==AdminOrg) plus a replayable credential.
func adminHdr() map[string]string {
return map[string]string{
"X-User-IsAdmin": "true", "X-Org-Id": "admin", "X-User-Id": "admin/z", "X-User-Email": "z@hanzo.ai",
"Authorization": "Bearer operator-jwt", "Cookie": "iam_access_token=operator-jwt",
}
}
// newCockpitFakes builds the stateful fleet: orgs acme + globex (owned by admin),
// with users, balances, subscriptions, and a dated usage ledger — all relative to
// `now` so the analytics windows are stable whenever the test runs.
func newCockpitFakes(t *testing.T) *cockpitFakes {
t.Helper()
now := time.Now().UTC()
f := &cockpitFakes{
forbidden: map[string]bool{},
balances: map[string]int64{"acme": 20000, "globex": 5000},
}
spend := map[string]int64{"acme": 1500, "globex": 300}
// Signup + usage dates relative to now so analytics windows include them.
acmeCreated := now.AddDate(0, 0, -45).Format(time.RFC3339)
globexCreated := now.AddDate(0, 0, -20).Format(time.RFC3339)
usage := map[string][]txn{
"acme": {
{ID: "t1", Type: "withdraw", Amount: 100, Currency: "usd", CreatedAt: now.AddDate(0, 0, -40).Format(time.RFC3339)},
{ID: "t2", Type: "withdraw", Amount: 200, Currency: "usd", CreatedAt: now.AddDate(0, 0, -5).Format(time.RFC3339)},
{ID: "t3", Type: "deposit", Amount: 20000, Currency: "usd", CreatedAt: now.AddDate(0, 0, -46).Format(time.RFC3339)},
},
"globex": {
{ID: "t4", Type: "withdraw", Amount: 400, Currency: "usd", CreatedAt: now.AddDate(0, 0, -3).Format(time.RFC3339)},
},
}
// users per org (owner/name): forbidden read live from f.forbidden.
type u struct{ owner, name, email, key string; admin bool }
users := map[string][]u{
"acme": {{"acme", "anna", "anna@acme.test", "hk-anna-secret", true}, {"acme", "bob", "bob@acme.test", "", false}},
"globex": {{"globex", "gwen", "gwen@globex.test", "hk-gwen-secret", true}},
}
f.iam = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
q := r.URL.Query()
switch {
case strings.HasSuffix(r.URL.Path, "/get-organizations"):
fmt.Fprintf(w, `{"status":"ok","msg":"","data":[
{"owner":"admin","name":"acme","displayName":"Acme Inc","createdTime":%q},
{"owner":"admin","name":"globex","displayName":"Globex","createdTime":%q}
],"data2":2}`, acmeCreated, globexCreated)
case strings.HasSuffix(r.URL.Path, "/get-users"):
owner := q.Get("owner")
rows := []string{}
for _, us := range users[owner] {
f.mu.Lock()
forb := f.forbidden[us.owner+"/"+us.name]
f.mu.Unlock()
created := acmeCreated
if owner == "globex" {
created = globexCreated
}
rows = append(rows, fmt.Sprintf(`{"owner":%q,"name":%q,"email":%q,"isAdmin":%v,"isForbidden":%v,"accessKey":%q,"createdTime":%q,"lastSigninTime":%q}`,
us.owner, us.name, us.email, us.admin, forb, us.key, created, now.AddDate(0, 0, -2).Format(time.RFC3339)))
}
fmt.Fprintf(w, `{"status":"ok","msg":"","data":[%s],"data2":%d}`, strings.Join(rows, ","), len(rows))
case strings.HasSuffix(r.URL.Path, "/get-user"):
id := q.Get("id")
parts := strings.SplitN(id, "/", 2)
owner := ""
if len(parts) == 2 {
owner = parts[0]
}
for _, us := range users[owner] {
if us.owner+"/"+us.name == id {
f.mu.Lock()
forb := f.forbidden[id]
f.mu.Unlock()
// Full object incl. fields update-user must preserve.
fmt.Fprintf(w, `{"status":"ok","msg":"","data":{"owner":%q,"name":%q,"email":%q,"isAdmin":%v,"isForbidden":%v,"accessKey":%q,"displayName":"X","phone":"","type":"normal-user"}}`,
us.owner, us.name, us.email, us.admin, forb, us.key)
return
}
}
w.WriteHeader(404)
io.WriteString(w, `{"status":"error","msg":"not found"}`)
case strings.HasSuffix(r.URL.Path, "/update-user"):
id := q.Get("id")
body, _ := io.ReadAll(r.Body)
var obj map[string]any
_ = json.Unmarshal(body, &obj)
forb, _ := obj["isForbidden"].(bool)
f.mu.Lock()
f.forbidden[id] = forb
f.updateCalls = append(f.updateCalls, id)
f.mu.Unlock()
io.WriteString(w, `{"status":"ok","msg":"","data":"Affected"}`)
default:
w.WriteHeader(404)
io.WriteString(w, `{"status":"error","msg":"not found"}`)
}
}))
f.commerce = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
org := r.Header.Get("X-Org-Id")
q := r.URL.Query()
user := q.Get("user")
f.mu.Lock()
bal := f.balances[org]
f.mu.Unlock()
sp := int64(0)
if org != "" && user == org {
sp = spend[org]
} else {
bal = 0 // wrong subject/namespace → empty wallet (the live contract)
}
switch {
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/deposit"):
var req struct {
User string `json:"user"`
Amount int64 `json:"amount"`
Currency string `json:"currency"`
}
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &req)
f.mu.Lock()
f.balances[org] += req.Amount
f.deposits = append(f.deposits, depositCapture{org: org, user: req.User, amount: req.Amount})
f.mu.Unlock()
w.WriteHeader(201)
fmt.Fprintf(w, `{"transactionId":"dep-%d","user":%q,"amount":%d,"currency":%q,"type":"deposit"}`, req.Amount, req.User, req.Amount, req.Currency)
case strings.HasSuffix(r.URL.Path, "/usage-rollup"):
fmt.Fprintf(w, `{"consumedCents":%d,"overageCents":0,"balance":{"balanceCents":%d,"availableCents":%d}}`, sp, bal, bal)
case strings.HasSuffix(r.URL.Path, "/balance"):
fmt.Fprintf(w, `{"user":%q,"currency":"usd","available":%d,"balance":%d}`, user, bal, bal)
case strings.HasSuffix(r.URL.Path, "/subscriptions"):
if org == "acme" && user == "acme" {
io.WriteString(w, `{"subscriptions":[{"status":"active","plan":{"name":"Pro","price":5000,"currency":"usd","interval":"month"}}]}`)
} else {
io.WriteString(w, `{"subscriptions":[]}`)
}
case strings.HasSuffix(r.URL.Path, "/transactions"):
// Commerce serves the ledger WRAPPED as { count, transactions:[...] }
// (the live contract) — the fake mirrors it so the decode is guarded
// against the real shape, not a bare array a mock would let pass.
rows := usage[org]
b, _ := json.Marshal(map[string]any{"count": len(rows), "transactions": rows})
w.Write(b)
default:
w.WriteHeader(404)
io.WriteString(w, `{"status":"error","msg":"not found"}`)
}
}))
_, s, fa := mountSvc(t, f.iam.URL, f.commerce.URL, "")
f.svc = s
f.do = func(method, path string, hdr map[string]string, body string) (*http.Response, []byte) {
t.Helper()
var rdr io.Reader
if body != "" {
rdr = strings.NewReader(body)
}
req := httptest.NewRequest(method, path, rdr)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
for k, v := range hdr {
req.Header.Set(k, v)
}
resp, err := fa.Test(req, fiber.TestConfig{Timeout: 30 * time.Second})
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
bb, _ := io.ReadAll(resp.Body)
return resp, bb
}
t.Cleanup(func() { f.iam.Close(); f.commerce.Close() })
return f
}
// TestCustomers_ListRealFleet proves the fleet customer list is real: every field
// (owner email, plan, balance, spend, MRR, status, user count) comes from the live
// IAM + commerce upstreams.
func TestCustomers_ListRealFleet(t *testing.T) {
f := newCockpitFakes(t)
resp, body := f.do("GET", "/v1/admin/customers", adminHdr(), "")
if resp.StatusCode != 200 {
t.Fatalf("customers: %d (%s)", resp.StatusCode, body)
}
var env struct {
Data []customerRow `json:"data"`
Data2 int `json:"data2"`
}
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode: %v", err)
}
if env.Data2 != 2 || len(env.Data) != 2 {
t.Fatalf("want 2 customers, got %d (%+v)", len(env.Data), env.Data)
}
acme := env.Data[0] // sorted: acme, globex
if acme.Org != "acme" || acme.OwnerEmail != "anna@acme.test" || acme.Plan != "Pro" {
t.Errorf("acme identity wrong: %+v", acme)
}
if acme.BalanceCents != 20000 || acme.SpendCents != 1500 || acme.MRRCents != 5000 {
t.Errorf("acme money wrong: bal=%d spend=%d mrr=%d", acme.BalanceCents, acme.SpendCents, acme.MRRCents)
}
if acme.Users != 2 || acme.Status != "active" {
t.Errorf("acme users/status wrong: users=%d status=%s", acme.Users, acme.Status)
}
}
// TestCustomerDetail_RealAndNoSecretLeak proves the detail is real AND that the
// hk- access key VALUE never appears in the response (presence only).
func TestCustomerDetail_RealAndNoSecretLeak(t *testing.T) {
f := newCockpitFakes(t)
resp, body := f.do("GET", "/v1/admin/customers/acme", adminHdr(), "")
if resp.StatusCode != 200 {
t.Fatalf("detail: %d (%s)", resp.StatusCode, body)
}
if strings.Contains(string(body), "hk-anna-secret") {
t.Fatalf("SECRET LEAK: the access key value appears in the customer detail response")
}
var env struct {
Data customerDetail `json:"data"`
}
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode: %v", err)
}
d := env.Data
if d.Org != "acme" || d.Plan != "Pro" || d.BalanceCents != 20000 || d.MRRCents != 5000 {
t.Errorf("detail money/plan wrong: %+v", d)
}
// anna has a key, bob does not → apiKeys count = 1.
if d.APIKeys != 1 {
t.Errorf("apiKeys = %d, want 1", d.APIKeys)
}
if len(d.Users) != 2 {
t.Fatalf("want 2 users, got %d", len(d.Users))
}
// The users carry hasApiKey (presence) but NO key value field exists in the type.
var anna *customerUser
for i := range d.Users {
if d.Users[i].Name == "anna" {
anna = &d.Users[i]
}
}
if anna == nil || !anna.HasAPIKey || !anna.IsAdmin {
t.Errorf("anna mapping wrong: %+v", anna)
}
if len(d.Transactions) == 0 {
t.Error("detail must include the real ledger transactions")
}
}
// TestGrantCredit_DepositLandsAndAudited proves grant credit is a REAL commerce
// deposit (right org/subject) reflected in the balance AND recorded to the
// tamper-evident audit trail with a before/after.
func TestGrantCredit_DepositLandsAndAudited(t *testing.T) {
f := newCockpitFakes(t)
rec, err := audit.Open(":memory:", nil)
if err != nil {
t.Fatalf("audit open: %v", err)
}
defer rec.Close()
f.svc.auditStore = rec
resp, body := f.do("POST", "/v1/admin/customers/acme/credit", adminHdr(), `{"amountCents":5000,"reason":"support comp"}`)
if resp.StatusCode != 200 {
t.Fatalf("credit: %d (%s)", resp.StatusCode, body)
}
var env struct {
Status string `json:"status"`
Data struct {
GrantedCents int64 `json:"grantedCents"`
BalanceCents int64 `json:"balanceCents"`
TransactionID string `json:"transactionId"`
} `json:"data"`
}
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode: %v", err)
}
if env.Status != "ok" || env.Data.GrantedCents != 5000 {
t.Fatalf("grant envelope wrong: %+v", env)
}
// The balance reflects the grant (20000 + 5000).
if env.Data.BalanceCents != 25000 {
t.Errorf("balance after grant = %d, want 25000", env.Data.BalanceCents)
}
// Commerce received a deposit for the RIGHT org + subject (X-Org-Id=acme, user=acme).
f.mu.Lock()
deps := append([]depositCapture(nil), f.deposits...)
f.mu.Unlock()
if len(deps) != 1 || deps[0].org != "acme" || deps[0].user != "acme" || deps[0].amount != 5000 {
t.Fatalf("deposit not landed on the right subject: %+v", deps)
}
// The action is on the tamper-evident trail with a before/after balance.
rows, total, err := rec.Query(context.Background(), audit.Filter{Action: "admin.customer.credit"})
if err != nil || total < 1 || len(rows) < 1 {
t.Fatalf("credit not audited: total=%d err=%v", total, err)
}
r := rows[0]
if r.Actor.Org != "admin" || r.Outcome.Result != "success" || r.Resource.ID != "acme" {
t.Errorf("audit record wrong: %+v", r)
}
if !strings.Contains(string(r.Before), "balanceCents") || !strings.Contains(string(r.After), "grantedCents") {
t.Errorf("audit before/after missing: before=%s after=%s", r.Before, r.After)
}
}
// TestGrantCredit_Validation proves the guardrails (positive amount, cap, real org).
func TestGrantCredit_Validation(t *testing.T) {
f := newCockpitFakes(t)
cases := []struct {
name, org, body string
wantStatus int
wantErr bool
}{
{"zero amount", "acme", `{"amountCents":0}`, 200, true},
{"negative", "acme", `{"amountCents":-100}`, 200, true},
{"over cap", "acme", `{"amountCents":999999999}`, 200, true},
{"unknown org", "nope", `{"amountCents":100}`, 404, true},
}
for _, tc := range cases {
resp, body := f.do("POST", "/v1/admin/customers/"+tc.org+"/credit", adminHdr(), tc.body)
if resp.StatusCode != tc.wantStatus {
t.Errorf("%s: status %d, want %d (%s)", tc.name, resp.StatusCode, tc.wantStatus, body)
}
if tc.wantErr && !strings.Contains(string(body), `"error"`) {
t.Errorf("%s: expected error envelope, got %s", tc.name, body)
}
}
// No deposit should have landed for any invalid grant.
f.mu.Lock()
n := len(f.deposits)
f.mu.Unlock()
if n != 0 {
t.Errorf("invalid grants must NOT deposit, but %d landed", n)
}
}
// TestSuspendReactivate_ForbidsUsersAndAudits proves suspend flips IAM isForbidden
// on every org user (the real access lever) and is audited, and reactivate reverses
// it — the customer's status reflects the change on a re-list.
func TestSuspendReactivate_ForbidsUsersAndAudits(t *testing.T) {
f := newCockpitFakes(t)
rec, _ := audit.Open(":memory:", nil)
defer rec.Close()
f.svc.auditStore = rec
// Suspend acme.
resp, body := f.do("POST", "/v1/admin/customers/acme/suspend", adminHdr(), "")
if resp.StatusCode != 200 {
t.Fatalf("suspend: %d (%s)", resp.StatusCode, body)
}
// Both acme users were update-user'd to forbidden.
f.mu.Lock()
if !f.forbidden["acme/anna"] || !f.forbidden["acme/bob"] {
t.Errorf("suspend did not forbid both users: %+v", f.forbidden)
}
f.mu.Unlock()
// A re-list shows acme suspended (all users forbidden).
_, lb := f.do("GET", "/v1/admin/customers", adminHdr(), "")
var env struct{ Data []customerRow `json:"data"` }
_ = json.Unmarshal(lb, &env)
for _, c := range env.Data {
if c.Org == "acme" && c.Status != "suspended" {
t.Errorf("acme status = %q after suspend, want suspended", c.Status)
}
}
// Audited.
if _, total, _ := rec.Query(context.Background(), audit.Filter{Action: "admin.customer.suspend"}); total < 1 {
t.Errorf("suspend not audited")
}
// Reactivate reverses it.
if _, rb := f.do("POST", "/v1/admin/customers/acme/reactivate", adminHdr(), ""); !strings.Contains(string(rb), `"suspended":false`) {
t.Errorf("reactivate response wrong: %s", rb)
}
f.mu.Lock()
if f.forbidden["acme/anna"] || f.forbidden["acme/bob"] {
t.Errorf("reactivate did not clear forbidden: %+v", f.forbidden)
}
f.mu.Unlock()
}
// TestRevenue_RealAggregate proves the fleet revenue board: totals, paying-customer
// count, ARPU, and the per-customer table are real commerce aggregates.
func TestRevenue_RealAggregate(t *testing.T) {
f := newCockpitFakes(t)
resp, body := f.do("GET", "/v1/admin/revenue", adminHdr(), "")
if resp.StatusCode != 200 {
t.Fatalf("revenue: %d (%s)", resp.StatusCode, body)
}
var env struct {
Data revenueData `json:"data"`
}
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode: %v", err)
}
d := env.Data
if d.TotalBalancesCents != 25000 { // acme 20000 + globex 5000
t.Errorf("total balances = %d, want 25000", d.TotalBalancesCents)
}
if d.TotalSpendCents != 1800 { // 1500 + 300
t.Errorf("total spend = %d, want 1800", d.TotalSpendCents)
}
if d.MRRCents != 5000 {
t.Errorf("MRR = %d, want 5000", d.MRRCents)
}
if d.PayingCustomers != 2 {
t.Errorf("paying customers = %d, want 2", d.PayingCustomers)
}
if d.ARPUCents != 900 { // 1800 / 2
t.Errorf("ARPU = %d, want 900", d.ARPUCents)
}
if len(d.PerCustomer) != 2 || d.PerCustomer[0].Org != "acme" { // sorted by spend desc
t.Errorf("per-customer table wrong: %+v", d.PerCustomer)
}
if len(d.SpendTrend) == 0 {
t.Error("revenue must include a real spend trend")
}
}
// TestAnalytics_HandlerRealWiring proves the analytics handler wires IAM signups +
// commerce ledger into a REAL cohort/active/growth board, and flags computed=true.
func TestAnalytics_HandlerRealWiring(t *testing.T) {
f := newCockpitFakes(t)
resp, body := f.do("GET", "/v1/admin/analytics?range=all", adminHdr(), "")
if resp.StatusCode != 200 {
t.Fatalf("analytics: %d (%s)", resp.StatusCode, body)
}
var env struct {
Data analyticsData `json:"data"`
}
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode: %v", err)
}
d := env.Data
if d.TotalCustomers != 2 {
t.Errorf("total customers = %d, want 2", d.TotalCustomers)
}
// Real ledger present → retention/active/usage computed, growth always.
if !d.Computed["growth"] || !d.Computed["retention"] || !d.Computed["active"] {
t.Errorf("computed flags must be true with a real ledger: %+v", d.Computed)
}
// Both customers had recent usage → MAU covers them.
if d.MAU < 1 {
t.Errorf("MAU = %d, want >=1 (recent usage)", d.MAU)
}
// Retention grid has cohorts from the two signups.
if len(d.Retention.Cohorts) == 0 {
t.Error("retention grid must have cohorts from real signups")
}
// Top customer by usage present (acme 300c > globex 400c? globex 400 wins).
if len(d.TopCustomers) == 0 {
t.Error("top customers must be populated from real usage")
}
}
+440
View File
@@ -0,0 +1,440 @@
package admin
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// commerceClient reads the commerce billing S2S surface (/v1/billing/*, /v1/costs)
// for the money panels (spend, tokens, credits, COGS). Commerce runs as its own
// deployment; these are HTTP calls authenticated with the admin-scoped
// COMMERCE_SERVICE_TOKEN (a KMS-sourced secret already on the cloud env — never
// hard-coded here). PER-ORG reads (balance/usage-rollup/subscriptions) resolve the
// org's billing namespace from the TRUSTED X-Org-Id header — commerce's EdgeAuth
// trusts it ONLY when the bearer is the service token — and key the wallet under the
// bare org slug (`user`). The fleet-wide /v1/costs god-view is org-INDEPENDENT
// (DigitalOcean + provider vendor bills) and sends NO org, so commerce falls back to
// its own service namespace (COMMERCE_SERVICE_ORG) there. (An earlier revision sent
// X-IAM-Org-Id, which commerce does NOT read — every per-org money panel read $0.)
type commerceClient struct {
base string // e.g. http://commerce.hanzo.svc.cluster.local:8001
token string // admin S2S bearer (secret; never logged)
http *http.Client
}
func newCommerceClient(base, token string) *commerceClient {
return &commerceClient{
base: strings.TrimRight(strings.TrimSpace(base), "/"),
token: strings.TrimSpace(token),
http: &http.Client{Timeout: 15 * time.Second},
}
}
func (c *commerceClient) configured() bool { return c != nil && c.base != "" }
// rollup is the org-scoped billing view commerce serves at /v1/billing/usage-rollup.
// Cents are the canonical unit; consumedCents is the org's month-to-date spend.
type rollup struct {
ConsumedCents int64 `json:"consumedCents"`
OverageCents int64 `json:"overageCents"`
Balance struct {
BalanceCents int64 `json:"balanceCents"`
AvailableCents int64 `json:"availableCents"`
} `json:"balance"`
}
// usageRollup fetches the current-month rollup for one billing subject (an IAM
// "org/user" identity) in org `org`. commerce keys usage per user; the operator
// aggregates across an org's users when a full breakdown is needed. Returns a
// zero rollup (not an error) when commerce is not configured so a partial deploy
// degrades to honest zeros rather than a 5xx.
func (c *commerceClient) usageRollup(ctx context.Context, org, user string) (rollup, error) {
var out rollup
if !c.configured() {
return out, nil
}
q := url.Values{"user": {user}}
body, err := c.get(ctx, "/v1/billing/usage-rollup", q, org)
if err != nil {
return out, err
}
if err := json.Unmarshal(body, &out); err != nil {
return out, fmt.Errorf("commerce rollup decode: %w", err)
}
return out, nil
}
// balanceAll is the org's prepaid credit balance across currencies (cents).
// Sourced from /v1/billing/balance/all for the "Credits" tile.
type balanceAll struct {
Balances map[string]struct {
Available int64 `json:"available"`
Balance int64 `json:"balance"`
} `json:"balances"`
}
// creditsCents returns the org's available credit balance in USD cents. Zero
// (not an error) when commerce is unconfigured.
func (c *commerceClient) creditsCents(ctx context.Context, org, user string) (int64, error) {
if !c.configured() {
return 0, nil
}
q := url.Values{"user": {user}, "currency": {"usd"}}
body, err := c.get(ctx, "/v1/billing/balance", q, org)
if err != nil {
return 0, err
}
var b struct {
Available int64 `json:"available"`
}
if err := json.Unmarshal(body, &b); err != nil {
return 0, fmt.Errorf("commerce balance decode: %w", err)
}
return b.Available, nil
}
// subscriptionsWire is the /v1/billing/subscriptions list shape the MRR + plan
// readers fold over. Only the fields we need are decoded (status + plan
// name/price/interval); commerce emits plan.price in the currency's minor unit
// (cents) per its wire.
type subscriptionsWire struct {
Subscriptions []struct {
Status string `json:"status"`
Plan struct {
Name string `json:"name"`
ID string `json:"id"`
Price int64 `json:"price"`
Currency string `json:"currency"`
Interval string `json:"interval"`
} `json:"plan"`
} `json:"subscriptions"`
}
// subSummary is the plan + MRR view of an org's subscriptions in ONE read: the
// active plan name (the customer's tier), the normalized monthly recurring cents,
// and whether any subscription is active. "pay-as-you-go" is the honest default
// for a metered customer with no active subscription (not a fabricated tier).
type subSummary struct {
Plan string // active plan name, else "pay-as-you-go"
MRR int64 // monthly-normalized recurring cents from active subs
Active bool // any active/trialing subscription present
}
// subscriptionSummary reads /v1/billing/subscriptions ONCE and derives both the
// plan tier and the MRR contribution, so the customer list/detail and the revenue
// board share a single upstream read (DRY). Only "active"/"trialing" subscriptions
// count; canceled/past-due do not. An honest zero/"pay-as-you-go" (not an error)
// when commerce is unconfigured, so a partial deploy degrades to honest values.
func (c *commerceClient) subscriptionSummary(ctx context.Context, org, user string) (subSummary, error) {
sum := subSummary{Plan: "pay-as-you-go"}
if !c.configured() {
return sum, nil
}
q := url.Values{"user": {user}}
body, err := c.get(ctx, "/v1/billing/subscriptions", q, org)
if err != nil {
return sum, err
}
var w subscriptionsWire
if err := json.Unmarshal(body, &w); err != nil {
return sum, fmt.Errorf("commerce subscriptions decode: %w", err)
}
for _, s := range w.Subscriptions {
switch strings.ToLower(strings.TrimSpace(s.Status)) {
case "active", "trialing":
sum.MRR += monthlyNormalizedCents(s.Plan.Price, s.Plan.Interval)
sum.Active = true
if name := strings.TrimSpace(s.Plan.Name); name != "" && sum.Plan == "pay-as-you-go" {
sum.Plan = name
}
}
}
return sum, nil
}
// mrrCents returns the monthly-recurring-revenue contribution of org `org`'s
// ACTIVE subscriptions (see subscriptionSummary). Kept as the narrow reader the
// finance/revenue folds call; it delegates so there is ONE subscriptions decode.
func (c *commerceClient) mrrCents(ctx context.Context, org, user string) (int64, error) {
sum, err := c.subscriptionSummary(ctx, org, user)
return sum.MRR, err
}
// monthlyNormalizedCents normalizes a plan price to a monthly figure by its
// billing interval so annual and monthly plans are comparable in one MRR sum.
func monthlyNormalizedCents(priceCents int64, interval string) int64 {
switch strings.ToLower(strings.TrimSpace(interval)) {
case "year", "yearly", "annual", "annually":
return priceCents / 12
case "week", "weekly":
return priceCents * 52 / 12
case "day", "daily":
return priceCents * 365 / 12
default: // month/monthly and anything unrecognized → treat as monthly
return priceCents
}
}
// vendorCost mirrors commerce's api/costs.VendorCost — one line of what WE pay a
// vendor for a service in a period (COGS, USD cents). Decoded verbatim from
// GET /v1/costs so the finance board renders the per-vendor breakdown without
// re-deriving any cost cloud-side.
type vendorCost struct {
Vendor string `json:"vendor"`
Service string `json:"service"`
AmountCents int64 `json:"amountCents"`
Source string `json:"source"` // "actual" | "estimated"
Note string `json:"note,omitempty"`
}
// costReport is the GET /v1/costs response: every vendor COGS line for a period
// plus the total. TotalCents is the platform's whole COGS (DigitalOcean compute +
// the LLM providers we resell) — the single figure the finance margin math folds.
type costReport struct {
Period string `json:"period"`
Vendors []vendorCost `json:"vendors"`
TotalCents int64 `json:"totalCents"`
Currency string `json:"currency"`
}
// costs reads commerce's vendor-COGS god-view (GET /v1/costs) for a period — the
// SINGLE source of truth for what we pay every vendor. It authenticates with the
// admin S2S service token (COMMERCE_SERVICE_TOKEN, no IAM user identity), which
// commerce's requireCostsAdmin admits on its M2M path (Admin bit + empty Subject).
//
// This is a PLATFORM god-view, deliberately NOT per-org, so NO org selector is
// sent: the DigitalOcean compute and OpenAI COGS lines are read from the vendor
// billing APIs (global, org-independent) and the metered LLM estimates come from
// commerce's own service namespace (COMMERCE_SERVICE_ORG) — which commerce resolves
// from its service-token config, never from a request header. Returns a zero report
// (not an error) when commerce is unconfigured so a partial deploy degrades to
// honest zeros rather than a 5xx.
func (c *commerceClient) costs(ctx context.Context, period string) (costReport, error) {
var out costReport
if !c.configured() {
return out, nil
}
q := url.Values{}
if period != "" {
q.Set("period", period)
}
// Empty org: /v1/costs is fleet-wide; commerce uses COMMERCE_SERVICE_ORG.
body, err := c.get(ctx, "/v1/costs", q, "")
if err != nil {
return out, err
}
if err := json.Unmarshal(body, &out); err != nil {
return out, fmt.Errorf("commerce costs decode: %w", err)
}
return out, nil
}
// depositResult is the /v1/billing/deposit 201 response — the transaction id of
// the credit that landed. The operator surfaces it as the receipt of a grant.
type depositResult struct {
TransactionID string `json:"transactionId"`
User string `json:"user"`
Amount int64 `json:"amount"`
Currency string `json:"currency"`
}
// deposit grants credit to an org's wallet by creating a commerce Deposit
// transaction (POST /v1/billing/deposit). This is the ONE money-in primitive the
// admin credit action uses (refunds/comps/support) — it is symmetric with the
// balance READ: the same X-Org-Id=<org> namespace + `user`=<org> subject the
// creditsCents/usageRollup reads resolve, so a grant lands exactly where the
// balance panel reads it. Authenticated with the admin S2S COMMERCE_SERVICE_TOKEN
// (commerce's /billing admin group), which is the same credential the reads use.
// Commerce's EdgeAuth additionally pins the body `user` to the X-Org-Id subject,
// so the grant can never be mis-targeted to another org's wallet. amountCents must
// be positive (a grant, never a silent debit) — the handler validates + caps it.
func (c *commerceClient) deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (depositResult, error) {
var out depositResult
if !c.configured() {
return out, errUnconfigured
}
if currency == "" {
currency = "usd"
}
body, err := json.Marshal(map[string]any{
"user": user,
"currency": currency,
"amount": amountCents,
"notes": notes,
"tags": tags,
})
if err != nil {
return out, err
}
respBody, err := c.post(ctx, "/v1/billing/deposit", org, body)
if err != nil {
return out, err
}
if err := json.Unmarshal(respBody, &out); err != nil {
return out, fmt.Errorf("commerce deposit decode: %w", err)
}
return out, nil
}
// txn is one commerce ledger row (GET /v1/billing/transactions). Cents is the
// canonical unit; Type is "deposit" (credit) or "withdraw" (usage/consumption).
// CreatedAt is the RFC3339 event time the analytics fold buckets on.
type txn struct {
ID string `json:"id"`
Type string `json:"type"`
Amount int64 `json:"amount"`
Currency string `json:"currency"`
Tags string `json:"tags,omitempty"`
Notes string `json:"notes,omitempty"`
CreatedAt string `json:"createdAt"`
}
// transactions reads an org's ledger (GET /v1/billing/transactions) for one
// billing subject. The rows carry a real event timestamp + type, so the analytics
// aggregator can derive signup-cohort retention, active-customer windows, churn,
// and usage-over-time from actual consumption events — NOT a fabricated series.
// `limit` bounds the read (the endpoint sorts newest-first). Returns an empty
// slice (not an error) when commerce is unconfigured so a partial deploy degrades
// to an honest empty history rather than a 5xx.
func (c *commerceClient) transactions(ctx context.Context, org, user string, limit int) ([]txn, error) {
if !c.configured() {
return nil, nil
}
q := url.Values{"user": {user}}
if limit > 0 {
q.Set("limit", fmt.Sprintf("%d", limit))
}
body, err := c.get(ctx, "/v1/billing/transactions", q, org)
if err != nil {
return nil, err
}
// Commerce serves the ledger WRAPPED as { count, transactions:[...] } (verified
// live). Decode that shape; tolerate a bare array too so a contract change in
// either direction degrades gracefully rather than silently reading zero rows
// (which would make the analytics honest-empty despite real usage).
var wrap struct {
Transactions []txn `json:"transactions"`
}
if err := json.Unmarshal(body, &wrap); err == nil && wrap.Transactions != nil {
return wrap.Transactions, nil
}
var rows []txn
if err := json.Unmarshal(body, &rows); err != nil {
return nil, fmt.Errorf("commerce transactions decode: %w", err)
}
return rows, nil
}
// post performs one admin-authenticated commerce POST (JSON body) and returns the
// raw response. It carries the SAME trust context as get: the admin S2S service
// token as the bearer and X-Org-Id=<org> as the per-org namespace selector that
// commerce's EdgeAuth trusts only after verifying the service token. A non-2xx is
// an error (the caller surfaces it honestly + records the failed attempt in the
// audit trail — a grant that did not land is never reported as success).
func (c *commerceClient) post(ctx context.Context, path, org string, body []byte) ([]byte, error) {
u := c.base + path
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
if org != "" {
req.Header.Set("X-Org-Id", org)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("commerce unreachable: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("commerce status %d", resp.StatusCode)
}
return respBody, nil
}
// get performs one admin-authenticated commerce GET and returns the raw body.
func (c *commerceClient) get(ctx context.Context, path string, q url.Values, org string) ([]byte, error) {
u := c.base + path
if enc := q.Encode(); enc != "" {
u += "?" + enc
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
if org != "" {
// Commerce's EdgeAuth (middleware/edgeauth.go) trusts X-Org-Id ONLY after it
// verifies the bearer is the COMMERCE_SERVICE_TOKEN, then resolves the per-org
// billing namespace from it. This is the service-to-service org selector.
// X-IAM-Org-Id is NOT read by commerce — it silently resolved to the default
// (COMMERCE_SERVICE_ORG) namespace, so every real org's balance/spend read $0.
req.Header.Set("X-Org-Id", org)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("commerce unreachable: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("commerce status %d", resp.StatusCode)
}
return body, nil
}
// healthClient probes an upstream's /v1/o11y/health (or any health path) so the
// overview can report System Health honestly. A non-2xx or unreachable upstream
// is reported as not-ok — never masked.
type healthClient struct {
url string
http *http.Client
}
func newHealthClient(u string) *healthClient {
return &healthClient{url: strings.TrimSpace(u), http: &http.Client{Timeout: 8 * time.Second}}
}
func (h *healthClient) configured() bool { return h != nil && h.url != "" }
// ok reports whether the o11y health endpoint answers 2xx.
func (h *healthClient) ok(ctx context.Context) (bool, error) {
if !h.configured() {
return false, fmt.Errorf("o11y health not configured")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.url, nil)
if err != nil {
return false, err
}
resp, err := h.http.Do(req)
if err != nil {
return false, fmt.Errorf("o11y unreachable: %w", err)
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return false, fmt.Errorf("o11y health %d", resp.StatusCode)
}
return true, nil
}
+232
View File
@@ -0,0 +1,232 @@
// 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
// compute — GET /v1/admin/compute, the cross-tenant compute-analytics read the
// operator's Bots and Machines boards (admin.hanzo.ai) group into an
// org → app → project tree. It aggregates the operator-owned usage table
// hanzo.compute_usage(org, app, project, kind, event, machine_id, size,
// price_cents, ts) — the same warehouse (`datastore`, ClickHouse) the analytics
// subsystem reads, over the SAME shared client (aiobject.DatastoreQuery), no
// second connection. `kind` is an OPEN LowCardinality spectrum (bot | machine |
// cluster | nodepool | container | function | …) — a bot is a machine running the
// @hanzo/bot agent, a machine is raw compute visor opens — and each console lens
// reuses this one endpoint with a different `?kind=` (Bots=bot, Machines=machine).
//
// GLOBAL-ADMIN ONLY (the s.guard wrap in admin.go), all-orgs by default; this is
// an AGGREGATOR — admin holds no compute state, it only reads. Honest by
// construction, exactly like the analytics events lens: no datastore connected, or
// the events table not provisioned yet (the emitter is still being wired) → the
// real empty list, NEVER a fabricated fleet. admin creates NO table (the datastore
// stream owns hanzo.compute_usage). Money is USD cents end to end.
import (
"context"
"strings"
"time"
aiobject "github.com/hanzoai/ai/object"
"github.com/zap-proto/zip"
)
// computeTable is the operator-owned compute-usage warehouse table (named to match
// the existing hanzo.cloud_usage convention; the visor/commerce emitter writes it).
// admin only READS it (never creates it — mirrors how analytics treats hanzo.events).
const computeTable = "hanzo.compute_usage"
// terminalComputeEvents are the lifecycle events whose LATEST occurrence means a
// machine is no longer running (mirrors the console foldEvents terminal set). A
// CLOSED server-side constant — never user input — so rendering it into the argMax
// check is injection-safe.
var terminalComputeEvents = []string{
"stop", "stopped", "destroy", "destroyed", "terminate", "terminated",
"delete", "deleted", "off", "shutdown", "expire", "expired",
}
// computeLeaf is one (org, app, project, kind) rollup: distinct machines of that
// kind, how many are currently active (latest event non-terminal), the billed
// spend over the window, and the most recent event. The console folds these into
// the org → app → project tree.
type computeLeaf struct {
Org string `json:"org"`
App string `json:"app"`
Project string `json:"project"`
Kind string `json:"kind"`
Machines int64 `json:"machines"`
Active int64 `json:"active"`
SpendCents int64 `json:"spendCents"`
LastTs string `json:"lastTs"`
}
// compute answers GET /v1/admin/compute. ?kind=<kind> and ?org= narrow the
// aggregate; ?range=24h|7d|30d bounds it (default 30d). Global-admin only.
func (s *svc) compute(c *zip.Ctx) error {
ctx := c.Context()
// Honest-empty when the warehouse is not connected or the usage table is not
// provisioned yet (the visor/commerce emitter is still being wired).
if !aiobject.DatastoreEnabled() || !computeTableExists(ctx) {
return okList(c, []computeLeaf{}, 0)
}
// `kind` is an OPEN LowCardinality spectrum (bot | machine | cluster | nodepool |
// container | function | …), matched as a PLAIN STRING — no enum assumption. Each
// console lens passes its own kind; empty = all kinds. Case-normalized to the
// warehouse's lower-case convention.
kind := strings.ToLower(strings.TrimSpace(c.Query("kind")))
sql, args := buildComputeQuery(c.Query("range"), kind, strings.TrimSpace(c.Query("org")))
rows, err := aiobject.DatastoreQuery(ctx, sql, args...)
if err != nil {
return fail(c, "compute query: "+err.Error())
}
leaves := computeLeavesFromRows(rows)
return okList(c, leaves, len(leaves))
}
// buildComputeQuery assembles the two-level roll-up (pure, so it is unit-tested).
// The inner query resolves each machine's LATEST lifecycle state (argMax event by
// ts) + its billed spend; the outer counts machines, counts the still-active ones,
// and sums spend per (org, app, project, kind). `kind` is a PLAIN STRING over an
// open LowCardinality spectrum (no enum assumption; any non-empty value filters) and
// the terminal set is a constant, so nothing user-derived is interpolated — org,
// kind, and the time bound are all POSITIONAL parameters.
func buildComputeQuery(rangeLabel, kind, org string) (string, []any) {
where := "ts >= ?"
args := []any{chTS(computeSince(rangeLabel))}
if kind != "" {
where += " AND kind = ?"
args = append(args, kind)
}
if org != "" {
where += " AND org = ?"
args = append(args, org)
}
sql := "SELECT org, app, project, kind, " +
"count() AS machines, countIf(active) AS active, sum(spend) AS spend_cents, max(last_ts) AS last_ts " +
"FROM (SELECT org, app, project, kind, machine_id, " +
"sum(price_cents) AS spend, max(ts) AS last_ts, " +
"argMax(event, ts) NOT IN (" + terminalComputeSQL() + ") AS active " +
"FROM " + computeTable + " WHERE " + where + " " +
"GROUP BY org, app, project, kind, machine_id) " +
"GROUP BY org, app, project, kind ORDER BY spend_cents DESC"
return sql, args
}
// computeLeavesFromRows maps the DatastoreQuery rows onto []computeLeaf (pure).
func computeLeavesFromRows(rows []map[string]any) []computeLeaf {
leaves := make([]computeLeaf, 0, len(rows))
for _, r := range rows {
leaves = append(leaves, computeLeaf{
Org: chStr(r["org"]),
App: chStr(r["app"]),
Project: chStr(r["project"]),
Kind: chStr(r["kind"]),
Machines: chInt64(r["machines"]),
Active: chInt64(r["active"]),
SpendCents: chInt64(r["spend_cents"]),
LastTs: chTime(r["last_ts"]),
})
}
return leaves
}
// computeTableExists probes for the operator-owned events table. Any error → false
// (honest "not available yet"), mirroring analytics.tableExists.
func computeTableExists(ctx context.Context) bool {
rows, err := aiobject.DatastoreQuery(ctx, "EXISTS TABLE "+computeTable)
if err != nil || len(rows) == 0 {
return false
}
for _, v := range rows[0] {
return chInt64(v) == 1
}
return false
}
// computeSince maps the ?range enum to a lower time bound (default 30d).
func computeSince(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)
}
}
// terminalComputeSQL renders the terminal-event set as a ClickHouse string list.
func terminalComputeSQL() string {
quoted := make([]string, len(terminalComputeEvents))
for i, e := range terminalComputeEvents {
quoted[i] = "'" + e + "'"
}
return strings.Join(quoted, ",")
}
// chTS formats a time as a ClickHouse DateTime literal (UTC), bound as a string arg.
func chTS(t time.Time) string { return t.UTC().Format("2006-01-02 15:04:05") }
// ── map[string]any coercers (the DatastoreQuery row shape) ───────────────────
//
// The ClickHouse driver decodes each column to its native Go type (uint64 for
// count()/sum(UInt*), time.Time for DateTime, string for String); these accept
// those natives so a driver/transport change can't crash a read.
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)
default:
return 0
}
}
func chStr(v any) string {
if s, ok := v.(string); ok {
return s
}
return ""
}
// chTime coerces a ClickHouse 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 ""
}
}
+143
View File
@@ -0,0 +1,143 @@
// 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"
"time"
)
// TestBuildComputeQuery_Filters proves the WHERE clause binds the range, and adds
// kind + org as POSITIONAL params only when supplied — never interpolated.
func TestBuildComputeQuery_Filters(t *testing.T) {
// Unfiltered: time bound only.
sql, args := buildComputeQuery("30d", "", "")
if len(args) != 1 {
t.Fatalf("unfiltered args = %d, want 1 (time bound)", len(args))
}
// Reads the real table the datastore stream + visor emitter write.
if !strings.Contains(sql, "FROM hanzo.compute_usage") {
t.Errorf("query must read hanzo.compute_usage; got %q", sql)
}
if !strings.Contains(sql, "GROUP BY org, app, project, kind") {
t.Errorf("query must group by (org, app, project, kind); got %q", sql)
}
if strings.Contains(sql, "AND kind = ?") || strings.Contains(sql, "AND org = ?") {
t.Errorf("unfiltered query must not add kind/org predicates; got %q", sql)
}
// kind=bot + org: two extra bound params, in order.
sql, args = buildComputeQuery("7d", "bot", "acme")
if len(args) != 3 {
t.Fatalf("kind+org args = %d, want 3", len(args))
}
if args[1] != "bot" || args[2] != "acme" {
t.Errorf("args = %v, want [<ts> bot acme]", args)
}
if !strings.Contains(sql, "AND kind = ?") || !strings.Contains(sql, "AND org = ?") {
t.Errorf("filtered query must bind kind + org; got %q", sql)
}
// OPEN SPECTRUM: an arbitrary kind (not bot/machine) filters too — no enum
// whitelist. Future Clusters/Functions lenses reuse this endpoint unchanged.
sql, args = buildComputeQuery("30d", "cluster", "")
if len(args) != 2 || args[1] != "cluster" {
t.Fatalf("kind=cluster args = %v, want [<ts> cluster]", args)
}
if !strings.Contains(sql, "AND kind = ?") {
t.Errorf("an arbitrary kind must still bind the kind predicate; got %q", sql)
}
}
// TestComputeSince maps the range enum to a lower time bound (default 30d).
func TestComputeSince(t *testing.T) {
now := time.Now().UTC()
cases := map[string]time.Duration{
"24h": 24 * time.Hour,
"7d": 7 * 24 * time.Hour,
"30d": 30 * 24 * time.Hour,
"": 30 * 24 * time.Hour, // default
"xyz": 30 * 24 * time.Hour, // unknown → default
}
for label, want := range cases {
got := now.Sub(computeSince(label))
if d := got - want; d < -2*time.Second || d > 2*time.Second {
t.Errorf("computeSince(%q) lookback = %v, want ≈%v", label, got, want)
}
}
}
// TestTerminalComputeSQL renders the terminal set as a quoted CH list.
func TestTerminalComputeSQL(t *testing.T) {
got := terminalComputeSQL()
for _, e := range []string{"'stop'", "'destroy'", "'terminated'", "'shutdown'"} {
if !strings.Contains(got, e) {
t.Errorf("terminal list missing %s; got %q", e, got)
}
}
if strings.Contains(got, "'start'") || strings.Contains(got, "'provision'") {
t.Errorf("terminal list must NOT include running states; got %q", got)
}
}
// TestComputeLeavesFromRows maps the driver's native row types (uint64 counts,
// time.Time DateTime, string dims) onto typed leaves — the honest-empty and the
// real-row paths both.
func TestComputeLeavesFromRows(t *testing.T) {
if got := computeLeavesFromRows(nil); len(got) != 0 {
t.Fatalf("nil rows → %d leaves, want 0", len(got))
}
ts := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
rows := []map[string]any{
{
"org": "acme", "app": "web", "project": "prod", "kind": "machine",
"machines": uint64(4), "active": uint64(3), "spend_cents": uint64(1200), "last_ts": ts,
},
}
got := computeLeavesFromRows(rows)
if len(got) != 1 {
t.Fatalf("rows → %d leaves, want 1", len(got))
}
l := got[0]
if l.Org != "acme" || l.App != "web" || l.Project != "prod" || l.Kind != "machine" {
t.Errorf("dims wrong: %+v", l)
}
if l.Machines != 4 || l.Active != 3 || l.SpendCents != 1200 {
t.Errorf("counts wrong: %+v", l)
}
if l.LastTs != "2026-07-01T12:00:00Z" {
t.Errorf("lastTs = %q, want 2026-07-01T12:00:00Z", l.LastTs)
}
}
// TestComputeCoercers proves the map coercers accept the driver natives + degrade.
func TestComputeCoercers(t *testing.T) {
if chInt64(uint64(7)) != 7 || chInt64(int64(7)) != 7 || chInt64(float64(7)) != 7 {
t.Error("chInt64 must accept uint64/int64/float64")
}
if chInt64("nope") != 0 || chInt64(nil) != 0 {
t.Error("chInt64 must degrade non-numerics to 0")
}
if chStr("x") != "x" || chStr(42) != "" {
t.Error("chStr must pass strings, degrade others to empty")
}
if chTime(time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) != "2026-01-02T03:04:05Z" {
t.Error("chTime must format time.Time as RFC3339 UTC")
}
if chTime(123) != "" {
t.Error("chTime must degrade non-time to empty")
}
}
+556
View File
@@ -0,0 +1,556 @@
package admin
// The CUSTOMER management surface (/v1/admin/customers*) — the operator cockpit's
// core: the live fleet customer list (incl. new self-serve signups), one-customer
// detail, and the audited management ACTIONS (grant credit, suspend, reactivate).
//
// It aggregates the SAME real upstreams the rest of admin reads — IAM for the org
// directory + user/owner/status, commerce for balance/spend/plan/ledger — and adds
// the two write levers an operator needs to run the paid cloud:
//
// - GRANT CREDIT is a real commerce Deposit (refunds/comps/support) landing in
// the org's own wallet, symmetric with the balance read.
// - SUSPEND / REACTIVATE flips IAM `isForbidden` on the org's users. That is the
// platform's REAL access lever: IAM refuses a forbidden user at login AND at
// token issuance (object/check.go + object/token_oauth.go), so a suspended
// customer cannot sign in or mint a fresh token — no new enforcement path is
// invented, and it is fully reversible.
//
// SECURITY. Every route is mounted behind s.guard (global-admin only, fail-closed)
// exactly like the read surface. The write actions REPLAY THE CALLER'S OWN global-
// admin credential to IAM (no service credential added — IAM re-checks
// IsGlobalAdmin, so admin can never mutate a boundary the caller couldn't already
// cross), and each is recorded to cloud's tamper-evident audit trail with a
// redacted BEFORE/AFTER (the AU "before/after on config-affecting change"), on top
// of the uniform request record the audit middleware already writes for every
// /v1/admin/* mutation. No customer card data is ever read or exposed here.
import (
"context"
"encoding/json"
"fmt"
"net/url"
"sort"
"strings"
"sync"
"github.com/hanzoai/cloud/audit"
"github.com/zap-proto/zip"
)
// ── wire shapes (operator contract) ──────────────────────────────────────────
// customerRow is one row in GET /v1/admin/customers — a fleet customer at a glance.
type customerRow struct {
Org string `json:"org"`
Display string `json:"display"`
OwnerEmail string `json:"ownerEmail"`
Plan string `json:"plan"`
Status string `json:"status"` // "active" | "suspended"
Users int `json:"users"`
BalanceCents int64 `json:"balanceCents"`
SpendCents int64 `json:"spendCents"`
MRRCents int64 `json:"mrrCents"`
Created string `json:"created"`
LastActive string `json:"lastActive"`
}
// customerUser is one member in the customer detail (no secrets — the AccessKey
// PRESENCE is surfaced as hasApiKey, never the key itself).
type customerUser struct {
Name string `json:"name"`
Email string `json:"email"`
IsAdmin bool `json:"isAdmin"`
Forbidden bool `json:"forbidden"`
HasAPIKey bool `json:"hasApiKey"`
LastSignin string `json:"lastSignin"`
Created string `json:"created"`
}
// customerTxn is one ledger row in the detail's top-up/usage history.
type customerTxn struct {
ID string `json:"id"`
Type string `json:"type"` // "deposit" (credit) | "withdraw" (usage)
Cents int64 `json:"cents"`
Currency string `json:"currency"`
Notes string `json:"notes,omitempty"`
Time string `json:"time"`
}
// customerDetail is GET /v1/admin/customers/:org.
type customerDetail struct {
Org string `json:"org"`
Display string `json:"display"`
OwnerEmail string `json:"ownerEmail"`
Plan string `json:"plan"`
Status string `json:"status"`
Created string `json:"created"`
BalanceCents int64 `json:"balanceCents"`
SpendCents int64 `json:"spendCents"`
MRRCents int64 `json:"mrrCents"`
APIKeys int `json:"apiKeys"`
Users []customerUser `json:"users"`
Transactions []customerTxn `json:"transactions"`
}
// ── GET /v1/admin/customers — the fleet customer list ────────────────────────
// maxCustomerConcurrency bounds the per-org enrichment fan-out so a large fleet
// does not open one upstream connection per org at once. Admin is low-QPS; 8 keeps
// latency low without hammering IAM/commerce.
const maxCustomerConcurrency = 8
func (s *svc) customers(c *zip.Ctx) error {
ctx := c.Context()
cr := callerCreds(c)
orgs, err := s.listOrgs(ctx, cr)
if err != nil {
return fail(c, err.Error())
}
rows := make([]customerRow, len(orgs))
sem := make(chan struct{}, maxCustomerConcurrency)
var wg sync.WaitGroup
for i, o := range orgs {
wg.Add(1)
sem <- struct{}{}
go func(i int, o iamOrg) {
defer wg.Done()
defer func() { <-sem }()
rows[i] = s.enrichCustomer(ctx, cr, o)
}(i, o)
}
wg.Wait()
sort.Slice(rows, func(i, j int) bool { return rows[i].Org < rows[j].Org })
return okList(c, rows, len(rows))
}
// enrichCustomer folds one org's real IAM + commerce reads into a customer row.
// Each read is best-effort: an upstream miss degrades that field to its honest
// zero/empty (never a fabricated value), so one flaky org never fails the fleet.
func (s *svc) enrichCustomer(ctx context.Context, cr creds, o iamOrg) customerRow {
subj := orgSubject(o.Name)
users, _ := s.orgUsers(ctx, cr, o.Name)
spend, credits := s.orgMoney(ctx, o.Name)
sub, _ := s.commerce.subscriptionSummary(ctx, o.Name, subj)
return customerRow{
Org: o.Name,
Display: display(o.DisplayName, o.Name),
OwnerEmail: ownerEmail(users),
Plan: sub.Plan,
Status: statusOf(users),
Users: len(users),
BalanceCents: credits,
SpendCents: spend,
MRRCents: sub.MRR,
Created: o.CreatedTime,
LastActive: lastActiveOf(users),
}
}
// ── GET /v1/admin/customers/:org — one customer's detail ─────────────────────
func (s *svc) customerDetail(c *zip.Ctx) error {
ctx := c.Context()
cr := callerCreds(c)
org := customerOrgParam(c)
if org == "" {
return fail(c, "org is required")
}
o, err := s.findOrg(ctx, cr, org)
if err != nil {
return fail(c, err.Error())
}
if o == nil {
return c.JSON(404, map[string]any{"status": "error", "msg": "customer not found", "data": nil})
}
subj := orgSubject(org)
users, _ := s.orgUsers(ctx, cr, org)
spend, credits := s.orgMoney(ctx, org)
sub, _ := s.commerce.subscriptionSummary(ctx, org, subj)
txns, _ := s.commerce.transactions(ctx, org, subj, 50)
rows := make([]customerUser, 0, len(users))
apiKeys := 0
for _, u := range users {
hasKey := strings.TrimSpace(u.AccessKey) != ""
if hasKey {
apiKeys++
}
rows = append(rows, customerUser{
Name: u.Name,
Email: u.Email,
IsAdmin: u.IsAdmin,
Forbidden: u.IsForbidden,
HasAPIKey: hasKey,
LastSignin: u.LastSigninTime,
Created: u.CreatedTime,
})
}
ledger := make([]customerTxn, 0, len(txns))
for _, t := range txns {
ledger = append(ledger, customerTxn{
ID: t.ID,
Type: t.Type,
Cents: t.Amount,
Currency: t.Currency,
Notes: t.Notes,
Time: t.CreatedAt,
})
}
return ok(c, customerDetail{
Org: org,
Display: display(o.DisplayName, org),
OwnerEmail: ownerEmail(users),
Plan: sub.Plan,
Status: statusOf(users),
Created: o.CreatedTime,
BalanceCents: credits,
SpendCents: spend,
MRRCents: sub.MRR,
APIKeys: apiKeys,
Users: rows,
Transactions: ledger,
})
}
// ── POST /v1/admin/customers/:org/credit — grant credit ──────────────────────
// creditRequest is the grant body. AmountCents is the credit to add (positive
// only — a grant, never a silent debit). Reason is the operator's justification,
// recorded in the audit trail's before/after (refund / comp / support).
type creditRequest struct {
AmountCents int64 `json:"amountCents"`
Currency string `json:"currency"`
Reason string `json:"reason"`
// Source splits the grant into the commerce ledger's two money buckets:
// - "trial" (default) — a non-cash promo/comp credit (billing/bucket
// Credit): spendable on non-premium metered usage only, NEVER
// refundable cash and NEVER paid out. A staff comp is a trial
// grant by default (we don't mint payout-able money on a comp).
// - "prepaid" — real money added to the customer's cash balance (e.g. a
// manual settlement of a wire). Refundable, GPU-eligible.
// Mapped to the commerce deposit Tags DepositKind reads (grant:* → Credit,
// bare admin-grant → Prepaid). Unknown/empty → trial (fail-closed to non-cash).
Source string `json:"source"`
}
// grantTag maps a grant source to the commerce deposit Tags that billing/bucket
// DepositKind classifies into Credit (trial) vs Prepaid (real money). Default
// (empty/unknown/"trial") is the non-cash Credit bucket — a staff comp is never
// silently minted as payout-able real money.
func grantTag(source string) (tag, normalized string) {
if strings.ToLower(strings.TrimSpace(source)) == "prepaid" {
return "admin-grant", "prepaid" // DepositKind: bare → Prepaid (real money)
}
return "grant:admin", "trial" // DepositKind: grant:* → Credit (non-cash trial)
}
// maxGrantCents caps a single grant at $100,000 — a guardrail against a fat-finger
// operator credit, not a policy limit. A larger comp is deliberate + should be
// deliberate (two grants), and the cap keeps a typo from minting a fortune.
const maxGrantCents int64 = 100 * 100 * 1000
func (s *svc) grantCredit(c *zip.Ctx) error {
org := customerOrgParam(c)
if org == "" {
return fail(c, "org is required")
}
var req creditRequest
if err := c.Bind(&req); err != nil {
return fail(c, "invalid request body")
}
return s.applyGrant(c, org, req)
}
// applyGrant is the ONE credit-write core shared by POST /v1/admin/customers/:org/credit
// (org from the path) and POST /v1/admin/grants (org from the body): validate the
// amount + target org, deposit into the org's commerce ledger (trial vs prepaid by
// source), and record the tamper-evident audit row. One path, one way to grant.
func (s *svc) applyGrant(c *zip.Ctx, org string, req creditRequest) error {
ctx := c.Context()
cr := callerCreds(c)
if req.AmountCents <= 0 {
return fail(c, "amountCents must be positive")
}
if req.AmountCents > maxGrantCents {
return fail(c, fmt.Sprintf("amountCents exceeds the %d-cent per-grant cap", maxGrantCents))
}
currency := strings.ToLower(strings.TrimSpace(req.Currency))
if currency == "" {
currency = "usd"
}
// Validate the target is a REAL org (never mint an orphan wallet on a typo).
o, err := s.findOrg(ctx, cr, org)
if err != nil {
return fail(c, err.Error())
}
if o == nil {
return c.JSON(404, map[string]any{"status": "error", "msg": "customer not found", "data": nil})
}
subj := orgSubject(org)
before, _ := s.commerce.creditsCents(ctx, org, subj)
tag, source := grantTag(req.Source)
notes := grantNote(c, req.Reason)
res, derr := s.commerce.deposit(ctx, org, subj, req.AmountCents, currency, notes, tag)
if derr != nil {
// The grant did not land — record the FAILED attempt (accountability), then
// surface the error. Never report a grant that failed as success.
s.emitAudit(c, "admin.customer.credit", "credit", org,
map[string]any{"balanceCents": before},
map[string]any{"amountCents": req.AmountCents, "currency": currency, "reason": req.Reason, "source": source, "error": derr.Error()},
audit.Outcome{Result: "error", Status: 200, Reason: "grant failed"})
return fail(c, "grant failed: "+derr.Error())
}
after, _ := s.commerce.creditsCents(ctx, org, subj)
s.emitAudit(c, "admin.customer.credit", "credit", org,
map[string]any{"balanceCents": before},
map[string]any{"balanceCents": after, "grantedCents": req.AmountCents, "currency": currency, "reason": req.Reason, "source": source, "transactionId": res.TransactionID},
audit.Outcome{Result: "success", Status: 200})
return ok(c, map[string]any{
"org": org,
"grantedCents": req.AmountCents,
"currency": currency,
"source": source,
"balanceCents": after,
"transactionId": res.TransactionID,
})
}
// ── POST /v1/admin/customers/:org/{suspend,reactivate} — access control ──────
func (s *svc) suspendCustomer(c *zip.Ctx) error { return s.setForbidden(c, true) }
func (s *svc) reactivateCustomer(c *zip.Ctx) error { return s.setForbidden(c, false) }
// setForbidden flips IAM `isForbidden` on every member of the org — suspend
// (forbidden=true) cuts login + token issuance; reactivate restores it. Each
// user's FULL object is read, the one field flipped, and written back (update-user
// replaces the row), replaying the caller's global-admin credential so IAM
// authorizes it. Best-effort per user with an aggregated result: a partial failure
// is reported honestly (affected vs failed), never masked as a clean success. The
// action is recorded with a redacted before/after user tally.
func (s *svc) setForbidden(c *zip.Ctx, forbidden bool) error {
ctx := c.Context()
cr := callerCreds(c)
org := customerOrgParam(c)
if org == "" {
return fail(c, "org is required")
}
o, err := s.findOrg(ctx, cr, org)
if err != nil {
return fail(c, err.Error())
}
if o == nil {
return c.JSON(404, map[string]any{"status": "error", "msg": "customer not found", "data": nil})
}
users, err := s.orgUsers(ctx, cr, org)
if err != nil {
return fail(c, err.Error())
}
beforeForbidden := 0
for _, u := range users {
if u.IsForbidden {
beforeForbidden++
}
}
var affected, failed []string
for _, u := range users {
id := u.Owner + "/" + u.Name
full, gerr := s.iam.getUserRaw(ctx, cr, id)
if gerr != nil {
failed = append(failed, u.Name)
continue
}
full["isForbidden"] = forbidden
if uerr := s.iam.updateUserRaw(ctx, cr, id, full); uerr != nil {
failed = append(failed, u.Name)
continue
}
affected = append(affected, u.Name)
}
action := "admin.customer.suspend"
if !forbidden {
action = "admin.customer.reactivate"
}
result := "success"
reason := ""
if len(failed) > 0 {
result = "error"
reason = fmt.Sprintf("%d user(s) not updated", len(failed))
}
s.emitAudit(c, action, "customer", org,
map[string]any{"suspended": beforeForbidden == len(users) && len(users) > 0, "forbiddenUsers": beforeForbidden, "totalUsers": len(users)},
map[string]any{"suspended": forbidden, "affected": affected, "failed": failed},
audit.Outcome{Result: result, Status: 200, Reason: reason})
return ok(c, map[string]any{
"org": org,
"suspended": forbidden,
"affected": affected,
"failed": failed,
})
}
// ── aggregation + derivation helpers ─────────────────────────────────────────
// orgUsers reads an org's members (a bounded page) as the typed subset the
// customer surface folds over. It is the ONE IAM read that yields the user count,
// the owner email, the suspend status, and the API-key presence — so a customer
// row costs a single get-users call, not four.
func (s *svc) orgUsers(ctx context.Context, cr creds, org string) ([]iamUser, error) {
q := url.Values{}
q.Set("owner", org)
q.Set("p", "1")
q.Set("pageSize", "200")
res, err := s.iam.getList(ctx, cr, "/v1/iam/get-users", q)
if err != nil {
return nil, err
}
var raw []iamUser
if len(res.rows) > 0 {
if err := json.Unmarshal(res.rows, &raw); err != nil {
return nil, fmt.Errorf("users decode: %w", err)
}
}
return raw, nil
}
// findOrg returns the IAM org by slug (nil, nil when it does not exist) so a
// management action can validate its target before acting — never credit or
// suspend an org that isn't real.
func (s *svc) findOrg(ctx context.Context, cr creds, org string) (*iamOrg, error) {
orgs, err := s.listOrgs(ctx, cr)
if err != nil {
return nil, err
}
for i := range orgs {
if orgs[i].Name == org {
return &orgs[i], nil
}
}
return nil, nil
}
// ownerEmail picks the org's admin user's email (the account owner), falling back
// to the first user with an email. Empty when no user carries one.
func ownerEmail(users []iamUser) string {
for _, u := range users {
if u.IsAdmin && strings.TrimSpace(u.Email) != "" {
return u.Email
}
}
for _, u := range users {
if strings.TrimSpace(u.Email) != "" {
return u.Email
}
}
return ""
}
// statusOf derives the suspend status: an org is "suspended" only when it has at
// least one user and EVERY user is forbidden (a partial forbid is still "active" —
// the operator sees the per-user state in the detail). Honest by construction.
func statusOf(users []iamUser) string {
if len(users) == 0 {
return "active"
}
for _, u := range users {
if !u.IsForbidden {
return "active"
}
}
return "suspended"
}
// lastActiveOf returns the most recent user sign-in across the org (RFC3339), the
// best "last active" signal available from IAM. Empty when no user has signed in.
func lastActiveOf(users []iamUser) string {
last := ""
for _, u := range users {
if u.LastSigninTime > last {
last = u.LastSigninTime
}
}
return last
}
// customerOrgParam reads + trims the :org path param.
func customerOrgParam(c *zip.Ctx) string { return strings.TrimSpace(c.Param("org")) }
// grantNote composes the deposit note from the operator's reason (bounded), so the
// commerce ledger row itself carries the justification alongside the audit trail.
func grantNote(c *zip.Ctx, reason string) string {
r := strings.TrimSpace(reason)
if len(r) > 200 {
r = r[:200]
}
by := strings.TrimSpace(c.UserEmail())
if by == "" {
by = strings.TrimSpace(c.User())
}
if r == "" {
r = "operator credit"
}
if by != "" {
return fmt.Sprintf("Admin grant by %s: %s", by, r)
}
return "Admin grant: " + r
}
// emitAudit writes ONE compliance record for a management action to cloud's
// tamper-evident trail: who (the validated global admin from the sanitized
// identity — the gate already proved it), what (action + resource), the redacted
// before/after, and the outcome. This is the "before/after on a config-affecting
// change" the request-level middleware record cannot carry (it never reads bodies).
// Best-effort: the audit MIDDLEWARE is the AU-5 fail-closed authority for the
// request; a failure here is logged loud, never silent, and never double-fails the
// response. A nil store (unconfigured deployment) is a no-op, like the middleware.
func (s *svc) emitAudit(c *zip.Ctx, action, resType, resID string, before, after any, outcome audit.Outcome) {
if s.auditStore == nil {
return
}
rec := audit.Record{
Actor: audit.Actor{Org: strings.TrimSpace(c.Org()), Sub: strings.TrimSpace(c.User()), Email: strings.TrimSpace(c.UserEmail())},
Action: action,
Resource: audit.Resource{Type: resType, ID: resID},
Auth: audit.AuthContext{Method: "jwt", IsAdmin: c.IsAdmin()},
Outcome: outcome,
UserAgent: c.Header("User-Agent"),
RequestID: c.RequestID(),
Method: c.Method(),
Path: c.Path(),
Before: audit.Redact(mustJSON(before)),
After: audit.Redact(mustJSON(after)),
}
if _, err := s.auditStore.Append(c.Context(), rec); err != nil {
c.Log().Error("admin: audit emit failed (request-level record still applies)",
"action", action, "resource", resType, "id", resID, "err", err)
}
}
// mustJSON marshals v to raw JSON for the audit before/after, returning an empty
// object on the (unexpected) marshal error rather than panicking — a metadata
// diff must never crash a money/access action.
func mustJSON(v any) json.RawMessage {
b, err := json.Marshal(v)
if err != nil {
return json.RawMessage("{}")
}
return b
}
+178
View File
@@ -0,0 +1,178 @@
package admin
import (
"context"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"strconv"
"strings"
"time"
)
// doClient reads DigitalOcean's billing API for the finance dashboard's cost
// side. DO is our PRIMARY venue (a ~$40k promotional credit); this client turns
// the customer balance + billing history into the cents the finance aggregator
// folds into gross margin and runway.
//
// Auth is a single personal-access token, DO_API_TOKEN, sourced from a KMSSecret
// on the cloud env — NEVER hard-coded (kms.hanzo.ai is the only secret store).
// When the token is unset the client is UNCONFIGURED and every read reports the
// honest not-configured state; the finance endpoint then returns
// cost.digitalocean = {configured:false} rather than a fabricated number.
//
// DIGITALOCEAN SIGN CONVENTION (authoritative, from DO's public OpenAPI spec):
// GET /v2/customers/my/balance returns three DECIMAL-DOLLAR STRINGS —
// - account_balance: most-recent billing balance, accounts-receivable sign.
// POSITIVE = the customer OWES DO; NEGATIVE = the customer
// holds CREDIT (DO owes us). Our promo credit shows as a
// NEGATIVE account_balance, so credit-remaining = -account_balance.
// - month_to_date_usage: spend in the current billing period (positive dollars).
// - month_to_date_balance = account_balance + month_to_date_usage.
//
// We convert dollars→cents once at the edge and work in int64 cents everywhere after.
type doClient struct {
base string // DO API base; https://api.digitalocean.com in prod
token string // DO_API_TOKEN (secret; never logged)
http *http.Client
}
// doAPIBase is DigitalOcean's public API host. Overridable in tests via
// newDOClientWithBase so a fake server can stand in.
const doAPIBase = "https://api.digitalocean.com"
func newDOClient(token string) *doClient {
return newDOClientWithBase(doAPIBase, token)
}
func newDOClientWithBase(base, token string) *doClient {
return &doClient{
base: strings.TrimRight(strings.TrimSpace(base), "/"),
token: strings.TrimSpace(token),
http: &http.Client{Timeout: 15 * time.Second},
}
}
// configured reports whether a DO token is present. Unconfigured → the finance
// endpoint returns cost.digitalocean = {configured:false}, never a fake balance.
func (c *doClient) configured() bool { return c != nil && c.token != "" }
// doBalance is the decoded /v2/customers/my/balance response. Dollars are parsed
// into cents at decode time so no float dollars leak past this boundary.
type doBalance struct {
// AccountBalanceCents mirrors DO's account_balance (accounts-receivable sign:
// positive = owed to DO, negative = credit we hold).
AccountBalanceCents int64
MonthToDateBalanceCents int64
MonthToDateUsageCents int64
GeneratedAt string
}
// doBalanceWire is the raw DO JSON (all money fields are decimal-dollar strings).
type doBalanceWire struct {
MonthToDateBalance string `json:"month_to_date_balance"`
AccountBalance string `json:"account_balance"`
MonthToDateUsage string `json:"month_to_date_usage"`
GeneratedAt string `json:"generated_at"`
}
// balance fetches the customer balance and converts every dollar string to cents.
func (c *doClient) balance(ctx context.Context) (doBalance, error) {
var out doBalance
if !c.configured() {
return out, fmt.Errorf("DO_API_TOKEN not configured")
}
body, err := c.get(ctx, "/v2/customers/my/balance")
if err != nil {
return out, err
}
var w doBalanceWire
if err := json.Unmarshal(body, &w); err != nil {
return out, fmt.Errorf("do balance decode: %w", err)
}
out = doBalance{
AccountBalanceCents: dollarsToCents(w.AccountBalance),
MonthToDateBalanceCents: dollarsToCents(w.MonthToDateBalance),
MonthToDateUsageCents: dollarsToCents(w.MonthToDateUsage),
GeneratedAt: strings.TrimSpace(w.GeneratedAt),
}
return out, nil
}
// doHistoryEntry is one row of the billing history (used to build the burn-down
// timeseries). amount is a decimal-dollar string in DO's wire.
type doHistoryEntry struct {
Description string `json:"description"`
AmountCents int64 `json:"-"`
Amount string `json:"amount"`
Date string `json:"date"`
Type string `json:"type"`
InvoiceID string `json:"invoice_id"`
}
// history fetches recent billing history (Invoice/Credit/Payment entries). Used
// only to render the credit burn-down series; a failure here is non-fatal (the
// finance endpoint still returns the balance-derived tiles with an empty series).
func (c *doClient) history(ctx context.Context, perPage int) ([]doHistoryEntry, error) {
if !c.configured() {
return nil, fmt.Errorf("DO_API_TOKEN not configured")
}
if perPage <= 0 {
perPage = 50
}
body, err := c.get(ctx, "/v2/customers/my/billing_history?per_page="+strconv.Itoa(perPage))
if err != nil {
return nil, err
}
var w struct {
BillingHistory []doHistoryEntry `json:"billing_history"`
}
if err := json.Unmarshal(body, &w); err != nil {
return nil, fmt.Errorf("do billing_history decode: %w", err)
}
for i := range w.BillingHistory {
w.BillingHistory[i].AmountCents = dollarsToCents(w.BillingHistory[i].Amount)
}
return w.BillingHistory, nil
}
// get performs one token-authenticated DO GET and returns the raw body.
func (c *doClient) get(ctx context.Context, path string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+c.token)
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("digitalocean unreachable: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("digitalocean status %d", resp.StatusCode)
}
return body, nil
}
// dollarsToCents parses a DO decimal-dollar string ("23.44", "-40000.00") into
// integer cents, rounding to the nearest cent. A blank/invalid string is 0 —
// DO always sends a value, so this only guards against a malformed field, and a
// zero there is the honest fallback (never a fabricated amount).
func dollarsToCents(s string) int64 {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0
}
return int64(math.Round(f * 100))
}
+331
View File
@@ -0,0 +1,331 @@
package admin
import (
"context"
"errors"
"os"
"strings"
"time"
"github.com/zap-proto/zip"
)
// errUnconfigured marks an upstream that is not wired on this deployment (no DO
// token / no commerce URL). srcOf reports it as a not-ok source so the console
// shows the honest not-configured state rather than a fabricated read.
var errUnconfigured = errors.New("not configured")
// errPartialRevenue marks a revenue read that succeeded at the org-list level but
// had one or more per-org failures — the fleet total is real but PARTIAL. srcOf
// reports it as a not-ok source so the console shows a degraded state rather than
// presenting an under-count as authoritative.
var errPartialRevenue = errors.New("partial: one or more org revenue reads failed")
// ── /v1/admin/finance — SaaS business/finance dashboard (FinanceData) ─────────
//
// The profitability panel the Hanzo Admin Console renders on admin.hanzo.ai: what
// we pay every vendor (COGS), what we earn, the resulting gross margin, how fast
// we're burning the DigitalOcean promo credit, and the runway that credit + burn
// imply. It is GLOBAL-ADMIN ONLY (s.guard) — financial data is Hanzo-internal and
// must never reach a customer or tenant-admin.
//
// Like the rest of admin it FABRICATES NOTHING and it OWNS NO cost logic. COGS is
// the SINGLE source of truth in commerce (GET /v1/costs: DigitalOcean compute +
// the LLM providers we resell) — cloud CONSUMES it, never re-reads a vendor's
// billing API to derive a cost, so the margin uses the whole multi-vendor COGS.
// Revenue + MRR come from commerce billing (honest zeros when unreachable). The
// one direct vendor read that remains is the DigitalOcean promo-CREDIT balance +
// burn-down history — an ORTHOGONAL treasury view (how long the credit lasts), NOT
// a COGS: commerce tracks what we SPEND with DO (the compute line), not our prepaid
// credit balance, so it can't provide it. The derived margin/runway math is a pure
// function (computeFinance) with a unit test proving the numbers and every
// unconfigured path.
// financeData is the full /v1/admin/finance aggregate (FinanceData).
type financeData struct {
Cost financeCost `json:"cost"`
Revenue financeRevenue `json:"revenue"`
Derived financeDerived `json:"derived"`
GeneratedAt string `json:"generatedAt"`
Sources []sourceStatus `json:"sources"`
}
// financeCost is the platform COGS view — what WE pay our vendors. Its authority
// is commerce GET /v1/costs (the SINGLE vendor-COGS source of truth): TotalCents is
// the whole-platform COGS the margin math folds, and Vendors is the per-vendor
// breakdown (DigitalOcean compute + each LLM provider we resell) the console
// renders as a donut. Configured is false (and every number 0) when commerce
// /v1/costs is unreachable — the console then shows the honest not-configured state.
//
// DigitalOcean here is an ORTHOGONAL treasury view (promo-credit remaining + the
// burn-down series), NOT part of COGS: its month-to-date spend is NO LONGER the
// margin cost (TotalCents is) — it feeds only the runway projection. Commerce owns
// the DO compute COGS line; this is our prepaid-credit balance, which commerce
// does not track, so it stays a direct DO account read.
type financeCost struct {
Configured bool `json:"configured"`
Error string `json:"error,omitempty"`
Period string `json:"period"`
TotalCents int64 `json:"totalCents"`
Vendors []vendorCost `json:"vendors"`
DigitalOcean doCost `json:"digitalocean"`
}
// doCost is the DigitalOcean credit + spend view. When Configured is false every
// number is zero and the console renders the honest "connect DO_API_TOKEN" state.
type doCost struct {
Configured bool `json:"configured"`
Error string `json:"error,omitempty"`
CreditRemainingCents int64 `json:"creditRemainingCents"`
MonthToDateSpendCents int64 `json:"monthToDateSpendCents"`
AvgDailyBurnCents int64 `json:"avgDailyBurnCents"`
AccountBalanceCents int64 `json:"accountBalanceCents"`
GeneratedAt string `json:"generatedAt,omitempty"`
History []doHistoryPoint `json:"history"`
}
// doHistoryPoint is one credit burn-down series point (usage charge over time).
type doHistoryPoint struct {
Date string `json:"date"`
AmountCents int64 `json:"amountCents"`
Type string `json:"type"`
Description string `json:"description"`
}
// financeRevenue is the commerce revenue view (all money in USD cents).
type financeRevenue struct {
Configured bool `json:"configured"`
TotalRevenueCents int64 `json:"totalRevenueCents"`
MRRCents int64 `json:"mrrCents"`
CreditsConsumedCents int64 `json:"creditsConsumedCents"`
}
// financeDerived is the pure profitability math. Runway is a pointer so it can be
// null (no honest runway when burn is zero or DO is unconfigured).
type financeDerived struct {
GrossMarginCents int64 `json:"grossMarginCents"`
GrossMarginPct float64 `json:"grossMarginPct"`
RunwayDays *float64 `json:"runwayDays"`
Profitable bool `json:"profitable"`
}
// financeInput is the raw material computeFinance folds into financeData. The
// handler fills cost from the commerce COGS read (+ the DO-credit treasury view)
// and revenue from commerce billing; the pure function does the math so the
// derivation is unit-testable in isolation.
type financeInput struct {
cost financeCost
revenue financeRevenue
generatedAt string
sources []sourceStatus
}
// computeFinance is the PURE derivation: given the multi-vendor COGS view and the
// commerce revenue view, it computes gross margin, margin %, runway, and
// profitability. No I/O, no clock, no globals — everything it needs is in
// financeInput, which is exactly why the finance math can be tested without any
// network.
//
// grossMarginCents = revenue - COGS(total, all vendors)
// grossMarginPct = grossMargin / revenue * 100 (0 when revenue is 0)
// runwayDays = DO creditRemaining / DO avgDailyBurn (nil when burn 0 or DO off)
// profitable = revenue > COGS
func computeFinance(in financeInput) financeData {
cost := in.cost.TotalCents
rev := in.revenue.TotalRevenueCents
margin := rev - cost
var marginPct float64
if rev > 0 {
marginPct = (float64(margin) / float64(rev)) * 100
}
// Runway is the DO promo-credit treasury projection (orthogonal to COGS): how
// many days the remaining credit lasts at the current DO burn. Nil when DO is
// off or burn is 0 — never a fabricated infinity.
do := in.cost.DigitalOcean
var runway *float64
if do.Configured && do.AvgDailyBurnCents > 0 {
d := float64(do.CreditRemainingCents) / float64(do.AvgDailyBurnCents)
runway = &d
}
return financeData{
Cost: in.cost,
Revenue: in.revenue,
Derived: financeDerived{
GrossMarginCents: margin,
GrossMarginPct: marginPct,
RunwayDays: runway,
Profitable: rev > cost,
},
GeneratedAt: in.generatedAt,
Sources: in.sources,
}
}
// finance answers GET /v1/admin/finance. It reads the multi-vendor COGS from
// commerce /v1/costs, the DO promo-credit/burn-down treasury view, and the fleet
// commerce revenue, then hands them to computeFinance. Global-admin only (mounted
// under s.guard); no principal / tenant-admin / forged header → 403 before this
// handler ever runs.
func (s *svc) finance(c *zip.Ctx) error {
ctx := c.Context()
cr := callerCreds(c)
now := time.Now().UTC().Format(time.RFC3339)
period := time.Now().UTC().Format("2006-01")
var sources []sourceStatus
// ── COGS: commerce /v1/costs (the single vendor-COGS source of truth) ──
// cloud CONSUMES the multi-vendor breakdown (DigitalOcean compute + the LLM
// providers we resell) — it does NOT re-derive any vendor cost. TotalCents is
// the margin cost. Honest not-configured when commerce is unreachable.
cost := financeCost{Period: period}
if s.commerce.configured() {
report, err := s.commerce.costs(ctx, period)
if err != nil {
cost.Error = err.Error()
sources = append(sources, srcOf("commerce-costs", err, 0, now))
} else {
cost.Configured = true
cost.TotalCents = report.TotalCents
cost.Vendors = report.Vendors
if report.Period != "" {
cost.Period = report.Period
}
sources = append(sources, srcOf("commerce-costs", nil, len(report.Vendors), now))
}
} else {
cost.Error = "commerce /v1/costs not configured"
sources = append(sources, srcOf("commerce-costs", errUnconfigured, 0, now))
}
if cost.Vendors == nil {
cost.Vendors = []vendorCost{}
}
// ── DigitalOcean promo-credit / runway (orthogonal treasury view) ──
// The one direct vendor read that remains: our DO prepaid-credit balance +
// burn-down history, which commerce does not track. Its MTD spend feeds ONLY
// the runway projection — it is NOT the margin cost (that is cost.TotalCents).
do := doCost{Configured: s.do.configured()}
if !s.do.configured() {
do.Error = "DO_API_TOKEN not configured"
sources = append(sources, srcOf("digitalocean", errUnconfigured, 0, now))
} else {
bal, err := s.do.balance(ctx)
if err != nil {
do.Error = err.Error()
sources = append(sources, srcOf("digitalocean", err, 0, now))
} else {
// creditRemaining = -account_balance clamped at 0 (negative account
// balance = credit we hold; a positive balance means we owe DO → 0 credit).
credit := -bal.AccountBalanceCents
if credit < 0 {
credit = 0
}
do.CreditRemainingCents = credit
do.MonthToDateSpendCents = bal.MonthToDateUsageCents
do.AccountBalanceCents = bal.AccountBalanceCents
do.GeneratedAt = bal.GeneratedAt
do.AvgDailyBurnCents = avgDailyBurnCents(bal.MonthToDateUsageCents, time.Now().UTC())
do.History = s.doHistory(ctx)
sources = append(sources, srcOf("digitalocean", nil, 1, now))
}
}
if do.History == nil {
do.History = []doHistoryPoint{}
}
cost.DigitalOcean = do
// ── Revenue: commerce (fleet-wide) ────────────────────────────────────
// Configured means the revenue source was actually READ, not merely wired: on a
// transient IAM/commerce failure it stays FALSE so computeFinance and the console
// never fabricate a negative margin / red "burning" alarm from a fake zero.
rev := financeRevenue{}
if !s.commerce.configured() {
sources = append(sources, srcOf("commerce", errUnconfigured, 0, now))
} else if orgs, orgErr := s.listOrgs(ctx, cr); orgErr != nil {
// The revenue source is unreadable → honest not-configured, never a zero
// that would flip the margin negative on an upstream hiccup.
sources = append(sources, srcOf("commerce", orgErr, 0, now))
} else {
var totalRev, mrr int64
partial := false
for _, o := range orgs {
subj := orgSubject(o.Name)
if r, e := s.commerce.usageRollup(ctx, o.Name, subj); e == nil {
totalRev += r.ConsumedCents
} else {
partial = true
}
if m, e := s.commerce.mrrCents(ctx, o.Name, subj); e == nil {
mrr += m
} else {
partial = true
}
}
// Realized revenue = what customers consumed (metered spend). Credits
// consumed mirrors that same figure at the fleet level.
rev.Configured = true
rev.TotalRevenueCents = totalRev
rev.CreditsConsumedCents = totalRev
rev.MRRCents = mrr
// A per-org read failure means the fleet total is PARTIAL — mark the source
// not-ok so the console shows a degraded state, never presents an under-count
// as authoritative.
if partial {
sources = append(sources, srcOf("commerce", errPartialRevenue, len(orgs), now))
} else {
sources = append(sources, srcOf("commerce", nil, len(orgs), now))
}
}
return ok(c, computeFinance(financeInput{
cost: cost,
revenue: rev,
generatedAt: now,
sources: sources,
}))
}
// doHistory reads DO billing history into the burn-down series (best-effort:
// a failure yields an empty series, never a fabricated trend). Only usage-side
// entries (Invoice/charges) shape the burn-down; the series stays honest-empty
// when history is unavailable.
func (s *svc) doHistory(ctx context.Context) []doHistoryPoint {
entries, err := s.do.history(ctx, 60)
if err != nil {
return []doHistoryPoint{}
}
pts := make([]doHistoryPoint, 0, len(entries))
for _, e := range entries {
pts = append(pts, doHistoryPoint{
Date: e.Date,
AmountCents: e.AmountCents,
Type: e.Type,
Description: e.Description,
})
}
return pts
}
// avgDailyBurnCents derives the average daily DO burn from month-to-date usage:
// month-to-date spend divided by the number of elapsed days in the current month
// (at least 1, so day 1 doesn't divide by zero). This is the honest run-rate the
// runway projection uses — a real read (MTD usage) over real elapsed time, never
// an invented rate.
func avgDailyBurnCents(monthToDateSpendCents int64, now time.Time) int64 {
day := now.Day()
if day < 1 {
day = 1
}
return monthToDateSpendCents / int64(day)
}
// doTokenFromEnv reads the DigitalOcean token from the environment. Sourced from
// a KMSSecret on the cloud deployment (DO_API_TOKEN) — never hard-coded.
func doTokenFromEnv() string {
return strings.TrimSpace(os.Getenv("DO_API_TOKEN"))
}
+431
View File
@@ -0,0 +1,431 @@
package admin
import (
"encoding/json"
"io"
"math"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// TestComputeFinance_Math is the PURE derivation proof: given a fixed multi-vendor
// COGS view and commerce revenue view, gross margin, margin %, runway, and
// profitability are exactly the arithmetic the dashboard promises — no I/O. The
// margin cost is the COGS total (all vendors); runway is the DO-credit projection.
func TestComputeFinance_Math(t *testing.T) {
// COGS: $30k total across vendors. DO treasury: $40k credit, $10k left, burning
// $1k/day. Revenue: $35k realized.
in := financeInput{
cost: financeCost{
Configured: true,
TotalCents: 3_000_000, // $30,000 COGS across all vendors (the margin cost)
DigitalOcean: doCost{
Configured: true,
CreditRemainingCents: 1_000_000, // $10,000 promo credit remaining
AvgDailyBurnCents: 100_000, // $1,000/day DO burn (runway input)
},
},
revenue: financeRevenue{
Configured: true,
TotalRevenueCents: 3_500_000, // $35,000 revenue
MRRCents: 500_000, // $5,000 MRR
},
}
got := computeFinance(in)
// margin = 35,000 - 30,000 = $5,000
if got.Derived.GrossMarginCents != 500_000 {
t.Errorf("grossMarginCents = %d, want 500000 ($5,000)", got.Derived.GrossMarginCents)
}
// marginPct = 5,000 / 35,000 * 100 = 14.2857…%
if math.Abs(got.Derived.GrossMarginPct-14.285714) > 0.0001 {
t.Errorf("grossMarginPct = %f, want ≈14.2857", got.Derived.GrossMarginPct)
}
// runway = 10,000 / 1,000 = 10 days
if got.Derived.RunwayDays == nil || math.Abs(*got.Derived.RunwayDays-10) > 1e-9 {
t.Errorf("runwayDays = %v, want 10", got.Derived.RunwayDays)
}
// revenue (35k) > cost (30k) → profitable this month.
if !got.Derived.Profitable {
t.Error("profitable must be true when revenue > cost")
}
}
// TestComputeFinance_BurningFasterThanEarning proves the red state: cost exceeds
// revenue → negative margin, not profitable, runway still finite.
func TestComputeFinance_BurningFasterThanEarning(t *testing.T) {
in := financeInput{
cost: financeCost{
Configured: true,
TotalCents: 4_000_000, // $40,000 COGS (the margin cost)
DigitalOcean: doCost{
Configured: true,
CreditRemainingCents: 2_000_000, // $20,000 left
AvgDailyBurnCents: 200_000, // $2,000/day
},
},
revenue: financeRevenue{Configured: true, TotalRevenueCents: 1_000_000}, // $10,000
}
got := computeFinance(in)
if got.Derived.GrossMarginCents != -3_000_000 { // 10k - 40k = -30k
t.Errorf("grossMarginCents = %d, want -3000000", got.Derived.GrossMarginCents)
}
if got.Derived.Profitable {
t.Error("must NOT be profitable when cost > revenue")
}
// runway = 20,000 / 2,000 = 10 days
if got.Derived.RunwayDays == nil || math.Abs(*got.Derived.RunwayDays-10) > 1e-9 {
t.Errorf("runwayDays = %v, want 10", got.Derived.RunwayDays)
}
}
// TestComputeFinance_HonestUnconfigured proves the DO-off path: no fabricated
// credit/burn, runway is NULL (not zero), margin is just revenue (cost 0), and
// margin % is 0 when revenue is 0.
func TestComputeFinance_HonestUnconfigured(t *testing.T) {
in := financeInput{
cost: financeCost{Configured: false, DigitalOcean: doCost{Configured: false}}, // commerce + DO both off
revenue: financeRevenue{Configured: false},
}
got := computeFinance(in)
if got.Cost.DigitalOcean.Configured {
t.Error("DO must report configured:false when the token is unset")
}
if got.Cost.DigitalOcean.CreditRemainingCents != 0 || got.Cost.DigitalOcean.AvgDailyBurnCents != 0 {
t.Error("unconfigured DO must not fabricate credit/burn")
}
// runway is null (nil) — no honest runway without a burn rate.
if got.Derived.RunwayDays != nil {
t.Errorf("runwayDays must be nil when DO is unconfigured, got %v", *got.Derived.RunwayDays)
}
if got.Derived.GrossMarginPct != 0 {
t.Errorf("grossMarginPct must be 0 when revenue is 0, got %f", got.Derived.GrossMarginPct)
}
// revenue 0 is not > cost 0 → not profitable.
if got.Derived.Profitable {
t.Error("zero revenue and zero cost is not profitable")
}
}
// TestComputeFinance_ZeroBurnNullRunway proves runway is null when DO is
// configured but burn is zero (no division by zero, no fabricated infinity).
func TestComputeFinance_ZeroBurnNullRunway(t *testing.T) {
in := financeInput{
cost: financeCost{Configured: true, TotalCents: 0, DigitalOcean: doCost{Configured: true, CreditRemainingCents: 4_000_000, AvgDailyBurnCents: 0}},
revenue: financeRevenue{Configured: true, TotalRevenueCents: 100_000},
}
got := computeFinance(in)
if got.Derived.RunwayDays != nil {
t.Errorf("runwayDays must be nil when burn is 0, got %v", *got.Derived.RunwayDays)
}
}
// TestAvgDailyBurn_ElapsedDays proves the run-rate is MTD spend over elapsed
// days (≥1), a real read over real time — never an invented rate.
func TestAvgDailyBurn_ElapsedDays(t *testing.T) {
// $3,000 MTD on the 10th → $300/day.
got := avgDailyBurnCents(300_000, time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC))
if got != 30_000 {
t.Errorf("avgDailyBurn = %d, want 30000 ($300/day)", got)
}
// Day 1 must not divide by zero.
if avgDailyBurnCents(50_000, time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)) != 50_000 {
t.Error("day-1 burn must be the full MTD spend (divide by 1)")
}
}
// TestDollarsToCents proves the DO decimal-dollar → cents parsing, including the
// negative (credit) case and the blank fallback.
func TestDollarsToCents(t *testing.T) {
cases := []struct {
in string
want int64
}{
{"23.44", 2344},
{"-40000.00", -4_000_000}, // promo credit held (negative account_balance)
{"12.23", 1223},
{"0", 0},
{"", 0},
{" 5.5 ", 550},
{"garbage", 0},
}
for _, c := range cases {
if got := dollarsToCents(c.in); got != c.want {
t.Errorf("dollarsToCents(%q) = %d, want %d", c.in, got, c.want)
}
}
}
// TestMonthlyNormalizedCents proves annual/monthly normalization for MRR.
func TestMonthlyNormalizedCents(t *testing.T) {
if got := monthlyNormalizedCents(12_000, "year"); got != 1_000 {
t.Errorf("yearly $120 → monthly = %d, want 1000", got)
}
if got := monthlyNormalizedCents(2_000, "month"); got != 2_000 {
t.Errorf("monthly must pass through, got %d", got)
}
if got := monthlyNormalizedCents(2_000, ""); got != 2_000 {
t.Errorf("unknown interval must be treated as monthly, got %d", got)
}
}
// newFakeDO serves the DO billing API with fixed decimal-dollar strings so the
// finance aggregation is deterministic. account_balance is NEGATIVE (credit held).
func newFakeDO() *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "/customers/my/balance"):
// $10,000 credit remaining (account_balance = -10000.00), $3,000 MTD usage.
io.WriteString(w, `{"month_to_date_balance":"-7000.00","account_balance":"-10000.00","month_to_date_usage":"3000.00","generated_at":"2026-07-15T00:00:00Z"}`)
case strings.HasSuffix(r.URL.Path, "/customers/my/billing_history"):
io.WriteString(w, `{"billing_history":[
{"description":"Invoice for June 2026","amount":"2800.00","date":"2026-06-01T00:00:00Z","type":"Invoice","invoice_id":"1"},
{"description":"Promo credit","amount":"-40000.00","date":"2026-05-01T00:00:00Z","type":"Credit","invoice_id":""}
],"meta":{"total":2}}`)
default:
w.WriteHeader(404)
}
}))
}
// TestFinance_RealAggregation drives GET /v1/admin/finance against fake DO +
// commerce and proves the whole pipe: DO credit/spend/burn derived with the
// right sign, commerce revenue + MRR summed fleet-wide, and the derived margin/
// runway from computeFinance — all in one envelope.
func TestFinance_RealAggregation(t *testing.T) {
iam := newFakeIAM()
defer iam.server.Close()
commerce := newFakeCommerceFinance()
defer commerce.Close()
do := newFakeDO()
defer do.Close()
doReq, s, _ := mountSvc(t, iam.server.URL, commerce.URL, "")
s.do = newDOClientWithBase(do.URL, "test-do-token") // configured DO client
admin := map[string]string{
"X-User-IsAdmin": "true", "X-Org-Id": "admin",
"Authorization": "Bearer operator-jwt", "Cookie": "iam_access_token=operator-jwt",
}
resp, body := doReq("GET", "/v1/admin/finance", admin)
if resp.StatusCode != http.StatusOK {
t.Fatalf("finance: got %d (body=%s)", resp.StatusCode, body)
}
var env struct {
Status string `json:"status"`
Data financeData `json:"data"`
}
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode: %v", err)
}
if env.Status != "ok" {
t.Fatalf("finance status = %q, want ok", env.Status)
}
d := env.Data
// ── COGS: commerce /v1/costs — DO compute $3,000 + OpenAI $500 = $3,500 total,
// the multi-vendor breakdown that is now the margin cost (not the DO MTD spend).
if !d.Cost.Configured {
t.Fatal("commerce COGS must be configured in this test")
}
if d.Cost.TotalCents != 350_000 {
t.Errorf("cost.totalCents = %d, want 350000 ($3,500 multi-vendor COGS)", d.Cost.TotalCents)
}
if len(d.Cost.Vendors) != 2 {
t.Fatalf("cost.vendors must carry 2 lines (DO + OpenAI), got %d", len(d.Cost.Vendors))
}
if d.Cost.Vendors[0].Vendor == "" || d.Cost.Vendors[0].AmountCents == 0 {
t.Errorf("vendor line must carry a vendor + amount, got %+v", d.Cost.Vendors[0])
}
// ── DO treasury: credit = -account_balance = $10,000; MTD usage $3,000 (runway
// input, NOT the margin cost); burn-down history preserved.
if !d.Cost.DigitalOcean.Configured {
t.Fatal("DO must be configured in this test")
}
if d.Cost.DigitalOcean.CreditRemainingCents != 1_000_000 {
t.Errorf("creditRemaining = %d, want 1000000 ($10,000 = -account_balance)", d.Cost.DigitalOcean.CreditRemainingCents)
}
if d.Cost.DigitalOcean.MonthToDateSpendCents != 300_000 {
t.Errorf("monthToDateSpend = %d, want 300000 ($3,000)", d.Cost.DigitalOcean.MonthToDateSpendCents)
}
if d.Cost.DigitalOcean.AccountBalanceCents != -1_000_000 {
t.Errorf("accountBalance = %d, want -1000000 (negative = credit held)", d.Cost.DigitalOcean.AccountBalanceCents)
}
if len(d.Cost.DigitalOcean.History) != 2 {
t.Errorf("history must carry 2 entries, got %d", len(d.Cost.DigitalOcean.History))
}
// ── Commerce revenue: 2 orgs × $150 consumed = $300; MRR 2 × $50 = $100.
if !d.Revenue.Configured {
t.Fatal("commerce must be configured in this test")
}
if d.Revenue.TotalRevenueCents != 30_000 {
t.Errorf("totalRevenue = %d, want 30000 (2 orgs × $150)", d.Revenue.TotalRevenueCents)
}
if d.Revenue.MRRCents != 10_000 {
t.Errorf("MRR = %d, want 10000 (2 orgs × $50/mo active sub)", d.Revenue.MRRCents)
}
// ── Derived: margin = revenue 30,000 - COGS 350,000 = -320,000 (COGS > revenue).
if d.Derived.GrossMarginCents != -320_000 {
t.Errorf("grossMargin = %d, want -320000 (revenue 30k - COGS 350k)", d.Derived.GrossMarginCents)
}
if d.Derived.Profitable {
t.Error("not profitable: revenue $300 < COGS $3,500")
}
// runway present because DO configured + burn > 0.
if d.Derived.RunwayDays == nil {
t.Error("runwayDays must be present when DO burn > 0")
}
// Every source reported (digitalocean + commerce both ok).
src := map[string]sourceStatus{}
for _, x := range d.Sources {
src[x.Name] = x
}
if !src["digitalocean"].OK {
t.Errorf("digitalocean source must be ok: %+v", src["digitalocean"])
}
if !src["commerce"].OK {
t.Errorf("commerce source must be ok: %+v", src["commerce"])
}
if !src["commerce-costs"].OK {
t.Errorf("commerce-costs source must be ok: %+v", src["commerce-costs"])
}
}
// TestFinance_HonestUnconfiguredDO proves the ONE thing the user must provide:
// with no DO_API_TOKEN the endpoint returns cost.digitalocean = {configured:false},
// zero credit/burn, null runway — the honest state, never a fabricated $40k.
func TestFinance_HonestUnconfiguredDO(t *testing.T) {
iam := newFakeIAM()
defer iam.server.Close()
commerce := newFakeCommerceFinance()
defer commerce.Close()
doReq, _, _ := mountSvc(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)
if resp.StatusCode != http.StatusOK {
t.Fatalf("finance: got %d (body=%s)", resp.StatusCode, body)
}
var env struct {
Data financeData `json:"data"`
}
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode: %v", err)
}
d := env.Data
// COGS still flows from commerce even with the DO treasury read off — the DO
// compute COGS line belongs to commerce /v1/costs, decoupled from our DO credit
// read, so a missing DO_API_TOKEN never blanks the margin.
if !d.Cost.Configured || d.Cost.TotalCents == 0 || len(d.Cost.Vendors) == 0 {
t.Errorf("commerce COGS must remain configured with vendors when DO treasury is off: %+v", d.Cost)
}
if d.Cost.DigitalOcean.Configured {
t.Error("DO must report configured:false with no token")
}
if d.Cost.DigitalOcean.CreditRemainingCents != 0 || d.Cost.DigitalOcean.AvgDailyBurnCents != 0 {
t.Error("unconfigured DO must not fabricate credit/burn")
}
if d.Cost.DigitalOcean.Error == "" {
t.Error("unconfigured DO must carry an honest error string")
}
if d.Derived.RunwayDays != nil {
t.Errorf("runway must be null when DO is unconfigured, got %v", *d.Derived.RunwayDays)
}
// History must be an empty array (renders as an empty chart), never nil/fabricated.
if d.Cost.DigitalOcean.History == nil {
t.Error("history must be [] (empty array), not null")
}
// Commerce still reports its real revenue even with DO off.
if d.Revenue.TotalRevenueCents != 30_000 {
t.Errorf("commerce revenue must still be real with DO off, got %d", d.Revenue.TotalRevenueCents)
}
// The digitalocean source must be present and NOT ok (honest not-configured).
var doSrc *sourceStatus
for i := range d.Sources {
if d.Sources[i].Name == "digitalocean" {
doSrc = &d.Sources[i]
}
}
if doSrc == nil || doSrc.OK {
t.Errorf("digitalocean source must be present and not-ok when unconfigured: %+v", doSrc)
}
}
// TestFinance_RevenueSourceDown_NoFabrication proves the anti-fabrication property
// (RED MED-1): when the revenue source (IAM listOrgs) is unreadable but commerce
// COGS is fine, revenue reports configured:false (never a fake zero), so the board
// cannot render a fabricated negative margin / "burning" alarm. COGS flows on.
func TestFinance_RevenueSourceDown_NoFabrication(t *testing.T) {
commerce := newFakeCommerceFinance()
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, "")
admin := map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "admin"}
resp, body := doReq("GET", "/v1/admin/finance", admin)
if resp.StatusCode != http.StatusOK {
t.Fatalf("finance: got %d (body=%s)", resp.StatusCode, body)
}
var env struct {
Data financeData `json:"data"`
}
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode: %v", err)
}
d := env.Data
// Revenue source unreadable → honest not-configured, NOT a fabricated zero.
if d.Revenue.Configured {
t.Error("revenue must report configured:false when the IAM org list is unreadable")
}
if d.Revenue.TotalRevenueCents != 0 {
t.Errorf("unreadable revenue must be 0, got %d", d.Revenue.TotalRevenueCents)
}
// COGS is independent — still configured from commerce /v1/costs.
if !d.Cost.Configured || d.Cost.TotalCents == 0 {
t.Errorf("COGS must remain configured when the revenue source is down: %+v", d.Cost)
}
// The commerce (revenue) source is present and NOT ok — honest degraded state.
var revSrc *sourceStatus
for i := range d.Sources {
if d.Sources[i].Name == "commerce" {
revSrc = &d.Sources[i]
}
}
if revSrc == nil || revSrc.OK {
t.Errorf("commerce revenue source must be present and not-ok when unreadable: %+v", revSrc)
}
}
// newFakeCommerceFinance serves the vendor-COGS god-view (/v1/costs) plus
// usage-rollup ($150 consumed) and subscriptions (one active $50/mo sub) so the
// finance COGS + revenue + MRR aggregation is deterministic.
func newFakeCommerceFinance() *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "/costs"):
// The vendor-COGS god-view: DO compute $3,000 + OpenAI $500 = $3,500 total.
io.WriteString(w, `{"period":"2026-07","vendors":[
{"vendor":"digitalocean","service":"compute","amountCents":300000,"source":"actual","currency":"usd"},
{"vendor":"openai","service":"llm-inference","amountCents":50000,"source":"actual","currency":"usd"}
],"totalCents":350000,"currency":"usd"}`)
case strings.HasSuffix(r.URL.Path, "/usage-rollup"):
io.WriteString(w, `{"consumedCents":15000,"overageCents":0,"balance":{"balanceCents":0,"availableCents":0}}`)
case strings.HasSuffix(r.URL.Path, "/subscriptions"):
io.WriteString(w, `{"subscriptions":[
{"status":"active","plan":{"price":5000,"currency":"usd","interval":"month"}},
{"status":"canceled","plan":{"price":9900,"currency":"usd","interval":"month"}}
]}`)
default:
w.WriteHeader(404)
}
}))
}
+25
View File
@@ -0,0 +1,25 @@
package admin
// The PLATFORM CONTROL PLANE board (/v1/admin/flags) — every runtime LAUNCH / RELEASE
// switch (waitlist, public signup, subsystem activation, gateway limits, network ids)
// with its LIVE value, evaluated through the Hanzo Insights feature-flag engine
// (clients/featureflags → insights rust/feature-flags). Global-admin only (mounted
// behind s.guard, like every /v1/admin/* route).
//
// ONE flag engine, not two. Insights OWNS the flag definitions, targeting, percentage
// rollout, and the change/activity log. This endpoint READS the switches for the
// cockpit and hands the operator the deep-links to the Insights flag MANAGER (where a
// switch is toggled / rolled out / cohort-targeted) and its ACTIVITY LOG (the native
// change audit). A flip there is hot — the consuming subsystems re-read within one
// evaluation TTL, no redeploy. The board is read-only here on purpose: management is
// the native Insights UI (the one-and-one-way flag surface), surfaced in the cockpit.
import (
"github.com/hanzoai/cloud/clients/featureflags"
"github.com/zap-proto/zip"
)
// flags answers GET /v1/admin/flags — the platform control-plane read board.
func (s *svc) flags(c *zip.Ctx) error {
return ok(c, featureflags.Board())
}
+162
View File
@@ -0,0 +1,162 @@
package admin
import (
"encoding/json"
"strconv"
"strings"
"github.com/hanzoai/cloud/audit"
"github.com/zap-proto/zip"
)
// The GRANTS surface (/v1/admin/grants) — the operator cockpit's credit-grant
// ledger. A grant is a staff-issued credit (a comp/refund/promo) written to a
// customer org's commerce ledger by POST /v1/admin/customers/:org/credit (or
// POST /v1/admin/grants). Every grant is recorded in cloud's tamper-evident audit
// store as action "admin.customer.credit" (resource type "credit", resource id =
// the target org), so THIS view is a projection of that trail — the ONE source of
// truth for "who granted what to whom, when, and from which bucket". Global-admin
// only (mounted behind s.guard, like every /v1/admin/* route).
//
// A grant's `source` splits it into the two commerce money buckets:
// - trial — a non-cash promo/comp credit (never refundable cash, never paid
// out, non-premium usage only).
// - prepaid — real money added to the customer's cash balance.
// The staff-issue default is trial (we never silently mint payout-able money).
// grantRow is one row in GET /v1/admin/grants.
type grantRow struct {
Org string `json:"org"`
AmountCents int64 `json:"amountCents"`
Currency string `json:"currency"`
Source string `json:"source"` // "trial" | "prepaid"
Reason string `json:"reason,omitempty"`
Actor string `json:"actor"` // staff email (or sub) who issued it
CreatedAt string `json:"createdAt"`
TransactionID string `json:"transactionId,omitempty"`
Result string `json:"result"` // success | error
}
// grantAfter is the audit record's After payload emitted by applyGrant. Success
// carries grantedCents+transactionId; a failed attempt carries amountCents+error.
type grantAfter struct {
GrantedCents int64 `json:"grantedCents"`
AmountCents int64 `json:"amountCents"`
Currency string `json:"currency"`
Reason string `json:"reason"`
Source string `json:"source"`
TransactionID string `json:"transactionId"`
}
// grants answers GET /v1/admin/grants — the credit-grant ledger across ALL orgs,
// newest first, projected from the audit trail. Filters: ?org, ?result
// (success|error), ?limit. Honest empty when no local audit store is configured
// (the grants view is audit-backed; without the store there is no grant history
// to read — never a fabricated list).
//
// GET /v1/admin/grants
func (s *svc) grants(c *zip.Ctx) error {
if s.auditStore == nil {
return c.JSON(200, map[string]any{
"status": "ok",
"msg": "grant history is unavailable (no local audit store configured on this deployment)",
"data": []grantRow{},
"data2": 0,
})
}
limit := 200
if v := strings.TrimSpace(c.Query("limit")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
f := audit.Filter{
Resource: "credit", // res_type of every grant audit row
Action: "admin.customer.credit",
Org: strings.TrimSpace(c.Query("org")), // actor org (rarely filtered)
Result: strings.TrimSpace(c.Query("result")), // "" = all (success+error)
Limit: limit,
}
rows, total, err := s.auditStore.Query(c.Context(), f)
if err != nil {
return fail(c, err.Error())
}
out := make([]grantRow, 0, len(rows))
for _, r := range rows {
var a grantAfter
if len(r.After) > 0 {
_ = json.Unmarshal(r.After, &a)
}
amount := a.GrantedCents
if amount == 0 {
amount = a.AmountCents
}
currency := a.Currency
if currency == "" {
currency = "usd"
}
source := a.Source
if source == "" {
source = "trial" // legacy rows predate the source field; a comp is trial
}
actor := r.Actor.Email
if actor == "" {
actor = r.Actor.Sub
}
out = append(out, grantRow{
Org: r.Resource.ID, // the TARGET org the credit landed on
AmountCents: amount,
Currency: currency,
Source: source,
Reason: a.Reason,
Actor: actor,
CreatedAt: r.Time.UTC().Format("2006-01-02T15:04:05Z07:00"),
TransactionID: a.TransactionID,
Result: r.Outcome.Result,
})
}
return c.JSON(200, map[string]any{
"status": "ok",
"msg": "",
"data": out,
"data2": total,
})
}
// issueGrantRequest is the POST /v1/admin/grants body: the credit fields plus the
// target org (which the per-customer route carries in its path instead).
type issueGrantRequest struct {
Org string `json:"org"`
AmountCents int64 `json:"amountCents"`
Currency string `json:"currency"`
Reason string `json:"reason"`
Source string `json:"source"` // "trial" (default) | "prepaid"
}
// issueGrant answers POST /v1/admin/grants — issue a credit grant to any org from
// the operator Grants view (org in the body). It funnels through the SAME
// applyGrant core POST /v1/admin/customers/:org/credit uses, so there is exactly
// ONE credit-write path (validate + deposit trial/prepaid + audit).
//
// POST /v1/admin/grants { org, amountCents, currency?, reason?, source? }
func (s *svc) issueGrant(c *zip.Ctx) error {
var body issueGrantRequest
if err := c.Bind(&body); err != nil {
return fail(c, "invalid request body")
}
org := strings.TrimSpace(body.Org)
if org == "" {
return fail(c, "org is required")
}
return s.applyGrant(c, org, creditRequest{
AmountCents: body.AmountCents,
Currency: body.Currency,
Reason: body.Reason,
Source: body.Source,
})
}
+29
View File
@@ -0,0 +1,29 @@
package admin
import "testing"
// TestGrantTag pins the money-bucket mapping: a staff comp defaults to the
// non-cash TRIAL (Credit) bucket, and ONLY an explicit "prepaid" mints real
// money. A tagging slip must never silently create payout-able cash.
func TestGrantTag(t *testing.T) {
cases := []struct {
source string
wantTag string
wantNorm string
}{
{"", "grant:admin", "trial"}, // default → trial (fail-closed non-cash)
{"trial", "grant:admin", "trial"}, // explicit trial
{"TRIAL", "grant:admin", "trial"}, // case-insensitive
{" trial ", "grant:admin", "trial"}, // trimmed
{"comp", "grant:admin", "trial"}, // unknown → trial (never real money by accident)
{"prepaid", "admin-grant", "prepaid"}, // explicit real money
{"PREPAID", "admin-grant", "prepaid"}, // case-insensitive
{" prepaid", "admin-grant", "prepaid"},
}
for _, tc := range cases {
tag, norm := grantTag(tc.source)
if tag != tc.wantTag || norm != tc.wantNorm {
t.Errorf("grantTag(%q) = (%q,%q), want (%q,%q)", tc.source, tag, norm, tc.wantTag, tc.wantNorm)
}
}
}
+250
View File
@@ -0,0 +1,250 @@
package admin
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// iamClient reads the IAM management surface (/v1/iam/get-*) on behalf of a
// verified global-admin caller. IAM runs as its own deployment (not fused into
// this binary — see subsystems.go), so these are HTTP calls, not Go method
// dispatch. Every call REPLAYS THE CALLER'S OWN credential (session cookie +
// Authorization), so IAM authorizes the read as the same principal the gateway
// already validated as a global admin. admin adds NO service credential of
// its own here: it never widens what the caller could read directly, and IAM's
// own IsGlobalAdmin gate stays the second line of defense.
type iamClient struct {
base string // e.g. http://iam.hanzo.svc.cluster.local:8000
http *http.Client
}
func newIAMClient(base string) *iamClient {
return &iamClient{
base: strings.TrimRight(strings.TrimSpace(base), "/"),
http: &http.Client{Timeout: 15 * time.Second},
}
}
func (c *iamClient) configured() bool { return c != nil && c.base != "" }
// creds is the caller's replayed authorization context: the raw Cookie header
// and Authorization bearer captured off the inbound request. IAM authenticates
// exactly as it does for the browser (credentials: 'include').
type creds struct {
cookie string
auth string
}
// envelope is the uniform /v1 response shape every /v1/iam handler returns.
// data is the payload; data2 the list total (paginated reads).
type envelope struct {
Status string `json:"status"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
Data2 json.RawMessage `json:"data2"`
}
// listResult is a decoded paginated read: the raw rows and the backend total.
type listResult struct {
rows json.RawMessage
total int
}
// getList calls an IAM get-* endpoint and returns the raw data array + data2
// total. A non-ok envelope is an error (surfaced honestly to the operator).
func (c *iamClient) getList(ctx context.Context, cr creds, path string, q url.Values) (listResult, error) {
env, err := c.get(ctx, cr, path, q)
if err != nil {
return listResult{}, err
}
total := envTotal(env.Data2, env.Data)
return listResult{rows: env.Data, total: total}, nil
}
// get performs one authenticated GET and decodes the /v1 envelope.
func (c *iamClient) get(ctx context.Context, cr creds, path string, q url.Values) (envelope, error) {
if !c.configured() {
return envelope{}, fmt.Errorf("iam endpoint not configured")
}
u := c.base + path
if enc := q.Encode(); enc != "" {
u += "?" + enc
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return envelope{}, err
}
req.Header.Set("Accept", "application/json")
if cr.cookie != "" {
req.Header.Set("Cookie", cr.cookie)
}
if cr.auth != "" {
req.Header.Set("Authorization", cr.auth)
}
resp, err := c.http.Do(req)
if err != nil {
return envelope{}, fmt.Errorf("iam unreachable: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
if err != nil {
return envelope{}, err
}
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return envelope{}, fmt.Errorf("iam denied (%d)", resp.StatusCode)
}
var env envelope
if err := json.Unmarshal(body, &env); err != nil {
return envelope{}, fmt.Errorf("iam non-envelope response (%d)", resp.StatusCode)
}
if env.Status != "ok" {
msg := env.Msg
if msg == "" {
msg = fmt.Sprintf("iam status %d", resp.StatusCode)
}
return envelope{}, fmt.Errorf("iam: %s", msg)
}
return env, nil
}
// getUserRaw fetches ONE user as its FULL wire object (GET /v1/iam/get-user?id=
// owner/name), preserving every field. The suspend/reactivate action reads the
// whole object, flips isForbidden, and writes it back — update-user REPLACES the
// row, so operating on the full object (not a typed subset) is what keeps every
// other field intact. Replays the caller's own credential, so IAM authorizes the
// read as the same validated global admin.
func (c *iamClient) getUserRaw(ctx context.Context, cr creds, id string) (map[string]any, error) {
q := url.Values{"id": {id}}
env, err := c.get(ctx, cr, "/v1/iam/get-user", q)
if err != nil {
return nil, err
}
var user map[string]any
if err := json.Unmarshal(env.Data, &user); err != nil {
return nil, fmt.Errorf("iam get-user decode: %w", err)
}
if user == nil {
return nil, fmt.Errorf("iam get-user %q: empty", id)
}
return user, nil
}
// getOrg fetches ONE organization row (GET /v1/iam/get-organization?id=owner/name)
// as the typed iamOrg subset the scoped read panels fold over. Replays the caller's
// own credential, so IAM authorizes the read as the same validated principal — a
// non-super caller can only ever read their OWN org this way (the second line of the
// tenant-scope defense). Best-effort by design: the scoped-orgs fan-in tolerates an
// error and falls back to a name-only row.
func (c *iamClient) getOrg(ctx context.Context, cr creds, id string) (iamOrg, error) {
q := url.Values{"id": {id}}
env, err := c.get(ctx, cr, "/v1/iam/get-organization", q)
if err != nil {
return iamOrg{}, err
}
var org iamOrg
if err := json.Unmarshal(env.Data, &org); err != nil {
return iamOrg{}, fmt.Errorf("iam get-organization decode: %w", err)
}
return org, nil
}
// updateUserRaw writes a full user object back (POST /v1/iam/update-user?id=
// owner/name). The caller's replayed credential is a VALIDATED global admin, whom
// IAM's CheckPermissionForUpdateUser admits to set privileged fields (isForbidden)
// on any user — a tenant/org-admin is refused by IAM itself, so this can never be
// abused to suspend across a boundary the caller couldn't already cross. admin
// adds no service credential of its own; IAM re-checks IsGlobalAdmin.
func (c *iamClient) updateUserRaw(ctx context.Context, cr creds, id string, user map[string]any) error {
q := url.Values{"id": {id}}
body, err := json.Marshal(user)
if err != nil {
return err
}
_, err = c.post(ctx, cr, "/v1/iam/update-user", q, body)
return err
}
// post performs one authenticated POST (JSON body) replaying the caller's cookie +
// bearer, and decodes the /v1 envelope. A non-ok envelope (or an IAM 401/403) is
// an error the mutation surfaces honestly + records as a failed audited attempt.
func (c *iamClient) post(ctx context.Context, cr creds, path string, q url.Values, body []byte) (envelope, error) {
if !c.configured() {
return envelope{}, fmt.Errorf("iam endpoint not configured")
}
u := c.base + path
if enc := q.Encode(); enc != "" {
u += "?" + enc
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body))
if err != nil {
return envelope{}, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
if cr.cookie != "" {
req.Header.Set("Cookie", cr.cookie)
}
if cr.auth != "" {
req.Header.Set("Authorization", cr.auth)
}
resp, err := c.http.Do(req)
if err != nil {
return envelope{}, fmt.Errorf("iam unreachable: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
if err != nil {
return envelope{}, err
}
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return envelope{}, fmt.Errorf("iam denied (%d)", resp.StatusCode)
}
var env envelope
if err := json.Unmarshal(respBody, &env); err != nil {
return envelope{}, fmt.Errorf("iam non-envelope response (%d)", resp.StatusCode)
}
if env.Status != "ok" {
msg := env.Msg
if msg == "" {
msg = fmt.Sprintf("iam status %d", resp.StatusCode)
}
return envelope{}, fmt.Errorf("iam: %s", msg)
}
return env, nil
}
// envTotal reads data2 as the list total when present, else counts data rows.
func envTotal(data2, data json.RawMessage) int {
if n, ok := asInt(data2); ok {
return n
}
var rows []json.RawMessage
if json.Unmarshal(data, &rows) == nil {
return len(rows)
}
return 0
}
// asInt decodes a JSON number (data2 may arrive as a bare int).
func asInt(raw json.RawMessage) (int, bool) {
t := strings.TrimSpace(string(raw))
if t == "" || t == "null" {
return 0, false
}
if n, err := strconv.Atoi(t); err == nil {
return n, true
}
var f float64
if json.Unmarshal(raw, &f) == nil {
return int(f), true
}
return 0, false
}
+424
View File
@@ -0,0 +1,424 @@
// 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
// o11y — GET /v1/admin/o11y, the GLOBAL fleet-wide observability read that powers
// the operator's o11y board on admin.hanzo.ai. It is the un-org-scoped twin of the
// per-org console o11y: the same signals, aggregated across EVERY tenant, over the
// ONE hanzoai/datastore (ClickHouse) — the same warehouse + shared client
// (aiobject.DatastoreQuery) the analytics/compute lenses already use, no second
// connection.
//
// Signals, each from its canonical table in the one datastore:
// - LLM usage → hanzo.cloud_usage : requests, tokens, cost, errors, top orgs, top models
// - Traces → o11y_traces.distributed_o11y_index_v3 : request count, latency p50/p95/p99,
// error rate, top services
// - Logs → o11y_logs.distributed_logs_v2 : fleet log volume + volume-over-time
// - LLM gens → langfuse.observations : generations + cost (fleet-wide; honest-empty today)
//
// GLOBAL-ADMIN ONLY (the s.guard wrap in admin.go): the gateway strips a client
// X-Org-Id and re-mints from the JWT owner, and this handler applies NO org filter,
// so it is the ONE place a fleet operator crosses tenants — a non-admin bearer is
// refused 403 before a single row is read. Fail-closed.
//
// Honest by construction, exactly like compute/analytics: no datastore connected →
// the real empty aggregate, never a fabricated fleet. admin READS only; it owns and
// creates NO table (the ZAP-fed collector + the ai-owned cloud_usage ledger own the
// data). Money is USD cents end to end; latency is milliseconds; time bounds are
// POSITIONAL parameters (never interpolated), and the bucket interval is a
// server-side constant — injection-safe.
import (
"strconv"
"strings"
"time"
aiobject "github.com/hanzoai/ai/object"
"github.com/zap-proto/zip"
)
// Fully-qualified datastore tables. admin only READS these — the ZAP collector
// (o11y_*), the ai ledger (hanzo.cloud_usage), and Langfuse own their writes.
const (
o11yUsageTable = "hanzo.cloud_usage"
o11yTraceTable = "o11y_traces.distributed_o11y_index_v3"
o11yLogTable = "o11y_logs.distributed_logs_v2"
o11yLangfuseObs = "langfuse.observations"
o11yTopN = 10
o11yServiceLimit = 12
)
// o11yGlobal is the whole fleet o11y board payload.
type o11yGlobal struct {
Range string `json:"range"`
Start string `json:"start"`
End string `json:"end"`
Totals o11yTotals `json:"totals"`
Series []o11ySeries `json:"series"`
LogSeries []o11yLogPoint `json:"logSeries"`
TopOrgs []o11yOrgStat `json:"topOrgs"`
TopModels []o11yModelStat `json:"topModels"`
TopServices []o11ySvcStat `json:"topServices"`
LLM o11yLLM `json:"llm"`
}
// o11yTotals is the fleet KPI band. LLM half from cloud_usage; RED half from traces;
// volume from logs. Every field is a real aggregate or an honest zero.
type o11yTotals struct {
// LLM usage (hanzo.cloud_usage), all orgs.
Requests int64 `json:"requests"`
Tokens int64 `json:"tokens"`
PromptTokens int64 `json:"promptTokens"`
CompletionTokens int64 `json:"completionTokens"`
CostCents int64 `json:"costCents"`
Errors int64 `json:"errors"`
Orgs int64 `json:"orgs"`
Models int64 `json:"models"`
// Traces (o11y_index_v3), all services.
TraceCount int64 `json:"traceCount"`
LatencyP50Ms float64 `json:"latencyP50Ms"`
LatencyP95Ms float64 `json:"latencyP95Ms"`
LatencyP99Ms float64 `json:"latencyP99Ms"`
TraceErrorRate float64 `json:"traceErrorRate"` // percent (0..100)
Services int64 `json:"services"`
// Logs (distributed_logs_v2), fleet volume over the window.
LogVolume int64 `json:"logVolume"`
}
// o11ySeries is one usage time bucket (fleet-wide).
type o11ySeries struct {
Ts string `json:"ts"`
Requests int64 `json:"requests"`
Tokens int64 `json:"tokens"`
CostCents int64 `json:"costCents"`
Errors int64 `json:"errors"`
}
// o11yLogPoint is one log-volume time bucket (fleet-wide).
type o11yLogPoint struct {
Ts string `json:"ts"`
Count int64 `json:"count"`
}
// o11yOrgStat is one row of the top-orgs-by-usage leaderboard.
type o11yOrgStat struct {
Org string `json:"org"`
Requests int64 `json:"requests"`
Tokens int64 `json:"tokens"`
CostCents int64 `json:"costCents"`
}
// o11yModelStat is one row of the top-models leaderboard.
type o11yModelStat struct {
Model string `json:"model"`
Requests int64 `json:"requests"`
Tokens int64 `json:"tokens"`
CostCents int64 `json:"costCents"`
}
// o11ySvcStat is one row of the top-services (by trace volume) leaderboard.
type o11ySvcStat struct {
Service string `json:"service"`
Requests int64 `json:"requests"`
ErrorRate float64 `json:"errorRate"` // percent (0..100)
LatencyP95Ms float64 `json:"latencyP95Ms"`
}
// o11yLLM is the fleet-wide Langfuse generation rollup (near-empty today → honest).
type o11yLLM struct {
Generations int64 `json:"generations"`
CostUsd float64 `json:"costUsd"`
}
// o11y answers GET /v1/admin/o11y. ?range=24h|7d|30d bounds the window (default 30d).
// GLOBAL-ADMIN ONLY (s.guard). Every signal degrades independently: a table that is
// absent or errors contributes its zero-value, never a failure — the fleet board
// always renders what the datastore actually holds.
func (s *svc) o11y(c *zip.Ctx) error {
ctx := c.Context()
rangeLabel := o11yRange(c.Query("range"))
since := computeSince(rangeLabel)
payload := o11yGlobal{
Range: rangeLabel,
Start: since.Format(time.RFC3339),
End: time.Now().UTC().Format(time.RFC3339),
Series: []o11ySeries{},
LogSeries: []o11yLogPoint{},
TopOrgs: []o11yOrgStat{},
TopModels: []o11yModelStat{},
TopServices: []o11ySvcStat{},
}
// Honest-empty when the warehouse is not connected: the board renders its zero
// state, never a fabricated fleet.
if !aiobject.DatastoreEnabled() {
return ok(c, payload)
}
sinceTS := chTS(since) // DateTime literal — cloud_usage.timestamp, traces.timestamp
sinceNanos := since.UnixNano() // UInt64 nanos — logs.timestamp
interval := o11yBucket(rangeLabel)
// LLM usage totals (all orgs).
if rows, err := aiobject.DatastoreQuery(ctx, o11yUsageTotalsSQL(), sinceTS); err == nil {
fillUsageTotals(&payload.Totals, firstRowOr(rows))
}
// Trace RED metrics (all services).
if rows, err := aiobject.DatastoreQuery(ctx, o11yTraceTotalsSQL(), sinceTS); err == nil {
fillTraceTotals(&payload.Totals, firstRowOr(rows))
}
// Fleet log volume.
if rows, err := aiobject.DatastoreQuery(ctx, o11yLogVolumeSQL(), sinceNanos); err == nil {
payload.Totals.LogVolume = chInt64(firstRowOr(rows)["c"])
}
// Usage time-series (fleet).
if rows, err := aiobject.DatastoreQuery(ctx, o11yUsageSeriesSQL(interval), sinceTS); err == nil {
payload.Series = usageSeriesFromRows(rows)
}
// Log-volume time-series (fleet).
if rows, err := aiobject.DatastoreQuery(ctx, o11yLogSeriesSQL(interval), sinceNanos); err == nil {
payload.LogSeries = logSeriesFromRows(rows)
}
// Top orgs by usage.
if rows, err := aiobject.DatastoreQuery(ctx, o11yTopOrgsSQL(), sinceTS); err == nil {
payload.TopOrgs = topOrgsFromRows(rows)
}
// Top models by usage.
if rows, err := aiobject.DatastoreQuery(ctx, o11yTopModelsSQL(), sinceTS); err == nil {
payload.TopModels = topModelsFromRows(rows)
}
// Top services by trace volume.
if rows, err := aiobject.DatastoreQuery(ctx, o11yTopServicesSQL(), sinceTS); err == nil {
payload.TopServices = topServicesFromRows(rows)
}
// Fleet LLM generations (Langfuse) — best-effort; near-empty today.
if rows, err := aiobject.DatastoreQuery(ctx, o11yLLMSQL(), sinceTS); err == nil {
r := firstRowOr(rows)
payload.LLM = o11yLLM{Generations: chInt64(r["gens"]), CostUsd: chFloat64(r["cost"])}
}
return ok(c, payload)
}
// ── pure SQL builders (static SQL + one positional time bound; unit-tested) ──
func o11yUsageTotalsSQL() 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, countIf(status = 'error') AS errors, " +
"uniqExact(organization) AS orgs, uniqExact(model) AS models " +
"FROM " + o11yUsageTable + " WHERE timestamp >= ?"
}
func o11yTraceTotalsSQL() string {
return "SELECT count() AS traces, " +
"round(quantile(0.5)(durationNano) / 1e6, 2) AS p50, " +
"round(quantile(0.95)(durationNano) / 1e6, 2) AS p95, " +
"round(quantile(0.99)(durationNano) / 1e6, 2) AS p99, " +
"round(100 * countIf(has_error) / greatest(count(), 1), 3) AS err_rate, " +
"uniqExact(serviceName) AS services " +
"FROM " + o11yTraceTable + " WHERE timestamp >= ?"
}
func o11yLogVolumeSQL() string {
return "SELECT count() AS c FROM " + o11yLogTable + " WHERE timestamp >= ?"
}
func o11yUsageSeriesSQL(interval string) string {
return "SELECT toStartOfInterval(timestamp, INTERVAL " + interval + ") AS ts, " +
"count() AS requests, sum(total_tokens) AS tokens, sum(cost_cents) AS cost_cents, " +
"countIf(status = 'error') AS errors " +
"FROM " + o11yUsageTable + " WHERE timestamp >= ? GROUP BY ts ORDER BY ts"
}
func o11yLogSeriesSQL(interval string) string {
return "SELECT toStartOfInterval(toDateTime(timestamp / 1000000000), INTERVAL " + interval + ") AS ts, " +
"count() AS c FROM " + o11yLogTable + " WHERE timestamp >= ? GROUP BY ts ORDER BY ts"
}
func o11yTopOrgsSQL() string {
return "SELECT organization AS org, count() AS requests, sum(total_tokens) AS tokens, " +
"sum(cost_cents) AS cost_cents FROM " + o11yUsageTable +
" WHERE timestamp >= ? GROUP BY org ORDER BY requests DESC LIMIT " + strconv.Itoa(o11yTopN)
}
func o11yTopModelsSQL() string {
return "SELECT model, count() AS requests, sum(total_tokens) AS tokens, " +
"sum(cost_cents) AS cost_cents FROM " + o11yUsageTable +
" WHERE timestamp >= ? AND model != '' GROUP BY model ORDER BY requests DESC LIMIT " + strconv.Itoa(o11yTopN)
}
func o11yTopServicesSQL() string {
return "SELECT serviceName AS service, count() AS requests, " +
"round(100 * countIf(has_error) / greatest(count(), 1), 3) AS error_rate, " +
"round(quantile(0.95)(durationNano) / 1e6, 2) AS p95 " +
"FROM " + o11yTraceTable + " WHERE timestamp >= ? AND serviceName != '' " +
"GROUP BY service ORDER BY requests DESC LIMIT " + strconv.Itoa(o11yServiceLimit)
}
func o11yLLMSQL() string {
return "SELECT count() AS gens, toFloat64(sum(total_cost)) AS cost FROM " + o11yLangfuseObs +
" WHERE type = 'GENERATION' AND start_time >= ?"
}
// ── pure row parsers (unit-tested) ──
func fillUsageTotals(t *o11yTotals, r map[string]any) {
t.Requests = chInt64(r["requests"])
t.Tokens = chInt64(r["tokens"])
t.PromptTokens = chInt64(r["prompt_tokens"])
t.CompletionTokens = chInt64(r["completion_tokens"])
t.CostCents = chInt64(r["cost_cents"])
t.Errors = chInt64(r["errors"])
t.Orgs = chInt64(r["orgs"])
t.Models = chInt64(r["models"])
}
func fillTraceTotals(t *o11yTotals, r map[string]any) {
t.TraceCount = chInt64(r["traces"])
t.LatencyP50Ms = chFloat64(r["p50"])
t.LatencyP95Ms = chFloat64(r["p95"])
t.LatencyP99Ms = chFloat64(r["p99"])
t.TraceErrorRate = chFloat64(r["err_rate"])
t.Services = chInt64(r["services"])
}
func usageSeriesFromRows(rows []map[string]any) []o11ySeries {
out := make([]o11ySeries, 0, len(rows))
for _, r := range rows {
out = append(out, o11ySeries{
Ts: chTime(r["ts"]),
Requests: chInt64(r["requests"]),
Tokens: chInt64(r["tokens"]),
CostCents: chInt64(r["cost_cents"]),
Errors: chInt64(r["errors"]),
})
}
return out
}
func logSeriesFromRows(rows []map[string]any) []o11yLogPoint {
out := make([]o11yLogPoint, 0, len(rows))
for _, r := range rows {
out = append(out, o11yLogPoint{Ts: chTime(r["ts"]), Count: chInt64(r["c"])})
}
return out
}
func topOrgsFromRows(rows []map[string]any) []o11yOrgStat {
out := make([]o11yOrgStat, 0, len(rows))
for _, r := range rows {
out = append(out, o11yOrgStat{
Org: chStr(r["org"]),
Requests: chInt64(r["requests"]),
Tokens: chInt64(r["tokens"]),
CostCents: chInt64(r["cost_cents"]),
})
}
return out
}
func topModelsFromRows(rows []map[string]any) []o11yModelStat {
out := make([]o11yModelStat, 0, len(rows))
for _, r := range rows {
out = append(out, o11yModelStat{
Model: chStr(r["model"]),
Requests: chInt64(r["requests"]),
Tokens: chInt64(r["tokens"]),
CostCents: chInt64(r["cost_cents"]),
})
}
return out
}
func topServicesFromRows(rows []map[string]any) []o11ySvcStat {
out := make([]o11ySvcStat, 0, len(rows))
for _, r := range rows {
out = append(out, o11ySvcStat{
Service: chStr(r["service"]),
Requests: chInt64(r["requests"]),
ErrorRate: chFloat64(r["error_rate"]),
LatencyP95Ms: chFloat64(r["p95"]),
})
}
return out
}
// ── small pure helpers ──
// o11yRange normalizes the ?range enum (default 30d).
func o11yRange(v string) string {
switch strings.TrimSpace(v) {
case "24h":
return "24h"
case "7d":
return "7d"
default:
return "30d"
}
}
// o11yBucket maps the range to a fixed ClickHouse interval clause (a server-side
// CONSTANT — never user input — so it is safe to render into the SQL). ~24-30
// buckets across the window keeps the charts legible.
func o11yBucket(rangeLabel string) string {
switch rangeLabel {
case "24h":
return "1 HOUR"
case "7d":
return "6 HOUR"
default:
return "1 DAY"
}
}
// firstRowOr returns the first row or an empty map (never nil), so a parser reads
// honest zeros from an empty result instead of panicking.
func firstRowOr(rows []map[string]any) map[string]any {
if len(rows) == 0 {
return map[string]any{}
}
return rows[0]
}
// chFloat64 coerces a ClickHouse numeric cell to float64 (the round()/quantile()
// columns land as float64; a Decimal serialized to string is parsed). The twin of
// chInt64 for the latency/error-rate/cost fields. Non-numeric → 0 (honest zero).
func chFloat64(v any) float64 {
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
}
}
+179
View File
@@ -0,0 +1,179 @@
// 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"
)
// TestO11yRange normalizes the enum and defaults to 30d.
func TestO11yRange(t *testing.T) {
for in, want := range map[string]string{"24h": "24h", "7d": "7d", "30d": "30d", "": "30d", "bogus": "30d", " 7d ": "7d"} {
if got := o11yRange(in); got != want {
t.Errorf("o11yRange(%q) = %q, want %q", in, got, want)
}
}
}
// TestO11yBucket maps each range to a fixed, injection-safe interval constant.
func TestO11yBucket(t *testing.T) {
for r, want := range map[string]string{"24h": "1 HOUR", "7d": "6 HOUR", "30d": "1 DAY"} {
if got := o11yBucket(r); got != want {
t.Errorf("o11yBucket(%q) = %q, want %q", r, got, want)
}
}
}
// TestO11ySQL_ReadsCanonicalTables proves every fleet 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 and it is a server-side constant.
func TestO11ySQL_ReadsCanonicalTables(t *testing.T) {
cases := []struct {
name, sql, table string
wantQMarks int
}{
{"usageTotals", o11yUsageTotalsSQL(), "hanzo.cloud_usage", 1},
{"traceTotals", o11yTraceTotalsSQL(), "o11y_traces.distributed_o11y_index_v3", 1},
{"logVolume", o11yLogVolumeSQL(), "o11y_logs.distributed_logs_v2", 1},
{"usageSeries", o11yUsageSeriesSQL("1 HOUR"), "hanzo.cloud_usage", 1},
{"logSeries", o11yLogSeriesSQL("1 HOUR"), "o11y_logs.distributed_logs_v2", 1},
{"topOrgs", o11yTopOrgsSQL(), "hanzo.cloud_usage", 1},
{"topModels", o11yTopModelsSQL(), "hanzo.cloud_usage", 1},
{"topServices", o11yTopServicesSQL(), "o11y_traces.distributed_o11y_index_v3", 1},
{"llm", o11yLLMSQL(), "langfuse.observations", 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)
}
}
}
// TestO11ySeriesSQL_IntervalBound proves the (constant) bucket interval is
// rendered into the series queries and grouped/ordered by the bucket.
func TestO11ySeriesSQL_IntervalBound(t *testing.T) {
for _, iv := range []string{"1 HOUR", "6 HOUR", "1 DAY"} {
u := o11yUsageSeriesSQL(iv)
if !strings.Contains(u, "INTERVAL "+iv) || !strings.Contains(u, "GROUP BY ts ORDER BY ts") {
t.Errorf("usage series must bucket by INTERVAL %s; got %q", iv, u)
}
l := o11yLogSeriesSQL(iv)
if !strings.Contains(l, "INTERVAL "+iv) {
t.Errorf("log series must bucket by INTERVAL %s; got %q", iv, l)
}
}
}
// TestO11yTop_LimitAndOrder proves the leaderboards bound + order the result.
func TestO11yTop_LimitAndOrder(t *testing.T) {
if !strings.Contains(o11yTopOrgsSQL(), "ORDER BY requests DESC LIMIT 10") {
t.Errorf("topOrgs must order by requests desc, limit %d", o11yTopN)
}
if !strings.Contains(o11yTopServicesSQL(), "LIMIT 12") {
t.Errorf("topServices must limit %d", o11yServiceLimit)
}
// The LLM lens is scoped to generations only (not spans/events).
if !strings.Contains(o11yLLMSQL(), "type = 'GENERATION'") {
t.Errorf("llm lens must scope to GENERATION observations; got %q", o11yLLMSQL())
}
}
// TestFillUsageTotals reads the ClickHouse row into the KPI band across the
// numeric variants the driver returns (uint64/int64/float64), honest zeros on
// an empty row.
func TestFillUsageTotals(t *testing.T) {
var empty o11yTotals
fillUsageTotals(&empty, map[string]any{})
if empty.Requests != 0 || empty.Tokens != 0 || empty.Orgs != 0 {
t.Fatalf("empty row must yield honest zeros; got %+v", empty)
}
var got o11yTotals
fillUsageTotals(&got, map[string]any{
"requests": uint64(274), "tokens": uint64(102597), "prompt_tokens": uint64(60000),
"completion_tokens": uint64(42597), "cost_cents": uint64(216), "errors": uint64(41),
"orgs": uint64(3), "models": uint64(42),
})
if got.Requests != 274 || got.Tokens != 102597 || got.CostCents != 216 || got.Orgs != 3 || got.Models != 42 || got.Errors != 41 {
t.Fatalf("usage totals mis-parsed: %+v", got)
}
}
// TestFillTraceTotals maps the RED metrics, including the float latency/error-rate
// columns (round()/quantile() land as float64; a Decimal-as-string is parsed).
func TestFillTraceTotals(t *testing.T) {
var got o11yTotals
fillTraceTotals(&got, map[string]any{
"traces": uint64(4044354), "p50": float64(12.5), "p95": float64(340.2),
"p99": "901.7", "err_rate": float64(1.25), "services": uint64(8),
})
if got.TraceCount != 4044354 || got.LatencyP50Ms != 12.5 || got.LatencyP95Ms != 340.2 {
t.Fatalf("trace latency mis-parsed: %+v", got)
}
if got.LatencyP99Ms != 901.7 { // string→float64 path
t.Errorf("p99 string→float64 = %v, want 901.7", got.LatencyP99Ms)
}
if got.TraceErrorRate != 1.25 || got.Services != 8 {
t.Errorf("trace error/services mis-parsed: %+v", got)
}
}
// TestTopParsers map ClickHouse rows into the leaderboard view-models and preserve
// order (the SQL already ORDER BYs; the parser must not reorder or drop rows).
func TestTopParsers(t *testing.T) {
orgs := topOrgsFromRows([]map[string]any{
{"org": "hanzo", "requests": uint64(154), "tokens": uint64(38966), "cost_cents": uint64(114)},
{"org": "maxpower", "requests": uint64(118), "tokens": uint64(61550), "cost_cents": uint64(101)},
})
if len(orgs) != 2 || orgs[0].Org != "hanzo" || orgs[1].Org != "maxpower" || orgs[0].Requests != 154 {
t.Fatalf("top orgs mis-parsed/reordered: %+v", orgs)
}
svcs := topServicesFromRows([]map[string]any{
{"service": "ingress", "requests": uint64(308125), "error_rate": float64(45.56), "p95": float64(38.5)},
{"service": "gateway", "requests": uint64(1112), "error_rate": float64(0), "p95": float64(8.3)},
})
if len(svcs) != 2 || svcs[0].Service != "ingress" || svcs[0].ErrorRate != 45.56 || svcs[1].LatencyP95Ms != 8.3 {
t.Fatalf("top services mis-parsed: %+v", svcs)
}
// Empty input → empty (non-nil) slice, never a panic.
if got := usageSeriesFromRows(nil); got == nil || len(got) != 0 {
t.Errorf("nil rows must yield empty slice, got %v", got)
}
}
// TestChFloat64 covers the numeric coercions the float accessor must survive.
func TestChFloat64(t *testing.T) {
cases := map[string]struct {
in any
want float64
}{
"float64": {float64(3.14), 3.14},
"float32": {float32(2.5), 2.5},
"int64": {int64(7), 7},
"uint64": {uint64(9), 9},
"string": {"12.5", 12.5},
"badstr": {"nope", 0},
"nil": {nil, 0},
}
for name, c := range cases {
if got := chFloat64(c.in); got != c.want {
t.Errorf("chFloat64(%s=%v) = %v, want %v", name, c.in, got, c.want)
}
}
}
+154
View File
@@ -0,0 +1,154 @@
package admin
// Fleet REVENUE aggregate (/v1/admin/revenue) — the operator's money board: total
// prepaid balances held, total realized spend, MRR, a per-customer revenue table,
// ARPU, and a real spend trend. Global-admin only (s.guard).
//
// This is ORTHOGONAL to /v1/admin/finance: finance is the COGS/margin god-view
// (what WE pay vendors, gross margin, DO-credit runway); revenue is the CUSTOMER
// money view (what each customer holds/spends/subscribes). Both read commerce, but
// answer different questions — one is "are we profitable", the other is "who are
// our paying customers and what do they pay". Every number is a real commerce read;
// an unreachable org degrades to honest zero (never a fabricated figure), and a
// partial fleet read marks its source degraded rather than presenting an undercount
// as authoritative.
import (
"context"
"sort"
"sync"
"time"
"github.com/zap-proto/zip"
)
// revenueCustomer is one row of the per-customer revenue table.
type revenueCustomer struct {
Org string `json:"org"`
Display string `json:"display"`
Plan string `json:"plan"`
BalanceCents int64 `json:"balanceCents"`
SpendCents int64 `json:"spendCents"`
MRRCents int64 `json:"mrrCents"`
}
// revenueData is the whole GET /v1/admin/revenue payload.
type revenueData struct {
TotalBalancesCents int64 `json:"totalBalancesCents"`
TotalSpendCents int64 `json:"totalSpendCents"`
MRRCents int64 `json:"mrrCents"`
Customers int `json:"customers"`
PayingCustomers int `json:"payingCustomers"`
ARPUCents int64 `json:"arpuCents"`
PerCustomer []revenueCustomer `json:"perCustomer"`
SpendTrend []seriesPoint `json:"spendTrend"`
GeneratedAt string `json:"generatedAt"`
Sources []sourceStatus `json:"sources"`
}
func (s *svc) revenue(c *zip.Ctx) error {
ctx := c.Context()
cr := callerCreds(c)
now := time.Now().UTC()
orgs, err := s.listOrgs(ctx, cr)
if err != nil {
return fail(c, err.Error())
}
// Per-org money, fanned out concurrently (balance + spend + plan/MRR).
rows := make([]revenueCustomer, len(orgs))
oks := make([]bool, len(orgs))
sem := make(chan struct{}, maxCustomerConcurrency)
var wg sync.WaitGroup
for i, o := range orgs {
wg.Add(1)
sem <- struct{}{}
go func(i int, o iamOrg) {
defer wg.Done()
defer func() { <-sem }()
rows[i], oks[i] = s.revenueOf(ctx, o)
}(i, o)
}
wg.Wait()
var totalBal, totalSpend, mrr int64
paying := 0
partial := false
for i, r := range rows {
totalBal += r.BalanceCents
totalSpend += r.SpendCents
mrr += r.MRRCents
if r.SpendCents > 0 || r.MRRCents > 0 {
paying++
}
if !oks[i] {
partial = true
}
}
arpu := int64(0)
if paying > 0 {
arpu = totalSpend / int64(paying)
}
// Real 30-day spend trend from the usage ledger (honest empty when no usage).
acts, ledgerOK := s.fleetActivity(ctx, orgs)
trend := spendSeries(acts, now.AddDate(0, 0, -30), now, "day")
// Highest-revenue customers first.
sort.Slice(rows, func(i, j int) bool {
if rows[i].SpendCents != rows[j].SpendCents {
return rows[i].SpendCents > rows[j].SpendCents
}
return rows[i].BalanceCents > rows[j].BalanceCents
})
nowStr := now.Format(time.RFC3339)
sources := []sourceStatus{srcOf("iam", nil, len(orgs), nowStr)}
if partial {
sources = append(sources, srcOf("commerce", errPartialRevenue, len(orgs), nowStr))
} else {
sources = append(sources, srcOf("commerce", nil, len(orgs), nowStr))
}
if !ledgerOK {
sources = append(sources, srcOf("commerce-ledger", errPartialRevenue, 0, nowStr))
}
return ok(c, revenueData{
TotalBalancesCents: totalBal,
TotalSpendCents: totalSpend,
MRRCents: mrr,
Customers: len(orgs),
PayingCustomers: paying,
ARPUCents: arpu,
PerCustomer: rows,
SpendTrend: trend,
GeneratedAt: nowStr,
Sources: sources,
})
}
// revenueOf reads one org's money view (balance + spend + plan/MRR). Returns
// (row, ok): ok is false when the spend OR balance read failed, so the caller can
// mark the fleet total PARTIAL rather than presenting an undercount as complete.
func (s *svc) revenueOf(ctx context.Context, o iamOrg) (revenueCustomer, bool) {
subj := orgSubject(o.Name)
row := revenueCustomer{Org: o.Name, Display: display(o.DisplayName, o.Name), Plan: "pay-as-you-go"}
ok := true
if r, err := s.commerce.usageRollup(ctx, o.Name, subj); err == nil {
row.SpendCents = r.ConsumedCents
} else {
ok = false
}
if credits, err := s.commerce.creditsCents(ctx, o.Name, subj); err == nil {
row.BalanceCents = credits
} else {
ok = false
}
if sub, err := s.commerce.subscriptionSummary(ctx, o.Name, subj); err == nil {
row.MRRCents = sub.MRR
row.Plan = sub.Plan
}
return row, ok
}
+103
View File
@@ -0,0 +1,103 @@
package admin
// The TENANT-SCOPE predicate — the ONE rule the whole cockpit obeys so admin.hanzo.ai
// is a single pane for BOTH tiers off ONE identity primitive:
//
// owner == the admin org (SuperAdmin, c.IsAdmin()) ⇒ CROSS-TENANT: every org.
// any other validated admin caller ⇒ OWN SUBTREE: their org
// (+ the sub-orgs they own).
//
// This is decomplected into exactly one place (resolveScope + scopedOrgs + descendants)
// so no handler re-derives it and the escalation line — a non-super caller reaching
// ANOTHER tenant — cannot be crossed by any single panel. c.IsAdmin() is the SANITIZED
// X-User-IsAdmin, which SanitizeIdentity (middleware_identity.go) sets true ONLY for a
// validated principal whose org IS the admin org; a non-super caller's org is pinned by
// the same boundary to their own owner (never a client-chosen value), so the subtree is
// derived from un-forgeable identity, not request input.
//
// RECURSION SEAM (honest gap). The subtree is TODAY the singleton {org}: IAM's
// Organization (hanzoai/iam object/organization.go) has NO parent-org / hierarchy field
// (Owner/Name compose the PK; Owner is the PLATFORM owner, not a tenant parent), so no
// tenant subtree exists to walk yet. `descendants` is the ONE function that becomes a
// parent-index BFS once IAM adds the ParentOrg link — every scoped read composes over it,
// so recursion lands there and nowhere else, with zero change to the callers. Until then
// the singleton is correct, not a placeholder.
import (
"context"
"strings"
"github.com/zap-proto/zip"
)
// tenantScope is a request's resolved visibility window. `super` and `orgs` are the two
// mutually exclusive views: a SuperAdmin sees all tenants (orgs ignored); anyone else
// sees exactly `orgs` (their own subtree).
type tenantScope struct {
super bool
orgs []string
}
// scopedToOrg reports whether the scope admits reads for org o. Super admits every org;
// a scoped caller admits only orgs in their subtree. Used by panels that filter an
// upstream list (e.g. bases) rather than fanning out per-org.
func (t tenantScope) scopedToOrg(o string) bool {
if t.super {
return true
}
o = strings.TrimSpace(o)
for _, s := range t.orgs {
if s == o {
return true
}
}
return false
}
// resolveScope derives the request's tenant window from the SANITIZED identity only —
// never a client-forgeable field. A SuperAdmin (c.IsAdmin(), owner == admin org) is
// cross-tenant; any other caller is pinned to the subtree of their own (sanitized) org.
func (s *svc) resolveScope(c *zip.Ctx) tenantScope {
if c.IsAdmin() {
return tenantScope{super: true}
}
org := strings.TrimSpace(c.Org())
if org == "" {
return tenantScope{} // no validated org ⇒ empty window ⇒ sees nothing
}
return tenantScope{orgs: s.descendants(org)}
}
// descendants returns org + every sub-org it owns — the subtree the caller administers.
// See the RECURSION SEAM note above: today the singleton {org}; the ONE place a future
// IAM parent-org index is walked.
func (s *svc) descendants(org string) []string {
org = strings.TrimSpace(org)
if org == "" {
return nil
}
return []string{org}
}
// scopedOrgs is the ONE fan-in the org-scoped read panels (overview, orgs, usage,
// analytics) fold over — enforcing the two-scope predicate in a single place. A
// SuperAdmin gets EVERY org (the cross-tenant list, IAM-authorized as the admin org);
// any other caller gets ONLY their own subtree, each row read from IAM so the display
// name / createdTime are the REAL values (analytics' signup cohort needs a real
// createdTime). An org row that can't be read best-effort degrades to a name-only row
// (Name = the sanitized org) rather than failing the panel — the scope is unaffected.
func (s *svc) scopedOrgs(ctx context.Context, c *zip.Ctx, cr creds) ([]iamOrg, error) {
sc := s.resolveScope(c)
if sc.super {
return s.listOrgs(ctx, cr)
}
rows := make([]iamOrg, 0, len(sc.orgs))
for _, name := range sc.orgs {
row := iamOrg{Owner: s.adminOrg, Name: name, DisplayName: name}
if full, err := s.iam.getOrg(ctx, cr, s.adminOrg+"/"+name); err == nil && full.Name != "" {
row = full
}
rows = append(rows, row)
}
return rows, nil
}
+205
View File
@@ -0,0 +1,205 @@
package admin
// Tests for the TWO-SCOPE predicate (scope.go) — the security core of the two-tier
// cockpit. They prove the ONE invariant that must never break: a SuperAdmin is
// cross-tenant, and a non-super caller is HARD-limited to their own org — a scoped caller
// can NEVER read another tenant, for ANY input (the escalation line).
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
// scopeIAM is a fake IAM serving the org directory + single-org + users reads. It RECORDS
// the owner query param it last saw on get-users, so a test can prove a scoped caller's
// read is hard-pinned to their own org (never a client-chosen ?org=).
type scopeIAM struct {
server *httptest.Server
mu sync.Mutex
lastUsersOwner string
}
func newScopeIAM() *scopeIAM {
f := &scopeIAM{}
f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "/get-organizations"):
io.WriteString(w, `{"status":"ok","msg":"","data":[
{"owner":"admin","name":"hanzo","displayName":"Hanzo","createdTime":"2020-01-01T00:00:00Z"},
{"owner":"admin","name":"maxpower","displayName":"MaxPower","createdTime":"2021-02-02T00:00:00Z"}
],"data2":2}`)
case strings.HasSuffix(r.URL.Path, "/get-organization"):
id := r.URL.Query().Get("id") // owner/name
name := id
if i := strings.LastIndex(id, "/"); i >= 0 {
name = id[i+1:]
}
fmt.Fprintf(w, `{"status":"ok","msg":"","data":{"owner":"admin","name":%q,"displayName":%q,"createdTime":"2021-02-02T00:00:00Z"}}`, name, name)
case strings.HasSuffix(r.URL.Path, "/get-users"):
f.mu.Lock()
f.lastUsersOwner = r.URL.Query().Get("owner")
f.mu.Unlock()
io.WriteString(w, `{"status":"ok","msg":"","data":[
{"owner":"maxpower","name":"dave","email":"dave@maxpower.test","displayName":"Dave","isAdmin":true}
],"data2":3}`)
default:
w.WriteHeader(404)
io.WriteString(w, `{"status":"error","msg":"not found"}`)
}
}))
return f
}
// superHdr / orgAdminHdr are the two tiers' SANITIZED identities (as SanitizeIdentity
// would mint them): a SuperAdmin carries X-User-IsAdmin=true; an org admin carries a
// validated X-User-Id + their pinned X-Org-Id but NO admin flag.
var superHdr = map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "admin", "X-User-Id": "admin/z"}
var orgAdminHdr = map[string]string{"X-Org-Id": "maxpower", "X-User-Id": "maxpower/dave", "X-User-Email": "dave@maxpower.test"}
func TestScope_SuperSeesAllOrgs(t *testing.T) {
iam := newScopeIAM()
defer iam.server.Close()
commerce := newFakeCommerce()
defer commerce.server.Close()
do := mount(t, iam.server.URL, commerce.server.URL, "")
resp, body := do("GET", "/v1/admin/orgs", superHdr)
if resp.StatusCode != http.StatusOK {
t.Fatalf("super orgs: %d (%s)", resp.StatusCode, body)
}
var env struct {
Data []orgRow `json:"data"`
}
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode: %v", err)
}
if len(env.Data) != 2 {
t.Fatalf("SuperAdmin must see EVERY org (2), got %d: %+v", len(env.Data), env.Data)
}
}
func TestScope_OrgAdminSeesOnlyOwnOrg(t *testing.T) {
iam := newScopeIAM()
defer iam.server.Close()
commerce := newFakeCommerce()
defer commerce.server.Close()
do := mount(t, iam.server.URL, commerce.server.URL, "")
// dave asks for hanzo, but must see ONLY maxpower — the escalation line.
resp, body := do("GET", "/v1/admin/orgs?org=hanzo", orgAdminHdr)
if resp.StatusCode != http.StatusOK {
t.Fatalf("org-admin orgs: %d (%s)", resp.StatusCode, body)
}
var env struct {
Data []orgRow `json:"data"`
}
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode: %v", err)
}
if len(env.Data) != 1 || env.Data[0].Org != "maxpower" {
t.Fatalf("org admin must see ONLY maxpower, got %+v (cross-tenant leak!)", env.Data)
}
}
func TestScope_UsersHardPinnedToOwnOrg(t *testing.T) {
iam := newScopeIAM()
defer iam.server.Close()
commerce := newFakeCommerce()
defer commerce.server.Close()
do := mount(t, iam.server.URL, commerce.server.URL, "")
// dave tries to list hanzo's users; the read MUST be pinned to maxpower.
if resp, body := do("GET", "/v1/admin/users?org=hanzo", orgAdminHdr); resp.StatusCode != http.StatusOK {
t.Fatalf("org-admin users: %d (%s)", resp.StatusCode, body)
}
iam.mu.Lock()
owner := iam.lastUsersOwner
iam.mu.Unlock()
if owner != "maxpower" {
t.Fatalf("org-admin users read was NOT hard-pinned: IAM saw owner=%q, want maxpower (cross-tenant escalation!)", owner)
}
}
func TestScope_AnalyticsScopedToOwnOrg(t *testing.T) {
iam := newScopeIAM()
defer iam.server.Close()
commerce := newFakeCommerce()
defer commerce.server.Close()
do := mount(t, iam.server.URL, commerce.server.URL, "")
resp, body := do("GET", "/v1/admin/analytics", orgAdminHdr)
if resp.StatusCode != http.StatusOK {
t.Fatalf("org-admin analytics: %d (%s)", resp.StatusCode, body)
}
var env struct {
Data analyticsData `json:"data"`
}
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode: %v", err)
}
if env.Data.TotalCustomers != 1 {
t.Fatalf("org-admin analytics TotalCustomers = %d, want 1 (their own org only)", env.Data.TotalCustomers)
}
}
func TestScope_PlatformRouteDeniesOrgAdminButScopedAdmits(t *testing.T) {
do := mount(t, "http://127.0.0.1:0", "http://127.0.0.1:0", "")
// flags is platform sudo — an org admin is 403.
if resp, _ := do("GET", "/v1/admin/flags", orgAdminHdr); resp.StatusCode != http.StatusForbidden {
t.Fatalf("org admin must be 403 on platform route /v1/admin/flags, got %d", resp.StatusCode)
}
// me is org-scoped — the same org admin is admitted (200), and sees a scoped identity.
resp, body := do("GET", "/v1/admin/me", orgAdminHdr)
if resp.StatusCode != http.StatusOK {
t.Fatalf("org admin must be admitted on scoped /v1/admin/me, got %d", resp.StatusCode)
}
var env struct {
Data adminMe `json:"data"`
}
_ = json.Unmarshal(body, &env)
if env.Data.IsGlobalAdmin {
t.Fatalf("org admin me.IsGlobalAdmin must be false")
}
if env.Data.Owner != "maxpower" {
t.Fatalf("org admin me.Owner = %q, want maxpower", env.Data.Owner)
}
}
// TestScope_DescendantsSingletonToday pins the honest RECURSION-SEAM gap: IAM has no
// parent-org field yet, so the subtree is the singleton. When IAM adds the parent link,
// this test changes (and descendants becomes the BFS) — nothing else does.
func TestScope_DescendantsSingletonToday(t *testing.T) {
s := &svc{adminOrg: "admin"}
got := s.descendants("maxpower")
if len(got) != 1 || got[0] != "maxpower" {
t.Fatalf("descendants(maxpower) = %v, want [maxpower] (IAM has no parent-org field yet)", got)
}
if s.descendants("") != nil {
t.Fatalf("descendants(\"\") must be nil")
}
}
func TestScope_ScopedToOrg(t *testing.T) {
super := tenantScope{super: true}
if !super.scopedToOrg("anything") {
t.Fatal("super admits any org")
}
scoped := tenantScope{orgs: []string{"maxpower"}}
if !scoped.scopedToOrg("maxpower") {
t.Fatal("scoped admits its own org")
}
if scoped.scopedToOrg("hanzo") {
t.Fatal("scoped must NOT admit another tenant's org")
}
if scoped.scopedToOrg("") {
t.Fatal("scoped must NOT admit an empty org")
}
}
+138
View File
@@ -0,0 +1,138 @@
package admin
// Response shapes for /v1/admin/*. Each mirrors the operator's api.ts contract
// (admin/apps/operator/src/lib/api.ts) field-for-field — the JSON tags ARE the
// contract, so the operator's TypeScript types decode these one-to-one.
// adminMe is the operator identity (AdminMe / GET /v1/admin/me).
//
// SuperAdmin naming: isSuperAdmin is the CANONICAL key; isGlobalAdmin is the
// transitional back-compat alias populated with the SAME value so a console
// reading either key sees the truth during the rename migration. Both derive
// from ONE fact — owner == AdminOrg (IAM's IsSuperAdmin, ex-IsGlobalAdmin).
type adminMe struct {
Owner string `json:"owner"`
Name string `json:"name"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
IsSuperAdmin bool `json:"isSuperAdmin"`
IsGlobalAdmin bool `json:"isGlobalAdmin"` // DEPRECATED alias of isSuperAdmin; kept populated for back-compat
}
// sourceStatus is the freshness of one upstream the aggregator pulls from
// (SourceStatus / overview.sources[]).
type sourceStatus struct {
Name string `json:"name"`
OK bool `json:"ok"`
Rows int `json:"rows"`
Error string `json:"error"`
At string `json:"at"`
}
// overviewData is the fleet overview tiles (OverviewData / GET /v1/admin/overview).
type overviewData struct {
Orgs int `json:"orgs"`
Users int `json:"users"`
Products int `json:"products"`
ActiveProducts int `json:"activeProducts"`
Drift int `json:"drift"`
SpendCents30d int64 `json:"spendCents30d"`
Tokens30d int64 `json:"tokens30d"`
CreditsCents int64 `json:"creditsCents"`
LastSync string `json:"lastSync"`
Sources []sourceStatus `json:"sources"`
}
// orgRow is one tenant row (OrgRow / GET /v1/admin/orgs).
type orgRow struct {
Org string `json:"org"`
Display string `json:"display"`
Users int `json:"users"`
Products int `json:"products"`
SpendCents int64 `json:"spendCents"`
CreditsCents int64 `json:"creditsCents"`
Tokens int64 `json:"tokens"`
Created string `json:"created"`
}
// operatorUser is one user in the cross-org directory (OperatorUser / GET
// /v1/admin/users).
type operatorUser struct {
Owner string `json:"owner"`
Name string `json:"name"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
IsAdmin bool `json:"isAdmin"`
IsSuperAdmin bool `json:"isSuperAdmin"`
IsGlobalAdmin bool `json:"isGlobalAdmin"` // DEPRECATED alias of isSuperAdmin; kept populated for back-compat
Tag string `json:"tag"`
Created string `json:"created"`
LastSignin string `json:"lastSignin"`
Forbidden bool `json:"forbidden"`
}
// usage roll-up (UsageData / GET /v1/admin/usage).
type usageTotals struct {
SpendCents int64 `json:"spendCents"`
Tokens int64 `json:"tokens"`
Requests int64 `json:"requests"`
}
type usagePoint struct {
Date string `json:"date"`
SpendCents int64 `json:"spendCents"`
Tokens int64 `json:"tokens"`
Requests int64 `json:"requests"`
}
type usageByProduct struct {
Product string `json:"product"`
SpendCents int64 `json:"spendCents"`
Tokens int64 `json:"tokens"`
}
type usageData struct {
Totals usageTotals `json:"totals"`
Series []usagePoint `json:"series"`
ByProduct []usageByProduct `json:"byProduct"`
}
// productRow is one product/workload row (ProductRow / GET /v1/admin/products).
type productRow struct {
Name string `json:"name"`
Kind string `json:"kind"`
Org string `json:"org"`
Cluster string `json:"cluster"`
DeclaredTag string `json:"declaredTag"`
RunningTag string `json:"runningTag"`
Health string `json:"health"`
Drift bool `json:"drift"`
Updated string `json:"updated"`
}
// ── IAM wire shapes (the subset admin decodes from get-* payloads) ─────────
// iamOrg is the IAM Organization subset the aggregators fold over.
type iamOrg struct {
Owner string `json:"owner"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
CreatedTime string `json:"createdTime"`
}
// iamUser is the IAM User subset mapped into OperatorUser. AccessKey is decoded
// ONLY to derive API-key PRESENCE (hasApiKey) for the customer detail — its VALUE
// is never surfaced in any admin response (the hk- key is a credential, not a
// display field), so no secret leaves this binary.
type iamUser struct {
Owner string `json:"owner"`
Name string `json:"name"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
Tag string `json:"tag"`
CreatedTime string `json:"createdTime"`
LastSigninTime string `json:"lastSigninTime"`
IsAdmin bool `json:"isAdmin"`
IsForbidden bool `json:"isForbidden"`
AccessKey string `json:"accessKey"`
}
+163
View File
@@ -0,0 +1,163 @@
package admin
// The ACCESS + WAITLIST cockpit (/v1/admin/waitlist*) — the SuperAdmin surface to SEE
// the waitlist (position / points / leaderboard) and control WHO gets access by
// granting points to move a user up toward the capacity cutoff. It is a server-authed
// passthrough to the Hanzo waitlist engine (the Base waitlist plugin — WAITLIST_URL +
// WAITLIST_AWARD_SECRET from KMS, the SAME engine + secret
// clients/automations/connector_waitlist.go bridges), so there is ONE waitlist system,
// not two: the cockpit reads its list and issues a grant AGAINST it.
//
// SECURITY. Both routes are global-admin only (mounted behind s.guard). A grant is a
// privileged mutation, so it is written to cloud's tamper-evident audit trail
// (action "admin.waitlist.grant", resource "waitlist"). The engine secret is never a
// client claim — it is injected from KMS into the process env by the deployment.
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/hanzoai/cloud/audit"
"github.com/zap-proto/zip"
)
const (
waitlistURLEnv = "WAITLIST_URL"
waitlistSecretEnv = "WAITLIST_AWARD_SECRET"
)
var waitlistHTTP = &http.Client{Timeout: 15 * time.Second}
func waitlistConfig() (base, secret string, ok bool) {
base = strings.TrimRight(strings.TrimSpace(os.Getenv(waitlistURLEnv)), "/")
secret = strings.TrimSpace(os.Getenv(waitlistSecretEnv))
return base, secret, base != "" && secret != ""
}
// waitlistProxy issues a server-authed request to the waitlist engine and returns its
// raw JSON body + status. Bounded read; Bearer secret; never forwards a client header.
func (s *svc) waitlistProxy(ctx context.Context, method, target, secret string, body []byte) (json.RawMessage, int, error) {
var rdr io.Reader
if body != nil {
rdr = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, target, rdr)
if err != nil {
return nil, 0, fmt.Errorf("waitlist request: %w", err)
}
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Authorization", "Bearer "+secret)
resp, err := waitlistHTTP.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("could not reach the waitlist engine: %w", err)
}
defer func() { _ = resp.Body.Close() }()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, resp.StatusCode, fmt.Errorf("waitlist read: %w", err)
}
return json.RawMessage(raw), resp.StatusCode, nil
}
// waitlist answers GET /v1/admin/waitlist — the leaderboard/list for one waitlist,
// proxied from the engine (GET /v1/waitlist/list?waitlist=&page=&pageSize=). Honest
// "not configured" empty when the engine is absent on this deployment (never fabricated).
func (s *svc) waitlist(c *zip.Ctx) error {
base, secret, configured := waitlistConfig()
if !configured {
return c.JSON(200, map[string]any{"status": "ok", "msg": "the waitlist engine is not configured on this deployment", "data": map[string]any{}, "data2": 0})
}
q := url.Values{}
for _, k := range []string{"waitlist", "page", "pageSize"} {
if v := strings.TrimSpace(c.Query(k)); v != "" {
q.Set(k, v)
}
}
target := base + "/v1/waitlist/list"
if len(q) > 0 {
target += "?" + q.Encode()
}
raw, code, err := s.waitlistProxy(c.Context(), http.MethodGet, target, secret, nil)
if err != nil {
return fail(c, err.Error())
}
if code/100 != 2 {
return fail(c, fmt.Sprintf("waitlist engine returned http %d", code))
}
return ok(c, raw) // data = the engine list payload verbatim (the console normalizes)
}
// waitlistBoostRequest is the POST /v1/admin/waitlist/boost body.
type waitlistBoostRequest struct {
Waitlist string `json:"waitlist"`
Email string `json:"email"`
RefCode string `json:"refCode"`
Points int `json:"points"`
Reason string `json:"reason"`
}
// waitlistBoost answers POST /v1/admin/waitlist/boost — grant a user waitlist points to
// move them up toward the access cutoff. It funnels through the engine's verified grant
// seam (POST /v1/waitlist/award, source="grant" — the one path that honors an explicit
// points amount) and audits the grant to cloud's tamper-evident trail.
func (s *svc) waitlistBoost(c *zip.Ctx) error {
base, secret, configured := waitlistConfig()
if !configured {
return fail(c, "the waitlist engine is not configured on this deployment")
}
var body waitlistBoostRequest
if err := c.Bind(&body); err != nil {
return fail(c, "invalid request body")
}
body.Waitlist = strings.TrimSpace(body.Waitlist)
body.Email = strings.TrimSpace(body.Email)
body.RefCode = strings.TrimSpace(body.RefCode)
if body.Waitlist == "" || (body.Email == "" && body.RefCode == "") {
return fail(c, "waitlist and (email or refCode) are required")
}
if body.Points <= 0 {
return fail(c, "points must be a positive number")
}
payload := map[string]any{"waitlist": body.Waitlist, "source": "grant", "points": body.Points}
if body.Email != "" {
payload["email"] = body.Email
}
if body.RefCode != "" {
payload["refCode"] = body.RefCode
}
enc, _ := json.Marshal(payload)
raw, code, err := s.waitlistProxy(c.Context(), http.MethodPost, base+"/v1/waitlist/award", secret, enc)
target := body.Email
if target == "" {
target = body.RefCode
}
result := "success"
if err != nil || code/100 != 2 {
result = "error"
}
s.emitAudit(c, "admin.waitlist.grant", "waitlist", body.Waitlist,
map[string]any{"target": target},
map[string]any{"points": body.Points, "reason": body.Reason, "source": "grant"},
audit.Outcome{Result: result, Status: code})
if err != nil {
return fail(c, err.Error())
}
if code/100 != 2 {
return fail(c, fmt.Sprintf("waitlist grant failed (http %d): %s", code, strings.TrimSpace(string(raw))))
}
return ok(c, raw)
}
+754
View File
@@ -0,0 +1,754 @@
// Package affiliates mounts the Hanzo Cloud /v1/affiliates/* partner-commission
// surface: a native-Go, per-org affiliate program on Base/SQLite that pays partners
// an ONGOING COMMISSION on the metered spend of the customers they refer. It sits
// next to clients/referrals (a one-time credit for both sides) as the OTHER growth
// loop — the recurring, partner-revenue one — and mirrors its structure exactly:
// one SQLite store, server-side tenant isolation, one Mount, HIP-0106, and the SAME
// commerce ledger path (a credits payout is a grant, tag grant:affiliate).
//
// The loop, end to end:
//
// 1. An org APPLIES to be an affiliate (POST /v1/affiliates/apply), optionally
// requesting a vanity code. Staff APPROVE it (POST /v1/admin/affiliates/:id/
// approve), which mints the code (vanity if free, else a derived slug) and sets
// a commission rate (default 20%). The affiliate now has a link
// https://<brand>/?aff=<code>.
// 2. A new org signs up via the link → the console posts POST /v1/affiliates/
// attribute with the code → we record referred_org↔affiliate (first-touch,
// one per referred org, self-attribution blocked).
// 3. The ACCRUAL SWEEP (POST /v1/admin/affiliates/sweep, the cron path; also lazy
// on the affiliate's own dashboard read) folds over each affiliate's referred
// orgs: commission = the referred org's metered spend THIS PERIOD × the rate,
// accrued into the affiliate's balance as an affiliate_event. The accrual is
// LATCHED at-most-once per (affiliate, referred_org, period) — a re-run in the
// same period never double-accrues, mirroring the referral credit latch.
// 4. Staff PAY OUT accrued commission (POST /v1/admin/affiliates/:id/payout):
// a "credits" method issues a commerce grant into the affiliate's wallet; cash
// methods (wire/paypal/…) are record-only. A payout can never exceed pending
// (accrued paid), guarded atomically.
//
// Surface:
//
// GET /v1/affiliates (org) my status, code, link, referred count, accrued/pending/paid, payouts
// POST /v1/affiliates/apply (org) apply to the program (optional vanity code)
// POST /v1/affiliates/attribute (org=referred) record attribution from an ?aff code
// GET /v1/admin/affiliates (global-admin) every affiliate + a summary
// POST /v1/admin/affiliates/:id/approve (global-admin) approve + mint the code
// POST /v1/admin/affiliates/:id/suspend (global-admin) suspend
// POST /v1/admin/affiliates/:id/payout (global-admin) record a payout (credits → grant; cash → record-only)
// POST /v1/admin/affiliates/sweep (global-admin) accrue commission for every referred org this period
//
// serve.go auto-registers GET /v1/affiliates/health.
package affiliates
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/audit"
"github.com/hanzoai/cloud/clients/principal"
"github.com/hanzoai/cloud/clients/treasury"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// The affiliate economy — ONE place. Amounts are USD minor units (cents); a credits
// payout lands in the commerce Credit/trial bucket (grant:* → Credit per DepositKind),
// distinct from grant:referral / grant:admin only by its tag.
const (
// defaultRateBps is the commission rate a new affiliate gets, in basis points
// (2000 = 20% of a referred org's metered spend).
defaultRateBps int64 = 2000
// bpsDenom converts basis points to a fraction (spend × rateBps / 10000).
bpsDenom int64 = 10000
// grantCurrency is the ledger currency for a credits payout.
grantCurrency = "usd"
// grantTag classifies a credits payout as a non-cash Credit in commerce's
// DepositKind (grant:* → Credit), distinct from admin's grant:admin + referrals'
// grant:referral so the ledger/audit can tell an affiliate payout apart.
grantTag = "grant:affiliate"
// methodCredits is the ONE payout method that issues a commerce grant; every
// other method (wire/paypal/check/…) is a record-only cash disbursement.
methodCredits = "credits"
)
const (
// sweepLimit bounds one accrual sweep (admin sweep + lazy-on-read) so an
// unbounded set can't wedge a single request.
sweepLimit = 500
// listLimit / maxAdminLimit bound the read responses; payoutLimit bounds the
// per-affiliate payout history.
listLimit = 500
maxAdminLimit = 1000
payoutLimit = 100
)
type svc struct {
store *Store
commerce commerce
log luxlog.Logger
linkBase string // https://hanzo.ai (brand host) — the ?aff link prefix
auditStore *audit.Recorder // best-effort payout/accrual audit; nil disables it
}
var mounted *svc
// Mount wires the affiliates surface onto app per HIP-0106.
func Mount(app *zip.App, deps cloud.Deps) error {
if app == nil {
return fmt.Errorf("affiliates.Mount: nil zip.App")
}
log := deps.Logger
if log == nil {
return fmt.Errorf("affiliates.Mount: nil deps.Logger")
}
log = log.New("subsystem", "affiliates")
if deps.DataDir == "" {
return fmt.Errorf("affiliates.Mount: empty DataDir")
}
if err := os.MkdirAll(deps.DataDir, 0o755); err != nil {
return fmt.Errorf("affiliates.Mount: data dir: %w", err)
}
store, err := openStore(filepath.Join(deps.DataDir, "affiliates.db"))
if err != nil {
return fmt.Errorf("affiliates.Mount: open store: %w", err)
}
s := &svc{
store: store,
commerce: newCommerceClient(os.Getenv("CLOUD_COMMERCE_HTTP_URL"), os.Getenv("COMMERCE_SERVICE_TOKEN")),
log: log,
linkBase: linkBase(deps),
auditStore: deps.Audit,
}
mounted = s
app.Get("/v1/affiliates", s.myAffiliates)
app.Post("/v1/affiliates/apply", s.apply)
app.Post("/v1/affiliates/attribute", s.attribute)
app.Get("/v1/admin/affiliates", s.adminList)
app.Post("/v1/admin/affiliates/sweep", s.adminSweep)
app.Post("/v1/admin/affiliates/:id/approve", s.adminApprove)
app.Post("/v1/admin/affiliates/:id/suspend", s.adminSuspend)
app.Post("/v1/admin/affiliates/:id/payout", s.adminPayout)
log.Info("affiliates mounted", "brand", deps.Brand, "linkBase", s.linkBase, "commerce", s.commerce.configured())
return nil
}
func init() {
// Order 144: a free slot before the AI /v1/* catch-all (150). No ordering
// dependency (it owns its own store + fans out to commerce over HTTP); its
// routes are all specific (/v1/affiliates*, /v1/admin/affiliates*), so they bind
// ahead of the catch-all regardless. The static /sweep binds before the /:id/*
// param routes (distinct segment counts).
cloud.Register("affiliates", 144, cloud.Typed(Mount))
}
// ── customer surface ─────────────────────────────────────────────────────────
// myAffiliates answers GET /v1/affiliates for the validated caller. If the org is
// not (yet) an affiliate it returns an honest "not enrolled" shape so the console
// shows the apply form; otherwise it returns the dashboard (status, code, link,
// rate, referred count, accrued/pending/paid, payout history). For an APPROVED
// affiliate it ALSO opportunistically runs the accrual sweep over its own referred
// orgs, so the dashboard is self-updating (bounded, best-effort).
func (s *svc) myAffiliates(c *zip.Ctx) error {
org, ok := principal.Tenant(c)
if !ok {
return zip.ErrForbidden("sign in to view your affiliate program")
}
ctx := c.Context()
a, err := s.store.GetByOrg(ctx, org)
if err == errNotFound {
return c.JSON(http.StatusOK, map[string]any{
"isAffiliate": false,
"defaultRateBps": defaultRateBps,
})
}
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "load affiliate: %v", err)
}
// Lazy accrual sweep for MY referred orgs (bounded, best-effort — a commerce
// hiccup never fails the page; it simply accrues on the next sweep).
if a.Status == StatusApproved {
if _, _, serr := s.sweepAffiliate(ctx, a); serr != nil {
s.log.Warn("affiliates: lazy sweep failed", "affiliate", a.ID, "err", serr)
}
if refreshed, rerr := s.store.GetByID(ctx, a.ID); rerr == nil {
a = refreshed // pick up any accrual the lazy sweep just latched
}
}
referred, err := s.store.CountReferrals(ctx, a.ID)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "count referrals: %v", err)
}
payouts, err := s.store.ListPayouts(ctx, a.ID, payoutLimit)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "list payouts: %v", err)
}
return c.JSON(http.StatusOK, map[string]any{
"isAffiliate": true,
"id": a.ID,
"status": a.Status,
"code": a.Code,
"requestedCode": a.RequestedCode,
"link": s.affiliateLink(a.Code),
"rateBps": a.RateBps,
"referredCount": referred,
"accruedCents": a.AccruedCents,
"pendingCents": a.PendingCents(),
"paidCents": a.PaidCents,
"payouts": payoutViews(payouts),
})
}
// applyRequest is the POST /v1/affiliates/apply body: an optional requested vanity
// code (staff approves + mints it).
type applyRequest struct {
RequestedCode string `json:"requestedCode"`
}
// apply enrolls the validated caller's org as an affiliate at status=applied.
// Idempotent (one affiliate per org, first apply wins). A malformed vanity code is
// refused up front.
func (s *svc) apply(c *zip.Ctx) error {
org, ok := principal.Tenant(c)
if !ok {
return zip.ErrForbidden("sign in to apply as an affiliate")
}
var body applyRequest
if err := c.Bind(&body); err != nil {
return err
}
code := normalizeCode(body.RequestedCode)
if code != "" && !validCode(code) {
return zip.ErrBadRequest("requested code must be 332 chars of az, 09, hyphen")
}
ctx := c.Context()
id, err := genID("aff")
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "rng: %v", err)
}
a, created, err := s.store.Apply(ctx, id, org, code, defaultRateBps)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "apply: %v", err)
}
status := http.StatusOK
if created {
status = http.StatusCreated
}
return c.JSON(status, map[string]any{
"id": a.ID,
"status": a.Status,
"code": a.Code,
"requestedCode": a.RequestedCode,
"rateBps": a.RateBps,
"created": created,
})
}
// attributeRequest is the POST /v1/affiliates/attribute body: the affiliate's code
// the referred org arrived with (from an ?aff= link, stashed at signup).
type attributeRequest struct {
Code string `json:"code"`
}
// attribute records an affiliate↔referred-org edge. The REFERRED org is the
// validated caller (never client-supplied); the affiliate is resolved from the code
// (approved affiliates only). Idempotent (one per referred org, first-touch wins),
// self-attribution blocked, unknown code rejected.
func (s *svc) attribute(c *zip.Ctx) error {
referredOrg, ok := principal.Tenant(c)
if !ok {
return zip.ErrForbidden("sign in to record an affiliate")
}
var body attributeRequest
if err := c.Bind(&body); err != nil {
return err
}
code := normalizeCode(body.Code)
if code == "" {
return zip.ErrBadRequest("code is required")
}
ctx := c.Context()
aff, err := s.store.AffiliateForCode(ctx, code)
if err != nil {
if err == errUnknownCode {
return zip.ErrNotFound("unknown affiliate code")
}
return zip.Errorf(http.StatusInternalServerError, "resolve code: %v", err)
}
if aff.Org == referredOrg {
return zip.ErrBadRequest("cannot attribute yourself")
}
id, err := genID("afr")
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "rng: %v", err)
}
edge, created, err := s.store.Attribute(ctx, id, aff.ID, referredOrg, aff.Org, code)
if err != nil {
if err == errSelfAttribution {
return zip.ErrBadRequest("cannot attribute yourself")
}
return zip.Errorf(http.StatusInternalServerError, "attribute: %v", err)
}
status := http.StatusOK
if created {
status = http.StatusCreated
}
return c.JSON(status, map[string]any{
"id": edge.ID,
"code": edge.Code,
"created": created,
"createdAt": edge.CreatedAt,
})
}
// ── admin surface (global-admin, fail-closed) ────────────────────────────────
// adminList answers GET /v1/admin/affiliates — every affiliate (org exposed) + a
// fleet summary. Global-admin only.
func (s *svc) adminList(c *zip.Ctx) error {
if !c.IsAdmin() {
return zip.ErrForbidden("global admin required")
}
ctx := c.Context()
rows, err := s.store.ListAll(ctx, adminLimitOf(c))
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "list affiliates: %v", err)
}
counts, err := s.store.ReferralCountsByAffiliate(ctx)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "count referrals: %v", err)
}
views := make([]adminAffiliateView, 0, len(rows))
sum := adminSummary{}
for _, a := range rows {
sum.add(a)
views = append(views, adminViewOf(a, counts[a.ID]))
}
return adminOK(c, map[string]any{"affiliates": views, "summary": sum})
}
// adminApprove answers POST /v1/admin/affiliates/:id/approve — approve + mint the
// code. Body may carry an explicit {code} override; else the requested vanity code;
// else a derived slug. Global-admin only.
func (s *svc) adminApprove(c *zip.Ctx) error {
if !c.IsAdmin() {
return zip.ErrForbidden("global admin required")
}
id := strings.TrimSpace(c.Param("id"))
var body struct {
Code string `json:"code"`
}
_ = c.Bind(&body) // body is optional
ctx := c.Context()
a, err := s.store.Approve(ctx, id, body.Code, time.Now().Unix())
if err != nil {
switch err {
case errNotFound:
return zip.ErrNotFound("affiliate not found")
case errInvalidCode:
return zip.ErrBadRequest("code must be 332 chars of az, 09, hyphen")
case errCodeTaken:
return zip.ErrConflict("that code is already taken")
default:
return zip.Errorf(http.StatusInternalServerError, "approve: %v", err)
}
}
s.emitAudit(ctx, "affiliate.approve", a, map[string]any{"code": a.Code, "rateBps": a.RateBps})
return adminOK(c, map[string]any{"affiliate": adminViewOf(a, 0)})
}
// adminSuspend answers POST /v1/admin/affiliates/:id/suspend. Global-admin only.
func (s *svc) adminSuspend(c *zip.Ctx) error {
if !c.IsAdmin() {
return zip.ErrForbidden("global admin required")
}
id := strings.TrimSpace(c.Param("id"))
ctx := c.Context()
a, err := s.store.Suspend(ctx, id, time.Now().Unix())
if err != nil {
if err == errNotFound {
return zip.ErrNotFound("affiliate not found")
}
return zip.Errorf(http.StatusInternalServerError, "suspend: %v", err)
}
s.emitAudit(ctx, "affiliate.suspend", a, nil)
return adminOK(c, map[string]any{"affiliate": adminViewOf(a, 0)})
}
// payoutRequest is the POST /v1/admin/affiliates/:id/payout body.
type payoutRequest struct {
AmountCents int64 `json:"amountCents"`
Method string `json:"method"`
Reference string `json:"reference"`
}
// adminPayout records a payout of accrued commission. A "credits" method issues a
// commerce grant into the affiliate's wallet; a cash method (wire/paypal/…) is
// record-only. The amount can never exceed pending (accrued paid), reserved
// atomically before any grant. Global-admin only.
func (s *svc) adminPayout(c *zip.Ctx) error {
if !c.IsAdmin() {
return zip.ErrForbidden("global admin required")
}
id := strings.TrimSpace(c.Param("id"))
var body payoutRequest
if err := c.Bind(&body); err != nil {
return err
}
if body.AmountCents <= 0 {
return zip.ErrBadRequest("amountCents must be positive")
}
method := strings.ToLower(strings.TrimSpace(body.Method))
if method == "" {
return zip.ErrBadRequest("method is required (credits, wire, paypal, …)")
}
ctx := c.Context()
a, err := s.store.GetByID(ctx, id)
if err != nil {
if err == errNotFound {
return zip.ErrNotFound("affiliate not found")
}
return zip.Errorf(http.StatusInternalServerError, "load affiliate: %v", err)
}
payoutID, err := genID("apo")
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "rng: %v", err)
}
// Reserve against pending FIRST (atomic guard) — a payout can never exceed owed.
payout, err := s.store.RecordPayout(ctx, payoutID, a.ID, body.AmountCents, method, strings.TrimSpace(body.Reference), time.Now().Unix())
if err != nil {
switch err {
case errNotFound:
return zip.ErrNotFound("affiliate not found")
case errInsufficientPending:
return zip.ErrBadRequest(fmt.Sprintf("amount exceeds pending commission (%d cents available)", a.PendingCents()))
default:
return zip.Errorf(http.StatusInternalServerError, "record payout: %v", err)
}
}
// BACK the payout against the platform reserve fund (double-entry
// fund→payout:affiliate, idempotent by payout id). This is the SECOND guard: a
// payout must not exceed EITHER the affiliate's pending commission (above) OR the
// funded reserve (here). Not backed → VOID the pending reservation (restore it)
// and refuse honestly — the platform has not reserved capital for this payout.
backed, _, berr := treasury.Reserve(ctx, treasury.ProgramAffiliate, "payout:"+payoutID,
fmt.Sprintf("Affiliate commission payout (%s)", a.Code), body.AmountCents)
if berr != nil || !backed {
if verr := s.store.VoidPayout(ctx, payoutID, a.ID, body.AmountCents); verr != nil {
s.log.Error("affiliates: void after unbacked payout failed", "payout", payoutID, "err", verr)
}
if berr != nil {
return zip.Errorf(http.StatusInternalServerError, "reserve payout: %v", berr)
}
reserve, _ := treasury.ReserveCents(ctx)
return zip.Errorf(http.StatusPaymentRequired,
"treasury reserve insufficient to back this payout (%d cents available); replenish via /v1/admin/treasury/sweep or seed", reserve)
}
// A credits payout issues the actual grant AFTER both reservations. The
// reservations are the safety authority (at-most-pending AND at-most-reserve); a
// grant failure is logged loud (never silent) so an operator reconciles from the
// payout row + audit.
if method == methodCredits {
txn, gerr := s.commerce.deposit(ctx, a.Org, orgSubject(a.Org), body.AmountCents, grantCurrency,
fmt.Sprintf("Affiliate commission payout (%s)", a.Code), grantTag)
if gerr != nil {
s.log.Error("affiliates: credits payout grant failed (reserved against pending; not retried)",
"affiliate", a.ID, "payout", payoutID, "err", gerr)
} else if serr := s.store.SetPayoutTxn(ctx, payoutID, txn); serr != nil {
s.log.Error("affiliates: record payout txn failed", "payout", payoutID, "err", serr)
}
payout.Txn = txn
}
after, _ := s.store.GetByID(ctx, a.ID)
s.emitAudit(ctx, "affiliate.payout", after, map[string]any{
"payoutId": payout.ID, "amountCents": payout.AmountCents, "method": payout.Method,
"reference": payout.Reference, "txn": payout.Txn,
})
return adminOK(c, map[string]any{"payout": payoutViewOf(payout), "affiliate": adminViewOf(after, 0)})
}
// adminSweep answers POST /v1/admin/affiliates/sweep — the periodic accrual path (a
// cron/o11y hits it, or an operator on demand). It folds over every approved
// affiliate's referred orgs and accrues this period's commission, at-most-once per
// period. Global-admin only.
func (s *svc) adminSweep(c *zip.Ctx) error {
if !c.IsAdmin() {
return zip.ErrForbidden("global admin required")
}
ctx := c.Context()
approved, err := s.store.ListApproved(ctx, sweepLimit)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "list approved: %v", err)
}
swept, accrued := 0, 0
for _, a := range approved {
checked, credited, serr := s.sweepAffiliate(ctx, a)
swept += checked
accrued += credited
if serr != nil {
s.log.Warn("affiliates: sweep affiliate failed", "affiliate", a.ID, "err", serr)
}
}
return adminOK(c, map[string]any{"swept": swept, "accrued": accrued})
}
// ── accrual core (the ONE commission path, shared by sweep + lazy read) ────────
// sweepAffiliate folds over one affiliate's referred orgs and accrues this period's
// commission for each (spend × rate), latched at-most-once per period. Returns
// (edges checked, accruals created). A per-edge commerce error is skipped (accrued
// next sweep) rather than failing the whole fold.
func (s *svc) sweepAffiliate(ctx context.Context, a Affiliate) (checked, created int, err error) {
edges, err := s.store.ListReferrals(ctx, a.ID, sweepLimit)
if err != nil {
return 0, 0, err
}
period := periodKey(time.Now())
now := time.Now().Unix()
for _, edge := range edges {
checked++
spend, serr := s.commerce.spendCents(ctx, edge.ReferredOrg, orgSubject(edge.ReferredOrg))
if serr != nil {
s.log.Warn("affiliates: spend read failed", "affiliate", a.ID, "referred", edge.ReferredOrg, "err", serr)
continue
}
commission := spend * a.RateBps / bpsDenom
if commission <= 0 {
continue // no spend to accrue yet this period
}
accrualID, gerr := genID("aca")
if gerr != nil {
continue
}
won, lerr := s.store.LatchAccrual(ctx, accrualID, a.ID, edge.ReferredOrg, period, spend, commission, now)
if lerr != nil {
s.log.Warn("affiliates: accrual latch failed", "affiliate", a.ID, "referred", edge.ReferredOrg, "err", lerr)
continue
}
if won {
created++
s.emitAudit(ctx, "affiliate.accrue", a, map[string]any{
"referredOrg": edge.ReferredOrg, "period": period,
"spendCents": spend, "commissionCents": commission,
})
}
}
return checked, created, nil
}
// ── audit ─────────────────────────────────────────────────────────────────────
// emitAudit records an affiliate money/lifecycle action in cloud's tamper-evident
// trail. Best-effort; a nil store is a no-op. The actor is the affiliate engine (a
// system action), scoped to the affiliate's own org.
func (s *svc) emitAudit(ctx context.Context, action string, a Affiliate, extra map[string]any) {
if s.auditStore == nil {
return
}
after := map[string]any{"affiliateId": a.ID, "org": a.Org, "code": a.Code, "status": a.Status}
for k, v := range extra {
after[k] = v
}
rec := audit.Record{
Actor: audit.Actor{Org: a.Org, Sub: "affiliates"},
Action: action,
Resource: audit.Resource{Type: "affiliate", ID: a.ID},
Auth: audit.AuthContext{Method: "service"},
Outcome: audit.Outcome{Result: "success", Status: 200},
After: audit.Redact(mustJSON(after)),
}
if _, err := s.auditStore.Append(ctx, rec); err != nil {
s.log.Error("affiliates: audit emit failed", "affiliate", a.ID, "action", action, "err", err)
}
}
// ── view models + helpers ─────────────────────────────────────────────────────
// adminAffiliateView is one row in the global-admin directory (org exposed).
type adminAffiliateView struct {
ID string `json:"id"`
Org string `json:"org"`
Code string `json:"code"`
RequestedCode string `json:"requestedCode,omitempty"`
Status string `json:"status"`
RateBps int64 `json:"rateBps"`
ReferredCount int `json:"referredCount"`
AccruedCents int64 `json:"accruedCents"`
PendingCents int64 `json:"pendingCents"`
PaidCents int64 `json:"paidCents"`
CreatedAt int64 `json:"createdAt"`
ApprovedAt int64 `json:"approvedAt"`
SuspendedAt int64 `json:"suspendedAt"`
}
func adminViewOf(a Affiliate, referred int) adminAffiliateView {
return adminAffiliateView{
ID: a.ID, Org: a.Org, Code: a.Code, RequestedCode: a.RequestedCode, Status: a.Status,
RateBps: a.RateBps, ReferredCount: referred, AccruedCents: a.AccruedCents,
PendingCents: a.PendingCents(), PaidCents: a.PaidCents,
CreatedAt: a.CreatedAt, ApprovedAt: a.ApprovedAt, SuspendedAt: a.SuspendedAt,
}
}
// payoutView is one row of an affiliate's payout history.
type payoutView struct {
ID string `json:"id"`
AmountCents int64 `json:"amountCents"`
Method string `json:"method"`
Reference string `json:"reference,omitempty"`
Txn string `json:"txn,omitempty"`
CreatedAt int64 `json:"createdAt"`
}
func payoutViewOf(p Payout) payoutView {
return payoutView{ID: p.ID, AmountCents: p.AmountCents, Method: p.Method, Reference: p.Reference, Txn: p.Txn, CreatedAt: p.CreatedAt}
}
func payoutViews(ps []Payout) []payoutView {
out := make([]payoutView, 0, len(ps))
for _, p := range ps {
out = append(out, payoutViewOf(p))
}
return out
}
// adminSummary is the fleet tally for the admin directory.
type adminSummary struct {
Total int `json:"total"`
Applied int `json:"applied"`
Approved int `json:"approved"`
Suspended int `json:"suspended"`
AccruedCents int64 `json:"accruedCents"`
PendingCents int64 `json:"pendingCents"`
PaidCents int64 `json:"paidCents"`
}
func (s *adminSummary) add(a Affiliate) {
s.Total++
switch a.Status {
case StatusApplied:
s.Applied++
case StatusApproved:
s.Approved++
case StatusSuspended:
s.Suspended++
}
s.AccruedCents += a.AccruedCents
s.PendingCents += a.PendingCents()
s.PaidCents += a.PaidCents
}
// affiliateLink builds the ?aff link for a code ("" when the affiliate has no code
// yet — un-approved).
func (s *svc) affiliateLink(code string) string {
if code == "" {
return ""
}
return s.linkBase + "/?aff=" + code
}
// adminOK writes the { status:"ok", msg, data } envelope the console's admin
// surface (originGet/originPost via app/admin/aggregate) unwraps — identical to
// clients/admin's ok() and clients/referrals' adminOK. The customer /v1/affiliates
// surface stays bare JSON (read via the /cloud proxy + restGet).
func adminOK(c *zip.Ctx, data any) error {
return c.JSON(http.StatusOK, map[string]any{"status": "ok", "msg": "", "data": data})
}
// orgSubject is the billing subject commerce keys an org's wallet on — the bare org
// slug, exactly like clients/admin.orgSubject + clients/referrals.orgSubject. Kept
// as a named function so the "subject == org" contract lives in one place.
func orgSubject(org string) string { return org }
// periodKey is the accrual period bucket — the UTC year-month (YYYY-MM). Commerce's
// usage rollup is month-to-date, so one accrual per referred org per month is the
// at-most-once unit.
func periodKey(t time.Time) string { return t.UTC().Format("2006-01") }
// genID returns a prefixed, collision-resistant id (prefix + 128 random bits).
func genID(prefix string) (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return prefix + "_" + hex.EncodeToString(b[:]), nil
}
func adminLimitOf(c *zip.Ctx) int {
n, err := strconv.Atoi(strings.TrimSpace(c.Query("limit")))
if err != nil || n <= 0 {
return listLimit
}
if n > maxAdminLimit {
return maxAdminLimit
}
return n
}
// mustJSON marshals v for the audit After payload, returning an empty object on the
// (unexpected) marshal error rather than crashing a money action.
func mustJSON(v any) json.RawMessage {
b, err := json.Marshal(v)
if err != nil {
return json.RawMessage(`{}`)
}
return b
}
// linkBase resolves the ?aff link prefix. AFFILIATE_LINK_BASE wins; else
// REFERRAL_LINK_BASE (the sibling loop shares the brand host); else the brand's
// public host; else hanzo.ai. White-label by brand so a Lux/Zoo deployment mints
// its OWN link, never hanzo.ai.
func linkBase(deps cloud.Deps) string {
if v := strings.TrimSpace(os.Getenv("AFFILIATE_LINK_BASE")); v != "" {
return strings.TrimRight(v, "/")
}
if v := strings.TrimSpace(os.Getenv("REFERRAL_LINK_BASE")); v != "" {
return strings.TrimRight(v, "/")
}
switch strings.ToLower(strings.TrimSpace(deps.Brand)) {
case "lux":
return "https://lux.network"
case "zoo":
return "https://zoo.ngo"
case "pars":
return "https://pars.ai"
default:
return "https://hanzo.ai"
}
}
// Shutdown closes the affiliates store. Idempotent.
func Shutdown() error {
if mounted == nil || mounted.store == nil {
return nil
}
err := mounted.store.Close()
mounted = nil
return err
}
+584
View File
@@ -0,0 +1,584 @@
package affiliates
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strconv"
"sync"
"testing"
"github.com/hanzoai/cloud"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// fakeCommerce is an in-memory commerce ledger: it records deposits per org (the
// wallet balance) and lets a test SET a referred org's metered spend (the accrual
// base). It is the money-seam stand-in that lets the tests PROVE commission accrues
// (spend × rate) and a credits payout moves a wallet — without a live commerce.
type fakeCommerce struct {
mu sync.Mutex
balance map[string]int64 // org → deposited cents (the wallet)
spend map[string]int64 // org → metered spend cents (accrual base)
deposits int // total deposit calls (one-grant proof)
failDep bool // when true, deposit errors (at-most-pending log path)
seq int
}
func newFakeCommerce() *fakeCommerce {
return &fakeCommerce{balance: map[string]int64{}, spend: map[string]int64{}}
}
func (f *fakeCommerce) configured() bool { return true }
func (f *fakeCommerce) deposit(_ context.Context, org, _ string, amountCents int64, _, _, _ string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.failDep {
return "", errUnconfigured
}
f.balance[org] += amountCents
f.deposits++
f.seq++
return "txn_test_" + org + "_" + strconv.Itoa(f.seq), nil
}
func (f *fakeCommerce) spendCents(_ context.Context, org, _ string) (int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.spend[org], nil
}
func (f *fakeCommerce) setSpend(org string, cents int64) {
f.mu.Lock()
defer f.mu.Unlock()
f.spend[org] = cents
}
func (f *fakeCommerce) bal(org string) int64 {
f.mu.Lock()
defer f.mu.Unlock()
return f.balance[org]
}
func (f *fakeCommerce) depositCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.deposits
}
// mount builds an affiliates app backed by a fresh store + the injected fake
// commerce, returning the app, the svc, and the fake for assertions.
func mount(t *testing.T) (*zip.App, *svc, *fakeCommerce) {
t.Helper()
store, err := openStore(t.TempDir() + "/affiliates.db")
if err != nil {
t.Fatalf("openStore: %v", err)
}
t.Cleanup(func() { _ = store.Close() })
fc := newFakeCommerce()
s := &svc{
store: store,
commerce: fc,
log: luxlog.New("test"),
linkBase: "https://hanzo.ai",
}
app := zip.New(zip.Config{Logger: luxlog.New("test")})
app.Get("/v1/affiliates", s.myAffiliates)
app.Post("/v1/affiliates/apply", s.apply)
app.Post("/v1/affiliates/attribute", s.attribute)
app.Get("/v1/admin/affiliates", s.adminList)
app.Post("/v1/admin/affiliates/sweep", s.adminSweep)
app.Post("/v1/admin/affiliates/:id/approve", s.adminApprove)
app.Post("/v1/admin/affiliates/:id/suspend", s.adminSuspend)
app.Post("/v1/admin/affiliates/:id/payout", s.adminPayout)
return app, s, fc
}
// req drives one HTTP request. org sets a VALIDATED principal (X-Org-Id +
// X-User-Id, the Tenant() gate); admin additionally sets X-User-IsAdmin.
func req(t *testing.T, app *zip.App, method, path, org string, admin bool, body any) (int, []byte) {
t.Helper()
var r io.Reader
if body != nil {
b, _ := json.Marshal(body)
r = bytes.NewReader(b)
}
hr := httptest.NewRequest(method, path, r)
if body != nil {
hr.Header.Set("Content-Type", "application/json")
}
if org != "" {
hr.Header.Set("X-Org-Id", org)
hr.Header.Set("X-User-Id", "u_"+org)
}
if admin {
hr.Header.Set("X-User-IsAdmin", "true")
}
resp, err := app.Fiber().Test(hr)
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
}
// envData pulls the `data` object out of an admin envelope {status,msg,data}.
func envData(t *testing.T, body []byte) map[string]json.RawMessage {
t.Helper()
var env struct {
Status string `json:"status"`
Data map[string]json.RawMessage `json:"data"`
}
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode envelope: %v (%s)", err, body)
}
return env.Data
}
// applyAndApprove applies for `org` (optional requested code) and staff-approves it
// with `approveCode`, returning the affiliate id and its minted code.
func applyAndApprove(t *testing.T, app *zip.App, s *svc, org, requestedCode, approveCode string) (id, code string) {
t.Helper()
code2, body := req(t, app, http.MethodPost, "/v1/affiliates/apply", org, false, map[string]any{"requestedCode": requestedCode})
if code2 != http.StatusCreated {
t.Fatalf("apply(%s) want 201, got %d (%s)", org, code2, body)
}
var ar struct {
ID string `json:"id"`
}
_ = json.Unmarshal(body, &ar)
if ar.ID == "" {
t.Fatalf("apply(%s) returned no id (%s)", org, body)
}
st, ab := req(t, app, http.MethodPost, "/v1/admin/affiliates/"+ar.ID+"/approve", "admin", true, map[string]any{"code": approveCode})
if st != http.StatusOK {
t.Fatalf("approve(%s) want 200, got %d (%s)", org, st, ab)
}
a, err := s.store.GetByID(context.Background(), ar.ID)
if err != nil {
t.Fatalf("GetByID after approve: %v", err)
}
if a.Status != StatusApproved {
t.Fatalf("approve(%s) status = %q, want approved", org, a.Status)
}
return a.ID, a.Code
}
// TestDeriveCodeAndValidCode: derived codes are stable + distinct + lowercase
// base32; validCode enforces the vanity charset.
func TestDeriveCodeAndValidCode(t *testing.T) {
a1 := deriveCode("maxpower", 0)
if a1 != deriveCode("maxpower", 0) {
t.Fatalf("deriveCode not deterministic")
}
if a1 == deriveCode("acme", 0) {
t.Fatalf("distinct orgs collided: %q", a1)
}
if deriveCode("maxpower", 1) == a1 {
t.Fatalf("salted code equals unsalted")
}
for _, r := range a1 {
if !((r >= 'a' && r <= 'z') || (r >= '2' && r <= '7')) {
t.Fatalf("derived code %q has non-lowercase-base32 char %q", a1, r)
}
}
for _, ok := range []string{"acme", "acme-labs", "a1b2", "launch2026"} {
if !validCode(ok) {
t.Fatalf("validCode(%q) = false, want true", ok)
}
}
for _, bad := range []string{"", "ab", "-acme", "acme-", "ACME", "a b", "acme_labs", "acmé"} {
if validCode(bad) {
t.Fatalf("validCode(%q) = true, want false", bad)
}
}
}
// TestApplyIdempotent: an org applies once → applied; a second apply is a no-op
// returning the FIRST record (first apply wins). No principal → 403.
func TestApplyIdempotent(t *testing.T) {
app, s, _ := mount(t)
if code, _ := req(t, app, http.MethodPost, "/v1/affiliates/apply", "", false, map[string]any{}); code != http.StatusForbidden {
t.Fatalf("no-principal apply want 403, got %d", code)
}
// Malformed vanity code → 400.
if code, _ := req(t, app, http.MethodPost, "/v1/affiliates/apply", "orgA", false, map[string]any{"requestedCode": "x"}); code != http.StatusBadRequest {
t.Fatalf("bad vanity code want 400, got %d", code)
}
// First apply → 201 applied, default rate.
code, body := req(t, app, http.MethodPost, "/v1/affiliates/apply", "orgA", false, map[string]any{"requestedCode": "acme"})
if code != http.StatusCreated {
t.Fatalf("first apply want 201, got %d (%s)", code, body)
}
var a1 struct {
ID, Status, RequestedCode string
RateBps int64
Created bool
}
_ = json.Unmarshal(body, &a1)
if a1.Status != StatusApplied || a1.RateBps != defaultRateBps || a1.RequestedCode != "acme" || !a1.Created {
t.Fatalf("first apply wrong: %+v", a1)
}
// Re-apply → 200, not created; SAME record (first wins, requested code preserved).
code, body = req(t, app, http.MethodPost, "/v1/affiliates/apply", "orgA", false, map[string]any{"requestedCode": "different"})
if code != http.StatusOK {
t.Fatalf("re-apply want 200, got %d (%s)", code, body)
}
var a2 struct {
ID, RequestedCode string
Created bool
}
_ = json.Unmarshal(body, &a2)
if a2.Created || a2.ID != a1.ID || a2.RequestedCode != "acme" {
t.Fatalf("re-apply not idempotent-first-wins: %+v", a2)
}
_ = s
}
// TestApproveMintsCodeAndVanityUniqueness: approve mints the code (vanity, explicit
// override, or derived); a vanity code is uniqueness-enforced across affiliates.
func TestApproveMintsCodeAndVanityUniqueness(t *testing.T) {
app, s, _ := mount(t)
ctx := context.Background()
// orgA requested "launch"; approve mints it.
idA, codeA := applyAndApprove(t, app, s, "orgA", "launch", "")
if codeA != "launch" {
t.Fatalf("orgA code = %q, want launch (the requested vanity)", codeA)
}
// orgB requests the SAME vanity → approve is a 409 conflict.
_, bBody := req(t, app, http.MethodPost, "/v1/affiliates/apply", "orgB", false, map[string]any{"requestedCode": "launch"})
var b struct{ ID string }
_ = json.Unmarshal(bBody, &b)
if st, body := req(t, app, http.MethodPost, "/v1/admin/affiliates/"+b.ID+"/approve", "admin", true, nil); st != http.StatusConflict {
t.Fatalf("duplicate vanity approve want 409, got %d (%s)", st, body)
}
// Re-approve orgB with an explicit free override → 200, that code minted.
if st, body := req(t, app, http.MethodPost, "/v1/admin/affiliates/"+b.ID+"/approve", "admin", true, map[string]any{"code": "launch-b"}); st != http.StatusOK {
t.Fatalf("override approve want 200, got %d (%s)", st, body)
}
bAff, _ := s.store.GetByID(ctx, b.ID)
if bAff.Code != "launch-b" || bAff.Status != StatusApproved {
t.Fatalf("orgB after override: code=%q status=%q", bAff.Code, bAff.Status)
}
// orgC requested nothing → approve derives a stable slug (non-empty, valid).
_, codeC := applyAndApprove(t, app, s, "orgC", "", "")
if codeC == "" || !validCode(codeC) || codeC == "launch" || codeC == "launch-b" {
t.Fatalf("orgC derived code invalid/colliding: %q", codeC)
}
// Approve on a missing id → 404.
if st, _ := req(t, app, http.MethodPost, "/v1/admin/affiliates/aff_missing/approve", "admin", true, nil); st != http.StatusNotFound {
t.Fatalf("approve missing want 404, got %d", st)
}
_ = idA
}
// TestAttributeSelfUnknownAndIdempotent: an ?aff code resolves ONLY for an approved
// affiliate; self-attribution is blocked; first-touch is idempotent.
func TestAttributeSelfUnknownAndIdempotent(t *testing.T) {
app, s, _ := mount(t)
ctx := context.Background()
// An un-approved affiliate has no code → its code can't be attributed.
req(t, app, http.MethodPost, "/v1/affiliates/apply", "orgPending", false, map[string]any{"requestedCode": "pending1"})
if code, _ := req(t, app, http.MethodPost, "/v1/affiliates/attribute", "orgX", false, map[string]any{"code": "pending1"}); code != http.StatusNotFound {
t.Fatalf("attribute to un-approved code want 404, got %d", code)
}
_, codeA := applyAndApprove(t, app, s, "orgA", "acme", "")
// No principal → 403.
if code, _ := req(t, app, http.MethodPost, "/v1/affiliates/attribute", "", false, map[string]any{"code": codeA}); code != http.StatusForbidden {
t.Fatalf("no-principal attribute want 403, got %d", code)
}
// Unknown code → 404.
if code, _ := req(t, app, http.MethodPost, "/v1/affiliates/attribute", "orgB", false, map[string]any{"code": "nope-nope"}); code != http.StatusNotFound {
t.Fatalf("unknown-code attribute want 404, got %d", code)
}
// Self-attribution (orgA uses its OWN code) → 400.
if code, _ := req(t, app, http.MethodPost, "/v1/affiliates/attribute", "orgA", false, map[string]any{"code": codeA}); code != http.StatusBadRequest {
t.Fatalf("self-attribution want 400, got %d", code)
}
// orgB attributes to orgA → 201.
if code, body := req(t, app, http.MethodPost, "/v1/affiliates/attribute", "orgB", false, map[string]any{"code": codeA}); code != http.StatusCreated {
t.Fatalf("first attribute want 201, got %d (%s)", code, body)
}
// Re-attribute (same code) → 200, not created (idempotent, first-touch).
code, body := req(t, app, http.MethodPost, "/v1/affiliates/attribute", "orgB", false, map[string]any{"code": codeA})
if code != http.StatusOK {
t.Fatalf("re-attribute want 200, got %d (%s)", code, body)
}
var re struct {
Created bool `json:"created"`
}
_ = json.Unmarshal(body, &re)
if re.Created {
t.Fatalf("re-attribute reported created=true")
}
// orgB tries a DIFFERENT affiliate's code → still bound to the FIRST (orgA).
_, codeC := applyAndApprove(t, app, s, "orgC", "cee", "")
req(t, app, http.MethodPost, "/v1/affiliates/attribute", "orgB", false, map[string]any{"code": codeC})
edge, err := s.store.getReferralByReferred(ctx, "orgB")
if err != nil {
t.Fatalf("getReferralByReferred: %v", err)
}
if edge.Code != codeA {
t.Fatalf("first-touch broken: code=%q (want %s)", edge.Code, codeA)
}
}
// TestSweepAccruesSpendTimesRateIdempotent is the CORE proof: after a referred org
// makes metered spend, the sweep accrues commission = spend × rate into the
// affiliate's balance, at-most-once per period (a re-sweep never double-accrues).
func TestSweepAccruesSpendTimesRateIdempotent(t *testing.T) {
app, s, fc := mount(t)
ctx := context.Background()
idA, codeA := applyAndApprove(t, app, s, "orgA", "acme", "")
// orgB signs up via orgA's link.
if code, _ := req(t, app, http.MethodPost, "/v1/affiliates/attribute", "orgB", false, map[string]any{"code": codeA}); code != http.StatusCreated {
t.Fatalf("attribute want 201, got %d", code)
}
// No spend yet: a sweep accrues NOTHING.
code, body := req(t, app, http.MethodPost, "/v1/admin/affiliates/sweep", "admin", true, nil)
if code != http.StatusOK {
t.Fatalf("pre-spend sweep want 200, got %d (%s)", code, body)
}
if got := sweptAccrued(t, body); got != 0 {
t.Fatalf("pre-spend sweep accrued=%d, want 0", got)
}
// orgB spends $100 (10000c). Commission @20% = $20 (2000c).
fc.setSpend("orgB", 10000)
code, body = req(t, app, http.MethodPost, "/v1/admin/affiliates/sweep", "admin", true, nil)
if code != http.StatusOK {
t.Fatalf("accrual sweep want 200, got %d (%s)", code, body)
}
if got := sweptAccrued(t, body); got != 1 {
t.Fatalf("accrual sweep accrued=%d, want 1", got)
}
a, _ := s.store.GetByID(ctx, idA)
const wantCommission = 10000 * defaultRateBps / bpsDenom // = 2000
if a.AccruedCents != wantCommission {
t.Fatalf("accrued = %d, want %d (spend×rate)", a.AccruedCents, wantCommission)
}
if a.PendingCents() != wantCommission {
t.Fatalf("pending = %d, want %d", a.PendingCents(), wantCommission)
}
// IDEMPOTENT: a re-sweep in the SAME period accrues nothing more.
req(t, app, http.MethodPost, "/v1/admin/affiliates/sweep", "admin", true, nil)
a2, _ := s.store.GetByID(ctx, idA)
if a2.AccruedCents != wantCommission {
t.Fatalf("double-accrual! accrued = %d, want %d", a2.AccruedCents, wantCommission)
}
// No wallet moved yet (accrual is not a payout).
if fc.depositCount() != 0 {
t.Fatalf("accrual issued a deposit (%d) — it must not", fc.depositCount())
}
}
// TestLazyAccrualOnAffiliateRead proves the affiliate's OWN GET /v1/affiliates runs
// the accrual sweep for its referred orgs (self-updating dashboard).
func TestLazyAccrualOnAffiliateRead(t *testing.T) {
app, s, fc := mount(t)
_, codeA := applyAndApprove(t, app, s, "orgA", "acme", "")
req(t, app, http.MethodPost, "/v1/affiliates/attribute", "orgB", false, map[string]any{"code": codeA})
fc.setSpend("orgB", 5000) // $50 → commission 1000c ($10)
code, body := req(t, app, http.MethodGet, "/v1/affiliates", "orgA", false, nil)
if code != http.StatusOK {
t.Fatalf("GET /v1/affiliates want 200, got %d (%s)", code, body)
}
var v struct {
IsAffiliate bool `json:"isAffiliate"`
Status string `json:"status"`
Code string `json:"code"`
Link string `json:"link"`
ReferredCount int `json:"referredCount"`
AccruedCents int64 `json:"accruedCents"`
PendingCents int64 `json:"pendingCents"`
}
if err := json.Unmarshal(body, &v); err != nil {
t.Fatalf("decode: %v (%s)", err, body)
}
if !v.IsAffiliate || v.Status != StatusApproved || v.Code != codeA {
t.Fatalf("dashboard head wrong: %+v", v)
}
if v.Link != "https://hanzo.ai/?aff="+codeA {
t.Fatalf("link = %q", v.Link)
}
const want = 5000 * defaultRateBps / bpsDenom // 1000
if v.ReferredCount != 1 || v.AccruedCents != want || v.PendingCents != want {
t.Fatalf("lazy accrual not reflected: %+v (want accrued %d)", v, want)
}
}
// TestPayoutCreditsOneGrantCashRecordOnlyAndPendingGuard: a credits payout issues
// exactly ONE commerce grant + moves paid; a cash payout is record-only; a payout
// can never exceed pending.
func TestPayoutCreditsOneGrantCashRecordOnlyAndPendingGuard(t *testing.T) {
app, s, fc := mount(t)
ctx := context.Background()
idA, codeA := applyAndApprove(t, app, s, "orgA", "acme", "")
req(t, app, http.MethodPost, "/v1/affiliates/attribute", "orgB", false, map[string]any{"code": codeA})
fc.setSpend("orgB", 10000) // accrue 2000c pending
req(t, app, http.MethodPost, "/v1/admin/affiliates/sweep", "admin", true, nil)
// Non-admin is refused on payout.
if st, _ := req(t, app, http.MethodPost, "/v1/admin/affiliates/"+idA+"/payout", "orgA", false, map[string]any{"amountCents": 100, "method": "credits"}); st != http.StatusForbidden {
t.Fatalf("non-admin payout want 403, got %d", st)
}
// Over-pending → 400 (2000 available, ask 3000).
if st, _ := req(t, app, http.MethodPost, "/v1/admin/affiliates/"+idA+"/payout", "admin", true, map[string]any{"amountCents": 3000, "method": "credits"}); st != http.StatusBadRequest {
t.Fatalf("over-pending payout want 400, got %d", st)
}
// Credits payout of 1200c → ONE grant into orgA's wallet, paid moves.
st, body := req(t, app, http.MethodPost, "/v1/admin/affiliates/"+idA+"/payout", "admin", true, map[string]any{"amountCents": 1200, "method": "credits", "reference": "ledger-1"})
if st != http.StatusOK {
t.Fatalf("credits payout want 200, got %d (%s)", st, body)
}
if fc.bal("orgA") != 1200 {
t.Fatalf("affiliate wallet = %d, want 1200 (the credits payout)", fc.bal("orgA"))
}
if fc.depositCount() != 1 {
t.Fatalf("deposit count = %d, want 1 (one grant)", fc.depositCount())
}
a, _ := s.store.GetByID(ctx, idA)
if a.PaidCents != 1200 || a.PendingCents() != 800 {
t.Fatalf("after credits payout: paid=%d pending=%d (want 1200/800)", a.PaidCents, a.PendingCents())
}
// The payout row records the txn.
pd := envData(t, body)
var payout struct {
AmountCents int64 `json:"amountCents"`
Method string `json:"method"`
Txn string `json:"txn"`
}
_ = json.Unmarshal(pd["payout"], &payout)
if payout.AmountCents != 1200 || payout.Method != "credits" || payout.Txn == "" {
t.Fatalf("payout view wrong: %+v", payout)
}
// Cash payout of the remaining 800c via wire → RECORD-ONLY (no new grant).
st, body = req(t, app, http.MethodPost, "/v1/admin/affiliates/"+idA+"/payout", "admin", true, map[string]any{"amountCents": 800, "method": "wire", "reference": "wire-xyz"})
if st != http.StatusOK {
t.Fatalf("cash payout want 200, got %d (%s)", st, body)
}
if fc.depositCount() != 1 {
t.Fatalf("cash payout issued a grant: deposit count = %d, want 1", fc.depositCount())
}
if fc.bal("orgA") != 1200 {
t.Fatalf("cash payout moved the wallet: bal = %d, want 1200", fc.bal("orgA"))
}
a, _ = s.store.GetByID(ctx, idA)
if a.PaidCents != 2000 || a.PendingCents() != 0 {
t.Fatalf("after cash payout: paid=%d pending=%d (want 2000/0)", a.PaidCents, a.PendingCents())
}
// Nothing left → any further payout is 400.
if st, _ := req(t, app, http.MethodPost, "/v1/admin/affiliates/"+idA+"/payout", "admin", true, map[string]any{"amountCents": 1, "method": "credits"}); st != http.StatusBadRequest {
t.Fatalf("drained payout want 400, got %d", st)
}
}
// TestAdminGateAndDirectory: every /v1/admin/affiliates* route is global-admin
// fail-closed, and the directory exposes orgs + a summary.
func TestAdminGateAndDirectory(t *testing.T) {
app, s, fc := mount(t)
idA, codeA := applyAndApprove(t, app, s, "orgA", "acme", "")
req(t, app, http.MethodPost, "/v1/affiliates/attribute", "orgB", false, map[string]any{"code": codeA})
fc.setSpend("orgB", 10000)
req(t, app, http.MethodPost, "/v1/admin/affiliates/sweep", "admin", true, nil)
// A non-admin tenant is refused 403 on EVERY admin route.
for _, p := range []string{"/v1/admin/affiliates", "/v1/admin/affiliates/sweep",
"/v1/admin/affiliates/" + idA + "/approve", "/v1/admin/affiliates/" + idA + "/suspend",
"/v1/admin/affiliates/" + idA + "/payout"} {
method := http.MethodPost
if p == "/v1/admin/affiliates" {
method = http.MethodGet
}
if code, _ := req(t, app, method, p, "orgA", false, map[string]any{"amountCents": 1, "method": "credits"}); code != http.StatusForbidden {
t.Fatalf("non-admin %s want 403, got %d", p, code)
}
}
// Global admin sees the directory with the affiliate + a summary.
code, body := req(t, app, http.MethodGet, "/v1/admin/affiliates", "admin", true, nil)
if code != http.StatusOK {
t.Fatalf("admin list want 200, got %d (%s)", code, body)
}
data := envData(t, body)
var affs []adminAffiliateView
if err := json.Unmarshal(data["affiliates"], &affs); err != nil {
t.Fatalf("decode affiliates: %v", err)
}
if len(affs) != 1 {
t.Fatalf("admin affiliates len = %d, want 1", len(affs))
}
a0 := affs[0]
if a0.Org != "orgA" || a0.Code != codeA || a0.Status != StatusApproved || a0.ReferredCount != 1 {
t.Fatalf("admin row wrong: %+v", a0)
}
const wantCommission = 10000 * defaultRateBps / bpsDenom
if a0.AccruedCents != wantCommission || a0.PendingCents != wantCommission {
t.Fatalf("admin row accrual: accrued=%d pending=%d, want %d", a0.AccruedCents, a0.PendingCents, wantCommission)
}
var sum adminSummary
if err := json.Unmarshal(data["summary"], &sum); err != nil {
t.Fatalf("decode summary: %v", err)
}
if sum.Total != 1 || sum.Approved != 1 || sum.AccruedCents != wantCommission || sum.PendingCents != wantCommission {
t.Fatalf("summary wrong: %+v", sum)
}
// Suspend flips status; the code stops resolving for new attribution.
if st, _ := req(t, app, http.MethodPost, "/v1/admin/affiliates/"+idA+"/suspend", "admin", true, nil); st != http.StatusOK {
t.Fatalf("suspend want 200, got %d", st)
}
if code, _ := req(t, app, http.MethodPost, "/v1/affiliates/attribute", "orgD", false, map[string]any{"code": codeA}); code != http.StatusNotFound {
t.Fatalf("attribute to suspended code want 404, got %d", code)
}
}
// sweptAccrued pulls the "accrued" count out of an ENVELOPED sweep response.
func sweptAccrued(t *testing.T, body []byte) int {
t.Helper()
var out struct {
Data struct {
Accrued int `json:"accrued"`
} `json:"data"`
}
_ = json.Unmarshal(body, &out)
return out.Data.Accrued
}
// TestMount exercises the real Mount wiring (store open + route registration)
// against a temp DataDir, proving the package boots as the binary loads it.
func TestMount(t *testing.T) {
app := zip.New(zip.Config{Logger: luxlog.New("test")})
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir(), Brand: "hanzo"}); err != nil {
t.Fatalf("Mount: %v", err)
}
t.Cleanup(func() { _ = Shutdown() })
// A no-principal GET is refused 403 (proves the route is bound + gated).
r := httptest.NewRequest(http.MethodGet, "/v1/affiliates", nil)
resp, err := app.Fiber().Test(r)
if err != nil {
t.Fatalf("Test: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("mounted GET /v1/affiliates (no principal) want 403, got %d", resp.StatusCode)
}
}
+151
View File
@@ -0,0 +1,151 @@
package affiliates
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// 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.
//
// 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).
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")
// httpCommerce is the production commerce binding (COMMERCE_SERVICE_TOKEN S2S).
type httpCommerce struct {
base string
token string
http *http.Client
}
func newCommerceClient(base, token string) *httpCommerce {
return &httpCommerce{
base: strings.TrimRight(strings.TrimSpace(base), "/"),
token: strings.TrimSpace(token),
http: &http.Client{Timeout: 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
}
+636
View File
@@ -0,0 +1,636 @@
package affiliates
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/base32"
"errors"
"fmt"
"strings"
// The ONE Hanzo SQLite driver (registers "sqlite" under both build tags).
// Mirrors clients/referrals / clients/crm — one storage pattern.
_ "github.com/hanzoai/sqlite"
)
// Sentinel errors mapped to HTTP status by the handlers:
//
// errNotFound → 404, errUnknownCode → 404, errSelfAttribution → 400,
// errCodeTaken → 409, errInvalidCode → 400, errInsufficientPending → 400.
var (
errNotFound = errors.New("affiliates: not found")
errUnknownCode = errors.New("affiliates: unknown affiliate code")
errSelfAttribution = errors.New("affiliates: cannot attribute yourself")
errCodeTaken = errors.New("affiliates: code already taken")
errInvalidCode = errors.New("affiliates: invalid code")
errInsufficientPending = errors.New("affiliates: payout exceeds pending commission")
)
// Status values. An affiliate advances applied → approved (and can be suspended).
// Only an APPROVED affiliate has a code and accrues commission.
const (
StatusApplied = "applied"
StatusApproved = "approved"
StatusSuspended = "suspended"
)
// Affiliate is one partner org enrolled in the commission program. Org is UNIQUE
// (one affiliate per org); Code is UNIQUE across affiliates (minted on approval,
// vanity opt-in). RateBps is the commission rate in basis points (2000 = 20%).
type Affiliate struct {
ID string `json:"id"`
Org string `json:"-"` // the affiliate's own org; admin view re-exposes it
Code string `json:"code"`
RequestedCode string `json:"-"` // vanity code requested at apply, pending staff approval
Status string `json:"status"`
RateBps int64 `json:"rateBps"`
AccruedCents int64 `json:"accruedCents"` // lifetime commission accrued
PaidCents int64 `json:"paidCents"` // lifetime commission paid out
CreatedAt int64 `json:"createdAt"`
ApprovedAt int64 `json:"approvedAt"`
SuspendedAt int64 `json:"suspendedAt"`
}
// PendingCents is the commission earned but not yet paid (never negative).
func (a Affiliate) PendingCents() int64 {
if a.PaidCents >= a.AccruedCents {
return 0
}
return a.AccruedCents - a.PaidCents
}
// AffiliateReferral is one referred_org → affiliate attribution edge. ReferredOrg
// is UNIQUE across the table (an org is attributed to at most one affiliate, ever
// — first-touch), which is also the idempotency key for POST /v1/affiliates/attribute.
type AffiliateReferral struct {
ID string `json:"id"`
AffiliateID string `json:"affiliateId"`
ReferredOrg string `json:"referredOrg"`
Code string `json:"code"`
CreatedAt int64 `json:"createdAt"`
}
// Payout is one recorded disbursement of accrued commission. A "credits" method
// issues a commerce grant (Txn set); cash methods (wire/paypal/…) are record-only.
type Payout struct {
ID string `json:"id"`
AffiliateID string `json:"affiliateId"`
AmountCents int64 `json:"amountCents"`
Method string `json:"method"`
Reference string `json:"reference"`
Txn string `json:"txn,omitempty"`
CreatedAt int64 `json:"createdAt"`
}
// Accrual is one per-period commission event (the affiliate_event): the referred
// org's spend for that period × the affiliate's rate. UNIQUE(affiliate, referred,
// period) makes the sweep at-most-once per period — the commission latch.
type Accrual struct {
ID string `json:"id"`
AffiliateID string `json:"affiliateId"`
ReferredOrg string `json:"referredOrg"`
Period string `json:"period"`
SpendCents int64 `json:"spendCents"`
CommissionCents int64 `json:"commissionCents"`
CreatedAt int64 `json:"createdAt"`
}
// Store is the affiliates database. ONE SQLite file holds every org's affiliate
// record, attribution edges, accrual events, and payouts. A code→affiliate lookup
// is a GLOBAL directory by design (a referred org presents a code minted by ANY
// affiliate); every /v1/affiliates read is scoped by the caller's org server-side.
type Store struct {
db *sql.DB
}
func openStore(path string) (*Store, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
}
db.SetMaxOpenConns(1)
for _, pragma := range []string{
"PRAGMA busy_timeout=5000",
"PRAGMA journal_mode=WAL",
"PRAGMA foreign_keys=ON",
} {
if _, err := db.Exec(pragma); err != nil {
_ = db.Close()
return nil, fmt.Errorf("pragma %q: %w", pragma, err)
}
}
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
return nil, err
}
return s, nil
}
func (s *Store) migrate() error {
const ddl = `
CREATE TABLE IF NOT EXISTS affiliates (
id TEXT PRIMARY KEY,
org TEXT NOT NULL UNIQUE,
code TEXT NOT NULL DEFAULT '',
requested_code TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL,
rate_bps INTEGER NOT NULL,
accrued_cents INTEGER NOT NULL DEFAULT 0,
paid_cents INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
approved_at INTEGER NOT NULL DEFAULT 0,
suspended_at INTEGER NOT NULL DEFAULT 0
);
-- A partial UNIQUE index: only a NON-EMPTY code must be unique (many affiliates
-- can share the '' placeholder while still applied/un-coded).
CREATE UNIQUE INDEX IF NOT EXISTS ux_affiliates_code ON affiliates(code) WHERE code <> '';
CREATE TABLE IF NOT EXISTS affiliate_referrals (
id TEXT PRIMARY KEY,
affiliate_id TEXT NOT NULL,
referred_org TEXT NOT NULL UNIQUE,
code TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS ix_aff_referrals_affiliate ON affiliate_referrals(affiliate_id, created_at);
CREATE TABLE IF NOT EXISTS affiliate_accruals (
id TEXT PRIMARY KEY,
affiliate_id TEXT NOT NULL,
referred_org TEXT NOT NULL,
period TEXT NOT NULL,
spend_cents INTEGER NOT NULL,
commission_cents INTEGER NOT NULL,
created_at INTEGER NOT NULL,
UNIQUE(affiliate_id, referred_org, period)
);
CREATE INDEX IF NOT EXISTS ix_aff_accruals_affiliate ON affiliate_accruals(affiliate_id, created_at);
CREATE TABLE IF NOT EXISTS affiliate_payouts (
id TEXT PRIMARY KEY,
affiliate_id TEXT NOT NULL,
amount_cents INTEGER NOT NULL,
method TEXT NOT NULL,
reference TEXT NOT NULL DEFAULT '',
txn TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS ix_aff_payouts_affiliate ON affiliate_payouts(affiliate_id, created_at);
`
if _, err := s.db.Exec(ddl); err != nil {
return fmt.Errorf("affiliates migrate: %w", err)
}
return nil
}
// Close closes the underlying database. Idempotent-safe via sql.DB.
func (s *Store) Close() error { return s.db.Close() }
// ── codes ─────────────────────────────────────────────────────────────────────
// codeEncoding: RFC-4648 base32 without padding, lowercased for a readable vanity
// slug. 5 bytes → 8 chars.
var codeEncoding = base32.StdEncoding.WithPadding(base32.NoPadding)
// deriveCode is the DETERMINISTIC fallback slug for an affiliate that requested no
// vanity code: lowercase base32 of the first 5 bytes of SHA-256("hanzo-affiliate:"+
// org). `n` disambiguates the vanishingly rare cross-affiliate collision.
func deriveCode(org string, n int) string {
seed := "hanzo-affiliate:" + org
if n > 0 {
seed = fmt.Sprintf("%s#%d", seed, n)
}
sum := sha256.Sum256([]byte(seed))
return strings.ToLower(codeEncoding.EncodeToString(sum[:5]))
}
// normalizeCode trims + lower-cases a code so a link works in any case; vanity
// codes are stored + compared lowercase.
func normalizeCode(code string) string { return strings.ToLower(strings.TrimSpace(code)) }
// validCode enforces the vanity charset: 332 chars of [a-z0-9-], not starting or
// ending with a hyphen. Applied to a normalized (lowercased) code.
func validCode(code string) bool {
if len(code) < 3 || len(code) > 32 {
return false
}
if code[0] == '-' || code[len(code)-1] == '-' {
return false
}
for _, r := range code {
if !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-') {
return false
}
}
return true
}
// ── affiliate records ─────────────────────────────────────────────────────────
const affiliateCols = `id,org,code,requested_code,status,rate_bps,accrued_cents,paid_cents,created_at,approved_at,suspended_at`
func scanAffiliate(sc interface{ Scan(...any) error }) (Affiliate, error) {
var a Affiliate
err := sc.Scan(&a.ID, &a.Org, &a.Code, &a.RequestedCode, &a.Status, &a.RateBps,
&a.AccruedCents, &a.PaidCents, &a.CreatedAt, &a.ApprovedAt, &a.SuspendedAt)
return a, err
}
// Apply enrolls org as an affiliate at status=applied with the default rate,
// idempotently. requestedCode is the optional vanity code (validated + minted at
// approval). A repeat apply returns the EXISTING record (first apply wins).
// Returns (affiliate, created).
func (s *Store) Apply(ctx context.Context, id, org, requestedCode string, rateBps int64) (Affiliate, bool, error) {
_, err := s.db.ExecContext(ctx,
`INSERT INTO affiliates (id, org, requested_code, status, rate_bps, created_at)
VALUES (?,?,?,?,?,strftime('%s','now'))`,
id, org, requestedCode, StatusApplied, rateBps)
if err == nil {
a, gerr := s.getByID(ctx, id)
return a, true, gerr
}
if isUnique(err) {
a, gerr := s.GetByOrg(ctx, org)
return a, false, gerr
}
return Affiliate{}, false, fmt.Errorf("apply: %w", err)
}
func (s *Store) getByID(ctx context.Context, id string) (Affiliate, error) {
row := s.db.QueryRowContext(ctx, `SELECT `+affiliateCols+` FROM affiliates WHERE id=?`, id)
a, err := scanAffiliate(row)
if errors.Is(err, sql.ErrNoRows) {
return Affiliate{}, errNotFound
}
if err != nil {
return Affiliate{}, fmt.Errorf("get affiliate: %w", err)
}
return a, nil
}
// GetByID re-reads an affiliate by id (post-mutation refresh).
func (s *Store) GetByID(ctx context.Context, id string) (Affiliate, error) { return s.getByID(ctx, id) }
// GetByOrg reads the affiliate record for org, or errNotFound.
func (s *Store) GetByOrg(ctx context.Context, org string) (Affiliate, error) {
row := s.db.QueryRowContext(ctx, `SELECT `+affiliateCols+` FROM affiliates WHERE org=?`, org)
a, err := scanAffiliate(row)
if errors.Is(err, sql.ErrNoRows) {
return Affiliate{}, errNotFound
}
if err != nil {
return Affiliate{}, fmt.Errorf("get affiliate by org: %w", err)
}
return a, nil
}
// AffiliateForCode reverse-resolves an affiliate code to its APPROVED owner (an
// un-approved affiliate has no code). Trims + lower-cases the client-supplied code.
func (s *Store) AffiliateForCode(ctx context.Context, code string) (Affiliate, error) {
code = normalizeCode(code)
if code == "" {
return Affiliate{}, errUnknownCode
}
row := s.db.QueryRowContext(ctx, `SELECT `+affiliateCols+` FROM affiliates WHERE code=? AND status=?`, code, StatusApproved)
a, err := scanAffiliate(row)
if errors.Is(err, sql.ErrNoRows) {
return Affiliate{}, errUnknownCode
}
if err != nil {
return Affiliate{}, fmt.Errorf("resolve code: %w", err)
}
return a, nil
}
// Approve moves an affiliate to approved and mints its code: wantCode wins, else
// the requested vanity code, else a deterministic derived slug. A non-empty code
// is validated + uniqueness-enforced (errCodeTaken on collision with another
// affiliate). Idempotent for the same resolved code.
func (s *Store) Approve(ctx context.Context, id, wantCode string, now int64) (Affiliate, error) {
a, err := s.getByID(ctx, id)
if err != nil {
return Affiliate{}, err
}
code := normalizeCode(wantCode)
if code == "" {
code = normalizeCode(a.RequestedCode)
}
if code != "" {
if !validCode(code) {
return Affiliate{}, errInvalidCode
}
if err := s.setApproved(ctx, id, code, now); err != nil {
if isUnique(err) {
return Affiliate{}, errCodeTaken
}
return Affiliate{}, err
}
return s.getByID(ctx, id)
}
// No requested/explicit code → derive a stable slug, salt-retry on collision.
for n := 0; n < 8; n++ {
cand := deriveCode(a.Org, n)
if err := s.setApproved(ctx, id, cand, now); err == nil {
return s.getByID(ctx, id)
} else if !isUnique(err) {
return Affiliate{}, err
}
}
return Affiliate{}, fmt.Errorf("approve: exhausted derived-code collision retries for %q", a.Org)
}
func (s *Store) setApproved(ctx context.Context, id, code string, now int64) error {
_, err := s.db.ExecContext(ctx,
`UPDATE affiliates
SET status=?, code=?, approved_at = CASE WHEN approved_at=0 THEN ? ELSE approved_at END, suspended_at=0
WHERE id=?`,
StatusApproved, code, now, id)
return err
}
// Suspend moves an affiliate to suspended (its code stops resolving for new
// attribution; earned commission is unaffected). errNotFound if missing.
func (s *Store) Suspend(ctx context.Context, id string, now int64) (Affiliate, error) {
res, err := s.db.ExecContext(ctx, `UPDATE affiliates SET status=?, suspended_at=? WHERE id=?`, StatusSuspended, now, id)
if err != nil {
return Affiliate{}, fmt.Errorf("suspend: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return Affiliate{}, errNotFound
}
return s.getByID(ctx, id)
}
// ListAll returns every affiliate newest-first (the admin directory), bounded.
func (s *Store) ListAll(ctx context.Context, limit int) ([]Affiliate, error) {
return s.queryAffiliates(ctx, `SELECT `+affiliateCols+` FROM affiliates ORDER BY created_at DESC LIMIT ?`, limit)
}
// ListApproved returns every approved affiliate (the sweep set), oldest-first.
func (s *Store) ListApproved(ctx context.Context, limit int) ([]Affiliate, error) {
return s.queryAffiliates(ctx, `SELECT `+affiliateCols+` FROM affiliates WHERE status=? ORDER BY created_at ASC LIMIT ?`, StatusApproved, limit)
}
func (s *Store) queryAffiliates(ctx context.Context, q string, args ...any) ([]Affiliate, error) {
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("list affiliates: %w", err)
}
defer func() { _ = rows.Close() }()
out := make([]Affiliate, 0, 16)
for rows.Next() {
a, err := scanAffiliate(rows)
if err != nil {
return nil, fmt.Errorf("scan affiliate: %w", err)
}
out = append(out, a)
}
return out, rows.Err()
}
// ── attribution edges ─────────────────────────────────────────────────────────
// Attribute records a referredOrg → affiliate edge, idempotently. The referred org
// is the VALIDATED caller (never client-supplied). One-per-referred-org (UNIQUE)
// makes a repeat attribute a no-op returning the FIRST edge (first-touch wins).
// Self-attribution (an affiliate's own org) is refused. Returns (edge, created).
func (s *Store) Attribute(ctx context.Context, id, affiliateID, referredOrg, affiliateOrg, code string) (AffiliateReferral, bool, error) {
if referredOrg == affiliateOrg {
return AffiliateReferral{}, false, errSelfAttribution
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO affiliate_referrals (id, affiliate_id, referred_org, code, created_at)
VALUES (?,?,?,?,strftime('%s','now'))`,
id, affiliateID, referredOrg, normalizeCode(code))
if err == nil {
r, gerr := s.getReferralByReferred(ctx, referredOrg)
return r, true, gerr
}
if isUnique(err) {
r, gerr := s.getReferralByReferred(ctx, referredOrg)
return r, false, gerr
}
return AffiliateReferral{}, false, fmt.Errorf("attribute: %w", err)
}
const referralCols = `id,affiliate_id,referred_org,code,created_at`
func scanReferral(sc interface{ Scan(...any) error }) (AffiliateReferral, error) {
var r AffiliateReferral
err := sc.Scan(&r.ID, &r.AffiliateID, &r.ReferredOrg, &r.Code, &r.CreatedAt)
return r, err
}
func (s *Store) getReferralByReferred(ctx context.Context, referredOrg string) (AffiliateReferral, error) {
row := s.db.QueryRowContext(ctx, `SELECT `+referralCols+` FROM affiliate_referrals WHERE referred_org=?`, referredOrg)
r, err := scanReferral(row)
if errors.Is(err, sql.ErrNoRows) {
return AffiliateReferral{}, errNotFound
}
if err != nil {
return AffiliateReferral{}, fmt.Errorf("get referral: %w", err)
}
return r, nil
}
// ListReferrals returns an affiliate's attribution edges (the orgs it referred),
// newest-first, bounded.
func (s *Store) ListReferrals(ctx context.Context, affiliateID string, limit int) ([]AffiliateReferral, error) {
rows, err := s.db.QueryContext(ctx, `SELECT `+referralCols+` FROM affiliate_referrals WHERE affiliate_id=? ORDER BY created_at DESC LIMIT ?`, affiliateID, limit)
if err != nil {
return nil, fmt.Errorf("list referrals: %w", err)
}
defer func() { _ = rows.Close() }()
out := make([]AffiliateReferral, 0, 16)
for rows.Next() {
r, err := scanReferral(rows)
if err != nil {
return nil, fmt.Errorf("scan referral: %w", err)
}
out = append(out, r)
}
return out, rows.Err()
}
// CountReferrals returns how many orgs an affiliate has referred.
func (s *Store) CountReferrals(ctx context.Context, affiliateID string) (int, error) {
var n int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM affiliate_referrals WHERE affiliate_id=?`, affiliateID).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count referrals: %w", err)
}
return n, nil
}
// ReferralCountsByAffiliate returns affiliate_id → referred-org count in ONE
// GROUP BY (the admin directory's per-row count, no N+1 fan-out).
func (s *Store) ReferralCountsByAffiliate(ctx context.Context) (map[string]int, error) {
rows, err := s.db.QueryContext(ctx, `SELECT affiliate_id, COUNT(*) FROM affiliate_referrals GROUP BY affiliate_id`)
if err != nil {
return nil, fmt.Errorf("count referrals by affiliate: %w", err)
}
defer func() { _ = rows.Close() }()
out := make(map[string]int)
for rows.Next() {
var id string
var n int
if err := rows.Scan(&id, &n); err != nil {
return nil, fmt.Errorf("scan referral count: %w", err)
}
out[id] = n
}
return out, rows.Err()
}
// ── accrual latch (the commission event, at-most-once per period) ─────────────
// LatchAccrual atomically records ONE accrual for (affiliate, referredOrg, period)
// and adds the commission to the affiliate's accrued balance — in a single
// transaction. RowsAffected on the INSERT is the latch: a UNIQUE violation means
// this period was already accrued (returns false, no error, no double-accrual).
// Returns won=true only when THIS call created the accrual and moved the balance.
func (s *Store) LatchAccrual(ctx context.Context, accrualID, affiliateID, referredOrg, period string, spendCents, commissionCents, now int64) (bool, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return false, fmt.Errorf("accrual tx: %w", err)
}
_, err = tx.ExecContext(ctx,
`INSERT INTO affiliate_accruals (id, affiliate_id, referred_org, period, spend_cents, commission_cents, created_at)
VALUES (?,?,?,?,?,?,?)`,
accrualID, affiliateID, referredOrg, period, spendCents, commissionCents, now)
if err != nil {
_ = tx.Rollback()
if isUnique(err) {
return false, nil // already accrued this period — idempotent
}
return false, fmt.Errorf("insert accrual: %w", err)
}
if _, err = tx.ExecContext(ctx,
`UPDATE affiliates SET accrued_cents = accrued_cents + ? WHERE id=?`, commissionCents, affiliateID); err != nil {
_ = tx.Rollback()
return false, fmt.Errorf("accrue balance: %w", err)
}
if err := tx.Commit(); err != nil {
return false, fmt.Errorf("accrual commit: %w", err)
}
return true, nil
}
// ── payouts ───────────────────────────────────────────────────────────────────
// RecordPayout atomically RESERVES amountCents against the affiliate's pending
// commission (accrued paid) and records a payout row — in one transaction. The
// WHERE guard `(accrued_cents paid_cents) >= amount` makes it impossible to pay
// out more than is owed, even under concurrency (RowsAffected 0 → errInsufficient
// Pending). The commerce grant (for a credits payout) happens AFTER, outside the
// tx; SetPayoutTxn records the receipt. errNotFound if the affiliate is missing.
func (s *Store) RecordPayout(ctx context.Context, payoutID, affiliateID string, amountCents int64, method, reference string, now int64) (Payout, error) {
if amountCents <= 0 {
return Payout{}, errInsufficientPending
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return Payout{}, fmt.Errorf("payout tx: %w", err)
}
res, err := tx.ExecContext(ctx,
`UPDATE affiliates SET paid_cents = paid_cents + ?
WHERE id=? AND (accrued_cents - paid_cents) >= ?`,
amountCents, affiliateID, amountCents)
if err != nil {
_ = tx.Rollback()
return Payout{}, fmt.Errorf("reserve payout: %w", err)
}
if n, _ := res.RowsAffected(); n != 1 {
_ = tx.Rollback()
// Distinguish missing affiliate from insufficient pending for a clear 404 vs 400.
if _, gerr := s.getByID(ctx, affiliateID); gerr == errNotFound {
return Payout{}, errNotFound
}
return Payout{}, errInsufficientPending
}
if _, err = tx.ExecContext(ctx,
`INSERT INTO affiliate_payouts (id, affiliate_id, amount_cents, method, reference, created_at)
VALUES (?,?,?,?,?,?)`,
payoutID, affiliateID, amountCents, method, reference, now); err != nil {
_ = tx.Rollback()
return Payout{}, fmt.Errorf("insert payout: %w", err)
}
if err := tx.Commit(); err != nil {
return Payout{}, fmt.Errorf("payout commit: %w", err)
}
return Payout{ID: payoutID, AffiliateID: affiliateID, AmountCents: amountCents, Method: method, Reference: reference, CreatedAt: now}, nil
}
// VoidPayout reverses a RecordPayout that could not be BACKED by the treasury
// reserve: it deletes the payout row and restores the reserved amount to pending
// (paid_cents = amount), in one transaction. It is the compensating action when the
// fund cannot cover a payout the pending-guard already reserved — so a blocked payout
// leaves the affiliate's pending intact, honestly, instead of silently burning it.
func (s *Store) VoidPayout(ctx context.Context, payoutID, affiliateID string, amountCents int64) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("void tx: %w", err)
}
if _, err = tx.ExecContext(ctx, `DELETE FROM affiliate_payouts WHERE id=?`, payoutID); err != nil {
_ = tx.Rollback()
return fmt.Errorf("delete payout: %w", err)
}
if _, err = tx.ExecContext(ctx,
`UPDATE affiliates SET paid_cents = paid_cents - ? WHERE id=?`, amountCents, affiliateID); err != nil {
_ = tx.Rollback()
return fmt.Errorf("restore pending: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("void commit: %w", err)
}
return nil
}
// SetPayoutTxn records the commerce ledger transaction id after a credits payout
// deposit lands (best-effort receipt; the pending reservation is the authority).
func (s *Store) SetPayoutTxn(ctx context.Context, payoutID, txn string) error {
_, err := s.db.ExecContext(ctx, `UPDATE affiliate_payouts SET txn=? WHERE id=?`, txn, payoutID)
if err != nil {
return fmt.Errorf("set payout txn: %w", err)
}
return nil
}
const payoutCols = `id,affiliate_id,amount_cents,method,reference,txn,created_at`
func scanPayout(sc interface{ Scan(...any) error }) (Payout, error) {
var p Payout
err := sc.Scan(&p.ID, &p.AffiliateID, &p.AmountCents, &p.Method, &p.Reference, &p.Txn, &p.CreatedAt)
return p, err
}
// ListPayouts returns an affiliate's payout history, newest-first, bounded.
func (s *Store) ListPayouts(ctx context.Context, affiliateID string, limit int) ([]Payout, error) {
rows, err := s.db.QueryContext(ctx, `SELECT `+payoutCols+` FROM affiliate_payouts WHERE affiliate_id=? ORDER BY created_at DESC LIMIT ?`, affiliateID, limit)
if err != nil {
return nil, fmt.Errorf("list payouts: %w", err)
}
defer func() { _ = rows.Close() }()
out := make([]Payout, 0, 8)
for rows.Next() {
p, err := scanPayout(rows)
if err != nil {
return nil, fmt.Errorf("scan payout: %w", err)
}
out = append(out, p)
}
return out, rows.Err()
}
// isUnique reports whether err is a SQLite UNIQUE/PRIMARY-KEY constraint violation
// (the idempotency + collision signal), matched on message text so it holds under
// BOTH the cgo and pure-Go drivers.
func isUnique(err error) bool {
if err == nil {
return false
}
m := strings.ToLower(err.Error())
return strings.Contains(m, "unique") || strings.Contains(m, "constraint failed") || strings.Contains(m, "primary key")
}
File diff suppressed because it is too large Load Diff
+187
View File
@@ -0,0 +1,187 @@
package agents
import (
"context"
"errors"
"path/filepath"
"testing"
"time"
"github.com/hanzoai/cloud/types"
)
func testStore(t *testing.T) *Store {
t.Helper()
s, err := openStore(filepath.Join(t.TempDir(), "agents.db"))
if err != nil {
t.Fatalf("openStore: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
return s
}
func mk(org, name string) Agent {
now := time.Now().Unix()
return Agent{
ID: org + "-" + name + "-id", Org: org, Name: name, Model: "gpt-4o-mini",
Instructions: "You are " + name, Description: "d", Tools: []string{"http"},
Status: "ready", CreatedAt: now, UpdatedAt: now,
}
}
// fakeAI is a deterministic AIClient for exercising executeRun without a real
// gateway — proves the compose + record contract, not the model.
type fakeAI struct {
gotModel string
gotPrompt string
content string
err error
}
func (f *fakeAI) ChatCompletion(_ context.Context, req *types.ChatRequest) (*types.ChatResponse, error) {
f.gotModel = req.Model
f.gotPrompt = req.Prompt
if f.err != nil {
return nil, f.err
}
return &types.ChatResponse{Content: f.content}, nil
}
func TestCreateGetListDelete(t *testing.T) {
s := testStore(t)
ctx := context.Background()
if err := s.Create(ctx, mk("maxpower", "helper")); err != nil {
t.Fatalf("create: %v", err)
}
if err := s.Create(ctx, mk("maxpower", "helper")); err != errConflict {
t.Fatalf("duplicate create should conflict, got %v", err)
}
a, err := s.Get(ctx, "maxpower", "helper")
if err != nil || a.Model != "gpt-4o-mini" {
t.Fatalf("get: %v model=%q", err, a.Model)
}
list, _ := s.List(ctx, "maxpower")
if len(list) != 1 {
t.Fatalf("want 1 agent, got %d", len(list))
}
deleted, err := s.Delete(ctx, "maxpower", "helper")
if err != nil || !deleted {
t.Fatalf("delete: %v deleted=%v", err, deleted)
}
if _, err := s.Get(ctx, "maxpower", "helper"); err != errNotFound {
t.Fatalf("want notfound after delete, got %v", err)
}
}
// TestResolveByIdOrName proves the id/name split that made a created agent
// un-gettable is closed: Store.Resolve returns the SAME agent whether addressed
// by its public id (the handle create/list return) or its org-unique name, and
// stays fail-closed for another tenant's ref and unknown refs.
func TestResolveByIdOrName(t *testing.T) {
s := testStore(t)
ctx := context.Background()
a := mk("maxpower", "helper") // ID = "maxpower-helper-id", Name = "helper"
if err := s.Create(ctx, a); err != nil {
t.Fatalf("create: %v", err)
}
byID, err := s.Resolve(ctx, "maxpower", a.ID)
if err != nil || byID.Name != "helper" || byID.ID != a.ID {
t.Fatalf("resolve by id must return the agent, got %+v err=%v", byID, err)
}
byName, err := s.Resolve(ctx, "maxpower", "helper")
if err != nil || byName.ID != a.ID {
t.Fatalf("resolve by name must return the SAME agent, got %+v err=%v", byName, err)
}
if byID.ID != byName.ID {
t.Fatalf("id and name must resolve to the same row: %q vs %q", byID.ID, byName.ID)
}
// Cross-org: maxpower's id/name must be invisible to acme (fail-closed).
if _, err := s.Resolve(ctx, "acme", a.ID); err != errNotFound {
t.Fatalf("cross-org resolve by id must be errNotFound, got %v", err)
}
if _, err := s.Resolve(ctx, "acme", "helper"); err != errNotFound {
t.Fatalf("cross-org resolve by name must be errNotFound, got %v", err)
}
// Unknown ref.
if _, err := s.Resolve(ctx, "maxpower", "agent_deadbeef"); err != errNotFound {
t.Fatalf("unknown ref must be errNotFound, got %v", err)
}
}
// TestTenantIsolation: one org cannot read, run-log, or delete another's agents.
func TestTenantIsolation(t *testing.T) {
s := testStore(t)
ctx := context.Background()
if err := s.Create(ctx, mk("maxpower", "shared")); err != nil {
t.Fatalf("seed maxpower: %v", err)
}
if err := s.Create(ctx, mk("acme", "shared")); err != nil {
t.Fatalf("seed acme: %v", err)
}
// Record a run for maxpower's agent only.
if err := s.InsertRun(ctx, Run{ID: "r1", Org: "maxpower", AgentName: "shared", Status: "ok", CreatedAt: time.Now().Unix()}); err != nil {
t.Fatalf("insert run: %v", err)
}
mpRuns, _ := s.ListRuns(ctx, "maxpower", "shared", 50)
if len(mpRuns) != 1 {
t.Fatalf("maxpower should have 1 run, got %d", len(mpRuns))
}
acRuns, _ := s.ListRuns(ctx, "acme", "shared", 50)
if len(acRuns) != 0 {
t.Fatalf("acme must NOT see maxpower's runs, got %d", len(acRuns))
}
if n, _ := s.CountRuns(ctx, "acme", "shared"); n != 0 {
t.Fatalf("acme run count must be 0, got %d", n)
}
// acme deleting "shared" must not remove maxpower's agent or its run log.
if _, err := s.Delete(ctx, "acme", "shared"); err != nil {
t.Fatalf("acme delete own: %v", err)
}
if _, err := s.Get(ctx, "maxpower", "shared"); err != nil {
t.Fatalf("maxpower agent must survive acme delete: %v", err)
}
if n, _ := s.CountRuns(ctx, "maxpower", "shared"); n != 1 {
t.Fatalf("maxpower run log must survive acme delete, got %d", n)
}
}
func TestExecuteRunOK(t *testing.T) {
ai := &fakeAI{content: "hi there"}
a := mk("maxpower", "greeter")
a.Instructions = "You are a greeter."
r := executeRun(context.Background(), ai, "maxpower", a, "say hi")
if r.Status != "ok" {
t.Fatalf("want ok, got %q err=%q", r.Status, r.Error)
}
if r.Output != "hi there" {
t.Fatalf("output should be the model content, got %q", r.Output)
}
if ai.gotModel != "gpt-4o-mini" {
t.Fatalf("run must use the agent's model, got %q", ai.gotModel)
}
if ai.gotPrompt != "You are a greeter.\n\nsay hi" {
t.Fatalf("prompt must compose instructions + input, got %q", ai.gotPrompt)
}
if r.Org != "maxpower" || r.AgentName != "greeter" {
t.Fatalf("run must be scoped to the org+agent, got %+v", r)
}
}
func TestExecuteRunRecordsError(t *testing.T) {
ai := &fakeAI{err: errors.New("model unavailable")}
r := executeRun(context.Background(), ai, "maxpower", mk("maxpower", "x"), "in")
if r.Status != "error" {
t.Fatalf("want error status, got %q", r.Status)
}
if r.Error != "model unavailable" {
t.Fatalf("error must be recorded honestly, got %q", r.Error)
}
if r.Output != "" {
t.Fatalf("failed run must not fabricate output, got %q", r.Output)
}
}
+281
View File
@@ -0,0 +1,281 @@
package agents
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/types"
"github.com/hanzoai/commerce/metering"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// errTest is the model-failure the "failed run is not billed" case injects.
var errTest = errors.New("model unavailable")
// billServer is a minimal commerce double: it returns a fixed balance and
// records the X-Org-Id header (the tenant the debit lands on) + the usage body
// of every debit. X-Org-Id is the header commerce's service-token auth reads
// (metering >= v0.1.2), so a wrong tenant here would prove a cross-tenant leak.
type billServer struct {
available int64
mu sync.Mutex
usageOrg string
usageBody []byte
usages int32
balances int32
}
func (b *billServer) start(t *testing.T) string {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/v1/billing/balance", func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&b.balances, 1)
_ = json.NewEncoder(w).Encode(map[string]any{"available": b.available})
})
mux.HandleFunc("/v1/billing/usage", func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&b.usages, 1)
body, _ := io.ReadAll(r.Body)
b.mu.Lock()
b.usageOrg, b.usageBody = r.Header.Get("X-Org-Id"), body
b.mu.Unlock()
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, `{"transactionId":"tx_1","type":"usage"}`)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv.URL
}
func (b *billServer) debits() int32 { return atomic.LoadInt32(&b.usages) }
func (b *billServer) lastDebit() (string, []byte) {
b.mu.Lock()
defer b.mu.Unlock()
return b.usageOrg, b.usageBody
}
// waitForDebit polls a condition briefly — debits are recorded on a detached
// goroutine, so the assertion must wait for the async write.
func waitForDebit(cond func() bool) bool {
for i := 0; i < 200; i++ {
if cond() {
return true
}
time.Sleep(5 * time.Millisecond)
}
return cond()
}
// mountBilled mounts the agents surface with a REAL metering client pointed at
// the fake commerce (default org "hanzo", so every "acme is billed" assertion
// proves the per-call org override scopes the ledger to the CALLER). No
// scheduler is started here (deps.AI is set, but these tests exercise the HTTP
// run path; scheduler tests drive tick() directly).
func mountBilled(t *testing.T, commerceURL string, ai types.AIClient) *zip.App {
t.Helper()
m, err := metering.New(metering.Config{BaseURL: commerceURL, Token: "svc-tok", Org: "hanzo"})
if err != nil {
t.Fatalf("metering.New: %v", err)
}
app := zip.New(zip.Config{Logger: luxlog.New("test")})
deps := cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir(), AI: ai, Metering: m}
if err := Mount(app, deps); err != nil {
t.Fatalf("Mount: %v", err)
}
t.Cleanup(func() { _ = Shutdown(context.Background()) })
return app
}
// TestRunGatesUnfundedOrg: a run for an org with a non-positive balance is
// refused 402 and NO usage is recorded and (fail-closed) no inference output is
// returned — an unfunded tenant gets no free agent run.
func TestRunGatesUnfundedOrg(t *testing.T) {
bs := &billServer{available: 0}
app := mountBilled(t, bs.start(t), &fakeAI{content: "should not run"})
if code, _ := do(t, app, http.MethodPost, "/v1/agents", "acme",
map[string]any{"name": "a", "model": "gpt-4o-mini", "instructions": "x"}); code != http.StatusCreated {
t.Fatalf("create want 201, got %d", code)
}
code, body := do(t, app, http.MethodPost, "/v1/agents/a/run", "acme", map[string]any{"input": "hi"})
if code != http.StatusPaymentRequired {
t.Fatalf("unfunded run want 402, got %d (%s)", code, body)
}
if bs.debits() != 0 {
t.Fatalf("a refused run must not debit, got %d", bs.debits())
}
}
// TestRunGatesUnderfundedOrg: an org with a POSITIVE balance that is still less
// than the run fee is refused 402 — the gate enforces available >= fee, not
// merely available > 0, so a 1-cent balance can't authorize a $1 run and take
// the ledger negative (Red MEDIUM-1). Default fee is $1.00 (100c).
func TestRunGatesUnderfundedOrg(t *testing.T) {
bs := &billServer{available: 1} // 1 cent, fee is 100 cents
app := mountBilled(t, bs.start(t), &fakeAI{content: "should not run"})
do(t, app, http.MethodPost, "/v1/agents", "acme",
map[string]any{"name": "a", "model": "m", "instructions": "x"})
code, body := do(t, app, http.MethodPost, "/v1/agents/a/run", "acme", map[string]any{"input": "hi"})
if code != http.StatusPaymentRequired {
t.Fatalf("underfunded (1c < 100c fee) run want 402, got %d (%s)", code, body)
}
if bs.debits() != 0 {
t.Fatalf("a gate-refused run must not debit, got %d", bs.debits())
}
}
// TestRunDebitsCallerOrg: a funded run returns the output AND debits the CALLER
// org (acme, never the client default 'hanzo'), with product=agent + the agent's
// model on the usage transaction.
func TestRunDebitsCallerOrg(t *testing.T) {
bs := &billServer{available: 100000}
app := mountBilled(t, bs.start(t), &fakeAI{content: "the answer"})
do(t, app, http.MethodPost, "/v1/agents", "acme",
map[string]any{"name": "a", "model": "gpt-4o-mini", "instructions": "x"})
code, body := do(t, app, http.MethodPost, "/v1/agents/a/run", "acme", map[string]any{"input": "hi"})
if code != http.StatusOK {
t.Fatalf("funded run want 200, got %d (%s)", code, body)
}
if !waitForDebit(func() bool { return bs.debits() == 1 }) {
t.Fatalf("a successful run must debit once, got %d", bs.debits())
}
org, ubody := bs.lastDebit()
if org != "acme" {
t.Fatalf("debited org %q, want caller %q (never default 'hanzo')", org, "acme")
}
var u struct {
User string `json:"user"`
Amount int64 `json:"amount"`
Model string `json:"model"`
Provider string `json:"provider"`
Actor string `json:"actor"`
}
_ = json.Unmarshal(ubody, &u)
if u.User != "acme" {
t.Fatalf("debit user = %q, want caller org %q", u.User, "acme")
}
if u.Amount != cloud.DefaultResourceFeeCents {
t.Fatalf("debit amount = %d, want default fee %d", u.Amount, cloud.DefaultResourceFeeCents)
}
if u.Provider != meterKind {
t.Fatalf("debit provider = %q, want %q (product:agent)", u.Provider, meterKind)
}
if u.Model != "gpt-4o-mini" {
t.Fatalf("debit model = %q, want the agent's model", u.Model)
}
if u.Actor == "" {
t.Fatalf("debit must carry an actor for the audit trail")
}
}
// TestRunByReturnedIDMetersOnce: running an agent addressed by the id create
// returned debits the caller org EXACTLY ONCE with product=agent — the run path
// meters identically whether the agent is addressed by id or by name.
func TestRunByReturnedIDMetersOnce(t *testing.T) {
bs := &billServer{available: 100000}
app := mountBilled(t, bs.start(t), &fakeAI{content: "the answer"})
_, body := do(t, app, http.MethodPost, "/v1/agents", "acme",
map[string]any{"name": "a", "model": "gpt-4o-mini", "instructions": "x"})
var created agentView
if err := json.Unmarshal(body, &created); err != nil || created.ID == "" {
t.Fatalf("create must return an id, got %s (err %v)", body, err)
}
code, rbody := do(t, app, http.MethodPost, "/v1/agents/"+created.ID+"/run", "acme", map[string]any{"input": "hi"})
if code != http.StatusOK {
t.Fatalf("run by returned id want 200, got %d (%s)", code, rbody)
}
if !waitForDebit(func() bool { return bs.debits() == 1 }) {
t.Fatalf("a run by id must debit exactly once, got %d", bs.debits())
}
org, ubody := bs.lastDebit()
if org != "acme" {
t.Fatalf("debited org %q, want caller %q", org, "acme")
}
var u struct {
Provider string `json:"provider"`
Model string `json:"model"`
}
_ = json.Unmarshal(ubody, &u)
if u.Provider != meterKind || u.Model != "gpt-4o-mini" {
t.Fatalf("debit must be product=agent for the agent's model, got provider=%q model=%q", u.Provider, u.Model)
}
}
// TestFailedRunNotBilled: when the model errors, the run is recorded as an error
// but NOT billed — failed work is never charged (mirrors the edge gate).
func TestFailedRunNotBilled(t *testing.T) {
bs := &billServer{available: 100000}
app := mountBilled(t, bs.start(t), &fakeAI{err: errTest})
do(t, app, http.MethodPost, "/v1/agents", "acme",
map[string]any{"name": "a", "model": "m", "instructions": "x"})
code, _ := do(t, app, http.MethodPost, "/v1/agents/a/run", "acme", map[string]any{"input": "hi"})
if code != http.StatusBadGateway {
t.Fatalf("errored run want 502, got %d", code)
}
// Give any (erroneous) async debit a chance to land, then assert none did.
if waitForDebit(func() bool { return bs.debits() > 0 }) {
t.Fatalf("a failed run must NOT be billed, got %d debits", bs.debits())
}
}
// TestRunRequiresValidatedPrincipal: a run with only a client X-Org-Id (no
// validated X-User-Id — the direct-to-pod no-bearer path) is refused 403 and
// NEVER debits. A money-moving action can't ride an unauthenticated, forgeable
// org header (Red MEDIUM-2). Read/create still work on the org header alone.
func TestRunRequiresValidatedPrincipal(t *testing.T) {
bs := &billServer{available: 100000}
app := mountBilled(t, bs.start(t), &fakeAI{content: "must not run"})
// create is allowed with X-User-Id (via do()).
if code, _ := do(t, app, http.MethodPost, "/v1/agents", "acme",
map[string]any{"name": "a", "model": "m", "instructions": "x"}); code != http.StatusCreated {
t.Fatalf("create want 201, got %d", code)
}
// A raw run request carrying ONLY X-Org-Id (no X-User-Id) must be 403.
req := httptest.NewRequest(http.MethodPost, "/v1/agents/a/run", nil)
req.Header.Set("X-Org-Id", "acme") // forged/unvalidated org, no principal
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("Test: %v", err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("run without a validated principal want 403, got %d", resp.StatusCode)
}
if waitForDebit(func() bool { return bs.debits() > 0 }) {
t.Fatalf("an unauthenticated run must never debit, got %d", bs.debits())
}
}
// TestRunAgentGateFailClosedOnUnreachableCommerce: when commerce cannot be
// reached, the gate denies (fail-closed) and no run executes — runAgent returns
// the gate error and the fake AI is never called.
func TestRunAgentGateFailClosedOnUnreachableCommerce(t *testing.T) {
// Point at a dead URL so Authorize errors (unknown balance -> fail-closed).
m, _ := metering.New(metering.Config{BaseURL: "http://127.0.0.1:1", Token: "t", Org: "hanzo", Timeout: 200 * time.Millisecond})
ai := &fakeAI{content: "must not run"}
s := &svc{store: testStore(t), ai: ai, log: luxlog.New("test"), bill: cloud.NewResourceMeter(cloud.Deps{Metering: m, Logger: luxlog.New("test")}, meterKind)}
a := mk("acme", "x")
_, gateErr := s.runAgent(context.Background(), a, "hi", "acme", "", "")
if gateErr == nil {
t.Fatal("unreachable commerce must fail closed (non-nil gate error)")
}
if ai.gotPrompt != "" {
t.Fatalf("no inference must run when the gate denies, got prompt %q", ai.gotPrompt)
}
}
+150
View File
@@ -0,0 +1,150 @@
package agents
// A minimal, dependency-free 5-field cron matcher — the ONE schedule grammar
// long-running agents use. It is deliberately tiny (no seconds field, no
// @-macros, no timezones beyond UTC) because the scheduler ticks once a minute
// and only needs to answer "does this expression fire at this minute?".
//
// Grammar (standard 5-field, all times UTC):
//
// minute hour day-of-month month day-of-week
// 0-59 0-23 1-31 1-12 0-6 (Sun=0)
//
// Each field is a comma list of terms; a term is "*", a number, a range "a-b",
// or a step "*/n" or "a-b/n". Day-of-month and day-of-week combine with OR when
// BOTH are restricted (Vixie-cron semantics), else AND — matching what operators
// expect from "0 9 * * 1" (09:00 on Mondays).
//
// Hand-rolled instead of pulling a cron module: it is ~one screen, fully
// unit-tested, and keeps the dependency surface minimal (no new module in a
// binary that mounts every subsystem).
import (
"fmt"
"strconv"
"strings"
"time"
)
// schedule is a parsed cron expression: one bitset per field (bit i set => the
// field matches value i). domRestricted/dowRestricted record whether the
// day-of-month / day-of-week field was anything other than "*", which selects
// the OR-vs-AND combination rule.
type schedule struct {
min, hour, dom, mon, dow uint64
domRestricted bool
dowRestricted bool
}
// fieldRange bounds each cron field (inclusive).
type fieldRange struct{ min, max int }
var cronRanges = [5]fieldRange{
{0, 59}, // minute
{0, 23}, // hour
{1, 31}, // day of month
{1, 12}, // month
{0, 6}, // day of week (Sunday=0)
}
// parseCron parses a 5-field cron expression or returns an error describing the
// first malformed field. Whitespace between fields is collapsed.
func parseCron(expr string) (schedule, error) {
fields := strings.Fields(strings.TrimSpace(expr))
if len(fields) != 5 {
return schedule{}, fmt.Errorf("cron: want 5 fields, got %d in %q", len(fields), expr)
}
var s schedule
dst := []*uint64{&s.min, &s.hour, &s.dom, &s.mon, &s.dow}
for i, f := range fields {
bits, err := parseField(f, cronRanges[i])
if err != nil {
return schedule{}, fmt.Errorf("cron field %d (%q): %w", i+1, f, err)
}
*dst[i] = bits
}
s.domRestricted = fields[2] != "*"
s.dowRestricted = fields[4] != "*"
return s, nil
}
// parseField parses one comma-separated cron field into a bitset over r.
func parseField(f string, r fieldRange) (uint64, error) {
if f == "" {
return 0, fmt.Errorf("empty field")
}
var bits uint64
for _, term := range strings.Split(f, ",") {
tb, err := parseTerm(term, r)
if err != nil {
return 0, err
}
bits |= tb
}
return bits, nil
}
// parseTerm parses a single term: "*", "n", "a-b", "*/n", or "a-b/n".
func parseTerm(term string, r fieldRange) (uint64, error) {
step := 1
if i := strings.IndexByte(term, '/'); i >= 0 {
n, err := strconv.Atoi(term[i+1:])
if err != nil || n <= 0 {
return 0, fmt.Errorf("bad step %q", term)
}
step = n
term = term[:i]
}
lo, hi := r.min, r.max
switch {
case term == "*":
// full range with the parsed step.
case strings.IndexByte(term, '-') > 0:
i := strings.IndexByte(term, '-')
a, err1 := strconv.Atoi(term[:i])
b, err2 := strconv.Atoi(term[i+1:])
if err1 != nil || err2 != nil {
return 0, fmt.Errorf("bad range %q", term)
}
lo, hi = a, b
default:
n, err := strconv.Atoi(term)
if err != nil {
return 0, fmt.Errorf("bad number %q", term)
}
lo, hi = n, n
}
if lo < r.min || hi > r.max || lo > hi {
return 0, fmt.Errorf("value out of range [%d,%d]", r.min, r.max)
}
var bits uint64
for v := lo; v <= hi; v += step {
bits |= 1 << uint(v)
}
return bits, nil
}
// matches reports whether the schedule fires at t (evaluated in UTC, minute
// granularity). The day-of-month / day-of-week combination follows Vixie cron:
// when BOTH are restricted the day matches if EITHER matches (OR); otherwise the
// unrestricted field is a wildcard and the restricted one is ANDed.
func (s schedule) matches(t time.Time) bool {
t = t.UTC()
if s.min&(1<<uint(t.Minute())) == 0 {
return false
}
if s.hour&(1<<uint(t.Hour())) == 0 {
return false
}
if s.mon&(1<<uint(int(t.Month()))) == 0 {
return false
}
domHit := s.dom&(1<<uint(t.Day())) != 0
dowHit := s.dow&(1<<uint(int(t.Weekday()))) != 0
if s.domRestricted && s.dowRestricted {
return domHit || dowHit
}
return domHit && dowHit
}
+105
View File
@@ -0,0 +1,105 @@
package agents
import (
"testing"
"time"
)
func TestParseCronErrors(t *testing.T) {
bad := []string{
"", // empty
"* * * *", // 4 fields
"* * * * * *", // 6 fields
"60 * * * *", // minute out of range
"* 24 * * *", // hour out of range
"* * 0 * *", // dom below range
"* * 32 * *", // dom above range
"* * * 13 *", // month above range
"* * * * 7", // dow above range
"*/0 * * * *", // zero step
"5-1 * * * *", // inverted range
"abc * * * *", // non-numeric
"1,,2 * * * *", // empty term
}
for _, expr := range bad {
if _, err := parseCron(expr); err == nil {
t.Errorf("parseCron(%q) = nil error, want error", expr)
}
}
}
func TestParseCronValid(t *testing.T) {
for _, expr := range []string{
"* * * * *", "*/5 * * * *", "0 9 * * 1", "0 0 1 * *",
"0,30 * * * *", "0-15 * * * *", "0 9-17/2 * * 1-5", "0 0 * * 0",
} {
if _, err := parseCron(expr); err != nil {
t.Errorf("parseCron(%q) unexpected error: %v", expr, err)
}
}
}
func at(t *testing.T, s string) time.Time {
t.Helper()
tm, err := time.Parse("2006-01-02 15:04 MST", s+" UTC")
if err != nil {
t.Fatalf("bad test time %q: %v", s, err)
}
return tm
}
func TestCronMatches(t *testing.T) {
cases := []struct {
expr string
when string // "YYYY-MM-DD HH:MM"
want bool
}{
{"* * * * *", "2026-07-01 12:34", true},
{"*/5 * * * *", "2026-07-01 12:35", true},
{"*/5 * * * *", "2026-07-01 12:36", false},
{"0 9 * * *", "2026-07-01 09:00", true},
{"0 9 * * *", "2026-07-01 09:01", false},
{"0 9 * * *", "2026-07-01 10:00", false},
// 2026-07-06 is a Monday; 0 9 * * 1 fires 09:00 Mondays.
{"0 9 * * 1", "2026-07-06 09:00", true},
{"0 9 * * 1", "2026-07-07 09:00", false}, // Tuesday
{"0-15 * * * *", "2026-07-01 12:15", true},
{"0-15 * * * *", "2026-07-01 12:16", false},
{"0 0 1 * *", "2026-08-01 00:00", true}, // first of month
{"0 0 1 * *", "2026-08-02 00:00", false}, // second of month
{"0 9-17/2 * * *", "2026-07-01 09:00", true},
{"0 9-17/2 * * *", "2026-07-01 11:00", true},
{"0 9-17/2 * * *", "2026-07-01 10:00", false}, // 10 not in 9,11,13,15,17
}
for _, c := range cases {
s, err := parseCron(c.expr)
if err != nil {
t.Fatalf("parseCron(%q): %v", c.expr, err)
}
if got := s.matches(at(t, c.when)); got != c.want {
t.Errorf("%q matches %q = %v, want %v", c.expr, c.when, got, c.want)
}
}
}
// TestCronDOMDOWOrSemantics: when BOTH day-of-month and day-of-week are
// restricted, Vixie cron fires if EITHER matches. "0 0 13 * 5" fires on the
// 13th OR on any Friday.
func TestCronDOMDOWOrSemantics(t *testing.T) {
s, err := parseCron("0 0 13 * 5") // 5 = Friday
if err != nil {
t.Fatalf("parse: %v", err)
}
// 2026-07-13 is a Monday -> matches via DOM (the 13th).
if !s.matches(at(t, "2026-07-13 00:00")) {
t.Error("should fire on the 13th regardless of weekday")
}
// 2026-07-03 is a Friday -> matches via DOW.
if !s.matches(at(t, "2026-07-03 00:00")) {
t.Error("should fire on a Friday regardless of day-of-month")
}
// 2026-07-06 is a Monday, not the 13th -> no match.
if s.matches(at(t, "2026-07-06 00:00")) {
t.Error("must NOT fire on a non-13th non-Friday")
}
}
+319
View File
@@ -0,0 +1,319 @@
package agents
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/types"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// mountApp mounts the agents surface with a deterministic fake AI so run() is
// exercised end-to-end over HTTP without a real gateway. Pass a nil interface
// to exercise the no-inference fail-closed path.
func mountApp(t *testing.T, ai types.AIClient) *zip.App {
t.Helper()
return mountAppModel(t, ai, "")
}
// mountAppModel is mountApp with an explicit deployment default model
// (deps.AIDefaultModel), so a test can exercise the empty-model → default path.
func mountAppModel(t *testing.T, ai types.AIClient, defaultModel string) *zip.App {
t.Helper()
app := zip.New(zip.Config{Logger: luxlog.New("test")})
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir(), AI: ai, AIDefaultModel: defaultModel}); err != nil {
t.Fatalf("Mount: %v", err)
}
// Mount starts the scheduler goroutine when AI is non-nil and sets the global
// `mounted` singleton; tear both down at test end so the loop goroutine can't
// leak and clobber a later test's singleton (Red re-review LOW).
t.Cleanup(func() { _ = Shutdown(context.Background()) })
return app
}
func do(t *testing.T, app *zip.App, method, path, org string, body any) (int, []byte) {
t.Helper()
var r io.Reader
if body != nil {
b, _ := json.Marshal(body)
r = bytes.NewReader(b)
}
req := httptest.NewRequest(method, path, r)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if org != "" {
req.Header.Set("X-Org-Id", org)
// A validated principal: the run path (money-moving) requires a non-empty
// c.User() (X-User-Id). SanitizeIdentity sets this only from a verified
// JWT; the test app has no sanitizer, so we inject it directly, exactly as
// the gateway would. Empty org => no user (the anonymous 403 path).
req.Header.Set("X-User-Id", "u-"+org)
}
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
}
func TestHTTPGateIsolationAndRun(t *testing.T) {
app := mountApp(t, &fakeAI{content: "the answer"})
if code, _ := do(t, app, http.MethodGet, "/v1/agents", "", nil); code != http.StatusForbidden {
t.Fatalf("no-org list want 403, got %d", code)
}
// maxpower creates an agent (model required).
if code, _ := do(t, app, http.MethodPost, "/v1/agents", "maxpower",
map[string]any{"name": "helper", "model": "gpt-4o-mini", "instructions": "be terse"}); code != http.StatusCreated {
t.Fatalf("create want 201, got %d", code)
}
// model is required — creating without one is a 400.
if code, _ := do(t, app, http.MethodPost, "/v1/agents", "maxpower",
map[string]any{"name": "nomodel"}); code != http.StatusBadRequest {
t.Fatalf("create without model want 400, got %d", code)
}
// List shape is {agents:[...]}.
code, body := do(t, app, http.MethodGet, "/v1/agents", "maxpower", nil)
var listed struct {
Agents []agentView `json:"agents"`
}
_ = json.Unmarshal(body, &listed)
if code != http.StatusOK || len(listed.Agents) != 1 || listed.Agents[0].Name != "helper" {
t.Fatalf("maxpower should see [helper], got %d %+v", code, listed.Agents)
}
// run executes via the (fake) AI and returns a real recorded run.
code, body = do(t, app, http.MethodPost, "/v1/agents/helper/run", "maxpower", map[string]any{"input": "hi"})
if code != http.StatusOK {
t.Fatalf("run want 200, got %d (%s)", code, body)
}
var rv runView
_ = json.Unmarshal(body, &rv)
if rv.Status != "ok" || rv.Output != "the answer" {
t.Fatalf("run should return the model output, got %+v", rv)
}
// The run was recorded and is org-scoped.
code, body = do(t, app, http.MethodGet, "/v1/agents/helper/runs", "maxpower", nil)
if code != http.StatusOK || !bytes.Contains(body, []byte("the answer")) {
t.Fatalf("runs history want the recorded run, got %d %s", code, body)
}
// acme cannot see, run, or read runs for maxpower's agent.
code, body = do(t, app, http.MethodGet, "/v1/agents", "acme", nil)
_ = json.Unmarshal(body, &listed)
if code != http.StatusOK || len(listed.Agents) != 0 {
t.Fatalf("acme must see zero agents, got %d %+v", code, listed.Agents)
}
if code, _ := do(t, app, http.MethodPost, "/v1/agents/helper/run", "acme", map[string]any{"input": "hi"}); code != http.StatusNotFound {
t.Fatalf("acme run on maxpower agent want 404, got %d", code)
}
}
// TestHTTPCreateThenGetRunByReturnedID reproduces Dave's exact flow and proves
// the id/name disconnect is fixed: create returns an id, and GETting AND running
// that agent BY THE RETURNED ID (not just the name) both resolve the SAME agent.
// Before the fix, get/run keyed the path only against the name column, so the id
// create handed back 404'd — a created agent was not runnable.
func TestHTTPCreateThenGetRunByReturnedID(t *testing.T) {
app := mountApp(t, &fakeAI{content: "the answer"})
// Create — capture the id the API returns (exactly what a client keeps).
code, body := do(t, app, http.MethodPost, "/v1/agents", "maxpower",
map[string]any{"name": "verify-run", "model": "gpt-4o-mini", "instructions": "be terse"})
if code != http.StatusCreated {
t.Fatalf("create want 201, got %d (%s)", code, body)
}
var created agentView
if err := json.Unmarshal(body, &created); err != nil {
t.Fatalf("create shape: %v (%s)", err, body)
}
if created.ID == "" || created.Name != "verify-run" {
t.Fatalf("create must return id+name, got %+v", created)
}
id := created.ID
// GET by the RETURNED ID must be 200 and the same agent (was 404 pre-fix).
code, body = do(t, app, http.MethodGet, "/v1/agents/"+id, "maxpower", nil)
if code != http.StatusOK {
t.Fatalf("GET by returned id want 200, got %d (%s)", code, body)
}
var got agentDetail
_ = json.Unmarshal(body, &got)
if got.ID != id || got.Name != "verify-run" {
t.Fatalf("GET by id resolved the wrong agent, got %+v", got.agentView)
}
// GET by NAME must resolve the SAME agent (both identifiers work).
code, body = do(t, app, http.MethodGet, "/v1/agents/verify-run", "maxpower", nil)
if code != http.StatusOK {
t.Fatalf("GET by name want 200, got %d (%s)", code, body)
}
var byName agentDetail
_ = json.Unmarshal(body, &byName)
if byName.ID != id {
t.Fatalf("GET by name must be the SAME agent as by id: %q vs %q", byName.ID, id)
}
// RUN by the RETURNED ID must execute and return real output (was 404 pre-fix).
code, body = do(t, app, http.MethodPost, "/v1/agents/"+id+"/run", "maxpower", map[string]any{"input": "hi"})
if code != http.StatusOK {
t.Fatalf("run by returned id want 200, got %d (%s)", code, body)
}
var rv runView
_ = json.Unmarshal(body, &rv)
if rv.Status != "ok" || rv.Output != "the answer" {
t.Fatalf("run by id must return the model output, got %+v", rv)
}
// The run recorded under the agent is visible via runs-by-id AND runs-by-name.
code, body = do(t, app, http.MethodGet, "/v1/agents/"+id+"/runs", "maxpower", nil)
if code != http.StatusOK || !bytes.Contains(body, []byte("the answer")) {
t.Fatalf("runs by id want the recorded run, got %d %s", code, body)
}
code, body = do(t, app, http.MethodGet, "/v1/agents/verify-run/runs", "maxpower", nil)
if code != http.StatusOK || !bytes.Contains(body, []byte("the answer")) {
t.Fatalf("runs by name want the same recorded run, got %d %s", code, body)
}
// Cross-org fail-closed: acme cannot GET or run maxpower's agent BY ITS ID.
if c2, _ := do(t, app, http.MethodGet, "/v1/agents/"+id, "acme", nil); c2 != http.StatusNotFound {
t.Fatalf("acme GET maxpower agent by id want 404, got %d", c2)
}
if c2, _ := do(t, app, http.MethodPost, "/v1/agents/"+id+"/run", "acme", map[string]any{"input": "x"}); c2 != http.StatusNotFound {
t.Fatalf("acme run maxpower agent by id want 404, got %d", c2)
}
}
// TestHTTPMetricsAndActivityNotShadowed proves /v1/agents/metrics and
// /v1/agents/activity resolve to their own handlers (not captured by the :name
// wildcard) and that every number is derived from REAL recorded runs.
func TestHTTPMetricsAndActivityNotShadowed(t *testing.T) {
app := mountApp(t, &fakeAI{content: "ok"})
// Both org-wide surfaces require a tenant, like every other route.
if code, _ := do(t, app, http.MethodGet, "/v1/agents/metrics", "", nil); code != http.StatusForbidden {
t.Fatalf("no-org metrics want 403, got %d", code)
}
if code, _ := do(t, app, http.MethodGet, "/v1/agents/activity", "", nil); code != http.StatusForbidden {
t.Fatalf("no-org activity want 403, got %d", code)
}
// Empty org: honest empty shapes, NOT a 404 (proves no wildcard shadowing)
// and NOT a fabricated trend.
code, body := do(t, app, http.MethodGet, "/v1/agents/metrics?range=7D", "maxpower", nil)
if code != http.StatusOK {
t.Fatalf("metrics want 200 (not shadowed 404), got %d (%s)", code, body)
}
var m struct {
Range string `json:"range"`
Series []seriesLine `json:"series"`
Resource struct {
CPUVcpuHours *float64 `json:"cpuVcpuHours"`
MemGbHours *float64 `json:"memGbHours"`
StorageIoBytes *float64 `json:"storageIoBytes"`
CostCents *float64 `json:"costCents"`
} `json:"resource"`
}
if err := json.Unmarshal(body, &m); err != nil {
t.Fatalf("metrics shape: %v (%s)", err, body)
}
if m.Range != "7D" || len(m.Series) != 0 {
t.Fatalf("empty-org metrics want range=7D, no series, got %+v", m)
}
if m.Resource.CPUVcpuHours != nil || m.Resource.CostCents != nil {
t.Fatalf("resource metering is unsourced — must be null, got %+v", m.Resource)
}
code, body = do(t, app, http.MethodGet, "/v1/agents/activity", "maxpower", nil)
if code != http.StatusOK {
t.Fatalf("activity want 200 (not shadowed 404), got %d (%s)", code, body)
}
var empty struct {
Activity []activityView `json:"activity"`
}
_ = json.Unmarshal(body, &empty)
if len(empty.Activity) != 0 {
t.Fatalf("empty-org activity want [], got %+v", empty.Activity)
}
// Seed a real agent + a real run, then the surfaces must reflect exactly it.
if code, _ := do(t, app, http.MethodPost, "/v1/agents", "maxpower",
map[string]any{"name": "helper", "model": "gpt-4o-mini"}); code != http.StatusCreated {
t.Fatalf("create want 201, got %d", code)
}
if code, _ := do(t, app, http.MethodPost, "/v1/agents/helper/run", "maxpower", map[string]any{"input": "hi"}); code != http.StatusOK {
t.Fatalf("run want 200, got %d", code)
}
// Metrics now carry a real invocation series for "helper" summing to 1.
_, body = do(t, app, http.MethodGet, "/v1/agents/metrics?range=24H", "maxpower", nil)
_ = json.Unmarshal(body, &m)
if len(m.Series) != 1 || m.Series[0].Key != "helper" {
t.Fatalf("metrics want one series for helper, got %+v", m.Series)
}
total := 0
for _, p := range m.Series[0].Points {
total += p.V
}
if total != 1 {
t.Fatalf("real invocation total want 1, got %d", total)
}
// Activity now carries the real invoked event + the created event, newest first.
_, body = do(t, app, http.MethodGet, "/v1/agents/activity", "maxpower", nil)
var feed struct {
Activity []activityView `json:"activity"`
}
_ = json.Unmarshal(body, &feed)
var invoked, created bool
for _, e := range feed.Activity {
if e.Agent != "helper" {
t.Fatalf("activity must be scoped to helper, got %+v", e)
}
switch e.Kind {
case "invoked":
invoked = true
case "created":
created = true
}
}
if !invoked || !created {
t.Fatalf("activity want a real invoked + created event, got %+v", feed.Activity)
}
// Cross-org isolation: acme sees none of maxpower's metrics/activity.
_, body = do(t, app, http.MethodGet, "/v1/agents/metrics?range=24H", "acme", nil)
_ = json.Unmarshal(body, &m)
if len(m.Series) != 0 {
t.Fatalf("acme must see zero series, got %+v", m.Series)
}
_, body = do(t, app, http.MethodGet, "/v1/agents/activity", "acme", nil)
_ = json.Unmarshal(body, &feed)
if len(feed.Activity) != 0 {
t.Fatalf("acme must see zero activity, got %+v", feed.Activity)
}
}
// TestHTTPRunWithoutAIFailsClosed: when no AI client is wired, run 503s and
// never fabricates output.
func TestHTTPRunWithoutAIFailsClosed(t *testing.T) {
app := mountApp(t, nil)
do(t, app, http.MethodPost, "/v1/agents", "maxpower",
map[string]any{"name": "a", "model": "m", "instructions": "x"})
if code, _ := do(t, app, http.MethodPost, "/v1/agents/a/run", "maxpower", map[string]any{"input": "hi"}); code != http.StatusServiceUnavailable {
t.Fatalf("run without AI want 503, got %d", code)
}
}
+153
View File
@@ -0,0 +1,153 @@
package agents
import (
"encoding/json"
"net/http"
"strings"
"testing"
)
// TestCreateRejectsOversizedRefs: computeRef/serviceAccountId are opaque ids,
// bounded at the boundary — a multi-KB "id" must be a 400, not persisted.
func TestCreateRejectsOversizedRefs(t *testing.T) {
app := mountApp(t, &fakeAI{content: "x"})
huge := strings.Repeat("a", maxRef+1)
if code, _ := do(t, app, http.MethodPost, "/v1/agents", "acme",
map[string]any{"name": "big", "model": "m", "computeRef": huge}); code != http.StatusBadRequest {
t.Fatalf("oversized computeRef want 400, got %d", code)
}
if code, _ := do(t, app, http.MethodPost, "/v1/agents", "acme",
map[string]any{"name": "big2", "model": "m", "serviceAccountId": huge}); code != http.StatusBadRequest {
t.Fatalf("oversized serviceAccountId want 400, got %d", code)
}
}
// TestCreateLongRunningRequiresValidCron: a long-running agent must carry a
// parseable cron; missing/invalid schedule is a 400. A valid one is 201 and the
// mode+schedule round-trip in the view.
func TestCreateLongRunningValidation(t *testing.T) {
app := mountApp(t, &fakeAI{content: "x"})
// long-running without a schedule -> 400.
if code, _ := do(t, app, http.MethodPost, "/v1/agents", "acme",
map[string]any{"name": "a", "model": "m", "executionMode": "long-running"}); code != http.StatusBadRequest {
t.Fatalf("long-running w/o schedule want 400, got %d", code)
}
// long-running with a bad cron -> 400.
if code, _ := do(t, app, http.MethodPost, "/v1/agents", "acme",
map[string]any{"name": "b", "model": "m", "executionMode": "long-running", "schedule": "not a cron"}); code != http.StatusBadRequest {
t.Fatalf("long-running w/ bad cron want 400, got %d", code)
}
// unknown mode -> 400.
if code, _ := do(t, app, http.MethodPost, "/v1/agents", "acme",
map[string]any{"name": "c", "model": "m", "executionMode": "daemon"}); code != http.StatusBadRequest {
t.Fatalf("unknown mode want 400, got %d", code)
}
// valid long-running -> 201, fields echoed.
code, body := do(t, app, http.MethodPost, "/v1/agents", "acme",
map[string]any{"name": "cron", "model": "m", "executionMode": "long-running",
"schedule": "*/5 * * * *", "computeRef": "vm-1", "serviceAccountId": "acme-cron"})
if code != http.StatusCreated {
t.Fatalf("valid long-running want 201, got %d (%s)", code, body)
}
var v agentView
_ = json.Unmarshal(body, &v)
if v.ExecutionMode != "long-running" || v.Schedule != "*/5 * * * *" ||
v.ComputeRef != "vm-1" || v.ServiceAccountID != "acme-cron" {
t.Fatalf("lifecycle fields not echoed in view: %+v", v)
}
}
// TestCreateOneShotDropsSchedule: a one-shot agent's schedule is meaningless and
// dropped, so the view carries no schedule and the scheduler will never pick it.
func TestCreateOneShotDropsSchedule(t *testing.T) {
app := mountApp(t, &fakeAI{content: "x"})
code, body := do(t, app, http.MethodPost, "/v1/agents", "acme",
map[string]any{"name": "one", "model": "m", "schedule": "* * * * *"})
if code != http.StatusCreated {
t.Fatalf("create want 201, got %d", code)
}
var v agentView
_ = json.Unmarshal(body, &v)
if v.ExecutionMode != "one-shot" || v.Schedule != "" {
t.Fatalf("one-shot must drop schedule, got mode=%q schedule=%q", v.ExecutionMode, v.Schedule)
}
}
// TestLongRunningPerOrgCap: an org cannot create more than the configured number
// of scheduled long-running agents (Red LOW-1). One-shot agents don't count.
func TestLongRunningPerOrgCap(t *testing.T) {
t.Setenv(longRunningCapEnv, "2")
app := mountApp(t, &fakeAI{content: "x"})
mk := func(name string) map[string]any {
return map[string]any{"name": name, "model": "m", "executionMode": "long-running", "schedule": "* * * * *"}
}
if code, _ := do(t, app, http.MethodPost, "/v1/agents", "acme", mk("a")); code != http.StatusCreated {
t.Fatalf("1st long-running want 201, got %d", code)
}
if code, _ := do(t, app, http.MethodPost, "/v1/agents", "acme", mk("b")); code != http.StatusCreated {
t.Fatalf("2nd long-running want 201, got %d", code)
}
// 3rd exceeds the cap of 2 -> 409.
if code, _ := do(t, app, http.MethodPost, "/v1/agents", "acme", mk("c")); code != http.StatusConflict {
t.Fatalf("3rd long-running want 409 (cap), got %d", code)
}
// A one-shot agent is unaffected by the cap.
if code, _ := do(t, app, http.MethodPost, "/v1/agents", "acme",
map[string]any{"name": "one", "model": "m"}); code != http.StatusCreated {
t.Fatalf("one-shot must not be capped, got %d", code)
}
// A DIFFERENT org has its own budget.
if code, _ := do(t, app, http.MethodPost, "/v1/agents", "beta", mk("a")); code != http.StatusCreated {
t.Fatalf("other org's 1st long-running want 201, got %d", code)
}
}
// TestLongRunningCapNotBypassedByPatch: the cap can't be dodged by creating
// one-shot agents (uncapped) then PATCHing them to long-running. Transition into
// long-running is capped too; re-saving an already-long-running agent is not.
func TestLongRunningCapNotBypassedByPatch(t *testing.T) {
t.Setenv(longRunningCapEnv, "1")
app := mountApp(t, &fakeAI{content: "x"})
// Fill the cap with one long-running agent.
if code, _ := do(t, app, http.MethodPost, "/v1/agents", "acme",
map[string]any{"name": "lr", "model": "m", "executionMode": "long-running", "schedule": "* * * * *"}); code != http.StatusCreated {
t.Fatalf("seed long-running want 201, got %d", code)
}
// Create a one-shot agent (uncapped), then try to PATCH it to long-running.
do(t, app, http.MethodPost, "/v1/agents", "acme", map[string]any{"name": "sneaky", "model": "m"})
if code, _ := do(t, app, http.MethodPatch, "/v1/agents/sneaky", "acme",
map[string]any{"executionMode": "long-running", "schedule": "* * * * *"}); code != http.StatusConflict {
t.Fatalf("PATCH one-shot->long-running over cap want 409, got %d", code)
}
// Re-saving the EXISTING long-running agent (no transition) must NOT 409.
if code, _ := do(t, app, http.MethodPatch, "/v1/agents/lr", "acme",
map[string]any{"schedule": "*/2 * * * *"}); code != http.StatusOK {
t.Fatalf("no-op re-save of own long-running agent want 200, got %d", code)
}
}
// TestPatchToLongRunningValidates: PATCHing an agent to long-running without a
// schedule is rejected; supplying a valid schedule in the same PATCH succeeds.
func TestPatchToLongRunningValidates(t *testing.T) {
app := mountApp(t, &fakeAI{content: "x"})
do(t, app, http.MethodPost, "/v1/agents", "acme", map[string]any{"name": "a", "model": "m"})
// flip to long-running with no schedule -> 400.
if code, _ := do(t, app, http.MethodPatch, "/v1/agents/a", "acme",
map[string]any{"executionMode": "long-running"}); code != http.StatusBadRequest {
t.Fatalf("patch to long-running w/o schedule want 400, got %d", code)
}
// flip with a schedule -> 200.
code, body := do(t, app, http.MethodPatch, "/v1/agents/a", "acme",
map[string]any{"executionMode": "long-running", "schedule": "0 * * * *"})
if code != http.StatusOK {
t.Fatalf("patch to long-running w/ schedule want 200, got %d (%s)", code, body)
}
var v agentView
_ = json.Unmarshal(body, &v)
if v.ExecutionMode != "long-running" || v.Schedule != "0 * * * *" {
t.Fatalf("patch did not apply lifecycle: %+v", v)
}
}
+34
View File
@@ -0,0 +1,34 @@
package agents
import (
"context"
"fmt"
"strings"
"github.com/hanzoai/cloud/clients/principal"
)
// ListForOrg returns the org's agents from the in-process store — the ONE
// exported seam other in-process subsystems use to read the canonical agent
// registry WITHOUT an HTTP hop back through the gateway.
//
// It is the decompleced replacement for the old bots-as-members path, which
// enumerated agents over HTTP (/v1/agents with a forwarded bearer) and broke
// when HANZO_API_KEY was rejected (bot_members=0). clients/team calls this
// directly to project each agent as a workspace Employee.
//
// ISOLATION: org is the ONLY tenant key and is used VERBATIM (Store.List filters
// WHERE org=?), so a caller for org A can never enumerate org B's agents. The
// caller MUST pass an org it already validated (principal.Tenant / a verified
// token claim), never a raw client header. Fails closed (nil, error) when the
// agents subsystem is not mounted or the org is empty/oversized.
func ListForOrg(ctx context.Context, org string) ([]Agent, error) {
if mounted == nil || mounted.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.store.List(ctx, org)
}
+89
View File
@@ -0,0 +1,89 @@
package agents
import (
"context"
"encoding/json"
"net/http"
"testing"
"github.com/hanzoai/cloud/types"
)
// catalogAI is a fake AIClient that ALSO implements types.ModelLister, so it
// exercises the create/update-time model validation. Its Models() is the
// gateway's served catalog; ChatCompletion lets a defaulted agent actually run.
type catalogAI struct {
content string
ids []string
}
func (c *catalogAI) ChatCompletion(_ context.Context, _ *types.ChatRequest) (*types.ChatResponse, error) {
return &types.ChatResponse{Content: c.content}, nil
}
func (c *catalogAI) Models(_ context.Context) ([]string, error) { return c.ids, nil }
// TestHTTPCreateModelValidation proves agent-create rejects a model outside the
// gateway's served catalog with a clean 400 (instead of the customer's reported
// run-time 502 for e.g. claude-sonnet-4-5), accepts a catalog model, and — when
// the model is omitted — stores the deployment default so the agent is still
// runnable. Update is guarded identically.
func TestHTTPCreateModelValidation(t *testing.T) {
const defaultModel = "deepseek-v4-flash"
ai := &catalogAI{content: "pong", ids: []string{"zen-flash", "deepseek-v4-flash"}}
app := mountAppModel(t, ai, defaultModel)
// A model this gateway never serves → a clean 400 at create (was a run-time 502).
code, body := do(t, app, http.MethodPost, "/v1/agents", "acme",
map[string]any{"name": "bad", "model": "claude-sonnet-4-5"})
if code != http.StatusBadRequest {
t.Fatalf("non-catalog model want 400, got %d (%s)", code, body)
}
// A catalog model is accepted.
if code, body := do(t, app, http.MethodPost, "/v1/agents", "acme",
map[string]any{"name": "good", "model": "zen-flash"}); code != http.StatusCreated {
t.Fatalf("catalog model want 201, got %d (%s)", code, body)
}
// An OMITTED model falls back to the deployment default (a valid catalog
// model), so the agent is created AND runnable — not a 400.
code, body = do(t, app, http.MethodPost, "/v1/agents", "acme",
map[string]any{"name": "defaulted", "instructions": "be terse"})
if code != http.StatusCreated {
t.Fatalf("omitted model want 201 (defaulted), got %d (%s)", code, body)
}
var created agentView
_ = json.Unmarshal(body, &created)
if created.Model != defaultModel {
t.Fatalf("omitted model must store the deployment default %q, got %q", defaultModel, created.Model)
}
// The defaulted agent runs (its stored default model is a real catalog model).
if code, body := do(t, app, http.MethodPost, "/v1/agents/defaulted/run", "acme",
map[string]any{"input": "hi"}); code != http.StatusOK {
t.Fatalf("defaulted agent run want 200, got %d (%s)", code, body)
}
// PATCH is guarded identically: a non-catalog model → 400; a catalog model → 200.
if code, body := do(t, app, http.MethodPatch, "/v1/agents/good", "acme",
map[string]any{"model": "gpt-9-imaginary"}); code != http.StatusBadRequest {
t.Fatalf("update to non-catalog model want 400, got %d (%s)", code, body)
}
if code, body := do(t, app, http.MethodPatch, "/v1/agents/good", "acme",
map[string]any{"model": "deepseek-v4-flash"}); code != http.StatusOK {
t.Fatalf("update to catalog model want 200, got %d (%s)", code, body)
}
}
// TestCreateModelValidationFailsOpen proves the guard NEVER blocks a create when
// the catalog cannot be enumerated: an AIClient that is not a ModelLister (the
// disabled/RPC clients, and the plain test fakes) skips validation entirely, so a
// create with any model still succeeds. Validation is a UX guard, not a gate.
func TestCreateModelValidationFailsOpen(t *testing.T) {
// fakeAI (from agents_test.go) implements ChatCompletion only — NOT ModelLister.
app := mountApp(t, &fakeAI{content: "ok"})
if code, body := do(t, app, http.MethodPost, "/v1/agents", "acme",
map[string]any{"name": "any", "model": "some-unlisted-model"}); code != http.StatusCreated {
t.Fatalf("non-lister AI must skip validation (fail-open), want 201, got %d (%s)", code, body)
}
}
+57
View File
@@ -0,0 +1,57 @@
package agents
import (
"context"
"fmt"
"strings"
"github.com/hanzoai/cloud/clients/principal"
)
// RunOnBehalf runs agent `ref` for `org` ON BEHALF OF `userSub`, IN-PROCESS —
// no gateway hop, no Cloudflare/IPv6 exposure. It is the clean in-process twin of
// the HTTP s.run handler: the CALLER (e.g. the Slack integrations bridge) has
// ALREADY authenticated org+userSub server-side, so this entry takes them
// DIRECTLY and never reads an HTTP principal / JWT / zip.Ctx. It resolves the
// agent org-scoped, runs it through the SAME runAgent → executeRun → meter path
// as s.run (one run path: one balance gate, one debit, one recorded run, one live
// session), and bills billingActor(org, userSub) against ORG's ledger.
//
// ISOLATION: org is the ONLY tenant key. Store.Resolve is org-scoped, so a caller
// for org A can never resolve, run, or bill against org B's agent — exactly the
// property the HTTP handler relies on the gateway-minted X-Org-Id for.
//
// A non-nil error means NO run happened: not mounted, invalid org, oversized
// input, inference not configured, agent-not-found (errNotFound), or a
// balance-gate denial (out-of-funds / commerce-unknown). A run that executed but
// whose model failed returns a recorded error-status Run and a nil error.
func RunOnBehalf(ctx context.Context, org, userSub, ref, input string) (Run, error) {
if mounted == nil {
return Run{}, fmt.Errorf("agents: not mounted")
}
return mounted.runOnBehalf(ctx, org, userSub, ref, input)
}
func (s *svc) runOnBehalf(ctx context.Context, org, userSub, ref, input string) (Run, error) {
org = strings.TrimSpace(org)
if org == "" || len(org) > principal.MaxOrgLen {
return Run{}, fmt.Errorf("agents: invalid org")
}
if len(input) > maxInput {
return Run{}, fmt.Errorf("agents: input too large")
}
if s.ai == nil {
return Run{}, fmt.Errorf("agents: inference is not configured on this deployment")
}
a, err := s.store.Resolve(ctx, org, strings.TrimSpace(ref))
if err != nil {
return Run{}, err // errNotFound or a real DB error — caller replies generically
}
// The actor attributes the spend to the acting principal (org/userSub) for the
// audit trail; the BALANCE gated + debited is always a.Org (== org), never the
// caller. Synthetic request id: in-process, there is no HTTP X-Request-Id; the
// client IP is empty (no socket).
actor := billingActor(org, userSub)
reqID, _ := genID("obh")
return s.runAgent(ctx, a, input, actor, reqID, "")
}
+117
View File
@@ -0,0 +1,117 @@
package agents
import (
"context"
"encoding/json"
"net/http"
"testing"
)
// TestRunOnBehalfBillsActor proves the in-process on-behalf-of path runs the
// agent through the SAME meter path as the HTTP handler and bills the AGENT's org
// with the actor "org/userSub" — the identity the Slack bridge passes for a linked
// user. This is the deliverable's "RunOnBehalf bills the right actor" bar.
func TestRunOnBehalfBillsActor(t *testing.T) {
bs := &billServer{available: 100000}
app := mountBilled(t, bs.start(t), &fakeAI{content: "on-behalf answer"})
_ = app
// Create the agent the bridge will address by ref (name "hanzo").
if code, _ := do(t, app, http.MethodPost, "/v1/agents", "acme",
map[string]any{"name": "hanzo", "model": "gpt-4o-mini", "instructions": "x"}); code != http.StatusCreated {
t.Fatalf("create want 201, got %d", code)
}
run, err := RunOnBehalf(context.Background(), "acme", "U-slack-123", "hanzo", "hi from slack")
if err != nil {
t.Fatalf("RunOnBehalf: %v", err)
}
if run.Status != "ok" || run.Output != "on-behalf answer" {
t.Fatalf("run must succeed with the model output, got %+v", run)
}
if !waitForDebit(func() bool { return bs.debits() == 1 }) {
t.Fatalf("an on-behalf run must debit exactly once, got %d", bs.debits())
}
org, ubody := bs.lastDebit()
if org != "acme" {
t.Fatalf("debited org %q, want the agent's org 'acme' (never the caller default)", org)
}
var u struct {
Actor string `json:"actor"`
Provider string `json:"provider"`
Model string `json:"model"`
}
_ = json.Unmarshal(ubody, &u)
if u.Actor != "acme/U-slack-123" {
t.Fatalf("actor = %q, want billingActor(org,userSub)=%q", u.Actor, "acme/U-slack-123")
}
if u.Provider != meterKind || u.Model != "gpt-4o-mini" {
t.Fatalf("debit must be product=agent for the agent's model, got provider=%q model=%q", u.Provider, u.Model)
}
}
// TestRunOnBehalfOrgScoped proves the in-process path is tenant-isolated: a caller
// for org A can never resolve/run/bill org B's agent — resolution is org-scoped, so
// a cross-org ref is errNotFound and NOTHING is billed.
func TestRunOnBehalfOrgScoped(t *testing.T) {
bs := &billServer{available: 100000}
app := mountBilled(t, bs.start(t), &fakeAI{content: "should not run"})
_ = app
// "secret" exists only in globex.
if code, _ := do(t, app, http.MethodPost, "/v1/agents", "globex",
map[string]any{"name": "secret", "model": "m", "instructions": "y"}); code != http.StatusCreated {
t.Fatalf("create want 201, got %d", code)
}
// acme attempts to run globex's agent by ref.
if _, err := RunOnBehalf(context.Background(), "acme", "u", "secret", "hi"); err == nil {
t.Fatal("acme must NOT resolve globex's agent (org-scoped) — cross-tenant run")
}
// No inference, no debit.
if waitForDebit(func() bool { return bs.debits() > 0 }) {
t.Fatalf("a cross-org run must never debit, got %d", bs.debits())
}
}
// TestRunOnBehalfGatesUnfunded proves the on-behalf path shares the balance gate:
// an unfunded org gets NO free inference (fail-closed) and the fake AI is never
// called.
func TestRunOnBehalfGatesUnfunded(t *testing.T) {
bs := &billServer{available: 0}
ai := &fakeAI{content: "must not run"}
app := mountBilled(t, bs.start(t), ai)
_ = app
if code, _ := do(t, app, http.MethodPost, "/v1/agents", "acme",
map[string]any{"name": "a", "model": "m", "instructions": "x"}); code != http.StatusCreated {
t.Fatalf("create want 201, got %d", code)
}
if _, err := RunOnBehalf(context.Background(), "acme", "u", "a", "hi"); err == nil {
t.Fatal("unfunded on-behalf run must fail closed (balance-gate denial)")
}
if ai.gotPrompt != "" {
t.Fatalf("no inference must run when the gate denies, got prompt %q", ai.gotPrompt)
}
if bs.debits() != 0 {
t.Fatalf("a gate-refused run must not debit, got %d", bs.debits())
}
}
// TestRunOnBehalfNotMounted proves the package-level entry fails closed when the
// agents subsystem is not mounted (no panic, an honest error).
func TestRunOnBehalfNotMounted(t *testing.T) {
_ = Shutdown(context.Background())
if _, err := RunOnBehalf(context.Background(), "acme", "u", "hanzo", "hi"); err == nil {
t.Fatal("RunOnBehalf must fail when agents is not mounted")
}
}
// TestRunOnBehalfInvalidOrg proves an empty/oversized org is refused before any
// store/inference — the org is a tenant key and must be bounded.
func TestRunOnBehalfInvalidOrg(t *testing.T) {
bs := &billServer{available: 100000}
app := mountBilled(t, bs.start(t), &fakeAI{content: "x"})
_ = app
if _, err := RunOnBehalf(context.Background(), "", "u", "hanzo", "hi"); err == nil {
t.Fatal("empty org must be refused")
}
}
+290
View File
@@ -0,0 +1,290 @@
package agents
// The long-running-agent scheduler: it invokes each long-running agent's run on
// its cron cadence, through the SAME svc.runAgent path the HTTP handler uses —
// so a scheduled run is gated (fail-closed on the agent's OWN org balance),
// executed, recorded, and billed identically to an interactive one. There is no
// self-HTTP call: the endpoint's BEHAVIOR is the contract, and calling runAgent
// directly keeps ONE run path (no duplicated gate/meter, no re-crossing the
// identity boundary with a synthetic token).
//
// Cadence: one ticker fires every minute (cron's finest granularity). On each
// tick it loads the long-running work set and, for every agent whose schedule
// matches the current minute, launches a run — subject to two safety controls:
//
// - Concurrency cap: at most maxConcurrentPerAgent in-flight runs per agent.
// A slow model must never let cron stack unbounded goroutines for one agent.
// - Exponential backoff: after a failed run (gate denial OR model error) an
// agent is skipped for a growing number of ticks (1,2,4,… up to a cap), so a
// persistently-failing or unfunded agent stops hammering commerce/the model.
// A success resets the backoff.
//
// All state is in-memory and keyed by org/name: the scheduler is a per-process
// singleton owned by the mounted svc, torn down on Shutdown.
import (
"context"
"strings"
"sync"
"time"
luxlog "github.com/luxfi/log"
)
const (
// tickInterval is cron's resolution. Aligned to the top of each minute so a
// "* * * * *" agent fires once per minute, not on process-start phase.
tickInterval = time.Minute
// maxConcurrentPerAgent caps in-flight runs for ONE agent. A cron agent is
// expected to complete within its period; 1 means "never overlap a run with
// itself" (the safe default for periodic work). >1 would allow catch-up.
maxConcurrentPerAgent = 1
// maxBackoffTicks caps the exponential skip so a failing agent still retries
// roughly hourly rather than backing off forever.
maxBackoffTicks = 60
// runTimeout bounds a single scheduled run so one stuck completion cannot pin
// a concurrency slot indefinitely.
runTimeout = 10 * time.Minute
)
// agentState is the per-agent runtime bookkeeping the scheduler keeps between
// ticks: how many runs are in flight, and the backoff countdown after failures.
type agentState struct {
inFlight int
failstreak int // consecutive failures; drives the backoff window.
skipRemain int // ticks still to skip before the next attempt.
parsed schedule
parsedExpr string // the expression `parsed` was compiled from (recompile on change).
}
type scheduler struct {
svc *svc
log luxlog.Logger
cancel context.CancelFunc // cancels the loop + all in-flight run contexts.
mu sync.Mutex
states map[string]*agentState // key: org + "\x00" + name
wg sync.WaitGroup // tracks in-flight run goroutines for clean shutdown.
// now is time.Now, overridable in tests for deterministic cron evaluation.
now func() time.Time
// tick, when non-nil, replaces the internal ticker so tests drive cadence.
tickC <-chan time.Time
}
func newScheduler(s *svc, log luxlog.Logger) *scheduler {
return &scheduler{
svc: s,
log: log.New("component", "scheduler"),
states: map[string]*agentState{},
now: time.Now,
}
}
// start launches the scheduler loop in its own goroutine. Cancelled by stop().
func (sc *scheduler) start() {
ctx, cancel := context.WithCancel(context.Background())
sc.cancel = cancel
sc.wg.Add(1)
go sc.loop(ctx)
}
// stop halts the scheduler and waits for in-flight runs to drain BEFORE the
// caller closes the store — otherwise a run could InsertRun into a closed DB.
//
// Cancelling the loop context also cancels every in-flight run's derived
// context, so a run whose AIClient honors ctx returns promptly. The drain wait
// is bounded by the caller's shutdown ctx: if a run ignores cancellation and
// runs long (up to runTimeout), stop returns at the deadline rather than hanging
// SIGTERM. Idempotent.
func (sc *scheduler) stop(ctx context.Context) {
if sc.cancel == nil {
return
}
sc.cancel()
sc.cancel = nil
done := make(chan struct{})
go func() { sc.wg.Wait(); close(done) }()
select {
case <-done: // clean drain
case <-ctx.Done():
sc.log.Warn("scheduler drain timed out; in-flight runs may not have recorded",
"err", ctx.Err())
}
}
// loop is the cadence driver. It uses the injected tick channel in tests, else a
// real minute ticker. Each tick evaluates the whole long-running work set.
func (sc *scheduler) loop(ctx context.Context) {
defer sc.wg.Done()
tickC := sc.tickC
if tickC == nil {
t := time.NewTicker(tickInterval)
defer t.Stop()
tickC = t.C
}
sc.log.Info("scheduler started", "interval", tickInterval)
for {
select {
case <-ctx.Done():
sc.log.Info("scheduler stopped")
return
case <-tickC:
sc.tick(ctx, sc.now())
}
}
}
// tick evaluates every long-running agent against the wall-clock minute now and
// launches the ones that are due, are not backed off, and have a free
// concurrency slot. It is separated from loop() so tests can invoke it directly.
func (sc *scheduler) tick(ctx context.Context, now time.Time) {
agents, err := sc.svc.store.ListLongRunning(ctx)
if err != nil {
sc.log.Warn("scheduler: list long-running failed", "err", err)
return
}
live := make(map[string]bool, len(agents))
for _, a := range agents {
key := stateKey(a.Org, a.Name)
live[key] = true
if sc.due(a, key, now) {
sc.launch(ctx, a, key)
}
}
sc.pruneDeleted(live)
}
// due decides, under a SINGLE lock acquisition, whether agent a should run this
// tick: it (re)compiles the cron on change, decrements a live backoff window,
// checks the cron against now and the per-agent concurrency slot, and — when it
// returns true — has already reserved the slot (inFlight++). All shared state
// (parsed cron, backoff, inFlight) is touched only while holding sc.mu, so there
// is no data race with the completion goroutine in launch().
func (sc *scheduler) due(a Agent, key string, now time.Time) bool {
sc.mu.Lock()
defer sc.mu.Unlock()
st := sc.stateForLocked(key)
// Recompile the cron only when the expression changed (edits via PATCH).
if st.parsedExpr != a.Schedule {
p, err := parseCron(a.Schedule)
if err != nil {
// Stored schedule is invalid (create/update validate, but a
// hand-edited DB could carry garbage). Skip, don't crash.
sc.log.Warn("scheduler: bad stored schedule, skipping",
"org", a.Org, "agent", a.Name, "schedule", a.Schedule, "err", err)
return false
}
st.parsed, st.parsedExpr = p, a.Schedule
}
if st.skipRemain > 0 { // in a backoff window — consume one tick.
st.skipRemain--
return false
}
if !st.parsed.matches(now) || st.inFlight >= maxConcurrentPerAgent {
return false
}
st.inFlight++ // reserve the slot before launching.
return true
}
// launch runs one scheduled invocation in its own goroutine, updating the
// agent's backoff/concurrency state on completion. The run is empty-input (a
// scheduled agent acts on its own instructions) and attributed to its service
// account when bound, else the synthetic scheduler actor.
func (sc *scheduler) launch(ctx context.Context, a Agent, key string) {
sc.wg.Add(1)
go func() {
defer sc.wg.Done()
runCtx, cancel := context.WithTimeout(ctx, runTimeout)
defer cancel()
// Scheduled runs carry no HTTP request/IP; requestID/clientIP are empty.
r, gateErr := sc.svc.runAgent(runCtx, a, "", scheduledActor(a), "", "")
ok := gateErr == nil && r.Status == "ok"
sc.mu.Lock()
st := sc.stateForLocked(key)
st.inFlight--
if ok {
st.failstreakReset()
} else {
st.failstreakBump()
}
remain := st.skipRemain
streak := st.failstreak
sc.mu.Unlock()
switch {
case gateErr != nil:
sc.log.Warn("scheduled run gated (not executed)",
"org", a.Org, "agent", a.Name, "err", gateErr, "failstreak", streak, "backoffTicks", remain)
case r.Status != "ok":
sc.log.Warn("scheduled run errored",
"org", a.Org, "agent", a.Name, "err", r.Error, "failstreak", streak, "backoffTicks", remain)
default:
sc.log.Info("scheduled run ok", "org", a.Org, "agent", a.Name, "durationMs", r.DurationMs)
}
}()
}
// stateForLocked returns (creating if needed) the runtime state for an agent
// key. The CALLER MUST hold sc.mu — every read/write of agentState fields is
// serialized by that one lock, so the scheduler has no data race between a tick
// deciding to run and a completion goroutine updating backoff/inFlight.
func (sc *scheduler) stateForLocked(key string) *agentState {
st := sc.states[key]
if st == nil {
st = &agentState{}
sc.states[key] = st
}
return st
}
// pruneDeleted drops runtime state for agents that no longer appear in the work
// set (deleted or switched to one-shot), but keeps any with a run still in
// flight so its completion bookkeeping lands on live state.
func (sc *scheduler) pruneDeleted(live map[string]bool) {
sc.mu.Lock()
defer sc.mu.Unlock()
for key, st := range sc.states {
if !live[key] && st.inFlight == 0 {
delete(sc.states, key)
}
}
}
// failstreakReset clears the failure streak and backoff after a success.
func (st *agentState) failstreakReset() { st.failstreak, st.skipRemain = 0, 0 }
// failstreakBump grows the failure streak and sets the next backoff window to
// 2^(streak-1) ticks, capped — 1,2,4,8,… minutes between retries.
func (st *agentState) failstreakBump() {
st.failstreak++
skip := 1 << uint(min(st.failstreak-1, 30)) // guard the shift; 2^30 >> cap.
if skip > maxBackoffTicks {
skip = maxBackoffTicks
}
st.skipRemain = skip
}
// stateKey namespaces runtime state by org+name. The NUL separator can never
// appear in either (nameRE + org validation forbid it), so keys are injective.
func stateKey(org, name string) string { return org + "\x00" + name }
// scheduledActor is the audit-trail Actor for a scheduled run. It is ALWAYS
// prefixed "scheduler" so a scheduled run can never masquerade as a validated
// interactive principal (org/sub). When the agent carries a service-account id
// it is appended as an UNVERIFIED hint (Red LOW-2): the id is client-supplied on
// create and not yet checked against IAM (that is the service-account keystone),
// so it must be clearly non-authoritative, not the bare "principal". Once IAM
// agent service accounts land, this becomes a verified identity.
func scheduledActor(a Agent) string {
if sa := strings.TrimSpace(a.ServiceAccountID); sa != "" {
return schedulerActor + ":" + a.Org + "/" + a.Name + " (sa:" + sa + " unverified)"
}
return schedulerActor + ":" + a.Org + "/" + a.Name
}
+311
View File
@@ -0,0 +1,311 @@
package agents
import (
"context"
"encoding/json"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/types"
"github.com/hanzoai/commerce/metering"
luxlog "github.com/luxfi/log"
)
// countingAI records how many completions ran and can be told to fail, so
// scheduler tests can assert on run count + drive backoff deterministically.
type countingAI struct {
mu sync.Mutex
calls int32
fail bool
err error
block chan struct{} // when non-nil, ChatCompletion blocks until closed.
}
func (c *countingAI) ChatCompletion(_ context.Context, _ *types.ChatRequest) (*types.ChatResponse, error) {
atomic.AddInt32(&c.calls, 1)
if c.block != nil {
<-c.block
}
c.mu.Lock()
fail, err := c.fail, c.err
c.mu.Unlock()
if fail {
if err == nil {
err = errTest
}
return nil, err
}
return &types.ChatResponse{Content: "done"}, nil
}
func (c *countingAI) count() int32 { return atomic.LoadInt32(&c.calls) }
// schedSvc builds an svc + 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 {
t.Helper()
s := &svc{store: testStore(t), ai: ai, log: luxlog.New("test")}
for _, a := range seed {
if err := s.store.Create(context.Background(), a); err != nil {
t.Fatalf("seed %s/%s: %v", a.Org, a.Name, err)
}
}
sc := newScheduler(s, luxlog.New("test"))
return sc
}
func longRunning(org, name, cron string) Agent {
a := mk(org, name)
a.ExecutionMode, a.Schedule = ModeLongRunning, cron
return a
}
// waitFor polls a condition briefly (async run goroutines).
func waitFor(cond func() bool) bool {
for i := 0; i < 200; i++ {
if cond() {
return true
}
time.Sleep(5 * time.Millisecond)
}
return cond()
}
// TestSchedulerFiresDueAgent: a tick at a minute the cron matches launches one
// 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 * * * *"))
ctx := context.Background()
sc.tick(ctx, at(t, "2026-07-01 12:36")) // 36 not multiple of 5 -> no fire
time.Sleep(20 * time.Millisecond)
if ai.count() != 0 {
t.Fatalf("non-matching minute must not fire, got %d", ai.count())
}
sc.tick(ctx, at(t, "2026-07-01 12:35")) // matches */5
if !waitFor(func() bool { return ai.count() == 1 }) {
t.Fatalf("matching minute must fire once, got %d", ai.count())
}
}
// TestSchedulerRecordsRun: a scheduled run is persisted to the run history, just
// like an HTTP run — the scheduler shares runAgent.
func TestSchedulerRecordsRun(t *testing.T) {
ai := &countingAI{}
sc := schedSvc(t, ai, longRunning("acme", "cron", "* * * * *"))
ctx := context.Background()
sc.tick(ctx, at(t, "2026-07-01 12:00"))
if !waitFor(func() bool {
runs, _ := sc.svc.store.ListRuns(ctx, "acme", "cron", 10)
return len(runs) == 1 && runs[0].Status == "ok"
}) {
runs, _ := sc.svc.store.ListRuns(ctx, "acme", "cron", 10)
t.Fatalf("scheduled run not recorded: %+v", runs)
}
}
// TestSchedulerBackoffOnFailure: after a failed run the agent is skipped for a
// growing number of ticks, so a broken agent stops hammering. The first failing
// 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", "* * * * *"))
ctx := context.Background()
sc.tick(ctx, at(t, "2026-07-01 12:00"))
if !waitFor(func() bool { return ai.count() == 1 }) {
t.Fatalf("first tick should attempt the run, got %d", ai.count())
}
// Wait for the failure to register the backoff window.
if !waitFor(func() bool {
sc.mu.Lock()
defer sc.mu.Unlock()
st := sc.states[stateKey("acme", "cron")]
return st != nil && st.failstreak == 1 && st.skipRemain == 1
}) {
t.Fatal("failure should set failstreak=1, skipRemain=1")
}
// Next matching tick is consumed by backoff -> no new run.
sc.tick(ctx, at(t, "2026-07-01 12:01"))
time.Sleep(20 * time.Millisecond)
if ai.count() != 1 {
t.Fatalf("backoff tick must not fire, got %d", ai.count())
}
// The tick after that (skip exhausted) fires again.
sc.tick(ctx, at(t, "2026-07-01 12:02"))
if !waitFor(func() bool { return ai.count() == 2 }) {
t.Fatalf("post-backoff tick should fire, got %d", ai.count())
}
}
// TestSchedulerConcurrencyCap: a slow run holds the single per-agent slot, so a
// 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", "* * * * *"))
ctx := context.Background()
sc.tick(ctx, at(t, "2026-07-01 12:00")) // starts run #1, which blocks
if !waitFor(func() bool { return ai.count() == 1 }) {
t.Fatalf("first run should start, got %d", ai.count())
}
sc.tick(ctx, at(t, "2026-07-01 12:01")) // slot busy -> no second run
time.Sleep(20 * time.Millisecond)
if ai.count() != 1 {
t.Fatalf("concurrency cap breached: %d runs in flight", ai.count())
}
close(ai.block) // let run #1 finish
if !waitFor(func() bool {
sc.mu.Lock()
defer sc.mu.Unlock()
st := sc.states[stateKey("acme", "cron")]
return st != nil && st.inFlight == 0
}) {
t.Fatal("in-flight count should drain to 0 after completion")
}
}
// TestSchedulerBillsScheduledRun: a scheduled tick goes through the SAME gate +
// meter as an HTTP run — a funded agent's scheduled run debits its OWN org via
// commerce (product=agent), proving the billing path is live on the cron path,
// not just the HTTP handler (Red INFO-2).
func TestSchedulerBillsScheduledRun(t *testing.T) {
bs := &billServer{available: 100000}
m, err := metering.New(metering.Config{BaseURL: bs.start(t), Token: "svc-tok", Org: "hanzo"})
if err != nil {
t.Fatalf("metering.New: %v", err)
}
s := &svc{
store: testStore(t),
ai: &countingAI{},
log: luxlog.New("test"),
bill: cloud.NewResourceMeter(cloud.Deps{Metering: m, Logger: luxlog.New("test")}, meterKind),
}
if err := s.store.Create(context.Background(), longRunning("acme", "cron", "* * * * *")); err != nil {
t.Fatalf("seed: %v", err)
}
sc := newScheduler(s, luxlog.New("test"))
sc.tick(context.Background(), at(t, "2026-07-01 12:00"))
if !waitForDebit(func() bool { return bs.debits() == 1 }) {
t.Fatalf("a scheduled run on a funded org must debit once, got %d", bs.debits())
}
org, ubody := bs.lastDebit()
if org != "acme" {
t.Fatalf("scheduled debit org = %q, want the agent's own org acme", org)
}
var u struct {
User string `json:"user"`
Provider string `json:"provider"`
Actor string `json:"actor"`
}
_ = json.Unmarshal(ubody, &u)
if u.User != "acme" || u.Provider != meterKind {
t.Fatalf("scheduled debit user/provider = %q/%q, want acme/%s", u.User, u.Provider, meterKind)
}
// The actor MUST be the "scheduler:" namespace, never a bare "org/sub" that
// could be mistaken for a validated interactive principal (Red LOW-2).
if !strings.HasPrefix(u.Actor, schedulerActor+":") {
t.Fatalf("scheduled actor = %q, want a %q-prefixed (non-principal) actor", u.Actor, schedulerActor)
}
}
// TestSchedulerGatesUnfundedRun: a scheduled run on an unfunded org is gated
// (fail-closed) so the model NEVER runs and nothing is debited — an unfunded
// long-running agent can't burn free inference every minute.
func TestSchedulerGatesUnfundedRun(t *testing.T) {
bs := &billServer{available: 0}
m, _ := metering.New(metering.Config{BaseURL: bs.start(t), Token: "t", Org: "hanzo"})
ai := &countingAI{}
s := &svc{
store: testStore(t),
ai: ai,
log: luxlog.New("test"),
bill: cloud.NewResourceMeter(cloud.Deps{Metering: m, Logger: luxlog.New("test")}, meterKind),
}
if err := s.store.Create(context.Background(), longRunning("acme", "cron", "* * * * *")); err != nil {
t.Fatalf("seed: %v", err)
}
sc := newScheduler(s, luxlog.New("test"))
sc.tick(context.Background(), at(t, "2026-07-01 12:00"))
time.Sleep(40 * time.Millisecond)
if ai.count() != 0 {
t.Fatalf("unfunded scheduled run must NOT invoke the model, got %d", ai.count())
}
if bs.debits() != 0 {
t.Fatalf("unfunded scheduled run must not debit, got %d", bs.debits())
}
}
// TestSchedulerStopDrainsCleanly: stop() with an un-expired ctx cancels the loop
// and waits for the (fast) in-flight run to finish before returning — the drain
// path that lets Shutdown close the store safely.
func TestSchedulerStopDrainsCleanly(t *testing.T) {
ai := &countingAI{}
sc := schedSvc(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"))
done := make(chan struct{})
go func() { sc.stop(context.Background()); close(done) }()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("stop() did not return — drain hung")
}
// After a clean stop, no run goroutine is left holding a slot.
sc.mu.Lock()
st := sc.states[stateKey("acme", "cron")]
inFlight := 0
if st != nil {
inFlight = st.inFlight
}
sc.mu.Unlock()
if inFlight != 0 {
t.Fatalf("after drain inFlight=%d, want 0", inFlight)
}
}
// TestSchedulerStopHonorsDeadline: a run that IGNORES cancellation (blocks) must
// not hang stop() past the caller's deadline — stop returns at the ctx deadline
// 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.start()
sc.tick(context.Background(), at(t, "2026-07-01 12:00"))
if !waitFor(func() bool { return ai.count() == 1 }) {
t.Fatal("run should have started")
}
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
start := time.Now()
sc.stop(ctx) // the run is blocked and ignores ctx; stop must return at deadline
if elapsed := time.Since(start); elapsed > 2*time.Second {
t.Fatalf("stop() waited %v — did not honor the 100ms deadline", elapsed)
}
// Release the stuck run and let it fully drain BEFORE the test's store
// cleanup, so the late InsertRun can't race a closed DB.
close(ai.block)
sc.wg.Wait()
}
// TestSchedulerOnlyLongRunning: a one-shot agent is never fired by the
// scheduler even if its (dropped) schedule would have matched.
func TestSchedulerOnlyLongRunning(t *testing.T) {
ai := &countingAI{}
one := mk("acme", "one") // one-shot default, no schedule
sc := schedSvc(t, ai, one)
sc.tick(context.Background(), at(t, "2026-07-01 12:00"))
time.Sleep(20 * time.Millisecond)
if ai.count() != 0 {
t.Fatalf("one-shot agent must never be scheduled, got %d", ai.count())
}
}
+704
View File
@@ -0,0 +1,704 @@
package agents
import (
"context"
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"github.com/zap-proto/zip"
)
// This file mounts the LIVE agent-session control plane under /v1/agents/sessions
// — the canonical registry every surface (the @hanzo/dev CLI outer agent,
// hanzo.bot, console, chat, app) hangs off. It is the VIEW + control + ZAP-stream
// layer over durable execution; the durable run itself is a hanzoai/tasks
// workflow (see sessions_tasks.go), never a bespoke scheduler here.
//
// POST /v1/agents/sessions register a session (opt parentSessionId) -> Session
// GET /v1/agents/sessions list live sessions (filter root/parent/status) -> {sessions:[...]}
// GET /v1/agents/sessions/stream SSE feed of session+event updates (rides ZAP)
// GET /v1/agents/sessions/:id detail + direct children + recent events -> SessionDetail
// PATCH /v1/agents/sessions/:id update status/title -> Session
// GET /v1/agents/sessions/:id/tree the full subagent-flow graph -> TreeNode
// POST /v1/agents/sessions/:id/events append an event (message/tool-call/spawn/log) -> Event
// POST /v1/agents/sessions/:id/{pause,resume,stop,message} control command -> {command,event,forwarded}
//
// Every route is org-scoped through principal.Tenant (a validated principal AND
// a non-empty org), so cross-tenant reads/writes/control are refused fail-closed.
// Event kinds — the closed vocabulary of a session's ordered log.
const (
KindMessage = "message"
KindToolCall = "tool-call"
KindSpawn = "spawn"
KindLog = "log"
KindStatus = "status"
KindControl = "control"
)
// Control commands — the closed vocabulary of remote steering.
const (
CmdPause = "pause"
CmdResume = "resume"
CmdStop = "stop"
CmdMessage = "message"
)
const (
maxTitle = 512
maxAgentLabel = 128
maxActor = 256
maxSessionID = 128
maxWorkflowRef = 256
maxEventPayload = 64 * 1024
maxControlMsg = 16 * 1024
recentEvents = 50
treeNodeCap = 10000
)
func validKind(k string) bool {
switch k {
case KindMessage, KindToolCall, KindSpawn, KindLog, KindStatus, KindControl:
return true
}
return false
}
// ---- HTTP shapes (the published contract) ----
type sessionView struct {
ID string `json:"id"`
Agent string `json:"agent"`
Actor string `json:"actor,omitempty"`
Status string `json:"status"`
ParentSessionID string `json:"parentSessionId,omitempty"`
RootSessionID string `json:"rootSessionId"`
Title string `json:"title,omitempty"`
TaskWorkflowID string `json:"taskWorkflowId,omitempty"`
TaskRunID string `json:"taskRunId,omitempty"`
Events int `json:"events"`
Children int `json:"children"`
StartedAt string `json:"startedAt"`
EndedAt string `json:"endedAt,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type eventView struct {
ID string `json:"id"`
SessionID string `json:"sessionId"`
Seq int64 `json:"seq"`
Kind string `json:"kind"`
Actor string `json:"actor,omitempty"`
Payload json.RawMessage `json:"payload,omitempty"`
CreatedAt string `json:"createdAt"`
}
type sessionDetail struct {
sessionView
Children []sessionView `json:"childSessions"`
RecentEvents []eventView `json:"recentEvents"`
}
// treeNode is one node of the subagent-flow graph: a session plus its children,
// recursively. Node = {session, children:[...]} — the session's own Children int
// is the direct fan-out count, the children array is the materialised subtree.
type treeNode struct {
Session sessionView `json:"session"`
Children []treeNode `json:"children"`
}
func toSessionView(x Session, events, children int) sessionView {
return sessionView{
ID: x.ID, Agent: x.Agent, Actor: x.Actor, Status: x.Status,
ParentSessionID: x.ParentID, RootSessionID: x.RootID, Title: x.Title,
TaskWorkflowID: x.TaskWorkflowID, TaskRunID: x.TaskRunID,
Events: events, Children: children,
StartedAt: rfc3339(x.StartedAt), EndedAt: rfc3339(x.EndedAt),
CreatedAt: rfc3339(x.CreatedAt), UpdatedAt: rfc3339(x.UpdatedAt),
}
}
func toEventView(e Event) eventView {
var p json.RawMessage
if e.Payload != "" {
p = json.RawMessage(e.Payload)
}
return eventView{
ID: e.ID, SessionID: e.SessionID, Seq: e.Seq, Kind: e.Kind, Actor: e.Actor,
Payload: p, CreatedAt: rfc3339(e.CreatedAt),
}
}
// mountSessions registers the sessions routes. It MUST be called before the
// /v1/agents/:name wildcard (Fiber matches in registration order, so a bare
// :name would otherwise capture "sessions"). Within the block, the static
// /stream route precedes the /:id param for the same reason.
func (s *svc) mountSessions(app *zip.App) {
app.Post("/v1/agents/sessions", s.registerSession)
app.Get("/v1/agents/sessions", s.listSessions)
app.Get("/v1/agents/sessions/stream", s.sessionsStream)
app.Get("/v1/agents/sessions/:id", s.getSession)
app.Patch("/v1/agents/sessions/:id", s.patchSession)
app.Get("/v1/agents/sessions/:id/tree", s.sessionTree)
app.Post("/v1/agents/sessions/:id/events", s.appendSessionEvent)
app.Post("/v1/agents/sessions/:id/pause", s.pauseSession)
app.Post("/v1/agents/sessions/:id/resume", s.resumeSession)
app.Post("/v1/agents/sessions/:id/stop", s.stopSession)
app.Post("/v1/agents/sessions/:id/message", s.messageSession)
}
func idParam(c *zip.Ctx) string { return strings.TrimSpace(c.Param("id")) }
// ---- register ----
type registerReq struct {
Agent string `json:"agent"`
Actor string `json:"actor"`
Title string `json:"title"`
Status string `json:"status"`
ParentSessionID string `json:"parentSessionId"`
TaskWorkflowID string `json:"taskWorkflowId"`
TaskRunID string `json:"taskRunId"`
}
func (s *svc) registerSession(c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
var body registerReq
if err := c.Bind(&body); err != nil {
return err
}
agent := strings.TrimSpace(body.Agent)
if agent == "" {
return zip.ErrBadRequest("agent is required")
}
if len(agent) > maxAgentLabel {
return zip.ErrBadRequest("agent too long")
}
if len(body.Title) > maxTitle {
return zip.ErrBadRequest("title too long")
}
status := strings.TrimSpace(body.Status)
if status == "" {
status = StatusRunning
}
if !validStatus(status) {
return zip.ErrBadRequest("status must be running|paused|done|error")
}
actor := strings.TrimSpace(body.Actor)
if actor == "" {
actor = billingActor(org, c.User())
}
if len(actor) > maxActor {
return zip.ErrBadRequest("actor too long")
}
if len(body.TaskWorkflowID) > maxWorkflowRef || len(body.TaskRunID) > maxWorkflowRef {
return zip.ErrBadRequest("task workflow/run reference too long")
}
id, err := genID("sess")
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "rng: %v", err)
}
now := time.Now().Unix()
x := Session{
ID: id, Org: org, Agent: agent, Actor: actor, Status: status,
Title: strings.TrimSpace(body.Title),
TaskWorkflowID: strings.TrimSpace(body.TaskWorkflowID),
TaskRunID: strings.TrimSpace(body.TaskRunID),
StartedAt: now, CreatedAt: now, UpdatedAt: now,
}
if isTerminalStatus(status) {
x.EndedAt = now
}
// Subagent linkage. A parent MUST exist IN THE SAME ORG — the tree can never
// cross a tenant boundary. RootID is inherited from the parent (all nodes in
// one flow share it); a session with no parent is itself a root.
parent := strings.TrimSpace(body.ParentSessionID)
if parent != "" {
p, perr := s.store.GetSession(c.Context(), org, parent)
if perr == errSessionNotFound {
return zip.ErrBadRequest("parentSessionId not found in this org")
}
if perr != nil {
return zip.Errorf(http.StatusInternalServerError, "parent: %v", perr)
}
x.ParentID = p.ID
x.RootID = p.RootID
} else {
x.RootID = id
}
if err := s.store.CreateSession(c.Context(), x); err != nil {
if err == errParentNotFound {
return zip.ErrBadRequest("parentSessionId not found in this org")
}
return zip.Errorf(http.StatusInternalServerError, "persist: %v", err)
}
s.publishSession(x, 0, 0)
return c.JSON(http.StatusCreated, toSessionView(x, 0, 0))
}
// ---- list ----
func (s *svc) listSessions(c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
f := SessionFilter{
Root: trimField(c.Query("root")),
Parent: trimField(c.Query("parent")),
Status: trimField(c.Query("status")),
Limit: queryInt(c, "limit"),
}
if f.Status != "" && !validStatus(f.Status) {
return zip.ErrBadRequest("status must be running|paused|done|error")
}
rows, err := s.store.ListSessions(c.Context(), org, f)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "list: %v", err)
}
out := make([]sessionView, 0, len(rows))
for _, x := range rows {
ev, _ := s.store.CountEvents(c.Context(), org, x.ID)
ch, _ := s.store.CountChildren(c.Context(), org, x.ID)
out = append(out, toSessionView(x, ev, ch))
}
return c.JSON(http.StatusOK, map[string]any{"sessions": out})
}
// ---- detail ----
func (s *svc) getSession(c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
id := idParam(c)
if len(id) > maxSessionID {
return zip.ErrNotFound("session not found")
}
x, err := s.store.GetSession(c.Context(), org, id)
if err == errSessionNotFound {
return zip.ErrNotFound("session not found")
}
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "get: %v", err)
}
kids, err := s.store.ListSessions(c.Context(), org, SessionFilter{Parent: id})
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "children: %v", err)
}
events, err := s.store.ListEvents(c.Context(), org, id, 0, recentEvents)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "events: %v", err)
}
evCount, _ := s.store.CountEvents(c.Context(), org, id)
kidViews := make([]sessionView, 0, len(kids))
for _, k := range kids {
kc, _ := s.store.CountChildren(c.Context(), org, k.ID)
ke, _ := s.store.CountEvents(c.Context(), org, k.ID)
kidViews = append(kidViews, toSessionView(k, ke, kc))
}
evViews := make([]eventView, 0, len(events))
for _, e := range events {
evViews = append(evViews, toEventView(e))
}
return c.JSON(http.StatusOK, sessionDetail{
sessionView: toSessionView(x, evCount, len(kids)),
Children: kidViews,
RecentEvents: evViews,
})
}
// ---- tree ----
func (s *svc) sessionTree(c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
id := idParam(c)
if len(id) > maxSessionID {
return zip.ErrNotFound("session not found")
}
x, err := s.store.GetSession(c.Context(), org, id)
if err == errSessionNotFound {
return zip.ErrNotFound("session not found")
}
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "get: %v", err)
}
// One indexed query pulls the whole tree (same RootID); assemble in memory.
nodes, err := s.store.ListTree(c.Context(), org, x.RootID, treeNodeCap)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "tree: %v", err)
}
counts, err := s.store.EventCountsByRoot(c.Context(), org, x.RootID)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "counts: %v", err)
}
return c.JSON(http.StatusOK, buildSubtree(nodes, counts, id))
}
// buildSubtree assembles the flat tree rows into the node rooted at rootAtID.
// children map is built once; each node's Children int (fan-out) comes from the
// map, its Events from counts. A missing rootAtID yields an empty node (the
// caller already verified the session exists, so this is defensive).
func buildSubtree(nodes []Session, counts map[string]int, rootAtID string) treeNode {
childrenOf := map[string][]Session{}
byID := map[string]Session{}
for _, n := range nodes {
byID[n.ID] = n
childrenOf[n.ParentID] = append(childrenOf[n.ParentID], n)
}
var build func(x Session) treeNode
build = func(x Session) treeNode {
kids := childrenOf[x.ID]
node := treeNode{Session: toSessionView(x, counts[x.ID], len(kids))}
for _, k := range kids {
node.Children = append(node.Children, build(k))
}
return node
}
root, ok := byID[rootAtID]
if !ok {
return treeNode{}
}
return build(root)
}
// ---- patch (status/title, surface-owned truth) ----
type patchSessionReq struct {
Status *string `json:"status"`
Title *string `json:"title"`
}
func (s *svc) patchSession(c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
id := idParam(c)
x, err := s.store.GetSession(c.Context(), org, id)
if err == errSessionNotFound {
return zip.ErrNotFound("session not found")
}
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "get: %v", err)
}
var body patchSessionReq
if err := c.Bind(&body); err != nil {
return err
}
if body.Status != nil {
ns := strings.TrimSpace(*body.Status)
if !validStatus(ns) {
return zip.ErrBadRequest("status must be running|paused|done|error")
}
// A finished session stays finished (truthful, monotonic terminal state):
// reopening a done/error run would fabricate liveness.
if isTerminalStatus(x.Status) && ns != x.Status {
return zip.Errorf(http.StatusConflict, "session is %s; cannot change status", x.Status)
}
x.Status = ns
if isTerminalStatus(ns) && x.EndedAt == 0 {
x.EndedAt = time.Now().Unix()
}
}
if body.Title != nil {
if len(*body.Title) > maxTitle {
return zip.ErrBadRequest("title too long")
}
x.Title = strings.TrimSpace(*body.Title)
}
x.UpdatedAt = time.Now().Unix()
if err := s.store.UpdateSession(c.Context(), x); err != nil {
if err == errSessionNotFound {
return zip.ErrNotFound("session not found")
}
return zip.Errorf(http.StatusInternalServerError, "update: %v", err)
}
ev, _ := s.store.CountEvents(c.Context(), org, id)
ch, _ := s.store.CountChildren(c.Context(), org, id)
s.publishSession(x, ev, ch)
return c.JSON(http.StatusOK, toSessionView(x, ev, ch))
}
// ---- append event ----
type eventReq struct {
Kind string `json:"kind"`
Actor string `json:"actor"`
Payload json.RawMessage `json:"payload"`
}
func (s *svc) appendSessionEvent(c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
id := idParam(c)
x, err := s.store.GetSession(c.Context(), org, id)
if err == errSessionNotFound {
return zip.ErrNotFound("session not found")
}
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "get: %v", err)
}
var body eventReq
if err := c.Bind(&body); err != nil {
return err
}
kind := strings.TrimSpace(body.Kind)
if !validKind(kind) {
return zip.ErrBadRequest("kind must be message|tool-call|spawn|log|status|control")
}
if len(body.Payload) > maxEventPayload {
return zip.ErrBadRequest("payload too large")
}
if len(body.Payload) > 0 && !json.Valid(body.Payload) {
return zip.ErrBadRequest("payload must be valid JSON")
}
actor := strings.TrimSpace(body.Actor)
if actor == "" {
actor = billingActor(org, c.User())
}
if len(actor) > maxActor {
return zip.ErrBadRequest("actor too long")
}
evID, err := genID("evt")
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "rng: %v", err)
}
e, err := s.store.AppendEvent(c.Context(), Event{
ID: evID, SessionID: id, Org: org, Kind: kind, Actor: actor,
Payload: string(body.Payload), CreatedAt: time.Now().Unix(),
})
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "append: %v", err)
}
s.publishEvent(org, x.RootID, e)
return c.JSON(http.StatusCreated, toEventView(e))
}
// ---- control (record intent + forward to the tasks engine when task-backed) ----
type controlReq struct {
Message string `json:"message"`
Payload json.RawMessage `json:"payload"`
}
type controlPayload struct {
Command string `json:"command"`
Message string `json:"message,omitempty"`
Payload json.RawMessage `json:"payload,omitempty"`
}
func (s *svc) pauseSession(c *zip.Ctx) error { return s.control(c, CmdPause) }
func (s *svc) resumeSession(c *zip.Ctx) error { return s.control(c, CmdResume) }
func (s *svc) stopSession(c *zip.Ctx) error { return s.control(c, CmdStop) }
func (s *svc) messageSession(c *zip.Ctx) error { return s.control(c, CmdMessage) }
// control records a steering command as a durable control event (the intent the
// running surface consumes) and, when the session is backed by a hanzoai/tasks
// workflow AND a tasks backend is wired, forwards it to the engine's signal/
// cancel API. Org/actor-authorized: principal.Tenant already requires a validated
// principal AND same-org ownership of the session, so no other tenant can steer.
func (s *svc) control(c *zip.Ctx, command string) error {
org, ok := tenant(c)
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
id := idParam(c)
x, err := s.store.GetSession(c.Context(), org, id)
if err == errSessionNotFound {
return zip.ErrNotFound("session not found")
}
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "get: %v", err)
}
if isTerminalStatus(x.Status) {
return zip.Errorf(http.StatusConflict, "session is %s; cannot %s a finished session", x.Status, command)
}
// The control body is optional (pause/resume/stop often carry none); only
// parse when present so a bodyless command is not a 400.
var body controlReq
if len(c.Body()) > 0 {
if err := c.Bind(&body); err != nil {
return err
}
}
if len(body.Message) > maxControlMsg {
return zip.ErrBadRequest("message too long")
}
if len(body.Payload) > maxEventPayload {
return zip.ErrBadRequest("payload too large")
}
if len(body.Payload) > 0 && !json.Valid(body.Payload) {
return zip.ErrBadRequest("payload must be valid JSON")
}
if command == CmdMessage && strings.TrimSpace(body.Message) == "" && len(body.Payload) == 0 {
return zip.ErrBadRequest("message requires a 'message' or 'payload'")
}
actor := billingActor(org, c.User())
cp, _ := json.Marshal(controlPayload{Command: command, Message: body.Message, Payload: body.Payload})
evID, err := genID("evt")
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "rng: %v", err)
}
e, err := s.store.AppendEvent(c.Context(), Event{
ID: evID, SessionID: id, Org: org, Kind: KindControl, Actor: actor,
Payload: string(cp), CreatedAt: time.Now().Unix(),
})
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "record control: %v", err)
}
s.publishEvent(org, x.RootID, e)
// Forward to the durable-execution engine when this session is task-backed.
// The intent is ALREADY durably recorded above, so a forward failure is
// reported (502) without losing the command; a session with no workflow link
// or no wired backend is record-only (stream-consuming surfaces act on it).
forwarded := false
if x.TaskWorkflowID != "" && s.tasks != nil && s.tasks.Enabled() {
var ferr error
if command == CmdStop {
ferr = s.tasks.Cancel(c.Context(), x.TaskWorkflowID, x.TaskRunID, reasonOf(body.Message))
} else {
ferr = s.tasks.Signal(c.Context(), x.TaskWorkflowID, x.TaskRunID, command, signalPayload(body))
}
if ferr != nil {
return zip.Errorf(http.StatusBadGateway, "control recorded but tasks forward failed: %v", ferr)
}
forwarded = true
}
return c.JSON(http.StatusOK, map[string]any{
"command": command, "event": toEventView(e), "forwarded": forwarded,
})
}
func reasonOf(msg string) string {
if m := strings.TrimSpace(msg); m != "" {
return m
}
return "stopped via control plane"
}
func signalPayload(b controlReq) []byte {
if len(b.Payload) > 0 {
return b.Payload
}
if b.Message != "" {
m, _ := json.Marshal(b.Message)
return m
}
return nil
}
// ---- run integration (#5): a /v1/agents/:name/run opens a root session ----
// openRunSession records a completed agent run as a ROOT session so every run is
// visible in the same registry the @hanzo/dev outer-agent flows use. Best-effort:
// a bookkeeping failure NEVER fails the run (the run + its billing already
// happened). A cloud one-shot run is a synchronous completion, so the session is
// born terminal with one log event; TaskWorkflowID is left empty because the run
// is not (yet) a tasks workflow — when runs are promoted to hanzoai/tasks
// ExecuteWorkflow, set TaskWorkflowID/TaskRunID here from the workflow handle.
func (s *svc) openRunSession(ctx context.Context, a Agent, r Run, actor string) {
if s.store == nil {
return
}
status := StatusDone
if r.Status != "ok" {
status = StatusError
}
id, err := genID("sess")
if err != nil {
s.log.Warn("run session: rng", "err", err)
return
}
ts := r.CreatedAt
if ts == 0 {
ts = time.Now().Unix()
}
x := Session{
ID: id, Org: a.Org, Agent: a.Name, Actor: actor, Status: status,
RootID: id, Title: runTitle(r.Input),
StartedAt: ts, EndedAt: ts, CreatedAt: ts, UpdatedAt: ts,
}
if err := s.store.CreateSession(ctx, x); err != nil {
s.log.Warn("run session: create", "org", a.Org, "agent", a.Name, "err", err)
return
}
payload, _ := json.Marshal(map[string]any{
"runId": r.ID, "status": r.Status, "model": r.Model,
"durationMs": r.DurationMs, "error": r.Error,
})
evID, err := genID("evt")
if err != nil {
s.publishSession(x, 0, 0)
return
}
e, aerr := s.store.AppendEvent(ctx, Event{
ID: evID, SessionID: id, Org: a.Org, Kind: KindLog, Actor: actor,
Payload: string(payload), CreatedAt: ts,
})
s.publishSession(x, 1, 0)
if aerr == nil {
s.publishEvent(a.Org, x.RootID, e)
}
}
func runTitle(input string) string {
t := strings.TrimSpace(input)
if t == "" {
return "agent run"
}
if len(t) > 120 {
t = t[:120]
}
return t
}
// ---- stream publish helpers (nil-safe: a bus-less svc, e.g. a direct-construct
// unit test, simply skips the live fan-out; the store is still the truth) ----
func (s *svc) publishSession(x Session, events, children int) {
if s.bus == nil {
return
}
v := toSessionView(x, events, children)
s.bus.publish(streamUpdate{Org: x.Org, RootID: x.RootID, Type: "session", Session: &v})
}
func (s *svc) publishEvent(org, rootID string, e Event) {
if s.bus == nil {
return
}
v := toEventView(e)
s.bus.publish(streamUpdate{Org: org, RootID: rootID, Type: "event", Event: &v})
}
// ---- small query helpers ----
func trimField(v string) string { return strings.TrimSpace(v) }
func queryInt(c *zip.Ctx, name string) int {
if q := strings.TrimSpace(c.Query(name)); q != "" {
if n, err := strconv.Atoi(q); err == nil && n >= 0 {
return n
}
}
return 0
}
+390
View File
@@ -0,0 +1,390 @@
package agents
import (
"context"
"database/sql"
"errors"
"fmt"
)
// A live agent-session is a running invocation — a cloud agent run, a bot loop,
// or a @hanzo/dev CLI run spawning subagents. The SUBAGENT TREE is sessions
// linked by ParentID: the outer agent is the root (ParentID==""), each spawned
// subagent is a child, and RootID is the tree key every node in one flow shares.
// It is the first-class, streamable form of the blue/red/cto fan-out tree.
//
// A session is NOT foreign-keyed to an agents row: an external surface (the
// @hanzo/dev CLI) registers a session whose Agent is just a label, not a cloud
// Agent definition. Tenant isolation is the Org column, enforced on every query
// exactly like agents/runs — one file (agents.db), tenancy is the org.
type Session struct {
ID string
Org string
Agent string // agent name / type label (need not be a cloud Agent row)
Actor string // the principal that started it (validated user, or a bound SA)
Status string // running|paused|done|error
ParentID string // "" for a root (the outer agent)
RootID string // the tree key; == ID for a root
Title string
StartedAt int64
EndedAt int64 // 0 until a terminal status is reached
CreatedAt int64
UpdatedAt int64
// TaskWorkflowID / TaskRunID link this session to the hanzoai/tasks durable
// workflow that actually EXECUTES it. This registry is the view/control/stream
// layer; durable execution (retries, resumability, scheduling) is owned by
// hanzoai/tasks — NOT by a bespoke scheduler here. A root session maps to a
// tasks workflow (ExecuteWorkflow); a subagent maps to a child workflow keyed
// by the same RootID. When these are set, control (pause/resume/stop/message)
// forwards to the tasks Signal/Cancel API (see svc.tasks). Empty = a surface
// that consumes control from the event stream instead (today's @hanzo/dev).
TaskWorkflowID string
TaskRunID string
}
// Event is one entry in a session's ordered log: a model message, a tool call, a
// subagent spawn, a free log line, a status change, or a control command the
// running surface consumes. Seq is monotonic PER SESSION so a subscriber can
// resume from its last-seen point; Org is denormalised so every read stays
// org-scoped without a join back to the session row.
type Event struct {
ID string
SessionID string
Org string
Seq int64
Kind string // message|tool-call|spawn|log|status|control
Actor string
Payload string // opaque JSON blob (validated well-formed, size-bounded)
CreatedAt int64
}
// Session status values. running/paused are live; done/error are terminal.
const (
StatusRunning = "running"
StatusPaused = "paused"
StatusDone = "done"
StatusError = "error"
)
func isTerminalStatus(s string) bool { return s == StatusDone || s == StatusError }
func validStatus(s string) bool {
switch s {
case StatusRunning, StatusPaused, StatusDone, StatusError:
return true
}
return false
}
var (
errSessionNotFound = errors.New("agents: session not found")
errParentNotFound = errors.New("agents: parent session not found")
)
// migrateSessions creates the session + event tables. Called from migrate() so
// the ONE agents.db carries agents, runs, sessions and events — one store, one
// tenancy column, no second DB handle. Idempotent (IF NOT EXISTS).
func (s *Store) migrateSessions() error {
const ddl = `
CREATE TABLE IF NOT EXISTS agent_sessions (
id TEXT PRIMARY KEY,
org TEXT NOT NULL,
agent TEXT NOT NULL DEFAULT '',
actor TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'running',
parent_id TEXT NOT NULL DEFAULT '',
root_id TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
started_at INTEGER NOT NULL,
ended_at INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
task_workflow_id TEXT NOT NULL DEFAULT '',
task_run_id TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS ix_sessions_org_root ON agent_sessions(org, root_id, created_at);
CREATE INDEX IF NOT EXISTS ix_sessions_org_parent ON agent_sessions(org, parent_id, created_at);
CREATE INDEX IF NOT EXISTS ix_sessions_org_status ON agent_sessions(org, status, updated_at);
CREATE TABLE IF NOT EXISTS agent_session_events (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
org TEXT NOT NULL,
seq INTEGER NOT NULL,
kind TEXT NOT NULL,
actor TEXT NOT NULL DEFAULT '',
payload TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS ux_events_session_seq ON agent_session_events(session_id, seq);
CREATE INDEX IF NOT EXISTS ix_events_org_session_seq ON agent_session_events(org, session_id, seq);
`
if _, err := s.db.Exec(ddl); err != nil {
return fmt.Errorf("migrate sessions: %w", err)
}
return nil
}
const sessionCols = `id,org,agent,actor,status,parent_id,root_id,title,started_at,ended_at,created_at,updated_at,task_workflow_id,task_run_id`
func scanSession(sc interface{ Scan(...any) error }) (Session, error) {
var x Session
err := sc.Scan(&x.ID, &x.Org, &x.Agent, &x.Actor, &x.Status, &x.ParentID, &x.RootID,
&x.Title, &x.StartedAt, &x.EndedAt, &x.CreatedAt, &x.UpdatedAt,
&x.TaskWorkflowID, &x.TaskRunID)
return x, err
}
// CreateSession inserts one session. When ParentID is set it MUST reference an
// existing session IN THE SAME ORG — the caller resolves it via GetSession first
// so a cross-tenant or dangling parent can never link a tree. RootID is derived
// by the caller (parent's root, or self for a root); this method persists what it
// is given after a final same-org sanity check on the parent.
func (s *Store) CreateSession(ctx context.Context, x Session) error {
if x.ParentID != "" {
// Re-verify the parent under the SAME org inside the write path so a
// TOCTOU between the handler's lookup and here cannot smuggle a foreign
// or deleted parent into the tree (fail-closed).
var org string
err := s.db.QueryRowContext(ctx,
`SELECT org FROM agent_sessions WHERE id=? AND org=?`, x.ParentID, x.Org).Scan(&org)
if errors.Is(err, sql.ErrNoRows) {
return errParentNotFound
}
if err != nil {
return fmt.Errorf("verify parent: %w", err)
}
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO agent_sessions (`+sessionCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
x.ID, x.Org, x.Agent, x.Actor, x.Status, x.ParentID, x.RootID, x.Title,
x.StartedAt, x.EndedAt, x.CreatedAt, x.UpdatedAt, x.TaskWorkflowID, x.TaskRunID)
if err != nil {
return fmt.Errorf("insert session: %w", err)
}
return nil
}
// GetSession returns the (org,id) session or errSessionNotFound. The org is part
// of the key so one tenant can never resolve another's session id.
func (s *Store) GetSession(ctx context.Context, org, id string) (Session, error) {
row := s.db.QueryRowContext(ctx,
`SELECT `+sessionCols+` FROM agent_sessions WHERE org=? AND id=?`, org, id)
x, err := scanSession(row)
if errors.Is(err, sql.ErrNoRows) {
return Session{}, errSessionNotFound
}
if err != nil {
return Session{}, fmt.Errorf("get session: %w", err)
}
return x, nil
}
// SessionFilter selects a slice of an org's sessions. The fields are AND-ed; a
// zero field is "any". Scope picks the structural axis:
// - Root set -> every session in that tree (root_id == Root).
// - Parent set -> the direct children of Parent (parent_id == Parent).
// - neither -> roots only (parent_id == ”), the outer-agent view.
type SessionFilter struct {
Root string
Parent string
Status string
Limit int
}
// ListSessions returns an org's sessions per filter, newest first, capped.
func (s *Store) ListSessions(ctx context.Context, org string, f SessionFilter) ([]Session, error) {
limit := f.Limit
if limit <= 0 || limit > 500 {
limit = 100
}
where := "org=?"
args := []any{org}
switch {
case f.Root != "":
where += " AND root_id=?"
args = append(args, f.Root)
case f.Parent != "":
where += " AND parent_id=?"
args = append(args, f.Parent)
default:
where += " AND parent_id=''"
}
if f.Status != "" {
where += " AND status=?"
args = append(args, f.Status)
}
args = append(args, limit)
rows, err := s.db.QueryContext(ctx,
`SELECT `+sessionCols+` FROM agent_sessions WHERE `+where+
` ORDER BY created_at DESC, id ASC LIMIT ?`, args...)
if err != nil {
return nil, fmt.Errorf("list sessions: %w", err)
}
defer func() { _ = rows.Close() }()
var out []Session
for rows.Next() {
x, err := scanSession(rows)
if err != nil {
return nil, fmt.Errorf("scan session: %w", err)
}
out = append(out, x)
}
return out, rows.Err()
}
// ListTree returns EVERY session in one org's tree (root_id == root), oldest
// first so a caller can assemble parent→child in a single pass. Capped so a
// pathological tree can't produce an unbounded response.
func (s *Store) ListTree(ctx context.Context, org, root string, cap int) ([]Session, error) {
if cap <= 0 || cap > 10000 {
cap = 10000
}
rows, err := s.db.QueryContext(ctx,
`SELECT `+sessionCols+` FROM agent_sessions WHERE org=? AND root_id=?
ORDER BY created_at ASC, id ASC LIMIT ?`, org, root, cap)
if err != nil {
return nil, fmt.Errorf("list tree: %w", err)
}
defer func() { _ = rows.Close() }()
var out []Session
for rows.Next() {
x, err := scanSession(rows)
if err != nil {
return nil, fmt.Errorf("scan session: %w", err)
}
out = append(out, x)
}
return out, rows.Err()
}
// UpdateSession persists status/title/ended_at for an existing (org,id) session.
// Scoped by org so a cross-tenant id can never mutate another's session.
func (s *Store) UpdateSession(ctx context.Context, x Session) error {
res, err := s.db.ExecContext(ctx,
`UPDATE agent_sessions SET status=?, title=?, ended_at=?, updated_at=?
WHERE org=? AND id=?`,
x.Status, x.Title, x.EndedAt, x.UpdatedAt, x.Org, x.ID)
if err != nil {
return fmt.Errorf("update session: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return errSessionNotFound
}
return nil
}
// CountChildren returns how many DIRECT children a session has (its fan-out).
func (s *Store) CountChildren(ctx context.Context, org, id string) (int, error) {
var n int
err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM agent_sessions WHERE org=? AND parent_id=?`, org, id).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count children: %w", err)
}
return n, nil
}
// AppendEvent inserts one event, allocating the next per-session Seq. The store
// runs on a single connection (SetMaxOpenConns(1)) so the read-then-write of the
// max seq is serialised; the UNIQUE(session_id,seq) index is the final backstop.
// The session's updated_at is bumped in the SAME transaction so "last activity"
// stays truthful. Returns the persisted event (with Seq/CreatedAt) for streaming.
func (s *Store) AppendEvent(ctx context.Context, e Event) (Event, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return Event{}, fmt.Errorf("begin: %w", err)
}
defer func() { _ = tx.Rollback() }()
var next int64
if err := tx.QueryRowContext(ctx,
`SELECT COALESCE(MAX(seq),0)+1 FROM agent_session_events WHERE session_id=?`,
e.SessionID).Scan(&next); err != nil {
return Event{}, fmt.Errorf("next seq: %w", err)
}
e.Seq = next
if _, err := tx.ExecContext(ctx,
`INSERT INTO agent_session_events (id,session_id,org,seq,kind,actor,payload,created_at)
VALUES (?,?,?,?,?,?,?,?)`,
e.ID, e.SessionID, e.Org, e.Seq, e.Kind, e.Actor, e.Payload, e.CreatedAt); err != nil {
return Event{}, fmt.Errorf("insert event: %w", err)
}
if _, err := tx.ExecContext(ctx,
`UPDATE agent_sessions SET updated_at=? WHERE org=? AND id=?`,
e.CreatedAt, e.Org, e.SessionID); err != nil {
return Event{}, fmt.Errorf("bump session: %w", err)
}
if err := tx.Commit(); err != nil {
return Event{}, fmt.Errorf("commit: %w", err)
}
return e, nil
}
// ListEvents returns a session's events in Seq order (optionally only those with
// Seq > since, so a subscriber resumes exactly where it dropped), capped.
func (s *Store) ListEvents(ctx context.Context, org, sessionID string, since int64, limit int) ([]Event, error) {
if limit <= 0 || limit > 1000 {
limit = 200
}
rows, err := s.db.QueryContext(ctx,
`SELECT id,session_id,org,seq,kind,actor,payload,created_at
FROM agent_session_events WHERE org=? AND session_id=? AND seq>?
ORDER BY seq ASC LIMIT ?`, org, sessionID, since, limit)
if err != nil {
return nil, fmt.Errorf("list events: %w", err)
}
defer func() { _ = rows.Close() }()
var out []Event
for rows.Next() {
var e Event
if err := rows.Scan(&e.ID, &e.SessionID, &e.Org, &e.Seq, &e.Kind, &e.Actor,
&e.Payload, &e.CreatedAt); err != nil {
return nil, fmt.Errorf("scan event: %w", err)
}
out = append(out, e)
}
return out, rows.Err()
}
// EventCountsByRoot returns per-session event counts for EVERY session in one
// org's tree (root_id == root) in a SINGLE grouped query — so materialising a
// tree of N nodes with real per-node event counts costs one round trip, not N,
// and never hits SQLite's bound-parameter limit (the join scopes by root_id, not
// an IN list of ids).
func (s *Store) EventCountsByRoot(ctx context.Context, org, root string) (map[string]int, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT e.session_id, COUNT(*)
FROM agent_session_events e
JOIN agent_sessions s ON s.id = e.session_id AND s.org = e.org
WHERE e.org=? AND s.root_id=?
GROUP BY e.session_id`, org, root)
if err != nil {
return nil, fmt.Errorf("event counts by root: %w", err)
}
defer func() { _ = rows.Close() }()
out := map[string]int{}
for rows.Next() {
var id string
var n int
if err := rows.Scan(&id, &n); err != nil {
return nil, fmt.Errorf("scan count: %w", err)
}
out[id] = n
}
return out, rows.Err()
}
// CountEvents returns how many events a session has (the list rollup).
func (s *Store) CountEvents(ctx context.Context, org, sessionID string) (int, error) {
var n int
err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM agent_session_events WHERE org=? AND session_id=?`,
org, sessionID).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count events: %w", err)
}
return n, nil
}
+203
View File
@@ -0,0 +1,203 @@
package agents
import (
"bufio"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"github.com/zap-proto/zip"
)
// streamUpdate is one live update fanned out to subscribers: either a session
// lifecycle change (register / status / control) or an appended event. Org and
// RootID are carried so the bus can filter by tenant AND a subscriber can scope
// to a single subagent tree (?root=). Exactly one of Session/Event is set.
type streamUpdate struct {
Org string `json:"-"`
RootID string `json:"-"`
Type string `json:"-"` // "session" | "event"
Session *sessionView `json:"session,omitempty"`
Event *eventView `json:"event,omitempty"`
}
// bus is the in-process publish/subscribe fan-out under the sessions surface. It
// is the SINGLE seam the live stream hangs off:
//
// - Today: the SSE handler (GET /v1/agents/sessions/stream) subscribes and
// writes each update as an SSE frame. Because zip's SendStreamWriter streams
// THROUGH the ZAP machine transport natively (proven by zip stream_test
// TestListenZAP_Streams), that SSE endpoint IS the live ZAP stream — a ZAP
// subscriber gets frames as they flush, no per-handler transport code.
//
// - ZAP HOOK POINT: a future direct ZAP push subscription (e.g. a browser
// /zap duplex that grows server-push, or a tasks-events → session-events
// indexer) attaches by calling subscribe(org) and forwarding updates. The
// publisher side (publish) does not change.
//
// Delivery is best-effort and non-blocking: a slow subscriber never stalls a
// writer. On buffer overrun the subscriber is dropped (its channel closed) and
// the client reconnects + re-fetches truth from the GET endpoints. The GET tree/
// list/detail endpoints are the source of truth; the stream is a live hint.
type bus struct {
mu sync.Mutex
subs map[int]*subscriber
nextID int
closed bool
}
type subscriber struct {
org string // tenant filter — a subscriber only ever receives its own org
ch chan streamUpdate
}
const subBuffer = 256
func newBus() *bus { return &bus{subs: map[int]*subscriber{}} }
// subscribe registers a tenant-scoped subscriber and returns its channel plus a
// cancel func. cancel is idempotent. After close(), subscribe returns a closed
// channel so a late subscriber exits immediately.
func (b *bus) subscribe(org string) (<-chan streamUpdate, func()) {
b.mu.Lock()
defer b.mu.Unlock()
if b.closed {
ch := make(chan streamUpdate)
close(ch)
return ch, func() {}
}
id := b.nextID
b.nextID++
s := &subscriber{org: org, ch: make(chan streamUpdate, subBuffer)}
b.subs[id] = s
var once sync.Once
cancel := func() {
once.Do(func() {
b.mu.Lock()
defer b.mu.Unlock()
if cur, ok := b.subs[id]; ok && cur == s {
delete(b.subs, id)
close(s.ch)
}
})
}
return s.ch, cancel
}
// publish fans an update out to every subscriber of the update's org. Non-
// blocking: if a subscriber's buffer is full it is dropped (channel closed), so
// one stuck dashboard can never back-pressure a session write.
func (b *bus) publish(u streamUpdate) {
b.mu.Lock()
defer b.mu.Unlock()
if b.closed {
return
}
for id, s := range b.subs {
if s.org != u.Org {
continue
}
select {
case s.ch <- u:
default:
// Overrun: drop this laggard. It reconnects and re-syncs via GET.
delete(b.subs, id)
close(s.ch)
}
}
}
// close tears the bus down on Shutdown: every subscriber channel is closed so
// its SSE loop returns and the handler unblocks within the shutdown deadline.
func (b *bus) close() {
b.mu.Lock()
defer b.mu.Unlock()
if b.closed {
return
}
b.closed = true
for id, s := range b.subs {
delete(b.subs, id)
close(s.ch)
}
}
// sessionsStream is GET /v1/agents/sessions/stream — a Server-Sent Events feed of
// live session + event updates for the caller's org. Optional ?root=<id> scopes
// the feed to one subagent tree. Org-scoped (fail-closed): a subscriber only ever
// receives its own tenant's updates because the bus filters on org.
//
// This handler streams over BOTH the plain HTTP listener and the ZAP machine
// transport with no transport-specific code (zip SendStreamWriter is transport-
// agnostic). Everything the stream loop needs is captured BEFORE SendStreamWriter
// so the loop never touches the request Ctx after the handler returns (fasthttp
// recycles it) — client-gone is detected by a flush error, bounded by a 25s
// heartbeat.
func (s *svc) sessionsStream(c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
// CLONE the root filter: c.Query returns a zero-copy view into the fasthttp
// request buffer, and the stream loop below OUTLIVES this handler (it runs
// under SendStreamWriter after the Ctx is recycled). tenant() already clones
// org for exactly this reason; root is retained past the request the same way,
// so it must be an owned copy or the filter races a reused buffer.
root := strings.Clone(trimField(c.Query("root")))
c.SetHeader("Content-Type", "text/event-stream")
c.SetHeader("Cache-Control", "no-cache")
c.SetHeader("Connection", "keep-alive")
c.SetHeader("X-Accel-Buffering", "no") // defeat proxy buffering of the stream
ch, cancel := s.bus.subscribe(org)
return c.SendStreamWriter(func(w *bufio.Writer) {
defer cancel()
// Initial comment flushes headers so the client's EventSource opens.
if _, err := w.WriteString(": stream open\n\n"); err != nil {
return
}
if err := w.Flush(); err != nil {
return
}
hb := time.NewTicker(25 * time.Second)
defer hb.Stop()
for {
select {
case <-hb.C:
if _, err := w.WriteString(": ping\n\n"); err != nil {
return
}
if err := w.Flush(); err != nil {
return
}
case u, open := <-ch:
if !open {
return // bus closed this sub (overrun or Shutdown)
}
if root != "" && u.RootID != root {
continue
}
if !writeSSE(w, u.Type, u) {
return // client gone
}
}
}
})
}
// writeSSE writes one SSE frame (event: <type>\ndata: <json>\n\n) and flushes.
// Returns false on any write/flush error (client disconnected) so the caller
// stops the loop.
func writeSSE(w *bufio.Writer, event string, v any) bool {
b, err := json.Marshal(v)
if err != nil {
return true // skip a bad frame, keep the stream alive
}
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, b); err != nil {
return false
}
return w.Flush() == nil
}
+58
View File
@@ -0,0 +1,58 @@
package agents
import (
"context"
"errors"
)
// TaskController is the seam to the hanzoai/tasks durable-execution engine — the
// ONE canonical engine for durable/retriable/scheduled agent work. This sessions
// surface is the REGISTRY + control + ZAP-stream VIEW layer; it deliberately owns
// NO scheduler, ticker, or lease. When a session is backed by a tasks workflow
// (Session.TaskWorkflowID set), a control command forwards through this seam to
// the engine's signal/cancel API instead of only being recorded.
//
// The method set mirrors github.com/hanzoai/tasks/pkg/sdk/client.Client exactly,
// so the live adapter is a thin wrapper (Signal→Client.SignalWorkflow,
// Cancel→Client.CancelWorkflow) with no impedance mismatch:
//
// SignalWorkflow(ctx, workflowID, runID, signalName string, arg any) error
// CancelWorkflow(ctx, workflowID, runID string) error
//
// TASKS PLUG-IN POINT. The live controller is wired in Mount from a dialed tasks
// client (client.Dial(TASKS_URL)); until the hanzoai/tasks native engine lands
// (today its workflow opcodes return 501 by design — "the shape is in place so
// callers depend on the API while the engine lands behind it"), the default is
// the disabled controller: control is still durably RECORDED as a session event
// for stream-consuming surfaces, and the forward is a clean no-op.
type TaskController interface {
// Signal forwards a cooperative control signal (pause/resume/message) to the
// durable workflow backing a session. name is the signal name; payload is the
// opaque signal argument (e.g. a steer message), nil when there is none.
Signal(ctx context.Context, workflowID, runID, name string, payload []byte) error
// Cancel gracefully cancels the durable workflow backing a session (a stop).
Cancel(ctx context.Context, workflowID, runID, reason string) error
// Enabled reports whether a real tasks backend is wired. When false the
// control endpoints record the intent and skip the forward (honest degrade,
// same pattern as deps.AI).
Enabled() bool
}
// errTasksNotConfigured is returned by the disabled controller's Signal/Cancel.
// Control handlers never surface it as a failure — they check Enabled() first —
// but it exists so a mis-wired direct call fails closed with a clear message.
var errTasksNotConfigured = errors.New("agents: tasks durable-execution backend not configured")
// disabledTaskController is the fail-safe default: no engine wired. It records
// nothing and forwards nothing; the control endpoints persist the command as a
// session event regardless, which is what today's stream-consuming surfaces
// (the @hanzo/dev CLI outer agent) act on.
type disabledTaskController struct{}
func (disabledTaskController) Signal(context.Context, string, string, string, []byte) error {
return errTasksNotConfigured
}
func (disabledTaskController) Cancel(context.Context, string, string, string) error {
return errTasksNotConfigured
}
func (disabledTaskController) Enabled() bool { return false }
+560
View File
@@ -0,0 +1,560 @@
package agents
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"sync"
"testing"
"time"
"github.com/zap-proto/zip"
)
// ---- store-level: tree linking, seq, tenant isolation ----
func testSessionStore(t *testing.T) *Store {
t.Helper()
s, err := openStore(filepath.Join(t.TempDir(), "agents.db"))
if err != nil {
t.Fatalf("openStore: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
return s
}
func mkSession(org, id, parent, root string) Session {
now := time.Now().Unix()
return Session{
ID: id, Org: org, Agent: "dev", Actor: "u", Status: StatusRunning,
ParentID: parent, RootID: root, StartedAt: now, CreatedAt: now, UpdatedAt: now,
}
}
func TestSessionTreeLinkingStore(t *testing.T) {
s := testSessionStore(t)
ctx := context.Background()
// root -> child -> grandchild, all one org.
if err := s.CreateSession(ctx, mkSession("acme", "root", "", "root")); err != nil {
t.Fatalf("root: %v", err)
}
if err := s.CreateSession(ctx, mkSession("acme", "child", "root", "root")); err != nil {
t.Fatalf("child: %v", err)
}
if err := s.CreateSession(ctx, mkSession("acme", "gchild", "child", "root")); err != nil {
t.Fatalf("gchild: %v", err)
}
tree, err := s.ListTree(ctx, "acme", "root", 0)
if err != nil || len(tree) != 3 {
t.Fatalf("tree want 3 nodes, got %d (%v)", len(tree), err)
}
// A parent that does not exist in the org is refused (no dangling tree).
if err := s.CreateSession(ctx, Session{ID: "x", Org: "acme", ParentID: "nope", RootID: "nope", StartedAt: 1, CreatedAt: 1, UpdatedAt: 1}); err != errParentNotFound {
t.Fatalf("dangling parent want errParentNotFound, got %v", err)
}
// A parent in ANOTHER org is refused (tree can't cross tenants).
if err := s.CreateSession(ctx, mkSession("evil", "e", "root", "root")); err != errParentNotFound {
t.Fatalf("cross-tenant parent want errParentNotFound, got %v", err)
}
nc, _ := s.CountChildren(ctx, "acme", "root")
if nc != 1 {
t.Fatalf("root direct children want 1, got %d", nc)
}
}
func TestSessionEventSeqAndCounts(t *testing.T) {
s := testSessionStore(t)
ctx := context.Background()
_ = s.CreateSession(ctx, mkSession("acme", "root", "", "root"))
_ = s.CreateSession(ctx, mkSession("acme", "child", "root", "root"))
for i := 0; i < 3; i++ {
e, err := s.AppendEvent(ctx, Event{ID: genIDMust(t), SessionID: "root", Org: "acme", Kind: KindLog, CreatedAt: time.Now().Unix()})
if err != nil {
t.Fatalf("append: %v", err)
}
if e.Seq != int64(i+1) {
t.Fatalf("seq want %d, got %d", i+1, e.Seq)
}
}
_, _ = s.AppendEvent(ctx, Event{ID: genIDMust(t), SessionID: "child", Org: "acme", Kind: KindSpawn, CreatedAt: time.Now().Unix()})
counts, err := s.EventCountsByRoot(ctx, "acme", "root")
if err != nil {
t.Fatalf("counts: %v", err)
}
if counts["root"] != 3 || counts["child"] != 1 {
t.Fatalf("event counts want root=3 child=1, got %+v", counts)
}
// Cross-tenant read sees nothing.
other, _ := s.EventCountsByRoot(ctx, "evil", "root")
if len(other) != 0 {
t.Fatalf("cross-tenant counts must be empty, got %+v", other)
}
if n, _ := s.CountEvents(ctx, "evil", "root"); n != 0 {
t.Fatalf("cross-tenant event count want 0, got %d", n)
}
}
// TestSessionEventSeqConcurrent proves the store's per-session Seq is gap-free
// and duplicate-free under CONCURRENT appends — the exact race vector #4. The
// store runs on a single connection (SetMaxOpenConns(1)) so the MAX(seq)+1
// read-then-write is serialised; the UNIQUE(session_id,seq) index is the final
// backstop. N goroutines append to the SAME session in parallel; the returned
// seqs must be EXACTLY {1..N} (no gap = no lost write, no dupe = no double
// allocation), and the persisted count must equal N. Run under -race.
func TestSessionEventSeqConcurrent(t *testing.T) {
s := testSessionStore(t)
ctx := context.Background()
if err := s.CreateSession(ctx, mkSession("acme", "root", "", "root")); err != nil {
t.Fatalf("root: %v", err)
}
const n = 64
var wg sync.WaitGroup
seqs := make([]int64, n)
errs := make([]error, n)
wg.Add(n)
for i := 0; i < n; i++ {
go func(i int) {
defer wg.Done()
id, err := genID("evt")
if err != nil {
errs[i] = err
return
}
e, err := s.AppendEvent(ctx, Event{
ID: id, SessionID: "root", Org: "acme", Kind: KindLog,
CreatedAt: time.Now().Unix(),
})
if err != nil {
errs[i] = err
return
}
seqs[i] = e.Seq
}(i)
}
wg.Wait()
seen := map[int64]bool{}
for i := 0; i < n; i++ {
if errs[i] != nil {
t.Fatalf("append %d: %v", i, errs[i])
}
if seen[seqs[i]] {
t.Fatalf("duplicate seq %d — MAX+1 allocation raced", seqs[i])
}
seen[seqs[i]] = true
}
for want := int64(1); want <= n; want++ {
if !seen[want] {
t.Fatalf("gap: seq %d missing — a concurrent append was lost", want)
}
}
if got, _ := s.CountEvents(ctx, "acme", "root"); got != n {
t.Fatalf("persisted event count want %d, got %d", n, got)
}
}
func genIDMust(t *testing.T) string {
t.Helper()
id, err := genID("evt")
if err != nil {
t.Fatalf("genID: %v", err)
}
return id
}
// ---- HTTP: helpers ----
// doNoUser sends X-Org-Id WITHOUT X-User-Id — the anonymous-forge path the
// principal gate must refuse (no validated principal).
func doNoUser(t *testing.T, app *zip.App, method, path, org string, body any) (int, []byte) {
t.Helper()
var r io.Reader
if body != nil {
b, _ := json.Marshal(body)
r = bytes.NewReader(b)
}
req := httptest.NewRequest(method, path, r)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if org != "" {
req.Header.Set("X-Org-Id", org)
}
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
}
func mustJSON(t *testing.T, b []byte, v any) {
t.Helper()
if err := json.Unmarshal(b, v); err != nil {
t.Fatalf("unmarshal %s: %v", b, err)
}
}
// register is a helper that POSTs a session and returns its view.
func register(t *testing.T, app *zip.App, org string, body map[string]any) sessionView {
t.Helper()
code, b := do(t, app, http.MethodPost, "/v1/agents/sessions", org, body)
if code != http.StatusCreated {
t.Fatalf("register want 201, got %d (%s)", code, b)
}
var v sessionView
mustJSON(t, b, &v)
return v
}
// ---- HTTP: tree, org-scope, precedence ----
func TestSessionsHTTPTreeAndScope(t *testing.T) {
app := mountApp(t, &fakeAI{content: "x"})
// Route precedence: /v1/agents/sessions is NOT captured by /v1/agents/:name.
code, b := do(t, app, http.MethodGet, "/v1/agents/sessions", "acme", nil)
if code != http.StatusOK {
t.Fatalf("list sessions want 200 (not shadowed by :name), got %d (%s)", code, b)
}
var empty struct {
Sessions []sessionView `json:"sessions"`
}
mustJSON(t, b, &empty)
if len(empty.Sessions) != 0 {
t.Fatalf("fresh org want 0 sessions, got %d", len(empty.Sessions))
}
// Build a tree: root (outer @hanzo/dev run) -> two subagents -> one grandchild.
root := register(t, app, "acme", map[string]any{"agent": "hanzo-dev", "title": "outer run"})
if root.RootSessionID != root.ID || root.ParentSessionID != "" {
t.Fatalf("root must self-root with no parent, got %+v", root)
}
childA := register(t, app, "acme", map[string]any{"agent": "planner", "parentSessionId": root.ID})
childB := register(t, app, "acme", map[string]any{"agent": "coder", "parentSessionId": root.ID})
gchild := register(t, app, "acme", map[string]any{"agent": "tester", "parentSessionId": childA.ID})
for _, c := range []sessionView{childA, childB, gchild} {
if c.RootSessionID != root.ID {
t.Fatalf("subagent %s must inherit rootSessionId %s, got %s", c.Agent, root.ID, c.RootSessionID)
}
}
if gchild.ParentSessionID != childA.ID {
t.Fatalf("grandchild parent want %s, got %s", childA.ID, gchild.ParentSessionID)
}
// Default list = ROOTS only (the outer-agent view).
code, b = do(t, app, http.MethodGet, "/v1/agents/sessions", "acme", nil)
mustJSON(t, b, &empty)
if code != http.StatusOK || len(empty.Sessions) != 1 || empty.Sessions[0].ID != root.ID {
t.Fatalf("default list want [root], got %d %+v", code, empty.Sessions)
}
if empty.Sessions[0].Children != 2 {
t.Fatalf("root fan-out want 2, got %d", empty.Sessions[0].Children)
}
// The tree endpoint returns the full subagent-flow graph.
code, b = do(t, app, http.MethodGet, "/v1/agents/sessions/"+root.ID+"/tree", "acme", nil)
if code != http.StatusOK {
t.Fatalf("tree want 200, got %d (%s)", code, b)
}
var tree treeNode
mustJSON(t, b, &tree)
if tree.Session.ID != root.ID || len(tree.Children) != 2 {
t.Fatalf("tree root want 2 children, got %+v", tree)
}
// Find childA subtree and confirm the grandchild hangs off it.
var found bool
for _, ch := range tree.Children {
if ch.Session.ID == childA.ID {
if len(ch.Children) != 1 || ch.Children[0].Session.ID != gchild.ID {
t.Fatalf("childA must have grandchild, got %+v", ch)
}
found = true
}
}
if !found {
t.Fatalf("childA not found in tree")
}
// Cross-tenant: evil cannot see, read, tree, control, or parent-under acme's root.
code, b = do(t, app, http.MethodGet, "/v1/agents/sessions", "evil", nil)
mustJSON(t, b, &empty)
if len(empty.Sessions) != 0 {
t.Fatalf("evil must see 0 sessions, got %d", len(empty.Sessions))
}
if code, _ := do(t, app, http.MethodGet, "/v1/agents/sessions/"+root.ID, "evil", nil); code != http.StatusNotFound {
t.Fatalf("evil get acme session want 404, got %d", code)
}
if code, _ := do(t, app, http.MethodGet, "/v1/agents/sessions/"+root.ID+"/tree", "evil", nil); code != http.StatusNotFound {
t.Fatalf("evil tree acme session want 404, got %d", code)
}
if code, _ := do(t, app, http.MethodPost, "/v1/agents/sessions/"+root.ID+"/stop", "evil", nil); code != http.StatusNotFound {
t.Fatalf("evil control acme session want 404, got %d", code)
}
if code, _ := do(t, app, http.MethodPost, "/v1/agents/sessions/"+root.ID+"/events", "evil",
map[string]any{"kind": "log"}); code != http.StatusNotFound {
t.Fatalf("evil append to acme session want 404, got %d", code)
}
if code, _ := do(t, app, http.MethodPost, "/v1/agents/sessions", "evil",
map[string]any{"agent": "x", "parentSessionId": root.ID}); code != http.StatusBadRequest {
t.Fatalf("evil parent-under acme root want 400, got %d", code)
}
}
// ---- HTTP: events + status ----
func TestSessionsHTTPEventsAndStatus(t *testing.T) {
app := mountApp(t, &fakeAI{content: "x"})
root := register(t, app, "acme", map[string]any{"agent": "dev"})
// Append events: message, tool-call, spawn — seq is monotonic.
for i, k := range []string{KindMessage, KindToolCall, KindSpawn} {
code, b := do(t, app, http.MethodPost, "/v1/agents/sessions/"+root.ID+"/events", "acme",
map[string]any{"kind": k, "payload": map[string]any{"n": i}})
if code != http.StatusCreated {
t.Fatalf("append %s want 201, got %d (%s)", k, code, b)
}
var ev eventView
mustJSON(t, b, &ev)
if ev.Seq != int64(i+1) || ev.Kind != k {
t.Fatalf("event %s seq want %d, got %+v", k, i+1, ev)
}
}
// Bad kind + bad payload are rejected.
if code, _ := do(t, app, http.MethodPost, "/v1/agents/sessions/"+root.ID+"/events", "acme",
map[string]any{"kind": "bogus"}); code != http.StatusBadRequest {
t.Fatalf("bad kind want 400, got %d", code)
}
// Detail shows recent events + event count.
code, b := do(t, app, http.MethodGet, "/v1/agents/sessions/"+root.ID, "acme", nil)
if code != http.StatusOK {
t.Fatalf("detail want 200, got %d (%s)", code, b)
}
var det sessionDetail
mustJSON(t, b, &det)
if det.Events != 3 || len(det.RecentEvents) != 3 {
t.Fatalf("detail want 3 events, got %d / %d", det.Events, len(det.RecentEvents))
}
// PATCH running -> done sets endedAt; then terminal is monotonic.
code, b = do(t, app, http.MethodPatch, "/v1/agents/sessions/"+root.ID, "acme",
map[string]any{"status": StatusDone})
if code != http.StatusOK {
t.Fatalf("patch done want 200, got %d (%s)", code, b)
}
var done sessionView
mustJSON(t, b, &done)
if done.Status != StatusDone || done.EndedAt == "" {
t.Fatalf("done must set endedAt, got %+v", done)
}
if code, _ := do(t, app, http.MethodPatch, "/v1/agents/sessions/"+root.ID, "acme",
map[string]any{"status": StatusRunning}); code != http.StatusConflict {
t.Fatalf("reopen finished session want 409, got %d", code)
}
}
// ---- HTTP: control authz + tasks forward ----
// fakeTasks is an enabled TaskController capturing the last forwarded op.
type fakeTasks struct {
mu sync.Mutex
signals []string
cancels int
lastWF string
failNext bool
}
func (f *fakeTasks) Signal(_ context.Context, wf, _ string, name string, _ []byte) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.failNext {
f.failNext = false
return context.DeadlineExceeded
}
f.signals = append(f.signals, name)
f.lastWF = wf
return nil
}
func (f *fakeTasks) Cancel(_ context.Context, wf, _, _ string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.cancels++
f.lastWF = wf
return nil
}
func (f *fakeTasks) Enabled() bool { return true }
func TestSessionsControlAuthzAndForward(t *testing.T) {
app := mountApp(t, &fakeAI{content: "x"})
ft := &fakeTasks{}
mounted.tasks = ft // inject an enabled durable-execution backend for this test
// A task-backed session forwards control to the tasks engine.
backed := register(t, app, "acme", map[string]any{
"agent": "dev", "taskWorkflowId": "wf-123", "taskRunId": "run-1",
})
// pause -> Signal("pause")
code, b := do(t, app, http.MethodPost, "/v1/agents/sessions/"+backed.ID+"/pause", "acme", nil)
if code != http.StatusOK {
t.Fatalf("pause want 200, got %d (%s)", code, b)
}
var res struct {
Command string `json:"command"`
Event eventView `json:"event"`
Forwarded bool `json:"forwarded"`
}
mustJSON(t, b, &res)
if !res.Forwarded || res.Command != CmdPause || res.Event.Kind != KindControl {
t.Fatalf("pause must forward + record a control event, got %+v", res)
}
// message (steer) -> Signal("message")
do(t, app, http.MethodPost, "/v1/agents/sessions/"+backed.ID+"/message", "acme",
map[string]any{"message": "focus on the bug"})
// stop -> Cancel
code, _ = do(t, app, http.MethodPost, "/v1/agents/sessions/"+backed.ID+"/stop", "acme", nil)
if code != http.StatusOK {
t.Fatalf("stop want 200, got %d", code)
}
ft.mu.Lock()
gotSignals, gotCancels, gotWF := append([]string{}, ft.signals...), ft.cancels, ft.lastWF
ft.mu.Unlock()
if len(gotSignals) != 2 || gotSignals[0] != CmdPause || gotSignals[1] != CmdMessage {
t.Fatalf("want signals [pause,message], got %v", gotSignals)
}
if gotCancels != 1 || gotWF != "wf-123" {
t.Fatalf("want 1 cancel on wf-123, got cancels=%d wf=%s", gotCancels, gotWF)
}
// Control is recorded as an event even on a NON-task-backed session
// (forwarded=false) — stream-consuming surfaces act on it.
plain := register(t, app, "acme", map[string]any{"agent": "dev"})
code, b = do(t, app, http.MethodPost, "/v1/agents/sessions/"+plain.ID+"/pause", "acme", nil)
mustJSON(t, b, &res)
if code != http.StatusOK || res.Forwarded {
t.Fatalf("plain pause want 200 forwarded=false, got %d %+v", code, res)
}
// The control command landed in the event log.
_, b = do(t, app, http.MethodGet, "/v1/agents/sessions/"+plain.ID, "acme", nil)
var det sessionDetail
mustJSON(t, b, &det)
if det.Events != 1 || det.RecentEvents[0].Kind != KindControl {
t.Fatalf("control must be recorded as an event, got %+v", det.RecentEvents)
}
// A forward FAILURE is a 502 but the intent is still recorded.
ft.failNext = true
code, _ = do(t, app, http.MethodPost, "/v1/agents/sessions/"+backed.ID+"/resume", "acme", nil)
// backed was stopped above (running still — stop only records/cancels, status
// is surface-owned), so resume is allowed; the forward fails -> 502.
if code != http.StatusBadGateway {
t.Fatalf("forward failure want 502, got %d", code)
}
// AuthZ: X-Org-Id without a validated principal (no X-User-Id) is refused.
if code, _ := doNoUser(t, app, http.MethodPost, "/v1/agents/sessions/"+backed.ID+"/pause", "acme", nil); code != http.StatusForbidden {
t.Fatalf("control without validated principal want 403, got %d", code)
}
if code, _ := doNoUser(t, app, http.MethodPost, "/v1/agents/sessions", "acme",
map[string]any{"agent": "x"}); code != http.StatusForbidden {
t.Fatalf("register without validated principal want 403, got %d", code)
}
// Control on a finished session is refused (409).
fin := register(t, app, "acme", map[string]any{"agent": "dev", "status": StatusDone})
if code, _ := do(t, app, http.MethodPost, "/v1/agents/sessions/"+fin.ID+"/pause", "acme", nil); code != http.StatusConflict {
t.Fatalf("control a finished session want 409, got %d", code)
}
}
// ---- run integration (#5): a run opens a root session ----
func TestRunOpensRootSession(t *testing.T) {
app := mountApp(t, &fakeAI{content: "the answer"})
do(t, app, http.MethodPost, "/v1/agents", "acme",
map[string]any{"name": "helper", "model": "m", "instructions": "x"})
if code, _ := do(t, app, http.MethodPost, "/v1/agents/helper/run", "acme", map[string]any{"input": "hi"}); code != http.StatusOK {
t.Fatalf("run want 200")
}
// The run is now visible as a root session with a log event.
_, b := do(t, app, http.MethodGet, "/v1/agents/sessions", "acme", nil)
var lst struct {
Sessions []sessionView `json:"sessions"`
}
mustJSON(t, b, &lst)
if len(lst.Sessions) != 1 {
t.Fatalf("run should open 1 root session, got %d", len(lst.Sessions))
}
s := lst.Sessions[0]
if s.Agent != "helper" || s.Status != StatusDone || s.Events != 1 {
t.Fatalf("run session shape wrong: %+v", s)
}
}
// ---- bus (the ZAP stream seam) ----
func TestBusFanoutOrgFilterAndOverrun(t *testing.T) {
b := newBus()
chA, cancelA := b.subscribe("acme")
chB, _ := b.subscribe("evil")
defer cancelA()
b.publish(streamUpdate{Org: "acme", RootID: "r", Type: "session"})
select {
case u := <-chA:
if u.Org != "acme" {
t.Fatalf("acme sub got wrong org %s", u.Org)
}
case <-time.After(time.Second):
t.Fatal("acme sub got no update")
}
// evil must NOT receive acme's update (org filter).
select {
case <-chB:
t.Fatal("evil sub must not receive acme update")
default:
}
// Overrun: fill acme's buffer past capacity — the laggard is dropped (closed).
for i := 0; i < subBuffer+10; i++ {
b.publish(streamUpdate{Org: "acme", RootID: "r", Type: "event"})
}
// Drain until closed.
dropped := false
for i := 0; i < subBuffer+20; i++ {
if _, open := <-chA; !open {
dropped = true
break
}
}
if !dropped {
t.Fatal("overrun laggard must be dropped (channel closed)")
}
// close() unblocks remaining subscribers.
b.close()
if _, open := <-chB; open {
t.Fatal("close() must close evil sub channel")
}
}
// TestPublishReachesSubscriber proves a live registration fans out to a bus
// subscriber — the exact path the SSE/ZAP stream handler consumes.
func TestPublishReachesSubscriber(t *testing.T) {
app := mountApp(t, &fakeAI{content: "x"})
ch, cancel := mounted.bus.subscribe("acme")
defer cancel()
root := register(t, app, "acme", map[string]any{"agent": "dev"})
select {
case u := <-ch:
if u.Type != "session" || u.Session == nil || u.Session.ID != root.ID {
t.Fatalf("subscriber should receive the registered session, got %+v", u)
}
case <-time.After(2 * time.Second):
t.Fatal("subscriber received no update for a live registration")
}
}

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