Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
585ec8754c | ||
|
|
2e31dba95c | ||
|
|
d930dcd503 | ||
|
|
9821e92606 | ||
|
|
cd12968d5e | ||
|
|
fb8bb6ad21 |
+12
-13
@@ -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
@@ -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
@@ -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
@@ -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)} }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)} }
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user