@aimearn/webhook-sdk 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.
package/dist/index.js ADDED
@@ -0,0 +1,294 @@
1
+ // src/eventId.ts
2
+ import { randomBytes } from "crypto";
3
+ var ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
4
+ var ENCODING_LEN = ENCODING.length;
5
+ var TIME_LEN = 10;
6
+ var RANDOM_LEN = 16;
7
+ function encodeTime(now, len) {
8
+ let out = "";
9
+ let n = now;
10
+ for (let i = len - 1; i >= 0; i--) {
11
+ const mod = n % ENCODING_LEN;
12
+ out = ENCODING[mod] + out;
13
+ n = (n - mod) / ENCODING_LEN;
14
+ }
15
+ return out;
16
+ }
17
+ function encodeRandom(len) {
18
+ const bytes = randomBytes(len);
19
+ let out = "";
20
+ for (let i = 0; i < len; i++) {
21
+ out += ENCODING[bytes[i] & 31];
22
+ }
23
+ return out;
24
+ }
25
+ function generateEventId(now = Date.now()) {
26
+ return `evt_${encodeTime(now, TIME_LEN)}${encodeRandom(RANDOM_LEN)}`;
27
+ }
28
+
29
+ // src/signing.ts
30
+ import { createHmac, timingSafeEqual } from "crypto";
31
+ var REPLAY_WINDOW_SECONDS = 300;
32
+ function signRequest(secret, rawBody, now = Math.floor(Date.now() / 1e3)) {
33
+ const v1 = createHmac("sha256", secret).update(`${now}.${rawBody}`).digest("hex");
34
+ return `t=${now},v1=${v1}`;
35
+ }
36
+ function parseSignatureHeader(header) {
37
+ if (!header) return null;
38
+ let t = null;
39
+ let v1 = null;
40
+ for (const part of header.split(",")) {
41
+ const eq = part.indexOf("=");
42
+ if (eq < 0) return null;
43
+ const k = part.slice(0, eq).trim();
44
+ const v = part.slice(eq + 1).trim();
45
+ if (k === "t") {
46
+ const parsed = Number(v);
47
+ if (!Number.isFinite(parsed) || parsed <= 0) return null;
48
+ t = parsed;
49
+ } else if (k === "v1") {
50
+ if (!/^[0-9a-f]{64}$/.test(v)) return null;
51
+ v1 = v;
52
+ }
53
+ }
54
+ if (t === null || v1 === null) return null;
55
+ return { t, v1 };
56
+ }
57
+ function verifyResponseSignature(secret, headerValue, responseBody, nowSeconds = Math.floor(Date.now() / 1e3)) {
58
+ const sig = parseSignatureHeader(headerValue);
59
+ if (!sig) return false;
60
+ if (Math.abs(nowSeconds - sig.t) > REPLAY_WINDOW_SECONDS) return false;
61
+ const expected = createHmac("sha256", secret).update(`${sig.t}.${responseBody}`).digest("hex");
62
+ if (expected.length !== sig.v1.length) return false;
63
+ return timingSafeEqual(Buffer.from(expected), Buffer.from(sig.v1));
64
+ }
65
+
66
+ // src/errors.ts
67
+ var AimearnError = class extends Error {
68
+ code;
69
+ status;
70
+ responseBody;
71
+ constructor(code, message, status, responseBody) {
72
+ super(message);
73
+ this.name = "AimearnError";
74
+ this.code = code;
75
+ this.status = status;
76
+ this.responseBody = responseBody;
77
+ }
78
+ };
79
+ var AimearnAuthError = class extends AimearnError {
80
+ constructor(code, message, status = 401, responseBody) {
81
+ super(code, message, status, responseBody);
82
+ this.name = "AimearnAuthError";
83
+ }
84
+ };
85
+ var AimearnValidationError = class extends AimearnError {
86
+ constructor(code, message, status = 400, responseBody) {
87
+ super(code, message, status, responseBody);
88
+ this.name = "AimearnValidationError";
89
+ }
90
+ };
91
+ var AimearnNotFoundError = class extends AimearnError {
92
+ constructor(code, message, status = 404, responseBody) {
93
+ super(code, message, status, responseBody);
94
+ this.name = "AimearnNotFoundError";
95
+ }
96
+ };
97
+ var AimearnForbiddenError = class extends AimearnError {
98
+ constructor(code, message, status = 403, responseBody) {
99
+ super(code, message, status, responseBody);
100
+ this.name = "AimearnForbiddenError";
101
+ }
102
+ };
103
+ var AimearnConflictError = class extends AimearnError {
104
+ constructor(code, message, status = 409, responseBody) {
105
+ super(code, message, status, responseBody);
106
+ this.name = "AimearnConflictError";
107
+ }
108
+ };
109
+ var AimearnPlatformError = class extends AimearnError {
110
+ constructor(code, message, status = 500, responseBody) {
111
+ super(code, message, status, responseBody);
112
+ this.name = "AimearnPlatformError";
113
+ }
114
+ };
115
+ var ERROR_CTOR_BY_STATUS = {
116
+ 400: AimearnValidationError,
117
+ 401: AimearnAuthError,
118
+ 403: AimearnForbiddenError,
119
+ 404: AimearnNotFoundError,
120
+ 409: AimearnConflictError,
121
+ 413: AimearnValidationError,
122
+ 429: AimearnPlatformError
123
+ };
124
+ function errorFromResponse(status, body) {
125
+ const errorCode = typeof body === "object" && body !== null && "error" in body && typeof body.error === "string" ? body.error : "internal_error";
126
+ const Ctor = ERROR_CTOR_BY_STATUS[status] ?? (status >= 500 ? AimearnPlatformError : AimearnError);
127
+ return new Ctor(
128
+ errorCode,
129
+ `Aim Earn ${status}: ${errorCode}`,
130
+ status,
131
+ body
132
+ );
133
+ }
134
+
135
+ // src/retry.ts
136
+ var DEFAULTS = {
137
+ maxAttempts: 24,
138
+ baseDelayMs: 1e3,
139
+ maxDelayMs: 6e4,
140
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
141
+ };
142
+ function resolveRetryConfig(config) {
143
+ return { ...DEFAULTS, ...config ?? {} };
144
+ }
145
+ function isRetriable(status) {
146
+ return status === 0 || status === 429 || status >= 500;
147
+ }
148
+ function backoffDelayMs(attempt, config) {
149
+ if (attempt <= 1) return 0;
150
+ const raw = config.baseDelayMs * 2 ** (attempt - 2);
151
+ return Math.min(raw, config.maxDelayMs);
152
+ }
153
+
154
+ // src/client.ts
155
+ var ROUTE = "/api/webhooks/order";
156
+ var AimearnClient = class {
157
+ config;
158
+ constructor(config) {
159
+ if (!config.keyId) throw new Error("AimearnClient: keyId is required.");
160
+ if (!config.secret) throw new Error("AimearnClient: secret is required.");
161
+ if (!config.endpoint) throw new Error("AimearnClient: endpoint is required.");
162
+ this.config = {
163
+ keyId: config.keyId,
164
+ secret: config.secret,
165
+ endpoint: config.endpoint.replace(/\/$/, ""),
166
+ fetch: config.fetch ?? globalThis.fetch.bind(globalThis),
167
+ retry: resolveRetryConfig(config.retry)
168
+ };
169
+ }
170
+ orderCompleted(input) {
171
+ return this.send("order.completed", input);
172
+ }
173
+ orderShipped(input) {
174
+ return this.send("order.shipped", input);
175
+ }
176
+ orderRefunded(input) {
177
+ return this.send("order.refunded", input);
178
+ }
179
+ orderCancelled(input) {
180
+ return this.send("order.cancelled", input);
181
+ }
182
+ async send(type, input) {
183
+ const envelope = {
184
+ eventId: input.eventId ?? generateEventId(),
185
+ type,
186
+ occurredAt: input.occurredAt ?? (/* @__PURE__ */ new Date()).toISOString(),
187
+ orderId: input.orderId,
188
+ data: input.data,
189
+ metadata: input.metadata ?? null
190
+ };
191
+ const rawBody = JSON.stringify(envelope);
192
+ const url = `${this.config.endpoint}${ROUTE}`;
193
+ return this.executeWithRetry(url, rawBody);
194
+ }
195
+ async executeWithRetry(url, rawBody) {
196
+ const { maxAttempts, sleep } = this.config.retry;
197
+ let lastError;
198
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
199
+ const delayMs = backoffDelayMs(attempt, this.config.retry);
200
+ if (delayMs > 0) await sleep(delayMs);
201
+ try {
202
+ return await this.attemptOnce(url, rawBody);
203
+ } catch (err) {
204
+ if (!(err instanceof AimearnError)) throw err;
205
+ lastError = err;
206
+ if (!isRetriable(err.status)) {
207
+ throw err;
208
+ }
209
+ }
210
+ }
211
+ throw lastError ?? new AimearnPlatformError(
212
+ "internal_error",
213
+ "AimearnClient retry budget exhausted with no error captured.",
214
+ 500
215
+ );
216
+ }
217
+ async attemptOnce(url, rawBody) {
218
+ const sigHeader = signRequest(this.config.secret, rawBody);
219
+ let response;
220
+ try {
221
+ response = await this.config.fetch(url, {
222
+ method: "POST",
223
+ headers: {
224
+ "Content-Type": "application/json",
225
+ "X-Aimearn-Key-Id": this.config.keyId,
226
+ "X-Aimearn-Signature": sigHeader
227
+ },
228
+ body: rawBody
229
+ });
230
+ } catch (err) {
231
+ throw new AimearnPlatformError(
232
+ "network_error",
233
+ `Network error during webhook POST: ${err.message ?? err}`,
234
+ 0
235
+ );
236
+ }
237
+ const responseBody = await response.text();
238
+ if (response.status >= 200 && response.status < 300) {
239
+ const sigOk = verifyResponseSignature(
240
+ this.config.secret,
241
+ response.headers.get("x-aimearn-response-signature"),
242
+ responseBody
243
+ );
244
+ if (!sigOk) {
245
+ throw new AimearnPlatformError(
246
+ "response_signature_failed",
247
+ `Aim Earn response signature missing or invalid (HTTP ${response.status}).`,
248
+ response.status,
249
+ responseBody
250
+ );
251
+ }
252
+ try {
253
+ return JSON.parse(responseBody);
254
+ } catch {
255
+ throw new AimearnPlatformError(
256
+ "internal_error",
257
+ "Aim Earn returned 2xx with an unparseable JSON body.",
258
+ response.status,
259
+ responseBody
260
+ );
261
+ }
262
+ }
263
+ let parsed = responseBody;
264
+ try {
265
+ parsed = JSON.parse(responseBody);
266
+ } catch {
267
+ }
268
+ throw errorFromResponse(response.status, parsed);
269
+ }
270
+ };
271
+
272
+ // src/index.ts
273
+ var SDK_VERSION = "0.1.0";
274
+ export {
275
+ AimearnAuthError,
276
+ AimearnClient,
277
+ AimearnConflictError,
278
+ AimearnError,
279
+ AimearnForbiddenError,
280
+ AimearnNotFoundError,
281
+ AimearnPlatformError,
282
+ AimearnValidationError,
283
+ REPLAY_WINDOW_SECONDS,
284
+ SDK_VERSION,
285
+ backoffDelayMs,
286
+ errorFromResponse,
287
+ generateEventId,
288
+ isRetriable,
289
+ parseSignatureHeader,
290
+ resolveRetryConfig,
291
+ signRequest,
292
+ verifyResponseSignature
293
+ };
294
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/eventId.ts","../src/signing.ts","../src/errors.ts","../src/retry.ts","../src/client.ts","../src/index.ts"],"sourcesContent":["// Stripe-style ULID-shaped idempotency key, formatted as\n// `evt_<26-char-base32-crockford>`. Same external shape as our backend\n// uses, but generated client-side so brands can collapse retries from\n// the same call site naturally — re-issuing the same `eventId` against\n// the platform is idempotent (the ingest Lambda's\n// `attribute_not_exists(orderId)` guard catches duplicates).\n//\n// Deliberately not pulling in the `ulid` npm package — keeping the\n// SDK dependency-free at runtime keeps the install size small and\n// avoids supply-chain surface. The implementation below is a faithful\n// 48-bit-time + 80-bit-randomness ULID with Crockford base32, matching\n// the spec at https://github.com/ulid/spec.\n\nimport { randomBytes } from \"node:crypto\";\n\nconst ENCODING = \"0123456789ABCDEFGHJKMNPQRSTVWXYZ\"; // Crockford base32\nconst ENCODING_LEN = ENCODING.length;\nconst TIME_LEN = 10;\nconst RANDOM_LEN = 16;\n\nfunction encodeTime(now: number, len: number): string {\n let out = \"\";\n let n = now;\n for (let i = len - 1; i >= 0; i--) {\n const mod = n % ENCODING_LEN;\n out = ENCODING[mod] + out;\n n = (n - mod) / ENCODING_LEN;\n }\n return out;\n}\n\nfunction encodeRandom(len: number): string {\n // 1 byte = 256 values; we map each to one of 32 base32 chars by\n // taking the low 5 bits. Equivalent randomness density.\n const bytes = randomBytes(len);\n let out = \"\";\n for (let i = 0; i < len; i++) {\n out += ENCODING[bytes[i] & 31];\n }\n return out;\n}\n\n/**\n * Returns a fresh idempotency key of shape `evt_<ULID>`.\n * 48-bit timestamp (millisecond precision) + 80-bit randomness.\n * Sortable lexicographically by creation time.\n */\nexport function generateEventId(now: number = Date.now()): string {\n return `evt_${encodeTime(now, TIME_LEN)}${encodeRandom(RANDOM_LEN)}`;\n}\n","// HMAC-SHA256 signing + verification for the Aim Earn webhook contract.\n// Spec: 2026-04-29-webhook-ingest-design.md §5.\n//\n// Request:\n// X-Aimearn-Signature: t=<unix>,v1=<hex>\n// v1 = HMAC-SHA256(secret, `${t}.${rawBody}`)\n//\n// Response:\n// X-Aimearn-Response-Signature: same shape; same per-brand secret.\n//\n// Brand SDK side: we sign every outbound request and verify every\n// inbound response. Replay window 300s.\n\nimport { createHmac, timingSafeEqual } from \"node:crypto\";\n\nexport const REPLAY_WINDOW_SECONDS = 300;\n\n/**\n * Builds the `X-Aimearn-Signature` header value for a request body.\n * Caller supplies the raw body bytes — never re-serialize after\n * signing, or the platform's HMAC verify will fail.\n */\nexport function signRequest(\n secret: string,\n rawBody: string,\n now: number = Math.floor(Date.now() / 1000),\n): string {\n const v1 = createHmac(\"sha256\", secret)\n .update(`${now}.${rawBody}`)\n .digest(\"hex\");\n return `t=${now},v1=${v1}`;\n}\n\nexport interface ParsedSignature {\n t: number;\n v1: string;\n}\n\n/**\n * Parses a `t=<unix>,v1=<hex>` header. Returns null on any malformed\n * input — caller treats null as \"missing/unverifiable signature.\"\n */\nexport function parseSignatureHeader(\n header: string | undefined | null,\n): ParsedSignature | null {\n if (!header) return null;\n\n let t: number | null = null;\n let v1: string | null = null;\n for (const part of header.split(\",\")) {\n const eq = part.indexOf(\"=\");\n if (eq < 0) return null;\n const k = part.slice(0, eq).trim();\n const v = part.slice(eq + 1).trim();\n if (k === \"t\") {\n const parsed = Number(v);\n if (!Number.isFinite(parsed) || parsed <= 0) return null;\n t = parsed;\n } else if (k === \"v1\") {\n // 64 lowercase hex chars for SHA-256.\n if (!/^[0-9a-f]{64}$/.test(v)) return null;\n v1 = v;\n }\n }\n if (t === null || v1 === null) return null;\n return { t, v1 };\n}\n\n/**\n * Verifies the platform's response signature against the response\n * body bytes. Returns true on match, false on any failure (missing\n * header, stale timestamp, mismatched HMAC). Brand SDK must reject\n * 2xx responses without a verifiable signature; 4xx/5xx may be\n * accepted as platform-level errors per spec §5.5.\n */\nexport function verifyResponseSignature(\n secret: string,\n headerValue: string | undefined | null,\n responseBody: string,\n nowSeconds: number = Math.floor(Date.now() / 1000),\n): boolean {\n const sig = parseSignatureHeader(headerValue);\n if (!sig) return false;\n if (Math.abs(nowSeconds - sig.t) > REPLAY_WINDOW_SECONDS) return false;\n\n const expected = createHmac(\"sha256\", secret)\n .update(`${sig.t}.${responseBody}`)\n .digest(\"hex\");\n\n if (expected.length !== sig.v1.length) return false;\n return timingSafeEqual(Buffer.from(expected), Buffer.from(sig.v1));\n}\n","// Typed error hierarchy mapping platform `WebhookDelivery.result`\n// values onto SDK-side error classes. Brand error handlers can switch\n// on `error.code` (the wire-format result string) or use `instanceof`.\n//\n// Spec: 2026-04-29-webhook-ingest-design.md §10.1 retry contract.\n\nexport type AimearnErrorCode =\n | \"signature_failed\"\n | \"parse_error\"\n | \"duplicate\"\n | \"country_not_published\"\n | \"product_not_found\"\n | \"unknown_referral\"\n | \"discarded\"\n | \"brand_not_found\"\n | \"brand_disabled\"\n | \"missing_key_id\"\n | \"rate_limited\"\n | \"body_too_large\"\n | \"unknown_event_type\"\n | \"unknown_order\"\n | \"brand_mismatch\"\n | \"cancelled_after_shipment\"\n | \"invalid_item_index\"\n | \"subtotal_mismatch\"\n | \"too_many_items\"\n // SDK-side codes for failure modes that don't reach the platform:\n | \"network_error\"\n | \"response_signature_failed\"\n | \"internal_error\";\n\nexport class AimearnError extends Error {\n readonly code: AimearnErrorCode;\n readonly status: number;\n readonly responseBody?: unknown;\n\n constructor(\n code: AimearnErrorCode,\n message: string,\n status: number,\n responseBody?: unknown,\n ) {\n super(message);\n this.name = \"AimearnError\";\n this.code = code;\n this.status = status;\n this.responseBody = responseBody;\n }\n}\n\n/** 401 — signature/auth failure. Brand should fix secret/clock; do NOT retry. */\nexport class AimearnAuthError extends AimearnError {\n constructor(code: AimearnErrorCode, message: string, status = 401, responseBody?: unknown) {\n super(code, message, status, responseBody);\n this.name = \"AimearnAuthError\";\n }\n}\n\n/** 400 — payload schema violation. Brand-side bug; do NOT retry. */\nexport class AimearnValidationError extends AimearnError {\n constructor(code: AimearnErrorCode, message: string, status = 400, responseBody?: unknown) {\n super(code, message, status, responseBody);\n this.name = \"AimearnValidationError\";\n }\n}\n\n/** 404 — referenced order or brand not found. Brand should re-check IDs; do NOT retry. */\nexport class AimearnNotFoundError extends AimearnError {\n constructor(code: AimearnErrorCode, message: string, status = 404, responseBody?: unknown) {\n super(code, message, status, responseBody);\n this.name = \"AimearnNotFoundError\";\n }\n}\n\n/** 403 — brand disabled / brand-mismatch. Escalate to platform admin. */\nexport class AimearnForbiddenError extends AimearnError {\n constructor(code: AimearnErrorCode, message: string, status = 403, responseBody?: unknown) {\n super(code, message, status, responseBody);\n this.name = \"AimearnForbiddenError\";\n }\n}\n\n/** 409 — state-machine violation (e.g. order.cancelled after shipment). */\nexport class AimearnConflictError extends AimearnError {\n constructor(code: AimearnErrorCode, message: string, status = 409, responseBody?: unknown) {\n super(code, message, status, responseBody);\n this.name = \"AimearnConflictError\";\n }\n}\n\n/** 5xx / network — platform-side error. SDK retries automatically up to the configured limit. */\nexport class AimearnPlatformError extends AimearnError {\n constructor(code: AimearnErrorCode, message: string, status = 500, responseBody?: unknown) {\n super(code, message, status, responseBody);\n this.name = \"AimearnPlatformError\";\n }\n}\n\nconst ERROR_CTOR_BY_STATUS: Record<number, typeof AimearnError> = {\n 400: AimearnValidationError,\n 401: AimearnAuthError,\n 403: AimearnForbiddenError,\n 404: AimearnNotFoundError,\n 409: AimearnConflictError,\n 413: AimearnValidationError,\n 429: AimearnPlatformError,\n};\n\n/**\n * Converts an HTTP error response into the right SDK error class.\n * `code` is the platform's `error` field from the response body; falls\n * back to `internal_error` if the body shape is unexpected.\n */\nexport function errorFromResponse(\n status: number,\n body: unknown,\n): AimearnError {\n const errorCode =\n typeof body === \"object\" &&\n body !== null &&\n \"error\" in body &&\n typeof (body as { error: unknown }).error === \"string\"\n ? ((body as { error: AimearnErrorCode }).error)\n : \"internal_error\";\n\n const Ctor =\n ERROR_CTOR_BY_STATUS[status] ??\n (status >= 500 ? AimearnPlatformError : AimearnError);\n\n return new Ctor(\n errorCode,\n `Aim Earn ${status}: ${errorCode}`,\n status,\n body,\n );\n}\n","// Exponential-backoff retry policy for the SDK's outbound POSTs.\n// Spec: 2026-04-29-webhook-ingest-design.md §10.1.\n//\n// 2xx → return result\n// 4xx (except 429) → throw immediately, no retry\n// 429 / 5xx / network → retry with exponential backoff\n//\n// Default: 1s, 2s, 4s, 8s, 16s, 32s, 60s, 60s, … up to 24 attempts ≈ 24h.\n\nexport interface RetryConfig {\n /** Maximum number of total attempts (including the first). Default 24. */\n maxAttempts?: number;\n /** Base delay in milliseconds. Default 1000. */\n baseDelayMs?: number;\n /** Cap individual delay; default 60_000ms. */\n maxDelayMs?: number;\n /**\n * Hook for tests / cancellation. Receives the milliseconds the SDK\n * intends to sleep; should resolve after that delay (or reject to\n * abort the retry loop).\n */\n sleep?: (ms: number) => Promise<void>;\n}\n\nconst DEFAULTS: Required<RetryConfig> = {\n maxAttempts: 24,\n baseDelayMs: 1000,\n maxDelayMs: 60_000,\n sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),\n};\n\nexport function resolveRetryConfig(\n config?: RetryConfig,\n): Required<RetryConfig> {\n return { ...DEFAULTS, ...(config ?? {}) };\n}\n\n/**\n * Returns true if the platform's HTTP status (or the SDK's network-error\n * sentinel of 0) should trigger a retry.\n *\n * - 0 — fetch threw; treat as retriable network failure\n * - 429 — explicit rate-limit; back off and retry\n * - 5xx — platform-side error; retry per spec §10.1\n */\nexport function isRetriable(status: number): boolean {\n return status === 0 || status === 429 || status >= 500;\n}\n\n/**\n * Pure delay-curve calculator. Attempt number is 1-indexed (the\n * first retry — i.e. the 2nd total attempt — uses attempt=2).\n */\nexport function backoffDelayMs(\n attempt: number,\n config: Required<RetryConfig>,\n): number {\n // Exponential: base * 2^(attempt - 1). attempt=1 → no delay (initial\n // call); attempt=2 → base; attempt=3 → 2*base; ...\n if (attempt <= 1) return 0;\n const raw = config.baseDelayMs * 2 ** (attempt - 2);\n return Math.min(raw, config.maxDelayMs);\n}\n","// Aim Earn webhook client. Brand-facing API surface:\n//\n// const client = new AimearnClient({ keyId, secret, endpoint });\n// await client.orderCompleted({ orderId, data: {...} });\n// await client.orderShipped({ orderId, data: {...} });\n// await client.orderRefunded({ orderId, data: {...} });\n// await client.orderCancelled({ orderId, data: {...} });\n//\n// Each method:\n// 1. Builds the envelope (auto-generates eventId via ULID if omitted)\n// 2. Serializes once to a stable string (the same bytes get signed)\n// 3. Signs with HMAC-SHA256 over `${t}.${rawBody}` per spec §5\n// 4. POSTs with X-Aimearn-Key-Id + X-Aimearn-Signature\n// 5. Retries 5xx/429 with exponential backoff up to maxAttempts\n// 6. Verifies the X-Aimearn-Response-Signature on 2xx (throws if missing/invalid)\n// 7. Returns the parsed AcceptedResponse, or throws a typed AimearnError\n\nimport { generateEventId } from \"./eventId\";\nimport { signRequest, verifyResponseSignature } from \"./signing\";\nimport {\n AimearnError,\n AimearnPlatformError,\n errorFromResponse,\n} from \"./errors\";\nimport {\n backoffDelayMs,\n isRetriable,\n resolveRetryConfig,\n type RetryConfig,\n} from \"./retry\";\nimport type {\n AcceptedResponse,\n Envelope,\n OrderCancelledInput,\n OrderCompletedInput,\n OrderRefundedInput,\n OrderShippedInput,\n WireEventType,\n} from \"./types\";\n\nexport interface AimearnClientConfig {\n /** Brand identifier registered with Aim Earn. Sent as X-Aimearn-Key-Id. */\n keyId: string;\n /**\n * Cleartext webhook secret returned by the Aim Earn admin\n * `rotateBrandWebhookSecret` mutation. Used to sign requests and\n * verify response signatures. NEVER log this.\n */\n secret: string;\n /**\n * Full base URL, e.g. `https://webhooks.aimearn.platform.com` or the\n * sandbox auto-generated `https://abc123.execute-api.region.amazonaws.com`.\n */\n endpoint: string;\n /** Optional fetch override for testing / non-Node runtimes. Defaults to global fetch. */\n fetch?: typeof globalThis.fetch;\n retry?: RetryConfig;\n}\n\nconst ROUTE = \"/api/webhooks/order\";\n\nexport class AimearnClient {\n private readonly config: Required<\n Pick<AimearnClientConfig, \"keyId\" | \"secret\" | \"endpoint\">\n > & {\n fetch: typeof globalThis.fetch;\n retry: ReturnType<typeof resolveRetryConfig>;\n };\n\n constructor(config: AimearnClientConfig) {\n if (!config.keyId) throw new Error(\"AimearnClient: keyId is required.\");\n if (!config.secret) throw new Error(\"AimearnClient: secret is required.\");\n if (!config.endpoint) throw new Error(\"AimearnClient: endpoint is required.\");\n\n this.config = {\n keyId: config.keyId,\n secret: config.secret,\n endpoint: config.endpoint.replace(/\\/$/, \"\"),\n fetch: config.fetch ?? globalThis.fetch.bind(globalThis),\n retry: resolveRetryConfig(config.retry),\n };\n }\n\n orderCompleted(input: OrderCompletedInput): Promise<AcceptedResponse> {\n return this.send(\"order.completed\", input);\n }\n\n orderShipped(input: OrderShippedInput): Promise<AcceptedResponse> {\n return this.send(\"order.shipped\", input);\n }\n\n orderRefunded(input: OrderRefundedInput): Promise<AcceptedResponse> {\n return this.send(\"order.refunded\", input);\n }\n\n orderCancelled(input: OrderCancelledInput): Promise<AcceptedResponse> {\n return this.send(\"order.cancelled\", input);\n }\n\n private async send<TData>(\n type: WireEventType,\n input: {\n orderId: string;\n eventId?: string;\n occurredAt?: string;\n data: TData;\n metadata?: Record<string, unknown> | null;\n },\n ): Promise<AcceptedResponse> {\n const envelope: Envelope<TData> = {\n eventId: input.eventId ?? generateEventId(),\n type,\n occurredAt: input.occurredAt ?? new Date().toISOString(),\n orderId: input.orderId,\n data: input.data,\n metadata: input.metadata ?? null,\n };\n const rawBody = JSON.stringify(envelope);\n\n const url = `${this.config.endpoint}${ROUTE}`;\n\n return this.executeWithRetry(url, rawBody);\n }\n\n private async executeWithRetry(\n url: string,\n rawBody: string,\n ): Promise<AcceptedResponse> {\n const { maxAttempts, sleep } = this.config.retry;\n let lastError: AimearnError | undefined;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n const delayMs = backoffDelayMs(attempt, this.config.retry);\n if (delayMs > 0) await sleep(delayMs);\n\n try {\n return await this.attemptOnce(url, rawBody);\n } catch (err) {\n if (!(err instanceof AimearnError)) throw err;\n lastError = err;\n\n if (!isRetriable(err.status)) {\n throw err;\n }\n // Retriable — fall through to next iteration.\n }\n }\n\n throw (\n lastError ??\n new AimearnPlatformError(\n \"internal_error\",\n \"AimearnClient retry budget exhausted with no error captured.\",\n 500,\n )\n );\n }\n\n private async attemptOnce(\n url: string,\n rawBody: string,\n ): Promise<AcceptedResponse> {\n const sigHeader = signRequest(this.config.secret, rawBody);\n\n let response: Response;\n try {\n response = await this.config.fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"X-Aimearn-Key-Id\": this.config.keyId,\n \"X-Aimearn-Signature\": sigHeader,\n },\n body: rawBody,\n });\n } catch (err) {\n // Network failure — retriable per spec §10.1. Wrap as\n // AimearnPlatformError(status:0) so the retry loop catches it.\n throw new AimearnPlatformError(\n \"network_error\",\n `Network error during webhook POST: ${(err as Error).message ?? err}`,\n 0,\n );\n }\n\n const responseBody = await response.text();\n\n // Verify response signature on 2xx — required per spec §5.5 brand\n // contract. Missing/invalid signature on a 2xx is a fatal trust\n // boundary violation.\n if (response.status >= 200 && response.status < 300) {\n const sigOk = verifyResponseSignature(\n this.config.secret,\n response.headers.get(\"x-aimearn-response-signature\"),\n responseBody,\n );\n if (!sigOk) {\n throw new AimearnPlatformError(\n \"response_signature_failed\",\n `Aim Earn response signature missing or invalid (HTTP ${response.status}).`,\n response.status,\n responseBody,\n );\n }\n try {\n return JSON.parse(responseBody) as AcceptedResponse;\n } catch {\n throw new AimearnPlatformError(\n \"internal_error\",\n \"Aim Earn returned 2xx with an unparseable JSON body.\",\n response.status,\n responseBody,\n );\n }\n }\n\n // 4xx / 5xx — parse the error body if possible and throw the\n // typed error class.\n let parsed: unknown = responseBody;\n try {\n parsed = JSON.parse(responseBody);\n } catch {\n // Leave parsed as the raw string.\n }\n throw errorFromResponse(response.status, parsed);\n }\n}\n","// @aimearn/webhook-sdk public surface (server entry).\n//\n// Spec: docs/superpowers/specs/2026-04-29-webhook-ingest-design.md\n// Docs: /docs/sdk in the host app\n\nexport const SDK_VERSION = \"0.1.0\";\n\nexport { AimearnClient } from \"./client\";\nexport type { AimearnClientConfig } from \"./client\";\n\nexport { generateEventId } from \"./eventId\";\nexport {\n signRequest,\n verifyResponseSignature,\n parseSignatureHeader,\n REPLAY_WINDOW_SECONDS,\n} from \"./signing\";\n\nexport {\n AimearnError,\n AimearnAuthError,\n AimearnValidationError,\n AimearnNotFoundError,\n AimearnForbiddenError,\n AimearnConflictError,\n AimearnPlatformError,\n errorFromResponse,\n} from \"./errors\";\nexport type { AimearnErrorCode } from \"./errors\";\n\nexport {\n isRetriable,\n backoffDelayMs,\n resolveRetryConfig,\n} from \"./retry\";\nexport type { RetryConfig } from \"./retry\";\n\nexport type {\n WireEventType,\n CompletedItem,\n CustomerInfo,\n CompletedData,\n ShippedData,\n RefundedData,\n CancelledData,\n Envelope,\n OrderCompletedInput,\n OrderShippedInput,\n OrderRefundedInput,\n OrderCancelledInput,\n AcceptedResponse,\n} from \"./types\";\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@aimearn/webhook-sdk",
3
+ "version": "0.1.0",
4
+ "description": "Brand SDK for the Aim Earn webhook ingest API — HMAC signing, response verification, retries, idempotency.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.cjs",
8
+ "module": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js",
14
+ "require": "./dist/index.cjs"
15
+ },
16
+ "./browser": {
17
+ "types": "./dist/browser/index.d.ts",
18
+ "import": "./dist/browser/index.js"
19
+ }
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "README.md",
24
+ "CHANGELOG.md",
25
+ "LICENSE"
26
+ ],
27
+ "keywords": [
28
+ "aimearn",
29
+ "webhook",
30
+ "affiliate",
31
+ "commission",
32
+ "sdk",
33
+ "hmac",
34
+ "ecommerce"
35
+ ],
36
+ "homepage": "https://github.com/sangium47/aim-earn/tree/master/packages/webhook-sdk#readme",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/sangium47/aim-earn.git",
40
+ "directory": "packages/webhook-sdk"
41
+ },
42
+ "bugs": {
43
+ "url": "https://github.com/sangium47/aim-earn/issues"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public",
47
+ "registry": "https://registry.npmjs.org/"
48
+ },
49
+ "scripts": {
50
+ "build": "tsup",
51
+ "build:watch": "tsup --watch",
52
+ "test": "vitest run",
53
+ "typecheck": "tsc --noEmit",
54
+ "prepublishOnly": "npm run typecheck && npm test && npm run build"
55
+ },
56
+ "engines": {
57
+ "node": ">=20"
58
+ },
59
+ "devDependencies": {
60
+ "tsup": "^8.3.5"
61
+ }
62
+ }