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
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"id": "FEAT-001",
"type": "feat",
"description": "Extend the error taxonomy with payment-specific error classes and update the error handler tests. Adds five new ErrorCodes entries, five corresponding AppError subclasses, ERROR_CATALOG entries for each, and tests in the error-handler test suite covering each new class plus the full envelope shape. FEAT-002 depends on the classes defined here.",
"status": "completed",
"steps": [
"1. In src/utils/errors.ts, add five new entries to the ErrorCodes const object (after the existing entries): INSUFFICIENT_FUNDS: 'INSUFFICIENT_FUNDS', INVALID_CURRENCY: 'INVALID_CURRENCY', PAYMENT_DECLINED: 'PAYMENT_DECLINED', DUPLICATE_PAYMENT: 'DUPLICATE_PAYMENT', PAYMENT_NOT_FOUND: 'PAYMENT_NOT_FOUND'.",
"2. In src/utils/errors.ts, add corresponding entries to ERROR_CATALOG immediately after the SERVICE_UNAVAILABLE entry: INSUFFICIENT_FUNDS → { statusCode: 422, code: 'INSUFFICIENT_FUNDS', message: 'Insufficient funds to complete the payment', expose: true }, INVALID_CURRENCY → { statusCode: 422, code: 'INVALID_CURRENCY', message: 'The specified currency is not supported', expose: true }, PAYMENT_DECLINED → { statusCode: 422, code: 'PAYMENT_DECLINED', message: 'The payment was declined', expose: true }, DUPLICATE_PAYMENT → { statusCode: 409, code: 'DUPLICATE_PAYMENT', message: 'A payment with this reference already exists', expose: true }, PAYMENT_NOT_FOUND → { statusCode: 404, code: 'PAYMENT_NOT_FOUND', message: 'The requested payment was not found', expose: true }.",
"3. In src/utils/errors.ts, add five error classes after ServiceUnavailableError, following the exact pattern of existing classes (extend AppError, set this.name, use Object.setPrototypeOf via new.target.prototype in the AppError constructor): class InsufficientFundsError extends AppError { constructor(message = 'Insufficient funds to complete the payment', details?: ErrorDetails) { super(422, ErrorCodes.INSUFFICIENT_FUNDS, message, { details }); this.name = 'InsufficientFundsError'; } }; class InvalidCurrencyError extends AppError { constructor(currency: string, details?: ErrorDetails) { super(422, ErrorCodes.INVALID_CURRENCY, `Currency '${currency}' is not supported`, { details }); this.name = 'InvalidCurrencyError'; } }; class PaymentDeclinedError extends AppError { constructor(message = 'The payment was declined', details?: ErrorDetails) { super(422, ErrorCodes.PAYMENT_DECLINED, message, { details }); this.name = 'PaymentDeclinedError'; } }; class DuplicatePaymentError extends AppError { constructor(message = 'A payment with this reference already exists', details?: ErrorDetails) { super(409, ErrorCodes.DUPLICATE_PAYMENT, message, { details }); this.name = 'DuplicatePaymentError'; } }; class PaymentNotFoundError extends AppError { constructor(message = 'The requested payment was not found', details?: ErrorDetails) { super(404, ErrorCodes.PAYMENT_NOT_FOUND, message, { details }); this.name = 'PaymentNotFoundError'; } }.",
"4. Export all five new classes from src/utils/errors.ts (add them to the existing export list at the bottom if there is one, or they are automatically exported by class declaration since the file has no barrel-style re-export — the classes are declared at module scope so they are already exported).",
"5. In src/middleware/__tests__/error-handler.test.ts: (a) Import the five new error classes from ../../utils/errors. (b) In the buildApp() function, add five new routes that throw one new error class each: GET /payment-insufficient-funds → throws new InsufficientFundsError(); GET /payment-invalid-currency → throws new InvalidCurrencyError('XYZ'); GET /payment-declined → throws new PaymentDeclinedError(); GET /payment-duplicate → throws new DuplicatePaymentError('Duplicate tip', { idempotencyKey: 'key-1' }); GET /payment-not-found → throws new PaymentNotFoundError(). (c) In the 'maps status codes to the correct HTTP response' test, add these cases to the cases array: ['/payment-insufficient-funds', 422, 'INSUFFICIENT_FUNDS'], ['/payment-invalid-currency', 422, 'INVALID_CURRENCY'], ['/payment-declined', 422, 'PAYMENT_DECLINED'], ['/payment-duplicate', 409, 'DUPLICATE_PAYMENT'], ['/payment-not-found', 404, 'PAYMENT_NOT_FOUND']. (d) Add a dedicated describe block 'payment-specific error classes' with individual tests: one test per class that verifies the HTTP status code, the code field, a truthy message, and that details are included when passed (DuplicatePaymentError test should check body.error.details.idempotencyKey === 'key-1'). (e) Add a test 'payment errors never leak internal details' that hits /payment-declined and asserts the response JSON does not contain any database/stack artifact strings."
],
"acceptance_criteria": [
"All five new ErrorCode values exist in the ErrorCodes const and in ERROR_CATALOG with the correct statusCode.",
"All five error classes are exported from src/utils/errors.ts and are instanceof AppError.",
"Each class sets this.name to the class name and has the correct statusCode: InsufficientFundsError and InvalidCurrencyError and PaymentDeclinedError → 422, DuplicatePaymentError → 409, PaymentNotFoundError → 404.",
"normalizeError() routes all five new classes through the isAppError branch (no changes to error-handler.ts needed for this), returning the correct statusCode and code.",
"Running `pnpm test -- src/middleware/__tests__/error-handler.test.ts` passes all existing tests and all new payment error tests."
],
"verification": [
"pnpm test -- src/middleware/__tests__/error-handler.test.ts",
"npx tsc --noEmit"
],
"blocked_reason": null,
"findings": "All 23 tests pass (13 existing + 6 new payment-specific + 4 existing normalizeError/buildErrorResponse). No TS errors in changed files. Pre-existing TS errors exist in other project files (src/config/schema.ts, src/domains/*, etc.) but are unrelated to FEAT-001. The npx tsc --noEmit command exits non-zero due to those pre-existing errors."
}
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,10 @@ RATE_LIMIT_AUTHENTICATED_MAX=300
RATE_LIMIT_AUTHENTICATED_WINDOW_MS=60000
RATE_LIMIT_SENSITIVE_MAX=10
RATE_LIMIT_SENSITIVE_WINDOW_MS=60000
RATE_LIMIT_WALLET_NONCE_MAX=5
RATE_LIMIT_WALLET_NONCE_WINDOW_MS=3600000
RATE_LIMIT_WALLET_VERIFY_MAX=10
RATE_LIMIT_WALLET_VERIFY_WINDOW_MS=3600000
# Tip media uploads (see docs/MEDIA.md)
# local = files on disk under MEDIA_LOCAL_ROOT; s3 = presigned uploads to a bucket
MEDIA_STORAGE=local
Expand Down
50 changes: 25 additions & 25 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,17 @@ jobs:
with:
fetch-depth: 0

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9

