@farthershore/backend 0.15.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 +1 -1
- package/dist/adapters/express.js +63 -1
- package/dist/generated/runtime-contract.js +10 -5
- package/dist/index.js +303 -148
- package/dist/testing/index.js +266 -140
- package/dist/types/adapters/express.d.ts +55 -5
- 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 +34 -24
- package/dist/types/generated/runtime-contract.d.ts +6 -3
- 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 +9 -6
- package/dist/types/testing/devRuntime.d.ts +3 -1
- package/dist/types/testing/signers.d.ts +12 -4
- 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";
|
|
@@ -312,10 +317,6 @@ var RUNTIME_HEADER_NAMES = {
|
|
|
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";
|
|
@@ -361,31 +362,16 @@ var CANONICAL_SIGNING_FIELDS = [
|
|
|
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 = {
|
|
@@ -398,10 +384,14 @@ function buildCanonicalSigningString(input) {
|
|
|
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;
|
|
@@ -941,7 +932,7 @@ async function withUsage(request, response, usage, options = {}) {
|
|
|
941
932
|
}
|
|
942
933
|
async function signResponse(request, response, usage, options, wrapOptions) {
|
|
943
934
|
const payload = buildPayload(request, usage, options, wrapOptions);
|
|
944
|
-
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;
|
|
945
936
|
const headers = await computeMeteringHeaders(payload, {
|
|
946
937
|
...options.token !== void 0 ? { token: options.token } : {},
|
|
947
938
|
...options.env !== void 0 ? { env: options.env } : {},
|
|
@@ -1195,8 +1186,8 @@ function resolveEndpoint2(endpoint, coreUrl) {
|
|
|
1195
1186
|
}
|
|
1196
1187
|
|
|
1197
1188
|
// src/core/nonceCache.ts
|
|
1198
|
-
var DEFAULT_MAX_ENTRIES =
|
|
1199
|
-
var DEFAULT_TTL_MS =
|
|
1189
|
+
var DEFAULT_MAX_ENTRIES = 25e4;
|
|
1190
|
+
var DEFAULT_TTL_MS = (RUNTIME_REPLAY_WINDOW_SECONDS + RUNTIME_CLOCK_SKEW_SECONDS) * 1e3;
|
|
1200
1191
|
var NonceCache = class {
|
|
1201
1192
|
maxEntries;
|
|
1202
1193
|
ttlMs;
|
|
@@ -1220,7 +1211,7 @@ var NonceCache = class {
|
|
|
1220
1211
|
this.seen.delete(id);
|
|
1221
1212
|
}
|
|
1222
1213
|
this.evictExpired(at);
|
|
1223
|
-
this.
|
|
1214
|
+
if (this.seen.size >= this.maxEntries) return true;
|
|
1224
1215
|
this.seen.set(id, at);
|
|
1225
1216
|
return false;
|
|
1226
1217
|
}
|
|
@@ -1234,13 +1225,6 @@ var NonceCache = class {
|
|
|
1234
1225
|
this.seen.delete(id);
|
|
1235
1226
|
}
|
|
1236
1227
|
}
|
|
1237
|
-
evictOverflow() {
|
|
1238
|
-
while (this.seen.size >= this.maxEntries) {
|
|
1239
|
-
const oldest = this.seen.keys().next().value;
|
|
1240
|
-
if (oldest === void 0) break;
|
|
1241
|
-
this.seen.delete(oldest);
|
|
1242
|
-
}
|
|
1243
|
-
}
|
|
1244
1228
|
};
|
|
1245
1229
|
|
|
1246
1230
|
// src/core/shutdown.ts
|
|
@@ -1575,53 +1559,82 @@ function resolvePackageBinary(require2, pkg, manifestPath) {
|
|
|
1575
1559
|
return `${root}${sep}${normalized}`;
|
|
1576
1560
|
}
|
|
1577
1561
|
|
|
1578
|
-
//
|
|
1579
|
-
var
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
status = 403;
|
|
1583
|
-
/** The permission key that was required but not held. */
|
|
1584
|
-
requiredPermission;
|
|
1585
|
-
constructor(requiredPermission, message) {
|
|
1586
|
-
super(message ?? `missing required permission: ${requiredPermission}`);
|
|
1587
|
-
this.name = "FartherShorePermissionError";
|
|
1588
|
-
this.requiredPermission = requiredPermission;
|
|
1589
|
-
}
|
|
1590
|
-
};
|
|
1591
|
-
function parsePermissionHeader(raw) {
|
|
1592
|
-
if (raw === null || raw === void 0) return void 0;
|
|
1593
|
-
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);
|
|
1594
1566
|
}
|
|
1595
|
-
function
|
|
1596
|
-
|
|
1597
|
-
if (permissions.includes(WILDCARD)) return true;
|
|
1598
|
-
return permissions.includes(key2);
|
|
1567
|
+
function isNonEmptyTrimmed(value) {
|
|
1568
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
1599
1569
|
}
|
|
1600
|
-
function
|
|
1601
|
-
|
|
1602
|
-
if (granted.includes(WILDCARD)) return true;
|
|
1603
|
-
if (granted.includes(required)) return true;
|
|
1604
|
-
const idx = required.indexOf(":");
|
|
1605
|
-
if (idx > 0 && idx < required.length - 1) {
|
|
1606
|
-
const subject = required.slice(0, idx);
|
|
1607
|
-
if (granted.includes(`${subject}:${WILDCARD}`)) return true;
|
|
1608
|
-
}
|
|
1609
|
-
return false;
|
|
1570
|
+
function normalizeIdentityId(value) {
|
|
1571
|
+
return value.trim();
|
|
1610
1572
|
}
|
|
1611
|
-
function
|
|
1612
|
-
|
|
1613
|
-
return ctx.signedContext !== void 0;
|
|
1614
|
-
}
|
|
1615
|
-
return permissionSatisfies(key2, ctx.permissions);
|
|
1573
|
+
function isNonEmptyStringArray(value) {
|
|
1574
|
+
return Array.isArray(value) && value.every((item) => isNonEmptyTrimmed(item));
|
|
1616
1575
|
}
|
|
1617
|
-
function
|
|
1618
|
-
if (
|
|
1619
|
-
|
|
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;
|
|
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
|
+
};
|
|
1620
1630
|
}
|
|
1631
|
+
return { org, subject: { kind: "member", memberId, via: "session" } };
|
|
1621
1632
|
}
|
|
1622
|
-
var IDENTITY_HEADER_NAMES = RUNTIME_IDENTITY_HEADER_NAMES;
|
|
1623
1633
|
|
|
1624
1634
|
// src/core/verifyContext.ts
|
|
1635
|
+
function principalFromContextClaims2(claims) {
|
|
1636
|
+
return principalFromContextClaims(claims);
|
|
1637
|
+
}
|
|
1625
1638
|
var EXPECTED_JWT_ALG = "HS256";
|
|
1626
1639
|
function base64urlDecode(value) {
|
|
1627
1640
|
const padded = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
@@ -1661,6 +1674,7 @@ async function verifyContext(token, secrets) {
|
|
|
1661
1674
|
}
|
|
1662
1675
|
let verified = false;
|
|
1663
1676
|
for (const secret of secrets) {
|
|
1677
|
+
if (secret.trim().length === 0) continue;
|
|
1664
1678
|
try {
|
|
1665
1679
|
const key2 = await importHmacKey(secret);
|
|
1666
1680
|
if (await crypto.subtle.verify(
|
|
@@ -1676,11 +1690,20 @@ async function verifyContext(token, secrets) {
|
|
|
1676
1690
|
}
|
|
1677
1691
|
}
|
|
1678
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) {
|
|
1679
1701
|
try {
|
|
1680
1702
|
const parsed = JSON.parse(
|
|
1681
1703
|
new TextDecoder().decode(base64urlDecode(payload))
|
|
1682
1704
|
);
|
|
1683
1705
|
if (typeof parsed !== "object" || parsed === null) return null;
|
|
1706
|
+
if (parsed.cv !== 2) return null;
|
|
1684
1707
|
return parsed;
|
|
1685
1708
|
} catch {
|
|
1686
1709
|
return null;
|
|
@@ -1689,7 +1712,7 @@ async function verifyContext(token, secrets) {
|
|
|
1689
1712
|
function contextRequiredError(reason) {
|
|
1690
1713
|
return new FartherShoreError(
|
|
1691
1714
|
"context_unverified",
|
|
1692
|
-
`X-Fs-Context ${reason}
|
|
1715
|
+
`X-Fs-Context ${reason} \u2014 signed context is required whenever context secrets are configured`
|
|
1693
1716
|
);
|
|
1694
1717
|
}
|
|
1695
1718
|
|
|
@@ -1765,6 +1788,7 @@ async function verifyRequest(input, deps) {
|
|
|
1765
1788
|
"signed route-id is not served by this backend"
|
|
1766
1789
|
);
|
|
1767
1790
|
}
|
|
1791
|
+
const contextToken = h("x-fs-context") ?? null;
|
|
1768
1792
|
const canonicalInput = {
|
|
1769
1793
|
method: input.method,
|
|
1770
1794
|
path: input.path,
|
|
@@ -1775,7 +1799,13 @@ async function verifyRequest(input, deps) {
|
|
|
1775
1799
|
businessId: signedBusinessId,
|
|
1776
1800
|
backendId: signedBackendId,
|
|
1777
1801
|
routeId: signedRouteId,
|
|
1778
|
-
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)
|
|
1779
1809
|
};
|
|
1780
1810
|
const canonical = buildCanonicalSigningString2(canonicalInput);
|
|
1781
1811
|
const publicJwk = await deps.jwks.getKey(kid);
|
|
@@ -1794,44 +1824,28 @@ async function verifyRequest(input, deps) {
|
|
|
1794
1824
|
"Ed25519 signature verification failed"
|
|
1795
1825
|
);
|
|
1796
1826
|
}
|
|
1797
|
-
if (deps.nonceCache.checkAndRemember(requestId)) {
|
|
1827
|
+
if (await deps.nonceCache.checkAndRemember(requestId)) {
|
|
1798
1828
|
throw new FartherShoreError(
|
|
1799
1829
|
"replayed_nonce",
|
|
1800
1830
|
"x-fs-request-id has already been seen (replay)"
|
|
1801
1831
|
);
|
|
1802
1832
|
}
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
signedContext = await verifyContext(token, contextSecrets);
|
|
1812
|
-
if (signedContext === null && deps.contextVerification === "required") {
|
|
1813
|
-
throw contextRequiredError("failed verification");
|
|
1814
|
-
}
|
|
1815
|
-
if (signedContext && signedContext.productId !== signedBusinessId) {
|
|
1816
|
-
throw new FartherShoreError(
|
|
1817
|
-
"context_unverified",
|
|
1818
|
-
"X-Fs-Context was minted for a different business than the signed request"
|
|
1819
|
-
);
|
|
1820
|
-
}
|
|
1821
|
-
} else if (deps.contextVerification === "required") {
|
|
1822
|
-
throw contextRequiredError("header is missing");
|
|
1823
|
-
}
|
|
1824
|
-
} else if (deps.contextVerification === "required") {
|
|
1825
|
-
throw contextRequiredError("keyring is empty");
|
|
1826
|
-
}
|
|
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;
|
|
1827
1841
|
if (signedContext) {
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
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;
|
|
1835
1849
|
}
|
|
1836
1850
|
return {
|
|
1837
1851
|
requestId,
|
|
@@ -1841,11 +1855,32 @@ async function verifyRequest(input, deps) {
|
|
|
1841
1855
|
policyVersion,
|
|
1842
1856
|
timestamp,
|
|
1843
1857
|
bodyHash: computedBodyHash,
|
|
1858
|
+
...principal ? { principal } : {},
|
|
1844
1859
|
...permissions !== void 0 ? { permissions } : {},
|
|
1845
1860
|
...roles !== void 0 ? { roles } : {},
|
|
1846
1861
|
...signedContext ? { signedContext } : {}
|
|
1847
1862
|
};
|
|
1848
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
|
+
}
|
|
1849
1884
|
async function computeBodyHash(input) {
|
|
1850
1885
|
if (input.streamingExempt) return STREAMING_EXEMPT_BODY_HASH;
|
|
1851
1886
|
const body = input.body;
|
|
@@ -1878,8 +1913,8 @@ function headerGetter(headers) {
|
|
|
1878
1913
|
|
|
1879
1914
|
// src/core/runtime.ts
|
|
1880
1915
|
var DEFAULT_CORE_URL = "https://core.farthershore.com";
|
|
1881
|
-
var SDK_VERSION = "0.
|
|
1882
|
-
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";
|
|
1883
1918
|
var FartherShore = class {
|
|
1884
1919
|
bootstrapClient;
|
|
1885
1920
|
fetchImpl;
|
|
@@ -1889,11 +1924,9 @@ var FartherShore = class {
|
|
|
1889
1924
|
coreUrl;
|
|
1890
1925
|
instanceId;
|
|
1891
1926
|
tunnelOptions;
|
|
1892
|
-
/**
|
|
1927
|
+
/** OPTIONAL HS256 secret(s) — defense-in-depth over the cv=2 X-Fs-Context. */
|
|
1893
1928
|
contextSecrets;
|
|
1894
|
-
|
|
1895
|
-
contextVerification;
|
|
1896
|
-
nonceCache = new NonceCache();
|
|
1929
|
+
nonceCache;
|
|
1897
1930
|
shutdownManager = new ShutdownManager();
|
|
1898
1931
|
jwks = null;
|
|
1899
1932
|
meteringClient = null;
|
|
@@ -1911,8 +1944,8 @@ var FartherShore = class {
|
|
|
1911
1944
|
this.meteringEnabledOverride = options.metering?.enabled ?? true;
|
|
1912
1945
|
this.tunnelOptions = options.tunnel ?? {};
|
|
1913
1946
|
this.instanceId = options.instanceId;
|
|
1947
|
+
this.nonceCache = options.nonceStore ?? new NonceCache();
|
|
1914
1948
|
this.contextSecrets = options.contextSecrets ?? parseContextSecrets(env.FS_CONTEXT_SECRETS);
|
|
1915
|
-
this.contextVerification = options.contextVerification ?? (env.FS_CONTEXT_VERIFICATION === "required" ? "required" : "preferred");
|
|
1916
1949
|
this.bootstrapClient = new BootstrapClient({
|
|
1917
1950
|
runtimeToken,
|
|
1918
1951
|
coreUrl,
|
|
@@ -2037,12 +2070,10 @@ var FartherShore = class {
|
|
|
2037
2070
|
knownRouteIds,
|
|
2038
2071
|
clockSkewSeconds: config.verification.clockSkewSeconds,
|
|
2039
2072
|
replayWindowSeconds: config.verification.replayWindowSeconds,
|
|
2040
|
-
//
|
|
2041
|
-
//
|
|
2042
|
-
//
|
|
2043
|
-
|
|
2044
|
-
contextSecrets: this.contextSecrets,
|
|
2045
|
-
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
|
|
2046
2077
|
});
|
|
2047
2078
|
return {
|
|
2048
2079
|
...context,
|
|
@@ -2169,6 +2200,45 @@ function parseContextSecrets(raw) {
|
|
|
2169
2200
|
return raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
2170
2201
|
}
|
|
2171
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
|
+
|
|
2172
2242
|
// src/adapters/express.ts
|
|
2173
2243
|
var STREAMING_CONTENT_TYPES = new Set(
|
|
2174
2244
|
RUNTIME_BODY_HASH_CONTRACT.streamingExemptContentTypes
|
|
@@ -2180,7 +2250,9 @@ function createExpressMiddleware(fs, options = {}) {
|
|
|
2180
2250
|
}
|
|
2181
2251
|
async function runMiddleware(fs, options, req, res, next) {
|
|
2182
2252
|
try {
|
|
2183
|
-
|
|
2253
|
+
const strict = options.always ?? true;
|
|
2254
|
+
if (!strict && !await fs.verificationRequired()) {
|
|
2255
|
+
stripFartherShoreHeaders(req);
|
|
2184
2256
|
next();
|
|
2185
2257
|
return;
|
|
2186
2258
|
}
|
|
@@ -2197,6 +2269,7 @@ async function runMiddleware(fs, options, req, res, next) {
|
|
|
2197
2269
|
streamingExempt
|
|
2198
2270
|
});
|
|
2199
2271
|
req.fartherShore = ctx;
|
|
2272
|
+
stripFartherShoreHeaders(req);
|
|
2200
2273
|
next();
|
|
2201
2274
|
} catch (error) {
|
|
2202
2275
|
fail(res, error);
|
|
@@ -2209,6 +2282,51 @@ function fail(res, error) {
|
|
|
2209
2282
|
}
|
|
2210
2283
|
res.status(401).json({ error: "bad_signature" });
|
|
2211
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
|
+
}
|
|
2212
2330
|
function splitUrl(req) {
|
|
2213
2331
|
const raw = req.originalUrl ?? req.url ?? req.path ?? "/";
|
|
2214
2332
|
const qIndex = raw.indexOf("?");
|
|
@@ -2235,6 +2353,32 @@ function headerValue(headers, name) {
|
|
|
2235
2353
|
return value;
|
|
2236
2354
|
}
|
|
2237
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
|
+
|
|
2238
2382
|
// src/testing/signers.ts
|
|
2239
2383
|
import { generateKeyPairSync, randomBytes } from "node:crypto";
|
|
2240
2384
|
var TEST_KID = "fs-runtime-test-2026";
|
|
@@ -2265,7 +2409,10 @@ async function makeSignedRequest(spec = {}) {
|
|
|
2265
2409
|
businessId: spec.businessId ?? "biz_test",
|
|
2266
2410
|
backendId: spec.backendId ?? "be_test",
|
|
2267
2411
|
routeId: spec.routeId ?? "route_test",
|
|
2268
|
-
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)
|
|
2269
2416
|
};
|
|
2270
2417
|
const canonical = buildCanonicalSigningString2(claim);
|
|
2271
2418
|
const signature = await signCanonicalString2(canonical, privateJwk);
|
|
@@ -2278,7 +2425,8 @@ async function makeSignedRequest(spec = {}) {
|
|
|
2278
2425
|
[RUNTIME_HEADER_NAMES.backendId]: claim.backendId,
|
|
2279
2426
|
[RUNTIME_HEADER_NAMES.routeId]: claim.routeId,
|
|
2280
2427
|
[RUNTIME_HEADER_NAMES.policyVersion]: claim.policyVersion,
|
|
2281
|
-
[RUNTIME_HEADER_NAMES.bodyHash]: claim.bodyHash
|
|
2428
|
+
[RUNTIME_HEADER_NAMES.bodyHash]: claim.bodyHash,
|
|
2429
|
+
...spec.contextToken ? { "x-fs-context": spec.contextToken } : {}
|
|
2282
2430
|
};
|
|
2283
2431
|
return {
|
|
2284
2432
|
input: { method, path, query, body, streamingExempt },
|
|
@@ -2296,7 +2444,7 @@ function base64urlEncodeJson(value) {
|
|
|
2296
2444
|
}
|
|
2297
2445
|
async function signContextToken(claim, secret = TEST_CONTEXT_SECRET, kid = TEST_CONTEXT_KID) {
|
|
2298
2446
|
const header = base64urlEncodeJson({ alg: "HS256", typ: "JWT", kid });
|
|
2299
|
-
const payload = base64urlEncodeJson({
|
|
2447
|
+
const payload = base64urlEncodeJson({ ...claim });
|
|
2300
2448
|
const signingInput = `${header}.${payload}`;
|
|
2301
2449
|
const key2 = await crypto.subtle.importKey(
|
|
2302
2450
|
"raw",
|
|
@@ -2376,12 +2524,15 @@ function mergeHeaders(initHeaders, signedHeaders) {
|
|
|
2376
2524
|
return headers;
|
|
2377
2525
|
}
|
|
2378
2526
|
function buildContextClaim(persona, businessId) {
|
|
2527
|
+
const memberId = persona.actor?.id ?? `user_${persona.name}`;
|
|
2379
2528
|
return {
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
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,
|
|
2385
2536
|
compiledPlanId: persona.compiledPlanId ?? "plan_dev",
|
|
2386
2537
|
subscriptionId: persona.subscriptionId ?? "sub_dev",
|
|
2387
2538
|
subscriberId: persona.subscriberId ?? "subscriber_dev",
|
|
@@ -2403,6 +2554,11 @@ function createPersonaClient(ctx) {
|
|
|
2403
2554
|
}
|
|
2404
2555
|
async function buildHeaders(persona, spec) {
|
|
2405
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
|
+
);
|
|
2406
2562
|
const signed = await makeSignedRequest({
|
|
2407
2563
|
method,
|
|
2408
2564
|
path: spec.path ?? "/",
|
|
@@ -2414,19 +2570,11 @@ function createPersonaClient(ctx) {
|
|
|
2414
2570
|
routeId: spec.routeId ?? "",
|
|
2415
2571
|
privateJwk: ctx.keys.privateJwk,
|
|
2416
2572
|
kid: ctx.keys.kid,
|
|
2573
|
+
...contextToken ? { contextToken } : {},
|
|
2417
2574
|
...spec.requestId ? { requestId: spec.requestId } : {},
|
|
2418
2575
|
...spec.timestamp !== void 0 ? { timestamp: spec.timestamp } : {}
|
|
2419
2576
|
});
|
|
2420
|
-
|
|
2421
|
-
if (!persona.anonymous) {
|
|
2422
|
-
const claim = buildContextClaim(persona, ctx.businessId);
|
|
2423
|
-
headers["x-fs-context"] = await signContextToken(
|
|
2424
|
-
claim,
|
|
2425
|
-
ctx.contextSecret,
|
|
2426
|
-
ctx.contextKid
|
|
2427
|
-
);
|
|
2428
|
-
}
|
|
2429
|
-
return headers;
|
|
2577
|
+
return { ...signed.headers };
|
|
2430
2578
|
}
|
|
2431
2579
|
function asPersona(name) {
|
|
2432
2580
|
const persona = resolve(name);
|
|
@@ -2810,8 +2958,9 @@ function createDevRuntime(options) {
|
|
|
2810
2958
|
runtimeToken: keys.runtimeToken,
|
|
2811
2959
|
coreUrl: DEV_CORE_URL,
|
|
2812
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.
|
|
2813
2963
|
contextSecrets: [keys.contextSecret],
|
|
2814
|
-
contextVerification: mode === "simulated" ? "required" : "preferred",
|
|
2815
2964
|
env: {}
|
|
2816
2965
|
});
|
|
2817
2966
|
const tracedAuthz = {
|
|
@@ -2837,7 +2986,8 @@ function createDevRuntime(options) {
|
|
|
2837
2986
|
});
|
|
2838
2987
|
}
|
|
2839
2988
|
function middleware(mwOptions) {
|
|
2840
|
-
const
|
|
2989
|
+
const resolved = mode === "passthrough" ? { always: false, ...mwOptions } : mwOptions ?? {};
|
|
2990
|
+
const inner = createExpressMiddleware(fs, resolved);
|
|
2841
2991
|
return (req, res, next) => {
|
|
2842
2992
|
const requestId = headerValue2(req.headers, "x-fs-request-id") ?? "unknown";
|
|
2843
2993
|
const { path } = splitUrl2(req);
|
|
@@ -2876,6 +3026,7 @@ function createDevRuntime(options) {
|
|
|
2876
3026
|
};
|
|
2877
3027
|
}
|
|
2878
3028
|
fs.middleware = middleware;
|
|
3029
|
+
fs.handler = createExpressHandler;
|
|
2879
3030
|
const devRuntime = {
|
|
2880
3031
|
fs,
|
|
2881
3032
|
asPersona: (name) => personaClient.asPersona(name),
|
|
@@ -3003,6 +3154,7 @@ var fartherShore = {
|
|
|
3003
3154
|
}
|
|
3004
3155
|
const fs = initFromEnv(options);
|
|
3005
3156
|
fs.middleware = (mwOptions) => createExpressMiddleware(fs, mwOptions);
|
|
3157
|
+
fs.handler = createExpressHandler;
|
|
3006
3158
|
return fs;
|
|
3007
3159
|
}
|
|
3008
3160
|
};
|
|
@@ -3018,7 +3170,6 @@ export {
|
|
|
3018
3170
|
FartherShore,
|
|
3019
3171
|
FartherShoreError,
|
|
3020
3172
|
FartherShorePermissionError,
|
|
3021
|
-
IDENTITY_HEADER_NAMES,
|
|
3022
3173
|
JwksClient,
|
|
3023
3174
|
MAX_BODY_BYTES,
|
|
3024
3175
|
METERING_PAYLOAD_HEADER,
|
|
@@ -3042,18 +3193,22 @@ export {
|
|
|
3042
3193
|
buildHealthReport,
|
|
3043
3194
|
canonicalizeQuery2 as canonicalizeQuery,
|
|
3044
3195
|
computeMeteringHeaders,
|
|
3196
|
+
createExpressHandler,
|
|
3045
3197
|
createExpressMiddleware,
|
|
3046
3198
|
createUsage,
|
|
3199
|
+
decodeContextClaims,
|
|
3047
3200
|
fartherShore,
|
|
3048
3201
|
hasPermission,
|
|
3049
3202
|
hashBody2 as hashBody,
|
|
3050
3203
|
initFromEnv2 as initFromEnv,
|
|
3051
3204
|
nodeSpawn,
|
|
3052
|
-
parsePermissionHeader,
|
|
3053
3205
|
permissionGrants,
|
|
3054
3206
|
permissionSatisfies,
|
|
3207
|
+
principalFromContextClaims2 as principalFromContextClaims,
|
|
3055
3208
|
reportHealth,
|
|
3209
|
+
requireMember,
|
|
3056
3210
|
requirePermission,
|
|
3211
|
+
requireService,
|
|
3057
3212
|
runtimeErrorToErrorCode,
|
|
3058
3213
|
runtimeTokenKind2 as runtimeTokenKind,
|
|
3059
3214
|
signCanonicalString2 as signCanonicalString,
|