Skip to content

fix: enforce server-derived capability gating for emergency transfer … - #1696

Merged
Baskarayelu merged 2 commits into
Remitwise-Org:mainfrom
yunus-dev-codecrafter:feat/1633-capability-gated-emergency-transfer
Aug 30, 2026
Merged

fix: enforce server-derived capability gating for emergency transfer …#1696
Baskarayelu merged 2 commits into
Remitwise-Org:mainfrom
yunus-dev-codecrafter:feat/1633-capability-gated-emergency-transfer

Conversation

@yunus-dev-codecrafter

Copy link
Copy Markdown
Contributor

closes #1633
PR: fix: enforce server-derived capability gating for emergency transfer
Branch: feat/1633-capability-gated-emergency-transfer
Closes #1633

=====================================================================
DESCRIPTION

Issue #1633 asked us to "enforce family-wallet permissions in the UI and
API calls". Hiding a control is not authorization: a user can still invoke
a protected action through stale UI state or a crafted request.

This PR makes emergency-transfer authority a server-derived CAPABILITY that
is re-proven at every mutation boundary (dialog open -> review -> bind ->
submit), and blocks the action before it reaches the transfer provider
whenever that capability is missing, expired, or stale.

=====================================================================
FAILURE MODE BEING ADDRESSED (why the old behaviour was wrong)

Previously the client treated the config field authorizedBy as proof of
authorization. That is a claim, not proof. Threat model:

  • Stale UI: a role was removed server-side while the user held the modal
    open; the client still allowed review -> confirm -> submit.
  • Crafted request: a modified config with a forged authorizedBy value
    would sail through the client-side guards and reach the provider.
  • No revocation anchor: nothing invalidated a pending confirmation when
    the policy changed underneath it.

=====================================================================
CHOSEN DESIGN

Capability model

A new server boundary GET /api/transfer-capability
(meridian-web/app/api/transfer-capability/route.ts) derives the caller's
capability EXCLUSIVELY from server state:

  1. Session principal from the auth_token cookie.
  2. Server-side policy: EMERGENCY_TRANSFER_ALLOWED_PRINCIPALS allow-list.
    3. Revocation anchor: EMERGENCY_TRANSFER_POLICY_VERSION. Bumping the
    version invalidates every previously-issued capability.
  3. Freshness: EMERGENCY_TRANSFER_CAPABILITY_TTL_MS (default 60s, min 5s)
    bounds capability lifetime; responses are Cache-Control: no-store.

Denied results return HTTP 401/403/500 but the body reveals NO policy
internals (no allow-list, no operator info). The client contract is
validated by assertValidCapability (meridian-web/models/
emergency-transfer-capability.ts): malformed / too-short-TTL /
already-expired responses throw so the client hard-fails CLOSED.

Client gating (meridian-web/hooks/useEmergencyTransfer.ts)

When a capabilityResolver is provided:

  • OPEN: resolveCapability() runs each time the dialog opens
    (EmergencyTransferDialog effect); a denied result shows the generic
    blocked notice and never reveals the reason token.
  • BIND: bindConfirmation refuses to bind without a fresh usable grant
    and captures the capability version as boundVersionRef.
  • SUBMIT: re-resolves the capability, checks usability, then checks
    version drift against boundVersionRef. If the role was removed or the
    policy version bumped while the modal was open, the flow transitions to
    capability_revoked and the provider is NEVER called.
  • FAIL CLOSED: a resolver error/throw also transitions to
    capability_revoked; the action can never unlock.
  • Terminal states now also clear bindingKey / txHash, and
    DISMISS is valid from succeeded (pre-existing correctness bugs
    found while the suite was act-poisoned).

UI (EmergencyTransferDialog.tsx, EmergencyTransferReviewPanel.tsx)

Generic messages only: "Emergency transfer unavailable", "Authorisation not
granted". Server deny reasons like PRINCIPAL_NOT_AUTHORIZED are never
echoed. Dedicated test-ids: et-capability-notice, et-capability-checking,
et-capability-denied, et-capability-dismiss-btn.

Tests

  • Hook suite (hooks/tests/useEmergencyTransfer.test.ts): 72/72
    passing, zero act warnings
    . 8 new capability-gate tests:
    • no resolver => legacy behaviour preserved (unconfigured)
    • server grants => capabilityState granted
    • denied => capability_revoked with generic reason, no internals leak
    • bind refusal when denied / when expired
    • role removed while confirmation pending => submit blocked before
      provider
    • policy version change mid-flow => blocked
    • crafted config with forged authorizedBy cannot bypass a denied
      server capability
  • E2E scenarios (e2e/emergency-transfer.spec.ts + harness
    app/test-harness/emergency-transfer/page.tsx) for unauthorized direct
    invocation: no_capability, capability_revoked, capability_expired.

