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_DOMAIN_NAME=EDUT Designation
|
||||||
SECRET_API_VERIFYING_CONTRACT=0x0000000000000000000000000000000000000000
|
SECRET_API_VERIFYING_CONTRACT=0x0000000000000000000000000000000000000000
|
||||||
SECRET_API_MEMBERSHIP_CONTRACT=0x0000000000000000000000000000000000000000
|
SECRET_API_MEMBERSHIP_CONTRACT=0x0000000000000000000000000000000000000000
|
||||||
SECRET_API_MINT_CURRENCY=ETH
|
SECRET_API_MINT_CURRENCY=USDC
|
||||||
SECRET_API_MINT_AMOUNT_ATOMIC=5000000000000000
|
SECRET_API_MINT_AMOUNT_ATOMIC=100000000
|
||||||
SECRET_API_MINT_DECIMALS=18
|
SECRET_API_MINT_DECIMALS=6
|
||||||
|
|
||||||
SECRET_API_INSTALL_TOKEN_TTL_SECONDS=900
|
SECRET_API_INSTALL_TOKEN_TTL_SECONDS=900
|
||||||
SECRET_API_LEASE_TTL_SECONDS=3600
|
SECRET_API_LEASE_TTL_SECONDS=3600
|
||||||
|
|||||||
@ -86,9 +86,9 @@ Company-first sponsor path is also supported:
|
|||||||
- `SECRET_API_DOMAIN_NAME`
|
- `SECRET_API_DOMAIN_NAME`
|
||||||
- `SECRET_API_VERIFYING_CONTRACT`
|
- `SECRET_API_VERIFYING_CONTRACT`
|
||||||
- `SECRET_API_MEMBERSHIP_CONTRACT`
|
- `SECRET_API_MEMBERSHIP_CONTRACT`
|
||||||
- `SECRET_API_MINT_CURRENCY` (default `ETH`)
|
- `SECRET_API_MINT_CURRENCY` (default `USDC`)
|
||||||
- `SECRET_API_MINT_AMOUNT_ATOMIC` (default `5000000000000000`)
|
- `SECRET_API_MINT_AMOUNT_ATOMIC` (default `100000000`)
|
||||||
- `SECRET_API_MINT_DECIMALS` (default `18`)
|
- `SECRET_API_MINT_DECIMALS` (default `6`)
|
||||||
|
|
||||||
### Governance install
|
### 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/quote", a.withCORS(a.handleMembershipQuote))
|
||||||
mux.HandleFunc("/secret/membership/confirm", a.withCORS(a.handleMembershipConfirm))
|
mux.HandleFunc("/secret/membership/confirm", a.withCORS(a.handleMembershipConfirm))
|
||||||
mux.HandleFunc("/secret/membership/status", a.withCORS(a.handleMembershipStatus))
|
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/token", a.withCORS(a.handleGovernanceInstallToken))
|
||||||
mux.HandleFunc("/governance/install/confirm", a.withCORS(a.handleGovernanceInstallConfirm))
|
mux.HandleFunc("/governance/install/confirm", a.withCORS(a.handleGovernanceInstallConfirm))
|
||||||
mux.HandleFunc("/governance/install/status", a.withCORS(a.handleGovernanceInstallStatus))
|
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")
|
writeErrorCode(w, http.StatusForbidden, "sponsor_not_authorized", "sponsor entitlement is not active")
|
||||||
return
|
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"
|
sponsorshipMode = "sponsored_company"
|
||||||
} else {
|
} else {
|
||||||
writeError(w, http.StatusForbidden, "distinct payer requires ownership proof")
|
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")
|
writeErrorCode(w, http.StatusForbidden, "entitlement_inactive", "entitlement inactive")
|
||||||
return
|
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)
|
renewedUntil := time.Now().UTC().Add(a.cfg.OfflineRenewTTL)
|
||||||
principal.AccessClass = "sovereign"
|
principal.AccessClass = "sovereign"
|
||||||
principal.AvailabilityState = "active"
|
principal.AvailabilityState = "active"
|
||||||
@ -1267,8 +1295,8 @@ func (a *app) resolveOrCreatePrincipal(ctx context.Context, wallet, orgRootID, p
|
|||||||
OrgRootID: orgRootID,
|
OrgRootID: orgRootID,
|
||||||
PrincipalID: principalID,
|
PrincipalID: principalID,
|
||||||
PrincipalRole: principalRole,
|
PrincipalRole: principalRole,
|
||||||
EntitlementID: "gov_" + wallet[2:10],
|
EntitlementID: "",
|
||||||
EntitlementStatus: "active",
|
EntitlementStatus: "inactive",
|
||||||
AccessClass: "connected",
|
AccessClass: "connected",
|
||||||
AvailabilityState: "active",
|
AvailabilityState: "active",
|
||||||
LeaseExpiresAt: &leaseExpires,
|
LeaseExpiresAt: &leaseExpires,
|
||||||
|
|||||||
@ -112,6 +112,23 @@ func TestMembershipCompanySponsorWithoutOwnerProof(t *testing.T) {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("seed governance principal: %v", err)
|
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{
|
intentRes := postJSONExpect[tWalletIntentResponse](t, a, "/secret/wallet/intent", walletIntentRequest{
|
||||||
Address: ownerAddr,
|
Address: ownerAddr,
|
||||||
@ -227,6 +244,37 @@ func TestGovernanceInstallOwnerOnlyAndConfirm(t *testing.T) {
|
|||||||
if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil {
|
if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil {
|
||||||
t.Fatalf("seed membership: %v", err)
|
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{
|
tokenRes := postJSONExpect[governanceInstallTokenResponse](t, a, "/governance/install/token", governanceInstallTokenRequest{
|
||||||
Wallet: ownerAddr,
|
Wallet: ownerAddr,
|
||||||
@ -289,6 +337,54 @@ func TestGovernanceLeaseAndOfflineRenew(t *testing.T) {
|
|||||||
if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil {
|
if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil {
|
||||||
t.Fatalf("seed membership: %v", err)
|
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{
|
_ = postJSONExpect[governanceInstallTokenResponse](t, a, "/governance/install/token", governanceInstallTokenRequest{
|
||||||
Wallet: ownerAddr,
|
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 {
|
type tWalletIntentResponse struct {
|
||||||
IntentID string `json:"intent_id"`
|
IntentID string `json:"intent_id"`
|
||||||
DesignationCode string `json:"designation_code"`
|
DesignationCode string `json:"designation_code"`
|
||||||
|
|||||||
@ -51,9 +51,9 @@ func loadConfig() Config {
|
|||||||
DomainName: env("SECRET_API_DOMAIN_NAME", "EDUT Designation"),
|
DomainName: env("SECRET_API_DOMAIN_NAME", "EDUT Designation"),
|
||||||
VerifyingContract: strings.ToLower(env("SECRET_API_VERIFYING_CONTRACT", "0x0000000000000000000000000000000000000000")),
|
VerifyingContract: strings.ToLower(env("SECRET_API_VERIFYING_CONTRACT", "0x0000000000000000000000000000000000000000")),
|
||||||
MembershipContract: strings.ToLower(env("SECRET_API_MEMBERSHIP_CONTRACT", "0x0000000000000000000000000000000000000000")),
|
MembershipContract: strings.ToLower(env("SECRET_API_MEMBERSHIP_CONTRACT", "0x0000000000000000000000000000000000000000")),
|
||||||
MintCurrency: strings.ToUpper(env("SECRET_API_MINT_CURRENCY", "ETH")),
|
MintCurrency: strings.ToUpper(env("SECRET_API_MINT_CURRENCY", "USDC")),
|
||||||
MintAmountAtomic: env("SECRET_API_MINT_AMOUNT_ATOMIC", "5000000000000000"),
|
MintAmountAtomic: env("SECRET_API_MINT_AMOUNT_ATOMIC", "100000000"),
|
||||||
MintDecimals: envInt("SECRET_API_MINT_DECIMALS", 18),
|
MintDecimals: envInt("SECRET_API_MINT_DECIMALS", 6),
|
||||||
ChainRPCURL: env("SECRET_API_CHAIN_RPC_URL", ""),
|
ChainRPCURL: env("SECRET_API_CHAIN_RPC_URL", ""),
|
||||||
RequireOnchainTxVerify: envBool("SECRET_API_REQUIRE_ONCHAIN_TX_VERIFICATION", false),
|
RequireOnchainTxVerify: envBool("SECRET_API_REQUIRE_ONCHAIN_TX_VERIFICATION", false),
|
||||||
GovernanceRuntimeVersion: env("SECRET_API_GOV_RUNTIME_VERSION", "0.1.0"),
|
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)
|
FOREIGN KEY(designation_code) REFERENCES designations(code)
|
||||||
);`,
|
);`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_quotes_designation ON quotes(designation_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 (
|
`CREATE TABLE IF NOT EXISTS governance_principals (
|
||||||
wallet TEXT PRIMARY KEY,
|
wallet TEXT PRIMARY KEY,
|
||||||
org_root_id TEXT NOT NULL,
|
org_root_id TEXT NOT NULL,
|
||||||
@ -373,6 +415,259 @@ func (s *store) getQuote(ctx context.Context, quoteID string) (quoteRecord, erro
|
|||||||
return rec, nil
|
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 {
|
func (s *store) putGovernancePrincipal(ctx context.Context, rec governancePrincipalRecord) error {
|
||||||
_, err := s.db.ExecContext(ctx, `
|
_, err := s.db.ExecContext(ctx, `
|
||||||
INSERT INTO governance_principals (
|
INSERT INTO governance_principals (
|
||||||
|
|||||||
@ -8,14 +8,14 @@ Success (`200`):
|
|||||||
{
|
{
|
||||||
"offers": [
|
"offers": [
|
||||||
{
|
{
|
||||||
"offer_id": "edut.governance.core",
|
"offer_id": "edut.workspace.core",
|
||||||
"issuer_id": "edut.firstparty",
|
"issuer_id": "edut.firstparty",
|
||||||
"title": "EDUT Governance Core",
|
"title": "EDUT Workspace Core",
|
||||||
"summary": "First paid runtime license.",
|
"summary": "Org-bound deterministic governance runtime.",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"pricing": {
|
"pricing": {
|
||||||
"currency": "USDC",
|
"currency": "USDC",
|
||||||
"amount_atomic": "499000000",
|
"amount_atomic": "1000000000",
|
||||||
"decimals": 6,
|
"decimals": 6,
|
||||||
"chain_id": 8453
|
"chain_id": 8453
|
||||||
},
|
},
|
||||||
@ -39,7 +39,7 @@ Request:
|
|||||||
{
|
{
|
||||||
"wallet": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5",
|
"wallet": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5",
|
||||||
"payer_wallet": "0x2299547f6fA9A8f9b6d9aEA9F9D8A4B53C8A0e11",
|
"payer_wallet": "0x2299547f6fA9A8f9b6d9aEA9F9D8A4B53C8A0e11",
|
||||||
"offer_id": "edut.governance.core",
|
"offer_id": "edut.workspace.core",
|
||||||
"org_root_id": "org.acme.root",
|
"org_root_id": "org.acme.root",
|
||||||
"principal_id": "human.joshua",
|
"principal_id": "human.joshua",
|
||||||
"principal_role": "org_root_owner",
|
"principal_role": "org_root_owner",
|
||||||
@ -55,31 +55,31 @@ Success (`200`):
|
|||||||
"quote_id": "cq_01HZZXFQ27ZP6MP0V2R9M6V3KX",
|
"quote_id": "cq_01HZZXFQ27ZP6MP0V2R9M6V3KX",
|
||||||
"wallet": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5",
|
"wallet": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5",
|
||||||
"payer_wallet": "0x2299547f6fA9A8f9b6d9aEA9F9D8A4B53C8A0e11",
|
"payer_wallet": "0x2299547f6fA9A8f9b6d9aEA9F9D8A4B53C8A0e11",
|
||||||
"offer_id": "edut.governance.core",
|
"offer_id": "edut.workspace.core",
|
||||||
"org_root_id": "org.acme.root",
|
"org_root_id": "org.acme.root",
|
||||||
"principal_id": "human.joshua",
|
"principal_id": "human.joshua",
|
||||||
"principal_role": "org_root_owner",
|
"principal_role": "org_root_owner",
|
||||||
"currency": "USDC",
|
"currency": "USDC",
|
||||||
"amount": "500.00",
|
"amount": "1000.00",
|
||||||
"amount_atomic": "500000000",
|
"amount_atomic": "1000000000",
|
||||||
"total_amount": "505.00",
|
"total_amount": "1100.00",
|
||||||
"total_amount_atomic": "505000000",
|
"total_amount_atomic": "1100000000",
|
||||||
"decimals": 6,
|
"decimals": 6,
|
||||||
"membership_activation_included": true,
|
"membership_activation_included": true,
|
||||||
"line_items": [
|
"line_items": [
|
||||||
{
|
{
|
||||||
"kind": "license",
|
"kind": "license",
|
||||||
"label": "Governance Core License",
|
"label": "EDUT Workspace Core",
|
||||||
"amount": "500.00",
|
"amount": "1000.00",
|
||||||
"amount_atomic": "500000000",
|
"amount_atomic": "1000000000",
|
||||||
"decimals": 6,
|
"decimals": 6,
|
||||||
"currency": "USDC"
|
"currency": "USDC"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"kind": "membership",
|
"kind": "membership",
|
||||||
"label": "EDUT Membership Activation",
|
"label": "EDUT Membership Activation",
|
||||||
"amount": "5.00",
|
"amount": "100.00",
|
||||||
"amount_atomic": "5000000",
|
"amount_atomic": "100000000",
|
||||||
"decimals": 6,
|
"decimals": 6,
|
||||||
"currency": "USDC"
|
"currency": "USDC"
|
||||||
}
|
}
|
||||||
@ -120,7 +120,7 @@ Request:
|
|||||||
"quote_id": "cq_01HZZXFQ27ZP6MP0V2R9M6V3KX",
|
"quote_id": "cq_01HZZXFQ27ZP6MP0V2R9M6V3KX",
|
||||||
"wallet": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5",
|
"wallet": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5",
|
||||||
"payer_wallet": "0x2299547f6fA9A8f9b6d9aEA9F9D8A4B53C8A0e11",
|
"payer_wallet": "0x2299547f6fA9A8f9b6d9aEA9F9D8A4B53C8A0e11",
|
||||||
"offer_id": "edut.governance.core",
|
"offer_id": "edut.workspace.core",
|
||||||
"org_root_id": "org.acme.root",
|
"org_root_id": "org.acme.root",
|
||||||
"principal_id": "human.joshua",
|
"principal_id": "human.joshua",
|
||||||
"principal_role": "org_root_owner",
|
"principal_role": "org_root_owner",
|
||||||
@ -136,7 +136,7 @@ Success (`200`):
|
|||||||
{
|
{
|
||||||
"status": "entitlement_active",
|
"status": "entitlement_active",
|
||||||
"entitlement_id": "ent:8453:0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5:000001",
|
"entitlement_id": "ent:8453:0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5:000001",
|
||||||
"offer_id": "edut.governance.core",
|
"offer_id": "edut.workspace.core",
|
||||||
"org_root_id": "org.acme.root",
|
"org_root_id": "org.acme.root",
|
||||||
"principal_id": "human.joshua",
|
"principal_id": "human.joshua",
|
||||||
"principal_role": "org_root_owner",
|
"principal_role": "org_root_owner",
|
||||||
@ -158,7 +158,7 @@ Success (`200`):
|
|||||||
"entitlements": [
|
"entitlements": [
|
||||||
{
|
{
|
||||||
"entitlement_id": "ent:8453:0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5:000001",
|
"entitlement_id": "ent:8453:0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5:000001",
|
||||||
"offer_id": "edut.crm.pro.annual",
|
"offer_id": "edut.workspace.core",
|
||||||
"wallet_address": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5",
|
"wallet_address": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5",
|
||||||
"workspace_id": "workspace.work.acme",
|
"workspace_id": "workspace.work.acme",
|
||||||
"org_root_id": "org.acme.root",
|
"org_root_id": "org.acme.root",
|
||||||
|
|||||||
@ -95,8 +95,8 @@ Success (`200`):
|
|||||||
"quote_id": "mq_01HZZX4F8VQXJ6A57R8P3SCB2W",
|
"quote_id": "mq_01HZZX4F8VQXJ6A57R8P3SCB2W",
|
||||||
"chain_id": 8453,
|
"chain_id": 8453,
|
||||||
"currency": "USDC",
|
"currency": "USDC",
|
||||||
"amount": "5.00",
|
"amount": "100.00",
|
||||||
"amount_atomic": "5000000",
|
"amount_atomic": "100000000",
|
||||||
"decimals": 6,
|
"decimals": 6,
|
||||||
"deadline": "2026-02-17T07:36:12Z",
|
"deadline": "2026-02-17T07:36:12Z",
|
||||||
"contract_address": "0x1111111111111111111111111111111111111111",
|
"contract_address": "0x1111111111111111111111111111111111111111",
|
||||||
|
|||||||
@ -4,7 +4,7 @@ This policy defines deterministic rules for membership mint pricing.
|
|||||||
|
|
||||||
## Policy Objectives
|
## Policy Objectives
|
||||||
|
|
||||||
1. Keep onboarding friction low.
|
1. Keep onboarding deterministic and explicit.
|
||||||
2. Guarantee mint-cost coverage.
|
2. Guarantee mint-cost coverage.
|
||||||
3. Support configurable growth tiers.
|
3. Support configurable growth tiers.
|
||||||
4. Keep pricing behavior transparent and auditable.
|
4. Keep pricing behavior transparent and auditable.
|
||||||
@ -13,35 +13,22 @@ This policy defines deterministic rules for membership mint pricing.
|
|||||||
|
|
||||||
1. Membership is paid.
|
1. Membership is paid.
|
||||||
2. Membership is required for marketplace purchases.
|
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
|
## Floor Rule
|
||||||
|
|
||||||
1. Default floor target: USD 5.00 equivalent.
|
1. Launch membership price: `100.00 USDC` (`100000000` atomic with 6 decimals).
|
||||||
2. Effective configured price must satisfy:
|
2. No discounts, no bundles, no alternate path pricing.
|
||||||
|
3. Any future change must be owner-governed and event-emitted.
|
||||||
```text
|
|
||||||
configured_price >= max(minimum_floor, estimated_network_cost * safety_multiplier)
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Recommended default `safety_multiplier`: `1.5`.
|
|
||||||
|
|
||||||
## Supported Settlement Currencies (v1)
|
## Supported Settlement Currencies (v1)
|
||||||
|
|
||||||
1. `USDC` on Base (preferred stable settlement).
|
1. `USDC` on Base.
|
||||||
2. `ETH` on Base (optional).
|
|
||||||
|
|
||||||
`BTC` display may be shown as reference only; settlement remains Base-native.
|
## Tier Policy
|
||||||
|
|
||||||
## Tier Policy (Optional)
|
1. Disabled for launch.
|
||||||
|
2. No supply curve, no promotions, no coupons.
|
||||||
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.
|
|
||||||
|
|
||||||
## Quote Lock Requirements
|
## Quote Lock Requirements
|
||||||
|
|
||||||
@ -65,9 +52,8 @@ A wallet confirmation is valid only when tx matches quote values exactly.
|
|||||||
## UX Disclosure Rules
|
## UX Disclosure Rules
|
||||||
|
|
||||||
1. Show exact payable amount before wallet confirmation.
|
1. Show exact payable amount before wallet confirmation.
|
||||||
2. Show currency clearly (`USDC` or `ETH`).
|
2. Show currency clearly (`USDC`).
|
||||||
3. If BTC equivalent is shown, label it `reference only`.
|
3. Never imply investment return or speculative upside.
|
||||||
4. Never imply investment return or speculative upside.
|
|
||||||
|
|
||||||
## Evidence Requirements
|
## Evidence Requirements
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"schema_version": "entitlement.v1",
|
"schema_version": "entitlement.v1",
|
||||||
"entitlement_id": "ent:8453:0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5:000001",
|
"entitlement_id": "ent:8453:0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5:000001",
|
||||||
"offer_id": "edut.crm.pro.annual",
|
"offer_id": "edut.workspace.core",
|
||||||
"issuer_id": "edut.firstparty",
|
"issuer_id": "edut.firstparty",
|
||||||
"wallet_address": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5",
|
"wallet_address": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5",
|
||||||
"workspace_id": "workspace.work.acme",
|
"workspace_id": "workspace.work.acme",
|
||||||
@ -10,7 +10,7 @@
|
|||||||
"tx_hash": "0x1111111111111111111111111111111111111111111111111111111111111111",
|
"tx_hash": "0x1111111111111111111111111111111111111111111111111111111111111111",
|
||||||
"chain_id": 8453,
|
"chain_id": 8453,
|
||||||
"currency": "USDC",
|
"currency": "USDC",
|
||||||
"amount_atomic": "199000000",
|
"amount_atomic": "1000000000",
|
||||||
"policy_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
"policy_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
},
|
},
|
||||||
"issued_at": "2026-02-17T00:02:12Z",
|
"issued_at": "2026-02-17T00:02:12Z",
|
||||||
|
|||||||
@ -3,10 +3,10 @@
|
|||||||
"catalog_id": "launch-2026-operator",
|
"catalog_id": "launch-2026-operator",
|
||||||
"offers": [
|
"offers": [
|
||||||
{
|
{
|
||||||
"offer_id": "edut.governance.core",
|
"offer_id": "edut.solo.core",
|
||||||
"title": "EDUT Governance Core",
|
"title": "EDUT Solo Core",
|
||||||
"summary": "First paid runtime license. Activates deterministic governance runtime.",
|
"summary": "Single-principal governance runtime for personal operations.",
|
||||||
"price": "499.00",
|
"price": "1000.00",
|
||||||
"currency": "USDC",
|
"currency": "USDC",
|
||||||
"member_only": true,
|
"member_only": true,
|
||||||
"workspace_bound": false,
|
"workspace_bound": false,
|
||||||
@ -15,10 +15,10 @@
|
|||||||
"multi_tenant": false
|
"multi_tenant": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"offer_id": "edut.crm.pro.annual",
|
"offer_id": "edut.workspace.core",
|
||||||
"title": "EDUT CRM Pro",
|
"title": "EDUT Workspace Core",
|
||||||
"summary": "Workspace-bound CRM module with governance and evidence integration.",
|
"summary": "Org-bound deterministic governance runtime for team operations.",
|
||||||
"price": "199.00",
|
"price": "1000.00",
|
||||||
"currency": "USDC",
|
"currency": "USDC",
|
||||||
"member_only": true,
|
"member_only": true,
|
||||||
"workspace_bound": true,
|
"workspace_bound": true,
|
||||||
@ -27,10 +27,34 @@
|
|||||||
"multi_tenant": false
|
"multi_tenant": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"offer_id": "edut.invoicing.core.annual",
|
"offer_id": "edut.workspace.ai",
|
||||||
"title": "EDUT Invoicing Core",
|
"title": "EDUT Workspace AI Layer",
|
||||||
"summary": "Invoicing workflow module for member workspaces.",
|
"summary": "AI reasoning layer for governed workspace operations.",
|
||||||
"price": "99.00",
|
"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",
|
"currency": "USDC",
|
||||||
"member_only": true,
|
"member_only": true,
|
||||||
"workspace_bound": true,
|
"workspace_bound": true,
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"schema_version": "offer.v1",
|
"schema_version": "offer.v1",
|
||||||
"offer_id": "edut.crm.pro.annual",
|
"offer_id": "edut.workspace.core",
|
||||||
"issuer_id": "edut.firstparty",
|
"issuer_id": "edut.firstparty",
|
||||||
"title": "EDUT CRM Pro",
|
"title": "EDUT Workspace Core",
|
||||||
"summary": "Workspace-bound CRM module entitlement with annual billing.",
|
"summary": "Org-bound deterministic governance runtime entitlement.",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"pricing": {
|
"pricing": {
|
||||||
"currency": "USDC",
|
"currency": "USDC",
|
||||||
"amount_atomic": "199000000",
|
"amount_atomic": "1000000000",
|
||||||
"decimals": 6,
|
"decimals": 6,
|
||||||
"chain_id": 8453
|
"chain_id": 8453
|
||||||
},
|
},
|
||||||
@ -17,18 +17,18 @@
|
|||||||
"transferable": false,
|
"transferable": false,
|
||||||
"internal_use_only": true,
|
"internal_use_only": true,
|
||||||
"multi_tenant": false,
|
"multi_tenant": false,
|
||||||
"max_per_wallet": 5,
|
"max_per_wallet": 1,
|
||||||
"requires_admin_approval": false
|
"requires_admin_approval": false
|
||||||
},
|
},
|
||||||
"entitlement": {
|
"entitlement": {
|
||||||
"type": "module_license",
|
"type": "runtime_license",
|
||||||
"scope": "workspace",
|
"scope": "org_root",
|
||||||
"runtime_policy_ref": "policy.crm.pro.v1"
|
"runtime_policy_ref": "policy.workspace.core.v1"
|
||||||
},
|
},
|
||||||
"created_at": "2026-02-17T00:00:00Z",
|
"created_at": "2026-02-17T00:00:00Z",
|
||||||
"updated_at": "2026-02-17T00:00:00Z",
|
"updated_at": "2026-02-17T00:00:00Z",
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"category": "business",
|
"category": "governance",
|
||||||
"support_tier": "standard"
|
"support_tier": "zero_ops"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -191,8 +191,8 @@ Response:
|
|||||||
"quote_id": "mq_...",
|
"quote_id": "mq_...",
|
||||||
"chain_id": 8453,
|
"chain_id": 8453,
|
||||||
"currency": "USDC",
|
"currency": "USDC",
|
||||||
"amount": "5.00",
|
"amount": "100.00",
|
||||||
"amount_atomic": "5000000",
|
"amount_atomic": "100000000",
|
||||||
"deadline": "2026-02-17T07:36:12Z",
|
"deadline": "2026-02-17T07:36:12Z",
|
||||||
"contract_address": "0x...",
|
"contract_address": "0x...",
|
||||||
"method": "mintMembership",
|
"method": "mintMembership",
|
||||||
|
|||||||
@ -406,13 +406,13 @@
|
|||||||
setCheckoutLog('Offer catalog loaded: ' + payload.offers.length + ' offers.');
|
setCheckoutLog('Offer catalog loaded: ' + payload.offers.length + ' offers.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
state.offers = [{
|
state.offers = [{
|
||||||
offer_id: 'edut.governance.core',
|
offer_id: 'edut.workspace.core',
|
||||||
title: 'EDUT Governance Core (fallback)',
|
title: 'EDUT Workspace Core (fallback)',
|
||||||
summary: 'Fallback governance offer loaded because catalog fetch failed.',
|
summary: 'Fallback workspace core offer loaded because catalog fetch failed.',
|
||||||
price: '499.00',
|
price: '1000.00',
|
||||||
currency: 'USDC',
|
currency: 'USDC',
|
||||||
member_only: true,
|
member_only: true,
|
||||||
workspace_bound: false,
|
workspace_bound: true,
|
||||||
transferable: false,
|
transferable: false,
|
||||||
}];
|
}];
|
||||||
state.selectedOfferId = state.offers[0].offer_id;
|
state.selectedOfferId = state.offers[0].offer_id;
|
||||||
|
|||||||
@ -2,22 +2,10 @@
|
|||||||
"catalog_id": "launch-2026-operator",
|
"catalog_id": "launch-2026-operator",
|
||||||
"offers": [
|
"offers": [
|
||||||
{
|
{
|
||||||
"offer_id": "edut.governance.core",
|
"offer_id": "edut.solo.core",
|
||||||
"title": "EDUT Governance Core",
|
"title": "EDUT Solo Core",
|
||||||
"summary": "First paid runtime license. Activates deterministic governance engine on entitled devices.",
|
"summary": "Single-principal governance runtime for personal operations.",
|
||||||
"price": "499.00",
|
"price": "1000.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",
|
|
||||||
"currency": "USDC",
|
"currency": "USDC",
|
||||||
"member_only": true,
|
"member_only": true,
|
||||||
"workspace_bound": true,
|
"workspace_bound": true,
|
||||||
@ -26,10 +14,46 @@
|
|||||||
"multi_tenant": false
|
"multi_tenant": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"offer_id": "edut.invoicing.core.annual",
|
"offer_id": "edut.workspace.core",
|
||||||
"title": "EDUT Invoicing Core",
|
"title": "EDUT Workspace Core",
|
||||||
"summary": "Invoicing workflow module for member workspaces.",
|
"summary": "Org-bound deterministic governance runtime for team operations.",
|
||||||
"price": "99.00",
|
"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",
|
"currency": "USDC",
|
||||||
"member_only": true,
|
"member_only": true,
|
||||||
"workspace_bound": true,
|
"workspace_bound": true,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user