Compare commits

...
Author SHA1 Message Date
hanzo-dev 585ec8754c fix(iam-edge): forward the public sign-in surface before the tenant gate
console.hanzo.ai is served one-binary off cloud, so its /v1/iam/* calls hit the
iam_edge — which required a validated org for EVERY route. That 401'd
'sign in to continue' on the sign-in routes themselves (get-app-login, login,
oauth token exchange), a chicken-and-egg that bricked console login (the
'unknown iam route' / 'sign in to continue' users saw). Forward the
unauthenticated-by-design sign-in surface (login-page config, credential submit,
signin/signup, captcha/verification aids, the OAuth token endpoint, OIDC
discovery) straight to IAM BEFORE the org gate. Tenant CRUD + org metadata stay
fully gated — no tenant-data route is opened. Test: TestIamEdgePublic.
2026-07-17 16:38:06 -07:00
hanzo-dev 2e31dba95c dedup: extract clients/payout from the 3 byte-mirror commerce.go copies
referrals/affiliates/authors each carried a byte-identical commerce.go (their own
doc-comments said so): the same commerce interface, httpCommerce, newCommerceClient,
deposit(), spendCents(), errUnconfigured — the S2S COMMERCE_SERVICE_TOKEN money-in
path (POST /v1/billing/deposit) + usage-rollup, triplicated.

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

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

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

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

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

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

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

Product routes are unchanged: the subsystem still serves /v1/plans/* (plural),
including its OwnsHealth /v1/plans/health probe — only the enable id / binary
name changes. No route drop; mount-all default still enables it (empty Enable =
all on). Updated the frozen wire row and the two cmd/cloud enable-id references;
TestMountAllAndServeHealth now maps plan to its real /v1/plans/health path
(enable id no longer equals route prefix for plan, as is already true for
account/runtime/agent).
2026-07-16 20:24:06 -07:00
36 changed files with 610 additions and 655 deletions
+12 -13
View File
@@ -67,10 +67,8 @@ import (
"github.com/hanzoai/cloud/clients/agent"
"github.com/hanzoai/cloud/clients/code"
"github.com/hanzoai/cloud/clients/company"
"github.com/hanzoai/cloud/clients/connectorruntime"
"github.com/hanzoai/cloud/clients/content"
"github.com/hanzoai/cloud/clients/crm"
"github.com/hanzoai/cloud/clients/cron"
"github.com/hanzoai/cloud/clients/dataroom"
"github.com/hanzoai/cloud/clients/deploy"
"github.com/hanzoai/cloud/clients/do"
@@ -239,7 +237,10 @@ func Wire() []cloud.MountSpec {
// hanzoai/licensing. Its Mount is func(any, cloud.Deps) error — a MountFunc
// already — so Wire references it DIRECTLY, not through Typed.
{Name: "licensing", Mount: licensing.Mount},
{Name: "plans", Mount: cloud.Typed(plan.Mount), OwnsHealth: true},
// clients/plan.Mount. Enable id normalized "plans" -> "plan" to match the
// package + generated cmd/plan (one subsystem, one name). Its product routes
// stay /v1/plans/* (incl. the OwnsHealth /v1/plans/health probe) — unchanged.
{Name: "plan", Mount: cloud.Typed(plan.Mount), OwnsHealth: true},
{Name: "pricing", Mount: cloud.Typed(pricing.Mount), OwnsHealth: true},
// /v1/s3/buckets/* + /v1/s3/health. Mounts BEFORE provisioning (120) so its static
// routes win over provisioning's /v1/s3/:name. OwnsHealth (real fail-closed probe).
@@ -345,18 +346,16 @@ func Wire() []cloud.MountSpec {
// ahead of the AI /v1/* catch-all; native Enforce middleware (wired in serve.go)
// reads the SAME store in-process. Owns a global store, so Shutdown closes it.
{Name: "featuregate", Mount: cloud.Typed(featuregate.Mount), Shutdown: ctxShutdown(featuregate.Shutdown)},
// Tasks: the durable workflow/UI surface AND platform cron (durable schedules
// on the same shared engine, replacing every k8s CronJob). cron was a separate
// Wire entry; it mounts no routes and only registers schedules, so it is folded
// in as a sub-mount of tasks.Mount — ONE tasks subsystem.
{Name: "tasks", Mount: cloud.Typed(tasks.Mount)},
// Platform cron: durable schedules on the shared tasks engine replacing
// every k8s CronJob — entries are cron.hanzo.ai ConfigMaps (universe git),
// runs visible in the Tasks console. Mounts no routes; starts after the
// engine is wired.
{Name: "cron", Mount: cloud.Typed(cron.Mount)},
// Automations: the connector catalogue + flow engine AND native single-connector
// execution (POST /v1/automations/connectors/:id/run, HIP-0126). The connector
// runner mounts no other routes, so it is folded in as a sub-mount of
// automations.Mount (was a separate "connectorruntime" entry) — ONE subsystem.
{Name: "automations", Mount: cloud.Typed(automations.Mount), Shutdown: automations.Shutdown},
// Native single-connector execution (HIP-0126): runs an ActivePieces JS
// connector action in-process via goja (clients/connectorruntime), retiring
// the standalone auto Node engine. Mounts POST /v1/automations/connectors/:id/run,
// paired with the automations catalogue above; STAGED like the rest.
{Name: "connectorruntime", Mount: cloud.Typed(connectorruntime.Mount)},
// Unified tool plane: /v1/tools/* — the ONE registry (connectors, functions,
// agents, skills, external MCP servers, full-cloud-control /v1 routes), per-org
// activation, and the unified MCP endpoint. Sources register into it from their
+3 -5
View File
@@ -37,7 +37,7 @@ var frozen = []struct {
{"authz", false, false}, // was order 70
{"commerce", false, false}, // was order 100
{"licensing", false, false}, // was order 110
{"plans", true, false}, // was order 111
{"plan", true, false}, // was order 111; enable id normalized plans->plan (routes stay /v1/plans/*)
{"pricing", true, false}, // was order 112
{"storage", true, false}, // was order 118
{"provisioning", false, false}, // was order 120
@@ -97,10 +97,8 @@ var frozen = []struct {
{"treasury", false, true}, // was order 146
{"admin", false, false}, // was order 146
{"featuregate", false, true}, // new: launch-control waitlist-mode plane (after admin, was order 147)
{"tasks", false, false}, // was order 147
{"cron", false, false}, // durable platform cron on the shared engine (post-freeze add)
{"automations", false, true}, // was order 148
{"connectorruntime", false, false}, // new: native single-connector exec via goja (after automations, HIP-0126)
{"tasks", false, false}, // was order 147; platform cron folded in as a sub-mount of tasks.Mount (was a separate entry)
{"automations", false, true}, // was order 148; connectorruntime (POST /v1/automations/connectors/:id/run) folded in as a sub-mount of automations.Mount
{"tools", false, true}, // new: unified tool plane (after automations)
{"marketplace", false, true}, // new: marketplace over the tool plane (after tools)
{"referrals", false, false}, // was order 149
+27 -136
View File
@@ -1,153 +1,44 @@
package affiliates
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/hanzoai/cloud/clients/commerceinproc"
"github.com/hanzoai/cloud/clients/payout"
)
// commerce is the narrow money seam the affiliate loop needs: read a referred
// org's metered spend (the accrual base) and grant a promo credit to a wallet (a
// payout made in credits). It is an INTERFACE so the store/handler logic is
// testable with a fake ledger the HTTP impl below is the ONE production binding.
// commerce is the narrow money seam the affiliate loop needs: read a referred org's
// metered spend (the commission accrual base) and grant a promo credit to a wallet
// (a payout made in credits, ledger tag grant:affiliate). It is an INTERFACE so the
// store/handler logic is testable with a fake ledger; the production binding is
// clients/payout, reached through the thin adapter below.
//
// This mirrors clients/referrals/commerce.go EXACTLY (which itself mirrors
// clients/admin/commerce.go): the same COMMERCE_SERVICE_TOKEN S2S path, the same
// X-Org-Id=<org> namespace + bare org `user` subject that admin.grantCredit uses —
// so an affiliate payout-in-credits lands in precisely the wallet the balance
// panel reads, indistinguishable from an admin grant except by its ledger tag
// (grant:affiliate vs grant:referral / grant:admin, all → the commerce Credit/trial
// bucket per DepositKind's grant:* rule).
// The S2S impl (COMMERCE_SERVICE_TOKEN path, X-Org-Id=<org> namespace, bare-org
// `user` subject) was three byte-identical commerce.go copies; it now lives ONCE in
// clients/payout. An affiliate payout-in-credits still lands in precisely the wallet
// the balance panel reads, indistinguishable from an admin grant except by its
// grant:affiliate tag.
type commerce interface {
configured() bool
// deposit grants amountCents to org's wallet (Credit/trial bucket via the
// grant:affiliate tag) and returns the ledger transaction id.
deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (txnID string, err error)
// spendCents is a referred org's month-to-date metered consumption — the
// commission accrual base (spend × the affiliate's rate).
spendCents(ctx context.Context, org, user string) (int64, error)
}
// errUnconfigured is returned by a deposit against an unwired commerce so the
// caller records an honest failure rather than reporting a phantom payout.
var errUnconfigured = errors.New("affiliates: commerce endpoint not configured")
// errUnconfigured is the shared sentinel a deposit against an unwired commerce
// returns, so the caller records an honest failure rather than a phantom payout.
var errUnconfigured = payout.ErrUnconfigured
// httpCommerce is the production commerce binding (COMMERCE_SERVICE_TOKEN S2S).
type httpCommerce struct {
base string
token string
http *http.Client
// commerceSeam adapts the shared payout.Client onto this program's lowercase seam
// (Go package-scoped interface methods cannot cross packages). Zero logic — pure
// delegation; the money path lives in clients/payout.
type commerceSeam struct{ c *payout.Client }
func (s commerceSeam) configured() bool { return s.c.Configured() }
func (s commerceSeam) deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (string, error) {
return s.c.Deposit(ctx, org, user, amountCents, currency, notes, tags)
}
func (s commerceSeam) spendCents(ctx context.Context, org, user string) (int64, error) {
return s.c.SpendCents(ctx, org, user)
}
func newCommerceClient(base, token string) *httpCommerce {
return &httpCommerce{
base: strings.TrimRight(strings.TrimSpace(base), "/"),
token: strings.TrimSpace(token),
http: commerceinproc.Client(15 * time.Second),
}
}
func (c *httpCommerce) configured() bool { return c != nil && c.base != "" && c.token != "" }
// deposit posts POST /v1/billing/deposit — the ONE money-in primitive (identical
// to admin.commerceClient.deposit). Commerce's EdgeAuth pins the body `user` to
// the X-Org-Id subject, so a payout can never be mis-targeted to another wallet.
func (c *httpCommerce) deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (string, error) {
if !c.configured() {
return "", errUnconfigured
}
if currency == "" {
currency = "usd"
}
body, err := json.Marshal(map[string]any{
"user": user,
"currency": currency,
"amount": amountCents,
"notes": notes,
"tags": tags,
})
if err != nil {
return "", err
}
raw, err := c.do(ctx, http.MethodPost, "/v1/billing/deposit", nil, org, body)
if err != nil {
return "", err
}
var out struct {
TransactionID string `json:"transactionId"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return "", fmt.Errorf("commerce deposit decode: %w", err)
}
return out.TransactionID, nil
}
// spendCents reads GET /v1/billing/usage-rollup and returns consumedCents. Zero
// (not an error) when commerce is unconfigured so a partial deploy degrades to
// "no spend to accrue yet" rather than a 5xx.
func (c *httpCommerce) spendCents(ctx context.Context, org, user string) (int64, error) {
if !c.configured() {
return 0, nil
}
q := url.Values{"user": {user}}
raw, err := c.do(ctx, http.MethodGet, "/v1/billing/usage-rollup", q, org, nil)
if err != nil {
return 0, err
}
var out struct {
ConsumedCents int64 `json:"consumedCents"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return 0, fmt.Errorf("commerce rollup decode: %w", err)
}
return out.ConsumedCents, nil
}
// do performs one admin-S2S commerce request. X-Org-Id=<org> is the per-org
// namespace selector commerce's EdgeAuth trusts only behind the service token.
func (c *httpCommerce) do(ctx context.Context, method, path string, q url.Values, org string, body []byte) ([]byte, error) {
u := c.base + path
if enc := q.Encode(); enc != "" {
u += "?" + enc
}
var r io.Reader
if body != nil {
r = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, u, r)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
if org != "" {
req.Header.Set("X-Org-Id", org)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("commerce unreachable: %w", err)
}
defer func() { _ = resp.Body.Close() }()
out, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("commerce status %d", resp.StatusCode)
}
return out, nil
}
// newCommerceClient builds the production binding, delegating to clients/payout.
func newCommerceClient(base, token string) commerce { return commerceSeam{payout.NewClient(base, token)} }
+26 -134
View File
@@ -1,152 +1,44 @@
package authors
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/hanzoai/cloud/clients/commerceinproc"
"github.com/hanzoai/cloud/clients/payout"
)
// commerce is the narrow money seam the author royalty loop needs: read a deploying
// org's metered spend (the accrual base) and grant a promo credit to a wallet (a
// payout made in credits). It is an INTERFACE so the store/handler logic is testable
// with a fake ledger the HTTP impl below is the ONE production binding.
// org's metered spend (the royalty accrual base) and grant a promo credit to a wallet
// (a payout made in credits, ledger tag grant:author). It is an INTERFACE so the
// store/handler logic is testable with a fake ledger; the production binding is
// clients/payout, reached through the thin adapter below.
//
// This mirrors clients/affiliates/commerce.go EXACTLY: the same
// COMMERCE_SERVICE_TOKEN S2S path, the same X-Org-Id=<org> namespace + bare org
// `user` subject — so an author payout-in-credits lands in precisely the wallet the
// balance panel reads, indistinguishable from an admin/affiliate grant except by its
// ledger tag (grant:author, → the commerce Credit/trial bucket per DepositKind's
// grant:* rule).
// The S2S impl (COMMERCE_SERVICE_TOKEN path, X-Org-Id=<org> namespace, bare-org
// `user` subject) was three byte-identical commerce.go copies; it now lives ONCE in
// clients/payout. An author payout-in-credits still lands in precisely the wallet the
// balance panel reads, indistinguishable from an admin grant except by its
// grant:author tag.
type commerce interface {
configured() bool
// deposit grants amountCents to org's wallet (Credit/trial bucket via the
// grant:author tag) and returns the ledger transaction id.
deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (txnID string, err error)
// spendCents is a deploying org's month-to-date metered consumption — the royalty
// accrual base (spend × the author's share).
spendCents(ctx context.Context, org, user string) (int64, error)
}
// errUnconfigured is returned by a deposit against an unwired commerce so the caller
// records an honest failure rather than reporting a phantom payout.
var errUnconfigured = errors.New("authors: commerce endpoint not configured")
// errUnconfigured is the shared sentinel a deposit against an unwired commerce
// returns, so the caller records an honest failure rather than a phantom payout.
var errUnconfigured = payout.ErrUnconfigured
// httpCommerce is the production commerce binding (COMMERCE_SERVICE_TOKEN S2S).
type httpCommerce struct {
base string
token string
http *http.Client
// commerceSeam adapts the shared payout.Client onto this program's lowercase seam
// (Go package-scoped interface methods cannot cross packages). Zero logic — pure
// delegation; the money path lives in clients/payout.
type commerceSeam struct{ c *payout.Client }
func (s commerceSeam) configured() bool { return s.c.Configured() }
func (s commerceSeam) deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (string, error) {
return s.c.Deposit(ctx, org, user, amountCents, currency, notes, tags)
}
func (s commerceSeam) spendCents(ctx context.Context, org, user string) (int64, error) {
return s.c.SpendCents(ctx, org, user)
}
func newCommerceClient(base, token string) *httpCommerce {
return &httpCommerce{
base: strings.TrimRight(strings.TrimSpace(base), "/"),
token: strings.TrimSpace(token),
http: commerceinproc.Client(15 * time.Second),
}
}
func (c *httpCommerce) configured() bool { return c != nil && c.base != "" && c.token != "" }
// deposit posts POST /v1/billing/deposit — the ONE money-in primitive (identical to
// affiliates.httpCommerce.deposit). Commerce's EdgeAuth pins the body `user` to the
// X-Org-Id subject, so a payout can never be mis-targeted to another wallet.
func (c *httpCommerce) deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (string, error) {
if !c.configured() {
return "", errUnconfigured
}
if currency == "" {
currency = "usd"
}
body, err := json.Marshal(map[string]any{
"user": user,
"currency": currency,
"amount": amountCents,
"notes": notes,
"tags": tags,
})
if err != nil {
return "", err
}
raw, err := c.do(ctx, http.MethodPost, "/v1/billing/deposit", nil, org, body)
if err != nil {
return "", err
}
var out struct {
TransactionID string `json:"transactionId"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return "", fmt.Errorf("commerce deposit decode: %w", err)
}
return out.TransactionID, nil
}
// spendCents reads GET /v1/billing/usage-rollup and returns consumedCents. Zero (not
// an error) when commerce is unconfigured so a partial deploy degrades to "no spend
// to accrue yet" rather than a 5xx.
func (c *httpCommerce) spendCents(ctx context.Context, org, user string) (int64, error) {
if !c.configured() {
return 0, nil
}
q := url.Values{"user": {user}}
raw, err := c.do(ctx, http.MethodGet, "/v1/billing/usage-rollup", q, org, nil)
if err != nil {
return 0, err
}
var out struct {
ConsumedCents int64 `json:"consumedCents"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return 0, fmt.Errorf("commerce rollup decode: %w", err)
}
return out.ConsumedCents, nil
}
// do performs one admin-S2S commerce request. X-Org-Id=<org> is the per-org
// namespace selector commerce's EdgeAuth trusts only behind the service token.
func (c *httpCommerce) do(ctx context.Context, method, path string, q url.Values, org string, body []byte) ([]byte, error) {
u := c.base + path
if enc := q.Encode(); enc != "" {
u += "?" + enc
}
var r io.Reader
if body != nil {
r = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, u, r)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
if org != "" {
req.Header.Set("X-Org-Id", org)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("commerce unreachable: %w", err)
}
defer func() { _ = resp.Body.Close() }()
out, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("commerce status %d", resp.StatusCode)
}
return out, nil
}
// newCommerceClient builds the production binding, delegating to clients/payout.
func newCommerceClient(base, token string) commerce { return commerceSeam{payout.NewClient(base, token)} }
+10
View File
@@ -43,6 +43,7 @@ import (
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/audit"
"github.com/hanzoai/cloud/clients/connectorruntime"
"github.com/hanzoai/cloud/clients/principal"
"github.com/hanzoai/cloud/clients/tools"
"github.com/zap-proto/zip"
@@ -138,6 +139,15 @@ func Mount(app *zip.App, deps cloud.Deps) error {
tools.Register(connectorToolProvider{})
b.Log.Info("automations mounted", "connectors", catalog.ConnectorCount, "runtime", len(registry), "brand", deps.Brand)
// Native single-connector execution (HIP-0126): POST /v1/automations/connectors/:id/run,
// the in-process goja runner paired with the connector catalogue above. It mounts one
// route DISTINCT from every automations route (no /v1/automations/* wildcard here, so no
// shadow), and was a separate Wire entry purely for that one route — fold it in as a
// terminal sub-mount so connector catalogue + execution are ONE automations subsystem.
if err := connectorruntime.Mount(app, deps); err != nil {
return err
}
return nil
}
+1 -1
View File
@@ -15,7 +15,7 @@
// LANE 2 — managed Base hosting (what superbase/PocketHost provided). ONE Base
// app PER ORG, opened lazily and pooled, each on its OWN SQLite under
// {DataDir}/base/{orgSegment}/ — the same "prod = SQLite per tenant" model
// (HIP-0302) the gojabase leaves (captable/sign/dataroom) use, so an org's
// (HIP-0302) the NewBase leaves (captable/sign/dataroom) use, so an org's
// collections/records are PHYSICALLY isolated. Served AUTHENTICATED under
// /v1/base/*, the org resolved from the VALIDATED cloud principal (never a
// client header). This is the console Bases manager's backend.
+3 -3
View File
@@ -18,7 +18,7 @@ import (
baseapp "github.com/hanzoai/base"
"github.com/hanzoai/base/core"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/gojabase"
"github.com/hanzoai/cloud/clients/goja"
luxlog "github.com/luxfi/log"
fiber "github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip"
@@ -170,8 +170,8 @@ func TestPerOrgIsolatedCRUD(t *testing.T) {
}
// The isolation is physical: distinct on-disk data dirs per org segment.
acmeDir := filepath.Join(dataDir, "base", gojabase.TenantSegment("acme"))
globexDir := filepath.Join(dataDir, "base", gojabase.TenantSegment("globex"))
acmeDir := filepath.Join(dataDir, "base", goja.TenantSegment("acme"))
globexDir := filepath.Join(dataDir, "base", goja.TenantSegment("globex"))
if acmeDir == globexDir {
t.Fatalf("orgs share a data dir: %s", acmeDir)
}
+4 -4
View File
@@ -17,7 +17,7 @@ import (
baseapp "github.com/hanzoai/base"
"github.com/hanzoai/base/apis"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/gojabase"
"github.com/hanzoai/cloud/clients/goja"
)
// Pool sizing (env-overridable). A full Base app is heavier than a bare *sql.DB
@@ -35,7 +35,7 @@ const (
// ({DataDir}/base/{TenantSegment}/), the "prod = SQLite per tenant" rule
// (HIP-0302). Apps open lazily on first request, migrate once, and are pooled
// (LRU-capped, idle-evicted). Concurrent opens of the same org are
// single-flighted under mu. The org→segment encoding is gojabase.TenantSegment —
// single-flighted under mu. The org→segment encoding is goja.TenantSegment —
// the ONE injective, traversal-safe tenant→path encoder, shared so an org maps
// to exactly one physical identity everywhere in the binary.
type pool struct {
@@ -85,7 +85,7 @@ func (p *pool) acquire(org string) (http.Handler, func(), error) {
if strings.TrimSpace(org) == "" {
return nil, nil, fmt.Errorf("base: empty org")
}
seg := gojabase.TenantSegment(org)
seg := goja.TenantSegment(org)
p.mu.Lock()
defer p.mu.Unlock()
@@ -111,7 +111,7 @@ func (p *pool) acquire(org string) (http.Handler, func(), error) {
// for in-process callers that drive the engine's Go API directly (collection
// provisioning, seeding) rather than the HTTP path.
func (p *pool) appFor(org string) (*baseapp.Base, error) {
seg := gojabase.TenantSegment(org)
seg := goja.TenantSegment(org)
p.mu.Lock()
defer p.mu.Unlock()
if e, ok := p.m[seg]; ok {
+7 -7
View File
@@ -8,12 +8,12 @@
// business LOGIC (ported to a self-contained goja bundle in github.com/hanzoai/
// captable) and gives it PERSISTENCE over per-tenant Base/SQLite. The bundle
// carries logic; the Go host carries storage. The seam between them is the
// REUSABLE clients/gojabase binding (the RW-Base goja host), which esign (#100)
// REUSABLE clients/goja binding (the RW-Base goja host), which esign (#100)
// and dataroom (#101) reuse unchanged — this leaf is just:
//
// captable bundle (github.com/hanzoai/captable.Bundle) + the per-tenant Schema
// │
// clients/gojabase.New(...) ← injects __db/__newId/__now,
// clients/goja.NewBase(...) ← injects __db/__newId/__now,
// │ one SQLite file per tenant,
// /v1/captable/* zip routes one transaction per request
//
@@ -37,7 +37,7 @@ import (
hcaptable "github.com/hanzoai/captable"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/gojabase"
"github.com/hanzoai/cloud/clients/goja"
"github.com/hanzoai/cloud/clients/principal"
"github.com/zap-proto/zip"
)
@@ -48,7 +48,7 @@ const maxBody = 1 << 20 // 1 MiB
// state is captable's own data; shared deps live in the embedded cloud.Base.
type state struct {
host *gojabase.Host
host *goja.BaseHost
}
// mounted is the active service so Shutdown can release the per-tenant stores.
@@ -72,7 +72,7 @@ func Mount(app *zip.App, deps cloud.Deps) error {
if err != nil {
return fmt.Errorf("captable.Mount: load bundle: %w", err)
}
host, err := gojabase.New(gojabase.Config{
host, err := goja.NewBase(goja.BaseConfig{
Name: "captable",
Bundle: bundle,
Schema: schema,
@@ -80,7 +80,7 @@ func Mount(app *zip.App, deps cloud.Deps) error {
OnOpen: seedCompany,
})
if err != nil {
return fmt.Errorf("captable.Mount: gojabase host: %w", err)
return fmt.Errorf("captable.Mount: goja NewBase host: %w", err)
}
s := &cloud.Service[state]{Base: cloud.NewBase(deps, "captable"), State: state{host: host}}
mounted = s
@@ -176,7 +176,7 @@ func dispatch(s *cloud.Service[state], c *zip.Ctx, route string, params map[stri
}
}
}
resp, err := s.State.host.Dispatch(c.Context(), org, gojabase.Request{
resp, err := s.State.host.Dispatch(c.Context(), org, goja.BaseRequest{
Route: route,
Params: params,
Body: body,
+7 -7
View File
@@ -6,22 +6,22 @@ import (
"testing"
hcaptable "github.com/hanzoai/captable"
"github.com/hanzoai/cloud/clients/gojabase"
"github.com/hanzoai/cloud/clients/goja"
)
// TestFullLifecycle drives the REAL embedded captable bundle against a REAL
// per-tenant SQLite through gojabase — the end-to-end proof that the bundle's SQL
// per-tenant SQLite through NewBase — the end-to-end proof that the bundle's SQL
// matches the Go host schema and that the whole cap-table fold round-trips
// through Base: company → stakeholders → share class → equity plan → share
// issuance → options → SAFE → priced round + investment (dilution) → transfer →
// computed cap table. Any column/route drift fails here, not in production.
func newHost(t *testing.T) *gojabase.Host {
func newHost(t *testing.T) *goja.BaseHost {
t.Helper()
bundle, err := hcaptable.Bundle()
if err != nil {
t.Fatal(err)
}
h, err := gojabase.New(gojabase.Config{
h, err := goja.NewBase(goja.BaseConfig{
Name: "captable",
Bundle: bundle,
Schema: schema,
@@ -36,9 +36,9 @@ func newHost(t *testing.T) *gojabase.Host {
}
// do dispatches a route and returns (status, decoded body).
func do(t *testing.T, h *gojabase.Host, org, route string, params map[string]string, body any) (int, any) {
func do(t *testing.T, h *goja.BaseHost, org, route string, params map[string]string, body any) (int, any) {
t.Helper()
resp, err := h.Dispatch(context.Background(), org, gojabase.Request{Route: route, Params: params, Body: body})
resp, err := h.Dispatch(context.Background(), org, goja.BaseRequest{Route: route, Params: params, Body: body})
if err != nil {
t.Fatalf("dispatch %s: %v", route, err)
}
@@ -300,7 +300,7 @@ func TestDilutiveOptionsExcludeTerminal(t *testing.T) {
}
// firstShareID returns the founder's original certificate share id.
func firstShareID(t *testing.T, h *gojabase.Host, org string) string {
func firstShareID(t *testing.T, h *goja.BaseHost, org string) string {
t.Helper()
_, body := do(t, h, org, "shares.list", nil, nil)
data := body.(map[string]any)["data"].([]any)
+4 -4
View File
@@ -7,7 +7,7 @@ import (
"fmt"
"time"
"github.com/hanzoai/cloud/clients/gojabase"
"github.com/hanzoai/cloud/clients/goja"
)
// facade.go is the in-process cap-table seam: it lets a sibling subsystem (Hanzo
@@ -74,7 +74,7 @@ type RoundInput struct {
// response. The body is round-tripped through JSON to a generic value so the goja
// bundle sees the SAME wire shape (lower-case json keys) the HTTP path produces —
// passing a typed Go struct straight to goja would expose Go field names instead.
func facadeDispatch(ctx context.Context, org, route string, params map[string]string, body any) (*gojabase.Response, error) {
func facadeDispatch(ctx context.Context, org, route string, params map[string]string, body any) (*goja.Response, error) {
if mounted == nil || mounted.State.host == nil {
return nil, ErrNotMounted
}
@@ -85,7 +85,7 @@ func facadeDispatch(ctx context.Context, org, route string, params map[string]st
if err != nil {
return nil, err
}
return mounted.State.host.Dispatch(ctx, org, gojabase.Request{Route: route, Params: params, Body: wire})
return mounted.State.host.Dispatch(ctx, org, goja.BaseRequest{Route: route, Params: params, Body: wire})
}
// toWire normalizes a typed value to a generic JSON value (map[string]any /
@@ -107,7 +107,7 @@ func toWire(body any) (any, error) {
// okBody checks the response is 2xx and returns the body bytes, else a descriptive
// error carrying the bundle's own message.
func okBody(resp *gojabase.Response, route string) ([]byte, error) {
func okBody(resp *goja.Response, route string) ([]byte, error) {
if resp.Status/100 != 2 {
return nil, fmt.Errorf("captable %s: status %d: %s", route, resp.Status, string(resp.Body))
}
+1 -1
View File
@@ -19,7 +19,7 @@ import (
// mountApp builds a bare zip.App (no SanitizeIdentity middleware, so X-Org-Id +
// X-User-Id are trusted verbatim — the standard cloud leaf test harness) and
// mounts the captable leaf on it. This exercises the REAL HTTP path: routing →
// body decode → principal gate → gojabase dispatch → per-tenant Base → response,
// body decode → principal gate → NewBase dispatch → per-tenant Base → response,
// the same path the live binary serves under CLOUD_ENABLE=captable.
func mountApp(t *testing.T) *zip.App {
t.Helper()
+2 -2
View File
@@ -9,7 +9,7 @@ import (
// schema is the per-tenant SQLite DDL — the Go host owns migrations; the goja
// bundle only issues SQL against these tables. Column names MUST match the SQL in
// the captable bundle (github.com/hanzoai/captable goja/src/routes/*). Idempotent
// (IF NOT EXISTS), so it runs on every tenant DB open via gojabase.
// (IF NOT EXISTS), so it runs on every tenant DB open via NewBase.
//
// This is the Prisma data model (prisma/schema.prisma) translated to SQLite:
// DateTime → TEXT (ISO strings stored verbatim; the bundle never parses them),
@@ -217,7 +217,7 @@ CREATE INDEX IF NOT EXISTS ix_investment_company ON investment(company_id);
CREATE INDEX IF NOT EXISTS ix_investment_round ON investment(round_id);
`
// seedCompany is the gojabase OnOpen hook: it ensures the tenant's cap-table
// seedCompany is the NewBase OnOpen hook: it ensures the tenant's cap-table
// company row exists (id == the validated tenant), so the bundle's companyId
// always resolves. The name defaults to the tenant and is renamed via
// PUT /v1/captable/company. INSERT OR IGNORE makes it idempotent across reopens.
+4 -4
View File
@@ -1,4 +1,4 @@
// Hanzo Dataroom — goja bundle (read-WRITE, on clients/gojabase).
// Hanzo Dataroom — goja bundle (read-WRITE, on clients/goja).
//
// SELF-CONTAINED, NO ESM, NO node: imports. The complete dataroom business
// logic (documents, data rooms, shareable links with access controls, viewers,
@@ -8,7 +8,7 @@
// data model becomes Base/SQLite tables (see the leaf's schema.go), the handlers
// become the route table below. No Postgres, no Next.js.
//
// Host contract (clients/gojabase injects these per dispatch; each dispatch runs
// Host contract (clients/goja injects these per dispatch; each dispatch runs
// inside ONE per-tenant SQLite transaction that commits iff status < 400):
// globalThis.__db.query(sql, args) -> [ {col: val, ...}, ... ]
// globalThis.__db.exec(sql, args) -> { changes, lastId }
@@ -49,7 +49,7 @@
}
// err builds a route result carrying a non-200 status via __status. A >=400
// status also rolls back the dispatch transaction (gojabase), so a rejected
// status also rolls back the dispatch transaction (NewBase), so a rejected
// request leaves the tenant DB untouched.
function err(status, message) { return { __status: status, error: message }; }
@@ -121,7 +121,7 @@
}
// === route handlers ========================================================
// Admin routes are org-scoped by the per-tenant DB gojabase selects; the Go
// Admin routes are org-scoped by the per-tenant DB NewBase selects; the Go
// leaf refuses any request without a validated principal before dispatching.
// Viewer routes run under the org resolved from the public link id.
+13 -13
View File
@@ -8,7 +8,7 @@
// business logic (documents, data rooms, shareable links with access controls,
// viewers, per-page view analytics) is a self-contained goja bundle (bundle.js, the
// ESM-free port of the Papermark API handlers). It runs in-process on the REUSABLE
// clients/gojabase host — the SAME RW-Base binding captable (#97) pilots and esign
// clients/goja host — the SAME RW-Base binding captable (#97) pilots and esign
// (#100) reuses — which injects __db/__newId/__now and one SQLite file per tenant,
// one transaction per request. This leaf adds only: the per-tenant Schema, the
// object-storage seam for document bytes, a bcrypt HostFn for link passwords, and
@@ -16,7 +16,7 @@
//
// dataroom bundle (bundle.js, go:embed) + per-tenant Schema + __bcrypt HostFn
// │
// clients/gojabase.New(...) ← __db/__newId/__now, per-tenant Base,
// clients/goja.NewBase(...) ← __db/__newId/__now, per-tenant Base,
// │ one transaction per request
// /v1/dataroom/* zip routes
//
@@ -29,7 +29,7 @@
// AUTH. Admin routes require a validated cloud principal (principal.Org → org);
// public viewer routes carry no principal and resolve their org from the link index
// (a link id → org routing table — the one cross-tenant piece). Tenant isolation is
// the per-org SQLite file gojabase selects from that org.
// the per-org SQLite file NewBase selects from that org.
//
// ACTIVATION: dataroom is NOT staged — it mounts under the mount-all default
// (empty CLOUD_ENABLE), so the one binary serves /v1/dataroom/* from first boot.
@@ -51,7 +51,7 @@ import (
"golang.org/x/crypto/bcrypt"
hcloud "github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/gojabase"
"github.com/hanzoai/cloud/clients/goja"
"github.com/hanzoai/cloud/clients/principal"
"github.com/zap-proto/zip"
)
@@ -75,7 +75,7 @@ type blobStore interface {
// state is dataroom's own data; shared deps live in the embedded cloud.Base,
// reached as s.Log.
type state struct {
host *gojabase.Host
host *goja.BaseHost
index *linkIndex
blob blobStore
}
@@ -105,7 +105,7 @@ func Mount(app *zip.App, deps hcloud.Deps) error {
return c.JSON(http.StatusOK, map[string]string{"service": "dataroom", "status": "ok"})
})
host, err := gojabase.New(gojabase.Config{
host, err := goja.NewBase(goja.BaseConfig{
Name: "dataroom",
Bundle: bundleJS,
Schema: schema,
@@ -233,7 +233,7 @@ func uploadDocument(s *hcloud.Service[state], c *zip.Ctx) error {
ct = "application/octet-stream"
}
// ONE tenant encoding everywhere: the object-store key prefix uses the SAME
// injective, path-safe gojabase.TenantSegment the per-tenant SQLite filename
// injective, path-safe goja.TenantSegment the per-tenant SQLite filename
// does — never the raw org (which could carry a '/' and traverse the key
// namespace, and would drift from the DB's encoding).
rk, err := randKey()
@@ -241,7 +241,7 @@ func uploadDocument(s *hcloud.Service[state], c *zip.Ctx) error {
s.Log.Error("dataroom: crypto/rand unavailable", "err", err)
return zip.Errorf(http.StatusInternalServerError, "storage key generation failed")
}
key := "dataroom/" + gojabase.TenantSegment(org) + "/" + rk
key := "dataroom/" + goja.TenantSegment(org) + "/" + rk
if err := s.State.blob.Put(c.Context(), key, raw); err != nil {
s.Log.Error("dataroom storage put failed", "err", err)
return zip.Errorf(http.StatusBadGateway, "document storage unavailable")
@@ -259,7 +259,7 @@ func adminDownload(s *hcloud.Service[state], c *zip.Ctx) error {
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
resp, err := s.State.host.Dispatch(c.Context(), org, gojabase.Request{
resp, err := s.State.host.Dispatch(c.Context(), org, goja.BaseRequest{
Route: "documents.file", Params: map[string]string{"id": c.Param("id")},
})
if err != nil {
@@ -275,7 +275,7 @@ func viewerDownload(s *hcloud.Service[state], c *zip.Ctx) error {
if err != nil || !ok {
return zip.ErrNotFound("link not found")
}
resp, err := s.State.host.Dispatch(c.Context(), org, gojabase.Request{
resp, err := s.State.host.Dispatch(c.Context(), org, goja.BaseRequest{
Route: "view.file",
Params: map[string]string{"linkId": linkID, "documentId": c.Param("documentId")},
Query: map[string]string{"viewId": c.Query("viewId"), "download": c.Query("download")},
@@ -288,7 +288,7 @@ func viewerDownload(s *hcloud.Service[state], c *zip.Ctx) error {
// streamFile turns a {fileKey,contentType,name} bundle result into a byte stream
// from object storage. A non-200 bundle result (404/403) passes through as JSON.
func streamFile(s *hcloud.Service[state], c *zip.Ctx, resp *gojabase.Response) error {
func streamFile(s *hcloud.Service[state], c *zip.Ctx, resp *goja.Response) error {
if resp.Status != http.StatusOK {
c.SetHeader("Content-Type", "application/json")
return c.Bytes(resp.Status, resp.Body)
@@ -325,7 +325,7 @@ func createLink(s *hcloud.Service[state], c *zip.Ctx) error {
if err != nil {
return err
}
resp, err := s.State.host.Dispatch(c.Context(), org, gojabase.Request{Route: "links.create", Body: body})
resp, err := s.State.host.Dispatch(c.Context(), org, goja.BaseRequest{Route: "links.create", Body: body})
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "dataroom dispatch failed")
}
@@ -349,7 +349,7 @@ func createLink(s *hcloud.Service[state], c *zip.Ctx) error {
// write dispatches one bundle route on the tenant's Base store (one transaction
// per request) and writes {status, body}.
func write(s *hcloud.Service[state], c *zip.Ctx, org, route string, params, query map[string]string, body any) error {
resp, err := s.State.host.Dispatch(c.Context(), org, gojabase.Request{
resp, err := s.State.host.Dispatch(c.Context(), org, goja.BaseRequest{
Route: route, Params: params, Query: query, Body: body,
})
if err != nil {
+1 -1
View File
@@ -19,7 +19,7 @@ import (
type linkIndex struct{ db *sql.DB }
func openLinkIndex(dataDir string) (*linkIndex, error) {
// Its OWN dir, distinct from gojabase's per-tenant tree ({dataDir}/dataroom/):
// Its OWN dir, distinct from NewBase's per-tenant tree ({dataDir}/dataroom/):
// this global routing table must never collide with a tenant's DB file.
dir := filepath.Join(dataDir, "dataroom_index")
if err := os.MkdirAll(dir, 0o700); err != nil {
+3 -3
View File
@@ -7,7 +7,7 @@ import (
"fmt"
"net/http"
"github.com/hanzoai/cloud/clients/gojabase"
"github.com/hanzoai/cloud/clients/goja"
)
// ingest.go is the in-process ingestion seam: it lets a sibling subsystem (Hanzo
@@ -48,11 +48,11 @@ func Ingest(ctx context.Context, org, name, contentType string, data []byte) (st
if err != nil {
return "", fmt.Errorf("dataroom.Ingest: storage key: %w", err)
}
key := "dataroom/" + gojabase.TenantSegment(org) + "/" + rk
key := "dataroom/" + goja.TenantSegment(org) + "/" + rk
if err := mounted.State.blob.Put(ctx, key, data); err != nil {
return "", fmt.Errorf("dataroom.Ingest: blob put: %w", err)
}
resp, err := mounted.State.host.Dispatch(ctx, org, gojabase.Request{
resp, err := mounted.State.host.Dispatch(ctx, org, goja.BaseRequest{
Route: "documents.create",
Body: map[string]any{"name": name, "fileKey": key, "contentType": contentType, "fileSize": len(data)},
})
+1 -1
View File
@@ -1,6 +1,6 @@
package dataroom
// schema is the per-tenant SQLite DDL — the Go host owns migrations (gojabase
// schema is the per-tenant SQLite DDL — the Go host owns migrations (NewBase
// runs this on every tenant DB open); the goja bundle only issues SQL against
// these tables. Column names MUST match the SQL in bundle.js. Idempotent
// (IF NOT EXISTS).
@@ -1,15 +1,16 @@
// Package gojabase is the REUSABLE read-write-Base goja host: it runs a Hanzo
// subsystem's self-contained JS bundle (globalThis.handle) inside dop251/goja and
// gives that bundle PERSISTENCE over per-tenant Base/SQLite, injected as native
// host globals. It is the storage-bearing sibling of clients/goja (which is the
// pure JS engine that plans/pricing use with a read-only catalog).
// base.go is the REUSABLE read-write-Base variant of this goja host: NewBase runs
// a Hanzo subsystem's self-contained JS bundle (globalThis.handle) inside
// dop251/goja and gives that bundle PERSISTENCE over per-tenant Base/SQLite,
// injected as native host globals. It is the storage-bearing constructor of THIS
// package — the Base binding is an OPTION: reach for New (above) for a read-only
// catalog bundle (plans/pricing), NewBase here when the bundle stores data.
//
// ONE-AND-ONLY-ONE-WAY. Any subsystem that wants "run my TS business logic in
// goja, persist to Base per tenant" uses THIS package: pass a Bundle + a per-
// tenant Schema (DDL) + the DataDir, get a Host, and Dispatch(ctx, tenant, req).
// goja, persist to Base per tenant" uses NewBase: pass a Bundle + a per-tenant
// Schema (DDL) + the DataDir, get a *BaseHost, and Dispatch(ctx, tenant, req).
// captable is the pilot; esign (#100) and dataroom (#101) reuse it unchanged —
// the binding carries ZERO domain logic (no cap-table, no signatures, no rooms),
// only the engine + the Base bridge.
// only the engine (goja.Host) + the Base bridge.
//
// # Host contract (what the binding injects onto the runtime per dispatch)
//
@@ -33,7 +34,8 @@
// So a request is all-or-nothing without any JS-visible transaction API — a
// multi-statement mutation (e.g. a share transfer: delete source + insert target)
// is atomic for free, and a validation 400 leaves the DB untouched.
package gojabase
package goja
import (
"context"
@@ -44,34 +46,29 @@ import (
"errors"
"fmt"
"time"
"github.com/hanzoai/cloud/clients/goja"
)
// BlobStore is the ONE object-storage seam a bundle uses to persist large binary
// payloads OUTSIDE its per-tenant SQLite (e.g. sign's PDFs — a 32 MiB base64 blob
// in a TEXT column would bloat the tenant DB and get copied on every read). The
// cloud VFS/S3 data plane (deps.VFS) satisfies it, exactly as clients/dataroom
// already uses it for document bytes. Keys are opaque; gojabase tenant-scopes them.
// already uses it for document bytes. Keys are opaque; the binding tenant-scopes them.
type BlobStore interface {
Put(ctx context.Context, key string, payload []byte) error
Get(ctx context.Context, key string) ([]byte, error)
}
// Response mirrors the JS-side {status, body} (reused from clients/goja).
type Response = goja.Response
// Request is the dispatch envelope. The binding adds the tenant (as orgId) and
// BaseRequest is the dispatch envelope. The binding adds the tenant (as orgId) and
// the Base bridge; the caller supplies route/params/query/body.
type Request struct {
type BaseRequest struct {
Route string
Params map[string]string
Query map[string]string
Body any
}
// Config configures a Host.
type Config struct {
// BaseConfig configures a BaseHost.
type BaseConfig struct {
// Name identifies the subsystem ("captable", "esign", "dataroom"). It names
// the goja host AND the per-tenant data subdir ({DataDir}/{Name}/).
Name string
@@ -82,7 +79,7 @@ type Config struct {
Schema string
// DataDir is the deployment data root; per-tenant files land at
// {DataDir}/{Name}/{TenantSegment(tenant)}.db (injective, traversal-safe
// base32 of the raw org bytes — see gojabase/store.go TenantSegment).
// base32 of the raw org bytes — see basestore.go TenantSegment).
DataDir string
// OnOpen is an optional per-tenant seed hook run ONCE after migration (e.g.
// captable seeds the tenant's company row). It runs outside the per-request
@@ -93,31 +90,32 @@ type Config struct {
// provide that a subsystem implements in Go (e.g. esign injects __pdf =
// { stamp, sign } for PDF rendering + x509/PKCS#7 signing). Values are Go
// funcs or map[string]any of Go funcs (goja exposes them as callable JS). They
// are process-global (set once at New), not per-tenant; the binding stays
// are process-global (set once at NewBase), not per-tenant; the binding stays
// domain-free. May be nil. A key MUST NOT collide with __db/__newId/__now/__blob.
HostFns map[string]any
// Blob is the OPTIONAL object-storage seam (see BlobStore). When set, gojabase
// Blob is the OPTIONAL object-storage seam (see BlobStore). When set, the binding
// injects globalThis.__blob = { put(key, b64), get(key) -> b64 } on every
// Dispatch, bound to the tenant: keys are prefixed with {Name}/{TenantSegment}
// so a bundle can NEVER address another tenant's blob. Payloads cross as base64
// strings (goja-friendly); gojabase decodes/encodes at the boundary so the
// strings (goja-friendly); the binding decodes/encodes at the boundary so the
// bundle never handles raw bytes. nil ⇒ no __blob is injected. This is the ONE
// way a bundle keeps big binaries out of its per-tenant SQLite.
Blob BlobStore
}
// Host is a compiled bundle + its per-tenant Base stores. Safe for concurrent use.
type Host struct {
// BaseHost is a compiled bundle + its per-tenant Base stores. Safe for concurrent use.
type BaseHost struct {
name string
engine *goja.Host
engine *Host
stores *stores
hostFns map[string]any
blob BlobStore
}
// New compiles the bundle (via clients/goja) and prepares the per-tenant store
// manager. It does NOT open any tenant DB — those open lazily on first Dispatch.
func New(cfg Config) (*Host, error) {
// NewBase compiles the bundle (via the goja engine, New) and prepares the
// per-tenant store manager. It does NOT open any tenant DB — those open lazily on
// first Dispatch.
func NewBase(cfg BaseConfig) (*BaseHost, error) {
if cfg.Name == "" {
return nil, errors.New("gojabase: Config.Name required")
}
@@ -127,11 +125,11 @@ func New(cfg Config) (*Host, error) {
if cfg.DataDir == "" {
return nil, fmt.Errorf("gojabase[%s]: Config.DataDir required", cfg.Name)
}
engine, err := goja.New(goja.Config{Name: cfg.Name, Bundle: cfg.Bundle})
engine, err := New(Config{Name: cfg.Name, Bundle: cfg.Bundle})
if err != nil {
return nil, err
}
return &Host{
return &BaseHost{
name: cfg.Name,
engine: engine,
stores: newStores(cfg.Name, cfg.DataDir, cfg.Schema, cfg.OnOpen),
@@ -145,7 +143,7 @@ func New(cfg Config) (*Host, error) {
// globalThis.handle. It commits on a <400 non-throwing response and rolls back
// otherwise. tenant MUST be a validated principal's org (the caller resolves it,
// e.g. via clients/principal.Org) — the binding does not itself authenticate.
func (h *Host) Dispatch(ctx context.Context, tenant string, req Request) (*Response, error) {
func (h *BaseHost) Dispatch(ctx context.Context, tenant string, req BaseRequest) (*Response, error) {
db, release, err := h.stores.acquire(ctx, tenant)
if err != nil {
return nil, err
@@ -178,7 +176,7 @@ func (h *Host) Dispatch(ctx context.Context, tenant string, req Request) (*Respo
for k, v := range h.hostFns {
globals[k] = v
}
resp, err := h.engine.DispatchWith(ctx, goja.Request{
resp, err := h.engine.DispatchWith(ctx, Request{
Route: req.Route,
Params: req.Params,
Query: req.Query,
@@ -198,7 +196,7 @@ func (h *Host) Dispatch(ctx context.Context, tenant string, req Request) (*Respo
}
// Close closes every open tenant DB and drops the goja engine. Idempotent.
func (h *Host) Close() error {
func (h *BaseHost) Close() error {
err := h.stores.closeAll()
if h.engine != nil {
_ = h.engine.Close()
@@ -229,8 +227,8 @@ func newBridge(ctx context.Context, q execQuerier) map[string]any {
// tenant. Keys are namespaced to {name}/{TenantSegment(tenant)}/ so a bundle can
// only ever address its OWN tenant's objects — cross-tenant isolation is a host
// property, using the same injective encoding the per-tenant DB file uses. The
// bundle handles base64 strings only; gojabase decodes/encodes at the boundary.
func (h *Host) blobBridge(ctx context.Context, tenant string) map[string]any {
// bundle handles base64 strings only; the binding decodes/encodes at the boundary.
func (h *BaseHost) blobBridge(ctx context.Context, tenant string) map[string]any {
prefix := h.name + "/" + TenantSegment(tenant) + "/"
return map[string]any{
"put": func(key, b64 string) error {
@@ -1,4 +1,4 @@
package gojabase
package goja
import (
"context"
@@ -42,9 +42,9 @@ const tinySchema = `CREATE TABLE IF NOT EXISTS kv(
org TEXT NOT NULL, k TEXT NOT NULL, v TEXT NOT NULL, at INTEGER NOT NULL,
PRIMARY KEY(org,k));`
func newTinyHost(t *testing.T, onOpen func(context.Context, string, *sql.DB) error) *Host {
func newTinyHost(t *testing.T, onOpen func(context.Context, string, *sql.DB) error) *BaseHost {
t.Helper()
h, err := New(Config{
h, err := NewBase(BaseConfig{
Name: "tiny",
Bundle: []byte(tinyBundle),
Schema: tinySchema,
@@ -63,7 +63,7 @@ func TestRoundTrip(t *testing.T) {
ctx := context.Background()
// write
resp, err := h.Dispatch(ctx, "acme", Request{Route: "put", Body: map[string]any{"k": "founder", "v": "ada"}})
resp, err := h.Dispatch(ctx, "acme", BaseRequest{Route: "put", Body: map[string]any{"k": "founder", "v": "ada"}})
if err != nil {
t.Fatal(err)
}
@@ -72,7 +72,7 @@ func TestRoundTrip(t *testing.T) {
}
// read it back (a fresh dispatch → committed data must be visible)
resp, err = h.Dispatch(ctx, "acme", Request{Route: "get", Params: map[string]string{"k": "founder"}})
resp, err = h.Dispatch(ctx, "acme", BaseRequest{Route: "get", Params: map[string]string{"k": "founder"}})
if err != nil {
t.Fatal(err)
}
@@ -90,14 +90,14 @@ func TestPerRequestRollback(t *testing.T) {
ctx := context.Background()
// (a) caught-error path: write then return 500 → status >= 400 → rollback.
resp, err := h.Dispatch(ctx, "acme", Request{Route: "boom"})
resp, err := h.Dispatch(ctx, "acme", BaseRequest{Route: "boom"})
if err != nil {
t.Fatal(err)
}
if resp.Status != 500 {
t.Fatalf("boom status=%d, want 500", resp.Status)
}
resp, err = h.Dispatch(ctx, "acme", Request{Route: "get", Params: map[string]string{"k": "ghost"}})
resp, err = h.Dispatch(ctx, "acme", BaseRequest{Route: "get", Params: map[string]string{"k": "ghost"}})
if err != nil {
t.Fatal(err)
}
@@ -106,10 +106,10 @@ func TestPerRequestRollback(t *testing.T) {
}
// (b) uncaught-throw path: surfaces as a host error, and still rolls back.
if _, err := h.Dispatch(ctx, "acme", Request{Route: "throw"}); err == nil {
if _, err := h.Dispatch(ctx, "acme", BaseRequest{Route: "throw"}); err == nil {
t.Fatal("expected a host error from an uncaught JS throw")
}
resp, err = h.Dispatch(ctx, "acme", Request{Route: "get", Params: map[string]string{"k": "ghost2"}})
resp, err = h.Dispatch(ctx, "acme", BaseRequest{Route: "get", Params: map[string]string{"k": "ghost2"}})
if err != nil {
t.Fatal(err)
}
@@ -122,11 +122,11 @@ func TestTenantIsolation(t *testing.T) {
h := newTinyHost(t, nil)
ctx := context.Background()
if _, err := h.Dispatch(ctx, "acme", Request{Route: "put", Body: map[string]any{"k": "secret", "v": "acme-only"}}); err != nil {
if _, err := h.Dispatch(ctx, "acme", BaseRequest{Route: "put", Body: map[string]any{"k": "secret", "v": "acme-only"}}); err != nil {
t.Fatal(err)
}
// A different tenant must not see acme's row (separate DB file).
resp, err := h.Dispatch(ctx, "globex", Request{Route: "get", Params: map[string]string{"k": "secret"}})
resp, err := h.Dispatch(ctx, "globex", BaseRequest{Route: "get", Params: map[string]string{"k": "secret"}})
if err != nil {
t.Fatal(err)
}
@@ -143,7 +143,7 @@ func TestOnOpenSeed(t *testing.T) {
return err
})
ctx := context.Background()
resp, err := h.Dispatch(ctx, "acme", Request{Route: "get", Params: map[string]string{"k": "seed"}})
resp, err := h.Dispatch(ctx, "acme", BaseRequest{Route: "get", Params: map[string]string{"k": "seed"}})
if err != nil {
t.Fatal(err)
}
@@ -1,4 +1,4 @@
package gojabase
package goja
import (
"container/list"
@@ -1,4 +1,4 @@
package gojabase
package goja
import (
"context"
@@ -84,7 +84,7 @@ func TestLRUEvictionReopen(t *testing.T) {
orgs := []string{"one", "two", "three", "four", "five"}
for _, org := range orgs {
resp, err := h.Dispatch(ctx, org, Request{Route: "put", Body: map[string]any{"k": "name", "v": org + "-val"}})
resp, err := h.Dispatch(ctx, org, BaseRequest{Route: "put", Body: map[string]any{"k": "name", "v": org + "-val"}})
if err != nil {
t.Fatalf("put %s: %v", org, err)
}
@@ -102,7 +102,7 @@ func TestLRUEvictionReopen(t *testing.T) {
// Every tenant — including the ones long since evicted — still reads back its
// own committed row (data persisted to disk; reopen re-migrated + re-read).
for _, org := range orgs {
resp, err := h.Dispatch(ctx, org, Request{Route: "get", Params: map[string]string{"k": "name"}})
resp, err := h.Dispatch(ctx, org, BaseRequest{Route: "get", Params: map[string]string{"k": "name"}})
if err != nil {
t.Fatalf("get %s: %v", org, err)
}
@@ -147,7 +147,7 @@ func TestConcurrentMultiTenantDispatch(t *testing.T) {
go func(org string, i int) {
defer wg.Done()
k := "k" + strconv.Itoa(i)
resp, err := h.Dispatch(ctx, org, Request{
resp, err := h.Dispatch(ctx, org, BaseRequest{
Route: "put",
Body: map[string]any{"k": k, "v": org + "|" + k},
})
@@ -170,7 +170,7 @@ func TestConcurrentMultiTenantDispatch(t *testing.T) {
// Integrity: each tenant holds EXACTLY its own perTenant rows, values intact,
// none leaked from a sibling (esp. the case/separator-variant pairs).
for _, org := range tenants {
resp, err := h.Dispatch(ctx, org, Request{Route: "list"})
resp, err := h.Dispatch(ctx, org, BaseRequest{Route: "list"})
if err != nil {
t.Fatalf("list %s: %v", org, err)
}
+6 -6
View File
@@ -18,10 +18,10 @@
// READ-WRITE variant: this package hosts bundles with a read-only catalog
// injected once at New (plans/pricing). Subsystems that need PERSISTENCE — a
// bundle that reads AND writes per-tenant Base/SQLite (captable #97, esign #100,
// dataroom #101) — use the sibling clients/gojabase, which builds on THIS engine
// (via DispatchWith) and injects a tenant-bound __db bridge per request. Reach
// for gojabase when your bundle stores data; reach for goja directly only for a
// read-only bundle.
// dataroom #101) — use NewBase (base.go / basestore.go), the Base binding folded
// into THIS package: it builds on this engine (via DispatchWith) and injects a
// tenant-bound __db bridge per request. Reach for NewBase when your bundle stores
// data; reach for New directly only for a read-only bundle.
package goja
import (
@@ -184,7 +184,7 @@ func (h *Host) Dispatch(ctx context.Context, req Request) (*Response, error) {
// DispatchWith is Dispatch plus a set of per-call NATIVE globals installed on the
// runtime immediately before handle() runs (left in place until the next
// dispatch on that slot overwrites them). This is the read-WRITE extension of
// the read-only plan/pricing pattern: clients/gojabase passes a tenant-bound
// the read-only plan/pricing pattern: the NewBase binding passes a tenant-bound
// __db bridge (+ __newId/__now) here so a bundle's SQL calls hit the right
// per-tenant Base. The slot is held exclusively for the whole call (withSlot
// serializes it), so installing globals on the shared runtime is race-free, and
@@ -256,7 +256,7 @@ func (h *Host) DispatchWith(ctx context.Context, req Request, hostGlobals map[st
return fmt.Errorf("gojahost[%s]: handle(%s) returned %T, want object", h.name, req.Route, exported)
}
// Fail closed: a response with no EXPLICIT, valid numeric status must NOT
// be treated as success. gojabase's per-request transaction commits iff
// be treated as success. NewBase's per-request transaction commits iff
// status < 400, so a defaulted 200 would silently PERSIST a bundle that
// forgot to set a status — on a boundary whose safety IS the status gate.
// Default 500 (which rolls the transaction back); only a bundle-provided
-95
View File
@@ -1,95 +0,0 @@
# `clients/gojabase` — the reusable read-write-Base goja host
`gojabase` is the **one-and-only-one-way** to run a Hanzo subsystem's
self-contained JS/TS business logic (a goja bundle exposing `globalThis.handle`)
in-process **with persistence over per-tenant Base/SQLite**. It is the
storage-bearing sibling of [`clients/goja`](../goja) (the pure JS engine that
`plans`/`pricing` use with a read-only catalog).
captable (#97) is the pilot. **esign (#100) and dataroom (#101) reuse this
package unchanged** — it carries ZERO domain logic (no cap table, no signatures,
no rooms), only the engine + the Base bridge.
## What a subsystem provides
```go
host, err := gojabase.New(gojabase.Config{
Name: "captable", // names the goja host AND the data subdir
Bundle: bundleBytes, // the go:embed'd bundle (globalThis.handle)
Schema: schemaDDL, // per-tenant SQLite DDL (CREATE TABLE IF NOT EXISTS …)
DataDir: deps.DataDir, // files land at {DataDir}/{Name}/{tenantSlug}.db
OnOpen: seedRow, // optional per-tenant seed, run once after migrate
})
```
Then, in each zip route handler, resolve the tenant from the **validated**
principal and dispatch:
```go
org, ok := principal.Tenant(c) // gojabase does NOT authenticate; the leaf does
if !ok { return zip.ErrForbidden("X-Org-Id required") }
resp, err := host.Dispatch(c.Context(), org, gojabase.Request{
Route: "stakeholders.add",
Params: map[string]string{"id": c.Param("id")},
Body: decodedJSONBody, // any (map / slice / scalar), or nil for reads
})
c.SetHeader("Content-Type", "application/json")
return c.Bytes(resp.Status, resp.Body) // resp is {Status int, Body json.RawMessage}
```
## What the bundle sees (the host contract)
gojabase injects these native globals onto the runtime **per dispatch**, bound to
the tenant's DB + a per-request transaction:
```
globalThis.__db.query(sql, args) -> row objects (SELECT; TEXT→string)
globalThis.__db.exec(sql, args) -> { changes, lastId } (INSERT/UPDATE/DELETE)
globalThis.__newId() -> collision-resistant id (crypto/rand, 128-bit)
globalThis.__now() -> unix milliseconds
globalThis.__blob.put(key, b64) -> (only when Config.Blob is set) store bytes off-DB
globalThis.__blob.get(key) -> b64 (only when Config.Blob is set) read them back
globalThis.handle({ route, params, query, orgId, body }) -> { status, body }
```
`__blob` is the OPTIONAL object-storage seam (`Config.Blob`, backed by the cloud
VFS/S3). Use it for large binaries that must NOT bloat the per-tenant SQLite — e.g.
sign's PDFs: the bundle stores the bytes with `__blob.put` and keeps only the
returned key in a column. Keys are tenant-scoped by the host (`{Name}/{TenantSegment}`),
so a bundle can never reach another tenant's blobs. Payloads cross as base64.
`orgId` is the tenant passed to `Dispatch` — the bundle uses it to scope rows
(defence in depth on top of the per-tenant file). `args` is a positional array
bound to `?` placeholders. The Go host owns the schema (migrations); the bundle
issues SQL against it — column names are the coupling, so keep them in sync.
## Guarantees
- **Per-tenant isolation** — one SQLite file per org
(`{DataDir}/{Name}/{TenantSegment}.db`), opened lazily, migrated once, pooled
(LRU-capped + idle-evicted). `TenantSegment` is an **injective**, traversal-safe
encoding (lowercased unpadded base32 of the raw org bytes), so DISTINCT orgs —
including case/separator variants like `Acme`/`acme` and `a b`/`a_b` — NEVER
share a file, and the `[a-z2-7]` segment can never traverse the data tree.
- **Atomicity** — each `Dispatch` runs `handle` inside ONE transaction that
**commits iff** the response status `< 400` and `handle` did not throw;
otherwise it **rolls back**. Multi-statement mutations (e.g. a share transfer:
shrink source + insert target) are all-or-nothing for free, and a validation
400 leaves the DB untouched. `MaxOpenConns(1)` serializes writes per tenant.
- **No JS-visible transaction API** — the per-request transaction removes the
need for one; bundles just call `query`/`exec`.
## Leaf wiring (register)
Register the leaf and blank-import it in `apps/apps.go`. It mounts
under the mount-all default (empty `CLOUD_ENABLE`) — the captable/sign/dataroom
folds are NOT staged (their standalone apps are retired/empty, so the one binary
is authoritative from first write):
```go
func init() { cloud.RegisterWithShutdown("captable", 133, cloud.Typed(Mount), shutdown) }
```
See `clients/captable` for the complete reference leaf (schema, seed, routes).
+166
View File
@@ -0,0 +1,166 @@
// Package payout is the ONE attributed-credit money seam shared by the credit
// programs — referrals, affiliates, authors: read an org's metered spend (the
// qualify / accrual base) and grant a promo credit to its wallet (a payout made
// in credits, landing in commerce's Credit/trial bucket). It was three
// byte-identical commerce.go copies (their own doc-comments said so); extracted
// here so the S2S commerce binding — the HTTP deposit + usage-rollup path — lives
// exactly ONCE.
//
// Every payout lands via the SAME COMMERCE_SERVICE_TOKEN S2S path, the same
// X-Org-Id=<org> namespace + bare-org `user` subject that admin.grantCredit uses,
// so it is indistinguishable from an admin grant except by its ledger tag
// (grant:referral / grant:affiliate / grant:author, all → the commerce Credit/trial
// bucket per DepositKind's grant:* rule). The tag is supplied BY THE CALLER, so
// payout carries zero program domain logic — it is the money seam, nothing else.
//
// Commerce is an INTERFACE so each program's store/handler logic stays testable
// with a fake ledger; Client is the ONE production binding. A program keeps its
// own narrow (unexported-method) seam and a thin adapter delegating to Client —
// Go package-scoped interface methods can't cross packages, and the adapter is
// where a program still names its own grant tag.
package payout
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/hanzoai/cloud/clients/commerceinproc"
)
// Commerce is the narrow money seam an attributed-credit program needs: read a
// referred/deploying org's metered spend and grant a credit to a wallet. The HTTP
// impl (Client) below is the ONE production binding.
type Commerce interface {
Configured() bool
// Deposit grants amountCents to org's wallet (Credit/trial bucket via the
// caller-supplied grant:* tag) and returns the ledger transaction id.
Deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (txnID string, err error)
// SpendCents is the org's month-to-date metered consumption — the qualify
// signal / commission accrual base (spend × the program's rate).
SpendCents(ctx context.Context, org, user string) (int64, error)
}
// ErrUnconfigured is returned by a Deposit against an unwired commerce so the
// caller records an honest failure rather than reporting a phantom grant.
var ErrUnconfigured = errors.New("payout: commerce endpoint not configured")
// Client is the production commerce binding (COMMERCE_SERVICE_TOKEN S2S).
type Client struct {
base string
token string
http *http.Client
}
// NewClient builds the production binding. base is the commerce HTTP URL (via
// commerceinproc.BaseURL at the call site); token is COMMERCE_SERVICE_TOKEN.
func NewClient(base, token string) *Client {
return &Client{
base: strings.TrimRight(strings.TrimSpace(base), "/"),
token: strings.TrimSpace(token),
http: commerceinproc.Client(15 * time.Second),
}
}
func (c *Client) Configured() bool { return c != nil && c.base != "" && c.token != "" }
// Deposit posts POST /v1/billing/deposit — the ONE money-in primitive (identical
// to admin.grantCredit's deposit). Commerce's EdgeAuth pins the body `user` to the
// X-Org-Id subject, so a payout can never be mis-targeted to another wallet.
func (c *Client) Deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (string, error) {
if !c.Configured() {
return "", ErrUnconfigured
}
if currency == "" {
currency = "usd"
}
body, err := json.Marshal(map[string]any{
"user": user,
"currency": currency,
"amount": amountCents,
"notes": notes,
"tags": tags,
})
if err != nil {
return "", err
}
raw, err := c.do(ctx, http.MethodPost, "/v1/billing/deposit", nil, org, body)
if err != nil {
return "", err
}
var out struct {
TransactionID string `json:"transactionId"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return "", fmt.Errorf("commerce deposit decode: %w", err)
}
return out.TransactionID, nil
}
// SpendCents reads GET /v1/billing/usage-rollup and returns consumedCents. Zero
// (not an error) when commerce is unconfigured so a partial deploy degrades to
// "no spend to accrue yet" rather than a 5xx.
func (c *Client) SpendCents(ctx context.Context, org, user string) (int64, error) {
if !c.Configured() {
return 0, nil
}
q := url.Values{"user": {user}}
raw, err := c.do(ctx, http.MethodGet, "/v1/billing/usage-rollup", q, org, nil)
if err != nil {
return 0, err
}
var out struct {
ConsumedCents int64 `json:"consumedCents"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return 0, fmt.Errorf("commerce rollup decode: %w", err)
}
return out.ConsumedCents, nil
}
// do performs one admin-S2S commerce request. X-Org-Id=<org> is the per-org
// namespace selector commerce's EdgeAuth trusts only behind the service token.
func (c *Client) do(ctx context.Context, method, path string, q url.Values, org string, body []byte) ([]byte, error) {
u := c.base + path
if enc := q.Encode(); enc != "" {
u += "?" + enc
}
var r io.Reader
if body != nil {
r = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, u, r)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
if org != "" {
req.Header.Set("X-Org-Id", org)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("commerce unreachable: %w", err)
}
defer func() { _ = resp.Body.Close() }()
out, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("commerce status %d", resp.StatusCode)
}
return out, nil
}
+108
View File
@@ -0,0 +1,108 @@
package payout
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
)
// TestDepositPostsGrant proves Deposit posts POST /v1/billing/deposit with the
// service token + X-Org-Id namespace and the body the commerce ledger expects,
// and returns the transactionId. This is the ONE money-in path the three credit
// programs share.
func TestDepositPostsGrant(t *testing.T) {
var gotOrg, gotAuth, gotPath, gotMethod string
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod, gotPath = r.Method, r.URL.Path
gotOrg = r.Header.Get("X-Org-Id")
gotAuth = r.Header.Get("Authorization")
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &gotBody)
_ = json.NewEncoder(w).Encode(map[string]string{"transactionId": "txn_123"})
}))
defer srv.Close()
c := NewClient(srv.URL, "svc-tok")
if !c.Configured() {
t.Fatal("client with base+token must be Configured")
}
txn, err := c.Deposit(context.Background(), "acme", "acme", 500, "", "welcome", "grant:referral")
if err != nil {
t.Fatalf("Deposit: %v", err)
}
if txn != "txn_123" {
t.Fatalf("txn = %q, want txn_123", txn)
}
if gotMethod != http.MethodPost || gotPath != "/v1/billing/deposit" {
t.Fatalf("request = %s %s, want POST /v1/billing/deposit", gotMethod, gotPath)
}
if gotOrg != "acme" {
t.Fatalf("X-Org-Id = %q, want acme", gotOrg)
}
if gotAuth != "Bearer svc-tok" {
t.Fatalf("Authorization = %q, want Bearer svc-tok", gotAuth)
}
// currency defaults to usd; amount is the cents int; tag carries the program's grant class.
if gotBody["currency"] != "usd" || gotBody["amount"].(float64) != 500 || gotBody["tags"] != "grant:referral" {
t.Fatalf("body = %v, want currency=usd amount=500 tags=grant:referral", gotBody)
}
}
// TestSpendCentsReadsRollup proves SpendCents reads GET /v1/billing/usage-rollup
// (the accrual base) and returns consumedCents.
func TestSpendCentsReadsRollup(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/v1/billing/usage-rollup" {
t.Errorf("request = %s %s, want GET /v1/billing/usage-rollup", r.Method, r.URL.Path)
}
if r.URL.Query().Get("user") != "acme" {
t.Errorf("user query = %q, want acme", r.URL.Query().Get("user"))
}
_ = json.NewEncoder(w).Encode(map[string]int64{"consumedCents": 4200})
}))
defer srv.Close()
c := NewClient(srv.URL, "svc-tok")
got, err := c.SpendCents(context.Background(), "acme", "acme")
if err != nil {
t.Fatalf("SpendCents: %v", err)
}
if got != 4200 {
t.Fatalf("consumedCents = %d, want 4200", got)
}
}
// TestUnconfigured proves the fail-soft contract: an unwired client returns
// ErrUnconfigured from Deposit (an honest failure, never a phantom grant) and 0
// from SpendCents (degrades to "no spend yet", not a 5xx).
func TestUnconfigured(t *testing.T) {
c := NewClient("", "") // no base, no token
if c.Configured() {
t.Fatal("client with empty base/token must NOT be Configured")
}
if _, err := c.Deposit(context.Background(), "acme", "acme", 100, "usd", "", "grant:author"); err != ErrUnconfigured {
t.Fatalf("Deposit err = %v, want ErrUnconfigured", err)
}
got, err := c.SpendCents(context.Background(), "acme", "acme")
if err != nil || got != 0 {
t.Fatalf("SpendCents = (%d, %v), want (0, nil)", got, err)
}
}
// TestNon2xxIsError proves a commerce 5xx surfaces as an error (the caller then
// records a failed payout / retries), never a silent success.
func TestNon2xxIsError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadGateway)
}))
defer srv.Close()
c := NewClient(srv.URL, "svc-tok")
if _, err := c.Deposit(context.Background(), "acme", "acme", 100, "usd", "", "grant:affiliate"); err == nil {
t.Fatal("Deposit against a 502 must return an error")
}
}
+24 -133
View File
@@ -1,152 +1,43 @@
package referrals
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/hanzoai/cloud/clients/commerceinproc"
"github.com/hanzoai/cloud/clients/payout"
)
// commerce is the narrow money seam the referral loop needs: read a referee's
// metered spend (the qualify signal) and grant a promo credit to a wallet (the
// bonus). It is an INTERFACE so the store/handler logic is testable with a fake
// ledger — the HTTP impl below is the ONE production binding.
// bonus, ledger tag grant:referral). It is an INTERFACE so the store/handler logic
// is testable with a fake ledger; the production binding is clients/payout, reached
// through the thin adapter below.
//
// This mirrors clients/admin/commerce.go's deposit + usage-rollup EXACTLY: the
// same COMMERCE_SERVICE_TOKEN S2S path, the same X-Org-Id=<org> namespace + bare
// org `user` subject that admin.grantCredit uses — so a referral bonus lands in
// precisely the wallet the balance panel reads, indistinguishable from an admin
// grant except by its ledger tag (grant:referral vs grant:admin, both → the
// commerce Credit/trial bucket per DepositKind's grant:* rule).
// The S2S impl (COMMERCE_SERVICE_TOKEN path, X-Org-Id=<org> namespace, bare-org
// `user` subject) was three byte-identical commerce.go copies; it now lives ONCE in
// clients/payout. A referral bonus still lands in precisely the wallet the balance
// panel reads, indistinguishable from an admin grant except by its grant:referral tag.
type commerce interface {
configured() bool
// deposit grants amountCents to org's wallet (Credit/trial bucket via the
// grant:referral tag) and returns the ledger transaction id.
deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (txnID string, err error)
// spendCents is the org's month-to-date metered consumption (the qualify
// signal): the referee has actually USED the product, not just signed up.
spendCents(ctx context.Context, org, user string) (int64, error)
}
// errUnconfigured is returned by a deposit against an unwired commerce so the
// caller records an honest failure rather than reporting a phantom grant.
var errUnconfigured = errors.New("referrals: commerce endpoint not configured")
// errUnconfigured is the shared sentinel a deposit against an unwired commerce
// returns, so the caller records an honest failure rather than a phantom grant.
var errUnconfigured = payout.ErrUnconfigured
// httpCommerce is the production commerce binding (COMMERCE_SERVICE_TOKEN S2S).
type httpCommerce struct {
base string
token string
http *http.Client
// commerceSeam adapts the shared payout.Client onto this program's lowercase seam
// (Go package-scoped interface methods cannot cross packages). Zero logic — pure
// delegation; the money path lives in clients/payout.
type commerceSeam struct{ c *payout.Client }
func (s commerceSeam) configured() bool { return s.c.Configured() }
func (s commerceSeam) deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (string, error) {
return s.c.Deposit(ctx, org, user, amountCents, currency, notes, tags)
}
func (s commerceSeam) spendCents(ctx context.Context, org, user string) (int64, error) {
return s.c.SpendCents(ctx, org, user)
}
func newCommerceClient(base, token string) *httpCommerce {
return &httpCommerce{
base: strings.TrimRight(strings.TrimSpace(base), "/"),
token: strings.TrimSpace(token),
http: commerceinproc.Client(15 * time.Second),
}
}
func (c *httpCommerce) configured() bool { return c != nil && c.base != "" && c.token != "" }
// deposit posts POST /v1/billing/deposit — the ONE money-in primitive (identical
// to admin.commerceClient.deposit). Commerce's EdgeAuth pins the body `user` to
// the X-Org-Id subject, so a bonus can never be mis-targeted to another wallet.
func (c *httpCommerce) deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (string, error) {
if !c.configured() {
return "", errUnconfigured
}
if currency == "" {
currency = "usd"
}
body, err := json.Marshal(map[string]any{
"user": user,
"currency": currency,
"amount": amountCents,
"notes": notes,
"tags": tags,
})
if err != nil {
return "", err
}
raw, err := c.do(ctx, http.MethodPost, "/v1/billing/deposit", nil, org, body)
if err != nil {
return "", err
}
var out struct {
TransactionID string `json:"transactionId"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return "", fmt.Errorf("commerce deposit decode: %w", err)
}
return out.TransactionID, nil
}
// spendCents reads GET /v1/billing/usage-rollup and returns consumedCents. Zero
// (not an error) when commerce is unconfigured so a partial deploy degrades to
// "not qualified yet" rather than a 5xx.
func (c *httpCommerce) spendCents(ctx context.Context, org, user string) (int64, error) {
if !c.configured() {
return 0, nil
}
q := url.Values{"user": {user}}
raw, err := c.do(ctx, http.MethodGet, "/v1/billing/usage-rollup", q, org, nil)
if err != nil {
return 0, err
}
var out struct {
ConsumedCents int64 `json:"consumedCents"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return 0, fmt.Errorf("commerce rollup decode: %w", err)
}
return out.ConsumedCents, nil
}
// do performs one admin-S2S commerce request. X-Org-Id=<org> is the per-org
// namespace selector commerce's EdgeAuth trusts only behind the service token.
func (c *httpCommerce) do(ctx context.Context, method, path string, q url.Values, org string, body []byte) ([]byte, error) {
u := c.base + path
if enc := q.Encode(); enc != "" {
u += "?" + enc
}
var r io.Reader
if body != nil {
r = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, u, r)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
if org != "" {
req.Header.Set("X-Org-Id", org)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("commerce unreachable: %w", err)
}
defer func() { _ = resp.Body.Close() }()
out, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("commerce status %d", resp.StatusCode)
}
return out, nil
}
// newCommerceClient builds the production binding, delegating to clients/payout.
func newCommerceClient(base, token string) commerce { return commerceSeam{payout.NewClient(base, token)} }
+4 -4
View File
@@ -1,16 +1,16 @@
package sign
// schema is the per-tenant SQLite DDL gojabase runs (idempotently) on first open
// schema is the per-tenant SQLite DDL NewBase runs (idempotently) on first open
// of each tenant's {DataDir}/sign/{org}.db. It is a faithful, lean projection of
// the Documenso Envelope/DocumentData/Recipient/Field/Signature/DocumentAuditLog
// models onto one PDF per document — the complete single-document signing flow.
// Ids are application-generated TEXT (via the gojabase __newId() host-fn), so no
// Ids are application-generated TEXT (via the NewBase __newId() host-fn), so no
// autoincrement crosses the host boundary. Tenant isolation is the per-tenant
// FILE (gojabase), so there is no org column — the bundle only ever sees its own
// FILE (NewBase), so there is no org column — the bundle only ever sees its own
// tenant's DB.
// PDF BYTES do NOT live in this DB: document_data.data / initial_data hold the
// opaque object-storage blob KEY (type 'BLOB_KEY'), and the bytes live on the
// gojabase __blob seam (deps.VFS / S3). A 32 MiB base64 PDF in a TEXT column would
// NewBase __blob seam (deps.VFS / S3). A 32 MiB base64 PDF in a TEXT column would
// bloat the per-tenant SQLite and be copied on every read — the same object-store
// seam clients/dataroom uses for document bytes. `data` points at the current
// (sealed once completed) PDF; `initial_data` keeps the original.
+10 -10
View File
@@ -7,13 +7,13 @@
// (the #96 pilot) established: the server-side domain (documents, recipients,
// fields, the signing flow/state machine, audit trail, completion) is ported to
// a self-contained goja bundle in github.com/hanzoai/sign; the REUSABLE
// clients/gojabase binding runs it and gives it PERSISTENCE over per-tenant
// clients/goja binding runs it and gives it PERSISTENCE over per-tenant
// Base/SQLite (__db/__newId/__now, one SQLite file per tenant, ONE transaction
// per request). This leaf adds ZERO storage glue of its own.
//
// THE HARD PART — PDF + PKI — is the one capability goja cannot provide: it is
// implemented as Go host-functions (signer.go: pdfcpu render + digitorus/pdfsign
// x509/PKCS#7 seal) and injected via the additive gojabase Config.HostFns as
// x509/PKCS#7 seal) and injected via the additive goja BaseConfig.HostFns as
// __pdf = { stamp, sign }. The signing-request/recipient/field/audit LOGIC and
// the seal ORCHESTRATION stay in the TS bundle; only the crypto/PDF primitive is
// Go. A real signed PDF comes out.
@@ -22,7 +22,7 @@
// VALIDATED cloud principal (principal.Org), never a client header. Recipient
// token routes (/v1/sign/o/:org/sign/:token) are unauthenticated capability
// links: the :org segment selects the tenant DB and the crypto-random token
// authorizes — a wrong org simply cannot hold a valid token. gojabase pre-routes
// authorizes — a wrong org simply cannot hold a valid token. NewBase pre-routes
// the bundle's db to that tenant, so isolation is a host property.
//
// ACTIVATION: sign is NOT staged — it mounts under the mount-all default (empty
@@ -40,7 +40,7 @@ import (
"net/http"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/gojabase"
"github.com/hanzoai/cloud/clients/goja"
"github.com/hanzoai/cloud/clients/principal"
signbundle "github.com/hanzoai/sign"
"github.com/zap-proto/zip"
@@ -52,7 +52,7 @@ const maxBody = 32 << 20
// state is sign's own data; shared deps live in the embedded cloud.Base.
type state struct {
host *gojabase.Host
host *goja.BaseHost
}
// mounted is the active service so shutdown can release the per-tenant stores.
@@ -79,7 +79,7 @@ func Mount(app *zip.App, deps cloud.Deps) error {
// Sign persists PDF BYTES on the object-storage seam (deps.VFS), NOT inline in
// the per-tenant SQLite — a 32 MiB base64 PDF in a TEXT column would bloat the
// tenant DB and be re-copied on every read. gojabase injects it as __blob,
// tenant DB and be re-copied on every read. NewBase injects it as __blob,
// tenant-scoped. Without VFS sign cannot store documents, so serve health-only
// (cloud stays up) rather than write PDFs into the tenant DB.
if deps.VFS == nil {
@@ -95,7 +95,7 @@ func Mount(app *zip.App, deps cloud.Deps) error {
if err != nil {
return fmt.Errorf("sign.Mount: load bundle: %w", err)
}
host, err := gojabase.New(gojabase.Config{
host, err := goja.NewBase(goja.BaseConfig{
Name: "sign",
Bundle: bundle,
Schema: schema,
@@ -104,7 +104,7 @@ func Mount(app *zip.App, deps cloud.Deps) error {
HostFns: map[string]any{"__pdf": sg.pdfHostObject()},
})
if err != nil {
return fmt.Errorf("sign.Mount: gojabase host: %w", err)
return fmt.Errorf("sign.Mount: goja NewBase host: %w", err)
}
s := &cloud.Service[state]{Base: cloud.NewBase(deps, "sign"), State: state{host: host}}
mounted = s
@@ -179,7 +179,7 @@ func token(s *cloud.Service[state], route string, readBody bool) zip.Handler {
}
// dispatch decodes the body, runs the bundle route on the tenant's Base store
// (one transaction per request via gojabase), and writes {status, body}.
// (one transaction per request via NewBase), and writes {status, body}.
func dispatch(s *cloud.Service[state], c *zip.Ctx, route, tenant string, params map[string]string, readBody bool) error {
var body any
if readBody {
@@ -193,7 +193,7 @@ func dispatch(s *cloud.Service[state], c *zip.Ctx, route, tenant string, params
}
}
}
resp, err := s.State.host.Dispatch(c.Context(), tenant, gojabase.Request{
resp, err := s.State.host.Dispatch(c.Context(), tenant, goja.BaseRequest{
Route: route,
Params: params,
Body: body,
+2 -2
View File
@@ -128,7 +128,7 @@ func decode(t *testing.T, b []byte) map[string]any {
}
// TestFullSigningFlow is the end-to-end wire proof of the COMPLETE e-sign flow on
// the reusable gojabase RW-Base host: create document → add recipient → add
// the reusable NewBase RW-Base host: create document → add recipient → add
// fields → send → recipient signs each field → complete → the document seals to
// COMPLETED with a REAL x509/PKCS#7 signed PDF, and the audit trail records every
// step. All per-tenant Base/SQLite-backed.
@@ -310,7 +310,7 @@ func TestFullSigningFlow(t *testing.T) {
// TestTenantIsolation proves two orgs never see each other's documents (the DB is
// per-tenant; a wrong token/org combination cannot resolve) and that a validation
// error rolls the request transaction back (gojabase atomicity).
// error rolls the request transaction back (NewBase atomicity).
func TestTenantIsolation(t *testing.T) {
app, _ := mountApp(t)
pdf, _ := os.ReadFile("testdata/example.pdf")
+1 -1
View File
@@ -309,7 +309,7 @@ func sanitizeText(s string) string {
// ---- host-function objects injected into the goja runtime -------------------
// pdfHostObject builds the __pdf = { stamp, sign } host object (injected via
// gojabase Config.HostFns) over base64 PDF strings — the PDF/PKI primitive goja
// NewBase Config.HostFns) over base64 PDF strings — the PDF/PKI primitive goja
// cannot do. A returned Go error surfaces in JS as a thrown Error the bundle
// catches. Signing ORCHESTRATION stays in the TS bundle; only stamp+seal is Go.
func (s *signer) pdfHostObject() map[string]any {
+10
View File
@@ -35,6 +35,7 @@ import (
"sync"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/cron"
tasksui "github.com/hanzoai/cloud/clients/tasks/ui"
tasksauth "github.com/hanzoai/tasks/pkg/auth"
tasks "github.com/hanzoai/tasks/pkg/tasks"
@@ -66,6 +67,15 @@ func Mount(app *zip.App, deps cloud.Deps) error {
app.All("/tasks/*", ui)
deps.Logger.New("subsystem", "tasks").Info("tasks HTTP+UI surface mounted (shared in-process engine)", "brand", deps.Brand)
// Platform cron is a FACET of tasks, not its own subsystem: it mounts NO routes,
// only registers durable schedules on the SAME shared engine (cloud.EmbeddedTasks)
// this surface fronts. Folded in here as a terminal sub-mount (was a separate Wire
// entry) so there is ONE tasks subsystem. cron.Mount just launches a background
// starter that waits for the engine wired after MountAll — no ordering dependency.
if err := cron.Mount(app, deps); err != nil {
return err
}
return nil
}
+19 -7
View File
@@ -20,7 +20,7 @@ import (
// is the proof Wire() actually assembles the whole matrix.
var wantSubsystems = []string{
"metrics", "base", "authz", "o11y",
"licensing", "plans", "pricing", "ai",
"licensing", "plan", "pricing", "ai",
}
func TestRegistryAssemblesSubsystems(t *testing.T) {
@@ -61,17 +61,29 @@ func newTestApp(t *testing.T, enable ...string) *zip.App {
// The self-contained subsystems mount in-process (per-tenant SQLite / in-mem,
// HIP-0302) and serve a healthy /v1/<name>/health with no external deps.
func TestMountAllAndServeHealth(t *testing.T) {
healthy := []string{"base", "authz", "metrics", "plans", "pricing"}
app := newTestApp(t, healthy...)
for _, name := range healthy {
path := "/v1/" + name + "/health"
// enable id -> the health path its Mount serves. For most, id == route prefix;
// "plan" is the exception (enable id normalized to match clients/plan, but its
// product routes — incl. /v1/plans/health — stay under the plural /v1/plans/*).
healthy := map[string]string{
"base": "/v1/base/health",
"authz": "/v1/authz/health",
"metrics": "/v1/metrics/health",
"plan": "/v1/plans/health",
"pricing": "/v1/pricing/health",
}
enable := make([]string, 0, len(healthy))
for name := range healthy {
enable = append(enable, name)
}
app := newTestApp(t, enable...)
for name, path := range healthy {
req := httptest.NewRequest("GET", path, nil)
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
t.Fatalf("enable %q: GET %s: %v", name, path, err)
}
if resp.StatusCode != 200 {
t.Errorf("GET %s = %d, want 200", path, resp.StatusCode)
t.Errorf("enable %q: GET %s = %d, want 200", name, path, resp.StatusCode)
}
}
}
+2 -2
View File
@@ -7,12 +7,12 @@ import (
"github.com/hanzoai/cloud/apps"
)
// Standalone entry for the plans app — generated by cmd/gen-app-cmds (the
// Standalone entry for the plan 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("plans"); err != nil {
if err := apps.ServeSingle("plan"); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
+38 -2
View File
@@ -36,6 +36,18 @@ var (
iamKeyed = words("get-organization-projects", "add-project", "delete-project")
// meta: the object is owned by the "admin" org, so it is guarded by NAME, not owner.
iamMeta = words("get-organization", "update-organization")
// public: the sign-in surface — unauthenticated BY DESIGN on IAM. Forwarded
// verbatim BEFORE the tenant gate so the console can reach sign-in before a
// session exists (without this, /v1/iam/get-app-login and /v1/iam/login 401
// "sign in to continue" — a chicken-and-egg that bricks console login when the
// console SPA is served one-binary off cloud, not the standalone IAM host).
// These carry NO tenant data: login-page config, credential submit, the OAuth
// token exchange, OIDC discovery, and the login-flow captcha/verification aids.
iamPublic = words("get-app-login", "login", "signin", "signup",
"get-captcha", "send-verification-code", "verify-code")
// publicPrefixes: multi-segment public sign-in routes (the OAuth token endpoint
// and OIDC discovery live under these).
iamPublicPrefixes = []string{"oauth/", "login/oauth/", ".well-known/"}
)
type iamEdge struct {
@@ -56,11 +68,17 @@ func (e *iamEdge) mount(app *zip.App) { app.Group("/v1/iam").All("/*", e.pass) }
// pass gates the request to the caller's own org, then forwards it to IAM under
// cloud's service credential, returning IAM's envelope verbatim.
func (e *iamEdge) pass(c *zip.Ctx) error {
seg := strings.Trim(c.Param("*"), "/")
// The public sign-in surface forwards to IAM BEFORE the tenant gate — a caller
// has no session (hence no org) until it signs in. No tenant data crosses here.
if e.public(seg) {
q, _ := url.ParseQuery(string(c.Fiber().Request().URI().QueryString()))
return e.forward(c, seg, q, c.Method() == http.MethodPost, c.Body())
}
org, ok := principal.Org(c)
if !ok {
return c.JSON(http.StatusUnauthorized, e.fail("sign in to continue"))
}
seg := strings.Trim(c.Param("*"), "/")
write := c.Method() == http.MethodPost
if write && !iamWrites[seg] || !write && !iamReads[seg] {
return c.JSON(http.StatusNotFound, e.fail("unknown iam route"))
@@ -137,6 +155,22 @@ func (e *iamEdge) forward(c *zip.Ctx, seg string, q url.Values, write bool, body
return c.Bytes(res.StatusCode, out)
}
// public reports whether seg is an unauthenticated-by-design sign-in route that
// must be reachable before a session exists — an exact match in iamPublic or a
// multi-segment route under a public prefix (the OAuth token endpoint / OIDC
// discovery). Everything else stays behind the tenant gate.
func (e *iamEdge) public(seg string) bool {
if iamPublic[seg] {
return true
}
for _, p := range iamPublicPrefixes {
if strings.HasPrefix(seg, p) {
return true
}
}
return false
}
// own reports whether the caller may NAME org v: empty is a no-op, a super admin
// crosses, "admin" is allowed only where the segment scopes to the caller itself
// (metadata reads), and otherwise v must equal the caller's scope.
@@ -176,7 +210,9 @@ func (e *iamEdge) field(body []byte, key string) string {
return ""
}
func (e *iamEdge) fail(msg string) map[string]any { return map[string]any{"status": "error", "msg": msg} }
func (e *iamEdge) fail(msg string) map[string]any {
return map[string]any{"status": "error", "msg": msg}
}
func words(xs ...string) map[string]bool {
m := make(map[string]bool, len(xs))
+39
View File
@@ -0,0 +1,39 @@
// Copyright 2026 Hanzo AI Inc. All Rights Reserved.
package cloud
import "testing"
// TestIamEdgePublic pins the sign-in surface that must bypass the tenant gate
// (else console login is a chicken-and-egg: /v1/iam/get-app-login + /v1/iam/login
// 401 "sign in to continue" before any session exists) — WITHOUT opening any
// tenant-data route. The exact-match sign-in verbs, the OAuth token endpoint, and
// OIDC discovery are public; every org-scoped CRUD verb stays gated.
func TestIamEdgePublic(t *testing.T) {
e := &iamEdge{}
public := []string{
"get-app-login", "login", "signin", "signup",
"get-captcha", "send-verification-code", "verify-code",
"oauth/access_token", "login/oauth/access_token",
".well-known/openid-configuration", ".well-known/jwks",
}
for _, seg := range public {
if !e.public(seg) {
t.Errorf("sign-in route %q must be public (login would 401)", seg)
}
}
// Tenant CRUD + org metadata must NEVER be public — they carry tenant data and
// rely on the org pin for cross-tenant isolation.
gated := []string{
"get-users", "get-user", "get-roles", "get-organization",
"get-organization-projects", "add-user", "update-user", "delete-user",
"update-organization", "add-project", "delete-project",
}
for _, seg := range gated {
if e.public(seg) {
t.Errorf("tenant route %q must stay behind the gate, not be public", seg)
}
}
}