Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions meridian-web/app/api/transfer-capability/route.ts
Original file line number Diff line number Diff line change
@@ -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….<payload>… — 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<string> {
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<NextResponse> {
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',
},
})
}
73 changes: 73 additions & 0 deletions meridian-web/app/test-harness/emergency-transfer/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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') {
Expand All @@ -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<TransferCapability> => {
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

Expand Down Expand Up @@ -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 (
Expand All @@ -100,6 +172,7 @@ export default function EmergencyTransferTestHarness({
onOpenChange={setOpen}
config={config}
provider={provider}
capabilityResolver={capabilityResolver}
/>

{/* State dump for assertions */}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
CheckCircle2,
Loader2,
ShieldAlert,
ShieldOff,
TriangleAlert,
XCircle,
} from 'lucide-react'
Expand All @@ -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'

Expand Down Expand Up @@ -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({
Expand All @@ -311,6 +320,7 @@ export function EmergencyTransferDialog({
config,
provider,
getNow,
capabilityResolver,
}: EmergencyTransferDialogProps) {
const {
state,
Expand All @@ -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] =
Expand Down Expand Up @@ -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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
Expand All @@ -412,6 +437,35 @@ export function EmergencyTransferDialog({
</DialogDescription>
</DialogHeader>

{/* ---- Server-capability notice (issue #1633) ---- */}
{capabilityResolver && isCapabilityDenied && (
<div
className="flex flex-col gap-3"
data-testid="et-capability-notice"
>
<Alert variant="destructive" role="status" aria-live="polite">
<ShieldOff className="size-4" aria-hidden />
<AlertTitle>Emergency transfer unavailable</AlertTitle>
<AlertDescription>
Your session does not currently have emergency-transfer
capability. The authorisation must be granted before this
action can be performed. Contact your administrator.
</AlertDescription>
</Alert>
{!isTerminal && (
<Button
variant="outline"
size="sm"
className="w-fit"
onClick={handleDismiss}
data-testid="et-capability-dismiss-btn"
>
Close
</Button>
)}
</div>
)}

{/* ---- Gate: show unavailable notice when policy not met ---- */}
{!isAvailable && state.phase === 'idle' && (
<EmergencyTransferGate config={config} getNow={getNow}>
Expand All @@ -429,7 +483,9 @@ export function EmergencyTransferDialog({
onAcknowledgeChange={setRiskAcknowledged}
onConfirm={handleConfirm}
onDismiss={handleDismiss}
confirmDisabled={!canConfirm}
confirmDisabled={!canConfirm || isCapabilityChecking || isCapabilityDenied}
capabilityChecking={isCapabilityChecking}
capabilityDenied={isCapabilityDenied}
/>
)}

Expand Down
Loading