@open-mercato/shared 0.4.9-develop-fefbbe0979 → 0.4.9-develop-db9ecc46fc

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.
Files changed (32) hide show
  1. package/dist/lib/auth/server.js +1 -1
  2. package/dist/lib/auth/server.js.map +1 -1
  3. package/dist/lib/crud/custom-route-interceptor.js +27 -0
  4. package/dist/lib/crud/custom-route-interceptor.js.map +7 -0
  5. package/dist/lib/crud/errors.js +8 -1
  6. package/dist/lib/crud/errors.js.map +2 -2
  7. package/dist/lib/crud/factory.js +2 -2
  8. package/dist/lib/crud/factory.js.map +2 -2
  9. package/dist/lib/middleware/page-executor.js +42 -0
  10. package/dist/lib/middleware/page-executor.js.map +7 -0
  11. package/dist/lib/version.js +1 -1
  12. package/dist/lib/version.js.map +1 -1
  13. package/dist/modules/generators/index.js +1 -0
  14. package/dist/modules/generators/index.js.map +7 -0
  15. package/dist/modules/generators/types.js +1 -0
  16. package/dist/modules/generators/types.js.map +7 -0
  17. package/dist/modules/middleware/page.js +15 -0
  18. package/dist/modules/middleware/page.js.map +7 -0
  19. package/dist/modules/widgets/component-registry.js +12 -1
  20. package/dist/modules/widgets/component-registry.js.map +2 -2
  21. package/package.json +5 -1
  22. package/src/lib/auth/server.ts +1 -1
  23. package/src/lib/crud/__tests__/custom-route-interceptor.test.ts +180 -0
  24. package/src/lib/crud/custom-route-interceptor.ts +47 -0
  25. package/src/lib/crud/errors.ts +14 -0
  26. package/src/lib/crud/factory.ts +2 -2
  27. package/src/lib/middleware/__tests__/page-executor.test.ts +161 -0
  28. package/src/lib/middleware/page-executor.ts +63 -0
  29. package/src/modules/generators/index.ts +1 -0
  30. package/src/modules/generators/types.ts +29 -0
  31. package/src/modules/middleware/page.ts +52 -0
  32. package/src/modules/widgets/component-registry.ts +15 -1
