diff --git a/src/compression.ts b/src/compression.ts index f552288..60857ed 100644 --- a/src/compression.ts +++ b/src/compression.ts @@ -8,6 +8,7 @@ import zlib from "node:zlib"; import { promisify } from "node:util"; import type { RequestInterceptor, ResponseInterceptor } from "./interceptors.js"; +import { SdkError } from "./errors.js"; const gzipAsync = promisify(zlib.gzip); const gunzipAsync = promisify(zlib.gunzip); @@ -214,3 +215,74 @@ export function createCompressionResponseInterceptor(): ResponseInterceptor { } }; } + +/** + * Serialize an object to JSON and encode as base64url (no padding). + * + * Throws {@link SdkError} with code `CONTRACT_REJECTED` if the resulting + * base64 string exceeds `maxBytes` (default 512 bytes). + * + * @param obj - The object to compress. + * @param maxBytes - Maximum allowed encoded size in bytes (default 512). + * @returns The base64url-encoded JSON string (no padding). + * @throws {SdkError} with code `CONTRACT_REJECTED` if the encoded string + * exceeds `maxBytes`. + */ +export function compressMetadata( + obj: Record, + maxBytes: number = 512, +): string { + const json = JSON.stringify(obj); + const encoded = Buffer.from(json).toString("base64url"); + + if (encoded.length > maxBytes) { + throw new SdkError( + `Encoded metadata exceeds ${maxBytes} bytes (got ${encoded.length})`, + "CONTRACT_REJECTED", + ); + } + + return encoded; +} + +/** + * Decode a base64url-encoded string and parse as JSON. + * + * Throws {@link SdkError} with code `CONTRACT_REJECTED` if the input is + * not valid base64url or does not contain valid JSON. + * + * @param encoded - The base64url-encoded string to decode. + * @returns The parsed object. + * @throws {SdkError} with code `CONTRACT_REJECTED` if decoding or parsing + * fails. + */ +export function decompressMetadata(encoded: string): Record { + let json: string; + try { + json = Buffer.from(encoded, "base64url").toString("utf8"); + } catch { + throw new SdkError( + "Invalid base64url input", + "CONTRACT_REJECTED", + ); + } + + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + throw new SdkError( + "Invalid JSON in decoded metadata", + "CONTRACT_REJECTED", + ); + } + + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new SdkError( + "Decoded metadata is not a plain object", + "CONTRACT_REJECTED", + ); + } + + return parsed as Record; +} diff --git a/src/horizonPaginator.ts b/src/horizonPaginator.ts index 3dcc12a..952afcd 100644 --- a/src/horizonPaginator.ts +++ b/src/horizonPaginator.ts @@ -22,6 +22,7 @@ import type { CollectionPage, HorizonPaginatorOptions } from "./types.js"; import { buildCursorKey, getDefaultCursorStore } from "./cursorTracker.js"; +import { SdkError } from "./errors.js"; /** Default namespace for cursor store keys. */ const DEFAULT_NAMESPACE = "horizon"; @@ -183,3 +184,55 @@ export async function collectAll( } return results; } + +/** + * Paginate a local in-memory array of items. + * + * Generic helper for consumers who filter locally and then need to page the + * result. Works for `Invoice[]`, `Payment[]`, or any array type. + * + * @param items - The array to paginate. + * @param opts - Pagination options: `page` (1-indexed) and `pageSize` (1-200). + * @returns An object with the page data and pagination metadata. + * @throws {SdkError} with code `INVALID_RECIPIENT` if `pageSize` is outside + * the range [1, 200]. + * + * @example + * ```typescript + * const result = paginateArray(invoices, { page: 1, pageSize: 50 }); + * // result.data — first 50 invoices + * // result.totalPages — total number of pages + * // result.hasNext — true if more pages exist + * ``` + */ +export function paginateArray( + items: T[], + opts: { page: number; pageSize: number }, +): { + data: T[]; + total: number; + totalPages: number; + hasNext: boolean; + hasPrev: boolean; +} { + const { page, pageSize } = opts; + + if (pageSize < 1 || pageSize > 200) { + throw new SdkError("pageSize must be between 1 and 200", "INVALID_RECIPIENT"); + } + + const total = items.length; + const totalPages = Math.ceil(total / pageSize); + const start = (page - 1) * pageSize; + const end = Math.min(start + pageSize, total); + + const data = page < 1 || start >= total ? [] : items.slice(start, end); + + return { + data, + total, + totalPages, + hasNext: page < totalPages, + hasPrev: page > 1, + }; +} diff --git a/src/webhooks/verify.ts b/src/webhooks/verify.ts index 88cab67..d2b87be 100644 --- a/src/webhooks/verify.ts +++ b/src/webhooks/verify.ts @@ -11,25 +11,42 @@ import { createHmac, timingSafeEqual } from "crypto"; const HEX_PATTERN = /^[0-9a-f]+$/i; /** - * Verifies the `X-Stellar-Split-Signature` header against the raw request - * body using a timing-safe comparison. + * Error thrown by {@link WebhookVerificationError} when a signature cannot + * be verified. Consumers who prefer throwing over boolean returns can catch + * this error instead of checking the return value of + * {@link verifyWebhookSignature}. + */ +export class WebhookVerificationError extends Error { + public readonly name = "WebhookVerificationError"; + + constructor(message: string) { + super(message); + Object.setPrototypeOf(this, WebhookVerificationError.prototype); + } +} + +/** + * Verifies an HMAC-SHA256 webhook signature against a payload using a + * timing-safe comparison. * + * @param payload - The raw payload string to verify. + * @param signature - The hex-encoded signature to check against. * @param secret - The shared HMAC secret configured for the webhook. - * @param rawBody - The exact, unparsed request body bytes as received. - * @param signatureHeader - The hex-encoded signature from the request header. - * @returns `true` only when the computed digest matches the header value. + * @returns `true` when the computed digest matches the signature, + * `false` when the signature is malformed, lengths differ, or the digest + * does not match. Never throws. */ export function verifyWebhookSignature( + payload: string, + signature: string, secret: string, - rawBody: string, - signatureHeader: string ): boolean { - if (!HEX_PATTERN.test(signatureHeader) || signatureHeader.length % 2 !== 0) { + if (!HEX_PATTERN.test(signature) || signature.length % 2 !== 0) { return false; } - const expected = createHmac("sha256", secret).update(rawBody).digest(); - const provided = Buffer.from(signatureHeader, "hex"); + const expected = createHmac("sha256", secret).update(payload).digest(); + const provided = Buffer.from(signature, "hex"); if (expected.length !== provided.length) { return false; @@ -37,3 +54,23 @@ export function verifyWebhookSignature( return timingSafeEqual(expected, provided); } + +/** + * Verifies an HMAC-SHA256 webhook signature and throws if it does not match. + * + * @param payload - The raw payload string to verify. + * @param signature - The hex-encoded signature to check against. + * @param secret - The shared HMAC secret configured for the webhook. + * @throws {WebhookVerificationError} If the signature is invalid. + * @returns `true` when the signature is valid. + */ +export function verifyWebhookSignatureOrThrow( + payload: string, + signature: string, + secret: string, +): boolean { + if (!verifyWebhookSignature(payload, signature, secret)) { + throw new WebhookVerificationError("Invalid webhook signature"); + } + return true; +}