diff --git a/meridian-web/app/api/transfer-capability/route.ts b/meridian-web/app/api/transfer-capability/route.ts new file mode 100644 index 00000000..9fdddf95 --- /dev/null +++ b/meridian-web/app/api/transfer-capability/route.ts @@ -0,0 +1,140 @@ +import { NextRequest, NextResponse } from 'next/server' + +import type { + TransferCapability, + CapabilityDenyReason, +} from '@/models/emergency-transfer-capability' + +/** + * GET /api/transfer-capability + * + * Server boundary for emergency-transfer authorization. Derives the caller's + * transfer capability EXCLUSIVELY from verified server state: + * + * 1. The session principal (bearer token in the `auth_token` cookie). + * 2. The server-side transfer policy (vault issuance + principal allow-list + * sourced from the deployment environment / policy vault). + * + * The client can never influence the outcome — it cannot pass a principal, + * a version, or an `authorizedBy` claim. A denied capability is returned + * with HTTP 403 so callers (and logs) can distinguish it from a transport + * failure, but the body reveals no policy internals (which principals are on + * the allow-list, how the policy is stored, etc.). + * + * The `version` field is the revocation / staleness anchor: when a role is + * removed or the policy is tightened, operators bump + * `EMERGENCY_TRANSFER_POLICY_VERSION` and every previously-issued capability + * becomes stale. The client must therefore reject any capability whose + * version differs from the one captured at review time — invalidating + * pending confirmations without shipping any server state to the client. + * + * This route is deliberately cheap and stateless so it can be called at every + * mutation boundary with no caching: `Cache-Control: no-store`. + */ + +interface PrincipalSession { + principal: string | null + isValid: boolean +} + +/** + * Parses the bearer token the client sends. The token is either a plain + * e-mail style principal (dev/test) or a JWT-style payload carrying a `sub` + * claim. Any failure is treated as "no session" — the route fails closed. + * No token data is ever echoed back to the client. + */ +function resolvePrincipal(token: string): PrincipalSession { + if (!token) return { principal: null, isValid: false } + + // JWT-ish payload: eyJ….… — parse only the payload segment. + const segments = token.split('.') + if (segments.length === 3) { + try { + const raw = Buffer.from(segments[1], 'base64url').toString('utf8') + const payload = JSON.parse(raw) as { sub?: unknown } + if (typeof payload.sub === 'string' && payload.sub.length > 0) { + return { principal: payload.sub, isValid: true } + } + } catch { + return { principal: null, isValid: false } + } + return { principal: null, isValid: false } + } + + // Plain principal (dev/test sign-in). Reject obvious garbage. + if (typeof token === 'string' && token.length > 0 && token.length <= 320) { + return { principal: token, isValid: true } + } + + return { principal: null, isValid: false } +} + +/** Reads the server-side policy revision (revocation anchor). */ +function policyVersion(): number { + const raw = process.env.EMERGENCY_TRANSFER_POLICY_VERSION + const parsed = raw ? Number(raw) : Number.NaN + if (!Number.isSafeInteger(parsed) || parsed < 0) return 0 + return parsed +} + +/** Principal allow-list from the server-side policy. Empty ⇒ denied-by-default. */ +function allowedPrincipals(): ReadonlySet { + const raw = process.env.EMERGENCY_TRANSFER_ALLOWED_PRINCIPALS ?? '' + return new Set( + raw + .split(',') + .map((p) => p.trim()) + .filter((p) => p.length > 0), + ) +} + +export async function GET(req: NextRequest): Promise { + try { + const token = req.cookies.get('auth_token')?.value ?? '' + const session = resolvePrincipal(token) + + if (!session.isValid || !session.principal) { + return deny('NO_ACTIVE_SESSION', 401) + } + + const principal = session.principal + if (!allowedPrincipals().has(principal)) { + return deny('PRINCIPAL_NOT_AUTHORIZED', 403) + } + + // Issued fresh from server state on every boundary call. + const issuedAt = Date.now() + const ttlMs = Number(process.env.EMERGENCY_TRANSFER_CAPABILITY_TTL_MS ?? '60000') + const safeTtl = Number.isSafeInteger(ttlMs) && ttlMs >= 5_000 ? ttlMs : 60_000 + + const capability: TransferCapability = Object.freeze({ + effect: 'granted', + principal, + version: policyVersion(), + issuedAt, + expiresAt: issuedAt + safeTtl, + }) + + return NextResponse.json(capability, { + status: 200, + headers: { + 'Cache-Control': 'no-store, private', + 'X-Content-Type-Options': 'nosniff', + }, + }) + } catch (err) { + console.error('[/api/transfer-capability]', err) + return deny('POLICY_NOT_ENABLED', 500) + } +} + +function deny(reason: CapabilityDenyReason, status: number): NextResponse { + const capability: TransferCapability = Object.freeze({ effect: 'denied', reason }) + return NextResponse.json(capability, { + status, + headers: { + 'Cache-Control': 'no-store, private', + 'X-Content-Type-Options': 'nosniff', + }, + }) +} \ No newline at end of file diff --git a/meridian-web/app/test-harness/emergency-transfer/page.tsx b/meridian-web/app/test-harness/emergency-transfer/page.tsx index f7da0842..e6349be4 100644 --- a/meridian-web/app/test-harness/emergency-transfer/page.tsx +++ b/meridian-web/app/test-harness/emergency-transfer/page.tsx @@ -13,7 +13,14 @@ * * Query params: * scenario=success|failure|unauthorized|expired|no_config + * |no_capability|capability_revoked|capability_expired * Default: success + * + * Capability scenarios simulate the server boundary in-memory so the E2E + * suite can prove the mutation gates close without a live backend: + * - no_capability → capability resolved as DENIED at open + * - capability_revoked → granted at review/bind, DENIED at submit + * - capability_expired → granted but already past its expiresAt */ import * as React from 'react' @@ -22,6 +29,8 @@ import { EmergencyTransferDialog } from '@/components/emergency-transfer' import { createEmergencyTransferConfig } from '@/models/emergency-transfer-config' import type { ConfirmationPayload } from '@/lib/validations/emergency-transfer' import type { TransferProvider } from '@/hooks/useEmergencyTransfer' +import type { TransferCapabilityResolver } from '@/lib/api/transfer-capability' +import type { TransferCapability } from '@/models/emergency-transfer-capability' // Guard: this route must never be accessible in production. if (process.env.NODE_ENV === 'production') { @@ -33,6 +42,64 @@ const AMOUNT_RAW = '1000000000000000000' const AMOUNT_DISPLAY = '1.0' const MOCK_TX_HASH = '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890' +const POLICY_VERSION = 3 + +/** + * Simulated server boundary. Mirrors the contract of + * `/api/transfer-capability`: the outcome is derived purely from "server" + * state (the scenario) and never from the client's config claim. + * + * Defined at module scope so the "server" call counter survives React + * re-renders — just like a real backend. + */ +function buildCapabilityResolver(scenario: string): TransferCapabilityResolver { + // Counts how many times the "server" has been consulted. + let calls = 0 + const now = Date.now() + + return async (): Promise => { + calls += 1 + + switch (scenario) { + case 'no_capability': + return { effect: 'denied', reason: 'PRINCIPAL_NOT_AUTHORIZED' } + + case 'capability_expired': + return { + effect: 'granted', + principal: 'admin@example.com', + version: POLICY_VERSION, + issuedAt: now - 60_000, + expiresAt: now - 1_000, + } + + case 'capability_revoked': + // Granted while the user reviews / binds… + if (calls <= 2) { + return { + effect: 'granted', + principal: 'admin@example.com', + version: POLICY_VERSION, + issuedAt: now, + expiresAt: now + 60_000, + } + } + // …then the role is removed server-side before submit. + return { effect: 'denied', reason: 'PRINCIPAL_NOT_AUTHORIZED' } + + default: + // success / failure / expired / unauthorized / no_config + return { + effect: 'granted', + principal: 'admin@example.com', + version: POLICY_VERSION, + issuedAt: now, + expiresAt: now + 60_000, + } + } + } +} + function buildConfig(scenario: string) { if (scenario === 'no_config') return null @@ -75,6 +142,11 @@ export default function EmergencyTransferTestHarness({ const scenario = searchParams?.scenario ?? 'success' const config = buildConfig(scenario) const provider = buildProvider(scenario) + // Stable across re-renders (the "server" call counter must not reset). + const capabilityResolver = React.useMemo( + () => buildCapabilityResolver(scenario), + [scenario], + ) const [open, setOpen] = React.useState(false) return ( @@ -100,6 +172,7 @@ export default function EmergencyTransferTestHarness({ onOpenChange={setOpen} config={config} provider={provider} + capabilityResolver={capabilityResolver} /> {/* State dump for assertions */} diff --git a/meridian-web/components/emergency-transfer/EmergencyTransferDialog.tsx b/meridian-web/components/emergency-transfer/EmergencyTransferDialog.tsx index e6d86937..66d74f67 100644 --- a/meridian-web/components/emergency-transfer/EmergencyTransferDialog.tsx +++ b/meridian-web/components/emergency-transfer/EmergencyTransferDialog.tsx @@ -21,6 +21,7 @@ import { CheckCircle2, Loader2, ShieldAlert, + ShieldOff, TriangleAlert, XCircle, } from 'lucide-react' @@ -43,6 +44,7 @@ import { useEmergencyTransfer, type TransferProvider, } from '@/hooks/useEmergencyTransfer' +import type { TransferCapabilityResolver } from '@/lib/api/transfer-capability' import type { EmergencyTransferConfig } from '@/models/emergency-transfer-config' import type { ConfirmationPayload } from '@/lib/validations/emergency-transfer' @@ -303,6 +305,13 @@ export interface EmergencyTransferDialogProps { provider: TransferProvider /** Optional override for "now" — useful in tests. */ getNow?: () => number + /** + * Server-derived capability resolver. When provided, the dialog re-resolves + * the capability every time it opens and the hook re-validates it before + * binding and before submitting — so a stale or revoked capability cannot + * execute, even if the config claim still says "authorised". + */ + capabilityResolver?: TransferCapabilityResolver } export function EmergencyTransferDialog({ @@ -311,6 +320,7 @@ export function EmergencyTransferDialog({ config, provider, getNow, + capabilityResolver, }: EmergencyTransferDialogProps) { const { state, @@ -321,9 +331,10 @@ export function EmergencyTransferDialog({ setRiskAcknowledged, bindConfirmation, submit, + resolveCapability, dismiss, msUntilExpiry, - } = useEmergencyTransfer({ config, provider, getNow }) + } = useEmergencyTransfer({ config, provider, getNow, capabilityResolver }) // Payload reference for step 2 — only valid after `bindConfirmation` is called. const [boundPayload, setBoundPayload] = @@ -395,8 +406,22 @@ export function EmergencyTransferDialog({ state.phase === 'expired' || state.phase === 'config_changed' || state.phase === 'unauthorized' || + state.phase === 'capability_revoked' || state.phase === 'dismissed' + const isCapabilityChecking = state.capabilityState === 'checking' + const isCapabilityDenied = state.capabilityState === 'denied' + + // Re-derive the capability from server state each time the dialog opens. + // A revoked capability invalidates the whole flow (blocks the review and + // any pending confirmation) before the user can interact. + React.useEffect(() => { + if (open && capabilityResolver) { + resolveCapability() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, capabilityResolver]) + return ( + {/* ---- Server-capability notice (issue #1633) ---- */} + {capabilityResolver && isCapabilityDenied && ( +
+ + + Emergency transfer unavailable + + Your session does not currently have emergency-transfer + capability. The authorisation must be granted before this + action can be performed. Contact your administrator. + + + {!isTerminal && ( + + )} +
+ )} + {/* ---- Gate: show unavailable notice when policy not met ---- */} {!isAvailable && state.phase === 'idle' && ( @@ -429,7 +483,9 @@ export function EmergencyTransferDialog({ onAcknowledgeChange={setRiskAcknowledged} onConfirm={handleConfirm} onDismiss={handleDismiss} - confirmDisabled={!canConfirm} + confirmDisabled={!canConfirm || isCapabilityChecking || isCapabilityDenied} + capabilityChecking={isCapabilityChecking} + capabilityDenied={isCapabilityDenied} /> )} diff --git a/meridian-web/components/emergency-transfer/EmergencyTransferReviewPanel.tsx b/meridian-web/components/emergency-transfer/EmergencyTransferReviewPanel.tsx index d28c3cb1..b6df0977 100644 --- a/meridian-web/components/emergency-transfer/EmergencyTransferReviewPanel.tsx +++ b/meridian-web/components/emergency-transfer/EmergencyTransferReviewPanel.tsx @@ -113,6 +113,10 @@ export interface EmergencyTransferReviewPanelProps { onDismiss: () => void /** Disable the confirm button externally (e.g. during parent async work). */ confirmDisabled?: boolean + /** True while the server capability is being re-derived. */ + capabilityChecking?: boolean + /** True when the server denied capability (confirmation is not allowed). */ + capabilityDenied?: boolean className?: string } @@ -124,6 +128,8 @@ export function EmergencyTransferReviewPanel({ onConfirm, onDismiss, confirmDisabled = false, + capabilityChecking = false, + capabilityDenied = false, className, }: EmergencyTransferReviewPanelProps) { const isExpired = msUntilExpiry <= 0 @@ -321,6 +327,31 @@ export function EmergencyTransferReviewPanel({ )} + {/* Server-capability overrides (issue #1633) */} + {capabilityChecking && ( + + + Verifying authorisation + + Re-checking emergency-transfer capability against the server… + + + )} + {capabilityDenied && ( + + + Authorisation not granted + + The server no longer grants emergency-transfer capability to this + session. The confirmation cannot proceed. + + + )} + {/* Action buttons */}