Add membership assurance levels and policy gates

This commit is contained in:
Joshua 2026-02-18 14:06:52 -08:00
parent 0696762d24
commit 7f00494456
11 changed files with 461 additions and 89 deletions

View File

@ -75,6 +75,27 @@ Company-first sponsor path is also supported:
- If `sponsor_org_root_id` is provided and the `payer_wallet` is a stored `org_root_owner` principal for that org root with active entitlement status, quote issuance is allowed without `payer_proof`. - If `sponsor_org_root_id` is provided and the `payer_wallet` is a stored `org_root_owner` principal for that org root with active entitlement status, quote issuance is allowed without `payer_proof`.
## Identity Assurance Model
Membership activation and identity assurance are stored as separate facts:
1. `membership_status`
2. `identity_assurance_level`
Assurance levels:
1. `none`
2. `crypto_direct_unattested`
3. `sponsored_unattested`
4. `onramp_attested`
`onramp_attested` can be set during membership confirm only on self-paid quotes and requires `identity_attested_by`.
Policy gates:
1. Store checkout requires active membership.
2. Workspace admin install/support actions require `onramp_attested` assurance.
## Key Environment Variables ## Key Environment Variables
### Core ### Core

View File

@ -121,17 +121,18 @@ func (a *app) handleWalletIntent(w http.ResponseWriter, r *http.Request) {
expiresAt := issuedAt.Add(a.cfg.IntentTTL) expiresAt := issuedAt.Add(a.cfg.IntentTTL)
record := designationRecord{ record := designationRecord{
Code: code, Code: code,
DisplayToken: displayToken, DisplayToken: displayToken,
IntentID: intentID, IntentID: intentID,
Nonce: nonce, Nonce: nonce,
Origin: strings.TrimSpace(req.Origin), Origin: strings.TrimSpace(req.Origin),
Locale: strings.TrimSpace(req.Locale), Locale: strings.TrimSpace(req.Locale),
Address: address, Address: address,
ChainID: req.ChainID, ChainID: req.ChainID,
IssuedAt: issuedAt, IssuedAt: issuedAt,
ExpiresAt: expiresAt, ExpiresAt: expiresAt,
MembershipStatus: "none", MembershipStatus: "none",
IdentityAssurance: assuranceNone,
} }
if record.Origin == "" { if record.Origin == "" {
record.Origin = "https://edut.ai" record.Origin = "https://edut.ai"
@ -438,9 +439,18 @@ func (a *app) handleMembershipConfirm(w http.ResponseWriter, r *http.Request) {
return return
} }
now := time.Now().UTC() now := time.Now().UTC()
identityAssurance, identityAttestedBy, identityAttestationID, identityAttestedAt, assuranceErr := resolveMembershipAssurance(quote, req, now)
if assuranceErr != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_identity_assurance", assuranceErr.Error())
return
}
rec.MembershipStatus = "active" rec.MembershipStatus = "active"
rec.MembershipTxHash = strings.ToLower(req.TxHash) rec.MembershipTxHash = strings.ToLower(req.TxHash)
rec.ActivatedAt = &now rec.ActivatedAt = &now
rec.IdentityAssurance = identityAssurance
rec.IdentityAttestedBy = identityAttestedBy
rec.IdentityAttestationID = identityAttestationID
rec.IdentityAttestedAt = identityAttestedAt
if err := a.store.putDesignation(r.Context(), rec); err != nil { if err := a.store.putDesignation(r.Context(), rec); err != nil {
writeError(w, http.StatusInternalServerError, "failed to persist membership activation") writeError(w, http.StatusInternalServerError, "failed to persist membership activation")
return return
@ -454,11 +464,14 @@ func (a *app) handleMembershipConfirm(w http.ResponseWriter, r *http.Request) {
} }
writeJSON(w, http.StatusOK, membershipConfirmResponse{ writeJSON(w, http.StatusOK, membershipConfirmResponse{
Status: "membership_active", Status: "membership_active",
DesignationCode: rec.Code, DesignationCode: rec.Code,
DisplayToken: rec.DisplayToken, DisplayToken: rec.DisplayToken,
TxHash: strings.ToLower(req.TxHash), TxHash: strings.ToLower(req.TxHash),
ActivatedAt: now.Format(time.RFC3339Nano), ActivatedAt: now.Format(time.RFC3339Nano),
IdentityAssurance: rec.IdentityAssurance,
IdentityAttestedBy: rec.IdentityAttestedBy,
IdentityAttestationID: rec.IdentityAttestationID,
}) })
} }
@ -503,9 +516,12 @@ func (a *app) handleMembershipStatus(w http.ResponseWriter, r *http.Request) {
status = "none" status = "none"
} }
writeJSON(w, http.StatusOK, membershipStatusResponse{ writeJSON(w, http.StatusOK, membershipStatusResponse{
Status: status, Status: status,
Wallet: rec.Address, Wallet: rec.Address,
DesignationCode: rec.Code, DesignationCode: rec.Code,
IdentityAssurance: normalizeAssuranceLevel(rec.IdentityAssurance),
IdentityAttestedBy: strings.TrimSpace(rec.IdentityAttestedBy),
IdentityAttestationID: strings.TrimSpace(rec.IdentityAttestationID),
}) })
} }
@ -542,6 +558,10 @@ func (a *app) handleGovernanceInstallToken(w http.ResponseWriter, r *http.Reques
writeErrorCode(w, http.StatusForbidden, "membership_inactive", "membership not active") writeErrorCode(w, http.StatusForbidden, "membership_inactive", "membership not active")
return return
} }
if !isOnrampAttested(rec.IdentityAssurance) {
writeErrorCode(w, http.StatusForbidden, "identity_assurance_insufficient", "workspace admin operations require onramp_attested identity assurance")
return
}
principal, err := a.resolveOrCreatePrincipal(r.Context(), wallet, req.OrgRootID, req.PrincipalID, req.PrincipalRole) principal, err := a.resolveOrCreatePrincipal(r.Context(), wallet, req.OrgRootID, req.PrincipalID, req.PrincipalRole)
if err != nil { if err != nil {
@ -734,16 +754,19 @@ func (a *app) handleGovernanceInstallStatus(w http.ResponseWriter, r *http.Reque
} }
membershipStatus := "none" membershipStatus := "none"
identityAssurance := assuranceNone
if rec, err := a.store.getDesignationByAddress(r.Context(), wallet); err == nil { if rec, err := a.store.getDesignationByAddress(r.Context(), wallet); err == nil {
membershipStatus = strings.ToLower(strings.TrimSpace(rec.MembershipStatus)) membershipStatus = strings.ToLower(strings.TrimSpace(rec.MembershipStatus))
if membershipStatus == "" { if membershipStatus == "" {
membershipStatus = "none" membershipStatus = "none"
} }
identityAssurance = normalizeAssuranceLevel(rec.IdentityAssurance)
} }
resp := governanceInstallStatusResponse{ resp := governanceInstallStatusResponse{
Wallet: wallet, Wallet: wallet,
MembershipStatus: membershipStatus, MembershipStatus: membershipStatus,
IdentityAssurance: identityAssurance,
EntitlementStatus: "unknown", EntitlementStatus: "unknown",
AccessClass: "unknown", AccessClass: "unknown",
AvailabilityState: "unknown", AvailabilityState: "unknown",
@ -1171,6 +1194,10 @@ func (a *app) handleMemberChannelSupportTicket(w http.ResponseWriter, r *http.Re
writeErrorCode(w, http.StatusForbidden, "membership_inactive", "membership_inactive") writeErrorCode(w, http.StatusForbidden, "membership_inactive", "membership_inactive")
return return
} }
if !isOnrampAttested(rec.IdentityAssurance) {
writeErrorCode(w, http.StatusForbidden, "identity_assurance_insufficient", "owner support actions require onramp_attested identity assurance")
return
}
role := "" role := ""
if principal, principalErr := a.store.getGovernancePrincipal(r.Context(), wallet); principalErr == nil && if principal, principalErr := a.store.getGovernancePrincipal(r.Context(), wallet); principalErr == nil &&

View File

@ -221,6 +221,114 @@ func TestMembershipConfirmRequiresChainRPCWhenStrictVerificationEnabled(t *testi
} }
} }
func TestMembershipConfirmDefaultsToCryptoDirectAssurance(t *testing.T) {
a, cfg, cleanup := newTestApp(t)
defer cleanup()
ownerKey := mustKey(t)
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex())
intentRes := postJSONExpect[tWalletIntentResponse](t, a, "/secret/wallet/intent", walletIntentRequest{
Address: ownerAddr,
Origin: "https://edut.ai",
Locale: "en",
ChainID: cfg.ChainID,
}, http.StatusOK)
issuedAt, err := time.Parse(time.RFC3339Nano, intentRes.IssuedAt)
if err != nil {
t.Fatalf("parse issued_at: %v", err)
}
td := buildTypedData(cfg, designationRecord{
Code: intentRes.DesignationCode,
DisplayToken: intentRes.DisplayToken,
Nonce: intentRes.Nonce,
IssuedAt: issuedAt,
Origin: "https://edut.ai",
})
sig := signTypedData(t, ownerKey, td)
verifyRes := postJSONExpect[tWalletVerifyResponse](t, a, "/secret/wallet/verify", walletVerifyRequest{
IntentID: intentRes.IntentID,
Address: ownerAddr,
ChainID: cfg.ChainID,
Signature: sig,
}, http.StatusOK)
quote := postJSONExpect[membershipQuoteResponse](t, a, "/secret/membership/quote", membershipQuoteRequest{
DesignationCode: verifyRes.DesignationCode,
Address: ownerAddr,
ChainID: cfg.ChainID,
}, http.StatusOK)
confirm := postJSONExpect[membershipConfirmResponse](t, a, "/secret/membership/confirm", membershipConfirmRequest{
DesignationCode: verifyRes.DesignationCode,
QuoteID: quote.QuoteID,
TxHash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
Address: ownerAddr,
ChainID: cfg.ChainID,
}, http.StatusOK)
if confirm.IdentityAssurance != assuranceCryptoDirect {
t.Fatalf("expected %s assurance, got %+v", assuranceCryptoDirect, confirm)
}
status := getJSONExpect[membershipStatusResponse](t, a, "/secret/membership/status?wallet="+ownerAddr, http.StatusOK)
if status.IdentityAssurance != assuranceCryptoDirect {
t.Fatalf("expected status assurance %s, got %+v", assuranceCryptoDirect, status)
}
}
func TestMembershipConfirmAcceptsOnrampAttestationAssurance(t *testing.T) {
a, cfg, cleanup := newTestApp(t)
defer cleanup()
ownerKey := mustKey(t)
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex())
intentRes := postJSONExpect[tWalletIntentResponse](t, a, "/secret/wallet/intent", walletIntentRequest{
Address: ownerAddr,
Origin: "https://edut.ai",
Locale: "en",
ChainID: cfg.ChainID,
}, http.StatusOK)
issuedAt, err := time.Parse(time.RFC3339Nano, intentRes.IssuedAt)
if err != nil {
t.Fatalf("parse issued_at: %v", err)
}
td := buildTypedData(cfg, designationRecord{
Code: intentRes.DesignationCode,
DisplayToken: intentRes.DisplayToken,
Nonce: intentRes.Nonce,
IssuedAt: issuedAt,
Origin: "https://edut.ai",
})
sig := signTypedData(t, ownerKey, td)
verifyRes := postJSONExpect[tWalletVerifyResponse](t, a, "/secret/wallet/verify", walletVerifyRequest{
IntentID: intentRes.IntentID,
Address: ownerAddr,
ChainID: cfg.ChainID,
Signature: sig,
}, http.StatusOK)
quote := postJSONExpect[membershipQuoteResponse](t, a, "/secret/membership/quote", membershipQuoteRequest{
DesignationCode: verifyRes.DesignationCode,
Address: ownerAddr,
ChainID: cfg.ChainID,
}, http.StatusOK)
confirm := postJSONExpect[membershipConfirmResponse](t, a, "/secret/membership/confirm", membershipConfirmRequest{
DesignationCode: verifyRes.DesignationCode,
QuoteID: quote.QuoteID,
TxHash: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
Address: ownerAddr,
ChainID: cfg.ChainID,
IdentityAssurance: assuranceOnrampAttested,
IdentityAttestedBy: "moonpay",
IdentityAttestationID: "onramp-session-123",
}, http.StatusOK)
if confirm.IdentityAssurance != assuranceOnrampAttested || confirm.IdentityAttestedBy != "moonpay" {
t.Fatalf("unexpected attested confirm response: %+v", confirm)
}
}
func TestGovernanceInstallOwnerOnlyAndConfirm(t *testing.T) { func TestGovernanceInstallOwnerOnlyAndConfirm(t *testing.T) {
a, cfg, cleanup := newTestApp(t) a, cfg, cleanup := newTestApp(t)
defer cleanup() defer cleanup()
@ -329,6 +437,61 @@ func TestGovernanceInstallOwnerOnlyAndConfirm(t *testing.T) {
} }
} }
func TestGovernanceInstallTokenRequiresOnrampAssurance(t *testing.T) {
a, _, cleanup := newTestApp(t)
defer cleanup()
ownerKey := mustKey(t)
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex())
if err := seedMembershipWithAssurance(context.Background(), a.store, ownerAddr, assuranceCryptoDirect); err != nil {
t.Fatalf("seed membership: %v", err)
}
now := time.Now().UTC()
if err := a.store.putMarketplaceEntitlement(context.Background(), marketplaceEntitlementRecord{
EntitlementID: "ent:install:workspace-core:assurance",
QuoteID: "seed-q-install-workspace-core-assurance",
OfferID: offerIDWorkspaceCore,
Wallet: ownerAddr,
OrgRootID: "org_root_assurance",
PrincipalID: "principal_owner_assurance",
PrincipalRole: "org_root_owner",
State: "active",
AccessClass: "connected",
AvailabilityState: "active",
PolicyHash: "sha256:testpolicy",
IssuedAt: now,
TxHash: "0xabababababababababababababababababababababababababababababababab",
}); err != nil {
t.Fatalf("seed workspace core entitlement: %v", err)
}
if err := a.store.putGovernancePrincipal(context.Background(), governancePrincipalRecord{
Wallet: ownerAddr,
OrgRootID: "org_root_assurance",
PrincipalID: "principal_owner_assurance",
PrincipalRole: "org_root_owner",
EntitlementID: "ent:install:workspace-core:assurance",
EntitlementStatus: "active",
AccessClass: "connected",
AvailabilityState: "active",
UpdatedAt: now,
}); err != nil {
t.Fatalf("seed owner principal: %v", err)
}
errResp := postJSONExpect[map[string]string](t, a, "/governance/install/token", governanceInstallTokenRequest{
Wallet: ownerAddr,
OrgRootID: "org_root_assurance",
PrincipalID: "principal_owner_assurance",
PrincipalRole: "org_root_owner",
DeviceID: "macstudio-assurance",
LauncherVersion: "0.1.0",
Platform: "macos",
}, http.StatusForbidden)
if code := errResp["code"]; code != "identity_assurance_insufficient" {
t.Fatalf("expected identity_assurance_insufficient, got %+v", errResp)
}
}
func TestGovernanceLeaseAndOfflineRenew(t *testing.T) { func TestGovernanceLeaseAndOfflineRenew(t *testing.T) {
a, _, cleanup := newTestApp(t) a, _, cleanup := newTestApp(t)
defer cleanup() defer cleanup()
@ -891,22 +1054,37 @@ func newTestApp(t *testing.T) (*app, Config, func()) {
} }
func seedActiveMembership(ctx context.Context, st *store, wallet string) error { func seedActiveMembership(ctx context.Context, st *store, wallet string) error {
return seedMembershipWithAssurance(ctx, st, wallet, assuranceOnrampAttested)
}
func seedMembershipWithAssurance(ctx context.Context, st *store, wallet, assurance string) error {
now := time.Now().UTC() now := time.Now().UTC()
assurance = normalizeAssuranceLevel(assurance)
attestedBy := ""
var attestedAt *time.Time
if assurance == assuranceOnrampAttested {
attestedBy = "test-onramp"
attestedAt = &now
}
return st.putDesignation(ctx, designationRecord{ return st.putDesignation(ctx, designationRecord{
Code: "1234567890123", Code: "1234567890123",
DisplayToken: "1234-5678-9012-3", DisplayToken: "1234-5678-9012-3",
IntentID: "intent-seeded", IntentID: "intent-seeded",
Nonce: "nonce-seeded", Nonce: "nonce-seeded",
Origin: "https://edut.ai", Origin: "https://edut.ai",
Locale: "en", Locale: "en",
Address: wallet, Address: wallet,
ChainID: 84532, ChainID: 84532,
IssuedAt: now.Add(-1 * time.Hour), IssuedAt: now.Add(-1 * time.Hour),
ExpiresAt: now.Add(1 * time.Hour), ExpiresAt: now.Add(1 * time.Hour),
VerifiedAt: &now, VerifiedAt: &now,
MembershipStatus: "active", MembershipStatus: "active",
MembershipTxHash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", MembershipTxHash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
ActivatedAt: &now, ActivatedAt: &now,
IdentityAssurance: assurance,
IdentityAttestedBy: attestedBy,
IdentityAttestationID: "test-attestation",
IdentityAttestedAt: attestedAt,
}) })
} }

View File

@ -0,0 +1,65 @@
package main
import (
"fmt"
"strings"
"time"
)
const (
assuranceNone = "none"
assuranceSponsoredUnattested = "sponsored_unattested"
assuranceCryptoDirect = "crypto_direct_unattested"
assuranceOnrampAttested = "onramp_attested"
)
func normalizeAssuranceLevel(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case assuranceNone, assuranceSponsoredUnattested, assuranceCryptoDirect, assuranceOnrampAttested:
return strings.ToLower(strings.TrimSpace(value))
default:
return assuranceNone
}
}
func defaultAssuranceForSponsorshipMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "self":
return assuranceCryptoDirect
case "sponsored", "sponsored_company":
return assuranceSponsoredUnattested
default:
return assuranceNone
}
}
func resolveMembershipAssurance(quote quoteRecord, req membershipConfirmRequest, now time.Time) (string, string, string, *time.Time, error) {
level := defaultAssuranceForSponsorshipMode(quote.SponsorshipMode)
attestedBy := ""
attestationID := ""
var attestedAt *time.Time
requestedLevel := normalizeAssuranceLevel(req.IdentityAssurance)
if requestedLevel != assuranceNone {
if requestedLevel == assuranceOnrampAttested {
if !strings.EqualFold(strings.TrimSpace(quote.SponsorshipMode), "self") {
return "", "", "", nil, fmt.Errorf("onramp_attested assurance requires self-paid quote")
}
attestedBy = strings.TrimSpace(req.IdentityAttestedBy)
attestationID = strings.TrimSpace(req.IdentityAttestationID)
if attestedBy == "" {
return "", "", "", nil, fmt.Errorf("identity_attested_by required for onramp_attested assurance")
}
level = assuranceOnrampAttested
attestedAt = &now
} else if requestedLevel != level {
return "", "", "", nil, fmt.Errorf("identity assurance mismatch for sponsorship mode")
}
}
return level, attestedBy, attestationID, attestedAt, nil
}
func isOnrampAttested(level string) bool {
return strings.EqualFold(strings.TrimSpace(level), assuranceOnrampAttested)
}