@@ -0,0 +1,42 @@
1
+ import { CONTINUE_PAGE_MIDDLEWARE, matchPageMiddlewareTarget } from "@open-mercato/shared/modules/middleware/page";
2
+ const DEFAULT_PRIORITY = 100;
3
+ function shouldRunMiddleware(middleware, mode, pathname) {
4
+ if (middleware.mode && middleware.mode !== mode) return false;
5
+ return matchPageMiddlewareTarget(pathname, middleware.target);
6
+ }
7
+ function compareMiddleware(a, b) {
8
+ const priorityDiff = (a.priority ?? DEFAULT_PRIORITY) - (b.priority ?? DEFAULT_PRIORITY);
9
+ if (priorityDiff !== 0) return priorityDiff;
10
+ return a.id.localeCompare(b.id);
11
+ }
12
+ function flattenAndSortMiddleware(entries, mode, pathname) {
13
+ return entries.flatMap((entry) => entry.middleware).filter((middleware) => shouldRunMiddleware(middleware, mode, pathname)).sort(compareMiddleware);
14
+ }
15
+ async function executePageMiddleware(args) {
16
+ const { entries, context, onError } = args;
17
+ const matchedMiddleware = flattenAndSortMiddleware(entries, context.mode, context.pathname);
18
+ for (const middleware of matchedMiddleware) {
19
+ try {
20
+ const result = await middleware.run(context);
21
+ if (result.action === "redirect") return result;
22
+ } catch (error) {
23
+ if (onError) {
24
+ onError(error, { id: middleware.id, priority: middleware.priority });
25
+ } else {
26
+ console.error("[middleware:page] execution failed", { id: middleware.id, error });
27
+ }
28
+ throw error;
29
+ }
30
+ }
31
+ return CONTINUE_PAGE_MIDDLEWARE;
32
+ }
33
+ async function resolvePageMiddlewareRedirect(args) {
34
+ const result = await executePageMiddleware(args);
35
+ if (result.action !== "redirect") return null;
36
+ return result.location;
37
+ }
38
+ export {
39
+ executePageMiddleware,
40
+ resolvePageMiddlewareRedirect
41
+ };
42
+ //# sourceMappingURL=page-executor.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/middleware/page-executor.ts"],
4
+ "sourcesContent": ["import type {\n PageMiddlewareContext,\n PageMiddlewareMode,\n PageMiddlewareRegistryEntry,\n PageMiddlewareResult,\n PageRouteMiddleware,\n} from '@open-mercato/shared/modules/middleware/page'\nimport { CONTINUE_PAGE_MIDDLEWARE, matchPageMiddlewareTarget } from '@open-mercato/shared/modules/middleware/page'\n\ntype ExecutePageMiddlewareArgs = {\n entries: PageMiddlewareRegistryEntry[]\n context: PageMiddlewareContext\n onError?: (error: unknown, middleware: Pick<PageRouteMiddleware, 'id' | 'priority'>) => void\n}\n\nconst DEFAULT_PRIORITY = 100\n\nfunction shouldRunMiddleware(middleware: PageRouteMiddleware, mode: PageMiddlewareMode, pathname: string): boolean {\n if (middleware.mode && middleware.mode !== mode) return false\n return matchPageMiddlewareTarget(pathname, middleware.target)\n}\n\nfunction compareMiddleware(a: PageRouteMiddleware, b: PageRouteMiddleware): number {\n const priorityDiff = (a.priority ?? DEFAULT_PRIORITY) - (b.priority ?? DEFAULT_PRIORITY)\n if (priorityDiff !== 0) return priorityDiff\n return a.id.localeCompare(b.id)\n}\n\nfunction flattenAndSortMiddleware(\n entries: PageMiddlewareRegistryEntry[],\n mode: PageMiddlewareMode,\n pathname: string\n): PageRouteMiddleware[] {\n return entries\n .flatMap((entry) => entry.middleware)\n .filter((middleware) => shouldRunMiddleware(middleware, mode, pathname))\n .sort(compareMiddleware)\n}\n\nexport async function executePageMiddleware(args: ExecutePageMiddlewareArgs): Promise<PageMiddlewareResult> {\n const { entries, context, onError } = args\n const matchedMiddleware = flattenAndSortMiddleware(entries, context.mode, context.pathname)\n for (const middleware of matchedMiddleware) {\n try {\n const result = await middleware.run(context)\n if (result.action === 'redirect') return result\n } catch (error) {\n if (onError) {\n onError(error, { id: middleware.id, priority: middleware.priority })\n } else {\n console.error('[middleware:page] execution failed', { id: middleware.id, error })\n }\n throw error\n }\n }\n return CONTINUE_PAGE_MIDDLEWARE\n}\n\nexport async function resolvePageMiddlewareRedirect(args: ExecutePageMiddlewareArgs): Promise<string | null> {\n const result = await executePageMiddleware(args)\n if (result.action !== 'redirect') return null\n return result.location\n}\n"],
5
+ "mappings": "AAOA,SAAS,0BAA0B,iCAAiC;AAQpE,MAAM,mBAAmB;AAEzB,SAAS,oBAAoB,YAAiC,MAA0B,UAA2B;AACjH,MAAI,WAAW,QAAQ,WAAW,SAAS,KAAM,QAAO;AACxD,SAAO,0BAA0B,UAAU,WAAW,MAAM;AAC9D;AAEA,SAAS,kBAAkB,GAAwB,GAAgC;AACjF,QAAM,gBAAgB,EAAE,YAAY,qBAAqB,EAAE,YAAY;AACvE,MAAI,iBAAiB,EAAG,QAAO;AAC/B,SAAO,EAAE,GAAG,cAAc,EAAE,EAAE;AAChC;AAEA,SAAS,yBACP,SACA,MACA,UACuB;AACvB,SAAO,QACJ,QAAQ,CAAC,UAAU,MAAM,UAAU,EACnC,OAAO,CAAC,eAAe,oBAAoB,YAAY,MAAM,QAAQ,CAAC,EACtE,KAAK,iBAAiB;AAC3B;AAEA,eAAsB,sBAAsB,MAAgE;AAC1G,QAAM,EAAE,SAAS,SAAS,QAAQ,IAAI;AACtC,QAAM,oBAAoB,yBAAyB,SAAS,QAAQ,MAAM,QAAQ,QAAQ;AAC1F,aAAW,cAAc,mBAAmB;AAC1C,QAAI;AACF,YAAM,SAAS,MAAM,WAAW,IAAI,OAAO;AAC3C,UAAI,OAAO,WAAW,WAAY,QAAO;AAAA,IAC3C,SAAS,OAAO;AACd,UAAI,SAAS;AACX,gBAAQ,OAAO,EAAE,IAAI,WAAW,IAAI,UAAU,WAAW,SAAS,CAAC;AAAA,MACrE,OAAO;AACL,gBAAQ,MAAM,sCAAsC,EAAE,IAAI,WAAW,IAAI,MAAM,CAAC;AAAA,MAClF;AACA,YAAM;AAAA,IACR;AAAA,EACF;AACA,SAAO;AACT;AAEA,eAAsB,8BAA8B,MAAyD;AAC3G,QAAM,SAAS,MAAM,sBAAsB,IAAI;AAC/C,MAAI,OAAO,WAAW,WAAY,QAAO;AACzC,SAAO,OAAO;AAChB;",
6
+ "names": []
7
+ }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.4.9-develop-fefbbe0979";
1
+ const APP_VERSION = "0.4.9-develop-db9ecc46fc";
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.9-develop-fefbbe0979'\nexport const appVersion = APP_VERSION\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.4.9-develop-db9ecc46fc'\nexport const appVersion = APP_VERSION\n"],
5
5
  "mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
