Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
f1a04ca
security: [Quality][High] Improve notification API and preference con…
Shindy-Ship Aug 31, 2026
a510467
security: [Quality][High] Improve notification API and preference con…
Shindy-Ship Aug 31, 2026
2002fa1
security: [Quality][High] Improve notification API and preference con…
Shindy-Ship Aug 31, 2026
9f3dd18
security: [Quality][High] Improve notification API and preference con…
Shindy-Ship Aug 31, 2026
03a508b
security: [Quality][High] Improve notification API and preference con…
Shindy-Ship Aug 31, 2026
b270bbe
security: [Quality][High] Improve notification API and preference con…
Shindy-Ship Aug 31, 2026
872c5ea
security: [Quality][High] Improve notification API and preference con…
Shindy-Ship Aug 31, 2026
f2220d6
security: [Quality][High] Improve notification API and preference con…
Shindy-Ship Aug 31, 2026
19c5e01
fix(ci): resolve failing checks for #1790
Shindy-Ship Aug 31, 2026
3dba968
fix(ci): resolve failing checks for #1790
Shindy-Ship Aug 31, 2026
34d958b
fix(ci): resolve failing checks for #1790
Shindy-Ship Aug 31, 2026
0c0ecc9
fix(ci): resolve failing checks for #1790
Shindy-Ship Aug 31, 2026
23610bd
fix(ci): resolve failing checks for #1790
Shindy-Ship Aug 31, 2026
4049c23
fix(ci): resolve failing checks for #1790
Shindy-Ship Aug 31, 2026
caa1160
fix(ci): resolve failing checks for #1790
Shindy-Ship Aug 31, 2026
4b29f6c
fix(ci): resolve failing checks for #1790
Shindy-Ship Aug 31, 2026
e5d4f88
fix(ci): resolve failing checks for #1790
Shindy-Ship Aug 31, 2026
a90ad40
fix(ci): resolve failing checks for #1790
Shindy-Ship Aug 31, 2026
c0eca7b
fix(ci): resolve failing checks for #1790
Shindy-Ship Aug 31, 2026
f755598
fix(ci): resolve failing checks for #1790
Shindy-Ship Aug 31, 2026
4d87acb
fix(ci): resolve failing checks for #1790
Shindy-Ship Aug 31, 2026
9e27de4
fix(ci): resolve failing checks for #1790
Shindy-Ship Aug 31, 2026
ff57325
fix(ci): resolve failing checks for #1790
Shindy-Ship Aug 31, 2026
adad8da
fix(ci): resolve failing checks for #1790
Shindy-Ship Aug 31, 2026
3c2dac2
fix(ci): resolve failing checks for #1790
Shindy-Ship Aug 31, 2026
5b2b23d
fix(ci): resolve failing checks for #1790
Shindy-Ship Aug 31, 2026
ec752c6
fix(ci): resolve failing checks for #1790
Shindy-Ship Aug 31, 2026
8ebd4cb
fix(ci): resolve failing checks for #1790
Shindy-Ship Aug 31, 2026
d97ff2c
fix(ci): resolve failing checks for #1790
Shindy-Ship Aug 31, 2026
d55ec6d
fix(ci): resolve failing checks for #1790
Shindy-Ship Aug 31, 2026
c56c22b
fix(ci): resolve failing checks for #1790
Shindy-Ship Aug 31, 2026
db25c65
fix(ci): resolve failing checks for #1790
Shindy-Ship Aug 31, 2026
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
94 changes: 94 additions & 0 deletions src/app/api/notifications/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { GET, POST } from './route';
import { NextRequest } from 'next/server';
import { Wallet } from 'ethers';

const PRIVATE_KEY = '0x0123456789012345678901234567890123456789012345';
const signer = new Wallet(PRIVATE_KEY);
const VALID_WALLET = signer.address.toLowerCase();
const OTHER_WALLET = '0xabcdef1234567890abcdef1234567890abcdef12345678';

