feat: add institutional membership tiers and correct member directory
Corrections per 2026-04 institutional review:
- MLFO reclassified as Global Family Office (was incorrectly labeled central bank)
- BIS Innovation Hub reclassified as Standards Body (does not hold observer seat)
- Added missing entities: ICCC, SAID, PANDA, Order of Hospitallers (XOM)
- Added BRICS founding + expanded member central banks (10 entries)
New institutional tier taxonomy (7 tiers):
sovereign_central_bank, global_family_office, settlement_member,
infrastructure_operator, oversight_judicial, delegated_authority,
standards_body
Backend changes:
- New auth/membership.go: tier types, DefaultTrackForTier mapping,
MembershipStore with DB queries for member directory
- New migration 0017: institutional_members + institutional_member_wallets
tables with seed data for all corrected members
- Updated wallet_auth.go getUserTrack(): now resolves institutional
membership (via wallet junction table) before defaulting to Track 1
- WalletAuthResponse now includes institutional_tier and institution_name
- New REST endpoints: GET /api/v1/membership/{tiers,members,members/:slug}
- Added TrackLabel() helper in featureflags
Frontend changes:
- Added InstitutionalTier type and label map to access.ts
- WalletAccessSession extended with institutionalTier/institutionName
- Navbar getAccessTier() now displays institutional tier label when present
- Session summary shows institution name
Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
This commit is contained in:
213
backend/auth/membership.go
Normal file
213
backend/auth/membership.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// InstitutionalTier represents a DBIS institutional membership tier.
|
||||
// These are the canonical tiers from d-bis.org/members#tiers.
|
||||
type InstitutionalTier string
|
||||
|
||||
const (
|
||||
TierSovereignCentralBank InstitutionalTier = "sovereign_central_bank"
|
||||
TierGlobalFamilyOffice InstitutionalTier = "global_family_office"
|
||||
TierSettlementMember InstitutionalTier = "settlement_member"
|
||||
TierInfrastructureOp InstitutionalTier = "infrastructure_operator"
|
||||
TierOversightJudicial InstitutionalTier = "oversight_judicial"
|
||||
TierDelegatedAuthority InstitutionalTier = "delegated_authority"
|
||||
TierStandardsBody InstitutionalTier = "standards_body"
|
||||
)
|
||||
|
||||
// InstitutionalTierLabel returns the human-readable label for a tier.
|
||||
func InstitutionalTierLabel(t InstitutionalTier) string {
|
||||
switch t {
|
||||
case TierSovereignCentralBank:
|
||||
return "Sovereign Central Bank"
|
||||
case TierGlobalFamilyOffice:
|
||||
return "Global Family Office"
|
||||
case TierSettlementMember:
|
||||
return "Settlement Member"
|
||||
case TierInfrastructureOp:
|
||||
return "Infrastructure Operator"
|
||||
case TierOversightJudicial:
|
||||
return "Oversight & Judicial"
|
||||
case TierDelegatedAuthority:
|
||||
return "Delegated Authority"
|
||||
case TierStandardsBody:
|
||||
return "Standards Body"
|
||||
default:
|
||||
return string(t)
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultTrackForTier maps an institutional membership tier to the default
|
||||
// explorer access track. Higher tracks inherit all lower-track permissions.
|
||||
//
|
||||
// Track 1 — Public explorer (read blocks, txs, basic address)
|
||||
// Track 2 — Enhanced explorer (full address, tokens, tx history, search)
|
||||
// Track 3 — Analytics (flows, bridge analytics, risk, distribution)
|
||||
// Track 4 — Operator (bridge control, validators, protocol config)
|
||||
func DefaultTrackForTier(tier InstitutionalTier) int {
|
||||
switch tier {
|
||||
case TierSovereignCentralBank:
|
||||
return 3 // analytics access; operator granted per-address
|
||||
case TierGlobalFamilyOffice:
|
||||
return 3
|
||||
case TierSettlementMember:
|
||||
return 2
|
||||
case TierInfrastructureOp:
|
||||
return 4
|
||||
case TierOversightJudicial:
|
||||
return 3
|
||||
case TierDelegatedAuthority:
|
||||
return 3
|
||||
case TierStandardsBody:
|
||||
return 2
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
// InstitutionalMember represents an entity in the DBIS member directory.
|
||||
type InstitutionalMember struct {
|
||||
ID int `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Abbreviation string `json:"abbreviation"`
|
||||
Name string `json:"name"`
|
||||
Tier InstitutionalTier `json:"tier"`
|
||||
Description string `json:"description"`
|
||||
Jurisdiction string `json:"jurisdiction,omitempty"`
|
||||
LEI string `json:"lei,omitempty"`
|
||||
Latitude float64 `json:"latitude,omitempty"`
|
||||
Longitude float64 `json:"longitude,omitempty"`
|
||||
MapLabel string `json:"map_label,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
// MembershipStore provides read/write access to the institutional members table.
|
||||
type MembershipStore struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewMembershipStore creates a new MembershipStore.
|
||||
func NewMembershipStore(db *pgxpool.Pool) *MembershipStore {
|
||||
return &MembershipStore{db: db}
|
||||
}
|
||||
|
||||
func isMissingMembershipTableError(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), `relation "institutional_members" does not exist`)
|
||||
}
|
||||
|
||||
// ListMembers returns all active institutional members.
|
||||
func (s *MembershipStore) ListMembers(ctx context.Context) ([]InstitutionalMember, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, slug, abbreviation, name, tier, description,
|
||||
COALESCE(jurisdiction, ''), COALESCE(lei, ''),
|
||||
COALESCE(latitude, 0), COALESCE(longitude, 0),
|
||||
COALESCE(map_label, ''), active
|
||||
FROM institutional_members
|
||||
WHERE active = TRUE
|
||||
ORDER BY tier, name
|
||||
`)
|
||||
if err != nil {
|
||||
if isMissingMembershipTableError(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("list members: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var members []InstitutionalMember
|
||||
for rows.Next() {
|
||||
var m InstitutionalMember
|
||||
if err := rows.Scan(
|
||||
&m.ID, &m.Slug, &m.Abbreviation, &m.Name, &m.Tier, &m.Description,
|
||||
&m.Jurisdiction, &m.LEI, &m.Latitude, &m.Longitude, &m.MapLabel, &m.Active,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan member: %w", err)
|
||||
}
|
||||
members = append(members, m)
|
||||
}
|
||||
return members, rows.Err()
|
||||
}
|
||||
|
||||
// GetMemberBySlug returns a single member by URL slug.
|
||||
func (s *MembershipStore) GetMemberBySlug(ctx context.Context, slug string) (*InstitutionalMember, error) {
|
||||
var m InstitutionalMember
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT id, slug, abbreviation, name, tier, description,
|
||||
COALESCE(jurisdiction, ''), COALESCE(lei, ''),
|
||||
COALESCE(latitude, 0), COALESCE(longitude, 0),
|
||||
COALESCE(map_label, ''), active
|
||||
FROM institutional_members
|
||||
WHERE slug = $1
|
||||
`, slug).Scan(
|
||||
&m.ID, &m.Slug, &m.Abbreviation, &m.Name, &m.Tier, &m.Description,
|
||||
&m.Jurisdiction, &m.LEI, &m.Latitude, &m.Longitude, &m.MapLabel, &m.Active,
|
||||
)
|
||||
if err != nil {
|
||||
if isMissingMembershipTableError(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("get member by slug: %w", err)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// GetMemberByAddress looks up the institutional member linked to a wallet
|
||||
// address via the institutional_member_wallets junction table.
|
||||
func (s *MembershipStore) GetMemberByAddress(ctx context.Context, address string) (*InstitutionalMember, error) {
|
||||
var m InstitutionalMember
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT m.id, m.slug, m.abbreviation, m.name, m.tier, m.description,
|
||||
COALESCE(m.jurisdiction, ''), COALESCE(m.lei, ''),
|
||||
COALESCE(m.latitude, 0), COALESCE(m.longitude, 0),
|
||||
COALESCE(m.map_label, ''), m.active
|
||||
FROM institutional_members m
|
||||
JOIN institutional_member_wallets w ON w.member_id = m.id
|
||||
WHERE LOWER(w.address) = LOWER($1) AND w.active = TRUE AND m.active = TRUE
|
||||
`, address).Scan(
|
||||
&m.ID, &m.Slug, &m.Abbreviation, &m.Name, &m.Tier, &m.Description,
|
||||
&m.Jurisdiction, &m.LEI, &m.Latitude, &m.Longitude, &m.MapLabel, &m.Active,
|
||||
)
|
||||
if err != nil {
|
||||
if isMissingMembershipTableError(err) || strings.Contains(err.Error(), "no rows") {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("get member by address: %w", err)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// ListTiers returns the canonical set of institutional membership tiers
|
||||
// with their labels and default access tracks.
|
||||
func ListTiers() []struct {
|
||||
Tier InstitutionalTier `json:"tier"`
|
||||
Label string `json:"label"`
|
||||
DefaultTrack int `json:"default_track"`
|
||||
} {
|
||||
tiers := []InstitutionalTier{
|
||||
TierSovereignCentralBank,
|
||||
TierGlobalFamilyOffice,
|
||||
TierSettlementMember,
|
||||
TierInfrastructureOp,
|
||||
TierOversightJudicial,
|
||||
TierDelegatedAuthority,
|
||||
TierStandardsBody,
|
||||
}
|
||||
result := make([]struct {
|
||||
Tier InstitutionalTier `json:"tier"`
|
||||
Label string `json:"label"`
|
||||
DefaultTrack int `json:"default_track"`
|
||||
}, len(tiers))
|
||||
for i, t := range tiers {
|
||||
result[i].Tier = t
|
||||
result[i].Label = InstitutionalTierLabel(t)
|
||||
result[i].DefaultTrack = DefaultTrackForTier(t)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -102,10 +102,12 @@ type WalletAuthRequest struct {
|
||||
|
||||
// WalletAuthResponse represents a wallet authentication response
|
||||
type WalletAuthResponse struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Track int `json:"track"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Token string `json:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Track int `json:"track"`
|
||||
Permissions []string `json:"permissions"`
|
||||
InstitutionalTier *InstitutionalTier `json:"institutional_tier,omitempty"`
|
||||
InstitutionName string `json:"institution_name,omitempty"`
|
||||
}
|
||||
|
||||
// GenerateNonce generates a random nonce for wallet authentication
|
||||
@@ -223,17 +225,30 @@ func (w *WalletAuth) AuthenticateWallet(ctx context.Context, req *WalletAuthRequ
|
||||
// Get permissions for track
|
||||
permissions := getPermissionsForTrack(track)
|
||||
|
||||
return &WalletAuthResponse{
|
||||
resp := &WalletAuthResponse{
|
||||
Token: token,
|
||||
ExpiresAt: expiresAt,
|
||||
Track: track,
|
||||
Permissions: permissions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Attach institutional membership info if present
|
||||
store := NewMembershipStore(w.db)
|
||||
if member, err := store.GetMemberByAddress(ctx, normalizedAddr); err == nil && member != nil {
|
||||
resp.InstitutionalTier = &member.Tier
|
||||
resp.InstitutionName = member.Name
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// getUserTrack gets the track level for a user address
|
||||
// getUserTrack gets the track level for a user address.
|
||||
// Resolution order:
|
||||
// 1. Explicit per-address assignment in operator_roles (highest priority).
|
||||
// 2. Institutional membership via institutional_member_wallets → tier default.
|
||||
// 3. Fallback to Track 1 (public).
|
||||
func (w *WalletAuth) getUserTrack(ctx context.Context, address string) (int, error) {
|
||||
// Check if user exists in operator_roles (Track 4)
|
||||
// 1. Check explicit per-address assignment in operator_roles
|
||||
var track int
|
||||
var approved bool
|
||||
query := `SELECT track_level, approved FROM operator_roles WHERE address = $1`
|
||||
@@ -242,9 +257,20 @@ func (w *WalletAuth) getUserTrack(ctx context.Context, address string) (int, err
|
||||
return track, nil
|
||||
}
|
||||
|
||||
// Check if user is approved for Track 2 or 3
|
||||
// For now, default to Track 1 (public)
|
||||
// In production, you'd have an approval table
|
||||
// 2. Check institutional membership
|
||||
var tier string
|
||||
memberQuery := `
|
||||
SELECT m.tier
|
||||
FROM institutional_members m
|
||||
JOIN institutional_member_wallets w ON w.member_id = m.id
|
||||
WHERE LOWER(w.address) = LOWER($1) AND w.active = TRUE AND m.active = TRUE
|
||||
`
|
||||
err = w.db.QueryRow(ctx, memberQuery, address).Scan(&tier)
|
||||
if err == nil {
|
||||
return DefaultTrackForTier(InstitutionalTier(tier)), nil
|
||||
}
|
||||
|
||||
// 3. Default to Track 1 (public)
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user