diff --git a/apps/web/src/app/api/merchant/profile/route.ts b/apps/web/src/app/api/merchant/profile/route.ts index 64d5047..f780fce 100644 --- a/apps/web/src/app/api/merchant/profile/route.ts +++ b/apps/web/src/app/api/merchant/profile/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import { revalidateTag } from 'next/cache'; import { withClient, withMerchantClient } from '@/lib/db'; import { getMerchantFromRequest, updateMerchantProfile, type Merchant } from '@/lib/merchants'; +import { isAdmin } from '@/lib/rbac'; import { recordMerchantConfigChange } from '@/lib/merchant-config'; import { getCachedMerchantFromRequest, @@ -45,6 +46,19 @@ export async function PATCH(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } + // RBAC (#156): the profile carries the webhook URL, signing key and asset + // watch-list — developer/settings configuration. Viewers must not be able + // to change it. + if (!isAdmin(request)) { + return NextResponse.json( + { error: 'Forbidden: viewer sessions cannot modify merchant settings' }, + { status: 403 }, + ); + } + + const profile = await withMerchantClient(caller.id, (client) => + updateMerchantProfile(client, caller.id, parsed.update), + ); const profile = await withMerchantClient(caller.id, async (client) => { const updated = await updateMerchantProfile(client, caller.id, parsed.update); diff --git a/apps/web/src/app/api/refund/preflight/route.ts b/apps/web/src/app/api/refund/preflight/route.ts index 92378b5..fe0d987 100644 --- a/apps/web/src/app/api/refund/preflight/route.ts +++ b/apps/web/src/app/api/refund/preflight/route.ts @@ -8,6 +8,7 @@ import { } from '@/lib/refund-vault'; import { withClient } from '@/lib/db'; import { getMerchantFromRequest } from '@/lib/merchants'; +import { isAdmin } from '@/lib/rbac'; export const dynamic = 'force-dynamic'; @@ -69,6 +70,12 @@ export async function POST(request: Request) { if (!caller) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } + + // RBAC (#156): refunds move a merchant's float. A viewer may inspect + // payments but must never initiate (or even preflight) a refund. + if (!isAdmin(request)) { + return NextResponse.json({ error: 'Forbidden: viewer sessions cannot refund' }, { status: 403 }); + } const vaultId = caller.refundVaultId ?? REFUND_VAULT_ID; let existing: RefundRecord | null = null; diff --git a/apps/web/src/app/api/session/route.ts b/apps/web/src/app/api/session/route.ts new file mode 100644 index 0000000..5db3605 --- /dev/null +++ b/apps/web/src/app/api/session/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from 'next/server'; +import { roleFromRequest, type Role } from '@/lib/rbac'; + +export const dynamic = 'force-dynamic'; + +/** + * Exposes the signed-in session's role to client components (#156). + * + * The middleware has already verified the session cookie and forwarded the + * role as `x-accensa-role` before this route runs; this handler simply echoes + * it back. Client components use it to hide admin-only actions (refunds, + * settings) from viewer sessions — the server routes themselves enforce the + * same boundary, so hiding UI here is a convenience, not the security + * control. + */ +export async function GET(request: Request) { + const role: Role = roleFromRequest(request); + return NextResponse.json({ role }); +} diff --git a/apps/web/src/app/api/sync/route.ts b/apps/web/src/app/api/sync/route.ts index 363124c..c54d186 100644 --- a/apps/web/src/app/api/sync/route.ts +++ b/apps/web/src/app/api/sync/route.ts @@ -100,7 +100,26 @@ async function rpc(method: string, params: unknown, maxAttempts = 3): Promise return body.result as T; } catch (error) { if (attempt >= maxAttempts) throw error; - await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt) * 100)); // Exponential backoff + // Exponential backoff (issue #143). Each retry waits 2^attempt * 100ms + // (100ms, 200ms), capped so a long outage cannot stall the whole + // invocation; the committed cursor means a partial run resumes cleanly + // on the next poll regardless. Log every retry as one structured JSON + // line so an operator can tell transient RPC blips from a real outage + // without waiting for the run to fail outright. + const delayMs = Math.min(Math.pow(2, attempt) * 100, 2_000); + console.error( + JSON.stringify({ + level: 'warn', + event: 'rpc.retry', + method, + attempt, + maxAttempts, + retryInMs: delayMs, + error: error instanceof Error ? error.message : String(error), + at: new Date().toISOString(), + }), + ); + await new Promise((resolve) => setTimeout(resolve, delayMs)); } } throw new Error('Unreachable'); diff --git a/apps/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx index 61e8f80..46c9fc7 100644 --- a/apps/web/src/app/dashboard/page.tsx +++ b/apps/web/src/app/dashboard/page.tsx @@ -11,6 +11,7 @@ import { RefundPanel } from '@/components/refund-panel'; import { CopyButton } from '@/components/copy-button'; import { useOnline } from '@/components/network-status'; import { describeFailure, isAbortError } from '@/lib/network-status'; +import type { Role } from '@/lib/rbac'; import { explorerTxUrl } from '@/lib/explorer'; import { focusRestorer, getFocusable, wrapTabTarget } from '@/lib/dialog-focus'; @@ -66,6 +67,13 @@ export default function Dashboard() { // Refunds issued in this session. The indexer does not watch RefundVault // events yet, so a refund is otherwise invisible until someone opens the // payment again and the contract is re-read. + const [refunded, setRefunded] = useState>(() => loadRefundedFromStorage()); + // RBAC (#156): the signed-in session's role, fetched once. `null` until the + // fetch resolves (and for legacy sessions without a role claim, which the + // server treats as admin), so the UI starts permissive and narrows only + // when the session is known to be a viewer. The server routes enforce the + // same boundary; hiding UI here is a convenience, not the control. + const [role, setRole] = useState(null); const [refunded, setRefunded] = useState>(() => new Set()); const markRefunded = useCallback( (txHash: string) => setRefunded((prev) => new Set(prev).add(txHash)), @@ -76,6 +84,69 @@ export default function Dashboard() { // would re-trap focus mid-interaction. const closeModal = useCallback(() => setSelected(null), []); const online = useOnline(); + const visible = useVisibility(); + + useEffect(() => { + let live = true; + fetch('/api/session', { cache: 'no-store' }) + .then((res) => (res.ok ? res.json() : null)) + .then((data: { role?: unknown } | null) => { + if (live && (data?.role === 'admin' || data?.role === 'viewer')) setRole(data.role); + }) + .catch(() => { + // A failed role read degrades to admin (permissive); server-side + // gating still protects every admin-only action. + }); + return () => { + live = false; + }; + }, []); + + // Viewers can inspect payments and revenue but cannot initiate refunds. + const canRefund = role !== 'viewer'; + + // The current page lives in the URL (?page=2) so it survives reloads and can + // be linked to; searchParams is the single source of truth, and `goToPage` + // writes a new URL that the router re-renders this component with. + const searchParams = useSearchParams(); + const router = useRouter(); + const pathname = usePathname(); + const pageParam = Number(searchParams.get('page') ?? '1'); + const page = Number.isInteger(pageParam) && pageParam >= 1 ? pageParam : 1; + + const goToPage = useCallback( + (next: number) => { + const params = new URLSearchParams(searchParams.toString()); + if (next <= 1) params.delete('page'); + else params.set('page', String(next)); + router.replace(`${pathname}${params.toString() ? `?${params.toString()}` : ''}`, { + scroll: false, + }); + }, + [router, pathname, searchParams], + ); + + // SWR caches each ?page=N response keyed by URL, so paging back to a visited + // page is instant. The 15s poll keeps only the visible page fresh, and the + // `online` gate means a disconnected browser stops requesting (every request + // would fail and replace a good table with an error); reconnecting turns the + // key back on, which refetches immediately rather than waiting out a tick. + const { data, error, mutate } = useSWR( + online ? paymentsUrl(page) : null, + fetchPaymentsPage, + { refreshInterval: POLL_INTERVAL_MS, keepPreviousData: true }, + ); + + // Refresh on demand (retry, or after a manual sync). + const reload = useCallback(() => { + void mutate(); + }, [mutate]); + + const state: LoadState = error + ? { status: 'error', message: describeFailure(error, navigator.onLine) } + : !data + ? { status: 'loading' } + : { status: 'ready', payments: data.payments, sync: data.sync ?? null }; const reload = useCallback(() => setReloadToken((n) => n + 1), []); @@ -297,6 +368,7 @@ export default function Dashboard() { onClose={closeModal} refunded={refunded} onRefunded={markRefunded} + canRefund={canRefund} /> )} @@ -310,11 +382,14 @@ export function PaymentModal({ onClose, refunded, onRefunded, + canRefund, }: { selected: Payment; onClose: () => void; refunded: ReadonlySet; onRefunded: (tx_hash: string) => void; + /** False for viewer sessions, which must not be able to initiate refunds (#156). */ + canRefund?: boolean; }) { const dialogRef = useRef(null); @@ -443,6 +518,14 @@ export function PaymentModal({ + {canRefund !== false && ( +
+

+ Refund +

+ +
+ )}