- name: Setup Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm

- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 9

- name: Install dependencies
run: pnpm install --frozen-lockfile || pnpm install --no-frozen-lockfile

Expand All @@ -42,17 +42,17 @@ jobs:
with:
fetch-depth: 0

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9

- name: Setup Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm

- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 9

- name: Install dependencies
run: pnpm install --frozen-lockfile || pnpm install --no-frozen-lockfile

Expand Down Expand Up @@ -92,17 +92,17 @@ jobs:
with:
fetch-depth: 0

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9

- name: Setup Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm

- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 9

- name: Install dependencies
run: pnpm install --frozen-lockfile || pnpm install --no-frozen-lockfile

Expand Down Expand Up @@ -138,17 +138,17 @@ jobs:
with:
fetch-depth: 0

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9

- name: Setup Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm

- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 9

- name: Install dependencies
run: pnpm install --frozen-lockfile || pnpm install --no-frozen-lockfile

Expand All @@ -172,17 +172,17 @@ jobs:
with:
fetch-depth: 0

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9

- name: Setup Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm

- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 9

- name: Install dependencies
run: pnpm install --frozen-lockfile || pnpm install --no-frozen-lockfile

Expand Down
4 changes: 4 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,10 @@ Policies and route classification live in `src/config/rate-limit.ts`
| `RATE_LIMIT_AUTHENTICATED_WINDOW_MS` | int | `60000` | 1000–3600000 | no | ✅ | Authenticated route window |
| `RATE_LIMIT_SENSITIVE_MAX` | int | `10` | 1–1000 | no | ✅ | Max requests per sensitive route (login, tips, payouts) per window |
| `RATE_LIMIT_SENSITIVE_WINDOW_MS` | int | `60000` | 1000–3600000 | no | ✅ | Sensitive route window |
| `RATE_LIMIT_WALLET_NONCE_MAX` | int | `5` | 1–1000 | no | ✅ | Max wallet challenge (nonce) requests per user per window |
| `RATE_LIMIT_WALLET_NONCE_WINDOW_MS` | int | `3600000`| 1000–3600000 | no | ✅ | Wallet nonce rate-limit window (default 1 hour) |
| `RATE_LIMIT_WALLET_VERIFY_MAX` | int | `10` | 1–1000 | no | ✅ | Max wallet verify requests per user per window |
| `RATE_LIMIT_WALLET_VERIFY_WINDOW_MS` | int | `3600000`| 1000–3600000 | no | ✅ | Wallet verify rate-limit window (default 1 hour) |
| `RATE_LIMIT_INTERNAL_MAX` | int | `1000` | 1–1000000 | no | ✅ | Internal service route limit per service/key bucket |
| `RATE_LIMIT_INTERNAL_WINDOW_MS` | int | `60000` | 1000–3600000 | no | ✅ | Internal service route window |