View File

@ -581,7 +581,11 @@ func (a *app) handleMarketplaceCheckoutConfirm(w http.ResponseWriter, r *http.Re
} }
if quote.MembershipIncluded { if quote.MembershipIncluded {
if err := a.ensureMembershipActiveForWallet(r.Context(), wallet, quote.ConfirmedTxHash); err != nil { assurance := assuranceCryptoDirect
if strings.TrimSpace(quote.PayerWallet) != "" && !strings.EqualFold(strings.TrimSpace(quote.PayerWallet), wallet) {
assurance = assuranceSponsoredUnattested
}
if err := a.ensureMembershipActiveForWallet(r.Context(), wallet, quote.ConfirmedTxHash, assurance); err != nil {
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to activate membership") writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to activate membership")
return return
} }
@ -686,7 +690,7 @@ func (a *app) resolveMembershipStatusForWallet(r *http.Request, wallet string) (
return status, nil return status, nil
} }
func (a *app) ensureMembershipActiveForWallet(ctx context.Context, wallet, txHash string) error { func (a *app) ensureMembershipActiveForWallet(ctx context.Context, wallet, txHash, assurance string) error {
rec, err := a.store.getDesignationByAddress(ctx, wallet) rec, err := a.store.getDesignationByAddress(ctx, wallet)
if err != nil { if err != nil {
if !errors.Is(err, errNotFound) { if !errors.Is(err, errNotFound) {
@ -706,24 +710,26 @@ func (a *app) ensureMembershipActiveForWallet(ctx context.Context, wallet, txHas
return err return err
} }
rec = designationRecord{ rec = designationRecord{
Code: code, Code: code,
DisplayToken: displayToken, DisplayToken: displayToken,
IntentID: intentID, IntentID: intentID,
Nonce: nonce, Nonce: nonce,
Origin: "edut-launcher", Origin: "edut-launcher",
Locale: "en", Locale: "en",
Address: wallet, Address: wallet,
ChainID: a.cfg.ChainID, ChainID: a.cfg.ChainID,
IssuedAt: now, IssuedAt: now,
ExpiresAt: now.Add(a.cfg.IntentTTL), ExpiresAt: now.Add(a.cfg.IntentTTL),
VerifiedAt: &now, VerifiedAt: &now,
MembershipStatus: "none", MembershipStatus: "none",
IdentityAssurance: assuranceNone,
} }
} }
now := time.Now().UTC() now := time.Now().UTC()
rec.MembershipStatus = "active" rec.MembershipStatus = "active"
rec.MembershipTxHash = strings.ToLower(strings.TrimSpace(txHash)) rec.MembershipTxHash = strings.ToLower(strings.TrimSpace(txHash))
rec.ActivatedAt = &now rec.ActivatedAt = &now
rec.IdentityAssurance = normalizeAssuranceLevel(assurance)
return a.store.putDesignation(ctx, rec) return a.store.putDesignation(ctx, rec)
} }

View File

@ -63,42 +63,55 @@ type membershipQuoteResponse struct {
} }
type membershipConfirmRequest struct { type membershipConfirmRequest struct {
DesignationCode string `json:"designation_code"` DesignationCode string `json:"designation_code"`
QuoteID string `json:"quote_id"` QuoteID string `json:"quote_id"`
TxHash string `json:"tx_hash"` TxHash string `json:"tx_hash"`
Address string `json:"address"` Address string `json:"address"`
ChainID int64 `json:"chain_id"` ChainID int64 `json:"chain_id"`
IdentityAssurance string `json:"identity_assurance_level,omitempty"`
IdentityAttestedBy string `json:"identity_attested_by,omitempty"`
IdentityAttestationID string `json:"identity_attestation_id,omitempty"`
} }
type membershipConfirmResponse struct { type membershipConfirmResponse struct {
Status string `json:"status"` Status string `json:"status"`
DesignationCode string `json:"designation_code"` DesignationCode string `json:"designation_code"`
DisplayToken string `json:"display_token"` DisplayToken string `json:"display_token"`
TxHash string `json:"tx_hash"` TxHash string `json:"tx_hash"`
ActivatedAt string `json:"activated_at"` ActivatedAt string `json:"activated_at"`
IdentityAssurance string `json:"identity_assurance_level"`
IdentityAttestedBy string `json:"identity_attested_by,omitempty"`
IdentityAttestationID string `json:"identity_attestation_id,omitempty"`
} }
type membershipStatusResponse struct { type membershipStatusResponse struct {
Status string `json:"status"` Status string `json:"status"`
Wallet string `json:"wallet,omitempty"` Wallet string `json:"wallet,omitempty"`
DesignationCode string `json:"designation_code,omitempty"` DesignationCode string `json:"designation_code,omitempty"`
IdentityAssurance string `json:"identity_assurance_level,omitempty"`
IdentityAttestedBy string `json:"identity_attested_by,omitempty"`
IdentityAttestationID string `json:"identity_attestation_id,omitempty"`
} }
type designationRecord struct { type designationRecord struct {
Code string Code string
DisplayToken string DisplayToken string
IntentID string IntentID string
Nonce string Nonce string
Origin string Origin string
Locale string Locale string
Address string Address string
ChainID int64 ChainID int64
IssuedAt time.Time IssuedAt time.Time
ExpiresAt time.Time ExpiresAt time.Time
VerifiedAt *time.Time VerifiedAt *time.Time
MembershipStatus string MembershipStatus string
MembershipTxHash string MembershipTxHash string
ActivatedAt *time.Time ActivatedAt *time.Time
IdentityAssurance string
IdentityAttestedBy string
IdentityAttestationID string
IdentityAttestedAt *time.Time
} }
type quoteRecord struct { type quoteRecord struct {
@ -177,6 +190,7 @@ type governanceInstallStatusResponse struct {
PrincipalID string `json:"principal_id,omitempty"` PrincipalID string `json:"principal_id,omitempty"`
PrincipalRole string `json:"principal_role,omitempty"` PrincipalRole string `json:"principal_role,omitempty"`
MembershipStatus string `json:"membership_status"` MembershipStatus string `json:"membership_status"`
IdentityAssurance string `json:"identity_assurance_level"`
EntitlementStatus string `json:"entitlement_status"` EntitlementStatus string `json:"entitlement_status"`
AccessClass string `json:"access_class"` AccessClass string `json:"access_class"`
AvailabilityState string `json:"availability_state"` AvailabilityState string `json:"availability_state"`

View File

@ -53,7 +53,11 @@ func (s *store) migrate(ctx context.Context) error {
verified_at TEXT, verified_at TEXT,
membership_status TEXT NOT NULL DEFAULT 'none', membership_status TEXT NOT NULL DEFAULT 'none',
membership_tx_hash TEXT, membership_tx_hash TEXT,
activated_at TEXT activated_at TEXT,
identity_assurance_level TEXT NOT NULL DEFAULT 'none',
identity_attested_by TEXT,
identity_attestation_id TEXT,
identity_attested_at TEXT
);`, );`,
`CREATE INDEX IF NOT EXISTS idx_designations_intent ON designations(intent_id);`, `CREATE INDEX IF NOT EXISTS idx_designations_intent ON designations(intent_id);`,
`CREATE INDEX IF NOT EXISTS idx_designations_address ON designations(address);`, `CREATE INDEX IF NOT EXISTS idx_designations_address ON designations(address);`,
@ -242,6 +246,18 @@ func (s *store) migrate(ctx context.Context) error {
if err := s.ensureColumn(ctx, "quotes", "sponsor_org_root_id", "TEXT"); err != nil { if err := s.ensureColumn(ctx, "quotes", "sponsor_org_root_id", "TEXT"); err != nil {
return err return err
} }
if err := s.ensureColumn(ctx, "designations", "identity_assurance_level", "TEXT NOT NULL DEFAULT 'none'"); err != nil {
return err
}
if err := s.ensureColumn(ctx, "designations", "identity_attested_by", "TEXT"); err != nil {
return err
}
if err := s.ensureColumn(ctx, "designations", "identity_attestation_id", "TEXT"); err != nil {
return err
}
if err := s.ensureColumn(ctx, "designations", "identity_attested_at", "TEXT"); err != nil {
return err
}
if err := s.ensureColumn(ctx, "marketplace_quotes", "expected_tx_to", "TEXT"); err != nil { if err := s.ensureColumn(ctx, "marketplace_quotes", "expected_tx_to", "TEXT"); err != nil {
return err return err
} }
@ -288,8 +304,8 @@ func (s *store) ensureColumn(ctx context.Context, table, column, columnDef strin
func (s *store) putDesignation(ctx context.Context, rec designationRecord) error { func (s *store) putDesignation(ctx context.Context, rec designationRecord) error {
_, err := s.db.ExecContext(ctx, ` _, err := s.db.ExecContext(ctx, `
INSERT INTO designations ( INSERT INTO designations (
code, display_token, intent_id, nonce, origin, locale, address, chain_id, issued_at, expires_at, verified_at, membership_status, membership_tx_hash, activated_at code, display_token, intent_id, nonce, origin, locale, address, chain_id, issued_at, expires_at, verified_at, membership_status, membership_tx_hash, activated_at, identity_assurance_level, identity_attested_by, identity_attestation_id, identity_attested_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(code) DO UPDATE SET ON CONFLICT(code) DO UPDATE SET
display_token=excluded.display_token, display_token=excluded.display_token,
intent_id=excluded.intent_id, intent_id=excluded.intent_id,
@ -303,14 +319,18 @@ func (s *store) putDesignation(ctx context.Context, rec designationRecord) error
verified_at=excluded.verified_at, verified_at=excluded.verified_at,
membership_status=excluded.membership_status, membership_status=excluded.membership_status,
membership_tx_hash=excluded.membership_tx_hash, membership_tx_hash=excluded.membership_tx_hash,
activated_at=excluded.activated_at activated_at=excluded.activated_at,
`, rec.Code, rec.DisplayToken, rec.IntentID, rec.Nonce, rec.Origin, rec.Locale, rec.Address, rec.ChainID, rec.IssuedAt.Format(time.RFC3339Nano), rec.ExpiresAt.Format(time.RFC3339Nano), formatNullableTime(rec.VerifiedAt), strings.ToLower(rec.MembershipStatus), nullableString(rec.MembershipTxHash), formatNullableTime(rec.ActivatedAt)) identity_assurance_level=excluded.identity_assurance_level,
identity_attested_by=excluded.identity_attested_by,
identity_attestation_id=excluded.identity_attestation_id,
identity_attested_at=excluded.identity_attested_at
`, rec.Code, rec.DisplayToken, rec.IntentID, rec.Nonce, rec.Origin, rec.Locale, rec.Address, rec.ChainID, rec.IssuedAt.Format(time.RFC3339Nano), rec.ExpiresAt.Format(time.RFC3339Nano), formatNullableTime(rec.VerifiedAt), strings.ToLower(rec.MembershipStatus), nullableString(rec.MembershipTxHash), formatNullableTime(rec.ActivatedAt), normalizeAssuranceLevel(rec.IdentityAssurance), nullableString(rec.IdentityAttestedBy), nullableString(rec.IdentityAttestationID), formatNullableTime(rec.IdentityAttestedAt))
return err return err
} }
func (s *store) getDesignationByIntent(ctx context.Context, intentID string) (designationRecord, error) { func (s *store) getDesignationByIntent(ctx context.Context, intentID string) (designationRecord, error) {
row := s.db.QueryRowContext(ctx, ` row := s.db.QueryRowContext(ctx, `
SELECT code, display_token, intent_id, nonce, origin, locale, address, chain_id, issued_at, expires_at, verified_at, membership_status, membership_tx_hash, activated_at SELECT code, display_token, intent_id, nonce, origin, locale, address, chain_id, issued_at, expires_at, verified_at, membership_status, membership_tx_hash, activated_at, identity_assurance_level, identity_attested_by, identity_attestation_id, identity_attested_at
FROM designations FROM designations
WHERE intent_id = ? WHERE intent_id = ?
`, intentID) `, intentID)
@ -319,7 +339,7 @@ func (s *store) getDesignationByIntent(ctx context.Context, intentID string) (de
func (s *store) getDesignationByCode(ctx context.Context, code string) (designationRecord, error) { func (s *store) getDesignationByCode(ctx context.Context, code string) (designationRecord, error) {
row := s.db.QueryRowContext(ctx, ` row := s.db.QueryRowContext(ctx, `
SELECT code, display_token, intent_id, nonce, origin, locale, address, chain_id, issued_at, expires_at, verified_at, membership_status, membership_tx_hash, activated_at SELECT code, display_token, intent_id, nonce, origin, locale, address, chain_id, issued_at, expires_at, verified_at, membership_status, membership_tx_hash, activated_at, identity_assurance_level, identity_attested_by, identity_attestation_id, identity_attested_at
FROM designations FROM designations
WHERE code = ? WHERE code = ?
`, code) `, code)
@ -328,7 +348,7 @@ func (s *store) getDesignationByCode(ctx context.Context, code string) (designat
func (s *store) getDesignationByAddress(ctx context.Context, address string) (designationRecord, error) { func (s *store) getDesignationByAddress(ctx context.Context, address string) (designationRecord, error) {
row := s.db.QueryRowContext(ctx, ` row := s.db.QueryRowContext(ctx, `
SELECT code, display_token, intent_id, nonce, origin, locale, address, chain_id, issued_at, expires_at, verified_at, membership_status, membership_tx_hash, activated_at SELECT code, display_token, intent_id, nonce, origin, locale, address, chain_id, issued_at, expires_at, verified_at, membership_status, membership_tx_hash, activated_at, identity_assurance_level, identity_attested_by, identity_attestation_id, identity_attested_at
FROM designations FROM designations
WHERE address = ? WHERE address = ?
ORDER BY issued_at DESC ORDER BY issued_at DESC
@ -339,9 +359,9 @@ func (s *store) getDesignationByAddress(ctx context.Context, address string) (de
func scanDesignation(row interface{ Scan(dest ...any) error }) (designationRecord, error) { func scanDesignation(row interface{ Scan(dest ...any) error }) (designationRecord, error) {
var rec designationRecord var rec designationRecord
var issued, expires, verified, activated sql.NullString var issued, expires, verified, activated, identityAttestedAt sql.NullString
var membershipTx sql.NullString var membershipTx, identityAssurance, identityAttestedBy, identityAttestationID sql.NullString
err := row.Scan(&rec.Code, &rec.DisplayToken, &rec.IntentID, &rec.Nonce, &rec.Origin, &rec.Locale, &rec.Address, &rec.ChainID, &issued, &expires, &verified, &rec.MembershipStatus, &membershipTx, &activated) err := row.Scan(&rec.Code, &rec.DisplayToken, &rec.IntentID, &rec.Nonce, &rec.Origin, &rec.Locale, &rec.Address, &rec.ChainID, &issued, &expires, &verified, &rec.MembershipStatus, &membershipTx, &activated, &identityAssurance, &identityAttestedBy, &identityAttestationID, &identityAttestedAt)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return designationRecord{}, errNotFound return designationRecord{}, errNotFound
@ -353,6 +373,10 @@ func scanDesignation(row interface{ Scan(dest ...any) error }) (designationRecor
rec.VerifiedAt = parseRFC3339Ptr(verified) rec.VerifiedAt = parseRFC3339Ptr(verified)
rec.MembershipTxHash = membershipTx.String rec.MembershipTxHash = membershipTx.String
rec.ActivatedAt = parseRFC3339Ptr(activated) rec.ActivatedAt = parseRFC3339Ptr(activated)
rec.IdentityAssurance = normalizeAssuranceLevel(identityAssurance.String)
rec.IdentityAttestedBy = identityAttestedBy.String
rec.IdentityAttestationID = identityAttestationID.String
rec.IdentityAttestedAt = parseRFC3339Ptr(identityAttestedAt)
return rec, nil return rec, nil
} }

View File

@ -93,6 +93,7 @@ Authorization: Bearer <wallet-session>
"principal_id": "human.joshua", "principal_id": "human.joshua",
"principal_role": "org_root_owner", "principal_role": "org_root_owner",
"membership_status": "active", "membership_status": "active",
"identity_assurance_level": "onramp_attested",
"entitlement_status": "active", "entitlement_status": "active",
"access_class": "connected", "access_class": "connected",
"availability_state": "active", "availability_state": "active",

View File

@ -145,7 +145,10 @@ Request:
"quote_id": "mq_01HZZX4F8VQXJ6A57R8P3SCB2W", "quote_id": "mq_01HZZX4F8VQXJ6A57R8P3SCB2W",
"tx_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "tx_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"address": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5", "address": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5",
"chain_id": 8453 "chain_id": 8453,
"identity_assurance_level": "onramp_attested",
"identity_attested_by": "moonpay",
"identity_attestation_id": "mp_session_01JAA..."
} }
``` ```
@ -157,7 +160,10 @@ Success (`200`):
"designation_code": "0217073045482", "designation_code": "0217073045482",
"display_token": "0217-0730-4548-2", "display_token": "0217-0730-4548-2",
"tx_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "tx_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"activated_at": "2026-02-17T07:33:09Z" "activated_at": "2026-02-17T07:33:09Z",
"identity_assurance_level": "onramp_attested",
"identity_attested_by": "moonpay",
"identity_attestation_id": "mp_session_01JAA..."
} }
``` ```
@ -183,7 +189,10 @@ Success (`200`):
{ {
"status": "active", "status": "active",
"wallet": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5", "wallet": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5",
"designation_code": "0217073045482" "designation_code": "0217073045482",
"identity_assurance_level": "onramp_attested",
"identity_attested_by": "moonpay",
"identity_attestation_id": "mp_session_01JAA..."
} }
``` ```

View File

@ -260,6 +260,7 @@ components:
required: required:
- wallet - wallet
- membership_status - membership_status
- identity_assurance_level
- entitlement_status - entitlement_status
- activation_status - activation_status
properties: properties:
@ -275,6 +276,9 @@ components:
membership_status: membership_status:
type: string type: string
enum: [active, none, suspended, revoked, unknown] enum: [active, none, suspended, revoked, unknown]
identity_assurance_level:
type: string
enum: [none, crypto_direct_unattested, sponsored_unattested, onramp_attested]
entitlement_status: entitlement_status:
type: string type: string
enum: [active, none, suspended, revoked, unknown] enum: [active, none, suspended, revoked, unknown]

View File

@ -234,9 +234,18 @@ components:
pattern: '^0x[a-fA-F0-9]{40}$' pattern: '^0x[a-fA-F0-9]{40}$'
chain_id: chain_id:
type: integer type: integer
identity_assurance_level:
type: string
enum: [crypto_direct_unattested, sponsored_unattested, onramp_attested]
identity_attested_by:
type: string
description: Optional provider identifier when assurance level is onramp_attested.
identity_attestation_id:
type: string
description: Optional provider attestation reference id.
MembershipConfirmResponse: MembershipConfirmResponse:
type: object type: object
required: [status, designation_code, display_token, tx_hash, activated_at] required: [status, designation_code, display_token, tx_hash, activated_at, identity_assurance_level]
properties: properties:
status: status:
type: string type: string
@ -250,6 +259,13 @@ components:
activated_at: activated_at:
type: string type: string
format: date-time format: date-time
identity_assurance_level:
type: string
enum: [none, crypto_direct_unattested, sponsored_unattested, onramp_attested]
identity_attested_by:
type: string
identity_attestation_id:
type: string
MembershipStatusResponse: MembershipStatusResponse:
type: object type: object
required: [status] required: [status]
@ -261,3 +277,10 @@ components:
type: string type: string
designation_code: designation_code:
type: string type: string
identity_assurance_level:
type: string
enum: [none, crypto_direct_unattested, sponsored_unattested, onramp_attested]
identity_attested_by:
type: string
identity_attestation_id:
type: string