@spreadspace/embed 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,141 @@
1
+ 'use strict';
2
+
3
+ var crypto = require('crypto');
4
+
5
+ // src/webhook-signature.ts
6
+ var DEFAULT_FRESHNESS_TOLERANCE_SECONDS = 5 * 60;
7
+ var VERSION_1_PREFIX = "v1";
8
+ function verifyWebhookSignature(rawBody, signature, secret, options) {
9
+ if (typeof signature !== "string" || signature.length === 0) {
10
+ throw new WebhookSignatureError("SpreadSpace-Signature header is missing or empty.");
11
+ }
12
+ if (typeof secret !== "string" || secret.length === 0) {
13
+ throw new WebhookSignatureError("Webhook signing secret is missing or empty.");
14
+ }
15
+ if (rawBody === null || rawBody === void 0) {
16
+ throw new WebhookSignatureError("Webhook raw body is missing.");
17
+ }
18
+ const parsed = parseSignatureHeader(signature);
19
+ if (!parsed) {
20
+ throw new WebhookSignatureError("Webhook signature header is malformed.");
21
+ }
22
+ const { timestamp, hex } = parsed;
23
+ const bodyString = bodyToString(rawBody);
24
+ const expectedHex = computeHmacHex(secret, `${timestamp}.${bodyString}`);
25
+ if (!hexEquals(expectedHex, hex)) {
26
+ throw new WebhookSignatureError("Webhook signature does not match.");
27
+ }
28
+ const tolerance = options?.freshnessTolerance ?? DEFAULT_FRESHNESS_TOLERANCE_SECONDS;
29
+ const now = options?.currentTimestamp ?? Math.floor(Date.now() / 1e3);
30
+ const age = now - timestamp;
31
+ if (age > tolerance || age < -tolerance) {
32
+ throw new WebhookSignatureError(
33
+ `Webhook timestamp is outside the freshness window of ${tolerance}s.`
34
+ );
35
+ }
36
+ return true;
37
+ }
38
+ function verifyAndParseWebhook(rawBody, signature, secret, options) {
39
+ verifyWebhookSignature(rawBody, signature, secret, options);
40
+ const bodyString = bodyToString(rawBody);
41
+ let parsed;
42
+ try {
43
+ parsed = JSON.parse(bodyString);
44
+ } catch (cause) {
45
+ throw new WebhookSignatureError("Webhook body is not valid JSON.", cause);
46
+ }
47
+ if (!isPlainObject(parsed)) {
48
+ throw new WebhookSignatureError("Webhook body is not a JSON object.");
49
+ }
50
+ const candidate = parsed;
51
+ if (typeof candidate.type !== "string" || candidate.type.length === 0) {
52
+ throw new WebhookSignatureError("Webhook body is missing a `type` field.");
53
+ }
54
+ if (typeof candidate.id !== "string" || candidate.id.length === 0) {
55
+ throw new WebhookSignatureError("Webhook body is missing an `id` field.");
56
+ }
57
+ if (typeof candidate.created !== "number") {
58
+ throw new WebhookSignatureError("Webhook body is missing a numeric `created` field.");
59
+ }
60
+ if (typeof candidate.tenant_id !== "string" || candidate.tenant_id.length === 0) {
61
+ throw new WebhookSignatureError("Webhook body is missing a `tenant_id` field.");
62
+ }
63
+ if (!isPlainObject(candidate.data)) {
64
+ throw new WebhookSignatureError("Webhook body is missing a `data` object.");
65
+ }
66
+ return parsed;
67
+ }
68
+ var WebhookSignatureError = class _WebhookSignatureError extends Error {
69
+ cause;
70
+ constructor(message, cause) {
71
+ super(message);
72
+ this.name = "WebhookSignatureError";
73
+ this.cause = cause;
74
+ Object.setPrototypeOf(this, _WebhookSignatureError.prototype);
75
+ }
76
+ };
77
+ function parseSignatureHeader(header) {
78
+ const segments = header.split(",").filter((s) => s.length > 0);
79
+ if (segments.length < 2) return null;
80
+ let parsedTs = null;
81
+ let parsedV1 = null;
82
+ for (const raw of segments) {
83
+ const segment = raw.trim();
84
+ const eq = segment.indexOf("=");
85
+ if (eq <= 0 || eq === segment.length - 1) {
86
+ return null;
87
+ }
88
+ const key = segment.slice(0, eq).trim();
89
+ const value = segment.slice(eq + 1).trim();
90
+ if (key === "t") {
91
+ if (parsedTs !== null) return null;
92
+ if (!/^-?\d+$/.test(value)) return null;
93
+ const ts = Number(value);
94
+ if (!Number.isFinite(ts) || !Number.isInteger(ts)) return null;
95
+ parsedTs = ts;
96
+ } else if (key === VERSION_1_PREFIX) {
97
+ if (parsedV1 !== null) return null;
98
+ if (value.length === 0) return null;
99
+ parsedV1 = value;
100
+ }
101
+ }
102
+ if (parsedTs === null || parsedV1 === null) return null;
103
+ return { timestamp: parsedTs, hex: parsedV1 };
104
+ }
105
+ function computeHmacHex(secret, signedPayload) {
106
+ const hmac = crypto.createHmac("sha256", Buffer.from(secret, "utf-8"));
107
+ hmac.update(Buffer.from(signedPayload, "utf-8"));
108
+ return hmac.digest("hex");
109
+ }
110
+ function hexEquals(expectedHex, providedHex) {
111
+ if (expectedHex.length !== providedHex.length) return false;
112
+ let expectedBytes;
113
+ let providedBytes;
114
+ try {
115
+ expectedBytes = Buffer.from(expectedHex, "hex");
116
+ providedBytes = Buffer.from(providedHex, "hex");
117
+ } catch {
118
+ return false;
119
+ }
120
+ if (expectedBytes.length !== providedBytes.length) return false;
121
+ if (expectedBytes.length * 2 !== expectedHex.length) return false;
122
+ if (providedBytes.length * 2 !== providedHex.length) return false;
123
+ return crypto.timingSafeEqual(
124
+ new Uint8Array(expectedBytes.buffer, expectedBytes.byteOffset, expectedBytes.byteLength),
125
+ new Uint8Array(providedBytes.buffer, providedBytes.byteOffset, providedBytes.byteLength)
126
+ );
127
+ }
128
+ function bodyToString(body) {
129
+ if (typeof body === "string") return body;
130
+ if (Buffer.isBuffer(body)) return body.toString("utf-8");
131
+ return Buffer.from(body).toString("utf-8");
132
+ }
133
+ function isPlainObject(v) {
134
+ return typeof v === "object" && v !== null && !Array.isArray(v);
135
+ }
136
+
137
+ exports.WebhookSignatureError = WebhookSignatureError;
138
+ exports.verifyAndParseWebhook = verifyAndParseWebhook;
139
+ exports.verifyWebhookSignature = verifyWebhookSignature;
140
+ //# sourceMappingURL=webhooks.cjs.map
141
+ //# sourceMappingURL=webhooks.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/webhook-signature.ts"],"names":["createHmac","timingSafeEqual"],"mappings":"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","file":"webhooks.cjs","sourcesContent":["import { createHmac, timingSafeEqual } from 'node:crypto';\n\nimport type { VerifyAndParseResult, WebhookEvent } from './types.js';\n\n/**\n * Webhook signature verifier — byte-for-byte compatible with\n * `api/src/Webhooks/WebhookSigner.cs` on the API side.\n *\n * Wire format (mirrors Stripe's `Stripe-Signature`):\n *\n * SpreadSpace-Signature: t=<unix_seconds>,v1=<hex_hmac_sha256(secret, \"{t}.{body}\")>\n *\n * The verifier:\n * 1. Parses `t=...` and `v1=...` tokens (tolerant of additional unrecognized\n * pairs to stay forward-compatible with future schemes).\n * 2. Recomputes HMAC-SHA256 over `\"{timestamp}.{rawBody}\"` with the secret.\n * 3. Compares with `crypto.timingSafeEqual` on raw bytes (never on hex\n * strings — string `===` short-circuits and leaks prefix info via timing).\n * 4. Two-sided freshness check: rejects timestamps too far in the past\n * (replay protection) AND too far in the future (attacker-controlled\n * timestamps that would extend the replay window).\n *\n * Verification order matches the C# side: HMAC first, then freshness. That\n * way a forged-but-fresh request and a real-but-stale request fail\n * indistinguishably from the timing perspective.\n */\n\n/** Default freshness tolerance — matches `WebhookSignatureFormat.DefaultFreshnessTolerance`. */\nconst DEFAULT_FRESHNESS_TOLERANCE_SECONDS = 5 * 60;\n\n/** Wire constant for the v1 HMAC-SHA256 scheme. */\nconst VERSION_1_PREFIX = 'v1';\n\nexport interface VerifyOptions {\n /**\n * Maximum age (and maximum future skew) of the timestamp before the\n * signature is rejected as stale. Symmetric — `Math.abs(now - t)` must be\n * ≤ this value.\n *\n * Default: 300 seconds (5 minutes).\n */\n freshnessTolerance?: number;\n\n /**\n * Override the \"current time\" used for the freshness check. Useful in\n * tests; otherwise leave undefined and the verifier reads\n * `Date.now() / 1000`.\n */\n currentTimestamp?: number;\n}\n\n/**\n * Verify a `SpreadSpace-Signature` header against a body and signing\n * secret. Throws on any failure (invalid format, bad HMAC, stale timestamp,\n * wrong secret); returns `true` on success.\n *\n * @param rawBody The exact request body received on the wire — bytes, not\n * re-serialized JSON. Any byte-level transformation between receipt and\n * verification (re-stringification, gzip decode, encoding change) will\n * invalidate the signature.\n * @param signature Contents of the `SpreadSpace-Signature` HTTP header.\n * @param secret The plaintext signing secret (`whsec_...`) issued at\n * webhook endpoint creation. Never log this.\n * @param options Optional overrides — freshness window and clock injection.\n */\nexport function verifyWebhookSignature(\n rawBody: string | Buffer | Uint8Array,\n signature: string,\n secret: string,\n options?: VerifyOptions,\n): true {\n if (typeof signature !== 'string' || signature.length === 0) {\n throw new WebhookSignatureError('SpreadSpace-Signature header is missing or empty.');\n }\n if (typeof secret !== 'string' || secret.length === 0) {\n throw new WebhookSignatureError('Webhook signing secret is missing or empty.');\n }\n if (rawBody === null || rawBody === undefined) {\n throw new WebhookSignatureError('Webhook raw body is missing.');\n }\n\n const parsed = parseSignatureHeader(signature);\n if (!parsed) {\n throw new WebhookSignatureError('Webhook signature header is malformed.');\n }\n const { timestamp, hex } = parsed;\n\n // HMAC FIRST. We never want to leak (via differential timing or distinct\n // error message text) whether a request was a forged signature vs. a real\n // one that aged out — both should be indistinguishable failures.\n const bodyString = bodyToString(rawBody);\n const expectedHex = computeHmacHex(secret, `${timestamp}.${bodyString}`);\n\n if (!hexEquals(expectedHex, hex)) {\n throw new WebhookSignatureError('Webhook signature does not match.');\n }\n\n // Then freshness. Two-sided check: too old OR too far in the future.\n const tolerance = options?.freshnessTolerance ?? DEFAULT_FRESHNESS_TOLERANCE_SECONDS;\n const now = options?.currentTimestamp ?? Math.floor(Date.now() / 1000);\n const age = now - timestamp;\n if (age > tolerance || age < -tolerance) {\n throw new WebhookSignatureError(\n `Webhook timestamp is outside the freshness window of ${tolerance}s.`,\n );\n }\n\n return true;\n}\n\n/**\n * Verify a webhook signature and JSON-parse the body into a typed\n * `WebhookEvent` envelope. Throws on signature failure or invalid JSON.\n *\n * The returned value is a discriminated union over `type` — switch on\n * `event.type` to narrow `event.data` to the matching payload.\n */\nexport function verifyAndParseWebhook(\n rawBody: string | Buffer | Uint8Array,\n signature: string,\n secret: string,\n options?: VerifyOptions,\n): VerifyAndParseResult {\n verifyWebhookSignature(rawBody, signature, secret, options);\n\n const bodyString = bodyToString(rawBody);\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(bodyString);\n } catch (cause) {\n throw new WebhookSignatureError('Webhook body is not valid JSON.', cause);\n }\n\n if (!isPlainObject(parsed)) {\n throw new WebhookSignatureError('Webhook body is not a JSON object.');\n }\n const candidate = parsed as Partial<WebhookEvent>;\n if (typeof candidate.type !== 'string' || candidate.type.length === 0) {\n throw new WebhookSignatureError('Webhook body is missing a `type` field.');\n }\n if (typeof candidate.id !== 'string' || candidate.id.length === 0) {\n throw new WebhookSignatureError('Webhook body is missing an `id` field.');\n }\n if (typeof candidate.created !== 'number') {\n throw new WebhookSignatureError('Webhook body is missing a numeric `created` field.');\n }\n if (typeof candidate.tenant_id !== 'string' || candidate.tenant_id.length === 0) {\n throw new WebhookSignatureError('Webhook body is missing a `tenant_id` field.');\n }\n if (!isPlainObject(candidate.data)) {\n throw new WebhookSignatureError('Webhook body is missing a `data` object.');\n }\n\n return parsed as VerifyAndParseResult;\n}\n\n/**\n * Thrown when a webhook signature fails verification or the envelope is\n * structurally malformed. Distinct from `SpreadSpaceError` because webhook\n * verification is a self-contained primitive — verifiers don't need the\n * full request/response error machinery, just a single failure type they\n * can reject against.\n */\nexport class WebhookSignatureError extends Error {\n public readonly cause?: unknown;\n\n constructor(message: string, cause?: unknown) {\n super(message);\n this.name = 'WebhookSignatureError';\n this.cause = cause;\n Object.setPrototypeOf(this, WebhookSignatureError.prototype);\n }\n}\n\n// ────────────────────────────────────────────────────────────────────────────\n// Internals\n// ────────────────────────────────────────────────────────────────────────────\n\ninterface ParsedSignature {\n timestamp: number;\n hex: string;\n}\n\n/**\n * Parse a header in the form `t=<long>,v1=<hex>`. Tolerant of whitespace\n * between segments and of additional unrecognized pairs (e.g. a future\n * `v2=...`) — those are silently ignored, not treated as parse errors, so\n * this verifier remains forward-compatible with new schemes.\n */\nfunction parseSignatureHeader(header: string): ParsedSignature | null {\n const segments = header.split(',').filter((s) => s.length > 0);\n if (segments.length < 2) return null;\n\n let parsedTs: number | null = null;\n let parsedV1: string | null = null;\n\n for (const raw of segments) {\n const segment = raw.trim();\n const eq = segment.indexOf('=');\n if (eq <= 0 || eq === segment.length - 1) {\n // Missing key, missing value, or `=` at the very end (`v1=`).\n return null;\n }\n\n const key = segment.slice(0, eq).trim();\n const value = segment.slice(eq + 1).trim();\n\n if (key === 't') {\n if (parsedTs !== null) return null; // Duplicate `t=`.\n // Strict integer parse — reject `1.5`, `1e3`, leading `+`, etc. The C#\n // verifier uses `long.TryParse(NumberStyles.Integer, Invariant)`,\n // which accepts an optional leading `-` and digits only. We mirror that.\n if (!/^-?\\d+$/.test(value)) return null;\n const ts = Number(value);\n if (!Number.isFinite(ts) || !Number.isInteger(ts)) return null;\n parsedTs = ts;\n } else if (key === VERSION_1_PREFIX) {\n if (parsedV1 !== null) return null; // Duplicate `v1=`.\n if (value.length === 0) return null;\n parsedV1 = value;\n }\n // Any other key — silently ignored for forward compatibility.\n }\n\n if (parsedTs === null || parsedV1 === null) return null;\n return { timestamp: parsedTs, hex: parsedV1 };\n}\n\n/**\n * Compute lowercase hex HMAC-SHA256 digest. Matches the C# side's\n * `Convert.ToHexStringLower(hmac.ComputeHash(...))` exactly.\n */\nfunction computeHmacHex(secret: string, signedPayload: string): string {\n const hmac = createHmac('sha256', Buffer.from(secret, 'utf-8'));\n hmac.update(Buffer.from(signedPayload, 'utf-8'));\n return hmac.digest('hex'); // Node default is lowercase.\n}\n\n/**\n * Constant-time compare of two hex strings. We decode both sides to bytes\n * before calling `timingSafeEqual` — it requires equal-length inputs and\n * we want a length mismatch to fail fast (length is not timing-sensitive\n * since both ends know SHA-256 emits 64 hex chars).\n */\nfunction hexEquals(expectedHex: string, providedHex: string): boolean {\n if (expectedHex.length !== providedHex.length) return false;\n\n let expectedBytes: Buffer;\n let providedBytes: Buffer;\n try {\n expectedBytes = Buffer.from(expectedHex, 'hex');\n providedBytes = Buffer.from(providedHex, 'hex');\n } catch {\n return false;\n }\n\n // `Buffer.from(..., 'hex')` silently stops at the first non-hex character,\n // producing a shorter buffer — guard explicitly against that to avoid a\n // false equality when the provided hex has invalid characters that happen\n // to truncate to an empty buffer the receiver also produced.\n if (expectedBytes.length !== providedBytes.length) return false;\n if (expectedBytes.length * 2 !== expectedHex.length) return false;\n if (providedBytes.length * 2 !== providedHex.length) return false;\n\n return timingSafeEqual(\n new Uint8Array(expectedBytes.buffer, expectedBytes.byteOffset, expectedBytes.byteLength),\n new Uint8Array(providedBytes.buffer, providedBytes.byteOffset, providedBytes.byteLength),\n );\n}\n\nfunction bodyToString(body: string | Buffer | Uint8Array): string {\n if (typeof body === 'string') return body;\n if (Buffer.isBuffer(body)) return body.toString('utf-8');\n return Buffer.from(body).toString('utf-8');\n}\n\nfunction isPlainObject(v: unknown): v is Record<string, unknown> {\n return typeof v === 'object' && v !== null && !Array.isArray(v);\n}\n"]}
@@ -0,0 +1 @@
1
+ export { D as DocumentFailedPayload, b as DocumentProcessedPayload, E as ExtractionReadyPayload, J as JobCompletedPayload, L as LoanClassifiedPayload, V as VerifyAndParseResult, c as VerifyOptions, W as WebhookEvent, d as WebhookEventType, e as WebhookSignatureError, a as verifyAndParseWebhook, v as verifyWebhookSignature } from './webhooks-D6PW9fcE.cjs';
@@ -0,0 +1 @@
1
+ export { D as DocumentFailedPayload, b as DocumentProcessedPayload, E as ExtractionReadyPayload, J as JobCompletedPayload, L as LoanClassifiedPayload, V as VerifyAndParseResult, c as VerifyOptions, W as WebhookEvent, d as WebhookEventType, e as WebhookSignatureError, a as verifyAndParseWebhook, v as verifyWebhookSignature } from './webhooks-D6PW9fcE.js';
@@ -0,0 +1,137 @@
1
+ import { createHmac, timingSafeEqual } from 'crypto';
2
+
3
+ // src/webhook-signature.ts
4
+ var DEFAULT_FRESHNESS_TOLERANCE_SECONDS = 5 * 60;
5
+ var VERSION_1_PREFIX = "v1";
6
+ function verifyWebhookSignature(rawBody, signature, secret, options) {
7
+ if (typeof signature !== "string" || signature.length === 0) {
8
+ throw new WebhookSignatureError("SpreadSpace-Signature header is missing or empty.");
9
+ }
10
+ if (typeof secret !== "string" || secret.length === 0) {
11
+ throw new WebhookSignatureError("Webhook signing secret is missing or empty.");
12
+ }
13
+ if (rawBody === null || rawBody === void 0) {
14
+ throw new WebhookSignatureError("Webhook raw body is missing.");
15
+ }
16
+ const parsed = parseSignatureHeader(signature);
17
+ if (!parsed) {
18
+ throw new WebhookSignatureError("Webhook signature header is malformed.");
19
+ }
20
+ const { timestamp, hex } = parsed;
21
+ const bodyString = bodyToString(rawBody);
22
+ const expectedHex = computeHmacHex(secret, `${timestamp}.${bodyString}`);
23
+ if (!hexEquals(expectedHex, hex)) {
24
+ throw new WebhookSignatureError("Webhook signature does not match.");
25
+ }
26
+ const tolerance = options?.freshnessTolerance ?? DEFAULT_FRESHNESS_TOLERANCE_SECONDS;
27
+ const now = options?.currentTimestamp ?? Math.floor(Date.now() / 1e3);
28
+ const age = now - timestamp;
29
+ if (age > tolerance || age < -tolerance) {
30
+ throw new WebhookSignatureError(
31
+ `Webhook timestamp is outside the freshness window of ${tolerance}s.`
32
+ );
33
+ }
34
+ return true;
35
+ }
36
+ function verifyAndParseWebhook(rawBody, signature, secret, options) {
37
+ verifyWebhookSignature(rawBody, signature, secret, options);
38
+ const bodyString = bodyToString(rawBody);
39
+ let parsed;
40
+ try {
41
+ parsed = JSON.parse(bodyString);
42
+ } catch (cause) {
43
+ throw new WebhookSignatureError("Webhook body is not valid JSON.", cause);
44
+ }
45
+ if (!isPlainObject(parsed)) {
46
+ throw new WebhookSignatureError("Webhook body is not a JSON object.");
47
+ }
48
+ const candidate = parsed;
49
+ if (typeof candidate.type !== "string" || candidate.type.length === 0) {
50
+ throw new WebhookSignatureError("Webhook body is missing a `type` field.");
51
+ }
52
+ if (typeof candidate.id !== "string" || candidate.id.length === 0) {
53
+ throw new WebhookSignatureError("Webhook body is missing an `id` field.");
54
+ }
55
+ if (typeof candidate.created !== "number") {
56
+ throw new WebhookSignatureError("Webhook body is missing a numeric `created` field.");
57
+ }
58
+ if (typeof candidate.tenant_id !== "string" || candidate.tenant_id.length === 0) {
59
+ throw new WebhookSignatureError("Webhook body is missing a `tenant_id` field.");
60
+ }
61
+ if (!isPlainObject(candidate.data)) {
62
+ throw new WebhookSignatureError("Webhook body is missing a `data` object.");
63
+ }
64
+ return parsed;
65
+ }
66
+ var WebhookSignatureError = class _WebhookSignatureError extends Error {
67
+ cause;
68
+ constructor(message, cause) {
69
+ super(message);
70
+ this.name = "WebhookSignatureError";
71
+ this.cause = cause;
72
+ Object.setPrototypeOf(this, _WebhookSignatureError.prototype);
73
+ }
74
+ };
75
+ function parseSignatureHeader(header) {
76
+ const segments = header.split(",").filter((s) => s.length > 0);
77
+ if (segments.length < 2) return null;
78
+ let parsedTs = null;
79
+ let parsedV1 = null;
80
+ for (const raw of segments) {
81
+ const segment = raw.trim();
82
+ const eq = segment.indexOf("=");
83
+ if (eq <= 0 || eq === segment.length - 1) {
84
+ return null;
85
+ }
86
+ const key = segment.slice(0, eq).trim();
87
+ const value = segment.slice(eq + 1).trim();
88
+ if (key === "t") {
89
+ if (parsedTs !== null) return null;
90
+ if (!/^-?\d+$/.test(value)) return null;
91
+ const ts = Number(value);
92
+ if (!Number.isFinite(ts) || !Number.isInteger(ts)) return null;
93
+ parsedTs = ts;
94
+ } else if (key === VERSION_1_PREFIX) {
95
+ if (parsedV1 !== null) return null;
96
+ if (value.length === 0) return null;
97
+ parsedV1 = value;
98
+ }
99
+ }
100
+ if (parsedTs === null || parsedV1 === null) return null;
101
+ return { timestamp: parsedTs, hex: parsedV1 };
102
+ }
103
+ function computeHmacHex(secret, signedPayload) {
104
+ const hmac = createHmac("sha256", Buffer.from(secret, "utf-8"));
105
+ hmac.update(Buffer.from(signedPayload, "utf-8"));
106
+ return hmac.digest("hex");
107
+ }
108
+ function hexEquals(expectedHex, providedHex) {
109
+ if (expectedHex.length !== providedHex.length) return false;
110
+ let expectedBytes;
111
+ let providedBytes;
112
+ try {
113
+ expectedBytes = Buffer.from(expectedHex, "hex");
114
+ providedBytes = Buffer.from(providedHex, "hex");
115
+ } catch {
116
+ return false;
117
+ }
118
+ if (expectedBytes.length !== providedBytes.length) return false;
119
+ if (expectedBytes.length * 2 !== expectedHex.length) return false;
120
+ if (providedBytes.length * 2 !== providedHex.length) return false;
121
+ return timingSafeEqual(
122
+ new Uint8Array(expectedBytes.buffer, expectedBytes.byteOffset, expectedBytes.byteLength),
123
+ new Uint8Array(providedBytes.buffer, providedBytes.byteOffset, providedBytes.byteLength)
124
+ );
125
+ }
126
+ function bodyToString(body) {
127
+ if (typeof body === "string") return body;
128
+ if (Buffer.isBuffer(body)) return body.toString("utf-8");
129
+ return Buffer.from(body).toString("utf-8");
130
+ }
131
+ function isPlainObject(v) {
132
+ return typeof v === "object" && v !== null && !Array.isArray(v);
133
+ }
134
+
135
+ export { WebhookSignatureError, verifyAndParseWebhook, verifyWebhookSignature };
136
+ //# sourceMappingURL=webhooks.js.map
137
+ //# sourceMappingURL=webhooks.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/webhook-signature.ts"],"names":[],"mappings":"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","file":"webhooks.js","sourcesContent":["import { createHmac, timingSafeEqual } from 'node:crypto';\n\nimport type { VerifyAndParseResult, WebhookEvent } from './types.js';\n\n/**\n * Webhook signature verifier — byte-for-byte compatible with\n * `api/src/Webhooks/WebhookSigner.cs` on the API side.\n *\n * Wire format (mirrors Stripe's `Stripe-Signature`):\n *\n * SpreadSpace-Signature: t=<unix_seconds>,v1=<hex_hmac_sha256(secret, \"{t}.{body}\")>\n *\n * The verifier:\n * 1. Parses `t=...` and `v1=...` tokens (tolerant of additional unrecognized\n * pairs to stay forward-compatible with future schemes).\n * 2. Recomputes HMAC-SHA256 over `\"{timestamp}.{rawBody}\"` with the secret.\n * 3. Compares with `crypto.timingSafeEqual` on raw bytes (never on hex\n * strings — string `===` short-circuits and leaks prefix info via timing).\n * 4. Two-sided freshness check: rejects timestamps too far in the past\n * (replay protection) AND too far in the future (attacker-controlled\n * timestamps that would extend the replay window).\n *\n * Verification order matches the C# side: HMAC first, then freshness. That\n * way a forged-but-fresh request and a real-but-stale request fail\n * indistinguishably from the timing perspective.\n */\n\n/** Default freshness tolerance — matches `WebhookSignatureFormat.DefaultFreshnessTolerance`. */\nconst DEFAULT_FRESHNESS_TOLERANCE_SECONDS = 5 * 60;\n\n/** Wire constant for the v1 HMAC-SHA256 scheme. */\nconst VERSION_1_PREFIX = 'v1';\n\nexport interface VerifyOptions {\n /**\n * Maximum age (and maximum future skew) of the timestamp before the\n * signature is rejected as stale. Symmetric — `Math.abs(now - t)` must be\n * ≤ this value.\n *\n * Default: 300 seconds (5 minutes).\n */\n freshnessTolerance?: number;\n\n /**\n * Override the \"current time\" used for the freshness check. Useful in\n * tests; otherwise leave undefined and the verifier reads\n * `Date.now() / 1000`.\n */\n currentTimestamp?: number;\n}\n\n/**\n * Verify a `SpreadSpace-Signature` header against a body and signing\n * secret. Throws on any failure (invalid format, bad HMAC, stale timestamp,\n * wrong secret); returns `true` on success.\n *\n * @param rawBody The exact request body received on the wire — bytes, not\n * re-serialized JSON. Any byte-level transformation between receipt and\n * verification (re-stringification, gzip decode, encoding change) will\n * invalidate the signature.\n * @param signature Contents of the `SpreadSpace-Signature` HTTP header.\n * @param secret The plaintext signing secret (`whsec_...`) issued at\n * webhook endpoint creation. Never log this.\n * @param options Optional overrides — freshness window and clock injection.\n */\nexport function verifyWebhookSignature(\n rawBody: string | Buffer | Uint8Array,\n signature: string,\n secret: string,\n options?: VerifyOptions,\n): true {\n if (typeof signature !== 'string' || signature.length === 0) {\n throw new WebhookSignatureError('SpreadSpace-Signature header is missing or empty.');\n }\n if (typeof secret !== 'string' || secret.length === 0) {\n throw new WebhookSignatureError('Webhook signing secret is missing or empty.');\n }\n if (rawBody === null || rawBody === undefined) {\n throw new WebhookSignatureError('Webhook raw body is missing.');\n }\n\n const parsed = parseSignatureHeader(signature);\n if (!parsed) {\n throw new WebhookSignatureError('Webhook signature header is malformed.');\n }\n const { timestamp, hex } = parsed;\n\n // HMAC FIRST. We never want to leak (via differential timing or distinct\n // error message text) whether a request was a forged signature vs. a real\n // one that aged out — both should be indistinguishable failures.\n const bodyString = bodyToString(rawBody);\n const expectedHex = computeHmacHex(secret, `${timestamp}.${bodyString}`);\n\n if (!hexEquals(expectedHex, hex)) {\n throw new WebhookSignatureError('Webhook signature does not match.');\n }\n\n // Then freshness. Two-sided check: too old OR too far in the future.\n const tolerance = options?.freshnessTolerance ?? DEFAULT_FRESHNESS_TOLERANCE_SECONDS;\n const now = options?.currentTimestamp ?? Math.floor(Date.now() / 1000);\n const age = now - timestamp;\n if (age > tolerance || age < -tolerance) {\n throw new WebhookSignatureError(\n `Webhook timestamp is outside the freshness window of ${tolerance}s.`,\n );\n }\n\n return true;\n}\n\n/**\n * Verify a webhook signature and JSON-parse the body into a typed\n * `WebhookEvent` envelope. Throws on signature failure or invalid JSON.\n *\n * The returned value is a discriminated union over `type` — switch on\n * `event.type` to narrow `event.data` to the matching payload.\n */\nexport function verifyAndParseWebhook(\n rawBody: string | Buffer | Uint8Array,\n signature: string,\n secret: string,\n options?: VerifyOptions,\n): VerifyAndParseResult {\n verifyWebhookSignature(rawBody, signature, secret, options);\n\n const bodyString = bodyToString(rawBody);\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(bodyString);\n } catch (cause) {\n throw new WebhookSignatureError('Webhook body is not valid JSON.', cause);\n }\n\n if (!isPlainObject(parsed)) {\n throw new WebhookSignatureError('Webhook body is not a JSON object.');\n }\n const candidate = parsed as Partial<WebhookEvent>;\n if (typeof candidate.type !== 'string' || candidate.type.length === 0) {\n throw new WebhookSignatureError('Webhook body is missing a `type` field.');\n }\n if (typeof candidate.id !== 'string' || candidate.id.length === 0) {\n throw new WebhookSignatureError('Webhook body is missing an `id` field.');\n }\n if (typeof candidate.created !== 'number') {\n throw new WebhookSignatureError('Webhook body is missing a numeric `created` field.');\n }\n if (typeof candidate.tenant_id !== 'string' || candidate.tenant_id.length === 0) {\n throw new WebhookSignatureError('Webhook body is missing a `tenant_id` field.');\n }\n if (!isPlainObject(candidate.data)) {\n throw new WebhookSignatureError('Webhook body is missing a `data` object.');\n }\n\n return parsed as VerifyAndParseResult;\n}\n\n/**\n * Thrown when a webhook signature fails verification or the envelope is\n * structurally malformed. Distinct from `SpreadSpaceError` because webhook\n * verification is a self-contained primitive — verifiers don't need the\n * full request/response error machinery, just a single failure type they\n * can reject against.\n */\nexport class WebhookSignatureError extends Error {\n public readonly cause?: unknown;\n\n constructor(message: string, cause?: unknown) {\n super(message);\n this.name = 'WebhookSignatureError';\n this.cause = cause;\n Object.setPrototypeOf(this, WebhookSignatureError.prototype);\n }\n}\n\n// ────────────────────────────────────────────────────────────────────────────\n// Internals\n// ────────────────────────────────────────────────────────────────────────────\n\ninterface ParsedSignature {\n timestamp: number;\n hex: string;\n}\n\n/**\n * Parse a header in the form `t=<long>,v1=<hex>`. Tolerant of whitespace\n * between segments and of additional unrecognized pairs (e.g. a future\n * `v2=...`) — those are silently ignored, not treated as parse errors, so\n * this verifier remains forward-compatible with new schemes.\n */\nfunction parseSignatureHeader(header: string): ParsedSignature | null {\n const segments = header.split(',').filter((s) => s.length > 0);\n if (segments.length < 2) return null;\n\n let parsedTs: number | null = null;\n let parsedV1: string | null = null;\n\n for (const raw of segments) {\n const segment = raw.trim();\n const eq = segment.indexOf('=');\n if (eq <= 0 || eq === segment.length - 1) {\n // Missing key, missing value, or `=` at the very end (`v1=`).\n return null;\n }\n\n const key = segment.slice(0, eq).trim();\n const value = segment.slice(eq + 1).trim();\n\n if (key === 't') {\n if (parsedTs !== null) return null; // Duplicate `t=`.\n // Strict integer parse — reject `1.5`, `1e3`, leading `+`, etc. The C#\n // verifier uses `long.TryParse(NumberStyles.Integer, Invariant)`,\n // which accepts an optional leading `-` and digits only. We mirror that.\n if (!/^-?\\d+$/.test(value)) return null;\n const ts = Number(value);\n if (!Number.isFinite(ts) || !Number.isInteger(ts)) return null;\n parsedTs = ts;\n } else if (key === VERSION_1_PREFIX) {\n if (parsedV1 !== null) return null; // Duplicate `v1=`.\n if (value.length === 0) return null;\n parsedV1 = value;\n }\n // Any other key — silently ignored for forward compatibility.\n }\n\n if (parsedTs === null || parsedV1 === null) return null;\n return { timestamp: parsedTs, hex: parsedV1 };\n}\n\n/**\n * Compute lowercase hex HMAC-SHA256 digest. Matches the C# side's\n * `Convert.ToHexStringLower(hmac.ComputeHash(...))` exactly.\n */\nfunction computeHmacHex(secret: string, signedPayload: string): string {\n const hmac = createHmac('sha256', Buffer.from(secret, 'utf-8'));\n hmac.update(Buffer.from(signedPayload, 'utf-8'));\n return hmac.digest('hex'); // Node default is lowercase.\n}\n\n/**\n * Constant-time compare of two hex strings. We decode both sides to bytes\n * before calling `timingSafeEqual` — it requires equal-length inputs and\n * we want a length mismatch to fail fast (length is not timing-sensitive\n * since both ends know SHA-256 emits 64 hex chars).\n */\nfunction hexEquals(expectedHex: string, providedHex: string): boolean {\n if (expectedHex.length !== providedHex.length) return false;\n\n let expectedBytes: Buffer;\n let providedBytes: Buffer;\n try {\n expectedBytes = Buffer.from(expectedHex, 'hex');\n providedBytes = Buffer.from(providedHex, 'hex');\n } catch {\n return false;\n }\n\n // `Buffer.from(..., 'hex')` silently stops at the first non-hex character,\n // producing a shorter buffer — guard explicitly against that to avoid a\n // false equality when the provided hex has invalid characters that happen\n // to truncate to an empty buffer the receiver also produced.\n if (expectedBytes.length !== providedBytes.length) return false;\n if (expectedBytes.length * 2 !== expectedHex.length) return false;\n if (providedBytes.length * 2 !== providedHex.length) return false;\n\n return timingSafeEqual(\n new Uint8Array(expectedBytes.buffer, expectedBytes.byteOffset, expectedBytes.byteLength),\n new Uint8Array(providedBytes.buffer, providedBytes.byteOffset, providedBytes.byteLength),\n );\n}\n\nfunction bodyToString(body: string | Buffer | Uint8Array): string {\n if (typeof body === 'string') return body;\n if (Buffer.isBuffer(body)) return body.toString('utf-8');\n return Buffer.from(body).toString('utf-8');\n}\n\nfunction isPlainObject(v: unknown): v is Record<string, unknown> {\n return typeof v === 'object' && v !== null && !Array.isArray(v);\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@spreadspace/embed",
3
+ "version": "0.1.0",
4
+ "description": "Server-side Node.js SDK for SpreadSpace embeds — mint embed sessions and verify webhooks.",
5
+ "license": "MIT",
6
+ "sideEffects": false,
7
+ "type": "module",
8
+ "main": "./dist/index.cjs",
9
+ "module": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js",
15
+ "require": "./dist/index.cjs"
16
+ },
17
+ "./webhooks": {
18
+ "types": "./dist/webhooks.d.ts",
19
+ "import": "./dist/webhooks.js",
20
+ "require": "./dist/webhooks.cjs"
21
+ }
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "dependencies": {},
29
+ "devDependencies": {
30
+ "@types/node": "^25.8.0",
31
+ "@typescript-eslint/eslint-plugin": "^8.59.3",
32
+ "@typescript-eslint/parser": "^8.59.3",
33
+ "eslint": "^10.4.0",
34
+ "openapi-typescript": "^7.4.4",
35
+ "rimraf": "^6.0.1",
36
+ "tsup": "^8.3.5",
37
+ "typescript": "^6.0.3",
38
+ "vitest": "^3.2.6"
39
+ },
40
+ "engines": {
41
+ "node": ">=18"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "keywords": [
47
+ "spreadspace",
48
+ "api",
49
+ "sdk",
50
+ "typescript",
51
+ "lending",
52
+ "documents"
53
+ ],
54
+ "scripts": {
55
+ "codegen": "bash ../../fern/build-public-spec.sh && openapi-typescript ../../fern/api-public.json -o src/generated/api-types.ts && pnpm run check:no-internal-paths",
56
+ "check:no-internal-paths": "node scripts/check-no-internal-paths.mjs",
57
+ "clean": "rimraf dist",
58
+ "build": "pnpm run clean && tsup && node scripts/check-no-internal-paths.mjs --dist",
59
+ "dev": "tsup --watch",
60
+ "test": "vitest run",
61
+ "test:watch": "vitest",
62
+ "lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
63
+ "typecheck": "tsc --noEmit"
64
+ }
65
+ }