Refund diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index 375ee93..be9860f 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -30,9 +30,9 @@ export async function decrypt(input: string): Promise | return payload; } -export async function createSession(publicKey: string) { +export async function createSession(publicKey: string, role: 'admin' | 'viewer' = 'admin') { const expires = new Date(Date.now() + 24 * 60 * 60 * 1000); - const session = await encrypt({ publicKey, expires: expires.toISOString() }); + const session = await encrypt({ publicKey, role, expires: expires.toISOString() }); const cookieStore = await cookies(); cookieStore.set('accensa_session', session, { httpOnly: true, diff --git a/apps/web/src/lib/rbac.ts b/apps/web/src/lib/rbac.ts index 05264cb..439f23c 100644 --- a/apps/web/src/lib/rbac.ts +++ b/apps/web/src/lib/rbac.ts @@ -1,4 +1,51 @@ /** + * Role-based access control for the merchant dashboard (#156). + * + * Merchants can grant staff read-only access to orders without handing over + * the ability to change settings or move money. Two roles exist: + * + * - `admin` — the full dashboard: settings, webhook config, refunds. + * - `viewer` — can view payments and revenue, but cannot reach developer + * settings, webhook configs, refunds, or any mutation endpoint. + * + * The role rides inside the signed session JWT (see `lib/auth.ts`) and is + * forwarded to route handlers by `src/middleware.ts` as the + * `x-accensa-role` header, exactly like the merchant address is forwarded + * as `x-accensa-merchant`. Server routes re-read the header instead of + * trusting anything a caller supplies. + * + * Legacy sessions minted before roles existed carry no role claim; they are + * treated as `admin` so a 24-hour-old cookie cannot lock a merchant out of + * their own dashboard mid-deployment. New sessions always carry an explicit + * role. + */ + +export type Role = 'admin' | 'viewer'; + +export const ROLES: readonly Role[] = ['admin', 'viewer']; + +/** Parses a role claim, rejecting anything that is not a known role. */ +export function parseRole(value: unknown): Role | null { + return typeof value === 'string' && (value === 'admin' || value === 'viewer') + ? value + : null; +} + +/** + * The caller's role, from the middleware-set header. + * + * Missing or unknown values resolve to `admin` for backward compatibility + * with pre-RBAC sessions (see the module comment). The header can only ever + * be set by middleware after jwtVerify succeeds, so a request cannot forge + * it. + */ +export function roleFromRequest(request: Request): Role { + return parseRole(request.headers.get('x-accensa-role')) ?? 'admin'; +} + +/** True when the caller may perform admin-only actions. */ +export function isAdmin(request: Request): boolean { + return roleFromRequest(request) === 'admin'; * Role-Based Access Control (RBAC) Migration to Zanzibar Model (#180). * * Implements a Google Zanzibar-inspired relationship-based access control diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index dc46d1a..2cb4399 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -1,6 +1,8 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; import { jwtVerify } from 'jose'; +import { rateLimit } from '@/lib/rate-limit'; +import { parseRole, type Role } from '@/lib/rbac'; /** * No fallback secret, deliberately. @@ -50,6 +52,29 @@ export async function middleware(request: NextRequest) { } try { + const { payload } = await jwtVerify(sessionCookie, key, { algorithms: ['HS256'] }); + const merchantAddress = typeof payload.publicKey === 'string' ? payload.publicKey : null; + if (isPrivateApi && !merchantAddress) { + // A session with no identifiable merchant cannot be scoped to any + // tenant's data — treat it the same as no session at all. + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // RBAC (#156): the role rides in the signed session. Legacy sessions + // without a role claim default to admin, so an existing cookie is never + // locked out of the dashboard mid-deployment. + const role: Role = parseRole(payload.role) ?? 'admin'; + + // Route handlers trust this header for merchant scoping instead of each + // re-verifying and re-decoding the session cookie themselves. It is only + // ever set here, after jwtVerify has succeeded, so a request cannot + // forge it — Next.js middleware runs before the request reaches a route + // handler and this header is set on the *outgoing* request, overwriting + // any value a caller tried to smuggle in. + const headers = new Headers(request.headers); + headers.set('x-accensa-merchant', merchantAddress ?? ''); + headers.set('x-accensa-role', role); + return NextResponse.next({ request: { headers } }); await jwtVerify(sessionCookie, key, { algorithms: ['HS256'] }); return NextResponse.next(); } catch {