@bleedingdev/modern-js-bff-core 3.2.0-ultramodern.12 → 3.2.0-ultramodern.121
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/index.js +140 -0
- package/dist/cjs/adapter-kit/parity.js +546 -0
- package/dist/cjs/api.js +9 -5
- package/dist/cjs/client/generateClient.js +74 -17
- package/dist/cjs/client/index.js +9 -5
- package/dist/cjs/client/result.js +13 -9
- package/dist/cjs/contracts/eventContracts.js +14 -10
- package/dist/cjs/errors/http.js +13 -9
- package/dist/cjs/index.js +83 -41
- package/dist/cjs/operators/http.js +9 -5
- package/dist/cjs/router/constants.js +9 -5
- package/dist/cjs/router/index.js +12 -8
- package/dist/cjs/router/utils.js +9 -5
- package/dist/cjs/security/crossProjectPolicy.js +25 -13
- package/dist/cjs/security/operationContracts.js +155 -59
- package/dist/cjs/security/resolveCrossProjectPolicy.js +65 -0
- package/dist/cjs/types.js +18 -13
- package/dist/cjs/utils/alias.js +9 -5
- package/dist/cjs/utils/debug.js +9 -5
- package/dist/cjs/utils/index.js +12 -8
- package/dist/cjs/utils/meta.js +15 -11
- package/dist/cjs/utils/storage.js +9 -5
- package/dist/cjs/utils/validate.js +9 -5
- package/dist/esm/adapter-kit/index.mjs +75 -0
- package/dist/esm/adapter-kit/parity.mjs +490 -0
- package/dist/esm/client/generateClient.mjs +66 -13
- package/dist/esm/index.mjs +2 -0
- package/dist/esm/rslib-runtime.mjs +18 -0
- package/dist/esm/security/crossProjectPolicy.mjs +10 -2
- package/dist/esm/security/operationContracts.mjs +111 -37
- package/dist/esm/security/resolveCrossProjectPolicy.mjs +27 -0
- package/dist/esm-node/adapter-kit/index.mjs +76 -0
- package/dist/esm-node/adapter-kit/parity.mjs +491 -0
- package/dist/esm-node/client/generateClient.mjs +66 -13
- package/dist/esm-node/index.mjs +2 -0
- package/dist/esm-node/rslib-runtime.mjs +19 -0
- package/dist/esm-node/security/crossProjectPolicy.mjs +10 -2
- package/dist/esm-node/security/operationContracts.mjs +111 -37
- package/dist/esm-node/security/resolveCrossProjectPolicy.mjs +28 -0
- package/dist/types/adapter-kit/index.d.ts +90 -0
- package/dist/types/adapter-kit/parity.d.ts +102 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/security/crossProjectPolicy.d.ts +40 -1
- package/dist/types/security/operationContracts.d.ts +60 -4
- package/dist/types/security/resolveCrossProjectPolicy.d.ts +48 -0
- package/package.json +12 -10
|
@@ -1,6 +1,85 @@
|
|
|
1
1
|
import "node:module";
|
|
2
2
|
import { createHash } from "crypto";
|
|
3
|
+
import "reflect-metadata";
|
|
4
|
+
import { HttpMetadata } from "../types.mjs";
|
|
5
|
+
import { __webpack_require__ } from "../rslib-runtime.mjs";
|
|
6
|
+
import * as __rspack_external_zod from "zod";
|
|
7
|
+
__webpack_require__.add({
|
|
8
|
+
zod (module) {
|
|
9
|
+
module.exports = __rspack_external_zod;
|
|
10
|
+
}
|
|
11
|
+
});
|
|
3
12
|
const DEFAULT_OPERATION_VERSION = 1;
|
|
13
|
+
const deriveOperationVersion = (packageVersion)=>{
|
|
14
|
+
if ('string' != typeof packageVersion) return DEFAULT_OPERATION_VERSION;
|
|
15
|
+
const match = packageVersion.trim().match(/^v?(\d+)\./);
|
|
16
|
+
if (!match) return DEFAULT_OPERATION_VERSION;
|
|
17
|
+
const major = Number.parseInt(match[1], 10);
|
|
18
|
+
return Number.isInteger(major) && major >= 0 ? major : DEFAULT_OPERATION_VERSION;
|
|
19
|
+
};
|
|
20
|
+
const stableStringify = (value)=>{
|
|
21
|
+
if (Array.isArray(value)) return `[${value.map((item)=>stableStringify(item)).join(',')}]`;
|
|
22
|
+
if (value && 'object' == typeof value) {
|
|
23
|
+
const entries = Object.entries(value).filter(([, entryValue])=>void 0 !== entryValue).sort(([a], [b])=>a.localeCompare(b)).map(([key, entryValue])=>`${JSON.stringify(key)}:${stableStringify(entryValue)}`);
|
|
24
|
+
return `{${entries.join(',')}}`;
|
|
25
|
+
}
|
|
26
|
+
return JSON.stringify(value) ?? 'null';
|
|
27
|
+
};
|
|
28
|
+
const sha256 = (text)=>createHash('sha256').update(text).digest('hex');
|
|
29
|
+
let cachedZodToJSONSchema;
|
|
30
|
+
const resolveZodToJSONSchema = ()=>{
|
|
31
|
+
if (void 0 !== cachedZodToJSONSchema) return cachedZodToJSONSchema;
|
|
32
|
+
try {
|
|
33
|
+
const zod = __webpack_require__("zod");
|
|
34
|
+
const candidate = zod?.toJSONSchema ?? zod?.z?.toJSONSchema;
|
|
35
|
+
cachedZodToJSONSchema = 'function' == typeof candidate ? candidate : null;
|
|
36
|
+
} catch {
|
|
37
|
+
cachedZodToJSONSchema = null;
|
|
38
|
+
}
|
|
39
|
+
return cachedZodToJSONSchema;
|
|
40
|
+
};
|
|
41
|
+
const INPUT_SCHEMA_METADATA_KEYS = [
|
|
42
|
+
HttpMetadata.Data,
|
|
43
|
+
HttpMetadata.Query,
|
|
44
|
+
HttpMetadata.Params,
|
|
45
|
+
HttpMetadata.Headers,
|
|
46
|
+
HttpMetadata.Files
|
|
47
|
+
];
|
|
48
|
+
const serializeSchema = (schema)=>{
|
|
49
|
+
const toJSONSchema = resolveZodToJSONSchema();
|
|
50
|
+
if (toJSONSchema) try {
|
|
51
|
+
return toJSONSchema(schema, {
|
|
52
|
+
io: 'input',
|
|
53
|
+
unrepresentable: 'any'
|
|
54
|
+
});
|
|
55
|
+
} catch {}
|
|
56
|
+
return {
|
|
57
|
+
__unserializableSchema: true
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
const serializeOperationSchemas = (handler)=>{
|
|
61
|
+
if ('function' != typeof handler) return;
|
|
62
|
+
const serialized = {};
|
|
63
|
+
for (const metadataKey of INPUT_SCHEMA_METADATA_KEYS){
|
|
64
|
+
let schema;
|
|
65
|
+
try {
|
|
66
|
+
schema = Reflect.getMetadata(metadataKey, handler);
|
|
67
|
+
} catch {
|
|
68
|
+
schema = void 0;
|
|
69
|
+
}
|
|
70
|
+
if (null != schema) serialized[metadataKey] = serializeSchema(schema);
|
|
71
|
+
}
|
|
72
|
+
return Object.keys(serialized).length > 0 ? serialized : void 0;
|
|
73
|
+
};
|
|
74
|
+
const createOperationContractHash = (operation, requestId)=>sha256(stableStringify({
|
|
75
|
+
httpMethod: String(operation.httpMethod || '').toUpperCase(),
|
|
76
|
+
name: operation.name,
|
|
77
|
+
requestId,
|
|
78
|
+
routePath: operation.routePath,
|
|
79
|
+
...operation.schemas ? {
|
|
80
|
+
schemas: operation.schemas
|
|
81
|
+
} : {}
|
|
82
|
+
}));
|
|
4
83
|
const createOperationEntries = (handlers)=>handlers.map((item)=>({
|
|
5
84
|
name: item.name,
|
|
6
85
|
httpMethod: String(item.httpMethod || '').toUpperCase(),
|
|
@@ -10,49 +89,44 @@ const createOperationEntries = (handlers)=>handlers.map((item)=>({
|
|
|
10
89
|
const keyB = `${b.routePath}:${b.httpMethod}:${b.name}`;
|
|
11
90
|
return keyA.localeCompare(keyB);
|
|
12
91
|
});
|
|
13
|
-
const createOperationSchemaHash = (operationEntries, requestId)=>
|
|
14
|
-
operations:
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
92
|
+
const createOperationSchemaHash = (operationEntries, requestId)=>sha256(stableStringify({
|
|
93
|
+
operations: [
|
|
94
|
+
...operationEntries
|
|
95
|
+
].map((item)=>({
|
|
96
|
+
hash: item.schemaHash ?? createOperationContractHash({
|
|
97
|
+
name: item.name,
|
|
98
|
+
httpMethod: item.httpMethod,
|
|
99
|
+
routePath: item.routePath
|
|
100
|
+
}, requestId)
|
|
101
|
+
})).sort((a, b)=>a.hash.localeCompare(b.hash)),
|
|
19
102
|
requestId
|
|
20
|
-
}))
|
|
21
|
-
const buildOperationContractMap = ({ handlers, requestId })=>{
|
|
103
|
+
}));
|
|
104
|
+
const buildOperationContractMap = ({ handlers, requestId, operationVersion })=>{
|
|
22
105
|
const normalizedRequestId = 'string' == typeof requestId && requestId.trim().length > 0 ? requestId.trim() : 'default';
|
|
23
|
-
const
|
|
106
|
+
const normalizedOperationVersion = 'number' == typeof operationVersion && Number.isInteger(operationVersion) ? operationVersion : DEFAULT_OPERATION_VERSION;
|
|
107
|
+
const contracts = {};
|
|
24
108
|
handlers.forEach((item)=>{
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
group.push({
|
|
109
|
+
const httpMethod = String(item.httpMethod || '').toUpperCase();
|
|
110
|
+
const schemaHash = createOperationContractHash({
|
|
28
111
|
name: item.name,
|
|
29
|
-
httpMethod
|
|
112
|
+
httpMethod,
|
|
113
|
+
routePath: item.routePath,
|
|
114
|
+
schemas: serializeOperationSchemas(item.handler)
|
|
115
|
+
}, normalizedRequestId);
|
|
116
|
+
const operationId = `${normalizedRequestId}:${item.name}`;
|
|
117
|
+
const contract = {
|
|
118
|
+
requestId: normalizedRequestId,
|
|
119
|
+
operationVersion: normalizedOperationVersion,
|
|
120
|
+
schemaHash,
|
|
121
|
+
method: httpMethod,
|
|
30
122
|
routePath: item.routePath,
|
|
123
|
+
operationId,
|
|
124
|
+
handlerName: item.name,
|
|
31
125
|
filename: item.filename
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const contracts = {};
|
|
36
|
-
byModule.forEach((moduleEntries)=>{
|
|
37
|
-
const entries = createOperationEntries(moduleEntries);
|
|
38
|
-
const schemaHash = createOperationSchemaHash(entries, normalizedRequestId);
|
|
39
|
-
const filename = moduleEntries[0]?.filename;
|
|
40
|
-
entries.forEach((entry)=>{
|
|
41
|
-
const operationId = `${normalizedRequestId}:${entry.name}`;
|
|
42
|
-
const contract = {
|
|
43
|
-
requestId: normalizedRequestId,
|
|
44
|
-
operationVersion: DEFAULT_OPERATION_VERSION,
|
|
45
|
-
schemaHash,
|
|
46
|
-
method: entry.httpMethod,
|
|
47
|
-
routePath: entry.routePath,
|
|
48
|
-
operationId,
|
|
49
|
-
handlerName: entry.name,
|
|
50
|
-
filename
|
|
51
|
-
};
|
|
52
|
-
contracts[`${entry.httpMethod}:${entry.routePath}`] = contract;
|
|
53
|
-
contracts[`operation:${operationId}`] = contract;
|
|
54
|
-
});
|
|
126
|
+
};
|
|
127
|
+
contracts[`${httpMethod}:${item.routePath}`] = contract;
|
|
128
|
+
contracts[`operation:${operationId}`] = contract;
|
|
55
129
|
});
|
|
56
130
|
return contracts;
|
|
57
131
|
};
|
|
58
|
-
export { DEFAULT_OPERATION_VERSION, buildOperationContractMap, createOperationEntries, createOperationSchemaHash };
|
|
132
|
+
export { DEFAULT_OPERATION_VERSION, buildOperationContractMap, createOperationContractHash, createOperationEntries, createOperationSchemaHash, deriveOperationVersion, serializeOperationSchemas };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import "node:module";
|
|
2
|
+
import { buildOperationContractMap } from "./operationContracts.mjs";
|
|
3
|
+
const resolveCrossProjectPolicy = (input)=>{
|
|
4
|
+
const { crossProjectPolicy, handlers, requestId, isCrossProjectServer, operationVersion } = input;
|
|
5
|
+
if (!crossProjectPolicy && !isCrossProjectServer) return;
|
|
6
|
+
const policy = crossProjectPolicy ?? {};
|
|
7
|
+
const effectiveRequestId = 'string' == typeof requestId && requestId.trim().length > 0 ? requestId : 'default';
|
|
8
|
+
const generatedContracts = buildOperationContractMap({
|
|
9
|
+
handlers,
|
|
10
|
+
requestId: effectiveRequestId,
|
|
11
|
+
operationVersion
|
|
12
|
+
});
|
|
13
|
+
return {
|
|
14
|
+
...policy,
|
|
15
|
+
enabled: policy.enabled ?? Boolean(isCrossProjectServer),
|
|
16
|
+
requireEnvelope: policy.requireEnvelope ?? true,
|
|
17
|
+
requireOperationContext: policy.requireOperationContext ?? true,
|
|
18
|
+
requireOperationContextDetails: policy.requireOperationContextDetails ?? true,
|
|
19
|
+
requireOperationSchemaHash: policy.requireOperationSchemaHash ?? true,
|
|
20
|
+
requireOperationVersion: policy.requireOperationVersion ?? true,
|
|
21
|
+
allowUnknownOperations: policy.allowUnknownOperations ?? false,
|
|
22
|
+
expectedOperationContracts: {
|
|
23
|
+
...policy.expectedOperationContracts ?? {},
|
|
24
|
+
...generatedContracts
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
export { resolveCrossProjectPolicy };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import type { ResponseMeta } from '../operators/http';
|
|
3
|
+
import type { APIHandlerInfo, ApiHandler } from '../router';
|
|
4
|
+
import { type CrossProjectPolicyConfig, type CrossProjectPolicyViolation } from '../security/crossProjectPolicy';
|
|
5
|
+
import { HttpMethod } from '../types';
|
|
6
|
+
/** Lowercase route-registration method shared by express/koa/hono routers. */
|
|
7
|
+
export type ApiRouteMethod = Lowercase<`${HttpMethod}`>;
|
|
8
|
+
/**
|
|
9
|
+
* Maps an `APIHandlerInfo.httpMethod` onto the lowercase router method.
|
|
10
|
+
* Unknown methods fail fast with a descriptive error instead of the
|
|
11
|
+
* `app[method] is not a function` TypeError the adapters used to throw.
|
|
12
|
+
*/
|
|
13
|
+
export declare const toApiRouteMethod: (httpMethod: APIHandlerInfo['httpMethod']) => ApiRouteMethod;
|
|
14
|
+
/** Route middlewares attached by BFF operators via reflect metadata. */
|
|
15
|
+
export declare const getRouteMiddlewares: <Middleware = unknown>(handler: ApiHandler) => Middleware[];
|
|
16
|
+
export type ApiRoutePlanEntry<Middleware = unknown> = {
|
|
17
|
+
method: ApiRouteMethod;
|
|
18
|
+
routePath: string;
|
|
19
|
+
handler: ApiHandler;
|
|
20
|
+
middlewares: Middleware[];
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Computes the framework-agnostic registration plan for a set of API
|
|
24
|
+
* handlers: registration order, lowercase route method and operator
|
|
25
|
+
* middlewares. Adapters only translate each entry into framework calls.
|
|
26
|
+
*/
|
|
27
|
+
export declare const planApiRoutes: <Middleware = unknown>(handlerInfos: APIHandlerInfo[]) => ApiRoutePlanEntry<Middleware>[];
|
|
28
|
+
/**
|
|
29
|
+
* Marker used by `@modern-js/bff-runtime` schema handlers. Re-declared here
|
|
30
|
+
* (value-compatible) so adapters do not need a runtime dependency on
|
|
31
|
+
* `@modern-js/bff-runtime` just to detect the handler mode.
|
|
32
|
+
*/
|
|
33
|
+
export declare const HANDLER_WITH_SCHEMA = "HANDLER_WITH_SCHEMA";
|
|
34
|
+
export declare const isSchemaApiHandler: (handler: unknown) => boolean;
|
|
35
|
+
export type ApiHandlerMode = 'meta' | 'schema' | 'inputParamsDecider' | 'plain';
|
|
36
|
+
/**
|
|
37
|
+
* Detects how an API handler expects to be invoked. The probe order (meta →
|
|
38
|
+
* schema → input-params-decider → plain) is shared by every adapter.
|
|
39
|
+
*
|
|
40
|
+
* Adapters call this once at route-registration time, not per request:
|
|
41
|
+
* meta/schema markers are attached by decorators at module load, so a handler
|
|
42
|
+
* marked after registration would not be picked up.
|
|
43
|
+
*/
|
|
44
|
+
export declare const getApiHandlerMode: (handler: ApiHandler) => ApiHandlerMode;
|
|
45
|
+
/** Result envelope produced by `@modern-js/bff-runtime` schema handlers. */
|
|
46
|
+
export type SchemaHandlerResult = {
|
|
47
|
+
type: 'HandleSuccess';
|
|
48
|
+
value: unknown;
|
|
49
|
+
} | {
|
|
50
|
+
type: 'InputValidationError' | 'OutputValidationError';
|
|
51
|
+
message: unknown;
|
|
52
|
+
};
|
|
53
|
+
export type SchemaHandlerHttpOutcome = {
|
|
54
|
+
success: boolean;
|
|
55
|
+
status: number;
|
|
56
|
+
body: unknown;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Maps a schema-handler result onto the HTTP outcome both adapters must
|
|
60
|
+
* produce: 200/value on success, 400/message on input validation errors and
|
|
61
|
+
* 500/message for any other failure.
|
|
62
|
+
*/
|
|
63
|
+
export declare const mapSchemaHandlerResult: (result: SchemaHandlerResult) => SchemaHandlerHttpOutcome;
|
|
64
|
+
/**
|
|
65
|
+
* Reads the response metadata (headers/redirect/status) attached to a meta
|
|
66
|
+
* handler. Returns an empty list when no metadata is present so adapters can
|
|
67
|
+
* iterate unconditionally.
|
|
68
|
+
*/
|
|
69
|
+
export declare const getResponseMetaList: (handler: ApiHandler) => ResponseMeta[];
|
|
70
|
+
export type ApiHandlerInput = {
|
|
71
|
+
params: Record<string, unknown>;
|
|
72
|
+
} & Record<string, unknown>;
|
|
73
|
+
/**
|
|
74
|
+
* Positional invocation convention for plain function handlers: route params
|
|
75
|
+
* in declaration order followed by the full input object.
|
|
76
|
+
*/
|
|
77
|
+
export declare const buildPositionalHandlerArgs: (input: ApiHandlerInput) => unknown[];
|
|
78
|
+
export type CrossProjectPolicyDenial = {
|
|
79
|
+
status: number;
|
|
80
|
+
body: {
|
|
81
|
+
code: CrossProjectPolicyViolation['code'];
|
|
82
|
+
reason: CrossProjectPolicyViolation['reason'];
|
|
83
|
+
message: string;
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* Evaluates the cross-project policy for a request and, on violation,
|
|
88
|
+
* returns the exact HTTP status and JSON body every adapter must send.
|
|
89
|
+
*/
|
|
90
|
+
export declare const checkCrossProjectPolicy: (headers: Record<string, unknown>, policy: CrossProjectPolicyConfig | undefined) => CrossProjectPolicyDenial | null;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { APIHandlerInfo } from '../router';
|
|
2
|
+
import type { CrossProjectPolicyViolationReason } from '../security/crossProjectPolicy';
|
|
3
|
+
/**
|
|
4
|
+
* Adapter parity (conformance) kit.
|
|
5
|
+
*
|
|
6
|
+
* One shared table of scenarios executed against every BFF server adapter
|
|
7
|
+
* in its own test harness. Each scenario asserts the adapters produce
|
|
8
|
+
* identical observable results: HTTP status, payload value and
|
|
9
|
+
* policy-rejection reason.
|
|
10
|
+
*
|
|
11
|
+
* The express/koa adapters were removed from the fork, and this is internal
|
|
12
|
+
* test support rather than a package subpath. Their expectations are retained
|
|
13
|
+
* in the per-adapter drift pins as documentation of the historical behavior.
|
|
14
|
+
* The live executable consumer of this table is the hono lane test
|
|
15
|
+
* (`@modern-js/plugin-bff` runs it against `createHonoRoutes` plus the
|
|
16
|
+
* cross-project policy middleware), while bff-core tests validate the table
|
|
17
|
+
* shape and assertion helpers.
|
|
18
|
+
*
|
|
19
|
+
* Transport details intentionally NOT asserted: express serialized scalar
|
|
20
|
+
* bodies as JSON while koa sent `text/plain`; {@link toParityResult}
|
|
21
|
+
* normalizes both to the decoded payload value before comparison.
|
|
22
|
+
*
|
|
23
|
+
* Intentionally OUT OF SCOPE (known, accepted adapter drift — do not add
|
|
24
|
+
* scenarios without deciding the drift first):
|
|
25
|
+
* - operator route-middlewares: express applied them, koa ignored them;
|
|
26
|
+
* - multipart/form-data: payload shapes differ per body parser;
|
|
27
|
+
* - undefined-returning plain handlers are pinned via a per-adapter
|
|
28
|
+
* scenario below: express ended the response 200/empty, koa served its
|
|
29
|
+
* stock 404 ("Not Found"), hono serves its stock "404 Not Found";
|
|
30
|
+
* - farrow schema-mode handlers are pinned per-adapter below: express/koa
|
|
31
|
+
* unwrapped the result envelope (200/400/500), the hono lane has no
|
|
32
|
+
* schema-mode unwrapping and passes the raw envelope through.
|
|
33
|
+
*/
|
|
34
|
+
export declare const PARITY_REQUEST_ID = "crm";
|
|
35
|
+
export declare const PARITY_PRODUCER_REQUEST_ID = "crm.producer-a";
|
|
36
|
+
/**
|
|
37
|
+
* Handler fixtures registered in both adapters before running the table.
|
|
38
|
+
*/
|
|
39
|
+
export declare const createParityApiHandlerInfos: () => APIHandlerInfo[];
|
|
40
|
+
/**
|
|
41
|
+
* `bff` config slice for the policy-enabled parity server. All `require*`
|
|
42
|
+
* switches stay at their strict defaults.
|
|
43
|
+
*/
|
|
44
|
+
export declare const createParityBffConfig: () => {
|
|
45
|
+
requestId: string;
|
|
46
|
+
crossProjectPolicy: {
|
|
47
|
+
enabled: boolean;
|
|
48
|
+
allowedNamespaces: string[];
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
export type ParityAdapterId = 'express' | 'koa' | 'hono';
|
|
52
|
+
export type ParityExpectation = {
|
|
53
|
+
kind: 'payload';
|
|
54
|
+
status: number;
|
|
55
|
+
payload: unknown;
|
|
56
|
+
} | {
|
|
57
|
+
kind: 'denied';
|
|
58
|
+
status: number;
|
|
59
|
+
reason: CrossProjectPolicyViolationReason;
|
|
60
|
+
} | {
|
|
61
|
+
/** Pinned, intentional adapter drift: each adapter has its own expectation. */
|
|
62
|
+
kind: 'perAdapter';
|
|
63
|
+
expectations: Record<ParityAdapterId, {
|
|
64
|
+
status: number;
|
|
65
|
+
payload: unknown;
|
|
66
|
+
}>;
|
|
67
|
+
};
|
|
68
|
+
export type AdapterParityScenario = {
|
|
69
|
+
name: string;
|
|
70
|
+
/** Run against the policy-enabled server instead of the open one. */
|
|
71
|
+
policy: boolean;
|
|
72
|
+
request: {
|
|
73
|
+
method: 'get' | 'post' | 'patch';
|
|
74
|
+
path: string;
|
|
75
|
+
headers?: Record<string, string>;
|
|
76
|
+
body?: unknown;
|
|
77
|
+
};
|
|
78
|
+
expected: ParityExpectation;
|
|
79
|
+
};
|
|
80
|
+
export declare const createAdapterParityScenarios: () => AdapterParityScenario[];
|
|
81
|
+
/** Structural slice of a supertest response used for normalization. */
|
|
82
|
+
export type ParityHttpResponse = {
|
|
83
|
+
status: number;
|
|
84
|
+
/** Content-type mime, e.g. `application/json`. */
|
|
85
|
+
type: string;
|
|
86
|
+
body: unknown;
|
|
87
|
+
text: string;
|
|
88
|
+
};
|
|
89
|
+
export type AdapterParityResult = {
|
|
90
|
+
status: number;
|
|
91
|
+
payload: unknown;
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* Normalizes a raw HTTP response to the observable payload value so JSON
|
|
95
|
+
* (express) and text (koa) encodings of the same scalar compare equal.
|
|
96
|
+
*/
|
|
97
|
+
export declare const toParityResult: (res: ParityHttpResponse) => AdapterParityResult;
|
|
98
|
+
/**
|
|
99
|
+
* Framework-agnostic assertion: throws a descriptive error when the adapter
|
|
100
|
+
* response deviates from the scenario expectation.
|
|
101
|
+
*/
|
|
102
|
+
export declare const assertParityResult: (scenario: AdapterParityScenario, res: ParityHttpResponse, adapter?: ParityAdapterId) => void;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export * from './adapter-kit';
|
|
1
2
|
export { Api } from './api';
|
|
2
3
|
export * from './client';
|
|
3
4
|
export type * from './compatible';
|
|
@@ -7,5 +8,6 @@ export * from './operators/http';
|
|
|
7
8
|
export * from './router';
|
|
8
9
|
export * from './security/crossProjectPolicy';
|
|
9
10
|
export * from './security/operationContracts';
|
|
11
|
+
export * from './security/resolveCrossProjectPolicy';
|
|
10
12
|
export * from './types';
|
|
11
13
|
export { createStorage, getRelativeRuntimePath, HANDLER_WITH_META, INPUT_PARAMS_DECIDER, isInputParamsDeciderHandler, isWithMetaHandler, registerPaths, } from './utils';
|
|
@@ -1,3 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-project BFF policy evaluator.
|
|
3
|
+
*
|
|
4
|
+
* THREAT MODEL — read before relying on this module:
|
|
5
|
+
*
|
|
6
|
+
* Every header this evaluator inspects (envelope, operation id, operation
|
|
7
|
+
* context details) is constructed by the CLIENT from public, open-source
|
|
8
|
+
* formats. Without an out-of-band identity binding the checks below are a
|
|
9
|
+
* version-skew and misconfiguration gate, not an authentication or
|
|
10
|
+
* authorization boundary: any caller can echo an allowed `requestId` and a
|
|
11
|
+
* matching operation context.
|
|
12
|
+
*
|
|
13
|
+
* To turn `allowedNamespaces` into a real control, supply
|
|
14
|
+
* {@link CrossProjectPolicyConfig.verifyProducerIdentity}: a server-side
|
|
15
|
+
* hook that derives the producer namespace from a VERIFIED channel (mTLS
|
|
16
|
+
* peer identity, gateway-authenticated JWT claims, service-mesh headers
|
|
17
|
+
* stripped at the edge, ...). When the hook is present, the client-asserted
|
|
18
|
+
* namespace must match the verified namespace and the allowlist is checked
|
|
19
|
+
* against the verified value — the envelope is no longer trusted for the
|
|
20
|
+
* authorization decision.
|
|
21
|
+
*
|
|
22
|
+
* Client-side counterparts in `@modern-js/create-request` (identity binding,
|
|
23
|
+
* operation contract validation) are developer-experience aids that fail
|
|
24
|
+
* fast in well-behaved clients; they protect nothing against a malicious
|
|
25
|
+
* caller.
|
|
26
|
+
*/
|
|
1
27
|
export declare const BFF_ENVELOPE_HEADER = "x-modernjs-bff-envelope";
|
|
2
28
|
export declare const BFF_OPERATION_CONTEXT_HEADER = "x-operation-id";
|
|
3
29
|
export declare const BFF_OPERATION_CONTEXT_DETAIL_HEADER = "x-modernjs-bff-operation-context";
|
|
@@ -5,7 +31,7 @@ export type CrossProjectOperationContract = {
|
|
|
5
31
|
schemaHash?: string;
|
|
6
32
|
operationVersion?: number;
|
|
7
33
|
};
|
|
8
|
-
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';
|
|
34
|
+
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';
|
|
9
35
|
export type CrossProjectPolicyViolation = {
|
|
10
36
|
code: 'BFF_CROSS_PROJECT_POLICY_DENIED';
|
|
11
37
|
reason: CrossProjectPolicyViolationReason;
|
|
@@ -26,5 +52,18 @@ export interface CrossProjectPolicyConfig {
|
|
|
26
52
|
expectedOperationContracts?: Record<string, CrossProjectOperationContract>;
|
|
27
53
|
allowUnknownOperations?: boolean;
|
|
28
54
|
denyStatus?: number;
|
|
55
|
+
/**
|
|
56
|
+
* Server-side hook binding the producer namespace to a VERIFIED identity
|
|
57
|
+
* channel (mTLS peer, gateway-authenticated JWT, mesh identity headers).
|
|
58
|
+
*
|
|
59
|
+
* When provided, the namespace asserted by the client envelope must match
|
|
60
|
+
* the namespace returned by this hook, and `allowedNamespaces` is checked
|
|
61
|
+
* against the verified value instead of the client-asserted one. Returning
|
|
62
|
+
* `undefined` (identity could not be verified) denies the request.
|
|
63
|
+
*
|
|
64
|
+
* Without this hook the namespace checks are advisory only — see the
|
|
65
|
+
* module-level threat model.
|
|
66
|
+
*/
|
|
67
|
+
verifyProducerIdentity?: (headers: Record<string, unknown>) => string | undefined;
|
|
29
68
|
}
|
|
30
69
|
export declare const evaluateCrossProjectPolicy: (headers: Record<string, unknown>, policy?: CrossProjectPolicyConfig) => CrossProjectPolicyViolation | null;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import type { ApiHandler } from '../router/types';
|
|
2
3
|
export type OperationContractEntry = {
|
|
3
4
|
name: string;
|
|
4
5
|
httpMethod: string;
|
|
@@ -16,13 +17,68 @@ export type OperationContractDefinition = {
|
|
|
16
17
|
};
|
|
17
18
|
export type OperationContractMap = Record<string, OperationContractDefinition>;
|
|
18
19
|
export declare const DEFAULT_OPERATION_VERSION = 1;
|
|
20
|
+
/**
|
|
21
|
+
* Derives the operation version from a producer package version: the semver
|
|
22
|
+
* major is the contract version, so consumers regenerated against an older
|
|
23
|
+
* producer major fail the `operation_version_mismatch` gate instead of
|
|
24
|
+
* silently calling an incompatible API.
|
|
25
|
+
*
|
|
26
|
+
* Falls back to {@link DEFAULT_OPERATION_VERSION} when no parseable version
|
|
27
|
+
* is available.
|
|
28
|
+
*/
|
|
29
|
+
export declare const deriveOperationVersion: (packageVersion?: unknown) => number;
|
|
30
|
+
/**
|
|
31
|
+
* Serializes the zod input schemas attached to an operator-decorated handler
|
|
32
|
+
* (`Data`/`Query`/`Params`/`Headers`/`Upload` metadata) into JSON-schema
|
|
33
|
+
* documents. Returns `undefined` for handlers without schema metadata
|
|
34
|
+
* (plain handlers, farrow schema-mode handlers).
|
|
35
|
+
*/
|
|
36
|
+
export declare const serializeOperationSchemas: (handler: ApiHandler | undefined) => Record<string, unknown> | undefined;
|
|
37
|
+
export type OperationContractHashInput = OperationContractEntry & {
|
|
38
|
+
/** Serialized schema documents; omit for schema-less operations. */
|
|
39
|
+
schemas?: Record<string, unknown> | undefined;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Per-operation contract hash. The hash covers the operation identity
|
|
43
|
+
* (name, method, route, producer requestId) plus the serialized input
|
|
44
|
+
* schemas, so:
|
|
45
|
+
*
|
|
46
|
+
* - changing an input schema changes the hash of exactly that operation;
|
|
47
|
+
* - reordering routes or adding unrelated operations never rotates the hash
|
|
48
|
+
* of other operations (each operation is hashed independently).
|
|
49
|
+
*/
|
|
50
|
+
export declare const createOperationContractHash: (operation: OperationContractHashInput, requestId: string) => string;
|
|
19
51
|
export declare const createOperationEntries: (handlers: Array<{
|
|
20
52
|
name: string;
|
|
21
53
|
httpMethod: string;
|
|
22
54
|
routePath: string;
|
|
23
55
|
}>) => OperationContractEntry[];
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Aggregate hash over a set of per-operation contract hashes. Used for the
|
|
58
|
+
* module-level `operationSchemaHash` manifest export; stable across route
|
|
59
|
+
* reordering because the per-operation hashes are sorted before hashing.
|
|
60
|
+
*/
|
|
61
|
+
export declare const createOperationSchemaHash: (operationEntries: Array<OperationContractEntry & {
|
|
62
|
+
schemaHash?: string;
|
|
63
|
+
}>, requestId: string) => string;
|
|
64
|
+
export type OperationContractSource = {
|
|
65
|
+
name: string;
|
|
66
|
+
httpMethod: string;
|
|
67
|
+
routePath: string;
|
|
68
|
+
filename?: string;
|
|
69
|
+
/**
|
|
70
|
+
* Optional handler function used to serialize schema metadata. Sources
|
|
71
|
+
* without a handler (e.g. reflected Effect HttpApi endpoints) hash the
|
|
72
|
+
* route identity only.
|
|
73
|
+
*/
|
|
74
|
+
handler?: ApiHandler;
|
|
75
|
+
};
|
|
76
|
+
export declare const buildOperationContractMap: ({ handlers, requestId, operationVersion, }: {
|
|
77
|
+
handlers: OperationContractSource[];
|
|
27
78
|
requestId?: string;
|
|
79
|
+
/**
|
|
80
|
+
* Producer contract version, usually `deriveOperationVersion(pkg.version)`.
|
|
81
|
+
* Defaults to {@link DEFAULT_OPERATION_VERSION}.
|
|
82
|
+
*/
|
|
83
|
+
operationVersion?: number;
|
|
28
84
|
}) => OperationContractMap;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { CrossProjectOperationContract, CrossProjectPolicyConfig } from './crossProjectPolicy';
|
|
2
|
+
import { type OperationContractSource } from './operationContracts';
|
|
3
|
+
/**
|
|
4
|
+
* Runtime input collected by BFF server adapters (hono/effect/...) before
|
|
5
|
+
* the cross-project policy can be enforced.
|
|
6
|
+
*
|
|
7
|
+
* `crossProjectPolicy` accepts the raw `bff.crossProjectPolicy` user config —
|
|
8
|
+
* `BffCrossProjectPolicyUserConfig` is a structural subset of
|
|
9
|
+
* `CrossProjectPolicyConfig`, so no casts are required at the call site.
|
|
10
|
+
*/
|
|
11
|
+
export interface ResolveCrossProjectPolicyInput {
|
|
12
|
+
crossProjectPolicy?: CrossProjectPolicyConfig;
|
|
13
|
+
/** Registered API handlers used to derive the operation-contract map. */
|
|
14
|
+
handlers: OperationContractSource[];
|
|
15
|
+
/** Logical producer ID (`bff.requestId`). */
|
|
16
|
+
requestId?: string;
|
|
17
|
+
/** Marker injected by generated cross-project SDK plugins. */
|
|
18
|
+
isCrossProjectServer?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Producer contract version (usually derived from the producer
|
|
21
|
+
* package.json major via `deriveOperationVersion`).
|
|
22
|
+
*/
|
|
23
|
+
operationVersion?: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Fully-defaulted policy returned by {@link resolveCrossProjectPolicy}.
|
|
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
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Normalizes the user-facing cross-project policy config into the evaluator
|
|
40
|
+
* input shared by every BFF server adapter:
|
|
41
|
+
*
|
|
42
|
+
* - returns `undefined` when neither a policy nor the cross-project server
|
|
43
|
+
* marker is present (policy middleware must not be installed);
|
|
44
|
+
* - applies the documented defaults for all `require*` switches;
|
|
45
|
+
* - merges generated operation contracts (derived from the registered
|
|
46
|
+
* handlers) over any user-provided `expectedOperationContracts`.
|
|
47
|
+
*/
|
|
48
|
+
export declare const resolveCrossProjectPolicy: (input: ResolveCrossProjectPolicyInput) => ResolvedCrossProjectPolicy | undefined;
|
package/package.json
CHANGED
|
@@ -17,12 +17,13 @@
|
|
|
17
17
|
"modern",
|
|
18
18
|
"modern.js"
|
|
19
19
|
],
|
|
20
|
-
"version": "3.2.0-ultramodern.
|
|
20
|
+
"version": "3.2.0-ultramodern.121",
|
|
21
21
|
"types": "./dist/types/index.d.ts",
|
|
22
22
|
"main": "./dist/cjs/index.js",
|
|
23
23
|
"exports": {
|
|
24
24
|
".": {
|
|
25
25
|
"types": "./dist/types/index.d.ts",
|
|
26
|
+
"modern:source": "./src/index.ts",
|
|
26
27
|
"node": {
|
|
27
28
|
"import": "./dist/esm-node/index.mjs",
|
|
28
29
|
"require": "./dist/cjs/index.js"
|
|
@@ -31,21 +32,22 @@
|
|
|
31
32
|
}
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
34
|
-
"@swc/helpers": "^0.5.
|
|
35
|
+
"@swc/helpers": "^0.5.23",
|
|
35
36
|
"koa-compose": "^4.1.0",
|
|
36
37
|
"reflect-metadata": "^0.2.2",
|
|
37
|
-
"type-fest": "5.
|
|
38
|
-
"@modern-js/utils": "npm:@bleedingdev/modern-js-utils@3.2.0-ultramodern.
|
|
38
|
+
"type-fest": "5.7.0",
|
|
39
|
+
"@modern-js/utils": "npm:@bleedingdev/modern-js-utils@3.2.0-ultramodern.121"
|
|
39
40
|
},
|
|
40
41
|
"devDependencies": {
|
|
41
|
-
"@rslib/core": "0.
|
|
42
|
+
"@rslib/core": "0.22.0",
|
|
42
43
|
"@types/koa-compose": "^3.2.9",
|
|
43
|
-
"@types/node": "^25.
|
|
44
|
-
"@typescript/native-preview": "7.0.0-dev.
|
|
44
|
+
"@types/node": "^25.9.3",
|
|
45
|
+
"@typescript/native-preview": "7.0.0-dev.20260610.1",
|
|
45
46
|
"tsconfig-paths": "^4.2.0",
|
|
46
47
|
"zod": "^4.4.3",
|
|
47
|
-
"@
|
|
48
|
-
"@modern-js/types": "npm:@bleedingdev/modern-js-types@3.2.0-ultramodern.
|
|
48
|
+
"@modern-js/bff-runtime": "npm:@bleedingdev/modern-js-bff-runtime@3.2.0-ultramodern.121",
|
|
49
|
+
"@modern-js/types": "npm:@bleedingdev/modern-js-types@3.2.0-ultramodern.121",
|
|
50
|
+
"@scripts/rstest-config": "2.66.0"
|
|
49
51
|
},
|
|
50
52
|
"peerDependencies": {
|
|
51
53
|
"tsconfig-paths": "^4.2.0",
|
|
@@ -62,7 +64,7 @@
|
|
|
62
64
|
},
|
|
63
65
|
"scripts": {
|
|
64
66
|
"dev": "rslib build --watch",
|
|
65
|
-
"build": "rslib build",
|
|
67
|
+
"build": "rslib build && pnpm -w tsgo:dts \"$PWD\"",
|
|
66
68
|
"test": "rstest --passWithNoTests"
|
|
67
69
|
}
|
|
68
70
|
}
|