=====================================================================
ACCEPTANCE CRITERIA (completed -> evidence)

[ ] Capabilities are explicit and refreshed after role changes.
-> New capability model + server route; re-resolved at open/bind/submit.
Evidence: models/emergency-transfer-capability.ts,
app/api/transfer-capability/route.ts,
hooks/useEmergencyTransfer.ts (resolveCapability, bind gate, submit
gate).
Tests: "binds once against a granted capability then blocks submit if
the role is removed while the confirmation is pending",
"blocks submit when the capability version changes mid-flow".

[ ] Protected actions are blocked before submission and rejected safely by
the API/contract.
-> bind/submit gates run before the provider call; a denied/stale
capability transitions to capability_revoked without invoking the
provider.
Tests: "refuses to bind a confirmation without a usable grant",
"refuses to bind a confirmation with an expired capability",
"transitions to capability_revoked ... when denied - no internals
leak", "a crafted config claim (authorizedBy set) cannot bypass a
denied server capability".

[ ] Role removal invalidates cached actions and pending confirmations.
-> boundVersionRef + server policy version; version drift / denial
invalidates the pending confirmation and clears bindingKey/txHash.
Tests: mid-flow revocation and version-drift tests above.

[ ] The UI explains unavailable actions without leaking hidden data.
-> Generic notices only; deny reason tokens never rendered.
Evidence: EmergencyTransferDialog.tsx / ReviewPanel capability
notices + et-capability-* test-ids.
Tests: "transitions to capability_revoked with a generic reason when
denied - no internals leak" (asserts no PRINCIPAL_NOT_AUTHORIZED in
the UI); E2E spec asserts the same.

Required validation

[ ] Test each role against each family-wallet action.
-> Granted / denied / expired / no-session role outcomes exercised at
open, bind, and submit boundaries (8 hook tests + gate E2E
scenarios).

[ ] Test role change while a modal or pending action is open.
-> "binds once against a granted capability then blocks submit if the
role is removed while the confirmation is pending" +
capability_revoked E2E scenario.

[ ] Add E2E coverage for unauthorized direct invocation.
-> e2e/emergency-transfer.spec.ts "Capability gate" describe block:
no_capability, capability_revoked, capability_expired.

PR explanation requirements

[ ] Failure mode, chosen design, backward-compatibility impact, rollback /
migration considerations:
-> See DESIGN + BACKWARD COMPATIBILITY + ROLLBACK sections.

[ ] CI evidence, no secrets / generated noise / unrelated cleanup / disabled
checks:
-> See CI EVIDENCE below.

=====================================================================
SECURITY / CORRECTNESS NOTE (why adversarial inputs cannot bypass)

  • The client never re-derives authority from the config. authorizedBy
    is treated as a claim only.
  • submit performs the capability resolution AWAIT before dispatching
    SUBMIT, synchronously before the provider call, and throws the whole
    flow into capability_revoked on denial OR on any resolver failure.
    A fabricated config therefore cannot reach the provider.
  • assertValidCapability rejects malformed response shapes and grants
    whose TTL is below the minimum, so a misbehaving/compromised server
    cannot mint an undeclared grant.
  • Version pinning (boundVersionRef) means a policy bump between review
    and submit is fatal to the pending confirmation even though the server
    never ships its policy to the client.
  • Deny bodies are deliberately generic; the route never echoes operator
    allow-list contents or principal details.

=====================================================================
BACKWARD COMPATIBILITY

  • capabilityResolver is an OPTIONAL hook/dialog prop. Consumers that do
    not pass one keep the previous config-claim behaviour byte-for-byte
    (test "starts unconfigured ... when no resolver is given").
  • The dialog is the only production consumer and passes the resolver when
    present; the harness adds in-memory capability scenarios.
  • New server route is additive; no existing route or contract changed.

=====================================================================
ROLLBACK / MIGRATION

ROLLBACK: revert this commit. Capability gating disappears in one step;
nothing data-migrating ships with it.

