fix: enforce server-derived capability gating for emergency transfer … - #1696
Merged
Baskarayelu merged 2 commits intoAug 30, 2026
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
authorizedByas proof ofauthorization. That is a claim, not proof. Threat model:
open; the client still allowed review -> confirm -> submit.
authorizedByvaluewould sail through the client-side guards and reach the provider.
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:
auth_tokencookie.3. Revocation anchor: EMERGENCY_TRANSFER_POLICY_VERSION. Bumping the
version invalidates every previously-issued capability.
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
capabilityResolveris provided:resolveCapability()runs each time the dialog opens(EmergencyTransferDialog effect); a denied result shows the generic
blocked notice and never reveals the reason token.
bindConfirmationrefuses to bind without a fresh usable grantand captures the capability
versionasboundVersionRef.version drift against
boundVersionRef. If the role was removed or thepolicy version bumped while the modal was open, the flow transitions to
capability_revokedand the provider is NEVER called.capability_revoked; the action can never unlock.bindingKey/txHash, andDISMISSis valid fromsucceeded(pre-existing correctness bugsfound 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_AUTHORIZEDare neverechoed. Dedicated test-ids: et-capability-notice, et-capability-checking,
et-capability-denied, et-capability-dismiss-btn.
Tests
passing, zero act warnings. 8 new capability-gate tests:
provider
server capability
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)
authorizedByis treated as a claim only.
submitperforms the capability resolution AWAIT before dispatchingSUBMIT, synchronously before the provider call, and throws the whole
flow into
capability_revokedon denial OR on any resolver failure.A fabricated config therefore cannot reach the provider.
assertValidCapabilityrejects malformed response shapes and grantswhose TTL is below the minimum, so a misbehaving/compromised server
cannot mint an undeclared grant.
boundVersionRef) means a policy bump between reviewand submit is fatal to the pending confirmation even though the server
never ships its policy to the client.
allow-list contents or principal details.
=====================================================================
BACKWARD COMPATIBILITY
capabilityResolveris an OPTIONAL hook/dialog prop. Consumers that donot pass one keep the previous config-claim behaviour byte-for-byte
(test "starts unconfigured ... when no resolver is given").
present; the harness adds in-memory capability scenarios.
=====================================================================
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 onthe 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