Compare commits
132
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46c0d0e11f | ||
|
|
86b0b79ecc | ||
|
|
12d0081e3b | ||
|
|
1256cda741 | ||
|
|
cf5d807cf0 | ||
|
|
3c4e05632c | ||
|
|
93e23b541d | ||
|
|
1d8de1dda7 | ||
|
|
56dd542879 | ||
|
|
78a8aa3310 | ||
|
|
e06eeb18da | ||
|
|
7f2deef78c | ||
|
|
cf24f0cbfc | ||
|
|
df250b65db | ||
|
|
8bcfb8eff3 | ||
|
|
7f3c381570 | ||
|
|
78a9c1f02e | ||
|
|
abcbbb155b | ||
|
|
48d3b35e67 | ||
|
|
3fef8bccce | ||
|
|
30a566846b | ||
|
|
ff5cefcddd | ||
|
|
aafa89064d | ||
|
|
893e7d7759 | ||
|
|
b6f15cd27c | ||
|
|
31e87b7f1e | ||
|
|
28c4f699d0 | ||
|
|
3649758225 | ||
|
|
e06813a5ed | ||
|
|
f09d98a1a0 | ||
|
|
707b6332ad | ||
|
|
524ffb7cdb | ||
|
|
4bd4feabe4 | ||
|
|
b7b61933bf | ||
|
|
a952bed0b7 | ||
|
|
2f3535d5be | ||
|
|
807bad3f4c | ||
|
|
fc3de9ede2 | ||
|
|
3a417701c8 | ||
|
|
3a464b01c4 | ||
|
|
c90196fa14 | ||
|
|
4264ddeb19 | ||
|
|
69cf880b2f | ||
|
|
1d309c9474 | ||
|
|
db2e9284be | ||
|
|
0a9c727609 | ||
|
|
a63189c83a | ||
|
|
f3843d34b5 | ||
|
|
807cf30294 | ||
|
|
e930c39fd6 | ||
|
|
2c1dd75101 | ||
|
|
a9e6e028b4 | ||
|
|
399b893b09 | ||
|
|
c59dcb2a85 | ||
|
|
4055226f6c | ||
|
|
8408c141eb | ||
|
|
605f565138 | ||
|
|
4a2e098705 | ||
|
|
4500f609ed | ||
|
|
d6f98175df | ||
|
|
5527827b46 | ||
|
|
96232be94e | ||
|
|
096de3d8b1 | ||
|
|
0d8c0df95f | ||
|
|
3b09f22fb1 | ||
|
|
be2243afc8 | ||
|
|
eff0ca6b79 | ||
|
|
17924d81b2 | ||
|
|
55f168324d | ||
|
|
d0f2a64ca9 | ||
|
|
6bdb64d13a | ||
|
|
8f398960bd | ||
|
|
b5292c5639 | ||
|
|
134ff852ef | ||
|
|
fbe07f3e7c | ||
|
|
222f91b898 | ||
|
|
f5a7ccdfc7 | ||
|
|
fdb7a31dd0 | ||
|
|
256b848ece | ||
|
|
643926bc76 | ||
|
|
5019d7a384 | ||
|
|
39b6b12823 | ||
|
|
efbdab87f6 | ||
|
|
ebf96d9851 | ||
|
|
78a8197e49 | ||
|
|
83519a957e | ||
|
|
7b19236966 | ||
|
|
2e3dc7d818 | ||
|
|
906f89a2b6 | ||
|
|
84cea2889a | ||
|
|
fa6cc69a65 | ||
|
|
0063793f12 | ||
|
|
198a025cf9 | ||
|
|
7b204ee0de | ||
|
|
81515ef108 | ||
|
|
320af40b0b | ||
|
|
277ea80a4f | ||
|
|
cd42e5f902 | ||
|
|
c0672a1fa6 | ||
|
|
675d17f29c | ||
|
|
46f42f43e6 | ||
|
|
214b5d2925 | ||
|
|
42f2ed8f52 | ||
|
|
70f8d29447 | ||
|
|
01378dea23 | ||
|
|
694bc4f716 | ||
|
|
184945862f | ||
|
|
b3e058490c | ||
|
|
7c301185ee | ||
|
|
40ca519c51 | ||
|
|
0f86fd4a5b | ||
|
|
5ac8e7a1a5 | ||
|
|
6625b5d41b | ||
|
|
3baee40745 | ||
|
|
2cb2e8e286 | ||
|
|
6dc2b3a355 | ||
|
|
6134408bad | ||
|
|
36fc7c3b74 | ||
|
|
180fd74369 | ||
|
|
78e0d35199 | ||
|
|
e80a6fc641 | ||
|
|
27a627bf35 | ||
|
|
4cdd2f2558 | ||
|
|
4fe61262a9 | ||
|
|
7136fff969 | ||
|
|
62068b2d1e | ||
|
|
cfce8b10d0 | ||
|
|
a4f68617a0 | ||
|
|
e3e87f8b69 | ||
|
|
0df009b329 | ||
|
|
02fab4bc40 | ||
|
|
4aff04accd |
@@ -34,4 +34,5 @@ Thumbs.db
|
||||
.shots/
|
||||
|
||||
.claude/
|
||||
.worktrees/
|
||||
native/flags/target
|
||||
|
||||
@@ -44,6 +44,48 @@ Port roadmap (P1-P15) lives in HIP-0129; do not restate it here. Every claim
|
||||
carries its tier: Shipped (on main, named package/route), In flight (named
|
||||
pre-main branch), Planned (backlog id or named reservation).
|
||||
|
||||
## Build & module graph — standalone module, NOT a go.work member
|
||||
|
||||
`cloud` is a self-contained deploy unit: its own `go.mod`, `Dockerfile`, binary.
|
||||
It is intentionally NOT listed in the parent `~/work/hanzo/go.work` workspace —
|
||||
that workspace deliberately excludes the heavy modules, and merging cloud's
|
||||
k8s/otel dependency tree with `o11y`'s reintroduces `koanf`/`ugorji`
|
||||
monolith-vs-split import ambiguities (the parent workspace is itself red on the
|
||||
koanf split; that is not cloud's bug to fix).
|
||||
|
||||
The catch: `go` auto-discovers that parent `go.work` whenever you run a bare
|
||||
`go build ./...` / `go test ./...` from inside this tree, which puts the build in
|
||||
workspace mode and SILENTLY DROPS cloud's own `go.mod` directives. Those
|
||||
directives are load-bearing and each fixes exactly one graph hazard:
|
||||
|
||||
- `replace github.com/vulcand/oxy/v2 => github.com/traefik/oxy/v2 <pseudo>` — the
|
||||
bare require is a placeholder (`v2.0.0-00010101000000-000000000000`); without
|
||||
the replace it resolves to an invalid version.
|
||||
- `exclude github.com/ugorji/go <old-monolith>` — drops the pre-split monolith so
|
||||
`github.com/ugorji/go/codec` (pulled by gin) is unambiguous.
|
||||
- the `k8s.io/*` staging replace block pins every staging module to the `v0.35.3`
|
||||
line. `k8s.io/kubernetes` is a GRAPH-ONLY transitive require of
|
||||
`hanzoai/deploy/gitops-engine` (clients/deploy uses its `pkg/utils/kube`); NO
|
||||
cloud package imports `k8s.io/kubernetes`, so its staging tree never compiles —
|
||||
do not "drop k8s.io/kubernetes", the pins keep the graph consistent and it is
|
||||
never built. koanf resolves to the split modules; the `koanf v1.5.0` monolith
|
||||
require is a harmless graph leaf, never imported.
|
||||
|
||||
So: build cloud in module mode, never workspace mode. `make build`/`test`/`vet`/
|
||||
`tidy` force `GOWORK=off` (matches CI and the Dockerfile, which check out cloud
|
||||
alone with no parent go.work). For a bare `go` command from this tree, prefix
|
||||
`GOWORK=off`. `GOWORK=off go build ./...` and `go vet ./...` are green; `go mod
|
||||
tidy` is stable. Do NOT commit a `go.work` here — it would flip the Dockerfile
|
||||
(`COPY go.mod go.sum` → `go mod download` → `COPY . .`) into workspace mode after
|
||||
its `-mod=readonly` download step.
|
||||
|
||||
Test modes: `make test` is pure-Go (`CGO_ENABLED=0`). Encrypted-at-rest OrgDB
|
||||
tests (`cek`, `CLOUD_KMS_MASTER_KEY_REF` set) REQUIRE `CGO_ENABLED=1` +
|
||||
libsqlcipher (`cek/cek.go` refuses to encrypt in pure-Go); those run only in the
|
||||
Dockerfile's dedicated `-tags libsqlite3` CGO stage, and fail under `make test`
|
||||
by design (clients/git, kms, flags, x402, cmd/kmsreseal, finance). Bundle-embed
|
||||
tests (clients/tasks/ui) need `make deploy-ui` first (real bundle is gitignored).
|
||||
|
||||
## Framework doctrine
|
||||
|
||||
One way to do everything. Composable, orthogonal, DRY. A new subsystem is a
|
||||
|
||||
@@ -4,6 +4,18 @@
|
||||
GO ?= go
|
||||
BIN ?= cloud
|
||||
PKG ?= ./cmd/cloud
|
||||
|
||||
# cloud is a STANDALONE Go module — a self-contained deploy unit (its own go.mod,
|
||||
# Dockerfile, binary). It is intentionally NOT a member of the parent
|
||||
# ~/work/hanzo/go.work workspace (that workspace deliberately excludes the heavy
|
||||
# modules; adding cloud would merge its k8s/otel graph with o11y's and reintroduce
|
||||
# koanf/ugorji import ambiguities). But `go` auto-discovers that parent go.work
|
||||
# whenever a dev builds from inside this tree, which shadows cloud's own
|
||||
# replace/exclude directives (oxy pin, ugorji monolith exclude, k8s staging pins)
|
||||
# and breaks `go build ./...`. Force module mode so make targets build EXACTLY
|
||||
# what CI/Docker build (fresh checkout, no parent go.work). Overridable via
|
||||
# `make GOWORK=... <target>` for the rare cross-module case.
|
||||
export GOWORK := off
|
||||
DOCKER_IMAGE ?= ghcr.io/hanzoai/cloud
|
||||
DOCKER_TAG ?= dev
|
||||
LDFLAGS ?= -s -w
|
||||
|
||||
+29
-27
@@ -34,7 +34,6 @@ package apps
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
@@ -66,6 +65,7 @@ import (
|
||||
"github.com/hanzoai/cloud/clients/bots"
|
||||
"github.com/hanzoai/cloud/clients/captable"
|
||||
"github.com/hanzoai/cloud/clients/catalogsync"
|
||||
"github.com/hanzoai/cloud/clients/channels"
|
||||
"github.com/hanzoai/cloud/clients/cloudflare"
|
||||
"github.com/hanzoai/cloud/clients/code"
|
||||
"github.com/hanzoai/cloud/clients/company"
|
||||
@@ -75,6 +75,7 @@ import (
|
||||
"github.com/hanzoai/cloud/clients/deploy"
|
||||
"github.com/hanzoai/cloud/clients/dns"
|
||||
"github.com/hanzoai/cloud/clients/do"
|
||||
"github.com/hanzoai/cloud/clients/domain"
|
||||
"github.com/hanzoai/cloud/clients/entitlements"
|
||||
"github.com/hanzoai/cloud/clients/eval"
|
||||
"github.com/hanzoai/cloud/clients/exec"
|
||||
@@ -86,12 +87,12 @@ import (
|
||||
"github.com/hanzoai/cloud/clients/graph"
|
||||
"github.com/hanzoai/cloud/clients/guide"
|
||||
"github.com/hanzoai/cloud/clients/iam"
|
||||
"github.com/hanzoai/cloud/clients/iam2"
|
||||
"github.com/hanzoai/cloud/clients/ingress"
|
||||
"github.com/hanzoai/cloud/clients/integrations"
|
||||
"github.com/hanzoai/cloud/clients/kafka"
|
||||
"github.com/hanzoai/cloud/clients/kms"
|
||||
"github.com/hanzoai/cloud/clients/knowledge"
|
||||
"github.com/hanzoai/cloud/clients/leaderboard"
|
||||
"github.com/hanzoai/cloud/clients/link"
|
||||
"github.com/hanzoai/cloud/clients/marketing"
|
||||
"github.com/hanzoai/cloud/clients/marketplace"
|
||||
@@ -109,6 +110,7 @@ import (
|
||||
"github.com/hanzoai/cloud/clients/provisioning"
|
||||
"github.com/hanzoai/cloud/clients/pubsub"
|
||||
"github.com/hanzoai/cloud/clients/referrals"
|
||||
"github.com/hanzoai/cloud/clients/rollingcap"
|
||||
"github.com/hanzoai/cloud/clients/runtime"
|
||||
"github.com/hanzoai/cloud/clients/sbom"
|
||||
"github.com/hanzoai/cloud/clients/security"
|
||||
@@ -124,6 +126,7 @@ import (
|
||||
"github.com/hanzoai/cloud/clients/tracker"
|
||||
"github.com/hanzoai/cloud/clients/treasury"
|
||||
"github.com/hanzoai/cloud/clients/usage"
|
||||
"github.com/hanzoai/cloud/clients/validators"
|
||||
"github.com/hanzoai/cloud/clients/visor"
|
||||
"github.com/hanzoai/cloud/clients/wallets"
|
||||
"github.com/hanzoai/cloud/clients/websearch"
|
||||
@@ -164,22 +167,6 @@ func init() {
|
||||
})
|
||||
}
|
||||
|
||||
// identitySpec selects the ONE identity backend that owns /v1/iam/* (+ /login/oauth/*)
|
||||
// for this boot. CLOUD_IAM_IMPL=iam2 picks the clean-room iam2 (zip+orm, beego-free);
|
||||
// anything else — including unset, the production default — keeps the legacy beego
|
||||
// Casdoor embed, byte-for-byte today's behavior. The two impls register the SAME
|
||||
// absolute prefixes and therefore cannot co-mount, so selection (this func) stays
|
||||
// separate from activation (cfg.Enabled): exactly one spec occupies the identity slot
|
||||
// in Wire, preserving mount order either way. os.Getenv (not the unexported
|
||||
// cloud.getenv, which is unreachable from package apps) is the read — CLOUD_IAM_IMPL is
|
||||
// the deliberate, off-by-default opt-in that keeps iam2 inert until a canary flips it.
|
||||
func identitySpec() cloud.MountSpec {
|
||||
if os.Getenv("CLOUD_IAM_IMPL") == "iam2" {
|
||||
return cloud.MountSpec{Name: "iam2", Mount: iam2.Mount}
|
||||
}
|
||||
return cloud.MountSpec{Name: "iam", Mount: iam.Mount}
|
||||
}
|
||||
|
||||
// Wire returns every linked subsystem as a cloud.MountSpec, in mount order. The
|
||||
// slice position IS the order: cloud.MountAll iterates it as-given, registering each
|
||||
// subsystem's teardown as a zip shutdown hook so teardown runs in reverse (LIFO).
|
||||
@@ -211,15 +198,12 @@ func Wire() []cloud.MountSpec {
|
||||
// /v1/commerce/topup/wallet). MUST mount before the IAM /v1/iam/* wildcard (50) so
|
||||
// they win Fiber's first-match scan (framework-guaranteed since zip v1.3.0).
|
||||
{Name: "account", Mount: account.MountAccount},
|
||||
// Embedded IAM identity plane (/v1/iam/*, /.well-known/*, /login/oauth/*, /_/iam/*,
|
||||
// /cas/*, /scim/*) — the identity authority, mounts before its dependents. STAGED:
|
||||
// the operator adds "iam" to --enable only after IAM config + the fold are verified.
|
||||
// Which IMPLEMENTATION owns these prefixes is selected by CLOUD_IAM_IMPL
|
||||
// (identitySpec): the clean-room iam2 (zip+orm, beego-free) when =="iam2", else the
|
||||
// legacy beego Casdoor embed — the default (unset = today's behavior, byte-for-byte).
|
||||
// Both register the SAME absolute paths and cannot co-mount, so this is an either/or
|
||||
// switch at this ONE slot, never a shadow prefix.
|
||||
identitySpec(),
|
||||
// Embedded IAM identity plane (/v1/iam/*, /login/oauth/*) — the identity authority,
|
||||
// mounts before its dependents. The ONE implementation: the clean-room iam-v2
|
||||
// (zip-native + hanzoai/orm, beego-free); the retired Casdoor iam-v1 embed is GONE.
|
||||
// STAGED: the operator adds "iam" to --enable only after IAM config + the fold are
|
||||
// verified (login/authorize/token/jwks + the operator SSO chain).
|
||||
{Name: "iam", Mount: iam.Mount},
|
||||
// Embedded Base app engine + viral waitlist (/v1/waitlist/*). STAGED behind
|
||||
// CLOUD_BASE_EMBED. OwnsHealth: native /v1/base/health.
|
||||
{Name: "base", Mount: base.Mount, Shutdown: base.Shutdown, OwnsHealth: true},
|
||||
@@ -248,6 +232,11 @@ func Wire() []cloud.MountSpec {
|
||||
// Provisioning control plane: /v1/sql,/v1/vector,/v1/datastore,/v1/kv,/v1/search,/v1/s3,/v1/docdb.
|
||||
{Name: "provisioning", Mount: provisioning.Mount},
|
||||
{Name: "billing", Mount: billing.Mount},
|
||||
// Rolling AI-spend cap: installs the ai gate's per-tier trailing-window cap
|
||||
// reader (registers no routes; its admin-editable knobs are platform switches
|
||||
// surfaced in the /v1/admin/flags cockpit). After commerce/plan so the tier +
|
||||
// finance globals it composes are wired.
|
||||
{Name: "rollingcap", Mount: rollingcap.Mount},
|
||||
// CATCH-ALL /v1/billing/* + /v1/commerce/* data bridges — AFTER clients/billing
|
||||
// (121) + the commerce embed (100). Same clients/account package as "account" (48).
|
||||
{Name: "account-bridge", Mount: account.MountBridge},
|
||||
@@ -257,6 +246,8 @@ func Wire() []cloud.MountSpec {
|
||||
// The /v1/dns forward head: relays the console DNS dashboard to the DNS
|
||||
// control plane under the caller's own validated bearer (clients/dns).
|
||||
{Name: "dns", Mount: dns.Mount},
|
||||
// The registrar: search/price/register domains (name.com) per org.
|
||||
{Name: "domain", Mount: domain.Mount},
|
||||
{Name: "prompts", Mount: prompts.Mount},
|
||||
{Name: "agents", Mount: agents.Mount, Shutdown: agents.Shutdown},
|
||||
// The unified AI login manager registry (/v1/links). Mounts AFTER agents so
|
||||
@@ -291,6 +282,11 @@ func Wire() []cloud.MountSpec {
|
||||
{Name: "catalogsync", Mount: catalogsync.Mount, Shutdown: catalogsync.Shutdown},
|
||||
{Name: "ml", Mount: ml.Mount, OwnsHealth: true},
|
||||
{Name: "usage", Mount: usage.Mount},
|
||||
// Gamified usage analytics: /v1/usage/leaderboard + /v1/usage/activity + the
|
||||
// per-day contribution graph, over the datastore rollup (#43). Co-owns /v1/usage/*
|
||||
// with usage (a distinct concern — who leads + your activity graph) at its own
|
||||
// exact paths; owns the opt-in SQLite store. Before the ai /v1/* catch-all.
|
||||
{Name: "leaderboard", Mount: leaderboard.Mount, Shutdown: leaderboard.Shutdown},
|
||||
{Name: "crm", Mount: crm.Mount},
|
||||
// Native /v1/marketing/* — the in-process fold of github.com/hanzoai/marketing
|
||||
// (per-org campaign store on Base/SQLite), twin of crm. Owns a DB handle, so
|
||||
@@ -300,6 +296,11 @@ func Wire() []cloud.MountSpec {
|
||||
// twin of crm/marketing. Owns a DB handle, so its Shutdown closes it cleanly
|
||||
// on SIGTERM (ctxShutdown adapts func() error).
|
||||
{Name: "ads", Mount: ads.Mount, Shutdown: ctxShutdown(ads.Shutdown)},
|
||||
// GDA/SDM validator onboarding /v1/validators/* — wallet-sig + ETH-mainnet
|
||||
// GenesisNFT ownerOf → seal luxd staking identity into KMS → write a NEW-node
|
||||
// LuxNetwork CR (node.lux.cloud, never the live luxd) → enqueue an owner-gated
|
||||
// registration (never auto-submitted to any P-Chain). Owns a DB handle.
|
||||
{Name: "validators", Mount: validators.Mount, Shutdown: ctxShutdown(validators.Shutdown)},
|
||||
// Native /v1/social/* — the in-process fold of the live social stack
|
||||
// (github.com/hanzoai/social: social-backend/frontend/orchestrator, a Postiz-style
|
||||
// scheduler), a per-org accounts+posts store on Base/SQLite, twin of crm. Owns a DB
|
||||
@@ -330,6 +331,7 @@ func Wire() []cloud.MountSpec {
|
||||
{Name: "team", Mount: team.Mount, Shutdown: ctxShutdown(team.Shutdown)},
|
||||
{Name: "settings", Mount: settings.Mount, Shutdown: settings.Shutdown},
|
||||
{Name: "notify", Mount: notify.Mount, OwnsHealth: true},
|
||||
{Name: "channels", Mount: channels.Mount, Shutdown: channels.Shutdown},
|
||||
{Name: "gateway", Mount: gateway.Mount},
|
||||
{Name: "entitlements", Mount: entitlements.Mount, Shutdown: entitlements.Shutdown},
|
||||
{Name: "exec", Mount: exec.Mount},
|
||||
|
||||
@@ -24,9 +24,11 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
accountclient "github.com/hanzoai/cloud/clients/account"
|
||||
"github.com/hanzoai/cloud/clients/commerceclient"
|
||||
"github.com/hanzoai/cloud/clients/commerceinproc"
|
||||
financeclient "github.com/hanzoai/cloud/clients/finance"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
"github.com/hanzoai/commerce"
|
||||
commercebilling "github.com/hanzoai/commerce/api/billing"
|
||||
commercestore "github.com/hanzoai/commerce/api/store"
|
||||
@@ -160,6 +162,121 @@ func mountCommerce(app *zip.App, deps cloud.Deps) error {
|
||||
commercebilling.RunAutoRechargeAllOrgs,
|
||||
)
|
||||
|
||||
// GET /v1/billing/plans — the public tier catalog the console renders. commerce's
|
||||
// legacy api.Route() billing bundle (ListPlans, invoices, subscriptions, …) is NOT
|
||||
// registered by the co-resident embed: setupRoutes wires only /v1/commerce/*, so
|
||||
// /v1/billing/plans has NO handler in this binary. The account bridge's
|
||||
// /v1/billing/* wildcard (order 122) then forwards the read BACK to commerce at
|
||||
// COMMERCE_URL — which defaults to the public api.hanzo.ai edge — re-entering the
|
||||
// same bridge in an unbounded self-dispatch loop that surfaces as a 502
|
||||
// ("commerce unreachable: Get https://api.hanzo.ai/v1/billing/plans"). Registering
|
||||
// the static ListPlans handler HERE (order 100, ahead of the bridge) shadows that
|
||||
// wildcard and serves plans in-process — the same co-resident move billing.go makes
|
||||
// for usage/balance. RequestContext supplies the namespaced context promo.Active reads.
|
||||
app.Get("/v1/billing/plans", commercemid.RequestContext(), commercebilling.ListPlans)
|
||||
|
||||
// The rest of the console's billing READS, served co-resident for the SAME reason
|
||||
// plans is: commerce's api.Route() billing bundle is never compiled here, so without
|
||||
// these registrations every one of them falls through to the account bridge's
|
||||
// /v1/billing/* wildcard, which forwards to COMMERCE_URL — the public api.hanzo.ai
|
||||
// edge, which is THIS binary — and self-dispatches into a 502 loop. In prod there is
|
||||
// no separate commerce backend to point COMMERCE_URL at (the in-cluster `commerce`
|
||||
// Service selects the cloud pods), so co-residence is the only way to break the loop.
|
||||
//
|
||||
// Chain: RequestContext (gated context) → IAMTokenRequired (resolves the org from the
|
||||
// gateway-validated X-Org-Id into Locals("organization"), the namespace commerce's
|
||||
// GetOrganization reads) → PinBillingSubject (pins the caller's OWN billing subject
|
||||
// into the query — the SAME isolation the bridge applies, so a read can never widen
|
||||
// past the caller; fail-closed for an unvalidated caller) → the commerce handler. The
|
||||
// specific paths shadow the bridge wildcard (order 100 < 122). payment-config is
|
||||
// org-scoped, not subject-scoped, but PinBillingSubject is still the auth gate that
|
||||
// keeps an unvalidated caller from reaching GetOrganization; its pinned (unused)
|
||||
// subject params are ignored by that handler.
|
||||
billingRead := []struct {
|
||||
path string
|
||||
h zip.Handler
|
||||
}{
|
||||
{"/v1/billing/invoices", commercebilling.ListInvoices},
|
||||
{"/v1/billing/invoices/:id/pdf", commercebilling.DownloadInvoicePDF},
|
||||
{"/v1/billing/subscriptions", commercebilling.ListBillingSubscriptions},
|
||||
{"/v1/billing/spend-alerts", commercebilling.ListSpendAlerts},
|
||||
{"/v1/billing/payouts", commercebilling.ListPayouts},
|
||||
{"/v1/billing/payment-config", commercebilling.GetPaymentConfig},
|
||||
}
|
||||
for _, r := range billingRead {
|
||||
app.Get(r.path,
|
||||
commercemid.RequestContext(),
|
||||
iammiddleware.IAMTokenRequired(),
|
||||
accountclient.PinBillingSubject(),
|
||||
r.h,
|
||||
)
|
||||
}
|
||||
|
||||
// GET /v1/billing/spend-alerts/authorize — the per-request per-scope spend-CAP
|
||||
// VERDICT the request-edge metering gate consumes (clients/metering scopeAuthorize,
|
||||
// pathLimitsAuthorize). It is a SERVICE-token S2S read (COMMERCE_SERVICE_TOKEN +
|
||||
// X-Org-Id), NOT a browser/IAM read — so it needs its OWN registration, distinct from
|
||||
// the console billingRead block above: without a co-resident handler this authorize
|
||||
// fell through to the account bridge's /v1/billing/* wildcard (order 122), which — being
|
||||
// service-token-forwardable (billing.go billingForwardable) — re-forwarded it to
|
||||
// COMMERCE_URL (the public api.hanzo.ai edge = THIS binary) over commerceinproc's
|
||||
// self-routing transport, re-entering the same wildcard until the depth-8 guard refused
|
||||
// → 502 → the gate fails OPEN (the cap is a policy overlay, so no traffic was blocked,
|
||||
// but ~135 502s/30m spammed the money path and each burned 8 full-app dispatches). The
|
||||
// plain GET /v1/billing/spend-alerts (registered above) already broke this loop for the
|
||||
// CRUD read; this closes the /authorize sibling the metering gate hits on every call.
|
||||
//
|
||||
// Chain mirrors commerce's OWN gate on this route (api/billing/handlers.go: the `billing`
|
||||
// group's userRequired = TokenRequired) plus the RequestContext the standalone supplies
|
||||
// globally — NOT the IAM/PinBillingSubject console chain: a raw service token is not an
|
||||
// IAM JWT, so IAMTokenRequired would leave GetOrganization unset and AuthorizeSpendCap
|
||||
// would 500. TokenRequired authenticates the service token AND resolves the tenant from
|
||||
// the gateway-pinned X-Org-Id into Locals("organization"), which AuthorizeSpendCap reads.
|
||||
// The specific route shadows the bridge wildcard (order 100 < 122). No PlatformOnly:
|
||||
// authorize is a per-org cap read, not a cross-org mint (unlike auto-recharge/run-all).
|
||||
app.Get("/v1/billing/spend-alerts/authorize",
|
||||
commercemid.RequestContext(),
|
||||
commercemid.TokenRequired(),
|
||||
commercebilling.AuthorizeSpendCap,
|
||||
)
|
||||
|
||||
// Self-service spend-cap CRUD WRITES — the customer half of the cap: a customer
|
||||
// (or the admin S2S) CREATES / EDITS / REMOVES their own usage caps. These are the
|
||||
// write siblings of the co-resident GET /v1/billing/spend-alerts list; without their
|
||||
// own registration they too fell through the account bridge's /v1/billing/* wildcard
|
||||
// (billingForwardable includes POST spend-alerts) into the SAME 502 self-dispatch loop
|
||||
// authorize hit — so a customer could not set a cap AT ALL in the unified binary
|
||||
// (POST/PATCH/DELETE all 502'd). Same chain commerce's own route table gates them with
|
||||
// (api/billing/handlers.go:322-325, the `user` group's userRequired = TokenRequired) +
|
||||
// the global RequestContext — an IAM JWT OR the COMMERCE_SERVICE_TOKEN, org resolved
|
||||
// from the gateway-pinned X-Org-Id into Locals("organization"). Org-scoped by that
|
||||
// namespace (a caller only ever writes their OWN org's caps; a foreign :id is a
|
||||
// not-found miss in the caller's namespace), so no PinBillingSubject — spend-alerts are
|
||||
// org-level, not billing-subject-level. Shadow the bridge wildcard (order 100 < 122).
|
||||
// A spend cap is a FINANCIAL SAFETY control, so its writes are gated to an ORG ADMIN
|
||||
// (or SuperAdmin, or the trusted S2S service token for the SuperAdmin cap-oversight
|
||||
// Forward) — never any authenticated member. commerce's own `user` group admits any
|
||||
// member, which would let a compromised member key DELETE the org's cap (→ unbounded
|
||||
// spend) or POST a 1¢ enforce cap (→ org-wide 402 DoS). requireSpendCapAdmin closes that.
|
||||
app.Post("/v1/billing/spend-alerts",
|
||||
commercemid.RequestContext(),
|
||||
commercemid.TokenRequired(),
|
||||
requireSpendCapAdmin(),
|
||||
commercebilling.CreateSpendAlert,
|
||||
)
|
||||
app.Patch("/v1/billing/spend-alerts/:id",
|
||||
commercemid.RequestContext(),
|
||||
commercemid.TokenRequired(),
|
||||
requireSpendCapAdmin(),
|
||||
commercebilling.UpdateSpendAlert,
|
||||
)
|
||||
app.Delete("/v1/billing/spend-alerts/:id",
|
||||
commercemid.RequestContext(),
|
||||
commercemid.TokenRequired(),
|
||||
requireSpendCapAdmin(),
|
||||
commercebilling.DeleteSpendAlert,
|
||||
)
|
||||
|
||||
// In-process seams:
|
||||
// - commerceinproc routes the S2S billing byte-stream into the co-resident
|
||||
// app (the metering debit path) instead of a socket to a standalone pod.
|
||||
@@ -270,3 +387,19 @@ func fireCapAlert(org string, test bool, project, service string) {
|
||||
db := commercedatastore.New(ctx)
|
||||
commercebilling.FireSpendAlerts(ctx, db, org, test, project, service, nil)
|
||||
}
|
||||
|
||||
// requireSpendCapAdmin gates a spend-alert WRITE to a validated ORG ADMIN or platform
|
||||
// SuperAdmin (the unforgeable SanitizeIdentity-minted X-User-IsOrgAdmin / isAdmin bits),
|
||||
// OR the trusted in-proc S2S service token (the SuperAdmin cap-oversight Forward + internal
|
||||
// automation). A validated non-admin MEMBER is REFUSED (403): a spend cap is a financial
|
||||
// safety boundary — a member must not be able to delete the org's cap (→ unbounded spend)
|
||||
// or set a punitive 1¢ cap (→ org-wide 402 DoS). The read paths (list/authorize) stay
|
||||
// member/S2S-open; only the mutations require admin.
|
||||
func requireSpendCapAdmin() zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
if principal.IsSuperAdmin(c) || principal.IsOrgAdmin(c) || accountclient.IsServiceToken(c) {
|
||||
return c.Next()
|
||||
}
|
||||
return zip.ErrForbidden("org admin required to change spend caps")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,11 +42,13 @@ var frozen = []struct {
|
||||
{"storage", true, false}, // was order 118
|
||||
{"provisioning", false, false}, // was order 120
|
||||
{"billing", false, false}, // was order 121
|
||||
{"rollingcap", false, false}, // rolling spend-cap gate (after billing); golden drifted — refrozen
|
||||
{"account-bridge", false, false}, // was order 122
|
||||
{"do", false, false}, // was order 123
|
||||
{"platform", true, false}, // was order 124
|
||||
{"projects", false, false}, // was order 125
|
||||
{"dns", false, false}, // new: /v1/dns zone plane (after projects)
|
||||
{"domain", false, false}, // new: Hanzo Domains registrar (/v1/domain), after dns
|
||||
{"prompts", false, false}, // was order 126
|
||||
{"agents", false, true}, // was order 127
|
||||
{"link", false, true}, // new: unified AI login manager (/v1/links), after agents
|
||||
@@ -63,9 +65,11 @@ var frozen = []struct {
|
||||
{"catalogsync", false, true}, // new: reverse loop (product.created → render) after content
|
||||
{"ml", true, false}, // was order 130
|
||||
{"usage", false, false}, // was order 131
|
||||
{"leaderboard", false, true}, // new: gamified usage analytics (after usage), owns opt-in SQLite (Shutdown)
|
||||
{"crm", false, false}, // was order 131
|
||||
{"marketing", false, true}, // new: marketing domain fold (after crm)
|
||||
{"ads", false, true}, // new: ads domain fold (after crm)
|
||||
{"validators", false, true}, // new: NFT-gated node provisioning (after ads); golden refrozen
|
||||
{"social", false, true}, // new: /v1/social fold (after crm)
|
||||
{"analytics", true, false}, // was order 132
|
||||
{"git", false, false}, // was order 132
|
||||
@@ -83,6 +87,7 @@ var frozen = []struct {
|
||||
{"team", false, true}, // was order 138
|
||||
{"settings", false, true}, // was order 138
|
||||
{"notify", true, false}, // was order 139
|
||||
{"channels", false, true}, // new: /v1/channels transport plane (after notify; must mount after integrations so RegisterIngress installs before webhooks emit)
|
||||
{"gateway", false, false}, // was order 139
|
||||
{"entitlements", false, true}, // was order 139
|
||||
{"exec", false, false}, // was order 140
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright 2026 Hanzo AI Inc. All Rights Reserved.
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// stubKMS is a KMSClient whose GetSecret returns a sealed value when present or a
|
||||
// not-found error otherwise — mirroring the co-resident store that, in production,
|
||||
// holds no upstream provider keys (they are provisioned as KMS-injected env).
|
||||
type stubKMS struct{ sealed map[string]string }
|
||||
|
||||
func (s stubKMS) GetSecret(_ context.Context, ref string) ([]byte, error) {
|
||||
if v, ok := s.sealed[ref]; ok {
|
||||
return []byte(v), nil
|
||||
}
|
||||
return nil, errors.New("secret not found")
|
||||
}
|
||||
func (stubKMS) PutSecret(context.Context, string, []byte) error { return nil }
|
||||
func (stubKMS) Sign(context.Context, string, []byte) ([]byte, error) { return nil, nil }
|
||||
|
||||
// TestZenKeyResolver_EnvFallback pins the production wiring: the upstream provider
|
||||
// key is provisioned as env (from the cloud-api-llm-keys secret), NOT sealed in the
|
||||
// co-resident KMS store, so a KMS miss must resolve to the env value rather than
|
||||
// returning "" (which would send an empty bearer upstream → provider 401).
|
||||
func TestZenKeyResolver_EnvFallback(t *testing.T) {
|
||||
const env = "DO_AI_API_KEY"
|
||||
t.Setenv(env, "env-provisioned-key")
|
||||
|
||||
// KMS store has no upstream keys (the real deployment state) — resolve from env.
|
||||
if got := zenKeyResolver(stubKMS{})(context.Background(), env); got != "env-provisioned-key" {
|
||||
t.Fatalf("KMS-miss: got %q, want env value", got)
|
||||
}
|
||||
|
||||
// A nil KMS client (KMS disabled) — still resolve from env.
|
||||
if got := zenKeyResolver(nil)(context.Background(), env); got != "env-provisioned-key" {
|
||||
t.Fatalf("nil-KMS: got %q, want env value", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZenKeyResolver_EnvTakesPrecedence pins the resolution ORDER: env is read
|
||||
// FIRST, then KMS — the same order ai uses on the prod hot path. The operator
|
||||
// injects provider keys as env from the KMS-synced secret, so the env is the live
|
||||
// value; the co-resident store is the fallback. A key present in BOTH surfaces
|
||||
// resolves to the env value.
|
||||
func TestZenKeyResolver_EnvTakesPrecedence(t *testing.T) {
|
||||
const env = "ANTHROPIC_API_KEY"
|
||||
t.Setenv(env, "env-key")
|
||||
got := zenKeyResolver(stubKMS{sealed: map[string]string{env: "sealed-key"}})(context.Background(), env)
|
||||
if got != "env-key" {
|
||||
t.Fatalf("got %q, want env-key (env precedence)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZenKeyResolver_AbsentEverywhere keeps the fail-fast contract: absent from both
|
||||
// surfaces resolves to "" so zen refuses rather than serving for free.
|
||||
func TestZenKeyResolver_AbsentEverywhere(t *testing.T) {
|
||||
if got := zenKeyResolver(stubKMS{})(context.Background(), "MISSING_KEY_XYZ"); got != "" {
|
||||
t.Fatalf("got %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright 2026 The Hanzo Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestJWTAudiences_AcceptsHanzoTeam pins the hanzo.team OIDC client. IAM mints
|
||||
// team's access tokens with aud=hanzo-team (each app's aud is its client_id);
|
||||
// the team OAuth callback sets that token as the hanzo_iam_token cookie, and
|
||||
// the usage/wallet page (/v1/team/billing/ui/) reads /v1/billing/balance +
|
||||
// /v1/usage/summary same-origin on it. If hanzo-team is not accepted the
|
||||
// cookie resolves anonymous and every wallet read 401s — and the callback's
|
||||
// own validator (NewTokenValidator shares this allowlist) refuses the login.
|
||||
func TestJWTAudiences_AcceptsHanzoTeam(t *testing.T) {
|
||||
os.Unsetenv("CLOUD_JWT_AUDIENCES")
|
||||
os.Unsetenv("GATEWAY_ALLOWED_AUDIENCES")
|
||||
|
||||
has := func(list []string, v string) bool {
|
||||
for _, s := range list {
|
||||
if s == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if !has(defaultJWTAudiences, "hanzo-team") {
|
||||
t.Fatalf("defaultJWTAudiences must include hanzo-team (the hanzo.team client_id); got %v", defaultJWTAudiences)
|
||||
}
|
||||
if !has(jwtAudiencesFromEnv(), "hanzo-team") {
|
||||
t.Fatalf("resolved JWT audiences must include hanzo-team; got %v", jwtAudiencesFromEnv())
|
||||
}
|
||||
}
|
||||
+9
-7
@@ -33,6 +33,7 @@ import (
|
||||
"github.com/go-jose/go-jose/v4/jwt"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
model "github.com/hanzoai/iam/pkg/model"
|
||||
)
|
||||
|
||||
// idClaims is the subset of Hanzo IAM JWT claims the identity sanitizer needs.
|
||||
@@ -40,13 +41,14 @@ import (
|
||||
type idClaims struct {
|
||||
jwt.Claims
|
||||
|
||||
Owner string `json:"owner"` // org slug (the org)
|
||||
Project string `json:"project"` // org SUB-SCOPE within owner (empty ⟹ default project)
|
||||
BillingAccount string `json:"billing_account"` // WHO PAYS, stated by IAM (empty ⟹ pre-claim token)
|
||||
Name string `json:"name"` // display name (id fallback)
|
||||
PreferredUsername string `json:"preferred_username"` // id fallback
|
||||
Email string `json:"email"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Owner string `json:"owner"` // org slug (the org)
|
||||
Project string `json:"project"` // org SUB-SCOPE within owner (empty ⟹ default project)
|
||||
BillingAccount string `json:"billing_account"` // WHO PAYS, stated by IAM (empty ⟹ pre-claim token)
|
||||
Name string `json:"name"` // display name (id fallback)
|
||||
PreferredUsername string `json:"preferred_username"` // id fallback
|
||||
Email string `json:"email"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Orgs []model.OrgRef `json:"orgs"` // membership SET (home first); empty on legacy tokens
|
||||
}
|
||||
|
||||
// mintedProject returns the project id to stamp into X-Project-Id, or "" when the
|
||||
|
||||
@@ -63,14 +63,15 @@ func BuildDeps(cfg *Config) Deps {
|
||||
)
|
||||
|
||||
deps := Deps{
|
||||
Logger: logger,
|
||||
Brand: cfg.Brand,
|
||||
Version: cfg.Version,
|
||||
Env: cfg.Env,
|
||||
Domain: cfg.Domain,
|
||||
IAMIssuer: cfg.IAMIssuer,
|
||||
DataDir: cfg.DataDir,
|
||||
AIDefaultModel: cfg.AIDefaultModel,
|
||||
Logger: logger,
|
||||
Brand: cfg.Brand,
|
||||
Version: cfg.Version,
|
||||
Env: cfg.Env,
|
||||
Domain: cfg.Domain,
|
||||
IAMIssuer: cfg.IAMIssuer,
|
||||
DataDir: cfg.DataDir,
|
||||
AIDefaultModel: cfg.AIDefaultModel,
|
||||
AIFallbackModel: cfg.AIFallbackModel,
|
||||
}
|
||||
|
||||
// For each subsystem: enabled → leave nil (Mount fills it); not
|
||||
@@ -91,7 +92,13 @@ func BuildDeps(cfg *Config) Deps {
|
||||
// pass-through and a dev deployment is never blocked.
|
||||
deps.Metering = buildMeteringClient(cfg, logger)
|
||||
wireTierReader(deps.Metering, logger)
|
||||
deps.AI = meteredAIClient(pickAIClient(cfg, logger), deps)
|
||||
// AI (completions, WRITE) and Embed (embeddings, READ-ONLY) are DISTINCT
|
||||
// credentials by concern: completions never ride the read-only publishable
|
||||
// (pk-) key — the gateway 403s a pk- key on any write endpoint — so deps.AI
|
||||
// resolves to the M2M identity, while deps.Embed keeps the pk- key (correct
|
||||
// least-privilege for a read-only call). Both meter through the ONE commerce path.
|
||||
deps.AI = meteredAIClient(pickCompletionsClient(cfg, logger), deps)
|
||||
deps.Embed = meteredAIClient(pickEmbedClient(cfg, logger), deps)
|
||||
wireFinance(cfg, logger)
|
||||
deps.O11y = pick(cfg, logger, "o11y", "O11y", cfg.O11yZAPAddr, clients.O11yRPCAt, clients.DisabledO11y)
|
||||
deps.VFS = pickVFSClient(cfg, logger)
|
||||
@@ -579,44 +586,76 @@ func RegisterCommerceClientFactory(f func(cfg *Config, log luxlog.Logger) Commer
|
||||
commerceClientFactory = f
|
||||
}
|
||||
|
||||
// pickAIClient resolves deps.AI — the client the agents subsystem runs chat
|
||||
// completions through. Unlike the co-resident subsystems, there is NO in-process
|
||||
// "ai" mount that fills a nil deps.AI: inference is an external gateway, so this
|
||||
// must return a concrete client, never nil. (A nil deps.AI was the live bug —
|
||||
// the default all-enabled config returned nil here and nothing ever filled it,
|
||||
// so every /v1/agents/:name/run 503'd "inference is not configured".)
|
||||
// publishableKey reports whether apiKey is a read-only PUBLISHABLE key (pk-). The
|
||||
// Hanzo gateway 403s a publishable key on any WRITE endpoint — "Publishable keys
|
||||
// can only access read-only endpoints … use a secret key (sk-)". So the COMPLETIONS
|
||||
// resolver must refuse it (it would only 403 chat), while the EMBED resolver accepts
|
||||
// it (embeddings ARE read-only). pk- is the IAM key family's read-only member
|
||||
// (hk-/sk-/pk-/fw_/hz_; clients/admission). This ONE predicate is the split's crux:
|
||||
// it kept the intermittent-403 bug — a pk- embed key riding the shared completions
|
||||
// client — from ever recurring, wherever the key comes from.
|
||||
func publishableKey(apiKey string) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(apiKey), "pk-")
|
||||
}
|
||||
|
||||
// pickCompletionsClient resolves deps.AI — the client the agents run path (and
|
||||
// guide/crm/content/sitegen/code /ask) execute CHAT COMPLETIONS through. There is
|
||||
// NO in-process "ai" mount that fills a nil deps.AI: inference is an external
|
||||
// gateway, so this returns a concrete client, never nil.
|
||||
//
|
||||
// Completions are a WRITE endpoint: a read-only publishable (pk-) key 403s them. So
|
||||
// this resolver NEVER rides a pk- key — that is the embed credential (pickEmbedClient).
|
||||
// Preference order:
|
||||
// 1. Static-key HTTP gateway when a base URL AND a static key are configured —
|
||||
// an operator override / pre-provisioned key. The key is a KMS-injected
|
||||
// secret; only the base URL and default model are ever logged.
|
||||
// 1. Static SECRET-key HTTP gateway when a base URL AND a completions-capable
|
||||
// (non-pk-) static key are configured — an explicit operator override (sk-/hk-).
|
||||
// A pk- key here is REFUSED (it would only 403 chat) and the resolver falls
|
||||
// through to M2M — THE fix for the intermittent publishable-key 403 on bot replies.
|
||||
// 2. M2M HTTP gateway when a base URL AND the binary's IAM identity are present
|
||||
// (the durable Hanzo default): the client mints+refreshes a client-
|
||||
// credentials token from IAM_CLIENT_ID/SECRET — no static key to rotate. The
|
||||
// secret is never logged.
|
||||
// (the durable Hanzo default): the client mints+refreshes a client-credentials
|
||||
// token from IAM_CLIENT_ID/SECRET — no static key to rotate. Secret never logged.
|
||||
// 3. ZAP RPC when an addr is configured (split-deploy of a future ai subsystem).
|
||||
// 4. Fail-closed stub otherwise — a run records an honest error, never fakes one.
|
||||
func pickAIClient(cfg *Config, log luxlog.Logger) AIClient {
|
||||
if cfg.AIBaseURL != "" && cfg.AIAPIKey != "" {
|
||||
log.Info("deps.AI → HTTP gateway (static key)", "base_url", cfg.AIBaseURL, "default_model", cfg.AIDefaultModel)
|
||||
func pickCompletionsClient(cfg *Config, log luxlog.Logger) AIClient {
|
||||
if cfg.AIBaseURL != "" && cfg.AIAPIKey != "" && !publishableKey(cfg.AIAPIKey) {
|
||||
log.Info("deps.AI (completions) → HTTP gateway (static secret key)", "base_url", cfg.AIBaseURL, "default_model", cfg.AIDefaultModel)
|
||||
return clients.AIHTTPAt(cfg.AIBaseURL, cfg.AIAPIKey, cfg.AIDefaultModel)
|
||||
}
|
||||
if cfg.AIAPIKey != "" && publishableKey(cfg.AIAPIKey) {
|
||||
log.Info("deps.AI (completions) → refusing read-only publishable (pk-) key for chat; using M2M", "base_url", cfg.AIBaseURL)
|
||||
}
|
||||
if cfg.AIBaseURL != "" && cfg.AIAuthClientID != "" && cfg.AIAuthClientSecret != "" {
|
||||
tokenURL := aiM2MTokenURL(cfg)
|
||||
if tokenURL != "" {
|
||||
log.Info("deps.AI → HTTP gateway (IAM M2M)", "base_url", cfg.AIBaseURL,
|
||||
log.Info("deps.AI (completions) → HTTP gateway (IAM M2M)", "base_url", cfg.AIBaseURL,
|
||||
"token_url", tokenURL, "client_id", cfg.AIAuthClientID, "default_model", cfg.AIDefaultModel)
|
||||
return clients.AIHTTPM2M(cfg.AIBaseURL, tokenURL, cfg.AIAuthClientID, cfg.AIAuthClientSecret, cfg.AIDefaultModel)
|
||||
}
|
||||
}
|
||||
if cfg.AIZAPAddr != "" {
|
||||
log.Info("deps.AI → ZAP RPC", "addr", cfg.AIZAPAddr)
|
||||
log.Info("deps.AI (completions) → ZAP RPC", "addr", cfg.AIZAPAddr)
|
||||
return clients.AIRPCAt(cfg.AIZAPAddr)
|
||||
}
|
||||
log.Info("deps.AI → disabled (no CLOUD_AI_API_KEY, no IAM M2M identity, no gateway configured)")
|
||||
log.Info("deps.AI (completions) → disabled (no secret key, no IAM M2M identity, no gateway configured)")
|
||||
return clients.DisabledAI()
|
||||
}
|
||||
|
||||
// pickEmbedClient resolves deps.Embed — the client code-index + KB knowledge run
|
||||
// EMBEDDINGS through. Embeddings are a READ-ONLY endpoint, so the read-only
|
||||
// publishable (pk-) key (CLOUD_AI_API_KEY ← cloud-ai-embed-key) is the CORRECT
|
||||
// least-privilege credential here, and is used UNCHANGED — this path is deliberately
|
||||
// not rewired. Preference order:
|
||||
// 1. Static-key HTTP gateway when a base URL AND a static key are configured. The
|
||||
// key (pk- or sk-) is a KMS-injected secret; only base URL + model are logged.
|
||||
// 2. Otherwise share the completions resolution (M2M / ZAP / fail-closed) so a
|
||||
// deploy with no dedicated embed key still indexes — no regression.
|
||||
func pickEmbedClient(cfg *Config, log luxlog.Logger) AIClient {
|
||||
if cfg.AIBaseURL != "" && cfg.AIAPIKey != "" {
|
||||
log.Info("deps.Embed → HTTP gateway (static embed key)", "base_url", cfg.AIBaseURL, "default_model", cfg.AIDefaultModel)
|
||||
return clients.AIHTTPAt(cfg.AIBaseURL, cfg.AIAPIKey, cfg.AIDefaultModel)
|
||||
}
|
||||
return pickCompletionsClient(cfg, log)
|
||||
}
|
||||
|
||||
// aiM2MTokenURL resolves IAM's client_credentials endpoint the agent runner mints
|
||||
// its M2M inference token at. It MUST be reachable FROM INSIDE THE CLUSTER: the
|
||||
// runner runs in-cluster and the public issuer host (https://hanzo.id) is fronted
|
||||
|
||||
+6
-2
@@ -63,7 +63,9 @@ var controlCommands = map[string]string{
|
||||
"k8s": "deploy-target helpers (current target)",
|
||||
"config": "view/edit ~/.hanzo/config preferences",
|
||||
"security": "scan files for hardcoded secrets (local guardrail; no server/auth)",
|
||||
"gpu": "connect this machine's GPU to the Hanzo cloud fleet (connect/status/disconnect)",
|
||||
"link": "bring this machine into the Hanzo cloud fleet as a node (fabric + compute worker)",
|
||||
"unlink": "take this machine out of the fleet (deregister + stop hanzod)",
|
||||
"status": "show the org's fleet — every node with each of its GPUs",
|
||||
"engine": "run a local hanzo-engine (OpenAI + Anthropic model server)",
|
||||
"code": "launch a coding agent (claude, codex, dev) on a Hanzo cloud model",
|
||||
"runner": "run this machine as a JIT CI runner for your org (GitHub Actions)",
|
||||
@@ -598,7 +600,9 @@ func newRootCmd() *cobra.Command {
|
||||
newBuildCmd(envOf, &f),
|
||||
newConfigCmd(),
|
||||
newSecurityCmd(envOf),
|
||||
newGPUCmd(envOf, &f),
|
||||
newLinkCmd(envOf, &f),
|
||||
newUnlinkCmd(envOf, &f),
|
||||
newStatusCmd(envOf, &f),
|
||||
newEngineCmd(envOf, &f),
|
||||
newCodeCmd(envOf, &f),
|
||||
newRunnerCmd(envOf, &f),
|
||||
|
||||
+25
-8
@@ -150,7 +150,7 @@ type codeAgent struct {
|
||||
continueArgs []string // harness-native form of Hanzo -c/--continue
|
||||
modelArg []string // how the model is passed on argv (empty: via env)
|
||||
carrier func(model string) string // maps the resolved model to a client-recognized id (claude: zen→carrier); nil = pass through
|
||||
provider func(base string) []string // agents that need the endpoint declared, not just env'd
|
||||
provider func(base, model string) []string // agents that need the endpoint declared, not just env'd
|
||||
clear []string // env that would shadow the wire (a stale key in the shell)
|
||||
configHome string // env var that relocates the agent's config dir to ~/.hanzo ("" = share the user's own install)
|
||||
seed func(dir string) error // one-time defaults for the isolated config dir
|
||||
@@ -159,6 +159,19 @@ type codeAgent struct {
|
||||
install string // hint when the binary is missing
|
||||
}
|
||||
|
||||
// codeContextWindow is the input context (tokens) the served coding model
|
||||
// budgets from. The enso and zen5 flagship tiers serve 1M; the flash tiers
|
||||
// serve 131072. Codex is told this so it sizes context to the real window
|
||||
// instead of a flat 256K cap — the cause of "maximum context exceeded" at
|
||||
// 262144 even on a 1M-capable model. This wrapper only launches Hanzo coding
|
||||
// models (default zen5), so non-flash defaults to the 1M flagship window.
|
||||
func codeContextWindow(model string) int {
|
||||
if strings.Contains(strings.ToLower(model), "flash") {
|
||||
return 131072
|
||||
}
|
||||
return 1000000
|
||||
}
|
||||
|
||||
// codex and @hanzo/dev share a lineage (dev is a Codex fork), hence a wire.
|
||||
// They also ignore OPENAI_BASE_URL and talk to chatgpt.com unless a provider is
|
||||
// declared, so declare Hanzo as the provider and select it.
|
||||
@@ -169,19 +182,23 @@ func codexLike(bin, install string) codeAgent {
|
||||
fullAuto: []string{"--dangerously-bypass-approvals-and-sandbox"},
|
||||
continueArgs: []string{"resume", "--last"},
|
||||
modelArg: []string{"-m"},
|
||||
provider: func(base string) []string {
|
||||
provider: func(base, model string) []string {
|
||||
// api.hanzo.ai exposes the standard OpenAI /v1/models shape, not
|
||||
// Codex's private remote model-catalog schema, so skip that refresh
|
||||
// and supply the model's window here — sized to the SERVED model
|
||||
// (enso / zen5 flagship = 1M, flash tiers = 131072) so Codex budgets
|
||||
// the real context instead of a flat 256K cap (the "maximum context
|
||||
// exceeded at 262144" bug). Auto-compact at 90% leaves headroom.
|
||||
win := codeContextWindow(model)
|
||||
return []string{
|
||||
"-c", "model_provider=hanzo",
|
||||
"-c", `model_providers.hanzo.name="Hanzo"`,
|
||||
"-c", fmt.Sprintf(`model_providers.hanzo.base_url="%s/v1"`, strings.TrimSuffix(base, "/")),
|
||||
"-c", `model_providers.hanzo.env_key="OPENAI_API_KEY"`,
|
||||
"-c", `model_providers.hanzo.wire_api="responses"`,
|
||||
// api.hanzo.ai exposes the standard OpenAI /v1/models shape,
|
||||
// not Codex's private remote model-catalog schema. Skip that
|
||||
// optional refresh and supply the coding model's metadata here.
|
||||
"-c", `features.remote_models=false`,
|
||||
"-c", `model_context_window=262144`,
|
||||
"-c", `model_auto_compact_token_limit=235929`,
|
||||
"-c", fmt.Sprintf("model_context_window=%d", win),
|
||||
"-c", fmt.Sprintf("model_auto_compact_token_limit=%d", win*9/10),
|
||||
}
|
||||
},
|
||||
install: install,
|
||||
@@ -447,7 +464,7 @@ func codeArgv(agent codeAgent, base, model string, safe bool, rest []string) []s
|
||||
argv = append(argv, agent.fullAuto...)
|
||||
}
|
||||
if agent.provider != nil {
|
||||
argv = append(argv, agent.provider(base)...)
|
||||
argv = append(argv, agent.provider(base, model)...)
|
||||
}
|
||||
if len(agent.modelArg) > 0 { // claude takes the model via env, codex/dev on argv
|
||||
argv = append(argv, agent.modelArg...)
|
||||
|
||||
+25
-2
@@ -100,19 +100,42 @@ func TestCodeUnknownOptionsAndPostSeparatorArgsPassThrough(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCodexProviderUsesNativeResponsesMetadata(t *testing.T) {
|
||||
// defaultCodeModel is zen5 — a 1M flagship tier — so the window must be 1M,
|
||||
// NOT the old flat 262144 cap that surfaced as "maximum context exceeded"
|
||||
// even on a 1M-capable model. Auto-compact is 90% of the window.
|
||||
argv := codeArgv(codeAgents["codex"], "https://api.hanzo.ai", defaultCodeModel, false, nil)
|
||||
for _, want := range []string{
|
||||
`model_provider=hanzo`,
|
||||
`model_providers.hanzo.base_url="https://api.hanzo.ai/v1"`,
|
||||
`model_providers.hanzo.wire_api="responses"`,
|
||||
`features.remote_models=false`,
|
||||
`model_context_window=262144`,
|
||||
`model_auto_compact_token_limit=235929`,
|
||||
`model_context_window=1000000`,
|
||||
`model_auto_compact_token_limit=900000`,
|
||||
} {
|
||||
if !slices.Contains(argv, want) {
|
||||
t.Errorf("Codex argv %q does not contain %q", argv, want)
|
||||
}
|
||||
}
|
||||
// A flash tier budgets its smaller real window, not 1M.
|
||||
flash := codeArgv(codeAgents["codex"], "https://api.hanzo.ai", "zen5-flash", false, nil)
|
||||
if !slices.Contains(flash, `model_context_window=131072`) {
|
||||
t.Errorf("zen5-flash argv %q must budget 131072, not the flagship 1M", flash)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodeContextWindowSizing pins the model→window map: every non-flash tier
|
||||
// gets the 1M flagship window; only flash tiers drop to 131072.
|
||||
func TestCodeContextWindowSizing(t *testing.T) {
|
||||
for _, m := range []string{"zen5", "zen5-pro", "zen5-coder", "enso", "enso-ultra"} {
|
||||
if w := codeContextWindow(m); w != 1000000 {
|
||||
t.Errorf("codeContextWindow(%q) = %d, want 1000000", m, w)
|
||||
}
|
||||
}
|
||||
for _, m := range []string{"zen5-flash", "enso-flash"} {
|
||||
if w := codeContextWindow(m); w != 131072 {
|
||||
t.Errorf("codeContextWindow(%q) = %d, want 131072", m, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodeTokenPrecedence locks in the 402 unblock: a fresh `hanzo login` JWT
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ package cli
|
||||
// cosign-signed binary from the latest github.com/hanzoai/engine release), so the
|
||||
// CLI never re-implements platform detection or verification. `serve` launches the
|
||||
// installed binary (`hanzoai --port P run -m MODEL`); `status` probes it, reusing
|
||||
// the same /v1/models probe `hanzo gpu connect --serve-engine` advertises with.
|
||||
// the same /v1/models probe `hanzo link --serve-engine` advertises with.
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
+695
-108
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestParseRocmSmiCSV — the AMD GPU inventory names a card from its marketing
|
||||
// series + gfx target, exactly as `rocm-smi --showproductname --csv` reports on
|
||||
// evo's gfx1151 Radeon 8060S. This is the primary AMD detection path.
|
||||
func TestParseRocmSmiCSV(t *testing.T) {
|
||||
// Real evo output (header + one card row).
|
||||
csv := []byte("device,Card Series,Card Model,Card Vendor,Card SKU,Subsystem ID,Device Rev,Node ID,GUID,GFX Version\n" +
|
||||
"card0,Radeon 8060S Graphics,0x1586,Advanced Micro Devices Inc. [AMD/ATI],STRXLGEN,-0x7fe3,0xc1,1,49819,gfx1151\n")
|
||||
gpus := parseRocmSmiCSV(csv)
|
||||
if len(gpus) != 1 {
|
||||
t.Fatalf("want 1 AMD GPU, got %d (%+v)", len(gpus), gpus)
|
||||
}
|
||||
if got, want := gpus[0].Name, "Radeon 8060S Graphics (gfx1151)"; got != want {
|
||||
t.Errorf("name = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGfxNameDecodesTargetVersion — the kfd fallback decodes gfx_target_version.
|
||||
func TestGfxNameDecodesTargetVersion(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
v int
|
||||
want string
|
||||
}{
|
||||
{110501, "gfx1151"}, // evo Radeon 8060S / Strix Halo
|
||||
{90012, "gfx9012"}, // sanity: MI-class encoding shape
|
||||
{100300, "gfx1030"}, // RDNA2
|
||||
} {
|
||||
if got := gfxName(c.v); got != c.want {
|
||||
t.Errorf("gfxName(%d) = %q, want %q", c.v, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseKfdTopology — the driver-only fallback (no rocm-smi) still finds the GPU
|
||||
// from /sys/class/kfd: node 0 is the CPU (simd_count 0, skipped), node 1 is the
|
||||
// gfx1151 GPU. Built against a fixture tree mirroring evo's real properties files.
|
||||
func TestParseKfdTopology(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeNode(t, root, "0", "simd_count 0\ngfx_target_version 0\n")
|
||||
writeNode(t, root, "1", "cpu_cores_count 0\nsimd_count 80\ngfx_target_version 110501\n")
|
||||
gpus := parseKfdTopology(root)
|
||||
if len(gpus) != 1 {
|
||||
t.Fatalf("want 1 GPU node (CPU node 0 skipped), got %d (%+v)", len(gpus), gpus)
|
||||
}
|
||||
if got, want := gpus[0].Name, "AMD GPU (gfx1151)"; got != want {
|
||||
t.Errorf("name = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseVulkaninfoSummary — the last resort keeps only AMD/Radeon devices so it
|
||||
// never double-counts a card another vendor path already reported.
|
||||
func TestParseVulkaninfoSummary(t *testing.T) {
|
||||
out := []byte("GPU0:\n\tdeviceName = AMD Radeon Graphics (RADV GFX1151)\n" +
|
||||
"GPU1:\n\tdeviceName = llvmpipe (LLVM 18.1.0, 256 bits)\n")
|
||||
gpus := parseVulkaninfoSummary(out)
|
||||
if len(gpus) != 1 {
|
||||
t.Fatalf("want 1 AMD device (llvmpipe filtered), got %d (%+v)", len(gpus), gpus)
|
||||
}
|
||||
if got := gpus[0].Name; got != "AMD Radeon Graphics (RADV GFX1151)" {
|
||||
t.Errorf("name = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func writeNode(t *testing.T, root, id, props string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, id)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "properties"), []byte(props), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,7 @@ func TestBuildRegistrationCarriesEngine(t *testing.T) {
|
||||
jobsNS: "gpu-jobs",
|
||||
gpus: []gpuInfo{{Name: "NVIDIA GB10", MemoryTotal: "122880 MiB"}},
|
||||
serveEngine: true,
|
||||
studioReady: true,
|
||||
engineURL: srv.URL,
|
||||
engineAdvURL: "http://node.example:1234",
|
||||
}
|
||||
@@ -136,7 +137,7 @@ func TestBuildRegistrationCarriesEngine(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCapabilitiesWithoutEngine(t *testing.T) {
|
||||
w := &worker{serveEngine: false}
|
||||
w := &worker{serveEngine: false, studioReady: true}
|
||||
caps := w.capabilities()
|
||||
if len(caps) != 1 || caps[0] != studioCap {
|
||||
t.Fatalf("capabilities = %v, want just [%q] when not serving an engine", caps, studioCap)
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
package cli
|
||||
|
||||
// gpu_queue_test.go — the per-GPU claim contract: a job pinned to THIS machine's
|
||||
// lane ("gpu:<identity>") is claimed BEFORE the shared any-GPU lane ("gpu-jobs"),
|
||||
// both within the gpu-jobs namespace. A stub cloud records the taskQueue of every
|
||||
// claim so the ORDER is the assertion.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// stubJobsCloud records the taskQueue of every claim and serves the queued job (if
|
||||
// any) on the matching lane, once. A lane with no job (or already drained) answers
|
||||
// 204; complete/fail/heartbeat answer 200.
|
||||
func stubJobsCloud(t *testing.T, claims *[]string, jobsByLane map[string]*claimedActivity) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/activities/claim") {
|
||||
var body struct {
|
||||
TaskQueue string `json:"taskQueue"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
*claims = append(*claims, body.TaskQueue)
|
||||
if job := jobsByLane[body.TaskQueue]; job != nil {
|
||||
jobsByLane[body.TaskQueue] = nil // deliver once
|
||||
_ = json.NewEncoder(w).Encode(job)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK) // complete / fail / heartbeat
|
||||
}))
|
||||
}
|
||||
|
||||
// echoJob is a GPU-free job the worker's echo handler runs to completion instantly,
|
||||
// so a claim test never needs a real render backend.
|
||||
func echoJob(id string) *claimedActivity {
|
||||
a := &claimedActivity{Input: json.RawMessage(`{}`)}
|
||||
a.Execution.WorkflowId, a.Execution.RunId = id, id
|
||||
a.Type.Name = "echo"
|
||||
return a
|
||||
}
|
||||
|
||||
func testWorker(t *testing.T, url string) *worker {
|
||||
t.Helper()
|
||||
t.Setenv("HANZO_TOKEN", "test-token") // ensureToken honors this; no `hanzo login`
|
||||
return &worker{
|
||||
env: &Env{CloudURL: url},
|
||||
http: &http.Client{Timeout: 5 * time.Second},
|
||||
baseURL: url,
|
||||
identity: "spark",
|
||||
hostname: "spark",
|
||||
jobsNS: "gpu-jobs",
|
||||
handlers: map[string]jobHandler{"echo": echoHandler},
|
||||
studioReady: true, // a render-capable node (preflight passed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGPUQueueLaneName(t *testing.T) {
|
||||
w := &worker{identity: "spark"}
|
||||
if got := w.gpuQueue(); got != "gpu:spark" {
|
||||
t.Fatalf("gpuQueue() = %q, want gpu:spark", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A job pinned to THIS GPU's lane is claimed first — the shared lane is not even
|
||||
// polled that cycle.
|
||||
func TestClaimPrefersTargetedLane(t *testing.T) {
|
||||
var claims []string
|
||||
srv := stubJobsCloud(t, &claims, map[string]*claimedActivity{"gpu:spark": echoJob("j1")})
|
||||
defer srv.Close()
|
||||
if err := testWorker(t, srv.URL).claimAndRun(context.Background(), io.Discard); err != nil {
|
||||
t.Fatalf("claimAndRun: %v", err)
|
||||
}
|
||||
if len(claims) != 1 || claims[0] != "gpu:spark" {
|
||||
t.Fatalf("claims = %v, want exactly [gpu:spark] (targeted first; shared not polled)", claims)
|
||||
}
|
||||
}
|
||||
|
||||
// With its own lane empty, the worker falls through to the shared any-GPU lane.
|
||||
func TestClaimFallsBackToSharedLane(t *testing.T) {
|
||||
var claims []string
|
||||
srv := stubJobsCloud(t, &claims, map[string]*claimedActivity{"gpu-jobs": echoJob("j2")})
|
||||
defer srv.Close()
|
||||
if err := testWorker(t, srv.URL).claimAndRun(context.Background(), io.Discard); err != nil {
|
||||
t.Fatalf("claimAndRun: %v", err)
|
||||
}
|
||||
if len(claims) != 2 || claims[0] != "gpu:spark" || claims[1] != "gpu-jobs" {
|
||||
t.Fatalf("claims = %v, want [gpu:spark gpu-jobs] (targeted, then shared)", claims)
|
||||
}
|
||||
}
|
||||
|
||||
// Both lanes empty polls targeted THEN shared and runs nothing.
|
||||
func TestClaimBothLanesEmpty(t *testing.T) {
|
||||
var claims []string
|
||||
srv := stubJobsCloud(t, &claims, map[string]*claimedActivity{})
|
||||
defer srv.Close()
|
||||
if err := testWorker(t, srv.URL).claimAndRun(context.Background(), io.Discard); err != nil {
|
||||
t.Fatalf("claimAndRun: %v", err)
|
||||
}
|
||||
if len(claims) != 2 || claims[0] != "gpu:spark" || claims[1] != "gpu-jobs" {
|
||||
t.Fatalf("claims = %v, want [gpu:spark gpu-jobs]", claims)
|
||||
}
|
||||
}
|
||||
|
||||
// The render submit seam is the gated worker-mode execute path, not the open /prompt
|
||||
// — the shared contract with the studio's --worker-mode gate.
|
||||
func TestWorkerExecuteSeamIsGated(t *testing.T) {
|
||||
if localWorkerExecute != "http://127.0.0.1:8188/v1/worker/execute" {
|
||||
t.Fatalf("localWorkerExecute = %q, want the gated /v1/worker/execute seam", localWorkerExecute)
|
||||
}
|
||||
}
|
||||
|
||||
// A HUNG nvidia-smi (blocks until its bounded context fires) must NOT block the
|
||||
// caller: reportSample detaches the probe+POST onto a goroutine and returns at once,
|
||||
// so the worker's select loop keeps heartbeating and claiming. Guards the
|
||||
// worker-wedge regression (a synchronous probe that stalls the loop under GPU/driver
|
||||
// pressure → the machine flaps offline mid-render).
|
||||
func TestReportSampleNeverBlocksLoop(t *testing.T) {
|
||||
orig := nvidiaSmi
|
||||
defer func() { nvidiaSmi = orig }()
|
||||
probing := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
nvidiaSmi = func(ctx context.Context) ([]byte, error) {
|
||||
close(probing) // entered the probe (the nvidiaSmi var read already happened)
|
||||
select {
|
||||
case <-release: // the test lets us finish
|
||||
case <-ctx.Done(): // or the bounded probe timeout fires
|
||||
}
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
t.Setenv("HANZO_TOKEN", "t")
|
||||
w := &worker{identity: "spark", hostname: "spark", http: &http.Client{Timeout: time.Second}, env: &Env{}, baseURL: "http://127.0.0.1:0"}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() { w.reportSample(context.Background()); close(done) }()
|
||||
select {
|
||||
case <-done: // returned immediately — the select loop is never wedged
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("reportSample blocked the caller — a hung sampler would wedge the worker loop")
|
||||
}
|
||||
select {
|
||||
case <-probing: // the probe really ran, on the detached goroutine (off the critical path)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("probe never started")
|
||||
}
|
||||
close(release)
|
||||
}
|
||||
|
||||
// A node that can't serve renders (preflight failed) claims NOTHING — it must never
|
||||
// pull a render job onto a box that will only refuse it on the gated seam (poison
|
||||
// loop). It still heartbeats presence; it just stays idle.
|
||||
func TestNotStudioReadyClaimsNothing(t *testing.T) {
|
||||
var claims []string
|
||||
srv := stubJobsCloud(t, &claims, map[string]*claimedActivity{"gpu:spark": echoJob("j1")})
|
||||
defer srv.Close()
|
||||
w := testWorker(t, srv.URL)
|
||||
w.studioReady = false
|
||||
if err := w.claimAndRun(context.Background(), io.Discard); err != nil {
|
||||
t.Fatalf("claimAndRun: %v", err)
|
||||
}
|
||||
if len(claims) != 0 {
|
||||
t.Fatalf("a not-ready node claimed %v; want zero claims", claims)
|
||||
}
|
||||
}
|
||||
|
||||
// The terminal report must hit the RIGHT activity — namespace gpu-jobs, the CLAIMED
|
||||
// workflow+run ids, the correct verb. A stub that 200s every path lets an ns/id
|
||||
// routing regression pass, so assert the exact paths for both complete and fail.
|
||||
func TestTerminalReportsHitCorrectActivityPath(t *testing.T) {
|
||||
t.Setenv("HANZO_TOKEN", "t")
|
||||
var mu sync.Mutex
|
||||
terminal := map[string]string{}
|
||||
served := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/activities/claim"):
|
||||
var body struct {
|
||||
TaskQueue string `json:"taskQueue"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body.TaskQueue != "gpu:spark" {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
n := served
|
||||
served++
|
||||
mu.Unlock()
|
||||
switch n {
|
||||
case 0:
|
||||
_ = json.NewEncoder(w).Encode(echoJob("wf1")) // echo handler → complete
|
||||
case 1:
|
||||
j := echoJob("wf2")
|
||||
j.Type.Name = "nope" // no handler → fail
|
||||
_ = json.NewEncoder(w).Encode(j)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
case strings.HasSuffix(r.URL.Path, "/complete"):
|
||||
mu.Lock()
|
||||
terminal["complete"] = r.URL.Path
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case strings.HasSuffix(r.URL.Path, "/fail"):
|
||||
mu.Lock()
|
||||
terminal["fail"] = r.URL.Path
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
w := testWorker(t, srv.URL)
|
||||
if err := w.claimAndRun(context.Background(), io.Discard); err != nil {
|
||||
t.Fatalf("run1 (echo→complete): %v", err)
|
||||
}
|
||||
if err := w.claimAndRun(context.Background(), io.Discard); err != nil {
|
||||
t.Fatalf("run2 (unknown→fail): %v", err)
|
||||
}
|
||||
if got := terminal["complete"]; got != "/v1/tasks/namespaces/gpu-jobs/activities/wf1/wf1/complete" {
|
||||
t.Fatalf("complete path = %q, want the claimed activity's exact ns+ids", got)
|
||||
}
|
||||
if got := terminal["fail"]; got != "/v1/tasks/namespaces/gpu-jobs/activities/wf2/wf2/fail" {
|
||||
t.Fatalf("fail path = %q, want the claimed activity's exact ns+ids", got)
|
||||
}
|
||||
}
|
||||
|
||||
// SharePolicy.reject's fallback: an ABSENT or unparseable input field skips its gate
|
||||
// (permissive), never a hard error, so a policy only ever narrows on fields it can read.
|
||||
func TestSharePolicyRejectFallback(t *testing.T) {
|
||||
var nilp *SharePolicy
|
||||
if r := nilp.reject("studio.render", nil); r != "" {
|
||||
t.Fatalf("nil policy must allow everything: %q", r)
|
||||
}
|
||||
p := &SharePolicy{AllowedJobTypes: []string{"studio.render"}}
|
||||
if p.reject("echo", nil) == "" {
|
||||
t.Fatal("a disallowed job type must be rejected")
|
||||
}
|
||||
if r := p.reject("studio.render", nil); r != "" {
|
||||
t.Fatalf("an allowed job type must pass: %q", r)
|
||||
}
|
||||
p2 := &SharePolicy{AllowedOrgs: []string{"acme"}}
|
||||
if r := p2.reject("studio.render", json.RawMessage(`{}`)); r != "" {
|
||||
t.Fatalf("absent org must SKIP the org gate (fallback), not reject: %q", r)
|
||||
}
|
||||
if r := p2.reject("studio.render", json.RawMessage(`{"org":"acme"}`)); r != "" {
|
||||
t.Fatalf("a matching org must pass: %q", r)
|
||||
}
|
||||
if p2.reject("studio.render", json.RawMessage(`{"org":"other"}`)) == "" {
|
||||
t.Fatal("a non-allowed org must be rejected")
|
||||
}
|
||||
if r := p2.reject("studio.render", json.RawMessage(`not json`)); r != "" {
|
||||
t.Fatalf("unparseable input must skip input gates, not reject: %q", r)
|
||||
}
|
||||
}
|
||||
|
||||
// studioCap is advertised ONLY when the node can actually render: a missing worker
|
||||
// token is never ready (the gated seam would 403), and the block reason is explicit.
|
||||
func TestStudioReadyGatesCapability(t *testing.T) {
|
||||
t.Setenv("STUDIO_WORKER_TOKEN", "")
|
||||
w := &worker{launchesStudio: true, http: &http.Client{}}
|
||||
w.refreshStudioReady(context.Background())
|
||||
if w.studioReady {
|
||||
t.Fatal("no token must never be studio-ready")
|
||||
}
|
||||
if contains(w.capabilities(), studioCap) {
|
||||
t.Fatalf("studioCap advertised without a token: %v", w.capabilities())
|
||||
}
|
||||
if w.studioBlockReason() == "" {
|
||||
t.Fatal("a not-ready node must explain why it won't render")
|
||||
}
|
||||
// Token present + we launch the studio ⇒ ready ⇒ studioCap advertised.
|
||||
t.Setenv("STUDIO_WORKER_TOKEN", "tok")
|
||||
if changed := w.refreshStudioReady(context.Background()); !changed {
|
||||
t.Fatal("adding the token should flip readiness")
|
||||
}
|
||||
if !w.studioReady || !contains(w.capabilities(), studioCap) {
|
||||
t.Fatalf("token + launchesStudio must be ready + advertise studioCap: ready=%v caps=%v", w.studioReady, w.capabilities())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package cli
|
||||
|
||||
// gpu_spec_test.go — the host static-spec a `hanzo link` node reports so
|
||||
// GET /v1/fleet can show its CPU arch, core count and total RAM (the fields a
|
||||
// code-linked box already carries). Real telemetry only: arch is `uname -m`, cores
|
||||
// are runtime.NumCPU, RAM is parsed from the OS — never a hardcoded machine.
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseMemTotalKB(t *testing.T) {
|
||||
// A real /proc/meminfo head from a 128 GiB box. MemTotal is in kB; we report bytes.
|
||||
meminfo := []byte("MemTotal: 131923980 kB\nMemFree: 1048576 kB\nMemAvailable: 120000000 kB\n")
|
||||
if got, want := parseMemTotalKB(meminfo), int64(131923980)*1024; got != want {
|
||||
t.Fatalf("parseMemTotalKB = %d, want %d bytes", got, want)
|
||||
}
|
||||
// Absent / malformed input is reported as 0 (unknown), never a guess.
|
||||
for name, in := range map[string]string{
|
||||
"empty": "",
|
||||
"no-memtotal": "MemFree: 100 kB\n",
|
||||
"malformed": "MemTotal: notanumber kB\n",
|
||||
"no-value": "MemTotal:\n",
|
||||
} {
|
||||
if got := parseMemTotalKB([]byte(in)); got != 0 {
|
||||
t.Fatalf("%s: parseMemTotalKB = %d, want 0", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// detectMemTotal reads the real host, so on Linux/macOS CI it must return a positive
|
||||
// byte count — proof the reporter reads actual RAM rather than shipping 0.
|
||||
func TestDetectMemTotalIsReal(t *testing.T) {
|
||||
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
|
||||
t.Skipf("no MemTotal source on %s", runtime.GOOS)
|
||||
}
|
||||
got := detectMemTotal()
|
||||
if got <= 0 {
|
||||
t.Fatalf("detectMemTotal = %d, want the host's real RAM (>0)", got)
|
||||
}
|
||||
// Evidence: what THIS host actually reports (never hardcoded). On the GB10 spark
|
||||
// box this prints aarch64 + ~128 GiB read from /proc/meminfo.
|
||||
t.Logf("real host spec: arch=%s cpus=%d memory=%d bytes (%.1f GiB)",
|
||||
detectArch(), runtime.NumCPU(), got, float64(got)/(1<<30))
|
||||
}
|
||||
|
||||
// detectArch must match the fleet's `uname -m` convention (aarch64 | x86_64 | arm64),
|
||||
// NOT runtime.GOARCH (arm64 | amd64) — so a machine that appears as both a run-target
|
||||
// and a linked worker shows ONE arch string on the board. On Linux uname -m is
|
||||
// aarch64/x86_64; assert the real host agrees and is never GOARCH's amd64.
|
||||
func TestDetectArchMatchesUnameConvention(t *testing.T) {
|
||||
got := detectArch()
|
||||
if got == "" {
|
||||
t.Fatal("detectArch returned empty; must fall back to runtime.GOARCH")
|
||||
}
|
||||
if out, err := exec.Command("uname", "-m").Output(); err == nil {
|
||||
if want := strings.TrimSpace(string(out)); want != "" && got != want {
|
||||
t.Fatalf("detectArch = %q, want `uname -m` %q (fleet convention)", got, want)
|
||||
}
|
||||
}
|
||||
// Guard the regression this test exists for: on Linux amd64 the value must be
|
||||
// x86_64, never GOARCH's "amd64".
|
||||
if runtime.GOOS == "linux" && runtime.GOARCH == "amd64" && got == "amd64" {
|
||||
t.Fatal("arch is GOARCH 'amd64'; the fleet convention is 'x86_64'")
|
||||
}
|
||||
t.Logf("detectArch=%q (GOARCH=%q)", got, runtime.GOARCH)
|
||||
}
|
||||
|
||||
// buildRegistration must carry this host's detected arch (uname -m), cores (NumCPU)
|
||||
// and RAM — so spark reports aarch64 and evo-2 reports x86_64, both ~128 GB, matching
|
||||
// how the same machines already report as code-linked run-targets.
|
||||
func TestBuildRegistrationCarriesHostSpec(t *testing.T) {
|
||||
const mem = int64(137438953472) // 128 GiB
|
||||
w := &worker{hostname: "spark", jobsNS: "gpu-jobs", arch: "aarch64", memory: mem}
|
||||
reg := w.buildRegistration()
|
||||
if reg.Arch != "aarch64" {
|
||||
t.Fatalf("Arch = %q, want the worker's detected arch %q", reg.Arch, "aarch64")
|
||||
}
|
||||
if reg.CPUs != runtime.NumCPU() {
|
||||
t.Fatalf("CPUs = %d, want runtime.NumCPU %d", reg.CPUs, runtime.NumCPU())
|
||||
}
|
||||
if reg.Memory != mem {
|
||||
t.Fatalf("Memory = %d, want the detected total %d", reg.Memory, mem)
|
||||
}
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
package cli
|
||||
|
||||
// link.go — `hanzo link | unlink | status`: bring THIS machine into the Hanzo
|
||||
// cloud fleet AS A NODE, take it back out, and view the fleet.
|
||||
//
|
||||
// A node is two orthogonal memberships, composed under one verb:
|
||||
//
|
||||
// 1. The FABRIC — hanzod on hanzo.network. `link` starts it by invoking the
|
||||
// canonical fabric verb `hanzo node up` (the Rust node CLI: resolve a hanzod
|
||||
// binary, spawn it detached, record its pid). link does NOT reimplement hanzod
|
||||
// supervision — there is exactly one way to start hanzod, and this composes it.
|
||||
// Best-effort: a node with no hanzod still joins the compute fleet (CPU-only
|
||||
// boxes and dev machines link fine); `--no-fabric` skips it outright.
|
||||
//
|
||||
// 2. The COMPUTE fleet — this machine's inventory (CPU cores + model, memory, and
|
||||
// each GPU as its own resource) registered as a heartbeating presence in the
|
||||
// org's `fleet` namespace, running the outbound worker loop that claims jobs
|
||||
// from `gpu-jobs`. That machinery lives in gpu.go (runConnect); `link` runs it.
|
||||
//
|
||||
// `unlink` reverses both: it deregisters the worker (drops the fleet row), then
|
||||
// stops the fabric (`hanzo node stop`). `status` is the fleet view — every machine
|
||||
// with each of its GPUs shown distinctly, this box highlighted.
|
||||
//
|
||||
// One identity: the IAM token `hanzo login` mints authorizes every cloud call; the
|
||||
// server derives the tenant from the token. No secrets on the box.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// link / unlink / status — the node-level command surface.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newLinkCmd(envOf func() *Env, _ *globalFlags) *cobra.Command {
|
||||
var opts connectOpts
|
||||
var daemon bool
|
||||
var noFabric bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "link",
|
||||
Short: "Bring this machine into the Hanzo cloud fleet as a node",
|
||||
Long: "Link this machine into the Hanzo cloud as a node: join the fabric (start\n" +
|
||||
"hanzod on hanzo.network) and register as a compute worker — advertising this\n" +
|
||||
"host's CPU (cores + model), memory, and each GPU as its own resource, then\n" +
|
||||
"heartbeating and claiming jobs from your org's queue. The node shows up in the\n" +
|
||||
"console (Machines + GPUs) and on `hanzo status`. Works on a CPU-only box.\n" +
|
||||
"Authentication reuses the `hanzo login` token; the org is taken from its claims.",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if daemon {
|
||||
return installDaemon(cmd, opts)
|
||||
}
|
||||
// 1. Fabric: start hanzod via the canonical `hanzo node up` (best-effort).
|
||||
startFabric(cmd, noFabric)
|
||||
// 2. Compute fleet: register inventory, heartbeat, claim jobs (foreground).
|
||||
return runConnect(cmd, envOf(), opts)
|
||||
},
|
||||
}
|
||||
|
||||
f := cmd.Flags()
|
||||
f.StringVar(&opts.jobsNS, "jobs-namespace", defaultJobsNS, "tasks namespace to claim jobs from")
|
||||
f.BoolVar(&noFabric, "no-fabric", false, "join the compute fleet only; do not start hanzod")
|
||||
f.BoolVar(&daemon, "daemon", false, "install a systemd --user unit (Restart=always) instead of running in the foreground")
|
||||
f.BoolVar(&opts.serveEngine, "serve-engine", false, "also advertise a hanzo-engine model server (OpenAI + Anthropic) running on this node")
|
||||
f.StringVar(&opts.engineURL, "engine-url", defaultEngineURL, "local URL where hanzo-engine is probed (GET /v1/models)")
|
||||
f.StringVar(&opts.engineEndpoint, "engine-endpoint", "", "public URL to advertise for gateway routing (defaults to --engine-url; a node behind NAT needs a reachable URL/tunnel)")
|
||||
f.BoolVar(&opts.registerProvider, "register-provider", false, "auto-register the engine endpoint as an org model provider (POST /v1/add-provider)")
|
||||
f.StringVar(&opts.studioDir, "studio-dir", os.Getenv("HANZO_STUDIO_DIR"), "local Hanzo Studio checkout; when set, link launches and supervises the render backend on 127.0.0.1:8188")
|
||||
f.StringVar(&opts.studioURL, "studio-url", firstNonEmpty(os.Getenv("HANZO_STUDIO_UPLOAD_URL"), defaultStudioUploadURL), "studio base URL the render mirror uploads finished images to (POST /v1/library/upload)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newUnlinkCmd(envOf func() *Env, _ *globalFlags) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "unlink",
|
||||
Short: "Take this machine out of the fleet (deregister + stop hanzod)",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
// Reverse of link, both best-effort: drop the compute-fleet row, then
|
||||
// ALWAYS stop the fabric so a deregister error never leaves hanzod
|
||||
// running. The deregister error is reported, not short-circuited.
|
||||
derr := runDisconnect(cmd, envOf())
|
||||
stopFabric(cmd)
|
||||
return derr
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newStatusCmd(envOf func() *Env, _ *globalFlags) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show the org's fleet — every machine with each of its GPUs (this box highlighted)",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error { return runFleetStatus(cmd, envOf()) },
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fabric — compose the canonical `hanzo node up` / `node stop`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// fabricCLI resolves the Hanzo node CLI that owns hanzod supervision — the Rust
|
||||
// fabric/dev CLI with `node up/stop`. In the canonical layout the Go unified binary
|
||||
// takes the `hanzo` name and the Rust CLI is installed alongside it as `hanzo-node`,
|
||||
// so `link` composes `node up` without shelling into itself (this binary has no
|
||||
// `node`). Resolution order: HANZO_FABRIC_CLI, then `hanzo-node`, then a
|
||||
// self-guarded `hanzo` (for boxes where the Rust CLI still holds the `hanzo` name).
|
||||
// Returns "" when none is resolvable.
|
||||
func fabricCLI() string {
|
||||
if p := os.Getenv("HANZO_FABRIC_CLI"); p != "" {
|
||||
return p
|
||||
}
|
||||
if p, err := exec.LookPath("hanzo-node"); err == nil {
|
||||
return p
|
||||
}
|
||||
p, err := exec.LookPath("hanzo")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if self, err := os.Executable(); err == nil {
|
||||
if sp, _ := os.Readlink(p); sp == self || p == self {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Passthrough delegates a verb this binary does not own — node, dev, wallet,
|
||||
// network, … — to the Rust fabric/dev CLI (resolved by fabricCLI, installed as
|
||||
// `hanzo-node`), so the single `hanzo` name is a SUPERSET: Go verbs served natively,
|
||||
// everything else handed through unchanged. `hanzo node up` (the fabric `link`
|
||||
// itself composes) works for users too. It runs the delegate to completion with
|
||||
// inherited stdio and exits with its code; it returns false only when no fabric CLI
|
||||
// is resolvable, so the caller can report unknown-subcommand.
|
||||
func Passthrough(args []string) bool {
|
||||
bin := fabricCLI()
|
||||
if bin == "" {
|
||||
return false
|
||||
}
|
||||
c := exec.Command(bin, args...)
|
||||
c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr
|
||||
if err := c.Run(); err != nil {
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) {
|
||||
os.Exit(ee.ExitCode())
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "hanzo: delegating %v to %s: %v\n", args, bin, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
return true
|
||||
}
|
||||
|
||||
// startFabric starts hanzod by invoking `hanzo node up` — the one canonical way to
|
||||
// join the fabric. Best-effort by design: a missing CLI or a missing hanzod prints
|
||||
// a clear note and the node still joins the compute fleet. `--no-fabric` skips it.
|
||||
func startFabric(cmd *cobra.Command, skip bool) {
|
||||
out := cmd.OutOrStdout()
|
||||
if skip {
|
||||
fmt.Fprintln(out, "fabric: skipped (--no-fabric); joining the compute fleet only")
|
||||
return
|
||||
}
|
||||
bin := fabricCLI()
|
||||
if bin == "" {
|
||||
fmt.Fprintln(out, "fabric: hanzo node CLI not found — joining the compute fleet only")
|
||||
fmt.Fprintln(out, " (install the `hanzo` node CLI or set HANZO_FABRIC_CLI to start hanzod)")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
c := exec.CommandContext(ctx, bin, "node", "up")
|
||||
c.Stdout, c.Stderr = out, cmd.ErrOrStderr()
|
||||
if err := c.Run(); err != nil {
|
||||
fmt.Fprintf(out, "fabric: `%s node up` did not start hanzod (%v) — joining the compute fleet only\n", bin, err)
|
||||
}
|
||||
}
|
||||
|
||||
// stopFabric stops the hanzod this box started, via the canonical `hanzo node stop`.
|
||||
// Best-effort — nothing to stop is not an error.
|
||||
func stopFabric(cmd *cobra.Command) {
|
||||
out := cmd.OutOrStdout()
|
||||
bin := fabricCLI()
|
||||
if bin == "" {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
c := exec.CommandContext(ctx, bin, "node", "stop")
|
||||
c.Stdout, c.Stderr = out, cmd.ErrOrStderr()
|
||||
_ = c.Run()
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestUnlinkIdempotent — a repeat `hanzo unlink` is a no-op. The deregister POST
|
||||
// hitting an already-terminal (409) or absent (404) fleet row is the desired end
|
||||
// state, so runDisconnect returns nil (not an error) and says so, letting `unlink`
|
||||
// be run twice safely.
|
||||
func TestUnlinkIdempotent(t *testing.T) {
|
||||
for _, code := range []int{http.StatusConflict, http.StatusNotFound} {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(code)
|
||||
_, _ = w.Write([]byte(`{"error":"activity terminal"}`))
|
||||
}))
|
||||
|
||||
t.Setenv("HANZO_TOKEN", "test-token") // ensureToken honors this; no login needed
|
||||
var out bytes.Buffer
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetContext(context.Background())
|
||||
cmd.SetOut(&out)
|
||||
|
||||
if err := runDisconnect(cmd, &Env{CloudURL: srv.URL}); err != nil {
|
||||
t.Errorf("HTTP %d: runDisconnect should be idempotent (nil), got %v", code, err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "already unlinked") {
|
||||
t.Errorf("HTTP %d: want 'already unlinked' notice, got %q", code, out.String())
|
||||
}
|
||||
srv.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnlinkDeregisterErrorSurfaces — a non-terminal deregister failure (e.g. 500)
|
||||
// is a real error and must be returned, not swallowed as idempotent success.
|
||||
func TestUnlinkDeregisterErrorSurfaces(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error":"boom"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
t.Setenv("HANZO_TOKEN", "test-token")
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetContext(context.Background())
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
|
||||
if err := runDisconnect(cmd, &Env{CloudURL: srv.URL}); err == nil {
|
||||
t.Fatal("a 500 deregister must surface an error, got nil")
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -6,7 +6,7 @@ package cli
|
||||
// org for queued workflow jobs and spawns ephemeral, auto-exiting actions-runner
|
||||
// subprocesses tagged with this box's labels (GPU / vulkan aware). Outbound only —
|
||||
// nothing listens for inbound. Completes the trifecta: `engine` serves models,
|
||||
// `gpu connect` shares compute, `runner` claims CI — one binary, one org login.
|
||||
// `link` shares compute, `runner` claims CI — one binary, one org login.
|
||||
|
||||
import (
|
||||
"os/signal"
|
||||
|
||||
+17
-4
@@ -1,5 +1,5 @@
|
||||
// studio.go — local Hanzo Studio render-backend supervision for `hanzo gpu
|
||||
// connect --studio-dir <checkout>`. The gpu-jobs claim loop renders on the
|
||||
// studio.go — local Hanzo Studio render-backend supervision for `hanzo link
|
||||
// --studio-dir <checkout>`. The gpu-jobs claim loop renders on the
|
||||
// LOCAL studio server (127.0.0.1:8188); this keeps that server alive so the
|
||||
// box needs no separate watchdog script or hand-rolled systemd unit — the
|
||||
// hanzo CLI is the one way a BYO box joins the fleet, render backend included.
|
||||
@@ -67,9 +67,22 @@ func launchStudio(dir string) (*exec.Cmd, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// --listen 127.0.0.1 (loopback only) + --worker-mode: the render backend serves
|
||||
// the fleet worker on this box and nothing else. The worker dials loopback
|
||||
// (localComfyUI) so binding wider bought nothing but an open, unauthenticated
|
||||
// /prompt — the hidden-run hole. --worker-mode makes the studio gate its submit
|
||||
// seam (/v1/worker/execute + X-Worker-Token) so only the worker can start a render.
|
||||
// VRAM mode: default --normalvram (safe for smaller BYO GPUs); override with
|
||||
// HANZO_STUDIO_VRAM (e.g. "--highvram") on big-memory boxes (GB10 128G unified) so
|
||||
// the Qwen text-encoder stays resident on-GPU instead of non-deterministically
|
||||
// offloading to CPU — offload makes renders CPU-bound and ~8x slower.
|
||||
vramMode := os.Getenv("HANZO_STUDIO_VRAM")
|
||||
if vramMode == "" {
|
||||
vramMode = "--normalvram"
|
||||
}
|
||||
cmd := exec.Command(studioPython(dir), "main.py",
|
||||
"--listen", "0.0.0.0", "--port", "8188",
|
||||
"--normalvram", "--disable-auto-launch",
|
||||
"--listen", "127.0.0.1", "--port", "8188", "--worker-mode",
|
||||
vramMode, "--disable-auto-launch",
|
||||
"--output-directory", filepath.Join(dir, "output"))
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(),
|
||||
|
||||
@@ -350,3 +350,9 @@ func s2sBillingCall(c *zip.Ctx) bool {
|
||||
bearer := strings.TrimSpace(strings.TrimPrefix(c.Header("Authorization"), "Bearer "))
|
||||
return bearer != "" && subtle.ConstantTimeCompare([]byte(bearer), []byte(token)) == 1
|
||||
}
|
||||
|
||||
// IsServiceToken is the exported view of s2sBillingCall — whether the request is a trusted
|
||||
// in-proc S2S caller bearing the verified COMMERCE_SERVICE_TOKEN. Used by co-resident route
|
||||
// gates (e.g. the spend-alert admin gate) that must admit the metering cap-gate and the
|
||||
// SuperAdmin cap-oversight Forward alongside org admins, while refusing a plain member.
|
||||
func IsServiceToken(c *zip.Ctx) bool { return s2sBillingCall(c) }
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// billing_coresident.go — PinBillingSubject, the subject-pinning middleware that lets
|
||||
// commerce's OWN billing READ handlers serve co-resident in the unified cloud binary.
|
||||
//
|
||||
// WHY IT EXISTS. Co-resident, commerce is EMBEDDED (apps/commerce.go mountCommerce →
|
||||
// commerce.Embed on the shared zip app), and in prod there is NO standalone commerce
|
||||
// backend — the in-cluster `commerce` Service selects the cloud pods themselves. So the
|
||||
// /v1/billing/* bridge (billing.go), which forwards to COMMERCE_URL, has nowhere to send
|
||||
// a read but back into cloud: the default base (the public api.hanzo.ai edge) re-enters
|
||||
// the same bridge in an unbounded self-dispatch loop that surfaces as a 502
|
||||
// ("billing upstream unreachable: Get https://api.hanzo.ai/v1/billing/<path>"). The fix
|
||||
// is to serve those reads co-resident from the embedded commerce — the same co-resident
|
||||
// move mountCommerce already makes for GET /v1/billing/plans — so the specific route
|
||||
// shadows the bridge wildcard (order 100 < 122) and never leaves the process.
|
||||
//
|
||||
// WHAT IT GUARANTEES. commerce's read handlers scope to the org by NAMESPACE (from the
|
||||
// gateway-validated X-Org-Id via iammiddleware) but filter finer scope (the billing
|
||||
// subject) only from a query param — an UNPINNED ListInvoices returns every user's rows
|
||||
// in the org namespace. The /v1/billing/* bridge is what pins that subject today; this
|
||||
// middleware carries the SAME pin onto the co-resident route so the isolation is
|
||||
// byte-for-byte the shipped behavior. It is the ONE subject rule (account.Payer, the same
|
||||
// function the ai spend-gate and the top-up resolve), fed the account the credential NAMES
|
||||
// — so a read scopes to exactly the account the gate debits, never wider.
|
||||
package account
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
|
||||
"github.com/hanzoai/account"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// PinBillingSubject pins every billing subject key in the request query to the VALIDATED
|
||||
// caller's own subject (dropping ?org), so a co-resident commerce read handler downstream
|
||||
// can only ever return the caller's OWN rows. It is the co-resident twin of billingData's
|
||||
// subject-pinning, reusing the SAME resolveCaller → account.Payer rule and the SAME
|
||||
// scopedBillingSearch, so the two paths scope identically.
|
||||
//
|
||||
// Three cases, mirroring billingData exactly:
|
||||
// - Browser customer (a validated principal with an org): OVERWRITE the subject keys
|
||||
// with the caller's own subject and drop ?org. The client cannot widen scope.
|
||||
// - Trusted in-proc S2S (the verified COMMERCE_SERVICE_TOKEN bearer, carrying its own
|
||||
// X-Org-Id): pass the query through VERBATIM — it legitimately names its own subject,
|
||||
// scoped by the EdgeAuth-controlled org.
|
||||
// - Neither: refuse. A bearer-less request with a forged X-Org-Id has no validated
|
||||
// principal and is fail-closed here, before the read handler runs.
|
||||
//
|
||||
// The pin rewrites the request URI's query string in place; fasthttp's SetQueryString
|
||||
// resets the parsed-args cache, so the handler's later c.Query() reads the pinned values.
|
||||
func PinBillingSubject() zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
inQuery, _ := url.ParseQuery(string(c.Fiber().Request().URI().QueryString()))
|
||||
|
||||
cr, ok := resolveCaller(c, true)
|
||||
if !ok {
|
||||
// Not a validated customer — admit ONLY a trusted in-proc S2S caller that
|
||||
// names its own org (same admission billingData makes), leaving its query
|
||||
// untouched. Everything else is refused before the read runs.
|
||||
if s2sBillingCall(c) && c.Org() != "" {
|
||||
return c.Next()
|
||||
}
|
||||
return zip.ErrForbidden("sign in to view billing")
|
||||
}
|
||||
|
||||
subject := account.Payer(account.Credential{
|
||||
Owner: cr.owner,
|
||||
Name: cr.username,
|
||||
Account: principal.BillingAccount(c),
|
||||
}).Subject()
|
||||
|
||||
c.Fiber().Request().URI().SetQueryString(scopedBillingSearch(inQuery, subject).Encode())
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
luxlog "github.com/luxfi/log"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// billing_coresident_test.go — proves PinBillingSubject carries the SAME tenant scoping
|
||||
// onto a co-resident commerce read handler that billingData applies on the bridge: the
|
||||
// caller's own subject is pinned into the query (dropping ?org), an unvalidated caller is
|
||||
// refused before the handler runs, and a trusted in-proc S2S caller passes through
|
||||
// verbatim. This is what lets commerce's ListInvoices/ListBillingSubscriptions/... serve
|
||||
// in-process without leaking another subject's rows.
|
||||
|
||||
// echoQuery is the downstream stand-in for a commerce read handler: it reports exactly the
|
||||
// query it observes AFTER the pin, so a test can assert the subject the handler would filter on.
|
||||
func echoQuery(c *zip.Ctx) error {
|
||||
return c.JSON(200, map[string]any{
|
||||
"user": c.Query("user"),
|
||||
"userId": c.Query("userId"),
|
||||
"customerId": c.Query("customerId"),
|
||||
"org": c.Query("org"),
|
||||
"status": c.Query("status"),
|
||||
})
|
||||
}
|
||||
|
||||
func pinApp(t *testing.T) *zip.App {
|
||||
t.Helper()
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
// MountAccount installs the identity middleware PinBillingSubject relies on; mounting
|
||||
// it keeps the probe on the same trust plane as the real co-resident registration.
|
||||
if err := MountAccount(app, cloud.Deps{Logger: luxlog.New("test"), Brand: "hanzo"}); err != nil {
|
||||
t.Fatalf("MountAccount: %v", err)
|
||||
}
|
||||
app.Get("/probe", PinBillingSubject(), echoQuery)
|
||||
return app
|
||||
}
|
||||
|
||||
// TestPinBillingSubject_PinsCallerAndDropsOrg — a validated customer's forged subject
|
||||
// keys are overwritten with its OWN subject and ?org is dropped, exactly like the bridge.
|
||||
func TestPinBillingSubject_PinsCallerAndDropsOrg(t *testing.T) {
|
||||
app := pinApp(t)
|
||||
code, body := callH(t, app, http.MethodGet,
|
||||
"/probe?userId=victim&customerId=victim&user=victim&org=othercorp&status=open", alice, "")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d (%s)", code, body)
|
||||
}
|
||||
var got map[string]string
|
||||
if err := json.Unmarshal(body, &got); err != nil {
|
||||
t.Fatalf("bad body: %s", body)
|
||||
}
|
||||
for _, k := range billingSubjectKeys {
|
||||
if got[k] != "acme" { // alice/acme resolves to the org subject "acme"
|
||||
t.Fatalf("handler must see %s=acme (caller's own subject), got %q", k, got[k])
|
||||
}
|
||||
}
|
||||
if got["org"] != "" {
|
||||
t.Fatalf("org must be dropped, handler saw org=%q", got["org"])
|
||||
}
|
||||
if got["status"] != "open" {
|
||||
t.Fatalf("non-subject filter must survive, got status=%q", got["status"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestPinBillingSubject_RefusesUnvalidated — a forged X-Org-Id with NO validated
|
||||
// X-User-Id (and no service token) is refused before the read handler runs: no
|
||||
// cross-tenant billing read is possible.
|
||||
func TestPinBillingSubject_RefusesUnvalidated(t *testing.T) {
|
||||
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-tok")
|
||||
app := pinApp(t)
|
||||
code, _ := callH(t, app, http.MethodGet, "/probe?userId=victim",
|
||||
map[string]string{"X-Org-Id": "victim"}, "")
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("unvalidated caller: want 403, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPinBillingSubject_S2SForwardsVerbatim — the trusted in-proc S2S caller (verified
|
||||
// COMMERCE_SERVICE_TOKEN + its own X-Org-Id, no validated user) is admitted and its query
|
||||
// is left UNTOUCHED, so it can name its own subject (the cap-gate's authorize read).
|
||||
func TestPinBillingSubject_S2SForwardsVerbatim(t *testing.T) {
|
||||
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-tok")
|
||||
app := pinApp(t)
|
||||
code, body := callH(t, app, http.MethodGet, "/probe?user=acme&status=open",
|
||||
map[string]string{"Authorization": "Bearer svc-tok", "X-Org-Id": "acme"}, "")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("S2S: want 200, got %d (%s)", code, body)
|
||||
}
|
||||
var got map[string]string
|
||||
_ = json.Unmarshal(body, &got)
|
||||
if got["user"] != "acme" || got["status"] != "open" {
|
||||
t.Fatalf("S2S query must pass through verbatim, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPinBillingSubject_S2SNoOrgRefused — the service token with NO X-Org-Id has no org
|
||||
// to scope the privileged read and is refused (matches billingData's s2s org requirement).
|
||||
func TestPinBillingSubject_S2SNoOrgRefused(t *testing.T) {
|
||||
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-tok")
|
||||
app := pinApp(t)
|
||||
code, _ := callH(t, app, http.MethodGet, "/probe",
|
||||
map[string]string{"Authorization": "Bearer svc-tok"}, "")
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("S2S without X-Org-Id: want 403, got %d", code)
|
||||
}
|
||||
}
|
||||
+27
-20
@@ -90,36 +90,43 @@ func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
// control plane behind core.Guard. Each carved-out domain (audit/customer/revenue/finance)
|
||||
// owns its own route registration.
|
||||
func routes(app *zip.App, s *cloud.Service[core.State]) {
|
||||
g := app.Group("/v1/admin")
|
||||
// Org-scoped panels — GuardScoped. Cross-tenant reads are impossible for a non-super
|
||||
// caller.
|
||||
app.Get("/v1/admin/me", core.GuardScoped(s, me))
|
||||
app.Get("/v1/admin/overview", core.GuardScoped(s, overview))
|
||||
app.Get("/v1/admin/orgs", core.GuardScoped(s, orgs))
|
||||
app.Get("/v1/admin/users", core.GuardScoped(s, users))
|
||||
app.Get("/v1/admin/usage", core.GuardScoped(s, usage))
|
||||
g.Get("/me", core.GuardScoped(s, me))
|
||||
g.Get("/overview", core.GuardScoped(s, overview))
|
||||
g.Get("/orgs", core.GuardScoped(s, orgs))
|
||||
g.Get("/users", core.GuardScoped(s, users))
|
||||
g.Get("/usage", core.GuardScoped(s, usage))
|
||||
// Platform reads — SuperAdmin only (cross-tenant by nature).
|
||||
app.Get("/v1/admin/roles", core.Guard(s, roles))
|
||||
app.Get("/v1/admin/applications", core.Guard(s, applications))
|
||||
app.Get("/v1/admin/products", core.Guard(s, products))
|
||||
app.Get("/v1/admin/compute", core.Guard(s, compute))
|
||||
app.Get("/v1/admin/o11y", core.Guard(s, o11y))
|
||||
app.Post("/v1/admin/sync", core.Guard(s, syncNow))
|
||||
g.Get("/roles", core.Guard(s, roles))
|
||||
g.Get("/applications", core.Guard(s, applications))
|
||||
g.Get("/products", core.Guard(s, products))
|
||||
g.Get("/compute", core.Guard(s, compute))
|
||||
g.Get("/o11y", core.Guard(s, o11y))
|
||||
g.Get("/aimetrics", core.Guard(s, aimetrics))
|
||||
g.Post("/sync", core.Guard(s, syncNow))
|
||||
|
||||
// Credit grants — the ONE admin mint surface (SuperAdmin only). Thin, audited
|
||||
// relay to commerce's mint-gated POST /v1/billing/credit-grants; commerce is the
|
||||
// sole ledger. See creditgrant.go.
|
||||
g.Post("/credit-grants", core.Guard(s, createCreditGrant))
|
||||
|
||||
// Product analytics — org-scoped (SuperAdmin: all-orgs; org admin: their own org).
|
||||
app.Get("/v1/admin/analytics", core.GuardScoped(s, analytics))
|
||||
g.Get("/analytics", core.GuardScoped(s, analytics))
|
||||
// Bases — the tenant Base-instance panel, org-scoped (bases.go).
|
||||
app.Get("/v1/admin/bases", core.GuardScoped(s, bases))
|
||||
g.Get("/bases", core.GuardScoped(s, bases))
|
||||
|
||||
// ── Platform control plane — SuperAdmin ONLY (launch/release/flags + access). ──
|
||||
app.Get("/v1/admin/flags", core.Guard(s, flagsBoard))
|
||||
app.Put("/v1/admin/flags/:key", core.Guard(s, setFlag))
|
||||
g.Get("/flags", core.Guard(s, flagsBoard))
|
||||
g.Put("/flags/:key", core.Guard(s, setFlag))
|
||||
// Launch-control services board — the waitlist-mode lens on the flag engine (twin
|
||||
// of /v1/admin/flags), reading the registry + decide the admission gate owns.
|
||||
app.Get("/v1/admin/services", core.Guard(s, services))
|
||||
app.Post("/v1/admin/services", core.Guard(s, upsertService))
|
||||
app.Post("/v1/admin/services/:service/mode", core.Guard(s, setServiceMode))
|
||||
app.Get("/v1/admin/waitlist", core.Guard(s, waitlist))
|
||||
app.Post("/v1/admin/waitlist/boost", core.Guard(s, waitlistBoost))
|
||||
g.Get("/services", core.Guard(s, services))
|
||||
g.Post("/services", core.Guard(s, upsertService))
|
||||
g.Post("/services/:service/mode", core.Guard(s, setServiceMode))
|
||||
g.Get("/waitlist", core.Guard(s, waitlist))
|
||||
g.Post("/waitlist/boost", core.Guard(s, waitlistBoost))
|
||||
|
||||
// Usage-cap + promo control plane (promos platform-only; spend-caps org-scoped).
|
||||
limitRoutes(app, s)
|
||||
|
||||
@@ -24,16 +24,16 @@ import (
|
||||
// mount builds a zip app with admin mounted against the given upstream bases,
|
||||
// and returns a `do` helper that issues test requests through the whole app.
|
||||
func mount(t *testing.T, iamURL, commerceURL, healthURL string) func(method, path string, hdr map[string]string) (*http.Response, []byte) {
|
||||
do, _, _ := mountSvc(t, iamURL, commerceURL, healthURL)
|
||||
do, _, _ := mountService(t, iamURL, commerceURL, healthURL)
|
||||
return do
|
||||
}
|
||||
|
||||
// mountSvc is mount but also returns the underlying cloud.Service[state] (so finance tests can swap
|
||||
// mountService is mount but also returns the underlying cloud.Service[state] (so finance tests can swap
|
||||
// in a fake DigitalOcean client, and the cockpit tests can attach an audit store)
|
||||
// AND the raw fiber app (so tests that need a request BODY can drive it directly —
|
||||
// the returned `do` sends a nil body). The handlers read s.* live at request time,
|
||||
// so an override before issuing a request takes effect.
|
||||
func mountSvc(t *testing.T, iamURL, commerceURL, healthURL string) (func(method, path string, hdr map[string]string) (*http.Response, []byte), *cloud.Service[core.State], *fiber.App) {
|
||||
func mountService(t *testing.T, iamURL, commerceURL, healthURL string) (func(method, path string, hdr map[string]string) (*http.Response, []byte), *cloud.Service[core.State], *fiber.App) {
|
||||
t.Helper()
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
s := &cloud.Service[core.State]{State: core.State{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,9 @@ import (
|
||||
|
||||
// Routes registers the /v1/admin/audit* surface (SuperAdmin only).
|
||||
func Routes(app *zip.App, s *cloud.Service[core.State]) {
|
||||
app.Get("/v1/admin/audit", core.Guard(s, Records))
|
||||
app.Get("/v1/admin/audit/verify", core.Guard(s, Verify))
|
||||
g := app.Group("/v1/admin")
|
||||
g.Get("/audit", core.Guard(s, Records))
|
||||
g.Get("/audit/verify", core.Guard(s, Verify))
|
||||
}
|
||||
|
||||
// Records answers GET /v1/admin/audit from cloud's local tamper-evident store when
|
||||
|
||||
@@ -203,7 +203,7 @@ func newCockpitFakes(t *testing.T) *cockpitFakes {
|
||||
}
|
||||
}))
|
||||
|
||||
_, s, fa := mountSvc(t, f.iam.URL, f.commerce.URL, "")
|
||||
_, s, fa := mountService(t, f.iam.URL, f.commerce.URL, "")
|
||||
f.service = s
|
||||
f.do = func(method, path string, hdr map[string]string, body string) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/admin/money"
|
||||
"github.com/hanzoai/cloud/clients/commerceinproc"
|
||||
)
|
||||
|
||||
// errUnconfigured marks a write (Deposit) attempted against an unwired commerce.
|
||||
@@ -45,12 +46,17 @@ type Client struct {
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// New builds a commerce client for base + admin S2S token.
|
||||
// New builds a commerce client for base + admin S2S token. The HTTP client uses the
|
||||
// commerceinproc self-routing transport: when commerce is CO-RESIDENT (base is the
|
||||
// commerce.inproc placeholder) it dispatches in-process — a plain http.Client would
|
||||
// instead DNS-resolve "commerce.inproc" and fail "no such host", silently breaking the
|
||||
// admin cost/finance god-view. For a split-deploy (a real commerce URL) it falls
|
||||
// through to plain HTTP unchanged.
|
||||
func New(base, token string) *Client {
|
||||
return &Client{
|
||||
base: strings.TrimRight(strings.TrimSpace(base), "/"),
|
||||
token: strings.TrimSpace(token),
|
||||
http: &http.Client{Timeout: 15 * time.Second},
|
||||
http: commerceinproc.Client(15 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,235 +301,16 @@ func (c *Client) Deposit(ctx context.Context, subject string, amount money.Cents
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ── SaaS-metrics god-view (fleet-wide, org-independent) ──────────────────────
|
||||
|
||||
// SaaSMetrics mirrors commerce's GET /v1/metrics/saas snapshot — the whole-business
|
||||
// SaaS-operations aggregate (MRR/ARR, new/churn, plan mix, top customers, recent
|
||||
// movements) computed IN commerce across every org namespace. It is org-INDEPENDENT
|
||||
// (like Costs) so the reader sends NO subject. Only the fields the admin god-view
|
||||
// renders are modeled; commerce fields we don't consume (upgrades/downgrades,
|
||||
// untagged-request counts) are simply ignored by the decoder.
|
||||
type SaaSMetrics struct {
|
||||
AsOf string `json:"asOf"`
|
||||
Currency string `json:"currency"`
|
||||
Window string `json:"window"`
|
||||
Revenue SaaSRevenue `json:"revenue"`
|
||||
Subs SaaSSubs `json:"subscriptions"`
|
||||
Usage SaaSUsage `json:"usage"`
|
||||
Customers []SaaSCustomer `json:"customers"`
|
||||
Orgs int `json:"orgs"`
|
||||
Gaps []string `json:"gaps"`
|
||||
}
|
||||
|
||||
// SaaSRevenue is the recurring-revenue headline (run-rate MRR/ARR + windowed movement).
|
||||
type SaaSRevenue struct {
|
||||
MRRCents money.Cents `json:"mrrCents"`
|
||||
ARRCents money.Cents `json:"arrCents"`
|
||||
ActiveSubscriptions int `json:"activeSubscriptions"`
|
||||
PayingCustomers int `json:"payingCustomers"`
|
||||
Trials int `json:"trials"`
|
||||
NewMRRCents money.Cents `json:"newMrrCents"`
|
||||
ChurnedMRRCents money.Cents `json:"churnedMrrCents"`
|
||||
NetNewMRRCents money.Cents `json:"netNewMrrCents"`
|
||||
ByCategory []SaaSCategory `json:"byCategory"`
|
||||
}
|
||||
|
||||
// SaaSCategory is one plan-category bucket of run-rate MRR (the plan mix).
|
||||
type SaaSCategory struct {
|
||||
Category string `json:"category"`
|
||||
MRRCents money.Cents `json:"mrrCents"`
|
||||
Subscriptions int `json:"subscriptions"`
|
||||
}
|
||||
|
||||
// SaaSSubs is the subscription-operations panel (per-plan mix, trials, new/canceled,
|
||||
// recent movements).
|
||||
type SaaSSubs struct {
|
||||
ByPlan []SaaSPlan `json:"byPlan"`
|
||||
TrialsActive int `json:"trialsActive"`
|
||||
New int `json:"new"`
|
||||
Canceled int `json:"canceled"`
|
||||
Recent []SaaSEvent `json:"recent"`
|
||||
}
|
||||
|
||||
// SaaSPlan is one plan's active/trialing counts, seats, and MRR contribution.
|
||||
type SaaSPlan struct {
|
||||
Plan string `json:"plan"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Active int `json:"active"`
|
||||
Trialing int `json:"trialing"`
|
||||
Seats int `json:"seats"`
|
||||
MRRCents money.Cents `json:"mrrCents"`
|
||||
}
|
||||
|
||||
// SaaSEvent is one recent subscription movement ("created" or "canceled").
|
||||
type SaaSEvent struct {
|
||||
At string `json:"at"`
|
||||
Org string `json:"org"`
|
||||
Type string `json:"type"`
|
||||
Plan string `json:"plan"`
|
||||
Category string `json:"category"`
|
||||
MRRDeltaCents money.Cents `json:"mrrDeltaCents"`
|
||||
}
|
||||
|
||||
// SaaSUsage is the metered / pay-as-you-go revenue headline for the window.
|
||||
type SaaSUsage struct {
|
||||
Instrumented bool `json:"instrumented"`
|
||||
WindowUsageCents money.Cents `json:"windowUsageCents"`
|
||||
Requests int64 `json:"requests"`
|
||||
}
|
||||
|
||||
// SaaSCustomer is one top customer by MRR + windowed usage.
|
||||
type SaaSCustomer struct {
|
||||
Org string `json:"org"`
|
||||
Plan string `json:"plan"`
|
||||
Category string `json:"category"`
|
||||
Status string `json:"status"`
|
||||
MRRCents money.Cents `json:"mrrCents"`
|
||||
UsageCents money.Cents `json:"usageCents"`
|
||||
Seats int `json:"seats"`
|
||||
Since string `json:"since,omitempty"`
|
||||
}
|
||||
|
||||
// Metrics reads the fleet SaaS-operations god-view (GET /v1/metrics/saas). Like Costs it
|
||||
// is org-INDEPENDENT — the engine walks every org namespace itself — so it authenticates
|
||||
// with the admin S2S service token and sends NO subject. Empty (not an error) when
|
||||
// commerce is unwired, so a partial deploy degrades to an honest empty snapshot.
|
||||
func (c *Client) Metrics(ctx context.Context, window string, limit int) (SaaSMetrics, error) {
|
||||
var out SaaSMetrics
|
||||
// CreateCreditGrant forwards a credit-grant request verbatim to commerce's
|
||||
// mint-gated POST /v1/billing/credit-grants (CreateCreditGrant), authenticated
|
||||
// by the admin service token, with subject as the target-org namespace selector.
|
||||
// Commerce is the sole credit-grant ledger; this relays its contract untouched
|
||||
// (the raw response is returned to the caller) so the admin surface stays thin.
|
||||
func (c *Client) CreateCreditGrant(ctx context.Context, subject string, body []byte, idempotencyKey string) ([]byte, error) {
|
||||
if !c.Ready() {
|
||||
return out, nil
|
||||
return nil, errUnconfigured
|
||||
}
|
||||
q := url.Values{}
|
||||
if window != "" {
|
||||
q.Set("window", window)
|
||||
}
|
||||
if limit > 0 {
|
||||
q.Set("limit", fmt.Sprintf("%d", limit))
|
||||
}
|
||||
body, err := c.get(ctx, "/v1/metrics/saas", q, "")
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return out, fmt.Errorf("commerce metrics decode: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ── billing invoices + subscriptions (per-subject fleet rows) ────────────────
|
||||
|
||||
// Invoice is one issued invoice as the fleet god-view renders it: the id (for a future
|
||||
// /v1/billing/invoices/:id detail fetch), the human number, status, amount due,
|
||||
// currency, and the issue/due dates. Sourced from GET /v1/billing/invoices
|
||||
// (invoiceResponse); all timestamps are RFC3339 strings.
|
||||
type Invoice struct {
|
||||
ID string `json:"id"`
|
||||
Number string `json:"numberStr"`
|
||||
Status string `json:"status"`
|
||||
AmountDue money.Cents `json:"amountDue"`
|
||||
Currency string `json:"currency"`
|
||||
Issued string `json:"createdAt"`
|
||||
Due string `json:"dueDate"`
|
||||
}
|
||||
|
||||
// Invoices lists a subject's invoices (GET /v1/billing/invoices), optionally filtered by
|
||||
// status. The subject selects the org's billing namespace via X-Org-Id (trusted only
|
||||
// after the service-token bearer verifies). Empty (not an error) when commerce is unwired.
|
||||
func (c *Client) Invoices(ctx context.Context, subject, status string) ([]Invoice, error) {
|
||||
if !c.Ready() {
|
||||
return nil, nil
|
||||
}
|
||||
q := url.Values{}
|
||||
if status != "" {
|
||||
q.Set("status", status)
|
||||
}
|
||||
body, err := c.get(ctx, "/v1/billing/invoices", q, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var wrap struct {
|
||||
Invoices []Invoice `json:"invoices"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &wrap); err != nil {
|
||||
return nil, fmt.Errorf("commerce invoices decode: %w", err)
|
||||
}
|
||||
return wrap.Invoices, nil
|
||||
}
|
||||
|
||||
// Subscription is one subscription row the fleet god-view renders: the id, the buyer
|
||||
// (userId), plan tier, status, monthly-normalized MRR, and the current-period
|
||||
// start/end (started/renews). MRR reuses monthlyNormalized so a yearly plan is
|
||||
// comparable to a monthly one in the fleet total.
|
||||
type Subscription struct {
|
||||
ID string `json:"id"`
|
||||
User string `json:"user"`
|
||||
Plan string `json:"plan"`
|
||||
Status string `json:"status"`
|
||||
MRR money.Cents `json:"mrrCents"`
|
||||
Started string `json:"started"`
|
||||
Renews string `json:"renews"`
|
||||
}
|
||||
|
||||
// subscriptionRowWire is the /v1/billing/subscriptions row shape the fleet view folds —
|
||||
// richer than subscriptionsWire (which Plan() uses for the MRR sum alone).
|
||||
type subscriptionRowWire struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
PlanID string `json:"planId"`
|
||||
Status string `json:"status"`
|
||||
Created string `json:"createdAt"`
|
||||
PeriodStart string `json:"currentPeriodStart"`
|
||||
PeriodEnd string `json:"currentPeriodEnd"`
|
||||
Plan struct {
|
||||
Name string `json:"name"`
|
||||
Price money.Cents `json:"price"`
|
||||
Interval string `json:"interval"`
|
||||
} `json:"plan"`
|
||||
}
|
||||
|
||||
// Subscriptions lists a subject's subscriptions (GET /v1/billing/subscriptions),
|
||||
// optionally filtered by status, as fleet rows with a monthly-normalized MRR. Empty (not
|
||||
// an error) when commerce is unwired.
|
||||
func (c *Client) Subscriptions(ctx context.Context, subject, status string) ([]Subscription, error) {
|
||||
if !c.Ready() {
|
||||
return nil, nil
|
||||
}
|
||||
q := url.Values{}
|
||||
if status != "" {
|
||||
q.Set("status", status)
|
||||
}
|
||||
body, err := c.get(ctx, "/v1/billing/subscriptions", q, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var wrap struct {
|
||||
Subscriptions []subscriptionRowWire `json:"subscriptions"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &wrap); err != nil {
|
||||
return nil, fmt.Errorf("commerce subscriptions decode: %w", err)
|
||||
}
|
||||
out := make([]Subscription, 0, len(wrap.Subscriptions))
|
||||
for _, s := range wrap.Subscriptions {
|
||||
name := strings.TrimSpace(s.Plan.Name)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(s.PlanID)
|
||||
}
|
||||
started := strings.TrimSpace(s.Created)
|
||||
if started == "" {
|
||||
started = s.PeriodStart
|
||||
}
|
||||
out = append(out, Subscription{
|
||||
ID: s.ID,
|
||||
User: s.UserID,
|
||||
Plan: name,
|
||||
Status: s.Status,
|
||||
MRR: monthlyNormalized(s.Plan.Price, s.Plan.Interval),
|
||||
Started: started,
|
||||
Renews: s.PeriodEnd,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
return c.post(ctx, "/v1/billing/credit-grants", subject, body, idempotencyKey)
|
||||
}
|
||||
|
||||
// post performs one admin-authenticated commerce POST (JSON body) and returns the
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -10,11 +10,12 @@ import (
|
||||
// precedes the :org param route; the write actions are POST (distinct method), so none
|
||||
// collide. The grants ledger + the org-in-body issue-grant share the ONE credit path.
|
||||
func Routes(app *zip.App, s *cloud.Service[core.State]) {
|
||||
app.Get("/v1/admin/customers", core.Guard(s, Customers))
|
||||
app.Get("/v1/admin/customers/:org", core.Guard(s, CustomerDetail))
|
||||
app.Post("/v1/admin/customers/:org/credit", core.Guard(s, GrantCredit))
|
||||
app.Get("/v1/admin/grants", core.Guard(s, Grants))
|
||||
app.Post("/v1/admin/grants", core.Guard(s, IssueGrant))
|
||||
app.Post("/v1/admin/customers/:org/suspend", core.Guard(s, SuspendCustomer))
|
||||
app.Post("/v1/admin/customers/:org/reactivate", core.Guard(s, ReactivateCustomer))
|
||||
g := app.Group("/v1/admin")
|
||||
g.Get("/customers", core.Guard(s, Customers))
|
||||
g.Get("/customers/:org", core.Guard(s, CustomerDetail))
|
||||
g.Post("/customers/:org/credit", core.Guard(s, GrantCredit))
|
||||
g.Get("/grants", core.Guard(s, Grants))
|
||||
g.Post("/grants", core.Guard(s, IssueGrant))
|
||||
g.Post("/customers/:org/suspend", core.Guard(s, SuspendCustomer))
|
||||
g.Post("/customers/:org/reactivate", core.Guard(s, ReactivateCustomer))
|
||||
}
|
||||
|
||||
@@ -28,16 +28,17 @@ var errUnconfigured = errors.New("not configured")
|
||||
|
||||
// Routes registers the finance dashboard (SuperAdmin only).
|
||||
func Routes(app *zip.App, s *cloud.Service[core.State]) {
|
||||
app.Get("/v1/admin/finance", core.Guard(s, Finance))
|
||||
g := app.Group("/v1/admin")
|
||||
g.Get("/finance", core.Guard(s, Finance))
|
||||
// One-time commerce→finance balance cutover (SuperAdmin only). Idempotent per org.
|
||||
app.Post("/v1/admin/finance/backfill", core.Guard(s, Backfill))
|
||||
g.Post("/finance/backfill", core.Guard(s, Backfill))
|
||||
// Fund an ARBITRARY subject's native wallet — an org pool or a human ("hanzo/z").
|
||||
// SuperAdmin only; additive (grants stack).
|
||||
app.Post("/v1/admin/finance/deposit", core.Guard(s, Deposit))
|
||||
g.Post("/finance/deposit", core.Guard(s, Deposit))
|
||||
// Per-provider upstream credit ledger + usage funding split (multi-provider
|
||||
// credit-management). Same SuperAdmin guard, same cloud_usage warehouse.
|
||||
app.Get("/v1/admin/providers/credit", core.Guard(s, ProvidersCredit))
|
||||
app.Get("/v1/admin/usage/funding", core.Guard(s, UsageFunding))
|
||||
g.Get("/providers/credit", core.Guard(s, ProvidersCredit))
|
||||
g.Get("/usage/funding", core.Guard(s, UsageFunding))
|
||||
}
|
||||
|
||||
// FinanceData is the full /v1/admin/finance aggregate.
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
|
||||
// The finance PURE-math derivation tests (ComputeFinance / AvgDailyBurnCents) live with
|
||||
// the handler in clients/admin/finance. These are the INTEGRATION tests that drive GET
|
||||
// /v1/admin/finance through the shared admin mount harness (mountSvc + fake IAM/commerce/DO).
|
||||
// /v1/admin/finance through the shared admin mount harness (mountService + fake IAM/commerce/DO).
|
||||
|
||||
// newFakeDO serves the DO billing API with fixed decimal-dollar strings so the
|
||||
// finance aggregation is deterministic. account_balance is NEGATIVE (credit held).
|
||||
@@ -49,7 +49,7 @@ func TestFinance_RealAggregation(t *testing.T) {
|
||||
do := newFakeDO()
|
||||
defer do.Close()
|
||||
|
||||
doReq, s, _ := mountSvc(t, iam.server.URL, commerce.URL, "")
|
||||
doReq, s, _ := mountService(t, iam.server.URL, commerce.URL, "")
|
||||
s.State.DO = digitalocean.NewWithBase(do.URL, "test-do-token") // configured DO client
|
||||
admin := map[string]string{
|
||||
"X-User-IsAdmin": "true", "X-Org-Id": "admin",
|
||||
@@ -151,7 +151,7 @@ func TestFinance_HonestUnconfiguredDO(t *testing.T) {
|
||||
commerce := newFakeCommerceFinance()
|
||||
defer commerce.Close()
|
||||
|
||||
doReq, _, _ := mountSvc(t, iam.server.URL, commerce.URL, "") // s.do already has empty token → unconfigured
|
||||
doReq, _, _ := mountService(t, iam.server.URL, commerce.URL, "") // s.do already has empty token → unconfigured
|
||||
admin := map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "admin"}
|
||||
|
||||
resp, body := doReq("GET", "/v1/admin/finance", admin)
|
||||
@@ -212,7 +212,7 @@ func TestFinance_RevenueSourceDown_NoFabrication(t *testing.T) {
|
||||
defer commerce.Close()
|
||||
|
||||
// IAM points nowhere reachable → listOrgs errors; commerce /v1/costs still 200s.
|
||||
doReq, _, _ := mountSvc(t, "http://127.0.0.1:0", commerce.URL, "")
|
||||
doReq, _, _ := mountService(t, "http://127.0.0.1:0", commerce.URL, "")
|
||||
admin := map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "admin"}
|
||||
|
||||
resp, body := doReq("GET", "/v1/admin/finance", admin)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -8,5 +8,6 @@ import (
|
||||
|
||||
// Routes registers the fleet invoice view (SuperAdmin only, cross-tenant).
|
||||
func Routes(app *zip.App, s *cloud.Service[core.State]) {
|
||||
app.Get("/v1/admin/invoices", core.Guard(s, Invoices))
|
||||
g := app.Group("/v1/admin")
|
||||
g.Get("/invoices", core.Guard(s, Invoices))
|
||||
}
|
||||
|
||||
@@ -25,17 +25,18 @@ import (
|
||||
|
||||
// limitRoutes registers the promo + cap control plane. Called from routes().
|
||||
func limitRoutes(app *zip.App, s *cloud.Service[core.State]) {
|
||||
g := app.Group("/v1/admin")
|
||||
// Platform plan promo — SuperAdmin only.
|
||||
app.Get("/v1/admin/promos", core.Guard(s, getPromo))
|
||||
app.Put("/v1/admin/promos", core.Guard(s, putPromo))
|
||||
g.Get("/promos", core.Guard(s, getPromo))
|
||||
g.Put("/promos", core.Guard(s, putPromo))
|
||||
|
||||
// Per-org usage-cap oversight/override — SuperAdmin (any org via ?org=) or an org
|
||||
// admin (own org only). Reuses the customer's OWN self-service spend-alert CRUD,
|
||||
// so a platform override and a customer edit are the same rows.
|
||||
app.Get("/v1/admin/spend-caps", core.GuardScoped(s, listSpendCaps))
|
||||
app.Post("/v1/admin/spend-caps", core.GuardScoped(s, createSpendCap))
|
||||
app.Patch("/v1/admin/spend-caps/:id", core.GuardScoped(s, updateSpendCap))
|
||||
app.Delete("/v1/admin/spend-caps/:id", core.GuardScoped(s, deleteSpendCap))
|
||||
g.Get("/spend-caps", core.GuardScoped(s, listSpendCaps))
|
||||
g.Post("/spend-caps", core.GuardScoped(s, createSpendCap))
|
||||
g.Patch("/spend-caps/:id", core.GuardScoped(s, updateSpendCap))
|
||||
g.Delete("/spend-caps/:id", core.GuardScoped(s, deleteSpendCap))
|
||||
}
|
||||
|
||||
// getPromo returns the current platform plan promo. X-Org-Id is the admin org —
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,5 +9,6 @@ import (
|
||||
// Routes registers the SaaS-metrics god-view (SuperAdmin only, cross-tenant business
|
||||
// aggregate).
|
||||
func Routes(app *zip.App, s *cloud.Service[core.State]) {
|
||||
app.Get("/v1/admin/metrics", core.Guard(s, Metrics))
|
||||
g := app.Group("/v1/admin")
|
||||
g.Get("/metrics", core.Guard(s, Metrics))
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ import (
|
||||
|
||||
// Routes registers the fleet revenue board (SuperAdmin only, cross-tenant profitability).
|
||||
func Routes(app *zip.App, s *cloud.Service[core.State]) {
|
||||
app.Get("/v1/admin/revenue", core.Guard(s, Revenue))
|
||||
g := app.Group("/v1/admin")
|
||||
g.Get("/revenue", core.Guard(s, Revenue))
|
||||
}
|
||||
|
||||
// RevenueCustomer is one row of the per-customer revenue table.
|
||||
|
||||
@@ -8,5 +8,6 @@ import (
|
||||
|
||||
// Routes registers the fleet subscription view (SuperAdmin only, cross-tenant).
|
||||
func Routes(app *zip.App, s *cloud.Service[core.State]) {
|
||||
app.Get("/v1/admin/subscriptions", core.Guard(s, Subscriptions))
|
||||
g := app.Group("/v1/admin")
|
||||
g.Get("/subscriptions", core.Guard(s, Subscriptions))
|
||||
}
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
// Package subscriptions is the fleet SUBSCRIPTION view (/v1/admin/subscriptions) —
|
||||
// every tenant's plan subscription: customer/org, plan, status, monthly-normalized MRR,
|
||||
// and the current-period start/renews. SuperAdmin only (core.Guard).
|
||||
// every tenant's plan subscription: customer/org, plan, status, monthly-normalized
|
||||
// MRR, and the current-period start/renews. SuperAdmin only (core.Guard).
|
||||
//
|
||||
// Like invoices (and revenue) it fans out the org directory concurrently and reads each
|
||||
// org's subscriptions via the admin S2S seam, tagging every row with its owning org. The
|
||||
// MRR is monthly-normalized in the commerce reader so a yearly plan is comparable to a
|
||||
// monthly one. Best-effort per org (a failed read contributes no rows, never fabricated
|
||||
// ones); optional ?org= scopes to one tenant, ?status= filters, ?limit= caps.
|
||||
// It reads the ONE shared warehouse (commerce.events) — the table the commerce
|
||||
// analytics collector lands every subscription-lifecycle event in — over the SAME
|
||||
// client (aiobject.DatastoreQuery) the o11y/compute lenses use, with ZERO per-org
|
||||
// fan-out: one GROUP BY resolves each subscription's LATEST lifecycle state
|
||||
// (argMax by timestamp), so the whole fleet is one query, not N per-org commerce
|
||||
// reads. Honest by construction: no datastore connected or the collector's table
|
||||
// not provisioned yet → the real empty list, never a fabricated tenant. The MRR is
|
||||
// the monthly-normalized figure the emitter already computed (cents). Optional
|
||||
// ?org= scopes to one tenant, ?status= filters the LATEST status, ?limit= caps.
|
||||
package subscriptions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
aiobject "github.com/hanzoai/ai/object"
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/admin/core"
|
||||
"github.com/hanzoai/cloud/clients/admin/iam"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// defaultLimit caps the merged fleet subscription list when the caller sends none.
|
||||
// defaultLimit caps the fleet subscription list when the caller sends none.
|
||||
const defaultLimit = 500
|
||||
|
||||
// SubscriptionRow is one row of GET /v1/admin/subscriptions — a tenant's subscription at
|
||||
@@ -44,89 +46,104 @@ type SubscriptionRow struct {
|
||||
// GET /v1/admin/subscriptions?org=&status=&limit=
|
||||
func Subscriptions(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
cr := core.CallerCreds(c)
|
||||
status := strings.TrimSpace(c.Query("status"))
|
||||
status := strings.ToLower(strings.TrimSpace(c.Query("status")))
|
||||
wantOrg := strings.TrimSpace(c.Query("org"))
|
||||
limit := parseLimit(c.Query("limit"))
|
||||
|
||||
orgs, err := core.ListOrgs(s, ctx, cr)
|
||||
// Honest-empty when the warehouse is not connected or the collector's events
|
||||
// table is not provisioned yet (the emitter is still being wired).
|
||||
if !core.BillingEventsReady(ctx) {
|
||||
return core.OKList(c, []SubscriptionRow{}, 0)
|
||||
}
|
||||
|
||||
rows, err := aiobject.DatastoreQuery(ctx, subscriptionsSQL())
|
||||
if err != nil {
|
||||
return core.Fail(c, err.Error())
|
||||
}
|
||||
if wantOrg != "" {
|
||||
orgs = filterOrg(orgs, wantOrg)
|
||||
return core.Fail(c, "subscriptions query: "+err.Error())
|
||||
}
|
||||
all := subscriptionRowsFromRows(rows)
|
||||
|
||||
// Per-org subscriptions, fanned out concurrently (best-effort per org).
|
||||
perOrg := make([][]SubscriptionRow, len(orgs))
|
||||
sem := make(chan struct{}, core.MaxCustomerConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
for i, o := range orgs {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(i int, o iam.Org) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
perOrg[i] = subscriptionsOf(s, ctx, o, status)
|
||||
}(i, o)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
rows := make([]SubscriptionRow, 0)
|
||||
for _, r := range perOrg {
|
||||
rows = append(rows, r...)
|
||||
}
|
||||
// Highest-MRR first (ties broken by most-recent start); cap to the merged limit.
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i].MRRCents != rows[j].MRRCents {
|
||||
return rows[i].MRRCents > rows[j].MRRCents
|
||||
// Filter (latest status / org) then sort highest-MRR first, cap to limit.
|
||||
out := make([]SubscriptionRow, 0, len(all))
|
||||
for _, r := range all {
|
||||
if wantOrg != "" && r.Org != wantOrg {
|
||||
continue
|
||||
}
|
||||
return rows[i].Started > rows[j].Started
|
||||
})
|
||||
total := len(rows)
|
||||
if len(rows) > limit {
|
||||
rows = rows[:limit]
|
||||
if status != "" && strings.ToLower(r.Status) != status {
|
||||
continue
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return core.OKList(c, rows, total)
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].MRRCents != out[j].MRRCents {
|
||||
return out[i].MRRCents > out[j].MRRCents
|
||||
}
|
||||
return out[i].Started > out[j].Started
|
||||
})
|
||||
total := len(out)
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return core.OKList(c, out, total)
|
||||
}
|
||||
|
||||
// subscriptionsOf reads one org's subscriptions into fleet rows, tagged with the org.
|
||||
// Best-effort: a failed read yields no rows so the fleet view degrades honestly.
|
||||
func subscriptionsOf(s *cloud.Service[core.State], ctx context.Context, o iam.Org, status string) []SubscriptionRow {
|
||||
entries, err := s.State.Commerce.Subscriptions(ctx, o.Name, status)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
display := core.Display(o.DisplayName, o.Name)
|
||||
rows := make([]SubscriptionRow, 0, len(entries))
|
||||
for _, sub := range entries {
|
||||
rows = append(rows, SubscriptionRow{
|
||||
ID: sub.ID,
|
||||
Org: o.Name,
|
||||
Display: display,
|
||||
User: sub.User,
|
||||
Plan: sub.Plan,
|
||||
Status: sub.Status,
|
||||
MRRCents: int64(sub.MRR),
|
||||
Started: sub.Started,
|
||||
Renews: sub.Renews,
|
||||
// subscriptionsSQL resolves each subscription's LATEST lifecycle state from
|
||||
// commerce.events (argMax by timestamp). Static SQL over a closed event-name set
|
||||
// (SQLInList of server constants) — no user input is interpolated, so it is
|
||||
// injection-safe. The emitted properties carry the plan/status/mrr/period fields.
|
||||
func subscriptionsSQL() string {
|
||||
return "SELECT JSONExtractString(properties, 'subscription_id') AS id, " +
|
||||
"argMax(organization_id, timestamp) AS org, " +
|
||||
"argMax(distinct_id, timestamp) AS user, " +
|
||||
"argMax(JSONExtractString(properties, 'plan_name'), timestamp) AS plan, " +
|
||||
"argMax(JSONExtractString(properties, 'status'), timestamp) AS status, " +
|
||||
"argMax(JSONExtractInt(properties, 'mrr_cents'), timestamp) AS mrr_cents, " +
|
||||
"argMax(event, timestamp) AS last_event, " +
|
||||
"min(timestamp) AS started, " +
|
||||
"argMax(JSONExtractString(properties, 'period_end'), timestamp) AS renews " +
|
||||
"FROM " + core.BillingEventsTable + " " +
|
||||
"WHERE event IN (" + core.SQLInList(core.SubscriptionEvents) + ") " +
|
||||
"AND JSONExtractString(properties, 'subscription_id') != '' " +
|
||||
"GROUP BY id"
|
||||
}
|
||||
|
||||
// subscriptionRowsFromRows maps the datastore rows onto []SubscriptionRow (pure).
|
||||
// Display is the org slug — the warehouse holds no friendly name and admin does
|
||||
// no per-org IAM fan-out here (honest, not fabricated). The final status folds
|
||||
// the lifecycle: a subscription whose LATEST event is a cancel reads "canceled"
|
||||
// regardless of the last-emitted status snapshot.
|
||||
func subscriptionRowsFromRows(rows []map[string]any) []SubscriptionRow {
|
||||
out := make([]SubscriptionRow, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
org := core.CHStr(r["org"])
|
||||
out = append(out, SubscriptionRow{
|
||||
ID: core.CHStr(r["id"]),
|
||||
Org: org,
|
||||
Display: org,
|
||||
User: core.CHStr(r["user"]),
|
||||
Plan: core.CHStr(r["plan"]),
|
||||
Status: foldStatus(core.CHStr(r["last_event"]), core.CHStr(r["status"])),
|
||||
MRRCents: core.CHInt64(r["mrr_cents"]),
|
||||
Started: core.CHTime(r["started"]),
|
||||
Renews: core.CHStr(r["renews"]),
|
||||
})
|
||||
}
|
||||
return rows
|
||||
return out
|
||||
}
|
||||
|
||||
// filterOrg narrows the directory to the one requested org (empty when it does not
|
||||
// exist — an honest empty list, never a fabricated tenant).
|
||||
func filterOrg(orgs []iam.Org, want string) []iam.Org {
|
||||
for _, o := range orgs {
|
||||
if o.Name == want {
|
||||
return []iam.Org{o}
|
||||
}
|
||||
// foldStatus resolves the effective status: a subscription whose latest event is
|
||||
// a cancel is "canceled"; otherwise the last-emitted status snapshot (falling
|
||||
// back to "active" when the emitter sent none).
|
||||
func foldStatus(lastEvent, snapshot string) string {
|
||||
if lastEvent == core.EvSubscriptionCanceled {
|
||||
return "canceled"
|
||||
}
|
||||
return nil
|
||||
if s := strings.TrimSpace(snapshot); s != "" {
|
||||
return s
|
||||
}
|
||||
return "active"
|
||||
}
|
||||
|
||||
// parseLimit clamps the merged-list cap to [1,5000], defaulting to defaultLimit.
|
||||
// parseLimit clamps the fleet-list cap to [1,5000], defaulting to defaultLimit.
|
||||
func parseLimit(s string) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil || n <= 0 {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package subscriptions
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/admin/core"
|
||||
)
|
||||
|
||||
// TestSubscriptionRowsFromRows proves the warehouse-row → SubscriptionRow mapping
|
||||
// (the JSON-shape contract) coerces the datastore driver's native types and folds
|
||||
// the lifecycle status; display honestly mirrors the org slug (no fan-out).
|
||||
func TestSubscriptionRowsFromRows(t *testing.T) {
|
||||
started := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
||||
rows := []map[string]any{
|
||||
{ // active (mrr as driver int64), latest event renewed
|
||||
"id": "sub_1", "org": "acme", "user": "hanzo/alice",
|
||||
"plan": "Pro", "status": "active", "mrr_cents": int64(4900),
|
||||
"last_event": core.EvSubscriptionRenewed, "started": started,
|
||||
"renews": "2026-08-01T00:00:00Z",
|
||||
},
|
||||
{ // canceled wins over a stale "active" snapshot
|
||||
"id": "sub_2", "org": "beta", "user": "hanzo/bob",
|
||||
"plan": "Team", "status": "active", "mrr_cents": uint64(0),
|
||||
"last_event": core.EvSubscriptionCanceled, "started": started,
|
||||
"renews": "",
|
||||
},
|
||||
}
|
||||
out := subscriptionRowsFromRows(rows)
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("got %d rows, want 2", len(out))
|
||||
}
|
||||
r0 := out[0]
|
||||
if r0.ID != "sub_1" || r0.Org != "acme" || r0.Display != "acme" || r0.User != "hanzo/alice" {
|
||||
t.Fatalf("row0 identity wrong: %+v", r0)
|
||||
}
|
||||
if r0.Plan != "Pro" || r0.Status != "active" || r0.MRRCents != 4900 {
|
||||
t.Fatalf("row0 plan/status/mrr wrong: %+v", r0)
|
||||
}
|
||||
if r0.Started != "2026-07-01T12:00:00Z" {
|
||||
t.Fatalf("row0 started = %q", r0.Started)
|
||||
}
|
||||
if r0.Renews != "2026-08-01T00:00:00Z" {
|
||||
t.Fatalf("row0 renews = %q", r0.Renews)
|
||||
}
|
||||
if out[1].Status != "canceled" {
|
||||
t.Fatalf("row1 status = %q, want canceled (latest-event folds)", out[1].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFoldStatus(t *testing.T) {
|
||||
if got := foldStatus(core.EvSubscriptionCanceled, "active"); got != "canceled" {
|
||||
t.Fatalf("cancel fold = %q", got)
|
||||
}
|
||||
if got := foldStatus(core.EvSubscriptionRenewed, "trialing"); got != "trialing" {
|
||||
t.Fatalf("snapshot passthrough = %q", got)
|
||||
}
|
||||
if got := foldStatus(core.EvSubscriptionCreated, ""); got != "active" {
|
||||
t.Fatalf("empty-snapshot default = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubscriptionsSQLInjectionSafe asserts the query is fully static over the
|
||||
// closed event-name set — the warehouse table, no user-derived interpolation.
|
||||
func TestSubscriptionsSQLInjectionSafe(t *testing.T) {
|
||||
sql := subscriptionsSQL()
|
||||
if !strings.Contains(sql, core.BillingEventsTable) {
|
||||
t.Fatalf("query must read %s: %q", core.BillingEventsTable, sql)
|
||||
}
|
||||
for _, ev := range core.SubscriptionEvents {
|
||||
if !strings.Contains(sql, "'"+ev+"'") {
|
||||
t.Fatalf("query missing event %q", ev)
|
||||
}
|
||||
}
|
||||
if strings.Contains(sql, "?") {
|
||||
t.Fatalf("subscriptions state query takes no positional args: %q", sql)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLimitBounds(t *testing.T) {
|
||||
if parseLimit("") != defaultLimit || parseLimit("0") != defaultLimit || parseLimit("x") != defaultLimit {
|
||||
t.Fatal("bad/empty limit must default")
|
||||
}
|
||||
if parseLimit("10") != 10 {
|
||||
t.Fatal("valid limit must pass through")
|
||||
}
|
||||
if parseLimit("999999") != 5000 {
|
||||
t.Fatal("limit must clamp to 5000")
|
||||
}
|
||||
}
|
||||
+7
-6
@@ -106,13 +106,14 @@ func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
|
||||
// routes registers the ads surface: the campaign CRUD + the summary roll-up.
|
||||
func routes(app *zip.App, s *cloud.Service[state]) {
|
||||
app.Get("/v1/ads/summary", cloud.Handle(s, summary))
|
||||
g := app.Group("/v1/ads")
|
||||
g.Get("/summary", cloud.Handle(s, summary))
|
||||
|
||||
app.Get("/v1/ads/campaigns", cloud.Handle(s, listCampaigns))
|
||||
app.Post("/v1/ads/campaigns", cloud.Handle(s, createCampaign))
|
||||
app.Get("/v1/ads/campaigns/:id", cloud.Handle(s, getCampaign))
|
||||
app.Put("/v1/ads/campaigns/:id", cloud.Handle(s, updateCampaign))
|
||||
app.Delete("/v1/ads/campaigns/:id", cloud.Handle(s, deleteCampaign))
|
||||
g.Get("/campaigns", cloud.Handle(s, listCampaigns))
|
||||
g.Post("/campaigns", cloud.Handle(s, createCampaign))
|
||||
g.Get("/campaigns/:id", cloud.Handle(s, getCampaign))
|
||||
g.Put("/campaigns/:id", cloud.Handle(s, updateCampaign))
|
||||
g.Delete("/campaigns/:id", cloud.Handle(s, deleteCampaign))
|
||||
}
|
||||
|
||||
// ---- shared helpers (mirror clients/crm) ----
|
||||
|
||||
+128
-23
@@ -28,7 +28,9 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
mrand "math/rand/v2"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -107,6 +109,13 @@ type state struct {
|
||||
// per subsystem. Empty only on a deployment that configured no default, in
|
||||
// which case create still requires an explicit model.
|
||||
defaultModel string
|
||||
// failoverModel is the reliable model a run falls over to when the agent's own
|
||||
// model stays throttled (429/overloaded) after bounded retries
|
||||
// (deps.AIFallbackModel, default "best"). It makes an autonomous bot reply
|
||||
// still land when the throttled default flash model is overloaded. Empty
|
||||
// disables failover (retry-only). Only the run path reads it — interactive
|
||||
// chat is untouched.
|
||||
failoverModel string
|
||||
// bill is the shared per-org gate+meter (reuses deps.Metering, the ONE
|
||||
// commerce client — the same object ml/provisioning use). Nil/!Enabled()
|
||||
// makes Gate allow and Meter a no-op, so an unconfigured deployment runs
|
||||
@@ -262,11 +271,12 @@ func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
s := &cloud.Service[state]{
|
||||
Base: cloud.NewBase(deps, "agents"),
|
||||
State: state{
|
||||
store: store,
|
||||
ai: deps.AI,
|
||||
defaultModel: strings.TrimSpace(deps.AIDefaultModel),
|
||||
bill: cloud.NewResourceMeter(deps, meterKind),
|
||||
bus: newBus(),
|
||||
store: store,
|
||||
ai: deps.AI,
|
||||
defaultModel: strings.TrimSpace(deps.AIDefaultModel),
|
||||
failoverModel: strings.TrimSpace(deps.AIFallbackModel),
|
||||
bill: cloud.NewResourceMeter(deps, meterKind),
|
||||
bus: newBus(),
|
||||
// TASKS PLUG-IN POINT: durable execution rides hanzoai/tasks, not a
|
||||
// bespoke engine. Default is record-only; wiring client.Dial(TASKS_URL)
|
||||
// from github.com/hanzoai/tasks/pkg/sdk/client here makes control forward
|
||||
@@ -276,6 +286,7 @@ func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
}
|
||||
mounted = s
|
||||
|
||||
g := app.Group("/v1/agents")
|
||||
app.Get("/v1/agents", cloud.Handle(s, list))
|
||||
app.Post("/v1/agents", cloud.Handle(s, create))
|
||||
// The static org-wide surfaces are listed before the :ref wildcard for reading
|
||||
@@ -284,18 +295,18 @@ func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
// a ref). Registration order decides nothing here — it only decides which
|
||||
// handler silently wins when two patterns are byte-identical, which is a
|
||||
// collision, not a precedence.
|
||||
app.Get("/v1/agents/metrics", cloud.Handle(s, metrics))
|
||||
app.Get("/v1/agents/activity", cloud.Handle(s, activity))
|
||||
g.Get("/metrics", cloud.Handle(s, metrics))
|
||||
g.Get("/activity", cloud.Handle(s, activity))
|
||||
// Live agent-session control plane: /v1/agents/sessions[/...].
|
||||
mountSessions(s, app)
|
||||
// Agent targets: /v1/agents/targets[/...] — the #48 dispatch destinations a
|
||||
// session runs on.
|
||||
mountTargets(s, app)
|
||||
app.Get("/v1/agents/:ref", cloud.Handle(s, get))
|
||||
app.Patch("/v1/agents/:ref", cloud.Handle(s, update))
|
||||
app.Delete("/v1/agents/:ref", cloud.Handle(s, del))
|
||||
app.Post("/v1/agents/:ref/run", cloud.Handle(s, run))
|
||||
app.Get("/v1/agents/:ref/runs", cloud.Handle(s, runs))
|
||||
g.Get("/:ref", cloud.Handle(s, get))
|
||||
g.Patch("/:ref", cloud.Handle(s, update))
|
||||
g.Delete("/:ref", cloud.Handle(s, del))
|
||||
g.Post("/:ref/run", cloud.Handle(s, run))
|
||||
g.Get("/:ref/runs", cloud.Handle(s, runs))
|
||||
|
||||
// Long-running scheduler: invokes each long-running agent's run on its cron
|
||||
// cadence through the SAME runAgent path as the HTTP handler (one run path,
|
||||
@@ -665,11 +676,12 @@ func runAgent(s *cloud.Service[state], ctx context.Context, a Agent, input, acto
|
||||
return Run{}, err
|
||||
}
|
||||
|
||||
r := executeRun(ctx, s.State.ai, a.Org, a, input)
|
||||
r := executeRun(ctx, s.State.ai, a.Org, a, input, s.State.failoverModel)
|
||||
span.SetAttributes(
|
||||
attribute.String("hanzo.agent.run_id", r.ID),
|
||||
attribute.String("hanzo.agent.run_status", r.Status),
|
||||
attribute.Int64("hanzo.agent.duration_ms", r.DurationMs),
|
||||
attribute.String("gen_ai.response.model", r.Model),
|
||||
)
|
||||
if r.Status == "error" {
|
||||
span.SetStatus(codes.Error, r.Error)
|
||||
@@ -685,12 +697,14 @@ func runAgent(s *cloud.Service[state], ctx context.Context, a Agent, input, acto
|
||||
openRunSession(s, ctx, a, r, actor)
|
||||
|
||||
// Bill only a successful run (mirrors the edge gate: failed work is not
|
||||
// charged). Rich attribution: product=agent (Provider), the agent's model,
|
||||
// and the actor for the audit trail. Fire-and-forget on a background context.
|
||||
// charged). Rich attribution: product=agent (Provider), the model ACTUALLY
|
||||
// used (r.Model — a failover run bills the reliable model it fell over to, not
|
||||
// the throttled one it started on), and the actor for the audit trail.
|
||||
// Fire-and-forget on a background context.
|
||||
if r.Status == "ok" {
|
||||
s.State.bill.MeterUsage(a.Org, meterKind, metering.Usage{
|
||||
AmountCents: fee,
|
||||
Model: a.Model,
|
||||
Model: r.Model,
|
||||
Actor: actor,
|
||||
RequestID: requestID,
|
||||
ClientIP: clientIP,
|
||||
@@ -699,11 +713,31 @@ func runAgent(s *cloud.Service[state], ctx context.Context, a Agent, input, acto
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// executeRun composes the agent's instructions with the caller input, runs one
|
||||
// real chat completion through the AI client, and returns the resulting Run —
|
||||
// status "ok" with output, or "error" with the upstream failure. Pure of HTTP
|
||||
// and persistence so it is directly testable; the caller records + responds.
|
||||
func executeRun(ctx context.Context, ai types.AIClient, org string, a Agent, input string) Run {
|
||||
// maxAttempts bounds retries of a SINGLE model's completion on a transient
|
||||
// upstream failure (429 / 5xx / empty-choices / "Platform overloaded"). Retrying
|
||||
// a completion is side-effect-free — nothing bills until it succeeds — so a
|
||||
// bounded retry with jittered backoff turns an intermittent gateway 429 into a
|
||||
// delivered reply instead of a dropped one.
|
||||
const maxAttempts = 3
|
||||
|
||||
// retryBaseDelay / retryMaxDelay bound the exponential, equal-jittered backoff
|
||||
// between attempts. Small by design: a gateway overload clears in well under a
|
||||
// second, and a run must not stall a bot conversation.
|
||||
const (
|
||||
retryBaseDelay = 150 * time.Millisecond
|
||||
retryMaxDelay = 2 * time.Second
|
||||
)
|
||||
|
||||
// executeRun composes the agent's instructions with the caller input and runs
|
||||
// one chat completion through the AI client — with a bounded retry on transient
|
||||
// upstream overload and, if the agent's own model stays throttled, ONE failover
|
||||
// to the deployment's reliable model (fallback) so an autonomous bot reply still
|
||||
// lands. It returns the resulting Run — status "ok" with output and Model set to
|
||||
// the model that ACTUALLY answered (so metering bills that model), or "error"
|
||||
// with the final upstream failure. Pure of HTTP and persistence so it is directly
|
||||
// testable; the caller records + responds. This reliability policy is the agent
|
||||
// runner's ALONE — the interactive user-facing chat path is untouched.
|
||||
func executeRun(ctx context.Context, ai types.AIClient, org string, a Agent, input, fallback string) Run {
|
||||
// Child step span; the AI client opens its own GenAI span nested under this.
|
||||
ctx, span := agentTracer.Start(ctx, "agent.step", trace.WithSpanKind(trace.SpanKindInternal))
|
||||
defer span.End()
|
||||
@@ -717,11 +751,11 @@ func executeRun(ctx context.Context, ai types.AIClient, org string, a Agent, inp
|
||||
prompt += in
|
||||
}
|
||||
start := time.Now()
|
||||
resp, aiErr := ai.ChatCompletion(ctx, &types.ChatRequest{Model: a.Model, Prompt: prompt, Org: org})
|
||||
resp, used, aiErr := completeWithFailover(ctx, ai, org, prompt, a.Model, fallback)
|
||||
dur := time.Since(start).Milliseconds()
|
||||
id, _ := genID("run")
|
||||
r := Run{
|
||||
ID: id, Org: org, AgentName: a.Name, Model: a.Model, Input: input,
|
||||
ID: id, Org: org, AgentName: a.Name, Model: used, Input: input,
|
||||
DurationMs: dur, CreatedAt: time.Now().Unix(),
|
||||
}
|
||||
if aiErr != nil {
|
||||
@@ -738,6 +772,77 @@ func executeRun(ctx context.Context, ai types.AIClient, org string, a Agent, inp
|
||||
return r
|
||||
}
|
||||
|
||||
// completeWithFailover runs the completion on the agent's model with a bounded
|
||||
// retry (completeWithRetry), then — only if that model is STILL throttled after
|
||||
// its retries — fails over ONCE to fallback, a reliable model. It returns the
|
||||
// response, the model that actually produced it (for honest metering), and the
|
||||
// final error. A non-transient failure on either model returns immediately (the
|
||||
// next model would fail identically). ONE ordered mechanism, no config sprawl.
|
||||
func completeWithFailover(ctx context.Context, ai types.AIClient, org, prompt, model, fallback string) (*types.ChatResponse, string, error) {
|
||||
models := []string{model}
|
||||
if f := strings.TrimSpace(fallback); f != "" && f != model {
|
||||
models = append(models, f)
|
||||
}
|
||||
var lastErr error
|
||||
for _, m := range models {
|
||||
resp, err := completeWithRetry(ctx, ai, org, prompt, m)
|
||||
if err == nil {
|
||||
return resp, m, nil
|
||||
}
|
||||
lastErr = err
|
||||
// Escalate to the next model ONLY on a transient overload; a hard error
|
||||
// (bad request, auth, unserved model) fails fast — failover cannot help.
|
||||
if !errors.Is(err, types.ErrUpstreamBusy) {
|
||||
return nil, m, err
|
||||
}
|
||||
}
|
||||
return nil, models[len(models)-1], lastErr
|
||||
}
|
||||
|
||||
// completeWithRetry calls the completion up to maxAttempts times, retrying ONLY a
|
||||
// transient upstream overload (types.ErrUpstreamBusy) with jittered backoff and
|
||||
// respecting context cancellation. A non-transient error returns immediately.
|
||||
func completeWithRetry(ctx context.Context, ai types.AIClient, org, prompt, model string) (*types.ChatResponse, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
resp, err := ai.ChatCompletion(ctx, &types.ChatRequest{Model: model, Prompt: prompt, Org: org})
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
lastErr = err
|
||||
if !errors.Is(err, types.ErrUpstreamBusy) {
|
||||
return nil, err // permanent — do not burn retries repeating it
|
||||
}
|
||||
if attempt == maxAttempts-1 {
|
||||
break
|
||||
}
|
||||
if err := sleepBackoff(ctx, attempt); err != nil {
|
||||
return nil, err // context cancelled/expired mid-backoff
|
||||
}
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// sleepBackoff waits an exponential, equal-jittered delay before the next
|
||||
// attempt, or returns the context error if the caller's deadline fires first.
|
||||
func sleepBackoff(ctx context.Context, attempt int) error {
|
||||
d := retryBaseDelay << attempt
|
||||
if d > retryMaxDelay {
|
||||
d = retryMaxDelay
|
||||
}
|
||||
// Equal jitter: half fixed, half random in [0, d/2) — spreads retries without
|
||||
// ever collapsing the delay to ~0 (guarantees forward progress under load).
|
||||
wait := d/2 + time.Duration(mrand.Int64N(int64(d/2)+1))
|
||||
t := time.NewTimer(wait)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-t.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func runs(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
|
||||
@@ -157,7 +157,7 @@ func TestExecuteRunOK(t *testing.T) {
|
||||
ai := &fakeAI{content: "hi there"}
|
||||
a := mk("maxpower", "greeter")
|
||||
a.Instructions = "You are a greeter."
|
||||
r := executeRun(context.Background(), ai, "maxpower", a, "say hi")
|
||||
r := executeRun(context.Background(), ai, "maxpower", a, "say hi", "")
|
||||
|
||||
if r.Status != "ok" {
|
||||
t.Fatalf("want ok, got %q err=%q", r.Status, r.Error)
|
||||
@@ -178,7 +178,7 @@ func TestExecuteRunOK(t *testing.T) {
|
||||
|
||||
func TestExecuteRunRecordsError(t *testing.T) {
|
||||
ai := &fakeAI{err: errors.New("model unavailable")}
|
||||
r := executeRun(context.Background(), ai, "maxpower", mk("maxpower", "x"), "in")
|
||||
r := executeRun(context.Background(), ai, "maxpower", mk("maxpower", "x"), "in", "")
|
||||
if r.Status != "error" {
|
||||
t.Fatalf("want error status, got %q", r.Status)
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/types"
|
||||
"github.com/hanzoai/cloud/clients/metering"
|
||||
"github.com/hanzoai/cloud/types"
|
||||
luxlog "github.com/luxfi/log"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
@@ -88,7 +88,10 @@ func mountBilled(t *testing.T, commerceURL string, ai types.AIClient) *zip.App {
|
||||
t.Fatalf("metering.New: %v", err)
|
||||
}
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
deps := cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir(), AI: ai, Metering: m}
|
||||
// AIFallbackModel="best" arms the agent runner's failover so the retry/failover
|
||||
// tests exercise the real escalation path; it never fires for a run whose model
|
||||
// answers (or fails non-transiently), so the other billed tests are unaffected.
|
||||
deps := cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir(), AI: ai, Metering: m, AIFallbackModel: "best"}
|
||||
if err := Mount(app, deps); err != nil {
|
||||
t.Fatalf("Mount: %v", err)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// personalities.go seeds an org's BUILT-IN agents — the named personas a human
|
||||
// @-mentions in Hanzo Team (@dev to build, @des to design, @vi for vision). They
|
||||
// are ordinary rows in the ONE agent registry (this package's store): nothing
|
||||
// about them is special-cased downstream — they list, project into Team as bot
|
||||
// members (bots.go), and answer through the SAME agents.RunOnBehalf path every
|
||||
// other agent uses. The only thing this file adds is a one-time, idempotent
|
||||
// create so a fresh org has its crew without anyone POSTing them by hand.
|
||||
//
|
||||
// One and only one seed: keyed by the registry's UNIQUE(org,name), a re-seed is a
|
||||
// no-op (errConflict is swallowed). The Name is the @-handle (dev/des/vi), so the
|
||||
// Team mention resolves to the persona; Description is the human-facing title.
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// persona is one built-in agent definition. Name is the lowercase @-handle;
|
||||
// Description is the display title; Instructions is the system prompt that gives
|
||||
// the persona its voice and remit.
|
||||
type persona struct {
|
||||
Name string
|
||||
Description string
|
||||
Instructions string
|
||||
}
|
||||
|
||||
// personalities is the canonical built-in crew. Adding one here is the ONE way a
|
||||
// new default persona ships — no per-org config, no duplicate definition. The old
|
||||
// hanzo.ai site's voices, brought into Team.
|
||||
var personalities = []persona{
|
||||
{
|
||||
Name: "dev",
|
||||
Description: "Dev — the builder",
|
||||
Instructions: "You are Dev, Hanzo's builder. You ship. When a human @-mentions you " +
|
||||
"you write the code, wire the change, and report what you did in plain terms — " +
|
||||
"file paths, commands, results. You prize the smallest correct change, one and " +
|
||||
"only one way to do a thing, and no ceremony. You never hand-wave: if you built " +
|
||||
"it you say so with proof; if you're blocked you name the blocker. Terse, exact, " +
|
||||
"and always moving toward a finished, working solution.",
|
||||
},
|
||||
{
|
||||
Name: "des",
|
||||
Description: "Des — the designer",
|
||||
Instructions: "You are Des, Hanzo's designer. You own how it looks and feels — layout, " +
|
||||
"type, color, motion, the whole experience. When a human @-mentions you, you " +
|
||||
"think in systems, not one-off screens: a token, a component, a consistent rule " +
|
||||
"that reads as one product in light and dark. You give concrete, buildable design " +
|
||||
"direction (spacing, hierarchy, states), not vague taste. Elegant, accessible, and " +
|
||||
"opinionated — you make the obvious thing beautiful.",
|
||||
},
|
||||
{
|
||||
Name: "vi",
|
||||
Description: "Vi — the visionary",
|
||||
Instructions: "You are Vi, Hanzo's visionary lead. You hold the big picture and the long " +
|
||||
"arc — where the product is going, why it matters, and what to do next to get there. " +
|
||||
"When a human @-mentions you, you connect the dots across the org, cut through noise " +
|
||||
"to the one thing that matters, and rally the crew around it. You think in bets and " +
|
||||
"outcomes, name the strategy plainly, and turn a sprawling ask into a sharp, " +
|
||||
"sequenced plan. Inspiring, decisive, and grounded in what actually ships.",
|
||||
},
|
||||
}
|
||||
|
||||
// SeedPersonalities ensures the built-in crew exists for org. Idempotent: an
|
||||
// already-present persona (UNIQUE org+name) is left untouched, so it is safe to
|
||||
// call on every org first-touch (a new Team workspace, say). Returns the number
|
||||
// newly created.
|
||||
//
|
||||
// It needs a model to attach — the deployment's configured default. With no
|
||||
// default model, seeding is a NO-OP (0, nil): an org gets its crew the moment the
|
||||
// binary has a model to run them on, never a half-created persona that can't run.
|
||||
// A subsystem that is not mounted also no-ops rather than erroring, so a caller on
|
||||
// the login path can call it best-effort without ever blocking a human.
|
||||
func SeedPersonalities(ctx context.Context, org string) (int, error) {
|
||||
if mounted == nil || mounted.State.store == nil {
|
||||
return 0, nil
|
||||
}
|
||||
org = strings.TrimSpace(org)
|
||||
if org == "" {
|
||||
return 0, nil
|
||||
}
|
||||
model := strings.TrimSpace(mounted.State.defaultModel)
|
||||
if model == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
created := 0
|
||||
now := time.Now().Unix()
|
||||
for _, p := range personalities {
|
||||
id, err := genID("agent")
|
||||
if err != nil {
|
||||
return created, err
|
||||
}
|
||||
a := Agent{
|
||||
ID: id,
|
||||
Org: org,
|
||||
Name: p.Name,
|
||||
Model: model,
|
||||
Instructions: p.Instructions,
|
||||
Description: p.Description,
|
||||
Status: "ready",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
err = mounted.State.store.Create(ctx, a)
|
||||
switch {
|
||||
case err == nil:
|
||||
created++
|
||||
case errors.Is(err, errConflict):
|
||||
// Already seeded — the one-way idempotent no-op.
|
||||
default:
|
||||
return created, err
|
||||
}
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
luxlog "github.com/luxfi/log"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
)
|
||||
|
||||
// mountSeedTest wires the `mounted` singleton to a fresh store with a default
|
||||
// model, so SeedPersonalities has both a store to write and a model to attach.
|
||||
func mountSeedTest(t *testing.T, defaultModel string) {
|
||||
t.Helper()
|
||||
prev := mounted
|
||||
mounted = &cloud.Service[state]{
|
||||
Base: cloud.Base{Log: luxlog.New("test")},
|
||||
State: state{store: testStore(t), defaultModel: defaultModel},
|
||||
}
|
||||
t.Cleanup(func() { mounted = prev })
|
||||
}
|
||||
|
||||
// TestSeedPersonalities proves the built-in crew is created once, is idempotent,
|
||||
// projects the exact @-handles a human mentions in Team, and no-ops without a
|
||||
// model — the full contract of the one-way seed.
|
||||
func TestSeedPersonalities(t *testing.T) {
|
||||
mountSeedTest(t, "zen-1")
|
||||
ctx := context.Background()
|
||||
const org = "acme"
|
||||
|
||||
// First seed creates the whole crew.
|
||||
n, err := SeedPersonalities(ctx, org)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedPersonalities: %v", err)
|
||||
}
|
||||
if n != len(personalities) {
|
||||
t.Fatalf("created %d, want %d (the full built-in crew)", n, len(personalities))
|
||||
}
|
||||
|
||||
// They are ordinary registry rows — ListForOrg returns them with the @-handles.
|
||||
list, err := ListForOrg(ctx, org)
|
||||
if err != nil {
|
||||
t.Fatalf("ListForOrg: %v", err)
|
||||
}
|
||||
got := map[string]Agent{}
|
||||
for _, a := range list {
|
||||
got[a.Name] = a
|
||||
}
|
||||
for _, want := range []string{"dev", "des", "vi"} {
|
||||
a, ok := got[want]
|
||||
if !ok {
|
||||
t.Fatalf("@%s not seeded; have %v", want, keysOf(got))
|
||||
}
|
||||
if a.Model != "zen-1" || a.Status != "ready" || a.Instructions == "" {
|
||||
t.Fatalf("@%s malformed: model=%q status=%q instr=%dB", want, a.Model, a.Status, len(a.Instructions))
|
||||
}
|
||||
}
|
||||
|
||||
// Idempotent: a re-seed creates nothing and never duplicates.
|
||||
n2, err := SeedPersonalities(ctx, org)
|
||||
if err != nil {
|
||||
t.Fatalf("re-seed: %v", err)
|
||||
}
|
||||
if n2 != 0 {
|
||||
t.Fatalf("re-seed created %d, want 0 (idempotent)", n2)
|
||||
}
|
||||
if list2, _ := ListForOrg(ctx, org); len(list2) != len(personalities) {
|
||||
t.Fatalf("after re-seed: %d agents, want %d (no dup)", len(list2), len(personalities))
|
||||
}
|
||||
|
||||
// No default model → no-op, never a half-seeded org.
|
||||
mountSeedTest(t, "")
|
||||
if n3, err := SeedPersonalities(ctx, "globex"); err != nil || n3 != 0 {
|
||||
t.Fatalf("no-model seed = (%d,%v), want (0,nil)", n3, err)
|
||||
}
|
||||
}
|
||||
|
||||
func keysOf(m map[string]Agent) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud/types"
|
||||
)
|
||||
|
||||
// scriptedAI is a deterministic AIClient that models a throttled gateway: a call
|
||||
// whose model still has "busy budget" (or when allBusy is set) returns a
|
||||
// transient ErrUpstreamBusy-tagged error — exactly the shape the real httpAI
|
||||
// produces for a 429 / "Platform overloaded" — otherwise it returns content. It
|
||||
// records every call + model so a test can assert the retry count and which model
|
||||
// finally answered. It does NOT implement types.ModelLister, so create fails open
|
||||
// (any model is accepted without a catalog round-trip).
|
||||
type scriptedAI struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
models []string
|
||||
busy map[string]int // model -> remaining transient failures before it succeeds
|
||||
allBusy bool // every call fails transiently (failover can never win)
|
||||
content string
|
||||
}
|
||||
|
||||
func (s *scriptedAI) ChatCompletion(_ context.Context, req *types.ChatRequest) (*types.ChatResponse, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.calls++
|
||||
s.models = append(s.models, req.Model)
|
||||
if s.allBusy {
|
||||
return nil, fmt.Errorf("429 Platform overloaded: %w", types.ErrUpstreamBusy)
|
||||
}
|
||||
if n := s.busy[req.Model]; n > 0 {
|
||||
s.busy[req.Model] = n - 1
|
||||
return nil, fmt.Errorf("429 Platform overloaded: %w", types.ErrUpstreamBusy)
|
||||
}
|
||||
return &types.ChatResponse{Content: s.content}, nil
|
||||
}
|
||||
|
||||
func (s *scriptedAI) Embed(context.Context, *types.EmbedRequest) ([][]float32, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *scriptedAI) callCount() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.calls
|
||||
}
|
||||
|
||||
func (s *scriptedAI) modelsSeen() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]string(nil), s.models...)
|
||||
}
|
||||
|
||||
// TestRunRetriesTransientThenSucceedsBillsOnce is the core reliability contract:
|
||||
// a gateway that 429s TWICE then answers must yield ONE successful run and EXACTLY
|
||||
// ONE debit — not a dropped reply (the old single-shot behavior), and not three
|
||||
// debits (retries must never bill the failed attempts; only the final success
|
||||
// bills, once).
|
||||
func TestRunRetriesTransientThenSucceedsBillsOnce(t *testing.T) {
|
||||
bs := &billServer{available: 100000}
|
||||
ai := &scriptedAI{content: "recovered", busy: map[string]int{"gpt-4o-mini": 2}}
|
||||
app := mountBilled(t, bs.start(t), ai)
|
||||
|
||||
do(t, app, http.MethodPost, "/v1/agents", "acme",
|
||||
map[string]any{"name": "a", "model": "gpt-4o-mini", "instructions": "x"})
|
||||
code, body := do(t, app, http.MethodPost, "/v1/agents/a/run", "acme", map[string]any{"input": "hi"})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("run that recovers after 2 retries want 200, got %d (%s)", code, body)
|
||||
}
|
||||
// The reply must be the recovered content — not dropped.
|
||||
var rv runView
|
||||
_ = json.Unmarshal(body, &rv)
|
||||
if rv.Output != "recovered" {
|
||||
t.Fatalf("reply must carry the recovered output, got %q", rv.Output)
|
||||
}
|
||||
if got := ai.callCount(); got != 3 {
|
||||
t.Fatalf("want 3 completion attempts (2 busy + 1 ok), got %d", got)
|
||||
}
|
||||
// Exactly one debit for the single eventual success — never one-per-attempt.
|
||||
if !waitForDebit(func() bool { return bs.debits() == 1 }) {
|
||||
t.Fatalf("a retried-then-succeeded run must debit exactly once, got %d", bs.debits())
|
||||
}
|
||||
// Give any erroneous extra debit a chance to land; assert it did not.
|
||||
if bs.debits() != 1 {
|
||||
t.Fatalf("retries must not bill failed attempts: want 1 debit, got %d", bs.debits())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunFailsOverToReliableModelAndBillsIt proves the escalation: the agent's
|
||||
// own model stays throttled through all its retries, so the run fails over to the
|
||||
// configured reliable model ("best"), still lands a reply, and bills the model
|
||||
// ACTUALLY used (best) — not the throttled one it started on.
|
||||
func TestRunFailsOverToReliableModelAndBillsIt(t *testing.T) {
|
||||
bs := &billServer{available: 100000}
|
||||
// gpt-4o-mini is busy for MORE than its retry budget (never recovers); "best"
|
||||
// answers first try.
|
||||
ai := &scriptedAI{content: "from-best", busy: map[string]int{"gpt-4o-mini": 99}}
|
||||
app := mountBilled(t, bs.start(t), ai)
|
||||
|
||||
do(t, app, http.MethodPost, "/v1/agents", "acme",
|
||||
map[string]any{"name": "a", "model": "gpt-4o-mini", "instructions": "x"})
|
||||
code, body := do(t, app, http.MethodPost, "/v1/agents/a/run", "acme", map[string]any{"input": "hi"})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("failover run want 200, got %d (%s)", code, body)
|
||||
}
|
||||
var rv runView
|
||||
_ = json.Unmarshal(body, &rv)
|
||||
if rv.Output != "from-best" {
|
||||
t.Fatalf("failover reply must come from the reliable model, got %q", rv.Output)
|
||||
}
|
||||
// Primary exhausted its retries, then exactly one failover attempt on "best".
|
||||
if got := ai.callCount(); got != maxAttempts+1 {
|
||||
t.Fatalf("want %d attempts (primary exhausted + 1 failover), got %d", maxAttempts+1, got)
|
||||
}
|
||||
seen := ai.modelsSeen()
|
||||
if seen[len(seen)-1] != "best" {
|
||||
t.Fatalf("the final attempt must be the failover model, got %q", seen[len(seen)-1])
|
||||
}
|
||||
if !waitForDebit(func() bool { return bs.debits() == 1 }) {
|
||||
t.Fatalf("a failover run must debit exactly once, got %d", bs.debits())
|
||||
}
|
||||
_, ubody := bs.lastDebit()
|
||||
var u struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
_ = json.Unmarshal(ubody, &u)
|
||||
if u.Model != "best" {
|
||||
t.Fatalf("failover run must bill the model actually used (best), got %q", u.Model)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunAllAttemptsBusyExhaustedNoDebit proves the failure contract: when EVERY
|
||||
// attempt on BOTH the agent's model and the failover model is throttled, the run
|
||||
// records a clean error (502) and NOTHING is billed — a persistent overload never
|
||||
// costs the tenant, and never fabricates a reply.
|
||||
func TestRunAllAttemptsBusyExhaustedNoDebit(t *testing.T) {
|
||||
bs := &billServer{available: 100000}
|
||||
ai := &scriptedAI{allBusy: true}
|
||||
app := mountBilled(t, bs.start(t), ai)
|
||||
|
||||
do(t, app, http.MethodPost, "/v1/agents", "acme",
|
||||
map[string]any{"name": "a", "model": "gpt-4o-mini", "instructions": "x"})
|
||||
code, _ := do(t, app, http.MethodPost, "/v1/agents/a/run", "acme", map[string]any{"input": "hi"})
|
||||
if code != http.StatusBadGateway {
|
||||
t.Fatalf("exhausted run want 502, got %d", code)
|
||||
}
|
||||
// Primary retries (maxAttempts) + failover retries (maxAttempts) were all tried.
|
||||
if got := ai.callCount(); got != 2*maxAttempts {
|
||||
t.Fatalf("want %d attempts (primary + failover, both exhausted), got %d", 2*maxAttempts, got)
|
||||
}
|
||||
if waitForDebit(func() bool { return bs.debits() > 0 }) {
|
||||
t.Fatalf("a fully-throttled run must NOT be billed, got %d debits", bs.debits())
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
@@ -38,9 +39,47 @@ const (
|
||||
// mountRouting registers the route-work machine surface. Called from mountTargets
|
||||
// AFTER the target CRUD routes so the extra-segment paths are unambiguous.
|
||||
func mountRouting(s *cloud.Service[state], app *zip.App) {
|
||||
app.Post("/v1/agents/targets/:id/claim-key", cloud.Handle(s, mintClaimKey))
|
||||
app.Post("/v1/agents/targets/:id/claim", cloud.Handle(s, claimRoutedRun))
|
||||
app.Post("/v1/agents/targets/:id/runs/:runId/report", cloud.Handle(s, reportRoutedRun))
|
||||
assertSingleReplica(s.Log)
|
||||
g := app.Group("/v1/agents")
|
||||
g.Post("/targets/:id/claim-key", cloud.Handle(s, mintClaimKey))
|
||||
g.Post("/targets/:id/claim", cloud.Handle(s, claimRoutedRun))
|
||||
g.Post("/targets/:id/runs/:runId/report", cloud.Handle(s, reportRoutedRun))
|
||||
}
|
||||
|
||||
// caller is the VALIDATED principal id (X-User-Id) — the machine-owner identity for
|
||||
// route-work. tenant() already required a validated principal, so on any handler that
|
||||
// resolved an org this is non-empty.
|
||||
func caller(c *zip.Ctx) string { return strings.TrimSpace(c.User()) }
|
||||
|
||||
// ownsTarget reports whether the caller may MANAGE this target's route-work plane —
|
||||
// mint/rotate the claim key, claim, report, patch, delete. A machine belongs to the
|
||||
// principal that registered it (least privilege, AC-6): its owner may manage it, and
|
||||
// an org admin (self-service org management, the admin-org model's isAdmin) may manage
|
||||
// any of the org's targets. An UNOWNED (pre-migration) row is admin-only until its
|
||||
// owner re-registers — register binds the owner. Fail-closed: an empty caller or an
|
||||
// empty owner never satisfies the ownership arm, so a non-validated request or a
|
||||
// pre-migration row is never owner-managed.
|
||||
func ownsTarget(c *zip.Ctx, t Target) bool {
|
||||
if principal.IsOrgAdmin(c) || principal.IsSuperAdmin(c) {
|
||||
return true
|
||||
}
|
||||
u := caller(c)
|
||||
return t.Owner != "" && u != "" && u == t.Owner
|
||||
}
|
||||
|
||||
// authorizeTargetManage resolves the (org,id) target and gates it on ownsTarget,
|
||||
// collapsing every failure — cross-org, unknown, or not-owned — to the SAME
|
||||
// errTargetNotFound so the machine surface never distinguishes them (no oracle). The
|
||||
// resolved target is returned for the caller to use (avoids a second read).
|
||||
func authorizeTargetManage(s *cloud.Service[state], c *zip.Ctx, org, id string) (Target, error) {
|
||||
t, err := s.State.store.GetTarget(c.Context(), org, id)
|
||||
if err != nil {
|
||||
return Target{}, errTargetNotFound // unknown / cross-org -> no oracle
|
||||
}
|
||||
if !ownsTarget(c, t) {
|
||||
return Target{}, errTargetNotFound
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// mintClaimKey (re)mints the target's claim key and returns it ONCE. Only the
|
||||
@@ -52,11 +91,12 @@ func mintClaimKey(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
id := idParam(c)
|
||||
// The target must exist in this org before it can carry a capability.
|
||||
if _, err := s.State.store.GetTarget(c.Context(), org, id); err == errTargetNotFound {
|
||||
// The target must exist in this org AND the caller must OWN it (or be an org
|
||||
// admin) before it can (re)mint a capability — minting rotates the key, so an
|
||||
// un-scoped mint would let any org member strand a victim's daemon and steal its
|
||||
// runs. Every failure collapses to the same not-found (no oracle).
|
||||
if _, err := authorizeTargetManage(s, c, org, id); err != nil {
|
||||
return zip.ErrNotFound("target not found")
|
||||
} else if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "target: %v", err)
|
||||
}
|
||||
key, err := newClaimKey()
|
||||
if err != nil {
|
||||
@@ -77,6 +117,14 @@ func claimRoutedRun(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
id := idParam(c)
|
||||
// TWO proofs, both required, both fail-closed to the SAME 403 (no oracle): the
|
||||
// caller must OWN this machine (or be an org admin) AND hold its claim key. The
|
||||
// ownership gate is defense in depth — with mint owner-scoped an attacker cannot
|
||||
// obtain a valid key for a victim's machine, but a claim still refuses a
|
||||
// non-owner outright rather than resting solely on the capability.
|
||||
if _, err := authorizeTargetManage(s, c, org, id); err != nil {
|
||||
return claimAuthError(errTargetNotFound)
|
||||
}
|
||||
if err := s.State.store.verifyClaimKey(c.Context(), org, id, c.Header(claimKeyHeader)); err != nil {
|
||||
return claimAuthError(err)
|
||||
}
|
||||
@@ -115,6 +163,11 @@ func reportRoutedRun(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
}
|
||||
id := idParam(c)
|
||||
runID := strings.TrimSpace(c.Param("runId"))
|
||||
// Same two proofs as claim: own the machine (or org admin) AND hold its key, so a
|
||||
// non-owner can neither fabricate a report nor complete a victim's run. No oracle.
|
||||
if _, err := authorizeTargetManage(s, c, org, id); err != nil {
|
||||
return claimAuthError(errTargetNotFound)
|
||||
}
|
||||
if err := s.State.store.verifyClaimKey(c.Context(), org, id, c.Header(claimKeyHeader)); err != nil {
|
||||
return claimAuthError(err)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,9 +48,9 @@ func (c *countingAI) Embed(_ context.Context, _ *types.EmbedRequest) ([][]float3
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// schedSvc builds a Service + scheduler with NO billing (gate allows) and the given
|
||||
// schedService builds a Service + scheduler with NO billing (gate allows) and the given
|
||||
// AI, seeded with the supplied agents. Returns the scheduler for direct tick().
|
||||
func schedSvc(t *testing.T, ai types.AIClient, seed ...Agent) *scheduler {
|
||||
func schedService(t *testing.T, ai types.AIClient, seed ...Agent) *scheduler {
|
||||
t.Helper()
|
||||
s := &cloud.Service[state]{Base: cloud.Base{Log: luxlog.New("test")}, State: state{store: testStore(t), ai: ai}}
|
||||
for _, a := range seed {
|
||||
@@ -83,7 +83,7 @@ func waitFor(cond func() bool) bool {
|
||||
// run; a tick at a non-matching minute launches none.
|
||||
func TestSchedulerFiresDueAgent(t *testing.T) {
|
||||
ai := &countingAI{}
|
||||
sc := schedSvc(t, ai, longRunning("acme", "cron", "*/5 * * * *"))
|
||||
sc := schedService(t, ai, longRunning("acme", "cron", "*/5 * * * *"))
|
||||
ctx := context.Background()
|
||||
|
||||
sc.tick(ctx, at(t, "2026-07-01 12:36")) // 36 not multiple of 5 -> no fire
|
||||
@@ -102,7 +102,7 @@ func TestSchedulerFiresDueAgent(t *testing.T) {
|
||||
// like an HTTP run — the scheduler shares runAgent.
|
||||
func TestSchedulerRecordsRun(t *testing.T) {
|
||||
ai := &countingAI{}
|
||||
sc := schedSvc(t, ai, longRunning("acme", "cron", "* * * * *"))
|
||||
sc := schedService(t, ai, longRunning("acme", "cron", "* * * * *"))
|
||||
ctx := context.Background()
|
||||
sc.tick(ctx, at(t, "2026-07-01 12:00"))
|
||||
if !waitFor(func() bool {
|
||||
@@ -119,7 +119,7 @@ func TestSchedulerRecordsRun(t *testing.T) {
|
||||
// tick fires; the immediately-following matching tick is skipped (backoff=1).
|
||||
func TestSchedulerBackoffOnFailure(t *testing.T) {
|
||||
ai := &countingAI{fail: true}
|
||||
sc := schedSvc(t, ai, longRunning("acme", "cron", "* * * * *"))
|
||||
sc := schedService(t, ai, longRunning("acme", "cron", "* * * * *"))
|
||||
ctx := context.Background()
|
||||
|
||||
sc.tick(ctx, at(t, "2026-07-01 12:00"))
|
||||
@@ -152,7 +152,7 @@ func TestSchedulerBackoffOnFailure(t *testing.T) {
|
||||
// second matching tick while it is in flight does NOT start a second run.
|
||||
func TestSchedulerConcurrencyCap(t *testing.T) {
|
||||
ai := &countingAI{block: make(chan struct{})}
|
||||
sc := schedSvc(t, ai, longRunning("acme", "cron", "* * * * *"))
|
||||
sc := schedService(t, ai, longRunning("acme", "cron", "* * * * *"))
|
||||
ctx := context.Background()
|
||||
|
||||
sc.tick(ctx, at(t, "2026-07-01 12:00")) // starts run #1, which blocks
|
||||
@@ -257,7 +257,7 @@ func TestSchedulerGatesUnfundedRun(t *testing.T) {
|
||||
// path that lets Shutdown close the store safely.
|
||||
func TestSchedulerStopDrainsCleanly(t *testing.T) {
|
||||
ai := &countingAI{}
|
||||
sc := schedSvc(t, ai, longRunning("acme", "cron", "* * * * *"))
|
||||
sc := schedService(t, ai, longRunning("acme", "cron", "* * * * *"))
|
||||
sc.start()
|
||||
// Fire one run via a direct tick, then stop — stop must return after drain.
|
||||
sc.tick(context.Background(), at(t, "2026-07-01 12:00"))
|
||||
@@ -286,7 +286,7 @@ func TestSchedulerStopDrainsCleanly(t *testing.T) {
|
||||
// rather than waiting the full runTimeout.
|
||||
func TestSchedulerStopHonorsDeadline(t *testing.T) {
|
||||
ai := &countingAI{block: make(chan struct{})}
|
||||
sc := schedSvc(t, ai, longRunning("acme", "cron", "* * * * *"))
|
||||
sc := schedService(t, ai, longRunning("acme", "cron", "* * * * *"))
|
||||
sc.start()
|
||||
sc.tick(context.Background(), at(t, "2026-07-01 12:00"))
|
||||
if !waitFor(func() bool { return ai.count() == 1 }) {
|
||||
@@ -310,7 +310,7 @@ func TestSchedulerStopHonorsDeadline(t *testing.T) {
|
||||
func TestSchedulerOnlyLongRunning(t *testing.T) {
|
||||
ai := &countingAI{}
|
||||
one := mk("acme", "one") // one-shot default, no schedule
|
||||
sc := schedSvc(t, ai, one)
|
||||
sc := schedService(t, ai, one)
|
||||
sc.tick(context.Background(), at(t, "2026-07-01 12:00"))
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if ai.count() != 0 {
|
||||
|
||||
+12
-11
@@ -182,17 +182,18 @@ func toEventView(e Event) eventView {
|
||||
// :name would otherwise capture "sessions"). Within the block, the static
|
||||
// /stream route precedes the /:id param for the same reason.
|
||||
func mountSessions(s *cloud.Service[state], app *zip.App) {
|
||||
app.Post("/v1/agents/sessions", cloud.Handle(s, registerSession))
|
||||
app.Get("/v1/agents/sessions", cloud.Handle(s, listSessions))
|
||||
app.Get("/v1/agents/sessions/stream", cloud.Handle(s, sessionsStream))
|
||||
app.Get("/v1/agents/sessions/:id", cloud.Handle(s, getSession))
|
||||
app.Patch("/v1/agents/sessions/:id", cloud.Handle(s, patchSession))
|
||||
app.Get("/v1/agents/sessions/:id/tree", cloud.Handle(s, sessionTree))
|
||||
app.Post("/v1/agents/sessions/:id/events", cloud.Handle(s, appendSessionEvent))
|
||||
app.Post("/v1/agents/sessions/:id/pause", cloud.Handle(s, pauseSession))
|
||||
app.Post("/v1/agents/sessions/:id/resume", cloud.Handle(s, resumeSession))
|
||||
app.Post("/v1/agents/sessions/:id/stop", cloud.Handle(s, stopSession))
|
||||
app.Post("/v1/agents/sessions/:id/message", cloud.Handle(s, messageSession))
|
||||
g := app.Group("/v1/agents")
|
||||
g.Post("/sessions", cloud.Handle(s, registerSession))
|
||||
g.Get("/sessions", cloud.Handle(s, listSessions))
|
||||
g.Get("/sessions/stream", cloud.Handle(s, sessionsStream))
|
||||
g.Get("/sessions/:id", cloud.Handle(s, getSession))
|
||||
g.Patch("/sessions/:id", cloud.Handle(s, patchSession))
|
||||
g.Get("/sessions/:id/tree", cloud.Handle(s, sessionTree))
|
||||
g.Post("/sessions/:id/events", cloud.Handle(s, appendSessionEvent))
|
||||
g.Post("/sessions/:id/pause", cloud.Handle(s, pauseSession))
|
||||
g.Post("/sessions/:id/resume", cloud.Handle(s, resumeSession))
|
||||
g.Post("/sessions/:id/stop", cloud.Handle(s, stopSession))
|
||||
g.Post("/sessions/:id/message", cloud.Handle(s, messageSession))
|
||||
}
|
||||
|
||||
func idParam(c *zip.Ctx) string { return strings.TrimSpace(c.Param("id")) }
|
||||
|
||||
+82
-20
@@ -87,6 +87,7 @@ var errTargetNotFound = errors.New("agents: target not found")
|
||||
type Target struct {
|
||||
ID string
|
||||
Org string
|
||||
Owner string // the VALIDATED principal (c.User()) that registered this machine; "" for a pre-migration row
|
||||
Label string
|
||||
Kind string // laptop | cloud | gpu | cluster | machine
|
||||
Status string // online | offline | draining
|
||||
@@ -112,6 +113,7 @@ func (s *Store) migrateTargets() error {
|
||||
CREATE TABLE IF NOT EXISTS agent_targets (
|
||||
id TEXT PRIMARY KEY,
|
||||
org TEXT NOT NULL,
|
||||
owner TEXT NOT NULL DEFAULT '',
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
kind TEXT NOT NULL DEFAULT 'machine',
|
||||
status TEXT NOT NULL DEFAULT 'online',
|
||||
@@ -129,9 +131,12 @@ CREATE INDEX IF NOT EXISTS ix_targets_org_created ON agent_targets(org, created_
|
||||
return fmt.Errorf("migrate targets: %w", err)
|
||||
}
|
||||
// Forward, idempotent upgrade for target rows created before the capability +
|
||||
// metrics columns existed. PRAGMA-guarded, so re-running on an upgraded DB is a
|
||||
// no-op — the DDL above covers fresh installs, this covers pre-existing ones.
|
||||
// metrics + owner columns existed. PRAGMA-guarded, so re-running on an upgraded
|
||||
// DB is a no-op — the DDL above covers fresh installs, this covers pre-existing
|
||||
// ones. A pre-owner row backfills owner='' (unowned) and is admin-only until its
|
||||
// owner re-registers (register binds the owner) — see registerTarget.
|
||||
if err := s.addColumns("agent_targets", map[string]string{
|
||||
"owner": "TEXT NOT NULL DEFAULT ''",
|
||||
"spec": "TEXT NOT NULL DEFAULT ''",
|
||||
"metrics": "TEXT NOT NULL DEFAULT ''",
|
||||
"metrics_at": "INTEGER NOT NULL DEFAULT 0",
|
||||
@@ -141,12 +146,12 @@ CREATE INDEX IF NOT EXISTS ix_targets_org_created ON agent_targets(org, created_
|
||||
return nil
|
||||
}
|
||||
|
||||
const targetCols = `id,org,label,kind,status,capacity,host,spec,metrics,metrics_at,created_at,updated_at`
|
||||
const targetCols = `id,org,owner,label,kind,status,capacity,host,spec,metrics,metrics_at,created_at,updated_at`
|
||||
|
||||
func scanTarget(sc interface{ Scan(...any) error }) (Target, error) {
|
||||
var t Target
|
||||
var spec, metrics string
|
||||
err := sc.Scan(&t.ID, &t.Org, &t.Label, &t.Kind, &t.Status, &t.Capacity, &t.Host,
|
||||
err := sc.Scan(&t.ID, &t.Org, &t.Owner, &t.Label, &t.Kind, &t.Status, &t.Capacity, &t.Host,
|
||||
&spec, &metrics, &t.MetricsAt, &t.CreatedAt, &t.UpdatedAt)
|
||||
if err != nil {
|
||||
return t, err
|
||||
@@ -159,8 +164,8 @@ func scanTarget(sc interface{ Scan(...any) error }) (Target, error) {
|
||||
// CreateTarget inserts one target. The id is caller-generated (genID("tgt")).
|
||||
func (s *Store) CreateTarget(ctx context.Context, t Target) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO agent_targets (`+targetCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
t.ID, t.Org, t.Label, t.Kind, t.Status, t.Capacity, t.Host,
|
||||
`INSERT INTO agent_targets (`+targetCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
t.ID, t.Org, t.Owner, t.Label, t.Kind, t.Status, t.Capacity, t.Host,
|
||||
encodeSpec(t.Spec), encodeMetrics(t.Metrics), t.MetricsAt, t.CreatedAt, t.UpdatedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert target: %w", err)
|
||||
@@ -203,12 +208,14 @@ func (s *Store) ListTargets(ctx context.Context, org string) ([]Target, error) {
|
||||
}
|
||||
|
||||
// UpdateTarget persists mutable fields for an existing (org,id) target. Scoped by org
|
||||
// so a cross-tenant id can never mutate another's target.
|
||||
// so a cross-tenant id can never mutate another's target. owner is persisted too so a
|
||||
// relink can BIND a previously-unowned row (registerTarget) and a patch preserves the
|
||||
// owner it read; no client-facing patch field sets owner, so it never moves by mutation.
|
||||
func (s *Store) UpdateTarget(ctx context.Context, t Target) error {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE agent_targets SET label=?, kind=?, status=?, capacity=?, host=?, spec=?, metrics=?, metrics_at=?, updated_at=?
|
||||
`UPDATE agent_targets SET owner=?, label=?, kind=?, status=?, capacity=?, host=?, spec=?, metrics=?, metrics_at=?, updated_at=?
|
||||
WHERE org=? AND id=?`,
|
||||
t.Label, t.Kind, t.Status, t.Capacity, t.Host,
|
||||
t.Owner, t.Label, t.Kind, t.Status, t.Capacity, t.Host,
|
||||
encodeSpec(t.Spec), encodeMetrics(t.Metrics), t.MetricsAt, t.UpdatedAt, t.Org, t.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update target: %w", err)
|
||||
@@ -220,6 +227,32 @@ func (s *Store) UpdateTarget(ctx context.Context, t Target) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLinkableTargetByHost returns the target for (org,host) that the caller `owner`
|
||||
// may re-link — its OWN row, else an UNOWNED (pre-migration) row it may adopt —
|
||||
// preferring the exact-owner match, newest first. A row owned by a DIFFERENT
|
||||
// principal is NEVER returned, so a re-link can never clobber another member's
|
||||
// machine: the caller gets its own row or (falling through in registerTarget) a fresh
|
||||
// one. errTargetNotFound when nothing linkable exists.
|
||||
func (s *Store) GetLinkableTargetByHost(ctx context.Context, org, host, owner string) (Target, error) {
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" {
|
||||
return Target{}, errTargetNotFound
|
||||
}
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT `+targetCols+` FROM agent_targets
|
||||
WHERE org=? AND host=? AND (owner=? OR owner='')
|
||||
ORDER BY CASE WHEN owner=? THEN 0 ELSE 1 END, created_at DESC, id ASC LIMIT 1`,
|
||||
org, host, owner, owner)
|
||||
t, err := scanTarget(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Target{}, errTargetNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Target{}, fmt.Errorf("get linkable target by host: %w", err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// GetTargetByHost returns an org's target reporting the given host, or
|
||||
// errTargetNotFound. It is how a re-link of the SAME machine finds its existing target
|
||||
// (idempotent register) instead of creating a duplicate. Org-scoped: a host string can
|
||||
@@ -476,11 +509,12 @@ func toTargetView(t Target, load TargetLoad) targetView {
|
||||
// /v1/agents/:ref wildcard (Fiber matches in registration order) so "targets" is not
|
||||
// captured as a ref. The static /v1/agents/targets precedes /v1/agents/targets/:id.
|
||||
func mountTargets(s *cloud.Service[state], app *zip.App) {
|
||||
app.Post("/v1/agents/targets", cloud.Handle(s, registerTarget))
|
||||
app.Get("/v1/agents/targets", cloud.Handle(s, listTargets))
|
||||
app.Get("/v1/agents/targets/:id", cloud.Handle(s, getTarget))
|
||||
app.Patch("/v1/agents/targets/:id", cloud.Handle(s, patchTarget))
|
||||
app.Delete("/v1/agents/targets/:id", cloud.Handle(s, deleteTarget))
|
||||
g := app.Group("/v1/agents")
|
||||
g.Post("/targets", cloud.Handle(s, registerTarget))
|
||||
g.Get("/targets", cloud.Handle(s, listTargets))
|
||||
g.Get("/targets/:id", cloud.Handle(s, getTarget))
|
||||
g.Patch("/targets/:id", cloud.Handle(s, patchTarget))
|
||||
g.Delete("/targets/:id", cloud.Handle(s, deleteTarget))
|
||||
// The #48 route-work machine surface (claim-key, claim long-poll, report)
|
||||
// lives on the same target routes; register after the CRUD so the
|
||||
// extra-segment paths are unambiguous.
|
||||
@@ -548,12 +582,21 @@ func registerTarget(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
metricsAt = now // the server owns the staleness clock; a client can't forge it
|
||||
}
|
||||
|
||||
// Idempotent re-link: the SAME machine (org+host) refreshes its existing target
|
||||
// rather than piling up duplicates, so mission-control shows one row per machine
|
||||
// with live spec/metrics. Only an explicit host keys this — an anonymous target
|
||||
// (no host) always creates.
|
||||
// The registering principal OWNS this machine (least privilege): only it (or an
|
||||
// org admin) may later mint the claim key, claim runs, report, patch, or delete
|
||||
// it. tenant() already required a validated principal, so this is non-empty.
|
||||
owner := caller(c)
|
||||
|
||||
// Idempotent re-link: the SAME machine (org+host+owner) refreshes its existing
|
||||
// target rather than piling up duplicates, so mission-control shows one row per
|
||||
// machine with live spec/metrics. It resolves ONLY the caller's own row (or an
|
||||
// UNOWNED pre-migration row, which it ADOPTS by binding owner) — a row owned by a
|
||||
// different member is never touched, so a re-link can never hijack another's
|
||||
// machine; the caller falls through to create its own. Only an explicit host keys
|
||||
// this — an anonymous target (no host) always creates.
|
||||
if host != "" {
|
||||
if existing, err := s.State.store.GetTargetByHost(c.Context(), org, host); err == nil {
|
||||
if existing, err := s.State.store.GetLinkableTargetByHost(c.Context(), org, host, owner); err == nil {
|
||||
existing.Owner = owner // bind an adopted unowned row; no-op if already ours
|
||||
existing.Label, existing.Kind, existing.Status, existing.Capacity = label, kind, status, capacity
|
||||
existing.Spec, existing.Metrics, existing.MetricsAt = spec, metrics, metricsAt
|
||||
existing.UpdatedAt = now
|
||||
@@ -571,7 +614,7 @@ func registerTarget(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
return zip.Errorf(http.StatusInternalServerError, "rng: %v", err)
|
||||
}
|
||||
t := Target{
|
||||
ID: id, Org: org, Label: label, Kind: kind, Status: status,
|
||||
ID: id, Org: org, Owner: owner, Label: label, Kind: kind, Status: status,
|
||||
Capacity: capacity, Host: host, Spec: spec, Metrics: metrics, MetricsAt: metricsAt,
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
@@ -648,6 +691,12 @@ func patchTarget(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "get: %v", err)
|
||||
}
|
||||
// Only the machine's owner (or an org admin) may mutate it — a member cannot
|
||||
// reconfigure/drain another member's machine. Fail-closed to the SAME not-found
|
||||
// an unknown id gives, so a probe learns nothing about what exists.
|
||||
if !ownsTarget(c, t) {
|
||||
return zip.ErrNotFound("target not found")
|
||||
}
|
||||
var body patchTargetReq
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return err
|
||||
@@ -729,6 +778,19 @@ func deleteTarget(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
id := idParam(c)
|
||||
// Resolve + ownership-gate before deleting: only the machine's owner (or an org
|
||||
// admin) may deregister it. A cross-org id, an unknown id, and a non-owned id all
|
||||
// collapse to the same not-found — no oracle.
|
||||
t, err := s.State.store.GetTarget(c.Context(), org, id)
|
||||
if err == errTargetNotFound {
|
||||
return zip.ErrNotFound("target not found")
|
||||
}
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "get: %v", err)
|
||||
}
|
||||
if !ownsTarget(c, t) {
|
||||
return zip.ErrNotFound("target not found")
|
||||
}
|
||||
deleted, err := s.State.store.DeleteTarget(c.Context(), org, id)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "delete: %v", err)
|
||||
|
||||
+45
-1
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -156,6 +157,12 @@ func (a *httpAI) ChatCompletion(ctx context.Context, req *types.ChatRequest) (*t
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, "chat completion failed")
|
||||
// Tag a transient overload (429/5xx/"overloaded") with types.ErrUpstreamBusy
|
||||
// so the agent runner can retry/fail over; a permanent error is left untagged
|
||||
// and fails fast. The message is preserved either way.
|
||||
if transientChat(err) {
|
||||
err = fmt.Errorf("%w: %w", err, types.ErrUpstreamBusy)
|
||||
}
|
||||
return nil, fmt.Errorf("cloud: chat completion (model %q): %w", model, err)
|
||||
}
|
||||
span.SetAttributes(
|
||||
@@ -164,8 +171,10 @@ func (a *httpAI) ChatCompletion(ctx context.Context, req *types.ChatRequest) (*t
|
||||
attribute.Int("gen_ai.usage.output_tokens", resp.Usage.CompletionTokens),
|
||||
)
|
||||
if len(resp.Choices) == 0 {
|
||||
// A 200 with no choices is the gateway's "overloaded, no capacity" shape —
|
||||
// transient, so tag it busy for retry/failover rather than dropping the reply.
|
||||
span.SetStatus(codes.Error, "no choices")
|
||||
return nil, fmt.Errorf("cloud: chat completion (model %q): upstream returned no choices", model)
|
||||
return nil, fmt.Errorf("cloud: chat completion (model %q): upstream returned no choices: %w", model, types.ErrUpstreamBusy)
|
||||
}
|
||||
return &types.ChatResponse{
|
||||
Content: resp.Choices[0].Message.Content,
|
||||
@@ -175,6 +184,41 @@ func (a *httpAI) ChatCompletion(ctx context.Context, req *types.ChatRequest) (*t
|
||||
}, nil
|
||||
}
|
||||
|
||||
// transientChat reports whether an upstream chat-completion error is a transient
|
||||
// overload the agent runner may safely retry or fail over on: HTTP
|
||||
// 429/500/502/503/504, or a gateway that surfaces "overloaded" without a typed
|
||||
// status. A permanent failure — a 4xx that is not 429 (bad request, auth, an
|
||||
// unserved model) — returns false so it fails fast instead of burning retries.
|
||||
func transientChat(err error) bool {
|
||||
if code, ok := httpStatus(err); ok {
|
||||
switch code {
|
||||
case http.StatusTooManyRequests, http.StatusInternalServerError,
|
||||
http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
// No typed status (a bare transport/overload message) — treat an explicit
|
||||
// "overloaded" as transient; anything else is not classifiable, so not retried.
|
||||
return strings.Contains(strings.ToLower(err.Error()), "overloaded")
|
||||
}
|
||||
|
||||
// httpStatus extracts the upstream HTTP status from a go-openai error
|
||||
// (*openai.APIError for a decoded error body, *openai.RequestError for a raw
|
||||
// non-2xx), returning ok=false when the error carries no status.
|
||||
func httpStatus(err error) (int, bool) {
|
||||
var apiErr *openai.APIError
|
||||
if errors.As(err, &apiErr) && apiErr.HTTPStatusCode > 0 {
|
||||
return apiErr.HTTPStatusCode, true
|
||||
}
|
||||
var reqErr *openai.RequestError
|
||||
if errors.As(err, &reqErr) && reqErr.HTTPStatusCode > 0 {
|
||||
return reqErr.HTTPStatusCode, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// Embed returns one vector per input from the gateway's OpenAI-compatible
|
||||
// /embeddings, authenticated by the SAME credential as ChatCompletion — so the
|
||||
// semantic tier bills and meters through the ONE org/project-aligned path, never
|
||||
|
||||
@@ -3,6 +3,7 @@ package clients
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -100,6 +101,49 @@ func TestAIHTTP_UpstreamErrorMapped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAIHTTP_TransientTagged proves the classification the agent runner relies on:
|
||||
// a transient overload (429 / 5xx / empty-choices) is tagged types.ErrUpstreamBusy
|
||||
// so the runner retries/fails over, while a permanent 4xx (400) is NOT tagged so it
|
||||
// fails fast. Without this split the runner would either drop replies on a passing
|
||||
// 429 or spin retries on an unrecoverable bad request.
|
||||
func TestAIHTTP_TransientTagged(t *testing.T) {
|
||||
serve := func(status int, body string) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if status != 0 {
|
||||
w.WriteHeader(status)
|
||||
}
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
transit bool
|
||||
}{
|
||||
{"429 overloaded", http.StatusTooManyRequests, `{"error":{"message":"Platform overloaded"}}`, true},
|
||||
{"503 unavailable", http.StatusServiceUnavailable, `{"error":{"message":"unavailable"}}`, true},
|
||||
{"500 server", http.StatusInternalServerError, `{"error":{"message":"boom"}}`, true},
|
||||
{"200 empty choices", 0, `{"id":"x","object":"chat.completion","choices":[]}`, true},
|
||||
{"400 bad request", http.StatusBadRequest, `{"error":{"message":"bad input"}}`, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := serve(tc.status, tc.body)
|
||||
defer srv.Close()
|
||||
_, err := AIHTTPAt(srv.URL, "sk-test", "deepseek-v4-flash").
|
||||
ChatCompletion(context.Background(), &types.ChatRequest{Prompt: "x"})
|
||||
if err == nil {
|
||||
t.Fatalf("%s: expected an error", tc.name)
|
||||
}
|
||||
if got := errors.Is(err, types.ErrUpstreamBusy); got != tc.transit {
|
||||
t.Fatalf("%s: errors.Is(ErrUpstreamBusy)=%v, want %v (err=%v)", tc.name, got, tc.transit, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAIHTTP_ServerErrorMapped asserts a 5xx upstream also maps to an error.
|
||||
func TestAIHTTP_ServerErrorMapped(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
@@ -105,6 +105,15 @@ func routes(app *zip.App, s *cloud.Service[state]) {
|
||||
// resolved IAM-only and fail-closed, into the ONE write core (ingestEvents).
|
||||
app.Post("/v1/event", cloud.Handle(s, eventIngest))
|
||||
|
||||
// Publishable-key direct ingest (publishable.go) — the FASTEST path: a
|
||||
// write-only pk_ key (HMAC-signed org, no IAM/DB hop) authenticates
|
||||
// {batch:[WireEvent]} straight into the ONE write core. /v1/ingest/keys mints a
|
||||
// pk_ for the caller's org; /v1/errors is the type:'error' read lens (validated
|
||||
// principal — reads never accept the write-only key).
|
||||
app.Post("/v1/ingest", cloud.Handle(s, ingest))
|
||||
app.Post("/v1/ingest/keys", cloud.Handle(s, mintKey))
|
||||
app.Get("/v1/errors", cloud.Handle(s, errorsLens))
|
||||
|
||||
// DEPRECATED ingest aliases — thin wire adapters that normalize onto the SAME
|
||||
// write core (log a one-shot deprecation, keep working). /v1/analytics{,/batch}
|
||||
// and /v1/tracker speak the Segment/beacon CaptureBatch wire; /v1/tracker is a
|
||||
|
||||
@@ -173,6 +173,7 @@ type CaptureEvent struct {
|
||||
Quantity uint32 `json:"quantity"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
Currency string `json:"currency"`
|
||||
Error *Exception `json:"error"` // set on type:'error' events (folded into properties.$exception)
|
||||
Properties map[string]any `json:"properties"`
|
||||
Library string `json:"library"`
|
||||
LibraryVer string `json:"libraryVersion"`
|
||||
@@ -297,12 +298,20 @@ func resolveEventName(e CaptureEvent) string {
|
||||
return "$identify"
|
||||
case "group":
|
||||
return "$group"
|
||||
case "error":
|
||||
if name == "" {
|
||||
return "$error"
|
||||
}
|
||||
return name
|
||||
default: // "event"
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
// canonicalType folds the type to the closed set {pageview,identify,group,event}.
|
||||
// canonicalType folds the type to the closed set
|
||||
// {pageview,identify,group,error,event}. `error` is first-class so the ingest can
|
||||
// store type:'error' events under event_type='error' — the key the /v1/errors
|
||||
// read lens filters on. An unknown type still folds to "event".
|
||||
func canonicalType(t string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(t)) {
|
||||
case "pageview", "page":
|
||||
@@ -311,6 +320,8 @@ func canonicalType(t string) string {
|
||||
return "identify"
|
||||
case "group":
|
||||
return "group"
|
||||
case "error":
|
||||
return "error"
|
||||
default:
|
||||
return "event"
|
||||
}
|
||||
@@ -604,7 +615,14 @@ func withSource(p map[string]any, source string) map[string]any {
|
||||
|
||||
// deprecatedOnce records one deprecation log per alias path per process, so a
|
||||
// high-volume ingest alias signals its sunset exactly once instead of flooding.
|
||||
var deprecatedOnce sync.Map
|
||||
// A plain mutex-guarded map (the alias set is tiny — /v1/analytics, /batch,
|
||||
// /tracker) rather than sync.Map: the latter (Go's HashTrieMap) was panicking
|
||||
// "ran out of hash bits" under the hot ingest path, 500-ing EVERY capture. A
|
||||
// bounded map+mutex has no trie state to corrupt and cannot panic here.
|
||||
var (
|
||||
deprecatedMu sync.Mutex
|
||||
deprecatedSeen = make(map[string]struct{}, 8)
|
||||
)
|
||||
|
||||
// deprecated logs (once per path) that a superseded ingest alias was hit, pointing
|
||||
// callers at the canonical front door. It NEVER changes behavior — the alias keeps
|
||||
@@ -612,7 +630,13 @@ var deprecatedOnce sync.Map
|
||||
// warehouse).
|
||||
func deprecated(s *cloud.Service[state], c *zip.Ctx, canonical string) {
|
||||
p := c.Path()
|
||||
if _, seen := deprecatedOnce.LoadOrStore(p, struct{}{}); seen {
|
||||
deprecatedMu.Lock()
|
||||
_, seen := deprecatedSeen[p]
|
||||
if !seen {
|
||||
deprecatedSeen[p] = struct{}{}
|
||||
}
|
||||
deprecatedMu.Unlock()
|
||||
if seen {
|
||||
return
|
||||
}
|
||||
s.Log.Warn("deprecated analytics ingest endpoint; migrate to the canonical event front door",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// publishable.go — the FASTEST capture path: a write-only PUBLISHABLE KEY (pk_…)
|
||||
// that authenticates a direct-to-datastore ingest with ZERO network hop.
|
||||
//
|
||||
// POST /v1/ingest body: {batch:[WireEvent]} auth: pk_… -> {accepted,dropped}
|
||||
// POST /v1/ingest/keys mint a pk_ for the caller's org (validated principal)
|
||||
// GET /v1/errors recent type:'error' events for the org (read lens)
|
||||
//
|
||||
// WHY a distinct key from the IAM hk-/sk-/pk- family: those resolve through IAM
|
||||
// (get-user?accessKey — a network round-trip) and mint a FULL principal that can
|
||||
// READ. A publishable key is meant to ship in a browser bundle, so it must be
|
||||
// write-only and cheap to verify. This key is:
|
||||
//
|
||||
// - INGEST-ONLY BY CONSTRUCTION. The `pk_` (underscore) prefix is deliberately
|
||||
// NOT in isAPIKey's set (hk-/sk-/pk-/fw_/hz_, all dash/`fw_`/`hz_`), so the
|
||||
// identity boundary (SanitizeIdentity) and OrgForKey both REFUSE it — it can
|
||||
// never become a bearer principal, so it can never read. Its only door is the
|
||||
// ingest verifier below. Write-only is a property of WHICH resolver accepts
|
||||
// the value, not a flag on a row.
|
||||
// - ORG-SCOPED, SIGNED, NON-FORGEABLE. The org is carried in the key but sealed
|
||||
// under HMAC-SHA256(secret, org): a client cannot flip the org without the
|
||||
// secret. The server stamps tenant_id from the VERIFIED org, never from the
|
||||
// request body — the same tenant invariant the rest of the plane enforces.
|
||||
// - LOWEST LATENCY. Verification is one HMAC compute — no IAM call, no keys
|
||||
// table, no DB read. This is the no-Kafka, no-bridge, direct-to-ClickHouse
|
||||
// path; it funnels through the SAME write core (ingestEvents) into the SAME
|
||||
// hanzo.events table as every other adapter. One write path, many front doors.
|
||||
//
|
||||
// SECRET: the HMAC secret is CLOUD_INGEST_KEY_SECRET (KMS-injected by the
|
||||
// operator). Absent ⇒ mint and verify BOTH fail closed (503 / 403) — a deployment
|
||||
// without the secret never mints a forgeable key nor admits an unverifiable one.
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
aiobject "github.com/hanzoai/ai/object"
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// ingestKeySecretEnv names the KMS-injected HMAC secret that seals a publishable
|
||||
// key's org. Absent ⇒ the publishable-key path is disabled (fails closed).
|
||||
const ingestKeySecretEnv = "CLOUD_INGEST_KEY_SECRET"
|
||||
|
||||
// publishablePrefix marks a write-only ingest key. Underscore (not the dash of
|
||||
// the isAPIKey family) is load-bearing: it keeps pk_ OUT of the bearer/principal
|
||||
// path, so a publishable key is structurally read-incapable.
|
||||
const publishablePrefix = "pk_"
|
||||
|
||||
// sourceIngest tags rows that arrived via the publishable-key direct ingest, so
|
||||
// the ONE hanzo.events table stays honest about origin (queryable as
|
||||
// properties.$source) without a second table — same mechanism as the other
|
||||
// adapters (sourceEvent/sourcePostHog/sourceCapture).
|
||||
const sourceIngest = "ingest"
|
||||
|
||||
// sigBytes is the HMAC truncation length (128 bits) — ample against forgery while
|
||||
// keeping the key short enough to embed in a bundle.
|
||||
const sigBytes = 16
|
||||
|
||||
// ── key codec (pure) ─────────────────────────────────────────────────────────
|
||||
|
||||
// ingestSecret returns the configured HMAC secret, or "" when unset (path off).
|
||||
func ingestSecret() string { return strings.TrimSpace(os.Getenv(ingestKeySecretEnv)) }
|
||||
|
||||
// keySig computes the org signature under the secret: HMAC-SHA256(secret, org),
|
||||
// truncated to sigBytes. The org is the only signed input — the tenant a key can
|
||||
// ever write into is fixed at mint time and cannot be shifted without the secret.
|
||||
func keySig(secret, org string) []byte {
|
||||
m := hmac.New(sha256.New, []byte(secret))
|
||||
m.Write([]byte(org))
|
||||
return m.Sum(nil)[:sigBytes]
|
||||
}
|
||||
|
||||
// mintPublishableKey mints "pk_<b64url(org)>.<b64url(sig)>" for org under secret.
|
||||
// '.' is the delimiter because it is OUTSIDE the base64url alphabet (which uses
|
||||
// '-' and '_'), so the two segments split unambiguously. Returns ("",false) when
|
||||
// the secret is unconfigured (fail closed) or org is empty.
|
||||
func mintPublishableKey(secret, org string) (string, bool) {
|
||||
org = strings.TrimSpace(org)
|
||||
if secret == "" || org == "" {
|
||||
return "", false
|
||||
}
|
||||
b64 := base64.RawURLEncoding
|
||||
return publishablePrefix + b64.EncodeToString([]byte(org)) + "." + b64.EncodeToString(keySig(secret, org)), true
|
||||
}
|
||||
|
||||
// verifyPublishableKey resolves a presented key to its org, or ("",false) if the
|
||||
// key is not a well-formed, correctly-signed publishable key under the configured
|
||||
// secret. FAILS CLOSED: unconfigured secret, wrong prefix, malformed segments, or
|
||||
// a signature mismatch all return not-ok. Constant-time signature compare. Pure:
|
||||
// no I/O, so tests drive it directly.
|
||||
func verifyPublishableKey(secret, key string) (string, bool) {
|
||||
key = strings.TrimSpace(key)
|
||||
if secret == "" || !strings.HasPrefix(key, publishablePrefix) {
|
||||
return "", false
|
||||
}
|
||||
body := key[len(publishablePrefix):]
|
||||
dot := strings.IndexByte(body, '.')
|
||||
if dot <= 0 || dot == len(body)-1 {
|
||||
return "", false
|
||||
}
|
||||
b64 := base64.RawURLEncoding
|
||||
orgBytes, err := b64.DecodeString(body[:dot])
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
sig, err := b64.DecodeString(body[dot+1:])
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
org := string(orgBytes)
|
||||
if org == "" || len(org) > maxIngestOrgLen {
|
||||
return "", false
|
||||
}
|
||||
if subtle.ConstantTimeCompare(sig, keySig(secret, org)) != 1 {
|
||||
return "", false
|
||||
}
|
||||
return org, true
|
||||
}
|
||||
|
||||
// maxIngestOrgLen bounds a decoded org (it becomes a warehouse partition key),
|
||||
// mirroring the cap OrgForKey applies to an IAM-resolved owner.
|
||||
const maxIngestOrgLen = 128
|
||||
|
||||
// ── request key extraction ───────────────────────────────────────────────────
|
||||
|
||||
// ingestKey pulls the presented publishable key, in priority order: the
|
||||
// Authorization: Bearer header (the common browser-fetch shape), the
|
||||
// x-hanzo-ingest-key header, then the ?ingest_key= query (navigator.sendBeacon
|
||||
// cannot set headers). Only a pk_-prefixed value is returned — an unrelated
|
||||
// bearer (a real JWT/IAM key) is ignored here so this door never shadows the
|
||||
// identity path. "" when none is present.
|
||||
func ingestKey(c *zip.Ctx) string {
|
||||
if auth := strings.TrimSpace(c.Header("authorization")); auth != "" {
|
||||
parts := strings.SplitN(auth, " ", 2)
|
||||
if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") {
|
||||
if k := strings.TrimSpace(parts[1]); strings.HasPrefix(k, publishablePrefix) {
|
||||
return k
|
||||
}
|
||||
}
|
||||
}
|
||||
if k := strings.TrimSpace(c.Header("x-hanzo-ingest-key")); strings.HasPrefix(k, publishablePrefix) {
|
||||
return k
|
||||
}
|
||||
if k := strings.TrimSpace(c.Query("ingest_key")); strings.HasPrefix(k, publishablePrefix) {
|
||||
return k
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ── error (exception) folding ────────────────────────────────────────────────
|
||||
|
||||
// Exception is the captured error carried on a type:'error' WireEvent (mirrors
|
||||
// @hanzo/event's Exception). The ingest folds it into properties.$exception so
|
||||
// the ONE events schema needs no new columns and the /v1/errors lens can surface
|
||||
// it straight from the properties JSON.
|
||||
type Exception struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Stack string `json:"stack,omitempty"`
|
||||
Handled *bool `json:"handled,omitempty"`
|
||||
}
|
||||
|
||||
// foldException normalizes a type:'error' event so the write core stores it as a
|
||||
// first-class error: it defaults the type to "error", and lifts the top-level
|
||||
// `error` object into properties.$exception (never mutating the caller's map).
|
||||
// A non-error event passes through unchanged.
|
||||
func foldException(e CaptureEvent) CaptureEvent {
|
||||
if e.Error == nil {
|
||||
return e
|
||||
}
|
||||
if strings.TrimSpace(e.Type) == "" {
|
||||
e.Type = "error"
|
||||
}
|
||||
props := make(map[string]any, len(e.Properties)+1)
|
||||
for k, v := range e.Properties {
|
||||
props[k] = v
|
||||
}
|
||||
props["$exception"] = e.Error
|
||||
e.Properties = props
|
||||
e.Error = nil
|
||||
return e
|
||||
}
|
||||
|
||||
// ── handlers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// ingest answers POST /v1/ingest — the publishable-key direct capture path. The
|
||||
// org is resolved from the SIGNED key (no IAM, no DB), the body is the
|
||||
// @hanzo/event WireEvent batch ({batch:[…]} | {events:[…]}), error events are
|
||||
// folded, and everything funnels through the ONE write core into hanzo.events.
|
||||
// FAILS CLOSED: a missing/unverifiable key is refused (403); the org is never
|
||||
// read from the body.
|
||||
func ingest(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
key := ingestKey(c)
|
||||
if key == "" {
|
||||
return zip.ErrForbidden("publishable ingest key required")
|
||||
}
|
||||
org, ok := verifyPublishableKey(ingestSecret(), key)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("invalid publishable ingest key")
|
||||
}
|
||||
var batch CaptureBatch
|
||||
if err := c.Bind(&batch); err != nil {
|
||||
return zip.ErrBadRequest("malformed ingest batch")
|
||||
}
|
||||
evs := batch.events()
|
||||
for i := range evs {
|
||||
evs[i] = foldException(evs[i])
|
||||
}
|
||||
res, err := ingestEvents(c.Context(), org, sourceIngest, evs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(http.StatusOK, res)
|
||||
}
|
||||
|
||||
// mintKey answers POST /v1/ingest/keys — an org owner (VALIDATED principal) mints
|
||||
// a publishable key for its OWN org. The key is org-scoped to the caller's tenant
|
||||
// (never a body-supplied org), so a caller can only ever mint a key that writes
|
||||
// into its own partition. Fails closed (503) when the secret is unconfigured.
|
||||
func mintKey(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("valid bearer required")
|
||||
}
|
||||
secret := ingestSecret()
|
||||
if secret == "" {
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "publishable keys unavailable: ingest key secret not configured")
|
||||
}
|
||||
key, ok := mintPublishableKey(secret, org)
|
||||
if !ok {
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "could not mint publishable key")
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{"key": key, "org": org, "scope": "ingest"})
|
||||
}
|
||||
|
||||
// errorsLens answers GET /v1/errors — the error-tracking read view: recent
|
||||
// type:'error' events for the org, newest first. Tenant-scoped server-side and
|
||||
// gated on a VALIDATED principal (tenant()), NOT the publishable key — reads
|
||||
// require real auth, reinforcing that pk_ is write-only. The captured exception
|
||||
// is surfaced straight from properties.$exception. limit defaults 50, caps 200.
|
||||
func errorsLens(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("valid bearer required")
|
||||
}
|
||||
limit, _ := strconv.Atoi(strings.TrimSpace(c.Query("limit")))
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
rows, err := aiobject.DatastoreQuery(c.Context(), `
|
||||
SELECT id, timestamp, event, distinct_id, session_id, product, url, path,
|
||||
library, library_version, properties
|
||||
FROM hanzo.events
|
||||
WHERE tenant_id = ? AND event_type = 'error'
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?`, org, limit)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "analytics warehouse unavailable: %v", err)
|
||||
}
|
||||
type errEvent struct {
|
||||
ID string `json:"id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Event string `json:"event"`
|
||||
DistinctID string `json:"distinctId,omitempty"`
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
Product string `json:"product,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Library string `json:"library,omitempty"`
|
||||
LibraryVer string `json:"libraryVersion,omitempty"`
|
||||
Exception json.RawMessage `json:"exception,omitempty"`
|
||||
Properties json.RawMessage `json:"properties,omitempty"`
|
||||
}
|
||||
out := make([]errEvent, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
e := errEvent{
|
||||
ID: asStr(r["id"]), Timestamp: asStr(r["timestamp"]), Event: asStr(r["event"]),
|
||||
DistinctID: asStr(r["distinct_id"]), SessionID: asStr(r["session_id"]),
|
||||
Product: asStr(r["product"]), URL: asStr(r["url"]), Path: asStr(r["path"]),
|
||||
Library: asStr(r["library"]), LibraryVer: asStr(r["library_version"]),
|
||||
}
|
||||
if p := asStr(r["properties"]); p != "" && json.Valid([]byte(p)) {
|
||||
e.Properties = json.RawMessage(p)
|
||||
e.Exception = extractException(p)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{"data": out})
|
||||
}
|
||||
|
||||
// extractException pulls the $exception object out of a properties JSON blob so
|
||||
// the errors lens surfaces it as a first-class field. "" (nil) when absent.
|
||||
func extractException(props string) json.RawMessage {
|
||||
var m map[string]json.RawMessage
|
||||
if json.Unmarshal([]byte(props), &m) != nil {
|
||||
return nil
|
||||
}
|
||||
if ex, ok := m["$exception"]; ok {
|
||||
return ex
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testSecret = "test-ingest-secret-0123456789"
|
||||
|
||||
// mint→verify is a round trip: a key minted for an org verifies back to exactly
|
||||
// that org under the same secret.
|
||||
func TestPublishableKeyRoundTrip(t *testing.T) {
|
||||
for _, org := range []string{"acme", "hanzo", "maxpower", "org-with-dashes", "MixedCase"} {
|
||||
key, ok := mintPublishableKey(testSecret, org)
|
||||
if !ok {
|
||||
t.Fatalf("mint failed for %q", org)
|
||||
}
|
||||
got, ok := verifyPublishableKey(testSecret, key)
|
||||
if !ok {
|
||||
t.Fatalf("verify failed for freshly minted key %q", key)
|
||||
}
|
||||
if got != org {
|
||||
t.Fatalf("round trip org = %q, want %q", got, org)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The key is write-only by construction: pk_ is NOT accepted by the isAPIKey
|
||||
// family, so it can never be minted into a bearer principal. (Guards the prefix
|
||||
// choice — an accidental switch to a dash prefix would silently make the key
|
||||
// readable.) We assert the prefix here; isAPIKey lives in the parent package.
|
||||
func TestPublishablePrefixIsUnderscore(t *testing.T) {
|
||||
key, _ := mintPublishableKey(testSecret, "acme")
|
||||
if key[:3] != "pk_" {
|
||||
t.Fatalf("publishable key must start with pk_ (write-only lane), got %q", key[:3])
|
||||
}
|
||||
}
|
||||
|
||||
// verify FAILS CLOSED on every malformed / forged / unconfigured case.
|
||||
func TestVerifyFailsClosed(t *testing.T) {
|
||||
good, _ := mintPublishableKey(testSecret, "acme")
|
||||
cases := []struct {
|
||||
name, secret, key string
|
||||
}{
|
||||
{"no secret", "", good},
|
||||
{"wrong prefix", testSecret, "sk-abcdef"},
|
||||
{"empty", testSecret, ""},
|
||||
{"no delimiter", testSecret, "pk_YWNtZQ"},
|
||||
{"trailing delimiter", testSecret, "pk_YWNtZQ."},
|
||||
{"leading delimiter", testSecret, "pk_.YWNtZQ"},
|
||||
{"bad base64 org", testSecret, "pk_!!!.YWNtZQ"},
|
||||
{"bad base64 sig", testSecret, "pk_YWNtZQ.!!!"},
|
||||
{"wrong secret", "other-secret", good},
|
||||
{"tampered org keeps old sig", testSecret, forgeOrg(good, "evil")},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if org, ok := verifyPublishableKey(tc.secret, tc.key); ok {
|
||||
t.Errorf("%s: verify admitted a bad key → org %q (want fail closed)", tc.name, org)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// forgeOrg swaps the org segment of a real key while keeping its signature — the
|
||||
// canonical forgery attempt the HMAC must reject.
|
||||
func forgeOrg(key, newOrg string) string {
|
||||
// pk_<b64org>.<b64sig> — replace the b64org segment.
|
||||
dot := -1
|
||||
for i := 3; i < len(key); i++ {
|
||||
if key[i] == '.' {
|
||||
dot = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if dot < 0 {
|
||||
return key
|
||||
}
|
||||
enc := base64Raw(newOrg)
|
||||
return "pk_" + enc + key[dot:]
|
||||
}
|
||||
|
||||
func base64Raw(s string) string {
|
||||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||
src := []byte(s)
|
||||
var out []byte
|
||||
for i := 0; i < len(src); i += 3 {
|
||||
var b [3]byte
|
||||
n := copy(b[:], src[i:])
|
||||
out = append(out, alphabet[b[0]>>2])
|
||||
out = append(out, alphabet[(b[0]&0x03)<<4|b[1]>>4])
|
||||
if n > 1 {
|
||||
out = append(out, alphabet[(b[1]&0x0f)<<2|b[2]>>6])
|
||||
}
|
||||
if n > 2 {
|
||||
out = append(out, alphabet[b[2]&0x3f])
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// foldException lifts a type:'error' event's exception into properties.$exception
|
||||
// and defaults the type, so the write core stores it as event_type='error'.
|
||||
func TestFoldException(t *testing.T) {
|
||||
handled := false
|
||||
e := CaptureEvent{
|
||||
Error: &Exception{Type: "TypeError", Message: "x is not a function", Stack: "at f()", Handled: &handled},
|
||||
}
|
||||
got := foldException(e)
|
||||
if got.Type != "error" {
|
||||
t.Fatalf("type = %q, want error", got.Type)
|
||||
}
|
||||
if got.Error != nil {
|
||||
t.Fatalf("error object must be lifted out (nil after fold), got %+v", got.Error)
|
||||
}
|
||||
ex, ok := got.Properties["$exception"]
|
||||
if !ok {
|
||||
t.Fatal("properties.$exception missing after fold")
|
||||
}
|
||||
b, _ := json.Marshal(ex)
|
||||
var back Exception
|
||||
if json.Unmarshal(b, &back) != nil || back.Message != "x is not a function" {
|
||||
t.Fatalf("lifted exception malformed: %s", b)
|
||||
}
|
||||
|
||||
// normalizeEvent must then store event_type='error'.
|
||||
row, ok := normalizeEvent("acme", time.Now().UTC(), got)
|
||||
if !ok {
|
||||
t.Fatal("normalize dropped a folded error event")
|
||||
}
|
||||
if row.eventType != "error" || row.event != "$error" {
|
||||
t.Fatalf("stored type/event = %q/%q, want error/$error", row.eventType, row.event)
|
||||
}
|
||||
|
||||
// A non-error event is untouched.
|
||||
plain := CaptureEvent{Event: "click"}
|
||||
if foldException(plain).Type != "" {
|
||||
t.Fatal("non-error event was mutated by foldException")
|
||||
}
|
||||
}
|
||||
|
||||
// canonicalType now recognizes error as first-class (still folds unknowns).
|
||||
func TestCanonicalTypeError(t *testing.T) {
|
||||
if canonicalType("error") != "error" {
|
||||
t.Fatal("error must canonicalize to error")
|
||||
}
|
||||
if canonicalType("ERROR") != "error" {
|
||||
t.Fatal("error canonicalization must be case-insensitive")
|
||||
}
|
||||
if canonicalType("weird") != "event" {
|
||||
t.Fatal("unknown type must still fold to event")
|
||||
}
|
||||
}
|
||||
@@ -154,29 +154,30 @@ func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
// routes registers the automations surface: the connector catalog, flow CRUD +
|
||||
// versioning + lifecycle, run history, and the MCP endpoint.
|
||||
func routes(app *zip.App, s *cloud.Service[state]) {
|
||||
app.Get("/v1/automations/connectors", cloud.Handle(s, connectors))
|
||||
g := app.Group("/v1/automations")
|
||||
g.Get("/connectors", cloud.Handle(s, connectors))
|
||||
// Back-compat alias: the pre-rename /pieces path stays valid (same handler, same
|
||||
// body) so live clients pinned to it keep working. "pieces" is the retired
|
||||
// ActivePieces term; "connectors" is the ONE Hanzo name (HIP-0126).
|
||||
app.Get("/v1/automations/pieces", cloud.Handle(s, connectors))
|
||||
g.Get("/pieces", cloud.Handle(s, connectors))
|
||||
|
||||
app.Get("/v1/automations/flows", cloud.Handle(s, listFlows))
|
||||
app.Post("/v1/automations/flows", cloud.Handle(s, createFlow))
|
||||
app.Get("/v1/automations/flows/:id", cloud.Handle(s, getFlow))
|
||||
app.Patch("/v1/automations/flows/:id", cloud.Handle(s, updateFlow))
|
||||
app.Delete("/v1/automations/flows/:id", cloud.Handle(s, deleteFlow))
|
||||
app.Get("/v1/automations/flows/:id/versions", cloud.Handle(s, listVersions))
|
||||
app.Post("/v1/automations/flows/:id/versions", cloud.Handle(s, createVersion))
|
||||
app.Post("/v1/automations/flows/:id/operations", cloud.Handle(s, applyOperation))
|
||||
app.Post("/v1/automations/flows/:id/run", cloud.Handle(s, runFlow))
|
||||
app.Post("/v1/automations/flows/:id/enable", cloud.Handle(s, enableFlow))
|
||||
app.Post("/v1/automations/flows/:id/disable", cloud.Handle(s, disableFlow))
|
||||
g.Get("/flows", cloud.Handle(s, listFlows))
|
||||
g.Post("/flows", cloud.Handle(s, createFlow))
|
||||
g.Get("/flows/:id", cloud.Handle(s, getFlow))
|
||||
g.Patch("/flows/:id", cloud.Handle(s, updateFlow))
|
||||
g.Delete("/flows/:id", cloud.Handle(s, deleteFlow))
|
||||
g.Get("/flows/:id/versions", cloud.Handle(s, listVersions))
|
||||
g.Post("/flows/:id/versions", cloud.Handle(s, createVersion))
|
||||
g.Post("/flows/:id/operations", cloud.Handle(s, applyOperation))
|
||||
g.Post("/flows/:id/run", cloud.Handle(s, runFlow))
|
||||
g.Post("/flows/:id/enable", cloud.Handle(s, enableFlow))
|
||||
g.Post("/flows/:id/disable", cloud.Handle(s, disableFlow))
|
||||
|
||||
app.Get("/v1/automations/runs", cloud.Handle(s, listRuns))
|
||||
app.Get("/v1/automations/runs/:id", cloud.Handle(s, getRun))
|
||||
app.Post("/v1/automations/runs/:id/resume", cloud.Handle(s, resumeRun))
|
||||
g.Get("/runs", cloud.Handle(s, listRuns))
|
||||
g.Get("/runs/:id", cloud.Handle(s, getRun))
|
||||
g.Post("/runs/:id/resume", cloud.Handle(s, resumeRun))
|
||||
|
||||
app.Post("/v1/automations/mcp", cloud.Handle(s, mcp))
|
||||
g.Post("/mcp", cloud.Handle(s, mcp))
|
||||
}
|
||||
|
||||
// Shutdown closes the store. Idempotent — safe when nothing is mounted.
|
||||
|
||||
@@ -61,6 +61,7 @@ import (
|
||||
"github.com/hanzoai/base/plugins/waitlist"
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
"github.com/hanzoai/cloud/clients/sites"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
@@ -158,12 +159,43 @@ func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
app.All("/v1/base/*", func(c *zip.Ctx) error { return serveOrg(p, log, c) })
|
||||
|
||||
mounted = &subsystem{pool: p, platform: platformApp}
|
||||
|
||||
// Host-as-project-ref (HIP-0014, gated by CLOUD_BASE_PUBLIC_HOST, default OFF):
|
||||
// let a published site host serve /v1/base, /v1/realtime and /_/ scoped to the
|
||||
// org its SUBDOMAIN resolves to — so an anon page can reach its own Base, authz
|
||||
// by Base's collection rules. The org comes from the resolved site, never the
|
||||
// caller. Absent the flag, site hosts serve only static files (unchanged).
|
||||
if publicHostEnabled() {
|
||||
sites.SetBaseHostHandler(func(org string, c *zip.Ctx) error {
|
||||
h, release, err := p.acquire(org)
|
||||
if err != nil {
|
||||
log.Error("base: open org app failed", "err", err)
|
||||
return zip.Errorf(http.StatusInternalServerError, "base unavailable")
|
||||
}
|
||||
defer release()
|
||||
return zip.AdaptNetHTTP(h)(c)
|
||||
})
|
||||
log.Info("base public-host routing enabled", "flag", publicHostEnv)
|
||||
}
|
||||
|
||||
log.Info("base app embedded",
|
||||
"waitlist", "/v1/waitlist/*", "hosting", "/v1/base/*",
|
||||
"prefix", os.Getenv("BASE_API_PREFIX"), "brand", deps.Brand, "env", deps.Env)
|
||||
return nil
|
||||
}
|
||||
|
||||
// publicHostEnv gates host-as-project-ref Base routing (default OFF).
|
||||
const publicHostEnv = "CLOUD_BASE_PUBLIC_HOST"
|
||||
|
||||
func publicHostEnabled() bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv(publicHostEnv))) {
|
||||
case "1", "true", "yes":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// serveOrg resolves the caller's org from the validated principal, acquires that
|
||||
// org's pooled Base app (pinned for the request so eviction can't close it
|
||||
// mid-flight), and serves the request through the org's own Base mux.
|
||||
|
||||
@@ -4,11 +4,12 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/hanzoai/account"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/account"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/finance"
|
||||
"github.com/hanzoai/cloud/clients/money"
|
||||
"github.com/hanzoai/cloud/types"
|
||||
@@ -45,6 +46,9 @@ func (f *fakeFinance) Balance(_ context.Context, org, subject, _ string, _ bool)
|
||||
|
||||
func (f *fakeFinance) Deposit(context.Context, types.DepositInput) (string, error) { return "", nil }
|
||||
func (f *fakeFinance) RecordUsage(context.Context, types.UsageInput) error { return nil }
|
||||
func (f *fakeFinance) SumUsageSince(context.Context, string, bool, int64) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func publishFinance(t *testing.T, f *fakeFinance) {
|
||||
t.Helper()
|
||||
|
||||
@@ -152,6 +152,12 @@ func build(b cloud.Base) (state, error) {
|
||||
// /v1/finance/* projection (same commerceProxy).
|
||||
func routes(app *zip.App, s *cloud.Service[state]) {
|
||||
app.Get("/v1/billing/usage", cloud.Handle(s, usage))
|
||||
// The per-account routed-usage breakdown the dashboard reads, in the billing
|
||||
// namespace beside /v1/billing/usage. The data is owned by clients/link (the
|
||||
// linked-account plane); this thin handler asks it, scoped to the caller's OWN
|
||||
// (org, subject). Registered here — not from link — so it shadows the console
|
||||
// pkg's /v1/billing/* wildcard exactly like the other specific customer routes.
|
||||
app.Get("/v1/billing/usage/accounts", cloud.Handle(s, usageAccounts))
|
||||
app.Get("/v1/billing/balance", cloud.Handle(s, balance))
|
||||
// GPU launch gate + saved cards — the customer half of the prepay-only GPU rule
|
||||
// commerce enforces server-side (api/billing/gpu_charge.go). Same org-scoping as
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package billing
|
||||
|
||||
// usage_accounts.go answers GET /v1/billing/usage/accounts — the per-account
|
||||
// SERVER-ROUTED usage breakdown the unified dashboard reads beside /v1/billing/usage.
|
||||
//
|
||||
// TENANT ISOLATION (the whole point, same as usage/balance). The org is the VALIDATED
|
||||
// IAM owner claim (principal.Org — the trusted X-Org-Id the identity middleware
|
||||
// minted, HIP-0026; NEVER a client header), and the subject is the validated user
|
||||
// (c.User(), which principal.Org having returned ok guarantees non-empty). The
|
||||
// breakdown is read from the linked-account plane scoped to exactly that (org,
|
||||
// subject), so a caller sees ONLY their OWN linked accounts — no client-supplied
|
||||
// subject/account/org param is ever consulted.
|
||||
//
|
||||
// The data lives in clients/link (RoutedBreakdown); this is a thin, org-scoped read
|
||||
// twin of usage(), mounted in the billing namespace so the dashboard's usage panels
|
||||
// find it where they already read /v1/billing/usage.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/link"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// usageAccounts serves the caller's per-account routed-usage breakdown.
|
||||
func usageAccounts(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := principal.Org(c)
|
||||
if !ok {
|
||||
return zip.ErrUnauthorized("sign in to view billing")
|
||||
}
|
||||
// c.User() is guaranteed non-empty once principal.Org returned ok (Org composes
|
||||
// Validated, which is c.User() != ""). It is the account-scope subject, taken from
|
||||
// the validated principal — never a request field.
|
||||
view, ok := link.RoutedBreakdown(c.Context(), org, c.User())
|
||||
if !ok {
|
||||
// The linked-account plane is not co-resident (a split deploy). Honest
|
||||
// "unavailable" — never a fabricated empty breakdown that reads as "no usage".
|
||||
return zip.Errorf(http.StatusNotImplemented, "per-account usage is not available on this deployment")
|
||||
}
|
||||
c.SetHeader("Content-Type", "application/json")
|
||||
c.SetHeader("Cache-Control", "no-store")
|
||||
return c.JSON(http.StatusOK, view)
|
||||
}
|
||||
@@ -134,9 +134,10 @@ func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
// routes registers the bots surface. The static /run literal and the :runId param
|
||||
// are resolved by specificity, so /v1/bots/run can never bind as a run id.
|
||||
func routes(app *zip.App, s *cloud.Service[state]) {
|
||||
app.Post("/v1/bots/run", cloud.Handle(s, run))
|
||||
g := app.Group("/v1/bots")
|
||||
g.Post("/run", cloud.Handle(s, run))
|
||||
app.Get("/v1/bots", cloud.Handle(s, list))
|
||||
app.Post("/v1/bots/:runId/stop", cloud.Handle(s, stop))
|
||||
g.Post("/:runId/stop", cloud.Handle(s, stop))
|
||||
}
|
||||
|
||||
// run reports that launching is not implemented.
|
||||
|
||||
@@ -98,48 +98,49 @@ func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
// routes wires the /v1/captable/* route table → bundle route names. GET reads
|
||||
// carry no body; mutations do.
|
||||
func routes(app *zip.App, s *cloud.Service[state]) {
|
||||
g := app.Group("/v1/captable")
|
||||
// company
|
||||
app.Get("/v1/captable/company", route(s, "company.get", nil, false))
|
||||
app.Put("/v1/captable/company", route(s, "company.update", nil, true))
|
||||
g.Get("/company", route(s, "company.get", nil, false))
|
||||
g.Put("/company", route(s, "company.update", nil, true))
|
||||
// stakeholders
|
||||
app.Get("/v1/captable/stakeholders", route(s, "stakeholders.list", nil, false))
|
||||
app.Post("/v1/captable/stakeholders", route(s, "stakeholders.add", nil, true))
|
||||
app.Patch("/v1/captable/stakeholders/:id", routeID(s, "stakeholders.update", true))
|
||||
app.Delete("/v1/captable/stakeholders/:id", routeID(s, "stakeholders.delete", false))
|
||||
g.Get("/stakeholders", route(s, "stakeholders.list", nil, false))
|
||||
g.Post("/stakeholders", route(s, "stakeholders.add", nil, true))
|
||||
g.Patch("/stakeholders/:id", routeID(s, "stakeholders.update", true))
|
||||
g.Delete("/stakeholders/:id", routeID(s, "stakeholders.delete", false))
|
||||
// share classes
|
||||
app.Get("/v1/captable/share-classes", route(s, "shareClasses.list", nil, false))
|
||||
app.Post("/v1/captable/share-classes", route(s, "shareClasses.create", nil, true))
|
||||
app.Patch("/v1/captable/share-classes/:id", routeID(s, "shareClasses.update", true))
|
||||
g.Get("/share-classes", route(s, "shareClasses.list", nil, false))
|
||||
g.Post("/share-classes", route(s, "shareClasses.create", nil, true))
|
||||
g.Patch("/share-classes/:id", routeID(s, "shareClasses.update", true))
|
||||
// equity plans
|
||||
app.Get("/v1/captable/equity-plans", route(s, "equityPlans.list", nil, false))
|
||||
app.Post("/v1/captable/equity-plans", route(s, "equityPlans.create", nil, true))
|
||||
g.Get("/equity-plans", route(s, "equityPlans.list", nil, false))
|
||||
g.Post("/equity-plans", route(s, "equityPlans.create", nil, true))
|
||||
// shares (issuance + transfer). /shares/transfer registers before /shares/:id
|
||||
// (different methods anyway) so it can never be shadowed.
|
||||
app.Get("/v1/captable/shares", route(s, "shares.list", nil, false))
|
||||
app.Post("/v1/captable/shares", route(s, "shares.add", nil, true))
|
||||
app.Post("/v1/captable/shares/transfer", route(s, "shares.transfer", nil, true))
|
||||
app.Delete("/v1/captable/shares/:id", routeID(s, "shares.delete", false))
|
||||
g.Get("/shares", route(s, "shares.list", nil, false))
|
||||
g.Post("/shares", route(s, "shares.add", nil, true))
|
||||
g.Post("/shares/transfer", route(s, "shares.transfer", nil, true))
|
||||
g.Delete("/shares/:id", routeID(s, "shares.delete", false))
|
||||
// options
|
||||
app.Get("/v1/captable/options", route(s, "options.list", nil, false))
|
||||
app.Post("/v1/captable/options", route(s, "options.add", nil, true))
|
||||
app.Delete("/v1/captable/options/:id", routeID(s, "options.delete", false))
|
||||
g.Get("/options", route(s, "options.list", nil, false))
|
||||
g.Post("/options", route(s, "options.add", nil, true))
|
||||
g.Delete("/options/:id", routeID(s, "options.delete", false))
|
||||
// SAFEs
|
||||
app.Get("/v1/captable/safes", route(s, "safes.list", nil, false))
|
||||
app.Post("/v1/captable/safes", route(s, "safes.create", nil, true))
|
||||
app.Delete("/v1/captable/safes/:id", routeID(s, "safes.delete", false))
|
||||
g.Get("/safes", route(s, "safes.list", nil, false))
|
||||
g.Post("/safes", route(s, "safes.create", nil, true))
|
||||
g.Delete("/safes/:id", routeID(s, "safes.delete", false))
|
||||
// convertible notes
|
||||
app.Get("/v1/captable/convertibles", route(s, "convertibles.list", nil, false))
|
||||
app.Post("/v1/captable/convertibles", route(s, "convertibles.create", nil, true))
|
||||
app.Delete("/v1/captable/convertibles/:id", routeID(s, "convertibles.delete", false))
|
||||
g.Get("/convertibles", route(s, "convertibles.list", nil, false))
|
||||
g.Post("/convertibles", route(s, "convertibles.create", nil, true))
|
||||
g.Delete("/convertibles/:id", routeID(s, "convertibles.delete", false))
|
||||
// rounds + investments
|
||||
app.Get("/v1/captable/rounds", route(s, "rounds.list", nil, false))
|
||||
app.Post("/v1/captable/rounds", route(s, "rounds.create", nil, true))
|
||||
app.Get("/v1/captable/rounds/:id", routeID(s, "rounds.get", false))
|
||||
app.Post("/v1/captable/rounds/:id/close", routeID(s, "rounds.close", true))
|
||||
app.Post("/v1/captable/rounds/:id/investments", routeID(s, "rounds.investments.add", true))
|
||||
app.Get("/v1/captable/investments", route(s, "rounds.investments.list", nil, false))
|
||||
g.Get("/rounds", route(s, "rounds.list", nil, false))
|
||||
g.Post("/rounds", route(s, "rounds.create", nil, true))
|
||||
g.Get("/rounds/:id", routeID(s, "rounds.get", false))
|
||||
g.Post("/rounds/:id/close", routeID(s, "rounds.close", true))
|
||||
g.Post("/rounds/:id/investments", routeID(s, "rounds.investments.add", true))
|
||||
g.Get("/investments", route(s, "rounds.investments.list", nil, false))
|
||||
// computed cap table
|
||||
app.Get("/v1/captable/summary", route(s, "captable", nil, false))
|
||||
g.Get("/summary", route(s, "captable", nil, false))
|
||||
}
|
||||
|
||||
// route builds a zip handler that dispatches a fixed bundle route. readBody
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)."
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
package channels
|
||||
|
||||
// routes.go — the /v1/channels HTTP surface. Every route is org-gated
|
||||
// (principal.Org) and wrapped cloud.Terminal(cloud.Handle(...)): channels
|
||||
// mounts after the commerce /v1 error-flattening filter, so Terminal writes
|
||||
// the real 4xx in-band before that filter can rewrite it to 500 (service.go).
|
||||
// Mutations (pairing approve, allowlist put) additionally require org admin.
|
||||
// There are NO public routes here — platform webhooks stay in integrations.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/integrations"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// sendMaxBody bounds one POST /send body (attachments are URLs, not bytes).
|
||||
const sendMaxBody = 1 << 20 // 1 MiB
|
||||
|
||||
// routes registers the /v1/channels surface. The :channel send route is LAST:
|
||||
// zip matches in registration order, so the static paths above must win.
|
||||
func routes(app *zip.App, s *cloud.Service[state]) {
|
||||
g := app.Group("/v1/channels")
|
||||
app.Get("/v1/channels", cloud.Terminal(cloud.Handle(s, list)))
|
||||
g.Get("/inbox", cloud.Terminal(cloud.Handle(s, inbox)))
|
||||
g.Get("/pairing", cloud.Terminal(cloud.Handle(s, pairingList)))
|
||||
g.Post("/pairing/approve", cloud.Terminal(cloud.Handle(s, pairingApprove)))
|
||||
g.Get("/allowlist", cloud.Terminal(cloud.Handle(s, allowlistGet)))
|
||||
g.Put("/allowlist", cloud.Terminal(cloud.Handle(s, allowlistPut)))
|
||||
g.Post("/:channel/send", cloud.Terminal(cloud.Handle(s, send)))
|
||||
}
|
||||
|
||||
// ── JSON projections (camelCase, closed shapes) ──────────────────────────────
|
||||
|
||||
type channelView struct {
|
||||
ID string `json:"id"`
|
||||
Connected bool `json:"connected"`
|
||||
Account string `json:"account"`
|
||||
AccountLabel string `json:"accountLabel"`
|
||||
Capabilities capabilities `json:"capabilities"`
|
||||
DMPolicy DMPolicy `json:"dmPolicy"`
|
||||
GroupPolicy GroupPolicy `json:"groupPolicy"`
|
||||
PendingPairing int `json:"pendingPairing"`
|
||||
}
|
||||
|
||||
type inboxView struct {
|
||||
ID int64 `json:"id"`
|
||||
Channel string `json:"channel"`
|
||||
Account string `json:"account"`
|
||||
RoomID string `json:"roomId"`
|
||||
RoomKind string `json:"roomKind"`
|
||||
Sender string `json:"sender"`
|
||||
SenderUser string `json:"senderUser,omitempty"`
|
||||
Text string `json:"text"`
|
||||
ReplyTo string `json:"replyTo,omitempty"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
type pairingView struct {
|
||||
Channel string `json:"channel"`
|
||||
Sender string `json:"sender"`
|
||||
Code string `json:"code"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
LastSeen int64 `json:"lastSeen"`
|
||||
}
|
||||
|
||||
type allowlistView struct {
|
||||
DMPolicy DMPolicy `json:"dmPolicy"`
|
||||
GroupPolicy GroupPolicy `json:"groupPolicy"`
|
||||
DM []string `json:"dm"`
|
||||
Group []string `json:"group"`
|
||||
Paired []string `json:"paired"`
|
||||
AccessGroups map[string]map[string][]string `json:"accessGroups"`
|
||||
}
|
||||
|
||||
// nonNil keeps list fields JSON arrays ([] not null) — integrations idiom.
|
||||
func nonNil(v []string) []string {
|
||||
if v == nil {
|
||||
return []string{}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ── handlers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// list is GET /v1/channels — the deterministic transport listing (registry
|
||||
// order) with the org's connection, policy, and pending-pairing facts.
|
||||
func list(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := principal.Org(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("a validated principal is required")
|
||||
}
|
||||
ctx := c.Context()
|
||||
pending := map[string]int{}
|
||||
rows, err := listPairing(ctx, s.State.store, org, time.Now().Unix())
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "pairing: %v", err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
pending[r.Channel]++
|
||||
}
|
||||
out := make([]channelView, 0, len(transports))
|
||||
for _, tr := range transports {
|
||||
conn, connected := integrations.ConnectionFor(org, tr.id)
|
||||
// Absent row ⇒ defaults (policyFor); a read error leaves zero policy
|
||||
// fields rather than failing the whole listing.
|
||||
p, _ := policyFor(ctx, s.State.store, org, tr.id)
|
||||
out = append(out, channelView{
|
||||
ID: tr.id,
|
||||
Connected: connected,
|
||||
// C2-7: account is the id-shaped fact (lowercased external id),
|
||||
// accountLabel the human label — never swapped, on any surface.
|
||||
Account: strings.ToLower(conn.ExternalID),
|
||||
AccountLabel: conn.AccountLabel,
|
||||
Capabilities: tr.caps,
|
||||
DMPolicy: p.DM,
|
||||
GroupPolicy: p.Group,
|
||||
PendingPairing: pending[tr.id],
|
||||
})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{"channels": out})
|
||||
}
|
||||
|
||||
// inbox is GET /v1/channels/inbox?since=&limit= — the org's stored inbound
|
||||
// messages, oldest first; cursor is the last row id (or since when empty).
|
||||
func inbox(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := principal.Org(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("a validated principal is required")
|
||||
}
|
||||
var since int64
|
||||
if q := c.Query("since"); q != "" {
|
||||
v, err := strconv.ParseInt(q, 10, 64)
|
||||
if err != nil {
|
||||
return zip.ErrBadRequest("since must be an integer cursor")
|
||||
}
|
||||
since = v
|
||||
}
|
||||
var limit int
|
||||
if q := c.Query("limit"); q != "" {
|
||||
v, err := strconv.Atoi(q)
|
||||
if err != nil {
|
||||
return zip.ErrBadRequest("limit must be an integer")
|
||||
}
|
||||
limit = v
|
||||
}
|
||||
rows, err := s.State.store.listInbox(c.Context(), org, since, limit)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "inbox: %v", err)
|
||||
}
|
||||
msgs := make([]inboxView, 0, len(rows))
|
||||
cursor := since
|
||||
for _, r := range rows {
|
||||
msgs = append(msgs, inboxView{
|
||||
ID: r.ID,
|
||||
Channel: r.Channel,
|
||||
Account: r.Account,
|
||||
RoomID: r.RoomID,
|
||||
RoomKind: string(r.RoomKind),
|
||||
Sender: r.Sender,
|
||||
SenderUser: r.SenderUser,
|
||||
Text: r.Text,
|
||||
ReplyTo: r.ReplyTo,
|
||||
CreatedAt: r.CreatedAt,
|
||||
})
|
||||
cursor = r.ID
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{"messages": msgs, "cursor": cursor})
|
||||
}
|
||||
|
||||
// pairingList is GET /v1/channels/pairing — the org's pending (unexpired)
|
||||
// pairing requests. Codes are capability strings shown to org members for
|
||||
// admin approval; they are returned here, never logged.
|
||||
func pairingList(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := principal.Org(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("a validated principal is required")
|
||||
}
|
||||
rows, err := listPairing(c.Context(), s.State.store, org, time.Now().Unix())
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "pairing: %v", err)
|
||||
}
|
||||
out := make([]pairingView, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, pairingView{
|
||||
Channel: r.Channel,
|
||||
Sender: r.Sender,
|
||||
Code: r.Code,
|
||||
CreatedAt: r.CreatedAt,
|
||||
LastSeen: r.LastSeen,
|
||||
})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{"pending": out})
|
||||
}
|
||||
|
||||
// pairingApprove is POST /v1/channels/pairing/approve — admin-gated; turns a
|
||||
// pending code into a pairing-source allow entry (approvePairing owns the
|
||||
// write and the one-time owner bootstrap).
|
||||
func pairingApprove(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := principal.Org(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("a validated principal is required")
|
||||
}
|
||||
if !(principal.IsSuperAdmin(c) || principal.IsOrgAdmin(c)) {
|
||||
return zip.ErrForbidden("approving a pairing requires org admin")
|
||||
}
|
||||
var body struct {
|
||||
Channel string `json:"channel"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.Unmarshal(c.Body(), &body); err != nil {
|
||||
return zip.ErrBadRequest("invalid request body")
|
||||
}
|
||||
channel := strings.TrimSpace(body.Channel)
|
||||
code := strings.TrimSpace(body.Code)
|
||||
if channel == "" || code == "" {
|
||||
return zip.ErrBadRequest("channel and code are required")
|
||||
}
|
||||
sender, ownerBoot, ok, err := approvePairing(c.Context(), s.State.store, org, channel, code, time.Now().Unix())
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "approve: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
return zip.ErrNotFound("unknown or expired code")
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{"sender": sender, "ownerBootstrapped": ownerBoot})
|
||||
}
|
||||
|
||||
// allowlistGet is GET /v1/channels/allowlist?channel=.
|
||||
func allowlistGet(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := principal.Org(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("a validated principal is required")
|
||||
}
|
||||
channel := strings.TrimSpace(c.Query("channel"))
|
||||
if channel == "" {
|
||||
return zip.ErrBadRequest("channel query parameter is required")
|
||||
}
|
||||
if _, ok := transportFor(channel); !ok {
|
||||
return zip.ErrNotFound("unknown channel")
|
||||
}
|
||||
v, err := allowlistFor(c.Context(), s.State.store, org, channel)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "allowlist: %v", err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, v)
|
||||
}
|
||||
|
||||
// allowlistPut is PUT /v1/channels/allowlist — admin-gated; each body field is
|
||||
// applied only when provided (nil slice / empty string = untouched), then the
|
||||
// GET payload is echoed so both verbs return ONE shape.
|
||||
func allowlistPut(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := principal.Org(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("a validated principal is required")
|
||||
}
|
||||
if !(principal.IsSuperAdmin(c) || principal.IsOrgAdmin(c)) {
|
||||
return zip.ErrForbidden("editing the allowlist requires org admin")
|
||||
}
|
||||
var body struct {
|
||||
Channel string `json:"channel"`
|
||||
DMPolicy string `json:"dmPolicy"`
|
||||
GroupPolicy string `json:"groupPolicy"`
|
||||
DM []string `json:"dm"`
|
||||
Group []string `json:"group"`
|
||||
AccessGroups map[string]map[string][]string `json:"accessGroups"`
|
||||
}
|
||||
if err := json.Unmarshal(c.Body(), &body); err != nil {
|
||||
return zip.ErrBadRequest("invalid request body")
|
||||
}
|
||||
channel := strings.TrimSpace(body.Channel)
|
||||
if channel == "" {
|
||||
return zip.ErrBadRequest("channel is required")
|
||||
}
|
||||
if _, ok := transportFor(channel); !ok {
|
||||
return zip.ErrNotFound("unknown channel")
|
||||
}
|
||||
dm, group := DMPolicy(body.DMPolicy), GroupPolicy(body.GroupPolicy)
|
||||
switch dm {
|
||||
case "", DMPairing, DMAllowlist, DMOpen:
|
||||
default:
|
||||
return zip.ErrBadRequest("dmPolicy must be pairing, allowlist, or open")
|
||||
}
|
||||
switch group {
|
||||
case "", GroupOpen, GroupAllowlist, GroupDisabled:
|
||||
default:
|
||||
return zip.ErrBadRequest("groupPolicy must be open, allowlist, or disabled")
|
||||
}
|
||||
ctx := c.Context()
|
||||
st := s.State.store
|
||||
now := time.Now().Unix()
|
||||
if dm != "" || group != "" {
|
||||
p, err := policyFor(ctx, st, org, channel)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "policy: %v", err)
|
||||
}
|
||||
if dm != "" {
|
||||
p.DM = dm
|
||||
}
|
||||
if group != "" {
|
||||
p.Group = group
|
||||
}
|
||||
if err := setPolicy(ctx, st, org, channel, p, now); err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "policy: %v", err)
|
||||
}
|
||||
}
|
||||
// channel_allow two-writer split: this PUT owns ONLY config-source rows
|
||||
// (putAllow); pairing-source rows belong to approvePairing — a policy edit
|
||||
// can never revoke an approved pairing.
|
||||
if body.DM != nil {
|
||||
if err := putAllow(ctx, st, org, channel, "dm", body.DM, now); err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "allowlist: %v", err)
|
||||
}
|
||||
}
|
||||
if body.Group != nil {
|
||||
if err := putAllow(ctx, st, org, channel, "group", body.Group, now); err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "allowlist: %v", err)
|
||||
}
|
||||
}
|
||||
if body.AccessGroups != nil {
|
||||
if err := putAccessGroups(ctx, st, org, body.AccessGroups); err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "access groups: %v", err)
|
||||
}
|
||||
}
|
||||
v, err := allowlistFor(ctx, st, org, channel)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "allowlist: %v", err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, v)
|
||||
}
|
||||
|
||||
// allowlistFor builds the allowlist payload GET returns and PUT echoes.
|
||||
func allowlistFor(ctx context.Context, st *store, org, channel string) (allowlistView, error) {
|
||||
p, err := policyFor(ctx, st, org, channel)
|
||||
if err != nil {
|
||||
return allowlistView{}, err
|
||||
}
|
||||
// paired = pairing-source rows, minted only by approvePairing (dm scope —
|
||||
// pairing is a DM concept); surfaced read-only so admins see who is paired.
|
||||
dm, paired, err := allowEntries(ctx, st, org, channel, "dm")
|
||||
if err != nil {
|
||||
return allowlistView{}, err
|
||||
}
|
||||
group, _, err := allowEntries(ctx, st, org, channel, "group")
|
||||
if err != nil {
|
||||
return allowlistView{}, err
|
||||
}
|
||||
groups, err := listAccessGroups(ctx, st, org)
|
||||
if err != nil {
|
||||
return allowlistView{}, err
|
||||
}
|
||||
return allowlistView{
|
||||
DMPolicy: p.DM,
|
||||
GroupPolicy: p.Group,
|
||||
DM: nonNil(dm),
|
||||
Group: nonNil(group),
|
||||
Paired: nonNil(paired),
|
||||
AccessGroups: groups,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// listAccessGroups reads the org's access groups (name → channel → entries) —
|
||||
// the read mirror of putAccessGroups (policy.go), for the allowlist payload.
|
||||
func listAccessGroups(ctx context.Context, st *store, org string) (map[string]map[string][]string, error) {
|
||||
rows, err := st.db.QueryContext(ctx, `SELECT name, channel, entry FROM channel_access_group
|
||||
WHERE org = ? ORDER BY name, channel, entry`, org)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
out := map[string]map[string][]string{}
|
||||
for rows.Next() {
|
||||
var name, channel, entry string
|
||||
if err := rows.Scan(&name, &channel, &entry); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out[name] == nil {
|
||||
out[name] = map[string][]string{}
|
||||
}
|
||||
out[name][channel] = append(out[name][channel], entry)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// send is POST /v1/channels/:channel/send — the ONE egress door. The body is
|
||||
// the envelope's narrow outbound projection (C2-6): identity fields (sender,
|
||||
// account, channel) are not decodable — DisallowUnknownFields rejects them
|
||||
// loudly instead of silently dropping them.
|
||||
func send(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := principal.Org(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("a validated principal is required")
|
||||
}
|
||||
raw := c.Body()
|
||||
if len(raw) > sendMaxBody {
|
||||
return zip.ErrBadRequest("body exceeds 1 MiB")
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
var r SendRequest
|
||||
if err := dec.Decode(&r); err != nil {
|
||||
return zip.ErrBadRequest("invalid body: " + err.Error())
|
||||
}
|
||||
if err := r.validate(); err != nil {
|
||||
return zip.ErrBadRequest(err.Error())
|
||||
}
|
||||
channel := strings.TrimSpace(c.Param("channel"))
|
||||
tr, ok := transportFor(channel)
|
||||
if !ok {
|
||||
return zip.ErrNotFound("unknown channel")
|
||||
}
|
||||
m := Message{
|
||||
Channel: channel,
|
||||
Room: r.Room,
|
||||
Text: r.Text,
|
||||
Attachments: r.Attachments,
|
||||
Actions: r.Actions,
|
||||
ReplyTo: r.ReplyTo,
|
||||
Idempotency: r.Idempotency,
|
||||
}
|
||||
ctx := c.Context()
|
||||
st := s.State.store
|
||||
if r.Idempotency != "" {
|
||||
fresh, prior, err := st.markSend(ctx, org, channel, r.Idempotency, time.Now().Unix())
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "idempotency: %v", err)
|
||||
}
|
||||
if !fresh {
|
||||
return c.JSON(http.StatusOK, prior)
|
||||
}
|
||||
}
|
||||
d, err := tr.send(ctx, s, org, m)
|
||||
if err != nil {
|
||||
// C2-1: release the claimed key in the SAME error path so the caller
|
||||
// can re-attempt; only a completed send replays a receipt.
|
||||
if r.Idempotency != "" {
|
||||
_ = st.unmarkSend(ctx, org, channel, r.Idempotency)
|
||||
}
|
||||
// The transports' typed refusals (errRoomNotBound telegram.go,
|
||||
// errNoRoute discord.go) map to a status HERE, in one place: 403 —
|
||||
// the room is not org-bound; 409 — no inbound-learned route yet.
|
||||
switch {
|
||||
case errors.Is(err, errRoomNotBound):
|
||||
return zip.ErrForbidden("room is not bound to this org")
|
||||
case errors.Is(err, errNoRoute):
|
||||
return zip.ErrConflict("no inbound route for this room; the bot must be messaged there first")
|
||||
}
|
||||
// Door errors carry status/shape only — never tokens (SendSlack /
|
||||
// SendDiscord contract, integrations/ingress.go).
|
||||
return zip.Errorf(http.StatusBadGateway, "%s: %v", tr.id, err)
|
||||
}
|
||||
if r.Idempotency != "" {
|
||||
// Best-effort: the message is delivered; a lost receipt only degrades
|
||||
// a later replay to an empty Delivery (documented markSend tradeoff).
|
||||
if err := st.finishSend(ctx, org, channel, r.Idempotency, d.MessageID); err != nil {
|
||||
s.Log.Warn("channels: send receipt", "channel", channel, "err", err)
|
||||
}
|
||||
}
|
||||
return c.JSON(http.StatusOK, d)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -111,37 +111,38 @@ func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
// target than another. Pages + Workers are WIRED; R2/KV/D1 are typed Phase-2 stubs
|
||||
// that answer an honest 501 (never a fake success).
|
||||
func routes(app *zip.App, s *cloud.Service[state]) {
|
||||
g := app.Group("/v1/integrations/cloudflare")
|
||||
// Pages (wired) — account-scoped.
|
||||
app.Get("/v1/integrations/cloudflare/pages/projects", cloud.Handle(s, pagesList))
|
||||
app.Post("/v1/integrations/cloudflare/pages/projects", cloud.Handle(s, pagesCreate))
|
||||
app.Get("/v1/integrations/cloudflare/pages/projects/:project", cloud.Handle(s, pagesGet))
|
||||
app.Delete("/v1/integrations/cloudflare/pages/projects/:project", cloud.Handle(s, pagesDelete))
|
||||
app.Post("/v1/integrations/cloudflare/pages/projects/:project/deployments", cloud.Handle(s, pagesDeploy))
|
||||
app.Post("/v1/integrations/cloudflare/pages/projects/:project/domains", cloud.Handle(s, pagesDomainAdd))
|
||||
app.Delete("/v1/integrations/cloudflare/pages/projects/:project/domains/:domain", cloud.Handle(s, pagesDomainDelete))
|
||||
g.Get("/pages/projects", cloud.Handle(s, pagesList))
|
||||
g.Post("/pages/projects", cloud.Handle(s, pagesCreate))
|
||||
g.Get("/pages/projects/:project", cloud.Handle(s, pagesGet))
|
||||
g.Delete("/pages/projects/:project", cloud.Handle(s, pagesDelete))
|
||||
g.Post("/pages/projects/:project/deployments", cloud.Handle(s, pagesDeploy))
|
||||
g.Post("/pages/projects/:project/domains", cloud.Handle(s, pagesDomainAdd))
|
||||
g.Delete("/pages/projects/:project/domains/:domain", cloud.Handle(s, pagesDomainDelete))
|
||||
|
||||
// Workers (wired) — scripts + workers.dev subdomain are account-scoped; routes
|
||||
// are zone-scoped.
|
||||
app.Get("/v1/integrations/cloudflare/workers/scripts", cloud.Handle(s, workersScriptList))
|
||||
app.Put("/v1/integrations/cloudflare/workers/scripts/:script", cloud.Handle(s, workersScriptPut))
|
||||
app.Delete("/v1/integrations/cloudflare/workers/scripts/:script", cloud.Handle(s, workersScriptDelete))
|
||||
app.Post("/v1/integrations/cloudflare/workers/scripts/:script/subdomain", cloud.Handle(s, workersScriptSubdomainSet))
|
||||
app.Get("/v1/integrations/cloudflare/workers/subdomain", cloud.Handle(s, workersSubdomainGet))
|
||||
app.Get("/v1/integrations/cloudflare/workers/zones/:zone/routes", cloud.Handle(s, workersRouteList))
|
||||
app.Post("/v1/integrations/cloudflare/workers/zones/:zone/routes", cloud.Handle(s, workersRouteCreate))
|
||||
app.Delete("/v1/integrations/cloudflare/workers/zones/:zone/routes/:route", cloud.Handle(s, workersRouteDelete))
|
||||
g.Get("/workers/scripts", cloud.Handle(s, workersScriptList))
|
||||
g.Put("/workers/scripts/:script", cloud.Handle(s, workersScriptPut))
|
||||
g.Delete("/workers/scripts/:script", cloud.Handle(s, workersScriptDelete))
|
||||
g.Post("/workers/scripts/:script/subdomain", cloud.Handle(s, workersScriptSubdomainSet))
|
||||
g.Get("/workers/subdomain", cloud.Handle(s, workersSubdomainGet))
|
||||
g.Get("/workers/zones/:zone/routes", cloud.Handle(s, workersRouteList))
|
||||
g.Post("/workers/zones/:zone/routes", cloud.Handle(s, workersRouteCreate))
|
||||
g.Delete("/workers/zones/:zone/routes/:route", cloud.Handle(s, workersRouteDelete))
|
||||
|
||||
// R2 / KV / D1 (Phase-2 stubs) — routes + typed provider methods exist; bodies
|
||||
// ship in Phase 2. Each answers an honest 501, never a misleading 200.
|
||||
app.Get("/v1/integrations/cloudflare/r2/buckets", cloud.Handle(s, r2BucketList))
|
||||
app.Post("/v1/integrations/cloudflare/r2/buckets", cloud.Handle(s, r2BucketCreate))
|
||||
app.Delete("/v1/integrations/cloudflare/r2/buckets/:bucket", cloud.Handle(s, r2BucketDelete))
|
||||
app.Get("/v1/integrations/cloudflare/kv/namespaces", cloud.Handle(s, kvNamespaceList))
|
||||
app.Post("/v1/integrations/cloudflare/kv/namespaces", cloud.Handle(s, kvNamespaceCreate))
|
||||
app.Delete("/v1/integrations/cloudflare/kv/namespaces/:namespace", cloud.Handle(s, kvNamespaceDelete))
|
||||
app.Get("/v1/integrations/cloudflare/d1/databases", cloud.Handle(s, d1DatabaseList))
|
||||
app.Post("/v1/integrations/cloudflare/d1/databases", cloud.Handle(s, d1DatabaseCreate))
|
||||
app.Delete("/v1/integrations/cloudflare/d1/databases/:database", cloud.Handle(s, d1DatabaseDelete))
|
||||
g.Get("/r2/buckets", cloud.Handle(s, r2BucketList))
|
||||
g.Post("/r2/buckets", cloud.Handle(s, r2BucketCreate))
|
||||
g.Delete("/r2/buckets/:bucket", cloud.Handle(s, r2BucketDelete))
|
||||
g.Get("/kv/namespaces", cloud.Handle(s, kvNamespaceList))
|
||||
g.Post("/kv/namespaces", cloud.Handle(s, kvNamespaceCreate))
|
||||
g.Delete("/kv/namespaces/:namespace", cloud.Handle(s, kvNamespaceDelete))
|
||||
g.Get("/d1/databases", cloud.Handle(s, d1DatabaseList))
|
||||
g.Post("/d1/databases", cloud.Handle(s, d1DatabaseCreate))
|
||||
g.Delete("/d1/databases/:database", cloud.Handle(s, d1DatabaseDelete))
|
||||
}
|
||||
|
||||
// ── client (the cfDo shape, reused verbatim from hanzodns) ──────────────────────
|
||||
|
||||
+10
-9
@@ -87,22 +87,23 @@ func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
}
|
||||
s := &service{
|
||||
dataDir: deps.DataDir,
|
||||
embed: newEmbedder(deps.AI, ""),
|
||||
synth: newSynth(deps.AI, deps.AIDefaultModel),
|
||||
embed: newEmbedder(deps.Embed, ""), // embeddings ride the read-only (pk-) embed credential
|
||||
synth: newSynth(deps.AI, deps.AIDefaultModel), // synthesis is chat completion → M2M
|
||||
log: deps.Logger.New("subsystem", "code"),
|
||||
stores: cloud.NewOrgStore(deps.DataDir, "code", openStore),
|
||||
}
|
||||
mounted = s
|
||||
|
||||
app.Get("/v1/code/search", s.handleSearch)
|
||||
app.Post("/v1/code/context", s.handleContext)
|
||||
app.Get("/v1/code/ask", s.handleAsk)
|
||||
app.Post("/v1/code/ask", s.handleAsk)
|
||||
app.Post("/v1/code/index", s.handleIndex)
|
||||
g := app.Group("/v1/code")
|
||||
g.Get("/search", s.handleSearch)
|
||||
g.Post("/context", s.handleContext)
|
||||
g.Get("/ask", s.handleAsk)
|
||||
g.Post("/ask", s.handleAsk)
|
||||
g.Post("/index", s.handleIndex)
|
||||
// Repo-inspection primitives (the zread contract over the org's own index):
|
||||
// tree = get_repo_structure, file = read_file.
|
||||
app.Get("/v1/code/tree", s.handleTree)
|
||||
app.Get("/v1/code/file", s.handleFile)
|
||||
g.Get("/tree", s.handleTree)
|
||||
g.Get("/file", s.handleFile)
|
||||
|
||||
s.log.Info("code surface mounted (native)",
|
||||
"brand", deps.Brand, "semantic", s.embed.Enabled(), "synth", s.synth.Enabled())
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
plansvc "github.com/hanzoai/cloud/clients/plan"
|
||||
"github.com/hanzoai/cloud/clients/plan"
|
||||
"github.com/hanzoai/cloud/types"
|
||||
commercemod "github.com/hanzoai/commerce"
|
||||
"github.com/hanzoai/commerce/datastore"
|
||||
@@ -134,7 +134,7 @@ func (c *inProcessClient) CheckEntitlement(ctx context.Context, orgID, productID
|
||||
if slug == "" {
|
||||
continue // no resolvable plan tier on this sub — cannot grant from it
|
||||
}
|
||||
_, features, found, ferr := plansvc.LicenseEntitlement(ctx, slug)
|
||||
_, features, found, ferr := plan.LicenseEntitlement(ctx, slug)
|
||||
if ferr != nil {
|
||||
// MACHINERY failure (plans vocabulary unavailable): cannot resolve features
|
||||
// ⇒ cannot verify ⇒ fail closed. Never deny-by-guess on an outage.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user