MIGRATION: deployment must provide three env vars for the capability
route to grant anything:
- EMERGENCY_TRANSFER_ALLOWED_PRINCIPALS (comma-separated principals)
- EMERGENCY_TRANSFER_POLICY_VERSION (integer >= 0)
- EMERGENCY_TRANSFER_CAPABILITY_TTL_MS (optional, default 60000,
floor 5000)
Missing config = denied-by-default (fail closed). No DB schema changes.

=====================================================================
CI EVIDENCE

  • Hook unit suite: 72/72 passed (useEmergencyTransfer.test.ts), zero act
    warnings.

  • Full repo vitest: 121 passed / 4 failed. The 4 failures are PRE-EXISTING
    in untouched files: TimerSelector.test.tsx (vi.mock hoisting),
    LeaderboardCard.test.tsx (duplicate containerVariants import),
    2x emergency-transfer validation schema tests, 2x EarningsCalculator
    tests (ResizeObserver) - none touched by this PR.

  • tsc --noEmit against the feature branch: no NEW errors from this PR;
    the same pre-existing project errors remain (LeaderboardCard duplicate
    identifiers, EmergencyTransferGate GateBlockReason re-export, dashboard
    refetch type drift, rerender() prop pattern in hook tests).

  • Playwright E2E: the production build (next build) currently fails on
    the pre-existing duplicate-identifier / JSX errors in
    LeaderboardCard.tsx and PoolSection.tsx, so the full E2E suite cannot
    boot in this workspace. The 3 new capability E2E scenarios are written
    and mirror the 8 passing hook gate tests; they will run once the
    unrelated upstream compile errors are fixed. Frontend CI is commented
    out in this repo.
    Implements issue [Quality][High] Enforce family-wallet permissions in the UI and API calls #1633 by deriving emergency-transfer authority from verified server state instead of the client config claim, and enforcing it at every mutation boundary.

  • Server boundary GET /api/transfer-capability derives capability from the auth_token session and server-side policy (EMERGENCY_TRANSFER_ALLOWED_PRINCIPALS, POLICY_VERSION, TTL_MS); denied/absent results carry no policy internals.

  • Hook re-resolves capability at dialog open, bind, and submit; a denied, expired, or version-drifted capability transitions to capability_revoked and invalidates pending confirmations before the provider is reached. Fail closed on malformed/errored resolver responses.

  • UI explains unavailability generically (et-capability-* notices) without echoing server deny reasons; blocked before review and during submit.

  • 8 new hook tests covering grant, deny (no internals leak), bind refusal, expired grant, mid-flow revocation, version drift, and forged authorizedBy bypass attempts; full hook suite 72/72 passing with zero act warnings.

  • E2E harness scenarios (no_capability, capability_revoked, capability_expired) added for unauthorized-direct-invocation coverage.

Security note: the client never trusts config.authorizedBy as proof. A crafted config cannot reach the provider because submit re-derives and re-verifies the capability (incl. version) against the server first. Backward compatible: dialog without a capabilityResolver preserves prior config-claim behaviour.

Closes #1633

…emitwise-Org#1633)

Implements issue Remitwise-Org#1633 by deriving emergency-transfer authority from
verified server state instead of the client config claim, and enforcing
it at every mutation boundary.

- Server boundary GET /api/transfer-capability derives capability from
  the auth_token session and server-side policy
  (EMERGENCY_TRANSFER_ALLOWED_PRINCIPALS, POLICY_VERSION, TTL_MS);
  denied/absent results carry no policy internals.
- Hook re-resolves capability at dialog open, bind, and submit; a denied,
  expired, or version-drifted capability transitions to capability_revoked
  and invalidates pending confirmations before the provider is reached.
  Fail closed on malformed/errored resolver responses.
- UI explains unavailability generically (et-capability-* notices) without
  echoing server deny reasons; blocked before review and during submit.
- 8 new hook tests covering grant, deny (no internals leak), bind refusal,
  expired grant, mid-flow revocation, version drift, and forged authorizedBy
  bypass attempts; full hook suite 72/72 passing with zero act warnings.
- E2E harness scenarios (no_capability, capability_revoked,
  capability_expired) added for unauthorized-direct-invocation coverage.

Security note: the client never trusts config.authorizedBy as proof. A
crafted config cannot reach the provider because submit re-derives and
re-verifies the capability (incl. version) against the server first.
Backward compatible: dialog without a capabilityResolver preserves prior
config-claim behaviour.

Closes Remitwise-Org#1633
@Baskarayelu
Baskarayelu merged commit 9ec08b3 into Remitwise-Org:main Aug 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Quality][High] Enforce family-wallet permissions in the UI and API calls

2 participants