6
6
  "names": []
7
7
  }
@@ -0,0 +1 @@
1
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": [],
4
+ "sourcesContent": [],
5
+ "mappings": "",
6
+ "names": []
7
+ }
@@ -0,0 +1 @@
1
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": [],
4
+ "sourcesContent": [],
5
+ "mappings": "",
6
+ "names": []
7
+ }
@@ -0,0 +1,15 @@
1
+ function matchPageMiddlewareTarget(pathname, target) {
2
+ if (target instanceof RegExp) {
3
+ return target.test(pathname);
4
+ }
5
+ if (target.endsWith("*")) {
6
+ return pathname.startsWith(target.slice(0, -1));
7
+ }
8
+ return pathname === target;
9
+ }
10
+ const CONTINUE_PAGE_MIDDLEWARE = { action: "continue" };
11
+ export {
12
+ CONTINUE_PAGE_MIDDLEWARE,
13
+ matchPageMiddlewareTarget
14
+ };
15
+ //# sourceMappingURL=page.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/modules/middleware/page.ts"],
4
+ "sourcesContent": ["import type { AuthContext } from '@open-mercato/shared/lib/auth/server'\n\nexport type PageMiddlewareMode = 'frontend' | 'backend'\n\nexport type PageRouteMeta = {\n requireAuth?: boolean\n requireRoles?: string[]\n requireFeatures?: string[]\n}\n\nexport type PageMiddlewareContainer = {\n resolve: (name: string) => unknown\n}\n\nexport type PageMiddlewareContext = {\n pathname: string\n mode: PageMiddlewareMode\n routeMeta: PageRouteMeta\n auth: AuthContext\n ensureContainer: () => Promise<PageMiddlewareContainer>\n}\n\nexport type PageMiddlewareResult =\n | { action: 'continue' }\n | { action: 'redirect'; location: string }\n\nexport type PageMiddlewareTarget = string | RegExp\n\nexport type PageRouteMiddleware = {\n id: string\n mode?: PageMiddlewareMode\n target: PageMiddlewareTarget\n priority?: number\n run: (ctx: PageMiddlewareContext) => Promise<PageMiddlewareResult> | PageMiddlewareResult\n}\n\nexport type PageMiddlewareRegistryEntry = {\n moduleId: string\n middleware: PageRouteMiddleware[]\n}\n\nexport function matchPageMiddlewareTarget(pathname: string, target: PageMiddlewareTarget): boolean {\n if (target instanceof RegExp) {\n return target.test(pathname)\n }\n if (target.endsWith('*')) {\n return pathname.startsWith(target.slice(0, -1))\n }\n return pathname === target\n}\n\nexport const CONTINUE_PAGE_MIDDLEWARE: PageMiddlewareResult = { action: 'continue' }\n"],
5
+ "mappings": "AAyCO,SAAS,0BAA0B,UAAkB,QAAuC;AACjG,MAAI,kBAAkB,QAAQ;AAC5B,WAAO,OAAO,KAAK,QAAQ;AAAA,EAC7B;AACA,MAAI,OAAO,SAAS,GAAG,GAAG;AACxB,WAAO,SAAS,WAAW,OAAO,MAAM,GAAG,EAAE,CAAC;AAAA,EAChD;AACA,SAAO,aAAa;AACtB;AAEO,MAAM,2BAAiD,EAAE,QAAQ,WAAW;",
6
+ "names": []
7
+ }
@@ -1,6 +1,16 @@
1
1
  import * as React from "react";
