diff --git a/src/compression.ts b/src/compression.ts index 4373003..f996a39 100644 --- a/src/compression.ts +++ b/src/compression.ts @@ -1,3 +1,4 @@ +import { SdkError, SdkErrorCode } from "./errors.js"; import type { RequestInterceptor, ResponseInterceptor } from "./interceptors.js"; export type CompressionAlgorithm = "gzip" | "deflate"; @@ -141,3 +142,159 @@ export function createCompressionResponseInterceptor(_config: CompressionConfig) }; }; } + +// --------------------------------------------------------------------------- +// Invoice metadata encoding (#619) +// --------------------------------------------------------------------------- + +/** Default byte ceiling for an encoded metadata string. */ +export const DEFAULT_METADATA_MAX_BYTES = 512; + +/** + * base64url alphabet. Trailing padding is tolerated on decode even though it + * is never produced, so a caller that padded the value elsewhere still round + * trips. + */ +const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*={0,2}$/; + +/** + * base64url encode, without depending on the Node `Buffer` global. + * + * This module is isomorphic - it feature-detects `CompressionStream` and falls + * back to `node:zlib` - so the metadata helpers must not reach for a Node-only + * global either. `TextEncoder`/`TextDecoder` and `btoa`/`atob` exist in both + * browsers and Node >= 16. + */ +function toBase64Url(json: string): string { + const bytes = new TextEncoder().encode(json); + + // Chunked rather than String.fromCharCode(...bytes): spreading a large array + // overflows the call stack, and the size limit is only checked after encoding. + const CHUNK_SIZE = 0x8000; + let binary = ""; + for (let i = 0; i < bytes.length; i += CHUNK_SIZE) { + binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK_SIZE)); + } + + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** base64url decode, tolerating optional padding. Mirror of {@link toBase64Url}. */ +function fromBase64Url(encoded: string): string { + const base64 = encoded.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4); + const binary = atob(padded); + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); + return new TextDecoder().decode(bytes); +} + +/** + * Encode an invoice metadata object as a compact base64url string. + * + * The value is JSON-serialised then base64url encoded without padding, so the + * result is safe to place in a Stellar transaction memo or an IPFS payload. + * + * @param metadata - Any JSON-serialisable plain object. + * @param maxBytes - Ceiling for the encoded string, in bytes. + * @returns The encoded metadata. + * @throws {SdkError} With {@link SdkErrorCode.CONTRACT_REJECTED} when the + * input is not a serialisable plain object, or when the encoded result + * exceeds `maxBytes`. + */ +export function compressMetadata( + metadata: Record, + maxBytes: number = DEFAULT_METADATA_MAX_BYTES, +): string { + if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) { + throw new SdkError( + "Metadata must be a plain object", + SdkErrorCode.CONTRACT_REJECTED, + { received: metadata === null ? "null" : typeof metadata }, + ); + } + + if (!Number.isFinite(maxBytes) || maxBytes <= 0) { + throw new SdkError( + "maxBytes must be a positive, finite number", + SdkErrorCode.CONTRACT_REJECTED, + { maxBytes }, + ); + } + + let json: string; + try { + json = JSON.stringify(metadata); + } catch (err) { + // Circular references and BigInt values both reach here. + throw new SdkError( + "Metadata is not JSON-serialisable", + SdkErrorCode.CONTRACT_REJECTED, + { reason: err instanceof Error ? err.message : String(err) }, + ); + } + + const encoded = toBase64Url(json); + // base64url is ASCII-only, so the character count is the byte count. + const bytes = encoded.length; + + if (bytes > maxBytes) { + throw new SdkError( + `Encoded metadata is ${bytes} bytes, over the ${maxBytes} byte limit`, + SdkErrorCode.CONTRACT_REJECTED, + { bytes, maxBytes }, + ); + } + + return encoded; +} + +/** + * Decode a metadata string produced by {@link compressMetadata}. + * + * @param encoded - base64url-encoded metadata. + * @returns The decoded object. + * @throws {SdkError} With {@link SdkErrorCode.CONTRACT_REJECTED} when the + * input is not base64url, does not contain JSON, or does not decode to a + * plain object. + */ +export function decompressMetadata(encoded: string): Record { + if (typeof encoded !== "string" || !BASE64URL_PATTERN.test(encoded)) { + throw new SdkError( + "Encoded metadata is not a base64url string", + SdkErrorCode.CONTRACT_REJECTED, + { received: typeof encoded }, + ); + } + + let json: string; + try { + json = fromBase64Url(encoded); + } catch (err) { + throw new SdkError( + "Encoded metadata is not valid base64url", + SdkErrorCode.CONTRACT_REJECTED, + { reason: err instanceof Error ? err.message : String(err) }, + ); + } + + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch (err) { + throw new SdkError( + "Encoded metadata does not contain valid JSON", + SdkErrorCode.CONTRACT_REJECTED, + { reason: err instanceof Error ? err.message : String(err) }, + ); + } + + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new SdkError( + "Encoded metadata did not decode to an object", + SdkErrorCode.CONTRACT_REJECTED, + { decodedType: parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed }, + ); + } + + return parsed as Record; +} diff --git a/src/index.ts b/src/index.ts index f9cb56b..73e4775 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1427,3 +1427,13 @@ export type { SubmitTransactionOptions, SubmitServer, } from "./transaction/submit.js"; + +// --------------------------------------------------------------------------- +// #619 - Invoice metadata encoding +// --------------------------------------------------------------------------- + +export { + compressMetadata, + decompressMetadata, + DEFAULT_METADATA_MAX_BYTES, +} from "./compression.js"; diff --git a/test/compression.metadata.invalid-base64url.test.ts b/test/compression.metadata.invalid-base64url.test.ts new file mode 100644 index 0000000..9e97952 --- /dev/null +++ b/test/compression.metadata.invalid-base64url.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +import { decompressMetadata } from "../src/compression.js"; +import { SdkError, SdkErrorCode } from "../src/errors.js"; + +describe("decompressMetadata malformed base64url", () => { + it.each(["A", "A=", "=="])( + "normalizes decoder failure for %j to CONTRACT_REJECTED", + (encoded) => { + try { + decompressMetadata(encoded); + throw new Error("expected malformed base64url to be rejected"); + } catch (error) { + expect(error).toBeInstanceOf(SdkError); + expect((error as SdkError).code).toBe(SdkErrorCode.CONTRACT_REJECTED); + } + }, + ); +}); diff --git a/test/compression.metadata.test.ts b/test/compression.metadata.test.ts new file mode 100644 index 0000000..11f9f35 --- /dev/null +++ b/test/compression.metadata.test.ts @@ -0,0 +1,175 @@ +/** + * Tests for invoice metadata encoding (Issue #619). + * Pure functions — no network, no filesystem. + */ + +import { describe, it, expect } from "vitest"; + +import { + DEFAULT_METADATA_MAX_BYTES, + compressMetadata, + decompressMetadata, +} from "../src/compression.js"; +import { SdkError, SdkErrorCode } from "../src/errors.js"; + +const expectRejected = (fn: () => unknown) => { + try { + fn(); + throw new Error("expected the call to throw an SdkError"); + } catch (error) { + expect(error).toBeInstanceOf(SdkError); + expect((error as SdkError).code).toBe(SdkErrorCode.CONTRACT_REJECTED); + return error as SdkError; + } +}; + +describe("DEFAULT_METADATA_MAX_BYTES", () => { + it("is 512", () => { + expect(DEFAULT_METADATA_MAX_BYTES).toBe(512); + }); +}); + +describe("compressMetadata", () => { + it("encodes without base64 padding", () => { + // "{}" is 2 bytes, which is the case standard base64 would pad. + const encoded = compressMetadata({}); + + expect(encoded).not.toContain("="); + expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it("uses the base64url alphabet, never + or /", () => { + // Bytes that encode to '+' and '/' under standard base64. + const encoded = compressMetadata({ v: "ÿÿÿ????>>>" }); + + expect(encoded).not.toContain("+"); + expect(encoded).not.toContain("/"); + }); + + it("rejects a payload over the default limit", () => { + const error = expectRejected(() => + compressMetadata({ blob: "x".repeat(1000) }), + ); + + expect(error.message).toContain(String(DEFAULT_METADATA_MAX_BYTES)); + }); + + it("honours a custom maxBytes", () => { + expect(() => compressMetadata({ a: 1 }, 4)).toThrow(SdkError); + expect(() => compressMetadata({ a: 1 }, 64)).not.toThrow(); + }); + + it("reports the actual size alongside the limit", () => { + const error = expectRejected(() => compressMetadata({ a: 1 }, 4)); + const details = error.details as { bytes: number; maxBytes: number }; + + expect(details.maxBytes).toBe(4); + expect(details.bytes).toBeGreaterThan(4); + }); + + it.each([ + ["null", null], + ["an array", [1, 2, 3]], + ["a string", "not an object"], + ["a number", 42], + ])("rejects %s", (_label, value) => { + expectRejected(() => compressMetadata(value as never)); + }); + + it("rejects a circular structure rather than throwing a raw TypeError", () => { + const circular: Record = {}; + circular["self"] = circular; + + expectRejected(() => compressMetadata(circular)); + }); + + it("rejects a BigInt value, which JSON cannot serialise", () => { + expectRejected(() => compressMetadata({ amount: 1n as unknown })); + }); + + it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])( + "rejects the invalid maxBytes %s", + (maxBytes) => { + expectRejected(() => compressMetadata({ a: 1 }, maxBytes)); + }, + ); +}); + +describe("decompressMetadata", () => { + it("rejects a string outside the base64url alphabet", () => { + expectRejected(() => decompressMetadata("not base64!")); + expectRejected(() => decompressMetadata("has+plus/slash")); + }); + + it("rejects base64url that does not contain JSON", () => { + // "Hello" — valid base64url, not JSON. + expectRejected(() => decompressMetadata("SGVsbG8")); + }); + + it("rejects a non-string input", () => { + expectRejected(() => decompressMetadata(undefined as never)); + expectRejected(() => decompressMetadata(123 as never)); + }); + + it.each([ + ["an array", "[1,2,3]"], + ["a number", "42"], + ["a string", '"hello"'], + ["null", "null"], + ])("rejects encoded JSON that decodes to %s", (_label, json) => { + const encoded = Buffer.from(json, "utf8").toString("base64url"); + + expectRejected(() => decompressMetadata(encoded)); + }); + + it("accepts padded input even though it never emits padding", () => { + // A caller may have padded the value elsewhere; decoding should still work. + const padded = Buffer.from(JSON.stringify({ p: 1 }), "utf8").toString("base64"); + + expect(decompressMetadata(padded)).toEqual({ p: 1 }); + }); +}); + +describe("browser safety", () => { + it("encodes and decodes with no Node Buffer global present", () => { + // compression.ts is isomorphic - it feature-detects CompressionStream and + // falls back to node:zlib - so these helpers must not need a Node global. + // A browser bundle without a Buffer polyfill is exactly this shape. + const originalBuffer = globalThis.Buffer; + + try { + // @ts-expect-error deliberately simulating an environment with no Buffer + delete globalThis.Buffer; + + const value = { id: "evt_1", note: "héllo ✓ 日本語" }; + const encoded = compressMetadata(value); + + expect(encoded).not.toContain("="); + expect(decompressMetadata(encoded)).toEqual(value); + } finally { + globalThis.Buffer = originalBuffer; + } + }); +}); + +describe("round trip", () => { + const cases: Array<[string, Record]> = [ + ["an empty object", {}], + ["a flat object", { invoiceId: "inv_1", amount: 1000 }], + ["nested objects and arrays", { a: { b: { c: [1, 2, { d: true }] } } }], + ["null and boolean values", { n: null, t: true, f: false }], + ["unicode", { note: "héllo ✓ 日本語" }], + ["keys needing escaping", { 'quote"key': 'value with "quotes"' }], + ]; + + it.each(cases)("round-trips %s", (_label, value) => { + expect(decompressMetadata(compressMetadata(value))).toEqual(value); + }); + + it("survives a second round trip unchanged", () => { + const value = { invoiceId: "inv_2", tags: ["a", "b"] }; + const once = compressMetadata(value); + + expect(compressMetadata(decompressMetadata(once))).toBe(once); + }); +});