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>
214 lines
7.0 KiB
Go
214 lines
7.0 KiB
Go
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
|
|
}
|