@open-mercato/shared 0.4.6-develop-af28b566dd → 0.4.6-develop-4d77832982

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.
@@ -0,0 +1,82 @@
1
+ let _interceptorEntries = null;
2
+ const GLOBAL_INTERCEPTOR_KEY = "__openMercatoApiInterceptors__";
3
+ function readGlobalInterceptors() {
4
+ try {
5
+ const value = globalThis[GLOBAL_INTERCEPTOR_KEY];
6
+ return Array.isArray(value) ? value : null;
7
+ } catch {
8
+ return null;
9
+ }
10
+ }
11
+ function writeGlobalInterceptors(entries) {
12
+ try {
13
+ ;
14
+ globalThis[GLOBAL_INTERCEPTOR_KEY] = entries;
15
+ } catch {
16
+ }
17
+ }
18
+ function registerApiInterceptors(entries) {
19
+ const flat = [];
20
+ entries.forEach((entry, moduleOrder) => {
21
+ entry.interceptors.forEach((interceptor, interceptorOrder) => {
22
+ flat.push({
23
+ moduleId: entry.moduleId,
24
+ interceptor,
25
+ moduleOrder,
26
+ interceptorOrder
27
+ });
28
+ });
29
+ });
30
+ _interceptorEntries = flat;
31
+ writeGlobalInterceptors(flat);
32
+ }
33
+ function getAllApiInterceptors() {
34
+ const globalEntries = readGlobalInterceptors();
35
+ if (globalEntries) return globalEntries;
36
+ if (!_interceptorEntries) return [];
37
+ return _interceptorEntries;
38
+ }
39
+ function routeMatches(targetRoute, routePath) {
40
+ if (targetRoute === "*") return true;
41
+ if (targetRoute.endsWith("/*")) {
42
+ const prefix = targetRoute.slice(0, -2);
43
+ return routePath === prefix || routePath.startsWith(`${prefix}/`);
44
+ }
45
+ return targetRoute === routePath;
46
+ }
47
+ const collisionWarnings = /* @__PURE__ */ new Set();
48
+ function getApiInterceptorsForRoute(routePath, method) {
49
+ const matching = getAllApiInterceptors().filter((entry) => {
50
+ const methods = entry.interceptor.methods ?? [];
51
+ return methods.includes(method) && routeMatches(entry.interceptor.targetRoute, routePath);
52
+ });
53
+ const sorted = matching.sort((a, b) => {
54
+ const byPriority = (b.interceptor.priority ?? 0) - (a.interceptor.priority ?? 0);
55
+ if (byPriority !== 0) return byPriority;
56
+ const byModule = a.moduleOrder - b.moduleOrder;
57
+ if (byModule !== 0) return byModule;
58
+ return a.interceptorOrder - b.interceptorOrder;
59
+ });
60
+ if (process.env.NODE_ENV !== "production") {
61
+ for (let i = 1; i < sorted.length; i++) {
62
+ const prev = sorted[i - 1];
63
+ const current = sorted[i];
64
+ const prevPriority = prev.interceptor.priority ?? 0;
65
+ const currentPriority = current.interceptor.priority ?? 0;
66
+ if (prevPriority !== currentPriority) continue;
67
+ const warningKey = `${routePath}:${method}:${prev.interceptor.id}:${current.interceptor.id}:${currentPriority}`;
68
+ if (collisionWarnings.has(warningKey)) continue;
69
+ collisionWarnings.add(warningKey);
70
+ console.warn(
71
+ `[UMES] Interceptors "${prev.interceptor.id}" and "${current.interceptor.id}" have the same priority (${currentPriority}) for route "${routePath}". Execution order is based on module registration order.`
72
+ );
73
+ }
74
+ }
75
+ return sorted;
76
+ }
77
+ export {
78
+ getAllApiInterceptors,
79
+ getApiInterceptorsForRoute,
80
+ registerApiInterceptors
81
+ };
82
+ //# sourceMappingURL=interceptor-registry.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/crud/interceptor-registry.ts"],
4
+ "sourcesContent": ["import type { ApiInterceptor, ApiInterceptorMethod, ApiInterceptorRegistryEntry } from './api-interceptor'\n\nlet _interceptorEntries: ApiInterceptorRegistryEntry[] | null = null\nconst GLOBAL_INTERCEPTOR_KEY = '__openMercatoApiInterceptors__'\n\nfunction readGlobalInterceptors(): ApiInterceptorRegistryEntry[] | null {\n try {\n const value = (globalThis as Record<string, unknown>)[GLOBAL_INTERCEPTOR_KEY]\n return Array.isArray(value) ? (value as ApiInterceptorRegistryEntry[]) : null\n } catch {\n return null\n }\n}\n\nfunction writeGlobalInterceptors(entries: ApiInterceptorRegistryEntry[]) {\n try {\n ;(globalThis as Record<string, unknown>)[GLOBAL_INTERCEPTOR_KEY] = entries\n } catch {\n // ignore global assignment failures\n }\n}\n\nexport function registerApiInterceptors(entries: Array<{ moduleId: string; interceptors: ApiInterceptor[] }>) {\n const flat: ApiInterceptorRegistryEntry[] = []\n entries.forEach((entry, moduleOrder) => {\n entry.interceptors.forEach((interceptor, interceptorOrder) => {\n flat.push({\n moduleId: entry.moduleId,\n interceptor,\n moduleOrder,\n interceptorOrder,\n })\n })\n })\n _interceptorEntries = flat\n writeGlobalInterceptors(flat)\n}\n\nexport function getAllApiInterceptors(): ApiInterceptorRegistryEntry[] {\n const globalEntries = readGlobalInterceptors()\n if (globalEntries) return globalEntries\n if (!_interceptorEntries) return []\n return _interceptorEntries\n}\n\nfunction routeMatches(targetRoute: string, routePath: string): boolean {\n if (targetRoute === '*') return true\n if (targetRoute.endsWith('/*')) {\n const prefix = targetRoute.slice(0, -2)\n return routePath === prefix || routePath.startsWith(`${prefix}/`)\n }\n return targetRoute === routePath\n}\n\nconst collisionWarnings = new Set<string>()\n\nexport function getApiInterceptorsForRoute(routePath: string, method: ApiInterceptorMethod): ApiInterceptorRegistryEntry[] {\n const matching = getAllApiInterceptors().filter((entry) => {\n const methods = entry.interceptor.methods ?? []\n return methods.includes(method) && routeMatches(entry.interceptor.targetRoute, routePath)\n })\n\n const sorted = matching.sort((a, b) => {\n const byPriority = (b.interceptor.priority ?? 0) - (a.interceptor.priority ?? 0)\n if (byPriority !== 0) return byPriority\n const byModule = a.moduleOrder - b.moduleOrder\n if (byModule !== 0) return byModule\n return a.interceptorOrder - b.interceptorOrder\n })\n\n if (process.env.NODE_ENV !== 'production') {\n for (let i = 1; i < sorted.length; i++) {\n const prev = sorted[i - 1]\n const current = sorted[i]\n const prevPriority = prev.interceptor.priority ?? 0\n const currentPriority = current.interceptor.priority ?? 0\n if (prevPriority !== currentPriority) continue\n const warningKey = `${routePath}:${method}:${prev.interceptor.id}:${current.interceptor.id}:${currentPriority}`\n if (collisionWarnings.has(warningKey)) continue\n collisionWarnings.add(warningKey)\n console.warn(\n `[UMES] Interceptors \"${prev.interceptor.id}\" and \"${current.interceptor.id}\" have the same priority (${currentPriority}) for route \"${routePath}\". Execution order is based on module registration order.`\n )\n }\n }\n\n return sorted\n}\n"],
5
+ "mappings": "AAEA,IAAI,sBAA4D;AAChE,MAAM,yBAAyB;AAE/B,SAAS,yBAA+D;AACtE,MAAI;AACF,UAAM,QAAS,WAAuC,sBAAsB;AAC5E,WAAO,MAAM,QAAQ,KAAK,IAAK,QAA0C;AAAA,EAC3E,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,wBAAwB,SAAwC;AACvE,MAAI;AACF;AAAC,IAAC,WAAuC,sBAAsB,IAAI;AAAA,EACrE,QAAQ;AAAA,EAER;AACF;AAEO,SAAS,wBAAwB,SAAsE;AAC5G,QAAM,OAAsC,CAAC;AAC7C,UAAQ,QAAQ,CAAC,OAAO,gBAAgB;AACtC,UAAM,aAAa,QAAQ,CAAC,aAAa,qBAAqB;AAC5D,WAAK,KAAK;AAAA,QACR,UAAU,MAAM;AAAA,QAChB;AAAA,QACA;AAAA,QACA;AAAA,MACF,CAAC;AAAA,IACH,CAAC;AAAA,EACH,CAAC;AACD,wBAAsB;AACtB,0BAAwB,IAAI;AAC9B;AAEO,SAAS,wBAAuD;AACrE,QAAM,gBAAgB,uBAAuB;AAC7C,MAAI,cAAe,QAAO;AAC1B,MAAI,CAAC,oBAAqB,QAAO,CAAC;AAClC,SAAO;AACT;AAEA,SAAS,aAAa,aAAqB,WAA4B;AACrE,MAAI,gBAAgB,IAAK,QAAO;AAChC,MAAI,YAAY,SAAS,IAAI,GAAG;AAC9B,UAAM,SAAS,YAAY,MAAM,GAAG,EAAE;AACtC,WAAO,cAAc,UAAU,UAAU,WAAW,GAAG,MAAM,GAAG;AAAA,EAClE;AACA,SAAO,gBAAgB;AACzB;AAEA,MAAM,oBAAoB,oBAAI,IAAY;AAEnC,SAAS,2BAA2B,WAAmB,QAA6D;AACzH,QAAM,WAAW,sBAAsB,EAAE,OAAO,CAAC,UAAU;AACzD,UAAM,UAAU,MAAM,YAAY,WAAW,CAAC;AAC9C,WAAO,QAAQ,SAAS,MAAM,KAAK,aAAa,MAAM,YAAY,aAAa,SAAS;AAAA,EAC1F,CAAC;AAED,QAAM,SAAS,SAAS,KAAK,CAAC,GAAG,MAAM;AACrC,UAAM,cAAc,EAAE,YAAY,YAAY,MAAM,EAAE,YAAY,YAAY;AAC9E,QAAI,eAAe,EAAG,QAAO;AAC7B,UAAM,WAAW,EAAE,cAAc,EAAE;AACnC,QAAI,aAAa,EAAG,QAAO;AAC3B,WAAO,EAAE,mBAAmB,EAAE;AAAA,EAChC,CAAC;AAED,MAAI,QAAQ,IAAI,aAAa,cAAc;AACzC,aAAS,IAAI,GAAG,IAAI,OAAO,QAAQ,KAAK;AACtC,YAAM,OAAO,OAAO,IAAI,CAAC;AACzB,YAAM,UAAU,OAAO,CAAC;AACxB,YAAM,eAAe,KAAK,YAAY,YAAY;AAClD,YAAM,kBAAkB,QAAQ,YAAY,YAAY;AACxD,UAAI,iBAAiB,gBAAiB;AACtC,YAAM,aAAa,GAAG,SAAS,IAAI,MAAM,IAAI,KAAK,YAAY,EAAE,IAAI,QAAQ,YAAY,EAAE,IAAI,eAAe;AAC7G,UAAI,kBAAkB,IAAI,UAAU,EAAG;AACvC,wBAAkB,IAAI,UAAU;AAChC,cAAQ;AAAA,QACN,wBAAwB,KAAK,YAAY,EAAE,UAAU,QAAQ,YAAY,EAAE,6BAA6B,eAAe,gBAAgB,SAAS;AAAA,MAClJ;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;",
6
+ "names": []
7
+ }
@@ -0,0 +1,175 @@
1
+ import { getApiInterceptorsForRoute } from "./interceptor-registry.js";
2
+ import { hasAllFeatures } from "../../security/features.js";
3
+ const DEFAULT_TIMEOUT_MS = 5e3;
4
+ function sanitizeObject(input) {
5
+ if (!input || typeof input !== "object") return void 0;
6
+ const clean = Object.fromEntries(Object.entries(input).filter(([, value]) => value !== void 0));
7
+ return Object.keys(clean).length > 0 ? clean : {};
8
+ }
9
+ function hasRequiredFeatures(features, userFeatures) {
10
+ return hasAllFeatures(userFeatures, features);
11
+ }
12
+ function timeoutPromise(ms, interceptorId) {
13
+ return new Promise((_, reject) => {
14
+ setTimeout(() => {
15
+ reject(new Error(`INTERCEPTOR_TIMEOUT:${interceptorId}`));
16
+ }, ms);
17
+ });
18
+ }
19
+ function isTimeoutError(error) {
20
+ return error instanceof Error && error.message.startsWith("INTERCEPTOR_TIMEOUT:");
21
+ }
22
+ async function runWithTimeout(task, timeoutMs, interceptorId) {
23
+ return Promise.race([task, timeoutPromise(timeoutMs, interceptorId)]);
24
+ }
25
+ function toErrorBody(interceptorId, error) {
26
+ const body = {
27
+ error: "Internal interceptor error"
28
+ };
29
+ if (process.env.NODE_ENV !== "production") {
30
+ body.interceptorId = interceptorId;
31
+ body.message = error instanceof Error ? error.message : String(error);
32
+ }
33
+ return body;
34
+ }
35
+ async function runApiInterceptorsBefore(args) {
36
+ const { routePath, method, context } = args;
37
+ let currentRequest = {
38
+ ...args.request,
39
+ body: sanitizeObject(args.request.body),
40
+ query: sanitizeObject(args.request.query),
41
+ headers: { ...args.request.headers ?? {} }
42
+ };
43
+ const metadataByInterceptor = {};
44
+ const interceptors = getApiInterceptorsForRoute(routePath, method);
45
+ for (const entry of interceptors) {
46
+ const interceptor = entry.interceptor;
47
+ if (!interceptor.before) continue;
48
+ if (!hasRequiredFeatures(interceptor.features, context.userFeatures)) continue;
49
+ try {
50
+ const timeoutMs = interceptor.timeoutMs ?? DEFAULT_TIMEOUT_MS;
51
+ const result = await runWithTimeout(
52
+ interceptor.before(currentRequest, context),
53
+ timeoutMs,
54
+ interceptor.id
55
+ );
56
+ const normalized = result ?? { ok: true };
57
+ if (!normalized.ok) {
58
+ const rejectBody = {
59
+ error: normalized.message ?? "Request blocked by API interceptor"
60
+ };
61
+ if (process.env.NODE_ENV !== "production") {
62
+ rejectBody.interceptorId = interceptor.id;
63
+ }
64
+ return {
65
+ ok: false,
66
+ statusCode: normalized.statusCode ?? 400,
67
+ body: rejectBody
68
+ };
69
+ }
70
+ if (normalized.headers) {
71
+ currentRequest = {
72
+ ...currentRequest,
73
+ headers: {
74
+ ...currentRequest.headers,
75
+ ...normalized.headers
76
+ }
77
+ };
78
+ }
79
+ if (normalized.body) {
80
+ currentRequest = {
81
+ ...currentRequest,
82
+ body: sanitizeObject(normalized.body)
83
+ };
84
+ }
85
+ if (normalized.query) {
86
+ currentRequest = {
87
+ ...currentRequest,
88
+ query: sanitizeObject(normalized.query)
89
+ };
90
+ }
91
+ metadataByInterceptor[interceptor.id] = normalized.metadata;
92
+ } catch (error) {
93
+ if (isTimeoutError(error)) {
94
+ const timeoutBody = { error: "Interceptor timeout" };
95
+ if (process.env.NODE_ENV !== "production") {
96
+ timeoutBody.interceptorId = interceptor.id;
97
+ }
98
+ return {
99
+ ok: false,
100
+ statusCode: 504,
101
+ body: timeoutBody
102
+ };
103
+ }
104
+ return {
105
+ ok: false,
106
+ statusCode: 500,
107
+ body: toErrorBody(interceptor.id, error)
108
+ };
109
+ }
110
+ }
111
+ return {
112
+ ok: true,
113
+ request: currentRequest,
114
+ metadataByInterceptor
115
+ };
116
+ }
117
+ async function runApiInterceptorsAfter(args) {
118
+ const { routePath, method, context } = args;
119
+ let body = { ...args.response.body ?? {} };
120
+ let headers = { ...args.response.headers ?? {} };
121
+ const interceptors = getApiInterceptorsForRoute(routePath, method);
122
+ for (const entry of interceptors) {
123
+ const interceptor = entry.interceptor;
124
+ if (!interceptor.after) continue;
125
+ if (!hasRequiredFeatures(interceptor.features, context.userFeatures)) continue;
126
+ try {
127
+ const timeoutMs = interceptor.timeoutMs ?? DEFAULT_TIMEOUT_MS;
128
+ const result = await runWithTimeout(
129
+ interceptor.after(
130
+ args.request,
131
+ { statusCode: args.response.statusCode, body, headers },
132
+ { ...context, metadata: args.metadataByInterceptor?.[interceptor.id] }
133
+ ),
134
+ timeoutMs,
135
+ interceptor.id
136
+ );
137
+ if (!result) continue;
138
+ if (result.replace && typeof result.replace === "object") {
139
+ body = { ...result.replace };
140
+ } else if (result.merge && typeof result.merge === "object") {
141
+ body = { ...body, ...result.merge };
142
+ }
143
+ } catch (error) {
144
+ if (isTimeoutError(error)) {
145
+ const timeoutBody = { error: "Interceptor timeout" };
146
+ if (process.env.NODE_ENV !== "production") {
147
+ timeoutBody.interceptorId = interceptor.id;
148
+ }
149
+ return {
150
+ ok: false,
151
+ statusCode: 504,
152
+ body: timeoutBody,
153
+ headers
154
+ };
155
+ }
156
+ return {
157
+ ok: false,
158
+ statusCode: 500,
159
+ body: toErrorBody(interceptor.id, error),
160
+ headers
161
+ };
162
+ }
163
+ }
164
+ return {
165
+ ok: true,
166
+ statusCode: args.response.statusCode,
167
+ body,
168
+ headers
169
+ };
170
+ }
171
+ export {
172
+ runApiInterceptorsAfter,
173
+ runApiInterceptorsBefore
174
+ };
175
+ //# sourceMappingURL=interceptor-runner.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/crud/interceptor-runner.ts"],
4
+ "sourcesContent": ["import type {\n ApiInterceptorMethod,\n InterceptorContext,\n InterceptorRequest,\n InterceptorResponse,\n InterceptorBeforeResult,\n} from './api-interceptor'\nimport { getApiInterceptorsForRoute } from './interceptor-registry'\nimport { hasAllFeatures } from '../../security/features'\n\nconst DEFAULT_TIMEOUT_MS = 5000\n\ntype BeforeRunOk = {\n ok: true\n request: InterceptorRequest\n metadataByInterceptor: Record<string, Record<string, unknown> | undefined>\n}\n\ntype BeforeRunFailed = {\n ok: false\n statusCode: number\n body: Record<string, unknown>\n}\n\nexport type RunInterceptorsBeforeResult = BeforeRunOk | BeforeRunFailed\n\nexport type RunInterceptorsAfterResult = {\n ok: boolean\n statusCode: number\n body: Record<string, unknown>\n headers: Record<string, string>\n}\n\nfunction sanitizeObject(input?: Record<string, unknown>): Record<string, unknown> | undefined {\n if (!input || typeof input !== 'object') return undefined\n const clean = Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined))\n return Object.keys(clean).length > 0 ? clean : {}\n}\n\nfunction hasRequiredFeatures(features: string[] | undefined, userFeatures: string[] | undefined): boolean {\n return hasAllFeatures(userFeatures, features)\n}\n\nfunction timeoutPromise(ms: number, interceptorId: string): Promise<never> {\n return new Promise((_, reject) => {\n setTimeout(() => {\n reject(new Error(`INTERCEPTOR_TIMEOUT:${interceptorId}`))\n }, ms)\n })\n}\n\nfunction isTimeoutError(error: unknown): boolean {\n return error instanceof Error && error.message.startsWith('INTERCEPTOR_TIMEOUT:')\n}\n\nasync function runWithTimeout<T>(\n task: Promise<T>,\n timeoutMs: number,\n interceptorId: string,\n): Promise<T> {\n return Promise.race([task, timeoutPromise(timeoutMs, interceptorId)])\n}\n\nfunction toErrorBody(interceptorId: string, error: unknown): Record<string, unknown> {\n const body: Record<string, unknown> = {\n error: 'Internal interceptor error',\n }\n if (process.env.NODE_ENV !== 'production') {\n body.interceptorId = interceptorId\n body.message = error instanceof Error ? error.message : String(error)\n }\n return body\n}\n\nexport async function runApiInterceptorsBefore(args: {\n routePath: string\n method: ApiInterceptorMethod\n request: InterceptorRequest\n context: Omit<InterceptorContext, 'metadata'>\n}): Promise<RunInterceptorsBeforeResult> {\n const { routePath, method, context } = args\n let currentRequest: InterceptorRequest = {\n ...args.request,\n body: sanitizeObject(args.request.body),\n query: sanitizeObject(args.request.query),\n headers: { ...(args.request.headers ?? {}) },\n }\n\n const metadataByInterceptor: Record<string, Record<string, unknown> | undefined> = {}\n const interceptors = getApiInterceptorsForRoute(routePath, method)\n\n for (const entry of interceptors) {\n const interceptor = entry.interceptor\n if (!interceptor.before) continue\n if (!hasRequiredFeatures(interceptor.features, context.userFeatures)) continue\n\n try {\n const timeoutMs = interceptor.timeoutMs ?? DEFAULT_TIMEOUT_MS\n const result = await runWithTimeout(\n interceptor.before(currentRequest, context),\n timeoutMs,\n interceptor.id,\n )\n\n const normalized: InterceptorBeforeResult = result ?? { ok: true }\n if (!normalized.ok) {\n const rejectBody: Record<string, unknown> = {\n error: normalized.message ?? 'Request blocked by API interceptor',\n }\n if (process.env.NODE_ENV !== 'production') {\n rejectBody.interceptorId = interceptor.id\n }\n return {\n ok: false,\n statusCode: normalized.statusCode ?? 400,\n body: rejectBody,\n }\n }\n\n if (normalized.headers) {\n currentRequest = {\n ...currentRequest,\n headers: {\n ...currentRequest.headers,\n ...normalized.headers,\n },\n }\n }\n if (normalized.body) {\n currentRequest = {\n ...currentRequest,\n body: sanitizeObject(normalized.body),\n }\n }\n if (normalized.query) {\n currentRequest = {\n ...currentRequest,\n query: sanitizeObject(normalized.query),\n }\n }\n metadataByInterceptor[interceptor.id] = normalized.metadata\n } catch (error) {\n if (isTimeoutError(error)) {\n const timeoutBody: Record<string, unknown> = { error: 'Interceptor timeout' }\n if (process.env.NODE_ENV !== 'production') {\n timeoutBody.interceptorId = interceptor.id\n }\n return {\n ok: false,\n statusCode: 504,\n body: timeoutBody,\n }\n }\n return {\n ok: false,\n statusCode: 500,\n body: toErrorBody(interceptor.id, error),\n }\n }\n }\n\n return {\n ok: true,\n request: currentRequest,\n metadataByInterceptor,\n }\n}\n\nexport async function runApiInterceptorsAfter(args: {\n routePath: string\n method: ApiInterceptorMethod\n request: InterceptorRequest\n response: InterceptorResponse\n context: Omit<InterceptorContext, 'metadata'>\n metadataByInterceptor?: Record<string, Record<string, unknown> | undefined>\n}): Promise<RunInterceptorsAfterResult> {\n const { routePath, method, context } = args\n let body: Record<string, unknown> = { ...(args.response.body ?? {}) }\n let headers: Record<string, string> = { ...(args.response.headers ?? {}) }\n\n const interceptors = getApiInterceptorsForRoute(routePath, method)\n for (const entry of interceptors) {\n const interceptor = entry.interceptor\n if (!interceptor.after) continue\n if (!hasRequiredFeatures(interceptor.features, context.userFeatures)) continue\n\n try {\n const timeoutMs = interceptor.timeoutMs ?? DEFAULT_TIMEOUT_MS\n const result = await runWithTimeout(\n interceptor.after(\n args.request,\n { statusCode: args.response.statusCode, body, headers },\n { ...context, metadata: args.metadataByInterceptor?.[interceptor.id] },\n ),\n timeoutMs,\n interceptor.id,\n )\n if (!result) continue\n if (result.replace && typeof result.replace === 'object') {\n body = { ...result.replace }\n } else if (result.merge && typeof result.merge === 'object') {\n body = { ...body, ...result.merge }\n }\n } catch (error) {\n if (isTimeoutError(error)) {\n const timeoutBody: Record<string, unknown> = { error: 'Interceptor timeout' }\n if (process.env.NODE_ENV !== 'production') {\n timeoutBody.interceptorId = interceptor.id\n }\n return {\n ok: false,\n statusCode: 504,\n body: timeoutBody,\n headers,\n }\n }\n return {\n ok: false,\n statusCode: 500,\n body: toErrorBody(interceptor.id, error),\n headers,\n }\n }\n }\n\n return {\n ok: true,\n statusCode: args.response.statusCode,\n body,\n headers,\n }\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.4.6-develop-af28b566dd";
1
+ const APP_VERSION = "0.4.6-develop-4d77832982";
2
2
  const appVersion = APP_VERSION;
3
3
  export {
4
4
  APP_VERSION,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/version.ts"],
4
- "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.4.6-develop-af28b566dd'\nexport const appVersion = APP_VERSION\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.4.6-develop-4d77832982'\nexport const appVersion = APP_VERSION\n"],
5
5
  "mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,79 @@
1
+ import * as React from "react";
2
+ import { hasAllFeatures } from "../../security/features.js";
3
+ const GLOBAL_COMPONENT_REGISTRY_KEY = "__openMercatoComponentRegistry__";
4
+ function getState() {
5
+ const globalValue = globalThis[GLOBAL_COMPONENT_REGISTRY_KEY];
6
+ if (globalValue && typeof globalValue === "object") {
7
+ const typed = globalValue;
8
+ if (typed.components instanceof Map && Array.isArray(typed.overrides)) {
9
+ return typed;
10
+ }
11
+ }
12
+ const initial = {
13
+ components: /* @__PURE__ */ new Map(),
14
+ overrides: []
15
+ };
16
+ globalThis[GLOBAL_COMPONENT_REGISTRY_KEY] = initial;
17
+ return initial;
18
+ }
19
+ function registerComponent(entry) {
20
+ const state = getState();
21
+ state.components.set(entry.id, entry);
22
+ }
23
+ function registerComponentOverrides(overrides) {
24
+ const state = getState();
25
+ state.overrides = [...overrides];
26
+ }
27
+ function getComponentEntry(componentId) {
28
+ const state = getState();
29
+ return state.components.get(componentId) ?? null;
30
+ }
31
+ function getComponentOverrides(componentId, userFeatures) {
32
+ const state = getState();
33
+ const relevant = state.overrides.filter((override) => {
34
+ if (override.target.componentId !== componentId) return false;
35
+ if (override.features && override.features.length > 0) {
36
+ if (!hasAllFeatures(userFeatures, override.features)) return false;
37
+ }
38
+ return true;
39
+ });
40
+ return relevant.sort((a, b) => a.priority - b.priority);
41
+ }
42
+ function resolveRegisteredComponent(componentId, fallback, userFeatures) {
43
+ const overrides = getComponentOverrides(componentId, userFeatures);
44
+ let resolved = fallback;
45
+ for (const override of overrides) {
46
+ if ("replacement" in override) {
47
+ resolved = override.replacement;
48
+ continue;
49
+ }
50
+ if ("wrapper" in override) {
51
+ resolved = override.wrapper(resolved);
52
+ continue;
53
+ }
54
+ if ("propsTransform" in override) {
55
+ const transform = override.propsTransform;
56
+ const Current = resolved;
57
+ resolved = ((props) => {
58
+ const transformed = transform(props);
59
+ return React.createElement(Current, transformed);
60
+ });
61
+ }
62
+ }
63
+ return resolved;
64
+ }
65
+ const ComponentReplacementHandles = {
66
+ page: (path) => `page:${path}`,
67
+ dataTable: (tableId) => `data-table:${tableId}`,
68
+ crudForm: (entityId) => `crud-form:${entityId}`,
69
+ section: (scope, sectionId) => `section:${scope}.${sectionId}`
70
+ };
71
+ export {
72
+ ComponentReplacementHandles,
73
+ getComponentEntry,
74
+ getComponentOverrides,
75
+ registerComponent,
76
+ registerComponentOverrides,
77
+ resolveRegisteredComponent
78
+ };
79
+ //# sourceMappingURL=component-registry.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/modules/widgets/component-registry.ts"],
4
+ "sourcesContent": ["import * as React from 'react'\nimport type { ComponentType, LazyExoticComponent } from 'react'\nimport type { ZodType } from 'zod'\nimport { hasAllFeatures } from '../../security/features'\n\nexport type ComponentRegistryEntry<TProps = unknown> = {\n id: string\n component: ComponentType<TProps>\n metadata: {\n module: string\n description?: string\n propsSchema?: ZodType<TProps>\n }\n}\n\nexport type ComponentOverride<TProps = unknown> = {\n target: { componentId: string }\n priority: number\n features?: string[]\n metadata?: {\n module?: string\n }\n} & (\n | {\n replacement: LazyExoticComponent<ComponentType<TProps>> | ComponentType<TProps>\n propsSchema: ZodType<TProps>\n }\n | {\n wrapper: (Original: ComponentType<TProps>) => ComponentType<TProps>\n }\n | {\n propsTransform: (props: TProps) => TProps\n }\n)\n\ntype RuntimeState = {\n components: Map<string, ComponentRegistryEntry>\n overrides: ComponentOverride[]\n}\n\nconst GLOBAL_COMPONENT_REGISTRY_KEY = '__openMercatoComponentRegistry__'\n\nfunction getState(): RuntimeState {\n const globalValue = (globalThis as Record<string, unknown>)[GLOBAL_COMPONENT_REGISTRY_KEY]\n if (globalValue && typeof globalValue === 'object') {\n const typed = globalValue as RuntimeState\n if (typed.components instanceof Map && Array.isArray(typed.overrides)) {\n return typed\n }\n }\n const initial: RuntimeState = {\n components: new Map<string, ComponentRegistryEntry>(),\n overrides: [],\n }\n ;(globalThis as Record<string, unknown>)[GLOBAL_COMPONENT_REGISTRY_KEY] = initial\n return initial\n}\n\nexport function registerComponent<TProps = unknown>(entry: ComponentRegistryEntry<TProps>) {\n const state = getState()\n state.components.set(entry.id, entry as ComponentRegistryEntry)\n}\n\nexport function registerComponentOverrides(overrides: ComponentOverride[]) {\n const state = getState()\n state.overrides = [...overrides]\n}\n\nexport function getComponentEntry(componentId: string): ComponentRegistryEntry | null {\n const state = getState()\n return state.components.get(componentId) ?? null\n}\n\nexport function getComponentOverrides(componentId: string, userFeatures?: readonly string[]): ComponentOverride[] {\n const state = getState()\n const relevant = state.overrides.filter((override) => {\n if (override.target.componentId !== componentId) return false\n if (override.features && override.features.length > 0) {\n if (!hasAllFeatures(userFeatures, override.features)) return false\n }\n return true\n })\n return relevant.sort((a, b) => a.priority - b.priority)\n}\n\nexport function resolveRegisteredComponent<TProps>(\n componentId: string,\n fallback: ComponentType<TProps>,\n userFeatures?: readonly string[],\n): ComponentType<TProps> {\n const overrides = getComponentOverrides(componentId, userFeatures)\n let resolved: ComponentType<TProps> = fallback\n for (const override of overrides) {\n if ('replacement' in override) {\n resolved = override.replacement as ComponentType<TProps>\n continue\n }\n if ('wrapper' in override) {\n resolved = override.wrapper(resolved as ComponentType<unknown>) as ComponentType<TProps>\n continue\n }\n if ('propsTransform' in override) {\n const transform = override.propsTransform as (props: TProps) => TProps\n const Current = resolved\n resolved = ((props: TProps) => {\n const transformed = transform(props)\n return React.createElement(Current as ComponentType<Record<string, unknown>>, transformed as Record<string, unknown>)\n }) as ComponentType<TProps>\n }\n }\n return resolved\n}\n\nexport const ComponentReplacementHandles = {\n page: (path: string) => `page:${path}`,\n dataTable: (tableId: string) => `data-table:${tableId}`,\n crudForm: (entityId: string) => `crud-form:${entityId}`,\n section: (scope: string, sectionId: string) => `section:${scope}.${sectionId}`,\n} as const\n"],
5
+ "mappings": "AAAA,YAAY,WAAW;AAGvB,SAAS,sBAAsB;AAqC/B,MAAM,gCAAgC;AAEtC,SAAS,WAAyB;AAChC,QAAM,cAAe,WAAuC,6BAA6B;AACzF,MAAI,eAAe,OAAO,gBAAgB,UAAU;AAClD,UAAM,QAAQ;AACd,QAAI,MAAM,sBAAsB,OAAO,MAAM,QAAQ,MAAM,SAAS,GAAG;AACrE,aAAO;AAAA,IACT;AAAA,EACF;AACA,QAAM,UAAwB;AAAA,IAC5B,YAAY,oBAAI,IAAoC;AAAA,IACpD,WAAW,CAAC;AAAA,EACd;AACC,EAAC,WAAuC,6BAA6B,IAAI;AAC1E,SAAO;AACT;AAEO,SAAS,kBAAoC,OAAuC;AACzF,QAAM,QAAQ,SAAS;AACvB,QAAM,WAAW,IAAI,MAAM,IAAI,KAA+B;AAChE;AAEO,SAAS,2BAA2B,WAAgC;AACzE,QAAM,QAAQ,SAAS;AACvB,QAAM,YAAY,CAAC,GAAG,SAAS;AACjC;AAEO,SAAS,kBAAkB,aAAoD;AACpF,QAAM,QAAQ,SAAS;AACvB,SAAO,MAAM,WAAW,IAAI,WAAW,KAAK;AAC9C;AAEO,SAAS,sBAAsB,aAAqB,cAAuD;AAChH,QAAM,QAAQ,SAAS;AACvB,QAAM,WAAW,MAAM,UAAU,OAAO,CAAC,aAAa;AACpD,QAAI,SAAS,OAAO,gBAAgB,YAAa,QAAO;AACxD,QAAI,SAAS,YAAY,SAAS,SAAS,SAAS,GAAG;AACrD,UAAI,CAAC,eAAe,cAAc,SAAS,QAAQ,EAAG,QAAO;AAAA,IAC/D;AACA,WAAO;AAAA,EACT,CAAC;AACD,SAAO,SAAS,KAAK,CAAC,GAAG,MAAM,EAAE,WAAW,EAAE,QAAQ;AACxD;AAEO,SAAS,2BACd,aACA,UACA,cACuB;AACvB,QAAM,YAAY,sBAAsB,aAAa,YAAY;AACjE,MAAI,WAAkC;AACtC,aAAW,YAAY,WAAW;AAChC,QAAI,iBAAiB,UAAU;AAC7B,iBAAW,SAAS;AACpB;AAAA,IACF;AACA,QAAI,aAAa,UAAU;AACzB,iBAAW,SAAS,QAAQ,QAAkC;AAC9D;AAAA,IACF;AACA,QAAI,oBAAoB,UAAU;AAChC,YAAM,YAAY,SAAS;AAC3B,YAAM,UAAU;AAChB,kBAAY,CAAC,UAAkB;AAC7B,cAAM,cAAc,UAAU,KAAK;AACnC,eAAO,MAAM,cAAc,SAAmD,WAAsC;AAAA,MACtH;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;AAEO,MAAM,8BAA8B;AAAA,EACzC,MAAM,CAAC,SAAiB,QAAQ,IAAI;AAAA,EACpC,WAAW,CAAC,YAAoB,cAAc,OAAO;AAAA,EACrD,UAAU,CAAC,aAAqB,aAAa,QAAQ;AAAA,EACrD,SAAS,CAAC,OAAe,cAAsB,WAAW,KAAK,IAAI,SAAS;AAC9E;",
6
+ "names": []
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/shared",
3
- "version": "0.4.6-develop-af28b566dd",
3
+ "version": "0.4.6-develop-4d77832982",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -155,6 +155,8 @@ export async function loadBootstrapData(appRoot?: string): Promise<BootstrapData
155
155
  dashboardWidgetEntries: [],
156
156
  injectionWidgetEntries: [],
157
157
  injectionTables: [],
158
+ interceptorEntries: [],
159
+ componentOverrideEntries: [],
158
160
  }
