@farthershore/backend 0.14.0 → 0.16.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/CHANGELOG.md +68 -0
- package/README.md +6 -6
- package/dist/adapters/express.js +63 -1
- package/dist/generated/runtime-contract.js +15 -10
- package/dist/index.js +330 -174
- package/dist/testing/index.js +294 -167
- package/dist/types/adapters/express.d.ts +55 -5
- package/dist/types/core/metering.d.ts +8 -2
- package/dist/types/core/nonceCache.d.ts +25 -4
- package/dist/types/core/permissions.d.ts +14 -25
- package/dist/types/core/runtime.d.ts +23 -30
- package/dist/types/core/subject.d.ts +25 -0
- package/dist/types/core/verifyContext.d.ts +96 -19
- package/dist/types/core/verifyRequest.d.ts +37 -27
- package/dist/types/generated/runtime-contract.d.ts +9 -6
- package/dist/types/index.d.ts +16 -7
- package/dist/types/response-metering.d.ts +7 -0
- package/dist/types/runtime-signing.d.ts +7 -0
- package/dist/types/runtime-types.d.ts +18 -10
- package/dist/types/testing/devGateway.d.ts +3 -3
- package/dist/types/testing/devRuntime.d.ts +4 -2
- package/dist/types/testing/keysFile.d.ts +1 -1
- package/dist/types/testing/personas.d.ts +2 -2
- package/dist/types/testing/signers.d.ts +14 -6
- package/package.json +5 -4
package/dist/index.js
CHANGED
|
@@ -215,7 +215,9 @@ var RUNTIME_ERROR_CODES = {
|
|
|
215
215
|
environmentMismatch: "environment_mismatch",
|
|
216
216
|
missingToken: "missing_token",
|
|
217
217
|
invalidToken: "invalid_token",
|
|
218
|
-
contextUnverified: "context_unverified"
|
|
218
|
+
contextUnverified: "context_unverified",
|
|
219
|
+
memberSubjectRequired: "member_subject_required",
|
|
220
|
+
serviceSubjectRequired: "service_subject_required"
|
|
219
221
|
};
|
|
220
222
|
var RUNTIME_RESPONSE_METERING_CONTRACT = {
|
|
221
223
|
headers: {
|
|
@@ -279,7 +281,10 @@ var RUNTIME_ERROR_CODE_TO_ERROR_CODE = {
|
|
|
279
281
|
// the canonical code keeps the "dependency down" semantic for callers.
|
|
280
282
|
[RUNTIME_ERROR_CODES.jwksUnavailable]: "SERVICE_UNAVAILABLE",
|
|
281
283
|
// The single non-401 (413) — oversized request body.
|
|
282
|
-
[RUNTIME_ERROR_CODES.bodyTooLarge]: "VALIDATION_ERROR"
|
|
284
|
+
[RUNTIME_ERROR_CODES.bodyTooLarge]: "VALIDATION_ERROR",
|
|
285
|
+
// Consumer-principal wave — route subject-requirement faults → FORBIDDEN (403).
|
|
286
|
+
[RUNTIME_ERROR_CODES.memberSubjectRequired]: "FORBIDDEN",
|
|
287
|
+
[RUNTIME_ERROR_CODES.serviceSubjectRequired]: "FORBIDDEN"
|
|
283
288
|
};
|
|
284
289
|
function runtimeErrorToErrorCode(code) {
|
|
285
290
|
return RUNTIME_ERROR_CODE_TO_ERROR_CODE[code] ?? "INTERNAL_ERROR";
|
|
@@ -306,16 +311,12 @@ var RUNTIME_HEADER_NAMES = {
|
|
|
306
311
|
keyId: "x-fs-key-id",
|
|
307
312
|
requestId: "x-fs-request-id",
|
|
308
313
|
timestamp: "x-fs-timestamp",
|
|
309
|
-
|
|
314
|
+
businessId: "x-fs-business-id",
|
|
310
315
|
backendId: "x-fs-backend-id",
|
|
311
316
|
routeId: "x-fs-route-id",
|
|
312
317
|
policyVersion: "x-fs-policy-version",
|
|
313
318
|
bodyHash: "x-fs-body-hash"
|
|
314
319
|
};
|
|
315
|
-
var RUNTIME_IDENTITY_HEADER_NAMES = {
|
|
316
|
-
permissions: "x-fs-permissions",
|
|
317
|
-
roles: "x-fs-roles"
|
|
318
|
-
};
|
|
319
320
|
var RUNTIME_CLOCK_SKEW_SECONDS = 5;
|
|
320
321
|
var RUNTIME_REPLAY_WINDOW_SECONDS = 300;
|
|
321
322
|
var EMPTY_BODY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
|
@@ -358,34 +359,19 @@ var CANONICAL_SIGNING_FIELDS = [
|
|
|
358
359
|
"body-hash",
|
|
359
360
|
"request-id",
|
|
360
361
|
"timestamp",
|
|
361
|
-
"
|
|
362
|
+
"business-id",
|
|
362
363
|
"backend-id",
|
|
363
364
|
"route-id",
|
|
364
|
-
"policy-version"
|
|
365
|
+
"policy-version",
|
|
366
|
+
// Consumer-principal wave (D3) — the trailing identity-context binding line.
|
|
367
|
+
// MUST stay last so pre-binding verifiers that stop at `policy-version`
|
|
368
|
+
// fail loud on a bound request rather than silently accepting a prefix.
|
|
369
|
+
"context-hash"
|
|
365
370
|
];
|
|
366
371
|
var CANONICAL_FIELD_SEPARATOR = "\n";
|
|
367
372
|
var CANONICAL_KV_SEPARATOR = ":";
|
|
368
373
|
function canonicalizeQuery(query) {
|
|
369
|
-
|
|
370
|
-
if (raw === "")
|
|
371
|
-
return "";
|
|
372
|
-
const pairs = raw.split("&").filter((p) => p.length > 0);
|
|
373
|
-
pairs.sort((a, b) => {
|
|
374
|
-
const [an, ...arest] = a.split("=");
|
|
375
|
-
const [bn, ...brest] = b.split("=");
|
|
376
|
-
if (an < bn)
|
|
377
|
-
return -1;
|
|
378
|
-
if (an > bn)
|
|
379
|
-
return 1;
|
|
380
|
-
const av = arest.join("=");
|
|
381
|
-
const bv = brest.join("=");
|
|
382
|
-
if (av < bv)
|
|
383
|
-
return -1;
|
|
384
|
-
if (av > bv)
|
|
385
|
-
return 1;
|
|
386
|
-
return 0;
|
|
387
|
-
});
|
|
388
|
-
return pairs.join("&");
|
|
374
|
+
return query;
|
|
389
375
|
}
|
|
390
376
|
function buildCanonicalSigningString(input) {
|
|
391
377
|
const values = {
|
|
@@ -395,13 +381,17 @@ function buildCanonicalSigningString(input) {
|
|
|
395
381
|
"body-hash": input.bodyHash,
|
|
396
382
|
"request-id": input.requestId,
|
|
397
383
|
timestamp: String(input.timestamp),
|
|
398
|
-
"
|
|
384
|
+
"business-id": input.businessId,
|
|
399
385
|
"backend-id": input.backendId,
|
|
400
386
|
"route-id": input.routeId,
|
|
401
|
-
"policy-version": input.policyVersion
|
|
387
|
+
"policy-version": input.policyVersion,
|
|
388
|
+
"context-hash": input.contextHash
|
|
402
389
|
};
|
|
403
390
|
return CANONICAL_SIGNING_FIELDS.map((field) => `${field}${CANONICAL_KV_SEPARATOR}${values[field]}`).join(CANONICAL_FIELD_SEPARATOR);
|
|
404
391
|
}
|
|
392
|
+
function hashContextToken(token) {
|
|
393
|
+
return hashBody(new TextEncoder().encode(token ?? ""));
|
|
394
|
+
}
|
|
405
395
|
var ED25519_ALGORITHM = "Ed25519";
|
|
406
396
|
async function importEd25519PrivateKey(jwk) {
|
|
407
397
|
return crypto.subtle.importKey("jwk", { ...jwk, alg: void 0 }, { name: ED25519_ALGORITHM }, false, ["sign"]);
|
|
@@ -444,6 +434,7 @@ function base64UrlDecode(value) {
|
|
|
444
434
|
var hashBody2 = hashBody;
|
|
445
435
|
var canonicalizeQuery2 = canonicalizeQuery;
|
|
446
436
|
var buildCanonicalSigningString2 = buildCanonicalSigningString;
|
|
437
|
+
var hashContextToken2 = hashContextToken;
|
|
447
438
|
var signCanonicalString2 = signCanonicalString;
|
|
448
439
|
var verifyCanonicalSignature2 = verifyCanonicalSignature;
|
|
449
440
|
var runtimeTokenKind2 = runtimeTokenKind;
|
|
@@ -761,7 +752,7 @@ var DEFAULT_MAX_RETRIES = 3;
|
|
|
761
752
|
var MeteringClient = class {
|
|
762
753
|
config;
|
|
763
754
|
endpoint;
|
|
764
|
-
|
|
755
|
+
businessId;
|
|
765
756
|
backendId;
|
|
766
757
|
fetchImpl;
|
|
767
758
|
maxRetries;
|
|
@@ -775,7 +766,7 @@ var MeteringClient = class {
|
|
|
775
766
|
constructor(options) {
|
|
776
767
|
this.config = options.config;
|
|
777
768
|
this.endpoint = resolveEndpoint(options.config.endpoint, options.coreUrl);
|
|
778
|
-
this.
|
|
769
|
+
this.businessId = options.businessId;
|
|
779
770
|
this.backendId = options.backendId;
|
|
780
771
|
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
781
772
|
this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
@@ -838,13 +829,14 @@ var MeteringClient = class {
|
|
|
838
829
|
}
|
|
839
830
|
const event = {
|
|
840
831
|
event_id: options.eventId ?? this.newId(),
|
|
841
|
-
|
|
832
|
+
business_id: this.businessId,
|
|
842
833
|
backend_id: this.backendId,
|
|
843
834
|
meter,
|
|
844
835
|
qty,
|
|
845
836
|
timestamp: options.timestamp ?? this.now().toISOString(),
|
|
846
837
|
...options.routeId ? { route_id: options.routeId } : {},
|
|
847
|
-
...options.requestId ? { request_id: options.requestId } : {}
|
|
838
|
+
...options.requestId ? { request_id: options.requestId } : {},
|
|
839
|
+
...options.subscriptionId ? { subscription_id: options.subscriptionId } : {}
|
|
848
840
|
};
|
|
849
841
|
this.buffer.push(event);
|
|
850
842
|
await this.flush();
|
|
@@ -940,7 +932,7 @@ async function withUsage(request, response, usage, options = {}) {
|
|
|
940
932
|
}
|
|
941
933
|
async function signResponse(request, response, usage, options, wrapOptions) {
|
|
942
934
|
const payload = buildPayload(request, usage, options, wrapOptions);
|
|
943
|
-
const requestId = request.headers.get("x-fs-request-id") ?? void 0;
|
|
935
|
+
const requestId = options.requestId ?? request.headers.get("x-fs-request-id") ?? void 0;
|
|
944
936
|
const headers = await computeMeteringHeaders(payload, {
|
|
945
937
|
...options.token !== void 0 ? { token: options.token } : {},
|
|
946
938
|
...options.env !== void 0 ? { env: options.env } : {},
|
|
@@ -1194,8 +1186,8 @@ function resolveEndpoint2(endpoint, coreUrl) {
|
|
|
1194
1186
|
}
|
|
1195
1187
|
|
|
1196
1188
|
// src/core/nonceCache.ts
|
|
1197
|
-
var DEFAULT_MAX_ENTRIES =
|
|
1198
|
-
var DEFAULT_TTL_MS =
|
|
1189
|
+
var DEFAULT_MAX_ENTRIES = 25e4;
|
|
1190
|
+
var DEFAULT_TTL_MS = (RUNTIME_REPLAY_WINDOW_SECONDS + RUNTIME_CLOCK_SKEW_SECONDS) * 1e3;
|
|
1199
1191
|
var NonceCache = class {
|
|
1200
1192
|
maxEntries;
|
|
1201
1193
|
ttlMs;
|
|
@@ -1219,7 +1211,7 @@ var NonceCache = class {
|
|
|
1219
1211
|
this.seen.delete(id);
|
|
1220
1212
|
}
|
|
1221
1213
|
this.evictExpired(at);
|
|
1222
|
-
this.
|
|
1214
|
+
if (this.seen.size >= this.maxEntries) return true;
|
|
1223
1215
|
this.seen.set(id, at);
|
|
1224
1216
|
return false;
|
|
1225
1217
|
}
|
|
@@ -1233,13 +1225,6 @@ var NonceCache = class {
|
|
|
1233
1225
|
this.seen.delete(id);
|
|
1234
1226
|
}
|
|
1235
1227
|
}
|
|
1236
|
-
evictOverflow() {
|
|
1237
|
-
while (this.seen.size >= this.maxEntries) {
|
|
1238
|
-
const oldest = this.seen.keys().next().value;
|
|
1239
|
-
if (oldest === void 0) break;
|
|
1240
|
-
this.seen.delete(oldest);
|
|
1241
|
-
}
|
|
1242
|
-
}
|
|
1243
1228
|
};
|
|
1244
1229
|
|
|
1245
1230
|
// src/core/shutdown.ts
|
|
@@ -1574,53 +1559,82 @@ function resolvePackageBinary(require2, pkg, manifestPath) {
|
|
|
1574
1559
|
return `${root}${sep}${normalized}`;
|
|
1575
1560
|
}
|
|
1576
1561
|
|
|
1577
|
-
//
|
|
1578
|
-
var
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
status = 403;
|
|
1582
|
-
/** The permission key that was required but not held. */
|
|
1583
|
-
requiredPermission;
|
|
1584
|
-
constructor(requiredPermission, message) {
|
|
1585
|
-
super(message ?? `missing required permission: ${requiredPermission}`);
|
|
1586
|
-
this.name = "FartherShorePermissionError";
|
|
1587
|
-
this.requiredPermission = requiredPermission;
|
|
1588
|
-
}
|
|
1589
|
-
};
|
|
1590
|
-
function parsePermissionHeader(raw) {
|
|
1591
|
-
if (raw === null || raw === void 0) return void 0;
|
|
1592
|
-
return raw.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
|
|
1562
|
+
// ../contracts/dist/authz/principal.js
|
|
1563
|
+
var SUBJECT_KINDS = ["member", "service"];
|
|
1564
|
+
function isSubjectKind(value) {
|
|
1565
|
+
return typeof value === "string" && SUBJECT_KINDS.includes(value);
|
|
1593
1566
|
}
|
|
1594
|
-
function
|
|
1595
|
-
|
|
1596
|
-
if (permissions.includes(WILDCARD)) return true;
|
|
1597
|
-
return permissions.includes(key2);
|
|
1567
|
+
function isNonEmptyTrimmed(value) {
|
|
1568
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
1598
1569
|
}
|
|
1599
|
-
function
|
|
1600
|
-
|
|
1601
|
-
if (granted.includes(WILDCARD)) return true;
|
|
1602
|
-
if (granted.includes(required)) return true;
|
|
1603
|
-
const idx = required.indexOf(":");
|
|
1604
|
-
if (idx > 0 && idx < required.length - 1) {
|
|
1605
|
-
const subject = required.slice(0, idx);
|
|
1606
|
-
if (granted.includes(`${subject}:${WILDCARD}`)) return true;
|
|
1607
|
-
}
|
|
1608
|
-
return false;
|
|
1570
|
+
function normalizeIdentityId(value) {
|
|
1571
|
+
return value.trim();
|
|
1609
1572
|
}
|
|
1610
|
-
function
|
|
1611
|
-
|
|
1612
|
-
return ctx.signedContext !== void 0;
|
|
1613
|
-
}
|
|
1614
|
-
return permissionSatisfies(key2, ctx.permissions);
|
|
1573
|
+
function isNonEmptyStringArray(value) {
|
|
1574
|
+
return Array.isArray(value) && value.every((item) => isNonEmptyTrimmed(item));
|
|
1615
1575
|
}
|
|
1616
|
-
function
|
|
1617
|
-
if (
|
|
1618
|
-
|
|
1576
|
+
function principalFromContextClaims(claims) {
|
|
1577
|
+
if (typeof claims !== "object" || claims === null)
|
|
1578
|
+
return null;
|
|
1579
|
+
const { sub, org: orgId, businessId, subjectKind } = claims;
|
|
1580
|
+
if (!isNonEmptyTrimmed(sub) || !isNonEmptyTrimmed(orgId) || !isNonEmptyTrimmed(businessId)) {
|
|
1581
|
+
return null;
|
|
1582
|
+
}
|
|
1583
|
+
if (!isSubjectKind(subjectKind))
|
|
1584
|
+
return null;
|
|
1585
|
+
if (claims.permissions !== void 0 && !isNonEmptyStringArray(claims.permissions)) {
|
|
1586
|
+
return null;
|
|
1619
1587
|
}
|
|
1588
|
+
if (claims.roles !== void 0 && !isNonEmptyStringArray(claims.roles)) {
|
|
1589
|
+
return null;
|
|
1590
|
+
}
|
|
1591
|
+
const clientId = claims.client_id;
|
|
1592
|
+
const actSub = claims.act?.sub;
|
|
1593
|
+
if (isNonEmptyTrimmed(clientId) && isNonEmptyTrimmed(actSub) && normalizeIdentityId(clientId) !== normalizeIdentityId(actSub)) {
|
|
1594
|
+
return null;
|
|
1595
|
+
}
|
|
1596
|
+
const org = { id: normalizeIdentityId(orgId) };
|
|
1597
|
+
if (subjectKind === "service") {
|
|
1598
|
+
const explicitServiceAccountId = claims.serviceAccountId;
|
|
1599
|
+
if (isNonEmptyTrimmed(explicitServiceAccountId) && normalizeIdentityId(explicitServiceAccountId) !== normalizeIdentityId(sub)) {
|
|
1600
|
+
return null;
|
|
1601
|
+
}
|
|
1602
|
+
const serviceAccountId = normalizeIdentityId(explicitServiceAccountId ?? sub);
|
|
1603
|
+
const keyIdSource = clientId ?? actSub ?? sub;
|
|
1604
|
+
if (!isNonEmptyTrimmed(serviceAccountId) || !isNonEmptyTrimmed(keyIdSource)) {
|
|
1605
|
+
return null;
|
|
1606
|
+
}
|
|
1607
|
+
return {
|
|
1608
|
+
org,
|
|
1609
|
+
subject: {
|
|
1610
|
+
kind: "service",
|
|
1611
|
+
serviceAccountId,
|
|
1612
|
+
keyId: normalizeIdentityId(keyIdSource)
|
|
1613
|
+
}
|
|
1614
|
+
};
|
|
1615
|
+
}
|
|
1616
|
+
const memberId = normalizeIdentityId(sub);
|
|
1617
|
+
const act = claims.act;
|
|
1618
|
+
if (act !== void 0 && act !== null) {
|
|
1619
|
+
if (typeof act !== "object" || !isNonEmptyTrimmed(act.sub))
|
|
1620
|
+
return null;
|
|
1621
|
+
return {
|
|
1622
|
+
org,
|
|
1623
|
+
subject: {
|
|
1624
|
+
kind: "member",
|
|
1625
|
+
memberId,
|
|
1626
|
+
via: "api_key",
|
|
1627
|
+
keyId: normalizeIdentityId(act.sub)
|
|
1628
|
+
}
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
return { org, subject: { kind: "member", memberId, via: "session" } };
|
|
1620
1632
|
}
|
|
1621
|
-
var IDENTITY_HEADER_NAMES = RUNTIME_IDENTITY_HEADER_NAMES;
|
|
1622
1633
|
|
|
1623
1634
|
// src/core/verifyContext.ts
|
|
1635
|
+
function principalFromContextClaims2(claims) {
|
|
1636
|
+
return principalFromContextClaims(claims);
|
|
1637
|
+
}
|
|
1624
1638
|
var EXPECTED_JWT_ALG = "HS256";
|
|
1625
1639
|
function base64urlDecode(value) {
|
|
1626
1640
|
const padded = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
@@ -1660,6 +1674,7 @@ async function verifyContext(token, secrets) {
|
|
|
1660
1674
|
}
|
|
1661
1675
|
let verified = false;
|
|
1662
1676
|
for (const secret of secrets) {
|
|
1677
|
+
if (secret.trim().length === 0) continue;
|
|
1663
1678
|
try {
|
|
1664
1679
|
const key2 = await importHmacKey(secret);
|
|
1665
1680
|
if (await crypto.subtle.verify(
|
|
@@ -1675,11 +1690,20 @@ async function verifyContext(token, secrets) {
|
|
|
1675
1690
|
}
|
|
1676
1691
|
}
|
|
1677
1692
|
if (!verified) return null;
|
|
1693
|
+
return parseContextPayload(payload);
|
|
1694
|
+
}
|
|
1695
|
+
function decodeContextClaims(token) {
|
|
1696
|
+
const parts = token.split(".");
|
|
1697
|
+
if (parts.length !== 3) return null;
|
|
1698
|
+
return parseContextPayload(parts[1]);
|
|
1699
|
+
}
|
|
1700
|
+
function parseContextPayload(payload) {
|
|
1678
1701
|
try {
|
|
1679
1702
|
const parsed = JSON.parse(
|
|
1680
1703
|
new TextDecoder().decode(base64urlDecode(payload))
|
|
1681
1704
|
);
|
|
1682
1705
|
if (typeof parsed !== "object" || parsed === null) return null;
|
|
1706
|
+
if (parsed.cv !== 2) return null;
|
|
1683
1707
|
return parsed;
|
|
1684
1708
|
} catch {
|
|
1685
1709
|
return null;
|
|
@@ -1688,7 +1712,7 @@ async function verifyContext(token, secrets) {
|
|
|
1688
1712
|
function contextRequiredError(reason) {
|
|
1689
1713
|
return new FartherShoreError(
|
|
1690
1714
|
"context_unverified",
|
|
1691
|
-
`X-Fs-Context ${reason}
|
|
1715
|
+
`X-Fs-Context ${reason} \u2014 signed context is required whenever context secrets are configured`
|
|
1692
1716
|
);
|
|
1693
1717
|
}
|
|
1694
1718
|
|
|
@@ -1705,12 +1729,12 @@ async function verifyRequest(input, deps) {
|
|
|
1705
1729
|
const kid = h(RUNTIME_HEADER_NAMES.keyId);
|
|
1706
1730
|
const requestId = h(RUNTIME_HEADER_NAMES.requestId);
|
|
1707
1731
|
const timestampRaw = h(RUNTIME_HEADER_NAMES.timestamp);
|
|
1708
|
-
const
|
|
1732
|
+
const signedBusinessId = h(RUNTIME_HEADER_NAMES.businessId);
|
|
1709
1733
|
const signedBackendId = h(RUNTIME_HEADER_NAMES.backendId);
|
|
1710
1734
|
const signedRouteId = h(RUNTIME_HEADER_NAMES.routeId) ?? "";
|
|
1711
1735
|
const policyVersion = h(RUNTIME_HEADER_NAMES.policyVersion);
|
|
1712
1736
|
const signedBodyHash = h(RUNTIME_HEADER_NAMES.bodyHash);
|
|
1713
|
-
if (!kid || !requestId || !timestampRaw || !
|
|
1737
|
+
if (!kid || !requestId || !timestampRaw || !signedBusinessId || !signedBackendId || policyVersion === void 0 || !signedBodyHash) {
|
|
1714
1738
|
throw new FartherShoreError(
|
|
1715
1739
|
"malformed_signature",
|
|
1716
1740
|
"request is missing one or more required x-fs-* headers"
|
|
@@ -1746,10 +1770,10 @@ async function verifyRequest(input, deps) {
|
|
|
1746
1770
|
"recomputed body hash does not match the signed x-fs-body-hash"
|
|
1747
1771
|
);
|
|
1748
1772
|
}
|
|
1749
|
-
if (deps.
|
|
1773
|
+
if (deps.businessId !== void 0 && signedBusinessId !== deps.businessId) {
|
|
1750
1774
|
throw new FartherShoreError(
|
|
1751
1775
|
"route_mismatch",
|
|
1752
|
-
"signed
|
|
1776
|
+
"signed business-id does not match this backend's business"
|
|
1753
1777
|
);
|
|
1754
1778
|
}
|
|
1755
1779
|
if (deps.backendId !== void 0 && signedBackendId !== deps.backendId) {
|
|
@@ -1764,6 +1788,7 @@ async function verifyRequest(input, deps) {
|
|
|
1764
1788
|
"signed route-id is not served by this backend"
|
|
1765
1789
|
);
|
|
1766
1790
|
}
|
|
1791
|
+
const contextToken = h("x-fs-context") ?? null;
|
|
1767
1792
|
const canonicalInput = {
|
|
1768
1793
|
method: input.method,
|
|
1769
1794
|
path: input.path,
|
|
@@ -1771,10 +1796,16 @@ async function verifyRequest(input, deps) {
|
|
|
1771
1796
|
bodyHash: computedBodyHash,
|
|
1772
1797
|
requestId,
|
|
1773
1798
|
timestamp,
|
|
1774
|
-
|
|
1799
|
+
businessId: signedBusinessId,
|
|
1775
1800
|
backendId: signedBackendId,
|
|
1776
1801
|
routeId: signedRouteId,
|
|
1777
|
-
policyVersion
|
|
1802
|
+
policyVersion,
|
|
1803
|
+
// Consumer-principal wave (D3): bind the presented X-Fs-Context hash into
|
|
1804
|
+
// the canonical string in exact lockstep with the gateway signer. A missing
|
|
1805
|
+
// context hashes the empty string, so an identity-less request still
|
|
1806
|
+
// verifies (it is rejected later by the fail-closed context gate when
|
|
1807
|
+
// secrets exist).
|
|
1808
|
+
contextHash: await hashContextToken2(contextToken)
|
|
1778
1809
|
};
|
|
1779
1810
|
const canonical = buildCanonicalSigningString2(canonicalInput);
|
|
1780
1811
|
const publicJwk = await deps.jwks.getKey(kid);
|
|
@@ -1793,58 +1824,63 @@ async function verifyRequest(input, deps) {
|
|
|
1793
1824
|
"Ed25519 signature verification failed"
|
|
1794
1825
|
);
|
|
1795
1826
|
}
|
|
1796
|
-
if (deps.nonceCache.checkAndRemember(requestId)) {
|
|
1827
|
+
if (await deps.nonceCache.checkAndRemember(requestId)) {
|
|
1797
1828
|
throw new FartherShoreError(
|
|
1798
1829
|
"replayed_nonce",
|
|
1799
1830
|
"x-fs-request-id has already been seen (replay)"
|
|
1800
1831
|
);
|
|
1801
1832
|
}
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
signedContext = await verifyContext(token, contextSecrets);
|
|
1811
|
-
if (signedContext === null && deps.contextVerification === "required") {
|
|
1812
|
-
throw contextRequiredError("failed verification");
|
|
1813
|
-
}
|
|
1814
|
-
if (signedContext && signedContext.productId !== signedProductId) {
|
|
1815
|
-
throw new FartherShoreError(
|
|
1816
|
-
"context_unverified",
|
|
1817
|
-
"X-Fs-Context was minted for a different product than the signed request"
|
|
1818
|
-
);
|
|
1819
|
-
}
|
|
1820
|
-
} else if (deps.contextVerification === "required") {
|
|
1821
|
-
throw contextRequiredError("header is missing");
|
|
1822
|
-
}
|
|
1823
|
-
} else if (deps.contextVerification === "required") {
|
|
1824
|
-
throw contextRequiredError("keyring is empty");
|
|
1825
|
-
}
|
|
1833
|
+
const signedContext = await resolveSignedContext(
|
|
1834
|
+
contextToken,
|
|
1835
|
+
deps.contextSecrets ?? [],
|
|
1836
|
+
signedBusinessId
|
|
1837
|
+
);
|
|
1838
|
+
const permissions = signedContext?.permissions;
|
|
1839
|
+
const roles = signedContext?.roles;
|
|
1840
|
+
let principal;
|
|
1826
1841
|
if (signedContext) {
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1842
|
+
const derived = principalFromContextClaims2(signedContext);
|
|
1843
|
+
if (derived === null) {
|
|
1844
|
+
throw contextRequiredError(
|
|
1845
|
+
"carried an invalid or incomplete consumer principal"
|
|
1846
|
+
);
|
|
1847
|
+
}
|
|
1848
|
+
principal = derived;
|
|
1834
1849
|
}
|
|
1835
1850
|
return {
|
|
1836
1851
|
requestId,
|
|
1837
|
-
|
|
1852
|
+
businessId: signedBusinessId,
|
|
1838
1853
|
backendId: signedBackendId,
|
|
1839
1854
|
routeId: signedRouteId,
|
|
1840
1855
|
policyVersion,
|
|
1841
1856
|
timestamp,
|
|
1842
1857
|
bodyHash: computedBodyHash,
|
|
1858
|
+
...principal ? { principal } : {},
|
|
1843
1859
|
...permissions !== void 0 ? { permissions } : {},
|
|
1844
1860
|
...roles !== void 0 ? { roles } : {},
|
|
1845
1861
|
...signedContext ? { signedContext } : {}
|
|
1846
1862
|
};
|
|
1847
1863
|
}
|
|
1864
|
+
async function resolveSignedContext(contextToken, contextSecrets, signedBusinessId) {
|
|
1865
|
+
if (!contextToken) return null;
|
|
1866
|
+
const signedContext = decodeContextClaims(contextToken);
|
|
1867
|
+
if (signedContext === null) {
|
|
1868
|
+
throw contextRequiredError("payload could not be parsed as a cv=2 claim");
|
|
1869
|
+
}
|
|
1870
|
+
if (contextSecrets.length > 0) {
|
|
1871
|
+
const hmacVerified = await verifyContext(contextToken, contextSecrets);
|
|
1872
|
+
if (hmacVerified === null) {
|
|
1873
|
+
throw contextRequiredError("failed HS256 verification");
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
if (signedContext.businessId !== signedBusinessId) {
|
|
1877
|
+
throw new FartherShoreError(
|
|
1878
|
+
"context_unverified",
|
|
1879
|
+
"X-Fs-Context was minted for a different business than the signed request"
|
|
1880
|
+
);
|
|
1881
|
+
}
|
|
1882
|
+
return signedContext;
|
|
1883
|
+
}
|
|
1848
1884
|
async function computeBodyHash(input) {
|
|
1849
1885
|
if (input.streamingExempt) return STREAMING_EXEMPT_BODY_HASH;
|
|
1850
1886
|
const body = input.body;
|
|
@@ -1877,8 +1913,8 @@ function headerGetter(headers) {
|
|
|
1877
1913
|
|
|
1878
1914
|
// src/core/runtime.ts
|
|
1879
1915
|
var DEFAULT_CORE_URL = "https://core.farthershore.com";
|
|
1880
|
-
var SDK_VERSION = "0.
|
|
1881
|
-
var CONTRACTS_FP = "
|
|
1916
|
+
var SDK_VERSION = "0.16.0".length > 0 ? "0.16.0" : "0.0.0-dev";
|
|
1917
|
+
var CONTRACTS_FP = "c3961d4ea07ff178".length > 0 ? "c3961d4ea07ff178" : "0000000000000000";
|
|
1882
1918
|
var FartherShore = class {
|
|
1883
1919
|
bootstrapClient;
|
|
1884
1920
|
fetchImpl;
|
|
@@ -1888,11 +1924,9 @@ var FartherShore = class {
|
|
|
1888
1924
|
coreUrl;
|
|
1889
1925
|
instanceId;
|
|
1890
1926
|
tunnelOptions;
|
|
1891
|
-
/**
|
|
1927
|
+
/** OPTIONAL HS256 secret(s) — defense-in-depth over the cv=2 X-Fs-Context. */
|
|
1892
1928
|
contextSecrets;
|
|
1893
|
-
|
|
1894
|
-
contextVerification;
|
|
1895
|
-
nonceCache = new NonceCache();
|
|
1929
|
+
nonceCache;
|
|
1896
1930
|
shutdownManager = new ShutdownManager();
|
|
1897
1931
|
jwks = null;
|
|
1898
1932
|
meteringClient = null;
|
|
@@ -1910,8 +1944,8 @@ var FartherShore = class {
|
|
|
1910
1944
|
this.meteringEnabledOverride = options.metering?.enabled ?? true;
|
|
1911
1945
|
this.tunnelOptions = options.tunnel ?? {};
|
|
1912
1946
|
this.instanceId = options.instanceId;
|
|
1947
|
+
this.nonceCache = options.nonceStore ?? new NonceCache();
|
|
1913
1948
|
this.contextSecrets = options.contextSecrets ?? parseContextSecrets(env.FS_CONTEXT_SECRETS);
|
|
1914
|
-
this.contextVerification = options.contextVerification ?? (env.FS_CONTEXT_VERIFICATION === "required" ? "required" : "preferred");
|
|
1915
1949
|
this.bootstrapClient = new BootstrapClient({
|
|
1916
1950
|
runtimeToken,
|
|
1917
1951
|
coreUrl,
|
|
@@ -1947,7 +1981,7 @@ var FartherShore = class {
|
|
|
1947
1981
|
if (!this.meteringClient && config.metering.enabled) {
|
|
1948
1982
|
this.meteringClient = new MeteringClient({
|
|
1949
1983
|
config: config.metering,
|
|
1950
|
-
|
|
1984
|
+
businessId: config.business.id,
|
|
1951
1985
|
backendId: config.backend.id,
|
|
1952
1986
|
coreUrl: this.coreUrl,
|
|
1953
1987
|
fetchImpl: this.fetchImpl
|
|
@@ -2031,17 +2065,15 @@ var FartherShore = class {
|
|
|
2031
2065
|
const context = await verifyRequest(input, {
|
|
2032
2066
|
jwks: this.jwks,
|
|
2033
2067
|
nonceCache: this.nonceCache,
|
|
2034
|
-
|
|
2068
|
+
businessId: config.business.id,
|
|
2035
2069
|
backendId: config.backend.id,
|
|
2036
2070
|
knownRouteIds,
|
|
2037
2071
|
clockSkewSeconds: config.verification.clockSkewSeconds,
|
|
2038
2072
|
replayWindowSeconds: config.verification.replayWindowSeconds,
|
|
2039
|
-
//
|
|
2040
|
-
//
|
|
2041
|
-
//
|
|
2042
|
-
|
|
2043
|
-
contextSecrets: this.contextSecrets,
|
|
2044
|
-
contextVerification: this.contextVerification
|
|
2073
|
+
// Consumer-principal wave (D3): OPTIONAL defense-in-depth. The principal
|
|
2074
|
+
// is derived from the Ed25519-vouched X-Fs-Context regardless; when these
|
|
2075
|
+
// secrets are set a presented token must ALSO pass HS256.
|
|
2076
|
+
contextSecrets: this.contextSecrets
|
|
2045
2077
|
});
|
|
2046
2078
|
return {
|
|
2047
2079
|
...context,
|
|
@@ -2168,6 +2200,45 @@ function parseContextSecrets(raw) {
|
|
|
2168
2200
|
return raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
2169
2201
|
}
|
|
2170
2202
|
|
|
2203
|
+
// src/core/permissions.ts
|
|
2204
|
+
var WILDCARD = "*";
|
|
2205
|
+
var FartherShorePermissionError = class extends Error {
|
|
2206
|
+
code = "permission_denied";
|
|
2207
|
+
status = 403;
|
|
2208
|
+
/** The permission key that was required but not held. */
|
|
2209
|
+
requiredPermission;
|
|
2210
|
+
constructor(requiredPermission, message) {
|
|
2211
|
+
super(message ?? `missing required permission: ${requiredPermission}`);
|
|
2212
|
+
this.name = "FartherShorePermissionError";
|
|
2213
|
+
this.requiredPermission = requiredPermission;
|
|
2214
|
+
}
|
|
2215
|
+
};
|
|
2216
|
+
function permissionGrants(permissions, key2) {
|
|
2217
|
+
if (permissions === void 0) return true;
|
|
2218
|
+
if (permissions.includes(WILDCARD)) return true;
|
|
2219
|
+
return permissions.includes(key2);
|
|
2220
|
+
}
|
|
2221
|
+
function permissionSatisfies(required, granted) {
|
|
2222
|
+
if (granted === void 0) return true;
|
|
2223
|
+
if (granted.includes(WILDCARD)) return true;
|
|
2224
|
+
if (granted.includes(required)) return true;
|
|
2225
|
+
const idx = required.indexOf(":");
|
|
2226
|
+
if (idx > 0 && idx < required.length - 1) {
|
|
2227
|
+
const subject = required.slice(0, idx);
|
|
2228
|
+
if (granted.includes(`${subject}:${WILDCARD}`)) return true;
|
|
2229
|
+
}
|
|
2230
|
+
return false;
|
|
2231
|
+
}
|
|
2232
|
+
function hasPermission(ctx, key2) {
|
|
2233
|
+
if (ctx.permissions === void 0) return false;
|
|
2234
|
+
return permissionSatisfies(key2, ctx.permissions);
|
|
2235
|
+
}
|
|
2236
|
+
function requirePermission(ctx, key2) {
|
|
2237
|
+
if (!hasPermission(ctx, key2)) {
|
|
2238
|
+
throw new FartherShorePermissionError(key2);
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2171
2242
|
// src/adapters/express.ts
|
|
2172
2243
|
var STREAMING_CONTENT_TYPES = new Set(
|
|
2173
2244
|
RUNTIME_BODY_HASH_CONTRACT.streamingExemptContentTypes
|
|
@@ -2179,7 +2250,9 @@ function createExpressMiddleware(fs, options = {}) {
|
|
|
2179
2250
|
}
|
|
2180
2251
|
async function runMiddleware(fs, options, req, res, next) {
|
|
2181
2252
|
try {
|
|
2182
|
-
|
|
2253
|
+
const strict = options.always ?? true;
|
|
2254
|
+
if (!strict && !await fs.verificationRequired()) {
|
|
2255
|
+
stripFartherShoreHeaders(req);
|
|
2183
2256
|
next();
|
|
2184
2257
|
return;
|
|
2185
2258
|
}
|
|
@@ -2196,6 +2269,7 @@ async function runMiddleware(fs, options, req, res, next) {
|
|
|
2196
2269
|
streamingExempt
|
|
2197
2270
|
});
|
|
2198
2271
|
req.fartherShore = ctx;
|
|
2272
|
+
stripFartherShoreHeaders(req);
|
|
2199
2273
|
next();
|
|
2200
2274
|
} catch (error) {
|
|
2201
2275
|
fail(res, error);
|
|
@@ -2208,6 +2282,51 @@ function fail(res, error) {
|
|
|
2208
2282
|
}
|
|
2209
2283
|
res.status(401).json({ error: "bad_signature" });
|
|
2210
2284
|
}
|
|
2285
|
+
function stripFartherShoreHeaders(req) {
|
|
2286
|
+
const headers = req.headers;
|
|
2287
|
+
for (const name of Object.keys(headers)) {
|
|
2288
|
+
if (name.toLowerCase().startsWith("x-fs-")) {
|
|
2289
|
+
delete headers[name];
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
const withRaw = req;
|
|
2293
|
+
const raw = withRaw.rawHeaders;
|
|
2294
|
+
if (Array.isArray(raw)) {
|
|
2295
|
+
const cleaned = [];
|
|
2296
|
+
for (let i = 0; i < raw.length; i += 2) {
|
|
2297
|
+
const key2 = raw[i];
|
|
2298
|
+
const value = raw[i + 1];
|
|
2299
|
+
if (typeof key2 !== "string" || value === void 0) continue;
|
|
2300
|
+
if (key2.toLowerCase().startsWith("x-fs-")) continue;
|
|
2301
|
+
cleaned.push(key2, value);
|
|
2302
|
+
}
|
|
2303
|
+
withRaw.rawHeaders = cleaned;
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
function createExpressHandler(handler) {
|
|
2307
|
+
return (req, res, next) => {
|
|
2308
|
+
const ctx = req.fartherShore;
|
|
2309
|
+
if (!ctx) {
|
|
2310
|
+
res.status(401).json({ error: "context_unverified" });
|
|
2311
|
+
return;
|
|
2312
|
+
}
|
|
2313
|
+
if (!ctx.principal) {
|
|
2314
|
+
res.status(401).json({ error: "principal_required" });
|
|
2315
|
+
return;
|
|
2316
|
+
}
|
|
2317
|
+
const verified = ctx;
|
|
2318
|
+
void Promise.resolve().then(
|
|
2319
|
+
() => handler(verified, req, res, next)
|
|
2320
|
+
).catch((error) => failHandler(res, next, error));
|
|
2321
|
+
};
|
|
2322
|
+
}
|
|
2323
|
+
function failHandler(res, next, error) {
|
|
2324
|
+
if (error instanceof FartherShoreError || error instanceof FartherShorePermissionError) {
|
|
2325
|
+
res.status(error.status).json({ error: error.code });
|
|
2326
|
+
return;
|
|
2327
|
+
}
|
|
2328
|
+
next(error);
|
|
2329
|
+
}
|
|
2211
2330
|
function splitUrl(req) {
|
|
2212
2331
|
const raw = req.originalUrl ?? req.url ?? req.path ?? "/";
|
|
2213
2332
|
const qIndex = raw.indexOf("?");
|
|
@@ -2234,6 +2353,32 @@ function headerValue(headers, name) {
|
|
|
2234
2353
|
return value;
|
|
2235
2354
|
}
|
|
2236
2355
|
|
|
2356
|
+
// src/core/subject.ts
|
|
2357
|
+
var MEMBER_SUBJECT_REQUIRED = RUNTIME_ERROR_CODES.memberSubjectRequired;
|
|
2358
|
+
var SERVICE_SUBJECT_REQUIRED = RUNTIME_ERROR_CODES.serviceSubjectRequired;
|
|
2359
|
+
function requireMember(ctx) {
|
|
2360
|
+
const subject = ctx.principal?.subject;
|
|
2361
|
+
if (!subject || subject.kind !== "member") {
|
|
2362
|
+
throw new FartherShoreError(
|
|
2363
|
+
MEMBER_SUBJECT_REQUIRED,
|
|
2364
|
+
"this operation requires a member subject (a user session or a personal key)",
|
|
2365
|
+
403
|
|
2366
|
+
);
|
|
2367
|
+
}
|
|
2368
|
+
return subject;
|
|
2369
|
+
}
|
|
2370
|
+
function requireService(ctx) {
|
|
2371
|
+
const subject = ctx.principal?.subject;
|
|
2372
|
+
if (!subject || subject.kind !== "service") {
|
|
2373
|
+
throw new FartherShoreError(
|
|
2374
|
+
SERVICE_SUBJECT_REQUIRED,
|
|
2375
|
+
"this operation requires a service subject (an org-owned service-account key)",
|
|
2376
|
+
403
|
|
2377
|
+
);
|
|
2378
|
+
}
|
|
2379
|
+
return subject;
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2237
2382
|
// src/testing/signers.ts
|
|
2238
2383
|
import { generateKeyPairSync, randomBytes } from "node:crypto";
|
|
2239
2384
|
var TEST_KID = "fs-runtime-test-2026";
|
|
@@ -2261,10 +2406,13 @@ async function makeSignedRequest(spec = {}) {
|
|
|
2261
2406
|
bodyHash,
|
|
2262
2407
|
requestId: spec.requestId ?? `req_${cryptoRandom()}`,
|
|
2263
2408
|
timestamp: spec.timestamp ?? Math.floor(Date.now() / 1e3),
|
|
2264
|
-
|
|
2409
|
+
businessId: spec.businessId ?? "biz_test",
|
|
2265
2410
|
backendId: spec.backendId ?? "be_test",
|
|
2266
2411
|
routeId: spec.routeId ?? "route_test",
|
|
2267
|
-
policyVersion: spec.policyVersion ?? "pv_1"
|
|
2412
|
+
policyVersion: spec.policyVersion ?? "pv_1",
|
|
2413
|
+
// Consumer-principal wave (D3): bind the X-Fs-Context hash into the
|
|
2414
|
+
// canonical string (empty context → SHA-256 of the empty string).
|
|
2415
|
+
contextHash: await hashContextToken2(spec.contextToken)
|
|
2268
2416
|
};
|
|
2269
2417
|
const canonical = buildCanonicalSigningString2(claim);
|
|
2270
2418
|
const signature = await signCanonicalString2(canonical, privateJwk);
|
|
@@ -2273,11 +2421,12 @@ async function makeSignedRequest(spec = {}) {
|
|
|
2273
2421
|
[RUNTIME_HEADER_NAMES.keyId]: kid,
|
|
2274
2422
|
[RUNTIME_HEADER_NAMES.requestId]: claim.requestId,
|
|
2275
2423
|
[RUNTIME_HEADER_NAMES.timestamp]: String(claim.timestamp),
|
|
2276
|
-
[RUNTIME_HEADER_NAMES.
|
|
2424
|
+
[RUNTIME_HEADER_NAMES.businessId]: claim.businessId,
|
|
2277
2425
|
[RUNTIME_HEADER_NAMES.backendId]: claim.backendId,
|
|
2278
2426
|
[RUNTIME_HEADER_NAMES.routeId]: claim.routeId,
|
|
2279
2427
|
[RUNTIME_HEADER_NAMES.policyVersion]: claim.policyVersion,
|
|
2280
|
-
[RUNTIME_HEADER_NAMES.bodyHash]: claim.bodyHash
|
|
2428
|
+
[RUNTIME_HEADER_NAMES.bodyHash]: claim.bodyHash,
|
|
2429
|
+
...spec.contextToken ? { "x-fs-context": spec.contextToken } : {}
|
|
2281
2430
|
};
|
|
2282
2431
|
return {
|
|
2283
2432
|
input: { method, path, query, body, streamingExempt },
|
|
@@ -2295,7 +2444,7 @@ function base64urlEncodeJson(value) {
|
|
|
2295
2444
|
}
|
|
2296
2445
|
async function signContextToken(claim, secret = TEST_CONTEXT_SECRET, kid = TEST_CONTEXT_KID) {
|
|
2297
2446
|
const header = base64urlEncodeJson({ alg: "HS256", typ: "JWT", kid });
|
|
2298
|
-
const payload = base64urlEncodeJson({
|
|
2447
|
+
const payload = base64urlEncodeJson({ ...claim });
|
|
2299
2448
|
const signingInput = `${header}.${payload}`;
|
|
2300
2449
|
const key2 = await crypto.subtle.importKey(
|
|
2301
2450
|
"raw",
|
|
@@ -2374,13 +2523,16 @@ function mergeHeaders(initHeaders, signedHeaders) {
|
|
|
2374
2523
|
}
|
|
2375
2524
|
return headers;
|
|
2376
2525
|
}
|
|
2377
|
-
function buildContextClaim(persona,
|
|
2526
|
+
function buildContextClaim(persona, businessId) {
|
|
2527
|
+
const memberId = persona.actor?.id ?? `user_${persona.name}`;
|
|
2378
2528
|
return {
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2529
|
+
cv: 2,
|
|
2530
|
+
sub: memberId,
|
|
2531
|
+
subjectKind: "member",
|
|
2532
|
+
org: persona.orgId ?? "org_dev",
|
|
2533
|
+
// Business binding: the signed-context businessId claim MUST equal the
|
|
2534
|
+
// signed request businessId or verifyRequest rejects it as tamper evidence.
|
|
2535
|
+
businessId,
|
|
2384
2536
|
compiledPlanId: persona.compiledPlanId ?? "plan_dev",
|
|
2385
2537
|
subscriptionId: persona.subscriptionId ?? "sub_dev",
|
|
2386
2538
|
subscriberId: persona.subscriberId ?? "subscriber_dev",
|
|
@@ -2402,30 +2554,27 @@ function createPersonaClient(ctx) {
|
|
|
2402
2554
|
}
|
|
2403
2555
|
async function buildHeaders(persona, spec) {
|
|
2404
2556
|
const method = normalizeMethod(spec.method);
|
|
2557
|
+
const contextToken = persona.anonymous ? void 0 : await signContextToken(
|
|
2558
|
+
buildContextClaim(persona, ctx.businessId),
|
|
2559
|
+
ctx.contextSecret,
|
|
2560
|
+
ctx.contextKid
|
|
2561
|
+
);
|
|
2405
2562
|
const signed = await makeSignedRequest({
|
|
2406
2563
|
method,
|
|
2407
2564
|
path: spec.path ?? "/",
|
|
2408
2565
|
query: spec.query ?? "",
|
|
2409
2566
|
body: spec.body ?? null,
|
|
2410
2567
|
streamingExempt: spec.streamingExempt ?? false,
|
|
2411
|
-
|
|
2568
|
+
businessId: ctx.businessId,
|
|
2412
2569
|
backendId: ctx.backendId,
|
|
2413
2570
|
routeId: spec.routeId ?? "",
|
|
2414
2571
|
privateJwk: ctx.keys.privateJwk,
|
|
2415
2572
|
kid: ctx.keys.kid,
|
|
2573
|
+
...contextToken ? { contextToken } : {},
|
|
2416
2574
|
...spec.requestId ? { requestId: spec.requestId } : {},
|
|
2417
2575
|
...spec.timestamp !== void 0 ? { timestamp: spec.timestamp } : {}
|
|
2418
2576
|
});
|
|
2419
|
-
|
|
2420
|
-
if (!persona.anonymous) {
|
|
2421
|
-
const claim = buildContextClaim(persona, ctx.productId);
|
|
2422
|
-
headers["x-fs-context"] = await signContextToken(
|
|
2423
|
-
claim,
|
|
2424
|
-
ctx.contextSecret,
|
|
2425
|
-
ctx.contextKid
|
|
2426
|
-
);
|
|
2427
|
-
}
|
|
2428
|
-
return headers;
|
|
2577
|
+
return { ...signed.headers };
|
|
2429
2578
|
}
|
|
2430
2579
|
function asPersona(name) {
|
|
2431
2580
|
const persona = resolve(name);
|
|
@@ -2491,12 +2640,12 @@ var DEV_CORE_URL = "https://dev-gateway.farthershore.local";
|
|
|
2491
2640
|
var DEV_JWKS_URL = `${DEV_CORE_URL}/.well-known/jwks.json`;
|
|
2492
2641
|
var DEV_METERING_ENDPOINT = `${DEV_CORE_URL}/v1/metering/events`;
|
|
2493
2642
|
function createDevGateway(options) {
|
|
2494
|
-
const
|
|
2643
|
+
const businessId = options.businessId ?? "biz_dev";
|
|
2495
2644
|
const backendId = options.backendId ?? "be_dev";
|
|
2496
2645
|
const meterEvents = [];
|
|
2497
2646
|
const reportUsageEvents = [];
|
|
2498
2647
|
const bootstrap = {
|
|
2499
|
-
|
|
2648
|
+
business: { id: businessId, slug: options.businessSlug ?? "dev-business" },
|
|
2500
2649
|
backend: {
|
|
2501
2650
|
id: backendId,
|
|
2502
2651
|
slug: options.backendSlug ?? "dev-backend",
|
|
@@ -2566,7 +2715,7 @@ function createDevGateway(options) {
|
|
|
2566
2715
|
bootstrap,
|
|
2567
2716
|
meterEvents,
|
|
2568
2717
|
reportUsageEvents,
|
|
2569
|
-
|
|
2718
|
+
businessId,
|
|
2570
2719
|
backendId,
|
|
2571
2720
|
jwksUrl: DEV_JWKS_URL
|
|
2572
2721
|
};
|
|
@@ -2768,7 +2917,7 @@ function createDevRuntime(options) {
|
|
|
2768
2917
|
const gateway = createDevGateway({
|
|
2769
2918
|
mode,
|
|
2770
2919
|
keys,
|
|
2771
|
-
...options.
|
|
2920
|
+
...options.businessId ? { businessId: options.businessId } : {},
|
|
2772
2921
|
...options.backendId ? { backendId: options.backendId } : {},
|
|
2773
2922
|
...options.routes ? { routeIds: options.routes } : {},
|
|
2774
2923
|
onMeterEvent: (event) => {
|
|
@@ -2797,7 +2946,7 @@ function createDevRuntime(options) {
|
|
|
2797
2946
|
const personas = buildPersonaMap(options.personas);
|
|
2798
2947
|
const personaClient = createPersonaClient({
|
|
2799
2948
|
keys,
|
|
2800
|
-
|
|
2949
|
+
businessId: gateway.businessId,
|
|
2801
2950
|
backendId: gateway.backendId,
|
|
2802
2951
|
contextSecret: keys.contextSecret,
|
|
2803
2952
|
contextKid: keys.contextKid,
|
|
@@ -2809,8 +2958,9 @@ function createDevRuntime(options) {
|
|
|
2809
2958
|
runtimeToken: keys.runtimeToken,
|
|
2810
2959
|
coreUrl: DEV_CORE_URL,
|
|
2811
2960
|
fetchImpl: gateway.fetchImpl,
|
|
2961
|
+
// Consumer-principal wave (D3): signed cv=2 context is fail-closed whenever
|
|
2962
|
+
// contextSecrets are set — no per-mode verification toggle.
|
|
2812
2963
|
contextSecrets: [keys.contextSecret],
|
|
2813
|
-
contextVerification: mode === "simulated" ? "required" : "preferred",
|
|
2814
2964
|
env: {}
|
|
2815
2965
|
});
|
|
2816
2966
|
const tracedAuthz = {
|
|
@@ -2836,7 +2986,8 @@ function createDevRuntime(options) {
|
|
|
2836
2986
|
});
|
|
2837
2987
|
}
|
|
2838
2988
|
function middleware(mwOptions) {
|
|
2839
|
-
const
|
|
2989
|
+
const resolved = mode === "passthrough" ? { always: false, ...mwOptions } : mwOptions ?? {};
|
|
2990
|
+
const inner = createExpressMiddleware(fs, resolved);
|
|
2840
2991
|
return (req, res, next) => {
|
|
2841
2992
|
const requestId = headerValue2(req.headers, "x-fs-request-id") ?? "unknown";
|
|
2842
2993
|
const { path } = splitUrl2(req);
|
|
@@ -2875,6 +3026,7 @@ function createDevRuntime(options) {
|
|
|
2875
3026
|
};
|
|
2876
3027
|
}
|
|
2877
3028
|
fs.middleware = middleware;
|
|
3029
|
+
fs.handler = createExpressHandler;
|
|
2878
3030
|
const devRuntime = {
|
|
2879
3031
|
fs,
|
|
2880
3032
|
asPersona: (name) => personaClient.asPersona(name),
|
|
@@ -2927,7 +3079,7 @@ function createDevRuntimeFromEnv(env = readProcessEnv3()) {
|
|
|
2927
3079
|
version: 1,
|
|
2928
3080
|
mode,
|
|
2929
3081
|
keys,
|
|
2930
|
-
|
|
3082
|
+
businessId: runtime.gateway.businessId,
|
|
2931
3083
|
backendId: runtime.gateway.backendId,
|
|
2932
3084
|
personas: mapToRecord(runtime.personas)
|
|
2933
3085
|
};
|
|
@@ -2940,7 +3092,7 @@ function printBanner(mode, runtime, tracePath) {
|
|
|
2940
3092
|
"============================================================",
|
|
2941
3093
|
" \u26A0 FARTHER SHORE DEV MODE ACTIVE \u2014 NOT FOR PRODUCTION",
|
|
2942
3094
|
` mode: ${mode.toUpperCase()}`,
|
|
2943
|
-
`
|
|
3095
|
+
` business: ${runtime.gateway.businessId}`,
|
|
2944
3096
|
` backend: ${runtime.gateway.backendId}`,
|
|
2945
3097
|
` personas: ${[...runtime.personas.keys()].join(", ")}`,
|
|
2946
3098
|
` usage log: ${USAGE_JSONL_PATH}`,
|
|
@@ -3002,6 +3154,7 @@ var fartherShore = {
|
|
|
3002
3154
|
}
|
|
3003
3155
|
const fs = initFromEnv(options);
|
|
3004
3156
|
fs.middleware = (mwOptions) => createExpressMiddleware(fs, mwOptions);
|
|
3157
|
+
fs.handler = createExpressHandler;
|
|
3005
3158
|
return fs;
|
|
3006
3159
|
}
|
|
3007
3160
|
};
|
|
@@ -3017,7 +3170,6 @@ export {
|
|
|
3017
3170
|
FartherShore,
|
|
3018
3171
|
FartherShoreError,
|
|
3019
3172
|
FartherShorePermissionError,
|
|
3020
|
-
IDENTITY_HEADER_NAMES,
|
|
3021
3173
|
JwksClient,
|
|
3022
3174
|
MAX_BODY_BYTES,
|
|
3023
3175
|
METERING_PAYLOAD_HEADER,
|
|
@@ -3041,18 +3193,22 @@ export {
|
|
|
3041
3193
|
buildHealthReport,
|
|
3042
3194
|
canonicalizeQuery2 as canonicalizeQuery,
|
|
3043
3195
|
computeMeteringHeaders,
|
|
3196
|
+
createExpressHandler,
|
|
3044
3197
|
createExpressMiddleware,
|
|
3045
3198
|
createUsage,
|
|
3199
|
+
decodeContextClaims,
|
|
3046
3200
|
fartherShore,
|
|
3047
3201
|
hasPermission,
|
|
3048
3202
|
hashBody2 as hashBody,
|
|
3049
3203
|
initFromEnv2 as initFromEnv,
|
|
3050
3204
|
nodeSpawn,
|
|
3051
|
-
parsePermissionHeader,
|
|
3052
3205
|
permissionGrants,
|
|
3053
3206
|
permissionSatisfies,
|
|
3207
|
+
principalFromContextClaims2 as principalFromContextClaims,
|
|
3054
3208
|
reportHealth,
|
|
3209
|
+
requireMember,
|
|
3055
3210
|
requirePermission,
|
|
3211
|
+
requireService,
|
|
3056
3212
|
runtimeErrorToErrorCode,
|
|
3057
3213
|
runtimeTokenKind2 as runtimeTokenKind,
|
|
3058
3214
|
signCanonicalString2 as signCanonicalString,
|