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")
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 {
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to validate sponsor entitlement")
return
@ -1033,7 +1033,7 @@ func (a *app) handleGovernanceOfflineRenew(w http.ResponseWriter, r *http.Reques
if isSoloRoot {
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 {
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to resolve sovereign entitlement")
return

View File

@ -1090,6 +1090,9 @@ func TestMarketplaceCheckoutBundlesMembershipAndMintsEntitlement(t *testing.T) {
if len(quote.LineItems) < 2 {
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{
QuoteID: quote.QuoteID,
@ -1117,6 +1120,90 @@ func TestMarketplaceCheckoutBundlesMembershipAndMintsEntitlement(t *testing.T) {
if entitlements.Entitlements[0].OfferID != offerIDWorkspaceCore {
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) {

View File

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

View File

@ -3,6 +3,7 @@ package main
import "time"
type marketplaceOffer struct {
MerchantID string `json:"merchant_id,omitempty"`
OfferID string `json:"offer_id"`
IssuerID string `json:"issuer_id"`
Title string `json:"title"`
@ -42,6 +43,7 @@ type marketplaceOffersResponse struct {
}
type marketplaceCheckoutQuoteRequest struct {
MerchantID string `json:"merchant_id,omitempty"`
Wallet string `json:"wallet"`
PayerWallet string `json:"payer_wallet,omitempty"`
OfferID string `json:"offer_id"`
@ -64,6 +66,7 @@ type marketplaceQuoteLineItem struct {
type marketplaceCheckoutQuoteResponse struct {
QuoteID string `json:"quote_id"`
MerchantID string `json:"merchant_id,omitempty"`
Wallet string `json:"wallet"`
PayerWallet string `json:"payer_wallet,omitempty"`
OfferID string `json:"offer_id"`
@ -87,6 +90,7 @@ type marketplaceCheckoutQuoteResponse struct {
type marketplaceCheckoutConfirmRequest struct {
QuoteID string `json:"quote_id"`
MerchantID string `json:"merchant_id,omitempty"`
Wallet string `json:"wallet"`
PayerWallet string `json:"payer_wallet,omitempty"`
OfferID string `json:"offer_id"`
@ -101,6 +105,7 @@ type marketplaceCheckoutConfirmRequest struct {
type marketplaceCheckoutConfirmResponse struct {
Status string `json:"status"`
EntitlementID string `json:"entitlement_id"`
MerchantID string `json:"merchant_id,omitempty"`
OfferID string `json:"offer_id"`
OrgRootID string `json:"org_root_id,omitempty"`
PrincipalID string `json:"principal_id,omitempty"`
@ -115,6 +120,7 @@ type marketplaceCheckoutConfirmResponse struct {
type marketplaceEntitlement struct {
EntitlementID string `json:"entitlement_id"`
MerchantID string `json:"merchant_id,omitempty"`
OfferID string `json:"offer_id"`
WalletAddress string `json:"wallet_address"`
WorkspaceID string `json:"workspace_id,omitempty"`
@ -132,6 +138,7 @@ type marketplaceEntitlementsResponse struct {
type marketplaceQuoteRecord struct {
QuoteID string
MerchantID string
Wallet string
PayerWallet string
OfferID string
@ -160,6 +167,7 @@ type marketplaceQuoteRecord struct {
type marketplaceEntitlementRecord struct {
EntitlementID string
QuoteID string
MerchantID string
OfferID string
Wallet 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 TABLE IF NOT EXISTS marketplace_quotes (
quote_id TEXT PRIMARY KEY,
merchant_id TEXT NOT NULL DEFAULT 'edut.firstparty',
wallet TEXT NOT NULL,
payer_wallet TEXT,
offer_id TEXT NOT NULL,
@ -128,6 +129,7 @@ func (s *store) migrate(ctx context.Context) error {
`CREATE TABLE IF NOT EXISTS marketplace_entitlements (
entitlement_id TEXT PRIMARY KEY,
quote_id TEXT NOT NULL UNIQUE,
merchant_id TEXT NOT NULL DEFAULT 'edut.firstparty',
offer_id TEXT NOT NULL,
wallet TEXT NOT NULL,
payer_wallet TEXT,
@ -143,6 +145,7 @@ func (s *store) migrate(ctx context.Context) error {
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_merchant_offer_state ON marketplace_entitlements(wallet, merchant_id, offer_id, state);`,
`CREATE TABLE IF NOT EXISTS governance_principals (
wallet TEXT PRIMARY KEY,
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 {
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
}
@ -588,11 +597,12 @@ func (s *store) getDesignationCodeByMembershipTxHash(ctx context.Context, txHash
func (s *store) putMarketplaceQuote(ctx context.Context, quote marketplaceQuoteRecord) error {
_, err := s.db.ExecContext(ctx, `
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,
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
merchant_id=excluded.merchant_id,
wallet=excluded.wallet,
payer_wallet=excluded.payer_wallet,
offer_id=excluded.offer_id,
@ -617,6 +627,7 @@ func (s *store) putMarketplaceQuote(ctx context.Context, quote marketplaceQuoteR
confirmed_at=excluded.confirmed_at,
confirmed_tx_hash=excluded.confirmed_tx_hash
`, quote.QuoteID,
normalizeMerchantID(quote.MerchantID),
strings.ToLower(strings.TrimSpace(quote.Wallet)),
nullableString(strings.ToLower(strings.TrimSpace(quote.PayerWallet))),
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) {
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,
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
WHERE quote_id = ?
`, strings.TrimSpace(quoteID))
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 createdAt, expiresAt, confirmedAt, confirmedTxHash sql.NullString
var membershipIncluded int
err := row.Scan(
&rec.QuoteID,
&merchantID,
&rec.Wallet,
&payerWallet,
&rec.OfferID,
@ -689,6 +701,7 @@ func (s *store) getMarketplaceQuote(ctx context.Context, quoteID string) (market
}
return marketplaceQuoteRecord{}, err
}
rec.MerchantID = normalizeMerchantID(merchantID.String)
rec.PayerWallet = payerWallet.String
rec.OrgRootID = orgRootID.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 {
_, err := s.db.ExecContext(ctx, `
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
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(entitlement_id) DO UPDATE SET
quote_id=excluded.quote_id,
merchant_id=excluded.merchant_id,
offer_id=excluded.offer_id,
wallet=excluded.wallet,
payer_wallet=excluded.payer_wallet,
@ -746,6 +760,7 @@ func (s *store) putMarketplaceEntitlement(ctx context.Context, ent marketplaceEn
tx_hash=excluded.tx_hash
`, strings.TrimSpace(ent.EntitlementID),
strings.TrimSpace(ent.QuoteID),
normalizeMerchantID(ent.MerchantID),
strings.TrimSpace(ent.OfferID),
strings.ToLower(strings.TrimSpace(ent.Wallet)),
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) {
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
FROM marketplace_entitlements
WHERE quote_id = ?
@ -773,14 +788,20 @@ func (s *store) getMarketplaceEntitlementByQuote(ctx context.Context, quoteID st
return scanMarketplaceEntitlement(row)
}
func (s *store) listMarketplaceEntitlementsByWallet(ctx context.Context, wallet string) ([]marketplaceEntitlementRecord, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT entitlement_id, quote_id, offer_id, wallet, payer_wallet, org_root_id, principal_id, principal_role,
func (s *store) listMarketplaceEntitlementsByWallet(ctx context.Context, wallet, merchantID string) ([]marketplaceEntitlementRecord, error) {
query := `
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
FROM marketplace_entitlements
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 {
return nil, err
}
@ -800,8 +821,9 @@ func (s *store) listMarketplaceEntitlementsByWallet(ctx context.Context, wallet
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))
merchantID = normalizeMerchantID(merchantID)
offerID = strings.TrimSpace(offerID)
orgRootID = strings.TrimSpace(orgRootID)
if wallet == "" || offerID == "" {
@ -812,10 +834,11 @@ func (s *store) hasActiveEntitlement(ctx context.Context, wallet, offerID, orgRo
SELECT 1
FROM marketplace_entitlements
WHERE wallet = ?
AND merchant_id = ?
AND offer_id = ?
AND state = 'active'
`
args := []any{wallet, offerID}
args := []any{wallet, merchantID, offerID}
if orgRootID != "" {
query += " AND org_root_id = ?"
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) {
var rec marketplaceEntitlementRecord
var payerWallet, orgRootID, principalID, principalRole, workspaceID sql.NullString
var merchantID, payerWallet, orgRootID, principalID, principalRole, workspaceID sql.NullString
var issuedAt sql.NullString
err := row.Scan(
&rec.EntitlementID,
&rec.QuoteID,
&merchantID,
&rec.OfferID,
&rec.Wallet,
&payerWallet,
@ -860,6 +884,7 @@ func scanMarketplaceEntitlement(row interface{ Scan(dest ...any) error }) (marke
}
return marketplaceEntitlementRecord{}, err
}
rec.MerchantID = normalizeMerchantID(merchantID.String)
rec.PayerWallet = payerWallet.String
rec.OrgRootID = orgRootID.String
rec.PrincipalID = principalID.String

View File

@ -11,6 +11,13 @@ paths:
/marketplace/offers:
get:
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:
'200':
description: Offer list
@ -33,6 +40,12 @@ paths:
required: true
schema:
type: string
- in: query
name: merchant_id
required: false
schema:
type: string
description: Merchant namespace. Defaults to `edut.firstparty`.
responses:
'200':
description: Offer record
@ -86,6 +99,12 @@ paths:
schema:
type: string
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:
'200':
description: Entitlements list
@ -113,6 +132,8 @@ components:
type: object
required: [offer_id, issuer_id, title, status, pricing, policies]
properties:
merchant_id:
type: string
offer_id:
type: string
issuer_id:
@ -168,6 +189,9 @@ components:
type: object
required: [wallet, offer_id]
properties:
merchant_id:
type: string
description: Merchant namespace. Defaults to `edut.firstparty`.
wallet:
type: string
pattern: '^0x[a-fA-F0-9]{40}$'
@ -202,6 +226,8 @@ components:
properties:
quote_id:
type: string
merchant_id:
type: string
wallet:
type: string
payer_wallet:
@ -272,6 +298,9 @@ components:
properties:
quote_id:
type: string
merchant_id:
type: string
description: Merchant namespace. Defaults to `edut.firstparty`.
wallet:
type: string
pattern: '^0x[a-fA-F0-9]{40}$'
@ -305,6 +334,8 @@ components:
enum: [entitlement_active]
entitlement_id:
type: string
merchant_id:
type: string
offer_id:
type: string
org_root_id:
@ -335,6 +366,8 @@ components:
properties:
entitlement_id:
type: string
merchant_id:
type: string
offer_id:
type: string
wallet_address: