@farthershore/backend 0.12.0 → 0.13.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 CHANGED
@@ -1583,7 +1583,7 @@ function headerGetter(headers) {
1583
1583
 
1584
1584
  // src/core/runtime.ts
1585
1585
  var DEFAULT_CORE_URL = "https://core.farthershore.com";
1586
- var SDK_VERSION = "0.12.0".length > 0 ? "0.12.0" : "0.0.0-dev";
1586
+ var SDK_VERSION = "0.13.0".length > 0 ? "0.13.0" : "0.0.0-dev";
1587
1587
  var CONTRACTS_FP = "220bea90107ed396".length > 0 ? "220bea90107ed396" : "0000000000000000";
1588
1588
  var FartherShore = class {
1589
1589
  bootstrapClient;
@@ -1906,6 +1906,10 @@ function headerValue(headers, name) {
1906
1906
  // src/response-metering.ts
1907
1907
  var RESPONSE_METERING_HEADERS = RUNTIME_RESPONSE_METERING_CONTRACT.headers;
1908
1908
  var RESPONSE_METERING_ERROR_CODES = RUNTIME_RESPONSE_METERING_CONTRACT.errors;
1909
+ var devMeteringHooks = null;
1910
+ function __setDevMeteringHooks(hooks) {
1911
+ devMeteringHooks = hooks;
1912
+ }
1909
1913
  var METERING_PAYLOAD_HEADER = RESPONSE_METERING_HEADERS.payload;
1910
1914
  var METERING_SIGNATURE_HEADER = RESPONSE_METERING_HEADERS.signature;
1911
1915
  var METERING_TOKEN_HEADER = RESPONSE_METERING_HEADERS.token;
@@ -1939,19 +1943,60 @@ async function withUsage(request, response, usage, options = {}) {
1939
1943
  return reporter.wrap(response);
1940
1944
  }
1941
1945
  async function signResponse(request, response, usage, options, wrapOptions) {
1942
- const token = resolveToken(options);
1943
1946
  const payload = buildPayload(request, usage, options, wrapOptions);
1944
- const signature = await signPayload(payload, token);
1945
- const headers = new Headers(response.headers);
1946
- headers.set(METERING_PAYLOAD_HEADER, payload);
1947
- headers.set(METERING_SIGNATURE_HEADER, signature);
1948
- headers.set(METERING_TOKEN_HEADER, token);
1947
+ const requestId = request.headers.get("x-fs-request-id") ?? void 0;
1948
+ const headers = await computeMeteringHeaders(payload, {
1949
+ ...options.token !== void 0 ? { token: options.token } : {},
1950
+ ...options.env !== void 0 ? { env: options.env } : {},
1951
+ ...requestId ? { requestId } : {},
1952
+ onSkip: () => {
1953
+ }
1954
+ });
1955
+ if (Object.keys(headers).length === 0) {
1956
+ throw new MeteringError(
1957
+ RESPONSE_METERING_ERROR_CODES.missingToken,
1958
+ `${DEFAULT_TOKEN_ENV} is required to sign Farther Shore metering reports`
1959
+ );
1960
+ }
1961
+ const merged = new Headers(response.headers);
1962
+ for (const [name, value] of Object.entries(headers)) merged.set(name, value);
1949
1963
  return new Response(response.body, {
1950
1964
  status: response.status,
1951
1965
  statusText: response.statusText,
1952
- headers
1966
+ headers: merged
1953
1967
  });
1954
1968
  }
1969
+ async function computeMeteringHeaders(payload, options = {}) {
1970
+ try {
1971
+ const token = resolveTokenSoft(options);
1972
+ if (!token) {
1973
+ skip(`${DEFAULT_TOKEN_ENV} is not set`, options);
1974
+ return {};
1975
+ }
1976
+ const json2 = JSON.stringify(payload);
1977
+ const signature = await signPayload(json2, token);
1978
+ devMeteringHooks?.record?.(payload, options.requestId);
1979
+ return {
1980
+ [METERING_PAYLOAD_HEADER]: json2,
1981
+ [METERING_SIGNATURE_HEADER]: signature,
1982
+ [METERING_TOKEN_HEADER]: token
1983
+ };
1984
+ } catch (error) {
1985
+ skip(error instanceof Error ? error.message : String(error), options);
1986
+ return {};
1987
+ }
1988
+ }
1989
+ function skip(reason, options) {
1990
+ if (options.onSkip) {
1991
+ options.onSkip(reason);
1992
+ } else {
1993
+ console.warn(`metering headers skipped: ${reason}`);
1994
+ }
1995
+ devMeteringHooks?.onSkip?.(reason, options.requestId);
1996
+ }
1997
+ function resolveTokenSoft(options) {
1998
+ return options.token ?? options.env?.[DEFAULT_TOKEN_ENV] ?? processEnv(DEFAULT_TOKEN_ENV) ?? devMeteringHooks?.fallbackToken?.();
1999
+ }
1955
2000
  function buildPayload(request, usage, options, wrapOptions) {
1956
2001
  const url = new URL(request.url);
1957
2002
  const measureContext = wrapOptions.measureContext ?? options.measureContext;
@@ -1971,7 +2016,7 @@ function buildPayload(request, usage, options, wrapOptions) {
1971
2016
  ...operationKey ? { operationKey: assertIdentifier(operationKey) } : {},
1972
2017
  ...usagePolicyId ? { usagePolicyId: assertIdentifier(usagePolicyId) } : {}
1973
2018
  };
1974
- return JSON.stringify(payload);
2019
+ return payload;
1975
2020
  }
1976
2021
  function sortUsage(usage) {
1977
2022
  return Object.fromEntries(
@@ -2013,16 +2058,6 @@ function assertIdentifier(value) {
2013
2058
  }
2014
2059
  return value;
2015
2060
  }
2016
- function resolveToken(options) {
2017
- const token = options.token ?? options.env?.[DEFAULT_TOKEN_ENV] ?? processEnv(DEFAULT_TOKEN_ENV);
2018
- if (!token) {
2019
- throw new MeteringError(
2020
- RESPONSE_METERING_ERROR_CODES.missingToken,
2021
- `${DEFAULT_TOKEN_ENV} is required to sign Farther Shore metering reports`
2022
- );
2023
- }
2024
- return token;
2025
- }
2026
2061
  function processEnv(key2) {
2027
2062
  const maybeProcess = globalThis.process;
2028
2063
  return maybeProcess?.env?.[key2];
@@ -2050,10 +2085,753 @@ function base64url(bytes) {
2050
2085
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
2051
2086
  }
2052
2087
 
2088
+ // src/testing/signers.ts
2089
+ import { generateKeyPairSync, randomBytes } from "node:crypto";
2090
+ var TEST_KID = "fs-runtime-test-2026";
2091
+ var TEST_PRIVATE_JWK = {
2092
+ kty: "OKP",
2093
+ crv: "Ed25519",
2094
+ d: "nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A",
2095
+ x: "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"
2096
+ };
2097
+ var TEST_CONTEXT_SECRET = "fs-dev-context-secret-2026";
2098
+ var TEST_CONTEXT_KID = "fs-context-test-2026";
2099
+ async function makeSignedRequest(spec = {}) {
2100
+ const method = spec.method ?? "POST";
2101
+ const path = spec.path ?? "/v1/chat/completions";
2102
+ const query = spec.query ?? "";
2103
+ const body = spec.body ?? null;
2104
+ const streamingExempt = spec.streamingExempt ?? false;
2105
+ const privateJwk = spec.privateJwk ?? TEST_PRIVATE_JWK;
2106
+ const kid = spec.kid ?? TEST_KID;
2107
+ const bodyHash = streamingExempt ? "STREAM" : body && body.byteLength > 0 ? await hashBody2(body) : EMPTY_BODY_SHA256;
2108
+ const claim = {
2109
+ method,
2110
+ path,
2111
+ query,
2112
+ bodyHash,
2113
+ requestId: spec.requestId ?? `req_${cryptoRandom()}`,
2114
+ timestamp: spec.timestamp ?? Math.floor(Date.now() / 1e3),
2115
+ productId: spec.productId ?? "prod_test",
2116
+ backendId: spec.backendId ?? "be_test",
2117
+ routeId: spec.routeId ?? "route_test",
2118
+ policyVersion: spec.policyVersion ?? "pv_1"
2119
+ };
2120
+ const canonical = buildCanonicalSigningString2(claim);
2121
+ const signature = await signCanonicalString2(canonical, privateJwk);
2122
+ const headers = {
2123
+ [RUNTIME_HEADER_NAMES.signature]: signature,
2124
+ [RUNTIME_HEADER_NAMES.keyId]: kid,
2125
+ [RUNTIME_HEADER_NAMES.requestId]: claim.requestId,
2126
+ [RUNTIME_HEADER_NAMES.timestamp]: String(claim.timestamp),
2127
+ [RUNTIME_HEADER_NAMES.productId]: claim.productId,
2128
+ [RUNTIME_HEADER_NAMES.backendId]: claim.backendId,
2129
+ [RUNTIME_HEADER_NAMES.routeId]: claim.routeId,
2130
+ [RUNTIME_HEADER_NAMES.policyVersion]: claim.policyVersion,
2131
+ [RUNTIME_HEADER_NAMES.bodyHash]: claim.bodyHash
2132
+ };
2133
+ return {
2134
+ input: { method, path, query, body, streamingExempt },
2135
+ claim,
2136
+ headers
2137
+ };
2138
+ }
2139
+ function base64urlEncodeBytes(bytes) {
2140
+ let binary = "";
2141
+ for (const byte of bytes) binary += String.fromCharCode(byte);
2142
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
2143
+ }
2144
+ function base64urlEncodeJson(value) {
2145
+ return base64urlEncodeBytes(new TextEncoder().encode(JSON.stringify(value)));
2146
+ }
2147
+ async function signContextToken(claim, secret = TEST_CONTEXT_SECRET, kid = TEST_CONTEXT_KID) {
2148
+ const header = base64urlEncodeJson({ alg: "HS256", typ: "JWT", kid });
2149
+ const payload = base64urlEncodeJson({ cv: 1, ...claim });
2150
+ const signingInput = `${header}.${payload}`;
2151
+ const key2 = await crypto.subtle.importKey(
2152
+ "raw",
2153
+ new TextEncoder().encode(secret),
2154
+ { name: "HMAC", hash: "SHA-256" },
2155
+ false,
2156
+ ["sign"]
2157
+ );
2158
+ const signature = await crypto.subtle.sign(
2159
+ "HMAC",
2160
+ key2,
2161
+ new TextEncoder().encode(signingInput)
2162
+ );
2163
+ return `${signingInput}.${base64urlEncodeBytes(new Uint8Array(signature))}`;
2164
+ }
2165
+ function generateDevSignerKeys() {
2166
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
2167
+ const privateJwk = privateKey.export({ format: "jwk" });
2168
+ const publicJwk = publicKey.export({ format: "jwk" });
2169
+ const kid = `fs-dev-${randomBytes(6).toString("hex")}`;
2170
+ const contextKid = `fs-dev-ctx-${randomBytes(6).toString("hex")}`;
2171
+ const contextSecret = randomBytes(32).toString("base64url");
2172
+ const runtimeToken = `${RUNTIME_TOKEN_PREFIXES.test}${randomBytes(24).toString("base64url")}`;
2173
+ return {
2174
+ kid,
2175
+ privateJwk,
2176
+ publicJwk,
2177
+ contextKid,
2178
+ contextSecret,
2179
+ runtimeToken
2180
+ };
2181
+ }
2182
+ function cryptoRandom() {
2183
+ return crypto.randomUUID().replace(/-/g, "").slice(0, 16);
2184
+ }
2185
+
2186
+ // src/testing/personas.ts
2187
+ function definePersona(def) {
2188
+ return def;
2189
+ }
2190
+ var DEFAULT_PERSONAS = {
2191
+ owner: definePersona({
2192
+ name: "owner",
2193
+ permissions: ["*"],
2194
+ roles: ["owner"],
2195
+ subjectKey: "owner"
2196
+ }),
2197
+ admin: definePersona({
2198
+ name: "admin",
2199
+ permissions: ["*"],
2200
+ roles: ["admin"],
2201
+ subjectKey: "admin"
2202
+ }),
2203
+ member: definePersona({
2204
+ name: "member",
2205
+ permissions: [],
2206
+ roles: ["member"],
2207
+ subjectKey: "member"
2208
+ }),
2209
+ anonymous: definePersona({ name: "anonymous", anonymous: true })
2210
+ };
2211
+ function normalizeBody(body) {
2212
+ if (body === null || body === void 0) return null;
2213
+ if (typeof body === "string") return new TextEncoder().encode(body);
2214
+ if (body instanceof Uint8Array) return body;
2215
+ if (body instanceof ArrayBuffer) return new Uint8Array(body);
2216
+ return null;
2217
+ }
2218
+ function normalizeMethod(method) {
2219
+ return (method ?? "GET").toUpperCase();
2220
+ }
2221
+ function mergeHeaders(initHeaders, signedHeaders) {
2222
+ const headers = new Headers(initHeaders);
2223
+ for (const [name, value] of Object.entries(signedHeaders)) {
2224
+ headers.set(name, value);
2225
+ }
2226
+ return headers;
2227
+ }
2228
+ function buildContextClaim(persona, productId) {
2229
+ return {
2230
+ orgId: persona.orgId ?? "org_dev",
2231
+ actor: persona.actor ?? { type: "user", id: `user_${persona.name}` },
2232
+ // Product binding: the signed context productId MUST equal the signed
2233
+ // request productId or verifyRequest rejects it as tamper evidence.
2234
+ productId,
2235
+ compiledPlanId: persona.compiledPlanId ?? "plan_dev",
2236
+ subscriptionId: persona.subscriptionId ?? "sub_dev",
2237
+ subscriberId: persona.subscriberId ?? "subscriber_dev",
2238
+ environmentId: persona.environmentId ?? null,
2239
+ subjectKey: persona.subjectKey ?? persona.name,
2240
+ ...persona.permissions !== void 0 ? { permissions: persona.permissions } : {},
2241
+ ...persona.roles !== void 0 ? { roles: persona.roles } : {}
2242
+ };
2243
+ }
2244
+ function createPersonaClient(ctx) {
2245
+ function resolve(name) {
2246
+ const persona = ctx.personas.get(name);
2247
+ if (!persona) {
2248
+ throw new Error(
2249
+ `unknown persona "${name}" (known: ${[...ctx.personas.keys()].join(", ")})`
2250
+ );
2251
+ }
2252
+ return persona;
2253
+ }
2254
+ async function buildHeaders(persona, spec) {
2255
+ const method = normalizeMethod(spec.method);
2256
+ const signed = await makeSignedRequest({
2257
+ method,
2258
+ path: spec.path ?? "/",
2259
+ query: spec.query ?? "",
2260
+ body: spec.body ?? null,
2261
+ streamingExempt: spec.streamingExempt ?? false,
2262
+ productId: ctx.productId,
2263
+ backendId: ctx.backendId,
2264
+ routeId: spec.routeId ?? "",
2265
+ privateJwk: ctx.keys.privateJwk,
2266
+ kid: ctx.keys.kid,
2267
+ ...spec.requestId ? { requestId: spec.requestId } : {},
2268
+ ...spec.timestamp !== void 0 ? { timestamp: spec.timestamp } : {}
2269
+ });
2270
+ const headers = { ...signed.headers };
2271
+ if (!persona.anonymous) {
2272
+ const claim = buildContextClaim(persona, ctx.productId);
2273
+ headers["x-fs-context"] = await signContextToken(
2274
+ claim,
2275
+ ctx.contextSecret,
2276
+ ctx.contextKid
2277
+ );
2278
+ }
2279
+ return headers;
2280
+ }
2281
+ function asPersona(name) {
2282
+ const persona = resolve(name);
2283
+ return {
2284
+ persona: name,
2285
+ headers: (spec = {}) => buildHeaders(persona, spec),
2286
+ async fetch(url, init = {}) {
2287
+ const parsed = new URL(url);
2288
+ const method = normalizeMethod(init.method);
2289
+ const headers = await buildHeaders(persona, {
2290
+ method,
2291
+ path: parsed.pathname,
2292
+ query: parsed.search.replace(/^\?/, ""),
2293
+ body: normalizeBody(init.body)
2294
+ });
2295
+ const doFetch = ctx.fetchImpl ?? globalThis.fetch;
2296
+ return doFetch(url, {
2297
+ ...init,
2298
+ method,
2299
+ headers: mergeHeaders(init.headers, headers)
2300
+ });
2301
+ },
2302
+ async inject(req, spec = {}) {
2303
+ const rawUrl = spec.path ?? req.url ?? req.path ?? "/";
2304
+ const qIndex = rawUrl.indexOf("?");
2305
+ const path = qIndex === -1 ? rawUrl : rawUrl.slice(0, qIndex);
2306
+ const query = spec.query ?? (qIndex === -1 ? "" : rawUrl.slice(qIndex + 1));
2307
+ const method = normalizeMethod(spec.method ?? req.method);
2308
+ const headers = await buildHeaders(persona, {
2309
+ method,
2310
+ path,
2311
+ query,
2312
+ body: spec.body ?? null,
2313
+ ...spec.streamingExempt !== void 0 ? { streamingExempt: spec.streamingExempt } : {},
2314
+ ...spec.routeId ? { routeId: spec.routeId } : {}
2315
+ });
2316
+ for (const [field, value] of Object.entries(headers)) {
2317
+ req.set(field, value);
2318
+ }
2319
+ return req;
2320
+ }
2321
+ };
2322
+ }
2323
+ return { asPersona, personas: ctx.personas };
2324
+ }
2325
+ function buildPersonaMap(overrides) {
2326
+ const map = /* @__PURE__ */ new Map();
2327
+ for (const [name, def] of Object.entries(DEFAULT_PERSONAS)) {
2328
+ map.set(name, def);
2329
+ }
2330
+ if (Array.isArray(overrides)) {
2331
+ for (const def of overrides) map.set(def.name, def);
2332
+ } else if (overrides) {
2333
+ for (const [name, def] of Object.entries(overrides)) {
2334
+ map.set(name, { ...def, name });
2335
+ }
2336
+ }
2337
+ return map;
2338
+ }
2339
+
2340
+ // src/testing/devGateway.ts
2341
+ var DEV_CORE_URL = "https://dev-gateway.farthershore.local";
2342
+ var DEV_JWKS_URL = `${DEV_CORE_URL}/.well-known/jwks.json`;
2343
+ var DEV_METERING_ENDPOINT = `${DEV_CORE_URL}/v1/metering/events`;
2344
+ function createDevGateway(options) {
2345
+ const productId = options.productId ?? "prod_dev";
2346
+ const backendId = options.backendId ?? "be_dev";
2347
+ const meterEvents = [];
2348
+ const bootstrap = {
2349
+ product: { id: productId, slug: options.productSlug ?? "dev-product" },
2350
+ backend: {
2351
+ id: backendId,
2352
+ slug: options.backendSlug ?? "dev-backend",
2353
+ name: "Dev Backend"
2354
+ },
2355
+ environment: { id: null, kind: "test" },
2356
+ capabilities: ["gateway_verification", "metering", "health"],
2357
+ verification: {
2358
+ required: options.mode === "simulated",
2359
+ jwksUrl: DEV_JWKS_URL,
2360
+ clockSkewSeconds: 5,
2361
+ replayWindowSeconds: 300,
2362
+ headerNames: RUNTIME_HEADER_NAMES
2363
+ },
2364
+ metering: {
2365
+ enabled: true,
2366
+ endpoint: DEV_METERING_ENDPOINT,
2367
+ credential: options.keys.runtimeToken,
2368
+ allowedMeters: [],
2369
+ allowedRoutes: [],
2370
+ perEventMax: 0
2371
+ },
2372
+ transport: { mode: "direct", runner: null },
2373
+ routes: (options.routeIds ?? []).map((id) => ({
2374
+ id,
2375
+ method: "POST",
2376
+ path: `/${id}`,
2377
+ backendId
2378
+ })),
2379
+ policyVersion: "pv_dev",
2380
+ refreshAfterSeconds: 3600
2381
+ };
2382
+ const jwksDoc = {
2383
+ keys: [{ ...options.keys.publicJwk, kid: options.keys.kid }]
2384
+ };
2385
+ const fetchImpl = async (input, init) => {
2386
+ const url = String(
2387
+ typeof input === "string" || input instanceof URL ? input : input.url
2388
+ );
2389
+ if (url.includes("/v1/runtime/bootstrap")) {
2390
+ return json(bootstrap);
2391
+ }
2392
+ if (url.includes("/.well-known/jwks.json") || url.includes("jwks")) {
2393
+ return json(jwksDoc);
2394
+ }
2395
+ if (url.includes("/v1/metering/events")) {
2396
+ const event = await readJsonBody(init, input);
2397
+ if (event) {
2398
+ meterEvents.push(event);
2399
+ options.onMeterEvent?.(event);
2400
+ }
2401
+ return json({ ok: true });
2402
+ }
2403
+ if (url.includes("/v1/runtime/health") || url.includes("/v1/runtime/drift")) {
2404
+ return json({ ok: true });
2405
+ }
2406
+ return new Response(JSON.stringify({ error: "not_found" }), {
2407
+ status: 404,
2408
+ headers: { "content-type": "application/json" }
2409
+ });
2410
+ };
2411
+ return {
2412
+ fetchImpl,
2413
+ bootstrap,
2414
+ meterEvents,
2415
+ productId,
2416
+ backendId,
2417
+ jwksUrl: DEV_JWKS_URL
2418
+ };
2419
+ }
2420
+ function json(body, status = 200) {
2421
+ return new Response(JSON.stringify(body), {
2422
+ status,
2423
+ headers: { "content-type": "application/json" }
2424
+ });
2425
+ }
2426
+ async function readJsonBody(init, input) {
2427
+ try {
2428
+ if (init?.body && typeof init.body === "string") {
2429
+ return JSON.parse(init.body);
2430
+ }
2431
+ if (input instanceof Request) {
2432
+ return await input.clone().json();
2433
+ }
2434
+ } catch {
2435
+ return null;
2436
+ }
2437
+ return null;
2438
+ }
2439
+
2440
+ // src/testing/devRuntime.ts
2441
+ import { appendFileSync, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2 } from "node:fs";
2442
+ import { dirname as dirname2 } from "node:path";
2443
+
2444
+ // src/testing/prodGuard.ts
2445
+ function isProductionEnv(env = readProcessEnv2()) {
2446
+ return (env.NODE_ENV ?? "").trim().toLowerCase() === "production";
2447
+ }
2448
+ var DevModeInProductionError = class extends Error {
2449
+ constructor(context) {
2450
+ super(
2451
+ `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.`
2452
+ );
2453
+ this.name = "DevModeInProductionError";
2454
+ }
2455
+ };
2456
+ function assertNotProduction(context, env = readProcessEnv2()) {
2457
+ if (isProductionEnv(env)) {
2458
+ throw new DevModeInProductionError(context);
2459
+ }
2460
+ }
2461
+ function readProcessEnv2() {
2462
+ const maybeProcess = globalThis.process;
2463
+ return maybeProcess?.env ?? {};
2464
+ }
2465
+
2466
+ // src/testing/usageSink.ts
2467
+ var DevUsageSink = class {
2468
+ events = [];
2469
+ /** Record a signed response-metering payload (withUsage / computeMeteringHeaders). */
2470
+ recordResponse(payload, requestId) {
2471
+ const raw = payload.rawDimsUnits;
2472
+ const meters = raw && typeof raw === "object" ? raw : {};
2473
+ this.events.push({
2474
+ source: "response",
2475
+ meters: { ...meters },
2476
+ payload,
2477
+ ...requestId ? { requestId } : {},
2478
+ at: Date.now()
2479
+ });
2480
+ }
2481
+ /** Record a background `fs.meter()` event captured by the dev gateway. */
2482
+ recordMeterEvent(event) {
2483
+ this.events.push({
2484
+ source: "meter",
2485
+ meters: { [event.meter]: event.qty },
2486
+ event,
2487
+ ...event.request_id ? { requestId: event.request_id } : {},
2488
+ at: Date.now()
2489
+ });
2490
+ }
2491
+ /** Total quantity per meter key across every recorded event. */
2492
+ byMeter() {
2493
+ const out = {};
2494
+ for (const evt of this.events) {
2495
+ for (const [meter, qty] of Object.entries(evt.meters)) {
2496
+ out[meter] = (out[meter] ?? 0) + qty;
2497
+ }
2498
+ }
2499
+ return out;
2500
+ }
2501
+ /** Clear all recorded usage. */
2502
+ reset() {
2503
+ this.events.length = 0;
2504
+ }
2505
+ };
2506
+
2507
+ // src/testing/traceSink.ts
2508
+ function redactValue(input) {
2509
+ 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]");
2510
+ }
2511
+ var DevTraceSink = class {
2512
+ traces = /* @__PURE__ */ new Map();
2513
+ flushed = /* @__PURE__ */ new Set();
2514
+ appendLine;
2515
+ constructor(options = {}) {
2516
+ this.appendLine = options.appendLine;
2517
+ }
2518
+ ensure(requestId, mode) {
2519
+ let trace = this.traces.get(requestId);
2520
+ if (!trace) {
2521
+ trace = { requestId, mode, authz: [], metering: [] };
2522
+ this.traces.set(requestId, trace);
2523
+ }
2524
+ return trace;
2525
+ }
2526
+ /** Record how a request's signature/context verification resolved. */
2527
+ recordVerification(requestId, mode, outcome, fields = {}) {
2528
+ const trace = this.ensure(requestId, mode);
2529
+ if (fields.method) trace.method = fields.method;
2530
+ if (fields.path) trace.path = fields.path;
2531
+ if (fields.persona) trace.persona = fields.persona;
2532
+ trace.verification = {
2533
+ outcome,
2534
+ ...fields.reason ? { reason: redactValue(fields.reason) } : {}
2535
+ };
2536
+ }
2537
+ /** Record a single hasPermission / requirePermission decision. */
2538
+ recordAuthz(requestId, mode, entry) {
2539
+ const trace = this.ensure(requestId, mode);
2540
+ trace.authz.push({
2541
+ permission: entry.permission,
2542
+ decision: entry.decision,
2543
+ ...entry.reason ? { reason: redactValue(entry.reason) } : {}
2544
+ });
2545
+ }
2546
+ /** Record usage reported for this request. */
2547
+ recordMetering(requestId, mode, entry) {
2548
+ const trace = this.ensure(requestId, mode);
2549
+ trace.metering.push(entry);
2550
+ }
2551
+ /** Record the final response status and flush the trace as one JSONL line. */
2552
+ recordResponse(requestId, mode, status) {
2553
+ const trace = this.ensure(requestId, mode);
2554
+ trace.response = { status };
2555
+ this.flush(requestId);
2556
+ }
2557
+ /**
2558
+ * Append a trace's current state as one JSONL line (if a sink is wired).
2559
+ * Flushes at most ONCE per request id, so wrapping several response methods
2560
+ * (status/json/end) never produces duplicate JSONL lines.
2561
+ */
2562
+ flush(requestId) {
2563
+ if (this.flushed.has(requestId)) return;
2564
+ const trace = this.traces.get(requestId);
2565
+ if (trace && this.appendLine) {
2566
+ this.appendLine(JSON.stringify(trace));
2567
+ this.flushed.add(requestId);
2568
+ }
2569
+ }
2570
+ /** The accumulated trace for a request id, or `undefined`. */
2571
+ forRequest(requestId) {
2572
+ return this.traces.get(requestId);
2573
+ }
2574
+ /** Every accumulated trace (insertion order). */
2575
+ all() {
2576
+ return [...this.traces.values()];
2577
+ }
2578
+ /** Clear all traces. */
2579
+ reset() {
2580
+ this.traces.clear();
2581
+ this.flushed.clear();
2582
+ }
2583
+ };
2584
+
2585
+ // src/testing/keysFile.ts
2586
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2587
+ import { dirname } from "node:path";
2588
+ var DEFAULT_KEYS_FILE = ".farthershore/dev-keys.json";
2589
+ function writeDevKeysFile(path, contents) {
2590
+ mkdirSync(dirname(path), { recursive: true });
2591
+ writeFileSync(path, JSON.stringify(contents, null, 2), { mode: 384 });
2592
+ chmodSync(path, 384);
2593
+ }
2594
+
2595
+ // src/testing/devRuntime.ts
2596
+ function createDevRuntime(options) {
2597
+ assertNotProduction("createDevRuntime", options.env);
2598
+ const mode = options.mode;
2599
+ const keys = options.keys ?? generateDevSignerKeys();
2600
+ const usage = new DevUsageSink();
2601
+ const trace = new DevTraceSink(
2602
+ options.traceJsonl ? { appendLine: options.traceJsonl } : {}
2603
+ );
2604
+ const gateway = createDevGateway({
2605
+ mode,
2606
+ keys,
2607
+ ...options.productId ? { productId: options.productId } : {},
2608
+ ...options.backendId ? { backendId: options.backendId } : {},
2609
+ ...options.routes ? { routeIds: options.routes } : {},
2610
+ onMeterEvent: (event) => {
2611
+ usage.recordMeterEvent(event);
2612
+ options.usageJsonl?.(JSON.stringify({ source: "meter", event }));
2613
+ }
2614
+ });
2615
+ __setDevMeteringHooks({
2616
+ fallbackToken: () => keys.runtimeToken,
2617
+ record: (payload, requestId) => {
2618
+ usage.recordResponse(payload, requestId);
2619
+ options.usageJsonl?.(JSON.stringify({ source: "response", payload }));
2620
+ if (requestId) {
2621
+ const raw = payload.rawDimsUnits;
2622
+ trace.recordMetering(requestId, mode, {
2623
+ meters: raw && typeof raw === "object" ? raw : {},
2624
+ source: "response"
2625
+ });
2626
+ }
2627
+ }
2628
+ });
2629
+ const personas = buildPersonaMap(options.personas);
2630
+ const personaClient = createPersonaClient({
2631
+ keys,
2632
+ productId: gateway.productId,
2633
+ backendId: gateway.backendId,
2634
+ contextSecret: keys.contextSecret,
2635
+ contextKid: keys.contextKid,
2636
+ personas,
2637
+ mode,
2638
+ ...options.appFetch ? { fetchImpl: options.appFetch } : {}
2639
+ });
2640
+ const fs = initFromEnv({
2641
+ runtimeToken: keys.runtimeToken,
2642
+ coreUrl: DEV_CORE_URL,
2643
+ fetchImpl: gateway.fetchImpl,
2644
+ contextSecrets: [keys.contextSecret],
2645
+ contextVerification: mode === "simulated" ? "required" : "preferred",
2646
+ env: {}
2647
+ });
2648
+ const tracedAuthz = {
2649
+ hasPermission(ctx, key2) {
2650
+ const decision = hasPermission(ctx, key2);
2651
+ recordDecision(ctx, key2, decision);
2652
+ return decision;
2653
+ },
2654
+ requirePermission(ctx, key2) {
2655
+ const decision = hasPermission(ctx, key2);
2656
+ recordDecision(ctx, key2, decision);
2657
+ if (!decision) throw new FartherShorePermissionError(key2);
2658
+ requirePermission(ctx, key2);
2659
+ }
2660
+ };
2661
+ function recordDecision(ctx, key2, decision) {
2662
+ const requestId = ctx.requestId ?? "unknown";
2663
+ const subject = ctx.signedContext?.subjectKey ?? "unknown";
2664
+ trace.recordAuthz(requestId, mode, {
2665
+ permission: key2,
2666
+ decision: decision ? "allow" : "deny",
2667
+ ...decision ? {} : { reason: `permission_denied: ${key2} (subject=${subject})` }
2668
+ });
2669
+ }
2670
+ function middleware(mwOptions) {
2671
+ const inner = createExpressMiddleware(fs, mwOptions);
2672
+ return (req, res, next) => {
2673
+ const requestId = headerValue2(req.headers, "x-fs-request-id") ?? "unknown";
2674
+ const { path } = splitUrl2(req);
2675
+ const method = req.method;
2676
+ let nextCalled = false;
2677
+ let lastStatus = 200;
2678
+ const origStatus = res.status.bind(res);
2679
+ res.status = (code) => {
2680
+ lastStatus = code;
2681
+ return origStatus(code);
2682
+ };
2683
+ const origJson = res.json.bind(res);
2684
+ res.json = (body) => {
2685
+ if (!nextCalled) {
2686
+ const reason = typeof body === "object" && body !== null && "error" in body ? String(body.error) : void 0;
2687
+ trace.recordVerification(requestId, mode, "rejected", {
2688
+ method,
2689
+ path,
2690
+ ...reason ? { reason } : {}
2691
+ });
2692
+ }
2693
+ trace.recordResponse(requestId, mode, lastStatus);
2694
+ return origJson(body);
2695
+ };
2696
+ const wrappedNext = (err) => {
2697
+ nextCalled = true;
2698
+ trace.recordVerification(
2699
+ requestId,
2700
+ mode,
2701
+ mode === "simulated" ? "verified" : "passthrough",
2702
+ { method, path }
2703
+ );
2704
+ next(err);
2705
+ };
2706
+ inner(req, res, wrappedNext);
2707
+ };
2708
+ }
2709
+ fs.middleware = middleware;
2710
+ const devRuntime = {
2711
+ fs,
2712
+ asPersona: (name) => personaClient.asPersona(name),
2713
+ usage,
2714
+ trace,
2715
+ gateway,
2716
+ keys,
2717
+ personas,
2718
+ mode,
2719
+ bootstrap: gateway.bootstrap,
2720
+ authz: tracedAuthz,
2721
+ middleware,
2722
+ reset() {
2723
+ usage.reset();
2724
+ trace.reset();
2725
+ gateway.meterEvents.length = 0;
2726
+ }
2727
+ };
2728
+ fs.dev = devRuntime;
2729
+ return devRuntime;
2730
+ }
2731
+ var USAGE_JSONL_PATH = ".farthershore/dev-usage.jsonl";
2732
+ var TRACE_JSONL_DEFAULT = ".farthershore/dev-trace.jsonl";
2733
+ var PERSONAS_FILE = "fs.dev.personas.json";
2734
+ function devModeFromEnv(env) {
2735
+ const raw = (env.FS_DEV_MODE ?? "").trim().toLowerCase();
2736
+ if (raw === "passthrough" || raw === "simulated") return raw;
2737
+ return null;
2738
+ }
2739
+ function createDevRuntimeFromEnv(env = readProcessEnv3()) {
2740
+ const mode = devModeFromEnv(env);
2741
+ if (!mode) {
2742
+ throw new Error(
2743
+ "createDevRuntimeFromEnv called without FS_DEV_MODE=passthrough|simulated"
2744
+ );
2745
+ }
2746
+ assertNotProduction("FS_DEV_MODE", env);
2747
+ const keys = generateDevSignerKeys();
2748
+ const personas = loadPersonasFile();
2749
+ const tracePath = env.FS_DEV_TRACE?.trim() || TRACE_JSONL_DEFAULT;
2750
+ const runtime = createDevRuntime({
2751
+ mode,
2752
+ ...personas ? { personas } : {},
2753
+ keys,
2754
+ env,
2755
+ usageJsonl: (line) => appendJsonl(USAGE_JSONL_PATH, line),
2756
+ traceJsonl: (line) => appendJsonl(tracePath, line)
2757
+ });
2758
+ const keysFile = {
2759
+ version: 1,
2760
+ mode,
2761
+ keys,
2762
+ productId: runtime.gateway.productId,
2763
+ backendId: runtime.gateway.backendId,
2764
+ personas: mapToRecord(runtime.personas)
2765
+ };
2766
+ writeDevKeysFile(DEFAULT_KEYS_FILE, keysFile);
2767
+ printBanner(mode, runtime, tracePath);
2768
+ return runtime.fs;
2769
+ }
2770
+ function printBanner(mode, runtime, tracePath) {
2771
+ const lines = [
2772
+ "============================================================",
2773
+ " \u26A0 FARTHER SHORE DEV MODE ACTIVE \u2014 NOT FOR PRODUCTION",
2774
+ ` mode: ${mode.toUpperCase()}`,
2775
+ ` product: ${runtime.gateway.productId}`,
2776
+ ` backend: ${runtime.gateway.backendId}`,
2777
+ ` personas: ${[...runtime.personas.keys()].join(", ")}`,
2778
+ ` usage log: ${USAGE_JSONL_PATH}`,
2779
+ ` trace log: ${tracePath}`,
2780
+ ` dev keys: ${DEFAULT_KEYS_FILE} (mode 600, ephemeral)`,
2781
+ mode === "simulated" ? " verification REQUIRED \u2014 requests must carry a signed persona." : " verification OFF (passthrough) \u2014 middleware passes through.",
2782
+ "============================================================"
2783
+ ];
2784
+ console.warn(lines.join("\n"));
2785
+ }
2786
+ function loadPersonasFile() {
2787
+ if (!existsSync(PERSONAS_FILE)) return void 0;
2788
+ try {
2789
+ return JSON.parse(readFileSync2(PERSONAS_FILE, "utf8"));
2790
+ } catch {
2791
+ return void 0;
2792
+ }
2793
+ }
2794
+ function appendJsonl(path, line) {
2795
+ try {
2796
+ mkdirSync2(dirname2(path), { recursive: true });
2797
+ appendFileSync(path, `${line}
2798
+ `);
2799
+ } catch {
2800
+ }
2801
+ }
2802
+ function mapToRecord(map) {
2803
+ const out = {};
2804
+ for (const [name, def] of map) out[name] = def;
2805
+ return out;
2806
+ }
2807
+ function readProcessEnv3() {
2808
+ const maybeProcess = globalThis.process;
2809
+ return maybeProcess?.env ?? {};
2810
+ }
2811
+ function headerValue2(headers, name) {
2812
+ const value = headers[name] ?? headers[name.toLowerCase()];
2813
+ if (Array.isArray(value)) return value[0];
2814
+ return value;
2815
+ }
2816
+ function splitUrl2(req) {
2817
+ const raw = req.originalUrl ?? req.url ?? req.path ?? "/";
2818
+ const qIndex = raw.indexOf("?");
2819
+ if (qIndex === -1) return { path: raw, query: "" };
2820
+ return { path: raw.slice(0, qIndex), query: raw.slice(qIndex + 1) };
2821
+ }
2822
+
2053
2823
  // src/index.ts
2824
+ function readProcessEnv4() {
2825
+ const maybeProcess = globalThis.process;
2826
+ return maybeProcess?.env ?? {};
2827
+ }
2054
2828
  var fartherShore = {
2055
2829
  /** Derive everything from FS_RUNTIME_TOKEN via bootstrap. */
2056
2830
  initFromEnv(options = {}) {
2831
+ const env = options.env ?? readProcessEnv4();
2832
+ if (devModeFromEnv(env)) {
2833
+ return createDevRuntimeFromEnv(env);
2834
+ }
2057
2835
  const fs = initFromEnv(options);
2058
2836
  fs.middleware = (mwOptions) => createExpressMiddleware(fs, mwOptions);
2059
2837
  return fs;
@@ -2093,6 +2871,7 @@ export {
2093
2871
  buildCanonicalSigningString2 as buildCanonicalSigningString,
2094
2872
  buildHealthReport,
2095
2873
  canonicalizeQuery2 as canonicalizeQuery,
2874
+ computeMeteringHeaders,
2096
2875
  createExpressMiddleware,
2097
2876
  createUsage,
2098
2877
  fartherShore,