159
161
  }
160
162
 
@@ -7,6 +7,8 @@ import { registerEntityFields } from '../encryption/entityFields'
7
7
  import { registerSearchModuleConfigs } from '../../modules/search'
8
8
  import { registerAnalyticsModuleConfigs } from '../../modules/analytics'
9
9
  import { registerResponseEnrichers } from '../crud/enricher-registry'
10
+ import { registerApiInterceptors } from '../crud/interceptor-registry'
11
+ import { registerComponentOverrides } from '../../modules/widgets/component-registry'
10
12
 
11
13
  let _bootstrapped = false
12
14
 
@@ -60,6 +62,17 @@ export function createBootstrap(data: BootstrapData, options: BootstrapOptions =
60
62
  registerResponseEnrichers(data.enricherEntries)
61
63
  }
62
64
 
65
+ // === 6c. API interceptors (for CRUD route interception) ===
66
+ if (data.interceptorEntries) {
67
+ registerApiInterceptors(data.interceptorEntries)
68
+ }
69
+
70
+ // === 6d. Component overrides (for page/component replacement) ===
71
+ if (data.componentOverrideEntries) {
72
+ const allOverrides = data.componentOverrideEntries.flatMap((entry) => entry.componentOverrides ?? [])
73
+ registerComponentOverrides(allOverrides)
74
+ }
75
+
63
76
  // === 7-8. UI Widgets and Optional packages (async to avoid circular deps) ===
