feat: institutional membership tiers and corrected member directory #16

Merged
nsatoshi merged 2 commits from devin/1778358341-institutional-membership-tiers into master 2026-05-09 21:01:17 +00:00
9 changed files with 575 additions and 14 deletions

View File

@@ -0,0 +1,89 @@
package rest
import (
"encoding/json"
"net/http"
"strings"
"github.com/explorer/backend/auth"
)
// handleMembershipTiers returns the canonical set of institutional tiers
// with their labels and default explorer access tracks.
// GET /api/v1/membership/tiers
func (s *Server) handleMembershipTiers(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w)
return
}
tiers := auth.ListTiers()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"tiers": tiers,
})
}
// handleMembershipMembers returns all active institutional members.
// GET /api/v1/membership/members
func (s *Server) handleMembershipMembers(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w)
return
}
if !s.requireDB(w) {
return
}
store := auth.NewMembershipStore(s.db)
members, err := store.ListMembers(r.Context())
if err != nil {
writeInternalError(w, err.Error())
return
}
if members == nil {
members = []auth.InstitutionalMember{}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"members": members,
"total": len(members),
})
}
// handleMembershipMemberDetail returns a single member by slug.
// GET /api/v1/membership/members/{slug}
func (s *Server) handleMembershipMemberDetail(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w)
return
}
if !s.requireDB(w) {
return
}
slug := strings.TrimPrefix(r.URL.Path, "/api/v1/membership/members/")
if slug == "" {
writeError(w, http.StatusBadRequest, "MISSING_SLUG", "Member slug is required")
return
}
store := auth.NewMembershipStore(s.db)
member, err := store.GetMemberBySlug(r.Context(), slug)
if err != nil {
writeInternalError(w, err.Error())
return
}
if member == nil {
writeNotFound(w, "Member")
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"member": member,
})
}

View File

@@ -67,6 +67,11 @@ func (s *Server) SetupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/access/usage", s.handleAccessUsage)
mux.HandleFunc("/api/v1/access/audit", s.handleAccessAudit)
// Institutional membership directory (public, read-only)
mux.HandleFunc("/api/v1/membership/tiers", s.handleMembershipTiers)
mux.HandleFunc("/api/v1/membership/members", s.handleMembershipMembers)
mux.HandleFunc("/api/v1/membership/members/", s.handleMembershipMemberDetail)
// Track 1 routes (public, optional auth)
// Note: Track 1 endpoints should be registered with OptionalAuth middleware
// mux.HandleFunc("/api/v1/track1/blocks/latest", s.track1Server.handleLatestBlocks)

213
backend/auth/membership.go Normal file
View 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
}

View File

@@ -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
}

View File

@@ -0,0 +1,5 @@
-- 0017_institutional_membership.down.sql
DROP TRIGGER IF EXISTS update_institutional_members_updated_at ON institutional_members;
DROP TABLE IF EXISTS institutional_member_wallets;
DROP TABLE IF EXISTS institutional_members;
DROP TYPE IF EXISTS institutional_tier;

View File