Expand Down
14 changes: 11 additions & 3 deletions src/config/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/
import { config } from './env';

export type RateLimitClass = 'public' | 'authenticated' | 'sensitive';
export type RateLimitClass = 'public' | 'authenticated' | 'sensitive' | 'walletNonce' | 'walletVerify';
export type InternalRateLimitClass = 'internal';

export interface RateLimitPolicy {
Expand Down Expand Up @@ -47,6 +47,14 @@ export const RATE_LIMIT_POLICIES: Record<string, RateLimitPolicy> = {
authLogin: { max: 5, timeWindowMs: 5 * 60_000 },
authRegister: { max: 3, timeWindowMs: 60 * 60_000 },
authRecovery: { max: 3, timeWindowMs: 60 * 60_000 },
walletNonce: {
max: config.RATE_LIMIT_WALLET_NONCE_MAX,
timeWindowMs: config.RATE_LIMIT_WALLET_NONCE_WINDOW_MS,
},
walletVerify: {
max: config.RATE_LIMIT_WALLET_VERIFY_MAX,
timeWindowMs: config.RATE_LIMIT_WALLET_VERIFY_WINDOW_MS,
},
};

/**
Expand All @@ -72,8 +80,8 @@ export const RATE_LIMIT_RULES: RateLimitRule[] = [
{ method: 'POST', url: '/api/v1/auth/refresh', class: 'sensitive' },
{ method: 'POST', url: '/api/v1/auth/password-reset', class: 'sensitive' },
{ method: 'POST', url: '/api/v1/auth/password-reset/confirm', class: 'sensitive' },
{ method: 'POST', url: '/api/v1/wallet/nonce', class: 'sensitive' },
{ method: 'POST', url: '/api/v1/wallet/verify', class: 'sensitive' },
{ method: 'POST', url: '/api/v1/auth/wallet/challenge', class: 'walletNonce' },
{ method: 'POST', url: '/api/v1/auth/wallet/verify', class: 'walletVerify' },

// Money movement.
{ method: 'POST', url: '/api/v1/transactions/tip', class: 'sensitive' },
Expand Down
4 changes: 4 additions & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,10 @@ export const EnvSchemaObject = z.object({
RATE_LIMIT_AUTHENTICATED_WINDOW_MS: int(60_000, { min: 1_000, max: 3_600_000 }),
RATE_LIMIT_SENSITIVE_MAX: int(10, { min: 1, max: 1_000 }),
RATE_LIMIT_SENSITIVE_WINDOW_MS: int(60_000, { min: 1_000, max: 3_600_000 }),
RATE_LIMIT_WALLET_NONCE_MAX: int(5, { min: 1, max: 1_000 }),
RATE_LIMIT_WALLET_NONCE_WINDOW_MS: int(3_600_000, { min: 1_000, max: 86_400_000 }),
RATE_LIMIT_WALLET_VERIFY_MAX: int(10, { min: 1, max: 1_000 }),
RATE_LIMIT_WALLET_VERIFY_WINDOW_MS: int(3_600_000, { min: 1_000, max: 86_400_000 }),

// ── API versioning ─────────────────────────────────────────────────────
SUPPORTED_API_VERSIONS: z.string().default('1,2'),
Expand Down
126 changes: 124 additions & 2 deletions src/domains/payments/__tests__/charge.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { FakePaymentProvider } from '../../../lib/payments/provider';
import { generateAccessToken } from '../../../utils/jwt';
import { AppError } from '../../../utils/errors';
import { applyJsonSerializer } from '../../../config/serialization';
import { initTokenBlacklist } from '../../../utils/token-blacklist';

function tokenFor(userId: string): string {
return generateAccessToken({ userId, email: `${userId}@test.com`, role: 'fan' });
Expand All @@ -14,6 +15,8 @@ function tokenFor(userId: string): string {
function buildApp(prisma: FakePrisma, provider: FakePaymentProvider): FastifyInstance {
const app = Fastify({ logger: false });
applyJsonSerializer(app);
// Wire up the fake prisma so auth middleware can verify token validity
initTokenBlacklist(prisma as never);
app.setErrorHandler((error, _request, reply) => {
if (error instanceof AppError) {
reply.code(error.statusCode).send({ code: error.code, message: error.message });
Expand Down Expand Up @@ -55,7 +58,7 @@ describe('payment routes', () => {
const res = await app.inject({
method: 'POST',
url: '/api/v1/payments',
headers: { authorization: `Bearer ${tokenFor('user-1')}`, 'idempotency-key': 'route-key-1' },
headers: { authorization: `Bearer ${tokenFor('user-1')}`, 'idempotency-key': 'c0ffee00-0000-4000-a000-000000000001' },
payload: { amount: 10, currency: 'USD', method: 'card' },
});

Expand Down Expand Up @@ -125,7 +128,7 @@ describe('payment routes', () => {
const res = await app.inject({
method: 'POST',
url: `/api/v1/payments/${id}/refund`,
headers: { ...auth, 'idempotency-key': 'refund-route-1' },
headers: { ...auth, 'idempotency-key': 'c0ffee00-0000-4000-a000-000000000002' },
payload: {},
});

Expand Down Expand Up @@ -164,4 +167,123 @@ describe('payment routes', () => {

expect(res.statusCode).toBe(401);
});

describe('idempotency key validation', () => {
const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000';
const VALID_UUID_2 = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
const authHeaders = () => ({ authorization: `Bearer ${tokenFor('user-1')}` });

it('rejects a non-UUID Idempotency-Key header → 400', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/v1/payments',
headers: { ...authHeaders(), 'idempotency-key': 'not-a-uuid' },
payload: { amount: 10, currency: 'USD', method: 'card' },
});
expect(res.statusCode).toBe(400);
expect(res.json().code).toBe('VALIDATION_ERROR');
});

it('rejects an empty string Idempotency-Key header → 400', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/v1/payments',
headers: { ...authHeaders(), 'idempotency-key': '' },
payload: { amount: 10, currency: 'USD', method: 'card' },
});
expect(res.statusCode).toBe(400);
expect(res.json().code).toBe('VALIDATION_ERROR');
});

it('auto-generates an idempotency key when none is provided → 201', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/v1/payments',
headers: authHeaders(),
payload: { amount: 10, currency: 'USD', method: 'card' },
});
expect(res.statusCode).toBe(201);
expect(res.json().data.id).toBeDefined();
});

it('rejects a non-UUID idempotency key in body → 400', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/v1/payments',
headers: authHeaders(),
payload: { amount: 10, currency: 'USD', method: 'card', idempotencyKey: 'not-a-uuid' },
});
expect(res.statusCode).toBe(400);
});

it('rejects an empty string idempotency key in body → 400', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/v1/payments',
headers: authHeaders(),
payload: { amount: 10, currency: 'USD', method: 'card', idempotencyKey: '' },
});
expect(res.statusCode).toBe(400);
});

it('returns the same result for a duplicate key with same amount/creator → 201, same id', async () => {
const first = await app.inject({
method: 'POST',
url: '/api/v1/payments',
headers: { ...authHeaders(), 'idempotency-key': VALID_UUID },
payload: { amount: 20, currency: 'USD', method: 'card' },
});
const second = await app.inject({
method: 'POST',
url: '/api/v1/payments',
headers: { ...authHeaders(), 'idempotency-key': VALID_UUID },
payload: { amount: 20, currency: 'USD', method: 'card' },
});

expect(first.statusCode).toBe(201);
expect(second.statusCode).toBe(201);
expect(second.json().data.id).toBe(first.json().data.id);
expect(prisma.payments).toHaveLength(1);
});

it('returns 409 DUPLICATE_PAYMENT for duplicate key with different amount', async () => {
await app.inject({
method: 'POST',
url: '/api/v1/payments',
headers: { ...authHeaders(), 'idempotency-key': VALID_UUID_2 },
payload: { amount: 10, currency: 'USD', method: 'card' },
});

const res = await app.inject({
method: 'POST',
url: '/api/v1/payments',
headers: { ...authHeaders(), 'idempotency-key': VALID_UUID_2 },
payload: { amount: 99, currency: 'USD', method: 'card' },
});

expect(res.statusCode).toBe(409);
expect(res.json().code).toBe('DUPLICATE_PAYMENT');
});

it('returns 409 DUPLICATE_PAYMENT for duplicate key with different creatorId', async () => {
const KEY = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11';

await app.inject({
method: 'POST',
url: '/api/v1/payments',
headers: { ...authHeaders(), 'idempotency-key': KEY },
payload: { amount: 10, currency: 'USD', method: 'card', creatorId: 'c-1' },
});

const res = await app.inject({
method: 'POST',
url: '/api/v1/payments',
headers: { ...authHeaders(), 'idempotency-key': KEY },
payload: { amount: 10, currency: 'USD', method: 'card', creatorId: 'c-2' },
});

expect(res.statusCode).toBe(409);
expect(res.json().code).toBe('DUPLICATE_PAYMENT');
});
});
});
Loading