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>
357 lines
12 KiB
TypeScript
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)
|
|
},
|
|
}
|