Compare commits

...
Author SHA1 Message Date
antje ac6102ded1 build(sqlite): bump hanzoai/sqlite v0.3.2 + vfs v0.6.6 — modernc out of the link
Roots modernc.org out of the package-level graph (go list -deps ./... | grep
'^modernc.org': 30 -> 0). go build ./... green; CGO_ENABLED=0 go test ./apps/ green.

BLOCKED from main: v0.3.2 changes the cek encryption-envelope contract (per-DB
DEK wrapped under a master-derived KEK) and makes the pure-Go build
encryption-capable, so the data plane now refuses unencrypted opens. clients/kms
tests go 1 -> 32 failures under BOTH CGO-off and CGO-on (they open stores without
CLOUD_KMS_MASTER_KEY_REF, relying on the old modernc unencrypted-dev path); a
global master key does not fix it (per-test no-key/wrong-key expectations). Needs a
coordinated clients/kms + flags/integrations/channels/link/base/admin cek/test
adoption before it can land.
2026-07-19 20:52:53 -07:00
antje 83519a957e merge: shared admin core/warehouse ClickHouse helpers (data-platform-warehouse-helpers)
feat/data-platform-warehouse-helpers: clients/admin/core/warehouse.go + invoices/
metrics/subscriptions refactored onto it (aimetrics portion already landed via
#10). Conflict resolved keeping main's CreateCreditGrant, dropping the SaaS-metrics
helpers moved to warehouse.go. Tests: clients/admin (all subpkgs) ok, apps ok.
2026-07-19 20:45:00 -07:00
hanzo-devandantje 7b19236966 admin(data-platform): shared core/warehouse ClickHouse helpers + invoices/metrics/subscriptions using them + test coverage + aimetrics 2026-07-19 20:43:08 -07:00
antje 2e3dc7d818 merge: fleet gpu-connect CPU arch/cores/RAM reporting (fleet-byo-cpu-spec)
feat/fleet-byo-cpu-spec: gpu-connect nodes report CPU arch + cores + RAM on
/v1/fleet (cli/gpu.go, clients/visor board+fleet). Redundant #331 flags-test
tweak dropped in favor of main's. Tests: visor ok, cli ok, apps ok (CGO-off);
flags green under CGO-on (its one CGO-off failure is pre-existing, matches main).
2026-07-19 20:40:58 -07:00
hanzo-devandantje 906f89a2b6 feat(fleet): gpu-connect nodes report CPU arch + cores + RAM on /v1/fleet
BYO nodes that dial in via `hanzo gpu connect` reported only their GPUs, so the
/v1/fleet board (and the world Fleet panel) showed NO CPU arch or system memory
for them — unlike code-linked run-targets, whose Spec already carries
arch/cpus/memory. evo-2 (Strix Halo, x86_64) and spark (GB10, aarch64), both
128 GB, appear on the board as BOTH a run-target AND a gpu-connect worker; the
BYO rows surfaced blank arch/memory.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 23:45:24 -07:00
107 changed files with 15087 additions and 649 deletions
+1
View File
@@ -34,4 +34,5 @@ Thumbs.db
.shots/
.claude/
.worktrees/
native/flags/target
+5
View File
@@ -66,6 +66,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 +76,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"
@@ -257,6 +259,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
@@ -330,6 +334,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},
+2
View File
@@ -47,6 +47,7 @@ var frozen = []struct {
{"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
@@ -83,6 +84,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
+78 -2
View File
@@ -39,6 +39,7 @@ import (
"regexp"
"runtime"
"slices"
"strconv"
"strings"
"syscall"
"time"
@@ -159,6 +160,8 @@ type worker struct {
hostname string
jobsNS string
gpus []gpuInfo
arch string // CPU arch (`uname -m`), detected once at newWorker
memory int64 // total system RAM in bytes, detected once at newWorker
handlers map[string]jobHandler
// studioUploadURL is the org studio base that receives finished render outputs
@@ -198,8 +201,16 @@ type gpuInfo struct {
// additive (omitempty): an older cloud that does not read them still renders the
// GPU; a newer one advertises the engine endpoint on GET /v1/fleet/workers.
type registration struct {
Hostname string `json:"hostname"`
Os string `json:"os"`
Hostname string `json:"hostname"`
Os string `json:"os"`
// Arch/CPUs/Memory are THIS host's static CPU spec, in the SAME convention the
// fleet already uses for code-linked run-targets: Arch is `uname -m`
// (aarch64 | x86_64 | arm64), Memory is total system RAM in BYTES. Matching the
// existing convention matters — evo-2 and spark appear on the board as BOTH a
// run-target and a gpu-connect worker, so both rows must show the SAME arch.
Arch string `json:"arch,omitempty"`
CPUs int `json:"cpus,omitempty"`
Memory int64 `json:"memory,omitempty"`
Version string `json:"version"`
JobQueue string `json:"jobQueue"`
GPUs []gpuInfo `json:"gpus"`
@@ -292,6 +303,8 @@ func newWorker(env *Env, jobsNS string) (*worker, error) {
hostname: host,
jobsNS: firstNonEmpty(jobsNS, defaultJobsNS),
gpus: detectGPUs(),
arch: detectArch(),
memory: detectMemTotal(),
studioUploadURL: firstNonEmpty(os.Getenv("HANZO_STUDIO_UPLOAD_URL"), defaultStudioUploadURL),
}
policy, err := loadSharePolicy()
@@ -365,6 +378,66 @@ func detectAppleGPU() []gpuInfo {
return []gpuInfo{info}
}
// detectArch reports this machine's CPU architecture in the SAME convention the
// fleet already uses for code-linked nodes — `uname -m` (aarch64 | x86_64 on Linux,
// arm64 | x86_64 on Darwin) — so a machine that shows up as both a run-target and a
// gpu-connect worker carries ONE arch string on the board. Falls back to the
// compiled runtime.GOARCH only if uname is unavailable; "" is never forced.
func detectArch() string {
if out, err := exec.Command("uname", "-m").Output(); err == nil {
if a := strings.TrimSpace(string(out)); a != "" {
return a
}
}
return runtime.GOARCH
}
// detectMemTotal returns this machine's total physical RAM in bytes, or 0 when it
// cannot be read (reported as "unknown" via omitempty — never faked). Linux reads
// /proc/meminfo's MemTotal (covers evo-2's Strix Halo and spark's GB10, both Linux);
// Darwin reads sysctl hw.memsize. This is the SAME total a code-linked box reports
// as Spec.Memory, so the fleet board describes both kinds of node identically.
func detectMemTotal() int64 {
if runtime.GOOS == "darwin" {
out, err := exec.Command("sysctl", "-n", "hw.memsize").Output()
if err != nil {
return 0
}
var b int64
if _, err := fmt.Sscan(strings.TrimSpace(string(out)), &b); err == nil && b > 0 {
return b
}
return 0
}
b, err := os.ReadFile("/proc/meminfo")
if err != nil {
return 0
}
return parseMemTotalKB(b)
}
// parseMemTotalKB extracts MemTotal from /proc/meminfo content (reported in kB) and
// returns it in bytes, or 0 when the line is absent or malformed.
func parseMemTotalKB(meminfo []byte) int64 {
sc := bufio.NewScanner(bytes.NewReader(meminfo))
for sc.Scan() {
line := sc.Text()
if !strings.HasPrefix(line, "MemTotal:") {
continue
}
f := strings.Fields(line) // "MemTotal:" <kb> "kB"
if len(f) < 2 {
return 0
}
kb, err := strconv.ParseInt(f[1], 10, 64)
if err != nil || kb <= 0 {
return 0
}
return kb * 1024
}
return 0
}
// ---------------------------------------------------------------------------
// connect.
// ---------------------------------------------------------------------------
@@ -510,6 +583,9 @@ func (w *worker) buildRegistration() registration {
return registration{
Hostname: w.hostname,
Os: runtime.GOOS,
Arch: w.arch,
CPUs: runtime.NumCPU(),
Memory: w.memory,
Version: Version,
JobQueue: w.jobsNS,
GPUs: w.gpus,
+88
View File
@@ -0,0 +1,88 @@
package cli
// gpu_spec_test.go — the host static-spec a `hanzo gpu connect` node reports so
// GET /v1/fleet can show its CPU arch, core count and total RAM (the fields a
// code-linked box already carries). Real telemetry only: arch is `uname -m`, cores
// are runtime.NumCPU, RAM is parsed from the OS — never a hardcoded machine.
import (
"os/exec"
"runtime"
"strings"
"testing"
)
func TestParseMemTotalKB(t *testing.T) {
// A real /proc/meminfo head from a 128 GiB box. MemTotal is in kB; we report bytes.
meminfo := []byte("MemTotal: 131923980 kB\nMemFree: 1048576 kB\nMemAvailable: 120000000 kB\n")
if got, want := parseMemTotalKB(meminfo), int64(131923980)*1024; got != want {
t.Fatalf("parseMemTotalKB = %d, want %d bytes", got, want)
}
// Absent / malformed input is reported as 0 (unknown), never a guess.
for name, in := range map[string]string{
"empty": "",
"no-memtotal": "MemFree: 100 kB\n",
"malformed": "MemTotal: notanumber kB\n",
"no-value": "MemTotal:\n",
} {
if got := parseMemTotalKB([]byte(in)); got != 0 {
t.Fatalf("%s: parseMemTotalKB = %d, want 0", name, got)
}
}
}
// detectMemTotal reads the real host, so on Linux/macOS CI it must return a positive
// byte count — proof the reporter reads actual RAM rather than shipping 0.
func TestDetectMemTotalIsReal(t *testing.T) {
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
t.Skipf("no MemTotal source on %s", runtime.GOOS)
}
got := detectMemTotal()
if got <= 0 {
t.Fatalf("detectMemTotal = %d, want the host's real RAM (>0)", got)
}
// Evidence: what THIS host actually reports (never hardcoded). On the GB10 spark
// box this prints aarch64 + ~128 GiB read from /proc/meminfo.
t.Logf("real host spec: arch=%s cpus=%d memory=%d bytes (%.1f GiB)",
detectArch(), runtime.NumCPU(), got, float64(got)/(1<<30))
}
// detectArch must match the fleet's `uname -m` convention (aarch64 | x86_64 | arm64),
// NOT runtime.GOARCH (arm64 | amd64) — so a machine that appears as both a run-target
// and a gpu-connect worker shows ONE arch string on the board. On Linux uname -m is
// aarch64/x86_64; assert the real host agrees and is never GOARCH's amd64.
func TestDetectArchMatchesUnameConvention(t *testing.T) {
got := detectArch()
if got == "" {
t.Fatal("detectArch returned empty; must fall back to runtime.GOARCH")
}
if out, err := exec.Command("uname", "-m").Output(); err == nil {
if want := strings.TrimSpace(string(out)); want != "" && got != want {
t.Fatalf("detectArch = %q, want `uname -m` %q (fleet convention)", got, want)
}
}
// Guard the regression this test exists for: on Linux amd64 the value must be
// x86_64, never GOARCH's "amd64".
if runtime.GOOS == "linux" && runtime.GOARCH == "amd64" && got == "amd64" {
t.Fatal("arch is GOARCH 'amd64'; the fleet convention is 'x86_64'")
}
t.Logf("detectArch=%q (GOARCH=%q)", got, runtime.GOARCH)
}
// buildRegistration must carry this host's detected arch (uname -m), cores (NumCPU)
// and RAM — so spark reports aarch64 and evo-2 reports x86_64, both ~128 GB, matching
// how the same machines already report as code-linked run-targets.
func TestBuildRegistrationCarriesHostSpec(t *testing.T) {
const mem = int64(137438953472) // 128 GiB
w := &worker{hostname: "spark", jobsNS: "gpu-jobs", arch: "aarch64", memory: mem}
reg := w.buildRegistration()
if reg.Arch != "aarch64" {
t.Fatalf("Arch = %q, want the worker's detected arch %q", reg.Arch, "aarch64")
}
if reg.CPUs != runtime.NumCPU() {
t.Fatalf("CPUs = %d, want runtime.NumCPU %d", reg.CPUs, runtime.NumCPU())
}
if reg.Memory != mem {
t.Fatalf("Memory = %d, want the detected total %d", reg.Memory, mem)
}
}
+6
View File
@@ -103,8 +103,14 @@ func routes(app *zip.App, s *cloud.Service[core.State]) {
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.Get("/v1/admin/aimetrics", core.Guard(s, aimetrics))
app.Post("/v1/admin/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.
app.Post("/v1/admin/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))
// Bases — the tenant Base-instance panel, org-scoped (bases.go).
+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)
}
}
+8 -227
View File
@@ -295,235 +295,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))
}
+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)
}
}
+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)
}
}
}
+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")
}
}
+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
+56 -4
View File
@@ -7,6 +7,7 @@ import (
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/principal"
"github.com/zap-proto/zip"
)
@@ -38,11 +39,48 @@ 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) {
assertSingleReplica(s.Log)
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))
}
// caller is the VALIDATED principal id (X-User-Id) — the machine-owner identity for
// route-work. tenant() already required a validated principal, so on any handler that
// resolved an org this is non-empty.
func caller(c *zip.Ctx) string { return strings.TrimSpace(c.User()) }
// ownsTarget reports whether the caller may MANAGE this target's route-work plane —
// mint/rotate the claim key, claim, report, patch, delete. A machine belongs to the
// principal that registered it (least privilege, AC-6): its owner may manage it, and
// an org admin (self-service org management, the admin-org model's isAdmin) may manage
// any of the org's targets. An UNOWNED (pre-migration) row is admin-only until its
// owner re-registers — register binds the owner. Fail-closed: an empty caller or an
// empty owner never satisfies the ownership arm, so a non-validated request or a
// pre-migration row is never owner-managed.
func ownsTarget(c *zip.Ctx, t Target) bool {
if principal.IsOrgAdmin(c) || principal.IsSuperAdmin(c) {
return true
}
u := caller(c)
return t.Owner != "" && u != "" && u == t.Owner
}
// authorizeTargetManage resolves the (org,id) target and gates it on ownsTarget,
// collapsing every failure — cross-org, unknown, or not-owned — to the SAME
// errTargetNotFound so the machine surface never distinguishes them (no oracle). The
// resolved target is returned for the caller to use (avoids a second read).
func authorizeTargetManage(s *cloud.Service[state], c *zip.Ctx, org, id string) (Target, error) {
t, err := s.State.store.GetTarget(c.Context(), org, id)
if err != nil {
return Target{}, errTargetNotFound // unknown / cross-org -> no oracle
}
if !ownsTarget(c, t) {
return Target{}, errTargetNotFound
}
return t, nil
}
// mintClaimKey (re)mints the target's claim key and returns it ONCE. Only the
// SHA-256 hash is stored. Org-scoped: only a caller in the target's org can mint,
// and the key is bound to (org, target). Rotating supersedes any prior daemon.
@@ -52,11 +90,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 +116,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 +162,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)
}
}
+76 -15
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
@@ -548,12 +581,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 +613,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 +690,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 +777,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)
+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)
}
}
+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
}
+468
View File
@@ -0,0 +1,468 @@
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]) {
app.Get("/v1/channels", cloud.Terminal(cloud.Handle(s, list)))
app.Get("/v1/channels/inbox", cloud.Terminal(cloud.Handle(s, inbox)))
app.Get("/v1/channels/pairing", cloud.Terminal(cloud.Handle(s, pairingList)))
app.Post("/v1/channels/pairing/approve", cloud.Terminal(cloud.Handle(s, pairingApprove)))
app.Get("/v1/channels/allowlist", cloud.Terminal(cloud.Handle(s, allowlistGet)))
app.Put("/v1/channels/allowlist", cloud.Terminal(cloud.Handle(s, allowlistPut)))
app.Post("/v1/channels/: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
}
+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)
}
}
+65
View File
@@ -24,9 +24,13 @@
package commerceinproc
import (
"fmt"
"net/http"
"net/http/httptest"
"runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
@@ -88,6 +92,47 @@ func BaseURL(env string) string {
return ""
}
// maxDepth caps how many co-resident dispatches may be NESTED on one goroutine.
// The in-process handler is the WHOLE shared app (SetApp publishes
// adaptor.FiberApp), so a dispatch re-runs every edge middleware — and any of
// those middlewares (e.g. the per-org scope rate-limiter reading its rules from
// commerce, or the ai per-tier gate) itself issues a commerce read, which
// dispatches the whole app AGAIN. On a cold config/tier cache that read never
// resolves before it re-enters, so it recurses without bound: synchronously it
// overflows the goroutine stack (a fatal ~1e9-byte "stack overflow"); each level
// also leaks the completion's net/http cancel watchdog, so under load the writer
// accumulates tens of thousands of goroutines parked in setRequestCancel and OOMs.
// A legitimate flow nests at most a couple of reads (a debit that first checks a
// balance), so a small cap admits every real path while turning the runaway into a
// bounded, fail-safe refusal at the seam. Callers of the co-resident reads treat
// the refusal as any transport error and fall safe (the tier gate ALLOWS, the
// scope-rule fetch fails OPEN); the prepaid balance read is a direct in-process
// ledger call, never this transport, so fail-closed billing is unaffected.
const maxDepth = 8
// depthByGoroutine tracks the current nested-dispatch depth per goroutine. The
// dispatch is synchronous (ServeHTTP runs on the caller's goroutine), so a
// goroutine-keyed counter measures exactly the nesting of the recursion; distinct
// concurrent requests run on distinct goroutines and never share a count.
var depthByGoroutine sync.Map // goid -> int
// goroutineID returns the current goroutine's numeric id. It is used ONLY for
// re-entrancy accounting (never identity or security); the runtime prints it at
// the head of the per-goroutine stack, which is the one portable way to read it.
func goroutineID() int64 {
var buf [64]byte
n := runtime.Stack(buf[:], false)
// "goroutine <id> [<state>]:"
s := string(buf[:n])
s = strings.TrimPrefix(s, "goroutine ")
if i := strings.IndexByte(s, ' '); i > 0 {
if id, err := strconv.ParseInt(s[:i], 10, 64); err == nil {
return id
}
}
return 0
}
// roundTripper dispatches to the in-process commerce handler when one is published,
// else to fallback (plain HTTP). The gin engine routes on req.URL.Path, so the host
// in the (placeholder or real) base is irrelevant when co-resident.
@@ -95,6 +140,26 @@ type roundTripper struct{ fallback http.RoundTripper }
func (rt roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if p := handler.Load(); p != nil {
// Re-entrancy guard: bound how deep co-resident dispatches may nest on
// this goroutine (see maxDepth). Without it a middleware that reads
// commerce while serving a commerce read re-runs the whole app forever.
id := goroutineID()
depth := 0
if v, ok := depthByGoroutine.Load(id); ok {
depth = v.(int)
}
if depth >= maxDepth {
return nil, fmt.Errorf("commerceinproc: in-process dispatch depth %d exceeded (re-entrant commerce read refused: %s %s)", maxDepth, req.Method, req.URL.Path)
}
depthByGoroutine.Store(id, depth+1)
defer func() {
if depth == 0 {
depthByGoroutine.Delete(id)
} else {
depthByGoroutine.Store(id, depth)
}
}()
// Callers build CLIENT-style requests (http.NewRequest → empty
// RequestURI); the in-process dispatch is SERVER-side, and the fiber
// pipeline routes on RequestURI. Normalize here — the one seam.
+91
View File
@@ -0,0 +1,91 @@
// Copyright © 2026 Hanzo AI. MIT License.
package commerceinproc
import (
"io"
"net/http"
"sync/atomic"
"testing"
"github.com/zap-proto/zip"
)
// TestRoundTripReentrancyBounded reproduces the production crash shape — a handler
// that reads the co-resident commerce app WHILE serving a commerce read, so the
// dispatch re-runs the whole app and re-enters itself — and proves the depth guard
// turns the unbounded recursion (stack overflow + setRequestCancel pileup) into a
// bounded, fail-safe refusal. Without the guard this test recurses until the
// goroutine stack overflows and the process dies; with it the outer request returns
// and the nesting never exceeds maxDepth.
func TestRoundTripReentrancyBounded(t *testing.T) {
var live, peak int64
client := Client(0)
app := zip.New(zip.Config{DisableStartupMessage: true})
app.All("/loop", func(c *zip.Ctx) error {
d := atomic.AddInt64(&live, 1)
for {
p := atomic.LoadInt64(&peak)
if d <= p || atomic.CompareAndSwapInt64(&peak, p, d) {
break
}
}
defer atomic.AddInt64(&live, -1)
// Self-read: dispatch the SAME co-resident app again (the scope-rate-limiter
// reading its rules from commerce / the per-tier gate reading the tier — a
// commerce read issued from inside a commerce read).
req, _ := http.NewRequest(http.MethodGet, PlaceholderBase+"/loop", nil)
if resp, err := client.Do(req); err == nil { // an err here is the guard's refusal — fail safe
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}
return c.Bytes(http.StatusOK, []byte("ok"))
})
SetApp(app)
defer SetHandler(nil)
// Enter THROUGH the transport so the outer request is dispatch depth 1.
req, _ := http.NewRequest(http.MethodGet, PlaceholderBase+"/loop", nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("outer request errored (guard must not refuse the first dispatch): %v", err)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
t.Logf("peak nesting reached = %d (cap maxDepth=%d)", peak, maxDepth)
if peak < 2 {
t.Fatalf("handler never re-entered (peak nesting %d) — the test did not exercise the recursion", peak)
}
if peak > maxDepth {
t.Fatalf("nesting reached %d, exceeds the cap %d — the guard did not bound the self-dispatch", peak, maxDepth)
}
}
// TestRoundTripSingleDispatchOK proves the guard is invisible to the normal path: a
// single (non-nested) co-resident dispatch succeeds and leaves no depth residue, so
// a later dispatch on the same goroutine starts fresh.
func TestRoundTripSingleDispatchOK(t *testing.T) {
app := zip.New(zip.Config{DisableStartupMessage: true})
app.All("/ok", func(c *zip.Ctx) error { return c.Bytes(http.StatusOK, []byte("ok")) })
SetApp(app)
defer SetHandler(nil)
client := Client(0)
for i := 0; i < 3; i++ {
req, _ := http.NewRequest(http.MethodGet, PlaceholderBase+"/ok", nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("dispatch %d errored: %v", i, err)
}
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK || string(body) != "ok" {
t.Fatalf("dispatch %d: status=%d body=%q, want 200 \"ok\"", i, resp.StatusCode, body)
}
}
if _, ok := depthByGoroutine.Load(goroutineID()); ok {
t.Fatalf("depth counter leaked after balanced dispatches")
}
}
+169 -18
View File
@@ -14,12 +14,19 @@
// GET /v1/deploy/applications/{name}/resource-tree → ApplicationTree
// POST /v1/deploy/applications/{name}/{sync,rollback} → request App-CR reconcile
//
// Every route is SuperAdmin-gated (c.IsAdmin), fail-closed; the argocd UI's own
// auth is disabled because IAM owns identity at the edge (the SPA is public
// static assets, the data is gated). AppProject → IAM/Org (no argocd RBAC).
// SECURITY — TENANT-SCOPED reads, SuperAdmin-only writes, fail-closed (scope.go):
// the READ projections (applications list/detail/resource-tree, clusters, projects,
// stream) resolve the request's scope (resolveScope) — 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 403s. The WRITE actions (sync/rollback) and
// the argocd bootstrap (settings/version/can-i) stay SuperAdmin-only (guard). The
// argocd UI's own auth is disabled because IAM owns identity at the edge (the SPA is
// public static assets, the data is scoped). AppProject → IAM/Org (no argocd RBAC):
// projects are REFLECTED read-only from the IAM-owned (org,name) Project resource.
package deploy
import (
"context"
"encoding/json"
"net/http"
"time"
@@ -42,20 +49,123 @@ const dashPrefix = "/v1/deploy"
func registerDashboardRoutes(app *zip.App, s *cloud.Service[state]) {
// Bootstrap (the SPA awaits settings + userinfo before first render).
app.Get(dashPrefix+"/settings", guard(s, cloud.Handle(s, dashSettings)))
app.Get(dashPrefix+"/session/userinfo", guard(s, cloud.Handle(s, dashUserInfo)))
// userinfo is the ONE deliberately PUBLIC bootstrap route: it is how the SPA
// asks "am I signed in?", and a 403 to that question is unanswerable — the SPA
// is an XHR client, so the document bounce in guard() never fires for it and it
// dead-ends with no way to reach sign-in. Anonymous callers get
// {loggedIn:false} and the sign-in URL; nothing else. It discloses no identity,
// no cluster state, and no configuration, and it is NOT a gate: every route
// that returns fleet data or mutates a CR stays guard()ed.
app.Get(dashPrefix+"/session/userinfo", cloud.Handle(s, dashUserInfo))
app.Get(dashPrefix+"/version", guard(s, cloud.Handle(s, dashVersion)))
app.Get(dashPrefix+"/account/can-i/*", guard(s, cloud.Handle(s, dashCanI)))
// Applications projection (read).
app.Get(dashPrefix+"/applications", guard(s, cloud.Handle(s, dashAppList)))
app.Get(dashPrefix+"/applications/:name", guard(s, cloud.Handle(s, dashApp)))
app.Get(dashPrefix+"/applications/:name/resource-tree", guard(s, cloud.Handle(s, dashResourceTree)))
// Applications projection (read). TENANT-SCOPED, not blanket-guard()ed: each handler
// resolves the request's scope (resolveScope) and fails closed — a SuperAdmin sees the
// whole fleet, a validated org member sees ONLY its own org's apps, anyone else 403s.
app.Get(dashPrefix+"/applications", cloud.Handle(s, dashAppList))
app.Get(dashPrefix+"/applications/:name", cloud.Handle(s, dashApp))
app.Get(dashPrefix+"/applications/:name/resource-tree", cloud.Handle(s, dashResourceTree))
// Per-app detail projections the SPA's application view calls (detail.go). Same tenant
// scope as dashApp: resolveScope + findNamespace, a cross-tenant name 404s.
app.Get(dashPrefix+"/applications/:name/syncwindows", cloud.Handle(s, dashSyncWindows))
app.Get(dashPrefix+"/applications/:name/revisions/:revision/metadata", cloud.Handle(s, dashRevisionMetadata))
// Applications watch (Server-Sent Events) — the live stream the applications
// view opens; see stream.go. Same tenant scope as the list.
app.Get(dashPrefix+"/stream/applications", cloud.Handle(s, dashStreamApps))
// Per-app live resource-tree stream (detail.go) — the detail view's tree watch, same
// tenant scope: the scope gate runs before any SSE frame is emitted.
app.Get(dashPrefix+"/stream/applications/:name/resource-tree", cloud.Handle(s, dashStreamResourceTree))
// Actions App-CR reconcile ops.
// Destination clusters + AppProjects — the two lists the applications view
// resolves alongside the fleet (Destination column + project filter). Tenant-scoped:
// clusters count only the caller's apps; projects reflect the caller's IAM projects.
app.Get(dashPrefix+"/clusters", cloud.Handle(s, dashClusters))
app.Get(dashPrefix+"/projects", cloud.Handle(s, dashProjects))
// Actions → App-CR reconcile ops. STILL SuperAdmin-only (guard): write-back to the
// fleet is a follow-on; this plane's tenant surface is read-only reflection for now.
app.Post(dashPrefix+"/applications/:name/sync", guard(s, cloud.Handle(s, dashSync)))
app.Post(dashPrefix+"/applications/:name/rollback", guard(s, cloud.Handle(s, dashSync)))
}
// ── clusters + projects projection ───────────────────────────────────────────
// dashClusters is GET /v1/deploy/clusters — the ArgoCD ClusterList of the
// destinations the fleet reconciles into (always ≥ the in-cluster destination),
// read from the SAME App-CR source dashAppList uses. It NEVER surfaces a cluster
// credential (argoCluster has no config field — see projection.go).
func dashClusters(s *cloud.Service[state], c *zip.Ctx) error {
sc, ok := resolveScope(c)
if !ok {
return refuse(c)
}
if err := ready(s); err != nil {
return err
}
// Only the caller's own apps are counted (SuperAdmin: the whole fleet). The in-cluster
// destination is always present (projectClusters), and no cluster credential can leak
// (argoCluster has no config field) — so a tenant view is still credential-free.
crs, err := sc.appCRs(s, c.Context())
if err != nil {
return err
}
return c.JSON(http.StatusOK, projectClusters(crs))
}
// dashProjects is GET /v1/deploy/projects — the ArgoCD AppProjectList. It PREFERS
// real argoproj.io/v1alpha1 AppProject CRs when that CRD is served; otherwise it
// synthesizes one permissive project per distinct App-CR project name (default
// always present). Read-only, from the same App-CR source.
func dashProjects(s *cloud.Service[state], c *zip.Ctx) error {
sc, ok := resolveScope(c)
if !ok {
return refuse(c)
}
if err := ready(s); err != nil {
return err
}
// IAM is the ONE source of truth for the (org,name) Project resource. Reflect it: a
// normal org sees ONLY its own organization's projects, a SuperAdmin sees every org's.
items := sc.iamProjects()
if sc.superAdmin && len(items) == 0 {
// Whole-fleet fallback when the embedded IAM store is unavailable/empty: preserve the
// pre-tenant projection — real argocd AppProject CRs if that CRD is served (that list is
// cluster-wide/unscoped, so it is a SuperAdmin-only path, NEVER a tenant's), else
// synthesize from the fleet's distinct App-CR project names. Keeps the SuperAdmin view
// populated even before IAM is reachable (e.g. in a unit test with no embedded store).
if real, served := listAppProjects(s, c.Context()); served {
return c.JSON(http.StatusOK, argoProjectList{Metadata: argoListMeta{}, Items: real})
}
crs, err := sc.appCRs(s, c.Context())
if err != nil {
return err
}
for _, name := range projectedProjectNames(crs) {
items = append(items, synthProject(name))
}
}
// "default" always resolves (every projected app's spec.project falls back to it), which
// also holds the e2e invariant that the projects list contains 'default'.
return c.JSON(http.StatusOK, argoProjectList{Metadata: argoListMeta{}, Items: ensureDefault(items)})
}
// listAppProjects lists real argoproj.io/v1alpha1 AppProject CRs cluster-wide. It
// returns (projected, true) ONLY when the CRD is served AND at least one project
// exists; any error (CRD absent — the norm here, or RBAC) or an empty set yields
// (nil, false) so the caller synthesizes. It never fails the request.
func listAppProjects(s *cloud.Service[state], ctx context.Context) ([]argoProject, bool) {
list, err := s.State.dyn.Resource(appProjectGVR).List(ctx, metav1.ListOptions{})
if err != nil || list == nil || len(list.Items) == 0 {
return nil, false
}
out := make([]argoProject, 0, len(list.Items))
for i := range list.Items {
out = append(out, projectAppProject(&list.Items[i]))
}
return out, true
}
// ── bootstrap ────────────────────────────────────────────────────────────────
func dashSettings(s *cloud.Service[state], c *zip.Ctx) error {
@@ -79,17 +189,36 @@ func dashSettings(s *cloud.Service[state], c *zip.Ctx) error {
})
}
// dashUserInfo answers "is this browser signed in, and if not where does it sign
// in?" — the SPA's bootstrap question, and the only route on this plane that
// answers for an anonymous caller.
//
// The anonymous branch carries loggedIn:false and a URL, and NOTHING else: no
// username, no org, no groups, no issuer, no hint about who the caller might be or
// what exists in the cluster. Answering it costs nothing (the caller already knows
// whether it holds a cookie) and withholding it costs the whole sign-in journey.
//
// The predicate is c.IsAdmin() — the SAME SuperAdmin fact guard() gates on, minted
// by SanitizeIdentity from a validated principal whose org is the reserved admin
// org. So a validated-but-not-SuperAdmin caller is reported as NOT logged in here,
// which is the truth as this console defines it: they cannot use it.
func dashUserInfo(s *cloud.Service[state], c *zip.Ctx) error {
// IAM authenticated the request at the edge; report the principal.
if !c.IsAdmin() {
return c.JSON(http.StatusOK, map[string]any{
"loggedIn": false,
"loginUrl": loginPath,
})
}
user := c.User()
if user == "" {
user = "admin"
}
return c.JSON(http.StatusOK, map[string]any{
"loggedIn": true,
"username": user,
"iss": "argocd", // keep == argocd so the UI never triggers an SSO redirect
"groups": []string{},
"loggedIn": true,
"username": user,
"iss": "argocd", // keep == argocd so the UI never triggers an SSO redirect
"groups": []string{},
"logoutUrl": logoutPath,
})
}
@@ -110,17 +239,24 @@ func dashCanI(s *cloud.Service[state], c *zip.Ctx) error {
// ── applications projection ──────────────────────────────────────────────────
func dashAppList(s *cloud.Service[state], c *zip.Ctx) error {
sc, ok := resolveScope(c)
if !ok {
return refuse(c)
}
if err := ready(s); err != nil {
return err
}
list := argoAppList{APIVersion: "argoproj.io/v1alpha1", Kind: "ApplicationList", Metadata: argoListMeta{}, Items: []argoApp{}}
for _, ns := range scanOrder() {
for _, ns := range sc.namespaces() {
crs, err := listAppCRs(s, c.Context(), ns)
if err != nil {
return k8sErr(s, "list", err)
}
running := runningVersions(s, c.Context(), ns)
for i := range crs {
if !sc.allows(&crs[i]) {
continue // cross-tenant CR — never projected to this scope
}
list.Items = append(list.Items, projectApp(&crs[i], ns, running[crs[i].GetName()]))
}
}
@@ -128,6 +264,10 @@ func dashAppList(s *cloud.Service[state], c *zip.Ctx) error {
}
func dashApp(s *cloud.Service[state], c *zip.Ctx) error {
sc, ok := resolveScope(c)
if !ok {
return refuse(c)
}
if err := ready(s); err != nil {
return err
}
@@ -135,7 +275,8 @@ func dashApp(s *cloud.Service[state], c *zip.Ctx) error {
if !appNameRE.MatchString(name) {
return zip.ErrBadRequest("name must be a DNS-1123 label")
}
ns, err := resolveNamespace(s, c, name)
// findNamespace 404s a cross-tenant name (org A's app requested by org B) — no oracle.
ns, err := sc.findNamespace(s, c, name)
if err != nil {
return err
}
@@ -157,6 +298,10 @@ func dashApp(s *cloud.Service[state], c *zip.Ctx) error {
}
func dashResourceTree(s *cloud.Service[state], c *zip.Ctx) error {
sc, ok := resolveScope(c)
if !ok {
return refuse(c)
}
if err := ready(s); err != nil {
return err
}
@@ -164,7 +309,7 @@ func dashResourceTree(s *cloud.Service[state], c *zip.Ctx) error {
if !appNameRE.MatchString(name) {
return zip.ErrBadRequest("name must be a DNS-1123 label")
}
ns, err := resolveNamespace(s, c, name)
ns, err := sc.findNamespace(s, c, name)
if err != nil {
return err
}
@@ -180,6 +325,12 @@ func dashResourceTree(s *cloud.Service[state], c *zip.Ctx) error {
// truth; rollback-by-revision is the image-pin follow-on). Returns the projected
// Application (the UI only checks for a non-error response).
func dashSync(s *cloud.Service[state], c *zip.Ctx) error {
// Reached only through guard() (SuperAdmin-only), so the scope is always whole-fleet;
// resolving it keeps ONE namespace-resolution path (findNamespace) across the plane.
sc, ok := resolveScope(c)
if !ok {
return refuse(c)
}
if err := ready(s); err != nil {
return err
}
@@ -187,7 +338,7 @@ func dashSync(s *cloud.Service[state], c *zip.Ctx) error {
if !appNameRE.MatchString(name) {
return zip.ErrBadRequest("name must be a DNS-1123 label")
}
ns, err := resolveNamespace(s, c, name)
ns, err := sc.findNamespace(s, c, name)
if err != nil {
return err
}
+334
View File
@@ -0,0 +1,334 @@
package deploy
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/hanzoai/cloud"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/watch"
)
// getJSON drives one guarded GET through the full router as a SuperAdmin and
// decodes the JSON body. It asserts a 200 so a route-not-registered (404) or a
// guard reject (403) fails loudly.
func getJSON(t *testing.T, s *cloud.Service[state], path string) map[string]any {
t.Helper()
app := zip.New(zip.Config{Logger: luxlog.New("test")})
routes(app, s)
req := httptest.NewRequest("GET", path, nil)
req.Header.Set("X-User-IsAdmin", "true")
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET %s (admin) = %d, want 200", path, resp.StatusCode)
}
var body map[string]any
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode %s: %v", path, err)
}
return body
}
// ── clusters endpoint ─────────────────────────────────────────────────────────
// TestDashClustersEndpoint: /clusters returns a ClusterList the SPA's Destination
// column reads (items[].server/name/connectionState), with the fleet's app count,
// and NO cluster credential.
func TestDashClustersEndpoint(t *testing.T) {
s := fakeSvc(
appCR("App", "hanzo", "cloud", "u1", "ghcr.io/hanzoai/cloud", "v1", "Running", 1, 1),
appCR("App", "hanzo", "iam", "u2", "ghcr.io/hanzoai/iam", "v1", "Running", 1, 1),
)
body := getJSON(t, s, "/v1/deploy/clusters")
items, ok := body["items"].([]any)
if !ok || len(items) != 1 {
t.Fatalf("clusters items = %v, want exactly one (in-cluster)", body["items"])
}
c0 := items[0].(map[string]any)
if c0["server"] != inClusterServer || c0["name"] != inClusterName {
t.Fatalf("cluster[0] = %v, want in-cluster", c0)
}
if cs, _ := c0["connectionState"].(map[string]any); cs["status"] != "Successful" {
t.Fatalf("connectionState = %v, want status Successful", c0["connectionState"])
}
if info, _ := c0["info"].(map[string]any); info["applicationsCount"].(float64) != 2 {
t.Fatalf("applicationsCount = %v, want 2", c0["info"])
}
// Credential-leak guard at the HTTP boundary.
raw, _ := json.Marshal(body)
for _, forbidden := range []string{"config", "bearerToken", "tlsClientConfig", "execProviderConfig"} {
if strings.Contains(string(raw), forbidden) {
t.Fatalf("clusters response leaked %q: %s", forbidden, raw)
}
}
}
// ── projects endpoint ─────────────────────────────────────────────────────────
// TestDashProjectsEndpoint_Synthesizes: with no AppProject CRD served, /projects
// synthesizes the distinct App-CR project set (default always present).
func TestDashProjectsEndpoint_Synthesizes(t *testing.T) {
s := fakeSvc(appCR("App", "hanzo", "cloud", "u1", "ghcr.io/hanzoai/cloud", "v1", "Running", 1, 1))
body := getJSON(t, s, "/v1/deploy/projects")
items, ok := body["items"].([]any)
if !ok || len(items) == 0 {
t.Fatalf("projects items = %v, want at least [default]", body["items"])
}
found := false
for _, it := range items {
if it.(map[string]any)["metadata"].(map[string]any)["name"] == "default" {
found = true
}
}
if !found {
t.Fatalf("projects missing 'default': %v", items)
}
}
// TestDashProjectsEndpoint_PrefersRealCRs: when real AppProject CRs are served,
// /projects lists THOSE (not synthesized ones) and surfaces only intended fields.
func TestDashProjectsEndpoint_PrefersRealCRs(t *testing.T) {
s := fakeSvc(
appCR("App", "hanzo", "cloud", "u1", "ghcr.io/hanzoai/cloud", "v1", "Running", 1, 1),
appProjectCR("team-a", "https://git.hanzo.ai/team-a/*"),
)
body := getJSON(t, s, "/v1/deploy/projects")
items := body["items"].([]any)
names := map[string]bool{}
for _, it := range items {
names[it.(map[string]any)["metadata"].(map[string]any)["name"].(string)] = true
}
if !names["team-a"] {
t.Fatalf("real AppProject 'team-a' not listed: %v", names)
}
// The synthesized 'default' must NOT appear when real projects are preferred.
if names["default"] {
t.Fatalf("synthesized 'default' present alongside real projects: %v", names)
}
raw, _ := json.Marshal(body)
if strings.Contains(string(raw), "secret-role") {
t.Fatalf("real AppProject leaked role metadata: %s", raw)
}
}
// ── auth: every new route is SuperAdmin-gated ─────────────────────────────────
// TestNewRoutesRequireAdmin: clusters, projects, and the stream must 403 without
// the SuperAdmin claim (fail-closed, no fleet/cluster data to an anonymous caller).
func TestNewRoutesRequireAdmin(t *testing.T) {
app := zip.New(zip.Config{Logger: luxlog.New("test")})
routes(app, fakeSvc())
for _, path := range []string{"/v1/deploy/clusters", "/v1/deploy/projects", "/v1/deploy/stream/applications"} {
// EventSource sends Accept: text/event-stream + Sec-Fetch-Dest: empty, so a
// non-admin gets a 403 (not the browser-document redirect) — assert both.
req := httptest.NewRequest("GET", path, nil)
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Sec-Fetch-Dest", "empty")
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
code := resp.StatusCode
_ = resp.Body.Close()
if code != http.StatusForbidden {
t.Errorf("GET %s WITHOUT admin = %d, want 403", path, code)
}
}
}
// ── stream: SSE headers ───────────────────────────────────────────────────────
// TestStreamSetsSSEHeaders: the handler sets the SSE response headers. Tested via
// the pure header helper so no body-stream goroutine is spawned (fasthttp starts
// the writer eagerly on SendStreamWriter — see setStreamHeaders).
func TestStreamSetsSSEHeaders(t *testing.T) {
app := zip.New(zip.Config{Logger: luxlog.New("test")})
c := app.TestCtx("GET", "/v1/deploy/stream/applications")
setStreamHeaders(c)
resp := c.Fiber().Response()
if ct := string(resp.Header.Peek("Content-Type")); ct != "text/event-stream" {
t.Errorf("Content-Type = %q, want text/event-stream", ct)
}
if cc := string(resp.Header.Peek("Cache-Control")); cc != "no-cache" {
t.Errorf("Cache-Control = %q, want no-cache", cc)
}
if conn := string(resp.Header.Peek("Connection")); conn != "keep-alive" {
t.Errorf("Connection = %q, want keep-alive", conn)
}
if b := string(resp.Header.Peek("X-Accel-Buffering")); b != "no" {
t.Errorf("X-Accel-Buffering = %q, want no (defeat proxy buffering)", b)
}
}
// TestStreamFailsClosedWithoutCluster: no k8s client → the handler 503s BEFORE
// any streaming (fail-closed), never a fabricated 200 stream.
func TestStreamFailsClosedWithoutCluster(t *testing.T) {
noK8s := &cloud.Service[state]{Base: cloud.Base{Log: luxlog.New("test")}, State: state{initErr: "no kubeconfig"}}
app := zip.New(zip.Config{Logger: luxlog.New("test")})
c := app.TestCtx("GET", "/v1/deploy/stream/applications")
err := dashStreamApps(noK8s, c)
if err == nil {
t.Fatal("dashStreamApps with no cluster client = nil error, want 503")
}
var he *zip.HTTPError
if !errors.As(err, &he) || he.Status != http.StatusServiceUnavailable {
t.Fatalf("stream no-cluster error = %v, want 503 HTTPError", err)
}
}
// ── stream: burst core ────────────────────────────────────────────────────────
// TestStreamBurstEmitsAddedPerApp: the burst emits one ArgoCD ADDED watch event
// per current App CR — the ApplicationWatchEvent envelope the SPA parses — with
// the argo Application shape (status.health/sync), projected identically to
// dashAppList.
func TestStreamBurstEmitsAddedPerApp(t *testing.T) {
s := fakeSvc(
appCR("App", "hanzo", "cloud", "u1", "ghcr.io/hanzoai/cloud", "v1", "Running", 1, 1),
appCR("App", "hanzo", "iam", "u2", "ghcr.io/hanzoai/iam", "v1", "Running", 1, 1),
)
var buf bytes.Buffer
w := bufio.NewWriter(&buf)
if ok := streamAppBurst(s, superScope(), context.Background(), w); !ok {
t.Fatal("streamAppBurst returned false (write failed) on a live buffer")
}
_ = w.Flush()
frames := parseSSE(buf.String())
if len(frames) != 2 {
t.Fatalf("burst emitted %d frames, want 2 (one per app): %q", len(frames), buf.String())
}
names := map[string]bool{}
for _, f := range frames {
var env struct {
Result applicationWatchEvent `json:"result"`
}
if err := json.Unmarshal([]byte(f), &env); err != nil {
t.Fatalf("frame is not the {result:{...}} envelope: %q (%v)", f, err)
}
if env.Result.Type != "ADDED" {
t.Fatalf("event type = %q, want ADDED", env.Result.Type)
}
if env.Result.Application.APIVersion != "argoproj.io/v1alpha1" || env.Result.Application.Kind != "Application" {
t.Fatalf("application is not an argo Application: %+v", env.Result.Application)
}
if env.Result.Application.Status.Health.Status == "" || env.Result.Application.Status.Sync.Status == "" {
t.Fatalf("application missing health/sync: %+v", env.Result.Application.Status)
}
names[env.Result.Application.Metadata.Name] = true
}
if !names["cloud"] || !names["iam"] {
t.Fatalf("burst missing an app: got %v", names)
}
}
// TestStreamBurstZeroAppsNoPanic: an empty fleet emits nothing, does not panic,
// and reports the client is still connected.
func TestStreamBurstZeroAppsNoPanic(t *testing.T) {
var buf bytes.Buffer
w := bufio.NewWriter(&buf)
if ok := streamAppBurst(fakeSvc(), superScope(), context.Background(), w); !ok {
t.Fatal("streamAppBurst on zero apps returned false, want true")
}
_ = w.Flush()
if buf.Len() != 0 {
t.Fatalf("zero-app burst wrote %q, want nothing", buf.String())
}
}
// TestStreamHonorsContextCancel: the watch loop returns promptly when the request
// context is canceled — no hung goroutine, no leaked watch. Cancel drives the
// return directly (the keep-alive interval is irrelevant), so this needs no global
// tuning and cannot race a concurrent stream.
func TestStreamHonorsContextCancel(t *testing.T) {
s := fakeSvc(appCR("App", "hanzo", "cloud", "u1", "ghcr.io/hanzoai/cloud", "v1", "Running", 1, 1))
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
streamApps(s, superScope(), ctx, bufio.NewWriter(io.Discard))
close(done)
}()
// Let the burst + watch establish, then cancel.
time.Sleep(40 * time.Millisecond)
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("streamApps did not return within 2s of context cancel (goroutine/watch leak)")
}
}
// TestForwardWatchSkipsTypedNilObjectNotFatal proves a malformed watch event
// carrying a typed-nil *unstructured.Unstructured is skipped, not fatal: the
// forwardWatch goroutine must not nil-deref on GetName() and a following valid
// event must still forward. A watch event is a system boundary and the read
// plane installs no panic recovery — a crash here would take down the process.
func TestForwardWatchSkipsTypedNilObjectNotFatal(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
fw := watch.NewFake()
events := make(chan streamEvent, 4)
go forwardWatch(ctx, superScope(), "hanzo", fw, events)
go func() {
// a typed-nil object as ADDED: e.Object.(*unstructured.Unstructured) is
// (nil, true), so the pre-guard code nil-derefs on GetName().
fw.Action(watch.Added, (*unstructured.Unstructured)(nil))
// then a valid App CR in a platform namespace — must still come through.
fw.Action(watch.Added, appCR("App", "hanzo", "cloud", "u1", "ghcr.io/hanzoai/cloud", "v1", "Running", 1, 1))
}()
select {
case ev := <-events:
if ev.obj == nil || ev.obj.GetName() != "cloud" {
t.Fatalf("expected the valid App CR to forward; got %+v", ev)
}
case <-time.After(2 * time.Second):
t.Fatal("valid event never arrived — forwardWatch died on the typed-nil object")
}
}
// ── watchType mapping ─────────────────────────────────────────────────────────
func TestWatchTypeMapsArgoVocab(t *testing.T) {
cases := map[string]string{"ADDED": "ADDED", "MODIFIED": "MODIFIED", "DELETED": "DELETED", "BOOKMARK": "", "ERROR": ""}
for in, want := range cases {
if got := watchType(watch.EventType(in)); got != want {
t.Errorf("watchType(%q) = %q, want %q", in, got, want)
}
}
}
// ── tiny local helpers (no new deps) ─────────────────────────────────────────
// parseSSE extracts the JSON payload of each `data: …` frame, ignoring keep-alive
// comment lines (`: …`).
func parseSSE(s string) []string {
var out []string
for _, block := range strings.Split(s, "\n\n") {
line := strings.TrimSpace(block)
if strings.HasPrefix(line, "data: ") {
out = append(out, strings.TrimPrefix(line, "data: "))
}
}
return out
}
+94 -1
View File
@@ -1,6 +1,7 @@
package deploy
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
@@ -23,7 +24,6 @@ func TestDeployRoutesRequireAdmin(t *testing.T) {
{"POST", "/v1/deploy/reconcile"},
// projection API — clean /v1/deploy/<resource> (no /api/, no inner /v1)
{"GET", "/v1/deploy/settings"},
{"GET", "/v1/deploy/session/userinfo"},
{"GET", "/v1/deploy/version"},
{"GET", "/v1/deploy/account/can-i/applications/get/x"},
{"GET", "/v1/deploy/applications"},
@@ -31,6 +31,9 @@ func TestDeployRoutesRequireAdmin(t *testing.T) {
{"GET", "/v1/deploy/applications/cloud/resource-tree"},
{"POST", "/v1/deploy/applications/cloud/sync"},
{"POST", "/v1/deploy/applications/cloud/rollback"},
// destination clusters + AppProjects (the applications view's side lists)
{"GET", "/v1/deploy/clusters"},
{"GET", "/v1/deploy/projects"},
}
for _, r := range guarded {
// WITHOUT admin → 403 (the guard, fail-closed).
@@ -66,4 +69,94 @@ func TestDeployRoutesRequireAdmin(t *testing.T) {
t.Error("/v1/deploy/health must stay public (probe-able without a JWT)")
}
_ = resp.Body.Close()
// Sign-in is DELIBERATELY public — these routes ARE how a browser becomes a
// SuperAdmin, so gating them behind SuperAdmin would be circular. They grant
// nothing: /login only redirects, /callback refuses anything but a token this
// deployment verifies for a member of the admin org.
for _, r := range []struct{ method, path string }{
{"GET", "/v1/deploy/login"},
{"GET", "/v1/deploy/callback"},
{"POST", "/v1/deploy/logout"},
} {
resp, err := app.Fiber().Test(httptest.NewRequest(r.method, r.path, nil))
if err != nil {
t.Fatalf("%s %s: %v", r.method, r.path, err)
}
code := resp.StatusCode
_ = resp.Body.Close()
if code == http.StatusForbidden {
t.Errorf("%s %s = 403; sign-in cannot require the role it grants", r.method, r.path)
}
}
// Logout must NOT be reachable as a GET: it changes state, and a cross-site
// top-level navigation carries a SameSite=Lax cookie.
resp, err = app.Fiber().Test(httptest.NewRequest("GET", "/v1/deploy/logout", nil))
if err != nil {
t.Fatalf("GET logout: %v", err)
}
code := resp.StatusCode
_ = resp.Body.Close()
if code != http.StatusMethodNotAllowed && code != http.StatusNotFound {
t.Errorf("GET /v1/deploy/logout = %d, want 404/405 (logout is POST-only)", code)
}
}
// TestUserInfoIsPublicBootstrap: the SPA's "am I signed in?" question must be
// answerable WITHOUT being signed in — it is an XHR client, so the document bounce
// never fires for it and a 403 here is a dead end with no route to sign-in. The
// anonymous answer carries the sign-in URL and NOTHING that identifies anyone.
func TestUserInfoIsPublicBootstrap(t *testing.T) {
app := zip.New(zip.Config{Logger: luxlog.New("test")})
routes(app, fakeSvc())
resp, err := app.Fiber().Test(httptest.NewRequest("GET", "/v1/deploy/session/userinfo", nil))
if err != nil {
t.Fatalf("userinfo: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("anonymous userinfo = %d, want 200", resp.StatusCode)
}
var body map[string]any
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
_ = resp.Body.Close()
if body["loggedIn"] != false {
t.Errorf("anonymous loggedIn = %v, want false", body["loggedIn"])
}
if body["loginUrl"] != "/v1/deploy/login" {
t.Errorf("loginUrl = %v, want /v1/deploy/login", body["loginUrl"])
}
// No identity may leak to an anonymous caller — not even an empty placeholder.
for _, k := range []string{"username", "iss", "groups", "email", "org", "logoutUrl"} {
if _, present := body[k]; present {
t.Errorf("anonymous userinfo leaked %q = %v", k, body[k])
}
}
// A cookie must never be minted by a read.
for _, ck := range resp.Cookies() {
if ck.Value != "" {
t.Errorf("userinfo minted a cookie %s=%q", ck.Name, ck.Value)
}
}
// A SuperAdmin gets the real identity on the same route.
req := httptest.NewRequest("GET", "/v1/deploy/session/userinfo", nil)
req.Header.Set("X-User-IsAdmin", "true")
req.Header.Set("X-User-Id", "cto")
resp2, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("admin userinfo: %v", err)
}
var admin map[string]any
if err := json.NewDecoder(resp2.Body).Decode(&admin); err != nil {
t.Fatalf("decode: %v", err)
}
_ = resp2.Body.Close()
if admin["loggedIn"] != true || admin["username"] != "cto" {
t.Errorf("admin userinfo = %v, want loggedIn:true username:cto", admin)
}
}
+46 -11
View File
@@ -18,12 +18,15 @@
// (the operator reconciles the rollout).
// POST /v1/deploy/{name}/sync — request an operator reconcile now.
//
// SECURITY — every route is SUPERADMIN ONLY, fail-closed, on the SAME predicate
// the rest of cloud uses (c.IsAdmin()): the plane reads and mutates SYSTEM Service
// CRs across the whole fleet, so a tenant must never reach it. Secret objects are
// never surfaced (no node, no manifest) so the tree can never leak materialized
// env. The user-facing per-org PaaS is /v1/platform; this is the platform-operator
// console the admin dashboard consumes.
// SECURITY — the projection READS are TENANT-SCOPED and the WRITES stay SuperAdmin-
// only, all fail-closed on the SAME identity boundary the rest of cloud trusts
// (resolveScope, scope.go — validated principal + injective provisioning.SanitizeOrg
// + the c.IsAdmin() SuperAdmin predicate): a SuperAdmin sees/mutates the whole fleet,
// a validated org member sees ONLY its own org's apps (hanzo.ai/org label), and the
// reconcile writes (sync/rollback) remain SuperAdmin-only. Secret objects are never
// surfaced (no node, no manifest) so the tree can never leak materialized env. The
// user-facing per-org PaaS is /v1/platform; this is the platform-operator console the
// admin dashboard consumes, now also serving a read-only per-org reflection.
//
// GitOps note (the follow-on seam): today the CR is the desired-state source and a
// rollback/rollout PATCHES it directly (P1's RegisterServiceReleaser), so deploys
@@ -118,17 +121,19 @@ type state struct {
dyn dynamic.Interface // nil when no kubeconfig resolved (fail-closed)
clientset kubernetes.Interface
initErr string
oauth oauth // sign-in configuration (login.go)
}
// Mount wires /v1/deploy/* onto app. Every handler gates on c.IsAdmin() first.
func Mount(app *zip.App, deps cloud.Deps) error {
return cloud.Mount(app, deps, "deploy", build, routes)
return cloud.Mount(app, deps, "deploy",
func(b cloud.Base) (state, error) { return build(b, newOAuth(deps)) }, routes)
}
// build resolves the in-process k8s clients (fail-closed: when no kubeconfig
// resolves the subsystem still mounts and every endpoint 503s honestly).
func build(b cloud.Base) (state, error) {
var st state
func build(b cloud.Base, o oauth) (state, error) {
st := state{oauth: o}
dyn, cs, err := newClients()
if err != nil {
st.initErr = err.Error()
@@ -136,7 +141,8 @@ func build(b cloud.Base) (state, error) {
} else {
st.dyn, st.clientset = dyn, cs
}
b.Log.Info("deploy control plane mounted", "prefix", "/v1/deploy", "k8s", st.dyn != nil, "brand", b.Brand, "env", b.Env)
b.Log.Info("deploy control plane mounted", "prefix", "/v1/deploy", "k8s", st.dyn != nil,
"brand", b.Brand, "env", b.Env, "iam", o.issuer, "client", o.clientID, "adminOrg", o.adminOrg)
return st, nil
}
@@ -145,6 +151,17 @@ func build(b cloud.Base) (state, error) {
func routes(app *zip.App, s *cloud.Service[state]) {
// Liveness — public (probe-able without a JWT).
app.Get("/v1/deploy/health", cloud.Handle(s, health))
// Sign-in — necessarily public: these three routes ARE how a browser gets an
// authenticated principal for this host. They grant nothing themselves; the
// session they mint is an IAM JWT the identity boundary re-verifies on every
// later request, and a principal outside the admin org is refused a cookie.
// See login.go.
app.Get(loginPath, cloud.Handle(s, login))
app.Get(callbackPath, cloud.Handle(s, callback))
// POST, not GET: signing out changes state, and a state-changing GET is
// reachable by a cross-site top-level navigation that a SameSite=Lax cookie
// still rides. See logout in login.go.
app.Post(logoutPath, cloud.Handle(s, logout))
// Engine (write) reconcile — the embedded gitops-engine that replaces
// universe-crs. Gated by DEPLOY_ENGINE_ENABLED; see engine_mount.go.
registerEngineRoutes(app, s)
@@ -156,15 +173,33 @@ func routes(app *zip.App, s *cloud.Service[state]) {
// guard wraps a handler with the SuperAdmin gate (fail-closed: a non-SuperAdmin is
// refused 403 before any cluster object is read or mutated), matching clients/paas.
//
// The gate itself is unchanged — c.IsAdmin() and nothing else, on the SanitizeIdentity
// -minted header no client can forge. Only the SHAPE of the refusal is negotiated: a
// browser NAVIGATION to a deploy URL is sent to the sign-in page (a 403 page with no
// way to sign in is a dead end), while every API call keeps its 403. wantsDocument
// decides, and it decides "no" unless the request positively identifies as a document
// GET — so the API contract, and every client that depends on the 403, is untouched.
func guard(s *cloud.Service[state], h zip.Handler) zip.Handler {
return func(c *zip.Ctx) error {
if !c.IsAdmin() {
return zip.ErrForbidden("SuperAdmin required")
return refuse(c) // the ONE fail-closed refusal (redirect a navigation, 403 an API call)
}
return h(c)
}
}
// currentPath is the path+query to return to after signing in. It is run through
// the same open-redirect guard as a caller-supplied returnTo — the value is
// server-derived, but there is exactly ONE rule for what a return path may be.
func currentPath(c *zip.Ctx) string {
p := c.Path()
if q := string(c.Fiber().Request().URI().QueryString()); q != "" {
p += "?" + q
}
return safeReturn(p)
}
// health is a REAL probe: the API server is reachable AND the App CRD is served.
// 200 only when both hold; 503 + the real reason otherwise. Not admin-gated —
// liveness must be probe-able without a JWT.
+20
View File
@@ -28,6 +28,7 @@ func fakeSvc(objs ...runtime.Object) *cloud.Service[state] {
hpaGVR: "HorizontalPodAutoscalerList",
pdbGVR: "PodDisruptionBudgetList",
configMapsGVR: "ConfigMapList",
appProjectGVR: "AppProjectList",
}, objs...)
return &cloud.Service[state]{Base: cloud.Base{Log: luxlog.New("test")}, State: state{dyn: dyn}}
}
@@ -72,6 +73,25 @@ func pod(ns, name, image string, labels map[string]any) *unstructured.Unstructur
}}
}
// appProjectCR builds a real argoproj.io/v1alpha1 AppProject CR (the "prefer real
// projects" path). sourceRepos are surfaced; anything else on the CR is not.
func appProjectCR(name string, sourceRepos ...string) *unstructured.Unstructured {
repos := make([]any, 0, len(sourceRepos))
for _, r := range sourceRepos {
repos = append(repos, r)
}
return &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "argoproj.io/v1alpha1", "kind": "AppProject",
"metadata": map[string]any{"name": name, "namespace": "argocd"},
"spec": map[string]any{
"sourceRepos": repos,
"destinations": []any{map[string]any{"server": inClusterServer, "namespace": "hanzo"}},
// A field the projection must NOT surface (roles carry token metadata).
"roles": []any{map[string]any{"name": "secret-role", "policies": []any{"p, proj:x:secret-role, *, *, *, allow"}}},
},
}}
}
// ── pure health ─────────────────────────────────────────────────────────────
func TestResourceHealth(t *testing.T) {
+227
View File
@@ -0,0 +1,227 @@
// detail.go — the three per-app DETAIL projections the ArgoCD SPA's application
// view calls (and 404-toasts when absent): sync windows, revision metadata, and
// the LIVE resource-tree stream. All THREE are TENANT-SCOPED exactly like
// dashApp/dashResourceTree — resolveScope + findNamespace decide visibility, so a
// normal org reads only its OWN apps' detail (a cross-tenant name is a clean 404,
// no existence oracle), a SuperAdmin reads the whole fleet, and an unvalidated
// caller fails closed. There is ONE scoping path (scope.go); nothing here forks it.
package deploy
import (
"bufio"
"context"
"encoding/json"
"net/http"
"time"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// ── sync windows ─────────────────────────────────────────────────────────────
// argoSyncWindows is v1alpha1 ApplicationSyncWindowState (models.ts
// ApplicationSyncWindowState). This platform runs no sync windows, so the
// projection is the permissive empty — nothing blocks a sync (canSync true, no
// active/assigned windows). Nil slices marshal to `null`, the exact shape the SPA
// reads.
type argoSyncWindows struct {
ActiveWindows []any `json:"activeWindows"`
AssignedWindows []any `json:"assignedWindows"`
CanSync bool `json:"canSync"`
}
// dashSyncWindows is GET /v1/deploy/applications/:name/syncwindows — the permissive
// empty ApplicationSyncWindowState, gated to the caller's own app (a cross-tenant
// name 404s before the static body is returned, so the endpoint discloses nothing
// about another tenant's fleet).
func dashSyncWindows(s *cloud.Service[state], c *zip.Ctx) error {
sc, ok := resolveScope(c)
if !ok {
return refuse(c)
}
if err := ready(s); err != nil {
return err
}
name := reqName(c)
if !appNameRE.MatchString(name) {
return zip.ErrBadRequest("name must be a DNS-1123 label")
}
// Existence + ownership check (discard the namespace): 404 a name that is not the
// caller's, so a tenant cannot probe whether another org runs an app of a given name.
if _, err := sc.findNamespace(s, c, name); err != nil {
return err
}
return c.JSON(http.StatusOK, argoSyncWindows{CanSync: true})
}
// ── revision metadata ────────────────────────────────────────────────────────
// argoRevisionMetadata is v1alpha1 RevisionMetadata (models.ts RevisionMetadata) —
// date is required (models.Time); author/tags/message/signatureInfo are optional.
type argoRevisionMetadata struct {
Author string `json:"author,omitempty"`
Date string `json:"date"`
Tags []string `json:"tags,omitempty"`
Message string `json:"message,omitempty"`
SignatureInfo string `json:"signatureInfo,omitempty"`
}
// maxRevisionLen bounds the reflected revision so an over-long path segment cannot
// bloat the response (the value is otherwise inert — JSON-escaped, never a shell/path arg).
const maxRevisionLen = 256
// dashRevisionMetadata is GET /v1/deploy/applications/:name/revisions/:revision/metadata.
//
// The App CR is IMAGE-based: the deploy is pinned to an image tag, not a git commit, and
// the projection's git source (git.hanzo.ai/hanzoai/universe) is the display-only manifest
// repo, NOT the app's own source — so there is no in-process commit to resolve a revision
// author/date/message from (clients/git exposes CloneURL + VerifyRef only, neither of which
// yields commit metadata for an arbitrary revision). Rather than 404 (the toast) or
// fabricate a git author, this returns an HONEST minimal RevisionMetadata: message = the
// revision (HEAD resolves to the CR's declared image tag), date = when the app was declared
// (the CR creation time), author = "" (none). Real git enrichment is a follow-on gated on
// the CR carrying a real git source + a clients/git CommitMetadata export.
func dashRevisionMetadata(s *cloud.Service[state], c *zip.Ctx) error {
sc, ok := resolveScope(c)
if !ok {
return refuse(c)
}
if err := ready(s); err != nil {
return err
}
name := reqName(c)
if !appNameRE.MatchString(name) {
return zip.ErrBadRequest("name must be a DNS-1123 label")
}
ns, err := sc.findNamespace(s, c, name)
if err != nil {
return err
}
cr, _, err := getAppCR(s, c.Context(), ns, name)
if err != nil {
return k8sErr(s, "get", err)
}
return c.JSON(http.StatusOK, revisionMetadataOf(cr, c.Param("revision")))
}
// revisionMetadataOf builds the honest minimal RevisionMetadata for an image-based App CR.
func revisionMetadataOf(cr *unstructured.Unstructured, revision string) argoRevisionMetadata {
if len(revision) > maxRevisionLen {
revision = revision[:maxRevisionLen]
}
message := revision
// HEAD (the SPA's default when no revision is pinned) resolves to the CR's declared tag.
if message == "" || message == "HEAD" {
if tag, _, _ := unstructured.NestedString(cr.Object, "spec", "image", "tag"); tag != "" {
message = tag
}
}
return argoRevisionMetadata{
Date: cr.GetCreationTimestamp().UTC().Format(time.RFC3339),
Message: message,
}
}
// ── live resource-tree stream ────────────────────────────────────────────────
// treeResult is the `{"result": …}` envelope the SPA unwraps for the resource-tree
// stream (watchResourceTree: JSON.parse(data).result) — the SAME envelope shape the
// applications stream uses.
type treeResult struct {
Result argoTree `json:"result"`
}
// dashStreamResourceTree is GET /v1/deploy/stream/applications/:name/resource-tree — the
// LIVE ApplicationTree as SSE. TENANT-SCOPED: resolveScope + findNamespace run BEFORE any
// emission (a cross-tenant name 404s with no SSE opened, an unvalidated caller 403s), then
// the tree is emitted once and refreshed on the keep-alive interval, honoring ctx cancel.
func dashStreamResourceTree(s *cloud.Service[state], c *zip.Ctx) error {
// Scope gate FIRST — nothing is emitted (SendStreamWriter is never reached) unless the
// caller is authorized for this specific app.
sc, ok := resolveScope(c)
if !ok {
return refuse(c)
}
if err := ready(s); err != nil {
return err
}
name := reqName(c)
if !appNameRE.MatchString(name) {
return zip.ErrBadRequest("name must be a DNS-1123 label")
}
ns, err := sc.findNamespace(s, c, name)
if err != nil {
return err // cross-tenant / unknown name → 404 BEFORE any stream is opened
}
// Capture the context BEFORE SendStreamWriter (its callback runs after this handler
// returns and must not touch c). c.Context() does NOT cancel on client disconnect; a
// failing flush inside the loop is the disconnect signal — mirrors dashStreamApps.
ctx := c.Context()
setStreamHeaders(c)
return c.SendStreamWriter(func(w *bufio.Writer) {
streamResourceTree(s, ctx, ns, name, w)
})
}
// streamResourceTree emits the app's ApplicationTree once, then re-emits it on the keep-alive
// interval (a cheap poll that doubles as the keep-alive — no multi-resource watch to leak),
// until the client disconnects (a failing write) or the context is canceled. Separated from
// the handler so it is unit-testable over a bytes.Buffer + a cancelable context.
func streamResourceTree(s *cloud.Service[state], ctx context.Context, ns, name string, w *bufio.Writer) {
if !emitResourceTree(s, ctx, ns, name, w) {
return // client gone during the initial emit
}
ticker := time.NewTicker(streamKeepalive)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if !emitResourceTree(s, ctx, ns, name, w) {
return
}
}
}
}
// emitResourceTree writes one ApplicationTree SSE frame (rebuilt from the live CR). If the
// CR read fails (app deleted / transient), it holds the connection open with a keep-alive
// comment rather than killing the stream. Returns false only when a write fails (client gone).
func emitResourceTree(s *cloud.Service[state], ctx context.Context, ns, name string, w *bufio.Writer) bool {
cr, _, err := getAppCR(s, ctx, ns, name)
if err != nil {
return writeKeepalive(w)
}
return writeTreeEvent(w, projectTree(buildTree(s, ctx, ns, name, cr)))
}
// writeTreeEvent writes one SSE frame — `data: {"result": <ApplicationTree>}` — and flushes.
// Returns false when the write/flush fails (client disconnected).
func writeTreeEvent(w *bufio.Writer, tree argoTree) bool {
payload, err := json.Marshal(treeResult{Result: tree})
if err != nil {
return true // unreachable for this shape; skip the frame rather than kill the stream
}
if _, err := w.WriteString("data: "); err != nil {
return false
}
if _, err := w.Write(payload); err != nil {
return false
}
if _, err := w.WriteString("\n\n"); err != nil {
return false
}
return w.Flush() == nil
}
// writeKeepalive writes one SSE keep-alive comment and flushes; false when the client is gone.
func writeKeepalive(w *bufio.Writer) bool {
if _, err := w.WriteString(": keep-alive\n\n"); err != nil {
return false
}
return w.Flush() == nil
}
+203
View File
@@ -0,0 +1,203 @@
package deploy
import (
"bufio"
"bytes"
"context"
"encoding/json"
"net/http"
"testing"
"time"
)
// ── sync windows ─────────────────────────────────────────────────────────────
// TestDashSyncWindows_TenantScoped: a caller reads the permissive-empty sync-window
// state for its OWN app; a cross-tenant name 404s (no oracle); a SuperAdmin reads the
// fleet; an unvalidated caller 403s; the caller's own app NEVER 404s.
func TestDashSyncWindows_TenantScoped(t *testing.T) {
s := twoTenantFleet()
// own app → 200 with the exact permissive-empty shape.
body := jsonBody(t, getAs(t, s, "/v1/deploy/applications/acme-web/syncwindows", orgHeaders("acme")))
if body["canSync"] != true {
t.Fatalf("canSync = %v, want true", body["canSync"])
}
if v, present := body["activeWindows"]; !present || v != nil {
t.Fatalf("activeWindows = %v (present=%v), want null", v, present)
}
if v, present := body["assignedWindows"]; !present || v != nil {
t.Fatalf("assignedWindows = %v (present=%v), want null", v, present)
}
// cross-tenant name → 404 (bravo cannot read acme's app's sync windows).
if r := getAs(t, s, "/v1/deploy/applications/acme-web/syncwindows", orgHeaders("bravo")); r.StatusCode != http.StatusNotFound {
t.Fatalf("bravo→acme syncwindows = %d, want 404", r.StatusCode)
_ = r.Body.Close()
}
// SuperAdmin reads a fleet app.
if r := getAs(t, s, "/v1/deploy/applications/cloud/syncwindows", map[string]string{"X-User-IsAdmin": "true"}); r.StatusCode != http.StatusOK {
t.Fatalf("admin cloud syncwindows = %d, want 200", r.StatusCode)
_ = r.Body.Close()
}
// unvalidated / forged org → 403 (fail closed), and empty org too.
for _, h := range []map[string]string{{"X-Org-Id": "acme"}, {"X-User-Id": "u"}} {
if r := getAs(t, s, "/v1/deploy/applications/acme-web/syncwindows", h); r.StatusCode != http.StatusForbidden {
t.Fatalf("unvalidated syncwindows (%v) = %d, want 403", h, r.StatusCode)
_ = r.Body.Close()
}
}
}
// ── revision metadata ────────────────────────────────────────────────────────
// TestDashRevisionMetadata_TenantScoped: a caller reads honest minimal metadata for its
// OWN app's revision (date always populated, message = the revision; HEAD → the declared
// tag); cross-tenant 404s; SuperAdmin reads the fleet; unvalidated 403s; NEVER 404 for the
// caller's own app.
func TestDashRevisionMetadata_TenantScoped(t *testing.T) {
s := twoTenantFleet()
// own app, explicit revision → message echoes the revision, date is populated.
body := jsonBody(t, getAs(t, s, "/v1/deploy/applications/acme-web/revisions/sha-abc123/metadata", orgHeaders("acme")))
if body["date"] == nil || body["date"] == "" {
t.Fatalf("date = %v, want a non-empty models.Time", body["date"])
}
if body["message"] != "sha-abc123" {
t.Fatalf("message = %v, want the revision sha-abc123", body["message"])
}
// HEAD → message resolves to the CR's declared image tag ("v1" from the fixture).
head := jsonBody(t, getAs(t, s, "/v1/deploy/applications/acme-web/revisions/HEAD/metadata", orgHeaders("acme")))
if head["message"] != "v1" {
t.Fatalf("HEAD message = %v, want the declared tag v1", head["message"])
}
// cross-tenant → 404.
if r := getAs(t, s, "/v1/deploy/applications/acme-web/revisions/HEAD/metadata", orgHeaders("bravo")); r.StatusCode != http.StatusNotFound {
t.Fatalf("bravo→acme revision metadata = %d, want 404", r.StatusCode)
_ = r.Body.Close()
}
// SuperAdmin reads a fleet app; unvalidated fails closed.
if r := getAs(t, s, "/v1/deploy/applications/cloud/revisions/HEAD/metadata", map[string]string{"X-User-IsAdmin": "true"}); r.StatusCode != http.StatusOK {
t.Fatalf("admin cloud revision metadata = %d, want 200", r.StatusCode)
_ = r.Body.Close()
}
if r := getAs(t, s, "/v1/deploy/applications/acme-web/revisions/HEAD/metadata", map[string]string{"X-Org-Id": "acme"}); r.StatusCode != http.StatusForbidden {
t.Fatalf("unvalidated revision metadata = %d, want 403", r.StatusCode)
_ = r.Body.Close()
}
}
// ── live resource-tree stream ────────────────────────────────────────────────
// TestDashStreamResourceTree_ScopeGateBeforeEmission: the scope gate runs BEFORE any SSE is
// opened — an unvalidated caller 403s and a cross-tenant name 404s, both as plain error
// responses (SendStreamWriter is never reached, so nothing is emitted).
func TestDashStreamResourceTree_ScopeGateBeforeEmission(t *testing.T) {
s := twoTenantFleet()
// forged org (no validated principal) → 403, no stream.
if r := getAs(t, s, "/v1/deploy/stream/applications/acme-web/resource-tree", map[string]string{"X-Org-Id": "acme"}); r.StatusCode != http.StatusForbidden {
t.Fatalf("forged-org tree stream = %d, want 403", r.StatusCode)
_ = r.Body.Close()
}
// cross-tenant name → 404, no stream.
if r := getAs(t, s, "/v1/deploy/stream/applications/acme-web/resource-tree", orgHeaders("bravo")); r.StatusCode != http.StatusNotFound {
t.Fatalf("bravo→acme tree stream = %d, want 404", r.StatusCode)
_ = r.Body.Close()
}
}
// TestStreamResourceTree_EmitsTreeOnce: the stream core emits the ApplicationTree as a
// `data: {"result": …}` frame (the envelope the SPA unwraps), then returns on ctx cancel.
func TestStreamResourceTree_EmitsTreeOnce(t *testing.T) {
s := twoTenantFleet()
var buf bytes.Buffer
w := bufio.NewWriter(&buf)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
streamResourceTree(s, ctx, "tenant-acme", "acme-web", w)
close(done)
}()
time.Sleep(40 * time.Millisecond) // let the initial synchronous emit land
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("streamResourceTree did not return within 2s of context cancel (leak)")
}
frames := parseSSE(buf.String())
if len(frames) < 1 {
t.Fatalf("no tree frame emitted: %q", buf.String())
}
var env struct {
Result argoTree `json:"result"`
}
if err := json.Unmarshal([]byte(frames[0]), &env); err != nil {
t.Fatalf("frame is not a {result:tree} envelope: %q (%v)", frames[0], err)
}
// The ApplicationTree shape the SPA renders: nodes/orphanedNodes/hosts marshal as arrays.
b, _ := json.Marshal(env.Result)
var m map[string]any
_ = json.Unmarshal(b, &m)
if _, ok := m["nodes"].([]any); !ok {
t.Fatalf("tree.nodes must be a JSON array, got %T", m["nodes"])
}
if _, ok := m["orphanedNodes"]; !ok {
t.Fatalf("tree.orphanedNodes must be present: %v", m)
}
}
// TestStreamResourceTree_HonorsCancel: the stream returns promptly on context cancel — no
// hung goroutine holding the keep-alive loop open.
func TestStreamResourceTree_HonorsCancel(t *testing.T) {
s := twoTenantFleet()
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
streamResourceTree(s, ctx, "tenant-acme", "acme-web", bufio.NewWriter(&bytes.Buffer{}))
close(done)
}()
time.Sleep(20 * time.Millisecond)
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("streamResourceTree did not return within 2s of cancel")
}
}
// TestRevisionMetadataOf_Honest: the pure builder always populates date, echoes the
// revision as message, and resolves HEAD to the declared image tag — never fabricating an
// author.
func TestRevisionMetadataOf_Honest(t *testing.T) {
cr := orgAppCR("tenant-acme", "acme-web", "acme", "storefront")
rm := revisionMetadataOf(cr, "sha-deadbeef")
if rm.Date == "" {
t.Fatal("date must always be populated (models.Time is required)")
}
if rm.Message != "sha-deadbeef" {
t.Fatalf("message = %q, want the revision", rm.Message)
}
if rm.Author != "" {
t.Fatalf("author = %q, want empty (no fabricated git author)", rm.Author)
}
// HEAD resolves to the declared image tag.
if got := revisionMetadataOf(cr, "HEAD"); got.Message != "v1" {
t.Fatalf("HEAD message = %q, want the declared tag v1", got.Message)
}
// over-long revision is bounded.
long := make([]byte, maxRevisionLen+50)
for i := range long {
long[i] = 'a'
}
if got := revisionMetadataOf(cr, string(long)); len(got.Message) != maxRevisionLen {
t.Fatalf("message len = %d, want bounded to %d", len(got.Message), maxRevisionLen)
}
}
+492
View File
@@ -0,0 +1,492 @@
// login.go — the sign-in round trip for the deploy plane at cd.hanzo.ai.
//
// THE PROBLEM. Every /v1/deploy route is SuperAdmin-gated on c.IsAdmin(), which
// SanitizeIdentity mints ONLY from a validated IAM principal whose org IS the
// reserved admin org. The dashboard SPA is served at cd.hanzo.ai/ and calls this
// plane same-origin — but the IAM session cookie is minted host-only on hanzo.id,
// so a session established at hanzo.id or admin.hanzo.ai is never presented to
// cd.hanzo.ai. With no sign-in of its own the whole surface 403s and there is no
// way in. This file IS the way in.
//
// GET /v1/deploy/login — start: redirect into IAM's authorize endpoint
// GET /v1/deploy/callback — finish: exchange the code, mint the session cookie
// GET /v1/deploy/logout — clear the session cookie
//
// WHAT IT MINTS — NOT A SECOND SESSION MECHANISM. The callback stores the IAM
// access-token JWT in the `__Host-hanzo_iam_token` cookie: the FIRST name in
// cloud's cookieTokenNames, which SanitizeIdentity already reads, independently
// verifies (signature/issuer/audience/expiry against the IAM JWKS) and turns into
// the same principal a Bearer would. So this adds exactly one thing — a way to PUT
// the token in the browser for this host. The gate, the validation, and the
// SuperAdmin predicate are untouched; a forged cookie is still just an invalid JWT,
// and a forged X-User-IsAdmin header is still stripped on ingress.
//
// MINT ONLY WHAT THIS DEPLOYMENT WILL ACCEPT. The callback runs the exchanged token
// through cloud's OWN validator (cloud.NewTokenValidator — the same JWKS, issuer set
// and audience allowlist the boundary uses) BEFORE writing the cookie, and makes the
// admin-org decision on those VERIFIED claims. This is not defence in depth against
// IAM; it is the thing that makes a misconfiguration fail FAST and LOUD. The audience
// allowlist is env-overridable (jwtAudiencesFromEnv REPLACES the baked default), so a
// deployment whose CLOUD_JWT_AUDIENCES / GATEWAY_ALLOWED_AUDIENCES omits this
// console's client_id would otherwise mint a cookie the boundary refuses on the very
// next request — 403 → document-bounce to sign-in → IAM session still live → instant
// code → mint → 403, looping until the browser gives up. Validating here turns that
// infinite loop into one clear error naming the real reason.
//
// PUBLIC CLIENT, PKCE. The deploy plane holds no client secret: it drives IAM's
// authorization-code flow with PKCE S256 (RFC 7636), which IAM accepts with an
// empty client_secret when the code carries a challenge. A secret is still sent
// when one is configured, for a deployment that registers a confidential client.
//
// CSRF. The `state` is a fresh 256-bit nonce echoed into a short-lived, HttpOnly,
// Secure, SameSite=Lax cookie alongside the PKCE verifier and the return path. The
// callback accepts a code ONLY when the returned state equals the cookie's nonce
// (constant time), so a login-CSRF — an attacker completing THEIR authorization in
// the victim's browser — is refused. The cookie is the only store, so the flow
// survives any replica handling the callback.
package deploy
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/hanzoai/cloud"
fiber "github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip"
)
const (
// loginPath / callbackPath / logoutPath are the three routes of the round trip.
// /v1/deploy/<resource>, like the rest of the plane — never an /api/ prefix.
loginPath = dashPrefix + "/login"
callbackPath = dashPrefix + "/callback"
logoutPath = dashPrefix + "/logout"
// sessionCookie is cloud's EXISTING session cookie name — cookieTokenNames[0]
// in middleware_identity.go. Writing it here is what makes SanitizeIdentity
// resolve a principal on the next request. Do not invent a second name.
//
// The __Host- prefix is a browser-enforced invariant, not decoration: a cookie
// so named may only be set Secure, Path=/ and with NO Domain, so a sibling
// *.hanzo.ai host cannot set a Domain=.hanzo.ai cookie of the same name to
// shadow this console's session.
sessionCookie = "__Host-hanzo_iam_token"
// flowCookie carries the one in-flight OAuth round trip (state nonce, PKCE
// verifier, return path). It is deleted the moment the callback reads it, and
// carries the same __Host- guarantee — a shadowed flow cookie would be a way
// to feed this console someone else's state nonce.
flowCookie = "__Host-hanzo_deploy_oauth"
// flowTTL bounds how long an unfinished sign-in stays resumable.
flowTTL = 10 * time.Minute
// sessionMaxTTL caps the session cookie regardless of what the token claims.
// An `exp` far in the future must not become a decade-long cookie; the token is
// re-validated on every request either way, so a shorter cookie costs only a
// re-sign-in.
sessionMaxTTL = 24 * time.Hour
// defaultClientID is the IAM application whose ORGANIZATION is the admin org,
// so a sign-in through it resolves users in `admin` — the only org whose
// members are SuperAdmins. hanzo-cloud is deliberately NOT used: it is owned by
// admin but its organization is `hanzo`, so it looks admin-org users up in the
// wrong org and never finds them.
defaultClientID = "admin-console"
)
// oauth is the sign-in configuration: where IAM is, who we are to it, and which
// org grants SuperAdmin. Resolved once at build time.
type oauth struct {
issuer string // IAM origin, e.g. https://hanzo.id
clientID string // IAM application client_id (organization == adminOrg)
clientSecret string // optional; empty ⟹ public client on PKCE alone
adminOrg string // the reserved org whose members are SuperAdmins
publicURL string // REQUIRED public origin of this console; "" disables sign-in
http *http.Client
// verify is cloud's own token validator (NewTokenValidator(issuer).Validate).
// It is a seam so a test can drive the round trip without a live JWKS; in the
// binary there is exactly one implementation, and a nil verify fails closed.
verify func(raw string) (cloud.VerifiedIdentity, error)
}
// newOAuth resolves the sign-in configuration from deps + env. The issuer comes
// from the SAME value the identity boundary validates tokens against
// (deps.IAMIssuer), and the verifier is built from that issuer, so a token this
// flow accepts is by construction a token cloud accepts.
func newOAuth(deps cloud.Deps) oauth {
issuer := strings.TrimRight(firstNonEmpty(deps.IAMIssuer, os.Getenv("IAM_ENDPOINT"), "https://hanzo.id"), "/")
return oauth{
issuer: issuer,
clientID: firstNonEmpty(os.Getenv("DEPLOY_IAM_CLIENT_ID"), defaultClientID),
clientSecret: os.Getenv("DEPLOY_IAM_CLIENT_SECRET"),
adminOrg: firstNonEmpty(os.Getenv("IAM_ADMIN_ORG"), "admin"),
publicURL: strings.TrimRight(firstNonEmpty(os.Getenv("DEPLOY_PUBLIC_URL"), os.Getenv("PUBLIC_ORIGIN")), "/"),
http: &http.Client{Timeout: 15 * time.Second},
verify: cloud.NewTokenValidator(issuer).Validate,
}
}
// oauthBase is the canonical IAM OAuth base: ${issuer}/v1/iam. IAM mounts
// authorize/token/userinfo under /v1/iam — never at the root, never under /api/.
func (o oauth) oauthBase() string { return o.issuer + "/v1/iam" }
// redirectURI is the OAuth redirect_uri, built from CONFIGURATION ONLY. It must be
// the byte-identical string in login (authorize) and callback (token exchange) —
// IAM compares them — and it must match a URI registered on the application.
//
// It is deliberately NOT derived from the request. Host and X-Forwarded-Proto are
// caller-controlled: behind the gateway Host is the internal cluster host (which
// IAM's allowlist rejects outright), and off-gateway a caller can set either freely.
// Deriving an OAuth redirect from attacker-controlled input is only ever saved by
// the registry's exact-match check — a second lock covering for a broken first one.
// So the public origin is required, and with none configured sign-in fails CLOSED
// with an error naming the knob, rather than guessing an origin from a header.
func (o oauth) redirectURI() (string, error) {
if o.publicURL == "" {
return "", fmt.Errorf("sign-in is not configured: set DEPLOY_PUBLIC_URL (or PUBLIC_ORIGIN) " +
"to this console's public origin, e.g. https://cd.hanzo.ai")
}
return o.publicURL + callbackPath, nil
}
// ── the round trip ───────────────────────────────────────────────────────────
// flow is the one in-flight sign-in, carried in flowCookie for the duration of the
// external hop. Nonce is echoed as `state`; Verifier is the PKCE secret that is
// never sent to the browser's address bar; Return is the already-validated
// same-host path to land on.
type flow struct {
Nonce string `json:"n"`
Verifier string `json:"v"`
Return string `json:"r"`
}
// login starts the round trip: mint a nonce + PKCE verifier, remember them in the
// flow cookie, and send the browser to IAM's authorize endpoint.
func login(s *cloud.Service[state], c *zip.Ctx) error {
redirect, err := s.State.oauth.redirectURI()
if err != nil {
s.Log.Error("deploy sign-in unavailable", "err", err)
return zip.Errorf(http.StatusServiceUnavailable, "%v", err)
}
nonce, err := randomToken()
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "login: %v", err)
}
verifier, err := randomToken()
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "login: %v", err)
}
f := flow{Nonce: nonce, Verifier: verifier, Return: safeReturn(c.Query("returnTo"))}
blob, err := json.Marshal(f)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "login: %v", err)
}
setCookie(c, flowCookie, base64.RawURLEncoding.EncodeToString(blob), int(flowTTL.Seconds()))
o := s.State.oauth
q := url.Values{
"client_id": {o.clientID},
"redirect_uri": {redirect},
"response_type": {"code"},
"scope": {"openid profile email"},
"state": {nonce},
"code_challenge": {pkceChallenge(verifier)},
"code_challenge_method": {"S256"},
}
return c.Redirect(http.StatusFound, o.oauthBase()+"/oauth/authorize?"+q.Encode())
}
// callback finishes the round trip: verify the state against the flow cookie,
// exchange the code (PKCE), REFUSE a principal that is not in the admin org, then
// write the session cookie and land on the return path.
//
// FAIL CLOSED, TWICE. The admin-org check here is not the authorization decision —
// SanitizeIdentity re-derives it from the verified JWT on every subsequent request,
// and guard() gates on that. It is here so a non-SuperAdmin is told plainly that
// they lack the role instead of being handed a session that silently 403s
// everything, and so no cookie is ever minted for a principal the plane will refuse.
func callback(s *cloud.Service[state], c *zip.Ctx) error {
redirect, cfgErr := s.State.oauth.redirectURI()
raw := c.Fiber().Cookies(flowCookie)
clearCookie(c, flowCookie) // single use: consumed whether or not it validates
if cfgErr != nil {
s.Log.Error("deploy sign-in unavailable", "err", cfgErr)
return zip.Errorf(http.StatusServiceUnavailable, "%v", cfgErr)
}
f, err := decodeFlow(raw)
if err != nil {
return zip.ErrBadRequest("no sign-in is in progress; start at " + loginPath)
}
// CSRF: the code is only accepted for the round trip THIS browser started.
if subtle.ConstantTimeCompare([]byte(f.Nonce), []byte(c.Query("state"))) != 1 {
return zip.ErrBadRequest("state mismatch; start again at " + loginPath)
}
if e := c.Query("error"); e != "" {
s.Log.Warn("deploy sign-in refused by IAM", "error", e)
return zip.ErrUnauthorized("sign-in was not completed")
}
code := c.Query("code")
if code == "" {
return zip.ErrBadRequest("missing authorization code")
}
access, err := s.State.oauth.exchange(c.Context(), code, redirect, f.Verifier)
if err != nil {
s.Log.Error("deploy sign-in code exchange failed", "err", err)
return zip.ErrUnauthorized("sign-in could not be completed")
}
// Verify the token the way THIS deployment's identity boundary will, before
// handing it to a browser. A token that fails here would be refused on the very
// next request, so minting a cookie for it would produce a sign-in loop rather
// than a session — fail now, with the real reason.
if s.State.oauth.verify == nil {
s.Log.Error("deploy sign-in has no token validator configured")
return zip.Errorf(http.StatusServiceUnavailable, "sign-in is not configured: no identity validator")
}
id, err := s.State.oauth.verify(access)
if err != nil {
s.Log.Error("deploy sign-in token failed validation", "err", err,
"issuer", s.State.oauth.issuer, "client", s.State.oauth.clientID)
return zip.ErrUnauthorized("the sign-in token was refused by this deployment's identity boundary (" +
err.Error() + "); check that " + s.State.oauth.clientID +
" is in the JWT audience allowlist (CLOUD_JWT_AUDIENCES / GATEWAY_ALLOWED_AUDIENCES) " +
"and that the issuer matches")
}
// SuperAdmin ⟺ the VERIFIED owner claim IS the reserved admin org. Not the
// `isAdmin` bit, which only says "admin of my own org" — conflating the two
// would be a privilege escalation.
if id.Owner != s.State.oauth.adminOrg {
s.Log.Warn("deploy sign-in refused: not a SuperAdmin", "user", id.User, "org", id.Owner)
return zip.ErrForbidden("SuperAdmin required: this console is limited to members of the " +
s.State.oauth.adminOrg + " organization")
}
maxAge := sessionMaxAge(id.Expiry)
if maxAge <= 0 {
return zip.ErrUnauthorized("the sign-in token has already expired")
}
setCookie(c, sessionCookie, access, maxAge)
s.Log.Info("deploy sign-in", "user", id.User, "org", id.Owner, "ttl", maxAge)
return c.Redirect(http.StatusFound, f.Return)
}
// logout clears the session cookie for this host. IAM's own session is untouched —
// this ends the console session only.
//
// It is a POST because it CHANGES STATE. As a GET it was reachable by a cross-site
// top-level navigation (an <img> or a link on any page), which a SameSite=Lax cookie
// still rides, so any site could sign a SuperAdmin out at will. A nuisance rather
// than a compromise, but a state-changing GET is a bug regardless; POST is not
// carried cross-site by a Lax cookie, so the class is closed.
func logout(s *cloud.Service[state], c *zip.Ctx) error {
clearCookie(c, sessionCookie)
return c.JSON(http.StatusOK, map[string]any{"loggedIn": false, "loginUrl": loginPath})
}
// exchange redeems the authorization code at IAM's token endpoint with the PKCE
// verifier. The client secret is sent only when one is configured (IAM accepts an
// empty secret for a code that carries a challenge).
func (o oauth) exchange(ctx context.Context, code, redirect, verifier string) (string, error) {
form := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {redirect},
"client_id": {o.clientID},
"code_verifier": {verifier},
}
if o.clientSecret != "" {
form.Set("client_secret", o.clientSecret)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, o.oauthBase()+"/oauth/token", strings.NewReader(form.Encode()))
if err != nil {
return "", fmt.Errorf("token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := o.http.Do(req)
if err != nil {
return "", fmt.Errorf("token endpoint: %w", err)
}
defer resp.Body.Close()
// Bound the read: a token response is small, and this body is attacker-
// influenced only insofar as IAM is reachable — cap it regardless.
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", fmt.Errorf("token response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("token endpoint status %d", resp.StatusCode)
}
var out struct {
AccessToken string `json:"access_token"`
Error string `json:"error"`
}
if err := json.Unmarshal(body, &out); err != nil {
return "", fmt.Errorf("token response: %w", err)
}
if out.Error != "" {
return "", fmt.Errorf("token endpoint: %s", out.Error)
}
if out.AccessToken == "" {
return "", fmt.Errorf("token endpoint returned no access_token")
}
return out.AccessToken, nil
}
// ── pure helpers (unit-tested) ───────────────────────────────────────────────
// safeReturn constrains the post-sign-in landing spot to a path on THIS host: it
// must be absolute-rooted, must not be protocol-relative ("//evil" or "/\evil",
// which browsers resolve as another origin), and must carry no scheme or host.
// Anything else collapses to "/". This is the open-redirect guard.
func safeReturn(raw string) string {
if raw == "" || raw[0] != '/' {
return "/"
}
if len(raw) > 1 && (raw[1] == '/' || raw[1] == '\\') {
return "/"
}
u, err := url.Parse(raw)
if err != nil || u.Scheme != "" || u.Host != "" {
return "/"
}
return u.RequestURI()
}
// wantsDocument reports whether a refused request is a BROWSER NAVIGATION, which
// should be bounced to the sign-in page rather than handed a 403 the user cannot
// act on. Everything else — every XHR, every API client, every request that does
// not positively identify as a document GET — keeps the 403, so the API contract
// is unchanged.
//
// It is deliberately conservative and ordered by trustworthiness: Sec-Fetch-Dest /
// Sec-Fetch-Mode are set by the browser and cannot be forged from page JS, so when
// present they DECIDE. Only when both are absent does it fall back to Accept.
// A non-GET is never redirected: bouncing a POST would silently drop a mutation.
func wantsDocument(method, dest, mode, accept, requestedWith string) bool {
if method != http.MethodGet {
return false
}
if dest != "" {
return dest == "document"
}
if mode != "" {
return mode == "navigate"
}
if strings.EqualFold(requestedWith, "XMLHttpRequest") {
return false
}
if strings.Contains(accept, "application/json") {
return false
}
return strings.Contains(accept, "text/html")
}
// pkceChallenge is the RFC 7636 S256 challenge: base64url(sha256(verifier)),
// unpadded — byte-identical to IAM's own pkceChallenge.
func pkceChallenge(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
// randomToken mints a 256-bit URL-safe secret (the state nonce and the PKCE
// verifier). A failure of the system CSPRNG fails the sign-in — never a weaker one.
func randomToken() (string, error) {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("entropy unavailable: %w", err)
}
return base64.RawURLEncoding.EncodeToString(b[:]), nil
}
// decodeFlow parses the flow cookie. A missing, malformed, or field-empty cookie is
// an error — the callback then has nothing to compare `state` against and refuses.
func decodeFlow(raw string) (flow, error) {
var f flow
if raw == "" {
return f, fmt.Errorf("no flow cookie")
}
blob, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil {
return f, fmt.Errorf("flow cookie: %w", err)
}
if err := json.Unmarshal(blob, &f); err != nil {
return f, fmt.Errorf("flow cookie: %w", err)
}
if f.Nonce == "" || f.Verifier == "" {
return f, fmt.Errorf("flow cookie is incomplete")
}
// Re-validate on the way out: the return path is re-checked against the same
// open-redirect rule that admitted it, so a tampered cookie cannot bounce the
// browser off-host.
f.Return = safeReturn(f.Return)
return f, nil
}
// sessionMaxAge is the session cookie's lifetime, derived from the token's VERIFIED
// expiry so the cookie dies with the credential it carries. It is bounded at both
// ends and never guesses:
//
// - already expired (or no expiry proven) → 0, and the caller refuses the sign-in.
// There is no "fall back to 8 hours" — a fallback for an expired token mints a
// cookie that cannot work, which is precisely the mint-then-refuse loop this
// whole path exists to prevent.
// - absurdly far future → clamped to sessionMaxTTL, so a mis-issued exp cannot
// become a decade-long cookie.
func sessionMaxAge(expiry time.Time) int {
if expiry.IsZero() {
return 0
}
remaining := time.Until(expiry)
if remaining <= 0 {
return 0
}
if remaining > sessionMaxTTL {
remaining = sessionMaxTTL
}
return int(remaining.Seconds())
}
// ── cookies ──────────────────────────────────────────────────────────────────
// setCookie writes a host-only, HttpOnly, Secure, SameSite=Lax cookie.
//
// HttpOnly: page JS never touches the session token. Secure: it never rides plain
// HTTP. Lax (not Strict): the sign-in lands here via a top-level redirect FROM
// hanzo.id, and Strict would withhold the cookie on that first cross-site
// navigation, so the user would arrive still signed out. Lax is the correct
// setting for an OAuth round trip and still withholds the cookie from cross-site
// subrequests. No Domain attribute: the cookie stays host-only to this console and
// is never broadcast to sibling *.hanzo.ai hosts.
func setCookie(c *zip.Ctx, name, value string, maxAge int) {
c.Fiber().Res().Cookie(&fiber.Cookie{
Name: name, Value: value, Path: "/",
HTTPOnly: true, Secure: true, SameSite: fiber.CookieSameSiteLaxMode,
MaxAge: maxAge,
})
}
func clearCookie(c *zip.Ctx, name string) { setCookie(c, name, "", -1) }
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
+697
View File
@@ -0,0 +1,697 @@
package deploy
import (
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
luxlog "github.com/luxfi/log"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
)
// ── open redirect ────────────────────────────────────────────────────────────
// TestSafeReturn is the open-redirect guard: a return path may only be a path on
// THIS host. Every off-host shape collapses to "/".
func TestSafeReturn(t *testing.T) {
cases := []struct{ in, want string }{
// legitimate same-host paths survive, query included.
{"/", "/"},
{"/applications", "/applications"},
{"/applications?name=cloud&view=tree", "/applications?name=cloud&view=tree"},
// empty / relative → default.
{"", "/"},
{"applications", "/"},
// protocol-relative: the browser resolves these as ANOTHER ORIGIN.
{"//evil.example", "/"},
{"//evil.example/path", "/"},
{`/\evil.example`, "/"},
{`/\/evil.example`, "/"},
// absolute URLs, any scheme.
{"https://evil.example/x", "/"},
{"http://evil.example", "/"},
{"javascript:alert(1)", "/"},
{"data:text/html,x", "/"},
// host-relative with userinfo tricks.
{"https://cd.hanzo.ai@evil.example", "/"},
}
for _, c := range cases {
if got := safeReturn(c.in); got != c.want {
t.Errorf("safeReturn(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// ── content negotiation: redirect vs 403 ─────────────────────────────────────
// TestWantsDocument pins the ONE rule that decides whether a refusal is a
// sign-in redirect or a 403. It must answer "no" for everything that is not
// positively a browser document GET — the API contract depends on it.
func TestWantsDocument(t *testing.T) {
cases := []struct {
name string
method, dest, mode, accept, requested string
want bool
}{
// Browser navigation — the only "yes" cases.
{"navigation (Sec-Fetch-Dest)", "GET", "document", "navigate", "text/html,*/*", "", true},
{"navigation (mode only)", "GET", "", "navigate", "text/html", "", true},
{"legacy browser, html accept", "GET", "", "", "text/html,application/xhtml+xml", "", true},
// Browser subresource/XHR — Sec-Fetch-* decides, and it says no.
{"fetch from page JS", "GET", "empty", "cors", "application/json", "", false},
{"fetch that lies via Accept", "GET", "empty", "cors", "text/html", "", false},
{"same-origin xhr", "GET", "empty", "same-origin", "*/*", "", false},
// Non-browser API clients.
{"curl (no headers)", "GET", "", "", "", "", false},
{"json client", "GET", "", "", "application/json", "", false},
{"wildcard accept", "GET", "", "", "*/*", "", false},
{"legacy xhr header", "GET", "", "", "text/html", "XMLHttpRequest", false},
{"html+json accept prefers api", "GET", "", "", "text/html,application/json", "", false},
// A mutation is NEVER redirected — bouncing a POST silently drops it.
{"POST navigation", "POST", "document", "navigate", "text/html", "", false},
{"PUT navigation", "PUT", "document", "navigate", "text/html", "", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := wantsDocument(c.method, c.dest, c.mode, c.accept, c.requested); got != c.want {
t.Errorf("wantsDocument(%q,%q,%q,%q,%q) = %v, want %v",
c.method, c.dest, c.mode, c.accept, c.requested, got, c.want)
}
})
}
}
// TestGuardRefusesNonAdmin drives the negotiation through the REAL router: a
// non-SuperAdmin never reaches a handler — an API call gets 403, a browser
// navigation gets bounced to sign-in. Neither is ever served the data.
func TestGuardRefusesNonAdmin(t *testing.T) {
app := zip.New(zip.Config{Logger: luxlog.New("test")})
routes(app, fakeSvc())
// API call (no Accept, the shape every API client and the existing e2e sends).
resp := do(t, app, httptest.NewRequest("GET", "/v1/deploy/applications", nil))
if resp.StatusCode != http.StatusForbidden {
t.Errorf("API call without admin = %d, want 403", resp.StatusCode)
}
// XHR that asks for HTML must STILL be 403 — Sec-Fetch-Dest decides.
req := httptest.NewRequest("GET", "/v1/deploy/applications", nil)
req.Header.Set("Accept", "text/html")
req.Header.Set("Sec-Fetch-Dest", "empty")
if resp := do(t, app, req); resp.StatusCode != http.StatusForbidden {
t.Errorf("XHR without admin = %d, want 403", resp.StatusCode)
}
// Browser navigation → 302 to the sign-in page, carrying where to come back to.
req = httptest.NewRequest("GET", "/v1/deploy/applications?env=main", nil)
req.Header.Set("Sec-Fetch-Dest", "document")
req.Header.Set("Accept", "text/html")
resp = do(t, app, req)
if resp.StatusCode != http.StatusFound {
t.Fatalf("navigation without admin = %d, want 302", resp.StatusCode)
}
loc, err := url.Parse(resp.Header.Get("Location"))
if err != nil {
t.Fatalf("Location: %v", err)
}
if loc.Path != loginPath {
t.Errorf("redirect path = %q, want %q", loc.Path, loginPath)
}
if got := loc.Query().Get("returnTo"); got != "/v1/deploy/applications?env=main" {
t.Errorf("returnTo = %q, want the originating path+query", got)
}
// A mutation is refused with 403, never redirected.
req = httptest.NewRequest("POST", "/v1/deploy/applications/cloud/sync", nil)
req.Header.Set("Sec-Fetch-Dest", "document")
req.Header.Set("Accept", "text/html")
if resp := do(t, app, req); resp.StatusCode != http.StatusForbidden {
t.Errorf("POST without admin = %d, want 403 (never a redirect)", resp.StatusCode)
}
}
// ── PKCE ─────────────────────────────────────────────────────────────────────
// TestPKCEChallenge pins the S256 transform against the RFC 7636 Appendix B
// vector, so it stays byte-identical to IAM's own pkceChallenge.
func TestPKCEChallenge(t *testing.T) {
const (
verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
)
if got := pkceChallenge(verifier); got != challenge {
t.Errorf("pkceChallenge = %q, want the RFC 7636 vector %q", got, challenge)
}
// The challenge is not the verifier (the whole point of S256).
if pkceChallenge("x") == "x" {
t.Error("challenge must never equal the verifier")
}
}
// TestRandomTokenIsUnique guards against a constant/predictable state nonce.
func TestRandomTokenIsUnique(t *testing.T) {
seen := map[string]bool{}
for i := 0; i < 100; i++ {
tok, err := randomToken()
if err != nil {
t.Fatalf("randomToken: %v", err)
}
if len(tok) < 43 {
t.Fatalf("randomToken = %q, want >= 256 bits of entropy", tok)
}
if seen[tok] {
t.Fatal("randomToken repeated a value")
}
seen[tok] = true
}
}
// ── login: the authorize hop ─────────────────────────────────────────────────
// TestLoginRedirectsToIAM asserts the authorize URL is well formed, carries PKCE,
// and that the state it publishes is the SAME nonce it stored in the flow cookie.
func TestLoginRedirectsToIAM(t *testing.T) {
app, _ := signinApp(t, "https://iam.test")
req := httptest.NewRequest("GET", "/v1/deploy/login?returnTo=/applications", nil)
req.Host = "cd.hanzo.ai"
req.Header.Set("X-Forwarded-Proto", "https")
resp := do(t, app, req)
if resp.StatusCode != http.StatusFound {
t.Fatalf("login = %d, want 302", resp.StatusCode)
}
loc, err := url.Parse(resp.Header.Get("Location"))
if err != nil {
t.Fatalf("Location: %v", err)
}
if want := "https://iam.test/v1/iam/oauth/authorize"; loc.Scheme+"://"+loc.Host+loc.Path != want {
t.Errorf("authorize endpoint = %q, want %q", loc.Scheme+"://"+loc.Host+loc.Path, want)
}
q := loc.Query()
if q.Get("client_id") != defaultClientID {
t.Errorf("client_id = %q, want %q (the app whose organization is the admin org)", q.Get("client_id"), defaultClientID)
}
if q.Get("response_type") != "code" {
t.Errorf("response_type = %q, want code", q.Get("response_type"))
}
if q.Get("redirect_uri") != "https://cd.hanzo.ai/v1/deploy/callback" {
t.Errorf("redirect_uri = %q", q.Get("redirect_uri"))
}
if q.Get("code_challenge_method") != "S256" {
t.Errorf("code_challenge_method = %q, want S256", q.Get("code_challenge_method"))
}
if q.Get("code_challenge") == "" {
t.Error("no code_challenge: the flow is not PKCE-protected")
}
// The flow cookie must exist, be HttpOnly, and its nonce must be the state.
f, cookie := flowFrom(t, resp)
if !cookie.HttpOnly || !cookie.Secure {
t.Errorf("flow cookie HttpOnly=%v Secure=%v, want both true", cookie.HttpOnly, cookie.Secure)
}
if f.Nonce != q.Get("state") {
t.Errorf("state %q != flow cookie nonce %q", q.Get("state"), f.Nonce)
}
if pkceChallenge(f.Verifier) != q.Get("code_challenge") {
t.Error("published code_challenge does not derive from the stored verifier")
}
if f.Return != "/applications" {
t.Errorf("stored return = %q, want /applications", f.Return)
}
// The verifier must never be published to the address bar.
if strings.Contains(resp.Header.Get("Location"), f.Verifier) {
t.Error("PKCE verifier leaked into the authorize URL")
}
}
// TestLoginRejectsOffHostReturnTo: an attacker-supplied returnTo cannot make the
// completed sign-in land off-host.
func TestLoginRejectsOffHostReturnTo(t *testing.T) {
app, _ := signinApp(t, "https://iam.test")
req := httptest.NewRequest("GET", "/v1/deploy/login?returnTo=https://evil.example/steal", nil)
req.Host = "cd.hanzo.ai"
resp := do(t, app, req)
f, _ := flowFrom(t, resp)
if f.Return != "/" {
t.Errorf("stored return = %q, want / (off-host returnTo must be dropped)", f.Return)
}
}
// ── callback: the exchange ───────────────────────────────────────────────────
// TestCallbackRequiresMatchingState is the login-CSRF guard: without the flow
// cookie this browser started, no code is redeemed and no session is minted.
func TestCallbackRequiresMatchingState(t *testing.T) {
app, iam := signinApp(t, "")
// No flow cookie at all.
resp := do(t, app, httptest.NewRequest("GET", "/v1/deploy/callback?code=c&state=s", nil))
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("callback with no flow cookie = %d, want 400", resp.StatusCode)
}
assertNoSession(t, resp)
// Flow cookie present, but the returned state is someone else's.
req := httptest.NewRequest("GET", "/v1/deploy/callback?code=c&state=attacker-state", nil)
req.AddCookie(&http.Cookie{Name: flowCookie, Value: encodeFlow(flow{Nonce: "real-nonce", Verifier: "v", Return: "/"})})
resp = do(t, app, req)
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("callback with mismatched state = %d, want 400", resp.StatusCode)
}
assertNoSession(t, resp)
// A garbage cookie is not a flow.
req = httptest.NewRequest("GET", "/v1/deploy/callback?code=c&state=x", nil)
req.AddCookie(&http.Cookie{Name: flowCookie, Value: "!!!not-base64!!!"})
if resp := do(t, app, req); resp.StatusCode != http.StatusBadRequest {
t.Errorf("callback with corrupt flow cookie = %d, want 400", resp.StatusCode)
}
if iam.calls != 0 {
t.Errorf("IAM token endpoint was called %d times for refused callbacks, want 0", iam.calls)
}
}
// TestCallbackRefusesNonAdminOrg: a VALID sign-in by a user outside the admin org
// mints NO session. The console refuses the role plainly rather than handing out a
// cookie that 403s everything.
func TestCallbackRefusesNonAdminOrg(t *testing.T) {
app, iam := signinApp(t, "")
iam.token = fakeJWT("hanzo", "someone", time.Hour) // a real user, wrong org
iam.verified = cloud.VerifiedIdentity{Owner: "hanzo", User: "someone", Expiry: time.Now().Add(time.Hour)}
resp := completeSignin(t, app)
if resp.StatusCode != http.StatusForbidden {
t.Errorf("callback for a non-admin-org principal = %d, want 403", resp.StatusCode)
}
assertNoSession(t, resp)
}
// TestCallbackRefusesUnverifiableToken is the MED-1 loop-breaker: a token this
// deployment's identity boundary will NOT accept (audience allowlist that omits the
// console's client_id, wrong issuer, expired) must fail HERE, once, with the real
// reason — never become a cookie that is refused on the next request, bounced back
// to sign-in, and re-minted forever.
func TestCallbackRefusesUnverifiableToken(t *testing.T) {
app, iam := signinApp(t, "")
// The exact shape of the misconfiguration: aud=admin-console, allowlist without it.
iam.verifyErr = errors.New(`claims: square/go-jose/jwt: validation failed, invalid audience claim (aud)`)
resp := completeSignin(t, app)
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("callback with an unverifiable token = %d, want 401", resp.StatusCode)
}
assertNoSession(t, resp)
// The error must NAME the knob, or an operator has nothing to act on.
body, _ := io.ReadAll(resp.Body)
for _, want := range []string{"audience", defaultClientID} {
if !strings.Contains(string(body), want) {
t.Errorf("error body does not mention %q: %s", want, body)
}
}
}
// TestCallbackRefusesAlreadyExpiredToken: an expired credential must not become an
// 8-hour cookie. There is no fallback lifetime — a cookie that cannot work is the
// loop, not a mitigation.
func TestCallbackRefusesAlreadyExpiredToken(t *testing.T) {
app, iam := signinApp(t, "")
iam.verified = cloud.VerifiedIdentity{Owner: "admin", User: "cto", Expiry: time.Now().Add(-time.Minute)}
resp := completeSignin(t, app)
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("callback with an expired token = %d, want 401", resp.StatusCode)
}
assertNoSession(t, resp)
}
// TestSessionMaxAge pins the clamp at both ends (RED LOW-2).
func TestSessionMaxAge(t *testing.T) {
cases := []struct {
name string
expiry time.Time
want func(int) bool
desc string
}{
{"no expiry proven", time.Time{}, func(n int) bool { return n == 0 }, "0"},
{"already expired", time.Now().Add(-time.Hour), func(n int) bool { return n == 0 }, "0"},
{"expired by a second", time.Now().Add(-time.Second), func(n int) bool { return n == 0 }, "0"},
{"normal hour", time.Now().Add(time.Hour), func(n int) bool { return n > 3500 && n <= 3600 }, "~3600"},
{"absurd future", time.Now().Add(292 * 365 * 24 * time.Hour), func(n int) bool { return n == int(sessionMaxTTL.Seconds()) }, "clamped to sessionMaxTTL"},
{"just over the cap", time.Now().Add(sessionMaxTTL + time.Hour), func(n int) bool { return n == int(sessionMaxTTL.Seconds()) }, "clamped to sessionMaxTTL"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := sessionMaxAge(c.expiry); !c.want(got) {
t.Errorf("sessionMaxAge = %d, want %s", got, c.desc)
}
})
}
}
// TestSignInFailsClosedWithoutPublicOrigin (RED MED-3): with no configured public
// origin the OAuth hop refuses rather than deriving a redirect_uri from the
// caller-controlled Host / X-Forwarded-Proto headers.
func TestSignInFailsClosedWithoutPublicOrigin(t *testing.T) {
svc := fakeSvc()
svc.State.oauth = oauth{
issuer: "https://iam.test", clientID: defaultClientID, adminOrg: "admin",
http: &http.Client{Timeout: time.Second},
verify: func(string) (cloud.VerifiedIdentity, error) { return cloud.VerifiedIdentity{}, nil },
} // publicURL deliberately empty
app := zip.New(zip.Config{Logger: luxlog.New("test")})
routes(app, svc)
// A forged Host must NOT be adopted as the origin.
req := httptest.NewRequest("GET", "/v1/deploy/login", nil)
req.Host = "evil.example"
req.Header.Set("X-Forwarded-Proto", "http")
resp := do(t, app, req)
if resp.StatusCode != http.StatusServiceUnavailable {
t.Errorf("login with no configured origin = %d, want 503", resp.StatusCode)
}
if loc := resp.Header.Get("Location"); strings.Contains(loc, "evil.example") {
t.Errorf("redirect adopted the forged Host: %q", loc)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "DEPLOY_PUBLIC_URL") {
t.Errorf("error must name the knob to set, got: %s", body)
}
// The callback refuses for the same reason, and mints nothing.
cb := httptest.NewRequest("GET", "/v1/deploy/callback?code=c&state=s", nil)
cb.Host = "evil.example"
resp = do(t, app, cb)
if resp.StatusCode != http.StatusServiceUnavailable {
t.Errorf("callback with no configured origin = %d, want 503", resp.StatusCode)
}
assertNoSession(t, resp)
}
// TestLogoutClearsSession: POST clears the cookie; the route is not a GET.
func TestLogoutClearsSession(t *testing.T) {
app, _ := signinApp(t, "https://iam.test")
resp := do(t, app, httptest.NewRequest("POST", "/v1/deploy/logout", nil))
if resp.StatusCode != http.StatusOK {
t.Fatalf("logout = %d, want 200", resp.StatusCode)
}
var cleared bool
for _, ck := range resp.Cookies() {
if ck.Name == sessionCookie && ck.Value == "" && ck.MaxAge < 0 {
cleared = true
}
}
if !cleared {
t.Error("logout did not clear the session cookie")
}
// A cross-site top-level navigation must not be able to sign anyone out.
if resp := do(t, app, httptest.NewRequest("GET", "/v1/deploy/logout", nil)); resp.StatusCode == http.StatusOK {
t.Error("GET /v1/deploy/logout succeeded; logout must be POST-only")
}
}
// TestCookiesCarryHostPrefix (RED LOW-3): both cookies use the __Host- prefix, which
// the browser will only honour for Secure, Path=/, Domain-less cookies — so a
// sibling *.hanzo.ai host cannot shadow them with a Domain=.hanzo.ai cookie.
func TestCookiesCarryHostPrefix(t *testing.T) {
if !strings.HasPrefix(sessionCookie, "__Host-") {
t.Errorf("session cookie %q lacks the __Host- prefix", sessionCookie)
}
if !strings.HasPrefix(flowCookie, "__Host-") {
t.Errorf("flow cookie %q lacks the __Host- prefix", flowCookie)
}
// The session name must be one cloud's identity boundary actually reads.
if sessionCookie != "__Host-hanzo_iam_token" {
t.Errorf("session cookie %q is not in cloud's cookieTokenNames", sessionCookie)
}
app, _ := signinApp(t, "")
resp := completeSignin(t, app)
for _, ck := range resp.Cookies() {
if !strings.HasPrefix(ck.Name, "__Host-") {
continue
}
// The __Host- contract, enforced here so a future edit cannot silently
// break it (a browser would then reject the cookie outright).
if !ck.Secure || ck.Path != "/" || ck.Domain != "" {
t.Errorf("%s violates the __Host- contract: Secure=%v Path=%q Domain=%q",
ck.Name, ck.Secure, ck.Path, ck.Domain)
}
}
}
// TestCallbackMintsSessionForSuperAdmin is the happy path: the session cookie is
// the token, under cloud's EXISTING cookie name, hardened, and the browser lands
// on the remembered path.
func TestCallbackMintsSessionForSuperAdmin(t *testing.T) {
app, iam := signinApp(t, "")
iam.token = fakeJWT("admin", "cto", 2*time.Hour)
resp := completeSignin(t, app)
if resp.StatusCode != http.StatusFound {
t.Fatalf("callback = %d, want 302", resp.StatusCode)
}
if got := resp.Header.Get("Location"); got != "/applications" {
t.Errorf("landed on %q, want the remembered /applications", got)
}
var sess *http.Cookie
for _, ck := range resp.Cookies() {
if ck.Name == sessionCookie {
sess = ck
}
if ck.Name == flowCookie && ck.MaxAge >= 0 {
t.Error("flow cookie must be cleared once consumed")
}
}
if sess == nil {
t.Fatalf("no %s cookie minted", sessionCookie)
}
if sess.Value != iam.token {
t.Error("session cookie must carry the IAM access token verbatim (cloud re-validates it)")
}
if !sess.HttpOnly {
t.Error("session cookie must be HttpOnly (page JS must never read the token)")
}
if !sess.Secure {
t.Error("session cookie must be Secure")
}
if sess.SameSite != http.SameSiteLaxMode {
t.Errorf("session cookie SameSite = %v, want Lax (Strict breaks the OAuth return hop)", sess.SameSite)
}
if sess.Domain != "" {
t.Errorf("session cookie Domain = %q, want host-only (never shared with sibling hosts)", sess.Domain)
}
if sess.MaxAge <= 0 || sess.MaxAge > int((2*time.Hour).Seconds()) {
t.Errorf("session MaxAge = %d, want bounded by the token's own expiry", sess.MaxAge)
}
// The exchange used PKCE and the code, with no secret configured.
if iam.form.Get("code_verifier") == "" {
t.Error("token exchange sent no code_verifier")
}
if iam.form.Get("grant_type") != "authorization_code" {
t.Errorf("grant_type = %q", iam.form.Get("grant_type"))
}
if iam.form.Get("redirect_uri") != "https://cd.hanzo.ai/v1/deploy/callback" {
t.Errorf("exchange redirect_uri = %q, must match the authorize one byte for byte", iam.form.Get("redirect_uri"))
}
}
// TestCallbackFailsClosedOnExchangeError: IAM refusing the code mints no session.
func TestCallbackFailsClosedOnExchangeError(t *testing.T) {
app, iam := signinApp(t, "")
iam.status = http.StatusBadRequest
resp := completeSignin(t, app)
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("callback with a rejected code = %d, want 401", resp.StatusCode)
}
assertNoSession(t, resp)
}
// ── flow cookie ──────────────────────────────────────────────────────────────
func TestDecodeFlow(t *testing.T) {
// Round trip.
f, err := decodeFlow(encodeFlow(flow{Nonce: "n", Verifier: "v", Return: "/x"}))
if err != nil || f.Nonce != "n" || f.Verifier != "v" || f.Return != "/x" {
t.Fatalf("round trip = (%+v, %v)", f, err)
}
// A tampered return path is re-checked, not trusted.
f, err = decodeFlow(encodeFlow(flow{Nonce: "n", Verifier: "v", Return: "https://evil.example"}))
if err != nil {
t.Fatalf("decodeFlow: %v", err)
}
if f.Return != "/" {
t.Errorf("tampered return = %q, want / (re-validated on read)", f.Return)
}
// Incomplete / malformed cookies are not flows.
for _, bad := range []string{"", "%%%", encodeFlow(flow{Nonce: "n"}), encodeFlow(flow{Verifier: "v"})} {
if _, err := decodeFlow(bad); err == nil {
t.Errorf("decodeFlow(%q) = nil error, want rejection", bad)
}
}
}
// TestVerifiedClaimsDecide: the admin-org decision is made on what the VALIDATOR
// proved, never on what the token says about itself. A token whose unverified body
// claims owner=admin is refused when validation reports a different owner — the
// unverified decode is gone, and this pins that it stays gone.
func TestVerifiedClaimsDecide(t *testing.T) {
app, iam := signinApp(t, "")
iam.token = fakeJWT("admin", "cto", time.Hour) // body SAYS admin
iam.verified = cloud.VerifiedIdentity{Owner: "hanzo", User: "someone", Expiry: time.Now().Add(time.Hour)}
resp := completeSignin(t, app)
if resp.StatusCode != http.StatusForbidden {
t.Errorf("callback = %d, want 403: the verified owner must win over the token body", resp.StatusCode)
}
assertNoSession(t, resp)
}
// ── harness ──────────────────────────────────────────────────────────────────
// fakeIAM is a stand-in for IAM's token endpoint, recording what the exchange sent.
// verified is what cloud's validator will PROVE about the token it hands back;
// verifyErr makes validation fail, which is how a real deployment behaves when its
// audience allowlist or issuer does not admit the token.
type fakeIAM struct {
token string
status int
calls int
form url.Values
verified cloud.VerifiedIdentity
verifyErr error
}
// signinApp builds the real router over a state whose IAM is a local fake (or the
// literal issuer when one is given, for the no-network authorize assertions).
//
// The token verifier is the ONE seam a test replaces: cloud's real validator needs
// a live JWKS and an RS256-signed token, which would test go-jose rather than this
// flow. The contract it stands in for — verified claims decide, a validation
// failure refuses — is exercised in both directions below.
func signinApp(t *testing.T, issuer string) (*zip.App, *fakeIAM) {
t.Helper()
iam := &fakeIAM{
token: fakeJWT("admin", "cto", time.Hour),
status: http.StatusOK,
verified: cloud.VerifiedIdentity{Owner: "admin", User: "cto", Expiry: time.Now().Add(time.Hour)},
}
if issuer == "" {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/iam/oauth/token" {
http.NotFound(w, r)
return
}
iam.calls++
_ = r.ParseForm()
iam.form = r.PostForm
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(iam.status)
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": iam.token})
}))
t.Cleanup(srv.Close)
issuer = srv.URL
}
svc := fakeSvc()
svc.State.oauth = oauth{
issuer: issuer, clientID: defaultClientID, adminOrg: "admin",
publicURL: "https://cd.hanzo.ai", http: &http.Client{Timeout: 5 * time.Second},
verify: func(raw string) (cloud.VerifiedIdentity, error) {
if iam.verifyErr != nil {
return cloud.VerifiedIdentity{}, iam.verifyErr
}
return iam.verified, nil
},
}
app := zip.New(zip.Config{Logger: luxlog.New("test")})
routes(app, svc)
return app, iam
}
// completeSignin drives login → callback with the flow cookie the login handed
// back, i.e. exactly what a browser does.
func completeSignin(t *testing.T, app *zip.App) *http.Response {
t.Helper()
req := httptest.NewRequest("GET", "/v1/deploy/login?returnTo=/applications", nil)
req.Host = "cd.hanzo.ai"
start := do(t, app, req)
_, cookie := flowFrom(t, start)
loc, _ := url.Parse(start.Header.Get("Location"))
cb := httptest.NewRequest("GET", "/v1/deploy/callback?code=the-code&state="+url.QueryEscape(loc.Query().Get("state")), nil)
cb.Host = "cd.hanzo.ai"
cb.AddCookie(&http.Cookie{Name: flowCookie, Value: cookie.Value})
return do(t, app, cb)
}
func do(t *testing.T, app *zip.App, req *http.Request) *http.Response {
t.Helper()
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("%s %s: %v", req.Method, req.URL, err)
}
t.Cleanup(func() { _ = resp.Body.Close() })
return resp
}
func flowFrom(t *testing.T, resp *http.Response) (flow, *http.Cookie) {
t.Helper()
for _, ck := range resp.Cookies() {
if ck.Name == flowCookie && ck.Value != "" {
f, err := decodeFlow(ck.Value)
if err != nil {
t.Fatalf("flow cookie: %v", err)
}
return f, ck
}
}
t.Fatal("no flow cookie was set")
return flow{}, nil
}
func assertNoSession(t *testing.T, resp *http.Response) {
t.Helper()
for _, ck := range resp.Cookies() {
if ck.Name == sessionCookie && ck.Value != "" {
t.Fatalf("a session cookie was minted on a refused sign-in: %q", ck.Value)
}
}
}
func encodeFlow(f flow) string {
blob, _ := json.Marshal(f)
return base64.RawURLEncoding.EncodeToString(blob)
}
// fakeJWT builds a structurally valid, UNSIGNED JWT. That is exactly the point of
// the test: nothing in this package trusts the signature — cloud's identity
// boundary re-verifies the token on every later request, and this only exercises
// the claim read that decides whether a cookie is worth minting.
func fakeJWT(owner, name string, ttl time.Duration) string {
enc := func(v any) string {
b, _ := json.Marshal(v)
return base64.RawURLEncoding.EncodeToString(b)
}
return enc(map[string]any{"alg": "RS256", "typ": "JWT"}) + "." +
enc(map[string]any{"owner": owner, "name": name, "exp": time.Now().Add(ttl).Unix()}) + "." +
"not-a-real-signature"
}
+244 -3
View File
@@ -16,6 +16,7 @@ package deploy
import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
)
// ── ArgoCD v1alpha1 JSON (minimal, UI-render-complete) ───────────────────────
@@ -41,6 +42,7 @@ type argoSource struct {
type argoDestination struct {
Server string `json:"server"`
Namespace string `json:"namespace"`
Name string `json:"name,omitempty"` // ArgoCD allows a destination by cluster name; omitted for the in-cluster projection.
}
type argoSpec struct {
@@ -176,6 +178,13 @@ func projectApp(cr *unstructured.Unstructured, ns, runningTag string) argoApp {
if tag != "" {
image = repository + ":" + tag
}
// Surface the tenant label the App CR already carries so the projection can be
// grouped/scoped by org; env stays as before. The tenant BOUNDARY is enforced upstream
// in the handlers (scope.allows) — this only reflects what the CR declares.
labels := map[string]string{"hanzo.ai/instance": native.Name, "hanzo.ai/env": native.Env}
if org := orgOf(cr); org != "" {
labels[orgLabel] = org
}
return argoApp{
APIVersion: "argoproj.io/v1alpha1",
Kind: "Application",
@@ -184,12 +193,14 @@ func projectApp(cr *unstructured.Unstructured, ns, runningTag string) argoApp {
Namespace: ns,
UID: string(cr.GetUID()),
CreationTimestamp: cr.GetCreationTimestamp().Format("2006-01-02T15:04:05Z07:00"),
Labels: map[string]string{"argocd.argoproj.io/instance": native.Name, "hanzo.ai/env": native.Env},
Labels: labels,
},
Spec: argoSpec{
Source: argoSource{RepoURL: deployManifestRepo, Path: "infra/k8s/operator/crs", TargetRevision: "main"},
Destination: argoDestination{Server: "https://kubernetes.default.svc", Namespace: ns},
Project: "default",
Destination: argoDestination{Server: inClusterServer, Namespace: ns},
// spec.project reflects the IAM Project the CR belongs to (app.kubernetes.io/part-of),
// falling back to "default" when the CR carries no project label. No longer hard-coded.
Project: projectName(cr),
},
Status: argoStatus{
Sync: argoSyncStatus{Status: argoSyncFrom(native.Sync), Revision: native.Version},
@@ -233,3 +244,233 @@ func nonEmpty(s string) []string {
}
return []string{s}
}
// ── ArgoCD Cluster projection (destinations → ClusterList) ───────────────────
const (
// inClusterServer / inClusterName are the destination every operator App CR
// reconciles into — the cluster this cloud runs in. projectApp synthesizes
// this destination, so the cluster set derives from it: one value, one home.
inClusterServer = "https://kubernetes.default.svc"
inClusterName = "in-cluster"
// connectionSuccessful is ArgoCD's ConnectionStatus for a reachable cluster.
// This plane projects CRs the operator already reconciles INTO the cluster, so
// the destination is reachable by definition — there is no cluster credential
// to probe and, by construction (argoCluster has no config field), none to leak.
connectionSuccessful = "Successful"
)
// argoConnectionState is v1alpha1.ConnectionState — status is what the UI reads;
// message + attemptedAt are optional and omitted from the projection.
type argoConnectionState struct {
Status string `json:"status"`
Message string `json:"message,omitempty"`
AttemptedAt string `json:"attemptedAt,omitempty"`
}
// argoClusterInfo is v1alpha1.ClusterInfo reduced to the connection + app count
// the UI's Destination/Clusters view reads. It carries NO credentials.
type argoClusterInfo struct {
ConnectionState argoConnectionState `json:"connectionState"`
ApplicationsCount int `json:"applicationsCount"`
ServerVersion string `json:"serverVersion,omitempty"`
}
// argoCluster is v1alpha1.Cluster REDUCED to the projection-safe fields. There is
// DELIBERATELY no `config` field: a projection has no cluster credential to
// surface, so the type physically cannot carry a bearer token, TLS key, or exec
// provider. server + name + connectionState is what the Destination column reads.
type argoCluster struct {
Server string `json:"server"`
Name string `json:"name"`
ConnectionState argoConnectionState `json:"connectionState"`
Info argoClusterInfo `json:"info"`
}
type argoClusterList struct {
Metadata argoListMeta `json:"metadata"`
Items []argoCluster `json:"items"`
}
// clusterOf is the (server, name) an App CR reconciles into. Operator App CRs
// declare no destination — they reconcile into THIS cluster — so absent a
// spec.destination the cluster is in-cluster. Reading spec.destination first keeps
// the derivation honest if a CR ever declares one.
func clusterOf(cr *unstructured.Unstructured) (server, name string) {
server, _, _ = unstructured.NestedString(cr.Object, "spec", "destination", "server")
name, _, _ = unstructured.NestedString(cr.Object, "spec", "destination", "name")
if server == "" {
server = inClusterServer
}
if name == "" {
if server == inClusterServer {
name = inClusterName
} else {
name = server
}
}
return server, name
}
// connectionOK is the ConnectionState of a projected (operator-owned) cluster:
// reachable by definition.
func connectionOK() argoConnectionState { return argoConnectionState{Status: connectionSuccessful} }
// projectClusters derives the ArgoCD ClusterList from the destinations the fleet
// reconciles into — deduped by server, counting the applications per cluster. The
// in-cluster destination is ALWAYS present (an empty fleet still has one cluster).
// It cannot emit a cluster credential: argoCluster has no config field.
func projectClusters(crs []unstructured.Unstructured) argoClusterList {
bySrv := map[string]*argoCluster{}
order := []string{}
ensure := func(server, name string) *argoCluster {
cl, ok := bySrv[server]
if !ok {
cl = &argoCluster{Server: server, Name: name, ConnectionState: connectionOK(), Info: argoClusterInfo{ConnectionState: connectionOK()}}
bySrv[server] = cl
order = append(order, server)
}
return cl
}
ensure(inClusterServer, inClusterName) // an empty fleet still has one cluster
for i := range crs {
server, name := clusterOf(&crs[i])
ensure(server, name).Info.ApplicationsCount++
}
items := make([]argoCluster, 0, len(order))
for _, srv := range order {
items = append(items, *bySrv[srv])
}
return argoClusterList{Metadata: argoListMeta{}, Items: items}
}
// ── ArgoCD AppProject projection (distinct App-CR projects → AppProjectList) ──
// appProjectGVR is the ArgoCD AppProject CRD. This plane does not run argocd, so
// the CRD is normally absent — dashProjects checks for it and falls back to
// synthesizing a project set from the distinct App-CR project names.
var appProjectGVR = schema.GroupVersionResource{Group: "argoproj.io", Version: "v1alpha1", Resource: "appprojects"}
// argoGroupKind is metav1.GroupKind — a clusterResourceWhitelist entry.
type argoGroupKind struct {
Group string `json:"group"`
Kind string `json:"kind"`
}
// argoProjectSpec is the subset of v1alpha1.AppProjectSpec the UI's project filter
// + detail read. Only these fields are surfaced (never the whole CR spec) so a
// real AppProject cannot leak roles/tokens or any field this plane didn't intend.
type argoProjectSpec struct {
SourceRepos []string `json:"sourceRepos"`
Destinations []argoDestination `json:"destinations"`
ClusterResourceWhitelist []argoGroupKind `json:"clusterResourceWhitelist"`
Description string `json:"description,omitempty"`
}
// argoProject is v1alpha1.AppProject (projected). Project scoping on this platform
// is IAM/Org, not argocd RBAC, so a synthesized project is permissive.
type argoProject struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Metadata argoMeta `json:"metadata"`
Spec argoProjectSpec `json:"spec"`
Status argoProjectStat `json:"status"`
}
// argoProjectStat marshals as the empty status object the UI expects.
type argoProjectStat struct{}
type argoProjectList struct {
Metadata argoListMeta `json:"metadata"`
Items []argoProject `json:"items"`
}
// projectedProjectNames is the distinct set of App-CR spec.project values, with
// "default" always first (operator App CRs carry no project → default). Pure.
func projectedProjectNames(crs []unstructured.Unstructured) []string {
seen := map[string]bool{"default": true}
order := []string{"default"}
for i := range crs {
p, _, _ := unstructured.NestedString(crs[i].Object, "spec", "project")
if p == "" || seen[p] {
continue
}
seen[p] = true
order = append(order, p)
}
return order
}
// synthProject builds a permissive projected AppProject for a name (project
// scoping here is IAM/Org, not argocd RBAC).
func synthProject(name string) argoProject {
return argoProject{
APIVersion: "argoproj.io/v1alpha1",
Kind: "AppProject",
Metadata: argoMeta{Name: name},
Spec: argoProjectSpec{
SourceRepos: []string{"*"},
Destinations: []argoDestination{{Server: "*", Namespace: "*"}},
ClusterResourceWhitelist: []argoGroupKind{{Group: "*", Kind: "*"}},
},
}
}
// projectAppProject reshapes a REAL argoproj.io/v1alpha1 AppProject CR into the
// projected shape — name + ONLY the spec fields the UI reads. It never passes the
// CR spec through verbatim, so a real project cannot surface roles, jwtToken
// metadata, or any field this plane did not intend.
func projectAppProject(cr *unstructured.Unstructured) argoProject {
desc, _, _ := unstructured.NestedString(cr.Object, "spec", "description")
return argoProject{
APIVersion: "argoproj.io/v1alpha1",
Kind: "AppProject",
Metadata: argoMeta{Name: cr.GetName(), Namespace: cr.GetNamespace()},
Spec: argoProjectSpec{
SourceRepos: nestedStringSlice(cr.Object, "spec", "sourceRepos"),
Destinations: nestedDestinations(cr.Object, "spec", "destinations"),
ClusterResourceWhitelist: nestedGroupKinds(cr.Object, "spec", "clusterResourceWhitelist"),
Description: desc,
},
}
}
// nestedDestinations reads a []{server,namespace,name} slice from a CR.
func nestedDestinations(obj map[string]any, fields ...string) []argoDestination {
raw, ok, _ := unstructured.NestedSlice(obj, fields...)
if !ok {
return nil
}
out := make([]argoDestination, 0, len(raw))
for _, e := range raw {
m, ok := e.(map[string]any)
if !ok {
continue
}
server, _ := m["server"].(string)
namespace, _ := m["namespace"].(string)
name, _ := m["name"].(string)
out = append(out, argoDestination{Server: server, Namespace: namespace, Name: name})
}
return out
}
// nestedGroupKinds reads a []{group,kind} slice from a CR.
func nestedGroupKinds(obj map[string]any, fields ...string) []argoGroupKind {
raw, ok, _ := unstructured.NestedSlice(obj, fields...)
if !ok {
return nil
}
out := make([]argoGroupKind, 0, len(raw))
for _, e := range raw {
m, ok := e.(map[string]any)
if !ok {
continue
}
group, _ := m["group"].(string)
kind, _ := m["kind"].(string)
out = append(out, argoGroupKind{Group: group, Kind: kind})
}
return out
}
+146
View File
@@ -2,6 +2,7 @@ package deploy
import (
"encoding/json"
"strings"
"testing"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@@ -102,3 +103,148 @@ func TestProjectTree_ShapeIsUIRenderable(t *testing.T) {
t.Fatalf("node ref wrong: %v", n0)
}
}
// ── clusters projection ───────────────────────────────────────────────────────
// TestProjectClusters_DedupesAndAlwaysInCluster: the fleet's destinations collapse
// to a deduped ClusterList; the in-cluster destination is always present with the
// right per-cluster application count; a CR that declares a distinct destination
// yields a second cluster.
func TestProjectClusters_DedupesAndAlwaysInCluster(t *testing.T) {
// Empty fleet still projects exactly one cluster (in-cluster), count 0.
empty := projectClusters(nil)
if len(empty.Items) != 1 || empty.Items[0].Server != inClusterServer || empty.Items[0].Name != inClusterName {
t.Fatalf("empty fleet clusters = %+v, want one in-cluster", empty.Items)
}
if empty.Items[0].Info.ApplicationsCount != 0 {
t.Fatalf("empty in-cluster count = %d, want 0", empty.Items[0].Info.ApplicationsCount)
}
if empty.Items[0].ConnectionState.Status != "Successful" || empty.Items[0].Info.ConnectionState.Status != "Successful" {
t.Fatalf("connectionState = %+v, want Successful", empty.Items[0])
}
// Two in-cluster apps + one app declaring a distinct destination server.
a := *projFixture("cloud", "hanzo", "ghcr.io/hanzoai/cloud", "v1", "Running", 1, 1)
b := *projFixture("iam", "hanzo", "ghcr.io/hanzoai/iam", "v1", "Running", 1, 1)
edge := *projFixture("edge", "hanzo", "ghcr.io/hanzoai/edge", "v1", "Running", 1, 1)
_ = unstructured.SetNestedField(edge.Object, map[string]any{"server": "https://edge.example:6443", "name": "edge"}, "spec", "destination")
cl := projectClusters([]unstructured.Unstructured{a, b, edge})
if len(cl.Items) != 2 {
t.Fatalf("clusters = %d, want 2 (in-cluster + edge)", len(cl.Items))
}
byServer := map[string]argoCluster{}
for _, c := range cl.Items {
byServer[c.Server] = c
}
if byServer[inClusterServer].Info.ApplicationsCount != 2 {
t.Fatalf("in-cluster count = %d, want 2", byServer[inClusterServer].Info.ApplicationsCount)
}
if e, ok := byServer["https://edge.example:6443"]; !ok || e.Name != "edge" || e.Info.ApplicationsCount != 1 {
t.Fatalf("edge cluster = %+v (ok=%v), want name=edge count=1", e, ok)
}
}
// TestProjectClusters_NeverEmitsCredentials is the credential-leak guard: the
// marshaled ClusterList must carry NO config / bearerToken / tlsClientConfig /
// execProviderConfig key anywhere — the argoCluster type has no field for them.
func TestProjectClusters_NeverEmitsCredentials(t *testing.T) {
// Even a CR polluted with a spec.destination.config must not surface it.
a := *projFixture("cloud", "hanzo", "ghcr.io/hanzoai/cloud", "v1", "Running", 1, 1)
_ = unstructured.SetNestedField(a.Object, map[string]any{
"server": inClusterServer,
"config": map[string]any{"bearerToken": "SECRET-DO-NOT-LEAK", "tlsClientConfig": map[string]any{"keyData": "PRIV"}},
}, "spec", "destination")
b, err := json.Marshal(projectClusters([]unstructured.Unstructured{a}))
if err != nil {
t.Fatalf("marshal: %v", err)
}
blob := string(b)
for _, forbidden := range []string{"config", "bearerToken", "tlsClientConfig", "execProviderConfig", "keyData", "SECRET-DO-NOT-LEAK", "PRIV"} {
if strings.Contains(blob, forbidden) {
t.Fatalf("clusters JSON leaked %q: %s", forbidden, blob)
}
}
}
// ── projects projection ───────────────────────────────────────────────────────
// TestProjectedProjectNames_DistinctWithDefault: default is always first; distinct
// spec.project values follow; empty projects collapse to default.
func TestProjectedProjectNames_DistinctWithDefault(t *testing.T) {
// No projects declared → just default.
if got := projectedProjectNames([]unstructured.Unstructured{
*projFixture("cloud", "hanzo", "r", "v1", "Running", 1, 1),
}); len(got) != 1 || got[0] != "default" {
t.Fatalf("no-project names = %v, want [default]", got)
}
a := *projFixture("cloud", "hanzo", "r", "v1", "Running", 1, 1)
b := *projFixture("iam", "hanzo", "r", "v1", "Running", 1, 1)
c := *projFixture("chat", "hanzo", "r", "v1", "Running", 1, 1)
_ = unstructured.SetNestedField(a.Object, "team-a", "spec", "project")
_ = unstructured.SetNestedField(b.Object, "team-a", "spec", "project") // dup
_ = unstructured.SetNestedField(c.Object, "team-b", "spec", "project")
got := projectedProjectNames([]unstructured.Unstructured{a, b, c})
if len(got) != 3 || got[0] != "default" {
t.Fatalf("names = %v, want [default team-a team-b]", got)
}
set := map[string]bool{}
for _, n := range got {
if set[n] {
t.Fatalf("duplicate project name %q in %v", n, got)
}
set[n] = true
}
if !set["team-a"] || !set["team-b"] {
t.Fatalf("names = %v, want team-a + team-b present", got)
}
}
// TestSynthProject_Permissive: a synthesized project is a permissive AppProject the
// UI can render + filter on.
func TestSynthProject_Permissive(t *testing.T) {
p := synthProject("default")
if p.APIVersion != "argoproj.io/v1alpha1" || p.Kind != "AppProject" || p.Metadata.Name != "default" {
t.Fatalf("synth project TypeMeta/name wrong: %+v", p)
}
if len(p.Spec.SourceRepos) != 1 || p.Spec.SourceRepos[0] != "*" {
t.Fatalf("sourceRepos = %v, want [*]", p.Spec.SourceRepos)
}
if len(p.Spec.Destinations) != 1 || p.Spec.Destinations[0].Server != "*" || p.Spec.Destinations[0].Namespace != "*" {
t.Fatalf("destinations = %v, want [{*,*}]", p.Spec.Destinations)
}
if len(p.Spec.ClusterResourceWhitelist) != 1 || p.Spec.ClusterResourceWhitelist[0].Group != "*" {
t.Fatalf("clusterResourceWhitelist = %v, want [{*,*}]", p.Spec.ClusterResourceWhitelist)
}
// status marshals as an object (UI expects one).
b, _ := json.Marshal(p)
var m map[string]any
_ = json.Unmarshal(b, &m)
if _, ok := m["status"].(map[string]any); !ok {
t.Fatalf("project status must be a JSON object, got %T", m["status"])
}
}
// TestProjectAppProject_SurfacesOnlyIntendedFields: a REAL AppProject CR is
// reshaped to name + the whitelisted spec fields ONLY — roles (token metadata)
// never leak through.
func TestProjectAppProject_SurfacesOnlyIntendedFields(t *testing.T) {
p := projectAppProject(appProjectCR("team-a", "https://git.hanzo.ai/team-a/*"))
if p.Metadata.Name != "team-a" {
t.Fatalf("name = %q, want team-a", p.Metadata.Name)
}
if len(p.Spec.SourceRepos) != 1 || p.Spec.SourceRepos[0] != "https://git.hanzo.ai/team-a/*" {
t.Fatalf("sourceRepos = %v, want the real CR value", p.Spec.SourceRepos)
}
if len(p.Spec.Destinations) != 1 || p.Spec.Destinations[0].Namespace != "hanzo" {
t.Fatalf("destinations = %v, want the real CR value", p.Spec.Destinations)
}
b, _ := json.Marshal(p)
for _, forbidden := range []string{"roles", "secret-role", "policies"} {
if strings.Contains(string(b), forbidden) {
t.Fatalf("projected AppProject leaked %q: %s", forbidden, b)
}
}
}
+272
View File
@@ -0,0 +1,272 @@
// scope.go — the TENANT SCOPE of a /v1/deploy request, and the IAM-owned project
// reflection.
//
// This plane was SuperAdmin-only: it listed App CRs across ALL platform
// namespaces, hard-coded spec.project = "default", and read no org/project
// labels. It is now TENANT-AWARE and IAM-MAPPED. Every operator App CR already
// carries the tenant + project labels (clients/platform serviceCR + the fleet
// crs/*.yaml stamp them); this plane READS them and scopes each list/detail read
// to the caller's org, while a SuperAdmin keeps the whole-fleet view.
//
// The tenant boundary is the SAME one the rest of cloud trusts (clients/platform
// .tenant / clients/s3.tenant): the gateway-minted, IAM-validated identity headers
// (c.IsAdmin/c.Org), the injective provisioning.SanitizeOrg normalizer, and the
// principal.Validated gate. There is NO third slug rule — resolveScope keys the
// SAME (org → tenant-<org>) boundary the PaaS writes into.
//
// Projects are owned by Hanzo IAM (hanzo.id), the ONE source of truth for the
// org-scoped (Owner,Name) Project resource. This plane REFLECTS them read-only via
// the in-process object store (embedded IAM, no HTTP hop) — mirroring
// clients/platform/projects.go — and never persists a CD-side project row.
package deploy
import (
"context"
"net/http"
"net/url"
iamobj "github.com/hanzoai/iam/object"
"github.com/zap-proto/zip"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/principal"
"github.com/hanzoai/cloud/clients/provisioning"
)
// Operator App CRs carry two labels this plane READS (never writes): clients/platform
// serviceCR and the fleet crs/*.yaml stamp them.
//
// hanzo.ai/org — the TENANT: the injective provisioning.SanitizeOrg slug.
// app.kubernetes.io/part-of — the IAM Project NAME (tenant-provisioned apps carry it).
const (
orgLabel = "hanzo.ai/org"
projectLabel = "app.kubernetes.io/part-of"
)
// scope is the resolved tenant view of a /v1/deploy request. A SuperAdmin sees the
// WHOLE fleet (org == "", superAdmin == true); a normal org sees ONLY its own rows
// (org == its injective slug, superAdmin == false).
type scope struct {
org string // sanitized org slug; "" == whole-fleet (SuperAdmin)
superAdmin bool
}
// resolveScope derives the scope from the VALIDATED identity, reusing clients/platform
// .tenant's EXACT boundary primitives so this plane keys the SAME tenant boundary as the
// rest of cloud — with no third slug rule.
//
// SuperAdmin FIRST and by c.IsAdmin() ALONE: SanitizeIdentity mints X-User-IsAdmin only
// for a JWT-verified SuperAdmin and NEVER restores it from the client (unlike X-Org-Id),
// so it already implies a validated principal. This is the SAME predicate the original
// guard() gates on, which is why the SuperAdmin whole-fleet view (and the e2e 108/109
// contract) is preserved byte-for-byte.
//
// A normal org additionally REQUIRES principal.Validated (X-User-Id present): X-Org-Id IS
// restorable from the client on the bearer-less "Phase-1 data" path, so trusting it without
// a validated principal would let an off-gateway caller forge `X-Org-Id: victim` and read
// another tenant. An empty or unvalidated org fails closed.
func resolveScope(c *zip.Ctx) (scope, bool) {
if c.IsAdmin() {
return scope{superAdmin: true}, true
}
if !principal.Validated(c) {
return scope{}, false
}
if org := provisioning.SanitizeOrg(c.Org()); org != "" {
return scope{org: org}, true
}
return scope{}, false
}
// refuse is the fail-closed refusal shared by guard() and every scoped route: a browser
// NAVIGATION is bounced to sign-in (a 403 page with no way to sign in is a dead end), while
// every API/XHR call keeps its 403. Identical shape to the original guard(); the message is
// deliberately generic so the 403 discloses no policy (whether admin or an org would pass).
func refuse(c *zip.Ctx) error {
if wantsDocument(c.Method(), c.Header("Sec-Fetch-Dest"), c.Header("Sec-Fetch-Mode"),
c.Header("Accept"), c.Header("X-Requested-With")) {
return c.Redirect(http.StatusFound, loginPath+"?returnTo="+url.QueryEscape(currentPath(c)))
}
return zip.ErrForbidden("not authorized for this deploy console")
}
// orgOf / projectOf read the tenant + IAM-project labels off an App CR ("" when absent).
func orgOf(cr *unstructured.Unstructured) string { return cr.GetLabels()[orgLabel] }
func projectOf(cr *unstructured.Unstructured) string { return cr.GetLabels()[projectLabel] }
// projectName is the App CR's IAM project — the app.kubernetes.io/part-of label, falling
// back to "default" when the CR carries none (the fleet CRs predate per-app projects).
func projectName(cr *unstructured.Unstructured) string {
if p := projectOf(cr); p != "" {
return p
}
return principal.DefaultProject
}
// allows reports whether an App CR is visible to this scope. A SuperAdmin sees every CR; a
// normal org sees ONLY a CR whose hanzo.ai/org label equals its slug — the injective
// SanitizeOrg slug, so two orgs can never collide. This is the cross-tenant boundary,
// applied to EVERY projected/accessed CR (list, detail, clusters, stream).
func (sc scope) allows(cr *unstructured.Unstructured) bool {
if sc.superAdmin {
return true
}
return sc.org != "" && orgOf(cr) == sc.org
}
// namespaces is the ordered set of namespaces this scope reads App CRs from. A SuperAdmin
// scans the whole platform tier (scanOrder, unchanged — preserving the pre-tenant fleet
// view); a normal org scans ONLY its own tenant namespace, tenant-<org>.
func (sc scope) namespaces() []string {
if sc.superAdmin {
return scanOrder()
}
return []string{tenantNS(sc.org)}
}
// tenantNS is the tenant namespace for an ALREADY-sanitized org slug: "tenant-"+slug — the
// read twin of clients/platform.tenantNamespace (which is "tenant-"+SanitizeOrg(org)).
// sc.org is never empty here (resolveScope gates it to a non-empty injective slug), so no
// "unknown" fallback is needed. The "tenant-" prefix is a frozen infra convention (renaming
// it is a gated namespace migration — see clients/platform/k8s.go); sc.org is the SAME
// SanitizeOrg slug platform stamps, so the two derive the same namespace.
func tenantNS(org string) string { return "tenant-" + org }
// appCRs collects every App CR VISIBLE to this scope across its namespaces, filtered by the
// org label (a SuperAdmin's filter admits all). Used where per-namespace running-tag
// batching is unneeded (clusters + the projects fallback).
func (sc scope) appCRs(s *cloud.Service[state], ctx context.Context) ([]unstructured.Unstructured, error) {
var out []unstructured.Unstructured
for _, ns := range sc.namespaces() {
crs, err := listAppCRs(s, ctx, ns)
if err != nil {
return nil, k8sErr(s, "list", err)
}
for i := range crs {
if sc.allows(&crs[i]) {
out = append(out, crs[i])
}
}
}
return out, nil
}
// findNamespace resolves the namespace an App CR named `name` lives in, scanning this
// scope's namespaces in order and REQUIRING the CR be visible to the scope. A cross-tenant
// name (org A's app requested by org B) is reported as a clean 404 — never confirmed to
// exist, so the detail routes leak no cross-tenant existence oracle.
func (sc scope) findNamespace(s *cloud.Service[state], c *zip.Ctx, name string) (string, error) {
for _, ns := range sc.namespaces() {
cr, _, err := getAppCR(s, c.Context(), ns, name)
if err != nil {
if apierrors.IsNotFound(err) {
continue
}
return "", k8sErr(s, "get", err)
}
if !sc.allows(cr) {
continue // exists but not this tenant's — treat as absent
}
return ns, nil
}
return "", zip.ErrNotFound("application " + name + " not found")
}
// watches reports whether a watched App-CR object belongs to this scope's stream: its
// namespace is one the scope watches AND (for a normal org) its org label matches. Replaces
// the platform-namespace nsEnv gate forwardWatch used, generalizing it to the scope.
func (sc scope) watches(obj *unstructured.Unstructured) bool {
return sc.watchesNamespace(obj.GetNamespace()) && sc.allows(obj)
}
func (sc scope) watchesNamespace(ns string) bool {
for _, n := range sc.namespaces() {
if n == ns {
return true
}
}
return false
}
// runningByNamespace collects running image tags per namespace this scope watches
// (best-effort — a list error yields an empty inner map). Scoped twin of the stream's
// per-namespace running-tag map.
func (sc scope) runningByNamespace(s *cloud.Service[state], ctx context.Context) map[string]map[string]string {
out := make(map[string]map[string]string, len(sc.namespaces()))
for _, ns := range sc.namespaces() {
out[ns] = runningVersions(s, ctx, ns)
}
return out
}
// ── IAM-owned project reflection ─────────────────────────────────────────────
// iamStore runs an embedded-IAM object-store call, converting a nil-store panic into a
// clean 503 rather than a nil-deref crash. The store's engine (iamobj.ormer) is a package
// global that is nil until the co-resident IAM subsystem initializes it; a project call
// against a nil engine would otherwise nil-deref. Mirrors clients/platform.iamStore — a
// deployment enabling "deploy" is meant to co-mount "iam" (single-binary co-residents).
// Never masks a real error.
func iamStore[T any](fn func() (T, error)) (out T, err error) {
defer func() {
if r := recover(); r != nil {
err = zip.Errorf(http.StatusServiceUnavailable,
"deploy requires the co-resident IAM store, which is not initialized")
}
}()
return fn()
}
// iamProjects reflects the IAM-owned projects VISIBLE to this scope into projected argo
// AppProjects. A normal org gets its own organization's projects
// (GetOrganizationProjects); a SuperAdmin gets every org's (GetProjects with an empty owner
// → all owners, the same all-orgs listing IAM's own get-projects endpoint serves). IAM is
// the ONE source; this NEVER persists a CD-side project. A nil/absent embedded IAM store
// yields nil (the caller's synthesized-default fallback keeps the projection populated).
func (sc scope) iamProjects() []argoProject {
list, err := iamStore(func() ([]*iamobj.Project, error) {
if sc.superAdmin {
return iamobj.GetProjects("") // empty owner → every org's projects
}
return iamobj.GetOrganizationProjects(sc.org)
})
if err != nil || len(list) == 0 {
return nil
}
out := make([]argoProject, 0, len(list))
for _, p := range list {
out = append(out, projectFromIAM(p))
}
return out
}
// projectFromIAM reflects ONE IAM Project into a projected argo AppProject: name = the
// project Name (the app-scope key AND the CR part-of label), description = DisplayName or
// Description, and an hanzo.ai/org label carrying the tenant. Project scoping on this
// platform is IAM/Org, not argocd RBAC, so the projected spec is permissive — and ONLY
// these fields are surfaced (never Tags/Metadata), so nothing unintended leaks.
func projectFromIAM(p *iamobj.Project) argoProject {
proj := synthProject(p.Name)
proj.Spec.Description = firstNonEmpty(p.DisplayName, p.Description)
if org := provisioning.SanitizeOrg(firstNonEmpty(p.Organization, p.Owner)); org != "" {
proj.Metadata.Labels = map[string]string{orgLabel: org}
}
return proj
}
// ensureDefault guarantees a project named "default" is present (prepended if absent by
// name), so every projected app's spec.project — which falls back to "default" when a CR
// carries no part-of label — always resolves to a listed project, and the CD SPA's project
// filter can render the default bucket. Preserves the e2e invariant (projects contains
// 'default') regardless of IAM state. Pure.
func ensureDefault(items []argoProject) []argoProject {
for i := range items {
if items[i].Metadata.Name == principal.DefaultProject {
return items
}
}
return append([]argoProject{synthProject(principal.DefaultProject)}, items...)
}
+454
View File
@@ -0,0 +1,454 @@
package deploy
import (
"bufio"
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
iamobj "github.com/hanzoai/iam/object"
luxlog "github.com/luxfi/log"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/provisioning"
"github.com/zap-proto/zip"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/watch"
)
// superScope is the whole-fleet SuperAdmin scope — the pre-tenant behavior. Used by the
// stream unit tests (which exercise the projection core directly).
func superScope() scope { return scope{superAdmin: true} }
// orgAppCR builds an operator App CR stamped with the tenant + project labels the platform
// stamps (hanzo.ai/org, app.kubernetes.io/part-of). ns is the namespace the CR lives in
// (tenant-<org> for a tenant app).
func orgAppCR(ns, name, org, project string) *unstructured.Unstructured {
cr := appCR("App", ns, name, "uid-"+ns+"-"+name, "ghcr.io/hanzoai/"+name, "v1", "Running", 1, 1)
labels := map[string]string{}
if org != "" {
labels[orgLabel] = org
}
if project != "" {
labels[projectLabel] = project
}
_ = unstructured.SetNestedStringMap(cr.Object, labels, "metadata", "labels")
return cr
}
// getAs drives a GET through the full router with the given identity headers and returns
// the response (no status assertion — callers test 200/403/404). Mirrors production: the
// gateway/SanitizeIdentity mints X-Org-Id / X-User-Id / X-User-IsAdmin; a test sets them.
func getAs(t *testing.T, s *cloud.Service[state], path string, headers map[string]string) *http.Response {
t.Helper()
app := zip.New(zip.Config{Logger: luxlog.New("test")})
routes(app, s)
req := httptest.NewRequest("GET", path, nil)
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
return resp
}
// jsonBody decodes a JSON response body into a map.
func jsonBody(t *testing.T, resp *http.Response) map[string]any {
t.Helper()
defer resp.Body.Close()
var body map[string]any
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode body: %v", err)
}
return body
}
// orgHeaders is a VALIDATED org member's identity (X-User-Id present ⇒ principal.Validated,
// X-Org-Id the tenant, no X-User-IsAdmin).
func orgHeaders(org string) map[string]string {
return map[string]string{"X-Org-Id": org, "X-User-Id": "u_" + org}
}
// appNames collects the projected application names from an ApplicationList body.
func appNames(body map[string]any) map[string]bool {
out := map[string]bool{}
items, _ := body["items"].([]any)
for _, it := range items {
m, _ := it.(map[string]any)
meta, _ := m["metadata"].(map[string]any)
if n, _ := meta["name"].(string); n != "" {
out[n] = true
}
}
return out
}
// ── resolveScope: the tenant boundary (pure) ─────────────────────────────────
// probeScope resolves a scope from a set of identity headers, driven through a real ctx.
func probeScope(t *testing.T, headers map[string]string) (scope, bool) {
t.Helper()
app := zip.New(zip.Config{Logger: luxlog.New("test")})
var got scope
var ok bool
app.Get("/probe", func(c *zip.Ctx) error {
got, ok = resolveScope(c)
return c.JSON(http.StatusOK, map[string]any{})
})
req := httptest.NewRequest("GET", "/probe", nil)
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("probe: %v", err)
}
_ = resp.Body.Close()
return got, ok
}
// TestResolveScope_Boundary proves resolveScope reuses the SAME boundary as
// clients/platform.tenant: SuperAdmin by c.IsAdmin() alone (whole-fleet), a normal org only
// when VALIDATED (X-User-Id present) with a non-empty sanitized org, everything else CLOSED.
func TestResolveScope_Boundary(t *testing.T) {
// SuperAdmin: c.IsAdmin() alone ⇒ whole-fleet (org == "").
if sc, ok := probeScope(t, map[string]string{"X-User-IsAdmin": "true"}); !ok || !sc.superAdmin || sc.org != "" {
t.Fatalf("admin scope = %+v ok=%v, want {superAdmin,org:\"\"}", sc, ok)
}
// A SuperAdmin whose X-Org-Id is also set STILL sees the whole fleet (admin wins).
if sc, ok := probeScope(t, map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "acme", "X-User-Id": "u"}); !ok || !sc.superAdmin {
t.Fatalf("admin+org scope = %+v ok=%v, want superAdmin", sc, ok)
}
// Validated org member ⇒ org scope, sanitized.
if sc, ok := probeScope(t, orgHeaders("acme")); !ok || sc.superAdmin || sc.org != "acme" {
t.Fatalf("org scope = %+v ok=%v, want {org:acme}", sc, ok)
}
// resolveScope keys the org through the SAME injective provisioning.SanitizeOrg the CR
// label filter uses — so a resolved org and the hanzo.ai/org label compare like-for-like,
// and two distinct owners never collide onto one tenant. (Uppercase/dirty inputs are NOT
// identity-mapped; they carry a hash suffix, which is exactly the injectivity guarantee.)
for _, raw := range []string{"acme", "ACME", "team1"} {
sc, ok := probeScope(t, map[string]string{"X-Org-Id": raw, "X-User-Id": "u"})
want := provisioning.SanitizeOrg(raw)
if want == "" {
t.Fatalf("test input %q unexpectedly sanitized to empty", raw)
}
if !ok || sc.org != want {
t.Fatalf("org(%q) scope = %+v ok=%v, want org %q (SanitizeOrg)", raw, sc, ok, want)
}
}
// An org carrying an unsafe rune (whitespace) is refused by SanitizeOrg (→ "") — a
// non-injective identifier — so resolveScope fails closed, never a fabricated tenant.
if sc, ok := probeScope(t, map[string]string{"X-Org-Id": "bad org", "X-User-Id": "u"}); ok {
t.Fatalf("whitespace org resolved a scope %+v — must fail closed", sc)
}
// FORGED X-Org-Id with NO validated principal (no X-User-Id) ⇒ fail closed.
if sc, ok := probeScope(t, map[string]string{"X-Org-Id": "victim"}); ok {
t.Fatalf("forged X-Org-Id resolved a scope: %+v — must fail closed", sc)
}
// Validated but EMPTY org, not admin ⇒ fail closed.
if _, ok := probeScope(t, map[string]string{"X-User-Id": "u"}); ok {
t.Fatal("empty-org validated non-admin resolved a scope — must fail closed")
}
// Nothing ⇒ fail closed.
if _, ok := probeScope(t, map[string]string{}); ok {
t.Fatal("anonymous resolved a scope — must fail closed")
}
}
// ── applications: org scoping + cross-org isolation ──────────────────────────
// twoTenantFleet is a fleet with apps for two tenants (in their tenant-<org> namespaces)
// plus a system app in the platform "hanzo" namespace.
func twoTenantFleet() *cloud.Service[state] {
return fakeSvc(
orgAppCR("tenant-acme", "acme-web", "acme", "storefront"),
orgAppCR("tenant-acme", "acme-api", "acme", "storefront"),
orgAppCR("tenant-bravo", "bravo-web", "bravo", "site"),
orgAppCR("hanzo", "cloud", "hanzo", ""), // a system/fleet app (platform namespace)
)
}
// TestDashAppList_OrgSeesOnlyItsApps (requirement a): a normal-org caller sees ONLY apps
// labeled its org, in its tenant namespace — and the projection surfaces the org label.
func TestDashAppList_OrgSeesOnlyItsApps(t *testing.T) {
s := twoTenantFleet()
resp := getAs(t, s, "/v1/deploy/applications", orgHeaders("acme"))
if resp.StatusCode != http.StatusOK {
t.Fatalf("acme /applications = %d, want 200", resp.StatusCode)
}
names := appNames(jsonBody(t, resp))
if !names["acme-web"] || !names["acme-api"] {
t.Fatalf("acme missing its own apps: %v", names)
}
if names["bravo-web"] || names["cloud"] {
t.Fatalf("acme sees another tenant's/system apps (CROSS-ORG LEAK): %v", names)
}
if len(names) != 2 {
t.Fatalf("acme app count = %d, want exactly 2 (its own): %v", len(names), names)
}
}
// TestDashAppList_CrossOrgIsolation (requirement b): org B never sees org A's apps — not
// with its own header, and not even claiming A's org without a validated principal.
func TestDashAppList_CrossOrgIsolation(t *testing.T) {
s := twoTenantFleet()
// bravo sees only bravo.
bravo := appNames(jsonBody(t, getAs(t, s, "/v1/deploy/applications", orgHeaders("bravo"))))
if !bravo["bravo-web"] || bravo["acme-web"] || bravo["acme-api"] || bravo["cloud"] {
t.Fatalf("bravo sees non-bravo apps (CROSS-ORG LEAK): %v", bravo)
}
// bravo forging X-Org-Id: acme WITHOUT a validated principal (no X-User-Id) ⇒ 403,
// never acme's fleet.
resp := getAs(t, s, "/v1/deploy/applications", map[string]string{"X-Org-Id": "acme"})
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("forged X-Org-Id:acme (no principal) = %d, want 403", resp.StatusCode)
}
_ = resp.Body.Close()
}
// TestDashAppList_SuperAdminSeesFleet (requirement c): a SuperAdmin sees the whole platform
// fleet (the scanOrder namespaces) exactly as before — the e2e 108/109 contract.
func TestDashAppList_SuperAdminSeesFleet(t *testing.T) {
s := twoTenantFleet()
names := appNames(jsonBody(t, getAs(t, s, "/v1/deploy/applications", map[string]string{"X-User-IsAdmin": "true"})))
if !names["cloud"] {
t.Fatalf("SuperAdmin missing the fleet's system app: %v", names)
}
}
// TestDashApp_CrossOrgAppIdIs404 (requirement b): an app id from org A, requested by org B,
// returns a clean 404 — never confirmed to exist (no cross-tenant oracle).
func TestDashApp_CrossOrgAppIdIs404(t *testing.T) {
s := twoTenantFleet()
for _, route := range []string{"/v1/deploy/applications/acme-web", "/v1/deploy/applications/acme-web/resource-tree"} {
resp := getAs(t, s, route, orgHeaders("bravo"))
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("bravo GET %s = %d, want 404 (acme's app must be invisible)", route, resp.StatusCode)
}
_ = resp.Body.Close()
}
// acme reaches its own app.
resp := getAs(t, s, "/v1/deploy/applications/acme-web", orgHeaders("acme"))
if resp.StatusCode != http.StatusOK {
t.Fatalf("acme GET its own app = %d, want 200", resp.StatusCode)
}
_ = resp.Body.Close()
}
// TestDashClusters_OrgCountsOnlyItsApps (requirement b): the ClusterList a tenant sees
// counts ONLY its own apps, and still leaks no cluster credential.
func TestDashClusters_OrgCountsOnlyItsApps(t *testing.T) {
s := twoTenantFleet()
body := jsonBody(t, getAs(t, s, "/v1/deploy/clusters", orgHeaders("acme")))
items, _ := body["items"].([]any)
if len(items) != 1 {
t.Fatalf("acme clusters = %v, want exactly one (in-cluster)", items)
}
c0 := items[0].(map[string]any)
info, _ := c0["info"].(map[string]any)
if info["applicationsCount"].(float64) != 2 {
t.Fatalf("acme in-cluster count = %v, want 2 (acme's apps only, not bravo/system)", info["applicationsCount"])
}
raw, _ := json.Marshal(body)
for _, forbidden := range []string{"bearerToken", "tlsClientConfig", "execProviderConfig", "keyData"} {
if strings.Contains(string(raw), forbidden) {
t.Fatalf("tenant clusters leaked %q: %s", forbidden, raw)
}
}
}
// TestDashReads_FailClosedOnUnvalidatedOrg (requirement f): every scoped READ route fails
// closed (403) for a forged X-Org-Id with no validated principal, and for an empty org.
func TestDashReads_FailClosedOnUnvalidatedOrg(t *testing.T) {
s := twoTenantFleet()
readRoutes := []string{
"/v1/deploy/applications",
"/v1/deploy/applications/acme-web",
"/v1/deploy/applications/acme-web/resource-tree",
"/v1/deploy/clusters",
"/v1/deploy/projects",
"/v1/deploy/stream/applications",
}
for _, r := range readRoutes {
// Forged org, no validated principal (no X-User-Id).
resp := getAs(t, s, r, map[string]string{"X-Org-Id": "acme"})
if resp.StatusCode != http.StatusForbidden {
t.Errorf("forged-org GET %s = %d, want 403", r, resp.StatusCode)
}
_ = resp.Body.Close()
// Validated but empty org, not admin.
resp2 := getAs(t, s, r, map[string]string{"X-User-Id": "u"})
if resp2.StatusCode != http.StatusForbidden {
t.Errorf("empty-org GET %s = %d, want 403", r, resp2.StatusCode)
}
_ = resp2.Body.Close()
}
}
// ── spec.project from part-of (projection) ───────────────────────────────────
// TestProjectApp_SpecProjectFromPartOf (requirement d): spec.project is read from the
// app.kubernetes.io/part-of label (the IAM Project), not hard-coded, and the tenant label
// is surfaced in the projection.
func TestProjectApp_SpecProjectFromPartOf(t *testing.T) {
app := projectApp(orgAppCR("tenant-acme", "acme-web", "acme", "storefront"), "tenant-acme", "v1")
if app.Spec.Project != "storefront" {
t.Fatalf("spec.project = %q, want storefront (from part-of)", app.Spec.Project)
}
if app.Metadata.Labels[orgLabel] != "acme" {
t.Fatalf("projected labels missing hanzo.ai/org=acme: %v", app.Metadata.Labels)
}
}
// TestProjectApp_UnlabeledFallsIntoDefault (requirement e): an App CR with no part-of label
// projects into the "default" project, and carries no org label when the CR has none.
func TestProjectApp_UnlabeledFallsIntoDefault(t *testing.T) {
// The bare appCR helper carries NO labels at all.
app := projectApp(appCR("App", "hanzo", "cloud", "u1", "ghcr.io/hanzoai/cloud", "v1", "Running", 1, 1), "hanzo", "v1")
if app.Spec.Project != "default" {
t.Fatalf("spec.project = %q, want default (no part-of)", app.Spec.Project)
}
if _, present := app.Metadata.Labels[orgLabel]; present {
t.Fatalf("unlabeled CR projected an org label: %v", app.Metadata.Labels)
}
}
// ── projects: IAM reflection + cross-org isolation ───────────────────────────
// TestDashProjects_OrgNeverSeesCrossOrgAppProjects (requirement b): a normal org's /projects
// NEVER surfaces the unscoped cluster-wide AppProject list (that path is SuperAdmin-only) —
// even when real AppProject CRs are served — and always contains 'default'.
func TestDashProjects_OrgNeverSeesCrossOrgAppProjects(t *testing.T) {
s := fakeSvc(
orgAppCR("tenant-acme", "acme-web", "acme", "storefront"),
appProjectCR("team-secret", "https://git.hanzo.ai/team-secret/*"), // a cross-org real AppProject CR
)
body := jsonBody(t, getAs(t, s, "/v1/deploy/projects", orgHeaders("acme")))
items, _ := body["items"].([]any)
names := map[string]bool{}
for _, it := range items {
m, _ := it.(map[string]any)
meta, _ := m["metadata"].(map[string]any)
names[meta["name"].(string)] = true
}
if names["team-secret"] {
t.Fatalf("a normal org saw a cluster-wide AppProject CR (CROSS-ORG LEAK): %v", names)
}
if !names["default"] {
t.Fatalf("org /projects missing 'default': %v", names)
}
}
// TestProjectFromIAM_Reflects (requirement a, pure): an IAM Project reflects to a permissive
// argo AppProject — name = Project.Name, description from DisplayName, an org label — and
// surfaces NONE of Tags/Metadata.
func TestProjectFromIAM_Reflects(t *testing.T) {
p := &iamobj.Project{
Owner: "acme", Name: "storefront", Organization: "acme",
DisplayName: "Storefront", Description: "the shop", IsDefault: false,
Tags: []string{"secret-tag"}, Metadata: `{"secret":"x"}`,
}
proj := projectFromIAM(p)
if proj.Metadata.Name != "storefront" {
t.Fatalf("name = %q, want storefront", proj.Metadata.Name)
}
if proj.Spec.Description != "Storefront" {
t.Fatalf("description = %q, want Storefront (DisplayName)", proj.Spec.Description)
}
if proj.Metadata.Labels[orgLabel] != "acme" {
t.Fatalf("org label = %v, want acme", proj.Metadata.Labels)
}
b, _ := json.Marshal(proj)
for _, forbidden := range []string{"secret-tag", "secret", "Metadata", "tags"} {
if strings.Contains(string(b), forbidden) {
t.Fatalf("projected IAM project leaked %q: %s", forbidden, b)
}
}
}
// TestEnsureDefault (invariant): 'default' is prepended when absent, a no-op when present.
func TestEnsureDefault(t *testing.T) {
got := ensureDefault(nil)
if len(got) != 1 || got[0].Metadata.Name != "default" {
t.Fatalf("ensureDefault(nil) = %v, want [default]", got)
}
withDefault := ensureDefault([]argoProject{synthProject("default"), synthProject("team-a")})
count := 0
for _, p := range withDefault {
if p.Metadata.Name == "default" {
count++
}
}
if count != 1 {
t.Fatalf("ensureDefault duplicated 'default': %d copies", count)
}
}
// ── stream: org scoping ──────────────────────────────────────────────────────
// TestStreamBurst_OrgScoped: a tenant's initial burst emits ONLY its own apps.
func TestStreamBurst_OrgScoped(t *testing.T) {
s := twoTenantFleet()
var buf bytes.Buffer
w := bufio.NewWriter(&buf)
if ok := streamAppBurst(s, scope{org: "acme"}, context.Background(), w); !ok {
t.Fatal("streamAppBurst(acme) returned false")
}
_ = w.Flush()
frames := parseSSE(buf.String())
names := map[string]bool{}
for _, f := range frames {
var env struct {
Result applicationWatchEvent `json:"result"`
}
if err := json.Unmarshal([]byte(f), &env); err != nil {
t.Fatalf("bad frame %q: %v", f, err)
}
names[env.Result.Application.Metadata.Name] = true
}
if !names["acme-web"] || !names["acme-api"] {
t.Fatalf("acme burst missing its apps: %v", names)
}
if names["bravo-web"] || names["cloud"] {
t.Fatalf("acme burst leaked another tenant's/system app: %v", names)
}
}
// TestForwardWatch_DropsCrossTenantObject: an org's watch forwarder drops a watched object
// that belongs to another tenant (wrong org label) and one outside its namespace.
func TestForwardWatch_DropsCrossTenantObject(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
fw := watch.NewFake()
events := make(chan streamEvent, 4)
go forwardWatch(ctx, scope{org: "acme"}, "tenant-acme", fw, events)
go func() {
// bravo's app in bravo's namespace — must be dropped.
fw.Action(watch.Added, orgAppCR("tenant-bravo", "bravo-web", "bravo", "site"))
// an object in acme's namespace but mislabeled bravo — must be dropped (label filter).
fw.Action(watch.Added, orgAppCR("tenant-acme", "sneaky", "bravo", "x"))
// acme's own app — must come through.
fw.Action(watch.Added, orgAppCR("tenant-acme", "acme-web", "acme", "storefront"))
}()
select {
case ev := <-events:
if ev.obj.GetName() != "acme-web" {
t.Fatalf("forwarded a cross-tenant object: %q (want only acme-web)", ev.obj.GetName())
}
case <-time.After(2 * time.Second):
t.Fatal("acme's own event never arrived (over-filtered)")
}
}
+246
View File
@@ -0,0 +1,246 @@
// stream.go — GET /v1/deploy/stream/applications: the ArgoCD applications watch
// as Server-Sent Events. The applications view opens this the moment it loads and
// keeps it open for live fleet updates; a 404 here makes the SPA error-toast.
//
// The stream emits one ADDED event per current App CR — the SAME projection
// dashAppList serves (listAppCRs + runningVersions + projectApp: one source, one
// projection) — then watches the App CRs and forwards ADDED/MODIFIED/DELETED as
// they occur, holding the connection open with periodic keep-alives. Every watch +
// goroutine it starts is bound to the request and torn down on return, so a client
// disconnect leaks nothing. Read-only and TENANT-SCOPED (resolveScope: SuperAdmin
// streams the whole fleet, a validated org member only its own org's apps); it fails
// closed (403 unauthorized, 503 when no cluster client is configured) and degrades to
// keep-alive only (the initial state still renders) if the watch verb is not granted.
package deploy
import (
"bufio"
"context"
"encoding/json"
"time"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/watch"
)
// streamKeepalive is the interval between keep-alive comments on an idle stream.
// The keep-alive holds the connection open AND is when a client disconnect is
// detected — a failing flush ends the stream — so it also bounds how long an idle
// disconnected client lingers before its watch is torn down.
const streamKeepalive = 25 * time.Second
// applicationWatchEvent is the ArgoCD v1alpha1 ApplicationWatchEvent — the object
// the SPA reads from each SSE frame's `.result`.
type applicationWatchEvent struct {
Type string `json:"type"` // ADDED | MODIFIED | DELETED
Application argoApp `json:"application"`
}
// streamResult is the `{"result": …}` envelope the SPA unwraps (JSON.parse(data).result).
type streamResult struct {
Result applicationWatchEvent `json:"result"`
}
// dashStreamApps is GET /v1/deploy/stream/applications — the applications watch as
// SSE. Tenant-scoped (resolveScope): a SuperAdmin streams the whole fleet, a validated
// org member streams ONLY its own org's apps; anyone else 403s. 503 when no cluster
// client is configured.
func dashStreamApps(s *cloud.Service[state], c *zip.Ctx) error {
// readiness BEFORE the scope gate: the readiness of the cluster client is already
// public via /v1/deploy/health, and the direct-call fail-closed test asserts the 503.
if err := ready(s); err != nil {
return err
}
sc, ok := resolveScope(c)
if !ok {
return refuse(c)
}
// Capture the context BEFORE SendStreamWriter: its callback runs AFTER this
// handler returns (fasthttp body writer) and must not touch c. c.Context() is
// background-derived — it does NOT cancel on client disconnect; the disconnect
// signal is a failing flush inside the loop.
ctx := c.Context()
setStreamHeaders(c)
return c.SendStreamWriter(func(w *bufio.Writer) {
streamApps(s, sc, ctx, w)
})
}
// setStreamHeaders writes the SSE response headers. Factored out so it is testable
// without spawning the body-stream goroutine (fasthttp starts the writer eagerly
// on SendStreamWriter).
func setStreamHeaders(c *zip.Ctx) {
c.SetHeader("Content-Type", "text/event-stream")
c.SetHeader("Cache-Control", "no-cache")
c.SetHeader("Connection", "keep-alive")
c.SetHeader("X-Accel-Buffering", "no") // never buffer SSE at a proxy
}
// streamApps is the SSE core, separated from the handler so it is unit-testable
// over a bytes.Buffer + a cancelable context: emit the initial ADDED burst, then
// watch for live changes until the client disconnects or the context is canceled.
func streamApps(s *cloud.Service[state], sc scope, ctx context.Context, w *bufio.Writer) {
wctx, cancel := context.WithCancel(ctx)
defer cancel() // stops every watch + forwarder started below
if !streamAppBurst(s, sc, wctx, w) {
return // client gone during the burst
}
streamAppWatch(s, sc, wctx, w)
}
// streamAppBurst emits one ADDED event per current App CR VISIBLE to the scope,
// projected identically to dashAppList. Returns false if a write failed (client
// disconnected).
func streamAppBurst(s *cloud.Service[state], sc scope, ctx context.Context, w *bufio.Writer) bool {
for _, ns := range sc.namespaces() {
crs, err := listAppCRs(s, ctx, ns)
if err != nil {
s.Log.Warn("deploy stream: initial list failed", "namespace", ns, "err", err)
continue // best-effort burst; other namespaces still stream
}
running := runningVersions(s, ctx, ns)
for i := range crs {
if !sc.allows(&crs[i]) {
continue // cross-tenant CR — never streamed to this scope
}
if !writeAppEvent(w, string(watch.Added), projectApp(&crs[i], ns, running[crs[i].GetName()])) {
return false
}
}
}
return true
}
// streamEvent is one forwarded watch event carrying the raw App CR (projected in
// the single-threaded main loop, which owns the running-tag map).
type streamEvent struct {
typ string
ns string
obj *unstructured.Unstructured
}
// streamAppWatch holds the stream open: it forwards live App-CR changes as ArgoCD
// watch events and writes a keep-alive on an idle interval. It returns when the
// context is canceled OR a write fails (client disconnected). Every watch +
// goroutine is bound to ctx and stopped on return — no leak.
func streamAppWatch(s *cloud.Service[state], sc scope, ctx context.Context, w *bufio.Writer) {
events := make(chan streamEvent, 16)
started := 0
for _, ns := range sc.namespaces() {
watcher, err := s.State.dyn.Resource(appsCRGVR).Namespace(ns).Watch(ctx, metav1.ListOptions{})
if err != nil {
// Degrade, don't fail: the initial state stands and the keep-alive holds
// the connection open. The operator can grant the `watch` verb to turn on
// live updates without any code change.
s.Log.Warn("deploy stream: watch unavailable; namespace will not stream live", "namespace", ns, "err", err)
continue
}
started++
// The read plane installs no panic recovery around detached goroutines, and a
// watch event is a system boundary — recover here so a malformed event can never
// crash the whole process; the watcher is still stopped by forwardWatch's defer.
go func(ns string, watcher watch.Interface) {
defer func() {
if r := recover(); r != nil {
s.Log.Error("deploy stream: watch goroutine panic recovered", "namespace", ns, "panic", r)
}
}()
forwardWatch(ctx, sc, ns, watcher, events)
}(ns, watcher)
}
if started > 0 {
s.Log.Info("deploy stream: watching App CRs", "namespaces", started)
}
// Running-image tags refresh on the keep-alive tick, bounding LIST calls to once
// per interval regardless of event rate; a live event projects with the last
// known tag (nil-map reads are the empty string — safe).
running := sc.runningByNamespace(s, ctx)
ticker := time.NewTicker(streamKeepalive)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case ev := <-events:
if !writeAppEvent(w, ev.typ, projectApp(ev.obj, ev.ns, running[ev.ns][ev.obj.GetName()])) {
return
}
case <-ticker.C:
running = sc.runningByNamespace(s, ctx)
if _, err := w.WriteString(": keep-alive\n\n"); err != nil {
return
}
if err := w.Flush(); err != nil {
return
}
}
}
}
// forwardWatch relays one namespace's App-CR watch onto events until ctx is
// canceled or the watch closes (server timeout / Stop). It stops its watcher on
// return and never blocks past ctx — the send to events selects on ctx.Done.
func forwardWatch(ctx context.Context, sc scope, ns string, watcher watch.Interface, events chan<- streamEvent) {
defer watcher.Stop()
for {
select {
case <-ctx.Done():
return
case e, ok := <-watcher.ResultChan():
if !ok {
return // watch closed
}
typ := watchType(e.Type)
if typ == "" {
continue // skip BOOKMARK / ERROR — not an application change
}
obj, ok := e.Object.(*unstructured.Unstructured)
if !ok || obj == nil || obj.GetName() == "" {
continue // guard the typed-nil object: GetName() would nil-deref
}
if !sc.watches(obj) {
continue // only namespaces this scope watches, and (for an org) only its own CRs
}
select {
case events <- streamEvent{typ: typ, ns: ns, obj: obj}:
case <-ctx.Done():
return
}
}
}
}
// watchType maps a k8s watch event type to the ArgoCD WatchType the UI parses.
// BOOKMARK and ERROR are not application changes → "" (skipped).
func watchType(t watch.EventType) string {
switch t {
case watch.Added, watch.Modified, watch.Deleted:
return string(t) // "ADDED" | "MODIFIED" | "DELETED"
default:
return ""
}
}
// writeAppEvent writes one SSE frame — `data: {"result":{"type":…,"application":…}}`
// — and flushes. Returns false when the write/flush fails (client disconnected).
func writeAppEvent(w *bufio.Writer, typ string, app argoApp) bool {
payload, err := json.Marshal(streamResult{Result: applicationWatchEvent{Type: typ, Application: app}})
if err != nil {
return true // unreachable for this shape; skip the frame rather than kill the stream
}
if _, err := w.WriteString("data: "); err != nil {
return false
}
if _, err := w.Write(payload); err != nil {
return false
}
if _, err := w.WriteString("\n\n"); err != nil {
return false
}
return w.Flush() == nil
}
+115
View File
@@ -0,0 +1,115 @@
// Package domain is Hanzo Domains — the DOMAIN-REGISTRATION product: search a name,
// see its price (with Hanzo's markup), buy it billed through the customer's prepaid
// wallet, and have it born pointing at Hanzo's own authoritative nameservers.
//
// This is DISTINCT from Hanzo DNS (hanzoai/dns): DNS manages records for a domain you
// already control; Domains ACQUIRES the domain. After a purchase, Domains hands the
// new zone to hanzoai/dns and points the registrar's nameservers at it — the two
// products compose (buy here, manage records there).
//
// Wholesale is resold from a registrar behind the Registrar interface (name.com Core
// API v4 today, clients/domain/namecom). The core here is transport-free: it
// orchestrates availability → price → authorize → register → provision-zone →
// capture → record over four interfaces (Registrar, Biller, Zones, Store), so the
// policy is unit-testable with no HTTP/registrar/billing backend. mount.go is the thin
// cloud adapter that binds the real backends and exposes /v1/domain/*.
package domain
import (
"context"
"errors"
"github.com/hanzoai/cloud/clients/domain/namecom"
)
// Sentinels the orchestration returns; the HTTP adapter maps them to status codes.
var (
// ErrInsufficientFunds — the org's prepaid balance cannot cover the price. → 402.
ErrInsufficientFunds = errors.New("domain: insufficient balance for this purchase")
// ErrUnavailable — the name is not purchasable (taken, reserved, or unsupported TLD). → 409.
ErrUnavailable = errors.New("domain: name is not available to register")
// ErrAlreadyOwned — this org already holds this domain. → 409.
ErrAlreadyOwned = errors.New("domain: already registered to this org")
// ErrNotOwned — a renew/transfer/manage on a domain this org does not hold. → 404/403.
ErrNotOwned = errors.New("domain: not registered to this org")
// ErrNotConfigured — the registrar has no credentials. → 503.
ErrNotConfigured = errors.New("domain: registrar not configured")
)
// Registrar is the wholesale registrar Hanzo resells. *namecom.Client satisfies it.
type Registrar interface {
CheckAvailability(ctx context.Context, names ...string) (*namecom.SearchResponse, error)
Search(ctx context.Context, keyword string, tldFilter ...string) (*namecom.SearchResponse, error)
CreateDomain(ctx context.Context, req namecom.CreateDomainRequest) (*namecom.CreateDomainResponse, error)
RenewDomain(ctx context.Context, domainName string, req namecom.RenewDomainRequest) (*namecom.RenewDomainResponse, error)
SetNameservers(ctx context.Context, domainName string, nameservers []string) (*namecom.Domain, error)
CreateTransfer(ctx context.Context, req namecom.TransferRequest) (*namecom.TransferResponse, error)
Hello(ctx context.Context) (*namecom.HelloResponse, error)
Configured() bool
}
// Biller is the two-phase deposit→charge a purchase bills through. Authorize refuses
// when the org's prepaid balance cannot cover the marked-up price (BEFORE the
// registrar is touched); Capture debits it AFTER the registrar succeeds. Backed by
// cloud's ResourceMeter (Gate → Authorize, Meter → Capture).
type Biller interface {
// Authorize returns ErrInsufficientFunds when the balance can't cover cents, nil
// to proceed, or another error when the balance is unknown (fail-closed).
Authorize(ctx context.Context, org string, cents int64) error
// Capture records the debit against the org's ledger. ref is the idempotency /
// attribution key (e.g. "domain:register:acme.ai").
Capture(org string, cents int64, ref string)
}
// Zones ensures an authoritative DNS zone exists for a freshly-registered domain and
// returns the nameservers to point it at. Backed by hanzoai/dns.
type Zones interface {
EnsureZone(ctx context.Context, org, domainName string) (nameservers []string, err error)
}
// Record is the domain↔org ownership row Hanzo issues on a successful purchase.
type Record struct {
Org string `json:"org"`
Domain string `json:"domain"`
RegisteredAt int64 `json:"registeredAt"` // unix seconds
ExpiresAt string `json:"expiresAt,omitempty"`
PriceCents int64 `json:"priceCents"` // what the customer paid (sell)
CostCents int64 `json:"costCents"` // wholesale cost
Nameservers []string `json:"nameservers,omitempty"`
Order int64 `json:"order,omitempty"` // registrar order id
}
// Store persists ownership records.
type Store interface {
Put(rec Record) error
Get(org, domainName string) (Record, bool, error)
ListByOrg(org string) ([]Record, error)
}
// Config tunes pricing and the DNS handoff.
type Config struct {
Markup Markup // wholesale → sell
Nameservers []string // Hanzo authoritative NS to point purchased domains at (fallback when Zones returns none)
Env string // registrar env label (test/prod) — attribution only
}
// Service is the transport-free orchestrator.
type Service struct {
reg Registrar
bill Biller
zones Zones
store Store
cfg Config
}
// NewService builds the orchestrator. All four backends are required; pass a no-op
// Zones/Store in a context that doesn't need them.
func NewService(reg Registrar, bill Biller, zones Zones, store Store, cfg Config) *Service {
return &Service{reg: reg, bill: bill, zones: zones, store: store, cfg: cfg}
}
// Env reports the registrar environment label (test/prod).
func (s *Service) Env() string { return s.cfg.Env }
// Configured reports whether the registrar has credentials.
func (s *Service) Configured() bool { return s.reg.Configured() }
+388
View File
@@ -0,0 +1,388 @@
package domain
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/domain/namecom"
"github.com/hanzoai/cloud/clients/metering"
"github.com/hanzoai/cloud/clients/principal"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// Mount wires Hanzo Domains onto the unified cloud binary as /v1/domain/*:
//
// GET /v1/domain/health registrar reachability (no auth)
// GET /v1/domain/search?q=&tld= keyword search + alternate TLDs (priced)
// GET /v1/domain/availability?domain=a,b exact-name availability + pricing
// GET /v1/domain/domains the org's registered domains
// POST /v1/domain/register {domain,years,contacts?} buy (billed)
// POST /v1/domain/renew {domain,years} renew (billed)
// POST /v1/domain/transfer {domain,authCode,years} transfer-in (billed)
//
// Every mutating route is org-scoped: a validated principal's org owns the purchase
// and is the ledger the charge lands on. The registrar's wholesale credentials come
// from the platform secret store (KMS) via the operator-injected env NAMECOM_USER /
// NAMECOM_TOKEN — never hard-coded, exactly as clients/sites reads CF_API_TOKEN.
func Mount(app *zip.App, deps cloud.Deps) error {
return cloud.Mount(app, deps, "domain", buildState, routes)
}
// state is the subsystem's data: the orchestrator plus the raw registrar (for the
// health probe's Hello) and a logger.
type state struct {
svc *Service
reg Registrar
log luxlog.Logger
}
func buildState(b cloud.Base) (state, error) {
cfg := configFromEnv()
reg := namecom.New(
strings.TrimSpace(os.Getenv("NAMECOM_USER")),
strings.TrimSpace(os.Getenv("NAMECOM_TOKEN")),
cfg.Env, nil,
)
biller := &meterBiller{rm: b.Bill}
zones := &hanzodnsZones{
base: strings.TrimRight(strings.TrimSpace(os.Getenv("HANZO_DNS_URL")), "/"),
ns: cfg.Nameservers,
http: &http.Client{Timeout: 10 * time.Second},
log: b.Log,
}
svc := NewService(reg, biller, zones, NewMemStore(), cfg)
b.Log.Info("hanzo domains ready",
"registrar", "name.com",
"env", cfg.Env,
"configured", reg.Configured(),
"nameservers", strings.Join(cfg.Nameservers, ","),
"markup", cfg.Markup.Multiplier,
)
return state{svc: svc, reg: reg, log: b.Log}, nil
}
func routes(app *zip.App, s *cloud.Service[state]) {
// Handlers mount AFTER commerce, whose /v1 error filter flattens a propagated
// error to 500 — so wrap in Terminal to preserve real 4xx/402/409 statuses.
h := func(fn func(*cloud.Service[state], *zip.Ctx) error) func(*zip.Ctx) error {
return cloud.Terminal(cloud.Handle(s, fn))
}
app.Get("/v1/domain/health", cloud.Handle(s, health))
app.Get("/v1/domain/search", h(searchHandler))
app.Get("/v1/domain/availability", h(availabilityHandler))
app.Get("/v1/domain/domains", h(listHandler))
app.Post("/v1/domain/register", h(registerHandler))
app.Post("/v1/domain/renew", h(renewHandler))
app.Post("/v1/domain/transfer", h(transferHandler))
}
// ── config ───────────────────────────────────────────────────────────────────────
func configFromEnv() Config {
return Config{
Markup: Markup{
Multiplier: floatEnv("DOMAIN_MARKUP", 1.15),
MinMarginCents: intEnv("DOMAIN_MIN_MARGIN_CENTS", 300),
},
Nameservers: nsEnv("HANZO_NAMESERVERS", []string{"ns1.hanzo.ai", "ns2.hanzo.ai"}),
// The registrar env is EXPLICIT and fail-safe: only "prod" hits the live,
// billable registrar; anything else (incl. unset) is the sandbox.
Env: envOr("NAMECOM_ENV", "test"),
}
}
func envOr(key, def string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return def
}
func floatEnv(key string, def float64) float64 {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
return f
}
}
return def
}
func intEnv(key string, def int64) int64 {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
return n
}
}
return def
}
func nsEnv(key string, def []string) []string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
parts := strings.Split(v, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
if len(out) > 0 {
return out
}
}
return def
}
// ── billing adapter (ResourceMeter → Biller) ──────────────────────────────────────
// meterBiller adapts cloud's ResourceMeter to the Biller interface: Gate is the
// pre-charge balance authorize, Meter is the debit capture. This is the exact
// deposit→charge seam every metered subsystem uses.
type meterBiller struct{ rm *cloud.ResourceMeter }
func (m *meterBiller) Authorize(ctx context.Context, org string, cents int64) error {
// ("", false): no project sub-scope on a domain purchase — org- and
// service-scoped caps apply; a domain buy is not project-attributed.
err := m.rm.Gate(ctx, org, "", false, "domain.register", cents)
if errors.Is(err, metering.ErrInsufficientBalance) {
return ErrInsufficientFunds
}
return err
}
func (m *meterBiller) Capture(org string, cents int64, ref string) {
m.rm.Meter(org, "", "domain.register", cents, ref, "")
}
// ── DNS adapter (hanzoai/dns) ──────────────────────────────────────────────────────
// hanzodnsZones ensures an authoritative zone exists in hanzoai/dns for a purchased
// domain and reports the Hanzo nameservers to point it at. When HANZO_DNS_URL is
// unset it is a no-op that still returns the nameservers, so a registration always
// points at Hanzo's NS even before the zone control plane is wired in an environment.
type hanzodnsZones struct {
base string
ns []string
http *http.Client
log luxlog.Logger
}
func (z *hanzodnsZones) EnsureZone(ctx context.Context, org, domainName string) ([]string, error) {
if z.base == "" {
return z.ns, nil
}
payload, _ := json.Marshal(map[string]any{"zone": domainName, "orgId": org})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, z.base+"/v1/dns/zones", bytes.NewReader(payload))
if err != nil {
return z.ns, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Org-Id", org)
resp, err := z.http.Do(req)
if err != nil {
z.log.Warn("hanzodns ensure-zone failed (registering against Hanzo NS anyway)", "domain", domainName, "err", err)
return z.ns, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode/100 != 2 && resp.StatusCode != http.StatusConflict {
z.log.Warn("hanzodns ensure-zone non-2xx (continuing)", "domain", domainName, "status", resp.StatusCode)
return z.ns, errors.New("hanzodns: status " + strconv.Itoa(resp.StatusCode))
}
return z.ns, nil
}
// ── handlers ───────────────────────────────────────────────────────────────────────
func org(c *zip.Ctx) (string, bool) { return principal.Org(c) }
// statusErr maps a core sentinel / registrar error to a zip HTTP status.
func statusErr(err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, ErrNotConfigured):
return zip.Errorf(http.StatusServiceUnavailable, "domain registration is not configured on this deployment")
case errors.Is(err, ErrInsufficientFunds):
return zip.Errorf(http.StatusPaymentRequired, "insufficient balance — add credits to buy this domain")
case errors.Is(err, ErrUnavailable):
return zip.ErrConflict("that domain is not available to register")
case errors.Is(err, ErrAlreadyOwned):
return zip.ErrConflict("your org already owns that domain")
case errors.Is(err, ErrNotOwned):
return zip.ErrNotFound("your org does not own that domain")
}
var apiErr *namecom.APIError
if errors.As(err, &apiErr) {
// Surface the registrar's own message; a 4xx from the registrar is a client
// problem, a 5xx a bad-gateway.
status := http.StatusBadGateway
if apiErr.Status >= 400 && apiErr.Status < 500 {
status = apiErr.Status
}
return zip.Errorf(status, "registrar: %s", apiErr.Message)
}
return zip.Errorf(http.StatusInternalServerError, "%v", err)
}
// health probes registrar reachability. Public (like every subsystem health) and
// honest: it reports whether credentials are present and, if so, whether name.com
// accepts them (the current go-live blocker surfaces here as ok:false + the reason).
func health(s *cloud.Service[state], c *zip.Ctx) error {
res := map[string]any{"service": "domain", "registrar": "name.com", "env": s.State.svc.Env()}
if !s.State.reg.Configured() {
res["status"], res["configured"] = "degraded", false
res["error"] = "registrar credentials not set (NAMECOM_USER/NAMECOM_TOKEN)"
return c.JSON(http.StatusServiceUnavailable, res)
}
res["configured"] = true
ctx, cancel := context.WithTimeout(c.Context(), 8*time.Second)
defer cancel()
if _, err := s.State.reg.Hello(ctx); err != nil {
res["status"], res["reachable"] = "degraded", false
res["error"] = err.Error()
return c.JSON(http.StatusServiceUnavailable, res)
}
res["status"], res["reachable"] = "ok", true
return c.JSON(http.StatusOK, res)
}
func searchHandler(s *cloud.Service[state], c *zip.Ctx) error {
if _, ok := org(c); !ok {
return zip.ErrForbidden("a validated principal is required")
}
q := strings.TrimSpace(c.Query("q"))
if q == "" {
return zip.ErrBadRequest("q (keyword) is required")
}
var tlds []string
if raw := strings.TrimSpace(c.Query("tld")); raw != "" {
for _, t := range strings.Split(raw, ",") {
if t = strings.TrimSpace(t); t != "" {
tlds = append(tlds, t)
}
}
}
quotes, err := s.State.svc.Search(c.Context(), q, tlds...)
if err != nil {
return statusErr(err)
}
return c.JSON(http.StatusOK, map[string]any{"results": quotes})
}
func availabilityHandler(s *cloud.Service[state], c *zip.Ctx) error {
if _, ok := org(c); !ok {
return zip.ErrForbidden("a validated principal is required")
}
raw := strings.TrimSpace(c.Query("domain"))
if raw == "" {
return zip.ErrBadRequest("domain is required (comma-separate for multiple)")
}
var names []string
for _, n := range strings.Split(raw, ",") {
if n = strings.ToLower(strings.TrimSpace(n)); n != "" {
names = append(names, n)
}
}
quotes, err := s.State.svc.Availability(c.Context(), names...)
if err != nil {
return statusErr(err)
}
return c.JSON(http.StatusOK, map[string]any{"results": quotes})
}
func listHandler(s *cloud.Service[state], c *zip.Ctx) error {
o, ok := org(c)
if !ok {
return zip.ErrForbidden("a validated principal is required")
}
recs, err := s.State.svc.ListByOrg(o)
if err != nil {
return statusErr(err)
}
if recs == nil {
recs = []Record{}
}
return c.JSON(http.StatusOK, map[string]any{"domains": recs})
}
type registerReq struct {
Domain string `json:"domain"`
Years int `json:"years"`
Contacts *namecom.Contacts `json:"contacts,omitempty"`
}
func registerHandler(s *cloud.Service[state], c *zip.Ctx) error {
o, ok := org(c)
if !ok {
return zip.ErrForbidden("a validated principal is required")
}
var body registerReq
if err := c.Bind(&body); err != nil {
return err
}
if strings.TrimSpace(body.Domain) == "" {
return zip.ErrBadRequest("domain is required")
}
res, err := s.State.svc.Register(c.Context(), o, body.Domain, body.Years, body.Contacts)
if err != nil {
return statusErr(err)
}
return c.JSON(http.StatusOK, res)
}
type renewReq struct {
Domain string `json:"domain"`
Years int `json:"years"`
}
func renewHandler(s *cloud.Service[state], c *zip.Ctx) error {
o, ok := org(c)
if !ok {
return zip.ErrForbidden("a validated principal is required")
}
var body renewReq
if err := c.Bind(&body); err != nil {
return err
}
if strings.TrimSpace(body.Domain) == "" {
return zip.ErrBadRequest("domain is required")
}
res, err := s.State.svc.Renew(c.Context(), o, body.Domain, body.Years)
if err != nil {
return statusErr(err)
}
return c.JSON(http.StatusOK, res)
}
type transferReq struct {
Domain string `json:"domain"`
AuthCode string `json:"authCode"`
Years int `json:"years"`
}
func transferHandler(s *cloud.Service[state], c *zip.Ctx) error {
o, ok := org(c)
if !ok {
return zip.ErrForbidden("a validated principal is required")
}
var body transferReq
if err := c.Bind(&body); err != nil {
return err
}
if strings.TrimSpace(body.Domain) == "" || strings.TrimSpace(body.AuthCode) == "" {
return zip.ErrBadRequest("domain and authCode are required")
}
res, err := s.State.svc.Transfer(c.Context(), o, body.Domain, body.AuthCode, body.Years)
if err != nil {
return statusErr(err)
}
return c.JSON(http.StatusOK, res)
}
+144
View File
@@ -0,0 +1,144 @@
// Package namecom is a minimal client for the name.com Core API v4 — the
// wholesale registrar surface Hanzo Domains resells: check availability, price,
// register, renew, transfer, and set nameservers/contacts on a domain.
//
// Contract (name.com Core API v4):
// - Base URL: https://api.name.com (production), https://api.dev.name.com (test).
// - Auth: HTTP Basic — username + API token.
// - Actions use a ":verb" suffix on the collection/resource, e.g.
// POST /v4/domains:checkAvailability, POST /v4/domains/{domain}:setNameservers.
//
// Credentials are NEVER hard-coded here: the caller passes the username/token it
// read from the platform secret store (KMS), exactly as the Cloudflare purger reads
// CF_API_TOKEN. This package holds no secret custody of its own.
package namecom
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Base URLs for the two name.com environments. Test is a full sandbox connected to
// the registries' own test systems — register/renew/transfer are exercisable there
// with no real charge.
const (
BaseProd = "https://api.name.com"
BaseTest = "https://api.dev.name.com"
)
// BaseFor maps an environment slug to its base URL. Anything other than "prod"/
// "production"/"mainnet" resolves to the TEST sandbox — fail-safe: an unset or
// misspelled env can never accidentally hit the live, billable registrar.
func BaseFor(env string) string {
switch strings.ToLower(strings.TrimSpace(env)) {
case "prod", "production", "mainnet", "live":
return BaseProd
default:
return BaseTest
}
}
// Client calls the name.com v4 API with HTTP Basic auth. It is safe to share across
// goroutines. A zero token/user yields a client whose calls fail closed at name.com
// (401/403) rather than panicking — the caller checks Configured() to degrade early.
type Client struct {
user string
token string
base string
http *http.Client
}
// New builds a client for (user, token) against the base URL for env. httpClient is
// optional (nil ⇒ a 20s-timeout default).
func New(user, token, env string, httpClient *http.Client) *Client {
if httpClient == nil {
httpClient = &http.Client{Timeout: 20 * time.Second}
}
return &Client{
user: strings.TrimSpace(user),
token: strings.TrimSpace(token),
base: BaseFor(env),
http: httpClient,
}
}
// NewWithBase is New with an explicit base URL — used by tests to point at an
// httptest server.
func NewWithBase(user, token, base string, httpClient *http.Client) *Client {
c := New(user, token, "", httpClient)
c.base = strings.TrimRight(base, "/")
return c
}
// Configured reports whether both a username and a token are present, i.e. whether
// a call has any chance of authenticating.
func (c *Client) Configured() bool { return c.user != "" && c.token != "" }
// APIError is a non-2xx response from name.com. name.com returns
// {"message":"...","details":"..."} on error; both are surfaced.
type APIError struct {
Status int `json:"-"`
Message string `json:"message"`
Details string `json:"details"`
}
func (e *APIError) Error() string {
if e.Details != "" {
return fmt.Sprintf("name.com %d: %s (%s)", e.Status, e.Message, e.Details)
}
return fmt.Sprintf("name.com %d: %s", e.Status, e.Message)
}
// do issues one request. method+path are the HTTP verb and the "/v4/..." path
// (including any ":verb" action suffix); body is JSON-marshaled when non-nil; out
// is JSON-unmarshaled from a 2xx response when non-nil.
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
var rdr io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("namecom: marshal request: %w", err)
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.base+path, rdr)
if err != nil {
return fmt.Errorf("namecom: build request: %w", err)
}
req.SetBasicAuth(c.user, c.token)
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("namecom: %s %s: %w", method, path, err)
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode/100 != 2 {
apiErr := &APIError{Status: resp.StatusCode}
_ = json.Unmarshal(raw, apiErr)
if apiErr.Message == "" {
apiErr.Message = strings.TrimSpace(string(raw))
if apiErr.Message == "" {
apiErr.Message = http.StatusText(resp.StatusCode)
}
}
return apiErr
}
if out != nil && len(raw) > 0 {
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("namecom: decode response: %w", err)
}
}
return nil
}
+167
View File
@@ -0,0 +1,167 @@
package namecom
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// mockServer stands in for name.com v4: it asserts Basic auth + records the path/body
// of each request, and replies with the canned JSON the handler registers.
func mockServer(t *testing.T, wantUser, wantToken string, routes map[string]func(*http.Request) (int, any)) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, p, ok := r.BasicAuth()
if !ok || u != wantUser || p != wantToken {
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(APIError{Message: "Permission Denied"})
return
}
key := r.Method + " " + r.URL.Path
h, ok := routes[key]
if !ok {
t.Errorf("unexpected request %s", key)
w.WriteHeader(http.StatusNotFound)
_ = json.NewEncoder(w).Encode(APIError{Message: "route not mocked: " + key})
return
}
code, body := h(r)
w.WriteHeader(code)
if body != nil {
_ = json.NewEncoder(w).Encode(body)
}
}))
}
func TestBaseFor(t *testing.T) {
if got := BaseFor("prod"); got != BaseProd {
t.Fatalf("prod → %s, want %s", got, BaseProd)
}
// Anything non-prod must resolve to the sandbox — an unset env can never hit live.
for _, env := range []string{"", "test", "dev", "testnet", "typo", "TEST"} {
if got := BaseFor(env); got != BaseTest {
t.Fatalf("env %q → %s, want sandbox %s", env, got, BaseTest)
}
}
}
func TestCheckAvailability(t *testing.T) {
srv := mockServer(t, "u", "tok", map[string]func(*http.Request) (int, any){
"POST /v4/domains:checkAvailability": func(r *http.Request) (int, any) {
var req AvailabilityRequest
_ = json.NewDecoder(r.Body).Decode(&req)
if len(req.DomainNames) != 1 || req.DomainNames[0] != "acme.ai" {
t.Errorf("body domainNames = %v", req.DomainNames)
}
return 200, SearchResponse{Results: []SearchResult{{
DomainName: "acme.ai", Purchasable: true, PurchasePrice: 55.99, RenewalPrice: 55.99, TLD: "ai",
}}}
},
})
defer srv.Close()
c := NewWithBase("u", "tok", srv.URL, nil)
res, err := c.CheckAvailability(context.Background(), "acme.ai")
if err != nil {
t.Fatal(err)
}
if len(res.Results) != 1 || !res.Results[0].Purchasable || res.Results[0].PurchasePrice != 55.99 {
t.Fatalf("unexpected result: %+v", res.Results)
}
}
func TestAuthFailureSurfacesAPIError(t *testing.T) {
srv := mockServer(t, "right", "right", nil)
defer srv.Close()
c := NewWithBase("wrong", "creds", srv.URL, nil)
_, err := c.Hello(context.Background())
apiErr, ok := err.(*APIError)
if !ok {
t.Fatalf("want *APIError, got %T: %v", err, err)
}
if apiErr.Status != http.StatusUnauthorized || apiErr.Message != "Permission Denied" {
t.Fatalf("unexpected APIError: %+v", apiErr)
}
}
func TestCreateDomainSendsPriceCapAndYears(t *testing.T) {
srv := mockServer(t, "u", "tok", map[string]func(*http.Request) (int, any){
"POST /v4/domains": func(r *http.Request) (int, any) {
var req CreateDomainRequest
_ = json.NewDecoder(r.Body).Decode(&req)
if req.Domain.DomainName != "acme.ai" {
t.Errorf("domainName = %q", req.Domain.DomainName)
}
if req.PurchasePrice != 55.99 {
t.Errorf("purchasePrice cap = %v, want 55.99", req.PurchasePrice)
}
if req.Years != 1 {
t.Errorf("years = %d, want default 1", req.Years)
}
if len(req.Domain.Nameservers) != 2 {
t.Errorf("nameservers = %v", req.Domain.Nameservers)
}
return 200, CreateDomainResponse{
Domain: &Domain{DomainName: "acme.ai", Nameservers: req.Domain.Nameservers},
Order: 42,
TotalPaid: 55.99,
}
},
})
defer srv.Close()
c := NewWithBase("u", "tok", srv.URL, nil)
res, err := c.CreateDomain(context.Background(), CreateDomainRequest{
Domain: DomainInput{DomainName: "acme.ai", Nameservers: []string{"ns1.hanzo.ai", "ns2.hanzo.ai"}},
PurchasePrice: 55.99,
})
if err != nil {
t.Fatal(err)
}
if res.TotalPaid != 55.99 || res.Order != 42 {
t.Fatalf("unexpected create response: %+v", res)
}
}
func TestSetNameserversPathAndBody(t *testing.T) {
srv := mockServer(t, "u", "tok", map[string]func(*http.Request) (int, any){
"POST /v4/domains/acme.ai:setNameservers": func(r *http.Request) (int, any) {
var req SetNameserversRequest
_ = json.NewDecoder(r.Body).Decode(&req)
if len(req.Nameservers) != 2 || req.Nameservers[0] != "ns1.hanzo.ai" {
t.Errorf("nameservers = %v", req.Nameservers)
}
return 200, Domain{DomainName: "acme.ai", Nameservers: req.Nameservers}
},
})
defer srv.Close()
c := NewWithBase("u", "tok", srv.URL, nil)
d, err := c.SetNameservers(context.Background(), "acme.ai", []string{"ns1.hanzo.ai", "ns2.hanzo.ai"})
if err != nil {
t.Fatal(err)
}
if len(d.Nameservers) != 2 {
t.Fatalf("unexpected: %+v", d)
}
}
func TestRenewDefaultsYears(t *testing.T) {
srv := mockServer(t, "u", "tok", map[string]func(*http.Request) (int, any){
"POST /v4/domains/acme.ai:renew": func(r *http.Request) (int, any) {
var req RenewDomainRequest
_ = json.NewDecoder(r.Body).Decode(&req)
if req.Years != 1 {
t.Errorf("years = %d, want 1", req.Years)
}
return 200, RenewDomainResponse{Domain: &Domain{DomainName: "acme.ai"}, TotalPaid: 55.99}
},
})
defer srv.Close()
c := NewWithBase("u", "tok", srv.URL, nil)
if _, err := c.RenewDomain(context.Background(), "acme.ai", RenewDomainRequest{}); err != nil {
t.Fatal(err)
}
}
+121
View File
@@ -0,0 +1,121 @@
package namecom
import (
"context"
"net/url"
)
// Hello probes auth + connectivity: GET /v4/hello. A 2xx means the credentials are
// accepted and API access is enabled for the account; a 403 "Permission Denied" means
// the token is IP-locked or the account lacks API/reseller access.
func (c *Client) Hello(ctx context.Context) (*HelloResponse, error) {
var out HelloResponse
if err := c.do(ctx, "GET", "/v4/hello", nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// CheckAvailability checks one or more exact domain names: POST /v4/domains:checkAvailability.
// Each result carries purchasable + the wholesale first-term and renewal prices (USD).
func (c *Client) CheckAvailability(ctx context.Context, names ...string) (*SearchResponse, error) {
var out SearchResponse
if err := c.do(ctx, "POST", "/v4/domains:checkAvailability",
AvailabilityRequest{DomainNames: names}, &out); err != nil {
return nil, err
}
return &out, nil
}
// Search runs a keyword search that also suggests alternate TLDs:
// POST /v4/domains:search. tldFilter (optional) narrows the returned TLDs.
func (c *Client) Search(ctx context.Context, keyword string, tldFilter ...string) (*SearchResponse, error) {
var out SearchResponse
req := SearchRequest{Keyword: keyword}
if len(tldFilter) > 0 {
req.TLDFilter = tldFilter
}
if err := c.do(ctx, "POST", "/v4/domains:search", req, &out); err != nil {
return nil, err
}
return &out, nil
}
// GetDomain reads one domain the reseller owns: GET /v4/domains/{domain}.
func (c *Client) GetDomain(ctx context.Context, domain string) (*Domain, error) {
var out Domain
if err := c.do(ctx, "GET", "/v4/domains/"+url.PathEscape(domain), nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// ListDomains lists the domains the reseller owns: GET /v4/domains.
func (c *Client) ListDomains(ctx context.Context) (*ListDomainsResponse, error) {
var out ListDomainsResponse
if err := c.do(ctx, "GET", "/v4/domains", nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// CreateDomain registers a domain: POST /v4/domains. This DEBITS the reseller account
// at name.com by the wholesale price. purchasePrice caps the accepted charge (a price
// change above it is rejected). years defaults to 1.
func (c *Client) CreateDomain(ctx context.Context, req CreateDomainRequest) (*CreateDomainResponse, error) {
if req.Years <= 0 {
req.Years = 1
}
var out CreateDomainResponse
if err := c.do(ctx, "POST", "/v4/domains", req, &out); err != nil {
return nil, err
}
return &out, nil
}
// RenewDomain renews a registered domain: POST /v4/domains/{domain}:renew.
func (c *Client) RenewDomain(ctx context.Context, domain string, req RenewDomainRequest) (*RenewDomainResponse, error) {
if req.Years <= 0 {
req.Years = 1
}
var out RenewDomainResponse
if err := c.do(ctx, "POST", "/v4/domains/"+url.PathEscape(domain)+":renew", req, &out); err != nil {
return nil, err
}
return &out, nil
}
// SetNameservers points a registered domain at the given authoritative nameservers:
// POST /v4/domains/{domain}:setNameservers. This is the step that hands DNS control
// to Hanzo's own nameservers after registration.
func (c *Client) SetNameservers(ctx context.Context, domain string, nameservers []string) (*Domain, error) {
var out Domain
if err := c.do(ctx, "POST", "/v4/domains/"+url.PathEscape(domain)+":setNameservers",
SetNameserversRequest{Nameservers: nameservers}, &out); err != nil {
return nil, err
}
return &out, nil
}
// SetContacts updates the WHOIS contact set: POST /v4/domains/{domain}:setContacts.
func (c *Client) SetContacts(ctx context.Context, domain string, contacts Contacts) (*Domain, error) {
var out Domain
if err := c.do(ctx, "POST", "/v4/domains/"+url.PathEscape(domain)+":setContacts",
SetContactsRequest{Contacts: contacts}, &out); err != nil {
return nil, err
}
return &out, nil
}
// CreateTransfer starts a transfer-in of a domain the customer owns elsewhere:
// POST /v4/transfers. authCode is the EPP/auth code from the losing registrar.
func (c *Client) CreateTransfer(ctx context.Context, req TransferRequest) (*TransferResponse, error) {
if req.Years <= 0 {
req.Years = 1
}
var out TransferResponse
if err := c.do(ctx, "POST", "/v4/transfers", req, &out); err != nil {
return nil, err
}
return &out, nil
}
+159
View File
@@ -0,0 +1,159 @@
package namecom
// The request/response shapes below mirror the name.com Core API v4 JSON. Only the
// fields Hanzo Domains uses are modeled; unknown fields are ignored on decode.
// Contact is a WHOIS/registration contact. name.com requires registrant/admin/tech/
// billing contacts on register; missing ones default to the reseller account.
type Contact struct {
FirstName string `json:"firstName,omitempty"`
LastName string `json:"lastName,omitempty"`
Company string `json:"companyName,omitempty"`
Address1 string `json:"address1,omitempty"`
Address2 string `json:"address2,omitempty"`
City string `json:"city,omitempty"`
State string `json:"state,omitempty"`
Zip string `json:"zip,omitempty"`
Country string `json:"country,omitempty"` // ISO-3166 alpha-2, e.g. "US"
Phone string `json:"phone,omitempty"` // +NN.NNNNNNN
Fax string `json:"fax,omitempty"`
Email string `json:"email,omitempty"`
}
// Contacts is the four-role contact set for a domain.
type Contacts struct {
Registrant *Contact `json:"registrant,omitempty"`
Admin *Contact `json:"admin,omitempty"`
Tech *Contact `json:"tech,omitempty"`
Billing *Contact `json:"billing,omitempty"`
}
// Domain is a domain record as name.com returns it (get/create/renew/setNameservers).
type Domain struct {
DomainName string `json:"domainName"`
Nameservers []string `json:"nameservers,omitempty"`
Contacts *Contacts `json:"contacts,omitempty"`
Locked bool `json:"locked,omitempty"`
AutorenewOn bool `json:"autorenewEnabled,omitempty"`
ExpireDate string `json:"expireDate,omitempty"` // RFC3339
CreateDate string `json:"createDate,omitempty"` // RFC3339
RenewalPrice float64 `json:"renewalPrice,omitempty"` // USD
PrivacyOn bool `json:"privacyEnabled,omitempty"`
}
// AvailabilityRequest is the body of POST /v4/domains:checkAvailability.
type AvailabilityRequest struct {
DomainNames []string `json:"domainNames"`
}
// SearchRequest is the body of POST /v4/domains:search — a keyword search that also
// suggests alternate TLDs. tldFilter narrows to specific TLDs (e.g. ["ai","com"]).
type SearchRequest struct {
Keyword string `json:"keyword"`
TLDFilter []string `json:"tldFilter,omitempty"`
Timeout int `json:"timeout,omitempty"` // ms; name.com caps the search
}
// SearchResult is one candidate in a search / availability response. Prices are USD.
type SearchResult struct {
DomainName string `json:"domainName"`
SLD string `json:"sld,omitempty"`
TLD string `json:"tld,omitempty"`
Purchasable bool `json:"purchasable"`
Premium bool `json:"premium,omitempty"`
PurchasePrice float64 `json:"purchasePrice,omitempty"` // first-term registration, USD
PurchaseType string `json:"purchaseType,omitempty"` // "registration" | "renewal" | ...
RenewalPrice float64 `json:"renewalPrice,omitempty"` // USD
Transferable bool `json:"transferable,omitempty"`
}
// SearchResponse wraps the results for both search and checkAvailability.
type SearchResponse struct {
Results []SearchResult `json:"results"`
}
// CreateDomainRequest is the body of POST /v4/domains (register). purchasePrice is
// the price the caller EXPECTS to pay (the wholesale quote from availability); name.com
// rejects a registration whose real price exceeds it — a guard against a price change
// between quote and buy. years defaults to 1.
type CreateDomainRequest struct {
Domain DomainInput `json:"domain"`
PurchasePrice float64 `json:"purchasePrice,omitempty"`
Years int `json:"years,omitempty"`
TLDRequirements map[string]string `json:"tldRequirements,omitempty"`
}
// DomainInput is the domain sub-object of a create request.
type DomainInput struct {
DomainName string `json:"domainName"`
Nameservers []string `json:"nameservers,omitempty"`
Contacts *Contacts `json:"contacts,omitempty"`
PrivacyOn bool `json:"privacyEnabled,omitempty"`
}
// CreateDomainResponse is the register result: the created domain plus what name.com
// actually charged the reseller account (order + totalPaid, USD).
type CreateDomainResponse struct {
Domain *Domain `json:"domain"`
Order int64 `json:"order,omitempty"`
TotalPaid float64 `json:"totalPaid,omitempty"`
}
// RenewDomainRequest is the body of POST /v4/domains/{domain}:renew.
type RenewDomainRequest struct {
PurchasePrice float64 `json:"purchasePrice,omitempty"`
Years int `json:"years,omitempty"`
}
// RenewDomainResponse is the renew result.
type RenewDomainResponse struct {
Domain *Domain `json:"domain"`
Order int64 `json:"order,omitempty"`
TotalPaid float64 `json:"totalPaid,omitempty"`
}
// SetNameserversRequest is the body of POST /v4/domains/{domain}:setNameservers —
// this is how a registered domain is pointed at Hanzo's authoritative nameservers.
type SetNameserversRequest struct {
Nameservers []string `json:"nameservers"`
}
// SetContactsRequest is the body of POST /v4/domains/{domain}:setContacts.
type SetContactsRequest struct {
Contacts Contacts `json:"contacts"`
}
// ListDomainsResponse is the body of GET /v4/domains.
type ListDomainsResponse struct {
Domains []Domain `json:"domains"`
NextPage int `json:"nextPage,omitempty"`
}
// TransferRequest is the body of POST /v4/transfers (transfer a domain IN to Hanzo).
type TransferRequest struct {
DomainName string `json:"domainName"`
AuthCode string `json:"authCode"`
PurchasePrice float64 `json:"purchasePrice,omitempty"`
Years int `json:"years,omitempty"`
}
// Transfer is a transfer record as name.com returns it.
type Transfer struct {
DomainName string `json:"domainName"`
Email string `json:"email,omitempty"`
Status string `json:"status,omitempty"`
}
// TransferResponse is the create-transfer result.
type TransferResponse struct {
Transfer *Transfer `json:"transfer"`
Order int64 `json:"order,omitempty"`
TotalPaid float64 `json:"totalPaid,omitempty"`
}
// HelloResponse is the body of GET /v4/hello — the auth/health probe.
type HelloResponse struct {
ServerName string `json:"serverName,omitempty"`
Motd string `json:"motd,omitempty"`
Username string `json:"username,omitempty"`
}
+45
View File
@@ -0,0 +1,45 @@
package domain
import "math"
// Markup turns a wholesale registrar cost into the price a customer pays. The
// multiplier is applied, then a minimum absolute margin is enforced (so a cheap TLD
// still clears fixed per-order cost), then the result is rounded UP to whole cents.
//
// This is the ONE place a margin is added, mirroring cloud/clients/pricing's
// THIRD_PARTY_MARKUP multiplier — kept as data (Config) so it is tunable without code.
type Markup struct {
Multiplier float64 // e.g. 1.15 = +15% over wholesale; <1 is clamped to 1 (never sell below cost)
MinMarginCents int64 // floor absolute margin over cost, e.g. 300 = at least $3
}
// Sell returns the customer price in cents for a wholesale cost in cents. A
// non-positive cost yields 0 (free / unpriced — the caller treats it as not
// purchasable). The result is always ≥ cost (never sell below wholesale).
func (m Markup) Sell(costCents int64) int64 {
if costCents <= 0 {
return 0
}
mult := m.Multiplier
if mult < 1 {
mult = 1
}
marked := int64(math.Ceil(float64(costCents) * mult))
if marked-costCents < m.MinMarginCents {
marked = costCents + m.MinMarginCents
}
if marked < costCents {
marked = costCents
}
return marked
}
// dollarsToCents converts a registrar USD price (a float) to integer cents, rounding
// to the nearest cent. Registrar prices are exact to the cent, so this is lossless in
// practice; rounding guards against float representation drift.
func dollarsToCents(usd float64) int64 {
if usd <= 0 {
return 0
}
return int64(math.Round(usd * 100))
}
+302
View File
@@ -0,0 +1,302 @@
package domain
import (
"context"
"fmt"
"strings"
"time"
"github.com/hanzoai/cloud/clients/domain/namecom"
)
// Quote is a priced availability result: the wholesale cost and the customer sell
// price (marked up), both in cents. It is what search/availability return and what a
// register bills against.
type Quote struct {
Domain string `json:"domain"`
Available bool `json:"available"`
Premium bool `json:"premium,omitempty"`
CostCents int64 `json:"-"` // wholesale (internal — not exposed to customers)
PriceCents int64 `json:"priceCents"` // sell (first-term registration)
RenewalPriceCents int64 `json:"renewalPriceCents"` // sell (renewal)
Currency string `json:"currency"`
TLD string `json:"tld,omitempty"`
}
// quoteFrom prices a registrar search result through the markup.
func (s *Service) quoteFrom(r namecom.SearchResult) Quote {
cost := dollarsToCents(r.PurchasePrice)
renewCost := dollarsToCents(r.RenewalPrice)
return Quote{
Domain: strings.ToLower(r.DomainName),
Available: r.Purchasable,
Premium: r.Premium,
CostCents: cost,
PriceCents: s.cfg.Markup.Sell(cost),
RenewalPriceCents: s.cfg.Markup.Sell(renewCost),
Currency: "usd",
TLD: r.TLD,
}
}
// Availability checks exact names and returns priced quotes (availability + pricing).
func (s *Service) Availability(ctx context.Context, names ...string) ([]Quote, error) {
if !s.reg.Configured() {
return nil, ErrNotConfigured
}
resp, err := s.reg.CheckAvailability(ctx, names...)
if err != nil {
return nil, err
}
return s.quotes(resp), nil
}
// Search runs a keyword search (with alternate-TLD suggestions) and returns priced
// quotes. tldFilter (optional) narrows the TLDs.
func (s *Service) Search(ctx context.Context, keyword string, tldFilter ...string) ([]Quote, error) {
if !s.reg.Configured() {
return nil, ErrNotConfigured
}
resp, err := s.reg.Search(ctx, keyword, tldFilter...)
if err != nil {
return nil, err
}
return s.quotes(resp), nil
}
func (s *Service) quotes(resp *namecom.SearchResponse) []Quote {
out := make([]Quote, 0, len(resp.Results))
for _, r := range resp.Results {
out = append(out, s.quoteFrom(r))
}
return out
}
// RegisterResult is a successful purchase.
type RegisterResult struct {
Record Record `json:"record"`
Quote Quote `json:"quote"`
}
// Register buys a domain for org: quote → guard → authorize (deposit) → provision the
// DNS zone → register at the registrar pointing at Hanzo nameservers → capture
// (charge) → record ownership. The customer is charged ONLY after the registrar
// confirms — a registrar failure leaves the balance untouched.
//
// contacts is optional; when nil the registrar uses the reseller account's default
// WHOIS contacts. years defaults to 1.
func (s *Service) Register(ctx context.Context, org, domainName string, years int, contacts *namecom.Contacts) (*RegisterResult, error) {
if !s.reg.Configured() {
return nil, ErrNotConfigured
}
domainName = strings.ToLower(strings.TrimSpace(domainName))
if domainName == "" {
return nil, fmt.Errorf("domain: name is required")
}
if years <= 0 {
years = 1
}
// Idempotency guard: never double-buy a name this org already holds.
if _, owned, err := s.store.Get(org, domainName); err != nil {
return nil, err
} else if owned {
return nil, ErrAlreadyOwned
}
// 1. Quote — must be purchasable + priced.
quotes, err := s.Availability(ctx, domainName)
if err != nil {
return nil, err
}
if len(quotes) == 0 {
return nil, ErrUnavailable
}
q := quotes[0]
if !q.Available || q.PriceCents <= 0 {
return nil, ErrUnavailable
}
// 2. Authorize the customer charge BEFORE spending at the registrar — refuse if the
// prepaid balance can't cover it, so we never buy a domain we can't bill for.
if err := s.bill.Authorize(ctx, org, q.PriceCents); err != nil {
return nil, err
}
// 3. Provision the authoritative zone in hanzoai/dns and learn which nameservers to
// point the domain at. Best-effort: if the zone service is down we still register
// against the fixed Hanzo nameservers (the zone reconciles later) rather than
// block the purchase.
ns, zoneErr := s.zones.EnsureZone(ctx, org, domainName)
if zoneErr != nil || len(ns) == 0 {
ns = s.cfg.Nameservers
}
// 4. Register at the registrar, born pointing at Hanzo's nameservers. purchasePrice
// caps the wholesale charge at the quoted cost (a price change above it is
// rejected by the registrar). This is the step that debits Hanzo's reseller
// account; the customer is not charged yet.
created, err := s.reg.CreateDomain(ctx, namecom.CreateDomainRequest{
Domain: namecom.DomainInput{
DomainName: domainName,
Nameservers: ns,
Contacts: contacts,
},
PurchasePrice: float64(q.CostCents) / 100,
Years: years,
})
if err != nil {
// The registrar rejected/failed — the customer was NOT charged (no Capture).
return nil, fmt.Errorf("domain: registrar create failed: %w", err)
}
// 5. Capture — charge the customer's prepaid wallet now that the domain is theirs.
ref := "domain:register:" + domainName
s.bill.Capture(org, q.PriceCents, ref)
// 6. Record ownership.
rec := Record{
Org: org,
Domain: domainName,
RegisteredAt: time.Now().Unix(),
PriceCents: q.PriceCents,
CostCents: q.CostCents,
Nameservers: ns,
}
if created.Domain != nil {
rec.ExpiresAt = created.Domain.ExpireDate
if len(created.Domain.Nameservers) > 0 {
rec.Nameservers = created.Domain.Nameservers
}
}
rec.Order = created.Order
if err := s.store.Put(rec); err != nil {
return nil, err
}
return &RegisterResult{Record: rec, Quote: q}, nil
}
// RenewResult is a successful renewal.
type RenewResult struct {
Record Record `json:"record"`
PaidCents int64 `json:"paidCents"`
}
// Renew extends a domain this org owns: guard ownership → quote renewal → authorize →
// renew at the registrar → capture → update the record's expiry.
func (s *Service) Renew(ctx context.Context, org, domainName string, years int) (*RenewResult, error) {
if !s.reg.Configured() {
return nil, ErrNotConfigured
}
domainName = strings.ToLower(strings.TrimSpace(domainName))
if years <= 0 {
years = 1
}
rec, owned, err := s.store.Get(org, domainName)
if err != nil {
return nil, err
}
if !owned {
return nil, ErrNotOwned
}
// Quote the renewal (re-check gives the current renewal price).
quotes, err := s.Availability(ctx, domainName)
var priceCents, costCents int64
if err == nil && len(quotes) > 0 {
priceCents = quotes[0].RenewalPriceCents
costCents = renewalCostFrom(quotes[0])
}
if priceCents <= 0 {
// Fall back to what the customer originally paid so a renewal is never free.
priceCents = rec.PriceCents
costCents = rec.CostCents
}
if err := s.bill.Authorize(ctx, org, priceCents); err != nil {
return nil, err
}
renewed, err := s.reg.RenewDomain(ctx, domainName, namecom.RenewDomainRequest{
PurchasePrice: float64(costCents) / 100,
Years: years,
})
if err != nil {
return nil, fmt.Errorf("domain: registrar renew failed: %w", err)
}
s.bill.Capture(org, priceCents, "domain:renew:"+domainName)
if renewed.Domain != nil && renewed.Domain.ExpireDate != "" {
rec.ExpiresAt = renewed.Domain.ExpireDate
}
if err := s.store.Put(rec); err != nil {
return nil, err
}
return &RenewResult{Record: rec, PaidCents: priceCents}, nil
}
// renewalCostFrom derives the wholesale renewal cost (cents) for a quote. The renewal
// SELL price already passed through markup; recover a cost floor from it so the
// registrar price cap is set sanely (never below the known wholesale).
func renewalCostFrom(q Quote) int64 {
if q.CostCents > 0 {
return q.CostCents
}
return q.RenewalPriceCents
}
// Transfer starts an inbound transfer of a domain the customer owns elsewhere: quote
// (the transfer price is the registration price) → authorize → create the transfer →
// capture → record. authCode is the EPP auth code from the losing registrar.
func (s *Service) Transfer(ctx context.Context, org, domainName, authCode string, years int) (*RegisterResult, error) {
if !s.reg.Configured() {
return nil, ErrNotConfigured
}
domainName = strings.ToLower(strings.TrimSpace(domainName))
if strings.TrimSpace(authCode) == "" {
return nil, fmt.Errorf("domain: transfer requires an auth code")
}
if years <= 0 {
years = 1
}
quotes, err := s.Availability(ctx, domainName)
if err != nil {
return nil, err
}
q := Quote{Currency: "usd", Domain: domainName}
if len(quotes) > 0 {
q = quotes[0]
}
if q.PriceCents <= 0 {
return nil, ErrUnavailable
}
if err := s.bill.Authorize(ctx, org, q.PriceCents); err != nil {
return nil, err
}
res, err := s.reg.CreateTransfer(ctx, namecom.TransferRequest{
DomainName: domainName,
AuthCode: authCode,
PurchasePrice: float64(q.CostCents) / 100,
Years: years,
})
if err != nil {
return nil, fmt.Errorf("domain: registrar transfer failed: %w", err)
}
s.bill.Capture(org, q.PriceCents, "domain:transfer:"+domainName)
rec := Record{
Org: org,
Domain: domainName,
RegisteredAt: time.Now().Unix(),
PriceCents: q.PriceCents,
CostCents: q.CostCents,
Nameservers: s.cfg.Nameservers,
Order: res.Order,
}
if err := s.store.Put(rec); err != nil {
return nil, err
}
return &RegisterResult{Record: rec, Quote: q}, nil
}
// ListByOrg returns the domains an org holds.
func (s *Service) ListByOrg(org string) ([]Record, error) { return s.store.ListByOrg(org) }
+307
View File
@@ -0,0 +1,307 @@
package domain
import (
"context"
"errors"
"testing"
"github.com/hanzoai/cloud/clients/domain/namecom"
)
// --- mock backends ---------------------------------------------------------------
type mockReg struct {
avail map[string]namecom.SearchResult
created []namecom.CreateDomainRequest
createErr error
renewed []string
configured bool
}
func (m *mockReg) Configured() bool { return m.configured }
func (m *mockReg) CheckAvailability(_ context.Context, names ...string) (*namecom.SearchResponse, error) {
var out namecom.SearchResponse
for _, n := range names {
if r, ok := m.avail[n]; ok {
out.Results = append(out.Results, r)
} else {
out.Results = append(out.Results, namecom.SearchResult{DomainName: n, Purchasable: false})
}
}
return &out, nil
}
func (m *mockReg) Search(_ context.Context, _ string, _ ...string) (*namecom.SearchResponse, error) {
out := &namecom.SearchResponse{}
for _, r := range m.avail {
out.Results = append(out.Results, r)
}
return out, nil
}
func (m *mockReg) CreateDomain(_ context.Context, req namecom.CreateDomainRequest) (*namecom.CreateDomainResponse, error) {
if m.createErr != nil {
return nil, m.createErr
}
m.created = append(m.created, req)
return &namecom.CreateDomainResponse{
Domain: &namecom.Domain{DomainName: req.Domain.DomainName, Nameservers: req.Domain.Nameservers, ExpireDate: "2027-01-01T00:00:00Z"},
Order: 1001,
TotalPaid: req.PurchasePrice,
}, nil
}
func (m *mockReg) RenewDomain(_ context.Context, d string, _ namecom.RenewDomainRequest) (*namecom.RenewDomainResponse, error) {
m.renewed = append(m.renewed, d)
return &namecom.RenewDomainResponse{Domain: &namecom.Domain{DomainName: d, ExpireDate: "2028-01-01T00:00:00Z"}}, nil
}
func (m *mockReg) SetNameservers(_ context.Context, d string, ns []string) (*namecom.Domain, error) {
return &namecom.Domain{DomainName: d, Nameservers: ns}, nil
}
func (m *mockReg) CreateTransfer(_ context.Context, req namecom.TransferRequest) (*namecom.TransferResponse, error) {
return &namecom.TransferResponse{Transfer: &namecom.Transfer{DomainName: req.DomainName, Status: "pending"}, Order: 2002}, nil
}
func (m *mockReg) Hello(_ context.Context) (*namecom.HelloResponse, error) {
return &namecom.HelloResponse{Username: "test"}, nil
}
// mockBill records authorize/capture and can refuse.
type mockBill struct {
balance int64 // cents; -1 = unlimited
captured []capture
authCalls int
}
type capture struct {
org string
cents int64
ref string
}
func (b *mockBill) Authorize(_ context.Context, _ string, cents int64) error {
b.authCalls++
if b.balance >= 0 && cents > b.balance {
return ErrInsufficientFunds
}
return nil
}
func (b *mockBill) Capture(org string, cents int64, ref string) {
b.captured = append(b.captured, capture{org, cents, ref})
if b.balance >= 0 {
b.balance -= cents
}
}
// mockZones returns fixed nameservers (or an error to exercise the fallback).
type mockZones struct {
ns []string
err error
}
func (z *mockZones) EnsureZone(_ context.Context, _, _ string) ([]string, error) {
return z.ns, z.err
}
// --- helpers ---------------------------------------------------------------------
func newSvc(reg *mockReg, bill *mockBill, zones *mockZones) *Service {
return NewService(reg, bill, zones, NewMemStore(), Config{
Markup: Markup{Multiplier: 1.15, MinMarginCents: 300},
Nameservers: []string{"ns1.hanzo.ai", "ns2.hanzo.ai"},
Env: "test",
})
}
// --- tests -----------------------------------------------------------------------
func TestMarkupSell(t *testing.T) {
m := Markup{Multiplier: 1.15, MinMarginCents: 300}
// 15% over $10.00 = $11.50 (min margin $1.50 < $3 → floor to cost+$3 = $13.00)
if got := m.Sell(1000); got != 1300 {
t.Fatalf("Sell(1000) = %d, want 1300 (min-margin floor)", got)
}
// 15% over $50.00 = $57.50; margin $7.50 > $3 → $57.50
if got := m.Sell(5000); got != 5750 {
t.Fatalf("Sell(5000) = %d, want 5750", got)
}
// never below cost
if got := (Markup{Multiplier: 0.5}).Sell(1000); got != 1000 {
t.Fatalf("Sell below cost = %d, want 1000", got)
}
if got := m.Sell(0); got != 0 {
t.Fatalf("Sell(0) = %d, want 0", got)
}
}
func TestAvailabilityPricesWithMarkup(t *testing.T) {
reg := &mockReg{configured: true, avail: map[string]namecom.SearchResult{
"acme.ai": {DomainName: "acme.ai", Purchasable: true, PurchasePrice: 55.99, RenewalPrice: 55.99, TLD: "ai"},
}}
svc := newSvc(reg, &mockBill{balance: -1}, &mockZones{ns: []string{"ns1.hanzo.ai"}})
qs, err := svc.Availability(context.Background(), "acme.ai")
if err != nil {
t.Fatal(err)
}
if len(qs) != 1 || !qs[0].Available {
t.Fatalf("unexpected quotes: %+v", qs)
}
// cost 5599; sell = ceil(5599*1.15)=6439 (margin 840 > 300)
if qs[0].CostCents != 5599 || qs[0].PriceCents != 6439 {
t.Fatalf("pricing: cost=%d price=%d, want 5599/6439", qs[0].CostCents, qs[0].PriceCents)
}
}
func TestRegisterHappyPath_BillsAndPointsNameservers(t *testing.T) {
reg := &mockReg{configured: true, avail: map[string]namecom.SearchResult{
"acme.ai": {DomainName: "acme.ai", Purchasable: true, PurchasePrice: 55.99, RenewalPrice: 55.99, TLD: "ai"},
}}
bill := &mockBill{balance: 100000} // $1000
zones := &mockZones{ns: []string{"ns1.hanzo.ai", "ns2.hanzo.ai"}}
svc := newSvc(reg, bill, zones)
res, err := svc.Register(context.Background(), "acme", "acme.ai", 1, nil)
if err != nil {
t.Fatal(err)
}
// Registrar got a create with Hanzo nameservers + the wholesale price cap.
if len(reg.created) != 1 {
t.Fatalf("expected 1 create, got %d", len(reg.created))
}
c := reg.created[0]
if len(c.Domain.Nameservers) != 2 || c.Domain.Nameservers[0] != "ns1.hanzo.ai" {
t.Fatalf("domain not born on Hanzo nameservers: %v", c.Domain.Nameservers)
}
if c.PurchasePrice != 55.99 {
t.Fatalf("wholesale price cap = %v, want 55.99", c.PurchasePrice)
}
// Customer charged the SELL price exactly once, after the registrar succeeded.
if len(bill.captured) != 1 || bill.captured[0].cents != 6439 || bill.captured[0].ref != "domain:register:acme.ai" {
t.Fatalf("unexpected capture: %+v", bill.captured)
}
if res.Record.Order != 1001 || res.Record.ExpiresAt != "2027-01-01T00:00:00Z" {
t.Fatalf("record not populated from registrar: %+v", res.Record)
}
// Ownership recorded.
owned, _ := svc.ListByOrg("acme")
if len(owned) != 1 || owned[0].Domain != "acme.ai" {
t.Fatalf("ownership not recorded: %+v", owned)
}
}
func TestRegisterRefusedWhenInsufficientBalance_NoRegistrarCall(t *testing.T) {
reg := &mockReg{configured: true, avail: map[string]namecom.SearchResult{
"acme.ai": {DomainName: "acme.ai", Purchasable: true, PurchasePrice: 55.99},
}}
bill := &mockBill{balance: 100} // $1.00 — nowhere near $64.39
svc := newSvc(reg, bill, &mockZones{})
_, err := svc.Register(context.Background(), "acme", "acme.ai", 1, nil)
if !errors.Is(err, ErrInsufficientFunds) {
t.Fatalf("want ErrInsufficientFunds, got %v", err)
}
if len(reg.created) != 0 {
t.Fatalf("registrar must NOT be called when balance insufficient; got %d creates", len(reg.created))
}
if len(bill.captured) != 0 {
t.Fatalf("no capture on a refused purchase; got %+v", bill.captured)
}
}
func TestRegisterRegistrarFailure_NoCharge(t *testing.T) {
reg := &mockReg{configured: true, createErr: errors.New("boom"), avail: map[string]namecom.SearchResult{
"acme.ai": {DomainName: "acme.ai", Purchasable: true, PurchasePrice: 55.99},
}}
bill := &mockBill{balance: 100000}
svc := newSvc(reg, bill, &mockZones{ns: []string{"ns1.hanzo.ai"}})
_, err := svc.Register(context.Background(), "acme", "acme.ai", 1, nil)
if err == nil {
t.Fatal("expected registrar failure to propagate")
}
// Authorized (reserved) but NEVER captured — customer not charged for a failed buy.
if len(bill.captured) != 0 {
t.Fatalf("customer must not be charged on registrar failure; got %+v", bill.captured)
}
}
func TestRegisterUnavailable(t *testing.T) {
reg := &mockReg{configured: true, avail: map[string]namecom.SearchResult{
"taken.ai": {DomainName: "taken.ai", Purchasable: false},
}}
svc := newSvc(reg, &mockBill{balance: -1}, &mockZones{})
_, err := svc.Register(context.Background(), "acme", "taken.ai", 1, nil)
if !errors.Is(err, ErrUnavailable) {
t.Fatalf("want ErrUnavailable, got %v", err)
}
}
func TestRegisterAlreadyOwned(t *testing.T) {
reg := &mockReg{configured: true, avail: map[string]namecom.SearchResult{
"acme.ai": {DomainName: "acme.ai", Purchasable: true, PurchasePrice: 55.99},
}}
bill := &mockBill{balance: 100000}
svc := newSvc(reg, bill, &mockZones{ns: []string{"ns1.hanzo.ai"}})
if _, err := svc.Register(context.Background(), "acme", "acme.ai", 1, nil); err != nil {
t.Fatal(err)
}
_, err := svc.Register(context.Background(), "acme", "acme.ai", 1, nil)
if !errors.Is(err, ErrAlreadyOwned) {
t.Fatalf("want ErrAlreadyOwned on re-register, got %v", err)
}
}
func TestRegisterFallsBackToConfigNameserversWhenZoneFails(t *testing.T) {
reg := &mockReg{configured: true, avail: map[string]namecom.SearchResult{
"acme.ai": {DomainName: "acme.ai", Purchasable: true, PurchasePrice: 10.00},
}}
bill := &mockBill{balance: 100000}
zones := &mockZones{err: errors.New("dns down")}
svc := newSvc(reg, bill, zones)
if _, err := svc.Register(context.Background(), "acme", "acme.ai", 1, nil); err != nil {
t.Fatal(err)
}
// Zone provisioning failed → registered against the configured Hanzo nameservers.
got := reg.created[0].Domain.Nameservers
if len(got) != 2 || got[0] != "ns1.hanzo.ai" {
t.Fatalf("expected fallback nameservers, got %v", got)
}
}
func TestRenewOwnedDomainBills(t *testing.T) {
reg := &mockReg{configured: true, avail: map[string]namecom.SearchResult{
"acme.ai": {DomainName: "acme.ai", Purchasable: true, PurchasePrice: 55.99, RenewalPrice: 55.99},
}}
bill := &mockBill{balance: 100000}
svc := newSvc(reg, bill, &mockZones{ns: []string{"ns1.hanzo.ai"}})
if _, err := svc.Register(context.Background(), "acme", "acme.ai", 1, nil); err != nil {
t.Fatal(err)
}
captBefore := len(bill.captured)
res, err := svc.Renew(context.Background(), "acme", "acme.ai", 1)
if err != nil {
t.Fatal(err)
}
if len(reg.renewed) != 1 || reg.renewed[0] != "acme.ai" {
t.Fatalf("registrar renew not called: %v", reg.renewed)
}
if len(bill.captured) != captBefore+1 {
t.Fatalf("renewal not billed: %+v", bill.captured)
}
if res.Record.ExpiresAt != "2028-01-01T00:00:00Z" {
t.Fatalf("expiry not updated: %+v", res.Record)
}
}
func TestRenewNotOwned(t *testing.T) {
reg := &mockReg{configured: true}
svc := newSvc(reg, &mockBill{balance: -1}, &mockZones{})
_, err := svc.Renew(context.Background(), "acme", "nope.ai", 1)
if !errors.Is(err, ErrNotOwned) {
t.Fatalf("want ErrNotOwned, got %v", err)
}
}
func TestNotConfigured(t *testing.T) {
reg := &mockReg{configured: false}
svc := newSvc(reg, &mockBill{balance: -1}, &mockZones{})
if _, err := svc.Availability(context.Background(), "acme.ai"); !errors.Is(err, ErrNotConfigured) {
t.Fatalf("want ErrNotConfigured, got %v", err)
}
}
+54
View File
@@ -0,0 +1,54 @@
package domain
import (
"sort"
"strings"
"sync"
)
// MemStore is an in-memory Store: a concurrency-safe map keyed by (org, domain). It is
// the default ownership store — sufficient for a single-process deployment and every
// test. A multi-replica deployment swaps in a SQLite/Postgres-backed Store with the
// SAME interface (the clients/finance per-org ledger pattern); nothing else changes.
type MemStore struct {
mu sync.RWMutex
rows map[string]Record // key = org + "\x00" + domain
}
// NewMemStore builds an empty in-memory store.
func NewMemStore() *MemStore { return &MemStore{rows: map[string]Record{}} }
func memKey(org, domainName string) string {
return strings.ToLower(org) + "\x00" + strings.ToLower(domainName)
}
// Put upserts a record.
func (s *MemStore) Put(rec Record) error {
s.mu.Lock()
defer s.mu.Unlock()
s.rows[memKey(rec.Org, rec.Domain)] = rec
return nil
}
// Get returns the record for (org, domain) and whether it exists.
func (s *MemStore) Get(org, domainName string) (Record, bool, error) {
s.mu.RLock()
defer s.mu.RUnlock()
rec, ok := s.rows[memKey(org, domainName)]
return rec, ok, nil
}
// ListByOrg returns every domain the org holds, newest registration first.
func (s *MemStore) ListByOrg(org string) ([]Record, error) {
s.mu.RLock()
defer s.mu.RUnlock()
prefix := strings.ToLower(org) + "\x00"
out := make([]Record, 0)
for k, rec := range s.rows {
if strings.HasPrefix(k, prefix) {
out = append(out, rec)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].RegisteredAt > out[j].RegisteredAt })
return out, nil
}
+15 -8
View File
@@ -46,33 +46,40 @@ func newTestClient(t *testing.T) *Client {
}
func TestEnvFallbackAndDefault(t *testing.T) {
// No engine/store configured -> env fallback, then literal default.
// No engine/store configured. Runtime flags carry no Env, so they resolve to
// their literal default and an env var must NOT move them — that is what makes
// /v1/flags the single source of truth, flippable live without a redeploy.
// Boot-time ReadOnly rows keep Env, because env IS their boot mechanism.
prev := mounted
mounted = &Client{} // not configured
t.Cleanup(func() { mounted = prev })
// waitlist_open default is "true" (no env set).
// waitlist_open default is "true".
t.Setenv("WAITLIST_OPEN", "")
if !Bool("waitlist_open") {
t.Fatalf("waitlist_open default should be true")
}
// env override beats the literal default.
// A runtime flag ignores env: the default still wins.
t.Setenv("WAITLIST_OPEN", "false")
if Bool("waitlist_open") {
t.Fatalf("waitlist_open env=false should win")
if !Bool("waitlist_open") {
t.Fatalf("waitlist_open is a runtime flag: env must not override its default")
}
// int default.
if Int("waitlist_access_capacity") != 0 {
t.Fatalf("capacity default should be 0")
}
t.Setenv("WAITLIST_ACCESS_CAPACITY", "250")
if Int("waitlist_access_capacity") != 250 {
t.Fatalf("capacity env should be 250, got %d", Int("waitlist_access_capacity"))
if Int("waitlist_access_capacity") != 0 {
t.Fatalf("waitlist_access_capacity is a runtime flag: env must not override its default")
}
// network id read-only default.
// A boot-time ReadOnly row still reads env, and falls back to its default.
if Int("network_id_localnet") != 1337 {
t.Fatalf("localnet id default should be 1337")
}
t.Setenv("LUX_NETWORK_ID_LOCALNET", "1338")
if Int("network_id_localnet") != 1338 {
t.Fatalf("network_id_localnet is boot-time: env must win, got %d", Int("network_id_localnet"))
}
// unknown key is safe.
if Bool("nope") || Int("nope") != 0 || String("nope") != "" {
t.Fatalf("unknown key must be zero-valued")
+1
View File
@@ -114,6 +114,7 @@ func discordInteractions(s *cloud.Service[state], c *zip.Ctx) error {
Provider: "discord", ExternalID: it.GuildID, User: it.User,
Channel: it.ChannelID, Text: it.Prompt, DedupeKey: it.ID,
}
emitIngress(org, in, "")
reply := discordReplier(it.AppID, it.Token)
bridgeSpawn(s, org, func() { runBridgeTurn(s, org, in, reply) })
// Ack SYNC with a deferred EPHEMERAL response (flags 64) — the async edit stays
+135
View File
@@ -0,0 +1,135 @@
package integrations
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// ingress.go is the chat-ingress seam between the platform adapters and the
// /v1/channels transport plane (clients/channels), plus the transport send
// doors. The seam is the cloud.RegisterSync idiom (sync_seam.go): channels
// registers its consumer at Mount, adapters emit with NO import of channels —
// the dependency points one way and token custody never leaves this package.
// IngressEvent is one authenticated inbound chat event crossing the seam. Org
// is resolved via OrgForExternalID on a signature-verified payload; In is the
// adapter-normalized Inbound (bridge.go); ReplyRoot is a transport-verified
// reply root (Teams: the JWT-verified serviceURL; "" elsewhere).
type IngressEvent struct {
Org string
In Inbound
ReplyRoot string
}
// ingressFn is the single registered consumer — nil until channels mounts.
// Written once at Mount before serving, read from webhook goroutines (the
// RegisterSync pattern, sync_seam.go).
var ingressFn func(context.Context, IngressEvent)
// RegisterIngress installs the ingress consumer. Called once at channels.Mount.
func RegisterIngress(fn func(context.Context, IngressEvent)) { ingressFn = fn }
// emitIngress hands one event to the registered consumer on a detached
// goroutine with its own bounded context, so the billed webhook path is never
// delayed and a panicking consumer cannot crash the shared process. No
// request-scoped context crosses the hop — everything the consumer needs rides
// the event. The adapter's bridgeLim slot ownership is untouched.
func emitIngress(org string, in Inbound, replyRoot string) {
fn := ingressFn
if fn == nil {
return
}
go func() {
defer func() { _ = recover() }()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
fn(ctx, IngressEvent{Org: org, In: in, ReplyRoot: replyRoot})
}()
}
// LinkedSubject returns the Hanzo account subject bound to (org, provider,
// extUser) by the account-link flow (bridge_link.go / *_link.go). Returns
// ("", false, nil) when the user has not linked; an error (fail closed) on an
// unmounted subsystem, invalid org, or KMS-down.
func LinkedSubject(org, provider, extUser string) (string, bool, error) {
if mounted == nil {
return "", false, fmt.Errorf("integrations: not mounted")
}
link, ok, err := getUserLink(mounted, org, provider, extUser)
if err != nil || !ok {
return "", false, err
}
return link.Subject, true, nil
}
// ── transport send doors (each delegates to the ONE existing HTTP path) ──────
// SendSlack posts text to channel, threaded under threadTS when non-empty
// (slackPostThread, slack_events.go — posts top-level chat.postMessage when
// threadTS == ""). The token is the org's OWN custodied bot token: TokenFor
// fails closed for unmounted/unknown/not-connected/KMS-down, so an org that
// never connected Slack cannot post — the per-org token IS the tenancy gate.
func SendSlack(ctx context.Context, org, channel, threadTS, text string) error {
tok, err := TokenFor(ctx, org, "slack", slackBotTokenSecret)
if err != nil {
return err
}
return slackPostThread(ctx, string(tok), channel, threadTS, text)
}
// SendTelegram posts text to chatID via the Bot API sendMessage, threaded under
// replyTo when non-zero (telegramSend, telegram_events.go).
func SendTelegram(ctx context.Context, chatID, replyTo int64, text string) error {
return telegramSend(ctx, chatID, replyTo, text)
}
// SendTeams posts a message activity to conversationID at the Bot Connector
// serviceURL (teamsSendActivity, teams_events.go).
func SendTeams(ctx context.Context, serviceURL, conversationID, text string) error {
return teamsSendActivity(ctx, serviceURL, conversationID, text)
}
// SendDiscord posts text to a Discord channel via POST /channels/{id}/messages,
// referencing replyTo when non-empty. Content is capped at discordMaxContent;
// the bot token rides only the Authorization header, which is never logged.
// Returns the created message id.
func SendDiscord(ctx context.Context, channelID, replyTo, text string) (string, error) {
tok := discordBotToken()
if tok == "" {
return "", fmt.Errorf("discord: bot token not configured")
}
if len(text) > discordMaxContent {
text = text[:discordMaxContent]
}
fields := map[string]any{"content": text}
if replyTo != "" {
fields["message_reference"] = map[string]string{"message_id": replyTo}
}
payload, _ := json.Marshal(fields)
endpoint := discordAPIBase + "/channels/" + channelID + "/messages"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(payload)))
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bot "+tok)
req.Header.Set("Content-Type", "application/json")
resp, err := bridgeHTTP.Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(io.LimitReader(resp.Body, bridgeMaxBody))
if resp.StatusCode/100 != 2 {
return "", fmt.Errorf("discord create message http %d", resp.StatusCode)
}
var m struct {
ID string `json:"id"`
}
_ = json.Unmarshal(body, &m)
return m.ID, nil
}
+219
View File
@@ -0,0 +1,219 @@
package integrations
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
// ingress_test.go proves the two seam pieces channels rides: the SendDiscord
// door (the one new HTTP verb — httptest via the package's own repoint
// pattern, zero live network) and emitIngress (registration, goroutine hop,
// bounded context, panic containment).
// discordMsgCapture records what SendDiscord put on the wire. Mutex-guarded:
// the httptest handler runs on the server goroutine.
type discordMsgCapture struct {
mu sync.Mutex
calls int
method string
path string
auth string
body []byte
status int
reply string
}
func (c *discordMsgCapture) handler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
c.mu.Lock()
c.calls++
c.method = r.Method
c.path = r.URL.Path
c.auth = r.Header.Get("Authorization")
c.body = body
status, reply := c.status, c.reply
c.mu.Unlock()
w.WriteHeader(status)
_, _ = w.Write([]byte(reply))
}
func (c *discordMsgCapture) count() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.calls
}
func (c *discordMsgCapture) last() (method, path, auth string, body []byte) {
c.mu.Lock()
defer c.mu.Unlock()
return c.method, c.path, c.auth, c.body
}
// newDiscordMsgServer repoints discordAPIBase at an httptest server — the
// package's established repoint idiom (slackWebAPIBase, telegramAPIBase).
func newDiscordMsgServer(t *testing.T, status int, reply string) *discordMsgCapture {
t.Helper()
rec := &discordMsgCapture{status: status, reply: reply}
srv := httptest.NewServer(http.HandlerFunc(rec.handler))
saved := discordAPIBase
discordAPIBase = srv.URL
t.Cleanup(func() {
discordAPIBase = saved
srv.Close()
})
return rec
}
func TestSendDiscordPostsMessage(t *testing.T) {
t.Setenv(discordBotTokenEnv, "test-token")
rec := newDiscordMsgServer(t, http.StatusOK, `{"id":"456"}`)
id, err := SendDiscord(context.Background(), "123", "789", "hello")
if err != nil {
t.Fatalf("SendDiscord: %v", err)
}
if id != "456" {
t.Fatalf("message id = %q, want 456", id)
}
method, path, auth, body := rec.last()
if method != http.MethodPost {
t.Fatalf("method = %q, want POST", method)
}
if path != "/channels/123/messages" {
t.Fatalf("path = %q, want /channels/123/messages", path)
}
if auth != "Bot test-token" {
t.Fatalf("authorization = %q, want the Bot token header", auth)
}
var payload struct {
Content string `json:"content"`
Reference *struct {
MessageID string `json:"message_id"`
} `json:"message_reference"`
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("body: %v (%s)", err, body)
}
if payload.Content != "hello" {
t.Fatalf("content = %q", payload.Content)
}
if payload.Reference == nil || payload.Reference.MessageID != "789" {
t.Fatalf("message_reference = %+v, want message_id 789", payload.Reference)
}
// replyTo "" ⇒ a top-level message: no message_reference key at all.
if _, err := SendDiscord(context.Background(), "123", "", "top"); err != nil {
t.Fatalf("SendDiscord top-level: %v", err)
}
_, _, _, body = rec.last()
var raw map[string]json.RawMessage
if err := json.Unmarshal(body, &raw); err != nil {
t.Fatalf("body: %v (%s)", err, body)
}
if _, ok := raw["message_reference"]; ok {
t.Fatal("message_reference must be absent when replyTo is empty")
}
}
func TestSendDiscordTokenUnset(t *testing.T) {
t.Setenv(discordBotTokenEnv, "")
rec := newDiscordMsgServer(t, http.StatusOK, `{"id":"1"}`)
_, err := SendDiscord(context.Background(), "123", "", "x")
if err == nil || !strings.Contains(err.Error(), "not configured") {
t.Fatalf("err = %v, want a not-configured refusal", err)
}
if rec.count() != 0 {
t.Fatal("no HTTP call may fire without a token")
}
}
func TestSendDiscordTruncatesContent(t *testing.T) {
t.Setenv(discordBotTokenEnv, "test-token")
rec := newDiscordMsgServer(t, http.StatusOK, `{"id":"1"}`)
if _, err := SendDiscord(context.Background(), "123", "", strings.Repeat("a", discordMaxContent+500)); err != nil {
t.Fatalf("SendDiscord: %v", err)
}
_, _, _, body := rec.last()
var payload struct {
Content string `json:"content"`
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("body: %v", err)
}
if len(payload.Content) != discordMaxContent {
t.Fatalf("content length = %d, want exactly %d", len(payload.Content), discordMaxContent)
}
}
func TestSendDiscordHTTPErrorRedacted(t *testing.T) {
t.Setenv(discordBotTokenEnv, "test-token")
newDiscordMsgServer(t, http.StatusForbidden, `{"message":"Missing Access"}`)
_, err := SendDiscord(context.Background(), "123", "", "x")
if err == nil || !strings.Contains(err.Error(), "403") {
t.Fatalf("err = %v, want the HTTP status", err)
}
if strings.Contains(err.Error(), "test-token") {
t.Fatal("door errors carry status/shape only — never the token")
}
}
func TestEmitIngressDelivers(t *testing.T) {
t.Cleanup(func() { ingressFn = nil })
got := make(chan IngressEvent, 1)
deadline := make(chan bool, 1)
RegisterIngress(func(ctx context.Context, ev IngressEvent) {
_, ok := ctx.Deadline()
deadline <- ok
got <- ev
})
in := Inbound{Provider: "slack", ExternalID: "T1", User: "u1", Channel: "C1", ThreadID: "th", Text: "hi", DedupeKey: "e1"}
emitIngress("org1", in, "root")
select {
case ev := <-got:
if ev.Org != "org1" || ev.ReplyRoot != "root" || ev.In != in {
t.Fatalf("event = %+v, want the emitted fields intact", ev)
}
case <-time.After(5 * time.Second):
t.Fatal("ingress event not delivered")
}
if ok := <-deadline; !ok {
t.Fatal("consumer context must carry the bounded deadline")
}
}
func TestEmitIngressRecoversPanic(t *testing.T) {
t.Cleanup(func() { ingressFn = nil })
entered := make(chan struct{})
RegisterIngress(func(context.Context, IngressEvent) {
close(entered)
panic("boom")
})
emitIngress("org1", Inbound{Provider: "slack"}, "")
select {
case <-entered:
case <-time.After(5 * time.Second):
t.Fatal("consumer never ran")
}
// The deferred recover in emitIngress owns the panic; give the goroutine a
// beat to unwind — the process staying alive IS the assertion.
time.Sleep(50 * time.Millisecond)
}
func TestEmitIngressUnregistered(t *testing.T) {
ingressFn = nil
// No consumer registered (fresh process state): emit must be a silent no-op.
emitIngress("org1", Inbound{Provider: "slack"}, "")
}
+1
View File
@@ -149,6 +149,7 @@ func slackEvents(s *cloud.Service[state], c *zip.Ctx) error {
Provider: "slack", ExternalID: route.TeamID, User: route.User,
Channel: route.Channel, ThreadID: route.ThreadTS, Text: route.Text, DedupeKey: key,
}
emitIngress(org, in, "")
reply := slackReplier(s, org, route.Channel, route.ThreadTS, route.User)
bridgeSpawn(s, org, func() { runBridgeTurn(s, org, in, reply) })
return c.NoContent(http.StatusOK)
+1
View File
@@ -98,6 +98,7 @@ func teamsEvents(s *cloud.Service[state], c *zip.Ctx) error {
Provider: "teams", ExternalID: tenant, User: user,
Channel: act.ConversationID, Text: stripTeamsMentions(act.Text), DedupeKey: act.ID,
}
emitIngress(org, in, act.ServiceURL)
reply := teamsReplier(act.ServiceURL, act.ConversationID)
bridgeSpawn(s, org, func() { runBridgeTurn(s, org, in, reply) })
return c.NoContent(http.StatusOK)
+1
View File
@@ -152,6 +152,7 @@ func telegramWebhook(s *cloud.Service[state], c *zip.Ctx) error {
Channel: chatID, ThreadID: strconv.FormatInt(m.MessageID, 10), Text: prompt,
DedupeKey: strconv.FormatInt(m.UpdateID, 10),
}
emitIngress(org, in, "")
reply := telegramReplier(in)
bridgeSpawn(s, org, func() { runBridgeTurn(s, org, in, reply) })
return c.NoContent(http.StatusOK)
@@ -16,15 +16,15 @@ import (
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/db"
commercemid "github.com/hanzoai/commerce/middleware"
"github.com/hanzoai/commerce/middleware/svcorg"
"github.com/hanzoai/commerce/pkg/org"
"github.com/hanzoai/commerce/util/bit"
"github.com/hanzoai/commerce/util/permission"
)
// seedInMemDatastore installs a fresh in-memory default datastore so the
// service-token branch's svcorg.Resolve (GetOrCreate on the org table) succeeds
// service-token branch's org.Resolve (GetOrCreate on the org table) succeeds
// and the request reaches the handler — the funded path — rather than 503ing on a
// resolve failure. Mirrors svcorg/resolver_test.go's setup.
// resolve failure. Mirrors pkg/org's lookup_test.go setup.
func seedInMemDatastore(t *testing.T) {
t.Helper()
mgr, err := db.NewManager(&db.Config{DataDir: t.TempDir(), EnableVectorSearch: false, IsDev: true})
@@ -65,8 +65,8 @@ func TestInProcMeteringDispatch_ServiceTokenAuthPath(t *testing.T) {
const svc = "svc-token-metering-xyz789"
t.Setenv("COMMERCE_SERVICE_TOKEN", svc)
seedInMemDatastore(t)
svcorg.Invalidate("funded")
svcorg.Invalidate("broke")
org.Invalidate("funded")
org.Invalidate("broke")
// finance NOT co-resident → the metering balance read goes over commerceinproc,
// through the real commerce middleware chain, exactly like the topology that 500'd.
+45
View File
@@ -139,3 +139,48 @@ func TestCreateRejectsReservedSlug(t *testing.T) {
t.Fatalf("create normal slug status=%d body=%s, want 201", status, body)
}
}
// TestResolveUniqueLiveSlug proves the bare `<slug>.hanzo.app` fallback is
// deterministic and collision-safe: it serves ONLY a slug owned by exactly one
// LIVE project across all orgs — drafts don't count, and an ambiguous slug
// (two live owners) serves neither (each keeps its org-scoped host + S3 URL).
// This is what keeps publishes from before host binding servable, no backfill.
func TestResolveUniqueLiveSlug(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
// One live owner, no site_hosts row (a pre-binding publish) → resolves.
solo := mkProject("maxpower", "dave-synapse-demo", "Synapse")
solo.Status, solo.Bucket = "live", "hanzo-sites"
if err := s.CreateProject(ctx, solo); err != nil {
t.Fatalf("create solo: %v", err)
}
if p, err := s.ResolveUniqueLiveSlug(ctx, "dave-synapse-demo"); err != nil || p.Org != "maxpower" {
t.Fatalf("unique live slug = (%+v,%v), want org=maxpower", p, err)
}
// A DRAFT with the same slug in another org does not make it ambiguous.
dr := mkProject("acme", "dave-synapse-demo", "Draft Synapse")
dr.Status = "draft"
if err := s.CreateProject(ctx, dr); err != nil {
t.Fatalf("create draft: %v", err)
}
if p, err := s.ResolveUniqueLiveSlug(ctx, "dave-synapse-demo"); err != nil || p.Org != "maxpower" {
t.Fatalf("draft polluted unique resolve: (%+v,%v)", p, err)
}
// A SECOND live owner makes the bare slug ambiguous → honest not-found.
ac := mkProject("acme2", "dave-synapse-demo", "Acme Synapse")
ac.Status, ac.Bucket = "live", "hanzo-sites"
if err := s.CreateProject(ctx, ac); err != nil {
t.Fatalf("create second live: %v", err)
}
if _, err := s.ResolveUniqueLiveSlug(ctx, "dave-synapse-demo"); !errors.Is(err, errNotFound) {
t.Fatalf("ambiguous slug = %v, want errNotFound", err)
}
// Unknown slug → not found.
if _, err := s.ResolveUniqueLiveSlug(ctx, "nope"); !errors.Is(err, errNotFound) {
t.Fatalf("unknown slug = %v, want errNotFound", err)
}
}
+10
View File
@@ -3,6 +3,7 @@ package projects
import (
"context"
"errors"
"strings"
"github.com/hanzoai/cloud/clients/sites"
)
@@ -18,8 +19,17 @@ type siteResolver struct{ store *Store }
// prefix are read ONLY from the store binding (site_hosts → projects), never from
// the request — the org-isolation boundary. A missing binding is a clean
// not-found (honest 404), not an error.
//
// A BARE key (no dot — the `<slug>.hanzo.app` product URL) additionally falls
// back to the unique-live-slug resolve: serve iff exactly one live project owns
// that slug across all orgs. Deterministic, shadow-safe (reserved labels never
// reach here), and migration-free for projects published before host binding
// existed. An org-scoped `<slug>.<org>` key resolves ONLY via its binding.
func (r siteResolver) Resolve(ctx context.Context, slug string) (sites.Site, bool, error) {
p, err := r.store.ResolveHost(ctx, slug)
if errors.Is(err, errNotFound) && !strings.Contains(slug, ".") {
p, err = r.store.ResolveUniqueLiveSlug(ctx, slug)
}
if errors.Is(err, errNotFound) {
return sites.Site{}, false, nil
}
+31
View File
@@ -442,6 +442,37 @@ func (s *Store) ResolveHost(ctx context.Context, host string) (Project, error) {
return p, nil
}
// ResolveUniqueLiveSlug resolves a BARE subdomain slug (`<slug>.hanzo.app`) to
// the single LIVE project owning that slug across all orgs. Slugs are only
// org-unique, so the bare host is servable ONLY when unambiguous: zero or 2+
// live owners ⇒ errNotFound (each project still serves at its org-scoped host
// and its S3 URL). LIMIT 2 — a second row is the whole ambiguity signal; we
// never enumerate. This is what keeps pre-binding publishes servable with no
// backfill migration.
func (s *Store) ResolveUniqueLiveSlug(ctx context.Context, slug string) (Project, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT `+projectCols+` FROM projects WHERE slug=? AND status='live' LIMIT 2`, slug)
if err != nil {
return Project{}, fmt.Errorf("resolve slug: %w", err)
}
defer func() { _ = rows.Close() }()
var out []Project
for rows.Next() {
p, err := scanProject(rows)
if err != nil {
return Project{}, fmt.Errorf("resolve slug: %w", err)
}
out = append(out, p)
}
if err := rows.Err(); err != nil {
return Project{}, fmt.Errorf("resolve slug: %w", err)
}
if len(out) != 1 {
return Project{}, errNotFound
}
return out[0], nil
}
// ListHostsForProject returns every public host bound to (org, slug), oldest
// first. It powers GET .../domains so a console/user can see which hostnames the
// site serves — its `<slug>.hanzo.app` subdomain plus any bound custom domains.
+387
View File
@@ -0,0 +1,387 @@
// Package session mounts the Hanzo Cloud /v1/code/sessions/* surface: the
// registry of live coding-agent runs launched by `hanzo code <agent>`. It is
// what console.hanzo.ai lists and drives — every claude/codex/dev process, on
// every machine, shown as a session you can watch (over its ttyd terminal
// tunnel) and steer.
//
// Org isolation is enforced SERVER-SIDE on every request: the org is
// principal.Org(c) — the value minted from the VALIDATED bearer owner claim —
// and NEVER a client header. One SQLite file per org ({DataDir}/orgs/{slug}/
// session.db), every query filtered WHERE org=?, so one org can never see or
// mutate another's sessions. Project is the LINK to the deployable
// projects.Project a run belongs to (a column, filterable), not a second
// partition: the console default is the org-wide "what's running" view.
//
// Surface (all org-scoped; /v1 only):
//
// POST /v1/code/sessions register/announce a run -> Session (201)
// GET /v1/code/sessions[?live&status=&project=&host=&agent=] list -> [Session]
// GET /v1/code/sessions/:id session detail -> Session
// PATCH /v1/code/sessions/:id heartbeat/status/terminalURL -> Session
// DELETE /v1/code/sessions/:id forget a session
// POST /v1/code/sessions/:id/events record a hook event -> Event (201)
// GET /v1/code/sessions/:id/events list events -> [Event]
package session
import (
"crypto/rand"
"encoding/hex"
"errors"
"net/http"
"regexp"
"strings"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/principal"
"github.com/zap-proto/zip"
)
const (
maxField = 256 // host, model, user, project, agent
maxCwd = 4096 // a working-directory path
maxURL = 2048 // the ttyd terminal endpoint
maxMsg = 16384 // an event message
)
// idRE constrains a client-minted session id to a URL/identifier-safe token, so
// the launcher can mint a uuid locally and reference the run before the register
// round-trip returns. It is the boundary guard on the :id path segment.
var idRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{7,63}$`)
// agents is the closed set of runnable coding agents (mirrors `hanzo code`).
var agents = map[string]bool{"claude": true, "codex": true, "dev": true}
// statuses is the lifecycle a session moves through; the launcher and the hook
// bridge set them, the console reads them.
var statuses = map[string]bool{
"starting": true, "running": true, "waiting": true, "ended": true, "error": true,
}
type state struct {
stores *cloud.OrgStore[*Store] // one session.db per org, opened once each
}
var mounted *cloud.Service[state]
// Mount wires /v1/code/sessions/* onto app.
func Mount(app *zip.App, deps cloud.Deps) error {
if app == nil {
return errors.New("session.Mount: nil zip.App")
}
if deps.Logger == nil {
return errors.New("session.Mount: nil deps.Logger")
}
if deps.DataDir == "" {
return errors.New("session.Mount: empty DataDir")
}
s := &cloud.Service[state]{Base: cloud.NewBase(deps, "session"), State: state{
stores: cloud.NewOrgStore(deps.DataDir, "session", openStore),
}}
mounted = s
routes(app, s)
s.Log.Info("session registry mounted", "brand", s.Brand)
return nil
}
// Shutdown closes every open per-org store. Idempotent.
func Shutdown() error {
if mounted == nil {
return nil
}
err := mounted.State.stores.CloseAll()
mounted = nil
return err
}
// routes registers the surface. Collection endpoints register before their
// :id siblings so the first-match scan resolves them first.
func routes(app *zip.App, s *cloud.Service[state]) {
app.Post("/v1/code/sessions", cloud.Handle(s, createSession))
app.Get("/v1/code/sessions", cloud.Handle(s, listSessions))
app.Get("/v1/code/sessions/:id", cloud.Handle(s, getSession))
app.Patch("/v1/code/sessions/:id", cloud.Handle(s, updateSession))
app.Delete("/v1/code/sessions/:id", cloud.Handle(s, deleteSession))
app.Post("/v1/code/sessions/:id/events", cloud.Handle(s, createEvent))
app.Get("/v1/code/sessions/:id/events", cloud.Handle(s, listEvents))
}
func org(c *zip.Ctx) (string, bool) { return principal.Org(c) }
func storeFor(s *cloud.Service[state], org string) (*Store, error) {
return s.State.stores.For(org, "")
}
func idParam(c *zip.Ctx) string { return strings.TrimSpace(c.Param("id")) }
func genID() string {
var b [16]byte
_, _ = rand.Read(b[:])
return "ses_" + hex.EncodeToString(b[:])
}
// ---- HTTP shapes (the published contract) ----
type createSessionReq struct {
ID string `json:"id"` // client-minted; generated if empty
Agent string `json:"agent"` // claude | codex | dev
Model string `json:"model"` // zen5-pro, ...
Host string `json:"host"` // machine name
Cwd string `json:"cwd"` // working directory
Project string `json:"project"` // deployable-project link ("" = none)
User string `json:"user"` // display label of who launched
TerminalURL string `json:"terminalUrl"` // set now if the tunnel is already up
}
type updateSessionReq struct {
Status *string `json:"status"`
TerminalURL *string `json:"terminalUrl"`
Model *string `json:"model"`
Ended *bool `json:"ended"` // true → stamp ended_at + status=ended
}
type createEventReq struct {
Kind string `json:"kind"` // notification | stop | error | log
Message string `json:"message"`
}
type sessionView struct {
ID string `json:"id"`
Org string `json:"org"`
Project string `json:"project,omitempty"`
User string `json:"user,omitempty"`
Agent string `json:"agent"`
Model string `json:"model,omitempty"`
Host string `json:"host,omitempty"`
Cwd string `json:"cwd,omitempty"`
TerminalURL string `json:"terminalUrl,omitempty"`
Status string `json:"status"`
StartedAt int64 `json:"startedAt"`
UpdatedAt int64 `json:"updatedAt"`
EndedAt int64 `json:"endedAt,omitempty"`
}
type eventView struct {
ID string `json:"id"`
SessionID string `json:"sessionId"`
Kind string `json:"kind"`
Message string `json:"message,omitempty"`
CreatedAt int64 `json:"createdAt"`
}
func toSessionView(x Session) sessionView {
return sessionView{
ID: x.ID, Org: x.Org, Project: x.Project, User: x.User, Agent: x.Agent,
Model: x.Model, Host: x.Host, Cwd: x.Cwd, TerminalURL: x.TerminalURL,
Status: x.Status, StartedAt: x.StartedAt, UpdatedAt: x.UpdatedAt, EndedAt: x.EndedAt,
}
}
func toEventView(e Event) eventView {
return eventView{ID: e.ID, SessionID: e.SessionID, Kind: e.Kind, Message: e.Message, CreatedAt: e.CreatedAt}
}
// ---- handlers ----
func createSession(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := org(c)
if !ok {
return zip.ErrForbidden("a validated org is required")
}
var body createSessionReq
if err := c.Bind(&body); err != nil {
return err
}
agent := strings.ToLower(strings.TrimSpace(body.Agent))
if !agents[agent] {
return zip.ErrBadRequest("agent must be one of claude, codex, dev")
}
id := strings.TrimSpace(body.ID)
if id == "" {
id = genID()
} else if !idRE.MatchString(id) {
return zip.ErrBadRequest("id must match ^[A-Za-z0-9][A-Za-z0-9._-]{7,63}$")
}
if len(body.Cwd) > maxCwd || len(body.TerminalURL) > maxURL ||
len(body.Model) > maxField || len(body.Host) > maxField ||
len(body.Project) > maxField || len(body.User) > maxField {
return zip.ErrBadRequest("a field exceeds its length bound")
}
store, err := storeFor(s, org)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "open store: %v", err)
}
now := time.Now().Unix()
x := Session{
ID: id, Org: org, Project: strings.TrimSpace(body.Project),
User: strings.TrimSpace(body.User), Agent: agent,
Model: strings.TrimSpace(body.Model), Host: strings.TrimSpace(body.Host),
Cwd: body.Cwd, TerminalURL: strings.TrimSpace(body.TerminalURL),
Status: "starting", StartedAt: now, UpdatedAt: now,
}
if err := store.Create(c.Context(), x); err != nil {
return zip.Errorf(http.StatusInternalServerError, "persist: %v", err)
}
return c.JSON(http.StatusCreated, toSessionView(x))
}
func listSessions(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := org(c)
if !ok {
return zip.ErrForbidden("a validated org is required")
}
store, err := storeFor(s, org)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "open store: %v", err)
}
f := Filter{
Status: strings.TrimSpace(c.Query("status")),
Project: strings.TrimSpace(c.Query("project")),
Host: strings.TrimSpace(c.Query("host")),
Agent: strings.TrimSpace(c.Query("agent")),
Live: c.Query("live") != "",
}
rows, err := store.List(c.Context(), org, f)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "list: %v", err)
}
out := make([]sessionView, 0, len(rows))
for _, x := range rows {
out = append(out, toSessionView(x))
}
return c.JSON(http.StatusOK, out)
}
func getSession(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := org(c)
if !ok {
return zip.ErrForbidden("a validated org is required")
}
store, err := storeFor(s, org)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "open store: %v", err)
}
x, err := store.Get(c.Context(), org, idParam(c))
if errors.Is(err, errNotFound) {
return zip.ErrNotFound("session not found")
}
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "get: %v", err)
}
return c.JSON(http.StatusOK, toSessionView(x))
}
func updateSession(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := org(c)
if !ok {
return zip.ErrForbidden("a validated org is required")
}
var body updateSessionReq
if err := c.Bind(&body); err != nil {
return err
}
p := Patch{Model: body.Model, TerminalURL: body.TerminalURL}
if body.TerminalURL != nil && len(*body.TerminalURL) > maxURL {
return zip.ErrBadRequest("terminalUrl too long")
}
if body.Status != nil {
st := strings.ToLower(strings.TrimSpace(*body.Status))
if !statuses[st] {
return zip.ErrBadRequest("status must be one of starting, running, waiting, ended, error")
}
p.Status = &st
}
if body.Ended != nil && *body.Ended {
now := time.Now().Unix()
ended := "ended"
p.EndedAt = &now
if p.Status == nil {
p.Status = &ended
}
}
store, err := storeFor(s, org)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "open store: %v", err)
}
x, err := store.Update(c.Context(), org, idParam(c), time.Now().Unix(), p)
if errors.Is(err, errNotFound) {
return zip.ErrNotFound("session not found")
}
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "update: %v", err)
}
return c.JSON(http.StatusOK, toSessionView(x))
}
func deleteSession(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := org(c)
if !ok {
return zip.ErrForbidden("a validated org is required")
}
store, err := storeFor(s, org)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "open store: %v", err)
}
if err := store.Delete(c.Context(), org, idParam(c)); errors.Is(err, errNotFound) {
return zip.ErrNotFound("session not found")
} else if err != nil {
return zip.Errorf(http.StatusInternalServerError, "delete: %v", err)
}
return c.NoContent(http.StatusNoContent)
}
func createEvent(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := org(c)
if !ok {
return zip.ErrForbidden("a validated org is required")
}
var body createEventReq
if err := c.Bind(&body); err != nil {
return err
}
kind := strings.ToLower(strings.TrimSpace(body.Kind))
if kind == "" || len(kind) > maxField {
return zip.ErrBadRequest("kind is required")
}
if len(body.Message) > maxMsg {
return zip.ErrBadRequest("message too long")
}
store, err := storeFor(s, org)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "open store: %v", err)
}
e := Event{ID: genID(), SessionID: idParam(c), Org: org, Kind: kind, Message: body.Message, CreatedAt: time.Now().Unix()}
if err := store.AddEvent(c.Context(), e); errors.Is(err, errNotFound) {
return zip.ErrNotFound("session not found")
} else if err != nil {
return zip.Errorf(http.StatusInternalServerError, "persist event: %v", err)
}
return c.JSON(http.StatusCreated, toEventView(e))
}
func listEvents(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := org(c)
if !ok {
return zip.ErrForbidden("a validated org is required")
}
store, err := storeFor(s, org)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "open store: %v", err)
}
// Confirm the session is in this org before listing its events, so a
// non-owned id 404s like every other :id route rather than leaking a 200.
if _, err := store.Get(c.Context(), org, idParam(c)); errors.Is(err, errNotFound) {
return zip.ErrNotFound("session not found")
} else if err != nil {
return zip.Errorf(http.StatusInternalServerError, "get: %v", err)
}
rows, err := store.ListEvents(c.Context(), org, idParam(c))
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "list events: %v", err)
}
out := make([]eventView, 0, len(rows))
for _, e := range rows {
out = append(out, toEventView(e))
}
return c.JSON(http.StatusOK, out)
}
+168
View File
@@ -0,0 +1,168 @@
package session
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/hanzoai/cloud"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// do runs a JSON request carrying a validated principal (X-Org-Id + X-User-Id,
// the pair org() gates on). An empty org sends neither header — the anonymous
// case that must be refused.
func do(t *testing.T, app *zip.App, method, path, org string, body any) (int, []byte) {
t.Helper()
var r io.Reader
if body != nil {
b, _ := json.Marshal(body)
r = bytes.NewReader(b)
}
req := httptest.NewRequest(method, path, r)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if org != "" {
req.Header.Set("X-Org-Id", org)
req.Header.Set("X-User-Id", "u_"+org)
}
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("Test %s %s: %v", method, path, err)
}
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, b
}
func mount(t *testing.T) *zip.App {
t.Helper()
app := zip.New(zip.Config{Logger: luxlog.New("test")})
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir()}); err != nil {
t.Fatalf("Mount: %v", err)
}
t.Cleanup(func() { _ = Shutdown() })
return app
}
func create(t *testing.T, app *zip.App, org string, body map[string]any) sessionView {
t.Helper()
st, b := do(t, app, http.MethodPost, "/v1/code/sessions", org, body)
if st != http.StatusCreated {
t.Fatalf("create: status %d: %s", st, b)
}
var v sessionView
if err := json.Unmarshal(b, &v); err != nil {
t.Fatalf("decode: %v", err)
}
return v
}
// TestTenantIsolation proves a session created under one org is invisible and
// immutable to another: read, update, delete, and event routes all 404 across
// the org boundary, and a list never leaks the row.
func TestTenantIsolation(t *testing.T) {
app := mount(t)
s := create(t, app, "acme", map[string]any{"agent": "claude", "model": "zen5-pro", "host": "evo", "project": "site"})
// acme sees its own session.
if st, _ := do(t, app, http.MethodGet, "/v1/code/sessions/"+s.ID, "acme", nil); st != http.StatusOK {
t.Fatalf("acme GET own: %d", st)
}
// evil cannot read, update, delete, or event acme's session.
for _, tc := range []struct {
method, path string
body any
}{
{http.MethodGet, "/v1/code/sessions/" + s.ID, nil},
{http.MethodPatch, "/v1/code/sessions/" + s.ID, map[string]any{"status": "running"}},
{http.MethodDelete, "/v1/code/sessions/" + s.ID, nil},
{http.MethodPost, "/v1/code/sessions/" + s.ID + "/events", map[string]any{"kind": "log", "message": "x"}},
{http.MethodGet, "/v1/code/sessions/" + s.ID + "/events", nil},
} {
st, b := do(t, app, tc.method, tc.path, "evil", tc.body)
// events GET returns 200 with an empty list only if the session is
// visible; since it is not, it must 404 like the rest.
if st != http.StatusNotFound {
t.Errorf("evil %s %s = %d (want 404): %s", tc.method, tc.path, st, b)
}
}
// evil's list is empty; acme's holds exactly the one session.
if st, b := do(t, app, http.MethodGet, "/v1/code/sessions", "evil", nil); st != 200 || string(bytes.TrimSpace(b)) != "[]" {
t.Errorf("evil list = %d %s (want 200 [])", st, b)
}
var mine []sessionView
_, b := do(t, app, http.MethodGet, "/v1/code/sessions", "acme", nil)
_ = json.Unmarshal(b, &mine)
if len(mine) != 1 || mine[0].ID != s.ID {
t.Errorf("acme list = %v (want 1 session %s)", mine, s.ID)
}
}
// TestAnonymousRefused proves no route serves a request without a validated
// principal.
func TestAnonymousRefused(t *testing.T) {
app := mount(t)
for _, tc := range []struct{ method, path string }{
{http.MethodPost, "/v1/code/sessions"},
{http.MethodGet, "/v1/code/sessions"},
{http.MethodGet, "/v1/code/sessions/ses_x"},
} {
if st, _ := do(t, app, tc.method, tc.path, "", map[string]any{"agent": "claude"}); st != http.StatusForbidden {
t.Errorf("anon %s %s = %d (want 403)", tc.method, tc.path, st)
}
}
}
// TestLifecycle walks a session from register through heartbeat, terminal
// publish, an event, and end — the launcher's real path.
func TestLifecycle(t *testing.T) {
app := mount(t)
s := create(t, app, "acme", map[string]any{"id": "ses_abcd1234", "agent": "codex", "host": "spark"})
if s.ID != "ses_abcd1234" || s.Status != "starting" {
t.Fatalf("register: %+v", s)
}
// publish the terminal URL + go running (heartbeat).
st, b := do(t, app, http.MethodPatch, "/v1/code/sessions/"+s.ID, "acme",
map[string]any{"status": "running", "terminalUrl": "https://code-x.zt.hanzo/"})
if st != http.StatusOK {
t.Fatalf("patch: %d %s", st, b)
}
var upd sessionView
_ = json.Unmarshal(b, &upd)
if upd.Status != "running" || upd.TerminalURL == "" {
t.Fatalf("patch result: %+v", upd)
}
// a hook event lands.
if st, b := do(t, app, http.MethodPost, "/v1/code/sessions/"+s.ID+"/events", "acme",
map[string]any{"kind": "notification", "message": "run tests?"}); st != http.StatusCreated {
t.Fatalf("event: %d %s", st, b)
}
// end it.
if st, _ := do(t, app, http.MethodPatch, "/v1/code/sessions/"+s.ID, "acme", map[string]any{"ended": true}); st != http.StatusOK {
t.Fatalf("end: %d", st)
}
// live filter now excludes it.
var live []sessionView
_, b = do(t, app, http.MethodGet, "/v1/code/sessions?live=1", "acme", nil)
_ = json.Unmarshal(b, &live)
if len(live) != 0 {
t.Errorf("live after end = %d (want 0)", len(live))
}
}
// TestValidation rejects an unknown agent and a malformed id.
func TestValidation(t *testing.T) {
app := mount(t)
if st, _ := do(t, app, http.MethodPost, "/v1/code/sessions", "acme", map[string]any{"agent": "emacs"}); st != http.StatusBadRequest {
t.Errorf("bad agent = %d (want 400)", st)
}
if st, _ := do(t, app, http.MethodPost, "/v1/code/sessions", "acme", map[string]any{"agent": "claude", "id": "bad id!"}); st != http.StatusBadRequest {
t.Errorf("bad id = %d (want 400)", st)
}
}
+280
View File
@@ -0,0 +1,280 @@
package session
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
)
// errNotFound is returned when a session (or its events) is not present in the
// org's file; handlers map it to HTTP 404.
var errNotFound = errors.New("session: not found")
// Session is one live (or ended) coding-agent run launched by `hanzo code
// <agent>`. It is the value console.hanzo.ai lists and drives: the registry row
// that says a claude/codex/dev process is running on some machine, against some
// Hanzo model, in some working directory, reachable at TerminalURL.
//
// Org is the physical tenant boundary (one sessions.db per org, filtered
// WHERE org=? on every query). Project is the LINK to the deployable
// projects.Project the run belongs to (principal.Project, "" = none) — so a
// project view can filter its own sessions without a second registry.
//
// TerminalURL is the ttyd web-terminal endpoint the launcher publishes once its
// ZT (or zrok) tunnel is up; empty until the mirror is ready. Status is the
// lifecycle column the console polls: starting → running → waiting (needs
// input) → ended | error.
type Session struct {
ID string
Org string
Project string
User string
Agent string // claude | codex | dev
Model string
Host string
Cwd string
TerminalURL string
Status string
StartedAt int64
UpdatedAt int64
EndedAt int64 // 0 while live
}
// Event is one lifecycle signal a session's agent emits — sourced from Claude
// Code's Notification/Stop hooks (needs-input, turn-done) — so the console and
// the @hanzo Slack relay learn state changes without scraping the terminal.
type Event struct {
ID string
SessionID string
Org string
Kind string // notification | stop | error | log
Message string
CreatedAt int64
}
// Filter narrows a session list. Empty fields do not constrain. Live selects
// only sessions that have not ended (the console's default "what's running").
type Filter struct {
Status string
Project string
Host string
Agent string
Live bool
}
// Store is one org's SQLite file. Every method filters WHERE org=?, so a query
// in one org's store can never reach another org's rows.
type Store struct{ db *sql.DB }
// Close releases the org's SQLite handle; the OrgStore cache calls it on
// eviction and on Shutdown.
func (s *Store) Close() error { return s.db.Close() }
const sessionCols = "id, org, project, usr, agent, model, host, cwd, terminal_url, status, started_at, updated_at, ended_at"
// openStore is the cloud.NewOrgStore factory: it receives the org's already-open
// *sql.DB and installs the schema. Idempotent (IF NOT EXISTS), so reopen is a
// no-op.
func openStore(db *sql.DB) (*Store, error) {
const schema = `
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
org TEXT NOT NULL,
project TEXT NOT NULL DEFAULT '',
usr TEXT NOT NULL DEFAULT '',
agent TEXT NOT NULL,
model TEXT NOT NULL DEFAULT '',
host TEXT NOT NULL DEFAULT '',
cwd TEXT NOT NULL DEFAULT '',
terminal_url TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'starting',
started_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
ended_at INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS sessions_org_status ON sessions(org, status);
CREATE INDEX IF NOT EXISTS sessions_org_project ON sessions(org, project);
CREATE TABLE IF NOT EXISTS session_events (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
org TEXT NOT NULL,
kind TEXT NOT NULL,
message TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS session_events_sid ON session_events(session_id, created_at);
`
if _, err := db.Exec(schema); err != nil {
return nil, fmt.Errorf("session schema: %w", err)
}
return &Store{db: db}, nil
}
// Create inserts a new session row.
func (s *Store) Create(ctx context.Context, x Session) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO sessions (`+sessionCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
x.ID, x.Org, x.Project, x.User, x.Agent, x.Model, x.Host, x.Cwd,
x.TerminalURL, x.Status, x.StartedAt, x.UpdatedAt, x.EndedAt)
if err != nil {
return fmt.Errorf("insert session: %w", err)
}
return nil
}
// Get returns one org-scoped session by id, or errNotFound.
func (s *Store) Get(ctx context.Context, org, id string) (Session, error) {
row := s.db.QueryRowContext(ctx,
`SELECT `+sessionCols+` FROM sessions WHERE org=? AND id=?`, org, id)
x, err := scanSession(row)
if errors.Is(err, sql.ErrNoRows) {
return Session{}, errNotFound
}
return x, err
}
// List returns the org's sessions, newest first, narrowed by f. The org
// predicate leads every query; f only ANDs further, so a filter can never widen
// past the tenant boundary.
func (s *Store) List(ctx context.Context, org string, f Filter) ([]Session, error) {
q := `SELECT ` + sessionCols + ` FROM sessions WHERE org=?`
args := []any{org}
if f.Live {
q += ` AND ended_at=0`
}
if f.Status != "" {
q += ` AND status=?`
args = append(args, f.Status)
}
if f.Project != "" {
q += ` AND project=?`
args = append(args, f.Project)
}
if f.Host != "" {
q += ` AND host=?`
args = append(args, f.Host)
}
if f.Agent != "" {
q += ` AND agent=?`
args = append(args, f.Agent)
}
q += ` ORDER BY started_at DESC`
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("list sessions: %w", err)
}
defer rows.Close()
var out []Session
for rows.Next() {
x, err := scanSession(rows)
if err != nil {
return nil, err
}
out = append(out, x)
}
return out, rows.Err()
}
// Patch is the mutable subset of a session. A nil field leaves the column
// unchanged, so a heartbeat that only refreshes updated_at need not resend the
// terminal URL.
type Patch struct {
Status *string
TerminalURL *string
Model *string
EndedAt *int64
}
// Update applies p to one org-scoped session and returns the new row. updated_at
// is always refreshed to now. Returns errNotFound when the id is absent.
func (s *Store) Update(ctx context.Context, org, id string, now int64, p Patch) (Session, error) {
sets := []string{"updated_at=?"}
args := []any{now}
if p.Status != nil {
sets = append(sets, "status=?")
args = append(args, *p.Status)
}
if p.TerminalURL != nil {
sets = append(sets, "terminal_url=?")
args = append(args, *p.TerminalURL)
}
if p.Model != nil {
sets = append(sets, "model=?")
args = append(args, *p.Model)
}
if p.EndedAt != nil {
sets = append(sets, "ended_at=?")
args = append(args, *p.EndedAt)
}
args = append(args, org, id)
res, err := s.db.ExecContext(ctx,
`UPDATE sessions SET `+strings.Join(sets, ", ")+` WHERE org=? AND id=?`, args...)
if err != nil {
return Session{}, fmt.Errorf("update session: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return Session{}, errNotFound
}
return s.Get(ctx, org, id)
}
// Delete removes one org-scoped session and its events. Returns errNotFound when
// the id is absent.
func (s *Store) Delete(ctx context.Context, org, id string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE org=? AND id=?`, org, id)
if err != nil {
return fmt.Errorf("delete session: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return errNotFound
}
_, _ = s.db.ExecContext(ctx, `DELETE FROM session_events WHERE org=? AND session_id=?`, org, id)
return nil
}
// AddEvent records one lifecycle event against an existing org-scoped session.
// It first confirms the session exists in this org (so an event can never be
// filed against another tenant's id), then inserts.
func (s *Store) AddEvent(ctx context.Context, e Event) error {
if _, err := s.Get(ctx, e.Org, e.SessionID); err != nil {
return err // errNotFound propagates → 404
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO session_events (id, session_id, org, kind, message, created_at) VALUES (?,?,?,?,?,?)`,
e.ID, e.SessionID, e.Org, e.Kind, e.Message, e.CreatedAt)
if err != nil {
return fmt.Errorf("insert event: %w", err)
}
return nil
}
// ListEvents returns a session's events oldest-first, org-scoped.
func (s *Store) ListEvents(ctx context.Context, org, sessionID string) ([]Event, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, session_id, org, kind, message, created_at FROM session_events
WHERE org=? AND session_id=? ORDER BY created_at ASC`, org, sessionID)
if err != nil {
return nil, fmt.Errorf("list events: %w", err)
}
defer rows.Close()
var out []Event
for rows.Next() {
var e Event
if err := rows.Scan(&e.ID, &e.SessionID, &e.Org, &e.Kind, &e.Message, &e.CreatedAt); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
// scanSession reads one sessions row from a *sql.Row or *sql.Rows.
func scanSession(sc interface{ Scan(...any) error }) (Session, error) {
var x Session
err := sc.Scan(&x.ID, &x.Org, &x.Project, &x.User, &x.Agent, &x.Model, &x.Host,
&x.Cwd, &x.TerminalURL, &x.Status, &x.StartedAt, &x.UpdatedAt, &x.EndedAt)
return x, err
}
+12 -3
View File
@@ -257,9 +257,18 @@ func (s *Server) siteSlug(host string) (string, bool) {
return "", false
}
key := strings.TrimSuffix(host, suffix)
slug, org, ok := strings.Cut(key, ".")
if !ok {
return "", false // single-label host (bare <label>.<apex>) is not org-scoped
slug, org, scoped := strings.Cut(key, ".")
if !scoped {
// Bare `<slug>.<apex>` — the product URL every surface advertises, and the
// ONLY shape the edge can physically serve today (a k8s Ingress host and a
// Let's Encrypt wildcard each match exactly one label, so
// `<slug>.<org>.<apex>` never routes nor gets TLS). The resolver serves it
// iff it maps to exactly one live project (explicit binding, else a unique
// live slug across orgs) — an ambiguous bare host is an honest 404.
if IsReserved(key) || !slugRE.MatchString(key) {
return "", false
}
return key, true
}
if strings.Contains(org, ".") {
return "", false // more than two labels under the apex
+10 -1
View File
@@ -146,8 +146,17 @@ func TestSiteSlug(t *testing.T) {
site("my-cool-site.acme.hanzo.app", "my-cool-site.acme")
site("myapp.maxpower.hanzo.app:443", "myapp.maxpower") // port stripped
// Bare `<slug>.<apex>` — the product URL (and the only shape the one-label
// ingress wildcard + LE cert can serve). Key is the bare slug; the resolver
// serves it iff it maps to exactly one live project.
site("dave-synapse-demo.hanzo.app", "dave-synapse-demo")
site("Brew.Hanzo.App", "brew") // case-insensitive
site("vibe-check.hanzo.app:443", "vibe-check") // port stripped
notSite("hanzo.app") // apex, no label
notSite("maxpower.hanzo.app") // single label — not org-scoped
notSite("www.hanzo.app") // reserved bare label
notSite("api.hanzo.app") // reserved bare label
notSite("-bad.hanzo.app") // invalid bare slug
notSite("www.acme.hanzo.app") // reserved slug label
notSite("app.acme.hanzo.app") // reserved (real app host)
notSite("api.acme.hanzo.app") // reserved
+18
View File
@@ -8,6 +8,7 @@ package team
import (
"context"
"errors"
"net/http"
"github.com/google/uuid"
@@ -123,6 +124,23 @@ func botActive(status string) bool {
return status == "" || status == "active" || status == "ready"
}
// agentReplyRunner is the ONE in-process seam the Chunter responder (chat.go) uses
// to make a bot answer: it runs the agent through agents.RunOnBehalf — the SAME
// billed/metered/recorded run path the HTTP POST /v1/agents/:id/run handler uses —
// on behalf of the human who addressed it, and returns the model's text. A run that
// executed but whose model errored (nil error, error-status Run) surfaces as an
// error so the responder posts nothing rather than an empty bubble.
func agentReplyRunner(ctx context.Context, org, userSub, agentID, input string) (string, error) {
run, err := agents.RunOnBehalf(ctx, org, userSub, agentID, input)
if err != nil {
return "", err
}
if run.Error != "" {
return "", errors.New(run.Error)
}
return run.Output, nil
}
// botUserID derives a STABLE member account uuid from an agent id — a UUIDv5 over
// namespace "agent:<id>", so re-syncs converge (never duplicate a bot member) and
// the value is a valid UUID (the Person.personUuid + token invariant). Empty in →
+401
View File
@@ -0,0 +1,401 @@
package team
// chat.go is the Chunter agent-responder: it makes the org's agents (already
// projected as workspace members by the roster reconcile) TALKABLE. When a human
// posts a Chunter ChatMessage addressed to a bot member — a direct message whose
// participants include the bot, or a channel message that @-mentions it — the
// transactor runs that agent through the canonical in-process run path
// (agents.RunOnBehalf, one balance gate + one debit + one recorded run) and posts
// the model's answer back into the SAME conversation as that bot.
//
// SAFETY POSTURE (why every path here is bounded and lazy). The responder is the
// ONE place in the team subsystem that can, per inbound message, initiate an
// OUTBOUND model call — and that call self-routes through the cloud gateway
// (api.hanzo.ai/v1). An unbounded version is a foot-gun: a replayed message
// backlog, a channel firehose, or a persistently-failing model (e.g. the
// publishable-key 403) could each fan out into thousands of concurrent HTTP calls
// and exhaust the writer. So the responder is:
// - OFF by default — Mount only wires runAgent when TEAM_AGENTS_ENABLED=1, so an
// un-configured or misconfigured binary NEVER answers (nil runAgent ⇒ inert).
// - Fresh-only — a message created before this process booted (a replay/backfill)
// is NEVER answered; only genuinely-live posts trigger a turn.
// - Single-flight per conversation+bot — at most one in-flight answer per
// (workspace, space, bot); duplicates are dropped, not queued.
// - Hard-capped — a global semaphore bounds concurrent turns; over the cap, drop.
// - Circuit-broken — after repeated failures an agent is skipped for a cooldown
// (the backoff that turns a 403 storm into a quiet trickle). No retries, ever.
//
// It is the chat twin of bots.go: bots.go is the READ/projection surface (agents
// AS members); this is the WRITE/response surface (agents that ANSWER). The seam to
// the LLM is a single injected func (AgentRunner) so the transactor stays decoupled
// from clients/agents' concrete run machinery and the loop is unit-testable with a
// fake runner.
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"html"
"regexp"
"strings"
"sync"
"time"
)
// Chunter class ids the responder recognizes. Kept beside the code that reads them
// (the model-vocabulary-in-one-place rule projections.go follows).
const (
clChatMessage = "chunter:class:ChatMessage"
clDirectMessage = "chunter:class:DirectMessage"
)
// Responder bounds. Conservative defaults; the concurrency cap is overridable in
// Mount via TEAM_AGENTS_MAX_CONCURRENCY.
const (
agentReplyTimeout = 90 * time.Second // one agent turn (model call + reply write)
defaultMaxConcurrent = 4 // global in-flight turn cap default
backfillGraceMs = 60_000 // a message created >60s before boot is backfill → never answered
breakerThreshold = 3 // consecutive failures before an agent's circuit opens
breakerCooldown = 60 * time.Second // how long a tripped agent is skipped
)
// AgentRunner runs agent `agentID` for `org` on behalf of `userSub` with `input`
// and returns the model's text output. It is injected in Mount (only when the
// feature is enabled) as an adapter over agents.RunOnBehalf — the ONE in-process
// run path (billed, metered, recorded) — so clients/team never speaks
// clients/agents' concrete run types and the responder is testable with a fake.
type AgentRunner func(ctx context.Context, org, userSub, agentID, input string) (string, error)
// agentBreaker is one agent's failure circuit. Guarded by its own mutex; stored in
// transServer.breaker keyed by agent id.
type agentBreaker struct {
mu sync.Mutex
fails int
openUntil int64 // unix millis; while now < openUntil the agent is skipped
}
// chatMsg is the parsed shape of one inbound Chunter ChatMessage create the
// responder needs: where it lives (space + the attach coordinates a reply mirrors),
// its author, its text, and WHEN it was created (the freshness gate).
type chatMsg struct {
objectID string
space string
attachedTo string
attachedToClass string
collection string
message string // stored markup (HTML)
authorUID string // account uuid (social id stripped of the "hanzo:" prefix)
createdOn int64 // unix millis; a value below the boot floor is backfill
}
// parseChatMessage returns the chatMsg for an applied tx iff it is a create of a
// chunter:class:ChatMessage. Every other applied tx (roster txes, tracker/docs
// writes, updates, removes) returns ok=false and is ignored. It reads the FLATTENED
// applied tx, so a create wrapped in TxCollectionCUD (which applyTx unwraps, setting
// attachedTo/attachedToClass/collection on the inner tx) is recognized identically
// to a bare TxCreateDoc.
func parseChatMessage(raw json.RawMessage) (chatMsg, bool) {
var t map[string]any
if err := json.Unmarshal(raw, &t); err != nil {
return chatMsg{}, false
}
if str(t["_class"]) != clTxCreate || str(t["objectClass"]) != clChatMessage {
return chatMsg{}, false
}
m := chatMsg{
objectID: str(t["objectId"]),
space: str(t["objectSpace"]),
attachedTo: str(t["attachedTo"]),
attachedToClass: str(t["attachedToClass"]),
collection: str(t["collection"]),
authorUID: stripHanzo(str(firstNonNil(t["createdBy"], t["modifiedBy"]))),
createdOn: asInt64(firstNonNil(t["createdOn"], t["modifiedOn"])),
}
if a, ok := t["attributes"].(map[string]any); ok {
m.message = str(a["message"])
}
// A top-level channel/DM message carries both (space == attachedTo == the
// conversation id). Without them there is no conversation to answer into.
if m.space == "" || m.attachedTo == "" {
return chatMsg{}, false
}
return m, true
}
// maybeAgentReply is the transactor hook (called from session.tx, the client WS
// write path — NEVER from the roster/sync applyTx path, so a projection can never
// trigger a reply). For every inbound, FRESH, human ChatMessage addressed to an
// active bot member it fires one async, bounded agent turn per bot. It returns
// immediately; the model call and the reply write happen in a recovered, capped
// goroutine so the WS read loop is never blocked and a model/DB error can never
// crash the session.
func (srv *transServer) maybeAgentReply(org, workspace string, applied []json.RawMessage) {
if srv.runAgent == nil || srv.bots == nil {
return // responder disabled (TEAM_AGENTS_ENABLED unset ⇒ no runner wired)
}
// Cheap gate first: only touch the agents registry if a FRESH chat message was
// written. A message created before this process booted is a replay/backfill and
// is never answered (the anti-storm invariant the boot-backlog test locks).
floor := srv.startedAt - backfillGraceMs
var msgs []chatMsg
for _, raw := range applied {
m, ok := parseChatMessage(raw)
if !ok {
continue
}
if srv.startedAt > 0 && m.createdOn > 0 && m.createdOn < floor {
continue // backfill — never answer
}
msgs = append(msgs, m)
}
if len(msgs) == 0 {
return
}
bots, err := srv.bots(context.Background(), org)
if err != nil || len(bots) == 0 {
return
}
byUID := make(map[string]Bot, len(bots))
for _, b := range bots {
if b.Active {
byUID[botUserID(b.ID)] = b
}
}
if len(byUID) == 0 {
return
}
for _, m := range msgs {
// Loop guard: a message a bot authored (its own reply, or another bot's)
// never triggers a reply.
if _, isBot := byUID[m.authorUID]; isBot {
continue
}
for _, bot := range srv.replyTargets(org, workspace, m, byUID) {
go srv.replyAsBot(org, workspace, m, bot)
}
}
}
// replyTargets is the addressing policy: which bots should answer message m.
// - Direct message: every active bot participant answers (a DM to a bot is, by
// construction, addressed to it).
// - Channel / group: only a bot the message explicitly @-mentions answers (a bot
// does not answer every line in a shared channel).
//
// A bot is never double-targeted (DM + mention) — the seen set collapses them.
func (srv *transServer) replyTargets(org, workspace string, m chatMsg, byUID map[string]Bot) []Bot {
seen := map[string]bool{}
var out []Bot
if doc, _ := srv.store.get(org, workspace, m.space); doc != nil && str(doc["_class"]) == clDirectMessage {
for _, mem := range toStringSlice(doc["members"]) {
if b, ok := byUID[mem]; ok && !seen[mem] {
seen[mem] = true
out = append(out, b)
}
}
}
for uid, b := range byUID {
if !seen[uid] && mentionsBot(m.message, uid) {
seen[uid] = true
out = append(out, b)
}
}
return out
}
// replyAsBot runs one bounded agent turn and posts its answer as the bot, in the
// SAME conversation (space + attach coordinates mirrored from the inbound message)
// and through the SAME write path (applyTx + broadcast) the SPA uses. It is
// single-flight per (workspace, space, bot), globally concurrency-capped, and
// circuit-broken per agent; recovered + timeout-bounded so a panicking model
// adapter or a hung call can neither crash the transactor nor leak.
func (srv *transServer) replyAsBot(org, workspace string, m chatMsg, bot Bot) {
defer func() {
if r := recover(); r != nil && srv.log != nil {
srv.log.Error("team: agent reply panicked", "agent", bot.ID, "err", r)
}
}()
// Single-flight: at most one in-flight answer per conversation+bot. A burst of
// messages to the same DM while a turn is running collapses to one.
flightKey := workspace + "|" + m.space + "|" + bot.ID
if _, busy := srv.inflight.LoadOrStore(flightKey, struct{}{}); busy {
return
}
defer srv.inflight.Delete(flightKey)
// Circuit breaker: skip a persistently-failing agent for a cooldown (the backoff
// that turns a 403/5xx storm into a quiet trickle).
if srv.breakerOpen(bot.ID) {
return
}
// Hard concurrency cap: never more than N turns in flight process-wide. Over the
// cap we DROP (bounded, no queue) rather than pile up goroutines/HTTP calls.
if srv.sem != nil {
select {
case srv.sem <- struct{}{}:
defer func() { <-srv.sem }()
default:
return
}
}
prompt := plainText(m.message)
if prompt == "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), agentReplyTimeout)
defer cancel()
out, err := srv.runAgent(ctx, org, m.authorUID, bot.ID, prompt)
if err != nil {
srv.breakerRecord(bot.ID, false)
if srv.log != nil {
srv.log.Warn("team: agent reply failed", "agent", bot.ID, "err", err)
}
return
}
srv.breakerRecord(bot.ID, true)
if strings.TrimSpace(out) == "" {
return
}
botUID := botUserID(bot.ID)
tx := attachedCreateTx(newMsgID(), clChatMessage, m.space, m.attachedTo, m.attachedToClass,
pick(m.collection, "messages"), "hanzo:"+botUID, map[string]any{"message": htmlMarkup(out)})
raw, err := json.Marshal(tx)
if err != nil {
return
}
// A detached system session bound to (org, workspace) — the same shape the
// /v1/team/bots/sync reconcile uses to write into a workspace off the WS loop.
sess := &session{server: srv, store: srv.store, hier: srv.hier, org: org, workspace: workspace, account: botUID}
if _, ap := sess.applyTx(raw); len(ap) > 0 {
srv.hub.broadcast(workspace, ap)
}
}
// breakerOpen reports whether agent `id`'s circuit is currently tripped.
func (srv *transServer) breakerOpen(id string) bool {
v, ok := srv.breaker.Load(id)
if !ok {
return false
}
b := v.(*agentBreaker)
b.mu.Lock()
defer b.mu.Unlock()
return time.Now().UnixMilli() < b.openUntil
}
// breakerRecord folds one turn's outcome into agent `id`'s circuit: a success
// resets it; breakerThreshold consecutive failures trip it for breakerCooldown.
func (srv *transServer) breakerRecord(id string, ok bool) {
v, _ := srv.breaker.LoadOrStore(id, &agentBreaker{})
b := v.(*agentBreaker)
b.mu.Lock()
defer b.mu.Unlock()
if ok {
b.fails = 0
b.openUntil = 0
return
}
b.fails++
if b.fails >= breakerThreshold {
b.openUntil = time.Now().UnixMilli() + breakerCooldown.Milliseconds()
b.fails = 0 // count fresh after the cooldown
}
}
// ── helpers ──────────────────────────────────────────────────────────────────
// stripHanzo drops the "hanzo:" social-id prefix, yielding the bare account uuid
// (the key the bot map and DM.members use). A value without the prefix is returned
// unchanged.
func stripHanzo(id string) string { return strings.TrimPrefix(id, "hanzo:") }
var tagRe = regexp.MustCompile(`<[^>]*>`)
// plainText renders stored message markup (HTML) to the plain text an LLM should
// read: tags dropped, entities unescaped, block tags becoming spaces, whitespace
// collapsed. Deterministic and dependency-free (no markup library on the hot path).
func plainText(markup string) string {
// Turn common block boundaries into spaces so words don't fuse across tags.
s := regexp.MustCompile(`(?i)<(/p|br|/div|/li)\s*/?>`).ReplaceAllString(markup, " ")
s = tagRe.ReplaceAllString(s, "")
s = html.UnescapeString(s)
return strings.TrimSpace(regexp.MustCompile(`\s+`).ReplaceAllString(s, " "))
}
// htmlMarkup wraps an agent's plain-text answer as the minimal ProseMirror-
// compatible markup Chunter stores: each non-empty line an escaped <p>. Empty input
// yields an empty paragraph (never invalid markup).
func htmlMarkup(text string) string {
lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
var b strings.Builder
for _, ln := range lines {
ln = strings.TrimRight(ln, " \t")
if ln == "" {
continue
}
b.WriteString("<p>")
b.WriteString(html.EscapeString(ln))
b.WriteString("</p>")
}
if b.Len() == 0 {
return "<p></p>"
}
return b.String()
}
// mentionsBot reports whether message markup @-references the bot member. Chunter
// stores a mention as a reference node carrying the referenced Person's _id, which
// is the deterministic PersonRef(uid) — so a substring match on that id is the
// mention test (no markup parse needed).
func mentionsBot(message, uid string) bool {
return uid != "" && strings.Contains(message, PersonRef(uid))
}
// toStringSlice coerces a JSON array (from a stored doc) to []string, dropping
// non-string entries. Nil-safe.
func toStringSlice(v any) []string {
arr, ok := v.([]any)
if !ok {
return nil
}
out := make([]string, 0, len(arr))
for _, it := range arr {
if s, ok := it.(string); ok {
out = append(out, s)
}
}
return out
}
// asInt64 coerces a JSON number (float64) or int to int64. Non-numeric ⇒ 0.
func asInt64(v any) int64 {
switch n := v.(type) {
case float64:
return int64(n)
case int64:
return n
case int:
return int64(n)
case json.Number:
i, _ := n.Int64()
return i
}
return 0
}
// newMsgID mints a fresh opaque doc id for a reply message. Chunter treats _id as an
// opaque Ref; 16 random bytes hex-encoded is collision-free in practice and a valid
// single path/ref segment.
func newMsgID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
// rand.Read never fails on supported platforms; fall back to a time seed so a
// reply id is still unique-enough rather than empty.
return "msg" + hex.EncodeToString([]byte(time.Now().Format(time.RFC3339Nano)))
}
return hex.EncodeToString(b[:])
}
+522
View File
@@ -0,0 +1,522 @@
package team
import (
"context"
"encoding/json"
"strings"
"sync"
"testing"
"time"
)
// ── pure helpers ───────────────────────────────────────────────────────────────
func TestParseChatMessageRecognizesChunterCreate(t *testing.T) {
raw := mustMarshal(t, map[string]any{
"_class": clTxCreate, "objectId": "m1", "objectClass": clChatMessage,
"objectSpace": "dm1", "attachedTo": "dm1", "attachedToClass": clDirectMessage,
"collection": "messages", "createdBy": "hanzo:u-human",
"attributes": map[string]any{"message": "<p>hi</p>"},
})
m, ok := parseChatMessage(raw)
if !ok {
t.Fatal("parseChatMessage did not recognize a ChatMessage create")
}
if m.space != "dm1" || m.attachedTo != "dm1" || m.attachedToClass != clDirectMessage ||
m.collection != "messages" || m.message != "<p>hi</p>" || m.authorUID != "u-human" {
t.Fatalf("parsed chatMsg wrong: %+v", m)
}
}
func TestParseChatMessageIgnoresNonChat(t *testing.T) {
// A Person create (roster projection) must not be read as a chat message.
raw := mustMarshal(t, map[string]any{
"_class": clTxCreate, "objectId": "p1", "objectClass": clPerson,
"objectSpace": spaceContacts, "attributes": map[string]any{"name": ",x"},
})
if _, ok := parseChatMessage(raw); ok {
t.Fatal("a Person create was misread as a chat message")
}
// A ChatMessage create missing its conversation coordinates is not answerable.
raw2 := mustMarshal(t, map[string]any{
"_class": clTxCreate, "objectId": "m2", "objectClass": clChatMessage,
"attributes": map[string]any{"message": "hi"},
})
if _, ok := parseChatMessage(raw2); ok {
t.Fatal("a ChatMessage with no space/attachedTo was accepted")
}
// An update tx is never a create.
raw3 := mustMarshal(t, map[string]any{"_class": clTxUpdate, "objectId": "m1", "objectClass": clChatMessage})
if _, ok := parseChatMessage(raw3); ok {
t.Fatal("an update tx was misread as a create")
}
}
func TestPlainText(t *testing.T) {
cases := map[string]string{
"<p>hello <b>world</b></p>": "hello world",
"a<br>b": "a b",
"<p>one</p><p>two</p>": "one two",
"tom &amp; jerry &lt;3": "tom & jerry <3",
" <div> spaced out </div> ": "spaced out",
"": "",
}
for in, want := range cases {
if got := plainText(in); got != want {
t.Errorf("plainText(%q) = %q, want %q", in, got, want)
}
}
}
func TestHTMLMarkupEscapesAndWraps(t *testing.T) {
got := htmlMarkup("a <script>x</script>\n\nb & c")
if strings.Contains(got, "<script>") {
t.Fatalf("htmlMarkup did not escape html: %q", got)
}
if got != "<p>a &lt;script&gt;x&lt;/script&gt;</p><p>b &amp; c</p>" {
t.Fatalf("htmlMarkup wrong: %q", got)
}
if htmlMarkup("") != "<p></p>" {
t.Fatalf("htmlMarkup(empty) = %q, want <p></p>", htmlMarkup(""))
}
}
func TestStripHanzo(t *testing.T) {
if stripHanzo("hanzo:abc") != "abc" {
t.Fatal("stripHanzo did not drop the prefix")
}
if stripHanzo("abc") != "abc" {
t.Fatal("stripHanzo mangled a bare uuid")
}
}
func TestMentionsBot(t *testing.T) {
uid := "u-bot"
ref := PersonRef(uid) // person-u-bot
if !mentionsBot(`hey <span data-id="`+ref+`">@bot</span> please`, uid) {
t.Fatal("mentionsBot missed a reference to the bot")
}
if mentionsBot("hey nobody here", uid) {
t.Fatal("mentionsBot matched a message with no reference")
}
if mentionsBot("anything", "") {
t.Fatal("mentionsBot must never match an empty uid")
}
}
func TestToStringSlice(t *testing.T) {
got := toStringSlice([]any{"a", 1, "b", nil})
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Fatalf("toStringSlice = %v, want [a b]", got)
}
if toStringSlice(nil) != nil || toStringSlice("nope") != nil {
t.Fatal("toStringSlice must be nil-safe for non-arrays")
}
}
func TestNewMsgIDUniqueAndOpaque(t *testing.T) {
a, b := newMsgID(), newMsgID()
if a == "" || a == b {
t.Fatalf("newMsgID not unique/non-empty: %q %q", a, b)
}
if strings.ContainsAny(a, "/ \t") {
t.Fatalf("newMsgID is not a clean ref segment: %q", a)
}
}
// ── addressing policy ──────────────────────────────────────────────────────────
func TestReplyTargetsDirectMessage(t *testing.T) {
const org, human = "acme", "11111111-1111-4111-8111-111111111111"
bot := Bot{ID: "agent_enso", Name: "enso", Active: true}
srv, _, ws := rosterServer(t, org, human, "Ada", []Bot{bot})
botUID := botUserID(bot.ID)
dmID := "dm-1"
putDoc(t, srv, org, ws, map[string]any{
"_id": dmID, "_class": clDirectMessage, "space": dmID,
"members": []any{human, botUID},
})
byUID := map[string]Bot{botUID: bot}
m := chatMsg{space: dmID, authorUID: human, message: "<p>hi</p>"}
got := srv.replyTargets(org, ws, m, byUID)
if len(got) != 1 || got[0].ID != bot.ID {
t.Fatalf("DM replyTargets = %v, want the one bot member", got)
}
// A DM between two humans (no bot member) → no target.
putDoc(t, srv, org, ws, map[string]any{
"_id": "dm-2", "_class": clDirectMessage, "space": "dm-2",
"members": []any{human, "someone-else"},
})
if got := srv.replyTargets(org, ws, chatMsg{space: "dm-2", authorUID: human}, byUID); len(got) != 0 {
t.Fatalf("human-only DM replyTargets = %v, want none", got)
}
}
func TestReplyTargetsChannelMentionOnly(t *testing.T) {
const org, human = "acme", "22222222-2222-4222-8222-222222222222"
bot := Bot{ID: "agent_enso", Name: "enso", Active: true}
srv, _, ws := rosterServer(t, org, human, "Ada", []Bot{bot})
botUID := botUserID(bot.ID)
byUID := map[string]Bot{botUID: bot}
chID := "channel-1"
putDoc(t, srv, org, ws, map[string]any{"_id": chID, "_class": clChannel, "space": chID})
// Plain channel chatter → the bot stays quiet.
if got := srv.replyTargets(org, ws, chatMsg{space: chID, authorUID: human, message: "<p>hello all</p>"}, byUID); len(got) != 0 {
t.Fatalf("un-mentioned channel message replyTargets = %v, want none", got)
}
// A message @-mentioning the bot → it answers.
mention := `<p>hey <span data-id="` + PersonRef(botUID) + `">@enso</span></p>`
if got := srv.replyTargets(org, ws, chatMsg{space: chID, authorUID: human, message: mention}, byUID); len(got) != 1 || got[0].ID != bot.ID {
t.Fatalf("mention channel message replyTargets = %v, want the bot", got)
}
}
// ── full reply loop (fake runner, real store) ──────────────────────────────────
// fakeRunner records the one call and returns a fixed answer, signalling done.
type fakeRunner struct {
mu sync.Mutex
called bool
org, userSub, agent, text string
out string
err error
}
func (f *fakeRunner) run(_ context.Context, org, userSub, agentID, input string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.called = true
f.org, f.userSub, f.agent, f.text = org, userSub, agentID, input
return f.out, f.err
}
func TestMaybeAgentReplyAnswersDirectMessage(t *testing.T) {
const org, human = "maxpower", "113d4dd4-2486-40de-be2b-88d6e3e0b718"
bot := Bot{ID: "agent_enso", Name: "enso", Active: true}
srv, _, ws := rosterServer(t, org, human, "Dave Lorenzini", []Bot{bot})
botUID := botUserID(bot.ID)
fr := &fakeRunner{out: "Hello Dave, I am enso."}
srv.runAgent = fr.run
dmID := "dm-enso"
putDoc(t, srv, org, ws, map[string]any{
"_id": dmID, "_class": clDirectMessage, "space": dmID,
"members": []any{human, botUID},
})
inbound := chatCreateRaw(t, dmID, clDirectMessage, "hanzo:"+human, "<p>hello <b>agent</b></p>")
srv.maybeAgentReply(org, ws, []json.RawMessage{inbound})
waitFor(t, 2*time.Second, func() bool {
return len(botMessages(t, srv, org, ws, botUID)) == 1
}, "the bot never posted a reply")
// The reply is the model output, authored BY the bot, in the SAME conversation.
reply := botMessages(t, srv, org, ws, botUID)[0]
if reply["space"] != dmID || reply["attachedTo"] != dmID {
t.Fatalf("reply not in the DM conversation: %v", reply)
}
attrs, _ := reply["attributes"].(map[string]any)
_ = attrs
if got := str(reply["message"]); !strings.Contains(got, "I am enso") {
t.Fatalf("reply message = %q, want the model output", got)
}
// The runner was invoked on-behalf-of the human, with the agent id and the
// PLAIN-TEXT prompt (markup stripped).
fr.mu.Lock()
defer fr.mu.Unlock()
if !fr.called || fr.org != org || fr.userSub != human || fr.agent != bot.ID || fr.text != "hello agent" {
t.Fatalf("runner call wrong: called=%v org=%q sub=%q agent=%q text=%q", fr.called, fr.org, fr.userSub, fr.agent, fr.text)
}
}
func TestMaybeAgentReplyNeverAnswersItself(t *testing.T) {
const org, human = "acme", "33333333-3333-4333-8333-333333333333"
bot := Bot{ID: "agent_enso", Name: "enso", Active: true}
srv, _, ws := rosterServer(t, org, human, "Ada", []Bot{bot})
botUID := botUserID(bot.ID)
fr := &fakeRunner{out: "should not fire"}
srv.runAgent = fr.run
dmID := "dm-enso"
putDoc(t, srv, org, ws, map[string]any{
"_id": dmID, "_class": clDirectMessage, "space": dmID,
"members": []any{human, botUID},
})
// The message is authored BY THE BOT — the loop guard must suppress any reply.
selfMsg := chatCreateRaw(t, dmID, clDirectMessage, "hanzo:"+botUID, "<p>my own message</p>")
srv.maybeAgentReply(org, ws, []json.RawMessage{selfMsg})
// Give any (erroneous) goroutine a chance, then assert nothing happened.
time.Sleep(150 * time.Millisecond)
fr.mu.Lock()
called := fr.called
fr.mu.Unlock()
if called {
t.Fatal("runner fired for a bot-authored message (infinite-loop risk)")
}
if n := len(botMessages(t, srv, org, ws, botUID)); n != 0 {
t.Fatalf("bot posted %d messages for its own message, want 0", n)
}
}
func TestMaybeAgentReplyDisabledWhenNoRunner(t *testing.T) {
const org, human = "acme", "44444444-4444-4444-8444-444444444444"
bot := Bot{ID: "agent_enso", Name: "enso", Active: true}
srv, _, ws := rosterServer(t, org, human, "Ada", []Bot{bot})
botUID := botUserID(bot.ID)
srv.runAgent = nil // responder off
dmID := "dm-enso"
putDoc(t, srv, org, ws, map[string]any{
"_id": dmID, "_class": clDirectMessage, "space": dmID, "members": []any{human, botUID},
})
inbound := chatCreateRaw(t, dmID, clDirectMessage, "hanzo:"+human, "<p>hi</p>")
srv.maybeAgentReply(org, ws, []json.RawMessage{inbound}) // must be a no-op, no panic
time.Sleep(100 * time.Millisecond)
if n := len(botMessages(t, srv, org, ws, botUID)); n != 0 {
t.Fatalf("reply posted with no runner wired: %d", n)
}
}
// ── hardening / anti-storm regression ──────────────────────────────────────────
// countingRunner records how many times it was invoked (thread-safe) and returns a
// fixed outcome. It is the "outbound call" probe the boot-backlog test asserts on.
type countingRunner struct {
mu sync.Mutex
n int
out string
err error
gate chan struct{} // if non-nil, each call blocks on it (for concurrency tests)
}
func (c *countingRunner) run(_ context.Context, _, _, _, _ string) (string, error) {
c.mu.Lock()
c.n++
c.mu.Unlock()
if c.gate != nil {
<-c.gate
}
return c.out, c.err
}
func (c *countingRunner) calls() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.n
}
// TestNoReplyToBacklogAtBoot is the anti-storm invariant the writer post-mortem
// demands: a workspace with a BACKLOG of old messages, replayed through the
// responder right after boot, must produce ZERO outbound model calls. Only a
// genuinely fresh (post-boot) message is ever answered. This is what prevents "a
// replayed message backlog fans out into thousands of HTTP calls".
func TestNoReplyToBacklogAtBoot(t *testing.T) {
const org, human = "maxpower", "113d4dd4-2486-40de-be2b-88d6e3e0b718"
bot := Bot{ID: "agent_enso", Name: "enso", Active: true}
srv, _, ws := rosterServer(t, org, human, "Dave", []Bot{bot})
botUID := botUserID(bot.ID)
cr := &countingRunner{out: "hi"}
srv.runAgent = cr.run
srv.startedAt = time.Now().UnixMilli() // boot NOW
dmID := "dm-enso"
putDoc(t, srv, org, ws, map[string]any{
"_id": dmID, "_class": clDirectMessage, "space": dmID, "members": []any{human, botUID},
})
// A backlog of 500 messages, each created an hour before boot (a replay/backfill).
old := srv.startedAt - int64(60*60*1000)
backlog := make([]json.RawMessage, 0, 500)
for i := 0; i < 500; i++ {
backlog = append(backlog, chatCreateRawAt(t, dmID, clDirectMessage, "hanzo:"+human, "<p>old</p>", old))
}
srv.maybeAgentReply(org, ws, backlog)
time.Sleep(150 * time.Millisecond) // give any (erroneous) goroutine a chance
if n := cr.calls(); n != 0 {
t.Fatalf("backlog replay fired %d outbound model calls, want 0 (storm risk)", n)
}
// A single FRESH message IS answered — the filter is precise, not a blanket off.
fresh := chatCreateRawAt(t, dmID, clDirectMessage, "hanzo:"+human, "<p>hello now</p>", time.Now().UnixMilli())
srv.maybeAgentReply(org, ws, []json.RawMessage{fresh})
waitFor(t, 2*time.Second, func() bool { return cr.calls() == 1 }, "a fresh post was not answered")
}
// TestConcurrencyCapBounded proves the hard concurrency cap: with the semaphore set
// to 2, firing 8 messages to DISTINCT conversations must never run more than 2
// turns at once — the surplus is DROPPED, not queued (no unbounded goroutine/HTTP
// fan-out).
func TestConcurrencyCapBounded(t *testing.T) {
const org, human = "acme", "11111111-1111-4111-8111-111111111111"
bot := Bot{ID: "agent_enso", Name: "enso", Active: true}
srv, _, ws := rosterServer(t, org, human, "Ada", []Bot{bot})
botUID := botUserID(bot.ID)
gate := make(chan struct{})
cr := &countingRunner{out: "ok", gate: gate}
srv.runAgent = cr.run
srv.sem = make(chan struct{}, 2) // cap = 2
// 8 distinct DMs (distinct single-flight keys) all with the bot.
for i := 0; i < 8; i++ {
dm := "dm-" + string(rune('a'+i))
putDoc(t, srv, org, ws, map[string]any{
"_id": dm, "_class": clDirectMessage, "space": dm, "members": []any{human, botUID},
})
srv.maybeAgentReply(org, ws, []json.RawMessage{chatCreateRaw(t, dm, clDirectMessage, "hanzo:"+human, "<p>hi</p>")})
}
// Exactly cap(=2) turns acquire the semaphore and block on the gate; the other 6
// hit the default branch and drop. Wait for the 2 to be in-flight, then confirm
// no third starts.
waitFor(t, 2*time.Second, func() bool { return cr.calls() == 2 }, "cap turns never started")
time.Sleep(150 * time.Millisecond)
if n := cr.calls(); n != 2 {
t.Fatalf("in-flight turns = %d, want exactly 2 (cap); surplus must drop, not queue", n)
}
close(gate) // release the 2
}
// TestSingleFlightPerConversation proves at most one in-flight answer per
// (workspace, space, bot): a burst of 5 messages to the SAME DM collapses to ONE
// turn while it runs; the rest are dropped.
func TestSingleFlightPerConversation(t *testing.T) {
const org, human = "acme", "22222222-2222-4222-8222-222222222222"
bot := Bot{ID: "agent_enso", Name: "enso", Active: true}
srv, _, ws := rosterServer(t, org, human, "Ada", []Bot{bot})
botUID := botUserID(bot.ID)
gate := make(chan struct{})
cr := &countingRunner{out: "ok", gate: gate}
srv.runAgent = cr.run
dm := "dm-solo"
putDoc(t, srv, org, ws, map[string]any{
"_id": dm, "_class": clDirectMessage, "space": dm, "members": []any{human, botUID},
})
for i := 0; i < 5; i++ {
srv.maybeAgentReply(org, ws, []json.RawMessage{chatCreateRaw(t, dm, clDirectMessage, "hanzo:"+human, "<p>spam</p>")})
}
waitFor(t, 2*time.Second, func() bool { return cr.calls() == 1 }, "no turn started")
time.Sleep(150 * time.Millisecond)
if n := cr.calls(); n != 1 {
t.Fatalf("single-flight broke: %d concurrent turns for one conversation, want 1", n)
}
close(gate)
}
// TestCircuitBreakerBacksOff proves a persistently-failing agent is skipped after
// breakerThreshold failures — the backoff that turns a 403 storm into a quiet
// trickle. Driven synchronously (replyAsBot direct) for determinism.
func TestCircuitBreakerBacksOff(t *testing.T) {
const org, human = "acme", "33333333-3333-4333-8333-333333333333"
bot := Bot{ID: "agent_enso", Name: "enso", Active: true}
srv, _, ws := rosterServer(t, org, human, "Ada", []Bot{bot})
botUID := botUserID(bot.ID)
cr := &countingRunner{err: errForced}
srv.runAgent = cr.run
// Each call to a DISTINCT conversation (so single-flight never collapses them),
// same bot. After breakerThreshold failures the circuit opens and the runner is
// no longer called.
for i := 0; i < 10; i++ {
dm := "dm-" + string(rune('a'+i))
putDoc(t, srv, org, ws, map[string]any{
"_id": dm, "_class": clDirectMessage, "space": dm, "members": []any{human, botUID},
})
m := chatMsg{space: dm, attachedTo: dm, attachedToClass: clDirectMessage, collection: "messages",
authorUID: human, message: "<p>hi</p>", createdOn: time.Now().UnixMilli()}
srv.replyAsBot(org, ws, m, bot) // synchronous
}
if n := cr.calls(); n != breakerThreshold {
t.Fatalf("runner called %d times, want %d (circuit must open after threshold)", n, breakerThreshold)
}
if !srv.breakerOpen(bot.ID) {
t.Fatal("circuit did not open after repeated failures")
}
}
// ── test helpers ───────────────────────────────────────────────────────────────
const clChannel = "chunter:class:Channel"
var errForced = errForcedType("forced failure")
type errForcedType string
func (e errForcedType) Error() string { return string(e) }
func mustMarshal(t *testing.T, v any) json.RawMessage {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return b
}
func putDoc(t *testing.T, srv *transServer, org, ws string, doc map[string]any) {
t.Helper()
if err := srv.store.put(org, ws, doc); err != nil {
t.Fatalf("put doc: %v", err)
}
}
func chatCreateRaw(t *testing.T, space, spaceClass, author, message string) json.RawMessage {
t.Helper()
return chatCreateRawAt(t, space, spaceClass, author, message, time.Now().UnixMilli())
}
// chatCreateRawAt is chatCreateRaw with an explicit createdOn (unix millis) so a
// test can forge a backlog message that predates the server boot.
func chatCreateRawAt(t *testing.T, space, spaceClass, author, message string, createdOn int64) json.RawMessage {
t.Helper()
return mustMarshal(t, map[string]any{
"_class": clTxCreate, "objectId": newMsgID(), "objectClass": clChatMessage,
"objectSpace": space, "attachedTo": space, "attachedToClass": spaceClass,
"collection": "messages", "createdBy": author, "modifiedBy": author,
"createdOn": createdOn, "modifiedOn": createdOn,
"attributes": map[string]any{"message": message},
})
}
// botMessages returns every ChatMessage doc authored by the bot (social id).
func botMessages(t *testing.T, srv *transServer, org, ws, botUID string) []map[string]any {
t.Helper()
docs, err := srv.store.byClasses(org, ws, []string{clChatMessage})
if err != nil {
t.Fatalf("byClasses: %v", err)
}
var out []map[string]any
for _, d := range docs {
if str(d["createdBy"]) == "hanzo:"+botUID {
out = append(out, d)
}
}
return out
}
func waitFor(t *testing.T, d time.Duration, cond func() bool, msg string) {
t.Helper()
deadline := time.Now().Add(d)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal(msg)
}
+44 -6
View File
@@ -5,7 +5,9 @@ import (
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
@@ -84,12 +86,27 @@ func Mount(app *zip.App, deps cloud.Deps) error {
}
trans := &transServer{
store: newStore(filepath.Join(root, "workspaces")),
hier: buildHierarchy(modelJSON),
hub: newHub(),
secret: cfg.serverSecret,
accounts: accounts,
bots: agentsBotLister, // the ONE in-process seam to the agents registry
store: newStore(filepath.Join(root, "workspaces")),
hier: buildHierarchy(modelJSON),
hub: newHub(),
secret: cfg.serverSecret,
accounts: accounts,
bots: agentsBotLister, // the ONE in-process seam to the agents registry
log: log,
startedAt: time.Now().UnixMilli(), // freshness floor: messages older than boot are never answered
}
// Chunter agent responder: OFF by default (one-way safe default). Only when
// TEAM_AGENTS_ENABLED=1 do we wire the LLM seam + the concurrency cap, so an
// un-configured OR misconfigured binary is provably inert — nil runAgent makes
// maybeAgentReply return at the top and NO outbound model call can ever fire.
// (This is the containment the writer-crash post-mortem demands: a new binary
// must be safe by default and only answer when an operator opts in.)
if os.Getenv("TEAM_AGENTS_ENABLED") == "1" {
trans.runAgent = agentReplyRunner
trans.sem = make(chan struct{}, teamAgentsMaxConcurrency())
log.Info("team: Chunter agent responder ENABLED", "maxConcurrency", cap(trans.sem))
} else {
log.Info("team: Chunter agent responder OFF (set TEAM_AGENTS_ENABLED=1 to enable)")
}
// Publish the singleton so the in-process projection path (Apply / ingest) and
// the /v1/team/bots/sync handler can write into the per-workspace store.
@@ -170,6 +187,27 @@ func loadConfig(deps cloud.Deps) config {
}
}
// teamAgentsMaxConcurrency resolves the responder's global in-flight turn cap from
// TEAM_AGENTS_MAX_CONCURRENCY (default defaultMaxConcurrent). It is clamped to
// [1,64] so a typo can never uncap the fan-out (0/negative → default) or make it
// absurd. The cap is the hard ceiling on concurrent outbound model calls the
// responder can have in flight process-wide.
func teamAgentsMaxConcurrency() int {
n := defaultMaxConcurrent
if v := os.Getenv("TEAM_AGENTS_MAX_CONCURRENCY"); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
n = parsed
}
}
if n < 1 {
n = 1
}
if n > 64 {
n = 64
}
return n
}
// env returns the value of key, or fallback when unset. The ONE env helper for the
// package (used by the docs store).
func env(key, fallback string) string {
+15
View File
@@ -17,6 +17,8 @@ import (
"strings"
"sync"
luxlog "github.com/luxfi/log"
"github.com/hanzoai/cloud/clients/team/token"
"github.com/zap-proto/zip"
"github.com/zap-proto/zip/wsx"
@@ -56,6 +58,15 @@ type transServer struct {
secret string
accounts *accountStore // human members (this deployment's workspaces)
bots BotLister // bot members (the org's in-process agents)
runAgent AgentRunner // the Chunter responder's LLM seam (agents.RunOnBehalf); nil = responder OFF
log luxlog.Logger // best-effort responder logging; nil-safe (tests leave it unset)
// Chunter responder bounds (chat.go). ALL zero-value-safe so a bare
// transServer literal (tests, the sync path) is inert-but-correct.
startedAt int64 // process boot (unix millis); messages older than this are backfill → never answered (0 = no filter, tests)
sem chan struct{} // hard concurrency cap on in-flight agent turns (nil = uncapped, tests)
inflight sync.Map // single-flight: (workspace|space|bot) currently answering → drop duplicates
breaker sync.Map // per-agent circuit breaker: agentID → *agentBreaker (backoff on repeated failure)
}
// live is the process-singleton transactor server, published in Mount so the
@@ -269,6 +280,10 @@ func (s *session) tx(id int64, params []json.RawMessage) []byte {
res, applied := s.applyTx(params[0])
if len(applied) > 0 {
s.server.hub.broadcast(s.workspace, applied)
// Fire agent replies for any bot-addressed Chunter message. Async + guarded
// inside; only the client WS write path reaches here (the roster/sync path
// calls applyTx directly), so a projection can never trigger a reply.
s.server.maybeAgentReply(s.org, s.workspace, applied)
}
return s.result(id, res)
}
+21 -11
View File
@@ -175,21 +175,31 @@ func workerUnits(org string) []fleetUnit {
workers := byoWorkers(org)
out := make([]fleetUnit, 0, len(workers))
for _, w := range workers {
u := fleetUnit{
Source: samples.SourceBYO, Unit: w.ID, Kind: samples.KindWorker,
Label: w.Hostname, Host: w.Hostname, Status: w.Status,
}
if w.Os != "" || len(w.GPUs) > 0 {
u.Spec = &fleetSpec{OS: w.Os, GPUs: len(w.GPUs)}
if len(w.GPUs) > 0 {
u.Spec.GPUModel = w.GPUs[0].Name
}
}
out = append(out, u)
out = append(out, byoUnit(w))
}
return out
}
// byoUnit projects one dialed-in BYO worker onto the board, carrying the host's full
// static spec — OS, CPU arch, logical cores, total RAM and the GPU summary — in the
// SAME fleetSpec a code-linked run-target reports (agentUnits). This is what surfaces
// a gpu-connect node's real arch (amd64/arm64) + memory on GET /v1/fleet, not just
// its GPU. A field the worker did not report stays zero (omitempty), never invented.
func byoUnit(w byoWorker) fleetUnit {
u := fleetUnit{
Source: samples.SourceBYO, Unit: w.ID, Kind: samples.KindWorker,
Label: w.Hostname, Host: w.Hostname, Status: w.Status,
}
sp := fleetSpec{OS: w.Os, Arch: w.Arch, CPUs: w.CPUs, Memory: w.Memory, GPUs: len(w.GPUs)}
if len(w.GPUs) > 0 {
sp.GPUModel = w.GPUs[0].Name
}
if sp != (fleetSpec{}) {
u.Spec = &sp
}
return u
}
// clusterUnits folds in the org's attached BYO clusters. A cluster's accelerators
// are counted, not modelled — the registry reports vendor totals across nodes, and
// the board reports exactly that rather than inventing per-card detail.
+13
View File
@@ -65,6 +65,13 @@ type byoWorker struct {
LastHeartbeat string `json:"lastHeartbeat,omitempty"`
FirstSeen string `json:"firstSeen,omitempty"`
Os string `json:"os,omitempty"`
// Arch/CPUs/Memory are the connecting host's static CPU spec, mirrored from the
// registration: Arch is runtime.GOARCH (amd64 | arm64), Memory is total RAM in
// BYTES — the same fields a code-linked run-target carries, so the /v1/fleet
// board renders a gpu-connect node's arch + cores + RAM like any other unit.
Arch string `json:"arch,omitempty"`
CPUs int `json:"cpus,omitempty"`
Memory int64 `json:"memory,omitempty"`
Version string `json:"version,omitempty"`
JobQueue string `json:"jobQueue,omitempty"`
// Capabilities the worker advertises ("studio.render", "engine.serve"); Engine
@@ -78,6 +85,9 @@ type byoWorker struct {
type fleetRegistration struct {
Hostname string `json:"hostname"`
Os string `json:"os"`
Arch string `json:"arch,omitempty"`
CPUs int `json:"cpus,omitempty"`
Memory int64 `json:"memory,omitempty"`
Version string `json:"version"`
JobQueue string `json:"jobQueue"`
GPUs []byoGPU `json:"gpus"`
@@ -121,6 +131,9 @@ func byoWorkers(org string) []byoWorker {
LastHeartbeat: a.LastHeartbeatTime,
FirstSeen: a.StartTime,
Os: reg.Os,
Arch: reg.Arch,
CPUs: reg.CPUs,
Memory: reg.Memory,
Version: reg.Version,
JobQueue: reg.JobQueue,
Capabilities: reg.Capabilities,
+81
View File
@@ -0,0 +1,81 @@
package visor
// fleet_spec_test.go — a gpu-connect node's CPU arch + core count + total RAM must
// survive the CLI→server decode (fleetRegistration) and land on the /v1/fleet board
// (byoUnit → fleetSpec), the SAME fields a code-linked run-target carries. This is
// what makes evo-2 (x86_64 / Strix Halo) and spark (aarch64 / GB10) show real arch +
// 128 GB on the world Fleet panel, not just their GPU. Arch is the fleet's `uname -m`
// convention — the SAME string these machines report as code-linked run-targets.
import (
"encoding/json"
"testing"
"github.com/hanzoai/cloud/clients/samples"
)
// cliHostSpecJSON is exactly what `hanzo gpu connect` writes as the fleet presence
// activity's Input for spark (GB10, aarch64, 128 GiB).
const cliHostSpecJSON = `{
"hostname": "spark",
"os": "linux",
"arch": "aarch64",
"cpus": 20,
"memory": 137438953472,
"version": "1.50.0",
"jobQueue": "gpu-jobs",
"gpus": [{"name": "NVIDIA GB10", "memoryTotal": "122880 MiB"}]
}`
func TestFleetRegistrationDecodesHostSpec(t *testing.T) {
var reg fleetRegistration
if err := json.Unmarshal([]byte(cliHostSpecJSON), &reg); err != nil {
t.Fatalf("decode CLI registration Input: %v", err)
}
if reg.Arch != "aarch64" || reg.CPUs != 20 || reg.Memory != 137438953472 {
t.Fatalf("host spec dropped on decode: arch=%q cpus=%d memory=%d", reg.Arch, reg.CPUs, reg.Memory)
}
}
func TestByoUnitCarriesHostSpec(t *testing.T) {
var reg fleetRegistration
if err := json.Unmarshal([]byte(cliHostSpecJSON), &reg); err != nil {
t.Fatalf("decode: %v", err)
}
// Mirror the mapping byoWorkers performs, then project onto the board.
w := byoWorker{
ID: "spark", Hostname: reg.Hostname, Provider: "byo", Status: "online",
Os: reg.Os, Arch: reg.Arch, CPUs: reg.CPUs, Memory: reg.Memory, GPUs: reg.GPUs,
}
u := byoUnit(w)
if u.Spec == nil {
t.Fatal("byoUnit dropped the spec — arch/memory would be MISSING on the board")
}
if u.Spec.Arch != "aarch64" {
t.Fatalf("Arch = %q, want aarch64 (the panel renders it as ARM64)", u.Spec.Arch)
}
if u.Spec.CPUs != 20 {
t.Fatalf("CPUs = %d, want 20", u.Spec.CPUs)
}
if u.Spec.Memory != 137438953472 {
t.Fatalf("Memory = %d, want 137438953472 (128 GiB)", u.Spec.Memory)
}
if u.Spec.GPUs != 1 || u.Spec.GPUModel != "NVIDIA GB10" {
t.Fatalf("GPU summary regressed: %+v", u.Spec)
}
if u.Source != samples.SourceBYO || u.Kind != samples.KindWorker {
t.Fatalf("identity/tag regressed: source=%s kind=%s", u.Source, u.Kind)
}
}
// A CPU-only worker that reported no arch/memory (an older CLI) still renders — the
// spec is simply omitted, never fabricated.
func TestByoUnitOmitsUnknownSpec(t *testing.T) {
u := byoUnit(byoWorker{ID: "old", Hostname: "old", Provider: "byo", Status: "online"})
if u.Spec != nil {
t.Fatalf("an all-zero spec must be omitted, not invented: %+v", u.Spec)
}
if u.Unit != "old" || u.Source != samples.SourceBYO {
t.Fatalf("identity regressed: %+v", u)
}
}
+19
View File
@@ -0,0 +1,19 @@
package main
import (
"fmt"
"os"
"github.com/hanzoai/cloud/apps"
)
// Standalone entry for the channels app — generated by cmd/gen-app-cmds (the
// go:generate directive in apps/apps.go). do not hand-edit; the app is the one
// edit in apps.Wire(), this binary is regenerated. The same app also mounts into
// the unified cloud binary via apps.Wire().
func main() {
if err := apps.ServeSingle("channels"); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+72
View File
@@ -0,0 +1,72 @@
package main
import (
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
)
// jwtWithOwner builds an UNSIGNED-looking JWT (header.payload.sig) whose payload
// carries the owner + isAdmin claims. The tool only DECODES these locally (the
// server verifies the signature), so a stub signature is fine for the unit test.
func jwtWithOwner(owner string, isAdmin bool) string {
hdr := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`))
payload, _ := json.Marshal(map[string]any{"owner": owner, "isAdmin": isAdmin})
body := base64.RawURLEncoding.EncodeToString(payload)
return hdr + "." + body + ".sig"
}
func TestDecodeJWTOwner(t *testing.T) {
owner, isAdmin, err := decodeJWTOwner(jwtWithOwner("hanzo", false))
if err != nil || owner != "hanzo" || isAdmin {
t.Fatalf("decodeJWTOwner = %q,%v,%v want hanzo,false,nil", owner, isAdmin, err)
}
if _, _, err := decodeJWTOwner("not-a-jwt"); err == nil {
t.Fatal("decodeJWTOwner(non-jwt) should error so callers skip the local assertion")
}
}
// loginDoer answers /v1/kms/auth/login with a JWT minted for a fixed owner.
type loginDoer struct {
owner string
isAdmin bool
}
func (d loginDoer) Do(r *http.Request) (*http.Response, error) {
body := `{"accessToken":"` + jwtWithOwner(d.owner, d.isAdmin) + `","expiresIn":3600,"tokenType":"Bearer"}`
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: http.Header{}}, nil
}
// fixedCred returns a dummy credential for any target (the login doer ignores it).
func fixedCred(t Target) (credRef, error) {
return credRef{ns: "hanzo", name: "cred", clientID: "cid", secret: "sec"}, nil
}
func TestTokenFunc_OwnerMustMatchOrg(t *testing.T) {
// Credential mints owner=hanzo. A hanzo target passes; an acme target is refused
// (misscoped credential — LOW-1 defense in depth, independent of the server guard).
c := newKMSClient("http://cloud", loginDoer{owner: "hanzo"})
tf := newTokenFunc(c, fixedCred, "dst")
if _, err := tf(context.Background(), Target{Org: "hanzo"}); err != nil {
t.Fatalf("hanzo target with hanzo-owner token: unexpected err %v", err)
}
_, err := tf(context.Background(), Target{Org: "acme"})
if err == nil || !strings.Contains(err.Error(), "owner") {
t.Fatalf("acme target with hanzo-owner token: err=%v, want an owner-mismatch refusal", err)
}
}
func TestTokenFunc_AdminTokenRefused(t *testing.T) {
// An admin-owner token must never be used for a fleet (tenant) target.
c := newKMSClient("http://cloud", loginDoer{owner: "admin", isAdmin: true})
tf := newTokenFunc(c, fixedCred, "dst")
_, err := tf(context.Background(), Target{Org: "admin"})
if err == nil || !strings.Contains(err.Error(), "ADMIN") {
t.Fatalf("admin token: err=%v, want an admin refusal (fleet identity must be org-bound)", err)
}
}
+94 -22
View File
@@ -55,42 +55,114 @@ func loadCRsFromKubectl() ([]cr, error) {
return all, nil
}
// newK8sTokenFunc returns a tokenFunc that, for a target, reads its CR's
// credentialsRef Secret and brokers an owner-scoped bearer at src. Tokens are
// cached per credential (namespace/name) so a credential logs in once.
func newK8sTokenFunc(src *kmsClient) tokenFunc {
// credRef is the credential a token func resolves for a target on one face.
type credRef struct {
ns, name string
clientID, secret string
}
// credResolver maps a target to the credential that authenticates it on one face.
// crCredResolver reads the CR's credentialsRef (the app-name identity the standalone
// accepts); machineAudResolver reads the per-org <org>-platform-kms identity cloud
// accepts dynamically and admin-denied (no static widening).
type credResolver func(t Target) (credRef, error)
// crCredResolver reads a target's CR credentialsRef Secret — the existing app-name
// machine identity the STANDALONE accepts (aud ∈ KMS_EXPECTED_AUDIENCE).
func crCredResolver(t Target) (credRef, error) {
if t.CredName == "" {
return credRef{}, fmt.Errorf("CR %s/%s has no credentialsRef — cannot authenticate", t.CRNamespace, t.CRName)
}
cid, sec, err := readCredential(t.CredNS, t.CredName)
if err != nil {
return credRef{}, err
}
return credRef{ns: t.CredNS, name: t.CredName, clientID: cid, secret: sec}, nil
}
// machineAudResolver reads the per-org <org>-platform-kms credential (Secret
// name = "<org>"+suffix in ns) — the dedicated KMS-sync identity CLOUD accepts
// dynamically via kmsMachineAudience (admin-denied, scoped to /v1/kms org==owner).
// A missing Secret fails loud: provisioning it is a gated cutover prerequisite.
func machineAudResolver(ns, suffix string) credResolver {
return func(t Target) (credRef, error) {
name := t.Org + suffix
cid, sec, err := readCredential(ns, name)
if err != nil {
return credRef{}, fmt.Errorf("per-org KMS-sync credential %s/%s not provisioned (mint the <org>-platform-kms IAM app + Secret first): %w", ns, name, err)
}
return credRef{ns: ns, name: name, clientID: cid, secret: sec}, nil
}
}
// newTokenFunc brokers an owner-scoped bearer at `client` for each target's
// credential, caches per credential, and (LOW-1, defense-in-depth) decodes the
// minted token's owner claim and asserts it EQUALS the target's org before the
// token is handed to any read/write — so a misscoped credential fails the target
// rather than acting on the wrong org. An admin-owner token is refused for a
// tenant target (the fleet identities must be org-bound, never platform admin).
func newTokenFunc(client *kmsClient, resolve credResolver, face string) tokenFunc {
var (
mu sync.Mutex
cache = map[string]string{}
)
return func(ctx context.Context, t Target) (string, error) {
if t.CredName == "" {
return "", fmt.Errorf("CR %s/%s has no credentialsRef — cannot authenticate", t.CRNamespace, t.CRName)
}
key := t.CredNS + "/" + t.CredName
mu.Lock()
tok, ok := cache[key]
mu.Unlock()
if ok {
return tok, nil
}
clientID, clientSecret, err := readCredential(t.CredNS, t.CredName)
ref, err := resolve(t)
if err != nil {
return "", err
}
tok, err = src.login(ctx, clientID, clientSecret)
// Wipe the secret material from our copy immediately after the login POST.
wipeString(&clientSecret)
if err != nil {
return "", fmt.Errorf("broker token for %s: %w", key, err)
}
key := ref.ns + "/" + ref.name
mu.Lock()
cache[key] = tok
tok, ok := cache[key]
mu.Unlock()
if !ok {
tok, err = client.login(ctx, ref.clientID, ref.secret)
wipeString(&ref.secret) // wipe the secret material right after the login POST
if err != nil {
return "", fmt.Errorf("broker %s token for %s: %w", face, key, err)
}
mu.Lock()
cache[key] = tok
mu.Unlock()
}
// LOW-1: assert token owner == target org (fail-closed on mismatch/admin).
owner, isAdmin, derr := decodeJWTOwner(tok)
if derr == nil {
if isAdmin {
return "", fmt.Errorf("%s credential %s mints an ADMIN token — the fleet KMS identity must be org-bound, not platform admin", face, key)
}
if owner != t.Org {
return "", fmt.Errorf("%s credential %s token owner %q != target org %q (misscoped credential)", face, key, owner, t.Org)
}
}
return tok, nil
}
}
// decodeJWTOwner reads the `owner` + `isAdmin` claims from a JWT WITHOUT verifying
// the signature — the SERVER validates the signature; this is a local sanity gate
// so the tool never uses a token whose owner disagrees with the target org. A
// non-JWT token (e.g. an opaque test stub) returns an error, which the caller
// treats as "skip the local assertion" (the server still enforces owner==:org).
func decodeJWTOwner(token string) (owner string, isAdmin bool, err error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return "", false, fmt.Errorf("not a JWT")
}
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return "", false, fmt.Errorf("jwt payload: %w", err)
}
var claims struct {
Owner string `json:"owner"`
IsAdmin bool `json:"isAdmin"`
}
if err := json.Unmarshal(raw, &claims); err != nil {
return "", false, fmt.Errorf("jwt claims: %w", err)
}
return claims.Owner, claims.IsAdmin, nil
}
// readCredential extracts (clientId, clientSecret) from a credentialsRef Secret.
// clientId is an OAuth client identifier (not sensitive); clientSecret is used only
// for the login POST and is never logged.
+61 -25
View File
@@ -63,10 +63,13 @@ func (r *resealReport) add(res resealResult) {
type tokenFunc func(ctx context.Context, t Target) (string, error)
// reseal migrates every explicit target and resolves every folder target, reading
// from src and writing (sealing) into dst. It never stops on a single target's
// failure — every result is recorded so the report is complete and the run is
// re-runnable. plan=true performs NO network I/O (just enumerates the work).
func reseal(ctx context.Context, inv Inventory, src, dst *kmsClient, tokenFor tokenFunc, plan bool) *resealReport {
// from src (with the src-face token) and writing+sealing into dst (with the
// dst-face token). The two faces carry DIFFERENT identities: src uses the CR's
// existing app-name credential (which the standalone accepts); dst uses the per-org
// <org>-platform-kms credential (which cloud accepts dynamically, admin-denied, with
// NO static audience widening). It never stops on a single target's failure — every
// result is recorded so the run is re-runnable. plan=true performs NO network I/O.
func reseal(ctx context.Context, inv Inventory, src, dst *kmsClient, srcAuth, dstAuth tokenFunc, plan bool) *resealReport {
rep := &resealReport{}
// Explicit targets.
@@ -75,7 +78,7 @@ func reseal(ctx context.Context, inv Inventory, src, dst *kmsClient, tokenFor to
rep.add(resealResult{Coord: t.Coord(), Outcome: outPlanned})
continue
}
rep.add(resealOne(ctx, t, src, dst, tokenFor))
rep.add(resealOne(ctx, t, src, dst, srcAuth, dstAuth))
}
// Folder targets: LIST the source folder to discover keys, then migrate each.
@@ -84,18 +87,25 @@ func reseal(ctx context.Context, inv Inventory, src, dst *kmsClient, tokenFor to
rep.add(resealResult{Coord: f.Coord(), Outcome: outPlanned, Detail: "folder (keys resolved at run)"})
continue
}
tok, err := tokenFor(ctx, f)
srcTok, err := srcAuth(ctx, f)
if err != nil {
rep.add(resealResult{Coord: f.Coord(), Outcome: outFailed, Detail: "auth: " + err.Error()})
rep.add(resealResult{Coord: f.Coord(), Outcome: outFailed, Detail: "src auth: " + err.Error()})
continue
}
keys, err := src.listFolder(ctx, tok, f.Org, f.Path, f.Env)
dstTok, err := dstAuth(ctx, f)
if err != nil {
rep.add(resealResult{Coord: f.Coord(), Outcome: outFailed, Detail: "dst auth: " + err.Error()})
continue
}
keys, err := src.listFolder(ctx, srcTok, f.Org, f.Path, f.Env)
if err != nil {
rep.add(resealResult{Coord: f.Coord(), Outcome: outFailed, Detail: "list folder: " + err.Error()})
continue
}
if len(keys) == 0 {
rep.add(resealResult{Coord: f.Coord(), Outcome: outAbsent, Detail: "folder empty at source"})
// Empty folder-sync: a crown-jewel path with no keys would produce an empty
// managed Secret and wedge its consumer. Flag it, never silently skip.
rep.add(resealResult{Coord: f.Coord(), Outcome: outAbsent, Detail: "folder EMPTY at source (seeding wedge risk — verify before cutover)"})
continue
}
sort.Strings(keys)
@@ -103,25 +113,29 @@ func reseal(ctx context.Context, inv Inventory, src, dst *kmsClient, tokenFor to
t := f
t.Folder = false
t.Key = k
rep.add(resealOneWithToken(ctx, t, src, dst, tok))
rep.add(resealOneWithTokens(ctx, t, src, dst, srcTok, dstTok))
}
}
return rep
}
// resealOne authenticates for the target then migrates it.
func resealOne(ctx context.Context, t Target, src, dst *kmsClient, tokenFor tokenFunc) resealResult {
tok, err := tokenFor(ctx, t)
// resealOne authenticates for the target on both faces then migrates it.
func resealOne(ctx context.Context, t Target, src, dst *kmsClient, srcAuth, dstAuth tokenFunc) resealResult {
srcTok, err := srcAuth(ctx, t)
if err != nil {
return resealResult{Coord: t.Coord(), Outcome: outFailed, Detail: "auth: " + err.Error()}
return resealResult{Coord: t.Coord(), Outcome: outFailed, Detail: "src auth: " + err.Error()}
}
return resealOneWithToken(ctx, t, src, dst, tok)
dstTok, err := dstAuth(ctx, t)
if err != nil {
return resealResult{Coord: t.Coord(), Outcome: outFailed, Detail: "dst auth: " + err.Error()}
}
return resealOneWithTokens(ctx, t, src, dst, srcTok, dstTok)
}
// resealOneWithToken performs the GET(src)→POST(dst) for one target with an already
// resolved token. The plaintext is wiped after the write.
func resealOneWithToken(ctx context.Context, t Target, src, dst *kmsClient, tok string) resealResult {
val, err := src.getSecret(ctx, tok, t.Org, t.Path, t.Env, t.Key)
// resealOneWithTokens performs GET(src, srcTok) → POST(dst, dstTok) for one target.
// The plaintext is wiped after the write.
func resealOneWithTokens(ctx context.Context, t Target, src, dst *kmsClient, srcTok, dstTok string) resealResult {
val, err := src.getSecret(ctx, srcTok, t.Org, t.Path, t.Env, t.Key)
if err == errSecretNotFound {
return resealResult{Coord: t.Coord(), Outcome: outAbsent, Detail: "not found at source"}
}
@@ -129,14 +143,16 @@ func resealOneWithToken(ctx context.Context, t Target, src, dst *kmsClient, tok
return resealResult{Coord: t.Coord(), Outcome: outFailed, Detail: "read: " + err.Error()}
}
defer wipe(val)
if err := dst.putSecret(ctx, tok, t.Org, t.Path, t.Env, t.Key, val); err != nil {
if err := dst.putSecret(ctx, dstTok, t.Org, t.Path, t.Env, t.Key, val); err != nil {
return resealResult{Coord: t.Coord(), Outcome: outFailed, Detail: "write: " + err.Error()}
}
return resealResult{Coord: t.Coord(), Outcome: outMigrated}
}
// wipe zeroes a plaintext buffer once it is no longer needed (defense in depth;
// the value already never leaves memory).
// wipe zeroes the plaintext buffer read from the source once the write is done.
// Best-effort defense in depth: the value already never leaves memory, and
// putSecret copies it into a JSON body (string(value)) whose backing bytes Go does
// not let us wipe — so this zeroes the READ buffer, not every transient copy.
func wipe(b []byte) {
for i := range b {
b[i] = 0
@@ -152,6 +168,8 @@ func runReseal(args []string) error {
srcURL := fs.String("src", "", "standalone KMS base URL (read source), e.g. http://kms.hanzo.svc")
cloudURL := fs.String("cloud", "", "cloud embedded KMS base URL (seal destination), e.g. http://cloud.hanzo.svc")
onlyHost := fs.String("only-host", "", "migrate only CRs whose hostAPI matches this (excludes e.g. the devnet KMS)")
dstCredNS := fs.String("dst-cred-namespace", "hanzo", "namespace holding the per-org <org>-platform-kms credential Secrets")
dstCredSuffix := fs.String("dst-cred-suffix", "-platform-kms-creds", "Secret name suffix for the per-org cloud (dst) credential: <org>+suffix")
plan := fs.Bool("plan", false, "enumerate the work without any network I/O or writes")
if err := fs.Parse(args); err != nil {
return err
@@ -165,21 +183,39 @@ func runReseal(args []string) error {
if err != nil {
return err
}
inv := filterHost(BuildInventory(crs), *onlyHost)
full := BuildInventory(crs)
inv := filterHost(full, *onlyHost)
ctx := context.Background()
src := newKMSClient(*srcURL, nil)
dst := newKMSClient(*cloudURL, nil)
tokenFor := newK8sTokenFunc(src) // logs in at the source with each CR's credential
// src (standalone read): the CR's existing app-name credential.
// dst (cloud write): the per-org <org>-platform-kms credential cloud accepts
// dynamically (admin-denied, no static widening) — provisioning it is gated.
srcAuth := newTokenFunc(src, crCredResolver, "src")
dstAuth := newTokenFunc(dst, machineAudResolver(*dstCredNS, *dstCredSuffix), "dst")
rep := reseal(ctx, inv, src, dst, tokenFor, *plan)
rep := reseal(ctx, inv, src, dst, srcAuth, dstAuth, *plan)
printResealReport(inv, rep, *plan)
printHostScope("RESEAL", full, inv, *onlyHost, rep.Migrated)
if rep.Failed > 0 {
return fmt.Errorf("%d target(s) failed — see report; re-run is safe (idempotent)", rep.Failed)
}
return nil
}
// printHostScope (MED-2) prints the host-scoped work vs the UNFILTERED inventory
// total, so the operator can assert Σ(per-host done) == the full inventory before
// cutover — a single host-filter typo can silently omit a CR otherwise.
func printHostScope(op string, full, scoped Inventory, onlyHost string, done int) {
scope := "ALL hosts"
if strings.TrimSpace(onlyHost) != "" {
scope = onlyHost
}
fmt.Printf("%s host-scope [%s]: %d done of %d explicit + %d folder in scope; UNFILTERED inventory total = %d explicit + %d folders across %d hosts. Assert Σ(per-host)==unfiltered before cutover.\n",
op, scope, done, len(scoped.Targets), len(scoped.Folders), len(full.Targets), len(full.Folders), len(full.Hosts))
}
// filterHost drops targets/folders whose CR hostAPI does not match onlyHost (when
// set), so the main-standalone cutover never touches the separate devnet KMS.
func filterHost(inv Inventory, onlyHost string) Inventory {
+6 -6
View File
@@ -188,13 +188,13 @@ func TestReseal_RoundTripRealSeal(t *testing.T) {
{Org: "hanzo", Path: "datastore", Env: "prod", Key: "DATASTORE_PASSWORD"},
}}
rep := reseal(context.Background(), inv, src, dst, injectToken, false)
rep := reseal(context.Background(), inv, src, dst, injectToken, injectToken, false)
if rep.Migrated != 3 || rep.Failed != 0 {
t.Fatalf("reseal: migrated=%d failed=%d, want 3/0: %+v", rep.Migrated, rep.Failed, rep.Results)
}
// VERIFY: every target byte-identical on cloud (real open) vs source.
vrep := verify(context.Background(), inv, src, dst, injectToken)
vrep := verify(context.Background(), inv, src, dst, injectToken, injectToken)
if vrep.Match != 3 || vrep.Mismatch != 0 || vrep.AbsentD != 0 {
t.Fatalf("verify: match=%d mismatch=%d absent-dst=%d, want 3/0/0: %+v", vrep.Match, vrep.Mismatch, vrep.AbsentD, vrep.Results)
}
@@ -220,7 +220,7 @@ func TestReseal_WrongOrgTokenRefusedByCloud(t *testing.T) {
// A token scoped to the WRONG org: the fake source refuses the read (403), so the
// migration fails closed — the value is never even read, let alone written cross-org.
badToken := func(_ context.Context, _ Target) (string, error) { return "org:evil", nil }
rep := reseal(context.Background(), inv, src, dst, badToken, false)
rep := reseal(context.Background(), inv, src, dst, badToken, badToken, false)
if rep.Failed != 1 || rep.Migrated != 0 {
t.Fatalf("wrong-org reseal: migrated=%d failed=%d, want 0/1", rep.Migrated, rep.Failed)
}
@@ -237,7 +237,7 @@ func TestReseal_FolderSyncResolvedViaList(t *testing.T) {
fs.seed("hanzo", "commerce", "prod", "STRIPE_KEY", "stripe-2")
inv := Inventory{Folders: []Target{{Org: "hanzo", Path: "commerce", Env: "prod", Folder: true}}}
rep := reseal(context.Background(), inv, src, dst, injectToken, false)
rep := reseal(context.Background(), inv, src, dst, injectToken, injectToken, false)
if rep.Migrated != 2 || rep.Failed != 0 {
t.Fatalf("folder reseal: migrated=%d failed=%d, want 2/0: %+v", rep.Migrated, rep.Failed, rep.Results)
}
@@ -246,7 +246,7 @@ func TestReseal_FolderSyncResolvedViaList(t *testing.T) {
{Org: "hanzo", Path: "commerce", Env: "prod", Key: "HUSD_TREASURY_KEY"},
{Org: "hanzo", Path: "commerce", Env: "prod", Key: "STRIPE_KEY"},
}}
if v := verify(context.Background(), explicit, src, dst, injectToken); v.Match != 2 {
if v := verify(context.Background(), explicit, src, dst, injectToken, injectToken); v.Match != 2 {
t.Fatalf("folder verify match=%d, want 2", v.Match)
}
}
@@ -256,7 +256,7 @@ func TestReseal_PlanDoesNoNetwork(t *testing.T) {
src := newKMSClient("http://kms.hanzo.svc", panicDoer{})
dst := newKMSClient("http://cloud.hanzo.svc", panicDoer{})
inv := Inventory{Targets: []Target{{Org: "hanzo", Path: "p", Env: "prod", Key: "K"}}, Folders: []Target{{Org: "hanzo", Path: "f", Env: "prod", Folder: true}}}
rep := reseal(context.Background(), inv, src, dst, injectToken, true)
rep := reseal(context.Background(), inv, src, dst, injectToken, injectToken, true)
if rep.Planned != 2 || rep.Migrated != 0 || rep.Failed != 0 {
t.Fatalf("plan: planned=%d migrated=%d failed=%d, want 2/0/0", rep.Planned, rep.Migrated, rep.Failed)
}
+49 -23
View File
@@ -25,6 +25,7 @@ const (
vMismatch verifyOutcome = "MISMATCH"
vAbsentSrc verifyOutcome = "absent-src"
vAbsentDst verifyOutcome = "ABSENT-DST"
vUnseeded verifyOutcome = "UNSEEDED-FOLDER"
vError verifyOutcome = "error"
)
@@ -40,6 +41,7 @@ type verifyReport struct {
Mismatch int
AbsentS int
AbsentD int
Unseeded int
Errors int
Iso []isoResult
}
@@ -55,6 +57,8 @@ func (r *verifyReport) add(res verifyResult) {
r.AbsentS++
case vAbsentDst:
r.AbsentD++
case vUnseeded:
r.Unseeded++
case vError:
r.Errors++
}
@@ -65,46 +69,63 @@ func hashHex(b []byte) string {
return hex.EncodeToString(sum[:])
}
// verify compares src vs dst for every target (and folder-resolved key). GREEN =
// every present source record matches on cloud with zero MISMATCH / ABSENT-DST.
func verify(ctx context.Context, inv Inventory, src, dst *kmsClient, tokenFor tokenFunc) *verifyReport {
// verify compares src (src-face token) vs dst (dst-face token) for every target and
// folder-resolved key. GREEN = every present source record matches on cloud with
// zero MISMATCH / ABSENT-DST / UNSEEDED-FOLDER / error.
func verify(ctx context.Context, inv Inventory, src, dst *kmsClient, srcAuth, dstAuth tokenFunc) *verifyReport {
rep := &verifyReport{}
for _, t := range inv.Targets {
rep.add(verifyOne(ctx, t, src, dst, tokenFor))
rep.add(verifyOne(ctx, t, src, dst, srcAuth, dstAuth))
}
for _, f := range inv.Folders {
tok, err := tokenFor(ctx, f)
srcTok, err := srcAuth(ctx, f)
if err != nil {
rep.add(verifyResult{Coord: f.Coord(), Outcome: vError, Detail: "auth: " + err.Error()})
rep.add(verifyResult{Coord: f.Coord(), Outcome: vError, Detail: "src auth: " + err.Error()})
continue
}
keys, err := src.listFolder(ctx, tok, f.Org, f.Path, f.Env)
dstTok, err := dstAuth(ctx, f)
if err != nil {
rep.add(verifyResult{Coord: f.Coord(), Outcome: vError, Detail: "dst auth: " + err.Error()})
continue
}
keys, err := src.listFolder(ctx, srcTok, f.Org, f.Path, f.Env)
if err != nil {
rep.add(verifyResult{Coord: f.Coord(), Outcome: vError, Detail: "list folder: " + err.Error()})
continue
}
// MED-1: an empty folder-sync is NON-green at the gate. reseal flags it as
// absent; verify (the designated GREEN gate) must also count it as a failure,
// or an UNSEEDED crown-jewel folder (e.g. billing-kms-sync) passes silently.
if len(keys) == 0 {
rep.add(verifyResult{Coord: f.Coord(), Outcome: vUnseeded, Detail: "folder EMPTY at source — nothing to verify (seeding wedge risk)"})
continue
}
sort.Strings(keys)
for _, k := range keys {
t := f
t.Folder = false
t.Key = k
rep.add(verifyOneWithToken(ctx, t, src, dst, tok))
rep.add(verifyOneWithTokens(ctx, t, src, dst, srcTok, dstTok))
}
}
return rep
}
func verifyOne(ctx context.Context, t Target, src, dst *kmsClient, tokenFor tokenFunc) verifyResult {
tok, err := tokenFor(ctx, t)
func verifyOne(ctx context.Context, t Target, src, dst *kmsClient, srcAuth, dstAuth tokenFunc) verifyResult {
srcTok, err := srcAuth(ctx, t)
if err != nil {
return verifyResult{Coord: t.Coord(), Outcome: vError, Detail: "auth: " + err.Error()}
return verifyResult{Coord: t.Coord(), Outcome: vError, Detail: "src auth: " + err.Error()}
}
return verifyOneWithToken(ctx, t, src, dst, tok)
dstTok, err := dstAuth(ctx, t)
if err != nil {
return verifyResult{Coord: t.Coord(), Outcome: vError, Detail: "dst auth: " + err.Error()}
}
return verifyOneWithTokens(ctx, t, src, dst, srcTok, dstTok)
}
// verifyOneWithToken reads both faces and compares digests. Buffers are wiped.
func verifyOneWithToken(ctx context.Context, t Target, src, dst *kmsClient, tok string) verifyResult {
sv, serr := src.getSecret(ctx, tok, t.Org, t.Path, t.Env, t.Key)
// verifyOneWithTokens reads src (srcTok) + dst (dstTok) and compares digests.
func verifyOneWithTokens(ctx context.Context, t Target, src, dst *kmsClient, srcTok, dstTok string) verifyResult {
sv, serr := src.getSecret(ctx, srcTok, t.Org, t.Path, t.Env, t.Key)
if serr == errSecretNotFound {
return verifyResult{Coord: t.Coord(), Outcome: vAbsentSrc, Detail: "not at source"}
}
@@ -112,7 +133,7 @@ func verifyOneWithToken(ctx context.Context, t Target, src, dst *kmsClient, tok
return verifyResult{Coord: t.Coord(), Outcome: vError, Detail: "read src: " + serr.Error()}
}
defer wipe(sv)
dv, derr := dst.getSecret(ctx, tok, t.Org, t.Path, t.Env, t.Key)
dv, derr := dst.getSecret(ctx, dstTok, t.Org, t.Path, t.Env, t.Key)
if derr == errSecretNotFound {
return verifyResult{Coord: t.Coord(), Outcome: vAbsentDst, Detail: "not migrated to cloud"}
}
@@ -166,6 +187,8 @@ func runVerify(args []string) error {
srcURL := fs.String("src", "", "standalone KMS base URL")
cloudURL := fs.String("cloud", "", "cloud embedded KMS base URL")
onlyHost := fs.String("only-host", "", "verify only CRs whose hostAPI matches this")
dstCredNS := fs.String("dst-cred-namespace", "hanzo", "namespace holding the per-org <org>-platform-kms credential Secrets")
dstCredSuffix := fs.String("dst-cred-suffix", "-platform-kms-creds", "Secret name suffix for the per-org cloud (dst) credential: <org>+suffix")
if err := fs.Parse(args); err != nil {
return err
}
@@ -176,24 +199,27 @@ func runVerify(args []string) error {
if err != nil {
return err
}
inv := filterHost(BuildInventory(crs), *onlyHost)
full := BuildInventory(crs)
inv := filterHost(full, *onlyHost)
ctx := context.Background()
src := newKMSClient(*srcURL, nil)
dst := newKMSClient(*cloudURL, nil)
tokenFor := newK8sTokenFunc(src)
srcAuth := newTokenFunc(src, crCredResolver, "src")
dstAuth := newTokenFunc(dst, machineAudResolver(*dstCredNS, *dstCredSuffix), "dst")
rep := verify(ctx, inv, src, dst, tokenFor)
rep := verify(ctx, inv, src, dst, srcAuth, dstAuth)
printVerifyReport(rep)
if rep.Mismatch > 0 || rep.AbsentD > 0 || rep.Errors > 0 {
return fmt.Errorf("verification RED: mismatch=%d absent-dst=%d errors=%d", rep.Mismatch, rep.AbsentD, rep.Errors)
printHostScope("VERIFY", full, inv, *onlyHost, rep.Match)
if rep.Mismatch > 0 || rep.AbsentD > 0 || rep.Unseeded > 0 || rep.Errors > 0 {
return fmt.Errorf("verification RED: mismatch=%d absent-dst=%d unseeded-folder=%d errors=%d", rep.Mismatch, rep.AbsentD, rep.Unseeded, rep.Errors)
}
return nil
}
func printVerifyReport(rep *verifyReport) {
fmt.Printf("VERIFY: match=%d MISMATCH=%d absent-src=%d ABSENT-DST=%d errors=%d\n",
rep.Match, rep.Mismatch, rep.AbsentS, rep.AbsentD, rep.Errors)
fmt.Printf("VERIFY: match=%d MISMATCH=%d absent-src=%d ABSENT-DST=%d UNSEEDED-FOLDER=%d errors=%d\n",
rep.Match, rep.Mismatch, rep.AbsentS, rep.AbsentD, rep.Unseeded, rep.Errors)
for _, r := range rep.Results {
if r.Outcome == vMatch || r.Outcome == vAbsentSrc {
continue // src-absent is not a regression (already broken at source)
+20 -3
View File
@@ -14,12 +14,12 @@ func TestVerify_MismatchDetected(t *testing.T) {
// Migrate value A, then the source diverges to value B → verify must catch it.
fs.seed("hanzo", "p", "prod", "K", "value-A")
inv := Inventory{Targets: []Target{{Org: "hanzo", Path: "p", Env: "prod", Key: "K"}}}
if r := reseal(context.Background(), inv, src, dst, injectToken, false); r.Migrated != 1 {
if r := reseal(context.Background(), inv, src, dst, injectToken, injectToken, false); r.Migrated != 1 {
t.Fatalf("seed migrate failed: %+v", r.Results)
}
fs.seed("hanzo", "p", "prod", "K", "value-B-DIFFERENT")
vrep := verify(context.Background(), inv, src, dst, injectToken)
vrep := verify(context.Background(), inv, src, dst, injectToken, injectToken)
if vrep.Mismatch != 1 {
t.Fatalf("verify mismatch=%d, want 1 (src diverged from cloud): %+v", vrep.Mismatch, vrep.Results)
}
@@ -40,12 +40,29 @@ func TestVerify_AbsentOnCloud(t *testing.T) {
dst := newKMSClient("http://cloud.hanzo.svc", cloudDoer{app})
inv := Inventory{Targets: []Target{{Org: "hanzo", Path: "p", Env: "prod", Key: "NOT_MIGRATED"}}}
vrep := verify(context.Background(), inv, src, dst, injectToken)
vrep := verify(context.Background(), inv, src, dst, injectToken, injectToken)
if vrep.AbsentD != 1 {
t.Fatalf("verify absent-dst=%d, want 1 (never migrated): %+v", vrep.AbsentD, vrep.Results)
}
}
func TestVerify_EmptyFolderIsNonGreen(t *testing.T) {
app, _, _ := newCloudApp(t)
src := newKMSClient("http://kms.hanzo.svc", newFakeStandalone()) // seed NOTHING
dst := newKMSClient("http://cloud.hanzo.svc", cloudDoer{app})
// A folder-sync CR whose source path holds no keys (billing-kms-sync shape).
inv := Inventory{Folders: []Target{{Org: "hanzo", Path: "billing-secrets", Env: "prod", Folder: true}}}
vrep := verify(context.Background(), inv, src, dst, injectToken, injectToken)
// MED-1: the GREEN gate must count an empty folder as NON-green (not silently pass).
if vrep.Unseeded != 1 {
t.Fatalf("empty folder: Unseeded=%d, want 1 (must be non-green): %+v", vrep.Unseeded, vrep.Results)
}
if vrep.Match != 0 {
t.Fatalf("empty folder produced %d matches, want 0", vrep.Match)
}
}
func TestVerify_OrgIsolationMatrixOnCloud(t *testing.T) {
app, _, _ := newCloudApp(t)
dst := newKMSClient("http://cloud.hanzo.svc", cloudDoer{app})

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