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

@ -132,6 +132,7 @@ func (a *app) handleWalletIntent(w http.ResponseWriter, r *http.Request) {
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
@ -459,6 +469,9 @@ func (a *app) handleMembershipConfirm(w http.ResponseWriter, r *http.Request) {
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,
}) })
} }
@ -506,6 +519,9 @@ func (a *app) handleMembershipStatus(w http.ResponseWriter, r *http.Request) {
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,7 +1054,18 @@ 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",
@ -907,6 +1081,10 @@ func seedActiveMembership(ctx context.Context, st *store, wallet string) error {
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) {
@ -718,12 +722,14 @@ func (a *app) ensureMembershipActiveForWallet(ctx context.Context, wallet, txHas
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

@ -68,6 +68,9 @@ type membershipConfirmRequest struct {
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 {
@ -76,12 +79,18 @@ type membershipConfirmResponse struct {
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 {
@ -99,6 +108,10 @@ type designationRecord struct {
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