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
14 changes: 14 additions & 0 deletions apps/web/src/app/api/merchant/profile/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);

Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/app/api/refund/preflight/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand Down
19 changes: 19 additions & 0 deletions apps/web/src/app/api/session/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
21 changes: 20 additions & 1 deletion apps/web/src/app/api/sync/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,26 @@ async function rpc<T>(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');
Expand Down
83 changes: 83 additions & 0 deletions apps/web/src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<ReadonlySet<string>>(() => 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<Role | null>(null);
const [refunded, setRefunded] = useState<ReadonlySet<string>>(() => new Set());
const markRefunded = useCallback(
(txHash: string) => setRefunded((prev) => new Set(prev).add(txHash)),
Expand All @@ -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<PaymentsResponse>(
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), []);

Expand Down Expand Up @@ -297,6 +368,7 @@ export default function Dashboard() {
onClose={closeModal}
refunded={refunded}
onRefunded={markRefunded}
canRefund={canRefund}
/>
)}
</main>
Expand All @@ -310,11 +382,14 @@ export function PaymentModal({
onClose,
refunded,
onRefunded,
canRefund,
}: {
selected: Payment;
onClose: () => void;
refunded: ReadonlySet<string>;
onRefunded: (tx_hash: string) => void;
/** False for viewer sessions, which must not be able to initiate refunds (#156). */
canRefund?: boolean;
}) {
const dialogRef = useRef<HTMLDivElement>(null);

Expand Down Expand Up @@ -443,6 +518,14 @@ export function PaymentModal({
</a>
</div>

{canRefund !== false && (
<div className="pt-6 mt-6 border-t border-slate-100 dark:border-white/10 transition-colors duration-300">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-600 dark:text-slate-300 mb-3">
Refund
</p>
<RefundPanel payment={selected} onRefunded={onRefunded} />
</div>
)}
<div className="pt-6 mt-6 border-t border-slate-100 dark:border-white/10 transition-colors duration-300">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 dark:text-slate-500 mb-3">
Refund
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ export async function decrypt(input: string): Promise<Record<string, unknown> |
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,
Expand Down
47 changes: 47 additions & 0 deletions apps/web/src/lib/rbac.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
25 changes: 25 additions & 0 deletions apps/web/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
Loading