@farthershore/backend 0.15.0 → 0.17.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 +83 -0
- package/README.md +1 -1
- package/dist/adapters/express.js +66 -2
- package/dist/generated/runtime-contract.js +11 -5
- package/dist/index.js +325 -154
- package/dist/testing/index.js +272 -142
- package/dist/types/adapters/express.d.ts +55 -5
- package/dist/types/core/errors.d.ts +4 -1
- 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 -31
- package/dist/types/core/subject.d.ts +45 -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 +7 -3
- package/dist/types/index.d.ts +17 -8
- 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 +12 -11
- 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/testing/index.js
CHANGED
|
@@ -218,7 +218,10 @@ var RUNTIME_ERROR_CODES = {
|
|
|
218
218
|
environmentMismatch: "environment_mismatch",
|
|
219
219
|
missingToken: "missing_token",
|
|
220
220
|
invalidToken: "invalid_token",
|
|
221
|
-
contextUnverified: "context_unverified"
|
|
221
|
+
contextUnverified: "context_unverified",
|
|
222
|
+
memberSubjectRequired: "member_subject_required",
|
|
223
|
+
serviceSubjectRequired: "service_subject_required",
|
|
224
|
+
surfaceNotAllowed: "surface_not_allowed"
|
|
222
225
|
};
|
|
223
226
|
var RUNTIME_RESPONSE_METERING_CONTRACT = {
|
|
224
227
|
headers: {
|
|
@@ -282,7 +285,12 @@ var RUNTIME_ERROR_CODE_TO_ERROR_CODE = {
|
|
|
282
285
|
// the canonical code keeps the "dependency down" semantic for callers.
|
|
283
286
|
[RUNTIME_ERROR_CODES.jwksUnavailable]: "SERVICE_UNAVAILABLE",
|
|
284
287
|
// The single non-401 (413) — oversized request body.
|
|
285
|
-
[RUNTIME_ERROR_CODES.bodyTooLarge]: "VALIDATION_ERROR"
|
|
288
|
+
[RUNTIME_ERROR_CODES.bodyTooLarge]: "VALIDATION_ERROR",
|
|
289
|
+
// Consumer-principal wave — route subject-requirement faults → FORBIDDEN (403).
|
|
290
|
+
[RUNTIME_ERROR_CODES.memberSubjectRequired]: "FORBIDDEN",
|
|
291
|
+
[RUNTIME_ERROR_CODES.serviceSubjectRequired]: "FORBIDDEN",
|
|
292
|
+
// A visible route whose surface set excludes the caller → 403.
|
|
293
|
+
[RUNTIME_ERROR_CODES.surfaceNotAllowed]: "FORBIDDEN"
|
|
286
294
|
};
|
|
287
295
|
var FS_RUNTIME_TOKEN_ENV = "FS_RUNTIME_TOKEN";
|
|
288
296
|
var RUNTIME_TOKEN_PREFIXES = {
|
|
@@ -300,10 +308,6 @@ var RUNTIME_HEADER_NAMES = {
|
|
|
300
308
|
policyVersion: "x-fs-policy-version",
|
|
301
309
|
bodyHash: "x-fs-body-hash"
|
|
302
310
|
};
|
|
303
|
-
var RUNTIME_IDENTITY_HEADER_NAMES = {
|
|
304
|
-
permissions: "x-fs-permissions",
|
|
305
|
-
roles: "x-fs-roles"
|
|
306
|
-
};
|
|
307
311
|
var RUNTIME_CLOCK_SKEW_SECONDS = 5;
|
|
308
312
|
var RUNTIME_REPLAY_WINDOW_SECONDS = 300;
|
|
309
313
|
var EMPTY_BODY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
|
@@ -349,31 +353,16 @@ var CANONICAL_SIGNING_FIELDS = [
|
|
|
349
353
|
"business-id",
|
|
350
354
|
"backend-id",
|
|
351
355
|
"route-id",
|
|
352
|
-
"policy-version"
|
|
356
|
+
"policy-version",
|
|
357
|
+
// Consumer-principal wave (D3) — the trailing identity-context binding line.
|
|
358
|
+
// MUST stay last so pre-binding verifiers that stop at `policy-version`
|
|
359
|
+
// fail loud on a bound request rather than silently accepting a prefix.
|
|
360
|
+
"context-hash"
|
|
353
361
|
];
|
|
354
362
|
var CANONICAL_FIELD_SEPARATOR = "\n";
|
|
355
363
|
var CANONICAL_KV_SEPARATOR = ":";
|
|
356
364
|
function canonicalizeQuery(query) {
|
|
357
|
-
|
|
358
|
-
if (raw === "")
|
|
359
|
-
return "";
|
|
360
|
-
const pairs = raw.split("&").filter((p) => p.length > 0);
|
|
361
|
-
pairs.sort((a, b) => {
|
|
362
|
-
const [an, ...arest] = a.split("=");
|
|
363
|
-
const [bn, ...brest] = b.split("=");
|
|
364
|
-
if (an < bn)
|
|
365
|
-
return -1;
|
|
366
|
-
if (an > bn)
|
|
367
|
-
return 1;
|
|
368
|
-
const av = arest.join("=");
|
|
369
|
-
const bv = brest.join("=");
|
|
370
|
-
if (av < bv)
|
|
371
|
-
return -1;
|
|
372
|
-
if (av > bv)
|
|
373
|
-
return 1;
|
|
374
|
-
return 0;
|
|
375
|
-
});
|
|
376
|
-
return pairs.join("&");
|
|
365
|
+
return query;
|
|
377
366
|
}
|
|
378
367
|
function buildCanonicalSigningString(input) {
|
|
379
368
|
const values = {
|
|
@@ -386,10 +375,14 @@ function buildCanonicalSigningString(input) {
|
|
|
386
375
|
"business-id": input.businessId,
|
|
387
376
|
"backend-id": input.backendId,
|
|
388
377
|
"route-id": input.routeId,
|
|
389
|
-
"policy-version": input.policyVersion
|
|
378
|
+
"policy-version": input.policyVersion,
|
|
379
|
+
"context-hash": input.contextHash
|
|
390
380
|
};
|
|
391
381
|
return CANONICAL_SIGNING_FIELDS.map((field) => `${field}${CANONICAL_KV_SEPARATOR}${values[field]}`).join(CANONICAL_FIELD_SEPARATOR);
|
|
392
382
|
}
|
|
383
|
+
function hashContextToken(token) {
|
|
384
|
+
return hashBody(new TextEncoder().encode(token ?? ""));
|
|
385
|
+
}
|
|
393
386
|
var ED25519_ALGORITHM = "Ed25519";
|
|
394
387
|
async function importEd25519PrivateKey(jwk) {
|
|
395
388
|
return crypto.subtle.importKey("jwk", { ...jwk, alg: void 0 }, { name: ED25519_ALGORITHM }, false, ["sign"]);
|
|
@@ -431,6 +424,7 @@ function base64UrlDecode(value) {
|
|
|
431
424
|
// src/runtime-signing.ts
|
|
432
425
|
var hashBody2 = hashBody;
|
|
433
426
|
var buildCanonicalSigningString2 = buildCanonicalSigningString;
|
|
427
|
+
var hashContextToken2 = hashContextToken;
|
|
434
428
|
var signCanonicalString2 = signCanonicalString;
|
|
435
429
|
var verifyCanonicalSignature2 = verifyCanonicalSignature;
|
|
436
430
|
var runtimeTokenKind2 = runtimeTokenKind;
|
|
@@ -453,7 +447,9 @@ var FartherShoreError = class extends Error {
|
|
|
453
447
|
}
|
|
454
448
|
};
|
|
455
449
|
function statusForCode(code) {
|
|
456
|
-
|
|
450
|
+
if (code === "body_too_large") return 413;
|
|
451
|
+
if (code === "surface_not_allowed") return 403;
|
|
452
|
+
return 401;
|
|
457
453
|
}
|
|
458
454
|
|
|
459
455
|
// src/core/jwks.ts
|
|
@@ -611,7 +607,10 @@ async function makeSignedRequest(spec = {}) {
|
|
|
611
607
|
businessId: spec.businessId ?? "biz_test",
|
|
612
608
|
backendId: spec.backendId ?? "be_test",
|
|
613
609
|
routeId: spec.routeId ?? "route_test",
|
|
614
|
-
policyVersion: spec.policyVersion ?? "pv_1"
|
|
610
|
+
policyVersion: spec.policyVersion ?? "pv_1",
|
|
611
|
+
// Consumer-principal wave (D3): bind the X-Fs-Context hash into the
|
|
612
|
+
// canonical string (empty context → SHA-256 of the empty string).
|
|
613
|
+
contextHash: await hashContextToken2(spec.contextToken)
|
|
615
614
|
};
|
|
616
615
|
const canonical = buildCanonicalSigningString2(claim);
|
|
617
616
|
const signature = await signCanonicalString2(canonical, privateJwk);
|
|
@@ -624,7 +623,8 @@ async function makeSignedRequest(spec = {}) {
|
|
|
624
623
|
[RUNTIME_HEADER_NAMES.backendId]: claim.backendId,
|
|
625
624
|
[RUNTIME_HEADER_NAMES.routeId]: claim.routeId,
|
|
626
625
|
[RUNTIME_HEADER_NAMES.policyVersion]: claim.policyVersion,
|
|
627
|
-
[RUNTIME_HEADER_NAMES.bodyHash]: claim.bodyHash
|
|
626
|
+
[RUNTIME_HEADER_NAMES.bodyHash]: claim.bodyHash,
|
|
627
|
+
...spec.contextToken ? { "x-fs-context": spec.contextToken } : {}
|
|
628
628
|
};
|
|
629
629
|
return {
|
|
630
630
|
input: { method, path, query, body, streamingExempt },
|
|
@@ -663,7 +663,7 @@ function base64urlEncodeJson(value) {
|
|
|
663
663
|
}
|
|
664
664
|
async function signContextToken(claim, secret = TEST_CONTEXT_SECRET, kid = TEST_CONTEXT_KID) {
|
|
665
665
|
const header = base64urlEncodeJson({ alg: "HS256", typ: "JWT", kid });
|
|
666
|
-
const payload = base64urlEncodeJson({
|
|
666
|
+
const payload = base64urlEncodeJson({ ...claim });
|
|
667
667
|
const signingInput = `${header}.${payload}`;
|
|
668
668
|
const key2 = await crypto.subtle.importKey(
|
|
669
669
|
"raw",
|
|
@@ -743,12 +743,15 @@ function mergeHeaders(initHeaders, signedHeaders) {
|
|
|
743
743
|
return headers;
|
|
744
744
|
}
|
|
745
745
|
function buildContextClaim(persona, businessId) {
|
|
746
|
+
const memberId = persona.actor?.id ?? `user_${persona.name}`;
|
|
746
747
|
return {
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
748
|
+
cv: 2,
|
|
749
|
+
sub: memberId,
|
|
750
|
+
subjectKind: "member",
|
|
751
|
+
org: persona.orgId ?? "org_dev",
|
|
752
|
+
// Business binding: the signed-context businessId claim MUST equal the
|
|
753
|
+
// signed request businessId or verifyRequest rejects it as tamper evidence.
|
|
754
|
+
businessId,
|
|
752
755
|
compiledPlanId: persona.compiledPlanId ?? "plan_dev",
|
|
753
756
|
subscriptionId: persona.subscriptionId ?? "sub_dev",
|
|
754
757
|
subscriberId: persona.subscriberId ?? "subscriber_dev",
|
|
@@ -770,6 +773,11 @@ function createPersonaClient(ctx) {
|
|
|
770
773
|
}
|
|
771
774
|
async function buildHeaders(persona, spec) {
|
|
772
775
|
const method = normalizeMethod(spec.method);
|
|
776
|
+
const contextToken = persona.anonymous ? void 0 : await signContextToken(
|
|
777
|
+
buildContextClaim(persona, ctx.businessId),
|
|
778
|
+
ctx.contextSecret,
|
|
779
|
+
ctx.contextKid
|
|
780
|
+
);
|
|
773
781
|
const signed = await makeSignedRequest({
|
|
774
782
|
method,
|
|
775
783
|
path: spec.path ?? "/",
|
|
@@ -781,19 +789,11 @@ function createPersonaClient(ctx) {
|
|
|
781
789
|
routeId: spec.routeId ?? "",
|
|
782
790
|
privateJwk: ctx.keys.privateJwk,
|
|
783
791
|
kid: ctx.keys.kid,
|
|
792
|
+
...contextToken ? { contextToken } : {},
|
|
784
793
|
...spec.requestId ? { requestId: spec.requestId } : {},
|
|
785
794
|
...spec.timestamp !== void 0 ? { timestamp: spec.timestamp } : {}
|
|
786
795
|
});
|
|
787
|
-
|
|
788
|
-
if (!persona.anonymous) {
|
|
789
|
-
const claim = buildContextClaim(persona, ctx.businessId);
|
|
790
|
-
headers["x-fs-context"] = await signContextToken(
|
|
791
|
-
claim,
|
|
792
|
-
ctx.contextSecret,
|
|
793
|
-
ctx.contextKid
|
|
794
|
-
);
|
|
795
|
-
}
|
|
796
|
-
return headers;
|
|
796
|
+
return { ...signed.headers };
|
|
797
797
|
}
|
|
798
798
|
function asPersona(name) {
|
|
799
799
|
const persona = resolve(name);
|
|
@@ -873,7 +873,7 @@ function createDevGateway(options) {
|
|
|
873
873
|
name: "Dev Backend"
|
|
874
874
|
},
|
|
875
875
|
environment: { id: null, kind: "test" },
|
|
876
|
-
|
|
876
|
+
operations: ["gateway_verification", "metering", "health"],
|
|
877
877
|
verification: {
|
|
878
878
|
required: options.mode === "simulated",
|
|
879
879
|
jwksUrl: DEV_JWKS_URL,
|
|
@@ -1422,8 +1422,8 @@ function resolveEndpoint2(endpoint, coreUrl) {
|
|
|
1422
1422
|
}
|
|
1423
1423
|
|
|
1424
1424
|
// src/core/nonceCache.ts
|
|
1425
|
-
var DEFAULT_MAX_ENTRIES =
|
|
1426
|
-
var DEFAULT_TTL_MS =
|
|
1425
|
+
var DEFAULT_MAX_ENTRIES = 25e4;
|
|
1426
|
+
var DEFAULT_TTL_MS = (RUNTIME_REPLAY_WINDOW_SECONDS + RUNTIME_CLOCK_SKEW_SECONDS) * 1e3;
|
|
1427
1427
|
var NonceCache = class {
|
|
1428
1428
|
maxEntries;
|
|
1429
1429
|
ttlMs;
|
|
@@ -1447,7 +1447,7 @@ var NonceCache = class {
|
|
|
1447
1447
|
this.seen.delete(id);
|
|
1448
1448
|
}
|
|
1449
1449
|
this.evictExpired(at);
|
|
1450
|
-
this.
|
|
1450
|
+
if (this.seen.size >= this.maxEntries) return true;
|
|
1451
1451
|
this.seen.set(id, at);
|
|
1452
1452
|
return false;
|
|
1453
1453
|
}
|
|
@@ -1461,13 +1461,6 @@ var NonceCache = class {
|
|
|
1461
1461
|
this.seen.delete(id);
|
|
1462
1462
|
}
|
|
1463
1463
|
}
|
|
1464
|
-
evictOverflow() {
|
|
1465
|
-
while (this.seen.size >= this.maxEntries) {
|
|
1466
|
-
const oldest = this.seen.keys().next().value;
|
|
1467
|
-
if (oldest === void 0) break;
|
|
1468
|
-
this.seen.delete(oldest);
|
|
1469
|
-
}
|
|
1470
|
-
}
|
|
1471
1464
|
};
|
|
1472
1465
|
|
|
1473
1466
|
// src/core/shutdown.ts
|
|
@@ -1802,47 +1795,82 @@ function resolvePackageBinary(require2, pkg, manifestPath) {
|
|
|
1802
1795
|
return `${root}${sep}${normalized}`;
|
|
1803
1796
|
}
|
|
1804
1797
|
|
|
1805
|
-
//
|
|
1806
|
-
var
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
status = 403;
|
|
1810
|
-
/** The permission key that was required but not held. */
|
|
1811
|
-
requiredPermission;
|
|
1812
|
-
constructor(requiredPermission, message) {
|
|
1813
|
-
super(message ?? `missing required permission: ${requiredPermission}`);
|
|
1814
|
-
this.name = "FartherShorePermissionError";
|
|
1815
|
-
this.requiredPermission = requiredPermission;
|
|
1816
|
-
}
|
|
1817
|
-
};
|
|
1818
|
-
function parsePermissionHeader(raw) {
|
|
1819
|
-
if (raw === null || raw === void 0) return void 0;
|
|
1820
|
-
return raw.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
|
|
1798
|
+
// ../contracts/dist/authz/principal.js
|
|
1799
|
+
var SUBJECT_KINDS = ["member", "service"];
|
|
1800
|
+
function isSubjectKind(value) {
|
|
1801
|
+
return typeof value === "string" && SUBJECT_KINDS.includes(value);
|
|
1821
1802
|
}
|
|
1822
|
-
function
|
|
1823
|
-
|
|
1824
|
-
if (granted.includes(WILDCARD)) return true;
|
|
1825
|
-
if (granted.includes(required)) return true;
|
|
1826
|
-
const idx = required.indexOf(":");
|
|
1827
|
-
if (idx > 0 && idx < required.length - 1) {
|
|
1828
|
-
const subject = required.slice(0, idx);
|
|
1829
|
-
if (granted.includes(`${subject}:${WILDCARD}`)) return true;
|
|
1830
|
-
}
|
|
1831
|
-
return false;
|
|
1803
|
+
function isNonEmptyTrimmed(value) {
|
|
1804
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
1832
1805
|
}
|
|
1833
|
-
function
|
|
1834
|
-
|
|
1835
|
-
return ctx.signedContext !== void 0;
|
|
1836
|
-
}
|
|
1837
|
-
return permissionSatisfies(key2, ctx.permissions);
|
|
1806
|
+
function normalizeIdentityId(value) {
|
|
1807
|
+
return value.trim();
|
|
1838
1808
|
}
|
|
1839
|
-
function
|
|
1840
|
-
|
|
1841
|
-
|
|
1809
|
+
function isNonEmptyStringArray(value) {
|
|
1810
|
+
return Array.isArray(value) && value.every((item) => isNonEmptyTrimmed(item));
|
|
1811
|
+
}
|
|
1812
|
+
function principalFromContextClaims(claims) {
|
|
1813
|
+
if (typeof claims !== "object" || claims === null)
|
|
1814
|
+
return null;
|
|
1815
|
+
const { sub, org: orgId, businessId, subjectKind } = claims;
|
|
1816
|
+
if (!isNonEmptyTrimmed(sub) || !isNonEmptyTrimmed(orgId) || !isNonEmptyTrimmed(businessId)) {
|
|
1817
|
+
return null;
|
|
1842
1818
|
}
|
|
1819
|
+
if (!isSubjectKind(subjectKind))
|
|
1820
|
+
return null;
|
|
1821
|
+
if (claims.permissions !== void 0 && !isNonEmptyStringArray(claims.permissions)) {
|
|
1822
|
+
return null;
|
|
1823
|
+
}
|
|
1824
|
+
if (claims.roles !== void 0 && !isNonEmptyStringArray(claims.roles)) {
|
|
1825
|
+
return null;
|
|
1826
|
+
}
|
|
1827
|
+
const clientId = claims.client_id;
|
|
1828
|
+
const actSub = claims.act?.sub;
|
|
1829
|
+
if (isNonEmptyTrimmed(clientId) && isNonEmptyTrimmed(actSub) && normalizeIdentityId(clientId) !== normalizeIdentityId(actSub)) {
|
|
1830
|
+
return null;
|
|
1831
|
+
}
|
|
1832
|
+
const org = { id: normalizeIdentityId(orgId) };
|
|
1833
|
+
if (subjectKind === "service") {
|
|
1834
|
+
const explicitServiceAccountId = claims.serviceAccountId;
|
|
1835
|
+
if (isNonEmptyTrimmed(explicitServiceAccountId) && normalizeIdentityId(explicitServiceAccountId) !== normalizeIdentityId(sub)) {
|
|
1836
|
+
return null;
|
|
1837
|
+
}
|
|
1838
|
+
const serviceAccountId = normalizeIdentityId(explicitServiceAccountId ?? sub);
|
|
1839
|
+
const keyIdSource = clientId ?? actSub ?? sub;
|
|
1840
|
+
if (!isNonEmptyTrimmed(serviceAccountId) || !isNonEmptyTrimmed(keyIdSource)) {
|
|
1841
|
+
return null;
|
|
1842
|
+
}
|
|
1843
|
+
return {
|
|
1844
|
+
org,
|
|
1845
|
+
subject: {
|
|
1846
|
+
kind: "service",
|
|
1847
|
+
serviceAccountId,
|
|
1848
|
+
keyId: normalizeIdentityId(keyIdSource)
|
|
1849
|
+
}
|
|
1850
|
+
};
|
|
1851
|
+
}
|
|
1852
|
+
const memberId = normalizeIdentityId(sub);
|
|
1853
|
+
const act = claims.act;
|
|
1854
|
+
if (act !== void 0 && act !== null) {
|
|
1855
|
+
if (typeof act !== "object" || !isNonEmptyTrimmed(act.sub))
|
|
1856
|
+
return null;
|
|
1857
|
+
return {
|
|
1858
|
+
org,
|
|
1859
|
+
subject: {
|
|
1860
|
+
kind: "member",
|
|
1861
|
+
memberId,
|
|
1862
|
+
via: "api_key",
|
|
1863
|
+
keyId: normalizeIdentityId(act.sub)
|
|
1864
|
+
}
|
|
1865
|
+
};
|
|
1866
|
+
}
|
|
1867
|
+
return { org, subject: { kind: "member", memberId, via: "session" } };
|
|
1843
1868
|
}
|
|
1844
1869
|
|
|
1845
1870
|
// src/core/verifyContext.ts
|
|
1871
|
+
function principalFromContextClaims2(claims) {
|
|
1872
|
+
return principalFromContextClaims(claims);
|
|
1873
|
+
}
|
|
1846
1874
|
var EXPECTED_JWT_ALG = "HS256";
|
|
1847
1875
|
function base64urlDecode(value) {
|
|
1848
1876
|
const padded = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
@@ -1882,6 +1910,7 @@ async function verifyContext(token, secrets) {
|
|
|
1882
1910
|
}
|
|
1883
1911
|
let verified = false;
|
|
1884
1912
|
for (const secret of secrets) {
|
|
1913
|
+
if (secret.trim().length === 0) continue;
|
|
1885
1914
|
try {
|
|
1886
1915
|
const key2 = await importHmacKey(secret);
|
|
1887
1916
|
if (await crypto.subtle.verify(
|
|
@@ -1897,11 +1926,20 @@ async function verifyContext(token, secrets) {
|
|
|
1897
1926
|
}
|
|
1898
1927
|
}
|
|
1899
1928
|
if (!verified) return null;
|
|
1929
|
+
return parseContextPayload(payload);
|
|
1930
|
+
}
|
|
1931
|
+
function decodeContextClaims(token) {
|
|
1932
|
+
const parts = token.split(".");
|
|
1933
|
+
if (parts.length !== 3) return null;
|
|
1934
|
+
return parseContextPayload(parts[1]);
|
|
1935
|
+
}
|
|
1936
|
+
function parseContextPayload(payload) {
|
|
1900
1937
|
try {
|
|
1901
1938
|
const parsed = JSON.parse(
|
|
1902
1939
|
new TextDecoder().decode(base64urlDecode(payload))
|
|
1903
1940
|
);
|
|
1904
1941
|
if (typeof parsed !== "object" || parsed === null) return null;
|
|
1942
|
+
if (parsed.cv !== 2) return null;
|
|
1905
1943
|
return parsed;
|
|
1906
1944
|
} catch {
|
|
1907
1945
|
return null;
|
|
@@ -1910,7 +1948,7 @@ async function verifyContext(token, secrets) {
|
|
|
1910
1948
|
function contextRequiredError(reason) {
|
|
1911
1949
|
return new FartherShoreError(
|
|
1912
1950
|
"context_unverified",
|
|
1913
|
-
`X-Fs-Context ${reason}
|
|
1951
|
+
`X-Fs-Context ${reason} \u2014 signed context is required whenever context secrets are configured`
|
|
1914
1952
|
);
|
|
1915
1953
|
}
|
|
1916
1954
|
|
|
@@ -1986,6 +2024,7 @@ async function verifyRequest(input, deps) {
|
|
|
1986
2024
|
"signed route-id is not served by this backend"
|
|
1987
2025
|
);
|
|
1988
2026
|
}
|
|
2027
|
+
const contextToken = h("x-fs-context") ?? null;
|
|
1989
2028
|
const canonicalInput = {
|
|
1990
2029
|
method: input.method,
|
|
1991
2030
|
path: input.path,
|
|
@@ -1996,7 +2035,13 @@ async function verifyRequest(input, deps) {
|
|
|
1996
2035
|
businessId: signedBusinessId,
|
|
1997
2036
|
backendId: signedBackendId,
|
|
1998
2037
|
routeId: signedRouteId,
|
|
1999
|
-
policyVersion
|
|
2038
|
+
policyVersion,
|
|
2039
|
+
// Consumer-principal wave (D3): bind the presented X-Fs-Context hash into
|
|
2040
|
+
// the canonical string in exact lockstep with the gateway signer. A missing
|
|
2041
|
+
// context hashes the empty string, so an identity-less request still
|
|
2042
|
+
// verifies (it is rejected later by the fail-closed context gate when
|
|
2043
|
+
// secrets exist).
|
|
2044
|
+
contextHash: await hashContextToken2(contextToken)
|
|
2000
2045
|
};
|
|
2001
2046
|
const canonical = buildCanonicalSigningString2(canonicalInput);
|
|
2002
2047
|
const publicJwk = await deps.jwks.getKey(kid);
|
|
@@ -2015,44 +2060,28 @@ async function verifyRequest(input, deps) {
|
|
|
2015
2060
|
"Ed25519 signature verification failed"
|
|
2016
2061
|
);
|
|
2017
2062
|
}
|
|
2018
|
-
if (deps.nonceCache.checkAndRemember(requestId)) {
|
|
2063
|
+
if (await deps.nonceCache.checkAndRemember(requestId)) {
|
|
2019
2064
|
throw new FartherShoreError(
|
|
2020
2065
|
"replayed_nonce",
|
|
2021
2066
|
"x-fs-request-id has already been seen (replay)"
|
|
2022
2067
|
);
|
|
2023
2068
|
}
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
signedContext = await verifyContext(token, contextSecrets);
|
|
2033
|
-
if (signedContext === null && deps.contextVerification === "required") {
|
|
2034
|
-
throw contextRequiredError("failed verification");
|
|
2035
|
-
}
|
|
2036
|
-
if (signedContext && signedContext.productId !== signedBusinessId) {
|
|
2037
|
-
throw new FartherShoreError(
|
|
2038
|
-
"context_unverified",
|
|
2039
|
-
"X-Fs-Context was minted for a different business than the signed request"
|
|
2040
|
-
);
|
|
2041
|
-
}
|
|
2042
|
-
} else if (deps.contextVerification === "required") {
|
|
2043
|
-
throw contextRequiredError("header is missing");
|
|
2044
|
-
}
|
|
2045
|
-
} else if (deps.contextVerification === "required") {
|
|
2046
|
-
throw contextRequiredError("keyring is empty");
|
|
2047
|
-
}
|
|
2069
|
+
const signedContext = await resolveSignedContext(
|
|
2070
|
+
contextToken,
|
|
2071
|
+
deps.contextSecrets ?? [],
|
|
2072
|
+
signedBusinessId
|
|
2073
|
+
);
|
|
2074
|
+
const permissions = signedContext?.permissions;
|
|
2075
|
+
const roles = signedContext?.roles;
|
|
2076
|
+
let principal;
|
|
2048
2077
|
if (signedContext) {
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2078
|
+
const derived = principalFromContextClaims2(signedContext);
|
|
2079
|
+
if (derived === null) {
|
|
2080
|
+
throw contextRequiredError(
|
|
2081
|
+
"carried an invalid or incomplete consumer principal"
|
|
2082
|
+
);
|
|
2083
|
+
}
|
|
2084
|
+
principal = derived;
|
|
2056
2085
|
}
|
|
2057
2086
|
return {
|
|
2058
2087
|
requestId,
|
|
@@ -2062,11 +2091,32 @@ async function verifyRequest(input, deps) {
|
|
|
2062
2091
|
policyVersion,
|
|
2063
2092
|
timestamp,
|
|
2064
2093
|
bodyHash: computedBodyHash,
|
|
2094
|
+
...principal ? { principal } : {},
|
|
2065
2095
|
...permissions !== void 0 ? { permissions } : {},
|
|
2066
2096
|
...roles !== void 0 ? { roles } : {},
|
|
2067
2097
|
...signedContext ? { signedContext } : {}
|
|
2068
2098
|
};
|
|
2069
2099
|
}
|
|
2100
|
+
async function resolveSignedContext(contextToken, contextSecrets, signedBusinessId) {
|
|
2101
|
+
if (!contextToken) return null;
|
|
2102
|
+
const signedContext = decodeContextClaims(contextToken);
|
|
2103
|
+
if (signedContext === null) {
|
|
2104
|
+
throw contextRequiredError("payload could not be parsed as a cv=2 claim");
|
|
2105
|
+
}
|
|
2106
|
+
if (contextSecrets.length > 0) {
|
|
2107
|
+
const hmacVerified = await verifyContext(contextToken, contextSecrets);
|
|
2108
|
+
if (hmacVerified === null) {
|
|
2109
|
+
throw contextRequiredError("failed HS256 verification");
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
if (signedContext.businessId !== signedBusinessId) {
|
|
2113
|
+
throw new FartherShoreError(
|
|
2114
|
+
"context_unverified",
|
|
2115
|
+
"X-Fs-Context was minted for a different business than the signed request"
|
|
2116
|
+
);
|
|
2117
|
+
}
|
|
2118
|
+
return signedContext;
|
|
2119
|
+
}
|
|
2070
2120
|
async function computeBodyHash(input) {
|
|
2071
2121
|
if (input.streamingExempt) return STREAMING_EXEMPT_BODY_HASH;
|
|
2072
2122
|
const body = input.body;
|
|
@@ -2099,8 +2149,7 @@ function headerGetter(headers) {
|
|
|
2099
2149
|
|
|
2100
2150
|
// src/core/runtime.ts
|
|
2101
2151
|
var DEFAULT_CORE_URL = "https://core.farthershore.com";
|
|
2102
|
-
var SDK_VERSION = "0.
|
|
2103
|
-
var CONTRACTS_FP = "4b6a36b4cb1f0b68".length > 0 ? "4b6a36b4cb1f0b68" : "0000000000000000";
|
|
2152
|
+
var SDK_VERSION = "0.17.0".length > 0 ? "0.17.0" : "0.0.0-dev";
|
|
2104
2153
|
var FartherShore = class {
|
|
2105
2154
|
bootstrapClient;
|
|
2106
2155
|
fetchImpl;
|
|
@@ -2110,11 +2159,9 @@ var FartherShore = class {
|
|
|
2110
2159
|
coreUrl;
|
|
2111
2160
|
instanceId;
|
|
2112
2161
|
tunnelOptions;
|
|
2113
|
-
/**
|
|
2162
|
+
/** OPTIONAL HS256 secret(s) — defense-in-depth over the cv=2 X-Fs-Context. */
|
|
2114
2163
|
contextSecrets;
|
|
2115
|
-
|
|
2116
|
-
contextVerification;
|
|
2117
|
-
nonceCache = new NonceCache();
|
|
2164
|
+
nonceCache;
|
|
2118
2165
|
shutdownManager = new ShutdownManager();
|
|
2119
2166
|
jwks = null;
|
|
2120
2167
|
meteringClient = null;
|
|
@@ -2132,8 +2179,8 @@ var FartherShore = class {
|
|
|
2132
2179
|
this.meteringEnabledOverride = options.metering?.enabled ?? true;
|
|
2133
2180
|
this.tunnelOptions = options.tunnel ?? {};
|
|
2134
2181
|
this.instanceId = options.instanceId;
|
|
2182
|
+
this.nonceCache = options.nonceStore ?? new NonceCache();
|
|
2135
2183
|
this.contextSecrets = options.contextSecrets ?? parseContextSecrets(env.FS_CONTEXT_SECRETS);
|
|
2136
|
-
this.contextVerification = options.contextVerification ?? (env.FS_CONTEXT_VERIFICATION === "required" ? "required" : "preferred");
|
|
2137
2184
|
this.bootstrapClient = new BootstrapClient({
|
|
2138
2185
|
runtimeToken,
|
|
2139
2186
|
coreUrl,
|
|
@@ -2258,12 +2305,10 @@ var FartherShore = class {
|
|
|
2258
2305
|
knownRouteIds,
|
|
2259
2306
|
clockSkewSeconds: config.verification.clockSkewSeconds,
|
|
2260
2307
|
replayWindowSeconds: config.verification.replayWindowSeconds,
|
|
2261
|
-
//
|
|
2262
|
-
//
|
|
2263
|
-
//
|
|
2264
|
-
|
|
2265
|
-
contextSecrets: this.contextSecrets,
|
|
2266
|
-
contextVerification: this.contextVerification
|
|
2308
|
+
// Consumer-principal wave (D3): OPTIONAL defense-in-depth. The principal
|
|
2309
|
+
// is derived from the Ed25519-vouched X-Fs-Context regardless; when these
|
|
2310
|
+
// secrets are set a presented token must ALSO pass HS256.
|
|
2311
|
+
contextSecrets: this.contextSecrets
|
|
2267
2312
|
});
|
|
2268
2313
|
return {
|
|
2269
2314
|
...context,
|
|
@@ -2390,6 +2435,40 @@ function parseContextSecrets(raw) {
|
|
|
2390
2435
|
return raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
2391
2436
|
}
|
|
2392
2437
|
|
|
2438
|
+
// src/core/permissions.ts
|
|
2439
|
+
var WILDCARD = "*";
|
|
2440
|
+
var FartherShorePermissionError = class extends Error {
|
|
2441
|
+
code = "permission_denied";
|
|
2442
|
+
status = 403;
|
|
2443
|
+
/** The permission key that was required but not held. */
|
|
2444
|
+
requiredPermission;
|
|
2445
|
+
constructor(requiredPermission, message) {
|
|
2446
|
+
super(message ?? `missing required permission: ${requiredPermission}`);
|
|
2447
|
+
this.name = "FartherShorePermissionError";
|
|
2448
|
+
this.requiredPermission = requiredPermission;
|
|
2449
|
+
}
|
|
2450
|
+
};
|
|
2451
|
+
function permissionSatisfies(required, granted) {
|
|
2452
|
+
if (granted === void 0) return true;
|
|
2453
|
+
if (granted.includes(WILDCARD)) return true;
|
|
2454
|
+
if (granted.includes(required)) return true;
|
|
2455
|
+
const idx = required.indexOf(":");
|
|
2456
|
+
if (idx > 0 && idx < required.length - 1) {
|
|
2457
|
+
const subject = required.slice(0, idx);
|
|
2458
|
+
if (granted.includes(`${subject}:${WILDCARD}`)) return true;
|
|
2459
|
+
}
|
|
2460
|
+
return false;
|
|
2461
|
+
}
|
|
2462
|
+
function hasPermission(ctx, key2) {
|
|
2463
|
+
if (ctx.permissions === void 0) return false;
|
|
2464
|
+
return permissionSatisfies(key2, ctx.permissions);
|
|
2465
|
+
}
|
|
2466
|
+
function requirePermission(ctx, key2) {
|
|
2467
|
+
if (!hasPermission(ctx, key2)) {
|
|
2468
|
+
throw new FartherShorePermissionError(key2);
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2393
2472
|
// src/adapters/express.ts
|
|
2394
2473
|
var STREAMING_CONTENT_TYPES = new Set(
|
|
2395
2474
|
RUNTIME_BODY_HASH_CONTRACT.streamingExemptContentTypes
|
|
@@ -2401,7 +2480,9 @@ function createExpressMiddleware(fs, options = {}) {
|
|
|
2401
2480
|
}
|
|
2402
2481
|
async function runMiddleware(fs, options, req, res, next) {
|
|
2403
2482
|
try {
|
|
2404
|
-
|
|
2483
|
+
const strict = options.always ?? true;
|
|
2484
|
+
if (!strict && !await fs.verificationRequired()) {
|
|
2485
|
+
stripFartherShoreHeaders(req);
|
|
2405
2486
|
next();
|
|
2406
2487
|
return;
|
|
2407
2488
|
}
|
|
@@ -2418,6 +2499,7 @@ async function runMiddleware(fs, options, req, res, next) {
|
|
|
2418
2499
|
streamingExempt
|
|
2419
2500
|
});
|
|
2420
2501
|
req.fartherShore = ctx;
|
|
2502
|
+
stripFartherShoreHeaders(req);
|
|
2421
2503
|
next();
|
|
2422
2504
|
} catch (error) {
|
|
2423
2505
|
fail(res, error);
|
|
@@ -2430,6 +2512,51 @@ function fail(res, error) {
|
|
|
2430
2512
|
}
|
|
2431
2513
|
res.status(401).json({ error: "bad_signature" });
|
|
2432
2514
|
}
|
|
2515
|
+
function stripFartherShoreHeaders(req) {
|
|
2516
|
+
const headers = req.headers;
|
|
2517
|
+
for (const name of Object.keys(headers)) {
|
|
2518
|
+
if (name.toLowerCase().startsWith("x-fs-")) {
|
|
2519
|
+
delete headers[name];
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
const withRaw = req;
|
|
2523
|
+
const raw = withRaw.rawHeaders;
|
|
2524
|
+
if (Array.isArray(raw)) {
|
|
2525
|
+
const cleaned = [];
|
|
2526
|
+
for (let i = 0; i < raw.length; i += 2) {
|
|
2527
|
+
const key2 = raw[i];
|
|
2528
|
+
const value = raw[i + 1];
|
|
2529
|
+
if (typeof key2 !== "string" || value === void 0) continue;
|
|
2530
|
+
if (key2.toLowerCase().startsWith("x-fs-")) continue;
|
|
2531
|
+
cleaned.push(key2, value);
|
|
2532
|
+
}
|
|
2533
|
+
withRaw.rawHeaders = cleaned;
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
function createExpressHandler(handler) {
|
|
2537
|
+
return (req, res, next) => {
|
|
2538
|
+
const ctx = req.fartherShore;
|
|
2539
|
+
if (!ctx) {
|
|
2540
|
+
res.status(401).json({ error: "context_unverified" });
|
|
2541
|
+
return;
|
|
2542
|
+
}
|
|
2543
|
+
if (!ctx.principal) {
|
|
2544
|
+
res.status(401).json({ error: "principal_required" });
|
|
2545
|
+
return;
|
|
2546
|
+
}
|
|
2547
|
+
const verified = ctx;
|
|
2548
|
+
void Promise.resolve().then(
|
|
2549
|
+
() => handler(verified, req, res, next)
|
|
2550
|
+
).catch((error) => failHandler(res, next, error));
|
|
2551
|
+
};
|
|
2552
|
+
}
|
|
2553
|
+
function failHandler(res, next, error) {
|
|
2554
|
+
if (error instanceof FartherShoreError || error instanceof FartherShorePermissionError) {
|
|
2555
|
+
res.status(error.status).json({ error: error.code });
|
|
2556
|
+
return;
|
|
2557
|
+
}
|
|
2558
|
+
next(error);
|
|
2559
|
+
}
|
|
2433
2560
|
function splitUrl(req) {
|
|
2434
2561
|
const raw = req.originalUrl ?? req.url ?? req.path ?? "/";
|
|
2435
2562
|
const qIndex = raw.indexOf("?");
|
|
@@ -2692,8 +2819,9 @@ function createDevRuntime(options) {
|
|
|
2692
2819
|
runtimeToken: keys.runtimeToken,
|
|
2693
2820
|
coreUrl: DEV_CORE_URL,
|
|
2694
2821
|
fetchImpl: gateway.fetchImpl,
|
|
2822
|
+
// Consumer-principal wave (D3): signed cv=2 context is fail-closed whenever
|
|
2823
|
+
// contextSecrets are set — no per-mode verification toggle.
|
|
2695
2824
|
contextSecrets: [keys.contextSecret],
|
|
2696
|
-
contextVerification: mode === "simulated" ? "required" : "preferred",
|
|
2697
2825
|
env: {}
|
|
2698
2826
|
});
|
|
2699
2827
|
const tracedAuthz = {
|
|
@@ -2719,7 +2847,8 @@ function createDevRuntime(options) {
|
|
|
2719
2847
|
});
|
|
2720
2848
|
}
|
|
2721
2849
|
function middleware(mwOptions) {
|
|
2722
|
-
const
|
|
2850
|
+
const resolved = mode === "passthrough" ? { always: false, ...mwOptions } : mwOptions ?? {};
|
|
2851
|
+
const inner = createExpressMiddleware(fs, resolved);
|
|
2723
2852
|
return (req, res, next) => {
|
|
2724
2853
|
const requestId = headerValue2(req.headers, "x-fs-request-id") ?? "unknown";
|
|
2725
2854
|
const { path } = splitUrl2(req);
|
|
@@ -2758,6 +2887,7 @@ function createDevRuntime(options) {
|
|
|
2758
2887
|
};
|
|
2759
2888
|
}
|
|
2760
2889
|
fs.middleware = middleware;
|
|
2890
|
+
fs.handler = createExpressHandler;
|
|
2761
2891
|
const devRuntime = {
|
|
2762
2892
|
fs,
|
|
2763
2893
|
asPersona: (name) => personaClient.asPersona(name),
|