mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
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.
346 lines
10 KiB
Go
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
|
|
}
|