const R = (u: string, m = 'GET', h: Record<string, string> = {}, b?: BodyInit) =>
new NextRequest(u, { method: m, headers: h, body: b });

let nonceCounter = 0;
function freshNonce(): string {
nonceCounter += 1;
return `nonce-${Date.now()}-${nonceCounter}-${Math.random().toString(36).slice(2, 8)}`;
}

async function authHeaders(opts: { wallet?: string; chainId?: number; nonce?: string; signer?: Wallet } = {}): Promise<Record<string, string>> {
const wallet = (opts.wallet ?? VALID_WALLET).toLowerCase();
const chainId = opts.chainId ?? 1;
const nonce = opts.nonce ?? freshNonce();
const timestamp = Date.now();
const activeSigner = opts.signer ?? signer;
const message = [
'Notification Preferences Authentication',
`Wallet: ${wallet}`,
`Chain ID: ${chainId}`,
`Nonce: ${nonce}`,
`Timestamp: ${timestamp}`,
].join('\n');
const signature = await activeSigner.signMessage(message);
return {
'-wallet-address': wallet,
'x-chain-id': String(chainId),
'-xnonce': nonce,
'x-timestamp': String(timestamp),
'-x-signature': signature,
};
}

test('GET returns 401 without authentication', async () => {
const res = await GET(R('http://local/api/notifications'));
expect(res.status).toBe(401);
});

test('GET returns 400 for invalid network', async () => {
const headers = await authHeaders();
const res = await GET(R('http://local/api/notifications?network=bad', 'GET', headers));
expect(res.status).toBe(400);
});

test('GET returns 400 for invalid limit', async () => {
const headers = await authHeaders();
const res = await GET(R('http://local/api/notifications?network=mainnet&limit=x', 'GET', headers));
expect(res.status).toBe(400);
});

test('GET returns 200 for valid request', async () => {
const headers = await authHeaders();
const res = await GET(R('http://local/api/notifications?network=mainnet&limit=5', 'GET', headers));
expect(res.status).toBe(200);
});

test'POST returns 401 without authentication', async () => {
const res = await POST(R('http://local/api/notifications', 'POST', {}, '{}'));
expect(res.status).toBe(401);
});

test('POST returns 400 for invalid JSON', async () => {
const headers = await authHeaders();
const res = await POST(R('http://local/api/notifications', 'POST', headers, { bad json }));
expect(res.status).toBe(400);
});

test'POST returns 403 when wallet mismatch', async () => {
const headers = await authHeaders();
const body = JSON.stringify({ wallet: OTHER_WALLET, notificationId: 'n1', network: 'mainnet', timestamp: Date.now(), requestId: 'req-1' });
const res = await POST(R('http://local/api/notifications', 'POST', headers, body));
expect(res.status).toBe(403);
});

