@farthershore/backend 0.11.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
@@ -1314,6 +1314,9 @@ function permissionSatisfies(required, granted) {
1314
1314
  return false;
1315
1315
  }
1316
1316
  function hasPermission(ctx, key2) {
1317
+ if (ctx.permissions === void 0) {
1318
+ return ctx.signedContext !== void 0;
1319
+ }
1317
1320
  return permissionSatisfies(key2, ctx.permissions);
1318
1321
  }
1319
1322
  function requirePermission(ctx, key2) {
@@ -1505,16 +1508,26 @@ async function verifyRequest(input, deps) {
1505
1508
  let permissions;
1506
1509
  let roles;
1507
1510
  let signedContext = null;
1508
- if (deps.contextSecrets && deps.contextSecrets.length > 0) {
1511
+ const contextSecrets = deps.contextSecrets ?? [];
1512
+ const hasContextKeyring = contextSecrets.length > 0;
1513
+ if (hasContextKeyring) {
1509
1514
  const token = h("x-fs-context");
1510
1515
  if (token) {
1511
- signedContext = await verifyContext(token, deps.contextSecrets);
1516
+ signedContext = await verifyContext(token, contextSecrets);
1512
1517
  if (signedContext === null && deps.contextVerification === "required") {
1513
1518
  throw contextRequiredError("failed verification");
1514
1519
  }
1520
+ if (signedContext && signedContext.productId !== signedProductId) {
1521
+ throw new FartherShoreError(
1522
+ "context_unverified",
1523
+ "X-Fs-Context was minted for a different product than the signed request"
1524
+ );
1525
+ }
1515
1526
  } else if (deps.contextVerification === "required") {
1516
1527
  throw contextRequiredError("header is missing");
1517
1528
  }
1529
+ } else if (deps.contextVerification === "required") {
1530
+ throw contextRequiredError("keyring is empty");
1518
1531
  }
1519
1532
  if (signedContext) {
1520
1533
  permissions = signedContext.permissions;
@@ -1570,8 +1583,8 @@ function headerGetter(headers) {
1570
1583
 
1571
1584
  // src/core/runtime.ts
1572
1585
  var DEFAULT_CORE_URL = "https://core.farthershore.com";
1573
- var SDK_VERSION = "0.11.0".length > 0 ? "0.11.0" : "0.0.0-dev";
1574
- var CONTRACTS_FP = "aae5b92b294c8968".length > 0 ? "aae5b92b294c8968" : "0000000000000000";
1586
+ var SDK_VERSION = "0.13.0".length > 0 ? "0.13.0" : "0.0.0-dev";
1587
+ var CONTRACTS_FP = "220bea90107ed396".length > 0 ? "220bea90107ed396" : "0000000000000000";
1575
1588
  var FartherShore = class {
1576
1589
  bootstrapClient;
1577
1590
  fetchImpl;
@@ -1581,6 +1594,10 @@ var FartherShore = class {
1581
1594
  coreUrl;
1582
1595
  instanceId;
1583
1596
  tunnelOptions;
1597
+ /** FAR-723 — HS256 secret(s) for verifying the signed X-Fs-Context claim. */
1598
+ contextSecrets;
1599
+ /** FAR-723 — "preferred" (fallback to unsigned) | "required" (fail-closed). */
1600
+ contextVerification;
1584
1601
  nonceCache = new NonceCache();
1585
1602
  shutdownManager = new ShutdownManager();
1586
1603
  jwks = null;
@@ -1598,6 +1615,8 @@ var FartherShore = class {
1598
1615
  this.meteringEnabledOverride = options.metering?.enabled ?? true;
1599
1616
  this.tunnelOptions = options.tunnel ?? {};
1600
1617
  this.instanceId = options.instanceId;
1618
+ this.contextSecrets = options.contextSecrets ?? parseContextSecrets(env.FS_CONTEXT_SECRETS);
1619
+ this.contextVerification = options.contextVerification ?? (env.FS_CONTEXT_VERIFICATION === "required" ? "required" : "preferred");
1601
1620
  this.bootstrapClient = new BootstrapClient({
1602
1621
  runtimeToken,
1603
1622
  coreUrl,
@@ -1716,7 +1735,13 @@ var FartherShore = class {
1716
1735
  backendId: config.backend.id,
1717
1736
  knownRouteIds,
1718
1737
  clockSkewSeconds: config.verification.clockSkewSeconds,
1719
- replayWindowSeconds: config.verification.replayWindowSeconds
1738
+ replayWindowSeconds: config.verification.replayWindowSeconds,
1739
+ // FAR-723 — a VERIFIED signed X-Fs-Context is the preferred (or
1740
+ // required) identity source. Required mode must also fail closed when the
1741
+ // keyring is empty; preferred mode preserves the transitional unsigned
1742
+ // fallback until the backend-v* publish gate removes it.
1743
+ contextSecrets: this.contextSecrets,
1744
+ contextVerification: this.contextVerification
1720
1745
  });
1721
1746
  }
1722
1747
  /** Whether verification is required (bootstrap × opt-out). */
@@ -1807,6 +1832,10 @@ function readProcessEnv() {
1807
1832
  const maybeProcess = globalThis.process;
1808
1833
  return maybeProcess?.env ?? {};
1809
1834
  }
1835
+ function parseContextSecrets(raw) {
1836
+ if (!raw) return [];
1837
+ return raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
1838
+ }
1810
1839
 
1811
1840
  // src/adapters/express.ts
1812
1841
  var STREAMING_CONTENT_TYPES = new Set(
@@ -1877,6 +1906,10 @@ function headerValue(headers, name) {
1877
1906
  // src/response-metering.ts
1878
1907
  var RESPONSE_METERING_HEADERS = RUNTIME_RESPONSE_METERING_CONTRACT.headers;
1879
1908
  var RESPONSE_METERING_ERROR_CODES = RUNTIME_RESPONSE_METERING_CONTRACT.errors;
1909
+ var devMeteringHooks = null;
1910
+ function __setDevMeteringHooks(hooks) {
1911
+ devMeteringHooks = hooks;
1912
+ }
1880
1913
  var METERING_PAYLOAD_HEADER = RESPONSE_METERING_HEADERS.payload;
1881
1914
  var METERING_SIGNATURE_HEADER = RESPONSE_METERING_HEADERS.signature;
1882
1915
  var METERING_TOKEN_HEADER = RESPONSE_METERING_HEADERS.token;
@@ -1910,19 +1943,60 @@ async function withUsage(request, response, usage, options = {}) {
1910
1943
  return reporter.wrap(response);
1911
1944
  }
1912
1945
  async function signResponse(request, response, usage, options, wrapOptions) {
1913
- const token = resolveToken(options);
1914
1946
  const payload = buildPayload(request, usage, options, wrapOptions);
1915
- const signature = await signPayload(payload, token);
1916
- const headers = new Headers(response.headers);
1917
- headers.set(METERING_PAYLOAD_HEADER, payload);
1918
- headers.set(METERING_SIGNATURE_HEADER, signature);
1919
- 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);
1920
1963
  return new Response(response.body, {
1921
1964
  status: response.status,
1922
1965
  statusText: response.statusText,
1923
- headers
1966
+ headers: merged
1924
1967
  });
1925
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
+ }
1926
2000
  function buildPayload(request, usage, options, wrapOptions) {
1927
2001
  const url = new URL(request.url);
1928
2002
  const measureContext = wrapOptions.measureContext ?? options.measureContext;
@@ -1942,7 +2016,7 @@ function buildPayload(request, usage, options, wrapOptions) {
1942
2016
  ...operationKey ? { operationKey: assertIdentifier(operationKey) } : {},
1943
2017
  ...usagePolicyId ? { usagePolicyId: assertIdentifier(usagePolicyId) } : {}
1944
2018
  };
1945
- return JSON.stringify(payload);
2019
+ return payload;
1946
2020
  }
1947
2021
  function sortUsage(usage) {
1948
2022
  return Object.fromEntries(
@@ -1984,16 +2058,6 @@ function assertIdentifier(value) {
1984
2058
  }
1985
2059
  return value;
1986
2060
  }
1987
- function resolveToken(options) {
1988
- const token = options.token ?? options.env?.[DEFAULT_TOKEN_ENV] ?? processEnv(DEFAULT_TOKEN_ENV);
1989
- if (!token) {
1990
- throw new MeteringError(
1991
- RESPONSE_METERING_ERROR_CODES.missingToken,
1992
- `${DEFAULT_TOKEN_ENV} is required to sign Farther Shore metering reports`
1993
- );
1994
- }
1995
- return token;
1996
- }
1997
2061
  function processEnv(key2) {
1998
2062
  const maybeProcess = globalThis.process;
1999
2063
  return maybeProcess?.env?.[key2];
@@ -2021,10 +2085,753 @@ function base64url(bytes) {
2021
2085
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
2022
2086
  }
2023
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
+
2024
2823
  // src/index.ts
2824
+ function readProcessEnv4() {
2825
+ const maybeProcess = globalThis.process;
2826
+ return maybeProcess?.env ?? {};
2827
+ }
2025
2828
  var fartherShore = {
2026
2829
  /** Derive everything from FS_RUNTIME_TOKEN via bootstrap. */
2027
2830
  initFromEnv(options = {}) {
2831
+ const env = options.env ?? readProcessEnv4();
2832
+ if (devModeFromEnv(env)) {
2833
+ return createDevRuntimeFromEnv(env);
2834
+ }
2028
2835
  const fs = initFromEnv(options);
2029
2836
  fs.middleware = (mwOptions) => createExpressMiddleware(fs, mwOptions);
2030
2837
  return fs;
@@ -2064,6 +2871,7 @@ export {
2064
2871
  buildCanonicalSigningString2 as buildCanonicalSigningString,
2065
2872
  buildHealthReport,
2066
2873
  canonicalizeQuery2 as canonicalizeQuery,
2874
+ computeMeteringHeaders,
2067
2875
  createExpressMiddleware,
2068
2876
  createUsage,
2069
2877
  fartherShore,