@@ -0,0 +1,178 @@
-- 0017_institutional_membership.up.sql
--
-- Adds institutional membership tables and seeds the canonical DBIS member
-- directory. The tier taxonomy comes from https://d-bis.org/members#tiers
-- with corrections per institutional review (2026-04).
-- Tier enum
DO $$ BEGIN
CREATE TYPE institutional_tier AS ENUM (
'sovereign_central_bank',
'global_family_office',
'settlement_member',
'infrastructure_operator',
'oversight_judicial',
'delegated_authority',
'standards_body'
);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- Members directory
CREATE TABLE IF NOT EXISTS institutional_members (
id SERIAL PRIMARY KEY,
slug VARCHAR(64) NOT NULL UNIQUE,
abbreviation VARCHAR(16) NOT NULL,
name TEXT NOT NULL,
tier institutional_tier NOT NULL,
description TEXT NOT NULL DEFAULT '',
jurisdiction TEXT,
lei VARCHAR(20),
latitude DOUBLE PRECISION,
longitude DOUBLE PRECISION,
map_label TEXT,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_institutional_members_tier ON institutional_members(tier);
CREATE INDEX IF NOT EXISTS idx_institutional_members_active ON institutional_members(active);
-- Junction: wallet addresses linked to institutional members
CREATE TABLE IF NOT EXISTS institutional_member_wallets (
id SERIAL PRIMARY KEY,
member_id INTEGER NOT NULL REFERENCES institutional_members(id) ON DELETE CASCADE,
address VARCHAR(42) NOT NULL,
label TEXT,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(member_id, address)
);
CREATE INDEX IF NOT EXISTS idx_imw_address ON institutional_member_wallets(address);
CREATE INDEX IF NOT EXISTS idx_imw_member_id ON institutional_member_wallets(member_id);
-- Triggers
CREATE TRIGGER update_institutional_members_updated_at
BEFORE UPDATE ON institutional_members
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ============================================================
-- Seed data — canonical member directory
-- ============================================================
-- Corrections from 2026-04 review:
-- • MLFO is a Global Family Office, NOT a central bank
-- • BIS Innovation Hub removed from directory entirely
-- • Added: ICCC, SAID, PANDA, Order of Hospitallers (XOM)
-- • Placeholder rows for BRICS founding central banks
INSERT INTO institutional_members
(slug, abbreviation, name, tier, description, jurisdiction, lei, latitude, longitude, map_label)
VALUES
-- Existing (corrected)
('omnl', 'OMNL', 'Organisation Mondiale du Numérique',
'sovereign_central_bank',
'Participating central bank — OMNL Head Office ledger and journal operator (Fineract / OMNL tenant). ARIN OrgId: OMNL.',
'International / participating monetary union', '98450070C57395F6B906',
39.61, -104.89, 'Greenwood Village, CO'),
('mlfo', 'MLFO', 'Mann Li Family Office',
'global_family_office',
'Founding family office (L.P.B.C., Colorado Entity 20241969162). Capital structure sponsor and BIS debit performance beneficiary. Registered agent: Pandora C. Walker.',
'US-CO (Colorado)', NULL,
40.02, -105.27, 'Boulder, CO'),
('defi-oracle', 'DFO', 'DeFi Oracle',
'infrastructure_operator',
'Infrastructure operator for Chain 138 ecosystem. Manages smart contract deployment (131 contracts), cross-chain bridges, PMM pools, and wallet integrations (MetaMask Snap, Ledger Live).',
'US-CO (Colorado)', NULL,
39.61, -104.89, 'Greenwood Village, CO'),
-- Added entities
('iccc', 'ICCC', 'International Criminal Court of Commerce',
'oversight_judicial',
'International court with oversight authority over DBIS ecosystem commercial disputes and enforcement.',
'International', NULL,
NULL, NULL, NULL),
('said', 'SAID', 'SAID',
'standards_body',
'Standards and identity body within the DBIS institutional framework.',
'International', NULL,
NULL, NULL, NULL),
('panda', 'PANDA', 'PANDA',
'standards_body',
'Standards and coordination body within the DBIS institutional framework.',
'International', NULL,
NULL, NULL, NULL),
('xom', 'XOM', 'Sovereign Military Hospitaller Order of St. John of Jerusalem of Rhodes and of Malta',
'delegated_authority',
'The sovereign entity (Order of Hospitallers) extending DBIS the agency authority under which it operates. Recognised UN observer state.',
'International (Rome)', NULL,
41.90, 12.48, 'Rome, Italy'),
-- BRICS founding member central banks (representative set)
('cb-brazil', 'BCB', 'Banco Central do Brasil',
'sovereign_central_bank',
'Central Bank of Brazil — BRICS founding member.',
'Brazil', NULL,
-15.79, -47.88, 'Brasília, Brazil'),
('cb-russia', 'CBR', 'Central Bank of the Russian Federation',
'sovereign_central_bank',
'Bank of Russia — BRICS founding member.',
'Russia', NULL,
55.76, 37.62, 'Moscow, Russia'),
('cb-india', 'RBI', 'Reserve Bank of India',
'sovereign_central_bank',
'Reserve Bank of India — BRICS founding member.',
'India', NULL,
18.93, 72.83, 'Mumbai, India'),
('cb-china', 'PBOC', 'People''s Bank of China',
'sovereign_central_bank',
'People''s Bank of China — BRICS founding member.',
'China', NULL,
39.91, 116.39, 'Beijing, China'),
('cb-south-africa', 'SARB', 'South African Reserve Bank',
'sovereign_central_bank',
'South African Reserve Bank — BRICS founding member.',
'South Africa', NULL,
-25.75, 28.19, 'Pretoria, South Africa'),
-- BRICS expanded members (2024+)
('cb-egypt', 'CBE', 'Central Bank of Egypt',
'sovereign_central_bank',
'Central Bank of Egypt — BRICS member (2024).',
'Egypt', NULL,
30.04, 31.24, 'Cairo, Egypt'),
('cb-ethiopia', 'NBE', 'National Bank of Ethiopia',
'sovereign_central_bank',
'National Bank of Ethiopia — BRICS member (2024).',
'Ethiopia', NULL,
9.02, 38.75, 'Addis Ababa, Ethiopia'),
('cb-iran', 'CBI', 'Central Bank of the Islamic Republic of Iran',
'sovereign_central_bank',
'Central Bank of Iran — BRICS member (2024).',
'Iran', NULL,
35.70, 51.42, 'Tehran, Iran'),
('cb-uae', 'CBUAE', 'Central Bank of the UAE',
'sovereign_central_bank',
'Central Bank of the UAE — BRICS member (2024).',
'United Arab Emirates', NULL,
24.45, 54.65, 'Abu Dhabi, UAE'),
('cb-saudi-arabia', 'SAMA', 'Saudi Central Bank',
'sovereign_central_bank',
'Saudi Central Bank (formerly SAMA) — BRICS member (2024).',
'Saudi Arabia', NULL,
24.71, 46.68, 'Riyadh, Saudi Arabia')
ON CONFLICT (slug) DO NOTHING;

View File

@@ -118,3 +118,19 @@ func GetAllFeatures() map[string]FeatureFlag {
return FeatureFlags
}
// TrackLabel returns a human-readable label for an access track number.
func TrackLabel(track int) string {
switch track {
case 1:
return "Explorer"
case 2:
return "Enhanced Explorer"
case 3:
return "Analytics"
case 4:
return "Operator"
default:
return "Unknown"
}
}

View File

@@ -3,7 +3,7 @@
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { type ReactNode, useEffect, useId, useMemo, useRef, useState } from 'react'
import { accessApi, type WalletAccessSession } from '@/services/api/access'
import { accessApi, institutionalTierLabels, type WalletAccessSession } from '@/services/api/access'
import BrandLockup from './BrandLockup'
import HeaderCommandPalette, { type HeaderCommandItem } from './HeaderCommandPalette'
import { useUiMode } from './UiModeContext'
@@ -310,6 +310,9 @@ function SearchControl({
}
function getAccessTier(walletSession: WalletAccessSession) {
if (walletSession.institutionalTier) {
return institutionalTierLabels[walletSession.institutionalTier] ?? walletSession.institutionalTier
}
const permissions = walletSession.permissions || []
if (permissions.some((permission) => permission.startsWith('operator.'))) {
return 'Operator Tier'
@@ -326,10 +329,11 @@ function getAccessTier(walletSession: WalletAccessSession) {
function getSessionSummary(walletSession: WalletAccessSession) {
const permissionCount = walletSession.permissions?.length || 0
const tierLabel = getAccessTier(walletSession)
const institutionSuffix = walletSession.institutionName ? ` (${walletSession.institutionName})` : ''
if (permissionCount > 0) {
return `${tierLabel} · ${permissionCount} permission${permissionCount === 1 ? '' : 's'}`
return `${tierLabel}${institutionSuffix} · ${permissionCount} permission${permissionCount === 1 ? '' : 's'}`
}
return `${tierLabel} · Explorer access active`
return `${tierLabel}${institutionSuffix} · Explorer access active`
}
function UiModeToggle({ mobile = false }: { mobile?: boolean }) {

View File

@@ -21,12 +21,33 @@ export interface AccessSession {
expires_at: string
}
export type InstitutionalTier =
| 'sovereign_central_bank'
| 'global_family_office'
| 'settlement_member'
| 'infrastructure_operator'
| 'oversight_judicial'
| 'delegated_authority'
| 'standards_body'
export const institutionalTierLabels: Record<InstitutionalTier, string> = {
sovereign_central_bank: 'Sovereign Central Bank',
global_family_office: 'Global Family Office',
settlement_member: 'Settlement Member',
infrastructure_operator: 'Infrastructure Operator',
oversight_judicial: 'Oversight & Judicial',
delegated_authority: 'Delegated Authority',
standards_body: 'Standards Body',
}
export interface WalletAccessSession {
token: string
expiresAt: string
track: string
permissions: string[]
address: string
institutionalTier?: InstitutionalTier
institutionName?: string
}
export interface AccessProduct {
@@ -220,6 +241,8 @@ export const accessApi = {
expires_at: string
track: string
permissions: string[]
institutional_tier?: InstitutionalTier
institution_name?: string
}>(`${ACCESS_API_PREFIX}/auth/wallet`, {
method: 'POST',
body: JSON.stringify({ address, signature, nonce }),
@@ -230,6 +253,8 @@ export const accessApi = {
track: response.track,
permissions: response.permissions || [],
address,
institutionalTier: response.institutional_tier,
institutionName: response.institution_name,
}
setStoredWalletSession(session)
return session