Files
commerce/api/catalog/handlers.go
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

293 lines
12 KiB
Go

// Package catalog is the HTTP surface for the platform product catalog — the
// CMS source-of-truth for a brand's OWN products (Models, Vector, KMS, …) that
// docs.<brand>, the console sidebar, and pricing derive from.
//
// Two audiences, two auth models:
// - PUBLIC read: GET /catalog returns the brand-scoped projection with no
// auth (it is public presentation + pricing data). Wired on the commerce
// public group so it serves the exact path GET /v1/commerce/catalog.
// - PLATFORM-ADMIN write: create/update/delete/seed mutate the platform-global
// catalog (the "system" namespace, NOT a per-tenant org), so they gate on
// auth.IAMClaims.IsSuperAdmin() — a Hanzo platform admin, never an org-level
// admin. Wired on the /v1 bundle under /catalog/entries.
//
// The catalog is platform-global: one store in the "system" namespace, scoped
// per requesting brand BY CATEGORY at projection time (matching @hanzo/products
// catalogForBrand). Entries are keyed by their globally-unique slug.
package catalog
import (
"errors"
nethttp "net/http"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/checkout"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/middleware"
"github.com/hanzoai/commerce/middleware/iammiddleware"
"github.com/hanzoai/commerce/models/catalogentry"
"github.com/hanzoai/commerce/util/json"
"github.com/hanzoai/commerce/util/json/http"
)
// catalogDB returns a datastore scoped to the platform catalog namespace.
// catalogentry.SystemDB owns that scoping — there is one way to reach the store.
func catalogDB(c *zip.Ctx) *datastore.Datastore {
return catalogentry.SystemDB(c.Context())
}
// requireSuperAdmin fails closed unless the caller is a Hanzo PLATFORM admin.
// The platform catalog is cross-tenant data, so an org-level admin must NOT edit
// it (Red: org-admin → platform escalation). Returns false + writes 403 on deny.
func requireSuperAdmin(c *zip.Ctx) bool {
claims := iammiddleware.GetIAMClaims(c)
if claims == nil || !claims.IsSuperAdmin() {
http.Fail(c, 403, "platform admin required to edit the catalog", errors.New("not a SuperAdmin"))
return false
}
return true
}
// requirePlatform fails closed unless the caller is the PLATFORM PRINCIPAL —
// a SuperAdmin or the internal service token (middleware.IsPlatform). It gates
// the SYNC endpoints, which a scheduled job has to be able to call: a sync that only
// a human can start is a catalog that goes stale, and the model rows stayed
// empty in production for exactly that reason.
//
// Admitting the service token here costs nothing a sync could abuse. A sync
// writes upstream COST and only ever freezes a retail price on a row BORN in
// that run (catalogentry.freezePrice); a live model's Price is unreachable from
// this endpoint, so the money a customer is charged still moves only by a human
// hand in admin.hanzo.ai. Editing entries stays requireSuperAdmin.
func requirePlatform(c *zip.Ctx) bool {
if !middleware.IsPlatform(c) {
http.Fail(c, 403, "platform administrator or internal-service credentials required to sync the catalog",
errors.New("caller is not a platform admin"))
return false
}
return true
}
// PublicRoute wires the public, unauthenticated catalog projection. Mount on the
// commerce public group so it serves GET /v1/commerce/catalog.
func PublicRoute(r *zip.Group) {
r.Raw(nethttp.MethodGet, "/catalog", Public)
}
// AdminCatalogRoute wires the owner=="admin" margin projection. Mount on an
// IAM-gated commerce group so it serves GET /v1/commerce/admin/catalog; the
// handler ALSO enforces IsSuperAdmin() (defense in depth).
func AdminCatalogRoute(r *zip.Group) {
r.Raw(nethttp.MethodGet, "/admin/catalog", AdminCatalog)
}
// AdminRoute wires the platform-admin catalog CRUD + seed on the /v1 bundle.
func AdminRoute(r *zip.Group, args ...zip.Handler) {
g := r.Group("/catalog")
// args carry the bundle's tokenRequired/adminRequired; each handler ALSO
// enforces IsSuperAdmin() explicitly (defense in depth — a token gate is not
// a platform-admin gate).
g.Raw(nethttp.MethodGet, "/entries", append(args, ListEntries)...)
g.Raw(nethttp.MethodPost, "/entries", append(args, CreateEntry)...)
// The slug is a WILDCARD, not a segment param, because a model's slug IS its
// callable id and those contain a slash ("anthropic/claude-opus-5"). A :slug
// param stops at the slash, so every model row would be listed and
// un-editable — a catalog whose own admin surface cannot address most of its
// rows. Measured on this router: fiber's `+` param does not bind here, `*`
// does, and it matches a single-segment slug identically, so the infra-tier
// edits are byte-for-byte unchanged.
g.Raw(nethttp.MethodPut, "/entries/*", append(args, UpdateEntry)...)
g.Raw(nethttp.MethodDelete, "/entries/*", append(args, DeleteEntry)...)
g.Raw(nethttp.MethodPost, "/seed", append(args, SeedCatalog)...)
g.Raw(nethttp.MethodPost, "/models", append(args, SyncModels)...)
g.Raw(nethttp.MethodPost, "/models/refresh", append(args, RefreshModels)...)
}
// entrySlug reads the addressed slug from the wildcard path.
func entrySlug(c *zip.Ctx) string { return c.Param("*") }
// Public returns the brand-scoped catalog projection. Public + cacheable.
// Brand from ?brand, else the brand the request's host resolves to — the same
// table the org endpoint reads, so pay.lux.cloud's catalog is Lux's.
func Public(c *zip.Ctx) error {
brand := c.Query("brand")
if brand == "" {
brand = checkout.BrandSlugForHost(checkout.RequestHost(c))
}
cat, err := catalogentry.Project(catalogDB(c), brand)
if err != nil {
return http.Fail(c, 500, "failed to project catalog", err)
}
return http.Render(c, 200, cat)
}
// AdminCatalog returns the brand-scoped catalog projection WITH the administrative
// economics (costCents + marginPct) the public projection withholds — the margin
// surface admin.hanzo.ai administrates. owner=="admin" only (IsSuperAdmin); an
// org-level admin is refused 403, so upstream cost and margin never leak to a
// tenant. Brand from ?brand (default hanzo).
func AdminCatalog(c *zip.Ctx) error {
if !requireSuperAdmin(c) {
return nil
}
brand := c.Query("brand")
if brand == "" {
brand = "hanzo"
}
cat, err := catalogentry.ProjectAdmin(catalogDB(c), brand)
if err != nil {
return http.Fail(c, 500, "failed to project admin catalog", err)
}
return http.Render(c, 200, cat)
}
// ListEntries returns the raw catalog entries (admin view — includes
// unpublished). Optional ?brand filter.
func ListEntries(c *zip.Ctx) error {
if !requireSuperAdmin(c) {
return nil
}
db := catalogDB(c)
entries := make([]*catalogentry.CatalogEntry, 0, 128)
if _, err := catalogentry.Query(db).GetAll(&entries); err != nil {
return http.Fail(c, 500, "failed to list catalog entries", err)
}
return http.Render(c, 200, entries)
}
// CreateEntry adds a catalog entry (platform admin).
func CreateEntry(c *zip.Ctx) error {
if !requireSuperAdmin(c) {
return nil
}
db := catalogDB(c)
e := catalogentry.New(db)
if err := json.DecodeBytes(c.Body(), e); err != nil {
return http.Fail(c, 400, "failed to decode request body", err)
}
if e.Slug == "" {
return http.Fail(c, 400, "slug is required", errors.New("missing slug"))
}
// Slug is the globally-unique catalog key — reject a duplicate.
dup := catalogentry.New(db)
if ok, _ := dup.Query().Filter("Slug=", e.Slug).Get(); ok {
return http.Fail(c, 409, "a catalog entry with this slug already exists", errors.New("duplicate slug"))
}
if err := e.Create(); err != nil {
return http.Fail(c, 500, "failed to create catalog entry", err)
}
return http.Render(c, 201, e)
}
// UpdateEntry edits a catalog entry by slug (platform admin). The slug identity
// is preserved; other fields are replaced from the body.
func UpdateEntry(c *zip.Ctx) error {
if !requireSuperAdmin(c) {
return nil
}
db := catalogDB(c)
slug := entrySlug(c)
e := catalogentry.New(db)
ok, err := e.Query().Filter("Slug=", slug).Get()
if err != nil {
return http.Fail(c, 500, "failed to load catalog entry", err)
}
if !ok {
return http.Fail(c, 404, "no catalog entry found with slug: "+slug, errors.New("not found"))
}
was := struct{ path, route, kind string }{e.ApiPath, e.ApiRoute, catalogentry.KindOf(e)}
if err := json.DecodeBytes(c.Body(), e); err != nil {
return http.Fail(c, 400, "failed to decode request body", err)
}
e.Slug = slug // identity is immutable — the path slug wins over any body value
// The ADDRESS is not editable here, and saying so beats accepting it.
//
// A product's apiPath is a fact about the fleet — either the router delivers
// there or it does not — so it arrives with a deploy, from the snapshot, and
// catalogentry.Correct reads it back over this row on every boot. Storing an
// edit would return 200 for a value the next restart deletes, which is an
// endpoint reporting durable success it cannot keep.
//
// Refused only when it CHANGES: this handler takes a whole entity, so the
// console reads a row and PUTs it back with the address it was given, and
// that has to keep working. Kind is compared defaulted, because a projection
// that always states "service" and a row that stores "" mean the same thing.
if e.ApiPath != was.path || e.ApiRoute != was.route || catalogentry.KindOf(e) != was.kind {
return http.Fail(c, 409, "a product's API address travels with a deploy, not this endpoint — "+
"edit apiPath/apiRoute/kind in hanzoai/commerce models/catalogentry/seed/hanzo-catalog.json, "+
"where cloud's catalog_address_test.go checks it against the routes the fleet publishes. "+
"PUT the address you were given, or leave it out",
errors.New("apiPath/apiRoute/kind are deploy-owned"))
}
if err := e.Update(); err != nil {
return http.Fail(c, 500, "failed to update catalog entry", err)
}
return http.Render(c, 200, e)
}
// DeleteEntry removes a catalog entry by slug (platform admin).
func DeleteEntry(c *zip.Ctx) error {
if !requireSuperAdmin(c) {
return nil
}
db := catalogDB(c)
slug := entrySlug(c)
e := catalogentry.New(db)
ok, err := e.Query().Filter("Slug=", slug).Get()
if err != nil {
return http.Fail(c, 500, "failed to load catalog entry", err)
}
if !ok {
return http.Fail(c, 404, "no catalog entry found with slug: "+slug, errors.New("not found"))
}
if err := e.Delete(); err != nil {
return http.Fail(c, 500, "failed to delete catalog entry", err)
}
c.SetHeader("Content-Type", "application/json")
return c.Bytes(204, make([]byte, 0))
}
// SyncModels lands a syncer's view of the model catalog: it refreshes each
// model's upstream COST and machine-observable facts and touches nothing a
// human owns — not the retail price, not the markup, not the entitlement tier
// (catalogentry.UpsertModels enforces that, so the rule holds no matter which
// syncer calls). This is the ONE write seam between the model families /
// upstream and the catalog: they own the structure and publish it, commerce
// holds the numbers, admin.hanzo.ai edits them.
//
// Platform principal, like its sibling RefreshModels — the two sync endpoints land
// through the same UpsertModels, so gating them differently would only decide
// which syncer has to be a human.
func SyncModels(c *zip.Ctx) error {
if !requirePlatform(c) {
return nil
}
var rows []catalogentry.ModelRow
if err := json.DecodeBytes(c.Body(), &rows); err != nil {
return http.Fail(c, 400, "failed to decode model rows", err)
}
stats, err := catalogentry.UpsertModels(catalogDB(c), rows)
if err != nil {
return http.Fail(c, 500, "failed to sync models", err)
}
return http.Render(c, 200, stats)
}
// SeedCatalog upserts the embedded Hanzo catalog seed (idempotent,
// non-destructive — never overwrites CMS edits). Platform admin only.
func SeedCatalog(c *zip.Ctx) error {
if !requireSuperAdmin(c) {
return nil
}
created, err := catalogentry.Seed(catalogDB(c))
if err != nil {
return http.Fail(c, 500, "failed to seed catalog", err)
}
return http.Render(c, 200, map[string]any{"created": created})
}