2
2
  import { hasAllFeatures } from "../../security/features.js";
3
3
  const GLOBAL_COMPONENT_REGISTRY_KEY = "__openMercatoComponentRegistry__";
4
+ function isComponentOverride(value) {
5
+ if (!value || typeof value !== "object") {
6
+ return false;
7
+ }
8
+ const target = value.target;
9
+ if (!target || typeof target !== "object") {
10
+ return false;
11
+ }
12
+ return typeof target.componentId === "string" && target.componentId.length > 0;
13
+ }
4
14
  function getState() {
5
15
  const globalValue = globalThis[GLOBAL_COMPONENT_REGISTRY_KEY];
6
16
  if (globalValue && typeof globalValue === "object") {
@@ -22,7 +32,7 @@ function registerComponent(entry) {
22
32
  }
23
33
  function registerComponentOverrides(overrides) {
24
34
  const state = getState();
25
- state.overrides = [...overrides];
35
+ state.overrides = overrides.filter(isComponentOverride);
26
36
  }
27
37
  function getComponentEntry(componentId) {
28
38
  const state = getState();
@@ -31,6 +41,7 @@ function getComponentEntry(componentId) {
31
41
  function getComponentOverrides(componentId, userFeatures) {
32
42
  const state = getState();
33
43
  const relevant = state.overrides.filter((override) => {
44
+ if (!isComponentOverride(override)) return false;
34
45
  if (override.target.componentId !== componentId) return false;
35
46
  if (override.features && override.features.length > 0) {
36
47
  if (!hasAllFeatures(userFeatures, override.features)) return false;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
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;",
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 isComponentOverride(value: unknown): value is ComponentOverride {\n if (!value || typeof value !== 'object') {\n return false\n }\n\n const target = (value as { target?: { componentId?: unknown } }).target\n if (!target || typeof target !== 'object') {\n return false\n }\n\n return typeof target.componentId === 'string' && target.componentId.length > 0\n}\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.filter(isComponentOverride)\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 (!isComponentOverride(override)) return false\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,oBAAoB,OAA4C;AACvE,MAAI,CAAC,SAAS,OAAO,UAAU,UAAU;AACvC,WAAO;AAAA,EACT;AAEA,QAAM,SAAU,MAAiD;AACjE,MAAI,CAAC,UAAU,OAAO,WAAW,UAAU;AACzC,WAAO;AAAA,EACT;AAEA,SAAO,OAAO,OAAO,gBAAgB,YAAY,OAAO,YAAY,SAAS;AAC/E;AAEA,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,UAAU,OAAO,mBAAmB;AACxD;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,CAAC,oBAAoB,QAAQ,EAAG,QAAO;AAC3C,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
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/shared",
3
- "version": "0.4.9-develop-fefbbe0979",
3
+ "version": "0.4.9-develop-db9ecc46fc",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -39,6 +39,10 @@
39
39
  "types": "./src/lib/bootstrap/dynamicLoader.ts",
40
40
  "default": "./dist/lib/bootstrap/dynamicLoader.js"
41
41
  },
42
+ "./modules/generators": {
43
+ "types": "./src/modules/generators/index.ts",
44
+ "default": "./dist/modules/generators/index.js"
45
+ },
42
46
  "./*.ts": {
43
47
  "types": "./src/*.ts",
44
48
  "default": "./dist/*.js"
@@ -1,4 +1,4 @@
1
- import { cookies } from 'next/headers'
1
+ import { cookies } from 'next/headers.js'
2
2
  import type { EntityManager } from '@mikro-orm/postgresql'
3
3
  import { verifyJwt } from './jwt'
4
4
 
@@ -0,0 +1,180 @@
1
+ import { registerApiInterceptors } from '@open-mercato/shared/lib/crud/interceptor-registry'
2
+ import { runCustomRouteAfterInterceptors } from '@open-mercato/shared/lib/crud/custom-route-interceptor'
3
+ import type { InterceptorContext } from '@open-mercato/shared/lib/crud/api-interceptor'
4
+
5
+ function buildArgs() {
6
+ return {
7
+ routePath: 'auth/login',
8
+ method: 'POST' as const,
9
+ request: {
10
+ method: 'POST' as const,
11
+ url: 'http://localhost/api/auth/login',
12
+ headers: {},
13
+ body: { email: 'user@example.com' },
14
+ },
15
+ response: {
16
+ statusCode: 200,
17
+ body: { ok: true, token: 'token-1', redirect: '/backend' },
18
+ headers: { 'x-test': '1' },
19
+ },
20
+ context: {
21
+ em: {} as InterceptorContext['em'],
22
+ container: { resolve: jest.fn() } as unknown as InterceptorContext['container'],
23
+ },
24
+ }
25
+ }
26
+
27
+ describe('runCustomRouteAfterInterceptors', () => {
28
+ beforeEach(() => {
29
+ registerApiInterceptors([])
30
+ jest.clearAllMocks()
31
+ })
32
+
33
+ test('returns unchanged response when no interceptor matches', async () => {
34
+ const result = await runCustomRouteAfterInterceptors(buildArgs())
35
+
36
+ expect(result).toEqual({
37
+ ok: true,
38
+ statusCode: 200,
39
+ body: { ok: true, token: 'token-1', redirect: '/backend' },
40
+ headers: { 'x-test': '1' },
41
+ })
42
+ })
43
+
44
+ test('supports merge result from matching after interceptor', async () => {
45
+ registerApiInterceptors([
46
+ {
47
+ moduleId: 'example',
48
+ interceptors: [
49
+ {
50
+ id: 'example.auth.login.merge',
51
+ targetRoute: 'auth/login',
52
+ methods: ['POST'],
53
+ async after() {
54
+ return { merge: { mfa_required: true } }
55
+ },
56
+ },
57
+ ],
58
+ },
59
+ ])
60
+
61
+ const result = await runCustomRouteAfterInterceptors(buildArgs())
62
+ expect(result.ok).toBe(true)
63
+ expect(result.body).toEqual({
64
+ ok: true,
65
+ token: 'token-1',
66
+ redirect: '/backend',
67
+ mfa_required: true,
68
+ })
69
+ })
70
+
71
+ test('supports replace result from matching after interceptor', async () => {
72
+ registerApiInterceptors([
73
+ {
74
+ moduleId: 'example',
75
+ interceptors: [
76
+ {
77
+ id: 'example.auth.login.replace',
78
+ targetRoute: 'auth/login',
79
+ methods: ['POST'],
80
+ async after() {
81
+ return { replace: { ok: true, mfa_required: true, challenge_id: 'c-1', token: 'pending' } }
82
+ },
83
+ },
84
+ ],
85
+ },
86
+ ])
87
+
88
+ const result = await runCustomRouteAfterInterceptors(buildArgs())
89
+ expect(result.ok).toBe(true)
90
+ expect(result.body).toEqual({
91
+ ok: true,
92
+ mfa_required: true,
93
+ challenge_id: 'c-1',
94
+ token: 'pending',
95
+ })
96
+ })
97
+
98
+ test('propagates timeout failures from interceptor runner', async () => {
99
+ registerApiInterceptors([
100
+ {
101
+ moduleId: 'example',
102
+ interceptors: [
103
+ {
104
+ id: 'example.auth.login.timeout',
105
+ targetRoute: 'auth/login',
106
+ methods: ['POST'],
107
+ timeoutMs: 5,
108
+ async after() {
109
+ await new Promise((resolve) => setTimeout(resolve, 20))
110
+ return {}
111
+ },
112
+ },
113
+ ],
114
+ },
115
+ ])
116
+
117
+ const result = await runCustomRouteAfterInterceptors(buildArgs())
118
+ expect(result.ok).toBe(false)
119
+ expect(result.statusCode).toBe(504)
120
+ })
121
+
122
+ test('supports unauthenticated execution context defaults', async () => {
123
+ const capturedContexts: Array<{ userId: string; organizationId: string; tenantId: string; userFeatures?: string[] }> = []
124
+ registerApiInterceptors([
125
+ {
126
+ moduleId: 'example',
127
+ interceptors: [
128
+ {
129
+ id: 'example.auth.login.capture-context',
130
+ targetRoute: 'auth/login',
131
+ methods: ['POST'],
132
+ async after(_request, _response, context) {
133
+ capturedContexts.push({
134
+ userId: context.userId,
135
+ organizationId: context.organizationId,
136
+ tenantId: context.tenantId,
137
+ userFeatures: context.userFeatures,
138
+ })
139
+ return {}
140
+ },
141
+ },
142
+ ],
143
+ },
144
+ ])
145
+
146
+ const result = await runCustomRouteAfterInterceptors(buildArgs())
147
+ expect(result.ok).toBe(true)
148
+ expect(capturedContexts).toEqual([
149
+ {
150
+ userId: '',
151
+ organizationId: '',
152
+ tenantId: '',
153
+ userFeatures: [],
154
+ },
155
+ ])
156
+ })
157
+
158
+ test('propagates interceptor exceptions as failed responses', async () => {
159
+ registerApiInterceptors([
160
+ {
161
+ moduleId: 'example',
162
+ interceptors: [
163
+ {
164
+ id: 'example.auth.login.throw',
165
+ targetRoute: 'auth/login',
166
+ methods: ['POST'],
167
+ async after() {
168
+ throw new Error('boom')
169
+ },
170
+ },
171
+ ],
172
+ },
173
+ ])
174
+
175
+ const result = await runCustomRouteAfterInterceptors(buildArgs())
176
+ expect(result.ok).toBe(false)
177
+ expect(result.statusCode).toBe(500)
178
+ expect(result.body.error).toBe('Internal interceptor error')
179
+ })
180
+ })
@@ -0,0 +1,47 @@
1
+ import type { InterceptorContext, InterceptorRequest, InterceptorResponse, ApiInterceptorMethod } from './api-interceptor'
2
+ import { runApiInterceptorsAfter, type RunInterceptorsAfterResult } from './interceptor-runner'
3
+
4
+ export type CustomRouteAfterInterceptorContext = {
5
+ em: InterceptorContext['em']
6
+ container: InterceptorContext['container']
7
+ userId?: string | null
8
+ organizationId?: string | null
9
+ tenantId?: string | null
10
+ userFeatures?: string[]
11
+ extensionHeaders?: InterceptorContext['extensionHeaders']
12
+ }
13
+
14
+ type RunCustomRouteAfterInterceptorsArgs = {
15
+ routePath: string
16
+ method: ApiInterceptorMethod
17
+ request: InterceptorRequest
18
+ response: InterceptorResponse
19
+ context: CustomRouteAfterInterceptorContext
20
+ metadataByInterceptor?: Record<string, Record<string, unknown> | undefined>
21
+ }
22
+
23
+ function normalizeIdentity(value: string | null | undefined): string {
24
+ if (!value) return ''
25
+ return value
26
+ }
27
+
28
+ export async function runCustomRouteAfterInterceptors(
29
+ args: RunCustomRouteAfterInterceptorsArgs,
30
+ ): Promise<RunInterceptorsAfterResult> {
31
+ return runApiInterceptorsAfter({
32
+ routePath: args.routePath,
33
+ method: args.method,
34
+ request: args.request,
35
+ response: args.response,
36
+ context: {
37
+ em: args.context.em,
38
+ container: args.context.container,
39
+ userId: normalizeIdentity(args.context.userId),
40
+ organizationId: normalizeIdentity(args.context.organizationId),
41
+ tenantId: normalizeIdentity(args.context.tenantId),
42
+ userFeatures: args.context.userFeatures ?? [],
43
+ extensionHeaders: args.context.extensionHeaders,
44
+ },
45
+ metadataByInterceptor: args.metadataByInterceptor,
46
+ })
47
+ }
@@ -1,4 +1,9 @@
1
+ // Use Symbol.for so the marker survives module duplication across bundle boundaries
2
+ // (same behaviour as globalThis-based registries used for DI registrars)
3
+ const CRUD_HTTP_ERROR_MARKER = Symbol.for('@open-mercato/CrudHttpError')
4
+
1
5
  export class CrudHttpError extends Error {
6
+ readonly [CRUD_HTTP_ERROR_MARKER] = true
2
7
  status: number
3
8
  body: Record<string, any>
4
9
 
@@ -10,6 +15,15 @@ export class CrudHttpError extends Error {
10
15
  }
11
16
  }
12
17
 
18
+ /**
19
+ * Type-safe check for CrudHttpError that works across module/bundle boundaries.
20
+ * Prefer this over `instanceof CrudHttpError` whenever the error may originate
21
+ * from a different module bundle (e.g. enterprise packages, dynamic imports).
22
+ */
23
+ export function isCrudHttpError(err: unknown): err is CrudHttpError {
24
+ return !!err && typeof err === 'object' && (err as Record<symbol, unknown>)[CRUD_HTTP_ERROR_MARKER] === true
25
+ }
26
+
13
27
  export function badRequest(message: string): CrudHttpError {
14
28
  return new CrudHttpError(400, { error: message })
15
29
  }
@@ -33,7 +33,7 @@ import {
33
33
  loadCustomFieldDefinitionIndex,
34
34
  } from './custom-fields'
35
35
  import { serializeExport, normalizeExportFormat, defaultExportFilename, ensureColumns, type CrudExportFormat, type PreparedExport } from './exporters'
36
- import { CrudHttpError } from './errors'
36
+ import { CrudHttpError, isCrudHttpError } from './errors'
37
37
  import type { CommandBus, CommandLogMetadata } from '@open-mercato/shared/lib/commands'
38
38
  import type { EntityId } from '@open-mercato/shared/modules/entities'
39
39
  import type { EntityManager } from '@mikro-orm/postgresql'
@@ -446,7 +446,7 @@ function attachOperationHeader(res: Response, logEntry: any) {
446
446
 
447
447
  function handleError(err: unknown): Response {
448
448
  if (err instanceof Response) return err
449
- if (err instanceof CrudHttpError) return json(err.body, { status: err.status })
449
+ if (isCrudHttpError(err)) return json(err.body, { status: err.status })
450
450
  if (err instanceof z.ZodError) return json({ error: 'Invalid input', details: err.issues }, { status: 400 })
451
451
 
452
452
  const message = err instanceof Error ? err.message : undefined
@@ -0,0 +1,161 @@
1
+ import {
2
+ executePageMiddleware,
3
+ resolvePageMiddlewareRedirect,
4
+ } from '@open-mercato/shared/lib/middleware/page-executor'
5
+ import {
6
+ CONTINUE_PAGE_MIDDLEWARE,
7
+ matchPageMiddlewareTarget,
8
+ type PageMiddlewareContext,
9
+ type PageMiddlewareRegistryEntry,
10
+ } from '@open-mercato/shared/modules/middleware/page'
11
+
12
+ function buildContext(overrides?: Partial<PageMiddlewareContext>): PageMiddlewareContext {
13
+ return {
14
+ pathname: '/backend/customers/people',
15
+ mode: 'backend',
16
+ routeMeta: { requireAuth: true },
17
+ auth: {
18
+ sub: 'user-1',
19
+ tenantId: 'tenant-1',
20
+ orgId: 'org-1',
21
+ roles: ['admin'],
22
+ },
23
+ ensureContainer: async () => ({ resolve: () => null }),
24
+ ...overrides,
25
+ }
26
+ }
27
+
28
+ describe('matchPageMiddlewareTarget', () => {
29
+ it('matches exact string targets', () => {
30
+ expect(matchPageMiddlewareTarget('/backend', '/backend')).toBe(true)
31
+ expect(matchPageMiddlewareTarget('/backend/customers', '/backend')).toBe(false)
32
+ })
33
+
34
+ it('matches wildcard prefix targets', () => {
35
+ expect(matchPageMiddlewareTarget('/backend/customers', '/backend/*')).toBe(true)
36
+ expect(matchPageMiddlewareTarget('/frontend/home', '/backend/*')).toBe(false)
37
+ })
38
+
39
+ it('matches regexp targets', () => {
40
+ expect(matchPageMiddlewareTarget('/backend/customers', /^\/backend\/.+$/)).toBe(true)
41
+ expect(matchPageMiddlewareTarget('/backend', /^\/backend\/.+$/)).toBe(false)
42
+ })
43
+ })
44
+
45
+ describe('executePageMiddleware', () => {
46
+ it('returns continue when no middleware matches', async () => {
47
+ const entries: PageMiddlewareRegistryEntry[] = [
48
+ {
49
+ moduleId: 'security',
50
+ middleware: [
51
+ {
52
+ id: 'security.frontend',
53
+ mode: 'frontend',
54
+ target: '/frontend/*',
55
+ run: async () => CONTINUE_PAGE_MIDDLEWARE,
56
+ },
57
+ ],
58
+ },
59
+ ]
60
+
61
+ await expect(executePageMiddleware({ entries, context: buildContext() })).resolves.toEqual(
62
+ CONTINUE_PAGE_MIDDLEWARE,
63
+ )
64
+ })
65
+
66
+ it('applies deterministic priority ordering and short-circuits on redirect', async () => {
67
+ const calls: string[] = []
68
+ const entries: PageMiddlewareRegistryEntry[] = [
69
+ {
70
+ moduleId: 'mod-a',
71
+ middleware: [
72
+ {
73
+ id: 'mod-a.first',
74
+ mode: 'backend',
75
+ target: '/backend/*',
76
+ priority: 20,
77
+ run: async () => {
78
+ calls.push('mod-a.first')
79
+ return CONTINUE_PAGE_MIDDLEWARE
80
+ },
81
+ },
82
+ {
83
+ id: 'mod-a.third',
84
+ mode: 'backend',
85
+ target: '/backend/*',
86
+ priority: 30,
87
+ run: async () => {
88
+ calls.push('mod-a.third')
89
+ return CONTINUE_PAGE_MIDDLEWARE
90
+ },
91
+ },
92
+ ],
93
+ },
94
+ {
95
+ moduleId: 'mod-b',
96
+ middleware: [
97
+ {
98
+ id: 'mod-b.second',
99
+ mode: 'backend',
100
+ target: '/backend/*',
101
+ priority: 25,
102
+ run: async () => {
103
+ calls.push('mod-b.second')
104
+ return { action: 'redirect', location: '/backend/profile/security/mfa' } as const
105
+ },
106
+ },
107
+ ],
108
+ },
109
+ ]
110
+
111
+ await expect(
112
+ executePageMiddleware({ entries, context: buildContext() }),
113
+ ).resolves.toEqual({ action: 'redirect', location: '/backend/profile/security/mfa' })
114
+ expect(calls).toEqual(['mod-a.first', 'mod-b.second'])
115
+ })
116
+
117
+ it('throws and emits error callback on middleware failure', async () => {
118
+ const onError = jest.fn()
119
+ const entries: PageMiddlewareRegistryEntry[] = [
120
+ {
121
+ moduleId: 'mod-a',
122
+ middleware: [
123
+ {
124
+ id: 'mod-a.fail',
125
+ mode: 'backend',
126
+ target: '/backend/*',
127
+ run: async () => {
128
+ throw new Error('boom')
129
+ },
130
+ },
131
+ ],
132
+ },
133
+ ]
134
+
135
+ await expect(
136
+ executePageMiddleware({ entries, context: buildContext(), onError }),
137
+ ).rejects.toThrow('boom')
138
+ expect(onError).toHaveBeenCalledWith(expect.any(Error), { id: 'mod-a.fail', priority: undefined })
139
+ })
140
+ })
141
+
142
+ describe('resolvePageMiddlewareRedirect', () => {
143
+ it('returns redirect location for first terminal middleware', async () => {
144
+ const entries: PageMiddlewareRegistryEntry[] = [
145
+ {
146
+ moduleId: 'mod-a',
147
+ middleware: [
148
+ {
149
+ id: 'mod-a.redirect',
150
+ target: '/backend/*',
151
+ run: async () => ({ action: 'redirect', location: '/backend/profile/security/mfa' }),
152
+ },
153
+ ],
154
+ },
155
+ ]
156
+
157
+ await expect(resolvePageMiddlewareRedirect({ entries, context: buildContext() })).resolves.toBe(
158
+ '/backend/profile/security/mfa',
159
+ )
160
+ })
161
+ })