@bleedingdev/modern-js-bff-core 3.8.3-ultramodern.1 → 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.
@@ -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.operationVersion + 1
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.PARITY_REQUEST_ID,
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.PARITY_REQUEST_ID
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 = `${shared_js_namespaceObject.PARITY_PRODUCER_REQUEST_ID}:parity`;
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 expectedContract = expectedContracts[`${method}:${routePath}`] || expectedContracts[`operation:${operationId}`];
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.operationVersion + 1
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: PARITY_REQUEST_ID,
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: PARITY_REQUEST_ID
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 = `${PARITY_PRODUCER_REQUEST_ID}:parity`;
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 expectedContract = expectedContracts[`${method}:${routePath}`] || expectedContracts[`operation:${operationId}`];
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.operationVersion + 1
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: PARITY_REQUEST_ID,
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: PARITY_REQUEST_ID
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 = `${PARITY_PRODUCER_REQUEST_ID}:parity`;
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 expectedContract = expectedContracts[`${method}:${routePath}`] || expectedContracts[`operation:${operationId}`];
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
- export declare const evaluateCrossProjectPolicy: (headers: Record<string, unknown>, policy?: CrossProjectPolicyConfig) => CrossProjectPolicyViolation | null;
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 { CrossProjectOperationContract, CrossProjectPolicyConfig } from './crossProjectPolicy';
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 = CrossProjectPolicyConfig & {
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.3-ultramodern.1",
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.3-ultramodern.1",
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.3-ultramodern.1",
42
- "@modern-js/types": "npm:@bleedingdev/modern-js-types@3.8.3-ultramodern.1",
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 && pnpm -w tsgo:dts \"$PWD\"",
74
+ "build": "rslib build",
67
75
  "test": "rstest --passWithNoTests"
68
76
  }
69
77
  }