64
77
  // Store the promise so CLI context can await it
65
78
  _asyncRegistrationPromise = registerWidgetsAndOptionalPackages(data, options)
@@ -19,6 +19,16 @@ export interface EnricherBootstrapEntry {
19
19
  enrichers: import('../../lib/crud/response-enricher').ResponseEnricher[]
20
20
  }
21
21
 
22
+ export interface InterceptorBootstrapEntry {
23
+ moduleId: string
24
+ interceptors: import('../../lib/crud/api-interceptor').ApiInterceptor[]
25
+ }
26
+
27
+ export interface ComponentOverrideBootstrapEntry {
28
+ moduleId: string
29
+ componentOverrides: import('../../modules/widgets/component-registry').ComponentOverride[]
30
+ }
31
+
22
32
  export interface BootstrapData {
23
33
  modules: Module[]
24
34
  entities: OrmEntity[]
@@ -31,6 +41,8 @@ export interface BootstrapData {
31
41
  searchModuleConfigs: SearchModuleConfig[]
32
42
  analyticsModuleConfigs?: AnalyticsModuleConfig[]
33
43
  enricherEntries?: EnricherBootstrapEntry[]
44
+ interceptorEntries?: InterceptorBootstrapEntry[]
45
+ componentOverrideEntries?: ComponentOverrideBootstrapEntry[]
34
46
  }
35
47
 
36
48
  export interface BootstrapOptions {
@@ -1,4 +1,5 @@
1
1
  import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
2
+ import { registerApiInterceptors } from '@open-mercato/shared/lib/crud/interceptor-registry'
2
3
  import { z } from 'zod'
3
4
 
4
5
  // ---- Mocks ----
@@ -127,6 +128,7 @@ describe('CRUD Factory', () => {
127
128
  execute: jest.fn(async () => ({ result: {}, logEntry: { id: 'log-1' } })),
128
129
  }
129
130
  crudMutationGuardService = null
131
+ registerApiInterceptors([])
130
132
  })
131
133
 
132
134
  const querySchema = z.object({
@@ -368,4 +370,68 @@ describe('CRUD Factory', () => {
368
370
  // to avoid duplicates (see commit 3f999f35).
369
371
  expect(mockDataEngine.emitOrmEntityEvent).not.toHaveBeenCalled()
370
372
  })
373
+
374
+ it('POST is blocked by interceptor before hook', async () => {
375
+ registerApiInterceptors([
376
+ {
377
+ moduleId: 'example',
378
+ interceptors: [
379
+ {
380
+ id: 'example.block-title',
381
+ targetRoute: 'example/todos',
382
+ methods: ['POST'],
383
+ async before(request) {
384
+ const title = request.body?.title
385
+ if (typeof title === 'string' && title.includes('BLOCKED')) {
386
+ return { ok: false, statusCode: 422, message: 'Blocked by interceptor' }
387
+ }
388
+ return { ok: true }
389
+ },
390
+ },
391
+ ],
392
+ },
393
+ ])
394
+
395
+ const res = await route.POST(new Request('http://x/api/example/todos', {
396
+ method: 'POST',
397
+ body: JSON.stringify({ title: 'BLOCKED item', is_done: false }),
398
+ headers: { 'content-type': 'application/json' },
399
+ }))
400
+ expect(res.status).toBe(422)
401
+ const payload = await res.json()
402
+ expect(payload).toMatchObject({
403
+ error: 'Blocked by interceptor',
404
+ interceptorId: 'example.block-title',
405
+ })
406
+ })
407
+
408
+ it('GET response is augmented by interceptor after hook', async () => {
409
+ registerApiInterceptors([
410
+ {
411
+ moduleId: 'example',
412
+ interceptors: [
413
+ {
414
+ id: 'example.add-response-flag',
415
+ targetRoute: 'example/todos',
416
+ methods: ['GET'],
417
+ async after(_request, response) {
418
+ return {
419
+ merge: {
420
+ _interceptor: {
421
+ ok: true,
422
+ count: Array.isArray(response.body.items) ? response.body.items.length : 0,
423
+ },
424
+ },
425
+ }
426
+ },
427
+ },
428
+ ],
429
+ },
430
+ ])
431
+
432
+ const res = await route.GET(new Request('http://x/api/example/todos?page=1&pageSize=10&sortField=id&sortDir=asc'))
433
+ expect(res.status).toBe(200)
434
+ const body = await res.json()
435
+ expect(body._interceptor).toEqual({ ok: true, count: 1 })
436
+ })
371
437
  })
@@ -0,0 +1,65 @@
1
+ import type { AwilixContainer } from 'awilix'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+
4
+ export type ApiInterceptorMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
5
+
6
+ export type InterceptorRequest = {
7
+ method: ApiInterceptorMethod
8
+ url: string
9
+ body?: Record<string, unknown>
10
+ query?: Record<string, unknown>
11
+ headers: Record<string, string>
12
+ }
13
+
14
+ export type InterceptorResponse = {
15
+ statusCode: number
16
+ body: Record<string, unknown>
17
+ headers: Record<string, string>
18
+ }
19
+
20
+ export type InterceptorContext = {
21
+ userId: string
22
+ organizationId: string
23
+ tenantId: string
24
+ em: EntityManager
25
+ container: AwilixContainer
26
+ userFeatures?: string[]
27
+ metadata?: Record<string, unknown>
28
+ }
29
+
30
+ export type InterceptorBeforeResult = {
31
+ ok: boolean
32
+ body?: Record<string, unknown>
33
+ query?: Record<string, unknown>
34
+ headers?: Record<string, string>
35
+ message?: string
36
+ statusCode?: number
37
+ metadata?: Record<string, unknown>
38
+ }
39
+
40
+ export type InterceptorAfterResult = {
41
+ merge?: Record<string, unknown>
42
+ replace?: Record<string, unknown>
43
+ }
44
+
45
+ export type ApiInterceptor = {
46
+ id: string
47
+ targetRoute: string
48
+ methods: ApiInterceptorMethod[]
49
+ priority?: number
50
+ features?: string[]
51
+ timeoutMs?: number
52
+ before?: (request: InterceptorRequest, context: InterceptorContext) => Promise<InterceptorBeforeResult>
53
+ after?: (
54
+ request: InterceptorRequest,
55
+ response: InterceptorResponse,
56
+ context: InterceptorContext,
57
+ ) => Promise<InterceptorAfterResult>
58
+ }
59
+
60
+ export type ApiInterceptorRegistryEntry = {
61
+ moduleId: string
62
+ interceptor: ApiInterceptor
63
+ moduleOrder: number
64
+ interceptorOrder: number
65
+ }