@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/README.md +66 -1
- package/dist/index.js +831 -23
- package/dist/testing/index.js +2716 -0
- package/dist/types/core/permissions.d.ts +37 -19
- package/dist/types/core/runtime.d.ts +36 -0
- package/dist/types/core/verifyRequest.d.ts +6 -5
- package/dist/types/index.d.ts +1 -1
- package/dist/types/response-metering.d.ts +45 -0
- package/dist/types/testing/devGateway.d.ts +36 -0
- package/dist/types/testing/devRuntime.d.ts +68 -0
- package/dist/types/testing/index.d.ts +8 -0
- package/dist/types/testing/keysFile.d.ts +30 -0
- package/dist/types/testing/personas.d.ts +104 -0
- package/dist/types/testing/prodGuard.d.ts +12 -0
- package/dist/types/testing/signers.d.ts +89 -0
- package/dist/types/testing/traceSink.d.ts +67 -0
- package/dist/types/testing/usageSink.d.ts +31 -0
- package/package.json +8 -4
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
|
-
|
|
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,
|
|
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.
|
|
1574
|
-
var CONTRACTS_FP = "
|
|
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
|
|
1916
|
-
const headers =
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
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
|
|
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,
|