Background
PR for #286 added in-dashboard purchase approval via the approve-{any,own}:purchases RBAC matrix and a session-first dispatch in internal/api/handler_purchases.go::approvePurchase. The companion approveRIExchange at internal/api/handler_ri_exchange.go:580 has the same shape gap (token-only auth, no session-authed branch) but mutates a state-machine and triggers downstream cloud-side execution — substantially larger surface than fits in #286's PR.
This issue tracks the symmetric work.
What needs to happen
Apply the same dual-auth shape approvePurchase now uses, adapted to the RI-exchange flow:
-
Reuse the existing approve-{any,own}:purchases verbs — the RI-exchange approval is conceptually still "approving a purchase action", just on a different resource type. Don't add new RI-specific verbs unless an operational reason emerges. (Alternative if you want stricter isolation: add approve-{any,own}:ri-exchanges and gate separately. Default to reuse to keep the matrix small.)
-
Refactor approveRIExchange into a three-mode dispatch like approvePurchase:
- Session present + RBAC matches → session-authed approve.
- token != "" → legacy email-link, unchanged.
- token == "" → session-authed branch.
Permission-denied (403) on the session check falls through to the token branch (legacy operators retain their email-link path).
-
approveRIExchangeViaSession that:
- Calls
validateExchangeApproval minus the token-equality check (session is the auth) but keeping the existing-record + state-machine state guards.
- Calls
TransitionRIExchangeStatus (pending → processing). The atomic transition there already handles concurrent cancel/expire — that protection remains.
- Calls the existing
executeApprovedExchange with the session-supplied actor.
- Stamps approver attribution somewhere on the RI-exchange row (mirror of
purchase_executions.approved_by TEXT). If the schema doesn't have a column today, add one in a small migration.
-
Frontend Approve button on the RI-exchange "pending" row UI, mirroring the History page's button. Find where pending RI-exchanges render today (riexchange.ts or similar) and add the same predicate + click + confirmDialog → api.approveRIExchange(id) → reload + toast pattern. The cancel/approve predicates can share created_by_user_id-based own-ness logic.
-
Tests:
- Backend: copy the cancel/approve precedent test pattern. Cover admin-session, approve-own-with-creator-match, approve-own-without-match (rejected), legacy email-token path still works (backwards-compat).
- Frontend: button render + click handler tests mirroring
history-approve-button.test.ts.
-
Cross-cutting check: ensure the RI-exchange executeApprovedExchange is safe to call from a logged-in session context (it might assume token-bearer-only — verify it doesn't read state that's only present on the email-link path).
Acceptance
- A logged-in admin (or
approve-any holder) can approve a pending RI-exchange directly from the dashboard.
- Token-based email-link approval continues to work for non-session approvers.
- A test seeds an admin session + a pending RI-exchange, hits the route with no token, asserts the row transitions and the cloud-side execution kicks off (mockable).
- Pre-existing RI-exchange tests on the token-only path continue to pass — backwards-compat mandatory.
References
Background
PR for #286 added in-dashboard purchase approval via the
approve-{any,own}:purchasesRBAC matrix and a session-first dispatch ininternal/api/handler_purchases.go::approvePurchase. The companionapproveRIExchangeatinternal/api/handler_ri_exchange.go:580has the same shape gap (token-only auth, no session-authed branch) but mutates a state-machine and triggers downstream cloud-side execution — substantially larger surface than fits in #286's PR.This issue tracks the symmetric work.
What needs to happen
Apply the same dual-auth shape
approvePurchasenow uses, adapted to the RI-exchange flow:Reuse the existing
approve-{any,own}:purchasesverbs — the RI-exchange approval is conceptually still "approving a purchase action", just on a different resource type. Don't add new RI-specific verbs unless an operational reason emerges. (Alternative if you want stricter isolation: addapprove-{any,own}:ri-exchangesand gate separately. Default to reuse to keep the matrix small.)Refactor
approveRIExchangeinto a three-mode dispatch likeapprovePurchase:Permission-denied (403) on the session check falls through to the token branch (legacy operators retain their email-link path).
approveRIExchangeViaSessionthat:validateExchangeApprovalminus the token-equality check (session is the auth) but keeping the existing-record + state-machine state guards.TransitionRIExchangeStatus(pending → processing). The atomic transition there already handles concurrent cancel/expire — that protection remains.executeApprovedExchangewith the session-supplied actor.purchase_executions.approved_byTEXT). If the schema doesn't have a column today, add one in a small migration.Frontend Approve button on the RI-exchange "pending" row UI, mirroring the History page's button. Find where pending RI-exchanges render today (
riexchange.tsor similar) and add the same predicate + click + confirmDialog →api.approveRIExchange(id)→ reload + toast pattern. Thecancel/approvepredicates can sharecreated_by_user_id-based own-ness logic.Tests:
history-approve-button.test.ts.Cross-cutting check: ensure the RI-exchange
executeApprovedExchangeis safe to call from a logged-in session context (it might assume token-bearer-only — verify it doesn't read state that's only present on the email-link path).Acceptance
approve-anyholder) can approve a pending RI-exchange directly from the dashboard.References
internal/api/handler_ri_exchange.go:579-624(approveRIExchange+validateExchangeApproval)internal/api/handler_ri_exchange.go:634+(executeApprovedExchange)internal/api/handler_purchases.go::approvePurchase/approvePurchaseViaSession/authorizeSessionApprove(the precedent to mirror)