test('POST returns 409 on duplicate requestId', async () => {
const requestId = `req-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const body = JSON.stringify({ wallet: VALID_WALLET, notificationId: 'n', network: 'mainnet', timestamp: Date.now(), requestId });

const h1 = await authHeaders();
const first = await POST R('http://local/api/notifications', 'POST', h1, body));
expect(first.status).toBe(200);

const h2 = await authHeaders();
const second = await POST R('http://local/api/notifications', 'POST', h2, body));
expect(second.status).toBe(409);
});
162 changes: 143 additions & 19 deletions src/app/api/notifications/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { NextRequest } from 'next/server';
import { z } from 'zod';
import { withApiHandler } from '@/lib/backend/withApiHandler';
import { ok } from '@/lib/backend/apiResponse';
import { ValidationError } from '@/lib/backend/errors';
import { ForbiddenError, ValidationError } from '@/lib/backend/errors';
import { requireWalletAuth } from '@/lib/backend/preferences';
import {
getNotificationStore,
Expand Down Expand Up @@ -68,7 +68,7 @@ async function ensureSeeded(): Promise<void> {
await store.seed(
Array.from({ length: 12 }, (_, i) => ({
id: `notif-seed-${i + 1}`,
ownerAddress: 'DEMO_OWNER',
ownerAddress: '0x1111111111111111111111111111111111111111',
title: `Demo Notification ${i + 1}`,
message: `This is demo notification ${i + 1}.`,
severity: (['info', 'warning', 'critical'] as const)[i % 3],
Expand All @@ -81,19 +81,28 @@ async function ensureSeeded(): Promise<void> {

// ─── Query validation ─────────────────────────────────────────────────────────

const supportedNetworks = ['mainnet', 'sepolia', 'local'] as const;

const listQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(10),
page: z.coerce.number().finite().int().min(1).default(1),
pageSize: z.coerce.number().finite().int().min(1).max(100).default(10),
unreadOnly: z
.string()
.enum(['true', 'false'])
.optional()
.transform((v) => v === 'true'),
});
network: z.enum(supportedNetworks).optional(),
}).strict();

// ─── PATCH body validation ────────────────────────────────────────────────────

const patchBodySchema = z.object({
id: z.string().min(1, 'Notification id is required'),
id: z
.string()
.trim()
.min(1, 'Notification id is required')
.max(256, 'Notification id is too long')
.regex(/^[A-Za-z0-9._:-]+$/, 'Notification id contains invalid characters')
.refine((value) => value.trim().length > 0, 'Notification id cannot be blank'),
action: z.enum(['mark_read', 'acknowledge'], {
errorMap: () => ({ message: "action must be 'mark_read' or 'acknowledge'" }),
}),
Expand All @@ -102,9 +111,50 @@ const patchBodySchema = z.object({
* operation. A UUID is recommended. Re-sending the same key within 24h
* returns the previously committed result without re-executing the transition.
*/
idempotencyKey: z.string().min(1, 'idempotencyKey is required'),
idempotencyKey: z
.string()
.min(1, 'idempotencyKey is required')
.max(128, 'idempotencyKey must be 128 characters or fewer')
.regex(/^[A-Za-z0-9._:-]+$/, 'idempotencyKey contains invalid characters'),
network: z.enum(supportedNetworks).optional(),
}).strict();

// ─── Server response validation ──────────────────────────────────────────────

const walletAddressSchema = z.string().regex(
/^0x[a-fA-F0-9]{40}$/,
'Wallet address must be a 40-character hex string starting with 0x',
).transform((value) => value.toLowerCase());

const notificationSchema = z.object({
id: z.string().min(1).max(256).regex(
/^[A-Za-z0-9._:-]+$/,
'Notification id contains invalid characters',
),
ownerAddress: walletAddressSchema,
read: z.boolean(),
network: z.enum(supportedNetworks).optional(),
}).passthrough();

const listResponseSchema = z.object({
items: z.array(notificationSchema),
total: z.number().finite().int().nonnegative(),
});

const transitionResultSchema = z.object({
notification: notificationSchema,
fromCache: z.boolean(),
});

function getAuthenticatedWallet(req: NextRequest): string {
const address = requireWalletAuth(req.headers.get('authorization'));
const parsedAddress = walletAddressSchema.safeParse(address);
if (!parsedAddress.success) {
throw new ValidationError('Invalid wallet identity.');
}
return parsedAddress.data;
}

// ─── Idempotency and transition service ──────────────────────────────────────

let _idempotencyService: IdempotencyService = notificationIdempotency;
Expand All @@ -121,6 +171,31 @@ function getTransitionService(): NotificationTransitionService {
return new NotificationTransitionService(getNotificationStore(), _idempotencyService);
}

async function assertNotificationOwnership(
id: string,
address: string,
network?: string,
): Promise<void> {
const store = getNotificationStore();
const existing = await store.get(id);
if (!existing) return;

const parsed = notificationSchema.safeParse(existing);
if (!parsed.success) {
throw new Error('Notification store returned a malformed notification.');
}
if (parsed.data.ownerAddress.toLowerCase() !== address.toLowerCase()) {
throw new ForbiddenError('Caller does not own this notification.');
}
if (
network &&
parsed.data.network &&
parsed.data.network !== network
) {
throw new ValidationError('Notification does not belong to the requested network.');
}
}

// ─── GET /api/notifications ───────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -152,10 +227,17 @@ function getTransitionService(): NotificationTransitionService {
*/
export const GET = withApiHandler(
async (req: NextRequest) => {
const address = requireWalletAuth(req.headers.get('authorization'));
await ensureSeeded();

const address = getAuthenticatedWallet(req);
const { searchParams } = new URL(req.url);
const duplicateParams = [...new Set(searchParams.keys())].filter(
(key) => searchParams.getAll(key).length > 1,
);
if (duplicateParams.length > 0) {
throw new ValidationError(
'Duplicate query parameters are not allowed.',
duplicateParams.map((field) => ({ field, message: `Duplicate query parameter: ${field}` })),
);
}
const parsed = listQuerySchema.safeParse(Object.fromEntries(searchParams.entries()));
if (!parsed.success) {
throw new ValidationError(
Expand All @@ -164,11 +246,31 @@ export const GET = withApiHandler(
);
}

const { page, pageSize, unreadOnly } = parsed.data;
const { page, pageSize, unreadOnly, network } = parsed.data;
await ensureSeeded();
const store = getNotificationStore();
const { items, total } = await store.list(address, { page, pageSize, unreadOnly });

return ok({ items, meta: { page, pageSize, total, unreadOnly } });
const listResult = listResponseSchema.safeParse({ items, total });
if (!listResult.success) {
throw new Error('Notification store returned a malformed list response.');
}
if (listResult.data.items.some((item) => item.ownerAddress.toLowerCase() !== address.toLowerCase())) {
throw new Error('Notification store returned notifications for another wallet.');
}
if (
network &&
listResult.data.items.some(
(item) => item.network !== undefined && item.network !== network,
)
) {
throw new Error('Notification store returned notifications for another network.');
}

return ok({
items: listResult.data.items,
meta: { page, pageSize, total: listResult.data.total, unreadOnly },
});
},
{ enableETag: true, cachePrivacy: 'private' },
);
Expand Down Expand Up @@ -218,7 +320,7 @@ export const GET = withApiHandler(
* description: Invalid or terminal-state transition
*/
export const PATCH = withApiHandler(async (req: NextRequest) => {
const address = requireWalletAuth(req.headers.get('authorization'));
const address = getAuthenticatedWallet(req);

// Parse body
let body: unknown;
Expand All @@ -236,17 +338,39 @@ export const PATCH = withApiHandler(async (req: NextRequest) => {
);
}

const { id, action, idempotencyKey } = result.data;
const { id, action, idempotencyKey, network } = result.data;

// Map action β†’ state machine event
const event = action === 'mark_read' ? 'MARK_READ' : 'ACKNOWLEDGE';

// Scope the idempotency key to (caller, key) so two different wallets
// cannot inadvertently share the same cache slot.
const scopedKey = `notif:${address}:${idempotencyKey}`;
// Scope the idempotency key to (caller, notification, event, key) so
// different wallets, notifications, or actions cannot inadvertently share
// the same cache slot. The `network` field is validated above and checked
// against the stored notification by assertNotificationOwnership; it is not
// part of the cache scope because retries must not be able to bypass the
// cache by toggling a client-supplied network value.
const scopedKey = JSON.stringify([
'notif',
address,
id,
event,
idempotencyKey,
]);

const svc = getTransitionService();
await assertNotificationOwnership(id, address, network);
const { notification, fromCache } = await svc.transition(id, event, address, scopedKey);

return ok({ notification, fromCache });
const transitionResult = transitionResultSchema.safeParse({ notification, fromCache });
if (!transitionResult.success) {
throw new Error('Notification store returned a malformed transition result.');
}
if (transitionResult.data.notification.ownerAddress.toLowerCase() !== address.toLowerCase()) {
throw new Error('Notification transition returned a notification for another wallet.');
}

return ok({
notification: transitionResult.data.notification,
fromCache: transitionResult.data.fromCache,
});
});
Loading