Compare commits

...
Author SHA1 Message Date
zeekayandClaude Fable 5 3baee40745 feat(team): Chunter agent responder — org agents become talkable in chat
Bots-as-members (bots.go/roster reconcile) already projects each org agent as a
workspace Employee, but a message to a bot did nothing — the AI was present and
mute. This adds the WRITE/response half: when a human posts a Chunter ChatMessage
addressed to an active bot member — a DirectMessage whose participants include the
bot, or a channel message that @-mentions it — the transactor runs that agent
through agents.RunOnBehalf (the ONE billed/metered/recorded in-process run path)
and posts the model's answer back into the SAME conversation as that bot, via the
SAME applyTx + hub.broadcast write the SPA and roster projection use.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Carries three fixes uncovered while landing it:
- follow commerce's resolver consolidation (middleware/svcorg -> pkg/org)
- point the go-unit test list at clients/flags; the stale clients/featureflags
  path failed setup on a missing directory and had CI/CD red on main
- assert the post-#331 flags contract: runtime flags ignore env, boot-time
  ReadOnly rows still read it. That test asserted the override #331 removed
  and never ran because of the stale path above.
2026-07-19 02:47:02 -07:00
78e0d35199 analytics(ingest): PostHog-wire uuid->idempotent MessageID + utm_* attribution mapping (#338)
Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-19 00:05:43 -07:00
hanzo-dev e80a6fc641 chore(cloud): vendor hanzoai/ai v1.826.6 — DO model catalog + mean-field + judge-panel
Brings the full run into the deployed service: 55-model DO GenAI catalog (Claude
opus-4.8/sonnet-5/fable-5/haiku, GPT-5.6/5.5/4o/o3, deepseek-v4-pro, llama-4, qwen,
glm, kimi — capabilities declared per live probe), the mean-field congestion router
(gated), the live /v1/router/judge-panel endpoint, the Mean-Field Judge Panel, and
geo-aware consent. Prod model ConfigMap (universe) syncs the catalog data separately.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:49:05 -07:00
zeekayandClaude Opus 4.8 bd7bce6b39 feat(cli,platform): unify PaaS auth on IAM — one login authorizes build/deploy/apps
A plain `hanzo login` (IAM) now authorizes every PaaS control-plane op with no
separate --build-token / --platform-token. ONE identity, org+role scoped.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 08:57:15 -07:00
hanzo-dev 8eabff4d44 fix(kms): unshadow the bare secrets-list route (/secrets/+ not /secrets/*)
The value routes registered the optional-greedy wildcard `/secrets/*`, which
fiber also matches with an empty tail — so the bare `GET .../secrets` list path
was answered by getSecret (400 "secret name is required") and listSecrets was
unreachable. Switch the getSecret/deleteSecret value routes to the required-
greedy `+` (one-or-more), so `/secrets` falls through to the exact list route
while `/secrets/<path>/<name>` still reads/deletes. reqWildcard reads the `+`
param.

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

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

Serves POST /v1/integrations/cloudflare/{connect,verify,disconnect} and
GET /v1/integrations.
2026-07-18 00:07:53 -07:00
z 9560169625 Merge feat/route-work-to-target: route coding run to chosen target machine 2026-07-17 23:32:54 -07:00
z 4ee67c1797 Merge feat/kms-reseal-migration: CR-driven KMS re-seal migration tool (#79) 2026-07-17 23:10:37 -07:00
2bb35ac291 auth: accept admin-console audience in the cloud JWT allowlist (#332)
The cloud already trusts hanzo-admin-guard (the admin surface) but not admin-console
(the admin console's own OIDC client), so a SuperAdmin token minted via admin-console
was rejected on /v1/admin with 'invalid audience' — forcing an awkward hanzo-admin-guard
detour. Add admin-console so the admin console's tokens work directly, matching
GATEWAY_ALLOWED_AUDIENCES which already lists it.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

minio-go leaves the direct requires and stays indirect (luxfi/zapdb via
clients/kms). Run go mod tidy after the s3-go v1.0.0 tag is published to
populate go.sum.
2026-07-17 21:24:02 -07:00
antje f7ded021ec gpu worker: a claimed job survives the engine recycle window
The supervisor recycles at queue-idle, but a freshly CLAIMED job is invisible
to the engine queue until its graph is submitted — so recycles fired over the
claim-to-submit window and staging failed on a dead engine, consuming the job
(observed twice in prod, seconds apart). Two invariants close it: a staging
latch the supervisor honors before recycling, and waitEngine() so a job
claimed while a recycle is already mid-flight waits out the restart instead
of dying on connection-refused.
2026-07-17 20:28:12 -07:00
zeekay dbc4966aeb build(iam2): bump v0.15.4 → v0.16.0 (argon2id SOTA password hashing)
Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 17:35:27 -07:00
z d38984f03f refactor(usage): unify account-usage onto the ONE /v1/usage surface
The account-usage plane (7456318) wrongly opened a SECOND usage surface inside
clients/link (/v1/links/usage). Move it into clients/usage so usage owns ALL
usage and link owns links and nothing usage — one surface, orthogonal, one window.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The 10m local history poll undercut the 4h startToCloseTimeout the dispatcher
grants — live renders (observed 8-70m) were marked failed while still sampling;
only the mirror later delivered them. renderWindow now matches the cap.

The engine leaks ~58GB per render. The handler signals a recycle after each
COMPLETED render (never on timeout — the engine may still be sampling and the
mirror rescues late finishes); the supervisor restarts on the signal.

* mirror: skip hidden files — AppleDouble forks pass the extension check

._foo.png is a mac resource fork, not a render; 700+ of them poisoned a
library within an hour of the mirror going live.

* deps: luxfi/consensus v1.36.2 -> v1.36.3 — the v1.36.2 tag was re-pushed

Cold builds fail sumdb verification against the moved tag (downloaded
eKzasq4O... vs sealed IbeWQF1w...). v1.36.3 is the immutable successor;
never re-tag a published version.
2026-07-17 01:26:40 -07:00
hanzo-dev d5e12b3df1 feat(ai): bump ai v1.818.0 → v1.820.0 — router live-by-default + record-all + self-export/delete
Ships to prod: router.enabled=true (model=auto routes for every org by default),
per-request RoutingEvent recording for auto AND explicit models (up/down feedback
works on all models), per-org + global fit-gate-deploy-publish training, and the
self-scoped routing-data export/delete (data ownership). Pairs with the universe
CR ROUTER_ENDPOINT removal (heuristic 300ns is the live path).

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-17 01:18:50 -07:00
226 changed files with 22858 additions and 2801 deletions
+22
View File
@@ -111,6 +111,28 @@ jobs:
echo "GOPROXY=https://proxy.golang.org,direct"
} >> "$GITHUB_ENV"
- name: zen streaming-fix floor — go.mod must pin github.com/hanzoai/zen >= v1.4.1
# The SSE body-close fix (zen commit 50328b8, first released in zen v1.4.1)
# is what makes streaming completions return a body instead of an empty
# stream. A stale-branch merge that reverts go.mod's zen pin below the floor
# silently re-breaks streaming, and `next build`'s ignoreBuildErrors hides
# the runtime break — so no image may be cut on a regressed pin. This is the
# durable root-cause guard: it reads the EFFECTIVE module version (post-MVS,
# exactly what the build links) and fails the PR/push below the floor.
run: |
set -euo pipefail
FLOOR="v1.4.1"
V="$(go list -m -f '{{.Version}}' github.com/hanzoai/zen)"
echo "effective github.com/hanzoai/zen = ${V} (floor ${FLOOR})"
# semver-correct compare: the lowest of {V, FLOOR} under `sort -V` must be
# the FLOOR, i.e. V >= FLOOR. (sort -V orders v1.4.2 above v1.4.10 too.)
low="$(printf '%s\n%s\n' "$V" "$FLOOR" | sort -V | head -1)"
if [ "$low" != "$FLOOR" ]; then
echo "::error::github.com/hanzoai/zen is pinned at ${V}, below the streaming-fix floor ${FLOOR} — this re-breaks SSE streaming (empty completions). Re-pin zen to >= ${FLOOR} in go.mod before merging."
exit 1
fi
echo "OK: zen ${V} is at or above the streaming-fix floor ${FLOOR}"
- name: positive proof — clients/controlplane is unreachable from the default build
run: |
set -euo pipefail
+161 -37
View File
@@ -124,20 +124,6 @@ jobs:
echo "sha_short=$(git rev-parse --short "$GITHUB_SHA")" >> "$GITHUB_OUTPUT"
echo "Next release: v${version} (git_max='${git_max:-none}' container_max='${cont_max:-none}')"
# Console-embed cachebust. The console clone+build layer is keyed on this;
# prefer hanzoai/console main HEAD so a CONSOLE-ONLY change re-embeds without
# needing a cloud commit (cloud-sha alone froze the embed between cloud pushes).
# git ls-remote must CLEAR the extraheader actions/checkout installs (it carries
# THIS repo's GITHUB_TOKEN, which 404s the cross-repo console lookup); gh is not
# on the runner. If resolution yields nothing, fall back to the cloud sha — still
# unique per cloud commit, so the embed is never frozen. Either way THIS build
# busts (new value) and re-clones console main fresh.
console_head="$(git -c 'http.https://github.com/.extraheader=' ls-remote \
"https://x-access-token:${GH_PAT}@github.com/hanzoai/console.git" refs/heads/main 2>/dev/null | cut -f1 || true)"
cachebust="${console_head:-$GITHUB_SHA}"
echo "cachebust=${cachebust}" >> "$GITHUB_OUTPUT"
echo "console cachebust: ${cachebust} (console_head='${console_head:-none}')"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
@@ -160,8 +146,12 @@ jobs:
# Direct credential first (repo/org secret — works on private repos,
# where the Free plan hides org KMS secrets); KMS kubeconfig fallback.
if [ -n "${REGISTRY_USER:-}" ] && [ -n "${REGISTRY_PASSWORD:-}" ]; then
echo "$REGISTRY_PASSWORD" | docker login registry.hanzo.ai -u "$REGISTRY_USER" --password-stdin
echo "MIRROR_OK=1" >> "$GITHUB_ENV"; exit 0
if echo "$REGISTRY_PASSWORD" | docker login registry.hanzo.ai -u "$REGISTRY_USER" --password-stdin; then
echo "MIRROR_OK=1" >> "$GITHUB_ENV"
else
echo "mirror login failed (registry.hanzo.ai unreachable) — mirror skipped, release continues"
fi
exit 0
fi
[ -z "${KMS_CLIENT_ID:-}" ] && { echo "no KMS creds — mirror skipped"; exit 0; }
TOKEN=$(curl -sf "$KMS_ENDPOINT/v1/kms/auth/login" -H 'Content-Type: application/json' -d "{\"clientId\":\"$KMS_CLIENT_ID\",\"clientSecret\":\"$KMS_CLIENT_SECRET\"}" | jq -r '.accessToken // empty')
@@ -180,8 +170,11 @@ jobs:
UP=$(echo "$CFG" | jq -r '.auths["registry.hanzo.ai"].auth // empty' | base64 -d)
[ -z "$UP" ] && { echo "no registry auth — mirror skipped"; exit 0; }
echo "::add-mask::${UP#*:}"
echo "${UP#*:}" | docker login registry.hanzo.ai -u "${UP%%:*}" --password-stdin
echo "MIRROR_OK=1" >> "$GITHUB_ENV"
if echo "${UP#*:}" | docker login registry.hanzo.ai -u "${UP%%:*}" --password-stdin; then
echo "MIRROR_OK=1" >> "$GITHUB_ENV"
else
echo "mirror login failed (registry.hanzo.ai unreachable) — mirror skipped, release continues"
fi
- name: Log in to ghcr.io (GH_PAT — writes the cloud package despite its ai-repo linkage)
uses: docker/login-action@v3
@@ -190,6 +183,39 @@ jobs:
username: hanzo-dev
password: ${{ secrets.GH_PAT }}
- name: Resolve decomplection artifact digests (the Go-only build's prebuilt inputs)
id: artifacts
run: |
set -euo pipefail
# cloud compiles ONLY Go; it pulls three prebuilt artifacts (console SPA,
# agent-skills catalog, native flags staticlib). Resolve each published
# :latest to an IMMUTABLE digest so THIS release is reproducible (pinned,
# not floating :latest) AND a console/skills/flags change is picked up —
# its CI republished :latest, so this resolves to the NEW digest. A MISSING
# artifact FAILS the release HERE, before build/smoke/push/tag: the receipt
# invariant means we never tag an image that couldn't embed the real console.
command -v crane >/dev/null 2>&1 || {
mkdir -p "$HOME/.local/bin"
curl -fsSL "https://github.com/google/go-containerregistry/releases/latest/download/go-containerregistry_Linux_x86_64.tar.gz" \
| tar -xz -C "$HOME/.local/bin" crane
}
export PATH="$HOME/.local/bin:$PATH"
resolve() {
local repo="$1" d
d="$(crane digest "ghcr.io/hanzoai/${repo}:latest" 2>/dev/null || true)"
[ -n "$d" ] || { echo "::error::decomplection artifact ghcr.io/hanzoai/${repo}:latest is not published — refusing to cut a release that would embed a stale/placeholder ${repo}"; return 1; }
printf 'ghcr.io/hanzoai/%s@%s' "$repo" "$d"
}
CONSOLE_IMAGE="$(resolve console-embed)" || exit 1
SKILLS_IMAGE="$(resolve agent-skills)" || exit 1
FLAGS_IMAGE="$(resolve cloud-flags)" || exit 1
{
echo "console_image=${CONSOLE_IMAGE}"
echo "skills_image=${SKILLS_IMAGE}"
echo "flags_image=${FLAGS_IMAGE}"
} >> "$GITHUB_OUTPUT"
echo "resolved: console=${CONSOLE_IMAGE} skills=${SKILLS_IMAGE} flags=${FLAGS_IMAGE}"
- name: OCI labels
id: meta
uses: docker/metadata-action@v5
@@ -216,11 +242,13 @@ jobs:
load: true
tags: cloud:smoke
labels: ${{ steps.meta.outputs.labels }}
# Bust the console clone+build layer every release (the cloud commit sha is
# unique per push) so the embed re-fetches console main HEAD fresh — never the
# frozen snapshot the persistent BuildKit cache would otherwise serve forever.
# cloud compiles ONLY Go: pull the three prebuilt artifacts pinned to the
# digests resolved above (reproducible, and fresh — a console/skills/flags
# change is a new digest). No node/python/rust toolchain in this build.
build-args: |
CONSOLE_CACHEBUST=${{ steps.ver.outputs.cachebust }}
CONSOLE_IMAGE=${{ steps.artifacts.outputs.console_image }}
SKILLS_IMAGE=${{ steps.artifacts.outputs.skills_image }}
FLAGS_IMAGE=${{ steps.artifacts.outputs.flags_image }}
# GIT_AUTH_TOKEN: BuildKit secret the Dockerfile consumes to fetch private
# cross-org Go modules (hanzoai/*, luxfi/*) over authenticated git.
secrets: |
@@ -462,10 +490,12 @@ jobs:
ghcr.io/hanzoai/cloud:sha-${{ steps.ver.outputs.sha_short }}
ghcr.io/hanzoai/cloud:latest
labels: ${{ steps.meta.outputs.labels }}
# SAME cachebust as the smoke build → every layer is a cache hit from step 1
# and the pushed image is byte-identical to the one the smoke test proved.
# SAME artifact digests as the smoke build → every layer is a cache hit from
# step 1 and the pushed image is byte-identical to the one smoke proved.
build-args: |
CONSOLE_CACHEBUST=${{ steps.ver.outputs.cachebust }}
CONSOLE_IMAGE=${{ steps.artifacts.outputs.console_image }}
SKILLS_IMAGE=${{ steps.artifacts.outputs.skills_image }}
FLAGS_IMAGE=${{ steps.artifacts.outputs.flags_image }}
secrets: |
GIT_AUTH_TOKEN=${{ secrets.GH_PAT }}
@@ -499,12 +529,25 @@ jobs:
# newest-first and version tags are monotonic, so the highest version
# is always among the most-recent versions; paginating the WHOLE
# registry history is what livelocked this step as tags accumulated.
# Fail-CLOSED. An ORPHANED container tag — image pushed by a run that
# died or was cancelled after imagetools-create but before its git tag —
# MUST raise the floor, or a later run reassigns that same number to a
# different image (an ambiguous mutable prod tag; the v1.801.50 flip). A
# git-only floor can't see the orphan, so if the container-tag lookup
# ERRORS (vs legitimately returning no tags) we retry the whole attempt
# rather than silently proceeding — a version with a pushed image is never
# reused. (Reordering git-tag before imagetools-create is the WRONG fix: it
# reintroduces the phantom "tag ⇔ no image" this workflow exists to prevent.)
cont_max=""
if command -v gh >/dev/null 2>&1; then
cont_max="$(GH_TOKEN="$GH_PAT" gh api \
'/orgs/hanzoai/packages/container/cloud/versions?per_page=100' \
--jq '.[].metadata.container.tags[]?' 2>/dev/null \
| sed 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true)"
if cont_raw="$(GH_TOKEN="$GH_PAT" gh api \
'/orgs/hanzoai/packages/container/cloud/versions?per_page=100' \
--jq '.[].metadata.container.tags[]?' 2>/dev/null)"; then
cont_max="$(printf '%s\n' "$cont_raw" \
| sed 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true)"
else
echo " container-tag lookup failed — retry so an orphaned tag can't be reused (attempt $attempt)"; sleep 3; continue
fi
fi
max="$(printf '%s\n%s\n%s\n' "1.786.0" "$git_max" "$cont_max" \
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)"
@@ -534,8 +577,12 @@ jobs:
export PATH="$HOME/.local/bin:$PATH"
}
for MT in "${V}" "${VER}" "${major}.${minor}"; do
crane copy "$SHA_IMG" "registry.hanzo.ai/hanzoai/cloud:${MT}" \
|| echo "::warning::mirror registry.hanzo.ai/hanzoai/cloud:${MT} failed"
# Bounded: registry.hanzo.ai can *hang* (not just fail), and this
# is best-effort — an unbounded crane copy once livelocked the whole
# tag step and held the serialized release lane. timeout makes the
# mirror truly best-effort so the git-tag receipt below always runs.
timeout 120 crane copy "$SHA_IMG" "registry.hanzo.ai/hanzoai/cloud:${MT}" \
|| echo "::warning::mirror registry.hanzo.ai/hanzoai/cloud:${MT} failed or timed out"
done
fi
git tag -a "$V" -m "release $V — image ghcr.io/hanzoai/cloud:$V (retagged from sha-${{ steps.ver.outputs.sha_short }}, smoke-passed ${GITHUB_SHA})"
@@ -552,9 +599,86 @@ jobs:
echo "::error::could not acquire a free version tag after 8 attempts"
exit 1
# Deploy = a declared-tag bump in hanzoai/universe crs/cloud.yaml — Hanzo CD
# (the ArgoCD instance in ns hanzo-cd) syncs universe→cluster and the operator
# reconciles the CR. The old notify-universe repository_dispatch hub is retired
# (its flagged-sender dispatches were silently suppressed anyway); the native
# release path (release.go rolloutRelease) and deliberate promote commits own
# the bump.
# ── Promote: the declared-tag bump that makes the release DEPLOY ─────────────
# The tag minted above is the receipt for a pushed, smoke-passed image; THIS job
# records it as the desired state Hanzo CD reconciles. The universe-crs ArgoCD
# Application (ns hanzo-cd, `automated` sync + selfHeal) syncs
# infra/k8s/operator/crs/*.yaml → cluster and the operator rolls the Deployment,
# so a tag bump committed here reaches api.hanzo.ai with NO hand-dispatch and NO
# hand-edit of the CR.
#
# This is the SAME yq-bump → `deploy(<svc>): <tag>` universe commit the hanzoai/ci
# reusable (build.yml deploy step) does for every other service. cloud owns it
# HERE because its image is built by this workflow, not the ci reusable — its
# hanzo.yml carries no main `images:` entry and `# NO deploy`, so the shared
# deploy step never bumps cloud's CR. A direct in-cluster CR patch is NOT enough:
# ArgoCD selfHeal reverts any live edit not also recorded in git within ~45s.
# The retired notify-universe repository_dispatch had no receiver after the
# image-update.yml deploy hub was deleted in the Hanzo CD cutover; the git commit
# IS the sanctioned path now.
promote:
needs: build-amd64
# Only a real release promotes: build+smoke+push+tag all succeeded, so a
# proven v* image exists. A failure earlier leaves version_v empty → skipped.
if: ${{ needs.build-amd64.outputs.version_v != '' }}
runs-on: [hanzo-build-linux-amd64]
steps:
- name: Record the proven tag in universe crs/cloud.yaml (Hanzo CD rolls it)
env:
# GH_PAT already pushes this repo's git tags above (contents:write on the
# hanzoai org), so it writes hanzoai/universe too — the SAME token the ci
# reusable falls back to for the universe deploy commit.
GH_PAT: ${{ secrets.GH_PAT }}
VERSION_V: ${{ needs.build-amd64.outputs.version_v }}
run: |
set -euo pipefail
[ -n "${GH_PAT:-}" ] || { echo "::error::no GH_PAT — cannot record the declared-tag bump in universe"; exit 1; }
# Bare arc runners ship no yq — provision the static binary (sudo-free,
# same pattern the ci reusable and this workflow's kubectl/crane fetches use).
if ! command -v yq >/dev/null 2>&1; then
mkdir -p "$HOME/.local/bin"; export PATH="$HOME/.local/bin:$PATH"
curl -fsSL https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 \
-o "$HOME/.local/bin/yq" && chmod +x "$HOME/.local/bin/yq"
fi
git clone -q --depth 1 \
"https://x-access-token:${GH_PAT}@github.com/hanzoai/universe.git" \
"$RUNNER_TEMP/universe"
CR="$RUNNER_TEMP/universe/infra/k8s/operator/crs/cloud.yaml"
[ -f "$CR" ] || { echo "::error::crs/cloud.yaml not found in universe"; exit 1; }
CUR="$(yq -r '.spec.image.tag // ""' "$CR")"
echo "cloud CR: ${CUR:-<empty>} → ${VERSION_V}"
# Monotonic guard: never roll the CR BACKWARD. Release runs finish under a
# serialized lane but a slow older run must never overwrite a newer promote.
# Skip iff the CR already holds a semver >= the version we just cut.
CURN="${CUR#v}"; NEWN="${VERSION_V#v}"
if printf '%s' "$CURN" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
top="$(printf '%s\n%s\n' "$CURN" "$NEWN" | sort -V | tail -1)"
if [ "$top" = "$CURN" ] && [ "$CURN" != "$NEWN" ]; then
echo "::notice::cloud CR already at v${CURN} (≥ ${VERSION_V}) — not rolling back"; exit 0
fi
fi
yq -i ".spec.image.tag = \"${VERSION_V}\"" "$CR"
if git -C "$RUNNER_TEMP/universe" diff --quiet; then
echo "::notice::crs/cloud.yaml already at ${VERSION_V} — nothing to record"; exit 0
fi
git -C "$RUNNER_TEMP/universe" -c user.name=hanzo-ci -c user.email=dev@hanzo.ai \
commit -qam "deploy(cloud): ${VERSION_V} (${GITHUB_REPOSITORY}@$(echo "${GITHUB_SHA}" | cut -c1-7))"
# Rebase-safe push: universe main advances on every service's deploy, so a
# concurrent commit must not make cloud's promote lose the whole roll. Retry
# a few times, rebasing between attempts.
for attempt in $(seq 1 5); do
if git -C "$RUNNER_TEMP/universe" push -q origin HEAD:main; then
echo "recorded deploy(cloud): ${VERSION_V} — Hanzo CD (universe-crs) will roll it to api.hanzo.ai"
exit 0
fi
echo " universe push lost the race — rebasing (attempt ${attempt})"
git -C "$RUNNER_TEMP/universe" pull -q --rebase origin main || true
sleep 3
done
echo "::error::could not record the cloud tag bump in universe after 5 attempts"; exit 1
+74
View File
@@ -6,6 +6,44 @@ artifact serves `api.hanzo.ai`, `api.lux.cloud`, `api.zoo.cloud`, `api.osage.clo
and every white-label reseller. Brand, enabled subsystems, and org scope are
deployment configuration.
## Open Cloud planes
Spec home: HIP-0129 `hip-0129-open-cloud-planes` (hips repo). This section is a
map, not the spec. One noun, one owner, one route family. No plane reads another
plane's store; imports flow custody-ward only (channels -> integrations, never
reverse).
| Route | Noun | Owner | Tier |
| --- | --- | --- | --- |
| `/v1/connectors` | Custody: per-user BYO external accounts | `clients/integrations` (extends; user scope new) | In flight (branch `feat/connectors`) |
| `/v1/channels` | Transport: portable message envelope, DM pairing, send + inbox | `clients/channels` (new) | Planned (branch `feat/channels` reserved; no transport code yet) |
| `/v1/sync` | Data: bidirectional sync engine | `clients/sync` | Shipped |
| `/v1/automations` | Workflows: flows/runs, goja piece runtime | `clients/automations` | Shipped |
| `/v1/compute/bots` | Hosting: `@hanzo/bot` Node containers | `clients/bots` | Shipped |
| `/v1/tasks` | Durable engine | `clients/tasks` | Shipped |
| `/v1/gpus` + fleet | BYO GPU presence | `clients/fleet` + `clients/visor` | Shipped |
| IAM | Identity: users, orgs, roles | IAM | Shipped |
| KMS | Secret custody: sealed secrets | `clients/kms` | Shipped |
Custody invariants: secrets sealed in KMS at
`/orgs/{org}/users/{user}/connectors/{provider}/{label}`, never in SQLite rows;
verify before store. Refresh is single-flight with rotation resealing; the CLI
does local browser PKCE and posts the bundle to
`POST /v1/connectors/:provider/credential`; cloud owns device-code flows.
Transport invariants: typed actions (`command|url|select|approval`), no raw
string sniffing; pairing codes 8 chars, 1h TTL, max 3 pending per account,
owner bootstrap on first approval.
Container boundary is permanent for native-module, host-filesystem, loop-state,
and vendor-Node work (agent loop, exec/PTY, harnesses, browser, voice, codecs,
Node-bound channels, plugin SDK/loader). The Node plugin SDK is never ported to
Go; cloud extensibility is connectors/automations/tools.
Port roadmap (P1-P15) lives in HIP-0129; do not restate it here. Every claim
carries its tier: Shipped (on main, named package/route), In flight (named
pre-main branch), Planned (backlog id or named reservation).
## Framework doctrine
One way to do everything. Composable, orthogonal, DRY. A new subsystem is a
@@ -184,3 +222,39 @@ Import path (already-incorporated orgs): Google Drive → data room, a Google Sh
captable, via the `google` OAuth provider now completed in `clients/integrations`
(token custodied in KMS; the automations `google` connector shares the same token).
Runbook: `docs/company-dogfood.md`.
## Deploy plane (`clients/deploy`, `/v1/deploy`)
Native ArgoCD-grade GitOps console over the operator-managed fleet, parallel to
`/v1/git`: each `hanzo.ai/v1` App CR IS the Application, and the plane OBSERVES the
operator's reconcile — `GET /v1/deploy/applications` (fleet list), `/{name}/tree`
(ownerRef resource tree + per-node health/sync), `/{name}/resource/{ref}` (live
manifest + desired-vs-live diff), `/{name}/logs`; `POST /{name}/rollback` pins the CR
image to a prior semver and `/{name}/sync` requests a reconcile. SUPERADMIN-only on
`c.IsAdmin()`, fail-closed; Secret nodes are never surfaced. `engine.go` embeds the argo
`gitops-engine` (`hanzoai/deploy/gitops-engine` v0.7.2, no replace) in-process for the
reconcile half behind `DEPLOY_ENGINE_ENABLED` (default off), with a prune-safety fuse.
## The `hanzo` CLI targets THIS binary — one contract, one IAM login
The `hanzo` CLI (`cli/`) is the same unified binary; its control-plane verbs speak the
routes THIS process serves, authorized off a plain `hanzo login` (the IAM access token is
the final bearer fallback — no `--platform-token`). The ONE contract, no TS-Dokploy drift:
- `hanzo apps list|get``GET /v1/paas/apps[/{app}]` (`clients/paas` fleet drift board)
- `hanzo deploy <app>``POST /v1/paas/apps/{app}/deploy` — a zero-downtime ROLLING
RESTART (stamps the Deployment pod-template `hanzo.ai/restartedAt` annotation; never
changes the declared TAG — that stays a git commit CD reconciles). `--env` picks the ns.
- `hanzo clusters list|get``GET /v1/clusters` (`clients/visor`, tenant-scoped)
- `hanzo build``POST /v1/runner` (native buildkit fabric)
`/v1/paas/*` auth mirrors `/v1/runner` (`clients/platform/runner.go`): the `guard` admits a
validated principal who is SuperAdmin OR OrgAdmin, then each handler CONFINES a non-super
caller to the platform namespaces its own validated org owns (`scopedNamespaces`, keyed on
`principal.Org` — a tenant admin can never observe/restart another org's, or a platform,
app; `?org=` cannot widen it). The rolling restart needs `patch` on `apps/deployments`
(ClusterRole/cloud, universe `infra/k8s/cloud/rbac.yaml`). There is NO `/v1/apps`,
`/v1/org/{org}/cluster`, or `/v1/platform/projects` CLI path — the first two never existed
here (TS-Dokploy contract, 404), and `/v1/platform/*` needs a co-resident IAM store this
deployment does not fold in (IAM runs as a separate svc) so it 500s; the live apps backend
is `/v1/paas`, whose board reads k8s directly with no IAM-store dependency.
+12 -1
View File
@@ -24,7 +24,7 @@ OPENAPI_DIR ?= ../openapi
# forces the fork to modernc too so the whole binary registers "sqlite" once.
CGO_ENABLED ?= 0
.PHONY: help native webui agentskills build build-standalone hanzo run smoke test test-cgo vet tidy docker docker-push clean
.PHONY: help native webui deploy-ui agentskills build build-standalone hanzo run smoke test test-cgo vet tidy docker docker-push clean
help: ## Show this help.
@awk 'BEGIN{FS=":.*##";printf "\nUsage: make <target>\n\nTargets:\n"} /^[a-zA-Z_-]+:.*##/{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
@@ -40,6 +40,17 @@ webui: ## Build the real console static bundle into webui/dist (go:embed source)
cp -r "$(CONSOLE_DIR)/out/." webui/dist/
@echo ">> embedded real console bundle into webui/dist (index.html $$(wc -c < webui/dist/index.html) bytes)"
deploy-ui: ## Build the monochrome ArgoCD dashboard bundle into clients/deploy/webui/dist (go:embed source). DEPLOY_DIR=<path to hanzoai/deploy>.
@command -v yarn >/dev/null 2>&1 || { echo "yarn is required to build the deploy dashboard bundle"; exit 1; }
@test -f "$(DEPLOY_DIR)/ui/package.json" || { echo "deploy checkout not found at $(DEPLOY_DIR) — set DEPLOY_DIR=<path to hanzoai/deploy on rebrand/hanzo-monochrome>"; exit 1; }
@test -d "$(DEPLOY_DIR)/ui/node_modules" || (cd "$(DEPLOY_DIR)/ui" && yarn install --frozen-lockfile)
cd "$(DEPLOY_DIR)/ui" && NODE_OPTIONS=--max-old-space-size=8192 yarn build
# Overlay the fresh bundle, keeping only the tracked fallback (.gitignore +
# index.html shell); the real 43MB bundle is build-time-only (gitignored).
find clients/deploy/webui/dist -mindepth 1 -maxdepth 1 ! -name .gitignore -exec rm -rf {} +
cp -r "$(DEPLOY_DIR)/ui/dist/app/." clients/deploy/webui/dist/
@echo ">> embedded monochrome ArgoCD bundle into clients/deploy/webui/dist (index.html $$(wc -c < clients/deploy/webui/dist/index.html) bytes)"
agentskills: ## Regenerate the FULL agent-skills catalog into clients/agentskills/catalog (go:embed source) from the openapi SOT. OPENAPI_DIR=<path to openapi>.
@test -f "$(OPENAPI_DIR)/skills.py" || { echo "openapi checkout not found at $(OPENAPI_DIR) — set OPENAPI_DIR=<path> or clone hanzoai/openapi"; exit 1; }
# skills.py rewrites the whole catalog dir; the .gitignore keeps only the tiny
+31 -18
View File
@@ -51,6 +51,7 @@ import (
// owns process-lifetime resources, a Shutdown); Wire references them directly.
"github.com/hanzoai/cloud/clients/account"
"github.com/hanzoai/cloud/clients/admin"
"github.com/hanzoai/cloud/clients/admission"
"github.com/hanzoai/cloud/clients/ads"
"github.com/hanzoai/cloud/clients/affiliates"
"github.com/hanzoai/cloud/clients/agent"
@@ -65,14 +66,14 @@ import (
"github.com/hanzoai/cloud/clients/bots"
"github.com/hanzoai/cloud/clients/captable"
"github.com/hanzoai/cloud/clients/catalogsync"
"github.com/hanzoai/cloud/clients/cloudflare"
"github.com/hanzoai/cloud/clients/code"
"github.com/hanzoai/cloud/clients/company"
"github.com/hanzoai/cloud/clients/connectorruntime"
"github.com/hanzoai/cloud/clients/content"
"github.com/hanzoai/cloud/clients/crm"
"github.com/hanzoai/cloud/clients/cron"
"github.com/hanzoai/cloud/clients/dataroom"
"github.com/hanzoai/cloud/clients/deploy"
"github.com/hanzoai/cloud/clients/dns"
"github.com/hanzoai/cloud/clients/do"
"github.com/hanzoai/cloud/clients/entitlements"
"github.com/hanzoai/cloud/clients/eval"
@@ -236,7 +237,10 @@ func Wire() []cloud.MountSpec {
// CommerceClient is wired directly in pickCommerceClient).
{Name: "commerce", Mount: mountCommerce},
{Name: "licensing", Mount: licensing.Mount},
{Name: "plans", Mount: plan.Mount, OwnsHealth: true},
// clients/plan.Mount. Enable id normalized "plans" -> "plan" to match the
// package + generated cmd/plan (one subsystem, one name). Its product routes
// stay /v1/plans/* (incl. the OwnsHealth /v1/plans/health probe) — unchanged.
{Name: "plan", Mount: plan.Mount, OwnsHealth: true},
{Name: "pricing", Mount: pricing.Mount, OwnsHealth: true},
// /v1/s3/buckets/* + /v1/s3/health. Mounts BEFORE provisioning (120) so its static
// routes win over provisioning's /v1/s3/:name. OwnsHealth (real fail-closed probe).
@@ -250,6 +254,9 @@ func Wire() []cloud.MountSpec {
{Name: "do", Mount: do.Mount},
{Name: "platform", Mount: platform.Mount, OwnsHealth: true},
{Name: "projects", Mount: projects.Mount},
// The /v1/dns forward head: relays the console DNS dashboard to the DNS
// control plane under the caller's own validated bearer (clients/dns).
{Name: "dns", Mount: dns.Mount},
{Name: "prompts", Mount: prompts.Mount},
{Name: "agents", Mount: agents.Mount, Shutdown: agents.Shutdown},
// The unified AI login manager registry (/v1/links). Mounts AFTER agents so
@@ -314,6 +321,11 @@ func Wire() []cloud.MountSpec {
{Name: "graph", Mount: graph.Mount},
{Name: "security", Mount: security.Mount, Shutdown: ctxShutdown(security.Shutdown), OwnsHealth: true},
{Name: "integrations", Mount: integrations.Mount, Shutdown: integrations.Shutdown},
// Per-org Cloudflare asset plane /v1/integrations/cloudflare/{pages,workers,r2,kv,d1}/*.
// Mounts AFTER integrations because it reads the org's Cloudflare token through
// the integrations custody seam (integrations.TokenFor) — one token, one
// custody boundary. Stateless: no store, no shutdown.
{Name: "cloudflare", Mount: cloudflare.Mount},
{Name: "sbom", Mount: sbom.Mount, OwnsHealth: true},
{Name: "team", Mount: team.Mount, Shutdown: ctxShutdown(team.Shutdown)},
{Name: "settings", Mount: settings.Mount, Shutdown: settings.Shutdown},
@@ -336,23 +348,24 @@ func Wire() []cloud.MountSpec {
{Name: "evals", Mount: eval.Mount},
{Name: "treasury", Mount: treasury.Mount, Shutdown: ctxShutdown(treasury.Shutdown)},
{Name: "admin", Mount: admin.Mount},
// Launch-control (per-service waitlist mode) folded into the flags engine: the
// mode IS the switch waitlist.<svc>, the board is the /v1/admin/services lens,
// and /v1/featuregate/mode is served by flags. featuregate is no longer a mounted
// subsystem — it exposes only the native Enforce middleware (wired in serve.go),
// a consumer of flags.WaitlistModeForHost.
// Launch-control gate (per-service waitlist): the COMPLETE feature — host→service
// registry + brand seed + the waitlist.<svc> switch registration + the
// /v1/flags/waitlist (and /v1/admission/mode compat) mode read + the Enforce
// middleware — COMPOSING the flags engine one-way (flags.Bool/Register/
// SetPlatformSwitch; flags never imports admission). Mounts AFTER flags so the
// engine's platform-switch plane is installed first; the admin board is the
// /v1/admin/services lens over it. Owns the registry store handle → Shutdown.
{Name: "admission", Mount: admission.Mount, Shutdown: ctxShutdown(admission.Shutdown)},
// Tasks: the durable workflow/UI surface AND platform cron (durable schedules
// on the same shared engine, replacing every k8s CronJob). cron was a separate
// Wire entry; it mounts no routes and only registers schedules, so it is folded
// in as a sub-mount of tasks.Mount — ONE tasks subsystem.
{Name: "tasks", Mount: tasks.Mount},
// Platform cron: durable schedules on the shared tasks engine replacing
// every k8s CronJob — entries are cron.hanzo.ai ConfigMaps (universe git),
// runs visible in the Tasks console. Mounts no routes; starts after the
// engine is wired.
{Name: "cron", Mount: cron.Mount},
// Automations: the connector catalogue + flow engine AND native single-connector
// execution (POST /v1/automations/connectors/:id/run, HIP-0126). The connector
// runner mounts no other routes, so it is folded in as a sub-mount of
// automations.Mount (was a separate "connectorruntime" entry) — ONE subsystem.
{Name: "automations", Mount: automations.Mount, Shutdown: automations.Shutdown},
// Native single-connector execution (HIP-0126): runs an ActivePieces JS
// connector action in-process via goja (clients/connectorruntime), retiring
// the standalone auto Node engine. Mounts POST /v1/automations/connectors/:id/run,
// paired with the automations catalogue above; STAGED like the rest.
{Name: "connectorruntime", Mount: connectorruntime.Mount},
// Unified tool plane: /v1/tools/* — the ONE registry (connectors, functions,
// agents, skills, external MCP servers, full-cloud-control /v1 routes), per-org
// activation, and the unified MCP endpoint. Sources register into it from their
+55
View File
@@ -21,15 +21,19 @@ import (
"net/http"
"path/filepath"
"strings"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/commerceclient"
"github.com/hanzoai/cloud/clients/commerceinproc"
financeclient "github.com/hanzoai/cloud/clients/finance"
"github.com/hanzoai/commerce"
commercebilling "github.com/hanzoai/commerce/api/billing"
commercestore "github.com/hanzoai/commerce/api/store"
commercedatastore "github.com/hanzoai/commerce/datastore"
commercemid "github.com/hanzoai/commerce/middleware"
"github.com/hanzoai/commerce/middleware/iammiddleware"
commercensctx "github.com/hanzoai/commerce/util/nscontext"
log "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
@@ -164,6 +168,19 @@ func mountCommerce(app *zip.App, deps cloud.Deps) error {
commerceinproc.SetApp(app)
commerceclient.PublishEmbedded(embedded)
// Usage-cap enforcement on the FINANCE path. The unified binary records usage in
// the finance ledger (fin.RecordUsage), NOT commerce's transaction store — which
// it leaves empty — so the cap must read spend from, and fire alerts on, the
// finance ledger. Two seams, both org-wide (the finance Entry carries no scope;
// per-scope caps are a follow-up):
// - SetPeriodSpendReader: AuthorizeSpendCap's scopeSpentCents reads the org's
// finance period spend instead of the empty commerce transaction ledger, so
// a real LLM request increments the cap's `spent` and trips the 402.
// - SetUsageHook: after each finance debit, fire the org's spend-alerts on the
// SAME crossing (the alert half), reading the same finance spend + debouncing.
commercebilling.SetPeriodSpendReader(financePeriodSpend)
financeclient.SetUsageHook(fireCapAlert)
lg.Info("commerce embedded natively (hanzoai/commerce module on the shared zip app)",
"data_dir", dataDir,
"brand", deps.Brand,
@@ -215,3 +232,41 @@ func mountCommerceFailClosed(app *zip.App) {
app.All(p+"/*", failed)
}
}
// financePeriodSpend is the usage-cap's period-spend source (injected into commerce
// via SetPeriodSpendReader). It returns the org's finance-ledger usage in cents since
// the start of the CURRENT UTC month — the window the cap resets on (mirrors
// commerce periodStartUTC). Org-wide: the finance Entry carries no project/service,
// so scope args are ignored and the org total is returned (what the covering
// org-wide spend-alert row binds on). A finance impl without the sum capability, or a
// split deploy (no co-resident finance), reports 0 — the cap can never over-count.
func financePeriodSpend(ctx context.Context, org string, test bool, _, _ string) (int64, error) {
fin := financeclient.Current()
if fin == nil {
return 0, nil
}
summer, ok := fin.(interface {
SumUsageSince(context.Context, string, bool, int64) (int64, error)
})
if !ok {
return 0, nil
}
n := time.Now().UTC()
since := time.Date(n.Year(), n.Month(), 1, 0, 0, 0, 0, time.UTC).Unix()
return summer.SumUsageSince(ctx, org, test, since)
}
// fireCapAlert fires the org's spend-alerts after a finance usage debit — the alert
// half of the cap on the finance path (wired via finance.SetUsageHook). It resolves
// the org's commerce datastore (where the spend-alert rows live) and calls the
// exported commerce trigger, which reads the org's period spend via financePeriodSpend
// and stamps/debounces. Detached + best-effort; never blocks the money path. Runs in
// its own goroutine (the hook is invoked with `go`), so a background context is right.
func fireCapAlert(org string, test bool, project, service string) {
if strings.TrimSpace(org) == "" {
return
}
ctx := commercensctx.WithNamespace(context.Background(), org)
db := commercedatastore.New(ctx)
commercebilling.FireSpendAlerts(ctx, db, org, test, project, service, nil)
}
+87 -86
View File
@@ -23,92 +23,93 @@ var frozen = []struct {
ownsHealth bool
hasShutdown bool
}{
{"pubsub", false, true}, // was order 5
{"kafka", false, true}, // was order 6
{"agentskills", false, false}, // was order 8
{"flags", true, true}, // was order 9; native engine: /v1/flags health + store shutdown
{"kms", true, false}, // was order 10
{"metrics", false, false}, // was order 40
{"ingress", false, true}, // was order 42
{"account", false, false}, // was order 48
{"iam", false, false}, // was order 50
{"base", true, true}, // was order 60; per-org embed added Shutdown (#298)
{"o11y", false, true}, // ONE observability subsystem (was co-owned orders 69+70): read plane + the hanzoai/o11y module wildcard folded in as MountO11y's terminal sub-mount. OwnsHealth=false keeps /v1/o11y/health the generic always-ok route the module co-entry used to trigger.
{"authz", false, false}, // was order 70
{"commerce", false, false}, // was order 100
{"licensing", false, false}, // was order 110
{"plans", true, false}, // was order 111
{"pricing", true, false}, // was order 112
{"storage", true, false}, // was order 118
{"provisioning", false, false}, // was order 120
{"billing", false, false}, // was order 121
{"account-bridge", false, false}, // was order 122
{"do", false, false}, // was order 123
{"platform", true, false}, // was order 124
{"projects", false, false}, // was order 125
{"prompts", false, false}, // was order 126
{"agents", false, true}, // was order 127
{"link", false, true}, // new: unified AI login manager (/v1/links), after agents
{"wallets", false, true}, // was order 127
{"x402", false, true}, // new: x402 pay-per-use settlement (after wallets)
{"paas", true, false}, // was order 128
{"deploy", true, false}, // after paas (release seam), before functions
{"functions", false, false}, // was order 128
{"tracker", false, false}, // was order 129
{"templates", false, false}, // was order 129
{"framework", false, true}, // was order 129
{"knowledge", false, false}, // was order 130
{"content", false, true}, // new: marketing content loop (after knowledge)
{"catalogsync", false, true}, // new: reverse loop (product.created → render) after content
{"ml", true, false}, // was order 130
{"usage", false, false}, // was order 131
{"crm", false, false}, // was order 131
{"marketing", false, true}, // new: marketing domain fold (after crm)
{"ads", false, true}, // new: ads domain fold (after crm)
{"social", false, true}, // new: /v1/social fold (after crm)
{"analytics", true, false}, // was order 132
{"git", false, false}, // was order 132
{"sync", false, true}, // /v1/sync engine (owns per-org DB handles → Shutdown)
{"visor", false, false}, // was order 133
{"captable", false, true}, // was order 133
{"code", false, true}, // was order 134
{"zero-trust", false, false}, // was order 134
{"dataroom", true, true}, // was order 134
{"graph", false, false}, // was order 135
{"security", true, true}, // was order 136
{"integrations", false, true}, // was order 137
{"sbom", true, false}, // was order 137
{"team", false, true}, // was order 138
{"settings", false, true}, // was order 138
{"notify", true, false}, // was order 139
{"gateway", false, false}, // was order 139
{"entitlements", false, true}, // was order 139
{"exec", false, false}, // was order 140
{"websearch", false, false}, // was order 141
{"world", false, true}, // was order 142
{"runtime", false, false}, // was order 143; was "bot" until the transport was named for what it is
{"authors", false, true}, // was order 143
{"bots", false, false}, // was order 143
{"audit", false, false}, // was order 144
{"affiliates", false, false}, // was order 144
{"sign", true, true}, // was order 145
{"product", false, false}, // was order 145
{"evals", false, false}, // was order 145
{"treasury", false, true}, // was order 146
{"admin", false, false}, // was order 146
{"tasks", false, false}, // was order 147
{"cron", false, false}, // durable platform cron on the shared engine (post-freeze add)
{"automations", false, true}, // was order 148
{"connectorruntime", false, false}, // new: native single-connector exec via goja (after automations, HIP-0126)
{"tools", false, true}, // new: unified tool plane (after automations)
{"marketplace", false, true}, // new: marketplace over the tool plane (after tools)
{"referrals", false, false}, // was order 149
{"guide", false, true}, // new: Business AI Guide (after referrals, before ai)
{"company", false, true}, // new: Hanzo Company formation state machine (after guide)
{"agent", false, false}, // new: /v1/agent tool-calling round (before zen/ai catch-all)
{"zen", false, false}, // zen* claim middleware before ai's catch-all (hip-00NN)
{"ai", false, false}, // was order 150
{"plugins", false, false}, // was order 900
{"pubsub", false, true}, // was order 5
{"kafka", false, true}, // was order 6
{"agentskills", false, false}, // was order 8
{"flags", true, true}, // was order 9; native engine: /v1/flags health + store shutdown
{"kms", true, false}, // was order 10
{"metrics", false, false}, // was order 40
{"ingress", false, true}, // was order 42
{"account", false, false}, // was order 48
{"iam", false, false}, // was order 50
{"base", true, true}, // was order 60; per-org embed added Shutdown (#298)
{"o11y", false, true}, // ONE observability subsystem (was co-owned orders 69+70): read plane + the hanzoai/o11y module wildcard folded in as MountO11y's terminal sub-mount. OwnsHealth=false keeps /v1/o11y/health the generic always-ok route the module co-entry used to trigger.
{"authz", false, false}, // was order 70
{"commerce", false, false}, // was order 100
{"licensing", false, false}, // was order 110
{"plan", true, false}, // was order 111; enable id normalized plans->plan (routes stay /v1/plans/*)
{"pricing", true, false}, // was order 112
{"storage", true, false}, // was order 118
{"provisioning", false, false}, // was order 120
{"billing", false, false}, // was order 121
{"account-bridge", false, false}, // was order 122
{"do", false, false}, // was order 123
{"platform", true, false}, // was order 124
{"projects", false, false}, // was order 125
{"dns", false, false}, // new: /v1/dns zone plane (after projects)
{"prompts", false, false}, // was order 126
{"agents", false, true}, // was order 127
{"link", false, true}, // new: unified AI login manager (/v1/links), after agents
{"wallets", false, true}, // was order 127
{"x402", false, true}, // new: x402 pay-per-use settlement (after wallets)
{"paas", true, false}, // was order 128
{"deploy", true, false}, // after paas (release seam), before functions
{"functions", false, false}, // was order 128
{"tracker", false, false}, // was order 129
{"templates", false, false}, // was order 129
{"framework", false, true}, // was order 129
{"knowledge", false, false}, // was order 130
{"content", false, true}, // new: marketing content loop (after knowledge)
{"catalogsync", false, true}, // new: reverse loop (product.created → render) after content
{"ml", true, false}, // was order 130
{"usage", false, false}, // was order 131
{"crm", false, false}, // was order 131
{"marketing", false, true}, // new: marketing domain fold (after crm)
{"ads", false, true}, // new: ads domain fold (after crm)
{"social", false, true}, // new: /v1/social fold (after crm)
{"analytics", true, false}, // was order 132
{"git", false, false}, // was order 132
{"sync", false, true}, // /v1/sync engine (owns per-org DB handles → Shutdown)
{"visor", false, false}, // was order 133
{"captable", false, true}, // was order 133
{"code", false, true}, // was order 134
{"zero-trust", false, false}, // was order 134
{"dataroom", true, true}, // was order 134
{"graph", false, false}, // was order 135
{"security", true, true}, // was order 136
{"integrations", false, true}, // was order 137
{"cloudflare", false, false}, // new: /v1/cloudflare edge plane (after integrations)
{"sbom", true, false}, // was order 137
{"team", false, true}, // was order 138
{"settings", false, true}, // was order 138
{"notify", true, false}, // was order 139
{"gateway", false, false}, // was order 139
{"entitlements", false, true}, // was order 139
{"exec", false, false}, // was order 140
{"websearch", false, false}, // was order 141
{"world", false, true}, // was order 142
{"runtime", false, false}, // was order 143; was "bot" until the transport was named for what it is
{"authors", false, true}, // was order 143
{"bots", false, false}, // was order 143
{"audit", false, false}, // was order 144
{"affiliates", false, false}, // was order 144
{"sign", true, true}, // was order 145
{"product", false, false}, // was order 145
{"evals", false, false}, // was order 145
{"treasury", false, true}, // was order 146
{"admin", false, false}, // was order 146
{"admission", false, true}, // launch-control gate: composes flags (registry+seed+mode route+Enforce); Shutdown closes the registry store
{"tasks", false, false}, // was order 147; platform cron folded in as a sub-mount of tasks.Mount (was a separate entry)
{"automations", false, true}, // was order 148; connectorruntime (POST /v1/automations/connectors/:id/run) folded in as a sub-mount of automations.Mount
{"tools", false, true}, // new: unified tool plane (after automations)
{"marketplace", false, true}, // new: marketplace over the tool plane (after tools)
{"referrals", false, false}, // was order 149
{"guide", false, true}, // new: Business AI Guide (after referrals, before ai)
{"company", false, true}, // new: Hanzo Company formation state machine (after guide)
{"agent", false, false}, // new: /v1/agent tool-calling round (before zen/ai catch-all)
{"zen", false, false}, // zen* claim middleware before ai's catch-all (hip-00NN)
{"ai", false, false}, // was order 150
{"plugins", false, false}, // was order 900
}
// TestWireOrderMatchesFrozen proves the composition root's mount order is
+50 -4
View File
@@ -37,10 +37,10 @@ type keyResolver interface {
// A brief cache keeps the hot auth path off the network; it caches misses too, so a
// bad key cannot hammer IAM.
type iamKeys struct {
base string
auth string // client_secret_basic, or "" when unconfigured
http *http.Client
cache cache[string, *idClaims]
base string
auth string // client_secret_basic, or "" when unconfigured
http *http.Client
cache cache[string, *idClaims]
}
// newIAMKeys reads the same IAM env clients/account does. With no confidential
@@ -55,6 +55,52 @@ func newIAMKeys() *iamKeys {
}
}
// sharedKeys memoizes ONE API-key resolver (and its 60s cache) for the whole
// binary. The identity boundary (SanitizeIdentity, via newIdentityValidator) and
// any subsystem that must resolve a key OUT-OF-BAND of the Authorization header
// (analytics capture: a project key posted in the SDK body/query) both go through
// this ONE seam, so a key resolves to the SAME org either way and IAM sees one
// warm cache — never a second, drifting resolver.
var (
sharedKeysOnce sync.Once
sharedKeysInst *iamKeys
)
func sharedKeys() *iamKeys {
sharedKeysOnce.Do(func() { sharedKeysInst = newIAMKeys() })
return sharedKeysInst
}
// maxKeyOrgLen bounds a resolved org key the same way principal.MaxOrgLen does: the
// org becomes a warehouse partition key, so an over-long value (malformed / hostile)
// is refused rather than stored.
const maxKeyOrgLen = 128
// OrgForKey resolves an opaque Hanzo API key (hk-/sk-/pk-/fw_/hz_) to the org it
// belongs to — the SAME owner org SanitizeIdentity mints when that key arrives as a
// bearer — through the ONE IAM key seam (get-user?accessKey). It is the exported
// door a keyed, bearer-less SDK path uses to attribute a project key to a tenant.
//
// FAILS CLOSED: ("", false) for a non-key-shaped string, an unknown/unresolvable
// key, an unconfigured resolver, or an out-of-bounds org — never a fabricated or
// default tenant, so a bad key can never be written into another org's partition.
// The isAPIKey prefix gate keeps garbage strings off the IAM network path.
func OrgForKey(ctx context.Context, key string) (string, bool) {
key = strings.TrimSpace(key)
if !isAPIKey(key) {
return "", false
}
claims := sharedKeys().resolve(ctx, key)
if claims == nil {
return "", false
}
owner := strings.TrimSpace(claims.Owner)
if owner == "" || len(owner) > maxKeyOrgLen {
return "", false
}
return owner, true
}
// iamHost is the standalone IAM origin cloud talks to; iamCred is the service
// credential (client_secret_basic) it presents — the ONE IAM identity, shared by
// the API-key resolver here and the /v1/iam edge (iam_edge.go), so both
+36 -15
View File
@@ -42,6 +42,7 @@ type idClaims struct {
Owner string `json:"owner"` // org slug (the org)
Project string `json:"project"` // org SUB-SCOPE within owner (empty ⟹ default project)
BillingAccount string `json:"billing_account"` // WHO PAYS, stated by IAM (empty ⟹ pre-claim token)
Name string `json:"name"` // display name (id fallback)
PreferredUsername string `json:"preferred_username"` // id fallback
Email string `json:"email"`
@@ -62,6 +63,20 @@ func (c *idClaims) mintedProject() string {
return strings.TrimSpace(c.Project)
}
// mintedBillingAccount returns the funding account to stamp into
// X-Billing-Account-Id, or "" when the header must be OMITTED (a token minted
// before IAM shipped the claim, or one IAM could not attribute).
//
// WHO PAYS IS NOT A CLIENT'S TO NAME. This rides the validated `billing_account`
// claim — IAM's signed statement, resolved at the identity boundary from the real
// grant context — exactly like `owner` and `project`. It mirrors the edge
// (iamauth.Claims.MintedBillingAccount) byte-for-byte, so the in-binary path binds
// the same header the gateway would, and ai/object.Payer reads the same payer on
// both. The raw client copy is deleted on ingress and NEVER restored.
func (c *idClaims) mintedBillingAccount() string {
return strings.TrimSpace(c.BillingAccount)
}
// userID resolves the canonical user id: sub, then preferred_username, then
// name. IAM may leave sub empty. This is the STABLE identifier (a UUID when IAM
// sets sub) stamped as X-User-Id and consumed as the attribution key everywhere.
@@ -123,7 +138,7 @@ func newIdentityValidator(issuer, jwksURL string, audiences []string, ttl time.D
issuers: trustedIssuers(issuer),
audiences: audiences,
cache: newJWKSCache(jwksURL, ttl),
keys: newIAMKeys(),
keys: sharedKeys(), // ONE resolver+cache, shared with OrgForKey (analytics capture)
}
}
@@ -197,6 +212,16 @@ func (v *identityValidator) validate(raw string) (*idClaims, error) {
return nil, err
}
// Fail SECURE on a misconfigured (empty) trust set: an empty issuer OR audience
// allowlist must REJECT every token, never silently disable that axis. In
// production both are always resolved non-empty (BrandIssuers + the baked
// audience defaults, unioned in config.go so they are "never empty"), so this
// fires ONLY on an operator misconfiguration (CLOUD_JWT_AUDIENCES="" emptying the
// resolved set, or an empty issuer set) — and then it denies, it never admits (I2).
if len(v.issuers) == 0 || len(v.audiences) == 0 {
return nil, fmt.Errorf("identity validator misconfigured: empty issuer or audience allowlist")
}
// Reject a missing issuer: an empty issuer must never pass the set check.
if claims.Issuer == "" {
return nil, fmt.Errorf("missing issuer")
@@ -221,14 +246,13 @@ func (v *identityValidator) validate(raw string) (*idClaims, error) {
// gates on owner == :org. Without this, a real client_credentials machine token
// (aud == its per-org clientId, never in the allowlist) fails here and the
// sync silently stays pending — the activation blocker.
expected := jwt.Expected{}
if len(v.audiences) > 0 {
auds := v.audiences
if mach := kmsMachineAudience(claims.Owner); mach != "" {
auds = append(append(make([]string, 0, len(v.audiences)+1), v.audiences...), mach)
}
expected.AnyAudience = jwt.Audience(auds)
// The audience allowlist is guaranteed non-empty (checked above), so the
// audience axis is ALWAYS enforced — never silently skipped.
auds := v.audiences
if mach := kmsMachineAudience(claims.Owner); mach != "" {
auds = append(append(make([]string, 0, len(v.audiences)+1), v.audiences...), mach)
}
expected := jwt.Expected{AnyAudience: jwt.Audience(auds)}
if err := claims.Claims.ValidateWithLeeway(expected, 2*time.Minute); err != nil {
return nil, fmt.Errorf("claims: %w", err)
}
@@ -425,14 +449,11 @@ func trustedIssuers(primary string) []string {
return out
}
// issuerAllowed reports whether iss is one of the trusted issuers. An empty set
// (no primary, no brands — never the case in production) skips the check, matching
// the prior "empty issuer disables the check" behavior; a non-empty set is
// fail-secure (a token whose iss is not in the set is rejected).
// issuerAllowed reports whether iss is one of the trusted issuers. It is
// fail-secure in BOTH directions: an empty trusted set matches NOTHING (deny), so
// a misconfiguration that empties the issuer allowlist rejects every token instead
// of silently disabling the check (I2); a non-empty set rejects any iss not in it.
func issuerAllowed(iss string, trusted []string) bool {
if len(trusted) == 0 {
return true
}
for _, t := range trusted {
if iss == t {
return true
+39 -3
View File
@@ -1,10 +1,45 @@
package cloud
import (
"crypto/rand"
"crypto/rsa"
"os"
"testing"
"time"
)
// TestValidate_FailSecureOnEmptyTrustSet proves I2: a validator whose resolved
// issuer OR audience allowlist is empty REJECTS an otherwise-valid, correctly
// signed token — the axis is never silently disabled. Production always resolves
// non-empty sets; this guards the misconfiguration path (CLOUD_JWT_AUDIENCES=""
// or an empty issuer set), which must fail closed, not open.
func TestValidate_FailSecureOnEmptyTrustSet(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("genkey: %v", err)
}
jwks := jwksServer(t, &key.PublicKey)
future := time.Now().Add(time.Hour)
tok := signWith(t, key, tokenClaims("hanzo-console", "acme", "", false, future))
// Sanity: a properly configured validator accepts the token.
if _, err := newIdentityValidator(testIssuer, jwks.URL, []string{"hanzo-console"}, 0).validate(tok); err != nil {
t.Fatalf("baseline valid token must be accepted, got %v", err)
}
// Empty audience set → deny.
if _, err := newIdentityValidator(testIssuer, jwks.URL, nil, 0).validate(tok); err == nil {
t.Error("empty audience allowlist must REJECT (fail-secure), not accept")
}
// Empty issuer set → deny (construct directly; trustedIssuers never yields empty
// with a primary, so bypass it to exercise the guard).
vEmptyIss := &identityValidator{issuers: nil, audiences: []string{"hanzo-console"}, cache: newJWKSCache(jwks.URL, 0), keys: newIAMKeys()}
if _, err := vEmptyIss.validate(tok); err == nil {
t.Error("empty issuer allowlist must REJECT (fail-secure), not accept")
}
}
// TestTrustedIssuers_WhiteLabel proves the in-binary validator's trusted-issuer
// set is the primary issuer UNIONED with every white-label brand issuer plus the
// WHITELABEL_ISSUERS override, deduped, primary-first.
@@ -40,7 +75,8 @@ func TestTrustedIssuers_WhiteLabel(t *testing.T) {
}
// TestIssuerAllowed proves the set membership check: brand issuers pass, an
// outsider is rejected, and an empty set (never in prod) skips the check.
// outsider is rejected, and an empty set is fail-secure — it matches NOTHING (I2),
// so a misconfiguration that empties the allowlist denies every token.
func TestIssuerAllowed(t *testing.T) {
set := []string{"https://hanzo.id", "https://lux.id"}
if !issuerAllowed("https://lux.id", set) {
@@ -49,8 +85,8 @@ func TestIssuerAllowed(t *testing.T) {
if issuerAllowed("https://attacker.id", set) {
t.Error("attacker.id must be rejected")
}
if !issuerAllowed("anything", nil) {
t.Error("empty set must skip the check (matches prior empty-issuer behavior)")
if issuerAllowed("anything", nil) {
t.Error("empty set must DENY (fail-secure), never skip the check")
}
}
+42 -8
View File
@@ -14,7 +14,7 @@ import (
"github.com/hanzoai/cloud/clients"
"github.com/hanzoai/cloud/clients/finance"
"github.com/hanzoai/cloud/clients/gatewaypolicy"
"github.com/hanzoai/cloud/clients/gateway/edge"
"github.com/hanzoai/cloud/clients/money"
"github.com/hanzoai/cloud/clients/s3admin"
"github.com/hanzoai/cloud/types"
@@ -90,6 +90,7 @@ func BuildDeps(cfg *Config) Deps {
// commerce URL yields a !Enabled() client, so the wrap is a transparent
// pass-through and a dev deployment is never blocked.
deps.Metering = buildMeteringClient(cfg, logger)
wireTierReader(deps.Metering, logger)
deps.AI = meteredAIClient(pickAIClient(cfg, logger), deps)
wireFinance(cfg, logger)
deps.O11y = pick(cfg, logger, "o11y", "O11y", cfg.O11yZAPAddr, clients.O11yRPCAt, clients.DisabledO11y)
@@ -107,7 +108,7 @@ func BuildDeps(cfg *Config) Deps {
// working *Store (static-only if the SQLite file can't open), so the edge
// middleware is never left without a policy source — a store-open error is
// logged, not fatal.
gp, err := gatewaypolicy.New(cfg.DataDir, cfg.AdminOrg, staticEdgePolicy(cfg))
gp, err := edge.New(cfg.DataDir, cfg.AdminOrg, staticEdgePolicy(cfg))
if err != nil {
logger.Warn("gateway policy store degraded to static-only", "err", err)
}
@@ -117,10 +118,10 @@ func BuildDeps(cfg *Config) Deps {
}
// staticEdgePolicy projects the static env/flag edge config into the boot-default
// policy the gatewaypolicy.Store layers runtime overrides on top of. A disabled
// policy the edge.Store layers runtime overrides on top of. A disabled
// per-IP limiter (CLOUD_EDGE_RATELIMIT=false) maps to PerIPRPM 0 (a live no-op).
func staticEdgePolicy(cfg *Config) gatewaypolicy.Policy {
p := gatewaypolicy.Policy{
func staticEdgePolicy(cfg *Config) edge.Policy {
p := edge.Policy{
CORSOrigins: cfg.CORSOrigins,
WindowSec: cfg.EdgeRateWindowSec,
}
@@ -157,9 +158,15 @@ func buildMeteringClient(cfg *Config, log luxlog.Logger) *metering.Client {
httpClient = commerceinproc.Client(0) // in-process dispatch; no network timeout
}
m, err := metering.New(metering.Config{
BaseURL: base,
Token: cfg.CommerceServiceToken,
Org: cfg.Brand, // X-Org-Id default for S2S; per-request org overrides.
BaseURL: base,
Token: cfg.CommerceServiceToken,
Org: cfg.Brand, // X-Org-Id default for S2S; per-request org overrides.
// Honor the documented METERING_TEST env: when "true", route every debit to
// commerce's TEST/sandbox books (fin.RecordUsage in.Test=true) so a staging /
// canary deployment records NO real money — and the usage-cap read (org.TestMode
// via SQUARE_ENVIRONMENT=sandbox) sees the SAME test books. Unset in prod → live,
// unchanged. Without this the flag was silently ignored (always live).
Test: strings.EqualFold(strings.TrimSpace(os.Getenv(metering.EnvTest)), "true"),
FailOpen: cfg.BillingFailOpen,
HTTPClient: httpClient, // nil off the co-resident path → metering builds its own
})
@@ -169,6 +176,11 @@ func buildMeteringClient(cfg *Config, log luxlog.Logger) *metering.Client {
log.Error("billing: invalid commerce URL, gate disabled", "err", err)
m, _ = metering.New(metering.Config{})
}
// Observe every cap-check fail-open (timeout / slow / broken commerce) — a cap that
// silently allows must never be silent. The completion still proceeds (fail-open).
metering.OnCapError = func(err error) {
log.Warn("spend-cap check failed open (allowing completion) — commerce authorize slow/unavailable", "err", err)
}
if m.Enabled() {
log.Info("billing gate enabled", "commerce", boolStr(inProcess, "in-process", "http:"+base), "fail_open", cfg.BillingFailOpen)
} else {
@@ -184,6 +196,28 @@ func boolStr(b bool, t, f string) string {
return f
}
// wireTierReader installs the embedded ai module's per-tier SKU gate reader so it
// resolves the caller's commerce subscription tier through the SAME co-resident
// commerce client the metering gate bills over — in-process (commerceinproc) when
// commerce is folded in, S2S HTTP with the service token otherwise — NEVER an authed
// self-call to the cloud edge. That self-call is the toothless-gate bug: the edge
// 401/403s a service call to /v1/billing/*, so the ai module's own HTTP lookup always
// returned "" in-cluster and every tier-gated SKU failed OPEN. This mirrors
// wireFinance's SetBalanceReader: cloud owns the co-resident read, ai stays
// transport-agnostic. Fail-safe is preserved — Client.Tier folds a commerce error or
// an unknown plan to "", which the gate treats as ALLOW, so a commerce blip never
// locks out a paying caller. No-op when commerce is unreachable (metering !Enabled),
// leaving ai's standalone HTTP fallback in place.
func wireTierReader(m *metering.Client, log luxlog.Logger) {
if m == nil || !m.Enabled() {
return
}
aiobject.SetTierReader(func(ctx context.Context, subject, namespace string) (string, error) {
return m.Tier(ctx, subject, namespace)
})
log.Info("ai per-tier SKU gate wired to co-resident commerce (in-process tier read, fail-safe)")
}
// wireFinance constructs the ONE in-process finance ledger (per-org SQLite
// double-entry prepaid wallet), publishes it for every money consumer to resolve by
// the narrow finance.Client, and installs the embedded ai router's balance-read +
+51
View File
@@ -0,0 +1,51 @@
package cloud
import (
"io"
"net/http"
"net/http/httptest"
"testing"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// CallerBearer relays the caller's OWN validated JWT bearer and nothing else: a JWT
// passes through unchanged, an opaque API key is not relayable, and no credential
// yields "". This is the token a downstream org-scoped service (the DNS forward
// head) re-validates to enforce tenant isolation across the hop.
func TestCallerBearer(t *testing.T) {
app := zip.New(zip.Config{Logger: luxlog.New("test")})
app.Get("/probe", func(c *zip.Ctx) error { return c.Bytes(200, []byte(CallerBearer(c))) })
probe := func(setup func(*http.Request)) string {
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
if setup != nil {
setup(req)
}
res, err := app.Fiber().Test(req)
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(res.Body)
_ = res.Body.Close()
return string(b)
}
cases := []struct {
name string
setup func(*http.Request)
want string
}{
{"jwt bearer relayed unchanged", func(r *http.Request) { r.Header.Set("Authorization", "Bearer jwt.header.sig") }, "jwt.header.sig"},
{"X-Authorization fallback", func(r *http.Request) { r.Header.Set("X-Authorization", "Bearer x.y.z") }, "x.y.z"},
{"opaque hk- api key is NOT relayable", func(r *http.Request) { r.Header.Set("Authorization", "Bearer hk-secret") }, ""},
{"opaque sk- api key is NOT relayable", func(r *http.Request) { r.Header.Set("Authorization", "Bearer sk-secret") }, ""},
{"no credential yields empty", nil, ""},
}
for _, c := range cases {
if got := probe(c.setup); got != c.want {
t.Errorf("%s: CallerBearer = %q, want %q", c.name, got, c.want)
}
}
}
+15 -8
View File
@@ -474,11 +474,14 @@ func (e *Env) freshAccessToken() string {
return tok
}
// platformToken resolves the platform control-plane service token. The
// platform REST surface is machine-to-machine (it cannot validate IAM user
// tokens), so apps/clusters/redeploy authenticate with this, sourced from
// (in precedence) the bound --platform-token flag, the environment, then the
// credential store. Never hardcoded.
// platformToken resolves the bearer the platform control plane authenticates
// apps/clusters/redeploy with. ONE identity authorizes everything: after a plain
// `hanzo login` the IAM access token is the FINAL fallback, so no separate
// --platform-token is needed — the platform verifies the IAM JWT (signature,
// issuer, expiry) and org-scopes the caller. A dedicated service token still
// wins when present (flag > env > credential store > IAM login), so purpose-minted
// machine tokens keep their precedence and internal automation is unchanged.
// Never hardcoded.
func (e *Env) platformToken(flagVal string) string {
return firstNonEmpty(
flagVal,
@@ -486,17 +489,22 @@ func (e *Env) platformToken(flagVal string) string {
os.Getenv("PLATFORM_SERVICE_TOKEN"),
os.Getenv("PAAS_SERVICE_TOKEN"),
e.creds.PlatformToken,
e.accessToken(), // IAM login is the one identity that authorizes control-plane ops
)
}
// buildToken resolves the platform build-enqueue token (a distinct credential
// from the service token — see /v1/runner).
// buildToken resolves the bearer `hanzo build` sends to the platform build
// enqueue (/v1/runner). Same unify-infra contract as platformToken: a dedicated
// build token wins when present, but a plain IAM login is the FINAL fallback, so
// `hanzo build` works off the one identity with no separate --build-token — the
// platform verifies the IAM JWT and authorizes the build by org + role.
func (e *Env) buildToken(flagVal string) string {
return firstNonEmpty(
flagVal,
os.Getenv("HANZO_BUILD_TOKEN"),
os.Getenv("PLATFORM_BUILD_CALLBACK_TOKEN"),
e.creds.BuildToken,
e.accessToken(), // IAM login is the one identity that authorizes builds
)
}
@@ -588,7 +596,6 @@ func newRootCmd() *cobra.Command {
newDeployCmd(envOf, &f),
newClustersCmd(envOf, &f),
newBuildCmd(envOf, &f),
newK8sCmd(envOf, &f),
newConfigCmd(),
newSecurityCmd(envOf),
newGPUCmd(envOf, &f),
+54
View File
@@ -141,6 +141,29 @@ func TestPlatformTokenPrecedence(t *testing.T) {
}
}
// TestPlatformTokenFallsBackToIAM is the UNIFY-INFRA contract for the control
// plane: after a plain `hanzo login`, the IAM access token is the FINAL fallback
// so `hanzo apps`/`hanzo deploy` authorize off the one identity. An explicit
// platform service token (creds/env/flag) still wins.
func TestPlatformTokenFallsBackToIAM(t *testing.T) {
sandbox(t)
// Only an IAM login: no platform token anywhere ⇒ the IAM access token is sent.
e := resolve(&Config{}, &Credentials{AccessToken: "iam-jwt"}, globalFlags{})
if got := e.platformToken(""); got != "iam-jwt" {
t.Fatalf("IAM access token should be the final platform-token fallback: %q", got)
}
// A dedicated platform service token still beats the IAM token.
e = resolve(&Config{}, &Credentials{AccessToken: "iam-jwt", PlatformToken: "svc"}, globalFlags{})
if got := e.platformToken(""); got != "svc" {
t.Fatalf("dedicated platform token must beat the IAM fallback: %q", got)
}
// No login at all ⇒ empty (caller surfaces "run `hanzo login`").
e = resolve(&Config{}, &Credentials{}, globalFlags{})
if got := e.platformToken(""); got != "" {
t.Fatalf("no token and no login should resolve empty: %q", got)
}
}
func TestBuildTokenPrecedence(t *testing.T) {
sandbox(t)
e := resolve(&Config{}, &Credentials{BuildToken: "creds"}, globalFlags{})
@@ -156,6 +179,37 @@ func TestBuildTokenPrecedence(t *testing.T) {
}
}
// TestBuildTokenFallsBackToIAM is the UNIFY-INFRA contract: after a plain
// `hanzo login` (no --build-token), the IAM access token is the FINAL fallback,
// so `hanzo build` authorizes off the one identity. An explicit build token
// (creds/env/flag) still wins — the IAM token is the LAST resort, never an
// override of a purpose-minted machine token.
func TestBuildTokenFallsBackToIAM(t *testing.T) {
sandbox(t)
// Only an IAM login: no build token anywhere ⇒ the IAM access token is sent.
e := resolve(&Config{}, &Credentials{AccessToken: "iam-jwt"}, globalFlags{})
if got := e.buildToken(""); got != "iam-jwt" {
t.Fatalf("IAM access token should be the final build-token fallback: %q", got)
}
// A dedicated build token still beats the IAM token (precedence preserved).
e = resolve(&Config{}, &Credentials{AccessToken: "iam-jwt", BuildToken: "creds"}, globalFlags{})
if got := e.buildToken(""); got != "creds" {
t.Fatalf("dedicated build token must beat the IAM fallback: %q", got)
}
// HANZO_TOKEN (the env form of the IAM token) is also honored via accessToken().
e = resolve(&Config{}, &Credentials{}, globalFlags{})
t.Setenv("HANZO_TOKEN", "iam-env")
if got := e.buildToken(""); got != "iam-env" {
t.Fatalf("HANZO_TOKEN should back the build-token fallback: %q", got)
}
// No login at all ⇒ empty, so the caller can surface "run `hanzo login`".
t.Setenv("HANZO_TOKEN", "")
e = resolve(&Config{}, &Credentials{}, globalFlags{})
if got := e.buildToken(""); got != "" {
t.Fatalf("no token and no login should resolve empty: %q", got)
}
}
func TestAccessTokenFromEnvOverCreds(t *testing.T) {
sandbox(t)
e := resolve(&Config{}, &Credentials{AccessToken: "creds"}, globalFlags{})
+64 -22
View File
@@ -147,6 +147,7 @@ type codeAgent struct {
bin string // executable to exec
wire wire // how it finds the cloud
fullAuto []string // flags that bypass approval prompts
continueArgs []string // harness-native form of Hanzo -c/--continue
modelArg []string // how the model is passed on argv (empty: via env)
carrier func(model string) string // maps the resolved model to a client-recognized id (claude: zen→carrier); nil = pass through
provider func(base string) []string // agents that need the endpoint declared, not just env'd
@@ -163,10 +164,11 @@ type codeAgent struct {
// declared, so declare Hanzo as the provider and select it.
func codexLike(bin, install string) codeAgent {
return codeAgent{
bin: bin,
wire: openaiWire,
fullAuto: []string{"--dangerously-bypass-approvals-and-sandbox"},
modelArg: []string{"-m"},
bin: bin,
wire: openaiWire,
fullAuto: []string{"--dangerously-bypass-approvals-and-sandbox"},
continueArgs: []string{"resume", "--last"},
modelArg: []string{"-m"},
provider: func(base string) []string {
return []string{
"-c", "model_provider=hanzo",
@@ -174,6 +176,12 @@ func codexLike(bin, install string) codeAgent {
"-c", fmt.Sprintf(`model_providers.hanzo.base_url="%s/v1"`, strings.TrimSuffix(base, "/")),
"-c", `model_providers.hanzo.env_key="OPENAI_API_KEY"`,
"-c", `model_providers.hanzo.wire_api="responses"`,
// api.hanzo.ai exposes the standard OpenAI /v1/models shape,
// not Codex's private remote model-catalog schema. Skip that
// optional refresh and supply the coding model's metadata here.
"-c", `features.remote_models=false`,
"-c", `model_context_window=262144`,
"-c", `model_auto_compact_token_limit=235929`,
}
},
install: install,
@@ -190,9 +198,10 @@ const zenIdentityPrompt = "You are running through the Hanzo AI cloud as a Hanzo
var codeAgents = map[string]codeAgent{
"claude": {
bin: "claude",
wire: anthropicWire,
fullAuto: []string{"--dangerously-skip-permissions"},
bin: "claude",
wire: anthropicWire,
fullAuto: []string{"--dangerously-skip-permissions"},
continueArgs: []string{"--continue"},
// --model forces the session model on argv. Claude Code persists the
// user's last /model selection (e.g. the reserved word "best"), and that
// persisted choice OVERRIDES ANTHROPIC_MODEL — so the env var alone cannot
@@ -246,9 +255,12 @@ func newCodeCmd(envOf func() *Env, _ *globalFlags) *cobra.Command {
Long: "Run @hanzo/dev, Claude Code, or Codex against api.hanzo.ai with the endpoint,\n" +
"credential and model injected — no env vars to remember. `hanzo code` alone runs\n" +
"dev (the Hanzo agent); name an agent to pick another. Model ids resolve fuzzily\n" +
"(glm5.2 -> glm-5.2) and agents run full-auto unless you pass --safe.",
"(glm5.2 -> glm-5.2), -c resumes either harness, and agents run full-auto unless\n" +
"you pass --safe. Unknown options pass through; -- forces verbatim passthrough.",
Example: " hanzo code # dev, the default agent\n" +
" hanzo code claude\n" +
" hanzo code claude -c\n" +
" hanzo code codex -c\n" +
" hanzo code codex deepseek-v4-pro\n" +
" hanzo code dev glm5.2 -- --resume\n" +
" hanzo code ls",
@@ -320,19 +332,10 @@ func runCode(env *Env, agent codeAgent, args []string) error {
}
base := strings.TrimSuffix(firstNonEmpty(env.CloudURL, "https://api.hanzo.ai"), "/")
// First non-flag arg is the model; --safe is ours; the rest is the agent's.
model, safe, rest := "", false, make([]string, 0, len(args))
for _, a := range args {
switch {
case a == "--": // the separator is ours; the agent must not see it
case a == "--safe" || a == "--ask":
safe = true
case model == "" && !strings.HasPrefix(a, "-") && len(rest) == 0:
model = a
default:
rest = append(rest, a)
}
}
// First non-flag arg before -- is the model; --safe and --continue are ours.
// Unknown options pass through unchanged. Everything after -- belongs to the
// agent, including positional subcommands and raw Codex -c config overrides.
model, safe, continueLast, rest := splitCodeArgs(args)
if model == "" {
model = defaultCodeModel
}
@@ -382,7 +385,7 @@ func runCode(env *Env, agent codeAgent, args []string) error {
}
}
argv := codeArgv(agent, base, model, safe, rest)
argv := codeArgv(agent, base, model, safe, codeAgentRest(agent, continueLast, rest))
for k, v := range agent.wire(base, token, model) {
if err := os.Setenv(k, v); err != nil {
@@ -397,6 +400,45 @@ func runCode(env *Env, agent codeAgent, args []string) error {
return execEngine(bin, argv) // exec: signals + exit code flow straight through
}
// splitCodeArgs pulls the launcher-owned tokens (the model, --safe, -c/--continue)
// out of the raw args; everything else is the agent's. The `--` separator is ours
// and switches on verbatim passthrough — every token after it goes to the agent
// untouched, including positional subcommands (`codex exec`) and raw Codex -c
// config overrides that would otherwise look like our --continue.
func splitCodeArgs(args []string) (model string, safe, continueLast bool, rest []string) {
rest = make([]string, 0, len(args))
passthrough := false
for _, a := range args {
switch {
case passthrough:
rest = append(rest, a)
case a == "--": // the separator is ours; the agent must not see it
passthrough = true
case a == "--safe" || a == "--ask":
safe = true
case a == "-c" || a == "--continue":
continueLast = true
case model == "" && !strings.HasPrefix(a, "-") && len(rest) == 0:
model = a
default:
rest = append(rest, a)
}
}
return model, safe, continueLast, rest
}
// codeAgentRest prepends the agent's harness-native resume tokens when -c/--continue
// was given, so one Hanzo flag resumes the last session on either harness (`--continue`
// for Claude Code, `resume --last` for Codex/dev).
func codeAgentRest(agent codeAgent, continueLast bool, rest []string) []string {
if !continueLast {
return rest
}
args := make([]string, 0, len(agent.continueArgs)+len(rest))
args = append(args, agent.continueArgs...)
return append(args, rest...)
}
// codeArgv builds the final agent command line. Permission bypass is the
// launcher default for every agent; --safe is the single explicit opt-out.
func codeArgv(agent codeAgent, base, model string, safe bool, rest []string) []string {
+77 -10
View File
@@ -18,6 +18,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"slices"
"testing"
"time"
@@ -48,6 +49,72 @@ func TestCodeAgentsBypassPermissionsByDefault(t *testing.T) {
}
}
func TestCodeArgsSeparatorPreservesAgentSubcommand(t *testing.T) {
model, safe, continueLast, rest := splitCodeArgs([]string{"--safe", "--", "exec", "--ephemeral", "do it"})
if model != "" || !safe || continueLast {
t.Fatalf("model=%q safe=%v continue=%v, want default model and safe mode", model, safe, continueLast)
}
if want := []string{"exec", "--ephemeral", "do it"}; !reflect.DeepEqual(rest, want) {
t.Fatalf("agent args = %q, want %q", rest, want)
}
}
func TestCodeArgsExplicitModelBeforeSeparator(t *testing.T) {
model, safe, continueLast, rest := splitCodeArgs([]string{"zen5-max", "--", "exec"})
if model != "zen5-max" || safe || continueLast || !reflect.DeepEqual(rest, []string{"exec"}) {
t.Fatalf("model=%q safe=%v continue=%v rest=%q", model, safe, continueLast, rest)
}
}
func TestCodeContinueIsNormalizedForBothHarnesses(t *testing.T) {
for _, tt := range []struct {
name string
want []string
}{
{name: "claude", want: []string{"--continue"}},
{name: "codex", want: []string{"resume", "--last"}},
} {
t.Run(tt.name, func(t *testing.T) {
model, safe, continueLast, rest := splitCodeArgs([]string{"-c"})
if model != "" || safe || !continueLast || len(rest) != 0 {
t.Fatalf("model=%q safe=%v continue=%v rest=%q", model, safe, continueLast, rest)
}
if got := codeAgentRest(codeAgents[tt.name], continueLast, rest); !reflect.DeepEqual(got, tt.want) {
t.Fatalf("normalized continue args = %q, want %q", got, tt.want)
}
})
}
}
func TestCodeUnknownOptionsAndPostSeparatorArgsPassThrough(t *testing.T) {
unknown := []string{"--mystery", "value", "--other=1"}
model, safe, continueLast, rest := splitCodeArgs(unknown)
if model != "" || safe || continueLast || !reflect.DeepEqual(rest, unknown) {
t.Fatalf("unknown options changed: model=%q safe=%v continue=%v rest=%q", model, safe, continueLast, rest)
}
_, _, continueLast, rest = splitCodeArgs([]string{"--", "-c", "model=x"})
if continueLast || !reflect.DeepEqual(rest, []string{"-c", "model=x"}) {
t.Fatalf("post-separator Codex config must pass verbatim: continue=%v rest=%q", continueLast, rest)
}
}
func TestCodexProviderUsesNativeResponsesMetadata(t *testing.T) {
argv := codeArgv(codeAgents["codex"], "https://api.hanzo.ai", defaultCodeModel, false, nil)
for _, want := range []string{
`model_provider=hanzo`,
`model_providers.hanzo.base_url="https://api.hanzo.ai/v1"`,
`model_providers.hanzo.wire_api="responses"`,
`features.remote_models=false`,
`model_context_window=262144`,
`model_auto_compact_token_limit=235929`,
} {
if !slices.Contains(argv, want) {
t.Errorf("Codex argv %q does not contain %q", argv, want)
}
}
}
// TestCodeTokenPrecedence locks in the 402 unblock: a fresh `hanzo login` JWT
// (which carries owner/project/sub on EVERY deployment) beats the hk- API key
// (which only mints a billing principal where the server has IAM_MINT_CLIENT_*).
@@ -60,10 +127,10 @@ func TestCodeTokenPrecedence(t *testing.T) {
freshExpiry := time.Now().Add(1 * time.Hour).Unix()
cases := []struct {
name string
envKey string // HANZO_API_KEY override
creds Credentials
want string
name string
envKey string // HANZO_API_KEY override
creds Credentials
want string
}{
{
name: "fresh JWT beats hk- key",
@@ -81,16 +148,16 @@ func TestCodeTokenPrecedence(t *testing.T) {
want: "hk-stored",
},
{
name: "HANZO_API_KEY overrides everything (deliberate operator override)",
name: "HANZO_API_KEY overrides everything (deliberate operator override)",
envKey: "hk-explicit",
creds: Credentials{AccessToken: "jwt-live", Expiry: freshExpiry},
want: "hk-explicit",
creds: Credentials{AccessToken: "jwt-live", Expiry: freshExpiry},
want: "hk-explicit",
},
{
name: "HANZO_API_KEY overrides even an expired JWT",
name: "HANZO_API_KEY overrides even an expired JWT",
envKey: "hk-explicit",
creds: Credentials{AccessToken: "jwt-dead", Expiry: time.Now().Add(-1 * time.Hour).Unix()},
want: "hk-explicit",
creds: Credentials{AccessToken: "jwt-dead", Expiry: time.Now().Add(-1 * time.Hour).Unix()},
want: "hk-explicit",
},
}
+103 -222
View File
@@ -3,6 +3,7 @@ package cli
import (
"fmt"
"io"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
@@ -15,12 +16,12 @@ func (e *Env) platform(gf *globalFlags) *Platform {
return newPlatform(e.PlatformURL, e.platformToken(gf.platformToken))
}
// deref renders a *string for a table cell, "-" when nil/empty.
func deref(p *string) string {
if p == nil || *p == "" {
// dashIfEmpty renders a string cell, "-" when empty.
func dashIfEmpty(s string) string {
if s == "" {
return "-"
}
return *p
return s
}
// yesno renders a bool for a table cell.
@@ -37,7 +38,8 @@ func newTab(w io.Writer) *tabwriter.Writer {
}
// ---------------------------------------------------------------------------
// apps — the observe surface.
// apps — the fleet drift board (GET /v1/paas/apps). Org-confined server-side by
// the IAM identity: a superadmin sees the whole fleet, an org-admin only its own.
// ---------------------------------------------------------------------------
func newAppsCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
@@ -56,7 +58,6 @@ func newAppsCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
RunE: func(cmd *cobra.Command, _ []string) error {
e := envOf()
res, err := e.platform(gf).Apps(cmd.Context(), AppsQuery{
Org: e.Org, // empty == all (single-tenant default)
Env: envFilter,
Health: healthFilter,
Drift: driftOnly,
@@ -69,8 +70,8 @@ func newAppsCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
fmt.Fprintln(tw, "ORG\tAPP\tENV\tDECLARED\tRUNNING\tHEALTH\tDRIFT")
for _, a := range res.Apps {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
a.Org, a.App, a.Env, deref(a.DeclaredTag), deref(a.RunningTag),
deref(a.Health), driftSeverity(a.Drift))
a.Org, a.App, a.Env, dashIfEmpty(a.DeclaredTag), dashIfEmpty(a.RunningTag),
dashIfEmpty(a.Health), driftSeverity(a.Drift))
}
tw.Flush()
fmt.Fprintf(w, "\n%d apps (ok=%d yellow=%d red=%d)\n",
@@ -79,17 +80,17 @@ func newAppsCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
})
},
}
list.Flags().StringVar(&envFilter, "env", "", "filter by env: dev|test|main")
list.Flags().StringVar(&envFilter, "env", "", "filter by env: main|test|dev")
list.Flags().StringVar(&healthFilter, "health", "", "filter by health: green|yellow|red")
list.Flags().BoolVar(&driftOnly, "drift", false, "only rows that are drifting")
get := &cobra.Command{
Use: "get <org/app/env>",
Short: "Get one app row by its <org>/<app>/<env> id",
Use: "get <app>",
Short: "Get one app row by its CR name (production by default)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
e := envOf()
a, err := e.platform(gf).App(cmd.Context(), args[0], e.Org)
a, err := e.platform(gf).App(cmd.Context(), args[0])
if err != nil {
return err
}
@@ -101,109 +102,93 @@ func newAppsCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
fmt.Fprintf(tw, "env:\t%s\n", a.Env)
fmt.Fprintf(tw, "repo:\t%s\n", a.Repo)
fmt.Fprintf(tw, "registry:\t%s\n", a.Registry)
fmt.Fprintf(tw, "declared:\t%s\n", deref(a.DeclaredTag))
fmt.Fprintf(tw, "running:\t%s\n", deref(a.RunningTag))
fmt.Fprintf(tw, "latest:\t%s\n", deref(a.LatestTag))
fmt.Fprintf(tw, "health:\t%s\n", deref(a.Health))
fmt.Fprintf(tw, "declared:\t%s\n", dashIfEmpty(a.DeclaredTag))
fmt.Fprintf(tw, "running:\t%s\n", dashIfEmpty(a.RunningTag))
fmt.Fprintf(tw, "health:\t%s\n", dashIfEmpty(a.Health))
fmt.Fprintf(tw, "phase:\t%s\n", dashIfEmpty(a.Phase))
fmt.Fprintf(tw, "drift:\t%s\n", driftSeverity(a.Drift))
fmt.Fprintf(tw, "cluster:\t%s\n", deref(a.Cluster))
fmt.Fprintf(tw, "namespace:\t%s\n", deref(a.Namespace))
fmt.Fprintf(tw, "updated:\t%s\n", a.UpdatedAt)
fmt.Fprintf(tw, "cluster:\t%s\n", dashIfEmpty(a.Cluster))
fmt.Fprintf(tw, "namespace:\t%s\n", dashIfEmpty(a.Namespace))
if len(a.Endpoints) > 0 {
fmt.Fprintf(tw, "endpoints:\t%s\n", strings.Join(a.Endpoints, ", "))
}
tw.Flush()
})
},
}
sync := &cobra.Command{
Use: "sync",
Short: "Trigger an inventory refresh of the apps board",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
e := envOf()
if err := e.platform(gf).SyncApps(cmd.Context()); err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout(), "apps sync triggered")
return nil
},
}
cmd.AddCommand(list, get, sync)
cmd.AddCommand(list, get)
return cmd
}
// ---------------------------------------------------------------------------
// deploy — the drive surface (rolling restart, zero-downtime).
// deploy — POST /v1/paas/apps/{app}/deploy: a zero-downtime rolling restart.
// ---------------------------------------------------------------------------
func newDeployCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
var project, environment string
var environment string
cmd := &cobra.Command{
Use: "deploy <container>",
Short: "Redeploy a container (rolling restart, zero-downtime)",
Long: "Drive a platform redeploy: a rolling restart of the container's k8s\n" +
"Deployment (re-pulls the image, recreates pods, zero downtime). Coordinates\n" +
"are exact — org (--org/config), project (--project), env (--env) and the\n" +
"container id (positional). This is the canonical PaaS-driven deploy.",
Use: "deploy <app>",
Short: "Redeploy an app (rolling restart, zero-downtime) — requires --env",
Long: "Drive a platform redeploy: a rolling restart of the app's k8s Deployment\n" +
"(re-pulls the declared image, recreates pods, zero downtime). The app is the\n" +
"operator App CR name; the org comes from your IAM identity. --env is REQUIRED\n" +
"(main|test|dev) — deploy never silently targets production. Restarting a shared\n" +
"platform service is a platform-operator action, so this needs a superadmin\n" +
"identity. A TAG change is still a git commit — this restarts what is declared.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(environment) == "" {
return fmt.Errorf("--env is required (main|test|dev) — deploy will not default to production")
}
e := envOf()
org, err := e.requireOrg()
res, err := e.platform(gf).Redeploy(cmd.Context(), args[0], environment)
if err != nil {
return err
}
if project == "" || environment == "" {
return fmt.Errorf("--project and --env are required (the container's project/environment ids)")
}
container := args[0]
if err := e.platform(gf).Redeploy(cmd.Context(), org, project, environment, container); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "redeployed %s (org=%s project=%s env=%s)\n", container, org, project, environment)
return nil
return e.emit(res, func(w io.Writer) {
fmt.Fprintf(w, "restarted %s (namespace=%s env=%s at %s)\n",
res.App, res.Namespace, dashIfEmpty(res.Env), res.RestartedAt)
})
},
}
cmd.Flags().StringVar(&project, "project", "", "project id")
cmd.Flags().StringVar(&environment, "env", "", "environment id")
cmd.Flags().StringVar(&environment, "env", "", "lifecycle env: main|test|dev (REQUIRED)")
return cmd
}
// ---------------------------------------------------------------------------
// clusters — dedicated DOKS cluster lifecycle.
// clusters — GET /v1/clusters: the org's compute fleet (Visor-managed + BYO),
// tenant-scoped server-side by the IAM identity.
// ---------------------------------------------------------------------------
func newClustersCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "clusters",
Aliases: []string{"cluster"},
Short: "Provision/list/select dedicated DOKS clusters",
Short: "List the org's clusters (managed + BYO)",
}
list := &cobra.Command{
Use: "list",
Short: "List the org's dedicated clusters",
Short: "List the org's clusters",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
e := envOf()
org, err := e.requireOrg()
if err != nil {
return err
}
cs, err := e.platform(gf).Clusters(cmd.Context(), org)
cs, err := e.platform(gf).Clusters(cmd.Context())
if err != nil {
return err
}
return e.emit(cs, func(w io.Writer) {
tw := newTab(w)
fmt.Fprintln(tw, "NAME\tID\tREGION\tSTATUS\tPHASE\tACTIVE\tOPERATOR\tBASELINE")
fmt.Fprintln(tw, "NAME\tID\tREGION\tSTATUS\tKIND\tNODES\tSIZE\tGPUS")
for _, c := range cs {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
c.Name, c.DoksClusterID, c.Region, c.Status, c.Phase,
yesno(c.Active), yesno(c.OperatorInstalled), yesno(c.BaselineInstalled))
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%d\t%s\t%s\n",
c.Name, dashIfEmpty(c.ID()), dashIfEmpty(c.Region), dashIfEmpty(c.Status),
dashIfEmpty(c.Kind), c.NodeCount, dashIfEmpty(c.NodeSize), gpuCell(c))
}
tw.Flush()
if len(cs) == 0 {
fmt.Fprintln(w, "(no dedicated clusters)")
fmt.Fprintln(w, "(no clusters)")
}
})
},
@@ -215,151 +200,51 @@ func newClustersCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
e := envOf()
org, err := e.requireOrg()
if err != nil {
return err
}
cs, err := e.platform(gf).Clusters(cmd.Context(), org)
cs, err := e.platform(gf).Clusters(cmd.Context())
if err != nil {
return err
}
for _, c := range cs {
if c.DoksClusterID == args[0] || c.Name == args[0] {
if c.ID() == args[0] || c.Name == args[0] {
return e.emit(c, func(w io.Writer) { printCluster(w, c) })
}
}
return fmt.Errorf("cluster %q not found in org %s", args[0], org)
return fmt.Errorf("cluster %q not found", args[0])
},
}
var region, nodeSize string
var ha bool
var nodeCount int
create := &cobra.Command{
Use: "create",
Short: "Provision a new dedicated DOKS cluster for the org",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
e := envOf()
org, err := e.requireOrg()
if err != nil {
return err
}
c, err := e.platform(gf).ProvisionCluster(cmd.Context(), org, ProvisionReq{
Region: region, HA: ha, NodeSize: nodeSize, NodeCount: nodeCount,
})
if err != nil {
return err
}
return e.emit(c, func(w io.Writer) {
fmt.Fprintf(w, "provisioning cluster %s (%s)\n", c.Name, c.DoksClusterID)
printCluster(w, *c)
})
},
}
create.Flags().StringVar(&region, "region", "", "DO region (default sfo3)")
create.Flags().BoolVar(&ha, "ha", false, "highly-available control plane")
create.Flags().StringVar(&nodeSize, "node-size", "", "node size slug (e.g. s-2vcpu-4gb)")
create.Flags().IntVar(&nodeCount, "node-count", 0, "node count")
var shared bool
selectCmd := &cobra.Command{
Use: "select <cluster-id>",
Short: "Set the org's active deploy target (or --shared to revert)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
e := envOf()
org, err := e.requireOrg()
if err != nil {
return err
}
var clusterID *string
switch {
case shared:
clusterID = nil
case len(args) == 1:
clusterID = &args[0]
default:
return fmt.Errorf("give a cluster id, or --shared to revert to the shared cluster")
}
t, err := e.platform(gf).SelectTarget(cmd.Context(), org, clusterID)
if err != nil {
return err
}
return e.emit(t, func(w io.Writer) { printTarget(w, t) })
},
}
selectCmd.Flags().BoolVar(&shared, "shared", false, "revert to the shared cluster")
installBaseline := &cobra.Command{
Use: "install-baseline <cluster-id>",
Short: "Install the hanzo-operator + per-tenant baseline on a cluster",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
e := envOf()
org, err := e.requireOrg()
if err != nil {
return err
}
if err := e.platform(gf).InstallBaseline(cmd.Context(), org, args[0]); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "baseline install requested for %s\n", args[0])
return nil
},
}
target := &cobra.Command{
Use: "target",
Short: "Show the org's current resolved deploy target",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
e := envOf()
org, err := e.requireOrg()
if err != nil {
return err
}
t, err := e.platform(gf).Target(cmd.Context(), org)
if err != nil {
return err
}
return e.emit(t, func(w io.Writer) { printTarget(w, t) })
},
}
cmd.AddCommand(list, get, create, selectCmd, installBaseline, target)
cmd.AddCommand(list, get)
return cmd
}
// gpuCell renders the live GPU inventory of a cluster ("-" when none).
func gpuCell(c Cluster) string {
var parts []string
if c.NvidiaGPU > 0 {
parts = append(parts, fmt.Sprintf("%d nvidia", c.NvidiaGPU))
}
if c.AmdGPU > 0 {
parts = append(parts, fmt.Sprintf("%d amd", c.AmdGPU))
}
if len(parts) == 0 {
return "-"
}
return strings.Join(parts, "+")
}
func printCluster(w io.Writer, c Cluster) {
tw := newTab(w)
fmt.Fprintf(tw, "id:\t%s\n", c.DoksClusterID)
fmt.Fprintf(tw, "id:\t%s\n", dashIfEmpty(c.ID()))
fmt.Fprintf(tw, "name:\t%s\n", c.Name)
fmt.Fprintf(tw, "region:\t%s\n", c.Region)
fmt.Fprintf(tw, "status:\t%s\n", c.Status)
fmt.Fprintf(tw, "phase:\t%s\n", c.Phase)
fmt.Fprintf(tw, "active:\t%s\n", yesno(c.Active))
fmt.Fprintf(tw, "operatorInstalled:\t%s\n", yesno(c.OperatorInstalled))
fmt.Fprintf(tw, "baselineInstalled:\t%s\n", yesno(c.BaselineInstalled))
fmt.Fprintf(tw, "endpoint:\t%s\n", deref(c.Endpoint))
fmt.Fprintf(tw, "k8sVersion:\t%s\n", deref(c.K8sVersion))
fmt.Fprintf(tw, "created:\t%s\n", c.CreatedAt)
if c.BaselineError != nil && *c.BaselineError != "" {
fmt.Fprintf(tw, "baselineError:\t%s\n", *c.BaselineError)
}
tw.Flush()
}
func printTarget(w io.Writer, t *Target) {
tw := newTab(w)
kind := "shared"
if t.Dedicated {
kind = "dedicated"
}
fmt.Fprintf(tw, "cluster:\t%s\n", t.Cluster)
fmt.Fprintf(tw, "kind:\t%s\n", kind)
for ns, env := range t.Namespaces {
fmt.Fprintf(tw, "namespace:\t%s -> %s\n", ns, env)
fmt.Fprintf(tw, "region:\t%s\n", dashIfEmpty(c.Region))
fmt.Fprintf(tw, "status:\t%s\n", dashIfEmpty(c.Status))
fmt.Fprintf(tw, "kind:\t%s\n", dashIfEmpty(c.Kind))
fmt.Fprintf(tw, "nodeCount:\t%d\n", c.NodeCount)
fmt.Fprintf(tw, "nodeSize:\t%s\n", dashIfEmpty(c.NodeSize))
fmt.Fprintf(tw, "gpus:\t%s\n", gpuCell(c))
fmt.Fprintf(tw, "created:\t%s\n", dashIfEmpty(c.CreatedAt))
for _, np := range c.NodePools {
fmt.Fprintf(tw, "pool:\t%s (%s x%d, autoscale=%s)\n", np.Name, np.Size, np.Count, yesno(np.AutoScale))
}
tw.Flush()
}
@@ -387,6 +272,10 @@ func newBuildCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
if br.Repo == "" || br.SHA == "" || br.Image == "" {
return fmt.Errorf("--repo (or positional), --sha and --image are required")
}
// The platform build muscle clones an https git URL; accept the
// idiomatic `owner/name` shorthand and expand it to GitHub (the host
// for every hanzoai/luxfi/zooai repo). A full URL passes through.
br.Repo = normalizeRepoURL(br.Repo)
if br.OrganizationID == "" {
br.OrganizationID = e.Org // optional; server defaults to DEFAULT_BUILD_ORG_ID
}
@@ -419,32 +308,24 @@ func newBuildCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
return cmd
}
// ---------------------------------------------------------------------------
// k8s — deploy-target helpers.
// ---------------------------------------------------------------------------
func newK8sCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "k8s",
Short: "Kubernetes deploy-target helpers",
// normalizeRepoURL expands the idiomatic `owner/name` shorthand to a full GitHub
// https URL (the platform build muscle clones https), and leaves an explicit URL
// (http/https/git/ssh scheme, or a scp-style git@host:owner/name) untouched. Only
// a bare single-segment `owner/name` — two path parts, no scheme, no host — is
// expanded; anything else is the caller's explicit choice and passes through.
func normalizeRepoURL(repo string) string {
r := strings.TrimSpace(repo)
if r == "" {
return r
}
target := &cobra.Command{
Use: "target",
Short: "Show the org's current resolved deploy target (cluster + namespaces)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
e := envOf()
org, err := e.requireOrg()
if err != nil {
return err
}
t, err := e.platform(gf).Target(cmd.Context(), org)
if err != nil {
return err
}
return e.emit(t, func(w io.Writer) { printTarget(w, t) })
},
// Already a URL or scp-style remote → leave as-is.
if strings.Contains(r, "://") || strings.Contains(r, "@") {
return r
}
cmd.AddCommand(target)
return cmd
// Bare owner/name (exactly two non-empty segments, no host dot in the first).
parts := strings.Split(strings.Trim(r, "/"), "/")
if len(parts) == 2 && parts[0] != "" && parts[1] != "" && !strings.Contains(parts[0], ".") {
return "https://github.com/" + parts[0] + "/" + parts[1]
}
return r
}
+109 -32
View File
@@ -8,6 +8,24 @@ import (
"testing"
)
func TestNormalizeRepoURL(t *testing.T) {
cases := map[string]string{
"luxfi/wallet": "https://github.com/luxfi/wallet",
"hanzoai/cloud": "https://github.com/hanzoai/cloud",
"https://github.com/luxfi/wallet": "https://github.com/luxfi/wallet", // full URL untouched
"git@github.com:luxfi/wallet.git": "git@github.com:luxfi/wallet.git", // scp-style untouched
"https://gitlab.com/org/repo": "https://gitlab.com/org/repo", // non-github URL untouched
"owner/name/extra": "owner/name/extra", // not a bare owner/name
"single": "single", // not two segments
"": "", // empty
}
for in, want := range cases {
if got := normalizeRepoURL(in); got != want {
t.Errorf("normalizeRepoURL(%q) = %q, want %q", in, got, want)
}
}
}
// withPlatform points the CLI at an httptest platform via env (HANZO_PLATFORM_URL
// + HANZO_PLATFORM_TOKEN), the same resolution path the real binary uses.
func withPlatform(t *testing.T, h http.HandlerFunc) string {
@@ -20,11 +38,15 @@ func withPlatform(t *testing.T, h http.HandlerFunc) string {
return srv.URL
}
// apps list hits the LIVE board path /v1/paas/apps and renders the fleet table.
func TestAppsListCommandTable(t *testing.T) {
withPlatform(t, func(w http.ResponseWriter, _ *http.Request) {
withPlatform(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/paas/apps" {
t.Errorf("apps path = %s, want /v1/paas/apps", r.URL.Path)
}
_ = json.NewEncoder(w).Encode(AppsList{
Apps: []AppView{
{Org: "hanzoai", App: "iam", Env: "main", DeclaredTag: strptr("v1.2.3"), RunningTag: strptr("v1.2.3"), Health: strptr("green"), Drift: json.RawMessage(`{"severity":"ok"}`)},
{Org: "hanzoai", App: "iam", Env: "main", DeclaredTag: "v1.2.3", RunningTag: "v1.2.3", Health: "green", Drift: json.RawMessage(`{"severity":"ok"}`)},
},
Summary: struct {
Total int `json:"total"`
@@ -43,6 +65,20 @@ func TestAppsListCommandTable(t *testing.T) {
}
}
// apps list honors --env/--health/--drift as server query params (the board filters).
func TestAppsListCommandFilters(t *testing.T) {
withPlatform(t, func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if q.Get("env") != "main" || q.Get("health") != "red" || q.Get("drift") != "1" {
t.Errorf("filters not forwarded: %s", r.URL.RawQuery)
}
_ = json.NewEncoder(w).Encode(AppsList{})
})
if _, err := runRoot(t, "", "apps", "list", "--env", "main", "--health", "red", "--drift"); err != nil {
t.Fatalf("apps list filters: %v", err)
}
}
func TestAppsListCommandJSON(t *testing.T) {
withPlatform(t, func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(AppsList{Apps: []AppView{{Org: "hanzoai", App: "iam", Env: "main"}}})
@@ -60,69 +96,112 @@ func TestAppsListCommandJSON(t *testing.T) {
}
}
// apps get hits /v1/paas/apps/{app}.
func TestAppsGetCommand(t *testing.T) {
withPlatform(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/paas/apps/iam" {
t.Errorf("path = %s, want /v1/paas/apps/iam", r.URL.Path)
}
_ = json.NewEncoder(w).Encode(AppView{ID: "hanzoai/iam/main", Org: "hanzoai", App: "iam", Env: "main", DeclaredTag: "v1.2.3", Health: "green", Phase: "Running"})
})
out, err := runRoot(t, "", "apps", "get", "iam")
if err != nil {
t.Fatalf("apps get: %v", err)
}
for _, want := range []string{"hanzoai/iam/main", "Running", "v1.2.3"} {
if !strings.Contains(out, want) {
t.Fatalf("apps get missing %q in:\n%s", want, out)
}
}
}
// deploy hits /v1/paas/apps/{app}/deploy — a rolling restart, org from identity.
func TestDeployCommand(t *testing.T) {
withPlatform(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/org/acme/project/p1/env/e1/container/app-x/redeploy" {
t.Errorf("redeploy path = %s", r.URL.Path)
if r.URL.Path != "/v1/paas/apps/app-x/deploy" || r.URL.Query().Get("env") != "main" {
t.Errorf("redeploy request = %s?%s", r.URL.Path, r.URL.RawQuery)
}
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true})
w.WriteHeader(202)
_ = json.NewEncoder(w).Encode(DeployResult{OK: true, App: "app-x", Namespace: "hanzo", Env: "main", RestartedAt: "2026-07-18T12:00:00Z"})
})
out, err := runRoot(t, "", "deploy", "app-x", "--org", "acme", "--project", "p1", "--env", "e1")
out, err := runRoot(t, "", "deploy", "app-x", "--env", "main")
if err != nil {
t.Fatalf("deploy: %v", err)
}
if !strings.Contains(out, "redeployed app-x") {
if !strings.Contains(out, "restarted app-x") || !strings.Contains(out, "namespace=hanzo") {
t.Fatalf("deploy output: %q", out)
}
}
func TestDeployRequiresProjectEnv(t *testing.T) {
withPlatform(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })
if _, err := runRoot(t, "", "deploy", "app-x", "--org", "acme"); err == nil {
t.Fatalf("deploy must require --project/--env")
// deploy REQUIRES --env — a bare deploy errors CLI-side, never silently prod.
func TestDeployRequiresEnv(t *testing.T) {
withPlatform(t, func(w http.ResponseWriter, _ *http.Request) {
t.Error("deploy without --env must not reach the server")
w.WriteHeader(202)
})
if _, err := runRoot(t, "", "deploy", "app-x"); err == nil {
t.Fatalf("deploy must require --env")
}
}
func TestDeployRequiresOrg(t *testing.T) {
withPlatform(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })
if _, err := runRoot(t, "", "deploy", "app-x", "--project", "p1", "--env", "e1"); err == nil {
t.Fatalf("deploy must require an org")
// deploy --env selects the lifecycle namespace via the ?env query param.
func TestDeployCommandEnv(t *testing.T) {
withPlatform(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/paas/apps/chat/deploy" || r.URL.Query().Get("env") != "test" {
t.Errorf("deploy env request = %s?%s", r.URL.Path, r.URL.RawQuery)
}
w.WriteHeader(202)
_ = json.NewEncoder(w).Encode(DeployResult{OK: true, App: "chat", Namespace: "hanzo-testnet", Env: "test", RestartedAt: "2026-07-18T12:00:00Z"})
})
if _, err := runRoot(t, "", "deploy", "chat", "--env", "test"); err != nil {
t.Fatalf("deploy --env: %v", err)
}
}
// A non-ok deploy response is surfaced as an error.
func TestDeployNotOK(t *testing.T) {
withPlatform(t, func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(DeployResult{OK: false})
})
if _, err := runRoot(t, "", "deploy", "app-x", "--env", "main"); err == nil {
t.Fatalf("deploy must error when the server does not report ok")
}
}
// clusters list hits the LIVE /v1/clusters (org from identity, not the path).
func TestClustersListCommand(t *testing.T) {
withPlatform(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/org/acme/cluster" {
t.Errorf("path = %s", r.URL.Path)
if r.URL.Path != "/v1/clusters" {
t.Errorf("path = %s, want /v1/clusters", r.URL.Path)
}
_ = json.NewEncoder(w).Encode(map[string]any{"clusters": []Cluster{
{DoksClusterID: "c1", Name: "hanzo-acme", Region: "sfo3", Status: "running", Phase: "ready", Active: true, OperatorInstalled: true, BaselineInstalled: true},
{DoksClusterID: "c1", Name: "hanzo-acme", Region: "sfo3", Status: "running", Kind: "managed", NodeCount: 3, NodeSize: "s-2vcpu-4gb", NvidiaGPU: 2},
}})
})
out, err := runRoot(t, "", "clusters", "list", "--org", "acme")
out, err := runRoot(t, "", "clusters", "list")
if err != nil {
t.Fatalf("clusters list: %v", err)
}
for _, want := range []string{"NAME", "hanzo-acme", "c1", "ready", "yes"} {
for _, want := range []string{"NAME", "hanzo-acme", "c1", "managed", "2 nvidia"} {
if !strings.Contains(out, want) {
t.Fatalf("clusters list missing %q in:\n%s", want, out)
}
}
}
func TestK8sTargetCommand(t *testing.T) {
withPlatform(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/org/acme/cluster/select" {
t.Errorf("path = %s", r.URL.Path)
}
_ = json.NewEncoder(w).Encode(map[string]any{"target": Target{Cluster: "hanzo-k8s", Dedicated: false, Namespaces: map[string]string{"hanzo": "main"}}})
// clusters get filters the live list client-side by id or name.
func TestClustersGetCommand(t *testing.T) {
withPlatform(t, func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"clusters": []Cluster{
{DoksClusterID: "c1", Name: "hanzo-acme", Region: "sfo3", Status: "running", Kind: "byo", NodeCount: 1},
}})
})
out, err := runRoot(t, "", "k8s", "target", "--org", "acme")
out, err := runRoot(t, "", "clusters", "get", "c1")
if err != nil {
t.Fatalf("k8s target: %v", err)
t.Fatalf("clusters get: %v", err)
}
if !strings.Contains(out, "hanzo-k8s") || !strings.Contains(out, "shared") {
t.Fatalf("k8s target output: %q", out)
if !strings.Contains(out, "hanzo-acme") || !strings.Contains(out, "byo") {
t.Fatalf("clusters get output: %q", out)
}
}
@@ -167,5 +246,3 @@ func TestConfigSetGetCommand(t *testing.T) {
t.Fatalf("config get = %q", out)
}
}
func strptr(s string) *string { return &s }
+64 -8
View File
@@ -54,6 +54,10 @@ const (
heartbeatEvery = 30 * time.Second
claimPoll = 2 * time.Second
claimLeaseSecs = 120
// renderWindow matches the dispatch cap (studio gpu_dispatch sets
// startToCloseTimeout 14400s). The old 10m local poll undercut it and
// marked live renders failed while they kept sampling (observed 8-70m).
renderWindow = 4 * time.Hour
// localComfyUI is the studio render backend the studio.render handler drives.
localComfyUI = "http://127.0.0.1:8188"
// defaultStudioUploadURL is where finished render outputs are POSTed so they
@@ -820,6 +824,14 @@ func (w *worker) studioRenderHandler(ctx context.Context, input json.RawMessage)
return nil, fmt.Errorf("studio.render: input needs a `prompt` graph")
}
cl := &http.Client{Timeout: 60 * time.Second}
// The claim-to-submit window: hold off the supervisor's recycle, and wait out
// one if it is already mid-flight — a claimed job must never die on staging
// because the engine happened to be restarting.
staging.Add(1)
defer staging.Add(-1)
if err := waitEngine(ctx, cl); err != nil {
return nil, fmt.Errorf("studio.render: %w", err)
}
// Materialize any uploaded inputs (they live in orgs/{org}/input on the cloud
// pod, which this worker cannot read) into the LOCAL studio input dir via its
// own /upload/image, so LoadImage resolves them before we render.
@@ -848,7 +860,7 @@ func (w *worker) studioRenderHandler(ctx context.Context, input json.RawMessage)
return nil, fmt.Errorf("studio.render: no prompt_id in /prompt response")
}
// Poll history until the prompt shows up (completed).
deadline := time.Now().Add(10 * time.Minute)
deadline := time.Now().Add(renderWindow)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
@@ -872,6 +884,11 @@ func (w *worker) studioRenderHandler(ctx context.Context, input json.RawMessage)
if uerr != nil {
return nil, fmt.Errorf("studio.render: prompt %s rendered but gallery upload failed: %w", pr.PromptID, uerr)
}
// The engine leaks ~58GB per render; recycling after each completed
// render caps it at one render's worth. Boot (~20s) is noise next to
// 8-70m renders. Never recycle on the timeout path — the engine may
// still be sampling and the mirror rescues late finishes.
requestStudioRecycle()
return map[string]any{"promptId": pr.PromptID, "outputs": outputs, "gallery": gallery}, nil
}
}
@@ -1014,6 +1031,12 @@ func (w *worker) mirrorRenders(ctx context.Context, out io.Writer, dir, base str
if werr != nil || info == nil || info.IsDir() || !isImageFile(p) {
return nil
}
// Hidden files and AppleDouble forks (`._*`, `.DS_Store`) ride along with
// mac scp and are not renders — `._foo.png` passes the extension check
// but is a 4KB resource fork that poisons the library.
if strings.HasPrefix(filepath.Base(p), ".") {
return nil
}
rel, rerr := filepath.Rel(dir, p)
if rerr != nil {
return nil
@@ -1148,14 +1171,47 @@ func (w *worker) materializeInputs(ctx context.Context, cl *http.Client, inputs
return nil
}
// collectOutputs pulls the output image/file names out of a ComfyUI history entry.
// waitEngine blocks until the local engine answers its /queue — up to 90s, which
// outlasts any supervisor recycle (engine restart is seconds, model reload longer).
func waitEngine(ctx context.Context, cl *http.Client) error {
deadline := time.Now().Add(90 * time.Second)
for {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, localComfyUI+"/queue", nil)
if err != nil {
return err
}
resp, err := cl.Do(req)
if err == nil {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<10))
resp.Body.Close()
if resp.StatusCode/100 == 2 {
return nil
}
}
if time.Now().After(deadline) {
return fmt.Errorf("engine not up: %v", err)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(3 * time.Second):
}
}
}
// collectOutputs pulls the output file names out of a ComfyUI history entry. Savers
// publish under different keys — SaveImage/SaveVideo under "images", SaveGLB under
// "3d" — so every saver's outputs are gathered, not just images (a 3D mesh would
// otherwise never travel back to the library).
func collectOutputs(entry json.RawMessage) []string {
type namedFile struct {
Filename string `json:"filename"`
Subfolder string `json:"subfolder"`
}
var e struct {
Outputs map[string]struct {
Images []struct {
Filename string `json:"filename"`
Subfolder string `json:"subfolder"`
} `json:"images"`
Images []namedFile `json:"images"`
ThreeD []namedFile `json:"3d"`
} `json:"outputs"`
}
if err := json.Unmarshal(entry, &e); err != nil {
@@ -1163,8 +1219,8 @@ func collectOutputs(entry json.RawMessage) []string {
}
var files []string
for _, node := range e.Outputs {
for _, img := range node.Images {
files = append(files, filepath.Join(img.Subfolder, img.Filename))
for _, f := range append(append([]namedFile{}, node.Images...), node.ThreeD...) {
files = append(files, filepath.Join(f.Subfolder, f.Filename))
}
}
return files
+110 -132
View File
@@ -12,11 +12,13 @@ import (
"time"
)
// Platform is a thin client over the platform.hanzo.ai /v1 control plane. That
// surface is machine-to-machine (service-token, "No OIDC" — it cannot validate
// IAM user tokens), so the token here is the platform service token, resolved
// from flag/env/credential store by the caller; the build endpoint takes its
// own token per call.
// Platform is a thin client over the LIVE Hanzo Cloud control plane
// (platform.hanzo.ai / api.hanzo.ai → svc `cloud`, the Go binary). Every route it
// calls is served by that one binary and authorized off ONE IAM identity: after
// `hanzo login` the CLI sends the IAM access token as the bearer, and the cloud's
// identity middleware (SanitizeIdentity) validates the JWT and org-scopes the
// caller — no separate platform/service token. A purpose-minted machine token
// still works (flag > env > credential store > IAM login) for automation.
type Platform struct {
baseURL string
token string
@@ -45,8 +47,8 @@ func (e *apiError) Error() string {
msg = http.StatusText(e.status)
}
hint := ""
if e.status == http.StatusUnauthorized {
hint = " (set the platform service token: --platform-token, HANZO_PLATFORM_TOKEN, or `hanzo login --platform-token`)"
if e.status == http.StatusUnauthorized || e.status == http.StatusForbidden {
hint = " (run `hanzo login` — your IAM identity authorizes the platform; admin ops need an org-admin or superadmin identity)"
}
return fmt.Sprintf("platform %s: HTTP %d: %s%s", e.path, e.status, msg, hint)
}
@@ -55,7 +57,7 @@ func (e *apiError) Error() string {
// into out (when non-nil) and mapping a non-2xx into an *apiError.
func (p *Platform) do(ctx context.Context, method, path, token string, body, out any) error {
if token == "" {
return fmt.Errorf("no platform token: pass --platform-token, set HANZO_PLATFORM_TOKEN, or run `hanzo login --platform-token <tok>`")
return fmt.Errorf("not authenticated: run `hanzo login` (an IAM login now authorizes the platform; a --platform-token / HANZO_PLATFORM_TOKEN still works for machine automation)")
}
var rdr io.Reader
if body != nil {
@@ -94,8 +96,8 @@ func (p *Platform) do(ctx context.Context, method, path, token string, body, out
return nil
}
// serverMessage pulls the `{ "message": … }` field platform errors use, falling
// back to the raw (truncated) body.
// serverMessage pulls the `{ "message": … }` / `{ "error": … }` field the cloud's
// errors use, falling back to the raw (truncated) body.
func serverMessage(raw []byte) string {
var e struct {
Message string `json:"message"`
@@ -117,33 +119,35 @@ func serverMessage(raw []byte) string {
}
// ---------------------------------------------------------------------------
// Apps board — GET /v1/apps, GET /v1/apps/{id}, POST /v1/apps/sync.
// Apps board — GET /v1/paas/apps, GET /v1/paas/apps/{app}. The live Go cloud's
// fleet drift board (clients/paas): the operator App CRs across the platform
// namespaces, declared/running/latest tags + health + the drift verdict. It is
// org-confined server-side (a SuperAdmin sees the fleet; an OrgAdmin only its own
// org), so the CLI sends NO org filter — identity scopes the view.
// ---------------------------------------------------------------------------
// AppView mirrors the platform apps-lifecycle DTO. Nullable columns are *string
// so JSON null round-trips; Drift is kept raw so --json is byte-faithful and
// the drift schema can evolve without a client bump.
// AppView mirrors clients/paas.AppView (the LIVE board DTO). Tags/health are plain
// strings ("" == unknown, rendered "-"); Drift is kept raw so --json is
// byte-faithful and the drift schema can evolve without a client bump.
type AppView struct {
ID string `json:"id"`
Org string `json:"org"`
App string `json:"app"`
Env string `json:"env"`
Repo string `json:"repo"`
Registry string `json:"registry"`
DeclaredTag *string `json:"declaredTag"`
RunningTag *string `json:"runningTag"`
LatestTag *string `json:"latestTag"`
ReleaseURL *string `json:"releaseUrl"`
ReleaseAssets int `json:"releaseAssets"`
Health *string `json:"health"`
Cluster *string `json:"cluster"`
Namespace *string `json:"namespace"`
LastObserved *string `json:"lastObserved"`
UpdatedAt string `json:"updatedAt"`
Drift json.RawMessage `json:"drift"`
ID string `json:"id"` // <org>/<app>/<env>, e.g. hanzoai/iam/main
Org string `json:"org"` // image namespace, e.g. hanzoai
App string `json:"app"`
Env string `json:"env"`
Repo string `json:"repo"`
Registry string `json:"registry"`
DeclaredTag string `json:"declaredTag"`
RunningTag string `json:"runningTag"`
LatestTag string `json:"latestTag"`
Health string `json:"health"`
Phase string `json:"phase"`
Cluster string `json:"cluster"`
Namespace string `json:"namespace"`
Endpoints []string `json:"endpoints"`
Drift json.RawMessage `json:"drift"`
}
// AppsList is the /v1/apps envelope: ordered rows + a drift summary.
// AppsList is the /v1/paas/apps envelope: ordered rows + a drift summary.
type AppsList struct {
Apps []AppView `json:"apps"`
Summary struct {
@@ -152,9 +156,10 @@ type AppsList struct {
} `json:"summary"`
}
// AppsQuery are the optional /v1/apps filters.
// AppsQuery are the optional /v1/paas/apps filters (server-honored). Env/Health/
// Drift narrow the board; there is deliberately no org filter — the board is
// confined to the caller's org by the validated identity, never a client value.
type AppsQuery struct {
Org string
Env string
Health string
Drift bool
@@ -162,9 +167,6 @@ type AppsQuery struct {
func (p *Platform) Apps(ctx context.Context, q AppsQuery) (*AppsList, error) {
v := url.Values{}
if q.Org != "" {
v.Set("org", q.Org)
}
if q.Env != "" {
v.Set("env", q.Env)
}
@@ -174,7 +176,7 @@ func (p *Platform) Apps(ctx context.Context, q AppsQuery) (*AppsList, error) {
if q.Drift {
v.Set("drift", "1")
}
path := "/v1/apps"
path := "/v1/paas/apps"
if len(v) > 0 {
path += "?" + v.Encode()
}
@@ -182,17 +184,11 @@ func (p *Platform) Apps(ctx context.Context, q AppsQuery) (*AppsList, error) {
return out, p.do(ctx, http.MethodGet, path, p.token, nil, out)
}
func (p *Platform) App(ctx context.Context, id, org string) (*AppView, error) {
path := "/v1/apps/" + id
if org != "" {
path += "?org=" + url.QueryEscape(org)
}
// App gets one app row by its <app> CR name (production by default; the server
// scans the caller's authorized namespaces main→test→dev).
func (p *Platform) App(ctx context.Context, app string) (*AppView, error) {
out := &AppView{}
return out, p.do(ctx, http.MethodGet, path, p.token, nil, out)
}
func (p *Platform) SyncApps(ctx context.Context) error {
return p.do(ctx, http.MethodPost, "/v1/apps/sync", p.token, nil, nil)
return out, p.do(ctx, http.MethodGet, "/v1/paas/apps/"+url.PathEscape(app), p.token, nil, out)
}
// driftSeverity extracts the severity string from the raw drift object.
@@ -207,110 +203,92 @@ func driftSeverity(raw json.RawMessage) string {
}
// ---------------------------------------------------------------------------
// Dedicated clusters — /v1/org/{org}/cluster[ /select | /{id}/install-baseline ].
// Clusters — GET /v1/clusters. The live Go cloud's compute fleet (clients/visor):
// Visor-managed node pools + the org's BYO clusters, tenant-scoped server-side by
// the validated org (?owner is the caller's IAM org). No org in the path.
// ---------------------------------------------------------------------------
// Cluster mirrors a doks_cluster record. `status` is DigitalOcean state; `phase`
// is the platform provisioning lifecycle — orthogonal (a DO-running cluster is
// not a usable target until phase=ready).
// NodePool mirrors clients/visor.nodePoolView.
type NodePool struct {
PoolID string `json:"poolId"`
Name string `json:"name"`
Size string `json:"size"`
Count int `json:"count"`
MinNodes int `json:"minNodes"`
MaxNodes int `json:"maxNodes"`
AutoScale bool `json:"autoScale"`
}
// Cluster mirrors clients/visor.clusterView — the LIVE cluster DTO. `kind` is
// "managed" (Visor-provisioned) or "byo" (attached kubeconfig).
type Cluster struct {
DoksClusterID string `json:"doksClusterId"`
Name string `json:"name"`
DoClusterID *string `json:"doClusterId"`
Region string `json:"region"`
Status string `json:"status"`
Endpoint *string `json:"endpoint"`
K8sVersion *string `json:"k8sVersion"`
HA bool `json:"ha"`
Phase string `json:"phase"`
OperatorInstalled bool `json:"operatorInstalled"`
BaselineInstalled bool `json:"baselineInstalled"`
Active bool `json:"active"`
BaselineError *string `json:"baselineError"`
OrganizationID string `json:"organizationId"`
CreatedAt string `json:"createdAt"`
Tags []string `json:"tags"`
MaintenancePolicy json.RawMessage `json:"maintenancePolicy,omitempty"`
DoksClusterID string `json:"doksClusterId"`
DoClusterID string `json:"doClusterId"`
Name string `json:"name"`
Region string `json:"region"`
Status string `json:"status"`
NodePools []NodePool `json:"nodePools"`
NodeSize string `json:"nodeSize"`
NodeCount int `json:"nodeCount"`
CreatedAt string `json:"createdAt"`
Kind string `json:"kind"`
NvidiaGPU int `json:"nvidiaGpu"`
AmdGPU int `json:"amdGpu"`
}
// ProvisionReq is the dedicated-cluster provisioning body (org forced by path).
type ProvisionReq struct {
Region string `json:"region,omitempty"`
HA bool `json:"ha,omitempty"`
NodeSize string `json:"nodeSize,omitempty"`
NodeCount int `json:"nodeCount,omitempty"`
// ID is the stable cluster identifier for display/lookup: the DOKS id when managed,
// else the name (a BYO cluster keys on its attached name).
func (c Cluster) ID() string {
if c.DoksClusterID != "" {
return c.DoksClusterID
}
return c.Name
}
// Target is the redacted ClusterTargetView — the kubeconfig is never present.
type Target struct {
Cluster string `json:"cluster"`
Namespaces map[string]string `json:"namespaces"`
Dedicated bool `json:"dedicated"`
}
func (p *Platform) Clusters(ctx context.Context, org string) ([]Cluster, error) {
func (p *Platform) Clusters(ctx context.Context) ([]Cluster, error) {
var out struct {
Clusters []Cluster `json:"clusters"`
}
err := p.do(ctx, http.MethodGet, "/v1/org/"+url.PathEscape(org)+"/cluster", p.token, nil, &out)
err := p.do(ctx, http.MethodGet, "/v1/clusters", p.token, nil, &out)
return out.Clusters, err
}
func (p *Platform) ProvisionCluster(ctx context.Context, org string, req ProvisionReq) (*Cluster, error) {
var out struct {
Cluster Cluster `json:"cluster"`
}
err := p.do(ctx, http.MethodPost, "/v1/org/"+url.PathEscape(org)+"/cluster", p.token, req, &out)
return &out.Cluster, err
}
func (p *Platform) Target(ctx context.Context, org string) (*Target, error) {
var out struct {
Target Target `json:"target"`
}
err := p.do(ctx, http.MethodGet, "/v1/org/"+url.PathEscape(org)+"/cluster/select", p.token, nil, &out)
return &out.Target, err
}
// SelectTarget activates a dedicated cluster as the org's deploy target, or
// reverts to the shared cluster when clusterID is nil.
func (p *Platform) SelectTarget(ctx context.Context, org string, clusterID *string) (*Target, error) {
var out struct {
Target Target `json:"target"`
}
body := map[string]any{"doksClusterId": clusterID}
err := p.do(ctx, http.MethodPost, "/v1/org/"+url.PathEscape(org)+"/cluster/select", p.token, body, &out)
return &out.Target, err
}
func (p *Platform) InstallBaseline(ctx context.Context, org, clusterID string) error {
path := "/v1/org/" + url.PathEscape(org) + "/cluster/" + url.PathEscape(clusterID) + "/install-baseline"
return p.do(ctx, http.MethodPost, path, p.token, nil, nil)
}
// ---------------------------------------------------------------------------
// Deploy — POST …/container/{id}/redeploy (rolling restart, zero-downtime).
// Deploy — POST /v1/paas/apps/{app}/deploy: a zero-downtime ROLLING RESTART of the
// app's Deployment (re-pulls the declared image, recreates pods). Org-confined
// server-side; an optional env selects the lifecycle namespace (main|test|dev).
// ---------------------------------------------------------------------------
// Redeploy triggers a rolling restart of the container's k8s Deployment. The
// coordinates are exact (the platform validates org+project+env+container scope).
func (p *Platform) Redeploy(ctx context.Context, org, project, env, container string) error {
path := fmt.Sprintf("/v1/org/%s/project/%s/env/%s/container/%s/redeploy",
url.PathEscape(org), url.PathEscape(project), url.PathEscape(env), url.PathEscape(container))
var out struct {
OK bool `json:"ok"`
// Redeploy triggers a rolling restart of the named app. env is optional
// (main|test|dev); empty targets production (the first match, main→test→dev).
func (p *Platform) Redeploy(ctx context.Context, app, env string) (*DeployResult, error) {
path := "/v1/paas/apps/" + url.PathEscape(app) + "/deploy"
if env != "" {
path += "?env=" + url.QueryEscape(env)
}
if err := p.do(ctx, http.MethodPost, path, p.token, nil, &out); err != nil {
return err
out := &DeployResult{}
if err := p.do(ctx, http.MethodPost, path, p.token, nil, out); err != nil {
return nil, err
}
if !out.OK {
return fmt.Errorf("redeploy did not report ok")
return nil, fmt.Errorf("redeploy did not report ok")
}
return nil
return out, nil
}
// DeployResult is the /deploy acceptance (202): the restarted app + its namespace.
type DeployResult struct {
OK bool `json:"ok"`
App string `json:"app"`
Namespace string `json:"namespace"`
Env string `json:"env"`
RestartedAt string `json:"restartedAt"`
}
// ---------------------------------------------------------------------------
// Build — POST /v1/runner (platform-native CI, no GitHub builders).
// Build — POST /v1/runner (platform-native CI, no GitHub builders). Authorized off
// the IAM login exactly like the surfaces above (or a dedicated build token for
// machine automation). Unchanged wire contract.
// ---------------------------------------------------------------------------
// BuildReq is the direct-enqueue body. Repo/SHA/Image are required.
@@ -337,11 +315,11 @@ type BuildJob struct {
Target string `json:"target"`
}
// EnqueueBuild enqueues a native build. It authenticates with the dedicated
// build-callback token, not the service token.
// EnqueueBuild enqueues a native build. buildToken is resolved by the caller (IAM
// login is the final fallback; a dedicated build token wins when present).
func (p *Platform) EnqueueBuild(ctx context.Context, req BuildReq, buildToken string) (*BuildJob, error) {
if buildToken == "" {
return nil, fmt.Errorf("no build token: set HANZO_BUILD_TOKEN / PLATFORM_BUILD_CALLBACK_TOKEN or `hanzo login --build-token <tok>`")
return nil, fmt.Errorf("not authenticated: run `hanzo login` (an IAM login now authorizes builds; HANZO_BUILD_TOKEN / --build-token still works for machine automation)")
}
out := &BuildJob{}
return out, p.do(ctx, http.MethodPost, "/v1/runner", buildToken, req, out)
+47 -91
View File
@@ -18,17 +18,22 @@ func platformStub(t *testing.T, token string, h http.HandlerFunc) (*Platform, fu
return newPlatform(srv.URL, token), srv.Close
}
// Apps hits the LIVE board /v1/paas/apps with the IAM bearer; it sends NO org
// filter (the board is org-confined server-side by the validated identity).
func TestPlatformAuthHeaderAndApps(t *testing.T) {
p, done := platformStub(t, "svc-tok", func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer svc-tok" {
t.Errorf("auth header = %q", got)
}
if r.URL.Path != "/v1/apps" {
t.Errorf("path = %s", r.URL.Path)
if r.URL.Path != "/v1/paas/apps" {
t.Errorf("path = %s, want /v1/paas/apps", r.URL.Path)
}
if r.URL.Query().Get("env") != "main" || r.URL.Query().Get("drift") != "1" {
t.Errorf("query = %s", r.URL.RawQuery)
}
if r.URL.Query().Has("org") {
t.Errorf("client must NOT send an org filter (identity confines the board): %s", r.URL.RawQuery)
}
_ = json.NewEncoder(w).Encode(AppsList{
Apps: []AppView{{ID: "hanzoai/iam/main", Org: "hanzoai", App: "iam", Env: "main", Drift: json.RawMessage(`{"severity":"red"}`)}},
})
@@ -47,126 +52,75 @@ func TestPlatformAuthHeaderAndApps(t *testing.T) {
}
}
// App hits /v1/paas/apps/{app}; no org query (identity scopes it).
func TestPlatformApp(t *testing.T) {
p, done := platformStub(t, "t", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/apps/hanzoai/iam/main" {
t.Errorf("path = %s", r.URL.Path)
if r.URL.Path != "/v1/paas/apps/iam" {
t.Errorf("path = %s, want /v1/paas/apps/iam", r.URL.Path)
}
if r.URL.Query().Get("org") != "hanzoai" {
t.Errorf("org query = %s", r.URL.RawQuery)
if r.URL.RawQuery != "" {
t.Errorf("app get must carry no query, got %s", r.URL.RawQuery)
}
_ = json.NewEncoder(w).Encode(AppView{ID: "hanzoai/iam/main", App: "iam"})
_ = json.NewEncoder(w).Encode(AppView{ID: "hanzoai/iam/main", App: "iam", Phase: "Running"})
})
defer done()
a, err := p.App(context.Background(), "hanzoai/iam/main", "hanzoai")
a, err := p.App(context.Background(), "iam")
if err != nil || a.App != "iam" {
t.Fatalf("App: %v %+v", err, a)
}
}
func TestPlatformSyncApps(t *testing.T) {
// Clusters hits the LIVE /v1/clusters (org from identity, not the path).
func TestPlatformClusters(t *testing.T) {
p, done := platformStub(t, "t", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/v1/apps/sync" {
t.Errorf("sync = %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(200)
})
defer done()
if err := p.SyncApps(context.Background()); err != nil {
t.Fatalf("SyncApps: %v", err)
}
}
func TestPlatformClustersAndProvision(t *testing.T) {
p, done := platformStub(t, "t", func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/v1/org/acme/cluster":
_ = json.NewEncoder(w).Encode(map[string]any{"clusters": []Cluster{{DoksClusterID: "c1", Name: "hanzo-acme", Region: "sfo3", Status: "running", Phase: "ready", Active: true}}})
case r.Method == http.MethodPost && r.URL.Path == "/v1/org/acme/cluster":
body, _ := io.ReadAll(r.Body)
var req ProvisionReq
_ = json.Unmarshal(body, &req)
if req.Region != "sfo3" || !req.HA {
t.Errorf("provision body = %+v", req)
}
w.WriteHeader(201)
_ = json.NewEncoder(w).Encode(map[string]any{"cluster": Cluster{DoksClusterID: "c2", Name: "new", Phase: "requested"}})
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
if r.Method != http.MethodGet || r.URL.Path != "/v1/clusters" {
t.Errorf("clusters = %s %s, want GET /v1/clusters", r.Method, r.URL.Path)
}
_ = json.NewEncoder(w).Encode(map[string]any{"clusters": []Cluster{
{DoksClusterID: "c1", Name: "hanzo-acme", Region: "sfo3", Status: "running", Kind: "managed", NodeCount: 3},
}})
})
defer done()
cs, err := p.Clusters(context.Background(), "acme")
if err != nil || len(cs) != 1 || cs[0].DoksClusterID != "c1" {
cs, err := p.Clusters(context.Background())
if err != nil || len(cs) != 1 || cs[0].ID() != "c1" || cs[0].Kind != "managed" {
t.Fatalf("Clusters: %v %+v", err, cs)
}
c, err := p.ProvisionCluster(context.Background(), "acme", ProvisionReq{Region: "sfo3", HA: true})
if err != nil || c.DoksClusterID != "c2" {
t.Fatalf("ProvisionCluster: %v %+v", err, c)
}
}
func TestPlatformTargetAndSelect(t *testing.T) {
p, done := platformStub(t, "t", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/org/acme/cluster/select" {
t.Errorf("path = %s", r.URL.Path)
}
if r.Method == http.MethodPost {
body, _ := io.ReadAll(r.Body)
var m map[string]any
_ = json.Unmarshal(body, &m)
if m["doksClusterId"] != "c1" {
t.Errorf("select body = %v", m)
}
}
_ = json.NewEncoder(w).Encode(map[string]any{"target": Target{Cluster: "hanzo-acme", Dedicated: true, Namespaces: map[string]string{"acme": "main"}}})
})
defer done()
tg, err := p.Target(context.Background(), "acme")
if err != nil || tg.Cluster != "hanzo-acme" || !tg.Dedicated {
t.Fatalf("Target: %v %+v", err, tg)
}
id := "c1"
if _, err := p.SelectTarget(context.Background(), "acme", &id); err != nil {
t.Fatalf("SelectTarget: %v", err)
}
}
func TestPlatformInstallBaseline(t *testing.T) {
p, done := platformStub(t, "t", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/v1/org/acme/cluster/c1/install-baseline" {
t.Errorf("install-baseline = %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(200)
})
defer done()
if err := p.InstallBaseline(context.Background(), "acme", "c1"); err != nil {
t.Fatalf("InstallBaseline: %v", err)
// A BYO cluster with no DOKS id keys on its name via ID().
func TestClusterIDFallsBackToName(t *testing.T) {
c := Cluster{Name: "byo-1", Kind: "byo"}
if c.ID() != "byo-1" {
t.Fatalf("ID() = %q, want byo-1", c.ID())
}
}
// Redeploy hits /v1/paas/apps/{app}/deploy (rolling restart), org from identity.
func TestPlatformRedeploy(t *testing.T) {
p, done := platformStub(t, "t", func(w http.ResponseWriter, r *http.Request) {
want := "/v1/org/acme/project/p1/env/e1/container/app-x/redeploy"
if r.Method != http.MethodPost || r.URL.Path != want {
t.Errorf("redeploy path = %s %s", r.Method, r.URL.Path)
if r.Method != http.MethodPost || r.URL.Path != "/v1/paas/apps/app-x/deploy" {
t.Errorf("redeploy = %s %s, want POST /v1/paas/apps/app-x/deploy", r.Method, r.URL.Path)
}
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true})
if r.URL.Query().Get("env") != "test" {
t.Errorf("env query = %s", r.URL.RawQuery)
}
w.WriteHeader(202)
_ = json.NewEncoder(w).Encode(DeployResult{OK: true, App: "app-x", Namespace: "hanzo-testnet", Env: "test", RestartedAt: "2026-07-18T00:00:00Z"})
})
defer done()
if err := p.Redeploy(context.Background(), "acme", "p1", "e1", "app-x"); err != nil {
t.Fatalf("Redeploy: %v", err)
res, err := p.Redeploy(context.Background(), "app-x", "test")
if err != nil || res.Namespace != "hanzo-testnet" {
t.Fatalf("Redeploy: %v %+v", err, res)
}
}
func TestPlatformRedeployNotOK(t *testing.T) {
p, done := platformStub(t, "t", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": false})
_ = json.NewEncoder(w).Encode(DeployResult{OK: false})
})
defer done()
if err := p.Redeploy(context.Background(), "o", "p", "e", "c"); err == nil {
if _, err := p.Redeploy(context.Background(), "c", ""); err == nil {
t.Fatalf("expected error when ok=false")
}
}
@@ -207,14 +161,16 @@ func TestPlatformError401Hint(t *testing.T) {
})
defer done()
_, err := p.Apps(context.Background(), AppsQuery{})
if err == nil || !strings.Contains(err.Error(), "HTTP 401") || !strings.Contains(err.Error(), "platform service token") {
t.Fatalf("401 error should carry a token hint, got %v", err)
if err == nil || !strings.Contains(err.Error(), "HTTP 401") || !strings.Contains(err.Error(), "hanzo login") {
t.Fatalf("401 error should point at `hanzo login`, got %v", err)
}
}
func TestPlatformNoTokenError(t *testing.T) {
p := newPlatform("https://platform.hanzo.ai", "")
if _, err := p.Apps(context.Background(), AppsQuery{}); err == nil || !strings.Contains(err.Error(), "no platform token") {
t.Fatalf("expected no-token error, got %v", err)
// After unify-infra, the "no credential" error points at `hanzo login` — the one
// identity that authorizes the platform — not a separate platform token.
if _, err := p.Apps(context.Background(), AppsQuery{}); err == nil || !strings.Contains(err.Error(), "hanzo login") {
t.Fatalf("expected a `hanzo login` hint, got %v", err)
}
}
+69 -6
View File
@@ -12,6 +12,7 @@ package cli
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
@@ -20,6 +21,7 @@ import (
"os/exec"
"path/filepath"
"strconv"
"sync/atomic"
"syscall"
"time"
)
@@ -107,6 +109,48 @@ func stopStudio(cmd *exec.Cmd) {
}
}
// studioRecycle carries at most one pending recycle request; the render
// handler signals it after each completed render (see gpu.go).
var studioRecycle = make(chan struct{}, 1)
// staging guards the claim-to-submit window: a claimed job is real work the
// engine queue cannot see yet, so the supervisor must never recycle over it
// (observed: jobs claimed during a recycle failed staging on a dead engine
// and were consumed).
var staging atomic.Int32
func requestStudioRecycle() {
select {
case studioRecycle <- struct{}{}:
default:
}
}
// studioBusy reports whether the engine holds queued or running prompts.
// A generous timeout: a saturated GB10 answers slowly mid-render — slow is
// alive, and killing a live render costs 8-70 minutes of GPU work.
func studioBusy(ctx context.Context) (busy, ok bool) {
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+studioAddr+"/queue", nil)
if err != nil {
return false, false
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return false, false
}
defer resp.Body.Close()
var q struct {
Running []json.RawMessage `json:"queue_running"`
Pending []json.RawMessage `json:"queue_pending"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 32<<20)).Decode(&q); err != nil {
return false, false
}
return len(q.Running)+len(q.Pending) > 0, true
}
// superviseStudio keeps the local render backend on :8188 alive until ctx
// ends. Quiet by design: one line per restart event, not a probe firehose.
func superviseStudio(ctx context.Context, dir string, out io.Writer) {
@@ -138,6 +182,12 @@ func superviseStudio(ctx context.Context, dir string, out io.Writer) {
tick := time.NewTicker(studioProbeEvery)
defer tick.Stop()
// recyclePending defers the post-render recycle until the queue is EMPTY:
// short jobs complete while a long render is mid-sample, and recycling on
// their completion killed the live render (observed: every direct render
// died within ~6 minutes while probe jobs cycled).
recyclePending := false
unhealthy := 0
for {
select {
case <-ctx.Done():
@@ -145,19 +195,32 @@ func superviseStudio(ctx context.Context, dir string, out io.Writer) {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM)
}
return
case <-studioRecycle:
recyclePending = true
case <-tick.C:
busy, ok := studioBusy(ctx)
if recyclePending && ok && !busy && staging.Load() == 0 {
recyclePending = false
unhealthy = 0
restart("recycle")
continue
}
if studioHealthy(ctx) {
unhealthy = 0
continue
}
// Grace re-check: it may be momentarily busy mid-render.
select {
case <-ctx.Done():
if ok && busy {
// Alive-busy: slow health under render load is not death.
unhealthy = 0
continue
case <-time.After(studioGraceWait):
}
if !studioHealthy(ctx) {
restart("unresponsive")
// Sustained silence with an idle or unreadable queue = actually dead.
unhealthy++
if unhealthy < 3 {
continue
}
unhealthy = 0
restart("unresponsive")
}
}
}
+61 -11
View File
@@ -30,6 +30,7 @@ package account
import (
"bytes"
"crypto/subtle"
"encoding/json"
"net/http"
"net/url"
@@ -39,6 +40,7 @@ import (
"github.com/hanzoai/account"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/principal"
"github.com/zap-proto/zip"
)
@@ -107,6 +109,7 @@ var billingForwardable = map[string][]string{
"subscriptions",
"payment-methods",
"spend-alerts",
"spend-alerts/authorize", // the S2S cap-verdict read (metering gate); 2 segments need their own entry
"payment-config",
"plans",
"payouts",
@@ -241,9 +244,26 @@ func commerceCreds() (base, token string) {
func billingData(s *cloud.Service[state], c *zip.Ctx) error {
// IDOR boundary: the subject is the VALIDATED caller's own org/user, never a client
// value. requireOwner=true — billing is always org-scoped (a zero-org user has none).
// Auth. A browser caller is the VALIDATED principal (customer path — subject-pinned
// below). An IN-PROC S2S caller carries the verified COMMERCE_SERVICE_TOKEN (the
// metering cap-gate's authorize + the SuperAdmin cap-oversight Forward). The gateway
// 401s a public Bearer that is not an IAM JWT / hk-|pk-|sk- key (the 64-hex service
// token fails JWT parse at the edge), so an EXTERNAL client can NEVER present it here —
// an unauthenticated caller still hits the 403 below. On the S2S path the caller
// legitimately names its own subject, so its query is forwarded as-is (no pin), scoped
// only by the EdgeAuth-controlled X-Org-Id.
cr, ok := resolveCaller(c, true)
s2s := false
owner := cr.owner
if !ok {
return zip.ErrForbidden("sign in to view billing")
if !s2sBillingCall(c) {
return zip.ErrForbidden("sign in to view billing")
}
owner = strings.TrimSpace(c.Org()) // trusted X-Org-Id (never a client value on a public call)
if owner == "" {
return zip.ErrForbidden("sign in to view billing")
}
s2s = true
}
method := c.Method()
@@ -277,20 +297,32 @@ func billingData(s *cloud.Service[state], c *zip.Ctx) error {
// Scope EVERY request to the caller's OWN subject — query AND write body — so
// commerce's per-tenant isolation can never be crossed from the browser. The
// subject comes from the ONE rule (ai/object.Payer), keyed on the IAM username
// (cr.username = X-User-Name) the gate also keys on — so a top-up credits the
// SAME account the gate debits. Keying on cr.name (X-User-Id, a UUID on the
// direct-bearer path) would fund an account the gate never reads: the split.
subject := account.Payer(account.Credential{Owner: cr.owner, Name: cr.username}).Subject()
// subject comes from the ONE rule (ai/object.Payer), fed the account the
// credential NAMES (the validated `billing_account` claim) — the same claim the
// ai gate reads, so a top-up credits the SAME account the gate debits. Feeding
// Payer a different credential here than the gate gets is the modern shape of
// the old split: money landing in an account the gate never reads.
inQuery, _ := url.ParseQuery(string(c.Fiber().Request().URI().QueryString()))
q := scopedBillingSearch(inQuery, subject)
var q url.Values
var body []byte
if method == http.MethodPost {
body = scopedBillingBody(c.Body(), subject)
if s2s {
// Trusted S2S caller: forward its query/body VERBATIM — it legitimately names the
// subject (e.g. the metering gate's ?user=<org>&amount=). Scoped by X-Org-Id.
q = inQuery
if method == http.MethodPost {
body = c.Body()
}
} else {
// Browser customer: pin EVERY subject key to the caller's OWN account so commerce's
// per-tenant isolation can never be crossed from the client.
subject := account.Payer(account.Credential{Owner: cr.owner, Name: cr.username, Account: principal.BillingAccount(c)}).Subject()
q = scopedBillingSearch(inQuery, subject)
if method == http.MethodPost {
body = scopedBillingBody(c.Body(), subject)
}
}
raw, status, err := commerceDo(c.Context(), base, token, method, "/v1/billing/"+sub, q, cr.owner, body)
raw, status, err := commerceDo(c.Context(), base, token, method, "/v1/billing/"+sub, q, owner, body)
if err != nil {
return zip.Errorf(http.StatusBadGateway, "billing upstream unreachable: %v", err)
}
@@ -300,3 +332,21 @@ func billingData(s *cloud.Service[state], c *zip.Ctx) error {
c.SetHeader("Cache-Control", "no-store, must-revalidate")
return c.Bytes(status, raw)
}
// s2sBillingCall reports whether the request carries the verified COMMERCE_SERVICE_TOKEN
// as its Bearer — a trusted IN-PROC service-to-service caller (the metering cap-gate's
// authorize, the SuperAdmin cap-oversight Forward). It is the SAME secret this bridge
// already forwards WITH, so admitting a caller who already holds it grants no authority it
// could not otherwise wield. Safety rests on the edge: the gateway 401s a public Bearer
// that is not an IAM JWT / hk-|pk-|sk- API key (the 64-hex service token is a JWT
// candidate that fails to parse), so an EXTERNAL client can never reach this handler
// holding it — only in-proc commerceinproc dispatch does. Constant-time compare; the token
// is never logged.
func s2sBillingCall(c *zip.Ctx) bool {
_, token := commerceCreds()
if token == "" {
return false
}
bearer := strings.TrimSpace(strings.TrimPrefix(c.Header("Authorization"), "Bearer "))
return bearer != "" && subtle.ConstantTimeCompare([]byte(bearer), []byte(token)) == 1
}
+62
View File
@@ -232,3 +232,65 @@ func TestBilling_RejectsTraversalSegment(t *testing.T) {
t.Fatalf("a traversal must never reach commerce, but upstream saw %q", f.path)
}
}
// ── S2S service-token admission (the auth fix; 4 security invariants) ─────────
// Invariant #4 — THE SECURITY GATE: a public/unauthenticated caller (no validated
// principal AND not the service token) STILL gets 403 on the spend-alert routes, incl.
// a WRONG bearer. The fix must NEVER open billing to the world.
func TestBilling_S2S_PublicStill403(t *testing.T) {
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-tok")
app := mountApp(t, "http://iam.invalid", "", "")
for _, path := range []string{
"/v1/billing/spend-alerts",
"/v1/billing/spend-alerts/authorize?user=acme&amount=1",
} {
// forged X-Org-Id, no validated principal, no service token
if code, body := callH(t, app, http.MethodGet, path, map[string]string{"X-Org-Id": "victim"}, ""); code != http.StatusForbidden {
t.Fatalf("public caller to %s: want 403, got %d (%s)", path, code, body)
}
}
// a WRONG bearer is still just a public caller → 403
if code, _ := callH(t, app, http.MethodGet, "/v1/billing/spend-alerts/authorize?user=acme&amount=1",
map[string]string{"X-Org-Id": "acme", "Authorization": "Bearer not-the-token"}, ""); code != http.StatusForbidden {
t.Fatalf("wrong bearer: want 403")
}
}
// The trusted in-proc S2S caller (verified COMMERCE_SERVICE_TOKEN + X-Org-Id) is admitted
// and its authorize query is forwarded to commerce VERBATIM (a trusted caller names its
// own subject), scoped by X-Org-Id — this is what lets the cap gate reach AuthorizeSpendCap.
func TestBilling_S2S_ServiceTokenForwardsVerbatim(t *testing.T) {
f := &fakeBilling{}
t.Setenv("COMMERCE_URL", f.server(t).URL)
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-tok")
app := mountApp(t, "http://iam.invalid", "", "")
code, body := callH(t, app, http.MethodGet,
"/v1/billing/spend-alerts/authorize?user=acme&amount=100&project=P",
map[string]string{"Authorization": "Bearer svc-tok", "X-Org-Id": "acme"}, "")
if code != http.StatusOK {
t.Fatalf("S2S authorize: want 200, got %d (%s)", code, body)
}
if f.path != "/v1/billing/spend-alerts/authorize" {
t.Fatalf("forwarded path = %q", f.path)
}
// VERBATIM: the S2S caller's ?user/?amount/?project reach commerce un-pinned.
if f.query.Get("user") != "acme" || f.query.Get("amount") != "100" || f.query.Get("project") != "P" {
t.Fatalf("S2S query must forward verbatim, got %v", f.query)
}
if f.org != "acme" || f.auth != "Bearer svc-tok" {
t.Fatalf("S2S must send X-Org-Id=acme + service token, got org=%q auth=%q", f.org, f.auth)
}
}
// S2S with the verified token but NO X-Org-Id → 403 (no org to scope the privileged
// forward to; never fall back to a client value).
func TestBilling_S2S_NoOrg403(t *testing.T) {
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-tok")
app := mountApp(t, "http://iam.invalid", "", "")
if code, _ := callH(t, app, http.MethodGet, "/v1/billing/spend-alerts/authorize?user=acme&amount=1",
map[string]string{"Authorization": "Bearer svc-tok"}, ""); code != http.StatusForbidden {
t.Fatalf("S2S without X-Org-Id: want 403")
}
}
+12 -2
View File
@@ -38,7 +38,10 @@ import (
"github.com/hanzoai/cloud/clients/admin/finance"
"github.com/hanzoai/cloud/clients/admin/health"
"github.com/hanzoai/cloud/clients/admin/iam"
"github.com/hanzoai/cloud/clients/admin/invoices"
"github.com/hanzoai/cloud/clients/admin/metrics"
"github.com/hanzoai/cloud/clients/admin/revenue"
"github.com/hanzoai/cloud/clients/admin/subscriptions"
"github.com/hanzoai/cloud/clients/commerceinproc"
"github.com/hanzoai/cloud/clients/principal"
"github.com/zap-proto/zip"
@@ -111,18 +114,25 @@ func routes(app *zip.App, s *cloud.Service[core.State]) {
app.Get("/v1/admin/flags", core.Guard(s, flagsBoard))
app.Put("/v1/admin/flags/:key", core.Guard(s, setFlag))
// Launch-control services board — the waitlist-mode lens on the flag engine (twin
// of /v1/admin/flags), folded in from the former featuregate control plane.
// of /v1/admin/flags), reading the registry + decide the admission gate owns.
app.Get("/v1/admin/services", core.Guard(s, services))
app.Post("/v1/admin/services", core.Guard(s, upsertService))
app.Post("/v1/admin/services/:service/mode", core.Guard(s, setServiceMode))
app.Get("/v1/admin/waitlist", core.Guard(s, waitlist))
app.Post("/v1/admin/waitlist/boost", core.Guard(s, waitlistBoost))
// ── Carved-out domains own their routes (audit/customer/revenue/finance). ──
// Usage-cap + promo control plane (promos platform-only; spend-caps org-scoped).
limitRoutes(app, s)
// ── Carved-out domains own their routes (audit/customer/revenue/finance +
// the billing fleet views metrics/invoices/subscriptions). ──
audit.Routes(app, s)
customer.Routes(app, s)
revenue.Routes(app, s)
finance.Routes(app, s)
metrics.Routes(app, s)
invoices.Routes(app, s)
subscriptions.Routes(app, s)
}
// ── /v1/admin/me — operator identity (AdminMe) ───────────────────────────────
+273
View File
@@ -295,6 +295,237 @@ func (c *Client) Deposit(ctx context.Context, subject string, amount money.Cents
return out, nil
}
// ── SaaS-metrics god-view (fleet-wide, org-independent) ──────────────────────
// SaaSMetrics mirrors commerce's GET /v1/metrics/saas snapshot — the whole-business
// SaaS-operations aggregate (MRR/ARR, new/churn, plan mix, top customers, recent
// movements) computed IN commerce across every org namespace. It is org-INDEPENDENT
// (like Costs) so the reader sends NO subject. Only the fields the admin god-view
// renders are modeled; commerce fields we don't consume (upgrades/downgrades,
// untagged-request counts) are simply ignored by the decoder.
type SaaSMetrics struct {
AsOf string `json:"asOf"`
Currency string `json:"currency"`
Window string `json:"window"`
Revenue SaaSRevenue `json:"revenue"`
Subs SaaSSubs `json:"subscriptions"`
Usage SaaSUsage `json:"usage"`
Customers []SaaSCustomer `json:"customers"`
Orgs int `json:"orgs"`
Gaps []string `json:"gaps"`
}
// SaaSRevenue is the recurring-revenue headline (run-rate MRR/ARR + windowed movement).
type SaaSRevenue struct {
MRRCents money.Cents `json:"mrrCents"`
ARRCents money.Cents `json:"arrCents"`
ActiveSubscriptions int `json:"activeSubscriptions"`
PayingCustomers int `json:"payingCustomers"`
Trials int `json:"trials"`
NewMRRCents money.Cents `json:"newMrrCents"`
ChurnedMRRCents money.Cents `json:"churnedMrrCents"`
NetNewMRRCents money.Cents `json:"netNewMrrCents"`
ByCategory []SaaSCategory `json:"byCategory"`
}
// SaaSCategory is one plan-category bucket of run-rate MRR (the plan mix).
type SaaSCategory struct {
Category string `json:"category"`
MRRCents money.Cents `json:"mrrCents"`
Subscriptions int `json:"subscriptions"`
}
// SaaSSubs is the subscription-operations panel (per-plan mix, trials, new/canceled,
// recent movements).
type SaaSSubs struct {
ByPlan []SaaSPlan `json:"byPlan"`
TrialsActive int `json:"trialsActive"`
New int `json:"new"`
Canceled int `json:"canceled"`
Recent []SaaSEvent `json:"recent"`
}
// SaaSPlan is one plan's active/trialing counts, seats, and MRR contribution.
type SaaSPlan struct {
Plan string `json:"plan"`
Name string `json:"name"`
Category string `json:"category"`
Active int `json:"active"`
Trialing int `json:"trialing"`
Seats int `json:"seats"`
MRRCents money.Cents `json:"mrrCents"`
}
// SaaSEvent is one recent subscription movement ("created" or "canceled").
type SaaSEvent struct {
At string `json:"at"`
Org string `json:"org"`
Type string `json:"type"`
Plan string `json:"plan"`
Category string `json:"category"`
MRRDeltaCents money.Cents `json:"mrrDeltaCents"`
}
// SaaSUsage is the metered / pay-as-you-go revenue headline for the window.
type SaaSUsage struct {
Instrumented bool `json:"instrumented"`
WindowUsageCents money.Cents `json:"windowUsageCents"`
Requests int64 `json:"requests"`
}
// SaaSCustomer is one top customer by MRR + windowed usage.
type SaaSCustomer struct {
Org string `json:"org"`
Plan string `json:"plan"`
Category string `json:"category"`
Status string `json:"status"`
MRRCents money.Cents `json:"mrrCents"`
UsageCents money.Cents `json:"usageCents"`
Seats int `json:"seats"`
Since string `json:"since,omitempty"`
}
// Metrics reads the fleet SaaS-operations god-view (GET /v1/metrics/saas). Like Costs it
// is org-INDEPENDENT — the engine walks every org namespace itself — so it authenticates
// with the admin S2S service token and sends NO subject. Empty (not an error) when
// commerce is unwired, so a partial deploy degrades to an honest empty snapshot.
func (c *Client) Metrics(ctx context.Context, window string, limit int) (SaaSMetrics, error) {
var out SaaSMetrics
if !c.Ready() {
return out, nil
}
q := url.Values{}
if window != "" {
q.Set("window", window)
}
if limit > 0 {
q.Set("limit", fmt.Sprintf("%d", limit))
}
body, err := c.get(ctx, "/v1/metrics/saas", q, "")
if err != nil {
return out, err
}
if err := json.Unmarshal(body, &out); err != nil {
return out, fmt.Errorf("commerce metrics decode: %w", err)
}
return out, nil
}
// ── billing invoices + subscriptions (per-subject fleet rows) ────────────────
// Invoice is one issued invoice as the fleet god-view renders it: the id (for a future
// /v1/billing/invoices/:id detail fetch), the human number, status, amount due,
// currency, and the issue/due dates. Sourced from GET /v1/billing/invoices
// (invoiceResponse); all timestamps are RFC3339 strings.
type Invoice struct {
ID string `json:"id"`
Number string `json:"numberStr"`
Status string `json:"status"`
AmountDue money.Cents `json:"amountDue"`
Currency string `json:"currency"`
Issued string `json:"createdAt"`
Due string `json:"dueDate"`
}
// Invoices lists a subject's invoices (GET /v1/billing/invoices), optionally filtered by
// status. The subject selects the org's billing namespace via X-Org-Id (trusted only
// after the service-token bearer verifies). Empty (not an error) when commerce is unwired.
func (c *Client) Invoices(ctx context.Context, subject, status string) ([]Invoice, error) {
if !c.Ready() {
return nil, nil
}
q := url.Values{}
if status != "" {
q.Set("status", status)
}
body, err := c.get(ctx, "/v1/billing/invoices", q, subject)
if err != nil {
return nil, err
}
var wrap struct {
Invoices []Invoice `json:"invoices"`
}
if err := json.Unmarshal(body, &wrap); err != nil {
return nil, fmt.Errorf("commerce invoices decode: %w", err)
}
return wrap.Invoices, nil
}
// Subscription is one subscription row the fleet god-view renders: the id, the buyer
// (userId), plan tier, status, monthly-normalized MRR, and the current-period
// start/end (started/renews). MRR reuses monthlyNormalized so a yearly plan is
// comparable to a monthly one in the fleet total.
type Subscription struct {
ID string `json:"id"`
User string `json:"user"`
Plan string `json:"plan"`
Status string `json:"status"`
MRR money.Cents `json:"mrrCents"`
Started string `json:"started"`
Renews string `json:"renews"`
}
// subscriptionRowWire is the /v1/billing/subscriptions row shape the fleet view folds —
// richer than subscriptionsWire (which Plan() uses for the MRR sum alone).
type subscriptionRowWire struct {
ID string `json:"id"`
UserID string `json:"userId"`
PlanID string `json:"planId"`
Status string `json:"status"`
Created string `json:"createdAt"`
PeriodStart string `json:"currentPeriodStart"`
PeriodEnd string `json:"currentPeriodEnd"`
Plan struct {
Name string `json:"name"`
Price money.Cents `json:"price"`
Interval string `json:"interval"`
} `json:"plan"`
}
// Subscriptions lists a subject's subscriptions (GET /v1/billing/subscriptions),
// optionally filtered by status, as fleet rows with a monthly-normalized MRR. Empty (not
// an error) when commerce is unwired.
func (c *Client) Subscriptions(ctx context.Context, subject, status string) ([]Subscription, error) {
if !c.Ready() {
return nil, nil
}
q := url.Values{}
if status != "" {
q.Set("status", status)
}
body, err := c.get(ctx, "/v1/billing/subscriptions", q, subject)
if err != nil {
return nil, err
}
var wrap struct {
Subscriptions []subscriptionRowWire `json:"subscriptions"`
}
if err := json.Unmarshal(body, &wrap); err != nil {
return nil, fmt.Errorf("commerce subscriptions decode: %w", err)
}
out := make([]Subscription, 0, len(wrap.Subscriptions))
for _, s := range wrap.Subscriptions {
name := strings.TrimSpace(s.Plan.Name)
if name == "" {
name = strings.TrimSpace(s.PlanID)
}
started := strings.TrimSpace(s.Created)
if started == "" {
started = s.PeriodStart
}
out = append(out, Subscription{
ID: s.ID,
User: s.UserID,
Plan: name,
Status: s.Status,
MRR: monthlyNormalized(s.Plan.Price, s.Plan.Interval),
Started: started,
Renews: s.PeriodEnd,
})
}
return out, nil
}
// post performs one admin-authenticated commerce POST (JSON body) and returns the
// raw response. The admin S2S service token is the bearer and X-Org-Id=<subject>
// the per-org namespace selector commerce's EdgeAuth trusts only after verifying
@@ -332,6 +563,48 @@ func (c *Client) post(ctx context.Context, path, subject string, body []byte, id
return respBody, nil
}
// Forward proxies an admin-authenticated request to commerce VERBATIM and returns
// the raw body + status. It is the ONE seam a SuperAdmin surface drives commerce's
// own endpoints through — the platform plan-promo config (/v1/platform/promo) and a
// per-org spend-alert override (/v1/billing/spend-alerts) — without a typed method
// per shape. subject is the X-Org-Id namespace selector (the target org for a cap
// override, or the admin org for platform config); body is nil for GET/DELETE. The
// status is returned so the caller surfaces commerce's OWN verdict (400 validation,
// 403, 404) instead of flattening every non-2xx into one code.
func (c *Client) Forward(ctx context.Context, method, path, subject string, body []byte) ([]byte, int, error) {
if !c.Ready() {
return nil, 0, errUnconfigured
}
var rdr io.Reader
if body != nil {
rdr = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, c.base+path, rdr)
if err != nil {
return nil, 0, err
}
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
if subject != "" {
req.Header.Set("X-Org-Id", subject)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("commerce unreachable: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, resp.StatusCode, err
}
return raw, resp.StatusCode, nil
}
// get performs one admin-authenticated commerce GET and returns the raw body.
func (c *Client) get(ctx context.Context, path string, q url.Values, subject string) ([]byte, error) {
u := c.base + path
+137
View File
@@ -0,0 +1,137 @@
// Package invoices is the fleet INVOICE view (/v1/admin/invoices) — every issued
// invoice across every tenant: number, org, amount, status, issue + due date, plus the
// id a future detail view fetches /v1/billing/invoices/:id with. SuperAdmin only
// (core.Guard).
//
// Commerce billing is per-tenant (an invoice lives in its org's own datastore
// namespace), so — like revenue — this fans out the org directory concurrently and
// reads each org's invoices via the admin S2S seam, tagging every row with its owning
// org. Best-effort per org: an org whose invoice read fails contributes NO rows rather
// than failing the fleet view (the SAME honest-degradation contract the customer list
// uses; an unreachable commerce yields an empty list, never fabricated rows). Optional
// ?org= scopes to one tenant, ?status= filters, ?limit= caps the merged list.
package invoices
import (
"context"
"sort"
"strconv"
"strings"
"sync"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/admin/core"
"github.com/hanzoai/cloud/clients/admin/iam"
"github.com/zap-proto/zip"
)
// defaultLimit caps the merged fleet invoice list when the caller sends none.
const defaultLimit = 500
// InvoiceRow is one row of GET /v1/admin/invoices — an issued invoice at a glance,
// tagged with its owning org. Money is USD cents; timestamps are RFC3339 strings.
type InvoiceRow struct {
ID string `json:"id"`
Number string `json:"number"`
Org string `json:"org"`
Display string `json:"display"`
Status string `json:"status"`
AmountCents int64 `json:"amountCents"`
Currency string `json:"currency"`
Issued string `json:"issued"`
Due string `json:"due"`
}
// Invoices answers GET /v1/admin/invoices.
//
// GET /v1/admin/invoices?org=&status=&limit=
func Invoices(s *cloud.Service[core.State], c *zip.Ctx) error {
ctx := c.Context()
cr := core.CallerCreds(c)
status := strings.TrimSpace(c.Query("status"))
wantOrg := strings.TrimSpace(c.Query("org"))
limit := parseLimit(c.Query("limit"))
orgs, err := core.ListOrgs(s, ctx, cr)
if err != nil {
return core.Fail(c, err.Error())
}
if wantOrg != "" {
orgs = filterOrg(orgs, wantOrg)
}
// Per-org invoices, fanned out concurrently (best-effort per org).
perOrg := make([][]InvoiceRow, len(orgs))
sem := make(chan struct{}, core.MaxCustomerConcurrency)
var wg sync.WaitGroup
for i, o := range orgs {
wg.Add(1)
sem <- struct{}{}
go func(i int, o iam.Org) {
defer wg.Done()
defer func() { <-sem }()
perOrg[i] = invoicesOf(s, ctx, o, status)
}(i, o)
}
wg.Wait()
rows := make([]InvoiceRow, 0)
for _, r := range perOrg {
rows = append(rows, r...)
}
// Newest issued first; cap to the merged limit (total reports the full pre-cap count).
sort.Slice(rows, func(i, j int) bool { return rows[i].Issued > rows[j].Issued })
total := len(rows)
if len(rows) > limit {
rows = rows[:limit]
}
return core.OKList(c, rows, total)
}
// invoicesOf reads one org's invoices into fleet rows, tagged with the org. Best-effort:
// a failed read yields no rows so the fleet view degrades honestly, never fabricating.
func invoicesOf(s *cloud.Service[core.State], ctx context.Context, o iam.Org, status string) []InvoiceRow {
entries, err := s.State.Commerce.Invoices(ctx, o.Name, status)
if err != nil {
return nil
}
display := core.Display(o.DisplayName, o.Name)
rows := make([]InvoiceRow, 0, len(entries))
for _, inv := range entries {
rows = append(rows, InvoiceRow{
ID: inv.ID,
Number: inv.Number,
Org: o.Name,
Display: display,
Status: inv.Status,
AmountCents: int64(inv.AmountDue),
Currency: inv.Currency,
Issued: inv.Issued,
Due: inv.Due,
})
}
return rows
}
// filterOrg narrows the directory to the one requested org (empty when it does not
// exist — an honest empty list, never a fabricated tenant).
func filterOrg(orgs []iam.Org, want string) []iam.Org {
for _, o := range orgs {
if o.Name == want {
return []iam.Org{o}
}
}
return nil
}
// parseLimit clamps the merged-list cap to [1,5000], defaulting to defaultLimit.
func parseLimit(s string) int {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n <= 0 {
return defaultLimit
}
if n > 5000 {
return 5000
}
return n
}
+12
View File
@@ -0,0 +1,12 @@
package invoices
import (
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/admin/core"
"github.com/zap-proto/zip"
)
// Routes registers the fleet invoice view (SuperAdmin only, cross-tenant).
func Routes(app *zip.App, s *cloud.Service[core.State]) {
app.Get("/v1/admin/invoices", core.Guard(s, Invoices))
}
+143
View File
@@ -0,0 +1,143 @@
package admin
import (
"encoding/json"
"net/http"
"net/url"
"strings"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/admin/core"
"github.com/zap-proto/zip"
)
// The SuperAdmin usage-cap + promo control plane, twinning /v1/admin/flags. It owns
// no store: it FORWARDS to commerce (the billing source of truth) over the ONE
// service-token seam —
//
// promos → commerce /v1/platform/promo (the admin-configured plan promo)
// spend-caps → commerce /v1/billing/spend-alerts (a per-org usage cap override)
//
// so admin.hanzo.ai configures the 50%-off promo and oversees/overrides any org's
// caps without a parallel model. Promo routes are platform-only (core.Guard); cap
// routes are org-scoped (core.GuardScoped) so a SuperAdmin targets any org via ?org=
// while a lesser admin is hard-pinned to their own.
// limitRoutes registers the promo + cap control plane. Called from routes().
func limitRoutes(app *zip.App, s *cloud.Service[core.State]) {
// Platform plan promo — SuperAdmin only.
app.Get("/v1/admin/promos", core.Guard(s, getPromo))
app.Put("/v1/admin/promos", core.Guard(s, putPromo))
// Per-org usage-cap oversight/override — SuperAdmin (any org via ?org=) or an org
// admin (own org only). Reuses the customer's OWN self-service spend-alert CRUD,
// so a platform override and a customer edit are the same rows.
app.Get("/v1/admin/spend-caps", core.GuardScoped(s, listSpendCaps))
app.Post("/v1/admin/spend-caps", core.GuardScoped(s, createSpendCap))
app.Patch("/v1/admin/spend-caps/:id", core.GuardScoped(s, updateSpendCap))
app.Delete("/v1/admin/spend-caps/:id", core.GuardScoped(s, deleteSpendCap))
}
// getPromo returns the current platform plan promo. X-Org-Id is the admin org —
// commerce stores the singleton in the reserved platform namespace regardless, and
// the service token is what passes commerce's RequirePlatformAdmin.
func getPromo(s *cloud.Service[core.State], c *zip.Ctx) error {
raw, status, err := s.State.Commerce.Forward(c.Context(), http.MethodGet, "/v1/platform/promo", s.State.AdminOrg, nil)
return relay(c, raw, status, err)
}
// putPromo upserts the platform plan promo from the SuperAdmin's {percentOff,start,
// end,plans,active} body — the ONE place the 50%-off offer is configured.
func putPromo(s *cloud.Service[core.State], c *zip.Ctx) error {
raw, status, err := s.State.Commerce.Forward(c.Context(), http.MethodPut, "/v1/platform/promo", s.State.AdminOrg, c.Body())
return relay(c, raw, status, err)
}
// listSpendCaps returns a target org's usage caps (spend-alerts + derived period
// spend/over/warn/resetsAt). The org is the SuperAdmin's ?org= or, for a scoped
// admin, their own — never a client-widened scope.
func listSpendCaps(s *cloud.Service[core.State], c *zip.Ctx) error {
org, ok := targetOrg(s, c)
if !ok {
return core.Fail(c, "org required")
}
raw, status, err := s.State.Commerce.Forward(c.Context(), http.MethodGet, "/v1/billing/spend-alerts", org, nil)
return relay(c, raw, status, err)
}
// createSpendCap sets a cap on a target org (platform override of a customer budget).
func createSpendCap(s *cloud.Service[core.State], c *zip.Ctx) error {
org, ok := targetOrg(s, c)
if !ok {
return core.Fail(c, "org required")
}
raw, status, err := s.State.Commerce.Forward(c.Context(), http.MethodPost, "/v1/billing/spend-alerts", org, c.Body())
return relay(c, raw, status, err)
}
// updateSpendCap edits a target org's cap by id (raise/lower the ceiling, flip enforce).
func updateSpendCap(s *cloud.Service[core.State], c *zip.Ctx) error {
org, ok := targetOrg(s, c)
if !ok {
return core.Fail(c, "org required")
}
id := strings.TrimSpace(c.Param("id"))
if id == "" {
return core.Fail(c, "cap id required")
}
raw, status, err := s.State.Commerce.Forward(c.Context(), http.MethodPatch, "/v1/billing/spend-alerts/"+url.PathEscape(id), org, c.Body())
return relay(c, raw, status, err)
}
// deleteSpendCap removes a target org's cap by id.
func deleteSpendCap(s *cloud.Service[core.State], c *zip.Ctx) error {
org, ok := targetOrg(s, c)
if !ok {
return core.Fail(c, "org required")
}
id := strings.TrimSpace(c.Param("id"))
if id == "" {
return core.Fail(c, "cap id required")
}
raw, status, err := s.State.Commerce.Forward(c.Context(), http.MethodDelete, "/v1/billing/spend-alerts/"+url.PathEscape(id), org, nil)
return relay(c, raw, status, err)
}
// targetOrg resolves which org a cap operation acts on: a SuperAdmin names it with
// ?org=; a scoped admin is hard-pinned to their own subtree (?org= ignored). Empty
// (false) when unresolvable, so the handler fails closed rather than acting on a
// guessed tenant.
func targetOrg(s *cloud.Service[core.State], c *zip.Ctx) (string, bool) {
sc := core.ResolveScope(s, c)
if sc.Super {
if org := strings.TrimSpace(c.Query("org")); org != "" {
return org, true
}
return "", false
}
if len(sc.Orgs) > 0 && strings.TrimSpace(sc.Orgs[0]) != "" {
return sc.Orgs[0], true
}
return "", false
}
// relay surfaces commerce's OWN verdict in the /v1 envelope: a 2xx passes the raw
// JSON through as data (so the console decodes the exact SpendAlert/Promo shape), a
// non-2xx becomes an honest failure carrying commerce's status + message rather than
// masking a 400 validation as success.
func relay(c *zip.Ctx, raw []byte, status int, err error) error {
if err != nil {
return core.Fail(c, err.Error())
}
if status < 200 || status >= 300 {
msg := strings.TrimSpace(string(raw))
if msg == "" {
msg = http.StatusText(status)
}
return core.Fail(c, msg)
}
if len(raw) == 0 {
return core.OK(c, map[string]any{"ok": true})
}
return core.OKRaw(c, json.RawMessage(raw), 0)
}
+120
View File
@@ -0,0 +1,120 @@
package admin
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
// recCommerce records the X-Org-Id + method + path of the last forwarded request so a
// test can prove the /v1/admin control plane targets the RIGHT tenant namespace, and
// serves the promo + spend-alert shapes verbatim.
type recCommerce struct {
server *httptest.Server
mu sync.Mutex
lastOrg string
lastMethod string
lastPath string
}
func newRecCommerce() *recCommerce {
f := &recCommerce{}
f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
f.lastOrg = r.Header.Get("X-Org-Id")
f.lastMethod = r.Method
f.lastPath = r.URL.Path
f.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "/platform/promo"):
io.WriteString(w, `{"percentOff":50,"plans":["pro"],"active":true}`)
case strings.HasSuffix(r.URL.Path, "/spend-alerts"):
io.WriteString(w, `[{"id":"a1","threshold":10000,"enforce":true,"period":"2026-07","resetsAt":"2026-08-01T00:00:00Z"}]`)
default:
io.WriteString(w, `{}`)
}
}))
return f
}
func (f *recCommerce) seen() (string, string, string) {
f.mu.Lock()
defer f.mu.Unlock()
return f.lastMethod, f.lastPath, f.lastOrg
}
func envStatus(t *testing.T, body []byte) string {
t.Helper()
var e struct {
Status string `json:"status"`
}
_ = json.Unmarshal(body, &e)
return e.Status
}
// The promo control plane is SuperAdmin-only (core.Guard) and forwards to commerce's
// platform-promo endpoint.
func TestLimits_Promo_SuperOnly(t *testing.T) {
iam := newScopeIAM()
defer iam.server.Close()
com := newRecCommerce()
defer com.server.Close()
do := mount(t, iam.server.URL, com.server.URL, "")
// SuperAdmin GET → 200 ok, forwarded to /v1/platform/promo.
resp, body := do("GET", "/v1/admin/promos", superHdr)
if resp.StatusCode != http.StatusOK || envStatus(t, body) != "ok" {
t.Fatalf("super GET promos = %d %s", resp.StatusCode, body)
}
if m, p, _ := com.seen(); m != "GET" || !strings.HasSuffix(p, "/platform/promo") {
t.Fatalf("forwarded %s %s, want GET .../platform/promo", m, p)
}
// SuperAdmin PUT → forwarded as PUT.
if resp, _ := do("PUT", "/v1/admin/promos", superHdr); resp.StatusCode != http.StatusOK {
t.Fatalf("super PUT promos = %d", resp.StatusCode)
}
if m, _, _ := com.seen(); m != "PUT" {
t.Fatalf("promo PUT forwarded as %s, want PUT", m)
}
// A non-super org admin is REFUSED at the platform gate (403), never reaching commerce.
if resp, _ := do("GET", "/v1/admin/promos", orgAdminHdr); resp.StatusCode != http.StatusForbidden {
t.Fatalf("org-admin GET promos = %d, want 403 (platform-only)", resp.StatusCode)
}
}
// Cap oversight is org-scoped: a SuperAdmin targets any org via ?org=; a scoped admin
// is hard-pinned to their OWN org (a client ?org= is ignored — the escalation line).
func TestLimits_SpendCaps_OrgScoped(t *testing.T) {
iam := newScopeIAM()
defer iam.server.Close()
com := newRecCommerce()
defer com.server.Close()
do := mount(t, iam.server.URL, com.server.URL, "")
// SuperAdmin with ?org=maxpower → forwards X-Org-Id=maxpower.
resp, body := do("GET", "/v1/admin/spend-caps?org=maxpower", superHdr)
if resp.StatusCode != http.StatusOK || envStatus(t, body) != "ok" {
t.Fatalf("super spend-caps = %d %s", resp.StatusCode, body)
}
if _, p, org := com.seen(); org != "maxpower" || !strings.HasSuffix(p, "/spend-alerts") {
t.Fatalf("forwarded org=%q path=%q, want maxpower .../spend-alerts", org, p)
}
// SuperAdmin WITHOUT ?org → org required (honest error, no guessed tenant).
if _, body := do("GET", "/v1/admin/spend-caps", superHdr); envStatus(t, body) != "error" {
t.Fatalf("super spend-caps without org must be an error envelope, got %s", body)
}
// A scoped org admin naming a FOREIGN ?org=hanzo is hard-pinned to their OWN org.
do("GET", "/v1/admin/spend-caps?org=hanzo", orgAdminHdr)
if _, _, org := com.seen(); org != "maxpower" {
t.Fatalf("scoped admin forwarded org=%q, want maxpower (client ?org= must be ignored)", org)
}
}
+110
View File
@@ -0,0 +1,110 @@
// Package metrics is the fleet SaaS-operations god-view (/v1/admin/metrics) — the
// operator's business dashboard: MRR/ARR, net-new vs churned MRR, the plan/category
// mix, the top customers, and the recent subscription movements. SuperAdmin only
// (core.Guard).
//
// It OWNS no aggregation. The whole snapshot is computed IN commerce (the system of
// record for subscriptions + the usage ledger) by its cross-org SaaS-metrics engine
// (GET /v1/metrics/saas), which admin PROXIES with the SAME admin-scoped S2S service
// token finance uses for COGS. The engine is ALREADY fleet-wide — it walks every org
// namespace itself — so this is a SINGLE upstream read, no per-org fan-out, exactly as
// finance consumes commerce Costs. An unwired or unreachable commerce degrades to an
// honest empty snapshot (real zeros, `[]` not null) with a not-ok source, never a
// fabricated number.
package metrics
import (
"errors"
"strconv"
"strings"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/admin/commerce"
"github.com/hanzoai/cloud/clients/admin/core"
"github.com/zap-proto/zip"
)
// errUnconfigured marks commerce not wired on this deployment — core.SrcOf reports it as
// a not-ok source so the console renders the honest not-configured state.
var errUnconfigured = errors.New("commerce metrics not configured")
// defaultLimit caps the top-customers list when the caller sends none (mirrors the
// commerce engine's own default so the proxy never asks for more than it returns).
const defaultLimit = 20
// MetricsData is the GET /v1/admin/metrics payload: the commerce SaaS snapshot, flat,
// plus the admin read time and the upstream freshness strip every god-view carries.
type MetricsData struct {
commerce.SaaSMetrics
GeneratedAt string `json:"generatedAt"`
Sources []core.SourceStatus `json:"sources"`
}
// Metrics answers GET /v1/admin/metrics by proxying the commerce SaaS-metrics engine
// (already a fleet-wide cross-org aggregate). SuperAdmin only.
//
// GET /v1/admin/metrics?window=30d&limit=20
func Metrics(s *cloud.Service[core.State], c *zip.Ctx) error {
ctx := c.Context()
now := time.Now().UTC().Format(time.RFC3339)
window := strings.TrimSpace(c.Query("window"))
limit := parseLimit(c.Query("limit"))
if !s.State.Commerce.Ready() {
return core.OK(c, empty(now, window, core.SrcOf("commerce-metrics", errUnconfigured, 0, now)))
}
m, err := s.State.Commerce.Metrics(ctx, window, limit)
if err != nil {
return core.OK(c, empty(now, window, core.SrcOf("commerce-metrics", err, 0, now)))
}
return core.OK(c, MetricsData{
SaaSMetrics: normalize(m),
GeneratedAt: now,
Sources: []core.SourceStatus{core.SrcOf("commerce-metrics", nil, m.Orgs, now)},
})
}
// empty is the honest not-configured/unreachable snapshot: real zeros + empty slices
// (never null, never fabricated) plus the not-ok source.
func empty(now, window string, src core.SourceStatus) MetricsData {
return MetricsData{
SaaSMetrics: normalize(commerce.SaaSMetrics{AsOf: now, Currency: "usd", Window: window}),
GeneratedAt: now,
Sources: []core.SourceStatus{src},
}
}
// normalize replaces nil slices with empty ones so the JSON is honest arrays (`[]`, not
// null) and the console never has to guard a missing collection.
func normalize(m commerce.SaaSMetrics) commerce.SaaSMetrics {
if m.Revenue.ByCategory == nil {
m.Revenue.ByCategory = []commerce.SaaSCategory{}
}
if m.Subs.ByPlan == nil {
m.Subs.ByPlan = []commerce.SaaSPlan{}
}
if m.Subs.Recent == nil {
m.Subs.Recent = []commerce.SaaSEvent{}
}
if m.Customers == nil {
m.Customers = []commerce.SaaSCustomer{}
}
if m.Gaps == nil {
m.Gaps = []string{}
}
return m
}
// parseLimit clamps the top-N cap to [1,200], defaulting to defaultLimit — mirrors the
// commerce engine's clamp exactly.
func parseLimit(s string) int {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n <= 0 {
return defaultLimit
}
if n > 200 {
return 200
}
return n
}
+13
View File
@@ -0,0 +1,13 @@
package metrics
import (
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/admin/core"
"github.com/zap-proto/zip"
)
// Routes registers the SaaS-metrics god-view (SuperAdmin only, cross-tenant business
// aggregate).
func Routes(app *zip.App, s *cloud.Service[core.State]) {
app.Get("/v1/admin/metrics", core.Guard(s, Metrics))
}
+15 -15
View File
@@ -1,16 +1,16 @@
package admin
// The /v1/admin/services board — the launch-control LENS on the ONE flag engine, twin
// The /v1/admin/services board — the launch-control LENS over the waitlist gate, twin
// of /v1/admin/flags. Every hosted service (studio/chat/console/app/api/team + runtime
// onboards) with its LIVE waitlist mode — the switch waitlist.<svc> evaluated through
// clients/flags. This is the "remove the waitlist one service at a time" toggle.
// SuperAdmin only (core.Guard), like every platform /v1/admin/*.
// onboards) with its LIVE waitlist mode — the switch waitlist.<svc>, evaluated through
// clients/admission (which composes the flag engine one-way). This is the "remove the
// waitlist one service at a time" toggle. SuperAdmin only (core.Guard), like every
// platform /v1/admin/*.
//
// Formerly clients/featuregate owned its OWN SQLite mode store + this control plane;
// both folded onto the flag engine so the platform has ONE decision plane. featuregate
// now owns only the native Enforce middleware — a consumer of flags.WaitlistModeForHost.
// Per-user approval (the second, orthogonal axis) stays IAM's, reached via the existing
// admin IAM proxy — not re-served here.
// The registry + mode decide + these admin control funcs live in clients/admission,
// the complete launch-gate feature; flags is the pure engine underneath. Per-user
// approval (the second, orthogonal axis) stays IAM's, reached via the existing admin IAM
// proxy — not re-served here.
import (
"errors"
@@ -19,13 +19,13 @@ import (
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/admin/core"
"github.com/hanzoai/cloud/clients/flags"
"github.com/hanzoai/cloud/clients/admission"
"github.com/zap-proto/zip"
)
// services answers GET /v1/admin/services — the launch board (every service + live mode).
func services(s *cloud.Service[core.State], c *zip.Ctx) error {
rows, err := flags.ListWaitlistServices(c.Context())
rows, err := admission.ListWaitlistServices(c.Context())
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "list services: %v", err)
}
@@ -35,14 +35,14 @@ func services(s *cloud.Service[core.State], c *zip.Ctx) error {
// upsertService answers POST /v1/admin/services — onboard or edit a hosted service so a
// new host is governed WITHOUT a redeploy. A re-register PRESERVES the live switch.
func upsertService(s *cloud.Service[core.State], c *zip.Ctx) error {
var in flags.ServiceInput
var in admission.ServiceInput
if err := c.Bind(&in); err != nil {
return err
}
if strings.TrimSpace(in.Service) == "" {
return zip.ErrBadRequest("service slug is required")
}
view, err := flags.UpsertWaitlistService(c.Context(), in, c.UserEmail())
view, err := admission.UpsertWaitlistService(c.Context(), in, c.UserEmail())
if err != nil {
return zip.ErrBadRequest(err.Error())
}
@@ -62,9 +62,9 @@ func setServiceMode(s *cloud.Service[core.State], c *zip.Ctx) error {
if err := c.Bind(&body); err != nil {
return err
}
view, err := flags.SetWaitlistMode(c.Context(), service, body.WaitlistMode, c.UserEmail())
view, err := admission.SetWaitlistMode(c.Context(), service, body.WaitlistMode, c.UserEmail())
if err != nil {
if errors.Is(err, flags.ErrServiceNotFound) {
if errors.Is(err, admission.ErrServiceNotFound) {
return zip.ErrNotFound("service not found: " + service)
}
return zip.Errorf(http.StatusInternalServerError, "set mode: %v", err)
+12
View File
@@ -0,0 +1,12 @@
package subscriptions
import (
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/admin/core"
"github.com/zap-proto/zip"
)
// Routes registers the fleet subscription view (SuperAdmin only, cross-tenant).
func Routes(app *zip.App, s *cloud.Service[core.State]) {
app.Get("/v1/admin/subscriptions", core.Guard(s, Subscriptions))
}
@@ -0,0 +1,139 @@
// Package subscriptions is the fleet SUBSCRIPTION view (/v1/admin/subscriptions) —
// every tenant's plan subscription: customer/org, plan, status, monthly-normalized MRR,
// and the current-period start/renews. SuperAdmin only (core.Guard).
//
// Like invoices (and revenue) it fans out the org directory concurrently and reads each
// org's subscriptions via the admin S2S seam, tagging every row with its owning org. The
// MRR is monthly-normalized in the commerce reader so a yearly plan is comparable to a
// monthly one. Best-effort per org (a failed read contributes no rows, never fabricated
// ones); optional ?org= scopes to one tenant, ?status= filters, ?limit= caps.
package subscriptions
import (
"context"
"sort"
"strconv"
"strings"
"sync"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/admin/core"
"github.com/hanzoai/cloud/clients/admin/iam"
"github.com/zap-proto/zip"
)
// defaultLimit caps the merged fleet subscription list when the caller sends none.
const defaultLimit = 500
// SubscriptionRow is one row of GET /v1/admin/subscriptions — a tenant's subscription at
// a glance, tagged with its owning org. MRR is USD cents; timestamps are RFC3339 strings.
type SubscriptionRow struct {
ID string `json:"id"`
Org string `json:"org"`
Display string `json:"display"`
User string `json:"user"`
Plan string `json:"plan"`
Status string `json:"status"`
MRRCents int64 `json:"mrrCents"`
Started string `json:"started"`
Renews string `json:"renews"`
}
// Subscriptions answers GET /v1/admin/subscriptions.
//
// GET /v1/admin/subscriptions?org=&status=&limit=
func Subscriptions(s *cloud.Service[core.State], c *zip.Ctx) error {
ctx := c.Context()
cr := core.CallerCreds(c)
status := strings.TrimSpace(c.Query("status"))
wantOrg := strings.TrimSpace(c.Query("org"))
limit := parseLimit(c.Query("limit"))
orgs, err := core.ListOrgs(s, ctx, cr)
if err != nil {
return core.Fail(c, err.Error())
}
if wantOrg != "" {
orgs = filterOrg(orgs, wantOrg)
}
// Per-org subscriptions, fanned out concurrently (best-effort per org).
perOrg := make([][]SubscriptionRow, len(orgs))
sem := make(chan struct{}, core.MaxCustomerConcurrency)
var wg sync.WaitGroup
for i, o := range orgs {
wg.Add(1)
sem <- struct{}{}
go func(i int, o iam.Org) {
defer wg.Done()
defer func() { <-sem }()
perOrg[i] = subscriptionsOf(s, ctx, o, status)
}(i, o)
}
wg.Wait()
rows := make([]SubscriptionRow, 0)
for _, r := range perOrg {
rows = append(rows, r...)
}
// Highest-MRR first (ties broken by most-recent start); cap to the merged limit.
sort.Slice(rows, func(i, j int) bool {
if rows[i].MRRCents != rows[j].MRRCents {
return rows[i].MRRCents > rows[j].MRRCents
}
return rows[i].Started > rows[j].Started
})
total := len(rows)
if len(rows) > limit {
rows = rows[:limit]
}
return core.OKList(c, rows, total)
}
// subscriptionsOf reads one org's subscriptions into fleet rows, tagged with the org.
// Best-effort: a failed read yields no rows so the fleet view degrades honestly.
func subscriptionsOf(s *cloud.Service[core.State], ctx context.Context, o iam.Org, status string) []SubscriptionRow {
entries, err := s.State.Commerce.Subscriptions(ctx, o.Name, status)
if err != nil {
return nil
}
display := core.Display(o.DisplayName, o.Name)
rows := make([]SubscriptionRow, 0, len(entries))
for _, sub := range entries {
rows = append(rows, SubscriptionRow{
ID: sub.ID,
Org: o.Name,
Display: display,
User: sub.User,
Plan: sub.Plan,
Status: sub.Status,
MRRCents: int64(sub.MRR),
Started: sub.Started,
Renews: sub.Renews,
})
}
return rows
}
// filterOrg narrows the directory to the one requested org (empty when it does not
// exist — an honest empty list, never a fabricated tenant).
func filterOrg(orgs []iam.Org, want string) []iam.Org {
for _, o := range orgs {
if o.Name == want {
return []iam.Org{o}
}
}
return nil
}
// parseLimit clamps the merged-list cap to [1,5000], defaulting to defaultLimit.
func parseLimit(s string) int {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n <= 0 {
return defaultLimit
}
if n > 5000 {
return 5000
}
return n
}
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package featuregate
package admission
import (
"context"
@@ -31,7 +31,7 @@ import (
// approval is FAIL-OPEN: a user is approved unless properties.approvalStatus is
// EXACTLY "pending" (absent / "approved" / "rejected" all read approved via
// IsApproved). Only "pending" holds a user on the waitlist. Keeping the literal
// here (not importing IAM) keeps featuregate self-contained.
// here (not importing IAM) keeps admission self-contained.
const approvalStatusPending = "pending"
// approvedHeader is the FORWARD-PERFECT path: once IAM carries approvalStatus in
@@ -1,7 +1,7 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
// Licensed under the Apache License, Version 2.0.
package featuregate
package admission
import (
"context"
@@ -12,16 +12,24 @@
// See the License for the specific language governing permissions and
// limitations under the License.
// Package featuregate is the launch-control ENFORCEMENT for Hanzo's hosted services:
// the native middleware (Enforce) + the per-user approval predicate (Approvals, reused
// from IAM). It is a CONSUMER of the ONE policy engine — the per-service waitlist MODE
// and the host→service registry live in clients/flags (a service's mode IS the
// switch waitlist.<svc>, evaluated through the native engine); the admin board is the
// /v1/admin/services lens and the guard's runtime mode read is /v1/featuregate/mode,
// both served there. This package owns only enforcement, decomplected into two axes:
// Package admission is the launch-control GATE for Hanzo's hosted services — the
// COMPLETE waitlist feature, COMPOSING the ONE flag engine (clients/flags) one-way. It
// owns:
//
// - PER-SERVICE waitlist mode on|off — the flags switch waitlist.<svc>,
// resolved for a request host via flags.WaitlistModeForHost (the decide).
// - the host→service registry (registry.go) + the brand seed (waitlist.go),
// - the per-service MODE decide WaitlistModeForHost — a service's mode IS the switch
// waitlist.<svc>, evaluated through the flag engine (flags.Bool),
// - the admin control funcs (List/Set/Upsert) the /v1/admin/services board calls,
// - the guard's public mode read /v1/flags/waitlist, Mount,
// - the native enforcement middleware (Enforce, this file),
// - the per-user approval predicate (Approvals, reused from IAM — approval.go).
//
// flags NEVER imports admission; admission imports flags. The engine is the pure
// (Principal, context) -> verdict primitive; this package is its first composed tenant.
// Enforcement is decomplected into two orthogonal axes:
//
// - PER-SERVICE waitlist mode on|off — the switch waitlist.<svc>, resolved for a
// request host via WaitlistModeForHost (the decide, waitlist.go).
// - PER-USER approvalStatus pending|approved — owned by IAM (approval.go), REUSED.
//
// THE RULE, applied at ONE native enforcement point (Enforce):
@@ -29,14 +37,13 @@
// if waitlistMode[host] AND NOT user.approved → bounce to the waitlist
// if approved OR mode=off → allow
// unauthenticated → login first
package featuregate
package admission
import (
"context"
"net/http"
"strings"
"github.com/hanzoai/cloud/clients/flags"
"github.com/zap-proto/zip"
)
@@ -59,13 +66,13 @@ import (
// INTEGRATION POINT — wire in serve.go RIGHT AFTER SanitizeIdentity:
//
// app.Use(IdentityMiddleware(cfg)) // establishes the validated principal
// app.Use(featuregate.Enforce(featuregate.EnforceConfig{ WaitlistURL: … })) // ← here
// app.Use(admission.Enforce(admission.EnforceConfig{ WaitlistURL: … })) // ← here
//
// It reads the sanitized X-User-Id / X-User-IsAdmin / X-User-Approved that
// IdentityMiddleware minted, so it MUST run after it and (like BillingGate) before
// the subsystem handlers. It is deliberately NOT wired here — the unified-binary
// agent owns serve.go's boot chain; this package exposes Enforce so the one-line
// app.Use lands without a merge collision. The decide (flags.WaitlistModeForHost) is
// app.Use lands without a merge collision. The decide (WaitlistModeForHost) is
// resolved PER REQUEST and fail-opens until the flags engine has mounted, so Enforce
// can be constructed before Mount runs.
//
@@ -102,7 +109,7 @@ type EnforceConfig struct {
ExemptPrefixes []string
// Gate is THE decide: it resolves whether a request host is in waitlist mode,
// via the ONE policy engine. When nil it is flags.WaitlistModeForHost —
// via the ONE policy engine. When nil it is WaitlistModeForHost —
// host→service→waitlist.<svc>. Injected only in tests. Fail-open by contract:
// known=false (unmounted / registry error / un-governed host) → not gated.
Gate func(ctx context.Context, host string) (mode bool, service string, known bool)
@@ -112,9 +119,9 @@ type EnforceConfig struct {
// health, the auth/OIDC handshake, and the waitlist join API itself (so a gated
// user can still submit the waitlist form).
var defaultExemptPrefixes = []string{
"/v1/featuregate/", // the mode read + the health route
"/v1/iam/", // auth / OIDC / approval-status / get-account handshake
"/v1/waitlist", // the waitlist join API (a gated user must reach it)
"/v1/flags/waitlist", // the guard's public mode read (flags engine)
"/v1/iam/", // auth / OIDC / approval-status / get-account handshake
"/v1/waitlist", // the waitlist join API (a gated user must reach it)
"/health",
"/healthz",
"/__guard/", // the @file guard's own callback surface (defense in depth)
@@ -131,7 +138,7 @@ func Enforce(cfg EnforceConfig) zip.Handler {
}
gate := cfg.Gate
if gate == nil {
gate = flags.WaitlistModeForHost // the ONE decide: host→service→waitlist.<svc>
gate = WaitlistModeForHost // the ONE decide: host→service→waitlist.<svc>
}
exempt := cfg.ExemptPrefixes
if len(exempt) == 0 {
@@ -199,7 +206,7 @@ func bounce(c *zip.Ctx, waitlistURL string) error {
}
// apiKeyPrefixes are the Hanzo API-key prefixes. This MIRRORS cloud
// auth_identity.go isAPIKey (the ONE authority) — kept local so featuregate stays
// auth_identity.go isAPIKey (the ONE authority) — kept local so admission stays
// self-contained (no cloud-internal import) while agreeing on the exact contract:
// a token with one of these prefixes is a possession-gated API key, not a session
// principal. If cloud adds a prefix there, add it here.
@@ -1,7 +1,7 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
// Licensed under the Apache License, Version 2.0.
package featuregate
package admission
import (
"context"
@@ -16,7 +16,7 @@ import (
// testGate is the injected decide (the flags engine's WaitlistModeForHost seam):
// hanzo.chat is gated, api.hanzo.ai is open, everything else is un-governed. This is
// exactly what flags.WaitlistModeForHost returns for the equivalent registry, without
// exactly what WaitlistModeForHost returns for the equivalent registry, without
// standing up the native flag engine (cgo) in a middleware unit test.
func testGate(_ context.Context, host string) (mode bool, service string, known bool) {
switch host {
@@ -186,7 +186,7 @@ func TestRule_UngovernedHost_PassesThrough(t *testing.T) {
func TestRule_ExemptPaths_NeverGated(t *testing.T) {
app := gateApp(t, "pending")
for _, p := range []string{"/health", "/v1/iam/get-account", "/v1/waitlist/join", "/v1/featuregate/mode"} {
for _, p := range []string{"/health", "/v1/iam/get-account", "/v1/waitlist/join", "/v1/flags/waitlist"} {
code, _ := drive(t, app, greq{host: "hanzo.chat", path: p, user: "u", org: "acme", accept: html})
if code != 200 {
t.Fatalf("exempt path %q = %d, want 200 (never gated)", p, code)
@@ -205,7 +205,7 @@ func TestRule_ForwardHeaderApproved_ThroughWithoutLookup(t *testing.T) {
}
}
// The DEFAULT gate (nil Gate → flags.WaitlistModeForHost) fail-opens before the flags
// The DEFAULT gate (nil Gate → WaitlistModeForHost) fail-opens before the flag
// engine has mounted: with no engine, WaitlistModeForHost returns known=false for every
// host, so Enforce never gates pre-boot.
func TestEnforce_DefaultGate_FailsOpenPreBoot(t *testing.T) {
@@ -1,11 +1,11 @@
package flags
package admission
// The waitlist REGISTRY — the host→service map + service metadata folded in from
// the former clients/featuregate SQLite store. It is deliberately MODE-FREE: a
// service's waitlist mode is NOT a column here, it is the platform switch
// waitlist.<svc> evaluated through the ONE native engine (waitlist.go). This store
// answers only "which service owns this host, and what is its display metadata" —
// the config the decide needs, with the decision itself owned by the flag engine.
// The launch-registry — the host→service map + service display metadata. It is
// deliberately MODE-FREE: a service's waitlist mode is NOT a column here, it is the
// platform switch waitlist.<svc> evaluated through the ONE flag engine (clients/flags,
// composed one-way from waitlist.go). This store answers only "which service owns this
// host, and what is its display metadata" — the config the decide needs, with the
// decision itself owned by the flag engine.
//
// It rides the SAME per-(org,project) OrgDB machinery as the flag defs (opened via
// cloud.OrgStore, encrypted at rest via cek); the registry is PLATFORM-global, so it
@@ -21,7 +21,7 @@ import (
)
// ErrServiceNotFound is returned when a service slug is not in the registry.
var ErrServiceNotFound = errors.New("flags: waitlist service not found")
var ErrServiceNotFound = errors.New("admission: waitlist service not found")
// ServiceRow is one hosted service in the registry (host→service + metadata). The
// waitlist MODE is intentionally absent — it is the platform switch waitlist.<svc>,
@@ -45,7 +45,7 @@ type waitlistStore struct {
}
// openWaitlistStore migrates the registry schema over an already-opened (pragma'd,
// cek-wrapped) OrgDB handle — the same open contract as openStore for flag defs.
// cek-wrapped) OrgDB handle — the same open contract as flags' openStore for flag defs.
func openWaitlistStore(db *sql.DB) (*waitlistStore, error) {
const schema = `
CREATE TABLE IF NOT EXISTS wl_services (
@@ -64,7 +64,7 @@ CREATE TABLE IF NOT EXISTS wl_hosts (
CREATE INDEX IF NOT EXISTS ix_wl_hosts_service ON wl_hosts(service);
`
if _, err := db.Exec(schema); err != nil {
return nil, fmt.Errorf("flags: waitlist migrate: %w", err)
return nil, fmt.Errorf("admission: waitlist migrate: %w", err)
}
return &waitlistStore{db: db}, nil
}
@@ -210,7 +210,7 @@ func (s *waitlistStore) Get(ctx context.Context, service string) (ServiceRow, er
func (s *waitlistStore) Upsert(ctx context.Context, in ServiceRow, by string, now int64) (ServiceRow, error) {
svc := strings.ToLower(strings.TrimSpace(in.Service))
if svc == "" {
return ServiceRow{}, fmt.Errorf("flags: waitlist service slug required")
return ServiceRow{}, fmt.Errorf("admission: waitlist service slug required")
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
@@ -1,9 +1,9 @@
package flags
package admission
// Registry coverage for the folded host→service store. It drives the store over a raw
// sqlite handle (the same driver OrgDB uses), so it exercises the fold WITHOUT the cek
// Registry coverage for the host→service store. It drives the store over a raw sqlite
// handle (the same driver OrgDB uses), so it exercises the registry WITHOUT the cek
// at-rest layer — runnable under CGO=0. The MODE is out of scope here by design (it is
// the waitlist.<svc> switch, evaluated by the native engine, covered separately).
// the waitlist.<svc> switch, evaluated by the flag engine, covered separately).
import (
"context"
@@ -1,19 +1,23 @@
package flags
package admission
// The waitlist LENS on the ONE flag engine — the launch-control plane folded in from
// the former clients/featuregate. Decomplected into the two orthogonal axes it always
// was, now with a single decision plane:
// The launch-control gate — the COMPLETE waitlist feature, COMPOSING the ONE flag
// engine (clients/flags) one-way. Decomplected into the two orthogonal axes it always
// was, with a single decision plane:
//
// - MODE (per service): waitlist.<svc> IS a platform switch, evaluated through the
// SAME native engine as every other platform flag. There is no second mode store.
// - HOST MAP + metadata: the registry (waitlist_store.go) resolves a request host
// to the service whose switch governs it, and carries display metadata.
// flag engine (flags.Bool / flags.SetPlatformSwitch / flags.Register). There is no
// second mode store.
// - HOST MAP + metadata: the registry (registry.go) resolves a request host to the
// service whose switch governs it, and carries display metadata.
//
// The decide is WaitlistModeForHost(host) → (mode, service, known): resolve host→svc,
// then read waitlist.<svc>. featuregate.Enforce is now a CONSUMER of this decide, and
// /v1/featuregate/mode + the /v1/admin/services board read it too. Per-user approval
// (pending|approved) stays IAM's (featuregate/approval.go) — the second, orthogonal
// axis, unchanged.
// then read waitlist.<svc>. Enforce (middleware.go) consumes this decide; the admin
// board (/v1/admin/services) and the guard's runtime mode read (/v1/flags/waitlist,
// served here) read it too. Per-user approval (pending|approved) is the second,
// orthogonal axis — IAM's, in approval.go.
//
// flags NEVER imports this package; this package imports flags. That one-way arrow is
// the whole point of the decomplection: the engine is pure, the feature composes it.
import (
"context"
@@ -25,10 +29,29 @@ import (
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/flags"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// The reserved platform tenant the launch registry rides in — the SAME reserved
// (org, project) the flag engine uses for its platform switches, so the registry and
// the waitlist.<svc> switches co-locate. One waitlist.db for the deployment.
const (
platformOrg = "platform"
platformProject = "platform"
)
// registryState is admission's process-wide launch state: the platform-tenant
// host→service registry store + the deployment brand it was seeded for. Installed by
// Mount, torn down by Shutdown.
type registryState struct {
store *cloud.OrgStore[*waitlistStore]
brand string
}
var mounted *registryState
// SeedService is one row of the launch registry (a hosted service + its hosts). Mode
// is intentionally absent — the launch posture (gated) is waitlistDef's Default "true".
type SeedService struct {
@@ -60,17 +83,17 @@ func waitlistKey(svc string) string { return "waitlist." + strings.ToLower(strin
// waitlistDef is the platform switch for one service's mode. Default "true" = the
// launch posture (gated until an admin opens it), so a deployment with no stored flag
// behaves exactly as the old featuregate seed (waitlistMode ON).
func waitlistDef(svc, display string) Def {
// behaves exactly as the old admission seed (waitlistMode ON).
func waitlistDef(svc, display string) flags.Def {
if strings.TrimSpace(display) == "" {
display = svc
}
return Def{
return flags.Def{
Key: waitlistKey(svc),
Category: "Launch",
Label: "Waitlist · " + display,
Desc: "Waitlist mode for " + display + ": ON gates the service to APPROVED users; OFF opens it.",
Type: TypeBool,
Type: flags.TypeBool,
Default: "true",
}
}
@@ -78,9 +101,13 @@ func waitlistDef(svc, display string) Def {
// ensureWaitlistDef registers a service's switch if it is not already registered
// (Mount registers the seed set with nicer labels; this covers runtime onboards).
func ensureWaitlistDef(svc, display string) {
if _, ok := lookupDef(waitlistKey(svc)); !ok {
Register(waitlistDef(svc, display))
key := waitlistKey(svc)
for _, d := range flags.Defs() {
if d.Key == key {
return
}
}
flags.Register(waitlistDef(svc, display))
}
// boolDef is the minimal PostHog flag definition for a boolean switch value.
@@ -92,26 +119,24 @@ func boolDef(on bool) json.RawMessage {
}
// requireRegistry resolves the platform-tenant registry store, or an error when the
// engine is not mounted (writes need it; the decide fail-opens instead).
// gate is not mounted (writes need it; the decide fail-opens instead).
func requireRegistry() (*waitlistStore, error) {
c := mounted
if c == nil || c.registry == nil {
return nil, fmt.Errorf("flags: waitlist registry not mounted")
if mounted == nil || mounted.store == nil {
return nil, fmt.Errorf("admission: waitlist registry not mounted")
}
return c.registry.For(platformOrg, platformProject)
return mounted.store.For(platformOrg, platformProject)
}
// WaitlistModeForHost is THE decide the Enforce consumer, /v1/featuregate/mode, and
// WaitlistModeForHost is THE decide the Enforce consumer, /v1/flags/waitlist, and
// the admin board call: resolve host→service, then read the waitlist.<svc> switch
// through the engine. FAIL-OPEN by construction — an unmounted registry, a store
// through the flag engine. FAIL-OPEN by construction — an unmounted registry, a store
// error, or an un-governed host all return known=false, so a request is NEVER gated
// pre-boot or on a registry fault (availability over a hard gate, matching the guard).
func WaitlistModeForHost(ctx context.Context, host string) (mode bool, service string, known bool) {
c := mounted
if c == nil || c.registry == nil {
if mounted == nil || mounted.store == nil {
return false, "", false
}
st, err := c.registry.For(platformOrg, platformProject)
st, err := mounted.store.For(platformOrg, platformProject)
if err != nil {
return false, "", false
}
@@ -119,7 +144,7 @@ func WaitlistModeForHost(ctx context.Context, host string) (mode bool, service s
if err != nil || !known {
return false, "", false
}
return Bool(waitlistKey(svc)), svc, true
return flags.Bool(waitlistKey(svc)), svc, true
}
// ListWaitlistServices returns the admin board: every registered service with its LIVE
@@ -135,19 +160,19 @@ func ListWaitlistServices(ctx context.Context) ([]ServiceView, error) {
}
out := make([]ServiceView, 0, len(rows))
for _, r := range rows {
out = append(out, ServiceView{ServiceRow: r, WaitlistMode: Bool(waitlistKey(r.Service))})
out = append(out, ServiceView{ServiceRow: r, WaitlistMode: flags.Bool(waitlistKey(r.Service))})
}
return out, nil
}
// SetWaitlistMode flips one service's waitlist switch — the launch lever — and returns
// the updated view. It is the ONE write path (through SetPlatformSwitch, audited in the
// flag activity log); the flip is hot (this pod applies immediately, peers converge
// within the eval TTL). ErrServiceNotFound when the slug is unknown.
// the updated view. It is the ONE write path (through flags.SetPlatformSwitch, audited
// in the flag activity log); the flip is hot (this pod applies immediately, peers
// converge within the eval TTL). ErrServiceNotFound when the slug is unknown.
func SetWaitlistMode(ctx context.Context, service string, mode bool, actor string) (ServiceView, error) {
service = strings.ToLower(strings.TrimSpace(service))
if service == "" {
return ServiceView{}, fmt.Errorf("flags: service is required")
return ServiceView{}, fmt.Errorf("admission: service is required")
}
st, err := requireRegistry()
if err != nil {
@@ -158,10 +183,10 @@ func SetWaitlistMode(ctx context.Context, service string, mode bool, actor strin
return ServiceView{}, err
}
ensureWaitlistDef(service, row.DisplayName)
if err := SetPlatformSwitch(waitlistKey(service), boolDef(mode), actor); err != nil {
if err := flags.SetPlatformSwitch(waitlistKey(service), boolDef(mode), actor); err != nil {
return ServiceView{}, err
}
return ServiceView{ServiceRow: row, WaitlistMode: Bool(waitlistKey(service))}, nil
return ServiceView{ServiceRow: row, WaitlistMode: flags.Bool(waitlistKey(service))}, nil
}
// UpsertWaitlistService onboards or edits a hosted service so a new host is governed
@@ -170,7 +195,7 @@ func SetWaitlistMode(ctx context.Context, service string, mode bool, actor strin
func UpsertWaitlistService(ctx context.Context, in ServiceInput, actor string) (ServiceView, error) {
svc := strings.ToLower(strings.TrimSpace(in.Service))
if svc == "" {
return ServiceView{}, fmt.Errorf("flags: service slug is required")
return ServiceView{}, fmt.Errorf("admission: service slug is required")
}
st, err := requireRegistry()
if err != nil {
@@ -192,43 +217,44 @@ func UpsertWaitlistService(ctx context.Context, in ServiceInput, actor string) (
}
ensureWaitlistDef(svc, row.DisplayName)
if isNew {
if err := SetPlatformSwitch(waitlistKey(svc), boolDef(in.WaitlistMode), actor); err != nil {
if err := flags.SetPlatformSwitch(waitlistKey(svc), boolDef(in.WaitlistMode), actor); err != nil {
return ServiceView{}, err
}
}
return ServiceView{ServiceRow: row, WaitlistMode: Bool(waitlistKey(svc))}, nil
return ServiceView{ServiceRow: row, WaitlistMode: flags.Bool(waitlistKey(svc))}, nil
}
// mountWaitlist seeds the registry and registers a waitlist.<svc> switch per known
// service. Best-effort + fail-safe: a registry error (e.g. cek master key not yet
// injected) degrades to the in-memory seed switches — the decide then fail-opens,
// exactly the flag engine's own boot posture. Called from Mount.
func mountWaitlist(c *Client, brand string, log luxlog.Logger) {
// seedRegistry seeds the registry and registers a waitlist.<svc> switch per known
// service, COMPOSING the flag engine (flags.Register). Best-effort + fail-safe: a
// registry error (e.g. cek master key not yet injected) degrades to the in-memory seed
// switches — the decide then fail-opens, exactly the flag engine's own boot posture.
// Returns the number of seeded services (for the mount log). Called from Mount.
func seedRegistry(brand string, log luxlog.Logger) int {
seed := seedWaitlist(brand)
for _, sv := range seed { // in-memory switches — always succeeds
Register(waitlistDef(sv.Service, sv.DisplayName))
flags.Register(waitlistDef(sv.Service, sv.DisplayName))
}
st, err := c.registry.For(platformOrg, platformProject)
st, err := mounted.store.For(platformOrg, platformProject)
if err != nil {
log.Warn("waitlist registry unavailable — modes degrade to seed defaults", "err", err)
return
return len(seed)
}
if _, err := st.Seed(context.Background(), seed, time.Now().Unix()); err != nil {
log.Warn("waitlist registry seed failed", "err", err)
return
return len(seed)
}
if rows, err := st.List(context.Background()); err == nil {
for _, r := range rows { // register any persisted onboard beyond the seed
ensureWaitlistDef(r.Service, r.DisplayName)
}
}
return len(seed)
}
// waitlistModeRoute answers GET /v1/featuregate/mode?host=<h> — the runtime lookup the
// waitlistModeRoute answers GET /v1/flags/waitlist?host=<h> — the runtime lookup the
// @file waitlist-guard caches. Public (in-cluster) read: it returns ONLY the boolean
// mode for the ONE queried host, never an enumeration. Same wire shape as the former
// featuregate route, so the interim guard ports 1:1.
func waitlistModeRoute(_ *cloud.Service[state], c *zip.Ctx) error {
// mode for the ONE queried host, never an enumeration.
func waitlistModeRoute(c *zip.Ctx) error {
host := strings.TrimSpace(c.Query("host"))
if host == "" {
host = c.Fiber().Hostname()
@@ -242,7 +268,44 @@ func waitlistModeRoute(_ *cloud.Service[state], c *zip.Ctx) error {
})
}
// ── brand seed (moved verbatim from the former featuregate/seed.go) ──────────────
// ── lifecycle ────────────────────────────────────────────────────────────────
// Mount installs the launch-control gate: it opens the platform-tenant host→service
// registry, seeds it for the deployment brand, registers a waitlist.<svc> switch per
// service in the flag engine (flags.Register), and serves the guard's public mode read
// at /v1/flags/waitlist. Fail-safe: a registry error (e.g. cek master key not yet
// injected) degrades to the in-memory seed switches — WaitlistModeForHost then
// fail-opens. Mounts AFTER flags so the engine's platform-switch plane is installed first.
func Mount(app *zip.App, deps cloud.Deps) error {
if deps.Logger == nil {
return fmt.Errorf("admission.Mount: nil deps.Logger")
}
if deps.DataDir == "" {
return fmt.Errorf("admission.Mount: empty deps.DataDir")
}
log := deps.Logger.New("subsystem", "admission")
mounted = &registryState{
store: cloud.NewOrgStore[*waitlistStore](deps.DataDir, "waitlist", openWaitlistStore),
brand: deps.Brand,
}
n := seedRegistry(deps.Brand, log)
// The guard's public runtime mode read (host→service→waitlist.<svc>), one namespace
// under /v1/flags. Exempt from the Enforce gate (see defaultExemptPrefixes) so a
// gated user can still resolve mode.
app.Get("/v1/flags/waitlist", waitlistModeRoute)
log.Info("admission gate ready", "services", n)
return nil
}
// Shutdown closes the launch registry's per-org store handles.
func Shutdown() error {
if mounted == nil || mounted.store == nil {
return nil
}
return mounted.store.CloseAll()
}
// ── brand seed (moved verbatim from the former flags/waitlist.go) ────────────────
// seedWaitlist returns the launch registry for a brand. White-labeled so a Lux/Zoo/Pars
// deployment governs its OWN hosts. New hosted services onboard at runtime via
+27 -136
View File
@@ -1,153 +1,44 @@
package affiliates
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/hanzoai/cloud/clients/commerceinproc"
"github.com/hanzoai/cloud/clients/payout"
)
// commerce is the narrow money seam the affiliate loop needs: read a referred
// org's metered spend (the accrual base) and grant a promo credit to a wallet (a
// payout made in credits). It is an INTERFACE so the store/handler logic is
// testable with a fake ledger the HTTP impl below is the ONE production binding.
// commerce is the narrow money seam the affiliate loop needs: read a referred org's
// metered spend (the commission accrual base) and grant a promo credit to a wallet
// (a payout made in credits, ledger tag grant:affiliate). It is an INTERFACE so the
// store/handler logic is testable with a fake ledger; the production binding is
// clients/payout, reached through the thin adapter below.
//
// This mirrors clients/referrals/commerce.go EXACTLY (which itself mirrors
// clients/admin/commerce.go): the same COMMERCE_SERVICE_TOKEN S2S path, the same
// X-Org-Id=<org> namespace + bare org `user` subject that admin.grantCredit uses —
// so an affiliate payout-in-credits lands in precisely the wallet the balance
// panel reads, indistinguishable from an admin grant except by its ledger tag
// (grant:affiliate vs grant:referral / grant:admin, all → the commerce Credit/trial
// bucket per DepositKind's grant:* rule).
// The S2S impl (COMMERCE_SERVICE_TOKEN path, X-Org-Id=<org> namespace, bare-org
// `user` subject) was three byte-identical commerce.go copies; it now lives ONCE in
// clients/payout. An affiliate payout-in-credits still lands in precisely the wallet
// the balance panel reads, indistinguishable from an admin grant except by its
// grant:affiliate tag.
type commerce interface {
configured() bool
// deposit grants amountCents to org's wallet (Credit/trial bucket via the
// grant:affiliate tag) and returns the ledger transaction id.
deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (txnID string, err error)
// spendCents is a referred org's month-to-date metered consumption — the
// commission accrual base (spend × the affiliate's rate).
spendCents(ctx context.Context, org, user string) (int64, error)
}
// errUnconfigured is returned by a deposit against an unwired commerce so the
// caller records an honest failure rather than reporting a phantom payout.
var errUnconfigured = errors.New("affiliates: commerce endpoint not configured")
// errUnconfigured is the shared sentinel a deposit against an unwired commerce
// returns, so the caller records an honest failure rather than a phantom payout.
var errUnconfigured = payout.ErrUnconfigured
// httpCommerce is the production commerce binding (COMMERCE_SERVICE_TOKEN S2S).
type httpCommerce struct {
base string
token string
http *http.Client
// commerceSeam adapts the shared payout.Client onto this program's lowercase seam
// (Go package-scoped interface methods cannot cross packages). Zero logic — pure
// delegation; the money path lives in clients/payout.
type commerceSeam struct{ c *payout.Client }
func (s commerceSeam) configured() bool { return s.c.Configured() }
func (s commerceSeam) deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (string, error) {
return s.c.Deposit(ctx, org, user, amountCents, currency, notes, tags)
}
func (s commerceSeam) spendCents(ctx context.Context, org, user string) (int64, error) {
return s.c.SpendCents(ctx, org, user)
}
func newCommerceClient(base, token string) *httpCommerce {
return &httpCommerce{
base: strings.TrimRight(strings.TrimSpace(base), "/"),
token: strings.TrimSpace(token),
http: commerceinproc.Client(15 * time.Second),
}
}
func (c *httpCommerce) configured() bool { return c != nil && c.base != "" && c.token != "" }
// deposit posts POST /v1/billing/deposit — the ONE money-in primitive (identical
// to admin.commerceClient.deposit). Commerce's EdgeAuth pins the body `user` to
// the X-Org-Id subject, so a payout can never be mis-targeted to another wallet.
func (c *httpCommerce) deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (string, error) {
if !c.configured() {
return "", errUnconfigured
}
if currency == "" {
currency = "usd"
}
body, err := json.Marshal(map[string]any{
"user": user,
"currency": currency,
"amount": amountCents,
"notes": notes,
"tags": tags,
})
if err != nil {
return "", err
}
raw, err := c.do(ctx, http.MethodPost, "/v1/billing/deposit", nil, org, body)
if err != nil {
return "", err
}
var out struct {
TransactionID string `json:"transactionId"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return "", fmt.Errorf("commerce deposit decode: %w", err)
}
return out.TransactionID, nil
}
// spendCents reads GET /v1/billing/usage-rollup and returns consumedCents. Zero
// (not an error) when commerce is unconfigured so a partial deploy degrades to
// "no spend to accrue yet" rather than a 5xx.
func (c *httpCommerce) spendCents(ctx context.Context, org, user string) (int64, error) {
if !c.configured() {
return 0, nil
}
q := url.Values{"user": {user}}
raw, err := c.do(ctx, http.MethodGet, "/v1/billing/usage-rollup", q, org, nil)
if err != nil {
return 0, err
}
var out struct {
ConsumedCents int64 `json:"consumedCents"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return 0, fmt.Errorf("commerce rollup decode: %w", err)
}
return out.ConsumedCents, nil
}
// do performs one admin-S2S commerce request. X-Org-Id=<org> is the per-org
// namespace selector commerce's EdgeAuth trusts only behind the service token.
func (c *httpCommerce) do(ctx context.Context, method, path string, q url.Values, org string, body []byte) ([]byte, error) {
u := c.base + path
if enc := q.Encode(); enc != "" {
u += "?" + enc
}
var r io.Reader
if body != nil {
r = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, u, r)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
if org != "" {
req.Header.Set("X-Org-Id", org)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("commerce unreachable: %w", err)
}
defer func() { _ = resp.Body.Close() }()
out, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("commerce status %d", resp.StatusCode)
}
return out, nil
}
// newCommerceClient builds the production binding, delegating to clients/payout.
func newCommerceClient(base, token string) commerce { return commerceSeam{payout.NewClient(base, token)} }
+1 -1
View File
@@ -22,7 +22,7 @@ import (
hz "github.com/hanzoai/agent"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/tools"
openai "github.com/sashabaranov/go-openai"
openai "github.com/hanzoai/go-openai"
fiber "github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip"
)
+38
View File
@@ -71,6 +71,44 @@ func OpenSession(ctx context.Context, org, actor, agent, title string) (string,
return id, nil
}
// OpenSessionOn is OpenSession with the run's dispatch TARGET recorded, so
// mission-control shows a routed run on the machine it was sent to (session.target
// == the target id) exactly as a locally-linked run shows its host. The target is
// re-resolved org-scoped and MUST belong to this org — a session can never claim
// to run on another tenant's machine (the same fail-closed rule sessionContext
// enforces on the HTTP register path). An empty target falls back to OpenSession.
func OpenSessionOn(ctx context.Context, org, actor, agent, title, target string) (string, error) {
target = strings.TrimSpace(target)
if target == "" {
return OpenSession(ctx, org, actor, agent, title)
}
if mounted == nil {
return "", fmt.Errorf("agents: not mounted")
}
org = strings.TrimSpace(org)
if org == "" {
return "", fmt.Errorf("agents: org required")
}
if _, err := mounted.State.store.GetTarget(ctx, org, target); err != nil {
if err == errTargetNotFound {
return "", fmt.Errorf("agents: target not found in this org")
}
return "", fmt.Errorf("agents: resolve target: %w", err)
}
id, err := OpenSession(ctx, org, actor, agent, title)
if err != nil {
return "", err
}
// Stamp the target onto the freshly-opened row (org-scoped update); a failure
// here is non-fatal — the session is live, it simply lacks its machine tag.
if x, gerr := mounted.State.store.GetSession(ctx, org, id); gerr == nil {
x.Target = target
x.UpdatedAt = time.Now().Unix()
_ = mounted.State.store.UpdateSession(ctx, x)
}
return id, nil
}
// LogSessionEvent appends one ordered event (message|tool-call|spawn|log|status|
// control) to an org's session and fans it out live. The (org, id) pair is
// re-resolved so a caller can only write to a session THIS org owns; kind is
+47
View File
@@ -114,3 +114,50 @@ func TestInproc_NotMounted_FailsClosed(t *testing.T) {
t.Fatal("unmounted OpenSession must fail closed")
}
}
// ResolveTarget turns a human's reference (id or friendly label) into the org's
// target, org-scoped and fail-closed: an id wins, else an exact case-folded label,
// and a reference matching neither — or another org's machine — is not found.
func TestResolveTarget_IdThenLabel_OrgScoped(t *testing.T) {
mountInproc(t)
ctx := context.Background()
now := int64(1000)
acme := Target{ID: "tgt_acme1", Org: "acme", Label: "evo", Kind: TargetGPU, Status: TargetOnline, Host: "evo", CreatedAt: now, UpdatedAt: now}
evil := Target{ID: "tgt_evil1", Org: "evil", Label: "evo", Kind: TargetGPU, Status: TargetOnline, Host: "evo", CreatedAt: now, UpdatedAt: now}
if err := mounted.State.store.CreateTarget(ctx, acme); err != nil {
t.Fatal(err)
}
if err := mounted.State.store.CreateTarget(ctx, evil); err != nil {
t.Fatal(err)
}
// By id.
if got, err := ResolveTarget(ctx, "acme", "tgt_acme1"); err != nil || got.ID != "tgt_acme1" {
t.Fatalf("resolve by id: %+v %v", got, err)
}
// By label (case-folded), scoped to the caller's org — never evil's same-labelled box.
if got, err := ResolveTarget(ctx, "acme", "EVO"); err != nil || got.ID != "tgt_acme1" {
t.Fatalf("resolve by label must find acme's own, got %+v %v", got, err)
}
// Another org's id is not found (no cross-tenant leak).
if _, err := ResolveTarget(ctx, "acme", "tgt_evil1"); err != errTargetNotFound {
t.Fatalf("cross-org id must be not-found, got %v", err)
}
// An unknown reference is not found — the caller renders an honest error.
if _, err := ResolveTarget(ctx, "acme", "nope"); err != errTargetNotFound {
t.Fatalf("unknown ref must be not-found, got %v", err)
}
// Empty ref is not found (never resolves to "some" machine).
if _, err := ResolveTarget(ctx, "acme", ""); err != errTargetNotFound {
t.Fatalf("empty ref must be not-found, got %v", err)
}
}
func TestResolveTarget_NotMounted_FailsClosed(t *testing.T) {
prev := mounted
mounted = nil
t.Cleanup(func() { mounted = prev })
if _, err := ResolveTarget(context.Background(), "acme", "evo"); err == nil {
t.Fatal("unmounted ResolveTarget must fail closed")
}
}
+236
View File
@@ -0,0 +1,236 @@
package agents
import (
"context"
"sync"
)
// mailbox.go is the LIVE hand-off between a routed run's durable owner (the
// coding RoutedRunWorkflow, running on the embedded tasks engine) and the
// external machine that claims and executes it over HTTP. It is the rendezvous
// ONLY — never the durable queue. The tasks engine is the queue of record: it
// survives a cloud restart, times a never-claimed run out, and retries. On every
// (re)start of the delivery activity the run is (re-)Offered here, so a machine
// that long-polls Claim always finds work the engine still owns; a cloud restart
// simply re-populates the mailbox from durable history.
//
// ISOLATION IS STRUCTURAL. Every offer is filed under the key (org, target), and
// Claim/Report only ever touch that one key's slot. A run offered for (orgB,
// targetY) is unreachable from a Claim or Report for (orgA, targetX) — the tenant
// + machine boundary is a property of the map key, not a check a caller can skip.
// RoutedRun is the NON-SECRET spec of one coding run dispatched to a target. It
// carries no credential by design: the executing machine authenticates git +
// model routing with its OWN already-held credentials (the same ones `hanzo code`
// uses), so no secret ever enters the durable store or crosses to the machine in
// the claim response. Everything here is safe to persist in the tasks engine.
type RoutedRun struct {
Org string `json:"org"`
TargetID string `json:"targetId"`
SessionID string `json:"sessionId"` // the live session opened at dispatch; the machine streams into it
Repo string `json:"repo"`
Project string `json:"project,omitempty"`
Base string `json:"base,omitempty"`
Branch string `json:"branch"`
Prompt string `json:"prompt"`
CloneURL string `json:"cloneUrl"`
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
}
// RoutedResult is a routed run's terminal outcome, reported by the machine and
// returned to the durable activity so the workflow completes.
type RoutedResult struct {
OK bool `json:"ok"`
Changed bool `json:"changed"`
Branch string `json:"branch,omitempty"`
CommitSha string `json:"commitSha,omitempty"`
Diffstat string `json:"diffstat,omitempty"`
Error string `json:"error,omitempty"`
}
// offer is one run waiting to be claimed, plus the channel its durable owner
// blocks on for the terminal result. result is buffered(1) so Report never blocks
// even if the owner is between selects; closed fires when the offer is finished
// (reported OR abandoned) so a waiter always unblocks.
type offer struct {
mb *mailbox
key string // (org,target)
rk string // (org,target,sessionID)
run RoutedRun
result chan RoutedResult
closed chan struct{}
once sync.Once
}
// Await blocks until the machine reports this run's result, the offer is
// abandoned, or ctx (the activity's StartToClose budget) fires. It is the
// durable owner's half of the rendezvous.
func (o *offer) Await(ctx context.Context) (RoutedResult, bool) {
select {
case res := <-o.result:
return res, true
case <-o.closed:
// Abandoned or reported-then-closed: drain a delivered result if one raced in.
select {
case res := <-o.result:
return res, true
default:
return RoutedResult{}, false
}
case <-ctx.Done():
return RoutedResult{}, false
}
}
// Close removes the offer from the mailbox (if still present) and unblocks any
// waiter. Idempotent — the durable owner defers it so a timed-out or crashed
// delivery never leaks a queued or claimed offer.
func (o *offer) Close() { o.mb.discard(o) }
// mailbox is the process-wide rendezvous. queues holds each key's FIFO of
// unclaimed offers; byRun indexes every live offer by (org,target,sessionID) for
// Report + re-offer dedupe; signal is a per-key broadcast channel (closed and
// recreated on Offer) that Claim waits on.
type mailbox struct {
mu sync.Mutex
queues map[string][]*offer
byRun map[string]*offer
signal map[string]chan struct{}
}
func newMailbox() *mailbox {
return &mailbox{
queues: map[string][]*offer{},
byRun: map[string]*offer{},
signal: map[string]chan struct{}{},
}
}
// routedMailbox is the ONE process-wide rendezvous, shared by the coding
// delivery activity (Offer/Await) and the machine-facing HTTP surface
// (Claim/Report). One mailbox, one way.
var routedMailbox = newMailbox()
func mbKey(org, target string) string { return org + "\x00" + target }
func runKey(org, target, sess string) string { return org + "\x00" + target + "\x00" + sess }
// Offer files run for its (org,target) and returns the handle its durable owner
// awaits. A re-offer of the same (org,target,sessionID) — the workflow retrying
// or replaying after a restart — supersedes the stale prior offer (removing it
// from the queue and unblocking its dead waiter) so a machine never claims a run
// whose owner has already moved on.
func (m *mailbox) Offer(run RoutedRun) *offer {
key := mbKey(run.Org, run.TargetID)
rk := runKey(run.Org, run.TargetID, run.SessionID)
o := &offer{mb: m, key: key, rk: rk, run: run, result: make(chan RoutedResult, 1), closed: make(chan struct{})}
m.mu.Lock()
if prev := m.byRun[rk]; prev != nil {
m.removeFromQueueLocked(key, prev)
prev.finish()
}
m.byRun[rk] = o
m.queues[key] = append(m.queues[key], o)
m.broadcastLocked(key)
m.mu.Unlock()
return o
}
// Claim blocks until an unclaimed run exists for (org,target) or ctx fires,
// returning the oldest. The claimed offer leaves the queue but stays in byRun,
// awaiting Report. Only this key's queue is ever read, so a claim can never
// surface another tenant's or another machine's run.
func (m *mailbox) Claim(ctx context.Context, org, target string) (RoutedRun, bool) {
key := mbKey(org, target)
for {
m.mu.Lock()
if q := m.queues[key]; len(q) > 0 {
o := q[0]
m.queues[key] = q[1:]
m.mu.Unlock()
return o.run, true
}
sig := m.signalLocked(key)
m.mu.Unlock()
select {
case <-sig:
// a new offer (or a superseding one) arrived — re-check
case <-ctx.Done():
return RoutedRun{}, false
}
}
}
// Report delivers a terminal result to the run's durable owner. Scoped to
// (org,target,sessionID): a report can only ever complete a run that exact key
// owns, so one machine can never report on behalf of another. Returns false when
// no live offer matches (already reported, abandoned, or never existed).
func (m *mailbox) Report(org, target, sess string, res RoutedResult) bool {
rk := runKey(org, target, sess)
m.mu.Lock()
o := m.byRun[rk]
if o == nil {
m.mu.Unlock()
return false
}
delete(m.byRun, rk)
m.removeFromQueueLocked(mbKey(org, target), o)
m.mu.Unlock()
o.deliver(res)
return true
}
// discard drops an offer the owner is done with (ctx timeout / crash / normal
// close) so neither the queue nor byRun retains it.
func (m *mailbox) discard(o *offer) {
m.mu.Lock()
if m.byRun[o.rk] == o {
delete(m.byRun, o.rk)
}
m.removeFromQueueLocked(o.key, o)
m.mu.Unlock()
o.finish()
}
func (m *mailbox) removeFromQueueLocked(key string, o *offer) {
q := m.queues[key]
for i, e := range q {
if e == o {
m.queues[key] = append(q[:i:i], q[i+1:]...)
return
}
}
}
// broadcastLocked wakes every Claim waiting on key by closing its signal channel;
// a fresh channel replaces it for the next wait.
func (m *mailbox) broadcastLocked(key string) {
if ch, ok := m.signal[key]; ok {
close(ch)
delete(m.signal, key)
}
}
func (m *mailbox) signalLocked(key string) chan struct{} {
ch, ok := m.signal[key]
if !ok {
ch = make(chan struct{})
m.signal[key] = ch
}
return ch
}
func (o *offer) deliver(res RoutedResult) {
o.once.Do(func() {
o.result <- res // buffered(1) — never blocks
close(o.closed)
})
}
func (o *offer) finish() {
o.once.Do(func() { close(o.closed) })
}
// OfferRoutedRun is the exported seam the coding delivery activity uses to place
// a run into the live rendezvous. Kept here (agents owns targets + sessions) so
// the machine-facing HTTP surface and the durable activity share ONE mailbox.
func OfferRoutedRun(run RoutedRun) *offer { return routedMailbox.Offer(run) }
+166
View File
@@ -0,0 +1,166 @@
package agents
import (
"context"
"sync"
"testing"
"time"
)
func mkRun(org, target, sess string) RoutedRun {
return RoutedRun{Org: org, TargetID: target, SessionID: sess, Repo: "api", Branch: "agent/" + sess}
}
// A claimed run comes back to exactly one claimer, then its report reaches the
// offerer that is awaiting it.
func TestMailbox_OfferClaimReport(t *testing.T) {
m := newMailbox()
off := m.Offer(mkRun("acme", "tgt_1", "sess_1"))
got, ok := m.Claim(context.Background(), "acme", "tgt_1")
if !ok || got.SessionID != "sess_1" {
t.Fatalf("claim wrong: ok=%v run=%+v", ok, got)
}
done := make(chan RoutedResult, 1)
go func() {
res, _ := off.Await(context.Background())
done <- res
}()
if !m.Report("acme", "tgt_1", "sess_1", RoutedResult{OK: true, CommitSha: "abc"}) {
t.Fatal("report should deliver to the awaiting offer")
}
select {
case res := <-done:
if !res.OK || res.CommitSha != "abc" {
t.Fatalf("await got wrong result: %+v", res)
}
case <-time.After(2 * time.Second):
t.Fatal("await never received the reported result")
}
}
// THE tenant + machine boundary: a claim for (org,target) can NEVER surface a run
// offered for a different org OR a different target — it is a property of the key.
func TestMailbox_CrossTenantAndCrossMachineIsolation(t *testing.T) {
m := newMailbox()
m.Offer(mkRun("orgB", "tgt_Y", "sess_foreign_org"))
m.Offer(mkRun("acme", "tgt_Y", "sess_foreign_machine"))
m.Offer(mkRun("acme", "tgt_X", "sess_mine"))
// A claim for (acme, tgt_X) gets ONLY acme/tgt_X's run.
got, ok := m.Claim(context.Background(), "acme", "tgt_X")
if !ok || got.SessionID != "sess_mine" {
t.Fatalf("claim leaked across a boundary: ok=%v run=%+v", ok, got)
}
// And that queue is now empty — no foreign run fell through.
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
if _, ok := m.Claim(ctx, "acme", "tgt_X"); ok {
t.Fatal("a foreign run must never be claimable as acme/tgt_X")
}
// A Report can only complete a run under its exact key: reporting the foreign
// machine's session under tgt_X does nothing.
if m.Report("acme", "tgt_X", "sess_foreign_machine", RoutedResult{OK: true}) {
t.Fatal("report crossed the machine boundary")
}
if m.Report("acme", "tgt_Y", "sess_foreign_org", RoutedResult{OK: true}) {
t.Fatal("report crossed the org boundary")
}
}
// Two racing claimers, one run: exactly one wins.
func TestMailbox_NoDoubleClaim(t *testing.T) {
m := newMailbox()
m.Offer(mkRun("acme", "tgt_1", "sess_1"))
var wins int
var mu sync.Mutex
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
if _, ok := m.Claim(ctx, "acme", "tgt_1"); ok {
mu.Lock()
wins++
mu.Unlock()
}
}()
}
wg.Wait()
if wins != 1 {
t.Fatalf("exactly one claimer must win, got %d", wins)
}
}
// A claim with no work times out on ctx and reports no run — fail closed, never hang.
func TestMailbox_ClaimTimesOut(t *testing.T) {
m := newMailbox()
ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond)
defer cancel()
if _, ok := m.Claim(ctx, "acme", "tgt_empty"); ok {
t.Fatal("an empty mailbox must not yield a run")
}
}
// The durable owner's Await unblocks (fail-closed) when its budget ctx fires with
// no report — the machine never claimed, or claimed and died.
func TestMailbox_AwaitFailsClosedOnDeadline(t *testing.T) {
m := newMailbox()
off := m.Offer(mkRun("acme", "tgt_1", "sess_1"))
ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond)
defer cancel()
if _, ok := off.Await(ctx); ok {
t.Fatal("await must fail closed when the deadline fires without a report")
}
}
// A re-offer of the same run (workflow retry / cloud restart) supersedes the stale
// offer: the old waiter unblocks abandoned, and the fresh run is claimable.
func TestMailbox_ReOfferSupersedes(t *testing.T) {
m := newMailbox()
old := m.Offer(mkRun("acme", "tgt_1", "sess_1"))
// re-offer BEFORE anyone claims the first
fresh := m.Offer(mkRun("acme", "tgt_1", "sess_1"))
// old is abandoned
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if _, ok := old.Await(ctx); ok {
t.Fatal("the superseded offer must not complete")
}
// exactly one claimable run remains, and reporting reaches the fresh offer
got, ok := m.Claim(context.Background(), "acme", "tgt_1")
if !ok || got.SessionID != "sess_1" {
t.Fatalf("fresh run not claimable: %+v", got)
}
if _, ok := m.Claim(ctxShort(), "acme", "tgt_1"); ok {
t.Fatal("the stale offer must not have left a duplicate in the queue")
}
done := make(chan struct{})
go func() { fresh.Await(context.Background()); close(done) }()
if !m.Report("acme", "tgt_1", "sess_1", RoutedResult{OK: true}) {
t.Fatal("report must reach the fresh offer")
}
<-done
}
// Report for an unknown/already-finished run is a clean false.
func TestMailbox_ReportUnknownIsNoOp(t *testing.T) {
m := newMailbox()
if m.Report("acme", "tgt_1", "nope", RoutedResult{OK: true}) {
t.Fatal("report for an unknown run must be a no-op")
}
}
func ctxShort() context.Context {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Millisecond)
_ = cancel
return ctx
}
+179
View File
@@ -0,0 +1,179 @@
package agents
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
)
// routing.go is the machine-identity + liveness plane for routed runs (#48 half
// B). A run dispatched to a target is executed by an EXTERNAL machine (`hanzo
// code --serve`) that claims it over HTTP. Two properties make that safe:
//
// - MACHINE IDENTITY. A target carries a claim key — a high-entropy capability
// minted server-side, returned to the daemon ONCE, and stored only as a
// SHA-256 hash (never plaintext, like every other secret). A claim/report
// must present the key; cloud verifies it in constant time, scoped to
// (org, target). Possession of the key IS being that machine, so one machine
// can never claim another's runs even within the same org.
//
// - LIVENESS. Dispatch routes ONLY to a target with a live runner. A serve
// daemon proves liveness by polling Claim, which stamps serving_at; the gate
// rejects a target whose last poll is older than servingTTL. A run to a dead
// or absent runner fails closed at dispatch (never silently runs elsewhere),
// and one that dies mid-flight is re-queued/timed-out by the durable owner.
//
// The claim key lives in its own table so the live (A) target CRUD is untouched.
const (
// servingTTL bounds how stale a target's last claim poll may be and still be
// considered "a runner is listening". The serve daemon re-polls right after a
// 25s long-poll returns empty, so a healthy runner stamps well inside this.
servingTTL = 90 * time.Second
claimKeyPrefix = "tgtk_"
claimKeyBytes = 32 // 256-bit capability
maxClaimKey = 128
)
var (
errNoClaimKey = errors.New("agents: target has no claim key")
errClaimKeyBad = errors.New("agents: claim key mismatch")
errTargetNotLive = errors.New("agents: target has no live runner")
errTargetNotReady = errors.New("agents: target is not online")
)
// migrateClaimKeys creates the per-target claim-key + serving-liveness table in
// the SAME agents.db (one store, one tenancy column). Idempotent.
func (s *Store) migrateClaimKeys() error {
const ddl = `
CREATE TABLE IF NOT EXISTS agent_target_claim_keys (
org TEXT NOT NULL,
target_id TEXT NOT NULL,
key_hash TEXT NOT NULL,
serving_at INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY (org, target_id)
);`
if _, err := s.db.Exec(ddl); err != nil {
return fmt.Errorf("migrate claim keys: %w", err)
}
return nil
}
// UpsertClaimKeyHash stores (or rotates) a target's claim-key hash. serving_at is
// reset to 0 on a fresh mint — the daemon proves liveness by its first poll.
func (s *Store) UpsertClaimKeyHash(ctx context.Context, org, targetID, hash string, now int64) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO agent_target_claim_keys (org,target_id,key_hash,serving_at,updated_at)
VALUES (?,?,?,0,?)
ON CONFLICT(org,target_id) DO UPDATE SET key_hash=excluded.key_hash, serving_at=0, updated_at=excluded.updated_at`,
org, targetID, hash, now)
if err != nil {
return fmt.Errorf("upsert claim key: %w", err)
}
return nil
}
// ClaimKeyHash returns a target's stored hash + last serving stamp, or
// errNoClaimKey when none was ever minted.
func (s *Store) ClaimKeyHash(ctx context.Context, org, targetID string) (hash string, servingAt int64, err error) {
row := s.db.QueryRowContext(ctx,
`SELECT key_hash, serving_at FROM agent_target_claim_keys WHERE org=? AND target_id=?`, org, targetID)
err = row.Scan(&hash, &servingAt)
if errors.Is(err, sql.ErrNoRows) {
return "", 0, errNoClaimKey
}
if err != nil {
return "", 0, fmt.Errorf("get claim key: %w", err)
}
return hash, servingAt, nil
}
// StampServing records that a target's runner polled at now (its liveness
// heartbeat). Best-effort by the caller; a missing row is a no-op.
func (s *Store) StampServing(ctx context.Context, org, targetID string, now int64) error {
_, err := s.db.ExecContext(ctx,
`UPDATE agent_target_claim_keys SET serving_at=? WHERE org=? AND target_id=?`, now, org, targetID)
return err
}
// hashClaimKey is the at-rest form: SHA-256 hex of a high-entropy token. A random
// 256-bit key needs no password KDF; SHA-256 gives a fixed-size, constant-time-
// comparable digest and the plaintext is never stored.
func hashClaimKey(key string) string {
sum := sha256.Sum256([]byte(key))
return hex.EncodeToString(sum[:])
}
// newClaimKey mints a fresh capability token.
func newClaimKey() (string, error) {
b := make([]byte, claimKeyBytes)
if _, err := rand.Read(b); err != nil {
return "", err
}
return claimKeyPrefix + hex.EncodeToString(b), nil
}
// verifyClaimKey checks a presented key against the target's stored hash in
// constant time. Fail-closed: no key on file, or an empty presented key, is a
// mismatch — never an accidental pass.
func (s *Store) verifyClaimKey(ctx context.Context, org, targetID, presented string) error {
presented = strings.TrimSpace(presented)
if presented == "" || len(presented) > maxClaimKey {
return errClaimKeyBad
}
stored, _, err := s.ClaimKeyHash(ctx, org, targetID)
if err != nil {
return err // errNoClaimKey or a real DB error
}
if subtle.ConstantTimeCompare([]byte(stored), []byte(hashClaimKey(presented))) != 1 {
return errClaimKeyBad
}
return nil
}
// TargetDispatchable is the DRY liveness gate, used at dispatch (fail closed
// before enqueue) AND re-checked at claim. A run is dispatchable only to a target
// that (a) exists in this org, (b) is online, and (c) has a live runner — a claim
// poll within servingTTL. Any failure is an explicit error the dispatcher renders
// honestly; it NEVER falls back to running elsewhere.
func (s *Store) TargetDispatchable(ctx context.Context, org, targetID string) error {
t, err := s.GetTarget(ctx, org, targetID)
if err != nil {
return err // errTargetNotFound or a real DB error
}
if t.Status != TargetOnline {
return errTargetNotReady
}
_, servingAt, err := s.ClaimKeyHash(ctx, org, targetID)
if err != nil {
return errTargetNotLive // no claim key => no runner ever attached
}
if servingAt <= 0 || time.Now().Unix()-servingAt > int64(servingTTL/time.Second) {
return errTargetNotLive
}
return nil
}
// TargetDispatchable is the exported gate the coding dispatcher injects (it never
// imports the store directly). Returns nil when a run may be routed to (org,
// targetID), else a descriptive error.
func TargetDispatchable(ctx context.Context, org, targetID string) error {
if mounted == nil {
return fmt.Errorf("agents: not mounted")
}
org = strings.TrimSpace(org)
targetID = strings.TrimSpace(targetID)
if org == "" || targetID == "" {
return fmt.Errorf("agents: org and target required")
}
return mounted.State.store.TargetDispatchable(ctx, org, targetID)
}
+163
View File
@@ -0,0 +1,163 @@
package agents
import (
"context"
"net/http"
"strings"
"time"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
)
// routing_http.go is the machine-facing surface a `hanzo code --serve` daemon
// uses to CLAIM and complete routed runs. Every route is BOTH org-scoped (the
// gateway-minted X-Org-Id, exactly like the rest of the targets plane) AND
// machine-authenticated (the target claim key in X-Target-Key): a caller must
// prove it is acting in the target's org and that it holds that specific
// machine's capability. A run offered to target X is never reachable from a claim
// for target Y, and a claim for another org's target 404s at the org boundary.
//
// POST /v1/agents/targets/:id/claim-key mint/rotate this target's claim key -> {claimKey}
// POST /v1/agents/targets/:id/claim long-poll for the next routed run (X-Target-Key)
// POST /v1/agents/targets/:id/runs/:runId/report report a routed run's terminal result (X-Target-Key)
// claimLongPoll bounds one Claim wait; on expiry the daemon gets 204 and re-polls
// immediately, which also refreshes its serving liveness. A var (not a const) so a
// test can shrink the empty-poll window without waiting the full window.
var claimLongPoll = 25 * time.Second
const (
// claimKeyHeader carries the machine capability. Distinct from Authorization
// (which carries the org bearer): org identity and machine identity are two
// independent proofs, both required.
claimKeyHeader = "X-Target-Key"
maxReportField = 64 << 10
)
// mountRouting registers the route-work machine surface. Called from mountTargets
// AFTER the target CRUD routes so the extra-segment paths are unambiguous.
func mountRouting(s *cloud.Service[state], app *zip.App) {
app.Post("/v1/agents/targets/:id/claim-key", cloud.Handle(s, mintClaimKey))
app.Post("/v1/agents/targets/:id/claim", cloud.Handle(s, claimRoutedRun))
app.Post("/v1/agents/targets/:id/runs/:runId/report", cloud.Handle(s, reportRoutedRun))
}
// mintClaimKey (re)mints the target's claim key and returns it ONCE. Only the
// SHA-256 hash is stored. Org-scoped: only a caller in the target's org can mint,
// and the key is bound to (org, target). Rotating supersedes any prior daemon.
func mintClaimKey(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
id := idParam(c)
// The target must exist in this org before it can carry a capability.
if _, err := s.State.store.GetTarget(c.Context(), org, id); err == errTargetNotFound {
return zip.ErrNotFound("target not found")
} else if err != nil {
return zip.Errorf(http.StatusInternalServerError, "target: %v", err)
}
key, err := newClaimKey()
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "rng: %v", err)
}
if err := s.State.store.UpsertClaimKeyHash(c.Context(), org, id, hashClaimKey(key), time.Now().Unix()); err != nil {
return zip.Errorf(http.StatusInternalServerError, "persist: %v", err)
}
return c.JSON(http.StatusOK, map[string]any{"targetId": id, "claimKey": key})
}
// claimRoutedRun authenticates the machine, refreshes its serving liveness, and
// long-polls the rendezvous for the next run addressed to THIS (org, target).
// 200 + the run on a claim; 204 when the poll window elapses with no work.
func claimRoutedRun(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
id := idParam(c)
if err := s.State.store.verifyClaimKey(c.Context(), org, id, c.Header(claimKeyHeader)); err != nil {
return claimAuthError(err)
}
// The poll itself is the runner's liveness proof — stamp it so the dispatch
// gate (TargetDispatchable) sees a live runner. Best-effort.
_ = s.State.store.StampServing(c.Context(), org, id, time.Now().Unix())
ctx, cancel := context.WithTimeout(c.Context(), claimLongPoll)
defer cancel()
run, got := routedMailbox.Claim(ctx, org, id)
if !got {
return c.NoContent(http.StatusNoContent)
}
return c.JSON(http.StatusOK, routedRunView(run))
}
type reportReq struct {
OK bool `json:"ok"`
Changed bool `json:"changed"`
Branch string `json:"branch"`
CommitSha string `json:"commitSha"`
Diffstat string `json:"diffstat"`
Error string `json:"error"`
}
// reportRoutedRun completes a claimed run: it delivers the terminal result to the
// run's durable owner (the RoutedRunWorkflow activity), which lets the workflow
// finish. Scoped to (org, target, runId) AND claim-key-authenticated, so a
// machine can only ever report a run it legitimately holds. Idempotent: a report
// for an unknown/already-finished run is a clean no-op (the session terminal was
// already set by the machine's own stream).
func reportRoutedRun(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
id := idParam(c)
runID := strings.TrimSpace(c.Param("runId"))
if err := s.State.store.verifyClaimKey(c.Context(), org, id, c.Header(claimKeyHeader)); err != nil {
return claimAuthError(err)
}
var body reportReq
if err := c.Bind(&body); err != nil {
return err
}
res := RoutedResult{
OK: body.OK, Changed: body.Changed,
Branch: clampStr(body.Branch, maxRepo),
CommitSha: clampStr(body.CommitSha, 128),
Diffstat: clampStr(body.Diffstat, maxReportField),
Error: clampStr(body.Error, maxReportField),
}
delivered := routedMailbox.Report(org, id, runID, res)
return c.JSON(http.StatusOK, map[string]any{"delivered": delivered})
}
// claimAuthError maps the claim-key verdict onto a fail-closed HTTP status. A
// missing target row, a missing/mismatched key, and an unknown org all collapse
// to 403 so the surface never distinguishes "wrong key" from "no such target" —
// an unauthorized caller learns nothing about what exists.
func claimAuthError(err error) error {
switch err {
case errNoClaimKey, errClaimKeyBad, errTargetNotFound:
return zip.ErrForbidden("target claim rejected")
default:
return zip.Errorf(http.StatusInternalServerError, "claim auth: %v", err)
}
}
// routedRunView is the non-secret run spec handed to the machine. It carries no
// credential by design — the machine authenticates git + model routing with its
// own already-held credentials.
func routedRunView(run RoutedRun) map[string]any {
return map[string]any{
"sessionId": run.SessionID,
"repo": run.Repo,
"project": run.Project,
"base": run.Base,
"branch": run.Branch,
"prompt": run.Prompt,
"cloneUrl": run.CloneURL,
"timeoutSeconds": run.TimeoutSeconds,
}
}
+250
View File
@@ -0,0 +1,250 @@
package agents
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http/httptest"
"testing"
"time"
"github.com/zap-proto/zip"
)
// doKey is a keyless-body request with a machine claim key (X-Target-Key) attached.
func doKey(t *testing.T, app *zip.App, method, path, org, key string) (int, []byte) {
t.Helper()
req := httptest.NewRequest(method, path, nil)
if org != "" {
req.Header.Set("X-Org-Id", org)
req.Header.Set("X-User-Id", "u-"+org)
}
if key != "" {
req.Header.Set(claimKeyHeader, key)
}
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("Test %s %s: %v", method, path, err)
}
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, b
}
// registerAndMint registers a target for org and mints its claim key, returning
// (targetID, claimKey).
func registerAndMint(t *testing.T, app *zip.App, org, host string) (string, string) {
t.Helper()
code, body := do(t, app, "POST", "/v1/agents/targets", org, map[string]any{"label": host, "host": host})
if code != 201 && code != 200 {
t.Fatalf("register target: %d %s", code, body)
}
var tv struct{ ID string `json:"id"` }
_ = json.Unmarshal(body, &tv)
code, body = doKey(t, app, "POST", "/v1/agents/targets/"+tv.ID+"/claim-key", org, "")
if code != 200 {
t.Fatalf("mint claim key: %d %s", code, body)
}
var kv struct{ ClaimKey string `json:"claimKey"` }
_ = json.Unmarshal(body, &kv)
if kv.ClaimKey == "" {
t.Fatal("claim key empty")
}
return tv.ID, kv.ClaimKey
}
// A claim without the machine's key, or with the WRONG key, is refused — org
// membership alone is not enough to claim a machine's runs.
func TestClaim_RequiresMachineKey(t *testing.T) {
app := mountApp(t, &fakeAI{content: "x"})
old := claimLongPoll
claimLongPoll = 150 * time.Millisecond
defer func() { claimLongPoll = old }()
id, key := registerAndMint(t, app, "acme", "evo")
// No key => 403.
if code, _ := doKey(t, app, "POST", "/v1/agents/targets/"+id+"/claim", "acme", ""); code != 403 {
t.Fatalf("claim with no key must be 403, got %d", code)
}
// Wrong key => 403.
if code, _ := doKey(t, app, "POST", "/v1/agents/targets/"+id+"/claim", "acme", "tgtk_wrong"); code != 403 {
t.Fatalf("claim with wrong key must be 403, got %d", code)
}
// Right key, no work => 204 (never 200, never another tenant's run).
if code, _ := doKey(t, app, "POST", "/v1/agents/targets/"+id+"/claim", "acme", key); code != 204 {
t.Fatalf("claim with right key + no work must be 204, got %d", code)
}
}
// THE machine boundary: a key minted for target A cannot claim target B, and a
// different org cannot claim at all — even with a real key for its own target.
func TestClaim_CrossMachineAndCrossOrgDenied(t *testing.T) {
app := mountApp(t, &fakeAI{content: "x"})
old := claimLongPoll
claimLongPoll = 150 * time.Millisecond
defer func() { claimLongPoll = old }()
idA, keyA := registerAndMint(t, app, "acme", "evoA")
idB, _ := registerAndMint(t, app, "acme", "evoB")
// A's key against B => 403 (constant-time mismatch on B's stored hash).
if code, _ := doKey(t, app, "POST", "/v1/agents/targets/"+idB+"/claim", "acme", keyA); code != 403 {
t.Fatalf("A's key claiming B must be 403, got %d", code)
}
// Offer a run for acme/idA, then a DIFFERENT org cannot claim idA at all (its
// org scope resolves no such target => 403), and the run is never handed out.
OfferRoutedRun(RoutedRun{Org: "acme", TargetID: idA, SessionID: "sess_a", Repo: "api"})
if code, _ := doKey(t, app, "POST", "/v1/agents/targets/"+idA+"/claim", "evil", keyA); code != 403 {
t.Fatalf("another org claiming acme's target must be 403, got %d", code)
}
// acme WITH A's key claims its own run.
code, body := doKey(t, app, "POST", "/v1/agents/targets/"+idA+"/claim", "acme", keyA)
if code != 200 {
t.Fatalf("acme must claim its own run, got %d %s", code, body)
}
var rv struct{ SessionID string `json:"sessionId"` }
_ = json.Unmarshal(body, &rv)
if rv.SessionID != "sess_a" {
t.Fatalf("claimed wrong run: %s", body)
}
}
// The end-to-end machine round trip: offer -> claim -> report reaches the durable
// owner awaiting the result.
func TestClaimReport_RoundTrip(t *testing.T) {
app := mountApp(t, &fakeAI{content: "x"})
id, key := registerAndMint(t, app, "acme", "evo")
off := OfferRoutedRun(RoutedRun{Org: "acme", TargetID: id, SessionID: "sess_rt", Repo: "api", Branch: "agent/rt"})
code, body := doKey(t, app, "POST", "/v1/agents/targets/"+id+"/claim", "acme", key)
if code != 200 {
t.Fatalf("claim: %d %s", code, body)
}
got := make(chan RoutedResult, 1)
go func() { res, _ := off.Await(context.Background()); got <- res }()
code, _ = doKeyBody(t, app, "POST", "/v1/agents/targets/"+id+"/runs/sess_rt/report", "acme", key,
map[string]any{"ok": true, "changed": true, "commitSha": "cafe"})
if code != 200 {
t.Fatalf("report: %d", code)
}
select {
case res := <-got:
if !res.OK || res.CommitSha != "cafe" {
t.Fatalf("report did not reach the owner: %+v", res)
}
case <-time.After(2 * time.Second):
t.Fatal("owner never received the report")
}
}
// doKeyBody is doKey with a JSON body.
func doKeyBody(t *testing.T, app *zip.App, method, path, org, key string, body any) (int, []byte) {
t.Helper()
b, _ := json.Marshal(body)
req := httptest.NewRequest(method, path, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
if org != "" {
req.Header.Set("X-Org-Id", org)
req.Header.Set("X-User-Id", "u-"+org)
}
if key != "" {
req.Header.Set(claimKeyHeader, key)
}
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("Test %s %s: %v", method, path, err)
}
defer func() { _ = resp.Body.Close() }()
out, _ := io.ReadAll(resp.Body)
return resp.StatusCode, out
}
// ---- store-level liveness gate ----
// TargetDispatchable is the fail-closed gate: online + a live runner (a recent
// claim poll). Offline, no key, or a stale poll all reject.
func TestTargetDispatchable_LivenessGate(t *testing.T) {
s := testSessionStore(t)
ctx := context.Background()
now := time.Now().Unix()
tgt := Target{ID: "t1", Org: "acme", Label: "evo", Kind: TargetMachine, Status: TargetOnline, Host: "evo", CreatedAt: now, UpdatedAt: now}
if err := s.CreateTarget(ctx, tgt); err != nil {
t.Fatal(err)
}
// No claim key yet => not live => not dispatchable.
if err := s.TargetDispatchable(ctx, "acme", "t1"); err != errTargetNotLive {
t.Fatalf("no runner => not dispatchable, got %v", err)
}
// Mint + a fresh serving stamp => dispatchable.
if err := s.UpsertClaimKeyHash(ctx, "acme", "t1", hashClaimKey("k"), now); err != nil {
t.Fatal(err)
}
if err := s.StampServing(ctx, "acme", "t1", now); err != nil {
t.Fatal(err)
}
if err := s.TargetDispatchable(ctx, "acme", "t1"); err != nil {
t.Fatalf("online + fresh runner => dispatchable, got %v", err)
}
// A stale serving stamp => not dispatchable (dead runner).
if err := s.StampServing(ctx, "acme", "t1", now-int64(servingTTL/time.Second)-5); err != nil {
t.Fatal(err)
}
if err := s.TargetDispatchable(ctx, "acme", "t1"); err != errTargetNotLive {
t.Fatalf("stale runner => not dispatchable, got %v", err)
}
// Fresh again but OFFLINE => not dispatchable.
_ = s.StampServing(ctx, "acme", "t1", time.Now().Unix())
tgt.Status = TargetOffline
tgt.UpdatedAt = time.Now().Unix()
if err := s.UpdateTarget(ctx, tgt); err != nil {
t.Fatal(err)
}
if err := s.TargetDispatchable(ctx, "acme", "t1"); err != errTargetNotReady {
t.Fatalf("offline => not dispatchable, got %v", err)
}
// Unknown target / cross-org => fail closed.
if err := s.TargetDispatchable(ctx, "acme", "nope"); err != errTargetNotFound {
t.Fatalf("unknown target => not found, got %v", err)
}
if err := s.TargetDispatchable(ctx, "evil", "t1"); err != errTargetNotFound {
t.Fatalf("cross-org => not found, got %v", err)
}
}
// The claim key is stored ONLY as a hash; verify is constant-time + fail closed.
func TestClaimKey_HashedAtRestAndVerified(t *testing.T) {
s := testSessionStore(t)
ctx := context.Background()
now := time.Now().Unix()
_ = s.CreateTarget(ctx, Target{ID: "t1", Org: "acme", Status: TargetOnline, CreatedAt: now, UpdatedAt: now})
key, _ := newClaimKey()
if err := s.UpsertClaimKeyHash(ctx, "acme", "t1", hashClaimKey(key), now); err != nil {
t.Fatal(err)
}
// The stored value is a hash, never the plaintext.
stored, _, _ := s.ClaimKeyHash(ctx, "acme", "t1")
if stored == key || stored != hashClaimKey(key) {
t.Fatalf("claim key must be stored as a hash, not plaintext")
}
if err := s.verifyClaimKey(ctx, "acme", "t1", key); err != nil {
t.Fatalf("correct key must verify: %v", err)
}
if err := s.verifyClaimKey(ctx, "acme", "t1", "tgtk_wrong"); err != errClaimKeyBad {
t.Fatalf("wrong key must fail: %v", err)
}
if err := s.verifyClaimKey(ctx, "acme", "t1", ""); err != errClaimKeyBad {
t.Fatalf("empty key must fail: %v", err)
}
// Cross-org verify resolves no key => fail closed.
if err := s.verifyClaimKey(ctx, "evil", "t1", key); err != errNoClaimKey {
t.Fatalf("cross-org verify must fail closed, got %v", err)
}
}
+4
View File
@@ -180,6 +180,10 @@ CREATE INDEX IF NOT EXISTS ix_runs_org_agent_created ON agent_runs(org, agent_na
if err := s.migrateTargets(); err != nil {
return err
}
// Per-target claim keys + serving liveness (the #48 route-work machine plane).
if err := s.migrateClaimKeys(); err != nil {
return err
}
return nil
}
+183 -4
View File
@@ -10,6 +10,8 @@ import (
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/principal"
"github.com/hanzoai/cloud/clients/samples"
"github.com/zap-proto/zip"
)
@@ -17,10 +19,11 @@ import (
// box, a GPU host, or a whole cluster. It is the #48 link-a-compute seam over the
// SAME agents.db (one store, one tenancy column) as sessions/events — NOT a rival
// device registry. It composes with the compute fleet rather than duplicating it: a
// session records the target id it runs on (agent_sessions.target), and the mission-
// control devices view unions these registered targets with the org's BYO workers
// (GET /v1/fleet/workers) and BYO clusters (GET /v1/clusters) at the view layer — the
// console's established pattern for folding compute sources.
// session records the target id it runs on (agent_sessions.target), and the org's
// unified board (GET /v1/fleet, clients/visor/board.go) unions these registered
// targets with its BYO workers (GET /v1/fleet/workers), BYO clusters and Visor
// machines — reading this registry through the in-process seam below rather than
// copying it.
//
// POST /v1/agents/targets register a target -> Target
// GET /v1/agents/targets list the org's targets (+ live session load)
@@ -30,6 +33,12 @@ import (
//
// Every route is org-scoped through principal.Org (tenant), fail-closed — a tenant
// can never see or mutate another org's targets, exactly like sessions.
//
// A write carrying `metrics` IS a heartbeat, and a heartbeat is two facts, not one:
// the LAST sample (kept on the row, rendered by the views here) and one point in a
// utilization SERIES (appended to clients/samples). The row answers "is this machine
// alive and what is it doing now"; the series answers "how hot has it been". The
// append is best-effort and detached — see recordSample.
// Target kinds — the closed vocabulary of dispatch destinations.
const (
@@ -233,6 +242,90 @@ func (s *Store) GetTargetByHost(ctx context.Context, org, host string) (Target,
return t, nil
}
// ---- the in-process seam (org-scoped, fail-closed) ----
//
// TargetsForOrg / LoadOn are the exported twins of the list + detail reads above:
// the ONE way another in-process subsystem (the /v1/fleet board in clients/visor)
// reads this registry WITHOUT an HTTP hop back through the gateway — the same
// shape ListForOrg gives the agent registry. They are two ORTHOGONAL values on
// purpose: a target is what the machine IS, its load is what is running on it, and
// a caller that only needs the inventory does not pay for the rollups.
//
// ISOLATION: org is the ONLY tenant key and is threaded verbatim into the
// org-scoped store methods, so a caller for org A can never enumerate or resolve
// org B's targets. The caller MUST pass an org it already validated server-side
// (principal.Org), never a raw client header.
// TargetsForOrg returns the org's registered run-targets from the in-process
// store, newest first. Fails closed when the subsystem is not mounted or the org
// is empty/oversized.
func TargetsForOrg(ctx context.Context, org string) ([]Target, error) {
if mounted == nil || mounted.State.store == nil {
return nil, fmt.Errorf("agents: not mounted")
}
org = strings.TrimSpace(org)
if org == "" || len(org) > principal.MaxOrgLen {
return nil, fmt.Errorf("agents: invalid org")
}
return mounted.State.store.ListTargets(ctx, org)
}
// ResolveTarget resolves a human's target REFERENCE — a target id or its friendly
// label (the hostname the CLI registers) — to the org's target, org-scoped and
// fail-closed. It is the ONE way a trigger surface (the Slack `code: <repo> on
// <target>` grammar, a console picker) turns "on evo" into a target id without
// leaking another tenant's inventory: an id or label that resolves to no target in
// THIS org returns errTargetNotFound, never another org's machine.
//
// Precedence: an exact id match wins (ids are unambiguous), else an exact,
// case-folded label match (newest first, so a re-registered machine's live row is
// preferred). A reference that matches neither is not found — the caller renders an
// honest error and NEVER falls back to a local run.
func ResolveTarget(ctx context.Context, org, ref string) (Target, error) {
if mounted == nil || mounted.State.store == nil {
return Target{}, fmt.Errorf("agents: not mounted")
}
org = strings.TrimSpace(org)
ref = strings.TrimSpace(ref)
if org == "" || len(org) > principal.MaxOrgLen {
return Target{}, fmt.Errorf("agents: invalid org")
}
if ref == "" || len(ref) > maxTargetID {
return Target{}, errTargetNotFound
}
// An id is exact and unambiguous — try it first.
if t, err := mounted.State.store.GetTarget(ctx, org, ref); err == nil {
return t, nil
} else if err != errTargetNotFound {
return Target{}, err
}
// Else an exact, case-folded label match within this org.
rows, err := mounted.State.store.ListTargets(ctx, org)
if err != nil {
return Target{}, err
}
for _, t := range rows { // ListTargets is newest-first: the live row wins a label tie
if strings.EqualFold(strings.TrimSpace(t.Label), ref) {
return t, nil
}
}
return Target{}, errTargetNotFound
}
// LoadOn returns the live session load on one of the org's targets — the same
// (target id OR host) mapping the HTTP views use, so the board and /v1/agents/
// targets can never disagree about what is running where.
func LoadOn(ctx context.Context, org, id, host string) (TargetLoad, error) {
if mounted == nil || mounted.State.store == nil {
return TargetLoad{}, fmt.Errorf("agents: not mounted")
}
org = strings.TrimSpace(org)
if org == "" || len(org) > principal.MaxOrgLen {
return TargetLoad{}, fmt.Errorf("agents: invalid org")
}
return mounted.State.store.SessionLoad(ctx, org, id, host)
}
// DeleteTarget removes an org's target. Sessions keep their recorded target id (a
// historical fact); a detached target simply stops appearing in the registry.
func (s *Store) DeleteTarget(ctx context.Context, org, id string) (bool, error) {
@@ -262,6 +355,83 @@ func (s *Store) SessionLoad(ctx context.Context, org, id, host string) (TargetLo
return TargetLoad{Sessions: total, Running: running}, nil
}
// ---- the fleet time series ----
//
// A heartbeat is the ONE moment this process learns what a linked machine is
// doing, so it is also where the fleet's utilization series is fed. The target row
// keeps the LAST sample (the snapshot the views render, unchanged); clients/samples
// keeps every sample over time. Two different questions — "is it alive now" and
// "how hot has it been" — so two homes, one write.
// sampleTimeout bounds the warehouse write. Generous (the insert is one small row
// in-cluster) but finite, so a wedged datastore can never hold the goroutine open.
const sampleTimeout = 5 * time.Second
// sampleOf projects a target's server-stamped heartbeat into a fleet sample. PURE
// (no clock, no I/O, no store) so the whole projection is unit-testable and the
// caller decides when it runs.
//
// cost_cents is 0: an agent run-target is the operator's OWN machine (a laptop, a
// dialed-in box) — the fleet meters its utilization, it does not resell it. A
// priced source (visor/cloud) fills that column from its own resale price.
func sampleOf(t Target) samples.Sample {
var model string
if len(t.Spec.GPUs) > 0 {
// The representative accelerator: the count already rides in GPUs, so the
// first card's model names the row. A heterogeneous host is rare enough
// that naming its first card beats inventing a summary string here.
model = t.Spec.GPUs[0].Model
if model == "" {
model = t.Spec.GPUs[0].Vendor
}
}
return samples.Sample{
Org: t.Org,
Source: samples.SourceAgent,
Unit: t.ID,
Host: t.Host,
Kind: t.Kind,
At: time.Unix(t.MetricsAt, 0).UTC(),
CPUs: t.Spec.CPUs,
Memory: t.Spec.Memory,
MemUsed: t.Metrics.MemUsed,
MemFree: t.Metrics.MemFree,
Load1: t.Metrics.Load1,
Load5: t.Metrics.Load5,
Load15: t.Metrics.Load15,
GPUUtil: t.Metrics.GPUUtil,
GPUs: len(t.Spec.GPUs),
GPUModel: model,
}
}
// recordSample appends a heartbeat to the fleet series. Best-effort and DETACHED
// on purpose — the warehouse is never in the heartbeat's critical path:
//
// - it runs on its own bounded context, so neither a slow datastore nor the
// client hanging up mid-request can stall or cancel the write;
// - it never touches the response, so the /v1/agents/targets contract is
// byte-identical whether the warehouse is present, absent or on fire;
// - a failure is logged, never surfaced — a dropped sample must not cost a
// machine its heartbeat.
//
// This is the shape the billing warehouse write already uses (`go zapWriteUsage`):
// the seam is synchronous, the CALLER owns the concurrency.
func recordSample(s *cloud.Service[state], t Target) {
if t.MetricsAt == 0 {
return // no heartbeat in this write — nothing to append
}
sample := sampleOf(t) // project on the caller's goroutine: t must not escape mutably
go func() {
ctx, cancel := context.WithTimeout(context.Background(), sampleTimeout)
defer cancel()
if err := samples.Record(ctx, sample); err != nil {
s.Log.Warn("fleet sample write failed", "org", sample.Org, "unit", sample.Unit, "err", err)
}
}()
}
// ---- HTTP shapes (the published contract) ----
type targetView struct {
@@ -311,6 +481,10 @@ func mountTargets(s *cloud.Service[state], app *zip.App) {
app.Get("/v1/agents/targets/:id", cloud.Handle(s, getTarget))
app.Patch("/v1/agents/targets/:id", cloud.Handle(s, patchTarget))
app.Delete("/v1/agents/targets/:id", cloud.Handle(s, deleteTarget))
// The #48 route-work machine surface (claim-key, claim long-poll, report)
// lives on the same target routes; register after the CRUD so the
// extra-segment paths are unambiguous.
mountRouting(s, app)
}
// ---- register ----
@@ -386,6 +560,7 @@ func registerTarget(s *cloud.Service[state], c *zip.Ctx) error {
if err := s.State.store.UpdateTarget(c.Context(), existing); err != nil {
return zip.Errorf(http.StatusInternalServerError, "persist: %v", err)
}
recordSample(s, existing) // a re-link carrying metrics IS a heartbeat
load, _ := s.State.store.SessionLoad(c.Context(), org, existing.ID, existing.Host)
return c.JSON(http.StatusOK, toTargetView(existing, load))
}
@@ -403,6 +578,7 @@ func registerTarget(s *cloud.Service[state], c *zip.Ctx) error {
if err := s.State.store.CreateTarget(c.Context(), t); err != nil {
return zip.Errorf(http.StatusInternalServerError, "persist: %v", err)
}
recordSample(s, t) // a registration carrying metrics is the target's first sample
return c.JSON(http.StatusCreated, toTargetView(t, TargetLoad{}))
}
@@ -538,6 +714,9 @@ func patchTarget(s *cloud.Service[state], c *zip.Ctx) error {
}
return zip.Errorf(http.StatusInternalServerError, "update: %v", err)
}
if body.Metrics != nil {
recordSample(s, t) // THE heartbeat: append it to the fleet series too
}
load, _ := s.State.store.SessionLoad(c.Context(), org, t.ID, t.Host)
return c.JSON(http.StatusOK, toTargetView(t, load))
}
+227
View File
@@ -0,0 +1,227 @@
package agents
import (
"encoding/json"
"net/http"
"testing"
"time"
"github.com/hanzoai/cloud/clients/samples"
)
// targetsample_test.go covers the FIRST emitter: a run-target heartbeat also
// appends to the fleet series (clients/samples).
//
// The datastore is absent under test, so samples.Record is a proven no-op (its own
// package tests that). What MUST be proven here is everything this side owns:
// the projection is faithful, the vocabularies agree, and the HTTP contract is
// untouched whether or not the warehouse exists.
// ---- the projection (pure) ----
// A heartbeat projects onto a sample with no loss and no invention.
func TestSampleOfProjectsTheHeartbeat(t *testing.T) {
at := time.Now().Unix()
tg := Target{
ID: "tgt-1", Org: "acme", Kind: TargetGPU, Host: "box.local", Label: "Box",
Spec: Spec{OS: "linux", Arch: "arm64", CPUs: 20, Memory: 128 << 30,
GPUs: []GPU{{Vendor: "nvidia", Model: "GB10", Memory: 96 << 30}}},
Metrics: Metrics{Load1: 2.5, Load5: 2, Load15: 1.5,
MemUsed: 64 << 30, MemFree: 64 << 30, GPUUtil: 0.75},
MetricsAt: at,
}
s := sampleOf(tg)
if s.Org != "acme" || s.Unit != "tgt-1" || s.Host != "box.local" {
t.Fatalf("identity did not project: %+v", s)
}
if s.Source != samples.SourceAgent {
t.Fatalf("source want %q, got %q", samples.SourceAgent, s.Source)
}
if s.Kind != TargetGPU {
t.Fatalf("kind want %q, got %q", TargetGPU, s.Kind)
}
if !s.At.Equal(time.Unix(at, 0).UTC()) {
t.Fatalf("at must be the SERVER-stamped heartbeat clock, got %v", s.At)
}
if s.CPUs != 20 || s.Memory != 128<<30 {
t.Fatalf("spec did not project: %+v", s)
}
if s.MemUsed != 64<<30 || s.MemFree != 64<<30 || s.Load1 != 2.5 || s.Load5 != 2 || s.Load15 != 1.5 {
t.Fatalf("metrics did not project: %+v", s)
}
if s.GPUUtil != 0.75 || s.GPUs != 1 || s.GPUModel != "GB10" {
t.Fatalf("gpu did not project: %+v", s)
}
// An agent's own machine is metered, never resold.
if s.CostCents != 0 {
t.Fatalf("an agent sample must be unpriced, got %d", s.CostCents)
}
// The projection must be acceptable to the plane it feeds.
if err := samples.Record(t.Context(), s); err != nil {
t.Fatalf("a projected sample must be recordable: %v", err)
}
}
// The accelerator count comes from the spec, and the row is named by the first
// card's model — falling back to its vendor when the model is unknown.
func TestSampleOfGPUSummary(t *testing.T) {
cases := []struct {
name string
gpus []GPU
wantN int
wantModel string
}{
{"none", nil, 0, ""},
{"model", []GPU{{Vendor: "nvidia", Model: "GB10"}}, 1, "GB10"},
{"vendor fallback", []GPU{{Vendor: "amd"}}, 1, "amd"},
{"multi is counted, first names it", []GPU{{Model: "GB10"}, {Model: "GB10"}}, 2, "GB10"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s := sampleOf(Target{ID: "t", Org: "o", Kind: TargetGPU, Spec: Spec{GPUs: tc.gpus}, MetricsAt: 1})
if s.GPUs != tc.wantN || s.GPUModel != tc.wantModel {
t.Fatalf("want (%d, %q), got (%d, %q)", tc.wantN, tc.wantModel, s.GPUs, s.GPUModel)
}
})
}
}
// THE cross-package contract: every kind a target can be must be a kind the fleet
// series accepts, or heartbeats would silently stop being recorded. This fails the
// day someone adds a target kind without teaching the series about it.
func TestEveryTargetKindIsAFleetKind(t *testing.T) {
fleet := map[string]bool{
samples.KindLaptop: true, samples.KindCloud: true, samples.KindGPU: true,
samples.KindCluster: true, samples.KindMachine: true, samples.KindWorker: true,
}
for _, k := range []string{TargetLaptop, TargetCloud, TargetGPU, TargetCluster, TargetMachine} {
if !fleet[k] {
t.Fatalf("target kind %q is not a fleet sample kind — its heartbeats would be dropped", k)
}
// Proven end to end: a sample carrying this kind validates.
s := sampleOf(Target{ID: "t", Org: "o", Kind: k, MetricsAt: 1})
if err := samples.Record(t.Context(), s); err != nil {
t.Fatalf("kind %q must be recordable: %v", k, err)
}
}
}
// A write with no heartbeat in it appends nothing — recordSample is a no-op when
// the server never stamped a metrics clock.
func TestRecordSampleSkipsWhenNoHeartbeat(t *testing.T) {
mountApp(t, nil) // sets the `mounted` singleton recordSample logs through
// No panic, no goroutine, no write: MetricsAt == 0 means "no sample here".
recordSample(mounted, Target{ID: "tgt-1", Org: "acme", Kind: TargetGPU, MetricsAt: 0})
}
// ---- (c) the HTTP contract is untouched by the series ----
// The heartbeat still 200s with no warehouse, and still returns the snapshot on
// the row exactly as before — the series is strictly additive.
func TestHeartbeatStill200sWithoutDatastore(t *testing.T) {
app := mountApp(t, nil)
code, body := do(t, app, http.MethodPost, "/v1/agents/targets", "acme", map[string]any{
"label": "Box", "kind": TargetGPU, "host": "box.local",
"spec": map[string]any{"os": "linux", "cpus": 20, "gpus": []map[string]any{{"vendor": "nvidia", "model": "GB10"}}},
"metrics": map[string]any{"load1": 2.5, "gpuUtil": 0.75, "memUsed": 100},
})
if code != http.StatusCreated {
t.Fatalf("register want 201 without a datastore, got %d (%s)", code, body)
}
var created targetView
if err := json.Unmarshal(body, &created); err != nil {
t.Fatalf("shape: %v (%s)", err, body)
}
if created.Metrics == nil || created.Metrics.GPUUtil != 0.75 {
t.Fatalf("the snapshot on the row must be unchanged: %+v", created.Metrics)
}
if created.MetricsAt == "" {
t.Fatal("the server must still stamp the heartbeat clock")
}
// The heartbeat itself.
code, body = do(t, app, http.MethodPatch, "/v1/agents/targets/"+created.ID, "acme", map[string]any{
"metrics": map[string]any{"load1": 4, "gpuUtil": 0.9, "memUsed": 200},
})
if code != http.StatusOK {
t.Fatalf("heartbeat want 200 without a datastore, got %d (%s)", code, body)
}
var beat targetView
if err := json.Unmarshal(body, &beat); err != nil {
t.Fatalf("shape: %v (%s)", err, body)
}
if beat.Metrics == nil || beat.Metrics.GPUUtil != 0.9 || beat.Metrics.Load1 != 4 {
t.Fatalf("the heartbeat must still refresh the row snapshot: %+v", beat.Metrics)
}
// A re-link (same org+host) is idempotent and still carries a heartbeat.
code, body = do(t, app, http.MethodPost, "/v1/agents/targets", "acme", map[string]any{
"label": "Box", "kind": TargetGPU, "host": "box.local",
"metrics": map[string]any{"load1": 1},
})
if code != http.StatusOK {
t.Fatalf("re-link want 200 (idempotent), got %d (%s)", code, body)
}
var relinked targetView
if err := json.Unmarshal(body, &relinked); err != nil {
t.Fatalf("shape: %v (%s)", err, body)
}
if relinked.ID != created.ID {
t.Fatalf("a re-link must refresh the SAME target: %s != %s", relinked.ID, created.ID)
}
}
// ---- the in-process seam ----
// TargetsForOrg / LoadOn are org-keyed and fail closed — the board reads through
// them, so a cross-tenant id must never resolve.
func TestInProcessSeamIsOrgScopedAndFailsClosed(t *testing.T) {
app := mountApp(t, nil)
code, body := do(t, app, http.MethodPost, "/v1/agents/targets", "acme", map[string]any{
"label": "Secret", "kind": TargetGPU, "host": "secret.local",
})
if code != http.StatusCreated {
t.Fatalf("register: %d (%s)", code, body)
}
var created targetView
_ = json.Unmarshal(body, &created)
// The owner sees it.
own, err := TargetsForOrg(t.Context(), "acme")
if err != nil {
t.Fatalf("TargetsForOrg(acme): %v", err)
}
if len(own) != 1 || own[0].ID != created.ID {
t.Fatalf("the owner must see its target, got %+v", own)
}
// Another tenant sees nothing — the same id is unreachable.
other, err := TargetsForOrg(t.Context(), "other")
if err != nil {
t.Fatalf("TargetsForOrg(other): %v", err)
}
if len(other) != 0 {
t.Fatalf("CROSS-TENANT LEAK: org 'other' enumerated %+v", other)
}
// A blank/oversized org fails closed on both.
for _, bad := range []string{"", " "} {
if _, err := TargetsForOrg(t.Context(), bad); err == nil {
t.Fatalf("TargetsForOrg(%q) must fail closed", bad)
}
if _, err := LoadOn(t.Context(), bad, created.ID, ""); err == nil {
t.Fatalf("LoadOn(%q) must fail closed", bad)
}
}
// LoadOn is org-keyed too: the foreign tenant resolves no load for the id.
load, err := LoadOn(t.Context(), "other", created.ID, "secret.local")
if err != nil {
t.Fatalf("LoadOn(other): %v", err)
}
if load.Sessions != 0 || load.Running != 0 {
t.Fatalf("CROSS-TENANT LEAK: foreign load %+v", load)
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"strings"
"time"
openai "github.com/sashabaranov/go-openai"
openai "github.com/hanzoai/go-openai"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
+11 -5
View File
@@ -100,15 +100,21 @@ func routes(app *zip.App, s *cloud.Service[state]) {
app.Get("/v1/analytics/timeseries", cloud.Handle(s, timeseries))
app.Get("/v1/analytics/top", cloud.Handle(s, top))
// Capture (WRITE) side — the ingest that fills hanzo.events (capture.go). All
// POST, all tenant-gated in-handler. /v1/tracker is the page-unload beacon
// alias (bare route; never collides with the /v1/tracker/projects* issue tracker).
// Capture (WRITE) side — the ingest that fills hanzo.events. POST /v1/event
// (event.go) is the ONE canonical front door: body Event | [Event], org
// resolved IAM-only and fail-closed, into the ONE write core (ingestEvents).
app.Post("/v1/event", cloud.Handle(s, eventIngest))
// DEPRECATED ingest aliases — thin wire adapters that normalize onto the SAME
// write core (log a one-shot deprecation, keep working). /v1/analytics{,/batch}
// and /v1/tracker speak the Segment/beacon CaptureBatch wire; /v1/tracker is a
// bare route (never collides with the /v1/tracker/projects* issue tracker).
app.Post("/v1/analytics", cloud.Handle(s, capture))
app.Post("/v1/analytics/batch", cloud.Handle(s, capture))
app.Post("/v1/tracker", cloud.Handle(s, capture))
// /v1/insights — the unified native surface (insights.go): PostHog-wire
// ingest + console reads over the SAME engine. Flags live at /v1/flags.
// /v1/insights — console reads over the SAME engine + the DEPRECATED PostHog-
// wire ingest adapter (/v1/insights/e → the ONE write core). Flags live at /v1/flags.
app.Get("/v1/insights/health", cloud.Handle(s, insightsHealth))
app.Post("/v1/insights/e", cloud.Handle(s, insightsIngest))
app.Get("/v1/insights/events", cloud.Handle(s, insightsEvents))
+143 -29
View File
@@ -52,6 +52,7 @@ import (
"os"
"regexp"
"strings"
"sync"
"sync/atomic"
"time"
@@ -498,19 +499,72 @@ func publicCaptureEnabled() bool {
}
}
// captureTenant resolves the tenant a batch is attributed to. A VALIDATED
// principal always wins (authenticated product traffic → its own org). Otherwise,
// for anonymous marketing traffic, the tenant is the PUBLIC brand org derived
// SERVER-SIDE from the request Host via the white-label registry — never a
// client-claimed org, so the isolation invariant holds: a caller with no bearer
// can only ever write into the brand-public partition of the Host it actually
// reached, and a forged X-Org-Id is ignored exactly as on the read path. An
// unrecognized Host is refused (we never dump anonymous events into a default
// org). Returns ("", false) when the caller must be answered 403.
// resolveKeyOrg maps a presented project/API key to its org through the ONE IAM
// key seam (cloud.OrgForKey). It is a package var ONLY so a test can substitute a
// resolver without standing up IAM; production is always cloud.OrgForKey.
var resolveKeyOrg = cloud.OrgForKey
// projectKey returns the project/API key a keyed SDK presents OUT-OF-BAND of the
// Authorization header — the transports SanitizeIdentity does NOT mint identity
// from, so they never reach tenant() as a principal. In priority order: the
// ?api_key= query, the x-api-key / api-key headers, and the PostHog-wire body
// field `api_key` (posthog-js and the insights-go batch envelope put it there).
// "" when none is present.
//
// The body is PEEKED via c.Body() — fasthttp buffers the full body, so the later
// c.Bind in the handler re-reads the same bytes; peeking does not consume it. Only
// the api_key field is decoded (a bad/unrelated JSON body simply yields "").
func projectKey(c *zip.Ctx) string {
if k := trim(c.Query("api_key")); k != "" {
return k
}
if k := trim(c.Header("x-api-key")); k != "" {
return k
}
if k := trim(c.Header("api-key")); k != "" {
return k
}
if body := c.Body(); len(body) > 0 {
var probe struct {
APIKey string `json:"api_key"`
}
if json.Unmarshal(body, &probe) == nil {
if k := trim(probe.APIKey); k != "" {
return k
}
}
}
return ""
}
// captureTenant resolves the tenant a batch is attributed to, in strict trust
// order:
//
// 1. A VALIDATED principal always wins (authenticated product traffic → its own
// org; this also covers a Hanzo key sent as a bearer, which SanitizeIdentity
// has already resolved to a principal upstream).
// 2. Otherwise, if the caller PRESENTS a project key out-of-band (posthog-js /
// insights-go: api_key in the body/query/x-api-key), resolve it to its org
// through the ONE IAM key seam. This FAILS CLOSED: a presented-but-unresolvable
// key is refused (→ 403), NEVER falling through to the brand-host fallback —
// attributing a keyed request to the wrong (brand-public) partition would be a
// cross-tenant write.
// 3. Only for TRULY anonymous traffic (no principal, no key) is the tenant the
// PUBLIC brand org derived SERVER-SIDE from the request Host via the
// white-label registry — never a client-claimed org. An unrecognized Host is
// refused (we never dump anonymous events into a default org).
//
// Returns ("", false) when the caller must be answered 403.
func captureTenant(c *zip.Ctx) (string, bool) {
if org, ok := tenant(c); ok {
return org, true
}
if key := projectKey(c); key != "" {
if org, ok := resolveKeyOrg(c.Context(), key); ok {
return org, true
}
return "", false // presented key that does not resolve → fail CLOSED
}
if !publicCaptureEnabled() {
return "", false
}
@@ -520,36 +574,75 @@ func captureTenant(c *zip.Ctx) (string, bool) {
return "", false
}
// capture ingests one batch into hanzo.events, tenant-scoped. Shared by
// /v1/analytics, /v1/analytics/batch, and /v1/tracker.
func capture(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := captureTenant(c)
if !ok {
return zip.ErrForbidden("valid bearer or a recognized brand host required")
// ── ONE write core ───────────────────────────────────────────────────────────
// event source tags — the ingest adapter each row arrived through. Stamped into
// properties.$source by ingestEvents so the ONE hanzo.events table stays honest
// about origin (canonical vs. deprecated wire) WITHOUT a second table or a schema
// migration: the read lenses are unchanged and $source is queryable in the
// properties JSON, which is exactly the migration signal for the alias sunset.
const (
sourceEvent = "event" // canonical POST /v1/event (native Event wire)
sourcePostHog = "posthog" // POST /v1/insights/e (PostHog wire adapter, deprecated)
sourceCapture = "capture" // POST /v1/analytics{,/batch}, /v1/tracker (Segment/beacon, deprecated)
)
// withSource returns a copy of p carrying $source=source (the ingest adapter), so
// normalizeEvent's scrub+store path records origin as a property. nil-safe; never
// mutates the caller's map (the adapters share their event structs).
func withSource(p map[string]any, source string) map[string]any {
if source == "" {
return p
}
var batch CaptureBatch
if err := c.Bind(&batch); err != nil {
return zip.ErrBadRequest("malformed capture batch")
out := make(map[string]any, len(p)+1)
for k, v := range p {
out[k] = v
}
evs := batch.events()
out["$source"] = source
return out
}
// deprecatedOnce records one deprecation log per alias path per process, so a
// high-volume ingest alias signals its sunset exactly once instead of flooding.
var deprecatedOnce sync.Map
// deprecated logs (once per path) that a superseded ingest alias was hit, pointing
// callers at the canonical front door. It NEVER changes behavior — the alias keeps
// working — it only records the migration signal (also visible as $source in the
// warehouse).
func deprecated(s *cloud.Service[state], c *zip.Ctx, canonical string) {
p := c.Path()
if _, seen := deprecatedOnce.LoadOrStore(p, struct{}{}); seen {
return
}
s.Log.Warn("deprecated analytics ingest endpoint; migrate to the canonical event front door",
"path", p, "canonical", canonical)
}
// ingestEvents is the ONE write core: normalize → scrub → batch INSERT into the
// ONE hanzo.events table. org is the SERVER-resolved tenant (never client input);
// source tags the ingest adapter. Every front door — the canonical /v1/event and
// the deprecated PostHog / Segment / beacon adapters — funnels here, so there is
// exactly one write path. Returns the honest accepted/dropped receipt; the errors
// it returns are already HTTP-shaped (zip) for the handler to pass straight up.
func ingestEvents(ctx context.Context, org, source string, evs []CaptureEvent) (CaptureResult, error) {
if len(evs) == 0 {
return c.JSON(http.StatusOK, CaptureResult{})
return CaptureResult{}, nil
}
if len(evs) > maxBatch {
return zip.ErrBadRequest("batch too large")
return CaptureResult{}, zip.ErrBadRequest("batch too large")
}
if err := requireDatastore(); err != nil {
return err
return CaptureResult{}, err
}
ctx := c.Context()
if err := EnsureEventsTable(ctx); err != nil {
return zip.Errorf(http.StatusServiceUnavailable, "analytics warehouse unavailable: %v", err)
return CaptureResult{}, zip.Errorf(http.StatusServiceUnavailable, "analytics warehouse unavailable: %v", err)
}
now := time.Now().UTC()
rows := make([]eventRow, 0, len(evs))
dropped := 0
for _, e := range evs {
e.Properties = withSource(e.Properties, source)
row, ok := normalizeEvent(org, now, e)
if !ok {
dropped++
@@ -558,12 +651,33 @@ func capture(s *cloud.Service[state], c *zip.Ctx) error {
rows = append(rows, row)
}
if len(rows) == 0 {
return c.JSON(http.StatusOK, CaptureResult{Dropped: dropped})
return CaptureResult{Dropped: dropped}, nil
}
stmt, args := buildEventsInsert(rows)
if err := aiobject.DatastoreExec(ctx, stmt, args...); err != nil {
return warehouseErr("capture", err)
return CaptureResult{}, warehouseErr("capture", err)
}
return c.JSON(http.StatusOK, CaptureResult{Accepted: len(rows), Dropped: dropped})
return CaptureResult{Accepted: len(rows), Dropped: dropped}, nil
}
// capture ingests a Segment/beacon batch into hanzo.events, tenant-scoped. It is
// the DEPRECATED wire adapter behind /v1/analytics, /v1/analytics/batch, and
// /v1/tracker: a thin CaptureBatch decoder over the ONE write core (ingestEvents).
// New callers post the canonical Event to /v1/event; this alias keeps working and
// keeps captureTenant's brand-host path for anonymous marketing traffic.
func capture(s *cloud.Service[state], c *zip.Ctx) error {
deprecated(s, c, "/v1/event")
org, ok := captureTenant(c)
if !ok {
return zip.ErrForbidden("valid bearer or a recognized brand host required")
}
var batch CaptureBatch
if err := c.Bind(&batch); err != nil {
return zip.ErrBadRequest("malformed capture batch")
}
res, err := ingestEvents(c.Context(), org, sourceCapture, batch.events())
if err != nil {
return err
}
return c.JSON(http.StatusOK, res)
}
+120
View File
@@ -0,0 +1,120 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// See the License for the specific language governing permissions and
// limitations under the License.
package analytics
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/zap-proto/zip"
)
// These tests cover the project-API-key → org resolution added to captureTenant so
// keyed, bearer-less SDK traffic (posthog-js / insights-go batch) maps to a tenant.
// They drive the REAL /v1/insights/e handler through the injectable resolveKeyOrg
// seam, so no IAM is needed. The observable proxy for "resolved to a tenant" is
// "passed the tenant gate" — i.e. NOT 403; without a datastore the handler then
// returns 503, so any non-403 status means captureTenant admitted the request.
// stubResolver swaps resolveKeyOrg for the test and records the key it was handed,
// so a test asserts BOTH that projectKey extracted the right key AND that
// captureTenant honored the resolution. Restored via t.Cleanup.
func stubResolver(t *testing.T, fn func(key string) (string, bool)) *string {
t.Helper()
var got string
orig := resolveKeyOrg
resolveKeyOrg = func(_ context.Context, key string) (string, bool) {
got = key
return fn(key)
}
t.Cleanup(func() { resolveKeyOrg = orig })
return &got
}
// postKeyed issues POST path (with optional ?query) to the mounted app, setting an
// optional Host and headers and a raw JSON body — no middleware, mirroring the
// SanitizeIdentity-minted-header harness the other analytics tests use.
func postKeyed(t *testing.T, app *zip.App, path, host, body string, hdr map[string]string) int {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if host != "" {
req.Host = host
}
for k, v := range hdr {
req.Header.Set(k, v)
}
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("Test POST %s: %v", path, err)
}
defer func() { _ = resp.Body.Close() }()
_, _ = io.Copy(io.Discard, resp.Body)
return resp.StatusCode
}
// TestCaptureTenant_KeyExtractionReachesResolver: a project key presented in the
// body, the ?api_key= query, or the x-api-key header is extracted and handed to the
// resolver, and a resolved key passes the tenant gate (never 403).
func TestCaptureTenant_KeyExtractionReachesResolver(t *testing.T) {
app := mountApp(t)
cases := []struct {
name, path, body string
hdr map[string]string
wantKey string
}{
{"body", "/v1/insights/e", `{"api_key":"hk-body","event":"e","distinct_id":"d"}`, nil, "hk-body"},
{"query", "/v1/insights/e?api_key=hk-query", `{"event":"e","distinct_id":"d"}`, nil, "hk-query"},
{"x-api-key", "/v1/insights/e", `{"event":"e","distinct_id":"d"}`, map[string]string{"x-api-key": "hk-hdr"}, "hk-hdr"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := stubResolver(t, func(string) (string, bool) { return "acme", true })
code := postKeyed(t, app, tc.path, "", tc.body, tc.hdr)
if *got != tc.wantKey {
t.Fatalf("resolver handed key %q, want %q", *got, tc.wantKey)
}
if code == http.StatusForbidden {
t.Fatalf("a resolved key must pass the tenant gate, got 403")
}
})
}
}
// TestCaptureTenant_UnresolvableKeyFailsClosed: a PRESENTED key that does not
// resolve is refused (403) EVEN on a recognized brand host — it must never fall
// through to the brand-public partition (that would be a cross-tenant write).
func TestCaptureTenant_UnresolvableKeyFailsClosed(t *testing.T) {
app := mountApp(t)
stubResolver(t, func(string) (string, bool) { return "", false }) // nothing resolves
code := postKeyed(t, app, "/v1/insights/e", "hanzo.ai",
`{"api_key":"hk-bad","event":"e","distinct_id":"d"}`, nil)
if code != http.StatusForbidden {
t.Fatalf("presented-but-unresolvable key on a brand host must 403 (fail closed), got %d", code)
}
}
// TestCaptureTenant_AnonBrandHostFallsBack: with NO key presented, anonymous
// traffic on a recognized brand host still resolves to the brand org (not 403), and
// the key resolver is never consulted — the key path only triggers on a real key.
func TestCaptureTenant_AnonBrandHostFallsBack(t *testing.T) {
app := mountApp(t)
stubResolver(t, func(key string) (string, bool) {
t.Fatalf("resolver consulted for a keyless request (key=%q)", key)
return "", false
})
code := postKeyed(t, app, "/v1/insights/e", "hanzo.ai",
`{"event":"e","distinct_id":"d"}`, nil)
if code == http.StatusForbidden {
t.Fatalf("anonymous capture on a recognized brand host must pass the tenant gate, got 403")
}
}
+139
View File
@@ -0,0 +1,139 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// event.go — the ONE canonical event-ingestion front door.
//
// POST /v1/event body: Event | [Event] -> {accepted, dropped}
//
// A JSON object is one event; a JSON array IS the batch (there is deliberately no
// /v1/event/batch). Every other ingest surface (the PostHog wire at
// /v1/insights/e, the Segment/beacon wire at /v1/analytics{,/batch} and
// /v1/tracker) is a thin DEPRECATED adapter that normalizes its own wire shape
// onto CaptureEvent and funnels through the SAME write core (ingestEvents) into
// the SAME hanzo.events table. One write path, many adapters.
//
// AUTH — IAM ONLY, FAIL-CLOSED: the tenant is resolved SERVER-SIDE from a
// validated bearer principal (its owner org) or, for a keyed bearer-less SDK, an
// access key resolved through the ONE IAM key seam (cloud.OrgForKey). There is NO
// brand-host fallback on this endpoint: an unauthenticated or unresolvable caller
// is refused (403), so the canonical door never writes an event into a tenant IAM
// did not vouch for. The org is NEVER read from the body.
package analytics
import (
"encoding/json"
"net/http"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
)
// Event is the canonical analytics event — the entire ingest contract in four
// fields. Only these are first-class; everything else a caller wants to record
// travels in Properties (the scrubber runs over it downstream, same as every
// event). The tenant is NOT a field: it is resolved server-side from IAM, so a
// caller can only ever write into its OWN org's partition.
type Event struct {
Event string `json:"event"` // event name (required; empty ⇒ dropped as unroutable)
DistinctID string `json:"distinctId"` // the person/visitor id the caller owns
Time string `json:"time"` // optional RFC3339; clamped to server-now on skew/absent
Properties map[string]any `json:"properties"` // everything non-core
}
// toCapture adapts the canonical Event onto the internal CaptureEvent the write
// core consumes. Type is left empty (canonicalType ⇒ "event"); no $-property is
// promoted to a column here — /v1/event stays a strict four-field contract, and
// every non-core field the caller sent stays in Properties.
func (e Event) toCapture() CaptureEvent {
return CaptureEvent{
Event: e.Event,
DistinctID: e.DistinctID,
Timestamp: e.Time,
Properties: e.Properties,
}
}
// eventTenant resolves the tenant for POST /v1/event — IAM ONLY, FAIL-CLOSED. A
// validated bearer principal wins (its owner org); otherwise a presented access
// key is resolved to its org through the ONE IAM key seam (resolveKeyOrg →
// cloud.OrgForKey). There is NO brand-host fallback: an unauthenticated or
// unresolvable caller returns ("", false) → 403. (The deprecated aliases keep
// captureTenant's brand-host path for anonymous marketing traffic; the canonical
// endpoint is deliberately stricter — IAM is the only tenant authority here.)
func eventTenant(c *zip.Ctx) (string, bool) {
if org, ok := tenant(c); ok {
return org, true
}
if key := projectKey(c); key != "" {
if org, ok := resolveKeyOrg(c.Context(), key); ok {
return org, true
}
}
return "", false
}
// decodeEvents decodes a request body as Event | []Event. The first non-space
// byte decides: '[' ⇒ the array batch, anything else ⇒ a single Event. An empty
// body yields no events (an honest empty receipt, not an error). Pure over the
// raw bytes (the handler passes c.Body() — fasthttp-buffered, the same bytes
// projectKey peeked) so the decode is driven directly by tests.
func decodeEvents(body []byte) ([]Event, error) {
i := 0
for i < len(body) {
if b := body[i]; b == ' ' || b == '\t' || b == '\r' || b == '\n' {
i++
continue
}
break
}
if i >= len(body) {
return nil, nil
}
if body[i] == '[' {
var evs []Event
if err := json.Unmarshal(body, &evs); err != nil {
return nil, err
}
return evs, nil
}
var e Event
if err := json.Unmarshal(body, &e); err != nil {
return nil, err
}
return []Event{e}, nil
}
// eventIngest answers POST /v1/event — the ONE canonical ingestion front door.
// Org is IAM-derived and fail-closed (eventTenant); the body is Event | [Event];
// every event flows through the ONE write core (ingestEvents) into the ONE
// hanzo.events table, tagged source=event.
func eventIngest(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := eventTenant(c)
if !ok {
return zip.ErrForbidden("valid bearer or a resolvable access key required")
}
evs, err := decodeEvents(c.Body())
if err != nil {
return zip.ErrBadRequest("malformed event payload")
}
caps := make([]CaptureEvent, len(evs))
for i, e := range evs {
caps[i] = e.toCapture()
}
res, err := ingestEvents(c.Context(), org, sourceEvent, caps)
if err != nil {
return err
}
return c.JSON(http.StatusOK, res)
}
+240
View File
@@ -0,0 +1,240 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// See the License for the specific language governing permissions and
// limitations under the License.
package analytics
import (
"net/http"
"testing"
"time"
)
// ── decodeEvents: Event | []Event (single + batch) ──────────────────────────
func TestDecodeEvents_Single(t *testing.T) {
evs, err := decodeEvents([]byte(`{"event":"signup","distinctId":"u1","properties":{"plan":"pro"}}`))
if err != nil {
t.Fatalf("decode single: %v", err)
}
if len(evs) != 1 {
t.Fatalf("single want 1 event, got %d", len(evs))
}
if evs[0].Event != "signup" || evs[0].DistinctID != "u1" {
t.Fatalf("single decoded = %+v", evs[0])
}
if evs[0].Properties["plan"] != "pro" {
t.Fatalf("single properties = %v", evs[0].Properties)
}
}
func TestDecodeEvents_Batch(t *testing.T) {
evs, err := decodeEvents([]byte(`[{"event":"a","distinctId":"d"},{"event":"b","distinctId":"d"}]`))
if err != nil {
t.Fatalf("decode batch: %v", err)
}
if len(evs) != 2 || evs[0].Event != "a" || evs[1].Event != "b" {
t.Fatalf("batch decoded = %+v", evs)
}
}
func TestDecodeEvents_BatchLeadingWhitespace(t *testing.T) {
// The array is detected past leading whitespace, not only at byte 0.
evs, err := decodeEvents([]byte(" \n\t [{\"event\":\"a\"}]"))
if err != nil {
t.Fatalf("decode ws-batch: %v", err)
}
if len(evs) != 1 || evs[0].Event != "a" {
t.Fatalf("ws-batch decoded = %+v", evs)
}
}
func TestDecodeEvents_EmptyIsNoEvents(t *testing.T) {
// Empty / whitespace-only body ⇒ zero events, NOT an error (honest empty receipt).
for _, b := range []string{"", " ", "\n\t"} {
evs, err := decodeEvents([]byte(b))
if err != nil || len(evs) != 0 {
t.Fatalf("empty %q ⇒ evs=%v err=%v", b, evs, err)
}
}
}
func TestDecodeEvents_Malformed(t *testing.T) {
for _, b := range []string{`{"event":`, `[{"event":"a"},`, `not json`} {
if _, err := decodeEvents([]byte(b)); err == nil {
t.Fatalf("malformed %q want error, got nil", b)
}
}
}
// ── adapter → Event/CaptureEvent normalization ──────────────────────────────
// TestEventToCapture: the canonical Event maps onto CaptureEvent with ONLY the
// four core fields promoted; Type is left empty (⇒ "event") and everything else
// stays in Properties (nothing is lifted to a column).
func TestEventToCapture(t *testing.T) {
e := Event{
Event: "purchase",
DistinctID: "u9",
Time: "2026-07-18T00:00:00Z",
Properties: map[string]any{"amount": 42, "$current_url": "https://x/y"},
}
ce := e.toCapture()
if ce.Event != "purchase" || ce.DistinctID != "u9" || ce.Timestamp != "2026-07-18T00:00:00Z" {
t.Fatalf("core fields = %+v", ce)
}
if ce.Type != "" {
t.Fatalf("Type must be empty (⇒ canonicalType event), got %q", ce.Type)
}
if ce.URL != "" {
t.Fatalf("no $-property is promoted to a column on the canonical wire; URL=%q", ce.URL)
}
// non-core stays in properties
if ce.Properties["amount"] != 42 || ce.Properties["$current_url"] != "https://x/y" {
t.Fatalf("properties passthrough = %v", ce.Properties)
}
}
// TestEventNormalizeThroughCore: a canonical Event, adapted and normalized, yields
// a row stamped with the SERVER org and the resolved event name.
func TestEventNormalizeThroughCore(t *testing.T) {
row, ok := normalizeEvent("acme", time.Now(), Event{Event: "signup", DistinctID: "u1"}.toCapture())
if !ok {
t.Fatal("want routable")
}
if row.tenant != "acme" || row.event != "signup" || row.eventType != "event" {
t.Fatalf("row = tenant %q event %q type %q", row.tenant, row.event, row.eventType)
}
}
// TestInsightsAdapterNormalization: the PostHog wire adapter lifts well-known
// $-properties to columns (that is its job) — the OTHER adapter feeding the ONE
// core, distinct from the canonical Event wire.
func TestInsightsAdapterNormalization(t *testing.T) {
ce := insightsEvent{
Event: "$pageview",
DistinctID: "d",
Properties: map[string]any{"$current_url": "https://x/y", "$session_id": "s1"},
}.toCapture()
if ce.Type != "pageview" {
t.Fatalf("posthog $pageview ⇒ type pageview, got %q", ce.Type)
}
if ce.URL != "https://x/y" || ce.SessionID != "s1" {
t.Fatalf("posthog $-props ⇒ columns: url=%q session=%q", ce.URL, ce.SessionID)
}
}
// TestCaptureBatchAdapter: the Segment/beacon adapter prefers `batch`, falling
// back to `events`.
func TestCaptureBatchAdapter(t *testing.T) {
if got := (CaptureBatch{Batch: []CaptureEvent{{Event: "a"}}, Events: []CaptureEvent{{Event: "b"}}}).events(); len(got) != 1 || got[0].Event != "a" {
t.Fatalf("batch preferred over events, got %+v", got)
}
if got := (CaptureBatch{Events: []CaptureEvent{{Event: "b"}}}).events(); len(got) != 1 || got[0].Event != "b" {
t.Fatalf("events fallback, got %+v", got)
}
}
// ── source tagging (the $source property, one-table origin discriminator) ────
func TestWithSource(t *testing.T) {
// stamps $source
got := withSource(nil, sourceEvent)
if got["$source"] != "event" {
t.Fatalf("withSource(nil,event) = %v", got)
}
// does not mutate the caller's map, and preserves existing keys
orig := map[string]any{"a": 1}
out := withSource(orig, sourcePostHog)
if out["a"] != 1 || out["$source"] != "posthog" {
t.Fatalf("withSource copy = %v", out)
}
if _, leaked := orig["$source"]; leaked {
t.Fatalf("withSource mutated the caller's map: %v", orig)
}
// empty source is a no-op passthrough (same map)
if got := withSource(orig, ""); got["$source"] != nil {
t.Fatalf("empty source must not stamp, got %v", got)
}
}
// TestSourceStampedIntoProperties: source flows through withSource → normalizeEvent
// → the stored properties JSON, so the ONE hanzo.events table carries origin
// WITHOUT a schema column.
func TestSourceStampedIntoProperties(t *testing.T) {
e := CaptureEvent{Event: "x", Properties: withSource(nil, sourceEvent)}
row, ok := normalizeEvent("acme", time.Now(), e)
if !ok {
t.Fatal("want routable")
}
props := decodeProps(t, row.properties)
if props["$source"] != "event" {
t.Fatalf("row.properties $source = %v (props=%v)", props["$source"], props)
}
}
// ── POST /v1/event: IAM-only, fail-closed auth ──────────────────────────────
//
// Observable proxy (mirrors capture_keyorg_test): a REFUSED request is 403; an
// ADMITTED one reaches requireDatastore and returns 503 (no datastore in tests).
// So "not 403" ⇒ the tenant gate admitted the request.
func TestEvent_NoPrincipalNoKeyForbidden(t *testing.T) {
app := mountApp(t)
if code, _ := doBody(t, app, http.MethodPost, "/v1/event", "", "", `{"event":"e","distinctId":"d"}`); code != http.StatusForbidden {
t.Fatalf("no-principal no-key /v1/event want 403, got %d", code)
}
}
func TestEvent_BearerPrincipalAdmitted(t *testing.T) {
app := mountApp(t)
// A validated principal (X-User/X-Org) is admitted → 503 (datastore down), not 403.
if code, _ := doBody(t, app, http.MethodPost, "/v1/event", "user-dave", "acme", `{"event":"signup","distinctId":"u1"}`); code != http.StatusServiceUnavailable {
t.Fatalf("bearer /v1/event want 503 (admitted, datastore down), got %d", code)
}
// A batch body is admitted the same way.
if code, _ := doBody(t, app, http.MethodPost, "/v1/event", "user-dave", "acme", `[{"event":"a","distinctId":"d"},{"event":"b","distinctId":"d"}]`); code != http.StatusServiceUnavailable {
t.Fatalf("bearer /v1/event batch want 503, got %d", code)
}
}
func TestEvent_ResolvedKeyAdmitted(t *testing.T) {
app := mountApp(t)
got := stubResolver(t, func(string) (string, bool) { return "acme", true })
code := postKeyed(t, app, "/v1/event", "", `{"api_key":"hk-k","event":"e","distinctId":"d"}`, nil)
if *got != "hk-k" {
t.Fatalf("resolver handed key %q, want hk-k", *got)
}
if code == http.StatusForbidden {
t.Fatalf("a resolved access key must pass the /v1/event gate, got 403")
}
}
func TestEvent_UnresolvableKeyFailsClosedEvenOnBrandHost(t *testing.T) {
app := mountApp(t)
stubResolver(t, func(string) (string, bool) { return "", false })
code := postKeyed(t, app, "/v1/event", "hanzo.ai", `{"api_key":"hk-bad","event":"e","distinctId":"d"}`, nil)
if code != http.StatusForbidden {
t.Fatalf("presented-but-unresolvable key on /v1/event must 403 (fail closed), got %d", code)
}
}
// TestEvent_NoBrandHostFallback is THE distinguishing invariant: anonymous traffic
// on a recognized brand host is ADMITTED by the deprecated /v1/analytics alias
// (brand-public partition) but REFUSED by the canonical /v1/event — IAM is the
// only tenant authority on the canonical door.
func TestEvent_NoBrandHostFallback(t *testing.T) {
app := mountApp(t)
body := `{"event":"e","distinctId":"d"}`
if code, _ := doHost(t, app, "/v1/event", "", "", "hanzo.ai", body); code != http.StatusForbidden {
t.Fatalf("anonymous brand-host /v1/event must 403 (no brand fallback), got %d", code)
}
// Contrast: the deprecated alias still admits the same anonymous brand-host
// traffic (503 = admitted, datastore down), proving the difference is by design.
if code, _ := doHost(t, app, "/v1/analytics", "", "", "hanzo.ai", `{"batch":[{"type":"pageview"}]}`); code != http.StatusServiceUnavailable {
t.Fatalf("deprecated alias still brand-admits (want 503), got %d", code)
}
}
+34 -32
View File
@@ -33,7 +33,11 @@ import (
)
// insightsEvent is the PostHog wire shape (subset that matters for ingest).
// UUID is the top-level per-event id PostHog SDKs mint for idempotency; the rest
// of the identity/attribution the SDKs carry rides inside Properties (mapped in
// toCapture).
type insightsEvent struct {
UUID string `json:"uuid"`
Event string `json:"event"`
DistinctID string `json:"distinct_id"`
Timestamp string `json:"timestamp"`
@@ -65,6 +69,11 @@ func (e insightsEvent) toCapture() CaptureEvent {
typ = "pageview"
}
return CaptureEvent{
// Idempotency id: PostHog SDKs carry a top-level event `uuid`; some send it
// as an `$insert_id` property instead. Preserve it as the client MessageID so
// a retried batch (insights-go retries with backoff) keeps a STABLE row id
// rather than the server minting a fresh one per attempt.
MessageID: firstNonEmptyStr(strings.TrimSpace(e.UUID), strings.TrimSpace(str("$insert_id"))),
Type: typ,
Event: e.Event,
Timestamp: e.Timestamp,
@@ -73,6 +82,19 @@ func (e insightsEvent) toCapture() CaptureEvent {
URL: str("$current_url"),
Path: str("$pathname"),
Referrer: str("$referrer"),
// UTM attribution: PostHog SDKs put campaign params in BARE `utm_*`
// properties (not $-prefixed — confirmed against the SDK/ingest source).
// hanzo.events has first-class utm_* columns and the native capture path
// maps CaptureEvent.UTM into them (capture.go), so surfacing them here is
// what lets the web/commerce lens attribute traffic to a campaign. They were
// previously dropped on the PostHog-wire front door.
UTM: UTM{
Source: str("utm_source"),
Medium: str("utm_medium"),
Campaign: str("utm_campaign"),
Term: str("utm_term"),
Content: str("utm_content"),
},
Product: str("product"),
Library: str("$lib"),
LibraryVer: str("$lib_version"),
@@ -80,9 +102,13 @@ func (e insightsEvent) toCapture() CaptureEvent {
}
}
// insightsIngest answers POST /v1/insights/e — the PostHog-compatible front
// door. Same tenant gate, same normalize/scrub, same warehouse as /v1/analytics.
// insightsIngest answers POST /v1/insights/e — the DEPRECATED PostHog-wire
// adapter. It normalizes the PostHog single/batch shape onto CaptureEvent and
// funnels through the ONE write core (ingestEvents, source=posthog); it keeps
// captureTenant's brand-host path so anonymous PostHog-wire traffic is unbroken.
// New callers post the canonical Event to /v1/event.
func insightsIngest(s *cloud.Service[state], c *zip.Ctx) error {
deprecated(s, c, "/v1/event")
org, ok := captureTenant(c)
if !ok {
return zip.ErrForbidden("valid bearer or a recognized brand host required")
@@ -95,39 +121,15 @@ func insightsIngest(s *cloud.Service[state], c *zip.Ctx) error {
if len(events) == 0 && body.Event != "" {
events = []insightsEvent{body.insightsEvent}
}
if len(events) == 0 {
return c.JSON(http.StatusOK, CaptureResult{})
caps := make([]CaptureEvent, len(events))
for i, e := range events {
caps[i] = e.toCapture()
}
if len(events) > maxBatch {
return zip.ErrBadRequest("batch too large")
}
if err := requireDatastore(); err != nil {
res, err := ingestEvents(c.Context(), org, sourcePostHog, caps)
if err != nil {
return err
}
ctx := c.Context()
if err := EnsureEventsTable(ctx); err != nil {
return zip.Errorf(http.StatusServiceUnavailable, "analytics warehouse unavailable: %v", err)
}
now := time.Now().UTC()
rows := make([]eventRow, 0, len(events))
dropped := 0
for _, e := range events {
row, ok := normalizeEvent(org, now, e.toCapture())
if !ok {
dropped++
continue
}
rows = append(rows, row)
}
if len(rows) == 0 {
return c.JSON(http.StatusOK, CaptureResult{Dropped: dropped})
}
stmt, args := buildEventsInsert(rows)
if err := aiobject.DatastoreExec(ctx, stmt, args...); err != nil {
return zip.Errorf(http.StatusServiceUnavailable, "analytics warehouse write failed: %v", err)
}
return c.JSON(http.StatusOK, CaptureResult{Accepted: len(rows), Dropped: dropped})
return c.JSON(http.StatusOK, res)
}
// insightsEvents answers GET /v1/insights/events — the console's recent-events
+97
View File
@@ -0,0 +1,97 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// See the License for the specific language governing permissions and
// limitations under the License.
package analytics
import (
"testing"
"time"
)
// TestToCapture_PreservesUTMAttribution proves the PostHog-wire adapter carries the
// BARE utm_* campaign params (what PostHog SDKs emit) through to the native
// CaptureEvent — and thence, via normalizeEvent, into the hanzo.events utm_*
// columns the INSERT binds. Regression guard: these were previously dropped, so
// every campaign-attributed pageview lost its source/medium/campaign on the
// /v1/insights/e front door and the web/commerce lens could never attribute it.
func TestToCapture_PreservesUTMAttribution(t *testing.T) {
e := insightsEvent{
Event: "$pageview",
DistinctID: "visitor-1",
Properties: map[string]any{
"utm_source": "newsletter",
"utm_medium": "email",
"utm_campaign": "launch",
"utm_term": "analytics",
"utm_content": "hero-cta",
"$current_url": "https://hanzo.ai/insights",
},
}
cap := e.toCapture()
if cap.UTM.Source != "newsletter" || cap.UTM.Medium != "email" ||
cap.UTM.Campaign != "launch" || cap.UTM.Term != "analytics" || cap.UTM.Content != "hero-cta" {
t.Fatalf("UTM not mapped from PostHog wire: %+v", cap.UTM)
}
// End-to-end through the normalizer into the positional row the INSERT binds.
row, ok := normalizeEvent("acme", time.Now(), cap)
if !ok {
t.Fatal("want ok")
}
if row.utmSource != "newsletter" || row.utmMedium != "email" ||
row.utmCampaign != "launch" || row.utmTerm != "analytics" || row.utmContent != "hero-cta" {
t.Fatalf("UTM lost before the events row: src=%q med=%q camp=%q term=%q content=%q",
row.utmSource, row.utmMedium, row.utmCampaign, row.utmTerm, row.utmContent)
}
}
// TestToCapture_IdempotencyID proves the client event id (PostHog top-level `uuid`,
// or the `$insert_id` property fallback) is preserved as the stable row id, so a
// retried batch does not mint a fresh id per attempt — while an absent id still
// falls back to a server-minted one (existing behavior unchanged).
func TestToCapture_IdempotencyID(t *testing.T) {
// top-level uuid wins
row, _ := normalizeEvent("acme", time.Now(),
insightsEvent{Event: "signup", DistinctID: "u1", UUID: "evt-abc"}.toCapture())
if row.id != "evt-abc" {
t.Fatalf("top-level uuid not preserved as row id, got %q", row.id)
}
// $insert_id property fallback when no top-level uuid
row2, _ := normalizeEvent("acme", time.Now(),
insightsEvent{Event: "signup", DistinctID: "u1", Properties: map[string]any{"$insert_id": "ins-9"}}.toCapture())
if row2.id != "ins-9" {
t.Fatalf("$insert_id fallback not preserved, got %q", row2.id)
}
// absent → server still mints a non-empty id
row3, _ := normalizeEvent("acme", time.Now(),
insightsEvent{Event: "signup", DistinctID: "u1"}.toCapture())
if row3.id == "" {
t.Fatal("server must still mint an id when the client sends none")
}
}
// TestToCapture_MapsCoreFields guards that the pre-existing $-property mappings
// still hold alongside the new UTM/idempotency mappings (no regression).
func TestToCapture_MapsCoreFields(t *testing.T) {
cap := insightsEvent{
Event: "$pageview",
DistinctID: "v1",
Properties: map[string]any{
"$session_id": "s1",
"$current_url": "https://hanzo.ai/x",
"$pathname": "/x",
"$referrer": "https://news.ycombinator.com/",
"$lib": "insights-go",
"$lib_version": "1.2.3",
"product": "console",
},
}.toCapture()
if cap.Type != "pageview" || cap.SessionID != "s1" || cap.URL != "https://hanzo.ai/x" ||
cap.Path != "/x" || cap.Referrer != "https://news.ycombinator.com/" ||
cap.Library != "insights-go" || cap.LibraryVer != "1.2.3" || cap.Product != "console" {
t.Fatalf("core PostHog-wire mapping regressed: %+v", cap)
}
}
+26 -134
View File
@@ -1,152 +1,44 @@
package authors
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/hanzoai/cloud/clients/commerceinproc"
"github.com/hanzoai/cloud/clients/payout"
)
// commerce is the narrow money seam the author royalty loop needs: read a deploying
// org's metered spend (the accrual base) and grant a promo credit to a wallet (a
// payout made in credits). It is an INTERFACE so the store/handler logic is testable
// with a fake ledger the HTTP impl below is the ONE production binding.
// org's metered spend (the royalty accrual base) and grant a promo credit to a wallet
// (a payout made in credits, ledger tag grant:author). It is an INTERFACE so the
// store/handler logic is testable with a fake ledger; the production binding is
// clients/payout, reached through the thin adapter below.
//
// This mirrors clients/affiliates/commerce.go EXACTLY: the same
// COMMERCE_SERVICE_TOKEN S2S path, the same X-Org-Id=<org> namespace + bare org
// `user` subject — so an author payout-in-credits lands in precisely the wallet the
// balance panel reads, indistinguishable from an admin/affiliate grant except by its
// ledger tag (grant:author, → the commerce Credit/trial bucket per DepositKind's
// grant:* rule).
// The S2S impl (COMMERCE_SERVICE_TOKEN path, X-Org-Id=<org> namespace, bare-org
// `user` subject) was three byte-identical commerce.go copies; it now lives ONCE in
// clients/payout. An author payout-in-credits still lands in precisely the wallet the
// balance panel reads, indistinguishable from an admin grant except by its
// grant:author tag.
type commerce interface {
configured() bool
// deposit grants amountCents to org's wallet (Credit/trial bucket via the
// grant:author tag) and returns the ledger transaction id.
deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (txnID string, err error)
// spendCents is a deploying org's month-to-date metered consumption — the royalty
// accrual base (spend × the author's share).
spendCents(ctx context.Context, org, user string) (int64, error)
}
// errUnconfigured is returned by a deposit against an unwired commerce so the caller
// records an honest failure rather than reporting a phantom payout.
var errUnconfigured = errors.New("authors: commerce endpoint not configured")
// errUnconfigured is the shared sentinel a deposit against an unwired commerce
// returns, so the caller records an honest failure rather than a phantom payout.
var errUnconfigured = payout.ErrUnconfigured
// httpCommerce is the production commerce binding (COMMERCE_SERVICE_TOKEN S2S).
type httpCommerce struct {
base string
token string
http *http.Client
// commerceSeam adapts the shared payout.Client onto this program's lowercase seam
// (Go package-scoped interface methods cannot cross packages). Zero logic — pure
// delegation; the money path lives in clients/payout.
type commerceSeam struct{ c *payout.Client }
func (s commerceSeam) configured() bool { return s.c.Configured() }
func (s commerceSeam) deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (string, error) {
return s.c.Deposit(ctx, org, user, amountCents, currency, notes, tags)
}
func (s commerceSeam) spendCents(ctx context.Context, org, user string) (int64, error) {
return s.c.SpendCents(ctx, org, user)
}
func newCommerceClient(base, token string) *httpCommerce {
return &httpCommerce{
base: strings.TrimRight(strings.TrimSpace(base), "/"),
token: strings.TrimSpace(token),
http: commerceinproc.Client(15 * time.Second),
}
}
func (c *httpCommerce) configured() bool { return c != nil && c.base != "" && c.token != "" }
// deposit posts POST /v1/billing/deposit — the ONE money-in primitive (identical to
// affiliates.httpCommerce.deposit). Commerce's EdgeAuth pins the body `user` to the
// X-Org-Id subject, so a payout can never be mis-targeted to another wallet.
func (c *httpCommerce) deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (string, error) {
if !c.configured() {
return "", errUnconfigured
}
if currency == "" {
currency = "usd"
}
body, err := json.Marshal(map[string]any{
"user": user,
"currency": currency,
"amount": amountCents,
"notes": notes,
"tags": tags,
})
if err != nil {
return "", err
}
raw, err := c.do(ctx, http.MethodPost, "/v1/billing/deposit", nil, org, body)
if err != nil {
return "", err
}
var out struct {
TransactionID string `json:"transactionId"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return "", fmt.Errorf("commerce deposit decode: %w", err)
}
return out.TransactionID, nil
}
// spendCents reads GET /v1/billing/usage-rollup and returns consumedCents. Zero (not
// an error) when commerce is unconfigured so a partial deploy degrades to "no spend
// to accrue yet" rather than a 5xx.
func (c *httpCommerce) spendCents(ctx context.Context, org, user string) (int64, error) {
if !c.configured() {
return 0, nil
}
q := url.Values{"user": {user}}
raw, err := c.do(ctx, http.MethodGet, "/v1/billing/usage-rollup", q, org, nil)
if err != nil {
return 0, err
}
var out struct {
ConsumedCents int64 `json:"consumedCents"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return 0, fmt.Errorf("commerce rollup decode: %w", err)
}
return out.ConsumedCents, nil
}
// do performs one admin-S2S commerce request. X-Org-Id=<org> is the per-org
// namespace selector commerce's EdgeAuth trusts only behind the service token.
func (c *httpCommerce) do(ctx context.Context, method, path string, q url.Values, org string, body []byte) ([]byte, error) {
u := c.base + path
if enc := q.Encode(); enc != "" {
u += "?" + enc
}
var r io.Reader
if body != nil {
r = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, u, r)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
if org != "" {
req.Header.Set("X-Org-Id", org)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("commerce unreachable: %w", err)
}
defer func() { _ = resp.Body.Close() }()
out, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("commerce status %d", resp.StatusCode)
}
return out, nil
}
// newCommerceClient builds the production binding, delegating to clients/payout.
func newCommerceClient(base, token string) commerce { return commerceSeam{payout.NewClient(base, token)} }
+10
View File
@@ -43,6 +43,7 @@ import (
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/audit"
"github.com/hanzoai/cloud/clients/connectorruntime"
"github.com/hanzoai/cloud/clients/principal"
"github.com/hanzoai/cloud/clients/tools"
"github.com/zap-proto/zip"
@@ -138,6 +139,15 @@ func Mount(app *zip.App, deps cloud.Deps) error {
tools.Register(connectorToolProvider{})
b.Log.Info("automations mounted", "connectors", catalog.ConnectorCount, "runtime", len(registry), "brand", deps.Brand)
// Native single-connector execution (HIP-0126): POST /v1/automations/connectors/:id/run,
// the in-process goja runner paired with the connector catalogue above. It mounts one
// route DISTINCT from every automations route (no /v1/automations/* wildcard here, so no
// shadow), and was a separate Wire entry purely for that one route — fold it in as a
// terminal sub-mount so connector catalogue + execution are ONE automations subsystem.
if err := connectorruntime.Mount(app, deps); err != nil {
return err
}
return nil
}
+1 -1
View File
@@ -15,7 +15,7 @@
// LANE 2 — managed Base hosting (what superbase/PocketHost provided). ONE Base
// app PER ORG, opened lazily and pooled, each on its OWN SQLite under
// {DataDir}/base/{orgSegment}/ — the same "prod = SQLite per tenant" model
// (HIP-0302) the gojabase leaves (captable/sign/dataroom) use, so an org's
// (HIP-0302) the NewBase leaves (captable/sign/dataroom) use, so an org's
// collections/records are PHYSICALLY isolated. Served AUTHENTICATED under
// /v1/base/*, the org resolved from the VALIDATED cloud principal (never a
// client header). This is the console Bases manager's backend.
+3 -3
View File
@@ -18,7 +18,7 @@ import (
baseapp "github.com/hanzoai/base"
"github.com/hanzoai/base/core"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/gojabase"
"github.com/hanzoai/cloud/clients/goja"
luxlog "github.com/luxfi/log"
fiber "github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip"
@@ -170,8 +170,8 @@ func TestPerOrgIsolatedCRUD(t *testing.T) {
}
// The isolation is physical: distinct on-disk data dirs per org segment.
acmeDir := filepath.Join(dataDir, "base", gojabase.TenantSegment("acme"))
globexDir := filepath.Join(dataDir, "base", gojabase.TenantSegment("globex"))
acmeDir := filepath.Join(dataDir, "base", goja.TenantSegment("acme"))
globexDir := filepath.Join(dataDir, "base", goja.TenantSegment("globex"))
if acmeDir == globexDir {
t.Fatalf("orgs share a data dir: %s", acmeDir)
}
+4 -4
View File
@@ -17,7 +17,7 @@ import (
baseapp "github.com/hanzoai/base"
"github.com/hanzoai/base/apis"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/gojabase"
"github.com/hanzoai/cloud/clients/goja"
)
// Pool sizing (env-overridable). A full Base app is heavier than a bare *sql.DB
@@ -35,7 +35,7 @@ const (
// ({DataDir}/base/{TenantSegment}/), the "prod = SQLite per tenant" rule
// (HIP-0302). Apps open lazily on first request, migrate once, and are pooled
// (LRU-capped, idle-evicted). Concurrent opens of the same org are
// single-flighted under mu. The org→segment encoding is gojabase.TenantSegment —
// single-flighted under mu. The org→segment encoding is goja.TenantSegment —
// the ONE injective, traversal-safe tenant→path encoder, shared so an org maps
// to exactly one physical identity everywhere in the binary.
type pool struct {
@@ -85,7 +85,7 @@ func (p *pool) acquire(org string) (http.Handler, func(), error) {
if strings.TrimSpace(org) == "" {
return nil, nil, fmt.Errorf("base: empty org")
}
seg := gojabase.TenantSegment(org)
seg := goja.TenantSegment(org)
p.mu.Lock()
defer p.mu.Unlock()
@@ -111,7 +111,7 @@ func (p *pool) acquire(org string) (http.Handler, func(), error) {
// for in-process callers that drive the engine's Go API directly (collection
// provisioning, seeding) rather than the HTTP path.
func (p *pool) appFor(org string) (*baseapp.Base, error) {
seg := gojabase.TenantSegment(org)
seg := goja.TenantSegment(org)
p.mu.Lock()
defer p.mu.Unlock()
if e, ok := p.m[seg]; ok {
+9 -1
View File
@@ -6,6 +6,7 @@ import (
"github.com/hanzoai/account"
"github.com/hanzoai/cloud/clients/finance"
"github.com/hanzoai/cloud/clients/principal"
"github.com/zap-proto/zip"
)
@@ -57,7 +58,14 @@ import (
// production tokens carry one. Called out for review rather than papered over.
func subjectFor(c *zip.Ctx, org string) string {
if name := strings.TrimSpace(c.Header("X-User-Name")); name != "" {
return account.Payer(account.Credential{Owner: org, Name: name}).Subject()
// Hand Payer the account the credential NAMES (the validated `billing_account`
// claim, minted into X-Billing-Account-Id). The ai gate reads the same claim,
// so this view and that gate resolve one wallet. Reading it here is what keeps
// them from drifting the way the org-vs-"org/user" split once did — except
// that split was two rules, and this would be one rule fed two different
// credentials, which reads the same to a user: a funded balance the gate
// refuses. Absent ⟹ Payer's legacy rule, exactly today's answer.
return account.Payer(account.Credential{Owner: org, Name: name, Account: principal.BillingAccount(c)}).Subject()
}
return account.PayerOf(org, strings.TrimSpace(c.User())).Subject()
}
+7 -7
View File
@@ -8,12 +8,12 @@
// business LOGIC (ported to a self-contained goja bundle in github.com/hanzoai/
// captable) and gives it PERSISTENCE over per-tenant Base/SQLite. The bundle
// carries logic; the Go host carries storage. The seam between them is the
// REUSABLE clients/gojabase binding (the RW-Base goja host), which esign (#100)
// REUSABLE clients/goja binding (the RW-Base goja host), which esign (#100)
// and dataroom (#101) reuse unchanged — this leaf is just:
//
// captable bundle (github.com/hanzoai/captable.Bundle) + the per-tenant Schema
// │
// clients/gojabase.New(...) ← injects __db/__newId/__now,
// clients/goja.NewBase(...) ← injects __db/__newId/__now,
// │ one SQLite file per tenant,
// /v1/captable/* zip routes one transaction per request
//
@@ -37,7 +37,7 @@ import (
hcaptable "github.com/hanzoai/captable"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/gojabase"
"github.com/hanzoai/cloud/clients/goja"
"github.com/hanzoai/cloud/clients/principal"
"github.com/zap-proto/zip"
)
@@ -48,7 +48,7 @@ const maxBody = 1 << 20 // 1 MiB
// state is captable's own data; shared deps live in the embedded cloud.Base.
type state struct {
host *gojabase.Host
host *goja.BaseHost
}
// mounted is the active service so Shutdown can release the per-tenant stores.
@@ -72,7 +72,7 @@ func Mount(app *zip.App, deps cloud.Deps) error {
if err != nil {
return fmt.Errorf("captable.Mount: load bundle: %w", err)
}
host, err := gojabase.New(gojabase.Config{
host, err := goja.NewBase(goja.BaseConfig{
Name: "captable",
Bundle: bundle,
Schema: schema,
@@ -80,7 +80,7 @@ func Mount(app *zip.App, deps cloud.Deps) error {
OnOpen: seedCompany,
})
if err != nil {
return fmt.Errorf("captable.Mount: gojabase host: %w", err)
return fmt.Errorf("captable.Mount: goja NewBase host: %w", err)
}
s := &cloud.Service[state]{Base: cloud.NewBase(deps, "captable"), State: state{host: host}}
mounted = s
@@ -176,7 +176,7 @@ func dispatch(s *cloud.Service[state], c *zip.Ctx, route string, params map[stri
}
}
}
resp, err := s.State.host.Dispatch(c.Context(), org, gojabase.Request{
resp, err := s.State.host.Dispatch(c.Context(), org, goja.BaseRequest{
Route: route,
Params: params,
Body: body,
+7 -7
View File
@@ -6,22 +6,22 @@ import (
"testing"
hcaptable "github.com/hanzoai/captable"
"github.com/hanzoai/cloud/clients/gojabase"
"github.com/hanzoai/cloud/clients/goja"
)
// TestFullLifecycle drives the REAL embedded captable bundle against a REAL
// per-tenant SQLite through gojabase — the end-to-end proof that the bundle's SQL
// per-tenant SQLite through NewBase — the end-to-end proof that the bundle's SQL
// matches the Go host schema and that the whole cap-table fold round-trips
// through Base: company → stakeholders → share class → equity plan → share
// issuance → options → SAFE → priced round + investment (dilution) → transfer →
// computed cap table. Any column/route drift fails here, not in production.
func newHost(t *testing.T) *gojabase.Host {
func newHost(t *testing.T) *goja.BaseHost {
t.Helper()
bundle, err := hcaptable.Bundle()
if err != nil {
t.Fatal(err)
}
h, err := gojabase.New(gojabase.Config{
h, err := goja.NewBase(goja.BaseConfig{
Name: "captable",
Bundle: bundle,
Schema: schema,
@@ -36,9 +36,9 @@ func newHost(t *testing.T) *gojabase.Host {
}
// do dispatches a route and returns (status, decoded body).
func do(t *testing.T, h *gojabase.Host, org, route string, params map[string]string, body any) (int, any) {
func do(t *testing.T, h *goja.BaseHost, org, route string, params map[string]string, body any) (int, any) {
t.Helper()
resp, err := h.Dispatch(context.Background(), org, gojabase.Request{Route: route, Params: params, Body: body})
resp, err := h.Dispatch(context.Background(), org, goja.BaseRequest{Route: route, Params: params, Body: body})
if err != nil {
t.Fatalf("dispatch %s: %v", route, err)
}
@@ -300,7 +300,7 @@ func TestDilutiveOptionsExcludeTerminal(t *testing.T) {
}
// firstShareID returns the founder's original certificate share id.
func firstShareID(t *testing.T, h *gojabase.Host, org string) string {
func firstShareID(t *testing.T, h *goja.BaseHost, org string) string {
t.Helper()
_, body := do(t, h, org, "shares.list", nil, nil)
data := body.(map[string]any)["data"].([]any)
+4 -4
View File
@@ -7,7 +7,7 @@ import (
"fmt"
"time"
"github.com/hanzoai/cloud/clients/gojabase"
"github.com/hanzoai/cloud/clients/goja"
)
// facade.go is the in-process cap-table seam: it lets a sibling subsystem (Hanzo
@@ -74,7 +74,7 @@ type RoundInput struct {
// response. The body is round-tripped through JSON to a generic value so the goja
// bundle sees the SAME wire shape (lower-case json keys) the HTTP path produces —
// passing a typed Go struct straight to goja would expose Go field names instead.
func facadeDispatch(ctx context.Context, org, route string, params map[string]string, body any) (*gojabase.Response, error) {
func facadeDispatch(ctx context.Context, org, route string, params map[string]string, body any) (*goja.Response, error) {
if mounted == nil || mounted.State.host == nil {
return nil, ErrNotMounted
}
@@ -85,7 +85,7 @@ func facadeDispatch(ctx context.Context, org, route string, params map[string]st
if err != nil {
return nil, err
}
return mounted.State.host.Dispatch(ctx, org, gojabase.Request{Route: route, Params: params, Body: wire})
return mounted.State.host.Dispatch(ctx, org, goja.BaseRequest{Route: route, Params: params, Body: wire})
}
// toWire normalizes a typed value to a generic JSON value (map[string]any /
@@ -107,7 +107,7 @@ func toWire(body any) (any, error) {
// okBody checks the response is 2xx and returns the body bytes, else a descriptive
// error carrying the bundle's own message.
func okBody(resp *gojabase.Response, route string) ([]byte, error) {
func okBody(resp *goja.Response, route string) ([]byte, error) {
if resp.Status/100 != 2 {
return nil, fmt.Errorf("captable %s: status %d: %s", route, resp.Status, string(resp.Body))
}
+1 -1
View File
@@ -19,7 +19,7 @@ import (
// mountApp builds a bare zip.App (no SanitizeIdentity middleware, so X-Org-Id +
// X-User-Id are trusted verbatim — the standard cloud leaf test harness) and
// mounts the captable leaf on it. This exercises the REAL HTTP path: routing →
// body decode → principal gate → gojabase dispatch → per-tenant Base → response,
// body decode → principal gate → NewBase dispatch → per-tenant Base → response,
// the same path the live binary serves under CLOUD_ENABLE=captable.
func mountApp(t *testing.T) *zip.App {
t.Helper()
+2 -2
View File
@@ -9,7 +9,7 @@ import (
// schema is the per-tenant SQLite DDL — the Go host owns migrations; the goja
// bundle only issues SQL against these tables. Column names MUST match the SQL in
// the captable bundle (github.com/hanzoai/captable goja/src/routes/*). Idempotent
// (IF NOT EXISTS), so it runs on every tenant DB open via gojabase.
// (IF NOT EXISTS), so it runs on every tenant DB open via NewBase.
//
// This is the Prisma data model (prisma/schema.prisma) translated to SQLite:
// DateTime → TEXT (ISO strings stored verbatim; the bundle never parses them),
@@ -217,7 +217,7 @@ CREATE INDEX IF NOT EXISTS ix_investment_company ON investment(company_id);
CREATE INDEX IF NOT EXISTS ix_investment_round ON investment(round_id);
`
// seedCompany is the gojabase OnOpen hook: it ensures the tenant's cap-table
// seedCompany is the NewBase OnOpen hook: it ensures the tenant's cap-table
// company row exists (id == the validated tenant), so the bundle's companyId
// always resolves. The name defaults to the tenant and is renamed via
// PUT /v1/captable/company. INSERT OR IGNORE makes it idempotent across reopens.
+400
View File
@@ -0,0 +1,400 @@
// Package cloudflare is the per-org Cloudflare asset plane for the unified Hanzo
// Cloud binary — the /v1/integrations/cloudflare/* surface that manages an org's Cloudflare
// Pages, Workers, and (Phase 2) R2/KV/D1 through the SAME per-org, KMS-sealed API
// token the org connected via clients/integrations. It is a sibling of hanzodns
// (which owns /v1/dns as a separate CoreDNS process): both drive Cloudflare with an
// org's own scoped token, so the platform never reaches Cloudflare with a global
// env token again — one token, one custody boundary, one org.
//
// TENANT ISOLATION (the crown jewel). Every handler resolves the caller's org from
// the VALIDATED principal (principal.Org → the X-Org-Id the identity boundary minted
// from a verified credential, HIP-0026 / SanitizeIdentity), NEVER from a body or
// query field. The org is then the ONLY input to token custody: the per-org token is
// read in-process through the ONE seam integrations.TokenFor, which keys KMS on that
// org (/orgs/{org}/integrations/cloudflare/api_token). So a request can ONLY ever
// address its own org's Cloudflare account:
// - no validated principal ⟹ principal.Org fails ⟹ 403 (a forged X-Org-Id with no
// bearer is refused by the identity boundary, then again here);
// - a non-SuperAdmin bearer has X-Org-Id pinned to its own owner (SanitizeIdentity),
// so it cannot name another org;
// - cross-org token reach is structurally impossible — the token path is derived
// from the validated org, not from any caller-controlled field.
//
// The token rides ONLY the Authorization header on the outbound Cloudflare request;
// it is never logged, echoed in an error, or stored by this subsystem.
//
// FAIL-CLOSED. An org that has not connected Cloudflare, an unmounted integrations
// plane, or a KMS that is not Ready each yield an error and a 503 — never another
// org's data and never a silent success.
package cloudflare
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/integrations"
"github.com/hanzoai/cloud/clients/principal"
"github.com/zap-proto/zip"
)
const (
// providerCloudflare is the integrations provider slug the token is custodied
// under, and secretAPIToken the secret name — the SAME coordinate the connector
// (clients/integrations/cloudflare.go) seals BOTH the apikey and OAuth paths to,
// and hanzodns reads for DNS. One coordinate, auth-method-agnostic.
providerCloudflare = "cloudflare"
secretAPIToken = "api_token"
)
// tokenFor is the ONE door to per-org Cloudflare token custody. It defaults to
// integrations.TokenFor (KMS-sealed, fail-closed, org-validated). It is a package
// var ONLY so a test can inject per-org tokens and prove every fetch scopes to the
// caller's org; production never reassigns it.
var tokenFor = integrations.TokenFor
// connectionFor reads an org's NON-secret connection metadata (the account id captured
// at connect time, ExternalID) from the integrations plane. Also a package var ONLY
// for test injection; production never reassigns it.
var connectionFor = integrations.ConnectionFor
// cfAPIBase is Cloudflare's API v4 origin. Overridable via CLOUDFLARE_API_BASE for
// tests (an httptest server) and CF-compatible endpoints; read at call time. The
// default is the real Cloudflare API. (Same knob hanzodns uses, so a test harness
// points both planes at one stub.)
func cfAPIBase() string {
if v := strings.TrimSpace(os.Getenv("CLOUDFLARE_API_BASE")); v != "" {
return strings.TrimRight(v, "/")
}
return "https://api.cloudflare.com/client/v4"
}
// cfHTTPClient is the shared client for every Cloudflare call. A bounded timeout so a
// slow/hung Cloudflare never wedges a request goroutine.
var cfHTTPClient = &http.Client{Timeout: 30 * time.Second}
var (
// nameRE bounds a Cloudflare NAME path segment (Pages project, Worker script,
// custom-domain name/id, bucket, namespace, database). It is validated before it
// is folded into an upstream URL so a hostile value can never smuggle path
// structure (a `/` or `..`) into the Cloudflare request.
nameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
// idRE bounds a Cloudflare 32-hex ID path segment (account id, zone id, route id).
idRE = regexp.MustCompile(`^[0-9a-fA-F]{32}$`)
)
// state is this subsystem's own data — none. Token custody lives in integrations;
// the account id is resolved per request. So the value is the shared Base only.
type state struct{}
// Mount wires /v1/integrations/cloudflare/* onto app. The subsystem is stateless (no store, no
// goroutine): it reads the per-org token in-process per request and proxies to
// Cloudflare, so there is nothing to build or tear down.
func Mount(app *zip.App, deps cloud.Deps) error {
return cloud.Mount(app, deps, "cloudflare",
func(cloud.Base) (state, error) { return state{}, nil },
routes)
}
// routes registers the /v1/integrations/cloudflare surface. Every route runs through authClient
// (validated-org gate + fail-closed per-org token) FIRST, so no route is a softer
// target than another. Pages + Workers are WIRED; R2/KV/D1 are typed Phase-2 stubs
// that answer an honest 501 (never a fake success).
func routes(app *zip.App, s *cloud.Service[state]) {
// Pages (wired) — account-scoped.
app.Get("/v1/integrations/cloudflare/pages/projects", cloud.Handle(s, pagesList))
app.Post("/v1/integrations/cloudflare/pages/projects", cloud.Handle(s, pagesCreate))
app.Get("/v1/integrations/cloudflare/pages/projects/:project", cloud.Handle(s, pagesGet))
app.Delete("/v1/integrations/cloudflare/pages/projects/:project", cloud.Handle(s, pagesDelete))
app.Post("/v1/integrations/cloudflare/pages/projects/:project/deployments", cloud.Handle(s, pagesDeploy))
app.Post("/v1/integrations/cloudflare/pages/projects/:project/domains", cloud.Handle(s, pagesDomainAdd))
app.Delete("/v1/integrations/cloudflare/pages/projects/:project/domains/:domain", cloud.Handle(s, pagesDomainDelete))
// Workers (wired) — scripts + workers.dev subdomain are account-scoped; routes
// are zone-scoped.
app.Get("/v1/integrations/cloudflare/workers/scripts", cloud.Handle(s, workersScriptList))
app.Put("/v1/integrations/cloudflare/workers/scripts/:script", cloud.Handle(s, workersScriptPut))
app.Delete("/v1/integrations/cloudflare/workers/scripts/:script", cloud.Handle(s, workersScriptDelete))
app.Post("/v1/integrations/cloudflare/workers/scripts/:script/subdomain", cloud.Handle(s, workersScriptSubdomainSet))
app.Get("/v1/integrations/cloudflare/workers/subdomain", cloud.Handle(s, workersSubdomainGet))
app.Get("/v1/integrations/cloudflare/workers/zones/:zone/routes", cloud.Handle(s, workersRouteList))
app.Post("/v1/integrations/cloudflare/workers/zones/:zone/routes", cloud.Handle(s, workersRouteCreate))
app.Delete("/v1/integrations/cloudflare/workers/zones/:zone/routes/:route", cloud.Handle(s, workersRouteDelete))
// R2 / KV / D1 (Phase-2 stubs) — routes + typed provider methods exist; bodies
// ship in Phase 2. Each answers an honest 501, never a misleading 200.
app.Get("/v1/integrations/cloudflare/r2/buckets", cloud.Handle(s, r2BucketList))
app.Post("/v1/integrations/cloudflare/r2/buckets", cloud.Handle(s, r2BucketCreate))
app.Delete("/v1/integrations/cloudflare/r2/buckets/:bucket", cloud.Handle(s, r2BucketDelete))
app.Get("/v1/integrations/cloudflare/kv/namespaces", cloud.Handle(s, kvNamespaceList))
app.Post("/v1/integrations/cloudflare/kv/namespaces", cloud.Handle(s, kvNamespaceCreate))
app.Delete("/v1/integrations/cloudflare/kv/namespaces/:namespace", cloud.Handle(s, kvNamespaceDelete))
app.Get("/v1/integrations/cloudflare/d1/databases", cloud.Handle(s, d1DatabaseList))
app.Post("/v1/integrations/cloudflare/d1/databases", cloud.Handle(s, d1DatabaseCreate))
app.Delete("/v1/integrations/cloudflare/d1/databases/:database", cloud.Handle(s, d1DatabaseDelete))
}
// ── client (the cfDo shape, reused verbatim from hanzodns) ──────────────────────
// client drives the Cloudflare API v4 with a per-org scoped token. The token rides
// only the Authorization header — never a query parameter, error, or log line.
type client struct {
token string
base string
}
// cfEnvelope is the shared Cloudflare API v4 response envelope.
type cfEnvelope struct {
Success bool `json:"success"`
Errors []struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"errors"`
}
// cfError carries the upstream Cloudflare HTTP status so a proxied not-found/bad
// request is reported with a recognizable code rather than a blanket 502. Its
// message is Cloudflare's own — token-free by construction.
type cfError struct {
upstream int
code int
msg string
}
func (e *cfError) Error() string { return e.msg }
func (e cfEnvelope) err(status int) error {
if e.Success {
return nil
}
if len(e.Errors) > 0 {
return &cfError{upstream: status, code: e.Errors[0].Code,
msg: fmt.Sprintf("cloudflare API error (%d): [%d] %s", status, e.Errors[0].Code, e.Errors[0].Message)}
}
return &cfError{upstream: status, msg: fmt.Sprintf("cloudflare API error (status %d)", status)}
}
// cfDo performs a Cloudflare API v4 call, JSON-encoding body, unwrapping the
// {success, errors, result} envelope, and decoding result into out. It fails closed:
// a transport error, an unsuccessful envelope, or a non-2xx status yields an error,
// and the error NEVER contains the token. (The hanzodns cfDo shape, verbatim.)
func (cl *client) cfDo(ctx context.Context, method, path string, body, out any) error {
var reader io.Reader
var contentType string
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
reader = bytes.NewReader(b)
contentType = "application/json"
}
return cl.do(ctx, method, path, contentType, reader, out)
}
// cfUpload performs a Cloudflare call with a pre-built body + content type (the
// multipart Worker-script upload), sharing cfDo's fail-closed envelope handling.
func (cl *client) cfUpload(ctx context.Context, method, path, contentType string, body []byte, out any) error {
return cl.do(ctx, method, path, contentType, bytes.NewReader(body), out)
}
// do is the shared request core for cfDo and cfUpload: Bearer-only auth, bounded
// response read, envelope unwrap, fail-closed. Token appears ONLY in the header.
func (cl *client) do(ctx context.Context, method, path, contentType string, body io.Reader, out any) error {
req, err := http.NewRequestWithContext(ctx, method, cl.base+path, body)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+cl.token)
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
resp, err := cfHTTPClient.Do(req)
if err != nil {
// Never wrap err: a transport error can echo the request URL but never the
// header. Keep the message token-free regardless.
return fmt.Errorf("cloudflare request failed")
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNoContent {
return nil
}
data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var env cfEnvelope
if len(data) > 0 {
_ = json.Unmarshal(data, &env)
}
if err := env.err(resp.StatusCode); err != nil {
return err
}
if out != nil {
var wrap struct {
Result json.RawMessage `json:"result"`
}
if err := json.Unmarshal(data, &wrap); err != nil {
return fmt.Errorf("cloudflare: malformed response")
}
if len(wrap.Result) > 0 {
if err := json.Unmarshal(wrap.Result, out); err != nil {
return fmt.Errorf("cloudflare: malformed result")
}
}
}
return nil
}
// pass runs a Cloudflare call and relays its result to the caller VERBATIM (as raw
// JSON), so the upstream shape reaches the platform without field loss — the ONE
// response path for every wired handler. An empty result (e.g. a 204 delete) becomes
// {"success":true}.
func (cl *client) pass(c *zip.Ctx, method, path string, body any) error {
var out json.RawMessage
if err := cl.cfDo(c.Context(), method, path, body, &out); err != nil {
return cfErr(err)
}
return writeResult(c, out)
}
func writeResult(c *zip.Ctx, out json.RawMessage) error {
if len(out) == 0 {
return c.JSON(http.StatusOK, map[string]any{"success": true})
}
c.SetHeader("Content-Type", "application/json; charset=utf-8")
return c.Bytes(http.StatusOK, out)
}
// ── request gate + token custody ────────────────────────────────────────────────
// actingOrgHeader is stamped on every SERVED /v1/integrations/cloudflare response with the org
// whose Cloudflare token was actually used. A per-org caller (the platform BFF) MUST
// assert it equals the org it requested: if a misdeployed, non-org-switch-capable
// service credential made the identity boundary PIN X-Org-Id to the token's OWN
// owner, this header exposes the mismatch so the caller fails LOUD instead of
// silently reading/writing another tenant's Cloudflare account.
const actingOrgHeader = "X-Hanzo-Org"
// authClient is the READ front door: it resolves the caller's validated org (403 if
// unvalidated — a forged X-Org-Id with no bearer never gets past this) and builds a
// Cloudflare client bound to THAT org's KMS-sealed token (503 if the org has not
// connected Cloudflare or KMS is down). On success it stamps actingOrgHeader with the
// served org. The token detail is logged token-free and never surfaced to the client.
func authClient(s *cloud.Service[state], c *zip.Ctx) (*client, string, error) {
org, ok := principal.Org(c)
if !ok {
return nil, "", zip.ErrForbidden("a validated principal is required")
}
tok, err := tokenFor(c.Context(), org, providerCloudflare, secretAPIToken)
if err != nil || len(bytes.TrimSpace(tok)) == 0 {
// err is custody-authored and token-free (not-connected / invalid-org /
// KMS-down). Log the reason, tell the client only that CF is unavailable.
s.Log.Warn("cloudflare token unavailable", "org", org, "err", err)
return nil, org, zip.Errorf(http.StatusServiceUnavailable, "cloudflare is not connected for this org")
}
// Stamp the org actually served so a per-org caller can prove no tenant comingling.
c.SetHeader(actingOrgHeader, org)
return &client{token: string(bytes.TrimSpace(tok)), base: cfAPIBase()}, org, nil
}
// authWrite is the MUTATION front door (POST/PUT/DELETE): it additionally requires the
// caller be an admin of its OWN org (principal.IsOrgAdmin — NOT SuperAdmin), parity
// with the AdminOnly connector that seals the token. Wielding the token's dangerous
// verbs (a Worker script PUT is arbitrary code on the org's Cloudflare account/domains;
// a Pages project DELETE is production destruction) must match connecting it. The admin
// check is FIRST, so a non-admin is refused before any KMS token read. Reads stay
// validated-org-only via authClient — org members may look, only org admins may change.
func authWrite(s *cloud.Service[state], c *zip.Ctx) (*client, string, error) {
if !principal.IsOrgAdmin(c) {
return nil, "", zip.ErrForbidden("this action requires org admin")
}
return authClient(s, c)
}
// resolveAccount resolves the Cloudflare account id for account-scoped endpoints
// (Pages / Workers). Order: (1) an explicit, validated ?account= override wins (for an
// org whose token spans multiple accounts); (2) the account captured at connect time
// (the connection's ExternalID) — no per-call round-trip and deterministic for a
// multi-account token; (3) only if none is stored, discover it live from the token's
// own /accounts. Every candidate is validated 32-hex so it can never inject path
// structure. Fails closed (400) when nothing yields a usable account.
func (cl *client) resolveAccount(ctx context.Context, org string, c *zip.Ctx) (string, error) {
if a := strings.TrimSpace(c.Query("account")); a != "" {
if !idRE.MatchString(a) {
return "", zip.ErrBadRequest("account must be a 32-character hex id")
}
return url.PathEscape(a), nil
}
if conn, ok := connectionFor(org, providerCloudflare); ok {
if id := strings.TrimSpace(conn.ExternalID); idRE.MatchString(id) {
return url.PathEscape(id), nil
}
}
var accts []struct {
ID string `json:"id"`
}
if err := cl.cfDo(ctx, http.MethodGet, "/accounts?per_page=1", nil, &accts); err != nil {
return "", cfErr(err)
}
for _, a := range accts {
if idRE.MatchString(a.ID) {
return url.PathEscape(a.ID), nil
}
}
return "", zip.ErrBadRequest("no cloudflare account is resolvable for this token; pass ?account=<id>")
}
// pathSeg reads a route param, rejects anything not matching re (so it can never
// smuggle path structure into the upstream Cloudflare URL), and returns the
// url.PathEscape'd value ready to concatenate into a CF path.
func pathSeg(c *zip.Ctx, name string, re *regexp.Regexp) (string, error) {
v := strings.TrimSpace(c.Param(name))
if !re.MatchString(v) {
return "", zip.ErrBadRequest(name + " is invalid")
}
return url.PathEscape(v), nil
}
// cfErr maps a Cloudflare call failure to a client-facing HTTP error, propagating a
// recognizable upstream status (404/400/403/409) so a proxied not-found is not
// mis-reported as a 502, and defaulting to 502 Bad Gateway otherwise. The message is
// Cloudflare's own (token-free by construction), never this process's token.
func cfErr(err error) error {
var ce *cfError
if errors.As(err, &ce) {
switch ce.upstream {
case http.StatusNotFound, http.StatusBadRequest, http.StatusForbidden, http.StatusConflict:
return zip.Errorf(ce.upstream, "%s", ce.msg)
}
}
return zip.Errorf(http.StatusBadGateway, "%s", err.Error())
}
// ── Phase-2 stub plumbing ───────────────────────────────────────────────────────
// errPhase2 marks a provider capability whose route + typed method exist but whose
// body ships in Phase 2. stubResult maps it to an honest 501 — never a fake 200, so
// a caller is never misled into thinking a no-op succeeded.
var errPhase2 = errors.New("cloudflare: not yet implemented (phase 2)")
// stubResult maps a Phase-2 provider seam's result to the wire: errPhase2 → 501, any
// real error surfaces as-is, and even a nil still yields 501 (a stub can never report
// success). This guarantees a stub route NEVER returns a misleading 200.
func stubResult(c *zip.Ctx, capability string, err error) error {
if err != nil && !errors.Is(err, errPhase2) {
return cfErr(err)
}
return zip.Errorf(http.StatusNotImplemented, "cloudflare %s is not yet wired (phase 2)", capability)
}
+446
View File
@@ -0,0 +1,446 @@
package cloudflare
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/integrations"
)
// testAccountID is a valid 32-hex Cloudflare account id the stub discovers.
const testAccountID = "0123456789abcdef0123456789abcdef"
// capture records every request the fake Cloudflare API received, so a test can
// assert WHICH token (Authorization) and WHICH path a handler used.
type capture struct {
mu sync.Mutex
reqs []capturedReq
}
type capturedReq struct {
method, path, auth, ctype string
body []byte
}
func (c *capture) add(r *http.Request) {
body, _ := io.ReadAll(r.Body)
c.mu.Lock()
c.reqs = append(c.reqs, capturedReq{r.Method, r.URL.Path, r.Header.Get("Authorization"), r.Header.Get("Content-Type"), body})
c.mu.Unlock()
}
// find returns the first captured request whose path CONTAINS sub.
func (c *capture) find(sub string) (capturedReq, bool) {
c.mu.Lock()
defer c.mu.Unlock()
for _, r := range c.reqs {
if strings.Contains(r.path, sub) {
return r, true
}
}
return capturedReq{}, false
}
// hasExact reports whether any captured request hit EXACTLY path p — used to detect
// the account-discovery call (GET /accounts), which "/accounts/{id}/..." contains as
// a substring but is not.
func (c *capture) hasExact(p string) bool {
c.mu.Lock()
defer c.mu.Unlock()
for _, r := range c.reqs {
if r.path == p {
return true
}
}
return false
}
// fakeCF is a minimal Cloudflare API v4 stub: it answers account discovery and
// echoes a success envelope for any account/zone-scoped call, recording every
// request. resultFor lets a test control the result body per path substring.
func fakeCF(rec *capture, resultFor func(path string) (int, string)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rec.add(r)
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/accounts" { // discovery
io.WriteString(w, `{"success":true,"errors":[],"result":[{"id":"`+testAccountID+`","name":"acme"}]}`)
return
}
if resultFor != nil {
if status, body := resultFor(r.URL.Path); body != "" {
w.WriteHeader(status)
io.WriteString(w, body)
return
}
}
io.WriteString(w, `{"success":true,"errors":[],"result":{"ok":true}}`)
}
}
// harness mounts the subsystem against a fake CF and per-org token seam.
func harness(t *testing.T, tokens map[string]string, rec *capture, resultFor func(string) (int, string)) *zip.App {
t.Helper()
srv := httptest.NewServer(fakeCF(rec, resultFor))
t.Cleanup(srv.Close)
t.Setenv("CLOUDFLARE_API_BASE", srv.URL)
prev := tokenFor
tokenFor = func(_ context.Context, org, provider, name string) ([]byte, error) {
if provider != providerCloudflare || name != secretAPIToken {
return nil, fmt.Errorf("seam called with unexpected coordinate %s/%s", provider, name)
}
tok, ok := tokens[org]
if !ok {
return nil, fmt.Errorf("cloudflare not connected for org %q", org)
}
return []byte(tok), nil
}
t.Cleanup(func() { tokenFor = prev })
app := zip.New(zip.Config{Logger: luxlog.New("test")})
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir()}); err != nil {
t.Fatalf("Mount: %v", err)
}
return app
}
// doReq drives one request with the given minted identity headers (as
// SanitizeIdentity would set them): user!="" makes a validated principal; admin sets
// X-User-IsOrgAdmin. Returns status + body + response headers.
func doReq(t *testing.T, app *zip.App, method, path, user, org string, admin bool, body string) (int, string, http.Header) {
t.Helper()
var rdr io.Reader
if body != "" {
rdr = strings.NewReader(body)
}
req := httptest.NewRequest(method, path, rdr)
if user != "" {
req.Header.Set("X-User-Id", user)
}
if org != "" {
req.Header.Set("X-Org-Id", org)
}
if admin {
req.Header.Set("X-User-IsOrgAdmin", "true")
}
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
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, string(b), resp.Header
}
// do is the common non-admin read/driver: validated principal, no admin bit.
func do(t *testing.T, app *zip.App, method, path, user, org, body string) (int, string) {
status, b, _ := doReq(t, app, method, path, user, org, false, body)
return status, b
}
// ── tenant isolation (the crown jewel) ──────────────────────────────────────────
// A request with no validated principal (no X-User-Id) is refused 403 and NEVER
// reaches token custody or Cloudflare — the forged-X-Org-Id path is dead.
func TestForgedOrgWithoutPrincipalIs403(t *testing.T) {
rec := &capture{}
app := harness(t, map[string]string{"victim": "tok-victim"}, rec, nil)
status, body := do(t, app, http.MethodGet, "/v1/integrations/cloudflare/pages/projects", "", "victim", "")
if status != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body=%s", status, body)
}
if len(rec.reqs) != 0 {
t.Fatalf("Cloudflare was contacted %d time(s) for an unvalidated request; must be 0", len(rec.reqs))
}
if strings.Contains(body, "tok-victim") {
t.Fatalf("victim token leaked into response: %s", body)
}
}
// Each org's request uses ONLY its own org's token; there is no input by which one
// org's request can carry another org's token (org is derived solely from the
// validated principal, never from a body/query field).
func TestTokenScopedToCallerOrg(t *testing.T) {
rec := &capture{}
app := harness(t, map[string]string{"orga": "tok-A", "orgb": "tok-B"}, rec, nil)
if status, body := do(t, app, http.MethodGet, "/v1/integrations/cloudflare/pages/projects", "ua", "orga", ""); status != 200 {
t.Fatalf("orgA status=%d body=%s", status, body)
}
rA, ok := rec.find("/pages/projects")
if !ok {
t.Fatal("orgA: no /pages/projects request reached Cloudflare")
}
if rA.auth != "Bearer tok-A" {
t.Fatalf("orgA used %q, want Bearer tok-A (cross-org token reach!)", rA.auth)
}
rec.reqs = nil
if status, _ := do(t, app, http.MethodGet, "/v1/integrations/cloudflare/pages/projects", "ub", "orgb", ""); status != 200 {
t.Fatalf("orgB status=%d", status)
}
rB, ok := rec.find("/pages/projects")
if !ok {
t.Fatal("orgB: no /pages/projects request reached Cloudflare")
}
if rB.auth != "Bearer tok-B" {
t.Fatalf("orgB used %q, want Bearer tok-B", rB.auth)
}
if rB.auth == "Bearer tok-A" {
t.Fatal("orgB reached orgA's token — cross-tenant break")
}
}
// A body/query field naming another org is IGNORED: the token is the caller-org's,
// proving the handler derives the org only from the validated principal. (Mutation →
// org-admin driver.)
func TestBodyOrgFieldCannotRedirectToken(t *testing.T) {
rec := &capture{}
app := harness(t, map[string]string{"orga": "tok-A", "orgb": "tok-B"}, rec, nil)
body := `{"name":"site","org":"orgb","organizationId":"orgb","account":"ffffffffffffffffffffffffffffffff"}`
if status, resp, _ := doReq(t, app, http.MethodPost, "/v1/integrations/cloudflare/pages/projects", "ua", "orga", true, body); status != 200 {
t.Fatalf("status=%d resp=%s", status, resp)
}
r, ok := rec.find("/pages/projects")
if !ok {
t.Fatal("no create request reached Cloudflare")
}
if r.auth != "Bearer tok-A" {
t.Fatalf("hostile body redirected the token to %q; must stay Bearer tok-A", r.auth)
}
}
// An org that has not connected Cloudflare fails closed with 503, never another
// org's data and never a fake success.
func TestNotConnectedIs503(t *testing.T) {
rec := &capture{}
app := harness(t, map[string]string{"orga": "tok-A"}, rec, nil)
status, body := do(t, app, http.MethodGet, "/v1/integrations/cloudflare/pages/projects", "ux", "stranger", "")
if status != http.StatusServiceUnavailable {
t.Fatalf("status=%d, want 503; body=%s", status, body)
}
if len(rec.reqs) != 0 {
t.Fatalf("Cloudflare contacted for an unconnected org; must be 0 (got %d)", len(rec.reqs))
}
}
// Every served response stamps X-Hanzo-Org with the org whose token was used, so a
// per-org caller can detect a pinned/comingled org (fix for the non-SuperAdmin
// platform-token misdeploy).
func TestResponseStampsActingOrg(t *testing.T) {
rec := &capture{}
app := harness(t, map[string]string{"orga": "tok-A"}, rec, nil)
status, _, hdr := doReq(t, app, http.MethodGet, "/v1/integrations/cloudflare/pages/projects", "ua", "orga", false, "")
if status != 200 {
t.Fatalf("status=%d", status)
}
if got := hdr.Get(actingOrgHeader); got != "orga" {
t.Fatalf("%s = %q, want orga", actingOrgHeader, got)
}
}
// ── mutation authorization (least privilege) ────────────────────────────────────
// Mutations (POST/PUT/DELETE) require org admin; reads do not. A refused non-admin
// mutation never reaches Cloudflare (rejected before the token read).
func TestMutationRequiresOrgAdmin(t *testing.T) {
rec := &capture{}
app := harness(t, map[string]string{"orga": "tok-A"}, rec, nil)
mutations := []struct{ method, path, body string }{
{http.MethodPost, "/v1/integrations/cloudflare/pages/projects", `{"name":"site"}`},
{http.MethodDelete, "/v1/integrations/cloudflare/pages/projects/site", ""},
{http.MethodPost, "/v1/integrations/cloudflare/pages/projects/site/deployments", ""},
{http.MethodPut, "/v1/integrations/cloudflare/workers/scripts/hello", `{"script":"export default {}"}`},
{http.MethodDelete, "/v1/integrations/cloudflare/workers/scripts/hello", ""},
{http.MethodPost, "/v1/integrations/cloudflare/r2/buckets", `{"name":"b"}`},
}
for _, m := range mutations {
if s, _, _ := doReq(t, app, m.method, m.path, "member", "orga", false, m.body); s != http.StatusForbidden {
t.Fatalf("non-admin %s %s: status=%d, want 403", m.method, m.path, s)
}
}
// A refused non-admin mutation must not have reached Cloudflare at all.
if len(rec.reqs) != 0 {
t.Fatalf("refused non-admin mutations reached Cloudflare %d time(s); must be 0", len(rec.reqs))
}
// An org ADMIN is allowed through to Cloudflare.
if s, b, _ := doReq(t, app, http.MethodPost, "/v1/integrations/cloudflare/pages/projects", "admin", "orga", true, `{"name":"site"}`); s != 200 {
t.Fatalf("org-admin create: status=%d body=%s, want 200", s, b)
}
// A read stays open to a non-admin member.
if s, _ := do(t, app, http.MethodGet, "/v1/integrations/cloudflare/pages/projects", "member", "orga", ""); s != 200 {
t.Fatalf("non-admin read: status=%d, want 200", s)
}
}
// ── wired behavior ──────────────────────────────────────────────────────────────
func TestPagesListHappyPath(t *testing.T) {
rec := &capture{}
resultFor := func(path string) (int, string) {
if strings.HasSuffix(path, "/pages/projects") {
return 200, `{"success":true,"errors":[],"result":[{"id":"p1","name":"marketing"}]}`
}
return 0, ""
}
app := harness(t, map[string]string{"orga": "tok-A"}, rec, resultFor)
status, body := do(t, app, http.MethodGet, "/v1/integrations/cloudflare/pages/projects", "ua", "orga", "")
if status != 200 {
t.Fatalf("status=%d body=%s", status, body)
}
if !strings.Contains(body, `"name":"marketing"`) {
t.Fatalf("result not relayed verbatim: %s", body)
}
r, _ := rec.find("/pages/projects")
if r.path != "/accounts/"+testAccountID+"/pages/projects" {
t.Fatalf("addressed %q, want the resolved-account path", r.path)
}
if strings.Contains(body, "tok-A") {
t.Fatalf("token leaked into response body: %s", body)
}
}
// Worker script PUT sends the modern multipart module upload with the caller-org
// token (org-admin driver).
func TestWorkersScriptPutMultipart(t *testing.T) {
rec := &capture{}
app := harness(t, map[string]string{"orga": "tok-A"}, rec, nil)
body := `{"script":"export default { fetch(){ return new Response('hi') } }","mainModule":"worker.js"}`
status, resp, _ := doReq(t, app, http.MethodPut, "/v1/integrations/cloudflare/workers/scripts/hello", "ua", "orga", true, body)
if status != 200 {
t.Fatalf("status=%d resp=%s", status, resp)
}
r, ok := rec.find("/workers/scripts/hello")
if !ok {
t.Fatal("no script PUT reached Cloudflare")
}
if !strings.HasPrefix(r.ctype, "multipart/form-data") {
t.Fatalf("content-type = %q, want multipart/form-data", r.ctype)
}
if !strings.Contains(string(r.body), `"main_module":"worker.js"`) {
t.Fatalf("multipart metadata missing main_module: %s", r.body)
}
if !strings.Contains(string(r.body), "export default") {
t.Fatal("multipart body missing the module source")
}
if r.auth != "Bearer tok-A" {
t.Fatalf("script PUT used %q, want Bearer tok-A", r.auth)
}
}
// Zone routes are zone-scoped and do NOT resolve an account (org-admin driver).
func TestWorkersRouteBindZoneScoped(t *testing.T) {
rec := &capture{}
zone := "abcdef0123456789abcdef0123456789"
app := harness(t, map[string]string{"orga": "tok-A"}, rec, nil)
body := `{"pattern":"example.com/*","script":"hello"}`
status, resp, _ := doReq(t, app, http.MethodPost, "/v1/integrations/cloudflare/workers/zones/"+zone+"/routes", "ua", "orga", true, body)
if status != 200 {
t.Fatalf("status=%d resp=%s", status, resp)
}
if r, ok := rec.find("/zones/" + zone + "/workers/routes"); !ok {
t.Fatalf("route bind did not address the zone path; got %+v", rec.reqs)
} else if r.auth != "Bearer tok-A" {
t.Fatalf("route bind used %q, want Bearer tok-A", r.auth)
}
if rec.hasExact("/accounts") {
t.Fatal("route bind resolved an account; zone routes must not")
}
}
// ── stubs never lie ─────────────────────────────────────────────────────────────
// R2/KV/D1 stub READ routes answer an honest 501 for a CONNECTED org — never a fake
// 200 — and still enforce the validated-principal gate.
func TestStubRoutesReturn501NeverSuccess(t *testing.T) {
rec := &capture{}
app := harness(t, map[string]string{"orga": "tok-A"}, rec, nil)
for _, path := range []string{
"/v1/integrations/cloudflare/r2/buckets",
"/v1/integrations/cloudflare/kv/namespaces",
"/v1/integrations/cloudflare/d1/databases",
} {
status, body := do(t, app, http.MethodGet, path, "ua", "orga", "")
if status != http.StatusNotImplemented {
t.Fatalf("%s: status=%d, want 501; body=%s", path, status, body)
}
if strings.Contains(strings.ToLower(body), `"success":true`) || strings.Contains(strings.ToLower(body), `"ok":true`) {
t.Fatalf("%s: stub returned a misleading success: %s", path, body)
}
if s, _ := do(t, app, http.MethodGet, path, "", "orga", ""); s != http.StatusForbidden {
t.Fatalf("%s: unvalidated status=%d, want 403", path, s)
}
}
}
// ── input hardening + account resolution ────────────────────────────────────────
// An explicit ?account= override must be a 32-hex id (defense against path
// injection), and it skips discovery.
func TestAccountOverrideValidated(t *testing.T) {
rec := &capture{}
app := harness(t, map[string]string{"orga": "tok-A"}, rec, nil)
if status, _ := do(t, app, http.MethodGet, "/v1/integrations/cloudflare/pages/projects?account=../../evil", "ua", "orga", ""); status != http.StatusBadRequest {
t.Fatalf("hostile account override status=%d, want 400", status)
}
rec.reqs = nil
override := "ffffffffffffffffffffffffffffffff"
if status, _ := do(t, app, http.MethodGet, "/v1/integrations/cloudflare/pages/projects?account="+override, "ua", "orga", ""); status != 200 {
t.Fatalf("valid account override status=%d", status)
}
r, _ := rec.find("/pages/projects")
if r.path != "/accounts/"+override+"/pages/projects" {
t.Fatalf("override not honored: addressed %q", r.path)
}
if rec.hasExact("/accounts") {
t.Fatal("discovery call made despite an explicit account override")
}
}
// The account captured at connect time (ConnectionFor.ExternalID) is used without a
// live discovery round-trip.
func TestStoredAccountSkipsDiscovery(t *testing.T) {
rec := &capture{}
app := harness(t, map[string]string{"orga": "tok-A"}, rec, nil)
stored := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
prev := connectionFor
connectionFor = func(org, provider string) (integrations.Connection, bool) {
if org == "orga" && provider == providerCloudflare {
return integrations.Connection{ExternalID: stored}, true
}
return integrations.Connection{}, false
}
t.Cleanup(func() { connectionFor = prev })
if s, _ := do(t, app, http.MethodGet, "/v1/integrations/cloudflare/pages/projects", "ua", "orga", ""); s != 200 {
t.Fatalf("status=%d", s)
}
r, ok := rec.find("/pages/projects")
if !ok {
t.Fatal("no pages request reached Cloudflare")
}
if r.path != "/accounts/"+stored+"/pages/projects" {
t.Fatalf("addressed %q, want the stored-account path", r.path)
}
if rec.hasExact("/accounts") {
t.Fatal("live discovery happened despite a stored account id")
}
}
+64
View File
@@ -0,0 +1,64 @@
package cloudflare
// d1.go — Cloudflare D1 (account-scoped: /accounts/{id}/d1/database). PHASE-2 STUB:
// routes + typed provider methods exist so the surface is complete and typed; the
// bodies ship in Phase 2, and each handler answers an honest 501 (via stubResult)
// after the authClient gate — never a fake success.
import (
"context"
"encoding/json"
"strings"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
)
// D1Database is the Phase-2 D1 database shape.
type D1Database struct {
UUID string `json:"uuid"`
Name string `json:"name"`
}
// listD1Databases / createD1Database / deleteD1Database are the typed Phase-2
// provider seams; the wired bodies land in Phase 2.
func (cl *client) listD1Databases(context.Context, string) ([]D1Database, error) {
return nil, errPhase2
}
func (cl *client) createD1Database(context.Context, string, string) (*D1Database, error) {
return nil, errPhase2
}
func (cl *client) deleteD1Database(context.Context, string, string) error {
return errPhase2
}
func d1DatabaseList(s *cloud.Service[state], c *zip.Ctx) error {
cl, _, err := authClient(s, c)
if err != nil {
return err
}
_, err = cl.listD1Databases(c.Context(), strings.TrimSpace(c.Query("account")))
return stubResult(c, "d1", err)
}
func d1DatabaseCreate(s *cloud.Service[state], c *zip.Ctx) error {
cl, _, err := authWrite(s, c)
if err != nil {
return err
}
var in struct {
Name string `json:"name"`
}
_ = json.Unmarshal(c.Body(), &in)
_, err = cl.createD1Database(c.Context(), strings.TrimSpace(c.Query("account")), strings.TrimSpace(in.Name))
return stubResult(c, "d1", err)
}
func d1DatabaseDelete(s *cloud.Service[state], c *zip.Ctx) error {
cl, _, err := authWrite(s, c)
if err != nil {
return err
}
err = cl.deleteD1Database(c.Context(), strings.TrimSpace(c.Query("account")), strings.TrimSpace(c.Param("database")))
return stubResult(c, "d1", err)
}
+64
View File
@@ -0,0 +1,64 @@
package cloudflare
// kv.go — Cloudflare Workers KV (account-scoped: /accounts/{id}/storage/kv/
// namespaces). PHASE-2 STUB: routes + typed provider methods exist so the surface is
// complete and typed; the bodies ship in Phase 2, and each handler answers an honest
// 501 (via stubResult) after the authClient gate — never a fake success.
import (
"context"
"encoding/json"
"strings"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
)
// KVNamespace is the Phase-2 KV namespace shape.
type KVNamespace struct {
ID string `json:"id"`
Title string `json:"title"`
}
// listKVNamespaces / createKVNamespace / deleteKVNamespace are the typed Phase-2
// provider seams; the wired bodies land in Phase 2.
func (cl *client) listKVNamespaces(context.Context, string) ([]KVNamespace, error) {
return nil, errPhase2
}
func (cl *client) createKVNamespace(context.Context, string, string) (*KVNamespace, error) {
return nil, errPhase2
}
func (cl *client) deleteKVNamespace(context.Context, string, string) error {
return errPhase2
}
func kvNamespaceList(s *cloud.Service[state], c *zip.Ctx) error {
cl, _, err := authClient(s, c)
if err != nil {
return err
}
_, err = cl.listKVNamespaces(c.Context(), strings.TrimSpace(c.Query("account")))
return stubResult(c, "kv", err)
}
func kvNamespaceCreate(s *cloud.Service[state], c *zip.Ctx) error {
cl, _, err := authWrite(s, c)
if err != nil {
return err
}
var in struct {
Title string `json:"title"`
}
_ = json.Unmarshal(c.Body(), &in)
_, err = cl.createKVNamespace(c.Context(), strings.TrimSpace(c.Query("account")), strings.TrimSpace(in.Title))
return stubResult(c, "kv", err)
}
func kvNamespaceDelete(s *cloud.Service[state], c *zip.Ctx) error {
cl, _, err := authWrite(s, c)
if err != nil {
return err
}
err = cl.deleteKVNamespace(c.Context(), strings.TrimSpace(c.Query("account")), strings.TrimSpace(c.Param("namespace")))
return stubResult(c, "kv", err)
}
+207
View File
@@ -0,0 +1,207 @@
package cloudflare
// pages.go — Cloudflare Pages, WIRED. Each handler resolves the caller-org token
// (authClient for reads, authWrite for mutations) and the account id (resolveAccount),
// then proxies to the CF Pages API under /accounts/{account_id}/pages/*. Request
// bodies are decoded into the typed params ported from the platform's cloudflare.ts
// (PagesProjectCreateParams et al.), so only modeled fields reach Cloudflare;
// responses relay verbatim (no field loss).
import (
"encoding/json"
"net/http"
"strings"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
)
// ── ported request structs (mirror platform pkg/platform/src/services/cloudflare.ts) ──
// PagesKVBinding / PagesD1Binding / PagesR2Binding are the deployment-config resource
// bindings (ported from PagesDeploymentConfig).
type PagesKVBinding struct {
NamespaceID string `json:"namespace_id"`
}
type PagesD1Binding struct {
ID string `json:"id"`
}
type PagesR2Binding struct {
Name string `json:"name"`
}
// PagesEnvVar is one deployment env var (plain_text | secret_text).
type PagesEnvVar struct {
Value string `json:"value"`
Type string `json:"type,omitempty"`
}
// PagesBuildConfig is the project build config.
type PagesBuildConfig struct {
BuildCommand string `json:"build_command,omitempty"`
DestinationDir string `json:"destination_dir,omitempty"`
RootDir string `json:"root_dir,omitempty"`
}
// PagesDeploymentConfig is a preview/production deployment config.
type PagesDeploymentConfig struct {
CompatibilityDate string `json:"compatibility_date,omitempty"`
CompatibilityFlags []string `json:"compatibility_flags,omitempty"`
EnvVars map[string]PagesEnvVar `json:"env_vars,omitempty"`
KVNamespaces map[string]PagesKVBinding `json:"kv_namespaces,omitempty"`
D1Databases map[string]PagesD1Binding `json:"d1_databases,omitempty"`
R2Buckets map[string]PagesR2Binding `json:"r2_buckets,omitempty"`
}
// PagesDeploymentConfigs pairs the preview + production deployment configs.
type PagesDeploymentConfigs struct {
Preview *PagesDeploymentConfig `json:"preview,omitempty"`
Production *PagesDeploymentConfig `json:"production,omitempty"`
}
// PagesProjectCreate is the create-project request body (ported from
// PagesProjectCreateParams). The platform sends {name, production_branch}; the full
// shape is modeled so a richer caller is forwarded faithfully.
type PagesProjectCreate struct {
Name string `json:"name"`
ProductionBranch string `json:"production_branch,omitempty"`
BuildConfig *PagesBuildConfig `json:"build_config,omitempty"`
DeploymentConfigs *PagesDeploymentConfigs `json:"deployment_configs,omitempty"`
}
// ── handlers ────────────────────────────────────────────────────────────────────
func pagesList(s *cloud.Service[state], c *zip.Ctx) error {
cl, org, err := authClient(s, c)
if err != nil {
return err
}
acct, err := cl.resolveAccount(c.Context(), org, c)
if err != nil {
return err
}
return cl.pass(c, http.MethodGet, "/accounts/"+acct+"/pages/projects", nil)
}
func pagesGet(s *cloud.Service[state], c *zip.Ctx) error {
cl, org, err := authClient(s, c)
if err != nil {
return err
}
acct, err := cl.resolveAccount(c.Context(), org, c)
if err != nil {
return err
}
proj, err := pathSeg(c, "project", nameRE)
if err != nil {
return err
}
return cl.pass(c, http.MethodGet, "/accounts/"+acct+"/pages/projects/"+proj, nil)
}
func pagesCreate(s *cloud.Service[state], c *zip.Ctx) error {
cl, org, err := authWrite(s, c)
if err != nil {
return err
}
acct, err := cl.resolveAccount(c.Context(), org, c)
if err != nil {
return err
}
var in PagesProjectCreate
if err := json.Unmarshal(c.Body(), &in); err != nil {
return zip.ErrBadRequest("invalid request body")
}
if !nameRE.MatchString(strings.TrimSpace(in.Name)) {
return zip.ErrBadRequest("project name is invalid")
}
return cl.pass(c, http.MethodPost, "/accounts/"+acct+"/pages/projects", in)
}
func pagesDelete(s *cloud.Service[state], c *zip.Ctx) error {
cl, org, err := authWrite(s, c)
if err != nil {
return err
}
acct, err := cl.resolveAccount(c.Context(), org, c)
if err != nil {
return err
}
proj, err := pathSeg(c, "project", nameRE)
if err != nil {
return err
}
return cl.pass(c, http.MethodDelete, "/accounts/"+acct+"/pages/projects/"+proj, nil)
}
func pagesDeploy(s *cloud.Service[state], c *zip.Ctx) error {
cl, org, err := authWrite(s, c)
if err != nil {
return err
}
acct, err := cl.resolveAccount(c.Context(), org, c)
if err != nil {
return err
}
proj, err := pathSeg(c, "project", nameRE)
if err != nil {
return err
}
// A Git-connected project builds from a branch (or its production branch when
// none is given); an absent branch is an empty POST.
var in struct {
Branch string `json:"branch"`
}
_ = json.Unmarshal(c.Body(), &in)
var body any
if b := strings.TrimSpace(in.Branch); b != "" {
body = map[string]string{"branch": b}
}
return cl.pass(c, http.MethodPost, "/accounts/"+acct+"/pages/projects/"+proj+"/deployments", body)
}
func pagesDomainAdd(s *cloud.Service[state], c *zip.Ctx) error {
cl, org, err := authWrite(s, c)
if err != nil {
return err
}
acct, err := cl.resolveAccount(c.Context(), org, c)
if err != nil {
return err
}
proj, err := pathSeg(c, "project", nameRE)
if err != nil {
return err
}
var in struct {
Name string `json:"name"`
}
if err := json.Unmarshal(c.Body(), &in); err != nil {
return zip.ErrBadRequest("invalid request body")
}
name := strings.TrimSpace(in.Name)
if name == "" {
return zip.ErrBadRequest("domain name is required")
}
return cl.pass(c, http.MethodPost, "/accounts/"+acct+"/pages/projects/"+proj+"/domains", map[string]string{"name": name})
}
func pagesDomainDelete(s *cloud.Service[state], c *zip.Ctx) error {
cl, org, err := authWrite(s, c)
if err != nil {
return err
}
acct, err := cl.resolveAccount(c.Context(), org, c)
if err != nil {
return err
}
proj, err := pathSeg(c, "project", nameRE)
if err != nil {
return err
}
dom, err := pathSeg(c, "domain", nameRE)
if err != nil {
return err
}
return cl.pass(c, http.MethodDelete, "/accounts/"+acct+"/pages/projects/"+proj+"/domains/"+dom, nil)
}
+66
View File
@@ -0,0 +1,66 @@
package cloudflare
// r2.go — Cloudflare R2 (account-scoped: /accounts/{id}/r2/buckets). PHASE-2 STUB:
// the routes and the typed provider methods exist so the surface is complete and
// typed, but the bodies ship in Phase 2 — each handler answers an honest 501 (via
// stubResult), never a fake success. Every handler still runs the authClient gate,
// so an R2 route is no softer a target than a wired one.
import (
"context"
"encoding/json"
"strings"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
)
// R2Bucket is the Phase-2 R2 bucket shape.
type R2Bucket struct {
Name string `json:"name"`
CreationDate string `json:"creation_date,omitempty"`
Location string `json:"location,omitempty"`
}
// listR2Buckets / createR2Bucket / deleteR2Bucket are the typed Phase-2 provider
// seams; the wired bodies land in Phase 2.
func (cl *client) listR2Buckets(context.Context, string) ([]R2Bucket, error) {
return nil, errPhase2
}
func (cl *client) createR2Bucket(context.Context, string, string) (*R2Bucket, error) {
return nil, errPhase2
}
func (cl *client) deleteR2Bucket(context.Context, string, string) error {
return errPhase2
}
func r2BucketList(s *cloud.Service[state], c *zip.Ctx) error {
cl, _, err := authClient(s, c)
if err != nil {
return err
}
_, err = cl.listR2Buckets(c.Context(), strings.TrimSpace(c.Query("account")))
return stubResult(c, "r2", err)
}
func r2BucketCreate(s *cloud.Service[state], c *zip.Ctx) error {
cl, _, err := authWrite(s, c)
if err != nil {
return err
}
var in struct {
Name string `json:"name"`
}
_ = json.Unmarshal(c.Body(), &in)
_, err = cl.createR2Bucket(c.Context(), strings.TrimSpace(c.Query("account")), strings.TrimSpace(in.Name))
return stubResult(c, "r2", err)
}
func r2BucketDelete(s *cloud.Service[state], c *zip.Ctx) error {
cl, _, err := authWrite(s, c)
if err != nil {
return err
}
err = cl.deleteR2Bucket(c.Context(), strings.TrimSpace(c.Query("account")), strings.TrimSpace(c.Param("bucket")))
return stubResult(c, "r2", err)
}
+248
View File
@@ -0,0 +1,248 @@
package cloudflare
// workers.go — Cloudflare Workers, WIRED: script put/list/delete, the account
// workers.dev subdomain + per-script enable, and zone route bind/list/delete. Reads
// use authClient (validated org); mutations use authWrite (validated org + org admin).
// Script upload is the modern multipart module format (a metadata part + the module
// part). Scripts + subdomain are account-scoped (/accounts/{id}/workers/*); routes are
// zone-scoped (/zones/{zone_id}/workers/routes).
import (
"bytes"
"encoding/json"
"fmt"
"mime/multipart"
"net/http"
"net/textproto"
"strings"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
)
// WorkerScriptPut is the upload request for a Workers module script. Script is the
// ES-module source; MainModule names the entry file (default "worker.js").
// CompatibilityDate/Flags and Bindings ride the multipart metadata part.
type WorkerScriptPut struct {
Script string `json:"script"`
MainModule string `json:"mainModule,omitempty"`
CompatibilityDate string `json:"compatibilityDate,omitempty"`
CompatibilityFlags []string `json:"compatibilityFlags,omitempty"`
Bindings json.RawMessage `json:"bindings,omitempty"`
}
// WorkerRouteCreate binds a Worker script to a URL pattern within a zone.
type WorkerRouteCreate struct {
Pattern string `json:"pattern"`
Script string `json:"script,omitempty"`
}
// ── scripts ─────────────────────────────────────────────────────────────────────
func workersScriptList(s *cloud.Service[state], c *zip.Ctx) error {
cl, org, err := authClient(s, c)
if err != nil {
return err
}
acct, err := cl.resolveAccount(c.Context(), org, c)
if err != nil {
return err
}
return cl.pass(c, http.MethodGet, "/accounts/"+acct+"/workers/scripts", nil)
}
func workersScriptPut(s *cloud.Service[state], c *zip.Ctx) error {
cl, org, err := authWrite(s, c)
if err != nil {
return err
}
acct, err := cl.resolveAccount(c.Context(), org, c)
if err != nil {
return err
}
name, err := pathSeg(c, "script", nameRE)
if err != nil {
return err
}
var in WorkerScriptPut
if err := json.Unmarshal(c.Body(), &in); err != nil {
return zip.ErrBadRequest("invalid request body")
}
if strings.TrimSpace(in.Script) == "" {
return zip.ErrBadRequest("script source is required")
}
body, contentType, err := buildWorkerUpload(in)
if err != nil {
return zip.ErrBadRequest(err.Error())
}
var out json.RawMessage
if err := cl.cfUpload(c.Context(), http.MethodPut, "/accounts/"+acct+"/workers/scripts/"+name, contentType, body, &out); err != nil {
return cfErr(err)
}
return writeResult(c, out)
}
func workersScriptDelete(s *cloud.Service[state], c *zip.Ctx) error {
cl, org, err := authWrite(s, c)
if err != nil {
return err
}
acct, err := cl.resolveAccount(c.Context(), org, c)
if err != nil {
return err
}
name, err := pathSeg(c, "script", nameRE)
if err != nil {
return err
}
return cl.pass(c, http.MethodDelete, "/accounts/"+acct+"/workers/scripts/"+name, nil)
}
// buildWorkerUpload builds the Cloudflare multipart/form-data body for a module
// Worker upload: a metadata JSON part (main_module + optional compatibility settings
// and bindings) plus the module source part, whose form field name MUST equal
// main_module so Cloudflare links them. Returns the body + its multipart content type.
func buildWorkerUpload(in WorkerScriptPut) ([]byte, string, error) {
main := strings.TrimSpace(in.MainModule)
if main == "" {
main = "worker.js"
}
if !nameRE.MatchString(main) {
return nil, "", fmt.Errorf("mainModule is invalid")
}
meta := map[string]any{"main_module": main}
if in.CompatibilityDate != "" {
meta["compatibility_date"] = in.CompatibilityDate
}
if len(in.CompatibilityFlags) > 0 {
meta["compatibility_flags"] = in.CompatibilityFlags
}
if len(in.Bindings) > 0 {
meta["bindings"] = in.Bindings
}
metaJSON, err := json.Marshal(meta)
if err != nil {
return nil, "", err
}
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
mh := make(textproto.MIMEHeader)
mh.Set("Content-Disposition", `form-data; name="metadata"`)
mh.Set("Content-Type", "application/json")
mp, err := mw.CreatePart(mh)
if err != nil {
return nil, "", err
}
if _, err := mp.Write(metaJSON); err != nil {
return nil, "", err
}
sh := make(textproto.MIMEHeader)
sh.Set("Content-Disposition", fmt.Sprintf(`form-data; name=%q; filename=%q`, main, main))
sh.Set("Content-Type", "application/javascript+module")
sp, err := mw.CreatePart(sh)
if err != nil {
return nil, "", err
}
if _, err := sp.Write([]byte(in.Script)); err != nil {
return nil, "", err
}
if err := mw.Close(); err != nil {
return nil, "", err
}
return buf.Bytes(), mw.FormDataContentType(), nil
}
// ── workers.dev subdomain ───────────────────────────────────────────────────────
func workersSubdomainGet(s *cloud.Service[state], c *zip.Ctx) error {
cl, org, err := authClient(s, c)
if err != nil {
return err
}
acct, err := cl.resolveAccount(c.Context(), org, c)
if err != nil {
return err
}
return cl.pass(c, http.MethodGet, "/accounts/"+acct+"/workers/subdomain", nil)
}
// workersScriptSubdomainSet enables/disables a script on the account workers.dev
// subdomain (POST .../scripts/{script}/subdomain {enabled}).
func workersScriptSubdomainSet(s *cloud.Service[state], c *zip.Ctx) error {
cl, org, err := authWrite(s, c)
if err != nil {
return err
}
acct, err := cl.resolveAccount(c.Context(), org, c)
if err != nil {
return err
}
name, err := pathSeg(c, "script", nameRE)
if err != nil {
return err
}
var in struct {
Enabled bool `json:"enabled"`
}
if err := json.Unmarshal(c.Body(), &in); err != nil {
return zip.ErrBadRequest("invalid request body")
}
return cl.pass(c, http.MethodPost, "/accounts/"+acct+"/workers/scripts/"+name+"/subdomain", map[string]bool{"enabled": in.Enabled})
}
// ── zone routes ─────────────────────────────────────────────────────────────────
func workersRouteList(s *cloud.Service[state], c *zip.Ctx) error {
cl, _, err := authClient(s, c)
if err != nil {
return err
}
zone, err := pathSeg(c, "zone", idRE)
if err != nil {
return err
}
return cl.pass(c, http.MethodGet, "/zones/"+zone+"/workers/routes", nil)
}
func workersRouteCreate(s *cloud.Service[state], c *zip.Ctx) error {
cl, _, err := authWrite(s, c)
if err != nil {
return err
}
zone, err := pathSeg(c, "zone", idRE)
if err != nil {
return err
}
var in WorkerRouteCreate
if err := json.Unmarshal(c.Body(), &in); err != nil {
return zip.ErrBadRequest("invalid request body")
}
pattern := strings.TrimSpace(in.Pattern)
if pattern == "" {
return zip.ErrBadRequest("route pattern is required")
}
body := map[string]string{"pattern": pattern}
if sc := strings.TrimSpace(in.Script); sc != "" {
body["script"] = sc
}
return cl.pass(c, http.MethodPost, "/zones/"+zone+"/workers/routes", body)
}
func workersRouteDelete(s *cloud.Service[state], c *zip.Ctx) error {
cl, _, err := authWrite(s, c)
if err != nil {
return err
}
zone, err := pathSeg(c, "zone", idRE)
if err != nil {
return err
}
route, err := pathSeg(c, "route", idRE)
if err != nil {
return err
}
return cl.pass(c, http.MethodDelete, "/zones/"+zone+"/workers/routes/"+route, nil)
}
+8
View File
@@ -31,6 +31,11 @@ func NewDispatcher(
CloneURL: cloneURL,
VerifyRef: verifyRef,
Log: log,
// #48 route-work: enqueue a routed run on the ONE embedded tasks engine,
// gated by the agents liveness check. Both bind to the real in-process
// packages; a routed run with no live engine/target fails closed.
Route: enqueueRoutedRun,
TargetGate: agents.TargetDispatchable,
}
}
@@ -40,6 +45,9 @@ type sessionAdapter struct{}
func (sessionAdapter) Open(ctx context.Context, org, actor, agent, title string) (string, error) {
return agents.OpenSession(ctx, org, actor, agent, title)
}
func (sessionAdapter) OpenOn(ctx context.Context, org, actor, agent, title, target string) (string, error) {
return agents.OpenSessionOn(ctx, org, actor, agent, title, target)
}
func (sessionAdapter) Log(ctx context.Context, org, sessionID, kind, actor string, payload []byte) error {
return agents.LogSessionEvent(ctx, org, sessionID, kind, actor, payload)
}
+123
View File
@@ -44,6 +44,10 @@ const (
// Sessions is the live agent-session registry seam (clients/agents in-process).
type Sessions interface {
Open(ctx context.Context, org, actor, agent, title string) (string, error)
// OpenOn opens a session tagged with the run's dispatch TARGET, so a routed
// run shows in mission-control on the machine it was sent to. An empty target
// behaves exactly like Open.
OpenOn(ctx context.Context, org, actor, agent, title, target string) (string, error)
Log(ctx context.Context, org, sessionID, kind, actor string, payload []byte) error
Close(ctx context.Context, org, sessionID, status string) error
}
@@ -109,6 +113,11 @@ type RunResult struct {
// Req is one coding request the trigger surface dispatches. Credential is the
// per-org agent git secret the caller resolved from KMS fail-closed; it is
// relayed to the sandbox and NEVER logged or placed in a session event.
//
// TargetID, when set, ROUTES the run to a registered machine (#48): the run is
// enqueued as a durable task addressed to that target instead of executing in the
// cloud-side sandbox, and the credential is NOT used (the machine authenticates
// with its own). When empty, the local sandbox path runs unchanged.
type Req struct {
Org string
UserID string // linked Hanzo subject — session attribution + X-User-Id
@@ -120,6 +129,25 @@ type Req struct {
CredUser string
CredToken string
TimeoutSeconds int
TargetID string // when set, route to this registered machine instead of the sandbox
}
// RoutedRun is the NON-SECRET spec coding hands the Route seam to enqueue on the
// durable engine. It mirrors agents.RoutedRun so coding.go stays pure (no agents
// import); the adapter bridges the two, exactly as PRInput/RunRequest mirror
// their downstream types. It carries no credential by design — the executing
// machine authenticates git + model routing with its own already-held creds.
type RoutedRun struct {
Org string
TargetID string
SessionID string
Repo string
Project string
Base string
Branch string
Prompt string
CloneURL string
TimeoutSeconds int
}
// Result is the terminal outcome the trigger surface renders.
@@ -135,6 +163,12 @@ type Result struct {
PR PRRef
LogTail string
Error string
// Routed reports that the run was ENQUEUED to a target machine rather than run
// in the cloud sandbox. When true, OK means "accepted + queued" (not
// "completed"): the terminal outcome flows through the session stream as the
// machine executes. TargetID is the machine it was routed to.
Routed bool
TargetID string
}
// Runner-facing behavioral defaults.
@@ -155,6 +189,14 @@ type Dispatcher struct {
// Log is an optional structured log seam for best-effort mirror failures; nil
// is fine (mirror failures are non-fatal and simply dropped).
Log func(msg string, kv ...any)
// Route enqueues a routed run on the durable engine (the tasks-engine binding
// in routed.go). Nil disables routing — a run with a TargetID then fails
// closed rather than silently running in the sandbox.
Route func(ctx context.Context, run RoutedRun) error
// TargetGate is the fail-closed liveness+existence check for a routed run's
// target (agents.TargetDispatchable): the target exists in this org, is
// online, and has a live runner. Nil disables routing.
TargetGate func(ctx context.Context, org, targetID string) error
}
// Run executes one coding job end to end and returns its Result. It never
@@ -179,6 +221,15 @@ func (d Dispatcher) Run(ctx context.Context, req Req) Result {
if len(prompt) > maxPromptLen {
prompt = prompt[:maxPromptLen]
}
// ROUTED PATH (#48): a run with a chosen target is ENQUEUED as a durable task
// addressed to that machine and executed THERE, never in the cloud sandbox. It
// carries no credential (the machine uses its own), so it branches BEFORE the
// credential gate; everything below — the local sandbox path — is unchanged.
if strings.TrimSpace(req.TargetID) != "" {
return d.routed(ctx, req, org, repo, prompt, res)
}
if strings.TrimSpace(req.CredToken) == "" {
res.Error = "no agent credential for this org"
return res
@@ -296,6 +347,78 @@ func (d Dispatcher) Run(ctx context.Context, req Req) Result {
return res
}
// routed dispatches one run to a chosen target machine (#48). It opens the live
// session tagged with the target (so mission-control shows it on that machine),
// enqueues a DURABLE task addressed to the target on the tasks engine, and
// returns immediately — the machine claims and executes it, streaming the
// terminal outcome into the SAME session. It never runs locally: an unavailable
// target, a disabled router, or a failed enqueue FAILS CLOSED with an honest
// error.
func (d Dispatcher) routed(ctx context.Context, req Req, org, repo, prompt string, res Result) Result {
target := strings.TrimSpace(req.TargetID)
res.Routed = true
res.TargetID = target
// Routing must be wired (composition root binds both). A half-wired dispatcher
// must not fall through to the sandbox with a target set.
if d.Route == nil || d.TargetGate == nil {
res.Error = "routing is not available"
return res
}
// The machine clones the org's repo with its OWN credential; we still need the
// clone URL (non-secret) to hand it.
cloneURL := ""
if d.CloneURL != nil {
cloneURL = d.CloneURL(org, repo)
}
if cloneURL == "" {
res.Error = "git is not available"
return res
}
// Liveness + existence gate: only dispatch to a target that exists in this
// org, is online, and has a live runner. Fail closed — never elsewhere.
if err := d.TargetGate(ctx, org, target); err != nil {
res.Error = "target " + target + " is not available: " + err.Error()
return res
}
agentRef := strings.TrimSpace(req.AgentRef)
if agentRef == "" {
agentRef = "hanzo"
}
actor := strings.TrimSpace(req.UserID)
sessionID, err := d.Sessions.OpenOn(ctx, org, actor, agentRef, codingTitle(repo, prompt), target)
if err != nil {
res.Error = "could not start a session: " + err.Error()
return res
}
res.SessionID = sessionID
branch := "agent/" + shortID(sessionID)
res.Branch = branch
d.mirror(ctx, org, sessionID, actor, kindStatus, map[string]any{
"status": "routed", "repo": repo, "branch": branch, "base": baseOr(req.Base), "target": target,
})
run := RoutedRun{
Org: org, TargetID: target, SessionID: sessionID,
Repo: repo, Project: strings.TrimSpace(req.Project), Base: strings.TrimSpace(req.Base),
Branch: branch, Prompt: prompt, CloneURL: cloneURL, TimeoutSeconds: timeoutOr(req.TimeoutSeconds),
}
// Enqueue on the durable engine. A failure fails the run closed (session
// error) rather than leaving a zombie "running" session or running locally.
if err := d.Route(ctx, run); err != nil {
term := context.WithoutCancel(ctx)
return d.fail(term, org, sessionID, actor, res, "could not queue the routed run: "+err.Error(), "")
}
res.OK = true // accepted + queued; the machine drives it to terminal from here
return res
}
// fail records the error into the session, closes it error, and stamps the Result.
func (d Dispatcher) fail(ctx context.Context, org, sessionID, actor string, res Result, msg, logTail string) Result {
d.mirror(ctx, org, sessionID, actor, kindStatus, map[string]any{"status": "error", "error": msg})
+11 -2
View File
@@ -9,7 +9,7 @@ import (
// ---- fakes recording every seam call for isolation + contract assertions ----
type openCall struct{ org, actor, agent, title string }
type openCall struct{ org, actor, agent, title, target string }
type eventCall struct {
org, session, kind, actor string
payload string
@@ -28,7 +28,16 @@ type fakeSessions struct {
func (f *fakeSessions) Open(_ context.Context, org, actor, agent, title string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.opened = append(f.opened, openCall{org, actor, agent, title})
f.opened = append(f.opened, openCall{org, actor, agent, title, ""})
if f.openErr != nil {
return "", f.openErr
}
return f.id, nil
}
func (f *fakeSessions) OpenOn(_ context.Context, org, actor, agent, title, target string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.opened = append(f.opened, openCall{org, actor, agent, title, target})
if f.openErr != nil {
return "", f.openErr
}
+161
View File
@@ -0,0 +1,161 @@
package coding
import (
"context"
"errors"
"fmt"
"sync"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/agents"
tasksclient "github.com/hanzoai/tasks/pkg/sdk/client"
"github.com/hanzoai/tasks/pkg/sdk/temporal"
tasksworker "github.com/hanzoai/tasks/pkg/sdk/worker"
"github.com/hanzoai/tasks/pkg/sdk/workflow"
)
// routed.go is coding's durable substrate for #48 route-work: it enqueues one
// routed run as a workflow on the ONE embedded hanzoai/tasks engine
// (cloud.EmbeddedTasks) — the SAME queue index-on-push and automations use, never
// a second async system — and owns that run until an external machine reports it.
//
// ONE ENGINE, ONE IDIOM. Like index_on_push: dial the loopback engine once,
// register a worker with the workflow + its single activity, and ExecuteWorkflow
// keyed by the session id (idempotent — a re-dispatch of the same run is a no-op).
//
// DURABILITY VIA A BLOCKING DELIVERY ACTIVITY. RoutedRunWorkflow runs exactly one
// activity, DeliverRoutedRunActivity, which OFFERS the run to the live mailbox
// (clients/agents) and BLOCKS until the machine reports a result. The activity —
// not the workflow — is what re-runs on a worker/cloud restart, so on recovery it
// re-offers the run to the (now-empty) mailbox and the long-polling machine
// re-claims it: durability the mailbox alone could not give. A run never claimed
// (or a machine that dies mid-flight) trips the activity's StartToClose budget →
// the engine retries → after the attempts are spent the workflow fails, which is
// fail-closed by construction (the run never silently succeeds or runs elsewhere).
const (
// routedQueue is this workflow family's own task queue (<service>-<purpose>).
routedQueue = "agent-routed"
// claimDeadline is how long a queued run waits for a machine to CLAIM it before
// the delivery activity's budget starts being dominated by the run itself. It
// is folded into StartToClose; a run unclaimed for the whole budget fails closed.
claimDeadline = 2 * time.Minute
// routedMaxConcurrent sizes the routed worker's activity pool. Each in-flight
// delivery blocks one slot for the run's lifetime, so this caps concurrent
// routed runs this cloud replica shepherds.
routedMaxConcurrent = 256
// routedHeartbeat lets the engine detect a dead worker: while this cloud is
// alive the worker auto-heartbeats at half this interval, so a crash stops the
// heartbeats and the engine re-dispatches the delivery to a live worker.
routedHeartbeat = 30 * time.Second
)
var errEngineNotReady = errors.New("coding: tasks engine not ready")
// routedStartToClose is the delivery activity's total budget: the claim window
// plus the run's own timeout. Exceeding it means no machine claimed, or one
// claimed and never reported — either way the run is re-queued then failed closed.
func routedStartToClose(timeoutSeconds int) time.Duration {
return claimDeadline + time.Duration(timeoutOr(timeoutSeconds))*time.Second
}
// RoutedRunWorkflow is the durable owner of one routed run. Exported for worker
// registration; not called directly.
func RoutedRunWorkflow(ctx workflow.Context, in agents.RoutedRun) (agents.RoutedResult, error) {
actCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
StartToCloseTimeout: routedStartToClose(in.TimeoutSeconds),
HeartbeatTimeout: routedHeartbeat,
RetryPolicy: &temporal.RetryPolicy{
InitialInterval: 2 * time.Second,
BackoffCoefficient: 2.0,
MaximumInterval: 30 * time.Second,
MaximumAttempts: 3,
},
})
var res agents.RoutedResult
err := workflow.ExecuteActivity(actCtx, DeliverRoutedRunActivity, in).Get(actCtx, &res)
return res, err
}
// DeliverRoutedRunActivity offers the run to the live mailbox and blocks until the
// machine reports a terminal result or the budget elapses. It derives an internal
// deadline from the same budget so the goroutine can never outlive the activity
// even if the engine does not cancel the passed ctx exactly at StartToClose.
// Exported for worker registration; not called directly.
func DeliverRoutedRunActivity(ctx context.Context, in agents.RoutedRun) (agents.RoutedResult, error) {
ctx, cancel := context.WithTimeout(ctx, routedStartToClose(in.TimeoutSeconds))
defer cancel()
off := agents.OfferRoutedRun(in)
defer off.Close()
res, ok := off.Await(ctx)
if !ok {
return agents.RoutedResult{}, fmt.Errorf("routed run %s was not completed before its deadline", in.SessionID)
}
return res, nil
}
var (
routedClientMu sync.Mutex
routedClient tasksclient.Client
)
// routedEngineClient lazily dials the ONE embedded engine and registers the
// routed worker once (process-lifetime), memoizing the client. Dialed on the
// always-registered `default` namespace — isolation rides in the workflow input
// (agents.RoutedRun.Org) exactly as index-on-push does. Nil engine (before
// wireDurableIngest, or an embed failure) => errEngineNotReady, which the
// dispatcher renders as an honest failure (a routed run NEVER falls back to
// local execution).
func routedEngineClient() (tasksclient.Client, error) {
routedClientMu.Lock()
defer routedClientMu.Unlock()
if routedClient != nil {
return routedClient, nil
}
eng := cloud.EmbeddedTasks()
if eng == nil {
return nil, errEngineNotReady
}
cli, err := tasksclient.Dial(tasksclient.Options{
HostPort: fmt.Sprintf("127.0.0.1:%d", eng.ZAPPort()),
Namespace: "default",
})
if err != nil {
return nil, fmt.Errorf("routed dial engine: %w", err)
}
w := tasksworker.New(cli, routedQueue, tasksworker.Options{
MaxConcurrentActivityExecutionSize: routedMaxConcurrent,
})
w.RegisterWorkflow(RoutedRunWorkflow)
w.RegisterActivity(DeliverRoutedRunActivity)
if err := w.Start(); err != nil {
cli.Close()
return nil, fmt.Errorf("routed worker start: %w", err)
}
routedClient = cli
return cli, nil
}
// enqueueRoutedRun is the Route seam's production binding: it starts the durable
// RoutedRunWorkflow keyed by the session id (idempotent) and returns immediately.
// No local execution, no new queue.
func enqueueRoutedRun(ctx context.Context, run RoutedRun) error {
cli, err := routedEngineClient()
if err != nil {
return err
}
in := agents.RoutedRun{
Org: run.Org, TargetID: run.TargetID, SessionID: run.SessionID,
Repo: run.Repo, Project: run.Project, Base: run.Base, Branch: run.Branch,
Prompt: run.Prompt, CloneURL: run.CloneURL, TimeoutSeconds: run.TimeoutSeconds,
}
_, err = cli.ExecuteWorkflow(ctx, tasksclient.StartWorkflowOptions{
ID: run.SessionID,
TaskQueue: routedQueue,
}, RoutedRunWorkflow, in)
return err
}
+231
View File
@@ -0,0 +1,231 @@
package coding
import (
"context"
"errors"
"strings"
"sync"
"testing"
)
// fakeRouter records every routed run handed to the Route seam.
type fakeRouter struct {
mu sync.Mutex
runs []RoutedRun
err error
called int
}
func (f *fakeRouter) route(_ context.Context, run RoutedRun) error {
f.mu.Lock()
defer f.mu.Unlock()
f.called++
f.runs = append(f.runs, run)
return f.err
}
// fakeGate records the (org,target) it was asked to admit and returns a verdict.
type fakeGate struct {
mu sync.Mutex
seen [][2]string
err error
called int
}
func (g *fakeGate) gate(_ context.Context, org, target string) error {
g.mu.Lock()
defer g.mu.Unlock()
g.called++
g.seen = append(g.seen, [2]string{org, target})
return g.err
}
// routedDispatcher builds a Dispatcher wired for routing. The Runner is present
// but MUST NOT be called on a routed run.
func routedDispatcher(sess *fakeSessions, run *fakeRunner, router *fakeRouter, gate *fakeGate) (Dispatcher, *[]string) {
var cloneCalls []string
d := Dispatcher{
Sessions: sess, Tracker: &fakeTracker{}, Runner: run,
CloneURL: func(org, repo string) string {
cloneCalls = append(cloneCalls, org+"/"+repo)
return "https://git.test/v1/git/" + org + "/" + repo + ".git"
},
Route: router.route,
TargetGate: gate.gate,
}
return d, &cloneCalls
}
func routedReq() Req {
// Deliberately NO credential — a routed run must not need one.
return Req{Org: "acme", UserID: "u-1", AgentRef: "hanzo", Repo: "api", Prompt: "add a test", TargetID: "tgt_evo"}
}
// A routed run is ENQUEUED to the target and NEVER executed in the sandbox.
func TestRun_RoutedToTarget_EnqueuesNotLocal(t *testing.T) {
sess := &fakeSessions{id: "sess_route1abc"}
run := &fakeRunner{}
router := &fakeRouter{}
gate := &fakeGate{}
d, cloneCalls := routedDispatcher(sess, run, router, gate)
res := d.Run(context.Background(), routedReq())
if !res.Routed || !res.OK || res.TargetID != "tgt_evo" {
t.Fatalf("want routed+accepted to tgt_evo, got %+v", res)
}
// The local sandbox runner was NEVER invoked.
if run.gotOrg != "" || run.gotReq.CloneURL != "" {
t.Fatalf("the local runner must not run for a routed dispatch: %+v", run.gotReq)
}
// The gate was consulted for exactly this (org,target).
if gate.called != 1 || gate.seen[0] != [2]string{"acme", "tgt_evo"} {
t.Fatalf("liveness gate wrong: %+v", gate.seen)
}
// One durable enqueue, addressed to the target, org-scoped clone url, NO secret.
if router.called != 1 {
t.Fatalf("want exactly one enqueue, got %d", router.called)
}
rr := router.runs[0]
if rr.Org != "acme" || rr.TargetID != "tgt_evo" || rr.SessionID != "sess_route1abc" {
t.Fatalf("routed run mis-addressed: %+v", rr)
}
if rr.CloneURL != "https://git.test/v1/git/acme/api.git" {
t.Fatalf("clone url must be org-scoped: %q", rr.CloneURL)
}
if rr.Branch != "agent/route1abc" {
t.Fatalf("branch derived from session id: %q", rr.Branch)
}
// The session was opened ON the target so mission-control shows it there.
if len(sess.opened) != 1 || sess.opened[0].target != "tgt_evo" {
t.Fatalf("session must be opened on the target: %+v", sess.opened)
}
if len(*cloneCalls) != 1 || (*cloneCalls)[0] != "acme/api" {
t.Fatalf("clone must target acme/api only: %v", *cloneCalls)
}
// A routed session stays live (the machine drives it to terminal) — not closed here.
if len(sess.closes) != 0 {
t.Fatalf("a queued routed run must not be closed at dispatch: %+v", sess.closes)
}
}
// The DEFAULT (no target) path is entirely unchanged: the local runner executes,
// the router is never touched.
func TestRun_NoTarget_LocalPathUnchanged(t *testing.T) {
sess := &fakeSessions{id: "sess_local1"}
run := &fakeRunner{result: RunResult{Branch: "agent/local1", Changed: true, OK: true}}
router := &fakeRouter{}
gate := &fakeGate{}
d, _ := routedDispatcher(sess, run, router, gate)
// A local run DOES need a credential.
req := Req{Org: "acme", UserID: "u-1", AgentRef: "hanzo", Repo: "api", Prompt: "fix it", CredToken: "hk-secret"}
res := d.Run(context.Background(), req)
if res.Routed {
t.Fatalf("a run with no target must NOT be routed: %+v", res)
}
if !res.OK || run.gotOrg != "acme" {
t.Fatalf("the local runner must execute the default path: %+v", res)
}
if router.called != 0 || gate.called != 0 {
t.Fatalf("routing seams must be untouched on the local path: route=%d gate=%d", router.called, gate.called)
}
// The session was opened WITHOUT a target (local open path).
if len(sess.opened) != 1 || sess.opened[0].target != "" {
t.Fatalf("local session must open with no target: %+v", sess.opened)
}
}
// A dead / stale / unknown target fails closed at dispatch — nothing is enqueued,
// the runner is never called, and no session is opened.
func TestRun_RoutedDeadTarget_FailsClosed(t *testing.T) {
sess := &fakeSessions{id: "sess_x"}
run := &fakeRunner{}
router := &fakeRouter{}
gate := &fakeGate{err: errors.New("target has no live runner")}
d, _ := routedDispatcher(sess, run, router, gate)
res := d.Run(context.Background(), routedReq())
if res.OK || !strings.Contains(res.Error, "not available") {
t.Fatalf("a dead target must fail closed, got %+v", res)
}
if router.called != 0 {
t.Fatal("nothing may be enqueued to a dead target")
}
if run.gotOrg != "" {
t.Fatal("the local runner must NEVER run as a fallback for a dead target")
}
if len(sess.opened) != 0 {
t.Fatalf("no session should open for an unavailable target: %+v", sess.opened)
}
}
// If the durable enqueue fails, the run fails closed and the (already-open)
// session is closed ERROR — never left a zombie and never run locally.
func TestRun_RoutedEnqueueFails_FailsClosed_SessionErrored(t *testing.T) {
sess := &fakeSessions{id: "sess_enq"}
run := &fakeRunner{}
router := &fakeRouter{err: errors.New("engine not ready")}
gate := &fakeGate{}
d, _ := routedDispatcher(sess, run, router, gate)
res := d.Run(context.Background(), routedReq())
if res.OK || !strings.Contains(res.Error, "queue the routed run") {
t.Fatalf("a failed enqueue must fail closed, got %+v", res)
}
if run.gotOrg != "" {
t.Fatal("a failed enqueue must NOT fall back to local execution")
}
if len(sess.closes) != 1 || sess.closes[0].status != statusError {
t.Fatalf("the session must be closed error, got %+v", sess.closes)
}
}
// A routed run needs NO credential (branches before the credential gate) and the
// routed run carries no secret field at all.
func TestRun_Routed_NeedsNoCredential_CarriesNoSecret(t *testing.T) {
sess := &fakeSessions{id: "sess_nocred"}
router := &fakeRouter{}
gate := &fakeGate{}
d, _ := routedDispatcher(sess, &fakeRunner{}, router, gate)
req := routedReq()
req.CredToken = "" // explicitly none
res := d.Run(context.Background(), req)
if !res.OK || !res.Routed {
t.Fatalf("routed run must succeed without a credential, got %+v", res)
}
// No event payload carries anything credential-shaped; the RoutedRun struct has
// no credential field, so this is structural — assert the enqueued run + events.
rr := router.runs[0]
if rr.Prompt != "add a test" {
t.Fatalf("routed run prompt wrong: %+v", rr)
}
for _, e := range sess.events {
if e.org != "acme" || e.session != "sess_nocred" {
t.Fatalf("routed event escaped tenant/session scope: %+v", e)
}
}
}
// Routing that is not wired must fail closed — never fall through to the sandbox
// with a target set.
func TestRun_RoutedButRoutingUnwired_FailsClosed(t *testing.T) {
sess := &fakeSessions{id: "sess_x"}
run := &fakeRunner{}
// No Route / TargetGate seams.
d := Dispatcher{Sessions: sess, Tracker: &fakeTracker{}, Runner: run,
CloneURL: func(org, repo string) string { return "https://git.test/v1/git/" + org + "/" + repo + ".git" }}
res := d.Run(context.Background(), routedReq())
if res.OK || !strings.Contains(res.Error, "routing is not available") {
t.Fatalf("unwired routing must fail closed, got %+v", res)
}
if run.gotOrg != "" {
t.Fatal("must not run locally when routing is unwired but a target was chosen")
}
}
+4 -4
View File
@@ -1,4 +1,4 @@
// Hanzo Dataroom — goja bundle (read-WRITE, on clients/gojabase).
// Hanzo Dataroom — goja bundle (read-WRITE, on clients/goja).
//
// SELF-CONTAINED, NO ESM, NO node: imports. The complete dataroom business
// logic (documents, data rooms, shareable links with access controls, viewers,
@@ -8,7 +8,7 @@
// data model becomes Base/SQLite tables (see the leaf's schema.go), the handlers
// become the route table below. No Postgres, no Next.js.
//
// Host contract (clients/gojabase injects these per dispatch; each dispatch runs
// Host contract (clients/goja injects these per dispatch; each dispatch runs
// inside ONE per-tenant SQLite transaction that commits iff status < 400):
// globalThis.__db.query(sql, args) -> [ {col: val, ...}, ... ]
// globalThis.__db.exec(sql, args) -> { changes, lastId }
@@ -49,7 +49,7 @@
}
// err builds a route result carrying a non-200 status via __status. A >=400
// status also rolls back the dispatch transaction (gojabase), so a rejected
// status also rolls back the dispatch transaction (NewBase), so a rejected
// request leaves the tenant DB untouched.
function err(status, message) { return { __status: status, error: message }; }
@@ -121,7 +121,7 @@
}
// === route handlers ========================================================
// Admin routes are org-scoped by the per-tenant DB gojabase selects; the Go
// Admin routes are org-scoped by the per-tenant DB NewBase selects; the Go
// leaf refuses any request without a validated principal before dispatching.
// Viewer routes run under the org resolved from the public link id.
+13 -13
View File
@@ -8,7 +8,7 @@
// business logic (documents, data rooms, shareable links with access controls,
// viewers, per-page view analytics) is a self-contained goja bundle (bundle.js, the
// ESM-free port of the Papermark API handlers). It runs in-process on the REUSABLE
// clients/gojabase host — the SAME RW-Base binding captable (#97) pilots and esign
// clients/goja host — the SAME RW-Base binding captable (#97) pilots and esign
// (#100) reuses — which injects __db/__newId/__now and one SQLite file per tenant,
// one transaction per request. This leaf adds only: the per-tenant Schema, the
// object-storage seam for document bytes, a bcrypt HostFn for link passwords, and
@@ -16,7 +16,7 @@
//
// dataroom bundle (bundle.js, go:embed) + per-tenant Schema + __bcrypt HostFn
// │
// clients/gojabase.New(...) ← __db/__newId/__now, per-tenant Base,
// clients/goja.NewBase(...) ← __db/__newId/__now, per-tenant Base,
// │ one transaction per request
// /v1/dataroom/* zip routes
//
@@ -29,7 +29,7 @@
// AUTH. Admin routes require a validated cloud principal (principal.Org → org);
// public viewer routes carry no principal and resolve their org from the link index
// (a link id → org routing table — the one cross-tenant piece). Tenant isolation is
// the per-org SQLite file gojabase selects from that org.
// the per-org SQLite file NewBase selects from that org.
//
// ACTIVATION: dataroom is NOT staged — it mounts under the mount-all default
// (empty CLOUD_ENABLE), so the one binary serves /v1/dataroom/* from first boot.
@@ -51,7 +51,7 @@ import (
"golang.org/x/crypto/bcrypt"
hcloud "github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/gojabase"
"github.com/hanzoai/cloud/clients/goja"
"github.com/hanzoai/cloud/clients/principal"
"github.com/zap-proto/zip"
)
@@ -75,7 +75,7 @@ type blobStore interface {
// state is dataroom's own data; shared deps live in the embedded cloud.Base,
// reached as s.Log.
type state struct {
host *gojabase.Host
host *goja.BaseHost
index *linkIndex
blob blobStore
}
@@ -105,7 +105,7 @@ func Mount(app *zip.App, deps hcloud.Deps) error {
return c.JSON(http.StatusOK, map[string]string{"service": "dataroom", "status": "ok"})
})
host, err := gojabase.New(gojabase.Config{
host, err := goja.NewBase(goja.BaseConfig{
Name: "dataroom",
Bundle: bundleJS,
Schema: schema,
@@ -233,7 +233,7 @@ func uploadDocument(s *hcloud.Service[state], c *zip.Ctx) error {
ct = "application/octet-stream"
}
// ONE tenant encoding everywhere: the object-store key prefix uses the SAME
// injective, path-safe gojabase.TenantSegment the per-tenant SQLite filename
// injective, path-safe goja.TenantSegment the per-tenant SQLite filename
// does — never the raw org (which could carry a '/' and traverse the key
// namespace, and would drift from the DB's encoding).
rk, err := randKey()
@@ -241,7 +241,7 @@ func uploadDocument(s *hcloud.Service[state], c *zip.Ctx) error {
s.Log.Error("dataroom: crypto/rand unavailable", "err", err)
return zip.Errorf(http.StatusInternalServerError, "storage key generation failed")
}
key := "dataroom/" + gojabase.TenantSegment(org) + "/" + rk
key := "dataroom/" + goja.TenantSegment(org) + "/" + rk
if err := s.State.blob.Put(c.Context(), key, raw); err != nil {
s.Log.Error("dataroom storage put failed", "err", err)
return zip.Errorf(http.StatusBadGateway, "document storage unavailable")
@@ -259,7 +259,7 @@ func adminDownload(s *hcloud.Service[state], c *zip.Ctx) error {
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
resp, err := s.State.host.Dispatch(c.Context(), org, gojabase.Request{
resp, err := s.State.host.Dispatch(c.Context(), org, goja.BaseRequest{
Route: "documents.file", Params: map[string]string{"id": c.Param("id")},
})
if err != nil {
@@ -275,7 +275,7 @@ func viewerDownload(s *hcloud.Service[state], c *zip.Ctx) error {
if err != nil || !ok {
return zip.ErrNotFound("link not found")
}
resp, err := s.State.host.Dispatch(c.Context(), org, gojabase.Request{
resp, err := s.State.host.Dispatch(c.Context(), org, goja.BaseRequest{
Route: "view.file",
Params: map[string]string{"linkId": linkID, "documentId": c.Param("documentId")},
Query: map[string]string{"viewId": c.Query("viewId"), "download": c.Query("download")},
@@ -288,7 +288,7 @@ func viewerDownload(s *hcloud.Service[state], c *zip.Ctx) error {
// streamFile turns a {fileKey,contentType,name} bundle result into a byte stream
// from object storage. A non-200 bundle result (404/403) passes through as JSON.
func streamFile(s *hcloud.Service[state], c *zip.Ctx, resp *gojabase.Response) error {
func streamFile(s *hcloud.Service[state], c *zip.Ctx, resp *goja.Response) error {
if resp.Status != http.StatusOK {
c.SetHeader("Content-Type", "application/json")
return c.Bytes(resp.Status, resp.Body)
@@ -325,7 +325,7 @@ func createLink(s *hcloud.Service[state], c *zip.Ctx) error {
if err != nil {
return err
}
resp, err := s.State.host.Dispatch(c.Context(), org, gojabase.Request{Route: "links.create", Body: body})
resp, err := s.State.host.Dispatch(c.Context(), org, goja.BaseRequest{Route: "links.create", Body: body})
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "dataroom dispatch failed")
}
@@ -349,7 +349,7 @@ func createLink(s *hcloud.Service[state], c *zip.Ctx) error {
// write dispatches one bundle route on the tenant's Base store (one transaction
// per request) and writes {status, body}.
func write(s *hcloud.Service[state], c *zip.Ctx, org, route string, params, query map[string]string, body any) error {
resp, err := s.State.host.Dispatch(c.Context(), org, gojabase.Request{
resp, err := s.State.host.Dispatch(c.Context(), org, goja.BaseRequest{
Route: route, Params: params, Query: query, Body: body,
})
if err != nil {
+1 -1
View File
@@ -19,7 +19,7 @@ import (
type linkIndex struct{ db *sql.DB }
func openLinkIndex(dataDir string) (*linkIndex, error) {
// Its OWN dir, distinct from gojabase's per-tenant tree ({dataDir}/dataroom/):
// Its OWN dir, distinct from NewBase's per-tenant tree ({dataDir}/dataroom/):
// this global routing table must never collide with a tenant's DB file.
dir := filepath.Join(dataDir, "dataroom_index")
if err := os.MkdirAll(dir, 0o700); err != nil {
+3 -3
View File
@@ -7,7 +7,7 @@ import (
"fmt"
"net/http"
"github.com/hanzoai/cloud/clients/gojabase"
"github.com/hanzoai/cloud/clients/goja"
)
// ingest.go is the in-process ingestion seam: it lets a sibling subsystem (Hanzo
@@ -48,11 +48,11 @@ func Ingest(ctx context.Context, org, name, contentType string, data []byte) (st
if err != nil {
return "", fmt.Errorf("dataroom.Ingest: storage key: %w", err)
}
key := "dataroom/" + gojabase.TenantSegment(org) + "/" + rk
key := "dataroom/" + goja.TenantSegment(org) + "/" + rk
if err := mounted.State.blob.Put(ctx, key, data); err != nil {
return "", fmt.Errorf("dataroom.Ingest: blob put: %w", err)
}
resp, err := mounted.State.host.Dispatch(ctx, org, gojabase.Request{
resp, err := mounted.State.host.Dispatch(ctx, org, goja.BaseRequest{
Route: "documents.create",
Body: map[string]any{"name": name, "fileKey": key, "contentType": contentType, "fileSize": len(data)},
})
+1 -1
View File
@@ -1,6 +1,6 @@
package dataroom
// schema is the per-tenant SQLite DDL — the Go host owns migrations (gojabase
// schema is the per-tenant SQLite DDL — the Go host owns migrations (NewBase
// runs this on every tenant DB open); the goja bundle only issues SQL against
// these tables. Column names MUST match the SQL in bundle.js. Idempotent
// (IF NOT EXISTS).
+231
View File
@@ -0,0 +1,231 @@
// dashboard.go — the ArgoCD-UI-compatible projection API at /v1/deploy/api/*,
// fed the App-CR projection (projection.go). NO argocd api-server, NO
// repo-server, NO redis, NO stored Application/AppProject CRD — every response
// is synthesized from our operator App CRs. The FRONTEND is NOT here: the
// monochrome dashboard ships as the `hanzoai/spa`-based `cd-ui` App CR served at
// cd.hanzo.ai/ (base-href /); this plane is only the same-origin API it calls (no /api/, no inner /v1):
//
// GET /v1/deploy/settings → AuthSettings (auth disabled; IAM gates at the edge)
// GET /v1/deploy/session/userinfo → {loggedIn:true,...}
// GET /v1/deploy/version → VersionMessage
// GET /v1/deploy/account/can-i/* → {"value":"yes"}
// GET /v1/deploy/applications → ApplicationList (projected)
// GET /v1/deploy/applications/{name} → Application (projected)
// GET /v1/deploy/applications/{name}/resource-tree → ApplicationTree
// POST /v1/deploy/applications/{name}/{sync,rollback} → request App-CR reconcile
//
// Every route is SuperAdmin-gated (c.IsAdmin), fail-closed; the argocd UI's own
// auth is disabled because IAM owns identity at the edge (the SPA is public
// static assets, the data is gated). AppProject → IAM/Org (no argocd RBAC).
package deploy
import (
"encoding/json"
"net/http"
"time"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
k8stypes "k8s.io/apimachinery/pkg/types"
)
// dashPrefix is the API base the monochrome dashboard SPA calls. The FE itself
// is NOT served here — it ships as the `hanzoai/spa`-based `cd-ui` App CR served
// at cd.hanzo.ai/ (base-href /); this cloud plane is ONLY the IAM-gated
// projection API at cd.hanzo.ai/v1/deploy/*, same-origin with the SPA (no CORS).
const dashPrefix = "/v1/deploy"
// registerDashboardRoutes wires the ArgoCD-UI-compatible API surface (no FE —
// the SPA is a separate hanzoai/spa App). Called from routes() (deploy.go).
func registerDashboardRoutes(app *zip.App, s *cloud.Service[state]) {
// Bootstrap (the SPA awaits settings + userinfo before first render).
app.Get(dashPrefix+"/settings", guard(s, cloud.Handle(s, dashSettings)))
// userinfo is the ONE deliberately PUBLIC bootstrap route: it is how the SPA
// asks "am I signed in?", and a 403 to that question is unanswerable — the SPA
// is an XHR client, so the document bounce in guard() never fires for it and it
// dead-ends with no way to reach sign-in. Anonymous callers get
// {loggedIn:false} and the sign-in URL; nothing else. It discloses no identity,
// no cluster state, and no configuration, and it is NOT a gate: every route
// that returns fleet data or mutates a CR stays guard()ed.
app.Get(dashPrefix+"/session/userinfo", cloud.Handle(s, dashUserInfo))
app.Get(dashPrefix+"/version", guard(s, cloud.Handle(s, dashVersion)))
app.Get(dashPrefix+"/account/can-i/*", guard(s, cloud.Handle(s, dashCanI)))
// Applications projection (read).
app.Get(dashPrefix+"/applications", guard(s, cloud.Handle(s, dashAppList)))
app.Get(dashPrefix+"/applications/:name", guard(s, cloud.Handle(s, dashApp)))
app.Get(dashPrefix+"/applications/:name/resource-tree", guard(s, cloud.Handle(s, dashResourceTree)))
// Actions → App-CR reconcile ops.
app.Post(dashPrefix+"/applications/:name/sync", guard(s, cloud.Handle(s, dashSync)))
app.Post(dashPrefix+"/applications/:name/rollback", guard(s, cloud.Handle(s, dashSync)))
}
// ── bootstrap ────────────────────────────────────────────────────────────────
func dashSettings(s *cloud.Service[state], c *zip.Ctx) error {
return c.JSON(http.StatusOK, map[string]any{
"url": "https://cd.hanzo.ai",
"statusBadgeEnabled": false,
"statusBadgeRootUrl": "",
"oidcConfig": nil,
"dexConfig": map[string]any{"connectors": []any{}},
"googleAnalytics": map[string]any{"trackingID": "", "anonymizeUsers": true},
"help": map[string]any{"chatUrl": "", "chatText": "", "binaryUrls": map[string]any{}},
"plugins": []any{},
"userLoginsDisabled": true,
"kustomizeVersions": []any{},
"uiCssURL": "",
"uiBannerContent": "",
"execEnabled": false,
"appsInAnyNamespaceEnabled": false,
"hydratorEnabled": false,
"syncWithReplaceAllowed": false,
})
}
// dashUserInfo answers "is this browser signed in, and if not where does it sign
// in?" — the SPA's bootstrap question, and the only route on this plane that
// answers for an anonymous caller.
//
// The anonymous branch carries loggedIn:false and a URL, and NOTHING else: no
// username, no org, no groups, no issuer, no hint about who the caller might be or
// what exists in the cluster. Answering it costs nothing (the caller already knows
// whether it holds a cookie) and withholding it costs the whole sign-in journey.
//
// The predicate is c.IsAdmin() — the SAME SuperAdmin fact guard() gates on, minted
// by SanitizeIdentity from a validated principal whose org is the reserved admin
// org. So a validated-but-not-SuperAdmin caller is reported as NOT logged in here,
// which is the truth as this console defines it: they cannot use it.
func dashUserInfo(s *cloud.Service[state], c *zip.Ctx) error {
if !c.IsAdmin() {
return c.JSON(http.StatusOK, map[string]any{
"loggedIn": false,
"loginUrl": loginPath,
})
}
user := c.User()
if user == "" {
user = "admin"
}
return c.JSON(http.StatusOK, map[string]any{
"loggedIn": true,
"username": user,
"iss": "argocd", // keep == argocd so the UI never triggers an SSO redirect
"groups": []string{},
"logoutUrl": logoutPath,
})
}
func dashVersion(s *cloud.Service[state], c *zip.Ctx) error {
// PascalCase keys (VersionMessage wire shape).
return c.JSON(http.StatusOK, map[string]any{
"Version": "hanzo-cd (projection)",
"BuildDate": time.Now().UTC().Format(time.RFC3339),
"GoVersion": "", "Compiler": "gc", "Platform": "linux/amd64",
})
}
func dashCanI(s *cloud.Service[state], c *zip.Ctx) error {
// Every route is already SuperAdmin-gated; report yes so buttons enable.
return c.JSON(http.StatusOK, map[string]any{"value": "yes"})
}
// ── applications projection ──────────────────────────────────────────────────
func dashAppList(s *cloud.Service[state], c *zip.Ctx) error {
if err := ready(s); err != nil {
return err
}
list := argoAppList{APIVersion: "argoproj.io/v1alpha1", Kind: "ApplicationList", Metadata: argoListMeta{}, Items: []argoApp{}}
for _, ns := range scanOrder() {
crs, err := listAppCRs(s, c.Context(), ns)
if err != nil {
return k8sErr(s, "list", err)
}
running := runningVersions(s, c.Context(), ns)
for i := range crs {
list.Items = append(list.Items, projectApp(&crs[i], ns, running[crs[i].GetName()]))
}
}
return c.JSON(http.StatusOK, list)
}
func dashApp(s *cloud.Service[state], c *zip.Ctx) error {
if err := ready(s); err != nil {
return err
}
name := reqName(c)
if !appNameRE.MatchString(name) {
return zip.ErrBadRequest("name must be a DNS-1123 label")
}
ns, err := resolveNamespace(s, c, name)
if err != nil {
return err
}
cr, _, err := getAppCR(s, c.Context(), ns, name)
if err != nil {
return k8sErr(s, "get", err)
}
running := runningVersions(s, c.Context(), ns)
app := projectApp(cr, ns, running[name])
// Detail view: populate status.resources from the reconciled tree.
tree := projectTree(buildTree(s, c.Context(), ns, name, cr))
for _, n := range tree.Nodes {
app.Status.Resources = append(app.Status.Resources, argoResourceStatus{
Group: n.Group, Version: n.Version, Kind: n.Kind, Namespace: n.Namespace,
Name: n.Name, Status: app.Status.Sync.Status, Health: n.Health,
})
}
return c.JSON(http.StatusOK, app)
}
func dashResourceTree(s *cloud.Service[state], c *zip.Ctx) error {
if err := ready(s); err != nil {
return err
}
name := reqName(c)
if !appNameRE.MatchString(name) {
return zip.ErrBadRequest("name must be a DNS-1123 label")
}
ns, err := resolveNamespace(s, c, name)
if err != nil {
return err
}
cr, _, err := getAppCR(s, c.Context(), ns, name)
if err != nil {
return k8sErr(s, "get", err)
}
return c.JSON(http.StatusOK, projectTree(buildTree(s, c.Context(), ns, name, cr)))
}
// dashSync requests an operator reconcile of the App CR (the sync + rollback UI
// actions both map to "reconcile this App now" — the App CR is the source of
// truth; rollback-by-revision is the image-pin follow-on). Returns the projected
// Application (the UI only checks for a non-error response).
func dashSync(s *cloud.Service[state], c *zip.Ctx) error {
if err := ready(s); err != nil {
return err
}
name := reqName(c)
if !appNameRE.MatchString(name) {
return zip.ErrBadRequest("name must be a DNS-1123 label")
}
ns, err := resolveNamespace(s, c, name)
if err != nil {
return err
}
cr, gvr, err := getAppCR(s, c.Context(), ns, name)
if err != nil {
return k8sErr(s, "get", err)
}
now := time.Now().UTC().Format(time.RFC3339)
patch, _ := json.Marshal(map[string]any{"metadata": map[string]any{"annotations": map[string]any{syncAnnotation: now}}})
if _, err := s.State.dyn.Resource(gvr).Namespace(ns).Patch(c.Context(), name, k8stypes.MergePatchType, patch, metav1.PatchOptions{}); err != nil {
return k8sErr(s, "patch", err)
}
s.Log.Info("dashboard sync requested", "app", name, "namespace", ns, "actor", c.User())
return c.JSON(http.StatusOK, projectApp(cr, ns, runningVersions(s, c.Context(), ns)[name]))
}
+159
View File
@@ -0,0 +1,159 @@
package deploy
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// TestDeployRoutesRequireAdmin (RED LOW-2): every mounted /v1/deploy route EXCEPT
// the public health probe must 403 without X-User-IsAdmin=true, and must NOT 403
// with it. This guards the guard: a future refactor that adds an /v1/deploy or
// /v1/deploy/ui route without wrapping it in guard() breaks this test.
func TestDeployRoutesRequireAdmin(t *testing.T) {
app := zip.New(zip.Config{Logger: luxlog.New("test")})
routes(app, fakeSvc()) // the COMPLETE surface: native + engine + dashboard
guarded := []struct{ method, path string }{
// engine
{"POST", "/v1/deploy/reconcile"},
// projection API — clean /v1/deploy/<resource> (no /api/, no inner /v1)
{"GET", "/v1/deploy/settings"},
{"GET", "/v1/deploy/version"},
{"GET", "/v1/deploy/account/can-i/applications/get/x"},
{"GET", "/v1/deploy/applications"},
{"GET", "/v1/deploy/applications/cloud"},
{"GET", "/v1/deploy/applications/cloud/resource-tree"},
{"POST", "/v1/deploy/applications/cloud/sync"},
{"POST", "/v1/deploy/applications/cloud/rollback"},
}
for _, r := range guarded {
// WITHOUT admin → 403 (the guard, fail-closed).
resp, err := app.Fiber().Test(httptest.NewRequest(r.method, r.path, nil))
if err != nil {
t.Fatalf("%s %s: %v", r.method, r.path, err)
}
code := resp.StatusCode
_ = resp.Body.Close()
if code != http.StatusForbidden {
t.Errorf("%s %s WITHOUT admin = %d, want 403 (route is not guarded!)", r.method, r.path, code)
}
// WITH admin → the guard passes (handler may 200/404/503, never the guard's 403).
req := httptest.NewRequest(r.method, r.path, nil)
req.Header.Set("X-User-IsAdmin", "true")
resp2, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("%s %s admin: %v", r.method, r.path, err)
}
code2 := resp2.StatusCode
_ = resp2.Body.Close()
if code2 == http.StatusForbidden {
t.Errorf("%s %s WITH admin = 403 (guard must pass for a validated SuperAdmin)", r.method, r.path)
}
}
// The health probe is DELIBERATELY public (liveness without a JWT) — never 403.
resp, err := app.Fiber().Test(httptest.NewRequest("GET", "/v1/deploy/health", nil))
if err != nil {
t.Fatalf("health: %v", err)
}
if resp.StatusCode == http.StatusForbidden {
t.Error("/v1/deploy/health must stay public (probe-able without a JWT)")
}
_ = resp.Body.Close()
// Sign-in is DELIBERATELY public — these routes ARE how a browser becomes a
// SuperAdmin, so gating them behind SuperAdmin would be circular. They grant
// nothing: /login only redirects, /callback refuses anything but a token this
// deployment verifies for a member of the admin org.
for _, r := range []struct{ method, path string }{
{"GET", "/v1/deploy/login"},
{"GET", "/v1/deploy/callback"},
{"POST", "/v1/deploy/logout"},
} {
resp, err := app.Fiber().Test(httptest.NewRequest(r.method, r.path, nil))
if err != nil {
t.Fatalf("%s %s: %v", r.method, r.path, err)
}
code := resp.StatusCode
_ = resp.Body.Close()
if code == http.StatusForbidden {
t.Errorf("%s %s = 403; sign-in cannot require the role it grants", r.method, r.path)
}
}
// Logout must NOT be reachable as a GET: it changes state, and a cross-site
// top-level navigation carries a SameSite=Lax cookie.
resp, err = app.Fiber().Test(httptest.NewRequest("GET", "/v1/deploy/logout", nil))
if err != nil {
t.Fatalf("GET logout: %v", err)
}
code := resp.StatusCode
_ = resp.Body.Close()
if code != http.StatusMethodNotAllowed && code != http.StatusNotFound {
t.Errorf("GET /v1/deploy/logout = %d, want 404/405 (logout is POST-only)", code)
}
}
// TestUserInfoIsPublicBootstrap: the SPA's "am I signed in?" question must be
// answerable WITHOUT being signed in — it is an XHR client, so the document bounce
// never fires for it and a 403 here is a dead end with no route to sign-in. The
// anonymous answer carries the sign-in URL and NOTHING that identifies anyone.
func TestUserInfoIsPublicBootstrap(t *testing.T) {
app := zip.New(zip.Config{Logger: luxlog.New("test")})
routes(app, fakeSvc())
resp, err := app.Fiber().Test(httptest.NewRequest("GET", "/v1/deploy/session/userinfo", nil))
if err != nil {
t.Fatalf("userinfo: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("anonymous userinfo = %d, want 200", resp.StatusCode)
}
var body map[string]any
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
_ = resp.Body.Close()
if body["loggedIn"] != false {
t.Errorf("anonymous loggedIn = %v, want false", body["loggedIn"])
}
if body["loginUrl"] != "/v1/deploy/login" {
t.Errorf("loginUrl = %v, want /v1/deploy/login", body["loginUrl"])
}
// No identity may leak to an anonymous caller — not even an empty placeholder.
for _, k := range []string{"username", "iss", "groups", "email", "org", "logoutUrl"} {
if _, present := body[k]; present {
t.Errorf("anonymous userinfo leaked %q = %v", k, body[k])
}
}
// A cookie must never be minted by a read.
for _, ck := range resp.Cookies() {
if ck.Value != "" {
t.Errorf("userinfo minted a cookie %s=%q", ck.Name, ck.Value)
}
}
// A SuperAdmin gets the real identity on the same route.
req := httptest.NewRequest("GET", "/v1/deploy/session/userinfo", nil)
req.Header.Set("X-User-IsAdmin", "true")
req.Header.Set("X-User-Id", "cto")
resp2, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("admin userinfo: %v", err)
}
var admin map[string]any
if err := json.NewDecoder(resp2.Body).Decode(&admin); err != nil {
t.Fatalf("decode: %v", err)
}
_ = resp2.Body.Close()
if admin["loggedIn"] != true || admin["username"] != "cto" {
t.Errorf("admin userinfo = %v, want loggedIn:true username:cto", admin)
}
}
+83 -93
View File
@@ -2,7 +2,7 @@
// ArgoCD-grade deploy dashboard for the operator-managed fleet, made native to
// the cloud binary and parallel to /v1/git (the native git server).
//
// Each operator hanzo.ai/v1 Service CR IS a GitOps Application: the desired state
// Each operator hanzo.ai/v1 App CR IS a GitOps Application: the desired state
// declared for one workload, which the Hanzo operator reconciles into a
// Deployment + Service + Ingress (+ HPA/PDB/Pods). This plane OBSERVES that
// reconciliation the way ArgoCD observes a synced Application —
@@ -39,6 +39,7 @@ import (
"context"
"fmt"
"net/http"
"net/url"
"regexp"
"github.com/hanzoai/cloud"
@@ -54,27 +55,12 @@ import (
"k8s.io/client-go/tools/clientcmd"
)
// The operator "Application" CR is undergoing a kind collapse: the specialized
// services.hanzo.ai (kind Service) + siblings become ONE apps.hanzo.ai (kind App),
// with the former kind carried as a value in spec.role. appsCRGVR is the FORWARD
// target; servicesCRGVR is the CURRENT live kind. The operator/CRD collapse lands
// on branches; the live cluster still serves kind Service until cutover, so this
// plane reads BOTH — App first (forward), Service as a transition shim. Group
// hanzo.ai disambiguates either from the core/v1 Service (a CHILD it reconciles),
// which is why a resource ref always carries its group.
//
// COMPAT SHIM (removable post-cutover): drop servicesCRGVR from appCRGVRs() and the
// "hanzo.ai/Service" registry entry once every cluster serves kind App.
var (
appsCRGVR = schema.GroupVersionResource{Group: "hanzo.ai", Version: "v1", Resource: "apps"}
servicesCRGVR = schema.GroupVersionResource{Group: "hanzo.ai", Version: "v1", Resource: "services"}
)
// appCRGVRs is the App-CR read order: the forward kind first, the transition kind
// second. Every CR read/resolve walks this list.
func appCRGVRs() []schema.GroupVersionResource {
return []schema.GroupVersionResource{appsCRGVR, servicesCRGVR}
}
// appsCRGVR is the operator App CR (apps.hanzo.ai) — the one workload kind this
// plane reads. Each App IS a GitOps Application: the desired state for one
// workload, which the operator reconciles into a Deployment + Service + Ingress
// (+ HPA/PDB/Pods). Group hanzo.ai disambiguates it from the core/v1 Service (a
// CHILD it reconciles), which is why a resource ref always carries its group.
var appsCRGVR = schema.GroupVersionResource{Group: "hanzo.ai", Version: "v1", Resource: "apps"}
// childGVRs are the operator-owned workload objects the tree walks at depth 1
// (owned by the Service CR) and their descendants (ReplicaSet → Pod). Secrets are
@@ -96,8 +82,7 @@ var (
// the resource endpoint can never be steered at an arbitrary cluster object.
// Keyed by "group/Kind" (group "" for the core API group).
var kindGVR = map[string]schema.GroupVersionResource{
"hanzo.ai/App": appsCRGVR, // forward kind
"hanzo.ai/Service": servicesCRGVR, // transition shim (removable post-cutover)
"hanzo.ai/App": appsCRGVR,
"apps/Deployment": deploymentsGVR,
"apps/ReplicaSet": replicaSetsGVR,
"/Pod": podsGVR,
@@ -134,17 +119,19 @@ type state struct {
dyn dynamic.Interface // nil when no kubeconfig resolved (fail-closed)
clientset kubernetes.Interface
initErr string
oauth oauth // sign-in configuration (login.go)
}
// Mount wires /v1/deploy/* onto app. Every handler gates on c.IsAdmin() first.
func Mount(app *zip.App, deps cloud.Deps) error {
return cloud.Mount(app, deps, "deploy", build, routes)
return cloud.Mount(app, deps, "deploy",
func(b cloud.Base) (state, error) { return build(b, newOAuth(deps)) }, routes)
}
// build resolves the in-process k8s clients (fail-closed: when no kubeconfig
// resolves the subsystem still mounts and every endpoint 503s honestly).
func build(b cloud.Base) (state, error) {
var st state
func build(b cloud.Base, o oauth) (state, error) {
st := state{oauth: o}
dyn, cs, err := newClients()
if err != nil {
st.initErr = err.Error()
@@ -152,60 +139,86 @@ func build(b cloud.Base) (state, error) {
} else {
st.dyn, st.clientset = dyn, cs
}
b.Log.Info("deploy control plane mounted", "prefix", "/v1/deploy", "k8s", st.dyn != nil, "brand", b.Brand, "env", b.Env)
b.Log.Info("deploy control plane mounted", "prefix", "/v1/deploy", "k8s", st.dyn != nil,
"brand", b.Brand, "env", b.Env, "iam", o.issuer, "client", o.clientID, "adminOrg", o.adminOrg)
return st, nil
}
// routes registers the /v1/deploy/* surface. Every observing/mutating route is
// SuperAdmin-gated; the health probe is public (real k8s reachability).
func routes(app *zip.App, s *cloud.Service[state]) {
app.Get("/v1/deploy/applications", guard(s, cloud.Handle(s, listApplications)))
// Liveness — public (probe-able without a JWT).
app.Get("/v1/deploy/health", cloud.Handle(s, health))
app.Get("/v1/deploy/:name/tree", guard(s, cloud.Handle(s, appTree)))
app.Get("/v1/deploy/:name/resource/:ref", guard(s, cloud.Handle(s, appResource)))
app.Get("/v1/deploy/:name/logs", guard(s, cloud.Handle(s, appLogs)))
app.Post("/v1/deploy/:name/rollback", guard(s, cloud.Handle(s, rollback)))
app.Post("/v1/deploy/:name/sync", guard(s, cloud.Handle(s, sync)))
// Sign-in — necessarily public: these three routes ARE how a browser gets an
// authenticated principal for this host. They grant nothing themselves; the
// session they mint is an IAM JWT the identity boundary re-verifies on every
// later request, and a principal outside the admin org is refused a cookie.
// See login.go.
app.Get(loginPath, cloud.Handle(s, login))
app.Get(callbackPath, cloud.Handle(s, callback))
// POST, not GET: signing out changes state, and a state-changing GET is
// reachable by a cross-site top-level navigation that a SameSite=Lax cookie
// still rides. See logout in login.go.
app.Post(logoutPath, cloud.Handle(s, logout))
// Engine (write) reconcile — the embedded gitops-engine that replaces
// universe-crs. Gated by DEPLOY_ENGINE_ENABLED; see engine_mount.go.
registerEngineRoutes(app, s)
// The deploy API at /v1/deploy/<resource> (no /api/ prefix, no inner /v1) —
// the App-CR projection the monochrome dashboard SPA (cd-ui) consumes. This
// IS the deploy API; the FE is the separate hanzoai/spa cd-ui App.
registerDashboardRoutes(app, s)
}
// guard wraps a handler with the SuperAdmin gate (fail-closed: a non-SuperAdmin is
// refused 403 before any cluster object is read or mutated), matching clients/paas.
//
// The gate itself is unchanged — c.IsAdmin() and nothing else, on the SanitizeIdentity
// -minted header no client can forge. Only the SHAPE of the refusal is negotiated: a
// browser NAVIGATION to a deploy URL is sent to the sign-in page (a 403 page with no
// way to sign in is a dead end), while every API call keeps its 403. wantsDocument
// decides, and it decides "no" unless the request positively identifies as a document
// GET — so the API contract, and every client that depends on the 403, is untouched.
func guard(s *cloud.Service[state], h zip.Handler) zip.Handler {
return func(c *zip.Ctx) error {
if !c.IsAdmin() {
if wantsDocument(c.Method(), c.Header("Sec-Fetch-Dest"), c.Header("Sec-Fetch-Mode"),
c.Header("Accept"), c.Header("X-Requested-With")) {
return c.Redirect(http.StatusFound, loginPath+"?returnTo="+url.QueryEscape(currentPath(c)))
}
return zip.ErrForbidden("SuperAdmin required")
}
return h(c)
}
}
// health is a REAL probe: the API server is reachable AND the Service CRD is
// served. 200 only when both hold; 503 + the real reason otherwise. Not
// admin-gated — liveness must be probe-able without a JWT.
// currentPath is the path+query to return to after signing in. It is run through
// the same open-redirect guard as a caller-supplied returnTo — the value is
// server-derived, but there is exactly ONE rule for what a return path may be.
func currentPath(c *zip.Ctx) string {
p := c.Path()
if q := string(c.Fiber().Request().URI().QueryString()); q != "" {
p += "?" + q
}
return safeReturn(p)
}
// health is a REAL probe: the API server is reachable AND the App CRD is served.
// 200 only when both hold; 503 + the real reason otherwise. Not admin-gated —
// liveness must be probe-able without a JWT.
func health(s *cloud.Service[state], c *zip.Ctx) error {
// This route is UNAUTHENTICATED (liveness must be probe-able without a JWT),
// so it reports booleans only — never the raw k8s error string, which can
// disclose the apiserver address / RBAC detail. The detail is logged
// server-side (RED INFO-1).
res := map[string]any{"service": "deploy", "status": "ok"}
if s.State.dyn == nil {
res["status"], res["k8s"], res["error"] = "degraded", false, s.State.initErr
s.Log.Warn("deploy health: kubernetes client unavailable", "err", s.State.initErr)
res["status"], res["k8s"] = "degraded", false
return c.JSON(http.StatusServiceUnavailable, res)
}
// The App CRD is served if EITHER the forward (apps) or transition (services)
// kind lists without error — a NotFound on one during the collapse is not a
// degradation as long as the other answers.
var lastErr error
served := false
for _, gvr := range appCRGVRs() {
if _, err := s.State.dyn.Resource(gvr).Namespace("hanzo").List(c.Context(), metav1.ListOptions{Limit: 1}); err == nil {
served = true
break
} else if !apierrors.IsNotFound(err) {
lastErr = err
}
}
if !served {
if _, err := s.State.dyn.Resource(appsCRGVR).Namespace("hanzo").List(c.Context(), metav1.ListOptions{Limit: 1}); err != nil {
s.Log.Warn("deploy health: App CRD list failed", "err", err)
res["status"], res["k8s"], res["crd"] = "degraded", true, false
if lastErr != nil {
res["error"] = lastErr.Error()
}
return c.JSON(http.StatusServiceUnavailable, res)
}
res["k8s"], res["crd"] = true, true
@@ -239,8 +252,7 @@ func regexpLower(s string) string {
}
// resolveNamespace finds the platform namespace an App CR lives in, scanning in
// env order (main first) across both CR kinds. Returns a clean 404 when found in
// none.
// env order (main first). Returns a clean 404 when found in none.
func resolveNamespace(s *cloud.Service[state], c *zip.Ctx, name string) (string, error) {
for _, ns := range scanOrder() {
if _, _, err := getAppCR(s, c.Context(), ns, name); err == nil {
@@ -252,49 +264,27 @@ func resolveNamespace(s *cloud.Service[state], c *zip.Ctx, name string) (string,
return "", zip.ErrNotFound("application " + name + " not found in the platform namespaces")
}
// getAppCR gets an App CR by name from ns, trying the forward kind (apps) then the
// transition kind (services). Returns the object and the GVR it was found under, so
// a mutation (sync) patches the SAME kind. A miss in both is an IsNotFound error.
// getAppCR gets an App CR by name from ns. Returns the object and its GVR so a
// mutation (sync/rollback) patches the App CR it read. A miss is an IsNotFound
// error.
func getAppCR(s *cloud.Service[state], ctx context.Context, ns, name string) (*unstructured.Unstructured, schema.GroupVersionResource, error) {
var readErr error
for _, gvr := range appCRGVRs() {
obj, err := s.State.dyn.Resource(gvr).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
if err == nil {
return obj, gvr, nil
}
if !apierrors.IsNotFound(err) {
readErr = err
}
obj, err := s.State.dyn.Resource(appsCRGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return nil, schema.GroupVersionResource{}, err
}
if readErr != nil {
return nil, schema.GroupVersionResource{}, readErr
}
return nil, schema.GroupVersionResource{}, apierrors.NewNotFound(appsCRGVR.GroupResource(), name)
return obj, appsCRGVR, nil
}
// listAppCRs lists every App CR in ns across both kinds, App first, de-duplicated
// by name (a name served by both kinds during the collapse yields the App copy).
// listAppCRs lists every App CR in ns.
func listAppCRs(s *cloud.Service[state], ctx context.Context, ns string) ([]unstructured.Unstructured, error) {
seen := map[string]bool{}
var out []unstructured.Unstructured
for _, gvr := range appCRGVRs() {
list, err := s.State.dyn.Resource(gvr).Namespace(ns).List(ctx, metav1.ListOptions{})
if err != nil {
if apierrors.IsNotFound(err) {
continue
}
return nil, err
}
for i := range list.Items {
n := list.Items[i].GetName()
if seen[n] {
continue
}
seen[n] = true
out = append(out, list.Items[i])
list, err := s.State.dyn.Resource(appsCRGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
if err != nil {
if apierrors.IsNotFound(err) {
return nil, nil
}
return nil, err
}
return out, nil
return list.Items, nil
}
// k8sErr maps a raw API error to an honest gateway error, naming the missing RBAC
+14 -29
View File
@@ -20,7 +20,6 @@ func fakeSvc(objs ...runtime.Object) *cloud.Service[state] {
scheme := runtime.NewScheme()
dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{
appsCRGVR: "AppList",
servicesCRGVR: "ServiceCRList",
deploymentsGVR: "DeploymentList",
replicaSetsGVR: "ReplicaSetList",
podsGVR: "PodList",
@@ -128,20 +127,18 @@ func TestSyncStatus(t *testing.T) {
// ── ref parsing ─────────────────────────────────────────────────────────────
func TestParseRef(t *testing.T) {
// forward App kind and transition Service kind both resolve.
// The App CR resolves; the core/v1 Service (a child object) resolves distinctly.
if _, gvr, err := parseRef("hanzo.ai:App:hanzo:iam"); err != nil || gvr != appsCRGVR {
t.Errorf("App ref → (%v, %v), want appsCRGVR", gvr, err)
}
if _, gvr, err := parseRef("hanzo.ai:Service:hanzo:iam"); err != nil || gvr != servicesCRGVR {
t.Errorf("Service ref → (%v, %v), want servicesCRGVR", gvr, err)
}
if _, gvr, err := parseRef("apps:Deployment:hanzo:iam"); err != nil || gvr != deploymentsGVR {
t.Errorf("Deployment ref → (%v, %v), want deploymentsGVR", gvr, err)
}
if _, _, err := parseRef(":Service:hanzo:iam"); err != nil {
t.Errorf("core Service ref err = %v, want nil", err)
}
bad := []string{"", "a:b:c", "unknown/Kind:hanzo:iam:x", "apps:Deployment:evil-ns:iam", "apps:Deployment:hanzo:Bad_Name"}
// hanzo.ai:Service is not a kind this plane reads — the operator CR is App.
bad := []string{"", "a:b:c", "hanzo.ai:Service:hanzo:iam", "unknown/Kind:hanzo:iam:x", "apps:Deployment:evil-ns:iam", "apps:Deployment:hanzo:Bad_Name"}
for _, r := range bad {
if _, _, err := parseRef(r); err == nil {
t.Errorf("parseRef(%q) = nil err, want rejection", r)
@@ -215,49 +212,37 @@ func TestObserveApplication(t *testing.T) {
}
}
// ── CR resolution shim (App forward, Service fallback) ───────────────────────
// ── CR resolution ────────────────────────────────────────────────────────────
func TestGetAppCRAppFirst(t *testing.T) {
s := fakeSvc(
appCR("App", "hanzo", "iam", "u-app", "ghcr.io/hanzoai/iam", "v2.0.0", "Running", 1, 1),
appCR("Service", "hanzo", "iam", "u-svc", "ghcr.io/hanzoai/iam", "v1.0.0", "Running", 1, 1),
)
func TestGetAppCR(t *testing.T) {
s := fakeSvc(appCR("App", "hanzo", "iam", "u-app", "ghcr.io/hanzoai/iam", "v2.0.0", "Running", 1, 1))
obj, gvr, err := getAppCR(s, context.Background(), "hanzo", "iam")
if err != nil {
t.Fatalf("getAppCR: %v", err)
}
if gvr != appsCRGVR {
t.Fatalf("gvr = %v, want appsCRGVR (App must win)", gvr)
t.Fatalf("gvr = %v, want appsCRGVR", gvr)
}
if tag, _, _ := unstructured.NestedString(obj.Object, "spec", "image", "tag"); tag != "v2.0.0" {
t.Fatalf("resolved tag = %q, want v2.0.0 (App copy)", tag)
t.Fatalf("resolved tag = %q, want v2.0.0", tag)
}
}
func TestGetAppCRServiceFallback(t *testing.T) {
s := fakeSvc(appCR("Service", "hanzo", "iam", "u-svc", "ghcr.io/hanzoai/iam", "v1.0.0", "Running", 1, 1))
_, gvr, err := getAppCR(s, context.Background(), "hanzo", "iam")
if err != nil || gvr != servicesCRGVR {
t.Fatalf("getAppCR fallback → (%v, %v), want servicesCRGVR", gvr, err)
}
// Missing everywhere → IsNotFound.
// Missing → IsNotFound.
if _, _, err := getAppCR(s, context.Background(), "hanzo", "ghost"); err == nil {
t.Fatal("getAppCR(ghost) = nil err, want NotFound")
}
}
func TestListAppCRsMergeDedup(t *testing.T) {
func TestListAppCRs(t *testing.T) {
s := fakeSvc(
appCR("App", "hanzo", "iam", "u1", "r", "v2.0.0", "Running", 1, 1), // App copy of iam
appCR("Service", "hanzo", "iam", "u2", "r", "v1.0.0", "Running", 1, 1), // stale Service copy — deduped out
appCR("Service", "hanzo", "cloud", "u3", "r", "v1.799.0", "Running", 1, 1),
appCR("App", "hanzo", "iam", "u1", "r", "v2.0.0", "Running", 1, 1),
appCR("App", "hanzo", "cloud", "u3", "r", "v1.799.0", "Running", 1, 1),
)
crs, err := listAppCRs(s, context.Background(), "hanzo")
if err != nil {
t.Fatalf("listAppCRs: %v", err)
}
if len(crs) != 2 {
t.Fatalf("listAppCRs len = %d, want 2 (iam deduped, cloud)", len(crs))
t.Fatalf("listAppCRs len = %d, want 2 (iam, cloud)", len(crs))
}
byName := map[string]string{}
for i := range crs {
@@ -265,7 +250,7 @@ func TestListAppCRsMergeDedup(t *testing.T) {
byName[crs[i].GetName()] = tag
}
if byName["iam"] != "v2.0.0" {
t.Errorf("iam tag = %q, want v2.0.0 (App wins the dedupe)", byName["iam"])
t.Errorf("iam tag = %q, want v2.0.0", byName["iam"])
}
if byName["cloud"] != "v1.799.0" {
t.Errorf("cloud tag = %q", byName["cloud"])
+282
View File
@@ -0,0 +1,282 @@
// engine.go embeds the argo gitops-engine (github.com/hanzoai/deploy/
// gitops-engine, the fork's independently-importable submodule) in-process, so
// the cloud binary reconciles git → cluster the way the retired argocd
// application-controller did — three-way merge (server-side apply), scoped
// prune, drift-correction, health, sync status — with NO separate argocd
// process and NO redis. It is the write/reconcile half of /v1/deploy; the
// existing routes are the read/visualize half.
//
// The apply-set is scoped by a tracking LABEL (deploy.hanzo.ai/instance): the
// isManaged predicate that drives pruning returns true ONLY for live objects
// carrying THIS instance's label, so a prune can never delete an App CR (or any
// object) this plane did not create. This is the prune-safety boundary for the
// 60+ live App CRs — the exact property universe-crs enforced with prune:false,
// kept here and made explicit.
//
// Enablement is opt-in and fail-safe (DEPLOY_ENGINE_ENABLED, default off): the
// first deploy of this binary is inert for the reconcile path, so it ships
// dark and is turned on deliberately after the shadow proof — mirroring the
// operator's gate discipline and the argocd shadow-then-flip cutover.
package deploy
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/go-logr/logr"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/client-go/rest"
"github.com/hanzoai/deploy/gitops-engine/pkg/cache"
"github.com/hanzoai/deploy/gitops-engine/pkg/engine"
enginehealth "github.com/hanzoai/deploy/gitops-engine/pkg/health"
enginesync "github.com/hanzoai/deploy/gitops-engine/pkg/sync"
synccommon "github.com/hanzoai/deploy/gitops-engine/pkg/sync/common"
"github.com/hanzoai/deploy/gitops-engine/pkg/utils/kube"
)
// engineTrackingLabel scopes an apply-set. Every object the engine declares
// carries deploy.hanzo.ai/instance=<instance>; pruning only ever considers live
// objects with THIS value.
const engineTrackingLabel = "deploy.hanzo.ai/instance"
// engineFieldManager is the server-side-apply manager for git-sourced applies —
// distinct from the operator's own `hanzo-operator` manager, so a delivery apply
// is attributable and never silently fights a per-Kind reconcile.
const engineFieldManager = "hanzo-deploy"
// engineResInfo is cached per live resource; `tracked` records whether the
// object carries THIS instance's tracking label (the prune predicate).
type engineResInfo struct{ tracked bool }
// reconciler embeds the argo gitops-engine over one apply-set (one git source,
// one tracking-label instance) across a fixed set of namespaces.
type reconciler struct {
instance string
namespaces []string
log logr.Logger
cache cache.ClusterCache
engine engine.GitOpsEngine
}
// newReconciler builds an engine-backed reconciler. `namespaces` is the platform
// tier the engine watches (empty = all); `instance` tags this apply-set.
func newReconciler(cfg *rest.Config, namespaces []string, instance string, log logr.Logger) *reconciler {
cc := cache.NewClusterCache(cfg,
cache.SetNamespaces(namespaces),
cache.SetLogr(log),
cache.SetPopulateResourceInfoHandler(func(un *unstructured.Unstructured, _ bool) (any, bool) {
v := un.GetLabels()[engineTrackingLabel]
return &engineResInfo{tracked: v == instance}, v != ""
}),
)
return &reconciler{
instance: instance,
namespaces: namespaces,
log: log,
cache: cc,
engine: engine.NewEngine(cfg, cc, engine.WithLogr(log)),
}
}
// run starts the cluster informer cache; the returned StopFunc tears it down.
func (r *reconciler) run() (engine.StopFunc, error) { return r.engine.Run() }
// stamp puts the tracking label on every desired object so an applied object is
// cached as managed by THIS instance.
func (r *reconciler) stamp(objs []*unstructured.Unstructured) {
for _, o := range objs {
l := o.GetLabels()
if l == nil {
l = map[string]string{}
}
l[engineTrackingLabel] = r.instance
o.SetLabels(l)
}
}
// PruneFuse bounds how much a single reconcile may delete — the circuit breaker
// against a silent empty/partial render sweeping the fleet. Both limits are
// checked; either one trips the fuse. Zero disables that check.
type PruneFuse struct {
MaxDeletions int // absolute cap on objects pruned in one reconcile
MaxRatio float64 // cap as a fraction of the managed set (0..1)
}
// isProtectedKind is the data-anchor exclusion: PersistentVolumeClaim (delete =
// irreversible data loss) and KMSSecret (kms.hanzo.ai) are NEVER prune
// candidates, even when absent from the desired set. They are still applied
// (target side); they are only removed from the prune decision.
func isProtectedKind(group, kind string) bool {
if group == "" && kind == "PersistentVolumeClaim" {
return true
}
if kind == "KMSSecret" {
return true
}
return false
}
// managed is the prune predicate: an object is a prune candidate only if it
// carries THIS instance's tracking label AND is not a protected data anchor.
func (r *reconciler) managed(res *cache.Resource) bool {
ri, ok := res.Info.(*engineResInfo)
if !ok || !ri.tracked {
return false
}
k := res.ResourceKey()
return !isProtectedKind(k.Group, k.Kind)
}
// reconcile syncs `target` → cluster at `revision`. With prune, a live object
// carrying THIS instance's tracking label but absent from `target` is deleted —
// UNLESS it is a protected data anchor (PVC/KMSSecret) or the PruneFuse trips.
// An UNTRACKED object is never touched.
//
// Prune safety (RED HIGH-1), all enforced here:
// - refuse an EMPTY desired set (a silent target=[] would sweep everything);
// - pre-flight DRY-RUN sizes the prune set before any deletion;
// - the PruneFuse caps the prune set by count and by ratio;
// - protected data anchors (PVC/KMSSecret) are excluded from prune entirely.
func (r *reconciler) reconcile(ctx context.Context, target []*unstructured.Unstructured, revision, defaultNS string, prune bool, fuse PruneFuse) ([]synccommon.ResourceSyncResult, error) {
// (i) Never reconcile nothing — an empty render must not delete the fleet.
if len(target) == 0 {
return nil, fmt.Errorf("prune fuse: refusing to reconcile an empty desired set (render produced 0 objects)")
}
r.stamp(target)
if prune {
// (ii)/(iii) Size the prune set with a dry-run BEFORE any deletion, then
// apply the fuse. A partial render that would prune most of the fleet is
// refused here, before a single object is removed.
dry, err := r.engine.Sync(ctx, target, r.managed, revision, defaultNS,
enginesync.WithOperationSettings(true /*dryRun*/, true /*prune*/, false, false),
enginesync.WithLogr(r.log))
if err != nil {
return nil, fmt.Errorf("prune fuse dry-run: %w", err)
}
pruneN, managedN := 0, 0
for _, rr := range dry {
managedN++
if rr.Status == synccommon.ResultCodePruned {
pruneN++
}
}
if fuse.MaxDeletions > 0 && pruneN > fuse.MaxDeletions {
return nil, fmt.Errorf("prune fuse tripped: reconcile would prune %d object(s) (> max %d); refusing — fix the git source or raise DEPLOY_ENGINE_PRUNE_MAX", pruneN, fuse.MaxDeletions)
}
if fuse.MaxRatio > 0 && managedN > 0 && float64(pruneN)/float64(managedN) > fuse.MaxRatio {
return nil, fmt.Errorf("prune fuse tripped: reconcile would prune %d/%d managed (> %.0f%%); refusing", pruneN, managedN, fuse.MaxRatio*100)
}
}
return r.engine.Sync(ctx, target, r.managed, revision, defaultNS,
enginesync.WithPrune(prune),
enginesync.WithPruneConfirmed(prune), // prune only after the fuse above confirms it
enginesync.WithServerSideApply(true),
enginesync.WithServerSideApplyManager(engineFieldManager),
enginesync.WithLogr(r.log),
)
}
// resourceHealth assesses one live object with the engine's built-in per-GVK
// checks — the SAME health library the ArgoCD dashboard uses.
func engineResourceHealth(un *unstructured.Unstructured) (*enginehealth.HealthStatus, error) {
return enginehealth.GetResourceHealth(un, nil)
}
// gitSource is the desired-state source: a shallow clone of repo@ref, from which
// `path` is rendered into the manifest set. It shells the `git` CLI — the same
// mechanism clients/git and the operator use — so there is ONE git strategy and
// no vendored transport. The revision is the cloned HEAD sha.
type gitSource struct {
repo string // https URL or local path
ref string // branch/tag/sha
path string // repo-relative dir of CR manifests
}
// render shallow-clones and parses the source into (objects, revision). The
// clone dir is removed before return.
func (g gitSource) render(ctx context.Context) ([]*unstructured.Unstructured, string, error) {
dir, err := os.MkdirTemp("", "deploy-src-")
if err != nil {
return nil, "", fmt.Errorf("workdir: %w", err)
}
defer os.RemoveAll(dir)
clone := exec.CommandContext(ctx, "git", "clone", "--depth", "1", "--single-branch", "--branch", g.ref, g.repo, dir)
clone.Env = hardenedGitEnv()
if out, err := clone.CombinedOutput(); err != nil {
return nil, "", fmt.Errorf("git clone: %v: %s", err, strings.TrimSpace(string(out)))
}
rev := exec.CommandContext(ctx, "git", "-C", dir, "rev-parse", "--short", "HEAD")
rev.Env = hardenedGitEnv()
revBytes, err := rev.Output()
if err != nil {
return nil, "", fmt.Errorf("git rev-parse: %w", err)
}
revision := strings.TrimSpace(string(revBytes))
objs, err := parseManifestDir(filepath.Join(dir, g.path))
if err != nil {
return nil, "", err
}
return objs, revision, nil
}
// parseManifestDir walks dir RECURSIVELY and splits every *.yaml/*.yml/*.json
// into typed objects, skipping kustomization inputs. Recursive on purpose (RED
// HIGH-1 (v)): a non-recursive read silently drops manifests in subdirectories,
// which — combined with prune — would delete the objects those nested files
// declare. Walking every subdir means the desired set is complete, so prune
// never mistakes a nested-but-present object for a removed one.
func parseManifestDir(dir string) ([]*unstructured.Unstructured, error) {
var objs []*unstructured.Unstructured
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
name := d.Name()
if name == "kustomization.yaml" || name == "kustomization.yml" {
return nil
}
ext := strings.ToLower(filepath.Ext(name))
if ext != ".yaml" && ext != ".yml" && ext != ".json" {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
items, err := kube.SplitYAML(data)
if err != nil {
return fmt.Errorf("parse %s: %w", path, err)
}
objs = append(objs, items...)
return nil
})
if err != nil {
return nil, fmt.Errorf("walk manifest dir %s: %w", dir, err)
}
return objs, nil
}
// hardenedGitEnv is the minimal, credential-free git environment: no interactive
// prompt, no ambient user/system config, no inherited secrets.
func hardenedGitEnv() []string {
return []string{
"GIT_TERMINAL_PROMPT=0",
"GIT_CONFIG_GLOBAL=/dev/null",
"GIT_CONFIG_SYSTEM=/dev/null",
"HOME=" + os.TempDir(),
"PATH=" + os.Getenv("PATH"),
}
}
+160
View File
@@ -0,0 +1,160 @@
// engine_mount.go wires the embedded gitops-engine (engine.go) into the
// /v1/deploy surface: a SuperAdmin-gated, one-shot reconcile endpoint that
// renders the configured git source and syncs it → cluster. This is the write
// half of /v1/deploy that replaces the retired universe-crs Application — the
// operator still renders each App CR into workloads (the domain half).
//
// Fail-safe: the whole path is gated by DEPLOY_ENGINE_ENABLED (default off), so
// the first deploy of this binary is inert and the engine is turned on
// deliberately after the shadow proof.
package deploy
import (
"net/http"
"os"
"strconv"
"time"
"github.com/go-logr/logr"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
synccommon "github.com/hanzoai/deploy/gitops-engine/pkg/sync/common"
)
// Engine config — all optional; defaults target the live universe manifest repo
// (the exact source universe-crs syncs). Configure only what must vary.
func engineEnabled() bool { return os.Getenv("DEPLOY_ENGINE_ENABLED") == "true" }
func enginePrune() bool { return os.Getenv("DEPLOY_ENGINE_PRUNE") == "true" }
// pruneFuse bounds a single reconcile's deletions (RED HIGH-1). Conservative
// defaults: at most 10 objects OR 20% of the managed set, whichever is smaller,
// unless explicitly raised. A silent empty/partial render trips the fuse instead
// of sweeping the fleet.
func pruneFuse() PruneFuse {
return PruneFuse{
MaxDeletions: envInt("DEPLOY_ENGINE_PRUNE_MAX", 10),
MaxRatio: envFloat("DEPLOY_ENGINE_PRUNE_MAX_RATIO", 0.20),
}
}
func envInt(k string, d int) int {
if v := os.Getenv(k); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return d
}
func envFloat(k string, d float64) float64 {
if v := os.Getenv(k); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
return f
}
}
return d
}
func engineRepo() string { return envOr("DEPLOY_ENGINE_REPO", "https://github.com/hanzoai/universe") }
func engineRef() string { return envOr("DEPLOY_ENGINE_REF", "main") }
func enginePath() string { return envOr("DEPLOY_ENGINE_PATH", "infra/k8s/operator/crs") }
func engineInstance() string { return envOr("DEPLOY_ENGINE_INSTANCE", "universe") }
func engineDefaultNS() string { return envOr("DEPLOY_ENGINE_NAMESPACE", "hanzo") }
func envOr(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
// registerEngineRoutes adds the engine (write) routes alongside the existing
// read/visualize routes. Called from routes() in deploy.go.
func registerEngineRoutes(app *zip.App, s *cloud.Service[state]) {
app.Post("/v1/deploy/reconcile", guard(s, cloud.Handle(s, engineReconcile)))
}
// engineReconcile is POST /v1/deploy/reconcile — a SuperAdmin-gated, one-shot
// engine sync: render the configured git source and reconcile it → cluster via
// the embedded gitops-engine (three-way server-side apply, scoped prune,
// per-resource health). The write half that replaces universe-crs.
func engineReconcile(s *cloud.Service[state], c *zip.Ctx) error {
if !engineEnabled() {
return zip.Errorf(http.StatusServiceUnavailable, "deploy engine disabled (set DEPLOY_ENGINE_ENABLED=true)")
}
cfg, err := engineRestConfig()
if err != nil {
return zip.Errorf(http.StatusServiceUnavailable, "engine: kube config: %v", err)
}
ctx := c.Context()
rec := newReconciler(cfg, []string{engineDefaultNS()}, engineInstance(), logr.Discard())
stop, err := rec.run()
if err != nil {
return zip.Errorf(http.StatusBadGateway, "engine start: %v", err)
}
defer stop()
// Let the informer cache warm before the first sync so live state is known.
time.Sleep(2 * time.Second)
objs, revision, err := gitSource{repo: engineRepo(), ref: engineRef(), path: enginePath()}.render(ctx)
if err != nil {
return zip.Errorf(http.StatusBadGateway, "engine: render git source: %v", err)
}
results, err := rec.reconcile(ctx, objs, revision, engineDefaultNS(), enginePrune(), pruneFuse())
if err != nil {
return zip.Errorf(http.StatusBadGateway, "engine: sync: %v", err)
}
synced, pruned, failed := 0, 0, 0
items := make([]map[string]any, 0, len(results))
for _, rr := range results {
switch rr.Status {
case synccommon.ResultCodeSynced:
synced++
case synccommon.ResultCodePruned:
pruned++
case synccommon.ResultCodeSyncFailed:
failed++
}
items = append(items, map[string]any{
"resource": rr.ResourceKey.String(),
"status": string(rr.Status),
"message": rr.Message,
})
}
s.Log.Info("deploy engine reconcile", "revision", revision, "objects", len(objs),
"synced", synced, "pruned", pruned, "failed", failed, "prune", enginePrune())
return c.JSON(http.StatusOK, map[string]any{
"revision": revision,
"source": map[string]any{"repo": engineRepo(), "ref": engineRef(), "path": enginePath()},
"instance": engineInstance(),
"prune": enginePrune(),
"declared": len(objs),
"synced": synced,
"pruned": pruned,
"failed": failed,
"results": items,
})
}
// engineRestConfig builds a rest.Config from the in-cluster service account,
// falling back to KUBECONFIG for local/dev — the SAME construction as
// newClients() (deploy.go), so the engine talks to the same cluster.
func engineRestConfig() (*rest.Config, error) {
cfg, err := rest.InClusterConfig()
if err != nil {
cc := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
clientcmd.NewDefaultClientConfigLoadingRules(), &clientcmd.ConfigOverrides{})
cfg, err = cc.ClientConfig()
if err != nil {
return nil, err
}
}
cfg.UserAgent = userAgent
return cfg, nil
}
+492
View File
@@ -0,0 +1,492 @@
// login.go — the sign-in round trip for the deploy plane at cd.hanzo.ai.
//
// THE PROBLEM. Every /v1/deploy route is SuperAdmin-gated on c.IsAdmin(), which
// SanitizeIdentity mints ONLY from a validated IAM principal whose org IS the
// reserved admin org. The dashboard SPA is served at cd.hanzo.ai/ and calls this
// plane same-origin — but the IAM session cookie is minted host-only on hanzo.id,
// so a session established at hanzo.id or admin.hanzo.ai is never presented to
// cd.hanzo.ai. With no sign-in of its own the whole surface 403s and there is no
// way in. This file IS the way in.
//
// GET /v1/deploy/login — start: redirect into IAM's authorize endpoint
// GET /v1/deploy/callback — finish: exchange the code, mint the session cookie
// GET /v1/deploy/logout — clear the session cookie
//
// WHAT IT MINTS — NOT A SECOND SESSION MECHANISM. The callback stores the IAM
// access-token JWT in the `__Host-hanzo_iam_token` cookie: the FIRST name in
// cloud's cookieTokenNames, which SanitizeIdentity already reads, independently
// verifies (signature/issuer/audience/expiry against the IAM JWKS) and turns into
// the same principal a Bearer would. So this adds exactly one thing — a way to PUT
// the token in the browser for this host. The gate, the validation, and the
// SuperAdmin predicate are untouched; a forged cookie is still just an invalid JWT,
// and a forged X-User-IsAdmin header is still stripped on ingress.
//
// MINT ONLY WHAT THIS DEPLOYMENT WILL ACCEPT. The callback runs the exchanged token
// through cloud's OWN validator (cloud.NewTokenValidator — the same JWKS, issuer set
// and audience allowlist the boundary uses) BEFORE writing the cookie, and makes the
// admin-org decision on those VERIFIED claims. This is not defence in depth against
// IAM; it is the thing that makes a misconfiguration fail FAST and LOUD. The audience
// allowlist is env-overridable (jwtAudiencesFromEnv REPLACES the baked default), so a
// deployment whose CLOUD_JWT_AUDIENCES / GATEWAY_ALLOWED_AUDIENCES omits this
// console's client_id would otherwise mint a cookie the boundary refuses on the very
// next request — 403 → document-bounce to sign-in → IAM session still live → instant
// code → mint → 403, looping until the browser gives up. Validating here turns that
// infinite loop into one clear error naming the real reason.
//
// PUBLIC CLIENT, PKCE. The deploy plane holds no client secret: it drives IAM's
// authorization-code flow with PKCE S256 (RFC 7636), which IAM accepts with an
// empty client_secret when the code carries a challenge. A secret is still sent
// when one is configured, for a deployment that registers a confidential client.
//
// CSRF. The `state` is a fresh 256-bit nonce echoed into a short-lived, HttpOnly,
// Secure, SameSite=Lax cookie alongside the PKCE verifier and the return path. The
// callback accepts a code ONLY when the returned state equals the cookie's nonce
// (constant time), so a login-CSRF — an attacker completing THEIR authorization in
// the victim's browser — is refused. The cookie is the only store, so the flow
// survives any replica handling the callback.
package deploy
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/hanzoai/cloud"
fiber "github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip"
)
const (
// loginPath / callbackPath / logoutPath are the three routes of the round trip.
// /v1/deploy/<resource>, like the rest of the plane — never an /api/ prefix.
loginPath = dashPrefix + "/login"
callbackPath = dashPrefix + "/callback"
logoutPath = dashPrefix + "/logout"
// sessionCookie is cloud's EXISTING session cookie name — cookieTokenNames[0]
// in middleware_identity.go. Writing it here is what makes SanitizeIdentity
// resolve a principal on the next request. Do not invent a second name.
//
// The __Host- prefix is a browser-enforced invariant, not decoration: a cookie
// so named may only be set Secure, Path=/ and with NO Domain, so a sibling
// *.hanzo.ai host cannot set a Domain=.hanzo.ai cookie of the same name to
// shadow this console's session.
sessionCookie = "__Host-hanzo_iam_token"
// flowCookie carries the one in-flight OAuth round trip (state nonce, PKCE
// verifier, return path). It is deleted the moment the callback reads it, and
// carries the same __Host- guarantee — a shadowed flow cookie would be a way
// to feed this console someone else's state nonce.
flowCookie = "__Host-hanzo_deploy_oauth"
// flowTTL bounds how long an unfinished sign-in stays resumable.
flowTTL = 10 * time.Minute
// sessionMaxTTL caps the session cookie regardless of what the token claims.
// An `exp` far in the future must not become a decade-long cookie; the token is
// re-validated on every request either way, so a shorter cookie costs only a
// re-sign-in.
sessionMaxTTL = 24 * time.Hour
// defaultClientID is the IAM application whose ORGANIZATION is the admin org,
// so a sign-in through it resolves users in `admin` — the only org whose
// members are SuperAdmins. hanzo-cloud is deliberately NOT used: it is owned by
// admin but its organization is `hanzo`, so it looks admin-org users up in the
// wrong org and never finds them.
defaultClientID = "admin-console"
)
// oauth is the sign-in configuration: where IAM is, who we are to it, and which
// org grants SuperAdmin. Resolved once at build time.
type oauth struct {
issuer string // IAM origin, e.g. https://hanzo.id
clientID string // IAM application client_id (organization == adminOrg)
clientSecret string // optional; empty ⟹ public client on PKCE alone
adminOrg string // the reserved org whose members are SuperAdmins
publicURL string // REQUIRED public origin of this console; "" disables sign-in
http *http.Client
// verify is cloud's own token validator (NewTokenValidator(issuer).Validate).
// It is a seam so a test can drive the round trip without a live JWKS; in the
// binary there is exactly one implementation, and a nil verify fails closed.
verify func(raw string) (cloud.VerifiedIdentity, error)
}
// newOAuth resolves the sign-in configuration from deps + env. The issuer comes
// from the SAME value the identity boundary validates tokens against
// (deps.IAMIssuer), and the verifier is built from that issuer, so a token this
// flow accepts is by construction a token cloud accepts.
func newOAuth(deps cloud.Deps) oauth {
issuer := strings.TrimRight(firstNonEmpty(deps.IAMIssuer, os.Getenv("IAM_ENDPOINT"), "https://hanzo.id"), "/")
return oauth{
issuer: issuer,
clientID: firstNonEmpty(os.Getenv("DEPLOY_IAM_CLIENT_ID"), defaultClientID),
clientSecret: os.Getenv("DEPLOY_IAM_CLIENT_SECRET"),
adminOrg: firstNonEmpty(os.Getenv("IAM_ADMIN_ORG"), "admin"),
publicURL: strings.TrimRight(firstNonEmpty(os.Getenv("DEPLOY_PUBLIC_URL"), os.Getenv("PUBLIC_ORIGIN")), "/"),
http: &http.Client{Timeout: 15 * time.Second},
verify: cloud.NewTokenValidator(issuer).Validate,
}
}
// oauthBase is the canonical IAM OAuth base: ${issuer}/v1/iam. IAM mounts
// authorize/token/userinfo under /v1/iam — never at the root, never under /api/.
func (o oauth) oauthBase() string { return o.issuer + "/v1/iam" }
// redirectURI is the OAuth redirect_uri, built from CONFIGURATION ONLY. It must be
// the byte-identical string in login (authorize) and callback (token exchange) —
// IAM compares them — and it must match a URI registered on the application.
//
// It is deliberately NOT derived from the request. Host and X-Forwarded-Proto are
// caller-controlled: behind the gateway Host is the internal cluster host (which
// IAM's allowlist rejects outright), and off-gateway a caller can set either freely.
// Deriving an OAuth redirect from attacker-controlled input is only ever saved by
// the registry's exact-match check — a second lock covering for a broken first one.
// So the public origin is required, and with none configured sign-in fails CLOSED
// with an error naming the knob, rather than guessing an origin from a header.
func (o oauth) redirectURI() (string, error) {
if o.publicURL == "" {
return "", fmt.Errorf("sign-in is not configured: set DEPLOY_PUBLIC_URL (or PUBLIC_ORIGIN) " +
"to this console's public origin, e.g. https://cd.hanzo.ai")
}
return o.publicURL + callbackPath, nil
}
// ── the round trip ───────────────────────────────────────────────────────────
// flow is the one in-flight sign-in, carried in flowCookie for the duration of the
// external hop. Nonce is echoed as `state`; Verifier is the PKCE secret that is
// never sent to the browser's address bar; Return is the already-validated
// same-host path to land on.
type flow struct {
Nonce string `json:"n"`
Verifier string `json:"v"`
Return string `json:"r"`
}
// login starts the round trip: mint a nonce + PKCE verifier, remember them in the
// flow cookie, and send the browser to IAM's authorize endpoint.
func login(s *cloud.Service[state], c *zip.Ctx) error {
redirect, err := s.State.oauth.redirectURI()
if err != nil {
s.Log.Error("deploy sign-in unavailable", "err", err)
return zip.Errorf(http.StatusServiceUnavailable, "%v", err)
}
nonce, err := randomToken()
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "login: %v", err)
}
verifier, err := randomToken()
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "login: %v", err)
}
f := flow{Nonce: nonce, Verifier: verifier, Return: safeReturn(c.Query("returnTo"))}
blob, err := json.Marshal(f)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "login: %v", err)
}
setCookie(c, flowCookie, base64.RawURLEncoding.EncodeToString(blob), int(flowTTL.Seconds()))
o := s.State.oauth
q := url.Values{
"client_id": {o.clientID},
"redirect_uri": {redirect},
"response_type": {"code"},
"scope": {"openid profile email"},
"state": {nonce},
"code_challenge": {pkceChallenge(verifier)},
"code_challenge_method": {"S256"},
}
return c.Redirect(http.StatusFound, o.oauthBase()+"/oauth/authorize?"+q.Encode())
}
// callback finishes the round trip: verify the state against the flow cookie,
// exchange the code (PKCE), REFUSE a principal that is not in the admin org, then
// write the session cookie and land on the return path.
//
// FAIL CLOSED, TWICE. The admin-org check here is not the authorization decision —
// SanitizeIdentity re-derives it from the verified JWT on every subsequent request,
// and guard() gates on that. It is here so a non-SuperAdmin is told plainly that
// they lack the role instead of being handed a session that silently 403s
// everything, and so no cookie is ever minted for a principal the plane will refuse.
func callback(s *cloud.Service[state], c *zip.Ctx) error {
redirect, cfgErr := s.State.oauth.redirectURI()
raw := c.Fiber().Cookies(flowCookie)
clearCookie(c, flowCookie) // single use: consumed whether or not it validates
if cfgErr != nil {
s.Log.Error("deploy sign-in unavailable", "err", cfgErr)
return zip.Errorf(http.StatusServiceUnavailable, "%v", cfgErr)
}
f, err := decodeFlow(raw)
if err != nil {
return zip.ErrBadRequest("no sign-in is in progress; start at " + loginPath)
}
// CSRF: the code is only accepted for the round trip THIS browser started.
if subtle.ConstantTimeCompare([]byte(f.Nonce), []byte(c.Query("state"))) != 1 {
return zip.ErrBadRequest("state mismatch; start again at " + loginPath)
}
if e := c.Query("error"); e != "" {
s.Log.Warn("deploy sign-in refused by IAM", "error", e)
return zip.ErrUnauthorized("sign-in was not completed")
}
code := c.Query("code")
if code == "" {
return zip.ErrBadRequest("missing authorization code")
}
access, err := s.State.oauth.exchange(c.Context(), code, redirect, f.Verifier)
if err != nil {
s.Log.Error("deploy sign-in code exchange failed", "err", err)
return zip.ErrUnauthorized("sign-in could not be completed")
}
// Verify the token the way THIS deployment's identity boundary will, before
// handing it to a browser. A token that fails here would be refused on the very
// next request, so minting a cookie for it would produce a sign-in loop rather
// than a session — fail now, with the real reason.
if s.State.oauth.verify == nil {
s.Log.Error("deploy sign-in has no token validator configured")
return zip.Errorf(http.StatusServiceUnavailable, "sign-in is not configured: no identity validator")
}
id, err := s.State.oauth.verify(access)
if err != nil {
s.Log.Error("deploy sign-in token failed validation", "err", err,
"issuer", s.State.oauth.issuer, "client", s.State.oauth.clientID)
return zip.ErrUnauthorized("the sign-in token was refused by this deployment's identity boundary (" +
err.Error() + "); check that " + s.State.oauth.clientID +
" is in the JWT audience allowlist (CLOUD_JWT_AUDIENCES / GATEWAY_ALLOWED_AUDIENCES) " +
"and that the issuer matches")
}
// SuperAdmin ⟺ the VERIFIED owner claim IS the reserved admin org. Not the
// `isAdmin` bit, which only says "admin of my own org" — conflating the two
// would be a privilege escalation.
if id.Owner != s.State.oauth.adminOrg {
s.Log.Warn("deploy sign-in refused: not a SuperAdmin", "user", id.User, "org", id.Owner)
return zip.ErrForbidden("SuperAdmin required: this console is limited to members of the " +
s.State.oauth.adminOrg + " organization")
}
maxAge := sessionMaxAge(id.Expiry)
if maxAge <= 0 {
return zip.ErrUnauthorized("the sign-in token has already expired")
}
setCookie(c, sessionCookie, access, maxAge)
s.Log.Info("deploy sign-in", "user", id.User, "org", id.Owner, "ttl", maxAge)
return c.Redirect(http.StatusFound, f.Return)
}
// logout clears the session cookie for this host. IAM's own session is untouched —
// this ends the console session only.
//
// It is a POST because it CHANGES STATE. As a GET it was reachable by a cross-site
// top-level navigation (an <img> or a link on any page), which a SameSite=Lax cookie
// still rides, so any site could sign a SuperAdmin out at will. A nuisance rather
// than a compromise, but a state-changing GET is a bug regardless; POST is not
// carried cross-site by a Lax cookie, so the class is closed.
func logout(s *cloud.Service[state], c *zip.Ctx) error {
clearCookie(c, sessionCookie)
return c.JSON(http.StatusOK, map[string]any{"loggedIn": false, "loginUrl": loginPath})
}
// exchange redeems the authorization code at IAM's token endpoint with the PKCE
// verifier. The client secret is sent only when one is configured (IAM accepts an
// empty secret for a code that carries a challenge).
func (o oauth) exchange(ctx context.Context, code, redirect, verifier string) (string, error) {
form := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {redirect},
"client_id": {o.clientID},
"code_verifier": {verifier},
}
if o.clientSecret != "" {
form.Set("client_secret", o.clientSecret)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, o.oauthBase()+"/oauth/token", strings.NewReader(form.Encode()))
if err != nil {
return "", fmt.Errorf("token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := o.http.Do(req)
if err != nil {
return "", fmt.Errorf("token endpoint: %w", err)
}
defer resp.Body.Close()
// Bound the read: a token response is small, and this body is attacker-
// influenced only insofar as IAM is reachable — cap it regardless.
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", fmt.Errorf("token response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("token endpoint status %d", resp.StatusCode)
}
var out struct {
AccessToken string `json:"access_token"`
Error string `json:"error"`
}
if err := json.Unmarshal(body, &out); err != nil {
return "", fmt.Errorf("token response: %w", err)
}
if out.Error != "" {
return "", fmt.Errorf("token endpoint: %s", out.Error)
}
if out.AccessToken == "" {
return "", fmt.Errorf("token endpoint returned no access_token")
}
return out.AccessToken, nil
}
// ── pure helpers (unit-tested) ───────────────────────────────────────────────
// safeReturn constrains the post-sign-in landing spot to a path on THIS host: it
// must be absolute-rooted, must not be protocol-relative ("//evil" or "/\evil",
// which browsers resolve as another origin), and must carry no scheme or host.
// Anything else collapses to "/". This is the open-redirect guard.
func safeReturn(raw string) string {
if raw == "" || raw[0] != '/' {
return "/"
}
if len(raw) > 1 && (raw[1] == '/' || raw[1] == '\\') {
return "/"
}
u, err := url.Parse(raw)
if err != nil || u.Scheme != "" || u.Host != "" {
return "/"
}
return u.RequestURI()
}
// wantsDocument reports whether a refused request is a BROWSER NAVIGATION, which
// should be bounced to the sign-in page rather than handed a 403 the user cannot
// act on. Everything else — every XHR, every API client, every request that does
// not positively identify as a document GET — keeps the 403, so the API contract
// is unchanged.
//
// It is deliberately conservative and ordered by trustworthiness: Sec-Fetch-Dest /
// Sec-Fetch-Mode are set by the browser and cannot be forged from page JS, so when
// present they DECIDE. Only when both are absent does it fall back to Accept.
// A non-GET is never redirected: bouncing a POST would silently drop a mutation.
func wantsDocument(method, dest, mode, accept, requestedWith string) bool {
if method != http.MethodGet {
return false
}
if dest != "" {
return dest == "document"
}
if mode != "" {
return mode == "navigate"
}
if strings.EqualFold(requestedWith, "XMLHttpRequest") {
return false
}
if strings.Contains(accept, "application/json") {
return false
}
return strings.Contains(accept, "text/html")
}
// pkceChallenge is the RFC 7636 S256 challenge: base64url(sha256(verifier)),
// unpadded — byte-identical to IAM's own pkceChallenge.
func pkceChallenge(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
// randomToken mints a 256-bit URL-safe secret (the state nonce and the PKCE
// verifier). A failure of the system CSPRNG fails the sign-in — never a weaker one.
func randomToken() (string, error) {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("entropy unavailable: %w", err)
}
return base64.RawURLEncoding.EncodeToString(b[:]), nil
}
// decodeFlow parses the flow cookie. A missing, malformed, or field-empty cookie is
// an error — the callback then has nothing to compare `state` against and refuses.
func decodeFlow(raw string) (flow, error) {
var f flow
if raw == "" {
return f, fmt.Errorf("no flow cookie")
}
blob, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil {
return f, fmt.Errorf("flow cookie: %w", err)
}
if err := json.Unmarshal(blob, &f); err != nil {
return f, fmt.Errorf("flow cookie: %w", err)
}
if f.Nonce == "" || f.Verifier == "" {
return f, fmt.Errorf("flow cookie is incomplete")
}
// Re-validate on the way out: the return path is re-checked against the same
// open-redirect rule that admitted it, so a tampered cookie cannot bounce the
// browser off-host.
f.Return = safeReturn(f.Return)
return f, nil
}
// sessionMaxAge is the session cookie's lifetime, derived from the token's VERIFIED
// expiry so the cookie dies with the credential it carries. It is bounded at both
// ends and never guesses:
//
// - already expired (or no expiry proven) → 0, and the caller refuses the sign-in.
// There is no "fall back to 8 hours" — a fallback for an expired token mints a
// cookie that cannot work, which is precisely the mint-then-refuse loop this
// whole path exists to prevent.
// - absurdly far future → clamped to sessionMaxTTL, so a mis-issued exp cannot
// become a decade-long cookie.
func sessionMaxAge(expiry time.Time) int {
if expiry.IsZero() {
return 0
}
remaining := time.Until(expiry)
if remaining <= 0 {
return 0
}
if remaining > sessionMaxTTL {
remaining = sessionMaxTTL
}
return int(remaining.Seconds())
}
// ── cookies ──────────────────────────────────────────────────────────────────
// setCookie writes a host-only, HttpOnly, Secure, SameSite=Lax cookie.
//
// HttpOnly: page JS never touches the session token. Secure: it never rides plain
// HTTP. Lax (not Strict): the sign-in lands here via a top-level redirect FROM
// hanzo.id, and Strict would withhold the cookie on that first cross-site
// navigation, so the user would arrive still signed out. Lax is the correct
// setting for an OAuth round trip and still withholds the cookie from cross-site
// subrequests. No Domain attribute: the cookie stays host-only to this console and
// is never broadcast to sibling *.hanzo.ai hosts.
func setCookie(c *zip.Ctx, name, value string, maxAge int) {
c.Fiber().Res().Cookie(&fiber.Cookie{
Name: name, Value: value, Path: "/",
HTTPOnly: true, Secure: true, SameSite: fiber.CookieSameSiteLaxMode,
MaxAge: maxAge,
})
}
func clearCookie(c *zip.Ctx, name string) { setCookie(c, name, "", -1) }
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
+697
View File
@@ -0,0 +1,697 @@
package deploy
import (
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
luxlog "github.com/luxfi/log"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
)
// ── open redirect ────────────────────────────────────────────────────────────
// TestSafeReturn is the open-redirect guard: a return path may only be a path on
// THIS host. Every off-host shape collapses to "/".
func TestSafeReturn(t *testing.T) {
cases := []struct{ in, want string }{
// legitimate same-host paths survive, query included.
{"/", "/"},
{"/applications", "/applications"},
{"/applications?name=cloud&view=tree", "/applications?name=cloud&view=tree"},
// empty / relative → default.
{"", "/"},
{"applications", "/"},
// protocol-relative: the browser resolves these as ANOTHER ORIGIN.
{"//evil.example", "/"},
{"//evil.example/path", "/"},
{`/\evil.example`, "/"},
{`/\/evil.example`, "/"},
// absolute URLs, any scheme.
{"https://evil.example/x", "/"},
{"http://evil.example", "/"},
{"javascript:alert(1)", "/"},
{"data:text/html,x", "/"},
// host-relative with userinfo tricks.
{"https://cd.hanzo.ai@evil.example", "/"},
}
for _, c := range cases {
if got := safeReturn(c.in); got != c.want {
t.Errorf("safeReturn(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// ── content negotiation: redirect vs 403 ─────────────────────────────────────
// TestWantsDocument pins the ONE rule that decides whether a refusal is a
// sign-in redirect or a 403. It must answer "no" for everything that is not
// positively a browser document GET — the API contract depends on it.
func TestWantsDocument(t *testing.T) {
cases := []struct {
name string
method, dest, mode, accept, requested string
want bool
}{
// Browser navigation — the only "yes" cases.
{"navigation (Sec-Fetch-Dest)", "GET", "document", "navigate", "text/html,*/*", "", true},
{"navigation (mode only)", "GET", "", "navigate", "text/html", "", true},
{"legacy browser, html accept", "GET", "", "", "text/html,application/xhtml+xml", "", true},
// Browser subresource/XHR — Sec-Fetch-* decides, and it says no.
{"fetch from page JS", "GET", "empty", "cors", "application/json", "", false},
{"fetch that lies via Accept", "GET", "empty", "cors", "text/html", "", false},
{"same-origin xhr", "GET", "empty", "same-origin", "*/*", "", false},
// Non-browser API clients.
{"curl (no headers)", "GET", "", "", "", "", false},
{"json client", "GET", "", "", "application/json", "", false},
{"wildcard accept", "GET", "", "", "*/*", "", false},
{"legacy xhr header", "GET", "", "", "text/html", "XMLHttpRequest", false},
{"html+json accept prefers api", "GET", "", "", "text/html,application/json", "", false},
// A mutation is NEVER redirected — bouncing a POST silently drops it.
{"POST navigation", "POST", "document", "navigate", "text/html", "", false},
{"PUT navigation", "PUT", "document", "navigate", "text/html", "", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := wantsDocument(c.method, c.dest, c.mode, c.accept, c.requested); got != c.want {
t.Errorf("wantsDocument(%q,%q,%q,%q,%q) = %v, want %v",
c.method, c.dest, c.mode, c.accept, c.requested, got, c.want)
}
})
}
}
// TestGuardRefusesNonAdmin drives the negotiation through the REAL router: a
// non-SuperAdmin never reaches a handler — an API call gets 403, a browser
// navigation gets bounced to sign-in. Neither is ever served the data.
func TestGuardRefusesNonAdmin(t *testing.T) {
app := zip.New(zip.Config{Logger: luxlog.New("test")})
routes(app, fakeSvc())
// API call (no Accept, the shape every API client and the existing e2e sends).
resp := do(t, app, httptest.NewRequest("GET", "/v1/deploy/applications", nil))
if resp.StatusCode != http.StatusForbidden {
t.Errorf("API call without admin = %d, want 403", resp.StatusCode)
}
// XHR that asks for HTML must STILL be 403 — Sec-Fetch-Dest decides.
req := httptest.NewRequest("GET", "/v1/deploy/applications", nil)
req.Header.Set("Accept", "text/html")
req.Header.Set("Sec-Fetch-Dest", "empty")
if resp := do(t, app, req); resp.StatusCode != http.StatusForbidden {
t.Errorf("XHR without admin = %d, want 403", resp.StatusCode)
}
// Browser navigation → 302 to the sign-in page, carrying where to come back to.
req = httptest.NewRequest("GET", "/v1/deploy/applications?env=main", nil)
req.Header.Set("Sec-Fetch-Dest", "document")
req.Header.Set("Accept", "text/html")
resp = do(t, app, req)
if resp.StatusCode != http.StatusFound {
t.Fatalf("navigation without admin = %d, want 302", resp.StatusCode)
}
loc, err := url.Parse(resp.Header.Get("Location"))
if err != nil {
t.Fatalf("Location: %v", err)
}
if loc.Path != loginPath {
t.Errorf("redirect path = %q, want %q", loc.Path, loginPath)
}
if got := loc.Query().Get("returnTo"); got != "/v1/deploy/applications?env=main" {
t.Errorf("returnTo = %q, want the originating path+query", got)
}
// A mutation is refused with 403, never redirected.
req = httptest.NewRequest("POST", "/v1/deploy/applications/cloud/sync", nil)
req.Header.Set("Sec-Fetch-Dest", "document")
req.Header.Set("Accept", "text/html")
if resp := do(t, app, req); resp.StatusCode != http.StatusForbidden {
t.Errorf("POST without admin = %d, want 403 (never a redirect)", resp.StatusCode)
}
}
// ── PKCE ─────────────────────────────────────────────────────────────────────
// TestPKCEChallenge pins the S256 transform against the RFC 7636 Appendix B
// vector, so it stays byte-identical to IAM's own pkceChallenge.
func TestPKCEChallenge(t *testing.T) {
const (
verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
)
if got := pkceChallenge(verifier); got != challenge {
t.Errorf("pkceChallenge = %q, want the RFC 7636 vector %q", got, challenge)
}
// The challenge is not the verifier (the whole point of S256).
if pkceChallenge("x") == "x" {
t.Error("challenge must never equal the verifier")
}
}
// TestRandomTokenIsUnique guards against a constant/predictable state nonce.
func TestRandomTokenIsUnique(t *testing.T) {
seen := map[string]bool{}
for i := 0; i < 100; i++ {
tok, err := randomToken()
if err != nil {
t.Fatalf("randomToken: %v", err)
}
if len(tok) < 43 {
t.Fatalf("randomToken = %q, want >= 256 bits of entropy", tok)
}
if seen[tok] {
t.Fatal("randomToken repeated a value")
}
seen[tok] = true
}
}
// ── login: the authorize hop ─────────────────────────────────────────────────
// TestLoginRedirectsToIAM asserts the authorize URL is well formed, carries PKCE,
// and that the state it publishes is the SAME nonce it stored in the flow cookie.
func TestLoginRedirectsToIAM(t *testing.T) {
app, _ := signinApp(t, "https://iam.test")
req := httptest.NewRequest("GET", "/v1/deploy/login?returnTo=/applications", nil)
req.Host = "cd.hanzo.ai"
req.Header.Set("X-Forwarded-Proto", "https")
resp := do(t, app, req)
if resp.StatusCode != http.StatusFound {
t.Fatalf("login = %d, want 302", resp.StatusCode)
}
loc, err := url.Parse(resp.Header.Get("Location"))
if err != nil {
t.Fatalf("Location: %v", err)
}
if want := "https://iam.test/v1/iam/oauth/authorize"; loc.Scheme+"://"+loc.Host+loc.Path != want {
t.Errorf("authorize endpoint = %q, want %q", loc.Scheme+"://"+loc.Host+loc.Path, want)
}
q := loc.Query()
if q.Get("client_id") != defaultClientID {
t.Errorf("client_id = %q, want %q (the app whose organization is the admin org)", q.Get("client_id"), defaultClientID)
}
if q.Get("response_type") != "code" {
t.Errorf("response_type = %q, want code", q.Get("response_type"))
}
if q.Get("redirect_uri") != "https://cd.hanzo.ai/v1/deploy/callback" {
t.Errorf("redirect_uri = %q", q.Get("redirect_uri"))
}
if q.Get("code_challenge_method") != "S256" {
t.Errorf("code_challenge_method = %q, want S256", q.Get("code_challenge_method"))
}
if q.Get("code_challenge") == "" {
t.Error("no code_challenge: the flow is not PKCE-protected")
}
// The flow cookie must exist, be HttpOnly, and its nonce must be the state.
f, cookie := flowFrom(t, resp)
if !cookie.HttpOnly || !cookie.Secure {
t.Errorf("flow cookie HttpOnly=%v Secure=%v, want both true", cookie.HttpOnly, cookie.Secure)
}
if f.Nonce != q.Get("state") {
t.Errorf("state %q != flow cookie nonce %q", q.Get("state"), f.Nonce)
}
if pkceChallenge(f.Verifier) != q.Get("code_challenge") {
t.Error("published code_challenge does not derive from the stored verifier")
}
if f.Return != "/applications" {
t.Errorf("stored return = %q, want /applications", f.Return)
}
// The verifier must never be published to the address bar.
if strings.Contains(resp.Header.Get("Location"), f.Verifier) {
t.Error("PKCE verifier leaked into the authorize URL")
}
}
// TestLoginRejectsOffHostReturnTo: an attacker-supplied returnTo cannot make the
// completed sign-in land off-host.
func TestLoginRejectsOffHostReturnTo(t *testing.T) {
app, _ := signinApp(t, "https://iam.test")
req := httptest.NewRequest("GET", "/v1/deploy/login?returnTo=https://evil.example/steal", nil)
req.Host = "cd.hanzo.ai"
resp := do(t, app, req)
f, _ := flowFrom(t, resp)
if f.Return != "/" {
t.Errorf("stored return = %q, want / (off-host returnTo must be dropped)", f.Return)
}
}
// ── callback: the exchange ───────────────────────────────────────────────────
// TestCallbackRequiresMatchingState is the login-CSRF guard: without the flow
// cookie this browser started, no code is redeemed and no session is minted.
func TestCallbackRequiresMatchingState(t *testing.T) {
app, iam := signinApp(t, "")
// No flow cookie at all.
resp := do(t, app, httptest.NewRequest("GET", "/v1/deploy/callback?code=c&state=s", nil))
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("callback with no flow cookie = %d, want 400", resp.StatusCode)
}
assertNoSession(t, resp)
// Flow cookie present, but the returned state is someone else's.
req := httptest.NewRequest("GET", "/v1/deploy/callback?code=c&state=attacker-state", nil)
req.AddCookie(&http.Cookie{Name: flowCookie, Value: encodeFlow(flow{Nonce: "real-nonce", Verifier: "v", Return: "/"})})
resp = do(t, app, req)
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("callback with mismatched state = %d, want 400", resp.StatusCode)
}
assertNoSession(t, resp)
// A garbage cookie is not a flow.
req = httptest.NewRequest("GET", "/v1/deploy/callback?code=c&state=x", nil)
req.AddCookie(&http.Cookie{Name: flowCookie, Value: "!!!not-base64!!!"})
if resp := do(t, app, req); resp.StatusCode != http.StatusBadRequest {
t.Errorf("callback with corrupt flow cookie = %d, want 400", resp.StatusCode)
}
if iam.calls != 0 {
t.Errorf("IAM token endpoint was called %d times for refused callbacks, want 0", iam.calls)
}
}
// TestCallbackRefusesNonAdminOrg: a VALID sign-in by a user outside the admin org
// mints NO session. The console refuses the role plainly rather than handing out a
// cookie that 403s everything.
func TestCallbackRefusesNonAdminOrg(t *testing.T) {
app, iam := signinApp(t, "")
iam.token = fakeJWT("hanzo", "someone", time.Hour) // a real user, wrong org
iam.verified = cloud.VerifiedIdentity{Owner: "hanzo", User: "someone", Expiry: time.Now().Add(time.Hour)}
resp := completeSignin(t, app)
if resp.StatusCode != http.StatusForbidden {
t.Errorf("callback for a non-admin-org principal = %d, want 403", resp.StatusCode)
}
assertNoSession(t, resp)
}
// TestCallbackRefusesUnverifiableToken is the MED-1 loop-breaker: a token this
// deployment's identity boundary will NOT accept (audience allowlist that omits the
// console's client_id, wrong issuer, expired) must fail HERE, once, with the real
// reason — never become a cookie that is refused on the next request, bounced back
// to sign-in, and re-minted forever.
func TestCallbackRefusesUnverifiableToken(t *testing.T) {
app, iam := signinApp(t, "")
// The exact shape of the misconfiguration: aud=admin-console, allowlist without it.
iam.verifyErr = errors.New(`claims: square/go-jose/jwt: validation failed, invalid audience claim (aud)`)
resp := completeSignin(t, app)
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("callback with an unverifiable token = %d, want 401", resp.StatusCode)
}
assertNoSession(t, resp)
// The error must NAME the knob, or an operator has nothing to act on.
body, _ := io.ReadAll(resp.Body)
for _, want := range []string{"audience", defaultClientID} {
if !strings.Contains(string(body), want) {
t.Errorf("error body does not mention %q: %s", want, body)
}
}
}
// TestCallbackRefusesAlreadyExpiredToken: an expired credential must not become an
// 8-hour cookie. There is no fallback lifetime — a cookie that cannot work is the
// loop, not a mitigation.
func TestCallbackRefusesAlreadyExpiredToken(t *testing.T) {
app, iam := signinApp(t, "")
iam.verified = cloud.VerifiedIdentity{Owner: "admin", User: "cto", Expiry: time.Now().Add(-time.Minute)}
resp := completeSignin(t, app)
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("callback with an expired token = %d, want 401", resp.StatusCode)
}
assertNoSession(t, resp)
}
// TestSessionMaxAge pins the clamp at both ends (RED LOW-2).
func TestSessionMaxAge(t *testing.T) {
cases := []struct {
name string
expiry time.Time
want func(int) bool
desc string
}{
{"no expiry proven", time.Time{}, func(n int) bool { return n == 0 }, "0"},
{"already expired", time.Now().Add(-time.Hour), func(n int) bool { return n == 0 }, "0"},
{"expired by a second", time.Now().Add(-time.Second), func(n int) bool { return n == 0 }, "0"},
{"normal hour", time.Now().Add(time.Hour), func(n int) bool { return n > 3500 && n <= 3600 }, "~3600"},
{"absurd future", time.Now().Add(292 * 365 * 24 * time.Hour), func(n int) bool { return n == int(sessionMaxTTL.Seconds()) }, "clamped to sessionMaxTTL"},
{"just over the cap", time.Now().Add(sessionMaxTTL + time.Hour), func(n int) bool { return n == int(sessionMaxTTL.Seconds()) }, "clamped to sessionMaxTTL"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := sessionMaxAge(c.expiry); !c.want(got) {
t.Errorf("sessionMaxAge = %d, want %s", got, c.desc)
}
})
}
}
// TestSignInFailsClosedWithoutPublicOrigin (RED MED-3): with no configured public
// origin the OAuth hop refuses rather than deriving a redirect_uri from the
// caller-controlled Host / X-Forwarded-Proto headers.
func TestSignInFailsClosedWithoutPublicOrigin(t *testing.T) {
svc := fakeSvc()
svc.State.oauth = oauth{
issuer: "https://iam.test", clientID: defaultClientID, adminOrg: "admin",
http: &http.Client{Timeout: time.Second},
verify: func(string) (cloud.VerifiedIdentity, error) { return cloud.VerifiedIdentity{}, nil },
} // publicURL deliberately empty
app := zip.New(zip.Config{Logger: luxlog.New("test")})
routes(app, svc)
// A forged Host must NOT be adopted as the origin.
req := httptest.NewRequest("GET", "/v1/deploy/login", nil)
req.Host = "evil.example"
req.Header.Set("X-Forwarded-Proto", "http")
resp := do(t, app, req)
if resp.StatusCode != http.StatusServiceUnavailable {
t.Errorf("login with no configured origin = %d, want 503", resp.StatusCode)
}
if loc := resp.Header.Get("Location"); strings.Contains(loc, "evil.example") {
t.Errorf("redirect adopted the forged Host: %q", loc)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "DEPLOY_PUBLIC_URL") {
t.Errorf("error must name the knob to set, got: %s", body)
}
// The callback refuses for the same reason, and mints nothing.
cb := httptest.NewRequest("GET", "/v1/deploy/callback?code=c&state=s", nil)
cb.Host = "evil.example"
resp = do(t, app, cb)
if resp.StatusCode != http.StatusServiceUnavailable {
t.Errorf("callback with no configured origin = %d, want 503", resp.StatusCode)
}
assertNoSession(t, resp)
}
// TestLogoutClearsSession: POST clears the cookie; the route is not a GET.
func TestLogoutClearsSession(t *testing.T) {
app, _ := signinApp(t, "https://iam.test")
resp := do(t, app, httptest.NewRequest("POST", "/v1/deploy/logout", nil))
if resp.StatusCode != http.StatusOK {
t.Fatalf("logout = %d, want 200", resp.StatusCode)
}
var cleared bool
for _, ck := range resp.Cookies() {
if ck.Name == sessionCookie && ck.Value == "" && ck.MaxAge < 0 {
cleared = true
}
}
if !cleared {
t.Error("logout did not clear the session cookie")
}
// A cross-site top-level navigation must not be able to sign anyone out.
if resp := do(t, app, httptest.NewRequest("GET", "/v1/deploy/logout", nil)); resp.StatusCode == http.StatusOK {
t.Error("GET /v1/deploy/logout succeeded; logout must be POST-only")
}
}
// TestCookiesCarryHostPrefix (RED LOW-3): both cookies use the __Host- prefix, which
// the browser will only honour for Secure, Path=/, Domain-less cookies — so a
// sibling *.hanzo.ai host cannot shadow them with a Domain=.hanzo.ai cookie.
func TestCookiesCarryHostPrefix(t *testing.T) {
if !strings.HasPrefix(sessionCookie, "__Host-") {
t.Errorf("session cookie %q lacks the __Host- prefix", sessionCookie)
}
if !strings.HasPrefix(flowCookie, "__Host-") {
t.Errorf("flow cookie %q lacks the __Host- prefix", flowCookie)
}
// The session name must be one cloud's identity boundary actually reads.
if sessionCookie != "__Host-hanzo_iam_token" {
t.Errorf("session cookie %q is not in cloud's cookieTokenNames", sessionCookie)
}
app, _ := signinApp(t, "")
resp := completeSignin(t, app)
for _, ck := range resp.Cookies() {
if !strings.HasPrefix(ck.Name, "__Host-") {
continue
}
// The __Host- contract, enforced here so a future edit cannot silently
// break it (a browser would then reject the cookie outright).
if !ck.Secure || ck.Path != "/" || ck.Domain != "" {
t.Errorf("%s violates the __Host- contract: Secure=%v Path=%q Domain=%q",
ck.Name, ck.Secure, ck.Path, ck.Domain)
}
}
}
// TestCallbackMintsSessionForSuperAdmin is the happy path: the session cookie is
// the token, under cloud's EXISTING cookie name, hardened, and the browser lands
// on the remembered path.
func TestCallbackMintsSessionForSuperAdmin(t *testing.T) {
app, iam := signinApp(t, "")
iam.token = fakeJWT("admin", "cto", 2*time.Hour)
resp := completeSignin(t, app)
if resp.StatusCode != http.StatusFound {
t.Fatalf("callback = %d, want 302", resp.StatusCode)
}
if got := resp.Header.Get("Location"); got != "/applications" {
t.Errorf("landed on %q, want the remembered /applications", got)
}
var sess *http.Cookie
for _, ck := range resp.Cookies() {
if ck.Name == sessionCookie {
sess = ck
}
if ck.Name == flowCookie && ck.MaxAge >= 0 {
t.Error("flow cookie must be cleared once consumed")
}
}
if sess == nil {
t.Fatalf("no %s cookie minted", sessionCookie)
}
if sess.Value != iam.token {
t.Error("session cookie must carry the IAM access token verbatim (cloud re-validates it)")
}
if !sess.HttpOnly {
t.Error("session cookie must be HttpOnly (page JS must never read the token)")
}
if !sess.Secure {
t.Error("session cookie must be Secure")
}
if sess.SameSite != http.SameSiteLaxMode {
t.Errorf("session cookie SameSite = %v, want Lax (Strict breaks the OAuth return hop)", sess.SameSite)
}
if sess.Domain != "" {
t.Errorf("session cookie Domain = %q, want host-only (never shared with sibling hosts)", sess.Domain)
}
if sess.MaxAge <= 0 || sess.MaxAge > int((2*time.Hour).Seconds()) {
t.Errorf("session MaxAge = %d, want bounded by the token's own expiry", sess.MaxAge)
}
// The exchange used PKCE and the code, with no secret configured.
if iam.form.Get("code_verifier") == "" {
t.Error("token exchange sent no code_verifier")
}
if iam.form.Get("grant_type") != "authorization_code" {
t.Errorf("grant_type = %q", iam.form.Get("grant_type"))
}
if iam.form.Get("redirect_uri") != "https://cd.hanzo.ai/v1/deploy/callback" {
t.Errorf("exchange redirect_uri = %q, must match the authorize one byte for byte", iam.form.Get("redirect_uri"))
}
}
// TestCallbackFailsClosedOnExchangeError: IAM refusing the code mints no session.
func TestCallbackFailsClosedOnExchangeError(t *testing.T) {
app, iam := signinApp(t, "")
iam.status = http.StatusBadRequest
resp := completeSignin(t, app)
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("callback with a rejected code = %d, want 401", resp.StatusCode)
}
assertNoSession(t, resp)
}
// ── flow cookie ──────────────────────────────────────────────────────────────
func TestDecodeFlow(t *testing.T) {
// Round trip.
f, err := decodeFlow(encodeFlow(flow{Nonce: "n", Verifier: "v", Return: "/x"}))
if err != nil || f.Nonce != "n" || f.Verifier != "v" || f.Return != "/x" {
t.Fatalf("round trip = (%+v, %v)", f, err)
}
// A tampered return path is re-checked, not trusted.
f, err = decodeFlow(encodeFlow(flow{Nonce: "n", Verifier: "v", Return: "https://evil.example"}))
if err != nil {
t.Fatalf("decodeFlow: %v", err)
}
if f.Return != "/" {
t.Errorf("tampered return = %q, want / (re-validated on read)", f.Return)
}
// Incomplete / malformed cookies are not flows.
for _, bad := range []string{"", "%%%", encodeFlow(flow{Nonce: "n"}), encodeFlow(flow{Verifier: "v"})} {
if _, err := decodeFlow(bad); err == nil {
t.Errorf("decodeFlow(%q) = nil error, want rejection", bad)
}
}
}
// TestVerifiedClaimsDecide: the admin-org decision is made on what the VALIDATOR
// proved, never on what the token says about itself. A token whose unverified body
// claims owner=admin is refused when validation reports a different owner — the
// unverified decode is gone, and this pins that it stays gone.
func TestVerifiedClaimsDecide(t *testing.T) {
app, iam := signinApp(t, "")
iam.token = fakeJWT("admin", "cto", time.Hour) // body SAYS admin
iam.verified = cloud.VerifiedIdentity{Owner: "hanzo", User: "someone", Expiry: time.Now().Add(time.Hour)}
resp := completeSignin(t, app)
if resp.StatusCode != http.StatusForbidden {
t.Errorf("callback = %d, want 403: the verified owner must win over the token body", resp.StatusCode)
}
assertNoSession(t, resp)
}
// ── harness ──────────────────────────────────────────────────────────────────
// fakeIAM is a stand-in for IAM's token endpoint, recording what the exchange sent.
// verified is what cloud's validator will PROVE about the token it hands back;
// verifyErr makes validation fail, which is how a real deployment behaves when its
// audience allowlist or issuer does not admit the token.
type fakeIAM struct {
token string
status int
calls int
form url.Values
verified cloud.VerifiedIdentity
verifyErr error
}
// signinApp builds the real router over a state whose IAM is a local fake (or the
// literal issuer when one is given, for the no-network authorize assertions).
//
// The token verifier is the ONE seam a test replaces: cloud's real validator needs
// a live JWKS and an RS256-signed token, which would test go-jose rather than this
// flow. The contract it stands in for — verified claims decide, a validation
// failure refuses — is exercised in both directions below.
func signinApp(t *testing.T, issuer string) (*zip.App, *fakeIAM) {
t.Helper()
iam := &fakeIAM{
token: fakeJWT("admin", "cto", time.Hour),
status: http.StatusOK,
verified: cloud.VerifiedIdentity{Owner: "admin", User: "cto", Expiry: time.Now().Add(time.Hour)},
}
if issuer == "" {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/iam/oauth/token" {
http.NotFound(w, r)
return
}
iam.calls++
_ = r.ParseForm()
iam.form = r.PostForm
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(iam.status)
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": iam.token})
}))
t.Cleanup(srv.Close)
issuer = srv.URL
}
svc := fakeSvc()
svc.State.oauth = oauth{
issuer: issuer, clientID: defaultClientID, adminOrg: "admin",
publicURL: "https://cd.hanzo.ai", http: &http.Client{Timeout: 5 * time.Second},
verify: func(raw string) (cloud.VerifiedIdentity, error) {
if iam.verifyErr != nil {
return cloud.VerifiedIdentity{}, iam.verifyErr
}
return iam.verified, nil
},
}
app := zip.New(zip.Config{Logger: luxlog.New("test")})
routes(app, svc)
return app, iam
}
// completeSignin drives login → callback with the flow cookie the login handed
// back, i.e. exactly what a browser does.
func completeSignin(t *testing.T, app *zip.App) *http.Response {
t.Helper()
req := httptest.NewRequest("GET", "/v1/deploy/login?returnTo=/applications", nil)
req.Host = "cd.hanzo.ai"
start := do(t, app, req)
_, cookie := flowFrom(t, start)
loc, _ := url.Parse(start.Header.Get("Location"))
cb := httptest.NewRequest("GET", "/v1/deploy/callback?code=the-code&state="+url.QueryEscape(loc.Query().Get("state")), nil)
cb.Host = "cd.hanzo.ai"
cb.AddCookie(&http.Cookie{Name: flowCookie, Value: cookie.Value})
return do(t, app, cb)
}
func do(t *testing.T, app *zip.App, req *http.Request) *http.Response {
t.Helper()
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("%s %s: %v", req.Method, req.URL, err)
}
t.Cleanup(func() { _ = resp.Body.Close() })
return resp
}
func flowFrom(t *testing.T, resp *http.Response) (flow, *http.Cookie) {
t.Helper()
for _, ck := range resp.Cookies() {
if ck.Name == flowCookie && ck.Value != "" {
f, err := decodeFlow(ck.Value)
if err != nil {
t.Fatalf("flow cookie: %v", err)
}
return f, ck
}
}
t.Fatal("no flow cookie was set")
return flow{}, nil
}
func assertNoSession(t *testing.T, resp *http.Response) {
t.Helper()
for _, ck := range resp.Cookies() {
if ck.Name == sessionCookie && ck.Value != "" {
t.Fatalf("a session cookie was minted on a refused sign-in: %q", ck.Value)
}
}
}
func encodeFlow(f flow) string {
blob, _ := json.Marshal(f)
return base64.RawURLEncoding.EncodeToString(blob)
}
// fakeJWT builds a structurally valid, UNSIGNED JWT. That is exactly the point of
// the test: nothing in this package trusts the signature — cloud's identity
// boundary re-verifies the token on every later request, and this only exercises
// the claim read that decides whether a cookie is worth minting.
func fakeJWT(owner, name string, ttl time.Duration) string {
enc := func(v any) string {
b, _ := json.Marshal(v)
return base64.RawURLEncoding.EncodeToString(b)
}
return enc(map[string]any{"alg": "RS256", "typ": "JWT"}) + "." +
enc(map[string]any{"owner": owner, "name": name, "exp": time.Now().Add(ttl).Unix()}) + "." +
"not-a-real-signature"
}
+235
View File
@@ -0,0 +1,235 @@
// projection.go — the App-CR → ArgoCD `Application` READ PROJECTION.
//
// The CTO decision (have-both): serve the full ArgoCD React UI, but feed it a
// projection of our operator `App` CRs shaped as ArgoCD `Application`s. There is
// NO stored Application/AppProject CRD — each App CR IS projected on the fly, its
// resource tree + health synthesized from the SAME readers the native
// /v1/deploy routes use (listAppCRs/getAppCR/buildTree/resourceHealth), with no
// repo-server and no redis. App CRs stay the single source of truth; the
// Application shape exists only at this API layer.
//
// These `argo*` types are the MINIMAL ArgoCD v1alpha1 JSON the React app renders
// (list + detail + tree). Distinct from the native `Application` (applications.go)
// which backs the native /v1/deploy/applications surface — this backs the
// ArgoCD-UI-compatible /v1/deploy/api/v1/* surface.
package deploy
import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// ── ArgoCD v1alpha1 JSON (minimal, UI-render-complete) ───────────────────────
type argoListMeta struct {
ResourceVersion string `json:"resourceVersion"`
}
type argoMeta struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
UID string `json:"uid,omitempty"`
CreationTimestamp string `json:"creationTimestamp,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
type argoSource struct {
RepoURL string `json:"repoURL"`
Path string `json:"path"`
TargetRevision string `json:"targetRevision"`
}
type argoDestination struct {
Server string `json:"server"`
Namespace string `json:"namespace"`
}
type argoSpec struct {
Source argoSource `json:"source"`
Destination argoDestination `json:"destination"`
Project string `json:"project"`
}
type argoHealth struct {
Status string `json:"status"`
Message string `json:"message,omitempty"`
}
type argoSyncStatus struct {
Status string `json:"status"`
Revision string `json:"revision,omitempty"`
}
type argoResourceStatus struct {
Group string `json:"group,omitempty"`
Version string `json:"version,omitempty"`
Kind string `json:"kind"`
Namespace string `json:"namespace,omitempty"`
Name string `json:"name"`
Status string `json:"status,omitempty"`
Health *argoHealth `json:"health,omitempty"`
}
type argoSummary struct {
Images []string `json:"images,omitempty"`
}
type argoStatus struct {
Sync argoSyncStatus `json:"sync"`
Health argoHealth `json:"health"`
Resources []argoResourceStatus `json:"resources"`
Summary argoSummary `json:"summary"`
ReconciledAt string `json:"reconciledAt,omitempty"`
}
type argoApp struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Metadata argoMeta `json:"metadata"`
Spec argoSpec `json:"spec"`
Status argoStatus `json:"status"`
}
type argoAppList struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Metadata argoListMeta `json:"metadata"`
Items []argoApp `json:"items"`
}
// ── resource tree (ArgoCD ApplicationTree) ───────────────────────────────────
type argoResourceRef struct {
Group string `json:"group,omitempty"`
Version string `json:"version,omitempty"`
Kind string `json:"kind"`
Namespace string `json:"namespace,omitempty"`
Name string `json:"name"`
UID string `json:"uid,omitempty"`
}
type argoInfoItem struct {
Name string `json:"name"`
Value string `json:"value"`
}
type argoNode struct {
argoResourceRef
ParentRefs []argoResourceRef `json:"parentRefs,omitempty"`
Info []argoInfoItem `json:"info,omitempty"`
Health *argoHealth `json:"health,omitempty"`
ResourceVersion string `json:"resourceVersion,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
Images []string `json:"images,omitempty"`
}
type argoTree struct {
Nodes []argoNode `json:"nodes"`
OrphanedNodes []argoNode `json:"orphanedNodes"`
Hosts []any `json:"hosts"`
}
// ── projection ───────────────────────────────────────────────────────────────
// argoHealthFrom maps the native lowercase health vocab (resourceHealth) to the
// Capitalized ArgoCD vocab the UI renders.
func argoHealthFrom(native string) string {
switch native {
case HealthHealthy:
return "Healthy"
case HealthProgressing:
return "Progressing"
case HealthDegraded:
return "Degraded"
case HealthSuspended:
return "Suspended"
case HealthMissing:
return "Missing"
default:
return "Unknown"
}
}
// argoSyncFrom maps the native sync verdict to the Capitalized ArgoCD vocab.
func argoSyncFrom(native string) string {
switch native {
case SyncSynced:
return "Synced"
case SyncOutOfSync:
return "OutOfSync"
default:
return "Unknown"
}
}
// deployManifestRepo is the desired-state source the projection reports as the
// Application's git source — the manifest repo the engine syncs. Display-only.
const deployManifestRepo = "https://git.hanzo.ai/hanzoai/universe"
// projectApp maps ONE operator App CR (+ its running image tag) to an ArgoCD
// Application. Reuses observeApplication's native derivation, then reshapes to
// the v1alpha1 JSON — one source of truth (the App CR), two shapes.
func projectApp(cr *unstructured.Unstructured, ns, runningTag string) argoApp {
native := observeApplication(cr, ns, runningTag)
repository, _, _ := unstructured.NestedString(cr.Object, "spec", "image", "repository")
tag := native.Version
image := repository
if tag != "" {
image = repository + ":" + tag
}
return argoApp{
APIVersion: "argoproj.io/v1alpha1",
Kind: "Application",
Metadata: argoMeta{
Name: native.Name,
Namespace: ns,
UID: string(cr.GetUID()),
CreationTimestamp: cr.GetCreationTimestamp().Format("2006-01-02T15:04:05Z07:00"),
Labels: map[string]string{"argocd.argoproj.io/instance": native.Name, "hanzo.ai/env": native.Env},
},
Spec: argoSpec{
Source: argoSource{RepoURL: deployManifestRepo, Path: "infra/k8s/operator/crs", TargetRevision: "main"},
Destination: argoDestination{Server: "https://kubernetes.default.svc", Namespace: ns},
Project: "default",
},
Status: argoStatus{
Sync: argoSyncStatus{Status: argoSyncFrom(native.Sync), Revision: native.Version},
Health: argoHealth{Status: argoHealthFrom(native.Health), Message: native.HealthMessage},
Resources: []argoResourceStatus{},
Summary: argoSummary{Images: nonEmpty(image)},
},
}
}
// projectTree reshapes the native buildTree []Node into an ArgoCD ApplicationTree.
func projectTree(nodes []Node) argoTree {
out := argoTree{Nodes: make([]argoNode, 0, len(nodes)), OrphanedNodes: []argoNode{}, Hosts: []any{}}
for i := range nodes {
n := &nodes[i]
an := argoNode{
argoResourceRef: argoResourceRef{
Group: n.Group, Version: n.Version, Kind: n.Kind,
Namespace: n.Namespace, Name: n.Name, UID: n.UID,
},
ResourceVersion: "",
CreatedAt: n.CreatedAt,
Health: &argoHealth{Status: argoHealthFrom(n.Health), Message: n.HealthMessage},
}
for _, p := range n.ParentRefs {
an.ParentRefs = append(an.ParentRefs, argoResourceRef{
Group: p.Group, Version: p.Version, Kind: p.Kind, Namespace: p.Namespace, Name: p.Name,
})
}
if n.Version != "" {
an.Info = append(an.Info, argoInfoItem{Name: "Image Tag", Value: n.Version})
}
out.Nodes = append(out.Nodes, an)
}
return out
}
func nonEmpty(s string) []string {
if s == "" {
return nil
}
return []string{s}
}
+104
View File
@@ -0,0 +1,104 @@
package deploy
import (
"encoding/json"
"testing"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
func projFixture(name, ns, repo, tag, phase string, replicas, ready int64) *unstructured.Unstructured {
return &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "hanzo.ai/v1", "kind": "App",
"metadata": map[string]any{"name": name, "namespace": ns, "uid": "uid-" + name},
"spec": map[string]any{"role": "service", "image": map[string]any{"repository": repo, "tag": tag}},
"status": map[string]any{"phase": phase, "replicas": replicas, "readyReplicas": ready, "availableReplicas": ready},
}}
}
// TestProjectApp_ShapeIsUIRenderable asserts the projection produces the exact
// minimal ArgoCD Application JSON the React UI needs (per the api-server
// contract): the fields the list tiles + detail header destructure must all be
// present and correctly typed, or the SPA throws.
func TestProjectApp_ShapeIsUIRenderable(t *testing.T) {
app := projectApp(projFixture("cloud", "hanzo", "ghcr.io/hanzoai/cloud", "v1.2.3", "Running", 1, 1), "hanzo", "v1.2.3")
if app.APIVersion != "argoproj.io/v1alpha1" || app.Kind != "Application" {
t.Fatalf("wrong TypeMeta: %s/%s", app.APIVersion, app.Kind)
}
if app.Metadata.Name != "cloud" || app.Metadata.Namespace != "hanzo" {
t.Fatalf("wrong metadata: %+v", app.Metadata)
}
if app.Spec.Source.RepoURL == "" || app.Spec.Destination.Server == "" {
t.Fatal("spec.source.repoURL and spec.destination.server are required by the UI")
}
// Running + declared==running ⇒ Healthy + Synced (Capitalized ArgoCD vocab).
if app.Status.Health.Status != "Healthy" {
t.Fatalf("health = %q, want Healthy", app.Status.Health.Status)
}
if app.Status.Sync.Status != "Synced" {
t.Fatalf("sync = %q, want Synced", app.Status.Sync.Status)
}
// The UI's parseAppFields requires status.resources to be an array and
// status.summary to be an object — assert they marshal as such.
b, err := json.Marshal(app)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var m map[string]any
if err := json.Unmarshal(b, &m); err != nil {
t.Fatalf("unmarshal: %v", err)
}
status := m["status"].(map[string]any)
if _, ok := status["resources"].([]any); !ok {
t.Fatalf("status.resources must be a JSON array, got %T", status["resources"])
}
if _, ok := status["summary"].(map[string]any); !ok {
t.Fatalf("status.summary must be a JSON object, got %T", status["summary"])
}
if m["spec"].(map[string]any)["source"].(map[string]any)["repoURL"] == "" {
t.Fatal("spec.source.repoURL empty")
}
}
// TestProjectApp_HealthAndSyncVocab covers the native→ArgoCD vocab mapping.
func TestProjectApp_HealthAndSyncVocab(t *testing.T) {
// 1 desired / 0 ready ⇒ Degraded; declared != running ⇒ OutOfSync.
app := projectApp(projFixture("chat", "hanzo", "ghcr.io/hanzoai/chat", "v2", "Creating", 1, 0), "hanzo", "v1")
if app.Status.Health.Status != "Degraded" {
t.Fatalf("health = %q, want Degraded", app.Status.Health.Status)
}
if app.Status.Sync.Status != "OutOfSync" {
t.Fatalf("sync = %q, want OutOfSync", app.Status.Sync.Status)
}
}
// TestProjectTree_ShapeIsUIRenderable asserts the ApplicationTree projection.
func TestProjectTree_ShapeIsUIRenderable(t *testing.T) {
nodes := []Node{
{ResourceRef: ResourceRef{Group: "apps", Version: "v1", Kind: "Deployment", Namespace: "hanzo", Name: "cloud"}, UID: "d1", Health: HealthHealthy, Version: "v1.2.3"},
{ResourceRef: ResourceRef{Version: "v1", Kind: "Pod", Namespace: "hanzo", Name: "cloud-xyz"}, UID: "p1", Health: HealthHealthy, ParentRefs: []ResourceRef{{Group: "apps", Version: "v1", Kind: "ReplicaSet", Namespace: "hanzo", Name: "cloud-abc"}}},
}
tree := projectTree(nodes)
if len(tree.Nodes) != 2 {
t.Fatalf("nodes = %d, want 2", len(tree.Nodes))
}
b, _ := json.Marshal(tree)
var m map[string]any
_ = json.Unmarshal(b, &m)
if _, ok := m["nodes"].([]any); !ok {
t.Fatal("tree.nodes must be a JSON array")
}
if _, ok := m["orphanedNodes"]; !ok {
t.Fatal("tree.orphanedNodes must be present")
}
// Node health mapped to Capitalized vocab; image surfaced as an info item.
n0 := m["nodes"].([]any)[0].(map[string]any)
if n0["health"].(map[string]any)["status"] != "Healthy" {
t.Fatalf("node health = %v", n0["health"])
}
if n0["kind"] != "Deployment" || n0["name"] != "cloud" {
t.Fatalf("node ref wrong: %v", n0)
}
}

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