Files
explorer-monorepo/frontend/src/services/api/access.ts
defiQUG ab9c1f9f98
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 15s
Validate Explorer / frontend (push) Failing after 20s
Validate Explorer / smoke-e2e (push) Has been skipped
Ship bridge lanes, public API access doc, and WalletConnect client stack.
Align CCIP catalog UX with 11-lane config-ready routes, document the no-key public API decision, and enable browser WalletConnect pairing with backend session registration and deploy-time project ID wiring.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 02:21:37 -07:00

357 lines
12 KiB
TypeScript

import { getExplorerApiBase } from './blockscout'
declare global {
interface Window {
ethereum?: {
request: (args: { method: string; params?: unknown[] | object }) => Promise<unknown>
}
}
}
export interface AccessUser {
id: string
email: string
username: string
is_admin?: boolean
}
export interface AccessSession {
user: AccessUser
token: string
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 {
slug: string
name: string
provider: string
vmid: number
http_url: string
ws_url?: string
default_tier: string
requires_approval: boolean
billing_model: string
description: string
use_cases: string[]
management_features: string[]
}
export interface AccessAPIKeyRecord {
id: string
name: string
tier: string
productSlug: string
scopes: string[]
monthlyQuota: number
requestsUsed: number
approved: boolean
approvedAt?: string | null
rateLimitPerSecond: number
rateLimitPerMinute: number
lastUsedAt?: string | null
expiresAt?: string | null
revoked: boolean
createdAt: string
}
export interface CreateAccessAPIKeyRequest {
name: string
tier: string
productSlug: string
expiresDays?: number
monthlyQuota?: number
scopes?: string[]
}
export interface AccessSubscription {
id: string
productSlug: string
tier: string
status: string
monthlyQuota: number
requestsUsed: number
requiresApproval: boolean
approvedAt?: string | null
approvedBy?: string | null
notes?: string | null
createdAt: string
}
export interface AccessUsageSummary {
product_slug: string
active_keys: number
requests_used: number
monthly_quota: number
}
export interface AccessAuditEntry {
id: number
apiKeyId: string
keyName: string
productSlug: string
methodName: string
requestCount: number
lastIp?: string | null
createdAt: string
}
const ACCESS_TOKEN_STORAGE_KEY = 'explorer_access_token'
const WALLET_SESSION_STORAGE_KEY = 'explorer_wallet_session'
const ACCESS_SESSION_EVENT = 'explorer-access-session-changed'
const ACCESS_API_PREFIX = '/explorer-api/v1'
function getStoredAccessToken(): string {
if (typeof window === 'undefined') return ''
return window.localStorage.getItem(ACCESS_TOKEN_STORAGE_KEY) || getStoredWalletSession()?.token || ''
}
function setStoredAccessToken(token: string) {
if (typeof window === 'undefined') return
if (token) {
window.localStorage.setItem(ACCESS_TOKEN_STORAGE_KEY, token)
} else {
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY)
}
window.dispatchEvent(new Event(ACCESS_SESSION_EVENT))
}
function getStoredWalletSession(): WalletAccessSession | null {
if (typeof window === 'undefined') return null
const raw = window.localStorage.getItem(WALLET_SESSION_STORAGE_KEY)
if (!raw) return null
try {
return JSON.parse(raw) as WalletAccessSession
} catch {
window.localStorage.removeItem(WALLET_SESSION_STORAGE_KEY)
return null
}
}
function setStoredWalletSession(session: WalletAccessSession | null) {
if (typeof window === 'undefined') return
if (session) {
window.localStorage.setItem(WALLET_SESSION_STORAGE_KEY, JSON.stringify(session))
} else {
window.localStorage.removeItem(WALLET_SESSION_STORAGE_KEY)
}
window.dispatchEvent(new Event(ACCESS_SESSION_EVENT))
}
// Keep in sync with walletAuthSignMessage() in backend/auth/wallet_auth.go.
function buildWalletMessage(nonce: string) {
return `Sign this message to authenticate with DBIS Explorer.\n\nNonce: ${nonce}`
}
async function fetchWalletJson<T>(path: string, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers || {})
headers.set('Content-Type', 'application/json')
const response = await fetch(`${getExplorerApiBase()}${path}`, {
...init,
headers,
})
const payload = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(payload?.error?.message || `HTTP ${response.status}`)
}
return payload as T
}
async function fetchJson<T>(path: string, init?: RequestInit, includeAuth = false): Promise<T> {
const headers = new Headers(init?.headers || {})
headers.set('Content-Type', 'application/json')
if (includeAuth) {
const token = getStoredAccessToken()
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
}
const response = await fetch(`${getExplorerApiBase()}${path}`, {
...init,
headers,
})
const payload = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(payload?.error?.message || `HTTP ${response.status}`)
}
return payload as T
}
export const accessApi = {
getStoredAccessToken,
getStoredWalletSession,
clearSession() {
setStoredAccessToken('')
},
clearWalletSession() {
setStoredWalletSession(null)
},
async register(email: string, username: string, password: string): Promise<AccessSession> {
const response = await fetchJson<AccessSession>(`${ACCESS_API_PREFIX}/auth/register`, {
method: 'POST',
body: JSON.stringify({ email, username, password }),
})
setStoredAccessToken(response.token)
return response
},
async login(email: string, password: string): Promise<AccessSession> {
const response = await fetchJson<AccessSession>(`${ACCESS_API_PREFIX}/auth/login`, {
method: 'POST',
body: JSON.stringify({ email, password }),
})
setStoredAccessToken(response.token)
return response
},
async createWalletNonce(address: string): Promise<{ nonce: string; address: string }> {
return fetchWalletJson<{ nonce: string; address: string }>(`${ACCESS_API_PREFIX}/auth/nonce`, {
method: 'POST',
body: JSON.stringify({ address }),
})
},
async authenticateWallet(address: string, signature: string, nonce: string): Promise<WalletAccessSession> {
const response = await fetchWalletJson<{
token: string
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 }),
})
const session: WalletAccessSession = {
token: response.token,
expiresAt: response.expires_at,
track: response.track,
permissions: response.permissions || [],
address,
institutionalTier: response.institutional_tier,
institutionName: response.institution_name,
}
setStoredWalletSession(session)
return session
},
async connectWalletSession(): Promise<WalletAccessSession> {
if (typeof window === 'undefined' || typeof window.ethereum === 'undefined') {
throw new Error('No EOA wallet detected. Please open the explorer with a browser wallet installed.')
}
return accessApi.connectWalletSessionWithSigner(async () => {
const accounts = (await window.ethereum!.request({
method: 'eth_requestAccounts',
})) as string[]
const address = accounts?.[0]
if (!address) {
throw new Error('Wallet connection was cancelled.')
}
return address
}, async (message, address) => {
return (await window.ethereum!.request({
method: 'personal_sign',
params: [message, address],
})) as string
})
},
async connectWalletSessionWithSigner(
resolveAddress: () => Promise<string>,
signMessage: (message: string, address: string) => Promise<string>,
): Promise<WalletAccessSession> {
const address = await resolveAddress()
const nonceResponse = await accessApi.createWalletNonce(address)
const message = buildWalletMessage(nonceResponse.nonce)
const signature = await signMessage(message, address)
return accessApi.authenticateWallet(address, signature, nonceResponse.nonce)
},
async getMe(): Promise<{ user: AccessUser; subscriptions?: AccessSubscription[] }> {
return fetchJson<{ user: AccessUser; subscriptions?: AccessSubscription[] }>(`${ACCESS_API_PREFIX}/access/me`, undefined, true)
},
async listProducts(): Promise<{ products: AccessProduct[]; note?: string }> {
return fetchJson<{ products: AccessProduct[]; note?: string }>(`${ACCESS_API_PREFIX}/access/products`)
},
async listAPIKeys(): Promise<{ api_keys: AccessAPIKeyRecord[] }> {
return fetchJson<{ api_keys: AccessAPIKeyRecord[] }>(`${ACCESS_API_PREFIX}/access/api-keys`, undefined, true)
},
async createAPIKey(request: CreateAccessAPIKeyRequest): Promise<{ api_key: string; record?: AccessAPIKeyRecord }> {
return fetchJson<{ api_key: string; record?: AccessAPIKeyRecord }>(`${ACCESS_API_PREFIX}/access/api-keys`, {
method: 'POST',
body: JSON.stringify({
name: request.name,
tier: request.tier,
product_slug: request.productSlug,
expires_days: request.expiresDays,
monthly_quota: request.monthlyQuota,
scopes: request.scopes,
}),
}, true)
},
async revokeAPIKey(id: string): Promise<{ revoked: boolean; api_key_id: string }> {
return fetchJson<{ revoked: boolean; api_key_id: string }>(`${ACCESS_API_PREFIX}/access/api-keys/${id}`, {
method: 'POST',
}, true)
},
async listSubscriptions(): Promise<{ subscriptions: AccessSubscription[] }> {
return fetchJson<{ subscriptions: AccessSubscription[] }>(`${ACCESS_API_PREFIX}/access/subscriptions`, undefined, true)
},
async requestSubscription(productSlug: string, tier: string): Promise<{ subscription: AccessSubscription }> {
return fetchJson<{ subscription: AccessSubscription }>(`${ACCESS_API_PREFIX}/access/subscriptions`, {
method: 'POST',
body: JSON.stringify({ product_slug: productSlug, tier }),
}, true)
},
async getUsage(): Promise<{ usage: AccessUsageSummary[] }> {
return fetchJson<{ usage: AccessUsageSummary[] }>(`${ACCESS_API_PREFIX}/access/usage`, undefined, true)
},
async listAudit(limit = 20): Promise<{ entries: AccessAuditEntry[] }> {
return fetchJson<{ entries: AccessAuditEntry[] }>(`${ACCESS_API_PREFIX}/access/audit?limit=${encodeURIComponent(limit)}`, undefined, true)
},
async listAdminSubscriptions(status = 'pending'): Promise<{ subscriptions: AccessSubscription[] }> {
const suffix = status ? `?status=${encodeURIComponent(status)}` : ''
return fetchJson<{ subscriptions: AccessSubscription[] }>(`${ACCESS_API_PREFIX}/access/admin/subscriptions${suffix}`, undefined, true)
},
async listAdminAudit(limit = 50, productSlug = ''): Promise<{ entries: AccessAuditEntry[] }> {
const params = new URLSearchParams()
params.set('limit', String(limit))
if (productSlug) params.set('product', productSlug)
return fetchJson<{ entries: AccessAuditEntry[] }>(`${ACCESS_API_PREFIX}/access/admin/audit?${params.toString()}`, undefined, true)
},
async updateAdminSubscription(subscriptionId: string, status: string, notes = ''): Promise<{ subscription: AccessSubscription }> {
return fetchJson<{ subscription: AccessSubscription }>(`${ACCESS_API_PREFIX}/access/admin/subscriptions`, {
method: 'POST',
body: JSON.stringify({
subscription_id: subscriptionId,
status,
notes,
}),
}, true)
},
}