Files
zeekay f7aabf72f7 SuperAdmin is the identity edge's verdict, and commerce compares no org
IsSuperAdmin folded the case of the home org and fell back to the owner
claim, the org of the application that minted the token, and the money
bits were granted on X-User-Owner == admin. Cloud writes X-User-Owner for
an admin-org application and an admin-org person's API key as well as for
a person, so either could mint money and edit the catalog here while
being nobody in the rest of the same process. Every gate now reads
X-User-IsAdmin, which the edge strips and writes from authz.Claims.Sudo
alone; the standalone EdgeAuth decides it with that predicate over the
verified token's signed membership set. X-User-IsAdmin no longer doubles
as the org-admin bit, which is X-User-IsOrgAdmin. No machine is
SuperAdmin. Global admin is SuperAdmin wherever it named this predicate.
2026-10-18 22:08:47 -07:00

346 lines
10 KiB
Go

package billing
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/log"
"github.com/hanzoai/commerce/middleware"
"github.com/hanzoai/commerce/models/transaction"
"github.com/hanzoai/commerce/models/transaction/util"
"github.com/hanzoai/commerce/models/types/currency"
httperr "github.com/hanzoai/commerce/util/json/http"
. "github.com/hanzoai/commerce/types"
)
// zapMintMethods are the ZAP-over-HTTP methods that MINT money / spendable
// balance and therefore carry the SAME platform-only bar as the REST mint routes
// (middleware.PlatformOnly): the internal service token OR a platform global
// admin ONLY. billing.deposit is the ZAP twin of POST /v1/billing/deposit —
// DestinationKind="iam-user", client-supplied amount — so an org-level Admin
// (a legacy per-org access token or a gateway-minted org owner) must NOT reach
// it, exactly as it cannot reach /deposit. Reads (getBalance*, getUsage) and
// recordUsage (a WITHDRAW/debit, not a mint) stay open to the admin group. Kept
// as a SET so the mint-surface guard (mint_surface_test.go) can assert every ZAP
// method that mints is gated here — the anti-regression for a new ZAP mint verb.
var zapMintMethods = map[string]bool{
"billing.deposit": true,
}
// ── ZAP-over-HTTP dispatch ──────────────────────────────────────────────
//
// Single endpoint that speaks the ZAP JSON envelope protocol.
//
// POST /v1/zap
//
// Request: {"method":"billing.getBalance","id":"req-1","params":{...}}
// Response: {"id":"req-1","result":{...}} or {"id":"req-1","error":{...}}
type zapRequest struct {
Method string `json:"method"`
ID string `json:"id"`
Params json.RawMessage `json:"params"`
}
type zapResponse struct {
ID string `json:"id"`
Result interface{} `json:"result,omitempty"`
Error *zapError `json:"error,omitempty"`
}
type zapError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// ZapDispatch is the single ZAP-over-HTTP endpoint for billing.
func ZapDispatch(c *zip.Ctx) error {
body := c.Body()
var req zapRequest
if err := json.Unmarshal(body, &req); err != nil {
return c.JSON(400, zapResponse{
Error: &zapError{Code: -32700, Message: "parse error: " + err.Error()},
})
}
if req.Method == "" {
return c.JSON(400, zapResponse{
ID: req.ID,
Error: &zapError{Code: -32600, Message: "method is required"},
})
}
// Money-MINT methods over ZAP are gated to the internal service token / a
// SuperAdmin ONLY — the SAME principal PlatformOnly enforces on the
// REST mint routes. This is the ZAP counterpart of that gate: without it, /zap
// billing.deposit let ANY org owner (Admin bit) mint unlimited iam-user balance
// while /deposit correctly 403'd them (C1). It is a real HTTP 403 (an auth
// boundary), not a 200 JSON-RPC error envelope — mirroring middleware.PlatformOnly.
// Reads + recordUsage (a debit) are deliberately NOT gated.
if zapMintMethods[req.Method] {
if !middleware.MayMintMoney(c) {
return httperr.Fail(c, 403,
"This operation requires platform-administrator or internal-service credentials.",
errors.New("ZAP money-mint method: caller is not a platform admin"))
}
// Proven mint principal → authorize the ledger sink so zapDeposit's write
// passes mintauth.Enforce (its datastore is built after this via
// org.Namespaced, which reads the authorized context).
middleware.AuthorizeMint(c)
}
var result interface{}
var zapErr *zapError
switch req.Method {
case "billing.getBalance":
result, zapErr = zapGetBalance(c, req.Params)
case "billing.getBalanceAll":
result, zapErr = zapGetBalanceAll(c, req.Params)
case "billing.getUsage":
result, zapErr = zapGetUsage(c, req.Params)
case "billing.recordUsage":
result, zapErr = zapRecordUsage(c, req.Params)
case "billing.deposit":
result, zapErr = zapDeposit(c, req.Params)
default:
zapErr = &zapError{Code: -32601, Message: "method not found: " + req.Method}
}
if zapErr != nil {
return c.JSON(200, zapResponse{ID: req.ID, Error: zapErr})
}
return c.JSON(200, zapResponse{ID: req.ID, Result: result})
}
// ── ZAP method handlers ─────────────────────────────────────────────────
type zapBalanceParams struct {
User string `json:"user"`
Currency string `json:"currency"`
}
func zapGetBalance(c *zip.Ctx, params json.RawMessage) (interface{}, *zapError) {
var p zapBalanceParams
if err := json.Unmarshal(params, &p); err != nil {
return nil, &zapError{Code: -32602, Message: "invalid params: " + err.Error()}
}
if p.User == "" {
return nil, &zapError{Code: -32602, Message: "user is required"}
}
org := middleware.GetOrganization(c)
ctx := org.Namespaced(c.Context())
cur := currency.Type(strings.ToLower(p.Currency))
if cur == "" {
cur = "usd"
}
datas, err := util.GetTransactionsByCurrency(ctx, p.User, "iam-user", cur, org.TestMode())
if err != nil {
return nil, &zapError{Code: -32000, Message: "balance query failed: " + err.Error()}
}
var balance, holds currency.Cents
if data, ok := datas.Data[cur]; ok {
balance = data.Balance
holds = data.Holds
}
available := balance - holds
if available < 0 {
available = 0
}
return map[string]any{
"user": p.User,
"currency": cur,
"balance": balance,
"holds": holds,
"available": available,
}, nil
}
func zapGetBalanceAll(c *zip.Ctx, params json.RawMessage) (interface{}, *zapError) {
var p struct {
User string `json:"user"`
}
if err := json.Unmarshal(params, &p); err != nil {
return nil, &zapError{Code: -32602, Message: "invalid params: " + err.Error()}
}
if p.User == "" {
return nil, &zapError{Code: -32602, Message: "user is required"}
}
org := middleware.GetOrganization(c)
ctx := org.Namespaced(c.Context())
datas, err := util.GetTransactions(ctx, p.User, "iam-user", org.TestMode())
if err != nil {
return nil, &zapError{Code: -32000, Message: "balance query failed: " + err.Error()}
}
balances := make([]map[string]any, 0, len(datas.Data))
for cur, data := range datas.Data {
available := data.Balance - data.Holds
if available < 0 {
available = 0
}
balances = append(balances, map[string]any{
"currency": cur,
"balance": data.Balance,
"holds": data.Holds,
"available": available,
})
}
return map[string]any{
"user": p.User,
"balances": balances,
}, nil
}
func zapGetUsage(c *zip.Ctx, params json.RawMessage) (interface{}, *zapError) {
var p zapBalanceParams
if err := json.Unmarshal(params, &p); err != nil {
return nil, &zapError{Code: -32602, Message: "invalid params: " + err.Error()}
}
if p.User == "" {
return nil, &zapError{Code: -32602, Message: "user is required"}
}
org := middleware.GetOrganization(c)
ctx := org.Namespaced(c.Context())
db := datastore.New(ctx)
rootKey := db.NewKey("synckey", "", 1, nil)
transs := make([]*transaction.Transaction, 0)
q := transaction.Query(db).Ancestor(rootKey).
Filter("Test=", org.TestMode()).
Filter("SourceKind=", "iam-user").
Filter("SourceId=", p.User).
Filter("Tags=", "api-usage")
cur := currency.Type(strings.ToLower(p.Currency))
if cur != "" {
q = q.Filter("Currency=", cur)
}
if _, err := q.GetAll(&transs); err != nil {
return nil, &zapError{Code: -32000, Message: "usage query failed: " + err.Error()}
}
items := make([]map[string]any, 0, len(transs))
for _, t := range transs {
items = append(items, map[string]any{
"transactionId": t.Id(),
"amount": t.Amount,
"currency": t.Currency,
"notes": t.Notes,
"metadata": t.Metadata,
"createdAt": t.CreatedAt,
})
}
return map[string]any{
"user": p.User,
"count": len(items),
"usage": items,
}, nil
}
// zapRecordUsage is billing.recordUsage: the SAME write as POST
// /v1/billing/usage (writeUsage), keyed for idempotency on the requestId.
func zapRecordUsage(c *zip.Ctx, params json.RawMessage) (interface{}, *zapError) {
var req usageRequest
if err := json.Unmarshal(params, &req); err != nil {
return nil, &zapError{Code: -32602, Message: "invalid params: " + err.Error()}
}
out, replay, err := writeUsage(c, req, strings.TrimSpace(req.RequestID))
var refused usageRefused
switch {
case errors.As(err, &refused):
return nil, &zapError{Code: -32602, Message: refused.msg}
case err != nil:
log.Error("ZAP: Failed to record usage: %v", err, c)
return nil, &zapError{Code: -32000, Message: "failed to record usage: " + err.Error()}
case replay != nil:
return json.RawMessage(replay), nil
}
return out, nil
}
func zapDeposit(c *zip.Ctx, params json.RawMessage) (interface{}, *zapError) {
var req depositRequest
if err := json.Unmarshal(params, &req); err != nil {
return nil, &zapError{Code: -32602, Message: "invalid params: " + err.Error()}
}
if req.User == "" {
return nil, &zapError{Code: -32602, Message: "user is required"}
}
if req.Amount <= 0 {
return nil, &zapError{Code: -32602, Message: "amount must be positive"}
}
org := middleware.GetOrganization(c)
db := datastore.New(org.Namespaced(c.Context()))
cur := currency.Type(strings.ToLower(req.Currency))
if cur == "" {
cur = "usd"
}
notes := req.Notes
if notes == "" {
notes = fmt.Sprintf("Deposit: %d cents %s", req.Amount, cur)
}
trans := transaction.New(db)
trans.Type = transaction.Deposit
trans.DestinationId = req.User
trans.DestinationKind = "iam-user"
trans.Currency = cur
trans.Amount = currency.Cents(req.Amount)
trans.Notes = notes
trans.Tags = req.Tags
trans.Metadata = Map{"mintedBy": middleware.Actor(c)}
trans.ExpiresAt = time.Now().AddDate(0, 0, expiryDays(req.ExpiresIn))
if org.TestMode() {
trans.Test = true
}
if err := trans.Create(); err != nil {
log.Error("ZAP: Failed to create deposit: %v", err, c)
return nil, &zapError{Code: -32000, Message: "failed to create deposit: " + err.Error()}
}
resp := map[string]any{
"transactionId": trans.Id(),
"user": req.User,
"amount": req.Amount,
"currency": cur,
"type": "deposit",
"tags": req.Tags,
}
if !trans.ExpiresAt.IsZero() {
resp["expiresAt"] = trans.ExpiresAt
}
return resp, nil
}