Compare commits

...
Author SHA1 Message Date
hanzo-dev 46c0d0e11f merge(main): bring the integration up to current main before shipping 2026-07-22 00:10:59 -07:00
hanzo-dev 86b0b79ecc fix(cli/code): size Codex context window to the served model, not a flat 262144
The codexLike provider hardcoded '-c model_context_window=262144' (+auto_compact
235929) for EVERY model, so enso / zen5 — which the gateway serves at 1M
(ai flagshipWindow pin, live) — were capped at 256K in the 'hanzo code' Codex
wrapper. That surfaced to the user as 'maximum context exceeded' at 262144 even
after switching to zen5-pro.

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

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

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

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

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

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

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

Usage:
  hanzo <command> [flags]

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

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

Meta:
  help         show this message
  version      print version and exit

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

Usage:
  hanzo <command> [flags]

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

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

Meta:
  help         show this message
  version      print version and exit

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

Usage:
  hanzo <command> [flags]

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

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

Meta:
  help         show this message
  version      print version and exit

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Carries three fixes uncovered while landing it:
- follow commerce's resolver consolidation (middleware/svcorg -> pkg/org)
- point the go-unit test list at clients/flags; the stale clients/featureflags
  path failed setup on a missing directory and had CI/CD red on main
- assert the post-#331 flags contract: runtime flags ignore env, boot-time
  ReadOnly rows still read it. That test asserted the override #331 removed
  and never ran because of the stale path above.
2026-07-19 02:47:02 -07:00
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 27a627bf35 feat(admin): thin audited credit-grant relay at POST /v1/admin/credit-grants
The one admin mint surface. SuperAdmin-only (core.Guard); forwards verbatim to
commerce's already-mint-gated POST /v1/billing/credit-grants (middleware.Mint →
PlatformOnly) via COMMERCE_SERVICE_TOKEN, scoped to the target org, and writes one
tamper-evident audit record. Commerce stays the sole credit-grant ledger — no
in-process mint. NOT deployed; for red review (mint surface).

Assisted-by: neo:claude-opus-4-8
2026-07-18 13:43:34 -07:00
hanzo-dev 4cdd2f2558 feat(channels): unified inbound channel ingest plane (envelope, pairing, policy, per-platform adapters)
Preserve divergent channel-ingest work: clients/channels package (envelope
normalization, pairing, delivery policy, Slack/Discord/Teams/Telegram
adapters, store), cmd/channels entrypoint, integration event-emit hooks,
and apps wiring.
2026-07-18 01:26:09 -07:00
hanzo-dev 4fe61262a9 coding: verify + PR + close the session when a routed run completes
A routed run's machine pushes with its own credential and streams into the
session, but cloud still owns the completion — the integrity gate, the PR row, and
the session's terminal state (the machine never closes the session, so it was
staying "running" forever). Give a routed run the SAME cloud-side completion the
local keystone path runs after a sandbox push.

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

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

Tests: routed changed+verify -> PR filed + session done; verify fails -> no PR +
session error; no changes -> done no PR; reported error -> error no PR (verify never
runs); NewDispatcher wires the seam; the durable type bridge preserves attribution.
2026-07-17 23:13:07 -07:00
z 7136fff969 feat(link): server-side failover router over linked accounts + per-account usage
Add the execution half of route.go's redundancy seam: a Router that routes a
signed-in caller's inference through one of their OWN linked provider accounts,
resolves that account's KMS-sealed credential, and cycles to the next in-org
account on a live 429 — never falling back to a platform key or crossing the
tenant boundary. Meters each served call per (org, provider, profile) and
exposes the per-account breakdown at /v1/billing/usage/accounts (+ the canonical
/v1/links/usage/accounts).

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

Tests prove routing through a linked account, in-org cycling on 429, the
cross-org isolation boundary (against the real store), fail-secure with no
platform-key fallback, and that a credential never reaches a log or error.
2026-07-17 19:19:33 -07:00
hanzo-dev 62068b2d1e feat(integrations): Cloudflare apikey connector (verify-before-store, KMS custody, org-admin gate) 2026-07-17 16:14:21 -07:00
z cfce8b10d0 probe: pin what zip typed ops can bind, before migrating 792 routes onto them
zip.Get[In, Out] is advertised as ONE op projected into three surfaces (REST ·
OpenAPI · MCP), and cloud's raw routes were slated to migrate onto it starting
with clients/agents. Measured first, against the route shapes cloud actually
has.

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

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

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

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

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

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

Try the sealed KMS value first (so completing sealed-store provisioning later
needs no code change), then fall back to env. Absent from both still returns
'' so the call fails fast — never silent free usage. Tests cover env-fallback,
sealed-precedence, and absent-everywhere.
2026-07-14 17:29:07 -07:00
hanzo-dev 02fab4bc40 analytics: accept anonymous capture, attributed to the brand-public org
Marketing sites emit anonymous pageviews (no session). captureTenant now falls
back — when there is no validated principal — to the PUBLIC brand org derived
SERVER-SIDE from the request Host via the white-label registry (BrandForHostOK),
never a client-claimed org. A forged X-Org-Id is still ignored, and an
unrecognized Host is refused (anonymous events are never dumped into a default
org). Gated by CLOUD_ANALYTICS_PUBLIC_CAPTURE (default on, matching the existing
public insights-capture posture). Verified live: an anonymous pageview to
Host hanzo.ai lands under tenant_id=hanzo.
2026-07-14 10:52:17 -07:00
hanzo-dev 4aff04accd analytics: add capture (write) plane — POST /v1/analytics + /v1/tracker → hanzo.events
The analytics subsystem served only read lenses over hanzo.events; nothing
wrote the table, so the web/commerce lenses were permanently honest-empty. This
adds the symmetric ingest: products POST batches to cloud (the ONE native front
door) and cloud writes org-scoped rows into the datastore warehouse the read
side already queries.

- POST /v1/analytics, /v1/analytics/batch, /v1/tracker (beacon alias) — all
  tenant-gated in-handler; tenant_id is always principal.Org, never client input.
- Writes ride ai/object.DatastoreExec (the SAME pooled client the reads use).
- The writer owns the hanzo.events DDL (EnsureEventsTable, idempotent/latched).
- Privacy scrub: credential/PII-shaped property keys dropped, email values
  redacted, before any row is built.
- Pure core (normalizeEvent/scrubProps/buildEventsInsert) unit-tested; HTTP
  contract tests cover no-principal 403, forged-org 403, oversized 400,
  datastore-down 503; a build-tagged live test proves the full round trip
  against a real datastore.
2026-07-13 16:09:17 -07:00
354 changed files with 49141 additions and 3734 deletions
+1
View File
@@ -34,4 +34,5 @@ Thumbs.db
.shots/
.claude/
.worktrees/
native/flags/target
+42
View File
@@ -44,6 +44,48 @@ Port roadmap (P1-P15) lives in HIP-0129; do not restate it here. Every claim
carries its tier: Shipped (on main, named package/route), In flight (named
pre-main branch), Planned (backlog id or named reservation).
## Build & module graph — standalone module, NOT a go.work member
`cloud` is a self-contained deploy unit: its own `go.mod`, `Dockerfile`, binary.
It is intentionally NOT listed in the parent `~/work/hanzo/go.work` workspace —
that workspace deliberately excludes the heavy modules, and merging cloud's
k8s/otel dependency tree with `o11y`'s reintroduces `koanf`/`ugorji`
monolith-vs-split import ambiguities (the parent workspace is itself red on the
koanf split; that is not cloud's bug to fix).
The catch: `go` auto-discovers that parent `go.work` whenever you run a bare
`go build ./...` / `go test ./...` from inside this tree, which puts the build in
workspace mode and SILENTLY DROPS cloud's own `go.mod` directives. Those
directives are load-bearing and each fixes exactly one graph hazard:
- `replace github.com/vulcand/oxy/v2 => github.com/traefik/oxy/v2 <pseudo>` — the
bare require is a placeholder (`v2.0.0-00010101000000-000000000000`); without
the replace it resolves to an invalid version.
- `exclude github.com/ugorji/go <old-monolith>` — drops the pre-split monolith so
`github.com/ugorji/go/codec` (pulled by gin) is unambiguous.
- the `k8s.io/*` staging replace block pins every staging module to the `v0.35.3`
line. `k8s.io/kubernetes` is a GRAPH-ONLY transitive require of
`hanzoai/deploy/gitops-engine` (clients/deploy uses its `pkg/utils/kube`); NO
cloud package imports `k8s.io/kubernetes`, so its staging tree never compiles —
do not "drop k8s.io/kubernetes", the pins keep the graph consistent and it is
never built. koanf resolves to the split modules; the `koanf v1.5.0` monolith
require is a harmless graph leaf, never imported.
So: build cloud in module mode, never workspace mode. `make build`/`test`/`vet`/
`tidy` force `GOWORK=off` (matches CI and the Dockerfile, which check out cloud
alone with no parent go.work). For a bare `go` command from this tree, prefix
`GOWORK=off`. `GOWORK=off go build ./...` and `go vet ./...` are green; `go mod
tidy` is stable. Do NOT commit a `go.work` here — it would flip the Dockerfile
(`COPY go.mod go.sum``go mod download``COPY . .`) into workspace mode after
its `-mod=readonly` download step.
Test modes: `make test` is pure-Go (`CGO_ENABLED=0`). Encrypted-at-rest OrgDB
tests (`cek`, `CLOUD_KMS_MASTER_KEY_REF` set) REQUIRE `CGO_ENABLED=1` +
libsqlcipher (`cek/cek.go` refuses to encrypt in pure-Go); those run only in the
Dockerfile's dedicated `-tags libsqlite3` CGO stage, and fail under `make test`
by design (clients/git, kms, flags, x402, cmd/kmsreseal, finance). Bundle-embed
tests (clients/tasks/ui) need `make deploy-ui` first (real bundle is gitignored).
## Framework doctrine
One way to do everything. Composable, orthogonal, DRY. A new subsystem is a
+12
View File
@@ -4,6 +4,18 @@
GO ?= go
BIN ?= cloud
PKG ?= ./cmd/cloud
# cloud is a STANDALONE Go module — a self-contained deploy unit (its own go.mod,
# Dockerfile, binary). It is intentionally NOT a member of the parent
# ~/work/hanzo/go.work workspace (that workspace deliberately excludes the heavy
# modules; adding cloud would merge its k8s/otel graph with o11y's and reintroduce
# koanf/ugorji import ambiguities). But `go` auto-discovers that parent go.work
# whenever a dev builds from inside this tree, which shadows cloud's own
# replace/exclude directives (oxy pin, ugorji monolith exclude, k8s staging pins)
# and breaks `go build ./...`. Force module mode so make targets build EXACTLY
# what CI/Docker build (fresh checkout, no parent go.work). Overridable via
# `make GOWORK=... <target>` for the rare cross-module case.
export GOWORK := off
DOCKER_IMAGE ?= ghcr.io/hanzoai/cloud
DOCKER_TAG ?= dev
LDFLAGS ?= -s -w
+29 -27
View File
@@ -34,7 +34,6 @@ package apps
import (
"context"
"fmt"
"os"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
@@ -66,6 +65,7 @@ import (
"github.com/hanzoai/cloud/clients/bots"
"github.com/hanzoai/cloud/clients/captable"
"github.com/hanzoai/cloud/clients/catalogsync"
"github.com/hanzoai/cloud/clients/channels"
"github.com/hanzoai/cloud/clients/cloudflare"
"github.com/hanzoai/cloud/clients/code"
"github.com/hanzoai/cloud/clients/company"
@@ -75,6 +75,7 @@ import (
"github.com/hanzoai/cloud/clients/deploy"
"github.com/hanzoai/cloud/clients/dns"
"github.com/hanzoai/cloud/clients/do"
"github.com/hanzoai/cloud/clients/domain"
"github.com/hanzoai/cloud/clients/entitlements"
"github.com/hanzoai/cloud/clients/eval"
"github.com/hanzoai/cloud/clients/exec"
@@ -86,12 +87,12 @@ import (
"github.com/hanzoai/cloud/clients/graph"
"github.com/hanzoai/cloud/clients/guide"
"github.com/hanzoai/cloud/clients/iam"
"github.com/hanzoai/cloud/clients/iam2"
"github.com/hanzoai/cloud/clients/ingress"
"github.com/hanzoai/cloud/clients/integrations"
"github.com/hanzoai/cloud/clients/kafka"
"github.com/hanzoai/cloud/clients/kms"
"github.com/hanzoai/cloud/clients/knowledge"
"github.com/hanzoai/cloud/clients/leaderboard"
"github.com/hanzoai/cloud/clients/link"
"github.com/hanzoai/cloud/clients/marketing"
"github.com/hanzoai/cloud/clients/marketplace"
@@ -109,6 +110,7 @@ import (
"github.com/hanzoai/cloud/clients/provisioning"
"github.com/hanzoai/cloud/clients/pubsub"
"github.com/hanzoai/cloud/clients/referrals"
"github.com/hanzoai/cloud/clients/rollingcap"
"github.com/hanzoai/cloud/clients/runtime"
"github.com/hanzoai/cloud/clients/sbom"
"github.com/hanzoai/cloud/clients/security"
@@ -124,6 +126,7 @@ import (
"github.com/hanzoai/cloud/clients/tracker"
"github.com/hanzoai/cloud/clients/treasury"
"github.com/hanzoai/cloud/clients/usage"
"github.com/hanzoai/cloud/clients/validators"
"github.com/hanzoai/cloud/clients/visor"
"github.com/hanzoai/cloud/clients/wallets"
"github.com/hanzoai/cloud/clients/websearch"
@@ -164,22 +167,6 @@ func init() {
})
}
// identitySpec selects the ONE identity backend that owns /v1/iam/* (+ /login/oauth/*)
// for this boot. CLOUD_IAM_IMPL=iam2 picks the clean-room iam2 (zip+orm, beego-free);
// anything else — including unset, the production default — keeps the legacy beego
// Casdoor embed, byte-for-byte today's behavior. The two impls register the SAME
// absolute prefixes and therefore cannot co-mount, so selection (this func) stays
// separate from activation (cfg.Enabled): exactly one spec occupies the identity slot
// in Wire, preserving mount order either way. os.Getenv (not the unexported
// cloud.getenv, which is unreachable from package apps) is the read — CLOUD_IAM_IMPL is
// the deliberate, off-by-default opt-in that keeps iam2 inert until a canary flips it.
func identitySpec() cloud.MountSpec {
if os.Getenv("CLOUD_IAM_IMPL") == "iam2" {
return cloud.MountSpec{Name: "iam2", Mount: iam2.Mount}
}
return cloud.MountSpec{Name: "iam", Mount: iam.Mount}
}
// Wire returns every linked subsystem as a cloud.MountSpec, in mount order. The
// slice position IS the order: cloud.MountAll iterates it as-given, registering each
// subsystem's teardown as a zip shutdown hook so teardown runs in reverse (LIFO).
@@ -211,15 +198,12 @@ func Wire() []cloud.MountSpec {
// /v1/commerce/topup/wallet). MUST mount before the IAM /v1/iam/* wildcard (50) so
// they win Fiber's first-match scan (framework-guaranteed since zip v1.3.0).
{Name: "account", Mount: account.MountAccount},
// Embedded IAM identity plane (/v1/iam/*, /.well-known/*, /login/oauth/*, /_/iam/*,
// /cas/*, /scim/*) — the identity authority, mounts before its dependents. STAGED:
// the operator adds "iam" to --enable only after IAM config + the fold are verified.
// Which IMPLEMENTATION owns these prefixes is selected by CLOUD_IAM_IMPL
// (identitySpec): the clean-room iam2 (zip+orm, beego-free) when =="iam2", else the
// legacy beego Casdoor embed — the default (unset = today's behavior, byte-for-byte).
// Both register the SAME absolute paths and cannot co-mount, so this is an either/or
// switch at this ONE slot, never a shadow prefix.
identitySpec(),
// Embedded IAM identity plane (/v1/iam/*, /login/oauth/*) — the identity authority,
// mounts before its dependents. The ONE implementation: the clean-room iam-v2
// (zip-native + hanzoai/orm, beego-free); the retired Casdoor iam-v1 embed is GONE.
// STAGED: the operator adds "iam" to --enable only after IAM config + the fold are
// verified (login/authorize/token/jwks + the operator SSO chain).
{Name: "iam", Mount: iam.Mount},
// Embedded Base app engine + viral waitlist (/v1/waitlist/*). STAGED behind
// CLOUD_BASE_EMBED. OwnsHealth: native /v1/base/health.
{Name: "base", Mount: base.Mount, Shutdown: base.Shutdown, OwnsHealth: true},
@@ -248,6 +232,11 @@ func Wire() []cloud.MountSpec {
// Provisioning control plane: /v1/sql,/v1/vector,/v1/datastore,/v1/kv,/v1/search,/v1/s3,/v1/docdb.
{Name: "provisioning", Mount: provisioning.Mount},
{Name: "billing", Mount: billing.Mount},
// Rolling AI-spend cap: installs the ai gate's per-tier trailing-window cap
// reader (registers no routes; its admin-editable knobs are platform switches
// surfaced in the /v1/admin/flags cockpit). After commerce/plan so the tier +
// finance globals it composes are wired.
{Name: "rollingcap", Mount: rollingcap.Mount},
// CATCH-ALL /v1/billing/* + /v1/commerce/* data bridges — AFTER clients/billing
// (121) + the commerce embed (100). Same clients/account package as "account" (48).
{Name: "account-bridge", Mount: account.MountBridge},
@@ -257,6 +246,8 @@ func Wire() []cloud.MountSpec {
// The /v1/dns forward head: relays the console DNS dashboard to the DNS
// control plane under the caller's own validated bearer (clients/dns).
{Name: "dns", Mount: dns.Mount},
// The registrar: search/price/register domains (name.com) per org.
{Name: "domain", Mount: domain.Mount},
{Name: "prompts", Mount: prompts.Mount},
{Name: "agents", Mount: agents.Mount, Shutdown: agents.Shutdown},
// The unified AI login manager registry (/v1/links). Mounts AFTER agents so
@@ -291,6 +282,11 @@ func Wire() []cloud.MountSpec {
{Name: "catalogsync", Mount: catalogsync.Mount, Shutdown: catalogsync.Shutdown},
{Name: "ml", Mount: ml.Mount, OwnsHealth: true},
{Name: "usage", Mount: usage.Mount},
// Gamified usage analytics: /v1/usage/leaderboard + /v1/usage/activity + the
// per-day contribution graph, over the datastore rollup (#43). Co-owns /v1/usage/*
// with usage (a distinct concern — who leads + your activity graph) at its own
// exact paths; owns the opt-in SQLite store. Before the ai /v1/* catch-all.
{Name: "leaderboard", Mount: leaderboard.Mount, Shutdown: leaderboard.Shutdown},
{Name: "crm", Mount: crm.Mount},
// Native /v1/marketing/* — the in-process fold of github.com/hanzoai/marketing
// (per-org campaign store on Base/SQLite), twin of crm. Owns a DB handle, so
@@ -300,6 +296,11 @@ func Wire() []cloud.MountSpec {
// twin of crm/marketing. Owns a DB handle, so its Shutdown closes it cleanly
// on SIGTERM (ctxShutdown adapts func() error).
{Name: "ads", Mount: ads.Mount, Shutdown: ctxShutdown(ads.Shutdown)},
// GDA/SDM validator onboarding /v1/validators/* — wallet-sig + ETH-mainnet
// GenesisNFT ownerOf → seal luxd staking identity into KMS → write a NEW-node
// LuxNetwork CR (node.lux.cloud, never the live luxd) → enqueue an owner-gated
// registration (never auto-submitted to any P-Chain). Owns a DB handle.
{Name: "validators", Mount: validators.Mount, Shutdown: ctxShutdown(validators.Shutdown)},
// Native /v1/social/* — the in-process fold of the live social stack
// (github.com/hanzoai/social: social-backend/frontend/orchestrator, a Postiz-style
// scheduler), a per-org accounts+posts store on Base/SQLite, twin of crm. Owns a DB
@@ -330,6 +331,7 @@ func Wire() []cloud.MountSpec {
{Name: "team", Mount: team.Mount, Shutdown: ctxShutdown(team.Shutdown)},
{Name: "settings", Mount: settings.Mount, Shutdown: settings.Shutdown},
{Name: "notify", Mount: notify.Mount, OwnsHealth: true},
{Name: "channels", Mount: channels.Mount, Shutdown: channels.Shutdown},
{Name: "gateway", Mount: gateway.Mount},
{Name: "entitlements", Mount: entitlements.Mount, Shutdown: entitlements.Shutdown},
{Name: "exec", Mount: exec.Mount},
+133
View File
@@ -24,9 +24,11 @@ import (
"time"
"github.com/hanzoai/cloud"
accountclient "github.com/hanzoai/cloud/clients/account"
"github.com/hanzoai/cloud/clients/commerceclient"
"github.com/hanzoai/cloud/clients/commerceinproc"
financeclient "github.com/hanzoai/cloud/clients/finance"
"github.com/hanzoai/cloud/clients/principal"
"github.com/hanzoai/commerce"
commercebilling "github.com/hanzoai/commerce/api/billing"
commercestore "github.com/hanzoai/commerce/api/store"
@@ -160,6 +162,121 @@ func mountCommerce(app *zip.App, deps cloud.Deps) error {
commercebilling.RunAutoRechargeAllOrgs,
)
// GET /v1/billing/plans — the public tier catalog the console renders. commerce's
// legacy api.Route() billing bundle (ListPlans, invoices, subscriptions, …) is NOT
// registered by the co-resident embed: setupRoutes wires only /v1/commerce/*, so
// /v1/billing/plans has NO handler in this binary. The account bridge's
// /v1/billing/* wildcard (order 122) then forwards the read BACK to commerce at
// COMMERCE_URL — which defaults to the public api.hanzo.ai edge — re-entering the
// same bridge in an unbounded self-dispatch loop that surfaces as a 502
// ("commerce unreachable: Get https://api.hanzo.ai/v1/billing/plans"). Registering
// the static ListPlans handler HERE (order 100, ahead of the bridge) shadows that
// wildcard and serves plans in-process — the same co-resident move billing.go makes
// for usage/balance. RequestContext supplies the namespaced context promo.Active reads.
app.Get("/v1/billing/plans", commercemid.RequestContext(), commercebilling.ListPlans)
// The rest of the console's billing READS, served co-resident for the SAME reason
// plans is: commerce's api.Route() billing bundle is never compiled here, so without
// these registrations every one of them falls through to the account bridge's
// /v1/billing/* wildcard, which forwards to COMMERCE_URL — the public api.hanzo.ai
// edge, which is THIS binary — and self-dispatches into a 502 loop. In prod there is
// no separate commerce backend to point COMMERCE_URL at (the in-cluster `commerce`
// Service selects the cloud pods), so co-residence is the only way to break the loop.
//
// Chain: RequestContext (gated context) → IAMTokenRequired (resolves the org from the
// gateway-validated X-Org-Id into Locals("organization"), the namespace commerce's
// GetOrganization reads) → PinBillingSubject (pins the caller's OWN billing subject
// into the query — the SAME isolation the bridge applies, so a read can never widen
// past the caller; fail-closed for an unvalidated caller) → the commerce handler. The
// specific paths shadow the bridge wildcard (order 100 < 122). payment-config is
// org-scoped, not subject-scoped, but PinBillingSubject is still the auth gate that
// keeps an unvalidated caller from reaching GetOrganization; its pinned (unused)
// subject params are ignored by that handler.
billingRead := []struct {
path string
h zip.Handler
}{
{"/v1/billing/invoices", commercebilling.ListInvoices},
{"/v1/billing/invoices/:id/pdf", commercebilling.DownloadInvoicePDF},
{"/v1/billing/subscriptions", commercebilling.ListBillingSubscriptions},
{"/v1/billing/spend-alerts", commercebilling.ListSpendAlerts},
{"/v1/billing/payouts", commercebilling.ListPayouts},
{"/v1/billing/payment-config", commercebilling.GetPaymentConfig},
}
for _, r := range billingRead {
app.Get(r.path,
commercemid.RequestContext(),
iammiddleware.IAMTokenRequired(),
accountclient.PinBillingSubject(),
r.h,
)
}
// GET /v1/billing/spend-alerts/authorize — the per-request per-scope spend-CAP
// VERDICT the request-edge metering gate consumes (clients/metering scopeAuthorize,
// pathLimitsAuthorize). It is a SERVICE-token S2S read (COMMERCE_SERVICE_TOKEN +
// X-Org-Id), NOT a browser/IAM read — so it needs its OWN registration, distinct from
// the console billingRead block above: without a co-resident handler this authorize
// fell through to the account bridge's /v1/billing/* wildcard (order 122), which — being
// service-token-forwardable (billing.go billingForwardable) — re-forwarded it to
// COMMERCE_URL (the public api.hanzo.ai edge = THIS binary) over commerceinproc's
// self-routing transport, re-entering the same wildcard until the depth-8 guard refused
// → 502 → the gate fails OPEN (the cap is a policy overlay, so no traffic was blocked,
// but ~135 502s/30m spammed the money path and each burned 8 full-app dispatches). The
// plain GET /v1/billing/spend-alerts (registered above) already broke this loop for the
// CRUD read; this closes the /authorize sibling the metering gate hits on every call.
//
// Chain mirrors commerce's OWN gate on this route (api/billing/handlers.go: the `billing`
// group's userRequired = TokenRequired) plus the RequestContext the standalone supplies
// globally — NOT the IAM/PinBillingSubject console chain: a raw service token is not an
// IAM JWT, so IAMTokenRequired would leave GetOrganization unset and AuthorizeSpendCap
// would 500. TokenRequired authenticates the service token AND resolves the tenant from
// the gateway-pinned X-Org-Id into Locals("organization"), which AuthorizeSpendCap reads.
// The specific route shadows the bridge wildcard (order 100 < 122). No PlatformOnly:
// authorize is a per-org cap read, not a cross-org mint (unlike auto-recharge/run-all).
app.Get("/v1/billing/spend-alerts/authorize",
commercemid.RequestContext(),
commercemid.TokenRequired(),
commercebilling.AuthorizeSpendCap,
)
// Self-service spend-cap CRUD WRITES — the customer half of the cap: a customer
// (or the admin S2S) CREATES / EDITS / REMOVES their own usage caps. These are the
// write siblings of the co-resident GET /v1/billing/spend-alerts list; without their
// own registration they too fell through the account bridge's /v1/billing/* wildcard
// (billingForwardable includes POST spend-alerts) into the SAME 502 self-dispatch loop
// authorize hit — so a customer could not set a cap AT ALL in the unified binary
// (POST/PATCH/DELETE all 502'd). Same chain commerce's own route table gates them with
// (api/billing/handlers.go:322-325, the `user` group's userRequired = TokenRequired) +
// the global RequestContext — an IAM JWT OR the COMMERCE_SERVICE_TOKEN, org resolved
// from the gateway-pinned X-Org-Id into Locals("organization"). Org-scoped by that
// namespace (a caller only ever writes their OWN org's caps; a foreign :id is a
// not-found miss in the caller's namespace), so no PinBillingSubject — spend-alerts are
// org-level, not billing-subject-level. Shadow the bridge wildcard (order 100 < 122).
// A spend cap is a FINANCIAL SAFETY control, so its writes are gated to an ORG ADMIN
// (or SuperAdmin, or the trusted S2S service token for the SuperAdmin cap-oversight
// Forward) — never any authenticated member. commerce's own `user` group admits any
// member, which would let a compromised member key DELETE the org's cap (→ unbounded
// spend) or POST a 1¢ enforce cap (→ org-wide 402 DoS). requireSpendCapAdmin closes that.
app.Post("/v1/billing/spend-alerts",
commercemid.RequestContext(),
commercemid.TokenRequired(),
requireSpendCapAdmin(),
commercebilling.CreateSpendAlert,
)
app.Patch("/v1/billing/spend-alerts/:id",
commercemid.RequestContext(),
commercemid.TokenRequired(),
requireSpendCapAdmin(),
commercebilling.UpdateSpendAlert,
)
app.Delete("/v1/billing/spend-alerts/:id",
commercemid.RequestContext(),
commercemid.TokenRequired(),
requireSpendCapAdmin(),
commercebilling.DeleteSpendAlert,
)
// In-process seams:
// - commerceinproc routes the S2S billing byte-stream into the co-resident
// app (the metering debit path) instead of a socket to a standalone pod.
@@ -270,3 +387,19 @@ func fireCapAlert(org string, test bool, project, service string) {
db := commercedatastore.New(ctx)
commercebilling.FireSpendAlerts(ctx, db, org, test, project, service, nil)
}
// requireSpendCapAdmin gates a spend-alert WRITE to a validated ORG ADMIN or platform
// SuperAdmin (the unforgeable SanitizeIdentity-minted X-User-IsOrgAdmin / isAdmin bits),
// OR the trusted in-proc S2S service token (the SuperAdmin cap-oversight Forward + internal
// automation). A validated non-admin MEMBER is REFUSED (403): a spend cap is a financial
// safety boundary — a member must not be able to delete the org's cap (→ unbounded spend)
// or set a punitive 1¢ cap (→ org-wide 402 DoS). The read paths (list/authorize) stay
// member/S2S-open; only the mutations require admin.
func requireSpendCapAdmin() zip.Handler {
return func(c *zip.Ctx) error {
if principal.IsSuperAdmin(c) || principal.IsOrgAdmin(c) || accountclient.IsServiceToken(c) {
return c.Next()
}
return zip.ErrForbidden("org admin required to change spend caps")
}
}
+5
View File
@@ -42,11 +42,13 @@ var frozen = []struct {
{"storage", true, false}, // was order 118
{"provisioning", false, false}, // was order 120
{"billing", false, false}, // was order 121
{"rollingcap", false, false}, // rolling spend-cap gate (after billing); golden drifted — refrozen
{"account-bridge", false, false}, // was order 122
{"do", false, false}, // was order 123
{"platform", true, false}, // was order 124
{"projects", false, false}, // was order 125
{"dns", false, false}, // new: /v1/dns zone plane (after projects)
{"domain", false, false}, // new: Hanzo Domains registrar (/v1/domain), after dns
{"prompts", false, false}, // was order 126
{"agents", false, true}, // was order 127
{"link", false, true}, // new: unified AI login manager (/v1/links), after agents
@@ -63,9 +65,11 @@ var frozen = []struct {
{"catalogsync", false, true}, // new: reverse loop (product.created → render) after content
{"ml", true, false}, // was order 130
{"usage", false, false}, // was order 131
{"leaderboard", false, true}, // new: gamified usage analytics (after usage), owns opt-in SQLite (Shutdown)
{"crm", false, false}, // was order 131
{"marketing", false, true}, // new: marketing domain fold (after crm)
{"ads", false, true}, // new: ads domain fold (after crm)
{"validators", false, true}, // new: NFT-gated node provisioning (after ads); golden refrozen
{"social", false, true}, // new: /v1/social fold (after crm)
{"analytics", true, false}, // was order 132
{"git", false, false}, // was order 132
@@ -83,6 +87,7 @@ var frozen = []struct {
{"team", false, true}, // was order 138
{"settings", false, true}, // was order 138
{"notify", true, false}, // was order 139
{"channels", false, true}, // new: /v1/channels transport plane (after notify; must mount after integrations so RegisterIngress installs before webhooks emit)
{"gateway", false, false}, // was order 139
{"entitlements", false, true}, // was order 139
{"exec", false, false}, // was order 140
+64
View File
@@ -0,0 +1,64 @@
// Copyright 2026 Hanzo AI Inc. All Rights Reserved.
package apps
import (
"context"
"errors"
"testing"
)
// stubKMS is a KMSClient whose GetSecret returns a sealed value when present or a
// not-found error otherwise — mirroring the co-resident store that, in production,
// holds no upstream provider keys (they are provisioned as KMS-injected env).
type stubKMS struct{ sealed map[string]string }
func (s stubKMS) GetSecret(_ context.Context, ref string) ([]byte, error) {
if v, ok := s.sealed[ref]; ok {
return []byte(v), nil
}
return nil, errors.New("secret not found")
}
func (stubKMS) PutSecret(context.Context, string, []byte) error { return nil }
func (stubKMS) Sign(context.Context, string, []byte) ([]byte, error) { return nil, nil }
// TestZenKeyResolver_EnvFallback pins the production wiring: the upstream provider
// key is provisioned as env (from the cloud-api-llm-keys secret), NOT sealed in the
// co-resident KMS store, so a KMS miss must resolve to the env value rather than
// returning "" (which would send an empty bearer upstream → provider 401).
func TestZenKeyResolver_EnvFallback(t *testing.T) {
const env = "DO_AI_API_KEY"
t.Setenv(env, "env-provisioned-key")
// KMS store has no upstream keys (the real deployment state) — resolve from env.
if got := zenKeyResolver(stubKMS{})(context.Background(), env); got != "env-provisioned-key" {
t.Fatalf("KMS-miss: got %q, want env value", got)
}
// A nil KMS client (KMS disabled) — still resolve from env.
if got := zenKeyResolver(nil)(context.Background(), env); got != "env-provisioned-key" {
t.Fatalf("nil-KMS: got %q, want env value", got)
}
}
// TestZenKeyResolver_EnvTakesPrecedence pins the resolution ORDER: env is read
// FIRST, then KMS — the same order ai uses on the prod hot path. The operator
// injects provider keys as env from the KMS-synced secret, so the env is the live
// value; the co-resident store is the fallback. A key present in BOTH surfaces
// resolves to the env value.
func TestZenKeyResolver_EnvTakesPrecedence(t *testing.T) {
const env = "ANTHROPIC_API_KEY"
t.Setenv(env, "env-key")
got := zenKeyResolver(stubKMS{sealed: map[string]string{env: "sealed-key"}})(context.Background(), env)
if got != "env-key" {
t.Fatalf("got %q, want env-key (env precedence)", got)
}
}
// TestZenKeyResolver_AbsentEverywhere keeps the fail-fast contract: absent from both
// surfaces resolves to "" so zen refuses rather than serving for free.
func TestZenKeyResolver_AbsentEverywhere(t *testing.T) {
if got := zenKeyResolver(stubKMS{})(context.Background(), "MISSING_KEY_XYZ"); got != "" {
t.Fatalf("got %q, want empty", got)
}
}
+48
View File
@@ -0,0 +1,48 @@
// Copyright 2026 The Hanzo Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cloud
import (
"os"
"testing"
)
// TestJWTAudiences_AcceptsHanzoTeam pins the hanzo.team OIDC client. IAM mints
// team's access tokens with aud=hanzo-team (each app's aud is its client_id);
// the team OAuth callback sets that token as the hanzo_iam_token cookie, and
// the usage/wallet page (/v1/team/billing/ui/) reads /v1/billing/balance +
// /v1/usage/summary same-origin on it. If hanzo-team is not accepted the
// cookie resolves anonymous and every wallet read 401s — and the callback's
// own validator (NewTokenValidator shares this allowlist) refuses the login.
func TestJWTAudiences_AcceptsHanzoTeam(t *testing.T) {
os.Unsetenv("CLOUD_JWT_AUDIENCES")
os.Unsetenv("GATEWAY_ALLOWED_AUDIENCES")
has := func(list []string, v string) bool {
for _, s := range list {
if s == v {
return true
}
}
return false
}
if !has(defaultJWTAudiences, "hanzo-team") {
t.Fatalf("defaultJWTAudiences must include hanzo-team (the hanzo.team client_id); got %v", defaultJWTAudiences)
}
if !has(jwtAudiencesFromEnv(), "hanzo-team") {
t.Fatalf("resolved JWT audiences must include hanzo-team; got %v", jwtAudiencesFromEnv())
}
}
+9 -7
View File
@@ -33,6 +33,7 @@ import (
"github.com/go-jose/go-jose/v4/jwt"
"github.com/hanzoai/cloud/clients/principal"
model "github.com/hanzoai/iam/pkg/model"
)
// idClaims is the subset of Hanzo IAM JWT claims the identity sanitizer needs.
@@ -40,13 +41,14 @@ import (
type idClaims struct {
jwt.Claims
Owner string `json:"owner"` // org slug (the org)
Project string `json:"project"` // org SUB-SCOPE within owner (empty ⟹ default project)
BillingAccount string `json:"billing_account"` // WHO PAYS, stated by IAM (empty ⟹ pre-claim token)
Name string `json:"name"` // display name (id fallback)
PreferredUsername string `json:"preferred_username"` // id fallback
Email string `json:"email"`
IsAdmin bool `json:"isAdmin"`
Owner string `json:"owner"` // org slug (the org)
Project string `json:"project"` // org SUB-SCOPE within owner (empty ⟹ default project)
BillingAccount string `json:"billing_account"` // WHO PAYS, stated by IAM (empty ⟹ pre-claim token)
Name string `json:"name"` // display name (id fallback)
PreferredUsername string `json:"preferred_username"` // id fallback
Email string `json:"email"`
IsAdmin bool `json:"isAdmin"`
Orgs []model.OrgRef `json:"orgs"` // membership SET (home first); empty on legacy tokens
}
// mintedProject returns the project id to stamp into X-Project-Id, or "" when the
+66 -27
View File
@@ -63,14 +63,15 @@ func BuildDeps(cfg *Config) Deps {
)
deps := Deps{
Logger: logger,
Brand: cfg.Brand,
Version: cfg.Version,
Env: cfg.Env,
Domain: cfg.Domain,
IAMIssuer: cfg.IAMIssuer,
DataDir: cfg.DataDir,
AIDefaultModel: cfg.AIDefaultModel,
Logger: logger,
Brand: cfg.Brand,
Version: cfg.Version,
Env: cfg.Env,
Domain: cfg.Domain,
IAMIssuer: cfg.IAMIssuer,
DataDir: cfg.DataDir,
AIDefaultModel: cfg.AIDefaultModel,
AIFallbackModel: cfg.AIFallbackModel,
}
// For each subsystem: enabled → leave nil (Mount fills it); not
@@ -91,7 +92,13 @@ func BuildDeps(cfg *Config) Deps {
// 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)
// AI (completions, WRITE) and Embed (embeddings, READ-ONLY) are DISTINCT
// credentials by concern: completions never ride the read-only publishable
// (pk-) key — the gateway 403s a pk- key on any write endpoint — so deps.AI
// resolves to the M2M identity, while deps.Embed keeps the pk- key (correct
// least-privilege for a read-only call). Both meter through the ONE commerce path.
deps.AI = meteredAIClient(pickCompletionsClient(cfg, logger), deps)
deps.Embed = meteredAIClient(pickEmbedClient(cfg, logger), deps)
wireFinance(cfg, logger)
deps.O11y = pick(cfg, logger, "o11y", "O11y", cfg.O11yZAPAddr, clients.O11yRPCAt, clients.DisabledO11y)
deps.VFS = pickVFSClient(cfg, logger)
@@ -579,44 +586,76 @@ func RegisterCommerceClientFactory(f func(cfg *Config, log luxlog.Logger) Commer
commerceClientFactory = f
}
// pickAIClient resolves deps.AI — the client the agents subsystem runs chat
// completions through. Unlike the co-resident subsystems, there is NO in-process
// "ai" mount that fills a nil deps.AI: inference is an external gateway, so this
// must return a concrete client, never nil. (A nil deps.AI was the live bug —
// the default all-enabled config returned nil here and nothing ever filled it,
// so every /v1/agents/:name/run 503'd "inference is not configured".)
// publishableKey reports whether apiKey is a read-only PUBLISHABLE key (pk-). The
// Hanzo gateway 403s a publishable key on any WRITE endpoint — "Publishable keys
// can only access read-only endpoints … use a secret key (sk-)". So the COMPLETIONS
// resolver must refuse it (it would only 403 chat), while the EMBED resolver accepts
// it (embeddings ARE read-only). pk- is the IAM key family's read-only member
// (hk-/sk-/pk-/fw_/hz_; clients/admission). This ONE predicate is the split's crux:
// it kept the intermittent-403 bug — a pk- embed key riding the shared completions
// client — from ever recurring, wherever the key comes from.
func publishableKey(apiKey string) bool {
return strings.HasPrefix(strings.TrimSpace(apiKey), "pk-")
}
// pickCompletionsClient resolves deps.AI — the client the agents run path (and
// guide/crm/content/sitegen/code /ask) execute CHAT COMPLETIONS through. There is
// NO in-process "ai" mount that fills a nil deps.AI: inference is an external
// gateway, so this returns a concrete client, never nil.
//
// Completions are a WRITE endpoint: a read-only publishable (pk-) key 403s them. So
// this resolver NEVER rides a pk- key — that is the embed credential (pickEmbedClient).
// Preference order:
// 1. Static-key HTTP gateway when a base URL AND a static key are configured —
// an operator override / pre-provisioned key. The key is a KMS-injected
// secret; only the base URL and default model are ever logged.
// 1. Static SECRET-key HTTP gateway when a base URL AND a completions-capable
// (non-pk-) static key are configured — an explicit operator override (sk-/hk-).
// A pk- key here is REFUSED (it would only 403 chat) and the resolver falls
// through to M2M — THE fix for the intermittent publishable-key 403 on bot replies.
// 2. M2M HTTP gateway when a base URL AND the binary's IAM identity are present
// (the durable Hanzo default): the client mints+refreshes a client-
// credentials token from IAM_CLIENT_ID/SECRET — no static key to rotate. The
// secret is never logged.
// (the durable Hanzo default): the client mints+refreshes a client-credentials
// token from IAM_CLIENT_ID/SECRET — no static key to rotate. Secret never logged.
// 3. ZAP RPC when an addr is configured (split-deploy of a future ai subsystem).
// 4. Fail-closed stub otherwise — a run records an honest error, never fakes one.
func pickAIClient(cfg *Config, log luxlog.Logger) AIClient {
if cfg.AIBaseURL != "" && cfg.AIAPIKey != "" {
log.Info("deps.AI → HTTP gateway (static key)", "base_url", cfg.AIBaseURL, "default_model", cfg.AIDefaultModel)
func pickCompletionsClient(cfg *Config, log luxlog.Logger) AIClient {
if cfg.AIBaseURL != "" && cfg.AIAPIKey != "" && !publishableKey(cfg.AIAPIKey) {
log.Info("deps.AI (completions) → HTTP gateway (static secret key)", "base_url", cfg.AIBaseURL, "default_model", cfg.AIDefaultModel)
return clients.AIHTTPAt(cfg.AIBaseURL, cfg.AIAPIKey, cfg.AIDefaultModel)
}
if cfg.AIAPIKey != "" && publishableKey(cfg.AIAPIKey) {
log.Info("deps.AI (completions) → refusing read-only publishable (pk-) key for chat; using M2M", "base_url", cfg.AIBaseURL)
}
if cfg.AIBaseURL != "" && cfg.AIAuthClientID != "" && cfg.AIAuthClientSecret != "" {
tokenURL := aiM2MTokenURL(cfg)
if tokenURL != "" {
log.Info("deps.AI → HTTP gateway (IAM M2M)", "base_url", cfg.AIBaseURL,
log.Info("deps.AI (completions) → HTTP gateway (IAM M2M)", "base_url", cfg.AIBaseURL,
"token_url", tokenURL, "client_id", cfg.AIAuthClientID, "default_model", cfg.AIDefaultModel)
return clients.AIHTTPM2M(cfg.AIBaseURL, tokenURL, cfg.AIAuthClientID, cfg.AIAuthClientSecret, cfg.AIDefaultModel)
}
}
if cfg.AIZAPAddr != "" {
log.Info("deps.AI → ZAP RPC", "addr", cfg.AIZAPAddr)
log.Info("deps.AI (completions) → ZAP RPC", "addr", cfg.AIZAPAddr)
return clients.AIRPCAt(cfg.AIZAPAddr)
}
log.Info("deps.AI → disabled (no CLOUD_AI_API_KEY, no IAM M2M identity, no gateway configured)")
log.Info("deps.AI (completions) → disabled (no secret key, no IAM M2M identity, no gateway configured)")
return clients.DisabledAI()
}
// pickEmbedClient resolves deps.Embed — the client code-index + KB knowledge run
// EMBEDDINGS through. Embeddings are a READ-ONLY endpoint, so the read-only
// publishable (pk-) key (CLOUD_AI_API_KEY ← cloud-ai-embed-key) is the CORRECT
// least-privilege credential here, and is used UNCHANGED — this path is deliberately
// not rewired. Preference order:
// 1. Static-key HTTP gateway when a base URL AND a static key are configured. The
// key (pk- or sk-) is a KMS-injected secret; only base URL + model are logged.
// 2. Otherwise share the completions resolution (M2M / ZAP / fail-closed) so a
// deploy with no dedicated embed key still indexes — no regression.
func pickEmbedClient(cfg *Config, log luxlog.Logger) AIClient {
if cfg.AIBaseURL != "" && cfg.AIAPIKey != "" {
log.Info("deps.Embed → HTTP gateway (static embed key)", "base_url", cfg.AIBaseURL, "default_model", cfg.AIDefaultModel)
return clients.AIHTTPAt(cfg.AIBaseURL, cfg.AIAPIKey, cfg.AIDefaultModel)
}
return pickCompletionsClient(cfg, log)
}
// aiM2MTokenURL resolves IAM's client_credentials endpoint the agent runner mints
// its M2M inference token at. It MUST be reachable FROM INSIDE THE CLUSTER: the
// runner runs in-cluster and the public issuer host (https://hanzo.id) is fronted
+6 -2
View File
@@ -63,7 +63,9 @@ var controlCommands = map[string]string{
"k8s": "deploy-target helpers (current target)",
"config": "view/edit ~/.hanzo/config preferences",
"security": "scan files for hardcoded secrets (local guardrail; no server/auth)",
"gpu": "connect this machine's GPU to the Hanzo cloud fleet (connect/status/disconnect)",
"link": "bring this machine into the Hanzo cloud fleet as a node (fabric + compute worker)",
"unlink": "take this machine out of the fleet (deregister + stop hanzod)",
"status": "show the org's fleet — every node with each of its GPUs",
"engine": "run a local hanzo-engine (OpenAI + Anthropic model server)",
"code": "launch a coding agent (claude, codex, dev) on a Hanzo cloud model",
"runner": "run this machine as a JIT CI runner for your org (GitHub Actions)",
@@ -598,7 +600,9 @@ func newRootCmd() *cobra.Command {
newBuildCmd(envOf, &f),
newConfigCmd(),
newSecurityCmd(envOf),
newGPUCmd(envOf, &f),
newLinkCmd(envOf, &f),
newUnlinkCmd(envOf, &f),
newStatusCmd(envOf, &f),
newEngineCmd(envOf, &f),
newCodeCmd(envOf, &f),
newRunnerCmd(envOf, &f),
+25 -8
View File
@@ -150,7 +150,7 @@ type codeAgent struct {
continueArgs []string // harness-native form of Hanzo -c/--continue
modelArg []string // how the model is passed on argv (empty: via env)
carrier func(model string) string // maps the resolved model to a client-recognized id (claude: zen→carrier); nil = pass through
provider func(base string) []string // agents that need the endpoint declared, not just env'd
provider func(base, model string) []string // agents that need the endpoint declared, not just env'd
clear []string // env that would shadow the wire (a stale key in the shell)
configHome string // env var that relocates the agent's config dir to ~/.hanzo ("" = share the user's own install)
seed func(dir string) error // one-time defaults for the isolated config dir
@@ -159,6 +159,19 @@ type codeAgent struct {
install string // hint when the binary is missing
}
// codeContextWindow is the input context (tokens) the served coding model
// budgets from. The enso and zen5 flagship tiers serve 1M; the flash tiers
// serve 131072. Codex is told this so it sizes context to the real window
// instead of a flat 256K cap — the cause of "maximum context exceeded" at
// 262144 even on a 1M-capable model. This wrapper only launches Hanzo coding
// models (default zen5), so non-flash defaults to the 1M flagship window.
func codeContextWindow(model string) int {
if strings.Contains(strings.ToLower(model), "flash") {
return 131072
}
return 1000000
}
// codex and @hanzo/dev share a lineage (dev is a Codex fork), hence a wire.
// They also ignore OPENAI_BASE_URL and talk to chatgpt.com unless a provider is
// declared, so declare Hanzo as the provider and select it.
@@ -169,19 +182,23 @@ func codexLike(bin, install string) codeAgent {
fullAuto: []string{"--dangerously-bypass-approvals-and-sandbox"},
continueArgs: []string{"resume", "--last"},
modelArg: []string{"-m"},
provider: func(base string) []string {
provider: func(base, model string) []string {
// api.hanzo.ai exposes the standard OpenAI /v1/models shape, not
// Codex's private remote model-catalog schema, so skip that refresh
// and supply the model's window here — sized to the SERVED model
// (enso / zen5 flagship = 1M, flash tiers = 131072) so Codex budgets
// the real context instead of a flat 256K cap (the "maximum context
// exceeded at 262144" bug). Auto-compact at 90% leaves headroom.
win := codeContextWindow(model)
return []string{
"-c", "model_provider=hanzo",
"-c", `model_providers.hanzo.name="Hanzo"`,
"-c", fmt.Sprintf(`model_providers.hanzo.base_url="%s/v1"`, strings.TrimSuffix(base, "/")),
"-c", `model_providers.hanzo.env_key="OPENAI_API_KEY"`,
"-c", `model_providers.hanzo.wire_api="responses"`,
// 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`,
"-c", fmt.Sprintf("model_context_window=%d", win),
"-c", fmt.Sprintf("model_auto_compact_token_limit=%d", win*9/10),
}
},
install: install,
@@ -447,7 +464,7 @@ func codeArgv(agent codeAgent, base, model string, safe bool, rest []string) []s
argv = append(argv, agent.fullAuto...)
}
if agent.provider != nil {
argv = append(argv, agent.provider(base)...)
argv = append(argv, agent.provider(base, model)...)
}
if len(agent.modelArg) > 0 { // claude takes the model via env, codex/dev on argv
argv = append(argv, agent.modelArg...)
+25 -2
View File
@@ -100,19 +100,42 @@ func TestCodeUnknownOptionsAndPostSeparatorArgsPassThrough(t *testing.T) {
}
func TestCodexProviderUsesNativeResponsesMetadata(t *testing.T) {
// defaultCodeModel is zen5 — a 1M flagship tier — so the window must be 1M,
// NOT the old flat 262144 cap that surfaced as "maximum context exceeded"
// even on a 1M-capable model. Auto-compact is 90% of the window.
argv := codeArgv(codeAgents["codex"], "https://api.hanzo.ai", defaultCodeModel, false, nil)
for _, want := range []string{
`model_provider=hanzo`,
`model_providers.hanzo.base_url="https://api.hanzo.ai/v1"`,
`model_providers.hanzo.wire_api="responses"`,
`features.remote_models=false`,
`model_context_window=262144`,
`model_auto_compact_token_limit=235929`,
`model_context_window=1000000`,
`model_auto_compact_token_limit=900000`,
} {
if !slices.Contains(argv, want) {
t.Errorf("Codex argv %q does not contain %q", argv, want)
}
}
// A flash tier budgets its smaller real window, not 1M.
flash := codeArgv(codeAgents["codex"], "https://api.hanzo.ai", "zen5-flash", false, nil)
if !slices.Contains(flash, `model_context_window=131072`) {
t.Errorf("zen5-flash argv %q must budget 131072, not the flagship 1M", flash)
}
}
// TestCodeContextWindowSizing pins the model→window map: every non-flash tier
// gets the 1M flagship window; only flash tiers drop to 131072.
func TestCodeContextWindowSizing(t *testing.T) {
for _, m := range []string{"zen5", "zen5-pro", "zen5-coder", "enso", "enso-ultra"} {
if w := codeContextWindow(m); w != 1000000 {
t.Errorf("codeContextWindow(%q) = %d, want 1000000", m, w)
}
}
for _, m := range []string{"zen5-flash", "enso-flash"} {
if w := codeContextWindow(m); w != 131072 {
t.Errorf("codeContextWindow(%q) = %d, want 131072", m, w)
}
}
}
// TestCodeTokenPrecedence locks in the 402 unblock: a fresh `hanzo login` JWT
+1 -1
View File
@@ -8,7 +8,7 @@ package cli
// cosign-signed binary from the latest github.com/hanzoai/engine release), so the
// CLI never re-implements platform detection or verification. `serve` launches the
// installed binary (`hanzoai --port P run -m MODEL`); `status` probes it, reusing
// the same /v1/models probe `hanzo gpu connect --serve-engine` advertises with.
// the same /v1/models probe `hanzo link --serve-engine` advertises with.
import (
"context"
+695 -108
View File
File diff suppressed because it is too large Load Diff
+80
View File
@@ -0,0 +1,80 @@
package cli
import (
"os"
"path/filepath"
"testing"
)
// TestParseRocmSmiCSV — the AMD GPU inventory names a card from its marketing
// series + gfx target, exactly as `rocm-smi --showproductname --csv` reports on
// evo's gfx1151 Radeon 8060S. This is the primary AMD detection path.
func TestParseRocmSmiCSV(t *testing.T) {
// Real evo output (header + one card row).
csv := []byte("device,Card Series,Card Model,Card Vendor,Card SKU,Subsystem ID,Device Rev,Node ID,GUID,GFX Version\n" +
"card0,Radeon 8060S Graphics,0x1586,Advanced Micro Devices Inc. [AMD/ATI],STRXLGEN,-0x7fe3,0xc1,1,49819,gfx1151\n")
gpus := parseRocmSmiCSV(csv)
if len(gpus) != 1 {
t.Fatalf("want 1 AMD GPU, got %d (%+v)", len(gpus), gpus)
}
if got, want := gpus[0].Name, "Radeon 8060S Graphics (gfx1151)"; got != want {
t.Errorf("name = %q, want %q", got, want)
}
}
// TestGfxNameDecodesTargetVersion — the kfd fallback decodes gfx_target_version.
func TestGfxNameDecodesTargetVersion(t *testing.T) {
for _, c := range []struct {
v int
want string
}{
{110501, "gfx1151"}, // evo Radeon 8060S / Strix Halo
{90012, "gfx9012"}, // sanity: MI-class encoding shape
{100300, "gfx1030"}, // RDNA2
} {
if got := gfxName(c.v); got != c.want {
t.Errorf("gfxName(%d) = %q, want %q", c.v, got, c.want)
}
}
}
// TestParseKfdTopology — the driver-only fallback (no rocm-smi) still finds the GPU
// from /sys/class/kfd: node 0 is the CPU (simd_count 0, skipped), node 1 is the
// gfx1151 GPU. Built against a fixture tree mirroring evo's real properties files.
func TestParseKfdTopology(t *testing.T) {
root := t.TempDir()
writeNode(t, root, "0", "simd_count 0\ngfx_target_version 0\n")
writeNode(t, root, "1", "cpu_cores_count 0\nsimd_count 80\ngfx_target_version 110501\n")
gpus := parseKfdTopology(root)
if len(gpus) != 1 {
t.Fatalf("want 1 GPU node (CPU node 0 skipped), got %d (%+v)", len(gpus), gpus)
}
if got, want := gpus[0].Name, "AMD GPU (gfx1151)"; got != want {
t.Errorf("name = %q, want %q", got, want)
}
}
// TestParseVulkaninfoSummary — the last resort keeps only AMD/Radeon devices so it
// never double-counts a card another vendor path already reported.
func TestParseVulkaninfoSummary(t *testing.T) {
out := []byte("GPU0:\n\tdeviceName = AMD Radeon Graphics (RADV GFX1151)\n" +
"GPU1:\n\tdeviceName = llvmpipe (LLVM 18.1.0, 256 bits)\n")
gpus := parseVulkaninfoSummary(out)
if len(gpus) != 1 {
t.Fatalf("want 1 AMD device (llvmpipe filtered), got %d (%+v)", len(gpus), gpus)
}
if got := gpus[0].Name; got != "AMD Radeon Graphics (RADV GFX1151)" {
t.Errorf("name = %q", got)
}
}
func writeNode(t *testing.T, root, id, props string) {
t.Helper()
dir := filepath.Join(root, id)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "properties"), []byte(props), 0o644); err != nil {
t.Fatal(err)
}
}
+2 -1
View File
@@ -109,6 +109,7 @@ func TestBuildRegistrationCarriesEngine(t *testing.T) {
jobsNS: "gpu-jobs",
gpus: []gpuInfo{{Name: "NVIDIA GB10", MemoryTotal: "122880 MiB"}},
serveEngine: true,
studioReady: true,
engineURL: srv.URL,
engineAdvURL: "http://node.example:1234",
}
@@ -136,7 +137,7 @@ func TestBuildRegistrationCarriesEngine(t *testing.T) {
}
func TestCapabilitiesWithoutEngine(t *testing.T) {
w := &worker{serveEngine: false}
w := &worker{serveEngine: false, studioReady: true}
caps := w.capabilities()
if len(caps) != 1 || caps[0] != studioCap {
t.Fatalf("capabilities = %v, want just [%q] when not serving an engine", caps, studioCap)
+291
View File
@@ -0,0 +1,291 @@
package cli
// gpu_queue_test.go — the per-GPU claim contract: a job pinned to THIS machine's
// lane ("gpu:<identity>") is claimed BEFORE the shared any-GPU lane ("gpu-jobs"),
// both within the gpu-jobs namespace. A stub cloud records the taskQueue of every
// claim so the ORDER is the assertion.
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
// stubJobsCloud records the taskQueue of every claim and serves the queued job (if
// any) on the matching lane, once. A lane with no job (or already drained) answers
// 204; complete/fail/heartbeat answer 200.
func stubJobsCloud(t *testing.T, claims *[]string, jobsByLane map[string]*claimedActivity) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/activities/claim") {
var body struct {
TaskQueue string `json:"taskQueue"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
*claims = append(*claims, body.TaskQueue)
if job := jobsByLane[body.TaskQueue]; job != nil {
jobsByLane[body.TaskQueue] = nil // deliver once
_ = json.NewEncoder(w).Encode(job)
return
}
w.WriteHeader(http.StatusNoContent)
return
}
w.WriteHeader(http.StatusOK) // complete / fail / heartbeat
}))
}
// echoJob is a GPU-free job the worker's echo handler runs to completion instantly,
// so a claim test never needs a real render backend.
func echoJob(id string) *claimedActivity {
a := &claimedActivity{Input: json.RawMessage(`{}`)}
a.Execution.WorkflowId, a.Execution.RunId = id, id
a.Type.Name = "echo"
return a
}
func testWorker(t *testing.T, url string) *worker {
t.Helper()
t.Setenv("HANZO_TOKEN", "test-token") // ensureToken honors this; no `hanzo login`
return &worker{
env: &Env{CloudURL: url},
http: &http.Client{Timeout: 5 * time.Second},
baseURL: url,
identity: "spark",
hostname: "spark",
jobsNS: "gpu-jobs",
handlers: map[string]jobHandler{"echo": echoHandler},
studioReady: true, // a render-capable node (preflight passed)
}
}
func TestGPUQueueLaneName(t *testing.T) {
w := &worker{identity: "spark"}
if got := w.gpuQueue(); got != "gpu:spark" {
t.Fatalf("gpuQueue() = %q, want gpu:spark", got)
}
}
// A job pinned to THIS GPU's lane is claimed first — the shared lane is not even
// polled that cycle.
func TestClaimPrefersTargetedLane(t *testing.T) {
var claims []string
srv := stubJobsCloud(t, &claims, map[string]*claimedActivity{"gpu:spark": echoJob("j1")})
defer srv.Close()
if err := testWorker(t, srv.URL).claimAndRun(context.Background(), io.Discard); err != nil {
t.Fatalf("claimAndRun: %v", err)
}
if len(claims) != 1 || claims[0] != "gpu:spark" {
t.Fatalf("claims = %v, want exactly [gpu:spark] (targeted first; shared not polled)", claims)
}
}
// With its own lane empty, the worker falls through to the shared any-GPU lane.
func TestClaimFallsBackToSharedLane(t *testing.T) {
var claims []string
srv := stubJobsCloud(t, &claims, map[string]*claimedActivity{"gpu-jobs": echoJob("j2")})
defer srv.Close()
if err := testWorker(t, srv.URL).claimAndRun(context.Background(), io.Discard); err != nil {
t.Fatalf("claimAndRun: %v", err)
}
if len(claims) != 2 || claims[0] != "gpu:spark" || claims[1] != "gpu-jobs" {
t.Fatalf("claims = %v, want [gpu:spark gpu-jobs] (targeted, then shared)", claims)
}
}
// Both lanes empty polls targeted THEN shared and runs nothing.
func TestClaimBothLanesEmpty(t *testing.T) {
var claims []string
srv := stubJobsCloud(t, &claims, map[string]*claimedActivity{})
defer srv.Close()
if err := testWorker(t, srv.URL).claimAndRun(context.Background(), io.Discard); err != nil {
t.Fatalf("claimAndRun: %v", err)
}
if len(claims) != 2 || claims[0] != "gpu:spark" || claims[1] != "gpu-jobs" {
t.Fatalf("claims = %v, want [gpu:spark gpu-jobs]", claims)
}
}
// The render submit seam is the gated worker-mode execute path, not the open /prompt
// — the shared contract with the studio's --worker-mode gate.
func TestWorkerExecuteSeamIsGated(t *testing.T) {
if localWorkerExecute != "http://127.0.0.1:8188/v1/worker/execute" {
t.Fatalf("localWorkerExecute = %q, want the gated /v1/worker/execute seam", localWorkerExecute)
}
}
// A HUNG nvidia-smi (blocks until its bounded context fires) must NOT block the
// caller: reportSample detaches the probe+POST onto a goroutine and returns at once,
// so the worker's select loop keeps heartbeating and claiming. Guards the
// worker-wedge regression (a synchronous probe that stalls the loop under GPU/driver
// pressure → the machine flaps offline mid-render).
func TestReportSampleNeverBlocksLoop(t *testing.T) {
orig := nvidiaSmi
defer func() { nvidiaSmi = orig }()
probing := make(chan struct{})
release := make(chan struct{})
nvidiaSmi = func(ctx context.Context) ([]byte, error) {
close(probing) // entered the probe (the nvidiaSmi var read already happened)
select {
case <-release: // the test lets us finish
case <-ctx.Done(): // or the bounded probe timeout fires
}
return nil, ctx.Err()
}
t.Setenv("HANZO_TOKEN", "t")
w := &worker{identity: "spark", hostname: "spark", http: &http.Client{Timeout: time.Second}, env: &Env{}, baseURL: "http://127.0.0.1:0"}
done := make(chan struct{})
go func() { w.reportSample(context.Background()); close(done) }()
select {
case <-done: // returned immediately — the select loop is never wedged
case <-time.After(500 * time.Millisecond):
t.Fatal("reportSample blocked the caller — a hung sampler would wedge the worker loop")
}
select {
case <-probing: // the probe really ran, on the detached goroutine (off the critical path)
case <-time.After(2 * time.Second):
t.Fatal("probe never started")
}
close(release)
}
// A node that can't serve renders (preflight failed) claims NOTHING — it must never
// pull a render job onto a box that will only refuse it on the gated seam (poison
// loop). It still heartbeats presence; it just stays idle.
func TestNotStudioReadyClaimsNothing(t *testing.T) {
var claims []string
srv := stubJobsCloud(t, &claims, map[string]*claimedActivity{"gpu:spark": echoJob("j1")})
defer srv.Close()
w := testWorker(t, srv.URL)
w.studioReady = false
if err := w.claimAndRun(context.Background(), io.Discard); err != nil {
t.Fatalf("claimAndRun: %v", err)
}
if len(claims) != 0 {
t.Fatalf("a not-ready node claimed %v; want zero claims", claims)
}
}
// The terminal report must hit the RIGHT activity — namespace gpu-jobs, the CLAIMED
// workflow+run ids, the correct verb. A stub that 200s every path lets an ns/id
// routing regression pass, so assert the exact paths for both complete and fail.
func TestTerminalReportsHitCorrectActivityPath(t *testing.T) {
t.Setenv("HANZO_TOKEN", "t")
var mu sync.Mutex
terminal := map[string]string{}
served := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/activities/claim"):
var body struct {
TaskQueue string `json:"taskQueue"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
if body.TaskQueue != "gpu:spark" {
w.WriteHeader(http.StatusNoContent)
return
}
mu.Lock()
n := served
served++
mu.Unlock()
switch n {
case 0:
_ = json.NewEncoder(w).Encode(echoJob("wf1")) // echo handler → complete
case 1:
j := echoJob("wf2")
j.Type.Name = "nope" // no handler → fail
_ = json.NewEncoder(w).Encode(j)
default:
w.WriteHeader(http.StatusNoContent)
}
case strings.HasSuffix(r.URL.Path, "/complete"):
mu.Lock()
terminal["complete"] = r.URL.Path
mu.Unlock()
w.WriteHeader(http.StatusOK)
case strings.HasSuffix(r.URL.Path, "/fail"):
mu.Lock()
terminal["fail"] = r.URL.Path
mu.Unlock()
w.WriteHeader(http.StatusOK)
default:
w.WriteHeader(http.StatusOK)
}
}))
defer srv.Close()
w := testWorker(t, srv.URL)
if err := w.claimAndRun(context.Background(), io.Discard); err != nil {
t.Fatalf("run1 (echo→complete): %v", err)
}
if err := w.claimAndRun(context.Background(), io.Discard); err != nil {
t.Fatalf("run2 (unknown→fail): %v", err)
}
if got := terminal["complete"]; got != "/v1/tasks/namespaces/gpu-jobs/activities/wf1/wf1/complete" {
t.Fatalf("complete path = %q, want the claimed activity's exact ns+ids", got)
}
if got := terminal["fail"]; got != "/v1/tasks/namespaces/gpu-jobs/activities/wf2/wf2/fail" {
t.Fatalf("fail path = %q, want the claimed activity's exact ns+ids", got)
}
}
// SharePolicy.reject's fallback: an ABSENT or unparseable input field skips its gate
// (permissive), never a hard error, so a policy only ever narrows on fields it can read.
func TestSharePolicyRejectFallback(t *testing.T) {
var nilp *SharePolicy
if r := nilp.reject("studio.render", nil); r != "" {
t.Fatalf("nil policy must allow everything: %q", r)
}
p := &SharePolicy{AllowedJobTypes: []string{"studio.render"}}
if p.reject("echo", nil) == "" {
t.Fatal("a disallowed job type must be rejected")
}
if r := p.reject("studio.render", nil); r != "" {
t.Fatalf("an allowed job type must pass: %q", r)
}
p2 := &SharePolicy{AllowedOrgs: []string{"acme"}}
if r := p2.reject("studio.render", json.RawMessage(`{}`)); r != "" {
t.Fatalf("absent org must SKIP the org gate (fallback), not reject: %q", r)
}
if r := p2.reject("studio.render", json.RawMessage(`{"org":"acme"}`)); r != "" {
t.Fatalf("a matching org must pass: %q", r)
}
if p2.reject("studio.render", json.RawMessage(`{"org":"other"}`)) == "" {
t.Fatal("a non-allowed org must be rejected")
}
if r := p2.reject("studio.render", json.RawMessage(`not json`)); r != "" {
t.Fatalf("unparseable input must skip input gates, not reject: %q", r)
}
}
// studioCap is advertised ONLY when the node can actually render: a missing worker
// token is never ready (the gated seam would 403), and the block reason is explicit.
func TestStudioReadyGatesCapability(t *testing.T) {
t.Setenv("STUDIO_WORKER_TOKEN", "")
w := &worker{launchesStudio: true, http: &http.Client{}}
w.refreshStudioReady(context.Background())
if w.studioReady {
t.Fatal("no token must never be studio-ready")
}
if contains(w.capabilities(), studioCap) {
t.Fatalf("studioCap advertised without a token: %v", w.capabilities())
}
if w.studioBlockReason() == "" {
t.Fatal("a not-ready node must explain why it won't render")
}
// Token present + we launch the studio ⇒ ready ⇒ studioCap advertised.
t.Setenv("STUDIO_WORKER_TOKEN", "tok")
if changed := w.refreshStudioReady(context.Background()); !changed {
t.Fatal("adding the token should flip readiness")
}
if !w.studioReady || !contains(w.capabilities(), studioCap) {
t.Fatalf("token + launchesStudio must be ready + advertise studioCap: ready=%v caps=%v", w.studioReady, w.capabilities())
}
}
+88
View File
@@ -0,0 +1,88 @@
package cli
// gpu_spec_test.go — the host static-spec a `hanzo link` node reports so
// GET /v1/fleet can show its CPU arch, core count and total RAM (the fields a
// code-linked box already carries). Real telemetry only: arch is `uname -m`, cores
// are runtime.NumCPU, RAM is parsed from the OS — never a hardcoded machine.
import (
"os/exec"
"runtime"
"strings"
"testing"
)
func TestParseMemTotalKB(t *testing.T) {
// A real /proc/meminfo head from a 128 GiB box. MemTotal is in kB; we report bytes.
meminfo := []byte("MemTotal: 131923980 kB\nMemFree: 1048576 kB\nMemAvailable: 120000000 kB\n")
if got, want := parseMemTotalKB(meminfo), int64(131923980)*1024; got != want {
t.Fatalf("parseMemTotalKB = %d, want %d bytes", got, want)
}
// Absent / malformed input is reported as 0 (unknown), never a guess.
for name, in := range map[string]string{
"empty": "",
"no-memtotal": "MemFree: 100 kB\n",
"malformed": "MemTotal: notanumber kB\n",
"no-value": "MemTotal:\n",
} {
if got := parseMemTotalKB([]byte(in)); got != 0 {
t.Fatalf("%s: parseMemTotalKB = %d, want 0", name, got)
}
}
}
// detectMemTotal reads the real host, so on Linux/macOS CI it must return a positive
// byte count — proof the reporter reads actual RAM rather than shipping 0.
func TestDetectMemTotalIsReal(t *testing.T) {
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
t.Skipf("no MemTotal source on %s", runtime.GOOS)
}
got := detectMemTotal()
if got <= 0 {
t.Fatalf("detectMemTotal = %d, want the host's real RAM (>0)", got)
}
// Evidence: what THIS host actually reports (never hardcoded). On the GB10 spark
// box this prints aarch64 + ~128 GiB read from /proc/meminfo.
t.Logf("real host spec: arch=%s cpus=%d memory=%d bytes (%.1f GiB)",
detectArch(), runtime.NumCPU(), got, float64(got)/(1<<30))
}
// detectArch must match the fleet's `uname -m` convention (aarch64 | x86_64 | arm64),
// NOT runtime.GOARCH (arm64 | amd64) — so a machine that appears as both a run-target
// and a linked worker shows ONE arch string on the board. On Linux uname -m is
// aarch64/x86_64; assert the real host agrees and is never GOARCH's amd64.
func TestDetectArchMatchesUnameConvention(t *testing.T) {
got := detectArch()
if got == "" {
t.Fatal("detectArch returned empty; must fall back to runtime.GOARCH")
}
if out, err := exec.Command("uname", "-m").Output(); err == nil {
if want := strings.TrimSpace(string(out)); want != "" && got != want {
t.Fatalf("detectArch = %q, want `uname -m` %q (fleet convention)", got, want)
}
}
// Guard the regression this test exists for: on Linux amd64 the value must be
// x86_64, never GOARCH's "amd64".
if runtime.GOOS == "linux" && runtime.GOARCH == "amd64" && got == "amd64" {
t.Fatal("arch is GOARCH 'amd64'; the fleet convention is 'x86_64'")
}
t.Logf("detectArch=%q (GOARCH=%q)", got, runtime.GOARCH)
}
// buildRegistration must carry this host's detected arch (uname -m), cores (NumCPU)
// and RAM — so spark reports aarch64 and evo-2 reports x86_64, both ~128 GB, matching
// how the same machines already report as code-linked run-targets.
func TestBuildRegistrationCarriesHostSpec(t *testing.T) {
const mem = int64(137438953472) // 128 GiB
w := &worker{hostname: "spark", jobsNS: "gpu-jobs", arch: "aarch64", memory: mem}
reg := w.buildRegistration()
if reg.Arch != "aarch64" {
t.Fatalf("Arch = %q, want the worker's detected arch %q", reg.Arch, "aarch64")
}
if reg.CPUs != runtime.NumCPU() {
t.Fatalf("CPUs = %d, want runtime.NumCPU %d", reg.CPUs, runtime.NumCPU())
}
if reg.Memory != mem {
t.Fatalf("Memory = %d, want the detected total %d", reg.Memory, mem)
}
}
+199
View File
@@ -0,0 +1,199 @@
package cli
// link.go — `hanzo link | unlink | status`: bring THIS machine into the Hanzo
// cloud fleet AS A NODE, take it back out, and view the fleet.
//
// A node is two orthogonal memberships, composed under one verb:
//
// 1. The FABRIC — hanzod on hanzo.network. `link` starts it by invoking the
// canonical fabric verb `hanzo node up` (the Rust node CLI: resolve a hanzod
// binary, spawn it detached, record its pid). link does NOT reimplement hanzod
// supervision — there is exactly one way to start hanzod, and this composes it.
// Best-effort: a node with no hanzod still joins the compute fleet (CPU-only
// boxes and dev machines link fine); `--no-fabric` skips it outright.
//
// 2. The COMPUTE fleet — this machine's inventory (CPU cores + model, memory, and
// each GPU as its own resource) registered as a heartbeating presence in the
// org's `fleet` namespace, running the outbound worker loop that claims jobs
// from `gpu-jobs`. That machinery lives in gpu.go (runConnect); `link` runs it.
//
// `unlink` reverses both: it deregisters the worker (drops the fleet row), then
// stops the fabric (`hanzo node stop`). `status` is the fleet view — every machine
// with each of its GPUs shown distinctly, this box highlighted.
//
// One identity: the IAM token `hanzo login` mints authorizes every cloud call; the
// server derives the tenant from the token. No secrets on the box.
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"time"
"github.com/spf13/cobra"
)
// ---------------------------------------------------------------------------
// link / unlink / status — the node-level command surface.
// ---------------------------------------------------------------------------
func newLinkCmd(envOf func() *Env, _ *globalFlags) *cobra.Command {
var opts connectOpts
var daemon bool
var noFabric bool
cmd := &cobra.Command{
Use: "link",
Short: "Bring this machine into the Hanzo cloud fleet as a node",
Long: "Link this machine into the Hanzo cloud as a node: join the fabric (start\n" +
"hanzod on hanzo.network) and register as a compute worker — advertising this\n" +
"host's CPU (cores + model), memory, and each GPU as its own resource, then\n" +
"heartbeating and claiming jobs from your org's queue. The node shows up in the\n" +
"console (Machines + GPUs) and on `hanzo status`. Works on a CPU-only box.\n" +
"Authentication reuses the `hanzo login` token; the org is taken from its claims.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if daemon {
return installDaemon(cmd, opts)
}
// 1. Fabric: start hanzod via the canonical `hanzo node up` (best-effort).
startFabric(cmd, noFabric)
// 2. Compute fleet: register inventory, heartbeat, claim jobs (foreground).
return runConnect(cmd, envOf(), opts)
},
}
f := cmd.Flags()
f.StringVar(&opts.jobsNS, "jobs-namespace", defaultJobsNS, "tasks namespace to claim jobs from")
f.BoolVar(&noFabric, "no-fabric", false, "join the compute fleet only; do not start hanzod")
f.BoolVar(&daemon, "daemon", false, "install a systemd --user unit (Restart=always) instead of running in the foreground")
f.BoolVar(&opts.serveEngine, "serve-engine", false, "also advertise a hanzo-engine model server (OpenAI + Anthropic) running on this node")
f.StringVar(&opts.engineURL, "engine-url", defaultEngineURL, "local URL where hanzo-engine is probed (GET /v1/models)")
f.StringVar(&opts.engineEndpoint, "engine-endpoint", "", "public URL to advertise for gateway routing (defaults to --engine-url; a node behind NAT needs a reachable URL/tunnel)")
f.BoolVar(&opts.registerProvider, "register-provider", false, "auto-register the engine endpoint as an org model provider (POST /v1/add-provider)")
f.StringVar(&opts.studioDir, "studio-dir", os.Getenv("HANZO_STUDIO_DIR"), "local Hanzo Studio checkout; when set, link launches and supervises the render backend on 127.0.0.1:8188")
f.StringVar(&opts.studioURL, "studio-url", firstNonEmpty(os.Getenv("HANZO_STUDIO_UPLOAD_URL"), defaultStudioUploadURL), "studio base URL the render mirror uploads finished images to (POST /v1/library/upload)")
return cmd
}
func newUnlinkCmd(envOf func() *Env, _ *globalFlags) *cobra.Command {
return &cobra.Command{
Use: "unlink",
Short: "Take this machine out of the fleet (deregister + stop hanzod)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
// Reverse of link, both best-effort: drop the compute-fleet row, then
// ALWAYS stop the fabric so a deregister error never leaves hanzod
// running. The deregister error is reported, not short-circuited.
derr := runDisconnect(cmd, envOf())
stopFabric(cmd)
return derr
},
}
}
func newStatusCmd(envOf func() *Env, _ *globalFlags) *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Show the org's fleet — every machine with each of its GPUs (this box highlighted)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error { return runFleetStatus(cmd, envOf()) },
}
}
// ---------------------------------------------------------------------------
// Fabric — compose the canonical `hanzo node up` / `node stop`.
// ---------------------------------------------------------------------------
// fabricCLI resolves the Hanzo node CLI that owns hanzod supervision — the Rust
// fabric/dev CLI with `node up/stop`. In the canonical layout the Go unified binary
// takes the `hanzo` name and the Rust CLI is installed alongside it as `hanzo-node`,
// so `link` composes `node up` without shelling into itself (this binary has no
// `node`). Resolution order: HANZO_FABRIC_CLI, then `hanzo-node`, then a
// self-guarded `hanzo` (for boxes where the Rust CLI still holds the `hanzo` name).
// Returns "" when none is resolvable.
func fabricCLI() string {
if p := os.Getenv("HANZO_FABRIC_CLI"); p != "" {
return p
}
if p, err := exec.LookPath("hanzo-node"); err == nil {
return p
}
p, err := exec.LookPath("hanzo")
if err != nil {
return ""
}
if self, err := os.Executable(); err == nil {
if sp, _ := os.Readlink(p); sp == self || p == self {
return ""
}
}
return p
}
// Passthrough delegates a verb this binary does not own — node, dev, wallet,
// network, … — to the Rust fabric/dev CLI (resolved by fabricCLI, installed as
// `hanzo-node`), so the single `hanzo` name is a SUPERSET: Go verbs served natively,
// everything else handed through unchanged. `hanzo node up` (the fabric `link`
// itself composes) works for users too. It runs the delegate to completion with
// inherited stdio and exits with its code; it returns false only when no fabric CLI
// is resolvable, so the caller can report unknown-subcommand.
func Passthrough(args []string) bool {
bin := fabricCLI()
if bin == "" {
return false
}
c := exec.Command(bin, args...)
c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr
if err := c.Run(); err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
os.Exit(ee.ExitCode())
}
fmt.Fprintf(os.Stderr, "hanzo: delegating %v to %s: %v\n", args, bin, err)
os.Exit(1)
}
os.Exit(0)
return true
}
// startFabric starts hanzod by invoking `hanzo node up` — the one canonical way to
// join the fabric. Best-effort by design: a missing CLI or a missing hanzod prints
// a clear note and the node still joins the compute fleet. `--no-fabric` skips it.
func startFabric(cmd *cobra.Command, skip bool) {
out := cmd.OutOrStdout()
if skip {
fmt.Fprintln(out, "fabric: skipped (--no-fabric); joining the compute fleet only")
return
}
bin := fabricCLI()
if bin == "" {
fmt.Fprintln(out, "fabric: hanzo node CLI not found — joining the compute fleet only")
fmt.Fprintln(out, " (install the `hanzo` node CLI or set HANZO_FABRIC_CLI to start hanzod)")
return
}
ctx, cancel := context.WithTimeout(cmd.Context(), 60*time.Second)
defer cancel()
c := exec.CommandContext(ctx, bin, "node", "up")
c.Stdout, c.Stderr = out, cmd.ErrOrStderr()
if err := c.Run(); err != nil {
fmt.Fprintf(out, "fabric: `%s node up` did not start hanzod (%v) — joining the compute fleet only\n", bin, err)
}
}
// stopFabric stops the hanzod this box started, via the canonical `hanzo node stop`.
// Best-effort — nothing to stop is not an error.
func stopFabric(cmd *cobra.Command) {
out := cmd.OutOrStdout()
bin := fabricCLI()
if bin == "" {
return
}
ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
defer cancel()
c := exec.CommandContext(ctx, bin, "node", "stop")
c.Stdout, c.Stderr = out, cmd.ErrOrStderr()
_ = c.Run()
}
+58
View File
@@ -0,0 +1,58 @@
package cli
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/spf13/cobra"
)
// TestUnlinkIdempotent — a repeat `hanzo unlink` is a no-op. The deregister POST
// hitting an already-terminal (409) or absent (404) fleet row is the desired end
// state, so runDisconnect returns nil (not an error) and says so, letting `unlink`
// be run twice safely.
func TestUnlinkIdempotent(t *testing.T) {
for _, code := range []int{http.StatusConflict, http.StatusNotFound} {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(code)
_, _ = w.Write([]byte(`{"error":"activity terminal"}`))
}))
t.Setenv("HANZO_TOKEN", "test-token") // ensureToken honors this; no login needed
var out bytes.Buffer
cmd := &cobra.Command{}
cmd.SetContext(context.Background())
cmd.SetOut(&out)
if err := runDisconnect(cmd, &Env{CloudURL: srv.URL}); err != nil {
t.Errorf("HTTP %d: runDisconnect should be idempotent (nil), got %v", code, err)
}
if !strings.Contains(out.String(), "already unlinked") {
t.Errorf("HTTP %d: want 'already unlinked' notice, got %q", code, out.String())
}
srv.Close()
}
}
// TestUnlinkDeregisterErrorSurfaces — a non-terminal deregister failure (e.g. 500)
// is a real error and must be returned, not swallowed as idempotent success.
func TestUnlinkDeregisterErrorSurfaces(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"boom"}`))
}))
defer srv.Close()
t.Setenv("HANZO_TOKEN", "test-token")
cmd := &cobra.Command{}
cmd.SetContext(context.Background())
cmd.SetOut(&bytes.Buffer{})
if err := runDisconnect(cmd, &Env{CloudURL: srv.URL}); err == nil {
t.Fatal("a 500 deregister must surface an error, got nil")
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ package cli
// org for queued workflow jobs and spawns ephemeral, auto-exiting actions-runner
// subprocesses tagged with this box's labels (GPU / vulkan aware). Outbound only —
// nothing listens for inbound. Completes the trifecta: `engine` serves models,
// `gpu connect` shares compute, `runner` claims CI — one binary, one org login.
// `link` shares compute, `runner` claims CI — one binary, one org login.
import (
"os/signal"
+17 -4
View File
@@ -1,5 +1,5 @@
// studio.go — local Hanzo Studio render-backend supervision for `hanzo gpu
// connect --studio-dir <checkout>`. The gpu-jobs claim loop renders on the
// studio.go — local Hanzo Studio render-backend supervision for `hanzo link
// --studio-dir <checkout>`. The gpu-jobs claim loop renders on the
// LOCAL studio server (127.0.0.1:8188); this keeps that server alive so the
// box needs no separate watchdog script or hand-rolled systemd unit — the
// hanzo CLI is the one way a BYO box joins the fleet, render backend included.
@@ -67,9 +67,22 @@ func launchStudio(dir string) (*exec.Cmd, error) {
if err != nil {
return nil, err
}
// --listen 127.0.0.1 (loopback only) + --worker-mode: the render backend serves
// the fleet worker on this box and nothing else. The worker dials loopback
// (localComfyUI) so binding wider bought nothing but an open, unauthenticated
// /prompt — the hidden-run hole. --worker-mode makes the studio gate its submit
// seam (/v1/worker/execute + X-Worker-Token) so only the worker can start a render.
// VRAM mode: default --normalvram (safe for smaller BYO GPUs); override with
// HANZO_STUDIO_VRAM (e.g. "--highvram") on big-memory boxes (GB10 128G unified) so
// the Qwen text-encoder stays resident on-GPU instead of non-deterministically
// offloading to CPU — offload makes renders CPU-bound and ~8x slower.
vramMode := os.Getenv("HANZO_STUDIO_VRAM")
if vramMode == "" {
vramMode = "--normalvram"
}
cmd := exec.Command(studioPython(dir), "main.py",
"--listen", "0.0.0.0", "--port", "8188",
"--normalvram", "--disable-auto-launch",
"--listen", "127.0.0.1", "--port", "8188", "--worker-mode",
vramMode, "--disable-auto-launch",
"--output-directory", filepath.Join(dir, "output"))
cmd.Dir = dir
cmd.Env = append(os.Environ(),
+6
View File
@@ -350,3 +350,9 @@ func s2sBillingCall(c *zip.Ctx) bool {
bearer := strings.TrimSpace(strings.TrimPrefix(c.Header("Authorization"), "Bearer "))
return bearer != "" && subtle.ConstantTimeCompare([]byte(bearer), []byte(token)) == 1
}
// IsServiceToken is the exported view of s2sBillingCall — whether the request is a trusted
// in-proc S2S caller bearing the verified COMMERCE_SERVICE_TOKEN. Used by co-resident route
// gates (e.g. the spend-alert admin gate) that must admit the metering cap-gate and the
// SuperAdmin cap-oversight Forward alongside org admins, while refusing a plain member.
func IsServiceToken(c *zip.Ctx) bool { return s2sBillingCall(c) }
+75
View File
@@ -0,0 +1,75 @@
// billing_coresident.go — PinBillingSubject, the subject-pinning middleware that lets
// commerce's OWN billing READ handlers serve co-resident in the unified cloud binary.
//
// WHY IT EXISTS. Co-resident, commerce is EMBEDDED (apps/commerce.go mountCommerce →
// commerce.Embed on the shared zip app), and in prod there is NO standalone commerce
// backend — the in-cluster `commerce` Service selects the cloud pods themselves. So the
// /v1/billing/* bridge (billing.go), which forwards to COMMERCE_URL, has nowhere to send
// a read but back into cloud: the default base (the public api.hanzo.ai edge) re-enters
// the same bridge in an unbounded self-dispatch loop that surfaces as a 502
// ("billing upstream unreachable: Get https://api.hanzo.ai/v1/billing/<path>"). The fix
// is to serve those reads co-resident from the embedded commerce — the same co-resident
// move mountCommerce already makes for GET /v1/billing/plans — so the specific route
// shadows the bridge wildcard (order 100 < 122) and never leaves the process.
//
// WHAT IT GUARANTEES. commerce's read handlers scope to the org by NAMESPACE (from the
// gateway-validated X-Org-Id via iammiddleware) but filter finer scope (the billing
// subject) only from a query param — an UNPINNED ListInvoices returns every user's rows
// in the org namespace. The /v1/billing/* bridge is what pins that subject today; this
// middleware carries the SAME pin onto the co-resident route so the isolation is
// byte-for-byte the shipped behavior. It is the ONE subject rule (account.Payer, the same
// function the ai spend-gate and the top-up resolve), fed the account the credential NAMES
// — so a read scopes to exactly the account the gate debits, never wider.
package account
import (
"net/url"
"github.com/hanzoai/account"
"github.com/hanzoai/cloud/clients/principal"
"github.com/zap-proto/zip"
)
// PinBillingSubject pins every billing subject key in the request query to the VALIDATED
// caller's own subject (dropping ?org), so a co-resident commerce read handler downstream
// can only ever return the caller's OWN rows. It is the co-resident twin of billingData's
// subject-pinning, reusing the SAME resolveCaller → account.Payer rule and the SAME
// scopedBillingSearch, so the two paths scope identically.
//
// Three cases, mirroring billingData exactly:
// - Browser customer (a validated principal with an org): OVERWRITE the subject keys
// with the caller's own subject and drop ?org. The client cannot widen scope.
// - Trusted in-proc S2S (the verified COMMERCE_SERVICE_TOKEN bearer, carrying its own
// X-Org-Id): pass the query through VERBATIM — it legitimately names its own subject,
// scoped by the EdgeAuth-controlled org.
// - Neither: refuse. A bearer-less request with a forged X-Org-Id has no validated
// principal and is fail-closed here, before the read handler runs.
//
// The pin rewrites the request URI's query string in place; fasthttp's SetQueryString
// resets the parsed-args cache, so the handler's later c.Query() reads the pinned values.
func PinBillingSubject() zip.Handler {
return func(c *zip.Ctx) error {
inQuery, _ := url.ParseQuery(string(c.Fiber().Request().URI().QueryString()))
cr, ok := resolveCaller(c, true)
if !ok {
// Not a validated customer — admit ONLY a trusted in-proc S2S caller that
// names its own org (same admission billingData makes), leaving its query
// untouched. Everything else is refused before the read runs.
if s2sBillingCall(c) && c.Org() != "" {
return c.Next()
}
return zip.ErrForbidden("sign in to view billing")
}
subject := account.Payer(account.Credential{
Owner: cr.owner,
Name: cr.username,
Account: principal.BillingAccount(c),
}).Subject()
c.Fiber().Request().URI().SetQueryString(scopedBillingSearch(inQuery, subject).Encode())
return c.Next()
}
}
+111
View File
@@ -0,0 +1,111 @@
package account
import (
"encoding/json"
"net/http"
"testing"
"github.com/hanzoai/cloud"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// billing_coresident_test.go — proves PinBillingSubject carries the SAME tenant scoping
// onto a co-resident commerce read handler that billingData applies on the bridge: the
// caller's own subject is pinned into the query (dropping ?org), an unvalidated caller is
// refused before the handler runs, and a trusted in-proc S2S caller passes through
// verbatim. This is what lets commerce's ListInvoices/ListBillingSubscriptions/... serve
// in-process without leaking another subject's rows.
// echoQuery is the downstream stand-in for a commerce read handler: it reports exactly the
// query it observes AFTER the pin, so a test can assert the subject the handler would filter on.
func echoQuery(c *zip.Ctx) error {
return c.JSON(200, map[string]any{
"user": c.Query("user"),
"userId": c.Query("userId"),
"customerId": c.Query("customerId"),
"org": c.Query("org"),
"status": c.Query("status"),
})
}
func pinApp(t *testing.T) *zip.App {
t.Helper()
app := zip.New(zip.Config{Logger: luxlog.New("test")})
// MountAccount installs the identity middleware PinBillingSubject relies on; mounting
// it keeps the probe on the same trust plane as the real co-resident registration.
if err := MountAccount(app, cloud.Deps{Logger: luxlog.New("test"), Brand: "hanzo"}); err != nil {
t.Fatalf("MountAccount: %v", err)
}
app.Get("/probe", PinBillingSubject(), echoQuery)
return app
}
// TestPinBillingSubject_PinsCallerAndDropsOrg — a validated customer's forged subject
// keys are overwritten with its OWN subject and ?org is dropped, exactly like the bridge.
func TestPinBillingSubject_PinsCallerAndDropsOrg(t *testing.T) {
app := pinApp(t)
code, body := callH(t, app, http.MethodGet,
"/probe?userId=victim&customerId=victim&user=victim&org=othercorp&status=open", alice, "")
if code != http.StatusOK {
t.Fatalf("want 200, got %d (%s)", code, body)
}
var got map[string]string
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("bad body: %s", body)
}
for _, k := range billingSubjectKeys {
if got[k] != "acme" { // alice/acme resolves to the org subject "acme"
t.Fatalf("handler must see %s=acme (caller's own subject), got %q", k, got[k])
}
}
if got["org"] != "" {
t.Fatalf("org must be dropped, handler saw org=%q", got["org"])
}
if got["status"] != "open" {
t.Fatalf("non-subject filter must survive, got status=%q", got["status"])
}
}
// TestPinBillingSubject_RefusesUnvalidated — a forged X-Org-Id with NO validated
// X-User-Id (and no service token) is refused before the read handler runs: no
// cross-tenant billing read is possible.
func TestPinBillingSubject_RefusesUnvalidated(t *testing.T) {
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-tok")
app := pinApp(t)
code, _ := callH(t, app, http.MethodGet, "/probe?userId=victim",
map[string]string{"X-Org-Id": "victim"}, "")
if code != http.StatusForbidden {
t.Fatalf("unvalidated caller: want 403, got %d", code)
}
}
// TestPinBillingSubject_S2SForwardsVerbatim — the trusted in-proc S2S caller (verified
// COMMERCE_SERVICE_TOKEN + its own X-Org-Id, no validated user) is admitted and its query
// is left UNTOUCHED, so it can name its own subject (the cap-gate's authorize read).
func TestPinBillingSubject_S2SForwardsVerbatim(t *testing.T) {
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-tok")
app := pinApp(t)
code, body := callH(t, app, http.MethodGet, "/probe?user=acme&status=open",
map[string]string{"Authorization": "Bearer svc-tok", "X-Org-Id": "acme"}, "")
if code != http.StatusOK {
t.Fatalf("S2S: want 200, got %d (%s)", code, body)
}
var got map[string]string
_ = json.Unmarshal(body, &got)
if got["user"] != "acme" || got["status"] != "open" {
t.Fatalf("S2S query must pass through verbatim, got %+v", got)
}
}
// TestPinBillingSubject_S2SNoOrgRefused — the service token with NO X-Org-Id has no org
// to scope the privileged read and is refused (matches billingData's s2s org requirement).
func TestPinBillingSubject_S2SNoOrgRefused(t *testing.T) {
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-tok")
app := pinApp(t)
code, _ := callH(t, app, http.MethodGet, "/probe",
map[string]string{"Authorization": "Bearer svc-tok"}, "")
if code != http.StatusForbidden {
t.Fatalf("S2S without X-Org-Id: want 403, got %d", code)
}
}
+27 -20
View File
@@ -90,36 +90,43 @@ func Mount(app *zip.App, deps cloud.Deps) error {
// control plane behind core.Guard. Each carved-out domain (audit/customer/revenue/finance)
// owns its own route registration.
func routes(app *zip.App, s *cloud.Service[core.State]) {
g := app.Group("/v1/admin")
// Org-scoped panels — GuardScoped. Cross-tenant reads are impossible for a non-super
// caller.
app.Get("/v1/admin/me", core.GuardScoped(s, me))
app.Get("/v1/admin/overview", core.GuardScoped(s, overview))
app.Get("/v1/admin/orgs", core.GuardScoped(s, orgs))
app.Get("/v1/admin/users", core.GuardScoped(s, users))
app.Get("/v1/admin/usage", core.GuardScoped(s, usage))
g.Get("/me", core.GuardScoped(s, me))
g.Get("/overview", core.GuardScoped(s, overview))
g.Get("/orgs", core.GuardScoped(s, orgs))
g.Get("/users", core.GuardScoped(s, users))
g.Get("/usage", core.GuardScoped(s, usage))
// Platform reads — SuperAdmin only (cross-tenant by nature).
app.Get("/v1/admin/roles", core.Guard(s, roles))
app.Get("/v1/admin/applications", core.Guard(s, applications))
app.Get("/v1/admin/products", core.Guard(s, products))
app.Get("/v1/admin/compute", core.Guard(s, compute))
app.Get("/v1/admin/o11y", core.Guard(s, o11y))
app.Post("/v1/admin/sync", core.Guard(s, syncNow))
g.Get("/roles", core.Guard(s, roles))
g.Get("/applications", core.Guard(s, applications))
g.Get("/products", core.Guard(s, products))
g.Get("/compute", core.Guard(s, compute))
g.Get("/o11y", core.Guard(s, o11y))
g.Get("/aimetrics", core.Guard(s, aimetrics))
g.Post("/sync", core.Guard(s, syncNow))
// Credit grants — the ONE admin mint surface (SuperAdmin only). Thin, audited
// relay to commerce's mint-gated POST /v1/billing/credit-grants; commerce is the
// sole ledger. See creditgrant.go.
g.Post("/credit-grants", core.Guard(s, createCreditGrant))
// Product analytics — org-scoped (SuperAdmin: all-orgs; org admin: their own org).
app.Get("/v1/admin/analytics", core.GuardScoped(s, analytics))
g.Get("/analytics", core.GuardScoped(s, analytics))
// Bases — the tenant Base-instance panel, org-scoped (bases.go).
app.Get("/v1/admin/bases", core.GuardScoped(s, bases))
g.Get("/bases", core.GuardScoped(s, bases))
// ── Platform control plane — SuperAdmin ONLY (launch/release/flags + access). ──
app.Get("/v1/admin/flags", core.Guard(s, flagsBoard))
app.Put("/v1/admin/flags/:key", core.Guard(s, setFlag))
g.Get("/flags", core.Guard(s, flagsBoard))
g.Put("/flags/:key", core.Guard(s, setFlag))
// Launch-control services board — the waitlist-mode lens on the flag engine (twin
// of /v1/admin/flags), 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))
g.Get("/services", core.Guard(s, services))
g.Post("/services", core.Guard(s, upsertService))
g.Post("/services/:service/mode", core.Guard(s, setServiceMode))
g.Get("/waitlist", core.Guard(s, waitlist))
g.Post("/waitlist/boost", core.Guard(s, waitlistBoost))
// Usage-cap + promo control plane (promos platform-only; spend-caps org-scoped).
limitRoutes(app, s)
+3 -3
View File
@@ -24,16 +24,16 @@ import (
// mount builds a zip app with admin mounted against the given upstream bases,
// and returns a `do` helper that issues test requests through the whole app.
func mount(t *testing.T, iamURL, commerceURL, healthURL string) func(method, path string, hdr map[string]string) (*http.Response, []byte) {
do, _, _ := mountSvc(t, iamURL, commerceURL, healthURL)
do, _, _ := mountService(t, iamURL, commerceURL, healthURL)
return do
}
// mountSvc is mount but also returns the underlying cloud.Service[state] (so finance tests can swap
// mountService is mount but also returns the underlying cloud.Service[state] (so finance tests can swap
// in a fake DigitalOcean client, and the cockpit tests can attach an audit store)
// AND the raw fiber app (so tests that need a request BODY can drive it directly —
// the returned `do` sends a nil body). The handlers read s.* live at request time,
// so an override before issuing a request takes effect.
func mountSvc(t *testing.T, iamURL, commerceURL, healthURL string) (func(method, path string, hdr map[string]string) (*http.Response, []byte), *cloud.Service[core.State], *fiber.App) {
func mountService(t *testing.T, iamURL, commerceURL, healthURL string) (func(method, path string, hdr map[string]string) (*http.Response, []byte), *cloud.Service[core.State], *fiber.App) {
t.Helper()
app := zip.New(zip.Config{Logger: luxlog.New("test")})
s := &cloud.Service[core.State]{State: core.State{
+392
View File
@@ -0,0 +1,392 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package admin
// aimetrics — GET /v1/admin/aimetrics, the GLOBAL fleet-wide AI / training / eval
// read that powers the operator's AI-metrics board on admin.hanzo.ai. It is the
// AI-and-eval-focused companion to o11y (o11y.go): where o11y answers "how is the
// FLEET behaving" (RED metrics, logs, usage), this answers "how are the MODELS and
// EVALS doing" — LLM generations, per-model spend, and eval-run quality/progress —
// over the SAME ONE datastore (Datastore), the SAME shared client
// (aiobject.DatastoreQuery), no second connection.
//
// Signals, each from its canonical table in the one datastore:
// - LLM generations → langfuse.observations : generations, cost (USD), latency
// (fleet-wide; honest-empty until the
// Langfuse ingest lands rows)
// - Per-model usage → hanzo.cloud_usage : requests, tokens, cost per model
// (the live usage ledger the ai gateway
// writes — populated today)
// - Eval runs → hanzo.eval_traces : traces, runs, datasets, models under
// test, per-trace latency
// - Eval progress → hanzo.eval_scores : score count, avg score, per-score-name
// distribution, recent-run averages, and
// the avg-score-over-time TREND — the
// training/eval progress signal
//
// The eval_traces / eval_scores tables are OWNED and written by the eval telemetry
// store (clients/eval/telemetry.go) — the SAME warehouse, same db ("hanzo"), same
// shared aiobject client. admin only READS them here. There is deliberately no
// "training_progress" table: the router's per-request training events live in the ai
// OLTP Postgres (object.RoutingEvent), NOT the OLAP warehouse, so the honest
// warehouse-side progress signal is the eval-score trend, not a routing table.
//
// SUPERADMIN ONLY (the core.Guard wrap in admin.go), all-orgs, no org filter — the
// one place a fleet operator crosses tenants for AI/eval metrics; a non-admin bearer
// is refused 403 before a single row is read. Fail-closed.
//
// Honest by construction, exactly like o11y/compute: no datastore connected → the
// real empty aggregate, never a fabricated fleet; and every signal degrades
// INDEPENDENTLY — a table that is absent or a column that differs contributes its
// zero-value (the enclosing `if err == nil`), never a failure, so the board always
// renders what the datastore actually holds. admin READS only; it owns and creates
// NO table. Money from cloud_usage is USD cents, from langfuse is USD; latency is
// milliseconds; time bounds are POSITIONAL parameters (never interpolated), and the
// bucket interval is a server-side constant — injection-safe.
import (
"strconv"
"time"
aiobject "github.com/hanzoai/ai/object"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/admin/core"
"github.com/zap-proto/zip"
)
// Fully-qualified datastore tables. admin only READS these — the ai gateway owns
// hanzo.cloud_usage, Langfuse owns langfuse.observations, and the eval telemetry
// store (clients/eval) owns hanzo.eval_traces / hanzo.eval_scores.
const (
aimUsageTable = "hanzo.cloud_usage"
aimLangfuseObs = "langfuse.observations"
aimEvalTraces = "hanzo.eval_traces"
aimEvalScores = "hanzo.eval_scores"
aimTopN = 12
)
// aiMetrics is the whole AI-metrics board payload.
type aiMetrics struct {
Range string `json:"range"`
Start string `json:"start"`
End string `json:"end"`
Langfuse aimLangfuse `json:"langfuse"`
Usage aimUsage `json:"usage"`
Evals aimEvals `json:"evals"`
TopModels []aimModelStat `json:"topModels"` // cloud_usage per-model (populated today)
LangfuseModels []aimLfModelStat `json:"langfuseModels"` // langfuse per-model (honest-empty today)
ScoreNames []aimScoreStat `json:"scoreNames"` // eval_scores per score-name
EvalRuns []aimRunStat `json:"evalRuns"` // recent eval runs (progress)
ScoreSeries []aimScorePoint `json:"scoreSeries"` // avg eval score over time (progress trend)
}
// aimLangfuse is the fleet-wide Langfuse generation rollup (honest-empty today).
// Cost is USD (Langfuse's native unit); latency is milliseconds (end_time-start_time).
type aimLangfuse struct {
Generations int64 `json:"generations"`
CostUsd float64 `json:"costUsd"`
LatencyMsAvg float64 `json:"latencyMsAvg"`
LatencyMsP95 float64 `json:"latencyMsP95"`
}
// aimUsage is the fleet LLM-usage KPI band from the live cloud_usage ledger.
type aimUsage struct {
Requests int64 `json:"requests"`
Tokens int64 `json:"tokens"`
PromptTokens int64 `json:"promptTokens"`
CompletionTokens int64 `json:"completionTokens"`
CostCents int64 `json:"costCents"`
Models int64 `json:"models"`
}
// aimEvals is the fleet eval KPI band: the trace half (eval_traces) and the score
// half (eval_scores). LatencyMsAvg is the mean model-under-test call window.
type aimEvals struct {
Runs int64 `json:"runs"`
Traces int64 `json:"traces"`
Datasets int64 `json:"datasets"`
Models int64 `json:"models"`
LatencyMsAvg float64 `json:"latencyMsAvg"`
Scores int64 `json:"scores"`
ScoreNames int64 `json:"scoreNames"`
AvgScore float64 `json:"avgScore"`
}
// aimModelStat is one row of the per-model usage leaderboard (cloud_usage).
type aimModelStat struct {
Model string `json:"model"`
Requests int64 `json:"requests"`
Tokens int64 `json:"tokens"`
CostCents int64 `json:"costCents"`
}
// aimLfModelStat is one row of the per-model Langfuse leaderboard (honest-empty today).
type aimLfModelStat struct {
Model string `json:"model"`
Generations int64 `json:"generations"`
CostUsd float64 `json:"costUsd"`
}
// aimScoreStat is one row of the per-score-name eval leaderboard (eval_scores).
type aimScoreStat struct {
Name string `json:"name"`
Count int64 `json:"count"`
AvgValue float64 `json:"avgValue"`
MinValue float64 `json:"minValue"`
MaxValue float64 `json:"maxValue"`
}
// aimRunStat is one recent eval run: its dataset, how many scores it recorded, its
// mean score, and when it last ran — the run-level eval-progress row.
type aimRunStat struct {
RunName string `json:"runName"`
Dataset string `json:"dataset"`
Scores int64 `json:"scores"`
AvgValue float64 `json:"avgValue"`
LastTs string `json:"lastTs"`
}
// aimScorePoint is one bucket of the avg-eval-score-over-time trend.
type aimScorePoint struct {
Ts string `json:"ts"`
AvgValue float64 `json:"avgValue"`
Count int64 `json:"count"`
}
// aimetrics answers GET /v1/admin/aimetrics. ?range=24h|7d|30d bounds the window
// (default 30d). SUPERADMIN ONLY (core.Guard). Every signal degrades independently:
// a table that is absent or errors contributes its zero-value, never a failure — the
// board always renders what the datastore actually holds.
func aimetrics(s *cloud.Service[core.State], c *zip.Ctx) error {
ctx := c.Context()
rangeLabel := o11yRange(c.Query("range"))
since := computeSince(rangeLabel)
payload := aiMetrics{
Range: rangeLabel,
Start: since.Format(time.RFC3339),
End: time.Now().UTC().Format(time.RFC3339),
TopModels: []aimModelStat{},
LangfuseModels: []aimLfModelStat{},
ScoreNames: []aimScoreStat{},
EvalRuns: []aimRunStat{},
ScoreSeries: []aimScorePoint{},
}
// Honest-empty when the warehouse is not connected: the board renders its zero
// state, never a fabricated fleet.
if !aiobject.DatastoreEnabled() {
return core.OK(c, payload)
}
sinceTS := chTS(since) // DateTime literal — cloud_usage.timestamp, langfuse.start_time, eval_*.ts
interval := o11yBucket(rangeLabel)
// ── Langfuse generations (fleet) — honest-empty until ingest lands rows ──
if rows, err := aiobject.DatastoreQuery(ctx, aimLangfuseTotalsSQL(), sinceTS); err == nil {
r := firstRowOr(rows)
payload.Langfuse.Generations = chInt64(r["gens"])
payload.Langfuse.CostUsd = chFloat64(r["cost"])
}
// Langfuse latency (separate query so a Nullable end_time / column mismatch never
// zeroes the proven generations+cost number above).
if rows, err := aiobject.DatastoreQuery(ctx, aimLangfuseLatencySQL(), sinceTS); err == nil {
r := firstRowOr(rows)
payload.Langfuse.LatencyMsAvg = chFloat64(r["lat_avg"])
payload.Langfuse.LatencyMsP95 = chFloat64(r["lat_p95"])
}
// Langfuse per-model.
if rows, err := aiobject.DatastoreQuery(ctx, aimLangfuseModelsSQL(), sinceTS); err == nil {
payload.LangfuseModels = lfModelsFromRows(rows)
}
// ── Per-model usage (fleet) from the live cloud_usage ledger ──
if rows, err := aiobject.DatastoreQuery(ctx, aimUsageTotalsSQL(), sinceTS); err == nil {
fillAimUsage(&payload.Usage, firstRowOr(rows))
}
if rows, err := aiobject.DatastoreQuery(ctx, aimTopModelsSQL(), sinceTS); err == nil {
payload.TopModels = aimModelsFromRows(rows)
}
// ── Evals (fleet): traces + scores + progress ──
if rows, err := aiobject.DatastoreQuery(ctx, aimEvalTracesSQL(), sinceTS); err == nil {
fillAimEvalTraces(&payload.Evals, firstRowOr(rows))
}
if rows, err := aiobject.DatastoreQuery(ctx, aimEvalScoresSQL(), sinceTS); err == nil {
fillAimEvalScores(&payload.Evals, firstRowOr(rows))
}
if rows, err := aiobject.DatastoreQuery(ctx, aimScoreNamesSQL(), sinceTS); err == nil {
payload.ScoreNames = scoreNamesFromRows(rows)
}
if rows, err := aiobject.DatastoreQuery(ctx, aimEvalRunsSQL(), sinceTS); err == nil {
payload.EvalRuns = evalRunsFromRows(rows)
}
if rows, err := aiobject.DatastoreQuery(ctx, aimScoreSeriesSQL(interval), sinceTS); err == nil {
payload.ScoreSeries = scoreSeriesFromRows(rows)
}
return core.OK(c, payload)
}
// ── pure SQL builders (static SQL + one positional time bound; unit-tested) ──
func aimLangfuseTotalsSQL() string {
return "SELECT count() AS gens, toFloat64(sum(total_cost)) AS cost FROM " + aimLangfuseObs +
" WHERE type = 'GENERATION' AND start_time >= ?"
}
func aimLangfuseLatencySQL() string {
lat := "(toUnixTimestamp64Milli(end_time) - toUnixTimestamp64Milli(start_time))"
return "SELECT round(avg(" + lat + "), 2) AS lat_avg, round(quantile(0.95)(" + lat + "), 2) AS lat_p95 " +
"FROM " + aimLangfuseObs + " WHERE type = 'GENERATION' AND start_time >= ? AND end_time > start_time"
}
func aimLangfuseModelsSQL() string {
return "SELECT provided_model_name AS model, count() AS gens, toFloat64(sum(total_cost)) AS cost " +
"FROM " + aimLangfuseObs + " WHERE type = 'GENERATION' AND start_time >= ? AND provided_model_name != '' " +
"GROUP BY model ORDER BY gens DESC LIMIT " + strconv.Itoa(aimTopN)
}
func aimUsageTotalsSQL() string {
return "SELECT count() AS requests, sum(total_tokens) AS tokens, " +
"sum(prompt_tokens) AS prompt_tokens, sum(completion_tokens) AS completion_tokens, " +
"sum(cost_cents) AS cost_cents, uniqExact(model) AS models " +
"FROM " + aimUsageTable + " WHERE timestamp >= ?"
}
func aimTopModelsSQL() string {
return "SELECT model, count() AS requests, sum(total_tokens) AS tokens, " +
"sum(cost_cents) AS cost_cents FROM " + aimUsageTable +
" WHERE timestamp >= ? AND model != '' GROUP BY model ORDER BY requests DESC LIMIT " + strconv.Itoa(aimTopN)
}
func aimEvalTracesSQL() string {
lat := "(toUnixTimestamp64Milli(end_time) - toUnixTimestamp64Milli(start_time))"
return "SELECT count() AS traces, uniqExact(run_name) AS runs, uniqExact(dataset) AS datasets, " +
"uniqExact(model) AS models, round(avgIf(" + lat + ", end_time > start_time), 2) AS lat_avg " +
"FROM " + aimEvalTraces + " WHERE ts >= ?"
}
func aimEvalScoresSQL() string {
return "SELECT count() AS scores, round(avg(value), 4) AS avg_value, uniqExact(name) AS score_names " +
"FROM " + aimEvalScores + " WHERE ts >= ?"
}
func aimScoreNamesSQL() string {
return "SELECT name, count() AS n, round(avg(value), 4) AS avg_value, " +
"round(min(value), 4) AS min_value, round(max(value), 4) AS max_value " +
"FROM " + aimEvalScores + " WHERE ts >= ? AND name != '' GROUP BY name ORDER BY n DESC LIMIT " + strconv.Itoa(aimTopN)
}
func aimEvalRunsSQL() string {
return "SELECT run_name, any(dataset) AS dataset, count() AS scores, round(avg(value), 4) AS avg_value, " +
"max(ts) AS last_ts FROM " + aimEvalScores + " WHERE ts >= ? AND run_name != '' " +
"GROUP BY run_name ORDER BY last_ts DESC LIMIT " + strconv.Itoa(aimTopN)
}
func aimScoreSeriesSQL(interval string) string {
return "SELECT toStartOfInterval(ts, INTERVAL " + interval + ") AS ts, " +
"round(avg(value), 4) AS avg_value, count() AS n FROM " + aimEvalScores +
" WHERE ts >= ? GROUP BY ts ORDER BY ts"
}
// ── pure row parsers (unit-tested) ──
func fillAimUsage(u *aimUsage, r map[string]any) {
u.Requests = chInt64(r["requests"])
u.Tokens = chInt64(r["tokens"])
u.PromptTokens = chInt64(r["prompt_tokens"])
u.CompletionTokens = chInt64(r["completion_tokens"])
u.CostCents = chInt64(r["cost_cents"])
u.Models = chInt64(r["models"])
}
func fillAimEvalTraces(e *aimEvals, r map[string]any) {
e.Traces = chInt64(r["traces"])
e.Runs = chInt64(r["runs"])
e.Datasets = chInt64(r["datasets"])
e.Models = chInt64(r["models"])
e.LatencyMsAvg = chFloat64(r["lat_avg"])
}
func fillAimEvalScores(e *aimEvals, r map[string]any) {
e.Scores = chInt64(r["scores"])
e.AvgScore = chFloat64(r["avg_value"])
e.ScoreNames = chInt64(r["score_names"])
}
func aimModelsFromRows(rows []map[string]any) []aimModelStat {
out := make([]aimModelStat, 0, len(rows))
for _, r := range rows {
out = append(out, aimModelStat{
Model: chStr(r["model"]),
Requests: chInt64(r["requests"]),
Tokens: chInt64(r["tokens"]),
CostCents: chInt64(r["cost_cents"]),
})
}
return out
}
func lfModelsFromRows(rows []map[string]any) []aimLfModelStat {
out := make([]aimLfModelStat, 0, len(rows))
for _, r := range rows {
out = append(out, aimLfModelStat{
Model: chStr(r["model"]),
Generations: chInt64(r["gens"]),
CostUsd: chFloat64(r["cost"]),
})
}
return out
}
func scoreNamesFromRows(rows []map[string]any) []aimScoreStat {
out := make([]aimScoreStat, 0, len(rows))
for _, r := range rows {
out = append(out, aimScoreStat{
Name: chStr(r["name"]),
Count: chInt64(r["n"]),
AvgValue: chFloat64(r["avg_value"]),
MinValue: chFloat64(r["min_value"]),
MaxValue: chFloat64(r["max_value"]),
})
}
return out
}
func evalRunsFromRows(rows []map[string]any) []aimRunStat {
out := make([]aimRunStat, 0, len(rows))
for _, r := range rows {
out = append(out, aimRunStat{
RunName: chStr(r["run_name"]),
Dataset: chStr(r["dataset"]),
Scores: chInt64(r["scores"]),
AvgValue: chFloat64(r["avg_value"]),
LastTs: chTime(r["last_ts"]),
})
}
return out
}
func scoreSeriesFromRows(rows []map[string]any) []aimScorePoint {
out := make([]aimScorePoint, 0, len(rows))
for _, r := range rows {
out = append(out, aimScorePoint{
Ts: chTime(r["ts"]),
AvgValue: chFloat64(r["avg_value"]),
Count: chInt64(r["n"]),
})
}
return out
}
+171
View File
@@ -0,0 +1,171 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package admin
import (
"strings"
"testing"
)
// TestAimSQL_ReadsCanonicalTables proves every AI-metrics query reads the ONE
// datastore's canonical table, binds the time bound as a POSITIONAL param (one
// `?`), and never interpolates user input. The bucket interval is the only rendered
// value in the series query and it is a server-side constant.
func TestAimSQL_ReadsCanonicalTables(t *testing.T) {
cases := []struct {
name, sql, table string
wantQMarks int
}{
{"langfuseTotals", aimLangfuseTotalsSQL(), "langfuse.observations", 1},
{"langfuseLatency", aimLangfuseLatencySQL(), "langfuse.observations", 1},
{"langfuseModels", aimLangfuseModelsSQL(), "langfuse.observations", 1},
{"usageTotals", aimUsageTotalsSQL(), "hanzo.cloud_usage", 1},
{"topModels", aimTopModelsSQL(), "hanzo.cloud_usage", 1},
{"evalTraces", aimEvalTracesSQL(), "hanzo.eval_traces", 1},
{"evalScores", aimEvalScoresSQL(), "hanzo.eval_scores", 1},
{"scoreNames", aimScoreNamesSQL(), "hanzo.eval_scores", 1},
{"evalRuns", aimEvalRunsSQL(), "hanzo.eval_scores", 1},
{"scoreSeries", aimScoreSeriesSQL("1 DAY"), "hanzo.eval_scores", 1},
}
for _, c := range cases {
if !strings.Contains(c.sql, "FROM "+c.table) {
t.Errorf("%s must read %s; got %q", c.name, c.table, c.sql)
}
if n := strings.Count(c.sql, "?"); n != c.wantQMarks {
t.Errorf("%s: %d bind params, want %d (time bound only) — no interpolation; got %q", c.name, n, c.wantQMarks, c.sql)
}
}
}
// TestAimLangfuseScopedToGeneration proves the Langfuse lens is scoped to
// generations only (not spans/events), matching the o11y LLM lens.
func TestAimLangfuseScopedToGeneration(t *testing.T) {
for _, sql := range []string{aimLangfuseTotalsSQL(), aimLangfuseLatencySQL(), aimLangfuseModelsSQL()} {
if !strings.Contains(sql, "type = 'GENERATION'") {
t.Errorf("langfuse lens must scope to GENERATION observations; got %q", sql)
}
}
}
// TestAimTop_LimitAndOrder proves the leaderboards bound + order the result.
func TestAimTop_LimitAndOrder(t *testing.T) {
if !strings.Contains(aimTopModelsSQL(), "ORDER BY requests DESC LIMIT 12") {
t.Errorf("topModels must order by requests desc, limit %d", aimTopN)
}
if !strings.Contains(aimScoreNamesSQL(), "GROUP BY name ORDER BY n DESC LIMIT 12") {
t.Errorf("scoreNames must group+order+limit %d", aimTopN)
}
if !strings.Contains(aimEvalRunsSQL(), "ORDER BY last_ts DESC LIMIT 12") {
t.Errorf("evalRuns must order by last_ts desc, limit %d", aimTopN)
}
}
// TestAimScoreSeries_IntervalBound proves the (constant) bucket interval is
// rendered into the score-trend series query and grouped/ordered by the bucket.
func TestAimScoreSeries_IntervalBound(t *testing.T) {
for _, iv := range []string{"1 HOUR", "6 HOUR", "1 DAY"} {
s := aimScoreSeriesSQL(iv)
if !strings.Contains(s, "INTERVAL "+iv) || !strings.Contains(s, "GROUP BY ts ORDER BY ts") {
t.Errorf("score series must bucket by INTERVAL %s; got %q", iv, s)
}
}
}
// TestAimEvalLatencyGuarded proves the latency expressions guard end_time>start_time
// so a zero/default end_time never contributes a garbage (negative) latency.
func TestAimEvalLatencyGuarded(t *testing.T) {
if !strings.Contains(aimEvalTracesSQL(), "end_time > start_time") {
t.Errorf("eval traces latency must guard end_time>start_time; got %q", aimEvalTracesSQL())
}
if !strings.Contains(aimLangfuseLatencySQL(), "end_time > start_time") {
t.Errorf("langfuse latency must guard end_time>start_time; got %q", aimLangfuseLatencySQL())
}
}
// TestFillAimUsage reads a cloud_usage row into the KPI band across the numeric
// variants the driver returns (uint64/int64/float64), honest zeros on an empty row.
func TestFillAimUsage(t *testing.T) {
var empty aimUsage
fillAimUsage(&empty, map[string]any{})
if empty.Requests != 0 || empty.Tokens != 0 || empty.Models != 0 {
t.Fatalf("empty row must yield honest zeros; got %+v", empty)
}
var got aimUsage
fillAimUsage(&got, map[string]any{
"requests": uint64(274), "tokens": uint64(102597), "prompt_tokens": uint64(60000),
"completion_tokens": uint64(42597), "cost_cents": uint64(216), "models": uint64(42),
})
if got.Requests != 274 || got.Tokens != 102597 || got.CostCents != 216 || got.Models != 42 {
t.Fatalf("usage totals mis-parsed: %+v", got)
}
}
// TestFillAimEvals maps both eval halves (traces + scores) into the KPI band,
// including the float latency/score columns (round()/avg() land as float64; a
// Decimal-as-string is parsed).
func TestFillAimEvals(t *testing.T) {
var e aimEvals
fillAimEvalTraces(&e, map[string]any{
"traces": uint64(1280), "runs": uint64(16), "datasets": uint64(4),
"models": uint64(6), "lat_avg": float64(842.5),
})
fillAimEvalScores(&e, map[string]any{
"scores": uint64(1280), "avg_value": "0.8125", "score_names": uint64(3),
})
if e.Traces != 1280 || e.Runs != 16 || e.Datasets != 4 || e.Models != 6 || e.LatencyMsAvg != 842.5 {
t.Fatalf("eval traces mis-parsed: %+v", e)
}
if e.Scores != 1280 || e.ScoreNames != 3 || e.AvgScore != 0.8125 { // string→float64 path
t.Fatalf("eval scores mis-parsed: %+v", e)
}
}
// TestAimParsers map datastore rows into the view-models and preserve order (the
// SQL already ORDER BYs; a parser must not reorder or drop rows), with empty input
// yielding an empty (non-nil) slice rather than a panic.
func TestAimParsers(t *testing.T) {
models := aimModelsFromRows([]map[string]any{
{"model": "glm-5.2", "requests": uint64(154), "tokens": uint64(38966), "cost_cents": uint64(114)},
{"model": "deepseek-v4-flash", "requests": uint64(118), "tokens": uint64(61550), "cost_cents": uint64(101)},
})
if len(models) != 2 || models[0].Model != "glm-5.2" || models[1].Model != "deepseek-v4-flash" || models[0].Requests != 154 {
t.Fatalf("top models mis-parsed/reordered: %+v", models)
}
lf := lfModelsFromRows([]map[string]any{
{"model": "gpt-4o", "gens": uint64(42), "cost": float64(1.25)},
})
if len(lf) != 1 || lf[0].Model != "gpt-4o" || lf[0].Generations != 42 || lf[0].CostUsd != 1.25 {
t.Fatalf("langfuse models mis-parsed: %+v", lf)
}
names := scoreNamesFromRows([]map[string]any{
{"name": "accuracy", "n": uint64(320), "avg_value": float64(0.82), "min_value": float64(0), "max_value": float64(1)},
})
if len(names) != 1 || names[0].Name != "accuracy" || names[0].Count != 320 || names[0].AvgValue != 0.82 || names[0].MaxValue != 1 {
t.Fatalf("score names mis-parsed: %+v", names)
}
runs := evalRunsFromRows([]map[string]any{
{"run_name": "nightly-2026-07", "dataset": "gsm8k", "scores": uint64(200), "avg_value": float64(0.9), "last_ts": nil},
})
if len(runs) != 1 || runs[0].RunName != "nightly-2026-07" || runs[0].Dataset != "gsm8k" || runs[0].Scores != 200 || runs[0].AvgValue != 0.9 {
t.Fatalf("eval runs mis-parsed: %+v", runs)
}
// Empty input → empty (non-nil) slices, never a panic.
if got := scoreSeriesFromRows(nil); got == nil || len(got) != 0 {
t.Errorf("nil rows must yield empty slice, got %v", got)
}
if got := aimModelsFromRows(nil); got == nil || len(got) != 0 {
t.Errorf("nil rows must yield empty slice, got %v", got)
}
}
+3 -2
View File
@@ -26,8 +26,9 @@ import (
// Routes registers the /v1/admin/audit* surface (SuperAdmin only).
func Routes(app *zip.App, s *cloud.Service[core.State]) {
app.Get("/v1/admin/audit", core.Guard(s, Records))
app.Get("/v1/admin/audit/verify", core.Guard(s, Verify))
g := app.Group("/v1/admin")
g.Get("/audit", core.Guard(s, Records))
g.Get("/audit/verify", core.Guard(s, Verify))
}
// Records answers GET /v1/admin/audit from cloud's local tamper-evident store when
+1 -1
View File
@@ -203,7 +203,7 @@ func newCockpitFakes(t *testing.T) *cockpitFakes {
}
}))
_, s, fa := mountSvc(t, f.iam.URL, f.commerce.URL, "")
_, s, fa := mountService(t, f.iam.URL, f.commerce.URL, "")
f.service = s
f.do = func(method, path string, hdr map[string]string, body string) (*http.Response, []byte) {
t.Helper()
+16 -229
View File
@@ -33,6 +33,7 @@ import (
"time"
"github.com/hanzoai/cloud/clients/admin/money"
"github.com/hanzoai/cloud/clients/commerceinproc"
)
// errUnconfigured marks a write (Deposit) attempted against an unwired commerce.
@@ -45,12 +46,17 @@ type Client struct {
http *http.Client
}
// New builds a commerce client for base + admin S2S token.
// New builds a commerce client for base + admin S2S token. The HTTP client uses the
// commerceinproc self-routing transport: when commerce is CO-RESIDENT (base is the
// commerce.inproc placeholder) it dispatches in-process — a plain http.Client would
// instead DNS-resolve "commerce.inproc" and fail "no such host", silently breaking the
// admin cost/finance god-view. For a split-deploy (a real commerce URL) it falls
// through to plain HTTP unchanged.
func New(base, token string) *Client {
return &Client{
base: strings.TrimRight(strings.TrimSpace(base), "/"),
token: strings.TrimSpace(token),
http: &http.Client{Timeout: 15 * time.Second},
http: commerceinproc.Client(15 * time.Second),
}
}
@@ -295,235 +301,16 @@ 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
// CreateCreditGrant forwards a credit-grant request verbatim to commerce's
// mint-gated POST /v1/billing/credit-grants (CreateCreditGrant), authenticated
// by the admin service token, with subject as the target-org namespace selector.
// Commerce is the sole credit-grant ledger; this relays its contract untouched
// (the raw response is returned to the caller) so the admin surface stays thin.
func (c *Client) CreateCreditGrant(ctx context.Context, subject string, body []byte, idempotencyKey string) ([]byte, error) {
if !c.Ready() {
return out, nil
return nil, errUnconfigured
}
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
return c.post(ctx, "/v1/billing/credit-grants", subject, body, idempotencyKey)
}
// post performs one admin-authenticated commerce POST (JSON body) and returns the
+220
View File
@@ -0,0 +1,220 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package core
// warehouse — the ONE-copy datastore-read kernel the billing FLEET views
// (metrics/invoices/subscriptions) compose. They read commerce.events — the
// single warehouse table the commerce analytics collector lands every
// customer-activity event in (subscription/invoice/usage lifecycle) — over the
// SAME shared client (aiobject.DatastoreQuery) the o11y/compute/analytics lenses
// already use, no second connection. This mirrors compute.go's row-coercers and
// EXISTS-TABLE probe, hoisted here so the three sibling domains share ONE copy
// instead of each re-deriving it (DRY; the admin-package o11y/compute keep their
// own private copies as the read template).
//
// Every read is honest by construction: no datastore connected, or the events
// table not provisioned (the emitter is still being wired) → the real empty
// aggregate, NEVER a fabricated fleet. admin READS only; it owns and creates NO
// table (the collector owns commerce.events). Time bounds are POSITIONAL
// parameters (never interpolated) so the reads are injection-safe; money is USD
// cents; timestamps are RFC3339.
import (
"context"
"strconv"
"strings"
"time"
aiobject "github.com/hanzoai/ai/object"
)
// BillingEventsTable is the collector-owned warehouse table the commerce
// customer-activity emitters land in (events/client.go → analytics-collector →
// commerce.events). admin only READS it (never creates it — the collector owns
// its writes), exactly as o11y reads hanzo.cloud_usage.
const BillingEventsTable = "commerce.events"
// Canonical customer-activity event names — the CONTRACT with the commerce
// emitters (events/client.go). These are server-side constants (never user
// input), so rendering them into an IN (...) list is injection-safe.
const (
EvSubscriptionCreated = "subscription_created"
EvSubscriptionRenewed = "subscription_renewed"
EvSubscriptionPlanChanged = "subscription_plan_changed"
EvSubscriptionCanceled = "subscription_canceled"
EvInvoiceFinalized = "invoice_finalized"
EvInvoicePaid = "invoice_paid"
EvInvoiceVoid = "invoice_void"
EvAPIUsageDebit = "api_usage_debit"
)
// SubscriptionEvents / InvoiceEvents are the lifecycle sets each fleet view
// folds over (latest-event-wins per entity). Closed server-side constants.
var (
SubscriptionEvents = []string{EvSubscriptionCreated, EvSubscriptionRenewed, EvSubscriptionPlanChanged, EvSubscriptionCanceled}
InvoiceEvents = []string{EvInvoiceFinalized, EvInvoicePaid, EvInvoiceVoid}
)
// WarehouseReady reports whether the shared datastore ledger is connected, the
// gate every fleet read checks first (honest-empty when false).
func WarehouseReady() bool { return aiobject.DatastoreEnabled() }
// BillingEventsReady reports whether the warehouse is connected AND the
// collector's commerce.events table is provisioned — the two-part gate every
// billing fleet view opens with, so an unwired collector degrades to an honest
// empty aggregate rather than an error.
func BillingEventsReady(ctx context.Context) bool {
return aiobject.DatastoreEnabled() && CHTableExists(ctx, BillingEventsTable)
}
// CHTableExists probes the datastore for a table's presence. The name is a
// package constant (never user input), so EXISTS TABLE is safe. Any error →
// false (honest "not available yet"), mirroring compute.computeTableExists.
func CHTableExists(ctx context.Context, qualified string) bool {
rows, err := aiobject.DatastoreQuery(ctx, "EXISTS TABLE "+qualified)
if err != nil || len(rows) == 0 {
return false
}
for _, v := range rows[0] {
return CHInt64(v) == 1
}
return false
}
// SQLInList renders a set of server-side-constant strings as a datastore string
// list ('a','b',…) for an IN (...) clause. ONLY for closed constant sets (the
// event-name enums above) — never for user input; positional args carry all
// caller-derived values.
func SQLInList(vals []string) string {
quoted := make([]string, len(vals))
for i, v := range vals {
quoted[i] = "'" + v + "'"
}
return strings.Join(quoted, ",")
}
// WarehouseSince maps the ?range enum (24h|7d|30d, default 30d) to a lower time
// bound, mirroring compute.computeSince so the fleet views share ONE window
// grammar.
func WarehouseSince(rangeLabel string) time.Time {
now := time.Now().UTC()
switch strings.TrimSpace(rangeLabel) {
case "24h":
return now.Add(-24 * time.Hour)
case "7d":
return now.Add(-7 * 24 * time.Hour)
default:
return now.Add(-30 * 24 * time.Hour)
}
}
// CHTimeLit formats a time as a datastore DateTime literal (UTC), bound as a
// POSITIONAL string arg (never interpolated).
func CHTimeLit(t time.Time) string { return t.UTC().Format("2006-01-02 15:04:05") }
// CHFirstRow returns the first row or an empty map (never nil), so a parser
// reads honest zeros from an empty result instead of panicking.
func CHFirstRow(rows []map[string]any) map[string]any {
if len(rows) == 0 {
return map[string]any{}
}
return rows[0]
}
// ── map[string]any coercers (the DatastoreQuery row shape) ───────────────────
//
// The datastore driver decodes each column to its native Go type (uint64 for
// count()/sum(UInt*), float64 for round()/JSON numerics, time.Time for DateTime,
// string for String); these accept those natives so a driver/transport change
// can't crash a read. Twins of the admin-package compute.go coercers.
func CHInt64(v any) int64 {
switch n := v.(type) {
case int:
return int64(n)
case int64:
return n
case int32:
return int64(n)
case uint:
return int64(n)
case uint64:
return int64(n)
case uint32:
return int64(n)
case uint16:
return int64(n)
case uint8:
return int64(n)
case float64:
return int64(n)
case float32:
return int64(n)
case string:
f, err := strconv.ParseFloat(strings.TrimSpace(n), 64)
if err != nil {
return 0
}
return int64(f)
default:
return 0
}
}
func CHFloat64(v any) float64 {
switch n := v.(type) {
case float64:
return n
case float32:
return float64(n)
case int:
return float64(n)
case int64:
return float64(n)
case int32:
return float64(n)
case uint64:
return float64(n)
case uint32:
return float64(n)
case string:
f, err := strconv.ParseFloat(strings.TrimSpace(n), 64)
if err != nil {
return 0
}
return f
default:
return 0
}
}
func CHStr(v any) string {
if s, ok := v.(string); ok {
return s
}
return ""
}
// CHTime coerces a datastore DateTime (time.Time) to an RFC3339 UTC string.
func CHTime(v any) string {
switch t := v.(type) {
case time.Time:
return t.UTC().Format(time.RFC3339)
case string:
return t
default:
return ""
}
}
+62
View File
@@ -0,0 +1,62 @@
package admin
import (
"encoding/json"
"strings"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/audit"
"github.com/hanzoai/cloud/clients/admin/core"
"github.com/zap-proto/zip"
)
// createCreditGrant is the admin mint surface: POST /v1/admin/credit-grants.
//
// SuperAdmin ONLY (wired through core.Guard). It does NOT mint in-process — it
// forwards the request VERBATIM to commerce's already-mint-gated
// POST /v1/billing/credit-grants (middleware.Mint → PlatformOnly), authenticated
// by COMMERCE_SERVICE_TOKEN and scoped to the target org, and writes ONE
// tamper-evident compliance record. Commerce stays the single credit-grant ledger;
// this is a thin, audited relay so there is exactly one place credit is minted.
//
// The body is commerce's own CreateCreditGrant contract; the only field this layer
// reads is the target org (`org`, or `user` as the org-pool alias) to select the
// per-org namespace commerce's EdgeAuth trusts after verifying the service token.
func createCreditGrant(s *cloud.Service[core.State], c *zip.Ctx) error {
if !s.State.Commerce.Ready() {
return core.Fail(c, "commerce is not configured on this deployment")
}
var req map[string]any
if err := c.Bind(&req); err != nil {
return core.Fail(c, "invalid request body")
}
org, _ := req["org"].(string)
if strings.TrimSpace(org) == "" {
org, _ = req["user"].(string)
}
org = strings.TrimSpace(org)
if org == "" {
return core.Fail(c, "org is required")
}
idempotencyKey, _ := req["idempotencyKey"].(string)
body, err := json.Marshal(req)
if err != nil {
return core.Fail(c, "invalid request body")
}
raw, err := s.State.Commerce.CreateCreditGrant(c.Context(), org, body, idempotencyKey)
if err != nil {
core.EmitAudit(s, c, "admin.customer.credit-grant", "credit-grant", org,
req, map[string]any{"error": err.Error()},
audit.Outcome{Result: "error", Status: 502, Reason: "credit-grant failed"})
return core.Fail(c, "credit-grant failed: "+err.Error())
}
core.EmitAudit(s, c, "admin.customer.credit-grant", "credit-grant", org,
nil, json.RawMessage(raw),
audit.Outcome{Result: "success", Status: 200})
return core.OK(c, json.RawMessage(raw))
}
+8 -7
View File
@@ -10,11 +10,12 @@ import (
// precedes the :org param route; the write actions are POST (distinct method), so none
// collide. The grants ledger + the org-in-body issue-grant share the ONE credit path.
func Routes(app *zip.App, s *cloud.Service[core.State]) {
app.Get("/v1/admin/customers", core.Guard(s, Customers))
app.Get("/v1/admin/customers/:org", core.Guard(s, CustomerDetail))
app.Post("/v1/admin/customers/:org/credit", core.Guard(s, GrantCredit))
app.Get("/v1/admin/grants", core.Guard(s, Grants))
app.Post("/v1/admin/grants", core.Guard(s, IssueGrant))
app.Post("/v1/admin/customers/:org/suspend", core.Guard(s, SuspendCustomer))
app.Post("/v1/admin/customers/:org/reactivate", core.Guard(s, ReactivateCustomer))
g := app.Group("/v1/admin")
g.Get("/customers", core.Guard(s, Customers))
g.Get("/customers/:org", core.Guard(s, CustomerDetail))
g.Post("/customers/:org/credit", core.Guard(s, GrantCredit))
g.Get("/grants", core.Guard(s, Grants))
g.Post("/grants", core.Guard(s, IssueGrant))
g.Post("/customers/:org/suspend", core.Guard(s, SuspendCustomer))
g.Post("/customers/:org/reactivate", core.Guard(s, ReactivateCustomer))
}
+6 -5
View File
@@ -28,16 +28,17 @@ var errUnconfigured = errors.New("not configured")
// Routes registers the finance dashboard (SuperAdmin only).
func Routes(app *zip.App, s *cloud.Service[core.State]) {
app.Get("/v1/admin/finance", core.Guard(s, Finance))
g := app.Group("/v1/admin")
g.Get("/finance", core.Guard(s, Finance))
// One-time commerce→finance balance cutover (SuperAdmin only). Idempotent per org.
app.Post("/v1/admin/finance/backfill", core.Guard(s, Backfill))
g.Post("/finance/backfill", core.Guard(s, Backfill))
// Fund an ARBITRARY subject's native wallet — an org pool or a human ("hanzo/z").
// SuperAdmin only; additive (grants stack).
app.Post("/v1/admin/finance/deposit", core.Guard(s, Deposit))
g.Post("/finance/deposit", core.Guard(s, Deposit))
// Per-provider upstream credit ledger + usage funding split (multi-provider
// credit-management). Same SuperAdmin guard, same cloud_usage warehouse.
app.Get("/v1/admin/providers/credit", core.Guard(s, ProvidersCredit))
app.Get("/v1/admin/usage/funding", core.Guard(s, UsageFunding))
g.Get("/providers/credit", core.Guard(s, ProvidersCredit))
g.Get("/usage/funding", core.Guard(s, UsageFunding))
}
// FinanceData is the full /v1/admin/finance aggregate.
+4 -4
View File
@@ -15,7 +15,7 @@ import (
// The finance PURE-math derivation tests (ComputeFinance / AvgDailyBurnCents) live with
// the handler in clients/admin/finance. These are the INTEGRATION tests that drive GET
// /v1/admin/finance through the shared admin mount harness (mountSvc + fake IAM/commerce/DO).
// /v1/admin/finance through the shared admin mount harness (mountService + fake IAM/commerce/DO).
// newFakeDO serves the DO billing API with fixed decimal-dollar strings so the
// finance aggregation is deterministic. account_balance is NEGATIVE (credit held).
@@ -49,7 +49,7 @@ func TestFinance_RealAggregation(t *testing.T) {
do := newFakeDO()
defer do.Close()
doReq, s, _ := mountSvc(t, iam.server.URL, commerce.URL, "")
doReq, s, _ := mountService(t, iam.server.URL, commerce.URL, "")
s.State.DO = digitalocean.NewWithBase(do.URL, "test-do-token") // configured DO client
admin := map[string]string{
"X-User-IsAdmin": "true", "X-Org-Id": "admin",
@@ -151,7 +151,7 @@ func TestFinance_HonestUnconfiguredDO(t *testing.T) {
commerce := newFakeCommerceFinance()
defer commerce.Close()
doReq, _, _ := mountSvc(t, iam.server.URL, commerce.URL, "") // s.do already has empty token → unconfigured
doReq, _, _ := mountService(t, iam.server.URL, commerce.URL, "") // s.do already has empty token → unconfigured
admin := map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "admin"}
resp, body := doReq("GET", "/v1/admin/finance", admin)
@@ -212,7 +212,7 @@ func TestFinance_RevenueSourceDown_NoFabrication(t *testing.T) {
defer commerce.Close()
// IAM points nowhere reachable → listOrgs errors; commerce /v1/costs still 200s.
doReq, _, _ := mountSvc(t, "http://127.0.0.1:0", commerce.URL, "")
doReq, _, _ := mountService(t, "http://127.0.0.1:0", commerce.URL, "")
admin := map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "admin"}
resp, body := doReq("GET", "/v1/admin/finance", admin)
+87 -72
View File
@@ -3,29 +3,28 @@
// 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.
// It reads the ONE shared warehouse (commerce.events) — the table the commerce
// analytics collector lands every invoice-lifecycle event in — over the SAME client
// (aiobject.DatastoreQuery) the o11y/compute lenses use, with ZERO per-org fan-out:
// one GROUP BY resolves each invoice's LATEST lifecycle state (argMax by timestamp),
// so the whole fleet is one query, not N per-org commerce reads. Honest by
// construction: no datastore connected or the collector's table not provisioned yet →
// the real empty list, never a fabricated row. Optional ?org= scopes to one tenant,
// ?status= filters the LATEST status, ?limit= caps the list.
package invoices
import (
"context"
"sort"
"strconv"
"strings"
"sync"
aiobject "github.com/hanzoai/ai/object"
"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.
// defaultLimit caps the fleet invoice list when the caller sends none.
const defaultLimit = 500
// InvoiceRow is one row of GET /v1/admin/invoices — an issued invoice at a glance,
@@ -47,84 +46,100 @@ type InvoiceRow struct {
// 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"))
status := strings.ToLower(strings.TrimSpace(c.Query("status")))
wantOrg := strings.TrimSpace(c.Query("org"))
limit := parseLimit(c.Query("limit"))
orgs, err := core.ListOrgs(s, ctx, cr)
// Honest-empty when the warehouse is not connected or the collector's events
// table is not provisioned yet (the emitter is still being wired).
if !core.BillingEventsReady(ctx) {
return core.OKList(c, []InvoiceRow{}, 0)
}
rows, err := aiobject.DatastoreQuery(ctx, invoicesSQL())
if err != nil {
return core.Fail(c, err.Error())
}
if wantOrg != "" {
orgs = filterOrg(orgs, wantOrg)
return core.Fail(c, "invoices query: "+err.Error())
}
all := invoiceRowsFromRows(rows)
// 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)
// Filter (latest status / org) then newest issued first, cap to limit.
out := make([]InvoiceRow, 0, len(all))
for _, r := range all {
if wantOrg != "" && r.Org != wantOrg {
continue
}
if status != "" && strings.ToLower(r.Status) != status {
continue
}
out = append(out, r)
}
wg.Wait()
rows := make([]InvoiceRow, 0)
for _, r := range perOrg {
rows = append(rows, r...)
sort.Slice(out, func(i, j int) bool { return out[i].Issued > out[j].Issued })
total := len(out)
if len(out) > limit {
out = out[:limit]
}
// 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)
return core.OKList(c, out, 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,
// invoicesSQL resolves each invoice's LATEST lifecycle state from commerce.events
// (argMax by timestamp). Static SQL over a closed event-name set (SQLInList of
// server constants) — no user input is interpolated, so it is injection-safe.
func invoicesSQL() string {
return "SELECT JSONExtractString(properties, 'invoice_id') AS id, " +
"argMax(JSONExtractString(properties, 'number'), timestamp) AS number, " +
"argMax(organization_id, timestamp) AS org, " +
"argMax(JSONExtractString(properties, 'status'), timestamp) AS status, " +
"argMax(JSONExtractInt(properties, 'amount_cents'), timestamp) AS amount_cents, " +
"argMax(JSONExtractString(properties, 'currency'), timestamp) AS currency, " +
"argMax(JSONExtractString(properties, 'issued'), timestamp) AS issued, " +
"argMax(JSONExtractString(properties, 'due'), timestamp) AS due, " +
"argMax(event, timestamp) AS last_event " +
"FROM " + core.BillingEventsTable + " " +
"WHERE event IN (" + core.SQLInList(core.InvoiceEvents) + ") " +
"AND JSONExtractString(properties, 'invoice_id') != '' " +
"GROUP BY id"
}
// invoiceRowsFromRows maps the datastore rows onto []InvoiceRow (pure). Display is
// the org slug — the warehouse holds no friendly name and admin does no per-org IAM
// fan-out here (honest, not fabricated). Status folds the lifecycle from the latest
// event so a paid/voided invoice reads correctly regardless of the status snapshot.
func invoiceRowsFromRows(rows []map[string]any) []InvoiceRow {
out := make([]InvoiceRow, 0, len(rows))
for _, r := range rows {
org := core.CHStr(r["org"])
out = append(out, InvoiceRow{
ID: core.CHStr(r["id"]),
Number: core.CHStr(r["number"]),
Org: org,
Display: org,
Status: foldInvoiceStatus(core.CHStr(r["last_event"]), core.CHStr(r["status"])),
AmountCents: core.CHInt64(r["amount_cents"]),
Currency: core.CHStr(r["currency"]),
Issued: core.CHStr(r["issued"]),
Due: core.CHStr(r["due"]),
})
}
return rows
return out
}
// 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}
}
// foldInvoiceStatus resolves the effective status from the latest lifecycle event
// (paid / void terminal), falling back to the last-emitted status snapshot (open
// for a finalized invoice) when the event is a finalize.
func foldInvoiceStatus(lastEvent, snapshot string) string {
switch lastEvent {
case core.EvInvoicePaid:
return "paid"
case core.EvInvoiceVoid:
return "void"
}
return nil
if s := strings.TrimSpace(snapshot); s != "" {
return s
}
return "open"
}
// parseLimit clamps the merged-list cap to [1,5000], defaulting to defaultLimit.
// parseLimit clamps the fleet-list cap to [1,5000], defaulting to defaultLimit.
func parseLimit(s string) int {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n <= 0 {
+77
View File
@@ -0,0 +1,77 @@
package invoices
import (
"strings"
"testing"
"github.com/hanzoai/cloud/clients/admin/core"
)
// TestInvoiceRowsFromRows proves the warehouse-row → InvoiceRow mapping (JSON-shape
// contract): amount coerced from driver ints, status folded from the latest event,
// display honestly the org slug (no fan-out).
func TestInvoiceRowsFromRows(t *testing.T) {
rows := []map[string]any{
{
"id": "inv_1", "number": "INV-0042", "org": "acme",
"status": "open", "amount_cents": int64(4900), "currency": "usd",
"issued": "2026-07-01T00:00:00Z", "due": "2026-07-15T00:00:00Z",
"last_event": core.EvInvoicePaid,
},
{
"id": "inv_2", "number": "INV-0043", "org": "beta",
"status": "open", "amount_cents": uint64(1200), "currency": "usd",
"issued": "2026-07-02T00:00:00Z", "due": "",
"last_event": core.EvInvoiceVoid,
},
}
out := invoiceRowsFromRows(rows)
if len(out) != 2 {
t.Fatalf("got %d rows, want 2", len(out))
}
if out[0].ID != "inv_1" || out[0].Number != "INV-0042" || out[0].Org != "acme" || out[0].Display != "acme" {
t.Fatalf("row0 identity wrong: %+v", out[0])
}
if out[0].AmountCents != 4900 || out[0].Currency != "usd" {
t.Fatalf("row0 amount/currency wrong: %+v", out[0])
}
if out[0].Status != "paid" {
t.Fatalf("row0 status = %q, want paid (paid event folds)", out[0].Status)
}
if out[0].Issued != "2026-07-01T00:00:00Z" || out[0].Due != "2026-07-15T00:00:00Z" {
t.Fatalf("row0 dates wrong: %+v", out[0])
}
if out[1].Status != "void" {
t.Fatalf("row1 status = %q, want void", out[1].Status)
}
}
func TestFoldInvoiceStatus(t *testing.T) {
if got := foldInvoiceStatus(core.EvInvoicePaid, "open"); got != "paid" {
t.Fatalf("paid fold = %q", got)
}
if got := foldInvoiceStatus(core.EvInvoiceVoid, "open"); got != "void" {
t.Fatalf("void fold = %q", got)
}
if got := foldInvoiceStatus(core.EvInvoiceFinalized, "open"); got != "open" {
t.Fatalf("finalized snapshot = %q", got)
}
if got := foldInvoiceStatus(core.EvInvoiceFinalized, ""); got != "open" {
t.Fatalf("finalized default = %q", got)
}
}
func TestInvoicesSQLInjectionSafe(t *testing.T) {
sql := invoicesSQL()
if !strings.Contains(sql, core.BillingEventsTable) {
t.Fatalf("query must read %s: %q", core.BillingEventsTable, sql)
}
for _, ev := range core.InvoiceEvents {
if !strings.Contains(sql, "'"+ev+"'") {
t.Fatalf("query missing event %q", ev)
}
}
if strings.Contains(sql, "?") {
t.Fatalf("invoices state query takes no positional args: %q", sql)
}
}
+2 -1
View File
@@ -8,5 +8,6 @@ import (
// 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))
g := app.Group("/v1/admin")
g.Get("/invoices", core.Guard(s, Invoices))
}
+7 -6
View File
@@ -25,17 +25,18 @@ import (
// limitRoutes registers the promo + cap control plane. Called from routes().
func limitRoutes(app *zip.App, s *cloud.Service[core.State]) {
g := app.Group("/v1/admin")
// Platform plan promo — SuperAdmin only.
app.Get("/v1/admin/promos", core.Guard(s, getPromo))
app.Put("/v1/admin/promos", core.Guard(s, putPromo))
g.Get("/promos", core.Guard(s, getPromo))
g.Put("/promos", core.Guard(s, putPromo))
// Per-org usage-cap oversight/override — SuperAdmin (any org via ?org=) or an org
// admin (own org only). Reuses the customer's OWN self-service spend-alert CRUD,
// so a platform override and a customer edit are the same rows.
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))
g.Get("/spend-caps", core.GuardScoped(s, listSpendCaps))
g.Post("/spend-caps", core.GuardScoped(s, createSpendCap))
g.Patch("/spend-caps/:id", core.GuardScoped(s, updateSpendCap))
g.Delete("/spend-caps/:id", core.GuardScoped(s, deleteSpendCap))
}
// getPromo returns the current platform plan promo. X-Org-Id is the admin org —
+416 -38
View File
@@ -3,92 +3,458 @@
// 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
// It reads the ONE shared warehouse (commerce.events) — the table the commerce
// analytics collector lands every subscription/invoice/usage-lifecycle event in —
// over the SAME client (aiobject.DatastoreQuery) the o11y/compute lenses use, with
// ZERO per-org fan-out. Each panel is ONE aggregate query that folds the whole fleet
// (subscription state = latest-event-wins via argMax; new/churn/usage = windowed),
// exactly the way o11y.go composes independent per-signal reads. An unconnected
// warehouse — or the collector's events table not provisioned yet — degrades to an
// honest empty snapshot (real zeros, `[]` not null) with a not-ok source, never a
// fabricated number.
// fabricated number. Money is USD cents end to end; time bounds are POSITIONAL args.
package metrics
import (
"context"
"errors"
"sort"
"strconv"
"strings"
"time"
aiobject "github.com/hanzoai/ai/object"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/admin/commerce"
"github.com/hanzoai/cloud/clients/admin/core"
"github.com/hanzoai/cloud/clients/admin/money"
"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")
// errUnconfigured marks the warehouse not connected on this deployment — core.SrcOf
// reports it as a not-ok source so the console renders the honest not-configured state.
var errUnconfigured = errors.New("billing warehouse not connected")
// defaultLimit caps the top-customers list 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
// defaultLimit caps the top-customers list; recentLimit caps the movement feed.
const (
defaultLimit = 20
recentLimit = 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.
// ── response shapes (byte-identical to the operator contract in api.ts) ──────
// These were formerly modeled on the commerce S2S client; they now live here (the
// one consumer) since the read is a direct warehouse aggregate. Money is money.Cents
// (int64 underlying → plain-integer JSON, unchanged on the wire).
// SaaSMetrics is the whole-business SaaS-operations aggregate.
type SaaSMetrics struct {
AsOf string `json:"asOf"`
Currency string `json:"currency"`
Window string `json:"window"`
Revenue SaaSRevenue `json:"revenue"`
Subs SaaSSubs `json:"subscriptions"`
Usage SaaSUsage `json:"usage"`
Customers []SaaSCustomer `json:"customers"`
Orgs int `json:"orgs"`
Gaps []string `json:"gaps"`
}
// SaaSRevenue is the recurring-revenue headline (run-rate MRR/ARR + windowed movement).
type SaaSRevenue struct {
MRRCents money.Cents `json:"mrrCents"`
ARRCents money.Cents `json:"arrCents"`
ActiveSubscriptions int `json:"activeSubscriptions"`
PayingCustomers int `json:"payingCustomers"`
Trials int `json:"trials"`
NewMRRCents money.Cents `json:"newMrrCents"`
ChurnedMRRCents money.Cents `json:"churnedMrrCents"`
NetNewMRRCents money.Cents `json:"netNewMrrCents"`
ByCategory []SaaSCategory `json:"byCategory"`
}
// SaaSCategory is one plan-category bucket of run-rate MRR (the plan mix).
type SaaSCategory struct {
Category string `json:"category"`
MRRCents money.Cents `json:"mrrCents"`
Subscriptions int `json:"subscriptions"`
}
// SaaSSubs is the subscription-operations panel (per-plan mix, trials, new/canceled,
// recent movements).
type SaaSSubs struct {
ByPlan []SaaSPlan `json:"byPlan"`
TrialsActive int `json:"trialsActive"`
New int `json:"new"`
Canceled int `json:"canceled"`
Recent []SaaSEvent `json:"recent"`
}
// SaaSPlan is one plan's active/trialing counts, seats, and MRR contribution.
type SaaSPlan struct {
Plan string `json:"plan"`
Name string `json:"name"`
Category string `json:"category"`
Active int `json:"active"`
Trialing int `json:"trialing"`
Seats int `json:"seats"`
MRRCents money.Cents `json:"mrrCents"`
}
// SaaSEvent is one recent subscription movement ("created" or "canceled").
type SaaSEvent struct {
At string `json:"at"`
Org string `json:"org"`
Type string `json:"type"`
Plan string `json:"plan"`
Category string `json:"category"`
MRRDeltaCents money.Cents `json:"mrrDeltaCents"`
}
// SaaSUsage is the metered / pay-as-you-go revenue headline for the window.
type SaaSUsage struct {
Instrumented bool `json:"instrumented"`
WindowUsageCents money.Cents `json:"windowUsageCents"`
Requests int64 `json:"requests"`
}
// SaaSCustomer is one top customer by MRR + windowed usage.
type SaaSCustomer struct {
Org string `json:"org"`
Plan string `json:"plan"`
Category string `json:"category"`
Status string `json:"status"`
MRRCents money.Cents `json:"mrrCents"`
UsageCents money.Cents `json:"usageCents"`
Seats int `json:"seats"`
Since string `json:"since,omitempty"`
}
// MetricsData is the GET /v1/admin/metrics payload: the SaaS snapshot, flat, plus the
// admin read time and the upstream freshness strip every god-view carries.
type MetricsData struct {
commerce.SaaSMetrics
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.
// Metrics answers GET /v1/admin/metrics by aggregating commerce.events directly
// (fleet-wide, no per-org fan-out). SuperAdmin only.
//
// GET /v1/admin/metrics?window=30d&limit=20
func Metrics(s *cloud.Service[core.State], c *zip.Ctx) error {
ctx := c.Context()
now := time.Now().UTC().Format(time.RFC3339)
window := strings.TrimSpace(c.Query("window"))
window := normalizeWindow(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)))
// Honest not-configured snapshot when the warehouse/collector table is absent.
if !core.BillingEventsReady(ctx) {
return core.OK(c, empty(now, window, core.SrcOf("billing-warehouse", errUnconfigured, 0, now)))
}
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)))
sinceTS := core.CHTimeLit(core.WarehouseSince(window))
m := SaaSMetrics{AsOf: now, Currency: "usd", Window: window}
// Revenue headline + plan-mix (run-rate, latest-event-wins over active subs).
if rows, err := aiobject.DatastoreQuery(ctx, headlineSQL()); err == nil {
fillHeadline(&m.Revenue, core.CHFirstRow(rows))
}
if rows, err := aiobject.DatastoreQuery(ctx, byCategorySQL()); err == nil {
m.Revenue.ByCategory = byCategoryFromRows(rows)
}
if rows, err := aiobject.DatastoreQuery(ctx, byPlanSQL()); err == nil {
m.Subs.ByPlan = byPlanFromRows(rows)
}
m.Subs.TrialsActive = m.Revenue.Trials
// Windowed movement: new vs churned MRR + counts.
if rows, err := aiobject.DatastoreQuery(ctx, movementSQL(), sinceTS); err == nil {
r := core.CHFirstRow(rows)
m.Revenue.NewMRRCents = money.Cents(core.CHInt64(r["new_mrr"]))
m.Revenue.ChurnedMRRCents = money.Cents(core.CHInt64(r["churned_mrr"]))
m.Revenue.NetNewMRRCents = m.Revenue.NewMRRCents - m.Revenue.ChurnedMRRCents
m.Subs.New = int(core.CHInt64(r["new_count"]))
m.Subs.Canceled = int(core.CHInt64(r["canceled_count"]))
}
// Recent movements feed.
if rows, err := aiobject.DatastoreQuery(ctx, recentSQL(), sinceTS); err == nil {
m.Subs.Recent = recentFromRows(rows)
}
// Metered usage headline (window).
if rows, err := aiobject.DatastoreQuery(ctx, usageSQL(), sinceTS); err == nil {
r := core.CHFirstRow(rows)
m.Usage.Requests = core.CHInt64(r["requests"])
m.Usage.WindowUsageCents = money.Cents(core.CHInt64(r["usage_cents"]))
m.Usage.Instrumented = m.Usage.Requests > 0
}
// Fleet org count (any billing activity).
if rows, err := aiobject.DatastoreQuery(ctx, orgCountSQL()); err == nil {
m.Orgs = int(core.CHInt64(core.CHFirstRow(rows)["orgs"]))
}
// Top customers by MRR + windowed usage (two reads merged, no fan-out).
m.Customers = topCustomers(ctx, sinceTS, limit)
m.Gaps = gapsFor(m)
return core.OK(c, MetricsData{
SaaSMetrics: normalize(m),
GeneratedAt: now,
Sources: []core.SourceStatus{core.SrcOf("commerce-metrics", nil, m.Orgs, now)},
Sources: []core.SourceStatus{core.SrcOf("billing-warehouse", 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.
// ── active-subscription state subquery (latest-event-wins, non-canceled) ─────
// activeSubs is the fleet's current subscription state: one row per subscription,
// its LATEST lifecycle values (argMax by timestamp), keeping only non-canceled
// subs (HAVING on the latest event). Static SQL over a closed event-name set — no
// user input interpolated. Reused by every run-rate panel so the definition of
// "active" lives in ONE place.
func activeSubs() string {
return "(SELECT " +
"argMax(organization_id, timestamp) AS org, " +
"argMax(JSONExtractString(properties, 'plan'), timestamp) AS plan, " +
"argMax(JSONExtractString(properties, 'plan_name'), timestamp) AS plan_name, " +
"argMax(JSONExtractString(properties, 'category'), timestamp) AS category, " +
"argMax(JSONExtractString(properties, 'status'), timestamp) AS status, " +
"argMax(JSONExtractInt(properties, 'mrr_cents'), timestamp) AS mrr_cents, " +
"argMax(JSONExtractInt(properties, 'seats'), timestamp) AS seats, " +
"min(timestamp) AS first_ts " +
"FROM " + core.BillingEventsTable + " " +
"WHERE event IN (" + core.SQLInList(core.SubscriptionEvents) + ") " +
"AND JSONExtractString(properties, 'subscription_id') != '' " +
"GROUP BY JSONExtractString(properties, 'subscription_id') " +
"HAVING argMax(event, timestamp) != '" + core.EvSubscriptionCanceled + "')"
}
// ── pure SQL builders (static SQL + at most one positional time bound) ────────
// headlineSQL: run-rate MRR (paying, non-trial), active-sub count, paying-customer
// count, and trial count — one pass over the active-subs state.
func headlineSQL() string {
return "SELECT sumIf(mrr_cents, status != 'trialing') AS mrr, " +
"count() AS active_subs, " +
"uniqExactIf(org, status != 'trialing' AND mrr_cents > 0) AS paying, " +
"countIf(status = 'trialing') AS trials FROM " + activeSubs()
}
func byCategorySQL() string {
return "SELECT category, sumIf(mrr_cents, status != 'trialing') AS mrr, count() AS subs " +
"FROM " + activeSubs() + " GROUP BY category ORDER BY mrr DESC"
}
func byPlanSQL() string {
return "SELECT plan, any(plan_name) AS name, any(category) AS category, " +
"countIf(status = 'active') AS active, countIf(status = 'trialing') AS trialing, " +
"sum(seats) AS seats, sumIf(mrr_cents, status != 'trialing') AS mrr " +
"FROM " + activeSubs() + " GROUP BY plan ORDER BY mrr DESC"
}
// movementSQL: windowed new vs churned MRR + counts (one positional since bound).
func movementSQL() string {
return "SELECT " +
"sumIf(JSONExtractInt(properties, 'mrr_cents'), event = '" + core.EvSubscriptionCreated + "') AS new_mrr, " +
"countIf(event = '" + core.EvSubscriptionCreated + "') AS new_count, " +
"sumIf(JSONExtractInt(properties, 'mrr_cents'), event = '" + core.EvSubscriptionCanceled + "') AS churned_mrr, " +
"countIf(event = '" + core.EvSubscriptionCanceled + "') AS canceled_count " +
"FROM " + core.BillingEventsTable + " " +
"WHERE event IN ('" + core.EvSubscriptionCreated + "','" + core.EvSubscriptionCanceled + "') AND timestamp >= ?"
}
func recentSQL() string {
return "SELECT timestamp AS at, organization_id AS org, event AS type, " +
"JSONExtractString(properties, 'plan_name') AS plan, " +
"JSONExtractString(properties, 'category') AS category, " +
"JSONExtractInt(properties, 'mrr_cents') AS mrr_delta " +
"FROM " + core.BillingEventsTable + " " +
"WHERE event IN ('" + core.EvSubscriptionCreated + "','" + core.EvSubscriptionCanceled + "') AND timestamp >= ? " +
"ORDER BY at DESC LIMIT " + strconv.Itoa(recentLimit)
}
func usageSQL() string {
return "SELECT count() AS requests, sum(JSONExtractInt(properties, 'amount_cents')) AS usage_cents " +
"FROM " + core.BillingEventsTable + " WHERE event = '" + core.EvAPIUsageDebit + "' AND timestamp >= ?"
}
func orgCountSQL() string {
return "SELECT uniqExact(organization_id) AS orgs FROM " + core.BillingEventsTable +
" WHERE event IN (" + core.SQLInList(allBillingEvents()) + ")"
}
func perOrgSubsSQL() string {
return "SELECT org, sumIf(mrr_cents, status != 'trialing') AS mrr, sum(seats) AS seats, " +
"argMax(plan_name, mrr_cents) AS plan, argMax(category, mrr_cents) AS category, " +
"argMax(status, mrr_cents) AS status, min(first_ts) AS since " +
"FROM " + activeSubs() + " GROUP BY org"
}
func perOrgUsageSQL() string {
return "SELECT organization_id AS org, sum(JSONExtractInt(properties, 'amount_cents')) AS usage_cents " +
"FROM " + core.BillingEventsTable + " WHERE event = '" + core.EvAPIUsageDebit + "' AND timestamp >= ? GROUP BY org"
}
// allBillingEvents is the union of every customer-activity event the fleet counts
// an org as "active" on (subscription + invoice + usage).
func allBillingEvents() []string {
out := append([]string{}, core.SubscriptionEvents...)
out = append(out, core.InvoiceEvents...)
return append(out, core.EvAPIUsageDebit)
}
// ── pure row parsers ─────────────────────────────────────────────────────────
func fillHeadline(r *SaaSRevenue, row map[string]any) {
r.MRRCents = money.Cents(core.CHInt64(row["mrr"]))
r.ARRCents = r.MRRCents * 12
r.ActiveSubscriptions = int(core.CHInt64(row["active_subs"]))
r.PayingCustomers = int(core.CHInt64(row["paying"]))
r.Trials = int(core.CHInt64(row["trials"]))
}
func byCategoryFromRows(rows []map[string]any) []SaaSCategory {
out := make([]SaaSCategory, 0, len(rows))
for _, r := range rows {
out = append(out, SaaSCategory{
Category: core.CHStr(r["category"]),
MRRCents: money.Cents(core.CHInt64(r["mrr"])),
Subscriptions: int(core.CHInt64(r["subs"])),
})
}
return out
}
func byPlanFromRows(rows []map[string]any) []SaaSPlan {
out := make([]SaaSPlan, 0, len(rows))
for _, r := range rows {
out = append(out, SaaSPlan{
Plan: core.CHStr(r["plan"]),
Name: core.CHStr(r["name"]),
Category: core.CHStr(r["category"]),
Active: int(core.CHInt64(r["active"])),
Trialing: int(core.CHInt64(r["trialing"])),
Seats: int(core.CHInt64(r["seats"])),
MRRCents: money.Cents(core.CHInt64(r["mrr"])),
})
}
return out
}
func recentFromRows(rows []map[string]any) []SaaSEvent {
out := make([]SaaSEvent, 0, len(rows))
for _, r := range rows {
typ := "created"
delta := money.Cents(core.CHInt64(r["mrr_delta"]))
if core.CHStr(r["type"]) == core.EvSubscriptionCanceled {
typ = "canceled"
delta = -delta // churn reduces run-rate MRR
}
out = append(out, SaaSEvent{
At: core.CHTime(r["at"]),
Org: core.CHStr(r["org"]),
Type: typ,
Plan: core.CHStr(r["plan"]),
Category: core.CHStr(r["category"]),
MRRDeltaCents: delta,
})
}
return out
}
// topCustomers folds per-org subscription state + per-org windowed usage into the
// top-N customers by MRR (then usage). Two reads merged in Go by org — a union, so
// a pay-as-you-go org with usage but no subscription still appears.
func topCustomers(ctx context.Context, sinceTS string, limit int) []SaaSCustomer {
byOrg := map[string]*SaaSCustomer{}
if rows, err := aiobject.DatastoreQuery(ctx, perOrgSubsSQL()); err == nil {
for _, r := range rows {
org := core.CHStr(r["org"])
if org == "" {
continue
}
byOrg[org] = &SaaSCustomer{
Org: org,
Plan: core.CHStr(r["plan"]),
Category: core.CHStr(r["category"]),
Status: core.CHStr(r["status"]),
MRRCents: money.Cents(core.CHInt64(r["mrr"])),
Seats: int(core.CHInt64(r["seats"])),
Since: core.CHTime(r["since"]),
}
}
}
if rows, err := aiobject.DatastoreQuery(ctx, perOrgUsageSQL(), sinceTS); err == nil {
for _, r := range rows {
org := core.CHStr(r["org"])
if org == "" {
continue
}
usage := money.Cents(core.CHInt64(r["usage_cents"]))
if cust, ok := byOrg[org]; ok {
cust.UsageCents = usage
continue
}
byOrg[org] = &SaaSCustomer{Org: org, Plan: "pay-as-you-go", Status: "active", UsageCents: usage}
}
}
out := make([]SaaSCustomer, 0, len(byOrg))
for _, c := range byOrg {
out = append(out, *c)
}
sortCustomers(out)
if len(out) > limit {
out = out[:limit]
}
return out
}
// ── small pure helpers ───────────────────────────────────────────────────────
// sortCustomers ranks by MRR desc, ties broken by windowed usage desc.
func sortCustomers(cs []SaaSCustomer) {
sort.SliceStable(cs, func(i, j int) bool { return lessCustomer(cs[i], cs[j]) })
}
func lessCustomer(a, b SaaSCustomer) bool {
if a.MRRCents != b.MRRCents {
return a.MRRCents > b.MRRCents
}
return a.UsageCents > b.UsageCents
}
// gapsFor lists honest not-yet-observed signals so the console can badge a partial
// snapshot without fabricating data.
func gapsFor(m SaaSMetrics) []string {
gaps := []string{}
if !m.Usage.Instrumented {
gaps = append(gaps, "api-usage debits not yet observed")
}
if m.Revenue.ActiveSubscriptions == 0 {
gaps = append(gaps, "no active subscriptions observed")
}
return gaps
}
// empty is the honest not-connected snapshot: real zeros + empty slices (never
// null, never fabricated) plus the not-ok source.
func empty(now, window string, src core.SourceStatus) MetricsData {
return MetricsData{
SaaSMetrics: normalize(commerce.SaaSMetrics{AsOf: now, Currency: "usd", Window: window}),
SaaSMetrics: normalize(SaaSMetrics{AsOf: now, Currency: "usd", Window: window}),
GeneratedAt: now,
Sources: []core.SourceStatus{src},
}
}
// normalize replaces nil slices with empty ones so the JSON is honest arrays (`[]`, not
// null) and the console never has to guard a missing collection.
func normalize(m commerce.SaaSMetrics) commerce.SaaSMetrics {
// normalize replaces nil slices with empty ones so the JSON is honest arrays (`[]`,
// not null) and the console never has to guard a missing collection.
func normalize(m SaaSMetrics) SaaSMetrics {
if m.Revenue.ByCategory == nil {
m.Revenue.ByCategory = []commerce.SaaSCategory{}
m.Revenue.ByCategory = []SaaSCategory{}
}
if m.Subs.ByPlan == nil {
m.Subs.ByPlan = []commerce.SaaSPlan{}
m.Subs.ByPlan = []SaaSPlan{}
}
if m.Subs.Recent == nil {
m.Subs.Recent = []commerce.SaaSEvent{}
m.Subs.Recent = []SaaSEvent{}
}
if m.Customers == nil {
m.Customers = []commerce.SaaSCustomer{}
m.Customers = []SaaSCustomer{}
}
if m.Gaps == nil {
m.Gaps = []string{}
@@ -96,8 +462,20 @@ func normalize(m commerce.SaaSMetrics) commerce.SaaSMetrics {
return m
}
// parseLimit clamps the top-N cap to [1,200], defaulting to defaultLimit — mirrors the
// commerce engine's clamp exactly.
// normalizeWindow clamps ?window to the supported set (default 30d) — mirrors the
// warehouse window grammar (core.WarehouseSince).
func normalizeWindow(v string) string {
switch strings.TrimSpace(v) {
case "24h":
return "24h"
case "7d":
return "7d"
default:
return "30d"
}
}
// parseLimit clamps the top-N cap to [1,200], defaulting to defaultLimit.
func parseLimit(s string) int {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n <= 0 {
+121
View File
@@ -0,0 +1,121 @@
package metrics
import (
"strings"
"testing"
"github.com/hanzoai/cloud/clients/admin/core"
)
// TestFillHeadline proves the run-rate headline coercion (driver ints) + the
// ARR = 12×MRR derivation.
func TestFillHeadline(t *testing.T) {
var rev SaaSRevenue
fillHeadline(&rev, map[string]any{
"mrr": int64(4900), "active_subs": uint64(3), "paying": uint64(2), "trials": uint64(1),
})
if rev.MRRCents != 4900 || rev.ARRCents != 4900*12 {
t.Fatalf("mrr/arr wrong: %+v", rev)
}
if rev.ActiveSubscriptions != 3 || rev.PayingCustomers != 2 || rev.Trials != 1 {
t.Fatalf("counts wrong: %+v", rev)
}
}
func TestByCategoryAndPlanFromRows(t *testing.T) {
cats := byCategoryFromRows([]map[string]any{
{"category": "cloud", "mrr": int64(9800), "subs": uint64(2)},
})
if len(cats) != 1 || cats[0].Category != "cloud" || cats[0].MRRCents != 9800 || cats[0].Subscriptions != 2 {
t.Fatalf("category row wrong: %+v", cats)
}
plans := byPlanFromRows([]map[string]any{
{"plan": "pro", "name": "Pro", "category": "cloud", "active": uint64(2), "trialing": uint64(1), "seats": uint64(5), "mrr": int64(9800)},
})
if len(plans) != 1 {
t.Fatalf("want 1 plan, got %d", len(plans))
}
p := plans[0]
if p.Plan != "pro" || p.Name != "Pro" || p.Category != "cloud" || p.Active != 2 || p.Trialing != 1 || p.Seats != 5 || p.MRRCents != 9800 {
t.Fatalf("plan row wrong: %+v", p)
}
}
// TestRecentFromRows proves the movement feed maps event→type and NEGATES churn MRR.
func TestRecentFromRows(t *testing.T) {
rows := []map[string]any{
{"at": "2026-07-10T00:00:00Z", "org": "acme", "type": core.EvSubscriptionCreated, "plan": "Pro", "category": "cloud", "mrr_delta": int64(4900)},
{"at": "2026-07-09T00:00:00Z", "org": "beta", "type": core.EvSubscriptionCanceled, "plan": "Team", "category": "cloud", "mrr_delta": int64(3000)},
}
out := recentFromRows(rows)
if len(out) != 2 {
t.Fatalf("want 2, got %d", len(out))
}
if out[0].Type != "created" || out[0].MRRDeltaCents != 4900 {
t.Fatalf("created row wrong: %+v", out[0])
}
if out[1].Type != "canceled" || out[1].MRRDeltaCents != -3000 {
t.Fatalf("canceled row must negate mrr: %+v", out[1])
}
}
func TestSortCustomers(t *testing.T) {
cs := []SaaSCustomer{
{Org: "a", MRRCents: 100, UsageCents: 0},
{Org: "b", MRRCents: 500, UsageCents: 0},
{Org: "c", MRRCents: 500, UsageCents: 999}, // ties on MRR → usage breaks
}
sortCustomers(cs)
if cs[0].Org != "c" || cs[1].Org != "b" || cs[2].Org != "a" {
t.Fatalf("order wrong: %s,%s,%s", cs[0].Org, cs[1].Org, cs[2].Org)
}
}
// TestStateQueriesNoPositionalArgs: the run-rate (state) queries are fully static.
func TestStateQueriesNoPositionalArgs(t *testing.T) {
for name, sql := range map[string]string{
"headline": headlineSQL(), "byCategory": byCategorySQL(), "byPlan": byPlanSQL(),
"orgCount": orgCountSQL(), "perOrgSubs": perOrgSubsSQL(),
} {
if !strings.Contains(sql, core.BillingEventsTable) {
t.Fatalf("%s must read %s", name, core.BillingEventsTable)
}
if strings.Contains(sql, "?") {
t.Fatalf("%s (run-rate) must take no positional args: %q", name, sql)
}
}
}
// TestWindowedQueriesOnePositionalArg: the windowed queries bind exactly ONE time
// arg (injection-safe — the since bound is never interpolated).
func TestWindowedQueriesOnePositionalArg(t *testing.T) {
for name, sql := range map[string]string{
"movement": movementSQL(), "recent": recentSQL(), "usage": usageSQL(), "perOrgUsage": perOrgUsageSQL(),
} {
if n := strings.Count(sql, "?"); n != 1 {
t.Fatalf("%s must bind exactly ONE positional time arg, got %d: %q", name, n, sql)
}
if !strings.Contains(sql, "timestamp >= ?") {
t.Fatalf("%s time bound must be positional: %q", name, sql)
}
}
}
func TestNormalizeAndEmpty(t *testing.T) {
m := normalize(SaaSMetrics{})
if m.Revenue.ByCategory == nil || m.Subs.ByPlan == nil || m.Subs.Recent == nil || m.Customers == nil || m.Gaps == nil {
t.Fatal("normalize must replace nil slices with empty (honest [] not null)")
}
e := empty("now", "30d", core.SrcOf("billing-warehouse", errUnconfigured, 0, "now"))
if e.Currency != "usd" || e.Window != "30d" || len(e.Sources) != 1 || e.Sources[0].OK {
t.Fatalf("empty snapshot wrong: %+v", e)
}
}
func TestNormalizeWindow(t *testing.T) {
for in, want := range map[string]string{"24h": "24h", "7d": "7d", "30d": "30d", "": "30d", "90d": "30d"} {
if got := normalizeWindow(in); got != want {
t.Fatalf("normalizeWindow(%q) = %q, want %q", in, got, want)
}
}
}
+2 -1
View File
@@ -9,5 +9,6 @@ import (
// 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))
g := app.Group("/v1/admin")
g.Get("/metrics", core.Guard(s, Metrics))
}
+2 -1
View File
@@ -22,7 +22,8 @@ import (
// Routes registers the fleet revenue board (SuperAdmin only, cross-tenant profitability).
func Routes(app *zip.App, s *cloud.Service[core.State]) {
app.Get("/v1/admin/revenue", core.Guard(s, Revenue))
g := app.Group("/v1/admin")
g.Get("/revenue", core.Guard(s, Revenue))
}
// RevenueCustomer is one row of the per-customer revenue table.
+2 -1
View File
@@ -8,5 +8,6 @@ import (
// 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))
g := app.Group("/v1/admin")
g.Get("/subscriptions", core.Guard(s, Subscriptions))
}
+94 -77
View File
@@ -1,28 +1,30 @@
// 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).
// 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.
// It reads the ONE shared warehouse (commerce.events) — the table the commerce
// analytics collector lands every subscription-lifecycle event in — over the SAME
// client (aiobject.DatastoreQuery) the o11y/compute lenses use, with ZERO per-org
// fan-out: one GROUP BY resolves each subscription's LATEST lifecycle state
// (argMax by timestamp), so the whole fleet is one query, not N per-org commerce
// reads. Honest by construction: no datastore connected or the collector's table
// not provisioned yet → the real empty list, never a fabricated tenant. The MRR is
// the monthly-normalized figure the emitter already computed (cents). Optional
// ?org= scopes to one tenant, ?status= filters the LATEST status, ?limit= caps.
package subscriptions
import (
"context"
"sort"
"strconv"
"strings"
"sync"
aiobject "github.com/hanzoai/ai/object"
"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.
// defaultLimit caps the fleet subscription list when the caller sends none.
const defaultLimit = 500
// SubscriptionRow is one row of GET /v1/admin/subscriptions — a tenant's subscription at
@@ -44,89 +46,104 @@ type SubscriptionRow struct {
// 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"))
status := strings.ToLower(strings.TrimSpace(c.Query("status")))
wantOrg := strings.TrimSpace(c.Query("org"))
limit := parseLimit(c.Query("limit"))
orgs, err := core.ListOrgs(s, ctx, cr)
// Honest-empty when the warehouse is not connected or the collector's events
// table is not provisioned yet (the emitter is still being wired).
if !core.BillingEventsReady(ctx) {
return core.OKList(c, []SubscriptionRow{}, 0)
}
rows, err := aiobject.DatastoreQuery(ctx, subscriptionsSQL())
if err != nil {
return core.Fail(c, err.Error())
}
if wantOrg != "" {
orgs = filterOrg(orgs, wantOrg)
return core.Fail(c, "subscriptions query: "+err.Error())
}
all := subscriptionRowsFromRows(rows)
// 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
// Filter (latest status / org) then sort highest-MRR first, cap to limit.
out := make([]SubscriptionRow, 0, len(all))
for _, r := range all {
if wantOrg != "" && r.Org != wantOrg {
continue
}
return rows[i].Started > rows[j].Started
})
total := len(rows)
if len(rows) > limit {
rows = rows[:limit]
if status != "" && strings.ToLower(r.Status) != status {
continue
}
out = append(out, r)
}
return core.OKList(c, rows, total)
sort.Slice(out, func(i, j int) bool {
if out[i].MRRCents != out[j].MRRCents {
return out[i].MRRCents > out[j].MRRCents
}
return out[i].Started > out[j].Started
})
total := len(out)
if len(out) > limit {
out = out[:limit]
}
return core.OKList(c, out, total)
}
// 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,
// subscriptionsSQL resolves each subscription's LATEST lifecycle state from
// commerce.events (argMax by timestamp). Static SQL over a closed event-name set
// (SQLInList of server constants) — no user input is interpolated, so it is
// injection-safe. The emitted properties carry the plan/status/mrr/period fields.
func subscriptionsSQL() string {
return "SELECT JSONExtractString(properties, 'subscription_id') AS id, " +
"argMax(organization_id, timestamp) AS org, " +
"argMax(distinct_id, timestamp) AS user, " +
"argMax(JSONExtractString(properties, 'plan_name'), timestamp) AS plan, " +
"argMax(JSONExtractString(properties, 'status'), timestamp) AS status, " +
"argMax(JSONExtractInt(properties, 'mrr_cents'), timestamp) AS mrr_cents, " +
"argMax(event, timestamp) AS last_event, " +
"min(timestamp) AS started, " +
"argMax(JSONExtractString(properties, 'period_end'), timestamp) AS renews " +
"FROM " + core.BillingEventsTable + " " +
"WHERE event IN (" + core.SQLInList(core.SubscriptionEvents) + ") " +
"AND JSONExtractString(properties, 'subscription_id') != '' " +
"GROUP BY id"
}
// subscriptionRowsFromRows maps the datastore rows onto []SubscriptionRow (pure).
// Display is the org slug — the warehouse holds no friendly name and admin does
// no per-org IAM fan-out here (honest, not fabricated). The final status folds
// the lifecycle: a subscription whose LATEST event is a cancel reads "canceled"
// regardless of the last-emitted status snapshot.
func subscriptionRowsFromRows(rows []map[string]any) []SubscriptionRow {
out := make([]SubscriptionRow, 0, len(rows))
for _, r := range rows {
org := core.CHStr(r["org"])
out = append(out, SubscriptionRow{
ID: core.CHStr(r["id"]),
Org: org,
Display: org,
User: core.CHStr(r["user"]),
Plan: core.CHStr(r["plan"]),
Status: foldStatus(core.CHStr(r["last_event"]), core.CHStr(r["status"])),
MRRCents: core.CHInt64(r["mrr_cents"]),
Started: core.CHTime(r["started"]),
Renews: core.CHStr(r["renews"]),
})
}
return rows
return out
}
// 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}
}
// foldStatus resolves the effective status: a subscription whose latest event is
// a cancel is "canceled"; otherwise the last-emitted status snapshot (falling
// back to "active" when the emitter sent none).
func foldStatus(lastEvent, snapshot string) string {
if lastEvent == core.EvSubscriptionCanceled {
return "canceled"
}
return nil
if s := strings.TrimSpace(snapshot); s != "" {
return s
}
return "active"
}
// parseLimit clamps the merged-list cap to [1,5000], defaulting to defaultLimit.
// parseLimit clamps the fleet-list cap to [1,5000], defaulting to defaultLimit.
func parseLimit(s string) int {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n <= 0 {
@@ -0,0 +1,91 @@
package subscriptions
import (
"strings"
"testing"
"time"
"github.com/hanzoai/cloud/clients/admin/core"
)
// TestSubscriptionRowsFromRows proves the warehouse-row → SubscriptionRow mapping
// (the JSON-shape contract) coerces the datastore driver's native types and folds
// the lifecycle status; display honestly mirrors the org slug (no fan-out).
func TestSubscriptionRowsFromRows(t *testing.T) {
started := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
rows := []map[string]any{
{ // active (mrr as driver int64), latest event renewed
"id": "sub_1", "org": "acme", "user": "hanzo/alice",
"plan": "Pro", "status": "active", "mrr_cents": int64(4900),
"last_event": core.EvSubscriptionRenewed, "started": started,
"renews": "2026-08-01T00:00:00Z",
},
{ // canceled wins over a stale "active" snapshot
"id": "sub_2", "org": "beta", "user": "hanzo/bob",
"plan": "Team", "status": "active", "mrr_cents": uint64(0),
"last_event": core.EvSubscriptionCanceled, "started": started,
"renews": "",
},
}
out := subscriptionRowsFromRows(rows)
if len(out) != 2 {
t.Fatalf("got %d rows, want 2", len(out))
}
r0 := out[0]
if r0.ID != "sub_1" || r0.Org != "acme" || r0.Display != "acme" || r0.User != "hanzo/alice" {
t.Fatalf("row0 identity wrong: %+v", r0)
}
if r0.Plan != "Pro" || r0.Status != "active" || r0.MRRCents != 4900 {
t.Fatalf("row0 plan/status/mrr wrong: %+v", r0)
}
if r0.Started != "2026-07-01T12:00:00Z" {
t.Fatalf("row0 started = %q", r0.Started)
}
if r0.Renews != "2026-08-01T00:00:00Z" {
t.Fatalf("row0 renews = %q", r0.Renews)
}
if out[1].Status != "canceled" {
t.Fatalf("row1 status = %q, want canceled (latest-event folds)", out[1].Status)
}
}
func TestFoldStatus(t *testing.T) {
if got := foldStatus(core.EvSubscriptionCanceled, "active"); got != "canceled" {
t.Fatalf("cancel fold = %q", got)
}
if got := foldStatus(core.EvSubscriptionRenewed, "trialing"); got != "trialing" {
t.Fatalf("snapshot passthrough = %q", got)
}
if got := foldStatus(core.EvSubscriptionCreated, ""); got != "active" {
t.Fatalf("empty-snapshot default = %q", got)
}
}
// TestSubscriptionsSQLInjectionSafe asserts the query is fully static over the
// closed event-name set — the warehouse table, no user-derived interpolation.
func TestSubscriptionsSQLInjectionSafe(t *testing.T) {
sql := subscriptionsSQL()
if !strings.Contains(sql, core.BillingEventsTable) {
t.Fatalf("query must read %s: %q", core.BillingEventsTable, sql)
}
for _, ev := range core.SubscriptionEvents {
if !strings.Contains(sql, "'"+ev+"'") {
t.Fatalf("query missing event %q", ev)
}
}
if strings.Contains(sql, "?") {
t.Fatalf("subscriptions state query takes no positional args: %q", sql)
}
}
func TestParseLimitBounds(t *testing.T) {
if parseLimit("") != defaultLimit || parseLimit("0") != defaultLimit || parseLimit("x") != defaultLimit {
t.Fatal("bad/empty limit must default")
}
if parseLimit("10") != 10 {
t.Fatal("valid limit must pass through")
}
if parseLimit("999999") != 5000 {
t.Fatal("limit must clamp to 5000")
}
}
+7 -6
View File
@@ -106,13 +106,14 @@ func Mount(app *zip.App, deps cloud.Deps) error {
// routes registers the ads surface: the campaign CRUD + the summary roll-up.
func routes(app *zip.App, s *cloud.Service[state]) {
app.Get("/v1/ads/summary", cloud.Handle(s, summary))
g := app.Group("/v1/ads")
g.Get("/summary", cloud.Handle(s, summary))
app.Get("/v1/ads/campaigns", cloud.Handle(s, listCampaigns))
app.Post("/v1/ads/campaigns", cloud.Handle(s, createCampaign))
app.Get("/v1/ads/campaigns/:id", cloud.Handle(s, getCampaign))
app.Put("/v1/ads/campaigns/:id", cloud.Handle(s, updateCampaign))
app.Delete("/v1/ads/campaigns/:id", cloud.Handle(s, deleteCampaign))
g.Get("/campaigns", cloud.Handle(s, listCampaigns))
g.Post("/campaigns", cloud.Handle(s, createCampaign))
g.Get("/campaigns/:id", cloud.Handle(s, getCampaign))
g.Put("/campaigns/:id", cloud.Handle(s, updateCampaign))
g.Delete("/campaigns/:id", cloud.Handle(s, deleteCampaign))
}
// ---- shared helpers (mirror clients/crm) ----
+128 -23
View File
@@ -28,7 +28,9 @@ import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
mrand "math/rand/v2"
"net/http"
"os"
"path/filepath"
@@ -107,6 +109,13 @@ type state struct {
// per subsystem. Empty only on a deployment that configured no default, in
// which case create still requires an explicit model.
defaultModel string
// failoverModel is the reliable model a run falls over to when the agent's own
// model stays throttled (429/overloaded) after bounded retries
// (deps.AIFallbackModel, default "best"). It makes an autonomous bot reply
// still land when the throttled default flash model is overloaded. Empty
// disables failover (retry-only). Only the run path reads it — interactive
// chat is untouched.
failoverModel string
// bill is the shared per-org gate+meter (reuses deps.Metering, the ONE
// commerce client — the same object ml/provisioning use). Nil/!Enabled()
// makes Gate allow and Meter a no-op, so an unconfigured deployment runs
@@ -262,11 +271,12 @@ func Mount(app *zip.App, deps cloud.Deps) error {
s := &cloud.Service[state]{
Base: cloud.NewBase(deps, "agents"),
State: state{
store: store,
ai: deps.AI,
defaultModel: strings.TrimSpace(deps.AIDefaultModel),
bill: cloud.NewResourceMeter(deps, meterKind),
bus: newBus(),
store: store,
ai: deps.AI,
defaultModel: strings.TrimSpace(deps.AIDefaultModel),
failoverModel: strings.TrimSpace(deps.AIFallbackModel),
bill: cloud.NewResourceMeter(deps, meterKind),
bus: newBus(),
// TASKS PLUG-IN POINT: durable execution rides hanzoai/tasks, not a
// bespoke engine. Default is record-only; wiring client.Dial(TASKS_URL)
// from github.com/hanzoai/tasks/pkg/sdk/client here makes control forward
@@ -276,6 +286,7 @@ func Mount(app *zip.App, deps cloud.Deps) error {
}
mounted = s
g := app.Group("/v1/agents")
app.Get("/v1/agents", cloud.Handle(s, list))
app.Post("/v1/agents", cloud.Handle(s, create))
// The static org-wide surfaces are listed before the :ref wildcard for reading
@@ -284,18 +295,18 @@ func Mount(app *zip.App, deps cloud.Deps) error {
// a ref). Registration order decides nothing here — it only decides which
// handler silently wins when two patterns are byte-identical, which is a
// collision, not a precedence.
app.Get("/v1/agents/metrics", cloud.Handle(s, metrics))
app.Get("/v1/agents/activity", cloud.Handle(s, activity))
g.Get("/metrics", cloud.Handle(s, metrics))
g.Get("/activity", cloud.Handle(s, activity))
// Live agent-session control plane: /v1/agents/sessions[/...].
mountSessions(s, app)
// Agent targets: /v1/agents/targets[/...] — the #48 dispatch destinations a
// session runs on.
mountTargets(s, app)
app.Get("/v1/agents/:ref", cloud.Handle(s, get))
app.Patch("/v1/agents/:ref", cloud.Handle(s, update))
app.Delete("/v1/agents/:ref", cloud.Handle(s, del))
app.Post("/v1/agents/:ref/run", cloud.Handle(s, run))
app.Get("/v1/agents/:ref/runs", cloud.Handle(s, runs))
g.Get("/:ref", cloud.Handle(s, get))
g.Patch("/:ref", cloud.Handle(s, update))
g.Delete("/:ref", cloud.Handle(s, del))
g.Post("/:ref/run", cloud.Handle(s, run))
g.Get("/:ref/runs", cloud.Handle(s, runs))
// Long-running scheduler: invokes each long-running agent's run on its cron
// cadence through the SAME runAgent path as the HTTP handler (one run path,
@@ -665,11 +676,12 @@ func runAgent(s *cloud.Service[state], ctx context.Context, a Agent, input, acto
return Run{}, err
}
r := executeRun(ctx, s.State.ai, a.Org, a, input)
r := executeRun(ctx, s.State.ai, a.Org, a, input, s.State.failoverModel)
span.SetAttributes(
attribute.String("hanzo.agent.run_id", r.ID),
attribute.String("hanzo.agent.run_status", r.Status),
attribute.Int64("hanzo.agent.duration_ms", r.DurationMs),
attribute.String("gen_ai.response.model", r.Model),
)
if r.Status == "error" {
span.SetStatus(codes.Error, r.Error)
@@ -685,12 +697,14 @@ func runAgent(s *cloud.Service[state], ctx context.Context, a Agent, input, acto
openRunSession(s, ctx, a, r, actor)
// Bill only a successful run (mirrors the edge gate: failed work is not
// charged). Rich attribution: product=agent (Provider), the agent's model,
// and the actor for the audit trail. Fire-and-forget on a background context.
// charged). Rich attribution: product=agent (Provider), the model ACTUALLY
// used (r.Model — a failover run bills the reliable model it fell over to, not
// the throttled one it started on), and the actor for the audit trail.
// Fire-and-forget on a background context.
if r.Status == "ok" {
s.State.bill.MeterUsage(a.Org, meterKind, metering.Usage{
AmountCents: fee,
Model: a.Model,
Model: r.Model,
Actor: actor,
RequestID: requestID,
ClientIP: clientIP,
@@ -699,11 +713,31 @@ func runAgent(s *cloud.Service[state], ctx context.Context, a Agent, input, acto
return r, nil
}
// executeRun composes the agent's instructions with the caller input, runs one
// real chat completion through the AI client, and returns the resulting Run —
// status "ok" with output, or "error" with the upstream failure. Pure of HTTP
// and persistence so it is directly testable; the caller records + responds.
func executeRun(ctx context.Context, ai types.AIClient, org string, a Agent, input string) Run {
// maxAttempts bounds retries of a SINGLE model's completion on a transient
// upstream failure (429 / 5xx / empty-choices / "Platform overloaded"). Retrying
// a completion is side-effect-free — nothing bills until it succeeds — so a
// bounded retry with jittered backoff turns an intermittent gateway 429 into a
// delivered reply instead of a dropped one.
const maxAttempts = 3
// retryBaseDelay / retryMaxDelay bound the exponential, equal-jittered backoff
// between attempts. Small by design: a gateway overload clears in well under a
// second, and a run must not stall a bot conversation.
const (
retryBaseDelay = 150 * time.Millisecond
retryMaxDelay = 2 * time.Second
)
// executeRun composes the agent's instructions with the caller input and runs
// one chat completion through the AI client — with a bounded retry on transient
// upstream overload and, if the agent's own model stays throttled, ONE failover
// to the deployment's reliable model (fallback) so an autonomous bot reply still
// lands. It returns the resulting Run — status "ok" with output and Model set to
// the model that ACTUALLY answered (so metering bills that model), or "error"
// with the final upstream failure. Pure of HTTP and persistence so it is directly
// testable; the caller records + responds. This reliability policy is the agent
// runner's ALONE — the interactive user-facing chat path is untouched.
func executeRun(ctx context.Context, ai types.AIClient, org string, a Agent, input, fallback string) Run {
// Child step span; the AI client opens its own GenAI span nested under this.
ctx, span := agentTracer.Start(ctx, "agent.step", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
@@ -717,11 +751,11 @@ func executeRun(ctx context.Context, ai types.AIClient, org string, a Agent, inp
prompt += in
}
start := time.Now()
resp, aiErr := ai.ChatCompletion(ctx, &types.ChatRequest{Model: a.Model, Prompt: prompt, Org: org})
resp, used, aiErr := completeWithFailover(ctx, ai, org, prompt, a.Model, fallback)
dur := time.Since(start).Milliseconds()
id, _ := genID("run")
r := Run{
ID: id, Org: org, AgentName: a.Name, Model: a.Model, Input: input,
ID: id, Org: org, AgentName: a.Name, Model: used, Input: input,
DurationMs: dur, CreatedAt: time.Now().Unix(),
}
if aiErr != nil {
@@ -738,6 +772,77 @@ func executeRun(ctx context.Context, ai types.AIClient, org string, a Agent, inp
return r
}
// completeWithFailover runs the completion on the agent's model with a bounded
// retry (completeWithRetry), then — only if that model is STILL throttled after
// its retries — fails over ONCE to fallback, a reliable model. It returns the
// response, the model that actually produced it (for honest metering), and the
// final error. A non-transient failure on either model returns immediately (the
// next model would fail identically). ONE ordered mechanism, no config sprawl.
func completeWithFailover(ctx context.Context, ai types.AIClient, org, prompt, model, fallback string) (*types.ChatResponse, string, error) {
models := []string{model}
if f := strings.TrimSpace(fallback); f != "" && f != model {
models = append(models, f)
}
var lastErr error
for _, m := range models {
resp, err := completeWithRetry(ctx, ai, org, prompt, m)
if err == nil {
return resp, m, nil
}
lastErr = err
// Escalate to the next model ONLY on a transient overload; a hard error
// (bad request, auth, unserved model) fails fast — failover cannot help.
if !errors.Is(err, types.ErrUpstreamBusy) {
return nil, m, err
}
}
return nil, models[len(models)-1], lastErr
}
// completeWithRetry calls the completion up to maxAttempts times, retrying ONLY a
// transient upstream overload (types.ErrUpstreamBusy) with jittered backoff and
// respecting context cancellation. A non-transient error returns immediately.
func completeWithRetry(ctx context.Context, ai types.AIClient, org, prompt, model string) (*types.ChatResponse, error) {
var lastErr error
for attempt := 0; attempt < maxAttempts; attempt++ {
resp, err := ai.ChatCompletion(ctx, &types.ChatRequest{Model: model, Prompt: prompt, Org: org})
if err == nil {
return resp, nil
}
lastErr = err
if !errors.Is(err, types.ErrUpstreamBusy) {
return nil, err // permanent — do not burn retries repeating it
}
if attempt == maxAttempts-1 {
break
}
if err := sleepBackoff(ctx, attempt); err != nil {
return nil, err // context cancelled/expired mid-backoff
}
}
return nil, lastErr
}
// sleepBackoff waits an exponential, equal-jittered delay before the next
// attempt, or returns the context error if the caller's deadline fires first.
func sleepBackoff(ctx context.Context, attempt int) error {
d := retryBaseDelay << attempt
if d > retryMaxDelay {
d = retryMaxDelay
}
// Equal jitter: half fixed, half random in [0, d/2) — spreads retries without
// ever collapsing the delay to ~0 (guarantees forward progress under load).
wait := d/2 + time.Duration(mrand.Int64N(int64(d/2)+1))
t := time.NewTimer(wait)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}
func runs(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
+2 -2
View File
@@ -157,7 +157,7 @@ func TestExecuteRunOK(t *testing.T) {
ai := &fakeAI{content: "hi there"}
a := mk("maxpower", "greeter")
a.Instructions = "You are a greeter."
r := executeRun(context.Background(), ai, "maxpower", a, "say hi")
r := executeRun(context.Background(), ai, "maxpower", a, "say hi", "")
if r.Status != "ok" {
t.Fatalf("want ok, got %q err=%q", r.Status, r.Error)
@@ -178,7 +178,7 @@ func TestExecuteRunOK(t *testing.T) {
func TestExecuteRunRecordsError(t *testing.T) {
ai := &fakeAI{err: errors.New("model unavailable")}
r := executeRun(context.Background(), ai, "maxpower", mk("maxpower", "x"), "in")
r := executeRun(context.Background(), ai, "maxpower", mk("maxpower", "x"), "in", "")
if r.Status != "error" {
t.Fatalf("want error status, got %q", r.Status)
}
+5 -2
View File
@@ -13,8 +13,8 @@ import (
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/types"
"github.com/hanzoai/cloud/clients/metering"
"github.com/hanzoai/cloud/types"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
@@ -88,7 +88,10 @@ func mountBilled(t *testing.T, commerceURL string, ai types.AIClient) *zip.App {
t.Fatalf("metering.New: %v", err)
}
app := zip.New(zip.Config{Logger: luxlog.New("test")})
deps := cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir(), AI: ai, Metering: m}
// AIFallbackModel="best" arms the agent runner's failover so the retry/failover
// tests exercise the real escalation path; it never fires for a run whose model
// answers (or fails non-transiently), so the other billed tests are unaffected.
deps := cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir(), AI: ai, Metering: m, AIFallbackModel: "best"}
if err := Mount(app, deps); err != nil {
t.Fatalf("Mount: %v", err)
}
+84 -4
View File
@@ -2,7 +2,12 @@ package agents
import (
"context"
"os"
"strconv"
"strings"
"sync"
luxlog "github.com/luxfi/log"
)
// mailbox.go is the LIVE hand-off between a routed run's durable owner (the
@@ -35,6 +40,11 @@ type RoutedRun struct {
Prompt string `json:"prompt"`
CloneURL string `json:"cloneUrl"`
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
// Actor + AgentRef are CLOUD-SIDE attribution for the completion path (session
// close + PR assignee). They are NOT part of routedRunView, so they never cross
// to the executing machine — the machine needs neither.
Actor string `json:"actor,omitempty"`
AgentRef string `json:"agentRef,omitempty"`
}
// RoutedResult is a routed run's terminal outcome, reported by the machine and
@@ -96,22 +106,92 @@ type mailbox struct {
queues map[string][]*offer
byRun map[string]*offer
signal map[string]chan struct{}
// inflight is the per-org set of live routed sessions (offered, not yet finished),
// the gauge the per-org admission cap reads. A SET keyed by session id (not a bare
// counter) so a re-offer after a restart re-adds idempotently and a superseded
// offer never double-counts or wrongly decrements the still-live session.
inflight map[string]map[string]struct{}
}
func newMailbox() *mailbox {
return &mailbox{
queues: map[string][]*offer{},
byRun: map[string]*offer{},
signal: map[string]chan struct{}{},
queues: map[string][]*offer{},
byRun: map[string]*offer{},
signal: map[string]chan struct{}{},
inflight: map[string]map[string]struct{}{},
}
}
func (m *mailbox) inflightAddLocked(org, sess string) {
s := m.inflight[org]
if s == nil {
s = map[string]struct{}{}
m.inflight[org] = s
}
s[sess] = struct{}{}
}
func (m *mailbox) inflightRemoveLocked(org, sess string) {
if s := m.inflight[org]; s != nil {
delete(s, sess)
if len(s) == 0 {
delete(m.inflight, org)
}
}
}
// InFlight returns how many routed runs an org has live (offered, not yet finished).
func (m *mailbox) InFlight(org string) int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.inflight[org])
}
// routedMailbox is the ONE process-wide rendezvous, shared by the coding
// delivery activity (Offer/Await) and the machine-facing HTTP surface
// (Claim/Report). One mailbox, one way.
//
// SINGLE-REPLICA DEPENDENCY (accepted, inherited). This rendezvous is IN-PROCESS: the
// durable delivery activity (Offer/Await, on whichever replica's tasks worker polls
// the agent-routed queue) and the external machine's POST /claim (ingress load-
// balanced to any replica) must land on the SAME process, because the mailbox is a
// package global, not a shared broker. cloud already runs HARD single-replica —
// Recreate, replicas:1 — because the embedded Badger KMS holds an exclusive file lock
// and the audit sequence is an in-memory counter (infra/k8s/operator/crs/cloud.yaml),
// so route-work INHERITS that guarantee for free and needs no broker. assertSingleReplica
// logs the assumption at mount and warns loudly if a multi-replica signal is present.
//
// IF cloud is ever made multi-replica (the KMS lock lifted): this rendezvous MUST
// become replica-aware — either a sticky route that pins a target's /claim to the
// replica whose worker owns its delivery, or a shared broker (the embedded NATS/
// JetStream already in-process, keyed by (org,target)) so Offer and Claim meet
// regardless of which replica each hits. Until then, single-replica is the contract.
var routedMailbox = newMailbox()
func mbKey(org, target string) string { return org + "\x00" + target }
// assertSingleReplica records the single-replica assumption the process-global
// rendezvous depends on, and warns LOUDLY if a multi-replica signal is detectable
// (CLOUD_REPLICAS > 1). It does not fail mount — cloud's replicas:1 is enforced by the
// deployment (the KMS lock), so this is a defensive breadcrumb for the day that
// changes, not a runtime gate. Called once from mountRouting.
func assertSingleReplica(log luxlog.Logger) {
if log == nil {
return
}
replicas := 1
if v := strings.TrimSpace(os.Getenv("CLOUD_REPLICAS")); v != "" {
if n, err := strconv.Atoi(v); err == nil {
replicas = n
}
}
if replicas > 1 {
log.Warn("route-work: the routed-run rendezvous is process-global and REQUIRES cloud to run single-replica, but CLOUD_REPLICAS>1 — routed /claim will silently fail on a replica that does not own the delivery. Make the rendezvous replica-aware (sticky target route or shared broker) before scaling out.",
"replicas", replicas)
return
}
log.Info("route-work: routed-run rendezvous is in-process; assumes cloud single-replica (inherited from the KMS exclusive lock)")
}
func mbKey(org, target string) string { return org + "\x00" + target }
func runKey(org, target, sess string) string { return org + "\x00" + target + "\x00" + sess }
// Offer files run for its (org,target) and returns the handle its durable owner
+119
View File
@@ -0,0 +1,119 @@
// personalities.go seeds an org's BUILT-IN agents — the named personas a human
// @-mentions in Hanzo Team (@dev to build, @des to design, @vi for vision). They
// are ordinary rows in the ONE agent registry (this package's store): nothing
// about them is special-cased downstream — they list, project into Team as bot
// members (bots.go), and answer through the SAME agents.RunOnBehalf path every
// other agent uses. The only thing this file adds is a one-time, idempotent
// create so a fresh org has its crew without anyone POSTing them by hand.
//
// One and only one seed: keyed by the registry's UNIQUE(org,name), a re-seed is a
// no-op (errConflict is swallowed). The Name is the @-handle (dev/des/vi), so the
// Team mention resolves to the persona; Description is the human-facing title.
package agents
import (
"context"
"errors"
"strings"
"time"
)
// persona is one built-in agent definition. Name is the lowercase @-handle;
// Description is the display title; Instructions is the system prompt that gives
// the persona its voice and remit.
type persona struct {
Name string
Description string
Instructions string
}
// personalities is the canonical built-in crew. Adding one here is the ONE way a
// new default persona ships — no per-org config, no duplicate definition. The old
// hanzo.ai site's voices, brought into Team.
var personalities = []persona{
{
Name: "dev",
Description: "Dev — the builder",
Instructions: "You are Dev, Hanzo's builder. You ship. When a human @-mentions you " +
"you write the code, wire the change, and report what you did in plain terms — " +
"file paths, commands, results. You prize the smallest correct change, one and " +
"only one way to do a thing, and no ceremony. You never hand-wave: if you built " +
"it you say so with proof; if you're blocked you name the blocker. Terse, exact, " +
"and always moving toward a finished, working solution.",
},
{
Name: "des",
Description: "Des — the designer",
Instructions: "You are Des, Hanzo's designer. You own how it looks and feels — layout, " +
"type, color, motion, the whole experience. When a human @-mentions you, you " +
"think in systems, not one-off screens: a token, a component, a consistent rule " +
"that reads as one product in light and dark. You give concrete, buildable design " +
"direction (spacing, hierarchy, states), not vague taste. Elegant, accessible, and " +
"opinionated — you make the obvious thing beautiful.",
},
{
Name: "vi",
Description: "Vi — the visionary",
Instructions: "You are Vi, Hanzo's visionary lead. You hold the big picture and the long " +
"arc — where the product is going, why it matters, and what to do next to get there. " +
"When a human @-mentions you, you connect the dots across the org, cut through noise " +
"to the one thing that matters, and rally the crew around it. You think in bets and " +
"outcomes, name the strategy plainly, and turn a sprawling ask into a sharp, " +
"sequenced plan. Inspiring, decisive, and grounded in what actually ships.",
},
}
// SeedPersonalities ensures the built-in crew exists for org. Idempotent: an
// already-present persona (UNIQUE org+name) is left untouched, so it is safe to
// call on every org first-touch (a new Team workspace, say). Returns the number
// newly created.
//
// It needs a model to attach — the deployment's configured default. With no
// default model, seeding is a NO-OP (0, nil): an org gets its crew the moment the
// binary has a model to run them on, never a half-created persona that can't run.
// A subsystem that is not mounted also no-ops rather than erroring, so a caller on
// the login path can call it best-effort without ever blocking a human.
func SeedPersonalities(ctx context.Context, org string) (int, error) {
if mounted == nil || mounted.State.store == nil {
return 0, nil
}
org = strings.TrimSpace(org)
if org == "" {
return 0, nil
}
model := strings.TrimSpace(mounted.State.defaultModel)
if model == "" {
return 0, nil
}
created := 0
now := time.Now().Unix()
for _, p := range personalities {
id, err := genID("agent")
if err != nil {
return created, err
}
a := Agent{
ID: id,
Org: org,
Name: p.Name,
Model: model,
Instructions: p.Instructions,
Description: p.Description,
Status: "ready",
CreatedAt: now,
UpdatedAt: now,
}
err = mounted.State.store.Create(ctx, a)
switch {
case err == nil:
created++
case errors.Is(err, errConflict):
// Already seeded — the one-way idempotent no-op.
default:
return created, err
}
}
return created, nil
}
+85
View File
@@ -0,0 +1,85 @@
package agents
import (
"context"
"testing"
luxlog "github.com/luxfi/log"
"github.com/hanzoai/cloud"
)
// mountSeedTest wires the `mounted` singleton to a fresh store with a default
// model, so SeedPersonalities has both a store to write and a model to attach.
func mountSeedTest(t *testing.T, defaultModel string) {
t.Helper()
prev := mounted
mounted = &cloud.Service[state]{
Base: cloud.Base{Log: luxlog.New("test")},
State: state{store: testStore(t), defaultModel: defaultModel},
}
t.Cleanup(func() { mounted = prev })
}
// TestSeedPersonalities proves the built-in crew is created once, is idempotent,
// projects the exact @-handles a human mentions in Team, and no-ops without a
// model — the full contract of the one-way seed.
func TestSeedPersonalities(t *testing.T) {
mountSeedTest(t, "zen-1")
ctx := context.Background()
const org = "acme"
// First seed creates the whole crew.
n, err := SeedPersonalities(ctx, org)
if err != nil {
t.Fatalf("SeedPersonalities: %v", err)
}
if n != len(personalities) {
t.Fatalf("created %d, want %d (the full built-in crew)", n, len(personalities))
}
// They are ordinary registry rows — ListForOrg returns them with the @-handles.
list, err := ListForOrg(ctx, org)
if err != nil {
t.Fatalf("ListForOrg: %v", err)
}
got := map[string]Agent{}
for _, a := range list {
got[a.Name] = a
}
for _, want := range []string{"dev", "des", "vi"} {
a, ok := got[want]
if !ok {
t.Fatalf("@%s not seeded; have %v", want, keysOf(got))
}
if a.Model != "zen-1" || a.Status != "ready" || a.Instructions == "" {
t.Fatalf("@%s malformed: model=%q status=%q instr=%dB", want, a.Model, a.Status, len(a.Instructions))
}
}
// Idempotent: a re-seed creates nothing and never duplicates.
n2, err := SeedPersonalities(ctx, org)
if err != nil {
t.Fatalf("re-seed: %v", err)
}
if n2 != 0 {
t.Fatalf("re-seed created %d, want 0 (idempotent)", n2)
}
if list2, _ := ListForOrg(ctx, org); len(list2) != len(personalities) {
t.Fatalf("after re-seed: %d agents, want %d (no dup)", len(list2), len(personalities))
}
// No default model → no-op, never a half-seeded org.
mountSeedTest(t, "")
if n3, err := SeedPersonalities(ctx, "globex"); err != nil || n3 != 0 {
t.Fatalf("no-model seed = (%d,%v), want (0,nil)", n3, err)
}
}
func keysOf(m map[string]Agent) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
+161
View File
@@ -0,0 +1,161 @@
package agents
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"testing"
"github.com/hanzoai/cloud/types"
)
// scriptedAI is a deterministic AIClient that models a throttled gateway: a call
// whose model still has "busy budget" (or when allBusy is set) returns a
// transient ErrUpstreamBusy-tagged error — exactly the shape the real httpAI
// produces for a 429 / "Platform overloaded" — otherwise it returns content. It
// records every call + model so a test can assert the retry count and which model
// finally answered. It does NOT implement types.ModelLister, so create fails open
// (any model is accepted without a catalog round-trip).
type scriptedAI struct {
mu sync.Mutex
calls int
models []string
busy map[string]int // model -> remaining transient failures before it succeeds
allBusy bool // every call fails transiently (failover can never win)
content string
}
func (s *scriptedAI) ChatCompletion(_ context.Context, req *types.ChatRequest) (*types.ChatResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.calls++
s.models = append(s.models, req.Model)
if s.allBusy {
return nil, fmt.Errorf("429 Platform overloaded: %w", types.ErrUpstreamBusy)
}
if n := s.busy[req.Model]; n > 0 {
s.busy[req.Model] = n - 1
return nil, fmt.Errorf("429 Platform overloaded: %w", types.ErrUpstreamBusy)
}
return &types.ChatResponse{Content: s.content}, nil
}
func (s *scriptedAI) Embed(context.Context, *types.EmbedRequest) ([][]float32, error) {
return nil, nil
}
func (s *scriptedAI) callCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.calls
}
func (s *scriptedAI) modelsSeen() []string {
s.mu.Lock()
defer s.mu.Unlock()
return append([]string(nil), s.models...)
}
// TestRunRetriesTransientThenSucceedsBillsOnce is the core reliability contract:
// a gateway that 429s TWICE then answers must yield ONE successful run and EXACTLY
// ONE debit — not a dropped reply (the old single-shot behavior), and not three
// debits (retries must never bill the failed attempts; only the final success
// bills, once).
func TestRunRetriesTransientThenSucceedsBillsOnce(t *testing.T) {
bs := &billServer{available: 100000}
ai := &scriptedAI{content: "recovered", busy: map[string]int{"gpt-4o-mini": 2}}
app := mountBilled(t, bs.start(t), ai)
do(t, app, http.MethodPost, "/v1/agents", "acme",
map[string]any{"name": "a", "model": "gpt-4o-mini", "instructions": "x"})
code, body := do(t, app, http.MethodPost, "/v1/agents/a/run", "acme", map[string]any{"input": "hi"})
if code != http.StatusOK {
t.Fatalf("run that recovers after 2 retries want 200, got %d (%s)", code, body)
}
// The reply must be the recovered content — not dropped.
var rv runView
_ = json.Unmarshal(body, &rv)
if rv.Output != "recovered" {
t.Fatalf("reply must carry the recovered output, got %q", rv.Output)
}
if got := ai.callCount(); got != 3 {
t.Fatalf("want 3 completion attempts (2 busy + 1 ok), got %d", got)
}
// Exactly one debit for the single eventual success — never one-per-attempt.
if !waitForDebit(func() bool { return bs.debits() == 1 }) {
t.Fatalf("a retried-then-succeeded run must debit exactly once, got %d", bs.debits())
}
// Give any erroneous extra debit a chance to land; assert it did not.
if bs.debits() != 1 {
t.Fatalf("retries must not bill failed attempts: want 1 debit, got %d", bs.debits())
}
}
// TestRunFailsOverToReliableModelAndBillsIt proves the escalation: the agent's
// own model stays throttled through all its retries, so the run fails over to the
// configured reliable model ("best"), still lands a reply, and bills the model
// ACTUALLY used (best) — not the throttled one it started on.
func TestRunFailsOverToReliableModelAndBillsIt(t *testing.T) {
bs := &billServer{available: 100000}
// gpt-4o-mini is busy for MORE than its retry budget (never recovers); "best"
// answers first try.
ai := &scriptedAI{content: "from-best", busy: map[string]int{"gpt-4o-mini": 99}}
app := mountBilled(t, bs.start(t), ai)
do(t, app, http.MethodPost, "/v1/agents", "acme",
map[string]any{"name": "a", "model": "gpt-4o-mini", "instructions": "x"})
code, body := do(t, app, http.MethodPost, "/v1/agents/a/run", "acme", map[string]any{"input": "hi"})
if code != http.StatusOK {
t.Fatalf("failover run want 200, got %d (%s)", code, body)
}
var rv runView
_ = json.Unmarshal(body, &rv)
if rv.Output != "from-best" {
t.Fatalf("failover reply must come from the reliable model, got %q", rv.Output)
}
// Primary exhausted its retries, then exactly one failover attempt on "best".
if got := ai.callCount(); got != maxAttempts+1 {
t.Fatalf("want %d attempts (primary exhausted + 1 failover), got %d", maxAttempts+1, got)
}
seen := ai.modelsSeen()
if seen[len(seen)-1] != "best" {
t.Fatalf("the final attempt must be the failover model, got %q", seen[len(seen)-1])
}
if !waitForDebit(func() bool { return bs.debits() == 1 }) {
t.Fatalf("a failover run must debit exactly once, got %d", bs.debits())
}
_, ubody := bs.lastDebit()
var u struct {
Model string `json:"model"`
}
_ = json.Unmarshal(ubody, &u)
if u.Model != "best" {
t.Fatalf("failover run must bill the model actually used (best), got %q", u.Model)
}
}
// TestRunAllAttemptsBusyExhaustedNoDebit proves the failure contract: when EVERY
// attempt on BOTH the agent's model and the failover model is throttled, the run
// records a clean error (502) and NOTHING is billed — a persistent overload never
// costs the tenant, and never fabricates a reply.
func TestRunAllAttemptsBusyExhaustedNoDebit(t *testing.T) {
bs := &billServer{available: 100000}
ai := &scriptedAI{allBusy: true}
app := mountBilled(t, bs.start(t), ai)
do(t, app, http.MethodPost, "/v1/agents", "acme",
map[string]any{"name": "a", "model": "gpt-4o-mini", "instructions": "x"})
code, _ := do(t, app, http.MethodPost, "/v1/agents/a/run", "acme", map[string]any{"input": "hi"})
if code != http.StatusBadGateway {
t.Fatalf("exhausted run want 502, got %d", code)
}
// Primary retries (maxAttempts) + failover retries (maxAttempts) were all tried.
if got := ai.callCount(); got != 2*maxAttempts {
t.Fatalf("want %d attempts (primary + failover, both exhausted), got %d", 2*maxAttempts, got)
}
if waitForDebit(func() bool { return bs.debits() > 0 }) {
t.Fatalf("a fully-throttled run must NOT be billed, got %d debits", bs.debits())
}
}
+60 -7
View File
@@ -7,6 +7,7 @@ import (
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/principal"
"github.com/zap-proto/zip"
)
@@ -38,9 +39,47 @@ const (
// 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))
assertSingleReplica(s.Log)
g := app.Group("/v1/agents")
g.Post("/targets/:id/claim-key", cloud.Handle(s, mintClaimKey))
g.Post("/targets/:id/claim", cloud.Handle(s, claimRoutedRun))
g.Post("/targets/:id/runs/:runId/report", cloud.Handle(s, reportRoutedRun))
}
// caller is the VALIDATED principal id (X-User-Id) — the machine-owner identity for
// route-work. tenant() already required a validated principal, so on any handler that
// resolved an org this is non-empty.
func caller(c *zip.Ctx) string { return strings.TrimSpace(c.User()) }
// ownsTarget reports whether the caller may MANAGE this target's route-work plane —
// mint/rotate the claim key, claim, report, patch, delete. A machine belongs to the
// principal that registered it (least privilege, AC-6): its owner may manage it, and
// an org admin (self-service org management, the admin-org model's isAdmin) may manage
// any of the org's targets. An UNOWNED (pre-migration) row is admin-only until its
// owner re-registers — register binds the owner. Fail-closed: an empty caller or an
// empty owner never satisfies the ownership arm, so a non-validated request or a
// pre-migration row is never owner-managed.
func ownsTarget(c *zip.Ctx, t Target) bool {
if principal.IsOrgAdmin(c) || principal.IsSuperAdmin(c) {
return true
}
u := caller(c)
return t.Owner != "" && u != "" && u == t.Owner
}
// authorizeTargetManage resolves the (org,id) target and gates it on ownsTarget,
// collapsing every failure — cross-org, unknown, or not-owned — to the SAME
// errTargetNotFound so the machine surface never distinguishes them (no oracle). The
// resolved target is returned for the caller to use (avoids a second read).
func authorizeTargetManage(s *cloud.Service[state], c *zip.Ctx, org, id string) (Target, error) {
t, err := s.State.store.GetTarget(c.Context(), org, id)
if err != nil {
return Target{}, errTargetNotFound // unknown / cross-org -> no oracle
}
if !ownsTarget(c, t) {
return Target{}, errTargetNotFound
}
return t, nil
}
// mintClaimKey (re)mints the target's claim key and returns it ONCE. Only the
@@ -52,11 +91,12 @@ func mintClaimKey(s *cloud.Service[state], c *zip.Ctx) error {
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 {
// The target must exist in this org AND the caller must OWN it (or be an org
// admin) before it can (re)mint a capability — minting rotates the key, so an
// un-scoped mint would let any org member strand a victim's daemon and steal its
// runs. Every failure collapses to the same not-found (no oracle).
if _, err := authorizeTargetManage(s, c, org, id); err != nil {
return zip.ErrNotFound("target not found")
} else if err != nil {
return zip.Errorf(http.StatusInternalServerError, "target: %v", err)
}
key, err := newClaimKey()
if err != nil {
@@ -77,6 +117,14 @@ func claimRoutedRun(s *cloud.Service[state], c *zip.Ctx) error {
return zip.ErrForbidden("X-Org-Id required")
}
id := idParam(c)
// TWO proofs, both required, both fail-closed to the SAME 403 (no oracle): the
// caller must OWN this machine (or be an org admin) AND hold its claim key. The
// ownership gate is defense in depth — with mint owner-scoped an attacker cannot
// obtain a valid key for a victim's machine, but a claim still refuses a
// non-owner outright rather than resting solely on the capability.
if _, err := authorizeTargetManage(s, c, org, id); err != nil {
return claimAuthError(errTargetNotFound)
}
if err := s.State.store.verifyClaimKey(c.Context(), org, id, c.Header(claimKeyHeader)); err != nil {
return claimAuthError(err)
}
@@ -115,6 +163,11 @@ func reportRoutedRun(s *cloud.Service[state], c *zip.Ctx) error {
}
id := idParam(c)
runID := strings.TrimSpace(c.Param("runId"))
// Same two proofs as claim: own the machine (or org admin) AND hold its key, so a
// non-owner can neither fabricate a report nor complete a victim's run. No oracle.
if _, err := authorizeTargetManage(s, c, org, id); err != nil {
return claimAuthError(errTargetNotFound)
}
if err := s.State.store.verifyClaimKey(c.Context(), org, id, c.Header(claimKeyHeader)); err != nil {
return claimAuthError(err)
}
+168 -3
View File
@@ -40,13 +40,17 @@ func registerAndMint(t *testing.T, app *zip.App, org, host string) (string, stri
if code != 201 && code != 200 {
t.Fatalf("register target: %d %s", code, body)
}
var tv struct{ ID string `json:"id"` }
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"` }
var kv struct {
ClaimKey string `json:"claimKey"`
}
_ = json.Unmarshal(body, &kv)
if kv.ClaimKey == "" {
t.Fatal("claim key empty")
@@ -105,7 +109,9 @@ func TestClaim_CrossMachineAndCrossOrgDenied(t *testing.T) {
if code != 200 {
t.Fatalf("acme must claim its own run, got %d %s", code, body)
}
var rv struct{ SessionID string `json:"sessionId"` }
var rv struct {
SessionID string `json:"sessionId"`
}
_ = json.Unmarshal(body, &rv)
if rv.SessionID != "sess_a" {
t.Fatalf("claimed wrong run: %s", body)
@@ -248,3 +254,162 @@ func TestClaimKey_HashedAtRestAndVerified(t *testing.T) {
t.Fatalf("cross-org verify must fail closed, got %v", err)
}
}
// ---- M2: owner/machine scoping of the claim-key plane ----
// reqAs sends a request with an EXPLICIT principal (X-User-Id) + optional org-admin
// bit and claim key + JSON body, so a test can prove owner/admin scoping distinct
// from org scoping.
func reqAs(t *testing.T, app *zip.App, method, path, org, user string, admin bool, key string, body any) (int, []byte) {
t.Helper()
var r io.Reader
if body != nil {
b, _ := json.Marshal(body)
r = bytes.NewReader(b)
}
req := httptest.NewRequest(method, path, r)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if org != "" {
req.Header.Set("X-Org-Id", org)
}
if user != "" {
req.Header.Set("X-User-Id", user)
}
if admin {
req.Header.Set("X-User-IsOrgAdmin", "true")
}
if key != "" {
req.Header.Set(claimKeyHeader, key)
}
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("Test %s %s: %v", method, path, err)
}
defer func() { _ = resp.Body.Close() }()
out, _ := io.ReadAll(resp.Body)
return resp.StatusCode, out
}
// registerAs registers a target in org OWNED by the given principal, returning its id.
func registerAs(t *testing.T, app *zip.App, org, user, host string) string {
t.Helper()
code, body := reqAs(t, app, "POST", "/v1/agents/targets", org, user, false, "", map[string]any{"label": host, "host": host})
if code != 201 && code != 200 {
t.Fatalf("register target: %d %s", code, body)
}
var tv struct {
ID string `json:"id"`
}
_ = json.Unmarshal(body, &tv)
if tv.ID == "" {
t.Fatalf("register returned no id: %s", body)
}
return tv.ID
}
// A machine belongs to the principal that registered it: a DIFFERENT member of the
// SAME org can neither mint/rotate its claim key, nor patch, nor delete it — only its
// owner or an org admin can. Every refusal collapses to not-found (no oracle).
func TestClaimKeyPlane_OwnerScoped(t *testing.T) {
app := mountApp(t, &fakeAI{content: "x"})
id := registerAs(t, app, "acme", "alice", "evo")
// A non-owner member of the same org is DENIED on every management verb.
if code, _ := reqAs(t, app, "POST", "/v1/agents/targets/"+id+"/claim-key", "acme", "mallory", false, "", nil); code != 404 {
t.Fatalf("non-owner mint must be denied (no oracle -> 404), got %d", code)
}
if code, _ := reqAs(t, app, "PATCH", "/v1/agents/targets/"+id, "acme", "mallory", false, "", map[string]any{"status": TargetOffline}); code != 404 {
t.Fatalf("non-owner patch must be denied, got %d", code)
}
if code, _ := reqAs(t, app, "DELETE", "/v1/agents/targets/"+id, "acme", "mallory", false, "", nil); code != 404 {
t.Fatalf("non-owner delete must be denied, got %d", code)
}
// The OWNER can mint.
code, body := reqAs(t, app, "POST", "/v1/agents/targets/"+id+"/claim-key", "acme", "alice", false, "", nil)
if code != 200 {
t.Fatalf("owner mint must succeed, got %d %s", code, body)
}
// An ORG ADMIN (self-service org management) can manage any of the org's machines.
if code, _ := reqAs(t, app, "POST", "/v1/agents/targets/"+id+"/claim-key", "acme", "boss", true, "", nil); code != 200 {
t.Fatalf("org admin mint must succeed, got %d", code)
}
if code, _ := reqAs(t, app, "PATCH", "/v1/agents/targets/"+id, "acme", "boss", true, "", map[string]any{"status": TargetOnline}); code != 200 {
t.Fatalf("org admin patch must succeed, got %d", code)
}
}
// A non-owner cannot CLAIM a victim's runs or REPORT on them, even if the claim-key
// authorization were somehow satisfied — the ownership gate refuses first.
func TestClaimReport_OwnerScoped(t *testing.T) {
app := mountApp(t, &fakeAI{content: "x"})
old := claimLongPoll
claimLongPoll = 150 * time.Millisecond
defer func() { claimLongPoll = old }()
id := registerAs(t, app, "acme", "alice", "evo")
// Alice mints her machine's key.
code, body := reqAs(t, app, "POST", "/v1/agents/targets/"+id+"/claim-key", "acme", "alice", false, "", nil)
if code != 200 {
t.Fatalf("owner mint: %d %s", code, body)
}
var kv struct {
ClaimKey string `json:"claimKey"`
}
_ = json.Unmarshal(body, &kv)
// Mallory (same org, not the owner) with the RIGHT key is still refused: she does
// not own the machine. (In practice she cannot obtain the key, since mint is
// owner-scoped — this is the defense-in-depth arm.)
if code, _ := reqAs(t, app, "POST", "/v1/agents/targets/"+id+"/claim", "acme", "mallory", false, kv.ClaimKey, nil); code != 403 {
t.Fatalf("non-owner claim must be 403, got %d", code)
}
if code, _ := reqAs(t, app, "POST", "/v1/agents/targets/"+id+"/runs/sess_x/report", "acme", "mallory", false, kv.ClaimKey, map[string]any{"ok": true}); code != 403 {
t.Fatalf("non-owner report must be 403, got %d", code)
}
// The owner with her key claims (no work -> 204) and reports fine.
if code, _ := reqAs(t, app, "POST", "/v1/agents/targets/"+id+"/claim", "acme", "alice", false, kv.ClaimKey, nil); code != 204 {
t.Fatalf("owner claim (no work) must be 204, got %d", code)
}
}
// A pre-migration UNOWNED target (owner=”) is admin-only, and its owner heals it by
// re-registering (register binds the owner). Proven at the store + handler seam.
func TestUnownedTarget_AdminOnly_ThenBoundByRegister(t *testing.T) {
app := mountApp(t, &fakeAI{content: "x"})
ctx := context.Background()
now := time.Now().Unix()
// Seed an unowned row directly, as an upgraded pre-owner DB would carry.
if err := mounted.State.store.CreateTarget(ctx, Target{ID: "tgt_legacy", Org: "acme", Owner: "", Label: "old", Kind: TargetMachine, Status: TargetOnline, Host: "old", CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
// A plain member cannot mint on an unowned row.
if code, _ := reqAs(t, app, "POST", "/v1/agents/targets/tgt_legacy/claim-key", "acme", "alice", false, "", nil); code != 404 {
t.Fatalf("unowned row must be member-denied, got %d", code)
}
// An org admin can.
if code, _ := reqAs(t, app, "POST", "/v1/agents/targets/tgt_legacy/claim-key", "acme", "boss", true, "", nil); code != 200 {
t.Fatalf("unowned row must be admin-manageable, got %d", code)
}
// The owner heals it by re-registering the SAME host — register ADOPTS the
// unowned row and BINDS the owner (no duplicate).
code, body := reqAs(t, app, "POST", "/v1/agents/targets", "acme", "alice", false, "", map[string]any{"label": "old", "host": "old"})
if code != 200 && code != 201 {
t.Fatalf("re-register: %d %s", code, body)
}
var tv struct {
ID string `json:"id"`
}
_ = json.Unmarshal(body, &tv)
if tv.ID != "tgt_legacy" {
t.Fatalf("re-register must adopt the unowned row (same id), got %q", tv.ID)
}
// Now alice (the bound owner) can mint.
if code, _ := reqAs(t, app, "POST", "/v1/agents/targets/tgt_legacy/claim-key", "acme", "alice", false, "", nil); code != 200 {
t.Fatalf("after binding, the owner must be able to mint, got %d", code)
}
}
+9 -9
View File
@@ -48,9 +48,9 @@ func (c *countingAI) Embed(_ context.Context, _ *types.EmbedRequest) ([][]float3
return nil, nil
}
// schedSvc builds a Service + scheduler with NO billing (gate allows) and the given
// schedService builds a Service + scheduler with NO billing (gate allows) and the given
// AI, seeded with the supplied agents. Returns the scheduler for direct tick().
func schedSvc(t *testing.T, ai types.AIClient, seed ...Agent) *scheduler {
func schedService(t *testing.T, ai types.AIClient, seed ...Agent) *scheduler {
t.Helper()
s := &cloud.Service[state]{Base: cloud.Base{Log: luxlog.New("test")}, State: state{store: testStore(t), ai: ai}}
for _, a := range seed {
@@ -83,7 +83,7 @@ func waitFor(cond func() bool) bool {
// run; a tick at a non-matching minute launches none.
func TestSchedulerFiresDueAgent(t *testing.T) {
ai := &countingAI{}
sc := schedSvc(t, ai, longRunning("acme", "cron", "*/5 * * * *"))
sc := schedService(t, ai, longRunning("acme", "cron", "*/5 * * * *"))
ctx := context.Background()
sc.tick(ctx, at(t, "2026-07-01 12:36")) // 36 not multiple of 5 -> no fire
@@ -102,7 +102,7 @@ func TestSchedulerFiresDueAgent(t *testing.T) {
// like an HTTP run — the scheduler shares runAgent.
func TestSchedulerRecordsRun(t *testing.T) {
ai := &countingAI{}
sc := schedSvc(t, ai, longRunning("acme", "cron", "* * * * *"))
sc := schedService(t, ai, longRunning("acme", "cron", "* * * * *"))
ctx := context.Background()
sc.tick(ctx, at(t, "2026-07-01 12:00"))
if !waitFor(func() bool {
@@ -119,7 +119,7 @@ func TestSchedulerRecordsRun(t *testing.T) {
// tick fires; the immediately-following matching tick is skipped (backoff=1).
func TestSchedulerBackoffOnFailure(t *testing.T) {
ai := &countingAI{fail: true}
sc := schedSvc(t, ai, longRunning("acme", "cron", "* * * * *"))
sc := schedService(t, ai, longRunning("acme", "cron", "* * * * *"))
ctx := context.Background()
sc.tick(ctx, at(t, "2026-07-01 12:00"))
@@ -152,7 +152,7 @@ func TestSchedulerBackoffOnFailure(t *testing.T) {
// second matching tick while it is in flight does NOT start a second run.
func TestSchedulerConcurrencyCap(t *testing.T) {
ai := &countingAI{block: make(chan struct{})}
sc := schedSvc(t, ai, longRunning("acme", "cron", "* * * * *"))
sc := schedService(t, ai, longRunning("acme", "cron", "* * * * *"))
ctx := context.Background()
sc.tick(ctx, at(t, "2026-07-01 12:00")) // starts run #1, which blocks
@@ -257,7 +257,7 @@ func TestSchedulerGatesUnfundedRun(t *testing.T) {
// path that lets Shutdown close the store safely.
func TestSchedulerStopDrainsCleanly(t *testing.T) {
ai := &countingAI{}
sc := schedSvc(t, ai, longRunning("acme", "cron", "* * * * *"))
sc := schedService(t, ai, longRunning("acme", "cron", "* * * * *"))
sc.start()
// Fire one run via a direct tick, then stop — stop must return after drain.
sc.tick(context.Background(), at(t, "2026-07-01 12:00"))
@@ -286,7 +286,7 @@ func TestSchedulerStopDrainsCleanly(t *testing.T) {
// rather than waiting the full runTimeout.
func TestSchedulerStopHonorsDeadline(t *testing.T) {
ai := &countingAI{block: make(chan struct{})}
sc := schedSvc(t, ai, longRunning("acme", "cron", "* * * * *"))
sc := schedService(t, ai, longRunning("acme", "cron", "* * * * *"))
sc.start()
sc.tick(context.Background(), at(t, "2026-07-01 12:00"))
if !waitFor(func() bool { return ai.count() == 1 }) {
@@ -310,7 +310,7 @@ func TestSchedulerStopHonorsDeadline(t *testing.T) {
func TestSchedulerOnlyLongRunning(t *testing.T) {
ai := &countingAI{}
one := mk("acme", "one") // one-shot default, no schedule
sc := schedSvc(t, ai, one)
sc := schedService(t, ai, one)
sc.tick(context.Background(), at(t, "2026-07-01 12:00"))
time.Sleep(20 * time.Millisecond)
if ai.count() != 0 {
+12 -11
View File
@@ -182,17 +182,18 @@ func toEventView(e Event) eventView {
// :name would otherwise capture "sessions"). Within the block, the static
// /stream route precedes the /:id param for the same reason.
func mountSessions(s *cloud.Service[state], app *zip.App) {
app.Post("/v1/agents/sessions", cloud.Handle(s, registerSession))
app.Get("/v1/agents/sessions", cloud.Handle(s, listSessions))
app.Get("/v1/agents/sessions/stream", cloud.Handle(s, sessionsStream))
app.Get("/v1/agents/sessions/:id", cloud.Handle(s, getSession))
app.Patch("/v1/agents/sessions/:id", cloud.Handle(s, patchSession))
app.Get("/v1/agents/sessions/:id/tree", cloud.Handle(s, sessionTree))
app.Post("/v1/agents/sessions/:id/events", cloud.Handle(s, appendSessionEvent))
app.Post("/v1/agents/sessions/:id/pause", cloud.Handle(s, pauseSession))
app.Post("/v1/agents/sessions/:id/resume", cloud.Handle(s, resumeSession))
app.Post("/v1/agents/sessions/:id/stop", cloud.Handle(s, stopSession))
app.Post("/v1/agents/sessions/:id/message", cloud.Handle(s, messageSession))
g := app.Group("/v1/agents")
g.Post("/sessions", cloud.Handle(s, registerSession))
g.Get("/sessions", cloud.Handle(s, listSessions))
g.Get("/sessions/stream", cloud.Handle(s, sessionsStream))
g.Get("/sessions/:id", cloud.Handle(s, getSession))
g.Patch("/sessions/:id", cloud.Handle(s, patchSession))
g.Get("/sessions/:id/tree", cloud.Handle(s, sessionTree))
g.Post("/sessions/:id/events", cloud.Handle(s, appendSessionEvent))
g.Post("/sessions/:id/pause", cloud.Handle(s, pauseSession))
g.Post("/sessions/:id/resume", cloud.Handle(s, resumeSession))
g.Post("/sessions/:id/stop", cloud.Handle(s, stopSession))
g.Post("/sessions/:id/message", cloud.Handle(s, messageSession))
}
func idParam(c *zip.Ctx) string { return strings.TrimSpace(c.Param("id")) }
+82 -20
View File
@@ -87,6 +87,7 @@ var errTargetNotFound = errors.New("agents: target not found")
type Target struct {
ID string
Org string
Owner string // the VALIDATED principal (c.User()) that registered this machine; "" for a pre-migration row
Label string
Kind string // laptop | cloud | gpu | cluster | machine
Status string // online | offline | draining
@@ -112,6 +113,7 @@ func (s *Store) migrateTargets() error {
CREATE TABLE IF NOT EXISTS agent_targets (
id TEXT PRIMARY KEY,
org TEXT NOT NULL,
owner TEXT NOT NULL DEFAULT '',
label TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL DEFAULT 'machine',
status TEXT NOT NULL DEFAULT 'online',
@@ -129,9 +131,12 @@ CREATE INDEX IF NOT EXISTS ix_targets_org_created ON agent_targets(org, created_
return fmt.Errorf("migrate targets: %w", err)
}
// Forward, idempotent upgrade for target rows created before the capability +
// metrics columns existed. PRAGMA-guarded, so re-running on an upgraded DB is a
// no-op — the DDL above covers fresh installs, this covers pre-existing ones.
// metrics + owner columns existed. PRAGMA-guarded, so re-running on an upgraded
// DB is a no-op — the DDL above covers fresh installs, this covers pre-existing
// ones. A pre-owner row backfills owner='' (unowned) and is admin-only until its
// owner re-registers (register binds the owner) — see registerTarget.
if err := s.addColumns("agent_targets", map[string]string{
"owner": "TEXT NOT NULL DEFAULT ''",
"spec": "TEXT NOT NULL DEFAULT ''",
"metrics": "TEXT NOT NULL DEFAULT ''",
"metrics_at": "INTEGER NOT NULL DEFAULT 0",
@@ -141,12 +146,12 @@ CREATE INDEX IF NOT EXISTS ix_targets_org_created ON agent_targets(org, created_
return nil
}
const targetCols = `id,org,label,kind,status,capacity,host,spec,metrics,metrics_at,created_at,updated_at`
const targetCols = `id,org,owner,label,kind,status,capacity,host,spec,metrics,metrics_at,created_at,updated_at`
func scanTarget(sc interface{ Scan(...any) error }) (Target, error) {
var t Target
var spec, metrics string
err := sc.Scan(&t.ID, &t.Org, &t.Label, &t.Kind, &t.Status, &t.Capacity, &t.Host,
err := sc.Scan(&t.ID, &t.Org, &t.Owner, &t.Label, &t.Kind, &t.Status, &t.Capacity, &t.Host,
&spec, &metrics, &t.MetricsAt, &t.CreatedAt, &t.UpdatedAt)
if err != nil {
return t, err
@@ -159,8 +164,8 @@ func scanTarget(sc interface{ Scan(...any) error }) (Target, error) {
// CreateTarget inserts one target. The id is caller-generated (genID("tgt")).
func (s *Store) CreateTarget(ctx context.Context, t Target) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO agent_targets (`+targetCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
t.ID, t.Org, t.Label, t.Kind, t.Status, t.Capacity, t.Host,
`INSERT INTO agent_targets (`+targetCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
t.ID, t.Org, t.Owner, t.Label, t.Kind, t.Status, t.Capacity, t.Host,
encodeSpec(t.Spec), encodeMetrics(t.Metrics), t.MetricsAt, t.CreatedAt, t.UpdatedAt)
if err != nil {
return fmt.Errorf("insert target: %w", err)
@@ -203,12 +208,14 @@ func (s *Store) ListTargets(ctx context.Context, org string) ([]Target, error) {
}
// UpdateTarget persists mutable fields for an existing (org,id) target. Scoped by org
// so a cross-tenant id can never mutate another's target.
// so a cross-tenant id can never mutate another's target. owner is persisted too so a
// relink can BIND a previously-unowned row (registerTarget) and a patch preserves the
// owner it read; no client-facing patch field sets owner, so it never moves by mutation.
func (s *Store) UpdateTarget(ctx context.Context, t Target) error {
res, err := s.db.ExecContext(ctx,
`UPDATE agent_targets SET label=?, kind=?, status=?, capacity=?, host=?, spec=?, metrics=?, metrics_at=?, updated_at=?
`UPDATE agent_targets SET owner=?, label=?, kind=?, status=?, capacity=?, host=?, spec=?, metrics=?, metrics_at=?, updated_at=?
WHERE org=? AND id=?`,
t.Label, t.Kind, t.Status, t.Capacity, t.Host,
t.Owner, t.Label, t.Kind, t.Status, t.Capacity, t.Host,
encodeSpec(t.Spec), encodeMetrics(t.Metrics), t.MetricsAt, t.UpdatedAt, t.Org, t.ID)
if err != nil {
return fmt.Errorf("update target: %w", err)
@@ -220,6 +227,32 @@ func (s *Store) UpdateTarget(ctx context.Context, t Target) error {
return nil
}
// GetLinkableTargetByHost returns the target for (org,host) that the caller `owner`
// may re-link — its OWN row, else an UNOWNED (pre-migration) row it may adopt —
// preferring the exact-owner match, newest first. A row owned by a DIFFERENT
// principal is NEVER returned, so a re-link can never clobber another member's
// machine: the caller gets its own row or (falling through in registerTarget) a fresh
// one. errTargetNotFound when nothing linkable exists.
func (s *Store) GetLinkableTargetByHost(ctx context.Context, org, host, owner string) (Target, error) {
host = strings.TrimSpace(host)
if host == "" {
return Target{}, errTargetNotFound
}
row := s.db.QueryRowContext(ctx,
`SELECT `+targetCols+` FROM agent_targets
WHERE org=? AND host=? AND (owner=? OR owner='')
ORDER BY CASE WHEN owner=? THEN 0 ELSE 1 END, created_at DESC, id ASC LIMIT 1`,
org, host, owner, owner)
t, err := scanTarget(row)
if errors.Is(err, sql.ErrNoRows) {
return Target{}, errTargetNotFound
}
if err != nil {
return Target{}, fmt.Errorf("get linkable target by host: %w", err)
}
return t, nil
}
// GetTargetByHost returns an org's target reporting the given host, or
// errTargetNotFound. It is how a re-link of the SAME machine finds its existing target
// (idempotent register) instead of creating a duplicate. Org-scoped: a host string can
@@ -476,11 +509,12 @@ func toTargetView(t Target, load TargetLoad) targetView {
// /v1/agents/:ref wildcard (Fiber matches in registration order) so "targets" is not
// captured as a ref. The static /v1/agents/targets precedes /v1/agents/targets/:id.
func mountTargets(s *cloud.Service[state], app *zip.App) {
app.Post("/v1/agents/targets", cloud.Handle(s, registerTarget))
app.Get("/v1/agents/targets", cloud.Handle(s, listTargets))
app.Get("/v1/agents/targets/:id", cloud.Handle(s, getTarget))
app.Patch("/v1/agents/targets/:id", cloud.Handle(s, patchTarget))
app.Delete("/v1/agents/targets/:id", cloud.Handle(s, deleteTarget))
g := app.Group("/v1/agents")
g.Post("/targets", cloud.Handle(s, registerTarget))
g.Get("/targets", cloud.Handle(s, listTargets))
g.Get("/targets/:id", cloud.Handle(s, getTarget))
g.Patch("/targets/:id", cloud.Handle(s, patchTarget))
g.Delete("/targets/:id", cloud.Handle(s, deleteTarget))
// The #48 route-work machine surface (claim-key, claim long-poll, report)
// lives on the same target routes; register after the CRUD so the
// extra-segment paths are unambiguous.
@@ -548,12 +582,21 @@ func registerTarget(s *cloud.Service[state], c *zip.Ctx) error {
metricsAt = now // the server owns the staleness clock; a client can't forge it
}
// Idempotent re-link: the SAME machine (org+host) refreshes its existing target
// rather than piling up duplicates, so mission-control shows one row per machine
// with live spec/metrics. Only an explicit host keys this — an anonymous target
// (no host) always creates.
// The registering principal OWNS this machine (least privilege): only it (or an
// org admin) may later mint the claim key, claim runs, report, patch, or delete
// it. tenant() already required a validated principal, so this is non-empty.
owner := caller(c)
// Idempotent re-link: the SAME machine (org+host+owner) refreshes its existing
// target rather than piling up duplicates, so mission-control shows one row per
// machine with live spec/metrics. It resolves ONLY the caller's own row (or an
// UNOWNED pre-migration row, which it ADOPTS by binding owner) — a row owned by a
// different member is never touched, so a re-link can never hijack another's
// machine; the caller falls through to create its own. Only an explicit host keys
// this — an anonymous target (no host) always creates.
if host != "" {
if existing, err := s.State.store.GetTargetByHost(c.Context(), org, host); err == nil {
if existing, err := s.State.store.GetLinkableTargetByHost(c.Context(), org, host, owner); err == nil {
existing.Owner = owner // bind an adopted unowned row; no-op if already ours
existing.Label, existing.Kind, existing.Status, existing.Capacity = label, kind, status, capacity
existing.Spec, existing.Metrics, existing.MetricsAt = spec, metrics, metricsAt
existing.UpdatedAt = now
@@ -571,7 +614,7 @@ func registerTarget(s *cloud.Service[state], c *zip.Ctx) error {
return zip.Errorf(http.StatusInternalServerError, "rng: %v", err)
}
t := Target{
ID: id, Org: org, Label: label, Kind: kind, Status: status,
ID: id, Org: org, Owner: owner, Label: label, Kind: kind, Status: status,
Capacity: capacity, Host: host, Spec: spec, Metrics: metrics, MetricsAt: metricsAt,
CreatedAt: now, UpdatedAt: now,
}
@@ -648,6 +691,12 @@ func patchTarget(s *cloud.Service[state], c *zip.Ctx) error {
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "get: %v", err)
}
// Only the machine's owner (or an org admin) may mutate it — a member cannot
// reconfigure/drain another member's machine. Fail-closed to the SAME not-found
// an unknown id gives, so a probe learns nothing about what exists.
if !ownsTarget(c, t) {
return zip.ErrNotFound("target not found")
}
var body patchTargetReq
if err := c.Bind(&body); err != nil {
return err
@@ -729,6 +778,19 @@ func deleteTarget(s *cloud.Service[state], c *zip.Ctx) error {
return zip.ErrForbidden("X-Org-Id required")
}
id := idParam(c)
// Resolve + ownership-gate before deleting: only the machine's owner (or an org
// admin) may deregister it. A cross-org id, an unknown id, and a non-owned id all
// collapse to the same not-found — no oracle.
t, err := s.State.store.GetTarget(c.Context(), org, id)
if err == errTargetNotFound {
return zip.ErrNotFound("target not found")
}
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "get: %v", err)
}
if !ownsTarget(c, t) {
return zip.ErrNotFound("target not found")
}
deleted, err := s.State.store.DeleteTarget(c.Context(), org, id)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "delete: %v", err)
+45 -1
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -156,6 +157,12 @@ func (a *httpAI) ChatCompletion(ctx context.Context, req *types.ChatRequest) (*t
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "chat completion failed")
// Tag a transient overload (429/5xx/"overloaded") with types.ErrUpstreamBusy
// so the agent runner can retry/fail over; a permanent error is left untagged
// and fails fast. The message is preserved either way.
if transientChat(err) {
err = fmt.Errorf("%w: %w", err, types.ErrUpstreamBusy)
}
return nil, fmt.Errorf("cloud: chat completion (model %q): %w", model, err)
}
span.SetAttributes(
@@ -164,8 +171,10 @@ func (a *httpAI) ChatCompletion(ctx context.Context, req *types.ChatRequest) (*t
attribute.Int("gen_ai.usage.output_tokens", resp.Usage.CompletionTokens),
)
if len(resp.Choices) == 0 {
// A 200 with no choices is the gateway's "overloaded, no capacity" shape —
// transient, so tag it busy for retry/failover rather than dropping the reply.
span.SetStatus(codes.Error, "no choices")
return nil, fmt.Errorf("cloud: chat completion (model %q): upstream returned no choices", model)
return nil, fmt.Errorf("cloud: chat completion (model %q): upstream returned no choices: %w", model, types.ErrUpstreamBusy)
}
return &types.ChatResponse{
Content: resp.Choices[0].Message.Content,
@@ -175,6 +184,41 @@ func (a *httpAI) ChatCompletion(ctx context.Context, req *types.ChatRequest) (*t
}, nil
}
// transientChat reports whether an upstream chat-completion error is a transient
// overload the agent runner may safely retry or fail over on: HTTP
// 429/500/502/503/504, or a gateway that surfaces "overloaded" without a typed
// status. A permanent failure — a 4xx that is not 429 (bad request, auth, an
// unserved model) — returns false so it fails fast instead of burning retries.
func transientChat(err error) bool {
if code, ok := httpStatus(err); ok {
switch code {
case http.StatusTooManyRequests, http.StatusInternalServerError,
http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
return true
default:
return false
}
}
// No typed status (a bare transport/overload message) — treat an explicit
// "overloaded" as transient; anything else is not classifiable, so not retried.
return strings.Contains(strings.ToLower(err.Error()), "overloaded")
}
// httpStatus extracts the upstream HTTP status from a go-openai error
// (*openai.APIError for a decoded error body, *openai.RequestError for a raw
// non-2xx), returning ok=false when the error carries no status.
func httpStatus(err error) (int, bool) {
var apiErr *openai.APIError
if errors.As(err, &apiErr) && apiErr.HTTPStatusCode > 0 {
return apiErr.HTTPStatusCode, true
}
var reqErr *openai.RequestError
if errors.As(err, &reqErr) && reqErr.HTTPStatusCode > 0 {
return reqErr.HTTPStatusCode, true
}
return 0, false
}
// Embed returns one vector per input from the gateway's OpenAI-compatible
// /embeddings, authenticated by the SAME credential as ChatCompletion — so the
// semantic tier bills and meters through the ONE org/project-aligned path, never
+44
View File
@@ -3,6 +3,7 @@ package clients
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
@@ -100,6 +101,49 @@ func TestAIHTTP_UpstreamErrorMapped(t *testing.T) {
}
}
// TestAIHTTP_TransientTagged proves the classification the agent runner relies on:
// a transient overload (429 / 5xx / empty-choices) is tagged types.ErrUpstreamBusy
// so the runner retries/fails over, while a permanent 4xx (400) is NOT tagged so it
// fails fast. Without this split the runner would either drop replies on a passing
// 429 or spin retries on an unrecoverable bad request.
func TestAIHTTP_TransientTagged(t *testing.T) {
serve := func(status int, body string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
if status != 0 {
w.WriteHeader(status)
}
_, _ = w.Write([]byte(body))
}))
}
cases := []struct {
name string
status int
body string
transit bool
}{
{"429 overloaded", http.StatusTooManyRequests, `{"error":{"message":"Platform overloaded"}}`, true},
{"503 unavailable", http.StatusServiceUnavailable, `{"error":{"message":"unavailable"}}`, true},
{"500 server", http.StatusInternalServerError, `{"error":{"message":"boom"}}`, true},
{"200 empty choices", 0, `{"id":"x","object":"chat.completion","choices":[]}`, true},
{"400 bad request", http.StatusBadRequest, `{"error":{"message":"bad input"}}`, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv := serve(tc.status, tc.body)
defer srv.Close()
_, err := AIHTTPAt(srv.URL, "sk-test", "deepseek-v4-flash").
ChatCompletion(context.Background(), &types.ChatRequest{Prompt: "x"})
if err == nil {
t.Fatalf("%s: expected an error", tc.name)
}
if got := errors.Is(err, types.ErrUpstreamBusy); got != tc.transit {
t.Fatalf("%s: errors.Is(ErrUpstreamBusy)=%v, want %v (err=%v)", tc.name, got, tc.transit, err)
}
})
}
}
// TestAIHTTP_ServerErrorMapped asserts a 5xx upstream also maps to an error.
func TestAIHTTP_ServerErrorMapped(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+9
View File
@@ -105,6 +105,15 @@ func routes(app *zip.App, s *cloud.Service[state]) {
// resolved IAM-only and fail-closed, into the ONE write core (ingestEvents).
app.Post("/v1/event", cloud.Handle(s, eventIngest))
// Publishable-key direct ingest (publishable.go) — the FASTEST path: a
// write-only pk_ key (HMAC-signed org, no IAM/DB hop) authenticates
// {batch:[WireEvent]} straight into the ONE write core. /v1/ingest/keys mints a
// pk_ for the caller's org; /v1/errors is the type:'error' read lens (validated
// principal — reads never accept the write-only key).
app.Post("/v1/ingest", cloud.Handle(s, ingest))
app.Post("/v1/ingest/keys", cloud.Handle(s, mintKey))
app.Get("/v1/errors", cloud.Handle(s, errorsLens))
// 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
+27 -3
View File
@@ -173,6 +173,7 @@ type CaptureEvent struct {
Quantity uint32 `json:"quantity"`
Revenue float64 `json:"revenue"`
Currency string `json:"currency"`
Error *Exception `json:"error"` // set on type:'error' events (folded into properties.$exception)
Properties map[string]any `json:"properties"`
Library string `json:"library"`
LibraryVer string `json:"libraryVersion"`
@@ -297,12 +298,20 @@ func resolveEventName(e CaptureEvent) string {
return "$identify"
case "group":
return "$group"
case "error":
if name == "" {
return "$error"
}
return name
default: // "event"
return name
}
}
// canonicalType folds the type to the closed set {pageview,identify,group,event}.
// canonicalType folds the type to the closed set
// {pageview,identify,group,error,event}. `error` is first-class so the ingest can
// store type:'error' events under event_type='error' — the key the /v1/errors
// read lens filters on. An unknown type still folds to "event".
func canonicalType(t string) string {
switch strings.ToLower(strings.TrimSpace(t)) {
case "pageview", "page":
@@ -311,6 +320,8 @@ func canonicalType(t string) string {
return "identify"
case "group":
return "group"
case "error":
return "error"
default:
return "event"
}
@@ -604,7 +615,14 @@ func withSource(p map[string]any, source string) map[string]any {
// 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
// A plain mutex-guarded map (the alias set is tiny — /v1/analytics, /batch,
// /tracker) rather than sync.Map: the latter (Go's HashTrieMap) was panicking
// "ran out of hash bits" under the hot ingest path, 500-ing EVERY capture. A
// bounded map+mutex has no trie state to corrupt and cannot panic here.
var (
deprecatedMu sync.Mutex
deprecatedSeen = make(map[string]struct{}, 8)
)
// 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
@@ -612,7 +630,13 @@ var deprecatedOnce sync.Map
// warehouse).
func deprecated(s *cloud.Service[state], c *zip.Ctx, canonical string) {
p := c.Path()
if _, seen := deprecatedOnce.LoadOrStore(p, struct{}{}); seen {
deprecatedMu.Lock()
_, seen := deprecatedSeen[p]
if !seen {
deprecatedSeen[p] = struct{}{}
}
deprecatedMu.Unlock()
if seen {
return
}
s.Log.Warn("deprecated analytics ingest endpoint; migrate to the canonical event front door",
+22
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"),
+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)
}
}
+328
View File
@@ -0,0 +1,328 @@
// 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.
// publishable.go — the FASTEST capture path: a write-only PUBLISHABLE KEY (pk_…)
// that authenticates a direct-to-datastore ingest with ZERO network hop.
//
// POST /v1/ingest body: {batch:[WireEvent]} auth: pk_… -> {accepted,dropped}
// POST /v1/ingest/keys mint a pk_ for the caller's org (validated principal)
// GET /v1/errors recent type:'error' events for the org (read lens)
//
// WHY a distinct key from the IAM hk-/sk-/pk- family: those resolve through IAM
// (get-user?accessKey — a network round-trip) and mint a FULL principal that can
// READ. A publishable key is meant to ship in a browser bundle, so it must be
// write-only and cheap to verify. This key is:
//
// - INGEST-ONLY BY CONSTRUCTION. The `pk_` (underscore) prefix is deliberately
// NOT in isAPIKey's set (hk-/sk-/pk-/fw_/hz_, all dash/`fw_`/`hz_`), so the
// identity boundary (SanitizeIdentity) and OrgForKey both REFUSE it — it can
// never become a bearer principal, so it can never read. Its only door is the
// ingest verifier below. Write-only is a property of WHICH resolver accepts
// the value, not a flag on a row.
// - ORG-SCOPED, SIGNED, NON-FORGEABLE. The org is carried in the key but sealed
// under HMAC-SHA256(secret, org): a client cannot flip the org without the
// secret. The server stamps tenant_id from the VERIFIED org, never from the
// request body — the same tenant invariant the rest of the plane enforces.
// - LOWEST LATENCY. Verification is one HMAC compute — no IAM call, no keys
// table, no DB read. This is the no-Kafka, no-bridge, direct-to-ClickHouse
// path; it funnels through the SAME write core (ingestEvents) into the SAME
// hanzo.events table as every other adapter. One write path, many front doors.
//
// SECRET: the HMAC secret is CLOUD_INGEST_KEY_SECRET (KMS-injected by the
// operator). Absent ⇒ mint and verify BOTH fail closed (503 / 403) — a deployment
// without the secret never mints a forgeable key nor admits an unverifiable one.
package analytics
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"net/http"
"os"
"strconv"
"strings"
aiobject "github.com/hanzoai/ai/object"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
)
// ingestKeySecretEnv names the KMS-injected HMAC secret that seals a publishable
// key's org. Absent ⇒ the publishable-key path is disabled (fails closed).
const ingestKeySecretEnv = "CLOUD_INGEST_KEY_SECRET"
// publishablePrefix marks a write-only ingest key. Underscore (not the dash of
// the isAPIKey family) is load-bearing: it keeps pk_ OUT of the bearer/principal
// path, so a publishable key is structurally read-incapable.
const publishablePrefix = "pk_"
// sourceIngest tags rows that arrived via the publishable-key direct ingest, so
// the ONE hanzo.events table stays honest about origin (queryable as
// properties.$source) without a second table — same mechanism as the other
// adapters (sourceEvent/sourcePostHog/sourceCapture).
const sourceIngest = "ingest"
// sigBytes is the HMAC truncation length (128 bits) — ample against forgery while
// keeping the key short enough to embed in a bundle.
const sigBytes = 16
// ── key codec (pure) ─────────────────────────────────────────────────────────
// ingestSecret returns the configured HMAC secret, or "" when unset (path off).
func ingestSecret() string { return strings.TrimSpace(os.Getenv(ingestKeySecretEnv)) }
// keySig computes the org signature under the secret: HMAC-SHA256(secret, org),
// truncated to sigBytes. The org is the only signed input — the tenant a key can
// ever write into is fixed at mint time and cannot be shifted without the secret.
func keySig(secret, org string) []byte {
m := hmac.New(sha256.New, []byte(secret))
m.Write([]byte(org))
return m.Sum(nil)[:sigBytes]
}
// mintPublishableKey mints "pk_<b64url(org)>.<b64url(sig)>" for org under secret.
// '.' is the delimiter because it is OUTSIDE the base64url alphabet (which uses
// '-' and '_'), so the two segments split unambiguously. Returns ("",false) when
// the secret is unconfigured (fail closed) or org is empty.
func mintPublishableKey(secret, org string) (string, bool) {
org = strings.TrimSpace(org)
if secret == "" || org == "" {
return "", false
}
b64 := base64.RawURLEncoding
return publishablePrefix + b64.EncodeToString([]byte(org)) + "." + b64.EncodeToString(keySig(secret, org)), true
}
// verifyPublishableKey resolves a presented key to its org, or ("",false) if the
// key is not a well-formed, correctly-signed publishable key under the configured
// secret. FAILS CLOSED: unconfigured secret, wrong prefix, malformed segments, or
// a signature mismatch all return not-ok. Constant-time signature compare. Pure:
// no I/O, so tests drive it directly.
func verifyPublishableKey(secret, key string) (string, bool) {
key = strings.TrimSpace(key)
if secret == "" || !strings.HasPrefix(key, publishablePrefix) {
return "", false
}
body := key[len(publishablePrefix):]
dot := strings.IndexByte(body, '.')
if dot <= 0 || dot == len(body)-1 {
return "", false
}
b64 := base64.RawURLEncoding
orgBytes, err := b64.DecodeString(body[:dot])
if err != nil {
return "", false
}
sig, err := b64.DecodeString(body[dot+1:])
if err != nil {
return "", false
}
org := string(orgBytes)
if org == "" || len(org) > maxIngestOrgLen {
return "", false
}
if subtle.ConstantTimeCompare(sig, keySig(secret, org)) != 1 {
return "", false
}
return org, true
}
// maxIngestOrgLen bounds a decoded org (it becomes a warehouse partition key),
// mirroring the cap OrgForKey applies to an IAM-resolved owner.
const maxIngestOrgLen = 128
// ── request key extraction ───────────────────────────────────────────────────
// ingestKey pulls the presented publishable key, in priority order: the
// Authorization: Bearer header (the common browser-fetch shape), the
// x-hanzo-ingest-key header, then the ?ingest_key= query (navigator.sendBeacon
// cannot set headers). Only a pk_-prefixed value is returned — an unrelated
// bearer (a real JWT/IAM key) is ignored here so this door never shadows the
// identity path. "" when none is present.
func ingestKey(c *zip.Ctx) string {
if auth := strings.TrimSpace(c.Header("authorization")); auth != "" {
parts := strings.SplitN(auth, " ", 2)
if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") {
if k := strings.TrimSpace(parts[1]); strings.HasPrefix(k, publishablePrefix) {
return k
}
}
}
if k := strings.TrimSpace(c.Header("x-hanzo-ingest-key")); strings.HasPrefix(k, publishablePrefix) {
return k
}
if k := strings.TrimSpace(c.Query("ingest_key")); strings.HasPrefix(k, publishablePrefix) {
return k
}
return ""
}
// ── error (exception) folding ────────────────────────────────────────────────
// Exception is the captured error carried on a type:'error' WireEvent (mirrors
// @hanzo/event's Exception). The ingest folds it into properties.$exception so
// the ONE events schema needs no new columns and the /v1/errors lens can surface
// it straight from the properties JSON.
type Exception struct {
Type string `json:"type,omitempty"`
Message string `json:"message"`
Stack string `json:"stack,omitempty"`
Handled *bool `json:"handled,omitempty"`
}
// foldException normalizes a type:'error' event so the write core stores it as a
// first-class error: it defaults the type to "error", and lifts the top-level
// `error` object into properties.$exception (never mutating the caller's map).
// A non-error event passes through unchanged.
func foldException(e CaptureEvent) CaptureEvent {
if e.Error == nil {
return e
}
if strings.TrimSpace(e.Type) == "" {
e.Type = "error"
}
props := make(map[string]any, len(e.Properties)+1)
for k, v := range e.Properties {
props[k] = v
}
props["$exception"] = e.Error
e.Properties = props
e.Error = nil
return e
}
// ── handlers ─────────────────────────────────────────────────────────────────
// ingest answers POST /v1/ingest — the publishable-key direct capture path. The
// org is resolved from the SIGNED key (no IAM, no DB), the body is the
// @hanzo/event WireEvent batch ({batch:[…]} | {events:[…]}), error events are
// folded, and everything funnels through the ONE write core into hanzo.events.
// FAILS CLOSED: a missing/unverifiable key is refused (403); the org is never
// read from the body.
func ingest(s *cloud.Service[state], c *zip.Ctx) error {
key := ingestKey(c)
if key == "" {
return zip.ErrForbidden("publishable ingest key required")
}
org, ok := verifyPublishableKey(ingestSecret(), key)
if !ok {
return zip.ErrForbidden("invalid publishable ingest key")
}
var batch CaptureBatch
if err := c.Bind(&batch); err != nil {
return zip.ErrBadRequest("malformed ingest batch")
}
evs := batch.events()
for i := range evs {
evs[i] = foldException(evs[i])
}
res, err := ingestEvents(c.Context(), org, sourceIngest, evs)
if err != nil {
return err
}
return c.JSON(http.StatusOK, res)
}
// mintKey answers POST /v1/ingest/keys — an org owner (VALIDATED principal) mints
// a publishable key for its OWN org. The key is org-scoped to the caller's tenant
// (never a body-supplied org), so a caller can only ever mint a key that writes
// into its own partition. Fails closed (503) when the secret is unconfigured.
func mintKey(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return zip.ErrForbidden("valid bearer required")
}
secret := ingestSecret()
if secret == "" {
return zip.Errorf(http.StatusServiceUnavailable, "publishable keys unavailable: ingest key secret not configured")
}
key, ok := mintPublishableKey(secret, org)
if !ok {
return zip.Errorf(http.StatusServiceUnavailable, "could not mint publishable key")
}
return c.JSON(http.StatusOK, map[string]any{"key": key, "org": org, "scope": "ingest"})
}
// errorsLens answers GET /v1/errors — the error-tracking read view: recent
// type:'error' events for the org, newest first. Tenant-scoped server-side and
// gated on a VALIDATED principal (tenant()), NOT the publishable key — reads
// require real auth, reinforcing that pk_ is write-only. The captured exception
// is surfaced straight from properties.$exception. limit defaults 50, caps 200.
func errorsLens(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return zip.ErrForbidden("valid bearer required")
}
limit, _ := strconv.Atoi(strings.TrimSpace(c.Query("limit")))
if limit <= 0 {
limit = 50
}
if limit > 200 {
limit = 200
}
rows, err := aiobject.DatastoreQuery(c.Context(), `
SELECT id, timestamp, event, distinct_id, session_id, product, url, path,
library, library_version, properties
FROM hanzo.events
WHERE tenant_id = ? AND event_type = 'error'
ORDER BY timestamp DESC
LIMIT ?`, org, limit)
if err != nil {
return zip.Errorf(http.StatusServiceUnavailable, "analytics warehouse unavailable: %v", err)
}
type errEvent struct {
ID string `json:"id"`
Timestamp string `json:"timestamp"`
Event string `json:"event"`
DistinctID string `json:"distinctId,omitempty"`
SessionID string `json:"sessionId,omitempty"`
Product string `json:"product,omitempty"`
URL string `json:"url,omitempty"`
Path string `json:"path,omitempty"`
Library string `json:"library,omitempty"`
LibraryVer string `json:"libraryVersion,omitempty"`
Exception json.RawMessage `json:"exception,omitempty"`
Properties json.RawMessage `json:"properties,omitempty"`
}
out := make([]errEvent, 0, len(rows))
for _, r := range rows {
e := errEvent{
ID: asStr(r["id"]), Timestamp: asStr(r["timestamp"]), Event: asStr(r["event"]),
DistinctID: asStr(r["distinct_id"]), SessionID: asStr(r["session_id"]),
Product: asStr(r["product"]), URL: asStr(r["url"]), Path: asStr(r["path"]),
Library: asStr(r["library"]), LibraryVer: asStr(r["library_version"]),
}
if p := asStr(r["properties"]); p != "" && json.Valid([]byte(p)) {
e.Properties = json.RawMessage(p)
e.Exception = extractException(p)
}
out = append(out, e)
}
return c.JSON(http.StatusOK, map[string]any{"data": out})
}
// extractException pulls the $exception object out of a properties JSON blob so
// the errors lens surfaces it as a first-class field. "" (nil) when absent.
func extractException(props string) json.RawMessage {
var m map[string]json.RawMessage
if json.Unmarshal([]byte(props), &m) != nil {
return nil
}
if ex, ok := m["$exception"]; ok {
return ex
}
return nil
}
+157
View File
@@ -0,0 +1,157 @@
// 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.
package analytics
import (
"encoding/json"
"testing"
"time"
)
const testSecret = "test-ingest-secret-0123456789"
// mint→verify is a round trip: a key minted for an org verifies back to exactly
// that org under the same secret.
func TestPublishableKeyRoundTrip(t *testing.T) {
for _, org := range []string{"acme", "hanzo", "maxpower", "org-with-dashes", "MixedCase"} {
key, ok := mintPublishableKey(testSecret, org)
if !ok {
t.Fatalf("mint failed for %q", org)
}
got, ok := verifyPublishableKey(testSecret, key)
if !ok {
t.Fatalf("verify failed for freshly minted key %q", key)
}
if got != org {
t.Fatalf("round trip org = %q, want %q", got, org)
}
}
}
// The key is write-only by construction: pk_ is NOT accepted by the isAPIKey
// family, so it can never be minted into a bearer principal. (Guards the prefix
// choice — an accidental switch to a dash prefix would silently make the key
// readable.) We assert the prefix here; isAPIKey lives in the parent package.
func TestPublishablePrefixIsUnderscore(t *testing.T) {
key, _ := mintPublishableKey(testSecret, "acme")
if key[:3] != "pk_" {
t.Fatalf("publishable key must start with pk_ (write-only lane), got %q", key[:3])
}
}
// verify FAILS CLOSED on every malformed / forged / unconfigured case.
func TestVerifyFailsClosed(t *testing.T) {
good, _ := mintPublishableKey(testSecret, "acme")
cases := []struct {
name, secret, key string
}{
{"no secret", "", good},
{"wrong prefix", testSecret, "sk-abcdef"},
{"empty", testSecret, ""},
{"no delimiter", testSecret, "pk_YWNtZQ"},
{"trailing delimiter", testSecret, "pk_YWNtZQ."},
{"leading delimiter", testSecret, "pk_.YWNtZQ"},
{"bad base64 org", testSecret, "pk_!!!.YWNtZQ"},
{"bad base64 sig", testSecret, "pk_YWNtZQ.!!!"},
{"wrong secret", "other-secret", good},
{"tampered org keeps old sig", testSecret, forgeOrg(good, "evil")},
}
for _, tc := range cases {
if org, ok := verifyPublishableKey(tc.secret, tc.key); ok {
t.Errorf("%s: verify admitted a bad key → org %q (want fail closed)", tc.name, org)
}
}
}
// forgeOrg swaps the org segment of a real key while keeping its signature — the
// canonical forgery attempt the HMAC must reject.
func forgeOrg(key, newOrg string) string {
// pk_<b64org>.<b64sig> — replace the b64org segment.
dot := -1
for i := 3; i < len(key); i++ {
if key[i] == '.' {
dot = i
break
}
}
if dot < 0 {
return key
}
enc := base64Raw(newOrg)
return "pk_" + enc + key[dot:]
}
func base64Raw(s string) string {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
src := []byte(s)
var out []byte
for i := 0; i < len(src); i += 3 {
var b [3]byte
n := copy(b[:], src[i:])
out = append(out, alphabet[b[0]>>2])
out = append(out, alphabet[(b[0]&0x03)<<4|b[1]>>4])
if n > 1 {
out = append(out, alphabet[(b[1]&0x0f)<<2|b[2]>>6])
}
if n > 2 {
out = append(out, alphabet[b[2]&0x3f])
}
}
return string(out)
}
// foldException lifts a type:'error' event's exception into properties.$exception
// and defaults the type, so the write core stores it as event_type='error'.
func TestFoldException(t *testing.T) {
handled := false
e := CaptureEvent{
Error: &Exception{Type: "TypeError", Message: "x is not a function", Stack: "at f()", Handled: &handled},
}
got := foldException(e)
if got.Type != "error" {
t.Fatalf("type = %q, want error", got.Type)
}
if got.Error != nil {
t.Fatalf("error object must be lifted out (nil after fold), got %+v", got.Error)
}
ex, ok := got.Properties["$exception"]
if !ok {
t.Fatal("properties.$exception missing after fold")
}
b, _ := json.Marshal(ex)
var back Exception
if json.Unmarshal(b, &back) != nil || back.Message != "x is not a function" {
t.Fatalf("lifted exception malformed: %s", b)
}
// normalizeEvent must then store event_type='error'.
row, ok := normalizeEvent("acme", time.Now().UTC(), got)
if !ok {
t.Fatal("normalize dropped a folded error event")
}
if row.eventType != "error" || row.event != "$error" {
t.Fatalf("stored type/event = %q/%q, want error/$error", row.eventType, row.event)
}
// A non-error event is untouched.
plain := CaptureEvent{Event: "click"}
if foldException(plain).Type != "" {
t.Fatal("non-error event was mutated by foldException")
}
}
// canonicalType now recognizes error as first-class (still folds unknowns).
func TestCanonicalTypeError(t *testing.T) {
if canonicalType("error") != "error" {
t.Fatal("error must canonicalize to error")
}
if canonicalType("ERROR") != "error" {
t.Fatal("error canonicalization must be case-insensitive")
}
if canonicalType("weird") != "event" {
t.Fatal("unknown type must still fold to event")
}
}
+18 -17
View File
@@ -154,29 +154,30 @@ func Mount(app *zip.App, deps cloud.Deps) error {
// routes registers the automations surface: the connector catalog, flow CRUD +
// versioning + lifecycle, run history, and the MCP endpoint.
func routes(app *zip.App, s *cloud.Service[state]) {
app.Get("/v1/automations/connectors", cloud.Handle(s, connectors))
g := app.Group("/v1/automations")
g.Get("/connectors", cloud.Handle(s, connectors))
// Back-compat alias: the pre-rename /pieces path stays valid (same handler, same
// body) so live clients pinned to it keep working. "pieces" is the retired
// ActivePieces term; "connectors" is the ONE Hanzo name (HIP-0126).
app.Get("/v1/automations/pieces", cloud.Handle(s, connectors))
g.Get("/pieces", cloud.Handle(s, connectors))
app.Get("/v1/automations/flows", cloud.Handle(s, listFlows))
app.Post("/v1/automations/flows", cloud.Handle(s, createFlow))
app.Get("/v1/automations/flows/:id", cloud.Handle(s, getFlow))
app.Patch("/v1/automations/flows/:id", cloud.Handle(s, updateFlow))
app.Delete("/v1/automations/flows/:id", cloud.Handle(s, deleteFlow))
app.Get("/v1/automations/flows/:id/versions", cloud.Handle(s, listVersions))
app.Post("/v1/automations/flows/:id/versions", cloud.Handle(s, createVersion))
app.Post("/v1/automations/flows/:id/operations", cloud.Handle(s, applyOperation))
app.Post("/v1/automations/flows/:id/run", cloud.Handle(s, runFlow))
app.Post("/v1/automations/flows/:id/enable", cloud.Handle(s, enableFlow))
app.Post("/v1/automations/flows/:id/disable", cloud.Handle(s, disableFlow))
g.Get("/flows", cloud.Handle(s, listFlows))
g.Post("/flows", cloud.Handle(s, createFlow))
g.Get("/flows/:id", cloud.Handle(s, getFlow))
g.Patch("/flows/:id", cloud.Handle(s, updateFlow))
g.Delete("/flows/:id", cloud.Handle(s, deleteFlow))
g.Get("/flows/:id/versions", cloud.Handle(s, listVersions))
g.Post("/flows/:id/versions", cloud.Handle(s, createVersion))
g.Post("/flows/:id/operations", cloud.Handle(s, applyOperation))
g.Post("/flows/:id/run", cloud.Handle(s, runFlow))
g.Post("/flows/:id/enable", cloud.Handle(s, enableFlow))
g.Post("/flows/:id/disable", cloud.Handle(s, disableFlow))
app.Get("/v1/automations/runs", cloud.Handle(s, listRuns))
app.Get("/v1/automations/runs/:id", cloud.Handle(s, getRun))
app.Post("/v1/automations/runs/:id/resume", cloud.Handle(s, resumeRun))
g.Get("/runs", cloud.Handle(s, listRuns))
g.Get("/runs/:id", cloud.Handle(s, getRun))
g.Post("/runs/:id/resume", cloud.Handle(s, resumeRun))
app.Post("/v1/automations/mcp", cloud.Handle(s, mcp))
g.Post("/mcp", cloud.Handle(s, mcp))
}
// Shutdown closes the store. Idempotent — safe when nothing is mounted.
+32
View File
@@ -61,6 +61,7 @@ import (
"github.com/hanzoai/base/plugins/waitlist"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/principal"
"github.com/hanzoai/cloud/clients/sites"
"github.com/zap-proto/zip"
)
@@ -158,12 +159,43 @@ func Mount(app *zip.App, deps cloud.Deps) error {
app.All("/v1/base/*", func(c *zip.Ctx) error { return serveOrg(p, log, c) })
mounted = &subsystem{pool: p, platform: platformApp}
// Host-as-project-ref (HIP-0014, gated by CLOUD_BASE_PUBLIC_HOST, default OFF):
// let a published site host serve /v1/base, /v1/realtime and /_/ scoped to the
// org its SUBDOMAIN resolves to — so an anon page can reach its own Base, authz
// by Base's collection rules. The org comes from the resolved site, never the
// caller. Absent the flag, site hosts serve only static files (unchanged).
if publicHostEnabled() {
sites.SetBaseHostHandler(func(org string, c *zip.Ctx) error {
h, release, err := p.acquire(org)
if err != nil {
log.Error("base: open org app failed", "err", err)
return zip.Errorf(http.StatusInternalServerError, "base unavailable")
}
defer release()
return zip.AdaptNetHTTP(h)(c)
})
log.Info("base public-host routing enabled", "flag", publicHostEnv)
}
log.Info("base app embedded",
"waitlist", "/v1/waitlist/*", "hosting", "/v1/base/*",
"prefix", os.Getenv("BASE_API_PREFIX"), "brand", deps.Brand, "env", deps.Env)
return nil
}
// publicHostEnv gates host-as-project-ref Base routing (default OFF).
const publicHostEnv = "CLOUD_BASE_PUBLIC_HOST"
func publicHostEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(publicHostEnv))) {
case "1", "true", "yes":
return true
default:
return false
}
}
// serveOrg resolves the caller's org from the validated principal, acquires that
// org's pooled Base app (pinned for the request so eviction can't close it
// mid-flight), and serves the request through the org's own Base mux.
+5 -1
View File
@@ -4,11 +4,12 @@ import (
"context"
"encoding/json"
"fmt"
"github.com/hanzoai/account"
"net/http"
"net/http/httptest"
"testing"
"github.com/hanzoai/account"
"github.com/hanzoai/cloud/clients/finance"
"github.com/hanzoai/cloud/clients/money"
"github.com/hanzoai/cloud/types"
@@ -45,6 +46,9 @@ func (f *fakeFinance) Balance(_ context.Context, org, subject, _ string, _ bool)
func (f *fakeFinance) Deposit(context.Context, types.DepositInput) (string, error) { return "", nil }
func (f *fakeFinance) RecordUsage(context.Context, types.UsageInput) error { return nil }
func (f *fakeFinance) SumUsageSince(context.Context, string, bool, int64) (int64, error) {
return 0, nil
}
func publishFinance(t *testing.T, f *fakeFinance) {
t.Helper()
+6
View File
@@ -152,6 +152,12 @@ func build(b cloud.Base) (state, error) {
// /v1/finance/* projection (same commerceProxy).
func routes(app *zip.App, s *cloud.Service[state]) {
app.Get("/v1/billing/usage", cloud.Handle(s, usage))
// The per-account routed-usage breakdown the dashboard reads, in the billing
// namespace beside /v1/billing/usage. The data is owned by clients/link (the
// linked-account plane); this thin handler asks it, scoped to the caller's OWN
// (org, subject). Registered here — not from link — so it shadows the console
// pkg's /v1/billing/* wildcard exactly like the other specific customer routes.
app.Get("/v1/billing/usage/accounts", cloud.Handle(s, usageAccounts))
app.Get("/v1/billing/balance", cloud.Handle(s, balance))
// GPU launch gate + saved cards — the customer half of the prepay-only GPU rule
// commerce enforces server-side (api/billing/gpu_charge.go). Same org-scoping as
+45
View File
@@ -0,0 +1,45 @@
package billing
// usage_accounts.go answers GET /v1/billing/usage/accounts — the per-account
// SERVER-ROUTED usage breakdown the unified dashboard reads beside /v1/billing/usage.
//
// TENANT ISOLATION (the whole point, same as usage/balance). The org is the VALIDATED
// IAM owner claim (principal.Org — the trusted X-Org-Id the identity middleware
// minted, HIP-0026; NEVER a client header), and the subject is the validated user
// (c.User(), which principal.Org having returned ok guarantees non-empty). The
// breakdown is read from the linked-account plane scoped to exactly that (org,
// subject), so a caller sees ONLY their OWN linked accounts — no client-supplied
// subject/account/org param is ever consulted.
//
// The data lives in clients/link (RoutedBreakdown); this is a thin, org-scoped read
// twin of usage(), mounted in the billing namespace so the dashboard's usage panels
// find it where they already read /v1/billing/usage.
import (
"net/http"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/link"
"github.com/hanzoai/cloud/clients/principal"
"github.com/zap-proto/zip"
)
// usageAccounts serves the caller's per-account routed-usage breakdown.
func usageAccounts(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := principal.Org(c)
if !ok {
return zip.ErrUnauthorized("sign in to view billing")
}
// c.User() is guaranteed non-empty once principal.Org returned ok (Org composes
// Validated, which is c.User() != ""). It is the account-scope subject, taken from
// the validated principal — never a request field.
view, ok := link.RoutedBreakdown(c.Context(), org, c.User())
if !ok {
// The linked-account plane is not co-resident (a split deploy). Honest
// "unavailable" — never a fabricated empty breakdown that reads as "no usage".
return zip.Errorf(http.StatusNotImplemented, "per-account usage is not available on this deployment")
}
c.SetHeader("Content-Type", "application/json")
c.SetHeader("Cache-Control", "no-store")
return c.JSON(http.StatusOK, view)
}
+3 -2
View File
@@ -134,9 +134,10 @@ func Mount(app *zip.App, deps cloud.Deps) error {
// routes registers the bots surface. The static /run literal and the :runId param
// are resolved by specificity, so /v1/bots/run can never bind as a run id.
func routes(app *zip.App, s *cloud.Service[state]) {
app.Post("/v1/bots/run", cloud.Handle(s, run))
g := app.Group("/v1/bots")
g.Post("/run", cloud.Handle(s, run))
app.Get("/v1/bots", cloud.Handle(s, list))
app.Post("/v1/bots/:runId/stop", cloud.Handle(s, stop))
g.Post("/:runId/stop", cloud.Handle(s, stop))
}
// run reports that launching is not implemented.
+32 -31
View File
@@ -98,48 +98,49 @@ func Mount(app *zip.App, deps cloud.Deps) error {
// routes wires the /v1/captable/* route table → bundle route names. GET reads
// carry no body; mutations do.
func routes(app *zip.App, s *cloud.Service[state]) {
g := app.Group("/v1/captable")
// company
app.Get("/v1/captable/company", route(s, "company.get", nil, false))
app.Put("/v1/captable/company", route(s, "company.update", nil, true))
g.Get("/company", route(s, "company.get", nil, false))
g.Put("/company", route(s, "company.update", nil, true))
// stakeholders
app.Get("/v1/captable/stakeholders", route(s, "stakeholders.list", nil, false))
app.Post("/v1/captable/stakeholders", route(s, "stakeholders.add", nil, true))
app.Patch("/v1/captable/stakeholders/:id", routeID(s, "stakeholders.update", true))
app.Delete("/v1/captable/stakeholders/:id", routeID(s, "stakeholders.delete", false))
g.Get("/stakeholders", route(s, "stakeholders.list", nil, false))
g.Post("/stakeholders", route(s, "stakeholders.add", nil, true))
g.Patch("/stakeholders/:id", routeID(s, "stakeholders.update", true))
g.Delete("/stakeholders/:id", routeID(s, "stakeholders.delete", false))
// share classes
app.Get("/v1/captable/share-classes", route(s, "shareClasses.list", nil, false))
app.Post("/v1/captable/share-classes", route(s, "shareClasses.create", nil, true))
app.Patch("/v1/captable/share-classes/:id", routeID(s, "shareClasses.update", true))
g.Get("/share-classes", route(s, "shareClasses.list", nil, false))
g.Post("/share-classes", route(s, "shareClasses.create", nil, true))
g.Patch("/share-classes/:id", routeID(s, "shareClasses.update", true))
// equity plans
app.Get("/v1/captable/equity-plans", route(s, "equityPlans.list", nil, false))
app.Post("/v1/captable/equity-plans", route(s, "equityPlans.create", nil, true))
g.Get("/equity-plans", route(s, "equityPlans.list", nil, false))
g.Post("/equity-plans", route(s, "equityPlans.create", nil, true))
// shares (issuance + transfer). /shares/transfer registers before /shares/:id
// (different methods anyway) so it can never be shadowed.
app.Get("/v1/captable/shares", route(s, "shares.list", nil, false))
app.Post("/v1/captable/shares", route(s, "shares.add", nil, true))
app.Post("/v1/captable/shares/transfer", route(s, "shares.transfer", nil, true))
app.Delete("/v1/captable/shares/:id", routeID(s, "shares.delete", false))
g.Get("/shares", route(s, "shares.list", nil, false))
g.Post("/shares", route(s, "shares.add", nil, true))
g.Post("/shares/transfer", route(s, "shares.transfer", nil, true))
g.Delete("/shares/:id", routeID(s, "shares.delete", false))
// options
app.Get("/v1/captable/options", route(s, "options.list", nil, false))
app.Post("/v1/captable/options", route(s, "options.add", nil, true))
app.Delete("/v1/captable/options/:id", routeID(s, "options.delete", false))
g.Get("/options", route(s, "options.list", nil, false))
g.Post("/options", route(s, "options.add", nil, true))
g.Delete("/options/:id", routeID(s, "options.delete", false))
// SAFEs
app.Get("/v1/captable/safes", route(s, "safes.list", nil, false))
app.Post("/v1/captable/safes", route(s, "safes.create", nil, true))
app.Delete("/v1/captable/safes/:id", routeID(s, "safes.delete", false))
g.Get("/safes", route(s, "safes.list", nil, false))
g.Post("/safes", route(s, "safes.create", nil, true))
g.Delete("/safes/:id", routeID(s, "safes.delete", false))
// convertible notes
app.Get("/v1/captable/convertibles", route(s, "convertibles.list", nil, false))
app.Post("/v1/captable/convertibles", route(s, "convertibles.create", nil, true))
app.Delete("/v1/captable/convertibles/:id", routeID(s, "convertibles.delete", false))
g.Get("/convertibles", route(s, "convertibles.list", nil, false))
g.Post("/convertibles", route(s, "convertibles.create", nil, true))
g.Delete("/convertibles/:id", routeID(s, "convertibles.delete", false))
// rounds + investments
app.Get("/v1/captable/rounds", route(s, "rounds.list", nil, false))
app.Post("/v1/captable/rounds", route(s, "rounds.create", nil, true))
app.Get("/v1/captable/rounds/:id", routeID(s, "rounds.get", false))
app.Post("/v1/captable/rounds/:id/close", routeID(s, "rounds.close", true))
app.Post("/v1/captable/rounds/:id/investments", routeID(s, "rounds.investments.add", true))
app.Get("/v1/captable/investments", route(s, "rounds.investments.list", nil, false))
g.Get("/rounds", route(s, "rounds.list", nil, false))
g.Post("/rounds", route(s, "rounds.create", nil, true))
g.Get("/rounds/:id", routeID(s, "rounds.get", false))
g.Post("/rounds/:id/close", routeID(s, "rounds.close", true))
g.Post("/rounds/:id/investments", routeID(s, "rounds.investments.add", true))
g.Get("/investments", route(s, "rounds.investments.list", nil, false))
// computed cap table
app.Get("/v1/captable/summary", route(s, "captable", nil, false))
g.Get("/summary", route(s, "captable", nil, false))
}
// route builds a zip handler that dispatches a fixed bundle route. readBody
+72
View File
@@ -0,0 +1,72 @@
// Package channels is the /v1/channels transport plane: the portable chat
// envelope, per-org access policy (pairing / allowlist / open), a durable
// inbox, and outbound send across the connected chat transports (Discord,
// Slack, Teams, Telegram). Identity and token custody stay in
// clients/integrations — channels consumes its ingress seam
// (integrations.RegisterIngress) and its send doors, so the dependency points
// one way: channels → integrations, never back.
package channels
import (
"context"
"fmt"
"os"
"path/filepath"
"sync/atomic"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/integrations"
"github.com/zap-proto/zip"
)
// state is the subsystem's mounted state: the ONE channels store.
type state struct {
store *store
}
// mounted is the active service, read by ingest on emit goroutines and written
// once at Mount/Shutdown — an atomic.Pointer (clients/sync pattern) so a
// detached event reads it race-free. nil ⇒ unmounted; ingest drops.
var mounted atomic.Pointer[cloud.Service[state]]
// Mount wires /v1/channels/* onto app and registers the ingress consumer.
func Mount(app *zip.App, deps cloud.Deps) error {
if app == nil {
return fmt.Errorf("channels.Mount: nil zip.App")
}
if deps.Logger == nil {
return fmt.Errorf("channels.Mount: nil deps.Logger")
}
if deps.DataDir == "" {
return fmt.Errorf("channels.Mount: empty DataDir")
}
if err := os.MkdirAll(deps.DataDir, 0o755); err != nil {
return fmt.Errorf("channels.Mount: data dir: %w", err)
}
st, err := openStore(filepath.Join(deps.DataDir, "channels.db"))
if err != nil {
return fmt.Errorf("channels.Mount: open store: %w", err)
}
b := cloud.NewBase(deps, "channels")
s := &cloud.Service[state]{Base: b, State: state{store: st}}
// Publish state BEFORE registering the ingress consumer so the first
// emitted event finds a mounted service.
mounted.Store(s)
routes(app, s)
integrations.RegisterIngress(ingest)
b.Log.Info("channels mounted", "transports", len(transports))
return nil
}
// Shutdown unpublishes the service, then closes the store. Idempotent. The
// context is unused; the signature matches integrations.Shutdown so apps.go
// wires it directly. Unpublish-first stops new ingest events from adopting a
// store that is about to close.
func Shutdown(_ context.Context) error {
s := mounted.Load()
if s == nil {
return nil
}
mounted.Store(nil)
return s.State.store.Close()
}
+65
View File
@@ -0,0 +1,65 @@
package channels
import (
"context"
"errors"
"strings"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/integrations"
)
// discord.go is the Discord transport: envelope normalization from the
// ingress seam and egress through integrations.SendDiscord (token custody
// stays in integrations).
// errNoRoute rejects egress to a room the org has no inbound-learned route
// for. routes.go maps it to 409 — the send needs a prior allowed inbound
// message from that room.
var errNoRoute = errors.New("channels: no reply route for this room")
// discordDoor is the send door; tests spy it, prod never repoints.
var discordDoor = integrations.SendDiscord
// DM:false is honest: the interactions ingress is guild-scoped only.
var discordTransport = transport{
id: "discord",
caps: capabilities{Group: true},
normalize: discordNormalize,
send: discordEgress,
}
// discordNormalize maps a Discord Inbound (ExternalID = guild id, DedupeKey =
// interaction id) into the envelope. The ingress is guild slash commands
// only, so every room is a group.
func discordNormalize(ev integrations.IngressEvent) (Message, bool) {
in := ev.In
return Message{
Channel: "discord",
Account: strings.ToLower(in.ExternalID),
Sender: Sender{ExternalID: in.User, Org: ev.Org},
Room: Room{ID: in.Channel, Kind: RoomGroup},
Text: in.Text,
Idempotency: in.DedupeKey,
}, true
}
// discordEgress sends via the shared bot after the tenancy gate: a
// channel_route row exists only after an ALLOWED inbound interaction in that
// channel, so route presence IS the org's verified send capability
// (reply_root is "" for discord; presence is the datum).
func discordEgress(ctx context.Context, s *cloud.Service[state], org string, m Message) (Delivery, error) {
_, ok, err := s.State.store.routeFor(ctx, org, "discord", m.Room.ID)
if err != nil {
return Delivery{}, err
}
if !ok {
return Delivery{}, errNoRoute
}
id, err := discordDoor(ctx, m.Room.ID, m.ReplyTo, renderText(m))
if err != nil {
return Delivery{}, err
}
return Delivery{MessageID: id, Timestamp: time.Now().Unix()}, nil
}
+268
View File
@@ -0,0 +1,268 @@
package channels
import (
"fmt"
"strings"
)
// envelope.go is the portable chat envelope — the ONE message shape every
// transport normalizes into and renders out of (the OpenClaw contract port).
// Every union is closed and kind-tagged; nothing here infers meaning from
// string shape.
// RoomKind classifies where a message lives.
type RoomKind string
const (
RoomDM RoomKind = "dm"
RoomGroup RoomKind = "group"
RoomThread RoomKind = "thread"
)
func (k RoomKind) valid() bool {
switch k {
case RoomDM, RoomGroup, RoomThread:
return true
}
return false
}
// ActionKind tags the closed Action union.
type ActionKind string
const (
ActionCommand ActionKind = "command"
ActionURL ActionKind = "url"
ActionSelect ActionKind = "select"
ActionApproval ActionKind = "approval"
)
// AttachmentKind is the closed attachment media class.
type AttachmentKind string
const (
AttachmentImage AttachmentKind = "image"
AttachmentAudio AttachmentKind = "audio"
AttachmentVideo AttachmentKind = "video"
AttachmentFile AttachmentKind = "file"
)
// Sender identifies who sent an inbound message. UserID is the bound Hanzo
// subject (integrations.LinkedSubject) and may be empty when the platform user
// has not linked. Org is filled on ingress and ignored on egress.
type Sender struct {
ExternalID string `json:"externalId"`
Display string `json:"display,omitempty"`
UserID string `json:"userId,omitempty"`
Org string `json:"org,omitempty"`
}
// Room is the conversation a message lives in.
type Room struct {
ID string `json:"id"`
Kind RoomKind `json:"kind,omitempty"`
}
// Attachment is a URL-addressed media item.
type Attachment struct {
Kind AttachmentKind `json:"kind"`
URL string `json:"url"`
MIME string `json:"mime,omitempty"`
}
func (a Attachment) validate() error {
switch a.Kind {
case AttachmentImage, AttachmentAudio, AttachmentVideo, AttachmentFile:
default:
return fmt.Errorf("attachment: unknown kind %q", a.Kind)
}
if a.URL == "" {
return fmt.Errorf("attachment %s: url required", a.Kind)
}
return nil
}
// SelectOption is one choice of a select action.
type SelectOption struct {
Label string `json:"label"`
Value string `json:"value"`
}
// Approval names the approval request an approval action refers to.
type Approval struct {
ID string `json:"id"`
}
// Action is the kind-tagged closed union (command | url | select | approval);
// exactly the fields of its Kind are set — validate enforces per-kind
// exclusivity so a channel never has to guess from string shape.
type Action struct {
Kind ActionKind `json:"kind"`
Label string `json:"label,omitempty"`
Command string `json:"command,omitempty"`
URL string `json:"url,omitempty"`
Options []SelectOption `json:"options,omitempty"`
Approval *Approval `json:"approval,omitempty"`
}
func (a Action) validate() error {
switch a.Kind {
case ActionCommand:
if a.Command == "" {
return fmt.Errorf("action command: command required")
}
if a.URL != "" || len(a.Options) > 0 || a.Approval != nil {
return fmt.Errorf("action command: only command may be set")
}
case ActionURL:
if a.URL == "" {
return fmt.Errorf("action url: url required")
}
if a.Command != "" || len(a.Options) > 0 || a.Approval != nil {
return fmt.Errorf("action url: only url may be set")
}
case ActionSelect:
if len(a.Options) == 0 {
return fmt.Errorf("action select: at least one option required")
}
for _, o := range a.Options {
if o.Label == "" || o.Value == "" {
return fmt.Errorf("action select: option label and value required")
}
}
if a.Command != "" || a.URL != "" || a.Approval != nil {
return fmt.Errorf("action select: only options may be set")
}
case ActionApproval:
if a.Approval == nil || a.Approval.ID == "" {
return fmt.Errorf("action approval: approval id required")
}
if a.Command != "" || a.URL != "" || len(a.Options) > 0 {
return fmt.Errorf("action approval: only approval may be set")
}
default:
return fmt.Errorf("action: unknown kind %q", a.Kind)
}
return nil
}
// Message is the normalized envelope. Account is informational — the lowercased
// external id of the org's connected platform account; the policy key is
// (org, channel) only.
type Message struct {
Channel string `json:"channel"`
Account string `json:"account,omitempty"`
Sender Sender `json:"sender"`
Room Room `json:"room"`
Text string `json:"text,omitempty"`
Attachments []Attachment `json:"attachments,omitempty"`
Actions []Action `json:"actions,omitempty"`
ReplyTo string `json:"replyTo,omitempty"`
Idempotency string `json:"idempotency,omitempty"`
}
func (m *Message) validate() error {
if m.Channel == "" {
return fmt.Errorf("message: channel required")
}
if m.Room.ID == "" {
return fmt.Errorf("message: room id required")
}
if !m.Room.Kind.valid() {
return fmt.Errorf("message: unknown room kind %q", m.Room.Kind)
}
if err := validateContent(m.Text, m.Attachments, m.Actions); err != nil {
return err
}
m.Account = strings.ToLower(m.Account)
return nil
}
// SendRequest is the narrow body of POST /v1/channels/:channel/send — the
// envelope's outbound projection. Identity fields (Sender, Account, Channel)
// are not decodable here: the route path names the channel and the caller's
// authenticated org supplies the tenant.
type SendRequest struct {
Room Room `json:"room"`
Text string `json:"text,omitempty"`
Attachments []Attachment `json:"attachments,omitempty"`
Actions []Action `json:"actions,omitempty"`
ReplyTo string `json:"replyTo,omitempty"`
Idempotency string `json:"idempotency,omitempty"`
}
func (r SendRequest) validate() error {
// Room.Kind may be empty on egress: the transport doors address a room by
// id alone; kind is an ingress classification.
if r.Room.ID == "" {
return fmt.Errorf("send: room id required")
}
if r.Room.Kind != "" && !r.Room.Kind.valid() {
return fmt.Errorf("send: unknown room kind %q", r.Room.Kind)
}
return validateContent(r.Text, r.Attachments, r.Actions)
}
// validateContent is the shared content rule: something to say, and every
// attachment/action well-formed.
func validateContent(text string, attachments []Attachment, actions []Action) error {
if text == "" && len(attachments) == 0 {
return fmt.Errorf("text or attachments required")
}
for _, a := range attachments {
if err := a.validate(); err != nil {
return err
}
}
for _, a := range actions {
if err := a.validate(); err != nil {
return err
}
}
return nil
}
// Delivery is a transport's send receipt. Timestamp is Unix seconds.
type Delivery struct {
MessageID string `json:"messageId"`
Timestamp int64 `json:"timestamp"`
}
// renderText is the ONE deterministic downgrade renderer: all four transports
// advertise media:false / actions:false this pass, so attachments and actions
// flatten to one line each after the text. Native rendering is a named
// follow-up. Called only on validated messages (an approval action carries a
// non-nil Approval).
func renderText(m Message) string {
var b strings.Builder
b.WriteString(m.Text)
for _, a := range m.Attachments {
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString(string(a.Kind) + ": " + a.URL)
if a.MIME != "" {
b.WriteString(" (" + a.MIME + ")")
}
}
for _, a := range m.Actions {
if b.Len() > 0 {
b.WriteString("\n")
}
switch a.Kind {
case ActionCommand:
b.WriteString("[" + a.Label + "] " + a.Command)
case ActionURL:
b.WriteString("[" + a.Label + "] " + a.URL)
case ActionSelect:
labels := make([]string, 0, len(a.Options))
for _, o := range a.Options {
labels = append(labels, o.Label)
}
b.WriteString("[" + a.Label + "] " + strings.Join(labels, " | "))
case ActionApproval:
b.WriteString("[" + a.Label + "] approval requested: " + a.Approval.ID)
}
}
return b.String()
}
+283
View File
@@ -0,0 +1,283 @@
package channels
import (
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/hanzoai/cloud/clients/integrations"
)
// envelope_test.go proves the portable envelope's closure: per-transport
// normalization into ONE shape, kind-tagged unions with no string sniffing,
// the narrow egress projection (C2-6), and the deterministic downgrade
// renderer. Pure — no store, no HTTP.
func TestNormalize(t *testing.T) {
ev := func(provider, externalID, user, channel, thread, text, key string) integrations.IngressEvent {
return integrations.IngressEvent{Org: "acme", In: integrations.Inbound{
Provider: provider, ExternalID: externalID, User: user,
Channel: channel, ThreadID: thread, Text: text, DedupeKey: key,
}}
}
cases := []struct {
name string
norm func(integrations.IngressEvent) (Message, bool)
ev integrations.IngressEvent
ok bool
want Message
}{
{
name: "telegram positive chat id is a DM; ThreadID is the reply target",
norm: telegramNormalize,
ev: ev("telegram", "HanzoBot", "42", "777", "55", "hi", "u-1"),
ok: true,
want: Message{Channel: "telegram", Account: "hanzobot", Sender: Sender{ExternalID: "42", Org: "acme"},
Room: Room{ID: "777", Kind: RoomDM}, Text: "hi", ReplyTo: "55", Idempotency: "u-1"},
},
{
name: "telegram negative chat id is a group",
norm: telegramNormalize,
ev: ev("telegram", "HanzoBot", "42", "-1001234", "", "hi", "u-2"),
ok: true,
want: Message{Channel: "telegram", Account: "hanzobot", Sender: Sender{ExternalID: "42", Org: "acme"},
Room: Room{ID: "-1001234", Kind: RoomGroup}, Text: "hi", Idempotency: "u-2"},
},
{
name: "telegram unparseable chat id drops",
norm: telegramNormalize,
ev: ev("telegram", "HanzoBot", "42", "abc", "", "hi", "u-3"),
ok: false,
},
{
name: "telegram zero chat id drops",
norm: telegramNormalize,
ev: ev("telegram", "HanzoBot", "42", "0", "", "hi", "u-4"),
ok: false,
},
{
name: "slack D-conversation is a DM",
norm: slackNormalize,
ev: ev("slack", "T024ABC", "u1", "D024BE91L", "", "hello", "e-1"),
ok: true,
want: Message{Channel: "slack", Account: "t024abc", Sender: Sender{ExternalID: "u1", Org: "acme"},
Room: Room{ID: "D024BE91L", Kind: RoomDM}, Text: "hello", Idempotency: "e-1"},
},
{
name: "slack threaded channel event is a thread replying under thread_ts",
norm: slackNormalize,
ev: ev("slack", "T024ABC", "u1", "C024BE91L", "1712.0001", "hello", "e-2"),
ok: true,
want: Message{Channel: "slack", Account: "t024abc", Sender: Sender{ExternalID: "u1", Org: "acme"},
Room: Room{ID: "C024BE91L", Kind: RoomThread}, Text: "hello", ReplyTo: "1712.0001", Idempotency: "e-2"},
},
{
name: "slack bare channel event is a group",
norm: slackNormalize,
ev: ev("slack", "T024ABC", "u1", "C024BE91L", "", "hello", "e-3"),
ok: true,
want: Message{Channel: "slack", Account: "t024abc", Sender: Sender{ExternalID: "u1", Org: "acme"},
Room: Room{ID: "C024BE91L", Kind: RoomGroup}, Text: "hello", Idempotency: "e-3"},
},
{
name: "teams 19: conversation is a group (Bot Framework thread id contract)",
norm: teamsNormalize,
ev: ev("teams", "Tenant-1", "u7", "19:abc@thread.tacv2", "", "hey", "a-1"),
ok: true,
want: Message{Channel: "teams", Account: "tenant-1", Sender: Sender{ExternalID: "u7", Org: "acme"},
Room: Room{ID: "19:abc@thread.tacv2", Kind: RoomGroup}, Text: "hey", Idempotency: "a-1"},
},
{
name: "teams a: conversation is personal",
norm: teamsNormalize,
ev: ev("teams", "Tenant-1", "u7", "a:1a2b3c", "", "hey", "a-2"),
ok: true,
want: Message{Channel: "teams", Account: "tenant-1", Sender: Sender{ExternalID: "u7", Org: "acme"},
Room: Room{ID: "a:1a2b3c", Kind: RoomDM}, Text: "hey", Idempotency: "a-2"},
},
{
// C1-F6 fail-safe: an unknown conversation shape classifies DM — the
// strictest direction, since dmPolicy defaults to pairing.
name: "teams unknown conversation shape falls back to DM",
norm: teamsNormalize,
ev: ev("teams", "Tenant-1", "u7", "48:whatever", "", "hey", "a-3"),
ok: true,
want: Message{Channel: "teams", Account: "tenant-1", Sender: Sender{ExternalID: "u7", Org: "acme"},
Room: Room{ID: "48:whatever", Kind: RoomDM}, Text: "hey", Idempotency: "a-3"},
},
{
name: "discord guild interaction is always a group",
norm: discordNormalize,
ev: ev("discord", "GUILD9", "u5", "555", "", "ping", "i-1"),
ok: true,
want: Message{Channel: "discord", Account: "guild9", Sender: Sender{ExternalID: "u5", Org: "acme"},
Room: Room{ID: "555", Kind: RoomGroup}, Text: "ping", Idempotency: "i-1"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, ok := tc.norm(tc.ev)
if ok != tc.ok {
t.Fatalf("ok = %v, want %v", ok, tc.ok)
}
if !ok {
return
}
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("message = %+v,\nwant %+v", got, tc.want)
}
})
}
}
func TestActionValidateClosedSet(t *testing.T) {
opts := []SelectOption{{Label: "One", Value: "a"}}
cases := []struct {
name string
a Action
ok bool
}{
{"command", Action{Kind: ActionCommand, Label: "Deploy", Command: "/deploy"}, true},
{"url", Action{Kind: ActionURL, Label: "Docs", URL: "https://docs.example"}, true},
{"select", Action{Kind: ActionSelect, Label: "Pick", Options: opts}, true},
{"approval", Action{Kind: ActionApproval, Label: "Approve", Approval: &Approval{ID: "ap-1"}}, true},
// No sniffing: a /command-looking value inside a url action stays a url
// action — kinds are declared, never inferred from string shape.
{"url that looks like a command", Action{Kind: ActionURL, URL: "/deploy"}, true},
{"unknown kind", Action{Kind: "menu"}, false},
{"command empty", Action{Kind: ActionCommand}, false},
{"command with url set", Action{Kind: ActionCommand, Command: "/x", URL: "https://x"}, false},
{"url empty", Action{Kind: ActionURL}, false},
{"select empty options", Action{Kind: ActionSelect}, false},
{"select blank option", Action{Kind: ActionSelect, Options: []SelectOption{{Label: "", Value: "a"}}}, false},
{"select with command set", Action{Kind: ActionSelect, Options: opts, Command: "/x"}, false},
{"approval nil", Action{Kind: ActionApproval}, false},
{"approval empty id", Action{Kind: ActionApproval, Approval: &Approval{}}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.a.validate()
if (err == nil) != tc.ok {
t.Fatalf("validate = %v, want ok=%v", err, tc.ok)
}
})
}
}
func TestAttachmentValidate(t *testing.T) {
cases := []struct {
name string
a Attachment
ok bool
}{
{"image", Attachment{Kind: AttachmentImage, URL: "https://cdn.example/a.png", MIME: "image/png"}, true},
{"file", Attachment{Kind: AttachmentFile, URL: "https://cdn.example/f.pdf"}, true},
{"empty url", Attachment{Kind: AttachmentImage}, false},
{"unknown kind", Attachment{Kind: "gif", URL: "https://x"}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.a.validate()
if (err == nil) != tc.ok {
t.Fatalf("validate = %v, want ok=%v", err, tc.ok)
}
})
}
}
func TestSendRequestValidate(t *testing.T) {
att := []Attachment{{Kind: AttachmentFile, URL: "https://cdn.example/f.pdf"}}
cases := []struct {
name string
r SendRequest
ok bool
}{
// Room.Kind is optional on egress — doors address rooms by id alone.
{"kind optional on egress", SendRequest{Room: Room{ID: "r"}, Text: "x"}, true},
{"attachments alone suffice", SendRequest{Room: Room{ID: "r"}, Attachments: att}, true},
{"room id required", SendRequest{Text: "x"}, false},
{"bad kind rejected", SendRequest{Room: Room{ID: "r", Kind: "castle"}, Text: "x"}, false},
{"content required", SendRequest{Room: Room{ID: "r"}}, false},
{"invalid action rejected", SendRequest{Room: Room{ID: "r"}, Text: "x", Actions: []Action{{Kind: "menu"}}}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.r.validate()
if (err == nil) != tc.ok {
t.Fatalf("validate = %v, want ok=%v", err, tc.ok)
}
})
}
}
func TestMessageValidate(t *testing.T) {
m := Message{Channel: "slack", Account: "MiXeD", Room: Room{ID: "r", Kind: RoomDM}, Text: "x"}
if err := m.validate(); err != nil {
t.Fatalf("validate: %v", err)
}
if m.Account != "mixed" {
t.Fatalf("account = %q, want lowercased", m.Account)
}
bad := []Message{
{Room: Room{ID: "r", Kind: RoomDM}, Text: "x"}, // channel required
{Channel: "slack", Room: Room{Kind: RoomDM}, Text: "x"}, // room id required
{Channel: "slack", Room: Room{ID: "r", Kind: "weird"}, Text: "x"}, // closed room kinds
{Channel: "slack", Room: Room{ID: "r"}, Text: "x"}, // kind required on the full envelope
{Channel: "slack", Room: Room{ID: "r", Kind: RoomDM}}, // content required
}
for i := range bad {
if err := bad[i].validate(); err == nil {
t.Fatalf("message %d must not validate: %+v", i, bad[i])
}
}
}
func TestRenderTextDeterministic(t *testing.T) {
m := Message{
Channel: "slack",
Room: Room{ID: "C1", Kind: RoomGroup},
Text: "body",
Attachments: []Attachment{
{Kind: AttachmentImage, URL: "https://cdn.example/a.png", MIME: "image/png"},
},
Actions: []Action{
{Kind: ActionCommand, Label: "Deploy", Command: "/deploy"},
{Kind: ActionURL, Label: "Docs", URL: "https://docs.example"},
{Kind: ActionSelect, Label: "Pick", Options: []SelectOption{{Label: "One", Value: "a"}, {Label: "Two", Value: "b"}}},
{Kind: ActionApproval, Label: "Approve", Approval: &Approval{ID: "ap-1"}},
},
}
a, b := renderText(m), renderText(m)
if a != b {
t.Fatalf("renderText not deterministic:\n%q\n%q", a, b)
}
if !strings.HasPrefix(a, "body") {
t.Fatalf("rendered = %q, want the text first", a)
}
for _, once := range []string{
"https://cdn.example/a.png", "(image/png)",
"[Deploy] /deploy", "[Docs] https://docs.example",
"[Pick] One | Two", "[Approve] approval requested: ap-1",
} {
if n := strings.Count(a, once); n != 1 {
t.Fatalf("%q rendered %d times, want exactly once in %q", once, n, a)
}
}
}
// TestSendRequestNarrowDecode proves C2-6: identity fields (sender, account,
// channel) are not decodable on the egress body — the struct simply has no
// such fields, so nothing a caller sends can alias them. The route layer
// additionally rejects unknown keys loudly (send_test.go).
func TestSendRequestNarrowDecode(t *testing.T) {
raw := []byte(`{"room":{"id":"r1"},"text":"hi","sender":{"externalId":"evil"},"account":"spoof","channel":"slack"}`)
var r SendRequest
if err := json.Unmarshal(raw, &r); err != nil {
t.Fatalf("unmarshal: %v", err)
}
want := SendRequest{Room: Room{ID: "r1"}, Text: "hi"}
if !reflect.DeepEqual(r, want) {
t.Fatalf("decoded = %+v, want only the outbound projection %+v", r, want)
}
}
+127
View File
@@ -0,0 +1,127 @@
package channels
import (
"context"
"sync/atomic"
"time"
"github.com/hanzoai/cloud/clients/integrations"
)
// ingest is the registered integrations ingress consumer (channels.Mount):
// normalize -> identity -> gate -> route -> inbox | pairing. It runs on a
// detached per-event goroutine with a bounded context
// (integrations.emitIngress), so nothing here can delay a webhook.
// gcEverySec bounds opportunistic retention GC to once per 10 min across all
// ingest goroutines.
const gcEverySec = 600
var lastGC atomic.Int64
func ingest(ctx context.Context, ev integrations.IngressEvent) {
s := mounted.Load()
if s == nil {
return
}
st := s.State.store
tr, ok := transportFor(ev.In.Provider)
if !ok {
return
}
m, ok := tr.normalize(ev)
if !ok {
return
}
// Identity is best-effort: an unlinked user or KMS-down leaves UserID empty
// and never blocks ingest.
if subj, found, err := integrations.LinkedSubject(ev.Org, ev.In.Provider, ev.In.User); err == nil && found {
m.Sender.UserID = subj
}
now := time.Now().Unix()
// Gate BEFORE any write (C1-F4): a channel_route row is a send capability,
// so a blocked sender must not mint one.
var v verdict
var err error
if m.Room.Kind == RoomDM {
v, err = dmGate(ctx, st, ev.Org, m.Channel, m.Sender.ExternalID, true)
} else {
// A thread is a group surface: RoomThread deliberately gates under the
// group policy.
v, err = groupGate(ctx, st, ev.Org, m.Channel, m.Sender.ExternalID)
}
if err != nil {
// Fail closed: an unreadable policy drops the event. No sender ids in
// logs — reason codes only.
s.Log.Warn("channels: gate error, inbound dropped", "channel", m.Channel, "err", err)
return
}
if v.Allow || v.Pair {
// Route capture on allow AND pair — the pairing reply below must be able
// to ride the teams door. Upserted for all four transports; only discord
// (row presence = egress capability, ReplyRoot "") and teams (the
// JWT-verified serviceURL) read it — slack/telegram bind egress via
// per-org token / OrgForExternalID instead.
if rerr := st.upsertRoute(ctx, ev.Org, m.Channel, m.Room.ID, ev.ReplyRoot, now); rerr != nil {
s.Log.Warn("channels: route upsert", "channel", m.Channel, "err", rerr)
}
}
switch {
case v.Allow:
// ACCEPTED TRADEOFF (C1-F3): under groupPolicy=open any group member
// inserts inbox rows; event-key dedupe, 8 KiB truncation, 30-day GC, and
// single-conn SQLite serialization bound the damage. A per-org ingest
// limiter is the named follow-up alongside agent delivery.
// Agent delivery is NOT built this pass: this insert is the seam a
// future channels.RegisterDelivery consumer will observe.
if ierr := st.insertInbox(ctx, inboxRow{
Org: ev.Org,
Channel: m.Channel,
Account: m.Account,
RoomID: m.Room.ID,
RoomKind: m.Room.Kind,
Sender: m.Sender.ExternalID,
SenderUser: m.Sender.UserID,
Text: m.Text,
ReplyTo: m.ReplyTo,
EventKey: m.Idempotency,
CreatedAt: now,
}); ierr != nil {
s.Log.Warn("channels: inbox insert", "channel", m.Channel, "err", ierr)
}
case v.Pair:
code, created, perr := upsertPairing(ctx, st, ev.Org, m.Channel, m.Sender.ExternalID, now)
if perr != nil {
s.Log.Warn("channels: pairing", "channel", m.Channel, "err", perr)
} else if created {
// Reply only when a request was minted (at most one per TTL per
// sender; a full pending cap mints nothing). Ordering invariant: the
// route upserted above is what lets this send pass the discord/teams
// binding checks, and a slack/telegram chat is org-bound by the very
// event that arrived — no binding special case needed. The pairing
// message is never stored in the inbox and the code is never logged.
if _, serr := tr.send(ctx, s, ev.Org, Message{
Channel: m.Channel,
Account: m.Account,
Room: m.Room,
ReplyTo: m.ReplyTo,
Text: pairingText(code),
}); serr != nil {
s.Log.Warn("channels: pairing reply", "channel", m.Channel, "err", serr)
}
}
default:
// Blocked: closed reason code only — never sender ids.
s.Log.Debug("channels: inbound blocked", "channel", m.Channel, "reason", string(v.Reason))
}
// Opportunistic retention GC, at most once per gcEverySec across goroutines.
if last := lastGC.Load(); now-last > gcEverySec && lastGC.CompareAndSwap(last, now) {
if gerr := st.gc(ctx, now); gerr != nil {
s.Log.Warn("channels: gc", "err", gerr)
}
}
}
func pairingText(code string) string {
return "Pairing code: " + code + " — an org admin can approve it in the Hanzo console (expires in 1 hour)."
}
+692
View File
@@ -0,0 +1,692 @@
package channels
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/integrations"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// ingest_test.go owns the shared package harness (mounted app, identity
// requests, door spies, seam fixtures) plus the ingress-gate behavior tests.
// ingest is driven DIRECTLY: the goroutine hop lives in
// integrations.emitIngress and is proven in clients/integrations/
// ingress_test.go, so every test here is deterministic.
// ── harness ──────────────────────────────────────────────────────────────────
// syncBuf is a mutex-guarded log sink so tests can assert never-log
// invariants (pairing codes, sender ids) without a data race.
type syncBuf struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *syncBuf) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *syncBuf) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
// testEnv is one mounted channels app: the zip app, captured subsystem logs,
// and the DataDir holding channels.db.
type testEnv struct {
app *zip.App
logs *syncBuf
dataDir string
}
// newApp mounts channels exactly as apps.go does. Integrations stays
// unmounted on purpose: LinkedSubject fails soft (empty UserID),
// OrgForExternalID / ConnectionFor answer not-found — the fail-closed side
// every gate must survive.
func newApp(t *testing.T) *testEnv {
t.Helper()
logs := &syncBuf{}
app := zip.New(zip.Config{Logger: luxlog.New("test")})
dataDir := t.TempDir()
deps := cloud.Deps{Logger: luxlog.NewWriter(logs), DataDir: dataDir, Domain: "api.hanzo.ai"}
if err := Mount(app, deps); err != nil {
t.Fatalf("Mount: %v", err)
}
t.Cleanup(func() { _ = Shutdown(context.Background()) })
return &testEnv{app: app, logs: logs, dataDir: dataDir}
}
func (e *testEnv) store(t *testing.T) *store {
t.Helper()
s := mounted.Load()
if s == nil {
t.Fatal("channels not mounted")
}
return s.State.store
}
type httpResult struct {
Code int
Body []byte
}
// doReq issues one request with the gateway identity headers the middleware
// would mint (the integrations_test.go req idiom). org == "" sends NO
// identity — the anonymous-forge path the 403 tests need. admin adds the
// org-admin bit (X-User-IsOrgAdmin, principal.IsOrgAdmin).
func doReq(t *testing.T, e *testEnv, method, path, org string, admin bool, body any) httpResult {
t.Helper()
var r io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
r = bytes.NewReader(b)
}
rq := httptest.NewRequest(method, path, r)
if body != nil {
rq.Header.Set("Content-Type", "application/json")
}
if org != "" {
rq.Header.Set("X-Org-Id", org)
rq.Header.Set("X-User-Id", "u-"+org)
}
if admin {
rq.Header.Set("X-User-IsOrgAdmin", "true")
}
resp, err := e.app.Fiber().Test(rq)
if err != nil {
t.Fatalf("Test %s %s: %v", method, path, err)
}
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
return httpResult{Code: resp.StatusCode, Body: b}
}
func req(t *testing.T, e *testEnv, method, path, org string, body any) httpResult {
t.Helper()
return doReq(t, e, method, path, org, false, body)
}
func reqAdmin(t *testing.T, e *testEnv, method, path, org string, body any) httpResult {
t.Helper()
return doReq(t, e, method, path, org, true, body)
}
func decodeJSON(t *testing.T, body []byte, out any) {
t.Helper()
if err := json.Unmarshal(body, out); err != nil {
t.Fatalf("json: %v (%s)", err, body)
}
}
func putAllowlist(t *testing.T, e *testEnv, org string, body map[string]any) {
t.Helper()
res := reqAdmin(t, e, http.MethodPut, "/v1/channels/allowlist", org, body)
if res.Code != http.StatusOK {
t.Fatalf("PUT allowlist: %d (%s)", res.Code, res.Body)
}
}
// ── door spies ───────────────────────────────────────────────────────────────
// doorCall is one recorded transport-door invocation.
type doorCall struct {
org string
root string
room string
replyTo string
text string
}
// doorRec records door invocations. Mutex-guarded so recorders stay
// race-clean if a caller ever drives them from a goroutine.
type doorRec struct {
mu sync.Mutex
id string
fail error
calls []doorCall
}
func (r *doorRec) hit(c doorCall) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = append(r.calls, c)
return r.id, r.fail
}
func (r *doorRec) setFail(err error) {
r.mu.Lock()
defer r.mu.Unlock()
r.fail = err
}
func (r *doorRec) count() int {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.calls)
}
func (r *doorRec) call(t *testing.T, i int) doorCall {
t.Helper()
r.mu.Lock()
defer r.mu.Unlock()
if i >= len(r.calls) {
t.Fatalf("door call %d not recorded (have %d)", i, len(r.calls))
}
return r.calls[i]
}
// The four spy installers swap the package door vars for recorders and
// restore them on cleanup. ALL FOUR doors are spies in this package —
// Discord's real HTTP path is proven in clients/integrations/ingress_test.go
// (C2-4), symmetric with the other transports' existing send-path tests.
func spyTelegram(t *testing.T) *doorRec {
t.Helper()
rec := &doorRec{}
saved := telegramDoor
telegramDoor = func(_ context.Context, chatID, replyTo int64, text string) error {
_, err := rec.hit(doorCall{room: strconv.FormatInt(chatID, 10), replyTo: strconv.FormatInt(replyTo, 10), text: text})
return err
}
t.Cleanup(func() { telegramDoor = saved })
return rec
}
func spySlack(t *testing.T) *doorRec {
t.Helper()
rec := &doorRec{}
saved := slackDoor
slackDoor = func(_ context.Context, org, channel, threadTS, text string) error {
_, err := rec.hit(doorCall{org: org, room: channel, replyTo: threadTS, text: text})
return err
}
t.Cleanup(func() { slackDoor = saved })
return rec
}
func spyTeams(t *testing.T) *doorRec {
t.Helper()
rec := &doorRec{}
saved := teamsDoor
teamsDoor = func(_ context.Context, serviceURL, conversationID, text string) error {
_, err := rec.hit(doorCall{root: serviceURL, room: conversationID, text: text})
return err
}
t.Cleanup(func() { teamsDoor = saved })
return rec
}
func spyDiscord(t *testing.T) *doorRec {
t.Helper()
rec := &doorRec{id: "m-1"}
saved := discordDoor
discordDoor = func(_ context.Context, channelID, replyTo, text string) (string, error) {
return rec.hit(doorCall{room: channelID, replyTo: replyTo, text: text})
}
t.Cleanup(func() { discordDoor = saved })
return rec
}
// ingressEv builds one seam event; realistic per-transport values live at the
// call sites.
func ingressEv(org, provider, externalID, user, channel, thread, text, key, replyRoot string) integrations.IngressEvent {
return integrations.IngressEvent{
Org: org,
In: integrations.Inbound{Provider: provider, ExternalID: externalID, User: user, Channel: channel, ThreadID: thread, Text: text, DedupeKey: key},
ReplyRoot: replyRoot,
}
}
// ── ingest behavior ──────────────────────────────────────────────────────────
func TestIngestPairingDefault(t *testing.T) {
e := newApp(t)
tg := spyTelegram(t)
sl := spySlack(t)
ctx := context.Background()
const org = "acme-pair"
st := e.store(t)
// Telegram DM from an unknown sender: pairing request minted, message dropped.
ingest(ctx, ingressEv(org, "telegram", "hanzobot", "42", "777", "", "hi", "k1", ""))
rows, err := listPairing(ctx, st, org, time.Now().Unix())
if err != nil {
t.Fatalf("listPairing: %v", err)
}
if len(rows) != 1 || rows[0].Channel != "telegram" || rows[0].Sender != "42" {
t.Fatalf("pending = %+v, want one telegram/42 request", rows)
}
tgCode := rows[0].Code
if len(tgCode) != pairCodeLen || tgCode != strings.ToUpper(tgCode) {
t.Fatalf("code %q: want %d uppercase chars", tgCode, pairCodeLen)
}
if inbox, _ := st.listInbox(ctx, org, 0, 0); len(inbox) != 0 {
t.Fatalf("inbox = %d rows; a pairing-gated message is never stored", len(inbox))
}
// Integrations is unmounted here, so the telegram chat has NO org bind and
// C1-F1 gates even the pairing reply. In prod the bind exists by
// construction — the adapter resolved the org from this very chat.
if tg.count() != 0 {
t.Fatal("telegram door must not fire for an unbound chat")
}
// Same sender again: the request is refreshed — same code, one row.
ingest(ctx, ingressEv(org, "telegram", "hanzobot", "42", "777", "", "hi again", "k2", ""))
if rows2, _ := listPairing(ctx, st, org, time.Now().Unix()); len(rows2) != 1 || rows2[0].Code != tgCode {
t.Fatalf("refresh must keep the pending code: %+v", rows2)
}
// Slack DM: the pairing reply rides the door and carries the code.
ingest(ctx, ingressEv(org, "slack", "T024ABC", "u1", "D024BE91L", "", "hello", "s1", ""))
var slCode string
rows3, _ := listPairing(ctx, st, org, time.Now().Unix())
for _, r := range rows3 {
if r.Channel == "slack" && r.Sender == "u1" {
slCode = r.Code
}
}
if slCode == "" {
t.Fatalf("slack pairing request missing: %+v", rows3)
}
if sl.count() != 1 {
t.Fatalf("slack pairing reply: %d door calls, want 1", sl.count())
}
reply := sl.call(t, 0)
if reply.org != org || reply.room != "D024BE91L" || !strings.Contains(reply.text, slCode) {
t.Fatalf("pairing reply = %+v, want the code delivered to the DM", reply)
}
// Reply throttle: a refresh (created=false) sends nothing.
ingest(ctx, ingressEv(org, "slack", "T024ABC", "u1", "D024BE91L", "", "again", "s2", ""))
if sl.count() != 1 {
t.Fatal("refreshed pairing must not re-send the code")
}
if inbox, _ := st.listInbox(ctx, org, 0, 0); len(inbox) != 0 {
t.Fatal("pairing-gated messages never reach the inbox")
}
// Codes are bearer capabilities: admin surface only, never logs.
logs := e.logs.String()
for _, c := range []string{tgCode, slCode} {
if strings.Contains(logs, c) {
t.Fatalf("pairing code %q leaked into logs", c)
}
}
}
func TestIngestApproveRoundtrip(t *testing.T) {
e := newApp(t)
_ = spyTelegram(t) // absorb the (bind-gated) pairing reply attempt
ctx := context.Background()
const org = "acme-approve"
ingest(ctx, ingressEv(org, "telegram", "hanzobot", "42", "777", "", "hi", "k1", ""))
res := reqAdmin(t, e, http.MethodGet, "/v1/channels/pairing", org, nil)
if res.Code != http.StatusOK {
t.Fatalf("pairing list: %d (%s)", res.Code, res.Body)
}
var pending struct {
Pending []struct {
Channel string `json:"channel"`
Sender string `json:"sender"`
Code string `json:"code"`
} `json:"pending"`
}
decodeJSON(t, res.Body, &pending)
if len(pending.Pending) != 1 || pending.Pending[0].Sender != "42" {
t.Fatalf("pending = %s", res.Body)
}
code := pending.Pending[0].Code
// Plain members may read; anonymous callers may not.
if r := req(t, e, http.MethodGet, "/v1/channels/pairing", org, nil); r.Code != http.StatusOK {
t.Fatalf("member pairing read: %d", r.Code)
}
if r := req(t, e, http.MethodGet, "/v1/channels/pairing", "", nil); r.Code != http.StatusForbidden {
t.Fatalf("anonymous pairing read: %d, want 403", r.Code)
}
// Approval is admin-gated.
approveBody := map[string]any{"channel": "telegram", "code": code}
if r := req(t, e, http.MethodPost, "/v1/channels/pairing/approve", org, approveBody); r.Code != http.StatusForbidden {
t.Fatalf("non-admin approve: %d, want 403", r.Code)
}
res = reqAdmin(t, e, http.MethodPost, "/v1/channels/pairing/approve", org, approveBody)
if res.Code != http.StatusOK {
t.Fatalf("approve: %d (%s)", res.Code, res.Body)
}
var approved struct {
Sender string `json:"sender"`
OwnerBootstrapped bool `json:"ownerBootstrapped"`
}
decodeJSON(t, res.Body, &approved)
if approved.Sender != "42" || !approved.OwnerBootstrapped {
t.Fatalf("approve = %+v, want sender 42 + first-approval owner bootstrap", approved)
}
// A consumed code is gone.
if r := reqAdmin(t, e, http.MethodPost, "/v1/channels/pairing/approve", org, approveBody); r.Code != http.StatusNotFound {
t.Fatalf("re-approve: %d, want 404", r.Code)
}
// The paired sender's next message lands in the inbox.
ingest(ctx, ingressEv(org, "telegram", "hanzobot", "42", "777", "", "hello", "k2", ""))
res = req(t, e, http.MethodGet, "/v1/channels/inbox", org, nil)
if res.Code != http.StatusOK {
t.Fatalf("inbox: %d (%s)", res.Code, res.Body)
}
var inbox struct {
Messages []struct {
ID int64 `json:"id"`
Channel string `json:"channel"`
RoomID string `json:"roomId"`
RoomKind string `json:"roomKind"`
Sender string `json:"sender"`
SenderUser string `json:"senderUser"`
Text string `json:"text"`
} `json:"messages"`
Cursor int64 `json:"cursor"`
}
decodeJSON(t, res.Body, &inbox)
if len(inbox.Messages) != 1 {
t.Fatalf("inbox = %s", res.Body)
}
m := inbox.Messages[0]
if m.Channel != "telegram" || m.RoomID != "777" || m.RoomKind != "dm" || m.Sender != "42" || m.Text != "hello" {
t.Fatalf("inbox row = %+v", m)
}
if m.SenderUser != "" {
t.Fatalf("senderUser = %q; unlinked identity (integrations unmounted) must stay empty", m.SenderUser)
}
if inbox.Cursor != m.ID {
t.Fatalf("cursor = %d, want the last row id %d", inbox.Cursor, m.ID)
}
// The cursor excludes what was read.
res = req(t, e, http.MethodGet, "/v1/channels/inbox?since="+strconv.FormatInt(inbox.Cursor, 10), org, nil)
var page2 struct {
Messages []json.RawMessage `json:"messages"`
Cursor int64 `json:"cursor"`
}
decodeJSON(t, res.Body, &page2)
if len(page2.Messages) != 0 || page2.Cursor != inbox.Cursor {
t.Fatalf("since-cursor page = %s", res.Body)
}
}
func TestIngestAllowlistMode(t *testing.T) {
e := newApp(t)
_ = spyTelegram(t)
ctx := context.Background()
const org = "acme-allow"
st := e.store(t)
putAllowlist(t, e, org, map[string]any{"channel": "telegram", "dmPolicy": "allowlist", "dm": []string{"42"}})
ingest(ctx, ingressEv(org, "telegram", "hanzobot", "42", "700", "", "yo", "a1", ""))
if rows, _ := st.listInbox(ctx, org, 0, 0); len(rows) != 1 {
t.Fatalf("allowlisted sender: %d inbox rows, want 1", len(rows))
}
// A stranger is dropped silently: no inbox row AND no pairing request —
// pairing-source grants exist only under the pairing policy.
ingest(ctx, ingressEv(org, "telegram", "hanzobot", "u-sneak", "701", "", "let me in", "a2", ""))
if rows, _ := st.listInbox(ctx, org, 0, 0); len(rows) != 1 {
t.Fatal("blocked sender reached the inbox")
}
if pend, _ := listPairing(ctx, st, org, time.Now().Unix()); len(pend) != 0 {
t.Fatal("allowlist policy must not mint pairing requests")
}
// Blocked drops log the closed reason code only — never sender ids.
logs := e.logs.String()
if !strings.Contains(logs, string(dmNotAllowlisted)) {
t.Fatal("blocked drop must log its reason code")
}
if strings.Contains(logs, "u-sneak") {
t.Fatal("sender ids must never appear in logs")
}
}
func TestIngestOpenMode(t *testing.T) {
e := newApp(t)
_ = spyTelegram(t)
ctx := context.Background()
const org = "acme-open"
st := e.store(t)
// Open without `*` (or an explicit entry) admits nobody — and never pairs.
putAllowlist(t, e, org, map[string]any{"channel": "telegram", "dmPolicy": "open"})
ingest(ctx, ingressEv(org, "telegram", "hanzobot", "50", "500", "", "x", "o1", ""))
if rows, _ := st.listInbox(ctx, org, 0, 0); len(rows) != 0 {
t.Fatal("open without a wildcard must block")
}
if pend, _ := listPairing(ctx, st, org, time.Now().Unix()); len(pend) != 0 {
t.Fatal("open policy must not mint pairing requests")
}
putAllowlist(t, e, org, map[string]any{"channel": "telegram", "dmPolicy": "open", "dm": []string{"*"}})
ingest(ctx, ingressEv(org, "telegram", "hanzobot", "50", "500", "", "x", "o2", ""))
if rows, _ := st.listInbox(ctx, org, 0, 0); len(rows) != 1 {
t.Fatal("open + wildcard must admit")
}
}
func TestIngestGroupPolicy(t *testing.T) {
e := newApp(t)
sl := spySlack(t)
ctx := context.Background()
const org = "acme-group"
st := e.store(t)
group := func(key, text string) integrations.IngressEvent {
return ingressEv(org, "slack", "T024ABC", "u9", "C024BE91L", "", text, key, "")
}
// Default groupPolicy=open: a group message is stored.
ingest(ctx, group("g1", "hi"))
rows, _ := st.listInbox(ctx, org, 0, 0)
if len(rows) != 1 || rows[0].RoomKind != RoomGroup {
t.Fatalf("rows = %+v, want one group row", rows)
}
putAllowlist(t, e, org, map[string]any{"channel": "slack", "groupPolicy": "disabled"})
ingest(ctx, group("g2", "anyone?"))
if rows, _ := st.listInbox(ctx, org, 0, 0); len(rows) != 1 {
t.Fatal("disabled group surface must drop")
}
putAllowlist(t, e, org, map[string]any{"channel": "slack", "groupPolicy": "allowlist"})
ingest(ctx, group("g3", "empty allowlist"))
if rows, _ := st.listInbox(ctx, org, 0, 0); len(rows) != 1 {
t.Fatal("an empty group allowlist admits nobody")
}
// Pair + approve the same sender over DM…
ingest(ctx, ingressEv(org, "slack", "T024ABC", "u9", "D9", "", "pair me", "d1", ""))
if sl.count() != 1 {
t.Fatalf("pairing reply calls = %d, want 1", sl.count())
}
pend, _ := listPairing(ctx, st, org, time.Now().Unix())
if len(pend) != 1 {
t.Fatalf("pending = %+v", pend)
}
res := reqAdmin(t, e, http.MethodPost, "/v1/channels/pairing/approve", org,
map[string]any{"channel": "slack", "code": pend[0].Code})
if res.Code != http.StatusOK {
t.Fatalf("approve: %d (%s)", res.Code, res.Body)
}
ingest(ctx, ingressEv(org, "slack", "T024ABC", "u9", "D9", "", "dm ok", "d2", ""))
if rows, _ := st.listInbox(ctx, org, 0, 0); len(rows) != 2 {
t.Fatal("approved sender's DM must land in the inbox")
}
// …and the DM approval STILL does not open the group allowlist.
ingest(ctx, group("g4", "group again"))
if rows, _ := st.listInbox(ctx, org, 0, 0); len(rows) != 2 {
t.Fatal("a DM pairing approval must never grant group access")
}
}
func TestIngestRouteAfterGate(t *testing.T) {
e := newApp(t)
tm := spyTeams(t)
ctx := context.Background()
const org = "acme-teams"
st := e.store(t)
const conv = "19:abc@thread.tacv2"
const root = "https://smba.example/amer/"
// C1-F4: a blocked sender mints NO route — route presence is a send
// capability.
putAllowlist(t, e, org, map[string]any{"channel": "teams", "groupPolicy": "disabled"})
ingest(ctx, ingressEv(org, "teams", "tenant-1", "u1", conv, "", "hi", "t1", root))
if _, ok, _ := st.routeFor(ctx, org, "teams", conv); ok {
t.Fatal("blocked inbound must not mint a route")
}
if tm.count() != 0 {
t.Fatal("no door traffic for a blocked event")
}
// Allowed inbound stores the JWT-verified reply root; egress rides it.
putAllowlist(t, e, org, map[string]any{"channel": "teams", "groupPolicy": "open"})
ingest(ctx, ingressEv(org, "teams", "tenant-1", "u1", conv, "", "hi again", "t2", root))
got, ok, err := st.routeFor(ctx, org, "teams", conv)
if err != nil || !ok || got != root {
t.Fatalf("route = %q ok=%v err=%v, want the stored reply root", got, ok, err)
}
res := req(t, e, http.MethodPost, "/v1/channels/teams/send", org,
map[string]any{"room": map[string]any{"id": conv}, "text": "reply"})
if res.Code != http.StatusOK {
t.Fatalf("send: %d (%s)", res.Code, res.Body)
}
sent := tm.call(t, 0)
if sent.root != root || sent.room != conv {
t.Fatalf("send rode %+v, want the learned root", sent)
}
// The PAIR branch mints the route too — the pairing reply must be able to
// ride the teams door.
const dmConv = "a:1a2b"
const root2 = "https://smba.example/emea/"
ingest(ctx, ingressEv(org, "teams", "tenant-1", "u7", dmConv, "", "hello", "t3", root2))
got2, ok2, _ := st.routeFor(ctx, org, "teams", dmConv)
if !ok2 || got2 != root2 {
t.Fatalf("pair-branch route = %q ok=%v, want %q", got2, ok2, root2)
}
if tm.count() != 2 {
t.Fatalf("door calls = %d, want the pairing reply as the 2nd", tm.count())
}
reply := tm.call(t, 1)
if reply.root != root2 || reply.room != dmConv || !strings.Contains(reply.text, "Pairing code: ") {
t.Fatalf("pairing reply = %+v", reply)
}
}
func TestIngestEventKeyIdempotent(t *testing.T) {
e := newApp(t)
_ = spyTelegram(t)
ctx := context.Background()
const org = "acme-dup"
st := e.store(t)
putAllowlist(t, e, org, map[string]any{"channel": "telegram", "dmPolicy": "open", "dm": []string{"*"}})
ev := ingressEv(org, "telegram", "hanzobot", "9", "900", "", "same", "dup-1", "")
ingest(ctx, ev)
ingest(ctx, ev)
if rows, _ := st.listInbox(ctx, org, 0, 0); len(rows) != 1 {
t.Fatalf("redelivered event key stored %d rows, want 1", len(rows))
}
}
func TestIngestOrgIsolation(t *testing.T) {
e := newApp(t)
_ = spyTelegram(t)
_ = spySlack(t)
ctx := context.Background()
const orgA = "acme-iso-a"
const orgB = "acme-iso-b"
st := e.store(t)
putAllowlist(t, e, orgA, map[string]any{"channel": "telegram", "dmPolicy": "open", "dm": []string{"*"}})
ingest(ctx, ingressEv(orgA, "telegram", "hanzobot", "1", "100", "", "secret-a", "i1", ""))
ingest(ctx, ingressEv(orgA, "slack", "T1", "u1", "D1", "", "pair", "i2", ""))
// Org B sees neither A's inbox nor A's pending pairings.
res := req(t, e, http.MethodGet, "/v1/channels/inbox", orgB, nil)
var inbox struct {
Messages []json.RawMessage `json:"messages"`
}
decodeJSON(t, res.Body, &inbox)
if len(inbox.Messages) != 0 {
t.Fatalf("org-b inbox = %s, want empty", res.Body)
}
res = reqAdmin(t, e, http.MethodGet, "/v1/channels/pairing", orgB, nil)
var pending struct {
Pending []json.RawMessage `json:"pending"`
}
decodeJSON(t, res.Body, &pending)
if len(pending.Pending) != 0 {
t.Fatalf("org-b pending = %s, want empty", res.Body)
}
// And A's open policy does not leak into B: the same sender pairs there.
ingest(ctx, ingressEv(orgB, "telegram", "hanzobot", "1", "100", "", "hi", "i3", ""))
if rows, _ := st.listInbox(ctx, orgB, 0, 0); len(rows) != 0 {
t.Fatal("org-b must keep the strict pairing default")
}
if pend, _ := listPairing(ctx, st, orgB, time.Now().Unix()); len(pend) != 1 {
t.Fatalf("org-b pending = %+v, want its own pairing request", pend)
}
}
func TestIngestDiscordRoute(t *testing.T) {
e := newApp(t)
dc := spyDiscord(t)
ctx := context.Background()
const org = "acme-disc"
st := e.store(t)
// Allowed guild inbound (groupPolicy default open) stores the message AND
// mints the discord route — presence with reply_root '' IS the send
// capability (C1-F1).
ingest(ctx, ingressEv(org, "discord", "GUILD9", "u5", "c-99", "", "hi", "dk1", ""))
rows, _ := st.listInbox(ctx, org, 0, 0)
if len(rows) != 1 || rows[0].RoomKind != RoomGroup || rows[0].Account != "guild9" {
t.Fatalf("rows = %+v", rows)
}
root, ok, err := st.routeFor(ctx, org, "discord", "c-99")
if err != nil || !ok || root != "" {
t.Fatalf("route = %q ok=%v err=%v, want present with empty root", root, ok, err)
}
res := req(t, e, http.MethodPost, "/v1/channels/discord/send", org,
map[string]any{"room": map[string]any{"id": "c-99"}, "text": "pong"})
if res.Code != http.StatusOK {
t.Fatalf("send: %d (%s)", res.Code, res.Body)
}
var d Delivery
decodeJSON(t, res.Body, &d)
if d.MessageID != "m-1" {
t.Fatalf("delivery = %+v, want the door's message id", d)
}
if dc.count() != 1 {
t.Fatalf("door calls = %d, want 1", dc.count())
}
if got := dc.call(t, 0); got.room != "c-99" {
t.Fatalf("door call = %+v, want room c-99", got)
}
}
+203
View File
@@ -0,0 +1,203 @@
package channels
import (
"context"
"crypto/rand"
"database/sql"
"errors"
"fmt"
"strings"
"time"
)
// pairing.go is the OpenClaw pairing-store port. A sender hitting a
// pairing-policy DM gets an 8-char code minted here; an org admin approves the
// code, which grants the sender a pairing-source DM allow entry. Codes are
// bearer capabilities: stored uppercase, shown only on the admin surface,
// never logged. The pending cap is per (org, channel) — the channel-account
// key, since one platform account exists per pair.
const (
pairCodeLen = 8
// pairAlphabet is uppercase A-Z0-9 minus the confusables 0/O/1/I — exactly
// 32 symbols, so one random byte mod 32 carries no modulo bias.
pairAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
pairTTL = time.Hour
pairMaxPending = 3
pairCodeAttempts = 500
)
// pairingRow is one pending pairing request. Times are Unix seconds.
type pairingRow struct {
Org string `json:"org"`
Channel string `json:"channel"`
Sender string `json:"sender"`
Code string `json:"code"`
CreatedAt int64 `json:"createdAt"`
LastSeen int64 `json:"lastSeen"`
}
// pairCutoff is the oldest unexpired created_at: rows strictly older are
// expired, so a request expires strictly AFTER pairTTL (the pairing-store.ts
// `>` boundary). prunePairing and listPairing use the same cutoff.
func pairCutoff(now int64) int64 { return now - int64(pairTTL/time.Second) }
func prunePairing(ctx context.Context, tx *sql.Tx, org, channel string, now int64) error {
_, err := tx.ExecContext(ctx, `DELETE FROM channel_pairing
WHERE org = ? AND channel = ? AND created_at < ?`, org, channel, pairCutoff(now))
return err
}
// mintCode returns pairCodeLen chars drawn from pairAlphabet via crypto/rand.
func mintCode() (string, error) {
buf := make([]byte, pairCodeLen)
if _, err := rand.Read(buf); err != nil {
return "", err
}
for i, b := range buf {
buf[i] = pairAlphabet[int(b)%len(pairAlphabet)]
}
return string(buf), nil
}
// upsertPairing records that sender needs pairing on (org, channel).
// created=true means a new code was minted and the caller should send the
// pairing reply; created=false means an existing pending request was refreshed
// (same code, last_seen advanced) or the pending cap is full (code="") — both
// throttle the chat reply to at most one per TTL per sender.
func upsertPairing(ctx context.Context, st *store, org, channel, sender string, now int64) (code string, created bool, err error) {
tx, err := st.db.BeginTx(ctx, nil)
if err != nil {
return "", false, err
}
defer func() { _ = tx.Rollback() }()
if err := prunePairing(ctx, tx, org, channel, now); err != nil {
return "", false, err
}
var existing string
err = tx.QueryRowContext(ctx, `SELECT code FROM channel_pairing
WHERE org = ? AND channel = ? AND sender = ?`, org, channel, sender).Scan(&existing)
switch {
case err == nil:
if _, err := tx.ExecContext(ctx, `UPDATE channel_pairing SET last_seen = ?
WHERE org = ? AND channel = ? AND sender = ?`, now, org, channel, sender); err != nil {
return "", false, err
}
return existing, false, tx.Commit()
case !errors.Is(err, sql.ErrNoRows):
return "", false, err
}
var pending int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM channel_pairing
WHERE org = ? AND channel = ?`, org, channel).Scan(&pending); err != nil {
return "", false, err
}
if pending >= pairMaxPending {
// Exact OpenClaw port: no eviction at the cap. ACCEPTED TRADEOFF — three
// junk requests lock pairing for this (org, channel) for up to 1 h; an
// admin approval or TTL expiry clears the slots.
return "", false, tx.Commit()
}
for range pairCodeAttempts {
c, err := mintCode()
if err != nil {
return "", false, err
}
// Codes are stored and minted uppercase, so exact match IS the
// case-insensitive uniqueness check against pending codes.
var one int
err = tx.QueryRowContext(ctx, `SELECT 1 FROM channel_pairing
WHERE org = ? AND channel = ? AND code = ?`, org, channel, c).Scan(&one)
if err == nil {
continue
}
if !errors.Is(err, sql.ErrNoRows) {
return "", false, err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO channel_pairing (org, channel, sender, code, created_at, last_seen)
VALUES (?,?,?,?,?,?)`, org, channel, sender, c, now, now); err != nil {
return "", false, err
}
return c, true, tx.Commit()
}
return "", false, fmt.Errorf("pairing: no unique code after %d attempts", pairCodeAttempts)
}
// approvePairing consumes a pending code: the request row is deleted and the
// sender gains a pairing-source DM allow entry. ok=false means no pending
// request matches the code (unknown, expired, or already approved).
func approvePairing(ctx context.Context, st *store, org, channel, code string, now int64) (sender string, ownerBootstrapped bool, ok bool, err error) {
code = strings.ToUpper(strings.TrimSpace(code))
if code == "" {
return "", false, false, nil
}
tx, err := st.db.BeginTx(ctx, nil)
if err != nil {
return "", false, false, err
}
defer func() { _ = tx.Rollback() }()
if err := prunePairing(ctx, tx, org, channel, now); err != nil {
return "", false, false, err
}
err = tx.QueryRowContext(ctx, `SELECT sender FROM channel_pairing
WHERE org = ? AND channel = ? AND code = ?`, org, channel, code).Scan(&sender)
if errors.Is(err, sql.ErrNoRows) {
return "", false, false, tx.Commit()
}
if err != nil {
return "", false, false, err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM channel_pairing
WHERE org = ? AND channel = ? AND sender = ?`, org, channel, sender); err != nil {
return "", false, false, err
}
// `*` and "" are policy syntax, never identities — refuse to mint a grant.
if sender == "" || sender == "*" {
return "", false, false, tx.Commit()
}
// OWNERSHIP (mirror of policy.go putAllow): this is the ONLY writer of
// pairing-source channel_allow rows, and approval grants DM access only —
// NEVER group. OR IGNORE keeps an existing config-source grant, which is
// already broader, authoritative.
if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO channel_allow (org, channel, scope, entry, source, created_at)
VALUES (?,?,'dm',?,'pairing',?)`, org, channel, sender, now); err != nil {
return "", false, false, err
}
// Owner bootstrap: the FIRST approved pairing in the org records the owner
// as '<channel>:<sender>'; once any owner exists, later approvals grant DM
// access only.
var ownerEntry string
err = tx.QueryRowContext(ctx, `SELECT entry FROM channel_owner WHERE org = ?`, org).Scan(&ownerEntry)
switch {
case errors.Is(err, sql.ErrNoRows):
if _, err := tx.ExecContext(ctx, `INSERT INTO channel_owner (org, entry, created_at)
VALUES (?,?,?)`, org, channel+":"+sender, now); err != nil {
return "", false, false, err
}
ownerBootstrapped = true
case err != nil:
return "", false, false, err
}
return sender, ownerBootstrapped, true, tx.Commit()
}
// listPairing returns the org's pending (unexpired) requests, ordered for
// deterministic JSON. Codes appear here for the admin approval surface.
func listPairing(ctx context.Context, st *store, org string, now int64) ([]pairingRow, error) {
rows, err := st.db.QueryContext(ctx, `SELECT org, channel, sender, code, created_at, last_seen
FROM channel_pairing WHERE org = ? AND created_at >= ?
ORDER BY channel, created_at, sender`, org, pairCutoff(now))
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
out := []pairingRow{}
for rows.Next() {
var r pairingRow
if err := rows.Scan(&r.Org, &r.Channel, &r.Sender, &r.Code, &r.CreatedAt, &r.LastSeen); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
+295
View File
@@ -0,0 +1,295 @@
package channels
import (
"context"
"database/sql"
"errors"
"fmt"
"slices"
"strings"
)
// policy.go is the gate engine — the OpenClaw sender-gates port onto SQLite.
// The policy key is (org, channel) only: integrations' connections PK is
// (org, provider), so exactly one platform account exists per pair and the
// account dimension collapses away. Gate decisions surface closed reason
// codes; sender ids never appear in logs.
// DMPolicy governs direct messages on a channel.
type DMPolicy string
const (
DMPairing DMPolicy = "pairing"
DMAllowlist DMPolicy = "allowlist"
DMOpen DMPolicy = "open"
)
// GroupPolicy governs group (and thread) surfaces on a channel.
type GroupPolicy string
const (
GroupOpen GroupPolicy = "open"
GroupAllowlist GroupPolicy = "allowlist"
GroupDisabled GroupPolicy = "disabled"
)
// policyRow is one channel's access policy; the zero row is never stored —
// absent means the defaults {pairing, open}.
type policyRow struct {
DM DMPolicy `json:"dmPolicy"`
Group GroupPolicy `json:"groupPolicy"`
}
func (p policyRow) validate() error {
switch p.DM {
case DMPairing, DMAllowlist, DMOpen:
default:
return fmt.Errorf("policy: unknown dm policy %q", p.DM)
}
switch p.Group {
case GroupOpen, GroupAllowlist, GroupDisabled:
default:
return fmt.Errorf("policy: unknown group policy %q", p.Group)
}
return nil
}
// gateReason is the closed set of gate outcomes — the only decision detail
// that may be logged.
type gateReason string
const (
dmOpenWildcard gateReason = "dmOpenWildcard"
dmAllowlisted gateReason = "dmAllowlisted"
dmPaired gateReason = "dmPaired"
dmNotAllowlisted gateReason = "dmNotAllowlisted"
dmPairingRequired gateReason = "dmPairingRequired"
groupOpen gateReason = "groupOpen"
groupAllowlisted gateReason = "groupAllowlisted"
groupDisabled gateReason = "groupDisabled"
groupEmptyAllowlist gateReason = "groupEmptyAllowlist"
groupNotAllowlisted gateReason = "groupNotAllowlisted"
)
// verdict is a gate decision. Pair=true only on pairing-required: mint a code
// and drop — the message never reaches the inbox.
type verdict struct {
Allow bool
Pair bool
Reason gateReason
}
// policyFor returns the channel's policy; an absent row means the defaults
// (dm=pairing — strictest; group=open).
func policyFor(ctx context.Context, st *store, org, channel string) (policyRow, error) {
var p policyRow
err := st.db.QueryRowContext(ctx, `SELECT dm_policy, group_policy FROM channel_policy
WHERE org = ? AND channel = ?`, org, channel).Scan(&p.DM, &p.Group)
if errors.Is(err, sql.ErrNoRows) {
return policyRow{DM: DMPairing, Group: GroupOpen}, nil
}
return p, err
}
// setPolicy upserts the channel's policy.
func setPolicy(ctx context.Context, st *store, org, channel string, p policyRow, now int64) error {
if err := p.validate(); err != nil {
return err
}
_, err := st.db.ExecContext(ctx, `INSERT INTO channel_policy (org, channel, dm_policy, group_policy, updated_at)
VALUES (?,?,?,?,?)
ON CONFLICT (org, channel) DO UPDATE SET dm_policy = excluded.dm_policy,
group_policy = excluded.group_policy, updated_at = excluded.updated_at`,
org, channel, string(p.DM), string(p.Group), now)
return err
}
// allowEntries returns the channel's allow entries for scope, split by source
// class: config (admin PUT) and paired (approved pairings).
func allowEntries(ctx context.Context, st *store, org, channel, scope string) (config, paired []string, err error) {
rows, err := st.db.QueryContext(ctx, `SELECT entry, source FROM channel_allow
WHERE org = ? AND channel = ? AND scope = ? ORDER BY entry`, org, channel, scope)
if err != nil {
return nil, nil, err
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var entry, source string
if err := rows.Scan(&entry, &source); err != nil {
return nil, nil, err
}
if source == "pairing" {
paired = append(paired, entry)
} else {
config = append(config, entry)
}
}
return config, paired, rows.Err()
}
// matches reports whether sender is granted by entries: an exact id match, or
// membership in an `accessGroup:<name>` resolved against the org's groups for
// this channel ('*' rows are shared across channels). No wildcard handling
// here — `*` is gate-level syntax, not an identity.
func matches(ctx context.Context, st *store, org, channel, sender string, entries []string) (bool, error) {
for _, e := range entries {
if e == sender {
return true, nil
}
name, isGroup := strings.CutPrefix(e, "accessGroup:")
if !isGroup {
continue
}
var one int
err := st.db.QueryRowContext(ctx, `SELECT 1 FROM channel_access_group
WHERE org = ? AND name = ? AND channel IN (?, '*') AND entry = ?`, org, name, channel, sender).Scan(&one)
if errors.Is(err, sql.ErrNoRows) {
continue
}
if err != nil {
return false, err
}
return true, nil
}
return false, nil
}
// dmGate decides a direct message, in the ported OpenClaw order. mayPair=true
// lets a pairing-policy miss mint a pairing request instead of a plain block.
func dmGate(ctx context.Context, st *store, org, channel, sender string, mayPair bool) (verdict, error) {
p, err := policyFor(ctx, st, org, channel)
if err != nil {
return verdict{}, err
}
config, paired, err := allowEntries(ctx, st, org, channel, "dm")
if err != nil {
return verdict{}, err
}
if p.DM == DMOpen {
// Open is NOT unconditional: it requires `*` or an explicit config
// match, and pairing-source rows never widen an open channel.
if slices.Contains(config, "*") {
return verdict{Allow: true, Reason: dmOpenWildcard}, nil
}
m, err := matches(ctx, st, org, channel, sender, config)
if err != nil {
return verdict{}, err
}
if m {
return verdict{Allow: true, Reason: dmAllowlisted}, nil
}
return verdict{Reason: dmNotAllowlisted}, nil
}
m, err := matches(ctx, st, org, channel, sender, config)
if err != nil {
return verdict{}, err
}
if m {
return verdict{Allow: true, Reason: dmAllowlisted}, nil
}
if p.DM == DMPairing {
// Pairing-source rows are valid ONLY under pairing policy — the source
// column encodes the grant class, so switching to allowlist suspends
// paired senders without deleting their grants.
if slices.Contains(paired, sender) {
return verdict{Allow: true, Reason: dmPaired}, nil
}
if mayPair {
return verdict{Pair: true, Reason: dmPairingRequired}, nil
}
}
return verdict{Reason: dmNotAllowlisted}, nil
}
// groupGate decides a group (or thread) message.
func groupGate(ctx context.Context, st *store, org, channel, sender string) (verdict, error) {
p, err := policyFor(ctx, st, org, channel)
if err != nil {
return verdict{}, err
}
switch p.Group {
case GroupDisabled:
return verdict{Reason: groupDisabled}, nil
case GroupOpen:
return verdict{Allow: true, Reason: groupOpen}, nil
}
config, _, err := allowEntries(ctx, st, org, channel, "group")
if err != nil {
return verdict{}, err
}
if len(config) == 0 {
return verdict{Reason: groupEmptyAllowlist}, nil
}
if slices.Contains(config, "*") {
return verdict{Allow: true, Reason: groupAllowlisted}, nil
}
m, err := matches(ctx, st, org, channel, sender, config)
if err != nil {
return verdict{}, err
}
if m {
return verdict{Allow: true, Reason: groupAllowlisted}, nil
}
return verdict{Reason: groupNotAllowlisted}, nil
}
// putAllow replaces the channel's config-source allow entries for scope.
// OWNERSHIP: config-source rows are owned by this func (the admin PUT);
// pairing-source rows are owned exclusively by approvePairing (pairing.go) —
// the source column is the write boundary, so policy edits never revoke an
// approved pairing and vice versa. An entry the admin lists explicitly takes
// config ownership (upgrade on conflict): a config grant is valid under every
// policy, and from then on the admin PUT owns its lifecycle.
func putAllow(ctx context.Context, st *store, org, channel, scope string, entries []string, now int64) error {
if scope != "dm" && scope != "group" {
return fmt.Errorf("allow: unknown scope %q", scope)
}
tx, err := st.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `DELETE FROM channel_allow
WHERE org = ? AND channel = ? AND scope = ? AND source = 'config'`, org, channel, scope); err != nil {
return err
}
for _, e := range entries {
if e == "" {
return fmt.Errorf("allow: empty entry")
}
if _, err := tx.ExecContext(ctx, `INSERT INTO channel_allow (org, channel, scope, entry, source, created_at)
VALUES (?,?,?,?,'config',?)
ON CONFLICT (org, channel, scope, entry) DO UPDATE SET source = 'config', created_at = excluded.created_at`,
org, channel, scope, e, now); err != nil {
return err
}
}
return tx.Commit()
}
// putAccessGroups replaces the org's access groups wholesale. groups maps
// name -> channel ('*' = shared across channels) -> member entries.
func putAccessGroups(ctx context.Context, st *store, org string, groups map[string]map[string][]string) error {
tx, err := st.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `DELETE FROM channel_access_group WHERE org = ?`, org); err != nil {
return err
}
for name, byChannel := range groups {
for channel, entries := range byChannel {
for _, e := range entries {
if name == "" || channel == "" || e == "" {
return fmt.Errorf("access group: name, channel, and entry required")
}
if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO channel_access_group (org, name, channel, entry)
VALUES (?,?,?,?)`, org, name, channel, e); err != nil {
return err
}
}
}
}
return tx.Commit()
}
+439
View File
@@ -0,0 +1,439 @@
package channels
import (
"context"
"path/filepath"
"strings"
"testing"
)
// policy_test.go is the pure-store proof of the gate engine and pairing state
// machine: no HTTP, no network, explicit clocks. The policy key is
// (org, channel) — no account dimension anywhere (C2-2).
// t0 is the fixed test epoch every explicit clock counts from.
const t0 int64 = 1_700_000_000
func newStore(t *testing.T) *store {
t.Helper()
st, err := openStore(filepath.Join(t.TempDir(), "channels.db"))
if err != nil {
t.Fatalf("openStore: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
return st
}
func mustPair(t *testing.T, st *store, org, channel, sender string, now int64) string {
t.Helper()
code, created, err := upsertPairing(context.Background(), st, org, channel, sender, now)
if err != nil || !created {
t.Fatalf("upsertPairing(%s): created=%v err=%v", sender, created, err)
}
return code
}
func TestPairingCodeShape(t *testing.T) {
st := newStore(t)
ctx := context.Background()
code := mustPair(t, st, "acme", "telegram", "42", t0)
if len(code) != pairCodeLen {
t.Fatalf("code %q length %d, want %d", code, len(code), pairCodeLen)
}
for _, r := range code {
if !strings.ContainsRune(pairAlphabet, r) {
t.Fatalf("code %q contains %q outside the pairing alphabet", code, r)
}
}
if code != strings.ToUpper(code) {
t.Fatalf("code %q must be uppercase", code)
}
var stored string
if err := st.db.QueryRowContext(ctx, `SELECT code FROM channel_pairing
WHERE org='acme' AND channel='telegram' AND sender='42'`).Scan(&stored); err != nil {
t.Fatalf("stored code: %v", err)
}
if stored != code {
t.Fatalf("stored %q != returned %q", stored, code)
}
}
func TestPairingTTLStrictAfter(t *testing.T) {
st := newStore(t)
ctx := context.Background()
code42 := mustPair(t, st, "acme", "telegram", "42", t0)
code43 := mustPair(t, st, "acme", "telegram", "43", t0)
// Exactly TTL later the request is STILL valid — expiry is strictly after
// one hour (the pairing-store.ts `>` boundary).
rows, err := listPairing(ctx, st, "acme", t0+3600)
if err != nil || len(rows) != 2 {
t.Fatalf("listPairing at +3600 = %d rows, err %v; want both", len(rows), err)
}
sender, _, ok, err := approvePairing(ctx, st, "acme", "telegram", code42, t0+3600)
if err != nil || !ok || sender != "42" {
t.Fatalf("approve at exactly TTL: ok=%v sender=%q err=%v", ok, sender, err)
}
// One second past TTL the request is gone.
if rows, _ := listPairing(ctx, st, "acme", t0+3601); len(rows) != 0 {
t.Fatalf("listPairing at +3601 = %d rows, want 0", len(rows))
}
if _, _, ok, err := approvePairing(ctx, st, "acme", "telegram", code43, t0+3601); err != nil || ok {
t.Fatalf("approve past TTL: ok=%v err=%v, want a miss", ok, err)
}
}
func TestPairingRefreshNotRemint(t *testing.T) {
st := newStore(t)
ctx := context.Background()
code := mustPair(t, st, "acme", "telegram", "42", t0)
code2, created, err := upsertPairing(ctx, st, "acme", "telegram", "42", t0+10)
if err != nil {
t.Fatalf("refresh: %v", err)
}
if created || code2 != code {
t.Fatalf("refresh minted (created=%v code=%q), want the same pending code %q", created, code2, code)
}
var lastSeen int64
if err := st.db.QueryRowContext(ctx, `SELECT last_seen FROM channel_pairing
WHERE org='acme' AND channel='telegram' AND sender='42'`).Scan(&lastSeen); err != nil {
t.Fatalf("last_seen: %v", err)
}
if lastSeen != t0+10 {
t.Fatalf("last_seen = %d, want bumped to %d", lastSeen, t0+10)
}
}
func TestPairingMaxPending(t *testing.T) {
st := newStore(t)
ctx := context.Background()
codes := make([]string, 0, pairMaxPending)
for _, sender := range []string{"a", "b", "c"} {
codes = append(codes, mustPair(t, st, "acme", "telegram", sender, t0))
}
// The 4th request finds the cap: no code, no error (the exact OpenClaw
// port — no eviction; TTL or approval clears slots).
code, created, err := upsertPairing(ctx, st, "acme", "telegram", "d", t0)
if err != nil || created || code != "" {
t.Fatalf("at cap: code=%q created=%v err=%v, want empty no-op", code, created, err)
}
// The first three stay approvable.
for i, c := range codes {
if _, _, ok, err := approvePairing(ctx, st, "acme", "telegram", c, t0+1); err != nil || !ok {
t.Fatalf("approve %d: ok=%v err=%v", i, ok, err)
}
}
}
func TestPairingCodesDistinct(t *testing.T) {
st := newStore(t)
seen := map[string]bool{}
for _, sender := range []string{"a", "b", "c"} {
code := strings.ToUpper(mustPair(t, st, "acme", "telegram", sender, t0))
if seen[code] {
t.Fatalf("code %q minted twice for one (org, channel)", code)
}
seen[code] = true
}
}
func TestApproveGrantsDMOnly(t *testing.T) {
st := newStore(t)
ctx := context.Background()
code := mustPair(t, st, "acme", "telegram", "42", t0)
sender, boot, ok, err := approvePairing(ctx, st, "acme", "telegram", code, t0+1)
if err != nil || !ok || sender != "42" || !boot {
t.Fatalf("approve: sender=%q boot=%v ok=%v err=%v", sender, boot, ok, err)
}
// The grant is a pairing-source DM allow entry.
_, paired, err := allowEntries(ctx, st, "acme", "telegram", "dm")
if err != nil || len(paired) != 1 || paired[0] != "42" {
t.Fatalf("paired entries = %v err=%v, want [42]", paired, err)
}
v, err := dmGate(ctx, st, "acme", "telegram", "42", true)
if err != nil || !v.Allow || v.Reason != dmPaired {
t.Fatalf("dmGate = %+v err=%v, want allow dmPaired", v, err)
}
// DM approval NEVER admits the sender to a group allowlist.
if err := setPolicy(ctx, st, "acme", "telegram", policyRow{DM: DMPairing, Group: GroupAllowlist}, t0+2); err != nil {
t.Fatalf("setPolicy: %v", err)
}
gv, err := groupGate(ctx, st, "acme", "telegram", "42")
if err != nil || gv.Allow || gv.Reason != groupEmptyAllowlist {
t.Fatalf("groupGate = %+v err=%v, want groupEmptyAllowlist block", gv, err)
}
}
func TestPairedRowsOnlyUnderPairingPolicy(t *testing.T) {
st := newStore(t)
ctx := context.Background()
code := mustPair(t, st, "acme", "telegram", "42", t0)
if _, _, ok, err := approvePairing(ctx, st, "acme", "telegram", code, t0+1); err != nil || !ok {
t.Fatalf("approve: ok=%v err=%v", ok, err)
}
// The pairing-source row is suspended, not deleted, under other policies.
for _, dm := range []DMPolicy{DMAllowlist, DMOpen} {
if err := setPolicy(ctx, st, "acme", "telegram", policyRow{DM: dm, Group: GroupOpen}, t0+2); err != nil {
t.Fatalf("setPolicy(%s): %v", dm, err)
}
v, err := dmGate(ctx, st, "acme", "telegram", "42", true)
if err != nil || v.Allow || v.Pair || v.Reason != dmNotAllowlisted {
t.Fatalf("dmGate under %s = %+v err=%v, want plain block", dm, v, err)
}
}
// Switching back re-validates the grant.
if err := setPolicy(ctx, st, "acme", "telegram", policyRow{DM: DMPairing, Group: GroupOpen}, t0+3); err != nil {
t.Fatalf("setPolicy: %v", err)
}
if v, err := dmGate(ctx, st, "acme", "telegram", "42", true); err != nil || !v.Allow || v.Reason != dmPaired {
t.Fatalf("dmGate back under pairing = %+v err=%v", v, err)
}
}
func TestDMOpenSemantics(t *testing.T) {
st := newStore(t)
ctx := context.Background()
if err := setPolicy(ctx, st, "acme", "telegram", policyRow{DM: DMOpen, Group: GroupOpen}, t0); err != nil {
t.Fatalf("setPolicy: %v", err)
}
// Open is not unconditional: no entries ⇒ block, and never a pairing mint.
v, err := dmGate(ctx, st, "acme", "telegram", "55", true)
if err != nil || v.Allow || v.Pair || v.Reason != dmNotAllowlisted {
t.Fatalf("open+empty = %+v err=%v, want block", v, err)
}
if err := putAllow(ctx, st, "acme", "telegram", "dm", []string{"*"}, t0+1); err != nil {
t.Fatalf("putAllow: %v", err)
}
if v, err := dmGate(ctx, st, "acme", "telegram", "55", true); err != nil || !v.Allow || v.Reason != dmOpenWildcard {
t.Fatalf("open+wildcard = %+v err=%v", v, err)
}
// Explicit config match, and only that match.
if err := putAllow(ctx, st, "acme", "telegram", "dm", []string{"55"}, t0+2); err != nil {
t.Fatalf("putAllow: %v", err)
}
if v, err := dmGate(ctx, st, "acme", "telegram", "55", true); err != nil || !v.Allow || v.Reason != dmAllowlisted {
t.Fatalf("open+explicit = %+v err=%v", v, err)
}
if v, err := dmGate(ctx, st, "acme", "telegram", "56", true); err != nil || v.Allow {
t.Fatalf("open must not admit an unlisted sender: %+v err=%v", v, err)
}
}
func TestOwnerBootstrapOnce(t *testing.T) {
st := newStore(t)
ctx := context.Background()
codeA := mustPair(t, st, "acme", "telegram", "a1", t0)
if _, boot, ok, err := approvePairing(ctx, st, "acme", "telegram", codeA, t0+1); err != nil || !ok || !boot {
t.Fatalf("first approve: boot=%v ok=%v err=%v", boot, ok, err)
}
var entry string
if err := st.db.QueryRowContext(ctx, `SELECT entry FROM channel_owner WHERE org='acme'`).Scan(&entry); err != nil {
t.Fatalf("owner: %v", err)
}
if entry != "telegram:a1" {
t.Fatalf("owner entry = %q, want telegram:a1", entry)
}
codeB := mustPair(t, st, "acme", "telegram", "b2", t0+2)
if _, boot, ok, err := approvePairing(ctx, st, "acme", "telegram", codeB, t0+3); err != nil || !ok || boot {
t.Fatalf("second approve: boot=%v ok=%v err=%v, want no re-bootstrap", boot, ok, err)
}
if err := st.db.QueryRowContext(ctx, `SELECT entry FROM channel_owner WHERE org='acme'`).Scan(&entry); err != nil || entry != "telegram:a1" {
t.Fatalf("owner after second approve = %q err=%v, want unchanged", entry, err)
}
}
func TestApproveInputHygiene(t *testing.T) {
st := newStore(t)
ctx := context.Background()
// Case-insensitive input: codes are stored uppercase, approvals fold.
code := mustPair(t, st, "acme", "telegram", "42", t0)
if sender, _, ok, err := approvePairing(ctx, st, "acme", "telegram", strings.ToLower(code), t0+1); err != nil || !ok || sender != "42" {
t.Fatalf("lowercase approve: sender=%q ok=%v err=%v", sender, ok, err)
}
// Blank input is a miss, not an error.
if _, _, ok, err := approvePairing(ctx, st, "acme", "telegram", " ", t0+1); err != nil || ok {
t.Fatalf("blank code: ok=%v err=%v", ok, err)
}
// `*` and "" are policy syntax, never identities — a poisoned pending row
// must not mint a grant.
for _, bad := range []struct{ sender, code string }{{"*", "WWWWWWWW"}, {"", "EEEEEEEE"}} {
if _, err := st.db.ExecContext(ctx, `INSERT INTO channel_pairing (org, channel, sender, code, created_at, last_seen)
VALUES ('acme','telegram',?,?,?,?)`, bad.sender, bad.code, t0, t0); err != nil {
t.Fatalf("seed %q: %v", bad.sender, err)
}
if _, _, ok, err := approvePairing(ctx, st, "acme", "telegram", bad.code, t0+1); err != nil || ok {
t.Fatalf("approve of sender %q: ok=%v err=%v, want refusal", bad.sender, ok, err)
}
}
_, paired, err := allowEntries(ctx, st, "acme", "telegram", "dm")
if err != nil {
t.Fatalf("allowEntries: %v", err)
}
for _, p := range paired {
if p == "*" || p == "" {
t.Fatalf("policy syntax %q minted as a grant", p)
}
}
}
func TestAccessGroups(t *testing.T) {
st := newStore(t)
ctx := context.Background()
err := putAccessGroups(ctx, st, "acme", map[string]map[string][]string{
"eng": {"telegram": {"7"}},
"ops": {"*": {"9"}}, // '*' = shared across channels
})
if err != nil {
t.Fatalf("putAccessGroups: %v", err)
}
if err := setPolicy(ctx, st, "acme", "telegram", policyRow{DM: DMAllowlist, Group: GroupOpen}, t0); err != nil {
t.Fatalf("setPolicy: %v", err)
}
if err := putAllow(ctx, st, "acme", "telegram", "dm", []string{"accessGroup:eng"}, t0); err != nil {
t.Fatalf("putAllow: %v", err)
}
if v, err := dmGate(ctx, st, "acme", "telegram", "7", true); err != nil || !v.Allow || v.Reason != dmAllowlisted {
t.Fatalf("group member = %+v err=%v", v, err)
}
if v, err := dmGate(ctx, st, "acme", "telegram", "8", true); err != nil || v.Allow {
t.Fatalf("non-member = %+v err=%v, want block", v, err)
}
// A '*'-channel group row matches from any channel.
if err := setPolicy(ctx, st, "acme", "slack", policyRow{DM: DMAllowlist, Group: GroupOpen}, t0); err != nil {
t.Fatalf("setPolicy: %v", err)
}
if err := putAllow(ctx, st, "acme", "slack", "dm", []string{"accessGroup:ops"}, t0); err != nil {
t.Fatalf("putAllow: %v", err)
}
if v, err := dmGate(ctx, st, "acme", "slack", "9", true); err != nil || !v.Allow {
t.Fatalf("shared-group member = %+v err=%v", v, err)
}
// An unknown group name grants nobody.
if err := putAllow(ctx, st, "acme", "slack", "dm", []string{"accessGroup:ghost"}, t0); err != nil {
t.Fatalf("putAllow: %v", err)
}
if v, err := dmGate(ctx, st, "acme", "slack", "9", true); err != nil || v.Allow {
t.Fatalf("ghost group = %+v err=%v, want block", v, err)
}
}
func TestGroupGate(t *testing.T) {
st := newStore(t)
ctx := context.Background()
// Absent policy row ⇒ the group default is open.
if v, err := groupGate(ctx, st, "acme", "slack", "u1"); err != nil || !v.Allow || v.Reason != groupOpen {
t.Fatalf("default = %+v err=%v", v, err)
}
if err := setPolicy(ctx, st, "acme", "slack", policyRow{DM: DMPairing, Group: GroupDisabled}, t0); err != nil {
t.Fatalf("setPolicy: %v", err)
}
if v, err := groupGate(ctx, st, "acme", "slack", "u1"); err != nil || v.Allow || v.Reason != groupDisabled {
t.Fatalf("disabled = %+v err=%v", v, err)
}
if err := setPolicy(ctx, st, "acme", "slack", policyRow{DM: DMPairing, Group: GroupAllowlist}, t0); err != nil {
t.Fatalf("setPolicy: %v", err)
}
if v, err := groupGate(ctx, st, "acme", "slack", "u1"); err != nil || v.Allow || v.Reason != groupEmptyAllowlist {
t.Fatalf("empty allowlist = %+v err=%v", v, err)
}
if err := putAllow(ctx, st, "acme", "slack", "group", []string{"u1"}, t0); err != nil {
t.Fatalf("putAllow: %v", err)
}
if v, err := groupGate(ctx, st, "acme", "slack", "u1"); err != nil || !v.Allow || v.Reason != groupAllowlisted {
t.Fatalf("allowlisted = %+v err=%v", v, err)
}
if v, err := groupGate(ctx, st, "acme", "slack", "u2"); err != nil || v.Allow || v.Reason != groupNotAllowlisted {
t.Fatalf("unlisted = %+v err=%v", v, err)
}
if err := putAllow(ctx, st, "acme", "slack", "group", []string{"*"}, t0); err != nil {
t.Fatalf("putAllow: %v", err)
}
if v, err := groupGate(ctx, st, "acme", "slack", "u2"); err != nil || !v.Allow {
t.Fatalf("wildcard = %+v err=%v", v, err)
}
}
func TestOrgIsolationStore(t *testing.T) {
st := newStore(t)
ctx := context.Background()
// Org A opens telegram wide; org B still gets the strict default.
if err := setPolicy(ctx, st, "org-a", "telegram", policyRow{DM: DMOpen, Group: GroupOpen}, t0); err != nil {
t.Fatalf("setPolicy: %v", err)
}
if err := putAllow(ctx, st, "org-a", "telegram", "dm", []string{"*"}, t0); err != nil {
t.Fatalf("putAllow: %v", err)
}
if v, err := dmGate(ctx, st, "org-a", "telegram", "42", true); err != nil || !v.Allow {
t.Fatalf("org-a gate = %+v err=%v", v, err)
}
if v, err := dmGate(ctx, st, "org-b", "telegram", "42", true); err != nil || v.Allow || !v.Pair {
t.Fatalf("org-b gate = %+v err=%v, want the pairing default", v, err)
}
// Pairing rows are org-scoped: invisible and unapprovable across orgs.
code := mustPair(t, st, "org-a", "slack", "s1", t0)
if rows, err := listPairing(ctx, st, "org-b", t0+1); err != nil || len(rows) != 0 {
t.Fatalf("org-b pending = %d rows err=%v, want none", len(rows), err)
}
if _, _, ok, err := approvePairing(ctx, st, "org-b", "slack", code, t0+1); err != nil || ok {
t.Fatalf("cross-org approve: ok=%v err=%v, want a miss", ok, err)
}
if rows, err := listPairing(ctx, st, "org-a", t0+1); err != nil || len(rows) != 1 {
t.Fatalf("org-a pending = %d rows err=%v, want the request intact", len(rows), err)
}
}
func TestStoreRetention(t *testing.T) {
st := newStore(t)
ctx := context.Background()
longText := strings.Repeat("x", inboxTextMax+100)
old := inboxRow{Org: "acme", Channel: "telegram", RoomID: "1", RoomKind: RoomDM, Sender: "s", Text: "old", EventKey: "e-old", CreatedAt: t0 - inboxKeepSec - 1}
young := inboxRow{Org: "acme", Channel: "telegram", RoomID: "1", RoomKind: RoomDM, Sender: "s", Text: longText, EventKey: "e-young", CreatedAt: t0 - 10}
for _, r := range []inboxRow{old, young} {
if err := st.insertInbox(ctx, r); err != nil {
t.Fatalf("insertInbox(%s): %v", r.EventKey, err)
}
}
if _, _, err := st.markSend(ctx, "acme", "telegram", "k-old", t0-sendKeepSec-1); err != nil {
t.Fatalf("markSend old: %v", err)
}
if _, _, err := st.markSend(ctx, "acme", "telegram", "k-young", t0-10); err != nil {
t.Fatalf("markSend young: %v", err)
}
if err := st.gc(ctx, t0); err != nil {
t.Fatalf("gc: %v", err)
}
rows, err := st.listInbox(ctx, "acme", 0, 0)
if err != nil || len(rows) != 1 || rows[0].EventKey != "e-young" {
t.Fatalf("inbox after gc = %+v err=%v, want only the young row", rows, err)
}
// Text is truncated at insert — flood damage is bounded (C1-F3).
if len(rows[0].Text) != inboxTextMax {
t.Fatalf("stored text length = %d, want the %d cap", len(rows[0].Text), inboxTextMax)
}
var n int
var key string
if err := st.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM channel_send WHERE org='acme'`).Scan(&n); err != nil || n != 1 {
t.Fatalf("send rows after gc = %d err=%v, want 1", n, err)
}
if err := st.db.QueryRowContext(ctx, `SELECT idempotency FROM channel_send WHERE org='acme'`).Scan(&key); err != nil || key != "k-young" {
t.Fatalf("surviving send key = %q err=%v, want k-young (48 h replay window)", key, err)
}
}
+48
View File
@@ -0,0 +1,48 @@
package channels
import (
"context"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/integrations"
)
// registry.go is the closed transport registry — exactly the four connected
// chat transports, enumerated in fixed alphabetical order so GET /v1/channels
// is deterministic.
// capabilities advertises what a transport renders natively. All four ship
// media:false / actions:false this pass — renderText (envelope.go) is the ONE
// downgrade path; native rendering is a named follow-up.
type capabilities struct {
DM bool `json:"dm"`
Group bool `json:"group"`
Thread bool `json:"thread"`
Media bool `json:"media"`
Actions bool `json:"actions"`
}
// transport is one chat transport. normalize turns an authenticated ingress
// event into the portable envelope (ok=false drops unclassifiable events).
// send delivers an outbound Message and owns the transport's org-verified
// target-binding check (chat bind / route row / per-org token), so no
// transport can be driven cross-tenant.
type transport struct {
id string
caps capabilities
normalize func(ev integrations.IngressEvent) (Message, bool)
send func(ctx context.Context, s *cloud.Service[state], org string, m Message) (Delivery, error)
}
// transports is the closed set; elements are package vars in their transport
// files. Fixed alphabetical order — the deterministic GET /v1/channels listing.
var transports = []transport{discordTransport, slackTransport, teamsTransport, telegramTransport}
func transportFor(id string) (transport, bool) {
for _, t := range transports {
if t.id == id {
return t, true
}
}
return transport{}, false
}
+469
View File
@@ -0,0 +1,469 @@
package channels
// routes.go — the /v1/channels HTTP surface. Every route is org-gated
// (principal.Org) and wrapped cloud.Terminal(cloud.Handle(...)): channels
// mounts after the commerce /v1 error-flattening filter, so Terminal writes
// the real 4xx in-band before that filter can rewrite it to 500 (service.go).
// Mutations (pairing approve, allowlist put) additionally require org admin.
// There are NO public routes here — platform webhooks stay in integrations.
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/integrations"
"github.com/hanzoai/cloud/clients/principal"
"github.com/zap-proto/zip"
)
// sendMaxBody bounds one POST /send body (attachments are URLs, not bytes).
const sendMaxBody = 1 << 20 // 1 MiB
// routes registers the /v1/channels surface. The :channel send route is LAST:
// zip matches in registration order, so the static paths above must win.
func routes(app *zip.App, s *cloud.Service[state]) {
g := app.Group("/v1/channels")
app.Get("/v1/channels", cloud.Terminal(cloud.Handle(s, list)))
g.Get("/inbox", cloud.Terminal(cloud.Handle(s, inbox)))
g.Get("/pairing", cloud.Terminal(cloud.Handle(s, pairingList)))
g.Post("/pairing/approve", cloud.Terminal(cloud.Handle(s, pairingApprove)))
g.Get("/allowlist", cloud.Terminal(cloud.Handle(s, allowlistGet)))
g.Put("/allowlist", cloud.Terminal(cloud.Handle(s, allowlistPut)))
g.Post("/:channel/send", cloud.Terminal(cloud.Handle(s, send)))
}
// ── JSON projections (camelCase, closed shapes) ──────────────────────────────
type channelView struct {
ID string `json:"id"`
Connected bool `json:"connected"`
Account string `json:"account"`
AccountLabel string `json:"accountLabel"`
Capabilities capabilities `json:"capabilities"`
DMPolicy DMPolicy `json:"dmPolicy"`
GroupPolicy GroupPolicy `json:"groupPolicy"`
PendingPairing int `json:"pendingPairing"`
}
type inboxView struct {
ID int64 `json:"id"`
Channel string `json:"channel"`
Account string `json:"account"`
RoomID string `json:"roomId"`
RoomKind string `json:"roomKind"`
Sender string `json:"sender"`
SenderUser string `json:"senderUser,omitempty"`
Text string `json:"text"`
ReplyTo string `json:"replyTo,omitempty"`
CreatedAt int64 `json:"createdAt"`
}
type pairingView struct {
Channel string `json:"channel"`
Sender string `json:"sender"`
Code string `json:"code"`
CreatedAt int64 `json:"createdAt"`
LastSeen int64 `json:"lastSeen"`
}
type allowlistView struct {
DMPolicy DMPolicy `json:"dmPolicy"`
GroupPolicy GroupPolicy `json:"groupPolicy"`
DM []string `json:"dm"`
Group []string `json:"group"`
Paired []string `json:"paired"`
AccessGroups map[string]map[string][]string `json:"accessGroups"`
}
// nonNil keeps list fields JSON arrays ([] not null) — integrations idiom.
func nonNil(v []string) []string {
if v == nil {
return []string{}
}
return v
}
// ── handlers ─────────────────────────────────────────────────────────────────
// list is GET /v1/channels — the deterministic transport listing (registry
// order) with the org's connection, policy, and pending-pairing facts.
func list(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := principal.Org(c)
if !ok {
return zip.ErrForbidden("a validated principal is required")
}
ctx := c.Context()
pending := map[string]int{}
rows, err := listPairing(ctx, s.State.store, org, time.Now().Unix())
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "pairing: %v", err)
}
for _, r := range rows {
pending[r.Channel]++
}
out := make([]channelView, 0, len(transports))
for _, tr := range transports {
conn, connected := integrations.ConnectionFor(org, tr.id)
// Absent row ⇒ defaults (policyFor); a read error leaves zero policy
// fields rather than failing the whole listing.
p, _ := policyFor(ctx, s.State.store, org, tr.id)
out = append(out, channelView{
ID: tr.id,
Connected: connected,
// C2-7: account is the id-shaped fact (lowercased external id),
// accountLabel the human label — never swapped, on any surface.
Account: strings.ToLower(conn.ExternalID),
AccountLabel: conn.AccountLabel,
Capabilities: tr.caps,
DMPolicy: p.DM,
GroupPolicy: p.Group,
PendingPairing: pending[tr.id],
})
}
return c.JSON(http.StatusOK, map[string]any{"channels": out})
}
// inbox is GET /v1/channels/inbox?since=&limit= — the org's stored inbound
// messages, oldest first; cursor is the last row id (or since when empty).
func inbox(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := principal.Org(c)
if !ok {
return zip.ErrForbidden("a validated principal is required")
}
var since int64
if q := c.Query("since"); q != "" {
v, err := strconv.ParseInt(q, 10, 64)
if err != nil {
return zip.ErrBadRequest("since must be an integer cursor")
}
since = v
}
var limit int
if q := c.Query("limit"); q != "" {
v, err := strconv.Atoi(q)
if err != nil {
return zip.ErrBadRequest("limit must be an integer")
}
limit = v
}
rows, err := s.State.store.listInbox(c.Context(), org, since, limit)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "inbox: %v", err)
}
msgs := make([]inboxView, 0, len(rows))
cursor := since
for _, r := range rows {
msgs = append(msgs, inboxView{
ID: r.ID,
Channel: r.Channel,
Account: r.Account,
RoomID: r.RoomID,
RoomKind: string(r.RoomKind),
Sender: r.Sender,
SenderUser: r.SenderUser,
Text: r.Text,
ReplyTo: r.ReplyTo,
CreatedAt: r.CreatedAt,
})
cursor = r.ID
}
return c.JSON(http.StatusOK, map[string]any{"messages": msgs, "cursor": cursor})
}
// pairingList is GET /v1/channels/pairing — the org's pending (unexpired)
// pairing requests. Codes are capability strings shown to org members for
// admin approval; they are returned here, never logged.
func pairingList(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := principal.Org(c)
if !ok {
return zip.ErrForbidden("a validated principal is required")
}
rows, err := listPairing(c.Context(), s.State.store, org, time.Now().Unix())
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "pairing: %v", err)
}
out := make([]pairingView, 0, len(rows))
for _, r := range rows {
out = append(out, pairingView{
Channel: r.Channel,
Sender: r.Sender,
Code: r.Code,
CreatedAt: r.CreatedAt,
LastSeen: r.LastSeen,
})
}
return c.JSON(http.StatusOK, map[string]any{"pending": out})
}
// pairingApprove is POST /v1/channels/pairing/approve — admin-gated; turns a
// pending code into a pairing-source allow entry (approvePairing owns the
// write and the one-time owner bootstrap).
func pairingApprove(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := principal.Org(c)
if !ok {
return zip.ErrForbidden("a validated principal is required")
}
if !(principal.IsSuperAdmin(c) || principal.IsOrgAdmin(c)) {
return zip.ErrForbidden("approving a pairing requires org admin")
}
var body struct {
Channel string `json:"channel"`
Code string `json:"code"`
}
if err := json.Unmarshal(c.Body(), &body); err != nil {
return zip.ErrBadRequest("invalid request body")
}
channel := strings.TrimSpace(body.Channel)
code := strings.TrimSpace(body.Code)
if channel == "" || code == "" {
return zip.ErrBadRequest("channel and code are required")
}
sender, ownerBoot, ok, err := approvePairing(c.Context(), s.State.store, org, channel, code, time.Now().Unix())
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "approve: %v", err)
}
if !ok {
return zip.ErrNotFound("unknown or expired code")
}
return c.JSON(http.StatusOK, map[string]any{"sender": sender, "ownerBootstrapped": ownerBoot})
}
// allowlistGet is GET /v1/channels/allowlist?channel=.
func allowlistGet(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := principal.Org(c)
if !ok {
return zip.ErrForbidden("a validated principal is required")
}
channel := strings.TrimSpace(c.Query("channel"))
if channel == "" {
return zip.ErrBadRequest("channel query parameter is required")
}
if _, ok := transportFor(channel); !ok {
return zip.ErrNotFound("unknown channel")
}
v, err := allowlistFor(c.Context(), s.State.store, org, channel)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "allowlist: %v", err)
}
return c.JSON(http.StatusOK, v)
}
// allowlistPut is PUT /v1/channels/allowlist — admin-gated; each body field is
// applied only when provided (nil slice / empty string = untouched), then the
// GET payload is echoed so both verbs return ONE shape.
func allowlistPut(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := principal.Org(c)
if !ok {
return zip.ErrForbidden("a validated principal is required")
}
if !(principal.IsSuperAdmin(c) || principal.IsOrgAdmin(c)) {
return zip.ErrForbidden("editing the allowlist requires org admin")
}
var body struct {
Channel string `json:"channel"`
DMPolicy string `json:"dmPolicy"`
GroupPolicy string `json:"groupPolicy"`
DM []string `json:"dm"`
Group []string `json:"group"`
AccessGroups map[string]map[string][]string `json:"accessGroups"`
}
if err := json.Unmarshal(c.Body(), &body); err != nil {
return zip.ErrBadRequest("invalid request body")
}
channel := strings.TrimSpace(body.Channel)
if channel == "" {
return zip.ErrBadRequest("channel is required")
}
if _, ok := transportFor(channel); !ok {
return zip.ErrNotFound("unknown channel")
}
dm, group := DMPolicy(body.DMPolicy), GroupPolicy(body.GroupPolicy)
switch dm {
case "", DMPairing, DMAllowlist, DMOpen:
default:
return zip.ErrBadRequest("dmPolicy must be pairing, allowlist, or open")
}
switch group {
case "", GroupOpen, GroupAllowlist, GroupDisabled:
default:
return zip.ErrBadRequest("groupPolicy must be open, allowlist, or disabled")
}
ctx := c.Context()
st := s.State.store
now := time.Now().Unix()
if dm != "" || group != "" {
p, err := policyFor(ctx, st, org, channel)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "policy: %v", err)
}
if dm != "" {
p.DM = dm
}
if group != "" {
p.Group = group
}
if err := setPolicy(ctx, st, org, channel, p, now); err != nil {
return zip.Errorf(http.StatusInternalServerError, "policy: %v", err)
}
}
// channel_allow two-writer split: this PUT owns ONLY config-source rows
// (putAllow); pairing-source rows belong to approvePairing — a policy edit
// can never revoke an approved pairing.
if body.DM != nil {
if err := putAllow(ctx, st, org, channel, "dm", body.DM, now); err != nil {
return zip.Errorf(http.StatusInternalServerError, "allowlist: %v", err)
}
}
if body.Group != nil {
if err := putAllow(ctx, st, org, channel, "group", body.Group, now); err != nil {
return zip.Errorf(http.StatusInternalServerError, "allowlist: %v", err)
}
}
if body.AccessGroups != nil {
if err := putAccessGroups(ctx, st, org, body.AccessGroups); err != nil {
return zip.Errorf(http.StatusInternalServerError, "access groups: %v", err)
}
}
v, err := allowlistFor(ctx, st, org, channel)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "allowlist: %v", err)
}
return c.JSON(http.StatusOK, v)
}
// allowlistFor builds the allowlist payload GET returns and PUT echoes.
func allowlistFor(ctx context.Context, st *store, org, channel string) (allowlistView, error) {
p, err := policyFor(ctx, st, org, channel)
if err != nil {
return allowlistView{}, err
}
// paired = pairing-source rows, minted only by approvePairing (dm scope —
// pairing is a DM concept); surfaced read-only so admins see who is paired.
dm, paired, err := allowEntries(ctx, st, org, channel, "dm")
if err != nil {
return allowlistView{}, err
}
group, _, err := allowEntries(ctx, st, org, channel, "group")
if err != nil {
return allowlistView{}, err
}
groups, err := listAccessGroups(ctx, st, org)
if err != nil {
return allowlistView{}, err
}
return allowlistView{
DMPolicy: p.DM,
GroupPolicy: p.Group,
DM: nonNil(dm),
Group: nonNil(group),
Paired: nonNil(paired),
AccessGroups: groups,
}, nil
}
// listAccessGroups reads the org's access groups (name → channel → entries) —
// the read mirror of putAccessGroups (policy.go), for the allowlist payload.
func listAccessGroups(ctx context.Context, st *store, org string) (map[string]map[string][]string, error) {
rows, err := st.db.QueryContext(ctx, `SELECT name, channel, entry FROM channel_access_group
WHERE org = ? ORDER BY name, channel, entry`, org)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
out := map[string]map[string][]string{}
for rows.Next() {
var name, channel, entry string
if err := rows.Scan(&name, &channel, &entry); err != nil {
return nil, err
}
if out[name] == nil {
out[name] = map[string][]string{}
}
out[name][channel] = append(out[name][channel], entry)
}
return out, rows.Err()
}
// send is POST /v1/channels/:channel/send — the ONE egress door. The body is
// the envelope's narrow outbound projection (C2-6): identity fields (sender,
// account, channel) are not decodable — DisallowUnknownFields rejects them
// loudly instead of silently dropping them.
func send(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := principal.Org(c)
if !ok {
return zip.ErrForbidden("a validated principal is required")
}
raw := c.Body()
if len(raw) > sendMaxBody {
return zip.ErrBadRequest("body exceeds 1 MiB")
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
var r SendRequest
if err := dec.Decode(&r); err != nil {
return zip.ErrBadRequest("invalid body: " + err.Error())
}
if err := r.validate(); err != nil {
return zip.ErrBadRequest(err.Error())
}
channel := strings.TrimSpace(c.Param("channel"))
tr, ok := transportFor(channel)
if !ok {
return zip.ErrNotFound("unknown channel")
}
m := Message{
Channel: channel,
Room: r.Room,
Text: r.Text,
Attachments: r.Attachments,
Actions: r.Actions,
ReplyTo: r.ReplyTo,
Idempotency: r.Idempotency,
}
ctx := c.Context()
st := s.State.store
if r.Idempotency != "" {
fresh, prior, err := st.markSend(ctx, org, channel, r.Idempotency, time.Now().Unix())
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "idempotency: %v", err)
}
if !fresh {
return c.JSON(http.StatusOK, prior)
}
}
d, err := tr.send(ctx, s, org, m)
if err != nil {
// C2-1: release the claimed key in the SAME error path so the caller
// can re-attempt; only a completed send replays a receipt.
if r.Idempotency != "" {
_ = st.unmarkSend(ctx, org, channel, r.Idempotency)
}
// The transports' typed refusals (errRoomNotBound telegram.go,
// errNoRoute discord.go) map to a status HERE, in one place: 403 —
// the room is not org-bound; 409 — no inbound-learned route yet.
switch {
case errors.Is(err, errRoomNotBound):
return zip.ErrForbidden("room is not bound to this org")
case errors.Is(err, errNoRoute):
return zip.ErrConflict("no inbound route for this room; the bot must be messaged there first")
}
// Door errors carry status/shape only — never tokens (SendSlack /
// SendDiscord contract, integrations/ingress.go).
return zip.Errorf(http.StatusBadGateway, "%s: %v", tr.id, err)
}
if r.Idempotency != "" {
// Best-effort: the message is delivered; a lost receipt only degrades
// a later replay to an empty Delivery (documented markSend tradeoff).
if err := st.finishSend(ctx, org, channel, r.Idempotency, d.MessageID); err != nil {
s.Log.Warn("channels: send receipt", "channel", channel, "err", err)
}
}
return c.JSON(http.StatusOK, d)
}
+373
View File
@@ -0,0 +1,373 @@
package channels
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// send_test.go proves the egress fan-out through the real HTTP surface using
// the ingest_test.go harness. Zero live network: all four doors are spies —
// Discord's real HTTP path is proven in clients/integrations/ingress_test.go
// (C2-4) — so what is under test here is the route surface, the C1-F1 target
// bindings, and the idempotency ledger.
func TestSendSlack(t *testing.T) {
e := newApp(t)
sl := spySlack(t)
res := req(t, e, http.MethodPost, "/v1/channels/slack/send", "acme",
map[string]any{"room": map[string]any{"id": "C1"}, "replyTo": "171.2", "text": "hi"})
if res.Code != http.StatusOK {
t.Fatalf("send: %d (%s)", res.Code, res.Body)
}
var d Delivery
decodeJSON(t, res.Body, &d)
if d.Timestamp <= 0 {
t.Fatalf("delivery = %+v, want a send timestamp", d)
}
if sl.count() != 1 {
t.Fatalf("door calls = %d, want 1", sl.count())
}
call := sl.call(t, 0)
// The caller's org rides to the door — SendSlack's per-org TokenFor IS the
// slack tenancy gate.
if call.org != "acme" || call.room != "C1" || call.replyTo != "171.2" || call.text != "hi" {
t.Fatalf("door call = %+v", call)
}
}
func TestSendDiscordRouteCapability(t *testing.T) {
e := newApp(t)
dc := spyDiscord(t)
ctx := context.Background()
st := e.store(t)
body := map[string]any{"room": map[string]any{"id": "999"}, "text": "x"}
// No inbound-learned route ⇒ 409, and the door is never consulted.
if r := req(t, e, http.MethodPost, "/v1/channels/discord/send", "acme", body); r.Code != http.StatusConflict {
t.Fatalf("routeless send: %d, want 409", r.Code)
}
if dc.count() != 0 {
t.Fatal("binding gate must precede the door")
}
if err := st.upsertRoute(ctx, "acme", "discord", "999", "", time.Now().Unix()); err != nil {
t.Fatalf("seed route: %v", err)
}
res := req(t, e, http.MethodPost, "/v1/channels/discord/send", "acme", body)
if res.Code != http.StatusOK {
t.Fatalf("send: %d (%s)", res.Code, res.Body)
}
var d Delivery
decodeJSON(t, res.Body, &d)
if d.MessageID != "m-1" || dc.count() != 1 {
t.Fatalf("delivery = %+v after %d door calls", d, dc.count())
}
// C1-F1 tenancy: another org holds no route for the same room.
if r := req(t, e, http.MethodPost, "/v1/channels/discord/send", "beta", body); r.Code != http.StatusConflict {
t.Fatalf("cross-org send: %d, want 409", r.Code)
}
if dc.count() != 1 {
t.Fatal("a foreign org must never reach the door")
}
}
func TestSendTeamsRouteCapability(t *testing.T) {
e := newApp(t)
tm := spyTeams(t)
ctx := context.Background()
st := e.store(t)
const conv = "19:x@thread.tacv2"
const root = "https://smba.example/amer/"
body := map[string]any{"room": map[string]any{"id": conv}, "text": "x"}
if r := req(t, e, http.MethodPost, "/v1/channels/teams/send", "acme", body); r.Code != http.StatusConflict {
t.Fatalf("routeless send: %d, want 409", r.Code)
}
if tm.count() != 0 {
t.Fatal("binding gate must precede the door")
}
if err := st.upsertRoute(ctx, "acme", "teams", conv, root, time.Now().Unix()); err != nil {
t.Fatalf("seed route: %v", err)
}
if r := req(t, e, http.MethodPost, "/v1/channels/teams/send", "acme", body); r.Code != http.StatusOK {
t.Fatalf("send: %d (%s)", r.Code, r.Body)
}
call := tm.call(t, 0)
if call.root != root || call.room != conv {
t.Fatalf("door call = %+v, want the learned serviceURL", call)
}
if r := req(t, e, http.MethodPost, "/v1/channels/teams/send", "beta", body); r.Code != http.StatusConflict {
t.Fatalf("cross-org send: %d, want 409", r.Code)
}
if tm.count() != 1 {
t.Fatal("a foreign org must never reach the door")
}
}
func TestSendTelegramBinding(t *testing.T) {
e := newApp(t)
tg := spyTelegram(t)
// The telegram bind lives in integrations (OrgForExternalID) and cannot be
// seeded from this package — unbound is exactly what an org that never
// onboarded telegram looks like, and it must 403 with the door untouched.
res := req(t, e, http.MethodPost, "/v1/channels/telegram/send", "acme",
map[string]any{"room": map[string]any{"id": "777"}, "text": "x"})
if res.Code != http.StatusForbidden {
t.Fatalf("unbound send: %d, want 403", res.Code)
}
if tg.count() != 0 {
t.Fatal("binding gate must precede the door")
}
// Unit-level: the typed refusal, and the gate ordering, are explicit.
s := mounted.Load()
if s == nil {
t.Fatal("channels not mounted")
}
_, err := telegramEgress(context.Background(), s, "acme", Message{Channel: "telegram", Room: Room{ID: "777"}, Text: "x"})
if !errors.Is(err, errRoomNotBound) {
t.Fatalf("err = %v, want errRoomNotBound", err)
}
if tg.count() != 0 {
t.Fatal("errRoomNotBound must fire before the door")
}
}
func TestSendAuthValidation(t *testing.T) {
e := newApp(t)
sl := spySlack(t)
ok := map[string]any{"room": map[string]any{"id": "C1"}, "text": "x"}
if r := req(t, e, http.MethodPost, "/v1/channels/slack/send", "", ok); r.Code != http.StatusForbidden {
t.Fatalf("anonymous send: %d, want 403", r.Code)
}
if r := req(t, e, http.MethodPost, "/v1/channels/bogus/send", "acme", ok); r.Code != http.StatusNotFound {
t.Fatalf("unknown channel: %d, want 404", r.Code)
}
bad := []map[string]any{
{"room": map[string]any{"id": ""}, "text": "x"}, // room required
{"room": map[string]any{"id": "C1"}}, // content required
{"room": map[string]any{"id": "C1"}, "text": "x", "actions": []map[string]any{{"kind": "menu"}}}, // closed action set
{"room": map[string]any{"id": "C1", "kind": "castle"}, "text": "x"}, // closed room kinds
// C2-6: identity fields are not decodable on the egress body — they are
// rejected loudly, never silently dropped.
{"room": map[string]any{"id": "C1"}, "text": "x", "sender": map[string]any{"externalId": "evil"}},
{"room": map[string]any{"id": "C1"}, "text": "x", "account": "spoof"},
{"room": map[string]any{"id": "C1"}, "text": "x", "channel": "slack"},
}
for i, b := range bad {
if r := req(t, e, http.MethodPost, "/v1/channels/slack/send", "acme", b); r.Code != http.StatusBadRequest {
t.Fatalf("bad body %d: %d, want 400 (%s)", i, r.Code, r.Body)
}
}
if sl.count() != 0 {
t.Fatalf("no rejected request may reach a door (%d calls)", sl.count())
}
}
func TestChannelsList(t *testing.T) {
e := newApp(t)
res := req(t, e, http.MethodGet, "/v1/channels", "acme", nil)
if res.Code != http.StatusOK {
t.Fatalf("list: %d (%s)", res.Code, res.Body)
}
var typed struct {
Channels []struct {
ID string `json:"id"`
Connected bool `json:"connected"`
DMPolicy string `json:"dmPolicy"`
GroupPolicy string `json:"groupPolicy"`
} `json:"channels"`
}
decodeJSON(t, res.Body, &typed)
want := []string{"discord", "slack", "teams", "telegram"}
if len(typed.Channels) != len(want) {
t.Fatalf("channels = %s, want the closed registry", res.Body)
}
for i, ch := range typed.Channels {
if ch.ID != want[i] {
t.Fatalf("channel[%d] = %q, want %q (fixed alphabetical order)", i, ch.ID, want[i])
}
if ch.Connected {
t.Fatalf("%s: connected must be false with integrations unmounted", ch.ID)
}
if ch.DMPolicy != string(DMPairing) || ch.GroupPolicy != string(GroupOpen) {
t.Fatalf("%s policy = %s/%s, want the pairing/open defaults", ch.ID, ch.DMPolicy, ch.GroupPolicy)
}
}
// C2-7: account (id-shaped fact) and accountLabel (human label) are both
// present on every entry — one meaning per field, on every surface.
var raw struct {
Channels []map[string]json.RawMessage `json:"channels"`
}
decodeJSON(t, res.Body, &raw)
for i, ch := range raw.Channels {
for _, key := range []string{"account", "accountLabel", "capabilities", "pendingPairing"} {
if _, ok := ch[key]; !ok {
t.Fatalf("channel[%d] missing %q", i, key)
}
}
}
if r := req(t, e, http.MethodGet, "/v1/channels", "", nil); r.Code != http.StatusForbidden {
t.Fatalf("anonymous list: %d, want 403", r.Code)
}
}
func TestSendIdempotency(t *testing.T) {
e := newApp(t)
dc := spyDiscord(t)
ctx := context.Background()
st := e.store(t)
if err := st.upsertRoute(ctx, "acme", "discord", "999", "", time.Now().Unix()); err != nil {
t.Fatalf("seed route: %v", err)
}
body := map[string]any{"room": map[string]any{"id": "999"}, "text": "x", "idempotency": "idem-1"}
res := req(t, e, http.MethodPost, "/v1/channels/discord/send", "acme", body)
if res.Code != http.StatusOK {
t.Fatalf("send: %d (%s)", res.Code, res.Body)
}
var first Delivery
decodeJSON(t, res.Body, &first)
if first.MessageID != "m-1" || dc.count() != 1 {
t.Fatalf("first = %+v after %d calls", first, dc.count())
}
// Same key replays the stored receipt without a second transport send.
res = req(t, e, http.MethodPost, "/v1/channels/discord/send", "acme", body)
if res.Code != http.StatusOK {
t.Fatalf("replay: %d (%s)", res.Code, res.Body)
}
var replay Delivery
decodeJSON(t, res.Body, &replay)
if replay.MessageID != "m-1" || replay.Timestamp <= 0 {
t.Fatalf("replay = %+v, want the stored receipt", replay)
}
if dc.count() != 1 {
t.Fatalf("door calls = %d; a replay must not re-send", dc.count())
}
// A different key is a different send.
body["idempotency"] = "idem-2"
if r := req(t, e, http.MethodPost, "/v1/channels/discord/send", "acme", body); r.Code != http.StatusOK {
t.Fatalf("second key: %d", r.Code)
}
if dc.count() != 2 {
t.Fatalf("door calls = %d, want 2", dc.count())
}
}
func TestSendRetryAfterFailure(t *testing.T) {
e := newApp(t)
dc := spyDiscord(t)
ctx := context.Background()
st := e.store(t)
if err := st.upsertRoute(ctx, "acme", "discord", "999", "", time.Now().Unix()); err != nil {
t.Fatalf("seed route: %v", err)
}
body := map[string]any{"room": map[string]any{"id": "999"}, "text": "x", "idempotency": "k-r"}
// C2-1: a transport failure releases the claimed key in the same error
// path — a failed send must not poison the retention window.
dc.setFail(errors.New("gateway sad"))
if r := req(t, e, http.MethodPost, "/v1/channels/discord/send", "acme", body); r.Code != http.StatusBadGateway {
t.Fatalf("failed send: %d, want 502 (%s)", r.Code, r.Body)
}
if dc.count() != 1 {
t.Fatalf("door calls = %d", dc.count())
}
var n int
if err := st.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM channel_send
WHERE org='acme' AND channel='discord' AND idempotency='k-r'`).Scan(&n); err != nil || n != 0 {
t.Fatalf("claimed key rows = %d err=%v, want released", n, err)
}
// The same key re-attempts and succeeds…
dc.setFail(nil)
if r := req(t, e, http.MethodPost, "/v1/channels/discord/send", "acme", body); r.Code != http.StatusOK {
t.Fatalf("retry: %d", r.Code)
}
if dc.count() != 2 {
t.Fatalf("door calls = %d, want the retry to re-send", dc.count())
}
// …and only the COMPLETED send replays.
if r := req(t, e, http.MethodPost, "/v1/channels/discord/send", "acme", body); r.Code != http.StatusOK {
t.Fatalf("replay: %d", r.Code)
}
if dc.count() != 2 {
t.Fatalf("door calls = %d; the completed send must replay", dc.count())
}
}
func TestSendNoSecretsAtRest(t *testing.T) {
// Plant a token in the transport env: channels must never read, store, or
// log it — custody stays in integrations, spies own the doors here.
const planted = "tok-discord-secret"
t.Setenv("DISCORD_BOT_TOKEN", planted)
e := newApp(t)
dc := spyDiscord(t)
sl := spySlack(t)
ctx := context.Background()
st := e.store(t)
if err := st.upsertRoute(ctx, "acme", "discord", "999", "", time.Now().Unix()); err != nil {
t.Fatalf("seed route: %v", err)
}
// Exercise the surfaces that persist state: a plain send, a failed
// idempotent send, its retry, and a replay.
if r := req(t, e, http.MethodPost, "/v1/channels/slack/send", "acme",
map[string]any{"room": map[string]any{"id": "C1"}, "text": "hi"}); r.Code != http.StatusOK {
t.Fatalf("slack send: %d", r.Code)
}
body := map[string]any{"room": map[string]any{"id": "999"}, "text": "x", "idempotency": "k-s"}
dc.setFail(errors.New("boom"))
if r := req(t, e, http.MethodPost, "/v1/channels/discord/send", "acme", body); r.Code != http.StatusBadGateway {
t.Fatalf("failed send: %d", r.Code)
}
dc.setFail(nil)
for range 2 {
if r := req(t, e, http.MethodPost, "/v1/channels/discord/send", "acme", body); r.Code != http.StatusOK {
t.Fatalf("send: %d", r.Code)
}
}
// Discord door: the failed attempt plus the retry; the final POST replays.
if sl.count() != 1 || dc.count() != 2 {
t.Fatalf("door calls slack=%d discord=%d, want 1/2", sl.count(), dc.count())
}
// The token bytes appear nowhere: not in any store file (channels.db plus
// its WAL sidecars), not in a log line.
entries, err := os.ReadDir(e.dataDir)
if err != nil {
t.Fatalf("ReadDir: %v", err)
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
data, err := os.ReadFile(filepath.Join(e.dataDir, entry.Name()))
if err != nil {
t.Fatalf("ReadFile %s: %v", entry.Name(), err)
}
if bytes.Contains(data, []byte(planted)) {
t.Fatalf("token bytes found in %s", entry.Name())
}
}
if strings.Contains(e.logs.String(), planted) {
t.Fatal("token bytes found in logs")
}
}
+60
View File
@@ -0,0 +1,60 @@
package channels
import (
"context"
"strings"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/integrations"
)
// slack.go is the Slack transport: envelope normalization from the ingress
// seam and egress through the ONE existing chat.postMessage path
// (integrations.SendSlack).
// slackDoor is the send door; tests spy it, prod never repoints.
var slackDoor = integrations.SendSlack
var slackTransport = transport{
id: "slack",
caps: capabilities{DM: true, Group: true, Thread: true},
normalize: slackNormalize,
send: slackEgress,
}
// slackNormalize maps a Slack Inbound (ExternalID = team id, DedupeKey =
// event_id) into the envelope. Slack conversation-id contract: D* = IM,
// C* = public channel, G* = private/mpim — a D-prefixed conversation is a DM;
// a threaded event (thread_ts set) is a thread; everything else is a group.
func slackNormalize(ev integrations.IngressEvent) (Message, bool) {
in := ev.In
kind := RoomGroup
switch {
case strings.HasPrefix(in.Channel, "D"):
kind = RoomDM
case in.ThreadID != "":
kind = RoomThread
}
return Message{
Channel: "slack",
Account: strings.ToLower(in.ExternalID),
Sender: Sender{ExternalID: in.User, Org: ev.Org},
Room: Room{ID: in.Channel, Kind: kind},
Text: in.Text,
ReplyTo: in.ThreadID,
Idempotency: in.DedupeKey,
}, true
}
// slackEgress posts via the org's OWN custodied bot token. Tenancy fails
// closed inside SendSlack via TokenFor(org, "slack"): no per-org token, no
// send — channels never sees a token, so no extra binding gate is needed.
func slackEgress(ctx context.Context, _ *cloud.Service[state], org string, m Message) (Delivery, error) {
if err := slackDoor(ctx, org, m.Room.ID, m.ReplyTo, renderText(m)); err != nil {
return Delivery{}, err
}
// chat.postMessage's ts is not surfaced by the existing helper — accepted
// tradeoff; the receipt carries the send time only.
return Delivery{MessageID: "", Timestamp: time.Now().Unix()}, nil
}
+301
View File
@@ -0,0 +1,301 @@
package channels
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/hanzoai/cloud/cek"
// github.com/hanzoai/sqlite is the ONE Hanzo SQLite driver (registers the
// "sqlite" database/sql name under both build tags). Blank import registers
// the driver — same as clients/integrations.
_ "github.com/hanzoai/sqlite"
)
const (
// inboxTextMax bounds one stored inbound text. Together with event-key
// dedupe, GC, and single-conn SQLite serialization it bounds flood damage
// under groupPolicy=open; a per-org ingest limiter is the named follow-up
// alongside agent delivery.
inboxTextMax = 8 << 10
// inboxKeepSec is inbox retention; gc drops older rows.
inboxKeepSec = 30 * 24 * 3600
// sendKeepSec is the documented idempotency replay window: a completed
// send replays its Delivery for 48 h, then the key is forgotten.
sendKeepSec = 48 * 3600
)
// store is the channels database. ONE SQLite file ({DataDir}/channels.db)
// holds every org's policy, pairing, allowlist, inbox, send-idempotency, and
// route rows; tenancy is the org column — org leads every PK. No secrets in
// any row (pairing codes are capability strings a sender must present; they
// are stored, never logged).
type store struct {
db *sql.DB
}
func openStore(path string) (*store, error) {
db, err := cek.Open(path)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
}
db.SetMaxOpenConns(1)
for _, pragma := range []string{
"PRAGMA busy_timeout=5000",
"PRAGMA journal_mode=WAL",
"PRAGMA foreign_keys=ON",
} {
if _, err := db.Exec(pragma); err != nil {
_ = db.Close()
return nil, fmt.Errorf("pragma %q: %w", pragma, err)
}
}
st := &store{db: db}
if err := st.migrate(); err != nil {
_ = db.Close()
return nil, err
}
return st, nil
}
func (st *store) migrate() error {
// No account dimension anywhere: integrations' connections PK is
// (org, provider), so (org, channel) IS the channel-account key — exactly
// one connected account per pair is representable in the custody plane.
//
// channel_allow has exactly two writers, one per source class: 'config'
// rows are written only by policy.go putAllow; 'pairing' rows only by
// pairing.go approvePairing. Keeping the classes disjoint is what lets
// policy edits never revoke an approved pairing and vice versa.
const ddl = `
CREATE TABLE IF NOT EXISTS channel_policy (
org TEXT NOT NULL,
channel TEXT NOT NULL,
dm_policy TEXT NOT NULL DEFAULT 'pairing' CHECK (dm_policy IN ('pairing','allowlist','open')),
group_policy TEXT NOT NULL DEFAULT 'open' CHECK (group_policy IN ('open','allowlist','disabled')),
updated_at INTEGER NOT NULL,
PRIMARY KEY (org, channel)
);
CREATE TABLE IF NOT EXISTS channel_pairing (
org TEXT NOT NULL,
channel TEXT NOT NULL,
sender TEXT NOT NULL,
code TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
PRIMARY KEY (org, channel, sender)
);
CREATE INDEX IF NOT EXISTS ix_pairing_code ON channel_pairing(org, channel, code);
CREATE TABLE IF NOT EXISTS channel_allow (
org TEXT NOT NULL,
channel TEXT NOT NULL,
scope TEXT NOT NULL CHECK (scope IN ('dm','group')),
entry TEXT NOT NULL,
source TEXT NOT NULL CHECK (source IN ('config','pairing')),
created_at INTEGER NOT NULL,
PRIMARY KEY (org, channel, scope, entry)
);
CREATE TABLE IF NOT EXISTS channel_access_group (
org TEXT NOT NULL,
name TEXT NOT NULL,
channel TEXT NOT NULL, -- '*' = shared across channels
entry TEXT NOT NULL,
PRIMARY KEY (org, name, channel, entry)
);
CREATE TABLE IF NOT EXISTS channel_owner (
org TEXT NOT NULL PRIMARY KEY,
entry TEXT NOT NULL, -- '<channel>:<sender>', set on first pairing approval only
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS channel_inbox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
org TEXT NOT NULL,
channel TEXT NOT NULL,
account TEXT NOT NULL DEFAULT '',
room_id TEXT NOT NULL,
room_kind TEXT NOT NULL CHECK (room_kind IN ('dm','group','thread')),
sender TEXT NOT NULL,
sender_user TEXT NOT NULL DEFAULT '',
text TEXT NOT NULL,
reply_to TEXT NOT NULL DEFAULT '',
event_key TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS ix_inbox_org ON channel_inbox(org, id);
CREATE UNIQUE INDEX IF NOT EXISTS ux_inbox_event ON channel_inbox(org, channel, event_key) WHERE event_key != '';
CREATE TABLE IF NOT EXISTS channel_send (
org TEXT NOT NULL,
channel TEXT NOT NULL,
idempotency TEXT NOT NULL,
message_id TEXT NOT NULL DEFAULT '',
ts INTEGER NOT NULL,
PRIMARY KEY (org, channel, idempotency)
);
-- Route presence is the send capability for global-token transports: a row is
-- upserted ONLY from allowed inbound, so an org can drive only rooms it was
-- messaged from. reply_root='' for discord; teams stores the JWT-verified
-- serviceURL.
CREATE TABLE IF NOT EXISTS channel_route (
org TEXT NOT NULL,
channel TEXT NOT NULL,
room_id TEXT NOT NULL,
reply_root TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (org, channel, room_id)
);
`
_, err := st.db.Exec(ddl)
return err
}
func (st *store) Close() error { return st.db.Close() }
// ── inbox ────────────────────────────────────────────────────────────────────
// inboxRow is one stored inbound message. CreatedAt is Unix seconds.
type inboxRow struct {
ID int64
Org string
Channel string
Account string
RoomID string
RoomKind RoomKind
Sender string
SenderUser string
Text string
ReplyTo string
EventKey string
CreatedAt int64
}
// insertInbox stores one allowed inbound message. INSERT OR IGNORE rides
// ux_inbox_event, so a redelivered event key is a no-op; event_key=""
// (non-dedupable) always inserts.
func (st *store) insertInbox(ctx context.Context, r inboxRow) error {
if len(r.Text) > inboxTextMax {
r.Text = r.Text[:inboxTextMax]
}
_, err := st.db.ExecContext(ctx, `INSERT OR IGNORE INTO channel_inbox
(org, channel, account, room_id, room_kind, sender, sender_user, text, reply_to, event_key, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
r.Org, r.Channel, r.Account, r.RoomID, string(r.RoomKind),
r.Sender, r.SenderUser, r.Text, r.ReplyTo, r.EventKey, r.CreatedAt)
return err
}
// listInbox returns the org's inbox rows with id > since, oldest first. limit
// clamps to 1..200; <=0 selects the default 50.
func (st *store) listInbox(ctx context.Context, org string, since int64, limit int) ([]inboxRow, error) {
switch {
case limit <= 0:
limit = 50
case limit > 200:
limit = 200
}
rows, err := st.db.QueryContext(ctx, `SELECT id, org, channel, account, room_id, room_kind,
sender, sender_user, text, reply_to, event_key, created_at
FROM channel_inbox WHERE org = ? AND id > ? ORDER BY id LIMIT ?`, org, since, limit)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
out := []inboxRow{}
for rows.Next() {
var r inboxRow
var kind string
if err := rows.Scan(&r.ID, &r.Org, &r.Channel, &r.Account, &r.RoomID, &kind,
&r.Sender, &r.SenderUser, &r.Text, &r.ReplyTo, &r.EventKey, &r.CreatedAt); err != nil {
return nil, err
}
r.RoomKind = RoomKind(kind)
out = append(out, r)
}
return out, rows.Err()
}
// ── send idempotency ─────────────────────────────────────────────────────────
// markSend claims an idempotency key. fresh=true ⇒ the caller owns the send;
// fresh=false ⇒ the key was already claimed and prior holds the stored receipt.
// A concurrent duplicate racing an in-flight send may replay an empty Delivery
// once (message_id not yet finished, or the row unmarked between statements) —
// accepted tradeoff; only a completed send replays a real receipt.
func (st *store) markSend(ctx context.Context, org, channel, idem string, now int64) (fresh bool, prior Delivery, err error) {
res, err := st.db.ExecContext(ctx, `INSERT INTO channel_send (org, channel, idempotency, message_id, ts)
VALUES (?,?,?,'',?) ON CONFLICT (org, channel, idempotency) DO NOTHING`, org, channel, idem, now)
if err != nil {
return false, Delivery{}, err
}
if n, _ := res.RowsAffected(); n == 1 {
return true, Delivery{}, nil
}
err = st.db.QueryRowContext(ctx, `SELECT message_id, ts FROM channel_send
WHERE org = ? AND channel = ? AND idempotency = ?`, org, channel, idem).Scan(&prior.MessageID, &prior.Timestamp)
if errors.Is(err, sql.ErrNoRows) {
return false, Delivery{}, nil
}
return false, prior, err
}
// unmarkSend releases a claimed key after a transport-send failure so the key
// can re-attempt; without it a failed send would replay an empty receipt for
// the whole retention window.
func (st *store) unmarkSend(ctx context.Context, org, channel, idem string) error {
_, err := st.db.ExecContext(ctx, `DELETE FROM channel_send
WHERE org = ? AND channel = ? AND idempotency = ?`, org, channel, idem)
return err
}
// finishSend records the transport receipt on a claimed key.
func (st *store) finishSend(ctx context.Context, org, channel, idem, messageID string) error {
_, err := st.db.ExecContext(ctx, `UPDATE channel_send SET message_id = ?
WHERE org = ? AND channel = ? AND idempotency = ?`, messageID, org, channel, idem)
return err
}
// ── routes (inbound-learned reply targets) ───────────────────────────────────
// upsertRoute records an allowed inbound room as a send target. Called only on
// the allow and pair branches — a blocked sender mints no route.
func (st *store) upsertRoute(ctx context.Context, org, channel, roomID, replyRoot string, now int64) error {
_, err := st.db.ExecContext(ctx, `INSERT INTO channel_route (org, channel, room_id, reply_root, updated_at)
VALUES (?,?,?,?,?)
ON CONFLICT (org, channel, room_id) DO UPDATE SET reply_root = excluded.reply_root, updated_at = excluded.updated_at`,
org, channel, roomID, replyRoot, now)
return err
}
// routeFor returns the stored reply root for a room; ok=false when the org has
// never received allowed inbound from it.
func (st *store) routeFor(ctx context.Context, org, channel, roomID string) (string, bool, error) {
var root string
err := st.db.QueryRowContext(ctx, `SELECT reply_root FROM channel_route
WHERE org = ? AND channel = ? AND room_id = ?`, org, channel, roomID).Scan(&root)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, err
}
return root, true, nil
}
// ── retention ────────────────────────────────────────────────────────────────
// gc drops inbox rows past retention and send keys past the idempotency replay
// window. Ridden opportunistically from ingest (bounded to once per 10 min).
func (st *store) gc(ctx context.Context, now int64) error {
if _, err := st.db.ExecContext(ctx, `DELETE FROM channel_inbox WHERE created_at < ?`, now-inboxKeepSec); err != nil {
return err
}
_, err := st.db.ExecContext(ctx, `DELETE FROM channel_send WHERE ts < ?`, now-sendKeepSec)
return err
}
+66
View File
@@ -0,0 +1,66 @@
package channels
import (
"context"
"strings"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/integrations"
)
// teams.go is the Teams transport: envelope normalization from the ingress
// seam and egress through the ONE existing Bot Connector send path
// (integrations.SendTeams).
// teamsDoor is the send door; tests spy it, prod never repoints.
var teamsDoor = integrations.SendTeams
var teamsTransport = transport{
id: "teams",
caps: capabilities{DM: true, Group: true},
normalize: teamsNormalize,
send: teamsEgress,
}
// teamsNormalize maps a Teams Inbound (ExternalID = AAD tenant id, User =
// aadObjectId or from.id, Channel = conversation id, no ThreadID) into the
// envelope. Bot Framework contract: channel/group-chat conversation ids are
// 19:...@thread.*; personal chats are a:.... Unknown shapes classify DM —
// the fail-safe direction, since dmPolicy defaults to pairing (strictest).
func teamsNormalize(ev integrations.IngressEvent) (Message, bool) {
in := ev.In
kind := RoomDM
if strings.HasPrefix(in.Channel, "19:") {
kind = RoomGroup
}
return Message{
Channel: "teams",
Account: strings.ToLower(in.ExternalID),
Sender: Sender{ExternalID: in.User, Org: ev.Org},
Room: Room{ID: in.Channel, Kind: kind},
Text: in.Text,
Idempotency: in.DedupeKey,
}, true
}
// teamsEgress sends via the Bot Connector at the stored reply root. The
// serviceURL is learned ONLY from JWT-verified inbound (IngressEvent.
// ReplyRoot); nothing else may mint it — that is both the security invariant
// (no attacker-chosen serviceURL) and the tenancy gate (an org can drive only
// conversations it was messaged from).
func teamsEgress(ctx context.Context, s *cloud.Service[state], org string, m Message) (Delivery, error) {
root, ok, err := s.State.store.routeFor(ctx, org, "teams", m.Room.ID)
if err != nil {
return Delivery{}, err
}
if !ok || root == "" {
return Delivery{}, errNoRoute
}
if err := teamsDoor(ctx, root, m.Room.ID, renderText(m)); err != nil {
return Delivery{}, err
}
// The Bot Connector activity id is not surfaced by the existing helper —
// accepted tradeoff; the receipt carries the send time only.
return Delivery{MessageID: "", Timestamp: time.Now().Unix()}, nil
}
+91
View File
@@ -0,0 +1,91 @@
package channels
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/integrations"
)
// telegram.go is the Telegram transport: envelope normalization from the
// ingress seam and egress through the ONE existing Bot API send path
// (integrations.SendTelegram).
// errRoomNotBound rejects egress to a room the caller's org has no verified
// binding for. routes.go maps it to 403 — the send is authenticated but the
// org holds no capability over that room.
var errRoomNotBound = errors.New("channels: room not bound to org")
// telegramDoor is the send door; tests spy it, prod never repoints.
var telegramDoor = integrations.SendTelegram
var telegramTransport = transport{
id: "telegram",
caps: capabilities{DM: true, Group: true},
normalize: telegramNormalize,
send: telegramEgress,
}
// telegramNormalize maps a Telegram Inbound (ExternalID = Channel = decimal
// chat id, User = from.id, ThreadID = triggering message id, DedupeKey =
// update_id) into the envelope. Telegram's ThreadID is the message to reply
// under, so it maps to ReplyTo, never RoomThread.
func telegramNormalize(ev integrations.IngressEvent) (Message, bool) {
in := ev.In
kind, ok := telegramRoomKind(in.Channel)
if !ok {
return Message{}, false
}
return Message{
Channel: "telegram",
Account: strings.ToLower(in.ExternalID),
Sender: Sender{ExternalID: in.User, Org: ev.Org},
Room: Room{ID: in.Channel, Kind: kind},
Text: in.Text,
ReplyTo: in.ThreadID,
Idempotency: in.DedupeKey,
}, true
}
// telegramRoomKind classifies a chat id. Bot API contract: group/supergroup
// chat ids are negative, private-chat ids positive. Unparseable (or zero)
// ids are unclassifiable — the event is dropped rather than guessed.
func telegramRoomKind(chatID string) (RoomKind, bool) {
id, err := strconv.ParseInt(chatID, 10, 64)
if err != nil || id == 0 {
return "", false
}
if id < 0 {
return RoomGroup, true
}
return RoomDM, true
}
// telegramEgress sends via the shared bot after the tenancy gate: the
// chat→org bind is the isolation root — the global bot token never fires for
// an unbound or foreign chat. This is also the connected-check: an org that
// never onboarded Telegram has no bind and fails closed.
func telegramEgress(ctx context.Context, _ *cloud.Service[state], org string, m Message) (Delivery, error) {
boundOrg, ok := integrations.OrgForExternalID("telegram", m.Room.ID)
if !ok || boundOrg != org {
return Delivery{}, errRoomNotBound
}
chatID, err := strconv.ParseInt(m.Room.ID, 10, 64)
if err != nil {
return Delivery{}, fmt.Errorf("telegram: invalid room id %q", m.Room.ID)
}
// Best-effort reply threading: an unparseable ReplyTo degrades to a
// top-level send rather than failing the message.
replyTo, _ := strconv.ParseInt(m.ReplyTo, 10, 64)
if err := telegramDoor(ctx, chatID, replyTo, renderText(m)); err != nil {
return Delivery{}, err
}
// sendMessage's message id is not surfaced by the existing helper —
// accepted tradeoff; the receipt carries the send time only.
return Delivery{MessageID: "", Timestamp: time.Now().Unix()}, nil
}
+25 -24
View File
@@ -111,37 +111,38 @@ func Mount(app *zip.App, deps cloud.Deps) error {
// 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]) {
g := app.Group("/v1/integrations/cloudflare")
// 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))
g.Get("/pages/projects", cloud.Handle(s, pagesList))
g.Post("/pages/projects", cloud.Handle(s, pagesCreate))
g.Get("/pages/projects/:project", cloud.Handle(s, pagesGet))
g.Delete("/pages/projects/:project", cloud.Handle(s, pagesDelete))
g.Post("/pages/projects/:project/deployments", cloud.Handle(s, pagesDeploy))
g.Post("/pages/projects/:project/domains", cloud.Handle(s, pagesDomainAdd))
g.Delete("/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))
g.Get("/workers/scripts", cloud.Handle(s, workersScriptList))
g.Put("/workers/scripts/:script", cloud.Handle(s, workersScriptPut))
g.Delete("/workers/scripts/:script", cloud.Handle(s, workersScriptDelete))
g.Post("/workers/scripts/:script/subdomain", cloud.Handle(s, workersScriptSubdomainSet))
g.Get("/workers/subdomain", cloud.Handle(s, workersSubdomainGet))
g.Get("/workers/zones/:zone/routes", cloud.Handle(s, workersRouteList))
g.Post("/workers/zones/:zone/routes", cloud.Handle(s, workersRouteCreate))
g.Delete("/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))
g.Get("/r2/buckets", cloud.Handle(s, r2BucketList))
g.Post("/r2/buckets", cloud.Handle(s, r2BucketCreate))
g.Delete("/r2/buckets/:bucket", cloud.Handle(s, r2BucketDelete))
g.Get("/kv/namespaces", cloud.Handle(s, kvNamespaceList))
g.Post("/kv/namespaces", cloud.Handle(s, kvNamespaceCreate))
g.Delete("/kv/namespaces/:namespace", cloud.Handle(s, kvNamespaceDelete))
g.Get("/d1/databases", cloud.Handle(s, d1DatabaseList))
g.Post("/d1/databases", cloud.Handle(s, d1DatabaseCreate))
g.Delete("/d1/databases/:database", cloud.Handle(s, d1DatabaseDelete))
}
// ── client (the cfDo shape, reused verbatim from hanzodns) ──────────────────────
+10 -9
View File
@@ -87,22 +87,23 @@ func Mount(app *zip.App, deps cloud.Deps) error {
}
s := &service{
dataDir: deps.DataDir,
embed: newEmbedder(deps.AI, ""),
synth: newSynth(deps.AI, deps.AIDefaultModel),
embed: newEmbedder(deps.Embed, ""), // embeddings ride the read-only (pk-) embed credential
synth: newSynth(deps.AI, deps.AIDefaultModel), // synthesis is chat completion → M2M
log: deps.Logger.New("subsystem", "code"),
stores: cloud.NewOrgStore(deps.DataDir, "code", openStore),
}
mounted = s
app.Get("/v1/code/search", s.handleSearch)
app.Post("/v1/code/context", s.handleContext)
app.Get("/v1/code/ask", s.handleAsk)
app.Post("/v1/code/ask", s.handleAsk)
app.Post("/v1/code/index", s.handleIndex)
g := app.Group("/v1/code")
g.Get("/search", s.handleSearch)
g.Post("/context", s.handleContext)
g.Get("/ask", s.handleAsk)
g.Post("/ask", s.handleAsk)
g.Post("/index", s.handleIndex)
// Repo-inspection primitives (the zread contract over the org's own index):
// tree = get_repo_structure, file = read_file.
app.Get("/v1/code/tree", s.handleTree)
app.Get("/v1/code/file", s.handleFile)
g.Get("/tree", s.handleTree)
g.Get("/file", s.handleFile)
s.log.Info("code surface mounted (native)",
"brand", deps.Brand, "semantic", s.embed.Enabled(), "synth", s.synth.Enabled())
+8 -1
View File
@@ -24,7 +24,7 @@ func NewDispatcher(
verifyRef func(ctx context.Context, org, repo, branch string) (string, bool),
log func(msg string, kv ...any),
) Dispatcher {
return Dispatcher{
d := Dispatcher{
Sessions: sessionAdapter{},
Tracker: trackerAdapter{},
Runner: runner{},
@@ -37,6 +37,13 @@ func NewDispatcher(
Route: enqueueRoutedRun,
TargetGate: agents.TargetDispatchable,
}
// #48 completion parity: bind the routed completion seam to THIS dispatcher's
// git/tracker/session seams, so the durable delivery activity verifies the
// pushed ref, files the PR, and closes the session exactly as the local path
// does. The two git functions resolve their state at call time, so binding here
// (init, before any run) is safe.
setRoutedFinalizer(d.finalizeRoutedDurable)
return d
}
// sessionAdapter forwards to the agents in-process session API (inproc.go).
+104 -20
View File
@@ -148,6 +148,12 @@ type RoutedRun struct {
Prompt string
CloneURL string
TimeoutSeconds int
// Actor + AgentRef are the dispatching user + agent label, carried so the durable
// completion path can attribute the session close and file the PR with the same
// assignee the local path uses. Neither is a secret and neither crosses to the
// machine (the durable view the machine claims omits them).
Actor string
AgentRef string
}
// Result is the terminal outcome the trigger surface renders.
@@ -309,44 +315,121 @@ func (d Dispatcher) Run(ctx context.Context, req Req) Result {
return res
}
// 4. Independently confirm the branch LANDED in native git (integrity: trust
// the branch tips we can read, not the runner's self-report). When the verify
// seam is wired and the ref is absent, fail closed.
// 4+5. Verify the branch landed and file the PR — the SAME completion a routed
// run's terminal report runs (completeChanged), so cloud-side integrity + the PR
// row are identical whether the run executed in the sandbox or on a machine.
return d.completeChanged(term, completion{
org: org, repo: repo, project: strings.TrimSpace(req.Project), base: req.Base,
prompt: prompt, sessionID: sessionID, branch: branch, actor: actor, agentRef: agentRef,
diffstat: runRes.Diffstat, logTail: runRes.LogTail,
}, res)
}
// completion bundles the run context the shared changed-run completion needs, so the
// local sandbox path and the routed path hand it the same values.
type completion struct {
org, repo, project, base string
prompt, sessionID, branch string
actor, agentRef string
diffstat, logTail string
}
// completeChanged is the shared terminal for a run that reported CHANGES: confirm the
// pushed branch LANDED in native git (integrity — trust the tips we can read, not a
// self-report), open the native PR work item, mirror the done status, and close the
// session done. Fail-closed: when the verify seam is wired and the ref is absent, the
// session closes ERROR and NO PR is filed. A tracker failure is recorded but does not
// fail the run (the branch is pushed + verified). ctx is the cancel-immune terminal
// context. Used by the local path (Run) and the routed completion (finalizeRouted).
func (d Dispatcher) completeChanged(ctx context.Context, c completion, res Result) Result {
if d.VerifyRef != nil {
sha, ok := d.VerifyRef(ctx, org, repo, branch)
sha, ok := d.VerifyRef(ctx, c.org, c.repo, c.branch)
if !ok {
return d.fail(term, org, sessionID, actor, res,
"pushed branch "+branch+" was not found in native git", runRes.LogTail)
return d.fail(ctx, c.org, c.sessionID, c.actor, res,
"pushed branch "+c.branch+" was not found in native git", c.logTail)
}
res.Verified = true
if sha != "" {
res.CommitSha = sha // authoritative tip from our own storage
}
}
// 5. Open the native PR work item (Kind:pr, Source:agent). A tracker failure
// does NOT fail the run — the branch is pushed and verified; the PR row is a
// side-effect — but it is recorded.
pr, perr := d.Tracker.CreatePR(term, PRInput{
Org: org, Project: strings.TrimSpace(req.Project), Repo: repo,
Base: baseOr(req.Base), Head: branch, Title: codingTitle(repo, prompt),
Body: prBody(prompt, req.Base, branch, res.CommitSha, runRes.Diffstat, sessionID), Assignee: agentRef,
pr, perr := d.Tracker.CreatePR(ctx, PRInput{
Org: c.org, Project: strings.TrimSpace(c.project), Repo: c.repo,
Base: baseOr(c.base), Head: c.branch, Title: codingTitle(c.repo, c.prompt),
Body: prBody(c.prompt, c.base, c.branch, res.CommitSha, c.diffstat, c.sessionID), Assignee: c.agentRef,
})
if perr != nil {
d.logf("coding: tracker PR create failed", "org", org, "repo", repo, "err", perr)
d.mirror(term, org, sessionID, actor, kindLog, map[string]any{"message": "tracker PR not created: " + perr.Error()})
d.logf("coding: tracker PR create failed", "org", c.org, "repo", c.repo, "err", perr)
d.mirror(ctx, c.org, c.sessionID, c.actor, kindLog, map[string]any{"message": "tracker PR not created: " + perr.Error()})
} else {
res.PR = pr
}
d.mirror(term, org, sessionID, actor, kindStatus, map[string]any{
"status": "done", "changed": true, "branch": branch, "commit": res.CommitSha, "pr": pr.Identifier,
d.mirror(ctx, c.org, c.sessionID, c.actor, kindStatus, map[string]any{
"status": "done", "changed": true, "branch": c.branch, "commit": res.CommitSha, "pr": pr.Identifier,
})
_ = d.Sessions.Close(term, org, sessionID, statusDone)
_ = d.Sessions.Close(ctx, c.org, c.sessionID, statusDone)
res.OK = true
return res
}
// finalizeRouted is the CLOUD-SIDE completion for a routed run whose machine reported
// a terminal result. The machine pushed with its OWN credential and streamed into the
// session; cloud still owns the integrity gate + the PR row + the session's terminal
// state (the machine never closes the session), exactly as the local keystone path
// does after a sandbox push. No secret crosses — cloud only reads the ref it can see.
//
// - reported failure -> session closed ERROR (no PR).
// - reported no changes -> session closed DONE (no PR).
// - reported a changed push -> completeChanged: VerifyRef the branch LANDED (fail
// closed to a session ERROR + no PR if absent), file
// the native PR, close DONE — the shared path.
//
// Best-effort + cancel-immune: it runs on its own terminal context so a run near its
// deadline still transitions out of "running". It is invoked from the durable delivery
// activity once, after the report is in hand, so it never re-executes the run.
func (d Dispatcher) finalizeRouted(ctx context.Context, in RoutedRun, res RoutedResult) {
if d.Sessions == nil {
return
}
out := Result{SessionID: in.SessionID, Repo: in.Repo, Routed: true, TargetID: in.TargetID}
if !res.OK {
d.fail(ctx, in.Org, in.SessionID, in.Actor, out, nonEmpty(res.Error, "the routed run reported failure"), "")
return
}
if !res.Changed {
d.mirror(ctx, in.Org, in.SessionID, in.Actor, kindStatus, map[string]any{"status": "done", "changed": false})
_ = d.Sessions.Close(ctx, in.Org, in.SessionID, statusDone)
return
}
branch := strings.TrimSpace(res.Branch)
if branch == "" {
branch = in.Branch
}
out.Branch = branch
out.CommitSha = res.CommitSha
out.Changed = true
agentRef := strings.TrimSpace(in.AgentRef)
if agentRef == "" {
agentRef = "hanzo"
}
_ = d.completeChanged(ctx, completion{
org: in.Org, repo: in.Repo, project: in.Project, base: in.Base,
prompt: in.Prompt, sessionID: in.SessionID, branch: branch, actor: in.Actor, agentRef: agentRef,
diffstat: res.Diffstat, logTail: "",
}, out)
}
// RoutedResult mirrors agents.RoutedResult so coding.go stays free of an agents import
// on the completion path (the adapter bridges). It is the terminal a machine reports.
type RoutedResult struct {
OK bool
Changed bool
Branch string
CommitSha string
Diffstat string
Error string
}
// 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
@@ -407,6 +490,7 @@ func (d Dispatcher) routed(ctx context.Context, req Req, org, repo, prompt strin
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),
Actor: actor, AgentRef: agentRef,
}
// Enqueue on the durable engine. A failure fails the run closed (session
// error) rather than leaving a zombie "running" session or running locally.
+45
View File
@@ -81,11 +81,24 @@ func RoutedRunWorkflow(ctx workflow.Context, in agents.RoutedRun) (agents.Routed
return res, err
}
// routedFinalizeTimeout bounds the cloud-side completion (verify ref + file PR +
// close session) that runs once the machine reports. Generous for a couple of local
// reads + writes, but finite so a wedged seam can never hold the activity open.
const routedFinalizeTimeout = 60 * time.Second
// 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.
//
// COMPLETION PARITY (#48): once the report is in hand, it runs the cloud-side
// completion (routedFinalizer) — VerifyRef the pushed branch landed, file the native
// PR, and CLOSE THE SESSION (the machine never closes it) — the SAME steps the local
// keystone path runs after a sandbox push. It runs on a cancel-immune, bounded
// context so a run near its deadline still transitions to terminal, and only AFTER a
// real report (never on the re-offer/timeout path), so a completed run is never
// re-executed by a retry.
func DeliverRoutedRunActivity(ctx context.Context, in agents.RoutedRun) (agents.RoutedResult, error) {
ctx, cancel := context.WithTimeout(ctx, routedStartToClose(in.TimeoutSeconds))
defer cancel()
@@ -95,9 +108,40 @@ func DeliverRoutedRunActivity(ctx context.Context, in agents.RoutedRun) (agents.
if !ok {
return agents.RoutedResult{}, fmt.Errorf("routed run %s was not completed before its deadline", in.SessionID)
}
if routedFinalizer != nil {
fctx, fcancel := context.WithTimeout(context.WithoutCancel(ctx), routedFinalizeTimeout)
routedFinalizer(fctx, in, res)
fcancel()
}
return res, nil
}
// routedFinalizer is the completion seam the delivery activity runs when a routed run
// reports terminal: verify the pushed ref, file the PR, and close the session. It is
// injected once at the composition root (NewDispatcher binds it to THIS dispatcher's
// git/tracker/session seams), so the free-function activity reaches those seams
// without coding holding global Dispatcher state — the same injected-seam shape
// index_on_push uses. Nil (unwired, e.g. a direct-Dispatcher unit test that fakes the
// Route seam) simply skips the cloud-side completion.
var routedFinalizer func(ctx context.Context, in agents.RoutedRun, res agents.RoutedResult)
func setRoutedFinalizer(fn func(ctx context.Context, in agents.RoutedRun, res agents.RoutedResult)) {
routedFinalizer = fn
}
// finalizeRoutedDurable adapts the durable agents types to coding's and runs the
// cloud-side completion. It is what NewDispatcher binds as the routedFinalizer seam.
func (d Dispatcher) finalizeRoutedDurable(ctx context.Context, in agents.RoutedRun, res agents.RoutedResult) {
d.finalizeRouted(ctx, RoutedRun{
Org: in.Org, TargetID: in.TargetID, SessionID: in.SessionID, Repo: in.Repo,
Project: in.Project, Base: in.Base, Branch: in.Branch, Prompt: in.Prompt,
Actor: in.Actor, AgentRef: in.AgentRef,
}, RoutedResult{
OK: res.OK, Changed: res.Changed, Branch: res.Branch,
CommitSha: res.CommitSha, Diffstat: res.Diffstat, Error: res.Error,
})
}
var (
routedClientMu sync.Mutex
routedClient tasksclient.Client
@@ -152,6 +196,7 @@ func enqueueRoutedRun(ctx context.Context, run RoutedRun) error {
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,
Actor: run.Actor, AgentRef: run.AgentRef,
}
_, err = cli.ExecuteWorkflow(ctx, tasksclient.StartWorkflowOptions{
ID: run.SessionID,
+148
View File
@@ -6,6 +6,8 @@ import (
"strings"
"sync"
"testing"
"github.com/hanzoai/cloud/clients/agents"
)
// fakeRouter records every routed run handed to the Route seam.
@@ -229,3 +231,149 @@ func TestRun_RoutedButRoutingUnwired_FailsClosed(t *testing.T) {
t.Fatal("must not run locally when routing is unwired but a target was chosen")
}
}
// ---- routed completion parity (#48 I2): verify + PR + session close ----
func finalizeDispatcher(sess *fakeSessions, tr *fakeTracker, verifyOK bool) Dispatcher {
return Dispatcher{
Sessions: sess, Tracker: tr,
VerifyRef: func(_ context.Context, _, _, _ string) (string, bool) {
if verifyOK {
return "verifiedsha", true
}
return "", false
},
}
}
// A routed run that reported CHANGES and whose branch verifies gets the SAME cloud-
// side completion as the local path: the PR is filed and the session is closed done.
func TestFinalizeRouted_ChangedVerifyPasses_FilesPR_ClosesDone(t *testing.T) {
sess := &fakeSessions{}
tr := &fakeTracker{ref: PRRef{Identifier: "API-9"}}
d := finalizeDispatcher(sess, tr, true)
in := RoutedRun{Org: "acme", SessionID: "sess_r", Repo: "api", Base: "main", Branch: "agent/r", Prompt: "add a test", Actor: "u-1", AgentRef: "hanzo"}
d.finalizeRouted(context.Background(), in, RoutedResult{OK: true, Changed: true, Branch: "agent/r", CommitSha: "cafe", Diffstat: "1 file changed"})
if len(tr.inputs) != 1 {
t.Fatalf("a verified changed run must file exactly one PR, got %d", len(tr.inputs))
}
pr := tr.inputs[0]
if pr.Org != "acme" || pr.Repo != "api" || pr.Head != "agent/r" || pr.Assignee != "hanzo" {
t.Fatalf("routed PR mis-filed: %+v", pr)
}
// The verified tip from OUR storage wins over the machine's self-report.
if !strings.Contains(pr.Body, "verifiedsha") {
t.Fatalf("PR body should carry the verified tip: %q", pr.Body)
}
if len(sess.closes) != 1 || sess.closes[0].org != "acme" || sess.closes[0].status != statusDone {
t.Fatalf("routed session must close done, got %+v", sess.closes)
}
// No event carries a secret-shaped field (structural: RoutedRun has none).
for _, e := range sess.events {
if e.org != "acme" || e.session != "sess_r" {
t.Fatalf("routed completion event escaped tenant/session scope: %+v", e)
}
}
}
// A routed run whose branch does NOT verify fails closed: no PR, session closed error
// — trust the tips we can read, not the machine's self-report.
func TestFinalizeRouted_VerifyFails_NoPR_ClosesError(t *testing.T) {
sess := &fakeSessions{}
tr := &fakeTracker{}
d := finalizeDispatcher(sess, tr, false) // verify fails
in := RoutedRun{Org: "acme", SessionID: "s", Repo: "api", Branch: "agent/r"}
d.finalizeRouted(context.Background(), in, RoutedResult{OK: true, Changed: true, Branch: "agent/r", CommitSha: "cafe"})
if len(tr.inputs) != 0 {
t.Fatalf("a routed run whose branch did not land must file no PR, got %d", len(tr.inputs))
}
if len(sess.closes) != 1 || sess.closes[0].status != statusError {
t.Fatalf("verify-fail must close the session error, got %+v", sess.closes)
}
}
// A routed run that reported NO changes closes the session done with no PR.
func TestFinalizeRouted_NoChanges_NoPR_ClosesDone(t *testing.T) {
sess := &fakeSessions{}
tr := &fakeTracker{}
d := finalizeDispatcher(sess, tr, true)
d.finalizeRouted(context.Background(), RoutedRun{Org: "acme", SessionID: "s", Repo: "api"}, RoutedResult{OK: true, Changed: false})
if len(tr.inputs) != 0 {
t.Fatalf("no-changes must file no PR, got %d", len(tr.inputs))
}
if len(sess.closes) != 1 || sess.closes[0].status != statusDone {
t.Fatalf("no-changes must close done, got %+v", sess.closes)
}
}
// A routed run the machine reported as FAILED closes the session error, no PR — and
// even VerifyRef is never consulted (there is nothing to verify).
func TestFinalizeRouted_ReportedError_ClosesError_NoPR(t *testing.T) {
sess := &fakeSessions{}
tr := &fakeTracker{}
verifyCalled := false
d := Dispatcher{Sessions: sess, Tracker: tr, VerifyRef: func(context.Context, string, string, string) (string, bool) {
verifyCalled = true
return "", true
}}
d.finalizeRouted(context.Background(), RoutedRun{Org: "acme", SessionID: "s", Repo: "api"}, RoutedResult{OK: false, Error: "the agent crashed"})
if len(tr.inputs) != 0 {
t.Fatalf("a failed routed run must file no PR, got %d", len(tr.inputs))
}
if verifyCalled {
t.Fatal("a failed run has nothing to verify — VerifyRef must not run")
}
if len(sess.closes) != 1 || sess.closes[0].status != statusError {
t.Fatalf("a failed routed run must close error, got %+v", sess.closes)
}
// The machine's error text reaches the session, never lost.
sawErr := false
for _, e := range sess.events {
if strings.Contains(e.payload, "the agent crashed") {
sawErr = true
}
}
if !sawErr {
t.Fatalf("the reported error must be mirrored into the session: %+v", sess.events)
}
}
// NewDispatcher wires the routed completion seam, so the durable delivery activity
// reaches this dispatcher's verify/PR/session seams. Without it, a routed run's
// session would never close.
func TestNewDispatcher_WiresRoutedFinalizer(t *testing.T) {
prev := routedFinalizer
t.Cleanup(func() { routedFinalizer = prev })
routedFinalizer = nil
_ = NewDispatcher(
func(_, _ string) string { return "https://git.test" },
func(context.Context, string, string, string) (string, bool) { return "", true },
nil,
)
if routedFinalizer == nil {
t.Fatal("NewDispatcher must wire the routed completion seam (else routed sessions never close)")
}
}
// finalizeRoutedDurable bridges the durable agents types to coding's without dropping
// the attribution the completion needs.
func TestFinalizeRoutedDurable_BridgesFields(t *testing.T) {
sess := &fakeSessions{}
tr := &fakeTracker{ref: PRRef{Identifier: "API-1"}}
d := finalizeDispatcher(sess, tr, true)
d.finalizeRoutedDurable(context.Background(),
agents.RoutedRun{Org: "acme", SessionID: "s", Repo: "api", Branch: "agent/b", AgentRef: "hanzo", Actor: "u-9"},
agents.RoutedResult{OK: true, Changed: true, Branch: "agent/b", CommitSha: "beef"})
if len(tr.inputs) != 1 || tr.inputs[0].Assignee != "hanzo" || tr.inputs[0].Head != "agent/b" {
t.Fatalf("bridge dropped attribution: %+v", tr.inputs)
}
if len(sess.closes) != 1 || sess.closes[0].status != statusDone {
t.Fatalf("bridge must drive the session to done: %+v", sess.closes)
}
}
+2 -2
View File
@@ -24,7 +24,7 @@ import (
"sync/atomic"
"time"
plansvc "github.com/hanzoai/cloud/clients/plan"
"github.com/hanzoai/cloud/clients/plan"
"github.com/hanzoai/cloud/types"
commercemod "github.com/hanzoai/commerce"
"github.com/hanzoai/commerce/datastore"
@@ -134,7 +134,7 @@ func (c *inProcessClient) CheckEntitlement(ctx context.Context, orgID, productID
if slug == "" {
continue // no resolvable plan tier on this sub — cannot grant from it
}
_, features, found, ferr := plansvc.LicenseEntitlement(ctx, slug)
_, features, found, ferr := plan.LicenseEntitlement(ctx, slug)
if ferr != nil {
// MACHINERY failure (plans vocabulary unavailable): cannot resolve features
// ⇒ cannot verify ⇒ fail closed. Never deny-by-guess on an outage.

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