Lock web catalog to fixed SKUs and enforce solo/workspace entitlement gates
This commit is contained in:
parent
889db8ea3b
commit
5a857f5554
@ -12,9 +12,9 @@ SECRET_API_QUOTE_TTL_SECONDS=900
|
||||
SECRET_API_DOMAIN_NAME=EDUT Designation
|
||||
SECRET_API_VERIFYING_CONTRACT=0x0000000000000000000000000000000000000000
|
||||
SECRET_API_MEMBERSHIP_CONTRACT=0x0000000000000000000000000000000000000000
|
||||
SECRET_API_MINT_CURRENCY=ETH
|
||||
SECRET_API_MINT_AMOUNT_ATOMIC=5000000000000000
|
||||
SECRET_API_MINT_DECIMALS=18
|
||||
SECRET_API_MINT_CURRENCY=USDC
|
||||
SECRET_API_MINT_AMOUNT_ATOMIC=100000000
|
||||
SECRET_API_MINT_DECIMALS=6
|
||||
|
||||
SECRET_API_INSTALL_TOKEN_TTL_SECONDS=900
|
||||
SECRET_API_LEASE_TTL_SECONDS=3600
|
||||
|
||||
@ -86,9 +86,9 @@ Company-first sponsor path is also supported:
|
||||
- `SECRET_API_DOMAIN_NAME`
|
||||
- `SECRET_API_VERIFYING_CONTRACT`
|
||||
- `SECRET_API_MEMBERSHIP_CONTRACT`
|
||||
- `SECRET_API_MINT_CURRENCY` (default `ETH`)
|
||||
- `SECRET_API_MINT_AMOUNT_ATOMIC` (default `5000000000000000`)
|
||||
- `SECRET_API_MINT_DECIMALS` (default `18`)
|
||||
- `SECRET_API_MINT_CURRENCY` (default `USDC`)
|
||||
- `SECRET_API_MINT_AMOUNT_ATOMIC` (default `100000000`)
|
||||
- `SECRET_API_MINT_DECIMALS` (default `6`)
|
||||
|
||||
### Governance install
|
||||
|
||||
|
||||
@ -37,6 +37,11 @@ func (a *app) routes() http.Handler {
|
||||
mux.HandleFunc("/secret/membership/quote", a.withCORS(a.handleMembershipQuote))
|
||||
mux.HandleFunc("/secret/membership/confirm", a.withCORS(a.handleMembershipConfirm))
|
||||
mux.HandleFunc("/secret/membership/status", a.withCORS(a.handleMembershipStatus))
|
||||
mux.HandleFunc("/marketplace/offers", a.withCORS(a.handleMarketplaceOffers))
|
||||
mux.HandleFunc("/marketplace/offers/", a.withCORS(a.handleMarketplaceOfferByID))
|
||||
mux.HandleFunc("/marketplace/checkout/quote", a.withCORS(a.handleMarketplaceCheckoutQuote))
|
||||
mux.HandleFunc("/marketplace/checkout/confirm", a.withCORS(a.handleMarketplaceCheckoutConfirm))
|
||||
mux.HandleFunc("/marketplace/entitlements", a.withCORS(a.handleMarketplaceEntitlements))
|
||||
mux.HandleFunc("/governance/install/token", a.withCORS(a.handleGovernanceInstallToken))
|
||||
mux.HandleFunc("/governance/install/confirm", a.withCORS(a.handleGovernanceInstallConfirm))
|
||||
mux.HandleFunc("/governance/install/status", a.withCORS(a.handleGovernanceInstallStatus))
|
||||
@ -286,6 +291,15 @@ 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)
|
||||
if entErr != nil {
|
||||
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to validate sponsor entitlement")
|
||||
return
|
||||
}
|
||||
if !hasWorkspaceCore {
|
||||
writeErrorCode(w, http.StatusForbidden, "sponsor_not_authorized", "workspace core entitlement required for sponsored member onboarding")
|
||||
return
|
||||
}
|
||||
sponsorshipMode = "sponsored_company"
|
||||
} else {
|
||||
writeError(w, http.StatusForbidden, "distinct payer requires ownership proof")
|
||||
@ -842,6 +856,20 @@ func (a *app) handleGovernanceOfflineRenew(w http.ResponseWriter, r *http.Reques
|
||||
writeErrorCode(w, http.StatusForbidden, "entitlement_inactive", "entitlement inactive")
|
||||
return
|
||||
}
|
||||
isSoloRoot := strings.EqualFold(strings.TrimSpace(principal.OrgRootID), defaultSoloOrgRootID(wallet))
|
||||
requiredOfferID := offerIDWorkspaceSovereign
|
||||
if isSoloRoot {
|
||||
requiredOfferID = offerIDSoloCore
|
||||
}
|
||||
hasRequired, entErr := a.store.hasActiveEntitlement(r.Context(), wallet, requiredOfferID, principal.OrgRootID)
|
||||
if entErr != nil {
|
||||
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to resolve sovereign entitlement")
|
||||
return
|
||||
}
|
||||
if !hasRequired {
|
||||
writeErrorCode(w, http.StatusForbidden, "sovereign_entitlement_required", "sovereign entitlement required for offline renew")
|
||||
return
|
||||
}
|
||||
renewedUntil := time.Now().UTC().Add(a.cfg.OfflineRenewTTL)
|
||||
principal.AccessClass = "sovereign"
|
||||
principal.AvailabilityState = "active"
|
||||
@ -1267,8 +1295,8 @@ func (a *app) resolveOrCreatePrincipal(ctx context.Context, wallet, orgRootID, p
|
||||
OrgRootID: orgRootID,
|
||||
PrincipalID: principalID,
|
||||
PrincipalRole: principalRole,
|
||||
EntitlementID: "gov_" + wallet[2:10],
|
||||
EntitlementStatus: "active",
|
||||
EntitlementID: "",
|
||||
EntitlementStatus: "inactive",
|
||||
AccessClass: "connected",
|
||||
AvailabilityState: "active",
|
||||
LeaseExpiresAt: &leaseExpires,
|
||||
|
||||
@ -112,6 +112,23 @@ func TestMembershipCompanySponsorWithoutOwnerProof(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Fatalf("seed governance principal: %v", err)
|
||||
}
|
||||
if err := a.store.putMarketplaceEntitlement(context.Background(), marketplaceEntitlementRecord{
|
||||
EntitlementID: "ent:sponsor:workspace-core",
|
||||
QuoteID: "seed-q-sponsor-workspace-core",
|
||||
OfferID: offerIDWorkspaceCore,
|
||||
Wallet: companyPayerAddr,
|
||||
OrgRootID: "org_company_b",
|
||||
PrincipalID: "principal_company_owner",
|
||||
PrincipalRole: "org_root_owner",
|
||||
State: "active",
|
||||
AccessClass: "connected",
|
||||
AvailabilityState: "active",
|
||||
PolicyHash: "sha256:testpolicy",
|
||||
IssuedAt: now,
|
||||
TxHash: "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed sponsor workspace core entitlement: %v", err)
|
||||
}
|
||||
|
||||
intentRes := postJSONExpect[tWalletIntentResponse](t, a, "/secret/wallet/intent", walletIntentRequest{
|
||||
Address: ownerAddr,
|
||||
@ -227,6 +244,37 @@ func TestGovernanceInstallOwnerOnlyAndConfirm(t *testing.T) {
|
||||
if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil {
|
||||
t.Fatalf("seed membership: %v", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if err := a.store.putMarketplaceEntitlement(context.Background(), marketplaceEntitlementRecord{
|
||||
EntitlementID: "ent:install:workspace-core",
|
||||
QuoteID: "seed-q-install-workspace-core",
|
||||
OfferID: offerIDWorkspaceCore,
|
||||
Wallet: ownerAddr,
|
||||
OrgRootID: "org_root_a",
|
||||
PrincipalID: "principal_owner",
|
||||
PrincipalRole: "org_root_owner",
|
||||
State: "active",
|
||||
AccessClass: "connected",
|
||||
AvailabilityState: "active",
|
||||
PolicyHash: cfg.GovernancePolicyHash,
|
||||
IssuedAt: now,
|
||||
TxHash: "0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed workspace core entitlement: %v", err)
|
||||
}
|
||||
if err := a.store.putGovernancePrincipal(context.Background(), governancePrincipalRecord{
|
||||
Wallet: ownerAddr,
|
||||
OrgRootID: "org_root_a",
|
||||
PrincipalID: "principal_owner",
|
||||
PrincipalRole: "org_root_owner",
|
||||
EntitlementID: "ent:install:workspace-core",
|
||||
EntitlementStatus: "active",
|
||||
AccessClass: "connected",
|
||||
AvailabilityState: "active",
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed owner principal: %v", err)
|
||||
}
|
||||
|
||||
tokenRes := postJSONExpect[governanceInstallTokenResponse](t, a, "/governance/install/token", governanceInstallTokenRequest{
|
||||
Wallet: ownerAddr,
|
||||
@ -289,6 +337,54 @@ func TestGovernanceLeaseAndOfflineRenew(t *testing.T) {
|
||||
if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil {
|
||||
t.Fatalf("seed membership: %v", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if err := a.store.putMarketplaceEntitlement(context.Background(), marketplaceEntitlementRecord{
|
||||
EntitlementID: "ent:lease:workspace-core",
|
||||
QuoteID: "seed-q-lease-workspace-core",
|
||||
OfferID: offerIDWorkspaceCore,
|
||||
Wallet: ownerAddr,
|
||||
OrgRootID: "org_root_b",
|
||||
PrincipalID: "principal_owner_b",
|
||||
PrincipalRole: "org_root_owner",
|
||||
State: "active",
|
||||
AccessClass: "connected",
|
||||
AvailabilityState: "active",
|
||||
PolicyHash: "sha256:testpolicy",
|
||||
IssuedAt: now,
|
||||
TxHash: "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed workspace core entitlement: %v", err)
|
||||
}
|
||||
if err := a.store.putMarketplaceEntitlement(context.Background(), marketplaceEntitlementRecord{
|
||||
EntitlementID: "ent:lease:workspace-sovereign",
|
||||
QuoteID: "seed-q-lease-workspace-sovereign",
|
||||
OfferID: offerIDWorkspaceSovereign,
|
||||
Wallet: ownerAddr,
|
||||
OrgRootID: "org_root_b",
|
||||
PrincipalID: "principal_owner_b",
|
||||
PrincipalRole: "org_root_owner",
|
||||
State: "active",
|
||||
AccessClass: "sovereign",
|
||||
AvailabilityState: "active",
|
||||
PolicyHash: "sha256:testpolicy",
|
||||
IssuedAt: now,
|
||||
TxHash: "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed workspace sovereign entitlement: %v", err)
|
||||
}
|
||||
if err := a.store.putGovernancePrincipal(context.Background(), governancePrincipalRecord{
|
||||
Wallet: ownerAddr,
|
||||
OrgRootID: "org_root_b",
|
||||
PrincipalID: "principal_owner_b",
|
||||
PrincipalRole: "org_root_owner",
|
||||
EntitlementID: "ent:lease:workspace-core",
|
||||
EntitlementStatus: "active",
|
||||
AccessClass: "connected",
|
||||
AvailabilityState: "active",
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed owner principal: %v", err)
|
||||
}
|
||||
|
||||
_ = postJSONExpect[governanceInstallTokenResponse](t, a, "/governance/install/token", governanceInstallTokenRequest{
|
||||
Wallet: ownerAddr,
|
||||
@ -487,6 +583,176 @@ func TestMemberChannelSupportTicketOwnerOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketplaceCheckoutBundlesMembershipAndMintsEntitlement(t *testing.T) {
|
||||
a, cfg, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
ownerKey := mustKey(t)
|
||||
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex())
|
||||
|
||||
quote := postJSONExpect[marketplaceCheckoutQuoteResponse](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{
|
||||
Wallet: ownerAddr,
|
||||
OfferID: offerIDWorkspaceCore,
|
||||
OrgRootID: "org.marketplace.a",
|
||||
PrincipalID: "human.owner",
|
||||
PrincipalRole: "org_root_owner",
|
||||
}, http.StatusOK)
|
||||
if !quote.MembershipActivationIncluded {
|
||||
t.Fatalf("expected bundled membership on first checkout: %+v", quote)
|
||||
}
|
||||
if len(quote.LineItems) < 2 {
|
||||
t.Fatalf("expected license + membership line items: %+v", quote.LineItems)
|
||||
}
|
||||
|
||||
confirm := postJSONExpect[marketplaceCheckoutConfirmResponse](t, a, "/marketplace/checkout/confirm", marketplaceCheckoutConfirmRequest{
|
||||
QuoteID: quote.QuoteID,
|
||||
Wallet: ownerAddr,
|
||||
OfferID: offerIDWorkspaceCore,
|
||||
OrgRootID: "org.marketplace.a",
|
||||
PrincipalID: "human.owner",
|
||||
PrincipalRole: "org_root_owner",
|
||||
TxHash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
ChainID: cfg.ChainID,
|
||||
}, http.StatusOK)
|
||||
if confirm.Status != "entitlement_active" || confirm.EntitlementID == "" {
|
||||
t.Fatalf("unexpected confirm response: %+v", confirm)
|
||||
}
|
||||
|
||||
status := getJSONExpect[membershipStatusResponse](t, a, "/secret/membership/status?wallet="+ownerAddr, http.StatusOK)
|
||||
if status.Status != "active" {
|
||||
t.Fatalf("expected active membership after bundled checkout, got %+v", status)
|
||||
}
|
||||
|
||||
entitlements := getJSONExpect[marketplaceEntitlementsResponse](t, a, "/marketplace/entitlements?wallet="+ownerAddr, http.StatusOK)
|
||||
if len(entitlements.Entitlements) == 0 {
|
||||
t.Fatalf("expected active entitlement after confirm")
|
||||
}
|
||||
if entitlements.Entitlements[0].OfferID != offerIDWorkspaceCore {
|
||||
t.Fatalf("unexpected entitlement offer: %+v", entitlements.Entitlements[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketplaceDistinctPayerRequiresOwnershipProof(t *testing.T) {
|
||||
a, _, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
ownerKey := mustKey(t)
|
||||
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex())
|
||||
payerKey := mustKey(t)
|
||||
payerAddr := strings.ToLower(crypto.PubkeyToAddress(payerKey.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{
|
||||
Wallet: ownerAddr,
|
||||
PayerWallet: payerAddr,
|
||||
OfferID: offerIDWorkspaceCore,
|
||||
}, http.StatusForbidden)
|
||||
if code := errResp["code"]; code != "ownership_proof_required" {
|
||||
t.Fatalf("expected ownership_proof_required, got %+v", errResp)
|
||||
}
|
||||
|
||||
quote := postJSONExpect[marketplaceCheckoutQuoteResponse](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{
|
||||
Wallet: ownerAddr,
|
||||
PayerWallet: payerAddr,
|
||||
OfferID: offerIDWorkspaceCore,
|
||||
OwnershipProof: "0x" + strings.Repeat("a", 130),
|
||||
OrgRootID: "org.marketplace.ownerproof",
|
||||
PrincipalID: "human.owner",
|
||||
PrincipalRole: "org_root_owner",
|
||||
}, http.StatusOK)
|
||||
if got := strings.ToLower(strings.TrimSpace(quote.PayerWallet)); got != payerAddr {
|
||||
t.Fatalf("payer wallet mismatch: got=%s want=%s", got, payerAddr)
|
||||
}
|
||||
if quote.Decimals != 6 || quote.OfferID != offerIDWorkspaceCore || quote.AccessClass != "connected" {
|
||||
t.Fatalf("unexpected quote response: %+v", quote)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketplaceWorkspaceAddOnRequiresCoreEntitlement(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{
|
||||
Wallet: ownerAddr,
|
||||
OfferID: offerIDWorkspaceAI,
|
||||
OrgRootID: "org.marketplace.addon",
|
||||
PrincipalID: "human.owner",
|
||||
PrincipalRole: "org_root_owner",
|
||||
}, http.StatusForbidden)
|
||||
if code := errResp["code"]; code != "prerequisite_required" {
|
||||
t.Fatalf("expected prerequisite_required, got %+v", errResp)
|
||||
}
|
||||
|
||||
if err := a.store.putMarketplaceEntitlement(context.Background(), marketplaceEntitlementRecord{
|
||||
EntitlementID: "ent:seed:workspace-core",
|
||||
QuoteID: "seed-q-workspace-core",
|
||||
OfferID: offerIDWorkspaceCore,
|
||||
Wallet: ownerAddr,
|
||||
OrgRootID: "org.marketplace.addon",
|
||||
PrincipalID: "human.owner",
|
||||
PrincipalRole: "org_root_owner",
|
||||
State: "active",
|
||||
AccessClass: "connected",
|
||||
AvailabilityState: "active",
|
||||
PolicyHash: "sha256:testpolicy",
|
||||
IssuedAt: time.Now().UTC(),
|
||||
TxHash: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed workspace core entitlement: %v", err)
|
||||
}
|
||||
|
||||
quote := postJSONExpect[marketplaceCheckoutQuoteResponse](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{
|
||||
Wallet: ownerAddr,
|
||||
OfferID: offerIDWorkspaceAI,
|
||||
OrgRootID: "org.marketplace.addon",
|
||||
PrincipalID: "human.owner",
|
||||
PrincipalRole: "org_root_owner",
|
||||
}, http.StatusOK)
|
||||
if quote.OfferID != offerIDWorkspaceAI || quote.AmountAtomic != marketplaceStandardOfferAtomic {
|
||||
t.Fatalf("unexpected add-on quote response: %+v", quote)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketplaceSoloCoreScopeValidation(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)
|
||||
}
|
||||
|
||||
quote := postJSONExpect[marketplaceCheckoutQuoteResponse](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{
|
||||
Wallet: ownerAddr,
|
||||
OfferID: offerIDSoloCore,
|
||||
PrincipalRole: "org_root_owner",
|
||||
}, http.StatusOK)
|
||||
expectedRoot := defaultSoloOrgRootID(ownerAddr)
|
||||
if !strings.EqualFold(strings.TrimSpace(quote.OrgRootID), expectedRoot) {
|
||||
t.Fatalf("expected auto solo org root %s, got %+v", expectedRoot, quote)
|
||||
}
|
||||
|
||||
errResp := postJSONExpect[map[string]string](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{
|
||||
Wallet: ownerAddr,
|
||||
OfferID: offerIDSoloCore,
|
||||
OrgRootID: "org.invalid.workspace",
|
||||
PrincipalRole: "org_root_owner",
|
||||
}, http.StatusBadRequest)
|
||||
if code := errResp["code"]; code != "invalid_scope" {
|
||||
t.Fatalf("expected invalid_scope, got %+v", errResp)
|
||||
}
|
||||
}
|
||||
|
||||
type tWalletIntentResponse struct {
|
||||
IntentID string `json:"intent_id"`
|
||||
DesignationCode string `json:"designation_code"`
|
||||
|
||||
@ -51,9 +51,9 @@ func loadConfig() Config {
|
||||
DomainName: env("SECRET_API_DOMAIN_NAME", "EDUT Designation"),
|
||||
VerifyingContract: strings.ToLower(env("SECRET_API_VERIFYING_CONTRACT", "0x0000000000000000000000000000000000000000")),
|
||||
MembershipContract: strings.ToLower(env("SECRET_API_MEMBERSHIP_CONTRACT", "0x0000000000000000000000000000000000000000")),
|
||||
MintCurrency: strings.ToUpper(env("SECRET_API_MINT_CURRENCY", "ETH")),
|
||||
MintAmountAtomic: env("SECRET_API_MINT_AMOUNT_ATOMIC", "5000000000000000"),
|
||||
MintDecimals: envInt("SECRET_API_MINT_DECIMALS", 18),
|
||||
MintCurrency: strings.ToUpper(env("SECRET_API_MINT_CURRENCY", "USDC")),
|
||||
MintAmountAtomic: env("SECRET_API_MINT_AMOUNT_ATOMIC", "100000000"),
|
||||
MintDecimals: envInt("SECRET_API_MINT_DECIMALS", 6),
|
||||
ChainRPCURL: env("SECRET_API_CHAIN_RPC_URL", ""),
|
||||
RequireOnchainTxVerify: envBool("SECRET_API_REQUIRE_ONCHAIN_TX_VERIFICATION", false),
|
||||
GovernanceRuntimeVersion: env("SECRET_API_GOV_RUNTIME_VERSION", "0.1.0"),
|
||||
|
||||
714
backend/secretapi/marketplace.go
Normal file
714
backend/secretapi/marketplace.go
Normal file
@ -0,0 +1,714 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
marketplaceMembershipActivationAtomic = "100000000" // 100.00 USDC (6 decimals)
|
||||
marketplaceStandardOfferAtomic = "1000000000" // 1000.00 USDC (6 decimals)
|
||||
|
||||
offerIDSoloCore = "edut.solo.core"
|
||||
offerIDWorkspaceCore = "edut.workspace.core"
|
||||
offerIDWorkspaceAI = "edut.workspace.ai"
|
||||
offerIDWorkspaceLane24 = "edut.workspace.lane24"
|
||||
offerIDWorkspaceSovereign = "edut.workspace.sovereign"
|
||||
)
|
||||
|
||||
func (a *app) marketplaceOffers() []marketplaceOffer {
|
||||
offers := []marketplaceOffer{
|
||||
{
|
||||
OfferID: offerIDSoloCore,
|
||||
IssuerID: "edut.firstparty",
|
||||
Title: "EDUT Solo Core",
|
||||
Summary: "Single-principal governance runtime for personal operations.",
|
||||
Status: "active",
|
||||
Pricing: marketplaceOfferPrice{
|
||||
Currency: "USDC",
|
||||
AmountAtomic: marketplaceStandardOfferAtomic,
|
||||
Decimals: 6,
|
||||
ChainID: a.cfg.ChainID,
|
||||
},
|
||||
Policies: marketplaceOfferPolicy{
|
||||
MemberOnly: true,
|
||||
WorkspaceBound: true,
|
||||
Transferable: false,
|
||||
InternalUseOnly: true,
|
||||
MultiTenant: false,
|
||||
EntitlementClass: "solo_core",
|
||||
},
|
||||
SortOrder: 10,
|
||||
},
|
||||
{
|
||||
OfferID: offerIDWorkspaceCore,
|
||||
IssuerID: "edut.firstparty",
|
||||
Title: "EDUT Workspace Core",
|
||||
Summary: "Org-bound deterministic governance runtime for team operations.",
|
||||
Status: "active",
|
||||
Pricing: marketplaceOfferPrice{
|
||||
Currency: "USDC",
|
||||
AmountAtomic: marketplaceStandardOfferAtomic,
|
||||
Decimals: 6,
|
||||
ChainID: a.cfg.ChainID,
|
||||
},
|
||||
Policies: marketplaceOfferPolicy{
|
||||
MemberOnly: true,
|
||||
WorkspaceBound: true,
|
||||
Transferable: false,
|
||||
InternalUseOnly: true,
|
||||
MultiTenant: false,
|
||||
EntitlementClass: "workspace_core",
|
||||
},
|
||||
SortOrder: 20,
|
||||
},
|
||||
{
|
||||
OfferID: offerIDWorkspaceAI,
|
||||
IssuerID: "edut.firstparty",
|
||||
Title: "EDUT Workspace AI Layer",
|
||||
Summary: "AI reasoning layer for governed workspace operations.",
|
||||
Status: "active",
|
||||
Pricing: marketplaceOfferPrice{
|
||||
Currency: "USDC",
|
||||
AmountAtomic: marketplaceStandardOfferAtomic,
|
||||
Decimals: 6,
|
||||
ChainID: a.cfg.ChainID,
|
||||
},
|
||||
Policies: marketplaceOfferPolicy{
|
||||
MemberOnly: true,
|
||||
WorkspaceBound: true,
|
||||
Transferable: false,
|
||||
InternalUseOnly: true,
|
||||
MultiTenant: false,
|
||||
EntitlementClass: "workspace_ai",
|
||||
RequiresOffers: []string{offerIDWorkspaceCore},
|
||||
},
|
||||
SortOrder: 30,
|
||||
},
|
||||
{
|
||||
OfferID: offerIDWorkspaceLane24,
|
||||
IssuerID: "edut.firstparty",
|
||||
Title: "EDUT Workspace 24h Lane",
|
||||
Summary: "Autonomous execution lane capacity for workspace queue throughput.",
|
||||
Status: "active",
|
||||
Pricing: marketplaceOfferPrice{
|
||||
Currency: "USDC",
|
||||
AmountAtomic: marketplaceStandardOfferAtomic,
|
||||
Decimals: 6,
|
||||
ChainID: a.cfg.ChainID,
|
||||
},
|
||||
Policies: marketplaceOfferPolicy{
|
||||
MemberOnly: true,
|
||||
WorkspaceBound: true,
|
||||
Transferable: false,
|
||||
InternalUseOnly: true,
|
||||
MultiTenant: false,
|
||||
EntitlementClass: "workspace_lane24",
|
||||
RequiresOffers: []string{offerIDWorkspaceCore},
|
||||
},
|
||||
SortOrder: 40,
|
||||
},
|
||||
{
|
||||
OfferID: offerIDWorkspaceSovereign,
|
||||
IssuerID: "edut.firstparty",
|
||||
Title: "EDUT Workspace Sovereign Continuity",
|
||||
Summary: "Workspace continuity profile for stronger local/offline operation.",
|
||||
Status: "active",
|
||||
Pricing: marketplaceOfferPrice{
|
||||
Currency: "USDC",
|
||||
AmountAtomic: marketplaceStandardOfferAtomic,
|
||||
Decimals: 6,
|
||||
ChainID: a.cfg.ChainID,
|
||||
},
|
||||
Policies: marketplaceOfferPolicy{
|
||||
MemberOnly: true,
|
||||
WorkspaceBound: true,
|
||||
Transferable: false,
|
||||
InternalUseOnly: true,
|
||||
MultiTenant: false,
|
||||
EntitlementClass: "workspace_sovereign",
|
||||
RequiresOffers: []string{offerIDWorkspaceCore},
|
||||
},
|
||||
SortOrder: 50,
|
||||
},
|
||||
}
|
||||
sort.SliceStable(offers, func(i, j int) bool {
|
||||
return offers[i].SortOrder < offers[j].SortOrder
|
||||
})
|
||||
return offers
|
||||
}
|
||||
|
||||
func (a *app) marketplaceOfferByID(offerID string) (marketplaceOffer, error) {
|
||||
target := strings.TrimSpace(strings.ToLower(offerID))
|
||||
for _, offer := range a.marketplaceOffers() {
|
||||
if strings.EqualFold(strings.TrimSpace(offer.OfferID), target) {
|
||||
return offer, nil
|
||||
}
|
||||
}
|
||||
return marketplaceOffer{}, errNotFound
|
||||
}
|
||||
|
||||
func isSoloOffer(offerID string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(offerID), offerIDSoloCore)
|
||||
}
|
||||
|
||||
func isWorkspaceOffer(offerID string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(offerID)) {
|
||||
case offerIDWorkspaceCore, offerIDWorkspaceAI, offerIDWorkspaceLane24, offerIDWorkspaceSovereign:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func shouldBindPrincipalOnOffer(offerID string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(offerID)) {
|
||||
case offerIDSoloCore, offerIDWorkspaceCore:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func defaultSoloOrgRootID(wallet string) string {
|
||||
return "solo:" + strings.ToLower(strings.TrimSpace(wallet))
|
||||
}
|
||||
|
||||
func (a *app) handleMarketplaceOffers(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, marketplaceOffersResponse{Offers: a.marketplaceOffers()})
|
||||
}
|
||||
|
||||
func (a *app) handleMarketplaceOfferByID(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
const prefix = "/marketplace/offers/"
|
||||
offerID := strings.TrimSpace(strings.TrimPrefix(r.URL.Path, prefix))
|
||||
if offerID == "" || strings.Contains(offerID, "/") {
|
||||
writeErrorCode(w, http.StatusNotFound, "offer_not_found", "offer not found")
|
||||
return
|
||||
}
|
||||
offer, err := a.marketplaceOfferByID(offerID)
|
||||
if err != nil {
|
||||
writeErrorCode(w, http.StatusNotFound, "offer_not_found", "offer not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, offer)
|
||||
}
|
||||
|
||||
func (a *app) handleMarketplaceCheckoutQuote(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
var req marketplaceCheckoutQuoteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErrorCode(w, http.StatusBadRequest, "invalid_request", "invalid request body")
|
||||
return
|
||||
}
|
||||
wallet, err := normalizeAddress(req.Wallet)
|
||||
if err != nil {
|
||||
writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
|
||||
return
|
||||
}
|
||||
offer, err := a.marketplaceOfferByID(req.OfferID)
|
||||
if err != nil {
|
||||
writeErrorCode(w, http.StatusNotFound, "offer_not_found", "offer not found")
|
||||
return
|
||||
}
|
||||
|
||||
payerWallet := wallet
|
||||
if strings.TrimSpace(req.PayerWallet) != "" {
|
||||
payerWallet, err = normalizeAddress(req.PayerWallet)
|
||||
if err != nil {
|
||||
writeErrorCode(w, http.StatusBadRequest, "invalid_payer_wallet", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if !strings.EqualFold(wallet, payerWallet) {
|
||||
if !isLikelyHexSignature(req.OwnershipProof) {
|
||||
writeErrorCode(w, http.StatusForbidden, "ownership_proof_required", "distinct payer requires ownership proof")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
orgRootID := strings.TrimSpace(req.OrgRootID)
|
||||
principalID := strings.TrimSpace(req.PrincipalID)
|
||||
if principalID == "" {
|
||||
principalID = wallet
|
||||
}
|
||||
principalRole := strings.ToLower(strings.TrimSpace(req.PrincipalRole))
|
||||
if principalRole == "" {
|
||||
principalRole = "org_root_owner"
|
||||
}
|
||||
workspaceID := strings.TrimSpace(req.WorkspaceID)
|
||||
|
||||
if isSoloOffer(offer.OfferID) {
|
||||
expectedSoloRoot := defaultSoloOrgRootID(wallet)
|
||||
if orgRootID == "" {
|
||||
orgRootID = expectedSoloRoot
|
||||
}
|
||||
if !strings.EqualFold(orgRootID, expectedSoloRoot) {
|
||||
writeErrorCode(w, http.StatusBadRequest, "invalid_scope", "solo core must use wallet-scoped solo org root")
|
||||
return
|
||||
}
|
||||
if principalRole != "org_root_owner" {
|
||||
writeErrorCode(w, http.StatusForbidden, "role_insufficient", "solo core checkout requires org_root_owner role")
|
||||
return
|
||||
}
|
||||
if workspaceID != "" {
|
||||
writeErrorCode(w, http.StatusBadRequest, "invalid_scope", "solo core does not bind workspace_id")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if isWorkspaceOffer(offer.OfferID) {
|
||||
if orgRootID == "" {
|
||||
writeErrorCode(w, http.StatusBadRequest, "invalid_scope", "workspace offer requires org_root_id")
|
||||
return
|
||||
}
|
||||
if principalRole != "org_root_owner" {
|
||||
writeErrorCode(w, http.StatusForbidden, "role_insufficient", "workspace checkout requires org_root_owner role")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for _, requiredOfferID := range offer.Policies.RequiresOffers {
|
||||
hasRequired, err := a.store.hasActiveEntitlement(r.Context(), wallet, requiredOfferID, orgRootID)
|
||||
if err != nil {
|
||||
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to resolve prerequisite entitlements")
|
||||
return
|
||||
}
|
||||
if !hasRequired {
|
||||
writeErrorCode(w, http.StatusForbidden, "prerequisite_required", fmt.Sprintf("active entitlement required: %s", requiredOfferID))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
includeMembership := true
|
||||
if req.IncludeMembershipIfMissing != nil {
|
||||
includeMembership = *req.IncludeMembershipIfMissing
|
||||
}
|
||||
|
||||
membershipStatus, statusErr := a.resolveMembershipStatusForWallet(r, wallet)
|
||||
if statusErr != nil {
|
||||
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to resolve membership status")
|
||||
return
|
||||
}
|
||||
membershipIncluded := false
|
||||
switch membershipStatus {
|
||||
case "active":
|
||||
// no-op
|
||||
case "none", "unknown", "":
|
||||
if includeMembership {
|
||||
membershipIncluded = true
|
||||
} else {
|
||||
writeErrorCode(w, http.StatusForbidden, "membership_required", "active membership is required for checkout")
|
||||
return
|
||||
}
|
||||
case "suspended", "revoked":
|
||||
writeErrorCode(w, http.StatusForbidden, "membership_required", "active membership is required for checkout")
|
||||
return
|
||||
default:
|
||||
if includeMembership {
|
||||
membershipIncluded = true
|
||||
} else {
|
||||
writeErrorCode(w, http.StatusForbidden, "membership_required", "active membership is required for checkout")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
licenseAmount := offer.Pricing.AmountAtomic
|
||||
totalAmount := new(big.Int)
|
||||
if _, ok := totalAmount.SetString(licenseAmount, 10); !ok {
|
||||
writeErrorCode(w, http.StatusInternalServerError, "pricing_invalid", "offer pricing is invalid")
|
||||
return
|
||||
}
|
||||
|
||||
lineItems := []marketplaceQuoteLineItem{
|
||||
{
|
||||
Kind: "license",
|
||||
Label: offer.Title,
|
||||
Amount: formatAtomicAmount(licenseAmount, offer.Pricing.Decimals),
|
||||
AmountAtomic: licenseAmount,
|
||||
Decimals: offer.Pricing.Decimals,
|
||||
Currency: offer.Pricing.Currency,
|
||||
},
|
||||
}
|
||||
if membershipIncluded {
|
||||
membershipAmount := new(big.Int)
|
||||
if _, ok := membershipAmount.SetString(marketplaceMembershipActivationAtomic, 10); !ok {
|
||||
writeErrorCode(w, http.StatusInternalServerError, "pricing_invalid", "membership pricing is invalid")
|
||||
return
|
||||
}
|
||||
totalAmount = new(big.Int).Add(totalAmount, membershipAmount)
|
||||
lineItems = append(lineItems, marketplaceQuoteLineItem{
|
||||
Kind: "membership",
|
||||
Label: "EDUT Membership Activation",
|
||||
Amount: formatAtomicAmount(marketplaceMembershipActivationAtomic, offer.Pricing.Decimals),
|
||||
AmountAtomic: marketplaceMembershipActivationAtomic,
|
||||
Decimals: offer.Pricing.Decimals,
|
||||
Currency: offer.Pricing.Currency,
|
||||
})
|
||||
}
|
||||
|
||||
lineItemsJSON, err := json.Marshal(lineItems)
|
||||
if err != nil {
|
||||
writeErrorCode(w, http.StatusInternalServerError, "encoding_failed", "failed to encode quote line items")
|
||||
return
|
||||
}
|
||||
|
||||
quoteIDRaw, err := randomHex(16)
|
||||
if err != nil {
|
||||
writeErrorCode(w, http.StatusInternalServerError, "quote_generation_failed", "failed to generate quote id")
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
expiresAt := now.Add(a.cfg.QuoteTTL)
|
||||
accessClass := "connected"
|
||||
if strings.EqualFold(strings.TrimSpace(offer.OfferID), offerIDWorkspaceSovereign) {
|
||||
accessClass = "sovereign"
|
||||
}
|
||||
quote := marketplaceQuoteRecord{
|
||||
QuoteID: "cq_" + quoteIDRaw,
|
||||
Wallet: wallet,
|
||||
PayerWallet: payerWallet,
|
||||
OfferID: offer.OfferID,
|
||||
OrgRootID: orgRootID,
|
||||
PrincipalID: principalID,
|
||||
PrincipalRole: principalRole,
|
||||
WorkspaceID: workspaceID,
|
||||
Currency: offer.Pricing.Currency,
|
||||
AmountAtomic: licenseAmount,
|
||||
TotalAmountAtomic: totalAmount.String(),
|
||||
Decimals: offer.Pricing.Decimals,
|
||||
MembershipIncluded: membershipIncluded,
|
||||
LineItemsJSON: string(lineItemsJSON),
|
||||
PolicyHash: a.cfg.GovernancePolicyHash,
|
||||
AccessClass: accessClass,
|
||||
AvailabilityState: "active",
|
||||
CreatedAt: now,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
if err := a.store.putMarketplaceQuote(r.Context(), quote); err != nil {
|
||||
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to persist quote")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, marketplaceCheckoutQuoteResponse{
|
||||
QuoteID: quote.QuoteID,
|
||||
Wallet: quote.Wallet,
|
||||
PayerWallet: quote.PayerWallet,
|
||||
OfferID: quote.OfferID,
|
||||
OrgRootID: quote.OrgRootID,
|
||||
PrincipalID: quote.PrincipalID,
|
||||
PrincipalRole: quote.PrincipalRole,
|
||||
Currency: quote.Currency,
|
||||
Amount: formatAtomicAmount(quote.AmountAtomic, quote.Decimals),
|
||||
AmountAtomic: quote.AmountAtomic,
|
||||
TotalAmount: formatAtomicAmount(quote.TotalAmountAtomic, quote.Decimals),
|
||||
TotalAmountAtomic: quote.TotalAmountAtomic,
|
||||
Decimals: quote.Decimals,
|
||||
MembershipActivationIncluded: quote.MembershipIncluded,
|
||||
LineItems: lineItems,
|
||||
PolicyHash: quote.PolicyHash,
|
||||
ExpiresAt: quote.ExpiresAt.Format(time.RFC3339Nano),
|
||||
Tx: map[string]any{
|
||||
"to": strings.ToLower(a.cfg.MembershipContract),
|
||||
"value": "0x0",
|
||||
"data": "0x",
|
||||
},
|
||||
AccessClass: quote.AccessClass,
|
||||
AvailabilityState: quote.AvailabilityState,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *app) handleMarketplaceCheckoutConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
var req marketplaceCheckoutConfirmRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErrorCode(w, http.StatusBadRequest, "invalid_request", "invalid request body")
|
||||
return
|
||||
}
|
||||
wallet, err := normalizeAddress(req.Wallet)
|
||||
if err != nil {
|
||||
writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
|
||||
return
|
||||
}
|
||||
if req.ChainID != a.cfg.ChainID {
|
||||
writeErrorCode(w, http.StatusBadRequest, "unsupported_chain_id", fmt.Sprintf("unsupported chain_id: %d", req.ChainID))
|
||||
return
|
||||
}
|
||||
if !isTxHash(req.TxHash) {
|
||||
writeErrorCode(w, http.StatusBadRequest, "invalid_tx_hash", "invalid tx hash")
|
||||
return
|
||||
}
|
||||
quote, err := a.store.getMarketplaceQuote(r.Context(), strings.TrimSpace(req.QuoteID))
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
writeErrorCode(w, http.StatusNotFound, "quote_not_found", "quote not found")
|
||||
return
|
||||
}
|
||||
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to load quote")
|
||||
return
|
||||
}
|
||||
if time.Now().UTC().After(quote.ExpiresAt) {
|
||||
writeErrorCode(w, http.StatusConflict, "quote_expired", "quote expired")
|
||||
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")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.OrgRootID) != "" && !strings.EqualFold(strings.TrimSpace(req.OrgRootID), quote.OrgRootID) {
|
||||
writeErrorCode(w, http.StatusBadRequest, "context_mismatch", "org_root_id mismatch")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.PrincipalID) != "" && !strings.EqualFold(strings.TrimSpace(req.PrincipalID), quote.PrincipalID) {
|
||||
writeErrorCode(w, http.StatusBadRequest, "context_mismatch", "principal_id mismatch")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.PrincipalRole) != "" && !strings.EqualFold(strings.TrimSpace(req.PrincipalRole), quote.PrincipalRole) {
|
||||
writeErrorCode(w, http.StatusBadRequest, "context_mismatch", "principal_role mismatch")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.WorkspaceID) != "" && !strings.EqualFold(strings.TrimSpace(req.WorkspaceID), quote.WorkspaceID) {
|
||||
writeErrorCode(w, http.StatusBadRequest, "context_mismatch", "workspace_id mismatch")
|
||||
return
|
||||
}
|
||||
if quote.ConfirmedAt != nil {
|
||||
if existing, existingErr := a.store.getMarketplaceEntitlementByQuote(r.Context(), quote.QuoteID); existingErr == nil {
|
||||
writeJSON(w, http.StatusOK, marketplaceCheckoutConfirmResponse{
|
||||
Status: "entitlement_active",
|
||||
EntitlementID: existing.EntitlementID,
|
||||
OfferID: existing.OfferID,
|
||||
OrgRootID: existing.OrgRootID,
|
||||
PrincipalID: existing.PrincipalID,
|
||||
PrincipalRole: existing.PrincipalRole,
|
||||
Wallet: existing.Wallet,
|
||||
TxHash: existing.TxHash,
|
||||
PolicyHash: existing.PolicyHash,
|
||||
ActivatedAt: existing.IssuedAt.Format(time.RFC3339Nano),
|
||||
AccessClass: existing.AccessClass,
|
||||
AvailabilityState: existing.AvailabilityState,
|
||||
})
|
||||
return
|
||||
}
|
||||
writeErrorCode(w, http.StatusConflict, "quote_already_confirmed", "quote already confirmed")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
quote.ConfirmedAt = &now
|
||||
quote.ConfirmedTxHash = strings.ToLower(strings.TrimSpace(req.TxHash))
|
||||
if err := a.store.putMarketplaceQuote(r.Context(), quote); err != nil {
|
||||
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to persist quote confirmation")
|
||||
return
|
||||
}
|
||||
|
||||
if quote.MembershipIncluded {
|
||||
if err := a.ensureMembershipActiveForWallet(r.Context(), wallet, quote.ConfirmedTxHash); err != nil {
|
||||
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to activate membership")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
entitlementID := buildEntitlementID(a.cfg.ChainID, wallet)
|
||||
ent := marketplaceEntitlementRecord{
|
||||
EntitlementID: entitlementID,
|
||||
QuoteID: quote.QuoteID,
|
||||
OfferID: quote.OfferID,
|
||||
Wallet: wallet,
|
||||
PayerWallet: quote.PayerWallet,
|
||||
OrgRootID: quote.OrgRootID,
|
||||
PrincipalID: quote.PrincipalID,
|
||||
PrincipalRole: quote.PrincipalRole,
|
||||
WorkspaceID: quote.WorkspaceID,
|
||||
State: "active",
|
||||
AccessClass: quote.AccessClass,
|
||||
AvailabilityState: quote.AvailabilityState,
|
||||
PolicyHash: quote.PolicyHash,
|
||||
IssuedAt: now,
|
||||
TxHash: quote.ConfirmedTxHash,
|
||||
}
|
||||
if err := a.store.putMarketplaceEntitlement(r.Context(), ent); err != nil {
|
||||
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to persist entitlement")
|
||||
return
|
||||
}
|
||||
|
||||
if shouldBindPrincipalOnOffer(quote.OfferID) {
|
||||
principal, principalErr := a.resolveOrCreatePrincipal(r.Context(), wallet, quote.OrgRootID, quote.PrincipalID, quote.PrincipalRole)
|
||||
if principalErr == nil {
|
||||
principal.EntitlementID = ent.EntitlementID
|
||||
principal.EntitlementStatus = "active"
|
||||
principal.AccessClass = quote.AccessClass
|
||||
principal.AvailabilityState = quote.AvailabilityState
|
||||
principal.UpdatedAt = now
|
||||
_ = a.store.putGovernancePrincipal(r.Context(), principal)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, marketplaceCheckoutConfirmResponse{
|
||||
Status: "entitlement_active",
|
||||
EntitlementID: ent.EntitlementID,
|
||||
OfferID: ent.OfferID,
|
||||
OrgRootID: ent.OrgRootID,
|
||||
PrincipalID: ent.PrincipalID,
|
||||
PrincipalRole: ent.PrincipalRole,
|
||||
Wallet: ent.Wallet,
|
||||
TxHash: ent.TxHash,
|
||||
PolicyHash: ent.PolicyHash,
|
||||
ActivatedAt: ent.IssuedAt.Format(time.RFC3339Nano),
|
||||
AccessClass: ent.AccessClass,
|
||||
AvailabilityState: ent.AvailabilityState,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *app) handleMarketplaceEntitlements(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
wallet, err := normalizeAddress(strings.TrimSpace(r.URL.Query().Get("wallet")))
|
||||
if err != nil {
|
||||
writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
|
||||
return
|
||||
}
|
||||
records, err := a.store.listMarketplaceEntitlementsByWallet(r.Context(), wallet)
|
||||
if err != nil {
|
||||
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to list entitlements")
|
||||
return
|
||||
}
|
||||
out := make([]marketplaceEntitlement, 0, len(records))
|
||||
for _, rec := range records {
|
||||
out = append(out, marketplaceEntitlement{
|
||||
EntitlementID: rec.EntitlementID,
|
||||
OfferID: rec.OfferID,
|
||||
WalletAddress: rec.Wallet,
|
||||
WorkspaceID: rec.WorkspaceID,
|
||||
OrgRootID: rec.OrgRootID,
|
||||
State: rec.State,
|
||||
AccessClass: rec.AccessClass,
|
||||
AvailabilityState: rec.AvailabilityState,
|
||||
PolicyHash: rec.PolicyHash,
|
||||
IssuedAt: rec.IssuedAt.Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, marketplaceEntitlementsResponse{Entitlements: out})
|
||||
}
|
||||
|
||||
func (a *app) resolveMembershipStatusForWallet(r *http.Request, wallet string) (string, error) {
|
||||
rec, err := a.store.getDesignationByAddress(r.Context(), wallet)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return "none", nil
|
||||
}
|
||||
return "unknown", err
|
||||
}
|
||||
status := strings.ToLower(strings.TrimSpace(rec.MembershipStatus))
|
||||
if status == "" {
|
||||
status = "none"
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (a *app) ensureMembershipActiveForWallet(ctx context.Context, wallet, txHash string) error {
|
||||
rec, err := a.store.getDesignationByAddress(ctx, wallet)
|
||||
if err != nil {
|
||||
if !errors.Is(err, errNotFound) {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
intentID, err := randomHex(16)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nonce, err := randomHex(16)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
code, displayToken, err := newDesignationCode()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rec = designationRecord{
|
||||
Code: code,
|
||||
DisplayToken: displayToken,
|
||||
IntentID: intentID,
|
||||
Nonce: nonce,
|
||||
Origin: "edut-launcher",
|
||||
Locale: "en",
|
||||
Address: wallet,
|
||||
ChainID: a.cfg.ChainID,
|
||||
IssuedAt: now,
|
||||
ExpiresAt: now.Add(a.cfg.IntentTTL),
|
||||
VerifiedAt: &now,
|
||||
MembershipStatus: "none",
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
rec.MembershipStatus = "active"
|
||||
rec.MembershipTxHash = strings.ToLower(strings.TrimSpace(txHash))
|
||||
rec.ActivatedAt = &now
|
||||
return a.store.putDesignation(ctx, rec)
|
||||
}
|
||||
|
||||
func buildEntitlementID(chainID int64, wallet string) string {
|
||||
token, _ := randomHex(4)
|
||||
return fmt.Sprintf("ent:%d:%s:%s", chainID, wallet, token)
|
||||
}
|
||||
|
||||
func formatAtomicAmount(amountAtomic string, decimals int) string {
|
||||
amountAtomic = strings.TrimSpace(amountAtomic)
|
||||
if amountAtomic == "" {
|
||||
return "0"
|
||||
}
|
||||
n := new(big.Int)
|
||||
if _, ok := n.SetString(amountAtomic, 10); !ok {
|
||||
return "0"
|
||||
}
|
||||
sign := ""
|
||||
if n.Sign() < 0 {
|
||||
sign = "-"
|
||||
n.Abs(n)
|
||||
}
|
||||
raw := n.String()
|
||||
if decimals <= 0 {
|
||||
return sign + raw
|
||||
}
|
||||
if len(raw) <= decimals {
|
||||
raw = strings.Repeat("0", decimals-len(raw)+1) + raw
|
||||
}
|
||||
whole := raw[:len(raw)-decimals]
|
||||
fraction := strings.TrimRight(raw[len(raw)-decimals:], "0")
|
||||
if fraction == "" {
|
||||
return sign + whole
|
||||
}
|
||||
return sign + whole + "." + fraction
|
||||
}
|
||||
|
||||
func isLikelyHexSignature(value string) bool {
|
||||
value = strings.TrimSpace(strings.ToLower(value))
|
||||
if !strings.HasPrefix(value, "0x") || len(value) < 132 {
|
||||
return false
|
||||
}
|
||||
_, err := strconv.ParseUint(value[2:18], 16, 64)
|
||||
return err == nil
|
||||
}
|
||||
166
backend/secretapi/marketplace_models.go
Normal file
166
backend/secretapi/marketplace_models.go
Normal file
@ -0,0 +1,166 @@
|
||||
package main
|
||||
|
||||
import "time"
|
||||
|
||||
type marketplaceOffer struct {
|
||||
OfferID string `json:"offer_id"`
|
||||
IssuerID string `json:"issuer_id"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Pricing marketplaceOfferPrice `json:"pricing"`
|
||||
Policies marketplaceOfferPolicy `json:"policies"`
|
||||
SortOrder int `json:"-"`
|
||||
}
|
||||
|
||||
type marketplaceOfferPrice struct {
|
||||
Currency string `json:"currency"`
|
||||
AmountAtomic string `json:"amount_atomic"`
|
||||
Decimals int `json:"decimals"`
|
||||
ChainID int64 `json:"chain_id"`
|
||||
}
|
||||
|
||||
type marketplaceOfferPolicy struct {
|
||||
MemberOnly bool `json:"member_only"`
|
||||
WorkspaceBound bool `json:"workspace_bound"`
|
||||
Transferable bool `json:"transferable"`
|
||||
InternalUseOnly bool `json:"internal_use_only"`
|
||||
MultiTenant bool `json:"multi_tenant"`
|
||||
EntitlementClass string `json:"entitlement_class,omitempty"`
|
||||
RequiresOffers []string `json:"requires_offers,omitempty"`
|
||||
}
|
||||
|
||||
type marketplaceOffersResponse struct {
|
||||
Offers []marketplaceOffer `json:"offers"`
|
||||
}
|
||||
|
||||
type marketplaceCheckoutQuoteRequest struct {
|
||||
Wallet string `json:"wallet"`
|
||||
PayerWallet string `json:"payer_wallet,omitempty"`
|
||||
OfferID string `json:"offer_id"`
|
||||
OrgRootID string `json:"org_root_id,omitempty"`
|
||||
PrincipalID string `json:"principal_id,omitempty"`
|
||||
PrincipalRole string `json:"principal_role,omitempty"`
|
||||
WorkspaceID string `json:"workspace_id,omitempty"`
|
||||
OwnershipProof string `json:"ownership_proof,omitempty"`
|
||||
IncludeMembershipIfMissing *bool `json:"include_membership_if_missing,omitempty"`
|
||||
}
|
||||
|
||||
type marketplaceQuoteLineItem struct {
|
||||
Kind string `json:"kind"`
|
||||
Label string `json:"label"`
|
||||
Amount string `json:"amount"`
|
||||
AmountAtomic string `json:"amount_atomic"`
|
||||
Decimals int `json:"decimals"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
type marketplaceCheckoutQuoteResponse struct {
|
||||
QuoteID string `json:"quote_id"`
|
||||
Wallet string `json:"wallet"`
|
||||
PayerWallet string `json:"payer_wallet,omitempty"`
|
||||
OfferID string `json:"offer_id"`
|
||||
OrgRootID string `json:"org_root_id,omitempty"`
|
||||
PrincipalID string `json:"principal_id,omitempty"`
|
||||
PrincipalRole string `json:"principal_role,omitempty"`
|
||||
Currency string `json:"currency"`
|
||||
Amount string `json:"amount"`
|
||||
AmountAtomic string `json:"amount_atomic"`
|
||||
TotalAmount string `json:"total_amount"`
|
||||
TotalAmountAtomic string `json:"total_amount_atomic"`
|
||||
Decimals int `json:"decimals"`
|
||||
MembershipActivationIncluded bool `json:"membership_activation_included"`
|
||||
LineItems []marketplaceQuoteLineItem `json:"line_items"`
|
||||
PolicyHash string `json:"policy_hash"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
Tx map[string]any `json:"tx"`
|
||||
AccessClass string `json:"access_class"`
|
||||
AvailabilityState string `json:"availability_state"`
|
||||
}
|
||||
|
||||
type marketplaceCheckoutConfirmRequest struct {
|
||||
QuoteID string `json:"quote_id"`
|
||||
Wallet string `json:"wallet"`
|
||||
PayerWallet string `json:"payer_wallet,omitempty"`
|
||||
OfferID string `json:"offer_id"`
|
||||
OrgRootID string `json:"org_root_id,omitempty"`
|
||||
PrincipalID string `json:"principal_id,omitempty"`
|
||||
PrincipalRole string `json:"principal_role,omitempty"`
|
||||
WorkspaceID string `json:"workspace_id,omitempty"`
|
||||
TxHash string `json:"tx_hash"`
|
||||
ChainID int64 `json:"chain_id"`
|
||||
}
|
||||
|
||||
type marketplaceCheckoutConfirmResponse struct {
|
||||
Status string `json:"status"`
|
||||
EntitlementID string `json:"entitlement_id"`
|
||||
OfferID string `json:"offer_id"`
|
||||
OrgRootID string `json:"org_root_id,omitempty"`
|
||||
PrincipalID string `json:"principal_id,omitempty"`
|
||||
PrincipalRole string `json:"principal_role,omitempty"`
|
||||
Wallet string `json:"wallet"`
|
||||
TxHash string `json:"tx_hash"`
|
||||
PolicyHash string `json:"policy_hash"`
|
||||
ActivatedAt string `json:"activated_at"`
|
||||
AccessClass string `json:"access_class"`
|
||||
AvailabilityState string `json:"availability_state"`
|
||||
}
|
||||
|
||||
type marketplaceEntitlement struct {
|
||||
EntitlementID string `json:"entitlement_id"`
|
||||
OfferID string `json:"offer_id"`
|
||||
WalletAddress string `json:"wallet_address"`
|
||||
WorkspaceID string `json:"workspace_id,omitempty"`
|
||||
OrgRootID string `json:"org_root_id,omitempty"`
|
||||
State string `json:"state"`
|
||||
AccessClass string `json:"access_class"`
|
||||
AvailabilityState string `json:"availability_state"`
|
||||
PolicyHash string `json:"policy_hash"`
|
||||
IssuedAt string `json:"issued_at"`
|
||||
}
|
||||
|
||||
type marketplaceEntitlementsResponse struct {
|
||||
Entitlements []marketplaceEntitlement `json:"entitlements"`
|
||||
}
|
||||
|
||||
type marketplaceQuoteRecord struct {
|
||||
QuoteID string
|
||||
Wallet string
|
||||
PayerWallet string
|
||||
OfferID string
|
||||
OrgRootID string
|
||||
PrincipalID string
|
||||
PrincipalRole string
|
||||
WorkspaceID string
|
||||
Currency string
|
||||
AmountAtomic string
|
||||
TotalAmountAtomic string
|
||||
Decimals int
|
||||
MembershipIncluded bool
|
||||
LineItemsJSON string
|
||||
PolicyHash string
|
||||
AccessClass string
|
||||
AvailabilityState string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
ConfirmedAt *time.Time
|
||||
ConfirmedTxHash string
|
||||
}
|
||||
|
||||
type marketplaceEntitlementRecord struct {
|
||||
EntitlementID string
|
||||
QuoteID string
|
||||
OfferID string
|
||||
Wallet string
|
||||
PayerWallet string
|
||||
OrgRootID string
|
||||
PrincipalID string
|
||||
PrincipalRole string
|
||||
WorkspaceID string
|
||||
State string
|
||||
AccessClass string
|
||||
AvailabilityState string
|
||||
PolicyHash string
|
||||
IssuedAt time.Time
|
||||
TxHash string
|
||||
}
|
||||
@ -79,6 +79,48 @@ func (s *store) migrate(ctx context.Context) error {
|
||||
FOREIGN KEY(designation_code) REFERENCES designations(code)
|
||||
);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_quotes_designation ON quotes(designation_code);`,
|
||||
`CREATE TABLE IF NOT EXISTS marketplace_quotes (
|
||||
quote_id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
payer_wallet TEXT,
|
||||
offer_id TEXT NOT NULL,
|
||||
org_root_id TEXT,
|
||||
principal_id TEXT,
|
||||
principal_role TEXT,
|
||||
workspace_id TEXT,
|
||||
currency TEXT NOT NULL,
|
||||
amount_atomic TEXT NOT NULL,
|
||||
total_amount_atomic TEXT NOT NULL,
|
||||
decimals INTEGER NOT NULL,
|
||||
membership_included INTEGER NOT NULL DEFAULT 0,
|
||||
line_items_json TEXT NOT NULL,
|
||||
policy_hash TEXT NOT NULL,
|
||||
access_class TEXT NOT NULL,
|
||||
availability_state TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
confirmed_at TEXT,
|
||||
confirmed_tx_hash TEXT
|
||||
);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_marketplace_quotes_wallet ON marketplace_quotes(wallet);`,
|
||||
`CREATE TABLE IF NOT EXISTS marketplace_entitlements (
|
||||
entitlement_id TEXT PRIMARY KEY,
|
||||
quote_id TEXT NOT NULL UNIQUE,
|
||||
offer_id TEXT NOT NULL,
|
||||
wallet TEXT NOT NULL,
|
||||
payer_wallet TEXT,
|
||||
org_root_id TEXT,
|
||||
principal_id TEXT,
|
||||
principal_role TEXT,
|
||||
workspace_id TEXT,
|
||||
state TEXT NOT NULL,
|
||||
access_class TEXT NOT NULL,
|
||||
availability_state TEXT NOT NULL,
|
||||
policy_hash TEXT NOT NULL,
|
||||
issued_at TEXT NOT NULL,
|
||||
tx_hash TEXT NOT NULL
|
||||
);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_marketplace_entitlements_wallet ON marketplace_entitlements(wallet);`,
|
||||
`CREATE TABLE IF NOT EXISTS governance_principals (
|
||||
wallet TEXT PRIMARY KEY,
|
||||
org_root_id TEXT NOT NULL,
|
||||
@ -373,6 +415,259 @@ func (s *store) getQuote(ctx context.Context, quoteID string) (quoteRecord, erro
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
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,
|
||||
currency, amount_atomic, total_amount_atomic, decimals, membership_included, line_items_json,
|
||||
policy_hash, access_class, availability_state, created_at, expires_at, confirmed_at, confirmed_tx_hash
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(quote_id) DO UPDATE SET
|
||||
wallet=excluded.wallet,
|
||||
payer_wallet=excluded.payer_wallet,
|
||||
offer_id=excluded.offer_id,
|
||||
org_root_id=excluded.org_root_id,
|
||||
principal_id=excluded.principal_id,
|
||||
principal_role=excluded.principal_role,
|
||||
workspace_id=excluded.workspace_id,
|
||||
currency=excluded.currency,
|
||||
amount_atomic=excluded.amount_atomic,
|
||||
total_amount_atomic=excluded.total_amount_atomic,
|
||||
decimals=excluded.decimals,
|
||||
membership_included=excluded.membership_included,
|
||||
line_items_json=excluded.line_items_json,
|
||||
policy_hash=excluded.policy_hash,
|
||||
access_class=excluded.access_class,
|
||||
availability_state=excluded.availability_state,
|
||||
created_at=excluded.created_at,
|
||||
expires_at=excluded.expires_at,
|
||||
confirmed_at=excluded.confirmed_at,
|
||||
confirmed_tx_hash=excluded.confirmed_tx_hash
|
||||
`, quote.QuoteID,
|
||||
strings.ToLower(strings.TrimSpace(quote.Wallet)),
|
||||
nullableString(strings.ToLower(strings.TrimSpace(quote.PayerWallet))),
|
||||
strings.TrimSpace(quote.OfferID),
|
||||
nullableString(strings.TrimSpace(quote.OrgRootID)),
|
||||
nullableString(strings.TrimSpace(quote.PrincipalID)),
|
||||
nullableString(strings.ToLower(strings.TrimSpace(quote.PrincipalRole))),
|
||||
nullableString(strings.TrimSpace(quote.WorkspaceID)),
|
||||
strings.TrimSpace(quote.Currency),
|
||||
strings.TrimSpace(quote.AmountAtomic),
|
||||
strings.TrimSpace(quote.TotalAmountAtomic),
|
||||
quote.Decimals,
|
||||
boolToInt(quote.MembershipIncluded),
|
||||
quote.LineItemsJSON,
|
||||
strings.TrimSpace(quote.PolicyHash),
|
||||
strings.ToLower(strings.TrimSpace(quote.AccessClass)),
|
||||
strings.ToLower(strings.TrimSpace(quote.AvailabilityState)),
|
||||
quote.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||
quote.ExpiresAt.UTC().Format(time.RFC3339Nano),
|
||||
formatNullableTime(quote.ConfirmedAt),
|
||||
nullableString(strings.ToLower(strings.TrimSpace(quote.ConfirmedTxHash))),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
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,
|
||||
currency, amount_atomic, total_amount_atomic, decimals, membership_included, line_items_json,
|
||||
policy_hash, access_class, availability_state, 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 createdAt, expiresAt, confirmedAt, confirmedTxHash sql.NullString
|
||||
var membershipIncluded int
|
||||
err := row.Scan(
|
||||
&rec.QuoteID,
|
||||
&rec.Wallet,
|
||||
&payerWallet,
|
||||
&rec.OfferID,
|
||||
&orgRootID,
|
||||
&principalID,
|
||||
&principalRole,
|
||||
&workspaceID,
|
||||
&rec.Currency,
|
||||
&rec.AmountAtomic,
|
||||
&rec.TotalAmountAtomic,
|
||||
&rec.Decimals,
|
||||
&membershipIncluded,
|
||||
&rec.LineItemsJSON,
|
||||
&rec.PolicyHash,
|
||||
&rec.AccessClass,
|
||||
&rec.AvailabilityState,
|
||||
&createdAt,
|
||||
&expiresAt,
|
||||
&confirmedAt,
|
||||
&confirmedTxHash,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return marketplaceQuoteRecord{}, errNotFound
|
||||
}
|
||||
return marketplaceQuoteRecord{}, err
|
||||
}
|
||||
rec.PayerWallet = payerWallet.String
|
||||
rec.OrgRootID = orgRootID.String
|
||||
rec.PrincipalID = principalID.String
|
||||
rec.PrincipalRole = principalRole.String
|
||||
rec.WorkspaceID = workspaceID.String
|
||||
rec.MembershipIncluded = membershipIncluded == 1
|
||||
rec.CreatedAt = parseRFC3339Nullable(createdAt)
|
||||
rec.ExpiresAt = parseRFC3339Nullable(expiresAt)
|
||||
rec.ConfirmedAt = parseRFC3339Ptr(confirmedAt)
|
||||
rec.ConfirmedTxHash = confirmedTxHash.String
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
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,
|
||||
workspace_id, state, access_class, availability_state, policy_hash, issued_at, tx_hash
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(entitlement_id) DO UPDATE SET
|
||||
quote_id=excluded.quote_id,
|
||||
offer_id=excluded.offer_id,
|
||||
wallet=excluded.wallet,
|
||||
payer_wallet=excluded.payer_wallet,
|
||||
org_root_id=excluded.org_root_id,
|
||||
principal_id=excluded.principal_id,
|
||||
principal_role=excluded.principal_role,
|
||||
workspace_id=excluded.workspace_id,
|
||||
state=excluded.state,
|
||||
access_class=excluded.access_class,
|
||||
availability_state=excluded.availability_state,
|
||||
policy_hash=excluded.policy_hash,
|
||||
issued_at=excluded.issued_at,
|
||||
tx_hash=excluded.tx_hash
|
||||
`, strings.TrimSpace(ent.EntitlementID),
|
||||
strings.TrimSpace(ent.QuoteID),
|
||||
strings.TrimSpace(ent.OfferID),
|
||||
strings.ToLower(strings.TrimSpace(ent.Wallet)),
|
||||
nullableString(strings.ToLower(strings.TrimSpace(ent.PayerWallet))),
|
||||
nullableString(strings.TrimSpace(ent.OrgRootID)),
|
||||
nullableString(strings.TrimSpace(ent.PrincipalID)),
|
||||
nullableString(strings.ToLower(strings.TrimSpace(ent.PrincipalRole))),
|
||||
nullableString(strings.TrimSpace(ent.WorkspaceID)),
|
||||
strings.ToLower(strings.TrimSpace(ent.State)),
|
||||
strings.ToLower(strings.TrimSpace(ent.AccessClass)),
|
||||
strings.ToLower(strings.TrimSpace(ent.AvailabilityState)),
|
||||
strings.TrimSpace(ent.PolicyHash),
|
||||
ent.IssuedAt.UTC().Format(time.RFC3339Nano),
|
||||
strings.ToLower(strings.TrimSpace(ent.TxHash)),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
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,
|
||||
workspace_id, state, access_class, availability_state, policy_hash, issued_at, tx_hash
|
||||
FROM marketplace_entitlements
|
||||
WHERE quote_id = ?
|
||||
`, strings.TrimSpace(quoteID))
|
||||
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,
|
||||
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)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
records := make([]marketplaceEntitlementRecord, 0)
|
||||
for rows.Next() {
|
||||
rec, err := scanMarketplaceEntitlement(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, rec)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (s *store) hasActiveEntitlement(ctx context.Context, wallet, offerID, orgRootID string) (bool, error) {
|
||||
wallet = strings.ToLower(strings.TrimSpace(wallet))
|
||||
offerID = strings.TrimSpace(offerID)
|
||||
orgRootID = strings.TrimSpace(orgRootID)
|
||||
if wallet == "" || offerID == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT 1
|
||||
FROM marketplace_entitlements
|
||||
WHERE wallet = ?
|
||||
AND offer_id = ?
|
||||
AND state = 'active'
|
||||
`
|
||||
args := []any{wallet, offerID}
|
||||
if orgRootID != "" {
|
||||
query += " AND org_root_id = ?"
|
||||
args = append(args, orgRootID)
|
||||
}
|
||||
query += " LIMIT 1"
|
||||
|
||||
row := s.db.QueryRowContext(ctx, query, args...)
|
||||
var marker int
|
||||
if err := row.Scan(&marker); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return marker == 1, nil
|
||||
}
|
||||
|
||||
func scanMarketplaceEntitlement(row interface{ Scan(dest ...any) error }) (marketplaceEntitlementRecord, error) {
|
||||
var rec marketplaceEntitlementRecord
|
||||
var payerWallet, orgRootID, principalID, principalRole, workspaceID sql.NullString
|
||||
var issuedAt sql.NullString
|
||||
err := row.Scan(
|
||||
&rec.EntitlementID,
|
||||
&rec.QuoteID,
|
||||
&rec.OfferID,
|
||||
&rec.Wallet,
|
||||
&payerWallet,
|
||||
&orgRootID,
|
||||
&principalID,
|
||||
&principalRole,
|
||||
&workspaceID,
|
||||
&rec.State,
|
||||
&rec.AccessClass,
|
||||
&rec.AvailabilityState,
|
||||
&rec.PolicyHash,
|
||||
&issuedAt,
|
||||
&rec.TxHash,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return marketplaceEntitlementRecord{}, errNotFound
|
||||
}
|
||||
return marketplaceEntitlementRecord{}, err
|
||||
}
|
||||
rec.PayerWallet = payerWallet.String
|
||||
rec.OrgRootID = orgRootID.String
|
||||
rec.PrincipalID = principalID.String
|
||||
rec.PrincipalRole = principalRole.String
|
||||
rec.WorkspaceID = workspaceID.String
|
||||
rec.IssuedAt = parseRFC3339Nullable(issuedAt)
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
func (s *store) putGovernancePrincipal(ctx context.Context, rec governancePrincipalRecord) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO governance_principals (
|
||||
|
||||
@ -8,14 +8,14 @@ Success (`200`):
|
||||
{
|
||||
"offers": [
|
||||
{
|
||||
"offer_id": "edut.governance.core",
|
||||
"offer_id": "edut.workspace.core",
|
||||
"issuer_id": "edut.firstparty",
|
||||
"title": "EDUT Governance Core",
|
||||
"summary": "First paid runtime license.",
|
||||
"title": "EDUT Workspace Core",
|
||||
"summary": "Org-bound deterministic governance runtime.",
|
||||
"status": "active",
|
||||
"pricing": {
|
||||
"currency": "USDC",
|
||||
"amount_atomic": "499000000",
|
||||
"amount_atomic": "1000000000",
|
||||
"decimals": 6,
|
||||
"chain_id": 8453
|
||||
},
|
||||
@ -39,7 +39,7 @@ Request:
|
||||
{
|
||||
"wallet": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5",
|
||||
"payer_wallet": "0x2299547f6fA9A8f9b6d9aEA9F9D8A4B53C8A0e11",
|
||||
"offer_id": "edut.governance.core",
|
||||
"offer_id": "edut.workspace.core",
|
||||
"org_root_id": "org.acme.root",
|
||||
"principal_id": "human.joshua",
|
||||
"principal_role": "org_root_owner",
|
||||
@ -55,31 +55,31 @@ Success (`200`):
|
||||
"quote_id": "cq_01HZZXFQ27ZP6MP0V2R9M6V3KX",
|
||||
"wallet": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5",
|
||||
"payer_wallet": "0x2299547f6fA9A8f9b6d9aEA9F9D8A4B53C8A0e11",
|
||||
"offer_id": "edut.governance.core",
|
||||
"offer_id": "edut.workspace.core",
|
||||
"org_root_id": "org.acme.root",
|
||||
"principal_id": "human.joshua",
|
||||
"principal_role": "org_root_owner",
|
||||
"currency": "USDC",
|
||||
"amount": "500.00",
|
||||
"amount_atomic": "500000000",
|
||||
"total_amount": "505.00",
|
||||
"total_amount_atomic": "505000000",
|
||||
"amount": "1000.00",
|
||||
"amount_atomic": "1000000000",
|
||||
"total_amount": "1100.00",
|
||||
"total_amount_atomic": "1100000000",
|
||||
"decimals": 6,
|
||||
"membership_activation_included": true,
|
||||
"line_items": [
|
||||
{
|
||||
"kind": "license",
|
||||
"label": "Governance Core License",
|
||||
"amount": "500.00",
|
||||
"amount_atomic": "500000000",
|
||||
"label": "EDUT Workspace Core",
|
||||
"amount": "1000.00",
|
||||
"amount_atomic": "1000000000",
|
||||
"decimals": 6,
|
||||
"currency": "USDC"
|
||||
},
|
||||
{
|
||||
"kind": "membership",
|
||||
"label": "EDUT Membership Activation",
|
||||
"amount": "5.00",
|
||||
"amount_atomic": "5000000",
|
||||
"amount": "100.00",
|
||||
"amount_atomic": "100000000",
|
||||
"decimals": 6,
|
||||
"currency": "USDC"
|
||||
}
|
||||
@ -120,7 +120,7 @@ Request:
|
||||
"quote_id": "cq_01HZZXFQ27ZP6MP0V2R9M6V3KX",
|
||||
"wallet": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5",
|
||||
"payer_wallet": "0x2299547f6fA9A8f9b6d9aEA9F9D8A4B53C8A0e11",
|
||||
"offer_id": "edut.governance.core",
|
||||
"offer_id": "edut.workspace.core",
|
||||
"org_root_id": "org.acme.root",
|
||||
"principal_id": "human.joshua",
|
||||
"principal_role": "org_root_owner",
|
||||
@ -136,7 +136,7 @@ Success (`200`):
|
||||
{
|
||||
"status": "entitlement_active",
|
||||
"entitlement_id": "ent:8453:0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5:000001",
|
||||
"offer_id": "edut.governance.core",
|
||||
"offer_id": "edut.workspace.core",
|
||||
"org_root_id": "org.acme.root",
|
||||
"principal_id": "human.joshua",
|
||||
"principal_role": "org_root_owner",
|
||||
@ -158,7 +158,7 @@ Success (`200`):
|
||||
"entitlements": [
|
||||
{
|
||||
"entitlement_id": "ent:8453:0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5:000001",
|
||||
"offer_id": "edut.crm.pro.annual",
|
||||
"offer_id": "edut.workspace.core",
|
||||
"wallet_address": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5",
|
||||
"workspace_id": "workspace.work.acme",
|
||||
"org_root_id": "org.acme.root",
|
||||
|
||||
@ -95,8 +95,8 @@ Success (`200`):
|
||||
"quote_id": "mq_01HZZX4F8VQXJ6A57R8P3SCB2W",
|
||||
"chain_id": 8453,
|
||||
"currency": "USDC",
|
||||
"amount": "5.00",
|
||||
"amount_atomic": "5000000",
|
||||
"amount": "100.00",
|
||||
"amount_atomic": "100000000",
|
||||
"decimals": 6,
|
||||
"deadline": "2026-02-17T07:36:12Z",
|
||||
"contract_address": "0x1111111111111111111111111111111111111111",
|
||||
|
||||
@ -4,7 +4,7 @@ This policy defines deterministic rules for membership mint pricing.
|
||||
|
||||
## Policy Objectives
|
||||
|
||||
1. Keep onboarding friction low.
|
||||
1. Keep onboarding deterministic and explicit.
|
||||
2. Guarantee mint-cost coverage.
|
||||
3. Support configurable growth tiers.
|
||||
4. Keep pricing behavior transparent and auditable.
|
||||
@ -13,35 +13,22 @@ This policy defines deterministic rules for membership mint pricing.
|
||||
|
||||
1. Membership is paid.
|
||||
2. Membership is required for marketplace purchases.
|
||||
3. Membership price is configurable but must not go below policy floor.
|
||||
3. Membership price is fixed at `100 USDC` for launch.
|
||||
|
||||
## Floor Rule
|
||||
|
||||
1. Default floor target: USD 5.00 equivalent.
|
||||
2. Effective configured price must satisfy:
|
||||
|
||||
```text
|
||||
configured_price >= max(minimum_floor, estimated_network_cost * safety_multiplier)
|
||||
```
|
||||
|
||||
3. Recommended default `safety_multiplier`: `1.5`.
|
||||
1. Launch membership price: `100.00 USDC` (`100000000` atomic with 6 decimals).
|
||||
2. No discounts, no bundles, no alternate path pricing.
|
||||
3. Any future change must be owner-governed and event-emitted.
|
||||
|
||||
## Supported Settlement Currencies (v1)
|
||||
|
||||
1. `USDC` on Base (preferred stable settlement).
|
||||
2. `ETH` on Base (optional).
|
||||
1. `USDC` on Base.
|
||||
|
||||
`BTC` display may be shown as reference only; settlement remains Base-native.
|
||||
## Tier Policy
|
||||
|
||||
## Tier Policy (Optional)
|
||||
|
||||
1. Tiering is supply-based (`total_membership_minted`).
|
||||
2. Each tier has:
|
||||
- `max_supply`
|
||||
- `currency`
|
||||
- `amount_atomic`
|
||||
3. Price transitions occur automatically when supply crosses a tier boundary.
|
||||
4. Tier changes emit on-chain events.
|
||||
1. Disabled for launch.
|
||||
2. No supply curve, no promotions, no coupons.
|
||||
|
||||
## Quote Lock Requirements
|
||||
|
||||
@ -65,9 +52,8 @@ A wallet confirmation is valid only when tx matches quote values exactly.
|
||||
## UX Disclosure Rules
|
||||
|
||||
1. Show exact payable amount before wallet confirmation.
|
||||
2. Show currency clearly (`USDC` or `ETH`).
|
||||
3. If BTC equivalent is shown, label it `reference only`.
|
||||
4. Never imply investment return or speculative upside.
|
||||
2. Show currency clearly (`USDC`).
|
||||
3. Never imply investment return or speculative upside.
|
||||
|
||||
## Evidence Requirements
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"schema_version": "entitlement.v1",
|
||||
"entitlement_id": "ent:8453:0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5:000001",
|
||||
"offer_id": "edut.crm.pro.annual",
|
||||
"offer_id": "edut.workspace.core",
|
||||
"issuer_id": "edut.firstparty",
|
||||
"wallet_address": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5",
|
||||
"workspace_id": "workspace.work.acme",
|
||||
@ -10,7 +10,7 @@
|
||||
"tx_hash": "0x1111111111111111111111111111111111111111111111111111111111111111",
|
||||
"chain_id": 8453,
|
||||
"currency": "USDC",
|
||||
"amount_atomic": "199000000",
|
||||
"amount_atomic": "1000000000",
|
||||
"policy_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
},
|
||||
"issued_at": "2026-02-17T00:02:12Z",
|
||||
|
||||
@ -3,10 +3,10 @@
|
||||
"catalog_id": "launch-2026-operator",
|
||||
"offers": [
|
||||
{
|
||||
"offer_id": "edut.governance.core",
|
||||
"title": "EDUT Governance Core",
|
||||
"summary": "First paid runtime license. Activates deterministic governance runtime.",
|
||||
"price": "499.00",
|
||||
"offer_id": "edut.solo.core",
|
||||
"title": "EDUT Solo Core",
|
||||
"summary": "Single-principal governance runtime for personal operations.",
|
||||
"price": "1000.00",
|
||||
"currency": "USDC",
|
||||
"member_only": true,
|
||||
"workspace_bound": false,
|
||||
@ -15,10 +15,10 @@
|
||||
"multi_tenant": false
|
||||
},
|
||||
{
|
||||
"offer_id": "edut.crm.pro.annual",
|
||||
"title": "EDUT CRM Pro",
|
||||
"summary": "Workspace-bound CRM module with governance and evidence integration.",
|
||||
"price": "199.00",
|
||||
"offer_id": "edut.workspace.core",
|
||||
"title": "EDUT Workspace Core",
|
||||
"summary": "Org-bound deterministic governance runtime for team operations.",
|
||||
"price": "1000.00",
|
||||
"currency": "USDC",
|
||||
"member_only": true,
|
||||
"workspace_bound": true,
|
||||
@ -27,10 +27,34 @@
|
||||
"multi_tenant": false
|
||||
},
|
||||
{
|
||||
"offer_id": "edut.invoicing.core.annual",
|
||||
"title": "EDUT Invoicing Core",
|
||||
"summary": "Invoicing workflow module for member workspaces.",
|
||||
"price": "99.00",
|
||||
"offer_id": "edut.workspace.ai",
|
||||
"title": "EDUT Workspace AI Layer",
|
||||
"summary": "AI reasoning layer for governed workspace operations.",
|
||||
"price": "1000.00",
|
||||
"currency": "USDC",
|
||||
"member_only": true,
|
||||
"workspace_bound": true,
|
||||
"transferable": false,
|
||||
"internal_use_only": true,
|
||||
"multi_tenant": false
|
||||
},
|
||||
{
|
||||
"offer_id": "edut.workspace.lane24",
|
||||
"title": "EDUT Workspace 24h Lane",
|
||||
"summary": "Autonomous execution lane capacity for workspace throughput.",
|
||||
"price": "1000.00",
|
||||
"currency": "USDC",
|
||||
"member_only": true,
|
||||
"workspace_bound": true,
|
||||
"transferable": false,
|
||||
"internal_use_only": true,
|
||||
"multi_tenant": false
|
||||
},
|
||||
{
|
||||
"offer_id": "edut.workspace.sovereign",
|
||||
"title": "EDUT Workspace Sovereign Continuity",
|
||||
"summary": "Continuity profile for stronger local/offline workspace operation.",
|
||||
"price": "1000.00",
|
||||
"currency": "USDC",
|
||||
"member_only": true,
|
||||
"workspace_bound": true,
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
{
|
||||
"schema_version": "offer.v1",
|
||||
"offer_id": "edut.crm.pro.annual",
|
||||
"offer_id": "edut.workspace.core",
|
||||
"issuer_id": "edut.firstparty",
|
||||
"title": "EDUT CRM Pro",
|
||||
"summary": "Workspace-bound CRM module entitlement with annual billing.",
|
||||
"title": "EDUT Workspace Core",
|
||||
"summary": "Org-bound deterministic governance runtime entitlement.",
|
||||
"status": "active",
|
||||
"pricing": {
|
||||
"currency": "USDC",
|
||||
"amount_atomic": "199000000",
|
||||
"amount_atomic": "1000000000",
|
||||
"decimals": 6,
|
||||
"chain_id": 8453
|
||||
},
|
||||
@ -17,18 +17,18 @@
|
||||
"transferable": false,
|
||||
"internal_use_only": true,
|
||||
"multi_tenant": false,
|
||||
"max_per_wallet": 5,
|
||||
"max_per_wallet": 1,
|
||||
"requires_admin_approval": false
|
||||
},
|
||||
"entitlement": {
|
||||
"type": "module_license",
|
||||
"scope": "workspace",
|
||||
"runtime_policy_ref": "policy.crm.pro.v1"
|
||||
"type": "runtime_license",
|
||||
"scope": "org_root",
|
||||
"runtime_policy_ref": "policy.workspace.core.v1"
|
||||
},
|
||||
"created_at": "2026-02-17T00:00:00Z",
|
||||
"updated_at": "2026-02-17T00:00:00Z",
|
||||
"metadata": {
|
||||
"category": "business",
|
||||
"support_tier": "standard"
|
||||
"category": "governance",
|
||||
"support_tier": "zero_ops"
|
||||
}
|
||||
}
|
||||
|
||||
@ -191,8 +191,8 @@ Response:
|
||||
"quote_id": "mq_...",
|
||||
"chain_id": 8453,
|
||||
"currency": "USDC",
|
||||
"amount": "5.00",
|
||||
"amount_atomic": "5000000",
|
||||
"amount": "100.00",
|
||||
"amount_atomic": "100000000",
|
||||
"deadline": "2026-02-17T07:36:12Z",
|
||||
"contract_address": "0x...",
|
||||
"method": "mintMembership",
|
||||
|
||||
@ -406,13 +406,13 @@
|
||||
setCheckoutLog('Offer catalog loaded: ' + payload.offers.length + ' offers.');
|
||||
} catch (err) {
|
||||
state.offers = [{
|
||||
offer_id: 'edut.governance.core',
|
||||
title: 'EDUT Governance Core (fallback)',
|
||||
summary: 'Fallback governance offer loaded because catalog fetch failed.',
|
||||
price: '499.00',
|
||||
offer_id: 'edut.workspace.core',
|
||||
title: 'EDUT Workspace Core (fallback)',
|
||||
summary: 'Fallback workspace core offer loaded because catalog fetch failed.',
|
||||
price: '1000.00',
|
||||
currency: 'USDC',
|
||||
member_only: true,
|
||||
workspace_bound: false,
|
||||
workspace_bound: true,
|
||||
transferable: false,
|
||||
}];
|
||||
state.selectedOfferId = state.offers[0].offer_id;
|
||||
|
||||
@ -2,22 +2,10 @@
|
||||
"catalog_id": "launch-2026-operator",
|
||||
"offers": [
|
||||
{
|
||||
"offer_id": "edut.governance.core",
|
||||
"title": "EDUT Governance Core",
|
||||
"summary": "First paid runtime license. Activates deterministic governance engine on entitled devices.",
|
||||
"price": "499.00",
|
||||
"currency": "USDC",
|
||||
"member_only": true,
|
||||
"workspace_bound": false,
|
||||
"transferable": false,
|
||||
"internal_use_only": true,
|
||||
"multi_tenant": false
|
||||
},
|
||||
{
|
||||
"offer_id": "edut.crm.pro.annual",
|
||||
"title": "EDUT CRM Pro",
|
||||
"summary": "Workspace-bound CRM module with governance and evidence integration.",
|
||||
"price": "199.00",
|
||||
"offer_id": "edut.solo.core",
|
||||
"title": "EDUT Solo Core",
|
||||
"summary": "Single-principal governance runtime for personal operations.",
|
||||
"price": "1000.00",
|
||||
"currency": "USDC",
|
||||
"member_only": true,
|
||||
"workspace_bound": true,
|
||||
@ -26,10 +14,46 @@
|
||||
"multi_tenant": false
|
||||
},
|
||||
{
|
||||
"offer_id": "edut.invoicing.core.annual",
|
||||
"title": "EDUT Invoicing Core",
|
||||
"summary": "Invoicing workflow module for member workspaces.",
|
||||
"price": "99.00",
|
||||
"offer_id": "edut.workspace.core",
|
||||
"title": "EDUT Workspace Core",
|
||||
"summary": "Org-bound deterministic governance runtime for team operations.",
|
||||
"price": "1000.00",
|
||||
"currency": "USDC",
|
||||
"member_only": true,
|
||||
"workspace_bound": true,
|
||||
"transferable": false,
|
||||
"internal_use_only": true,
|
||||
"multi_tenant": false
|
||||
},
|
||||
{
|
||||
"offer_id": "edut.workspace.ai",
|
||||
"title": "EDUT Workspace AI Layer",
|
||||
"summary": "AI reasoning layer for governed workspace operations.",
|
||||
"price": "1000.00",
|
||||
"currency": "USDC",
|
||||
"member_only": true,
|
||||
"workspace_bound": true,
|
||||
"transferable": false,
|
||||
"internal_use_only": true,
|
||||
"multi_tenant": false
|
||||
},
|
||||
{
|
||||
"offer_id": "edut.workspace.lane24",
|
||||
"title": "EDUT Workspace 24h Lane",
|
||||
"summary": "Autonomous execution lane capacity for workspace queue throughput.",
|
||||
"price": "1000.00",
|
||||
"currency": "USDC",
|
||||
"member_only": true,
|
||||
"workspace_bound": true,
|
||||
"transferable": false,
|
||||
"internal_use_only": true,
|
||||
"multi_tenant": false
|
||||
},
|
||||
{
|
||||
"offer_id": "edut.workspace.sovereign",
|
||||
"title": "EDUT Workspace Sovereign Continuity",
|
||||
"summary": "Stronger local/offline continuity profile for governed workspaces.",
|
||||
"price": "1000.00",
|
||||
"currency": "USDC",
|
||||
"member_only": true,
|
||||
"workspace_bound": true,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user