@farthershore/backend 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1496 @@
1
+ import { createRequire as __createRequire } from "node:module";const require=__createRequire(import.meta.url);
2
+
3
+ // src/runtime-types.ts
4
+ var FS_RUNTIME_TOKEN_ENV = "FS_RUNTIME_TOKEN";
5
+ var RUNTIME_TOKEN_PREFIXES = {
6
+ live: "fsrt_live_",
7
+ test: "fsrt_test_"
8
+ };
9
+ var RUNTIME_TOKEN_CAPABILITIES = [
10
+ "gateway_verification",
11
+ "metering",
12
+ "health",
13
+ "tunnel"
14
+ ];
15
+ var RUNTIME_HEADER_NAMES = {
16
+ signature: "x-fs-signature",
17
+ keyId: "x-fs-key-id",
18
+ requestId: "x-fs-request-id",
19
+ timestamp: "x-fs-timestamp",
20
+ productId: "x-fs-product-id",
21
+ backendId: "x-fs-backend-id",
22
+ routeId: "x-fs-route-id",
23
+ policyVersion: "x-fs-policy-version",
24
+ bodyHash: "x-fs-body-hash"
25
+ };
26
+ var RUNTIME_CLOCK_SKEW_SECONDS = 5;
27
+ var RUNTIME_REPLAY_WINDOW_SECONDS = 300;
28
+ var EMPTY_BODY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
29
+ var STREAMING_EXEMPT_BODY_HASH = "STREAM";
30
+ var MAX_BODY_BYTES = 10 * 1024 * 1024;
31
+
32
+ // ../contracts/dist/runtime.js
33
+ var RUNTIME_TOKEN_PREFIXES2 = {
34
+ live: "fsrt_live_",
35
+ test: "fsrt_test_"
36
+ };
37
+ function runtimeTokenKind(token) {
38
+ if (token.startsWith(RUNTIME_TOKEN_PREFIXES2.live))
39
+ return "live";
40
+ if (token.startsWith(RUNTIME_TOKEN_PREFIXES2.test))
41
+ return "test";
42
+ return null;
43
+ }
44
+ var MAX_BODY_BYTES2 = 10 * 1024 * 1024;
45
+ async function hashBody(body) {
46
+ const bytes = new Uint8Array(body);
47
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
48
+ return toHex(new Uint8Array(digest));
49
+ }
50
+ var CANONICAL_SIGNING_FIELDS = [
51
+ "method",
52
+ "path",
53
+ "query",
54
+ "body-hash",
55
+ "request-id",
56
+ "timestamp",
57
+ "product-id",
58
+ "backend-id",
59
+ "route-id",
60
+ "policy-version"
61
+ ];
62
+ var CANONICAL_FIELD_SEPARATOR = "\n";
63
+ var CANONICAL_KV_SEPARATOR = ":";
64
+ function canonicalizeQuery(query) {
65
+ const raw = query.startsWith("?") ? query.slice(1) : query;
66
+ if (raw === "")
67
+ return "";
68
+ const pairs = raw.split("&").filter((p) => p.length > 0);
69
+ pairs.sort((a, b) => {
70
+ const [an, ...arest] = a.split("=");
71
+ const [bn, ...brest] = b.split("=");
72
+ if (an < bn)
73
+ return -1;
74
+ if (an > bn)
75
+ return 1;
76
+ const av = arest.join("=");
77
+ const bv = brest.join("=");
78
+ if (av < bv)
79
+ return -1;
80
+ if (av > bv)
81
+ return 1;
82
+ return 0;
83
+ });
84
+ return pairs.join("&");
85
+ }
86
+ function buildCanonicalSigningString(input) {
87
+ const values = {
88
+ method: input.method.toUpperCase(),
89
+ path: input.path,
90
+ query: canonicalizeQuery(input.query),
91
+ "body-hash": input.bodyHash,
92
+ "request-id": input.requestId,
93
+ timestamp: String(input.timestamp),
94
+ "product-id": input.productId,
95
+ "backend-id": input.backendId,
96
+ "route-id": input.routeId,
97
+ "policy-version": input.policyVersion
98
+ };
99
+ return CANONICAL_SIGNING_FIELDS.map((field) => `${field}${CANONICAL_KV_SEPARATOR}${values[field]}`).join(CANONICAL_FIELD_SEPARATOR);
100
+ }
101
+ var ED25519_ALGORITHM = "Ed25519";
102
+ async function importEd25519PrivateKey(jwk) {
103
+ return crypto.subtle.importKey("jwk", { ...jwk, alg: void 0 }, { name: ED25519_ALGORITHM }, false, ["sign"]);
104
+ }
105
+ async function importEd25519PublicKey(jwk) {
106
+ return crypto.subtle.importKey("jwk", { ...jwk, alg: void 0 }, { name: ED25519_ALGORITHM }, false, ["verify"]);
107
+ }
108
+ async function signCanonicalString(canonical, privateJwk) {
109
+ const key = await importEd25519PrivateKey(privateJwk);
110
+ const sig = await crypto.subtle.sign(ED25519_ALGORITHM, key, new TextEncoder().encode(canonical));
111
+ return base64UrlEncode(new Uint8Array(sig));
112
+ }
113
+ async function verifyCanonicalSignature(canonical, signatureB64Url, publicJwk) {
114
+ const key = await importEd25519PublicKey(publicJwk);
115
+ return crypto.subtle.verify(ED25519_ALGORITHM, key, base64UrlDecode(signatureB64Url), new TextEncoder().encode(canonical));
116
+ }
117
+ function toHex(bytes) {
118
+ let out = "";
119
+ for (const b of bytes)
120
+ out += b.toString(16).padStart(2, "0");
121
+ return out;
122
+ }
123
+ function base64UrlEncode(bytes) {
124
+ let binary = "";
125
+ for (const b of bytes)
126
+ binary += String.fromCharCode(b);
127
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
128
+ }
129
+ function base64UrlDecode(value) {
130
+ const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
131
+ const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
132
+ const binary = atob(padded);
133
+ const bytes = new Uint8Array(binary.length);
134
+ for (let i = 0; i < binary.length; i += 1)
135
+ bytes[i] = binary.charCodeAt(i);
136
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
137
+ }
138
+
139
+ // src/runtime-signing.ts
140
+ var hashBody2 = hashBody;
141
+ var canonicalizeQuery2 = canonicalizeQuery;
142
+ var buildCanonicalSigningString2 = buildCanonicalSigningString;
143
+ var signCanonicalString2 = signCanonicalString;
144
+ var verifyCanonicalSignature2 = verifyCanonicalSignature;
145
+ var runtimeTokenKind2 = runtimeTokenKind;
146
+
147
+ // src/core/errors.ts
148
+ var FartherShoreError = class extends Error {
149
+ code;
150
+ status;
151
+ constructor(code, message, status) {
152
+ super(message);
153
+ this.name = "FartherShoreError";
154
+ this.code = code;
155
+ this.status = status ?? statusForCode(code);
156
+ }
157
+ };
158
+ function statusForCode(code) {
159
+ return code === "body_too_large" ? 413 : 401;
160
+ }
161
+
162
+ // src/core/bootstrap.ts
163
+ var BOOTSTRAP_PATH = "/v1/runtime/bootstrap";
164
+ var DEFAULT_MIN_REFRESH_SECONDS = 30;
165
+ var BootstrapClient = class {
166
+ runtimeToken;
167
+ endpoint;
168
+ request;
169
+ fetchImpl;
170
+ now;
171
+ minRefreshSeconds;
172
+ cached = null;
173
+ fetchedAt = 0;
174
+ refreshAfterMs = 0;
175
+ inflight = null;
176
+ constructor(options) {
177
+ if (!options.runtimeToken) {
178
+ throw new FartherShoreError(
179
+ "missing_token",
180
+ `${FS_RUNTIME_TOKEN_ENV} is required to bootstrap the Farther Shore backend SDK`
181
+ );
182
+ }
183
+ if (runtimeTokenKind2(options.runtimeToken) === null) {
184
+ throw new FartherShoreError(
185
+ "invalid_token",
186
+ `${FS_RUNTIME_TOKEN_ENV} must start with fsrt_live_ or fsrt_test_`
187
+ );
188
+ }
189
+ this.runtimeToken = options.runtimeToken;
190
+ this.endpoint = joinUrl(options.coreUrl, BOOTSTRAP_PATH);
191
+ this.request = options.request ?? {};
192
+ this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
193
+ this.now = options.now ?? (() => Date.now());
194
+ this.minRefreshSeconds = options.minRefreshSeconds ?? DEFAULT_MIN_REFRESH_SECONDS;
195
+ }
196
+ /** Cached config when fresh; otherwise refreshes. */
197
+ async get() {
198
+ if (this.cached && !this.isStale()) return this.cached;
199
+ return this.refresh();
200
+ }
201
+ /** Force a network refresh (single-flight). */
202
+ async refresh() {
203
+ if (this.inflight) return this.inflight;
204
+ this.inflight = this.doBootstrap().finally(() => {
205
+ this.inflight = null;
206
+ });
207
+ return this.inflight;
208
+ }
209
+ /** Last cached value without triggering a refresh (null until bootstrapped). */
210
+ peek() {
211
+ return this.cached;
212
+ }
213
+ isStale() {
214
+ return this.now() - this.fetchedAt >= this.refreshAfterMs;
215
+ }
216
+ async doBootstrap() {
217
+ let response;
218
+ try {
219
+ response = await this.fetchImpl(this.endpoint, {
220
+ method: "POST",
221
+ headers: {
222
+ authorization: `Bearer ${this.runtimeToken}`,
223
+ "content-type": "application/json",
224
+ accept: "application/json"
225
+ },
226
+ body: JSON.stringify(this.request)
227
+ });
228
+ } catch (cause) {
229
+ if (this.cached) return this.cached;
230
+ throw new FartherShoreError(
231
+ "jwks_unavailable",
232
+ `bootstrap request failed: ${stringify(cause)}`
233
+ );
234
+ }
235
+ if (response.status === 401 || response.status === 403) {
236
+ throw new FartherShoreError(
237
+ "invalid_token",
238
+ `${FS_RUNTIME_TOKEN_ENV} was rejected by core (HTTP ${response.status})`
239
+ );
240
+ }
241
+ if (!response.ok) {
242
+ if (this.cached) return this.cached;
243
+ throw new FartherShoreError(
244
+ "jwks_unavailable",
245
+ `bootstrap returned HTTP ${response.status}`
246
+ );
247
+ }
248
+ const body = await response.json();
249
+ this.cached = body;
250
+ this.fetchedAt = this.now();
251
+ const refreshSeconds = Math.max(
252
+ this.minRefreshSeconds,
253
+ body.refreshAfterSeconds || this.minRefreshSeconds
254
+ );
255
+ this.refreshAfterMs = refreshSeconds * 1e3;
256
+ return body;
257
+ }
258
+ };
259
+ function joinUrl(base, path) {
260
+ return `${base.replace(/\/+$/, "")}${path}`;
261
+ }
262
+ function stringify(cause) {
263
+ return cause instanceof Error ? cause.message : String(cause);
264
+ }
265
+
266
+ // src/core/health.ts
267
+ function buildHealthReport(snapshot) {
268
+ return {
269
+ runtimeToken: snapshot.runtimeToken,
270
+ bootstrap: snapshot.bootstrap,
271
+ tunnel: snapshot.tunnel,
272
+ verification: snapshot.verification,
273
+ metering: snapshot.metering
274
+ };
275
+ }
276
+ var HEALTH_PATH = "/v1/runtime/health";
277
+ async function reportHealth(options) {
278
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
279
+ const endpoint = `${options.coreUrl.replace(/\/+$/, "")}${HEALTH_PATH}`;
280
+ try {
281
+ const response = await fetchImpl(endpoint, {
282
+ method: "POST",
283
+ headers: {
284
+ authorization: `Bearer ${options.runtimeToken}`,
285
+ "content-type": "application/json"
286
+ },
287
+ body: JSON.stringify({
288
+ status: options.status,
289
+ ...options.instanceId ? { instanceId: options.instanceId } : {}
290
+ })
291
+ });
292
+ return response.ok;
293
+ } catch {
294
+ return false;
295
+ }
296
+ }
297
+
298
+ // src/core/jwks.ts
299
+ var DEFAULT_CACHE_TTL_MS = 5 * 6e4;
300
+ var DEFAULT_NEGATIVE_CACHE_MS = 3e4;
301
+ var MAX_NEGATIVE_KIDS = 1e3;
302
+ var JwksClient = class {
303
+ jwksUrl;
304
+ fetchImpl;
305
+ cacheTtlMs;
306
+ negativeCacheMs;
307
+ now;
308
+ keysByKid = /* @__PURE__ */ new Map();
309
+ fetchedAt = 0;
310
+ hasFetchedOnce = false;
311
+ inflight = null;
312
+ negativeKids = /* @__PURE__ */ new Map();
313
+ constructor(options) {
314
+ this.jwksUrl = options.jwksUrl;
315
+ this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
316
+ this.cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
317
+ this.negativeCacheMs = options.negativeCacheMs ?? DEFAULT_NEGATIVE_CACHE_MS;
318
+ this.now = options.now ?? (() => Date.now());
319
+ }
320
+ /**
321
+ * Resolve a public JWK for `kid`, fail-closed. Throws FartherShoreError with
322
+ * `jwks_unavailable` (cold cache + fetch failed) or `unknown_key_id`.
323
+ */
324
+ async getKey(kid) {
325
+ const cached = this.keysByKid.get(kid);
326
+ if (cached && !this.isStale()) return cached;
327
+ const negAt = this.negativeKids.get(kid);
328
+ if (negAt !== void 0 && this.now() - negAt < this.negativeCacheMs) {
329
+ const warm = this.keysByKid.get(kid);
330
+ if (warm) return warm;
331
+ throw new FartherShoreError(
332
+ "unknown_key_id",
333
+ `signing key '${kid}' is not present in the JWKS`
334
+ );
335
+ }
336
+ await this.refresh();
337
+ const key = this.keysByKid.get(kid);
338
+ if (key) {
339
+ this.negativeKids.delete(kid);
340
+ return key;
341
+ }
342
+ this.rememberMissingKid(kid);
343
+ throw new FartherShoreError(
344
+ "unknown_key_id",
345
+ `signing key '${kid}' is not present in the JWKS`
346
+ );
347
+ }
348
+ /** Record a confirmed-missing kid, evicting the oldest if at capacity. */
349
+ rememberMissingKid(kid) {
350
+ if (!this.negativeKids.has(kid)) {
351
+ while (this.negativeKids.size >= MAX_NEGATIVE_KIDS) {
352
+ const oldest = this.negativeKids.keys().next().value;
353
+ if (oldest === void 0) break;
354
+ this.negativeKids.delete(oldest);
355
+ }
356
+ }
357
+ this.negativeKids.set(kid, this.now());
358
+ }
359
+ isStale() {
360
+ return this.now() - this.fetchedAt >= this.cacheTtlMs;
361
+ }
362
+ /** Single-flight refresh: concurrent callers share one fetch. */
363
+ async refresh() {
364
+ if (this.inflight) return this.inflight;
365
+ this.inflight = this.doFetch().finally(() => {
366
+ this.inflight = null;
367
+ });
368
+ return this.inflight;
369
+ }
370
+ async doFetch() {
371
+ let response;
372
+ try {
373
+ response = await this.fetchImpl(this.jwksUrl, {
374
+ headers: { accept: "application/json" }
375
+ });
376
+ } catch (cause) {
377
+ this.failOnColdCache(cause);
378
+ return;
379
+ }
380
+ if (!response.ok) {
381
+ this.failOnColdCache(
382
+ new Error(`JWKS endpoint returned HTTP ${response.status}`)
383
+ );
384
+ return;
385
+ }
386
+ let doc;
387
+ try {
388
+ doc = await response.json();
389
+ } catch (cause) {
390
+ this.failOnColdCache(cause);
391
+ return;
392
+ }
393
+ const next = /* @__PURE__ */ new Map();
394
+ for (const key of doc.keys ?? []) {
395
+ if (typeof key.kid === "string") next.set(key.kid, key);
396
+ }
397
+ this.keysByKid = next;
398
+ this.fetchedAt = this.now();
399
+ this.hasFetchedOnce = true;
400
+ this.negativeKids.clear();
401
+ }
402
+ /**
403
+ * Stale-while-revalidate: with a warm cache, swallow the refresh failure and
404
+ * keep serving the last-known keys. With a COLD cache, fail closed.
405
+ */
406
+ failOnColdCache(cause) {
407
+ if (this.hasFetchedOnce) return;
408
+ throw new FartherShoreError(
409
+ "jwks_unavailable",
410
+ `JWKS unavailable on a cold cache: ${stringifyCause(cause)}`
411
+ );
412
+ }
413
+ };
414
+ function stringifyCause(cause) {
415
+ if (cause instanceof Error) return cause.message;
416
+ return String(cause);
417
+ }
418
+
419
+ // src/core/metering.ts
420
+ var METER_KEY_RE = /^[a-z0-9_]{1,64}$/;
421
+ var DEFAULT_MAX_RETRIES = 3;
422
+ var MeteringClient = class {
423
+ config;
424
+ endpoint;
425
+ productId;
426
+ backendId;
427
+ fetchImpl;
428
+ maxRetries;
429
+ newId;
430
+ now;
431
+ buffer = [];
432
+ constructor(options) {
433
+ this.config = options.config;
434
+ this.endpoint = resolveEndpoint(options.config.endpoint, options.coreUrl);
435
+ this.productId = options.productId;
436
+ this.backendId = options.backendId;
437
+ this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
438
+ this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
439
+ this.newId = options.newId ?? (() => crypto.randomUUID());
440
+ this.now = options.now ?? (() => /* @__PURE__ */ new Date());
441
+ }
442
+ /**
443
+ * Record `qty` of `meter`. Enforces meter-key shape, non-negative finite qty,
444
+ * the bootstrap allowedMeters/allowedRoutes scope, and the per-event sanity
445
+ * max, then enqueues and flushes (best-effort; failures stay buffered).
446
+ */
447
+ async meter(meter, qty, options = {}) {
448
+ if (!this.config.enabled) {
449
+ throw new FartherShoreError(
450
+ "invalid_token",
451
+ "metering is not enabled for this runtime token"
452
+ );
453
+ }
454
+ if (!METER_KEY_RE.test(meter)) {
455
+ throw new FartherShoreError(
456
+ "invalid_token",
457
+ `meter key '${meter}' must be lowercase alphanumeric with underscores`
458
+ );
459
+ }
460
+ if (!Number.isFinite(qty) || qty < 0) {
461
+ throw new FartherShoreError(
462
+ "invalid_token",
463
+ `meter '${meter}' qty must be a non-negative finite number`
464
+ );
465
+ }
466
+ if (this.config.allowedMeters.length > 0 && !this.config.allowedMeters.includes(meter)) {
467
+ throw new FartherShoreError(
468
+ "invalid_token",
469
+ `meter '${meter}' is not in the token's allowedMeters`
470
+ );
471
+ }
472
+ if (options.routeId && this.config.allowedRoutes.length > 0 && !this.config.allowedRoutes.includes(options.routeId)) {
473
+ throw new FartherShoreError(
474
+ "invalid_token",
475
+ `route '${options.routeId}' is not in the token's allowedRoutes`
476
+ );
477
+ }
478
+ if (this.config.perEventMax > 0 && qty > this.config.perEventMax) {
479
+ throw new FartherShoreError(
480
+ "invalid_token",
481
+ `meter '${meter}' qty ${qty} exceeds the per-event max ${this.config.perEventMax}`
482
+ );
483
+ }
484
+ const event = {
485
+ event_id: options.eventId ?? this.newId(),
486
+ product_id: this.productId,
487
+ backend_id: this.backendId,
488
+ meter,
489
+ qty,
490
+ timestamp: options.timestamp ?? this.now().toISOString(),
491
+ ...options.routeId ? { route_id: options.routeId } : {},
492
+ ...options.requestId ? { request_id: options.requestId } : {}
493
+ };
494
+ this.buffer.push(event);
495
+ await this.flush();
496
+ }
497
+ /** Drain the buffer. Events that fail all retries stay buffered (at-least-once). */
498
+ async flush() {
499
+ const pending = this.buffer.splice(0, this.buffer.length);
500
+ const stillPending = [];
501
+ for (const event of pending) {
502
+ const sent = await this.sendWithRetry(event);
503
+ if (!sent) stillPending.push(event);
504
+ }
505
+ if (stillPending.length > 0) this.buffer.unshift(...stillPending);
506
+ }
507
+ /** Buffered-but-unsent count (observability/tests). */
508
+ get pending() {
509
+ return this.buffer.length;
510
+ }
511
+ async sendWithRetry(event) {
512
+ for (let attempt = 0; attempt < this.maxRetries; attempt += 1) {
513
+ try {
514
+ const response = await this.fetchImpl(this.endpoint, {
515
+ method: "POST",
516
+ headers: {
517
+ authorization: `Bearer ${this.config.credential}`,
518
+ "content-type": "application/json",
519
+ accept: "application/json"
520
+ },
521
+ body: JSON.stringify(event)
522
+ });
523
+ if (response.ok) return true;
524
+ if (response.status >= 400 && response.status < 500 && response.status !== 429) {
525
+ return true;
526
+ }
527
+ } catch {
528
+ }
529
+ }
530
+ return false;
531
+ }
532
+ };
533
+ function resolveEndpoint(endpoint, coreUrl) {
534
+ if (/^https?:\/\//.test(endpoint)) return endpoint;
535
+ if (!coreUrl) return endpoint;
536
+ return `${coreUrl.replace(/\/+$/, "")}${endpoint}`;
537
+ }
538
+
539
+ // src/core/nonceCache.ts
540
+ var DEFAULT_MAX_ENTRIES = 1e5;
541
+ var DEFAULT_TTL_MS = 6e5;
542
+ var NonceCache = class {
543
+ maxEntries;
544
+ ttlMs;
545
+ now;
546
+ // insertion-ordered Map ⇒ first key is the oldest (LRU eviction).
547
+ seen = /* @__PURE__ */ new Map();
548
+ constructor(options = {}) {
549
+ this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
550
+ this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
551
+ this.now = options.now ?? (() => Date.now());
552
+ }
553
+ /**
554
+ * @returns true if `id` was already seen (a REPLAY); false on first sight
555
+ * (the id is then remembered).
556
+ */
557
+ checkAndRemember(id) {
558
+ const at = this.now();
559
+ const previous = this.seen.get(id);
560
+ if (previous !== void 0) {
561
+ if (at - previous < this.ttlMs) return true;
562
+ this.seen.delete(id);
563
+ }
564
+ this.evictExpired(at);
565
+ this.evictOverflow();
566
+ this.seen.set(id, at);
567
+ return false;
568
+ }
569
+ /** Number of retained nonces (test/observability hook). */
570
+ get size() {
571
+ return this.seen.size;
572
+ }
573
+ evictExpired(at) {
574
+ for (const [id, ts] of this.seen) {
575
+ if (at - ts < this.ttlMs) break;
576
+ this.seen.delete(id);
577
+ }
578
+ }
579
+ evictOverflow() {
580
+ while (this.seen.size >= this.maxEntries) {
581
+ const oldest = this.seen.keys().next().value;
582
+ if (oldest === void 0) break;
583
+ this.seen.delete(oldest);
584
+ }
585
+ }
586
+ };
587
+
588
+ // src/core/shutdown.ts
589
+ var ShutdownManager = class {
590
+ hooks = [];
591
+ done = false;
592
+ register(hook) {
593
+ this.hooks.push(hook);
594
+ }
595
+ get isShutDown() {
596
+ return this.done;
597
+ }
598
+ /** Run all hooks in reverse order, isolating failures. Idempotent. */
599
+ async shutdown() {
600
+ if (this.done) return;
601
+ this.done = true;
602
+ for (let i = this.hooks.length - 1; i >= 0; i -= 1) {
603
+ try {
604
+ await this.hooks[i]();
605
+ } catch {
606
+ }
607
+ }
608
+ }
609
+ };
610
+
611
+ // src/core/tunnel.ts
612
+ import { spawn as nodeChildSpawn } from "node:child_process";
613
+ var REDACTED_TOKEN = "***REDACTED***";
614
+ var CLOUDFLARED_RUN_ARGS = [
615
+ "tunnel",
616
+ "--no-autoupdate",
617
+ "run",
618
+ "--token"
619
+ ];
620
+ var DEFAULT_BINARY_CANDIDATES = [
621
+ "cloudflared",
622
+ "/usr/local/bin/cloudflared",
623
+ "/usr/bin/cloudflared",
624
+ "/opt/cloudflared/cloudflared"
625
+ ];
626
+ var DEFAULT_BASE_BACKOFF_MS = 1e3;
627
+ var DEFAULT_MAX_BACKOFF_MS = 6e4;
628
+ var DEFAULT_BINARY = "cloudflared";
629
+ var MAX_LOG_LINE_BYTES = 64 * 1024;
630
+ var CloudflaredSupervisor = class {
631
+ tunnelToken;
632
+ spawn;
633
+ binaryPath;
634
+ locateBinary;
635
+ logger;
636
+ onError;
637
+ failClosed;
638
+ baseBackoffMs;
639
+ maxBackoffMs;
640
+ setTimeoutFn;
641
+ clearTimeoutFn;
642
+ childEnv;
643
+ child = null;
644
+ state = "stopped";
645
+ restarts = 0;
646
+ consecutiveFailures = 0;
647
+ lastError = null;
648
+ intentionalStop = false;
649
+ restartTimer = null;
650
+ signalsBound = false;
651
+ signalHandler = () => {
652
+ void this.shutdown();
653
+ };
654
+ constructor(options) {
655
+ if (!options.tunnelToken) {
656
+ throw new Error("CloudflaredSupervisor requires a tunnel token");
657
+ }
658
+ this.tunnelToken = options.tunnelToken;
659
+ this.spawn = options.spawn;
660
+ this.binaryPath = options.binaryPath;
661
+ this.locateBinary = options.locateBinary ?? (() => defaultLocateBinary());
662
+ this.logger = options.logger ?? ((line) => console.error(line));
663
+ this.onError = options.onError;
664
+ this.failClosed = options.failClosed ?? false;
665
+ this.baseBackoffMs = options.baseBackoffMs ?? DEFAULT_BASE_BACKOFF_MS;
666
+ this.maxBackoffMs = options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
667
+ this.setTimeoutFn = options.setTimeoutFn ?? ((cb, ms) => setTimeout(cb, ms));
668
+ this.clearTimeoutFn = options.clearTimeoutFn ?? ((h) => clearTimeout(h));
669
+ this.childEnv = options.childEnv;
670
+ }
671
+ /**
672
+ * Resolve the binary, spawn the child, wire log piping + exit handling, and
673
+ * bind SIGTERM/SIGINT. Fail-open by default (resolves; error in `status()`);
674
+ * fail-closed when configured (rejects).
675
+ *
676
+ * Async by contract: the public lifecycle API (and a future binary
677
+ * download/health-probe step) is Promise-returning even when today's body is
678
+ * synchronous, so callers can always `await fs.start()`.
679
+ */
680
+ // eslint-disable-next-line @typescript-eslint/require-await
681
+ async start() {
682
+ this.intentionalStop = false;
683
+ this.bindSignals();
684
+ try {
685
+ this.spawnChild();
686
+ } catch (error) {
687
+ this.handleStartFailure(error);
688
+ }
689
+ }
690
+ /**
691
+ * Intentional graceful stop: cancel any pending restart, signal the child
692
+ * (SIGTERM), unbind signals, and mark the supervisor stopped. Any exit that
693
+ * the kill provokes will NOT trigger a restart. Async by lifecycle contract.
694
+ */
695
+ // eslint-disable-next-line @typescript-eslint/require-await
696
+ async shutdown() {
697
+ this.intentionalStop = true;
698
+ if (this.restartTimer !== null) {
699
+ this.clearTimeoutFn(this.restartTimer);
700
+ this.restartTimer = null;
701
+ }
702
+ this.unbindSignals();
703
+ const child = this.child;
704
+ this.child = null;
705
+ this.state = "stopped";
706
+ if (child) {
707
+ try {
708
+ child.kill("SIGTERM");
709
+ } catch (error) {
710
+ this.recordError(error);
711
+ }
712
+ }
713
+ }
714
+ /** Token-free status snapshot for diagnostics / health. */
715
+ status() {
716
+ return {
717
+ state: this.state,
718
+ pid: this.child?.pid ?? null,
719
+ restarts: this.restarts,
720
+ lastError: this.lastError
721
+ };
722
+ }
723
+ /** Compact health string for `fs.health().tunnel`. */
724
+ healthString() {
725
+ return this.state;
726
+ }
727
+ // --- internals ------------------------------------------------------------
728
+ spawnChild() {
729
+ this.state = "starting";
730
+ const command = this.resolveBinary();
731
+ const args = [...CLOUDFLARED_RUN_ARGS, this.tunnelToken];
732
+ const child = this.spawn(
733
+ command,
734
+ args,
735
+ this.childEnv ? { env: this.childEnv } : void 0
736
+ );
737
+ this.child = child;
738
+ this.state = "running";
739
+ this.pipeLogs(child);
740
+ child.on("exit", (code, signal) => this.handleExit(code, signal));
741
+ }
742
+ /**
743
+ * Resolve the cloudflared binary path. Explicit `binaryPath` wins; otherwise
744
+ * the injected locator runs (Linux-first). Missing ⇒ a clear, redacted error.
745
+ */
746
+ resolveBinary() {
747
+ if (this.binaryPath) return this.binaryPath;
748
+ const located = this.locateBinary();
749
+ if (located) return located;
750
+ throw new Error(
751
+ "cloudflared binary not found \u2014 install it in the image, supply binaryPath, or run the sidecar runner instead. (Linux container is the V1 supported target.)"
752
+ );
753
+ }
754
+ /** Pipe stdout/stderr to the logger with the tunnel token redacted. */
755
+ pipeLogs(child) {
756
+ child.stdout?.on("data", this.makeLineSink());
757
+ child.stderr?.on("data", this.makeLineSink());
758
+ }
759
+ /**
760
+ * Build a per-stream `data` handler that BUFFERS partial lines across chunks
761
+ * before redacting. The OS can deliver a single log line in two `data` events
762
+ * with the boundary mid-token; redacting each chunk independently would let
763
+ * the two token halves slip through. Buffering until a newline reassembles
764
+ * the full line (a token never contains a newline), so redaction always sees
765
+ * the whole token. A trailing partial is held for the next chunk, or flushed
766
+ * if it grows past a safety cap (cloudflared is line-oriented, so this is a
767
+ * belt-and-suspenders guard against unbounded buffer growth).
768
+ */
769
+ makeLineSink() {
770
+ let buffer = "";
771
+ return (chunk) => {
772
+ buffer += chunkToString(chunk);
773
+ if (buffer.length > MAX_LOG_LINE_BYTES) {
774
+ this.emitLogLine(buffer);
775
+ buffer = "";
776
+ return;
777
+ }
778
+ const parts = buffer.split("\n");
779
+ buffer = parts.pop() ?? "";
780
+ for (const part of parts) {
781
+ this.emitLogLine(part.replace(/\r$/, ""));
782
+ }
783
+ };
784
+ }
785
+ emitLogLine(line) {
786
+ if (line.length === 0) return;
787
+ this.logger(this.redact(line));
788
+ }
789
+ /** Replace every occurrence of the tunnel token with the sentinel. */
790
+ redact(text) {
791
+ if (this.tunnelToken.length === 0) return text;
792
+ return text.split(this.tunnelToken).join(REDACTED_TOKEN);
793
+ }
794
+ handleExit(code, _signal) {
795
+ this.child = null;
796
+ if (this.intentionalStop) {
797
+ this.state = "stopped";
798
+ return;
799
+ }
800
+ this.consecutiveFailures += 1;
801
+ this.lastError = this.redact(
802
+ `cloudflared exited unexpectedly (code=${String(code)})`
803
+ );
804
+ this.state = "restarting";
805
+ this.scheduleRestart();
806
+ }
807
+ scheduleRestart() {
808
+ const delay = this.backoffDelay();
809
+ this.restartTimer = this.setTimeoutFn(() => {
810
+ this.restartTimer = null;
811
+ if (this.intentionalStop) return;
812
+ this.restarts += 1;
813
+ try {
814
+ this.spawnChild();
815
+ } catch (error) {
816
+ this.recordError(error);
817
+ this.consecutiveFailures += 1;
818
+ this.state = "restarting";
819
+ this.scheduleRestart();
820
+ }
821
+ }, delay);
822
+ }
823
+ backoffDelay() {
824
+ const exponent = Math.max(0, this.consecutiveFailures - 1);
825
+ const raw = this.baseBackoffMs * 2 ** exponent;
826
+ return Math.min(raw, this.maxBackoffMs);
827
+ }
828
+ handleStartFailure(error) {
829
+ this.recordError(error);
830
+ this.state = "error";
831
+ this.child = null;
832
+ if (this.failClosed) {
833
+ throw new Error(this.lastError ?? "cloudflared failed to start");
834
+ }
835
+ }
836
+ recordError(error) {
837
+ const message = error instanceof Error ? error.message : String(error);
838
+ const redacted = this.redact(message);
839
+ this.lastError = redacted;
840
+ if (this.onError) {
841
+ this.onError(new Error(redacted));
842
+ }
843
+ }
844
+ bindSignals() {
845
+ if (this.signalsBound) return;
846
+ const proc = nodeProcess();
847
+ if (!proc) return;
848
+ proc.on("SIGTERM", this.signalHandler);
849
+ proc.on("SIGINT", this.signalHandler);
850
+ this.signalsBound = true;
851
+ }
852
+ unbindSignals() {
853
+ if (!this.signalsBound) return;
854
+ const proc = nodeProcess();
855
+ if (proc) {
856
+ proc.removeListener("SIGTERM", this.signalHandler);
857
+ proc.removeListener("SIGINT", this.signalHandler);
858
+ }
859
+ this.signalsBound = false;
860
+ }
861
+ };
862
+ function nodeSpawn() {
863
+ return (command, args, options) => nodeChildSpawn(command, args, {
864
+ stdio: ["ignore", "pipe", "pipe"],
865
+ ...options?.env ? { env: options.env } : {}
866
+ });
867
+ }
868
+ var LOG_DECODER = new TextDecoder("utf-8");
869
+ function chunkToString(chunk) {
870
+ if (typeof chunk === "string") return chunk;
871
+ if (chunk instanceof Uint8Array) {
872
+ return LOG_DECODER.decode(chunk);
873
+ }
874
+ return "";
875
+ }
876
+ function nodeProcess() {
877
+ const proc = globalThis.process;
878
+ return proc && typeof proc.on === "function" ? proc : null;
879
+ }
880
+ function defaultLocateBinary() {
881
+ return DEFAULT_BINARY_CANDIDATES[0] ?? DEFAULT_BINARY;
882
+ }
883
+
884
+ // src/core/verifyRequest.ts
885
+ async function verifyRequest(input, deps) {
886
+ const h = headerGetter(input.headers);
887
+ const signature = h(RUNTIME_HEADER_NAMES.signature);
888
+ if (!signature) {
889
+ throw new FartherShoreError(
890
+ "missing_signature",
891
+ "request is missing the x-fs-signature header"
892
+ );
893
+ }
894
+ const kid = h(RUNTIME_HEADER_NAMES.keyId);
895
+ const requestId = h(RUNTIME_HEADER_NAMES.requestId);
896
+ const timestampRaw = h(RUNTIME_HEADER_NAMES.timestamp);
897
+ const signedProductId = h(RUNTIME_HEADER_NAMES.productId);
898
+ const signedBackendId = h(RUNTIME_HEADER_NAMES.backendId);
899
+ const signedRouteId = h(RUNTIME_HEADER_NAMES.routeId) ?? "";
900
+ const policyVersion = h(RUNTIME_HEADER_NAMES.policyVersion);
901
+ const signedBodyHash = h(RUNTIME_HEADER_NAMES.bodyHash);
902
+ if (!kid || !requestId || !timestampRaw || !signedProductId || !signedBackendId || policyVersion === void 0 || !signedBodyHash) {
903
+ throw new FartherShoreError(
904
+ "malformed_signature",
905
+ "request is missing one or more required x-fs-* headers"
906
+ );
907
+ }
908
+ const timestamp = Number(timestampRaw);
909
+ if (!Number.isInteger(timestamp)) {
910
+ throw new FartherShoreError(
911
+ "malformed_signature",
912
+ "x-fs-timestamp is not an integer"
913
+ );
914
+ }
915
+ const skew = deps.clockSkewSeconds ?? RUNTIME_CLOCK_SKEW_SECONDS;
916
+ const window = deps.replayWindowSeconds ?? RUNTIME_REPLAY_WINDOW_SECONDS;
917
+ const now = (deps.nowSeconds ?? (() => Math.floor(Date.now() / 1e3)))();
918
+ const delta = now - timestamp;
919
+ if (Math.abs(delta) > window) {
920
+ if (Math.abs(delta) <= window + skew) {
921
+ throw new FartherShoreError(
922
+ "clock_skew",
923
+ "x-fs-timestamp is outside the replay window but within clock-skew tolerance"
924
+ );
925
+ }
926
+ throw new FartherShoreError(
927
+ "expired_signature",
928
+ "x-fs-timestamp is outside the replay window"
929
+ );
930
+ }
931
+ const computedBodyHash = await computeBodyHash(input);
932
+ if (signedBodyHash !== computedBodyHash) {
933
+ throw new FartherShoreError(
934
+ "body_hash_mismatch",
935
+ "recomputed body hash does not match the signed x-fs-body-hash"
936
+ );
937
+ }
938
+ if (deps.productId !== void 0 && signedProductId !== deps.productId) {
939
+ throw new FartherShoreError(
940
+ "route_mismatch",
941
+ "signed product-id does not match this backend's product"
942
+ );
943
+ }
944
+ if (deps.backendId !== void 0 && signedBackendId !== deps.backendId) {
945
+ throw new FartherShoreError(
946
+ "route_mismatch",
947
+ "signed backend-id does not match this backend"
948
+ );
949
+ }
950
+ if (deps.knownRouteIds !== void 0 && signedRouteId !== "" && !deps.knownRouteIds.has(signedRouteId)) {
951
+ throw new FartherShoreError(
952
+ "route_mismatch",
953
+ "signed route-id is not served by this backend"
954
+ );
955
+ }
956
+ const canonicalInput = {
957
+ method: input.method,
958
+ path: input.path,
959
+ query: input.query ?? "",
960
+ bodyHash: computedBodyHash,
961
+ requestId,
962
+ timestamp,
963
+ productId: signedProductId,
964
+ backendId: signedBackendId,
965
+ routeId: signedRouteId,
966
+ policyVersion
967
+ };
968
+ const canonical = buildCanonicalSigningString2(canonicalInput);
969
+ const publicJwk = await deps.jwks.getKey(kid);
970
+ let ok = false;
971
+ try {
972
+ ok = await verifyCanonicalSignature2(canonical, signature, publicJwk);
973
+ } catch {
974
+ throw new FartherShoreError(
975
+ "malformed_signature",
976
+ "x-fs-signature could not be decoded or verified"
977
+ );
978
+ }
979
+ if (!ok) {
980
+ throw new FartherShoreError(
981
+ "bad_signature",
982
+ "Ed25519 signature verification failed"
983
+ );
984
+ }
985
+ if (deps.nonceCache.checkAndRemember(requestId)) {
986
+ throw new FartherShoreError(
987
+ "replayed_nonce",
988
+ "x-fs-request-id has already been seen (replay)"
989
+ );
990
+ }
991
+ return {
992
+ requestId,
993
+ productId: signedProductId,
994
+ backendId: signedBackendId,
995
+ routeId: signedRouteId,
996
+ policyVersion,
997
+ timestamp,
998
+ bodyHash: computedBodyHash
999
+ };
1000
+ }
1001
+ async function computeBodyHash(input) {
1002
+ if (input.streamingExempt) return STREAMING_EXEMPT_BODY_HASH;
1003
+ const body = input.body;
1004
+ if (!body || body.byteLength === 0) return EMPTY_BODY_SHA256;
1005
+ if (body.byteLength > MAX_BODY_BYTES) {
1006
+ throw new FartherShoreError(
1007
+ "body_too_large",
1008
+ `request body exceeds the ${MAX_BODY_BYTES}-byte limit`
1009
+ );
1010
+ }
1011
+ return hashBody2(body);
1012
+ }
1013
+ function headerGetter(headers) {
1014
+ if (typeof headers.get === "function") {
1015
+ const h = headers;
1016
+ return (name) => h.get(name) ?? void 0;
1017
+ }
1018
+ const lower = /* @__PURE__ */ new Map();
1019
+ for (const [key, value] of Object.entries(
1020
+ headers
1021
+ )) {
1022
+ if (value === void 0) continue;
1023
+ lower.set(
1024
+ key.toLowerCase(),
1025
+ Array.isArray(value) ? value[0] ?? "" : value
1026
+ );
1027
+ }
1028
+ return (name) => lower.get(name.toLowerCase());
1029
+ }
1030
+
1031
+ // src/core/runtime.ts
1032
+ var DEFAULT_CORE_URL = "https://api.farthershore.com";
1033
+ var SDK_VERSION = "0.1.0";
1034
+ var FartherShore = class {
1035
+ bootstrapClient;
1036
+ fetchImpl;
1037
+ verificationEnabled;
1038
+ meteringEnabledOverride;
1039
+ runtimeToken;
1040
+ coreUrl;
1041
+ instanceId;
1042
+ tunnelOptions;
1043
+ nonceCache = new NonceCache();
1044
+ shutdownManager = new ShutdownManager();
1045
+ jwks = null;
1046
+ meteringClient = null;
1047
+ tunnel = null;
1048
+ bootstrapped = false;
1049
+ constructor(options = {}) {
1050
+ const env = options.env ?? readProcessEnv();
1051
+ const runtimeToken = options.runtimeToken ?? env[FS_RUNTIME_TOKEN_ENV] ?? "";
1052
+ const coreUrl = options.coreUrl ?? env.FS_CORE_URL ?? env.FARTHERSHORE_CORE_URL ?? DEFAULT_CORE_URL;
1053
+ this.runtimeToken = runtimeToken;
1054
+ this.coreUrl = coreUrl;
1055
+ this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
1056
+ this.verificationEnabled = options.verification?.enabled ?? true;
1057
+ this.meteringEnabledOverride = options.metering?.enabled ?? true;
1058
+ this.tunnelOptions = options.tunnel ?? {};
1059
+ this.instanceId = options.instanceId;
1060
+ this.bootstrapClient = new BootstrapClient({
1061
+ runtimeToken,
1062
+ coreUrl,
1063
+ fetchImpl: this.fetchImpl,
1064
+ request: {
1065
+ sdkVersion: SDK_VERSION,
1066
+ sdkLanguage: "typescript",
1067
+ ...options.instanceId ? { instanceId: options.instanceId } : {}
1068
+ }
1069
+ });
1070
+ this.shutdownManager.register(async () => {
1071
+ await this.meteringClient?.flush();
1072
+ });
1073
+ this.shutdownManager.register(async () => {
1074
+ await reportHealth({
1075
+ runtimeToken: this.runtimeToken,
1076
+ coreUrl: this.coreUrl,
1077
+ status: "stopping",
1078
+ instanceId: this.instanceId,
1079
+ fetchImpl: this.fetchImpl
1080
+ });
1081
+ });
1082
+ }
1083
+ /** Ensure bootstrap config is loaded; build the JWKS + metering clients. */
1084
+ async ensureBootstrapped() {
1085
+ const config = await this.bootstrapClient.get();
1086
+ if (!this.jwks) {
1087
+ this.jwks = new JwksClient({
1088
+ jwksUrl: config.verification.jwksUrl,
1089
+ fetchImpl: this.fetchImpl
1090
+ });
1091
+ }
1092
+ if (!this.meteringClient && config.metering.enabled) {
1093
+ this.meteringClient = new MeteringClient({
1094
+ config: config.metering,
1095
+ productId: config.product.id,
1096
+ backendId: config.backend.id,
1097
+ coreUrl: this.coreUrl,
1098
+ fetchImpl: this.fetchImpl
1099
+ });
1100
+ }
1101
+ this.bootstrapped = true;
1102
+ return config;
1103
+ }
1104
+ /**
1105
+ * Framework-neutral verification primitive. Fail-closed: throws a typed
1106
+ * FartherShoreError on any verification failure. Returns the verified context.
1107
+ */
1108
+ async verifyRequest(input) {
1109
+ const config = await this.ensureBootstrapped();
1110
+ if (!this.jwks) {
1111
+ throw new FartherShoreError(
1112
+ "jwks_unavailable",
1113
+ "JWKS client is not initialized"
1114
+ );
1115
+ }
1116
+ const knownRouteIds = new Set(config.routes.map((r) => r.id));
1117
+ return verifyRequest(input, {
1118
+ jwks: this.jwks,
1119
+ nonceCache: this.nonceCache,
1120
+ productId: config.product.id,
1121
+ backendId: config.backend.id,
1122
+ knownRouteIds,
1123
+ clockSkewSeconds: config.verification.clockSkewSeconds,
1124
+ replayWindowSeconds: config.verification.replayWindowSeconds
1125
+ });
1126
+ }
1127
+ /** Whether verification is required (bootstrap × opt-out). */
1128
+ async verificationRequired() {
1129
+ if (!this.verificationEnabled) return false;
1130
+ const config = await this.ensureBootstrapped();
1131
+ return config.verification.required;
1132
+ }
1133
+ /**
1134
+ * Start the managed runner. For a `cloudflare_tunnel` backend whose runner is
1135
+ * `managed_cloudflared`, this supervises `cloudflared` as a child process
1136
+ * (spawned via the injected/default spawner) using the tunnel token from
1137
+ * bootstrap. For every other transport (`public_origin`, `mtls`, or the
1138
+ * `sidecar` runner) it is a no-op — there is no SDK-managed process to run.
1139
+ *
1140
+ * Fail-open by default: a tunnel that cannot start does NOT crash the host app
1141
+ * (request verification stays fail-closed regardless — a different axis).
1142
+ */
1143
+ async start() {
1144
+ if (this.tunnelOptions.enabled === false) return;
1145
+ const config = await this.ensureBootstrapped();
1146
+ const transport = config.transport;
1147
+ if (transport.mode !== "cloudflare_tunnel" || transport.runner !== "managed_cloudflared") {
1148
+ return;
1149
+ }
1150
+ const tunnelToken = transport.cloudflared?.tunnelToken;
1151
+ if (!tunnelToken) {
1152
+ if (this.tunnelOptions.failClosed) {
1153
+ throw new FartherShoreError(
1154
+ "invalid_token",
1155
+ "managed cloudflared runner requires a tunnel token from bootstrap"
1156
+ );
1157
+ }
1158
+ return;
1159
+ }
1160
+ if (this.tunnel) return;
1161
+ const supervisor = new CloudflaredSupervisor({
1162
+ tunnelToken,
1163
+ spawn: this.tunnelOptions.spawn ?? nodeSpawn(),
1164
+ ...this.tunnelOptions.binaryPath ? { binaryPath: this.tunnelOptions.binaryPath } : {},
1165
+ ...this.tunnelOptions.logger ? { logger: this.tunnelOptions.logger } : {},
1166
+ failClosed: this.tunnelOptions.failClosed ?? false
1167
+ });
1168
+ this.tunnel = supervisor;
1169
+ this.shutdownManager.register(async () => {
1170
+ await supervisor.shutdown();
1171
+ });
1172
+ await supervisor.start();
1173
+ }
1174
+ /** Record metering usage (billing-only). */
1175
+ async meter(meter, qty, options = {}) {
1176
+ await this.ensureBootstrapped();
1177
+ if (!this.meteringEnabledOverride) return;
1178
+ if (!this.meteringClient) {
1179
+ throw new FartherShoreError(
1180
+ "invalid_token",
1181
+ "metering is not enabled for this runtime token"
1182
+ );
1183
+ }
1184
+ await this.meteringClient.meter(meter, qty, options);
1185
+ }
1186
+ /** Current local health report. */
1187
+ health() {
1188
+ const config = this.bootstrapClient.peek();
1189
+ return buildHealthReport({
1190
+ runtimeToken: this.runtimeToken.length > 0,
1191
+ bootstrap: this.bootstrapped && config !== null,
1192
+ // Populated by the managed-cloudflared supervisor (Slice 3). Null until
1193
+ // fs.start() launches a managed tunnel; otherwise the supervisor state.
1194
+ tunnel: this.tunnel ? this.tunnel.healthString() : null,
1195
+ verification: this.verificationEnabled && config !== null,
1196
+ metering: this.meteringClient !== null
1197
+ });
1198
+ }
1199
+ /** Graceful shutdown: flush metering + send a stopping heartbeat. */
1200
+ async shutdown() {
1201
+ await this.shutdownManager.shutdown();
1202
+ }
1203
+ /** Register an additional shutdown hook (e.g. the cloudflared supervisor). */
1204
+ onShutdown(hook) {
1205
+ this.shutdownManager.register(hook);
1206
+ }
1207
+ };
1208
+ function initFromEnv(options = {}) {
1209
+ return new FartherShore(options);
1210
+ }
1211
+ function readProcessEnv() {
1212
+ const maybeProcess = globalThis.process;
1213
+ return maybeProcess?.env ?? {};
1214
+ }
1215
+
1216
+ // src/generated/runtime-contract.ts
1217
+ var RUNTIME_BODY_HASH_CONTRACT = {
1218
+ algorithm: "SHA-256",
1219
+ encoding: "hex-lower",
1220
+ source: "raw-request-bytes",
1221
+ emptyBodyHash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1222
+ maxBodyBytes: 10485760,
1223
+ streamingExemptToken: "STREAM",
1224
+ streamingExemptContentTypes: [
1225
+ "text/event-stream",
1226
+ "application/octet-stream",
1227
+ "multipart/form-data"
1228
+ ],
1229
+ overMaxStatus: 413
1230
+ };
1231
+ var RUNTIME_ERROR_CODES = {
1232
+ missingSignature: "missing_signature",
1233
+ malformedSignature: "malformed_signature",
1234
+ unknownKeyId: "unknown_key_id",
1235
+ jwksUnavailable: "jwks_unavailable",
1236
+ badSignature: "bad_signature",
1237
+ bodyHashMismatch: "body_hash_mismatch",
1238
+ routeMismatch: "route_mismatch",
1239
+ clockSkew: "clock_skew",
1240
+ expiredSignature: "expired_signature",
1241
+ replayedNonce: "replayed_nonce",
1242
+ bodyTooLarge: "body_too_large",
1243
+ environmentMismatch: "environment_mismatch",
1244
+ missingToken: "missing_token",
1245
+ invalidToken: "invalid_token"
1246
+ };
1247
+
1248
+ // src/adapters/express.ts
1249
+ var STREAMING_CONTENT_TYPES = new Set(
1250
+ RUNTIME_BODY_HASH_CONTRACT.streamingExemptContentTypes
1251
+ );
1252
+ function createExpressMiddleware(fs, options = {}) {
1253
+ return (req, res, next) => {
1254
+ void runMiddleware(fs, options, req, res, next);
1255
+ };
1256
+ }
1257
+ async function runMiddleware(fs, options, req, res, next) {
1258
+ try {
1259
+ if (!options.always && !await fs.verificationRequired()) {
1260
+ next();
1261
+ return;
1262
+ }
1263
+ const { path, query } = splitUrl(req);
1264
+ const contentType = headerValue(req.headers, "content-type");
1265
+ const streamingExempt = isStreamingExempt(contentType);
1266
+ const body = streamingExempt ? null : extractRawBody(req);
1267
+ const ctx = await fs.verifyRequest({
1268
+ method: req.method,
1269
+ path,
1270
+ query,
1271
+ headers: req.headers,
1272
+ body,
1273
+ streamingExempt
1274
+ });
1275
+ req.fartherShore = ctx;
1276
+ next();
1277
+ } catch (error) {
1278
+ fail(res, error);
1279
+ }
1280
+ }
1281
+ function fail(res, error) {
1282
+ if (error instanceof FartherShoreError) {
1283
+ res.status(error.status).json({ error: error.code });
1284
+ return;
1285
+ }
1286
+ res.status(401).json({ error: "bad_signature" });
1287
+ }
1288
+ function splitUrl(req) {
1289
+ const raw = req.originalUrl ?? req.url ?? req.path ?? "/";
1290
+ const qIndex = raw.indexOf("?");
1291
+ if (qIndex === -1) return { path: raw, query: "" };
1292
+ return { path: raw.slice(0, qIndex), query: raw.slice(qIndex + 1) };
1293
+ }
1294
+ function isStreamingExempt(contentType) {
1295
+ if (!contentType) return false;
1296
+ const base = contentType.split(";")[0].trim().toLowerCase();
1297
+ return STREAMING_CONTENT_TYPES.has(base);
1298
+ }
1299
+ function extractRawBody(req) {
1300
+ const raw = req.rawBody;
1301
+ if (raw) return raw instanceof Uint8Array ? raw : new Uint8Array(raw);
1302
+ const body = req.body;
1303
+ if (body === void 0 || body === null) return null;
1304
+ if (typeof body === "string") return new TextEncoder().encode(body);
1305
+ if (body instanceof Uint8Array) return body;
1306
+ return new Uint8Array(0);
1307
+ }
1308
+ function headerValue(headers, name) {
1309
+ const value = headers[name] ?? headers[name.toLowerCase()];
1310
+ if (Array.isArray(value)) return value[0];
1311
+ return value;
1312
+ }
1313
+
1314
+ // src/generated/metering-contract.ts
1315
+ var METERING_HEADERS = {
1316
+ payload: "x-fs-metering",
1317
+ signature: "x-fs-metering-sig",
1318
+ token: "x-fs-metering-token"
1319
+ };
1320
+ var METERING_TOKEN_ENV = "FARTHERSHORE_METERING_TOKEN";
1321
+ var METERING_ERROR_CODES = {
1322
+ missingToken: "missing_token",
1323
+ invalidMeterKey: "invalid_meter_key",
1324
+ invalidMeterValue: "invalid_meter_value"
1325
+ };
1326
+
1327
+ // src/legacy/metering.ts
1328
+ var METERING_PAYLOAD_HEADER = METERING_HEADERS.payload;
1329
+ var METERING_SIGNATURE_HEADER = METERING_HEADERS.signature;
1330
+ var METERING_TOKEN_HEADER = METERING_HEADERS.token;
1331
+ var DEFAULT_TOKEN_ENV = METERING_TOKEN_ENV;
1332
+ var MeteringError = class extends Error {
1333
+ code;
1334
+ constructor(code, message) {
1335
+ super(message);
1336
+ this.name = "MeteringError";
1337
+ this.code = code;
1338
+ }
1339
+ };
1340
+ function createUsage(request, options = {}) {
1341
+ const usage = {};
1342
+ const reporter = {
1343
+ report(meter, value) {
1344
+ usage[assertMeterKey(meter)] = assertMeterValue(meter, value);
1345
+ return reporter;
1346
+ },
1347
+ async wrap(response) {
1348
+ return signResponse(request, response, usage, options);
1349
+ }
1350
+ };
1351
+ return reporter;
1352
+ }
1353
+ async function withUsage(request, response, usage, options = {}) {
1354
+ const reporter = createUsage(request, options);
1355
+ for (const [meter, value] of Object.entries(usage)) {
1356
+ reporter.report(meter, value);
1357
+ }
1358
+ return reporter.wrap(response);
1359
+ }
1360
+ async function signResponse(request, response, usage, options) {
1361
+ const token = resolveToken(options);
1362
+ const payload = buildPayload(request, usage);
1363
+ const signature = await signPayload(payload, token);
1364
+ const headers = new Headers(response.headers);
1365
+ headers.set(METERING_PAYLOAD_HEADER, payload);
1366
+ headers.set(METERING_SIGNATURE_HEADER, signature);
1367
+ headers.set(METERING_TOKEN_HEADER, token);
1368
+ return new Response(response.body, {
1369
+ status: response.status,
1370
+ statusText: response.statusText,
1371
+ headers
1372
+ });
1373
+ }
1374
+ function buildPayload(request, usage) {
1375
+ const url = new URL(request.url);
1376
+ const payload = {
1377
+ method: request.method.toUpperCase(),
1378
+ path: url.pathname,
1379
+ rawDimsUnits: sortUsage(usage)
1380
+ };
1381
+ return JSON.stringify(payload);
1382
+ }
1383
+ function sortUsage(usage) {
1384
+ return Object.fromEntries(
1385
+ Object.entries(usage).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
1386
+ );
1387
+ }
1388
+ function assertMeterKey(meter) {
1389
+ if (!/^[a-z0-9_]{1,64}$/.test(meter)) {
1390
+ throw new MeteringError(
1391
+ METERING_ERROR_CODES.invalidMeterKey,
1392
+ `meter key "${meter}" must be lowercase alphanumeric with underscores`
1393
+ );
1394
+ }
1395
+ return meter;
1396
+ }
1397
+ function assertMeterValue(meter, value) {
1398
+ if (!Number.isFinite(value) || value < 0) {
1399
+ throw new MeteringError(
1400
+ METERING_ERROR_CODES.invalidMeterValue,
1401
+ `meter "${meter}" value must be a non-negative finite number`
1402
+ );
1403
+ }
1404
+ return value;
1405
+ }
1406
+ function resolveToken(options) {
1407
+ const token = options.token ?? options.env?.[DEFAULT_TOKEN_ENV] ?? processEnv(DEFAULT_TOKEN_ENV);
1408
+ if (!token) {
1409
+ throw new MeteringError(
1410
+ METERING_ERROR_CODES.missingToken,
1411
+ `${DEFAULT_TOKEN_ENV} is required to sign Farther Shore metering reports`
1412
+ );
1413
+ }
1414
+ return token;
1415
+ }
1416
+ function processEnv(key) {
1417
+ const maybeProcess = globalThis.process;
1418
+ return maybeProcess?.env?.[key];
1419
+ }
1420
+ async function signPayload(payload, token) {
1421
+ const key = await crypto.subtle.importKey(
1422
+ "raw",
1423
+ new TextEncoder().encode(token),
1424
+ { name: "HMAC", hash: "SHA-256" },
1425
+ false,
1426
+ ["sign"]
1427
+ );
1428
+ const signature = await crypto.subtle.sign(
1429
+ "HMAC",
1430
+ key,
1431
+ new TextEncoder().encode(payload)
1432
+ );
1433
+ return base64url(new Uint8Array(signature));
1434
+ }
1435
+ function base64url(bytes) {
1436
+ let binary = "";
1437
+ for (const byte of bytes) {
1438
+ binary += String.fromCharCode(byte);
1439
+ }
1440
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1441
+ }
1442
+
1443
+ // src/index.ts
1444
+ var fartherShore = {
1445
+ /** Derive everything from FS_RUNTIME_TOKEN via bootstrap. */
1446
+ initFromEnv(options = {}) {
1447
+ const fs = initFromEnv(options);
1448
+ fs.middleware = (mwOptions) => createExpressMiddleware(fs, mwOptions);
1449
+ return fs;
1450
+ }
1451
+ };
1452
+ function initFromEnv2(options = {}) {
1453
+ return fartherShore.initFromEnv(options);
1454
+ }
1455
+ export {
1456
+ BootstrapClient,
1457
+ CloudflaredSupervisor,
1458
+ DEFAULT_TOKEN_ENV,
1459
+ EMPTY_BODY_SHA256,
1460
+ FS_RUNTIME_TOKEN_ENV,
1461
+ FartherShore,
1462
+ FartherShoreError,
1463
+ JwksClient,
1464
+ MAX_BODY_BYTES,
1465
+ METERING_PAYLOAD_HEADER,
1466
+ METERING_SIGNATURE_HEADER,
1467
+ METERING_TOKEN_HEADER,
1468
+ MeteringClient,
1469
+ MeteringError,
1470
+ NonceCache,
1471
+ REDACTED_TOKEN,
1472
+ RUNTIME_CLOCK_SKEW_SECONDS,
1473
+ RUNTIME_ERROR_CODES,
1474
+ RUNTIME_HEADER_NAMES,
1475
+ RUNTIME_REPLAY_WINDOW_SECONDS,
1476
+ RUNTIME_TOKEN_CAPABILITIES,
1477
+ RUNTIME_TOKEN_PREFIXES,
1478
+ STREAMING_EXEMPT_BODY_HASH,
1479
+ ShutdownManager,
1480
+ buildCanonicalSigningString2 as buildCanonicalSigningString,
1481
+ buildHealthReport,
1482
+ canonicalizeQuery2 as canonicalizeQuery,
1483
+ createExpressMiddleware,
1484
+ createUsage,
1485
+ fartherShore,
1486
+ hashBody2 as hashBody,
1487
+ initFromEnv2 as initFromEnv,
1488
+ nodeSpawn,
1489
+ reportHealth,
1490
+ runtimeTokenKind2 as runtimeTokenKind,
1491
+ signCanonicalString2 as signCanonicalString,
1492
+ statusForCode,
1493
+ verifyCanonicalSignature2 as verifyCanonicalSignature,
1494
+ verifyRequest,
1495
+ withUsage
1496
+ };