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
72 changes: 72 additions & 0 deletions src/compression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<string, unknown>,
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<string, unknown> {
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<string, unknown>;
}
53 changes: 53 additions & 0 deletions src/horizonPaginator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -183,3 +184,55 @@ export async function collectAll<T>(
}
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<T>(
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,
};
}
57 changes: 47 additions & 10 deletions src/webhooks/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,66 @@ 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;
}

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;
}