@farthershore/backend 0.19.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 +139 -0
- package/README.md +267 -91
- package/dist/adapters/express.js +68 -12
- package/dist/generated/runtime-contract.js +21 -236
- package/dist/index.js +837 -465
- package/dist/internal/index.js +587 -0
- package/dist/testing/index.js +935 -353
- package/dist/types/adapters/express.d.ts +32 -3
- package/dist/types/core/bootstrap.d.ts +8 -0
- package/dist/types/core/deadline.d.ts +80 -0
- package/dist/types/core/jwks.d.ts +42 -7
- package/dist/types/core/permissions.d.ts +25 -13
- package/dist/types/core/post-stream-usage.d.ts +23 -4
- package/dist/types/core/replay-protection.d.ts +28 -0
- package/dist/types/core/report.d.ts +133 -0
- package/dist/types/core/runtime.d.ts +44 -20
- 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 +30 -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 +21 -12
- package/dist/types/core/metering.d.ts +0 -68
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
import { createRequire as __createRequire } from "node:module";const require=__createRequire(import.meta.url);
|
|
2
|
+
|
|
3
|
+
// src/generated/runtime-contract.ts
|
|
4
|
+
var RUNTIME_RESPONSE_METERING_CONTRACT = {
|
|
5
|
+
headers: {
|
|
6
|
+
payload: "x-fs-metering",
|
|
7
|
+
signature: "x-fs-metering-sig",
|
|
8
|
+
token: "x-fs-metering-token"
|
|
9
|
+
},
|
|
10
|
+
token: {
|
|
11
|
+
environmentVariable: "FS_RUNTIME_TOKEN",
|
|
12
|
+
presentation: "x-fs-metering-token",
|
|
13
|
+
storage: "sha256-hash-only"
|
|
14
|
+
},
|
|
15
|
+
signature: {
|
|
16
|
+
algorithm: "HMAC-SHA256",
|
|
17
|
+
encoding: "base64url",
|
|
18
|
+
input: "payload-json",
|
|
19
|
+
secret: "presented-runtime-token"
|
|
20
|
+
},
|
|
21
|
+
payload: {
|
|
22
|
+
method: "string",
|
|
23
|
+
path: "string",
|
|
24
|
+
rawDimsUnits: "Record<string, number>?",
|
|
25
|
+
measureContext: "Record<string, unknown>?",
|
|
26
|
+
creditUnitsConsumed: "Record<string, number>?",
|
|
27
|
+
measurementsVersion: "1?",
|
|
28
|
+
measurements: "Array<{ meter: string; values: Record<string, number>; dims?: Record<string, string> }>?",
|
|
29
|
+
quote: "{ currency: string; amountNanos: string }?"
|
|
30
|
+
},
|
|
31
|
+
errors: {
|
|
32
|
+
missingToken: "missing_token",
|
|
33
|
+
invalidMeterKey: "invalid_meter_key",
|
|
34
|
+
invalidMeterValue: "invalid_meter_value",
|
|
35
|
+
invalidQuote: "invalid_quote"
|
|
36
|
+
},
|
|
37
|
+
httpAdapter: {
|
|
38
|
+
input: "Request",
|
|
39
|
+
output: "Response",
|
|
40
|
+
networkCalls: false,
|
|
41
|
+
preserves: ["body", "headers", "status", "statusText"],
|
|
42
|
+
gatewayStripsInternalHeaders: true
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// src/response-metering.ts
|
|
47
|
+
var RESPONSE_METERING_HEADERS = RUNTIME_RESPONSE_METERING_CONTRACT.headers;
|
|
48
|
+
var RESPONSE_METERING_ERROR_CODES = RUNTIME_RESPONSE_METERING_CONTRACT.errors;
|
|
49
|
+
var METERING_PAYLOAD_HEADER = RESPONSE_METERING_HEADERS.payload;
|
|
50
|
+
var METERING_SIGNATURE_HEADER = RESPONSE_METERING_HEADERS.signature;
|
|
51
|
+
var METERING_TOKEN_HEADER = RESPONSE_METERING_HEADERS.token;
|
|
52
|
+
var DEFAULT_TOKEN_ENV = RUNTIME_RESPONSE_METERING_CONTRACT.token.environmentVariable;
|
|
53
|
+
var MeteringError = class extends Error {
|
|
54
|
+
code;
|
|
55
|
+
constructor(code, message) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.name = "MeteringError";
|
|
58
|
+
this.code = code;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
async function signPayload(payload, token) {
|
|
62
|
+
const key = await crypto.subtle.importKey(
|
|
63
|
+
"raw",
|
|
64
|
+
new TextEncoder().encode(token),
|
|
65
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
66
|
+
false,
|
|
67
|
+
["sign"]
|
|
68
|
+
);
|
|
69
|
+
const signature = await crypto.subtle.sign(
|
|
70
|
+
"HMAC",
|
|
71
|
+
key,
|
|
72
|
+
new TextEncoder().encode(payload)
|
|
73
|
+
);
|
|
74
|
+
return base64url(new Uint8Array(signature));
|
|
75
|
+
}
|
|
76
|
+
function base64url(bytes) {
|
|
77
|
+
let binary = "";
|
|
78
|
+
for (const byte of bytes) {
|
|
79
|
+
binary += String.fromCharCode(byte);
|
|
80
|
+
}
|
|
81
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// src/core/report.ts
|
|
85
|
+
var MEASUREMENTS_VERSION = 1;
|
|
86
|
+
var KEY_RE = /^[a-z0-9_]{1,64}$/;
|
|
87
|
+
var DIMENSION_VALUE_RE = /^[\w.:-]{1,128}$/;
|
|
88
|
+
var CURRENCY_RE = /^[A-Za-z]{3}$/;
|
|
89
|
+
var DECIMAL_INTEGER_RE = /^\d{1,30}$/;
|
|
90
|
+
function createReportFn(channels) {
|
|
91
|
+
let stampedMeasurements = [];
|
|
92
|
+
let stampedQuote;
|
|
93
|
+
let inBandTail = Promise.resolve();
|
|
94
|
+
let postStreamFinalized = false;
|
|
95
|
+
let pendingPostStreamBatch = null;
|
|
96
|
+
const deliverPostStream = async (reported, quote) => {
|
|
97
|
+
if (pendingPostStreamBatch) {
|
|
98
|
+
if (!quotesEqual(pendingPostStreamBatch.quote, quote)) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
transport: "post_stream",
|
|
102
|
+
reason: "quote conflicts with this request's pending post-stream batch: one served request carries one quote across all measurements"
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (reported.some(
|
|
106
|
+
(measurement) => !dimsEqual(
|
|
107
|
+
pendingPostStreamBatch.measurements[0]?.dims,
|
|
108
|
+
measurement.dims
|
|
109
|
+
)
|
|
110
|
+
)) {
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
transport: "post_stream",
|
|
114
|
+
reason: "dims conflict with this request's pending post-stream batch: the request receipt rates under ONE dims tuple"
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
pendingPostStreamBatch.measurements.push(...reported);
|
|
118
|
+
return pendingPostStreamBatch.flush;
|
|
119
|
+
}
|
|
120
|
+
if (postStreamFinalized) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
transport: "post_stream",
|
|
124
|
+
reason: "the served request already used its post-stream callback; report multiple meters in ONE call \u2014 ctx.report([a, b]) \u2014 or before the flush"
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (stampedMeasurements.length > 0) {
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
transport: "post_stream",
|
|
131
|
+
reason: "this request already reported in-band; every report on one request must share the stamped aggregate (same quote, before the response is sent)"
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
postStreamFinalized = true;
|
|
135
|
+
const batch = {
|
|
136
|
+
measurements: [...reported],
|
|
137
|
+
quote,
|
|
138
|
+
flush: void 0
|
|
139
|
+
};
|
|
140
|
+
batch.flush = new Promise((resolve) => setTimeout(resolve, 0)).then(
|
|
141
|
+
async () => {
|
|
142
|
+
pendingPostStreamBatch = null;
|
|
143
|
+
const result = await channels.postStream({
|
|
144
|
+
measurements: batch.measurements,
|
|
145
|
+
...batch.quote ? { quote: batch.quote } : {}
|
|
146
|
+
});
|
|
147
|
+
return result.ok ? { ok: true, transport: "post_stream" } : {
|
|
148
|
+
ok: false,
|
|
149
|
+
transport: "post_stream",
|
|
150
|
+
reason: result.reason ?? "post-stream delivery failed"
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
);
|
|
154
|
+
pendingPostStreamBatch = batch;
|
|
155
|
+
return batch.flush;
|
|
156
|
+
};
|
|
157
|
+
const tryInBand = (reported, quote) => {
|
|
158
|
+
const run = inBandTail.then(async () => {
|
|
159
|
+
const sink = channels.responseSink;
|
|
160
|
+
if (!sink || !channels.request || !sink.canStampHeaders()) return null;
|
|
161
|
+
if (postStreamFinalized) return null;
|
|
162
|
+
if (!quotesEqual(stampedQuote, quote) && stampedMeasurements.length > 0) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
if (stampedMeasurements.length > 0 && reported.some(
|
|
166
|
+
(measurement) => !dimsEqual(stampedMeasurements[0].dims, measurement.dims)
|
|
167
|
+
)) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
const measurements = [...stampedMeasurements, ...reported];
|
|
171
|
+
const payload = buildInBandPayload(channels.request, measurements, quote);
|
|
172
|
+
const headers = await channels.computeHeaders(payload);
|
|
173
|
+
if (Object.keys(headers).length === 0 || !sink.canStampHeaders()) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
sink.stampHeaders(headers);
|
|
177
|
+
stampedMeasurements = measurements;
|
|
178
|
+
stampedQuote = quote;
|
|
179
|
+
return { ok: true, transport: "in_band" };
|
|
180
|
+
});
|
|
181
|
+
inBandTail = run.then(
|
|
182
|
+
() => void 0,
|
|
183
|
+
() => void 0
|
|
184
|
+
);
|
|
185
|
+
return run;
|
|
186
|
+
};
|
|
187
|
+
return async (input) => {
|
|
188
|
+
const inputs = Array.isArray(input) ? input : [input];
|
|
189
|
+
if (inputs.length === 0) {
|
|
190
|
+
throw new MeteringError(
|
|
191
|
+
RESPONSE_METERING_ERROR_CODES.invalidMeterKey,
|
|
192
|
+
"report([]) is empty: a batched report needs at least one measurement"
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
const measurements = inputs.map((entry) => validateMeasurement(entry));
|
|
196
|
+
for (const measurement of measurements) {
|
|
197
|
+
if (!dimsEqual(measurements[0].dims, measurement.dims)) {
|
|
198
|
+
throw new MeteringError(
|
|
199
|
+
RESPONSE_METERING_ERROR_CODES.invalidMeterValue,
|
|
200
|
+
"a batched report carries ONE dims tuple: the request receipt rates under (route, dims), so mixed dims are unratable \u2014 report each dims tuple on its own request"
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
let quote;
|
|
205
|
+
for (const entry of inputs) {
|
|
206
|
+
if (entry.quote === void 0) continue;
|
|
207
|
+
const validated = validateQuote(entry.quote);
|
|
208
|
+
if (quote === void 0) {
|
|
209
|
+
quote = validated;
|
|
210
|
+
} else if (!quotesEqual(quote, validated)) {
|
|
211
|
+
throw new MeteringError(
|
|
212
|
+
RESPONSE_METERING_ERROR_CODES.invalidQuote,
|
|
213
|
+
"a batched report carries ONE quote: two entries supplied different quotes"
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const inBand = await tryInBand(measurements, quote);
|
|
218
|
+
if (inBand) return inBand;
|
|
219
|
+
return deliverPostStream(measurements, quote);
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
function rawDimsUnitsOf(measurement) {
|
|
223
|
+
let total = 0;
|
|
224
|
+
for (const value of Object.values(measurement.values)) total += value;
|
|
225
|
+
return { [measurement.meter]: total };
|
|
226
|
+
}
|
|
227
|
+
function buildInBandPayload(request, measurements, quote) {
|
|
228
|
+
const rawDimsUnits = {};
|
|
229
|
+
for (const measurement of measurements) {
|
|
230
|
+
for (const [meter, units] of Object.entries(rawDimsUnitsOf(measurement))) {
|
|
231
|
+
rawDimsUnits[meter] = (rawDimsUnits[meter] ?? 0) + units;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
method: request.method.toUpperCase(),
|
|
236
|
+
path: request.path,
|
|
237
|
+
rawDimsUnits,
|
|
238
|
+
measurementsVersion: MEASUREMENTS_VERSION,
|
|
239
|
+
measurements,
|
|
240
|
+
...quote ? { quote } : {}
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
function dimsEqual(left, right) {
|
|
244
|
+
const l = Object.entries(left ?? {}).sort(([a], [b]) => a < b ? -1 : 1);
|
|
245
|
+
const r = Object.entries(right ?? {}).sort(([a], [b]) => a < b ? -1 : 1);
|
|
246
|
+
if (l.length !== r.length) return false;
|
|
247
|
+
return l.every(([k, v], i) => r[i][0] === k && r[i][1] === v);
|
|
248
|
+
}
|
|
249
|
+
function quotesEqual(left, right) {
|
|
250
|
+
return left === right || left !== void 0 && right !== void 0 && left.currency === right.currency && left.amountNanos === right.amountNanos;
|
|
251
|
+
}
|
|
252
|
+
function validateMeasurement(input) {
|
|
253
|
+
if (!input || typeof input !== "object") {
|
|
254
|
+
throw invalidKey("report() requires a { meter, values } object");
|
|
255
|
+
}
|
|
256
|
+
const meter = assertKey(input.meter, "meter");
|
|
257
|
+
const values = assertValues(input.values);
|
|
258
|
+
const dims = input.dims === void 0 ? void 0 : assertDims(input.dims);
|
|
259
|
+
return {
|
|
260
|
+
meter,
|
|
261
|
+
values,
|
|
262
|
+
...dims && Object.keys(dims).length > 0 ? { dims } : {}
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
function validateQuote(quote) {
|
|
266
|
+
if (!quote || typeof quote !== "object" || Array.isArray(quote)) {
|
|
267
|
+
throw invalidQuote(
|
|
268
|
+
"quote must be an object of the form { currency, amountNanos }"
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
const { currency, amountNanos } = quote;
|
|
272
|
+
if (typeof currency !== "string" || !CURRENCY_RE.test(currency)) {
|
|
273
|
+
throw invalidQuote("quote.currency must be a 3-letter currency code");
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
currency: currency.toLowerCase(),
|
|
277
|
+
amountNanos: assertAmountNanos(amountNanos)
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
function assertAmountNanos(value) {
|
|
281
|
+
if (typeof value === "bigint") {
|
|
282
|
+
if (value < 0n) throw negativeAmountNanos();
|
|
283
|
+
return value.toString();
|
|
284
|
+
}
|
|
285
|
+
if (typeof value === "number") {
|
|
286
|
+
if (!Number.isSafeInteger(value)) {
|
|
287
|
+
throw invalidQuote(
|
|
288
|
+
"quote.amountNanos must be a safe integer number of nanodollars (pass a string or bigint for larger amounts)"
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
if (value < 0) throw negativeAmountNanos();
|
|
292
|
+
return String(value);
|
|
293
|
+
}
|
|
294
|
+
if (typeof value === "string") {
|
|
295
|
+
if (/^-/.test(value)) throw negativeAmountNanos();
|
|
296
|
+
if (DECIMAL_INTEGER_RE.test(value)) return value;
|
|
297
|
+
}
|
|
298
|
+
throw invalidQuote(
|
|
299
|
+
"quote.amountNanos must be a non-negative integer number of nanodollars"
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
function negativeAmountNanos() {
|
|
303
|
+
return invalidQuote(
|
|
304
|
+
"quote.amountNanos must be non-negative: a quote is a proposed rate, never a credit \u2014 refunds are platform operations"
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
function assertValues(values) {
|
|
308
|
+
if (!values || typeof values !== "object" || Array.isArray(values)) {
|
|
309
|
+
throw invalidKey("report() requires a values object");
|
|
310
|
+
}
|
|
311
|
+
const entries = Object.entries(values).sort(
|
|
312
|
+
([a], [b]) => a < b ? -1 : a > b ? 1 : 0
|
|
313
|
+
);
|
|
314
|
+
if (entries.length === 0) {
|
|
315
|
+
throw invalidKey("report() requires at least one measure in values");
|
|
316
|
+
}
|
|
317
|
+
const out = {};
|
|
318
|
+
for (const [measure, value] of entries) {
|
|
319
|
+
assertKey(measure, "measure");
|
|
320
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
|
321
|
+
throw new MeteringError(
|
|
322
|
+
RESPONSE_METERING_ERROR_CODES.invalidMeterValue,
|
|
323
|
+
`values.${measure} must be a non-negative safe integer`
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
out[measure] = value;
|
|
327
|
+
}
|
|
328
|
+
return out;
|
|
329
|
+
}
|
|
330
|
+
function assertDims(dims) {
|
|
331
|
+
if (!dims || typeof dims !== "object" || Array.isArray(dims)) {
|
|
332
|
+
throw invalidKey("report() dims must be an object of dimension selectors");
|
|
333
|
+
}
|
|
334
|
+
const entries = Object.entries(dims).sort(
|
|
335
|
+
([a], [b]) => a < b ? -1 : a > b ? 1 : 0
|
|
336
|
+
);
|
|
337
|
+
const out = {};
|
|
338
|
+
for (const [dimension, value] of entries) {
|
|
339
|
+
assertKey(dimension, "dimension");
|
|
340
|
+
if (typeof value !== "string" || !DIMENSION_VALUE_RE.test(value)) {
|
|
341
|
+
throw invalidKey(
|
|
342
|
+
`dims.${dimension} must be a 1-128 character selector value`
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
out[dimension] = value;
|
|
346
|
+
}
|
|
347
|
+
return out;
|
|
348
|
+
}
|
|
349
|
+
function assertKey(value, label) {
|
|
350
|
+
if (typeof value !== "string" || !KEY_RE.test(value)) {
|
|
351
|
+
throw invalidKey(
|
|
352
|
+
`${label} key ${JSON.stringify(value)} must be 1-64 lowercase alphanumeric characters or underscores`
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
return value;
|
|
356
|
+
}
|
|
357
|
+
function invalidKey(message) {
|
|
358
|
+
return new MeteringError(
|
|
359
|
+
RESPONSE_METERING_ERROR_CODES.invalidMeterKey,
|
|
360
|
+
message
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
function invalidQuote(message) {
|
|
364
|
+
return new MeteringError(RESPONSE_METERING_ERROR_CODES.invalidQuote, message);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// src/core/deadline.ts
|
|
368
|
+
var DEADLINE_MS = {
|
|
369
|
+
/** Boot-blocking; generous because it runs once and gates startup. */
|
|
370
|
+
bootstrap: 1e4,
|
|
371
|
+
/** On the inbound verification path — must not hold a request open. */
|
|
372
|
+
jwks: 5e3,
|
|
373
|
+
/** Background economic report, retried by the caller. */
|
|
374
|
+
metering: 1e4,
|
|
375
|
+
/** Background attested usage callback. */
|
|
376
|
+
postStreamUsage: 1e4,
|
|
377
|
+
/** Best-effort heartbeat; never blocks anything. */
|
|
378
|
+
health: 5e3,
|
|
379
|
+
/** Boot-time route drift report; fail-open at the caller. */
|
|
380
|
+
report: 1e4
|
|
381
|
+
};
|
|
382
|
+
var DeadlineExceededError = class extends Error {
|
|
383
|
+
operation;
|
|
384
|
+
constructor(operation, timeoutMs) {
|
|
385
|
+
super(`${operation} exceeded its ${timeoutMs}ms deadline`);
|
|
386
|
+
this.name = "TimeoutError";
|
|
387
|
+
this.operation = operation;
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
async function fetchWithDeadline(fetchImpl, input, init, operation, options = {}) {
|
|
391
|
+
const timeoutMs = options.timeoutMs ?? DEADLINE_MS[operation];
|
|
392
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
393
|
+
const signal = options.callerSignal ? AbortSignal.any([options.callerSignal, timeout]) : timeout;
|
|
394
|
+
try {
|
|
395
|
+
return await fetchImpl(input, { ...init, signal });
|
|
396
|
+
} catch (cause) {
|
|
397
|
+
if (options.callerSignal?.aborted) throw cause;
|
|
398
|
+
if (timeout.aborted) throw new DeadlineExceededError(operation, timeoutMs);
|
|
399
|
+
throw cause;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// src/core/post-stream-usage.ts
|
|
404
|
+
var METER_KEY_RE = /^[a-z0-9_]{1,64}$/;
|
|
405
|
+
var PostStreamUsageClient = class {
|
|
406
|
+
config;
|
|
407
|
+
endpoint;
|
|
408
|
+
fetchImpl;
|
|
409
|
+
newNonce;
|
|
410
|
+
logger;
|
|
411
|
+
sleep;
|
|
412
|
+
retryDelaysMs;
|
|
413
|
+
maxRetryDelayMs;
|
|
414
|
+
constructor(options) {
|
|
415
|
+
this.config = options.config;
|
|
416
|
+
this.endpoint = resolveEndpoint(options.config.endpoint, options.coreUrl);
|
|
417
|
+
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
418
|
+
this.newNonce = options.newNonce ?? (() => crypto.randomUUID());
|
|
419
|
+
this.logger = options.logger ?? ((message) => console.warn(message));
|
|
420
|
+
this.sleep = options.sleep ?? sleep;
|
|
421
|
+
this.retryDelaysMs = options.retryDelaysMs ?? [100, 250, 500];
|
|
422
|
+
this.maxRetryDelayMs = options.maxRetryDelayMs ?? 1e4;
|
|
423
|
+
}
|
|
424
|
+
async reportUsage(input) {
|
|
425
|
+
try {
|
|
426
|
+
if (!this.config.enabled) throw new Error("metering is not enabled");
|
|
427
|
+
if (!input.requestId) throw new Error("requestId is required");
|
|
428
|
+
if (!input.subscriptionId) throw new Error("subscriptionId is required");
|
|
429
|
+
const unsigned = {
|
|
430
|
+
requestId: input.requestId,
|
|
431
|
+
subscriptionId: input.subscriptionId,
|
|
432
|
+
nonce: this.newNonce(),
|
|
433
|
+
// The token's `allowedMeters` scope is enforced on BOTH lanes
|
|
434
|
+
// independently (P0-1): `meters` is the flat METER-keyed projection
|
|
435
|
+
// (the billed lane), so its keys must be in scope regardless of
|
|
436
|
+
// whether the measurement lane is also present; `measurements[].meter`
|
|
437
|
+
// is scoped in validateMeasurements below.
|
|
438
|
+
meters: validateAndSortUsage(input.meters, "meters", this.config, true),
|
|
439
|
+
...input.creditUnitsConsumed ? {
|
|
440
|
+
creditUnitsConsumed: validateAndSortUsage(
|
|
441
|
+
input.creditUnitsConsumed,
|
|
442
|
+
"creditUnitsConsumed",
|
|
443
|
+
this.config,
|
|
444
|
+
false
|
|
445
|
+
)
|
|
446
|
+
} : {},
|
|
447
|
+
...input.measureContext ? { measureContext: input.measureContext } : {},
|
|
448
|
+
// Key ORDER is load-bearing: core recomputes the HMAC over
|
|
449
|
+
// JSON.stringify(unsigned) rebuilt in its zod schema's field order, so
|
|
450
|
+
// these additive fields must sit in the same position on both sides.
|
|
451
|
+
...input.measurementsVersion !== void 0 ? { measurementsVersion: input.measurementsVersion } : {},
|
|
452
|
+
...input.measurements ? { measurements: this.validateMeasurements(input.measurements) } : {},
|
|
453
|
+
...input.quote ? { quote: input.quote } : {}
|
|
454
|
+
};
|
|
455
|
+
const signature = await signPayload(
|
|
456
|
+
JSON.stringify(unsigned),
|
|
457
|
+
this.config.credential
|
|
458
|
+
);
|
|
459
|
+
const event = { ...unsigned, signature };
|
|
460
|
+
const body = JSON.stringify(event);
|
|
461
|
+
const headerSignature = await signPayload(body, this.config.credential);
|
|
462
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
463
|
+
let response;
|
|
464
|
+
try {
|
|
465
|
+
response = await fetchWithDeadline(
|
|
466
|
+
this.fetchImpl,
|
|
467
|
+
this.endpoint,
|
|
468
|
+
{
|
|
469
|
+
method: "POST",
|
|
470
|
+
headers: {
|
|
471
|
+
authorization: `Bearer ${this.config.credential}`,
|
|
472
|
+
"content-type": "application/json",
|
|
473
|
+
accept: "application/json",
|
|
474
|
+
[RUNTIME_RESPONSE_METERING_CONTRACT.headers.signature]: headerSignature
|
|
475
|
+
},
|
|
476
|
+
body
|
|
477
|
+
},
|
|
478
|
+
"postStreamUsage"
|
|
479
|
+
);
|
|
480
|
+
} catch (cause) {
|
|
481
|
+
const delayMs2 = this.retryDelayForAttempt(attempt, null);
|
|
482
|
+
if (delayMs2 === null) throw cause;
|
|
483
|
+
await this.sleep(delayMs2);
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
if (response.ok) return { ok: true };
|
|
487
|
+
const requestNotFound = await isPostStreamRequestNotFound(response);
|
|
488
|
+
const retryable = requestNotFound || isRetryableStatus(response.status);
|
|
489
|
+
const delayMs = this.retryDelayForAttempt(
|
|
490
|
+
attempt,
|
|
491
|
+
retryAfterMs(response.headers)
|
|
492
|
+
);
|
|
493
|
+
if (!retryable || delayMs === null) {
|
|
494
|
+
throw new Error(`metering endpoint returned ${response.status}`);
|
|
495
|
+
}
|
|
496
|
+
await this.sleep(delayMs);
|
|
497
|
+
}
|
|
498
|
+
} catch (error) {
|
|
499
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
500
|
+
this.logger(`post-stream usage report skipped: ${reason}`);
|
|
501
|
+
return { ok: false, reason };
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
/** Enforce the token's meter scope + per-event bounds on the measurement lane. */
|
|
505
|
+
validateMeasurements(measurements) {
|
|
506
|
+
const allowed = this.config.allowedMeters;
|
|
507
|
+
for (const measurement of measurements) {
|
|
508
|
+
if (allowed.length > 0 && !allowed.includes(measurement.meter)) {
|
|
509
|
+
throw new Error(
|
|
510
|
+
`meter '${measurement.meter}' is not in the token's allowedMeters`
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
for (const [measure, value] of Object.entries(measurement.values)) {
|
|
514
|
+
if (this.config.perEventMax > 0 && value > this.config.perEventMax) {
|
|
515
|
+
throw new Error(
|
|
516
|
+
`measure '${measure}' value ${value} exceeds the per-event max ${this.config.perEventMax}`
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return measurements;
|
|
522
|
+
}
|
|
523
|
+
retryDelayForAttempt(attempt, retryAfterMs2) {
|
|
524
|
+
const fallback = this.retryDelaysMs[attempt];
|
|
525
|
+
if (fallback === void 0) return null;
|
|
526
|
+
if (retryAfterMs2 === null) return fallback;
|
|
527
|
+
return Math.min(retryAfterMs2, this.maxRetryDelayMs);
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
async function isPostStreamRequestNotFound(response) {
|
|
531
|
+
if (response.status !== 422) return false;
|
|
532
|
+
try {
|
|
533
|
+
const body = await response.json();
|
|
534
|
+
return body.error?.code === "post_stream_request_not_found";
|
|
535
|
+
} catch {
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
function isRetryableStatus(status) {
|
|
540
|
+
return status === 429 || status >= 500 && status <= 599;
|
|
541
|
+
}
|
|
542
|
+
function retryAfterMs(headers) {
|
|
543
|
+
const raw = headers.get("retry-after");
|
|
544
|
+
if (!raw) return null;
|
|
545
|
+
const seconds = Number(raw);
|
|
546
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
|
|
547
|
+
const dateMs = Date.parse(raw);
|
|
548
|
+
if (!Number.isFinite(dateMs)) return null;
|
|
549
|
+
return Math.max(0, dateMs - Date.now());
|
|
550
|
+
}
|
|
551
|
+
function sleep(delayMs) {
|
|
552
|
+
return new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
553
|
+
}
|
|
554
|
+
function validateAndSortUsage(usage, label, config, enforceMeterScope) {
|
|
555
|
+
const entries = Object.entries(usage).sort(
|
|
556
|
+
([a], [b]) => a < b ? -1 : a > b ? 1 : 0
|
|
557
|
+
);
|
|
558
|
+
for (const [meter, qty] of entries) {
|
|
559
|
+
if (!METER_KEY_RE.test(meter)) {
|
|
560
|
+
throw new Error(
|
|
561
|
+
`${label} key '${meter}' must be lowercase alphanumeric with underscores`
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
if (!Number.isFinite(qty) || qty < 0) {
|
|
565
|
+
throw new Error(`${label}.${meter} must be a non-negative finite number`);
|
|
566
|
+
}
|
|
567
|
+
if (enforceMeterScope && config.allowedMeters.length > 0 && !config.allowedMeters.includes(meter)) {
|
|
568
|
+
throw new Error(`meter '${meter}' is not in the token's allowedMeters`);
|
|
569
|
+
}
|
|
570
|
+
if (enforceMeterScope && config.perEventMax > 0 && qty > config.perEventMax) {
|
|
571
|
+
throw new Error(
|
|
572
|
+
`meter '${meter}' qty ${qty} exceeds the per-event max ${config.perEventMax}`
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return Object.fromEntries(entries);
|
|
577
|
+
}
|
|
578
|
+
function resolveEndpoint(endpoint, coreUrl) {
|
|
579
|
+
if (/^https?:\/\//.test(endpoint)) return endpoint;
|
|
580
|
+
if (!coreUrl) return endpoint;
|
|
581
|
+
return `${coreUrl.replace(/\/+$/, "")}${endpoint}`;
|
|
582
|
+
}
|
|
583
|
+
export {
|
|
584
|
+
PostStreamUsageClient,
|
|
585
|
+
createReportFn,
|
|
586
|
+
rawDimsUnitsOf
|
|
587
|
+
};
|