Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac6102ded1 | ||
|
|
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 | ||
|
|
3baee40745 | ||
|
|
2cb2e8e286 | ||
|
|
6dc2b3a355 | ||
|
|
6134408bad | ||
|
|
36fc7c3b74 | ||
|
|
180fd74369 | ||
|
|
78e0d35199 | ||
|
|
e80a6fc641 |
@@ -34,4 +34,5 @@ Thumbs.db
|
||||
.shots/
|
||||
|
||||
.claude/
|
||||
.worktrees/
|
||||
native/flags/target
|
||||
|
||||
@@ -66,6 +66,7 @@ import (
|
||||
"github.com/hanzoai/cloud/clients/bots"
|
||||
"github.com/hanzoai/cloud/clients/captable"
|
||||
"github.com/hanzoai/cloud/clients/catalogsync"
|
||||
"github.com/hanzoai/cloud/clients/channels"
|
||||
"github.com/hanzoai/cloud/clients/cloudflare"
|
||||
"github.com/hanzoai/cloud/clients/code"
|
||||
"github.com/hanzoai/cloud/clients/company"
|
||||
@@ -75,6 +76,7 @@ import (
|
||||
"github.com/hanzoai/cloud/clients/deploy"
|
||||
"github.com/hanzoai/cloud/clients/dns"
|
||||
"github.com/hanzoai/cloud/clients/do"
|
||||
"github.com/hanzoai/cloud/clients/domain"
|
||||
"github.com/hanzoai/cloud/clients/entitlements"
|
||||
"github.com/hanzoai/cloud/clients/eval"
|
||||
"github.com/hanzoai/cloud/clients/exec"
|
||||
@@ -257,6 +259,8 @@ func Wire() []cloud.MountSpec {
|
||||
// The /v1/dns forward head: relays the console DNS dashboard to the DNS
|
||||
// control plane under the caller's own validated bearer (clients/dns).
|
||||
{Name: "dns", Mount: dns.Mount},
|
||||
// The registrar: search/price/register domains (name.com) per org.
|
||||
{Name: "domain", Mount: domain.Mount},
|
||||
{Name: "prompts", Mount: prompts.Mount},
|
||||
{Name: "agents", Mount: agents.Mount, Shutdown: agents.Shutdown},
|
||||
// The unified AI login manager registry (/v1/links). Mounts AFTER agents so
|
||||
@@ -330,6 +334,7 @@ func Wire() []cloud.MountSpec {
|
||||
{Name: "team", Mount: team.Mount, Shutdown: ctxShutdown(team.Shutdown)},
|
||||
{Name: "settings", Mount: settings.Mount, Shutdown: settings.Shutdown},
|
||||
{Name: "notify", Mount: notify.Mount, OwnsHealth: true},
|
||||
{Name: "channels", Mount: channels.Mount, Shutdown: channels.Shutdown},
|
||||
{Name: "gateway", Mount: gateway.Mount},
|
||||
{Name: "entitlements", Mount: entitlements.Mount, Shutdown: entitlements.Shutdown},
|
||||
{Name: "exec", Mount: exec.Mount},
|
||||
|
||||
@@ -47,6 +47,7 @@ var frozen = []struct {
|
||||
{"platform", true, false}, // was order 124
|
||||
{"projects", false, false}, // was order 125
|
||||
{"dns", false, false}, // new: /v1/dns zone plane (after projects)
|
||||
{"domain", false, false}, // new: Hanzo Domains registrar (/v1/domain), after dns
|
||||
{"prompts", false, false}, // was order 126
|
||||
{"agents", false, true}, // was order 127
|
||||
{"link", false, true}, // new: unified AI login manager (/v1/links), after agents
|
||||
@@ -83,6 +84,7 @@ var frozen = []struct {
|
||||
{"team", false, true}, // was order 138
|
||||
{"settings", false, true}, // was order 138
|
||||
{"notify", true, false}, // was order 139
|
||||
{"channels", false, true}, // new: /v1/channels transport plane (after notify; must mount after integrations so RegisterIngress installs before webhooks emit)
|
||||
{"gateway", false, false}, // was order 139
|
||||
{"entitlements", false, true}, // was order 139
|
||||
{"exec", false, false}, // was order 140
|
||||
|
||||
+78
-2
@@ -39,6 +39,7 @@ import (
|
||||
"regexp"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -159,6 +160,8 @@ type worker struct {
|
||||
hostname string
|
||||
jobsNS string
|
||||
gpus []gpuInfo
|
||||
arch string // CPU arch (`uname -m`), detected once at newWorker
|
||||
memory int64 // total system RAM in bytes, detected once at newWorker
|
||||
handlers map[string]jobHandler
|
||||
|
||||
// studioUploadURL is the org studio base that receives finished render outputs
|
||||
@@ -198,8 +201,16 @@ type gpuInfo struct {
|
||||
// additive (omitempty): an older cloud that does not read them still renders the
|
||||
// GPU; a newer one advertises the engine endpoint on GET /v1/fleet/workers.
|
||||
type registration struct {
|
||||
Hostname string `json:"hostname"`
|
||||
Os string `json:"os"`
|
||||
Hostname string `json:"hostname"`
|
||||
Os string `json:"os"`
|
||||
// Arch/CPUs/Memory are THIS host's static CPU spec, in the SAME convention the
|
||||
// fleet already uses for code-linked run-targets: Arch is `uname -m`
|
||||
// (aarch64 | x86_64 | arm64), Memory is total system RAM in BYTES. Matching the
|
||||
// existing convention matters — evo-2 and spark appear on the board as BOTH a
|
||||
// run-target and a gpu-connect worker, so both rows must show the SAME arch.
|
||||
Arch string `json:"arch,omitempty"`
|
||||
CPUs int `json:"cpus,omitempty"`
|
||||
Memory int64 `json:"memory,omitempty"`
|
||||
Version string `json:"version"`
|
||||
JobQueue string `json:"jobQueue"`
|
||||
GPUs []gpuInfo `json:"gpus"`
|
||||
@@ -292,6 +303,8 @@ func newWorker(env *Env, jobsNS string) (*worker, error) {
|
||||
hostname: host,
|
||||
jobsNS: firstNonEmpty(jobsNS, defaultJobsNS),
|
||||
gpus: detectGPUs(),
|
||||
arch: detectArch(),
|
||||
memory: detectMemTotal(),
|
||||
studioUploadURL: firstNonEmpty(os.Getenv("HANZO_STUDIO_UPLOAD_URL"), defaultStudioUploadURL),
|
||||
}
|
||||
policy, err := loadSharePolicy()
|
||||
@@ -365,6 +378,66 @@ func detectAppleGPU() []gpuInfo {
|
||||
return []gpuInfo{info}
|
||||
}
|
||||
|
||||
// detectArch reports this machine's CPU architecture in the SAME convention the
|
||||
// fleet already uses for code-linked nodes — `uname -m` (aarch64 | x86_64 on Linux,
|
||||
// arm64 | x86_64 on Darwin) — so a machine that shows up as both a run-target and a
|
||||
// gpu-connect worker carries ONE arch string on the board. Falls back to the
|
||||
// compiled runtime.GOARCH only if uname is unavailable; "" is never forced.
|
||||
func detectArch() string {
|
||||
if out, err := exec.Command("uname", "-m").Output(); err == nil {
|
||||
if a := strings.TrimSpace(string(out)); a != "" {
|
||||
return a
|
||||
}
|
||||
}
|
||||
return runtime.GOARCH
|
||||
}
|
||||
|
||||
// detectMemTotal returns this machine's total physical RAM in bytes, or 0 when it
|
||||
// cannot be read (reported as "unknown" via omitempty — never faked). Linux reads
|
||||
// /proc/meminfo's MemTotal (covers evo-2's Strix Halo and spark's GB10, both Linux);
|
||||
// Darwin reads sysctl hw.memsize. This is the SAME total a code-linked box reports
|
||||
// as Spec.Memory, so the fleet board describes both kinds of node identically.
|
||||
func detectMemTotal() int64 {
|
||||
if runtime.GOOS == "darwin" {
|
||||
out, err := exec.Command("sysctl", "-n", "hw.memsize").Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
var b int64
|
||||
if _, err := fmt.Sscan(strings.TrimSpace(string(out)), &b); err == nil && b > 0 {
|
||||
return b
|
||||
}
|
||||
return 0
|
||||
}
|
||||
b, err := os.ReadFile("/proc/meminfo")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return parseMemTotalKB(b)
|
||||
}
|
||||
|
||||
// parseMemTotalKB extracts MemTotal from /proc/meminfo content (reported in kB) and
|
||||
// returns it in bytes, or 0 when the line is absent or malformed.
|
||||
func parseMemTotalKB(meminfo []byte) int64 {
|
||||
sc := bufio.NewScanner(bytes.NewReader(meminfo))
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if !strings.HasPrefix(line, "MemTotal:") {
|
||||
continue
|
||||
}
|
||||
f := strings.Fields(line) // "MemTotal:" <kb> "kB"
|
||||
if len(f) < 2 {
|
||||
return 0
|
||||
}
|
||||
kb, err := strconv.ParseInt(f[1], 10, 64)
|
||||
if err != nil || kb <= 0 {
|
||||
return 0
|
||||
}
|
||||
return kb * 1024
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// connect.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -510,6 +583,9 @@ func (w *worker) buildRegistration() registration {
|
||||
return registration{
|
||||
Hostname: w.hostname,
|
||||
Os: runtime.GOOS,
|
||||
Arch: w.arch,
|
||||
CPUs: runtime.NumCPU(),
|
||||
Memory: w.memory,
|
||||
Version: Version,
|
||||
JobQueue: w.jobsNS,
|
||||
GPUs: w.gpus,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package cli
|
||||
|
||||
// gpu_spec_test.go — the host static-spec a `hanzo gpu connect` node reports so
|
||||
// GET /v1/fleet can show its CPU arch, core count and total RAM (the fields a
|
||||
// code-linked box already carries). Real telemetry only: arch is `uname -m`, cores
|
||||
// are runtime.NumCPU, RAM is parsed from the OS — never a hardcoded machine.
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseMemTotalKB(t *testing.T) {
|
||||
// A real /proc/meminfo head from a 128 GiB box. MemTotal is in kB; we report bytes.
|
||||
meminfo := []byte("MemTotal: 131923980 kB\nMemFree: 1048576 kB\nMemAvailable: 120000000 kB\n")
|
||||
if got, want := parseMemTotalKB(meminfo), int64(131923980)*1024; got != want {
|
||||
t.Fatalf("parseMemTotalKB = %d, want %d bytes", got, want)
|
||||
}
|
||||
// Absent / malformed input is reported as 0 (unknown), never a guess.
|
||||
for name, in := range map[string]string{
|
||||
"empty": "",
|
||||
"no-memtotal": "MemFree: 100 kB\n",
|
||||
"malformed": "MemTotal: notanumber kB\n",
|
||||
"no-value": "MemTotal:\n",
|
||||
} {
|
||||
if got := parseMemTotalKB([]byte(in)); got != 0 {
|
||||
t.Fatalf("%s: parseMemTotalKB = %d, want 0", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// detectMemTotal reads the real host, so on Linux/macOS CI it must return a positive
|
||||
// byte count — proof the reporter reads actual RAM rather than shipping 0.
|
||||
func TestDetectMemTotalIsReal(t *testing.T) {
|
||||
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
|
||||
t.Skipf("no MemTotal source on %s", runtime.GOOS)
|
||||
}
|
||||
got := detectMemTotal()
|
||||
if got <= 0 {
|
||||
t.Fatalf("detectMemTotal = %d, want the host's real RAM (>0)", got)
|
||||
}
|
||||
// Evidence: what THIS host actually reports (never hardcoded). On the GB10 spark
|
||||
// box this prints aarch64 + ~128 GiB read from /proc/meminfo.
|
||||
t.Logf("real host spec: arch=%s cpus=%d memory=%d bytes (%.1f GiB)",
|
||||
detectArch(), runtime.NumCPU(), got, float64(got)/(1<<30))
|
||||
}
|
||||
|
||||
// detectArch must match the fleet's `uname -m` convention (aarch64 | x86_64 | arm64),
|
||||
// NOT runtime.GOARCH (arm64 | amd64) — so a machine that appears as both a run-target
|
||||
// and a gpu-connect worker shows ONE arch string on the board. On Linux uname -m is
|
||||
// aarch64/x86_64; assert the real host agrees and is never GOARCH's amd64.
|
||||
func TestDetectArchMatchesUnameConvention(t *testing.T) {
|
||||
got := detectArch()
|
||||
if got == "" {
|
||||
t.Fatal("detectArch returned empty; must fall back to runtime.GOARCH")
|
||||
}
|
||||
if out, err := exec.Command("uname", "-m").Output(); err == nil {
|
||||
if want := strings.TrimSpace(string(out)); want != "" && got != want {
|
||||
t.Fatalf("detectArch = %q, want `uname -m` %q (fleet convention)", got, want)
|
||||
}
|
||||
}
|
||||
// Guard the regression this test exists for: on Linux amd64 the value must be
|
||||
// x86_64, never GOARCH's "amd64".
|
||||
if runtime.GOOS == "linux" && runtime.GOARCH == "amd64" && got == "amd64" {
|
||||
t.Fatal("arch is GOARCH 'amd64'; the fleet convention is 'x86_64'")
|
||||
}
|
||||
t.Logf("detectArch=%q (GOARCH=%q)", got, runtime.GOARCH)
|
||||
}
|
||||
|
||||
// buildRegistration must carry this host's detected arch (uname -m), cores (NumCPU)
|
||||
// and RAM — so spark reports aarch64 and evo-2 reports x86_64, both ~128 GB, matching
|
||||
// how the same machines already report as code-linked run-targets.
|
||||
func TestBuildRegistrationCarriesHostSpec(t *testing.T) {
|
||||
const mem = int64(137438953472) // 128 GiB
|
||||
w := &worker{hostname: "spark", jobsNS: "gpu-jobs", arch: "aarch64", memory: mem}
|
||||
reg := w.buildRegistration()
|
||||
if reg.Arch != "aarch64" {
|
||||
t.Fatalf("Arch = %q, want the worker's detected arch %q", reg.Arch, "aarch64")
|
||||
}
|
||||
if reg.CPUs != runtime.NumCPU() {
|
||||
t.Fatalf("CPUs = %d, want runtime.NumCPU %d", reg.CPUs, runtime.NumCPU())
|
||||
}
|
||||
if reg.Memory != mem {
|
||||
t.Fatalf("Memory = %d, want the detected total %d", reg.Memory, mem)
|
||||
}
|
||||
}
|
||||
@@ -103,8 +103,14 @@ func routes(app *zip.App, s *cloud.Service[core.State]) {
|
||||
app.Get("/v1/admin/products", core.Guard(s, products))
|
||||
app.Get("/v1/admin/compute", core.Guard(s, compute))
|
||||
app.Get("/v1/admin/o11y", core.Guard(s, o11y))
|
||||
app.Get("/v1/admin/aimetrics", core.Guard(s, aimetrics))
|
||||
app.Post("/v1/admin/sync", core.Guard(s, syncNow))
|
||||
|
||||
// Credit grants — the ONE admin mint surface (SuperAdmin only). Thin, audited
|
||||
// relay to commerce's mint-gated POST /v1/billing/credit-grants; commerce is the
|
||||
// sole ledger. See creditgrant.go.
|
||||
app.Post("/v1/admin/credit-grants", core.Guard(s, createCreditGrant))
|
||||
|
||||
// Product analytics — org-scoped (SuperAdmin: all-orgs; org admin: their own org).
|
||||
app.Get("/v1/admin/analytics", core.GuardScoped(s, analytics))
|
||||
// Bases — the tenant Base-instance panel, org-scoped (bases.go).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -295,235 +295,16 @@ func (c *Client) Deposit(ctx context.Context, subject string, amount money.Cents
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ── SaaS-metrics god-view (fleet-wide, org-independent) ──────────────────────
|
||||
|
||||
// SaaSMetrics mirrors commerce's GET /v1/metrics/saas snapshot — the whole-business
|
||||
// SaaS-operations aggregate (MRR/ARR, new/churn, plan mix, top customers, recent
|
||||
// movements) computed IN commerce across every org namespace. It is org-INDEPENDENT
|
||||
// (like Costs) so the reader sends NO subject. Only the fields the admin god-view
|
||||
// renders are modeled; commerce fields we don't consume (upgrades/downgrades,
|
||||
// untagged-request counts) are simply ignored by the decoder.
|
||||
type SaaSMetrics struct {
|
||||
AsOf string `json:"asOf"`
|
||||
Currency string `json:"currency"`
|
||||
Window string `json:"window"`
|
||||
Revenue SaaSRevenue `json:"revenue"`
|
||||
Subs SaaSSubs `json:"subscriptions"`
|
||||
Usage SaaSUsage `json:"usage"`
|
||||
Customers []SaaSCustomer `json:"customers"`
|
||||
Orgs int `json:"orgs"`
|
||||
Gaps []string `json:"gaps"`
|
||||
}
|
||||
|
||||
// SaaSRevenue is the recurring-revenue headline (run-rate MRR/ARR + windowed movement).
|
||||
type SaaSRevenue struct {
|
||||
MRRCents money.Cents `json:"mrrCents"`
|
||||
ARRCents money.Cents `json:"arrCents"`
|
||||
ActiveSubscriptions int `json:"activeSubscriptions"`
|
||||
PayingCustomers int `json:"payingCustomers"`
|
||||
Trials int `json:"trials"`
|
||||
NewMRRCents money.Cents `json:"newMrrCents"`
|
||||
ChurnedMRRCents money.Cents `json:"churnedMrrCents"`
|
||||
NetNewMRRCents money.Cents `json:"netNewMrrCents"`
|
||||
ByCategory []SaaSCategory `json:"byCategory"`
|
||||
}
|
||||
|
||||
// SaaSCategory is one plan-category bucket of run-rate MRR (the plan mix).
|
||||
type SaaSCategory struct {
|
||||
Category string `json:"category"`
|
||||
MRRCents money.Cents `json:"mrrCents"`
|
||||
Subscriptions int `json:"subscriptions"`
|
||||
}
|
||||
|
||||
// SaaSSubs is the subscription-operations panel (per-plan mix, trials, new/canceled,
|
||||
// recent movements).
|
||||
type SaaSSubs struct {
|
||||
ByPlan []SaaSPlan `json:"byPlan"`
|
||||
TrialsActive int `json:"trialsActive"`
|
||||
New int `json:"new"`
|
||||
Canceled int `json:"canceled"`
|
||||
Recent []SaaSEvent `json:"recent"`
|
||||
}
|
||||
|
||||
// SaaSPlan is one plan's active/trialing counts, seats, and MRR contribution.
|
||||
type SaaSPlan struct {
|
||||
Plan string `json:"plan"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Active int `json:"active"`
|
||||
Trialing int `json:"trialing"`
|
||||
Seats int `json:"seats"`
|
||||
MRRCents money.Cents `json:"mrrCents"`
|
||||
}
|
||||
|
||||
// SaaSEvent is one recent subscription movement ("created" or "canceled").
|
||||
type SaaSEvent struct {
|
||||
At string `json:"at"`
|
||||
Org string `json:"org"`
|
||||
Type string `json:"type"`
|
||||
Plan string `json:"plan"`
|
||||
Category string `json:"category"`
|
||||
MRRDeltaCents money.Cents `json:"mrrDeltaCents"`
|
||||
}
|
||||
|
||||
// SaaSUsage is the metered / pay-as-you-go revenue headline for the window.
|
||||
type SaaSUsage struct {
|
||||
Instrumented bool `json:"instrumented"`
|
||||
WindowUsageCents money.Cents `json:"windowUsageCents"`
|
||||
Requests int64 `json:"requests"`
|
||||
}
|
||||
|
||||
// SaaSCustomer is one top customer by MRR + windowed usage.
|
||||
type SaaSCustomer struct {
|
||||
Org string `json:"org"`
|
||||
Plan string `json:"plan"`
|
||||
Category string `json:"category"`
|
||||
Status string `json:"status"`
|
||||
MRRCents money.Cents `json:"mrrCents"`
|
||||
UsageCents money.Cents `json:"usageCents"`
|
||||
Seats int `json:"seats"`
|
||||
Since string `json:"since,omitempty"`
|
||||
}
|
||||
|
||||
// Metrics reads the fleet SaaS-operations god-view (GET /v1/metrics/saas). Like Costs it
|
||||
// is org-INDEPENDENT — the engine walks every org namespace itself — so it authenticates
|
||||
// with the admin S2S service token and sends NO subject. Empty (not an error) when
|
||||
// commerce is unwired, so a partial deploy degrades to an honest empty snapshot.
|
||||
func (c *Client) Metrics(ctx context.Context, window string, limit int) (SaaSMetrics, error) {
|
||||
var out SaaSMetrics
|
||||
// CreateCreditGrant forwards a credit-grant request verbatim to commerce's
|
||||
// mint-gated POST /v1/billing/credit-grants (CreateCreditGrant), authenticated
|
||||
// by the admin service token, with subject as the target-org namespace selector.
|
||||
// Commerce is the sole credit-grant ledger; this relays its contract untouched
|
||||
// (the raw response is returned to the caller) so the admin surface stays thin.
|
||||
func (c *Client) CreateCreditGrant(ctx context.Context, subject string, body []byte, idempotencyKey string) ([]byte, error) {
|
||||
if !c.Ready() {
|
||||
return out, nil
|
||||
return nil, errUnconfigured
|
||||
}
|
||||
q := url.Values{}
|
||||
if window != "" {
|
||||
q.Set("window", window)
|
||||
}
|
||||
if limit > 0 {
|
||||
q.Set("limit", fmt.Sprintf("%d", limit))
|
||||
}
|
||||
body, err := c.get(ctx, "/v1/metrics/saas", q, "")
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return out, fmt.Errorf("commerce metrics decode: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ── billing invoices + subscriptions (per-subject fleet rows) ────────────────
|
||||
|
||||
// Invoice is one issued invoice as the fleet god-view renders it: the id (for a future
|
||||
// /v1/billing/invoices/:id detail fetch), the human number, status, amount due,
|
||||
// currency, and the issue/due dates. Sourced from GET /v1/billing/invoices
|
||||
// (invoiceResponse); all timestamps are RFC3339 strings.
|
||||
type Invoice struct {
|
||||
ID string `json:"id"`
|
||||
Number string `json:"numberStr"`
|
||||
Status string `json:"status"`
|
||||
AmountDue money.Cents `json:"amountDue"`
|
||||
Currency string `json:"currency"`
|
||||
Issued string `json:"createdAt"`
|
||||
Due string `json:"dueDate"`
|
||||
}
|
||||
|
||||
// Invoices lists a subject's invoices (GET /v1/billing/invoices), optionally filtered by
|
||||
// status. The subject selects the org's billing namespace via X-Org-Id (trusted only
|
||||
// after the service-token bearer verifies). Empty (not an error) when commerce is unwired.
|
||||
func (c *Client) Invoices(ctx context.Context, subject, status string) ([]Invoice, error) {
|
||||
if !c.Ready() {
|
||||
return nil, nil
|
||||
}
|
||||
q := url.Values{}
|
||||
if status != "" {
|
||||
q.Set("status", status)
|
||||
}
|
||||
body, err := c.get(ctx, "/v1/billing/invoices", q, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var wrap struct {
|
||||
Invoices []Invoice `json:"invoices"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &wrap); err != nil {
|
||||
return nil, fmt.Errorf("commerce invoices decode: %w", err)
|
||||
}
|
||||
return wrap.Invoices, nil
|
||||
}
|
||||
|
||||
// Subscription is one subscription row the fleet god-view renders: the id, the buyer
|
||||
// (userId), plan tier, status, monthly-normalized MRR, and the current-period
|
||||
// start/end (started/renews). MRR reuses monthlyNormalized so a yearly plan is
|
||||
// comparable to a monthly one in the fleet total.
|
||||
type Subscription struct {
|
||||
ID string `json:"id"`
|
||||
User string `json:"user"`
|
||||
Plan string `json:"plan"`
|
||||
Status string `json:"status"`
|
||||
MRR money.Cents `json:"mrrCents"`
|
||||
Started string `json:"started"`
|
||||
Renews string `json:"renews"`
|
||||
}
|
||||
|
||||
// subscriptionRowWire is the /v1/billing/subscriptions row shape the fleet view folds —
|
||||
// richer than subscriptionsWire (which Plan() uses for the MRR sum alone).
|
||||
type subscriptionRowWire struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
PlanID string `json:"planId"`
|
||||
Status string `json:"status"`
|
||||
Created string `json:"createdAt"`
|
||||
PeriodStart string `json:"currentPeriodStart"`
|
||||
PeriodEnd string `json:"currentPeriodEnd"`
|
||||
Plan struct {
|
||||
Name string `json:"name"`
|
||||
Price money.Cents `json:"price"`
|
||||
Interval string `json:"interval"`
|
||||
} `json:"plan"`
|
||||
}
|
||||
|
||||
// Subscriptions lists a subject's subscriptions (GET /v1/billing/subscriptions),
|
||||
// optionally filtered by status, as fleet rows with a monthly-normalized MRR. Empty (not
|
||||
// an error) when commerce is unwired.
|
||||
func (c *Client) Subscriptions(ctx context.Context, subject, status string) ([]Subscription, error) {
|
||||
if !c.Ready() {
|
||||
return nil, nil
|
||||
}
|
||||
q := url.Values{}
|
||||
if status != "" {
|
||||
q.Set("status", status)
|
||||
}
|
||||
body, err := c.get(ctx, "/v1/billing/subscriptions", q, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var wrap struct {
|
||||
Subscriptions []subscriptionRowWire `json:"subscriptions"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &wrap); err != nil {
|
||||
return nil, fmt.Errorf("commerce subscriptions decode: %w", err)
|
||||
}
|
||||
out := make([]Subscription, 0, len(wrap.Subscriptions))
|
||||
for _, s := range wrap.Subscriptions {
|
||||
name := strings.TrimSpace(s.Plan.Name)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(s.PlanID)
|
||||
}
|
||||
started := strings.TrimSpace(s.Created)
|
||||
if started == "" {
|
||||
started = s.PeriodStart
|
||||
}
|
||||
out = append(out, Subscription{
|
||||
ID: s.ID,
|
||||
User: s.UserID,
|
||||
Plan: name,
|
||||
Status: s.Status,
|
||||
MRR: monthlyNormalized(s.Plan.Price, s.Plan.Interval),
|
||||
Started: started,
|
||||
Renews: s.PeriodEnd,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
return c.post(ctx, "/v1/billing/credit-grants", subject, body, idempotencyKey)
|
||||
}
|
||||
|
||||
// post performs one admin-authenticated commerce POST (JSON body) and returns the
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
@@ -38,11 +39,48 @@ const (
|
||||
// mountRouting registers the route-work machine surface. Called from mountTargets
|
||||
// AFTER the target CRUD routes so the extra-segment paths are unambiguous.
|
||||
func mountRouting(s *cloud.Service[state], app *zip.App) {
|
||||
assertSingleReplica(s.Log)
|
||||
app.Post("/v1/agents/targets/:id/claim-key", cloud.Handle(s, mintClaimKey))
|
||||
app.Post("/v1/agents/targets/:id/claim", cloud.Handle(s, claimRoutedRun))
|
||||
app.Post("/v1/agents/targets/:id/runs/:runId/report", cloud.Handle(s, reportRoutedRun))
|
||||
}
|
||||
|
||||
// caller is the VALIDATED principal id (X-User-Id) — the machine-owner identity for
|
||||
// route-work. tenant() already required a validated principal, so on any handler that
|
||||
// resolved an org this is non-empty.
|
||||
func caller(c *zip.Ctx) string { return strings.TrimSpace(c.User()) }
|
||||
|
||||
// ownsTarget reports whether the caller may MANAGE this target's route-work plane —
|
||||
// mint/rotate the claim key, claim, report, patch, delete. A machine belongs to the
|
||||
// principal that registered it (least privilege, AC-6): its owner may manage it, and
|
||||
// an org admin (self-service org management, the admin-org model's isAdmin) may manage
|
||||
// any of the org's targets. An UNOWNED (pre-migration) row is admin-only until its
|
||||
// owner re-registers — register binds the owner. Fail-closed: an empty caller or an
|
||||
// empty owner never satisfies the ownership arm, so a non-validated request or a
|
||||
// pre-migration row is never owner-managed.
|
||||
func ownsTarget(c *zip.Ctx, t Target) bool {
|
||||
if principal.IsOrgAdmin(c) || principal.IsSuperAdmin(c) {
|
||||
return true
|
||||
}
|
||||
u := caller(c)
|
||||
return t.Owner != "" && u != "" && u == t.Owner
|
||||
}
|
||||
|
||||
// authorizeTargetManage resolves the (org,id) target and gates it on ownsTarget,
|
||||
// collapsing every failure — cross-org, unknown, or not-owned — to the SAME
|
||||
// errTargetNotFound so the machine surface never distinguishes them (no oracle). The
|
||||
// resolved target is returned for the caller to use (avoids a second read).
|
||||
func authorizeTargetManage(s *cloud.Service[state], c *zip.Ctx, org, id string) (Target, error) {
|
||||
t, err := s.State.store.GetTarget(c.Context(), org, id)
|
||||
if err != nil {
|
||||
return Target{}, errTargetNotFound // unknown / cross-org -> no oracle
|
||||
}
|
||||
if !ownsTarget(c, t) {
|
||||
return Target{}, errTargetNotFound
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// mintClaimKey (re)mints the target's claim key and returns it ONCE. Only the
|
||||
// SHA-256 hash is stored. Org-scoped: only a caller in the target's org can mint,
|
||||
// and the key is bound to (org, target). Rotating supersedes any prior daemon.
|
||||
@@ -52,11 +90,12 @@ func mintClaimKey(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
id := idParam(c)
|
||||
// The target must exist in this org before it can carry a capability.
|
||||
if _, err := s.State.store.GetTarget(c.Context(), org, id); err == errTargetNotFound {
|
||||
// The target must exist in this org AND the caller must OWN it (or be an org
|
||||
// admin) before it can (re)mint a capability — minting rotates the key, so an
|
||||
// un-scoped mint would let any org member strand a victim's daemon and steal its
|
||||
// runs. Every failure collapses to the same not-found (no oracle).
|
||||
if _, err := authorizeTargetManage(s, c, org, id); err != nil {
|
||||
return zip.ErrNotFound("target not found")
|
||||
} else if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "target: %v", err)
|
||||
}
|
||||
key, err := newClaimKey()
|
||||
if err != nil {
|
||||
@@ -77,6 +116,14 @@ func claimRoutedRun(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
id := idParam(c)
|
||||
// TWO proofs, both required, both fail-closed to the SAME 403 (no oracle): the
|
||||
// caller must OWN this machine (or be an org admin) AND hold its claim key. The
|
||||
// ownership gate is defense in depth — with mint owner-scoped an attacker cannot
|
||||
// obtain a valid key for a victim's machine, but a claim still refuses a
|
||||
// non-owner outright rather than resting solely on the capability.
|
||||
if _, err := authorizeTargetManage(s, c, org, id); err != nil {
|
||||
return claimAuthError(errTargetNotFound)
|
||||
}
|
||||
if err := s.State.store.verifyClaimKey(c.Context(), org, id, c.Header(claimKeyHeader)); err != nil {
|
||||
return claimAuthError(err)
|
||||
}
|
||||
@@ -115,6 +162,11 @@ func reportRoutedRun(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
}
|
||||
id := idParam(c)
|
||||
runID := strings.TrimSpace(c.Param("runId"))
|
||||
// Same two proofs as claim: own the machine (or org admin) AND hold its key, so a
|
||||
// non-owner can neither fabricate a report nor complete a victim's run. No oracle.
|
||||
if _, err := authorizeTargetManage(s, c, org, id); err != nil {
|
||||
return claimAuthError(errTargetNotFound)
|
||||
}
|
||||
if err := s.State.store.verifyClaimKey(c.Context(), org, id, c.Header(claimKeyHeader)); err != nil {
|
||||
return claimAuthError(err)
|
||||
}
|
||||
|
||||
@@ -40,13 +40,17 @@ func registerAndMint(t *testing.T, app *zip.App, org, host string) (string, stri
|
||||
if code != 201 && code != 200 {
|
||||
t.Fatalf("register target: %d %s", code, body)
|
||||
}
|
||||
var tv struct{ ID string `json:"id"` }
|
||||
var tv struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &tv)
|
||||
code, body = doKey(t, app, "POST", "/v1/agents/targets/"+tv.ID+"/claim-key", org, "")
|
||||
if code != 200 {
|
||||
t.Fatalf("mint claim key: %d %s", code, body)
|
||||
}
|
||||
var kv struct{ ClaimKey string `json:"claimKey"` }
|
||||
var kv struct {
|
||||
ClaimKey string `json:"claimKey"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &kv)
|
||||
if kv.ClaimKey == "" {
|
||||
t.Fatal("claim key empty")
|
||||
@@ -105,7 +109,9 @@ func TestClaim_CrossMachineAndCrossOrgDenied(t *testing.T) {
|
||||
if code != 200 {
|
||||
t.Fatalf("acme must claim its own run, got %d %s", code, body)
|
||||
}
|
||||
var rv struct{ SessionID string `json:"sessionId"` }
|
||||
var rv struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &rv)
|
||||
if rv.SessionID != "sess_a" {
|
||||
t.Fatalf("claimed wrong run: %s", body)
|
||||
@@ -248,3 +254,162 @@ func TestClaimKey_HashedAtRestAndVerified(t *testing.T) {
|
||||
t.Fatalf("cross-org verify must fail closed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- M2: owner/machine scoping of the claim-key plane ----
|
||||
|
||||
// reqAs sends a request with an EXPLICIT principal (X-User-Id) + optional org-admin
|
||||
// bit and claim key + JSON body, so a test can prove owner/admin scoping distinct
|
||||
// from org scoping.
|
||||
func reqAs(t *testing.T, app *zip.App, method, path, org, user string, admin bool, key string, body any) (int, []byte) {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
r = bytes.NewReader(b)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, r)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if org != "" {
|
||||
req.Header.Set("X-Org-Id", org)
|
||||
}
|
||||
if user != "" {
|
||||
req.Header.Set("X-User-Id", user)
|
||||
}
|
||||
if admin {
|
||||
req.Header.Set("X-User-IsOrgAdmin", "true")
|
||||
}
|
||||
if key != "" {
|
||||
req.Header.Set(claimKeyHeader, key)
|
||||
}
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Test %s %s: %v", method, path, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
out, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, out
|
||||
}
|
||||
|
||||
// registerAs registers a target in org OWNED by the given principal, returning its id.
|
||||
func registerAs(t *testing.T, app *zip.App, org, user, host string) string {
|
||||
t.Helper()
|
||||
code, body := reqAs(t, app, "POST", "/v1/agents/targets", org, user, false, "", map[string]any{"label": host, "host": host})
|
||||
if code != 201 && code != 200 {
|
||||
t.Fatalf("register target: %d %s", code, body)
|
||||
}
|
||||
var tv struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &tv)
|
||||
if tv.ID == "" {
|
||||
t.Fatalf("register returned no id: %s", body)
|
||||
}
|
||||
return tv.ID
|
||||
}
|
||||
|
||||
// A machine belongs to the principal that registered it: a DIFFERENT member of the
|
||||
// SAME org can neither mint/rotate its claim key, nor patch, nor delete it — only its
|
||||
// owner or an org admin can. Every refusal collapses to not-found (no oracle).
|
||||
func TestClaimKeyPlane_OwnerScoped(t *testing.T) {
|
||||
app := mountApp(t, &fakeAI{content: "x"})
|
||||
id := registerAs(t, app, "acme", "alice", "evo")
|
||||
|
||||
// A non-owner member of the same org is DENIED on every management verb.
|
||||
if code, _ := reqAs(t, app, "POST", "/v1/agents/targets/"+id+"/claim-key", "acme", "mallory", false, "", nil); code != 404 {
|
||||
t.Fatalf("non-owner mint must be denied (no oracle -> 404), got %d", code)
|
||||
}
|
||||
if code, _ := reqAs(t, app, "PATCH", "/v1/agents/targets/"+id, "acme", "mallory", false, "", map[string]any{"status": TargetOffline}); code != 404 {
|
||||
t.Fatalf("non-owner patch must be denied, got %d", code)
|
||||
}
|
||||
if code, _ := reqAs(t, app, "DELETE", "/v1/agents/targets/"+id, "acme", "mallory", false, "", nil); code != 404 {
|
||||
t.Fatalf("non-owner delete must be denied, got %d", code)
|
||||
}
|
||||
|
||||
// The OWNER can mint.
|
||||
code, body := reqAs(t, app, "POST", "/v1/agents/targets/"+id+"/claim-key", "acme", "alice", false, "", nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("owner mint must succeed, got %d %s", code, body)
|
||||
}
|
||||
|
||||
// An ORG ADMIN (self-service org management) can manage any of the org's machines.
|
||||
if code, _ := reqAs(t, app, "POST", "/v1/agents/targets/"+id+"/claim-key", "acme", "boss", true, "", nil); code != 200 {
|
||||
t.Fatalf("org admin mint must succeed, got %d", code)
|
||||
}
|
||||
if code, _ := reqAs(t, app, "PATCH", "/v1/agents/targets/"+id, "acme", "boss", true, "", map[string]any{"status": TargetOnline}); code != 200 {
|
||||
t.Fatalf("org admin patch must succeed, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// A non-owner cannot CLAIM a victim's runs or REPORT on them, even if the claim-key
|
||||
// authorization were somehow satisfied — the ownership gate refuses first.
|
||||
func TestClaimReport_OwnerScoped(t *testing.T) {
|
||||
app := mountApp(t, &fakeAI{content: "x"})
|
||||
old := claimLongPoll
|
||||
claimLongPoll = 150 * time.Millisecond
|
||||
defer func() { claimLongPoll = old }()
|
||||
|
||||
id := registerAs(t, app, "acme", "alice", "evo")
|
||||
// Alice mints her machine's key.
|
||||
code, body := reqAs(t, app, "POST", "/v1/agents/targets/"+id+"/claim-key", "acme", "alice", false, "", nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("owner mint: %d %s", code, body)
|
||||
}
|
||||
var kv struct {
|
||||
ClaimKey string `json:"claimKey"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &kv)
|
||||
|
||||
// Mallory (same org, not the owner) with the RIGHT key is still refused: she does
|
||||
// not own the machine. (In practice she cannot obtain the key, since mint is
|
||||
// owner-scoped — this is the defense-in-depth arm.)
|
||||
if code, _ := reqAs(t, app, "POST", "/v1/agents/targets/"+id+"/claim", "acme", "mallory", false, kv.ClaimKey, nil); code != 403 {
|
||||
t.Fatalf("non-owner claim must be 403, got %d", code)
|
||||
}
|
||||
if code, _ := reqAs(t, app, "POST", "/v1/agents/targets/"+id+"/runs/sess_x/report", "acme", "mallory", false, kv.ClaimKey, map[string]any{"ok": true}); code != 403 {
|
||||
t.Fatalf("non-owner report must be 403, got %d", code)
|
||||
}
|
||||
|
||||
// The owner with her key claims (no work -> 204) and reports fine.
|
||||
if code, _ := reqAs(t, app, "POST", "/v1/agents/targets/"+id+"/claim", "acme", "alice", false, kv.ClaimKey, nil); code != 204 {
|
||||
t.Fatalf("owner claim (no work) must be 204, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// A pre-migration UNOWNED target (owner=”) is admin-only, and its owner heals it by
|
||||
// re-registering (register binds the owner). Proven at the store + handler seam.
|
||||
func TestUnownedTarget_AdminOnly_ThenBoundByRegister(t *testing.T) {
|
||||
app := mountApp(t, &fakeAI{content: "x"})
|
||||
ctx := context.Background()
|
||||
now := time.Now().Unix()
|
||||
// Seed an unowned row directly, as an upgraded pre-owner DB would carry.
|
||||
if err := mounted.State.store.CreateTarget(ctx, Target{ID: "tgt_legacy", Org: "acme", Owner: "", Label: "old", Kind: TargetMachine, Status: TargetOnline, Host: "old", CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A plain member cannot mint on an unowned row.
|
||||
if code, _ := reqAs(t, app, "POST", "/v1/agents/targets/tgt_legacy/claim-key", "acme", "alice", false, "", nil); code != 404 {
|
||||
t.Fatalf("unowned row must be member-denied, got %d", code)
|
||||
}
|
||||
// An org admin can.
|
||||
if code, _ := reqAs(t, app, "POST", "/v1/agents/targets/tgt_legacy/claim-key", "acme", "boss", true, "", nil); code != 200 {
|
||||
t.Fatalf("unowned row must be admin-manageable, got %d", code)
|
||||
}
|
||||
// The owner heals it by re-registering the SAME host — register ADOPTS the
|
||||
// unowned row and BINDS the owner (no duplicate).
|
||||
code, body := reqAs(t, app, "POST", "/v1/agents/targets", "acme", "alice", false, "", map[string]any{"label": "old", "host": "old"})
|
||||
if code != 200 && code != 201 {
|
||||
t.Fatalf("re-register: %d %s", code, body)
|
||||
}
|
||||
var tv struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &tv)
|
||||
if tv.ID != "tgt_legacy" {
|
||||
t.Fatalf("re-register must adopt the unowned row (same id), got %q", tv.ID)
|
||||
}
|
||||
// Now alice (the bound owner) can mint.
|
||||
if code, _ := reqAs(t, app, "POST", "/v1/agents/targets/tgt_legacy/claim-key", "acme", "alice", false, "", nil); code != 200 {
|
||||
t.Fatalf("after binding, the owner must be able to mint, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
+76
-15
@@ -87,6 +87,7 @@ var errTargetNotFound = errors.New("agents: target not found")
|
||||
type Target struct {
|
||||
ID string
|
||||
Org string
|
||||
Owner string // the VALIDATED principal (c.User()) that registered this machine; "" for a pre-migration row
|
||||
Label string
|
||||
Kind string // laptop | cloud | gpu | cluster | machine
|
||||
Status string // online | offline | draining
|
||||
@@ -112,6 +113,7 @@ func (s *Store) migrateTargets() error {
|
||||
CREATE TABLE IF NOT EXISTS agent_targets (
|
||||
id TEXT PRIMARY KEY,
|
||||
org TEXT NOT NULL,
|
||||
owner TEXT NOT NULL DEFAULT '',
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
kind TEXT NOT NULL DEFAULT 'machine',
|
||||
status TEXT NOT NULL DEFAULT 'online',
|
||||
@@ -129,9 +131,12 @@ CREATE INDEX IF NOT EXISTS ix_targets_org_created ON agent_targets(org, created_
|
||||
return fmt.Errorf("migrate targets: %w", err)
|
||||
}
|
||||
// Forward, idempotent upgrade for target rows created before the capability +
|
||||
// metrics columns existed. PRAGMA-guarded, so re-running on an upgraded DB is a
|
||||
// no-op — the DDL above covers fresh installs, this covers pre-existing ones.
|
||||
// metrics + owner columns existed. PRAGMA-guarded, so re-running on an upgraded
|
||||
// DB is a no-op — the DDL above covers fresh installs, this covers pre-existing
|
||||
// ones. A pre-owner row backfills owner='' (unowned) and is admin-only until its
|
||||
// owner re-registers (register binds the owner) — see registerTarget.
|
||||
if err := s.addColumns("agent_targets", map[string]string{
|
||||
"owner": "TEXT NOT NULL DEFAULT ''",
|
||||
"spec": "TEXT NOT NULL DEFAULT ''",
|
||||
"metrics": "TEXT NOT NULL DEFAULT ''",
|
||||
"metrics_at": "INTEGER NOT NULL DEFAULT 0",
|
||||
@@ -141,12 +146,12 @@ CREATE INDEX IF NOT EXISTS ix_targets_org_created ON agent_targets(org, created_
|
||||
return nil
|
||||
}
|
||||
|
||||
const targetCols = `id,org,label,kind,status,capacity,host,spec,metrics,metrics_at,created_at,updated_at`
|
||||
const targetCols = `id,org,owner,label,kind,status,capacity,host,spec,metrics,metrics_at,created_at,updated_at`
|
||||
|
||||
func scanTarget(sc interface{ Scan(...any) error }) (Target, error) {
|
||||
var t Target
|
||||
var spec, metrics string
|
||||
err := sc.Scan(&t.ID, &t.Org, &t.Label, &t.Kind, &t.Status, &t.Capacity, &t.Host,
|
||||
err := sc.Scan(&t.ID, &t.Org, &t.Owner, &t.Label, &t.Kind, &t.Status, &t.Capacity, &t.Host,
|
||||
&spec, &metrics, &t.MetricsAt, &t.CreatedAt, &t.UpdatedAt)
|
||||
if err != nil {
|
||||
return t, err
|
||||
@@ -159,8 +164,8 @@ func scanTarget(sc interface{ Scan(...any) error }) (Target, error) {
|
||||
// CreateTarget inserts one target. The id is caller-generated (genID("tgt")).
|
||||
func (s *Store) CreateTarget(ctx context.Context, t Target) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO agent_targets (`+targetCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
t.ID, t.Org, t.Label, t.Kind, t.Status, t.Capacity, t.Host,
|
||||
`INSERT INTO agent_targets (`+targetCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
t.ID, t.Org, t.Owner, t.Label, t.Kind, t.Status, t.Capacity, t.Host,
|
||||
encodeSpec(t.Spec), encodeMetrics(t.Metrics), t.MetricsAt, t.CreatedAt, t.UpdatedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert target: %w", err)
|
||||
@@ -203,12 +208,14 @@ func (s *Store) ListTargets(ctx context.Context, org string) ([]Target, error) {
|
||||
}
|
||||
|
||||
// UpdateTarget persists mutable fields for an existing (org,id) target. Scoped by org
|
||||
// so a cross-tenant id can never mutate another's target.
|
||||
// so a cross-tenant id can never mutate another's target. owner is persisted too so a
|
||||
// relink can BIND a previously-unowned row (registerTarget) and a patch preserves the
|
||||
// owner it read; no client-facing patch field sets owner, so it never moves by mutation.
|
||||
func (s *Store) UpdateTarget(ctx context.Context, t Target) error {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE agent_targets SET label=?, kind=?, status=?, capacity=?, host=?, spec=?, metrics=?, metrics_at=?, updated_at=?
|
||||
`UPDATE agent_targets SET owner=?, label=?, kind=?, status=?, capacity=?, host=?, spec=?, metrics=?, metrics_at=?, updated_at=?
|
||||
WHERE org=? AND id=?`,
|
||||
t.Label, t.Kind, t.Status, t.Capacity, t.Host,
|
||||
t.Owner, t.Label, t.Kind, t.Status, t.Capacity, t.Host,
|
||||
encodeSpec(t.Spec), encodeMetrics(t.Metrics), t.MetricsAt, t.UpdatedAt, t.Org, t.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update target: %w", err)
|
||||
@@ -220,6 +227,32 @@ func (s *Store) UpdateTarget(ctx context.Context, t Target) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLinkableTargetByHost returns the target for (org,host) that the caller `owner`
|
||||
// may re-link — its OWN row, else an UNOWNED (pre-migration) row it may adopt —
|
||||
// preferring the exact-owner match, newest first. A row owned by a DIFFERENT
|
||||
// principal is NEVER returned, so a re-link can never clobber another member's
|
||||
// machine: the caller gets its own row or (falling through in registerTarget) a fresh
|
||||
// one. errTargetNotFound when nothing linkable exists.
|
||||
func (s *Store) GetLinkableTargetByHost(ctx context.Context, org, host, owner string) (Target, error) {
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" {
|
||||
return Target{}, errTargetNotFound
|
||||
}
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT `+targetCols+` FROM agent_targets
|
||||
WHERE org=? AND host=? AND (owner=? OR owner='')
|
||||
ORDER BY CASE WHEN owner=? THEN 0 ELSE 1 END, created_at DESC, id ASC LIMIT 1`,
|
||||
org, host, owner, owner)
|
||||
t, err := scanTarget(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Target{}, errTargetNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Target{}, fmt.Errorf("get linkable target by host: %w", err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// GetTargetByHost returns an org's target reporting the given host, or
|
||||
// errTargetNotFound. It is how a re-link of the SAME machine finds its existing target
|
||||
// (idempotent register) instead of creating a duplicate. Org-scoped: a host string can
|
||||
@@ -548,12 +581,21 @@ func registerTarget(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
metricsAt = now // the server owns the staleness clock; a client can't forge it
|
||||
}
|
||||
|
||||
// Idempotent re-link: the SAME machine (org+host) refreshes its existing target
|
||||
// rather than piling up duplicates, so mission-control shows one row per machine
|
||||
// with live spec/metrics. Only an explicit host keys this — an anonymous target
|
||||
// (no host) always creates.
|
||||
// The registering principal OWNS this machine (least privilege): only it (or an
|
||||
// org admin) may later mint the claim key, claim runs, report, patch, or delete
|
||||
// it. tenant() already required a validated principal, so this is non-empty.
|
||||
owner := caller(c)
|
||||
|
||||
// Idempotent re-link: the SAME machine (org+host+owner) refreshes its existing
|
||||
// target rather than piling up duplicates, so mission-control shows one row per
|
||||
// machine with live spec/metrics. It resolves ONLY the caller's own row (or an
|
||||
// UNOWNED pre-migration row, which it ADOPTS by binding owner) — a row owned by a
|
||||
// different member is never touched, so a re-link can never hijack another's
|
||||
// machine; the caller falls through to create its own. Only an explicit host keys
|
||||
// this — an anonymous target (no host) always creates.
|
||||
if host != "" {
|
||||
if existing, err := s.State.store.GetTargetByHost(c.Context(), org, host); err == nil {
|
||||
if existing, err := s.State.store.GetLinkableTargetByHost(c.Context(), org, host, owner); err == nil {
|
||||
existing.Owner = owner // bind an adopted unowned row; no-op if already ours
|
||||
existing.Label, existing.Kind, existing.Status, existing.Capacity = label, kind, status, capacity
|
||||
existing.Spec, existing.Metrics, existing.MetricsAt = spec, metrics, metricsAt
|
||||
existing.UpdatedAt = now
|
||||
@@ -571,7 +613,7 @@ func registerTarget(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
return zip.Errorf(http.StatusInternalServerError, "rng: %v", err)
|
||||
}
|
||||
t := Target{
|
||||
ID: id, Org: org, Label: label, Kind: kind, Status: status,
|
||||
ID: id, Org: org, Owner: owner, Label: label, Kind: kind, Status: status,
|
||||
Capacity: capacity, Host: host, Spec: spec, Metrics: metrics, MetricsAt: metricsAt,
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
@@ -648,6 +690,12 @@ func patchTarget(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "get: %v", err)
|
||||
}
|
||||
// Only the machine's owner (or an org admin) may mutate it — a member cannot
|
||||
// reconfigure/drain another member's machine. Fail-closed to the SAME not-found
|
||||
// an unknown id gives, so a probe learns nothing about what exists.
|
||||
if !ownsTarget(c, t) {
|
||||
return zip.ErrNotFound("target not found")
|
||||
}
|
||||
var body patchTargetReq
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return err
|
||||
@@ -729,6 +777,19 @@ func deleteTarget(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
id := idParam(c)
|
||||
// Resolve + ownership-gate before deleting: only the machine's owner (or an org
|
||||
// admin) may deregister it. A cross-org id, an unknown id, and a non-owned id all
|
||||
// collapse to the same not-found — no oracle.
|
||||
t, err := s.State.store.GetTarget(c.Context(), org, id)
|
||||
if err == errTargetNotFound {
|
||||
return zip.ErrNotFound("target not found")
|
||||
}
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "get: %v", err)
|
||||
}
|
||||
if !ownsTarget(c, t) {
|
||||
return zip.ErrNotFound("target not found")
|
||||
}
|
||||
deleted, err := s.State.store.DeleteTarget(c.Context(), org, id)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "delete: %v", err)
|
||||
|
||||
@@ -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,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,468 @@
|
||||
package channels
|
||||
|
||||
// routes.go — the /v1/channels HTTP surface. Every route is org-gated
|
||||
// (principal.Org) and wrapped cloud.Terminal(cloud.Handle(...)): channels
|
||||
// mounts after the commerce /v1 error-flattening filter, so Terminal writes
|
||||
// the real 4xx in-band before that filter can rewrite it to 500 (service.go).
|
||||
// Mutations (pairing approve, allowlist put) additionally require org admin.
|
||||
// There are NO public routes here — platform webhooks stay in integrations.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/integrations"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// sendMaxBody bounds one POST /send body (attachments are URLs, not bytes).
|
||||
const sendMaxBody = 1 << 20 // 1 MiB
|
||||
|
||||
// routes registers the /v1/channels surface. The :channel send route is LAST:
|
||||
// zip matches in registration order, so the static paths above must win.
|
||||
func routes(app *zip.App, s *cloud.Service[state]) {
|
||||
app.Get("/v1/channels", cloud.Terminal(cloud.Handle(s, list)))
|
||||
app.Get("/v1/channels/inbox", cloud.Terminal(cloud.Handle(s, inbox)))
|
||||
app.Get("/v1/channels/pairing", cloud.Terminal(cloud.Handle(s, pairingList)))
|
||||
app.Post("/v1/channels/pairing/approve", cloud.Terminal(cloud.Handle(s, pairingApprove)))
|
||||
app.Get("/v1/channels/allowlist", cloud.Terminal(cloud.Handle(s, allowlistGet)))
|
||||
app.Put("/v1/channels/allowlist", cloud.Terminal(cloud.Handle(s, allowlistPut)))
|
||||
app.Post("/v1/channels/:channel/send", cloud.Terminal(cloud.Handle(s, send)))
|
||||
}
|
||||
|
||||
// ── JSON projections (camelCase, closed shapes) ──────────────────────────────
|
||||
|
||||
type channelView struct {
|
||||
ID string `json:"id"`
|
||||
Connected bool `json:"connected"`
|
||||
Account string `json:"account"`
|
||||
AccountLabel string `json:"accountLabel"`
|
||||
Capabilities capabilities `json:"capabilities"`
|
||||
DMPolicy DMPolicy `json:"dmPolicy"`
|
||||
GroupPolicy GroupPolicy `json:"groupPolicy"`
|
||||
PendingPairing int `json:"pendingPairing"`
|
||||
}
|
||||
|
||||
type inboxView struct {
|
||||
ID int64 `json:"id"`
|
||||
Channel string `json:"channel"`
|
||||
Account string `json:"account"`
|
||||
RoomID string `json:"roomId"`
|
||||
RoomKind string `json:"roomKind"`
|
||||
Sender string `json:"sender"`
|
||||
SenderUser string `json:"senderUser,omitempty"`
|
||||
Text string `json:"text"`
|
||||
ReplyTo string `json:"replyTo,omitempty"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
type pairingView struct {
|
||||
Channel string `json:"channel"`
|
||||
Sender string `json:"sender"`
|
||||
Code string `json:"code"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
LastSeen int64 `json:"lastSeen"`
|
||||
}
|
||||
|
||||
type allowlistView struct {
|
||||
DMPolicy DMPolicy `json:"dmPolicy"`
|
||||
GroupPolicy GroupPolicy `json:"groupPolicy"`
|
||||
DM []string `json:"dm"`
|
||||
Group []string `json:"group"`
|
||||
Paired []string `json:"paired"`
|
||||
AccessGroups map[string]map[string][]string `json:"accessGroups"`
|
||||
}
|
||||
|
||||
// nonNil keeps list fields JSON arrays ([] not null) — integrations idiom.
|
||||
func nonNil(v []string) []string {
|
||||
if v == nil {
|
||||
return []string{}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ── handlers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// list is GET /v1/channels — the deterministic transport listing (registry
|
||||
// order) with the org's connection, policy, and pending-pairing facts.
|
||||
func list(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := principal.Org(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("a validated principal is required")
|
||||
}
|
||||
ctx := c.Context()
|
||||
pending := map[string]int{}
|
||||
rows, err := listPairing(ctx, s.State.store, org, time.Now().Unix())
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "pairing: %v", err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
pending[r.Channel]++
|
||||
}
|
||||
out := make([]channelView, 0, len(transports))
|
||||
for _, tr := range transports {
|
||||
conn, connected := integrations.ConnectionFor(org, tr.id)
|
||||
// Absent row ⇒ defaults (policyFor); a read error leaves zero policy
|
||||
// fields rather than failing the whole listing.
|
||||
p, _ := policyFor(ctx, s.State.store, org, tr.id)
|
||||
out = append(out, channelView{
|
||||
ID: tr.id,
|
||||
Connected: connected,
|
||||
// C2-7: account is the id-shaped fact (lowercased external id),
|
||||
// accountLabel the human label — never swapped, on any surface.
|
||||
Account: strings.ToLower(conn.ExternalID),
|
||||
AccountLabel: conn.AccountLabel,
|
||||
Capabilities: tr.caps,
|
||||
DMPolicy: p.DM,
|
||||
GroupPolicy: p.Group,
|
||||
PendingPairing: pending[tr.id],
|
||||
})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{"channels": out})
|
||||
}
|
||||
|
||||
// inbox is GET /v1/channels/inbox?since=&limit= — the org's stored inbound
|
||||
// messages, oldest first; cursor is the last row id (or since when empty).
|
||||
func inbox(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := principal.Org(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("a validated principal is required")
|
||||
}
|
||||
var since int64
|
||||
if q := c.Query("since"); q != "" {
|
||||
v, err := strconv.ParseInt(q, 10, 64)
|
||||
if err != nil {
|
||||
return zip.ErrBadRequest("since must be an integer cursor")
|
||||
}
|
||||
since = v
|
||||
}
|
||||
var limit int
|
||||
if q := c.Query("limit"); q != "" {
|
||||
v, err := strconv.Atoi(q)
|
||||
if err != nil {
|
||||
return zip.ErrBadRequest("limit must be an integer")
|
||||
}
|
||||
limit = v
|
||||
}
|
||||
rows, err := s.State.store.listInbox(c.Context(), org, since, limit)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "inbox: %v", err)
|
||||
}
|
||||
msgs := make([]inboxView, 0, len(rows))
|
||||
cursor := since
|
||||
for _, r := range rows {
|
||||
msgs = append(msgs, inboxView{
|
||||
ID: r.ID,
|
||||
Channel: r.Channel,
|
||||
Account: r.Account,
|
||||
RoomID: r.RoomID,
|
||||
RoomKind: string(r.RoomKind),
|
||||
Sender: r.Sender,
|
||||
SenderUser: r.SenderUser,
|
||||
Text: r.Text,
|
||||
ReplyTo: r.ReplyTo,
|
||||
CreatedAt: r.CreatedAt,
|
||||
})
|
||||
cursor = r.ID
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{"messages": msgs, "cursor": cursor})
|
||||
}
|
||||
|
||||
// pairingList is GET /v1/channels/pairing — the org's pending (unexpired)
|
||||
// pairing requests. Codes are capability strings shown to org members for
|
||||
// admin approval; they are returned here, never logged.
|
||||
func pairingList(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := principal.Org(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("a validated principal is required")
|
||||
}
|
||||
rows, err := listPairing(c.Context(), s.State.store, org, time.Now().Unix())
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "pairing: %v", err)
|
||||
}
|
||||
out := make([]pairingView, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, pairingView{
|
||||
Channel: r.Channel,
|
||||
Sender: r.Sender,
|
||||
Code: r.Code,
|
||||
CreatedAt: r.CreatedAt,
|
||||
LastSeen: r.LastSeen,
|
||||
})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{"pending": out})
|
||||
}
|
||||
|
||||
// pairingApprove is POST /v1/channels/pairing/approve — admin-gated; turns a
|
||||
// pending code into a pairing-source allow entry (approvePairing owns the
|
||||
// write and the one-time owner bootstrap).
|
||||
func pairingApprove(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := principal.Org(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("a validated principal is required")
|
||||
}
|
||||
if !(principal.IsSuperAdmin(c) || principal.IsOrgAdmin(c)) {
|
||||
return zip.ErrForbidden("approving a pairing requires org admin")
|
||||
}
|
||||
var body struct {
|
||||
Channel string `json:"channel"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.Unmarshal(c.Body(), &body); err != nil {
|
||||
return zip.ErrBadRequest("invalid request body")
|
||||
}
|
||||
channel := strings.TrimSpace(body.Channel)
|
||||
code := strings.TrimSpace(body.Code)
|
||||
if channel == "" || code == "" {
|
||||
return zip.ErrBadRequest("channel and code are required")
|
||||
}
|
||||
sender, ownerBoot, ok, err := approvePairing(c.Context(), s.State.store, org, channel, code, time.Now().Unix())
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "approve: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
return zip.ErrNotFound("unknown or expired code")
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{"sender": sender, "ownerBootstrapped": ownerBoot})
|
||||
}
|
||||
|
||||
// allowlistGet is GET /v1/channels/allowlist?channel=.
|
||||
func allowlistGet(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := principal.Org(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("a validated principal is required")
|
||||
}
|
||||
channel := strings.TrimSpace(c.Query("channel"))
|
||||
if channel == "" {
|
||||
return zip.ErrBadRequest("channel query parameter is required")
|
||||
}
|
||||
if _, ok := transportFor(channel); !ok {
|
||||
return zip.ErrNotFound("unknown channel")
|
||||
}
|
||||
v, err := allowlistFor(c.Context(), s.State.store, org, channel)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "allowlist: %v", err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, v)
|
||||
}
|
||||
|
||||
// allowlistPut is PUT /v1/channels/allowlist — admin-gated; each body field is
|
||||
// applied only when provided (nil slice / empty string = untouched), then the
|
||||
// GET payload is echoed so both verbs return ONE shape.
|
||||
func allowlistPut(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := principal.Org(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("a validated principal is required")
|
||||
}
|
||||
if !(principal.IsSuperAdmin(c) || principal.IsOrgAdmin(c)) {
|
||||
return zip.ErrForbidden("editing the allowlist requires org admin")
|
||||
}
|
||||
var body struct {
|
||||
Channel string `json:"channel"`
|
||||
DMPolicy string `json:"dmPolicy"`
|
||||
GroupPolicy string `json:"groupPolicy"`
|
||||
DM []string `json:"dm"`
|
||||
Group []string `json:"group"`
|
||||
AccessGroups map[string]map[string][]string `json:"accessGroups"`
|
||||
}
|
||||
if err := json.Unmarshal(c.Body(), &body); err != nil {
|
||||
return zip.ErrBadRequest("invalid request body")
|
||||
}
|
||||
channel := strings.TrimSpace(body.Channel)
|
||||
if channel == "" {
|
||||
return zip.ErrBadRequest("channel is required")
|
||||
}
|
||||
if _, ok := transportFor(channel); !ok {
|
||||
return zip.ErrNotFound("unknown channel")
|
||||
}
|
||||
dm, group := DMPolicy(body.DMPolicy), GroupPolicy(body.GroupPolicy)
|
||||
switch dm {
|
||||
case "", DMPairing, DMAllowlist, DMOpen:
|
||||
default:
|
||||
return zip.ErrBadRequest("dmPolicy must be pairing, allowlist, or open")
|
||||
}
|
||||
switch group {
|
||||
case "", GroupOpen, GroupAllowlist, GroupDisabled:
|
||||
default:
|
||||
return zip.ErrBadRequest("groupPolicy must be open, allowlist, or disabled")
|
||||
}
|
||||
ctx := c.Context()
|
||||
st := s.State.store
|
||||
now := time.Now().Unix()
|
||||
if dm != "" || group != "" {
|
||||
p, err := policyFor(ctx, st, org, channel)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "policy: %v", err)
|
||||
}
|
||||
if dm != "" {
|
||||
p.DM = dm
|
||||
}
|
||||
if group != "" {
|
||||
p.Group = group
|
||||
}
|
||||
if err := setPolicy(ctx, st, org, channel, p, now); err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "policy: %v", err)
|
||||
}
|
||||
}
|
||||
// channel_allow two-writer split: this PUT owns ONLY config-source rows
|
||||
// (putAllow); pairing-source rows belong to approvePairing — a policy edit
|
||||
// can never revoke an approved pairing.
|
||||
if body.DM != nil {
|
||||
if err := putAllow(ctx, st, org, channel, "dm", body.DM, now); err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "allowlist: %v", err)
|
||||
}
|
||||
}
|
||||
if body.Group != nil {
|
||||
if err := putAllow(ctx, st, org, channel, "group", body.Group, now); err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "allowlist: %v", err)
|
||||
}
|
||||
}
|
||||
if body.AccessGroups != nil {
|
||||
if err := putAccessGroups(ctx, st, org, body.AccessGroups); err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "access groups: %v", err)
|
||||
}
|
||||
}
|
||||
v, err := allowlistFor(ctx, st, org, channel)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "allowlist: %v", err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, v)
|
||||
}
|
||||
|
||||
// allowlistFor builds the allowlist payload GET returns and PUT echoes.
|
||||
func allowlistFor(ctx context.Context, st *store, org, channel string) (allowlistView, error) {
|
||||
p, err := policyFor(ctx, st, org, channel)
|
||||
if err != nil {
|
||||
return allowlistView{}, err
|
||||
}
|
||||
// paired = pairing-source rows, minted only by approvePairing (dm scope —
|
||||
// pairing is a DM concept); surfaced read-only so admins see who is paired.
|
||||
dm, paired, err := allowEntries(ctx, st, org, channel, "dm")
|
||||
if err != nil {
|
||||
return allowlistView{}, err
|
||||
}
|
||||
group, _, err := allowEntries(ctx, st, org, channel, "group")
|
||||
if err != nil {
|
||||
return allowlistView{}, err
|
||||
}
|
||||
groups, err := listAccessGroups(ctx, st, org)
|
||||
if err != nil {
|
||||
return allowlistView{}, err
|
||||
}
|
||||
return allowlistView{
|
||||
DMPolicy: p.DM,
|
||||
GroupPolicy: p.Group,
|
||||
DM: nonNil(dm),
|
||||
Group: nonNil(group),
|
||||
Paired: nonNil(paired),
|
||||
AccessGroups: groups,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// listAccessGroups reads the org's access groups (name → channel → entries) —
|
||||
// the read mirror of putAccessGroups (policy.go), for the allowlist payload.
|
||||
func listAccessGroups(ctx context.Context, st *store, org string) (map[string]map[string][]string, error) {
|
||||
rows, err := st.db.QueryContext(ctx, `SELECT name, channel, entry FROM channel_access_group
|
||||
WHERE org = ? ORDER BY name, channel, entry`, org)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
out := map[string]map[string][]string{}
|
||||
for rows.Next() {
|
||||
var name, channel, entry string
|
||||
if err := rows.Scan(&name, &channel, &entry); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out[name] == nil {
|
||||
out[name] = map[string][]string{}
|
||||
}
|
||||
out[name][channel] = append(out[name][channel], entry)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// send is POST /v1/channels/:channel/send — the ONE egress door. The body is
|
||||
// the envelope's narrow outbound projection (C2-6): identity fields (sender,
|
||||
// account, channel) are not decodable — DisallowUnknownFields rejects them
|
||||
// loudly instead of silently dropping them.
|
||||
func send(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := principal.Org(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("a validated principal is required")
|
||||
}
|
||||
raw := c.Body()
|
||||
if len(raw) > sendMaxBody {
|
||||
return zip.ErrBadRequest("body exceeds 1 MiB")
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
var r SendRequest
|
||||
if err := dec.Decode(&r); err != nil {
|
||||
return zip.ErrBadRequest("invalid body: " + err.Error())
|
||||
}
|
||||
if err := r.validate(); err != nil {
|
||||
return zip.ErrBadRequest(err.Error())
|
||||
}
|
||||
channel := strings.TrimSpace(c.Param("channel"))
|
||||
tr, ok := transportFor(channel)
|
||||
if !ok {
|
||||
return zip.ErrNotFound("unknown channel")
|
||||
}
|
||||
m := Message{
|
||||
Channel: channel,
|
||||
Room: r.Room,
|
||||
Text: r.Text,
|
||||
Attachments: r.Attachments,
|
||||
Actions: r.Actions,
|
||||
ReplyTo: r.ReplyTo,
|
||||
Idempotency: r.Idempotency,
|
||||
}
|
||||
ctx := c.Context()
|
||||
st := s.State.store
|
||||
if r.Idempotency != "" {
|
||||
fresh, prior, err := st.markSend(ctx, org, channel, r.Idempotency, time.Now().Unix())
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "idempotency: %v", err)
|
||||
}
|
||||
if !fresh {
|
||||
return c.JSON(http.StatusOK, prior)
|
||||
}
|
||||
}
|
||||
d, err := tr.send(ctx, s, org, m)
|
||||
if err != nil {
|
||||
// C2-1: release the claimed key in the SAME error path so the caller
|
||||
// can re-attempt; only a completed send replays a receipt.
|
||||
if r.Idempotency != "" {
|
||||
_ = st.unmarkSend(ctx, org, channel, r.Idempotency)
|
||||
}
|
||||
// The transports' typed refusals (errRoomNotBound telegram.go,
|
||||
// errNoRoute discord.go) map to a status HERE, in one place: 403 —
|
||||
// the room is not org-bound; 409 — no inbound-learned route yet.
|
||||
switch {
|
||||
case errors.Is(err, errRoomNotBound):
|
||||
return zip.ErrForbidden("room is not bound to this org")
|
||||
case errors.Is(err, errNoRoute):
|
||||
return zip.ErrConflict("no inbound route for this room; the bot must be messaged there first")
|
||||
}
|
||||
// Door errors carry status/shape only — never tokens (SendSlack /
|
||||
// SendDiscord contract, integrations/ingress.go).
|
||||
return zip.Errorf(http.StatusBadGateway, "%s: %v", tr.id, err)
|
||||
}
|
||||
if r.Idempotency != "" {
|
||||
// Best-effort: the message is delivered; a lost receipt only degrades
|
||||
// a later replay to an empty Delivery (documented markSend tradeoff).
|
||||
if err := st.finishSend(ctx, org, channel, r.Idempotency, d.MessageID); err != nil {
|
||||
s.Log.Warn("channels: send receipt", "channel", channel, "err", err)
|
||||
}
|
||||
}
|
||||
return c.JSON(http.StatusOK, d)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,9 +24,13 @@
|
||||
package commerceinproc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -88,6 +92,47 @@ func BaseURL(env string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// maxDepth caps how many co-resident dispatches may be NESTED on one goroutine.
|
||||
// The in-process handler is the WHOLE shared app (SetApp publishes
|
||||
// adaptor.FiberApp), so a dispatch re-runs every edge middleware — and any of
|
||||
// those middlewares (e.g. the per-org scope rate-limiter reading its rules from
|
||||
// commerce, or the ai per-tier gate) itself issues a commerce read, which
|
||||
// dispatches the whole app AGAIN. On a cold config/tier cache that read never
|
||||
// resolves before it re-enters, so it recurses without bound: synchronously it
|
||||
// overflows the goroutine stack (a fatal ~1e9-byte "stack overflow"); each level
|
||||
// also leaks the completion's net/http cancel watchdog, so under load the writer
|
||||
// accumulates tens of thousands of goroutines parked in setRequestCancel and OOMs.
|
||||
// A legitimate flow nests at most a couple of reads (a debit that first checks a
|
||||
// balance), so a small cap admits every real path while turning the runaway into a
|
||||
// bounded, fail-safe refusal at the seam. Callers of the co-resident reads treat
|
||||
// the refusal as any transport error and fall safe (the tier gate ALLOWS, the
|
||||
// scope-rule fetch fails OPEN); the prepaid balance read is a direct in-process
|
||||
// ledger call, never this transport, so fail-closed billing is unaffected.
|
||||
const maxDepth = 8
|
||||
|
||||
// depthByGoroutine tracks the current nested-dispatch depth per goroutine. The
|
||||
// dispatch is synchronous (ServeHTTP runs on the caller's goroutine), so a
|
||||
// goroutine-keyed counter measures exactly the nesting of the recursion; distinct
|
||||
// concurrent requests run on distinct goroutines and never share a count.
|
||||
var depthByGoroutine sync.Map // goid -> int
|
||||
|
||||
// goroutineID returns the current goroutine's numeric id. It is used ONLY for
|
||||
// re-entrancy accounting (never identity or security); the runtime prints it at
|
||||
// the head of the per-goroutine stack, which is the one portable way to read it.
|
||||
func goroutineID() int64 {
|
||||
var buf [64]byte
|
||||
n := runtime.Stack(buf[:], false)
|
||||
// "goroutine <id> [<state>]:"
|
||||
s := string(buf[:n])
|
||||
s = strings.TrimPrefix(s, "goroutine ")
|
||||
if i := strings.IndexByte(s, ' '); i > 0 {
|
||||
if id, err := strconv.ParseInt(s[:i], 10, 64); err == nil {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// roundTripper dispatches to the in-process commerce handler when one is published,
|
||||
// else to fallback (plain HTTP). The gin engine routes on req.URL.Path, so the host
|
||||
// in the (placeholder or real) base is irrelevant when co-resident.
|
||||
@@ -95,6 +140,26 @@ type roundTripper struct{ fallback http.RoundTripper }
|
||||
|
||||
func (rt roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if p := handler.Load(); p != nil {
|
||||
// Re-entrancy guard: bound how deep co-resident dispatches may nest on
|
||||
// this goroutine (see maxDepth). Without it a middleware that reads
|
||||
// commerce while serving a commerce read re-runs the whole app forever.
|
||||
id := goroutineID()
|
||||
depth := 0
|
||||
if v, ok := depthByGoroutine.Load(id); ok {
|
||||
depth = v.(int)
|
||||
}
|
||||
if depth >= maxDepth {
|
||||
return nil, fmt.Errorf("commerceinproc: in-process dispatch depth %d exceeded (re-entrant commerce read refused: %s %s)", maxDepth, req.Method, req.URL.Path)
|
||||
}
|
||||
depthByGoroutine.Store(id, depth+1)
|
||||
defer func() {
|
||||
if depth == 0 {
|
||||
depthByGoroutine.Delete(id)
|
||||
} else {
|
||||
depthByGoroutine.Store(id, depth)
|
||||
}
|
||||
}()
|
||||
|
||||
// Callers build CLIENT-style requests (http.NewRequest → empty
|
||||
// RequestURI); the in-process dispatch is SERVER-side, and the fiber
|
||||
// pipeline routes on RequestURI. Normalize here — the one seam.
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copyright © 2026 Hanzo AI. MIT License.
|
||||
|
||||
package commerceinproc
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// TestRoundTripReentrancyBounded reproduces the production crash shape — a handler
|
||||
// that reads the co-resident commerce app WHILE serving a commerce read, so the
|
||||
// dispatch re-runs the whole app and re-enters itself — and proves the depth guard
|
||||
// turns the unbounded recursion (stack overflow + setRequestCancel pileup) into a
|
||||
// bounded, fail-safe refusal. Without the guard this test recurses until the
|
||||
// goroutine stack overflows and the process dies; with it the outer request returns
|
||||
// and the nesting never exceeds maxDepth.
|
||||
func TestRoundTripReentrancyBounded(t *testing.T) {
|
||||
var live, peak int64
|
||||
client := Client(0)
|
||||
|
||||
app := zip.New(zip.Config{DisableStartupMessage: true})
|
||||
app.All("/loop", func(c *zip.Ctx) error {
|
||||
d := atomic.AddInt64(&live, 1)
|
||||
for {
|
||||
p := atomic.LoadInt64(&peak)
|
||||
if d <= p || atomic.CompareAndSwapInt64(&peak, p, d) {
|
||||
break
|
||||
}
|
||||
}
|
||||
defer atomic.AddInt64(&live, -1)
|
||||
// Self-read: dispatch the SAME co-resident app again (the scope-rate-limiter
|
||||
// reading its rules from commerce / the per-tier gate reading the tier — a
|
||||
// commerce read issued from inside a commerce read).
|
||||
req, _ := http.NewRequest(http.MethodGet, PlaceholderBase+"/loop", nil)
|
||||
if resp, err := client.Do(req); err == nil { // an err here is the guard's refusal — fail safe
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
return c.Bytes(http.StatusOK, []byte("ok"))
|
||||
})
|
||||
SetApp(app)
|
||||
defer SetHandler(nil)
|
||||
|
||||
// Enter THROUGH the transport so the outer request is dispatch depth 1.
|
||||
req, _ := http.NewRequest(http.MethodGet, PlaceholderBase+"/loop", nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("outer request errored (guard must not refuse the first dispatch): %v", err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
t.Logf("peak nesting reached = %d (cap maxDepth=%d)", peak, maxDepth)
|
||||
if peak < 2 {
|
||||
t.Fatalf("handler never re-entered (peak nesting %d) — the test did not exercise the recursion", peak)
|
||||
}
|
||||
if peak > maxDepth {
|
||||
t.Fatalf("nesting reached %d, exceeds the cap %d — the guard did not bound the self-dispatch", peak, maxDepth)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRoundTripSingleDispatchOK proves the guard is invisible to the normal path: a
|
||||
// single (non-nested) co-resident dispatch succeeds and leaves no depth residue, so
|
||||
// a later dispatch on the same goroutine starts fresh.
|
||||
func TestRoundTripSingleDispatchOK(t *testing.T) {
|
||||
app := zip.New(zip.Config{DisableStartupMessage: true})
|
||||
app.All("/ok", func(c *zip.Ctx) error { return c.Bytes(http.StatusOK, []byte("ok")) })
|
||||
SetApp(app)
|
||||
defer SetHandler(nil)
|
||||
|
||||
client := Client(0)
|
||||
for i := 0; i < 3; i++ {
|
||||
req, _ := http.NewRequest(http.MethodGet, PlaceholderBase+"/ok", nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch %d errored: %v", i, err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK || string(body) != "ok" {
|
||||
t.Fatalf("dispatch %d: status=%d body=%q, want 200 \"ok\"", i, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
if _, ok := depthByGoroutine.Load(goroutineID()); ok {
|
||||
t.Fatalf("depth counter leaked after balanced dispatches")
|
||||
}
|
||||
}
|
||||
+169
-18
@@ -14,12 +14,19 @@
|
||||
// GET /v1/deploy/applications/{name}/resource-tree → ApplicationTree
|
||||
// POST /v1/deploy/applications/{name}/{sync,rollback} → request App-CR reconcile
|
||||
//
|
||||
// Every route is SuperAdmin-gated (c.IsAdmin), fail-closed; the argocd UI's own
|
||||
// auth is disabled because IAM owns identity at the edge (the SPA is public
|
||||
// static assets, the data is gated). AppProject → IAM/Org (no argocd RBAC).
|
||||
// SECURITY — TENANT-SCOPED reads, SuperAdmin-only writes, fail-closed (scope.go):
|
||||
// the READ projections (applications list/detail/resource-tree, clusters, projects,
|
||||
// stream) resolve the request's scope (resolveScope) — a SuperAdmin sees the whole
|
||||
// fleet, a validated org member sees ONLY its own org's apps (hanzo.ai/org label,
|
||||
// tenant-<org> namespace), anyone else 403s. The WRITE actions (sync/rollback) and
|
||||
// the argocd bootstrap (settings/version/can-i) stay SuperAdmin-only (guard). The
|
||||
// argocd UI's own auth is disabled because IAM owns identity at the edge (the SPA is
|
||||
// public static assets, the data is scoped). AppProject → IAM/Org (no argocd RBAC):
|
||||
// projects are REFLECTED read-only from the IAM-owned (org,name) Project resource.
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -42,20 +49,123 @@ const dashPrefix = "/v1/deploy"
|
||||
func registerDashboardRoutes(app *zip.App, s *cloud.Service[state]) {
|
||||
// Bootstrap (the SPA awaits settings + userinfo before first render).
|
||||
app.Get(dashPrefix+"/settings", guard(s, cloud.Handle(s, dashSettings)))
|
||||
app.Get(dashPrefix+"/session/userinfo", guard(s, cloud.Handle(s, dashUserInfo)))
|
||||
// userinfo is the ONE deliberately PUBLIC bootstrap route: it is how the SPA
|
||||
// asks "am I signed in?", and a 403 to that question is unanswerable — the SPA
|
||||
// is an XHR client, so the document bounce in guard() never fires for it and it
|
||||
// dead-ends with no way to reach sign-in. Anonymous callers get
|
||||
// {loggedIn:false} and the sign-in URL; nothing else. It discloses no identity,
|
||||
// no cluster state, and no configuration, and it is NOT a gate: every route
|
||||
// that returns fleet data or mutates a CR stays guard()ed.
|
||||
app.Get(dashPrefix+"/session/userinfo", cloud.Handle(s, dashUserInfo))
|
||||
app.Get(dashPrefix+"/version", guard(s, cloud.Handle(s, dashVersion)))
|
||||
app.Get(dashPrefix+"/account/can-i/*", guard(s, cloud.Handle(s, dashCanI)))
|
||||
|
||||
// Applications projection (read).
|
||||
app.Get(dashPrefix+"/applications", guard(s, cloud.Handle(s, dashAppList)))
|
||||
app.Get(dashPrefix+"/applications/:name", guard(s, cloud.Handle(s, dashApp)))
|
||||
app.Get(dashPrefix+"/applications/:name/resource-tree", guard(s, cloud.Handle(s, dashResourceTree)))
|
||||
// Applications projection (read). TENANT-SCOPED, not blanket-guard()ed: each handler
|
||||
// resolves the request's scope (resolveScope) and fails closed — a SuperAdmin sees the
|
||||
// whole fleet, a validated org member sees ONLY its own org's apps, anyone else 403s.
|
||||
app.Get(dashPrefix+"/applications", cloud.Handle(s, dashAppList))
|
||||
app.Get(dashPrefix+"/applications/:name", cloud.Handle(s, dashApp))
|
||||
app.Get(dashPrefix+"/applications/:name/resource-tree", cloud.Handle(s, dashResourceTree))
|
||||
// Per-app detail projections the SPA's application view calls (detail.go). Same tenant
|
||||
// scope as dashApp: resolveScope + findNamespace, a cross-tenant name 404s.
|
||||
app.Get(dashPrefix+"/applications/:name/syncwindows", cloud.Handle(s, dashSyncWindows))
|
||||
app.Get(dashPrefix+"/applications/:name/revisions/:revision/metadata", cloud.Handle(s, dashRevisionMetadata))
|
||||
// Applications watch (Server-Sent Events) — the live stream the applications
|
||||
// view opens; see stream.go. Same tenant scope as the list.
|
||||
app.Get(dashPrefix+"/stream/applications", cloud.Handle(s, dashStreamApps))
|
||||
// Per-app live resource-tree stream (detail.go) — the detail view's tree watch, same
|
||||
// tenant scope: the scope gate runs before any SSE frame is emitted.
|
||||
app.Get(dashPrefix+"/stream/applications/:name/resource-tree", cloud.Handle(s, dashStreamResourceTree))
|
||||
|
||||
// Actions → App-CR reconcile ops.
|
||||
// Destination clusters + AppProjects — the two lists the applications view
|
||||
// resolves alongside the fleet (Destination column + project filter). Tenant-scoped:
|
||||
// clusters count only the caller's apps; projects reflect the caller's IAM projects.
|
||||
app.Get(dashPrefix+"/clusters", cloud.Handle(s, dashClusters))
|
||||
app.Get(dashPrefix+"/projects", cloud.Handle(s, dashProjects))
|
||||
|
||||
// Actions → App-CR reconcile ops. STILL SuperAdmin-only (guard): write-back to the
|
||||
// fleet is a follow-on; this plane's tenant surface is read-only reflection for now.
|
||||
app.Post(dashPrefix+"/applications/:name/sync", guard(s, cloud.Handle(s, dashSync)))
|
||||
app.Post(dashPrefix+"/applications/:name/rollback", guard(s, cloud.Handle(s, dashSync)))
|
||||
}
|
||||
|
||||
// ── clusters + projects projection ───────────────────────────────────────────
|
||||
|
||||
// dashClusters is GET /v1/deploy/clusters — the ArgoCD ClusterList of the
|
||||
// destinations the fleet reconciles into (always ≥ the in-cluster destination),
|
||||
// read from the SAME App-CR source dashAppList uses. It NEVER surfaces a cluster
|
||||
// credential (argoCluster has no config field — see projection.go).
|
||||
func dashClusters(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
sc, ok := resolveScope(c)
|
||||
if !ok {
|
||||
return refuse(c)
|
||||
}
|
||||
if err := ready(s); err != nil {
|
||||
return err
|
||||
}
|
||||
// Only the caller's own apps are counted (SuperAdmin: the whole fleet). The in-cluster
|
||||
// destination is always present (projectClusters), and no cluster credential can leak
|
||||
// (argoCluster has no config field) — so a tenant view is still credential-free.
|
||||
crs, err := sc.appCRs(s, c.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(http.StatusOK, projectClusters(crs))
|
||||
}
|
||||
|
||||
// dashProjects is GET /v1/deploy/projects — the ArgoCD AppProjectList. It PREFERS
|
||||
// real argoproj.io/v1alpha1 AppProject CRs when that CRD is served; otherwise it
|
||||
// synthesizes one permissive project per distinct App-CR project name (default
|
||||
// always present). Read-only, from the same App-CR source.
|
||||
func dashProjects(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
sc, ok := resolveScope(c)
|
||||
if !ok {
|
||||
return refuse(c)
|
||||
}
|
||||
if err := ready(s); err != nil {
|
||||
return err
|
||||
}
|
||||
// IAM is the ONE source of truth for the (org,name) Project resource. Reflect it: a
|
||||
// normal org sees ONLY its own organization's projects, a SuperAdmin sees every org's.
|
||||
items := sc.iamProjects()
|
||||
if sc.superAdmin && len(items) == 0 {
|
||||
// Whole-fleet fallback when the embedded IAM store is unavailable/empty: preserve the
|
||||
// pre-tenant projection — real argocd AppProject CRs if that CRD is served (that list is
|
||||
// cluster-wide/unscoped, so it is a SuperAdmin-only path, NEVER a tenant's), else
|
||||
// synthesize from the fleet's distinct App-CR project names. Keeps the SuperAdmin view
|
||||
// populated even before IAM is reachable (e.g. in a unit test with no embedded store).
|
||||
if real, served := listAppProjects(s, c.Context()); served {
|
||||
return c.JSON(http.StatusOK, argoProjectList{Metadata: argoListMeta{}, Items: real})
|
||||
}
|
||||
crs, err := sc.appCRs(s, c.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range projectedProjectNames(crs) {
|
||||
items = append(items, synthProject(name))
|
||||
}
|
||||
}
|
||||
// "default" always resolves (every projected app's spec.project falls back to it), which
|
||||
// also holds the e2e invariant that the projects list contains 'default'.
|
||||
return c.JSON(http.StatusOK, argoProjectList{Metadata: argoListMeta{}, Items: ensureDefault(items)})
|
||||
}
|
||||
|
||||
// listAppProjects lists real argoproj.io/v1alpha1 AppProject CRs cluster-wide. It
|
||||
// returns (projected, true) ONLY when the CRD is served AND at least one project
|
||||
// exists; any error (CRD absent — the norm here, or RBAC) or an empty set yields
|
||||
// (nil, false) so the caller synthesizes. It never fails the request.
|
||||
func listAppProjects(s *cloud.Service[state], ctx context.Context) ([]argoProject, bool) {
|
||||
list, err := s.State.dyn.Resource(appProjectGVR).List(ctx, metav1.ListOptions{})
|
||||
if err != nil || list == nil || len(list.Items) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
out := make([]argoProject, 0, len(list.Items))
|
||||
for i := range list.Items {
|
||||
out = append(out, projectAppProject(&list.Items[i]))
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// ── bootstrap ────────────────────────────────────────────────────────────────
|
||||
|
||||
func dashSettings(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
@@ -79,17 +189,36 @@ func dashSettings(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// dashUserInfo answers "is this browser signed in, and if not where does it sign
|
||||
// in?" — the SPA's bootstrap question, and the only route on this plane that
|
||||
// answers for an anonymous caller.
|
||||
//
|
||||
// The anonymous branch carries loggedIn:false and a URL, and NOTHING else: no
|
||||
// username, no org, no groups, no issuer, no hint about who the caller might be or
|
||||
// what exists in the cluster. Answering it costs nothing (the caller already knows
|
||||
// whether it holds a cookie) and withholding it costs the whole sign-in journey.
|
||||
//
|
||||
// The predicate is c.IsAdmin() — the SAME SuperAdmin fact guard() gates on, minted
|
||||
// by SanitizeIdentity from a validated principal whose org is the reserved admin
|
||||
// org. So a validated-but-not-SuperAdmin caller is reported as NOT logged in here,
|
||||
// which is the truth as this console defines it: they cannot use it.
|
||||
func dashUserInfo(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
// IAM authenticated the request at the edge; report the principal.
|
||||
if !c.IsAdmin() {
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"loggedIn": false,
|
||||
"loginUrl": loginPath,
|
||||
})
|
||||
}
|
||||
user := c.User()
|
||||
if user == "" {
|
||||
user = "admin"
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"loggedIn": true,
|
||||
"username": user,
|
||||
"iss": "argocd", // keep == argocd so the UI never triggers an SSO redirect
|
||||
"groups": []string{},
|
||||
"loggedIn": true,
|
||||
"username": user,
|
||||
"iss": "argocd", // keep == argocd so the UI never triggers an SSO redirect
|
||||
"groups": []string{},
|
||||
"logoutUrl": logoutPath,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -110,17 +239,24 @@ func dashCanI(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
// ── applications projection ──────────────────────────────────────────────────
|
||||
|
||||
func dashAppList(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
sc, ok := resolveScope(c)
|
||||
if !ok {
|
||||
return refuse(c)
|
||||
}
|
||||
if err := ready(s); err != nil {
|
||||
return err
|
||||
}
|
||||
list := argoAppList{APIVersion: "argoproj.io/v1alpha1", Kind: "ApplicationList", Metadata: argoListMeta{}, Items: []argoApp{}}
|
||||
for _, ns := range scanOrder() {
|
||||
for _, ns := range sc.namespaces() {
|
||||
crs, err := listAppCRs(s, c.Context(), ns)
|
||||
if err != nil {
|
||||
return k8sErr(s, "list", err)
|
||||
}
|
||||
running := runningVersions(s, c.Context(), ns)
|
||||
for i := range crs {
|
||||
if !sc.allows(&crs[i]) {
|
||||
continue // cross-tenant CR — never projected to this scope
|
||||
}
|
||||
list.Items = append(list.Items, projectApp(&crs[i], ns, running[crs[i].GetName()]))
|
||||
}
|
||||
}
|
||||
@@ -128,6 +264,10 @@ func dashAppList(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
}
|
||||
|
||||
func dashApp(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
sc, ok := resolveScope(c)
|
||||
if !ok {
|
||||
return refuse(c)
|
||||
}
|
||||
if err := ready(s); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -135,7 +275,8 @@ func dashApp(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
if !appNameRE.MatchString(name) {
|
||||
return zip.ErrBadRequest("name must be a DNS-1123 label")
|
||||
}
|
||||
ns, err := resolveNamespace(s, c, name)
|
||||
// findNamespace 404s a cross-tenant name (org A's app requested by org B) — no oracle.
|
||||
ns, err := sc.findNamespace(s, c, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -157,6 +298,10 @@ func dashApp(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
}
|
||||
|
||||
func dashResourceTree(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
sc, ok := resolveScope(c)
|
||||
if !ok {
|
||||
return refuse(c)
|
||||
}
|
||||
if err := ready(s); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -164,7 +309,7 @@ func dashResourceTree(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
if !appNameRE.MatchString(name) {
|
||||
return zip.ErrBadRequest("name must be a DNS-1123 label")
|
||||
}
|
||||
ns, err := resolveNamespace(s, c, name)
|
||||
ns, err := sc.findNamespace(s, c, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -180,6 +325,12 @@ func dashResourceTree(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
// truth; rollback-by-revision is the image-pin follow-on). Returns the projected
|
||||
// Application (the UI only checks for a non-error response).
|
||||
func dashSync(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
// Reached only through guard() (SuperAdmin-only), so the scope is always whole-fleet;
|
||||
// resolving it keeps ONE namespace-resolution path (findNamespace) across the plane.
|
||||
sc, ok := resolveScope(c)
|
||||
if !ok {
|
||||
return refuse(c)
|
||||
}
|
||||
if err := ready(s); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -187,7 +338,7 @@ func dashSync(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
if !appNameRE.MatchString(name) {
|
||||
return zip.ErrBadRequest("name must be a DNS-1123 label")
|
||||
}
|
||||
ns, err := resolveNamespace(s, c, name)
|
||||
ns, err := sc.findNamespace(s, c, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
luxlog "github.com/luxfi/log"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/watch"
|
||||
)
|
||||
|
||||
// getJSON drives one guarded GET through the full router as a SuperAdmin and
|
||||
// decodes the JSON body. It asserts a 200 so a route-not-registered (404) or a
|
||||
// guard reject (403) fails loudly.
|
||||
func getJSON(t *testing.T, s *cloud.Service[state], path string) map[string]any {
|
||||
t.Helper()
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
routes(app, s)
|
||||
req := httptest.NewRequest("GET", path, nil)
|
||||
req.Header.Set("X-User-IsAdmin", "true")
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET %s (admin) = %d, want 200", path, resp.StatusCode)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode %s: %v", path, err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// ── clusters endpoint ─────────────────────────────────────────────────────────
|
||||
|
||||
// TestDashClustersEndpoint: /clusters returns a ClusterList the SPA's Destination
|
||||
// column reads (items[].server/name/connectionState), with the fleet's app count,
|
||||
// and NO cluster credential.
|
||||
func TestDashClustersEndpoint(t *testing.T) {
|
||||
s := fakeSvc(
|
||||
appCR("App", "hanzo", "cloud", "u1", "ghcr.io/hanzoai/cloud", "v1", "Running", 1, 1),
|
||||
appCR("App", "hanzo", "iam", "u2", "ghcr.io/hanzoai/iam", "v1", "Running", 1, 1),
|
||||
)
|
||||
body := getJSON(t, s, "/v1/deploy/clusters")
|
||||
|
||||
items, ok := body["items"].([]any)
|
||||
if !ok || len(items) != 1 {
|
||||
t.Fatalf("clusters items = %v, want exactly one (in-cluster)", body["items"])
|
||||
}
|
||||
c0 := items[0].(map[string]any)
|
||||
if c0["server"] != inClusterServer || c0["name"] != inClusterName {
|
||||
t.Fatalf("cluster[0] = %v, want in-cluster", c0)
|
||||
}
|
||||
if cs, _ := c0["connectionState"].(map[string]any); cs["status"] != "Successful" {
|
||||
t.Fatalf("connectionState = %v, want status Successful", c0["connectionState"])
|
||||
}
|
||||
if info, _ := c0["info"].(map[string]any); info["applicationsCount"].(float64) != 2 {
|
||||
t.Fatalf("applicationsCount = %v, want 2", c0["info"])
|
||||
}
|
||||
// Credential-leak guard at the HTTP boundary.
|
||||
raw, _ := json.Marshal(body)
|
||||
for _, forbidden := range []string{"config", "bearerToken", "tlsClientConfig", "execProviderConfig"} {
|
||||
if strings.Contains(string(raw), forbidden) {
|
||||
t.Fatalf("clusters response leaked %q: %s", forbidden, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── projects endpoint ─────────────────────────────────────────────────────────
|
||||
|
||||
// TestDashProjectsEndpoint_Synthesizes: with no AppProject CRD served, /projects
|
||||
// synthesizes the distinct App-CR project set (default always present).
|
||||
func TestDashProjectsEndpoint_Synthesizes(t *testing.T) {
|
||||
s := fakeSvc(appCR("App", "hanzo", "cloud", "u1", "ghcr.io/hanzoai/cloud", "v1", "Running", 1, 1))
|
||||
body := getJSON(t, s, "/v1/deploy/projects")
|
||||
|
||||
items, ok := body["items"].([]any)
|
||||
if !ok || len(items) == 0 {
|
||||
t.Fatalf("projects items = %v, want at least [default]", body["items"])
|
||||
}
|
||||
found := false
|
||||
for _, it := range items {
|
||||
if it.(map[string]any)["metadata"].(map[string]any)["name"] == "default" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("projects missing 'default': %v", items)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashProjectsEndpoint_PrefersRealCRs: when real AppProject CRs are served,
|
||||
// /projects lists THOSE (not synthesized ones) and surfaces only intended fields.
|
||||
func TestDashProjectsEndpoint_PrefersRealCRs(t *testing.T) {
|
||||
s := fakeSvc(
|
||||
appCR("App", "hanzo", "cloud", "u1", "ghcr.io/hanzoai/cloud", "v1", "Running", 1, 1),
|
||||
appProjectCR("team-a", "https://git.hanzo.ai/team-a/*"),
|
||||
)
|
||||
body := getJSON(t, s, "/v1/deploy/projects")
|
||||
items := body["items"].([]any)
|
||||
names := map[string]bool{}
|
||||
for _, it := range items {
|
||||
names[it.(map[string]any)["metadata"].(map[string]any)["name"].(string)] = true
|
||||
}
|
||||
if !names["team-a"] {
|
||||
t.Fatalf("real AppProject 'team-a' not listed: %v", names)
|
||||
}
|
||||
// The synthesized 'default' must NOT appear when real projects are preferred.
|
||||
if names["default"] {
|
||||
t.Fatalf("synthesized 'default' present alongside real projects: %v", names)
|
||||
}
|
||||
raw, _ := json.Marshal(body)
|
||||
if strings.Contains(string(raw), "secret-role") {
|
||||
t.Fatalf("real AppProject leaked role metadata: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// ── auth: every new route is SuperAdmin-gated ─────────────────────────────────
|
||||
|
||||
// TestNewRoutesRequireAdmin: clusters, projects, and the stream must 403 without
|
||||
// the SuperAdmin claim (fail-closed, no fleet/cluster data to an anonymous caller).
|
||||
func TestNewRoutesRequireAdmin(t *testing.T) {
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
routes(app, fakeSvc())
|
||||
for _, path := range []string{"/v1/deploy/clusters", "/v1/deploy/projects", "/v1/deploy/stream/applications"} {
|
||||
// EventSource sends Accept: text/event-stream + Sec-Fetch-Dest: empty, so a
|
||||
// non-admin gets a 403 (not the browser-document redirect) — assert both.
|
||||
req := httptest.NewRequest("GET", path, nil)
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
req.Header.Set("Sec-Fetch-Dest", "empty")
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", path, err)
|
||||
}
|
||||
code := resp.StatusCode
|
||||
_ = resp.Body.Close()
|
||||
if code != http.StatusForbidden {
|
||||
t.Errorf("GET %s WITHOUT admin = %d, want 403", path, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── stream: SSE headers ───────────────────────────────────────────────────────
|
||||
|
||||
// TestStreamSetsSSEHeaders: the handler sets the SSE response headers. Tested via
|
||||
// the pure header helper so no body-stream goroutine is spawned (fasthttp starts
|
||||
// the writer eagerly on SendStreamWriter — see setStreamHeaders).
|
||||
func TestStreamSetsSSEHeaders(t *testing.T) {
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
c := app.TestCtx("GET", "/v1/deploy/stream/applications")
|
||||
setStreamHeaders(c)
|
||||
resp := c.Fiber().Response()
|
||||
if ct := string(resp.Header.Peek("Content-Type")); ct != "text/event-stream" {
|
||||
t.Errorf("Content-Type = %q, want text/event-stream", ct)
|
||||
}
|
||||
if cc := string(resp.Header.Peek("Cache-Control")); cc != "no-cache" {
|
||||
t.Errorf("Cache-Control = %q, want no-cache", cc)
|
||||
}
|
||||
if conn := string(resp.Header.Peek("Connection")); conn != "keep-alive" {
|
||||
t.Errorf("Connection = %q, want keep-alive", conn)
|
||||
}
|
||||
if b := string(resp.Header.Peek("X-Accel-Buffering")); b != "no" {
|
||||
t.Errorf("X-Accel-Buffering = %q, want no (defeat proxy buffering)", b)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamFailsClosedWithoutCluster: no k8s client → the handler 503s BEFORE
|
||||
// any streaming (fail-closed), never a fabricated 200 stream.
|
||||
func TestStreamFailsClosedWithoutCluster(t *testing.T) {
|
||||
noK8s := &cloud.Service[state]{Base: cloud.Base{Log: luxlog.New("test")}, State: state{initErr: "no kubeconfig"}}
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
c := app.TestCtx("GET", "/v1/deploy/stream/applications")
|
||||
err := dashStreamApps(noK8s, c)
|
||||
if err == nil {
|
||||
t.Fatal("dashStreamApps with no cluster client = nil error, want 503")
|
||||
}
|
||||
var he *zip.HTTPError
|
||||
if !errors.As(err, &he) || he.Status != http.StatusServiceUnavailable {
|
||||
t.Fatalf("stream no-cluster error = %v, want 503 HTTPError", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── stream: burst core ────────────────────────────────────────────────────────
|
||||
|
||||
// TestStreamBurstEmitsAddedPerApp: the burst emits one ArgoCD ADDED watch event
|
||||
// per current App CR — the ApplicationWatchEvent envelope the SPA parses — with
|
||||
// the argo Application shape (status.health/sync), projected identically to
|
||||
// dashAppList.
|
||||
func TestStreamBurstEmitsAddedPerApp(t *testing.T) {
|
||||
s := fakeSvc(
|
||||
appCR("App", "hanzo", "cloud", "u1", "ghcr.io/hanzoai/cloud", "v1", "Running", 1, 1),
|
||||
appCR("App", "hanzo", "iam", "u2", "ghcr.io/hanzoai/iam", "v1", "Running", 1, 1),
|
||||
)
|
||||
var buf bytes.Buffer
|
||||
w := bufio.NewWriter(&buf)
|
||||
if ok := streamAppBurst(s, superScope(), context.Background(), w); !ok {
|
||||
t.Fatal("streamAppBurst returned false (write failed) on a live buffer")
|
||||
}
|
||||
_ = w.Flush()
|
||||
|
||||
frames := parseSSE(buf.String())
|
||||
if len(frames) != 2 {
|
||||
t.Fatalf("burst emitted %d frames, want 2 (one per app): %q", len(frames), buf.String())
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, f := range frames {
|
||||
var env struct {
|
||||
Result applicationWatchEvent `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(f), &env); err != nil {
|
||||
t.Fatalf("frame is not the {result:{...}} envelope: %q (%v)", f, err)
|
||||
}
|
||||
if env.Result.Type != "ADDED" {
|
||||
t.Fatalf("event type = %q, want ADDED", env.Result.Type)
|
||||
}
|
||||
if env.Result.Application.APIVersion != "argoproj.io/v1alpha1" || env.Result.Application.Kind != "Application" {
|
||||
t.Fatalf("application is not an argo Application: %+v", env.Result.Application)
|
||||
}
|
||||
if env.Result.Application.Status.Health.Status == "" || env.Result.Application.Status.Sync.Status == "" {
|
||||
t.Fatalf("application missing health/sync: %+v", env.Result.Application.Status)
|
||||
}
|
||||
names[env.Result.Application.Metadata.Name] = true
|
||||
}
|
||||
if !names["cloud"] || !names["iam"] {
|
||||
t.Fatalf("burst missing an app: got %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamBurstZeroAppsNoPanic: an empty fleet emits nothing, does not panic,
|
||||
// and reports the client is still connected.
|
||||
func TestStreamBurstZeroAppsNoPanic(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
w := bufio.NewWriter(&buf)
|
||||
if ok := streamAppBurst(fakeSvc(), superScope(), context.Background(), w); !ok {
|
||||
t.Fatal("streamAppBurst on zero apps returned false, want true")
|
||||
}
|
||||
_ = w.Flush()
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("zero-app burst wrote %q, want nothing", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamHonorsContextCancel: the watch loop returns promptly when the request
|
||||
// context is canceled — no hung goroutine, no leaked watch. Cancel drives the
|
||||
// return directly (the keep-alive interval is irrelevant), so this needs no global
|
||||
// tuning and cannot race a concurrent stream.
|
||||
func TestStreamHonorsContextCancel(t *testing.T) {
|
||||
s := fakeSvc(appCR("App", "hanzo", "cloud", "u1", "ghcr.io/hanzoai/cloud", "v1", "Running", 1, 1))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
streamApps(s, superScope(), ctx, bufio.NewWriter(io.Discard))
|
||||
close(done)
|
||||
}()
|
||||
// Let the burst + watch establish, then cancel.
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("streamApps did not return within 2s of context cancel (goroutine/watch leak)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestForwardWatchSkipsTypedNilObjectNotFatal proves a malformed watch event
|
||||
// carrying a typed-nil *unstructured.Unstructured is skipped, not fatal: the
|
||||
// forwardWatch goroutine must not nil-deref on GetName() and a following valid
|
||||
// event must still forward. A watch event is a system boundary and the read
|
||||
// plane installs no panic recovery — a crash here would take down the process.
|
||||
func TestForwardWatchSkipsTypedNilObjectNotFatal(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
fw := watch.NewFake()
|
||||
events := make(chan streamEvent, 4)
|
||||
go forwardWatch(ctx, superScope(), "hanzo", fw, events)
|
||||
|
||||
go func() {
|
||||
// a typed-nil object as ADDED: e.Object.(*unstructured.Unstructured) is
|
||||
// (nil, true), so the pre-guard code nil-derefs on GetName().
|
||||
fw.Action(watch.Added, (*unstructured.Unstructured)(nil))
|
||||
// then a valid App CR in a platform namespace — must still come through.
|
||||
fw.Action(watch.Added, appCR("App", "hanzo", "cloud", "u1", "ghcr.io/hanzoai/cloud", "v1", "Running", 1, 1))
|
||||
}()
|
||||
|
||||
select {
|
||||
case ev := <-events:
|
||||
if ev.obj == nil || ev.obj.GetName() != "cloud" {
|
||||
t.Fatalf("expected the valid App CR to forward; got %+v", ev)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("valid event never arrived — forwardWatch died on the typed-nil object")
|
||||
}
|
||||
}
|
||||
|
||||
// ── watchType mapping ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestWatchTypeMapsArgoVocab(t *testing.T) {
|
||||
cases := map[string]string{"ADDED": "ADDED", "MODIFIED": "MODIFIED", "DELETED": "DELETED", "BOOKMARK": "", "ERROR": ""}
|
||||
for in, want := range cases {
|
||||
if got := watchType(watch.EventType(in)); got != want {
|
||||
t.Errorf("watchType(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── tiny local helpers (no new deps) ─────────────────────────────────────────
|
||||
|
||||
// parseSSE extracts the JSON payload of each `data: …` frame, ignoring keep-alive
|
||||
// comment lines (`: …`).
|
||||
func parseSSE(s string) []string {
|
||||
var out []string
|
||||
for _, block := range strings.Split(s, "\n\n") {
|
||||
line := strings.TrimSpace(block)
|
||||
if strings.HasPrefix(line, "data: ") {
|
||||
out = append(out, strings.TrimPrefix(line, "data: "))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -23,7 +24,6 @@ func TestDeployRoutesRequireAdmin(t *testing.T) {
|
||||
{"POST", "/v1/deploy/reconcile"},
|
||||
// projection API — clean /v1/deploy/<resource> (no /api/, no inner /v1)
|
||||
{"GET", "/v1/deploy/settings"},
|
||||
{"GET", "/v1/deploy/session/userinfo"},
|
||||
{"GET", "/v1/deploy/version"},
|
||||
{"GET", "/v1/deploy/account/can-i/applications/get/x"},
|
||||
{"GET", "/v1/deploy/applications"},
|
||||
@@ -31,6 +31,9 @@ func TestDeployRoutesRequireAdmin(t *testing.T) {
|
||||
{"GET", "/v1/deploy/applications/cloud/resource-tree"},
|
||||
{"POST", "/v1/deploy/applications/cloud/sync"},
|
||||
{"POST", "/v1/deploy/applications/cloud/rollback"},
|
||||
// destination clusters + AppProjects (the applications view's side lists)
|
||||
{"GET", "/v1/deploy/clusters"},
|
||||
{"GET", "/v1/deploy/projects"},
|
||||
}
|
||||
for _, r := range guarded {
|
||||
// WITHOUT admin → 403 (the guard, fail-closed).
|
||||
@@ -66,4 +69,94 @@ func TestDeployRoutesRequireAdmin(t *testing.T) {
|
||||
t.Error("/v1/deploy/health must stay public (probe-able without a JWT)")
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// Sign-in is DELIBERATELY public — these routes ARE how a browser becomes a
|
||||
// SuperAdmin, so gating them behind SuperAdmin would be circular. They grant
|
||||
// nothing: /login only redirects, /callback refuses anything but a token this
|
||||
// deployment verifies for a member of the admin org.
|
||||
for _, r := range []struct{ method, path string }{
|
||||
{"GET", "/v1/deploy/login"},
|
||||
{"GET", "/v1/deploy/callback"},
|
||||
{"POST", "/v1/deploy/logout"},
|
||||
} {
|
||||
resp, err := app.Fiber().Test(httptest.NewRequest(r.method, r.path, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", r.method, r.path, err)
|
||||
}
|
||||
code := resp.StatusCode
|
||||
_ = resp.Body.Close()
|
||||
if code == http.StatusForbidden {
|
||||
t.Errorf("%s %s = 403; sign-in cannot require the role it grants", r.method, r.path)
|
||||
}
|
||||
}
|
||||
|
||||
// Logout must NOT be reachable as a GET: it changes state, and a cross-site
|
||||
// top-level navigation carries a SameSite=Lax cookie.
|
||||
resp, err = app.Fiber().Test(httptest.NewRequest("GET", "/v1/deploy/logout", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("GET logout: %v", err)
|
||||
}
|
||||
code := resp.StatusCode
|
||||
_ = resp.Body.Close()
|
||||
if code != http.StatusMethodNotAllowed && code != http.StatusNotFound {
|
||||
t.Errorf("GET /v1/deploy/logout = %d, want 404/405 (logout is POST-only)", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUserInfoIsPublicBootstrap: the SPA's "am I signed in?" question must be
|
||||
// answerable WITHOUT being signed in — it is an XHR client, so the document bounce
|
||||
// never fires for it and a 403 here is a dead end with no route to sign-in. The
|
||||
// anonymous answer carries the sign-in URL and NOTHING that identifies anyone.
|
||||
func TestUserInfoIsPublicBootstrap(t *testing.T) {
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
routes(app, fakeSvc())
|
||||
|
||||
resp, err := app.Fiber().Test(httptest.NewRequest("GET", "/v1/deploy/session/userinfo", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("userinfo: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("anonymous userinfo = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
if body["loggedIn"] != false {
|
||||
t.Errorf("anonymous loggedIn = %v, want false", body["loggedIn"])
|
||||
}
|
||||
if body["loginUrl"] != "/v1/deploy/login" {
|
||||
t.Errorf("loginUrl = %v, want /v1/deploy/login", body["loginUrl"])
|
||||
}
|
||||
// No identity may leak to an anonymous caller — not even an empty placeholder.
|
||||
for _, k := range []string{"username", "iss", "groups", "email", "org", "logoutUrl"} {
|
||||
if _, present := body[k]; present {
|
||||
t.Errorf("anonymous userinfo leaked %q = %v", k, body[k])
|
||||
}
|
||||
}
|
||||
// A cookie must never be minted by a read.
|
||||
for _, ck := range resp.Cookies() {
|
||||
if ck.Value != "" {
|
||||
t.Errorf("userinfo minted a cookie %s=%q", ck.Name, ck.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// A SuperAdmin gets the real identity on the same route.
|
||||
req := httptest.NewRequest("GET", "/v1/deploy/session/userinfo", nil)
|
||||
req.Header.Set("X-User-IsAdmin", "true")
|
||||
req.Header.Set("X-User-Id", "cto")
|
||||
resp2, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("admin userinfo: %v", err)
|
||||
}
|
||||
var admin map[string]any
|
||||
if err := json.NewDecoder(resp2.Body).Decode(&admin); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
_ = resp2.Body.Close()
|
||||
if admin["loggedIn"] != true || admin["username"] != "cto" {
|
||||
t.Errorf("admin userinfo = %v, want loggedIn:true username:cto", admin)
|
||||
}
|
||||
}
|
||||
|
||||
+46
-11
@@ -18,12 +18,15 @@
|
||||
// (the operator reconciles the rollout).
|
||||
// POST /v1/deploy/{name}/sync — request an operator reconcile now.
|
||||
//
|
||||
// SECURITY — every route is SUPERADMIN ONLY, fail-closed, on the SAME predicate
|
||||
// the rest of cloud uses (c.IsAdmin()): the plane reads and mutates SYSTEM Service
|
||||
// CRs across the whole fleet, so a tenant must never reach it. Secret objects are
|
||||
// never surfaced (no node, no manifest) so the tree can never leak materialized
|
||||
// env. The user-facing per-org PaaS is /v1/platform; this is the platform-operator
|
||||
// console the admin dashboard consumes.
|
||||
// SECURITY — the projection READS are TENANT-SCOPED and the WRITES stay SuperAdmin-
|
||||
// only, all fail-closed on the SAME identity boundary the rest of cloud trusts
|
||||
// (resolveScope, scope.go — validated principal + injective provisioning.SanitizeOrg
|
||||
// + the c.IsAdmin() SuperAdmin predicate): a SuperAdmin sees/mutates the whole fleet,
|
||||
// a validated org member sees ONLY its own org's apps (hanzo.ai/org label), and the
|
||||
// reconcile writes (sync/rollback) remain SuperAdmin-only. Secret objects are never
|
||||
// surfaced (no node, no manifest) so the tree can never leak materialized env. The
|
||||
// user-facing per-org PaaS is /v1/platform; this is the platform-operator console the
|
||||
// admin dashboard consumes, now also serving a read-only per-org reflection.
|
||||
//
|
||||
// GitOps note (the follow-on seam): today the CR is the desired-state source and a
|
||||
// rollback/rollout PATCHES it directly (P1's RegisterServiceReleaser), so deploys
|
||||
@@ -118,17 +121,19 @@ type state struct {
|
||||
dyn dynamic.Interface // nil when no kubeconfig resolved (fail-closed)
|
||||
clientset kubernetes.Interface
|
||||
initErr string
|
||||
oauth oauth // sign-in configuration (login.go)
|
||||
}
|
||||
|
||||
// Mount wires /v1/deploy/* onto app. Every handler gates on c.IsAdmin() first.
|
||||
func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
return cloud.Mount(app, deps, "deploy", build, routes)
|
||||
return cloud.Mount(app, deps, "deploy",
|
||||
func(b cloud.Base) (state, error) { return build(b, newOAuth(deps)) }, routes)
|
||||
}
|
||||
|
||||
// build resolves the in-process k8s clients (fail-closed: when no kubeconfig
|
||||
// resolves the subsystem still mounts and every endpoint 503s honestly).
|
||||
func build(b cloud.Base) (state, error) {
|
||||
var st state
|
||||
func build(b cloud.Base, o oauth) (state, error) {
|
||||
st := state{oauth: o}
|
||||
dyn, cs, err := newClients()
|
||||
if err != nil {
|
||||
st.initErr = err.Error()
|
||||
@@ -136,7 +141,8 @@ func build(b cloud.Base) (state, error) {
|
||||
} else {
|
||||
st.dyn, st.clientset = dyn, cs
|
||||
}
|
||||
b.Log.Info("deploy control plane mounted", "prefix", "/v1/deploy", "k8s", st.dyn != nil, "brand", b.Brand, "env", b.Env)
|
||||
b.Log.Info("deploy control plane mounted", "prefix", "/v1/deploy", "k8s", st.dyn != nil,
|
||||
"brand", b.Brand, "env", b.Env, "iam", o.issuer, "client", o.clientID, "adminOrg", o.adminOrg)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
@@ -145,6 +151,17 @@ func build(b cloud.Base) (state, error) {
|
||||
func routes(app *zip.App, s *cloud.Service[state]) {
|
||||
// Liveness — public (probe-able without a JWT).
|
||||
app.Get("/v1/deploy/health", cloud.Handle(s, health))
|
||||
// Sign-in — necessarily public: these three routes ARE how a browser gets an
|
||||
// authenticated principal for this host. They grant nothing themselves; the
|
||||
// session they mint is an IAM JWT the identity boundary re-verifies on every
|
||||
// later request, and a principal outside the admin org is refused a cookie.
|
||||
// See login.go.
|
||||
app.Get(loginPath, cloud.Handle(s, login))
|
||||
app.Get(callbackPath, cloud.Handle(s, callback))
|
||||
// POST, not GET: signing out changes state, and a state-changing GET is
|
||||
// reachable by a cross-site top-level navigation that a SameSite=Lax cookie
|
||||
// still rides. See logout in login.go.
|
||||
app.Post(logoutPath, cloud.Handle(s, logout))
|
||||
// Engine (write) reconcile — the embedded gitops-engine that replaces
|
||||
// universe-crs. Gated by DEPLOY_ENGINE_ENABLED; see engine_mount.go.
|
||||
registerEngineRoutes(app, s)
|
||||
@@ -156,15 +173,33 @@ func routes(app *zip.App, s *cloud.Service[state]) {
|
||||
|
||||
// guard wraps a handler with the SuperAdmin gate (fail-closed: a non-SuperAdmin is
|
||||
// refused 403 before any cluster object is read or mutated), matching clients/paas.
|
||||
//
|
||||
// The gate itself is unchanged — c.IsAdmin() and nothing else, on the SanitizeIdentity
|
||||
// -minted header no client can forge. Only the SHAPE of the refusal is negotiated: a
|
||||
// browser NAVIGATION to a deploy URL is sent to the sign-in page (a 403 page with no
|
||||
// way to sign in is a dead end), while every API call keeps its 403. wantsDocument
|
||||
// decides, and it decides "no" unless the request positively identifies as a document
|
||||
// GET — so the API contract, and every client that depends on the 403, is untouched.
|
||||
func guard(s *cloud.Service[state], h zip.Handler) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
if !c.IsAdmin() {
|
||||
return zip.ErrForbidden("SuperAdmin required")
|
||||
return refuse(c) // the ONE fail-closed refusal (redirect a navigation, 403 an API call)
|
||||
}
|
||||
return h(c)
|
||||
}
|
||||
}
|
||||
|
||||
// currentPath is the path+query to return to after signing in. It is run through
|
||||
// the same open-redirect guard as a caller-supplied returnTo — the value is
|
||||
// server-derived, but there is exactly ONE rule for what a return path may be.
|
||||
func currentPath(c *zip.Ctx) string {
|
||||
p := c.Path()
|
||||
if q := string(c.Fiber().Request().URI().QueryString()); q != "" {
|
||||
p += "?" + q
|
||||
}
|
||||
return safeReturn(p)
|
||||
}
|
||||
|
||||
// health is a REAL probe: the API server is reachable AND the App CRD is served.
|
||||
// 200 only when both hold; 503 + the real reason otherwise. Not admin-gated —
|
||||
// liveness must be probe-able without a JWT.
|
||||
|
||||
@@ -28,6 +28,7 @@ func fakeSvc(objs ...runtime.Object) *cloud.Service[state] {
|
||||
hpaGVR: "HorizontalPodAutoscalerList",
|
||||
pdbGVR: "PodDisruptionBudgetList",
|
||||
configMapsGVR: "ConfigMapList",
|
||||
appProjectGVR: "AppProjectList",
|
||||
}, objs...)
|
||||
return &cloud.Service[state]{Base: cloud.Base{Log: luxlog.New("test")}, State: state{dyn: dyn}}
|
||||
}
|
||||
@@ -72,6 +73,25 @@ func pod(ns, name, image string, labels map[string]any) *unstructured.Unstructur
|
||||
}}
|
||||
}
|
||||
|
||||
// appProjectCR builds a real argoproj.io/v1alpha1 AppProject CR (the "prefer real
|
||||
// projects" path). sourceRepos are surfaced; anything else on the CR is not.
|
||||
func appProjectCR(name string, sourceRepos ...string) *unstructured.Unstructured {
|
||||
repos := make([]any, 0, len(sourceRepos))
|
||||
for _, r := range sourceRepos {
|
||||
repos = append(repos, r)
|
||||
}
|
||||
return &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "argoproj.io/v1alpha1", "kind": "AppProject",
|
||||
"metadata": map[string]any{"name": name, "namespace": "argocd"},
|
||||
"spec": map[string]any{
|
||||
"sourceRepos": repos,
|
||||
"destinations": []any{map[string]any{"server": inClusterServer, "namespace": "hanzo"}},
|
||||
// A field the projection must NOT surface (roles carry token metadata).
|
||||
"roles": []any{map[string]any{"name": "secret-role", "policies": []any{"p, proj:x:secret-role, *, *, *, allow"}}},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// ── pure health ─────────────────────────────────────────────────────────────
|
||||
|
||||
func TestResourceHealth(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
// detail.go — the three per-app DETAIL projections the ArgoCD SPA's application
|
||||
// view calls (and 404-toasts when absent): sync windows, revision metadata, and
|
||||
// the LIVE resource-tree stream. All THREE are TENANT-SCOPED exactly like
|
||||
// dashApp/dashResourceTree — resolveScope + findNamespace decide visibility, so a
|
||||
// normal org reads only its OWN apps' detail (a cross-tenant name is a clean 404,
|
||||
// no existence oracle), a SuperAdmin reads the whole fleet, and an unvalidated
|
||||
// caller fails closed. There is ONE scoping path (scope.go); nothing here forks it.
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
)
|
||||
|
||||
// ── sync windows ─────────────────────────────────────────────────────────────
|
||||
|
||||
// argoSyncWindows is v1alpha1 ApplicationSyncWindowState (models.ts
|
||||
// ApplicationSyncWindowState). This platform runs no sync windows, so the
|
||||
// projection is the permissive empty — nothing blocks a sync (canSync true, no
|
||||
// active/assigned windows). Nil slices marshal to `null`, the exact shape the SPA
|
||||
// reads.
|
||||
type argoSyncWindows struct {
|
||||
ActiveWindows []any `json:"activeWindows"`
|
||||
AssignedWindows []any `json:"assignedWindows"`
|
||||
CanSync bool `json:"canSync"`
|
||||
}
|
||||
|
||||
// dashSyncWindows is GET /v1/deploy/applications/:name/syncwindows — the permissive
|
||||
// empty ApplicationSyncWindowState, gated to the caller's own app (a cross-tenant
|
||||
// name 404s before the static body is returned, so the endpoint discloses nothing
|
||||
// about another tenant's fleet).
|
||||
func dashSyncWindows(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
sc, ok := resolveScope(c)
|
||||
if !ok {
|
||||
return refuse(c)
|
||||
}
|
||||
if err := ready(s); err != nil {
|
||||
return err
|
||||
}
|
||||
name := reqName(c)
|
||||
if !appNameRE.MatchString(name) {
|
||||
return zip.ErrBadRequest("name must be a DNS-1123 label")
|
||||
}
|
||||
// Existence + ownership check (discard the namespace): 404 a name that is not the
|
||||
// caller's, so a tenant cannot probe whether another org runs an app of a given name.
|
||||
if _, err := sc.findNamespace(s, c, name); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(http.StatusOK, argoSyncWindows{CanSync: true})
|
||||
}
|
||||
|
||||
// ── revision metadata ────────────────────────────────────────────────────────
|
||||
|
||||
// argoRevisionMetadata is v1alpha1 RevisionMetadata (models.ts RevisionMetadata) —
|
||||
// date is required (models.Time); author/tags/message/signatureInfo are optional.
|
||||
type argoRevisionMetadata struct {
|
||||
Author string `json:"author,omitempty"`
|
||||
Date string `json:"date"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
SignatureInfo string `json:"signatureInfo,omitempty"`
|
||||
}
|
||||
|
||||
// maxRevisionLen bounds the reflected revision so an over-long path segment cannot
|
||||
// bloat the response (the value is otherwise inert — JSON-escaped, never a shell/path arg).
|
||||
const maxRevisionLen = 256
|
||||
|
||||
// dashRevisionMetadata is GET /v1/deploy/applications/:name/revisions/:revision/metadata.
|
||||
//
|
||||
// The App CR is IMAGE-based: the deploy is pinned to an image tag, not a git commit, and
|
||||
// the projection's git source (git.hanzo.ai/hanzoai/universe) is the display-only manifest
|
||||
// repo, NOT the app's own source — so there is no in-process commit to resolve a revision
|
||||
// author/date/message from (clients/git exposes CloneURL + VerifyRef only, neither of which
|
||||
// yields commit metadata for an arbitrary revision). Rather than 404 (the toast) or
|
||||
// fabricate a git author, this returns an HONEST minimal RevisionMetadata: message = the
|
||||
// revision (HEAD resolves to the CR's declared image tag), date = when the app was declared
|
||||
// (the CR creation time), author = "" (none). Real git enrichment is a follow-on gated on
|
||||
// the CR carrying a real git source + a clients/git CommitMetadata export.
|
||||
func dashRevisionMetadata(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
sc, ok := resolveScope(c)
|
||||
if !ok {
|
||||
return refuse(c)
|
||||
}
|
||||
if err := ready(s); err != nil {
|
||||
return err
|
||||
}
|
||||
name := reqName(c)
|
||||
if !appNameRE.MatchString(name) {
|
||||
return zip.ErrBadRequest("name must be a DNS-1123 label")
|
||||
}
|
||||
ns, err := sc.findNamespace(s, c, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cr, _, err := getAppCR(s, c.Context(), ns, name)
|
||||
if err != nil {
|
||||
return k8sErr(s, "get", err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, revisionMetadataOf(cr, c.Param("revision")))
|
||||
}
|
||||
|
||||
// revisionMetadataOf builds the honest minimal RevisionMetadata for an image-based App CR.
|
||||
func revisionMetadataOf(cr *unstructured.Unstructured, revision string) argoRevisionMetadata {
|
||||
if len(revision) > maxRevisionLen {
|
||||
revision = revision[:maxRevisionLen]
|
||||
}
|
||||
message := revision
|
||||
// HEAD (the SPA's default when no revision is pinned) resolves to the CR's declared tag.
|
||||
if message == "" || message == "HEAD" {
|
||||
if tag, _, _ := unstructured.NestedString(cr.Object, "spec", "image", "tag"); tag != "" {
|
||||
message = tag
|
||||
}
|
||||
}
|
||||
return argoRevisionMetadata{
|
||||
Date: cr.GetCreationTimestamp().UTC().Format(time.RFC3339),
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
|
||||
// ── live resource-tree stream ────────────────────────────────────────────────
|
||||
|
||||
// treeResult is the `{"result": …}` envelope the SPA unwraps for the resource-tree
|
||||
// stream (watchResourceTree: JSON.parse(data).result) — the SAME envelope shape the
|
||||
// applications stream uses.
|
||||
type treeResult struct {
|
||||
Result argoTree `json:"result"`
|
||||
}
|
||||
|
||||
// dashStreamResourceTree is GET /v1/deploy/stream/applications/:name/resource-tree — the
|
||||
// LIVE ApplicationTree as SSE. TENANT-SCOPED: resolveScope + findNamespace run BEFORE any
|
||||
// emission (a cross-tenant name 404s with no SSE opened, an unvalidated caller 403s), then
|
||||
// the tree is emitted once and refreshed on the keep-alive interval, honoring ctx cancel.
|
||||
func dashStreamResourceTree(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
// Scope gate FIRST — nothing is emitted (SendStreamWriter is never reached) unless the
|
||||
// caller is authorized for this specific app.
|
||||
sc, ok := resolveScope(c)
|
||||
if !ok {
|
||||
return refuse(c)
|
||||
}
|
||||
if err := ready(s); err != nil {
|
||||
return err
|
||||
}
|
||||
name := reqName(c)
|
||||
if !appNameRE.MatchString(name) {
|
||||
return zip.ErrBadRequest("name must be a DNS-1123 label")
|
||||
}
|
||||
ns, err := sc.findNamespace(s, c, name)
|
||||
if err != nil {
|
||||
return err // cross-tenant / unknown name → 404 BEFORE any stream is opened
|
||||
}
|
||||
// Capture the context BEFORE SendStreamWriter (its callback runs after this handler
|
||||
// returns and must not touch c). c.Context() does NOT cancel on client disconnect; a
|
||||
// failing flush inside the loop is the disconnect signal — mirrors dashStreamApps.
|
||||
ctx := c.Context()
|
||||
setStreamHeaders(c)
|
||||
return c.SendStreamWriter(func(w *bufio.Writer) {
|
||||
streamResourceTree(s, ctx, ns, name, w)
|
||||
})
|
||||
}
|
||||
|
||||
// streamResourceTree emits the app's ApplicationTree once, then re-emits it on the keep-alive
|
||||
// interval (a cheap poll that doubles as the keep-alive — no multi-resource watch to leak),
|
||||
// until the client disconnects (a failing write) or the context is canceled. Separated from
|
||||
// the handler so it is unit-testable over a bytes.Buffer + a cancelable context.
|
||||
func streamResourceTree(s *cloud.Service[state], ctx context.Context, ns, name string, w *bufio.Writer) {
|
||||
if !emitResourceTree(s, ctx, ns, name, w) {
|
||||
return // client gone during the initial emit
|
||||
}
|
||||
ticker := time.NewTicker(streamKeepalive)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if !emitResourceTree(s, ctx, ns, name, w) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// emitResourceTree writes one ApplicationTree SSE frame (rebuilt from the live CR). If the
|
||||
// CR read fails (app deleted / transient), it holds the connection open with a keep-alive
|
||||
// comment rather than killing the stream. Returns false only when a write fails (client gone).
|
||||
func emitResourceTree(s *cloud.Service[state], ctx context.Context, ns, name string, w *bufio.Writer) bool {
|
||||
cr, _, err := getAppCR(s, ctx, ns, name)
|
||||
if err != nil {
|
||||
return writeKeepalive(w)
|
||||
}
|
||||
return writeTreeEvent(w, projectTree(buildTree(s, ctx, ns, name, cr)))
|
||||
}
|
||||
|
||||
// writeTreeEvent writes one SSE frame — `data: {"result": <ApplicationTree>}` — and flushes.
|
||||
// Returns false when the write/flush fails (client disconnected).
|
||||
func writeTreeEvent(w *bufio.Writer, tree argoTree) bool {
|
||||
payload, err := json.Marshal(treeResult{Result: tree})
|
||||
if err != nil {
|
||||
return true // unreachable for this shape; skip the frame rather than kill the stream
|
||||
}
|
||||
if _, err := w.WriteString("data: "); err != nil {
|
||||
return false
|
||||
}
|
||||
if _, err := w.Write(payload); err != nil {
|
||||
return false
|
||||
}
|
||||
if _, err := w.WriteString("\n\n"); err != nil {
|
||||
return false
|
||||
}
|
||||
return w.Flush() == nil
|
||||
}
|
||||
|
||||
// writeKeepalive writes one SSE keep-alive comment and flushes; false when the client is gone.
|
||||
func writeKeepalive(w *bufio.Writer) bool {
|
||||
if _, err := w.WriteString(": keep-alive\n\n"); err != nil {
|
||||
return false
|
||||
}
|
||||
return w.Flush() == nil
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── sync windows ─────────────────────────────────────────────────────────────
|
||||
|
||||
// TestDashSyncWindows_TenantScoped: a caller reads the permissive-empty sync-window
|
||||
// state for its OWN app; a cross-tenant name 404s (no oracle); a SuperAdmin reads the
|
||||
// fleet; an unvalidated caller 403s; the caller's own app NEVER 404s.
|
||||
func TestDashSyncWindows_TenantScoped(t *testing.T) {
|
||||
s := twoTenantFleet()
|
||||
|
||||
// own app → 200 with the exact permissive-empty shape.
|
||||
body := jsonBody(t, getAs(t, s, "/v1/deploy/applications/acme-web/syncwindows", orgHeaders("acme")))
|
||||
if body["canSync"] != true {
|
||||
t.Fatalf("canSync = %v, want true", body["canSync"])
|
||||
}
|
||||
if v, present := body["activeWindows"]; !present || v != nil {
|
||||
t.Fatalf("activeWindows = %v (present=%v), want null", v, present)
|
||||
}
|
||||
if v, present := body["assignedWindows"]; !present || v != nil {
|
||||
t.Fatalf("assignedWindows = %v (present=%v), want null", v, present)
|
||||
}
|
||||
|
||||
// cross-tenant name → 404 (bravo cannot read acme's app's sync windows).
|
||||
if r := getAs(t, s, "/v1/deploy/applications/acme-web/syncwindows", orgHeaders("bravo")); r.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("bravo→acme syncwindows = %d, want 404", r.StatusCode)
|
||||
_ = r.Body.Close()
|
||||
}
|
||||
|
||||
// SuperAdmin reads a fleet app.
|
||||
if r := getAs(t, s, "/v1/deploy/applications/cloud/syncwindows", map[string]string{"X-User-IsAdmin": "true"}); r.StatusCode != http.StatusOK {
|
||||
t.Fatalf("admin cloud syncwindows = %d, want 200", r.StatusCode)
|
||||
_ = r.Body.Close()
|
||||
}
|
||||
|
||||
// unvalidated / forged org → 403 (fail closed), and empty org too.
|
||||
for _, h := range []map[string]string{{"X-Org-Id": "acme"}, {"X-User-Id": "u"}} {
|
||||
if r := getAs(t, s, "/v1/deploy/applications/acme-web/syncwindows", h); r.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("unvalidated syncwindows (%v) = %d, want 403", h, r.StatusCode)
|
||||
_ = r.Body.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── revision metadata ────────────────────────────────────────────────────────
|
||||
|
||||
// TestDashRevisionMetadata_TenantScoped: a caller reads honest minimal metadata for its
|
||||
// OWN app's revision (date always populated, message = the revision; HEAD → the declared
|
||||
// tag); cross-tenant 404s; SuperAdmin reads the fleet; unvalidated 403s; NEVER 404 for the
|
||||
// caller's own app.
|
||||
func TestDashRevisionMetadata_TenantScoped(t *testing.T) {
|
||||
s := twoTenantFleet()
|
||||
|
||||
// own app, explicit revision → message echoes the revision, date is populated.
|
||||
body := jsonBody(t, getAs(t, s, "/v1/deploy/applications/acme-web/revisions/sha-abc123/metadata", orgHeaders("acme")))
|
||||
if body["date"] == nil || body["date"] == "" {
|
||||
t.Fatalf("date = %v, want a non-empty models.Time", body["date"])
|
||||
}
|
||||
if body["message"] != "sha-abc123" {
|
||||
t.Fatalf("message = %v, want the revision sha-abc123", body["message"])
|
||||
}
|
||||
|
||||
// HEAD → message resolves to the CR's declared image tag ("v1" from the fixture).
|
||||
head := jsonBody(t, getAs(t, s, "/v1/deploy/applications/acme-web/revisions/HEAD/metadata", orgHeaders("acme")))
|
||||
if head["message"] != "v1" {
|
||||
t.Fatalf("HEAD message = %v, want the declared tag v1", head["message"])
|
||||
}
|
||||
|
||||
// cross-tenant → 404.
|
||||
if r := getAs(t, s, "/v1/deploy/applications/acme-web/revisions/HEAD/metadata", orgHeaders("bravo")); r.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("bravo→acme revision metadata = %d, want 404", r.StatusCode)
|
||||
_ = r.Body.Close()
|
||||
}
|
||||
|
||||
// SuperAdmin reads a fleet app; unvalidated fails closed.
|
||||
if r := getAs(t, s, "/v1/deploy/applications/cloud/revisions/HEAD/metadata", map[string]string{"X-User-IsAdmin": "true"}); r.StatusCode != http.StatusOK {
|
||||
t.Fatalf("admin cloud revision metadata = %d, want 200", r.StatusCode)
|
||||
_ = r.Body.Close()
|
||||
}
|
||||
if r := getAs(t, s, "/v1/deploy/applications/acme-web/revisions/HEAD/metadata", map[string]string{"X-Org-Id": "acme"}); r.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("unvalidated revision metadata = %d, want 403", r.StatusCode)
|
||||
_ = r.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// ── live resource-tree stream ────────────────────────────────────────────────
|
||||
|
||||
// TestDashStreamResourceTree_ScopeGateBeforeEmission: the scope gate runs BEFORE any SSE is
|
||||
// opened — an unvalidated caller 403s and a cross-tenant name 404s, both as plain error
|
||||
// responses (SendStreamWriter is never reached, so nothing is emitted).
|
||||
func TestDashStreamResourceTree_ScopeGateBeforeEmission(t *testing.T) {
|
||||
s := twoTenantFleet()
|
||||
|
||||
// forged org (no validated principal) → 403, no stream.
|
||||
if r := getAs(t, s, "/v1/deploy/stream/applications/acme-web/resource-tree", map[string]string{"X-Org-Id": "acme"}); r.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("forged-org tree stream = %d, want 403", r.StatusCode)
|
||||
_ = r.Body.Close()
|
||||
}
|
||||
// cross-tenant name → 404, no stream.
|
||||
if r := getAs(t, s, "/v1/deploy/stream/applications/acme-web/resource-tree", orgHeaders("bravo")); r.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("bravo→acme tree stream = %d, want 404", r.StatusCode)
|
||||
_ = r.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamResourceTree_EmitsTreeOnce: the stream core emits the ApplicationTree as a
|
||||
// `data: {"result": …}` frame (the envelope the SPA unwraps), then returns on ctx cancel.
|
||||
func TestStreamResourceTree_EmitsTreeOnce(t *testing.T) {
|
||||
s := twoTenantFleet()
|
||||
var buf bytes.Buffer
|
||||
w := bufio.NewWriter(&buf)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
streamResourceTree(s, ctx, "tenant-acme", "acme-web", w)
|
||||
close(done)
|
||||
}()
|
||||
time.Sleep(40 * time.Millisecond) // let the initial synchronous emit land
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("streamResourceTree did not return within 2s of context cancel (leak)")
|
||||
}
|
||||
|
||||
frames := parseSSE(buf.String())
|
||||
if len(frames) < 1 {
|
||||
t.Fatalf("no tree frame emitted: %q", buf.String())
|
||||
}
|
||||
var env struct {
|
||||
Result argoTree `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(frames[0]), &env); err != nil {
|
||||
t.Fatalf("frame is not a {result:tree} envelope: %q (%v)", frames[0], err)
|
||||
}
|
||||
// The ApplicationTree shape the SPA renders: nodes/orphanedNodes/hosts marshal as arrays.
|
||||
b, _ := json.Marshal(env.Result)
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(b, &m)
|
||||
if _, ok := m["nodes"].([]any); !ok {
|
||||
t.Fatalf("tree.nodes must be a JSON array, got %T", m["nodes"])
|
||||
}
|
||||
if _, ok := m["orphanedNodes"]; !ok {
|
||||
t.Fatalf("tree.orphanedNodes must be present: %v", m)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamResourceTree_HonorsCancel: the stream returns promptly on context cancel — no
|
||||
// hung goroutine holding the keep-alive loop open.
|
||||
func TestStreamResourceTree_HonorsCancel(t *testing.T) {
|
||||
s := twoTenantFleet()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
streamResourceTree(s, ctx, "tenant-acme", "acme-web", bufio.NewWriter(&bytes.Buffer{}))
|
||||
close(done)
|
||||
}()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("streamResourceTree did not return within 2s of cancel")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRevisionMetadataOf_Honest: the pure builder always populates date, echoes the
|
||||
// revision as message, and resolves HEAD to the declared image tag — never fabricating an
|
||||
// author.
|
||||
func TestRevisionMetadataOf_Honest(t *testing.T) {
|
||||
cr := orgAppCR("tenant-acme", "acme-web", "acme", "storefront")
|
||||
rm := revisionMetadataOf(cr, "sha-deadbeef")
|
||||
if rm.Date == "" {
|
||||
t.Fatal("date must always be populated (models.Time is required)")
|
||||
}
|
||||
if rm.Message != "sha-deadbeef" {
|
||||
t.Fatalf("message = %q, want the revision", rm.Message)
|
||||
}
|
||||
if rm.Author != "" {
|
||||
t.Fatalf("author = %q, want empty (no fabricated git author)", rm.Author)
|
||||
}
|
||||
// HEAD resolves to the declared image tag.
|
||||
if got := revisionMetadataOf(cr, "HEAD"); got.Message != "v1" {
|
||||
t.Fatalf("HEAD message = %q, want the declared tag v1", got.Message)
|
||||
}
|
||||
// over-long revision is bounded.
|
||||
long := make([]byte, maxRevisionLen+50)
|
||||
for i := range long {
|
||||
long[i] = 'a'
|
||||
}
|
||||
if got := revisionMetadataOf(cr, string(long)); len(got.Message) != maxRevisionLen {
|
||||
t.Fatalf("message len = %d, want bounded to %d", len(got.Message), maxRevisionLen)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
// login.go — the sign-in round trip for the deploy plane at cd.hanzo.ai.
|
||||
//
|
||||
// THE PROBLEM. Every /v1/deploy route is SuperAdmin-gated on c.IsAdmin(), which
|
||||
// SanitizeIdentity mints ONLY from a validated IAM principal whose org IS the
|
||||
// reserved admin org. The dashboard SPA is served at cd.hanzo.ai/ and calls this
|
||||
// plane same-origin — but the IAM session cookie is minted host-only on hanzo.id,
|
||||
// so a session established at hanzo.id or admin.hanzo.ai is never presented to
|
||||
// cd.hanzo.ai. With no sign-in of its own the whole surface 403s and there is no
|
||||
// way in. This file IS the way in.
|
||||
//
|
||||
// GET /v1/deploy/login — start: redirect into IAM's authorize endpoint
|
||||
// GET /v1/deploy/callback — finish: exchange the code, mint the session cookie
|
||||
// GET /v1/deploy/logout — clear the session cookie
|
||||
//
|
||||
// WHAT IT MINTS — NOT A SECOND SESSION MECHANISM. The callback stores the IAM
|
||||
// access-token JWT in the `__Host-hanzo_iam_token` cookie: the FIRST name in
|
||||
// cloud's cookieTokenNames, which SanitizeIdentity already reads, independently
|
||||
// verifies (signature/issuer/audience/expiry against the IAM JWKS) and turns into
|
||||
// the same principal a Bearer would. So this adds exactly one thing — a way to PUT
|
||||
// the token in the browser for this host. The gate, the validation, and the
|
||||
// SuperAdmin predicate are untouched; a forged cookie is still just an invalid JWT,
|
||||
// and a forged X-User-IsAdmin header is still stripped on ingress.
|
||||
//
|
||||
// MINT ONLY WHAT THIS DEPLOYMENT WILL ACCEPT. The callback runs the exchanged token
|
||||
// through cloud's OWN validator (cloud.NewTokenValidator — the same JWKS, issuer set
|
||||
// and audience allowlist the boundary uses) BEFORE writing the cookie, and makes the
|
||||
// admin-org decision on those VERIFIED claims. This is not defence in depth against
|
||||
// IAM; it is the thing that makes a misconfiguration fail FAST and LOUD. The audience
|
||||
// allowlist is env-overridable (jwtAudiencesFromEnv REPLACES the baked default), so a
|
||||
// deployment whose CLOUD_JWT_AUDIENCES / GATEWAY_ALLOWED_AUDIENCES omits this
|
||||
// console's client_id would otherwise mint a cookie the boundary refuses on the very
|
||||
// next request — 403 → document-bounce to sign-in → IAM session still live → instant
|
||||
// code → mint → 403, looping until the browser gives up. Validating here turns that
|
||||
// infinite loop into one clear error naming the real reason.
|
||||
//
|
||||
// PUBLIC CLIENT, PKCE. The deploy plane holds no client secret: it drives IAM's
|
||||
// authorization-code flow with PKCE S256 (RFC 7636), which IAM accepts with an
|
||||
// empty client_secret when the code carries a challenge. A secret is still sent
|
||||
// when one is configured, for a deployment that registers a confidential client.
|
||||
//
|
||||
// CSRF. The `state` is a fresh 256-bit nonce echoed into a short-lived, HttpOnly,
|
||||
// Secure, SameSite=Lax cookie alongside the PKCE verifier and the return path. The
|
||||
// callback accepts a code ONLY when the returned state equals the cookie's nonce
|
||||
// (constant time), so a login-CSRF — an attacker completing THEIR authorization in
|
||||
// the victim's browser — is refused. The cookie is the only store, so the flow
|
||||
// survives any replica handling the callback.
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
fiber "github.com/zap-proto/fiber/v3"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
const (
|
||||
// loginPath / callbackPath / logoutPath are the three routes of the round trip.
|
||||
// /v1/deploy/<resource>, like the rest of the plane — never an /api/ prefix.
|
||||
loginPath = dashPrefix + "/login"
|
||||
callbackPath = dashPrefix + "/callback"
|
||||
logoutPath = dashPrefix + "/logout"
|
||||
|
||||
// sessionCookie is cloud's EXISTING session cookie name — cookieTokenNames[0]
|
||||
// in middleware_identity.go. Writing it here is what makes SanitizeIdentity
|
||||
// resolve a principal on the next request. Do not invent a second name.
|
||||
//
|
||||
// The __Host- prefix is a browser-enforced invariant, not decoration: a cookie
|
||||
// so named may only be set Secure, Path=/ and with NO Domain, so a sibling
|
||||
// *.hanzo.ai host cannot set a Domain=.hanzo.ai cookie of the same name to
|
||||
// shadow this console's session.
|
||||
sessionCookie = "__Host-hanzo_iam_token"
|
||||
|
||||
// flowCookie carries the one in-flight OAuth round trip (state nonce, PKCE
|
||||
// verifier, return path). It is deleted the moment the callback reads it, and
|
||||
// carries the same __Host- guarantee — a shadowed flow cookie would be a way
|
||||
// to feed this console someone else's state nonce.
|
||||
flowCookie = "__Host-hanzo_deploy_oauth"
|
||||
|
||||
// flowTTL bounds how long an unfinished sign-in stays resumable.
|
||||
flowTTL = 10 * time.Minute
|
||||
|
||||
// sessionMaxTTL caps the session cookie regardless of what the token claims.
|
||||
// An `exp` far in the future must not become a decade-long cookie; the token is
|
||||
// re-validated on every request either way, so a shorter cookie costs only a
|
||||
// re-sign-in.
|
||||
sessionMaxTTL = 24 * time.Hour
|
||||
|
||||
// defaultClientID is the IAM application whose ORGANIZATION is the admin org,
|
||||
// so a sign-in through it resolves users in `admin` — the only org whose
|
||||
// members are SuperAdmins. hanzo-cloud is deliberately NOT used: it is owned by
|
||||
// admin but its organization is `hanzo`, so it looks admin-org users up in the
|
||||
// wrong org and never finds them.
|
||||
defaultClientID = "admin-console"
|
||||
)
|
||||
|
||||
// oauth is the sign-in configuration: where IAM is, who we are to it, and which
|
||||
// org grants SuperAdmin. Resolved once at build time.
|
||||
type oauth struct {
|
||||
issuer string // IAM origin, e.g. https://hanzo.id
|
||||
clientID string // IAM application client_id (organization == adminOrg)
|
||||
clientSecret string // optional; empty ⟹ public client on PKCE alone
|
||||
adminOrg string // the reserved org whose members are SuperAdmins
|
||||
publicURL string // REQUIRED public origin of this console; "" disables sign-in
|
||||
http *http.Client
|
||||
|
||||
// verify is cloud's own token validator (NewTokenValidator(issuer).Validate).
|
||||
// It is a seam so a test can drive the round trip without a live JWKS; in the
|
||||
// binary there is exactly one implementation, and a nil verify fails closed.
|
||||
verify func(raw string) (cloud.VerifiedIdentity, error)
|
||||
}
|
||||
|
||||
// newOAuth resolves the sign-in configuration from deps + env. The issuer comes
|
||||
// from the SAME value the identity boundary validates tokens against
|
||||
// (deps.IAMIssuer), and the verifier is built from that issuer, so a token this
|
||||
// flow accepts is by construction a token cloud accepts.
|
||||
func newOAuth(deps cloud.Deps) oauth {
|
||||
issuer := strings.TrimRight(firstNonEmpty(deps.IAMIssuer, os.Getenv("IAM_ENDPOINT"), "https://hanzo.id"), "/")
|
||||
return oauth{
|
||||
issuer: issuer,
|
||||
clientID: firstNonEmpty(os.Getenv("DEPLOY_IAM_CLIENT_ID"), defaultClientID),
|
||||
clientSecret: os.Getenv("DEPLOY_IAM_CLIENT_SECRET"),
|
||||
adminOrg: firstNonEmpty(os.Getenv("IAM_ADMIN_ORG"), "admin"),
|
||||
publicURL: strings.TrimRight(firstNonEmpty(os.Getenv("DEPLOY_PUBLIC_URL"), os.Getenv("PUBLIC_ORIGIN")), "/"),
|
||||
http: &http.Client{Timeout: 15 * time.Second},
|
||||
verify: cloud.NewTokenValidator(issuer).Validate,
|
||||
}
|
||||
}
|
||||
|
||||
// oauthBase is the canonical IAM OAuth base: ${issuer}/v1/iam. IAM mounts
|
||||
// authorize/token/userinfo under /v1/iam — never at the root, never under /api/.
|
||||
func (o oauth) oauthBase() string { return o.issuer + "/v1/iam" }
|
||||
|
||||
// redirectURI is the OAuth redirect_uri, built from CONFIGURATION ONLY. It must be
|
||||
// the byte-identical string in login (authorize) and callback (token exchange) —
|
||||
// IAM compares them — and it must match a URI registered on the application.
|
||||
//
|
||||
// It is deliberately NOT derived from the request. Host and X-Forwarded-Proto are
|
||||
// caller-controlled: behind the gateway Host is the internal cluster host (which
|
||||
// IAM's allowlist rejects outright), and off-gateway a caller can set either freely.
|
||||
// Deriving an OAuth redirect from attacker-controlled input is only ever saved by
|
||||
// the registry's exact-match check — a second lock covering for a broken first one.
|
||||
// So the public origin is required, and with none configured sign-in fails CLOSED
|
||||
// with an error naming the knob, rather than guessing an origin from a header.
|
||||
func (o oauth) redirectURI() (string, error) {
|
||||
if o.publicURL == "" {
|
||||
return "", fmt.Errorf("sign-in is not configured: set DEPLOY_PUBLIC_URL (or PUBLIC_ORIGIN) " +
|
||||
"to this console's public origin, e.g. https://cd.hanzo.ai")
|
||||
}
|
||||
return o.publicURL + callbackPath, nil
|
||||
}
|
||||
|
||||
// ── the round trip ───────────────────────────────────────────────────────────
|
||||
|
||||
// flow is the one in-flight sign-in, carried in flowCookie for the duration of the
|
||||
// external hop. Nonce is echoed as `state`; Verifier is the PKCE secret that is
|
||||
// never sent to the browser's address bar; Return is the already-validated
|
||||
// same-host path to land on.
|
||||
type flow struct {
|
||||
Nonce string `json:"n"`
|
||||
Verifier string `json:"v"`
|
||||
Return string `json:"r"`
|
||||
}
|
||||
|
||||
// login starts the round trip: mint a nonce + PKCE verifier, remember them in the
|
||||
// flow cookie, and send the browser to IAM's authorize endpoint.
|
||||
func login(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
redirect, err := s.State.oauth.redirectURI()
|
||||
if err != nil {
|
||||
s.Log.Error("deploy sign-in unavailable", "err", err)
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "%v", err)
|
||||
}
|
||||
nonce, err := randomToken()
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "login: %v", err)
|
||||
}
|
||||
verifier, err := randomToken()
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "login: %v", err)
|
||||
}
|
||||
f := flow{Nonce: nonce, Verifier: verifier, Return: safeReturn(c.Query("returnTo"))}
|
||||
blob, err := json.Marshal(f)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "login: %v", err)
|
||||
}
|
||||
setCookie(c, flowCookie, base64.RawURLEncoding.EncodeToString(blob), int(flowTTL.Seconds()))
|
||||
|
||||
o := s.State.oauth
|
||||
q := url.Values{
|
||||
"client_id": {o.clientID},
|
||||
"redirect_uri": {redirect},
|
||||
"response_type": {"code"},
|
||||
"scope": {"openid profile email"},
|
||||
"state": {nonce},
|
||||
"code_challenge": {pkceChallenge(verifier)},
|
||||
"code_challenge_method": {"S256"},
|
||||
}
|
||||
return c.Redirect(http.StatusFound, o.oauthBase()+"/oauth/authorize?"+q.Encode())
|
||||
}
|
||||
|
||||
// callback finishes the round trip: verify the state against the flow cookie,
|
||||
// exchange the code (PKCE), REFUSE a principal that is not in the admin org, then
|
||||
// write the session cookie and land on the return path.
|
||||
//
|
||||
// FAIL CLOSED, TWICE. The admin-org check here is not the authorization decision —
|
||||
// SanitizeIdentity re-derives it from the verified JWT on every subsequent request,
|
||||
// and guard() gates on that. It is here so a non-SuperAdmin is told plainly that
|
||||
// they lack the role instead of being handed a session that silently 403s
|
||||
// everything, and so no cookie is ever minted for a principal the plane will refuse.
|
||||
func callback(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
redirect, cfgErr := s.State.oauth.redirectURI()
|
||||
raw := c.Fiber().Cookies(flowCookie)
|
||||
clearCookie(c, flowCookie) // single use: consumed whether or not it validates
|
||||
if cfgErr != nil {
|
||||
s.Log.Error("deploy sign-in unavailable", "err", cfgErr)
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "%v", cfgErr)
|
||||
}
|
||||
f, err := decodeFlow(raw)
|
||||
if err != nil {
|
||||
return zip.ErrBadRequest("no sign-in is in progress; start at " + loginPath)
|
||||
}
|
||||
// CSRF: the code is only accepted for the round trip THIS browser started.
|
||||
if subtle.ConstantTimeCompare([]byte(f.Nonce), []byte(c.Query("state"))) != 1 {
|
||||
return zip.ErrBadRequest("state mismatch; start again at " + loginPath)
|
||||
}
|
||||
if e := c.Query("error"); e != "" {
|
||||
s.Log.Warn("deploy sign-in refused by IAM", "error", e)
|
||||
return zip.ErrUnauthorized("sign-in was not completed")
|
||||
}
|
||||
code := c.Query("code")
|
||||
if code == "" {
|
||||
return zip.ErrBadRequest("missing authorization code")
|
||||
}
|
||||
|
||||
access, err := s.State.oauth.exchange(c.Context(), code, redirect, f.Verifier)
|
||||
if err != nil {
|
||||
s.Log.Error("deploy sign-in code exchange failed", "err", err)
|
||||
return zip.ErrUnauthorized("sign-in could not be completed")
|
||||
}
|
||||
|
||||
// Verify the token the way THIS deployment's identity boundary will, before
|
||||
// handing it to a browser. A token that fails here would be refused on the very
|
||||
// next request, so minting a cookie for it would produce a sign-in loop rather
|
||||
// than a session — fail now, with the real reason.
|
||||
if s.State.oauth.verify == nil {
|
||||
s.Log.Error("deploy sign-in has no token validator configured")
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "sign-in is not configured: no identity validator")
|
||||
}
|
||||
id, err := s.State.oauth.verify(access)
|
||||
if err != nil {
|
||||
s.Log.Error("deploy sign-in token failed validation", "err", err,
|
||||
"issuer", s.State.oauth.issuer, "client", s.State.oauth.clientID)
|
||||
return zip.ErrUnauthorized("the sign-in token was refused by this deployment's identity boundary (" +
|
||||
err.Error() + "); check that " + s.State.oauth.clientID +
|
||||
" is in the JWT audience allowlist (CLOUD_JWT_AUDIENCES / GATEWAY_ALLOWED_AUDIENCES) " +
|
||||
"and that the issuer matches")
|
||||
}
|
||||
// SuperAdmin ⟺ the VERIFIED owner claim IS the reserved admin org. Not the
|
||||
// `isAdmin` bit, which only says "admin of my own org" — conflating the two
|
||||
// would be a privilege escalation.
|
||||
if id.Owner != s.State.oauth.adminOrg {
|
||||
s.Log.Warn("deploy sign-in refused: not a SuperAdmin", "user", id.User, "org", id.Owner)
|
||||
return zip.ErrForbidden("SuperAdmin required: this console is limited to members of the " +
|
||||
s.State.oauth.adminOrg + " organization")
|
||||
}
|
||||
|
||||
maxAge := sessionMaxAge(id.Expiry)
|
||||
if maxAge <= 0 {
|
||||
return zip.ErrUnauthorized("the sign-in token has already expired")
|
||||
}
|
||||
setCookie(c, sessionCookie, access, maxAge)
|
||||
s.Log.Info("deploy sign-in", "user", id.User, "org", id.Owner, "ttl", maxAge)
|
||||
return c.Redirect(http.StatusFound, f.Return)
|
||||
}
|
||||
|
||||
// logout clears the session cookie for this host. IAM's own session is untouched —
|
||||
// this ends the console session only.
|
||||
//
|
||||
// It is a POST because it CHANGES STATE. As a GET it was reachable by a cross-site
|
||||
// top-level navigation (an <img> or a link on any page), which a SameSite=Lax cookie
|
||||
// still rides, so any site could sign a SuperAdmin out at will. A nuisance rather
|
||||
// than a compromise, but a state-changing GET is a bug regardless; POST is not
|
||||
// carried cross-site by a Lax cookie, so the class is closed.
|
||||
func logout(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
clearCookie(c, sessionCookie)
|
||||
return c.JSON(http.StatusOK, map[string]any{"loggedIn": false, "loginUrl": loginPath})
|
||||
}
|
||||
|
||||
// exchange redeems the authorization code at IAM's token endpoint with the PKCE
|
||||
// verifier. The client secret is sent only when one is configured (IAM accepts an
|
||||
// empty secret for a code that carries a challenge).
|
||||
func (o oauth) exchange(ctx context.Context, code, redirect, verifier string) (string, error) {
|
||||
form := url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {code},
|
||||
"redirect_uri": {redirect},
|
||||
"client_id": {o.clientID},
|
||||
"code_verifier": {verifier},
|
||||
}
|
||||
if o.clientSecret != "" {
|
||||
form.Set("client_secret", o.clientSecret)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, o.oauthBase()+"/oauth/token", strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := o.http.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("token endpoint: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// Bound the read: a token response is small, and this body is attacker-
|
||||
// influenced only insofar as IAM is reachable — cap it regardless.
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("token response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("token endpoint status %d", resp.StatusCode)
|
||||
}
|
||||
var out struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return "", fmt.Errorf("token response: %w", err)
|
||||
}
|
||||
if out.Error != "" {
|
||||
return "", fmt.Errorf("token endpoint: %s", out.Error)
|
||||
}
|
||||
if out.AccessToken == "" {
|
||||
return "", fmt.Errorf("token endpoint returned no access_token")
|
||||
}
|
||||
return out.AccessToken, nil
|
||||
}
|
||||
|
||||
// ── pure helpers (unit-tested) ───────────────────────────────────────────────
|
||||
|
||||
// safeReturn constrains the post-sign-in landing spot to a path on THIS host: it
|
||||
// must be absolute-rooted, must not be protocol-relative ("//evil" or "/\evil",
|
||||
// which browsers resolve as another origin), and must carry no scheme or host.
|
||||
// Anything else collapses to "/". This is the open-redirect guard.
|
||||
func safeReturn(raw string) string {
|
||||
if raw == "" || raw[0] != '/' {
|
||||
return "/"
|
||||
}
|
||||
if len(raw) > 1 && (raw[1] == '/' || raw[1] == '\\') {
|
||||
return "/"
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme != "" || u.Host != "" {
|
||||
return "/"
|
||||
}
|
||||
return u.RequestURI()
|
||||
}
|
||||
|
||||
// wantsDocument reports whether a refused request is a BROWSER NAVIGATION, which
|
||||
// should be bounced to the sign-in page rather than handed a 403 the user cannot
|
||||
// act on. Everything else — every XHR, every API client, every request that does
|
||||
// not positively identify as a document GET — keeps the 403, so the API contract
|
||||
// is unchanged.
|
||||
//
|
||||
// It is deliberately conservative and ordered by trustworthiness: Sec-Fetch-Dest /
|
||||
// Sec-Fetch-Mode are set by the browser and cannot be forged from page JS, so when
|
||||
// present they DECIDE. Only when both are absent does it fall back to Accept.
|
||||
// A non-GET is never redirected: bouncing a POST would silently drop a mutation.
|
||||
func wantsDocument(method, dest, mode, accept, requestedWith string) bool {
|
||||
if method != http.MethodGet {
|
||||
return false
|
||||
}
|
||||
if dest != "" {
|
||||
return dest == "document"
|
||||
}
|
||||
if mode != "" {
|
||||
return mode == "navigate"
|
||||
}
|
||||
if strings.EqualFold(requestedWith, "XMLHttpRequest") {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(accept, "application/json") {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(accept, "text/html")
|
||||
}
|
||||
|
||||
// pkceChallenge is the RFC 7636 S256 challenge: base64url(sha256(verifier)),
|
||||
// unpadded — byte-identical to IAM's own pkceChallenge.
|
||||
func pkceChallenge(verifier string) string {
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// randomToken mints a 256-bit URL-safe secret (the state nonce and the PKCE
|
||||
// verifier). A failure of the system CSPRNG fails the sign-in — never a weaker one.
|
||||
func randomToken() (string, error) {
|
||||
var b [32]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", fmt.Errorf("entropy unavailable: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b[:]), nil
|
||||
}
|
||||
|
||||
// decodeFlow parses the flow cookie. A missing, malformed, or field-empty cookie is
|
||||
// an error — the callback then has nothing to compare `state` against and refuses.
|
||||
func decodeFlow(raw string) (flow, error) {
|
||||
var f flow
|
||||
if raw == "" {
|
||||
return f, fmt.Errorf("no flow cookie")
|
||||
}
|
||||
blob, err := base64.RawURLEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return f, fmt.Errorf("flow cookie: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(blob, &f); err != nil {
|
||||
return f, fmt.Errorf("flow cookie: %w", err)
|
||||
}
|
||||
if f.Nonce == "" || f.Verifier == "" {
|
||||
return f, fmt.Errorf("flow cookie is incomplete")
|
||||
}
|
||||
// Re-validate on the way out: the return path is re-checked against the same
|
||||
// open-redirect rule that admitted it, so a tampered cookie cannot bounce the
|
||||
// browser off-host.
|
||||
f.Return = safeReturn(f.Return)
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// sessionMaxAge is the session cookie's lifetime, derived from the token's VERIFIED
|
||||
// expiry so the cookie dies with the credential it carries. It is bounded at both
|
||||
// ends and never guesses:
|
||||
//
|
||||
// - already expired (or no expiry proven) → 0, and the caller refuses the sign-in.
|
||||
// There is no "fall back to 8 hours" — a fallback for an expired token mints a
|
||||
// cookie that cannot work, which is precisely the mint-then-refuse loop this
|
||||
// whole path exists to prevent.
|
||||
// - absurdly far future → clamped to sessionMaxTTL, so a mis-issued exp cannot
|
||||
// become a decade-long cookie.
|
||||
func sessionMaxAge(expiry time.Time) int {
|
||||
if expiry.IsZero() {
|
||||
return 0
|
||||
}
|
||||
remaining := time.Until(expiry)
|
||||
if remaining <= 0 {
|
||||
return 0
|
||||
}
|
||||
if remaining > sessionMaxTTL {
|
||||
remaining = sessionMaxTTL
|
||||
}
|
||||
return int(remaining.Seconds())
|
||||
}
|
||||
|
||||
// ── cookies ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// setCookie writes a host-only, HttpOnly, Secure, SameSite=Lax cookie.
|
||||
//
|
||||
// HttpOnly: page JS never touches the session token. Secure: it never rides plain
|
||||
// HTTP. Lax (not Strict): the sign-in lands here via a top-level redirect FROM
|
||||
// hanzo.id, and Strict would withhold the cookie on that first cross-site
|
||||
// navigation, so the user would arrive still signed out. Lax is the correct
|
||||
// setting for an OAuth round trip and still withholds the cookie from cross-site
|
||||
// subrequests. No Domain attribute: the cookie stays host-only to this console and
|
||||
// is never broadcast to sibling *.hanzo.ai hosts.
|
||||
func setCookie(c *zip.Ctx, name, value string, maxAge int) {
|
||||
c.Fiber().Res().Cookie(&fiber.Cookie{
|
||||
Name: name, Value: value, Path: "/",
|
||||
HTTPOnly: true, Secure: true, SameSite: fiber.CookieSameSiteLaxMode,
|
||||
MaxAge: maxAge,
|
||||
})
|
||||
}
|
||||
|
||||
func clearCookie(c *zip.Ctx, name string) { setCookie(c, name, "", -1) }
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
luxlog "github.com/luxfi/log"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// ── open redirect ────────────────────────────────────────────────────────────
|
||||
|
||||
// TestSafeReturn is the open-redirect guard: a return path may only be a path on
|
||||
// THIS host. Every off-host shape collapses to "/".
|
||||
func TestSafeReturn(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
// legitimate same-host paths survive, query included.
|
||||
{"/", "/"},
|
||||
{"/applications", "/applications"},
|
||||
{"/applications?name=cloud&view=tree", "/applications?name=cloud&view=tree"},
|
||||
// empty / relative → default.
|
||||
{"", "/"},
|
||||
{"applications", "/"},
|
||||
// protocol-relative: the browser resolves these as ANOTHER ORIGIN.
|
||||
{"//evil.example", "/"},
|
||||
{"//evil.example/path", "/"},
|
||||
{`/\evil.example`, "/"},
|
||||
{`/\/evil.example`, "/"},
|
||||
// absolute URLs, any scheme.
|
||||
{"https://evil.example/x", "/"},
|
||||
{"http://evil.example", "/"},
|
||||
{"javascript:alert(1)", "/"},
|
||||
{"data:text/html,x", "/"},
|
||||
// host-relative with userinfo tricks.
|
||||
{"https://cd.hanzo.ai@evil.example", "/"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := safeReturn(c.in); got != c.want {
|
||||
t.Errorf("safeReturn(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── content negotiation: redirect vs 403 ─────────────────────────────────────
|
||||
|
||||
// TestWantsDocument pins the ONE rule that decides whether a refusal is a
|
||||
// sign-in redirect or a 403. It must answer "no" for everything that is not
|
||||
// positively a browser document GET — the API contract depends on it.
|
||||
func TestWantsDocument(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
method, dest, mode, accept, requested string
|
||||
want bool
|
||||
}{
|
||||
// Browser navigation — the only "yes" cases.
|
||||
{"navigation (Sec-Fetch-Dest)", "GET", "document", "navigate", "text/html,*/*", "", true},
|
||||
{"navigation (mode only)", "GET", "", "navigate", "text/html", "", true},
|
||||
{"legacy browser, html accept", "GET", "", "", "text/html,application/xhtml+xml", "", true},
|
||||
|
||||
// Browser subresource/XHR — Sec-Fetch-* decides, and it says no.
|
||||
{"fetch from page JS", "GET", "empty", "cors", "application/json", "", false},
|
||||
{"fetch that lies via Accept", "GET", "empty", "cors", "text/html", "", false},
|
||||
{"same-origin xhr", "GET", "empty", "same-origin", "*/*", "", false},
|
||||
|
||||
// Non-browser API clients.
|
||||
{"curl (no headers)", "GET", "", "", "", "", false},
|
||||
{"json client", "GET", "", "", "application/json", "", false},
|
||||
{"wildcard accept", "GET", "", "", "*/*", "", false},
|
||||
{"legacy xhr header", "GET", "", "", "text/html", "XMLHttpRequest", false},
|
||||
{"html+json accept prefers api", "GET", "", "", "text/html,application/json", "", false},
|
||||
|
||||
// A mutation is NEVER redirected — bouncing a POST silently drops it.
|
||||
{"POST navigation", "POST", "document", "navigate", "text/html", "", false},
|
||||
{"PUT navigation", "PUT", "document", "navigate", "text/html", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := wantsDocument(c.method, c.dest, c.mode, c.accept, c.requested); got != c.want {
|
||||
t.Errorf("wantsDocument(%q,%q,%q,%q,%q) = %v, want %v",
|
||||
c.method, c.dest, c.mode, c.accept, c.requested, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuardRefusesNonAdmin drives the negotiation through the REAL router: a
|
||||
// non-SuperAdmin never reaches a handler — an API call gets 403, a browser
|
||||
// navigation gets bounced to sign-in. Neither is ever served the data.
|
||||
func TestGuardRefusesNonAdmin(t *testing.T) {
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
routes(app, fakeSvc())
|
||||
|
||||
// API call (no Accept, the shape every API client and the existing e2e sends).
|
||||
resp := do(t, app, httptest.NewRequest("GET", "/v1/deploy/applications", nil))
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("API call without admin = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
|
||||
// XHR that asks for HTML must STILL be 403 — Sec-Fetch-Dest decides.
|
||||
req := httptest.NewRequest("GET", "/v1/deploy/applications", nil)
|
||||
req.Header.Set("Accept", "text/html")
|
||||
req.Header.Set("Sec-Fetch-Dest", "empty")
|
||||
if resp := do(t, app, req); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("XHR without admin = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Browser navigation → 302 to the sign-in page, carrying where to come back to.
|
||||
req = httptest.NewRequest("GET", "/v1/deploy/applications?env=main", nil)
|
||||
req.Header.Set("Sec-Fetch-Dest", "document")
|
||||
req.Header.Set("Accept", "text/html")
|
||||
resp = do(t, app, req)
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("navigation without admin = %d, want 302", resp.StatusCode)
|
||||
}
|
||||
loc, err := url.Parse(resp.Header.Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatalf("Location: %v", err)
|
||||
}
|
||||
if loc.Path != loginPath {
|
||||
t.Errorf("redirect path = %q, want %q", loc.Path, loginPath)
|
||||
}
|
||||
if got := loc.Query().Get("returnTo"); got != "/v1/deploy/applications?env=main" {
|
||||
t.Errorf("returnTo = %q, want the originating path+query", got)
|
||||
}
|
||||
|
||||
// A mutation is refused with 403, never redirected.
|
||||
req = httptest.NewRequest("POST", "/v1/deploy/applications/cloud/sync", nil)
|
||||
req.Header.Set("Sec-Fetch-Dest", "document")
|
||||
req.Header.Set("Accept", "text/html")
|
||||
if resp := do(t, app, req); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("POST without admin = %d, want 403 (never a redirect)", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// ── PKCE ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestPKCEChallenge pins the S256 transform against the RFC 7636 Appendix B
|
||||
// vector, so it stays byte-identical to IAM's own pkceChallenge.
|
||||
func TestPKCEChallenge(t *testing.T) {
|
||||
const (
|
||||
verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||
)
|
||||
if got := pkceChallenge(verifier); got != challenge {
|
||||
t.Errorf("pkceChallenge = %q, want the RFC 7636 vector %q", got, challenge)
|
||||
}
|
||||
// The challenge is not the verifier (the whole point of S256).
|
||||
if pkceChallenge("x") == "x" {
|
||||
t.Error("challenge must never equal the verifier")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRandomTokenIsUnique guards against a constant/predictable state nonce.
|
||||
func TestRandomTokenIsUnique(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 100; i++ {
|
||||
tok, err := randomToken()
|
||||
if err != nil {
|
||||
t.Fatalf("randomToken: %v", err)
|
||||
}
|
||||
if len(tok) < 43 {
|
||||
t.Fatalf("randomToken = %q, want >= 256 bits of entropy", tok)
|
||||
}
|
||||
if seen[tok] {
|
||||
t.Fatal("randomToken repeated a value")
|
||||
}
|
||||
seen[tok] = true
|
||||
}
|
||||
}
|
||||
|
||||
// ── login: the authorize hop ─────────────────────────────────────────────────
|
||||
|
||||
// TestLoginRedirectsToIAM asserts the authorize URL is well formed, carries PKCE,
|
||||
// and that the state it publishes is the SAME nonce it stored in the flow cookie.
|
||||
func TestLoginRedirectsToIAM(t *testing.T) {
|
||||
app, _ := signinApp(t, "https://iam.test")
|
||||
|
||||
req := httptest.NewRequest("GET", "/v1/deploy/login?returnTo=/applications", nil)
|
||||
req.Host = "cd.hanzo.ai"
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
resp := do(t, app, req)
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("login = %d, want 302", resp.StatusCode)
|
||||
}
|
||||
|
||||
loc, err := url.Parse(resp.Header.Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatalf("Location: %v", err)
|
||||
}
|
||||
if want := "https://iam.test/v1/iam/oauth/authorize"; loc.Scheme+"://"+loc.Host+loc.Path != want {
|
||||
t.Errorf("authorize endpoint = %q, want %q", loc.Scheme+"://"+loc.Host+loc.Path, want)
|
||||
}
|
||||
q := loc.Query()
|
||||
if q.Get("client_id") != defaultClientID {
|
||||
t.Errorf("client_id = %q, want %q (the app whose organization is the admin org)", q.Get("client_id"), defaultClientID)
|
||||
}
|
||||
if q.Get("response_type") != "code" {
|
||||
t.Errorf("response_type = %q, want code", q.Get("response_type"))
|
||||
}
|
||||
if q.Get("redirect_uri") != "https://cd.hanzo.ai/v1/deploy/callback" {
|
||||
t.Errorf("redirect_uri = %q", q.Get("redirect_uri"))
|
||||
}
|
||||
if q.Get("code_challenge_method") != "S256" {
|
||||
t.Errorf("code_challenge_method = %q, want S256", q.Get("code_challenge_method"))
|
||||
}
|
||||
if q.Get("code_challenge") == "" {
|
||||
t.Error("no code_challenge: the flow is not PKCE-protected")
|
||||
}
|
||||
|
||||
// The flow cookie must exist, be HttpOnly, and its nonce must be the state.
|
||||
f, cookie := flowFrom(t, resp)
|
||||
if !cookie.HttpOnly || !cookie.Secure {
|
||||
t.Errorf("flow cookie HttpOnly=%v Secure=%v, want both true", cookie.HttpOnly, cookie.Secure)
|
||||
}
|
||||
if f.Nonce != q.Get("state") {
|
||||
t.Errorf("state %q != flow cookie nonce %q", q.Get("state"), f.Nonce)
|
||||
}
|
||||
if pkceChallenge(f.Verifier) != q.Get("code_challenge") {
|
||||
t.Error("published code_challenge does not derive from the stored verifier")
|
||||
}
|
||||
if f.Return != "/applications" {
|
||||
t.Errorf("stored return = %q, want /applications", f.Return)
|
||||
}
|
||||
// The verifier must never be published to the address bar.
|
||||
if strings.Contains(resp.Header.Get("Location"), f.Verifier) {
|
||||
t.Error("PKCE verifier leaked into the authorize URL")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginRejectsOffHostReturnTo: an attacker-supplied returnTo cannot make the
|
||||
// completed sign-in land off-host.
|
||||
func TestLoginRejectsOffHostReturnTo(t *testing.T) {
|
||||
app, _ := signinApp(t, "https://iam.test")
|
||||
req := httptest.NewRequest("GET", "/v1/deploy/login?returnTo=https://evil.example/steal", nil)
|
||||
req.Host = "cd.hanzo.ai"
|
||||
resp := do(t, app, req)
|
||||
f, _ := flowFrom(t, resp)
|
||||
if f.Return != "/" {
|
||||
t.Errorf("stored return = %q, want / (off-host returnTo must be dropped)", f.Return)
|
||||
}
|
||||
}
|
||||
|
||||
// ── callback: the exchange ───────────────────────────────────────────────────
|
||||
|
||||
// TestCallbackRequiresMatchingState is the login-CSRF guard: without the flow
|
||||
// cookie this browser started, no code is redeemed and no session is minted.
|
||||
func TestCallbackRequiresMatchingState(t *testing.T) {
|
||||
app, iam := signinApp(t, "")
|
||||
|
||||
// No flow cookie at all.
|
||||
resp := do(t, app, httptest.NewRequest("GET", "/v1/deploy/callback?code=c&state=s", nil))
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("callback with no flow cookie = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
assertNoSession(t, resp)
|
||||
|
||||
// Flow cookie present, but the returned state is someone else's.
|
||||
req := httptest.NewRequest("GET", "/v1/deploy/callback?code=c&state=attacker-state", nil)
|
||||
req.AddCookie(&http.Cookie{Name: flowCookie, Value: encodeFlow(flow{Nonce: "real-nonce", Verifier: "v", Return: "/"})})
|
||||
resp = do(t, app, req)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("callback with mismatched state = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
assertNoSession(t, resp)
|
||||
|
||||
// A garbage cookie is not a flow.
|
||||
req = httptest.NewRequest("GET", "/v1/deploy/callback?code=c&state=x", nil)
|
||||
req.AddCookie(&http.Cookie{Name: flowCookie, Value: "!!!not-base64!!!"})
|
||||
if resp := do(t, app, req); resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("callback with corrupt flow cookie = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
|
||||
if iam.calls != 0 {
|
||||
t.Errorf("IAM token endpoint was called %d times for refused callbacks, want 0", iam.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCallbackRefusesNonAdminOrg: a VALID sign-in by a user outside the admin org
|
||||
// mints NO session. The console refuses the role plainly rather than handing out a
|
||||
// cookie that 403s everything.
|
||||
func TestCallbackRefusesNonAdminOrg(t *testing.T) {
|
||||
app, iam := signinApp(t, "")
|
||||
iam.token = fakeJWT("hanzo", "someone", time.Hour) // a real user, wrong org
|
||||
iam.verified = cloud.VerifiedIdentity{Owner: "hanzo", User: "someone", Expiry: time.Now().Add(time.Hour)}
|
||||
|
||||
resp := completeSignin(t, app)
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("callback for a non-admin-org principal = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
assertNoSession(t, resp)
|
||||
}
|
||||
|
||||
// TestCallbackRefusesUnverifiableToken is the MED-1 loop-breaker: a token this
|
||||
// deployment's identity boundary will NOT accept (audience allowlist that omits the
|
||||
// console's client_id, wrong issuer, expired) must fail HERE, once, with the real
|
||||
// reason — never become a cookie that is refused on the next request, bounced back
|
||||
// to sign-in, and re-minted forever.
|
||||
func TestCallbackRefusesUnverifiableToken(t *testing.T) {
|
||||
app, iam := signinApp(t, "")
|
||||
// The exact shape of the misconfiguration: aud=admin-console, allowlist without it.
|
||||
iam.verifyErr = errors.New(`claims: square/go-jose/jwt: validation failed, invalid audience claim (aud)`)
|
||||
|
||||
resp := completeSignin(t, app)
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("callback with an unverifiable token = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
assertNoSession(t, resp)
|
||||
|
||||
// The error must NAME the knob, or an operator has nothing to act on.
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
for _, want := range []string{"audience", defaultClientID} {
|
||||
if !strings.Contains(string(body), want) {
|
||||
t.Errorf("error body does not mention %q: %s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCallbackRefusesAlreadyExpiredToken: an expired credential must not become an
|
||||
// 8-hour cookie. There is no fallback lifetime — a cookie that cannot work is the
|
||||
// loop, not a mitigation.
|
||||
func TestCallbackRefusesAlreadyExpiredToken(t *testing.T) {
|
||||
app, iam := signinApp(t, "")
|
||||
iam.verified = cloud.VerifiedIdentity{Owner: "admin", User: "cto", Expiry: time.Now().Add(-time.Minute)}
|
||||
|
||||
resp := completeSignin(t, app)
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("callback with an expired token = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
assertNoSession(t, resp)
|
||||
}
|
||||
|
||||
// TestSessionMaxAge pins the clamp at both ends (RED LOW-2).
|
||||
func TestSessionMaxAge(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
expiry time.Time
|
||||
want func(int) bool
|
||||
desc string
|
||||
}{
|
||||
{"no expiry proven", time.Time{}, func(n int) bool { return n == 0 }, "0"},
|
||||
{"already expired", time.Now().Add(-time.Hour), func(n int) bool { return n == 0 }, "0"},
|
||||
{"expired by a second", time.Now().Add(-time.Second), func(n int) bool { return n == 0 }, "0"},
|
||||
{"normal hour", time.Now().Add(time.Hour), func(n int) bool { return n > 3500 && n <= 3600 }, "~3600"},
|
||||
{"absurd future", time.Now().Add(292 * 365 * 24 * time.Hour), func(n int) bool { return n == int(sessionMaxTTL.Seconds()) }, "clamped to sessionMaxTTL"},
|
||||
{"just over the cap", time.Now().Add(sessionMaxTTL + time.Hour), func(n int) bool { return n == int(sessionMaxTTL.Seconds()) }, "clamped to sessionMaxTTL"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := sessionMaxAge(c.expiry); !c.want(got) {
|
||||
t.Errorf("sessionMaxAge = %d, want %s", got, c.desc)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignInFailsClosedWithoutPublicOrigin (RED MED-3): with no configured public
|
||||
// origin the OAuth hop refuses rather than deriving a redirect_uri from the
|
||||
// caller-controlled Host / X-Forwarded-Proto headers.
|
||||
func TestSignInFailsClosedWithoutPublicOrigin(t *testing.T) {
|
||||
svc := fakeSvc()
|
||||
svc.State.oauth = oauth{
|
||||
issuer: "https://iam.test", clientID: defaultClientID, adminOrg: "admin",
|
||||
http: &http.Client{Timeout: time.Second},
|
||||
verify: func(string) (cloud.VerifiedIdentity, error) { return cloud.VerifiedIdentity{}, nil },
|
||||
} // publicURL deliberately empty
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
routes(app, svc)
|
||||
|
||||
// A forged Host must NOT be adopted as the origin.
|
||||
req := httptest.NewRequest("GET", "/v1/deploy/login", nil)
|
||||
req.Host = "evil.example"
|
||||
req.Header.Set("X-Forwarded-Proto", "http")
|
||||
resp := do(t, app, req)
|
||||
if resp.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Errorf("login with no configured origin = %d, want 503", resp.StatusCode)
|
||||
}
|
||||
if loc := resp.Header.Get("Location"); strings.Contains(loc, "evil.example") {
|
||||
t.Errorf("redirect adopted the forged Host: %q", loc)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if !strings.Contains(string(body), "DEPLOY_PUBLIC_URL") {
|
||||
t.Errorf("error must name the knob to set, got: %s", body)
|
||||
}
|
||||
|
||||
// The callback refuses for the same reason, and mints nothing.
|
||||
cb := httptest.NewRequest("GET", "/v1/deploy/callback?code=c&state=s", nil)
|
||||
cb.Host = "evil.example"
|
||||
resp = do(t, app, cb)
|
||||
if resp.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Errorf("callback with no configured origin = %d, want 503", resp.StatusCode)
|
||||
}
|
||||
assertNoSession(t, resp)
|
||||
}
|
||||
|
||||
// TestLogoutClearsSession: POST clears the cookie; the route is not a GET.
|
||||
func TestLogoutClearsSession(t *testing.T) {
|
||||
app, _ := signinApp(t, "https://iam.test")
|
||||
|
||||
resp := do(t, app, httptest.NewRequest("POST", "/v1/deploy/logout", nil))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("logout = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var cleared bool
|
||||
for _, ck := range resp.Cookies() {
|
||||
if ck.Name == sessionCookie && ck.Value == "" && ck.MaxAge < 0 {
|
||||
cleared = true
|
||||
}
|
||||
}
|
||||
if !cleared {
|
||||
t.Error("logout did not clear the session cookie")
|
||||
}
|
||||
|
||||
// A cross-site top-level navigation must not be able to sign anyone out.
|
||||
if resp := do(t, app, httptest.NewRequest("GET", "/v1/deploy/logout", nil)); resp.StatusCode == http.StatusOK {
|
||||
t.Error("GET /v1/deploy/logout succeeded; logout must be POST-only")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCookiesCarryHostPrefix (RED LOW-3): both cookies use the __Host- prefix, which
|
||||
// the browser will only honour for Secure, Path=/, Domain-less cookies — so a
|
||||
// sibling *.hanzo.ai host cannot shadow them with a Domain=.hanzo.ai cookie.
|
||||
func TestCookiesCarryHostPrefix(t *testing.T) {
|
||||
if !strings.HasPrefix(sessionCookie, "__Host-") {
|
||||
t.Errorf("session cookie %q lacks the __Host- prefix", sessionCookie)
|
||||
}
|
||||
if !strings.HasPrefix(flowCookie, "__Host-") {
|
||||
t.Errorf("flow cookie %q lacks the __Host- prefix", flowCookie)
|
||||
}
|
||||
// The session name must be one cloud's identity boundary actually reads.
|
||||
if sessionCookie != "__Host-hanzo_iam_token" {
|
||||
t.Errorf("session cookie %q is not in cloud's cookieTokenNames", sessionCookie)
|
||||
}
|
||||
|
||||
app, _ := signinApp(t, "")
|
||||
resp := completeSignin(t, app)
|
||||
for _, ck := range resp.Cookies() {
|
||||
if !strings.HasPrefix(ck.Name, "__Host-") {
|
||||
continue
|
||||
}
|
||||
// The __Host- contract, enforced here so a future edit cannot silently
|
||||
// break it (a browser would then reject the cookie outright).
|
||||
if !ck.Secure || ck.Path != "/" || ck.Domain != "" {
|
||||
t.Errorf("%s violates the __Host- contract: Secure=%v Path=%q Domain=%q",
|
||||
ck.Name, ck.Secure, ck.Path, ck.Domain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCallbackMintsSessionForSuperAdmin is the happy path: the session cookie is
|
||||
// the token, under cloud's EXISTING cookie name, hardened, and the browser lands
|
||||
// on the remembered path.
|
||||
func TestCallbackMintsSessionForSuperAdmin(t *testing.T) {
|
||||
app, iam := signinApp(t, "")
|
||||
iam.token = fakeJWT("admin", "cto", 2*time.Hour)
|
||||
|
||||
resp := completeSignin(t, app)
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("callback = %d, want 302", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Location"); got != "/applications" {
|
||||
t.Errorf("landed on %q, want the remembered /applications", got)
|
||||
}
|
||||
|
||||
var sess *http.Cookie
|
||||
for _, ck := range resp.Cookies() {
|
||||
if ck.Name == sessionCookie {
|
||||
sess = ck
|
||||
}
|
||||
if ck.Name == flowCookie && ck.MaxAge >= 0 {
|
||||
t.Error("flow cookie must be cleared once consumed")
|
||||
}
|
||||
}
|
||||
if sess == nil {
|
||||
t.Fatalf("no %s cookie minted", sessionCookie)
|
||||
}
|
||||
if sess.Value != iam.token {
|
||||
t.Error("session cookie must carry the IAM access token verbatim (cloud re-validates it)")
|
||||
}
|
||||
if !sess.HttpOnly {
|
||||
t.Error("session cookie must be HttpOnly (page JS must never read the token)")
|
||||
}
|
||||
if !sess.Secure {
|
||||
t.Error("session cookie must be Secure")
|
||||
}
|
||||
if sess.SameSite != http.SameSiteLaxMode {
|
||||
t.Errorf("session cookie SameSite = %v, want Lax (Strict breaks the OAuth return hop)", sess.SameSite)
|
||||
}
|
||||
if sess.Domain != "" {
|
||||
t.Errorf("session cookie Domain = %q, want host-only (never shared with sibling hosts)", sess.Domain)
|
||||
}
|
||||
if sess.MaxAge <= 0 || sess.MaxAge > int((2*time.Hour).Seconds()) {
|
||||
t.Errorf("session MaxAge = %d, want bounded by the token's own expiry", sess.MaxAge)
|
||||
}
|
||||
|
||||
// The exchange used PKCE and the code, with no secret configured.
|
||||
if iam.form.Get("code_verifier") == "" {
|
||||
t.Error("token exchange sent no code_verifier")
|
||||
}
|
||||
if iam.form.Get("grant_type") != "authorization_code" {
|
||||
t.Errorf("grant_type = %q", iam.form.Get("grant_type"))
|
||||
}
|
||||
if iam.form.Get("redirect_uri") != "https://cd.hanzo.ai/v1/deploy/callback" {
|
||||
t.Errorf("exchange redirect_uri = %q, must match the authorize one byte for byte", iam.form.Get("redirect_uri"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCallbackFailsClosedOnExchangeError: IAM refusing the code mints no session.
|
||||
func TestCallbackFailsClosedOnExchangeError(t *testing.T) {
|
||||
app, iam := signinApp(t, "")
|
||||
iam.status = http.StatusBadRequest
|
||||
|
||||
resp := completeSignin(t, app)
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("callback with a rejected code = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
assertNoSession(t, resp)
|
||||
}
|
||||
|
||||
// ── flow cookie ──────────────────────────────────────────────────────────────
|
||||
|
||||
func TestDecodeFlow(t *testing.T) {
|
||||
// Round trip.
|
||||
f, err := decodeFlow(encodeFlow(flow{Nonce: "n", Verifier: "v", Return: "/x"}))
|
||||
if err != nil || f.Nonce != "n" || f.Verifier != "v" || f.Return != "/x" {
|
||||
t.Fatalf("round trip = (%+v, %v)", f, err)
|
||||
}
|
||||
// A tampered return path is re-checked, not trusted.
|
||||
f, err = decodeFlow(encodeFlow(flow{Nonce: "n", Verifier: "v", Return: "https://evil.example"}))
|
||||
if err != nil {
|
||||
t.Fatalf("decodeFlow: %v", err)
|
||||
}
|
||||
if f.Return != "/" {
|
||||
t.Errorf("tampered return = %q, want / (re-validated on read)", f.Return)
|
||||
}
|
||||
// Incomplete / malformed cookies are not flows.
|
||||
for _, bad := range []string{"", "%%%", encodeFlow(flow{Nonce: "n"}), encodeFlow(flow{Verifier: "v"})} {
|
||||
if _, err := decodeFlow(bad); err == nil {
|
||||
t.Errorf("decodeFlow(%q) = nil error, want rejection", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifiedClaimsDecide: the admin-org decision is made on what the VALIDATOR
|
||||
// proved, never on what the token says about itself. A token whose unverified body
|
||||
// claims owner=admin is refused when validation reports a different owner — the
|
||||
// unverified decode is gone, and this pins that it stays gone.
|
||||
func TestVerifiedClaimsDecide(t *testing.T) {
|
||||
app, iam := signinApp(t, "")
|
||||
iam.token = fakeJWT("admin", "cto", time.Hour) // body SAYS admin
|
||||
iam.verified = cloud.VerifiedIdentity{Owner: "hanzo", User: "someone", Expiry: time.Now().Add(time.Hour)}
|
||||
|
||||
resp := completeSignin(t, app)
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("callback = %d, want 403: the verified owner must win over the token body", resp.StatusCode)
|
||||
}
|
||||
assertNoSession(t, resp)
|
||||
}
|
||||
|
||||
// ── harness ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// fakeIAM is a stand-in for IAM's token endpoint, recording what the exchange sent.
|
||||
// verified is what cloud's validator will PROVE about the token it hands back;
|
||||
// verifyErr makes validation fail, which is how a real deployment behaves when its
|
||||
// audience allowlist or issuer does not admit the token.
|
||||
type fakeIAM struct {
|
||||
token string
|
||||
status int
|
||||
calls int
|
||||
form url.Values
|
||||
verified cloud.VerifiedIdentity
|
||||
verifyErr error
|
||||
}
|
||||
|
||||
// signinApp builds the real router over a state whose IAM is a local fake (or the
|
||||
// literal issuer when one is given, for the no-network authorize assertions).
|
||||
//
|
||||
// The token verifier is the ONE seam a test replaces: cloud's real validator needs
|
||||
// a live JWKS and an RS256-signed token, which would test go-jose rather than this
|
||||
// flow. The contract it stands in for — verified claims decide, a validation
|
||||
// failure refuses — is exercised in both directions below.
|
||||
func signinApp(t *testing.T, issuer string) (*zip.App, *fakeIAM) {
|
||||
t.Helper()
|
||||
iam := &fakeIAM{
|
||||
token: fakeJWT("admin", "cto", time.Hour),
|
||||
status: http.StatusOK,
|
||||
verified: cloud.VerifiedIdentity{Owner: "admin", User: "cto", Expiry: time.Now().Add(time.Hour)},
|
||||
}
|
||||
if issuer == "" {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/iam/oauth/token" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
iam.calls++
|
||||
_ = r.ParseForm()
|
||||
iam.form = r.PostForm
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(iam.status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": iam.token})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
issuer = srv.URL
|
||||
}
|
||||
svc := fakeSvc()
|
||||
svc.State.oauth = oauth{
|
||||
issuer: issuer, clientID: defaultClientID, adminOrg: "admin",
|
||||
publicURL: "https://cd.hanzo.ai", http: &http.Client{Timeout: 5 * time.Second},
|
||||
verify: func(raw string) (cloud.VerifiedIdentity, error) {
|
||||
if iam.verifyErr != nil {
|
||||
return cloud.VerifiedIdentity{}, iam.verifyErr
|
||||
}
|
||||
return iam.verified, nil
|
||||
},
|
||||
}
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
routes(app, svc)
|
||||
return app, iam
|
||||
}
|
||||
|
||||
// completeSignin drives login → callback with the flow cookie the login handed
|
||||
// back, i.e. exactly what a browser does.
|
||||
func completeSignin(t *testing.T, app *zip.App) *http.Response {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("GET", "/v1/deploy/login?returnTo=/applications", nil)
|
||||
req.Host = "cd.hanzo.ai"
|
||||
start := do(t, app, req)
|
||||
_, cookie := flowFrom(t, start)
|
||||
|
||||
loc, _ := url.Parse(start.Header.Get("Location"))
|
||||
cb := httptest.NewRequest("GET", "/v1/deploy/callback?code=the-code&state="+url.QueryEscape(loc.Query().Get("state")), nil)
|
||||
cb.Host = "cd.hanzo.ai"
|
||||
cb.AddCookie(&http.Cookie{Name: flowCookie, Value: cookie.Value})
|
||||
return do(t, app, cb)
|
||||
}
|
||||
|
||||
func do(t *testing.T, app *zip.App, req *http.Request) *http.Response {
|
||||
t.Helper()
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", req.Method, req.URL, err)
|
||||
}
|
||||
t.Cleanup(func() { _ = resp.Body.Close() })
|
||||
return resp
|
||||
}
|
||||
|
||||
func flowFrom(t *testing.T, resp *http.Response) (flow, *http.Cookie) {
|
||||
t.Helper()
|
||||
for _, ck := range resp.Cookies() {
|
||||
if ck.Name == flowCookie && ck.Value != "" {
|
||||
f, err := decodeFlow(ck.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("flow cookie: %v", err)
|
||||
}
|
||||
return f, ck
|
||||
}
|
||||
}
|
||||
t.Fatal("no flow cookie was set")
|
||||
return flow{}, nil
|
||||
}
|
||||
|
||||
func assertNoSession(t *testing.T, resp *http.Response) {
|
||||
t.Helper()
|
||||
for _, ck := range resp.Cookies() {
|
||||
if ck.Name == sessionCookie && ck.Value != "" {
|
||||
t.Fatalf("a session cookie was minted on a refused sign-in: %q", ck.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func encodeFlow(f flow) string {
|
||||
blob, _ := json.Marshal(f)
|
||||
return base64.RawURLEncoding.EncodeToString(blob)
|
||||
}
|
||||
|
||||
// fakeJWT builds a structurally valid, UNSIGNED JWT. That is exactly the point of
|
||||
// the test: nothing in this package trusts the signature — cloud's identity
|
||||
// boundary re-verifies the token on every later request, and this only exercises
|
||||
// the claim read that decides whether a cookie is worth minting.
|
||||
func fakeJWT(owner, name string, ttl time.Duration) string {
|
||||
enc := func(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
return enc(map[string]any{"alg": "RS256", "typ": "JWT"}) + "." +
|
||||
enc(map[string]any{"owner": owner, "name": name, "exp": time.Now().Add(ttl).Unix()}) + "." +
|
||||
"not-a-real-signature"
|
||||
}
|
||||
@@ -16,6 +16,7 @@ package deploy
|
||||
|
||||
import (
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
)
|
||||
|
||||
// ── ArgoCD v1alpha1 JSON (minimal, UI-render-complete) ───────────────────────
|
||||
@@ -41,6 +42,7 @@ type argoSource struct {
|
||||
type argoDestination struct {
|
||||
Server string `json:"server"`
|
||||
Namespace string `json:"namespace"`
|
||||
Name string `json:"name,omitempty"` // ArgoCD allows a destination by cluster name; omitted for the in-cluster projection.
|
||||
}
|
||||
|
||||
type argoSpec struct {
|
||||
@@ -176,6 +178,13 @@ func projectApp(cr *unstructured.Unstructured, ns, runningTag string) argoApp {
|
||||
if tag != "" {
|
||||
image = repository + ":" + tag
|
||||
}
|
||||
// Surface the tenant label the App CR already carries so the projection can be
|
||||
// grouped/scoped by org; env stays as before. The tenant BOUNDARY is enforced upstream
|
||||
// in the handlers (scope.allows) — this only reflects what the CR declares.
|
||||
labels := map[string]string{"hanzo.ai/instance": native.Name, "hanzo.ai/env": native.Env}
|
||||
if org := orgOf(cr); org != "" {
|
||||
labels[orgLabel] = org
|
||||
}
|
||||
return argoApp{
|
||||
APIVersion: "argoproj.io/v1alpha1",
|
||||
Kind: "Application",
|
||||
@@ -184,12 +193,14 @@ func projectApp(cr *unstructured.Unstructured, ns, runningTag string) argoApp {
|
||||
Namespace: ns,
|
||||
UID: string(cr.GetUID()),
|
||||
CreationTimestamp: cr.GetCreationTimestamp().Format("2006-01-02T15:04:05Z07:00"),
|
||||
Labels: map[string]string{"argocd.argoproj.io/instance": native.Name, "hanzo.ai/env": native.Env},
|
||||
Labels: labels,
|
||||
},
|
||||
Spec: argoSpec{
|
||||
Source: argoSource{RepoURL: deployManifestRepo, Path: "infra/k8s/operator/crs", TargetRevision: "main"},
|
||||
Destination: argoDestination{Server: "https://kubernetes.default.svc", Namespace: ns},
|
||||
Project: "default",
|
||||
Destination: argoDestination{Server: inClusterServer, Namespace: ns},
|
||||
// spec.project reflects the IAM Project the CR belongs to (app.kubernetes.io/part-of),
|
||||
// falling back to "default" when the CR carries no project label. No longer hard-coded.
|
||||
Project: projectName(cr),
|
||||
},
|
||||
Status: argoStatus{
|
||||
Sync: argoSyncStatus{Status: argoSyncFrom(native.Sync), Revision: native.Version},
|
||||
@@ -233,3 +244,233 @@ func nonEmpty(s string) []string {
|
||||
}
|
||||
return []string{s}
|
||||
}
|
||||
|
||||
// ── ArgoCD Cluster projection (destinations → ClusterList) ───────────────────
|
||||
|
||||
const (
|
||||
// inClusterServer / inClusterName are the destination every operator App CR
|
||||
// reconciles into — the cluster this cloud runs in. projectApp synthesizes
|
||||
// this destination, so the cluster set derives from it: one value, one home.
|
||||
inClusterServer = "https://kubernetes.default.svc"
|
||||
inClusterName = "in-cluster"
|
||||
|
||||
// connectionSuccessful is ArgoCD's ConnectionStatus for a reachable cluster.
|
||||
// This plane projects CRs the operator already reconciles INTO the cluster, so
|
||||
// the destination is reachable by definition — there is no cluster credential
|
||||
// to probe and, by construction (argoCluster has no config field), none to leak.
|
||||
connectionSuccessful = "Successful"
|
||||
)
|
||||
|
||||
// argoConnectionState is v1alpha1.ConnectionState — status is what the UI reads;
|
||||
// message + attemptedAt are optional and omitted from the projection.
|
||||
type argoConnectionState struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message,omitempty"`
|
||||
AttemptedAt string `json:"attemptedAt,omitempty"`
|
||||
}
|
||||
|
||||
// argoClusterInfo is v1alpha1.ClusterInfo reduced to the connection + app count
|
||||
// the UI's Destination/Clusters view reads. It carries NO credentials.
|
||||
type argoClusterInfo struct {
|
||||
ConnectionState argoConnectionState `json:"connectionState"`
|
||||
ApplicationsCount int `json:"applicationsCount"`
|
||||
ServerVersion string `json:"serverVersion,omitempty"`
|
||||
}
|
||||
|
||||
// argoCluster is v1alpha1.Cluster REDUCED to the projection-safe fields. There is
|
||||
// DELIBERATELY no `config` field: a projection has no cluster credential to
|
||||
// surface, so the type physically cannot carry a bearer token, TLS key, or exec
|
||||
// provider. server + name + connectionState is what the Destination column reads.
|
||||
type argoCluster struct {
|
||||
Server string `json:"server"`
|
||||
Name string `json:"name"`
|
||||
ConnectionState argoConnectionState `json:"connectionState"`
|
||||
Info argoClusterInfo `json:"info"`
|
||||
}
|
||||
|
||||
type argoClusterList struct {
|
||||
Metadata argoListMeta `json:"metadata"`
|
||||
Items []argoCluster `json:"items"`
|
||||
}
|
||||
|
||||
// clusterOf is the (server, name) an App CR reconciles into. Operator App CRs
|
||||
// declare no destination — they reconcile into THIS cluster — so absent a
|
||||
// spec.destination the cluster is in-cluster. Reading spec.destination first keeps
|
||||
// the derivation honest if a CR ever declares one.
|
||||
func clusterOf(cr *unstructured.Unstructured) (server, name string) {
|
||||
server, _, _ = unstructured.NestedString(cr.Object, "spec", "destination", "server")
|
||||
name, _, _ = unstructured.NestedString(cr.Object, "spec", "destination", "name")
|
||||
if server == "" {
|
||||
server = inClusterServer
|
||||
}
|
||||
if name == "" {
|
||||
if server == inClusterServer {
|
||||
name = inClusterName
|
||||
} else {
|
||||
name = server
|
||||
}
|
||||
}
|
||||
return server, name
|
||||
}
|
||||
|
||||
// connectionOK is the ConnectionState of a projected (operator-owned) cluster:
|
||||
// reachable by definition.
|
||||
func connectionOK() argoConnectionState { return argoConnectionState{Status: connectionSuccessful} }
|
||||
|
||||
// projectClusters derives the ArgoCD ClusterList from the destinations the fleet
|
||||
// reconciles into — deduped by server, counting the applications per cluster. The
|
||||
// in-cluster destination is ALWAYS present (an empty fleet still has one cluster).
|
||||
// It cannot emit a cluster credential: argoCluster has no config field.
|
||||
func projectClusters(crs []unstructured.Unstructured) argoClusterList {
|
||||
bySrv := map[string]*argoCluster{}
|
||||
order := []string{}
|
||||
ensure := func(server, name string) *argoCluster {
|
||||
cl, ok := bySrv[server]
|
||||
if !ok {
|
||||
cl = &argoCluster{Server: server, Name: name, ConnectionState: connectionOK(), Info: argoClusterInfo{ConnectionState: connectionOK()}}
|
||||
bySrv[server] = cl
|
||||
order = append(order, server)
|
||||
}
|
||||
return cl
|
||||
}
|
||||
ensure(inClusterServer, inClusterName) // an empty fleet still has one cluster
|
||||
for i := range crs {
|
||||
server, name := clusterOf(&crs[i])
|
||||
ensure(server, name).Info.ApplicationsCount++
|
||||
}
|
||||
items := make([]argoCluster, 0, len(order))
|
||||
for _, srv := range order {
|
||||
items = append(items, *bySrv[srv])
|
||||
}
|
||||
return argoClusterList{Metadata: argoListMeta{}, Items: items}
|
||||
}
|
||||
|
||||
// ── ArgoCD AppProject projection (distinct App-CR projects → AppProjectList) ──
|
||||
|
||||
// appProjectGVR is the ArgoCD AppProject CRD. This plane does not run argocd, so
|
||||
// the CRD is normally absent — dashProjects checks for it and falls back to
|
||||
// synthesizing a project set from the distinct App-CR project names.
|
||||
var appProjectGVR = schema.GroupVersionResource{Group: "argoproj.io", Version: "v1alpha1", Resource: "appprojects"}
|
||||
|
||||
// argoGroupKind is metav1.GroupKind — a clusterResourceWhitelist entry.
|
||||
type argoGroupKind struct {
|
||||
Group string `json:"group"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
|
||||
// argoProjectSpec is the subset of v1alpha1.AppProjectSpec the UI's project filter
|
||||
// + detail read. Only these fields are surfaced (never the whole CR spec) so a
|
||||
// real AppProject cannot leak roles/tokens or any field this plane didn't intend.
|
||||
type argoProjectSpec struct {
|
||||
SourceRepos []string `json:"sourceRepos"`
|
||||
Destinations []argoDestination `json:"destinations"`
|
||||
ClusterResourceWhitelist []argoGroupKind `json:"clusterResourceWhitelist"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// argoProject is v1alpha1.AppProject (projected). Project scoping on this platform
|
||||
// is IAM/Org, not argocd RBAC, so a synthesized project is permissive.
|
||||
type argoProject struct {
|
||||
APIVersion string `json:"apiVersion"`
|
||||
Kind string `json:"kind"`
|
||||
Metadata argoMeta `json:"metadata"`
|
||||
Spec argoProjectSpec `json:"spec"`
|
||||
Status argoProjectStat `json:"status"`
|
||||
}
|
||||
|
||||
// argoProjectStat marshals as the empty status object the UI expects.
|
||||
type argoProjectStat struct{}
|
||||
|
||||
type argoProjectList struct {
|
||||
Metadata argoListMeta `json:"metadata"`
|
||||
Items []argoProject `json:"items"`
|
||||
}
|
||||
|
||||
// projectedProjectNames is the distinct set of App-CR spec.project values, with
|
||||
// "default" always first (operator App CRs carry no project → default). Pure.
|
||||
func projectedProjectNames(crs []unstructured.Unstructured) []string {
|
||||
seen := map[string]bool{"default": true}
|
||||
order := []string{"default"}
|
||||
for i := range crs {
|
||||
p, _, _ := unstructured.NestedString(crs[i].Object, "spec", "project")
|
||||
if p == "" || seen[p] {
|
||||
continue
|
||||
}
|
||||
seen[p] = true
|
||||
order = append(order, p)
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
// synthProject builds a permissive projected AppProject for a name (project
|
||||
// scoping here is IAM/Org, not argocd RBAC).
|
||||
func synthProject(name string) argoProject {
|
||||
return argoProject{
|
||||
APIVersion: "argoproj.io/v1alpha1",
|
||||
Kind: "AppProject",
|
||||
Metadata: argoMeta{Name: name},
|
||||
Spec: argoProjectSpec{
|
||||
SourceRepos: []string{"*"},
|
||||
Destinations: []argoDestination{{Server: "*", Namespace: "*"}},
|
||||
ClusterResourceWhitelist: []argoGroupKind{{Group: "*", Kind: "*"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// projectAppProject reshapes a REAL argoproj.io/v1alpha1 AppProject CR into the
|
||||
// projected shape — name + ONLY the spec fields the UI reads. It never passes the
|
||||
// CR spec through verbatim, so a real project cannot surface roles, jwtToken
|
||||
// metadata, or any field this plane did not intend.
|
||||
func projectAppProject(cr *unstructured.Unstructured) argoProject {
|
||||
desc, _, _ := unstructured.NestedString(cr.Object, "spec", "description")
|
||||
return argoProject{
|
||||
APIVersion: "argoproj.io/v1alpha1",
|
||||
Kind: "AppProject",
|
||||
Metadata: argoMeta{Name: cr.GetName(), Namespace: cr.GetNamespace()},
|
||||
Spec: argoProjectSpec{
|
||||
SourceRepos: nestedStringSlice(cr.Object, "spec", "sourceRepos"),
|
||||
Destinations: nestedDestinations(cr.Object, "spec", "destinations"),
|
||||
ClusterResourceWhitelist: nestedGroupKinds(cr.Object, "spec", "clusterResourceWhitelist"),
|
||||
Description: desc,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// nestedDestinations reads a []{server,namespace,name} slice from a CR.
|
||||
func nestedDestinations(obj map[string]any, fields ...string) []argoDestination {
|
||||
raw, ok, _ := unstructured.NestedSlice(obj, fields...)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]argoDestination, 0, len(raw))
|
||||
for _, e := range raw {
|
||||
m, ok := e.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
server, _ := m["server"].(string)
|
||||
namespace, _ := m["namespace"].(string)
|
||||
name, _ := m["name"].(string)
|
||||
out = append(out, argoDestination{Server: server, Namespace: namespace, Name: name})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nestedGroupKinds reads a []{group,kind} slice from a CR.
|
||||
func nestedGroupKinds(obj map[string]any, fields ...string) []argoGroupKind {
|
||||
raw, ok, _ := unstructured.NestedSlice(obj, fields...)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]argoGroupKind, 0, len(raw))
|
||||
for _, e := range raw {
|
||||
m, ok := e.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
group, _ := m["group"].(string)
|
||||
kind, _ := m["kind"].(string)
|
||||
out = append(out, argoGroupKind{Group: group, Kind: kind})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package deploy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
@@ -102,3 +103,148 @@ func TestProjectTree_ShapeIsUIRenderable(t *testing.T) {
|
||||
t.Fatalf("node ref wrong: %v", n0)
|
||||
}
|
||||
}
|
||||
|
||||
// ── clusters projection ───────────────────────────────────────────────────────
|
||||
|
||||
// TestProjectClusters_DedupesAndAlwaysInCluster: the fleet's destinations collapse
|
||||
// to a deduped ClusterList; the in-cluster destination is always present with the
|
||||
// right per-cluster application count; a CR that declares a distinct destination
|
||||
// yields a second cluster.
|
||||
func TestProjectClusters_DedupesAndAlwaysInCluster(t *testing.T) {
|
||||
// Empty fleet still projects exactly one cluster (in-cluster), count 0.
|
||||
empty := projectClusters(nil)
|
||||
if len(empty.Items) != 1 || empty.Items[0].Server != inClusterServer || empty.Items[0].Name != inClusterName {
|
||||
t.Fatalf("empty fleet clusters = %+v, want one in-cluster", empty.Items)
|
||||
}
|
||||
if empty.Items[0].Info.ApplicationsCount != 0 {
|
||||
t.Fatalf("empty in-cluster count = %d, want 0", empty.Items[0].Info.ApplicationsCount)
|
||||
}
|
||||
if empty.Items[0].ConnectionState.Status != "Successful" || empty.Items[0].Info.ConnectionState.Status != "Successful" {
|
||||
t.Fatalf("connectionState = %+v, want Successful", empty.Items[0])
|
||||
}
|
||||
|
||||
// Two in-cluster apps + one app declaring a distinct destination server.
|
||||
a := *projFixture("cloud", "hanzo", "ghcr.io/hanzoai/cloud", "v1", "Running", 1, 1)
|
||||
b := *projFixture("iam", "hanzo", "ghcr.io/hanzoai/iam", "v1", "Running", 1, 1)
|
||||
edge := *projFixture("edge", "hanzo", "ghcr.io/hanzoai/edge", "v1", "Running", 1, 1)
|
||||
_ = unstructured.SetNestedField(edge.Object, map[string]any{"server": "https://edge.example:6443", "name": "edge"}, "spec", "destination")
|
||||
|
||||
cl := projectClusters([]unstructured.Unstructured{a, b, edge})
|
||||
if len(cl.Items) != 2 {
|
||||
t.Fatalf("clusters = %d, want 2 (in-cluster + edge)", len(cl.Items))
|
||||
}
|
||||
byServer := map[string]argoCluster{}
|
||||
for _, c := range cl.Items {
|
||||
byServer[c.Server] = c
|
||||
}
|
||||
if byServer[inClusterServer].Info.ApplicationsCount != 2 {
|
||||
t.Fatalf("in-cluster count = %d, want 2", byServer[inClusterServer].Info.ApplicationsCount)
|
||||
}
|
||||
if e, ok := byServer["https://edge.example:6443"]; !ok || e.Name != "edge" || e.Info.ApplicationsCount != 1 {
|
||||
t.Fatalf("edge cluster = %+v (ok=%v), want name=edge count=1", e, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectClusters_NeverEmitsCredentials is the credential-leak guard: the
|
||||
// marshaled ClusterList must carry NO config / bearerToken / tlsClientConfig /
|
||||
// execProviderConfig key anywhere — the argoCluster type has no field for them.
|
||||
func TestProjectClusters_NeverEmitsCredentials(t *testing.T) {
|
||||
// Even a CR polluted with a spec.destination.config must not surface it.
|
||||
a := *projFixture("cloud", "hanzo", "ghcr.io/hanzoai/cloud", "v1", "Running", 1, 1)
|
||||
_ = unstructured.SetNestedField(a.Object, map[string]any{
|
||||
"server": inClusterServer,
|
||||
"config": map[string]any{"bearerToken": "SECRET-DO-NOT-LEAK", "tlsClientConfig": map[string]any{"keyData": "PRIV"}},
|
||||
}, "spec", "destination")
|
||||
|
||||
b, err := json.Marshal(projectClusters([]unstructured.Unstructured{a}))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
blob := string(b)
|
||||
for _, forbidden := range []string{"config", "bearerToken", "tlsClientConfig", "execProviderConfig", "keyData", "SECRET-DO-NOT-LEAK", "PRIV"} {
|
||||
if strings.Contains(blob, forbidden) {
|
||||
t.Fatalf("clusters JSON leaked %q: %s", forbidden, blob)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── projects projection ───────────────────────────────────────────────────────
|
||||
|
||||
// TestProjectedProjectNames_DistinctWithDefault: default is always first; distinct
|
||||
// spec.project values follow; empty projects collapse to default.
|
||||
func TestProjectedProjectNames_DistinctWithDefault(t *testing.T) {
|
||||
// No projects declared → just default.
|
||||
if got := projectedProjectNames([]unstructured.Unstructured{
|
||||
*projFixture("cloud", "hanzo", "r", "v1", "Running", 1, 1),
|
||||
}); len(got) != 1 || got[0] != "default" {
|
||||
t.Fatalf("no-project names = %v, want [default]", got)
|
||||
}
|
||||
|
||||
a := *projFixture("cloud", "hanzo", "r", "v1", "Running", 1, 1)
|
||||
b := *projFixture("iam", "hanzo", "r", "v1", "Running", 1, 1)
|
||||
c := *projFixture("chat", "hanzo", "r", "v1", "Running", 1, 1)
|
||||
_ = unstructured.SetNestedField(a.Object, "team-a", "spec", "project")
|
||||
_ = unstructured.SetNestedField(b.Object, "team-a", "spec", "project") // dup
|
||||
_ = unstructured.SetNestedField(c.Object, "team-b", "spec", "project")
|
||||
got := projectedProjectNames([]unstructured.Unstructured{a, b, c})
|
||||
if len(got) != 3 || got[0] != "default" {
|
||||
t.Fatalf("names = %v, want [default team-a team-b]", got)
|
||||
}
|
||||
set := map[string]bool{}
|
||||
for _, n := range got {
|
||||
if set[n] {
|
||||
t.Fatalf("duplicate project name %q in %v", n, got)
|
||||
}
|
||||
set[n] = true
|
||||
}
|
||||
if !set["team-a"] || !set["team-b"] {
|
||||
t.Fatalf("names = %v, want team-a + team-b present", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSynthProject_Permissive: a synthesized project is a permissive AppProject the
|
||||
// UI can render + filter on.
|
||||
func TestSynthProject_Permissive(t *testing.T) {
|
||||
p := synthProject("default")
|
||||
if p.APIVersion != "argoproj.io/v1alpha1" || p.Kind != "AppProject" || p.Metadata.Name != "default" {
|
||||
t.Fatalf("synth project TypeMeta/name wrong: %+v", p)
|
||||
}
|
||||
if len(p.Spec.SourceRepos) != 1 || p.Spec.SourceRepos[0] != "*" {
|
||||
t.Fatalf("sourceRepos = %v, want [*]", p.Spec.SourceRepos)
|
||||
}
|
||||
if len(p.Spec.Destinations) != 1 || p.Spec.Destinations[0].Server != "*" || p.Spec.Destinations[0].Namespace != "*" {
|
||||
t.Fatalf("destinations = %v, want [{*,*}]", p.Spec.Destinations)
|
||||
}
|
||||
if len(p.Spec.ClusterResourceWhitelist) != 1 || p.Spec.ClusterResourceWhitelist[0].Group != "*" {
|
||||
t.Fatalf("clusterResourceWhitelist = %v, want [{*,*}]", p.Spec.ClusterResourceWhitelist)
|
||||
}
|
||||
// status marshals as an object (UI expects one).
|
||||
b, _ := json.Marshal(p)
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(b, &m)
|
||||
if _, ok := m["status"].(map[string]any); !ok {
|
||||
t.Fatalf("project status must be a JSON object, got %T", m["status"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectAppProject_SurfacesOnlyIntendedFields: a REAL AppProject CR is
|
||||
// reshaped to name + the whitelisted spec fields ONLY — roles (token metadata)
|
||||
// never leak through.
|
||||
func TestProjectAppProject_SurfacesOnlyIntendedFields(t *testing.T) {
|
||||
p := projectAppProject(appProjectCR("team-a", "https://git.hanzo.ai/team-a/*"))
|
||||
if p.Metadata.Name != "team-a" {
|
||||
t.Fatalf("name = %q, want team-a", p.Metadata.Name)
|
||||
}
|
||||
if len(p.Spec.SourceRepos) != 1 || p.Spec.SourceRepos[0] != "https://git.hanzo.ai/team-a/*" {
|
||||
t.Fatalf("sourceRepos = %v, want the real CR value", p.Spec.SourceRepos)
|
||||
}
|
||||
if len(p.Spec.Destinations) != 1 || p.Spec.Destinations[0].Namespace != "hanzo" {
|
||||
t.Fatalf("destinations = %v, want the real CR value", p.Spec.Destinations)
|
||||
}
|
||||
b, _ := json.Marshal(p)
|
||||
for _, forbidden := range []string{"roles", "secret-role", "policies"} {
|
||||
if strings.Contains(string(b), forbidden) {
|
||||
t.Fatalf("projected AppProject leaked %q: %s", forbidden, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
// scope.go — the TENANT SCOPE of a /v1/deploy request, and the IAM-owned project
|
||||
// reflection.
|
||||
//
|
||||
// This plane was SuperAdmin-only: it listed App CRs across ALL platform
|
||||
// namespaces, hard-coded spec.project = "default", and read no org/project
|
||||
// labels. It is now TENANT-AWARE and IAM-MAPPED. Every operator App CR already
|
||||
// carries the tenant + project labels (clients/platform serviceCR + the fleet
|
||||
// crs/*.yaml stamp them); this plane READS them and scopes each list/detail read
|
||||
// to the caller's org, while a SuperAdmin keeps the whole-fleet view.
|
||||
//
|
||||
// The tenant boundary is the SAME one the rest of cloud trusts (clients/platform
|
||||
// .tenant / clients/s3.tenant): the gateway-minted, IAM-validated identity headers
|
||||
// (c.IsAdmin/c.Org), the injective provisioning.SanitizeOrg normalizer, and the
|
||||
// principal.Validated gate. There is NO third slug rule — resolveScope keys the
|
||||
// SAME (org → tenant-<org>) boundary the PaaS writes into.
|
||||
//
|
||||
// Projects are owned by Hanzo IAM (hanzo.id), the ONE source of truth for the
|
||||
// org-scoped (Owner,Name) Project resource. This plane REFLECTS them read-only via
|
||||
// the in-process object store (embedded IAM, no HTTP hop) — mirroring
|
||||
// clients/platform/projects.go — and never persists a CD-side project row.
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
iamobj "github.com/hanzoai/iam/object"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
"github.com/hanzoai/cloud/clients/provisioning"
|
||||
)
|
||||
|
||||
// Operator App CRs carry two labels this plane READS (never writes): clients/platform
|
||||
// serviceCR and the fleet crs/*.yaml stamp them.
|
||||
//
|
||||
// hanzo.ai/org — the TENANT: the injective provisioning.SanitizeOrg slug.
|
||||
// app.kubernetes.io/part-of — the IAM Project NAME (tenant-provisioned apps carry it).
|
||||
const (
|
||||
orgLabel = "hanzo.ai/org"
|
||||
projectLabel = "app.kubernetes.io/part-of"
|
||||
)
|
||||
|
||||
// scope is the resolved tenant view of a /v1/deploy request. A SuperAdmin sees the
|
||||
// WHOLE fleet (org == "", superAdmin == true); a normal org sees ONLY its own rows
|
||||
// (org == its injective slug, superAdmin == false).
|
||||
type scope struct {
|
||||
org string // sanitized org slug; "" == whole-fleet (SuperAdmin)
|
||||
superAdmin bool
|
||||
}
|
||||
|
||||
// resolveScope derives the scope from the VALIDATED identity, reusing clients/platform
|
||||
// .tenant's EXACT boundary primitives so this plane keys the SAME tenant boundary as the
|
||||
// rest of cloud — with no third slug rule.
|
||||
//
|
||||
// SuperAdmin FIRST and by c.IsAdmin() ALONE: SanitizeIdentity mints X-User-IsAdmin only
|
||||
// for a JWT-verified SuperAdmin and NEVER restores it from the client (unlike X-Org-Id),
|
||||
// so it already implies a validated principal. This is the SAME predicate the original
|
||||
// guard() gates on, which is why the SuperAdmin whole-fleet view (and the e2e 108/109
|
||||
// contract) is preserved byte-for-byte.
|
||||
//
|
||||
// A normal org additionally REQUIRES principal.Validated (X-User-Id present): X-Org-Id IS
|
||||
// restorable from the client on the bearer-less "Phase-1 data" path, so trusting it without
|
||||
// a validated principal would let an off-gateway caller forge `X-Org-Id: victim` and read
|
||||
// another tenant. An empty or unvalidated org fails closed.
|
||||
func resolveScope(c *zip.Ctx) (scope, bool) {
|
||||
if c.IsAdmin() {
|
||||
return scope{superAdmin: true}, true
|
||||
}
|
||||
if !principal.Validated(c) {
|
||||
return scope{}, false
|
||||
}
|
||||
if org := provisioning.SanitizeOrg(c.Org()); org != "" {
|
||||
return scope{org: org}, true
|
||||
}
|
||||
return scope{}, false
|
||||
}
|
||||
|
||||
// refuse is the fail-closed refusal shared by guard() and every scoped route: a browser
|
||||
// NAVIGATION is bounced to sign-in (a 403 page with no way to sign in is a dead end), while
|
||||
// every API/XHR call keeps its 403. Identical shape to the original guard(); the message is
|
||||
// deliberately generic so the 403 discloses no policy (whether admin or an org would pass).
|
||||
func refuse(c *zip.Ctx) error {
|
||||
if wantsDocument(c.Method(), c.Header("Sec-Fetch-Dest"), c.Header("Sec-Fetch-Mode"),
|
||||
c.Header("Accept"), c.Header("X-Requested-With")) {
|
||||
return c.Redirect(http.StatusFound, loginPath+"?returnTo="+url.QueryEscape(currentPath(c)))
|
||||
}
|
||||
return zip.ErrForbidden("not authorized for this deploy console")
|
||||
}
|
||||
|
||||
// orgOf / projectOf read the tenant + IAM-project labels off an App CR ("" when absent).
|
||||
func orgOf(cr *unstructured.Unstructured) string { return cr.GetLabels()[orgLabel] }
|
||||
func projectOf(cr *unstructured.Unstructured) string { return cr.GetLabels()[projectLabel] }
|
||||
|
||||
// projectName is the App CR's IAM project — the app.kubernetes.io/part-of label, falling
|
||||
// back to "default" when the CR carries none (the fleet CRs predate per-app projects).
|
||||
func projectName(cr *unstructured.Unstructured) string {
|
||||
if p := projectOf(cr); p != "" {
|
||||
return p
|
||||
}
|
||||
return principal.DefaultProject
|
||||
}
|
||||
|
||||
// allows reports whether an App CR is visible to this scope. A SuperAdmin sees every CR; a
|
||||
// normal org sees ONLY a CR whose hanzo.ai/org label equals its slug — the injective
|
||||
// SanitizeOrg slug, so two orgs can never collide. This is the cross-tenant boundary,
|
||||
// applied to EVERY projected/accessed CR (list, detail, clusters, stream).
|
||||
func (sc scope) allows(cr *unstructured.Unstructured) bool {
|
||||
if sc.superAdmin {
|
||||
return true
|
||||
}
|
||||
return sc.org != "" && orgOf(cr) == sc.org
|
||||
}
|
||||
|
||||
// namespaces is the ordered set of namespaces this scope reads App CRs from. A SuperAdmin
|
||||
// scans the whole platform tier (scanOrder, unchanged — preserving the pre-tenant fleet
|
||||
// view); a normal org scans ONLY its own tenant namespace, tenant-<org>.
|
||||
func (sc scope) namespaces() []string {
|
||||
if sc.superAdmin {
|
||||
return scanOrder()
|
||||
}
|
||||
return []string{tenantNS(sc.org)}
|
||||
}
|
||||
|
||||
// tenantNS is the tenant namespace for an ALREADY-sanitized org slug: "tenant-"+slug — the
|
||||
// read twin of clients/platform.tenantNamespace (which is "tenant-"+SanitizeOrg(org)).
|
||||
// sc.org is never empty here (resolveScope gates it to a non-empty injective slug), so no
|
||||
// "unknown" fallback is needed. The "tenant-" prefix is a frozen infra convention (renaming
|
||||
// it is a gated namespace migration — see clients/platform/k8s.go); sc.org is the SAME
|
||||
// SanitizeOrg slug platform stamps, so the two derive the same namespace.
|
||||
func tenantNS(org string) string { return "tenant-" + org }
|
||||
|
||||
// appCRs collects every App CR VISIBLE to this scope across its namespaces, filtered by the
|
||||
// org label (a SuperAdmin's filter admits all). Used where per-namespace running-tag
|
||||
// batching is unneeded (clusters + the projects fallback).
|
||||
func (sc scope) appCRs(s *cloud.Service[state], ctx context.Context) ([]unstructured.Unstructured, error) {
|
||||
var out []unstructured.Unstructured
|
||||
for _, ns := range sc.namespaces() {
|
||||
crs, err := listAppCRs(s, ctx, ns)
|
||||
if err != nil {
|
||||
return nil, k8sErr(s, "list", err)
|
||||
}
|
||||
for i := range crs {
|
||||
if sc.allows(&crs[i]) {
|
||||
out = append(out, crs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// findNamespace resolves the namespace an App CR named `name` lives in, scanning this
|
||||
// scope's namespaces in order and REQUIRING the CR be visible to the scope. A cross-tenant
|
||||
// name (org A's app requested by org B) is reported as a clean 404 — never confirmed to
|
||||
// exist, so the detail routes leak no cross-tenant existence oracle.
|
||||
func (sc scope) findNamespace(s *cloud.Service[state], c *zip.Ctx, name string) (string, error) {
|
||||
for _, ns := range sc.namespaces() {
|
||||
cr, _, err := getAppCR(s, c.Context(), ns, name)
|
||||
if err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
continue
|
||||
}
|
||||
return "", k8sErr(s, "get", err)
|
||||
}
|
||||
if !sc.allows(cr) {
|
||||
continue // exists but not this tenant's — treat as absent
|
||||
}
|
||||
return ns, nil
|
||||
}
|
||||
return "", zip.ErrNotFound("application " + name + " not found")
|
||||
}
|
||||
|
||||
// watches reports whether a watched App-CR object belongs to this scope's stream: its
|
||||
// namespace is one the scope watches AND (for a normal org) its org label matches. Replaces
|
||||
// the platform-namespace nsEnv gate forwardWatch used, generalizing it to the scope.
|
||||
func (sc scope) watches(obj *unstructured.Unstructured) bool {
|
||||
return sc.watchesNamespace(obj.GetNamespace()) && sc.allows(obj)
|
||||
}
|
||||
|
||||
func (sc scope) watchesNamespace(ns string) bool {
|
||||
for _, n := range sc.namespaces() {
|
||||
if n == ns {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// runningByNamespace collects running image tags per namespace this scope watches
|
||||
// (best-effort — a list error yields an empty inner map). Scoped twin of the stream's
|
||||
// per-namespace running-tag map.
|
||||
func (sc scope) runningByNamespace(s *cloud.Service[state], ctx context.Context) map[string]map[string]string {
|
||||
out := make(map[string]map[string]string, len(sc.namespaces()))
|
||||
for _, ns := range sc.namespaces() {
|
||||
out[ns] = runningVersions(s, ctx, ns)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── IAM-owned project reflection ─────────────────────────────────────────────
|
||||
|
||||
// iamStore runs an embedded-IAM object-store call, converting a nil-store panic into a
|
||||
// clean 503 rather than a nil-deref crash. The store's engine (iamobj.ormer) is a package
|
||||
// global that is nil until the co-resident IAM subsystem initializes it; a project call
|
||||
// against a nil engine would otherwise nil-deref. Mirrors clients/platform.iamStore — a
|
||||
// deployment enabling "deploy" is meant to co-mount "iam" (single-binary co-residents).
|
||||
// Never masks a real error.
|
||||
func iamStore[T any](fn func() (T, error)) (out T, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = zip.Errorf(http.StatusServiceUnavailable,
|
||||
"deploy requires the co-resident IAM store, which is not initialized")
|
||||
}
|
||||
}()
|
||||
return fn()
|
||||
}
|
||||
|
||||
// iamProjects reflects the IAM-owned projects VISIBLE to this scope into projected argo
|
||||
// AppProjects. A normal org gets its own organization's projects
|
||||
// (GetOrganizationProjects); a SuperAdmin gets every org's (GetProjects with an empty owner
|
||||
// → all owners, the same all-orgs listing IAM's own get-projects endpoint serves). IAM is
|
||||
// the ONE source; this NEVER persists a CD-side project. A nil/absent embedded IAM store
|
||||
// yields nil (the caller's synthesized-default fallback keeps the projection populated).
|
||||
func (sc scope) iamProjects() []argoProject {
|
||||
list, err := iamStore(func() ([]*iamobj.Project, error) {
|
||||
if sc.superAdmin {
|
||||
return iamobj.GetProjects("") // empty owner → every org's projects
|
||||
}
|
||||
return iamobj.GetOrganizationProjects(sc.org)
|
||||
})
|
||||
if err != nil || len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]argoProject, 0, len(list))
|
||||
for _, p := range list {
|
||||
out = append(out, projectFromIAM(p))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// projectFromIAM reflects ONE IAM Project into a projected argo AppProject: name = the
|
||||
// project Name (the app-scope key AND the CR part-of label), description = DisplayName or
|
||||
// Description, and an hanzo.ai/org label carrying the tenant. Project scoping on this
|
||||
// platform is IAM/Org, not argocd RBAC, so the projected spec is permissive — and ONLY
|
||||
// these fields are surfaced (never Tags/Metadata), so nothing unintended leaks.
|
||||
func projectFromIAM(p *iamobj.Project) argoProject {
|
||||
proj := synthProject(p.Name)
|
||||
proj.Spec.Description = firstNonEmpty(p.DisplayName, p.Description)
|
||||
if org := provisioning.SanitizeOrg(firstNonEmpty(p.Organization, p.Owner)); org != "" {
|
||||
proj.Metadata.Labels = map[string]string{orgLabel: org}
|
||||
}
|
||||
return proj
|
||||
}
|
||||
|
||||
// ensureDefault guarantees a project named "default" is present (prepended if absent by
|
||||
// name), so every projected app's spec.project — which falls back to "default" when a CR
|
||||
// carries no part-of label — always resolves to a listed project, and the CD SPA's project
|
||||
// filter can render the default bucket. Preserves the e2e invariant (projects contains
|
||||
// 'default') regardless of IAM state. Pure.
|
||||
func ensureDefault(items []argoProject) []argoProject {
|
||||
for i := range items {
|
||||
if items[i].Metadata.Name == principal.DefaultProject {
|
||||
return items
|
||||
}
|
||||
}
|
||||
return append([]argoProject{synthProject(principal.DefaultProject)}, items...)
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
iamobj "github.com/hanzoai/iam/object"
|
||||
luxlog "github.com/luxfi/log"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/provisioning"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/watch"
|
||||
)
|
||||
|
||||
// superScope is the whole-fleet SuperAdmin scope — the pre-tenant behavior. Used by the
|
||||
// stream unit tests (which exercise the projection core directly).
|
||||
func superScope() scope { return scope{superAdmin: true} }
|
||||
|
||||
// orgAppCR builds an operator App CR stamped with the tenant + project labels the platform
|
||||
// stamps (hanzo.ai/org, app.kubernetes.io/part-of). ns is the namespace the CR lives in
|
||||
// (tenant-<org> for a tenant app).
|
||||
func orgAppCR(ns, name, org, project string) *unstructured.Unstructured {
|
||||
cr := appCR("App", ns, name, "uid-"+ns+"-"+name, "ghcr.io/hanzoai/"+name, "v1", "Running", 1, 1)
|
||||
labels := map[string]string{}
|
||||
if org != "" {
|
||||
labels[orgLabel] = org
|
||||
}
|
||||
if project != "" {
|
||||
labels[projectLabel] = project
|
||||
}
|
||||
_ = unstructured.SetNestedStringMap(cr.Object, labels, "metadata", "labels")
|
||||
return cr
|
||||
}
|
||||
|
||||
// getAs drives a GET through the full router with the given identity headers and returns
|
||||
// the response (no status assertion — callers test 200/403/404). Mirrors production: the
|
||||
// gateway/SanitizeIdentity mints X-Org-Id / X-User-Id / X-User-IsAdmin; a test sets them.
|
||||
func getAs(t *testing.T, s *cloud.Service[state], path string, headers map[string]string) *http.Response {
|
||||
t.Helper()
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
routes(app, s)
|
||||
req := httptest.NewRequest("GET", path, nil)
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", path, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// jsonBody decodes a JSON response body into a map.
|
||||
func jsonBody(t *testing.T, resp *http.Response) map[string]any {
|
||||
t.Helper()
|
||||
defer resp.Body.Close()
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// orgHeaders is a VALIDATED org member's identity (X-User-Id present ⇒ principal.Validated,
|
||||
// X-Org-Id the tenant, no X-User-IsAdmin).
|
||||
func orgHeaders(org string) map[string]string {
|
||||
return map[string]string{"X-Org-Id": org, "X-User-Id": "u_" + org}
|
||||
}
|
||||
|
||||
// appNames collects the projected application names from an ApplicationList body.
|
||||
func appNames(body map[string]any) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
items, _ := body["items"].([]any)
|
||||
for _, it := range items {
|
||||
m, _ := it.(map[string]any)
|
||||
meta, _ := m["metadata"].(map[string]any)
|
||||
if n, _ := meta["name"].(string); n != "" {
|
||||
out[n] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── resolveScope: the tenant boundary (pure) ─────────────────────────────────
|
||||
|
||||
// probeScope resolves a scope from a set of identity headers, driven through a real ctx.
|
||||
func probeScope(t *testing.T, headers map[string]string) (scope, bool) {
|
||||
t.Helper()
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
var got scope
|
||||
var ok bool
|
||||
app.Get("/probe", func(c *zip.Ctx) error {
|
||||
got, ok = resolveScope(c)
|
||||
return c.JSON(http.StatusOK, map[string]any{})
|
||||
})
|
||||
req := httptest.NewRequest("GET", "/probe", nil)
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("probe: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
return got, ok
|
||||
}
|
||||
|
||||
// TestResolveScope_Boundary proves resolveScope reuses the SAME boundary as
|
||||
// clients/platform.tenant: SuperAdmin by c.IsAdmin() alone (whole-fleet), a normal org only
|
||||
// when VALIDATED (X-User-Id present) with a non-empty sanitized org, everything else CLOSED.
|
||||
func TestResolveScope_Boundary(t *testing.T) {
|
||||
// SuperAdmin: c.IsAdmin() alone ⇒ whole-fleet (org == "").
|
||||
if sc, ok := probeScope(t, map[string]string{"X-User-IsAdmin": "true"}); !ok || !sc.superAdmin || sc.org != "" {
|
||||
t.Fatalf("admin scope = %+v ok=%v, want {superAdmin,org:\"\"}", sc, ok)
|
||||
}
|
||||
// A SuperAdmin whose X-Org-Id is also set STILL sees the whole fleet (admin wins).
|
||||
if sc, ok := probeScope(t, map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "acme", "X-User-Id": "u"}); !ok || !sc.superAdmin {
|
||||
t.Fatalf("admin+org scope = %+v ok=%v, want superAdmin", sc, ok)
|
||||
}
|
||||
// Validated org member ⇒ org scope, sanitized.
|
||||
if sc, ok := probeScope(t, orgHeaders("acme")); !ok || sc.superAdmin || sc.org != "acme" {
|
||||
t.Fatalf("org scope = %+v ok=%v, want {org:acme}", sc, ok)
|
||||
}
|
||||
// resolveScope keys the org through the SAME injective provisioning.SanitizeOrg the CR
|
||||
// label filter uses — so a resolved org and the hanzo.ai/org label compare like-for-like,
|
||||
// and two distinct owners never collide onto one tenant. (Uppercase/dirty inputs are NOT
|
||||
// identity-mapped; they carry a hash suffix, which is exactly the injectivity guarantee.)
|
||||
for _, raw := range []string{"acme", "ACME", "team1"} {
|
||||
sc, ok := probeScope(t, map[string]string{"X-Org-Id": raw, "X-User-Id": "u"})
|
||||
want := provisioning.SanitizeOrg(raw)
|
||||
if want == "" {
|
||||
t.Fatalf("test input %q unexpectedly sanitized to empty", raw)
|
||||
}
|
||||
if !ok || sc.org != want {
|
||||
t.Fatalf("org(%q) scope = %+v ok=%v, want org %q (SanitizeOrg)", raw, sc, ok, want)
|
||||
}
|
||||
}
|
||||
// An org carrying an unsafe rune (whitespace) is refused by SanitizeOrg (→ "") — a
|
||||
// non-injective identifier — so resolveScope fails closed, never a fabricated tenant.
|
||||
if sc, ok := probeScope(t, map[string]string{"X-Org-Id": "bad org", "X-User-Id": "u"}); ok {
|
||||
t.Fatalf("whitespace org resolved a scope %+v — must fail closed", sc)
|
||||
}
|
||||
// FORGED X-Org-Id with NO validated principal (no X-User-Id) ⇒ fail closed.
|
||||
if sc, ok := probeScope(t, map[string]string{"X-Org-Id": "victim"}); ok {
|
||||
t.Fatalf("forged X-Org-Id resolved a scope: %+v — must fail closed", sc)
|
||||
}
|
||||
// Validated but EMPTY org, not admin ⇒ fail closed.
|
||||
if _, ok := probeScope(t, map[string]string{"X-User-Id": "u"}); ok {
|
||||
t.Fatal("empty-org validated non-admin resolved a scope — must fail closed")
|
||||
}
|
||||
// Nothing ⇒ fail closed.
|
||||
if _, ok := probeScope(t, map[string]string{}); ok {
|
||||
t.Fatal("anonymous resolved a scope — must fail closed")
|
||||
}
|
||||
}
|
||||
|
||||
// ── applications: org scoping + cross-org isolation ──────────────────────────
|
||||
|
||||
// twoTenantFleet is a fleet with apps for two tenants (in their tenant-<org> namespaces)
|
||||
// plus a system app in the platform "hanzo" namespace.
|
||||
func twoTenantFleet() *cloud.Service[state] {
|
||||
return fakeSvc(
|
||||
orgAppCR("tenant-acme", "acme-web", "acme", "storefront"),
|
||||
orgAppCR("tenant-acme", "acme-api", "acme", "storefront"),
|
||||
orgAppCR("tenant-bravo", "bravo-web", "bravo", "site"),
|
||||
orgAppCR("hanzo", "cloud", "hanzo", ""), // a system/fleet app (platform namespace)
|
||||
)
|
||||
}
|
||||
|
||||
// TestDashAppList_OrgSeesOnlyItsApps (requirement a): a normal-org caller sees ONLY apps
|
||||
// labeled its org, in its tenant namespace — and the projection surfaces the org label.
|
||||
func TestDashAppList_OrgSeesOnlyItsApps(t *testing.T) {
|
||||
s := twoTenantFleet()
|
||||
resp := getAs(t, s, "/v1/deploy/applications", orgHeaders("acme"))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("acme /applications = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
names := appNames(jsonBody(t, resp))
|
||||
if !names["acme-web"] || !names["acme-api"] {
|
||||
t.Fatalf("acme missing its own apps: %v", names)
|
||||
}
|
||||
if names["bravo-web"] || names["cloud"] {
|
||||
t.Fatalf("acme sees another tenant's/system apps (CROSS-ORG LEAK): %v", names)
|
||||
}
|
||||
if len(names) != 2 {
|
||||
t.Fatalf("acme app count = %d, want exactly 2 (its own): %v", len(names), names)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashAppList_CrossOrgIsolation (requirement b): org B never sees org A's apps — not
|
||||
// with its own header, and not even claiming A's org without a validated principal.
|
||||
func TestDashAppList_CrossOrgIsolation(t *testing.T) {
|
||||
s := twoTenantFleet()
|
||||
|
||||
// bravo sees only bravo.
|
||||
bravo := appNames(jsonBody(t, getAs(t, s, "/v1/deploy/applications", orgHeaders("bravo"))))
|
||||
if !bravo["bravo-web"] || bravo["acme-web"] || bravo["acme-api"] || bravo["cloud"] {
|
||||
t.Fatalf("bravo sees non-bravo apps (CROSS-ORG LEAK): %v", bravo)
|
||||
}
|
||||
|
||||
// bravo forging X-Org-Id: acme WITHOUT a validated principal (no X-User-Id) ⇒ 403,
|
||||
// never acme's fleet.
|
||||
resp := getAs(t, s, "/v1/deploy/applications", map[string]string{"X-Org-Id": "acme"})
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("forged X-Org-Id:acme (no principal) = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
// TestDashAppList_SuperAdminSeesFleet (requirement c): a SuperAdmin sees the whole platform
|
||||
// fleet (the scanOrder namespaces) exactly as before — the e2e 108/109 contract.
|
||||
func TestDashAppList_SuperAdminSeesFleet(t *testing.T) {
|
||||
s := twoTenantFleet()
|
||||
names := appNames(jsonBody(t, getAs(t, s, "/v1/deploy/applications", map[string]string{"X-User-IsAdmin": "true"})))
|
||||
if !names["cloud"] {
|
||||
t.Fatalf("SuperAdmin missing the fleet's system app: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashApp_CrossOrgAppIdIs404 (requirement b): an app id from org A, requested by org B,
|
||||
// returns a clean 404 — never confirmed to exist (no cross-tenant oracle).
|
||||
func TestDashApp_CrossOrgAppIdIs404(t *testing.T) {
|
||||
s := twoTenantFleet()
|
||||
for _, route := range []string{"/v1/deploy/applications/acme-web", "/v1/deploy/applications/acme-web/resource-tree"} {
|
||||
resp := getAs(t, s, route, orgHeaders("bravo"))
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("bravo GET %s = %d, want 404 (acme's app must be invisible)", route, resp.StatusCode)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
// acme reaches its own app.
|
||||
resp := getAs(t, s, "/v1/deploy/applications/acme-web", orgHeaders("acme"))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("acme GET its own app = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
// TestDashClusters_OrgCountsOnlyItsApps (requirement b): the ClusterList a tenant sees
|
||||
// counts ONLY its own apps, and still leaks no cluster credential.
|
||||
func TestDashClusters_OrgCountsOnlyItsApps(t *testing.T) {
|
||||
s := twoTenantFleet()
|
||||
body := jsonBody(t, getAs(t, s, "/v1/deploy/clusters", orgHeaders("acme")))
|
||||
items, _ := body["items"].([]any)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("acme clusters = %v, want exactly one (in-cluster)", items)
|
||||
}
|
||||
c0 := items[0].(map[string]any)
|
||||
info, _ := c0["info"].(map[string]any)
|
||||
if info["applicationsCount"].(float64) != 2 {
|
||||
t.Fatalf("acme in-cluster count = %v, want 2 (acme's apps only, not bravo/system)", info["applicationsCount"])
|
||||
}
|
||||
raw, _ := json.Marshal(body)
|
||||
for _, forbidden := range []string{"bearerToken", "tlsClientConfig", "execProviderConfig", "keyData"} {
|
||||
if strings.Contains(string(raw), forbidden) {
|
||||
t.Fatalf("tenant clusters leaked %q: %s", forbidden, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashReads_FailClosedOnUnvalidatedOrg (requirement f): every scoped READ route fails
|
||||
// closed (403) for a forged X-Org-Id with no validated principal, and for an empty org.
|
||||
func TestDashReads_FailClosedOnUnvalidatedOrg(t *testing.T) {
|
||||
s := twoTenantFleet()
|
||||
readRoutes := []string{
|
||||
"/v1/deploy/applications",
|
||||
"/v1/deploy/applications/acme-web",
|
||||
"/v1/deploy/applications/acme-web/resource-tree",
|
||||
"/v1/deploy/clusters",
|
||||
"/v1/deploy/projects",
|
||||
"/v1/deploy/stream/applications",
|
||||
}
|
||||
for _, r := range readRoutes {
|
||||
// Forged org, no validated principal (no X-User-Id).
|
||||
resp := getAs(t, s, r, map[string]string{"X-Org-Id": "acme"})
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("forged-org GET %s = %d, want 403", r, resp.StatusCode)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
// Validated but empty org, not admin.
|
||||
resp2 := getAs(t, s, r, map[string]string{"X-User-Id": "u"})
|
||||
if resp2.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("empty-org GET %s = %d, want 403", r, resp2.StatusCode)
|
||||
}
|
||||
_ = resp2.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// ── spec.project from part-of (projection) ───────────────────────────────────
|
||||
|
||||
// TestProjectApp_SpecProjectFromPartOf (requirement d): spec.project is read from the
|
||||
// app.kubernetes.io/part-of label (the IAM Project), not hard-coded, and the tenant label
|
||||
// is surfaced in the projection.
|
||||
func TestProjectApp_SpecProjectFromPartOf(t *testing.T) {
|
||||
app := projectApp(orgAppCR("tenant-acme", "acme-web", "acme", "storefront"), "tenant-acme", "v1")
|
||||
if app.Spec.Project != "storefront" {
|
||||
t.Fatalf("spec.project = %q, want storefront (from part-of)", app.Spec.Project)
|
||||
}
|
||||
if app.Metadata.Labels[orgLabel] != "acme" {
|
||||
t.Fatalf("projected labels missing hanzo.ai/org=acme: %v", app.Metadata.Labels)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectApp_UnlabeledFallsIntoDefault (requirement e): an App CR with no part-of label
|
||||
// projects into the "default" project, and carries no org label when the CR has none.
|
||||
func TestProjectApp_UnlabeledFallsIntoDefault(t *testing.T) {
|
||||
// The bare appCR helper carries NO labels at all.
|
||||
app := projectApp(appCR("App", "hanzo", "cloud", "u1", "ghcr.io/hanzoai/cloud", "v1", "Running", 1, 1), "hanzo", "v1")
|
||||
if app.Spec.Project != "default" {
|
||||
t.Fatalf("spec.project = %q, want default (no part-of)", app.Spec.Project)
|
||||
}
|
||||
if _, present := app.Metadata.Labels[orgLabel]; present {
|
||||
t.Fatalf("unlabeled CR projected an org label: %v", app.Metadata.Labels)
|
||||
}
|
||||
}
|
||||
|
||||
// ── projects: IAM reflection + cross-org isolation ───────────────────────────
|
||||
|
||||
// TestDashProjects_OrgNeverSeesCrossOrgAppProjects (requirement b): a normal org's /projects
|
||||
// NEVER surfaces the unscoped cluster-wide AppProject list (that path is SuperAdmin-only) —
|
||||
// even when real AppProject CRs are served — and always contains 'default'.
|
||||
func TestDashProjects_OrgNeverSeesCrossOrgAppProjects(t *testing.T) {
|
||||
s := fakeSvc(
|
||||
orgAppCR("tenant-acme", "acme-web", "acme", "storefront"),
|
||||
appProjectCR("team-secret", "https://git.hanzo.ai/team-secret/*"), // a cross-org real AppProject CR
|
||||
)
|
||||
body := jsonBody(t, getAs(t, s, "/v1/deploy/projects", orgHeaders("acme")))
|
||||
items, _ := body["items"].([]any)
|
||||
names := map[string]bool{}
|
||||
for _, it := range items {
|
||||
m, _ := it.(map[string]any)
|
||||
meta, _ := m["metadata"].(map[string]any)
|
||||
names[meta["name"].(string)] = true
|
||||
}
|
||||
if names["team-secret"] {
|
||||
t.Fatalf("a normal org saw a cluster-wide AppProject CR (CROSS-ORG LEAK): %v", names)
|
||||
}
|
||||
if !names["default"] {
|
||||
t.Fatalf("org /projects missing 'default': %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectFromIAM_Reflects (requirement a, pure): an IAM Project reflects to a permissive
|
||||
// argo AppProject — name = Project.Name, description from DisplayName, an org label — and
|
||||
// surfaces NONE of Tags/Metadata.
|
||||
func TestProjectFromIAM_Reflects(t *testing.T) {
|
||||
p := &iamobj.Project{
|
||||
Owner: "acme", Name: "storefront", Organization: "acme",
|
||||
DisplayName: "Storefront", Description: "the shop", IsDefault: false,
|
||||
Tags: []string{"secret-tag"}, Metadata: `{"secret":"x"}`,
|
||||
}
|
||||
proj := projectFromIAM(p)
|
||||
if proj.Metadata.Name != "storefront" {
|
||||
t.Fatalf("name = %q, want storefront", proj.Metadata.Name)
|
||||
}
|
||||
if proj.Spec.Description != "Storefront" {
|
||||
t.Fatalf("description = %q, want Storefront (DisplayName)", proj.Spec.Description)
|
||||
}
|
||||
if proj.Metadata.Labels[orgLabel] != "acme" {
|
||||
t.Fatalf("org label = %v, want acme", proj.Metadata.Labels)
|
||||
}
|
||||
b, _ := json.Marshal(proj)
|
||||
for _, forbidden := range []string{"secret-tag", "secret", "Metadata", "tags"} {
|
||||
if strings.Contains(string(b), forbidden) {
|
||||
t.Fatalf("projected IAM project leaked %q: %s", forbidden, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureDefault (invariant): 'default' is prepended when absent, a no-op when present.
|
||||
func TestEnsureDefault(t *testing.T) {
|
||||
got := ensureDefault(nil)
|
||||
if len(got) != 1 || got[0].Metadata.Name != "default" {
|
||||
t.Fatalf("ensureDefault(nil) = %v, want [default]", got)
|
||||
}
|
||||
withDefault := ensureDefault([]argoProject{synthProject("default"), synthProject("team-a")})
|
||||
count := 0
|
||||
for _, p := range withDefault {
|
||||
if p.Metadata.Name == "default" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("ensureDefault duplicated 'default': %d copies", count)
|
||||
}
|
||||
}
|
||||
|
||||
// ── stream: org scoping ──────────────────────────────────────────────────────
|
||||
|
||||
// TestStreamBurst_OrgScoped: a tenant's initial burst emits ONLY its own apps.
|
||||
func TestStreamBurst_OrgScoped(t *testing.T) {
|
||||
s := twoTenantFleet()
|
||||
var buf bytes.Buffer
|
||||
w := bufio.NewWriter(&buf)
|
||||
if ok := streamAppBurst(s, scope{org: "acme"}, context.Background(), w); !ok {
|
||||
t.Fatal("streamAppBurst(acme) returned false")
|
||||
}
|
||||
_ = w.Flush()
|
||||
frames := parseSSE(buf.String())
|
||||
names := map[string]bool{}
|
||||
for _, f := range frames {
|
||||
var env struct {
|
||||
Result applicationWatchEvent `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(f), &env); err != nil {
|
||||
t.Fatalf("bad frame %q: %v", f, err)
|
||||
}
|
||||
names[env.Result.Application.Metadata.Name] = true
|
||||
}
|
||||
if !names["acme-web"] || !names["acme-api"] {
|
||||
t.Fatalf("acme burst missing its apps: %v", names)
|
||||
}
|
||||
if names["bravo-web"] || names["cloud"] {
|
||||
t.Fatalf("acme burst leaked another tenant's/system app: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// TestForwardWatch_DropsCrossTenantObject: an org's watch forwarder drops a watched object
|
||||
// that belongs to another tenant (wrong org label) and one outside its namespace.
|
||||
func TestForwardWatch_DropsCrossTenantObject(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
fw := watch.NewFake()
|
||||
events := make(chan streamEvent, 4)
|
||||
go forwardWatch(ctx, scope{org: "acme"}, "tenant-acme", fw, events)
|
||||
|
||||
go func() {
|
||||
// bravo's app in bravo's namespace — must be dropped.
|
||||
fw.Action(watch.Added, orgAppCR("tenant-bravo", "bravo-web", "bravo", "site"))
|
||||
// an object in acme's namespace but mislabeled bravo — must be dropped (label filter).
|
||||
fw.Action(watch.Added, orgAppCR("tenant-acme", "sneaky", "bravo", "x"))
|
||||
// acme's own app — must come through.
|
||||
fw.Action(watch.Added, orgAppCR("tenant-acme", "acme-web", "acme", "storefront"))
|
||||
}()
|
||||
|
||||
select {
|
||||
case ev := <-events:
|
||||
if ev.obj.GetName() != "acme-web" {
|
||||
t.Fatalf("forwarded a cross-tenant object: %q (want only acme-web)", ev.obj.GetName())
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("acme's own event never arrived (over-filtered)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
// stream.go — GET /v1/deploy/stream/applications: the ArgoCD applications watch
|
||||
// as Server-Sent Events. The applications view opens this the moment it loads and
|
||||
// keeps it open for live fleet updates; a 404 here makes the SPA error-toast.
|
||||
//
|
||||
// The stream emits one ADDED event per current App CR — the SAME projection
|
||||
// dashAppList serves (listAppCRs + runningVersions + projectApp: one source, one
|
||||
// projection) — then watches the App CRs and forwards ADDED/MODIFIED/DELETED as
|
||||
// they occur, holding the connection open with periodic keep-alives. Every watch +
|
||||
// goroutine it starts is bound to the request and torn down on return, so a client
|
||||
// disconnect leaks nothing. Read-only and TENANT-SCOPED (resolveScope: SuperAdmin
|
||||
// streams the whole fleet, a validated org member only its own org's apps); it fails
|
||||
// closed (403 unauthorized, 503 when no cluster client is configured) and degrades to
|
||||
// keep-alive only (the initial state still renders) if the watch verb is not granted.
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/watch"
|
||||
)
|
||||
|
||||
// streamKeepalive is the interval between keep-alive comments on an idle stream.
|
||||
// The keep-alive holds the connection open AND is when a client disconnect is
|
||||
// detected — a failing flush ends the stream — so it also bounds how long an idle
|
||||
// disconnected client lingers before its watch is torn down.
|
||||
const streamKeepalive = 25 * time.Second
|
||||
|
||||
// applicationWatchEvent is the ArgoCD v1alpha1 ApplicationWatchEvent — the object
|
||||
// the SPA reads from each SSE frame's `.result`.
|
||||
type applicationWatchEvent struct {
|
||||
Type string `json:"type"` // ADDED | MODIFIED | DELETED
|
||||
Application argoApp `json:"application"`
|
||||
}
|
||||
|
||||
// streamResult is the `{"result": …}` envelope the SPA unwraps (JSON.parse(data).result).
|
||||
type streamResult struct {
|
||||
Result applicationWatchEvent `json:"result"`
|
||||
}
|
||||
|
||||
// dashStreamApps is GET /v1/deploy/stream/applications — the applications watch as
|
||||
// SSE. Tenant-scoped (resolveScope): a SuperAdmin streams the whole fleet, a validated
|
||||
// org member streams ONLY its own org's apps; anyone else 403s. 503 when no cluster
|
||||
// client is configured.
|
||||
func dashStreamApps(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
// readiness BEFORE the scope gate: the readiness of the cluster client is already
|
||||
// public via /v1/deploy/health, and the direct-call fail-closed test asserts the 503.
|
||||
if err := ready(s); err != nil {
|
||||
return err
|
||||
}
|
||||
sc, ok := resolveScope(c)
|
||||
if !ok {
|
||||
return refuse(c)
|
||||
}
|
||||
// Capture the context BEFORE SendStreamWriter: its callback runs AFTER this
|
||||
// handler returns (fasthttp body writer) and must not touch c. c.Context() is
|
||||
// background-derived — it does NOT cancel on client disconnect; the disconnect
|
||||
// signal is a failing flush inside the loop.
|
||||
ctx := c.Context()
|
||||
setStreamHeaders(c)
|
||||
return c.SendStreamWriter(func(w *bufio.Writer) {
|
||||
streamApps(s, sc, ctx, w)
|
||||
})
|
||||
}
|
||||
|
||||
// setStreamHeaders writes the SSE response headers. Factored out so it is testable
|
||||
// without spawning the body-stream goroutine (fasthttp starts the writer eagerly
|
||||
// on SendStreamWriter).
|
||||
func setStreamHeaders(c *zip.Ctx) {
|
||||
c.SetHeader("Content-Type", "text/event-stream")
|
||||
c.SetHeader("Cache-Control", "no-cache")
|
||||
c.SetHeader("Connection", "keep-alive")
|
||||
c.SetHeader("X-Accel-Buffering", "no") // never buffer SSE at a proxy
|
||||
}
|
||||
|
||||
// streamApps is the SSE core, separated from the handler so it is unit-testable
|
||||
// over a bytes.Buffer + a cancelable context: emit the initial ADDED burst, then
|
||||
// watch for live changes until the client disconnects or the context is canceled.
|
||||
func streamApps(s *cloud.Service[state], sc scope, ctx context.Context, w *bufio.Writer) {
|
||||
wctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel() // stops every watch + forwarder started below
|
||||
if !streamAppBurst(s, sc, wctx, w) {
|
||||
return // client gone during the burst
|
||||
}
|
||||
streamAppWatch(s, sc, wctx, w)
|
||||
}
|
||||
|
||||
// streamAppBurst emits one ADDED event per current App CR VISIBLE to the scope,
|
||||
// projected identically to dashAppList. Returns false if a write failed (client
|
||||
// disconnected).
|
||||
func streamAppBurst(s *cloud.Service[state], sc scope, ctx context.Context, w *bufio.Writer) bool {
|
||||
for _, ns := range sc.namespaces() {
|
||||
crs, err := listAppCRs(s, ctx, ns)
|
||||
if err != nil {
|
||||
s.Log.Warn("deploy stream: initial list failed", "namespace", ns, "err", err)
|
||||
continue // best-effort burst; other namespaces still stream
|
||||
}
|
||||
running := runningVersions(s, ctx, ns)
|
||||
for i := range crs {
|
||||
if !sc.allows(&crs[i]) {
|
||||
continue // cross-tenant CR — never streamed to this scope
|
||||
}
|
||||
if !writeAppEvent(w, string(watch.Added), projectApp(&crs[i], ns, running[crs[i].GetName()])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// streamEvent is one forwarded watch event carrying the raw App CR (projected in
|
||||
// the single-threaded main loop, which owns the running-tag map).
|
||||
type streamEvent struct {
|
||||
typ string
|
||||
ns string
|
||||
obj *unstructured.Unstructured
|
||||
}
|
||||
|
||||
// streamAppWatch holds the stream open: it forwards live App-CR changes as ArgoCD
|
||||
// watch events and writes a keep-alive on an idle interval. It returns when the
|
||||
// context is canceled OR a write fails (client disconnected). Every watch +
|
||||
// goroutine is bound to ctx and stopped on return — no leak.
|
||||
func streamAppWatch(s *cloud.Service[state], sc scope, ctx context.Context, w *bufio.Writer) {
|
||||
events := make(chan streamEvent, 16)
|
||||
started := 0
|
||||
for _, ns := range sc.namespaces() {
|
||||
watcher, err := s.State.dyn.Resource(appsCRGVR).Namespace(ns).Watch(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
// Degrade, don't fail: the initial state stands and the keep-alive holds
|
||||
// the connection open. The operator can grant the `watch` verb to turn on
|
||||
// live updates without any code change.
|
||||
s.Log.Warn("deploy stream: watch unavailable; namespace will not stream live", "namespace", ns, "err", err)
|
||||
continue
|
||||
}
|
||||
started++
|
||||
// The read plane installs no panic recovery around detached goroutines, and a
|
||||
// watch event is a system boundary — recover here so a malformed event can never
|
||||
// crash the whole process; the watcher is still stopped by forwardWatch's defer.
|
||||
go func(ns string, watcher watch.Interface) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
s.Log.Error("deploy stream: watch goroutine panic recovered", "namespace", ns, "panic", r)
|
||||
}
|
||||
}()
|
||||
forwardWatch(ctx, sc, ns, watcher, events)
|
||||
}(ns, watcher)
|
||||
}
|
||||
if started > 0 {
|
||||
s.Log.Info("deploy stream: watching App CRs", "namespaces", started)
|
||||
}
|
||||
|
||||
// Running-image tags refresh on the keep-alive tick, bounding LIST calls to once
|
||||
// per interval regardless of event rate; a live event projects with the last
|
||||
// known tag (nil-map reads are the empty string — safe).
|
||||
running := sc.runningByNamespace(s, ctx)
|
||||
ticker := time.NewTicker(streamKeepalive)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case ev := <-events:
|
||||
if !writeAppEvent(w, ev.typ, projectApp(ev.obj, ev.ns, running[ev.ns][ev.obj.GetName()])) {
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
running = sc.runningByNamespace(s, ctx)
|
||||
if _, err := w.WriteString(": keep-alive\n\n"); err != nil {
|
||||
return
|
||||
}
|
||||
if err := w.Flush(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// forwardWatch relays one namespace's App-CR watch onto events until ctx is
|
||||
// canceled or the watch closes (server timeout / Stop). It stops its watcher on
|
||||
// return and never blocks past ctx — the send to events selects on ctx.Done.
|
||||
func forwardWatch(ctx context.Context, sc scope, ns string, watcher watch.Interface, events chan<- streamEvent) {
|
||||
defer watcher.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case e, ok := <-watcher.ResultChan():
|
||||
if !ok {
|
||||
return // watch closed
|
||||
}
|
||||
typ := watchType(e.Type)
|
||||
if typ == "" {
|
||||
continue // skip BOOKMARK / ERROR — not an application change
|
||||
}
|
||||
obj, ok := e.Object.(*unstructured.Unstructured)
|
||||
if !ok || obj == nil || obj.GetName() == "" {
|
||||
continue // guard the typed-nil object: GetName() would nil-deref
|
||||
}
|
||||
if !sc.watches(obj) {
|
||||
continue // only namespaces this scope watches, and (for an org) only its own CRs
|
||||
}
|
||||
select {
|
||||
case events <- streamEvent{typ: typ, ns: ns, obj: obj}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// watchType maps a k8s watch event type to the ArgoCD WatchType the UI parses.
|
||||
// BOOKMARK and ERROR are not application changes → "" (skipped).
|
||||
func watchType(t watch.EventType) string {
|
||||
switch t {
|
||||
case watch.Added, watch.Modified, watch.Deleted:
|
||||
return string(t) // "ADDED" | "MODIFIED" | "DELETED"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// writeAppEvent writes one SSE frame — `data: {"result":{"type":…,"application":…}}`
|
||||
// — and flushes. Returns false when the write/flush fails (client disconnected).
|
||||
func writeAppEvent(w *bufio.Writer, typ string, app argoApp) bool {
|
||||
payload, err := json.Marshal(streamResult{Result: applicationWatchEvent{Type: typ, Application: app}})
|
||||
if err != nil {
|
||||
return true // unreachable for this shape; skip the frame rather than kill the stream
|
||||
}
|
||||
if _, err := w.WriteString("data: "); err != nil {
|
||||
return false
|
||||
}
|
||||
if _, err := w.Write(payload); err != nil {
|
||||
return false
|
||||
}
|
||||
if _, err := w.WriteString("\n\n"); err != nil {
|
||||
return false
|
||||
}
|
||||
return w.Flush() == nil
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Package domain is Hanzo Domains — the DOMAIN-REGISTRATION product: search a name,
|
||||
// see its price (with Hanzo's markup), buy it billed through the customer's prepaid
|
||||
// wallet, and have it born pointing at Hanzo's own authoritative nameservers.
|
||||
//
|
||||
// This is DISTINCT from Hanzo DNS (hanzoai/dns): DNS manages records for a domain you
|
||||
// already control; Domains ACQUIRES the domain. After a purchase, Domains hands the
|
||||
// new zone to hanzoai/dns and points the registrar's nameservers at it — the two
|
||||
// products compose (buy here, manage records there).
|
||||
//
|
||||
// Wholesale is resold from a registrar behind the Registrar interface (name.com Core
|
||||
// API v4 today, clients/domain/namecom). The core here is transport-free: it
|
||||
// orchestrates availability → price → authorize → register → provision-zone →
|
||||
// capture → record over four interfaces (Registrar, Biller, Zones, Store), so the
|
||||
// policy is unit-testable with no HTTP/registrar/billing backend. mount.go is the thin
|
||||
// cloud adapter that binds the real backends and exposes /v1/domain/*.
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/domain/namecom"
|
||||
)
|
||||
|
||||
// Sentinels the orchestration returns; the HTTP adapter maps them to status codes.
|
||||
var (
|
||||
// ErrInsufficientFunds — the org's prepaid balance cannot cover the price. → 402.
|
||||
ErrInsufficientFunds = errors.New("domain: insufficient balance for this purchase")
|
||||
// ErrUnavailable — the name is not purchasable (taken, reserved, or unsupported TLD). → 409.
|
||||
ErrUnavailable = errors.New("domain: name is not available to register")
|
||||
// ErrAlreadyOwned — this org already holds this domain. → 409.
|
||||
ErrAlreadyOwned = errors.New("domain: already registered to this org")
|
||||
// ErrNotOwned — a renew/transfer/manage on a domain this org does not hold. → 404/403.
|
||||
ErrNotOwned = errors.New("domain: not registered to this org")
|
||||
// ErrNotConfigured — the registrar has no credentials. → 503.
|
||||
ErrNotConfigured = errors.New("domain: registrar not configured")
|
||||
)
|
||||
|
||||
// Registrar is the wholesale registrar Hanzo resells. *namecom.Client satisfies it.
|
||||
type Registrar interface {
|
||||
CheckAvailability(ctx context.Context, names ...string) (*namecom.SearchResponse, error)
|
||||
Search(ctx context.Context, keyword string, tldFilter ...string) (*namecom.SearchResponse, error)
|
||||
CreateDomain(ctx context.Context, req namecom.CreateDomainRequest) (*namecom.CreateDomainResponse, error)
|
||||
RenewDomain(ctx context.Context, domainName string, req namecom.RenewDomainRequest) (*namecom.RenewDomainResponse, error)
|
||||
SetNameservers(ctx context.Context, domainName string, nameservers []string) (*namecom.Domain, error)
|
||||
CreateTransfer(ctx context.Context, req namecom.TransferRequest) (*namecom.TransferResponse, error)
|
||||
Hello(ctx context.Context) (*namecom.HelloResponse, error)
|
||||
Configured() bool
|
||||
}
|
||||
|
||||
// Biller is the two-phase deposit→charge a purchase bills through. Authorize refuses
|
||||
// when the org's prepaid balance cannot cover the marked-up price (BEFORE the
|
||||
// registrar is touched); Capture debits it AFTER the registrar succeeds. Backed by
|
||||
// cloud's ResourceMeter (Gate → Authorize, Meter → Capture).
|
||||
type Biller interface {
|
||||
// Authorize returns ErrInsufficientFunds when the balance can't cover cents, nil
|
||||
// to proceed, or another error when the balance is unknown (fail-closed).
|
||||
Authorize(ctx context.Context, org string, cents int64) error
|
||||
// Capture records the debit against the org's ledger. ref is the idempotency /
|
||||
// attribution key (e.g. "domain:register:acme.ai").
|
||||
Capture(org string, cents int64, ref string)
|
||||
}
|
||||
|
||||
// Zones ensures an authoritative DNS zone exists for a freshly-registered domain and
|
||||
// returns the nameservers to point it at. Backed by hanzoai/dns.
|
||||
type Zones interface {
|
||||
EnsureZone(ctx context.Context, org, domainName string) (nameservers []string, err error)
|
||||
}
|
||||
|
||||
// Record is the domain↔org ownership row Hanzo issues on a successful purchase.
|
||||
type Record struct {
|
||||
Org string `json:"org"`
|
||||
Domain string `json:"domain"`
|
||||
RegisteredAt int64 `json:"registeredAt"` // unix seconds
|
||||
ExpiresAt string `json:"expiresAt,omitempty"`
|
||||
PriceCents int64 `json:"priceCents"` // what the customer paid (sell)
|
||||
CostCents int64 `json:"costCents"` // wholesale cost
|
||||
Nameservers []string `json:"nameservers,omitempty"`
|
||||
Order int64 `json:"order,omitempty"` // registrar order id
|
||||
}
|
||||
|
||||
// Store persists ownership records.
|
||||
type Store interface {
|
||||
Put(rec Record) error
|
||||
Get(org, domainName string) (Record, bool, error)
|
||||
ListByOrg(org string) ([]Record, error)
|
||||
}
|
||||
|
||||
// Config tunes pricing and the DNS handoff.
|
||||
type Config struct {
|
||||
Markup Markup // wholesale → sell
|
||||
Nameservers []string // Hanzo authoritative NS to point purchased domains at (fallback when Zones returns none)
|
||||
Env string // registrar env label (test/prod) — attribution only
|
||||
}
|
||||
|
||||
// Service is the transport-free orchestrator.
|
||||
type Service struct {
|
||||
reg Registrar
|
||||
bill Biller
|
||||
zones Zones
|
||||
store Store
|
||||
cfg Config
|
||||
}
|
||||
|
||||
// NewService builds the orchestrator. All four backends are required; pass a no-op
|
||||
// Zones/Store in a context that doesn't need them.
|
||||
func NewService(reg Registrar, bill Biller, zones Zones, store Store, cfg Config) *Service {
|
||||
return &Service{reg: reg, bill: bill, zones: zones, store: store, cfg: cfg}
|
||||
}
|
||||
|
||||
// Env reports the registrar environment label (test/prod).
|
||||
func (s *Service) Env() string { return s.cfg.Env }
|
||||
|
||||
// Configured reports whether the registrar has credentials.
|
||||
func (s *Service) Configured() bool { return s.reg.Configured() }
|
||||
@@ -0,0 +1,388 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/domain/namecom"
|
||||
"github.com/hanzoai/cloud/clients/metering"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
luxlog "github.com/luxfi/log"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// Mount wires Hanzo Domains onto the unified cloud binary as /v1/domain/*:
|
||||
//
|
||||
// GET /v1/domain/health registrar reachability (no auth)
|
||||
// GET /v1/domain/search?q=&tld= keyword search + alternate TLDs (priced)
|
||||
// GET /v1/domain/availability?domain=a,b exact-name availability + pricing
|
||||
// GET /v1/domain/domains the org's registered domains
|
||||
// POST /v1/domain/register {domain,years,contacts?} buy (billed)
|
||||
// POST /v1/domain/renew {domain,years} renew (billed)
|
||||
// POST /v1/domain/transfer {domain,authCode,years} transfer-in (billed)
|
||||
//
|
||||
// Every mutating route is org-scoped: a validated principal's org owns the purchase
|
||||
// and is the ledger the charge lands on. The registrar's wholesale credentials come
|
||||
// from the platform secret store (KMS) via the operator-injected env NAMECOM_USER /
|
||||
// NAMECOM_TOKEN — never hard-coded, exactly as clients/sites reads CF_API_TOKEN.
|
||||
func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
return cloud.Mount(app, deps, "domain", buildState, routes)
|
||||
}
|
||||
|
||||
// state is the subsystem's data: the orchestrator plus the raw registrar (for the
|
||||
// health probe's Hello) and a logger.
|
||||
type state struct {
|
||||
svc *Service
|
||||
reg Registrar
|
||||
log luxlog.Logger
|
||||
}
|
||||
|
||||
func buildState(b cloud.Base) (state, error) {
|
||||
cfg := configFromEnv()
|
||||
reg := namecom.New(
|
||||
strings.TrimSpace(os.Getenv("NAMECOM_USER")),
|
||||
strings.TrimSpace(os.Getenv("NAMECOM_TOKEN")),
|
||||
cfg.Env, nil,
|
||||
)
|
||||
biller := &meterBiller{rm: b.Bill}
|
||||
zones := &hanzodnsZones{
|
||||
base: strings.TrimRight(strings.TrimSpace(os.Getenv("HANZO_DNS_URL")), "/"),
|
||||
ns: cfg.Nameservers,
|
||||
http: &http.Client{Timeout: 10 * time.Second},
|
||||
log: b.Log,
|
||||
}
|
||||
svc := NewService(reg, biller, zones, NewMemStore(), cfg)
|
||||
b.Log.Info("hanzo domains ready",
|
||||
"registrar", "name.com",
|
||||
"env", cfg.Env,
|
||||
"configured", reg.Configured(),
|
||||
"nameservers", strings.Join(cfg.Nameservers, ","),
|
||||
"markup", cfg.Markup.Multiplier,
|
||||
)
|
||||
return state{svc: svc, reg: reg, log: b.Log}, nil
|
||||
}
|
||||
|
||||
func routes(app *zip.App, s *cloud.Service[state]) {
|
||||
// Handlers mount AFTER commerce, whose /v1 error filter flattens a propagated
|
||||
// error to 500 — so wrap in Terminal to preserve real 4xx/402/409 statuses.
|
||||
h := func(fn func(*cloud.Service[state], *zip.Ctx) error) func(*zip.Ctx) error {
|
||||
return cloud.Terminal(cloud.Handle(s, fn))
|
||||
}
|
||||
app.Get("/v1/domain/health", cloud.Handle(s, health))
|
||||
app.Get("/v1/domain/search", h(searchHandler))
|
||||
app.Get("/v1/domain/availability", h(availabilityHandler))
|
||||
app.Get("/v1/domain/domains", h(listHandler))
|
||||
app.Post("/v1/domain/register", h(registerHandler))
|
||||
app.Post("/v1/domain/renew", h(renewHandler))
|
||||
app.Post("/v1/domain/transfer", h(transferHandler))
|
||||
}
|
||||
|
||||
// ── config ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
func configFromEnv() Config {
|
||||
return Config{
|
||||
Markup: Markup{
|
||||
Multiplier: floatEnv("DOMAIN_MARKUP", 1.15),
|
||||
MinMarginCents: intEnv("DOMAIN_MIN_MARGIN_CENTS", 300),
|
||||
},
|
||||
Nameservers: nsEnv("HANZO_NAMESERVERS", []string{"ns1.hanzo.ai", "ns2.hanzo.ai"}),
|
||||
// The registrar env is EXPLICIT and fail-safe: only "prod" hits the live,
|
||||
// billable registrar; anything else (incl. unset) is the sandbox.
|
||||
Env: envOr("NAMECOM_ENV", "test"),
|
||||
}
|
||||
}
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func floatEnv(key string, def float64) float64 {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func intEnv(key string, def int64) int64 {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func nsEnv(key string, def []string) []string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
parts := strings.Split(v, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// ── billing adapter (ResourceMeter → Biller) ──────────────────────────────────────
|
||||
|
||||
// meterBiller adapts cloud's ResourceMeter to the Biller interface: Gate is the
|
||||
// pre-charge balance authorize, Meter is the debit capture. This is the exact
|
||||
// deposit→charge seam every metered subsystem uses.
|
||||
type meterBiller struct{ rm *cloud.ResourceMeter }
|
||||
|
||||
func (m *meterBiller) Authorize(ctx context.Context, org string, cents int64) error {
|
||||
// ("", false): no project sub-scope on a domain purchase — org- and
|
||||
// service-scoped caps apply; a domain buy is not project-attributed.
|
||||
err := m.rm.Gate(ctx, org, "", false, "domain.register", cents)
|
||||
if errors.Is(err, metering.ErrInsufficientBalance) {
|
||||
return ErrInsufficientFunds
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *meterBiller) Capture(org string, cents int64, ref string) {
|
||||
m.rm.Meter(org, "", "domain.register", cents, ref, "")
|
||||
}
|
||||
|
||||
// ── DNS adapter (hanzoai/dns) ──────────────────────────────────────────────────────
|
||||
|
||||
// hanzodnsZones ensures an authoritative zone exists in hanzoai/dns for a purchased
|
||||
// domain and reports the Hanzo nameservers to point it at. When HANZO_DNS_URL is
|
||||
// unset it is a no-op that still returns the nameservers, so a registration always
|
||||
// points at Hanzo's NS even before the zone control plane is wired in an environment.
|
||||
type hanzodnsZones struct {
|
||||
base string
|
||||
ns []string
|
||||
http *http.Client
|
||||
log luxlog.Logger
|
||||
}
|
||||
|
||||
func (z *hanzodnsZones) EnsureZone(ctx context.Context, org, domainName string) ([]string, error) {
|
||||
if z.base == "" {
|
||||
return z.ns, nil
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{"zone": domainName, "orgId": org})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, z.base+"/v1/dns/zones", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return z.ns, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Org-Id", org)
|
||||
resp, err := z.http.Do(req)
|
||||
if err != nil {
|
||||
z.log.Warn("hanzodns ensure-zone failed (registering against Hanzo NS anyway)", "domain", domainName, "err", err)
|
||||
return z.ns, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode/100 != 2 && resp.StatusCode != http.StatusConflict {
|
||||
z.log.Warn("hanzodns ensure-zone non-2xx (continuing)", "domain", domainName, "status", resp.StatusCode)
|
||||
return z.ns, errors.New("hanzodns: status " + strconv.Itoa(resp.StatusCode))
|
||||
}
|
||||
return z.ns, nil
|
||||
}
|
||||
|
||||
// ── handlers ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
func org(c *zip.Ctx) (string, bool) { return principal.Org(c) }
|
||||
|
||||
// statusErr maps a core sentinel / registrar error to a zip HTTP status.
|
||||
func statusErr(err error) error {
|
||||
switch {
|
||||
case err == nil:
|
||||
return nil
|
||||
case errors.Is(err, ErrNotConfigured):
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "domain registration is not configured on this deployment")
|
||||
case errors.Is(err, ErrInsufficientFunds):
|
||||
return zip.Errorf(http.StatusPaymentRequired, "insufficient balance — add credits to buy this domain")
|
||||
case errors.Is(err, ErrUnavailable):
|
||||
return zip.ErrConflict("that domain is not available to register")
|
||||
case errors.Is(err, ErrAlreadyOwned):
|
||||
return zip.ErrConflict("your org already owns that domain")
|
||||
case errors.Is(err, ErrNotOwned):
|
||||
return zip.ErrNotFound("your org does not own that domain")
|
||||
}
|
||||
var apiErr *namecom.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
// Surface the registrar's own message; a 4xx from the registrar is a client
|
||||
// problem, a 5xx a bad-gateway.
|
||||
status := http.StatusBadGateway
|
||||
if apiErr.Status >= 400 && apiErr.Status < 500 {
|
||||
status = apiErr.Status
|
||||
}
|
||||
return zip.Errorf(status, "registrar: %s", apiErr.Message)
|
||||
}
|
||||
return zip.Errorf(http.StatusInternalServerError, "%v", err)
|
||||
}
|
||||
|
||||
// health probes registrar reachability. Public (like every subsystem health) and
|
||||
// honest: it reports whether credentials are present and, if so, whether name.com
|
||||
// accepts them (the current go-live blocker surfaces here as ok:false + the reason).
|
||||
func health(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
res := map[string]any{"service": "domain", "registrar": "name.com", "env": s.State.svc.Env()}
|
||||
if !s.State.reg.Configured() {
|
||||
res["status"], res["configured"] = "degraded", false
|
||||
res["error"] = "registrar credentials not set (NAMECOM_USER/NAMECOM_TOKEN)"
|
||||
return c.JSON(http.StatusServiceUnavailable, res)
|
||||
}
|
||||
res["configured"] = true
|
||||
ctx, cancel := context.WithTimeout(c.Context(), 8*time.Second)
|
||||
defer cancel()
|
||||
if _, err := s.State.reg.Hello(ctx); err != nil {
|
||||
res["status"], res["reachable"] = "degraded", false
|
||||
res["error"] = err.Error()
|
||||
return c.JSON(http.StatusServiceUnavailable, res)
|
||||
}
|
||||
res["status"], res["reachable"] = "ok", true
|
||||
return c.JSON(http.StatusOK, res)
|
||||
}
|
||||
|
||||
func searchHandler(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
if _, ok := org(c); !ok {
|
||||
return zip.ErrForbidden("a validated principal is required")
|
||||
}
|
||||
q := strings.TrimSpace(c.Query("q"))
|
||||
if q == "" {
|
||||
return zip.ErrBadRequest("q (keyword) is required")
|
||||
}
|
||||
var tlds []string
|
||||
if raw := strings.TrimSpace(c.Query("tld")); raw != "" {
|
||||
for _, t := range strings.Split(raw, ",") {
|
||||
if t = strings.TrimSpace(t); t != "" {
|
||||
tlds = append(tlds, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
quotes, err := s.State.svc.Search(c.Context(), q, tlds...)
|
||||
if err != nil {
|
||||
return statusErr(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{"results": quotes})
|
||||
}
|
||||
|
||||
func availabilityHandler(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
if _, ok := org(c); !ok {
|
||||
return zip.ErrForbidden("a validated principal is required")
|
||||
}
|
||||
raw := strings.TrimSpace(c.Query("domain"))
|
||||
if raw == "" {
|
||||
return zip.ErrBadRequest("domain is required (comma-separate for multiple)")
|
||||
}
|
||||
var names []string
|
||||
for _, n := range strings.Split(raw, ",") {
|
||||
if n = strings.ToLower(strings.TrimSpace(n)); n != "" {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
quotes, err := s.State.svc.Availability(c.Context(), names...)
|
||||
if err != nil {
|
||||
return statusErr(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{"results": quotes})
|
||||
}
|
||||
|
||||
func listHandler(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
o, ok := org(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("a validated principal is required")
|
||||
}
|
||||
recs, err := s.State.svc.ListByOrg(o)
|
||||
if err != nil {
|
||||
return statusErr(err)
|
||||
}
|
||||
if recs == nil {
|
||||
recs = []Record{}
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{"domains": recs})
|
||||
}
|
||||
|
||||
type registerReq struct {
|
||||
Domain string `json:"domain"`
|
||||
Years int `json:"years"`
|
||||
Contacts *namecom.Contacts `json:"contacts,omitempty"`
|
||||
}
|
||||
|
||||
func registerHandler(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
o, ok := org(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("a validated principal is required")
|
||||
}
|
||||
var body registerReq
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(body.Domain) == "" {
|
||||
return zip.ErrBadRequest("domain is required")
|
||||
}
|
||||
res, err := s.State.svc.Register(c.Context(), o, body.Domain, body.Years, body.Contacts)
|
||||
if err != nil {
|
||||
return statusErr(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, res)
|
||||
}
|
||||
|
||||
type renewReq struct {
|
||||
Domain string `json:"domain"`
|
||||
Years int `json:"years"`
|
||||
}
|
||||
|
||||
func renewHandler(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
o, ok := org(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("a validated principal is required")
|
||||
}
|
||||
var body renewReq
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(body.Domain) == "" {
|
||||
return zip.ErrBadRequest("domain is required")
|
||||
}
|
||||
res, err := s.State.svc.Renew(c.Context(), o, body.Domain, body.Years)
|
||||
if err != nil {
|
||||
return statusErr(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, res)
|
||||
}
|
||||
|
||||
type transferReq struct {
|
||||
Domain string `json:"domain"`
|
||||
AuthCode string `json:"authCode"`
|
||||
Years int `json:"years"`
|
||||
}
|
||||
|
||||
func transferHandler(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
o, ok := org(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("a validated principal is required")
|
||||
}
|
||||
var body transferReq
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(body.Domain) == "" || strings.TrimSpace(body.AuthCode) == "" {
|
||||
return zip.ErrBadRequest("domain and authCode are required")
|
||||
}
|
||||
res, err := s.State.svc.Transfer(c.Context(), o, body.Domain, body.AuthCode, body.Years)
|
||||
if err != nil {
|
||||
return statusErr(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, res)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Package namecom is a minimal client for the name.com Core API v4 — the
|
||||
// wholesale registrar surface Hanzo Domains resells: check availability, price,
|
||||
// register, renew, transfer, and set nameservers/contacts on a domain.
|
||||
//
|
||||
// Contract (name.com Core API v4):
|
||||
// - Base URL: https://api.name.com (production), https://api.dev.name.com (test).
|
||||
// - Auth: HTTP Basic — username + API token.
|
||||
// - Actions use a ":verb" suffix on the collection/resource, e.g.
|
||||
// POST /v4/domains:checkAvailability, POST /v4/domains/{domain}:setNameservers.
|
||||
//
|
||||
// Credentials are NEVER hard-coded here: the caller passes the username/token it
|
||||
// read from the platform secret store (KMS), exactly as the Cloudflare purger reads
|
||||
// CF_API_TOKEN. This package holds no secret custody of its own.
|
||||
package namecom
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Base URLs for the two name.com environments. Test is a full sandbox connected to
|
||||
// the registries' own test systems — register/renew/transfer are exercisable there
|
||||
// with no real charge.
|
||||
const (
|
||||
BaseProd = "https://api.name.com"
|
||||
BaseTest = "https://api.dev.name.com"
|
||||
)
|
||||
|
||||
// BaseFor maps an environment slug to its base URL. Anything other than "prod"/
|
||||
// "production"/"mainnet" resolves to the TEST sandbox — fail-safe: an unset or
|
||||
// misspelled env can never accidentally hit the live, billable registrar.
|
||||
func BaseFor(env string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(env)) {
|
||||
case "prod", "production", "mainnet", "live":
|
||||
return BaseProd
|
||||
default:
|
||||
return BaseTest
|
||||
}
|
||||
}
|
||||
|
||||
// Client calls the name.com v4 API with HTTP Basic auth. It is safe to share across
|
||||
// goroutines. A zero token/user yields a client whose calls fail closed at name.com
|
||||
// (401/403) rather than panicking — the caller checks Configured() to degrade early.
|
||||
type Client struct {
|
||||
user string
|
||||
token string
|
||||
base string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// New builds a client for (user, token) against the base URL for env. httpClient is
|
||||
// optional (nil ⇒ a 20s-timeout default).
|
||||
func New(user, token, env string, httpClient *http.Client) *Client {
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: 20 * time.Second}
|
||||
}
|
||||
return &Client{
|
||||
user: strings.TrimSpace(user),
|
||||
token: strings.TrimSpace(token),
|
||||
base: BaseFor(env),
|
||||
http: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
// NewWithBase is New with an explicit base URL — used by tests to point at an
|
||||
// httptest server.
|
||||
func NewWithBase(user, token, base string, httpClient *http.Client) *Client {
|
||||
c := New(user, token, "", httpClient)
|
||||
c.base = strings.TrimRight(base, "/")
|
||||
return c
|
||||
}
|
||||
|
||||
// Configured reports whether both a username and a token are present, i.e. whether
|
||||
// a call has any chance of authenticating.
|
||||
func (c *Client) Configured() bool { return c.user != "" && c.token != "" }
|
||||
|
||||
// APIError is a non-2xx response from name.com. name.com returns
|
||||
// {"message":"...","details":"..."} on error; both are surfaced.
|
||||
type APIError struct {
|
||||
Status int `json:"-"`
|
||||
Message string `json:"message"`
|
||||
Details string `json:"details"`
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
if e.Details != "" {
|
||||
return fmt.Sprintf("name.com %d: %s (%s)", e.Status, e.Message, e.Details)
|
||||
}
|
||||
return fmt.Sprintf("name.com %d: %s", e.Status, e.Message)
|
||||
}
|
||||
|
||||
// do issues one request. method+path are the HTTP verb and the "/v4/..." path
|
||||
// (including any ":verb" action suffix); body is JSON-marshaled when non-nil; out
|
||||
// is JSON-unmarshaled from a 2xx response when non-nil.
|
||||
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("namecom: marshal request: %w", err)
|
||||
}
|
||||
rdr = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.base+path, rdr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("namecom: build request: %w", err)
|
||||
}
|
||||
req.SetBasicAuth(c.user, c.token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("namecom: %s %s: %w", method, path, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
|
||||
if resp.StatusCode/100 != 2 {
|
||||
apiErr := &APIError{Status: resp.StatusCode}
|
||||
_ = json.Unmarshal(raw, apiErr)
|
||||
if apiErr.Message == "" {
|
||||
apiErr.Message = strings.TrimSpace(string(raw))
|
||||
if apiErr.Message == "" {
|
||||
apiErr.Message = http.StatusText(resp.StatusCode)
|
||||
}
|
||||
}
|
||||
return apiErr
|
||||
}
|
||||
if out != nil && len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, out); err != nil {
|
||||
return fmt.Errorf("namecom: decode response: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package namecom
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mockServer stands in for name.com v4: it asserts Basic auth + records the path/body
|
||||
// of each request, and replies with the canned JSON the handler registers.
|
||||
func mockServer(t *testing.T, wantUser, wantToken string, routes map[string]func(*http.Request) (int, any)) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u, p, ok := r.BasicAuth()
|
||||
if !ok || u != wantUser || p != wantToken {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_ = json.NewEncoder(w).Encode(APIError{Message: "Permission Denied"})
|
||||
return
|
||||
}
|
||||
key := r.Method + " " + r.URL.Path
|
||||
h, ok := routes[key]
|
||||
if !ok {
|
||||
t.Errorf("unexpected request %s", key)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_ = json.NewEncoder(w).Encode(APIError{Message: "route not mocked: " + key})
|
||||
return
|
||||
}
|
||||
code, body := h(r)
|
||||
w.WriteHeader(code)
|
||||
if body != nil {
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func TestBaseFor(t *testing.T) {
|
||||
if got := BaseFor("prod"); got != BaseProd {
|
||||
t.Fatalf("prod → %s, want %s", got, BaseProd)
|
||||
}
|
||||
// Anything non-prod must resolve to the sandbox — an unset env can never hit live.
|
||||
for _, env := range []string{"", "test", "dev", "testnet", "typo", "TEST"} {
|
||||
if got := BaseFor(env); got != BaseTest {
|
||||
t.Fatalf("env %q → %s, want sandbox %s", env, got, BaseTest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAvailability(t *testing.T) {
|
||||
srv := mockServer(t, "u", "tok", map[string]func(*http.Request) (int, any){
|
||||
"POST /v4/domains:checkAvailability": func(r *http.Request) (int, any) {
|
||||
var req AvailabilityRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if len(req.DomainNames) != 1 || req.DomainNames[0] != "acme.ai" {
|
||||
t.Errorf("body domainNames = %v", req.DomainNames)
|
||||
}
|
||||
return 200, SearchResponse{Results: []SearchResult{{
|
||||
DomainName: "acme.ai", Purchasable: true, PurchasePrice: 55.99, RenewalPrice: 55.99, TLD: "ai",
|
||||
}}}
|
||||
},
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
c := NewWithBase("u", "tok", srv.URL, nil)
|
||||
res, err := c.CheckAvailability(context.Background(), "acme.ai")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Results) != 1 || !res.Results[0].Purchasable || res.Results[0].PurchasePrice != 55.99 {
|
||||
t.Fatalf("unexpected result: %+v", res.Results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthFailureSurfacesAPIError(t *testing.T) {
|
||||
srv := mockServer(t, "right", "right", nil)
|
||||
defer srv.Close()
|
||||
c := NewWithBase("wrong", "creds", srv.URL, nil)
|
||||
_, err := c.Hello(context.Background())
|
||||
apiErr, ok := err.(*APIError)
|
||||
if !ok {
|
||||
t.Fatalf("want *APIError, got %T: %v", err, err)
|
||||
}
|
||||
if apiErr.Status != http.StatusUnauthorized || apiErr.Message != "Permission Denied" {
|
||||
t.Fatalf("unexpected APIError: %+v", apiErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDomainSendsPriceCapAndYears(t *testing.T) {
|
||||
srv := mockServer(t, "u", "tok", map[string]func(*http.Request) (int, any){
|
||||
"POST /v4/domains": func(r *http.Request) (int, any) {
|
||||
var req CreateDomainRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.Domain.DomainName != "acme.ai" {
|
||||
t.Errorf("domainName = %q", req.Domain.DomainName)
|
||||
}
|
||||
if req.PurchasePrice != 55.99 {
|
||||
t.Errorf("purchasePrice cap = %v, want 55.99", req.PurchasePrice)
|
||||
}
|
||||
if req.Years != 1 {
|
||||
t.Errorf("years = %d, want default 1", req.Years)
|
||||
}
|
||||
if len(req.Domain.Nameservers) != 2 {
|
||||
t.Errorf("nameservers = %v", req.Domain.Nameservers)
|
||||
}
|
||||
return 200, CreateDomainResponse{
|
||||
Domain: &Domain{DomainName: "acme.ai", Nameservers: req.Domain.Nameservers},
|
||||
Order: 42,
|
||||
TotalPaid: 55.99,
|
||||
}
|
||||
},
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
c := NewWithBase("u", "tok", srv.URL, nil)
|
||||
res, err := c.CreateDomain(context.Background(), CreateDomainRequest{
|
||||
Domain: DomainInput{DomainName: "acme.ai", Nameservers: []string{"ns1.hanzo.ai", "ns2.hanzo.ai"}},
|
||||
PurchasePrice: 55.99,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.TotalPaid != 55.99 || res.Order != 42 {
|
||||
t.Fatalf("unexpected create response: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetNameserversPathAndBody(t *testing.T) {
|
||||
srv := mockServer(t, "u", "tok", map[string]func(*http.Request) (int, any){
|
||||
"POST /v4/domains/acme.ai:setNameservers": func(r *http.Request) (int, any) {
|
||||
var req SetNameserversRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if len(req.Nameservers) != 2 || req.Nameservers[0] != "ns1.hanzo.ai" {
|
||||
t.Errorf("nameservers = %v", req.Nameservers)
|
||||
}
|
||||
return 200, Domain{DomainName: "acme.ai", Nameservers: req.Nameservers}
|
||||
},
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
c := NewWithBase("u", "tok", srv.URL, nil)
|
||||
d, err := c.SetNameservers(context.Background(), "acme.ai", []string{"ns1.hanzo.ai", "ns2.hanzo.ai"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(d.Nameservers) != 2 {
|
||||
t.Fatalf("unexpected: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewDefaultsYears(t *testing.T) {
|
||||
srv := mockServer(t, "u", "tok", map[string]func(*http.Request) (int, any){
|
||||
"POST /v4/domains/acme.ai:renew": func(r *http.Request) (int, any) {
|
||||
var req RenewDomainRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.Years != 1 {
|
||||
t.Errorf("years = %d, want 1", req.Years)
|
||||
}
|
||||
return 200, RenewDomainResponse{Domain: &Domain{DomainName: "acme.ai"}, TotalPaid: 55.99}
|
||||
},
|
||||
})
|
||||
defer srv.Close()
|
||||
c := NewWithBase("u", "tok", srv.URL, nil)
|
||||
if _, err := c.RenewDomain(context.Background(), "acme.ai", RenewDomainRequest{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package namecom
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// Hello probes auth + connectivity: GET /v4/hello. A 2xx means the credentials are
|
||||
// accepted and API access is enabled for the account; a 403 "Permission Denied" means
|
||||
// the token is IP-locked or the account lacks API/reseller access.
|
||||
func (c *Client) Hello(ctx context.Context) (*HelloResponse, error) {
|
||||
var out HelloResponse
|
||||
if err := c.do(ctx, "GET", "/v4/hello", nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// CheckAvailability checks one or more exact domain names: POST /v4/domains:checkAvailability.
|
||||
// Each result carries purchasable + the wholesale first-term and renewal prices (USD).
|
||||
func (c *Client) CheckAvailability(ctx context.Context, names ...string) (*SearchResponse, error) {
|
||||
var out SearchResponse
|
||||
if err := c.do(ctx, "POST", "/v4/domains:checkAvailability",
|
||||
AvailabilityRequest{DomainNames: names}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// Search runs a keyword search that also suggests alternate TLDs:
|
||||
// POST /v4/domains:search. tldFilter (optional) narrows the returned TLDs.
|
||||
func (c *Client) Search(ctx context.Context, keyword string, tldFilter ...string) (*SearchResponse, error) {
|
||||
var out SearchResponse
|
||||
req := SearchRequest{Keyword: keyword}
|
||||
if len(tldFilter) > 0 {
|
||||
req.TLDFilter = tldFilter
|
||||
}
|
||||
if err := c.do(ctx, "POST", "/v4/domains:search", req, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// GetDomain reads one domain the reseller owns: GET /v4/domains/{domain}.
|
||||
func (c *Client) GetDomain(ctx context.Context, domain string) (*Domain, error) {
|
||||
var out Domain
|
||||
if err := c.do(ctx, "GET", "/v4/domains/"+url.PathEscape(domain), nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// ListDomains lists the domains the reseller owns: GET /v4/domains.
|
||||
func (c *Client) ListDomains(ctx context.Context) (*ListDomainsResponse, error) {
|
||||
var out ListDomainsResponse
|
||||
if err := c.do(ctx, "GET", "/v4/domains", nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// CreateDomain registers a domain: POST /v4/domains. This DEBITS the reseller account
|
||||
// at name.com by the wholesale price. purchasePrice caps the accepted charge (a price
|
||||
// change above it is rejected). years defaults to 1.
|
||||
func (c *Client) CreateDomain(ctx context.Context, req CreateDomainRequest) (*CreateDomainResponse, error) {
|
||||
if req.Years <= 0 {
|
||||
req.Years = 1
|
||||
}
|
||||
var out CreateDomainResponse
|
||||
if err := c.do(ctx, "POST", "/v4/domains", req, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// RenewDomain renews a registered domain: POST /v4/domains/{domain}:renew.
|
||||
func (c *Client) RenewDomain(ctx context.Context, domain string, req RenewDomainRequest) (*RenewDomainResponse, error) {
|
||||
if req.Years <= 0 {
|
||||
req.Years = 1
|
||||
}
|
||||
var out RenewDomainResponse
|
||||
if err := c.do(ctx, "POST", "/v4/domains/"+url.PathEscape(domain)+":renew", req, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// SetNameservers points a registered domain at the given authoritative nameservers:
|
||||
// POST /v4/domains/{domain}:setNameservers. This is the step that hands DNS control
|
||||
// to Hanzo's own nameservers after registration.
|
||||
func (c *Client) SetNameservers(ctx context.Context, domain string, nameservers []string) (*Domain, error) {
|
||||
var out Domain
|
||||
if err := c.do(ctx, "POST", "/v4/domains/"+url.PathEscape(domain)+":setNameservers",
|
||||
SetNameserversRequest{Nameservers: nameservers}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// SetContacts updates the WHOIS contact set: POST /v4/domains/{domain}:setContacts.
|
||||
func (c *Client) SetContacts(ctx context.Context, domain string, contacts Contacts) (*Domain, error) {
|
||||
var out Domain
|
||||
if err := c.do(ctx, "POST", "/v4/domains/"+url.PathEscape(domain)+":setContacts",
|
||||
SetContactsRequest{Contacts: contacts}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// CreateTransfer starts a transfer-in of a domain the customer owns elsewhere:
|
||||
// POST /v4/transfers. authCode is the EPP/auth code from the losing registrar.
|
||||
func (c *Client) CreateTransfer(ctx context.Context, req TransferRequest) (*TransferResponse, error) {
|
||||
if req.Years <= 0 {
|
||||
req.Years = 1
|
||||
}
|
||||
var out TransferResponse
|
||||
if err := c.do(ctx, "POST", "/v4/transfers", req, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package namecom
|
||||
|
||||
// The request/response shapes below mirror the name.com Core API v4 JSON. Only the
|
||||
// fields Hanzo Domains uses are modeled; unknown fields are ignored on decode.
|
||||
|
||||
// Contact is a WHOIS/registration contact. name.com requires registrant/admin/tech/
|
||||
// billing contacts on register; missing ones default to the reseller account.
|
||||
type Contact struct {
|
||||
FirstName string `json:"firstName,omitempty"`
|
||||
LastName string `json:"lastName,omitempty"`
|
||||
Company string `json:"companyName,omitempty"`
|
||||
Address1 string `json:"address1,omitempty"`
|
||||
Address2 string `json:"address2,omitempty"`
|
||||
City string `json:"city,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
Zip string `json:"zip,omitempty"`
|
||||
Country string `json:"country,omitempty"` // ISO-3166 alpha-2, e.g. "US"
|
||||
Phone string `json:"phone,omitempty"` // +NN.NNNNNNN
|
||||
Fax string `json:"fax,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
}
|
||||
|
||||
// Contacts is the four-role contact set for a domain.
|
||||
type Contacts struct {
|
||||
Registrant *Contact `json:"registrant,omitempty"`
|
||||
Admin *Contact `json:"admin,omitempty"`
|
||||
Tech *Contact `json:"tech,omitempty"`
|
||||
Billing *Contact `json:"billing,omitempty"`
|
||||
}
|
||||
|
||||
// Domain is a domain record as name.com returns it (get/create/renew/setNameservers).
|
||||
type Domain struct {
|
||||
DomainName string `json:"domainName"`
|
||||
Nameservers []string `json:"nameservers,omitempty"`
|
||||
Contacts *Contacts `json:"contacts,omitempty"`
|
||||
Locked bool `json:"locked,omitempty"`
|
||||
AutorenewOn bool `json:"autorenewEnabled,omitempty"`
|
||||
ExpireDate string `json:"expireDate,omitempty"` // RFC3339
|
||||
CreateDate string `json:"createDate,omitempty"` // RFC3339
|
||||
RenewalPrice float64 `json:"renewalPrice,omitempty"` // USD
|
||||
PrivacyOn bool `json:"privacyEnabled,omitempty"`
|
||||
}
|
||||
|
||||
// AvailabilityRequest is the body of POST /v4/domains:checkAvailability.
|
||||
type AvailabilityRequest struct {
|
||||
DomainNames []string `json:"domainNames"`
|
||||
}
|
||||
|
||||
// SearchRequest is the body of POST /v4/domains:search — a keyword search that also
|
||||
// suggests alternate TLDs. tldFilter narrows to specific TLDs (e.g. ["ai","com"]).
|
||||
type SearchRequest struct {
|
||||
Keyword string `json:"keyword"`
|
||||
TLDFilter []string `json:"tldFilter,omitempty"`
|
||||
Timeout int `json:"timeout,omitempty"` // ms; name.com caps the search
|
||||
}
|
||||
|
||||
// SearchResult is one candidate in a search / availability response. Prices are USD.
|
||||
type SearchResult struct {
|
||||
DomainName string `json:"domainName"`
|
||||
SLD string `json:"sld,omitempty"`
|
||||
TLD string `json:"tld,omitempty"`
|
||||
Purchasable bool `json:"purchasable"`
|
||||
Premium bool `json:"premium,omitempty"`
|
||||
PurchasePrice float64 `json:"purchasePrice,omitempty"` // first-term registration, USD
|
||||
PurchaseType string `json:"purchaseType,omitempty"` // "registration" | "renewal" | ...
|
||||
RenewalPrice float64 `json:"renewalPrice,omitempty"` // USD
|
||||
Transferable bool `json:"transferable,omitempty"`
|
||||
}
|
||||
|
||||
// SearchResponse wraps the results for both search and checkAvailability.
|
||||
type SearchResponse struct {
|
||||
Results []SearchResult `json:"results"`
|
||||
}
|
||||
|
||||
// CreateDomainRequest is the body of POST /v4/domains (register). purchasePrice is
|
||||
// the price the caller EXPECTS to pay (the wholesale quote from availability); name.com
|
||||
// rejects a registration whose real price exceeds it — a guard against a price change
|
||||
// between quote and buy. years defaults to 1.
|
||||
type CreateDomainRequest struct {
|
||||
Domain DomainInput `json:"domain"`
|
||||
PurchasePrice float64 `json:"purchasePrice,omitempty"`
|
||||
Years int `json:"years,omitempty"`
|
||||
TLDRequirements map[string]string `json:"tldRequirements,omitempty"`
|
||||
}
|
||||
|
||||
// DomainInput is the domain sub-object of a create request.
|
||||
type DomainInput struct {
|
||||
DomainName string `json:"domainName"`
|
||||
Nameservers []string `json:"nameservers,omitempty"`
|
||||
Contacts *Contacts `json:"contacts,omitempty"`
|
||||
PrivacyOn bool `json:"privacyEnabled,omitempty"`
|
||||
}
|
||||
|
||||
// CreateDomainResponse is the register result: the created domain plus what name.com
|
||||
// actually charged the reseller account (order + totalPaid, USD).
|
||||
type CreateDomainResponse struct {
|
||||
Domain *Domain `json:"domain"`
|
||||
Order int64 `json:"order,omitempty"`
|
||||
TotalPaid float64 `json:"totalPaid,omitempty"`
|
||||
}
|
||||
|
||||
// RenewDomainRequest is the body of POST /v4/domains/{domain}:renew.
|
||||
type RenewDomainRequest struct {
|
||||
PurchasePrice float64 `json:"purchasePrice,omitempty"`
|
||||
Years int `json:"years,omitempty"`
|
||||
}
|
||||
|
||||
// RenewDomainResponse is the renew result.
|
||||
type RenewDomainResponse struct {
|
||||
Domain *Domain `json:"domain"`
|
||||
Order int64 `json:"order,omitempty"`
|
||||
TotalPaid float64 `json:"totalPaid,omitempty"`
|
||||
}
|
||||
|
||||
// SetNameserversRequest is the body of POST /v4/domains/{domain}:setNameservers —
|
||||
// this is how a registered domain is pointed at Hanzo's authoritative nameservers.
|
||||
type SetNameserversRequest struct {
|
||||
Nameservers []string `json:"nameservers"`
|
||||
}
|
||||
|
||||
// SetContactsRequest is the body of POST /v4/domains/{domain}:setContacts.
|
||||
type SetContactsRequest struct {
|
||||
Contacts Contacts `json:"contacts"`
|
||||
}
|
||||
|
||||
// ListDomainsResponse is the body of GET /v4/domains.
|
||||
type ListDomainsResponse struct {
|
||||
Domains []Domain `json:"domains"`
|
||||
NextPage int `json:"nextPage,omitempty"`
|
||||
}
|
||||
|
||||
// TransferRequest is the body of POST /v4/transfers (transfer a domain IN to Hanzo).
|
||||
type TransferRequest struct {
|
||||
DomainName string `json:"domainName"`
|
||||
AuthCode string `json:"authCode"`
|
||||
PurchasePrice float64 `json:"purchasePrice,omitempty"`
|
||||
Years int `json:"years,omitempty"`
|
||||
}
|
||||
|
||||
// Transfer is a transfer record as name.com returns it.
|
||||
type Transfer struct {
|
||||
DomainName string `json:"domainName"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// TransferResponse is the create-transfer result.
|
||||
type TransferResponse struct {
|
||||
Transfer *Transfer `json:"transfer"`
|
||||
Order int64 `json:"order,omitempty"`
|
||||
TotalPaid float64 `json:"totalPaid,omitempty"`
|
||||
}
|
||||
|
||||
// HelloResponse is the body of GET /v4/hello — the auth/health probe.
|
||||
type HelloResponse struct {
|
||||
ServerName string `json:"serverName,omitempty"`
|
||||
Motd string `json:"motd,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package domain
|
||||
|
||||
import "math"
|
||||
|
||||
// Markup turns a wholesale registrar cost into the price a customer pays. The
|
||||
// multiplier is applied, then a minimum absolute margin is enforced (so a cheap TLD
|
||||
// still clears fixed per-order cost), then the result is rounded UP to whole cents.
|
||||
//
|
||||
// This is the ONE place a margin is added, mirroring cloud/clients/pricing's
|
||||
// THIRD_PARTY_MARKUP multiplier — kept as data (Config) so it is tunable without code.
|
||||
type Markup struct {
|
||||
Multiplier float64 // e.g. 1.15 = +15% over wholesale; <1 is clamped to 1 (never sell below cost)
|
||||
MinMarginCents int64 // floor absolute margin over cost, e.g. 300 = at least $3
|
||||
}
|
||||
|
||||
// Sell returns the customer price in cents for a wholesale cost in cents. A
|
||||
// non-positive cost yields 0 (free / unpriced — the caller treats it as not
|
||||
// purchasable). The result is always ≥ cost (never sell below wholesale).
|
||||
func (m Markup) Sell(costCents int64) int64 {
|
||||
if costCents <= 0 {
|
||||
return 0
|
||||
}
|
||||
mult := m.Multiplier
|
||||
if mult < 1 {
|
||||
mult = 1
|
||||
}
|
||||
marked := int64(math.Ceil(float64(costCents) * mult))
|
||||
if marked-costCents < m.MinMarginCents {
|
||||
marked = costCents + m.MinMarginCents
|
||||
}
|
||||
if marked < costCents {
|
||||
marked = costCents
|
||||
}
|
||||
return marked
|
||||
}
|
||||
|
||||
// dollarsToCents converts a registrar USD price (a float) to integer cents, rounding
|
||||
// to the nearest cent. Registrar prices are exact to the cent, so this is lossless in
|
||||
// practice; rounding guards against float representation drift.
|
||||
func dollarsToCents(usd float64) int64 {
|
||||
if usd <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int64(math.Round(usd * 100))
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/domain/namecom"
|
||||
)
|
||||
|
||||
// Quote is a priced availability result: the wholesale cost and the customer sell
|
||||
// price (marked up), both in cents. It is what search/availability return and what a
|
||||
// register bills against.
|
||||
type Quote struct {
|
||||
Domain string `json:"domain"`
|
||||
Available bool `json:"available"`
|
||||
Premium bool `json:"premium,omitempty"`
|
||||
CostCents int64 `json:"-"` // wholesale (internal — not exposed to customers)
|
||||
PriceCents int64 `json:"priceCents"` // sell (first-term registration)
|
||||
RenewalPriceCents int64 `json:"renewalPriceCents"` // sell (renewal)
|
||||
Currency string `json:"currency"`
|
||||
TLD string `json:"tld,omitempty"`
|
||||
}
|
||||
|
||||
// quoteFrom prices a registrar search result through the markup.
|
||||
func (s *Service) quoteFrom(r namecom.SearchResult) Quote {
|
||||
cost := dollarsToCents(r.PurchasePrice)
|
||||
renewCost := dollarsToCents(r.RenewalPrice)
|
||||
return Quote{
|
||||
Domain: strings.ToLower(r.DomainName),
|
||||
Available: r.Purchasable,
|
||||
Premium: r.Premium,
|
||||
CostCents: cost,
|
||||
PriceCents: s.cfg.Markup.Sell(cost),
|
||||
RenewalPriceCents: s.cfg.Markup.Sell(renewCost),
|
||||
Currency: "usd",
|
||||
TLD: r.TLD,
|
||||
}
|
||||
}
|
||||
|
||||
// Availability checks exact names and returns priced quotes (availability + pricing).
|
||||
func (s *Service) Availability(ctx context.Context, names ...string) ([]Quote, error) {
|
||||
if !s.reg.Configured() {
|
||||
return nil, ErrNotConfigured
|
||||
}
|
||||
resp, err := s.reg.CheckAvailability(ctx, names...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.quotes(resp), nil
|
||||
}
|
||||
|
||||
// Search runs a keyword search (with alternate-TLD suggestions) and returns priced
|
||||
// quotes. tldFilter (optional) narrows the TLDs.
|
||||
func (s *Service) Search(ctx context.Context, keyword string, tldFilter ...string) ([]Quote, error) {
|
||||
if !s.reg.Configured() {
|
||||
return nil, ErrNotConfigured
|
||||
}
|
||||
resp, err := s.reg.Search(ctx, keyword, tldFilter...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.quotes(resp), nil
|
||||
}
|
||||
|
||||
func (s *Service) quotes(resp *namecom.SearchResponse) []Quote {
|
||||
out := make([]Quote, 0, len(resp.Results))
|
||||
for _, r := range resp.Results {
|
||||
out = append(out, s.quoteFrom(r))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RegisterResult is a successful purchase.
|
||||
type RegisterResult struct {
|
||||
Record Record `json:"record"`
|
||||
Quote Quote `json:"quote"`
|
||||
}
|
||||
|
||||
// Register buys a domain for org: quote → guard → authorize (deposit) → provision the
|
||||
// DNS zone → register at the registrar pointing at Hanzo nameservers → capture
|
||||
// (charge) → record ownership. The customer is charged ONLY after the registrar
|
||||
// confirms — a registrar failure leaves the balance untouched.
|
||||
//
|
||||
// contacts is optional; when nil the registrar uses the reseller account's default
|
||||
// WHOIS contacts. years defaults to 1.
|
||||
func (s *Service) Register(ctx context.Context, org, domainName string, years int, contacts *namecom.Contacts) (*RegisterResult, error) {
|
||||
if !s.reg.Configured() {
|
||||
return nil, ErrNotConfigured
|
||||
}
|
||||
domainName = strings.ToLower(strings.TrimSpace(domainName))
|
||||
if domainName == "" {
|
||||
return nil, fmt.Errorf("domain: name is required")
|
||||
}
|
||||
if years <= 0 {
|
||||
years = 1
|
||||
}
|
||||
|
||||
// Idempotency guard: never double-buy a name this org already holds.
|
||||
if _, owned, err := s.store.Get(org, domainName); err != nil {
|
||||
return nil, err
|
||||
} else if owned {
|
||||
return nil, ErrAlreadyOwned
|
||||
}
|
||||
|
||||
// 1. Quote — must be purchasable + priced.
|
||||
quotes, err := s.Availability(ctx, domainName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(quotes) == 0 {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
q := quotes[0]
|
||||
if !q.Available || q.PriceCents <= 0 {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
|
||||
// 2. Authorize the customer charge BEFORE spending at the registrar — refuse if the
|
||||
// prepaid balance can't cover it, so we never buy a domain we can't bill for.
|
||||
if err := s.bill.Authorize(ctx, org, q.PriceCents); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3. Provision the authoritative zone in hanzoai/dns and learn which nameservers to
|
||||
// point the domain at. Best-effort: if the zone service is down we still register
|
||||
// against the fixed Hanzo nameservers (the zone reconciles later) rather than
|
||||
// block the purchase.
|
||||
ns, zoneErr := s.zones.EnsureZone(ctx, org, domainName)
|
||||
if zoneErr != nil || len(ns) == 0 {
|
||||
ns = s.cfg.Nameservers
|
||||
}
|
||||
|
||||
// 4. Register at the registrar, born pointing at Hanzo's nameservers. purchasePrice
|
||||
// caps the wholesale charge at the quoted cost (a price change above it is
|
||||
// rejected by the registrar). This is the step that debits Hanzo's reseller
|
||||
// account; the customer is not charged yet.
|
||||
created, err := s.reg.CreateDomain(ctx, namecom.CreateDomainRequest{
|
||||
Domain: namecom.DomainInput{
|
||||
DomainName: domainName,
|
||||
Nameservers: ns,
|
||||
Contacts: contacts,
|
||||
},
|
||||
PurchasePrice: float64(q.CostCents) / 100,
|
||||
Years: years,
|
||||
})
|
||||
if err != nil {
|
||||
// The registrar rejected/failed — the customer was NOT charged (no Capture).
|
||||
return nil, fmt.Errorf("domain: registrar create failed: %w", err)
|
||||
}
|
||||
|
||||
// 5. Capture — charge the customer's prepaid wallet now that the domain is theirs.
|
||||
ref := "domain:register:" + domainName
|
||||
s.bill.Capture(org, q.PriceCents, ref)
|
||||
|
||||
// 6. Record ownership.
|
||||
rec := Record{
|
||||
Org: org,
|
||||
Domain: domainName,
|
||||
RegisteredAt: time.Now().Unix(),
|
||||
PriceCents: q.PriceCents,
|
||||
CostCents: q.CostCents,
|
||||
Nameservers: ns,
|
||||
}
|
||||
if created.Domain != nil {
|
||||
rec.ExpiresAt = created.Domain.ExpireDate
|
||||
if len(created.Domain.Nameservers) > 0 {
|
||||
rec.Nameservers = created.Domain.Nameservers
|
||||
}
|
||||
}
|
||||
rec.Order = created.Order
|
||||
if err := s.store.Put(rec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &RegisterResult{Record: rec, Quote: q}, nil
|
||||
}
|
||||
|
||||
// RenewResult is a successful renewal.
|
||||
type RenewResult struct {
|
||||
Record Record `json:"record"`
|
||||
PaidCents int64 `json:"paidCents"`
|
||||
}
|
||||
|
||||
// Renew extends a domain this org owns: guard ownership → quote renewal → authorize →
|
||||
// renew at the registrar → capture → update the record's expiry.
|
||||
func (s *Service) Renew(ctx context.Context, org, domainName string, years int) (*RenewResult, error) {
|
||||
if !s.reg.Configured() {
|
||||
return nil, ErrNotConfigured
|
||||
}
|
||||
domainName = strings.ToLower(strings.TrimSpace(domainName))
|
||||
if years <= 0 {
|
||||
years = 1
|
||||
}
|
||||
rec, owned, err := s.store.Get(org, domainName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !owned {
|
||||
return nil, ErrNotOwned
|
||||
}
|
||||
|
||||
// Quote the renewal (re-check gives the current renewal price).
|
||||
quotes, err := s.Availability(ctx, domainName)
|
||||
var priceCents, costCents int64
|
||||
if err == nil && len(quotes) > 0 {
|
||||
priceCents = quotes[0].RenewalPriceCents
|
||||
costCents = renewalCostFrom(quotes[0])
|
||||
}
|
||||
if priceCents <= 0 {
|
||||
// Fall back to what the customer originally paid so a renewal is never free.
|
||||
priceCents = rec.PriceCents
|
||||
costCents = rec.CostCents
|
||||
}
|
||||
|
||||
if err := s.bill.Authorize(ctx, org, priceCents); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
renewed, err := s.reg.RenewDomain(ctx, domainName, namecom.RenewDomainRequest{
|
||||
PurchasePrice: float64(costCents) / 100,
|
||||
Years: years,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("domain: registrar renew failed: %w", err)
|
||||
}
|
||||
s.bill.Capture(org, priceCents, "domain:renew:"+domainName)
|
||||
|
||||
if renewed.Domain != nil && renewed.Domain.ExpireDate != "" {
|
||||
rec.ExpiresAt = renewed.Domain.ExpireDate
|
||||
}
|
||||
if err := s.store.Put(rec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &RenewResult{Record: rec, PaidCents: priceCents}, nil
|
||||
}
|
||||
|
||||
// renewalCostFrom derives the wholesale renewal cost (cents) for a quote. The renewal
|
||||
// SELL price already passed through markup; recover a cost floor from it so the
|
||||
// registrar price cap is set sanely (never below the known wholesale).
|
||||
func renewalCostFrom(q Quote) int64 {
|
||||
if q.CostCents > 0 {
|
||||
return q.CostCents
|
||||
}
|
||||
return q.RenewalPriceCents
|
||||
}
|
||||
|
||||
// Transfer starts an inbound transfer of a domain the customer owns elsewhere: quote
|
||||
// (the transfer price is the registration price) → authorize → create the transfer →
|
||||
// capture → record. authCode is the EPP auth code from the losing registrar.
|
||||
func (s *Service) Transfer(ctx context.Context, org, domainName, authCode string, years int) (*RegisterResult, error) {
|
||||
if !s.reg.Configured() {
|
||||
return nil, ErrNotConfigured
|
||||
}
|
||||
domainName = strings.ToLower(strings.TrimSpace(domainName))
|
||||
if strings.TrimSpace(authCode) == "" {
|
||||
return nil, fmt.Errorf("domain: transfer requires an auth code")
|
||||
}
|
||||
if years <= 0 {
|
||||
years = 1
|
||||
}
|
||||
quotes, err := s.Availability(ctx, domainName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := Quote{Currency: "usd", Domain: domainName}
|
||||
if len(quotes) > 0 {
|
||||
q = quotes[0]
|
||||
}
|
||||
if q.PriceCents <= 0 {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if err := s.bill.Authorize(ctx, org, q.PriceCents); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := s.reg.CreateTransfer(ctx, namecom.TransferRequest{
|
||||
DomainName: domainName,
|
||||
AuthCode: authCode,
|
||||
PurchasePrice: float64(q.CostCents) / 100,
|
||||
Years: years,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("domain: registrar transfer failed: %w", err)
|
||||
}
|
||||
s.bill.Capture(org, q.PriceCents, "domain:transfer:"+domainName)
|
||||
|
||||
rec := Record{
|
||||
Org: org,
|
||||
Domain: domainName,
|
||||
RegisteredAt: time.Now().Unix(),
|
||||
PriceCents: q.PriceCents,
|
||||
CostCents: q.CostCents,
|
||||
Nameservers: s.cfg.Nameservers,
|
||||
Order: res.Order,
|
||||
}
|
||||
if err := s.store.Put(rec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &RegisterResult{Record: rec, Quote: q}, nil
|
||||
}
|
||||
|
||||
// ListByOrg returns the domains an org holds.
|
||||
func (s *Service) ListByOrg(org string) ([]Record, error) { return s.store.ListByOrg(org) }
|
||||
@@ -0,0 +1,307 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/domain/namecom"
|
||||
)
|
||||
|
||||
// --- mock backends ---------------------------------------------------------------
|
||||
|
||||
type mockReg struct {
|
||||
avail map[string]namecom.SearchResult
|
||||
created []namecom.CreateDomainRequest
|
||||
createErr error
|
||||
renewed []string
|
||||
configured bool
|
||||
}
|
||||
|
||||
func (m *mockReg) Configured() bool { return m.configured }
|
||||
func (m *mockReg) CheckAvailability(_ context.Context, names ...string) (*namecom.SearchResponse, error) {
|
||||
var out namecom.SearchResponse
|
||||
for _, n := range names {
|
||||
if r, ok := m.avail[n]; ok {
|
||||
out.Results = append(out.Results, r)
|
||||
} else {
|
||||
out.Results = append(out.Results, namecom.SearchResult{DomainName: n, Purchasable: false})
|
||||
}
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
func (m *mockReg) Search(_ context.Context, _ string, _ ...string) (*namecom.SearchResponse, error) {
|
||||
out := &namecom.SearchResponse{}
|
||||
for _, r := range m.avail {
|
||||
out.Results = append(out.Results, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (m *mockReg) CreateDomain(_ context.Context, req namecom.CreateDomainRequest) (*namecom.CreateDomainResponse, error) {
|
||||
if m.createErr != nil {
|
||||
return nil, m.createErr
|
||||
}
|
||||
m.created = append(m.created, req)
|
||||
return &namecom.CreateDomainResponse{
|
||||
Domain: &namecom.Domain{DomainName: req.Domain.DomainName, Nameservers: req.Domain.Nameservers, ExpireDate: "2027-01-01T00:00:00Z"},
|
||||
Order: 1001,
|
||||
TotalPaid: req.PurchasePrice,
|
||||
}, nil
|
||||
}
|
||||
func (m *mockReg) RenewDomain(_ context.Context, d string, _ namecom.RenewDomainRequest) (*namecom.RenewDomainResponse, error) {
|
||||
m.renewed = append(m.renewed, d)
|
||||
return &namecom.RenewDomainResponse{Domain: &namecom.Domain{DomainName: d, ExpireDate: "2028-01-01T00:00:00Z"}}, nil
|
||||
}
|
||||
func (m *mockReg) SetNameservers(_ context.Context, d string, ns []string) (*namecom.Domain, error) {
|
||||
return &namecom.Domain{DomainName: d, Nameservers: ns}, nil
|
||||
}
|
||||
func (m *mockReg) CreateTransfer(_ context.Context, req namecom.TransferRequest) (*namecom.TransferResponse, error) {
|
||||
return &namecom.TransferResponse{Transfer: &namecom.Transfer{DomainName: req.DomainName, Status: "pending"}, Order: 2002}, nil
|
||||
}
|
||||
func (m *mockReg) Hello(_ context.Context) (*namecom.HelloResponse, error) {
|
||||
return &namecom.HelloResponse{Username: "test"}, nil
|
||||
}
|
||||
|
||||
// mockBill records authorize/capture and can refuse.
|
||||
type mockBill struct {
|
||||
balance int64 // cents; -1 = unlimited
|
||||
captured []capture
|
||||
authCalls int
|
||||
}
|
||||
type capture struct {
|
||||
org string
|
||||
cents int64
|
||||
ref string
|
||||
}
|
||||
|
||||
func (b *mockBill) Authorize(_ context.Context, _ string, cents int64) error {
|
||||
b.authCalls++
|
||||
if b.balance >= 0 && cents > b.balance {
|
||||
return ErrInsufficientFunds
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (b *mockBill) Capture(org string, cents int64, ref string) {
|
||||
b.captured = append(b.captured, capture{org, cents, ref})
|
||||
if b.balance >= 0 {
|
||||
b.balance -= cents
|
||||
}
|
||||
}
|
||||
|
||||
// mockZones returns fixed nameservers (or an error to exercise the fallback).
|
||||
type mockZones struct {
|
||||
ns []string
|
||||
err error
|
||||
}
|
||||
|
||||
func (z *mockZones) EnsureZone(_ context.Context, _, _ string) ([]string, error) {
|
||||
return z.ns, z.err
|
||||
}
|
||||
|
||||
// --- helpers ---------------------------------------------------------------------
|
||||
|
||||
func newSvc(reg *mockReg, bill *mockBill, zones *mockZones) *Service {
|
||||
return NewService(reg, bill, zones, NewMemStore(), Config{
|
||||
Markup: Markup{Multiplier: 1.15, MinMarginCents: 300},
|
||||
Nameservers: []string{"ns1.hanzo.ai", "ns2.hanzo.ai"},
|
||||
Env: "test",
|
||||
})
|
||||
}
|
||||
|
||||
// --- tests -----------------------------------------------------------------------
|
||||
|
||||
func TestMarkupSell(t *testing.T) {
|
||||
m := Markup{Multiplier: 1.15, MinMarginCents: 300}
|
||||
// 15% over $10.00 = $11.50 (min margin $1.50 < $3 → floor to cost+$3 = $13.00)
|
||||
if got := m.Sell(1000); got != 1300 {
|
||||
t.Fatalf("Sell(1000) = %d, want 1300 (min-margin floor)", got)
|
||||
}
|
||||
// 15% over $50.00 = $57.50; margin $7.50 > $3 → $57.50
|
||||
if got := m.Sell(5000); got != 5750 {
|
||||
t.Fatalf("Sell(5000) = %d, want 5750", got)
|
||||
}
|
||||
// never below cost
|
||||
if got := (Markup{Multiplier: 0.5}).Sell(1000); got != 1000 {
|
||||
t.Fatalf("Sell below cost = %d, want 1000", got)
|
||||
}
|
||||
if got := m.Sell(0); got != 0 {
|
||||
t.Fatalf("Sell(0) = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailabilityPricesWithMarkup(t *testing.T) {
|
||||
reg := &mockReg{configured: true, avail: map[string]namecom.SearchResult{
|
||||
"acme.ai": {DomainName: "acme.ai", Purchasable: true, PurchasePrice: 55.99, RenewalPrice: 55.99, TLD: "ai"},
|
||||
}}
|
||||
svc := newSvc(reg, &mockBill{balance: -1}, &mockZones{ns: []string{"ns1.hanzo.ai"}})
|
||||
qs, err := svc.Availability(context.Background(), "acme.ai")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(qs) != 1 || !qs[0].Available {
|
||||
t.Fatalf("unexpected quotes: %+v", qs)
|
||||
}
|
||||
// cost 5599; sell = ceil(5599*1.15)=6439 (margin 840 > 300)
|
||||
if qs[0].CostCents != 5599 || qs[0].PriceCents != 6439 {
|
||||
t.Fatalf("pricing: cost=%d price=%d, want 5599/6439", qs[0].CostCents, qs[0].PriceCents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterHappyPath_BillsAndPointsNameservers(t *testing.T) {
|
||||
reg := &mockReg{configured: true, avail: map[string]namecom.SearchResult{
|
||||
"acme.ai": {DomainName: "acme.ai", Purchasable: true, PurchasePrice: 55.99, RenewalPrice: 55.99, TLD: "ai"},
|
||||
}}
|
||||
bill := &mockBill{balance: 100000} // $1000
|
||||
zones := &mockZones{ns: []string{"ns1.hanzo.ai", "ns2.hanzo.ai"}}
|
||||
svc := newSvc(reg, bill, zones)
|
||||
|
||||
res, err := svc.Register(context.Background(), "acme", "acme.ai", 1, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Registrar got a create with Hanzo nameservers + the wholesale price cap.
|
||||
if len(reg.created) != 1 {
|
||||
t.Fatalf("expected 1 create, got %d", len(reg.created))
|
||||
}
|
||||
c := reg.created[0]
|
||||
if len(c.Domain.Nameservers) != 2 || c.Domain.Nameservers[0] != "ns1.hanzo.ai" {
|
||||
t.Fatalf("domain not born on Hanzo nameservers: %v", c.Domain.Nameservers)
|
||||
}
|
||||
if c.PurchasePrice != 55.99 {
|
||||
t.Fatalf("wholesale price cap = %v, want 55.99", c.PurchasePrice)
|
||||
}
|
||||
// Customer charged the SELL price exactly once, after the registrar succeeded.
|
||||
if len(bill.captured) != 1 || bill.captured[0].cents != 6439 || bill.captured[0].ref != "domain:register:acme.ai" {
|
||||
t.Fatalf("unexpected capture: %+v", bill.captured)
|
||||
}
|
||||
if res.Record.Order != 1001 || res.Record.ExpiresAt != "2027-01-01T00:00:00Z" {
|
||||
t.Fatalf("record not populated from registrar: %+v", res.Record)
|
||||
}
|
||||
// Ownership recorded.
|
||||
owned, _ := svc.ListByOrg("acme")
|
||||
if len(owned) != 1 || owned[0].Domain != "acme.ai" {
|
||||
t.Fatalf("ownership not recorded: %+v", owned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRefusedWhenInsufficientBalance_NoRegistrarCall(t *testing.T) {
|
||||
reg := &mockReg{configured: true, avail: map[string]namecom.SearchResult{
|
||||
"acme.ai": {DomainName: "acme.ai", Purchasable: true, PurchasePrice: 55.99},
|
||||
}}
|
||||
bill := &mockBill{balance: 100} // $1.00 — nowhere near $64.39
|
||||
svc := newSvc(reg, bill, &mockZones{})
|
||||
|
||||
_, err := svc.Register(context.Background(), "acme", "acme.ai", 1, nil)
|
||||
if !errors.Is(err, ErrInsufficientFunds) {
|
||||
t.Fatalf("want ErrInsufficientFunds, got %v", err)
|
||||
}
|
||||
if len(reg.created) != 0 {
|
||||
t.Fatalf("registrar must NOT be called when balance insufficient; got %d creates", len(reg.created))
|
||||
}
|
||||
if len(bill.captured) != 0 {
|
||||
t.Fatalf("no capture on a refused purchase; got %+v", bill.captured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRegistrarFailure_NoCharge(t *testing.T) {
|
||||
reg := &mockReg{configured: true, createErr: errors.New("boom"), avail: map[string]namecom.SearchResult{
|
||||
"acme.ai": {DomainName: "acme.ai", Purchasable: true, PurchasePrice: 55.99},
|
||||
}}
|
||||
bill := &mockBill{balance: 100000}
|
||||
svc := newSvc(reg, bill, &mockZones{ns: []string{"ns1.hanzo.ai"}})
|
||||
|
||||
_, err := svc.Register(context.Background(), "acme", "acme.ai", 1, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected registrar failure to propagate")
|
||||
}
|
||||
// Authorized (reserved) but NEVER captured — customer not charged for a failed buy.
|
||||
if len(bill.captured) != 0 {
|
||||
t.Fatalf("customer must not be charged on registrar failure; got %+v", bill.captured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterUnavailable(t *testing.T) {
|
||||
reg := &mockReg{configured: true, avail: map[string]namecom.SearchResult{
|
||||
"taken.ai": {DomainName: "taken.ai", Purchasable: false},
|
||||
}}
|
||||
svc := newSvc(reg, &mockBill{balance: -1}, &mockZones{})
|
||||
_, err := svc.Register(context.Background(), "acme", "taken.ai", 1, nil)
|
||||
if !errors.Is(err, ErrUnavailable) {
|
||||
t.Fatalf("want ErrUnavailable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterAlreadyOwned(t *testing.T) {
|
||||
reg := &mockReg{configured: true, avail: map[string]namecom.SearchResult{
|
||||
"acme.ai": {DomainName: "acme.ai", Purchasable: true, PurchasePrice: 55.99},
|
||||
}}
|
||||
bill := &mockBill{balance: 100000}
|
||||
svc := newSvc(reg, bill, &mockZones{ns: []string{"ns1.hanzo.ai"}})
|
||||
if _, err := svc.Register(context.Background(), "acme", "acme.ai", 1, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := svc.Register(context.Background(), "acme", "acme.ai", 1, nil)
|
||||
if !errors.Is(err, ErrAlreadyOwned) {
|
||||
t.Fatalf("want ErrAlreadyOwned on re-register, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterFallsBackToConfigNameserversWhenZoneFails(t *testing.T) {
|
||||
reg := &mockReg{configured: true, avail: map[string]namecom.SearchResult{
|
||||
"acme.ai": {DomainName: "acme.ai", Purchasable: true, PurchasePrice: 10.00},
|
||||
}}
|
||||
bill := &mockBill{balance: 100000}
|
||||
zones := &mockZones{err: errors.New("dns down")}
|
||||
svc := newSvc(reg, bill, zones)
|
||||
|
||||
if _, err := svc.Register(context.Background(), "acme", "acme.ai", 1, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Zone provisioning failed → registered against the configured Hanzo nameservers.
|
||||
got := reg.created[0].Domain.Nameservers
|
||||
if len(got) != 2 || got[0] != "ns1.hanzo.ai" {
|
||||
t.Fatalf("expected fallback nameservers, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewOwnedDomainBills(t *testing.T) {
|
||||
reg := &mockReg{configured: true, avail: map[string]namecom.SearchResult{
|
||||
"acme.ai": {DomainName: "acme.ai", Purchasable: true, PurchasePrice: 55.99, RenewalPrice: 55.99},
|
||||
}}
|
||||
bill := &mockBill{balance: 100000}
|
||||
svc := newSvc(reg, bill, &mockZones{ns: []string{"ns1.hanzo.ai"}})
|
||||
if _, err := svc.Register(context.Background(), "acme", "acme.ai", 1, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
captBefore := len(bill.captured)
|
||||
res, err := svc.Renew(context.Background(), "acme", "acme.ai", 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(reg.renewed) != 1 || reg.renewed[0] != "acme.ai" {
|
||||
t.Fatalf("registrar renew not called: %v", reg.renewed)
|
||||
}
|
||||
if len(bill.captured) != captBefore+1 {
|
||||
t.Fatalf("renewal not billed: %+v", bill.captured)
|
||||
}
|
||||
if res.Record.ExpiresAt != "2028-01-01T00:00:00Z" {
|
||||
t.Fatalf("expiry not updated: %+v", res.Record)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewNotOwned(t *testing.T) {
|
||||
reg := &mockReg{configured: true}
|
||||
svc := newSvc(reg, &mockBill{balance: -1}, &mockZones{})
|
||||
_, err := svc.Renew(context.Background(), "acme", "nope.ai", 1)
|
||||
if !errors.Is(err, ErrNotOwned) {
|
||||
t.Fatalf("want ErrNotOwned, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotConfigured(t *testing.T) {
|
||||
reg := &mockReg{configured: false}
|
||||
svc := newSvc(reg, &mockBill{balance: -1}, &mockZones{})
|
||||
if _, err := svc.Availability(context.Background(), "acme.ai"); !errors.Is(err, ErrNotConfigured) {
|
||||
t.Fatalf("want ErrNotConfigured, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// MemStore is an in-memory Store: a concurrency-safe map keyed by (org, domain). It is
|
||||
// the default ownership store — sufficient for a single-process deployment and every
|
||||
// test. A multi-replica deployment swaps in a SQLite/Postgres-backed Store with the
|
||||
// SAME interface (the clients/finance per-org ledger pattern); nothing else changes.
|
||||
type MemStore struct {
|
||||
mu sync.RWMutex
|
||||
rows map[string]Record // key = org + "\x00" + domain
|
||||
}
|
||||
|
||||
// NewMemStore builds an empty in-memory store.
|
||||
func NewMemStore() *MemStore { return &MemStore{rows: map[string]Record{}} }
|
||||
|
||||
func memKey(org, domainName string) string {
|
||||
return strings.ToLower(org) + "\x00" + strings.ToLower(domainName)
|
||||
}
|
||||
|
||||
// Put upserts a record.
|
||||
func (s *MemStore) Put(rec Record) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.rows[memKey(rec.Org, rec.Domain)] = rec
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns the record for (org, domain) and whether it exists.
|
||||
func (s *MemStore) Get(org, domainName string) (Record, bool, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
rec, ok := s.rows[memKey(org, domainName)]
|
||||
return rec, ok, nil
|
||||
}
|
||||
|
||||
// ListByOrg returns every domain the org holds, newest registration first.
|
||||
func (s *MemStore) ListByOrg(org string) ([]Record, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
prefix := strings.ToLower(org) + "\x00"
|
||||
out := make([]Record, 0)
|
||||
for k, rec := range s.rows {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
out = append(out, rec)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].RegisteredAt > out[j].RegisteredAt })
|
||||
return out, nil
|
||||
}
|
||||
@@ -46,33 +46,40 @@ func newTestClient(t *testing.T) *Client {
|
||||
}
|
||||
|
||||
func TestEnvFallbackAndDefault(t *testing.T) {
|
||||
// No engine/store configured -> env fallback, then literal default.
|
||||
// No engine/store configured. Runtime flags carry no Env, so they resolve to
|
||||
// their literal default and an env var must NOT move them — that is what makes
|
||||
// /v1/flags the single source of truth, flippable live without a redeploy.
|
||||
// Boot-time ReadOnly rows keep Env, because env IS their boot mechanism.
|
||||
prev := mounted
|
||||
mounted = &Client{} // not configured
|
||||
t.Cleanup(func() { mounted = prev })
|
||||
|
||||
// waitlist_open default is "true" (no env set).
|
||||
// waitlist_open default is "true".
|
||||
t.Setenv("WAITLIST_OPEN", "")
|
||||
if !Bool("waitlist_open") {
|
||||
t.Fatalf("waitlist_open default should be true")
|
||||
}
|
||||
// env override beats the literal default.
|
||||
// A runtime flag ignores env: the default still wins.
|
||||
t.Setenv("WAITLIST_OPEN", "false")
|
||||
if Bool("waitlist_open") {
|
||||
t.Fatalf("waitlist_open env=false should win")
|
||||
if !Bool("waitlist_open") {
|
||||
t.Fatalf("waitlist_open is a runtime flag: env must not override its default")
|
||||
}
|
||||
// int default.
|
||||
if Int("waitlist_access_capacity") != 0 {
|
||||
t.Fatalf("capacity default should be 0")
|
||||
}
|
||||
t.Setenv("WAITLIST_ACCESS_CAPACITY", "250")
|
||||
if Int("waitlist_access_capacity") != 250 {
|
||||
t.Fatalf("capacity env should be 250, got %d", Int("waitlist_access_capacity"))
|
||||
if Int("waitlist_access_capacity") != 0 {
|
||||
t.Fatalf("waitlist_access_capacity is a runtime flag: env must not override its default")
|
||||
}
|
||||
// network id read-only default.
|
||||
// A boot-time ReadOnly row still reads env, and falls back to its default.
|
||||
if Int("network_id_localnet") != 1337 {
|
||||
t.Fatalf("localnet id default should be 1337")
|
||||
}
|
||||
t.Setenv("LUX_NETWORK_ID_LOCALNET", "1338")
|
||||
if Int("network_id_localnet") != 1338 {
|
||||
t.Fatalf("network_id_localnet is boot-time: env must win, got %d", Int("network_id_localnet"))
|
||||
}
|
||||
// unknown key is safe.
|
||||
if Bool("nope") || Int("nope") != 0 || String("nope") != "" {
|
||||
t.Fatalf("unknown key must be zero-valued")
|
||||
|
||||
@@ -114,6 +114,7 @@ func discordInteractions(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
Provider: "discord", ExternalID: it.GuildID, User: it.User,
|
||||
Channel: it.ChannelID, Text: it.Prompt, DedupeKey: it.ID,
|
||||
}
|
||||
emitIngress(org, in, "")
|
||||
reply := discordReplier(it.AppID, it.Token)
|
||||
bridgeSpawn(s, org, func() { runBridgeTurn(s, org, in, reply) })
|
||||
// Ack SYNC with a deferred EPHEMERAL response (flags 64) — the async edit stays
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package integrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ingress.go is the chat-ingress seam between the platform adapters and the
|
||||
// /v1/channels transport plane (clients/channels), plus the transport send
|
||||
// doors. The seam is the cloud.RegisterSync idiom (sync_seam.go): channels
|
||||
// registers its consumer at Mount, adapters emit with NO import of channels —
|
||||
// the dependency points one way and token custody never leaves this package.
|
||||
|
||||
// IngressEvent is one authenticated inbound chat event crossing the seam. Org
|
||||
// is resolved via OrgForExternalID on a signature-verified payload; In is the
|
||||
// adapter-normalized Inbound (bridge.go); ReplyRoot is a transport-verified
|
||||
// reply root (Teams: the JWT-verified serviceURL; "" elsewhere).
|
||||
type IngressEvent struct {
|
||||
Org string
|
||||
In Inbound
|
||||
ReplyRoot string
|
||||
}
|
||||
|
||||
// ingressFn is the single registered consumer — nil until channels mounts.
|
||||
// Written once at Mount before serving, read from webhook goroutines (the
|
||||
// RegisterSync pattern, sync_seam.go).
|
||||
var ingressFn func(context.Context, IngressEvent)
|
||||
|
||||
// RegisterIngress installs the ingress consumer. Called once at channels.Mount.
|
||||
func RegisterIngress(fn func(context.Context, IngressEvent)) { ingressFn = fn }
|
||||
|
||||
// emitIngress hands one event to the registered consumer on a detached
|
||||
// goroutine with its own bounded context, so the billed webhook path is never
|
||||
// delayed and a panicking consumer cannot crash the shared process. No
|
||||
// request-scoped context crosses the hop — everything the consumer needs rides
|
||||
// the event. The adapter's bridgeLim slot ownership is untouched.
|
||||
func emitIngress(org string, in Inbound, replyRoot string) {
|
||||
fn := ingressFn
|
||||
if fn == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer func() { _ = recover() }()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
fn(ctx, IngressEvent{Org: org, In: in, ReplyRoot: replyRoot})
|
||||
}()
|
||||
}
|
||||
|
||||
// LinkedSubject returns the Hanzo account subject bound to (org, provider,
|
||||
// extUser) by the account-link flow (bridge_link.go / *_link.go). Returns
|
||||
// ("", false, nil) when the user has not linked; an error (fail closed) on an
|
||||
// unmounted subsystem, invalid org, or KMS-down.
|
||||
func LinkedSubject(org, provider, extUser string) (string, bool, error) {
|
||||
if mounted == nil {
|
||||
return "", false, fmt.Errorf("integrations: not mounted")
|
||||
}
|
||||
link, ok, err := getUserLink(mounted, org, provider, extUser)
|
||||
if err != nil || !ok {
|
||||
return "", false, err
|
||||
}
|
||||
return link.Subject, true, nil
|
||||
}
|
||||
|
||||
// ── transport send doors (each delegates to the ONE existing HTTP path) ──────
|
||||
|
||||
// SendSlack posts text to channel, threaded under threadTS when non-empty
|
||||
// (slackPostThread, slack_events.go — posts top-level chat.postMessage when
|
||||
// threadTS == ""). The token is the org's OWN custodied bot token: TokenFor
|
||||
// fails closed for unmounted/unknown/not-connected/KMS-down, so an org that
|
||||
// never connected Slack cannot post — the per-org token IS the tenancy gate.
|
||||
func SendSlack(ctx context.Context, org, channel, threadTS, text string) error {
|
||||
tok, err := TokenFor(ctx, org, "slack", slackBotTokenSecret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return slackPostThread(ctx, string(tok), channel, threadTS, text)
|
||||
}
|
||||
|
||||
// SendTelegram posts text to chatID via the Bot API sendMessage, threaded under
|
||||
// replyTo when non-zero (telegramSend, telegram_events.go).
|
||||
func SendTelegram(ctx context.Context, chatID, replyTo int64, text string) error {
|
||||
return telegramSend(ctx, chatID, replyTo, text)
|
||||
}
|
||||
|
||||
// SendTeams posts a message activity to conversationID at the Bot Connector
|
||||
// serviceURL (teamsSendActivity, teams_events.go).
|
||||
func SendTeams(ctx context.Context, serviceURL, conversationID, text string) error {
|
||||
return teamsSendActivity(ctx, serviceURL, conversationID, text)
|
||||
}
|
||||
|
||||
// SendDiscord posts text to a Discord channel via POST /channels/{id}/messages,
|
||||
// referencing replyTo when non-empty. Content is capped at discordMaxContent;
|
||||
// the bot token rides only the Authorization header, which is never logged.
|
||||
// Returns the created message id.
|
||||
func SendDiscord(ctx context.Context, channelID, replyTo, text string) (string, error) {
|
||||
tok := discordBotToken()
|
||||
if tok == "" {
|
||||
return "", fmt.Errorf("discord: bot token not configured")
|
||||
}
|
||||
if len(text) > discordMaxContent {
|
||||
text = text[:discordMaxContent]
|
||||
}
|
||||
fields := map[string]any{"content": text}
|
||||
if replyTo != "" {
|
||||
fields["message_reference"] = map[string]string{"message_id": replyTo}
|
||||
}
|
||||
payload, _ := json.Marshal(fields)
|
||||
endpoint := discordAPIBase + "/channels/" + channelID + "/messages"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(payload)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bot "+tok)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := bridgeHTTP.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, bridgeMaxBody))
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return "", fmt.Errorf("discord create message http %d", resp.StatusCode)
|
||||
}
|
||||
var m struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &m)
|
||||
return m.ID, nil
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package integrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ingress_test.go proves the two seam pieces channels rides: the SendDiscord
|
||||
// door (the one new HTTP verb — httptest via the package's own repoint
|
||||
// pattern, zero live network) and emitIngress (registration, goroutine hop,
|
||||
// bounded context, panic containment).
|
||||
|
||||
// discordMsgCapture records what SendDiscord put on the wire. Mutex-guarded:
|
||||
// the httptest handler runs on the server goroutine.
|
||||
type discordMsgCapture struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
method string
|
||||
path string
|
||||
auth string
|
||||
body []byte
|
||||
status int
|
||||
reply string
|
||||
}
|
||||
|
||||
func (c *discordMsgCapture) handler(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
c.mu.Lock()
|
||||
c.calls++
|
||||
c.method = r.Method
|
||||
c.path = r.URL.Path
|
||||
c.auth = r.Header.Get("Authorization")
|
||||
c.body = body
|
||||
status, reply := c.status, c.reply
|
||||
c.mu.Unlock()
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(reply))
|
||||
}
|
||||
|
||||
func (c *discordMsgCapture) count() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.calls
|
||||
}
|
||||
|
||||
func (c *discordMsgCapture) last() (method, path, auth string, body []byte) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.method, c.path, c.auth, c.body
|
||||
}
|
||||
|
||||
// newDiscordMsgServer repoints discordAPIBase at an httptest server — the
|
||||
// package's established repoint idiom (slackWebAPIBase, telegramAPIBase).
|
||||
func newDiscordMsgServer(t *testing.T, status int, reply string) *discordMsgCapture {
|
||||
t.Helper()
|
||||
rec := &discordMsgCapture{status: status, reply: reply}
|
||||
srv := httptest.NewServer(http.HandlerFunc(rec.handler))
|
||||
saved := discordAPIBase
|
||||
discordAPIBase = srv.URL
|
||||
t.Cleanup(func() {
|
||||
discordAPIBase = saved
|
||||
srv.Close()
|
||||
})
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestSendDiscordPostsMessage(t *testing.T) {
|
||||
t.Setenv(discordBotTokenEnv, "test-token")
|
||||
rec := newDiscordMsgServer(t, http.StatusOK, `{"id":"456"}`)
|
||||
|
||||
id, err := SendDiscord(context.Background(), "123", "789", "hello")
|
||||
if err != nil {
|
||||
t.Fatalf("SendDiscord: %v", err)
|
||||
}
|
||||
if id != "456" {
|
||||
t.Fatalf("message id = %q, want 456", id)
|
||||
}
|
||||
method, path, auth, body := rec.last()
|
||||
if method != http.MethodPost {
|
||||
t.Fatalf("method = %q, want POST", method)
|
||||
}
|
||||
if path != "/channels/123/messages" {
|
||||
t.Fatalf("path = %q, want /channels/123/messages", path)
|
||||
}
|
||||
if auth != "Bot test-token" {
|
||||
t.Fatalf("authorization = %q, want the Bot token header", auth)
|
||||
}
|
||||
var payload struct {
|
||||
Content string `json:"content"`
|
||||
Reference *struct {
|
||||
MessageID string `json:"message_id"`
|
||||
} `json:"message_reference"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatalf("body: %v (%s)", err, body)
|
||||
}
|
||||
if payload.Content != "hello" {
|
||||
t.Fatalf("content = %q", payload.Content)
|
||||
}
|
||||
if payload.Reference == nil || payload.Reference.MessageID != "789" {
|
||||
t.Fatalf("message_reference = %+v, want message_id 789", payload.Reference)
|
||||
}
|
||||
|
||||
// replyTo "" ⇒ a top-level message: no message_reference key at all.
|
||||
if _, err := SendDiscord(context.Background(), "123", "", "top"); err != nil {
|
||||
t.Fatalf("SendDiscord top-level: %v", err)
|
||||
}
|
||||
_, _, _, body = rec.last()
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
t.Fatalf("body: %v (%s)", err, body)
|
||||
}
|
||||
if _, ok := raw["message_reference"]; ok {
|
||||
t.Fatal("message_reference must be absent when replyTo is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendDiscordTokenUnset(t *testing.T) {
|
||||
t.Setenv(discordBotTokenEnv, "")
|
||||
rec := newDiscordMsgServer(t, http.StatusOK, `{"id":"1"}`)
|
||||
|
||||
_, err := SendDiscord(context.Background(), "123", "", "x")
|
||||
if err == nil || !strings.Contains(err.Error(), "not configured") {
|
||||
t.Fatalf("err = %v, want a not-configured refusal", err)
|
||||
}
|
||||
if rec.count() != 0 {
|
||||
t.Fatal("no HTTP call may fire without a token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendDiscordTruncatesContent(t *testing.T) {
|
||||
t.Setenv(discordBotTokenEnv, "test-token")
|
||||
rec := newDiscordMsgServer(t, http.StatusOK, `{"id":"1"}`)
|
||||
|
||||
if _, err := SendDiscord(context.Background(), "123", "", strings.Repeat("a", discordMaxContent+500)); err != nil {
|
||||
t.Fatalf("SendDiscord: %v", err)
|
||||
}
|
||||
_, _, _, body := rec.last()
|
||||
var payload struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatalf("body: %v", err)
|
||||
}
|
||||
if len(payload.Content) != discordMaxContent {
|
||||
t.Fatalf("content length = %d, want exactly %d", len(payload.Content), discordMaxContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendDiscordHTTPErrorRedacted(t *testing.T) {
|
||||
t.Setenv(discordBotTokenEnv, "test-token")
|
||||
newDiscordMsgServer(t, http.StatusForbidden, `{"message":"Missing Access"}`)
|
||||
|
||||
_, err := SendDiscord(context.Background(), "123", "", "x")
|
||||
if err == nil || !strings.Contains(err.Error(), "403") {
|
||||
t.Fatalf("err = %v, want the HTTP status", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "test-token") {
|
||||
t.Fatal("door errors carry status/shape only — never the token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitIngressDelivers(t *testing.T) {
|
||||
t.Cleanup(func() { ingressFn = nil })
|
||||
got := make(chan IngressEvent, 1)
|
||||
deadline := make(chan bool, 1)
|
||||
RegisterIngress(func(ctx context.Context, ev IngressEvent) {
|
||||
_, ok := ctx.Deadline()
|
||||
deadline <- ok
|
||||
got <- ev
|
||||
})
|
||||
|
||||
in := Inbound{Provider: "slack", ExternalID: "T1", User: "u1", Channel: "C1", ThreadID: "th", Text: "hi", DedupeKey: "e1"}
|
||||
emitIngress("org1", in, "root")
|
||||
|
||||
select {
|
||||
case ev := <-got:
|
||||
if ev.Org != "org1" || ev.ReplyRoot != "root" || ev.In != in {
|
||||
t.Fatalf("event = %+v, want the emitted fields intact", ev)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("ingress event not delivered")
|
||||
}
|
||||
if ok := <-deadline; !ok {
|
||||
t.Fatal("consumer context must carry the bounded deadline")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitIngressRecoversPanic(t *testing.T) {
|
||||
t.Cleanup(func() { ingressFn = nil })
|
||||
entered := make(chan struct{})
|
||||
RegisterIngress(func(context.Context, IngressEvent) {
|
||||
close(entered)
|
||||
panic("boom")
|
||||
})
|
||||
|
||||
emitIngress("org1", Inbound{Provider: "slack"}, "")
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("consumer never ran")
|
||||
}
|
||||
// The deferred recover in emitIngress owns the panic; give the goroutine a
|
||||
// beat to unwind — the process staying alive IS the assertion.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
func TestEmitIngressUnregistered(t *testing.T) {
|
||||
ingressFn = nil
|
||||
// No consumer registered (fresh process state): emit must be a silent no-op.
|
||||
emitIngress("org1", Inbound{Provider: "slack"}, "")
|
||||
}
|
||||
@@ -149,6 +149,7 @@ func slackEvents(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
Provider: "slack", ExternalID: route.TeamID, User: route.User,
|
||||
Channel: route.Channel, ThreadID: route.ThreadTS, Text: route.Text, DedupeKey: key,
|
||||
}
|
||||
emitIngress(org, in, "")
|
||||
reply := slackReplier(s, org, route.Channel, route.ThreadTS, route.User)
|
||||
bridgeSpawn(s, org, func() { runBridgeTurn(s, org, in, reply) })
|
||||
return c.NoContent(http.StatusOK)
|
||||
|
||||
@@ -98,6 +98,7 @@ func teamsEvents(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
Provider: "teams", ExternalID: tenant, User: user,
|
||||
Channel: act.ConversationID, Text: stripTeamsMentions(act.Text), DedupeKey: act.ID,
|
||||
}
|
||||
emitIngress(org, in, act.ServiceURL)
|
||||
reply := teamsReplier(act.ServiceURL, act.ConversationID)
|
||||
bridgeSpawn(s, org, func() { runBridgeTurn(s, org, in, reply) })
|
||||
return c.NoContent(http.StatusOK)
|
||||
|
||||
@@ -152,6 +152,7 @@ func telegramWebhook(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
Channel: chatID, ThreadID: strconv.FormatInt(m.MessageID, 10), Text: prompt,
|
||||
DedupeKey: strconv.FormatInt(m.UpdateID, 10),
|
||||
}
|
||||
emitIngress(org, in, "")
|
||||
reply := telegramReplier(in)
|
||||
bridgeSpawn(s, org, func() { runBridgeTurn(s, org, in, reply) })
|
||||
return c.NoContent(http.StatusOK)
|
||||
|
||||
@@ -16,15 +16,15 @@ import (
|
||||
"github.com/hanzoai/commerce/datastore"
|
||||
"github.com/hanzoai/commerce/db"
|
||||
commercemid "github.com/hanzoai/commerce/middleware"
|
||||
"github.com/hanzoai/commerce/middleware/svcorg"
|
||||
"github.com/hanzoai/commerce/pkg/org"
|
||||
"github.com/hanzoai/commerce/util/bit"
|
||||
"github.com/hanzoai/commerce/util/permission"
|
||||
)
|
||||
|
||||
// seedInMemDatastore installs a fresh in-memory default datastore so the
|
||||
// service-token branch's svcorg.Resolve (GetOrCreate on the org table) succeeds
|
||||
// service-token branch's org.Resolve (GetOrCreate on the org table) succeeds
|
||||
// and the request reaches the handler — the funded path — rather than 503ing on a
|
||||
// resolve failure. Mirrors svcorg/resolver_test.go's setup.
|
||||
// resolve failure. Mirrors pkg/org's lookup_test.go setup.
|
||||
func seedInMemDatastore(t *testing.T) {
|
||||
t.Helper()
|
||||
mgr, err := db.NewManager(&db.Config{DataDir: t.TempDir(), EnableVectorSearch: false, IsDev: true})
|
||||
@@ -65,8 +65,8 @@ func TestInProcMeteringDispatch_ServiceTokenAuthPath(t *testing.T) {
|
||||
const svc = "svc-token-metering-xyz789"
|
||||
t.Setenv("COMMERCE_SERVICE_TOKEN", svc)
|
||||
seedInMemDatastore(t)
|
||||
svcorg.Invalidate("funded")
|
||||
svcorg.Invalidate("broke")
|
||||
org.Invalidate("funded")
|
||||
org.Invalidate("broke")
|
||||
|
||||
// finance NOT co-resident → the metering balance read goes over commerceinproc,
|
||||
// through the real commerce middleware chain, exactly like the topology that 500'd.
|
||||
|
||||
@@ -139,3 +139,48 @@ func TestCreateRejectsReservedSlug(t *testing.T) {
|
||||
t.Fatalf("create normal slug status=%d body=%s, want 201", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveUniqueLiveSlug proves the bare `<slug>.hanzo.app` fallback is
|
||||
// deterministic and collision-safe: it serves ONLY a slug owned by exactly one
|
||||
// LIVE project across all orgs — drafts don't count, and an ambiguous slug
|
||||
// (two live owners) serves neither (each keeps its org-scoped host + S3 URL).
|
||||
// This is what keeps publishes from before host binding servable, no backfill.
|
||||
func TestResolveUniqueLiveSlug(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
// One live owner, no site_hosts row (a pre-binding publish) → resolves.
|
||||
solo := mkProject("maxpower", "dave-synapse-demo", "Synapse")
|
||||
solo.Status, solo.Bucket = "live", "hanzo-sites"
|
||||
if err := s.CreateProject(ctx, solo); err != nil {
|
||||
t.Fatalf("create solo: %v", err)
|
||||
}
|
||||
if p, err := s.ResolveUniqueLiveSlug(ctx, "dave-synapse-demo"); err != nil || p.Org != "maxpower" {
|
||||
t.Fatalf("unique live slug = (%+v,%v), want org=maxpower", p, err)
|
||||
}
|
||||
|
||||
// A DRAFT with the same slug in another org does not make it ambiguous.
|
||||
dr := mkProject("acme", "dave-synapse-demo", "Draft Synapse")
|
||||
dr.Status = "draft"
|
||||
if err := s.CreateProject(ctx, dr); err != nil {
|
||||
t.Fatalf("create draft: %v", err)
|
||||
}
|
||||
if p, err := s.ResolveUniqueLiveSlug(ctx, "dave-synapse-demo"); err != nil || p.Org != "maxpower" {
|
||||
t.Fatalf("draft polluted unique resolve: (%+v,%v)", p, err)
|
||||
}
|
||||
|
||||
// A SECOND live owner makes the bare slug ambiguous → honest not-found.
|
||||
ac := mkProject("acme2", "dave-synapse-demo", "Acme Synapse")
|
||||
ac.Status, ac.Bucket = "live", "hanzo-sites"
|
||||
if err := s.CreateProject(ctx, ac); err != nil {
|
||||
t.Fatalf("create second live: %v", err)
|
||||
}
|
||||
if _, err := s.ResolveUniqueLiveSlug(ctx, "dave-synapse-demo"); !errors.Is(err, errNotFound) {
|
||||
t.Fatalf("ambiguous slug = %v, want errNotFound", err)
|
||||
}
|
||||
|
||||
// Unknown slug → not found.
|
||||
if _, err := s.ResolveUniqueLiveSlug(ctx, "nope"); !errors.Is(err, errNotFound) {
|
||||
t.Fatalf("unknown slug = %v, want errNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package projects
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/sites"
|
||||
)
|
||||
@@ -18,8 +19,17 @@ type siteResolver struct{ store *Store }
|
||||
// prefix are read ONLY from the store binding (site_hosts → projects), never from
|
||||
// the request — the org-isolation boundary. A missing binding is a clean
|
||||
// not-found (honest 404), not an error.
|
||||
//
|
||||
// A BARE key (no dot — the `<slug>.hanzo.app` product URL) additionally falls
|
||||
// back to the unique-live-slug resolve: serve iff exactly one live project owns
|
||||
// that slug across all orgs. Deterministic, shadow-safe (reserved labels never
|
||||
// reach here), and migration-free for projects published before host binding
|
||||
// existed. An org-scoped `<slug>.<org>` key resolves ONLY via its binding.
|
||||
func (r siteResolver) Resolve(ctx context.Context, slug string) (sites.Site, bool, error) {
|
||||
p, err := r.store.ResolveHost(ctx, slug)
|
||||
if errors.Is(err, errNotFound) && !strings.Contains(slug, ".") {
|
||||
p, err = r.store.ResolveUniqueLiveSlug(ctx, slug)
|
||||
}
|
||||
if errors.Is(err, errNotFound) {
|
||||
return sites.Site{}, false, nil
|
||||
}
|
||||
|
||||
@@ -442,6 +442,37 @@ func (s *Store) ResolveHost(ctx context.Context, host string) (Project, error) {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// ResolveUniqueLiveSlug resolves a BARE subdomain slug (`<slug>.hanzo.app`) to
|
||||
// the single LIVE project owning that slug across all orgs. Slugs are only
|
||||
// org-unique, so the bare host is servable ONLY when unambiguous: zero or 2+
|
||||
// live owners ⇒ errNotFound (each project still serves at its org-scoped host
|
||||
// and its S3 URL). LIMIT 2 — a second row is the whole ambiguity signal; we
|
||||
// never enumerate. This is what keeps pre-binding publishes servable with no
|
||||
// backfill migration.
|
||||
func (s *Store) ResolveUniqueLiveSlug(ctx context.Context, slug string) (Project, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT `+projectCols+` FROM projects WHERE slug=? AND status='live' LIMIT 2`, slug)
|
||||
if err != nil {
|
||||
return Project{}, fmt.Errorf("resolve slug: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var out []Project
|
||||
for rows.Next() {
|
||||
p, err := scanProject(rows)
|
||||
if err != nil {
|
||||
return Project{}, fmt.Errorf("resolve slug: %w", err)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return Project{}, fmt.Errorf("resolve slug: %w", err)
|
||||
}
|
||||
if len(out) != 1 {
|
||||
return Project{}, errNotFound
|
||||
}
|
||||
return out[0], nil
|
||||
}
|
||||
|
||||
// ListHostsForProject returns every public host bound to (org, slug), oldest
|
||||
// first. It powers GET .../domains so a console/user can see which hostnames the
|
||||
// site serves — its `<slug>.hanzo.app` subdomain plus any bound custom domains.
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
// Package session mounts the Hanzo Cloud /v1/code/sessions/* surface: the
|
||||
// registry of live coding-agent runs launched by `hanzo code <agent>`. It is
|
||||
// what console.hanzo.ai lists and drives — every claude/codex/dev process, on
|
||||
// every machine, shown as a session you can watch (over its ttyd terminal
|
||||
// tunnel) and steer.
|
||||
//
|
||||
// Org isolation is enforced SERVER-SIDE on every request: the org is
|
||||
// principal.Org(c) — the value minted from the VALIDATED bearer owner claim —
|
||||
// and NEVER a client header. One SQLite file per org ({DataDir}/orgs/{slug}/
|
||||
// session.db), every query filtered WHERE org=?, so one org can never see or
|
||||
// mutate another's sessions. Project is the LINK to the deployable
|
||||
// projects.Project a run belongs to (a column, filterable), not a second
|
||||
// partition: the console default is the org-wide "what's running" view.
|
||||
//
|
||||
// Surface (all org-scoped; /v1 only):
|
||||
//
|
||||
// POST /v1/code/sessions register/announce a run -> Session (201)
|
||||
// GET /v1/code/sessions[?live&status=&project=&host=&agent=] list -> [Session]
|
||||
// GET /v1/code/sessions/:id session detail -> Session
|
||||
// PATCH /v1/code/sessions/:id heartbeat/status/terminalURL -> Session
|
||||
// DELETE /v1/code/sessions/:id forget a session
|
||||
// POST /v1/code/sessions/:id/events record a hook event -> Event (201)
|
||||
// GET /v1/code/sessions/:id/events list events -> [Event]
|
||||
package session
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
const (
|
||||
maxField = 256 // host, model, user, project, agent
|
||||
maxCwd = 4096 // a working-directory path
|
||||
maxURL = 2048 // the ttyd terminal endpoint
|
||||
maxMsg = 16384 // an event message
|
||||
)
|
||||
|
||||
// idRE constrains a client-minted session id to a URL/identifier-safe token, so
|
||||
// the launcher can mint a uuid locally and reference the run before the register
|
||||
// round-trip returns. It is the boundary guard on the :id path segment.
|
||||
var idRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{7,63}$`)
|
||||
|
||||
// agents is the closed set of runnable coding agents (mirrors `hanzo code`).
|
||||
var agents = map[string]bool{"claude": true, "codex": true, "dev": true}
|
||||
|
||||
// statuses is the lifecycle a session moves through; the launcher and the hook
|
||||
// bridge set them, the console reads them.
|
||||
var statuses = map[string]bool{
|
||||
"starting": true, "running": true, "waiting": true, "ended": true, "error": true,
|
||||
}
|
||||
|
||||
type state struct {
|
||||
stores *cloud.OrgStore[*Store] // one session.db per org, opened once each
|
||||
}
|
||||
|
||||
var mounted *cloud.Service[state]
|
||||
|
||||
// Mount wires /v1/code/sessions/* onto app.
|
||||
func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
if app == nil {
|
||||
return errors.New("session.Mount: nil zip.App")
|
||||
}
|
||||
if deps.Logger == nil {
|
||||
return errors.New("session.Mount: nil deps.Logger")
|
||||
}
|
||||
if deps.DataDir == "" {
|
||||
return errors.New("session.Mount: empty DataDir")
|
||||
}
|
||||
s := &cloud.Service[state]{Base: cloud.NewBase(deps, "session"), State: state{
|
||||
stores: cloud.NewOrgStore(deps.DataDir, "session", openStore),
|
||||
}}
|
||||
mounted = s
|
||||
routes(app, s)
|
||||
s.Log.Info("session registry mounted", "brand", s.Brand)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown closes every open per-org store. Idempotent.
|
||||
func Shutdown() error {
|
||||
if mounted == nil {
|
||||
return nil
|
||||
}
|
||||
err := mounted.State.stores.CloseAll()
|
||||
mounted = nil
|
||||
return err
|
||||
}
|
||||
|
||||
// routes registers the surface. Collection endpoints register before their
|
||||
// :id siblings so the first-match scan resolves them first.
|
||||
func routes(app *zip.App, s *cloud.Service[state]) {
|
||||
app.Post("/v1/code/sessions", cloud.Handle(s, createSession))
|
||||
app.Get("/v1/code/sessions", cloud.Handle(s, listSessions))
|
||||
app.Get("/v1/code/sessions/:id", cloud.Handle(s, getSession))
|
||||
app.Patch("/v1/code/sessions/:id", cloud.Handle(s, updateSession))
|
||||
app.Delete("/v1/code/sessions/:id", cloud.Handle(s, deleteSession))
|
||||
app.Post("/v1/code/sessions/:id/events", cloud.Handle(s, createEvent))
|
||||
app.Get("/v1/code/sessions/:id/events", cloud.Handle(s, listEvents))
|
||||
}
|
||||
|
||||
func org(c *zip.Ctx) (string, bool) { return principal.Org(c) }
|
||||
|
||||
func storeFor(s *cloud.Service[state], org string) (*Store, error) {
|
||||
return s.State.stores.For(org, "")
|
||||
}
|
||||
|
||||
func idParam(c *zip.Ctx) string { return strings.TrimSpace(c.Param("id")) }
|
||||
|
||||
func genID() string {
|
||||
var b [16]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return "ses_" + hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// ---- HTTP shapes (the published contract) ----
|
||||
|
||||
type createSessionReq struct {
|
||||
ID string `json:"id"` // client-minted; generated if empty
|
||||
Agent string `json:"agent"` // claude | codex | dev
|
||||
Model string `json:"model"` // zen5-pro, ...
|
||||
Host string `json:"host"` // machine name
|
||||
Cwd string `json:"cwd"` // working directory
|
||||
Project string `json:"project"` // deployable-project link ("" = none)
|
||||
User string `json:"user"` // display label of who launched
|
||||
TerminalURL string `json:"terminalUrl"` // set now if the tunnel is already up
|
||||
}
|
||||
|
||||
type updateSessionReq struct {
|
||||
Status *string `json:"status"`
|
||||
TerminalURL *string `json:"terminalUrl"`
|
||||
Model *string `json:"model"`
|
||||
Ended *bool `json:"ended"` // true → stamp ended_at + status=ended
|
||||
}
|
||||
|
||||
type createEventReq struct {
|
||||
Kind string `json:"kind"` // notification | stop | error | log
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type sessionView struct {
|
||||
ID string `json:"id"`
|
||||
Org string `json:"org"`
|
||||
Project string `json:"project,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
Agent string `json:"agent"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Cwd string `json:"cwd,omitempty"`
|
||||
TerminalURL string `json:"terminalUrl,omitempty"`
|
||||
Status string `json:"status"`
|
||||
StartedAt int64 `json:"startedAt"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
EndedAt int64 `json:"endedAt,omitempty"`
|
||||
}
|
||||
|
||||
type eventView struct {
|
||||
ID string `json:"id"`
|
||||
SessionID string `json:"sessionId"`
|
||||
Kind string `json:"kind"`
|
||||
Message string `json:"message,omitempty"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
func toSessionView(x Session) sessionView {
|
||||
return sessionView{
|
||||
ID: x.ID, Org: x.Org, Project: x.Project, User: x.User, Agent: x.Agent,
|
||||
Model: x.Model, Host: x.Host, Cwd: x.Cwd, TerminalURL: x.TerminalURL,
|
||||
Status: x.Status, StartedAt: x.StartedAt, UpdatedAt: x.UpdatedAt, EndedAt: x.EndedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func toEventView(e Event) eventView {
|
||||
return eventView{ID: e.ID, SessionID: e.SessionID, Kind: e.Kind, Message: e.Message, CreatedAt: e.CreatedAt}
|
||||
}
|
||||
|
||||
// ---- handlers ----
|
||||
|
||||
func createSession(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := org(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("a validated org is required")
|
||||
}
|
||||
var body createSessionReq
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return err
|
||||
}
|
||||
agent := strings.ToLower(strings.TrimSpace(body.Agent))
|
||||
if !agents[agent] {
|
||||
return zip.ErrBadRequest("agent must be one of claude, codex, dev")
|
||||
}
|
||||
id := strings.TrimSpace(body.ID)
|
||||
if id == "" {
|
||||
id = genID()
|
||||
} else if !idRE.MatchString(id) {
|
||||
return zip.ErrBadRequest("id must match ^[A-Za-z0-9][A-Za-z0-9._-]{7,63}$")
|
||||
}
|
||||
if len(body.Cwd) > maxCwd || len(body.TerminalURL) > maxURL ||
|
||||
len(body.Model) > maxField || len(body.Host) > maxField ||
|
||||
len(body.Project) > maxField || len(body.User) > maxField {
|
||||
return zip.ErrBadRequest("a field exceeds its length bound")
|
||||
}
|
||||
store, err := storeFor(s, org)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "open store: %v", err)
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
x := Session{
|
||||
ID: id, Org: org, Project: strings.TrimSpace(body.Project),
|
||||
User: strings.TrimSpace(body.User), Agent: agent,
|
||||
Model: strings.TrimSpace(body.Model), Host: strings.TrimSpace(body.Host),
|
||||
Cwd: body.Cwd, TerminalURL: strings.TrimSpace(body.TerminalURL),
|
||||
Status: "starting", StartedAt: now, UpdatedAt: now,
|
||||
}
|
||||
if err := store.Create(c.Context(), x); err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "persist: %v", err)
|
||||
}
|
||||
return c.JSON(http.StatusCreated, toSessionView(x))
|
||||
}
|
||||
|
||||
func listSessions(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := org(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("a validated org is required")
|
||||
}
|
||||
store, err := storeFor(s, org)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "open store: %v", err)
|
||||
}
|
||||
f := Filter{
|
||||
Status: strings.TrimSpace(c.Query("status")),
|
||||
Project: strings.TrimSpace(c.Query("project")),
|
||||
Host: strings.TrimSpace(c.Query("host")),
|
||||
Agent: strings.TrimSpace(c.Query("agent")),
|
||||
Live: c.Query("live") != "",
|
||||
}
|
||||
rows, err := store.List(c.Context(), org, f)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "list: %v", err)
|
||||
}
|
||||
out := make([]sessionView, 0, len(rows))
|
||||
for _, x := range rows {
|
||||
out = append(out, toSessionView(x))
|
||||
}
|
||||
return c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func getSession(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := org(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("a validated org is required")
|
||||
}
|
||||
store, err := storeFor(s, org)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "open store: %v", err)
|
||||
}
|
||||
x, err := store.Get(c.Context(), org, idParam(c))
|
||||
if errors.Is(err, errNotFound) {
|
||||
return zip.ErrNotFound("session not found")
|
||||
}
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "get: %v", err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, toSessionView(x))
|
||||
}
|
||||
|
||||
func updateSession(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := org(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("a validated org is required")
|
||||
}
|
||||
var body updateSessionReq
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return err
|
||||
}
|
||||
p := Patch{Model: body.Model, TerminalURL: body.TerminalURL}
|
||||
if body.TerminalURL != nil && len(*body.TerminalURL) > maxURL {
|
||||
return zip.ErrBadRequest("terminalUrl too long")
|
||||
}
|
||||
if body.Status != nil {
|
||||
st := strings.ToLower(strings.TrimSpace(*body.Status))
|
||||
if !statuses[st] {
|
||||
return zip.ErrBadRequest("status must be one of starting, running, waiting, ended, error")
|
||||
}
|
||||
p.Status = &st
|
||||
}
|
||||
if body.Ended != nil && *body.Ended {
|
||||
now := time.Now().Unix()
|
||||
ended := "ended"
|
||||
p.EndedAt = &now
|
||||
if p.Status == nil {
|
||||
p.Status = &ended
|
||||
}
|
||||
}
|
||||
store, err := storeFor(s, org)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "open store: %v", err)
|
||||
}
|
||||
x, err := store.Update(c.Context(), org, idParam(c), time.Now().Unix(), p)
|
||||
if errors.Is(err, errNotFound) {
|
||||
return zip.ErrNotFound("session not found")
|
||||
}
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "update: %v", err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, toSessionView(x))
|
||||
}
|
||||
|
||||
func deleteSession(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := org(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("a validated org is required")
|
||||
}
|
||||
store, err := storeFor(s, org)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "open store: %v", err)
|
||||
}
|
||||
if err := store.Delete(c.Context(), org, idParam(c)); errors.Is(err, errNotFound) {
|
||||
return zip.ErrNotFound("session not found")
|
||||
} else if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "delete: %v", err)
|
||||
}
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func createEvent(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := org(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("a validated org is required")
|
||||
}
|
||||
var body createEventReq
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return err
|
||||
}
|
||||
kind := strings.ToLower(strings.TrimSpace(body.Kind))
|
||||
if kind == "" || len(kind) > maxField {
|
||||
return zip.ErrBadRequest("kind is required")
|
||||
}
|
||||
if len(body.Message) > maxMsg {
|
||||
return zip.ErrBadRequest("message too long")
|
||||
}
|
||||
store, err := storeFor(s, org)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "open store: %v", err)
|
||||
}
|
||||
e := Event{ID: genID(), SessionID: idParam(c), Org: org, Kind: kind, Message: body.Message, CreatedAt: time.Now().Unix()}
|
||||
if err := store.AddEvent(c.Context(), e); errors.Is(err, errNotFound) {
|
||||
return zip.ErrNotFound("session not found")
|
||||
} else if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "persist event: %v", err)
|
||||
}
|
||||
return c.JSON(http.StatusCreated, toEventView(e))
|
||||
}
|
||||
|
||||
func listEvents(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := org(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("a validated org is required")
|
||||
}
|
||||
store, err := storeFor(s, org)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "open store: %v", err)
|
||||
}
|
||||
// Confirm the session is in this org before listing its events, so a
|
||||
// non-owned id 404s like every other :id route rather than leaking a 200.
|
||||
if _, err := store.Get(c.Context(), org, idParam(c)); errors.Is(err, errNotFound) {
|
||||
return zip.ErrNotFound("session not found")
|
||||
} else if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "get: %v", err)
|
||||
}
|
||||
rows, err := store.ListEvents(c.Context(), org, idParam(c))
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "list events: %v", err)
|
||||
}
|
||||
out := make([]eventView, 0, len(rows))
|
||||
for _, e := range rows {
|
||||
out = append(out, toEventView(e))
|
||||
}
|
||||
return c.JSON(http.StatusOK, out)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
luxlog "github.com/luxfi/log"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// do runs a JSON request carrying a validated principal (X-Org-Id + X-User-Id,
|
||||
// the pair org() gates on). An empty org sends neither header — the anonymous
|
||||
// case that must be refused.
|
||||
func do(t *testing.T, app *zip.App, method, path, org string, body any) (int, []byte) {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
r = bytes.NewReader(b)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, r)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if org != "" {
|
||||
req.Header.Set("X-Org-Id", org)
|
||||
req.Header.Set("X-User-Id", "u_"+org)
|
||||
}
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Test %s %s: %v", method, path, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, b
|
||||
}
|
||||
|
||||
func mount(t *testing.T) *zip.App {
|
||||
t.Helper()
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir()}); err != nil {
|
||||
t.Fatalf("Mount: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = Shutdown() })
|
||||
return app
|
||||
}
|
||||
|
||||
func create(t *testing.T, app *zip.App, org string, body map[string]any) sessionView {
|
||||
t.Helper()
|
||||
st, b := do(t, app, http.MethodPost, "/v1/code/sessions", org, body)
|
||||
if st != http.StatusCreated {
|
||||
t.Fatalf("create: status %d: %s", st, b)
|
||||
}
|
||||
var v sessionView
|
||||
if err := json.Unmarshal(b, &v); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// TestTenantIsolation proves a session created under one org is invisible and
|
||||
// immutable to another: read, update, delete, and event routes all 404 across
|
||||
// the org boundary, and a list never leaks the row.
|
||||
func TestTenantIsolation(t *testing.T) {
|
||||
app := mount(t)
|
||||
s := create(t, app, "acme", map[string]any{"agent": "claude", "model": "zen5-pro", "host": "evo", "project": "site"})
|
||||
|
||||
// acme sees its own session.
|
||||
if st, _ := do(t, app, http.MethodGet, "/v1/code/sessions/"+s.ID, "acme", nil); st != http.StatusOK {
|
||||
t.Fatalf("acme GET own: %d", st)
|
||||
}
|
||||
// evil cannot read, update, delete, or event acme's session.
|
||||
for _, tc := range []struct {
|
||||
method, path string
|
||||
body any
|
||||
}{
|
||||
{http.MethodGet, "/v1/code/sessions/" + s.ID, nil},
|
||||
{http.MethodPatch, "/v1/code/sessions/" + s.ID, map[string]any{"status": "running"}},
|
||||
{http.MethodDelete, "/v1/code/sessions/" + s.ID, nil},
|
||||
{http.MethodPost, "/v1/code/sessions/" + s.ID + "/events", map[string]any{"kind": "log", "message": "x"}},
|
||||
{http.MethodGet, "/v1/code/sessions/" + s.ID + "/events", nil},
|
||||
} {
|
||||
st, b := do(t, app, tc.method, tc.path, "evil", tc.body)
|
||||
// events GET returns 200 with an empty list only if the session is
|
||||
// visible; since it is not, it must 404 like the rest.
|
||||
if st != http.StatusNotFound {
|
||||
t.Errorf("evil %s %s = %d (want 404): %s", tc.method, tc.path, st, b)
|
||||
}
|
||||
}
|
||||
// evil's list is empty; acme's holds exactly the one session.
|
||||
if st, b := do(t, app, http.MethodGet, "/v1/code/sessions", "evil", nil); st != 200 || string(bytes.TrimSpace(b)) != "[]" {
|
||||
t.Errorf("evil list = %d %s (want 200 [])", st, b)
|
||||
}
|
||||
var mine []sessionView
|
||||
_, b := do(t, app, http.MethodGet, "/v1/code/sessions", "acme", nil)
|
||||
_ = json.Unmarshal(b, &mine)
|
||||
if len(mine) != 1 || mine[0].ID != s.ID {
|
||||
t.Errorf("acme list = %v (want 1 session %s)", mine, s.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnonymousRefused proves no route serves a request without a validated
|
||||
// principal.
|
||||
func TestAnonymousRefused(t *testing.T) {
|
||||
app := mount(t)
|
||||
for _, tc := range []struct{ method, path string }{
|
||||
{http.MethodPost, "/v1/code/sessions"},
|
||||
{http.MethodGet, "/v1/code/sessions"},
|
||||
{http.MethodGet, "/v1/code/sessions/ses_x"},
|
||||
} {
|
||||
if st, _ := do(t, app, tc.method, tc.path, "", map[string]any{"agent": "claude"}); st != http.StatusForbidden {
|
||||
t.Errorf("anon %s %s = %d (want 403)", tc.method, tc.path, st)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLifecycle walks a session from register through heartbeat, terminal
|
||||
// publish, an event, and end — the launcher's real path.
|
||||
func TestLifecycle(t *testing.T) {
|
||||
app := mount(t)
|
||||
s := create(t, app, "acme", map[string]any{"id": "ses_abcd1234", "agent": "codex", "host": "spark"})
|
||||
if s.ID != "ses_abcd1234" || s.Status != "starting" {
|
||||
t.Fatalf("register: %+v", s)
|
||||
}
|
||||
// publish the terminal URL + go running (heartbeat).
|
||||
st, b := do(t, app, http.MethodPatch, "/v1/code/sessions/"+s.ID, "acme",
|
||||
map[string]any{"status": "running", "terminalUrl": "https://code-x.zt.hanzo/"})
|
||||
if st != http.StatusOK {
|
||||
t.Fatalf("patch: %d %s", st, b)
|
||||
}
|
||||
var upd sessionView
|
||||
_ = json.Unmarshal(b, &upd)
|
||||
if upd.Status != "running" || upd.TerminalURL == "" {
|
||||
t.Fatalf("patch result: %+v", upd)
|
||||
}
|
||||
// a hook event lands.
|
||||
if st, b := do(t, app, http.MethodPost, "/v1/code/sessions/"+s.ID+"/events", "acme",
|
||||
map[string]any{"kind": "notification", "message": "run tests?"}); st != http.StatusCreated {
|
||||
t.Fatalf("event: %d %s", st, b)
|
||||
}
|
||||
// end it.
|
||||
if st, _ := do(t, app, http.MethodPatch, "/v1/code/sessions/"+s.ID, "acme", map[string]any{"ended": true}); st != http.StatusOK {
|
||||
t.Fatalf("end: %d", st)
|
||||
}
|
||||
// live filter now excludes it.
|
||||
var live []sessionView
|
||||
_, b = do(t, app, http.MethodGet, "/v1/code/sessions?live=1", "acme", nil)
|
||||
_ = json.Unmarshal(b, &live)
|
||||
if len(live) != 0 {
|
||||
t.Errorf("live after end = %d (want 0)", len(live))
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidation rejects an unknown agent and a malformed id.
|
||||
func TestValidation(t *testing.T) {
|
||||
app := mount(t)
|
||||
if st, _ := do(t, app, http.MethodPost, "/v1/code/sessions", "acme", map[string]any{"agent": "emacs"}); st != http.StatusBadRequest {
|
||||
t.Errorf("bad agent = %d (want 400)", st)
|
||||
}
|
||||
if st, _ := do(t, app, http.MethodPost, "/v1/code/sessions", "acme", map[string]any{"agent": "claude", "id": "bad id!"}); st != http.StatusBadRequest {
|
||||
t.Errorf("bad id = %d (want 400)", st)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// errNotFound is returned when a session (or its events) is not present in the
|
||||
// org's file; handlers map it to HTTP 404.
|
||||
var errNotFound = errors.New("session: not found")
|
||||
|
||||
// Session is one live (or ended) coding-agent run launched by `hanzo code
|
||||
// <agent>`. It is the value console.hanzo.ai lists and drives: the registry row
|
||||
// that says a claude/codex/dev process is running on some machine, against some
|
||||
// Hanzo model, in some working directory, reachable at TerminalURL.
|
||||
//
|
||||
// Org is the physical tenant boundary (one sessions.db per org, filtered
|
||||
// WHERE org=? on every query). Project is the LINK to the deployable
|
||||
// projects.Project the run belongs to (principal.Project, "" = none) — so a
|
||||
// project view can filter its own sessions without a second registry.
|
||||
//
|
||||
// TerminalURL is the ttyd web-terminal endpoint the launcher publishes once its
|
||||
// ZT (or zrok) tunnel is up; empty until the mirror is ready. Status is the
|
||||
// lifecycle column the console polls: starting → running → waiting (needs
|
||||
// input) → ended | error.
|
||||
type Session struct {
|
||||
ID string
|
||||
Org string
|
||||
Project string
|
||||
User string
|
||||
Agent string // claude | codex | dev
|
||||
Model string
|
||||
Host string
|
||||
Cwd string
|
||||
TerminalURL string
|
||||
Status string
|
||||
StartedAt int64
|
||||
UpdatedAt int64
|
||||
EndedAt int64 // 0 while live
|
||||
}
|
||||
|
||||
// Event is one lifecycle signal a session's agent emits — sourced from Claude
|
||||
// Code's Notification/Stop hooks (needs-input, turn-done) — so the console and
|
||||
// the @hanzo Slack relay learn state changes without scraping the terminal.
|
||||
type Event struct {
|
||||
ID string
|
||||
SessionID string
|
||||
Org string
|
||||
Kind string // notification | stop | error | log
|
||||
Message string
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
// Filter narrows a session list. Empty fields do not constrain. Live selects
|
||||
// only sessions that have not ended (the console's default "what's running").
|
||||
type Filter struct {
|
||||
Status string
|
||||
Project string
|
||||
Host string
|
||||
Agent string
|
||||
Live bool
|
||||
}
|
||||
|
||||
// Store is one org's SQLite file. Every method filters WHERE org=?, so a query
|
||||
// in one org's store can never reach another org's rows.
|
||||
type Store struct{ db *sql.DB }
|
||||
|
||||
// Close releases the org's SQLite handle; the OrgStore cache calls it on
|
||||
// eviction and on Shutdown.
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
const sessionCols = "id, org, project, usr, agent, model, host, cwd, terminal_url, status, started_at, updated_at, ended_at"
|
||||
|
||||
// openStore is the cloud.NewOrgStore factory: it receives the org's already-open
|
||||
// *sql.DB and installs the schema. Idempotent (IF NOT EXISTS), so reopen is a
|
||||
// no-op.
|
||||
func openStore(db *sql.DB) (*Store, error) {
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
org TEXT NOT NULL,
|
||||
project TEXT NOT NULL DEFAULT '',
|
||||
usr TEXT NOT NULL DEFAULT '',
|
||||
agent TEXT NOT NULL,
|
||||
model TEXT NOT NULL DEFAULT '',
|
||||
host TEXT NOT NULL DEFAULT '',
|
||||
cwd TEXT NOT NULL DEFAULT '',
|
||||
terminal_url TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'starting',
|
||||
started_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
ended_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS sessions_org_status ON sessions(org, status);
|
||||
CREATE INDEX IF NOT EXISTS sessions_org_project ON sessions(org, project);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
org TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS session_events_sid ON session_events(session_id, created_at);
|
||||
`
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
return nil, fmt.Errorf("session schema: %w", err)
|
||||
}
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
// Create inserts a new session row.
|
||||
func (s *Store) Create(ctx context.Context, x Session) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO sessions (`+sessionCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
x.ID, x.Org, x.Project, x.User, x.Agent, x.Model, x.Host, x.Cwd,
|
||||
x.TerminalURL, x.Status, x.StartedAt, x.UpdatedAt, x.EndedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns one org-scoped session by id, or errNotFound.
|
||||
func (s *Store) Get(ctx context.Context, org, id string) (Session, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT `+sessionCols+` FROM sessions WHERE org=? AND id=?`, org, id)
|
||||
x, err := scanSession(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Session{}, errNotFound
|
||||
}
|
||||
return x, err
|
||||
}
|
||||
|
||||
// List returns the org's sessions, newest first, narrowed by f. The org
|
||||
// predicate leads every query; f only ANDs further, so a filter can never widen
|
||||
// past the tenant boundary.
|
||||
func (s *Store) List(ctx context.Context, org string, f Filter) ([]Session, error) {
|
||||
q := `SELECT ` + sessionCols + ` FROM sessions WHERE org=?`
|
||||
args := []any{org}
|
||||
if f.Live {
|
||||
q += ` AND ended_at=0`
|
||||
}
|
||||
if f.Status != "" {
|
||||
q += ` AND status=?`
|
||||
args = append(args, f.Status)
|
||||
}
|
||||
if f.Project != "" {
|
||||
q += ` AND project=?`
|
||||
args = append(args, f.Project)
|
||||
}
|
||||
if f.Host != "" {
|
||||
q += ` AND host=?`
|
||||
args = append(args, f.Host)
|
||||
}
|
||||
if f.Agent != "" {
|
||||
q += ` AND agent=?`
|
||||
args = append(args, f.Agent)
|
||||
}
|
||||
q += ` ORDER BY started_at DESC`
|
||||
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list sessions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Session
|
||||
for rows.Next() {
|
||||
x, err := scanSession(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, x)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Patch is the mutable subset of a session. A nil field leaves the column
|
||||
// unchanged, so a heartbeat that only refreshes updated_at need not resend the
|
||||
// terminal URL.
|
||||
type Patch struct {
|
||||
Status *string
|
||||
TerminalURL *string
|
||||
Model *string
|
||||
EndedAt *int64
|
||||
}
|
||||
|
||||
// Update applies p to one org-scoped session and returns the new row. updated_at
|
||||
// is always refreshed to now. Returns errNotFound when the id is absent.
|
||||
func (s *Store) Update(ctx context.Context, org, id string, now int64, p Patch) (Session, error) {
|
||||
sets := []string{"updated_at=?"}
|
||||
args := []any{now}
|
||||
if p.Status != nil {
|
||||
sets = append(sets, "status=?")
|
||||
args = append(args, *p.Status)
|
||||
}
|
||||
if p.TerminalURL != nil {
|
||||
sets = append(sets, "terminal_url=?")
|
||||
args = append(args, *p.TerminalURL)
|
||||
}
|
||||
if p.Model != nil {
|
||||
sets = append(sets, "model=?")
|
||||
args = append(args, *p.Model)
|
||||
}
|
||||
if p.EndedAt != nil {
|
||||
sets = append(sets, "ended_at=?")
|
||||
args = append(args, *p.EndedAt)
|
||||
}
|
||||
args = append(args, org, id)
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE sessions SET `+strings.Join(sets, ", ")+` WHERE org=? AND id=?`, args...)
|
||||
if err != nil {
|
||||
return Session{}, fmt.Errorf("update session: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return Session{}, errNotFound
|
||||
}
|
||||
return s.Get(ctx, org, id)
|
||||
}
|
||||
|
||||
// Delete removes one org-scoped session and its events. Returns errNotFound when
|
||||
// the id is absent.
|
||||
func (s *Store) Delete(ctx context.Context, org, id string) error {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE org=? AND id=?`, org, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete session: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return errNotFound
|
||||
}
|
||||
_, _ = s.db.ExecContext(ctx, `DELETE FROM session_events WHERE org=? AND session_id=?`, org, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddEvent records one lifecycle event against an existing org-scoped session.
|
||||
// It first confirms the session exists in this org (so an event can never be
|
||||
// filed against another tenant's id), then inserts.
|
||||
func (s *Store) AddEvent(ctx context.Context, e Event) error {
|
||||
if _, err := s.Get(ctx, e.Org, e.SessionID); err != nil {
|
||||
return err // errNotFound propagates → 404
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO session_events (id, session_id, org, kind, message, created_at) VALUES (?,?,?,?,?,?)`,
|
||||
e.ID, e.SessionID, e.Org, e.Kind, e.Message, e.CreatedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert event: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListEvents returns a session's events oldest-first, org-scoped.
|
||||
func (s *Store) ListEvents(ctx context.Context, org, sessionID string) ([]Event, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, session_id, org, kind, message, created_at FROM session_events
|
||||
WHERE org=? AND session_id=? ORDER BY created_at ASC`, org, sessionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list events: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Event
|
||||
for rows.Next() {
|
||||
var e Event
|
||||
if err := rows.Scan(&e.ID, &e.SessionID, &e.Org, &e.Kind, &e.Message, &e.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// scanSession reads one sessions row from a *sql.Row or *sql.Rows.
|
||||
func scanSession(sc interface{ Scan(...any) error }) (Session, error) {
|
||||
var x Session
|
||||
err := sc.Scan(&x.ID, &x.Org, &x.Project, &x.User, &x.Agent, &x.Model, &x.Host,
|
||||
&x.Cwd, &x.TerminalURL, &x.Status, &x.StartedAt, &x.UpdatedAt, &x.EndedAt)
|
||||
return x, err
|
||||
}
|
||||
+12
-3
@@ -257,9 +257,18 @@ func (s *Server) siteSlug(host string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
key := strings.TrimSuffix(host, suffix)
|
||||
slug, org, ok := strings.Cut(key, ".")
|
||||
if !ok {
|
||||
return "", false // single-label host (bare <label>.<apex>) is not org-scoped
|
||||
slug, org, scoped := strings.Cut(key, ".")
|
||||
if !scoped {
|
||||
// Bare `<slug>.<apex>` — the product URL every surface advertises, and the
|
||||
// ONLY shape the edge can physically serve today (a k8s Ingress host and a
|
||||
// Let's Encrypt wildcard each match exactly one label, so
|
||||
// `<slug>.<org>.<apex>` never routes nor gets TLS). The resolver serves it
|
||||
// iff it maps to exactly one live project (explicit binding, else a unique
|
||||
// live slug across orgs) — an ambiguous bare host is an honest 404.
|
||||
if IsReserved(key) || !slugRE.MatchString(key) {
|
||||
return "", false
|
||||
}
|
||||
return key, true
|
||||
}
|
||||
if strings.Contains(org, ".") {
|
||||
return "", false // more than two labels under the apex
|
||||
|
||||
@@ -146,8 +146,17 @@ func TestSiteSlug(t *testing.T) {
|
||||
site("my-cool-site.acme.hanzo.app", "my-cool-site.acme")
|
||||
site("myapp.maxpower.hanzo.app:443", "myapp.maxpower") // port stripped
|
||||
|
||||
// Bare `<slug>.<apex>` — the product URL (and the only shape the one-label
|
||||
// ingress wildcard + LE cert can serve). Key is the bare slug; the resolver
|
||||
// serves it iff it maps to exactly one live project.
|
||||
site("dave-synapse-demo.hanzo.app", "dave-synapse-demo")
|
||||
site("Brew.Hanzo.App", "brew") // case-insensitive
|
||||
site("vibe-check.hanzo.app:443", "vibe-check") // port stripped
|
||||
|
||||
notSite("hanzo.app") // apex, no label
|
||||
notSite("maxpower.hanzo.app") // single label — not org-scoped
|
||||
notSite("www.hanzo.app") // reserved bare label
|
||||
notSite("api.hanzo.app") // reserved bare label
|
||||
notSite("-bad.hanzo.app") // invalid bare slug
|
||||
notSite("www.acme.hanzo.app") // reserved slug label
|
||||
notSite("app.acme.hanzo.app") // reserved (real app host)
|
||||
notSite("api.acme.hanzo.app") // reserved
|
||||
|
||||
@@ -8,6 +8,7 @@ package team
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -123,6 +124,23 @@ func botActive(status string) bool {
|
||||
return status == "" || status == "active" || status == "ready"
|
||||
}
|
||||
|
||||
// agentReplyRunner is the ONE in-process seam the Chunter responder (chat.go) uses
|
||||
// to make a bot answer: it runs the agent through agents.RunOnBehalf — the SAME
|
||||
// billed/metered/recorded run path the HTTP POST /v1/agents/:id/run handler uses —
|
||||
// on behalf of the human who addressed it, and returns the model's text. A run that
|
||||
// executed but whose model errored (nil error, error-status Run) surfaces as an
|
||||
// error so the responder posts nothing rather than an empty bubble.
|
||||
func agentReplyRunner(ctx context.Context, org, userSub, agentID, input string) (string, error) {
|
||||
run, err := agents.RunOnBehalf(ctx, org, userSub, agentID, input)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if run.Error != "" {
|
||||
return "", errors.New(run.Error)
|
||||
}
|
||||
return run.Output, nil
|
||||
}
|
||||
|
||||
// botUserID derives a STABLE member account uuid from an agent id — a UUIDv5 over
|
||||
// namespace "agent:<id>", so re-syncs converge (never duplicate a bot member) and
|
||||
// the value is a valid UUID (the Person.personUuid + token invariant). Empty in →
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
package team
|
||||
|
||||
// chat.go is the Chunter agent-responder: it makes the org's agents (already
|
||||
// projected as workspace members by the roster reconcile) TALKABLE. When a human
|
||||
// posts a Chunter ChatMessage addressed to a bot member — a direct message whose
|
||||
// participants include the bot, or a channel message that @-mentions it — the
|
||||
// transactor runs that agent through the canonical in-process run path
|
||||
// (agents.RunOnBehalf, one balance gate + one debit + one recorded run) and posts
|
||||
// the model's answer back into the SAME conversation as that bot.
|
||||
//
|
||||
// SAFETY POSTURE (why every path here is bounded and lazy). The responder is the
|
||||
// ONE place in the team subsystem that can, per inbound message, initiate an
|
||||
// OUTBOUND model call — and that call self-routes through the cloud gateway
|
||||
// (api.hanzo.ai/v1). An unbounded version is a foot-gun: a replayed message
|
||||
// backlog, a channel firehose, or a persistently-failing model (e.g. the
|
||||
// publishable-key 403) could each fan out into thousands of concurrent HTTP calls
|
||||
// and exhaust the writer. So the responder is:
|
||||
// - OFF by default — Mount only wires runAgent when TEAM_AGENTS_ENABLED=1, so an
|
||||
// un-configured or misconfigured binary NEVER answers (nil runAgent ⇒ inert).
|
||||
// - Fresh-only — a message created before this process booted (a replay/backfill)
|
||||
// is NEVER answered; only genuinely-live posts trigger a turn.
|
||||
// - Single-flight per conversation+bot — at most one in-flight answer per
|
||||
// (workspace, space, bot); duplicates are dropped, not queued.
|
||||
// - Hard-capped — a global semaphore bounds concurrent turns; over the cap, drop.
|
||||
// - Circuit-broken — after repeated failures an agent is skipped for a cooldown
|
||||
// (the backoff that turns a 403 storm into a quiet trickle). No retries, ever.
|
||||
//
|
||||
// It is the chat twin of bots.go: bots.go is the READ/projection surface (agents
|
||||
// AS members); this is the WRITE/response surface (agents that ANSWER). The seam to
|
||||
// the LLM is a single injected func (AgentRunner) so the transactor stays decoupled
|
||||
// from clients/agents' concrete run machinery and the loop is unit-testable with a
|
||||
// fake runner.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"html"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Chunter class ids the responder recognizes. Kept beside the code that reads them
|
||||
// (the model-vocabulary-in-one-place rule projections.go follows).
|
||||
const (
|
||||
clChatMessage = "chunter:class:ChatMessage"
|
||||
clDirectMessage = "chunter:class:DirectMessage"
|
||||
)
|
||||
|
||||
// Responder bounds. Conservative defaults; the concurrency cap is overridable in
|
||||
// Mount via TEAM_AGENTS_MAX_CONCURRENCY.
|
||||
const (
|
||||
agentReplyTimeout = 90 * time.Second // one agent turn (model call + reply write)
|
||||
defaultMaxConcurrent = 4 // global in-flight turn cap default
|
||||
backfillGraceMs = 60_000 // a message created >60s before boot is backfill → never answered
|
||||
breakerThreshold = 3 // consecutive failures before an agent's circuit opens
|
||||
breakerCooldown = 60 * time.Second // how long a tripped agent is skipped
|
||||
)
|
||||
|
||||
// AgentRunner runs agent `agentID` for `org` on behalf of `userSub` with `input`
|
||||
// and returns the model's text output. It is injected in Mount (only when the
|
||||
// feature is enabled) as an adapter over agents.RunOnBehalf — the ONE in-process
|
||||
// run path (billed, metered, recorded) — so clients/team never speaks
|
||||
// clients/agents' concrete run types and the responder is testable with a fake.
|
||||
type AgentRunner func(ctx context.Context, org, userSub, agentID, input string) (string, error)
|
||||
|
||||
// agentBreaker is one agent's failure circuit. Guarded by its own mutex; stored in
|
||||
// transServer.breaker keyed by agent id.
|
||||
type agentBreaker struct {
|
||||
mu sync.Mutex
|
||||
fails int
|
||||
openUntil int64 // unix millis; while now < openUntil the agent is skipped
|
||||
}
|
||||
|
||||
// chatMsg is the parsed shape of one inbound Chunter ChatMessage create the
|
||||
// responder needs: where it lives (space + the attach coordinates a reply mirrors),
|
||||
// its author, its text, and WHEN it was created (the freshness gate).
|
||||
type chatMsg struct {
|
||||
objectID string
|
||||
space string
|
||||
attachedTo string
|
||||
attachedToClass string
|
||||
collection string
|
||||
message string // stored markup (HTML)
|
||||
authorUID string // account uuid (social id stripped of the "hanzo:" prefix)
|
||||
createdOn int64 // unix millis; a value below the boot floor is backfill
|
||||
}
|
||||
|
||||
// parseChatMessage returns the chatMsg for an applied tx iff it is a create of a
|
||||
// chunter:class:ChatMessage. Every other applied tx (roster txes, tracker/docs
|
||||
// writes, updates, removes) returns ok=false and is ignored. It reads the FLATTENED
|
||||
// applied tx, so a create wrapped in TxCollectionCUD (which applyTx unwraps, setting
|
||||
// attachedTo/attachedToClass/collection on the inner tx) is recognized identically
|
||||
// to a bare TxCreateDoc.
|
||||
func parseChatMessage(raw json.RawMessage) (chatMsg, bool) {
|
||||
var t map[string]any
|
||||
if err := json.Unmarshal(raw, &t); err != nil {
|
||||
return chatMsg{}, false
|
||||
}
|
||||
if str(t["_class"]) != clTxCreate || str(t["objectClass"]) != clChatMessage {
|
||||
return chatMsg{}, false
|
||||
}
|
||||
m := chatMsg{
|
||||
objectID: str(t["objectId"]),
|
||||
space: str(t["objectSpace"]),
|
||||
attachedTo: str(t["attachedTo"]),
|
||||
attachedToClass: str(t["attachedToClass"]),
|
||||
collection: str(t["collection"]),
|
||||
authorUID: stripHanzo(str(firstNonNil(t["createdBy"], t["modifiedBy"]))),
|
||||
createdOn: asInt64(firstNonNil(t["createdOn"], t["modifiedOn"])),
|
||||
}
|
||||
if a, ok := t["attributes"].(map[string]any); ok {
|
||||
m.message = str(a["message"])
|
||||
}
|
||||
// A top-level channel/DM message carries both (space == attachedTo == the
|
||||
// conversation id). Without them there is no conversation to answer into.
|
||||
if m.space == "" || m.attachedTo == "" {
|
||||
return chatMsg{}, false
|
||||
}
|
||||
return m, true
|
||||
}
|
||||
|
||||
// maybeAgentReply is the transactor hook (called from session.tx, the client WS
|
||||
// write path — NEVER from the roster/sync applyTx path, so a projection can never
|
||||
// trigger a reply). For every inbound, FRESH, human ChatMessage addressed to an
|
||||
// active bot member it fires one async, bounded agent turn per bot. It returns
|
||||
// immediately; the model call and the reply write happen in a recovered, capped
|
||||
// goroutine so the WS read loop is never blocked and a model/DB error can never
|
||||
// crash the session.
|
||||
func (srv *transServer) maybeAgentReply(org, workspace string, applied []json.RawMessage) {
|
||||
if srv.runAgent == nil || srv.bots == nil {
|
||||
return // responder disabled (TEAM_AGENTS_ENABLED unset ⇒ no runner wired)
|
||||
}
|
||||
// Cheap gate first: only touch the agents registry if a FRESH chat message was
|
||||
// written. A message created before this process booted is a replay/backfill and
|
||||
// is never answered (the anti-storm invariant the boot-backlog test locks).
|
||||
floor := srv.startedAt - backfillGraceMs
|
||||
var msgs []chatMsg
|
||||
for _, raw := range applied {
|
||||
m, ok := parseChatMessage(raw)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if srv.startedAt > 0 && m.createdOn > 0 && m.createdOn < floor {
|
||||
continue // backfill — never answer
|
||||
}
|
||||
msgs = append(msgs, m)
|
||||
}
|
||||
if len(msgs) == 0 {
|
||||
return
|
||||
}
|
||||
bots, err := srv.bots(context.Background(), org)
|
||||
if err != nil || len(bots) == 0 {
|
||||
return
|
||||
}
|
||||
byUID := make(map[string]Bot, len(bots))
|
||||
for _, b := range bots {
|
||||
if b.Active {
|
||||
byUID[botUserID(b.ID)] = b
|
||||
}
|
||||
}
|
||||
if len(byUID) == 0 {
|
||||
return
|
||||
}
|
||||
for _, m := range msgs {
|
||||
// Loop guard: a message a bot authored (its own reply, or another bot's)
|
||||
// never triggers a reply.
|
||||
if _, isBot := byUID[m.authorUID]; isBot {
|
||||
continue
|
||||
}
|
||||
for _, bot := range srv.replyTargets(org, workspace, m, byUID) {
|
||||
go srv.replyAsBot(org, workspace, m, bot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// replyTargets is the addressing policy: which bots should answer message m.
|
||||
// - Direct message: every active bot participant answers (a DM to a bot is, by
|
||||
// construction, addressed to it).
|
||||
// - Channel / group: only a bot the message explicitly @-mentions answers (a bot
|
||||
// does not answer every line in a shared channel).
|
||||
//
|
||||
// A bot is never double-targeted (DM + mention) — the seen set collapses them.
|
||||
func (srv *transServer) replyTargets(org, workspace string, m chatMsg, byUID map[string]Bot) []Bot {
|
||||
seen := map[string]bool{}
|
||||
var out []Bot
|
||||
if doc, _ := srv.store.get(org, workspace, m.space); doc != nil && str(doc["_class"]) == clDirectMessage {
|
||||
for _, mem := range toStringSlice(doc["members"]) {
|
||||
if b, ok := byUID[mem]; ok && !seen[mem] {
|
||||
seen[mem] = true
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
for uid, b := range byUID {
|
||||
if !seen[uid] && mentionsBot(m.message, uid) {
|
||||
seen[uid] = true
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// replyAsBot runs one bounded agent turn and posts its answer as the bot, in the
|
||||
// SAME conversation (space + attach coordinates mirrored from the inbound message)
|
||||
// and through the SAME write path (applyTx + broadcast) the SPA uses. It is
|
||||
// single-flight per (workspace, space, bot), globally concurrency-capped, and
|
||||
// circuit-broken per agent; recovered + timeout-bounded so a panicking model
|
||||
// adapter or a hung call can neither crash the transactor nor leak.
|
||||
func (srv *transServer) replyAsBot(org, workspace string, m chatMsg, bot Bot) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil && srv.log != nil {
|
||||
srv.log.Error("team: agent reply panicked", "agent", bot.ID, "err", r)
|
||||
}
|
||||
}()
|
||||
|
||||
// Single-flight: at most one in-flight answer per conversation+bot. A burst of
|
||||
// messages to the same DM while a turn is running collapses to one.
|
||||
flightKey := workspace + "|" + m.space + "|" + bot.ID
|
||||
if _, busy := srv.inflight.LoadOrStore(flightKey, struct{}{}); busy {
|
||||
return
|
||||
}
|
||||
defer srv.inflight.Delete(flightKey)
|
||||
|
||||
// Circuit breaker: skip a persistently-failing agent for a cooldown (the backoff
|
||||
// that turns a 403/5xx storm into a quiet trickle).
|
||||
if srv.breakerOpen(bot.ID) {
|
||||
return
|
||||
}
|
||||
|
||||
// Hard concurrency cap: never more than N turns in flight process-wide. Over the
|
||||
// cap we DROP (bounded, no queue) rather than pile up goroutines/HTTP calls.
|
||||
if srv.sem != nil {
|
||||
select {
|
||||
case srv.sem <- struct{}{}:
|
||||
defer func() { <-srv.sem }()
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
prompt := plainText(m.message)
|
||||
if prompt == "" {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), agentReplyTimeout)
|
||||
defer cancel()
|
||||
out, err := srv.runAgent(ctx, org, m.authorUID, bot.ID, prompt)
|
||||
if err != nil {
|
||||
srv.breakerRecord(bot.ID, false)
|
||||
if srv.log != nil {
|
||||
srv.log.Warn("team: agent reply failed", "agent", bot.ID, "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
srv.breakerRecord(bot.ID, true)
|
||||
if strings.TrimSpace(out) == "" {
|
||||
return
|
||||
}
|
||||
botUID := botUserID(bot.ID)
|
||||
tx := attachedCreateTx(newMsgID(), clChatMessage, m.space, m.attachedTo, m.attachedToClass,
|
||||
pick(m.collection, "messages"), "hanzo:"+botUID, map[string]any{"message": htmlMarkup(out)})
|
||||
raw, err := json.Marshal(tx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// A detached system session bound to (org, workspace) — the same shape the
|
||||
// /v1/team/bots/sync reconcile uses to write into a workspace off the WS loop.
|
||||
sess := &session{server: srv, store: srv.store, hier: srv.hier, org: org, workspace: workspace, account: botUID}
|
||||
if _, ap := sess.applyTx(raw); len(ap) > 0 {
|
||||
srv.hub.broadcast(workspace, ap)
|
||||
}
|
||||
}
|
||||
|
||||
// breakerOpen reports whether agent `id`'s circuit is currently tripped.
|
||||
func (srv *transServer) breakerOpen(id string) bool {
|
||||
v, ok := srv.breaker.Load(id)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
b := v.(*agentBreaker)
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return time.Now().UnixMilli() < b.openUntil
|
||||
}
|
||||
|
||||
// breakerRecord folds one turn's outcome into agent `id`'s circuit: a success
|
||||
// resets it; breakerThreshold consecutive failures trip it for breakerCooldown.
|
||||
func (srv *transServer) breakerRecord(id string, ok bool) {
|
||||
v, _ := srv.breaker.LoadOrStore(id, &agentBreaker{})
|
||||
b := v.(*agentBreaker)
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if ok {
|
||||
b.fails = 0
|
||||
b.openUntil = 0
|
||||
return
|
||||
}
|
||||
b.fails++
|
||||
if b.fails >= breakerThreshold {
|
||||
b.openUntil = time.Now().UnixMilli() + breakerCooldown.Milliseconds()
|
||||
b.fails = 0 // count fresh after the cooldown
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// stripHanzo drops the "hanzo:" social-id prefix, yielding the bare account uuid
|
||||
// (the key the bot map and DM.members use). A value without the prefix is returned
|
||||
// unchanged.
|
||||
func stripHanzo(id string) string { return strings.TrimPrefix(id, "hanzo:") }
|
||||
|
||||
var tagRe = regexp.MustCompile(`<[^>]*>`)
|
||||
|
||||
// plainText renders stored message markup (HTML) to the plain text an LLM should
|
||||
// read: tags dropped, entities unescaped, block tags becoming spaces, whitespace
|
||||
// collapsed. Deterministic and dependency-free (no markup library on the hot path).
|
||||
func plainText(markup string) string {
|
||||
// Turn common block boundaries into spaces so words don't fuse across tags.
|
||||
s := regexp.MustCompile(`(?i)<(/p|br|/div|/li)\s*/?>`).ReplaceAllString(markup, " ")
|
||||
s = tagRe.ReplaceAllString(s, "")
|
||||
s = html.UnescapeString(s)
|
||||
return strings.TrimSpace(regexp.MustCompile(`\s+`).ReplaceAllString(s, " "))
|
||||
}
|
||||
|
||||
// htmlMarkup wraps an agent's plain-text answer as the minimal ProseMirror-
|
||||
// compatible markup Chunter stores: each non-empty line an escaped <p>. Empty input
|
||||
// yields an empty paragraph (never invalid markup).
|
||||
func htmlMarkup(text string) string {
|
||||
lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
|
||||
var b strings.Builder
|
||||
for _, ln := range lines {
|
||||
ln = strings.TrimRight(ln, " \t")
|
||||
if ln == "" {
|
||||
continue
|
||||
}
|
||||
b.WriteString("<p>")
|
||||
b.WriteString(html.EscapeString(ln))
|
||||
b.WriteString("</p>")
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return "<p></p>"
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// mentionsBot reports whether message markup @-references the bot member. Chunter
|
||||
// stores a mention as a reference node carrying the referenced Person's _id, which
|
||||
// is the deterministic PersonRef(uid) — so a substring match on that id is the
|
||||
// mention test (no markup parse needed).
|
||||
func mentionsBot(message, uid string) bool {
|
||||
return uid != "" && strings.Contains(message, PersonRef(uid))
|
||||
}
|
||||
|
||||
// toStringSlice coerces a JSON array (from a stored doc) to []string, dropping
|
||||
// non-string entries. Nil-safe.
|
||||
func toStringSlice(v any) []string {
|
||||
arr, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
if s, ok := it.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// asInt64 coerces a JSON number (float64) or int to int64. Non-numeric ⇒ 0.
|
||||
func asInt64(v any) int64 {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int64(n)
|
||||
case int64:
|
||||
return n
|
||||
case int:
|
||||
return int64(n)
|
||||
case json.Number:
|
||||
i, _ := n.Int64()
|
||||
return i
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// newMsgID mints a fresh opaque doc id for a reply message. Chunter treats _id as an
|
||||
// opaque Ref; 16 random bytes hex-encoded is collision-free in practice and a valid
|
||||
// single path/ref segment.
|
||||
func newMsgID() string {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
// rand.Read never fails on supported platforms; fall back to a time seed so a
|
||||
// reply id is still unique-enough rather than empty.
|
||||
return "msg" + hex.EncodeToString([]byte(time.Now().Format(time.RFC3339Nano)))
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
package team
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── pure helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
func TestParseChatMessageRecognizesChunterCreate(t *testing.T) {
|
||||
raw := mustMarshal(t, map[string]any{
|
||||
"_class": clTxCreate, "objectId": "m1", "objectClass": clChatMessage,
|
||||
"objectSpace": "dm1", "attachedTo": "dm1", "attachedToClass": clDirectMessage,
|
||||
"collection": "messages", "createdBy": "hanzo:u-human",
|
||||
"attributes": map[string]any{"message": "<p>hi</p>"},
|
||||
})
|
||||
m, ok := parseChatMessage(raw)
|
||||
if !ok {
|
||||
t.Fatal("parseChatMessage did not recognize a ChatMessage create")
|
||||
}
|
||||
if m.space != "dm1" || m.attachedTo != "dm1" || m.attachedToClass != clDirectMessage ||
|
||||
m.collection != "messages" || m.message != "<p>hi</p>" || m.authorUID != "u-human" {
|
||||
t.Fatalf("parsed chatMsg wrong: %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChatMessageIgnoresNonChat(t *testing.T) {
|
||||
// A Person create (roster projection) must not be read as a chat message.
|
||||
raw := mustMarshal(t, map[string]any{
|
||||
"_class": clTxCreate, "objectId": "p1", "objectClass": clPerson,
|
||||
"objectSpace": spaceContacts, "attributes": map[string]any{"name": ",x"},
|
||||
})
|
||||
if _, ok := parseChatMessage(raw); ok {
|
||||
t.Fatal("a Person create was misread as a chat message")
|
||||
}
|
||||
// A ChatMessage create missing its conversation coordinates is not answerable.
|
||||
raw2 := mustMarshal(t, map[string]any{
|
||||
"_class": clTxCreate, "objectId": "m2", "objectClass": clChatMessage,
|
||||
"attributes": map[string]any{"message": "hi"},
|
||||
})
|
||||
if _, ok := parseChatMessage(raw2); ok {
|
||||
t.Fatal("a ChatMessage with no space/attachedTo was accepted")
|
||||
}
|
||||
// An update tx is never a create.
|
||||
raw3 := mustMarshal(t, map[string]any{"_class": clTxUpdate, "objectId": "m1", "objectClass": clChatMessage})
|
||||
if _, ok := parseChatMessage(raw3); ok {
|
||||
t.Fatal("an update tx was misread as a create")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlainText(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"<p>hello <b>world</b></p>": "hello world",
|
||||
"a<br>b": "a b",
|
||||
"<p>one</p><p>two</p>": "one two",
|
||||
"tom & jerry <3": "tom & jerry <3",
|
||||
" <div> spaced out </div> ": "spaced out",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := plainText(in); got != want {
|
||||
t.Errorf("plainText(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLMarkupEscapesAndWraps(t *testing.T) {
|
||||
got := htmlMarkup("a <script>x</script>\n\nb & c")
|
||||
if strings.Contains(got, "<script>") {
|
||||
t.Fatalf("htmlMarkup did not escape html: %q", got)
|
||||
}
|
||||
if got != "<p>a <script>x</script></p><p>b & c</p>" {
|
||||
t.Fatalf("htmlMarkup wrong: %q", got)
|
||||
}
|
||||
if htmlMarkup("") != "<p></p>" {
|
||||
t.Fatalf("htmlMarkup(empty) = %q, want <p></p>", htmlMarkup(""))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripHanzo(t *testing.T) {
|
||||
if stripHanzo("hanzo:abc") != "abc" {
|
||||
t.Fatal("stripHanzo did not drop the prefix")
|
||||
}
|
||||
if stripHanzo("abc") != "abc" {
|
||||
t.Fatal("stripHanzo mangled a bare uuid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMentionsBot(t *testing.T) {
|
||||
uid := "u-bot"
|
||||
ref := PersonRef(uid) // person-u-bot
|
||||
if !mentionsBot(`hey <span data-id="`+ref+`">@bot</span> please`, uid) {
|
||||
t.Fatal("mentionsBot missed a reference to the bot")
|
||||
}
|
||||
if mentionsBot("hey nobody here", uid) {
|
||||
t.Fatal("mentionsBot matched a message with no reference")
|
||||
}
|
||||
if mentionsBot("anything", "") {
|
||||
t.Fatal("mentionsBot must never match an empty uid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToStringSlice(t *testing.T) {
|
||||
got := toStringSlice([]any{"a", 1, "b", nil})
|
||||
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
|
||||
t.Fatalf("toStringSlice = %v, want [a b]", got)
|
||||
}
|
||||
if toStringSlice(nil) != nil || toStringSlice("nope") != nil {
|
||||
t.Fatal("toStringSlice must be nil-safe for non-arrays")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMsgIDUniqueAndOpaque(t *testing.T) {
|
||||
a, b := newMsgID(), newMsgID()
|
||||
if a == "" || a == b {
|
||||
t.Fatalf("newMsgID not unique/non-empty: %q %q", a, b)
|
||||
}
|
||||
if strings.ContainsAny(a, "/ \t") {
|
||||
t.Fatalf("newMsgID is not a clean ref segment: %q", a)
|
||||
}
|
||||
}
|
||||
|
||||
// ── addressing policy ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestReplyTargetsDirectMessage(t *testing.T) {
|
||||
const org, human = "acme", "11111111-1111-4111-8111-111111111111"
|
||||
bot := Bot{ID: "agent_enso", Name: "enso", Active: true}
|
||||
srv, _, ws := rosterServer(t, org, human, "Ada", []Bot{bot})
|
||||
botUID := botUserID(bot.ID)
|
||||
|
||||
dmID := "dm-1"
|
||||
putDoc(t, srv, org, ws, map[string]any{
|
||||
"_id": dmID, "_class": clDirectMessage, "space": dmID,
|
||||
"members": []any{human, botUID},
|
||||
})
|
||||
byUID := map[string]Bot{botUID: bot}
|
||||
m := chatMsg{space: dmID, authorUID: human, message: "<p>hi</p>"}
|
||||
got := srv.replyTargets(org, ws, m, byUID)
|
||||
if len(got) != 1 || got[0].ID != bot.ID {
|
||||
t.Fatalf("DM replyTargets = %v, want the one bot member", got)
|
||||
}
|
||||
|
||||
// A DM between two humans (no bot member) → no target.
|
||||
putDoc(t, srv, org, ws, map[string]any{
|
||||
"_id": "dm-2", "_class": clDirectMessage, "space": "dm-2",
|
||||
"members": []any{human, "someone-else"},
|
||||
})
|
||||
if got := srv.replyTargets(org, ws, chatMsg{space: "dm-2", authorUID: human}, byUID); len(got) != 0 {
|
||||
t.Fatalf("human-only DM replyTargets = %v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplyTargetsChannelMentionOnly(t *testing.T) {
|
||||
const org, human = "acme", "22222222-2222-4222-8222-222222222222"
|
||||
bot := Bot{ID: "agent_enso", Name: "enso", Active: true}
|
||||
srv, _, ws := rosterServer(t, org, human, "Ada", []Bot{bot})
|
||||
botUID := botUserID(bot.ID)
|
||||
byUID := map[string]Bot{botUID: bot}
|
||||
|
||||
chID := "channel-1"
|
||||
putDoc(t, srv, org, ws, map[string]any{"_id": chID, "_class": clChannel, "space": chID})
|
||||
|
||||
// Plain channel chatter → the bot stays quiet.
|
||||
if got := srv.replyTargets(org, ws, chatMsg{space: chID, authorUID: human, message: "<p>hello all</p>"}, byUID); len(got) != 0 {
|
||||
t.Fatalf("un-mentioned channel message replyTargets = %v, want none", got)
|
||||
}
|
||||
// A message @-mentioning the bot → it answers.
|
||||
mention := `<p>hey <span data-id="` + PersonRef(botUID) + `">@enso</span></p>`
|
||||
if got := srv.replyTargets(org, ws, chatMsg{space: chID, authorUID: human, message: mention}, byUID); len(got) != 1 || got[0].ID != bot.ID {
|
||||
t.Fatalf("mention channel message replyTargets = %v, want the bot", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── full reply loop (fake runner, real store) ──────────────────────────────────
|
||||
|
||||
// fakeRunner records the one call and returns a fixed answer, signalling done.
|
||||
type fakeRunner struct {
|
||||
mu sync.Mutex
|
||||
called bool
|
||||
org, userSub, agent, text string
|
||||
out string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeRunner) run(_ context.Context, org, userSub, agentID, input string) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.called = true
|
||||
f.org, f.userSub, f.agent, f.text = org, userSub, agentID, input
|
||||
return f.out, f.err
|
||||
}
|
||||
|
||||
func TestMaybeAgentReplyAnswersDirectMessage(t *testing.T) {
|
||||
const org, human = "maxpower", "113d4dd4-2486-40de-be2b-88d6e3e0b718"
|
||||
bot := Bot{ID: "agent_enso", Name: "enso", Active: true}
|
||||
srv, _, ws := rosterServer(t, org, human, "Dave Lorenzini", []Bot{bot})
|
||||
botUID := botUserID(bot.ID)
|
||||
|
||||
fr := &fakeRunner{out: "Hello Dave, I am enso."}
|
||||
srv.runAgent = fr.run
|
||||
|
||||
dmID := "dm-enso"
|
||||
putDoc(t, srv, org, ws, map[string]any{
|
||||
"_id": dmID, "_class": clDirectMessage, "space": dmID,
|
||||
"members": []any{human, botUID},
|
||||
})
|
||||
|
||||
inbound := chatCreateRaw(t, dmID, clDirectMessage, "hanzo:"+human, "<p>hello <b>agent</b></p>")
|
||||
srv.maybeAgentReply(org, ws, []json.RawMessage{inbound})
|
||||
|
||||
waitFor(t, 2*time.Second, func() bool {
|
||||
return len(botMessages(t, srv, org, ws, botUID)) == 1
|
||||
}, "the bot never posted a reply")
|
||||
|
||||
// The reply is the model output, authored BY the bot, in the SAME conversation.
|
||||
reply := botMessages(t, srv, org, ws, botUID)[0]
|
||||
if reply["space"] != dmID || reply["attachedTo"] != dmID {
|
||||
t.Fatalf("reply not in the DM conversation: %v", reply)
|
||||
}
|
||||
attrs, _ := reply["attributes"].(map[string]any)
|
||||
_ = attrs
|
||||
if got := str(reply["message"]); !strings.Contains(got, "I am enso") {
|
||||
t.Fatalf("reply message = %q, want the model output", got)
|
||||
}
|
||||
|
||||
// The runner was invoked on-behalf-of the human, with the agent id and the
|
||||
// PLAIN-TEXT prompt (markup stripped).
|
||||
fr.mu.Lock()
|
||||
defer fr.mu.Unlock()
|
||||
if !fr.called || fr.org != org || fr.userSub != human || fr.agent != bot.ID || fr.text != "hello agent" {
|
||||
t.Fatalf("runner call wrong: called=%v org=%q sub=%q agent=%q text=%q", fr.called, fr.org, fr.userSub, fr.agent, fr.text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeAgentReplyNeverAnswersItself(t *testing.T) {
|
||||
const org, human = "acme", "33333333-3333-4333-8333-333333333333"
|
||||
bot := Bot{ID: "agent_enso", Name: "enso", Active: true}
|
||||
srv, _, ws := rosterServer(t, org, human, "Ada", []Bot{bot})
|
||||
botUID := botUserID(bot.ID)
|
||||
|
||||
fr := &fakeRunner{out: "should not fire"}
|
||||
srv.runAgent = fr.run
|
||||
|
||||
dmID := "dm-enso"
|
||||
putDoc(t, srv, org, ws, map[string]any{
|
||||
"_id": dmID, "_class": clDirectMessage, "space": dmID,
|
||||
"members": []any{human, botUID},
|
||||
})
|
||||
|
||||
// The message is authored BY THE BOT — the loop guard must suppress any reply.
|
||||
selfMsg := chatCreateRaw(t, dmID, clDirectMessage, "hanzo:"+botUID, "<p>my own message</p>")
|
||||
srv.maybeAgentReply(org, ws, []json.RawMessage{selfMsg})
|
||||
|
||||
// Give any (erroneous) goroutine a chance, then assert nothing happened.
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
fr.mu.Lock()
|
||||
called := fr.called
|
||||
fr.mu.Unlock()
|
||||
if called {
|
||||
t.Fatal("runner fired for a bot-authored message (infinite-loop risk)")
|
||||
}
|
||||
if n := len(botMessages(t, srv, org, ws, botUID)); n != 0 {
|
||||
t.Fatalf("bot posted %d messages for its own message, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeAgentReplyDisabledWhenNoRunner(t *testing.T) {
|
||||
const org, human = "acme", "44444444-4444-4444-8444-444444444444"
|
||||
bot := Bot{ID: "agent_enso", Name: "enso", Active: true}
|
||||
srv, _, ws := rosterServer(t, org, human, "Ada", []Bot{bot})
|
||||
botUID := botUserID(bot.ID)
|
||||
srv.runAgent = nil // responder off
|
||||
|
||||
dmID := "dm-enso"
|
||||
putDoc(t, srv, org, ws, map[string]any{
|
||||
"_id": dmID, "_class": clDirectMessage, "space": dmID, "members": []any{human, botUID},
|
||||
})
|
||||
inbound := chatCreateRaw(t, dmID, clDirectMessage, "hanzo:"+human, "<p>hi</p>")
|
||||
srv.maybeAgentReply(org, ws, []json.RawMessage{inbound}) // must be a no-op, no panic
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if n := len(botMessages(t, srv, org, ws, botUID)); n != 0 {
|
||||
t.Fatalf("reply posted with no runner wired: %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ── hardening / anti-storm regression ──────────────────────────────────────────
|
||||
|
||||
// countingRunner records how many times it was invoked (thread-safe) and returns a
|
||||
// fixed outcome. It is the "outbound call" probe the boot-backlog test asserts on.
|
||||
type countingRunner struct {
|
||||
mu sync.Mutex
|
||||
n int
|
||||
out string
|
||||
err error
|
||||
gate chan struct{} // if non-nil, each call blocks on it (for concurrency tests)
|
||||
}
|
||||
|
||||
func (c *countingRunner) run(_ context.Context, _, _, _, _ string) (string, error) {
|
||||
c.mu.Lock()
|
||||
c.n++
|
||||
c.mu.Unlock()
|
||||
if c.gate != nil {
|
||||
<-c.gate
|
||||
}
|
||||
return c.out, c.err
|
||||
}
|
||||
|
||||
func (c *countingRunner) calls() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.n
|
||||
}
|
||||
|
||||
// TestNoReplyToBacklogAtBoot is the anti-storm invariant the writer post-mortem
|
||||
// demands: a workspace with a BACKLOG of old messages, replayed through the
|
||||
// responder right after boot, must produce ZERO outbound model calls. Only a
|
||||
// genuinely fresh (post-boot) message is ever answered. This is what prevents "a
|
||||
// replayed message backlog fans out into thousands of HTTP calls".
|
||||
func TestNoReplyToBacklogAtBoot(t *testing.T) {
|
||||
const org, human = "maxpower", "113d4dd4-2486-40de-be2b-88d6e3e0b718"
|
||||
bot := Bot{ID: "agent_enso", Name: "enso", Active: true}
|
||||
srv, _, ws := rosterServer(t, org, human, "Dave", []Bot{bot})
|
||||
botUID := botUserID(bot.ID)
|
||||
|
||||
cr := &countingRunner{out: "hi"}
|
||||
srv.runAgent = cr.run
|
||||
srv.startedAt = time.Now().UnixMilli() // boot NOW
|
||||
|
||||
dmID := "dm-enso"
|
||||
putDoc(t, srv, org, ws, map[string]any{
|
||||
"_id": dmID, "_class": clDirectMessage, "space": dmID, "members": []any{human, botUID},
|
||||
})
|
||||
|
||||
// A backlog of 500 messages, each created an hour before boot (a replay/backfill).
|
||||
old := srv.startedAt - int64(60*60*1000)
|
||||
backlog := make([]json.RawMessage, 0, 500)
|
||||
for i := 0; i < 500; i++ {
|
||||
backlog = append(backlog, chatCreateRawAt(t, dmID, clDirectMessage, "hanzo:"+human, "<p>old</p>", old))
|
||||
}
|
||||
srv.maybeAgentReply(org, ws, backlog)
|
||||
|
||||
time.Sleep(150 * time.Millisecond) // give any (erroneous) goroutine a chance
|
||||
if n := cr.calls(); n != 0 {
|
||||
t.Fatalf("backlog replay fired %d outbound model calls, want 0 (storm risk)", n)
|
||||
}
|
||||
|
||||
// A single FRESH message IS answered — the filter is precise, not a blanket off.
|
||||
fresh := chatCreateRawAt(t, dmID, clDirectMessage, "hanzo:"+human, "<p>hello now</p>", time.Now().UnixMilli())
|
||||
srv.maybeAgentReply(org, ws, []json.RawMessage{fresh})
|
||||
waitFor(t, 2*time.Second, func() bool { return cr.calls() == 1 }, "a fresh post was not answered")
|
||||
}
|
||||
|
||||
// TestConcurrencyCapBounded proves the hard concurrency cap: with the semaphore set
|
||||
// to 2, firing 8 messages to DISTINCT conversations must never run more than 2
|
||||
// turns at once — the surplus is DROPPED, not queued (no unbounded goroutine/HTTP
|
||||
// fan-out).
|
||||
func TestConcurrencyCapBounded(t *testing.T) {
|
||||
const org, human = "acme", "11111111-1111-4111-8111-111111111111"
|
||||
bot := Bot{ID: "agent_enso", Name: "enso", Active: true}
|
||||
srv, _, ws := rosterServer(t, org, human, "Ada", []Bot{bot})
|
||||
botUID := botUserID(bot.ID)
|
||||
|
||||
gate := make(chan struct{})
|
||||
cr := &countingRunner{out: "ok", gate: gate}
|
||||
srv.runAgent = cr.run
|
||||
srv.sem = make(chan struct{}, 2) // cap = 2
|
||||
|
||||
// 8 distinct DMs (distinct single-flight keys) all with the bot.
|
||||
for i := 0; i < 8; i++ {
|
||||
dm := "dm-" + string(rune('a'+i))
|
||||
putDoc(t, srv, org, ws, map[string]any{
|
||||
"_id": dm, "_class": clDirectMessage, "space": dm, "members": []any{human, botUID},
|
||||
})
|
||||
srv.maybeAgentReply(org, ws, []json.RawMessage{chatCreateRaw(t, dm, clDirectMessage, "hanzo:"+human, "<p>hi</p>")})
|
||||
}
|
||||
|
||||
// Exactly cap(=2) turns acquire the semaphore and block on the gate; the other 6
|
||||
// hit the default branch and drop. Wait for the 2 to be in-flight, then confirm
|
||||
// no third starts.
|
||||
waitFor(t, 2*time.Second, func() bool { return cr.calls() == 2 }, "cap turns never started")
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
if n := cr.calls(); n != 2 {
|
||||
t.Fatalf("in-flight turns = %d, want exactly 2 (cap); surplus must drop, not queue", n)
|
||||
}
|
||||
close(gate) // release the 2
|
||||
}
|
||||
|
||||
// TestSingleFlightPerConversation proves at most one in-flight answer per
|
||||
// (workspace, space, bot): a burst of 5 messages to the SAME DM collapses to ONE
|
||||
// turn while it runs; the rest are dropped.
|
||||
func TestSingleFlightPerConversation(t *testing.T) {
|
||||
const org, human = "acme", "22222222-2222-4222-8222-222222222222"
|
||||
bot := Bot{ID: "agent_enso", Name: "enso", Active: true}
|
||||
srv, _, ws := rosterServer(t, org, human, "Ada", []Bot{bot})
|
||||
botUID := botUserID(bot.ID)
|
||||
|
||||
gate := make(chan struct{})
|
||||
cr := &countingRunner{out: "ok", gate: gate}
|
||||
srv.runAgent = cr.run
|
||||
|
||||
dm := "dm-solo"
|
||||
putDoc(t, srv, org, ws, map[string]any{
|
||||
"_id": dm, "_class": clDirectMessage, "space": dm, "members": []any{human, botUID},
|
||||
})
|
||||
for i := 0; i < 5; i++ {
|
||||
srv.maybeAgentReply(org, ws, []json.RawMessage{chatCreateRaw(t, dm, clDirectMessage, "hanzo:"+human, "<p>spam</p>")})
|
||||
}
|
||||
waitFor(t, 2*time.Second, func() bool { return cr.calls() == 1 }, "no turn started")
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
if n := cr.calls(); n != 1 {
|
||||
t.Fatalf("single-flight broke: %d concurrent turns for one conversation, want 1", n)
|
||||
}
|
||||
close(gate)
|
||||
}
|
||||
|
||||
// TestCircuitBreakerBacksOff proves a persistently-failing agent is skipped after
|
||||
// breakerThreshold failures — the backoff that turns a 403 storm into a quiet
|
||||
// trickle. Driven synchronously (replyAsBot direct) for determinism.
|
||||
func TestCircuitBreakerBacksOff(t *testing.T) {
|
||||
const org, human = "acme", "33333333-3333-4333-8333-333333333333"
|
||||
bot := Bot{ID: "agent_enso", Name: "enso", Active: true}
|
||||
srv, _, ws := rosterServer(t, org, human, "Ada", []Bot{bot})
|
||||
botUID := botUserID(bot.ID)
|
||||
|
||||
cr := &countingRunner{err: errForced}
|
||||
srv.runAgent = cr.run
|
||||
|
||||
// Each call to a DISTINCT conversation (so single-flight never collapses them),
|
||||
// same bot. After breakerThreshold failures the circuit opens and the runner is
|
||||
// no longer called.
|
||||
for i := 0; i < 10; i++ {
|
||||
dm := "dm-" + string(rune('a'+i))
|
||||
putDoc(t, srv, org, ws, map[string]any{
|
||||
"_id": dm, "_class": clDirectMessage, "space": dm, "members": []any{human, botUID},
|
||||
})
|
||||
m := chatMsg{space: dm, attachedTo: dm, attachedToClass: clDirectMessage, collection: "messages",
|
||||
authorUID: human, message: "<p>hi</p>", createdOn: time.Now().UnixMilli()}
|
||||
srv.replyAsBot(org, ws, m, bot) // synchronous
|
||||
}
|
||||
if n := cr.calls(); n != breakerThreshold {
|
||||
t.Fatalf("runner called %d times, want %d (circuit must open after threshold)", n, breakerThreshold)
|
||||
}
|
||||
if !srv.breakerOpen(bot.ID) {
|
||||
t.Fatal("circuit did not open after repeated failures")
|
||||
}
|
||||
}
|
||||
|
||||
// ── test helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
const clChannel = "chunter:class:Channel"
|
||||
|
||||
var errForced = errForcedType("forced failure")
|
||||
|
||||
type errForcedType string
|
||||
|
||||
func (e errForcedType) Error() string { return string(e) }
|
||||
|
||||
func mustMarshal(t *testing.T, v any) json.RawMessage {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func putDoc(t *testing.T, srv *transServer, org, ws string, doc map[string]any) {
|
||||
t.Helper()
|
||||
if err := srv.store.put(org, ws, doc); err != nil {
|
||||
t.Fatalf("put doc: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func chatCreateRaw(t *testing.T, space, spaceClass, author, message string) json.RawMessage {
|
||||
t.Helper()
|
||||
return chatCreateRawAt(t, space, spaceClass, author, message, time.Now().UnixMilli())
|
||||
}
|
||||
|
||||
// chatCreateRawAt is chatCreateRaw with an explicit createdOn (unix millis) so a
|
||||
// test can forge a backlog message that predates the server boot.
|
||||
func chatCreateRawAt(t *testing.T, space, spaceClass, author, message string, createdOn int64) json.RawMessage {
|
||||
t.Helper()
|
||||
return mustMarshal(t, map[string]any{
|
||||
"_class": clTxCreate, "objectId": newMsgID(), "objectClass": clChatMessage,
|
||||
"objectSpace": space, "attachedTo": space, "attachedToClass": spaceClass,
|
||||
"collection": "messages", "createdBy": author, "modifiedBy": author,
|
||||
"createdOn": createdOn, "modifiedOn": createdOn,
|
||||
"attributes": map[string]any{"message": message},
|
||||
})
|
||||
}
|
||||
|
||||
// botMessages returns every ChatMessage doc authored by the bot (social id).
|
||||
func botMessages(t *testing.T, srv *transServer, org, ws, botUID string) []map[string]any {
|
||||
t.Helper()
|
||||
docs, err := srv.store.byClasses(org, ws, []string{clChatMessage})
|
||||
if err != nil {
|
||||
t.Fatalf("byClasses: %v", err)
|
||||
}
|
||||
var out []map[string]any
|
||||
for _, d := range docs {
|
||||
if str(d["createdBy"]) == "hanzo:"+botUID {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, d time.Duration, cond func() bool, msg string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(d)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal(msg)
|
||||
}
|
||||
+44
-6
@@ -5,7 +5,9 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
luxlog "github.com/luxfi/log"
|
||||
"github.com/zap-proto/zip"
|
||||
@@ -84,12 +86,27 @@ func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
}
|
||||
|
||||
trans := &transServer{
|
||||
store: newStore(filepath.Join(root, "workspaces")),
|
||||
hier: buildHierarchy(modelJSON),
|
||||
hub: newHub(),
|
||||
secret: cfg.serverSecret,
|
||||
accounts: accounts,
|
||||
bots: agentsBotLister, // the ONE in-process seam to the agents registry
|
||||
store: newStore(filepath.Join(root, "workspaces")),
|
||||
hier: buildHierarchy(modelJSON),
|
||||
hub: newHub(),
|
||||
secret: cfg.serverSecret,
|
||||
accounts: accounts,
|
||||
bots: agentsBotLister, // the ONE in-process seam to the agents registry
|
||||
log: log,
|
||||
startedAt: time.Now().UnixMilli(), // freshness floor: messages older than boot are never answered
|
||||
}
|
||||
// Chunter agent responder: OFF by default (one-way safe default). Only when
|
||||
// TEAM_AGENTS_ENABLED=1 do we wire the LLM seam + the concurrency cap, so an
|
||||
// un-configured OR misconfigured binary is provably inert — nil runAgent makes
|
||||
// maybeAgentReply return at the top and NO outbound model call can ever fire.
|
||||
// (This is the containment the writer-crash post-mortem demands: a new binary
|
||||
// must be safe by default and only answer when an operator opts in.)
|
||||
if os.Getenv("TEAM_AGENTS_ENABLED") == "1" {
|
||||
trans.runAgent = agentReplyRunner
|
||||
trans.sem = make(chan struct{}, teamAgentsMaxConcurrency())
|
||||
log.Info("team: Chunter agent responder ENABLED", "maxConcurrency", cap(trans.sem))
|
||||
} else {
|
||||
log.Info("team: Chunter agent responder OFF (set TEAM_AGENTS_ENABLED=1 to enable)")
|
||||
}
|
||||
// Publish the singleton so the in-process projection path (Apply / ingest) and
|
||||
// the /v1/team/bots/sync handler can write into the per-workspace store.
|
||||
@@ -170,6 +187,27 @@ func loadConfig(deps cloud.Deps) config {
|
||||
}
|
||||
}
|
||||
|
||||
// teamAgentsMaxConcurrency resolves the responder's global in-flight turn cap from
|
||||
// TEAM_AGENTS_MAX_CONCURRENCY (default defaultMaxConcurrent). It is clamped to
|
||||
// [1,64] so a typo can never uncap the fan-out (0/negative → default) or make it
|
||||
// absurd. The cap is the hard ceiling on concurrent outbound model calls the
|
||||
// responder can have in flight process-wide.
|
||||
func teamAgentsMaxConcurrency() int {
|
||||
n := defaultMaxConcurrent
|
||||
if v := os.Getenv("TEAM_AGENTS_MAX_CONCURRENCY"); v != "" {
|
||||
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
|
||||
n = parsed
|
||||
}
|
||||
}
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
if n > 64 {
|
||||
n = 64
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// env returns the value of key, or fallback when unset. The ONE env helper for the
|
||||
// package (used by the docs store).
|
||||
func env(key, fallback string) string {
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
luxlog "github.com/luxfi/log"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/team/token"
|
||||
"github.com/zap-proto/zip"
|
||||
"github.com/zap-proto/zip/wsx"
|
||||
@@ -56,6 +58,15 @@ type transServer struct {
|
||||
secret string
|
||||
accounts *accountStore // human members (this deployment's workspaces)
|
||||
bots BotLister // bot members (the org's in-process agents)
|
||||
runAgent AgentRunner // the Chunter responder's LLM seam (agents.RunOnBehalf); nil = responder OFF
|
||||
log luxlog.Logger // best-effort responder logging; nil-safe (tests leave it unset)
|
||||
|
||||
// Chunter responder bounds (chat.go). ALL zero-value-safe so a bare
|
||||
// transServer literal (tests, the sync path) is inert-but-correct.
|
||||
startedAt int64 // process boot (unix millis); messages older than this are backfill → never answered (0 = no filter, tests)
|
||||
sem chan struct{} // hard concurrency cap on in-flight agent turns (nil = uncapped, tests)
|
||||
inflight sync.Map // single-flight: (workspace|space|bot) currently answering → drop duplicates
|
||||
breaker sync.Map // per-agent circuit breaker: agentID → *agentBreaker (backoff on repeated failure)
|
||||
}
|
||||
|
||||
// live is the process-singleton transactor server, published in Mount so the
|
||||
@@ -269,6 +280,10 @@ func (s *session) tx(id int64, params []json.RawMessage) []byte {
|
||||
res, applied := s.applyTx(params[0])
|
||||
if len(applied) > 0 {
|
||||
s.server.hub.broadcast(s.workspace, applied)
|
||||
// Fire agent replies for any bot-addressed Chunter message. Async + guarded
|
||||
// inside; only the client WS write path reaches here (the roster/sync path
|
||||
// calls applyTx directly), so a projection can never trigger a reply.
|
||||
s.server.maybeAgentReply(s.org, s.workspace, applied)
|
||||
}
|
||||
return s.result(id, res)
|
||||
}
|
||||
|
||||
+21
-11
@@ -175,21 +175,31 @@ func workerUnits(org string) []fleetUnit {
|
||||
workers := byoWorkers(org)
|
||||
out := make([]fleetUnit, 0, len(workers))
|
||||
for _, w := range workers {
|
||||
u := fleetUnit{
|
||||
Source: samples.SourceBYO, Unit: w.ID, Kind: samples.KindWorker,
|
||||
Label: w.Hostname, Host: w.Hostname, Status: w.Status,
|
||||
}
|
||||
if w.Os != "" || len(w.GPUs) > 0 {
|
||||
u.Spec = &fleetSpec{OS: w.Os, GPUs: len(w.GPUs)}
|
||||
if len(w.GPUs) > 0 {
|
||||
u.Spec.GPUModel = w.GPUs[0].Name
|
||||
}
|
||||
}
|
||||
out = append(out, u)
|
||||
out = append(out, byoUnit(w))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// byoUnit projects one dialed-in BYO worker onto the board, carrying the host's full
|
||||
// static spec — OS, CPU arch, logical cores, total RAM and the GPU summary — in the
|
||||
// SAME fleetSpec a code-linked run-target reports (agentUnits). This is what surfaces
|
||||
// a gpu-connect node's real arch (amd64/arm64) + memory on GET /v1/fleet, not just
|
||||
// its GPU. A field the worker did not report stays zero (omitempty), never invented.
|
||||
func byoUnit(w byoWorker) fleetUnit {
|
||||
u := fleetUnit{
|
||||
Source: samples.SourceBYO, Unit: w.ID, Kind: samples.KindWorker,
|
||||
Label: w.Hostname, Host: w.Hostname, Status: w.Status,
|
||||
}
|
||||
sp := fleetSpec{OS: w.Os, Arch: w.Arch, CPUs: w.CPUs, Memory: w.Memory, GPUs: len(w.GPUs)}
|
||||
if len(w.GPUs) > 0 {
|
||||
sp.GPUModel = w.GPUs[0].Name
|
||||
}
|
||||
if sp != (fleetSpec{}) {
|
||||
u.Spec = &sp
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// clusterUnits folds in the org's attached BYO clusters. A cluster's accelerators
|
||||
// are counted, not modelled — the registry reports vendor totals across nodes, and
|
||||
// the board reports exactly that rather than inventing per-card detail.
|
||||
|
||||
@@ -65,6 +65,13 @@ type byoWorker struct {
|
||||
LastHeartbeat string `json:"lastHeartbeat,omitempty"`
|
||||
FirstSeen string `json:"firstSeen,omitempty"`
|
||||
Os string `json:"os,omitempty"`
|
||||
// Arch/CPUs/Memory are the connecting host's static CPU spec, mirrored from the
|
||||
// registration: Arch is runtime.GOARCH (amd64 | arm64), Memory is total RAM in
|
||||
// BYTES — the same fields a code-linked run-target carries, so the /v1/fleet
|
||||
// board renders a gpu-connect node's arch + cores + RAM like any other unit.
|
||||
Arch string `json:"arch,omitempty"`
|
||||
CPUs int `json:"cpus,omitempty"`
|
||||
Memory int64 `json:"memory,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
JobQueue string `json:"jobQueue,omitempty"`
|
||||
// Capabilities the worker advertises ("studio.render", "engine.serve"); Engine
|
||||
@@ -78,6 +85,9 @@ type byoWorker struct {
|
||||
type fleetRegistration struct {
|
||||
Hostname string `json:"hostname"`
|
||||
Os string `json:"os"`
|
||||
Arch string `json:"arch,omitempty"`
|
||||
CPUs int `json:"cpus,omitempty"`
|
||||
Memory int64 `json:"memory,omitempty"`
|
||||
Version string `json:"version"`
|
||||
JobQueue string `json:"jobQueue"`
|
||||
GPUs []byoGPU `json:"gpus"`
|
||||
@@ -121,6 +131,9 @@ func byoWorkers(org string) []byoWorker {
|
||||
LastHeartbeat: a.LastHeartbeatTime,
|
||||
FirstSeen: a.StartTime,
|
||||
Os: reg.Os,
|
||||
Arch: reg.Arch,
|
||||
CPUs: reg.CPUs,
|
||||
Memory: reg.Memory,
|
||||
Version: reg.Version,
|
||||
JobQueue: reg.JobQueue,
|
||||
Capabilities: reg.Capabilities,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package visor
|
||||
|
||||
// fleet_spec_test.go — a gpu-connect node's CPU arch + core count + total RAM must
|
||||
// survive the CLI→server decode (fleetRegistration) and land on the /v1/fleet board
|
||||
// (byoUnit → fleetSpec), the SAME fields a code-linked run-target carries. This is
|
||||
// what makes evo-2 (x86_64 / Strix Halo) and spark (aarch64 / GB10) show real arch +
|
||||
// 128 GB on the world Fleet panel, not just their GPU. Arch is the fleet's `uname -m`
|
||||
// convention — the SAME string these machines report as code-linked run-targets.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/samples"
|
||||
)
|
||||
|
||||
// cliHostSpecJSON is exactly what `hanzo gpu connect` writes as the fleet presence
|
||||
// activity's Input for spark (GB10, aarch64, 128 GiB).
|
||||
const cliHostSpecJSON = `{
|
||||
"hostname": "spark",
|
||||
"os": "linux",
|
||||
"arch": "aarch64",
|
||||
"cpus": 20,
|
||||
"memory": 137438953472,
|
||||
"version": "1.50.0",
|
||||
"jobQueue": "gpu-jobs",
|
||||
"gpus": [{"name": "NVIDIA GB10", "memoryTotal": "122880 MiB"}]
|
||||
}`
|
||||
|
||||
func TestFleetRegistrationDecodesHostSpec(t *testing.T) {
|
||||
var reg fleetRegistration
|
||||
if err := json.Unmarshal([]byte(cliHostSpecJSON), ®); err != nil {
|
||||
t.Fatalf("decode CLI registration Input: %v", err)
|
||||
}
|
||||
if reg.Arch != "aarch64" || reg.CPUs != 20 || reg.Memory != 137438953472 {
|
||||
t.Fatalf("host spec dropped on decode: arch=%q cpus=%d memory=%d", reg.Arch, reg.CPUs, reg.Memory)
|
||||
}
|
||||
}
|
||||
|
||||
func TestByoUnitCarriesHostSpec(t *testing.T) {
|
||||
var reg fleetRegistration
|
||||
if err := json.Unmarshal([]byte(cliHostSpecJSON), ®); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
// Mirror the mapping byoWorkers performs, then project onto the board.
|
||||
w := byoWorker{
|
||||
ID: "spark", Hostname: reg.Hostname, Provider: "byo", Status: "online",
|
||||
Os: reg.Os, Arch: reg.Arch, CPUs: reg.CPUs, Memory: reg.Memory, GPUs: reg.GPUs,
|
||||
}
|
||||
u := byoUnit(w)
|
||||
if u.Spec == nil {
|
||||
t.Fatal("byoUnit dropped the spec — arch/memory would be MISSING on the board")
|
||||
}
|
||||
if u.Spec.Arch != "aarch64" {
|
||||
t.Fatalf("Arch = %q, want aarch64 (the panel renders it as ARM64)", u.Spec.Arch)
|
||||
}
|
||||
if u.Spec.CPUs != 20 {
|
||||
t.Fatalf("CPUs = %d, want 20", u.Spec.CPUs)
|
||||
}
|
||||
if u.Spec.Memory != 137438953472 {
|
||||
t.Fatalf("Memory = %d, want 137438953472 (128 GiB)", u.Spec.Memory)
|
||||
}
|
||||
if u.Spec.GPUs != 1 || u.Spec.GPUModel != "NVIDIA GB10" {
|
||||
t.Fatalf("GPU summary regressed: %+v", u.Spec)
|
||||
}
|
||||
if u.Source != samples.SourceBYO || u.Kind != samples.KindWorker {
|
||||
t.Fatalf("identity/tag regressed: source=%s kind=%s", u.Source, u.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
// A CPU-only worker that reported no arch/memory (an older CLI) still renders — the
|
||||
// spec is simply omitted, never fabricated.
|
||||
func TestByoUnitOmitsUnknownSpec(t *testing.T) {
|
||||
u := byoUnit(byoWorker{ID: "old", Hostname: "old", Provider: "byo", Status: "online"})
|
||||
if u.Spec != nil {
|
||||
t.Fatalf("an all-zero spec must be omitted, not invented: %+v", u.Spec)
|
||||
}
|
||||
if u.Unit != "old" || u.Source != samples.SourceBYO {
|
||||
t.Fatalf("identity regressed: %+v", u)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/hanzoai/cloud/apps"
|
||||
)
|
||||
|
||||
// Standalone entry for the channels app — generated by cmd/gen-app-cmds (the
|
||||
// go:generate directive in apps/apps.go). do not hand-edit; the app is the one
|
||||
// edit in apps.Wire(), this binary is regenerated. The same app also mounts into
|
||||
// the unified cloud binary via apps.Wire().
|
||||
func main() {
|
||||
if err := apps.ServeSingle("channels"); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// jwtWithOwner builds an UNSIGNED-looking JWT (header.payload.sig) whose payload
|
||||
// carries the owner + isAdmin claims. The tool only DECODES these locally (the
|
||||
// server verifies the signature), so a stub signature is fine for the unit test.
|
||||
func jwtWithOwner(owner string, isAdmin bool) string {
|
||||
hdr := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`))
|
||||
payload, _ := json.Marshal(map[string]any{"owner": owner, "isAdmin": isAdmin})
|
||||
body := base64.RawURLEncoding.EncodeToString(payload)
|
||||
return hdr + "." + body + ".sig"
|
||||
}
|
||||
|
||||
func TestDecodeJWTOwner(t *testing.T) {
|
||||
owner, isAdmin, err := decodeJWTOwner(jwtWithOwner("hanzo", false))
|
||||
if err != nil || owner != "hanzo" || isAdmin {
|
||||
t.Fatalf("decodeJWTOwner = %q,%v,%v want hanzo,false,nil", owner, isAdmin, err)
|
||||
}
|
||||
if _, _, err := decodeJWTOwner("not-a-jwt"); err == nil {
|
||||
t.Fatal("decodeJWTOwner(non-jwt) should error so callers skip the local assertion")
|
||||
}
|
||||
}
|
||||
|
||||
// loginDoer answers /v1/kms/auth/login with a JWT minted for a fixed owner.
|
||||
type loginDoer struct {
|
||||
owner string
|
||||
isAdmin bool
|
||||
}
|
||||
|
||||
func (d loginDoer) Do(r *http.Request) (*http.Response, error) {
|
||||
body := `{"accessToken":"` + jwtWithOwner(d.owner, d.isAdmin) + `","expiresIn":3600,"tokenType":"Bearer"}`
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: http.Header{}}, nil
|
||||
}
|
||||
|
||||
// fixedCred returns a dummy credential for any target (the login doer ignores it).
|
||||
func fixedCred(t Target) (credRef, error) {
|
||||
return credRef{ns: "hanzo", name: "cred", clientID: "cid", secret: "sec"}, nil
|
||||
}
|
||||
|
||||
func TestTokenFunc_OwnerMustMatchOrg(t *testing.T) {
|
||||
// Credential mints owner=hanzo. A hanzo target passes; an acme target is refused
|
||||
// (misscoped credential — LOW-1 defense in depth, independent of the server guard).
|
||||
c := newKMSClient("http://cloud", loginDoer{owner: "hanzo"})
|
||||
tf := newTokenFunc(c, fixedCred, "dst")
|
||||
|
||||
if _, err := tf(context.Background(), Target{Org: "hanzo"}); err != nil {
|
||||
t.Fatalf("hanzo target with hanzo-owner token: unexpected err %v", err)
|
||||
}
|
||||
_, err := tf(context.Background(), Target{Org: "acme"})
|
||||
if err == nil || !strings.Contains(err.Error(), "owner") {
|
||||
t.Fatalf("acme target with hanzo-owner token: err=%v, want an owner-mismatch refusal", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenFunc_AdminTokenRefused(t *testing.T) {
|
||||
// An admin-owner token must never be used for a fleet (tenant) target.
|
||||
c := newKMSClient("http://cloud", loginDoer{owner: "admin", isAdmin: true})
|
||||
tf := newTokenFunc(c, fixedCred, "dst")
|
||||
_, err := tf(context.Background(), Target{Org: "admin"})
|
||||
if err == nil || !strings.Contains(err.Error(), "ADMIN") {
|
||||
t.Fatalf("admin token: err=%v, want an admin refusal (fleet identity must be org-bound)", err)
|
||||
}
|
||||
}
|
||||
+94
-22
@@ -55,42 +55,114 @@ func loadCRsFromKubectl() ([]cr, error) {
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// newK8sTokenFunc returns a tokenFunc that, for a target, reads its CR's
|
||||
// credentialsRef Secret and brokers an owner-scoped bearer at src. Tokens are
|
||||
// cached per credential (namespace/name) so a credential logs in once.
|
||||
func newK8sTokenFunc(src *kmsClient) tokenFunc {
|
||||
// credRef is the credential a token func resolves for a target on one face.
|
||||
type credRef struct {
|
||||
ns, name string
|
||||
clientID, secret string
|
||||
}
|
||||
|
||||
// credResolver maps a target to the credential that authenticates it on one face.
|
||||
// crCredResolver reads the CR's credentialsRef (the app-name identity the standalone
|
||||
// accepts); machineAudResolver reads the per-org <org>-platform-kms identity cloud
|
||||
// accepts dynamically and admin-denied (no static widening).
|
||||
type credResolver func(t Target) (credRef, error)
|
||||
|
||||
// crCredResolver reads a target's CR credentialsRef Secret — the existing app-name
|
||||
// machine identity the STANDALONE accepts (aud ∈ KMS_EXPECTED_AUDIENCE).
|
||||
func crCredResolver(t Target) (credRef, error) {
|
||||
if t.CredName == "" {
|
||||
return credRef{}, fmt.Errorf("CR %s/%s has no credentialsRef — cannot authenticate", t.CRNamespace, t.CRName)
|
||||
}
|
||||
cid, sec, err := readCredential(t.CredNS, t.CredName)
|
||||
if err != nil {
|
||||
return credRef{}, err
|
||||
}
|
||||
return credRef{ns: t.CredNS, name: t.CredName, clientID: cid, secret: sec}, nil
|
||||
}
|
||||
|
||||
// machineAudResolver reads the per-org <org>-platform-kms credential (Secret
|
||||
// name = "<org>"+suffix in ns) — the dedicated KMS-sync identity CLOUD accepts
|
||||
// dynamically via kmsMachineAudience (admin-denied, scoped to /v1/kms org==owner).
|
||||
// A missing Secret fails loud: provisioning it is a gated cutover prerequisite.
|
||||
func machineAudResolver(ns, suffix string) credResolver {
|
||||
return func(t Target) (credRef, error) {
|
||||
name := t.Org + suffix
|
||||
cid, sec, err := readCredential(ns, name)
|
||||
if err != nil {
|
||||
return credRef{}, fmt.Errorf("per-org KMS-sync credential %s/%s not provisioned (mint the <org>-platform-kms IAM app + Secret first): %w", ns, name, err)
|
||||
}
|
||||
return credRef{ns: ns, name: name, clientID: cid, secret: sec}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// newTokenFunc brokers an owner-scoped bearer at `client` for each target's
|
||||
// credential, caches per credential, and (LOW-1, defense-in-depth) decodes the
|
||||
// minted token's owner claim and asserts it EQUALS the target's org before the
|
||||
// token is handed to any read/write — so a misscoped credential fails the target
|
||||
// rather than acting on the wrong org. An admin-owner token is refused for a
|
||||
// tenant target (the fleet identities must be org-bound, never platform admin).
|
||||
func newTokenFunc(client *kmsClient, resolve credResolver, face string) tokenFunc {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
cache = map[string]string{}
|
||||
)
|
||||
return func(ctx context.Context, t Target) (string, error) {
|
||||
if t.CredName == "" {
|
||||
return "", fmt.Errorf("CR %s/%s has no credentialsRef — cannot authenticate", t.CRNamespace, t.CRName)
|
||||
}
|
||||
key := t.CredNS + "/" + t.CredName
|
||||
mu.Lock()
|
||||
tok, ok := cache[key]
|
||||
mu.Unlock()
|
||||
if ok {
|
||||
return tok, nil
|
||||
}
|
||||
clientID, clientSecret, err := readCredential(t.CredNS, t.CredName)
|
||||
ref, err := resolve(t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tok, err = src.login(ctx, clientID, clientSecret)
|
||||
// Wipe the secret material from our copy immediately after the login POST.
|
||||
wipeString(&clientSecret)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("broker token for %s: %w", key, err)
|
||||
}
|
||||
key := ref.ns + "/" + ref.name
|
||||
mu.Lock()
|
||||
cache[key] = tok
|
||||
tok, ok := cache[key]
|
||||
mu.Unlock()
|
||||
if !ok {
|
||||
tok, err = client.login(ctx, ref.clientID, ref.secret)
|
||||
wipeString(&ref.secret) // wipe the secret material right after the login POST
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("broker %s token for %s: %w", face, key, err)
|
||||
}
|
||||
mu.Lock()
|
||||
cache[key] = tok
|
||||
mu.Unlock()
|
||||
}
|
||||
// LOW-1: assert token owner == target org (fail-closed on mismatch/admin).
|
||||
owner, isAdmin, derr := decodeJWTOwner(tok)
|
||||
if derr == nil {
|
||||
if isAdmin {
|
||||
return "", fmt.Errorf("%s credential %s mints an ADMIN token — the fleet KMS identity must be org-bound, not platform admin", face, key)
|
||||
}
|
||||
if owner != t.Org {
|
||||
return "", fmt.Errorf("%s credential %s token owner %q != target org %q (misscoped credential)", face, key, owner, t.Org)
|
||||
}
|
||||
}
|
||||
return tok, nil
|
||||
}
|
||||
}
|
||||
|
||||
// decodeJWTOwner reads the `owner` + `isAdmin` claims from a JWT WITHOUT verifying
|
||||
// the signature — the SERVER validates the signature; this is a local sanity gate
|
||||
// so the tool never uses a token whose owner disagrees with the target org. A
|
||||
// non-JWT token (e.g. an opaque test stub) returns an error, which the caller
|
||||
// treats as "skip the local assertion" (the server still enforces owner==:org).
|
||||
func decodeJWTOwner(token string) (owner string, isAdmin bool, err error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return "", false, fmt.Errorf("not a JWT")
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("jwt payload: %w", err)
|
||||
}
|
||||
var claims struct {
|
||||
Owner string `json:"owner"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &claims); err != nil {
|
||||
return "", false, fmt.Errorf("jwt claims: %w", err)
|
||||
}
|
||||
return claims.Owner, claims.IsAdmin, nil
|
||||
}
|
||||
|
||||
// readCredential extracts (clientId, clientSecret) from a credentialsRef Secret.
|
||||
// clientId is an OAuth client identifier (not sensitive); clientSecret is used only
|
||||
// for the login POST and is never logged.
|
||||
|
||||
+61
-25
@@ -63,10 +63,13 @@ func (r *resealReport) add(res resealResult) {
|
||||
type tokenFunc func(ctx context.Context, t Target) (string, error)
|
||||
|
||||
// reseal migrates every explicit target and resolves every folder target, reading
|
||||
// from src and writing (sealing) into dst. It never stops on a single target's
|
||||
// failure — every result is recorded so the report is complete and the run is
|
||||
// re-runnable. plan=true performs NO network I/O (just enumerates the work).
|
||||
func reseal(ctx context.Context, inv Inventory, src, dst *kmsClient, tokenFor tokenFunc, plan bool) *resealReport {
|
||||
// from src (with the src-face token) and writing+sealing into dst (with the
|
||||
// dst-face token). The two faces carry DIFFERENT identities: src uses the CR's
|
||||
// existing app-name credential (which the standalone accepts); dst uses the per-org
|
||||
// <org>-platform-kms credential (which cloud accepts dynamically, admin-denied, with
|
||||
// NO static audience widening). It never stops on a single target's failure — every
|
||||
// result is recorded so the run is re-runnable. plan=true performs NO network I/O.
|
||||
func reseal(ctx context.Context, inv Inventory, src, dst *kmsClient, srcAuth, dstAuth tokenFunc, plan bool) *resealReport {
|
||||
rep := &resealReport{}
|
||||
|
||||
// Explicit targets.
|
||||
@@ -75,7 +78,7 @@ func reseal(ctx context.Context, inv Inventory, src, dst *kmsClient, tokenFor to
|
||||
rep.add(resealResult{Coord: t.Coord(), Outcome: outPlanned})
|
||||
continue
|
||||
}
|
||||
rep.add(resealOne(ctx, t, src, dst, tokenFor))
|
||||
rep.add(resealOne(ctx, t, src, dst, srcAuth, dstAuth))
|
||||
}
|
||||
|
||||
// Folder targets: LIST the source folder to discover keys, then migrate each.
|
||||
@@ -84,18 +87,25 @@ func reseal(ctx context.Context, inv Inventory, src, dst *kmsClient, tokenFor to
|
||||
rep.add(resealResult{Coord: f.Coord(), Outcome: outPlanned, Detail: "folder (keys resolved at run)"})
|
||||
continue
|
||||
}
|
||||
tok, err := tokenFor(ctx, f)
|
||||
srcTok, err := srcAuth(ctx, f)
|
||||
if err != nil {
|
||||
rep.add(resealResult{Coord: f.Coord(), Outcome: outFailed, Detail: "auth: " + err.Error()})
|
||||
rep.add(resealResult{Coord: f.Coord(), Outcome: outFailed, Detail: "src auth: " + err.Error()})
|
||||
continue
|
||||
}
|
||||
keys, err := src.listFolder(ctx, tok, f.Org, f.Path, f.Env)
|
||||
dstTok, err := dstAuth(ctx, f)
|
||||
if err != nil {
|
||||
rep.add(resealResult{Coord: f.Coord(), Outcome: outFailed, Detail: "dst auth: " + err.Error()})
|
||||
continue
|
||||
}
|
||||
keys, err := src.listFolder(ctx, srcTok, f.Org, f.Path, f.Env)
|
||||
if err != nil {
|
||||
rep.add(resealResult{Coord: f.Coord(), Outcome: outFailed, Detail: "list folder: " + err.Error()})
|
||||
continue
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
rep.add(resealResult{Coord: f.Coord(), Outcome: outAbsent, Detail: "folder empty at source"})
|
||||
// Empty folder-sync: a crown-jewel path with no keys would produce an empty
|
||||
// managed Secret and wedge its consumer. Flag it, never silently skip.
|
||||
rep.add(resealResult{Coord: f.Coord(), Outcome: outAbsent, Detail: "folder EMPTY at source (seeding wedge risk — verify before cutover)"})
|
||||
continue
|
||||
}
|
||||
sort.Strings(keys)
|
||||
@@ -103,25 +113,29 @@ func reseal(ctx context.Context, inv Inventory, src, dst *kmsClient, tokenFor to
|
||||
t := f
|
||||
t.Folder = false
|
||||
t.Key = k
|
||||
rep.add(resealOneWithToken(ctx, t, src, dst, tok))
|
||||
rep.add(resealOneWithTokens(ctx, t, src, dst, srcTok, dstTok))
|
||||
}
|
||||
}
|
||||
return rep
|
||||
}
|
||||
|
||||
// resealOne authenticates for the target then migrates it.
|
||||
func resealOne(ctx context.Context, t Target, src, dst *kmsClient, tokenFor tokenFunc) resealResult {
|
||||
tok, err := tokenFor(ctx, t)
|
||||
// resealOne authenticates for the target on both faces then migrates it.
|
||||
func resealOne(ctx context.Context, t Target, src, dst *kmsClient, srcAuth, dstAuth tokenFunc) resealResult {
|
||||
srcTok, err := srcAuth(ctx, t)
|
||||
if err != nil {
|
||||
return resealResult{Coord: t.Coord(), Outcome: outFailed, Detail: "auth: " + err.Error()}
|
||||
return resealResult{Coord: t.Coord(), Outcome: outFailed, Detail: "src auth: " + err.Error()}
|
||||
}
|
||||
return resealOneWithToken(ctx, t, src, dst, tok)
|
||||
dstTok, err := dstAuth(ctx, t)
|
||||
if err != nil {
|
||||
return resealResult{Coord: t.Coord(), Outcome: outFailed, Detail: "dst auth: " + err.Error()}
|
||||
}
|
||||
return resealOneWithTokens(ctx, t, src, dst, srcTok, dstTok)
|
||||
}
|
||||
|
||||
// resealOneWithToken performs the GET(src)→POST(dst) for one target with an already
|
||||
// resolved token. The plaintext is wiped after the write.
|
||||
func resealOneWithToken(ctx context.Context, t Target, src, dst *kmsClient, tok string) resealResult {
|
||||
val, err := src.getSecret(ctx, tok, t.Org, t.Path, t.Env, t.Key)
|
||||
// resealOneWithTokens performs GET(src, srcTok) → POST(dst, dstTok) for one target.
|
||||
// The plaintext is wiped after the write.
|
||||
func resealOneWithTokens(ctx context.Context, t Target, src, dst *kmsClient, srcTok, dstTok string) resealResult {
|
||||
val, err := src.getSecret(ctx, srcTok, t.Org, t.Path, t.Env, t.Key)
|
||||
if err == errSecretNotFound {
|
||||
return resealResult{Coord: t.Coord(), Outcome: outAbsent, Detail: "not found at source"}
|
||||
}
|
||||
@@ -129,14 +143,16 @@ func resealOneWithToken(ctx context.Context, t Target, src, dst *kmsClient, tok
|
||||
return resealResult{Coord: t.Coord(), Outcome: outFailed, Detail: "read: " + err.Error()}
|
||||
}
|
||||
defer wipe(val)
|
||||
if err := dst.putSecret(ctx, tok, t.Org, t.Path, t.Env, t.Key, val); err != nil {
|
||||
if err := dst.putSecret(ctx, dstTok, t.Org, t.Path, t.Env, t.Key, val); err != nil {
|
||||
return resealResult{Coord: t.Coord(), Outcome: outFailed, Detail: "write: " + err.Error()}
|
||||
}
|
||||
return resealResult{Coord: t.Coord(), Outcome: outMigrated}
|
||||
}
|
||||
|
||||
// wipe zeroes a plaintext buffer once it is no longer needed (defense in depth;
|
||||
// the value already never leaves memory).
|
||||
// wipe zeroes the plaintext buffer read from the source once the write is done.
|
||||
// Best-effort defense in depth: the value already never leaves memory, and
|
||||
// putSecret copies it into a JSON body (string(value)) whose backing bytes Go does
|
||||
// not let us wipe — so this zeroes the READ buffer, not every transient copy.
|
||||
func wipe(b []byte) {
|
||||
for i := range b {
|
||||
b[i] = 0
|
||||
@@ -152,6 +168,8 @@ func runReseal(args []string) error {
|
||||
srcURL := fs.String("src", "", "standalone KMS base URL (read source), e.g. http://kms.hanzo.svc")
|
||||
cloudURL := fs.String("cloud", "", "cloud embedded KMS base URL (seal destination), e.g. http://cloud.hanzo.svc")
|
||||
onlyHost := fs.String("only-host", "", "migrate only CRs whose hostAPI matches this (excludes e.g. the devnet KMS)")
|
||||
dstCredNS := fs.String("dst-cred-namespace", "hanzo", "namespace holding the per-org <org>-platform-kms credential Secrets")
|
||||
dstCredSuffix := fs.String("dst-cred-suffix", "-platform-kms-creds", "Secret name suffix for the per-org cloud (dst) credential: <org>+suffix")
|
||||
plan := fs.Bool("plan", false, "enumerate the work without any network I/O or writes")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
@@ -165,21 +183,39 @@ func runReseal(args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inv := filterHost(BuildInventory(crs), *onlyHost)
|
||||
full := BuildInventory(crs)
|
||||
inv := filterHost(full, *onlyHost)
|
||||
|
||||
ctx := context.Background()
|
||||
src := newKMSClient(*srcURL, nil)
|
||||
dst := newKMSClient(*cloudURL, nil)
|
||||
tokenFor := newK8sTokenFunc(src) // logs in at the source with each CR's credential
|
||||
// src (standalone read): the CR's existing app-name credential.
|
||||
// dst (cloud write): the per-org <org>-platform-kms credential cloud accepts
|
||||
// dynamically (admin-denied, no static widening) — provisioning it is gated.
|
||||
srcAuth := newTokenFunc(src, crCredResolver, "src")
|
||||
dstAuth := newTokenFunc(dst, machineAudResolver(*dstCredNS, *dstCredSuffix), "dst")
|
||||
|
||||
rep := reseal(ctx, inv, src, dst, tokenFor, *plan)
|
||||
rep := reseal(ctx, inv, src, dst, srcAuth, dstAuth, *plan)
|
||||
printResealReport(inv, rep, *plan)
|
||||
printHostScope("RESEAL", full, inv, *onlyHost, rep.Migrated)
|
||||
if rep.Failed > 0 {
|
||||
return fmt.Errorf("%d target(s) failed — see report; re-run is safe (idempotent)", rep.Failed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// printHostScope (MED-2) prints the host-scoped work vs the UNFILTERED inventory
|
||||
// total, so the operator can assert Σ(per-host done) == the full inventory before
|
||||
// cutover — a single host-filter typo can silently omit a CR otherwise.
|
||||
func printHostScope(op string, full, scoped Inventory, onlyHost string, done int) {
|
||||
scope := "ALL hosts"
|
||||
if strings.TrimSpace(onlyHost) != "" {
|
||||
scope = onlyHost
|
||||
}
|
||||
fmt.Printf("%s host-scope [%s]: %d done of %d explicit + %d folder in scope; UNFILTERED inventory total = %d explicit + %d folders across %d hosts. Assert Σ(per-host)==unfiltered before cutover.\n",
|
||||
op, scope, done, len(scoped.Targets), len(scoped.Folders), len(full.Targets), len(full.Folders), len(full.Hosts))
|
||||
}
|
||||
|
||||
// filterHost drops targets/folders whose CR hostAPI does not match onlyHost (when
|
||||
// set), so the main-standalone cutover never touches the separate devnet KMS.
|
||||
func filterHost(inv Inventory, onlyHost string) Inventory {
|
||||
|
||||
@@ -188,13 +188,13 @@ func TestReseal_RoundTripRealSeal(t *testing.T) {
|
||||
{Org: "hanzo", Path: "datastore", Env: "prod", Key: "DATASTORE_PASSWORD"},
|
||||
}}
|
||||
|
||||
rep := reseal(context.Background(), inv, src, dst, injectToken, false)
|
||||
rep := reseal(context.Background(), inv, src, dst, injectToken, injectToken, false)
|
||||
if rep.Migrated != 3 || rep.Failed != 0 {
|
||||
t.Fatalf("reseal: migrated=%d failed=%d, want 3/0: %+v", rep.Migrated, rep.Failed, rep.Results)
|
||||
}
|
||||
|
||||
// VERIFY: every target byte-identical on cloud (real open) vs source.
|
||||
vrep := verify(context.Background(), inv, src, dst, injectToken)
|
||||
vrep := verify(context.Background(), inv, src, dst, injectToken, injectToken)
|
||||
if vrep.Match != 3 || vrep.Mismatch != 0 || vrep.AbsentD != 0 {
|
||||
t.Fatalf("verify: match=%d mismatch=%d absent-dst=%d, want 3/0/0: %+v", vrep.Match, vrep.Mismatch, vrep.AbsentD, vrep.Results)
|
||||
}
|
||||
@@ -220,7 +220,7 @@ func TestReseal_WrongOrgTokenRefusedByCloud(t *testing.T) {
|
||||
// A token scoped to the WRONG org: the fake source refuses the read (403), so the
|
||||
// migration fails closed — the value is never even read, let alone written cross-org.
|
||||
badToken := func(_ context.Context, _ Target) (string, error) { return "org:evil", nil }
|
||||
rep := reseal(context.Background(), inv, src, dst, badToken, false)
|
||||
rep := reseal(context.Background(), inv, src, dst, badToken, badToken, false)
|
||||
if rep.Failed != 1 || rep.Migrated != 0 {
|
||||
t.Fatalf("wrong-org reseal: migrated=%d failed=%d, want 0/1", rep.Migrated, rep.Failed)
|
||||
}
|
||||
@@ -237,7 +237,7 @@ func TestReseal_FolderSyncResolvedViaList(t *testing.T) {
|
||||
fs.seed("hanzo", "commerce", "prod", "STRIPE_KEY", "stripe-2")
|
||||
inv := Inventory{Folders: []Target{{Org: "hanzo", Path: "commerce", Env: "prod", Folder: true}}}
|
||||
|
||||
rep := reseal(context.Background(), inv, src, dst, injectToken, false)
|
||||
rep := reseal(context.Background(), inv, src, dst, injectToken, injectToken, false)
|
||||
if rep.Migrated != 2 || rep.Failed != 0 {
|
||||
t.Fatalf("folder reseal: migrated=%d failed=%d, want 2/0: %+v", rep.Migrated, rep.Failed, rep.Results)
|
||||
}
|
||||
@@ -246,7 +246,7 @@ func TestReseal_FolderSyncResolvedViaList(t *testing.T) {
|
||||
{Org: "hanzo", Path: "commerce", Env: "prod", Key: "HUSD_TREASURY_KEY"},
|
||||
{Org: "hanzo", Path: "commerce", Env: "prod", Key: "STRIPE_KEY"},
|
||||
}}
|
||||
if v := verify(context.Background(), explicit, src, dst, injectToken); v.Match != 2 {
|
||||
if v := verify(context.Background(), explicit, src, dst, injectToken, injectToken); v.Match != 2 {
|
||||
t.Fatalf("folder verify match=%d, want 2", v.Match)
|
||||
}
|
||||
}
|
||||
@@ -256,7 +256,7 @@ func TestReseal_PlanDoesNoNetwork(t *testing.T) {
|
||||
src := newKMSClient("http://kms.hanzo.svc", panicDoer{})
|
||||
dst := newKMSClient("http://cloud.hanzo.svc", panicDoer{})
|
||||
inv := Inventory{Targets: []Target{{Org: "hanzo", Path: "p", Env: "prod", Key: "K"}}, Folders: []Target{{Org: "hanzo", Path: "f", Env: "prod", Folder: true}}}
|
||||
rep := reseal(context.Background(), inv, src, dst, injectToken, true)
|
||||
rep := reseal(context.Background(), inv, src, dst, injectToken, injectToken, true)
|
||||
if rep.Planned != 2 || rep.Migrated != 0 || rep.Failed != 0 {
|
||||
t.Fatalf("plan: planned=%d migrated=%d failed=%d, want 2/0/0", rep.Planned, rep.Migrated, rep.Failed)
|
||||
}
|
||||
|
||||
+49
-23
@@ -25,6 +25,7 @@ const (
|
||||
vMismatch verifyOutcome = "MISMATCH"
|
||||
vAbsentSrc verifyOutcome = "absent-src"
|
||||
vAbsentDst verifyOutcome = "ABSENT-DST"
|
||||
vUnseeded verifyOutcome = "UNSEEDED-FOLDER"
|
||||
vError verifyOutcome = "error"
|
||||
)
|
||||
|
||||
@@ -40,6 +41,7 @@ type verifyReport struct {
|
||||
Mismatch int
|
||||
AbsentS int
|
||||
AbsentD int
|
||||
Unseeded int
|
||||
Errors int
|
||||
Iso []isoResult
|
||||
}
|
||||
@@ -55,6 +57,8 @@ func (r *verifyReport) add(res verifyResult) {
|
||||
r.AbsentS++
|
||||
case vAbsentDst:
|
||||
r.AbsentD++
|
||||
case vUnseeded:
|
||||
r.Unseeded++
|
||||
case vError:
|
||||
r.Errors++
|
||||
}
|
||||
@@ -65,46 +69,63 @@ func hashHex(b []byte) string {
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// verify compares src vs dst for every target (and folder-resolved key). GREEN =
|
||||
// every present source record matches on cloud with zero MISMATCH / ABSENT-DST.
|
||||
func verify(ctx context.Context, inv Inventory, src, dst *kmsClient, tokenFor tokenFunc) *verifyReport {
|
||||
// verify compares src (src-face token) vs dst (dst-face token) for every target and
|
||||
// folder-resolved key. GREEN = every present source record matches on cloud with
|
||||
// zero MISMATCH / ABSENT-DST / UNSEEDED-FOLDER / error.
|
||||
func verify(ctx context.Context, inv Inventory, src, dst *kmsClient, srcAuth, dstAuth tokenFunc) *verifyReport {
|
||||
rep := &verifyReport{}
|
||||
for _, t := range inv.Targets {
|
||||
rep.add(verifyOne(ctx, t, src, dst, tokenFor))
|
||||
rep.add(verifyOne(ctx, t, src, dst, srcAuth, dstAuth))
|
||||
}
|
||||
for _, f := range inv.Folders {
|
||||
tok, err := tokenFor(ctx, f)
|
||||
srcTok, err := srcAuth(ctx, f)
|
||||
if err != nil {
|
||||
rep.add(verifyResult{Coord: f.Coord(), Outcome: vError, Detail: "auth: " + err.Error()})
|
||||
rep.add(verifyResult{Coord: f.Coord(), Outcome: vError, Detail: "src auth: " + err.Error()})
|
||||
continue
|
||||
}
|
||||
keys, err := src.listFolder(ctx, tok, f.Org, f.Path, f.Env)
|
||||
dstTok, err := dstAuth(ctx, f)
|
||||
if err != nil {
|
||||
rep.add(verifyResult{Coord: f.Coord(), Outcome: vError, Detail: "dst auth: " + err.Error()})
|
||||
continue
|
||||
}
|
||||
keys, err := src.listFolder(ctx, srcTok, f.Org, f.Path, f.Env)
|
||||
if err != nil {
|
||||
rep.add(verifyResult{Coord: f.Coord(), Outcome: vError, Detail: "list folder: " + err.Error()})
|
||||
continue
|
||||
}
|
||||
// MED-1: an empty folder-sync is NON-green at the gate. reseal flags it as
|
||||
// absent; verify (the designated GREEN gate) must also count it as a failure,
|
||||
// or an UNSEEDED crown-jewel folder (e.g. billing-kms-sync) passes silently.
|
||||
if len(keys) == 0 {
|
||||
rep.add(verifyResult{Coord: f.Coord(), Outcome: vUnseeded, Detail: "folder EMPTY at source — nothing to verify (seeding wedge risk)"})
|
||||
continue
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
t := f
|
||||
t.Folder = false
|
||||
t.Key = k
|
||||
rep.add(verifyOneWithToken(ctx, t, src, dst, tok))
|
||||
rep.add(verifyOneWithTokens(ctx, t, src, dst, srcTok, dstTok))
|
||||
}
|
||||
}
|
||||
return rep
|
||||
}
|
||||
|
||||
func verifyOne(ctx context.Context, t Target, src, dst *kmsClient, tokenFor tokenFunc) verifyResult {
|
||||
tok, err := tokenFor(ctx, t)
|
||||
func verifyOne(ctx context.Context, t Target, src, dst *kmsClient, srcAuth, dstAuth tokenFunc) verifyResult {
|
||||
srcTok, err := srcAuth(ctx, t)
|
||||
if err != nil {
|
||||
return verifyResult{Coord: t.Coord(), Outcome: vError, Detail: "auth: " + err.Error()}
|
||||
return verifyResult{Coord: t.Coord(), Outcome: vError, Detail: "src auth: " + err.Error()}
|
||||
}
|
||||
return verifyOneWithToken(ctx, t, src, dst, tok)
|
||||
dstTok, err := dstAuth(ctx, t)
|
||||
if err != nil {
|
||||
return verifyResult{Coord: t.Coord(), Outcome: vError, Detail: "dst auth: " + err.Error()}
|
||||
}
|
||||
return verifyOneWithTokens(ctx, t, src, dst, srcTok, dstTok)
|
||||
}
|
||||
|
||||
// verifyOneWithToken reads both faces and compares digests. Buffers are wiped.
|
||||
func verifyOneWithToken(ctx context.Context, t Target, src, dst *kmsClient, tok string) verifyResult {
|
||||
sv, serr := src.getSecret(ctx, tok, t.Org, t.Path, t.Env, t.Key)
|
||||
// verifyOneWithTokens reads src (srcTok) + dst (dstTok) and compares digests.
|
||||
func verifyOneWithTokens(ctx context.Context, t Target, src, dst *kmsClient, srcTok, dstTok string) verifyResult {
|
||||
sv, serr := src.getSecret(ctx, srcTok, t.Org, t.Path, t.Env, t.Key)
|
||||
if serr == errSecretNotFound {
|
||||
return verifyResult{Coord: t.Coord(), Outcome: vAbsentSrc, Detail: "not at source"}
|
||||
}
|
||||
@@ -112,7 +133,7 @@ func verifyOneWithToken(ctx context.Context, t Target, src, dst *kmsClient, tok
|
||||
return verifyResult{Coord: t.Coord(), Outcome: vError, Detail: "read src: " + serr.Error()}
|
||||
}
|
||||
defer wipe(sv)
|
||||
dv, derr := dst.getSecret(ctx, tok, t.Org, t.Path, t.Env, t.Key)
|
||||
dv, derr := dst.getSecret(ctx, dstTok, t.Org, t.Path, t.Env, t.Key)
|
||||
if derr == errSecretNotFound {
|
||||
return verifyResult{Coord: t.Coord(), Outcome: vAbsentDst, Detail: "not migrated to cloud"}
|
||||
}
|
||||
@@ -166,6 +187,8 @@ func runVerify(args []string) error {
|
||||
srcURL := fs.String("src", "", "standalone KMS base URL")
|
||||
cloudURL := fs.String("cloud", "", "cloud embedded KMS base URL")
|
||||
onlyHost := fs.String("only-host", "", "verify only CRs whose hostAPI matches this")
|
||||
dstCredNS := fs.String("dst-cred-namespace", "hanzo", "namespace holding the per-org <org>-platform-kms credential Secrets")
|
||||
dstCredSuffix := fs.String("dst-cred-suffix", "-platform-kms-creds", "Secret name suffix for the per-org cloud (dst) credential: <org>+suffix")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -176,24 +199,27 @@ func runVerify(args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inv := filterHost(BuildInventory(crs), *onlyHost)
|
||||
full := BuildInventory(crs)
|
||||
inv := filterHost(full, *onlyHost)
|
||||
|
||||
ctx := context.Background()
|
||||
src := newKMSClient(*srcURL, nil)
|
||||
dst := newKMSClient(*cloudURL, nil)
|
||||
tokenFor := newK8sTokenFunc(src)
|
||||
srcAuth := newTokenFunc(src, crCredResolver, "src")
|
||||
dstAuth := newTokenFunc(dst, machineAudResolver(*dstCredNS, *dstCredSuffix), "dst")
|
||||
|
||||
rep := verify(ctx, inv, src, dst, tokenFor)
|
||||
rep := verify(ctx, inv, src, dst, srcAuth, dstAuth)
|
||||
printVerifyReport(rep)
|
||||
if rep.Mismatch > 0 || rep.AbsentD > 0 || rep.Errors > 0 {
|
||||
return fmt.Errorf("verification RED: mismatch=%d absent-dst=%d errors=%d", rep.Mismatch, rep.AbsentD, rep.Errors)
|
||||
printHostScope("VERIFY", full, inv, *onlyHost, rep.Match)
|
||||
if rep.Mismatch > 0 || rep.AbsentD > 0 || rep.Unseeded > 0 || rep.Errors > 0 {
|
||||
return fmt.Errorf("verification RED: mismatch=%d absent-dst=%d unseeded-folder=%d errors=%d", rep.Mismatch, rep.AbsentD, rep.Unseeded, rep.Errors)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printVerifyReport(rep *verifyReport) {
|
||||
fmt.Printf("VERIFY: match=%d MISMATCH=%d absent-src=%d ABSENT-DST=%d errors=%d\n",
|
||||
rep.Match, rep.Mismatch, rep.AbsentS, rep.AbsentD, rep.Errors)
|
||||
fmt.Printf("VERIFY: match=%d MISMATCH=%d absent-src=%d ABSENT-DST=%d UNSEEDED-FOLDER=%d errors=%d\n",
|
||||
rep.Match, rep.Mismatch, rep.AbsentS, rep.AbsentD, rep.Unseeded, rep.Errors)
|
||||
for _, r := range rep.Results {
|
||||
if r.Outcome == vMatch || r.Outcome == vAbsentSrc {
|
||||
continue // src-absent is not a regression (already broken at source)
|
||||
|
||||
@@ -14,12 +14,12 @@ func TestVerify_MismatchDetected(t *testing.T) {
|
||||
// Migrate value A, then the source diverges to value B → verify must catch it.
|
||||
fs.seed("hanzo", "p", "prod", "K", "value-A")
|
||||
inv := Inventory{Targets: []Target{{Org: "hanzo", Path: "p", Env: "prod", Key: "K"}}}
|
||||
if r := reseal(context.Background(), inv, src, dst, injectToken, false); r.Migrated != 1 {
|
||||
if r := reseal(context.Background(), inv, src, dst, injectToken, injectToken, false); r.Migrated != 1 {
|
||||
t.Fatalf("seed migrate failed: %+v", r.Results)
|
||||
}
|
||||
fs.seed("hanzo", "p", "prod", "K", "value-B-DIFFERENT")
|
||||
|
||||
vrep := verify(context.Background(), inv, src, dst, injectToken)
|
||||
vrep := verify(context.Background(), inv, src, dst, injectToken, injectToken)
|
||||
if vrep.Mismatch != 1 {
|
||||
t.Fatalf("verify mismatch=%d, want 1 (src diverged from cloud): %+v", vrep.Mismatch, vrep.Results)
|
||||
}
|
||||
@@ -40,12 +40,29 @@ func TestVerify_AbsentOnCloud(t *testing.T) {
|
||||
dst := newKMSClient("http://cloud.hanzo.svc", cloudDoer{app})
|
||||
|
||||
inv := Inventory{Targets: []Target{{Org: "hanzo", Path: "p", Env: "prod", Key: "NOT_MIGRATED"}}}
|
||||
vrep := verify(context.Background(), inv, src, dst, injectToken)
|
||||
vrep := verify(context.Background(), inv, src, dst, injectToken, injectToken)
|
||||
if vrep.AbsentD != 1 {
|
||||
t.Fatalf("verify absent-dst=%d, want 1 (never migrated): %+v", vrep.AbsentD, vrep.Results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_EmptyFolderIsNonGreen(t *testing.T) {
|
||||
app, _, _ := newCloudApp(t)
|
||||
src := newKMSClient("http://kms.hanzo.svc", newFakeStandalone()) // seed NOTHING
|
||||
dst := newKMSClient("http://cloud.hanzo.svc", cloudDoer{app})
|
||||
|
||||
// A folder-sync CR whose source path holds no keys (billing-kms-sync shape).
|
||||
inv := Inventory{Folders: []Target{{Org: "hanzo", Path: "billing-secrets", Env: "prod", Folder: true}}}
|
||||
vrep := verify(context.Background(), inv, src, dst, injectToken, injectToken)
|
||||
// MED-1: the GREEN gate must count an empty folder as NON-green (not silently pass).
|
||||
if vrep.Unseeded != 1 {
|
||||
t.Fatalf("empty folder: Unseeded=%d, want 1 (must be non-green): %+v", vrep.Unseeded, vrep.Results)
|
||||
}
|
||||
if vrep.Match != 0 {
|
||||
t.Fatalf("empty folder produced %d matches, want 0", vrep.Match)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_OrgIsolationMatrixOnCloud(t *testing.T) {
|
||||
app, _, _ := newCloudApp(t)
|
||||
dst := newKMSClient("http://cloud.hanzo.svc", cloudDoer{app})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user