Add merchant-scoped marketplace checkout plumbing
Some checks are pending
check / secretapi (push) Waiting to run

This commit is contained in:
Joshua 2026-02-19 15:12:56 -08:00
parent 20e68c4dff
commit b8d9147f5c
6 changed files with 230 additions and 31 deletions

View File

@ -397,7 +397,7 @@ func (a *app) handleMembershipQuote(w http.ResponseWriter, r *http.Request) {
writeErrorCode(w, http.StatusForbidden, "sponsor_not_authorized", "sponsor entitlement is not active") writeErrorCode(w, http.StatusForbidden, "sponsor_not_authorized", "sponsor entitlement is not active")
return return
} }
hasWorkspaceCore, entErr := a.store.hasActiveEntitlement(r.Context(), payerAddress, offerIDWorkspaceCore, sponsorOrgRoot) hasWorkspaceCore, entErr := a.store.hasActiveEntitlement(r.Context(), payerAddress, defaultMarketplaceMerchantID, offerIDWorkspaceCore, sponsorOrgRoot)
if entErr != nil { if entErr != nil {
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to validate sponsor entitlement") writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to validate sponsor entitlement")
return return
@ -1033,7 +1033,7 @@ func (a *app) handleGovernanceOfflineRenew(w http.ResponseWriter, r *http.Reques
if isSoloRoot { if isSoloRoot {
requiredOfferID = offerIDSoloCore requiredOfferID = offerIDSoloCore
} }
hasRequired, entErr := a.store.hasActiveEntitlement(r.Context(), wallet, requiredOfferID, principal.OrgRootID) hasRequired, entErr := a.store.hasActiveEntitlement(r.Context(), wallet, defaultMarketplaceMerchantID, requiredOfferID, principal.OrgRootID)
if entErr != nil { if entErr != nil {
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to resolve sovereign entitlement") writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to resolve sovereign entitlement")
return return

View File

@ -1090,6 +1090,9 @@ func TestMarketplaceCheckoutBundlesMembershipAndMintsEntitlement(t *testing.T) {
if len(quote.LineItems) < 2 { if len(quote.LineItems) < 2 {
t.Fatalf("expected license + membership line items: %+v", quote.LineItems) t.Fatalf("expected license + membership line items: %+v", quote.LineItems)
} }
if quote.MerchantID != defaultMarketplaceMerchantID {
t.Fatalf("expected default merchant id, got %+v", quote)
}
confirm := postJSONExpect[marketplaceCheckoutConfirmResponse](t, a, "/marketplace/checkout/confirm", marketplaceCheckoutConfirmRequest{ confirm := postJSONExpect[marketplaceCheckoutConfirmResponse](t, a, "/marketplace/checkout/confirm", marketplaceCheckoutConfirmRequest{
QuoteID: quote.QuoteID, QuoteID: quote.QuoteID,
@ -1117,6 +1120,90 @@ func TestMarketplaceCheckoutBundlesMembershipAndMintsEntitlement(t *testing.T) {
if entitlements.Entitlements[0].OfferID != offerIDWorkspaceCore { if entitlements.Entitlements[0].OfferID != offerIDWorkspaceCore {
t.Fatalf("unexpected entitlement offer: %+v", entitlements.Entitlements[0]) t.Fatalf("unexpected entitlement offer: %+v", entitlements.Entitlements[0])
} }
if entitlements.Entitlements[0].MerchantID != defaultMarketplaceMerchantID {
t.Fatalf("expected default merchant id on entitlement, got %+v", entitlements.Entitlements[0])
}
}
func TestMarketplaceQuoteRejectsUnknownMerchant(t *testing.T) {
a, _, cleanup := newTestApp(t)
defer cleanup()
ownerKey := mustKey(t)
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex())
if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil {
t.Fatalf("seed active membership: %v", err)
}
errResp := postJSONExpect[map[string]string](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{
MerchantID: "acme.workspace",
Wallet: ownerAddr,
OfferID: offerIDWorkspaceCore,
OrgRootID: "org.marketplace.acme",
PrincipalID: "human.owner",
PrincipalRole: "org_root_owner",
}, http.StatusNotFound)
if code := errResp["code"]; code != "offer_not_found" {
t.Fatalf("expected offer_not_found for unknown merchant, got %+v", errResp)
}
}
func TestMarketplaceEntitlementsFilterByMerchant(t *testing.T) {
a, _, cleanup := newTestApp(t)
defer cleanup()
ownerKey := mustKey(t)
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex())
now := time.Now().UTC()
if err := a.store.putMarketplaceEntitlement(context.Background(), marketplaceEntitlementRecord{
EntitlementID: "ent:seed:firstparty",
QuoteID: "seed-q-firstparty",
MerchantID: defaultMarketplaceMerchantID,
OfferID: offerIDWorkspaceCore,
Wallet: ownerAddr,
OrgRootID: "org.marketplace.filter",
PrincipalID: "human.owner",
PrincipalRole: "org_root_owner",
State: "active",
AccessClass: "connected",
AvailabilityState: "active",
PolicyHash: "sha256:testpolicy",
IssuedAt: now,
TxHash: "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
}); err != nil {
t.Fatalf("seed first-party entitlement: %v", err)
}
if err := a.store.putMarketplaceEntitlement(context.Background(), marketplaceEntitlementRecord{
EntitlementID: "ent:seed:other",
QuoteID: "seed-q-other",
MerchantID: "acme.workspace",
OfferID: "acme.offer.alpha",
Wallet: ownerAddr,
OrgRootID: "org.marketplace.filter",
PrincipalID: "human.owner",
PrincipalRole: "org_root_owner",
State: "active",
AccessClass: "connected",
AvailabilityState: "active",
PolicyHash: "sha256:testpolicy",
IssuedAt: now,
TxHash: "0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
}); err != nil {
t.Fatalf("seed third-party entitlement: %v", err)
}
all := getJSONExpect[marketplaceEntitlementsResponse](t, a, "/marketplace/entitlements?wallet="+ownerAddr, http.StatusOK)
if len(all.Entitlements) != 2 {
t.Fatalf("expected both merchant entitlements, got %+v", all.Entitlements)
}
firstParty := getJSONExpect[marketplaceEntitlementsResponse](t, a, "/marketplace/entitlements?wallet="+ownerAddr+"&merchant_id="+defaultMarketplaceMerchantID, http.StatusOK)
if len(firstParty.Entitlements) != 1 {
t.Fatalf("expected one first-party entitlement, got %+v", firstParty.Entitlements)
}
if firstParty.Entitlements[0].MerchantID != defaultMarketplaceMerchantID {
t.Fatalf("expected first-party merchant id, got %+v", firstParty.Entitlements[0])
}
} }
func TestMarketplaceDistinctPayerRequiresOwnershipProof(t *testing.T) { func TestMarketplaceDistinctPayerRequiresOwnershipProof(t *testing.T) {

View File

@ -16,6 +16,7 @@ import (
const ( const (
marketplaceMembershipActivationAtomic = "100000000" // 100.00 USDC (6 decimals) marketplaceMembershipActivationAtomic = "100000000" // 100.00 USDC (6 decimals)
marketplaceStandardOfferAtomic = "1000000000" // 1000.00 USDC (6 decimals) marketplaceStandardOfferAtomic = "1000000000" // 1000.00 USDC (6 decimals)
defaultMarketplaceMerchantID = "edut.firstparty"
offerIDSoloCore = "edut.solo.core" offerIDSoloCore = "edut.solo.core"
offerIDWorkspaceCore = "edut.workspace.core" offerIDWorkspaceCore = "edut.workspace.core"
@ -24,11 +25,33 @@ const (
offerIDWorkspaceSovereign = "edut.workspace.sovereign" offerIDWorkspaceSovereign = "edut.workspace.sovereign"
) )
func (a *app) marketplaceOffers() []marketplaceOffer { func normalizeMerchantID(raw string) string {
merchantID := strings.ToLower(strings.TrimSpace(raw))
if merchantID == "" {
return defaultMarketplaceMerchantID
}
return merchantID
}
func marketplaceContractOfferID(merchantID, offerID string) string {
merchantID = normalizeMerchantID(merchantID)
offerID = strings.TrimSpace(strings.ToLower(offerID))
if merchantID == defaultMarketplaceMerchantID {
return offerID
}
return merchantID + ":" + offerID
}
func (a *app) marketplaceOffersForMerchant(merchantID string) []marketplaceOffer {
merchantID = normalizeMerchantID(merchantID)
if merchantID != defaultMarketplaceMerchantID {
return []marketplaceOffer{}
}
offers := []marketplaceOffer{ offers := []marketplaceOffer{
{ {
MerchantID: defaultMarketplaceMerchantID,
OfferID: offerIDSoloCore, OfferID: offerIDSoloCore,
IssuerID: "edut.firstparty", IssuerID: defaultMarketplaceMerchantID,
Title: "EDUT Solo Core", Title: "EDUT Solo Core",
Summary: "Single-principal governance runtime for personal operations.", Summary: "Single-principal governance runtime for personal operations.",
Status: "active", Status: "active",
@ -54,8 +77,9 @@ func (a *app) marketplaceOffers() []marketplaceOffer {
SortOrder: 10, SortOrder: 10,
}, },
{ {
MerchantID: defaultMarketplaceMerchantID,
OfferID: offerIDWorkspaceCore, OfferID: offerIDWorkspaceCore,
IssuerID: "edut.firstparty", IssuerID: defaultMarketplaceMerchantID,
Title: "EDUT Workspace Core", Title: "EDUT Workspace Core",
Summary: "Org-bound deterministic governance runtime for team operations.", Summary: "Org-bound deterministic governance runtime for team operations.",
Status: "active", Status: "active",
@ -81,8 +105,9 @@ func (a *app) marketplaceOffers() []marketplaceOffer {
SortOrder: 20, SortOrder: 20,
}, },
{ {
MerchantID: defaultMarketplaceMerchantID,
OfferID: offerIDWorkspaceAI, OfferID: offerIDWorkspaceAI,
IssuerID: "edut.firstparty", IssuerID: defaultMarketplaceMerchantID,
Title: "EDUT Workspace AI Layer", Title: "EDUT Workspace AI Layer",
Summary: "AI reasoning layer for governed workspace operations.", Summary: "AI reasoning layer for governed workspace operations.",
Status: "active", Status: "active",
@ -109,8 +134,9 @@ func (a *app) marketplaceOffers() []marketplaceOffer {
SortOrder: 30, SortOrder: 30,
}, },
{ {
MerchantID: defaultMarketplaceMerchantID,
OfferID: offerIDWorkspaceLane24, OfferID: offerIDWorkspaceLane24,
IssuerID: "edut.firstparty", IssuerID: defaultMarketplaceMerchantID,
Title: "EDUT Workspace 24h Lane", Title: "EDUT Workspace 24h Lane",
Summary: "Autonomous execution lane capacity for workspace queue throughput.", Summary: "Autonomous execution lane capacity for workspace queue throughput.",
Status: "active", Status: "active",
@ -136,8 +162,9 @@ func (a *app) marketplaceOffers() []marketplaceOffer {
SortOrder: 40, SortOrder: 40,
}, },
{ {
MerchantID: defaultMarketplaceMerchantID,
OfferID: offerIDWorkspaceSovereign, OfferID: offerIDWorkspaceSovereign,
IssuerID: "edut.firstparty", IssuerID: defaultMarketplaceMerchantID,
Title: "EDUT Workspace Sovereign Continuity", Title: "EDUT Workspace Sovereign Continuity",
Summary: "Workspace continuity profile for stronger local/offline operation.", Summary: "Workspace continuity profile for stronger local/offline operation.",
Status: "active", Status: "active",
@ -169,9 +196,13 @@ func (a *app) marketplaceOffers() []marketplaceOffer {
return offers return offers
} }
func (a *app) marketplaceOfferByID(offerID string) (marketplaceOffer, error) { func (a *app) marketplaceOffers() []marketplaceOffer {
return a.marketplaceOffersForMerchant(defaultMarketplaceMerchantID)
}
func (a *app) marketplaceOfferByID(merchantID, offerID string) (marketplaceOffer, error) {
target := strings.TrimSpace(strings.ToLower(offerID)) target := strings.TrimSpace(strings.ToLower(offerID))
for _, offer := range a.marketplaceOffers() { for _, offer := range a.marketplaceOffersForMerchant(merchantID) {
if strings.EqualFold(strings.TrimSpace(offer.OfferID), target) { if strings.EqualFold(strings.TrimSpace(offer.OfferID), target) {
return offer, nil return offer, nil
} }
@ -210,7 +241,8 @@ func (a *app) handleMarketplaceOffers(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusMethodNotAllowed, "method not allowed") writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return return
} }
writeJSON(w, http.StatusOK, marketplaceOffersResponse{Offers: a.marketplaceOffers()}) merchantID := normalizeMerchantID(r.URL.Query().Get("merchant_id"))
writeJSON(w, http.StatusOK, marketplaceOffersResponse{Offers: a.marketplaceOffersForMerchant(merchantID)})
} }
func (a *app) handleMarketplaceOfferByID(w http.ResponseWriter, r *http.Request) { func (a *app) handleMarketplaceOfferByID(w http.ResponseWriter, r *http.Request) {
@ -224,7 +256,8 @@ func (a *app) handleMarketplaceOfferByID(w http.ResponseWriter, r *http.Request)
writeErrorCode(w, http.StatusNotFound, "offer_not_found", "offer not found") writeErrorCode(w, http.StatusNotFound, "offer_not_found", "offer not found")
return return
} }
offer, err := a.marketplaceOfferByID(offerID) merchantID := normalizeMerchantID(r.URL.Query().Get("merchant_id"))
offer, err := a.marketplaceOfferByID(merchantID, offerID)
if err != nil { if err != nil {
writeErrorCode(w, http.StatusNotFound, "offer_not_found", "offer not found") writeErrorCode(w, http.StatusNotFound, "offer_not_found", "offer not found")
return return
@ -250,7 +283,8 @@ func (a *app) handleMarketplaceCheckoutQuote(w http.ResponseWriter, r *http.Requ
if !a.enforceWalletSession(w, r, wallet) { if !a.enforceWalletSession(w, r, wallet) {
return return
} }
offer, err := a.marketplaceOfferByID(req.OfferID) merchantID := normalizeMerchantID(req.MerchantID)
offer, err := a.marketplaceOfferByID(merchantID, req.OfferID)
if err != nil { if err != nil {
writeErrorCode(w, http.StatusNotFound, "offer_not_found", "offer not found") writeErrorCode(w, http.StatusNotFound, "offer_not_found", "offer not found")
return return
@ -313,7 +347,7 @@ func (a *app) handleMarketplaceCheckoutQuote(w http.ResponseWriter, r *http.Requ
} }
for _, requiredOfferID := range offer.Policies.RequiresOffers { for _, requiredOfferID := range offer.Policies.RequiresOffers {
hasRequired, err := a.store.hasActiveEntitlement(r.Context(), wallet, requiredOfferID, orgRootID) hasRequired, err := a.store.hasActiveEntitlement(r.Context(), wallet, merchantID, requiredOfferID, orgRootID)
if err != nil { if err != nil {
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to resolve prerequisite entitlements") writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to resolve prerequisite entitlements")
return return
@ -410,7 +444,7 @@ func (a *app) handleMarketplaceCheckoutQuote(w http.ResponseWriter, r *http.Requ
txTo := entitlementContract txTo := entitlementContract
txValueHex := "0x0" txValueHex := "0x0"
txData, calldataErr := encodePurchaseEntitlementCalldata(offer.OfferID, wallet, orgRootID, workspaceID) txData, calldataErr := encodePurchaseEntitlementCalldata(marketplaceContractOfferID(merchantID, offer.OfferID), wallet, orgRootID, workspaceID)
if calldataErr != nil { if calldataErr != nil {
writeErrorCode(w, http.StatusInternalServerError, "quote_generation_failed", "failed to encode checkout transaction") writeErrorCode(w, http.StatusInternalServerError, "quote_generation_failed", "failed to encode checkout transaction")
return return
@ -424,6 +458,7 @@ func (a *app) handleMarketplaceCheckoutQuote(w http.ResponseWriter, r *http.Requ
} }
quote := marketplaceQuoteRecord{ quote := marketplaceQuoteRecord{
QuoteID: "cq_" + quoteIDRaw, QuoteID: "cq_" + quoteIDRaw,
MerchantID: merchantID,
Wallet: wallet, Wallet: wallet,
PayerWallet: payerWallet, PayerWallet: payerWallet,
OfferID: offer.OfferID, OfferID: offer.OfferID,
@ -453,6 +488,7 @@ func (a *app) handleMarketplaceCheckoutQuote(w http.ResponseWriter, r *http.Requ
writeJSON(w, http.StatusOK, marketplaceCheckoutQuoteResponse{ writeJSON(w, http.StatusOK, marketplaceCheckoutQuoteResponse{
QuoteID: quote.QuoteID, QuoteID: quote.QuoteID,
MerchantID: quote.MerchantID,
Wallet: quote.Wallet, Wallet: quote.Wallet,
PayerWallet: quote.PayerWallet, PayerWallet: quote.PayerWallet,
OfferID: quote.OfferID, OfferID: quote.OfferID,
@ -527,6 +563,11 @@ func (a *app) handleMarketplaceCheckoutConfirm(w http.ResponseWriter, r *http.Re
writeErrorCode(w, http.StatusConflict, "quote_expired", "quote expired") writeErrorCode(w, http.StatusConflict, "quote_expired", "quote expired")
return return
} }
merchantID := normalizeMerchantID(req.MerchantID)
if !strings.EqualFold(quote.MerchantID, merchantID) {
writeErrorCode(w, http.StatusBadRequest, "context_mismatch", "merchant_id mismatch")
return
}
if !strings.EqualFold(quote.Wallet, wallet) || if !strings.EqualFold(quote.Wallet, wallet) ||
!strings.EqualFold(strings.TrimSpace(quote.OfferID), strings.TrimSpace(req.OfferID)) { !strings.EqualFold(strings.TrimSpace(quote.OfferID), strings.TrimSpace(req.OfferID)) {
writeErrorCode(w, http.StatusBadRequest, "context_mismatch", "quote context mismatch") writeErrorCode(w, http.StatusBadRequest, "context_mismatch", "quote context mismatch")
@ -566,6 +607,7 @@ func (a *app) handleMarketplaceCheckoutConfirm(w http.ResponseWriter, r *http.Re
writeJSON(w, http.StatusOK, marketplaceCheckoutConfirmResponse{ writeJSON(w, http.StatusOK, marketplaceCheckoutConfirmResponse{
Status: "entitlement_active", Status: "entitlement_active",
EntitlementID: existing.EntitlementID, EntitlementID: existing.EntitlementID,
MerchantID: existing.MerchantID,
OfferID: existing.OfferID, OfferID: existing.OfferID,
OrgRootID: existing.OrgRootID, OrgRootID: existing.OrgRootID,
PrincipalID: existing.PrincipalID, PrincipalID: existing.PrincipalID,
@ -633,6 +675,7 @@ func (a *app) handleMarketplaceCheckoutConfirm(w http.ResponseWriter, r *http.Re
ent := marketplaceEntitlementRecord{ ent := marketplaceEntitlementRecord{
EntitlementID: entitlementID, EntitlementID: entitlementID,
QuoteID: quote.QuoteID, QuoteID: quote.QuoteID,
MerchantID: quote.MerchantID,
OfferID: quote.OfferID, OfferID: quote.OfferID,
Wallet: wallet, Wallet: wallet,
PayerWallet: quote.PayerWallet, PayerWallet: quote.PayerWallet,
@ -667,6 +710,7 @@ func (a *app) handleMarketplaceCheckoutConfirm(w http.ResponseWriter, r *http.Re
writeJSON(w, http.StatusOK, marketplaceCheckoutConfirmResponse{ writeJSON(w, http.StatusOK, marketplaceCheckoutConfirmResponse{
Status: "entitlement_active", Status: "entitlement_active",
EntitlementID: ent.EntitlementID, EntitlementID: ent.EntitlementID,
MerchantID: ent.MerchantID,
OfferID: ent.OfferID, OfferID: ent.OfferID,
OrgRootID: ent.OrgRootID, OrgRootID: ent.OrgRootID,
PrincipalID: ent.PrincipalID, PrincipalID: ent.PrincipalID,
@ -693,7 +737,8 @@ func (a *app) handleMarketplaceEntitlements(w http.ResponseWriter, r *http.Reque
if !a.enforceWalletSession(w, r, wallet) { if !a.enforceWalletSession(w, r, wallet) {
return return
} }
records, err := a.store.listMarketplaceEntitlementsByWallet(r.Context(), wallet) merchantID := strings.TrimSpace(r.URL.Query().Get("merchant_id"))
records, err := a.store.listMarketplaceEntitlementsByWallet(r.Context(), wallet, merchantID)
if err != nil { if err != nil {
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to list entitlements") writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to list entitlements")
return return
@ -702,6 +747,7 @@ func (a *app) handleMarketplaceEntitlements(w http.ResponseWriter, r *http.Reque
for _, rec := range records { for _, rec := range records {
out = append(out, marketplaceEntitlement{ out = append(out, marketplaceEntitlement{
EntitlementID: rec.EntitlementID, EntitlementID: rec.EntitlementID,
MerchantID: rec.MerchantID,
OfferID: rec.OfferID, OfferID: rec.OfferID,
WalletAddress: rec.Wallet, WalletAddress: rec.Wallet,
WorkspaceID: rec.WorkspaceID, WorkspaceID: rec.WorkspaceID,

View File

@ -3,6 +3,7 @@ package main
import "time" import "time"
type marketplaceOffer struct { type marketplaceOffer struct {
MerchantID string `json:"merchant_id,omitempty"`
OfferID string `json:"offer_id"` OfferID string `json:"offer_id"`
IssuerID string `json:"issuer_id"` IssuerID string `json:"issuer_id"`
Title string `json:"title"` Title string `json:"title"`
@ -42,6 +43,7 @@ type marketplaceOffersResponse struct {
} }
type marketplaceCheckoutQuoteRequest struct { type marketplaceCheckoutQuoteRequest struct {
MerchantID string `json:"merchant_id,omitempty"`
Wallet string `json:"wallet"` Wallet string `json:"wallet"`
PayerWallet string `json:"payer_wallet,omitempty"` PayerWallet string `json:"payer_wallet,omitempty"`
OfferID string `json:"offer_id"` OfferID string `json:"offer_id"`
@ -64,6 +66,7 @@ type marketplaceQuoteLineItem struct {
type marketplaceCheckoutQuoteResponse struct { type marketplaceCheckoutQuoteResponse struct {
QuoteID string `json:"quote_id"` QuoteID string `json:"quote_id"`
MerchantID string `json:"merchant_id,omitempty"`
Wallet string `json:"wallet"` Wallet string `json:"wallet"`
PayerWallet string `json:"payer_wallet,omitempty"` PayerWallet string `json:"payer_wallet,omitempty"`
OfferID string `json:"offer_id"` OfferID string `json:"offer_id"`
@ -87,6 +90,7 @@ type marketplaceCheckoutQuoteResponse struct {
type marketplaceCheckoutConfirmRequest struct { type marketplaceCheckoutConfirmRequest struct {
QuoteID string `json:"quote_id"` QuoteID string `json:"quote_id"`
MerchantID string `json:"merchant_id,omitempty"`
Wallet string `json:"wallet"` Wallet string `json:"wallet"`
PayerWallet string `json:"payer_wallet,omitempty"` PayerWallet string `json:"payer_wallet,omitempty"`
OfferID string `json:"offer_id"` OfferID string `json:"offer_id"`
@ -101,6 +105,7 @@ type marketplaceCheckoutConfirmRequest struct {
type marketplaceCheckoutConfirmResponse struct { type marketplaceCheckoutConfirmResponse struct {
Status string `json:"status"` Status string `json:"status"`
EntitlementID string `json:"entitlement_id"` EntitlementID string `json:"entitlement_id"`
MerchantID string `json:"merchant_id,omitempty"`
OfferID string `json:"offer_id"` OfferID string `json:"offer_id"`
OrgRootID string `json:"org_root_id,omitempty"` OrgRootID string `json:"org_root_id,omitempty"`
PrincipalID string `json:"principal_id,omitempty"` PrincipalID string `json:"principal_id,omitempty"`
@ -115,6 +120,7 @@ type marketplaceCheckoutConfirmResponse struct {
type marketplaceEntitlement struct { type marketplaceEntitlement struct {
EntitlementID string `json:"entitlement_id"` EntitlementID string `json:"entitlement_id"`
MerchantID string `json:"merchant_id,omitempty"`
OfferID string `json:"offer_id"` OfferID string `json:"offer_id"`
WalletAddress string `json:"wallet_address"` WalletAddress string `json:"wallet_address"`
WorkspaceID string `json:"workspace_id,omitempty"` WorkspaceID string `json:"workspace_id,omitempty"`
@ -132,6 +138,7 @@ type marketplaceEntitlementsResponse struct {
type marketplaceQuoteRecord struct { type marketplaceQuoteRecord struct {
QuoteID string QuoteID string
MerchantID string
Wallet string Wallet string
PayerWallet string PayerWallet string
OfferID string OfferID string
@ -160,6 +167,7 @@ type marketplaceQuoteRecord struct {
type marketplaceEntitlementRecord struct { type marketplaceEntitlementRecord struct {
EntitlementID string EntitlementID string
QuoteID string QuoteID string
MerchantID string
OfferID string OfferID string
Wallet string Wallet string
PayerWallet string PayerWallet string

View File

@ -99,6 +99,7 @@ func (s *store) migrate(ctx context.Context) error {
`CREATE INDEX IF NOT EXISTS idx_quotes_confirmed_tx_hash ON quotes(confirmed_tx_hash);`, `CREATE INDEX IF NOT EXISTS idx_quotes_confirmed_tx_hash ON quotes(confirmed_tx_hash);`,
`CREATE TABLE IF NOT EXISTS marketplace_quotes ( `CREATE TABLE IF NOT EXISTS marketplace_quotes (
quote_id TEXT PRIMARY KEY, quote_id TEXT PRIMARY KEY,
merchant_id TEXT NOT NULL DEFAULT 'edut.firstparty',
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
payer_wallet TEXT, payer_wallet TEXT,
offer_id TEXT NOT NULL, offer_id TEXT NOT NULL,
@ -128,6 +129,7 @@ func (s *store) migrate(ctx context.Context) error {
`CREATE TABLE IF NOT EXISTS marketplace_entitlements ( `CREATE TABLE IF NOT EXISTS marketplace_entitlements (
entitlement_id TEXT PRIMARY KEY, entitlement_id TEXT PRIMARY KEY,
quote_id TEXT NOT NULL UNIQUE, quote_id TEXT NOT NULL UNIQUE,
merchant_id TEXT NOT NULL DEFAULT 'edut.firstparty',
offer_id TEXT NOT NULL, offer_id TEXT NOT NULL,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
payer_wallet TEXT, payer_wallet TEXT,
@ -143,6 +145,7 @@ func (s *store) migrate(ctx context.Context) error {
tx_hash TEXT NOT NULL tx_hash TEXT NOT NULL
);`, );`,
`CREATE INDEX IF NOT EXISTS idx_marketplace_entitlements_wallet ON marketplace_entitlements(wallet);`, `CREATE INDEX IF NOT EXISTS idx_marketplace_entitlements_wallet ON marketplace_entitlements(wallet);`,
`CREATE INDEX IF NOT EXISTS idx_marketplace_entitlements_wallet_merchant_offer_state ON marketplace_entitlements(wallet, merchant_id, offer_id, state);`,
`CREATE TABLE IF NOT EXISTS governance_principals ( `CREATE TABLE IF NOT EXISTS governance_principals (
wallet TEXT PRIMARY KEY, wallet TEXT PRIMARY KEY,
org_root_id TEXT NOT NULL, org_root_id TEXT NOT NULL,
@ -282,6 +285,12 @@ func (s *store) migrate(ctx context.Context) error {
if err := s.ensureColumn(ctx, "marketplace_quotes", "expected_tx_value_hex", "TEXT"); err != nil { if err := s.ensureColumn(ctx, "marketplace_quotes", "expected_tx_value_hex", "TEXT"); err != nil {
return err return err
} }
if err := s.ensureColumn(ctx, "marketplace_quotes", "merchant_id", "TEXT NOT NULL DEFAULT 'edut.firstparty'"); err != nil {
return err
}
if err := s.ensureColumn(ctx, "marketplace_entitlements", "merchant_id", "TEXT NOT NULL DEFAULT 'edut.firstparty'"); err != nil {
return err
}
return nil return nil
} }
@ -588,11 +597,12 @@ func (s *store) getDesignationCodeByMembershipTxHash(ctx context.Context, txHash
func (s *store) putMarketplaceQuote(ctx context.Context, quote marketplaceQuoteRecord) error { func (s *store) putMarketplaceQuote(ctx context.Context, quote marketplaceQuoteRecord) error {
_, err := s.db.ExecContext(ctx, ` _, err := s.db.ExecContext(ctx, `
INSERT INTO marketplace_quotes ( INSERT INTO marketplace_quotes (
quote_id, wallet, payer_wallet, offer_id, org_root_id, principal_id, principal_role, workspace_id, quote_id, merchant_id, wallet, payer_wallet, offer_id, org_root_id, principal_id, principal_role, workspace_id,
currency, amount_atomic, total_amount_atomic, decimals, membership_included, line_items_json, currency, amount_atomic, total_amount_atomic, decimals, membership_included, line_items_json,
policy_hash, access_class, availability_state, expected_tx_to, expected_tx_data, expected_tx_value_hex, created_at, expires_at, confirmed_at, confirmed_tx_hash policy_hash, access_class, availability_state, expected_tx_to, expected_tx_data, expected_tx_value_hex, created_at, expires_at, confirmed_at, confirmed_tx_hash
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(quote_id) DO UPDATE SET ON CONFLICT(quote_id) DO UPDATE SET
merchant_id=excluded.merchant_id,
wallet=excluded.wallet, wallet=excluded.wallet,
payer_wallet=excluded.payer_wallet, payer_wallet=excluded.payer_wallet,
offer_id=excluded.offer_id, offer_id=excluded.offer_id,
@ -617,6 +627,7 @@ func (s *store) putMarketplaceQuote(ctx context.Context, quote marketplaceQuoteR
confirmed_at=excluded.confirmed_at, confirmed_at=excluded.confirmed_at,
confirmed_tx_hash=excluded.confirmed_tx_hash confirmed_tx_hash=excluded.confirmed_tx_hash
`, quote.QuoteID, `, quote.QuoteID,
normalizeMerchantID(quote.MerchantID),
strings.ToLower(strings.TrimSpace(quote.Wallet)), strings.ToLower(strings.TrimSpace(quote.Wallet)),
nullableString(strings.ToLower(strings.TrimSpace(quote.PayerWallet))), nullableString(strings.ToLower(strings.TrimSpace(quote.PayerWallet))),
strings.TrimSpace(quote.OfferID), strings.TrimSpace(quote.OfferID),
@ -646,19 +657,20 @@ func (s *store) putMarketplaceQuote(ctx context.Context, quote marketplaceQuoteR
func (s *store) getMarketplaceQuote(ctx context.Context, quoteID string) (marketplaceQuoteRecord, error) { func (s *store) getMarketplaceQuote(ctx context.Context, quoteID string) (marketplaceQuoteRecord, error) {
row := s.db.QueryRowContext(ctx, ` row := s.db.QueryRowContext(ctx, `
SELECT quote_id, wallet, payer_wallet, offer_id, org_root_id, principal_id, principal_role, workspace_id, SELECT quote_id, merchant_id, wallet, payer_wallet, offer_id, org_root_id, principal_id, principal_role, workspace_id,
currency, amount_atomic, total_amount_atomic, decimals, membership_included, line_items_json, currency, amount_atomic, total_amount_atomic, decimals, membership_included, line_items_json,
policy_hash, access_class, availability_state, expected_tx_to, expected_tx_data, expected_tx_value_hex, created_at, expires_at, confirmed_at, confirmed_tx_hash policy_hash, access_class, availability_state, expected_tx_to, expected_tx_data, expected_tx_value_hex, created_at, expires_at, confirmed_at, confirmed_tx_hash
FROM marketplace_quotes FROM marketplace_quotes
WHERE quote_id = ? WHERE quote_id = ?
`, strings.TrimSpace(quoteID)) `, strings.TrimSpace(quoteID))
var rec marketplaceQuoteRecord var rec marketplaceQuoteRecord
var payerWallet, orgRootID, principalID, principalRole, workspaceID sql.NullString var merchantID, payerWallet, orgRootID, principalID, principalRole, workspaceID sql.NullString
var expectedTxTo, expectedTxData, expectedTxValueHex sql.NullString var expectedTxTo, expectedTxData, expectedTxValueHex sql.NullString
var createdAt, expiresAt, confirmedAt, confirmedTxHash sql.NullString var createdAt, expiresAt, confirmedAt, confirmedTxHash sql.NullString
var membershipIncluded int var membershipIncluded int
err := row.Scan( err := row.Scan(
&rec.QuoteID, &rec.QuoteID,
&merchantID,
&rec.Wallet, &rec.Wallet,
&payerWallet, &payerWallet,
&rec.OfferID, &rec.OfferID,
@ -689,6 +701,7 @@ func (s *store) getMarketplaceQuote(ctx context.Context, quoteID string) (market
} }
return marketplaceQuoteRecord{}, err return marketplaceQuoteRecord{}, err
} }
rec.MerchantID = normalizeMerchantID(merchantID.String)
rec.PayerWallet = payerWallet.String rec.PayerWallet = payerWallet.String
rec.OrgRootID = orgRootID.String rec.OrgRootID = orgRootID.String
rec.PrincipalID = principalID.String rec.PrincipalID = principalID.String
@ -726,11 +739,12 @@ func (s *store) getMarketplaceQuoteIDByConfirmedTxHash(ctx context.Context, txHa
func (s *store) putMarketplaceEntitlement(ctx context.Context, ent marketplaceEntitlementRecord) error { func (s *store) putMarketplaceEntitlement(ctx context.Context, ent marketplaceEntitlementRecord) error {
_, err := s.db.ExecContext(ctx, ` _, err := s.db.ExecContext(ctx, `
INSERT INTO marketplace_entitlements ( INSERT INTO marketplace_entitlements (
entitlement_id, quote_id, offer_id, wallet, payer_wallet, org_root_id, principal_id, principal_role, entitlement_id, quote_id, merchant_id, offer_id, wallet, payer_wallet, org_root_id, principal_id, principal_role,
workspace_id, state, access_class, availability_state, policy_hash, issued_at, tx_hash workspace_id, state, access_class, availability_state, policy_hash, issued_at, tx_hash
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(entitlement_id) DO UPDATE SET ON CONFLICT(entitlement_id) DO UPDATE SET
quote_id=excluded.quote_id, quote_id=excluded.quote_id,
merchant_id=excluded.merchant_id,
offer_id=excluded.offer_id, offer_id=excluded.offer_id,
wallet=excluded.wallet, wallet=excluded.wallet,
payer_wallet=excluded.payer_wallet, payer_wallet=excluded.payer_wallet,
@ -746,6 +760,7 @@ func (s *store) putMarketplaceEntitlement(ctx context.Context, ent marketplaceEn
tx_hash=excluded.tx_hash tx_hash=excluded.tx_hash
`, strings.TrimSpace(ent.EntitlementID), `, strings.TrimSpace(ent.EntitlementID),
strings.TrimSpace(ent.QuoteID), strings.TrimSpace(ent.QuoteID),
normalizeMerchantID(ent.MerchantID),
strings.TrimSpace(ent.OfferID), strings.TrimSpace(ent.OfferID),
strings.ToLower(strings.TrimSpace(ent.Wallet)), strings.ToLower(strings.TrimSpace(ent.Wallet)),
nullableString(strings.ToLower(strings.TrimSpace(ent.PayerWallet))), nullableString(strings.ToLower(strings.TrimSpace(ent.PayerWallet))),
@ -765,7 +780,7 @@ func (s *store) putMarketplaceEntitlement(ctx context.Context, ent marketplaceEn
func (s *store) getMarketplaceEntitlementByQuote(ctx context.Context, quoteID string) (marketplaceEntitlementRecord, error) { func (s *store) getMarketplaceEntitlementByQuote(ctx context.Context, quoteID string) (marketplaceEntitlementRecord, error) {
row := s.db.QueryRowContext(ctx, ` row := s.db.QueryRowContext(ctx, `
SELECT entitlement_id, quote_id, offer_id, wallet, payer_wallet, org_root_id, principal_id, principal_role, SELECT entitlement_id, quote_id, merchant_id, offer_id, wallet, payer_wallet, org_root_id, principal_id, principal_role,
workspace_id, state, access_class, availability_state, policy_hash, issued_at, tx_hash workspace_id, state, access_class, availability_state, policy_hash, issued_at, tx_hash
FROM marketplace_entitlements FROM marketplace_entitlements
WHERE quote_id = ? WHERE quote_id = ?
@ -773,14 +788,20 @@ func (s *store) getMarketplaceEntitlementByQuote(ctx context.Context, quoteID st
return scanMarketplaceEntitlement(row) return scanMarketplaceEntitlement(row)
} }
func (s *store) listMarketplaceEntitlementsByWallet(ctx context.Context, wallet string) ([]marketplaceEntitlementRecord, error) { func (s *store) listMarketplaceEntitlementsByWallet(ctx context.Context, wallet, merchantID string) ([]marketplaceEntitlementRecord, error) {
rows, err := s.db.QueryContext(ctx, ` query := `
SELECT entitlement_id, quote_id, offer_id, wallet, payer_wallet, org_root_id, principal_id, principal_role, SELECT entitlement_id, quote_id, merchant_id, offer_id, wallet, payer_wallet, org_root_id, principal_id, principal_role,
workspace_id, state, access_class, availability_state, policy_hash, issued_at, tx_hash workspace_id, state, access_class, availability_state, policy_hash, issued_at, tx_hash
FROM marketplace_entitlements FROM marketplace_entitlements
WHERE wallet = ? WHERE wallet = ?
ORDER BY issued_at DESC `
`, strings.ToLower(strings.TrimSpace(wallet))) args := []any{strings.ToLower(strings.TrimSpace(wallet))}
if strings.TrimSpace(merchantID) != "" {
query += " AND merchant_id = ?"
args = append(args, normalizeMerchantID(merchantID))
}
query += " ORDER BY issued_at DESC"
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -800,8 +821,9 @@ func (s *store) listMarketplaceEntitlementsByWallet(ctx context.Context, wallet
return records, nil return records, nil
} }
func (s *store) hasActiveEntitlement(ctx context.Context, wallet, offerID, orgRootID string) (bool, error) { func (s *store) hasActiveEntitlement(ctx context.Context, wallet, merchantID, offerID, orgRootID string) (bool, error) {
wallet = strings.ToLower(strings.TrimSpace(wallet)) wallet = strings.ToLower(strings.TrimSpace(wallet))
merchantID = normalizeMerchantID(merchantID)
offerID = strings.TrimSpace(offerID) offerID = strings.TrimSpace(offerID)
orgRootID = strings.TrimSpace(orgRootID) orgRootID = strings.TrimSpace(orgRootID)
if wallet == "" || offerID == "" { if wallet == "" || offerID == "" {
@ -812,10 +834,11 @@ func (s *store) hasActiveEntitlement(ctx context.Context, wallet, offerID, orgRo
SELECT 1 SELECT 1
FROM marketplace_entitlements FROM marketplace_entitlements
WHERE wallet = ? WHERE wallet = ?
AND merchant_id = ?
AND offer_id = ? AND offer_id = ?
AND state = 'active' AND state = 'active'
` `
args := []any{wallet, offerID} args := []any{wallet, merchantID, offerID}
if orgRootID != "" { if orgRootID != "" {
query += " AND org_root_id = ?" query += " AND org_root_id = ?"
args = append(args, orgRootID) args = append(args, orgRootID)
@ -835,11 +858,12 @@ func (s *store) hasActiveEntitlement(ctx context.Context, wallet, offerID, orgRo
func scanMarketplaceEntitlement(row interface{ Scan(dest ...any) error }) (marketplaceEntitlementRecord, error) { func scanMarketplaceEntitlement(row interface{ Scan(dest ...any) error }) (marketplaceEntitlementRecord, error) {
var rec marketplaceEntitlementRecord var rec marketplaceEntitlementRecord
var payerWallet, orgRootID, principalID, principalRole, workspaceID sql.NullString var merchantID, payerWallet, orgRootID, principalID, principalRole, workspaceID sql.NullString
var issuedAt sql.NullString var issuedAt sql.NullString
err := row.Scan( err := row.Scan(
&rec.EntitlementID, &rec.EntitlementID,
&rec.QuoteID, &rec.QuoteID,
&merchantID,
&rec.OfferID, &rec.OfferID,
&rec.Wallet, &rec.Wallet,
&payerWallet, &payerWallet,
@ -860,6 +884,7 @@ func scanMarketplaceEntitlement(row interface{ Scan(dest ...any) error }) (marke
} }
return marketplaceEntitlementRecord{}, err return marketplaceEntitlementRecord{}, err
} }
rec.MerchantID = normalizeMerchantID(merchantID.String)
rec.PayerWallet = payerWallet.String rec.PayerWallet = payerWallet.String
rec.OrgRootID = orgRootID.String rec.OrgRootID = orgRootID.String
rec.PrincipalID = principalID.String rec.PrincipalID = principalID.String

View File

@ -11,6 +11,13 @@ paths:
/marketplace/offers: /marketplace/offers:
get: get:
summary: List active offers (launcher/app surface) summary: List active offers (launcher/app surface)
parameters:
- in: query
name: merchant_id
required: false
schema:
type: string
description: Merchant namespace. Defaults to `edut.firstparty`.
responses: responses:
'200': '200':
description: Offer list description: Offer list
@ -33,6 +40,12 @@ paths:
required: true required: true
schema: schema:
type: string type: string
- in: query
name: merchant_id
required: false
schema:
type: string
description: Merchant namespace. Defaults to `edut.firstparty`.
responses: responses:
'200': '200':
description: Offer record description: Offer record
@ -86,6 +99,12 @@ paths:
schema: schema:
type: string type: string
pattern: '^0x[a-fA-F0-9]{40}$' pattern: '^0x[a-fA-F0-9]{40}$'
- in: query
name: merchant_id
required: false
schema:
type: string
description: Optional merchant filter. If omitted, returns all merchants for wallet.
responses: responses:
'200': '200':
description: Entitlements list description: Entitlements list
@ -113,6 +132,8 @@ components:
type: object type: object
required: [offer_id, issuer_id, title, status, pricing, policies] required: [offer_id, issuer_id, title, status, pricing, policies]
properties: properties:
merchant_id:
type: string
offer_id: offer_id:
type: string type: string
issuer_id: issuer_id:
@ -168,6 +189,9 @@ components:
type: object type: object
required: [wallet, offer_id] required: [wallet, offer_id]
properties: properties:
merchant_id:
type: string
description: Merchant namespace. Defaults to `edut.firstparty`.
wallet: wallet:
type: string type: string
pattern: '^0x[a-fA-F0-9]{40}$' pattern: '^0x[a-fA-F0-9]{40}$'
@ -202,6 +226,8 @@ components:
properties: properties:
quote_id: quote_id:
type: string type: string
merchant_id:
type: string
wallet: wallet:
type: string type: string
payer_wallet: payer_wallet:
@ -272,6 +298,9 @@ components:
properties: properties:
quote_id: quote_id:
type: string type: string
merchant_id:
type: string
description: Merchant namespace. Defaults to `edut.firstparty`.
wallet: wallet:
type: string type: string
pattern: '^0x[a-fA-F0-9]{40}$' pattern: '^0x[a-fA-F0-9]{40}$'
@ -305,6 +334,8 @@ components:
enum: [entitlement_active] enum: [entitlement_active]
entitlement_id: entitlement_id:
type: string type: string
merchant_id:
type: string
offer_id: offer_id:
type: string type: string
org_root_id: org_root_id:
@ -335,6 +366,8 @@ components:
properties: properties:
entitlement_id: entitlement_id:
type: string type: string
merchant_id:
type: string
offer_id: offer_id:
type: string type: string
wallet_address: wallet_address: