@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
package/dist/testing/index.js
CHANGED
|
@@ -194,20 +194,6 @@ var init_reconcile = __esm({
|
|
|
194
194
|
import { generateKeyPairSync, randomBytes } from "node:crypto";
|
|
195
195
|
|
|
196
196
|
// src/generated/runtime-contract.ts
|
|
197
|
-
var RUNTIME_BODY_HASH_CONTRACT = {
|
|
198
|
-
algorithm: "SHA-256",
|
|
199
|
-
encoding: "hex-lower",
|
|
200
|
-
source: "raw-request-bytes",
|
|
201
|
-
emptyBodyHash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
202
|
-
maxBodyBytes: 10485760,
|
|
203
|
-
streamingExemptToken: "STREAM",
|
|
204
|
-
streamingExemptContentTypes: [
|
|
205
|
-
"text/event-stream",
|
|
206
|
-
"application/octet-stream",
|
|
207
|
-
"multipart/form-data"
|
|
208
|
-
],
|
|
209
|
-
overMaxStatus: 413
|
|
210
|
-
};
|
|
211
197
|
var RUNTIME_ERROR_CODES = {
|
|
212
198
|
missingSignature: "missing_signature",
|
|
213
199
|
malformedSignature: "malformed_signature",
|
|
@@ -228,6 +214,20 @@ var RUNTIME_ERROR_CODES = {
|
|
|
228
214
|
serviceSubjectRequired: "service_subject_required",
|
|
229
215
|
surfaceNotAllowed: "surface_not_allowed"
|
|
230
216
|
};
|
|
217
|
+
var RUNTIME_BODY_HASH_CONTRACT = {
|
|
218
|
+
algorithm: "SHA-256",
|
|
219
|
+
encoding: "hex-lower",
|
|
220
|
+
source: "raw-request-bytes",
|
|
221
|
+
emptyBodyHash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
222
|
+
maxBodyBytes: 10485760,
|
|
223
|
+
streamingExemptToken: "STREAM",
|
|
224
|
+
streamingExemptContentTypes: [
|
|
225
|
+
"text/event-stream",
|
|
226
|
+
"application/octet-stream",
|
|
227
|
+
"multipart/form-data"
|
|
228
|
+
],
|
|
229
|
+
overMaxStatus: 413
|
|
230
|
+
};
|
|
231
231
|
var RUNTIME_RESPONSE_METERING_CONTRACT = {
|
|
232
232
|
headers: {
|
|
233
233
|
payload: "x-fs-metering",
|
|
@@ -248,14 +248,18 @@ var RUNTIME_RESPONSE_METERING_CONTRACT = {
|
|
|
248
248
|
payload: {
|
|
249
249
|
method: "string",
|
|
250
250
|
path: "string",
|
|
251
|
-
rawDimsUnits: "Record<string, number
|
|
251
|
+
rawDimsUnits: "Record<string, number>?",
|
|
252
252
|
measureContext: "Record<string, unknown>?",
|
|
253
|
-
creditUnitsConsumed: "Record<string, number>?"
|
|
253
|
+
creditUnitsConsumed: "Record<string, number>?",
|
|
254
|
+
measurementsVersion: "1?",
|
|
255
|
+
measurements: "Array<{ meter: string; values: Record<string, number>; dims?: Record<string, string> }>?",
|
|
256
|
+
quote: "{ currency: string; amountNanos: string }?"
|
|
254
257
|
},
|
|
255
258
|
errors: {
|
|
256
259
|
missingToken: "missing_token",
|
|
257
260
|
invalidMeterKey: "invalid_meter_key",
|
|
258
|
-
invalidMeterValue: "invalid_meter_value"
|
|
261
|
+
invalidMeterValue: "invalid_meter_value",
|
|
262
|
+
invalidQuote: "invalid_quote"
|
|
259
263
|
},
|
|
260
264
|
httpAdapter: {
|
|
261
265
|
input: "Request",
|
|
@@ -1281,185 +1285,6 @@ async function reportHealth(options) {
|
|
|
1281
1285
|
}
|
|
1282
1286
|
}
|
|
1283
1287
|
|
|
1284
|
-
// src/core/backoff.ts
|
|
1285
|
-
function computeBackoff(attempt, options) {
|
|
1286
|
-
const { baseMs, maxMs, jitter = "equal", random = Math.random } = options;
|
|
1287
|
-
const exponent = Math.max(0, attempt - 1);
|
|
1288
|
-
const cap = Math.min(baseMs * 2 ** exponent, maxMs);
|
|
1289
|
-
switch (jitter) {
|
|
1290
|
-
case "none":
|
|
1291
|
-
return cap;
|
|
1292
|
-
case "full":
|
|
1293
|
-
return random() * cap;
|
|
1294
|
-
case "equal":
|
|
1295
|
-
default:
|
|
1296
|
-
return cap / 2 + random() * (cap / 2);
|
|
1297
|
-
}
|
|
1298
|
-
}
|
|
1299
|
-
|
|
1300
|
-
// src/core/metering.ts
|
|
1301
|
-
var METER_KEY_RE = /^[a-z0-9_]{1,64}$/;
|
|
1302
|
-
var DEFAULT_BASE_DELAY_MS = 200;
|
|
1303
|
-
var DEFAULT_MAX_DELAY_MS = 1e4;
|
|
1304
|
-
function isTransientStatus(status) {
|
|
1305
|
-
return status === 429 || status >= 500;
|
|
1306
|
-
}
|
|
1307
|
-
function retryAfterMs(headers) {
|
|
1308
|
-
const raw = headers.get("retry-after");
|
|
1309
|
-
if (raw === null) return null;
|
|
1310
|
-
const trimmed = raw.trim();
|
|
1311
|
-
if (!/^\d+$/.test(trimmed)) return null;
|
|
1312
|
-
const secs = Number(trimmed);
|
|
1313
|
-
return Number.isFinite(secs) ? secs * 1e3 : null;
|
|
1314
|
-
}
|
|
1315
|
-
var DEFAULT_MAX_RETRIES = 3;
|
|
1316
|
-
var MeteringClient = class {
|
|
1317
|
-
config;
|
|
1318
|
-
endpoint;
|
|
1319
|
-
businessId;
|
|
1320
|
-
backendId;
|
|
1321
|
-
fetchImpl;
|
|
1322
|
-
maxRetries;
|
|
1323
|
-
baseDelayMs;
|
|
1324
|
-
maxDelayMs;
|
|
1325
|
-
sleep;
|
|
1326
|
-
random;
|
|
1327
|
-
newId;
|
|
1328
|
-
now;
|
|
1329
|
-
buffer = [];
|
|
1330
|
-
constructor(options) {
|
|
1331
|
-
this.config = options.config;
|
|
1332
|
-
this.endpoint = resolveEndpoint(options.config.endpoint, options.coreUrl);
|
|
1333
|
-
this.businessId = options.businessId;
|
|
1334
|
-
this.backendId = options.backendId;
|
|
1335
|
-
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
1336
|
-
this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
1337
|
-
this.baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
|
|
1338
|
-
this.maxDelayMs = options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
|
|
1339
|
-
this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
1340
|
-
this.random = options.random ?? Math.random;
|
|
1341
|
-
this.newId = options.newId ?? (() => crypto.randomUUID());
|
|
1342
|
-
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
1343
|
-
}
|
|
1344
|
-
/**
|
|
1345
|
-
* Record `qty` of `meter`. Enforces meter-key shape, non-negative finite qty,
|
|
1346
|
-
* the bootstrap allowedMeters/allowedRoutes scope, and the per-event sanity
|
|
1347
|
-
* max, then enqueues and flushes (best-effort; failures stay buffered).
|
|
1348
|
-
*/
|
|
1349
|
-
async meter(meter, qty, options = {}) {
|
|
1350
|
-
if (!this.config.enabled) {
|
|
1351
|
-
throw new FartherShoreError(
|
|
1352
|
-
"invalid_token",
|
|
1353
|
-
"metering is not enabled for this runtime token"
|
|
1354
|
-
);
|
|
1355
|
-
}
|
|
1356
|
-
if (!METER_KEY_RE.test(meter)) {
|
|
1357
|
-
throw new FartherShoreError(
|
|
1358
|
-
"invalid_token",
|
|
1359
|
-
`meter key '${meter}' must be lowercase alphanumeric with underscores`
|
|
1360
|
-
);
|
|
1361
|
-
}
|
|
1362
|
-
if (!Number.isFinite(qty) || qty < 0) {
|
|
1363
|
-
throw new FartherShoreError(
|
|
1364
|
-
"invalid_token",
|
|
1365
|
-
`meter '${meter}' qty must be a non-negative finite number`
|
|
1366
|
-
);
|
|
1367
|
-
}
|
|
1368
|
-
if (this.config.allowedMeters.length > 0 && !this.config.allowedMeters.includes(meter)) {
|
|
1369
|
-
throw new FartherShoreError(
|
|
1370
|
-
"invalid_token",
|
|
1371
|
-
`meter '${meter}' is not in the token's allowedMeters`
|
|
1372
|
-
);
|
|
1373
|
-
}
|
|
1374
|
-
if (this.config.allowedRoutes.length > 0) {
|
|
1375
|
-
if (!options.routeId) {
|
|
1376
|
-
throw new FartherShoreError(
|
|
1377
|
-
"invalid_token",
|
|
1378
|
-
"routeId is required because this runtime token is route-scoped"
|
|
1379
|
-
);
|
|
1380
|
-
}
|
|
1381
|
-
if (!this.config.allowedRoutes.includes(options.routeId)) {
|
|
1382
|
-
throw new FartherShoreError(
|
|
1383
|
-
"invalid_token",
|
|
1384
|
-
`route '${options.routeId}' is not in the token's allowedRoutes`
|
|
1385
|
-
);
|
|
1386
|
-
}
|
|
1387
|
-
}
|
|
1388
|
-
if (this.config.perEventMax > 0 && qty > this.config.perEventMax) {
|
|
1389
|
-
throw new FartherShoreError(
|
|
1390
|
-
"invalid_token",
|
|
1391
|
-
`meter '${meter}' qty ${qty} exceeds the per-event max ${this.config.perEventMax}`
|
|
1392
|
-
);
|
|
1393
|
-
}
|
|
1394
|
-
const event = {
|
|
1395
|
-
event_id: options.eventId ?? this.newId(),
|
|
1396
|
-
business_id: this.businessId,
|
|
1397
|
-
backend_id: this.backendId,
|
|
1398
|
-
meter,
|
|
1399
|
-
qty,
|
|
1400
|
-
timestamp: options.timestamp ?? this.now().toISOString(),
|
|
1401
|
-
...options.routeId ? { route_id: options.routeId } : {},
|
|
1402
|
-
...options.requestId ? { request_id: options.requestId } : {},
|
|
1403
|
-
...options.subscriptionId ? { subscription_id: options.subscriptionId } : {}
|
|
1404
|
-
};
|
|
1405
|
-
this.buffer.push(event);
|
|
1406
|
-
await this.flush();
|
|
1407
|
-
}
|
|
1408
|
-
/** Drain the buffer. Events that fail all retries stay buffered (at-least-once). */
|
|
1409
|
-
async flush() {
|
|
1410
|
-
const pending = this.buffer.splice(0, this.buffer.length);
|
|
1411
|
-
const stillPending = [];
|
|
1412
|
-
for (const event of pending) {
|
|
1413
|
-
const sent = await this.sendWithRetry(event);
|
|
1414
|
-
if (!sent) stillPending.push(event);
|
|
1415
|
-
}
|
|
1416
|
-
if (stillPending.length > 0) this.buffer.unshift(...stillPending);
|
|
1417
|
-
}
|
|
1418
|
-
/** Buffered-but-unsent count (observability/tests). */
|
|
1419
|
-
get pending() {
|
|
1420
|
-
return this.buffer.length;
|
|
1421
|
-
}
|
|
1422
|
-
async sendWithRetry(event) {
|
|
1423
|
-
for (let attempt = 0; attempt < this.maxRetries; attempt += 1) {
|
|
1424
|
-
let retryAfter = null;
|
|
1425
|
-
try {
|
|
1426
|
-
const response = await fetchWithDeadline(
|
|
1427
|
-
this.fetchImpl,
|
|
1428
|
-
this.endpoint,
|
|
1429
|
-
{
|
|
1430
|
-
method: "POST",
|
|
1431
|
-
headers: {
|
|
1432
|
-
authorization: `Bearer ${this.config.credential}`,
|
|
1433
|
-
"content-type": "application/json",
|
|
1434
|
-
accept: "application/json"
|
|
1435
|
-
},
|
|
1436
|
-
body: JSON.stringify(event)
|
|
1437
|
-
},
|
|
1438
|
-
"metering"
|
|
1439
|
-
);
|
|
1440
|
-
if (response.ok) return true;
|
|
1441
|
-
if (!isTransientStatus(response.status)) return false;
|
|
1442
|
-
retryAfter = retryAfterMs(response.headers);
|
|
1443
|
-
} catch {
|
|
1444
|
-
}
|
|
1445
|
-
const isLast = attempt === this.maxRetries - 1;
|
|
1446
|
-
if (isLast) break;
|
|
1447
|
-
const delay = retryAfter !== null ? Math.min(retryAfter, this.maxDelayMs) : computeBackoff(attempt + 1, {
|
|
1448
|
-
baseMs: this.baseDelayMs,
|
|
1449
|
-
maxMs: this.maxDelayMs,
|
|
1450
|
-
random: this.random
|
|
1451
|
-
});
|
|
1452
|
-
await this.sleep(delay);
|
|
1453
|
-
}
|
|
1454
|
-
return false;
|
|
1455
|
-
}
|
|
1456
|
-
};
|
|
1457
|
-
function resolveEndpoint(endpoint, coreUrl) {
|
|
1458
|
-
if (/^https?:\/\//.test(endpoint)) return endpoint;
|
|
1459
|
-
if (!coreUrl) return endpoint;
|
|
1460
|
-
return `${coreUrl.replace(/\/+$/, "")}${endpoint}`;
|
|
1461
|
-
}
|
|
1462
|
-
|
|
1463
1288
|
// src/response-metering.ts
|
|
1464
1289
|
var RESPONSE_METERING_HEADERS = RUNTIME_RESPONSE_METERING_CONTRACT.headers;
|
|
1465
1290
|
var RESPONSE_METERING_ERROR_CODES = RUNTIME_RESPONSE_METERING_CONTRACT.errors;
|
|
@@ -1471,6 +1296,49 @@ var METERING_PAYLOAD_HEADER = RESPONSE_METERING_HEADERS.payload;
|
|
|
1471
1296
|
var METERING_SIGNATURE_HEADER = RESPONSE_METERING_HEADERS.signature;
|
|
1472
1297
|
var METERING_TOKEN_HEADER = RESPONSE_METERING_HEADERS.token;
|
|
1473
1298
|
var DEFAULT_TOKEN_ENV = RUNTIME_RESPONSE_METERING_CONTRACT.token.environmentVariable;
|
|
1299
|
+
var MeteringError = class extends Error {
|
|
1300
|
+
code;
|
|
1301
|
+
constructor(code, message) {
|
|
1302
|
+
super(message);
|
|
1303
|
+
this.name = "MeteringError";
|
|
1304
|
+
this.code = code;
|
|
1305
|
+
}
|
|
1306
|
+
};
|
|
1307
|
+
async function computeMeteringHeaders(payload, options = {}) {
|
|
1308
|
+
try {
|
|
1309
|
+
const token = resolveTokenSoft(options);
|
|
1310
|
+
if (!token) {
|
|
1311
|
+
skip(`${DEFAULT_TOKEN_ENV} is not set`, options);
|
|
1312
|
+
return {};
|
|
1313
|
+
}
|
|
1314
|
+
const json2 = JSON.stringify(payload);
|
|
1315
|
+
const signature = await signPayload(json2, token);
|
|
1316
|
+
devMeteringHooks?.record?.(payload, options.requestId);
|
|
1317
|
+
return {
|
|
1318
|
+
[METERING_PAYLOAD_HEADER]: json2,
|
|
1319
|
+
[METERING_SIGNATURE_HEADER]: signature,
|
|
1320
|
+
[METERING_TOKEN_HEADER]: token
|
|
1321
|
+
};
|
|
1322
|
+
} catch (error) {
|
|
1323
|
+
skip(error instanceof Error ? error.message : String(error), options);
|
|
1324
|
+
return {};
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
function skip(reason, options) {
|
|
1328
|
+
if (options.onSkip) {
|
|
1329
|
+
options.onSkip(reason);
|
|
1330
|
+
} else {
|
|
1331
|
+
console.warn(`metering headers skipped: ${reason}`);
|
|
1332
|
+
}
|
|
1333
|
+
devMeteringHooks?.onSkip?.(reason, options.requestId);
|
|
1334
|
+
}
|
|
1335
|
+
function resolveTokenSoft(options) {
|
|
1336
|
+
return options.token ?? options.env?.[DEFAULT_TOKEN_ENV] ?? processEnv(DEFAULT_TOKEN_ENV) ?? devMeteringHooks?.fallbackToken?.();
|
|
1337
|
+
}
|
|
1338
|
+
function processEnv(key2) {
|
|
1339
|
+
const maybeProcess = globalThis.process;
|
|
1340
|
+
return maybeProcess?.env?.[key2];
|
|
1341
|
+
}
|
|
1474
1342
|
async function signPayload(payload, token) {
|
|
1475
1343
|
const key2 = await crypto.subtle.importKey(
|
|
1476
1344
|
"raw",
|
|
@@ -1495,7 +1363,7 @@ function base64url(bytes) {
|
|
|
1495
1363
|
}
|
|
1496
1364
|
|
|
1497
1365
|
// src/core/post-stream-usage.ts
|
|
1498
|
-
var
|
|
1366
|
+
var METER_KEY_RE = /^[a-z0-9_]{1,64}$/;
|
|
1499
1367
|
var PostStreamUsageClient = class {
|
|
1500
1368
|
config;
|
|
1501
1369
|
endpoint;
|
|
@@ -1507,7 +1375,7 @@ var PostStreamUsageClient = class {
|
|
|
1507
1375
|
maxRetryDelayMs;
|
|
1508
1376
|
constructor(options) {
|
|
1509
1377
|
this.config = options.config;
|
|
1510
|
-
this.endpoint =
|
|
1378
|
+
this.endpoint = resolveEndpoint(options.config.endpoint, options.coreUrl);
|
|
1511
1379
|
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
1512
1380
|
this.newNonce = options.newNonce ?? (() => crypto.randomUUID());
|
|
1513
1381
|
this.logger = options.logger ?? ((message) => console.warn(message));
|
|
@@ -1524,6 +1392,11 @@ var PostStreamUsageClient = class {
|
|
|
1524
1392
|
requestId: input.requestId,
|
|
1525
1393
|
subscriptionId: input.subscriptionId,
|
|
1526
1394
|
nonce: this.newNonce(),
|
|
1395
|
+
// The token's `allowedMeters` scope is enforced on BOTH lanes
|
|
1396
|
+
// independently (P0-1): `meters` is the flat METER-keyed projection
|
|
1397
|
+
// (the billed lane), so its keys must be in scope regardless of
|
|
1398
|
+
// whether the measurement lane is also present; `measurements[].meter`
|
|
1399
|
+
// is scoped in validateMeasurements below.
|
|
1527
1400
|
meters: validateAndSortUsage(input.meters, "meters", this.config, true),
|
|
1528
1401
|
...input.creditUnitsConsumed ? {
|
|
1529
1402
|
creditUnitsConsumed: validateAndSortUsage(
|
|
@@ -1533,7 +1406,13 @@ var PostStreamUsageClient = class {
|
|
|
1533
1406
|
false
|
|
1534
1407
|
)
|
|
1535
1408
|
} : {},
|
|
1536
|
-
...input.measureContext ? { measureContext: input.measureContext } : {}
|
|
1409
|
+
...input.measureContext ? { measureContext: input.measureContext } : {},
|
|
1410
|
+
// Key ORDER is load-bearing: core recomputes the HMAC over
|
|
1411
|
+
// JSON.stringify(unsigned) rebuilt in its zod schema's field order, so
|
|
1412
|
+
// these additive fields must sit in the same position on both sides.
|
|
1413
|
+
...input.measurementsVersion !== void 0 ? { measurementsVersion: input.measurementsVersion } : {},
|
|
1414
|
+
...input.measurements ? { measurements: this.validateMeasurements(input.measurements) } : {},
|
|
1415
|
+
...input.quote ? { quote: input.quote } : {}
|
|
1537
1416
|
};
|
|
1538
1417
|
const signature = await signPayload(
|
|
1539
1418
|
JSON.stringify(unsigned),
|
|
@@ -1541,6 +1420,7 @@ var PostStreamUsageClient = class {
|
|
|
1541
1420
|
);
|
|
1542
1421
|
const event = { ...unsigned, signature };
|
|
1543
1422
|
const body = JSON.stringify(event);
|
|
1423
|
+
const headerSignature = await signPayload(body, this.config.credential);
|
|
1544
1424
|
for (let attempt = 0; ; attempt += 1) {
|
|
1545
1425
|
let response;
|
|
1546
1426
|
try {
|
|
@@ -1552,7 +1432,8 @@ var PostStreamUsageClient = class {
|
|
|
1552
1432
|
headers: {
|
|
1553
1433
|
authorization: `Bearer ${this.config.credential}`,
|
|
1554
1434
|
"content-type": "application/json",
|
|
1555
|
-
accept: "application/json"
|
|
1435
|
+
accept: "application/json",
|
|
1436
|
+
[RUNTIME_RESPONSE_METERING_CONTRACT.headers.signature]: headerSignature
|
|
1556
1437
|
},
|
|
1557
1438
|
body
|
|
1558
1439
|
},
|
|
@@ -1569,7 +1450,7 @@ var PostStreamUsageClient = class {
|
|
|
1569
1450
|
const retryable = requestNotFound || isRetryableStatus(response.status);
|
|
1570
1451
|
const delayMs = this.retryDelayForAttempt(
|
|
1571
1452
|
attempt,
|
|
1572
|
-
|
|
1453
|
+
retryAfterMs(response.headers)
|
|
1573
1454
|
);
|
|
1574
1455
|
if (!retryable || delayMs === null) {
|
|
1575
1456
|
throw new Error(`metering endpoint returned ${response.status}`);
|
|
@@ -1582,11 +1463,30 @@ var PostStreamUsageClient = class {
|
|
|
1582
1463
|
return { ok: false, reason };
|
|
1583
1464
|
}
|
|
1584
1465
|
}
|
|
1585
|
-
|
|
1466
|
+
/** Enforce the token's meter scope + per-event bounds on the measurement lane. */
|
|
1467
|
+
validateMeasurements(measurements) {
|
|
1468
|
+
const allowed = this.config.allowedMeters;
|
|
1469
|
+
for (const measurement of measurements) {
|
|
1470
|
+
if (allowed.length > 0 && !allowed.includes(measurement.meter)) {
|
|
1471
|
+
throw new Error(
|
|
1472
|
+
`meter '${measurement.meter}' is not in the token's allowedMeters`
|
|
1473
|
+
);
|
|
1474
|
+
}
|
|
1475
|
+
for (const [measure, value] of Object.entries(measurement.values)) {
|
|
1476
|
+
if (this.config.perEventMax > 0 && value > this.config.perEventMax) {
|
|
1477
|
+
throw new Error(
|
|
1478
|
+
`measure '${measure}' value ${value} exceeds the per-event max ${this.config.perEventMax}`
|
|
1479
|
+
);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
return measurements;
|
|
1484
|
+
}
|
|
1485
|
+
retryDelayForAttempt(attempt, retryAfterMs2) {
|
|
1586
1486
|
const fallback = this.retryDelaysMs[attempt];
|
|
1587
1487
|
if (fallback === void 0) return null;
|
|
1588
|
-
if (
|
|
1589
|
-
return Math.min(
|
|
1488
|
+
if (retryAfterMs2 === null) return fallback;
|
|
1489
|
+
return Math.min(retryAfterMs2, this.maxRetryDelayMs);
|
|
1590
1490
|
}
|
|
1591
1491
|
};
|
|
1592
1492
|
async function isPostStreamRequestNotFound(response) {
|
|
@@ -1601,7 +1501,7 @@ async function isPostStreamRequestNotFound(response) {
|
|
|
1601
1501
|
function isRetryableStatus(status) {
|
|
1602
1502
|
return status === 429 || status >= 500 && status <= 599;
|
|
1603
1503
|
}
|
|
1604
|
-
function
|
|
1504
|
+
function retryAfterMs(headers) {
|
|
1605
1505
|
const raw = headers.get("retry-after");
|
|
1606
1506
|
if (!raw) return null;
|
|
1607
1507
|
const seconds = Number(raw);
|
|
@@ -1618,7 +1518,7 @@ function validateAndSortUsage(usage, label, config, enforceMeterScope) {
|
|
|
1618
1518
|
([a], [b]) => a < b ? -1 : a > b ? 1 : 0
|
|
1619
1519
|
);
|
|
1620
1520
|
for (const [meter, qty] of entries) {
|
|
1621
|
-
if (!
|
|
1521
|
+
if (!METER_KEY_RE.test(meter)) {
|
|
1622
1522
|
throw new Error(
|
|
1623
1523
|
`${label} key '${meter}' must be lowercase alphanumeric with underscores`
|
|
1624
1524
|
);
|
|
@@ -1637,12 +1537,303 @@ function validateAndSortUsage(usage, label, config, enforceMeterScope) {
|
|
|
1637
1537
|
}
|
|
1638
1538
|
return Object.fromEntries(entries);
|
|
1639
1539
|
}
|
|
1640
|
-
function
|
|
1540
|
+
function resolveEndpoint(endpoint, coreUrl) {
|
|
1641
1541
|
if (/^https?:\/\//.test(endpoint)) return endpoint;
|
|
1642
1542
|
if (!coreUrl) return endpoint;
|
|
1643
1543
|
return `${coreUrl.replace(/\/+$/, "")}${endpoint}`;
|
|
1644
1544
|
}
|
|
1645
1545
|
|
|
1546
|
+
// src/core/report.ts
|
|
1547
|
+
var MEASUREMENTS_VERSION = 1;
|
|
1548
|
+
var KEY_RE = /^[a-z0-9_]{1,64}$/;
|
|
1549
|
+
var DIMENSION_VALUE_RE = /^[\w.:-]{1,128}$/;
|
|
1550
|
+
var CURRENCY_RE = /^[A-Za-z]{3}$/;
|
|
1551
|
+
var DECIMAL_INTEGER_RE = /^\d{1,30}$/;
|
|
1552
|
+
function createReportFn(channels) {
|
|
1553
|
+
let stampedMeasurements = [];
|
|
1554
|
+
let stampedQuote;
|
|
1555
|
+
let inBandTail = Promise.resolve();
|
|
1556
|
+
let postStreamFinalized = false;
|
|
1557
|
+
let pendingPostStreamBatch = null;
|
|
1558
|
+
const deliverPostStream = async (reported, quote) => {
|
|
1559
|
+
if (pendingPostStreamBatch) {
|
|
1560
|
+
if (!quotesEqual(pendingPostStreamBatch.quote, quote)) {
|
|
1561
|
+
return {
|
|
1562
|
+
ok: false,
|
|
1563
|
+
transport: "post_stream",
|
|
1564
|
+
reason: "quote conflicts with this request's pending post-stream batch: one served request carries one quote across all measurements"
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
if (reported.some(
|
|
1568
|
+
(measurement) => !dimsEqual(
|
|
1569
|
+
pendingPostStreamBatch.measurements[0]?.dims,
|
|
1570
|
+
measurement.dims
|
|
1571
|
+
)
|
|
1572
|
+
)) {
|
|
1573
|
+
return {
|
|
1574
|
+
ok: false,
|
|
1575
|
+
transport: "post_stream",
|
|
1576
|
+
reason: "dims conflict with this request's pending post-stream batch: the request receipt rates under ONE dims tuple"
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
pendingPostStreamBatch.measurements.push(...reported);
|
|
1580
|
+
return pendingPostStreamBatch.flush;
|
|
1581
|
+
}
|
|
1582
|
+
if (postStreamFinalized) {
|
|
1583
|
+
return {
|
|
1584
|
+
ok: false,
|
|
1585
|
+
transport: "post_stream",
|
|
1586
|
+
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"
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1589
|
+
if (stampedMeasurements.length > 0) {
|
|
1590
|
+
return {
|
|
1591
|
+
ok: false,
|
|
1592
|
+
transport: "post_stream",
|
|
1593
|
+
reason: "this request already reported in-band; every report on one request must share the stamped aggregate (same quote, before the response is sent)"
|
|
1594
|
+
};
|
|
1595
|
+
}
|
|
1596
|
+
postStreamFinalized = true;
|
|
1597
|
+
const batch = {
|
|
1598
|
+
measurements: [...reported],
|
|
1599
|
+
quote,
|
|
1600
|
+
flush: void 0
|
|
1601
|
+
};
|
|
1602
|
+
batch.flush = new Promise((resolve) => setTimeout(resolve, 0)).then(
|
|
1603
|
+
async () => {
|
|
1604
|
+
pendingPostStreamBatch = null;
|
|
1605
|
+
const result = await channels.postStream({
|
|
1606
|
+
measurements: batch.measurements,
|
|
1607
|
+
...batch.quote ? { quote: batch.quote } : {}
|
|
1608
|
+
});
|
|
1609
|
+
return result.ok ? { ok: true, transport: "post_stream" } : {
|
|
1610
|
+
ok: false,
|
|
1611
|
+
transport: "post_stream",
|
|
1612
|
+
reason: result.reason ?? "post-stream delivery failed"
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
);
|
|
1616
|
+
pendingPostStreamBatch = batch;
|
|
1617
|
+
return batch.flush;
|
|
1618
|
+
};
|
|
1619
|
+
const tryInBand = (reported, quote) => {
|
|
1620
|
+
const run = inBandTail.then(async () => {
|
|
1621
|
+
const sink = channels.responseSink;
|
|
1622
|
+
if (!sink || !channels.request || !sink.canStampHeaders()) return null;
|
|
1623
|
+
if (postStreamFinalized) return null;
|
|
1624
|
+
if (!quotesEqual(stampedQuote, quote) && stampedMeasurements.length > 0) {
|
|
1625
|
+
return null;
|
|
1626
|
+
}
|
|
1627
|
+
if (stampedMeasurements.length > 0 && reported.some(
|
|
1628
|
+
(measurement) => !dimsEqual(stampedMeasurements[0].dims, measurement.dims)
|
|
1629
|
+
)) {
|
|
1630
|
+
return null;
|
|
1631
|
+
}
|
|
1632
|
+
const measurements = [...stampedMeasurements, ...reported];
|
|
1633
|
+
const payload = buildInBandPayload(channels.request, measurements, quote);
|
|
1634
|
+
const headers = await channels.computeHeaders(payload);
|
|
1635
|
+
if (Object.keys(headers).length === 0 || !sink.canStampHeaders()) {
|
|
1636
|
+
return null;
|
|
1637
|
+
}
|
|
1638
|
+
sink.stampHeaders(headers);
|
|
1639
|
+
stampedMeasurements = measurements;
|
|
1640
|
+
stampedQuote = quote;
|
|
1641
|
+
return { ok: true, transport: "in_band" };
|
|
1642
|
+
});
|
|
1643
|
+
inBandTail = run.then(
|
|
1644
|
+
() => void 0,
|
|
1645
|
+
() => void 0
|
|
1646
|
+
);
|
|
1647
|
+
return run;
|
|
1648
|
+
};
|
|
1649
|
+
return async (input) => {
|
|
1650
|
+
const inputs = Array.isArray(input) ? input : [input];
|
|
1651
|
+
if (inputs.length === 0) {
|
|
1652
|
+
throw new MeteringError(
|
|
1653
|
+
RESPONSE_METERING_ERROR_CODES.invalidMeterKey,
|
|
1654
|
+
"report([]) is empty: a batched report needs at least one measurement"
|
|
1655
|
+
);
|
|
1656
|
+
}
|
|
1657
|
+
const measurements = inputs.map((entry) => validateMeasurement(entry));
|
|
1658
|
+
for (const measurement of measurements) {
|
|
1659
|
+
if (!dimsEqual(measurements[0].dims, measurement.dims)) {
|
|
1660
|
+
throw new MeteringError(
|
|
1661
|
+
RESPONSE_METERING_ERROR_CODES.invalidMeterValue,
|
|
1662
|
+
"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"
|
|
1663
|
+
);
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
let quote;
|
|
1667
|
+
for (const entry of inputs) {
|
|
1668
|
+
if (entry.quote === void 0) continue;
|
|
1669
|
+
const validated = validateQuote(entry.quote);
|
|
1670
|
+
if (quote === void 0) {
|
|
1671
|
+
quote = validated;
|
|
1672
|
+
} else if (!quotesEqual(quote, validated)) {
|
|
1673
|
+
throw new MeteringError(
|
|
1674
|
+
RESPONSE_METERING_ERROR_CODES.invalidQuote,
|
|
1675
|
+
"a batched report carries ONE quote: two entries supplied different quotes"
|
|
1676
|
+
);
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
const inBand = await tryInBand(measurements, quote);
|
|
1680
|
+
if (inBand) return inBand;
|
|
1681
|
+
return deliverPostStream(measurements, quote);
|
|
1682
|
+
};
|
|
1683
|
+
}
|
|
1684
|
+
function unattachedReport() {
|
|
1685
|
+
return () => Promise.reject(
|
|
1686
|
+
new MeteringError(
|
|
1687
|
+
RESPONSE_METERING_ERROR_CODES.missingToken,
|
|
1688
|
+
"report() has no metering channel on this context: verify through the runtime (fs.middleware() / fs.verifyRequest()) instead of the bare verifyRequest() primitive, and pass that context to background jobs"
|
|
1689
|
+
)
|
|
1690
|
+
);
|
|
1691
|
+
}
|
|
1692
|
+
function rawDimsUnitsOf(measurement) {
|
|
1693
|
+
let total = 0;
|
|
1694
|
+
for (const value of Object.values(measurement.values)) total += value;
|
|
1695
|
+
return { [measurement.meter]: total };
|
|
1696
|
+
}
|
|
1697
|
+
function buildInBandPayload(request, measurements, quote) {
|
|
1698
|
+
const rawDimsUnits = {};
|
|
1699
|
+
for (const measurement of measurements) {
|
|
1700
|
+
for (const [meter, units] of Object.entries(rawDimsUnitsOf(measurement))) {
|
|
1701
|
+
rawDimsUnits[meter] = (rawDimsUnits[meter] ?? 0) + units;
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
return {
|
|
1705
|
+
method: request.method.toUpperCase(),
|
|
1706
|
+
path: request.path,
|
|
1707
|
+
rawDimsUnits,
|
|
1708
|
+
measurementsVersion: MEASUREMENTS_VERSION,
|
|
1709
|
+
measurements,
|
|
1710
|
+
...quote ? { quote } : {}
|
|
1711
|
+
};
|
|
1712
|
+
}
|
|
1713
|
+
function dimsEqual(left, right) {
|
|
1714
|
+
const l = Object.entries(left ?? {}).sort(([a], [b]) => a < b ? -1 : 1);
|
|
1715
|
+
const r = Object.entries(right ?? {}).sort(([a], [b]) => a < b ? -1 : 1);
|
|
1716
|
+
if (l.length !== r.length) return false;
|
|
1717
|
+
return l.every(([k, v], i) => r[i][0] === k && r[i][1] === v);
|
|
1718
|
+
}
|
|
1719
|
+
function quotesEqual(left, right) {
|
|
1720
|
+
return left === right || left !== void 0 && right !== void 0 && left.currency === right.currency && left.amountNanos === right.amountNanos;
|
|
1721
|
+
}
|
|
1722
|
+
function validateMeasurement(input) {
|
|
1723
|
+
if (!input || typeof input !== "object") {
|
|
1724
|
+
throw invalidKey("report() requires a { meter, values } object");
|
|
1725
|
+
}
|
|
1726
|
+
const meter = assertKey(input.meter, "meter");
|
|
1727
|
+
const values = assertValues(input.values);
|
|
1728
|
+
const dims = input.dims === void 0 ? void 0 : assertDims(input.dims);
|
|
1729
|
+
return {
|
|
1730
|
+
meter,
|
|
1731
|
+
values,
|
|
1732
|
+
...dims && Object.keys(dims).length > 0 ? { dims } : {}
|
|
1733
|
+
};
|
|
1734
|
+
}
|
|
1735
|
+
function validateQuote(quote) {
|
|
1736
|
+
if (!quote || typeof quote !== "object" || Array.isArray(quote)) {
|
|
1737
|
+
throw invalidQuote(
|
|
1738
|
+
"quote must be an object of the form { currency, amountNanos }"
|
|
1739
|
+
);
|
|
1740
|
+
}
|
|
1741
|
+
const { currency, amountNanos } = quote;
|
|
1742
|
+
if (typeof currency !== "string" || !CURRENCY_RE.test(currency)) {
|
|
1743
|
+
throw invalidQuote("quote.currency must be a 3-letter currency code");
|
|
1744
|
+
}
|
|
1745
|
+
return {
|
|
1746
|
+
currency: currency.toLowerCase(),
|
|
1747
|
+
amountNanos: assertAmountNanos(amountNanos)
|
|
1748
|
+
};
|
|
1749
|
+
}
|
|
1750
|
+
function assertAmountNanos(value) {
|
|
1751
|
+
if (typeof value === "bigint") {
|
|
1752
|
+
if (value < 0n) throw negativeAmountNanos();
|
|
1753
|
+
return value.toString();
|
|
1754
|
+
}
|
|
1755
|
+
if (typeof value === "number") {
|
|
1756
|
+
if (!Number.isSafeInteger(value)) {
|
|
1757
|
+
throw invalidQuote(
|
|
1758
|
+
"quote.amountNanos must be a safe integer number of nanodollars (pass a string or bigint for larger amounts)"
|
|
1759
|
+
);
|
|
1760
|
+
}
|
|
1761
|
+
if (value < 0) throw negativeAmountNanos();
|
|
1762
|
+
return String(value);
|
|
1763
|
+
}
|
|
1764
|
+
if (typeof value === "string") {
|
|
1765
|
+
if (/^-/.test(value)) throw negativeAmountNanos();
|
|
1766
|
+
if (DECIMAL_INTEGER_RE.test(value)) return value;
|
|
1767
|
+
}
|
|
1768
|
+
throw invalidQuote(
|
|
1769
|
+
"quote.amountNanos must be a non-negative integer number of nanodollars"
|
|
1770
|
+
);
|
|
1771
|
+
}
|
|
1772
|
+
function negativeAmountNanos() {
|
|
1773
|
+
return invalidQuote(
|
|
1774
|
+
"quote.amountNanos must be non-negative: a quote is a proposed rate, never a credit \u2014 refunds are platform operations"
|
|
1775
|
+
);
|
|
1776
|
+
}
|
|
1777
|
+
function assertValues(values) {
|
|
1778
|
+
if (!values || typeof values !== "object" || Array.isArray(values)) {
|
|
1779
|
+
throw invalidKey("report() requires a values object");
|
|
1780
|
+
}
|
|
1781
|
+
const entries = Object.entries(values).sort(
|
|
1782
|
+
([a], [b]) => a < b ? -1 : a > b ? 1 : 0
|
|
1783
|
+
);
|
|
1784
|
+
if (entries.length === 0) {
|
|
1785
|
+
throw invalidKey("report() requires at least one measure in values");
|
|
1786
|
+
}
|
|
1787
|
+
const out = {};
|
|
1788
|
+
for (const [measure, value] of entries) {
|
|
1789
|
+
assertKey(measure, "measure");
|
|
1790
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
|
1791
|
+
throw new MeteringError(
|
|
1792
|
+
RESPONSE_METERING_ERROR_CODES.invalidMeterValue,
|
|
1793
|
+
`values.${measure} must be a non-negative safe integer`
|
|
1794
|
+
);
|
|
1795
|
+
}
|
|
1796
|
+
out[measure] = value;
|
|
1797
|
+
}
|
|
1798
|
+
return out;
|
|
1799
|
+
}
|
|
1800
|
+
function assertDims(dims) {
|
|
1801
|
+
if (!dims || typeof dims !== "object" || Array.isArray(dims)) {
|
|
1802
|
+
throw invalidKey("report() dims must be an object of dimension selectors");
|
|
1803
|
+
}
|
|
1804
|
+
const entries = Object.entries(dims).sort(
|
|
1805
|
+
([a], [b]) => a < b ? -1 : a > b ? 1 : 0
|
|
1806
|
+
);
|
|
1807
|
+
const out = {};
|
|
1808
|
+
for (const [dimension, value] of entries) {
|
|
1809
|
+
assertKey(dimension, "dimension");
|
|
1810
|
+
if (typeof value !== "string" || !DIMENSION_VALUE_RE.test(value)) {
|
|
1811
|
+
throw invalidKey(
|
|
1812
|
+
`dims.${dimension} must be a 1-128 character selector value`
|
|
1813
|
+
);
|
|
1814
|
+
}
|
|
1815
|
+
out[dimension] = value;
|
|
1816
|
+
}
|
|
1817
|
+
return out;
|
|
1818
|
+
}
|
|
1819
|
+
function assertKey(value, label) {
|
|
1820
|
+
if (typeof value !== "string" || !KEY_RE.test(value)) {
|
|
1821
|
+
throw invalidKey(
|
|
1822
|
+
`${label} key ${JSON.stringify(value)} must be 1-64 lowercase alphanumeric characters or underscores`
|
|
1823
|
+
);
|
|
1824
|
+
}
|
|
1825
|
+
return value;
|
|
1826
|
+
}
|
|
1827
|
+
function invalidKey(message) {
|
|
1828
|
+
return new MeteringError(
|
|
1829
|
+
RESPONSE_METERING_ERROR_CODES.invalidMeterKey,
|
|
1830
|
+
message
|
|
1831
|
+
);
|
|
1832
|
+
}
|
|
1833
|
+
function invalidQuote(message) {
|
|
1834
|
+
return new MeteringError(RESPONSE_METERING_ERROR_CODES.invalidQuote, message);
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1646
1837
|
// src/core/nonceCache.ts
|
|
1647
1838
|
var DEFAULT_MAX_ENTRIES = 25e4;
|
|
1648
1839
|
var DEFAULT_TTL_MS = (RUNTIME_REPLAY_WINDOW_SECONDS + RUNTIME_CLOCK_SKEW_SECONDS) * 1e3;
|
|
@@ -2236,22 +2427,7 @@ async function verifyRequest(input, deps) {
|
|
|
2236
2427
|
"x-fs-timestamp is not an integer"
|
|
2237
2428
|
);
|
|
2238
2429
|
}
|
|
2239
|
-
|
|
2240
|
-
const window = deps.replayWindowSeconds ?? RUNTIME_REPLAY_WINDOW_SECONDS;
|
|
2241
|
-
const now = (deps.nowSeconds ?? (() => Math.floor(Date.now() / 1e3)))();
|
|
2242
|
-
const delta = now - timestamp;
|
|
2243
|
-
if (Math.abs(delta) > window) {
|
|
2244
|
-
if (Math.abs(delta) <= window + skew) {
|
|
2245
|
-
throw new FartherShoreError(
|
|
2246
|
-
"clock_skew",
|
|
2247
|
-
"x-fs-timestamp is outside the replay window but within clock-skew tolerance"
|
|
2248
|
-
);
|
|
2249
|
-
}
|
|
2250
|
-
throw new FartherShoreError(
|
|
2251
|
-
"expired_signature",
|
|
2252
|
-
"x-fs-timestamp is outside the replay window"
|
|
2253
|
-
);
|
|
2254
|
-
}
|
|
2430
|
+
assertTimestampWithinWindow(timestamp, deps);
|
|
2255
2431
|
const computedBodyHash = await computeBodyHash(input);
|
|
2256
2432
|
if (signedBodyHash !== computedBodyHash) {
|
|
2257
2433
|
throw new FartherShoreError(
|
|
@@ -2259,31 +2435,7 @@ async function verifyRequest(input, deps) {
|
|
|
2259
2435
|
"recomputed body hash does not match the signed x-fs-body-hash"
|
|
2260
2436
|
);
|
|
2261
2437
|
}
|
|
2262
|
-
|
|
2263
|
-
throw new FartherShoreError(
|
|
2264
|
-
"route_mismatch",
|
|
2265
|
-
"signed business-id does not match this backend's business"
|
|
2266
|
-
);
|
|
2267
|
-
}
|
|
2268
|
-
if (deps.backendIds !== void 0 && deps.backendIds.size > 0) {
|
|
2269
|
-
if (!deps.backendIds.has(signedBackendId)) {
|
|
2270
|
-
throw new FartherShoreError(
|
|
2271
|
-
"route_mismatch",
|
|
2272
|
-
"signed backend-id is not one this deployment serves"
|
|
2273
|
-
);
|
|
2274
|
-
}
|
|
2275
|
-
} else if (deps.backendId !== void 0 && signedBackendId !== deps.backendId) {
|
|
2276
|
-
throw new FartherShoreError(
|
|
2277
|
-
"route_mismatch",
|
|
2278
|
-
"signed backend-id does not match this backend"
|
|
2279
|
-
);
|
|
2280
|
-
}
|
|
2281
|
-
if (deps.knownRouteIds !== void 0 && signedRouteId !== "" && !deps.knownRouteIds.has(signedRouteId)) {
|
|
2282
|
-
throw new FartherShoreError(
|
|
2283
|
-
"route_mismatch",
|
|
2284
|
-
"signed route-id is not served by this backend"
|
|
2285
|
-
);
|
|
2286
|
-
}
|
|
2438
|
+
assertClaimBinding(deps, signedBusinessId, signedBackendId, signedRouteId);
|
|
2287
2439
|
const contextToken = h("x-fs-context") ?? null;
|
|
2288
2440
|
const canonicalInput = {
|
|
2289
2441
|
method: input.method,
|
|
@@ -2354,9 +2506,57 @@ async function verifyRequest(input, deps) {
|
|
|
2354
2506
|
...principal ? { principal } : {},
|
|
2355
2507
|
...permissions !== void 0 ? { permissions } : {},
|
|
2356
2508
|
...roles !== void 0 ? { roles } : {},
|
|
2357
|
-
...signedContext ? { signedContext } : {}
|
|
2509
|
+
...signedContext ? { signedContext } : {},
|
|
2510
|
+
// The bare primitive has no metering channel; the runtime facade replaces
|
|
2511
|
+
// this with the real bound verb (see FartherShore.verifyRequest).
|
|
2512
|
+
report: unattachedReport()
|
|
2358
2513
|
};
|
|
2359
2514
|
}
|
|
2515
|
+
function assertTimestampWithinWindow(timestamp, deps) {
|
|
2516
|
+
const skew = deps.clockSkewSeconds ?? RUNTIME_CLOCK_SKEW_SECONDS;
|
|
2517
|
+
const window = deps.replayWindowSeconds ?? RUNTIME_REPLAY_WINDOW_SECONDS;
|
|
2518
|
+
const now = (deps.nowSeconds ?? (() => Math.floor(Date.now() / 1e3)))();
|
|
2519
|
+
const delta = now - timestamp;
|
|
2520
|
+
if (Math.abs(delta) > window) {
|
|
2521
|
+
if (Math.abs(delta) <= window + skew) {
|
|
2522
|
+
throw new FartherShoreError(
|
|
2523
|
+
"clock_skew",
|
|
2524
|
+
"x-fs-timestamp is outside the replay window but within clock-skew tolerance"
|
|
2525
|
+
);
|
|
2526
|
+
}
|
|
2527
|
+
throw new FartherShoreError(
|
|
2528
|
+
"expired_signature",
|
|
2529
|
+
"x-fs-timestamp is outside the replay window"
|
|
2530
|
+
);
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
function assertClaimBinding(deps, signedBusinessId, signedBackendId, signedRouteId) {
|
|
2534
|
+
if (deps.businessId !== void 0 && signedBusinessId !== deps.businessId) {
|
|
2535
|
+
throw new FartherShoreError(
|
|
2536
|
+
"route_mismatch",
|
|
2537
|
+
"signed business-id does not match this backend's business"
|
|
2538
|
+
);
|
|
2539
|
+
}
|
|
2540
|
+
if (deps.backendIds !== void 0 && deps.backendIds.size > 0) {
|
|
2541
|
+
if (!deps.backendIds.has(signedBackendId)) {
|
|
2542
|
+
throw new FartherShoreError(
|
|
2543
|
+
"route_mismatch",
|
|
2544
|
+
"signed backend-id is not one this deployment serves"
|
|
2545
|
+
);
|
|
2546
|
+
}
|
|
2547
|
+
} else if (deps.backendId !== void 0 && signedBackendId !== deps.backendId) {
|
|
2548
|
+
throw new FartherShoreError(
|
|
2549
|
+
"route_mismatch",
|
|
2550
|
+
"signed backend-id does not match this backend"
|
|
2551
|
+
);
|
|
2552
|
+
}
|
|
2553
|
+
if (deps.knownRouteIds !== void 0 && signedRouteId !== "" && !deps.knownRouteIds.has(signedRouteId)) {
|
|
2554
|
+
throw new FartherShoreError(
|
|
2555
|
+
"route_mismatch",
|
|
2556
|
+
"signed route-id is not served by this backend"
|
|
2557
|
+
);
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2360
2560
|
async function resolveSignedContext(contextToken, contextSecrets, signedBusinessId) {
|
|
2361
2561
|
if (!contextToken) return null;
|
|
2362
2562
|
const signedContext = decodeContextClaims(contextToken);
|
|
@@ -2409,7 +2609,7 @@ function headerGetter(headers) {
|
|
|
2409
2609
|
|
|
2410
2610
|
// src/core/runtime.ts
|
|
2411
2611
|
var DEFAULT_CORE_URL = "https://core.farthershore.com";
|
|
2412
|
-
var SDK_VERSION = "0.
|
|
2612
|
+
var SDK_VERSION = "0.21.0".length > 0 ? "0.21.0" : "0.0.0-dev";
|
|
2413
2613
|
var FartherShore = class {
|
|
2414
2614
|
bootstrapClient;
|
|
2415
2615
|
fetchImpl;
|
|
@@ -2425,14 +2625,13 @@ var FartherShore = class {
|
|
|
2425
2625
|
replayProtectionDiagnostic;
|
|
2426
2626
|
shutdownManager = new ShutdownManager();
|
|
2427
2627
|
jwks = null;
|
|
2428
|
-
meteringClient = null;
|
|
2429
2628
|
postStreamUsageClient = null;
|
|
2430
2629
|
tunnel = null;
|
|
2431
2630
|
bootstrapped = false;
|
|
2432
2631
|
constructor(options = {}) {
|
|
2433
2632
|
const env = options.env ?? readProcessEnv();
|
|
2434
2633
|
const runtimeToken = options.runtimeToken ?? env[FS_RUNTIME_TOKEN_ENV] ?? "";
|
|
2435
|
-
const coreUrl = options.coreUrl ?? env.FS_CORE_URL ??
|
|
2634
|
+
const coreUrl = options.coreUrl ?? env.FS_CORE_URL ?? DEFAULT_CORE_URL;
|
|
2436
2635
|
this.runtimeToken = runtimeToken;
|
|
2437
2636
|
this.coreUrl = coreUrl;
|
|
2438
2637
|
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
@@ -2454,9 +2653,6 @@ var FartherShore = class {
|
|
|
2454
2653
|
...options.instanceId ? { instanceId: options.instanceId } : {}
|
|
2455
2654
|
}
|
|
2456
2655
|
});
|
|
2457
|
-
this.shutdownManager.register(async () => {
|
|
2458
|
-
await this.meteringClient?.flush();
|
|
2459
|
-
});
|
|
2460
2656
|
this.shutdownManager.register(async () => {
|
|
2461
2657
|
await reportHealth({
|
|
2462
2658
|
runtimeToken: this.runtimeToken,
|
|
@@ -2476,14 +2672,7 @@ var FartherShore = class {
|
|
|
2476
2672
|
fetchImpl: this.fetchImpl
|
|
2477
2673
|
});
|
|
2478
2674
|
}
|
|
2479
|
-
if (!this.
|
|
2480
|
-
this.meteringClient = new MeteringClient({
|
|
2481
|
-
config: config.metering,
|
|
2482
|
-
businessId: config.business.id,
|
|
2483
|
-
backendId: config.backend.id,
|
|
2484
|
-
coreUrl: this.coreUrl,
|
|
2485
|
-
fetchImpl: this.fetchImpl
|
|
2486
|
-
});
|
|
2675
|
+
if (!this.postStreamUsageClient && config.metering.enabled) {
|
|
2487
2676
|
this.postStreamUsageClient = new PostStreamUsageClient({
|
|
2488
2677
|
config: config.metering,
|
|
2489
2678
|
coreUrl: this.coreUrl,
|
|
@@ -2556,7 +2745,7 @@ var FartherShore = class {
|
|
|
2556
2745
|
* Framework-neutral verification primitive. Fail-closed: throws a typed
|
|
2557
2746
|
* FartherShoreError on any verification failure. Returns the verified context.
|
|
2558
2747
|
*/
|
|
2559
|
-
async verifyRequest(input) {
|
|
2748
|
+
async verifyRequest(input, options = {}) {
|
|
2560
2749
|
const config = await this.ensureBootstrapped();
|
|
2561
2750
|
if (!this.jwks) {
|
|
2562
2751
|
throw new FartherShoreError(
|
|
@@ -2582,21 +2771,52 @@ var FartherShore = class {
|
|
|
2582
2771
|
});
|
|
2583
2772
|
return {
|
|
2584
2773
|
...context,
|
|
2585
|
-
|
|
2586
|
-
|
|
2774
|
+
report: this.buildReportFn(context, input, options.responseSink)
|
|
2775
|
+
};
|
|
2776
|
+
}
|
|
2777
|
+
/**
|
|
2778
|
+
* Bind the ONE reporting verb to a verified context. Identity comes from the
|
|
2779
|
+
* context (`signedContext.subscriptionId` + `requestId`) — never from the
|
|
2780
|
+
* caller — so a handler cannot forget it, and a background job that is handed
|
|
2781
|
+
* this context keeps reporting against the SAME served identity.
|
|
2782
|
+
*/
|
|
2783
|
+
buildReportFn(context, input, responseSink) {
|
|
2784
|
+
const channels = {
|
|
2785
|
+
request: { method: input.method, path: input.path },
|
|
2786
|
+
...responseSink ? { responseSink } : {},
|
|
2787
|
+
computeHeaders: (payload) => computeMeteringHeaders(payload, {
|
|
2788
|
+
token: this.runtimeToken,
|
|
2789
|
+
requestId: context.requestId,
|
|
2790
|
+
onSkip: () => {
|
|
2791
|
+
}
|
|
2792
|
+
}),
|
|
2793
|
+
postStream: async ({ measurements, quote }) => {
|
|
2794
|
+
const subscriptionId = context.signedContext?.subscriptionId;
|
|
2587
2795
|
if (!subscriptionId) {
|
|
2588
|
-
return
|
|
2796
|
+
return {
|
|
2589
2797
|
ok: false,
|
|
2590
|
-
reason: "
|
|
2591
|
-
}
|
|
2798
|
+
reason: "this request carries no subscription identity, so late usage cannot be attributed \u2014 report before the response is sent, or serve the route through a subscribed surface"
|
|
2799
|
+
};
|
|
2592
2800
|
}
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2801
|
+
const meters = {};
|
|
2802
|
+
for (const measurement of measurements) {
|
|
2803
|
+
for (const [meter, qty] of Object.entries(
|
|
2804
|
+
rawDimsUnitsOf(measurement)
|
|
2805
|
+
)) {
|
|
2806
|
+
meters[meter] = (meters[meter] ?? 0) + qty;
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2809
|
+
return this.reportPostStreamUsage({
|
|
2810
|
+
requestId: context.requestId,
|
|
2811
|
+
subscriptionId,
|
|
2812
|
+
meters,
|
|
2813
|
+
measurementsVersion: MEASUREMENTS_VERSION,
|
|
2814
|
+
measurements,
|
|
2815
|
+
...quote ? { quote } : {}
|
|
2597
2816
|
});
|
|
2598
2817
|
}
|
|
2599
2818
|
};
|
|
2819
|
+
return createReportFn(channels);
|
|
2600
2820
|
}
|
|
2601
2821
|
/** Whether verification is required (bootstrap × opt-out). */
|
|
2602
2822
|
async verificationRequired() {
|
|
@@ -2645,20 +2865,12 @@ var FartherShore = class {
|
|
|
2645
2865
|
});
|
|
2646
2866
|
await supervisor.start();
|
|
2647
2867
|
}
|
|
2648
|
-
/**
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
"invalid_token",
|
|
2655
|
-
"metering is not enabled for this runtime token"
|
|
2656
|
-
);
|
|
2657
|
-
}
|
|
2658
|
-
await this.meteringClient.meter(meter, qty, options);
|
|
2659
|
-
}
|
|
2660
|
-
/** Best-effort attested post-stream usage callback. Never rejects. */
|
|
2661
|
-
async reportUsage(input) {
|
|
2868
|
+
/**
|
|
2869
|
+
* PRIVATE transport for the post-stream lane of `ctx.report()`. Never rejects
|
|
2870
|
+
* — a metering hiccup must not break a builder's endpoint. This is machinery,
|
|
2871
|
+
* not surface: the ONE public reporting verb is `ctx.report()`.
|
|
2872
|
+
*/
|
|
2873
|
+
async reportPostStreamUsage(input) {
|
|
2662
2874
|
try {
|
|
2663
2875
|
await this.ensureBootstrapped();
|
|
2664
2876
|
if (!this.meteringEnabledOverride || !this.postStreamUsageClient) {
|
|
@@ -2690,7 +2902,7 @@ var FartherShore = class {
|
|
|
2690
2902
|
// fs.start() launches an embedded tunnel; otherwise the supervisor state.
|
|
2691
2903
|
tunnel: this.tunnel ? this.tunnel.healthString() : null,
|
|
2692
2904
|
verification: this.verificationEnabled && config !== null,
|
|
2693
|
-
metering: this.
|
|
2905
|
+
metering: this.postStreamUsageClient !== null
|
|
2694
2906
|
});
|
|
2695
2907
|
}
|
|
2696
2908
|
/** Graceful shutdown: flush metering + send a stopping heartbeat. */
|
|
@@ -2734,7 +2946,12 @@ function permissionSatisfies(required, granted) {
|
|
|
2734
2946
|
const idx = required.indexOf(":");
|
|
2735
2947
|
if (idx > 0 && idx < required.length - 1) {
|
|
2736
2948
|
const subject = required.slice(0, idx);
|
|
2737
|
-
if (granted.includes(`${subject}:${WILDCARD}`))
|
|
2949
|
+
if (subject !== WILDCARD && granted.includes(`${subject}:${WILDCARD}`))
|
|
2950
|
+
return true;
|
|
2951
|
+
if (required.slice(idx + 1) === WILDCARD && subject !== WILDCARD) {
|
|
2952
|
+
const prefix = `${subject}:`;
|
|
2953
|
+
return granted.some((permission) => permission.startsWith(prefix));
|
|
2954
|
+
}
|
|
2738
2955
|
}
|
|
2739
2956
|
return false;
|
|
2740
2957
|
}
|
|
@@ -2769,14 +2986,17 @@ async function runMiddleware(fs, options, req, res, next) {
|
|
|
2769
2986
|
const contentType = headerValue(req.headers, "content-type");
|
|
2770
2987
|
const streamingExempt = isStreamingExempt(contentType);
|
|
2771
2988
|
const body = streamingExempt ? null : extractRawBody(req);
|
|
2772
|
-
const ctx = await fs.verifyRequest(
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2989
|
+
const ctx = await fs.verifyRequest(
|
|
2990
|
+
{
|
|
2991
|
+
method: req.method,
|
|
2992
|
+
path,
|
|
2993
|
+
query,
|
|
2994
|
+
headers: req.headers,
|
|
2995
|
+
body,
|
|
2996
|
+
streamingExempt
|
|
2997
|
+
},
|
|
2998
|
+
{ responseSink: expressResponseSink(res) }
|
|
2999
|
+
);
|
|
2780
3000
|
req.fartherShore = ctx;
|
|
2781
3001
|
stripFartherShoreHeaders(req);
|
|
2782
3002
|
next();
|
|
@@ -2784,6 +3004,16 @@ async function runMiddleware(fs, options, req, res, next) {
|
|
|
2784
3004
|
fail(res, error, options, req);
|
|
2785
3005
|
}
|
|
2786
3006
|
}
|
|
3007
|
+
function expressResponseSink(res) {
|
|
3008
|
+
return {
|
|
3009
|
+
canStampHeaders: () => res.headersSent !== true,
|
|
3010
|
+
stampHeaders: (headers) => {
|
|
3011
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
3012
|
+
res.setHeader(name, value);
|
|
3013
|
+
}
|
|
3014
|
+
}
|
|
3015
|
+
};
|
|
3016
|
+
}
|
|
2787
3017
|
function fail(res, error, options, req) {
|
|
2788
3018
|
const code = error instanceof FartherShoreError ? error.code : "bad_signature";
|
|
2789
3019
|
const status = error instanceof FartherShoreError ? error.status : 401;
|
|
@@ -2824,7 +3054,12 @@ function stripFartherShoreHeaders(req) {
|
|
|
2824
3054
|
withRaw.rawHeaders = cleaned;
|
|
2825
3055
|
}
|
|
2826
3056
|
}
|
|
2827
|
-
function createExpressHandler(
|
|
3057
|
+
function createExpressHandler(optionsOrHandler, maybeHandler) {
|
|
3058
|
+
const options = typeof optionsOrHandler === "function" ? {} : optionsOrHandler;
|
|
3059
|
+
const handler = typeof optionsOrHandler === "function" ? optionsOrHandler : maybeHandler;
|
|
3060
|
+
if (typeof handler !== "function") {
|
|
3061
|
+
throw new TypeError("fs.handler(options, cb) requires a handler callback");
|
|
3062
|
+
}
|
|
2828
3063
|
return (req, res, next) => {
|
|
2829
3064
|
const ctx = req.fartherShore;
|
|
2830
3065
|
if (!ctx) {
|
|
@@ -2835,10 +3070,22 @@ function createExpressHandler(handler) {
|
|
|
2835
3070
|
res.status(401).json({ error: "principal_required" });
|
|
2836
3071
|
return;
|
|
2837
3072
|
}
|
|
3073
|
+
if (!ctx.signedContext) {
|
|
3074
|
+
res.status(401).json({ error: "context_unverified" });
|
|
3075
|
+
return;
|
|
3076
|
+
}
|
|
2838
3077
|
const verified = ctx;
|
|
2839
|
-
void Promise.resolve().then(
|
|
2840
|
-
|
|
2841
|
-
|
|
3078
|
+
void Promise.resolve().then(() => {
|
|
3079
|
+
if (options.permission !== void 0) {
|
|
3080
|
+
requirePermission(verified, options.permission);
|
|
3081
|
+
}
|
|
3082
|
+
return handler(
|
|
3083
|
+
verified,
|
|
3084
|
+
req,
|
|
3085
|
+
res,
|
|
3086
|
+
next
|
|
3087
|
+
);
|
|
3088
|
+
}).catch((error) => failHandler(res, next, error));
|
|
2842
3089
|
};
|
|
2843
3090
|
}
|
|
2844
3091
|
function failHandler(res, next, error) {
|
|
@@ -2899,7 +3146,7 @@ function readProcessEnv2() {
|
|
|
2899
3146
|
// src/testing/usageSink.ts
|
|
2900
3147
|
var DevUsageSink = class {
|
|
2901
3148
|
events = [];
|
|
2902
|
-
/** Record a signed response-metering payload (
|
|
3149
|
+
/** Record a signed response-metering payload (report() in-band / computeMeteringHeaders). */
|
|
2903
3150
|
recordResponse(payload, requestId) {
|
|
2904
3151
|
const raw = payload.rawDimsUnits;
|
|
2905
3152
|
const meters = raw && typeof raw === "object" ? raw : {};
|
|
@@ -3179,6 +3426,7 @@ function createDevRuntime(options) {
|
|
|
3179
3426
|
}
|
|
3180
3427
|
fs.middleware = middleware;
|
|
3181
3428
|
fs.handler = createExpressHandler;
|
|
3429
|
+
fs.authz = tracedAuthz;
|
|
3182
3430
|
const devRuntime = {
|
|
3183
3431
|
fs,
|
|
3184
3432
|
asPersona: (name) => personaClient.asPersona(name),
|
|
@@ -3291,6 +3539,69 @@ function splitUrl2(req) {
|
|
|
3291
3539
|
if (qIndex === -1) return { path: raw, query: "" };
|
|
3292
3540
|
return { path: raw.slice(0, qIndex), query: raw.slice(qIndex + 1) };
|
|
3293
3541
|
}
|
|
3542
|
+
|
|
3543
|
+
// ../contracts/dist/webhooks/standard-webhooks.js
|
|
3544
|
+
import { createHmac, randomBytes as randomBytes2, timingSafeEqual } from "node:crypto";
|
|
3545
|
+
var WEBHOOK_ID_HEADER = "webhook-id";
|
|
3546
|
+
var WEBHOOK_TIMESTAMP_HEADER = "webhook-timestamp";
|
|
3547
|
+
var WEBHOOK_SIGNATURE_HEADER = "webhook-signature";
|
|
3548
|
+
var WEBHOOK_SIGNATURE_VERSION = "v1";
|
|
3549
|
+
var WEBHOOK_SECRET_PREFIX = "fswh_";
|
|
3550
|
+
function webhookSecretKeyBytes(secret) {
|
|
3551
|
+
const key2 = 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");
|
|
3552
|
+
if (key2.length === 0) {
|
|
3553
|
+
throw new Error("webhook secret resolves to an empty signing key \u2014 the configured secret must be a non-empty fswh_ value");
|
|
3554
|
+
}
|
|
3555
|
+
return key2;
|
|
3556
|
+
}
|
|
3557
|
+
function webhookSignedContent(id, timestamp, body) {
|
|
3558
|
+
return `${id}.${timestamp}.${body}`;
|
|
3559
|
+
}
|
|
3560
|
+
function signWebhookContent(secret, id, timestamp, body) {
|
|
3561
|
+
const mac = createHmac("sha256", webhookSecretKeyBytes(secret)).update(webhookSignedContent(id, timestamp, body)).digest("base64");
|
|
3562
|
+
return `${WEBHOOK_SIGNATURE_VERSION},${mac}`;
|
|
3563
|
+
}
|
|
3564
|
+
function signWebhook(input) {
|
|
3565
|
+
if (input.secrets.length === 0) {
|
|
3566
|
+
throw new Error("signWebhook: at least one secret is required");
|
|
3567
|
+
}
|
|
3568
|
+
const signature = input.secrets.map((secret) => signWebhookContent(secret, input.id, input.timestamp, input.body)).join(" ");
|
|
3569
|
+
return {
|
|
3570
|
+
[WEBHOOK_ID_HEADER]: input.id,
|
|
3571
|
+
[WEBHOOK_TIMESTAMP_HEADER]: String(input.timestamp),
|
|
3572
|
+
[WEBHOOK_SIGNATURE_HEADER]: signature
|
|
3573
|
+
};
|
|
3574
|
+
}
|
|
3575
|
+
|
|
3576
|
+
// src/testing/webhooks.ts
|
|
3577
|
+
function signWebhookForTesting(input) {
|
|
3578
|
+
const secrets = typeof input.secret === "string" ? [input.secret] : [...input.secret];
|
|
3579
|
+
const id = input.id ?? `test_${crypto.randomUUID()}`;
|
|
3580
|
+
const envelope = {
|
|
3581
|
+
id,
|
|
3582
|
+
type: input.type,
|
|
3583
|
+
createdAt: input.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
3584
|
+
businessId: input.businessId ?? "biz_test",
|
|
3585
|
+
environmentId: input.environmentId ?? null,
|
|
3586
|
+
data: input.data
|
|
3587
|
+
};
|
|
3588
|
+
const body = JSON.stringify(envelope);
|
|
3589
|
+
const timestamp = input.timestamp ?? Math.floor(Date.now() / 1e3);
|
|
3590
|
+
const signed = signWebhook({ id, timestamp, body, secrets });
|
|
3591
|
+
const headers = {
|
|
3592
|
+
"content-type": "application/json",
|
|
3593
|
+
...signed,
|
|
3594
|
+
"x-fs-webhook-event": input.type
|
|
3595
|
+
};
|
|
3596
|
+
return {
|
|
3597
|
+
envelope,
|
|
3598
|
+
body,
|
|
3599
|
+
headers,
|
|
3600
|
+
request(url = "https://receiver.test/webhooks/farthershore") {
|
|
3601
|
+
return new Request(url, { method: "POST", headers, body });
|
|
3602
|
+
}
|
|
3603
|
+
};
|
|
3604
|
+
}
|
|
3294
3605
|
export {
|
|
3295
3606
|
CONTEXT_HEADER_NAME,
|
|
3296
3607
|
DEFAULT_KEYS_FILE,
|
|
@@ -3323,6 +3634,7 @@ export {
|
|
|
3323
3634
|
readDevKeysFile,
|
|
3324
3635
|
redactValue,
|
|
3325
3636
|
signContextToken,
|
|
3637
|
+
signWebhookForTesting,
|
|
3326
3638
|
unreachableJwks,
|
|
3327
3639
|
writeDevKeysFile
|
|
3328
3640
|
};
|