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`.
## 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
### Core

View File

@ -121,17 +121,18 @@ func (a *app) handleWalletIntent(w http.ResponseWriter, r *http.Request) {
expiresAt := issuedAt.Add(a.cfg.IntentTTL)
record := designationRecord{
Code: code,
DisplayToken: displayToken,
IntentID: intentID,
Nonce: nonce,
Origin: strings.TrimSpace(req.Origin),
Locale: strings.TrimSpace(req.Locale),
Address: address,
ChainID: req.ChainID,
IssuedAt: issuedAt,
ExpiresAt: expiresAt,
MembershipStatus: "none",
Code: code,
DisplayToken: displayToken,
IntentID: intentID,
Nonce: nonce,
Origin: strings.TrimSpace(req.Origin),
Locale: strings.TrimSpace(req.Locale),
Address: address,
ChainID: req.ChainID,
IssuedAt: issuedAt,
ExpiresAt: expiresAt,
MembershipStatus: "none",
IdentityAssurance: assuranceNone,
}
if record.Origin == "" {
record.Origin = "https://edut.ai"
@ -438,9 +439,18 @@ func (a *app) handleMembershipConfirm(w http.ResponseWriter, r *http.Request) {
return
}
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.MembershipTxHash = strings.ToLower(req.TxHash)
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 {
writeError(w, http.StatusInternalServerError, "failed to persist membership activation")
return
@ -454,11 +464,14 @@ func (a *app) handleMembershipConfirm(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, membershipConfirmResponse{
Status: "membership_active",
DesignationCode: rec.Code,
DisplayToken: rec.DisplayToken,
TxHash: strings.ToLower(req.TxHash),
ActivatedAt: now.Format(time.RFC3339Nano),
Status: "membership_active",
DesignationCode: rec.Code,
DisplayToken: rec.DisplayToken,
TxHash: strings.ToLower(req.TxHash),
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"
}
writeJSON(w, http.StatusOK, membershipStatusResponse{
Status: status,
Wallet: rec.Address,
DesignationCode: rec.Code,
Status: status,
Wallet: rec.Address,
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")
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)
if err != nil {
@ -734,16 +754,19 @@ func (a *app) handleGovernanceInstallStatus(w http.ResponseWriter, r *http.Reque
}
membershipStatus := "none"
identityAssurance := assuranceNone
if rec, err := a.store.getDesignationByAddress(r.Context(), wallet); err == nil {
membershipStatus = strings.ToLower(strings.TrimSpace(rec.MembershipStatus))
if membershipStatus == "" {
membershipStatus = "none"
}
identityAssurance = normalizeAssuranceLevel(rec.IdentityAssurance)
}
resp := governanceInstallStatusResponse{
Wallet: wallet,
MembershipStatus: membershipStatus,
IdentityAssurance: identityAssurance,
EntitlementStatus: "unknown",
AccessClass: "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")
return
}
if !isOnrampAttested(rec.IdentityAssurance) {
writeErrorCode(w, http.StatusForbidden, "identity_assurance_insufficient", "owner support actions require onramp_attested identity assurance")
return
}
role := ""
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) {
a, cfg, cleanup := newTestApp(t)
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) {
a, _, cleanup := newTestApp(t)
defer cleanup()
@ -891,22 +1054,37 @@ func newTestApp(t *testing.T) (*app, Config, func()) {
}
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()
assurance = normalizeAssuranceLevel(assurance)
attestedBy := ""
var attestedAt *time.Time
if assurance == assuranceOnrampAttested {
attestedBy = "test-onramp"
attestedAt = &now
}
return st.putDesignation(ctx, designationRecord{
Code: "1234567890123",
DisplayToken: "1234-5678-9012-3",
IntentID: "intent-seeded",
Nonce: "nonce-seeded",
Origin: "https://edut.ai",
Locale: "en",
Address: wallet,
ChainID: 84532,
IssuedAt: now.Add(-1 * time.Hour),
ExpiresAt: now.Add(1 * time.Hour),
VerifiedAt: &now,
MembershipStatus: "active",
MembershipTxHash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
ActivatedAt: &now,
Code: "1234567890123",
DisplayToken: "1234-5678-9012-3",
IntentID: "intent-seeded",
Nonce: "nonce-seeded",
Origin: "https://edut.ai",
Locale: "en",
Address: wallet,
ChainID: 84532,
IssuedAt: now.Add(-1 * time.Hour),
ExpiresAt: now.Add(1 * time.Hour),
VerifiedAt: &now,
MembershipStatus: "active",
MembershipTxHash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
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 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")
return
}
@ -686,7 +690,7 @@ func (a *app) resolveMembershipStatusForWallet(r *http.Request, wallet string) (
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)
if err != nil {
if !errors.Is(err, errNotFound) {
@ -706,24 +710,26 @@ func (a *app) ensureMembershipActiveForWallet(ctx context.Context, wallet, txHas
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",
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",
IdentityAssurance: assuranceNone,
}
}
now := time.Now().UTC()
rec.MembershipStatus = "active"
rec.MembershipTxHash = strings.ToLower(strings.TrimSpace(txHash))
rec.ActivatedAt = &now
rec.IdentityAssurance = normalizeAssuranceLevel(assurance)
return a.store.putDesignation(ctx, rec)
}

View File

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

View File

@ -53,7 +53,11 @@ func (s *store) migrate(ctx context.Context) error {
verified_at TEXT,
membership_status TEXT NOT NULL DEFAULT 'none',
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_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 {
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 {
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 {
_, err := s.db.ExecContext(ctx, `
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
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(code) DO UPDATE SET
display_token=excluded.display_token,
intent_id=excluded.intent_id,
@ -303,14 +319,18 @@ func (s *store) putDesignation(ctx context.Context, rec designationRecord) error
verified_at=excluded.verified_at,
membership_status=excluded.membership_status,
membership_tx_hash=excluded.membership_tx_hash,
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))
activated_at=excluded.activated_at,
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
}
func (s *store) getDesignationByIntent(ctx context.Context, intentID string) (designationRecord, error) {
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
WHERE intent_id = ?
`, 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) {
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
WHERE 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) {
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
WHERE address = ?
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) {
var rec designationRecord
var issued, expires, verified, activated sql.NullString
var membershipTx 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)
var issued, expires, verified, activated, identityAttestedAt 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, &identityAssurance, &identityAttestedBy, &identityAttestationID, &identityAttestedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return designationRecord{}, errNotFound
@ -353,6 +373,10 @@ func scanDesignation(row interface{ Scan(dest ...any) error }) (designationRecor
rec.VerifiedAt = parseRFC3339Ptr(verified)
rec.MembershipTxHash = membershipTx.String
rec.ActivatedAt = parseRFC3339Ptr(activated)
rec.IdentityAssurance = normalizeAssuranceLevel(identityAssurance.String)
rec.IdentityAttestedBy = identityAttestedBy.String
rec.IdentityAttestationID = identityAttestationID.String
rec.IdentityAttestedAt = parseRFC3339Ptr(identityAttestedAt)
return rec, nil
}

View File

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

View File

@ -145,7 +145,10 @@ Request:
"quote_id": "mq_01HZZX4F8VQXJ6A57R8P3SCB2W",
"tx_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"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",
"display_token": "0217-0730-4548-2",
"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",
"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:
- wallet
- membership_status
- identity_assurance_level
- entitlement_status
- activation_status
properties:
@ -275,6 +276,9 @@ components:
membership_status:
type: string
enum: [active, none, suspended, revoked, unknown]
identity_assurance_level:
type: string
enum: [none, crypto_direct_unattested, sponsored_unattested, onramp_attested]
entitlement_status:
type: string
enum: [active, none, suspended, revoked, unknown]

View File

@ -234,9 +234,18 @@ components:
pattern: '^0x[a-fA-F0-9]{40}$'
chain_id:
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:
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:
status:
type: string
@ -250,6 +259,13 @@ components:
activated_at:
type: string
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:
type: object
required: [status]
@ -261,3 +277,10 @@ components:
type: string
designation_code:
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