@bleedingdev/modern-js-bff-core 3.8.2-ultramodern.9 → 3.8.3-ultramodern.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/adapter-kit/parity-scenarios/operation-context.js +6 -12
- package/dist/cjs/adapter-kit/parity.js +3 -3
- package/dist/cjs/security/crossProjectPolicy.js +105 -6
- package/dist/esm/adapter-kit/parity-scenarios/operation-context.mjs +6 -12
- package/dist/esm/adapter-kit/parity.mjs +3 -3
- package/dist/esm/security/crossProjectPolicy.mjs +101 -5
- package/dist/esm-node/adapter-kit/parity-scenarios/operation-context.mjs +6 -12
- package/dist/esm-node/adapter-kit/parity.mjs +3 -3
- package/dist/esm-node/security/crossProjectPolicy.mjs +101 -5
- package/dist/types/security/crossProjectPolicy.d.ts +33 -1
- package/dist/types/security/resolveCrossProjectPolicy.d.ts +2 -11
- package/package.json +13 -5
|
@@ -60,6 +60,11 @@ const createOperationContextSuccessScenarios = ({ helloContract, validEnvelope,
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
];
|
|
63
|
+
const getMismatchedOperationVersion = (contract)=>{
|
|
64
|
+
const operationVersion = contract.operationVersion;
|
|
65
|
+
if ('number' != typeof operationVersion) throw new Error('Adapter parity hello contract requires operationVersion');
|
|
66
|
+
return operationVersion + 1;
|
|
67
|
+
};
|
|
63
68
|
const createOperationContextDenialScenarios = ({ helloContract, validEnvelope, validOperationId })=>[
|
|
64
69
|
(0, external_shared_js_namespaceObject.deniedScenario)('policy denies missing operation context', 'missing_operation_context', {
|
|
65
70
|
'x-modernjs-bff-envelope': validEnvelope
|
|
@@ -113,17 +118,6 @@ const createOperationContextDenialScenarios = ({ helloContract, validEnvelope, v
|
|
|
113
118
|
schemaHash: helloContract.schemaHash
|
|
114
119
|
})
|
|
115
120
|
}),
|
|
116
|
-
(0, external_shared_js_namespaceObject.deniedScenario)('policy denies unknown operation contract', 'unknown_operation_contract', {
|
|
117
|
-
'x-modernjs-bff-envelope': validEnvelope,
|
|
118
|
-
'x-operation-id': validOperationId,
|
|
119
|
-
'x-modernjs-bff-operation-context': (0, external_shared_js_namespaceObject.detailHeader)({
|
|
120
|
-
requestId: external_shared_js_namespaceObject.PARITY_PRODUCER_REQUEST_ID,
|
|
121
|
-
method: 'GET',
|
|
122
|
-
routePath: '/does-not-exist',
|
|
123
|
-
schemaHash: helloContract.schemaHash,
|
|
124
|
-
operationVersion: helloContract.operationVersion
|
|
125
|
-
})
|
|
126
|
-
}),
|
|
127
121
|
(0, external_shared_js_namespaceObject.deniedScenario)('policy denies operation schema hash mismatch', 'operation_schema_hash_mismatch', {
|
|
128
122
|
'x-modernjs-bff-envelope': validEnvelope,
|
|
129
123
|
'x-operation-id': validOperationId,
|
|
@@ -143,7 +137,7 @@ const createOperationContextDenialScenarios = ({ helloContract, validEnvelope, v
|
|
|
143
137
|
method: 'GET',
|
|
144
138
|
routePath: '/hello',
|
|
145
139
|
schemaHash: helloContract.schemaHash,
|
|
146
|
-
operationVersion: helloContract
|
|
140
|
+
operationVersion: getMismatchedOperationVersion(helloContract)
|
|
147
141
|
})
|
|
148
142
|
})
|
|
149
143
|
];
|
|
@@ -132,7 +132,7 @@ const createParityApiHandlerInfos = ()=>[
|
|
|
132
132
|
}
|
|
133
133
|
];
|
|
134
134
|
const createParityBffConfig = ()=>({
|
|
135
|
-
requestId: shared_js_namespaceObject.
|
|
135
|
+
requestId: shared_js_namespaceObject.PARITY_PRODUCER_REQUEST_ID,
|
|
136
136
|
crossProjectPolicy: {
|
|
137
137
|
enabled: true,
|
|
138
138
|
allowedNamespaces: [
|
|
@@ -143,13 +143,13 @@ const createParityBffConfig = ()=>({
|
|
|
143
143
|
});
|
|
144
144
|
const getParityContracts = ()=>(0, operationContracts_js_namespaceObject.buildOperationContractMap)({
|
|
145
145
|
handlers: createParityApiHandlerInfos(),
|
|
146
|
-
requestId: shared_js_namespaceObject.
|
|
146
|
+
requestId: shared_js_namespaceObject.PARITY_PRODUCER_REQUEST_ID
|
|
147
147
|
});
|
|
148
148
|
const createAdapterParityScenarios = ()=>{
|
|
149
149
|
const contracts = getParityContracts();
|
|
150
150
|
const helloContract = contracts['GET:/hello'];
|
|
151
151
|
const validEnvelope = (0, shared_js_namespaceObject.envelopeHeader)(shared_js_namespaceObject.PARITY_PRODUCER_REQUEST_ID);
|
|
152
|
-
const validOperationId =
|
|
152
|
+
const validOperationId = helloContract.operationId;
|
|
153
153
|
const context = {
|
|
154
154
|
helloContract,
|
|
155
155
|
validEnvelope,
|
|
@@ -33,6 +33,90 @@ const BFF_OPERATION_CONTEXT_DETAIL_HEADER = 'x-modernjs-bff-operation-context';
|
|
|
33
33
|
const DEFAULT_DENY_STATUS = 403;
|
|
34
34
|
const NAMESPACE_ALLOWLIST_REQUIRES_VERIFIER_MESSAGE = 'cross-project namespace allowlist requires verifyProducerIdentity';
|
|
35
35
|
let hasWarnedAdvisoryNamespaceAllowlist = false;
|
|
36
|
+
const splitRoutePath = (routePath)=>routePath.split('/').filter(Boolean);
|
|
37
|
+
const isDynamicRouteSegment = (segment)=>segment.startsWith(':') || '*' === segment || segment.startsWith('*') || segment.startsWith('{*');
|
|
38
|
+
const hasTrailingSlash = (routePath)=>routePath.length > 1 && routePath.endsWith('/');
|
|
39
|
+
const hasUnsafePathEncoding = (requestPath)=>{
|
|
40
|
+
let decoded = requestPath;
|
|
41
|
+
for(let depth = 0; depth < 8; depth += 1){
|
|
42
|
+
if (/%(?:2f|5c)/i.test(decoded)) return true;
|
|
43
|
+
try {
|
|
44
|
+
const next = decodeURIComponent(decoded);
|
|
45
|
+
if (next === decoded) return false;
|
|
46
|
+
decoded = next;
|
|
47
|
+
} catch {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return decoded.includes('%');
|
|
52
|
+
};
|
|
53
|
+
const routeSegmentsMatch = (templateSegments, requestSegments)=>{
|
|
54
|
+
let templateIndex = 0;
|
|
55
|
+
let requestIndex = 0;
|
|
56
|
+
while(templateIndex < templateSegments.length){
|
|
57
|
+
const segment = templateSegments[templateIndex];
|
|
58
|
+
const wildcard = '*' === segment || segment.startsWith('*') || segment.startsWith('{*');
|
|
59
|
+
const optional = segment.startsWith(':') && segment.endsWith('?');
|
|
60
|
+
if (templateIndex < templateSegments.length - 1 && (wildcard || optional)) return false;
|
|
61
|
+
if (wildcard) return true;
|
|
62
|
+
const requestSegment = requestSegments[requestIndex];
|
|
63
|
+
if (void 0 === requestSegment) return optional && templateIndex === templateSegments.length - 1;
|
|
64
|
+
if (!segment.startsWith(':') && segment !== requestSegment) return false;
|
|
65
|
+
templateIndex += 1;
|
|
66
|
+
requestIndex += 1;
|
|
67
|
+
}
|
|
68
|
+
return requestIndex === requestSegments.length;
|
|
69
|
+
};
|
|
70
|
+
const routeTemplateMatchScore = (routePath, requestPath)=>{
|
|
71
|
+
if (hasTrailingSlash(routePath) !== hasTrailingSlash(requestPath)) return;
|
|
72
|
+
const templateSegments = splitRoutePath(routePath);
|
|
73
|
+
const requestSegments = splitRoutePath(requestPath);
|
|
74
|
+
for(let prefixLength = 0; prefixLength <= templateSegments.length; prefixLength += 1){
|
|
75
|
+
const removedPrefix = templateSegments.slice(0, prefixLength);
|
|
76
|
+
if (removedPrefix.some(isDynamicRouteSegment)) break;
|
|
77
|
+
const mountedTemplate = templateSegments.slice(prefixLength);
|
|
78
|
+
if (!routeSegmentsMatch(mountedTemplate, requestSegments)) continue;
|
|
79
|
+
const literalSegments = mountedTemplate.filter((segment)=>!isDynamicRouteSegment(segment)).length;
|
|
80
|
+
const dynamicSegments = mountedTemplate.length - literalSegments;
|
|
81
|
+
return (0 === prefixLength ? 1000000 : 0) + 10000 * literalSegments - 100 * dynamicSegments - prefixLength;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
const resolveCrossProjectRequestObservation = (request, policy)=>{
|
|
85
|
+
const requestMethod = String(request.method || '').trim().toUpperCase();
|
|
86
|
+
const routePaths = [
|
|
87
|
+
...new Set(Object.keys(policy.expectedOperationContracts ?? {}).flatMap((key)=>{
|
|
88
|
+
if (key.startsWith('operation:')) return [];
|
|
89
|
+
const separator = key.indexOf(':');
|
|
90
|
+
if (-1 === separator || key.slice(0, separator).trim().toUpperCase() !== requestMethod) return [];
|
|
91
|
+
return [
|
|
92
|
+
key.slice(separator + 1)
|
|
93
|
+
];
|
|
94
|
+
}))
|
|
95
|
+
];
|
|
96
|
+
if (0 === routePaths.length) return;
|
|
97
|
+
if (hasUnsafePathEncoding(request.pathname)) return {
|
|
98
|
+
method: request.method,
|
|
99
|
+
routePath: request.pathname
|
|
100
|
+
};
|
|
101
|
+
const matches = routePaths.flatMap((routePath)=>{
|
|
102
|
+
const score = routeTemplateMatchScore(routePath, request.pathname);
|
|
103
|
+
return void 0 === score ? [] : [
|
|
104
|
+
{
|
|
105
|
+
routePath,
|
|
106
|
+
score
|
|
107
|
+
}
|
|
108
|
+
];
|
|
109
|
+
});
|
|
110
|
+
if (0 === matches.length) return {
|
|
111
|
+
method: request.method,
|
|
112
|
+
routePath: request.pathname
|
|
113
|
+
};
|
|
114
|
+
matches.sort((left, right)=>right.score - left.score);
|
|
115
|
+
return {
|
|
116
|
+
method: request.method,
|
|
117
|
+
routePath: matches[1]?.score === matches[0]?.score ? request.pathname : matches[0].routePath
|
|
118
|
+
};
|
|
119
|
+
};
|
|
36
120
|
const normalizeHeaderName = (headerName, fallback)=>(headerName || fallback).trim().toLowerCase();
|
|
37
121
|
const normalizeStatusCode = (statusCode)=>{
|
|
38
122
|
if ('number' == typeof statusCode && Number.isFinite(statusCode) && statusCode >= 400 && statusCode <= 599) return Math.floor(statusCode);
|
|
@@ -58,7 +142,7 @@ const warnAdvisoryNamespaceAllowlist = ()=>{
|
|
|
58
142
|
hasWarnedAdvisoryNamespaceAllowlist = true;
|
|
59
143
|
console.warn(`[Modern.js BFF] ${NAMESPACE_ALLOWLIST_REQUIRES_VERIFIER_MESSAGE}. allowClientAssertedNamespace=true will continue to evaluate allowlist against client-asserted requestId namespaces only.`);
|
|
60
144
|
};
|
|
61
|
-
const createCrossProjectPolicyEvaluationState = (headers, policy)=>({
|
|
145
|
+
const createCrossProjectPolicyEvaluationState = (headers, policy, observedRequest)=>({
|
|
62
146
|
headers,
|
|
63
147
|
policy,
|
|
64
148
|
status: normalizeStatusCode(policy.denyStatus),
|
|
@@ -77,6 +161,10 @@ const createCrossProjectPolicyEvaluationState = (headers, policy)=>({
|
|
|
77
161
|
requestId: '',
|
|
78
162
|
detailSchemaHash: '',
|
|
79
163
|
detailOperationVersion: void 0,
|
|
164
|
+
observedRequest: observedRequest ? {
|
|
165
|
+
method: String(observedRequest.method || '').trim().toUpperCase(),
|
|
166
|
+
routePath: String(observedRequest.routePath || '').trim()
|
|
167
|
+
} : void 0,
|
|
80
168
|
complete: false
|
|
81
169
|
});
|
|
82
170
|
const checkNamespaceAllowlistVerifier = (state)=>{
|
|
@@ -163,8 +251,16 @@ const checkOperationContract = (state)=>{
|
|
|
163
251
|
const method = String(state.operationContextDetails.method || '').toUpperCase();
|
|
164
252
|
const routePath = String(state.operationContextDetails.routePath || '').trim();
|
|
165
253
|
const operationId = String(state.operationContextDetails.operationId || '').trim();
|
|
166
|
-
const
|
|
254
|
+
const observedMethod = state.observedRequest?.method;
|
|
255
|
+
const observedRoutePath = state.observedRequest?.routePath;
|
|
256
|
+
if (observedMethod && observedRoutePath && (method !== observedMethod || routePath !== observedRoutePath)) return createViolation('operation_context_mismatch', `Client operation "${operationId || `${method}:${routePath}`}" does not match observed request "${observedMethod}:${observedRoutePath}"`, state.status);
|
|
257
|
+
const contractMethod = observedMethod || method;
|
|
258
|
+
const contractRoutePath = observedRoutePath || routePath;
|
|
259
|
+
const expectedContract = expectedContracts[`${contractMethod}:${contractRoutePath}`] || (state.observedRequest ? void 0 : expectedContracts[`operation:${operationId}`]);
|
|
167
260
|
if (expectedContract) {
|
|
261
|
+
const assertedOperationId = operationId || state.operationContext || '';
|
|
262
|
+
const canonicalRouteOperationId = `${state.requestId}:${contractMethod}:${contractRoutePath}`;
|
|
263
|
+
if (state.observedRequest && assertedOperationId !== expectedContract.operationId && assertedOperationId !== canonicalRouteOperationId) return createViolation('operation_context_mismatch', `Client operation "${assertedOperationId}" does not identify observed contract "${expectedContract.operationId || canonicalRouteOperationId}"`, state.status);
|
|
168
264
|
if (expectedContract.schemaHash && state.detailSchemaHash && expectedContract.schemaHash !== state.detailSchemaHash) return createViolation('operation_schema_hash_mismatch', `Operation schema hash mismatch for "${operationId || `${method}:${routePath}`}"`, state.status);
|
|
169
265
|
if ('number' == typeof expectedContract.operationVersion && 'number' == typeof state.detailOperationVersion && expectedContract.operationVersion !== state.detailOperationVersion) return createViolation('operation_version_mismatch', `Operation version mismatch for "${operationId || `${method}:${routePath}`}"`, state.status);
|
|
170
266
|
} else if (!state.allowUnknownOperations) return createViolation('unknown_operation_contract', `No expected operation contract found for operation "${operationId || `${method}:${routePath}`}"`, state.status);
|
|
@@ -179,9 +275,9 @@ const CROSS_PROJECT_POLICY_CHECKS = [
|
|
|
179
275
|
checkOperationContextDetails,
|
|
180
276
|
checkOperationContract
|
|
181
277
|
];
|
|
182
|
-
const evaluateCrossProjectPolicy = (headers, policy)=>{
|
|
278
|
+
const evaluateCrossProjectPolicy = (headers, policy, observedRequest)=>{
|
|
183
279
|
if (!policy?.enabled) return null;
|
|
184
|
-
const state = createCrossProjectPolicyEvaluationState(headers, policy);
|
|
280
|
+
const state = createCrossProjectPolicyEvaluationState(headers, policy, observedRequest);
|
|
185
281
|
for (const check of CROSS_PROJECT_POLICY_CHECKS){
|
|
186
282
|
const violation = check(state);
|
|
187
283
|
if (violation) return violation;
|
|
@@ -193,17 +289,20 @@ __webpack_require__.d(__webpack_exports__, {}, {
|
|
|
193
289
|
BFF_ENVELOPE_HEADER: BFF_ENVELOPE_HEADER,
|
|
194
290
|
BFF_OPERATION_CONTEXT_DETAIL_HEADER: BFF_OPERATION_CONTEXT_DETAIL_HEADER,
|
|
195
291
|
BFF_OPERATION_CONTEXT_HEADER: BFF_OPERATION_CONTEXT_HEADER,
|
|
196
|
-
evaluateCrossProjectPolicy: evaluateCrossProjectPolicy
|
|
292
|
+
evaluateCrossProjectPolicy: evaluateCrossProjectPolicy,
|
|
293
|
+
resolveCrossProjectRequestObservation: resolveCrossProjectRequestObservation
|
|
197
294
|
});
|
|
198
295
|
exports.BFF_ENVELOPE_HEADER = __webpack_exports__.BFF_ENVELOPE_HEADER;
|
|
199
296
|
exports.BFF_OPERATION_CONTEXT_DETAIL_HEADER = __webpack_exports__.BFF_OPERATION_CONTEXT_DETAIL_HEADER;
|
|
200
297
|
exports.BFF_OPERATION_CONTEXT_HEADER = __webpack_exports__.BFF_OPERATION_CONTEXT_HEADER;
|
|
201
298
|
exports.evaluateCrossProjectPolicy = __webpack_exports__.evaluateCrossProjectPolicy;
|
|
299
|
+
exports.resolveCrossProjectRequestObservation = __webpack_exports__.resolveCrossProjectRequestObservation;
|
|
202
300
|
for(var __rspack_i in __webpack_exports__)if (-1 === [
|
|
203
301
|
"BFF_ENVELOPE_HEADER",
|
|
204
302
|
"BFF_OPERATION_CONTEXT_DETAIL_HEADER",
|
|
205
303
|
"BFF_OPERATION_CONTEXT_HEADER",
|
|
206
|
-
"evaluateCrossProjectPolicy"
|
|
304
|
+
"evaluateCrossProjectPolicy",
|
|
305
|
+
"resolveCrossProjectRequestObservation"
|
|
207
306
|
].indexOf(__rspack_i)) exports[__rspack_i] = __webpack_exports__[__rspack_i];
|
|
208
307
|
Object.defineProperty(exports, '__esModule', {
|
|
209
308
|
value: true
|
|
@@ -27,6 +27,11 @@ const createOperationContextSuccessScenarios = ({ helloContract, validEnvelope,
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
];
|
|
30
|
+
const getMismatchedOperationVersion = (contract)=>{
|
|
31
|
+
const operationVersion = contract.operationVersion;
|
|
32
|
+
if ('number' != typeof operationVersion) throw new Error('Adapter parity hello contract requires operationVersion');
|
|
33
|
+
return operationVersion + 1;
|
|
34
|
+
};
|
|
30
35
|
const createOperationContextDenialScenarios = ({ helloContract, validEnvelope, validOperationId })=>[
|
|
31
36
|
deniedScenario('policy denies missing operation context', 'missing_operation_context', {
|
|
32
37
|
'x-modernjs-bff-envelope': validEnvelope
|
|
@@ -80,17 +85,6 @@ const createOperationContextDenialScenarios = ({ helloContract, validEnvelope, v
|
|
|
80
85
|
schemaHash: helloContract.schemaHash
|
|
81
86
|
})
|
|
82
87
|
}),
|
|
83
|
-
deniedScenario('policy denies unknown operation contract', 'unknown_operation_contract', {
|
|
84
|
-
'x-modernjs-bff-envelope': validEnvelope,
|
|
85
|
-
'x-operation-id': validOperationId,
|
|
86
|
-
'x-modernjs-bff-operation-context': detailHeader({
|
|
87
|
-
requestId: PARITY_PRODUCER_REQUEST_ID,
|
|
88
|
-
method: 'GET',
|
|
89
|
-
routePath: '/does-not-exist',
|
|
90
|
-
schemaHash: helloContract.schemaHash,
|
|
91
|
-
operationVersion: helloContract.operationVersion
|
|
92
|
-
})
|
|
93
|
-
}),
|
|
94
88
|
deniedScenario('policy denies operation schema hash mismatch', 'operation_schema_hash_mismatch', {
|
|
95
89
|
'x-modernjs-bff-envelope': validEnvelope,
|
|
96
90
|
'x-operation-id': validOperationId,
|
|
@@ -110,7 +104,7 @@ const createOperationContextDenialScenarios = ({ helloContract, validEnvelope, v
|
|
|
110
104
|
method: 'GET',
|
|
111
105
|
routePath: '/hello',
|
|
112
106
|
schemaHash: helloContract.schemaHash,
|
|
113
|
-
operationVersion: helloContract
|
|
107
|
+
operationVersion: getMismatchedOperationVersion(helloContract)
|
|
114
108
|
})
|
|
115
109
|
})
|
|
116
110
|
];
|
|
@@ -94,7 +94,7 @@ const createParityApiHandlerInfos = ()=>[
|
|
|
94
94
|
}
|
|
95
95
|
];
|
|
96
96
|
const createParityBffConfig = ()=>({
|
|
97
|
-
requestId:
|
|
97
|
+
requestId: PARITY_PRODUCER_REQUEST_ID,
|
|
98
98
|
crossProjectPolicy: {
|
|
99
99
|
enabled: true,
|
|
100
100
|
allowedNamespaces: [
|
|
@@ -105,13 +105,13 @@ const createParityBffConfig = ()=>({
|
|
|
105
105
|
});
|
|
106
106
|
const getParityContracts = ()=>buildOperationContractMap({
|
|
107
107
|
handlers: createParityApiHandlerInfos(),
|
|
108
|
-
requestId:
|
|
108
|
+
requestId: PARITY_PRODUCER_REQUEST_ID
|
|
109
109
|
});
|
|
110
110
|
const createAdapterParityScenarios = ()=>{
|
|
111
111
|
const contracts = getParityContracts();
|
|
112
112
|
const helloContract = contracts['GET:/hello'];
|
|
113
113
|
const validEnvelope = envelopeHeader(PARITY_PRODUCER_REQUEST_ID);
|
|
114
|
-
const validOperationId =
|
|
114
|
+
const validOperationId = helloContract.operationId;
|
|
115
115
|
const context = {
|
|
116
116
|
helloContract,
|
|
117
117
|
validEnvelope,
|
|
@@ -4,6 +4,90 @@ const BFF_OPERATION_CONTEXT_DETAIL_HEADER = 'x-modernjs-bff-operation-context';
|
|
|
4
4
|
const DEFAULT_DENY_STATUS = 403;
|
|
5
5
|
const NAMESPACE_ALLOWLIST_REQUIRES_VERIFIER_MESSAGE = 'cross-project namespace allowlist requires verifyProducerIdentity';
|
|
6
6
|
let hasWarnedAdvisoryNamespaceAllowlist = false;
|
|
7
|
+
const splitRoutePath = (routePath)=>routePath.split('/').filter(Boolean);
|
|
8
|
+
const isDynamicRouteSegment = (segment)=>segment.startsWith(':') || '*' === segment || segment.startsWith('*') || segment.startsWith('{*');
|
|
9
|
+
const hasTrailingSlash = (routePath)=>routePath.length > 1 && routePath.endsWith('/');
|
|
10
|
+
const hasUnsafePathEncoding = (requestPath)=>{
|
|
11
|
+
let decoded = requestPath;
|
|
12
|
+
for(let depth = 0; depth < 8; depth += 1){
|
|
13
|
+
if (/%(?:2f|5c)/i.test(decoded)) return true;
|
|
14
|
+
try {
|
|
15
|
+
const next = decodeURIComponent(decoded);
|
|
16
|
+
if (next === decoded) return false;
|
|
17
|
+
decoded = next;
|
|
18
|
+
} catch {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return decoded.includes('%');
|
|
23
|
+
};
|
|
24
|
+
const routeSegmentsMatch = (templateSegments, requestSegments)=>{
|
|
25
|
+
let templateIndex = 0;
|
|
26
|
+
let requestIndex = 0;
|
|
27
|
+
while(templateIndex < templateSegments.length){
|
|
28
|
+
const segment = templateSegments[templateIndex];
|
|
29
|
+
const wildcard = '*' === segment || segment.startsWith('*') || segment.startsWith('{*');
|
|
30
|
+
const optional = segment.startsWith(':') && segment.endsWith('?');
|
|
31
|
+
if (templateIndex < templateSegments.length - 1 && (wildcard || optional)) return false;
|
|
32
|
+
if (wildcard) return true;
|
|
33
|
+
const requestSegment = requestSegments[requestIndex];
|
|
34
|
+
if (void 0 === requestSegment) return optional && templateIndex === templateSegments.length - 1;
|
|
35
|
+
if (!segment.startsWith(':') && segment !== requestSegment) return false;
|
|
36
|
+
templateIndex += 1;
|
|
37
|
+
requestIndex += 1;
|
|
38
|
+
}
|
|
39
|
+
return requestIndex === requestSegments.length;
|
|
40
|
+
};
|
|
41
|
+
const routeTemplateMatchScore = (routePath, requestPath)=>{
|
|
42
|
+
if (hasTrailingSlash(routePath) !== hasTrailingSlash(requestPath)) return;
|
|
43
|
+
const templateSegments = splitRoutePath(routePath);
|
|
44
|
+
const requestSegments = splitRoutePath(requestPath);
|
|
45
|
+
for(let prefixLength = 0; prefixLength <= templateSegments.length; prefixLength += 1){
|
|
46
|
+
const removedPrefix = templateSegments.slice(0, prefixLength);
|
|
47
|
+
if (removedPrefix.some(isDynamicRouteSegment)) break;
|
|
48
|
+
const mountedTemplate = templateSegments.slice(prefixLength);
|
|
49
|
+
if (!routeSegmentsMatch(mountedTemplate, requestSegments)) continue;
|
|
50
|
+
const literalSegments = mountedTemplate.filter((segment)=>!isDynamicRouteSegment(segment)).length;
|
|
51
|
+
const dynamicSegments = mountedTemplate.length - literalSegments;
|
|
52
|
+
return (0 === prefixLength ? 1000000 : 0) + 10000 * literalSegments - 100 * dynamicSegments - prefixLength;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const resolveCrossProjectRequestObservation = (request, policy)=>{
|
|
56
|
+
const requestMethod = String(request.method || '').trim().toUpperCase();
|
|
57
|
+
const routePaths = [
|
|
58
|
+
...new Set(Object.keys(policy.expectedOperationContracts ?? {}).flatMap((key)=>{
|
|
59
|
+
if (key.startsWith('operation:')) return [];
|
|
60
|
+
const separator = key.indexOf(':');
|
|
61
|
+
if (-1 === separator || key.slice(0, separator).trim().toUpperCase() !== requestMethod) return [];
|
|
62
|
+
return [
|
|
63
|
+
key.slice(separator + 1)
|
|
64
|
+
];
|
|
65
|
+
}))
|
|
66
|
+
];
|
|
67
|
+
if (0 === routePaths.length) return;
|
|
68
|
+
if (hasUnsafePathEncoding(request.pathname)) return {
|
|
69
|
+
method: request.method,
|
|
70
|
+
routePath: request.pathname
|
|
71
|
+
};
|
|
72
|
+
const matches = routePaths.flatMap((routePath)=>{
|
|
73
|
+
const score = routeTemplateMatchScore(routePath, request.pathname);
|
|
74
|
+
return void 0 === score ? [] : [
|
|
75
|
+
{
|
|
76
|
+
routePath,
|
|
77
|
+
score
|
|
78
|
+
}
|
|
79
|
+
];
|
|
80
|
+
});
|
|
81
|
+
if (0 === matches.length) return {
|
|
82
|
+
method: request.method,
|
|
83
|
+
routePath: request.pathname
|
|
84
|
+
};
|
|
85
|
+
matches.sort((left, right)=>right.score - left.score);
|
|
86
|
+
return {
|
|
87
|
+
method: request.method,
|
|
88
|
+
routePath: matches[1]?.score === matches[0]?.score ? request.pathname : matches[0].routePath
|
|
89
|
+
};
|
|
90
|
+
};
|
|
7
91
|
const normalizeHeaderName = (headerName, fallback)=>(headerName || fallback).trim().toLowerCase();
|
|
8
92
|
const normalizeStatusCode = (statusCode)=>{
|
|
9
93
|
if ('number' == typeof statusCode && Number.isFinite(statusCode) && statusCode >= 400 && statusCode <= 599) return Math.floor(statusCode);
|
|
@@ -29,7 +113,7 @@ const warnAdvisoryNamespaceAllowlist = ()=>{
|
|
|
29
113
|
hasWarnedAdvisoryNamespaceAllowlist = true;
|
|
30
114
|
console.warn(`[Modern.js BFF] ${NAMESPACE_ALLOWLIST_REQUIRES_VERIFIER_MESSAGE}. allowClientAssertedNamespace=true will continue to evaluate allowlist against client-asserted requestId namespaces only.`);
|
|
31
115
|
};
|
|
32
|
-
const createCrossProjectPolicyEvaluationState = (headers, policy)=>({
|
|
116
|
+
const createCrossProjectPolicyEvaluationState = (headers, policy, observedRequest)=>({
|
|
33
117
|
headers,
|
|
34
118
|
policy,
|
|
35
119
|
status: normalizeStatusCode(policy.denyStatus),
|
|
@@ -48,6 +132,10 @@ const createCrossProjectPolicyEvaluationState = (headers, policy)=>({
|
|
|
48
132
|
requestId: '',
|
|
49
133
|
detailSchemaHash: '',
|
|
50
134
|
detailOperationVersion: void 0,
|
|
135
|
+
observedRequest: observedRequest ? {
|
|
136
|
+
method: String(observedRequest.method || '').trim().toUpperCase(),
|
|
137
|
+
routePath: String(observedRequest.routePath || '').trim()
|
|
138
|
+
} : void 0,
|
|
51
139
|
complete: false
|
|
52
140
|
});
|
|
53
141
|
const checkNamespaceAllowlistVerifier = (state)=>{
|
|
@@ -134,8 +222,16 @@ const checkOperationContract = (state)=>{
|
|
|
134
222
|
const method = String(state.operationContextDetails.method || '').toUpperCase();
|
|
135
223
|
const routePath = String(state.operationContextDetails.routePath || '').trim();
|
|
136
224
|
const operationId = String(state.operationContextDetails.operationId || '').trim();
|
|
137
|
-
const
|
|
225
|
+
const observedMethod = state.observedRequest?.method;
|
|
226
|
+
const observedRoutePath = state.observedRequest?.routePath;
|
|
227
|
+
if (observedMethod && observedRoutePath && (method !== observedMethod || routePath !== observedRoutePath)) return createViolation('operation_context_mismatch', `Client operation "${operationId || `${method}:${routePath}`}" does not match observed request "${observedMethod}:${observedRoutePath}"`, state.status);
|
|
228
|
+
const contractMethod = observedMethod || method;
|
|
229
|
+
const contractRoutePath = observedRoutePath || routePath;
|
|
230
|
+
const expectedContract = expectedContracts[`${contractMethod}:${contractRoutePath}`] || (state.observedRequest ? void 0 : expectedContracts[`operation:${operationId}`]);
|
|
138
231
|
if (expectedContract) {
|
|
232
|
+
const assertedOperationId = operationId || state.operationContext || '';
|
|
233
|
+
const canonicalRouteOperationId = `${state.requestId}:${contractMethod}:${contractRoutePath}`;
|
|
234
|
+
if (state.observedRequest && assertedOperationId !== expectedContract.operationId && assertedOperationId !== canonicalRouteOperationId) return createViolation('operation_context_mismatch', `Client operation "${assertedOperationId}" does not identify observed contract "${expectedContract.operationId || canonicalRouteOperationId}"`, state.status);
|
|
139
235
|
if (expectedContract.schemaHash && state.detailSchemaHash && expectedContract.schemaHash !== state.detailSchemaHash) return createViolation('operation_schema_hash_mismatch', `Operation schema hash mismatch for "${operationId || `${method}:${routePath}`}"`, state.status);
|
|
140
236
|
if ('number' == typeof expectedContract.operationVersion && 'number' == typeof state.detailOperationVersion && expectedContract.operationVersion !== state.detailOperationVersion) return createViolation('operation_version_mismatch', `Operation version mismatch for "${operationId || `${method}:${routePath}`}"`, state.status);
|
|
141
237
|
} else if (!state.allowUnknownOperations) return createViolation('unknown_operation_contract', `No expected operation contract found for operation "${operationId || `${method}:${routePath}`}"`, state.status);
|
|
@@ -150,9 +246,9 @@ const CROSS_PROJECT_POLICY_CHECKS = [
|
|
|
150
246
|
checkOperationContextDetails,
|
|
151
247
|
checkOperationContract
|
|
152
248
|
];
|
|
153
|
-
const evaluateCrossProjectPolicy = (headers, policy)=>{
|
|
249
|
+
const evaluateCrossProjectPolicy = (headers, policy, observedRequest)=>{
|
|
154
250
|
if (!policy?.enabled) return null;
|
|
155
|
-
const state = createCrossProjectPolicyEvaluationState(headers, policy);
|
|
251
|
+
const state = createCrossProjectPolicyEvaluationState(headers, policy, observedRequest);
|
|
156
252
|
for (const check of CROSS_PROJECT_POLICY_CHECKS){
|
|
157
253
|
const violation = check(state);
|
|
158
254
|
if (violation) return violation;
|
|
@@ -160,4 +256,4 @@ const evaluateCrossProjectPolicy = (headers, policy)=>{
|
|
|
160
256
|
}
|
|
161
257
|
return null;
|
|
162
258
|
};
|
|
163
|
-
export { BFF_ENVELOPE_HEADER, BFF_OPERATION_CONTEXT_DETAIL_HEADER, BFF_OPERATION_CONTEXT_HEADER, evaluateCrossProjectPolicy };
|
|
259
|
+
export { BFF_ENVELOPE_HEADER, BFF_OPERATION_CONTEXT_DETAIL_HEADER, BFF_OPERATION_CONTEXT_HEADER, evaluateCrossProjectPolicy, resolveCrossProjectRequestObservation };
|
|
@@ -28,6 +28,11 @@ const createOperationContextSuccessScenarios = ({ helloContract, validEnvelope,
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
];
|
|
31
|
+
const getMismatchedOperationVersion = (contract)=>{
|
|
32
|
+
const operationVersion = contract.operationVersion;
|
|
33
|
+
if ('number' != typeof operationVersion) throw new Error('Adapter parity hello contract requires operationVersion');
|
|
34
|
+
return operationVersion + 1;
|
|
35
|
+
};
|
|
31
36
|
const createOperationContextDenialScenarios = ({ helloContract, validEnvelope, validOperationId })=>[
|
|
32
37
|
deniedScenario('policy denies missing operation context', 'missing_operation_context', {
|
|
33
38
|
'x-modernjs-bff-envelope': validEnvelope
|
|
@@ -81,17 +86,6 @@ const createOperationContextDenialScenarios = ({ helloContract, validEnvelope, v
|
|
|
81
86
|
schemaHash: helloContract.schemaHash
|
|
82
87
|
})
|
|
83
88
|
}),
|
|
84
|
-
deniedScenario('policy denies unknown operation contract', 'unknown_operation_contract', {
|
|
85
|
-
'x-modernjs-bff-envelope': validEnvelope,
|
|
86
|
-
'x-operation-id': validOperationId,
|
|
87
|
-
'x-modernjs-bff-operation-context': detailHeader({
|
|
88
|
-
requestId: PARITY_PRODUCER_REQUEST_ID,
|
|
89
|
-
method: 'GET',
|
|
90
|
-
routePath: '/does-not-exist',
|
|
91
|
-
schemaHash: helloContract.schemaHash,
|
|
92
|
-
operationVersion: helloContract.operationVersion
|
|
93
|
-
})
|
|
94
|
-
}),
|
|
95
89
|
deniedScenario('policy denies operation schema hash mismatch', 'operation_schema_hash_mismatch', {
|
|
96
90
|
'x-modernjs-bff-envelope': validEnvelope,
|
|
97
91
|
'x-operation-id': validOperationId,
|
|
@@ -111,7 +105,7 @@ const createOperationContextDenialScenarios = ({ helloContract, validEnvelope, v
|
|
|
111
105
|
method: 'GET',
|
|
112
106
|
routePath: '/hello',
|
|
113
107
|
schemaHash: helloContract.schemaHash,
|
|
114
|
-
operationVersion: helloContract
|
|
108
|
+
operationVersion: getMismatchedOperationVersion(helloContract)
|
|
115
109
|
})
|
|
116
110
|
})
|
|
117
111
|
];
|
|
@@ -95,7 +95,7 @@ const createParityApiHandlerInfos = ()=>[
|
|
|
95
95
|
}
|
|
96
96
|
];
|
|
97
97
|
const createParityBffConfig = ()=>({
|
|
98
|
-
requestId:
|
|
98
|
+
requestId: PARITY_PRODUCER_REQUEST_ID,
|
|
99
99
|
crossProjectPolicy: {
|
|
100
100
|
enabled: true,
|
|
101
101
|
allowedNamespaces: [
|
|
@@ -106,13 +106,13 @@ const createParityBffConfig = ()=>({
|
|
|
106
106
|
});
|
|
107
107
|
const getParityContracts = ()=>buildOperationContractMap({
|
|
108
108
|
handlers: createParityApiHandlerInfos(),
|
|
109
|
-
requestId:
|
|
109
|
+
requestId: PARITY_PRODUCER_REQUEST_ID
|
|
110
110
|
});
|
|
111
111
|
const createAdapterParityScenarios = ()=>{
|
|
112
112
|
const contracts = getParityContracts();
|
|
113
113
|
const helloContract = contracts['GET:/hello'];
|
|
114
114
|
const validEnvelope = envelopeHeader(PARITY_PRODUCER_REQUEST_ID);
|
|
115
|
-
const validOperationId =
|
|
115
|
+
const validOperationId = helloContract.operationId;
|
|
116
116
|
const context = {
|
|
117
117
|
helloContract,
|
|
118
118
|
validEnvelope,
|
|
@@ -5,6 +5,90 @@ const BFF_OPERATION_CONTEXT_DETAIL_HEADER = 'x-modernjs-bff-operation-context';
|
|
|
5
5
|
const DEFAULT_DENY_STATUS = 403;
|
|
6
6
|
const NAMESPACE_ALLOWLIST_REQUIRES_VERIFIER_MESSAGE = 'cross-project namespace allowlist requires verifyProducerIdentity';
|
|
7
7
|
let hasWarnedAdvisoryNamespaceAllowlist = false;
|
|
8
|
+
const splitRoutePath = (routePath)=>routePath.split('/').filter(Boolean);
|
|
9
|
+
const isDynamicRouteSegment = (segment)=>segment.startsWith(':') || '*' === segment || segment.startsWith('*') || segment.startsWith('{*');
|
|
10
|
+
const hasTrailingSlash = (routePath)=>routePath.length > 1 && routePath.endsWith('/');
|
|
11
|
+
const hasUnsafePathEncoding = (requestPath)=>{
|
|
12
|
+
let decoded = requestPath;
|
|
13
|
+
for(let depth = 0; depth < 8; depth += 1){
|
|
14
|
+
if (/%(?:2f|5c)/i.test(decoded)) return true;
|
|
15
|
+
try {
|
|
16
|
+
const next = decodeURIComponent(decoded);
|
|
17
|
+
if (next === decoded) return false;
|
|
18
|
+
decoded = next;
|
|
19
|
+
} catch {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return decoded.includes('%');
|
|
24
|
+
};
|
|
25
|
+
const routeSegmentsMatch = (templateSegments, requestSegments)=>{
|
|
26
|
+
let templateIndex = 0;
|
|
27
|
+
let requestIndex = 0;
|
|
28
|
+
while(templateIndex < templateSegments.length){
|
|
29
|
+
const segment = templateSegments[templateIndex];
|
|
30
|
+
const wildcard = '*' === segment || segment.startsWith('*') || segment.startsWith('{*');
|
|
31
|
+
const optional = segment.startsWith(':') && segment.endsWith('?');
|
|
32
|
+
if (templateIndex < templateSegments.length - 1 && (wildcard || optional)) return false;
|
|
33
|
+
if (wildcard) return true;
|
|
34
|
+
const requestSegment = requestSegments[requestIndex];
|
|
35
|
+
if (void 0 === requestSegment) return optional && templateIndex === templateSegments.length - 1;
|
|
36
|
+
if (!segment.startsWith(':') && segment !== requestSegment) return false;
|
|
37
|
+
templateIndex += 1;
|
|
38
|
+
requestIndex += 1;
|
|
39
|
+
}
|
|
40
|
+
return requestIndex === requestSegments.length;
|
|
41
|
+
};
|
|
42
|
+
const routeTemplateMatchScore = (routePath, requestPath)=>{
|
|
43
|
+
if (hasTrailingSlash(routePath) !== hasTrailingSlash(requestPath)) return;
|
|
44
|
+
const templateSegments = splitRoutePath(routePath);
|
|
45
|
+
const requestSegments = splitRoutePath(requestPath);
|
|
46
|
+
for(let prefixLength = 0; prefixLength <= templateSegments.length; prefixLength += 1){
|
|
47
|
+
const removedPrefix = templateSegments.slice(0, prefixLength);
|
|
48
|
+
if (removedPrefix.some(isDynamicRouteSegment)) break;
|
|
49
|
+
const mountedTemplate = templateSegments.slice(prefixLength);
|
|
50
|
+
if (!routeSegmentsMatch(mountedTemplate, requestSegments)) continue;
|
|
51
|
+
const literalSegments = mountedTemplate.filter((segment)=>!isDynamicRouteSegment(segment)).length;
|
|
52
|
+
const dynamicSegments = mountedTemplate.length - literalSegments;
|
|
53
|
+
return (0 === prefixLength ? 1000000 : 0) + 10000 * literalSegments - 100 * dynamicSegments - prefixLength;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const resolveCrossProjectRequestObservation = (request, policy)=>{
|
|
57
|
+
const requestMethod = String(request.method || '').trim().toUpperCase();
|
|
58
|
+
const routePaths = [
|
|
59
|
+
...new Set(Object.keys(policy.expectedOperationContracts ?? {}).flatMap((key)=>{
|
|
60
|
+
if (key.startsWith('operation:')) return [];
|
|
61
|
+
const separator = key.indexOf(':');
|
|
62
|
+
if (-1 === separator || key.slice(0, separator).trim().toUpperCase() !== requestMethod) return [];
|
|
63
|
+
return [
|
|
64
|
+
key.slice(separator + 1)
|
|
65
|
+
];
|
|
66
|
+
}))
|
|
67
|
+
];
|
|
68
|
+
if (0 === routePaths.length) return;
|
|
69
|
+
if (hasUnsafePathEncoding(request.pathname)) return {
|
|
70
|
+
method: request.method,
|
|
71
|
+
routePath: request.pathname
|
|
72
|
+
};
|
|
73
|
+
const matches = routePaths.flatMap((routePath)=>{
|
|
74
|
+
const score = routeTemplateMatchScore(routePath, request.pathname);
|
|
75
|
+
return void 0 === score ? [] : [
|
|
76
|
+
{
|
|
77
|
+
routePath,
|
|
78
|
+
score
|
|
79
|
+
}
|
|
80
|
+
];
|
|
81
|
+
});
|
|
82
|
+
if (0 === matches.length) return {
|
|
83
|
+
method: request.method,
|
|
84
|
+
routePath: request.pathname
|
|
85
|
+
};
|
|
86
|
+
matches.sort((left, right)=>right.score - left.score);
|
|
87
|
+
return {
|
|
88
|
+
method: request.method,
|
|
89
|
+
routePath: matches[1]?.score === matches[0]?.score ? request.pathname : matches[0].routePath
|
|
90
|
+
};
|
|
91
|
+
};
|
|
8
92
|
const normalizeHeaderName = (headerName, fallback)=>(headerName || fallback).trim().toLowerCase();
|
|
9
93
|
const normalizeStatusCode = (statusCode)=>{
|
|
10
94
|
if ('number' == typeof statusCode && Number.isFinite(statusCode) && statusCode >= 400 && statusCode <= 599) return Math.floor(statusCode);
|
|
@@ -30,7 +114,7 @@ const warnAdvisoryNamespaceAllowlist = ()=>{
|
|
|
30
114
|
hasWarnedAdvisoryNamespaceAllowlist = true;
|
|
31
115
|
console.warn(`[Modern.js BFF] ${NAMESPACE_ALLOWLIST_REQUIRES_VERIFIER_MESSAGE}. allowClientAssertedNamespace=true will continue to evaluate allowlist against client-asserted requestId namespaces only.`);
|
|
32
116
|
};
|
|
33
|
-
const createCrossProjectPolicyEvaluationState = (headers, policy)=>({
|
|
117
|
+
const createCrossProjectPolicyEvaluationState = (headers, policy, observedRequest)=>({
|
|
34
118
|
headers,
|
|
35
119
|
policy,
|
|
36
120
|
status: normalizeStatusCode(policy.denyStatus),
|
|
@@ -49,6 +133,10 @@ const createCrossProjectPolicyEvaluationState = (headers, policy)=>({
|
|
|
49
133
|
requestId: '',
|
|
50
134
|
detailSchemaHash: '',
|
|
51
135
|
detailOperationVersion: void 0,
|
|
136
|
+
observedRequest: observedRequest ? {
|
|
137
|
+
method: String(observedRequest.method || '').trim().toUpperCase(),
|
|
138
|
+
routePath: String(observedRequest.routePath || '').trim()
|
|
139
|
+
} : void 0,
|
|
52
140
|
complete: false
|
|
53
141
|
});
|
|
54
142
|
const checkNamespaceAllowlistVerifier = (state)=>{
|
|
@@ -135,8 +223,16 @@ const checkOperationContract = (state)=>{
|
|
|
135
223
|
const method = String(state.operationContextDetails.method || '').toUpperCase();
|
|
136
224
|
const routePath = String(state.operationContextDetails.routePath || '').trim();
|
|
137
225
|
const operationId = String(state.operationContextDetails.operationId || '').trim();
|
|
138
|
-
const
|
|
226
|
+
const observedMethod = state.observedRequest?.method;
|
|
227
|
+
const observedRoutePath = state.observedRequest?.routePath;
|
|
228
|
+
if (observedMethod && observedRoutePath && (method !== observedMethod || routePath !== observedRoutePath)) return createViolation('operation_context_mismatch', `Client operation "${operationId || `${method}:${routePath}`}" does not match observed request "${observedMethod}:${observedRoutePath}"`, state.status);
|
|
229
|
+
const contractMethod = observedMethod || method;
|
|
230
|
+
const contractRoutePath = observedRoutePath || routePath;
|
|
231
|
+
const expectedContract = expectedContracts[`${contractMethod}:${contractRoutePath}`] || (state.observedRequest ? void 0 : expectedContracts[`operation:${operationId}`]);
|
|
139
232
|
if (expectedContract) {
|
|
233
|
+
const assertedOperationId = operationId || state.operationContext || '';
|
|
234
|
+
const canonicalRouteOperationId = `${state.requestId}:${contractMethod}:${contractRoutePath}`;
|
|
235
|
+
if (state.observedRequest && assertedOperationId !== expectedContract.operationId && assertedOperationId !== canonicalRouteOperationId) return createViolation('operation_context_mismatch', `Client operation "${assertedOperationId}" does not identify observed contract "${expectedContract.operationId || canonicalRouteOperationId}"`, state.status);
|
|
140
236
|
if (expectedContract.schemaHash && state.detailSchemaHash && expectedContract.schemaHash !== state.detailSchemaHash) return createViolation('operation_schema_hash_mismatch', `Operation schema hash mismatch for "${operationId || `${method}:${routePath}`}"`, state.status);
|
|
141
237
|
if ('number' == typeof expectedContract.operationVersion && 'number' == typeof state.detailOperationVersion && expectedContract.operationVersion !== state.detailOperationVersion) return createViolation('operation_version_mismatch', `Operation version mismatch for "${operationId || `${method}:${routePath}`}"`, state.status);
|
|
142
238
|
} else if (!state.allowUnknownOperations) return createViolation('unknown_operation_contract', `No expected operation contract found for operation "${operationId || `${method}:${routePath}`}"`, state.status);
|
|
@@ -151,9 +247,9 @@ const CROSS_PROJECT_POLICY_CHECKS = [
|
|
|
151
247
|
checkOperationContextDetails,
|
|
152
248
|
checkOperationContract
|
|
153
249
|
];
|
|
154
|
-
const evaluateCrossProjectPolicy = (headers, policy)=>{
|
|
250
|
+
const evaluateCrossProjectPolicy = (headers, policy, observedRequest)=>{
|
|
155
251
|
if (!policy?.enabled) return null;
|
|
156
|
-
const state = createCrossProjectPolicyEvaluationState(headers, policy);
|
|
252
|
+
const state = createCrossProjectPolicyEvaluationState(headers, policy, observedRequest);
|
|
157
253
|
for (const check of CROSS_PROJECT_POLICY_CHECKS){
|
|
158
254
|
const violation = check(state);
|
|
159
255
|
if (violation) return violation;
|
|
@@ -161,4 +257,4 @@ const evaluateCrossProjectPolicy = (headers, policy)=>{
|
|
|
161
257
|
}
|
|
162
258
|
return null;
|
|
163
259
|
};
|
|
164
|
-
export { BFF_ENVELOPE_HEADER, BFF_OPERATION_CONTEXT_DETAIL_HEADER, BFF_OPERATION_CONTEXT_HEADER, evaluateCrossProjectPolicy };
|
|
260
|
+
export { BFF_ENVELOPE_HEADER, BFF_OPERATION_CONTEXT_DETAIL_HEADER, BFF_OPERATION_CONTEXT_HEADER, evaluateCrossProjectPolicy, resolveCrossProjectRequestObservation };
|
|
@@ -32,6 +32,20 @@ export declare const BFF_OPERATION_CONTEXT_DETAIL_HEADER = "x-modernjs-bff-opera
|
|
|
32
32
|
export type CrossProjectOperationContract = {
|
|
33
33
|
schemaHash?: string;
|
|
34
34
|
operationVersion?: number;
|
|
35
|
+
operationId?: string;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Request facts observed by the server adapter rather than asserted by the
|
|
39
|
+
* client. `routePath` is the matched route template when the router exposes
|
|
40
|
+
* one (for example `/api/customers/:id`), not the client-provided detail.
|
|
41
|
+
*/
|
|
42
|
+
export type CrossProjectRequestObservation = {
|
|
43
|
+
method: string;
|
|
44
|
+
routePath: string;
|
|
45
|
+
};
|
|
46
|
+
export type CrossProjectRequestTarget = {
|
|
47
|
+
method: string;
|
|
48
|
+
pathname: string;
|
|
35
49
|
};
|
|
36
50
|
export type CrossProjectPolicyViolationReason = 'missing_envelope' | 'invalid_envelope' | 'missing_request_id' | 'namespace_not_allowed' | 'missing_operation_context' | 'operation_context_mismatch' | 'missing_operation_context_details' | 'invalid_operation_context_details' | 'operation_context_details_request_id_mismatch' | 'missing_operation_schema_hash' | 'missing_operation_version' | 'unknown_operation_contract' | 'operation_schema_hash_mismatch' | 'operation_version_mismatch' | 'producer_identity_mismatch';
|
|
37
51
|
export type CrossProjectPolicyViolation = {
|
|
@@ -75,4 +89,22 @@ export interface CrossProjectPolicyConfig {
|
|
|
75
89
|
*/
|
|
76
90
|
verifyProducerIdentity?: (headers: Record<string, unknown>) => string | undefined;
|
|
77
91
|
}
|
|
78
|
-
|
|
92
|
+
/** Serializable, fully-defaulted policy accepted by edge dispatchers. */
|
|
93
|
+
export type NormalizedCrossProjectPolicy = CrossProjectPolicyConfig & {
|
|
94
|
+
enabled: boolean;
|
|
95
|
+
requireEnvelope: boolean;
|
|
96
|
+
requireOperationContext: boolean;
|
|
97
|
+
requireOperationContextDetails: boolean;
|
|
98
|
+
requireOperationSchemaHash: boolean;
|
|
99
|
+
requireOperationVersion: boolean;
|
|
100
|
+
allowUnknownOperations: boolean;
|
|
101
|
+
expectedOperationContracts: Record<string, CrossProjectOperationContract>;
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* Resolves a concrete adapter request to the server-owned route template in
|
|
105
|
+
* the operation contract map. Effect receives requests after its BFF mount
|
|
106
|
+
* prefix has been stripped, so leading literal route segments may be mount
|
|
107
|
+
* prefixes. Ambiguous matches fail closed by retaining the concrete path.
|
|
108
|
+
*/
|
|
109
|
+
export declare const resolveCrossProjectRequestObservation: (request: CrossProjectRequestTarget, policy: Pick<CrossProjectPolicyConfig, 'expectedOperationContracts'>) => CrossProjectRequestObservation | undefined;
|
|
110
|
+
export declare const evaluateCrossProjectPolicy: (headers: Record<string, unknown>, policy?: CrossProjectPolicyConfig, observedRequest?: CrossProjectRequestObservation) => CrossProjectPolicyViolation | null;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { CrossProjectPolicyConfig, NormalizedCrossProjectPolicy } from './crossProjectPolicy';
|
|
2
2
|
import { type OperationContractSource } from './operationContracts';
|
|
3
3
|
/**
|
|
4
4
|
* Runtime input collected by BFF server adapters (hono/effect/...) before
|
|
@@ -25,16 +25,7 @@ export interface ResolveCrossProjectPolicyInput {
|
|
|
25
25
|
/**
|
|
26
26
|
* Fully-defaulted policy returned by {@link resolveCrossProjectPolicy}.
|
|
27
27
|
*/
|
|
28
|
-
export type ResolvedCrossProjectPolicy =
|
|
29
|
-
enabled: boolean;
|
|
30
|
-
requireEnvelope: boolean;
|
|
31
|
-
requireOperationContext: boolean;
|
|
32
|
-
requireOperationContextDetails: boolean;
|
|
33
|
-
requireOperationSchemaHash: boolean;
|
|
34
|
-
requireOperationVersion: boolean;
|
|
35
|
-
allowUnknownOperations: boolean;
|
|
36
|
-
expectedOperationContracts: Record<string, CrossProjectOperationContract>;
|
|
37
|
-
};
|
|
28
|
+
export type ResolvedCrossProjectPolicy = NormalizedCrossProjectPolicy;
|
|
38
29
|
/**
|
|
39
30
|
* Normalizes the user-facing cross-project policy config into the evaluator
|
|
40
31
|
* input shared by every BFF server adapter:
|
package/package.json
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"modern",
|
|
18
18
|
"modern.js"
|
|
19
19
|
],
|
|
20
|
-
"version": "3.8.
|
|
20
|
+
"version": "3.8.3-ultramodern.2",
|
|
21
21
|
"types": "./dist/types/index.d.ts",
|
|
22
22
|
"main": "./dist/cjs/index.js",
|
|
23
23
|
"exports": {
|
|
@@ -28,18 +28,26 @@
|
|
|
28
28
|
"require": "./dist/cjs/index.js"
|
|
29
29
|
},
|
|
30
30
|
"default": "./dist/cjs/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./security/cross-project-policy": {
|
|
33
|
+
"types": "./dist/types/security/crossProjectPolicy.d.ts",
|
|
34
|
+
"node": {
|
|
35
|
+
"import": "./dist/esm-node/security/crossProjectPolicy.mjs",
|
|
36
|
+
"require": "./dist/cjs/security/crossProjectPolicy.js"
|
|
37
|
+
},
|
|
38
|
+
"default": "./dist/esm/security/crossProjectPolicy.mjs"
|
|
31
39
|
}
|
|
32
40
|
},
|
|
33
41
|
"dependencies": {
|
|
34
|
-
"@modern-js/utils": "npm:@bleedingdev/modern-js-utils@3.8.
|
|
42
|
+
"@modern-js/utils": "npm:@bleedingdev/modern-js-utils@3.8.3-ultramodern.2",
|
|
35
43
|
"@swc/helpers": "^0.5.23",
|
|
36
44
|
"koa-compose": "^4.1.0",
|
|
37
45
|
"reflect-metadata": "^0.2.2",
|
|
38
46
|
"type-fest": "5.8.0"
|
|
39
47
|
},
|
|
40
48
|
"devDependencies": {
|
|
41
|
-
"@modern-js/bff-runtime": "npm:@bleedingdev/modern-js-bff-runtime@3.8.
|
|
42
|
-
"@modern-js/types": "npm:@bleedingdev/modern-js-types@3.8.
|
|
49
|
+
"@modern-js/bff-runtime": "npm:@bleedingdev/modern-js-bff-runtime@3.8.3-ultramodern.2",
|
|
50
|
+
"@modern-js/types": "npm:@bleedingdev/modern-js-types@3.8.3-ultramodern.2",
|
|
43
51
|
"@rslib/core": "0.23.2",
|
|
44
52
|
"@scripts/rstest-config": "2.66.0",
|
|
45
53
|
"@types/koa-compose": "^3.2.9",
|
|
@@ -63,7 +71,7 @@
|
|
|
63
71
|
},
|
|
64
72
|
"scripts": {
|
|
65
73
|
"dev": "rslib build --watch",
|
|
66
|
-
"build": "rslib build
|
|
74
|
+
"build": "rslib build",
|
|
67
75
|
"test": "rstest --passWithNoTests"
|
|
68
76
|
}
|
|
69
77
|
}
|