@farthershore/backend 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2906 @@
1
+ import { createRequire as __createRequire } from "node:module";const require=__createRequire(import.meta.url);
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+
12
+ // src/reflect/route-canon.ts
13
+ import { createHash } from "node:crypto";
14
+ function normalizeSegment(seg) {
15
+ if (seg === "**") return "{__fs_rest}";
16
+ if (seg === "*") return "{splat}";
17
+ if (seg.startsWith(":")) return `{${seg.slice(1)}}`;
18
+ if (seg.startsWith("[") && seg.endsWith("]")) return `{${seg.slice(1, -1)}}`;
19
+ return seg;
20
+ }
21
+ function normalizePath(path) {
22
+ if (path === "/" || path === "") return "/";
23
+ const lead = path.startsWith("/");
24
+ const out = path.split("/").filter((s) => s.length > 0).map(normalizeSegment).join("/");
25
+ return lead ? `/${out}` : out;
26
+ }
27
+ function pathSpecificity(path) {
28
+ return path.split("/").filter(
29
+ (s) => s.length > 0 && !s.startsWith("{") && s !== "*" && s !== "**"
30
+ ).length;
31
+ }
32
+ function canonicalSort(routes) {
33
+ return [...routes].sort(
34
+ (a, b) => pathSpecificity(b.path) - pathSpecificity(a.path) || a.method.localeCompare(b.method) || a.path.localeCompare(b.path)
35
+ );
36
+ }
37
+ function surfaceHash(routes) {
38
+ const canonical = canonicalSort(routes).map(
39
+ (r) => `${r.method.toUpperCase()} ${r.path}`
40
+ );
41
+ const h = createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
42
+ return `sha256:${h}`;
43
+ }
44
+ var init_route_canon = __esm({
45
+ "src/reflect/route-canon.ts"() {
46
+ "use strict";
47
+ }
48
+ });
49
+
50
+ // src/reflect/index.ts
51
+ var reflect_exports = {};
52
+ __export(reflect_exports, {
53
+ reflectRoutes: () => reflectRoutes,
54
+ reflectRoutesDetailed: () => reflectRoutesDetailed
55
+ });
56
+ function methodsOf(route) {
57
+ const out = /* @__PURE__ */ new Set();
58
+ if (route.methods) {
59
+ for (const [m, on] of Object.entries(route.methods))
60
+ if (on) out.add(m.toUpperCase());
61
+ }
62
+ for (const s of route.stack ?? []) {
63
+ if (typeof s.method === "string") out.add(s.method.toUpperCase());
64
+ }
65
+ if (out.has("GET")) out.delete("HEAD");
66
+ return [...out].filter((m) => VALID_METHODS.has(m));
67
+ }
68
+ function rootStack(app) {
69
+ const a = app;
70
+ return a?.router?.stack ?? a?._router?.stack;
71
+ }
72
+ function prefixFromRegexp(re) {
73
+ if (!re) return void 0;
74
+ let s = re.source;
75
+ if (s === "^\\/?$" || s === "^\\/") return "";
76
+ s = s.replace(/^\^/, "").replace(/\\\/\?\(\?=\\\/\|\$\)$/, "").replace(/\(\?=\\\/\|\$\)$/, "").replace(/\\\/\?$/, "").replace(/\$$/, "");
77
+ s = s.replace(/\\\//g, "/");
78
+ if (/[()?:*+\[\]|]/.test(s)) return void 0;
79
+ return s.startsWith("/") ? s : `/${s}`;
80
+ }
81
+ function walk(stack, prefix, out, counters) {
82
+ for (const layer of stack) {
83
+ if (layer.route && typeof layer.route.path === "string") {
84
+ const path = normalizePath(`${prefix}${layer.route.path}`);
85
+ for (const method of methodsOf(layer.route)) {
86
+ out.push({ method, path });
87
+ }
88
+ } else if (layer.handle?.stack && Array.isArray(layer.handle.stack)) {
89
+ const sub = prefixFromRegexp(layer.regexp);
90
+ if (sub === void 0 && layer.name === "router") {
91
+ counters.unreflectable += layer.handle.stack.filter(
92
+ (l) => l.route
93
+ ).length;
94
+ continue;
95
+ }
96
+ walk(layer.handle.stack, `${prefix}${sub ?? ""}`, out, counters);
97
+ }
98
+ }
99
+ }
100
+ function reflectRoutesDetailed(app, _opts) {
101
+ const stack = rootStack(app);
102
+ if (!stack) return { routes: [], unreflectable: 0 };
103
+ const raw = [];
104
+ const counters = { unreflectable: 0 };
105
+ walk(stack, "", raw, counters);
106
+ const seen = /* @__PURE__ */ new Set();
107
+ const routes = [];
108
+ for (const r of raw) {
109
+ const key2 = `${r.method} ${r.path}`;
110
+ if (seen.has(key2)) continue;
111
+ seen.add(key2);
112
+ routes.push(r);
113
+ }
114
+ return { routes, unreflectable: counters.unreflectable };
115
+ }
116
+ function reflectRoutes(app, opts) {
117
+ return reflectRoutesDetailed(app, opts).routes;
118
+ }
119
+ var VALID_METHODS;
120
+ var init_reflect = __esm({
121
+ "src/reflect/index.ts"() {
122
+ "use strict";
123
+ init_route_canon();
124
+ VALID_METHODS = /* @__PURE__ */ new Set([
125
+ "GET",
126
+ "HEAD",
127
+ "POST",
128
+ "PUT",
129
+ "PATCH",
130
+ "DELETE",
131
+ "OPTIONS"
132
+ ]);
133
+ }
134
+ });
135
+
136
+ // src/reflect/reconcile.ts
137
+ var reconcile_exports = {};
138
+ __export(reconcile_exports, {
139
+ reconcileOnStartup: () => reconcileOnStartup
140
+ });
141
+ function key(r) {
142
+ return `${r.method.toUpperCase()} ${r.path}`;
143
+ }
144
+ async function reconcileOnStartup(args) {
145
+ const { reflected, bootstrap, report } = args;
146
+ const reflectedHash = surfaceHash(reflected);
147
+ const lockVersion = bootstrap.lock?.lockVersion ?? 0;
148
+ const inSync = bootstrap.lock?.surfaceHash === reflectedHash;
149
+ const result = {
150
+ inSync,
151
+ reportedDrift: false,
152
+ confirmedRouteIds: []
153
+ };
154
+ const servedKeys = new Set(reflected.map(key));
155
+ const confirmedRouteIds = (bootstrap.routes ?? []).filter(
156
+ (r) => r.pending === true && servedKeys.has(key(r)) && typeof r.id === "string"
157
+ ).map((r) => r.id);
158
+ try {
159
+ if (confirmedRouteIds.length > 0) {
160
+ await report.confirmServed({
161
+ backendId: bootstrap.backendId,
162
+ lockVersion,
163
+ servedRouteIds: confirmedRouteIds
164
+ });
165
+ result.confirmedRouteIds = confirmedRouteIds;
166
+ }
167
+ if (!inSync) {
168
+ await report.reportDrift({
169
+ backendId: bootstrap.backendId,
170
+ lockVersion,
171
+ reflectedSurfaceHash: reflectedHash,
172
+ routes: reflected.map((r) => ({ method: r.method, path: r.path }))
173
+ });
174
+ result.reportedDrift = true;
175
+ }
176
+ } catch (err) {
177
+ result.reportError = err instanceof Error ? err.message : String(err);
178
+ }
179
+ return result;
180
+ }
181
+ var init_reconcile = __esm({
182
+ "src/reflect/reconcile.ts"() {
183
+ "use strict";
184
+ init_route_canon();
185
+ }
186
+ });
187
+
188
+ // src/testing/signers.ts
189
+ import { generateKeyPairSync, randomBytes } from "node:crypto";
190
+
191
+ // src/generated/runtime-contract.ts
192
+ var RUNTIME_BODY_HASH_CONTRACT = {
193
+ algorithm: "SHA-256",
194
+ encoding: "hex-lower",
195
+ source: "raw-request-bytes",
196
+ emptyBodyHash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
197
+ maxBodyBytes: 10485760,
198
+ streamingExemptToken: "STREAM",
199
+ streamingExemptContentTypes: [
200
+ "text/event-stream",
201
+ "application/octet-stream",
202
+ "multipart/form-data"
203
+ ],
204
+ overMaxStatus: 413
205
+ };
206
+ var RUNTIME_ERROR_CODES = {
207
+ missingSignature: "missing_signature",
208
+ malformedSignature: "malformed_signature",
209
+ unknownKeyId: "unknown_key_id",
210
+ jwksUnavailable: "jwks_unavailable",
211
+ badSignature: "bad_signature",
212
+ bodyHashMismatch: "body_hash_mismatch",
213
+ routeMismatch: "route_mismatch",
214
+ clockSkew: "clock_skew",
215
+ expiredSignature: "expired_signature",
216
+ replayedNonce: "replayed_nonce",
217
+ bodyTooLarge: "body_too_large",
218
+ environmentMismatch: "environment_mismatch",
219
+ missingToken: "missing_token",
220
+ invalidToken: "invalid_token",
221
+ contextUnverified: "context_unverified"
222
+ };
223
+ var RUNTIME_RESPONSE_METERING_CONTRACT = {
224
+ headers: {
225
+ payload: "x-fs-metering",
226
+ signature: "x-fs-metering-sig",
227
+ token: "x-fs-metering-token"
228
+ },
229
+ token: {
230
+ environmentVariable: "FS_RUNTIME_TOKEN",
231
+ presentation: "x-fs-metering-token",
232
+ storage: "sha256-hash-only"
233
+ },
234
+ signature: {
235
+ algorithm: "HMAC-SHA256",
236
+ encoding: "base64url",
237
+ input: "payload-json",
238
+ secret: "presented-runtime-token"
239
+ },
240
+ payload: {
241
+ method: "string",
242
+ path: "string",
243
+ rawDimsUnits: "Record<string, number>",
244
+ measureContext: "Record<string, unknown>?",
245
+ creditUnitsConsumed: "Record<string, number>?"
246
+ },
247
+ errors: {
248
+ missingToken: "missing_token",
249
+ invalidMeterKey: "invalid_meter_key",
250
+ invalidMeterValue: "invalid_meter_value"
251
+ },
252
+ httpAdapter: {
253
+ input: "Request",
254
+ output: "Response",
255
+ networkCalls: false,
256
+ preserves: ["body", "headers", "status", "statusText"],
257
+ gatewayStripsInternalHeaders: true
258
+ }
259
+ };
260
+
261
+ // src/runtime-types.ts
262
+ var RUNTIME_ERROR_CODE_TO_ERROR_CODE = {
263
+ // Credential / token presentation faults → UNAUTHORIZED (401).
264
+ [RUNTIME_ERROR_CODES.missingToken]: "UNAUTHORIZED",
265
+ [RUNTIME_ERROR_CODES.invalidToken]: "UNAUTHORIZED",
266
+ // UA-6 — fail-closed signed-context requirement (mirrors contracts).
267
+ [RUNTIME_ERROR_CODES.contextUnverified]: "UNAUTHORIZED",
268
+ // Signature / key faults → UNAUTHORIZED (401, fail-closed verification).
269
+ [RUNTIME_ERROR_CODES.missingSignature]: "UNAUTHORIZED",
270
+ [RUNTIME_ERROR_CODES.malformedSignature]: "UNAUTHORIZED",
271
+ [RUNTIME_ERROR_CODES.unknownKeyId]: "UNAUTHORIZED",
272
+ [RUNTIME_ERROR_CODES.badSignature]: "UNAUTHORIZED",
273
+ [RUNTIME_ERROR_CODES.expiredSignature]: "UNAUTHORIZED",
274
+ // Replay / freshness faults → UNAUTHORIZED (401).
275
+ [RUNTIME_ERROR_CODES.clockSkew]: "UNAUTHORIZED",
276
+ [RUNTIME_ERROR_CODES.replayedNonce]: "UNAUTHORIZED",
277
+ // Request/route binding faults → UNAUTHORIZED (401, fail-closed).
278
+ [RUNTIME_ERROR_CODES.bodyHashMismatch]: "UNAUTHORIZED",
279
+ [RUNTIME_ERROR_CODES.routeMismatch]: "UNAUTHORIZED",
280
+ [RUNTIME_ERROR_CODES.environmentMismatch]: "UNAUTHORIZED",
281
+ // JWKS fetch unavailable — dependency fault (still 401 to the client), but
282
+ // the canonical code keeps the "dependency down" semantic for callers.
283
+ [RUNTIME_ERROR_CODES.jwksUnavailable]: "SERVICE_UNAVAILABLE",
284
+ // The single non-401 (413) — oversized request body.
285
+ [RUNTIME_ERROR_CODES.bodyTooLarge]: "VALIDATION_ERROR"
286
+ };
287
+ var FS_RUNTIME_TOKEN_ENV = "FS_RUNTIME_TOKEN";
288
+ var RUNTIME_TOKEN_PREFIXES = {
289
+ live: "fsrt_live_",
290
+ test: "fsrt_test_"
291
+ };
292
+ var RUNTIME_HEADER_NAMES = {
293
+ signature: "x-fs-signature",
294
+ keyId: "x-fs-key-id",
295
+ requestId: "x-fs-request-id",
296
+ timestamp: "x-fs-timestamp",
297
+ productId: "x-fs-product-id",
298
+ backendId: "x-fs-backend-id",
299
+ routeId: "x-fs-route-id",
300
+ policyVersion: "x-fs-policy-version",
301
+ bodyHash: "x-fs-body-hash"
302
+ };
303
+ var RUNTIME_IDENTITY_HEADER_NAMES = {
304
+ permissions: "x-fs-permissions",
305
+ roles: "x-fs-roles"
306
+ };
307
+ var RUNTIME_CLOCK_SKEW_SECONDS = 5;
308
+ var RUNTIME_REPLAY_WINDOW_SECONDS = 300;
309
+ var EMPTY_BODY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
310
+ var STREAMING_EXEMPT_BODY_HASH = "STREAM";
311
+ var MAX_BODY_BYTES = 10 * 1024 * 1024;
312
+
313
+ // ../contracts/dist/runtime.js
314
+ var FS_RUNTIME_TOKEN_ENV2 = "FS_RUNTIME_TOKEN";
315
+ var RUNTIME_TOKEN_PREFIXES2 = {
316
+ live: "fsrt_live_",
317
+ test: "fsrt_test_"
318
+ };
319
+ function runtimeTokenKind(token) {
320
+ if (token.startsWith(RUNTIME_TOKEN_PREFIXES2.live))
321
+ return "live";
322
+ if (token.startsWith(RUNTIME_TOKEN_PREFIXES2.test))
323
+ return "test";
324
+ return null;
325
+ }
326
+ var RESPONSE_METERING_HEADER_NAMES = {
327
+ payload: "x-fs-metering",
328
+ signature: "x-fs-metering-sig",
329
+ token: "x-fs-metering-token"
330
+ };
331
+ var RESPONSE_METERING_TOKEN_CONTRACT = {
332
+ environmentVariable: FS_RUNTIME_TOKEN_ENV2,
333
+ presentation: RESPONSE_METERING_HEADER_NAMES.token,
334
+ storage: "sha256-hash-only"
335
+ };
336
+ var MAX_BODY_BYTES2 = 10 * 1024 * 1024;
337
+ async function hashBody(body) {
338
+ const bytes = new Uint8Array(body);
339
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
340
+ return toHex(new Uint8Array(digest));
341
+ }
342
+ var CANONICAL_SIGNING_FIELDS = [
343
+ "method",
344
+ "path",
345
+ "query",
346
+ "body-hash",
347
+ "request-id",
348
+ "timestamp",
349
+ "product-id",
350
+ "backend-id",
351
+ "route-id",
352
+ "policy-version"
353
+ ];
354
+ var CANONICAL_FIELD_SEPARATOR = "\n";
355
+ var CANONICAL_KV_SEPARATOR = ":";
356
+ function canonicalizeQuery(query) {
357
+ const raw = query.startsWith("?") ? query.slice(1) : query;
358
+ if (raw === "")
359
+ return "";
360
+ const pairs = raw.split("&").filter((p) => p.length > 0);
361
+ pairs.sort((a, b) => {
362
+ const [an, ...arest] = a.split("=");
363
+ const [bn, ...brest] = b.split("=");
364
+ if (an < bn)
365
+ return -1;
366
+ if (an > bn)
367
+ return 1;
368
+ const av = arest.join("=");
369
+ const bv = brest.join("=");
370
+ if (av < bv)
371
+ return -1;
372
+ if (av > bv)
373
+ return 1;
374
+ return 0;
375
+ });
376
+ return pairs.join("&");
377
+ }
378
+ function buildCanonicalSigningString(input) {
379
+ const values = {
380
+ method: input.method.toUpperCase(),
381
+ path: input.path,
382
+ query: canonicalizeQuery(input.query),
383
+ "body-hash": input.bodyHash,
384
+ "request-id": input.requestId,
385
+ timestamp: String(input.timestamp),
386
+ "product-id": input.productId,
387
+ "backend-id": input.backendId,
388
+ "route-id": input.routeId,
389
+ "policy-version": input.policyVersion
390
+ };
391
+ return CANONICAL_SIGNING_FIELDS.map((field) => `${field}${CANONICAL_KV_SEPARATOR}${values[field]}`).join(CANONICAL_FIELD_SEPARATOR);
392
+ }
393
+ var ED25519_ALGORITHM = "Ed25519";
394
+ async function importEd25519PrivateKey(jwk) {
395
+ return crypto.subtle.importKey("jwk", { ...jwk, alg: void 0 }, { name: ED25519_ALGORITHM }, false, ["sign"]);
396
+ }
397
+ async function importEd25519PublicKey(jwk) {
398
+ return crypto.subtle.importKey("jwk", { ...jwk, alg: void 0 }, { name: ED25519_ALGORITHM }, false, ["verify"]);
399
+ }
400
+ async function signCanonicalString(canonical, privateJwk) {
401
+ const key2 = await importEd25519PrivateKey(privateJwk);
402
+ const sig = await crypto.subtle.sign(ED25519_ALGORITHM, key2, new TextEncoder().encode(canonical));
403
+ return base64UrlEncode(new Uint8Array(sig));
404
+ }
405
+ async function verifyCanonicalSignature(canonical, signatureB64Url, publicJwk) {
406
+ const key2 = await importEd25519PublicKey(publicJwk);
407
+ return crypto.subtle.verify(ED25519_ALGORITHM, key2, base64UrlDecode(signatureB64Url), new TextEncoder().encode(canonical));
408
+ }
409
+ function toHex(bytes) {
410
+ let out = "";
411
+ for (const b of bytes)
412
+ out += b.toString(16).padStart(2, "0");
413
+ return out;
414
+ }
415
+ function base64UrlEncode(bytes) {
416
+ let binary = "";
417
+ for (const b of bytes)
418
+ binary += String.fromCharCode(b);
419
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
420
+ }
421
+ function base64UrlDecode(value) {
422
+ const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
423
+ const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
424
+ const binary = atob(padded);
425
+ const bytes = new Uint8Array(binary.length);
426
+ for (let i = 0; i < binary.length; i += 1)
427
+ bytes[i] = binary.charCodeAt(i);
428
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
429
+ }
430
+
431
+ // src/runtime-signing.ts
432
+ var hashBody2 = hashBody;
433
+ var buildCanonicalSigningString2 = buildCanonicalSigningString;
434
+ var signCanonicalString2 = signCanonicalString;
435
+ var verifyCanonicalSignature2 = verifyCanonicalSignature;
436
+ var runtimeTokenKind2 = runtimeTokenKind;
437
+
438
+ // src/core/errors.ts
439
+ var FartherShoreError = class extends Error {
440
+ code;
441
+ status;
442
+ /** Present only when this error is a self-minted plan-limit deny (rare; the
443
+ * backend usually relays the gateway's descriptor-bearing deny instead). */
444
+ limitDescriptor;
445
+ constructor(code, message, status, limitDescriptor) {
446
+ super(message);
447
+ this.name = "FartherShoreError";
448
+ this.code = code;
449
+ this.status = status ?? statusForCode(code);
450
+ if (limitDescriptor !== void 0) {
451
+ this.limitDescriptor = limitDescriptor;
452
+ }
453
+ }
454
+ };
455
+ function statusForCode(code) {
456
+ return code === "body_too_large" ? 413 : 401;
457
+ }
458
+
459
+ // src/core/jwks.ts
460
+ var DEFAULT_CACHE_TTL_MS = 5 * 6e4;
461
+ var DEFAULT_NEGATIVE_CACHE_MS = 3e4;
462
+ var MAX_NEGATIVE_KIDS = 1e3;
463
+ var JwksClient = class {
464
+ jwksUrl;
465
+ fetchImpl;
466
+ cacheTtlMs;
467
+ negativeCacheMs;
468
+ now;
469
+ keysByKid = /* @__PURE__ */ new Map();
470
+ fetchedAt = 0;
471
+ hasFetchedOnce = false;
472
+ inflight = null;
473
+ negativeKids = /* @__PURE__ */ new Map();
474
+ constructor(options) {
475
+ this.jwksUrl = options.jwksUrl;
476
+ this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
477
+ this.cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
478
+ this.negativeCacheMs = options.negativeCacheMs ?? DEFAULT_NEGATIVE_CACHE_MS;
479
+ this.now = options.now ?? (() => Date.now());
480
+ }
481
+ /**
482
+ * Resolve a public JWK for `kid`, fail-closed. Throws FartherShoreError with
483
+ * `jwks_unavailable` (cold cache + fetch failed) or `unknown_key_id`.
484
+ */
485
+ async getKey(kid) {
486
+ const cached = this.keysByKid.get(kid);
487
+ if (cached && !this.isStale()) return cached;
488
+ const negAt = this.negativeKids.get(kid);
489
+ if (negAt !== void 0 && this.now() - negAt < this.negativeCacheMs) {
490
+ const warm = this.keysByKid.get(kid);
491
+ if (warm) return warm;
492
+ throw new FartherShoreError(
493
+ "unknown_key_id",
494
+ `signing key '${kid}' is not present in the JWKS`
495
+ );
496
+ }
497
+ await this.refresh();
498
+ const key2 = this.keysByKid.get(kid);
499
+ if (key2) {
500
+ this.negativeKids.delete(kid);
501
+ return key2;
502
+ }
503
+ this.rememberMissingKid(kid);
504
+ throw new FartherShoreError(
505
+ "unknown_key_id",
506
+ `signing key '${kid}' is not present in the JWKS`
507
+ );
508
+ }
509
+ /** Record a confirmed-missing kid, evicting the oldest if at capacity. */
510
+ rememberMissingKid(kid) {
511
+ if (!this.negativeKids.has(kid)) {
512
+ while (this.negativeKids.size >= MAX_NEGATIVE_KIDS) {
513
+ const oldest = this.negativeKids.keys().next().value;
514
+ if (oldest === void 0) break;
515
+ this.negativeKids.delete(oldest);
516
+ }
517
+ }
518
+ this.negativeKids.set(kid, this.now());
519
+ }
520
+ isStale() {
521
+ return this.now() - this.fetchedAt >= this.cacheTtlMs;
522
+ }
523
+ /** Single-flight refresh: concurrent callers share one fetch. */
524
+ async refresh() {
525
+ if (this.inflight) return this.inflight;
526
+ this.inflight = this.doFetch().finally(() => {
527
+ this.inflight = null;
528
+ });
529
+ return this.inflight;
530
+ }
531
+ async doFetch() {
532
+ let response;
533
+ try {
534
+ response = await this.fetchImpl(this.jwksUrl, {
535
+ headers: { accept: "application/json" }
536
+ });
537
+ } catch (cause) {
538
+ this.failOnColdCache(cause);
539
+ return;
540
+ }
541
+ if (!response.ok) {
542
+ this.failOnColdCache(
543
+ new Error(`JWKS endpoint returned HTTP ${response.status}`)
544
+ );
545
+ return;
546
+ }
547
+ let doc;
548
+ try {
549
+ doc = await response.json();
550
+ } catch (cause) {
551
+ this.failOnColdCache(cause);
552
+ return;
553
+ }
554
+ const next = /* @__PURE__ */ new Map();
555
+ for (const key2 of doc.keys ?? []) {
556
+ if (typeof key2.kid === "string") next.set(key2.kid, key2);
557
+ }
558
+ this.keysByKid = next;
559
+ this.fetchedAt = this.now();
560
+ this.hasFetchedOnce = true;
561
+ this.negativeKids.clear();
562
+ }
563
+ /**
564
+ * Stale-while-revalidate: with a warm cache, swallow the refresh failure and
565
+ * keep serving the last-known keys. With a COLD cache, fail closed.
566
+ */
567
+ failOnColdCache(cause) {
568
+ if (this.hasFetchedOnce) return;
569
+ throw new FartherShoreError(
570
+ "jwks_unavailable",
571
+ `JWKS unavailable on a cold cache: ${stringifyCause(cause)}`
572
+ );
573
+ }
574
+ };
575
+ function stringifyCause(cause) {
576
+ if (cause instanceof Error) return cause.message;
577
+ return String(cause);
578
+ }
579
+
580
+ // src/testing/signers.ts
581
+ var TEST_KID = "fs-runtime-test-2026";
582
+ var TEST_PRIVATE_JWK = {
583
+ kty: "OKP",
584
+ crv: "Ed25519",
585
+ d: "nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A",
586
+ x: "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"
587
+ };
588
+ var TEST_PUBLIC_JWK = {
589
+ kty: "OKP",
590
+ crv: "Ed25519",
591
+ x: "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"
592
+ };
593
+ var TEST_CONTEXT_SECRET = "fs-dev-context-secret-2026";
594
+ var TEST_CONTEXT_KID = "fs-context-test-2026";
595
+ async function makeSignedRequest(spec = {}) {
596
+ const method = spec.method ?? "POST";
597
+ const path = spec.path ?? "/v1/chat/completions";
598
+ const query = spec.query ?? "";
599
+ const body = spec.body ?? null;
600
+ const streamingExempt = spec.streamingExempt ?? false;
601
+ const privateJwk = spec.privateJwk ?? TEST_PRIVATE_JWK;
602
+ const kid = spec.kid ?? TEST_KID;
603
+ const bodyHash = streamingExempt ? "STREAM" : body && body.byteLength > 0 ? await hashBody2(body) : EMPTY_BODY_SHA256;
604
+ const claim = {
605
+ method,
606
+ path,
607
+ query,
608
+ bodyHash,
609
+ requestId: spec.requestId ?? `req_${cryptoRandom()}`,
610
+ timestamp: spec.timestamp ?? Math.floor(Date.now() / 1e3),
611
+ productId: spec.productId ?? "prod_test",
612
+ backendId: spec.backendId ?? "be_test",
613
+ routeId: spec.routeId ?? "route_test",
614
+ policyVersion: spec.policyVersion ?? "pv_1"
615
+ };
616
+ const canonical = buildCanonicalSigningString2(claim);
617
+ const signature = await signCanonicalString2(canonical, privateJwk);
618
+ const headers = {
619
+ [RUNTIME_HEADER_NAMES.signature]: signature,
620
+ [RUNTIME_HEADER_NAMES.keyId]: kid,
621
+ [RUNTIME_HEADER_NAMES.requestId]: claim.requestId,
622
+ [RUNTIME_HEADER_NAMES.timestamp]: String(claim.timestamp),
623
+ [RUNTIME_HEADER_NAMES.productId]: claim.productId,
624
+ [RUNTIME_HEADER_NAMES.backendId]: claim.backendId,
625
+ [RUNTIME_HEADER_NAMES.routeId]: claim.routeId,
626
+ [RUNTIME_HEADER_NAMES.policyVersion]: claim.policyVersion,
627
+ [RUNTIME_HEADER_NAMES.bodyHash]: claim.bodyHash
628
+ };
629
+ return {
630
+ input: { method, path, query, body, streamingExempt },
631
+ claim,
632
+ headers
633
+ };
634
+ }
635
+ function memoryJwks(keys = [
636
+ { ...TEST_PUBLIC_JWK, kid: TEST_KID }
637
+ ]) {
638
+ const fetchImpl = () => Promise.resolve(
639
+ new Response(JSON.stringify({ keys }), {
640
+ status: 200,
641
+ headers: { "content-type": "application/json" }
642
+ })
643
+ );
644
+ return new JwksClient({
645
+ jwksUrl: "https://core.test/.well-known/jwks.json",
646
+ fetchImpl
647
+ });
648
+ }
649
+ function unreachableJwks() {
650
+ const fetchImpl = () => Promise.reject(new Error("network down"));
651
+ return new JwksClient({
652
+ jwksUrl: "https://core.test/.well-known/jwks.json",
653
+ fetchImpl
654
+ });
655
+ }
656
+ function base64urlEncodeBytes(bytes) {
657
+ let binary = "";
658
+ for (const byte of bytes) binary += String.fromCharCode(byte);
659
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
660
+ }
661
+ function base64urlEncodeJson(value) {
662
+ return base64urlEncodeBytes(new TextEncoder().encode(JSON.stringify(value)));
663
+ }
664
+ async function signContextToken(claim, secret = TEST_CONTEXT_SECRET, kid = TEST_CONTEXT_KID) {
665
+ const header = base64urlEncodeJson({ alg: "HS256", typ: "JWT", kid });
666
+ const payload = base64urlEncodeJson({ cv: 1, ...claim });
667
+ const signingInput = `${header}.${payload}`;
668
+ const key2 = await crypto.subtle.importKey(
669
+ "raw",
670
+ new TextEncoder().encode(secret),
671
+ { name: "HMAC", hash: "SHA-256" },
672
+ false,
673
+ ["sign"]
674
+ );
675
+ const signature = await crypto.subtle.sign(
676
+ "HMAC",
677
+ key2,
678
+ new TextEncoder().encode(signingInput)
679
+ );
680
+ return `${signingInput}.${base64urlEncodeBytes(new Uint8Array(signature))}`;
681
+ }
682
+ function generateDevSignerKeys() {
683
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
684
+ const privateJwk = privateKey.export({ format: "jwk" });
685
+ const publicJwk = publicKey.export({ format: "jwk" });
686
+ const kid = `fs-dev-${randomBytes(6).toString("hex")}`;
687
+ const contextKid = `fs-dev-ctx-${randomBytes(6).toString("hex")}`;
688
+ const contextSecret = randomBytes(32).toString("base64url");
689
+ const runtimeToken = `${RUNTIME_TOKEN_PREFIXES.test}${randomBytes(24).toString("base64url")}`;
690
+ return {
691
+ kid,
692
+ privateJwk,
693
+ publicJwk,
694
+ contextKid,
695
+ contextSecret,
696
+ runtimeToken
697
+ };
698
+ }
699
+ function cryptoRandom() {
700
+ return crypto.randomUUID().replace(/-/g, "").slice(0, 16);
701
+ }
702
+
703
+ // src/testing/personas.ts
704
+ function definePersona(def) {
705
+ return def;
706
+ }
707
+ var DEFAULT_PERSONAS = {
708
+ owner: definePersona({
709
+ name: "owner",
710
+ permissions: ["*"],
711
+ roles: ["owner"],
712
+ subjectKey: "owner"
713
+ }),
714
+ admin: definePersona({
715
+ name: "admin",
716
+ permissions: ["*"],
717
+ roles: ["admin"],
718
+ subjectKey: "admin"
719
+ }),
720
+ member: definePersona({
721
+ name: "member",
722
+ permissions: [],
723
+ roles: ["member"],
724
+ subjectKey: "member"
725
+ }),
726
+ anonymous: definePersona({ name: "anonymous", anonymous: true })
727
+ };
728
+ function normalizeBody(body) {
729
+ if (body === null || body === void 0) return null;
730
+ if (typeof body === "string") return new TextEncoder().encode(body);
731
+ if (body instanceof Uint8Array) return body;
732
+ if (body instanceof ArrayBuffer) return new Uint8Array(body);
733
+ return null;
734
+ }
735
+ function normalizeMethod(method) {
736
+ return (method ?? "GET").toUpperCase();
737
+ }
738
+ function mergeHeaders(initHeaders, signedHeaders) {
739
+ const headers = new Headers(initHeaders);
740
+ for (const [name, value] of Object.entries(signedHeaders)) {
741
+ headers.set(name, value);
742
+ }
743
+ return headers;
744
+ }
745
+ function buildContextClaim(persona, productId) {
746
+ return {
747
+ orgId: persona.orgId ?? "org_dev",
748
+ actor: persona.actor ?? { type: "user", id: `user_${persona.name}` },
749
+ // Product binding: the signed context productId MUST equal the signed
750
+ // request productId or verifyRequest rejects it as tamper evidence.
751
+ productId,
752
+ compiledPlanId: persona.compiledPlanId ?? "plan_dev",
753
+ subscriptionId: persona.subscriptionId ?? "sub_dev",
754
+ subscriberId: persona.subscriberId ?? "subscriber_dev",
755
+ environmentId: persona.environmentId ?? null,
756
+ subjectKey: persona.subjectKey ?? persona.name,
757
+ ...persona.permissions !== void 0 ? { permissions: persona.permissions } : {},
758
+ ...persona.roles !== void 0 ? { roles: persona.roles } : {}
759
+ };
760
+ }
761
+ function createPersonaClient(ctx) {
762
+ function resolve(name) {
763
+ const persona = ctx.personas.get(name);
764
+ if (!persona) {
765
+ throw new Error(
766
+ `unknown persona "${name}" (known: ${[...ctx.personas.keys()].join(", ")})`
767
+ );
768
+ }
769
+ return persona;
770
+ }
771
+ async function buildHeaders(persona, spec) {
772
+ const method = normalizeMethod(spec.method);
773
+ const signed = await makeSignedRequest({
774
+ method,
775
+ path: spec.path ?? "/",
776
+ query: spec.query ?? "",
777
+ body: spec.body ?? null,
778
+ streamingExempt: spec.streamingExempt ?? false,
779
+ productId: ctx.productId,
780
+ backendId: ctx.backendId,
781
+ routeId: spec.routeId ?? "",
782
+ privateJwk: ctx.keys.privateJwk,
783
+ kid: ctx.keys.kid,
784
+ ...spec.requestId ? { requestId: spec.requestId } : {},
785
+ ...spec.timestamp !== void 0 ? { timestamp: spec.timestamp } : {}
786
+ });
787
+ const headers = { ...signed.headers };
788
+ if (!persona.anonymous) {
789
+ const claim = buildContextClaim(persona, ctx.productId);
790
+ headers["x-fs-context"] = await signContextToken(
791
+ claim,
792
+ ctx.contextSecret,
793
+ ctx.contextKid
794
+ );
795
+ }
796
+ return headers;
797
+ }
798
+ function asPersona(name) {
799
+ const persona = resolve(name);
800
+ return {
801
+ persona: name,
802
+ headers: (spec = {}) => buildHeaders(persona, spec),
803
+ async fetch(url, init = {}) {
804
+ const parsed = new URL(url);
805
+ const method = normalizeMethod(init.method);
806
+ const headers = await buildHeaders(persona, {
807
+ method,
808
+ path: parsed.pathname,
809
+ query: parsed.search.replace(/^\?/, ""),
810
+ body: normalizeBody(init.body)
811
+ });
812
+ const doFetch = ctx.fetchImpl ?? globalThis.fetch;
813
+ return doFetch(url, {
814
+ ...init,
815
+ method,
816
+ headers: mergeHeaders(init.headers, headers)
817
+ });
818
+ },
819
+ async inject(req, spec = {}) {
820
+ const rawUrl = spec.path ?? req.url ?? req.path ?? "/";
821
+ const qIndex = rawUrl.indexOf("?");
822
+ const path = qIndex === -1 ? rawUrl : rawUrl.slice(0, qIndex);
823
+ const query = spec.query ?? (qIndex === -1 ? "" : rawUrl.slice(qIndex + 1));
824
+ const method = normalizeMethod(spec.method ?? req.method);
825
+ const headers = await buildHeaders(persona, {
826
+ method,
827
+ path,
828
+ query,
829
+ body: spec.body ?? null,
830
+ ...spec.streamingExempt !== void 0 ? { streamingExempt: spec.streamingExempt } : {},
831
+ ...spec.routeId ? { routeId: spec.routeId } : {}
832
+ });
833
+ for (const [field, value] of Object.entries(headers)) {
834
+ req.set(field, value);
835
+ }
836
+ return req;
837
+ }
838
+ };
839
+ }
840
+ return { asPersona, personas: ctx.personas };
841
+ }
842
+ function buildPersonaMap(overrides) {
843
+ const map = /* @__PURE__ */ new Map();
844
+ for (const [name, def] of Object.entries(DEFAULT_PERSONAS)) {
845
+ map.set(name, def);
846
+ }
847
+ if (Array.isArray(overrides)) {
848
+ for (const def of overrides) map.set(def.name, def);
849
+ } else if (overrides) {
850
+ for (const [name, def] of Object.entries(overrides)) {
851
+ map.set(name, { ...def, name });
852
+ }
853
+ }
854
+ return map;
855
+ }
856
+ var CONTEXT_HEADER_NAME = "x-fs-context";
857
+ var SIGNED_HEADER_NAMES = RUNTIME_HEADER_NAMES;
858
+
859
+ // src/testing/devGateway.ts
860
+ var DEV_CORE_URL = "https://dev-gateway.farthershore.local";
861
+ var DEV_JWKS_URL = `${DEV_CORE_URL}/.well-known/jwks.json`;
862
+ var DEV_METERING_ENDPOINT = `${DEV_CORE_URL}/v1/metering/events`;
863
+ function createDevGateway(options) {
864
+ const productId = options.productId ?? "prod_dev";
865
+ const backendId = options.backendId ?? "be_dev";
866
+ const meterEvents = [];
867
+ const reportUsageEvents = [];
868
+ const bootstrap = {
869
+ product: { id: productId, slug: options.productSlug ?? "dev-product" },
870
+ backend: {
871
+ id: backendId,
872
+ slug: options.backendSlug ?? "dev-backend",
873
+ name: "Dev Backend"
874
+ },
875
+ environment: { id: null, kind: "test" },
876
+ capabilities: ["gateway_verification", "metering", "health"],
877
+ verification: {
878
+ required: options.mode === "simulated",
879
+ jwksUrl: DEV_JWKS_URL,
880
+ clockSkewSeconds: 5,
881
+ replayWindowSeconds: 300,
882
+ headerNames: RUNTIME_HEADER_NAMES
883
+ },
884
+ metering: {
885
+ enabled: true,
886
+ endpoint: DEV_METERING_ENDPOINT,
887
+ credential: options.keys.runtimeToken,
888
+ allowedMeters: [],
889
+ allowedRoutes: [],
890
+ perEventMax: 0
891
+ },
892
+ transport: { mode: "direct", runner: null },
893
+ routes: (options.routeIds ?? []).map((id) => ({
894
+ id,
895
+ method: "POST",
896
+ path: `/${id}`,
897
+ backendId
898
+ })),
899
+ policyVersion: "pv_dev",
900
+ refreshAfterSeconds: 3600
901
+ };
902
+ const jwksDoc = {
903
+ keys: [{ ...options.keys.publicJwk, kid: options.keys.kid }]
904
+ };
905
+ const fetchImpl = async (input, init) => {
906
+ const url = String(
907
+ typeof input === "string" || input instanceof URL ? input : input.url
908
+ );
909
+ if (url.includes("/v1/runtime/bootstrap")) {
910
+ return json(bootstrap);
911
+ }
912
+ if (url.includes("/.well-known/jwks.json") || url.includes("jwks")) {
913
+ return json(jwksDoc);
914
+ }
915
+ if (url.includes("/v1/metering/events")) {
916
+ const event = await readJsonBody(init, input);
917
+ if (event && "meters" in event) {
918
+ reportUsageEvents.push(event);
919
+ options.onReportUsage?.(event);
920
+ } else if (event) {
921
+ meterEvents.push(event);
922
+ options.onMeterEvent?.(event);
923
+ }
924
+ return json({ ok: true });
925
+ }
926
+ if (url.includes("/v1/runtime/health") || url.includes("/v1/runtime/drift")) {
927
+ return json({ ok: true });
928
+ }
929
+ return new Response(JSON.stringify({ error: "not_found" }), {
930
+ status: 404,
931
+ headers: { "content-type": "application/json" }
932
+ });
933
+ };
934
+ return {
935
+ fetchImpl,
936
+ bootstrap,
937
+ meterEvents,
938
+ reportUsageEvents,
939
+ productId,
940
+ backendId,
941
+ jwksUrl: DEV_JWKS_URL
942
+ };
943
+ }
944
+ function json(body, status = 200) {
945
+ return new Response(JSON.stringify(body), {
946
+ status,
947
+ headers: { "content-type": "application/json" }
948
+ });
949
+ }
950
+ async function readJsonBody(init, input) {
951
+ try {
952
+ if (init?.body && typeof init.body === "string") {
953
+ return JSON.parse(init.body);
954
+ }
955
+ if (input instanceof Request) {
956
+ return await input.clone().json();
957
+ }
958
+ } catch {
959
+ return null;
960
+ }
961
+ return null;
962
+ }
963
+
964
+ // src/testing/devRuntime.ts
965
+ import { appendFileSync, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2 } from "node:fs";
966
+ import { dirname as dirname2 } from "node:path";
967
+
968
+ // src/core/bootstrap.ts
969
+ var BOOTSTRAP_PATH = "/v1/runtime/bootstrap";
970
+ var DEFAULT_MIN_REFRESH_SECONDS = 30;
971
+ var BootstrapClient = class {
972
+ runtimeToken;
973
+ endpoint;
974
+ request;
975
+ fetchImpl;
976
+ now;
977
+ minRefreshSeconds;
978
+ cached = null;
979
+ fetchedAt = 0;
980
+ refreshAfterMs = 0;
981
+ inflight = null;
982
+ constructor(options) {
983
+ if (!options.runtimeToken) {
984
+ throw new FartherShoreError(
985
+ "missing_token",
986
+ `${FS_RUNTIME_TOKEN_ENV} is required to bootstrap the Farther Shore backend SDK`
987
+ );
988
+ }
989
+ if (runtimeTokenKind2(options.runtimeToken) === null) {
990
+ throw new FartherShoreError(
991
+ "invalid_token",
992
+ `${FS_RUNTIME_TOKEN_ENV} must start with fsrt_live_ or fsrt_test_`
993
+ );
994
+ }
995
+ this.runtimeToken = options.runtimeToken;
996
+ this.endpoint = joinUrl(options.coreUrl, BOOTSTRAP_PATH);
997
+ this.request = options.request ?? {};
998
+ this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
999
+ this.now = options.now ?? (() => Date.now());
1000
+ this.minRefreshSeconds = options.minRefreshSeconds ?? DEFAULT_MIN_REFRESH_SECONDS;
1001
+ }
1002
+ /** Cached config when fresh; otherwise refreshes. */
1003
+ async get() {
1004
+ if (this.cached && !this.isStale()) return this.cached;
1005
+ return this.refresh();
1006
+ }
1007
+ /** Force a network refresh (single-flight). */
1008
+ async refresh() {
1009
+ if (this.inflight) return this.inflight;
1010
+ this.inflight = this.doBootstrap().finally(() => {
1011
+ this.inflight = null;
1012
+ });
1013
+ return this.inflight;
1014
+ }
1015
+ /** Last cached value without triggering a refresh (null until bootstrapped). */
1016
+ peek() {
1017
+ return this.cached;
1018
+ }
1019
+ isStale() {
1020
+ return this.now() - this.fetchedAt >= this.refreshAfterMs;
1021
+ }
1022
+ async doBootstrap() {
1023
+ let response;
1024
+ try {
1025
+ response = await this.fetchImpl(this.endpoint, {
1026
+ method: "POST",
1027
+ headers: {
1028
+ authorization: `Bearer ${this.runtimeToken}`,
1029
+ "content-type": "application/json",
1030
+ accept: "application/json"
1031
+ },
1032
+ body: JSON.stringify(this.request)
1033
+ });
1034
+ } catch (cause) {
1035
+ if (this.cached) return this.cached;
1036
+ throw new FartherShoreError(
1037
+ "jwks_unavailable",
1038
+ `bootstrap request failed: ${stringify(cause)}`
1039
+ );
1040
+ }
1041
+ if (response.status === 401 || response.status === 403) {
1042
+ throw new FartherShoreError(
1043
+ "invalid_token",
1044
+ `${FS_RUNTIME_TOKEN_ENV} was rejected by core (HTTP ${response.status})`
1045
+ );
1046
+ }
1047
+ if (!response.ok) {
1048
+ if (this.cached) return this.cached;
1049
+ throw new FartherShoreError(
1050
+ "jwks_unavailable",
1051
+ `bootstrap returned HTTP ${response.status}`
1052
+ );
1053
+ }
1054
+ const body = await response.json();
1055
+ this.cached = body;
1056
+ this.fetchedAt = this.now();
1057
+ const refreshSeconds = Math.max(
1058
+ this.minRefreshSeconds,
1059
+ body.refreshAfterSeconds || this.minRefreshSeconds
1060
+ );
1061
+ this.refreshAfterMs = refreshSeconds * 1e3;
1062
+ return body;
1063
+ }
1064
+ };
1065
+ function joinUrl(base, path) {
1066
+ return `${base.replace(/\/+$/, "")}${path}`;
1067
+ }
1068
+ function stringify(cause) {
1069
+ return cause instanceof Error ? cause.message : String(cause);
1070
+ }
1071
+
1072
+ // src/core/health.ts
1073
+ function buildHealthReport(snapshot) {
1074
+ return {
1075
+ runtimeToken: snapshot.runtimeToken,
1076
+ bootstrap: snapshot.bootstrap,
1077
+ tunnel: snapshot.tunnel,
1078
+ verification: snapshot.verification,
1079
+ metering: snapshot.metering
1080
+ };
1081
+ }
1082
+ var HEALTH_PATH = "/v1/runtime/health";
1083
+ async function reportHealth(options) {
1084
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
1085
+ const endpoint = `${options.coreUrl.replace(/\/+$/, "")}${HEALTH_PATH}`;
1086
+ try {
1087
+ const response = await fetchImpl(endpoint, {
1088
+ method: "POST",
1089
+ headers: {
1090
+ authorization: `Bearer ${options.runtimeToken}`,
1091
+ "content-type": "application/json"
1092
+ },
1093
+ body: JSON.stringify({
1094
+ status: options.status,
1095
+ ...options.instanceId ? { instanceId: options.instanceId } : {}
1096
+ })
1097
+ });
1098
+ return response.ok;
1099
+ } catch {
1100
+ return false;
1101
+ }
1102
+ }
1103
+
1104
+ // src/core/backoff.ts
1105
+ function computeBackoff(attempt, options) {
1106
+ const { baseMs, maxMs, jitter = "equal", random = Math.random } = options;
1107
+ const exponent = Math.max(0, attempt - 1);
1108
+ const cap = Math.min(baseMs * 2 ** exponent, maxMs);
1109
+ switch (jitter) {
1110
+ case "none":
1111
+ return cap;
1112
+ case "full":
1113
+ return random() * cap;
1114
+ case "equal":
1115
+ default:
1116
+ return cap / 2 + random() * (cap / 2);
1117
+ }
1118
+ }
1119
+
1120
+ // src/core/metering.ts
1121
+ var METER_KEY_RE = /^[a-z0-9_]{1,64}$/;
1122
+ var DEFAULT_BASE_DELAY_MS = 200;
1123
+ var DEFAULT_MAX_DELAY_MS = 1e4;
1124
+ function isTransientStatus(status) {
1125
+ return status === 429 || status >= 500;
1126
+ }
1127
+ function retryAfterMs(headers) {
1128
+ const raw = headers.get("retry-after");
1129
+ if (raw === null) return null;
1130
+ const trimmed = raw.trim();
1131
+ if (!/^\d+$/.test(trimmed)) return null;
1132
+ const secs = Number(trimmed);
1133
+ return Number.isFinite(secs) ? secs * 1e3 : null;
1134
+ }
1135
+ var DEFAULT_MAX_RETRIES = 3;
1136
+ var MeteringClient = class {
1137
+ config;
1138
+ endpoint;
1139
+ productId;
1140
+ backendId;
1141
+ fetchImpl;
1142
+ maxRetries;
1143
+ baseDelayMs;
1144
+ maxDelayMs;
1145
+ sleep;
1146
+ random;
1147
+ newId;
1148
+ now;
1149
+ buffer = [];
1150
+ constructor(options) {
1151
+ this.config = options.config;
1152
+ this.endpoint = resolveEndpoint(options.config.endpoint, options.coreUrl);
1153
+ this.productId = options.productId;
1154
+ this.backendId = options.backendId;
1155
+ this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
1156
+ this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
1157
+ this.baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
1158
+ this.maxDelayMs = options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
1159
+ this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
1160
+ this.random = options.random ?? Math.random;
1161
+ this.newId = options.newId ?? (() => crypto.randomUUID());
1162
+ this.now = options.now ?? (() => /* @__PURE__ */ new Date());
1163
+ }
1164
+ /**
1165
+ * Record `qty` of `meter`. Enforces meter-key shape, non-negative finite qty,
1166
+ * the bootstrap allowedMeters/allowedRoutes scope, and the per-event sanity
1167
+ * max, then enqueues and flushes (best-effort; failures stay buffered).
1168
+ */
1169
+ async meter(meter, qty, options = {}) {
1170
+ if (!this.config.enabled) {
1171
+ throw new FartherShoreError(
1172
+ "invalid_token",
1173
+ "metering is not enabled for this runtime token"
1174
+ );
1175
+ }
1176
+ if (!METER_KEY_RE.test(meter)) {
1177
+ throw new FartherShoreError(
1178
+ "invalid_token",
1179
+ `meter key '${meter}' must be lowercase alphanumeric with underscores`
1180
+ );
1181
+ }
1182
+ if (!Number.isFinite(qty) || qty < 0) {
1183
+ throw new FartherShoreError(
1184
+ "invalid_token",
1185
+ `meter '${meter}' qty must be a non-negative finite number`
1186
+ );
1187
+ }
1188
+ if (this.config.allowedMeters.length > 0 && !this.config.allowedMeters.includes(meter)) {
1189
+ throw new FartherShoreError(
1190
+ "invalid_token",
1191
+ `meter '${meter}' is not in the token's allowedMeters`
1192
+ );
1193
+ }
1194
+ if (this.config.allowedRoutes.length > 0) {
1195
+ if (!options.routeId) {
1196
+ throw new FartherShoreError(
1197
+ "invalid_token",
1198
+ "routeId is required because this runtime token is route-scoped"
1199
+ );
1200
+ }
1201
+ if (!this.config.allowedRoutes.includes(options.routeId)) {
1202
+ throw new FartherShoreError(
1203
+ "invalid_token",
1204
+ `route '${options.routeId}' is not in the token's allowedRoutes`
1205
+ );
1206
+ }
1207
+ }
1208
+ if (this.config.perEventMax > 0 && qty > this.config.perEventMax) {
1209
+ throw new FartherShoreError(
1210
+ "invalid_token",
1211
+ `meter '${meter}' qty ${qty} exceeds the per-event max ${this.config.perEventMax}`
1212
+ );
1213
+ }
1214
+ const event = {
1215
+ event_id: options.eventId ?? this.newId(),
1216
+ product_id: this.productId,
1217
+ backend_id: this.backendId,
1218
+ meter,
1219
+ qty,
1220
+ timestamp: options.timestamp ?? this.now().toISOString(),
1221
+ ...options.routeId ? { route_id: options.routeId } : {},
1222
+ ...options.requestId ? { request_id: options.requestId } : {}
1223
+ };
1224
+ this.buffer.push(event);
1225
+ await this.flush();
1226
+ }
1227
+ /** Drain the buffer. Events that fail all retries stay buffered (at-least-once). */
1228
+ async flush() {
1229
+ const pending = this.buffer.splice(0, this.buffer.length);
1230
+ const stillPending = [];
1231
+ for (const event of pending) {
1232
+ const sent = await this.sendWithRetry(event);
1233
+ if (!sent) stillPending.push(event);
1234
+ }
1235
+ if (stillPending.length > 0) this.buffer.unshift(...stillPending);
1236
+ }
1237
+ /** Buffered-but-unsent count (observability/tests). */
1238
+ get pending() {
1239
+ return this.buffer.length;
1240
+ }
1241
+ async sendWithRetry(event) {
1242
+ for (let attempt = 0; attempt < this.maxRetries; attempt += 1) {
1243
+ let retryAfter = null;
1244
+ try {
1245
+ const response = await this.fetchImpl(this.endpoint, {
1246
+ method: "POST",
1247
+ headers: {
1248
+ authorization: `Bearer ${this.config.credential}`,
1249
+ "content-type": "application/json",
1250
+ accept: "application/json"
1251
+ },
1252
+ body: JSON.stringify(event)
1253
+ });
1254
+ if (response.ok) return true;
1255
+ if (!isTransientStatus(response.status)) return false;
1256
+ retryAfter = retryAfterMs(response.headers);
1257
+ } catch {
1258
+ }
1259
+ const isLast = attempt === this.maxRetries - 1;
1260
+ if (isLast) break;
1261
+ const delay = retryAfter !== null ? Math.min(retryAfter, this.maxDelayMs) : computeBackoff(attempt + 1, {
1262
+ baseMs: this.baseDelayMs,
1263
+ maxMs: this.maxDelayMs,
1264
+ random: this.random
1265
+ });
1266
+ await this.sleep(delay);
1267
+ }
1268
+ return false;
1269
+ }
1270
+ };
1271
+ function resolveEndpoint(endpoint, coreUrl) {
1272
+ if (/^https?:\/\//.test(endpoint)) return endpoint;
1273
+ if (!coreUrl) return endpoint;
1274
+ return `${coreUrl.replace(/\/+$/, "")}${endpoint}`;
1275
+ }
1276
+
1277
+ // src/response-metering.ts
1278
+ var RESPONSE_METERING_HEADERS = RUNTIME_RESPONSE_METERING_CONTRACT.headers;
1279
+ var RESPONSE_METERING_ERROR_CODES = RUNTIME_RESPONSE_METERING_CONTRACT.errors;
1280
+ var devMeteringHooks = null;
1281
+ function __setDevMeteringHooks(hooks) {
1282
+ devMeteringHooks = hooks;
1283
+ }
1284
+ var METERING_PAYLOAD_HEADER = RESPONSE_METERING_HEADERS.payload;
1285
+ var METERING_SIGNATURE_HEADER = RESPONSE_METERING_HEADERS.signature;
1286
+ var METERING_TOKEN_HEADER = RESPONSE_METERING_HEADERS.token;
1287
+ var DEFAULT_TOKEN_ENV = RUNTIME_RESPONSE_METERING_CONTRACT.token.environmentVariable;
1288
+ async function signPayload(payload, token) {
1289
+ const key2 = await crypto.subtle.importKey(
1290
+ "raw",
1291
+ new TextEncoder().encode(token),
1292
+ { name: "HMAC", hash: "SHA-256" },
1293
+ false,
1294
+ ["sign"]
1295
+ );
1296
+ const signature = await crypto.subtle.sign(
1297
+ "HMAC",
1298
+ key2,
1299
+ new TextEncoder().encode(payload)
1300
+ );
1301
+ return base64url(new Uint8Array(signature));
1302
+ }
1303
+ function base64url(bytes) {
1304
+ let binary = "";
1305
+ for (const byte of bytes) {
1306
+ binary += String.fromCharCode(byte);
1307
+ }
1308
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1309
+ }
1310
+
1311
+ // src/core/post-stream-usage.ts
1312
+ var METER_KEY_RE2 = /^[a-z0-9_]{1,64}$/;
1313
+ var PostStreamUsageClient = class {
1314
+ config;
1315
+ endpoint;
1316
+ fetchImpl;
1317
+ newNonce;
1318
+ logger;
1319
+ sleep;
1320
+ retryDelaysMs;
1321
+ constructor(options) {
1322
+ this.config = options.config;
1323
+ this.endpoint = resolveEndpoint2(options.config.endpoint, options.coreUrl);
1324
+ this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
1325
+ this.newNonce = options.newNonce ?? (() => crypto.randomUUID());
1326
+ this.logger = options.logger ?? ((message) => console.warn(message));
1327
+ this.sleep = options.sleep ?? sleep;
1328
+ this.retryDelaysMs = options.retryDelaysMs ?? [100, 250, 500];
1329
+ }
1330
+ async reportUsage(input) {
1331
+ try {
1332
+ if (!this.config.enabled) throw new Error("metering is not enabled");
1333
+ if (!input.requestId) throw new Error("requestId is required");
1334
+ if (!input.subscriptionId) throw new Error("subscriptionId is required");
1335
+ const unsigned = {
1336
+ requestId: input.requestId,
1337
+ subscriptionId: input.subscriptionId,
1338
+ nonce: this.newNonce(),
1339
+ meters: validateAndSortUsage(input.meters, "meters", this.config, true),
1340
+ ...input.creditUnitsConsumed ? {
1341
+ creditUnitsConsumed: validateAndSortUsage(
1342
+ input.creditUnitsConsumed,
1343
+ "creditUnitsConsumed",
1344
+ this.config,
1345
+ false
1346
+ )
1347
+ } : {},
1348
+ ...input.measureContext ? { measureContext: input.measureContext } : {}
1349
+ };
1350
+ const signature = await signPayload(
1351
+ JSON.stringify(unsigned),
1352
+ this.config.credential
1353
+ );
1354
+ const event = { ...unsigned, signature };
1355
+ const body = JSON.stringify(event);
1356
+ for (let attempt = 0; ; attempt += 1) {
1357
+ const response = await this.fetchImpl(this.endpoint, {
1358
+ method: "POST",
1359
+ headers: {
1360
+ authorization: `Bearer ${this.config.credential}`,
1361
+ "content-type": "application/json",
1362
+ accept: "application/json"
1363
+ },
1364
+ body
1365
+ });
1366
+ if (response.ok) return { ok: true };
1367
+ const requestNotFound = await isPostStreamRequestNotFound(response);
1368
+ const delayMs = this.retryDelaysMs[attempt];
1369
+ if (!requestNotFound || delayMs === void 0) {
1370
+ throw new Error(`metering endpoint returned ${response.status}`);
1371
+ }
1372
+ await this.sleep(delayMs);
1373
+ }
1374
+ } catch (error) {
1375
+ const reason = error instanceof Error ? error.message : String(error);
1376
+ this.logger(`post-stream usage report skipped: ${reason}`);
1377
+ return { ok: false, reason };
1378
+ }
1379
+ }
1380
+ };
1381
+ async function isPostStreamRequestNotFound(response) {
1382
+ if (response.status !== 422) return false;
1383
+ try {
1384
+ const body = await response.json();
1385
+ return body.error?.code === "post_stream_request_not_found";
1386
+ } catch {
1387
+ return false;
1388
+ }
1389
+ }
1390
+ function sleep(delayMs) {
1391
+ return new Promise((resolve) => setTimeout(resolve, delayMs));
1392
+ }
1393
+ function validateAndSortUsage(usage, label, config, enforceMeterScope) {
1394
+ const entries = Object.entries(usage).sort(
1395
+ ([a], [b]) => a < b ? -1 : a > b ? 1 : 0
1396
+ );
1397
+ for (const [meter, qty] of entries) {
1398
+ if (!METER_KEY_RE2.test(meter)) {
1399
+ throw new Error(
1400
+ `${label} key '${meter}' must be lowercase alphanumeric with underscores`
1401
+ );
1402
+ }
1403
+ if (!Number.isFinite(qty) || qty < 0) {
1404
+ throw new Error(`${label}.${meter} must be a non-negative finite number`);
1405
+ }
1406
+ if (enforceMeterScope && config.allowedMeters.length > 0 && !config.allowedMeters.includes(meter)) {
1407
+ throw new Error(`meter '${meter}' is not in the token's allowedMeters`);
1408
+ }
1409
+ if (enforceMeterScope && config.perEventMax > 0 && qty > config.perEventMax) {
1410
+ throw new Error(
1411
+ `meter '${meter}' qty ${qty} exceeds the per-event max ${config.perEventMax}`
1412
+ );
1413
+ }
1414
+ }
1415
+ return Object.fromEntries(entries);
1416
+ }
1417
+ function resolveEndpoint2(endpoint, coreUrl) {
1418
+ if (/^https?:\/\//.test(endpoint)) return endpoint;
1419
+ if (!coreUrl) return endpoint;
1420
+ return `${coreUrl.replace(/\/+$/, "")}${endpoint}`;
1421
+ }
1422
+
1423
+ // src/core/nonceCache.ts
1424
+ var DEFAULT_MAX_ENTRIES = 1e5;
1425
+ var DEFAULT_TTL_MS = 6e5;
1426
+ var NonceCache = class {
1427
+ maxEntries;
1428
+ ttlMs;
1429
+ now;
1430
+ // insertion-ordered Map ⇒ first key is the oldest (LRU eviction).
1431
+ seen = /* @__PURE__ */ new Map();
1432
+ constructor(options = {}) {
1433
+ this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
1434
+ this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
1435
+ this.now = options.now ?? (() => Date.now());
1436
+ }
1437
+ /**
1438
+ * @returns true if `id` was already seen (a REPLAY); false on first sight
1439
+ * (the id is then remembered).
1440
+ */
1441
+ checkAndRemember(id) {
1442
+ const at = this.now();
1443
+ const previous = this.seen.get(id);
1444
+ if (previous !== void 0) {
1445
+ if (at - previous < this.ttlMs) return true;
1446
+ this.seen.delete(id);
1447
+ }
1448
+ this.evictExpired(at);
1449
+ this.evictOverflow();
1450
+ this.seen.set(id, at);
1451
+ return false;
1452
+ }
1453
+ /** Number of retained nonces (test/observability hook). */
1454
+ get size() {
1455
+ return this.seen.size;
1456
+ }
1457
+ evictExpired(at) {
1458
+ for (const [id, ts] of this.seen) {
1459
+ if (at - ts < this.ttlMs) break;
1460
+ this.seen.delete(id);
1461
+ }
1462
+ }
1463
+ evictOverflow() {
1464
+ while (this.seen.size >= this.maxEntries) {
1465
+ const oldest = this.seen.keys().next().value;
1466
+ if (oldest === void 0) break;
1467
+ this.seen.delete(oldest);
1468
+ }
1469
+ }
1470
+ };
1471
+
1472
+ // src/core/shutdown.ts
1473
+ var ShutdownManager = class {
1474
+ hooks = [];
1475
+ done = false;
1476
+ register(hook) {
1477
+ this.hooks.push(hook);
1478
+ }
1479
+ get isShutDown() {
1480
+ return this.done;
1481
+ }
1482
+ /** Run all hooks in reverse order, isolating failures. Idempotent. */
1483
+ async shutdown() {
1484
+ if (this.done) return;
1485
+ this.done = true;
1486
+ for (let i = this.hooks.length - 1; i >= 0; i -= 1) {
1487
+ try {
1488
+ await this.hooks[i]();
1489
+ } catch {
1490
+ }
1491
+ }
1492
+ }
1493
+ };
1494
+
1495
+ // src/core/tunnel.ts
1496
+ import { spawn as nodeChildSpawn } from "node:child_process";
1497
+ import { createRequire } from "node:module";
1498
+ var REDACTED_TOKEN = "***REDACTED***";
1499
+ var CLOUDFLARED_RUN_ARGS = [
1500
+ "tunnel",
1501
+ "--no-autoupdate",
1502
+ "run",
1503
+ "--token"
1504
+ ];
1505
+ var CLOUDFLARED_BINARY_PACKAGES = {
1506
+ "linux-x64": "@farthershore/cloudflared-linux-x64",
1507
+ "linux-arm64": "@farthershore/cloudflared-linux-arm64",
1508
+ "darwin-arm64": "@farthershore/cloudflared-darwin-arm64",
1509
+ "darwin-x64": "@farthershore/cloudflared-darwin-x64"
1510
+ };
1511
+ var DEFAULT_BASE_BACKOFF_MS = 1e3;
1512
+ var DEFAULT_MAX_BACKOFF_MS = 6e4;
1513
+ var DEFAULT_BINARY = "cloudflared";
1514
+ var MAX_LOG_LINE_BYTES = 64 * 1024;
1515
+ var CloudflaredSupervisor = class {
1516
+ tunnelToken;
1517
+ spawn;
1518
+ binaryPath;
1519
+ locateBinary;
1520
+ logger;
1521
+ onError;
1522
+ failClosed;
1523
+ baseBackoffMs;
1524
+ maxBackoffMs;
1525
+ backoffJitter;
1526
+ random;
1527
+ setTimeoutFn;
1528
+ clearTimeoutFn;
1529
+ childEnv;
1530
+ child = null;
1531
+ state = "stopped";
1532
+ restarts = 0;
1533
+ consecutiveFailures = 0;
1534
+ lastError = null;
1535
+ intentionalStop = false;
1536
+ restartTimer = null;
1537
+ signalsBound = false;
1538
+ signalHandler = () => {
1539
+ void this.shutdown();
1540
+ };
1541
+ constructor(options) {
1542
+ if (!options.tunnelToken) {
1543
+ throw new Error("CloudflaredSupervisor requires a tunnel token");
1544
+ }
1545
+ this.tunnelToken = options.tunnelToken;
1546
+ this.spawn = options.spawn;
1547
+ this.binaryPath = options.binaryPath;
1548
+ this.locateBinary = options.locateBinary ?? (() => defaultLocateBinary());
1549
+ this.logger = options.logger ?? ((line) => console.error(line));
1550
+ this.onError = options.onError;
1551
+ this.failClosed = options.failClosed ?? false;
1552
+ this.baseBackoffMs = options.baseBackoffMs ?? DEFAULT_BASE_BACKOFF_MS;
1553
+ this.maxBackoffMs = options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
1554
+ this.backoffJitter = options.backoffJitter ?? "equal";
1555
+ this.random = options.random ?? Math.random;
1556
+ this.setTimeoutFn = options.setTimeoutFn ?? ((cb, ms) => setTimeout(cb, ms));
1557
+ this.clearTimeoutFn = options.clearTimeoutFn ?? ((h) => clearTimeout(h));
1558
+ this.childEnv = options.childEnv;
1559
+ }
1560
+ /**
1561
+ * Resolve the binary, spawn the child, wire log piping + exit handling, and
1562
+ * bind SIGTERM/SIGINT. Fail-open by default (resolves; error in `status()`);
1563
+ * fail-closed when configured (rejects).
1564
+ *
1565
+ * Async by contract: the public lifecycle API (and a future binary
1566
+ * download/health-probe step) is Promise-returning even when today's body is
1567
+ * synchronous, so callers can always `await fs.start()`.
1568
+ */
1569
+ // eslint-disable-next-line @typescript-eslint/require-await
1570
+ async start() {
1571
+ this.intentionalStop = false;
1572
+ this.bindSignals();
1573
+ try {
1574
+ this.spawnChild();
1575
+ } catch (error) {
1576
+ this.handleStartFailure(error);
1577
+ }
1578
+ }
1579
+ /**
1580
+ * Intentional graceful stop: cancel any pending restart, signal the child
1581
+ * (SIGTERM), unbind signals, and mark the supervisor stopped. Any exit that
1582
+ * the kill provokes will NOT trigger a restart. Async by lifecycle contract.
1583
+ */
1584
+ // eslint-disable-next-line @typescript-eslint/require-await
1585
+ async shutdown() {
1586
+ this.intentionalStop = true;
1587
+ if (this.restartTimer !== null) {
1588
+ this.clearTimeoutFn(this.restartTimer);
1589
+ this.restartTimer = null;
1590
+ }
1591
+ this.unbindSignals();
1592
+ const child = this.child;
1593
+ this.child = null;
1594
+ this.state = "stopped";
1595
+ if (child) {
1596
+ try {
1597
+ child.kill("SIGTERM");
1598
+ } catch (error) {
1599
+ this.recordError(error);
1600
+ }
1601
+ }
1602
+ }
1603
+ /** Token-free status snapshot for diagnostics / health. */
1604
+ status() {
1605
+ return {
1606
+ state: this.state,
1607
+ pid: this.child?.pid ?? null,
1608
+ restarts: this.restarts,
1609
+ lastError: this.lastError
1610
+ };
1611
+ }
1612
+ /** Compact health string for `fs.health().tunnel`. */
1613
+ healthString() {
1614
+ return this.state;
1615
+ }
1616
+ // --- internals ------------------------------------------------------------
1617
+ spawnChild() {
1618
+ this.state = "starting";
1619
+ const command = this.resolveBinary();
1620
+ const args = [...CLOUDFLARED_RUN_ARGS, this.tunnelToken];
1621
+ const child = this.spawn(
1622
+ command,
1623
+ args,
1624
+ this.childEnv ? { env: this.childEnv } : void 0
1625
+ );
1626
+ this.child = child;
1627
+ this.state = "running";
1628
+ this.pipeLogs(child);
1629
+ child.on("exit", (code, signal) => this.handleExit(code, signal));
1630
+ }
1631
+ /**
1632
+ * Resolve the cloudflared binary path. Order:
1633
+ * 1. an installed `@farthershore/cloudflared-<platform>` optional-dependency
1634
+ * matching process.platform+arch (the injected locator), then
1635
+ * 2. an explicit `binaryPath` supplied by the host, then
1636
+ * 3. the bare `cloudflared` name on PATH (resolved at spawn time).
1637
+ *
1638
+ * On an unsupported arch (no optional dep, no binaryPath) AND no usable PATH
1639
+ * fallback, this raises a clear, redacted error pointing at the sidecar — it
1640
+ * NEVER downloads a binary at runtime. (Cross-platform binary management is the
1641
+ * optional-dep packages' job, populated at publish time.)
1642
+ */
1643
+ resolveBinary() {
1644
+ const located = this.locateBinary();
1645
+ if (located) return located;
1646
+ if (this.binaryPath) return this.binaryPath;
1647
+ if (currentBinaryPackageName() !== null) return DEFAULT_BINARY;
1648
+ throw new Error(
1649
+ "cloudflared binary not found for this platform/arch \u2014 install an @farthershore/cloudflared-<platform> package, supply binaryPath, or run the sidecar runner instead (no binary is downloaded at runtime)."
1650
+ );
1651
+ }
1652
+ /** Pipe stdout/stderr to the logger with the tunnel token redacted. */
1653
+ pipeLogs(child) {
1654
+ child.stdout?.on("data", this.makeLineSink());
1655
+ child.stderr?.on("data", this.makeLineSink());
1656
+ }
1657
+ /**
1658
+ * Build a per-stream `data` handler that BUFFERS partial lines across chunks
1659
+ * before redacting. The OS can deliver a single log line in two `data` events
1660
+ * with the boundary mid-token; redacting each chunk independently would let
1661
+ * the two token halves slip through. Buffering until a newline reassembles
1662
+ * the full line (a token never contains a newline), so redaction always sees
1663
+ * the whole token. A trailing partial is held for the next chunk, or flushed
1664
+ * if it grows past a safety cap (cloudflared is line-oriented, so this is a
1665
+ * belt-and-suspenders guard against unbounded buffer growth).
1666
+ */
1667
+ makeLineSink() {
1668
+ let buffer = "";
1669
+ return (chunk) => {
1670
+ buffer += chunkToString(chunk);
1671
+ if (buffer.length > MAX_LOG_LINE_BYTES) {
1672
+ this.emitLogLine(buffer);
1673
+ buffer = "";
1674
+ return;
1675
+ }
1676
+ const parts = buffer.split("\n");
1677
+ buffer = parts.pop() ?? "";
1678
+ for (const part of parts) {
1679
+ this.emitLogLine(part.replace(/\r$/, ""));
1680
+ }
1681
+ };
1682
+ }
1683
+ emitLogLine(line) {
1684
+ if (line.length === 0) return;
1685
+ this.logger(this.redact(line));
1686
+ }
1687
+ /** Replace every occurrence of the tunnel token with the sentinel. */
1688
+ redact(text) {
1689
+ if (this.tunnelToken.length === 0) return text;
1690
+ return text.split(this.tunnelToken).join(REDACTED_TOKEN);
1691
+ }
1692
+ handleExit(code, _signal) {
1693
+ this.child = null;
1694
+ if (this.intentionalStop) {
1695
+ this.state = "stopped";
1696
+ return;
1697
+ }
1698
+ this.consecutiveFailures += 1;
1699
+ this.lastError = this.redact(
1700
+ `cloudflared exited unexpectedly (code=${String(code)})`
1701
+ );
1702
+ this.state = "restarting";
1703
+ this.scheduleRestart();
1704
+ }
1705
+ scheduleRestart() {
1706
+ const delay = this.backoffDelay();
1707
+ this.restartTimer = this.setTimeoutFn(() => {
1708
+ this.restartTimer = null;
1709
+ if (this.intentionalStop) return;
1710
+ this.restarts += 1;
1711
+ try {
1712
+ this.spawnChild();
1713
+ } catch (error) {
1714
+ this.recordError(error);
1715
+ this.consecutiveFailures += 1;
1716
+ this.state = "restarting";
1717
+ this.scheduleRestart();
1718
+ }
1719
+ }, delay);
1720
+ }
1721
+ backoffDelay() {
1722
+ const exponent = Math.max(0, this.consecutiveFailures - 1);
1723
+ const raw = this.baseBackoffMs * 2 ** exponent;
1724
+ return Math.min(raw, this.maxBackoffMs);
1725
+ }
1726
+ handleStartFailure(error) {
1727
+ this.recordError(error);
1728
+ this.state = "error";
1729
+ this.child = null;
1730
+ if (this.failClosed) {
1731
+ throw new Error(this.lastError ?? "cloudflared failed to start");
1732
+ }
1733
+ }
1734
+ recordError(error) {
1735
+ const message = error instanceof Error ? error.message : String(error);
1736
+ const redacted = this.redact(message);
1737
+ this.lastError = redacted;
1738
+ if (this.onError) {
1739
+ this.onError(new Error(redacted));
1740
+ }
1741
+ }
1742
+ bindSignals() {
1743
+ if (this.signalsBound) return;
1744
+ const proc = nodeProcess();
1745
+ if (!proc) return;
1746
+ proc.on("SIGTERM", this.signalHandler);
1747
+ proc.on("SIGINT", this.signalHandler);
1748
+ this.signalsBound = true;
1749
+ }
1750
+ unbindSignals() {
1751
+ if (!this.signalsBound) return;
1752
+ const proc = nodeProcess();
1753
+ if (proc) {
1754
+ proc.removeListener("SIGTERM", this.signalHandler);
1755
+ proc.removeListener("SIGINT", this.signalHandler);
1756
+ }
1757
+ this.signalsBound = false;
1758
+ }
1759
+ };
1760
+ function nodeSpawn() {
1761
+ return (command, args, options) => nodeChildSpawn(command, args, {
1762
+ stdio: ["ignore", "pipe", "pipe"],
1763
+ ...options?.env ? { env: options.env } : {}
1764
+ });
1765
+ }
1766
+ var LOG_DECODER = new TextDecoder("utf-8");
1767
+ function chunkToString(chunk) {
1768
+ if (typeof chunk === "string") return chunk;
1769
+ if (chunk instanceof Uint8Array) {
1770
+ return LOG_DECODER.decode(chunk);
1771
+ }
1772
+ return "";
1773
+ }
1774
+ function nodeProcess() {
1775
+ const proc = globalThis.process;
1776
+ return proc && typeof proc.on === "function" ? proc : null;
1777
+ }
1778
+ function currentBinaryPackageName() {
1779
+ const proc = globalThis.process;
1780
+ if (!proc?.platform || !proc.arch) return null;
1781
+ return CLOUDFLARED_BINARY_PACKAGES[`${proc.platform}-${proc.arch}`] ?? null;
1782
+ }
1783
+ function defaultLocateBinary() {
1784
+ const pkg = currentBinaryPackageName();
1785
+ if (!pkg) return null;
1786
+ try {
1787
+ const require2 = createRequire(import.meta.url);
1788
+ const manifestPath = require2.resolve(`${pkg}/package.json`);
1789
+ return resolvePackageBinary(require2, pkg, manifestPath);
1790
+ } catch {
1791
+ return null;
1792
+ }
1793
+ }
1794
+ function resolvePackageBinary(require2, pkg, manifestPath) {
1795
+ const sep = manifestPath.includes("\\") ? "\\" : "/";
1796
+ const root = manifestPath.slice(0, manifestPath.lastIndexOf(sep));
1797
+ const manifest = require2(manifestPath);
1798
+ const binField = manifest.bin;
1799
+ const relative = typeof binField === "string" ? binField : binField?.cloudflared ?? `bin${sep}cloudflared`;
1800
+ const normalized = relative.replace(/^\.[\\/]/, "");
1801
+ return `${root}${sep}${normalized}`;
1802
+ }
1803
+
1804
+ // src/core/permissions.ts
1805
+ var WILDCARD = "*";
1806
+ var FartherShorePermissionError = class extends Error {
1807
+ code = "permission_denied";
1808
+ status = 403;
1809
+ /** The permission key that was required but not held. */
1810
+ requiredPermission;
1811
+ constructor(requiredPermission, message) {
1812
+ super(message ?? `missing required permission: ${requiredPermission}`);
1813
+ this.name = "FartherShorePermissionError";
1814
+ this.requiredPermission = requiredPermission;
1815
+ }
1816
+ };
1817
+ function parsePermissionHeader(raw) {
1818
+ if (raw === null || raw === void 0) return void 0;
1819
+ return raw.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
1820
+ }
1821
+ function permissionSatisfies(required, granted) {
1822
+ if (granted === void 0) return true;
1823
+ if (granted.includes(WILDCARD)) return true;
1824
+ if (granted.includes(required)) return true;
1825
+ const idx = required.indexOf(":");
1826
+ if (idx > 0 && idx < required.length - 1) {
1827
+ const subject = required.slice(0, idx);
1828
+ if (granted.includes(`${subject}:${WILDCARD}`)) return true;
1829
+ }
1830
+ return false;
1831
+ }
1832
+ function hasPermission(ctx, key2) {
1833
+ if (ctx.permissions === void 0) {
1834
+ return ctx.signedContext !== void 0;
1835
+ }
1836
+ return permissionSatisfies(key2, ctx.permissions);
1837
+ }
1838
+ function requirePermission(ctx, key2) {
1839
+ if (!hasPermission(ctx, key2)) {
1840
+ throw new FartherShorePermissionError(key2);
1841
+ }
1842
+ }
1843
+
1844
+ // src/core/verifyContext.ts
1845
+ var EXPECTED_JWT_ALG = "HS256";
1846
+ function base64urlDecode(value) {
1847
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/");
1848
+ const binary = atob(padded + "=".repeat((4 - padded.length % 4) % 4));
1849
+ const bytes = new Uint8Array(binary.length);
1850
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
1851
+ return bytes;
1852
+ }
1853
+ async function importHmacKey(secret) {
1854
+ return crypto.subtle.importKey(
1855
+ "raw",
1856
+ new TextEncoder().encode(secret),
1857
+ { name: "HMAC", hash: "SHA-256" },
1858
+ false,
1859
+ ["verify"]
1860
+ );
1861
+ }
1862
+ async function verifyContext(token, secrets) {
1863
+ const parts = token.split(".");
1864
+ if (parts.length !== 3) return null;
1865
+ const [header, payload, signature] = parts;
1866
+ let headerJson = null;
1867
+ try {
1868
+ headerJson = JSON.parse(
1869
+ new TextDecoder().decode(base64urlDecode(header))
1870
+ );
1871
+ } catch {
1872
+ return null;
1873
+ }
1874
+ if (headerJson?.alg !== EXPECTED_JWT_ALG) return null;
1875
+ const signingInput = new TextEncoder().encode(`${header}.${payload}`);
1876
+ let signatureBytes;
1877
+ try {
1878
+ signatureBytes = base64urlDecode(signature);
1879
+ } catch {
1880
+ return null;
1881
+ }
1882
+ let verified = false;
1883
+ for (const secret of secrets) {
1884
+ try {
1885
+ const key2 = await importHmacKey(secret);
1886
+ if (await crypto.subtle.verify(
1887
+ "HMAC",
1888
+ key2,
1889
+ signatureBytes,
1890
+ signingInput
1891
+ )) {
1892
+ verified = true;
1893
+ break;
1894
+ }
1895
+ } catch {
1896
+ }
1897
+ }
1898
+ if (!verified) return null;
1899
+ try {
1900
+ const parsed = JSON.parse(
1901
+ new TextDecoder().decode(base64urlDecode(payload))
1902
+ );
1903
+ if (typeof parsed !== "object" || parsed === null) return null;
1904
+ return parsed;
1905
+ } catch {
1906
+ return null;
1907
+ }
1908
+ }
1909
+ function contextRequiredError(reason) {
1910
+ return new FartherShoreError(
1911
+ "context_unverified",
1912
+ `X-Fs-Context ${reason} (contextVerification is "required")`
1913
+ );
1914
+ }
1915
+
1916
+ // src/core/verifyRequest.ts
1917
+ async function verifyRequest(input, deps) {
1918
+ const h = headerGetter(input.headers);
1919
+ const signature = h(RUNTIME_HEADER_NAMES.signature);
1920
+ if (!signature) {
1921
+ throw new FartherShoreError(
1922
+ "missing_signature",
1923
+ "request is missing the x-fs-signature header"
1924
+ );
1925
+ }
1926
+ const kid = h(RUNTIME_HEADER_NAMES.keyId);
1927
+ const requestId = h(RUNTIME_HEADER_NAMES.requestId);
1928
+ const timestampRaw = h(RUNTIME_HEADER_NAMES.timestamp);
1929
+ const signedProductId = h(RUNTIME_HEADER_NAMES.productId);
1930
+ const signedBackendId = h(RUNTIME_HEADER_NAMES.backendId);
1931
+ const signedRouteId = h(RUNTIME_HEADER_NAMES.routeId) ?? "";
1932
+ const policyVersion = h(RUNTIME_HEADER_NAMES.policyVersion);
1933
+ const signedBodyHash = h(RUNTIME_HEADER_NAMES.bodyHash);
1934
+ if (!kid || !requestId || !timestampRaw || !signedProductId || !signedBackendId || policyVersion === void 0 || !signedBodyHash) {
1935
+ throw new FartherShoreError(
1936
+ "malformed_signature",
1937
+ "request is missing one or more required x-fs-* headers"
1938
+ );
1939
+ }
1940
+ const timestamp = Number(timestampRaw);
1941
+ if (!Number.isInteger(timestamp)) {
1942
+ throw new FartherShoreError(
1943
+ "malformed_signature",
1944
+ "x-fs-timestamp is not an integer"
1945
+ );
1946
+ }
1947
+ const skew = deps.clockSkewSeconds ?? RUNTIME_CLOCK_SKEW_SECONDS;
1948
+ const window = deps.replayWindowSeconds ?? RUNTIME_REPLAY_WINDOW_SECONDS;
1949
+ const now = (deps.nowSeconds ?? (() => Math.floor(Date.now() / 1e3)))();
1950
+ const delta = now - timestamp;
1951
+ if (Math.abs(delta) > window) {
1952
+ if (Math.abs(delta) <= window + skew) {
1953
+ throw new FartherShoreError(
1954
+ "clock_skew",
1955
+ "x-fs-timestamp is outside the replay window but within clock-skew tolerance"
1956
+ );
1957
+ }
1958
+ throw new FartherShoreError(
1959
+ "expired_signature",
1960
+ "x-fs-timestamp is outside the replay window"
1961
+ );
1962
+ }
1963
+ const computedBodyHash = await computeBodyHash(input);
1964
+ if (signedBodyHash !== computedBodyHash) {
1965
+ throw new FartherShoreError(
1966
+ "body_hash_mismatch",
1967
+ "recomputed body hash does not match the signed x-fs-body-hash"
1968
+ );
1969
+ }
1970
+ if (deps.productId !== void 0 && signedProductId !== deps.productId) {
1971
+ throw new FartherShoreError(
1972
+ "route_mismatch",
1973
+ "signed product-id does not match this backend's product"
1974
+ );
1975
+ }
1976
+ if (deps.backendId !== void 0 && signedBackendId !== deps.backendId) {
1977
+ throw new FartherShoreError(
1978
+ "route_mismatch",
1979
+ "signed backend-id does not match this backend"
1980
+ );
1981
+ }
1982
+ if (deps.knownRouteIds !== void 0 && signedRouteId !== "" && !deps.knownRouteIds.has(signedRouteId)) {
1983
+ throw new FartherShoreError(
1984
+ "route_mismatch",
1985
+ "signed route-id is not served by this backend"
1986
+ );
1987
+ }
1988
+ const canonicalInput = {
1989
+ method: input.method,
1990
+ path: input.path,
1991
+ query: input.query ?? "",
1992
+ bodyHash: computedBodyHash,
1993
+ requestId,
1994
+ timestamp,
1995
+ productId: signedProductId,
1996
+ backendId: signedBackendId,
1997
+ routeId: signedRouteId,
1998
+ policyVersion
1999
+ };
2000
+ const canonical = buildCanonicalSigningString2(canonicalInput);
2001
+ const publicJwk = await deps.jwks.getKey(kid);
2002
+ let ok = false;
2003
+ try {
2004
+ ok = await verifyCanonicalSignature2(canonical, signature, publicJwk);
2005
+ } catch {
2006
+ throw new FartherShoreError(
2007
+ "malformed_signature",
2008
+ "x-fs-signature could not be decoded or verified"
2009
+ );
2010
+ }
2011
+ if (!ok) {
2012
+ throw new FartherShoreError(
2013
+ "bad_signature",
2014
+ "Ed25519 signature verification failed"
2015
+ );
2016
+ }
2017
+ if (deps.nonceCache.checkAndRemember(requestId)) {
2018
+ throw new FartherShoreError(
2019
+ "replayed_nonce",
2020
+ "x-fs-request-id has already been seen (replay)"
2021
+ );
2022
+ }
2023
+ let permissions;
2024
+ let roles;
2025
+ let signedContext = null;
2026
+ const contextSecrets = deps.contextSecrets ?? [];
2027
+ const hasContextKeyring = contextSecrets.length > 0;
2028
+ if (hasContextKeyring) {
2029
+ const token = h("x-fs-context");
2030
+ if (token) {
2031
+ signedContext = await verifyContext(token, contextSecrets);
2032
+ if (signedContext === null && deps.contextVerification === "required") {
2033
+ throw contextRequiredError("failed verification");
2034
+ }
2035
+ if (signedContext && signedContext.productId !== signedProductId) {
2036
+ throw new FartherShoreError(
2037
+ "context_unverified",
2038
+ "X-Fs-Context was minted for a different product than the signed request"
2039
+ );
2040
+ }
2041
+ } else if (deps.contextVerification === "required") {
2042
+ throw contextRequiredError("header is missing");
2043
+ }
2044
+ } else if (deps.contextVerification === "required") {
2045
+ throw contextRequiredError("keyring is empty");
2046
+ }
2047
+ if (signedContext) {
2048
+ permissions = signedContext.permissions;
2049
+ roles = signedContext.roles;
2050
+ } else {
2051
+ permissions = parsePermissionHeader(
2052
+ h(RUNTIME_IDENTITY_HEADER_NAMES.permissions)
2053
+ );
2054
+ roles = parsePermissionHeader(h(RUNTIME_IDENTITY_HEADER_NAMES.roles));
2055
+ }
2056
+ return {
2057
+ requestId,
2058
+ productId: signedProductId,
2059
+ backendId: signedBackendId,
2060
+ routeId: signedRouteId,
2061
+ policyVersion,
2062
+ timestamp,
2063
+ bodyHash: computedBodyHash,
2064
+ ...permissions !== void 0 ? { permissions } : {},
2065
+ ...roles !== void 0 ? { roles } : {},
2066
+ ...signedContext ? { signedContext } : {}
2067
+ };
2068
+ }
2069
+ async function computeBodyHash(input) {
2070
+ if (input.streamingExempt) return STREAMING_EXEMPT_BODY_HASH;
2071
+ const body = input.body;
2072
+ if (!body || body.byteLength === 0) return EMPTY_BODY_SHA256;
2073
+ if (body.byteLength > MAX_BODY_BYTES) {
2074
+ throw new FartherShoreError(
2075
+ "body_too_large",
2076
+ `request body exceeds the ${MAX_BODY_BYTES}-byte limit`
2077
+ );
2078
+ }
2079
+ return hashBody2(body);
2080
+ }
2081
+ function headerGetter(headers) {
2082
+ if (typeof headers.get === "function") {
2083
+ const h = headers;
2084
+ return (name) => h.get(name) ?? void 0;
2085
+ }
2086
+ const lower = /* @__PURE__ */ new Map();
2087
+ for (const [key2, value] of Object.entries(
2088
+ headers
2089
+ )) {
2090
+ if (value === void 0) continue;
2091
+ lower.set(
2092
+ key2.toLowerCase(),
2093
+ Array.isArray(value) ? value[0] ?? "" : value
2094
+ );
2095
+ }
2096
+ return (name) => lower.get(name.toLowerCase());
2097
+ }
2098
+
2099
+ // src/core/runtime.ts
2100
+ var DEFAULT_CORE_URL = "https://core.farthershore.com";
2101
+ var SDK_VERSION = "0.14.0".length > 0 ? "0.14.0" : "0.0.0-dev";
2102
+ var CONTRACTS_FP = "1feb5a4a80b447ad".length > 0 ? "1feb5a4a80b447ad" : "0000000000000000";
2103
+ var FartherShore = class {
2104
+ bootstrapClient;
2105
+ fetchImpl;
2106
+ verificationEnabled;
2107
+ meteringEnabledOverride;
2108
+ runtimeToken;
2109
+ coreUrl;
2110
+ instanceId;
2111
+ tunnelOptions;
2112
+ /** FAR-723 — HS256 secret(s) for verifying the signed X-Fs-Context claim. */
2113
+ contextSecrets;
2114
+ /** FAR-723 — "preferred" (fallback to unsigned) | "required" (fail-closed). */
2115
+ contextVerification;
2116
+ nonceCache = new NonceCache();
2117
+ shutdownManager = new ShutdownManager();
2118
+ jwks = null;
2119
+ meteringClient = null;
2120
+ postStreamUsageClient = null;
2121
+ tunnel = null;
2122
+ bootstrapped = false;
2123
+ constructor(options = {}) {
2124
+ const env = options.env ?? readProcessEnv();
2125
+ const runtimeToken = options.runtimeToken ?? env[FS_RUNTIME_TOKEN_ENV] ?? "";
2126
+ const coreUrl = options.coreUrl ?? env.FS_CORE_URL ?? env.FARTHERSHORE_CORE_URL ?? DEFAULT_CORE_URL;
2127
+ this.runtimeToken = runtimeToken;
2128
+ this.coreUrl = coreUrl;
2129
+ this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
2130
+ this.verificationEnabled = options.verification?.enabled ?? true;
2131
+ this.meteringEnabledOverride = options.metering?.enabled ?? true;
2132
+ this.tunnelOptions = options.tunnel ?? {};
2133
+ this.instanceId = options.instanceId;
2134
+ this.contextSecrets = options.contextSecrets ?? parseContextSecrets(env.FS_CONTEXT_SECRETS);
2135
+ this.contextVerification = options.contextVerification ?? (env.FS_CONTEXT_VERIFICATION === "required" ? "required" : "preferred");
2136
+ this.bootstrapClient = new BootstrapClient({
2137
+ runtimeToken,
2138
+ coreUrl,
2139
+ fetchImpl: this.fetchImpl,
2140
+ request: {
2141
+ sdkVersion: SDK_VERSION,
2142
+ sdkLanguage: "typescript",
2143
+ ...options.instanceId ? { instanceId: options.instanceId } : {}
2144
+ }
2145
+ });
2146
+ this.shutdownManager.register(async () => {
2147
+ await this.meteringClient?.flush();
2148
+ });
2149
+ this.shutdownManager.register(async () => {
2150
+ await reportHealth({
2151
+ runtimeToken: this.runtimeToken,
2152
+ coreUrl: this.coreUrl,
2153
+ status: "stopping",
2154
+ instanceId: this.instanceId,
2155
+ fetchImpl: this.fetchImpl
2156
+ });
2157
+ });
2158
+ }
2159
+ /** Ensure bootstrap config is loaded; build the JWKS + metering clients. */
2160
+ async ensureBootstrapped() {
2161
+ const config = await this.bootstrapClient.get();
2162
+ if (!this.jwks) {
2163
+ this.jwks = new JwksClient({
2164
+ jwksUrl: config.verification.jwksUrl,
2165
+ fetchImpl: this.fetchImpl
2166
+ });
2167
+ }
2168
+ if (!this.meteringClient && config.metering.enabled) {
2169
+ this.meteringClient = new MeteringClient({
2170
+ config: config.metering,
2171
+ productId: config.product.id,
2172
+ backendId: config.backend.id,
2173
+ coreUrl: this.coreUrl,
2174
+ fetchImpl: this.fetchImpl
2175
+ });
2176
+ this.postStreamUsageClient = new PostStreamUsageClient({
2177
+ config: config.metering,
2178
+ coreUrl: this.coreUrl,
2179
+ fetchImpl: this.fetchImpl
2180
+ });
2181
+ }
2182
+ this.bootstrapped = true;
2183
+ return config;
2184
+ }
2185
+ /**
2186
+ * Boot-time route reconciliation (call once, before `listen()`). Reflects the
2187
+ * app's real route surface, diffs it against the declared lock from bootstrap,
2188
+ * REPORTS drift to the platform, and CONFIRMS the declared `pending` routes
2189
+ * this replica serves. Fail-OPEN: never throws / never blocks boot.
2190
+ *
2191
+ * The reflection code is dynamically imported so it stays OFF the per-request
2192
+ * verification hot path (the runtime stays route-unaware there). The report is
2193
+ * an OUTBOUND backend→core call (same channel as bootstrap/metering), so it
2194
+ * works for every transport (direct / tunnel).
2195
+ *
2196
+ * Returns the reconcile result (or null if reflection is unavailable / boot
2197
+ * reporting failed). v1 reflects Express; `app` omitted → no-op.
2198
+ */
2199
+ async ready(app) {
2200
+ try {
2201
+ const config = await this.ensureBootstrapped();
2202
+ if (app === void 0) return null;
2203
+ const [{ reflectRoutes: reflectRoutes2 }, { reconcileOnStartup: reconcileOnStartup2 }] = await Promise.all([
2204
+ Promise.resolve().then(() => (init_reflect(), reflect_exports)),
2205
+ Promise.resolve().then(() => (init_reconcile(), reconcile_exports))
2206
+ ]);
2207
+ return await reconcileOnStartup2({
2208
+ reflected: reflectRoutes2(app),
2209
+ bootstrap: {
2210
+ backendId: config.backend.id,
2211
+ lock: config.lock,
2212
+ routes: config.routes
2213
+ },
2214
+ report: this.buildReportSink()
2215
+ });
2216
+ } catch {
2217
+ return null;
2218
+ }
2219
+ }
2220
+ /** Outbound report sink for `ready()` — runtime-token-authed POSTs to core. */
2221
+ buildReportSink() {
2222
+ const post = async (path, body) => {
2223
+ const base = this.coreUrl.replace(/\/$/, "");
2224
+ const res = await this.fetchImpl(`${base}${path}`, {
2225
+ method: "POST",
2226
+ headers: {
2227
+ "content-type": "application/json",
2228
+ authorization: `Bearer ${this.runtimeToken}`
2229
+ },
2230
+ body: JSON.stringify(body)
2231
+ });
2232
+ if (!res.ok) throw new Error(`runtime report ${path} -> ${res.status}`);
2233
+ };
2234
+ return {
2235
+ reportDrift: (report) => post("/v1/runtime/drift", report),
2236
+ confirmServed: (confirm) => post("/v1/runtime/health", confirm)
2237
+ };
2238
+ }
2239
+ /**
2240
+ * Framework-neutral verification primitive. Fail-closed: throws a typed
2241
+ * FartherShoreError on any verification failure. Returns the verified context.
2242
+ */
2243
+ async verifyRequest(input) {
2244
+ const config = await this.ensureBootstrapped();
2245
+ if (!this.jwks) {
2246
+ throw new FartherShoreError(
2247
+ "jwks_unavailable",
2248
+ "JWKS client is not initialized"
2249
+ );
2250
+ }
2251
+ const knownRouteIds = new Set(config.routes.map((r) => r.id));
2252
+ const context = await verifyRequest(input, {
2253
+ jwks: this.jwks,
2254
+ nonceCache: this.nonceCache,
2255
+ productId: config.product.id,
2256
+ backendId: config.backend.id,
2257
+ knownRouteIds,
2258
+ clockSkewSeconds: config.verification.clockSkewSeconds,
2259
+ replayWindowSeconds: config.verification.replayWindowSeconds,
2260
+ // FAR-723 — a VERIFIED signed X-Fs-Context is the preferred (or
2261
+ // required) identity source. Required mode must also fail closed when the
2262
+ // keyring is empty; preferred mode preserves the transitional unsigned
2263
+ // fallback until the backend-v* publish gate removes it.
2264
+ contextSecrets: this.contextSecrets,
2265
+ contextVerification: this.contextVerification
2266
+ });
2267
+ return {
2268
+ ...context,
2269
+ reportUsage: (report) => {
2270
+ const subscriptionId = report.subscriptionId ?? context.signedContext?.subscriptionId;
2271
+ if (!subscriptionId) {
2272
+ return Promise.resolve({
2273
+ ok: false,
2274
+ reason: "subscriptionId is required"
2275
+ });
2276
+ }
2277
+ return this.reportUsage({
2278
+ ...report,
2279
+ requestId: report.requestId ?? context.requestId,
2280
+ subscriptionId
2281
+ });
2282
+ }
2283
+ };
2284
+ }
2285
+ /** Whether verification is required (bootstrap × opt-out). */
2286
+ async verificationRequired() {
2287
+ if (!this.verificationEnabled) return false;
2288
+ const config = await this.ensureBootstrapped();
2289
+ return config.verification.required;
2290
+ }
2291
+ /**
2292
+ * Start the embedded runner. For a `tunnel` backend whose runner is
2293
+ * `embedded`, this supervises `cloudflared` as a child process
2294
+ * (spawned via the injected/default spawner) using the tunnel token from
2295
+ * bootstrap. For every other transport (`direct`, or the `sidecar` runner)
2296
+ * it is a no-op — there is no SDK-managed process to run.
2297
+ *
2298
+ * Fail-open by default: a tunnel that cannot start does NOT crash the host app
2299
+ * (request verification stays fail-closed regardless — a different axis).
2300
+ */
2301
+ async start() {
2302
+ if (this.tunnelOptions.enabled === false) return;
2303
+ const config = await this.ensureBootstrapped();
2304
+ const transport = config.transport;
2305
+ if (transport.mode !== "tunnel" || transport.runner !== "embedded") {
2306
+ return;
2307
+ }
2308
+ const tunnelToken = transport.cloudflared?.tunnelToken;
2309
+ if (!tunnelToken) {
2310
+ if (this.tunnelOptions.failClosed) {
2311
+ throw new FartherShoreError(
2312
+ "invalid_token",
2313
+ "embedded cloudflared runner requires a tunnel token from bootstrap"
2314
+ );
2315
+ }
2316
+ return;
2317
+ }
2318
+ if (this.tunnel) return;
2319
+ const supervisor = new CloudflaredSupervisor({
2320
+ tunnelToken,
2321
+ spawn: this.tunnelOptions.spawn ?? nodeSpawn(),
2322
+ ...this.tunnelOptions.binaryPath ? { binaryPath: this.tunnelOptions.binaryPath } : {},
2323
+ ...this.tunnelOptions.logger ? { logger: this.tunnelOptions.logger } : {},
2324
+ failClosed: this.tunnelOptions.failClosed ?? false
2325
+ });
2326
+ this.tunnel = supervisor;
2327
+ this.shutdownManager.register(async () => {
2328
+ await supervisor.shutdown();
2329
+ });
2330
+ await supervisor.start();
2331
+ }
2332
+ /** Record metering usage (billing-only). */
2333
+ async meter(meter, qty, options = {}) {
2334
+ await this.ensureBootstrapped();
2335
+ if (!this.meteringEnabledOverride) return;
2336
+ if (!this.meteringClient) {
2337
+ throw new FartherShoreError(
2338
+ "invalid_token",
2339
+ "metering is not enabled for this runtime token"
2340
+ );
2341
+ }
2342
+ await this.meteringClient.meter(meter, qty, options);
2343
+ }
2344
+ /** Best-effort attested post-stream usage callback. Never rejects. */
2345
+ async reportUsage(input) {
2346
+ try {
2347
+ await this.ensureBootstrapped();
2348
+ if (!this.meteringEnabledOverride || !this.postStreamUsageClient) {
2349
+ return { ok: false, reason: "metering is not enabled" };
2350
+ }
2351
+ return await this.postStreamUsageClient.reportUsage(input);
2352
+ } catch (error) {
2353
+ const reason = error instanceof Error ? error.message : String(error);
2354
+ console.warn(`post-stream usage report skipped: ${reason}`);
2355
+ return { ok: false, reason };
2356
+ }
2357
+ }
2358
+ /** Current local health report. */
2359
+ health() {
2360
+ const config = this.bootstrapClient.peek();
2361
+ return buildHealthReport({
2362
+ runtimeToken: this.runtimeToken.length > 0,
2363
+ bootstrap: this.bootstrapped && config !== null,
2364
+ // Populated by the embedded-cloudflared supervisor (Slice 3). Null until
2365
+ // fs.start() launches an embedded tunnel; otherwise the supervisor state.
2366
+ tunnel: this.tunnel ? this.tunnel.healthString() : null,
2367
+ verification: this.verificationEnabled && config !== null,
2368
+ metering: this.meteringClient !== null
2369
+ });
2370
+ }
2371
+ /** Graceful shutdown: flush metering + send a stopping heartbeat. */
2372
+ async shutdown() {
2373
+ await this.shutdownManager.shutdown();
2374
+ }
2375
+ /** Register an additional shutdown hook (e.g. the cloudflared supervisor). */
2376
+ onShutdown(hook) {
2377
+ this.shutdownManager.register(hook);
2378
+ }
2379
+ };
2380
+ function initFromEnv(options = {}) {
2381
+ return new FartherShore(options);
2382
+ }
2383
+ function readProcessEnv() {
2384
+ const maybeProcess = globalThis.process;
2385
+ return maybeProcess?.env ?? {};
2386
+ }
2387
+ function parseContextSecrets(raw) {
2388
+ if (!raw) return [];
2389
+ return raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
2390
+ }
2391
+
2392
+ // src/adapters/express.ts
2393
+ var STREAMING_CONTENT_TYPES = new Set(
2394
+ RUNTIME_BODY_HASH_CONTRACT.streamingExemptContentTypes
2395
+ );
2396
+ function createExpressMiddleware(fs, options = {}) {
2397
+ return (req, res, next) => {
2398
+ void runMiddleware(fs, options, req, res, next);
2399
+ };
2400
+ }
2401
+ async function runMiddleware(fs, options, req, res, next) {
2402
+ try {
2403
+ if (!options.always && !await fs.verificationRequired()) {
2404
+ next();
2405
+ return;
2406
+ }
2407
+ const { path, query } = splitUrl(req);
2408
+ const contentType = headerValue(req.headers, "content-type");
2409
+ const streamingExempt = isStreamingExempt(contentType);
2410
+ const body = streamingExempt ? null : extractRawBody(req);
2411
+ const ctx = await fs.verifyRequest({
2412
+ method: req.method,
2413
+ path,
2414
+ query,
2415
+ headers: req.headers,
2416
+ body,
2417
+ streamingExempt
2418
+ });
2419
+ req.fartherShore = ctx;
2420
+ next();
2421
+ } catch (error) {
2422
+ fail(res, error);
2423
+ }
2424
+ }
2425
+ function fail(res, error) {
2426
+ if (error instanceof FartherShoreError) {
2427
+ res.status(error.status).json({ error: error.code });
2428
+ return;
2429
+ }
2430
+ res.status(401).json({ error: "bad_signature" });
2431
+ }
2432
+ function splitUrl(req) {
2433
+ const raw = req.originalUrl ?? req.url ?? req.path ?? "/";
2434
+ const qIndex = raw.indexOf("?");
2435
+ if (qIndex === -1) return { path: raw, query: "" };
2436
+ return { path: raw.slice(0, qIndex), query: raw.slice(qIndex + 1) };
2437
+ }
2438
+ function isStreamingExempt(contentType) {
2439
+ if (!contentType) return false;
2440
+ const base = contentType.split(";")[0].trim().toLowerCase();
2441
+ return STREAMING_CONTENT_TYPES.has(base);
2442
+ }
2443
+ function extractRawBody(req) {
2444
+ const raw = req.rawBody;
2445
+ if (raw) return raw instanceof Uint8Array ? raw : new Uint8Array(raw);
2446
+ const body = req.body;
2447
+ if (body === void 0 || body === null) return null;
2448
+ if (typeof body === "string") return new TextEncoder().encode(body);
2449
+ if (body instanceof Uint8Array) return body;
2450
+ return new Uint8Array(0);
2451
+ }
2452
+ function headerValue(headers, name) {
2453
+ const value = headers[name] ?? headers[name.toLowerCase()];
2454
+ if (Array.isArray(value)) return value[0];
2455
+ return value;
2456
+ }
2457
+
2458
+ // src/testing/prodGuard.ts
2459
+ function isProductionEnv(env = readProcessEnv2()) {
2460
+ return (env.NODE_ENV ?? "").trim().toLowerCase() === "production";
2461
+ }
2462
+ var DevModeInProductionError = class extends Error {
2463
+ constructor(context) {
2464
+ super(
2465
+ `Farther Shore dev mode (${context}) is disabled in this process: NODE_ENV=production. Dev mode mints ephemeral signing keys and signed identity and must never run in production.`
2466
+ );
2467
+ this.name = "DevModeInProductionError";
2468
+ }
2469
+ };
2470
+ function assertNotProduction(context, env = readProcessEnv2()) {
2471
+ if (isProductionEnv(env)) {
2472
+ throw new DevModeInProductionError(context);
2473
+ }
2474
+ }
2475
+ function readProcessEnv2() {
2476
+ const maybeProcess = globalThis.process;
2477
+ return maybeProcess?.env ?? {};
2478
+ }
2479
+
2480
+ // src/testing/usageSink.ts
2481
+ var DevUsageSink = class {
2482
+ events = [];
2483
+ /** Record a signed response-metering payload (withUsage / computeMeteringHeaders). */
2484
+ recordResponse(payload, requestId) {
2485
+ const raw = payload.rawDimsUnits;
2486
+ const meters = raw && typeof raw === "object" ? raw : {};
2487
+ this.events.push({
2488
+ source: "response",
2489
+ meters: { ...meters },
2490
+ payload,
2491
+ ...requestId ? { requestId } : {},
2492
+ at: Date.now()
2493
+ });
2494
+ }
2495
+ /** Record a background `fs.meter()` event captured by the dev gateway. */
2496
+ recordMeterEvent(event) {
2497
+ this.events.push({
2498
+ source: "meter",
2499
+ meters: { [event.meter]: event.qty },
2500
+ event,
2501
+ ...event.request_id ? { requestId: event.request_id } : {},
2502
+ at: Date.now()
2503
+ });
2504
+ }
2505
+ /** Record an attested post-stream report captured by the dev gateway. */
2506
+ recordReportUsage(event) {
2507
+ this.events.push({
2508
+ source: "reportUsage",
2509
+ meters: { ...event.meters },
2510
+ event,
2511
+ requestId: event.requestId,
2512
+ at: Date.now()
2513
+ });
2514
+ }
2515
+ /** Total quantity per meter key across every recorded event. */
2516
+ byMeter() {
2517
+ const out = {};
2518
+ for (const evt of this.events) {
2519
+ for (const [meter, qty] of Object.entries(evt.meters)) {
2520
+ out[meter] = (out[meter] ?? 0) + qty;
2521
+ }
2522
+ }
2523
+ return out;
2524
+ }
2525
+ /** Clear all recorded usage. */
2526
+ reset() {
2527
+ this.events.length = 0;
2528
+ }
2529
+ };
2530
+
2531
+ // src/testing/traceSink.ts
2532
+ function redactValue(input) {
2533
+ return input.replace(/fsrt_(?:live|test)_[A-Za-z0-9_-]+/g, "fsrt_[redacted]").replace(/fsk_[A-Za-z0-9_-]+/g, "fsk_[redacted]").replace(/fsc_[A-Za-z0-9_-]+/g, "fsc_[redacted]").replace(/[Bb]earer\s+[A-Za-z0-9._-]+/g, "Bearer [redacted]");
2534
+ }
2535
+ var DevTraceSink = class {
2536
+ traces = /* @__PURE__ */ new Map();
2537
+ flushed = /* @__PURE__ */ new Set();
2538
+ appendLine;
2539
+ constructor(options = {}) {
2540
+ this.appendLine = options.appendLine;
2541
+ }
2542
+ ensure(requestId, mode) {
2543
+ let trace = this.traces.get(requestId);
2544
+ if (!trace) {
2545
+ trace = { requestId, mode, authz: [], metering: [] };
2546
+ this.traces.set(requestId, trace);
2547
+ }
2548
+ return trace;
2549
+ }
2550
+ /** Record how a request's signature/context verification resolved. */
2551
+ recordVerification(requestId, mode, outcome, fields = {}) {
2552
+ const trace = this.ensure(requestId, mode);
2553
+ if (fields.method) trace.method = fields.method;
2554
+ if (fields.path) trace.path = fields.path;
2555
+ if (fields.persona) trace.persona = fields.persona;
2556
+ trace.verification = {
2557
+ outcome,
2558
+ ...fields.reason ? { reason: redactValue(fields.reason) } : {}
2559
+ };
2560
+ }
2561
+ /** Record a single hasPermission / requirePermission decision. */
2562
+ recordAuthz(requestId, mode, entry) {
2563
+ const trace = this.ensure(requestId, mode);
2564
+ trace.authz.push({
2565
+ permission: entry.permission,
2566
+ decision: entry.decision,
2567
+ ...entry.reason ? { reason: redactValue(entry.reason) } : {}
2568
+ });
2569
+ }
2570
+ /** Record usage reported for this request. */
2571
+ recordMetering(requestId, mode, entry) {
2572
+ const trace = this.ensure(requestId, mode);
2573
+ trace.metering.push(entry);
2574
+ }
2575
+ /** Record the final response status and flush the trace as one JSONL line. */
2576
+ recordResponse(requestId, mode, status) {
2577
+ const trace = this.ensure(requestId, mode);
2578
+ trace.response = { status };
2579
+ this.flush(requestId);
2580
+ }
2581
+ /**
2582
+ * Append a trace's current state as one JSONL line (if a sink is wired).
2583
+ * Flushes at most ONCE per request id, so wrapping several response methods
2584
+ * (status/json/end) never produces duplicate JSONL lines.
2585
+ */
2586
+ flush(requestId) {
2587
+ if (this.flushed.has(requestId)) return;
2588
+ const trace = this.traces.get(requestId);
2589
+ if (trace && this.appendLine) {
2590
+ this.appendLine(JSON.stringify(trace));
2591
+ this.flushed.add(requestId);
2592
+ }
2593
+ }
2594
+ /** The accumulated trace for a request id, or `undefined`. */
2595
+ forRequest(requestId) {
2596
+ return this.traces.get(requestId);
2597
+ }
2598
+ /** Every accumulated trace (insertion order). */
2599
+ all() {
2600
+ return [...this.traces.values()];
2601
+ }
2602
+ /** Clear all traces. */
2603
+ reset() {
2604
+ this.traces.clear();
2605
+ this.flushed.clear();
2606
+ }
2607
+ };
2608
+
2609
+ // src/testing/keysFile.ts
2610
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2611
+ import { dirname } from "node:path";
2612
+ var DEFAULT_KEYS_FILE = ".farthershore/dev-keys.json";
2613
+ function writeDevKeysFile(path, contents) {
2614
+ mkdirSync(dirname(path), { recursive: true });
2615
+ writeFileSync(path, JSON.stringify(contents, null, 2), { mode: 384 });
2616
+ chmodSync(path, 384);
2617
+ }
2618
+ function readDevKeysFile(path = DEFAULT_KEYS_FILE) {
2619
+ const raw = readFileSync(path, "utf8");
2620
+ return JSON.parse(raw);
2621
+ }
2622
+ function personaClientFromKeysFile(path = DEFAULT_KEYS_FILE, options = {}) {
2623
+ const file = readDevKeysFile(path);
2624
+ const client = createPersonaClient({
2625
+ keys: file.keys,
2626
+ productId: file.productId,
2627
+ backendId: file.backendId,
2628
+ contextSecret: file.keys.contextSecret,
2629
+ contextKid: file.keys.contextKid,
2630
+ personas: buildPersonaMap(file.personas),
2631
+ mode: file.mode,
2632
+ ...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}
2633
+ });
2634
+ return {
2635
+ asPersona: (name) => client.asPersona(name),
2636
+ personas: client.personas,
2637
+ file
2638
+ };
2639
+ }
2640
+
2641
+ // src/testing/devRuntime.ts
2642
+ function createDevRuntime(options) {
2643
+ assertNotProduction("createDevRuntime", options.env);
2644
+ const mode = options.mode;
2645
+ const keys = options.keys ?? generateDevSignerKeys();
2646
+ const usage = new DevUsageSink();
2647
+ const trace = new DevTraceSink(
2648
+ options.traceJsonl ? { appendLine: options.traceJsonl } : {}
2649
+ );
2650
+ const gateway = createDevGateway({
2651
+ mode,
2652
+ keys,
2653
+ ...options.productId ? { productId: options.productId } : {},
2654
+ ...options.backendId ? { backendId: options.backendId } : {},
2655
+ ...options.routes ? { routeIds: options.routes } : {},
2656
+ onMeterEvent: (event) => {
2657
+ usage.recordMeterEvent(event);
2658
+ options.usageJsonl?.(JSON.stringify({ source: "meter", event }));
2659
+ },
2660
+ onReportUsage: (event) => {
2661
+ usage.recordReportUsage(event);
2662
+ options.usageJsonl?.(JSON.stringify({ source: "reportUsage", event }));
2663
+ }
2664
+ });
2665
+ __setDevMeteringHooks({
2666
+ fallbackToken: () => keys.runtimeToken,
2667
+ record: (payload, requestId) => {
2668
+ usage.recordResponse(payload, requestId);
2669
+ options.usageJsonl?.(JSON.stringify({ source: "response", payload }));
2670
+ if (requestId) {
2671
+ const raw = payload.rawDimsUnits;
2672
+ trace.recordMetering(requestId, mode, {
2673
+ meters: raw && typeof raw === "object" ? raw : {},
2674
+ source: "response"
2675
+ });
2676
+ }
2677
+ }
2678
+ });
2679
+ const personas = buildPersonaMap(options.personas);
2680
+ const personaClient = createPersonaClient({
2681
+ keys,
2682
+ productId: gateway.productId,
2683
+ backendId: gateway.backendId,
2684
+ contextSecret: keys.contextSecret,
2685
+ contextKid: keys.contextKid,
2686
+ personas,
2687
+ mode,
2688
+ ...options.appFetch ? { fetchImpl: options.appFetch } : {}
2689
+ });
2690
+ const fs = initFromEnv({
2691
+ runtimeToken: keys.runtimeToken,
2692
+ coreUrl: DEV_CORE_URL,
2693
+ fetchImpl: gateway.fetchImpl,
2694
+ contextSecrets: [keys.contextSecret],
2695
+ contextVerification: mode === "simulated" ? "required" : "preferred",
2696
+ env: {}
2697
+ });
2698
+ const tracedAuthz = {
2699
+ hasPermission(ctx, key2) {
2700
+ const decision = hasPermission(ctx, key2);
2701
+ recordDecision(ctx, key2, decision);
2702
+ return decision;
2703
+ },
2704
+ requirePermission(ctx, key2) {
2705
+ const decision = hasPermission(ctx, key2);
2706
+ recordDecision(ctx, key2, decision);
2707
+ if (!decision) throw new FartherShorePermissionError(key2);
2708
+ requirePermission(ctx, key2);
2709
+ }
2710
+ };
2711
+ function recordDecision(ctx, key2, decision) {
2712
+ const requestId = ctx.requestId ?? "unknown";
2713
+ const subject = ctx.signedContext?.subjectKey ?? "unknown";
2714
+ trace.recordAuthz(requestId, mode, {
2715
+ permission: key2,
2716
+ decision: decision ? "allow" : "deny",
2717
+ ...decision ? {} : { reason: `permission_denied: ${key2} (subject=${subject})` }
2718
+ });
2719
+ }
2720
+ function middleware(mwOptions) {
2721
+ const inner = createExpressMiddleware(fs, mwOptions);
2722
+ return (req, res, next) => {
2723
+ const requestId = headerValue2(req.headers, "x-fs-request-id") ?? "unknown";
2724
+ const { path } = splitUrl2(req);
2725
+ const method = req.method;
2726
+ let nextCalled = false;
2727
+ let lastStatus = 200;
2728
+ const origStatus = res.status.bind(res);
2729
+ res.status = (code) => {
2730
+ lastStatus = code;
2731
+ return origStatus(code);
2732
+ };
2733
+ const origJson = res.json.bind(res);
2734
+ res.json = (body) => {
2735
+ if (!nextCalled) {
2736
+ const reason = typeof body === "object" && body !== null && "error" in body ? String(body.error) : void 0;
2737
+ trace.recordVerification(requestId, mode, "rejected", {
2738
+ method,
2739
+ path,
2740
+ ...reason ? { reason } : {}
2741
+ });
2742
+ }
2743
+ trace.recordResponse(requestId, mode, lastStatus);
2744
+ return origJson(body);
2745
+ };
2746
+ const wrappedNext = (err) => {
2747
+ nextCalled = true;
2748
+ trace.recordVerification(
2749
+ requestId,
2750
+ mode,
2751
+ mode === "simulated" ? "verified" : "passthrough",
2752
+ { method, path }
2753
+ );
2754
+ next(err);
2755
+ };
2756
+ inner(req, res, wrappedNext);
2757
+ };
2758
+ }
2759
+ fs.middleware = middleware;
2760
+ const devRuntime = {
2761
+ fs,
2762
+ asPersona: (name) => personaClient.asPersona(name),
2763
+ usage,
2764
+ trace,
2765
+ gateway,
2766
+ keys,
2767
+ personas,
2768
+ mode,
2769
+ bootstrap: gateway.bootstrap,
2770
+ authz: tracedAuthz,
2771
+ middleware,
2772
+ reset() {
2773
+ usage.reset();
2774
+ trace.reset();
2775
+ gateway.meterEvents.length = 0;
2776
+ }
2777
+ };
2778
+ fs.dev = devRuntime;
2779
+ return devRuntime;
2780
+ }
2781
+ var USAGE_JSONL_PATH = ".farthershore/dev-usage.jsonl";
2782
+ var TRACE_JSONL_DEFAULT = ".farthershore/dev-trace.jsonl";
2783
+ var PERSONAS_FILE = "fs.dev.personas.json";
2784
+ function devModeFromEnv(env) {
2785
+ const raw = (env.FS_DEV_MODE ?? "").trim().toLowerCase();
2786
+ if (raw === "passthrough" || raw === "simulated") return raw;
2787
+ return null;
2788
+ }
2789
+ function createDevRuntimeFromEnv(env = readProcessEnv3()) {
2790
+ const mode = devModeFromEnv(env);
2791
+ if (!mode) {
2792
+ throw new Error(
2793
+ "createDevRuntimeFromEnv called without FS_DEV_MODE=passthrough|simulated"
2794
+ );
2795
+ }
2796
+ assertNotProduction("FS_DEV_MODE", env);
2797
+ const keys = generateDevSignerKeys();
2798
+ const personas = loadPersonasFile();
2799
+ const tracePath = env.FS_DEV_TRACE?.trim() || TRACE_JSONL_DEFAULT;
2800
+ const runtime = createDevRuntime({
2801
+ mode,
2802
+ ...personas ? { personas } : {},
2803
+ keys,
2804
+ env,
2805
+ usageJsonl: (line) => appendJsonl(USAGE_JSONL_PATH, line),
2806
+ traceJsonl: (line) => appendJsonl(tracePath, line)
2807
+ });
2808
+ const keysFile = {
2809
+ version: 1,
2810
+ mode,
2811
+ keys,
2812
+ productId: runtime.gateway.productId,
2813
+ backendId: runtime.gateway.backendId,
2814
+ personas: mapToRecord(runtime.personas)
2815
+ };
2816
+ writeDevKeysFile(DEFAULT_KEYS_FILE, keysFile);
2817
+ printBanner(mode, runtime, tracePath);
2818
+ return runtime.fs;
2819
+ }
2820
+ function printBanner(mode, runtime, tracePath) {
2821
+ const lines = [
2822
+ "============================================================",
2823
+ " \u26A0 FARTHER SHORE DEV MODE ACTIVE \u2014 NOT FOR PRODUCTION",
2824
+ ` mode: ${mode.toUpperCase()}`,
2825
+ ` product: ${runtime.gateway.productId}`,
2826
+ ` backend: ${runtime.gateway.backendId}`,
2827
+ ` personas: ${[...runtime.personas.keys()].join(", ")}`,
2828
+ ` usage log: ${USAGE_JSONL_PATH}`,
2829
+ ` trace log: ${tracePath}`,
2830
+ ` dev keys: ${DEFAULT_KEYS_FILE} (mode 600, ephemeral)`,
2831
+ mode === "simulated" ? " verification REQUIRED \u2014 requests must carry a signed persona." : " verification OFF (passthrough) \u2014 middleware passes through.",
2832
+ "============================================================"
2833
+ ];
2834
+ console.warn(lines.join("\n"));
2835
+ }
2836
+ function loadPersonasFile() {
2837
+ if (!existsSync(PERSONAS_FILE)) return void 0;
2838
+ try {
2839
+ return JSON.parse(readFileSync2(PERSONAS_FILE, "utf8"));
2840
+ } catch {
2841
+ return void 0;
2842
+ }
2843
+ }
2844
+ function appendJsonl(path, line) {
2845
+ try {
2846
+ mkdirSync2(dirname2(path), { recursive: true });
2847
+ appendFileSync(path, `${line}
2848
+ `);
2849
+ } catch {
2850
+ }
2851
+ }
2852
+ function mapToRecord(map) {
2853
+ const out = {};
2854
+ for (const [name, def] of map) out[name] = def;
2855
+ return out;
2856
+ }
2857
+ function readProcessEnv3() {
2858
+ const maybeProcess = globalThis.process;
2859
+ return maybeProcess?.env ?? {};
2860
+ }
2861
+ function headerValue2(headers, name) {
2862
+ const value = headers[name] ?? headers[name.toLowerCase()];
2863
+ if (Array.isArray(value)) return value[0];
2864
+ return value;
2865
+ }
2866
+ function splitUrl2(req) {
2867
+ const raw = req.originalUrl ?? req.url ?? req.path ?? "/";
2868
+ const qIndex = raw.indexOf("?");
2869
+ if (qIndex === -1) return { path: raw, query: "" };
2870
+ return { path: raw.slice(0, qIndex), query: raw.slice(qIndex + 1) };
2871
+ }
2872
+ export {
2873
+ CONTEXT_HEADER_NAME,
2874
+ DEFAULT_KEYS_FILE,
2875
+ DEFAULT_PERSONAS,
2876
+ DEV_CORE_URL,
2877
+ DEV_JWKS_URL,
2878
+ DEV_METERING_ENDPOINT,
2879
+ DevModeInProductionError,
2880
+ DevTraceSink,
2881
+ DevUsageSink,
2882
+ SIGNED_HEADER_NAMES,
2883
+ TEST_CONTEXT_KID,
2884
+ TEST_CONTEXT_SECRET,
2885
+ TEST_KID,
2886
+ TEST_PRIVATE_JWK,
2887
+ TEST_PUBLIC_JWK,
2888
+ assertNotProduction,
2889
+ buildPersonaMap,
2890
+ createDevGateway,
2891
+ createDevRuntime,
2892
+ createDevRuntimeFromEnv,
2893
+ createPersonaClient,
2894
+ definePersona,
2895
+ devModeFromEnv,
2896
+ generateDevSignerKeys,
2897
+ isProductionEnv,
2898
+ makeSignedRequest,
2899
+ memoryJwks,
2900
+ personaClientFromKeysFile,
2901
+ readDevKeysFile,
2902
+ redactValue,
2903
+ signContextToken,
2904
+ unreachableJwks,
2905
+ writeDevKeysFile
2906
+ };