@farthershore/backend 0.20.0 → 0.21.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/CHANGELOG.md +103 -0
- package/README.md +239 -91
- package/dist/adapters/express.js +68 -12
- package/dist/generated/runtime-contract.js +21 -236
- package/dist/index.js +519 -417
- package/dist/internal/index.js +587 -0
- package/dist/testing/index.js +614 -302
- package/dist/types/adapters/express.d.ts +32 -3
- package/dist/types/core/permissions.d.ts +25 -13
- package/dist/types/core/post-stream-usage.d.ts +19 -4
- package/dist/types/core/report.d.ts +133 -0
- package/dist/types/core/runtime.d.ts +25 -12
- package/dist/types/core/verifyRequest.d.ts +21 -3
- package/dist/types/generated/runtime-contract.d.ts +14 -189
- package/dist/types/index.d.ts +29 -8
- package/dist/types/internal/index.d.ts +2 -0
- package/dist/types/response-metering.d.ts +29 -39
- package/dist/types/runtime-types.d.ts +16 -1
- package/dist/types/testing/devRuntime.d.ts +11 -2
- package/dist/types/testing/index.d.ts +1 -0
- package/dist/types/testing/usageSink.d.ts +1 -1
- package/dist/types/testing/webhooks.d.ts +30 -0
- package/dist/types/webhooks/index.d.ts +247 -0
- package/dist/types/webhooks/types.d.ts +110 -0
- package/dist/webhooks/index.js +498 -0
- package/package.json +12 -3
- package/dist/types/core/metering.d.ts +0 -68
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
import { createRequire as __createRequire } from "node:module";const require=__createRequire(import.meta.url);
|
|
2
|
+
|
|
3
|
+
// ../contracts/dist/webhooks/standard-webhooks.js
|
|
4
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
5
|
+
var WEBHOOK_ID_HEADER = "webhook-id";
|
|
6
|
+
var WEBHOOK_TIMESTAMP_HEADER = "webhook-timestamp";
|
|
7
|
+
var WEBHOOK_SIGNATURE_HEADER = "webhook-signature";
|
|
8
|
+
var WEBHOOK_SIGNATURE_VERSION = "v1";
|
|
9
|
+
var WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS = 300;
|
|
10
|
+
var WEBHOOK_SECRET_PREFIX = "fswh_";
|
|
11
|
+
function webhookSecretKeyBytes(secret) {
|
|
12
|
+
const key = secret.startsWith(WEBHOOK_SECRET_PREFIX) ? Buffer.from(secret.slice(WEBHOOK_SECRET_PREFIX.length), "base64") : secret.startsWith("whsec_") ? Buffer.from(secret.slice("whsec_".length), "base64") : Buffer.from(secret, "utf8");
|
|
13
|
+
if (key.length === 0) {
|
|
14
|
+
throw new Error("webhook secret resolves to an empty signing key \u2014 the configured secret must be a non-empty fswh_ value");
|
|
15
|
+
}
|
|
16
|
+
return key;
|
|
17
|
+
}
|
|
18
|
+
function webhookSignedContent(id, timestamp, body) {
|
|
19
|
+
return `${id}.${timestamp}.${body}`;
|
|
20
|
+
}
|
|
21
|
+
function readHeader(headers, name) {
|
|
22
|
+
if (typeof headers.get === "function") {
|
|
23
|
+
return headers.get(name) ?? void 0;
|
|
24
|
+
}
|
|
25
|
+
const record = headers;
|
|
26
|
+
const direct = record[name] ?? record[name.toLowerCase()];
|
|
27
|
+
if (direct !== void 0)
|
|
28
|
+
return Array.isArray(direct) ? direct[0] : direct;
|
|
29
|
+
const lower = name.toLowerCase();
|
|
30
|
+
for (const key of Object.keys(record)) {
|
|
31
|
+
if (key.toLowerCase() === lower) {
|
|
32
|
+
const value = record[key];
|
|
33
|
+
return Array.isArray(value) ? value[0] : value;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return void 0;
|
|
37
|
+
}
|
|
38
|
+
function parseWebhookSignatureHeader(header) {
|
|
39
|
+
const macs = [];
|
|
40
|
+
for (const entry of header.split(" ")) {
|
|
41
|
+
if (entry.length === 0)
|
|
42
|
+
continue;
|
|
43
|
+
const comma = entry.indexOf(",");
|
|
44
|
+
if (comma <= 0)
|
|
45
|
+
continue;
|
|
46
|
+
if (entry.slice(0, comma) !== WEBHOOK_SIGNATURE_VERSION)
|
|
47
|
+
continue;
|
|
48
|
+
macs.push(entry.slice(comma + 1));
|
|
49
|
+
}
|
|
50
|
+
return macs;
|
|
51
|
+
}
|
|
52
|
+
function verifyWebhook(input) {
|
|
53
|
+
const id = readHeader(input.headers, WEBHOOK_ID_HEADER);
|
|
54
|
+
const timestampRaw = readHeader(input.headers, WEBHOOK_TIMESTAMP_HEADER);
|
|
55
|
+
const signatureRaw = readHeader(input.headers, WEBHOOK_SIGNATURE_HEADER);
|
|
56
|
+
if (!id || !timestampRaw || !signatureRaw) {
|
|
57
|
+
return { ok: false, reason: "missing_headers" };
|
|
58
|
+
}
|
|
59
|
+
if (!/^\d{1,12}$/.test(timestampRaw)) {
|
|
60
|
+
return { ok: false, reason: "invalid_timestamp" };
|
|
61
|
+
}
|
|
62
|
+
const timestamp = Number(timestampRaw);
|
|
63
|
+
const nowSeconds = Math.floor((input.now ?? Date.now)() / 1e3);
|
|
64
|
+
const tolerance = input.toleranceSeconds ?? WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS;
|
|
65
|
+
if (Math.abs(nowSeconds - timestamp) > tolerance) {
|
|
66
|
+
return { ok: false, reason: "timestamp_out_of_tolerance" };
|
|
67
|
+
}
|
|
68
|
+
const body = typeof input.body === "string" ? input.body : Buffer.from(input.body).toString("utf8");
|
|
69
|
+
const presented = parseWebhookSignatureHeader(signatureRaw).map((mac) => Buffer.from(mac, "base64"));
|
|
70
|
+
for (const secret of input.secrets) {
|
|
71
|
+
let keyBytes;
|
|
72
|
+
try {
|
|
73
|
+
keyBytes = webhookSecretKeyBytes(secret);
|
|
74
|
+
} catch {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const expected = Buffer.from(createHmac("sha256", keyBytes).update(webhookSignedContent(id, timestamp, body)).digest("base64"), "base64");
|
|
78
|
+
for (const candidate of presented) {
|
|
79
|
+
if (candidate.length === expected.length && timingSafeEqual(candidate, expected)) {
|
|
80
|
+
return { ok: true, id, timestamp };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { ok: false, reason: "no_matching_signature" };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// src/webhooks/types.ts
|
|
88
|
+
var WEBHOOK_EVENT_NAMES = [
|
|
89
|
+
"subscription.created",
|
|
90
|
+
"subscription.updated",
|
|
91
|
+
"subscription.canceled",
|
|
92
|
+
"payment.succeeded",
|
|
93
|
+
"payment.failed",
|
|
94
|
+
"entitlement.changed",
|
|
95
|
+
"usage.threshold_reached"
|
|
96
|
+
];
|
|
97
|
+
var WEBHOOK_TEST_EVENT = "webhook.test";
|
|
98
|
+
var WEBHOOK_ID_HEADER2 = "webhook-id";
|
|
99
|
+
var WEBHOOK_TIMESTAMP_HEADER2 = "webhook-timestamp";
|
|
100
|
+
var WEBHOOK_SIGNATURE_HEADER2 = "webhook-signature";
|
|
101
|
+
var WEBHOOK_EVENT_HEADER = "x-fs-webhook-event";
|
|
102
|
+
var WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS2 = 300;
|
|
103
|
+
var WEBHOOK_SECRET_PREFIX2 = "fswh_";
|
|
104
|
+
|
|
105
|
+
// src/webhooks/index.ts
|
|
106
|
+
function verifyWebhook2(input) {
|
|
107
|
+
return verifyWebhook(input);
|
|
108
|
+
}
|
|
109
|
+
var ENVELOPE_TYPES = /* @__PURE__ */ new Set([
|
|
110
|
+
...WEBHOOK_EVENT_NAMES,
|
|
111
|
+
WEBHOOK_TEST_EVENT
|
|
112
|
+
]);
|
|
113
|
+
function isWebhookEnvelopeType(value) {
|
|
114
|
+
return typeof value === "string" && ENVELOPE_TYPES.has(value);
|
|
115
|
+
}
|
|
116
|
+
var WEBHOOK_DEDUPE_MAX_ENTRIES = 25e4;
|
|
117
|
+
var WEBHOOK_INFLIGHT_LEASE_MS = 5 * 60 * 1e3;
|
|
118
|
+
var MemoryWebhookNonceStore = class {
|
|
119
|
+
constructor(options = {}) {
|
|
120
|
+
this.options = options;
|
|
121
|
+
}
|
|
122
|
+
options;
|
|
123
|
+
seen = /* @__PURE__ */ new Map();
|
|
124
|
+
tokenSeq = 0;
|
|
125
|
+
horizonFor(state) {
|
|
126
|
+
return state === "processed" ? this.options.ttlMs ?? WEBHOOK_DEDUPE_TTL_MS : this.options.leaseMs ?? WEBHOOK_INFLIGHT_LEASE_MS;
|
|
127
|
+
}
|
|
128
|
+
claim(id) {
|
|
129
|
+
const now = (this.options.now ?? Date.now)();
|
|
130
|
+
const max = this.options.maxEntries ?? WEBHOOK_DEDUPE_MAX_ENTRIES;
|
|
131
|
+
const existing = this.seen.get(id);
|
|
132
|
+
if (existing && now - existing.at < this.horizonFor(existing.state)) {
|
|
133
|
+
return { outcome: existing.state };
|
|
134
|
+
}
|
|
135
|
+
if (existing) this.seen.delete(id);
|
|
136
|
+
for (const [key, entry] of this.seen) {
|
|
137
|
+
if (now - entry.at >= this.horizonFor(entry.state)) this.seen.delete(key);
|
|
138
|
+
else break;
|
|
139
|
+
}
|
|
140
|
+
if (this.seen.size >= max) {
|
|
141
|
+
for (const [key, entry] of this.seen) {
|
|
142
|
+
if (now - entry.at >= this.horizonFor(entry.state))
|
|
143
|
+
this.seen.delete(key);
|
|
144
|
+
}
|
|
145
|
+
if (this.seen.size >= max) return { outcome: "saturated" };
|
|
146
|
+
}
|
|
147
|
+
const token = String(++this.tokenSeq);
|
|
148
|
+
this.seen.set(id, { state: "in_flight", at: now, token });
|
|
149
|
+
return { outcome: "fresh", token };
|
|
150
|
+
}
|
|
151
|
+
settle(id, token, outcome) {
|
|
152
|
+
const entry = this.seen.get(id);
|
|
153
|
+
if (!entry || entry.token !== token) return;
|
|
154
|
+
if (outcome === "release") {
|
|
155
|
+
this.seen.delete(id);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
this.seen.set(id, {
|
|
159
|
+
state: "processed",
|
|
160
|
+
at: (this.options.now ?? Date.now)(),
|
|
161
|
+
token
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
get size() {
|
|
165
|
+
return this.seen.size;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
var WEBHOOK_DEDUPE_TTL_MS = 60 * 60 * 1e3;
|
|
169
|
+
var WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
|
|
170
|
+
function statusBodyFor(outcome) {
|
|
171
|
+
switch (outcome.kind) {
|
|
172
|
+
case "rejected":
|
|
173
|
+
return { error: outcome.rejection.reason };
|
|
174
|
+
case "handler_error":
|
|
175
|
+
return { error: "handler_error", id: outcome.id };
|
|
176
|
+
case "store_unavailable":
|
|
177
|
+
return { error: "store_unavailable", id: outcome.id };
|
|
178
|
+
case "in_flight":
|
|
179
|
+
return { error: "in_flight", id: outcome.id };
|
|
180
|
+
default:
|
|
181
|
+
return { ok: true, kind: outcome.kind, id: outcome.id };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async function readBoundedRequestBody(request, limit) {
|
|
185
|
+
const declared = request.headers.get("content-length");
|
|
186
|
+
if (declared !== null && /^\d+$/.test(declared) && Number(declared) > limit) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
if (!request.body) return new Uint8Array(0);
|
|
190
|
+
const reader = request.body.getReader();
|
|
191
|
+
const chunks = [];
|
|
192
|
+
let total = 0;
|
|
193
|
+
try {
|
|
194
|
+
for (; ; ) {
|
|
195
|
+
const { done, value } = await reader.read();
|
|
196
|
+
if (done) break;
|
|
197
|
+
if (!value) continue;
|
|
198
|
+
total += value.byteLength;
|
|
199
|
+
if (total > limit) {
|
|
200
|
+
await reader.cancel().catch(() => void 0);
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
chunks.push(value);
|
|
204
|
+
}
|
|
205
|
+
} finally {
|
|
206
|
+
reader.releaseLock();
|
|
207
|
+
}
|
|
208
|
+
const out = new Uint8Array(total);
|
|
209
|
+
let offset = 0;
|
|
210
|
+
for (const chunk of chunks) {
|
|
211
|
+
out.set(chunk, offset);
|
|
212
|
+
offset += chunk.byteLength;
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
function readRawBody(req) {
|
|
217
|
+
const raw = req.rawBody;
|
|
218
|
+
if (raw !== void 0) return raw;
|
|
219
|
+
if (typeof req.body === "string") return req.body;
|
|
220
|
+
if (req.body instanceof Uint8Array) return req.body;
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
function createWebhookHandler(options) {
|
|
224
|
+
const secrets = [
|
|
225
|
+
...options.secret ? [options.secret] : [],
|
|
226
|
+
...options.secrets ?? []
|
|
227
|
+
];
|
|
228
|
+
if (secrets.length === 0) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
"createWebhookHandler: pass `secret` (or `secrets`) \u2014 the endpoint's fswh_ signing secret"
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
for (const secret of secrets) {
|
|
234
|
+
try {
|
|
235
|
+
webhookSecretKeyBytes(secret);
|
|
236
|
+
} catch {
|
|
237
|
+
throw new Error(
|
|
238
|
+
"createWebhookHandler: a configured signing secret is empty or malformed \u2014 expected a non-empty fswh_ secret"
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const now = options.now ?? (() => Date.now());
|
|
243
|
+
const store = options.nonceStore ?? new MemoryWebhookNonceStore({ ttlMs: WEBHOOK_DEDUPE_TTL_MS, now });
|
|
244
|
+
const maxBodyBytes = options.maxBodyBytes ?? WEBHOOK_MAX_BODY_BYTES;
|
|
245
|
+
const runObserver = (fn, arg) => {
|
|
246
|
+
if (!fn) return;
|
|
247
|
+
try {
|
|
248
|
+
const result = fn(arg);
|
|
249
|
+
if (result != null && typeof result.then === "function") {
|
|
250
|
+
void result.catch(() => {
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
} catch {
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
const notifyRejected = (rejection) => runObserver(options.onRejected, rejection);
|
|
257
|
+
async function handle(input) {
|
|
258
|
+
const size = typeof input.body === "string" ? Buffer.byteLength(input.body, "utf8") : input.body.byteLength;
|
|
259
|
+
if (size > maxBodyBytes) {
|
|
260
|
+
const rejection = {
|
|
261
|
+
reason: "payload_too_large",
|
|
262
|
+
limitBytes: maxBodyBytes
|
|
263
|
+
};
|
|
264
|
+
notifyRejected(rejection);
|
|
265
|
+
return { status: 413, kind: "rejected", rejection };
|
|
266
|
+
}
|
|
267
|
+
const verified = verifyWebhook({
|
|
268
|
+
body: input.body,
|
|
269
|
+
headers: input.headers,
|
|
270
|
+
secrets,
|
|
271
|
+
now
|
|
272
|
+
});
|
|
273
|
+
if (!verified.ok) {
|
|
274
|
+
const rejection = { reason: verified.reason };
|
|
275
|
+
notifyRejected(rejection);
|
|
276
|
+
return {
|
|
277
|
+
status: verified.reason === "missing_headers" ? 400 : 401,
|
|
278
|
+
kind: "rejected",
|
|
279
|
+
rejection
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
const text = typeof input.body === "string" ? input.body : new TextDecoder().decode(input.body);
|
|
283
|
+
let parsed;
|
|
284
|
+
try {
|
|
285
|
+
parsed = JSON.parse(text);
|
|
286
|
+
} catch {
|
|
287
|
+
const rejection = { reason: "invalid_json" };
|
|
288
|
+
notifyRejected(rejection);
|
|
289
|
+
return { status: 400, kind: "rejected", rejection };
|
|
290
|
+
}
|
|
291
|
+
const envelope = parsed;
|
|
292
|
+
if (!envelope || typeof envelope !== "object" || typeof envelope.id !== "string" || typeof envelope.type !== "string") {
|
|
293
|
+
const rejection = {
|
|
294
|
+
reason: "invalid_envelope",
|
|
295
|
+
detail: "body must be an object with string `id` and `type`"
|
|
296
|
+
};
|
|
297
|
+
notifyRejected(rejection);
|
|
298
|
+
return { status: 400, kind: "rejected", rejection };
|
|
299
|
+
}
|
|
300
|
+
if (envelope.id !== verified.id) {
|
|
301
|
+
const rejection = {
|
|
302
|
+
reason: "invalid_envelope",
|
|
303
|
+
detail: "body `id` does not match the signed webhook-id header"
|
|
304
|
+
};
|
|
305
|
+
notifyRejected(rejection);
|
|
306
|
+
return { status: 400, kind: "rejected", rejection };
|
|
307
|
+
}
|
|
308
|
+
const type = envelope.type;
|
|
309
|
+
const id = envelope.id;
|
|
310
|
+
if (isWebhookEnvelopeType(type)) {
|
|
311
|
+
const problem = envelopeShapeProblem(envelope);
|
|
312
|
+
if (problem) {
|
|
313
|
+
const rejection = {
|
|
314
|
+
reason: "invalid_envelope",
|
|
315
|
+
detail: problem
|
|
316
|
+
};
|
|
317
|
+
notifyRejected(rejection);
|
|
318
|
+
return { status: 400, kind: "rejected", rejection };
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
let claim;
|
|
322
|
+
try {
|
|
323
|
+
claim = await store.claim(id);
|
|
324
|
+
} catch {
|
|
325
|
+
return { status: 503, kind: "store_unavailable", id, type };
|
|
326
|
+
}
|
|
327
|
+
if (claim.outcome === "processed") {
|
|
328
|
+
runObserver(options.onDuplicate, { id, type });
|
|
329
|
+
return { status: 200, kind: "duplicate", id, type };
|
|
330
|
+
}
|
|
331
|
+
if (claim.outcome === "in_flight") {
|
|
332
|
+
return { status: 503, kind: "in_flight", id, type };
|
|
333
|
+
}
|
|
334
|
+
const token = claim.outcome === "fresh" && typeof claim.token === "string" ? claim.token : void 0;
|
|
335
|
+
const settle = async (outcome) => {
|
|
336
|
+
if (token === void 0) return;
|
|
337
|
+
try {
|
|
338
|
+
await store.settle(id, token, outcome);
|
|
339
|
+
} catch {
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
if (!isWebhookEnvelopeType(type)) {
|
|
343
|
+
try {
|
|
344
|
+
await options.onUnknown?.({ id, type, body: parsed });
|
|
345
|
+
} catch {
|
|
346
|
+
}
|
|
347
|
+
await settle("processed");
|
|
348
|
+
return { status: 200, kind: "unknown_type", id, type };
|
|
349
|
+
}
|
|
350
|
+
const handler = options.on[type];
|
|
351
|
+
if (!handler) {
|
|
352
|
+
await settle("processed");
|
|
353
|
+
return { status: 200, kind: "ignored", id, type };
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
await handler(envelope);
|
|
357
|
+
} catch (error) {
|
|
358
|
+
await settle("release");
|
|
359
|
+
return { status: 500, kind: "handler_error", id, type, error };
|
|
360
|
+
}
|
|
361
|
+
await settle("processed");
|
|
362
|
+
return { status: 200, kind: "handled", id, type };
|
|
363
|
+
}
|
|
364
|
+
return {
|
|
365
|
+
handle,
|
|
366
|
+
async fetch(request) {
|
|
367
|
+
const body = await readBoundedRequestBody(request, maxBodyBytes);
|
|
368
|
+
if (body === null) {
|
|
369
|
+
const rejection = {
|
|
370
|
+
reason: "payload_too_large",
|
|
371
|
+
limitBytes: maxBodyBytes
|
|
372
|
+
};
|
|
373
|
+
notifyRejected(rejection);
|
|
374
|
+
return new Response(JSON.stringify({ error: rejection.reason }), {
|
|
375
|
+
status: 413,
|
|
376
|
+
headers: { "content-type": "application/json" }
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
const outcome = await handle({ body, headers: request.headers });
|
|
380
|
+
return new Response(JSON.stringify(statusBodyFor(outcome)), {
|
|
381
|
+
status: outcome.status,
|
|
382
|
+
headers: { "content-type": "application/json" }
|
|
383
|
+
});
|
|
384
|
+
},
|
|
385
|
+
express() {
|
|
386
|
+
return async (req, res) => {
|
|
387
|
+
const body = readRawBody(req);
|
|
388
|
+
if (body === null) {
|
|
389
|
+
const rejection = {
|
|
390
|
+
reason: "invalid_envelope",
|
|
391
|
+
detail: "raw body unavailable \u2014 mount express.raw({ type: '*/*' }) before this handler or set req.rawBody"
|
|
392
|
+
};
|
|
393
|
+
notifyRejected(rejection);
|
|
394
|
+
res.status(400).json({ error: rejection.reason, detail: rejection.detail });
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const outcome = await handle({ body, headers: req.headers });
|
|
398
|
+
res.status(outcome.status).json(statusBodyFor(outcome));
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
function envelopeShapeProblem(envelope) {
|
|
404
|
+
if (typeof envelope.createdAt !== "string" || envelope.createdAt.length === 0) {
|
|
405
|
+
return "`createdAt` must be an ISO-8601 string";
|
|
406
|
+
}
|
|
407
|
+
if (Number.isNaN(Date.parse(envelope.createdAt))) {
|
|
408
|
+
return "`createdAt` is not a parseable timestamp";
|
|
409
|
+
}
|
|
410
|
+
if (typeof envelope.businessId !== "string" || envelope.businessId.length === 0) {
|
|
411
|
+
return "`businessId` must be a non-empty string";
|
|
412
|
+
}
|
|
413
|
+
if (envelope.environmentId !== null && typeof envelope.environmentId !== "string") {
|
|
414
|
+
return "`environmentId` must be a string or null";
|
|
415
|
+
}
|
|
416
|
+
if (typeof envelope.data !== "object" || envelope.data === null || Array.isArray(envelope.data)) {
|
|
417
|
+
return "`data` must be an object";
|
|
418
|
+
}
|
|
419
|
+
const data = envelope.data;
|
|
420
|
+
if (envelope.type === WEBHOOK_TEST_EVENT) {
|
|
421
|
+
if (typeof data.businessId !== "string" || typeof data.sentAt !== "string") {
|
|
422
|
+
return "`webhook.test` data must carry `businessId` and `sentAt`";
|
|
423
|
+
}
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
const expected = DATA_FIELD_TYPES[envelope.type];
|
|
427
|
+
for (const [key, kind] of Object.entries(expected)) {
|
|
428
|
+
const value = data[key];
|
|
429
|
+
if (value === void 0) continue;
|
|
430
|
+
const ok = kind === "string" ? typeof value === "string" : kind === "number" ? typeof value === "number" && Number.isFinite(value) : kind === "string|null" ? value === null || typeof value === "string" : value === null || typeof value === "number" && Number.isFinite(value);
|
|
431
|
+
if (!ok) return `\`data.${key}\` must be ${kind}`;
|
|
432
|
+
}
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
var DATA_FIELD_TYPES = {
|
|
436
|
+
"subscription.created": {
|
|
437
|
+
subscriptionId: "string",
|
|
438
|
+
compiledPlanId: "string"
|
|
439
|
+
},
|
|
440
|
+
"subscription.updated": {
|
|
441
|
+
subscriptionId: "string",
|
|
442
|
+
reason: "string",
|
|
443
|
+
lifecycle: "string",
|
|
444
|
+
compiledPlanId: "string"
|
|
445
|
+
},
|
|
446
|
+
"subscription.canceled": {
|
|
447
|
+
subscriptionId: "string",
|
|
448
|
+
reason: "string",
|
|
449
|
+
lifecycle: "string"
|
|
450
|
+
},
|
|
451
|
+
"payment.succeeded": {
|
|
452
|
+
subscriptionId: "string",
|
|
453
|
+
invoiceId: "string",
|
|
454
|
+
amount: "number|null",
|
|
455
|
+
currency: "string|null",
|
|
456
|
+
reason: "string"
|
|
457
|
+
},
|
|
458
|
+
"payment.failed": {
|
|
459
|
+
subscriptionId: "string",
|
|
460
|
+
invoiceId: "string",
|
|
461
|
+
amount: "number|null",
|
|
462
|
+
currency: "string|null",
|
|
463
|
+
reason: "string"
|
|
464
|
+
},
|
|
465
|
+
"entitlement.changed": {
|
|
466
|
+
compiledPlanId: "string",
|
|
467
|
+
lineageId: "string",
|
|
468
|
+
status: "string"
|
|
469
|
+
},
|
|
470
|
+
"usage.threshold_reached": {
|
|
471
|
+
subscriptionId: "string",
|
|
472
|
+
subscriberId: "string",
|
|
473
|
+
limitId: "string",
|
|
474
|
+
threshold: "number",
|
|
475
|
+
windowStartMs: "number",
|
|
476
|
+
windowEndMs: "number"
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
export {
|
|
480
|
+
DATA_FIELD_TYPES,
|
|
481
|
+
MemoryWebhookNonceStore,
|
|
482
|
+
WEBHOOK_DEDUPE_MAX_ENTRIES,
|
|
483
|
+
WEBHOOK_DEDUPE_TTL_MS,
|
|
484
|
+
WEBHOOK_EVENT_HEADER,
|
|
485
|
+
WEBHOOK_EVENT_NAMES,
|
|
486
|
+
WEBHOOK_ID_HEADER2 as WEBHOOK_ID_HEADER,
|
|
487
|
+
WEBHOOK_INFLIGHT_LEASE_MS,
|
|
488
|
+
WEBHOOK_MAX_BODY_BYTES,
|
|
489
|
+
WEBHOOK_SECRET_PREFIX2 as WEBHOOK_SECRET_PREFIX,
|
|
490
|
+
WEBHOOK_SIGNATURE_HEADER2 as WEBHOOK_SIGNATURE_HEADER,
|
|
491
|
+
WEBHOOK_TEST_EVENT,
|
|
492
|
+
WEBHOOK_TIMESTAMP_HEADER2 as WEBHOOK_TIMESTAMP_HEADER,
|
|
493
|
+
WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS2 as WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS,
|
|
494
|
+
createWebhookHandler,
|
|
495
|
+
isWebhookEnvelopeType,
|
|
496
|
+
readBoundedRequestBody,
|
|
497
|
+
verifyWebhook2 as verifyWebhook
|
|
498
|
+
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@farthershore/backend",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Farther Shore backend SDK for builder upstreams: signed response usage, fail-closed gateway request verification, health, and lifecycle from FS_RUNTIME_TOKEN",
|
|
3
|
+
"version": "0.21.0",
|
|
4
|
+
"description": "Farther Shore backend SDK for builder upstreams: signed response usage, fail-closed gateway request verification, first-class webhook consumption, health, and lifecycle from FS_RUNTIME_TOKEN",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"module": "./dist/index.js",
|
|
@@ -26,6 +26,14 @@
|
|
|
26
26
|
"./testing": {
|
|
27
27
|
"types": "./dist/types/testing/index.d.ts",
|
|
28
28
|
"import": "./dist/testing/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./webhooks": {
|
|
31
|
+
"types": "./dist/types/webhooks/index.d.ts",
|
|
32
|
+
"import": "./dist/webhooks/index.js"
|
|
33
|
+
},
|
|
34
|
+
"./internal": {
|
|
35
|
+
"types": "./dist/types/internal/index.d.ts",
|
|
36
|
+
"import": "./dist/internal/index.js"
|
|
29
37
|
}
|
|
30
38
|
},
|
|
31
39
|
"files": [
|
|
@@ -37,8 +45,8 @@
|
|
|
37
45
|
"access": "public"
|
|
38
46
|
},
|
|
39
47
|
"optionalDependencies": {
|
|
40
|
-
"@farthershore/cloudflared-darwin-arm64": "0.0.0",
|
|
41
48
|
"@farthershore/cloudflared-darwin-x64": "0.0.0",
|
|
49
|
+
"@farthershore/cloudflared-darwin-arm64": "0.0.0",
|
|
42
50
|
"@farthershore/cloudflared-linux-arm64": "0.0.0",
|
|
43
51
|
"@farthershore/cloudflared-linux-x64": "0.0.0"
|
|
44
52
|
},
|
|
@@ -55,6 +63,7 @@
|
|
|
55
63
|
"esbuild": "^0.28.1",
|
|
56
64
|
"eslint": "^9.39.5",
|
|
57
65
|
"eslint-plugin-sonarjs": "^4.2.0",
|
|
66
|
+
"standardwebhooks": "1.0.0",
|
|
58
67
|
"typescript": "^6.0.3",
|
|
59
68
|
"typescript-eslint": "^8.66.0",
|
|
60
69
|
"vitest": "^4.1.10",
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import type { RuntimeMeteringConfig } from "../runtime-types.js";
|
|
2
|
-
export type MeterOptions = {
|
|
3
|
-
requestId?: string;
|
|
4
|
-
routeId?: string;
|
|
5
|
-
/** Subscription to attribute the usage to (billing identity). Pass the
|
|
6
|
-
* verified request context's `signedContext.subscriptionId` when metering
|
|
7
|
-
* inside a request handler. Without it (and without `requestId`, which core
|
|
8
|
-
* can resolve back to the served gateway request), core persists the event
|
|
9
|
-
* UNBILLED and flags it unattributable. */
|
|
10
|
-
subscriptionId?: string;
|
|
11
|
-
/** Override event_id (idempotency key). Defaults to a random uuid. */
|
|
12
|
-
eventId?: string;
|
|
13
|
-
/** Override the timestamp (ISO-8601). Defaults to now. */
|
|
14
|
-
timestamp?: string;
|
|
15
|
-
};
|
|
16
|
-
export type MeteringClientOptions = {
|
|
17
|
-
config: RuntimeMeteringConfig;
|
|
18
|
-
businessId: string;
|
|
19
|
-
backendId: string;
|
|
20
|
-
/** Core base URL when the config endpoint is a relative path. */
|
|
21
|
-
coreUrl?: string;
|
|
22
|
-
fetchImpl?: typeof fetch;
|
|
23
|
-
/** Max retry attempts per flush before re-buffering. */
|
|
24
|
-
maxRetries?: number;
|
|
25
|
-
/** Base for the exponential inter-attempt backoff (ms). Default 200. */
|
|
26
|
-
baseDelayMs?: number;
|
|
27
|
-
/** Ceiling on any single inter-attempt wait (ms) — caps both backoff and a
|
|
28
|
-
* `Retry-After` hint. Default 10000. */
|
|
29
|
-
maxDelayMs?: number;
|
|
30
|
-
/** Injectable delay primitive (tests pass a no-op; default is a timer). */
|
|
31
|
-
sleep?: (ms: number) => Promise<void>;
|
|
32
|
-
/** Injectable uniform random in [0,1) for the backoff jitter (tests pin it). */
|
|
33
|
-
random?: () => number;
|
|
34
|
-
/** Injectable id generator (tests). */
|
|
35
|
-
newId?: () => string;
|
|
36
|
-
now?: () => Date;
|
|
37
|
-
};
|
|
38
|
-
/**
|
|
39
|
-
* Buffers + flushes metering events. `meter()` validates and enqueues; `flush()`
|
|
40
|
-
* drains the buffer (re-buffering on failure for at-least-once delivery).
|
|
41
|
-
*/
|
|
42
|
-
export declare class MeteringClient {
|
|
43
|
-
private readonly config;
|
|
44
|
-
private readonly endpoint;
|
|
45
|
-
private readonly businessId;
|
|
46
|
-
private readonly backendId;
|
|
47
|
-
private readonly fetchImpl;
|
|
48
|
-
private readonly maxRetries;
|
|
49
|
-
private readonly baseDelayMs;
|
|
50
|
-
private readonly maxDelayMs;
|
|
51
|
-
private readonly sleep;
|
|
52
|
-
private readonly random;
|
|
53
|
-
private readonly newId;
|
|
54
|
-
private readonly now;
|
|
55
|
-
private readonly buffer;
|
|
56
|
-
constructor(options: MeteringClientOptions);
|
|
57
|
-
/**
|
|
58
|
-
* Record `qty` of `meter`. Enforces meter-key shape, non-negative finite qty,
|
|
59
|
-
* the bootstrap allowedMeters/allowedRoutes scope, and the per-event sanity
|
|
60
|
-
* max, then enqueues and flushes (best-effort; failures stay buffered).
|
|
61
|
-
*/
|
|
62
|
-
meter(meter: string, qty: number, options?: MeterOptions): Promise<void>;
|
|
63
|
-
/** Drain the buffer. Events that fail all retries stay buffered (at-least-once). */
|
|
64
|
-
flush(): Promise<void>;
|
|
65
|
-
/** Buffered-but-unsent count (observability/tests). */
|
|
66
|
-
get pending(): number;
|
|
67
|
-
private sendWithRetry;
|
|
68
|
-
}
|