Merge kms-nonce-bind-2026-04-28

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
2026-04-28 00:58:33 -07:00
9 changed files with 621 additions and 1905 deletions
+53 -158
View File
@@ -1,72 +1,28 @@
import { Router, Route, Switch, Link, useLocation, Redirect } from 'wouter'
import { Router, Route, Switch, Link, useLocation } from 'wouter'
import { useHashLocation } from 'wouter/use-hash-location'
import { auth } from './lib/api'
import { Login } from './pages/Login'
import { Dashboard } from './pages/Dashboard'
import { Operations } from './pages/Operations'
import { Sign } from './pages/Sign'
import { Wallets } from './pages/Wallets'
import { Vaults } from './pages/Vaults'
import { Policies } from './pages/Policies'
import { Whitelist } from './pages/Whitelist'
import { Settlements } from './pages/Settlements'
import { Bridge } from './pages/Bridge'
import { Payments } from './pages/Payments'
import { Audit } from './pages/Audit'
import { Devices } from './pages/Devices'
import { Webhooks } from './pages/Webhooks'
import { APIKeys } from './pages/APIKeys'
import { Users } from './pages/Users'
import { Settings } from './pages/Settings'
type NavItem = { href: string; label: string }
type NavGroup = { title: string; items: NavItem[] }
const navItems = [
{ href: '/', label: 'Dashboard' },
{ href: '/wallets', label: 'Wallets' },
{ href: '/vaults', label: 'Vaults' },
{ href: '/policies', label: 'Policies' },
{ href: '/settlements', label: 'Settlements' },
{ href: '/bridge', label: 'Bridge' },
{ href: '/payments', label: 'Payments' },
{ href: '/audit', label: 'Audit' },
{ href: '/settings', label: 'Settings' },
] as const
const groups: NavGroup[] = [
{
title: 'Treasury',
items: [
{ href: '/', label: 'Dashboard' },
{ href: '/operations', label: 'Operations' },
{ href: '/sign', label: 'New transaction' },
],
},
{
title: 'Wallets',
items: [
{ href: '/wallets', label: 'Wallets' },
{ href: '/vaults', label: 'Vaults' },
{ href: '/whitelist', label: 'Allowlist' },
],
},
{
title: 'Governance',
items: [
{ href: '/policies', label: 'Policies' },
{ href: '/users', label: 'Users' },
{ href: '/devices', label: 'Devices' },
],
},
{
title: 'Pipeline',
items: [
{ href: '/settlements', label: 'Settlements' },
{ href: '/bridge', label: 'Bridge' },
{ href: '/payments', label: 'Payments' },
],
},
{
title: 'System',
items: [
{ href: '/audit', label: 'Audit log' },
{ href: '/api-keys', label: 'API keys' },
{ href: '/webhooks', label: 'Webhooks' },
{ href: '/settings', label: 'Settings' },
],
},
]
function NavLink({ href, label }: NavItem) {
function NavLink({ href, label }: { href: string; label: string }) {
const [location] = useLocation()
const active = href === '/' ? location === '/' : location.startsWith(href)
return (
@@ -74,12 +30,12 @@ function NavLink({ href, label }: NavItem) {
href={href}
style={{
display: 'block',
padding: '5px 12px',
padding: '6px 12px',
borderRadius: 6,
color: active ? '#fff' : '#a3a3a3',
background: active ? '#262626' : 'transparent',
textDecoration: 'none',
fontSize: 13,
fontSize: 14,
}}
>
{label}
@@ -87,108 +43,47 @@ function NavLink({ href, label }: NavItem) {
)
}
function Sidebar() {
const me = auth.user()
function logout() {
auth.clear()
window.location.hash = '#/login'
}
return (
<aside
style={{
width: 220,
borderRight: '1px solid #262626',
padding: 16,
display: 'flex',
flexDirection: 'column',
gap: 12,
flexShrink: 0,
overflowY: 'auto',
}}
>
<div>
<div style={{ fontWeight: 600, fontSize: 16, color: '#fff' }}>MPC Admin</div>
{me && (
<div style={{ fontSize: 11, color: '#737373', marginTop: 2 }}>
{me.email} · {me.role}
</div>
)}
</div>
{groups.map((g) => (
<nav key={g.title} style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<div style={{
fontSize: 10,
color: '#525252',
textTransform: 'uppercase',
letterSpacing: '0.08em',
padding: '4px 12px',
}}>
{g.title}
</div>
{g.items.map((n) => <NavLink key={n.href} {...n} />)}
</nav>
))}
<button
onClick={logout}
style={{
marginTop: 'auto',
background: 'transparent',
border: '1px solid #262626',
borderRadius: 6,
padding: '6px 12px',
color: '#a3a3a3',
fontSize: 12,
cursor: 'pointer',
}}
>
Sign out
</button>
</aside>
)
}
function Protected({ children }: { children: React.ReactNode }) {
const tok = auth.token()
if (!tok) return <Redirect to="/login" />
return <>{children}</>
}
export function App() {
return (
<Router hook={useHashLocation}>
<Switch>
<Route path="/login" component={Login} />
<Route>
<Protected>
<div style={{ display: 'flex', height: '100vh', background: '#000', color: '#e5e5e5' }}>
<Sidebar />
<main style={{ flex: 1, overflow: 'auto', padding: 24 }}>
<Switch>
<Route path="/" component={Dashboard} />
<Route path="/operations" component={Operations} />
<Route path="/sign" component={Sign} />
<Route path="/wallets" component={Wallets} />
<Route path="/vaults" component={Vaults} />
<Route path="/whitelist" component={Whitelist} />
<Route path="/policies" component={Policies} />
<Route path="/users" component={Users} />
<Route path="/devices" component={Devices} />
<Route path="/settlements" component={Settlements} />
<Route path="/bridge" component={Bridge} />
<Route path="/payments" component={Payments} />
<Route path="/audit" component={Audit} />
<Route path="/api-keys" component={APIKeys} />
<Route path="/webhooks" component={Webhooks} />
<Route path="/settings" component={Settings} />
<Route>
<div style={{ color: '#737373' }}>Not found</div>
</Route>
</Switch>
</main>
</div>
</Protected>
</Route>
</Switch>
<div style={{ display: 'flex', height: '100vh', background: '#000', color: '#e5e5e5' }}>
<aside
style={{
width: 200,
borderRight: '1px solid #262626',
padding: 16,
display: 'flex',
flexDirection: 'column',
gap: 2,
flexShrink: 0,
}}
>
<div style={{ fontWeight: 600, fontSize: 16, marginBottom: 16, color: '#fff' }}>
MPC Admin
</div>
<nav style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{navItems.map((n) => (
<NavLink key={n.href} href={n.href} label={n.label} />
))}
</nav>
</aside>
<main style={{ flex: 1, overflow: 'auto', padding: 24 }}>
<Switch>
<Route path="/" component={Dashboard} />
<Route path="/wallets" component={Wallets} />
<Route path="/vaults" component={Vaults} />
<Route path="/policies" component={Policies} />
<Route path="/settlements" component={Settlements} />
<Route path="/bridge" component={Bridge} />
<Route path="/payments" component={Payments} />
<Route path="/audit" component={Audit} />
<Route path="/settings" component={Settings} />
<Route>
<div style={{ color: '#737373' }}>Not found</div>
</Route>
</Switch>
</main>
</div>
</Router>
)
}
-44
View File
@@ -28,30 +28,6 @@ export function useTransactions(filters?: { status?: string; chain?: string }) {
})
}
export function useOperations(filters?: { status?: string; walletId?: string; page?: number; perPage?: number }) {
return useQuery({
queryKey: ['mpc', 'operations', filters],
queryFn: () => api.listOperations(filters),
refetchInterval: 5000,
})
}
export function useOperation(operationId: string) {
return useQuery({
queryKey: ['mpc', 'operation', operationId],
queryFn: () => api.getOperation(operationId),
enabled: !!operationId,
})
}
export function useSessions(walletId: string) {
return useQuery({
queryKey: ['mpc', 'sessions', walletId],
queryFn: () => api.listSessions(walletId),
enabled: !!walletId,
})
}
export function usePolicies() {
return useQuery({ queryKey: ['mpc', 'policies'], queryFn: api.listPolicies })
}
@@ -86,23 +62,3 @@ export function useWalletBackups(walletId: string) {
enabled: !!walletId,
})
}
export function useUsers() {
return useQuery({ queryKey: ['mpc', 'users'], queryFn: api.listUsers })
}
export function useAPIKeys() {
return useQuery({ queryKey: ['mpc', 'api-keys'], queryFn: api.listAPIKeys })
}
export function useWebhooks() {
return useQuery({ queryKey: ['mpc', 'webhooks'], queryFn: api.listWebhooks })
}
export function useWhitelist() {
return useQuery({ queryKey: ['mpc', 'whitelist'], queryFn: api.listWhitelist })
}
export function useWebAuthnCredentials() {
return useQuery({ queryKey: ['mpc', 'webauthn'], queryFn: api.listWebAuthnCredentials })
}
+43 -298
View File
@@ -1,48 +1,24 @@
// Typed API client for MPC backend.
//
// Two surfaces: the spec'd Liquidity MPC namespace at /v1/mpc/* and the
// platform-internal /v1/* (auth, users, webhooks, api-keys, whitelist,
// webauthn). Everything authenticates with the bearer JWT in localStorage.
// Typed API client for MPC backend — all paths relative to /v1/mpc
const ROOT = '/v1'
const MPC = '/v1/mpc'
const BASE = '/v1/mpc'
export class APIError extends Error {
constructor(public status: number, public body: { error?: string }) {
super(body.error ?? `HTTP ${status}`)
constructor(public status: number, public body: { error: string }) {
super(body.error)
this.name = 'APIError'
}
}
const TOKEN_KEY = 'mpc_token'
const REFRESH_KEY = 'mpc_refresh'
const USER_KEY = 'mpc_user'
export const auth = {
token: (): string | null => localStorage.getItem(TOKEN_KEY),
refresh: (): string | null => localStorage.getItem(REFRESH_KEY),
user: (): { id: string; org_id: string; role: string; email: string } | null => {
const raw = localStorage.getItem(USER_KEY)
return raw ? JSON.parse(raw) : null
},
set: (token: string, refresh: string, user: { id: string; org_id: string; role: string; email: string }) => {
localStorage.setItem(TOKEN_KEY, token)
localStorage.setItem(REFRESH_KEY, refresh)
localStorage.setItem(USER_KEY, JSON.stringify(user))
},
clear: () => {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(REFRESH_KEY)
localStorage.removeItem(USER_KEY)
},
function token(): string | null {
return localStorage.getItem('mpc_token')
}
async function request<T>(method: string, fullPath: string, body?: unknown): Promise<T> {
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
const t = auth.token()
const t = token()
if (t) headers['Authorization'] = `Bearer ${t}`
const res = await fetch(fullPath, {
const res = await fetch(`${BASE}${path}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
@@ -50,26 +26,17 @@ async function request<T>(method: string, fullPath: string, body?: unknown): Pro
if (res.status === 204) return undefined as T
// 401: token dead, force re-login
if (res.status === 401 && fullPath !== `${ROOT}/auth/login`) {
auth.clear()
if (typeof window !== 'undefined') window.location.hash = '#/login'
}
const text = await res.text()
const data = text ? JSON.parse(text) : {}
const data = await res.json()
if (!res.ok) throw new APIError(res.status, data)
return data as T
}
const get = <T>(p: string) => request<T>('GET', p)
const post = <T>(p: string, b?: unknown) => request<T>('POST', p, b)
const patch = <T>(p: string, b?: unknown) => request<T>('PATCH', p, b)
const del = <T>(p: string) => request<T>('DELETE', p)
function get<T>(path: string): Promise<T> { return request<T>('GET', path) }
function post<T>(path: string, body?: unknown): Promise<T> { return request<T>('POST', path, body) }
function patch<T>(path: string, body?: unknown): Promise<T> { return request<T>('PATCH', path, body) }
function del<T>(path: string): Promise<T> { return request<T>('DELETE', path) }
// =================================================================
// Types — matching Go backend models
// =================================================================
// --- Types (matching Go backend models) ---
export interface ClusterStatus {
node_id: string
@@ -131,33 +98,6 @@ export interface Transaction {
created_at: string
}
export interface OperationApproval {
approverId: string
approvedAt: string
notes?: string
}
export interface Operation {
operationId: string
walletId: string
kind: string
payload?: Record<string, unknown>
status: string
approvers?: string[]
approvals?: OperationApproval[]
rejectionReason?: string
result?: Record<string, string>
createdAt: string
completedAt?: string
}
export interface OperationsPage {
items: Operation[]
page: number
perPage: number
totalItems: number
}
export interface Policy {
id: string
org_id: string
@@ -233,151 +173,35 @@ export interface BridgeConfig {
withdrawalsEnabled: boolean
}
export interface User {
id: string
createdAt: string
orgId: string
email: string
role: string
}
export interface APIKey {
id: string
name: string
keyPrefix: string
permissions: string[]
expiresAt?: string | null
lastUsedAt?: string | null
createdAt: string
}
export interface APIKeyCreated {
id: string
name: string
key: string
key_prefix: string
permissions: string[]
created_at: string
}
export interface Webhook {
id: string
org_id: string
url: string
secret: string
events: string[]
enabled: boolean
created_at: string
}
export interface WhitelistEntry {
id: string
org_id: string
vault_id?: string | null
address: string
chain: string
label?: string | null
created_at: string
}
export interface WebAuthnCredential {
id: string
device_name?: string | null
created_at: string
}
export interface Session {
sessionId: string
walletId: string
grantedTo?: string
scopes?: string[]
valueLimit?: string
operationLimit?: number
status: string
createdAt: string
expiresAt: string
revokedAt?: string
}
export interface LoginResponse {
user_id: string
org_id: string
role: string
email?: string
access_token: string
refresh_token: string
mfa_required?: boolean
}
// =================================================================
// API methods
// =================================================================
// --- API methods ---
export const api = {
// -------- Auth (no /mpc prefix; raw v1) --------
login: (email: string, password: string, mfa_code?: string) =>
post<LoginResponse>(`${ROOT}/auth/login`, { email, password, mfa_code }),
refresh: (refresh_token: string) =>
post<{ access_token: string; refresh_token: string }>(`${ROOT}/auth/refresh`, { refresh_token }),
register: (email: string, password: string, org_name: string) =>
post<LoginResponse>(`${ROOT}/auth/register`, { email, password, org_name }),
// Status
getStatus: () => get<ClusterStatus>('/status'),
getInfo: () => get<InfoResponse>('/info'),
// -------- Status --------
getStatus: () => get<ClusterStatus>(`${MPC}/status`),
getInfo: () => get<InfoResponse>(`${MPC}/info`),
// Vaults
listVaults: () => get<Vault[]>('/vaults'),
createVault: (req: { name: string; description?: string }) => post<Vault>('/vaults', req),
deleteVault: (id: string) => del(`/vaults/${id}`),
// -------- Vaults --------
listVaults: () => get<Vault[]>(`${MPC}/vaults`),
createVault: (req: { name: string; description?: string }) =>
post<Vault>(`${MPC}/vaults`, req),
deleteVault: (id: string) => del<void>(`${MPC}/vaults/${id}`),
// -------- Wallets --------
listWallets: (vaultId: string) => get<Wallet[]>(`${MPC}/vaults/${vaultId}/wallets`),
// Wallets
listWallets: (vaultId: string) => get<Wallet[]>(`/vaults/${vaultId}/wallets`),
createWallet: (vaultId: string, req?: { name?: string; key_type?: string; protocol?: string }) =>
post<Wallet>(`${MPC}/vaults/${vaultId}/wallets`, req),
getWalletBackups: (walletId: string) =>
get<WalletBackup[]>(`${MPC}/wallets/${walletId}/backup`),
post<Wallet>(`/vaults/${vaultId}/wallets`, req),
getWalletBackups: (walletId: string) => get<WalletBackup[]>(`/wallets/${walletId}/backup`),
// -------- Transactions / Operations --------
// Transactions
listTransactions: (filters?: { status?: string; chain?: string }) => {
const params = new URLSearchParams()
if (filters?.status) params.set('status', filters.status)
if (filters?.chain) params.set('chain', filters.chain)
const qs = params.toString()
return get<Transaction[]>(`${MPC}/transactions${qs ? `?${qs}` : ''}`)
return get<Transaction[]>(`/transactions${qs ? `?${qs}` : ''}`)
},
listOperations: (filters?: { status?: string; walletId?: string; page?: number; perPage?: number }) => {
const params = new URLSearchParams()
if (filters?.status) params.set('status', filters.status)
if (filters?.walletId) params.set('walletId', filters.walletId)
if (filters?.page) params.set('page', String(filters.page))
if (filters?.perPage) params.set('perPage', String(filters.perPage))
const qs = params.toString()
return get<OperationsPage>(`${MPC}/operations${qs ? `?${qs}` : ''}`)
},
getOperation: (id: string) => get<Operation>(`${MPC}/operations/${id}`),
approveOperation: (id: string, notes?: string) =>
post<Operation>(`${MPC}/operations/${id}/approve`, { notes: notes ?? '' }),
rejectOperation: (id: string, reason: string) =>
post<Operation>(`${MPC}/operations/${id}/reject`, { reason }),
// -------- Sessions (per-wallet signing grants) --------
listSessions: (walletId: string) =>
get<{ items: Session[] }>(`${MPC}/wallets/${walletId}/sessions`),
createSession: (walletId: string, req: {
grantedTo?: string
scopes: string[]
valueLimit?: string
operationLimit?: number
expiresAt: string
}) => post<Session>(`${MPC}/wallets/${walletId}/sessions`, req),
revokeSession: (walletId: string, sessionId: string) =>
del<void>(`${MPC}/wallets/${walletId}/sessions/${sessionId}`),
// -------- Policies --------
listPolicies: () => get<Policy[]>(`${MPC}/policies`),
// Policies
listPolicies: () => get<Policy[]>('/policies'),
createPolicy: (req: {
name: string
action: string
@@ -385,104 +209,25 @@ export const api = {
conditions: Record<string, unknown>
required_approvers: number
approver_roles: string[]
}) => post<Policy>(`${MPC}/policies`, req),
updatePolicy: (id: string, req: { enabled?: boolean }) =>
patch<Policy>(`${MPC}/policies/${id}`, req),
deletePolicy: (id: string) => del<void>(`${MPC}/policies/${id}`),
}) => post<Policy>('/policies', req),
updatePolicy: (id: string, req: { enabled?: boolean }) => patch<Policy>(`/policies/${id}`, req),
deletePolicy: (id: string) => del(`/policies/${id}`),
// -------- Settlements --------
// Settlements
listSettlements: (filters?: { status?: string }) => {
const params = new URLSearchParams()
if (filters?.status) params.set('status', filters.status)
const qs = params.toString()
return get<Settlement[]>(`${MPC}/settlements${qs ? `?${qs}` : ''}`)
return get<Settlement[]>(`/settlements${qs ? `?${qs}` : ''}`)
},
// -------- Bridge --------
getBridgeConfig: () => get<BridgeConfig>(`${MPC}/bridge/config`),
listBridgeNetworks: () => get<BridgeNetwork[]>(`${MPC}/bridge/networks`),
// Bridge
getBridgeConfig: () => get<BridgeConfig>('/bridge/config'),
listBridgeNetworks: () => get<BridgeNetwork[]>('/bridge/networks'),
// -------- Payments --------
listPaymentRequests: () => get<PaymentRequest[]>(`${MPC}/payment-requests`),
// Payments
listPaymentRequests: () => get<PaymentRequest[]>('/payment-requests'),
// -------- Audit --------
listAudit: () => get<AuditEntry[]>(`${MPC}/audit`),
// -------- Users --------
listUsers: () => get<User[]>(`${ROOT}/users`),
inviteUser: (req: { email: string; password: string; role?: string }) =>
post<User>(`${ROOT}/users`, req),
updateUser: (id: string, req: { role?: string }) =>
patch<User>(`${ROOT}/users/${id}`, req),
deleteUser: (id: string) => del<void>(`${ROOT}/users/${id}`),
// -------- API Keys --------
listAPIKeys: () => get<APIKey[]>(`${ROOT}/api-keys`),
createAPIKey: (req: { name: string; permissions?: string[] }) =>
post<APIKeyCreated>(`${ROOT}/api-keys`, req),
deleteAPIKey: (id: string) => del<void>(`${ROOT}/api-keys/${id}`),
// -------- Webhooks --------
listWebhooks: () => get<Webhook[]>(`${ROOT}/webhooks`),
createWebhook: (req: { url: string; events: string[]; secret: string }) =>
post<Webhook>(`${ROOT}/webhooks`, req),
updateWebhook: (id: string, req: { url?: string; events?: string[]; enabled?: boolean }) =>
patch<Webhook>(`${ROOT}/webhooks/${id}`, req),
deleteWebhook: (id: string) => del<void>(`${ROOT}/webhooks/${id}`),
testWebhook: (id: string) =>
post<{ status: string }>(`${ROOT}/webhooks/${id}/test`),
// -------- Whitelist (allowlist for destinations) --------
listWhitelist: () => get<WhitelistEntry[]>(`${ROOT}/whitelist`),
addWhitelist: (req: { address: string; chain: string; label?: string; vault_id?: string }) =>
post<WhitelistEntry>(`${ROOT}/whitelist`, req),
deleteWhitelist: (id: string) => del<void>(`${ROOT}/whitelist/${id}`),
// -------- WebAuthn --------
listWebAuthnCredentials: () => get<WebAuthnCredential[]>(`${ROOT}/webauthn/credentials`),
deleteWebAuthnCredential: (id: string) => del<void>(`${ROOT}/webauthn/credentials/${id}`),
webAuthnRegisterBegin: () =>
post<WebAuthnRegisterOptions>(`${ROOT}/webauthn/register/begin`),
webAuthnRegisterComplete: (body: WebAuthnRegisterComplete) =>
post<{ status: string; credential_id: string }>(`${ROOT}/webauthn/register/complete`, body),
webAuthnVerify: (body: WebAuthnVerify) =>
post<{ status: string; biometric: boolean; signing_started: boolean; approvals: number; required: number }>(
`${ROOT}/webauthn/verify`, body,
),
}
// WebAuthn ceremony types — server-shaped, not browser-shaped.
export interface WebAuthnRegisterOptions {
challenge: string
rp: { id: string; name: string }
user: { id: string; name: string; displayName: string }
pubKeyCredParams: { type: string; alg: number }[]
timeout: number
attestation: string
authenticatorSelection: {
authenticatorAttachment: string
userVerification: string
residentKey: string
}
credential_id: string
}
export interface WebAuthnRegisterComplete {
credential_id: string
id: string
rawId: string
type: string
response: {
attestationObject: string
clientDataJSON: string
}
device_name: string
}
export interface WebAuthnVerify {
transaction_id: string
credential_id: string
authenticator_data: string
client_data_json: string
signature: string
// Audit
listAudit: () => get<AuditEntry[]>('/audit'),
}