diff --git a/backend/secretapi/app.go b/backend/secretapi/app.go index bf0225a..af5ba59 100644 --- a/backend/secretapi/app.go +++ b/backend/secretapi/app.go @@ -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 diff --git a/backend/secretapi/app_test.go b/backend/secretapi/app_test.go index ce343c6..d95a32e 100644 --- a/backend/secretapi/app_test.go +++ b/backend/secretapi/app_test.go @@ -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) { diff --git a/backend/secretapi/marketplace.go b/backend/secretapi/marketplace.go index 5204afd..7c4a832 100644 --- a/backend/secretapi/marketplace.go +++ b/backend/secretapi/marketplace.go @@ -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, diff --git a/backend/secretapi/marketplace_models.go b/backend/secretapi/marketplace_models.go index a825bea..27b5bce 100644 --- a/backend/secretapi/marketplace_models.go +++ b/backend/secretapi/marketplace_models.go @@ -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 diff --git a/backend/secretapi/store.go b/backend/secretapi/store.go index 0707ad3..4bb5a01 100644 --- a/backend/secretapi/store.go +++ b/backend/secretapi/store.go @@ -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 diff --git a/docs/api/marketplace.openapi.yaml b/docs/api/marketplace.openapi.yaml index 5580020..07386cc 100644 --- a/docs/api/marketplace.openapi.yaml +++ b/docs/api/marketplace.openapi.yaml @@ -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: