@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.
- package/dist/lib/auth/server.js +1 -1
- package/dist/lib/auth/server.js.map +1 -1
- package/dist/lib/crud/custom-route-interceptor.js +27 -0
- package/dist/lib/crud/custom-route-interceptor.js.map +7 -0
- package/dist/lib/crud/errors.js +8 -1
- package/dist/lib/crud/errors.js.map +2 -2
- package/dist/lib/crud/factory.js +2 -2
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/middleware/page-executor.js +42 -0
- package/dist/lib/middleware/page-executor.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/generators/index.js +1 -0
- package/dist/modules/generators/index.js.map +7 -0
- package/dist/modules/generators/types.js +1 -0
- package/dist/modules/generators/types.js.map +7 -0
- package/dist/modules/middleware/page.js +15 -0
- package/dist/modules/middleware/page.js.map +7 -0
- package/dist/modules/widgets/component-registry.js +12 -1
- package/dist/modules/widgets/component-registry.js.map +2 -2
- package/package.json +5 -1
- package/src/lib/auth/server.ts +1 -1
- package/src/lib/crud/__tests__/custom-route-interceptor.test.ts +180 -0
- package/src/lib/crud/custom-route-interceptor.ts +47 -0
- package/src/lib/crud/errors.ts +14 -0
- package/src/lib/crud/factory.ts +2 -2
- package/src/lib/middleware/__tests__/page-executor.test.ts +161 -0
- package/src/lib/middleware/page-executor.ts +63 -0
- package/src/modules/generators/index.ts +1 -0
- package/src/modules/generators/types.ts +29 -0
- package/src/modules/middleware/page.ts +52 -0
- 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
|
+
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -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-
|
|
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 @@
|
|
|
1
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -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 =
|
|
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 =
|
|
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,
|
|
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-
|
|
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"
|
package/src/lib/auth/server.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/lib/crud/errors.ts
CHANGED
|
@@ -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
|
}
|
package/src/lib/crud/factory.ts
CHANGED
|
@@ -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
|
|
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
|
+
})
|