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
1 change: 1 addition & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import { corsAllowlistMiddleware } from './middleware/cors.js';
import { requestLoggerMiddleware } from './middleware/requestLogger.js';
import { errorHandler } from './middleware/errorHandler.js';
import { bodySizeLimitMiddleware, requestTimeoutMiddleware, BODY_LIMIT_BYTES } from './middleware/requestProtection.js';

Check failure on line 16 in src/app.ts

View workflow job for this annotation

GitHub Actions / Lint, Format, Test (20.x)

'requestTimeoutMiddleware' is defined but never used
import { httpMetrics } from './middleware/httpMetrics.js';
import { isShuttingDown } from './shutdown.js';
import { createRateLimiter } from './middleware/rateLimiter.js';
Expand Down Expand Up @@ -75,6 +75,7 @@
app.use('/api/streams', streamsRouter);
app.use('/api/admin', adminRouter);
app.use('/internal/indexer', indexerRouter);
app.use('/internal/webhooks', webhooksRouter);
app.use('/api/audit', auditRouter);
app.use('/admin/dlq', dlqRouter);
app.use('/api/rate-limits', createRateLimitsRouter(rateLimiter, { defaults: getRateLimitConfig(env) }));
Expand All @@ -90,7 +91,7 @@
});

app.use((req: Request, res: Response) => {
const requestId = (req as any).id as string | undefined;

Check failure on line 94 in src/app.ts

View workflow job for this annotation

GitHub Actions / Lint, Format, Test (20.x)

Unexpected any. Specify a different type
res.status(404).json(
errorResponse('NOT_FOUND', 'The requested resource was not found', undefined, requestId),
);
Expand Down
6 changes: 4 additions & 2 deletions src/routes/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import express from 'express';
import type { Request, Response } from 'express';
import { webhookService } from '../webhooks/service.js';
import { webhookDeliveryStore, type DeadLetterQueueItem, type OutboxItem, type CircuitBreakerState } from '../webhooks/store.js';
import { verifyWebhookSignature } from '../webhooks/signature.js';
Expand Down Expand Up @@ -75,7 +76,7 @@ webhooksRouter.post('/queue', express.json(), async (req, res) => {
* GET /api/webhooks/deliveries/:deliveryId
* Get the status of a webhook delivery
*/
webhooksRouter.get('/deliveries/:deliveryId', (req, res) => {
webhooksRouter.get('/deliveries/:deliveryId', (req: Request, res: Response): void => {
const { deliveryId } = req.params;
const requestId = (req as any).id as string | undefined;

Expand Down Expand Up @@ -107,7 +108,7 @@ webhooksRouter.get('/deliveries/:deliveryId', (req, res) => {
});

/**
* GET /api/webhooks/deliveries
* GET /deliveries
* List all webhook deliveries (for monitoring/debugging)
*/
webhooksRouter.get('/deliveries', (req, res) => {
Expand Down Expand Up @@ -392,6 +393,7 @@ webhooksRouter.post('/process-outbox', express.json(), async (req, res) => {
message: 'Webhook secret is required as query parameter',
},
});
return;
}

try {
Expand Down
54 changes: 43 additions & 11 deletions src/serialization/decimal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,37 @@ export const DECIMAL_STRING_PATTERN = /^[+-]?\d+(\.\d+)?$/;
*/
export const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER);

/**
* Maximum allowed integer part of a decimal amount (int64 max).
* Values whose integer part exceeds this are rejected with OUT_OF_RANGE.
*/
export const MAX_DECIMAL_INTEGER_PART = 9_223_372_036_854_775_807n;

/**
* Normalize a validated decimal string by stripping trailing fractional zeros.
* The input must already match DECIMAL_STRING_PATTERN.
*
* @example
* normalizeDecimalString("100.50") // "100.5"
* normalizeDecimalString("1.0000000") // "1"
* normalizeDecimalString("100.0") // "100"
* normalizeDecimalString("0.0000116") // "0.0000116" (no trailing zeros)
* normalizeDecimalString("100") // "100"
*/
export function normalizeDecimalString(value: string): string {
const dotIndex = value.indexOf('.');
if (dotIndex === -1) return value;

// Strip trailing zeros from the fractional part
let end = value.length;
while (end > dotIndex + 1 && value[end - 1] === '0') end--;

// If only the dot remains, drop it too
if (end === dotIndex + 1) return value.slice(0, dotIndex);

return value.slice(0, end);
}

/**
* Error codes for decimal serialization failures
*/
Expand Down Expand Up @@ -140,13 +171,14 @@ export function validateDecimalString(value: unknown, fieldName?: string): Valid
};
}

// Check for out of range (would cause precision loss in JSON)
// Check for out of range: compare the integer part against int64 max.
// We extract the integer part directly to avoid magnitude errors from
// stripping the decimal point (e.g. "1.5" must not be treated as 15).
const dotIndex = value.indexOf('.');
const integerPart = dotIndex === -1 ? value : value.slice(0, dotIndex);
const absIntegerPart = integerPart.replace(/^[+-]/, '');
try {
const bigIntValue = BigInt(value.replace('.', ''));
const absBigIntValue = bigIntValue < 0n ? -bigIntValue : bigIntValue;

// Allow values up to 10^20 (more than enough for any financial amount)
if (absBigIntValue > 10_000_000_000_000_000_000n) {
if (BigInt(absIntegerPart) > MAX_DECIMAL_INTEGER_PART) {
return {
valid: false,
error: new DecimalSerializationError(
Expand All @@ -158,11 +190,11 @@ export function validateDecimalString(value: unknown, fieldName?: string): Valid
};
}
} catch {
// If BigInt conversion fails for any reason, still allow the value
// as it's already validated by the regex
// BigInt conversion failed — the regex already validated the format,
// so this is unreachable in practice; allow the value through.
}

return { valid: true, value };
return { valid: true, value: normalizeDecimalString(value) };
}

/**
Expand All @@ -188,13 +220,13 @@ export function serializeToDecimalString(value: unknown, fieldName?: string): st
);
}

// Handle strings - validate and return as-is if valid
// Handle strings - validate and return normalized form if valid
if (typeof value === 'string') {
const result = validateDecimalString(value, fieldName);
if (!result.valid) {
throw result.error;
}
return value;
return result.value!; // validateDecimalString already normalizes
}

// Handle numbers
Expand Down
1 change: 1 addition & 0 deletions src/validation/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* @module validation/schemas
*/
import { z } from 'zod';
import { normalizeDecimalString } from '../serialization/decimal.js';

/** Regex for valid decimal strings: optional sign, digits, optional fraction */
export const DECIMAL_STRING_REGEX = /^[+-]?\d+(\.\d+)?$/;
Expand Down
7 changes: 7 additions & 0 deletions src/webhooks/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,13 @@ export class WebhookService {
return webhookDeliveryStore.getByDeliveryId(deliveryId);
}

/**
* Register an inbound delivery ID for deduplication.
*/
registerDeliveryId(deliveryId: string): void {
webhookDeliveryStore.registerDeliveryId(deliveryId);
}

/**
* Check if a delivery ID has been seen (for deduplication)
*/
Expand Down
8 changes: 8 additions & 0 deletions src/webhooks/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,14 @@ export class WebhookDeliveryStore {
return results;
}

/**
* Register a delivery ID for deduplication without a full delivery record.
* Used by the /receive endpoint for inbound webhook verification.
*/
registerDeliveryId(deliveryId: string): void {
this.deliveryIdIndex.set(deliveryId, deliveryId);
}

/**
* Check if a delivery ID has been seen before (for deduplication)
*/
Expand Down
76 changes: 72 additions & 4 deletions tests/decimal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
validateAmountFields,
parseToStroops,
formatFromStroops,
normalizeDecimalString,
DecimalSerializationError,
DecimalErrorCode,
DECIMAL_STRING_PATTERN,
Expand Down Expand Up @@ -48,6 +49,26 @@ describe('Decimal String Serialization Policy', () => {
});
});

describe('normalizeDecimalString', () => {
it('strips trailing fractional zeros', () => {
expect(normalizeDecimalString('100.50')).toBe('100.5');
expect(normalizeDecimalString('1.0000000')).toBe('1');
expect(normalizeDecimalString('100.0')).toBe('100');
expect(normalizeDecimalString('0.10')).toBe('0.1');
});

it('leaves values without trailing zeros unchanged', () => {
expect(normalizeDecimalString('100')).toBe('100');
expect(normalizeDecimalString('0.0000116')).toBe('0.0000116');
expect(normalizeDecimalString('0')).toBe('0');
});

it('handles negative values', () => {
expect(normalizeDecimalString('-100.50')).toBe('-100.5');
expect(normalizeDecimalString('-1.0')).toBe('-1');
});
});

describe('validateDecimalString', () => {
describe('valid inputs', () => {
it('should validate positive integers', () => {
Expand All @@ -66,7 +87,7 @@ describe('Decimal String Serialization Policy', () => {
it('should validate positive decimals', () => {
const result = validateDecimalString('100.50');
expect(result.valid).toBe(true);
expect(result.value).toBe('100.50');
expect(result.value).toBe('100.5'); // trailing zero stripped
});

it('should validate zero', () => {
Expand Down Expand Up @@ -195,13 +216,60 @@ describe('Decimal String Serialization Policy', () => {
expect(result.valid).toBe(false);
expect(result.error?.code).toBe(DecimalErrorCode.OUT_OF_RANGE);
});

// --- Extreme value boundary tests ---

it('should accept int64 max (9223372036854775807)', () => {
const result = validateDecimalString('9223372036854775807');
expect(result.valid).toBe(true);
expect(result.value).toBe('9223372036854775807');
});

it('should reject int64 max + 1 (9223372036854775808)', () => {
const result = validateDecimalString('9223372036854775808');
expect(result.valid).toBe(false);
expect(result.error?.code).toBe(DecimalErrorCode.OUT_OF_RANGE);
});

it('should accept int64 max with fractional part', () => {
// Integer part equals int64 max — still valid
const result = validateDecimalString('9223372036854775807.9999999');
expect(result.valid).toBe(true);
});

it('should reject value whose integer part exceeds int64 max', () => {
const result = validateDecimalString('9223372036854775808.0');
expect(result.valid).toBe(false);
expect(result.error?.code).toBe(DecimalErrorCode.OUT_OF_RANGE);
});

// --- Trailing-zero normalization tests ---

it('should normalize trailing fractional zeros', () => {
expect(validateDecimalString('100.50').value).toBe('100.5');
expect(validateDecimalString('1.0000000').value).toBe('1');
expect(validateDecimalString('100.0').value).toBe('100');
expect(validateDecimalString('0.10').value).toBe('0.1');
});

it('should not alter values with no trailing zeros', () => {
expect(validateDecimalString('0.0000116').value).toBe('0.0000116');
expect(validateDecimalString('100').value).toBe('100');
expect(validateDecimalString('0').value).toBe('0');
});
});
});

describe('serializeToDecimalString', () => {
it('should serialize valid string as-is', () => {
const result = serializeToDecimalString('100.50');
expect(result).toBe('100.50');
it('should serialize valid string as-is when already normalized', () => {
const result = serializeToDecimalString('100.5');
expect(result).toBe('100.5');
});

it('should normalize trailing zeros in string input', () => {
expect(serializeToDecimalString('100.50')).toBe('100.5');
expect(serializeToDecimalString('1.0000000')).toBe('1');
expect(serializeToDecimalString('100.0')).toBe('100');
});

it('should serialize integer numbers', () => {
Expand Down
Loading
Loading